diff --git a/README.md b/README.md index 11f8410c..293d7aba 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,39 @@ Configure AWS CLI with your credentials as shown here - https://docs.aws.amazon. `gdk component publish` +6. Deploy the component to a target thing or thing group. + +First, add the deploy configuration to your `gdk-config.json`: +```json +{ + "component": { + "com.example.PythonHelloWorld": { + "author": "J. Doe", + "version": "NEXT_PATCH", + "build": { + "build_system": "zip" + }, + "publish": { + "bucket": "my-s3-bucket", + "region": "us-east-1" + }, + "deploy": { + "target_arn": "arn:aws:iot:us-east-1:123456789012:thing/MyGreengrassCore" + } + } + }, + "gdk_version": "1.0.0" +} +``` + +Then run: + +`gdk component deploy` + +Or specify the target directly via CLI: + +`gdk component deploy --target-arn arn:aws:iot:us-east-1:123456789012:thinggroup/MyGroup` +
diff --git a/gdk/aws_clients/Greengrassv2Client.py b/gdk/aws_clients/Greengrassv2Client.py index 0aeb895e..b408bb24 100644 --- a/gdk/aws_clients/Greengrassv2Client.py +++ b/gdk/aws_clients/Greengrassv2Client.py @@ -47,3 +47,54 @@ def create_gg_component(self, file_path) -> None: except Exception: logging.error("Failed to create a private version of the component using the recipe at '%s'.", file_path) raise + + def create_deployment( + self, + target_arn: str, + component_name: str, + component_version: str, + deployment_name: str = None, + deployment_policies: dict = None, + ) -> dict: + """ + Create a Greengrass deployment to the specified target. + + Returns CreateDeployment API response containing deploymentId. + """ + params = { + "targetArn": target_arn, + "components": { + component_name: {"componentVersion": component_version} + }, + } + + if deployment_name: + params["deploymentName"] = deployment_name + + if deployment_policies: + params["deploymentPolicies"] = deployment_policies + + try: + response = self.client.create_deployment(**params) + logging.debug("CreateDeployment response: %s", response) + return response + except Exception: + logging.error( + "Failed to create deployment for component '%s' to target '%s'.", + component_name, + target_arn, + ) + raise + + def component_version_exists(self, component_version_arn: str) -> bool: + """ + Check if a specific component version exists in the account. + """ + try: + self.client.get_component(arn=component_version_arn) + return True + except self.client.exceptions.ResourceNotFoundException: + return False + except Exception: + logging.error("Error checking component version: %s", component_version_arn) + raise diff --git a/gdk/commands/component/DeployCommand.py b/gdk/commands/component/DeployCommand.py new file mode 100644 index 00000000..84b905d0 --- /dev/null +++ b/gdk/commands/component/DeployCommand.py @@ -0,0 +1,95 @@ +import logging +from botocore.exceptions import ClientError +from gdk.commands.Command import Command +from gdk.commands.component.config.ComponentDeployConfiguration import ComponentDeployConfiguration +from gdk.aws_clients.Greengrassv2Client import Greengrassv2Client + + +class DeployCommand(Command): + """Command handler for 'gdk component deploy'.""" + + def __init__(self, command_args: dict) -> None: + super().__init__(command_args, "deploy") + self.project_config = ComponentDeployConfiguration(command_args) + self.greengrass_client = Greengrassv2Client(self.project_config.region) + + def run(self) -> None: + """Execute the deployment workflow.""" + try: + logging.info( + "Deploying component '%s' version '%s' to target '%s'", + self.project_config.component_name, + self.project_config.component_version, + self.project_config.target_arn, + ) + + self._validate_component_published() + response = self._create_deployment() + self._log_success(response) + + except ClientError as e: + self._handle_client_error(e) + raise + except ValueError as e: + logging.error("Configuration error: %s", str(e)) + raise + except Exception: + logging.error( + "Failed to deploy component '%s' version '%s' to target '%s'.", + self.project_config.component_name, + self.project_config.component_version, + self.project_config.target_arn, + ) + raise + + def _validate_component_published(self) -> None: + """Verify the component version exists in the account.""" + component_version_arn = self._get_component_version_arn() + if not self.greengrass_client.component_version_exists(component_version_arn): + raise ValueError( + f"Component version '{self.project_config.component_version}' not found. " + "Run 'gdk component publish' first." + ) + logging.debug("Component version verified: %s", component_version_arn) + + def _get_component_version_arn(self) -> str: + """Construct the full component version ARN.""" + config = self.project_config + partition = config._get_aws_partition() + return ( + f"arn:{partition}:greengrass:{config.region}:{config.account_num}:" + f"components:{config.component_name}:versions:{config.component_version}" + ) + + def _create_deployment(self) -> dict: + """Create the deployment and return the response.""" + config = self.project_config + return self.greengrass_client.create_deployment( + target_arn=config.target_arn, + component_name=config.component_name, + component_version=config.component_version, + deployment_name=config.deployment_name, + deployment_policies=config.deployment_policies, + ) + + def _log_success(self, response: dict) -> None: + """Log successful deployment details.""" + deployment_id = response.get("deploymentId", "unknown") + logging.info("Deployment created successfully. Deployment ID: %s", deployment_id) + + def _handle_client_error(self, error: ClientError) -> None: + """Handle AWS API errors with appropriate messages.""" + error_code = error.response.get("Error", {}).get("Code", "") + error_message = error.response.get("Error", {}).get("Message", str(error)) + + if error_code == "ResourceNotFoundException": + logging.error( + "Target '%s' not found. Verify the thing or thing group exists.", + self.project_config.target_arn, + ) + elif error_code == "AccessDeniedException": + logging.error( + "Permission denied. Ensure IAM role has greengrass:CreateDeployment permission." + ) + else: + logging.error("Deployment failed: %s", error_message) diff --git a/gdk/commands/component/component.py b/gdk/commands/component/component.py index 927aef8c..1c063561 100644 --- a/gdk/commands/component/component.py +++ b/gdk/commands/component/component.py @@ -20,3 +20,9 @@ def list(d_args): from gdk.commands.component.ListCommand import ListCommand ListCommand(d_args).run() + + +def deploy(d_args): + from gdk.commands.component.DeployCommand import DeployCommand + + DeployCommand(d_args).run() diff --git a/gdk/commands/component/config/ComponentDeployConfiguration.py b/gdk/commands/component/config/ComponentDeployConfiguration.py new file mode 100644 index 00000000..254cdea8 --- /dev/null +++ b/gdk/commands/component/config/ComponentDeployConfiguration.py @@ -0,0 +1,140 @@ +import re +import logging +from typing import Optional +import boto3 +from gdk.common.config.GDKProject import GDKProject +from gdk.aws_clients.Greengrassv2Client import Greengrassv2Client + + +TARGET_ARN_PATTERN = re.compile( + r"^arn:(aws|aws-cn|aws-us-gov):iot:[a-z0-9-]+:[0-9]+:(thing|thinggroup)/.+$" +) + + +class ComponentDeployConfiguration(GDKProject): + """Configuration handler for the deploy command.""" + + def __init__(self, args: dict) -> None: + super().__init__() + self._args = args + self._deploy_config = self.component_config.get("deploy", {}) + self._publish_config = self.component_config.get("publish", {}) + + self.region = self._get_region() + self.account_num = self._get_account_number() + self.target_arn = self._get_target_arn() + self.deployment_name = self._get_deployment_name() + self.component_version = self._get_component_version() + self.deployment_policies = self._get_deployment_policies() + + def _get_region(self) -> str: + """Get region from publish config (reuse publish region).""" + region = self._publish_config.get("region", "") + if not region: + raise ValueError("Region not configured. Please set region in publish configuration.") + return region + + def _get_account_number(self) -> str: + """Get AWS account number using STS.""" + try: + sts_client = boto3.client("sts") + account_num = sts_client.get_caller_identity().get("Account") + logging.debug("Identified account number as '%s'.", account_num) + return account_num + except Exception: + logging.error("Error while fetching account number from credentials.") + raise + + def _get_target_arn(self) -> str: + """Get target ARN from CLI args or config, validate format.""" + target_arn = self._args.get("target_arn") + if not target_arn: + target_arn = self._deploy_config.get("target_arn") + + if not target_arn: + raise ValueError( + "Target ARN is required. Provide --target-arn or set deploy.target_arn in gdk-config.json" + ) + + if not self._validate_target_arn_format(target_arn): + raise ValueError( + f"Invalid target ARN format: {target_arn}. Expected IoT thing or thing group ARN." + ) + + return target_arn + + def _get_deployment_name(self) -> Optional[str]: + """Get optional deployment name from CLI args or config.""" + deployment_name = self._args.get("deployment_name") + if not deployment_name: + deployment_name = self._deploy_config.get("deployment_name") + return deployment_name + + def _get_component_version(self) -> str: + """Get component version, resolving NEXT_PATCH if needed.""" + version = self._args.get("component_version") + if not version: + version = self.component_config.get("version") + + if not version: + raise ValueError("Component version is required.") + + if version == "NEXT_PATCH": + logging.debug("Resolving NEXT_PATCH to latest published version.") + return self._resolve_latest_version() + + return version + + def _resolve_latest_version(self) -> str: + """Resolve the latest published version of the component.""" + try: + component_arn = self._get_component_arn() + client = Greengrassv2Client(self.region) + latest_version = client.get_highest_cloud_component_version(component_arn) + if not latest_version: + raise ValueError( + f"No published version found for component '{self.component_name}'. " + "Run 'gdk component publish' first." + ) + logging.info("Resolved latest version: %s", latest_version) + return latest_version + except Exception as e: + logging.error("Failed to resolve latest component version: %s", e) + raise + + def _get_component_arn(self) -> str: + """Construct the component ARN.""" + partition = self._get_aws_partition() + return f"arn:{partition}:greengrass:{self.region}:{self.account_num}:components:{self.component_name}" + + def _get_aws_partition(self) -> str: + """Get AWS partition for the region.""" + session = boto3.Session() + return session.get_partition_for_region(region_name=self.region) + + def _get_deployment_policies(self) -> Optional[dict]: + """Get deployment policies from config if present.""" + policies = self._deploy_config.get("deployment_policies") + if not policies: + return None + + api_policies = {} + + if "failure_handling_policy" in policies: + api_policies["failureHandlingPolicy"] = policies["failure_handling_policy"] + + if "component_update_policy" in policies: + update_policy = policies["component_update_policy"] + api_update_policy = {} + if "timeout_in_seconds" in update_policy: + api_update_policy["timeoutInSeconds"] = update_policy["timeout_in_seconds"] + if "action" in update_policy: + api_update_policy["action"] = update_policy["action"] + if api_update_policy: + api_policies["componentUpdatePolicy"] = api_update_policy + + return api_policies if api_policies else None + + def _validate_target_arn_format(self, arn: str) -> bool: + """Validate ARN matches IoT thing or thing group pattern.""" + return bool(TARGET_ARN_PATTERN.match(arn)) diff --git a/gdk/commands/methods.py b/gdk/commands/methods.py index ea3bca07..34f99a22 100644 --- a/gdk/commands/methods.py +++ b/gdk/commands/methods.py @@ -25,6 +25,10 @@ def _gdk_component_list(d_args): component.list(d_args) +def _gdk_component_deploy(d_args): + component.deploy(d_args) + + def _gdk_config_update(d_args): config.update(d_args) diff --git a/gdk/static/cli_model.json b/gdk/static/cli_model.json index 2c794bf9..ab7ab59d 100644 --- a/gdk/static/cli_model.json +++ b/gdk/static/cli_model.json @@ -122,6 +122,32 @@ "repository" ] ] + }, + "deploy": { + "help": "Deploy a GreengrassV2 component to a target core device or thing group.", + "arguments": { + "target_arn": { + "name": [ + "-t", + "--target-arn" + ], + "help": "ARN of the target IoT thing or thing group. Overrides gdk-config.json." + }, + "deployment_name": { + "name": [ + "-n", + "--deployment-name" + ], + "help": "Optional name for the deployment." + }, + "component_version": { + "name": [ + "-cv", + "--component-version" + ], + "help": "Component version to deploy. Defaults to version in gdk-config.json." + } + } } }, "help": "Initialize, build and publish GreengrassV2 components using this command." diff --git a/gdk/static/config_schema.json b/gdk/static/config_schema.json index a718266d..ba40b497 100644 --- a/gdk/static/config_schema.json +++ b/gdk/static/config_schema.json @@ -138,6 +138,53 @@ "required": [ "bucket" ] + }, + "deploy": { + "type": "object", + "description": "Configuration used with the deploy command of the cli.", + "properties": { + "target_arn": { + "description": "ARN of the IoT thing or thing group to deploy to.", + "type": "string", + "pattern": "^arn:(aws|aws-cn|aws-us-gov):iot:[a-z0-9-]+:[0-9]+:(thing|thinggroup)/.+" + }, + "deployment_name": { + "description": "Optional name for the deployment.", + "type": "string" + }, + "deployment_policies": { + "type": "object", + "description": "Deployment policies for failure handling and component updates.", + "properties": { + "failure_handling_policy": { + "type": "string", + "enum": [ + "ROLLBACK", + "DO_NOTHING" + ] + }, + "component_update_policy": { + "type": "object", + "properties": { + "timeout_in_seconds": { + "type": "integer", + "minimum": 1 + }, + "action": { + "type": "string", + "enum": [ + "NOTIFY_COMPONENTS", + "SKIP_NOTIFY_COMPONENTS" + ] + } + } + } + } + } + }, + "required": [ + "target_arn" + ] } }, "required": [ diff --git a/integration_tests/gdk/components/test_integ_DeployCommand.py b/integration_tests/gdk/components/test_integ_DeployCommand.py new file mode 100644 index 00000000..efd84467 --- /dev/null +++ b/integration_tests/gdk/components/test_integ_DeployCommand.py @@ -0,0 +1,51 @@ +"""Integration tests for the deploy command CLI dispatch.""" +import gdk.CLIParser as CLIParser +import gdk.common.parse_args_actions as parse_args_actions + + +class TestDeployCommandCLI: + """Integration tests for gdk component deploy CLI.""" + + def test_deploy_command_dispatch(self, mocker): + """Test that gdk component deploy dispatches to deploy function.""" + mock_deploy = mocker.patch("gdk.commands.component.component.deploy", return_value=None) + parse_args_actions.run_command(CLIParser.cli_parser.parse_args(["component", "deploy", "-d"])) + assert mock_deploy.called + + def test_deploy_argument_parsing_target_arn(self, mocker): + """Test that --target-arn argument is parsed correctly.""" + mocker.patch("gdk.commands.component.component.deploy", return_value=None) + args = CLIParser.cli_parser.parse_args([ + "component", "deploy", + "--target-arn", "arn:aws:iot:us-west-2:123456789012:thing/MyThing" + ]) + assert args.target_arn == "arn:aws:iot:us-west-2:123456789012:thing/MyThing" + + def test_deploy_argument_parsing_deployment_name(self, mocker): + """Test that --deployment-name argument is parsed correctly.""" + mocker.patch("gdk.commands.component.component.deploy", return_value=None) + args = CLIParser.cli_parser.parse_args([ + "component", "deploy", + "--deployment-name", "TestDeployment" + ]) + assert args.deployment_name == "TestDeployment" + + def test_deploy_argument_parsing_component_version(self, mocker): + """Test that --component-version argument is parsed correctly.""" + mocker.patch("gdk.commands.component.component.deploy", return_value=None) + args = CLIParser.cli_parser.parse_args([ + "component", "deploy", + "--component-version", "1.0.0" + ]) + assert args.component_version == "1.0.0" + + def test_deploy_short_args(self, mocker): + """Test that short argument forms work.""" + mocker.patch("gdk.commands.component.component.deploy", return_value=None) + args = CLIParser.cli_parser.parse_args([ + "component", "deploy", + "-t", "arn:aws:iot:us-west-2:123456789012:thinggroup/MyGroup", + "-n", "ShortNameDeploy" + ]) + assert args.target_arn == "arn:aws:iot:us-west-2:123456789012:thinggroup/MyGroup" + assert args.deployment_name == "ShortNameDeploy" diff --git a/tests/gdk/aws_clients/test_Greengrassv2Client.py b/tests/gdk/aws_clients/test_Greengrassv2Client.py index 47cc6ed9..b6800982 100644 --- a/tests/gdk/aws_clients/test_Greengrassv2Client.py +++ b/tests/gdk/aws_clients/test_Greengrassv2Client.py @@ -76,3 +76,110 @@ def test_create_gg_component_exception(self): assert "An error occurred (400) when calling the CreateComponentVersion operation" in e.value.args[0] self.mock_ggv2_client.assert_no_pending_responses() + + def test_create_deployment_success(self): + """Test successful deployment creation.""" + ggv2 = Greengrassv2Client("region") + response = {"deploymentId": "deploy-123", "iotJobId": "job-123"} + expected_params = { + "targetArn": "arn:aws:iot:us-west-2:123456789012:thing/MyThing", + "components": {"MyComponent": {"componentVersion": "1.0.0"}} + } + self.mock_ggv2_client.add_response("create_deployment", response, expected_params) + + result = ggv2.create_deployment( + target_arn="arn:aws:iot:us-west-2:123456789012:thing/MyThing", + component_name="MyComponent", + component_version="1.0.0" + ) + + assert result["deploymentId"] == "deploy-123" + self.mock_ggv2_client.assert_no_pending_responses() + + def test_create_deployment_with_name(self): + """Test deployment creation with deployment name.""" + ggv2 = Greengrassv2Client("region") + response = {"deploymentId": "deploy-456"} + expected_params = { + "targetArn": "arn:aws:iot:us-west-2:123456789012:thinggroup/MyGroup", + "components": {"MyComponent": {"componentVersion": "2.0.0"}}, + "deploymentName": "TestDeployment" + } + self.mock_ggv2_client.add_response("create_deployment", response, expected_params) + + result = ggv2.create_deployment( + target_arn="arn:aws:iot:us-west-2:123456789012:thinggroup/MyGroup", + component_name="MyComponent", + component_version="2.0.0", + deployment_name="TestDeployment" + ) + + assert result["deploymentId"] == "deploy-456" + self.mock_ggv2_client.assert_no_pending_responses() + + def test_create_deployment_with_policies(self): + """Test deployment creation with deployment policies.""" + ggv2 = Greengrassv2Client("region") + response = {"deploymentId": "deploy-789"} + policies = { + "failureHandlingPolicy": "ROLLBACK", + "componentUpdatePolicy": {"timeoutInSeconds": 60} + } + expected_params = { + "targetArn": "arn:aws:iot:us-west-2:123456789012:thing/MyThing", + "components": {"MyComponent": {"componentVersion": "1.0.0"}}, + "deploymentPolicies": policies + } + self.mock_ggv2_client.add_response("create_deployment", response, expected_params) + + result = ggv2.create_deployment( + target_arn="arn:aws:iot:us-west-2:123456789012:thing/MyThing", + component_name="MyComponent", + component_version="1.0.0", + deployment_policies=policies + ) + + assert result["deploymentId"] == "deploy-789" + self.mock_ggv2_client.assert_no_pending_responses() + + def test_create_deployment_error(self): + """Test deployment creation error handling.""" + ggv2 = Greengrassv2Client("region") + self.mock_ggv2_client.add_client_error( + "create_deployment", + service_error_code="ResourceNotFoundException" + ) + + with pytest.raises(Exception) as e: + ggv2.create_deployment( + target_arn="arn:aws:iot:us-west-2:123456789012:thing/NonExistent", + component_name="MyComponent", + component_version="1.0.0" + ) + assert "ResourceNotFoundException" in str(e.value) + + def test_component_version_exists_true(self): + """Test component version exists returns True.""" + ggv2 = Greengrassv2Client("region") + component_arn = "arn:aws:greengrass:us-west-2:123456789012:components:MyComponent:versions:1.0.0" + self.mock_ggv2_client.add_response( + "get_component", + {"recipeOutputFormat": "JSON", "recipe": b'{}', "tags": {}}, + {"arn": component_arn} + ) + + result = ggv2.component_version_exists(component_arn) + assert result is True + self.mock_ggv2_client.assert_no_pending_responses() + + def test_component_version_exists_false(self): + """Test component version exists returns False when not found.""" + ggv2 = Greengrassv2Client("region") + component_arn = "arn:aws:greengrass:us-west-2:123456789012:components:MyComponent:versions:1.0.0" + self.mock_ggv2_client.add_client_error( + "get_component", + service_error_code="ResourceNotFoundException" + ) + + result = ggv2.component_version_exists(component_arn) + assert result is False diff --git a/tests/gdk/aws_clients/test_Greengrassv2Client_properties.py b/tests/gdk/aws_clients/test_Greengrassv2Client_properties.py new file mode 100644 index 00000000..c6b42e8e --- /dev/null +++ b/tests/gdk/aws_clients/test_Greengrassv2Client_properties.py @@ -0,0 +1,158 @@ +"""Property-based tests for Greengrassv2Client. + +These tests validate universal properties across randomly generated inputs. +""" +from unittest import TestCase +from unittest.mock import patch +import pytest +from hypothesis import given, strategies as st, settings +import boto3 +from botocore.stub import Stubber + +from gdk.aws_clients.Greengrassv2Client import Greengrassv2Client + + +# Generators +valid_partition = st.sampled_from(["aws", "aws-cn", "aws-us-gov"]) +valid_region = st.from_regex(r"[a-z]{2}-[a-z]+-[0-9]", fullmatch=True) +valid_account = st.from_regex(r"[0-9]{12}", fullmatch=True) +valid_resource_name = st.text( + min_size=1, max_size=20, + alphabet=st.characters(whitelist_categories=("L", "N"), whitelist_characters="-_.") +).filter(lambda s: len(s) > 0) + +valid_thing_arn = st.builds( + lambda p, r, a, n: f"arn:{p}:iot:{r}:{a}:thing/{n}", + p=valid_partition, r=valid_region, a=valid_account, n=valid_resource_name +) + +valid_thinggroup_arn = st.builds( + lambda p, r, a, n: f"arn:{p}:iot:{r}:{a}:thinggroup/{n}", + p=valid_partition, r=valid_region, a=valid_account, n=valid_resource_name +) + +valid_target_arn = st.one_of(valid_thing_arn, valid_thinggroup_arn) + +valid_component_name = st.text( + min_size=1, max_size=50, + alphabet=st.characters(whitelist_categories=("L", "N"), whitelist_characters=".-_") +).filter(lambda s: len(s) > 0 and s[0].isalpha()) + +valid_version = st.from_regex(r"[0-9]+\.[0-9]+\.[0-9]+", fullmatch=True) + +valid_deployment_name = st.text( + min_size=1, max_size=50, + alphabet=st.characters(whitelist_categories=("L", "N"), whitelist_characters=" -_") +).filter(lambda s: len(s.strip()) > 0) + + +class TestProperty4ApiParameterConstruction(TestCase): + """ + Property 4: API Parameter Construction + + For any valid ComponentDeployConfiguration, the create_deployment method SHALL + produce API parameters containing: + - targetArn equal to the configured target ARN + - components dictionary with the component name as key and version as nested value + - deploymentName if and only if a deployment name is configured + - deploymentPolicies if and only if policies are configured + + **Validates: Requirements 6.1, 7.1** + """ + + @pytest.fixture(autouse=True) + def __inject_fixtures(self, mocker): + self.mocker = mocker + self.client = boto3.client("greengrassv2", region_name="us-west-2") + self.mocker.patch("boto3.client", return_value=self.client) + self.stub = Stubber(self.client) + self.stub.activate() + + @given( + target_arn=valid_target_arn, + component_name=valid_component_name, + component_version=valid_version, + ) + @settings(max_examples=50) + def test_basic_params_always_included(self, target_arn, component_name, component_version): + """targetArn and components should always be in API params.""" + ggv2 = Greengrassv2Client("us-west-2") + + # Capture the actual params sent to the API + captured_params = {} + + def capture_params(**kwargs): + captured_params.update(kwargs) + return {"deploymentId": "test-id"} + + with patch.object(ggv2.client, 'create_deployment', side_effect=capture_params): + ggv2.create_deployment( + target_arn=target_arn, + component_name=component_name, + component_version=component_version, + ) + + assert captured_params["targetArn"] == target_arn + assert component_name in captured_params["components"] + assert captured_params["components"][component_name]["componentVersion"] == component_version + assert "deploymentName" not in captured_params + assert "deploymentPolicies" not in captured_params + + @given( + target_arn=valid_target_arn, + component_name=valid_component_name, + component_version=valid_version, + deployment_name=valid_deployment_name, + ) + @settings(max_examples=50) + def test_deployment_name_included_when_provided( + self, target_arn, component_name, component_version, deployment_name + ): + """deploymentName should be included if and only if provided.""" + ggv2 = Greengrassv2Client("us-west-2") + + captured_params = {} + + def capture_params(**kwargs): + captured_params.update(kwargs) + return {"deploymentId": "test-id"} + + with patch.object(ggv2.client, 'create_deployment', side_effect=capture_params): + ggv2.create_deployment( + target_arn=target_arn, + component_name=component_name, + component_version=component_version, + deployment_name=deployment_name, + ) + + assert captured_params["deploymentName"] == deployment_name + + @given( + target_arn=valid_target_arn, + component_name=valid_component_name, + component_version=valid_version, + failure_policy=st.sampled_from(["ROLLBACK", "DO_NOTHING"]), + ) + @settings(max_examples=50) + def test_deployment_policies_included_when_provided( + self, target_arn, component_name, component_version, failure_policy + ): + """deploymentPolicies should be included if and only if provided.""" + ggv2 = Greengrassv2Client("us-west-2") + policies = {"failureHandlingPolicy": failure_policy} + + captured_params = {} + + def capture_params(**kwargs): + captured_params.update(kwargs) + return {"deploymentId": "test-id"} + + with patch.object(ggv2.client, 'create_deployment', side_effect=capture_params): + ggv2.create_deployment( + target_arn=target_arn, + component_name=component_name, + component_version=component_version, + deployment_policies=policies, + ) + + assert captured_params["deploymentPolicies"] == policies diff --git a/tests/gdk/commands/component/config/test_ComponentDeployConfiguration.py b/tests/gdk/commands/component/config/test_ComponentDeployConfiguration.py new file mode 100644 index 00000000..1c243ca7 --- /dev/null +++ b/tests/gdk/commands/component/config/test_ComponentDeployConfiguration.py @@ -0,0 +1,231 @@ +from pathlib import Path +from unittest import TestCase +from unittest.mock import Mock +import pytest +import boto3 +from botocore.stub import Stubber + +from gdk.commands.component.config.ComponentDeployConfiguration import ( + ComponentDeployConfiguration, + TARGET_ARN_PATTERN, +) +from gdk.common.config.GDKProject import GDKProject + + +class ComponentDeployConfigurationTest(TestCase): + @pytest.fixture(autouse=True) + def __inject_fixtures(self, mocker): + self.mocker = mocker + self.mocker.patch.object( + GDKProject, "_get_recipe_file", + return_value=Path(".").joinpath("recipe.json").resolve() + ) + + self.sts_client = boto3.client("sts", region_name="us-west-2") + self.mocker.patch("boto3.client", return_value=self.sts_client) + self.sts_client_stub = Stubber(self.sts_client) + self.sts_client_stub.activate() + self.sts_client_stub.add_response("get_caller_identity", {"Account": "123456789012"}) + + boto3_ses = Mock() + boto3_ses.get_partition_for_region.return_value = "aws" + self.mocker.patch("boto3.Session", return_value=boto3_ses) + + def test_config_from_file(self): + """Test configuration loaded from gdk-config.json.""" + self.mocker.patch( + "gdk.common.configuration.get_configuration", + return_value=config_full(), + ) + + config = ComponentDeployConfiguration({}) + + assert config.target_arn == "arn:aws:iot:us-west-2:123456789012:thing/MyThing" + assert config.deployment_name == "MyDeployment" + assert config.component_version == "1.0.0" + assert config.region == "us-west-2" + + def test_cli_args_override_config(self): + """Test CLI arguments take precedence over config file.""" + self.mocker.patch( + "gdk.common.configuration.get_configuration", + return_value=config_full(), + ) + + config = ComponentDeployConfiguration({ + "target_arn": "arn:aws:iot:us-east-1:999999999999:thinggroup/CliGroup", + "deployment_name": "CliDeployment", + "component_version": "2.0.0" + }) + + assert config.target_arn == "arn:aws:iot:us-east-1:999999999999:thinggroup/CliGroup" + assert config.deployment_name == "CliDeployment" + assert config.component_version == "2.0.0" + + def test_missing_target_arn_error(self): + """Test error when target ARN is missing.""" + self.mocker.patch( + "gdk.common.configuration.get_configuration", + return_value=config_no_deploy(), + ) + + with pytest.raises(ValueError, match="Target ARN is required"): + ComponentDeployConfiguration({}) + + def test_invalid_target_arn_error(self): + """Test error when target ARN format is invalid.""" + self.mocker.patch( + "gdk.common.configuration.get_configuration", + return_value=config_no_deploy(), + ) + + with pytest.raises(ValueError, match="Invalid target ARN format"): + ComponentDeployConfiguration({ + "target_arn": "not-a-valid-arn" + }) + + def test_deployment_policies_parsing(self): + """Test deployment policies are correctly parsed.""" + self.mocker.patch( + "gdk.common.configuration.get_configuration", + return_value=config_with_policies(), + ) + + config = ComponentDeployConfiguration({}) + + assert config.deployment_policies is not None + assert config.deployment_policies["failureHandlingPolicy"] == "ROLLBACK" + assert config.deployment_policies["componentUpdatePolicy"]["timeoutInSeconds"] == 60 + assert config.deployment_policies["componentUpdatePolicy"]["action"] == "NOTIFY_COMPONENTS" + + def test_no_deployment_policies(self): + """Test when no deployment policies are configured.""" + self.mocker.patch( + "gdk.common.configuration.get_configuration", + return_value=config_full(), + ) + + config = ComponentDeployConfiguration({}) + assert config.deployment_policies is None + + def test_next_patch_version_resolution(self): + """Test NEXT_PATCH version resolution.""" + self.mocker.patch( + "gdk.common.configuration.get_configuration", + return_value=config_next_patch(), + ) + + self.mocker.patch( + "gdk.aws_clients.Greengrassv2Client.Greengrassv2Client.get_highest_cloud_component_version", + return_value="1.2.3" + ) + + config = ComponentDeployConfiguration({}) + assert config.component_version == "1.2.3" + + +class TargetArnValidationTest(TestCase): + """Test target ARN pattern validation.""" + + def test_valid_thing_arn(self): + arn = "arn:aws:iot:us-west-2:123456789012:thing/MyThing" + assert TARGET_ARN_PATTERN.match(arn) is not None + + def test_valid_thinggroup_arn(self): + arn = "arn:aws:iot:us-east-1:123456789012:thinggroup/MyGroup" + assert TARGET_ARN_PATTERN.match(arn) is not None + + def test_valid_china_partition(self): + arn = "arn:aws-cn:iot:cn-north-1:123456789012:thing/MyThing" + assert TARGET_ARN_PATTERN.match(arn) is not None + + def test_valid_govcloud_partition(self): + arn = "arn:aws-us-gov:iot:us-gov-west-1:123456789012:thinggroup/MyGroup" + assert TARGET_ARN_PATTERN.match(arn) is not None + + def test_invalid_service(self): + arn = "arn:aws:s3:us-west-2:123456789012:bucket/MyBucket" + assert TARGET_ARN_PATTERN.match(arn) is None + + def test_invalid_resource_type(self): + arn = "arn:aws:iot:us-west-2:123456789012:certificate/abc123" + assert TARGET_ARN_PATTERN.match(arn) is None + + def test_empty_string(self): + assert TARGET_ARN_PATTERN.match("") is None + + def test_random_string(self): + assert TARGET_ARN_PATTERN.match("not-an-arn") is None + + +def config_full(): + return { + "component": { + "com.example.HelloWorld": { + "author": "TestAuthor", + "version": "1.0.0", + "build": {"build_system": "zip"}, + "publish": {"bucket": "default", "region": "us-west-2"}, + "deploy": { + "target_arn": "arn:aws:iot:us-west-2:123456789012:thing/MyThing", + "deployment_name": "MyDeployment" + } + } + }, + "gdk_version": "1.0.0", + } + + +def config_no_deploy(): + return { + "component": { + "com.example.HelloWorld": { + "author": "TestAuthor", + "version": "1.0.0", + "build": {"build_system": "zip"}, + "publish": {"bucket": "default", "region": "us-west-2"}, + } + }, + "gdk_version": "1.0.0", + } + + +def config_with_policies(): + return { + "component": { + "com.example.HelloWorld": { + "author": "TestAuthor", + "version": "1.0.0", + "build": {"build_system": "zip"}, + "publish": {"bucket": "default", "region": "us-west-2"}, + "deploy": { + "target_arn": "arn:aws:iot:us-west-2:123456789012:thing/MyThing", + "deployment_policies": { + "failure_handling_policy": "ROLLBACK", + "component_update_policy": { + "timeout_in_seconds": 60, + "action": "NOTIFY_COMPONENTS" + } + } + } + } + }, + "gdk_version": "1.0.0", + } + + +def config_next_patch(): + return { + "component": { + "com.example.HelloWorld": { + "author": "TestAuthor", + "version": "NEXT_PATCH", + "build": {"build_system": "zip"}, + "publish": {"bucket": "default", "region": "us-west-2"}, + "deploy": { + "target_arn": "arn:aws:iot:us-west-2:123456789012:thing/MyThing" + } + } + }, + "gdk_version": "1.0.0", + } diff --git a/tests/gdk/commands/component/config/test_ComponentDeployConfiguration_properties.py b/tests/gdk/commands/component/config/test_ComponentDeployConfiguration_properties.py new file mode 100644 index 00000000..ebdffbeb --- /dev/null +++ b/tests/gdk/commands/component/config/test_ComponentDeployConfiguration_properties.py @@ -0,0 +1,191 @@ +"""Property-based tests for ComponentDeployConfiguration. + +These tests validate universal properties across randomly generated inputs. +""" +from pathlib import Path +from unittest import TestCase +from unittest.mock import Mock +import pytest +from hypothesis import given, strategies as st, settings +import boto3 +from botocore.stub import Stubber + +from gdk.commands.component.config.ComponentDeployConfiguration import ( + ComponentDeployConfiguration, + TARGET_ARN_PATTERN, +) +from gdk.common.config.GDKProject import GDKProject + + +# Generators for valid ARNs +valid_partition = st.sampled_from(["aws", "aws-cn", "aws-us-gov"]) +valid_region = st.from_regex(r"[a-z]{2}-[a-z]+-[0-9]", fullmatch=True) +valid_account = st.from_regex(r"[0-9]{12}", fullmatch=True) +valid_resource_name = st.text( + min_size=1, max_size=20, + alphabet=st.characters(whitelist_categories=("L", "N"), whitelist_characters="-_") +).filter(lambda s: len(s) > 0) + +valid_thing_arn = st.builds( + lambda p, r, a, n: f"arn:{p}:iot:{r}:{a}:thing/{n}", + p=valid_partition, r=valid_region, a=valid_account, n=valid_resource_name +) + +valid_thinggroup_arn = st.builds( + lambda p, r, a, n: f"arn:{p}:iot:{r}:{a}:thinggroup/{n}", + p=valid_partition, r=valid_region, a=valid_account, n=valid_resource_name +) + +valid_target_arn = st.one_of(valid_thing_arn, valid_thinggroup_arn) + + +class PropertyTestBase(TestCase): + """Base class for property tests with common setup.""" + + @pytest.fixture(autouse=True) + def __inject_fixtures(self, mocker): + self.mocker = mocker + self.mocker.patch.object( + GDKProject, "_get_recipe_file", + return_value=Path(".").joinpath("recipe.json").resolve() + ) + + self.sts_client = boto3.client("sts", region_name="us-west-2") + self.mocker.patch("boto3.client", return_value=self.sts_client) + self.sts_client_stub = Stubber(self.sts_client) + self.sts_client_stub.activate() + self.sts_client_stub.add_response("get_caller_identity", {"Account": "123456789012"}) + + boto3_ses = Mock() + boto3_ses.get_partition_for_region.return_value = "aws" + self.mocker.patch("boto3.Session", return_value=boto3_ses) + + +class TestProperty2TargetArnValidation(TestCase): + """ + Property 2: Target ARN Validation + + For any string input, the validate_target_arn function SHALL return True + if and only if the string matches the IoT thing or thing group ARN pattern. + + **Validates: Requirements 2.4** + """ + + @given(arn=valid_thing_arn) + @settings(max_examples=100) + def test_valid_thing_arns_pass_validation(self, arn): + """For any valid IoT thing ARN, validation should return True.""" + assert TARGET_ARN_PATTERN.match(arn) is not None + + @given(arn=valid_thinggroup_arn) + @settings(max_examples=100) + def test_valid_thinggroup_arns_pass_validation(self, arn): + """For any valid IoT thing group ARN, validation should return True.""" + assert TARGET_ARN_PATTERN.match(arn) is not None + + @given(invalid_string=st.text(max_size=100).filter( + lambda s: not TARGET_ARN_PATTERN.match(s) + )) + @settings(max_examples=100) + def test_invalid_strings_fail_validation(self, invalid_string): + """For any string not matching the ARN pattern, validation should return False.""" + assert TARGET_ARN_PATTERN.match(invalid_string) is None + + +class TestProperty1CliArgumentPrecedence(TestCase): + """ + Property 1: CLI Argument Precedence + + For any CLI argument (target-arn, component-version, deployment-name) and any + corresponding config file value, when the CLI argument is provided with a non-None + value, the configuration object SHALL use the CLI argument value instead of the + config file value. + + **Validates: Requirements 2.1, 3.1, 4.1** + """ + + @pytest.fixture(autouse=True) + def __inject_fixtures(self, mocker): + self.mocker = mocker + self.mocker.patch.object( + GDKProject, "_get_recipe_file", + return_value=Path(".").joinpath("recipe.json").resolve() + ) + + # Mock boto3.client to return a mock that always works + mock_sts = Mock() + mock_sts.get_caller_identity.return_value = {"Account": "123456789012"} + self.mocker.patch("boto3.client", return_value=mock_sts) + + boto3_ses = Mock() + boto3_ses.get_partition_for_region.return_value = "aws" + self.mocker.patch("boto3.Session", return_value=boto3_ses) + + @given( + cli_arn=valid_target_arn, + config_arn=valid_target_arn, + ) + @settings(max_examples=50, deadline=None) + def test_cli_target_arn_takes_precedence(self, cli_arn, config_arn): + """CLI target_arn should override config file target_arn.""" + self.mocker.patch( + "gdk.common.configuration.get_configuration", + return_value=make_config(target_arn=config_arn), + ) + + config = ComponentDeployConfiguration({"target_arn": cli_arn}) + assert config.target_arn == cli_arn + + @given( + cli_name=st.text(min_size=1, max_size=50, alphabet=st.characters(whitelist_categories=("L", "N"))), + config_name=st.text(min_size=1, max_size=50, alphabet=st.characters(whitelist_categories=("L", "N"))), + ) + @settings(max_examples=50, deadline=None) + def test_cli_deployment_name_takes_precedence(self, cli_name, config_name): + """CLI deployment_name should override config file deployment_name.""" + self.mocker.patch( + "gdk.common.configuration.get_configuration", + return_value=make_config(deployment_name=config_name), + ) + + config = ComponentDeployConfiguration({"deployment_name": cli_name}) + assert config.deployment_name == cli_name + + @given( + cli_version=st.from_regex(r"[0-9]+\.[0-9]+\.[0-9]+", fullmatch=True), + config_version=st.from_regex(r"[0-9]+\.[0-9]+\.[0-9]+", fullmatch=True), + ) + @settings(max_examples=50, deadline=None) + def test_cli_component_version_takes_precedence(self, cli_version, config_version): + """CLI component_version should override config file version.""" + self.mocker.patch( + "gdk.common.configuration.get_configuration", + return_value=make_config(version=config_version), + ) + + config = ComponentDeployConfiguration({"component_version": cli_version}) + assert config.component_version == cli_version + + +def make_config( + target_arn="arn:aws:iot:us-west-2:123456789012:thing/MyThing", + deployment_name=None, + version="1.0.0" +): + """Helper to create test configuration.""" + deploy_config = {"target_arn": target_arn} + if deployment_name: + deploy_config["deployment_name"] = deployment_name + + return { + "component": { + "com.example.HelloWorld": { + "author": "TestAuthor", + "version": version, + "build": {"build_system": "zip"}, + "publish": {"bucket": "default", "region": "us-west-2"}, + "deploy": deploy_config + } + }, + "gdk_version": "1.0.0", + } diff --git a/tests/gdk/commands/component/test_DeployCommand.py b/tests/gdk/commands/component/test_DeployCommand.py new file mode 100644 index 00000000..59baa98d --- /dev/null +++ b/tests/gdk/commands/component/test_DeployCommand.py @@ -0,0 +1,211 @@ +from pathlib import Path +from unittest import TestCase +from unittest.mock import Mock +import pytest +import boto3 +from botocore.stub import Stubber +from botocore.exceptions import ClientError + +from gdk.commands.component.DeployCommand import DeployCommand +from gdk.common.config.GDKProject import GDKProject + + +class DeployCommandTest(TestCase): + @pytest.fixture(autouse=True) + def __inject_fixtures(self, mocker): + self.mocker = mocker + self.mock_get_proj_config = self.mocker.patch( + "gdk.common.configuration.get_configuration", + return_value=config(), + ) + self.mocker.patch.object( + GDKProject, "_get_recipe_file", + return_value=Path(".").joinpath("recipe.json").resolve() + ) + + self.gg_client = boto3.client("greengrassv2", region_name="us-west-2") + self.sts_client = boto3.client("sts", region_name="us-west-2") + + def _clients(*args, **kwargs): + if args[0] == "greengrassv2": + return self.gg_client + elif args[0] == "sts": + return self.sts_client + + self.mocker.patch("boto3.client", side_effect=_clients) + self.gg_client_stub = Stubber(self.gg_client) + self.sts_client_stub = Stubber(self.sts_client) + self.gg_client_stub.activate() + self.sts_client_stub.activate() + self.sts_client_stub.add_response("get_caller_identity", {"Account": "123456789012"}) + + boto3_ses = Mock() + boto3_ses.get_partition_for_region.return_value = "aws" + self.mocker.patch("boto3.Session", return_value=boto3_ses) + + def test_deploy_success(self): + """Test successful deployment flow.""" + self.gg_client_stub.add_response( + "get_component", + {"recipeOutputFormat": "JSON", "recipe": b'{}', "tags": {}} + ) + self.gg_client_stub.add_response( + "create_deployment", + {"deploymentId": "deploy-123", "iotJobId": "job-123"} + ) + + deploy = DeployCommand({}) + deploy.run() + + self.gg_client_stub.assert_no_pending_responses() + + def test_deploy_with_cli_args(self): + """Test deployment with CLI argument overrides.""" + self.gg_client_stub.add_response( + "get_component", + {"recipeOutputFormat": "JSON", "recipe": b'{}', "tags": {}} + ) + self.gg_client_stub.add_response( + "create_deployment", + {"deploymentId": "deploy-456"} + ) + + deploy = DeployCommand({ + "target_arn": "arn:aws:iot:us-west-2:123456789012:thinggroup/MyGroup", + "deployment_name": "TestDeployment", + "component_version": "2.0.0" + }) + deploy.run() + + assert deploy.project_config.target_arn == "arn:aws:iot:us-west-2:123456789012:thinggroup/MyGroup" + assert deploy.project_config.deployment_name == "TestDeployment" + assert deploy.project_config.component_version == "2.0.0" + + def test_deploy_component_not_published(self): + """Test error when component version doesn't exist.""" + self.gg_client_stub.add_client_error( + "get_component", + service_error_code="ResourceNotFoundException", + service_message="Component not found" + ) + + deploy = DeployCommand({}) + with pytest.raises(ValueError, match="not found"): + deploy.run() + + def test_deploy_target_not_found(self): + """Test error when target doesn't exist.""" + self.gg_client_stub.add_response( + "get_component", + {"recipeOutputFormat": "JSON", "recipe": b'{}', "tags": {}} + ) + self.gg_client_stub.add_client_error( + "create_deployment", + service_error_code="ResourceNotFoundException", + service_message="Target not found" + ) + + deploy = DeployCommand({}) + with pytest.raises(ClientError): + deploy.run() + + def test_deploy_permission_denied(self): + """Test error when permission is denied.""" + self.gg_client_stub.add_response( + "get_component", + {"recipeOutputFormat": "JSON", "recipe": b'{}', "tags": {}} + ) + self.gg_client_stub.add_client_error( + "create_deployment", + service_error_code="AccessDeniedException", + service_message="Access denied" + ) + + deploy = DeployCommand({}) + with pytest.raises(ClientError): + deploy.run() + + +class DeployCommandMissingConfigTest(TestCase): + @pytest.fixture(autouse=True) + def __inject_fixtures(self, mocker): + self.mocker = mocker + self.mocker.patch.object( + GDKProject, "_get_recipe_file", + return_value=Path(".").joinpath("recipe.json").resolve() + ) + + self.sts_client = boto3.client("sts", region_name="us-west-2") + self.mocker.patch("boto3.client", return_value=self.sts_client) + self.sts_client_stub = Stubber(self.sts_client) + self.sts_client_stub.activate() + self.sts_client_stub.add_response("get_caller_identity", {"Account": "123456789012"}) + + def test_deploy_missing_target_arn(self): + """Test error when target ARN is missing.""" + self.mocker.patch( + "gdk.common.configuration.get_configuration", + return_value=config_no_target(), + ) + + with pytest.raises(ValueError, match="Target ARN is required"): + DeployCommand({}) + + def test_deploy_invalid_target_arn(self): + """Test error when target ARN format is invalid.""" + self.mocker.patch( + "gdk.common.configuration.get_configuration", + return_value=config_invalid_arn(), + ) + + with pytest.raises(ValueError, match="Invalid target ARN format"): + DeployCommand({}) + + +def config(): + return { + "component": { + "com.example.HelloWorld": { + "author": "TestAuthor", + "version": "1.0.0", + "build": {"build_system": "zip"}, + "publish": {"bucket": "default", "region": "us-west-2"}, + "deploy": { + "target_arn": "arn:aws:iot:us-west-2:123456789012:thing/MyThing" + } + } + }, + "gdk_version": "1.0.0", + } + + +def config_no_target(): + return { + "component": { + "com.example.HelloWorld": { + "author": "TestAuthor", + "version": "1.0.0", + "build": {"build_system": "zip"}, + "publish": {"bucket": "default", "region": "us-west-2"}, + "deploy": {} + } + }, + "gdk_version": "1.0.0", + } + + +def config_invalid_arn(): + return { + "component": { + "com.example.HelloWorld": { + "author": "TestAuthor", + "version": "1.0.0", + "build": {"build_system": "zip"}, + "publish": {"bucket": "default", "region": "us-west-2"}, + "deploy": { + "target_arn": "invalid-arn-format" + } + } + }, + "gdk_version": "1.0.0", + } diff --git a/tests/gdk/commands/component/test_DeployCommand_properties.py b/tests/gdk/commands/component/test_DeployCommand_properties.py new file mode 100644 index 00000000..de17e984 --- /dev/null +++ b/tests/gdk/commands/component/test_DeployCommand_properties.py @@ -0,0 +1,104 @@ +"""Property-based tests for DeployCommand. + +These tests validate universal properties across randomly generated inputs. +""" +from pathlib import Path +from unittest import TestCase +from unittest.mock import Mock +import pytest +from hypothesis import given, strategies as st, settings +import boto3 +from botocore.stub import Stubber +from botocore.exceptions import ClientError + +from gdk.commands.component.DeployCommand import DeployCommand +from gdk.common.config.GDKProject import GDKProject + + +class TestProperty6ErrorMessagePreservation(TestCase): + """ + Property 6: Error Message Preservation + + For any AWS API error raised during deployment, the error message logged + by DeployCommand SHALL contain the original AWS error message string. + + **Validates: Requirements 10.4** + """ + + @pytest.fixture(autouse=True) + def __inject_fixtures(self, mocker): + self.mocker = mocker + self.mocker.patch.object( + GDKProject, "_get_recipe_file", + return_value=Path(".").joinpath("recipe.json").resolve() + ) + + self.gg_client = boto3.client("greengrassv2", region_name="us-west-2") + + # Mock STS client + mock_sts = Mock() + mock_sts.get_caller_identity.return_value = {"Account": "123456789012"} + + def _clients(*args, **kwargs): + if args[0] == "greengrassv2": + return self.gg_client + elif args[0] == "sts": + return mock_sts + + self.mocker.patch("boto3.client", side_effect=_clients) + self.gg_client_stub = Stubber(self.gg_client) + self.gg_client_stub.activate() + + boto3_ses = Mock() + boto3_ses.get_partition_for_region.return_value = "aws" + self.mocker.patch("boto3.Session", return_value=boto3_ses) + + self.mocker.patch( + "gdk.common.configuration.get_configuration", + return_value=config(), + ) + + @given( + error_message=st.text(min_size=5, max_size=200, alphabet=st.characters( + whitelist_categories=("L", "N", "P", "S"), + whitelist_characters=" " + )).filter(lambda s: len(s.strip()) > 0) + ) + @settings(max_examples=20, deadline=None) + def test_aws_error_messages_are_preserved_in_logs(self, error_message): + """AWS error messages should be preserved when logged.""" + # Setup: component exists but deployment fails + self.gg_client_stub.add_response( + "get_component", + {"recipeOutputFormat": "JSON", "recipe": b'{}', "tags": {}} + ) + self.gg_client_stub.add_client_error( + "create_deployment", + service_error_code="ValidationException", + service_message=error_message + ) + + deploy = DeployCommand({}) + + with pytest.raises(ClientError) as exc_info: + deploy.run() + + # The original error message should be in the exception + assert error_message in str(exc_info.value) + + +def config(): + return { + "component": { + "com.example.HelloWorld": { + "author": "TestAuthor", + "version": "1.0.0", + "build": {"build_system": "zip"}, + "publish": {"bucket": "default", "region": "us-west-2"}, + "deploy": { + "target_arn": "arn:aws:iot:us-west-2:123456789012:thing/MyThing" + } + } + }, + "gdk_version": "1.0.0", + } diff --git a/uat/component_deploy.feature b/uat/component_deploy.feature new file mode 100644 index 00000000..7313670a --- /dev/null +++ b/uat/component_deploy.feature @@ -0,0 +1,84 @@ +Feature: gdk component deploy works + + @version(min='1.7.0') + @change_cwd + Scenario: deploy published component to thing group + Given we have cli installed + And we make directory helloworld + And we run gdk component init -t HelloWorld -l python + And command was successful + And we verify gdk project files + And change component name to com.example.PythonHelloWorld + And change artifact uri for all platform from com.example.PythonHelloWorld to ${context.last_component} + And we run gdk component build + And command was successful + And we run gdk component publish + And command was successful + And we add deploy config with target arn + When we run gdk component deploy + Then command was successful + + @version(min='1.7.0') + @change_cwd + Scenario: deploy with target arn argument + Given we have cli installed + And we make directory helloworld + And we run gdk component init -t HelloWorld -l python + And command was successful + And we verify gdk project files + And change component name to com.example.PythonHelloWorld + And change artifact uri for all platform from com.example.PythonHelloWorld to ${context.last_component} + And we run gdk component build + And command was successful + And we run gdk component publish + And command was successful + When we run gdk component deploy -t + Then command was successful + + @version(min='1.7.0') + @change_cwd + Scenario: deploy with deployment name + Given we have cli installed + And we make directory helloworld + And we run gdk component init -t HelloWorld -l python + And command was successful + And we verify gdk project files + And change component name to com.example.PythonHelloWorld + And change artifact uri for all platform from com.example.PythonHelloWorld to ${context.last_component} + And we run gdk component build + And command was successful + And we run gdk component publish + And command was successful + And we add deploy config with target arn + When we run gdk component deploy -n TestDeployment + Then command was successful + + @version(min='1.7.0') + @change_cwd + Scenario: deploy fails without target arn + Given we have cli installed + And we make directory helloworld + And we run gdk component init -t HelloWorld -l python + And command was successful + And we verify gdk project files + And change component name to com.example.PythonHelloWorld + And change artifact uri for all platform from com.example.PythonHelloWorld to ${context.last_component} + And we run gdk component build + And command was successful + And we run gdk component publish + And command was successful + When we run gdk component deploy + Then command was not successful + + @version(min='1.7.0') + @change_cwd + Scenario: deploy fails for unpublished component + Given we have cli installed + And we make directory helloworld + And we run gdk component init -t HelloWorld -l python + And command was successful + And we verify gdk project files + And change component name to com.example.PythonHelloWorld + And we add deploy config with target arn + When we run gdk component deploy + Then command was not successful diff --git a/uat/steps/component.py b/uat/steps/component.py index 37f4f407..204973a3 100644 --- a/uat/steps/component.py +++ b/uat/steps/component.py @@ -1,4 +1,5 @@ import ast +import json import os import t_utils import shutil @@ -6,7 +7,8 @@ from pathlib import Path from constants import ( GG_CONFIG_JSON, GG_RECIPE_YAML, GG_BUILD_DIR, GG_BUILD_ZIP_DIR, - DEFAULT_AWS_REGION, DEFAULT_S3_BUCKET_PREFIX, DEFAULT_ARTIFACT_AUTHOR + DEFAULT_AWS_REGION, DEFAULT_S3_BUCKET_PREFIX, DEFAULT_ARTIFACT_AUTHOR, + DEFAULT_DEPLOY_TARGET_ARN ) @@ -123,3 +125,22 @@ def verify_files_in_build_zip_artifact(context, artifact_name): assert not unpack_dir.joinpath(file).exists(), f"File {file} found at {unpack_dir}" for file in included_files: assert unpack_dir.joinpath(file).exists(), f"File {file} not found at {unpack_dir}" + + +@step('we add deploy config with target arn') +def add_deploy_config(context): + cwd = context.cwd if "cwd" in context else os.getcwd() + config_file = Path(cwd).joinpath(GG_CONFIG_JSON).resolve() + assert config_file.exists(), f"{GG_CONFIG_JSON} does not exist" + + with open(str(config_file), "r") as f: + config = json.load(f) + + component_name = context.last_component + if "deploy" not in config["component"][component_name]: + config["component"][component_name]["deploy"] = {} + + config["component"][component_name]["deploy"]["target_arn"] = DEFAULT_DEPLOY_TARGET_ARN + + with open(str(config_file), "w") as f: + json.dump(config, f, indent=4) diff --git a/uat/steps/constants.py b/uat/steps/constants.py index 2f4f6420..78e8272e 100644 --- a/uat/steps/constants.py +++ b/uat/steps/constants.py @@ -11,3 +11,4 @@ DEFAULT_AWS_REGION = "us-east-1" DEFAULT_S3_BUCKET_PREFIX = "gdk-github-workflow-cdk-test-data" DEFAULT_ARTIFACT_AUTHOR = "gdk-cli-uat" +DEFAULT_DEPLOY_TARGET_ARN = "arn:aws:iot:us-east-1:123456789012:thinggroup/GdkCliUatTestGroup" diff --git a/uat/t_utils.py b/uat/t_utils.py index 8fe8d77a..120062db 100644 --- a/uat/t_utils.py +++ b/uat/t_utils.py @@ -54,6 +54,7 @@ def clean_up_aws_resources(component_name, component_version, region): account_num = get_acc_num(region) delete_component(component_name, component_version, region, account_num) delete_s3_artifact(region, account_num, component_name, component_version) + cancel_deployment_for_component(component_name, region) def delete_s3_artifact(region, account, component_name, component_version): @@ -82,6 +83,27 @@ def delete_component(name, version, region, account_num): print(e) +def cancel_deployment_for_component(component_name, region): + """Cancel any active deployments for a component during cleanup.""" + try: + gg_client = boto3.client("greengrassv2", region_name=region) + deployments = gg_client.list_deployments(historyFilter="LATEST_ONLY") + for deployment in deployments.get("deployments", []): + deployment_id = deployment.get("deploymentId") + if deployment_id: + try: + detail = gg_client.get_deployment(deploymentId=deployment_id) + components = detail.get("components", {}) + if component_name in components: + gg_client.cancel_deployment(deploymentId=deployment_id) + print(f"Cancelled deployment {deployment_id} for {component_name}") + except Exception: + pass + except Exception as e: + print(f"Failed to cancel deployments for {component_name}") + print(e) + + def get_version_created(recipes_path, component_name): for f in Path(recipes_path).iterdir(): if component_name in str(f.resolve()):