diff --git a/AmazonQ.md b/AmazonQ.md new file mode 100644 index 000000000..a65ba395b --- /dev/null +++ b/AmazonQ.md @@ -0,0 +1,7 @@ +# Runway Requirements + +- Ensure you understand the codebase in ./runway before trying to make changes +- Docs for contributing are located at ./docs/source/developers +- If we add functionality, ensure we update ./docs appropriately. +- Ensure we add a commit to git when we add code/functionality +- NEVER commit URLs, secrets, or sensitive data like parameter/env values diff --git a/docs/source/sam/configuration.rst b/docs/source/sam/configuration.rst new file mode 100644 index 000000000..df315115c --- /dev/null +++ b/docs/source/sam/configuration.rst @@ -0,0 +1,113 @@ +############ +Configuration +############ + +Standard `AWS SAM CLI `__ rules apply but, we have some added functionality. + +**Supported SAM CLI Versions:** ``>=1.0.0`` + +************* +Configuration +************* + +Configuration options for AWS SAM modules can be specified as ``module_options`` or ``options``. + +.. rubric:: Example +.. code-block:: yaml + + deployments: + - modules: + - path: sampleapp.sam + options: + build_args: + - --use-container + deploy_args: + - --guided + skip_build: false + +.. automodule:: runway.config.models.runway.options.sam + :members: + :exclude-members: model_config, model_fields + +***************** +Runway Config Dir +***************** + +Runway will change to the directory containing the SAM template file before executing SAM CLI commands. + +******************* +Environment Support +******************* + +Runway will look for SAM configuration files in the following order: + +1. ``samconfig--.toml`` +2. ``samconfig-.toml`` +3. ``samconfig.toml`` + +Where ```` is the current Runway environment name and ```` is the current AWS region. + +***************** +Template Location +***************** + +Runway will automatically detect SAM template files in the following order: + +1. ``template.yaml`` +2. ``template.yml`` +3. ``sam.yaml`` +4. ``sam.yml`` + +***************** +Parameter Support +***************** + +Runway supports passing parameters to SAM deployments through the ``parameters`` configuration option. These parameters will be passed to the SAM CLI as ``--parameter-overrides``. + +.. rubric:: Example +.. code-block:: yaml + + deployments: + - modules: + - path: sampleapp.sam + parameters: + Stage: ${env DEPLOY_ENVIRONMENT} + BucketName: my-bucket-${env DEPLOY_ENVIRONMENT} + +************* +Build Support +************* + +By default, Runway will run ``sam build`` before deploying. This can be disabled by setting ``skip_build: true`` in the module options. + +Additional build arguments can be passed using the ``build_args`` option. + +.. rubric:: Example +.. code-block:: yaml + + deployments: + - modules: + - path: sampleapp.sam + options: + build_args: + - --use-container + - --parallel + skip_build: false + +************** +Deploy Support +************** + +Additional deploy arguments can be passed using the ``deploy_args`` option. + +.. rubric:: Example +.. code-block:: yaml + + deployments: + - modules: + - path: sampleapp.sam + options: + deploy_args: + - --guided + - --capabilities + - CAPABILITY_IAM diff --git a/runway/_cli/commands/_gen_sample/__init__.py b/runway/_cli/commands/_gen_sample/__init__.py index d6a97c78b..937f7877b 100644 --- a/runway/_cli/commands/_gen_sample/__init__.py +++ b/runway/_cli/commands/_gen_sample/__init__.py @@ -11,6 +11,7 @@ from ._cdk_tsc import cdk_tsc from ._cfn import cfn from ._cfngin import cfngin +from ._sam import sam from ._sls_py import sls_py from ._sls_tsc import sls_tsc from ._static_angular import static_angular @@ -23,6 +24,7 @@ "cdk_tsc", "cfn", "cfngin", + "sam", "sls_py", "sls_tsc", "static_angular", @@ -36,6 +38,7 @@ cdk_tsc, cfn, cfngin, + sam, sls_py, sls_tsc, static_angular, diff --git a/runway/_cli/commands/_gen_sample/_sam.py b/runway/_cli/commands/_gen_sample/_sam.py new file mode 100644 index 000000000..7d4141df7 --- /dev/null +++ b/runway/_cli/commands/_gen_sample/_sam.py @@ -0,0 +1,29 @@ +"""``runway gen-sample sam`` command.""" + +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast + +import click + +from ... import options +from .utils import TEMPLATES, copy_sample + +if TYPE_CHECKING: + from ...._logging import RunwayLogger + +LOGGER = cast("RunwayLogger", logging.getLogger(__name__.replace("._", "."))) + + +@click.command("sam", short_help="AWS SAM (sampleapp.sam)") +@options.debug +@options.no_color +@options.verbose +@click.pass_context +def sam(ctx: click.Context, **_: Any) -> None: + """Generate a sample AWS SAM project.""" + src = TEMPLATES / "sam" + dest = Path.cwd() / "sampleapp.sam" + + copy_sample(ctx, src, dest) + LOGGER.success("Sample AWS SAM module created at %s", dest) diff --git a/runway/config/models/runway/_type_defs.py b/runway/config/models/runway/_type_defs.py index af5818a22..f7c5fbd28 100644 --- a/runway/config/models/runway/_type_defs.py +++ b/runway/config/models/runway/_type_defs.py @@ -12,5 +12,5 @@ RunwayEnvVarsType: TypeAlias = dict[str, Union[list[str], str]] RunwayEnvVarsUnresolvedType: TypeAlias = Union[RunwayEnvVarsType, str] RunwayModuleTypeTypeDef: TypeAlias = Literal[ - "cdk", "cloudformation", "serverless", "static", "terraform" + "cdk", "cloudformation", "sam", "serverless", "static", "terraform" ] diff --git a/runway/config/models/runway/options/sam.py b/runway/config/models/runway/options/sam.py new file mode 100644 index 000000000..e4d727108 --- /dev/null +++ b/runway/config/models/runway/options/sam.py @@ -0,0 +1,22 @@ +"""Runway AWS SAM Module options.""" + +from __future__ import annotations + +from pydantic import ConfigDict + +from ...base import ConfigProperty + + +class RunwaySamModuleOptionsDataModel(ConfigProperty): + """Model for Runway AWS SAM Module options.""" + + model_config = ConfigDict( + extra="ignore", + title="Runway AWS SAM Module options", + validate_default=True, + validate_assignment=True, + ) + + build_args: list[str] = [] + deploy_args: list[str] = [] + skip_build: bool = False diff --git a/runway/core/components/_module_type.py b/runway/core/components/_module_type.py index 6d5e9b9cd..700d6a471 100644 --- a/runway/core/components/_module_type.py +++ b/runway/core/components/_module_type.py @@ -19,7 +19,7 @@ LOGGER = logging.getLogger(__name__) -RunwayModuleTypeExtensionsTypeDef = Literal["cdk", "cfn", "sls", "tf", "web"] +RunwayModuleTypeExtensionsTypeDef = Literal["cdk", "cfn", "sam", "sls", "tf", "web"] class RunwayModuleType: @@ -45,6 +45,8 @@ class RunwayModuleType: +--------------------+-----------------------------------------------+ | ``cloudformation`` | CloudFormation | +--------------------+-----------------------------------------------+ + | ``sam`` | AWS SAM | + +--------------------+-----------------------------------------------+ | ``serverless`` | Serverless Framework | +--------------------+-----------------------------------------------+ | ``terraform`` | Terraform | @@ -61,6 +63,7 @@ class RunwayModuleType: EXTENSION_MAP: ClassVar[dict[str, str]] = { "cdk": "runway.module.cdk.CloudDevelopmentKit", "cfn": "runway.module.cloudformation.CloudFormation", + "sam": "runway.module.sam.Sam", "sls": "runway.module.serverless.Serverless", "tf": "runway.module.terraform.Terraform", "web": "runway.module.staticsite.handler.StaticSite", @@ -69,6 +72,7 @@ class RunwayModuleType: TYPE_MAP: ClassVar[dict[str, str]] = { "cdk": EXTENSION_MAP["cdk"], "cloudformation": EXTENSION_MAP["cfn"], + "sam": EXTENSION_MAP["sam"], "serverless": EXTENSION_MAP["sls"], "static": EXTENSION_MAP["web"], "terraform": EXTENSION_MAP["tf"], @@ -143,7 +147,12 @@ def _set_class_path_based_on_extension(self) -> None: def _set_class_path_based_on_autodetection(self) -> None: """Based on the files detected in the base path set the class_path.""" - if ( + if any( + (self.path / sam_template).is_file() + for sam_template in ["template.yaml", "template.yml", "sam.yaml", "sam.yml"] + ) and any((self.path / sam_config).is_file() for sam_config in ["samconfig.toml"]): + self.class_path = self.TYPE_MAP.get("sam", None) + elif ( any( (self.path / sls).is_file() for sls in ["serverless.js", "serverless.ts", "serverless.yml"] diff --git a/runway/exceptions.py b/runway/exceptions.py index 6531019e5..0d9f84cc4 100644 --- a/runway/exceptions.py +++ b/runway/exceptions.py @@ -249,6 +249,12 @@ def __reduce__(self) -> tuple[type[Exception], tuple[Any, ...]]: return self.__class__, (self.invalid_lookup, self.concatenated_lookups) +class SamNotFound(RunwayError): + """Raised when SAM CLI could not be executed or was not found in path.""" + + message: str = "AWS SAM CLI not found" + + class NpmNotFound(RunwayError): """Raised when npm could not be executed or was not found in path.""" diff --git a/runway/module/sam.py b/runway/module/sam.py new file mode 100644 index 000000000..a0285da02 --- /dev/null +++ b/runway/module/sam.py @@ -0,0 +1,303 @@ +"""AWS SAM module.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, cast + +if TYPE_CHECKING: + from pathlib import Path + +from .._logging import PrefixAdaptor +from ..compat import cached_property +from ..config.models.runway.options.sam import RunwaySamModuleOptionsDataModel +from ..exceptions import SamNotFound +from ..utils import which +from .base import ModuleOptions, RunwayModule +from .utils import run_module_command + +if TYPE_CHECKING: + from .._logging import RunwayLogger + from ..context import RunwayContext + +LOGGER = cast("RunwayLogger", logging.getLogger(__name__)) + + +def gen_sam_config_files(stage: str, region: str) -> list[str]: + """Generate possible SAM config files names.""" + names: list[str] = [] + for ext in ["toml"]: + # Give preference to explicit stage-region files + names.append(f"samconfig-{stage}-{region}.{ext}") + # Fallback to stage name only + names.append(f"samconfig-{stage}.{ext}") + # Default samconfig.toml + names.append("samconfig.toml") + return names + + +class SamOptions(ModuleOptions): + """Module options for AWS SAM. + + Attributes: + data: Options parsed into a data model. + build_args: Additional arguments to pass to `sam build`. + deploy_args: Additional arguments to pass to `sam deploy`. + skip_build: Skip running `sam build` before deploy. + + """ + + def __init__(self, data: RunwaySamModuleOptionsDataModel) -> None: + """Instantiate class. + + Args: + data: Options parsed into a data model. + + """ + self.data = data + self.build_args = data.build_args + self.deploy_args = data.deploy_args + self.skip_build = data.skip_build + + @classmethod + def parse_obj(cls, obj: object) -> SamOptions: + """Parse options definition and return an options object. + + Args: + obj: Object to parse. + + """ + return cls(data=RunwaySamModuleOptionsDataModel.model_validate(obj)) + + +class Sam(RunwayModule[SamOptions]): + """AWS SAM Runway Module.""" + + def __init__( + self, + context: RunwayContext, + *, + explicitly_enabled: bool | None = False, + logger: RunwayLogger = LOGGER, + module_root: Path, + name: str | None = None, + options: dict[str, Any] | ModuleOptions | None = None, + parameters: dict[str, Any] | None = None, + **_: Any, + ) -> None: + """Instantiate class. + + Args: + context: Runway context object for the current session. + explicitly_enabled: Whether or not the module is explicitly enabled. + logger: Used to write logs. + module_root: Root path of the module. + name: Name of the module. + options: Options passed to the module class from the config. + parameters: Values to pass to SAM that will alter the deployment. + + """ + super().__init__( + context, + explicitly_enabled=explicitly_enabled, + logger=logger, + module_root=module_root, + name=name, + options=SamOptions.parse_obj(options or {}), + parameters=parameters, + ) + self.logger = PrefixAdaptor(self.name, logger) + self.stage = self.ctx.env.name + self.check_for_sam(logger=self.logger) # fail fast + + @property + def cli_args(self) -> list[str]: + """Generate CLI args from self used in all SAM commands.""" + result = ["--region", self.region] + if "DEBUG" in self.ctx.env.vars: + result.append("--debug") + return result + + @cached_property + def config_file(self) -> Path | None: + """Find the SAM config file for the module.""" + for name in gen_sam_config_files(self.stage, self.region): + test_path = self.path / name + if test_path.is_file(): + return test_path + return None + + @cached_property + def template_file(self) -> Path | None: + """Find the SAM template file for the module.""" + for name in ["template.yaml", "template.yml", "sam.yaml", "sam.yml"]: + test_path = self.path / name + if test_path.is_file(): + return test_path + return None + + @property + def skip(self) -> bool: + """Determine if the module should be skipped.""" + if not self.template_file: + self.logger.info( + "skipped; SAM template file not found -- looking for one of: %s", + "template.yaml, template.yml, sam.yaml, sam.yml", + ) + return True + + if self.parameters or self.explicitly_enabled or self.config_file: + return False + + self.logger.info( + "skipped; config file for this stage/region not found -- looking for one of: %s", + ", ".join(gen_sam_config_files(self.stage, self.region)), + ) + return True + + def gen_cmd(self, command: str, args_list: list[str] | None = None) -> list[str]: + """Generate and log a SAM command. + + Args: + command: The SAM command to be executed. + args_list: Additional arguments to include in the generated command. + + Returns: + The full command to be passed into a subprocess. + + """ + cmd = ["sam", command] + + # Add template file if found + if self.template_file: + cmd.extend(["--template-file", str(self.template_file)]) + + # Add config file if found + if self.config_file: + cmd.extend(["--config-file", str(self.config_file)]) + + # Add common CLI args + cmd.extend(self.cli_args) + + # Add command-specific args + cmd.extend(args_list or []) + + # Add no-color if needed + if self.ctx.no_color: + cmd.append("--no-color") + + return cmd + + def sam_build(self, *, skip_build: bool = False) -> None: + """Execute `sam build` command. + + Args: + skip_build: Skip running the build command. + + """ + if skip_build or self.options.skip_build: + self.logger.info("skipped sam build") + return + + self.logger.info("build (in progress)") + run_module_command( + cmd_list=self.gen_cmd("build", self.options.build_args), + env_vars=self.ctx.env.vars, + logger=self.logger, + ) + self.logger.info("build (complete)") + + def sam_deploy(self, *, skip_build: bool = False) -> None: + """Execute `sam deploy` command. + + Args: + skip_build: Skip running build before deploy. + + """ + if not skip_build: + self.sam_build() + + self.logger.info("deploy (in progress)") + + # Build deploy command with parameters + deploy_args = self.options.deploy_args.copy() + + # Add parameter overrides if provided + if self.parameters: + param_overrides = [] + for key, value in self.parameters.items(): + param_overrides.append(f"{key}={value}") + if param_overrides: + deploy_args.extend(["--parameter-overrides", *param_overrides]) + + # Add stack name based on stage if not already provided + if not any(arg.startswith("--stack-name") for arg in deploy_args): + stack_name = f"{self.name}-{self.stage}" + deploy_args.extend(["--stack-name", stack_name]) + + run_module_command( + cmd_list=self.gen_cmd("deploy", deploy_args), + env_vars=self.ctx.env.vars, + logger=self.logger, + ) + self.logger.info("deploy (complete)") + + def sam_delete(self) -> None: + """Execute `sam delete` command.""" + self.logger.info("destroy (in progress)") + + # Build delete command + delete_args = [] + + # Add stack name based on stage + stack_name = f"{self.name}-{self.stage}" + delete_args.extend(["--stack-name", stack_name]) + + # Add no-prompts for non-interactive mode + if self.ctx.is_noninteractive: + delete_args.append("--no-prompts") + + run_module_command( + cmd_list=self.gen_cmd("delete", delete_args), + env_vars=self.ctx.env.vars, + logger=self.logger, + ) + self.logger.info("destroy (complete)") + + def deploy(self) -> None: + """Entrypoint for Runway's deploy action.""" + if self.skip: + return + self.sam_deploy() + + def destroy(self) -> None: + """Entrypoint for Runway's destroy action.""" + if self.skip: + return + self.sam_delete() + + def init(self) -> None: + """Run init.""" + self.logger.warning("init not currently supported for %s", self.__class__.__name__) + + def plan(self) -> None: + """Entrypoint for Runway's plan action.""" + self.logger.info("plan not currently supported for SAM") + + @staticmethod + def check_for_sam(*, logger: logging.Logger | PrefixAdaptor | RunwayLogger = LOGGER) -> None: + """Ensure SAM CLI is installed and in the current path. + + Args: + logger: Optionally provide a custom logger to use. + + Raises: + SamNotFound: SAM CLI not found. + + """ + if not which("sam"): + logger.error( + '"sam" not found in path or is not executable; ' + "please ensure AWS SAM CLI is installed correctly" + ) + raise SamNotFound diff --git a/runway/templates/sam/hello_world/__init__.py b/runway/templates/sam/hello_world/__init__.py new file mode 100644 index 000000000..74edf86f6 --- /dev/null +++ b/runway/templates/sam/hello_world/__init__.py @@ -0,0 +1 @@ +"""Hello World Lambda function package.""" diff --git a/runway/templates/sam/hello_world/app.py b/runway/templates/sam/hello_world/app.py new file mode 100644 index 000000000..c9923e97d --- /dev/null +++ b/runway/templates/sam/hello_world/app.py @@ -0,0 +1,46 @@ +"""Sample Lambda function for SAM template.""" + +import json +from typing import Any + +# import requests + + +def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, Any]: # noqa: ARG001 + """Sample pure Lambda function. + + Parameters + ---------- + event: dict, required + API Gateway Lambda Proxy Input Format + + Event doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format + + context: object, required + Lambda Context runtime methods and attributes + + Context doc: https://docs.aws.amazon.com/lambda/latest/dg/python-context-object.html + + Returns: + ------- + API Gateway Lambda Proxy Output Format: dict + + Return doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html + """ + # try: + # ip = requests.get("http://checkip.amazonaws.com/") + # except requests.RequestException as e: + # # Send some context about this error to Lambda Logs + # print(e) + + # raise e + + return { + "statusCode": 200, + "body": json.dumps( + { + "message": "hello world", + # "location": ip.text.replace("\n", "") + } + ), + } diff --git a/runway/templates/sam/hello_world/requirements.txt b/runway/templates/sam/hello_world/requirements.txt new file mode 100644 index 000000000..c6d21dc96 --- /dev/null +++ b/runway/templates/sam/hello_world/requirements.txt @@ -0,0 +1 @@ +# requests diff --git a/runway/templates/sam/samconfig.toml b/runway/templates/sam/samconfig.toml new file mode 100644 index 000000000..5c526e13a --- /dev/null +++ b/runway/templates/sam/samconfig.toml @@ -0,0 +1,36 @@ +# More information about the configuration file can be found here: +# https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-config.html + +version = 0.1 + +[default] + +[default.build.parameters] +cached = true +parallel = true + +[default.deploy.parameters] +capabilities = "CAPABILITY_IAM" +confirm_changeset = true +image_repositories = [] +region = "us-east-1" +resolve_s3 = true +s3_prefix = "sam-app" + +[default.global.parameters] +stack_name = "sam-app" + +[default.local_start_api.parameters] +warm_containers = "EAGER" + +[default.local_start_lambda.parameters] +warm_containers = "EAGER" + +[default.package.parameters] +resolve_s3 = true + +[default.sync.parameters] +watch = true + +[default.validate.parameters] +lint = true diff --git a/runway/templates/sam/template.yaml b/runway/templates/sam/template.yaml new file mode 100644 index 000000000..3bc1c12e8 --- /dev/null +++ b/runway/templates/sam/template.yaml @@ -0,0 +1,54 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: > + Sample SAM Application + + Sample SAM Template for a simple Lambda function + +# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst +Globals: + Function: + Timeout: 3 + MemorySize: 128 + +Parameters: + Stage: + Type: String + Default: dev + Description: Stage name for the deployment + +Resources: + HelloWorldFunction: + Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction + Properties: + CodeUri: hello_world/ + Handler: app.lambda_handler + Runtime: python3.9 + Architectures: + - x86_64 + Events: + HelloWorld: + Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api + Properties: + Path: /hello + Method: get + Tags: + customer: skynet + application: sample-app + environment: !Ref Stage + regulated: "no" + owner: jon@zer0day.net + +Outputs: + # ServerlessRestApi is an implicit API created out of Events key under Serverless::Function + # Find out more about other implicit resources you can reference within SAM + # https://github.com/awslabs/serverless-application-model/blob/master/docs/internals/generated_resources.rst#api + HelloWorldApi: + Description: "API Gateway endpoint URL for Prod stage for Hello World function" + Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/" + HelloWorldFunction: + Description: "Hello World Lambda Function ARN" + Value: !GetAtt HelloWorldFunction.Arn + HelloWorldFunctionIamRole: + Description: "Implicit IAM Role created for Hello World function" + Value: !GetAtt HelloWorldFunctionRole.Arn diff --git a/tests/unit/core/components/test__module_type.py b/tests/unit/core/components/test__module_type.py index 5865978c1..9b535285c 100644 --- a/tests/unit/core/components/test__module_type.py +++ b/tests/unit/core/components/test__module_type.py @@ -10,6 +10,7 @@ from runway.core.components import RunwayModuleType from runway.module.cdk import CloudDevelopmentKit from runway.module.cloudformation import CloudFormation +from runway.module.sam import Sam from runway.module.serverless import Serverless from runway.module.staticsite.handler import StaticSite from runway.module.terraform import Terraform @@ -30,6 +31,10 @@ class TestRunwayModuleType: (["cdk.json", "package.json"], CloudDevelopmentKit), (["any.yml", "serverless.yml"], CloudFormation), (["any.yaml", "package.json"], CloudFormation), + (["template.yaml", "samconfig.toml"], Sam), + (["template.yml", "samconfig.toml"], Sam), + (["sam.yaml", "samconfig.toml"], Sam), + (["sam.yml", "samconfig.toml"], Sam), (["serverless.yml", "package.json", "cdk.json"], Serverless), (["serverless.js", "package.json", "cdk.json"], Serverless), (["serverless.ts", "package.json", "cdk.json"], Serverless), @@ -75,6 +80,7 @@ def test_from_class_path(self, cd_tmp_path: Path) -> None: [ ("cdk", CloudDevelopmentKit), ("cfn", CloudFormation), + ("sam", Sam), ("sls", Serverless), ("web", StaticSite), ("tf", Terraform), @@ -93,6 +99,7 @@ def test_from_extension( [ ("cdk", CloudDevelopmentKit), ("cloudformation", CloudFormation), + ("sam", Sam), ("serverless", Serverless), ("static", StaticSite), ("terraform", Terraform), diff --git a/tests/unit/module/test_sam.py b/tests/unit/module/test_sam.py new file mode 100644 index 000000000..80b1f4189 --- /dev/null +++ b/tests/unit/module/test_sam.py @@ -0,0 +1,215 @@ +"""Test runway.module.sam.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest + +from runway.core.components import DeployEnvironment +from runway.exceptions import SamNotFound +from runway.module.sam import Sam, SamOptions + +from ..factories import MockRunwayContext + +if TYPE_CHECKING: + from pathlib import Path + + from pytest_mock import MockerFixture + + +MODULE = "runway.module.sam" + + +class TestSamOptions: + """Test runway.module.sam.SamOptions.""" + + def test_init(self) -> None: + """Test __init__.""" + options = SamOptions.parse_obj( + {"build_args": ["--use-container"], "deploy_args": ["--guided"], "skip_build": True} + ) + assert options.build_args == ["--use-container"] + assert options.deploy_args == ["--guided"] + assert options.skip_build is True + + def test_init_defaults(self) -> None: + """Test __init__ with defaults.""" + options = SamOptions.parse_obj({}) + assert options.build_args == [] + assert options.deploy_args == [] + assert options.skip_build is False + + +class TestSam: + """Test runway.module.sam.Sam.""" + + @property + def generic_parameters(self) -> dict[str, Any]: + """Return generic module parameters.""" + return {"test_key": "test-value"} + + @staticmethod + def get_context(name: str = "test", region: str = "us-east-1") -> MockRunwayContext: + """Create a basic Runway context object.""" + context = MockRunwayContext(deploy_environment=DeployEnvironment(explicit_name=name)) + context.env.aws_region = region + return context + + def test_init(self, tmp_path: Path, mocker: MockerFixture) -> None: + """Test __init__.""" + mock_check_for_sam = mocker.patch(f"{MODULE}.Sam.check_for_sam") + module = Sam(self.get_context(), module_root=tmp_path, parameters=self.generic_parameters) + assert module.name == tmp_path.name + assert module.stage == "test" + assert module.region == "us-east-1" + mock_check_for_sam.assert_called_once() + + def test_cli_args(self, tmp_path: Path, mocker: MockerFixture) -> None: + """Test cli_args property.""" + mocker.patch(f"{MODULE}.Sam.check_for_sam") + module = Sam(self.get_context(), module_root=tmp_path) + assert module.cli_args == ["--region", "us-east-1"] + + def test_cli_args_debug(self, tmp_path: Path, mocker: MockerFixture) -> None: + """Test cli_args property with debug.""" + mocker.patch(f"{MODULE}.Sam.check_for_sam") + context = self.get_context() + context.env.vars["DEBUG"] = "1" + module = Sam(context, module_root=tmp_path) + assert module.cli_args == ["--region", "us-east-1", "--debug"] + + def test_template_file(self, tmp_path: Path, mocker: MockerFixture) -> None: + """Test template_file property.""" + mocker.patch(f"{MODULE}.Sam.check_for_sam") + template_file = tmp_path / "template.yaml" + template_file.write_text( + "AWSTemplateFormatVersion: '2010-09-09'\nTransform: AWS::Serverless-2016-10-31" + ) + + module = Sam(self.get_context(), module_root=tmp_path) + assert module.template_file == template_file + + def test_template_file_not_found(self, tmp_path: Path, mocker: MockerFixture) -> None: + """Test template_file property when no template found.""" + mocker.patch(f"{MODULE}.Sam.check_for_sam") + module = Sam(self.get_context(), module_root=tmp_path) + assert module.template_file is None + + def test_config_file(self, tmp_path: Path, mocker: MockerFixture) -> None: + """Test config_file property.""" + mocker.patch(f"{MODULE}.Sam.check_for_sam") + config_file = tmp_path / "samconfig.toml" + config_file.write_text('[default.deploy.parameters]\nstack_name = "test-stack"') + + module = Sam(self.get_context(), module_root=tmp_path) + assert module.config_file == config_file + + def test_skip_no_template(self, tmp_path: Path, mocker: MockerFixture) -> None: + """Test skip property when no template file.""" + mocker.patch(f"{MODULE}.Sam.check_for_sam") + module = Sam(self.get_context(), module_root=tmp_path) + assert module.skip is True + + def test_skip_with_template_and_config(self, tmp_path: Path, mocker: MockerFixture) -> None: + """Test skip property with template and config.""" + mocker.patch(f"{MODULE}.Sam.check_for_sam") + template_file = tmp_path / "template.yaml" + template_file.write_text( + "AWSTemplateFormatVersion: '2010-09-09'\nTransform: AWS::Serverless-2016-10-31" + ) + config_file = tmp_path / "samconfig.toml" + config_file.write_text('[default.deploy.parameters]\nstack_name = "test-stack"') + + module = Sam(self.get_context(), module_root=tmp_path) + assert module.skip is False + + def test_gen_cmd_basic(self, tmp_path: Path, mocker: MockerFixture) -> None: + """Test gen_cmd method.""" + mocker.patch(f"{MODULE}.Sam.check_for_sam") + template_file = tmp_path / "template.yaml" + template_file.write_text( + "AWSTemplateFormatVersion: '2010-09-09'\nTransform: AWS::Serverless-2016-10-31" + ) + + context = self.get_context() + context.no_color = False # Ensure no-color is False + module = Sam(context, module_root=tmp_path) + cmd = module.gen_cmd("build") + + expected = ["sam", "build", "--template-file", str(template_file), "--region", "us-east-1"] + assert cmd == expected + + def test_deploy(self, tmp_path: Path, mocker: MockerFixture) -> None: + """Test deploy method.""" + mock_check_for_sam = mocker.patch(f"{MODULE}.Sam.check_for_sam") + mock_sam_deploy = mocker.patch(f"{MODULE}.Sam.sam_deploy") + + template_file = tmp_path / "template.yaml" + template_file.write_text( + "AWSTemplateFormatVersion: '2010-09-09'\nTransform: AWS::Serverless-2016-10-31" + ) + config_file = tmp_path / "samconfig.toml" + config_file.write_text('[default.deploy.parameters]\nstack_name = "test-stack"') + + module = Sam(self.get_context(), module_root=tmp_path) + module.deploy() + + mock_check_for_sam.assert_called_once() + mock_sam_deploy.assert_called_once() + + def test_deploy_skip(self, tmp_path: Path, mocker: MockerFixture) -> None: + """Test deploy method when skipped.""" + mock_check_for_sam = mocker.patch(f"{MODULE}.Sam.check_for_sam") + mock_sam_deploy = mocker.patch(f"{MODULE}.Sam.sam_deploy") + + module = Sam(self.get_context(), module_root=tmp_path) + module.deploy() + + mock_check_for_sam.assert_called_once() + mock_sam_deploy.assert_not_called() + + def test_destroy(self, tmp_path: Path, mocker: MockerFixture) -> None: + """Test destroy method.""" + mock_check_for_sam = mocker.patch(f"{MODULE}.Sam.check_for_sam") + mock_sam_delete = mocker.patch(f"{MODULE}.Sam.sam_delete") + + template_file = tmp_path / "template.yaml" + template_file.write_text( + "AWSTemplateFormatVersion: '2010-09-09'\nTransform: AWS::Serverless-2016-10-31" + ) + config_file = tmp_path / "samconfig.toml" + config_file.write_text('[default.deploy.parameters]\nstack_name = "test-stack"') + + module = Sam(self.get_context(), module_root=tmp_path) + module.destroy() + + mock_check_for_sam.assert_called_once() + mock_sam_delete.assert_called_once() + + def test_check_for_sam_success(self, mocker: MockerFixture) -> None: + """Test check_for_sam when SAM CLI is available.""" + mock_which = mocker.patch(f"{MODULE}.which", return_value="/usr/local/bin/sam") + Sam.check_for_sam() + mock_which.assert_called_once_with("sam") + + def test_check_for_sam_not_found(self, mocker: MockerFixture) -> None: + """Test check_for_sam when SAM CLI is not available.""" + mock_which = mocker.patch(f"{MODULE}.which", return_value=None) + with pytest.raises(SamNotFound): + Sam.check_for_sam() + mock_which.assert_called_once_with("sam") + + def test_init_method(self, tmp_path: Path, mocker: MockerFixture) -> None: + """Test init method.""" + mock_check_for_sam = mocker.patch(f"{MODULE}.Sam.check_for_sam") + module = Sam(self.get_context(), module_root=tmp_path) + module.init() # Should just log a warning + mock_check_for_sam.assert_called_once() + + def test_plan(self, tmp_path: Path, mocker: MockerFixture) -> None: + """Test plan method.""" + mock_check_for_sam = mocker.patch(f"{MODULE}.Sam.check_for_sam") + module = Sam(self.get_context(), module_root=tmp_path) + module.plan() # Should just log a message + mock_check_for_sam.assert_called_once()