Skip to content

Commit a26cb46

Browse files
authored
Merge branch 'develop' into fix/adjust-function-nesting
2 parents 50cb8db + a8221cf commit a26cb46

File tree

13 files changed

+421
-150
lines changed

13 files changed

+421
-150
lines changed

.github/workflows/release.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ jobs:
2222
fetch-depth: 0
2323

2424
- name: Setup Python 3.12
25-
uses: actions/setup-python@v5.3.0
25+
uses: actions/setup-python@v5.4.0
2626
with:
2727
python-version: '3.12'
2828
architecture: x64
@@ -75,7 +75,7 @@ jobs:
7575
uses: actions/[email protected]
7676

7777
- name: Setup Python 3.12
78-
uses: actions/setup-python@v5.3.0
78+
uses: actions/setup-python@v5.4.0
7979
with:
8080
python-version: '3.12'
8181
architecture: x64

.github/workflows/test.yml

+3-3
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,14 @@ jobs:
1818
uses: actions/[email protected]
1919

2020
- name: Setup Latest Python
21-
uses: actions/setup-python@v5.3.0
21+
uses: actions/setup-python@v5.4.0
2222
with:
2323
python-version: 3.12
2424
architecture: x64
2525

2626
- name: Setup Poetry
2727
run: |
28-
pip install poetry==1.4.2
28+
pip install poetry
2929
poetry install
3030
3131
- name: Setup Coverage
@@ -56,7 +56,7 @@ jobs:
5656
uses: actions/[email protected]
5757

5858
- name: Setup Python ${{ matrix.python-version }}
59-
uses: actions/setup-python@v5.3.0
59+
uses: actions/setup-python@v5.4.0
6060
with:
6161
python-version: ${{ matrix.python-version }}
6262
architecture: x64

.scripts/requirements.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
requests==2.32.3
2-
semver==3.0.2
2+
semver==3.0.4

README.md

+20-3
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,8 @@ The default values for pseudo parameters:
169169
| **NoValue** | "" |
170170
| **Partition** | "aws" |
171171
| Region | "us-east-1" |
172-
| **StackId** | "" |
173-
| **StackName** | "" |
172+
| StackId | (generated based on other values) |
173+
| StackName | "my-cloud-radar-stack" |
174174
| **URLSuffix** | "amazonaws.com" |
175175
_Note: Bold variables are not fully implemented yet see the [Roadmap](#roadmap)_
176176

@@ -211,6 +211,24 @@ dynamic_references = {
211211
template = Template(template_content, dynamic_references=dynamic_references)
212212
```
213213

214+
There are cases where the default behaviour of our `GetAtt` implementation may not be sufficient and you need a more accurate returned value. When unit testing there are no real AWS resources created, and cloud-radar does not attempt to realistically generate attribute values - a string is always returned. This works good enough most of the time, but there are some cases where if you are attempting to apply intrinsic functions against the attribute value it needs to be more correct. When this occurs, you can add Metadata to the template to provide test values to use.
215+
216+
```
217+
Resources:
218+
MediaPackageV2Channel:
219+
Type: AWS::MediaPackageV2::Channel
220+
Metadata:
221+
Cloud-Radar:
222+
attribute-values:
223+
# Default behaviour of a string is not good enough here, the attribute value is expected to be a List.
224+
IngestEndpointUrls:
225+
- http://one.example.com
226+
- http://two.example.com
227+
Properties:
228+
ChannelGroupName: dev_video_1
229+
ChannelName: !Sub ${AWS::StackName}-MediaPackageChannel
230+
```
231+
214232
A real unit testing example using Pytest can be seen [here](./tests/test_cf/test_examples/test_unit.py)
215233

216234
</details>
@@ -307,7 +325,6 @@ A real functional testing example using Pytest can be seen [here](./tests/test_c
307325
### Unit
308326
- Add full functionality to pseudo variables.
309327
* Variables like `Partition`, `URLSuffix` should change if the region changes.
310-
* Variables like `StackName` and `StackId` should have a better default than ""
311328
- Handle References to resources that shouldn't exist.
312329
* It's currently possible that a `!Ref` to a Resource stays in the final template even if that resource is later removed because of a conditional.
313330

poetry.lock

+186-119
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

+24-16
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,36 @@
1-
[tool.poetry]
1+
[project]
22
name = "cloud-radar"
33
version = "0.0.0"
44
description = "Run functional tests on cloudformation stacks."
55
readme = "README.md"
6-
authors = ["Levi Blaney <[email protected]>"]
6+
authors = [
7+
{ name = "Levi Blaney", email = "[email protected]" },
8+
]
79
license = "Apache-2.0"
8-
repository = "https://github.com/DontShaveTheYak/cloud-radar"
910
keywords = ["aws", "cloudformation", "cloud-radar", "testing", "taskcat", "cloud", "radar"]
11+
requires-python = ">=3.9,<4.0"
12+
dynamic = [ "classifiers" ]
13+
dependencies = [
14+
"taskcat >=0.9.41, <1.0.0",
15+
"cfn-flip >=1.3.0, <2.0.0",
16+
"botocore >=1.35.36, <2.0.0",
17+
]
18+
[project.urls]
19+
Repository = "https://github.com/DontShaveTheYak/cloud-radar"
20+
Issues = "https://github.com/DontShaveTheYak/cloud-radar/issues"
21+
Changelog = "https://github.com/DontShaveTheYak/cloud-radar/releases"
22+
23+
24+
[tool.poetry]
25+
requires-poetry = ">=2.0"
1026
classifiers = [
1127
"Development Status :: 2 - Pre-Alpha",
12-
"License :: OSI Approved :: Apache Software License",
1328
"Operating System :: OS Independent",
14-
"Programming Language :: Python :: 3",
15-
"Programming Language :: Python :: 3.9",
16-
"Programming Language :: Python :: 3.10",
17-
"Programming Language :: Python :: 3.11",
18-
"Programming Language :: Python :: 3.12",
19-
"Programming Language :: Python :: 3.13",
2029
"Topic :: Software Development :: Libraries",
2130
"Topic :: Software Development :: Testing"
2231
]
2332

24-
[tool.poetry.dependencies]
25-
python = ">=3.9,<3.14"
26-
taskcat = "^0.9.41"
27-
cfn-flip = "^1.3.0"
28-
botocore = {version = ">=1.35.36", python = ">=3.13"}
33+
2934

3035
[tool.poetry.group.dev.dependencies]
3136
pytest = "^8.0.0"
@@ -41,9 +46,12 @@ flake8-bugbear = "^24.0.0"
4146
mypy = "^1.0.0"
4247
types-requests = "^2.28.11"
4348
types-PyYAML = "^6.0.12"
44-
cfn-lint = "1.22.3"
49+
cfn-lint = "1.24.0"
4550
setuptools = {version = "75.4.0", python = ">=3.12"}
4651

52+
[tool.poetry.requires-plugins]
53+
poetry-plugin-export = ">=1.8"
54+
4755
[tool.coverage.paths]
4856
source = ["src", "*/site-packages"]
4957

src/cloud_radar/cf/unit/_template.py

+17-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import re
5+
import uuid
56
from pathlib import Path
67
from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, Union
78

@@ -26,8 +27,8 @@ class Template:
2627
NoValue: str = "" # Not yet implemented
2728
Partition: str = "aws" # Other regions not implemented
2829
Region: str = "us-east-1"
29-
StackId: str = "" # Not yet implemented
30-
StackName: str = "" # Not yet implemented
30+
StackId: str = "" # If left blank this will be generated
31+
StackName: str = "my-cloud-radar-stack"
3132
URLSuffix: str = "amazonaws.com" # Other regions not implemented
3233

3334
def __init__(
@@ -310,6 +311,19 @@ def remove_condtional_resources(self, template: Dict[str, Any]) -> Dict[str, Any
310311

311312
return template
312313

314+
# If the StackId variable is not set, generate a value for it
315+
def _get_populated_stack_id(self) -> str:
316+
if not Template.StackId:
317+
# Not explicitly set, generate a value
318+
unique_uuid = uuid.uuid4()
319+
320+
return (
321+
f"arn:{Template.Partition}:cloudformation:{self.Region}:"
322+
f"{Template.AccountId}:stack/{Template.StackName}/{unique_uuid}"
323+
)
324+
325+
return Template.StackId
326+
313327
def create_stack(
314328
self,
315329
params: Optional[Dict[str, str]] = None,
@@ -318,6 +332,7 @@ def create_stack(
318332
):
319333
if region:
320334
self.Region = region
335+
self.StackId = self._get_populated_stack_id()
321336

322337
self.render(params, parameters_file=parameters_file)
323338

src/cloud_radar/cf/unit/functions.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,18 @@ def get_att(template: "Template", values: Any) -> str:
455455
if resource_name not in template.template["Resources"]:
456456
raise KeyError(f"Fn::GetAtt - Resource {resource_name} not found in template.")
457457

458-
return f"{resource_name}.{att_name}"
458+
# Get the resource definition
459+
resource = template.template["Resources"][resource_name]
460+
461+
# Check if there is a value in the resource Metadata for this attribute.
462+
# If the attribute requested is in the metadata, return it.
463+
# Otherwise use the string value of "{resource_name}.{att_name}"
464+
465+
metadata = resource.get("Metadata", {})
466+
cloud_radar_metadata = metadata.get("Cloud-Radar", {})
467+
attribute_values = cloud_radar_metadata.get("attribute-values", {})
468+
469+
return attribute_values.get(att_name, f"{resource_name}.{att_name}")
459470

460471

461472
def get_azs(_t: "Template", region: Any) -> List[str]:
+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
AWSTemplateFormatVersion: 2010-09-09
2+
Description: "Basic template to check GetAtt behaviours"
3+
4+
Resources:
5+
MediaPackageV2Channel:
6+
Type: AWS::MediaPackageV2::Channel
7+
Metadata:
8+
Cloud-Radar:
9+
attribute-values:
10+
# When unit testing there are no real AWS resources created, and cloud-radar
11+
# does not attempt to realistically generate attribute values - a string is always
12+
# returned. This works good enough most of the time, but there are some cases where
13+
# if you are attempting to apply intrinsic functions against the attribute value
14+
# it needs to be more correct.
15+
#
16+
# In this case, the attribute value is expected to be a List, not a string.
17+
IngestEndpointUrls:
18+
- http://one.example.com
19+
- http://two.example.com
20+
Properties:
21+
ChannelGroupName: dev_video_1
22+
ChannelName: !Sub ${AWS::StackName}-MediaPackageChannel
23+
24+
Outputs:
25+
ChannelArn:
26+
Description: The ARN of the MediaPackageV2 Channel.
27+
Value: !GetAtt MediaPackageV2Channel.Arn
28+
ChannelCreatedAt:
29+
Description: The creation timestamp of the MediaPackageV2 Channel.
30+
Value: !GetAtt MediaPackageV2Channel.CreatedAt
31+
ChannelIngestEndpointUrl1:
32+
Description: The first IngestEndpointUrl of the MediaPackageV2 Channel.
33+
Value: !Select [0, !GetAtt MediaPackageV2Channel.IngestEndpointUrls]
34+
ChannelIngestEndpointUrl2:
35+
Description: The second IngestEndpointUrl of the MediaPackageV2 Channel.
36+
Value: !Select [1, !GetAtt MediaPackageV2Channel.IngestEndpointUrls]

tests/templates/test_stackid.yaml

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
AWSTemplateFormatVersion: 2010-09-09
2+
Description: "Creates an S3 bucket to store logs."
3+
4+
Resources:
5+
UniqueBucket:
6+
Type: AWS::S3::Bucket
7+
Properties:
8+
BucketName: !Sub
9+
- 'my-test-${stack_region}-${uniqifier}-bucket'
10+
- # AWS::StackId has this format
11+
# arn:aws:cloudformation:us-west-2:123456789012:stack/teststack/51af3dc0-da77-11e4-872e-1234567db123
12+
# Trying to capture the last piece after the '-'
13+
# As stack name could contain "-"s, split on the "/"s first
14+
uniqifier: !Select [ 4, !Split [ "-", !Select [ 2, !Split [ "/", !Ref AWS::StackId ] ] ] ]
15+
# Usually you would refer to AWS:::Region, but trying to test StackId creation works as expected
16+
stack_region: !Select [ 3, !Split [":", !Ref AWS::StackId]]

tests/test_cf/test_e2e/test_stack.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,8 @@ def test_constructor(template_dir, default_params):
2727

2828
assert stack.config.config.project.regions[0] == "us-east-1"
2929

30-
assert (
31-
not stack.config.config.project.parameters
32-
) or stack.config.config.project.parameters == {}
30+
# Assert either empty or None at the start
31+
assert not stack.config.config.project.parameters
3332

3433
stack = Stack(str(template))
3534

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
5+
from cloud_radar.cf.unit._template import Template
6+
7+
"""Tests that the GetAtt function can use attribute values defined in a template."""
8+
9+
10+
@pytest.fixture
11+
def template():
12+
template_path = Path(__file__).parent / "../../templates/test_media_getatt.yaml"
13+
14+
return Template.from_yaml(template_path.resolve(), {})
15+
16+
17+
def test_outputs(template: Template):
18+
stack = template.create_stack()
19+
20+
# These two outputs are expected to use values which came from the metadata override
21+
stack.get_output("ChannelIngestEndpointUrl1").assert_value_is(
22+
"http://one.example.com"
23+
)
24+
stack.get_output("ChannelIngestEndpointUrl2").assert_value_is(
25+
"http://two.example.com"
26+
)
27+
28+
# This attribute will use the default format
29+
stack.get_output("ChannelArn").assert_value_is("MediaPackageV2Channel.Arn")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Test case that verifies that generation of the value for AWS::StackId works as expected
2+
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
from cloud_radar.cf.unit._template import Template
8+
9+
10+
@pytest.fixture
11+
def template():
12+
template_path = Path(__file__).parent / "../../templates/test_stackid.yaml"
13+
14+
return Template.from_yaml(template_path.resolve(), {})
15+
16+
17+
def test_function_populated_var(template):
18+
expected_value = "arn:aws:cloudformation:us-west-2:123456789012:stack/teststack/51af3dc0-da77-11e4-872e-1234567db123"
19+
Template.StackId = expected_value
20+
21+
actual_value = template._get_populated_stack_id()
22+
assert actual_value == expected_value
23+
24+
25+
def test_function_blank_var(template):
26+
Template.StackId = ""
27+
28+
actual_value = template._get_populated_stack_id()
29+
# Check all except the UUID
30+
assert actual_value.startswith(
31+
f"arn:{Template.Partition}:cloudformation:{Template.Region}:{Template.AccountId}:stack/{Template.StackName}/"
32+
)
33+
34+
# Check the UUID part looks UUID like
35+
unique_uuid = actual_value.split("/")[2]
36+
assert 5 == len(unique_uuid.split("-"))
37+
38+
39+
def test_template_blank_var_stack_region(template):
40+
Template.StackId = ""
41+
42+
stack = template.create_stack({}, region="eu-west-1")
43+
44+
bucket = stack.get_resource("UniqueBucket")
45+
bucket_name = bucket.get_property_value("BucketName")
46+
47+
assert len(bucket_name) == 37
48+
assert bucket_name[:18] == "my-test-eu-west-1-"
49+
assert bucket_name[30:] == "-bucket"
50+
51+
52+
def test_template_blank_var_global_region(template):
53+
Template.StackId = ""
54+
55+
stack = template.create_stack({})
56+
57+
bucket = stack.get_resource("UniqueBucket")
58+
bucket_name = bucket.get_property_value("BucketName")
59+
60+
assert len(bucket_name) == 37
61+
assert bucket_name[:18] == "my-test-us-east-1-"
62+
assert bucket_name[30:] == "-bucket"
63+
64+
65+
def test_template_populated_var(template):
66+
Template.StackId = "arn:aws:cloudformation:us-west-2:123456789012:stack/teststack/51af3dc0-da77-11e4-872e-1234567db123"
67+
68+
stack = template.create_stack({})
69+
70+
bucket = stack.get_resource("UniqueBucket")
71+
bucket_name = bucket.get_property_value("BucketName")
72+
73+
assert "my-test-us-west-2-1234567db123-bucket" == bucket_name

0 commit comments

Comments
 (0)