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()):