From 77b8758e8a4a8656d5ef2f20de845a7cd8d3ec4e Mon Sep 17 00:00:00 2001 From: Abiodun Sotunde Date: Thu, 14 Nov 2024 09:21:36 +0100 Subject: [PATCH] fixup! feat(action): migrate steps from yaml to python --- .github/workflows/ci.yml | 2 +- action.yml | 3 +- .../commands/create_task_definition.py | 4 +- .../commands/deregister_task_definition.py | 32 +-- actions_helper/main.py | 21 +- actions_helper/utils.py | 2 + poetry.lock | 66 ++--- tests/test_action_helper.py | 106 -------- tests/test_create_task_definition.py | 113 +++++++++ tests/test_deregister_task_definition.py | 230 ++++++++++++++++++ .../test_get_active_task_definition_by_tag.py | 98 ++++++++ tests/test_get_image_uri.py | 58 +++++ tests/test_main.py | 142 +++++++++++ tests/test_run_preflight.py | 166 +++++++++++++ tests/utils.py | 11 + 15 files changed, 877 insertions(+), 177 deletions(-) delete mode 100644 tests/test_action_helper.py create mode 100644 tests/test_create_task_definition.py create mode 100644 tests/test_deregister_task_definition.py create mode 100644 tests/test_get_active_task_definition_by_tag.py create mode 100644 tests/test_get_image_uri.py create mode 100644 tests/test_main.py create mode 100644 tests/test_run_preflight.py create mode 100644 tests/utils.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fd7869..ed1eaca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,4 +13,4 @@ jobs: - uses: moneymeets/moneymeets-composite-actions/lint-python@master - - run: poetry run python -m pytest --cov --cov-fail-under=89 + - run: poetry run python -m pytest --cov --cov-fail-under=100 diff --git a/action.yml b/action.yml index a7a0a71..e671c22 100644 --- a/action.yml +++ b/action.yml @@ -55,9 +55,8 @@ runs: # ToDo: Re-enable cache when https://github.com/actions/setup-python/issues/361 is fixed poetry_cache_enabled: 'false' - - name: AWS ECS Task Deploy + - id: run-ecs-deploy shell: bash - id: run-ecs-deploy working-directory: ${{ github.action_path }} run: | if [[ "${{ inputs.allow_feature_branch_deployment }}" == "true" ]]; then diff --git a/actions_helper/commands/create_task_definition.py b/actions_helper/commands/create_task_definition.py index a222598..b8408a6 100644 --- a/actions_helper/commands/create_task_definition.py +++ b/actions_helper/commands/create_task_definition.py @@ -5,9 +5,7 @@ from actions_helper.commands.get_active_task_definition_by_tag import get_active_task_definition_arn_by_tag from actions_helper.outputs import CreateTaskDefinitionOutput -from actions_helper.utils import set_error - -PLACEHOLDER_TEXT = "PLACEHOLDER" +from actions_helper.utils import PLACEHOLDER_TEXT, set_error KEYS_TO_DELETE_FROM_TASK_DEFINITION = [ "taskDefinitionArn", diff --git a/actions_helper/commands/deregister_task_definition.py b/actions_helper/commands/deregister_task_definition.py index 9b07135..8f0395d 100644 --- a/actions_helper/commands/deregister_task_definition.py +++ b/actions_helper/commands/deregister_task_definition.py @@ -11,9 +11,9 @@ def deregister_task_definition( ecs_client: BaseClient, cluster: str, service: str, - production_task_definition_output: Optional[CreateTaskDefinitionOutput] = None, - local_task_definition_output: Optional[CreateTaskDefinitionOutput] = None, - preflight_task_definition_output: Optional[CreateTaskDefinitionOutput] = None, + production_task_definition_output: Optional[CreateTaskDefinitionOutput], + local_task_definition_output: Optional[CreateTaskDefinitionOutput], + preflight_task_definition_output: Optional[CreateTaskDefinitionOutput], ): (primary_deployment_definition_arn,) = ( deployment["taskDefinition"] @@ -26,35 +26,27 @@ def deregister_task_definition( click.echo(f"{primary_deployment_definition_arn=}") - production_task_definition_to_deregister = local_task_definition_to_deregister = ( - preflight_task_definition_to_deregister - ) = None - fail_pipeline = True if ( - production_task_definition_output + all((production_task_definition_output, local_task_definition_output, preflight_task_definition_output)) and primary_deployment_definition_arn == production_task_definition_output.latest_task_definition_arn ): # Do not deregister task definition for initial deployment - if production_task_definition_output.previous_task_definition_arn: - production_task_definition_to_deregister = production_task_definition_output.previous_task_definition_arn - local_task_definition_to_deregister = local_task_definition_output.previous_task_definition_arn - preflight_task_definition_to_deregister = preflight_task_definition_output.previous_task_definition_arn + if not production_task_definition_output.previous_task_definition_arn: + return fail_pipeline = False - else: - production_task_definition_to_deregister = production_task_definition_output.latest_task_definition_arn - local_task_definition_to_deregister = local_task_definition_output.latest_task_definition_arn - preflight_task_definition_to_deregister = preflight_task_definition_output.latest_task_definition_arn for task_definition in ( - production_task_definition_to_deregister, - local_task_definition_to_deregister, - preflight_task_definition_to_deregister, + production_task_definition_output, + local_task_definition_output, + preflight_task_definition_output, ): if task_definition: click.echo(f"Deregister {task_definition}") ecs_client.deregister_task_definition( - taskDefinition=task_definition, + taskDefinition=task_definition.latest_task_definition_arn + if fail_pipeline + else task_definition.previous_task_definition_arn, ) if fail_pipeline: diff --git a/actions_helper/main.py b/actions_helper/main.py index 015cea4..26d153f 100644 --- a/actions_helper/main.py +++ b/actions_helper/main.py @@ -10,7 +10,7 @@ @click.group() def cli(): - pass + pass # pragma: no cover @cli.command( @@ -40,6 +40,7 @@ def cmd_ecs_deploy( ecs_client = boto3.Session(region_name=aws_region).client("ecs") ecr_client = boto3.Session(region_name=aws_region).client("ecr") + service = f"{ecr_repository}-{environment}" production_task_definition = local_task_definition = preflight_output = None try: click.echo("Getting docker image URI...") @@ -56,7 +57,7 @@ def cmd_ecs_deploy( click.echo("Creating production task definition...") production_task_definition = create_task_definition( ecs_client=ecs_client, - application_id=f"{ecr_repository}-{environment}", + application_id=service, deployment_tag=deployment_tag, image_uri=image_uri, ) @@ -65,7 +66,7 @@ def cmd_ecs_deploy( click.echo("Run preflight enabled") preflight_output = run_preflight_container( ecs_client=ecs_client, - service=f"{ecr_repository}-{environment}", + service=service, cluster=environment, application_id=f"{ecr_repository}-preflight-{environment}", image_uri=image_uri, @@ -77,32 +78,28 @@ def cmd_ecs_deploy( taskDefinition=production_task_definition.latest_task_definition_arn, desiredCount=desired_count, cluster=environment, - service=f"{ecr_repository}-{environment}", + service=service, ) - click.echo("Service updated") click.echo("Waiting for service stability...") wait_for_service_stability( ecs_client=ecs_client, cluster=environment, - service=f"{ecr_repository}-{environment}", + service=service, ) - - except Exception as e: - click.echo("An exception occurred", err=True) - click.echo(e) + click.echo("Service stable") finally: click.echo("De-registering task definition") deregister_task_definition( ecs_client=ecs_client, cluster=environment, - service=f"{ecr_repository}-{environment}", + service=service, production_task_definition_output=production_task_definition, local_task_definition_output=local_task_definition, preflight_task_definition_output=preflight_output, ) -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover cli() diff --git a/actions_helper/utils.py b/actions_helper/utils.py index dd540ca..43d5012 100644 --- a/actions_helper/utils.py +++ b/actions_helper/utils.py @@ -1,5 +1,7 @@ from typing import Optional +PLACEHOLDER_TEXT = "PLACEHOLDER" + def set_error(message: str, file: Optional[str] = None, line: Optional[str] = None): print(f"::error {f'file={file}' if file else ''}{f',line={line}' if line else ''}::{message}") diff --git a/poetry.lock b/poetry.lock index fb7b4f4..f0e4eaf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,18 +1,18 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "boto3" -version = "1.35.54" +version = "1.35.58" description = "The AWS SDK for Python" optional = false python-versions = ">=3.8" files = [ - {file = "boto3-1.35.54-py3-none-any.whl", hash = "sha256:2d5e160b614db55fbee7981001c54476cb827c441cef65b2fcb2c52a62019909"}, - {file = "boto3-1.35.54.tar.gz", hash = "sha256:7d9c359bbbc858a60b51c86328db813353c8bd1940212cdbd0a7da835291c2e1"}, + {file = "boto3-1.35.58-py3-none-any.whl", hash = "sha256:856896fd5fc5871758eb04b27bad5bbbf0fdb6143a923f9e8d10125351efdf98"}, + {file = "boto3-1.35.58.tar.gz", hash = "sha256:1ee139e63f1545ee0192914cfe422b68360b8c344a94e4612ac657dd7ece93de"}, ] [package.dependencies] -botocore = ">=1.35.54,<1.36.0" +botocore = ">=1.35.58,<1.36.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.10.0,<0.11.0" @@ -21,13 +21,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.35.54" +version = "1.35.58" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.8" files = [ - {file = "botocore-1.35.54-py3-none-any.whl", hash = "sha256:9cca1811094b6cdc144c2c063a3ec2db6d7c88194b04d4277cd34fc8e3473aff"}, - {file = "botocore-1.35.54.tar.gz", hash = "sha256:131bb59ce59c8a939b31e8e647242d70cf11d32d4529fa4dca01feea1e891a76"}, + {file = "botocore-1.35.58-py3-none-any.whl", hash = "sha256:647b8706ae6484ee4c2208235f38976d9f0e52f80143e81d7941075215e96111"}, + {file = "botocore-1.35.58.tar.gz", hash = "sha256:8303309c7b59ddf04b11d79813530809d6b10b411ac9f93916d2032c283d6881"}, ] [package.dependencies] @@ -561,13 +561,13 @@ xray = ["aws-xray-sdk (>=0.93,!=0.96)", "setuptools"] [[package]] name = "packaging" -version = "24.1" +version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, - {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, ] [[package]] @@ -752,29 +752,29 @@ tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asy [[package]] name = "ruff" -version = "0.7.2" +version = "0.7.3" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.7.2-py3-none-linux_armv6l.whl", hash = "sha256:b73f873b5f52092e63ed540adefc3c36f1f803790ecf2590e1df8bf0a9f72cb8"}, - {file = "ruff-0.7.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5b813ef26db1015953daf476202585512afd6a6862a02cde63f3bafb53d0b2d4"}, - {file = "ruff-0.7.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:853277dbd9675810c6826dad7a428d52a11760744508340e66bf46f8be9701d9"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21aae53ab1490a52bf4e3bf520c10ce120987b047c494cacf4edad0ba0888da2"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ccc7e0fc6e0cb3168443eeadb6445285abaae75142ee22b2b72c27d790ab60ba"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd77877a4e43b3a98e5ef4715ba3862105e299af0c48942cc6d51ba3d97dc859"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e00163fb897d35523c70d71a46fbaa43bf7bf9af0f4534c53ea5b96b2e03397b"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3c54b538633482dc342e9b634d91168fe8cc56b30a4b4f99287f4e339103e88"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b792468e9804a204be221b14257566669d1db5c00d6bb335996e5cd7004ba80"}, - {file = "ruff-0.7.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dba53ed84ac19ae4bfb4ea4bf0172550a2285fa27fbb13e3746f04c80f7fa088"}, - {file = "ruff-0.7.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b19fafe261bf741bca2764c14cbb4ee1819b67adb63ebc2db6401dcd652e3748"}, - {file = "ruff-0.7.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28bd8220f4d8f79d590db9e2f6a0674f75ddbc3847277dd44ac1f8d30684b828"}, - {file = "ruff-0.7.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9fd67094e77efbea932e62b5d2483006154794040abb3a5072e659096415ae1e"}, - {file = "ruff-0.7.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:576305393998b7bd6c46018f8104ea3a9cb3fa7908c21d8580e3274a3b04b691"}, - {file = "ruff-0.7.2-py3-none-win32.whl", hash = "sha256:fa993cfc9f0ff11187e82de874dfc3611df80852540331bc85c75809c93253a8"}, - {file = "ruff-0.7.2-py3-none-win_amd64.whl", hash = "sha256:dd8800cbe0254e06b8fec585e97554047fb82c894973f7ff18558eee33d1cb88"}, - {file = "ruff-0.7.2-py3-none-win_arm64.whl", hash = "sha256:bb8368cd45bba3f57bb29cbb8d64b4a33f8415d0149d2655c5c8539452ce7760"}, - {file = "ruff-0.7.2.tar.gz", hash = "sha256:2b14e77293380e475b4e3a7a368e14549288ed2931fce259a6f99978669e844f"}, + {file = "ruff-0.7.3-py3-none-linux_armv6l.whl", hash = "sha256:34f2339dc22687ec7e7002792d1f50712bf84a13d5152e75712ac08be565d344"}, + {file = "ruff-0.7.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fb397332a1879b9764a3455a0bb1087bda876c2db8aca3a3cbb67b3dbce8cda0"}, + {file = "ruff-0.7.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:37d0b619546103274e7f62643d14e1adcbccb242efda4e4bdb9544d7764782e9"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d59f0c3ee4d1a6787614e7135b72e21024875266101142a09a61439cb6e38a5"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:44eb93c2499a169d49fafd07bc62ac89b1bc800b197e50ff4633aed212569299"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d0242ce53f3a576c35ee32d907475a8d569944c0407f91d207c8af5be5dae4e"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:6b6224af8b5e09772c2ecb8dc9f3f344c1aa48201c7f07e7315367f6dd90ac29"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c50f95a82b94421c964fae4c27c0242890a20fe67d203d127e84fbb8013855f5"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7f3eff9961b5d2644bcf1616c606e93baa2d6b349e8aa8b035f654df252c8c67"}, + {file = "ruff-0.7.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8963cab06d130c4df2fd52c84e9f10d297826d2e8169ae0c798b6221be1d1d2"}, + {file = "ruff-0.7.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:61b46049d6edc0e4317fb14b33bd693245281a3007288b68a3f5b74a22a0746d"}, + {file = "ruff-0.7.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:10ebce7696afe4644e8c1a23b3cf8c0f2193a310c18387c06e583ae9ef284de2"}, + {file = "ruff-0.7.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3f36d56326b3aef8eeee150b700e519880d1aab92f471eefdef656fd57492aa2"}, + {file = "ruff-0.7.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5d024301109a0007b78d57ab0ba190087b43dce852e552734ebf0b0b85e4fb16"}, + {file = "ruff-0.7.3-py3-none-win32.whl", hash = "sha256:4ba81a5f0c5478aa61674c5a2194de8b02652f17addf8dfc40c8937e6e7d79fc"}, + {file = "ruff-0.7.3-py3-none-win_amd64.whl", hash = "sha256:588a9ff2fecf01025ed065fe28809cd5a53b43505f48b69a1ac7707b1b7e4088"}, + {file = "ruff-0.7.3-py3-none-win_arm64.whl", hash = "sha256:1713e2c5545863cdbfe2cbce21f69ffaf37b813bfd1fb3b90dc9a6f1963f5a8c"}, + {file = "ruff-0.7.3.tar.gz", hash = "sha256:e1d1ba2e40b6e71a61b063354d04be669ab0d39c352461f3d789cac68b54a313"}, ] [[package]] @@ -824,13 +824,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "werkzeug" -version = "3.1.2" +version = "3.1.3" description = "The comprehensive WSGI web application library." optional = false python-versions = ">=3.9" files = [ - {file = "werkzeug-3.1.2-py3-none-any.whl", hash = "sha256:4f7d1a5de312c810a8a2c6f0b47e9f6a7cffb7c8322def35e4d4d9841ff85597"}, - {file = "werkzeug-3.1.2.tar.gz", hash = "sha256:f471a4cd167233077e9d2a8190c3471c5bc520c636a9e3c1e9300c33bced03bc"}, + {file = "werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e"}, + {file = "werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746"}, ] [package.dependencies] diff --git a/tests/test_action_helper.py b/tests/test_action_helper.py deleted file mode 100644 index 9a3c03d..0000000 --- a/tests/test_action_helper.py +++ /dev/null @@ -1,106 +0,0 @@ -import unittest -from typing import Sequence - -import boto3 -from click.testing import CliRunner -from moto import mock_aws - -from actions_helper.commands.get_active_task_definition_by_tag import NonSingleValueError, Tag, format_tags -from actions_helper.main import cmd_get_active_task_definition_arn_by_tag as command - -TEST_AWS_DEFAULT_REGION = "us-east-1" -TEST_APPLICATION_ID = "foo" - - -@mock_aws -class GetTaskDefinitionByTagTestCase(unittest.TestCase): - def setUp(self): - self.client = boto3.Session(region_name=TEST_AWS_DEFAULT_REGION).client("ecs") - self.runner = CliRunner( - env={ - "AWS_DEFAULT_REGION": TEST_AWS_DEFAULT_REGION, - }, - ) - self.pulumi_tag = {"key": "created_by", "value": "Pulumi"} - self.pulumi_command_arg = f"--application-id {TEST_APPLICATION_ID} --tags created_by:Pulumi" - - def create_task_definition(self, tags: Sequence[dict[str, str]]) -> dict: - return self.client.register_task_definition( - family=TEST_APPLICATION_ID, - containerDefinitions=[], - tags=tags, - ) - - def test_format_tags(self): - with self.subTest("Invalid tag format"), self.assertRaises(expected_exception=ValueError): - format_tags("invalid-tag") - format_tags("key:value key:value") - - with self.subTest("valid tag format"): - tags = format_tags("key:value, key2:value2") - self.assertEqual(len(tags), 2) - self.assertTupleEqual(tags, (Tag("key", "value"), Tag("key2", "value2"))) - - def test_get_active_definition_by_tag(self): - with self.subTest("Tag does not exist"): - result = self.runner.invoke( - command, - args=self.pulumi_command_arg, - ) - self.assertEqual(result.exit_code, 1) - self.assertIsInstance(result.exception, NonSingleValueError) - - with self.subTest("Tag exist"): - task_definition = self.create_task_definition(tags=[self.pulumi_tag])["taskDefinition"] - - result = self.runner.invoke( - command, - args=self.pulumi_command_arg, - ) - - self.assertEqual(result.exit_code, 0) - self.assertEqual(result.output.strip(), task_definition["taskDefinitionArn"]) - - with self.subTest("More than 1 definition with same Tag exist"): - self.create_task_definition(tags=[self.pulumi_tag]) - result = self.runner.invoke( - command, - args=self.pulumi_command_arg, - ) - self.assertEqual(result.exit_code, 1) - self.assertIsInstance(result.exception, NonSingleValueError) - - def test_initial_deployment_tag_exist(self): - with self.subTest("Initial deployment, Pulumi's definition does not exist but Tag exist"): - created_by = "Github Action Deployment" - task_definition = self.create_task_definition(tags=[{"key": "created_by", "value": created_by}])[ - "taskDefinition" - ] - result = self.runner.invoke( - command, - args=f'--application-id foo --tags "created_by:{created_by}" --allow-initial-deployment', - ) - self.assertEqual(result.exit_code, 0) - self.assertEqual(result.output.strip(), task_definition["taskDefinitionArn"]) - - def test_initial_deployment_tag_does_not_exist(self): - created_by = "Github Action Deployment" - command_args = ( - f"--application-id {TEST_APPLICATION_ID} --tags 'created_by:{created_by}' --allow-initial-deployment" - ) - with self.subTest("Initial deployment, Tag and Pulumi's definition does not exist"): - result = self.runner.invoke( - command, - args=command_args, - ) - self.assertEqual(result.exit_code, 1) - self.assertIsInstance(result.exception, NonSingleValueError) - - with self.subTest("Initial deployment, Tag does not exist but Pulumi's definition exist"): - self.create_task_definition(tags=[self.pulumi_tag]) - result = self.runner.invoke( - command, - args=command_args, - ) - self.assertEqual(result.exit_code, 0) - self.assertEqual(result.output.strip(), "") diff --git a/tests/test_create_task_definition.py b/tests/test_create_task_definition.py new file mode 100644 index 0000000..b318ff4 --- /dev/null +++ b/tests/test_create_task_definition.py @@ -0,0 +1,113 @@ +import unittest +from typing import Any, Sequence +from unittest.mock import patch + +import boto3 +from moto import mock_aws +from moto.core import DEFAULT_ACCOUNT_ID as ACCOUNT_ID + +from actions_helper.commands.create_task_definition import create_task_definition, get_rendered_task_definition +from actions_helper.utils import PLACEHOLDER_TEXT +from tests.utils import TEST_APPLICATION_ID, TEST_AWS_DEFAULT_REGION, TEST_KEYS_TO_DELETE_FROM_TASK_DEFINITION + + +@mock_aws +class CreateTaskDefinitionTestCase(unittest.TestCase): + def setUp(self): + self.ecs_client = boto3.Session(region_name=TEST_AWS_DEFAULT_REGION).client("ecs") + self.pulumi_tag = {"key": "created_by", "value": "Pulumi"} + self.name_tag = {"key": "Name", "value": TEST_APPLICATION_ID} + self.image_uri = f"{ACCOUNT_ID}.dkr.ecr.{TEST_AWS_DEFAULT_REGION}.amazonaws.com/test/dummy:master-e0428b7" + self.container_definition = { + "name": "web", + "image": self.image_uri, + "cpu": 1024, + "memory": 400, + } + + def create_task_definition( + self, + tags: Sequence[dict[str, str]], + container_definitions: Sequence[dict[str, Any]], + ) -> dict: + return self.ecs_client.register_task_definition( + family=TEST_APPLICATION_ID, + requiresCompatibilities=["FARGATE"], + containerDefinitions=container_definitions, + networkMode="awsvpc", + runtimePlatform={"cpuArchitecture": "X86_64", "operatingSystemFamily": "LINUX"}, + tags=tags, + ) + + def test_rendered_task_definition_invalid(self): + with ( + self.subTest("Task definition does not exist"), + self.assertRaises(self.ecs_client.exceptions.ClientException), + ): + get_rendered_task_definition(ecs_client=self.ecs_client, task_definition_arn="dummy", image_uri="dummy") + + with self.subTest("Expects exactly one task definition"), self.assertRaises(SystemExit): + get_rendered_task_definition( + ecs_client=self.ecs_client, + task_definition_arn=self.create_task_definition(container_definitions=[], tags=[self.pulumi_tag])[ + "taskDefinition" + ]["taskDefinitionArn"], + image_uri=self.image_uri, + ) + + with ( + self.subTest(f"containerDefinitions 'image' not equals to placeholder text - {PLACEHOLDER_TEXT}"), + self.assertRaises(SystemExit), + ): + get_rendered_task_definition( + ecs_client=self.ecs_client, + task_definition_arn=self.create_task_definition( + tags=[self.pulumi_tag], + container_definitions=[self.container_definition], + )["taskDefinition"]["taskDefinitionArn"], + image_uri=self.image_uri, + ) + + @patch( + "actions_helper.commands.create_task_definition.KEYS_TO_DELETE_FROM_TASK_DEFINITION", + TEST_KEYS_TO_DELETE_FROM_TASK_DEFINITION, + ) + def test_rendered_task_definition(self): + task_definition = get_rendered_task_definition( + ecs_client=self.ecs_client, + task_definition_arn=self.create_task_definition( + container_definitions=[self.container_definition | {"image": PLACEHOLDER_TEXT}], + tags=[self.pulumi_tag], + )["taskDefinition"]["taskDefinitionArn"], + image_uri=self.image_uri, + ) + + (replaced_container_definition_image_uri,) = { + container_definition["image"] for container_definition in task_definition["containerDefinitions"] + } + self.assertEqual(replaced_container_definition_image_uri, self.image_uri) + + @patch( + "actions_helper.commands.create_task_definition.KEYS_TO_DELETE_FROM_TASK_DEFINITION", + TEST_KEYS_TO_DELETE_FROM_TASK_DEFINITION, + ) + def test_create_task_definition(self): + self.create_task_definition( + container_definitions=[self.container_definition | {"image": PLACEHOLDER_TEXT}], + tags=[self.pulumi_tag, self.name_tag], + ) + gh_action_task_definition = self.create_task_definition( + container_definitions=[self.container_definition], + tags=[{"key": "created_by", "value": "GitHub Actions Deployment"}, self.name_tag], + )["taskDefinition"] + output = create_task_definition( + ecs_client=self.ecs_client, + application_id=TEST_APPLICATION_ID, + image_uri=self.image_uri, + deployment_tag="GitHub Actions Deployment", + ) + self.assertEqual(output.previous_task_definition_arn, gh_action_task_definition["taskDefinitionArn"]) + latest_task_definition = self.ecs_client.describe_task_definition( + taskDefinition=output.latest_task_definition_arn, + )["taskDefinition"] + self.assertEqual(latest_task_definition["revision"], gh_action_task_definition["revision"] + 1) diff --git a/tests/test_deregister_task_definition.py b/tests/test_deregister_task_definition.py new file mode 100644 index 0000000..b0b18cd --- /dev/null +++ b/tests/test_deregister_task_definition.py @@ -0,0 +1,230 @@ +import unittest +from typing import Literal +from unittest.mock import call, patch + +import boto3 +from moto import mock_aws + +from actions_helper.commands.deregister_task_definition import deregister_task_definition +from actions_helper.outputs import CreateTaskDefinitionOutput +from tests.utils import ( + TEST_AWS_DEFAULT_REGION, +) + + +@mock_aws +class DeregisterTaskDefinitionTestCase(unittest.TestCase): + ecs_client = boto3.Session(region_name=TEST_AWS_DEFAULT_REGION).client("ecs") + + def create_task_definition(self, application_id: str) -> dict: + return self.ecs_client.register_task_definition(family=application_id, containerDefinitions=[]) + + @staticmethod + def describe_service_return_value(status: Literal["ACTIVE", "INACTIVE", "PRIMARY"], arn: str): + return {"services": [{"deployments": [{"status": status, "taskDefinition": arn}]}]} + + @patch.object(ecs_client, "describe_services", return_value=describe_service_return_value("PRIMARY", arn="")) + def test_deregister_task_definition_no_task_definitions(self, ecs_describe_services_patch): + with ( + patch.object(self.ecs_client, "deregister_task_definition") as ecs_deregister_patch, + self.assertRaises(SystemExit), + ): + deregister_task_definition( + ecs_client=self.ecs_client, + service="", + cluster="", + production_task_definition_output=None, + preflight_task_definition_output=None, + local_task_definition_output=None, + ) + ecs_describe_services_patch.assert_called() + ecs_deregister_patch.assert_not_called() + + @patch.object(ecs_client, "describe_services", return_value=describe_service_return_value("PRIMARY", arn="")) + def test_deregister_task_definition_failed_pipeline(self, ecs_describe_services_patch): + local_exec_task_definition_1 = self.create_task_definition(application_id="local-exec") + with ( + self.subTest("Failed initial deployment"), + self.assertRaises(SystemExit), + patch.object(self.ecs_client, "deregister_task_definition") as ecs_deregister_patch, + ): + deregister_task_definition( + ecs_client=self.ecs_client, + service="", + cluster="", + local_task_definition_output=CreateTaskDefinitionOutput( + previous_task_definition_arn="", + latest_task_definition_arn=local_exec_task_definition_1["taskDefinition"]["taskDefinitionArn"], + ), + production_task_definition_output=None, + preflight_task_definition_output=None, + ) + ecs_describe_services_patch.assert_called() + ecs_deregister_patch.assert_called_once_with( + local_exec_task_definition_1["taskDefinition"]["taskDefinitionArn"], + ) + + with ( + self.subTest("Failed deployment"), + self.assertRaises(SystemExit), + patch.object(self.ecs_client, "deregister_task_definition") as ecs_deregister_patch, + ): + local_exec_task_definition_2 = self.create_task_definition(application_id="local-exec") + deregister_task_definition( + ecs_client=self.ecs_client, + service="", + cluster="", + local_task_definition_output=CreateTaskDefinitionOutput( + previous_task_definition_arn=local_exec_task_definition_1["taskDefinition"]["taskDefinitionArn"], + latest_task_definition_arn=local_exec_task_definition_2["taskDefinition"]["taskDefinitionArn"], + ), + production_task_definition_output=None, + preflight_task_definition_output=None, + ) + ecs_describe_services_patch.assert_called() + ecs_deregister_patch.assert_called_once_with( + local_exec_task_definition_2["taskDefinition"]["taskDefinitionArn"], + ) + + def test_deregister_task_definition_successful_initial_deploy(self): + local_task_definition_1 = self.create_task_definition(application_id="local-exec") + production_task_definition_1 = self.create_task_definition(application_id="production") + preflight_task_definition_1 = self.create_task_definition(application_id="preflight") + + response = self.ecs_client.list_task_definitions() + self.assertEqual(len(response["taskDefinitionArns"]), 3) + + with ( + self.subTest("Successful initial deployment"), + patch.object( + self.ecs_client, + "describe_services", + return_value=self.describe_service_return_value( + "PRIMARY", + arn=production_task_definition_1["taskDefinition"]["taskDefinitionArn"], + ), + ) as ecs_describe_services_patch, + patch.object(self.ecs_client, "deregister_task_definition") as ecs_deregister_patch, + ): + deregister_task_definition( + ecs_client=self.ecs_client, + service="", + cluster="", + local_task_definition_output=CreateTaskDefinitionOutput( + previous_task_definition_arn="", + latest_task_definition_arn=local_task_definition_1["taskDefinition"]["taskDefinitionArn"], + ), + production_task_definition_output=CreateTaskDefinitionOutput( + previous_task_definition_arn="", + latest_task_definition_arn=production_task_definition_1["taskDefinition"]["taskDefinitionArn"], + ), + preflight_task_definition_output=CreateTaskDefinitionOutput( + previous_task_definition_arn="", + latest_task_definition_arn=preflight_task_definition_1["taskDefinition"]["taskDefinitionArn"], + ), + ) + + ecs_describe_services_patch.assert_called() + ecs_deregister_patch.assert_not_called() + response = self.ecs_client.list_task_definitions() + self.assertEqual(len(response["taskDefinitionArns"]), 3) + + def test_deregister_task_definition_successful_deploy(self): + local_task_definition_1 = self.create_task_definition(application_id="local-exec") + production_task_definition_1 = self.create_task_definition(application_id="production") + preflight_task_definition_1 = self.create_task_definition(application_id="preflight") + local_task_definition_2 = self.create_task_definition(application_id="local-exec") + production_task_definition_2 = self.create_task_definition(application_id="production") + preflight_task_definition_2 = self.create_task_definition(application_id="preflight") + with ( + self.subTest("Successful deployment"), + patch.object( + self.ecs_client, + "describe_services", + return_value=self.describe_service_return_value( + "PRIMARY", + arn=production_task_definition_2["taskDefinition"]["taskDefinitionArn"], + ), + ) as ecs_describe_services_patch, + patch.object(self.ecs_client, "deregister_task_definition") as ecs_deregister_patch, + ): + deregister_task_definition( + ecs_client=self.ecs_client, + service="", + cluster="", + local_task_definition_output=CreateTaskDefinitionOutput( + previous_task_definition_arn=local_task_definition_1["taskDefinition"]["taskDefinitionArn"], + latest_task_definition_arn=local_task_definition_2["taskDefinition"]["taskDefinitionArn"], + ), + production_task_definition_output=CreateTaskDefinitionOutput( + previous_task_definition_arn=production_task_definition_1["taskDefinition"]["taskDefinitionArn"], + latest_task_definition_arn=production_task_definition_2["taskDefinition"]["taskDefinitionArn"], + ), + preflight_task_definition_output=CreateTaskDefinitionOutput( + previous_task_definition_arn=preflight_task_definition_1["taskDefinition"]["taskDefinitionArn"], + latest_task_definition_arn=preflight_task_definition_2["taskDefinition"]["taskDefinitionArn"], + ), + ) + + ecs_describe_services_patch.assert_called() + ecs_deregister_patch.assert_has_calls( + [ + call( + taskDefinition=local_task_definition_1["taskDefinition"]["taskDefinitionArn"], + ), + call(taskDefinition=production_task_definition_1["taskDefinition"]["taskDefinitionArn"]), + call(taskDefinition=preflight_task_definition_1["taskDefinition"]["taskDefinitionArn"]), + ], + any_order=True, + ) + + def test_deregister_task_definition_failed_deployment(self): + local_task_definition_1 = self.create_task_definition(application_id="local-exec") + production_task_definition_1 = self.create_task_definition(application_id="production") + preflight_task_definition_1 = self.create_task_definition(application_id="preflight") + local_task_definition_2 = self.create_task_definition(application_id="local-exec") + production_task_definition_2 = self.create_task_definition(application_id="production") + preflight_task_definition_2 = self.create_task_definition(application_id="preflight") + + with ( + self.subTest("Failed deployment, roll backed to previous"), + patch.object( + self.ecs_client, + "describe_services", + return_value=self.describe_service_return_value( + "PRIMARY", + arn=production_task_definition_1["taskDefinition"]["taskDefinitionArn"], + ), + ) as ecs_describe_services_patch, + patch.object(self.ecs_client, "deregister_task_definition") as ecs_deregister_patch, + self.assertRaises(SystemExit), + ): + deregister_task_definition( + ecs_client=self.ecs_client, + service="", + cluster="", + local_task_definition_output=CreateTaskDefinitionOutput( + previous_task_definition_arn=local_task_definition_1["taskDefinition"]["taskDefinitionArn"], + latest_task_definition_arn=local_task_definition_2["taskDefinition"]["taskDefinitionArn"], + ), + production_task_definition_output=CreateTaskDefinitionOutput( + previous_task_definition_arn=production_task_definition_1["taskDefinition"]["taskDefinitionArn"], + latest_task_definition_arn=production_task_definition_2["taskDefinition"]["taskDefinitionArn"], + ), + preflight_task_definition_output=CreateTaskDefinitionOutput( + previous_task_definition_arn=preflight_task_definition_1["taskDefinition"]["taskDefinitionArn"], + latest_task_definition_arn=preflight_task_definition_2["taskDefinition"]["taskDefinitionArn"], + ), + ) + + ecs_describe_services_patch.assert_called() + ecs_deregister_patch.assert_has_calls( + [ + call( + taskDefinition=local_task_definition_2["taskDefinition"]["taskDefinitionArn"], + ), + call(taskDefinition=production_task_definition_2["taskDefinition"]["taskDefinitionArn"]), + call(taskDefinition=preflight_task_definition_2["taskDefinition"]["taskDefinitionArn"]), + ], + any_order=True, + ) diff --git a/tests/test_get_active_task_definition_by_tag.py b/tests/test_get_active_task_definition_by_tag.py new file mode 100644 index 0000000..273e9b6 --- /dev/null +++ b/tests/test_get_active_task_definition_by_tag.py @@ -0,0 +1,98 @@ +import unittest +from typing import Sequence + +import boto3 +from moto import mock_aws + +from actions_helper.commands.get_active_task_definition_by_tag import ( + NonSingleValueError, + Tag, + format_tags, + get_active_task_definition_arn_by_tag, +) +from tests.utils import TEST_APPLICATION_ID, TEST_AWS_DEFAULT_REGION + + +@mock_aws +class GetTaskDefinitionByTagTestCase(unittest.TestCase): + def setUp(self): + self.client = boto3.Session(region_name=TEST_AWS_DEFAULT_REGION).client("ecs") + self.pulumi_tag = {"key": "created_by", "value": "Pulumi"} + + def create_task_definition(self, tags: Sequence[dict[str, str]]) -> dict: + return self.client.register_task_definition(family=TEST_APPLICATION_ID, containerDefinitions=[], tags=tags) + + def test_format_tags(self): + with self.subTest("Invalid tag format"), self.assertRaises(expected_exception=ValueError): + format_tags("invalid-tag") + format_tags("key:value key:value") + + with self.subTest("valid tag format"): + tags = format_tags("key:value, key2:value2") + self.assertEqual(len(tags), 2) + self.assertTupleEqual(tags, (Tag("key", "value"), Tag("key2", "value2"))) + + def test_get_active_definition_by_tag(self): + with self.subTest("Tag does not exist"), self.assertRaises(NonSingleValueError): + get_active_task_definition_arn_by_tag( + ecs_client=self.client, + task_definition_family_prefix=TEST_APPLICATION_ID, + task_definition_tags="created_by:Pulumi", + allow_initial_deployment=False, + ) + + with self.subTest("Tag exist"): + task_definition = self.create_task_definition(tags=[self.pulumi_tag])["taskDefinition"] + arn = get_active_task_definition_arn_by_tag( + ecs_client=self.client, + task_definition_family_prefix=TEST_APPLICATION_ID, + task_definition_tags=f"{self.pulumi_tag['key']}:{self.pulumi_tag['value']}", + allow_initial_deployment=False, + ) + self.assertEqual(arn, task_definition["taskDefinitionArn"]) + + with self.subTest("More than 1 definition with same Tag exist"), self.assertRaises(NonSingleValueError): + self.create_task_definition(tags=[self.pulumi_tag]) + get_active_task_definition_arn_by_tag( + ecs_client=self.client, + task_definition_family_prefix=TEST_APPLICATION_ID, + task_definition_tags=f"{self.pulumi_tag['key']}:{self.pulumi_tag['value']}", + allow_initial_deployment=False, + ) + + def test_initial_deployment_tag_exist(self): + with self.subTest("Initial deployment, Pulumi's definition does not exist but Tag exist"): + created_by = "Github Action Deployment" + task_definition = self.create_task_definition(tags=[{"key": "created_by", "value": created_by}])[ + "taskDefinition" + ] + arn = get_active_task_definition_arn_by_tag( + ecs_client=self.client, + task_definition_family_prefix="foo", + task_definition_tags=f"created_by:{created_by}", + allow_initial_deployment=True, + ) + self.assertEqual(arn, task_definition["taskDefinitionArn"]) + + def test_initial_deployment_tag_does_not_exist(self): + created_by = "Github Action Deployment" + with ( + self.subTest("Initial deployment, Tag and Pulumi's definition does not exist"), + self.assertRaises(NonSingleValueError), + ): + get_active_task_definition_arn_by_tag( + ecs_client=self.client, + task_definition_family_prefix=TEST_APPLICATION_ID, + task_definition_tags=f"created_by:{created_by}", + allow_initial_deployment=True, + ) + + with self.subTest("Initial deployment, Tag does not exist but Pulumi's definition exist"): + self.create_task_definition(tags=[self.pulumi_tag]) + arn = get_active_task_definition_arn_by_tag( + ecs_client=self.client, + task_definition_family_prefix=TEST_APPLICATION_ID, + task_definition_tags=f"created_by:{created_by}", + allow_initial_deployment=True, + ) + self.assertEqual(arn, "") diff --git a/tests/test_get_image_uri.py b/tests/test_get_image_uri.py new file mode 100644 index 0000000..02a785e --- /dev/null +++ b/tests/test_get_image_uri.py @@ -0,0 +1,58 @@ +import json +import unittest + +import boto3 +from moto import mock_aws + +from actions_helper.commands.get_image_uri import get_image_uri +from tests.utils import TEST_AWS_DEFAULT_REGION + + +@mock_aws +class GetImageUriTestCase(unittest.TestCase): + def setUp(self): + self.ecr_client = boto3.Session(region_name=TEST_AWS_DEFAULT_REGION).client("ecr") + self.image_tag = "master-e0428b7" + self.repository_name = "test/dummy" + + def create_image_repository(self) -> dict: + return self.ecr_client.create_repository(repositoryName=self.repository_name) + + def create_image(self): + self.ecr_client.put_image( + repositoryName=self.repository_name, + imageManifest=json.dumps( + { + "schemaVersion": 2, + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "repositoryName": self.repository_name, + "imageTag": self.image_tag, + }, + ), + imageTag=self.image_tag, + ) + + def test_image_tag_does_not_exist(self): + self.create_image_repository() + self.create_image() + with self.assertRaises(expected_exception=self.ecr_client.exceptions.ImageNotFoundException): + get_image_uri( + ecr_client=self.ecr_client, + ecr_repository=self.repository_name, + tag="dummy", + ) + + def test_image_does_not_exist(self): + self.create_image_repository() + with self.assertRaises(expected_exception=self.ecr_client.exceptions.ImageNotFoundException): + get_image_uri( + ecr_client=self.ecr_client, + ecr_repository=self.repository_name, + tag="dummy", + ) + + def test_get_image_uri(self): + repository_uri = self.create_image_repository()["repository"]["repositoryUri"] + self.create_image() + image_uri = get_image_uri(ecr_client=self.ecr_client, ecr_repository=self.repository_name, tag=self.image_tag) + self.assertEqual(image_uri, f"{repository_uri}:{self.image_tag}") diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..8926713 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,142 @@ +import json +import unittest +from typing import Any, Sequence +from unittest.mock import patch + +import boto3 +from click.testing import CliRunner +from moto import mock_aws + +from actions_helper.main import cmd_ecs_deploy +from actions_helper.utils import PLACEHOLDER_TEXT +from tests.test_run_preflight import setup_ecs_service +from tests.utils import TEST_APPLICATION_ID, TEST_AWS_DEFAULT_REGION, TEST_KEYS_TO_DELETE_FROM_TASK_DEFINITION + +TEST_ENVIRONMENT = "dev" + + +@mock_aws +class CmdECSDeployTestCase(unittest.TestCase): + def setUp(self): + self.ecs_client = boto3.Session(region_name=TEST_AWS_DEFAULT_REGION).client("ecs") + self.ecr_client = boto3.Session(region_name=TEST_AWS_DEFAULT_REGION).client("ecr") + self.runner = CliRunner(env={"AWS_DEFAULT_REGION": TEST_AWS_DEFAULT_REGION}) + self.pulumi_tag = {"key": "created_by", "value": "Pulumi"} + + self.repository_name = TEST_APPLICATION_ID + self.image_tag = "master-e0428b7" + self.name_tag = {"key": "Name", "value": TEST_APPLICATION_ID} + self.container_definition = { + "name": "web", + "image": PLACEHOLDER_TEXT, + "cpu": 1024, + "memory": 400, + } + + self.pulumi_command_args = { + "--environment": TEST_ENVIRONMENT, + "--ecr-repository": TEST_APPLICATION_ID, + "--deployment-tag": "Github-Action", + "--image-tag": self.image_tag, + "--run-preflight": False, + } + + self.ecr_client.create_repository(repositoryName=self.repository_name) + self.ecr_client.put_image( + repositoryName=self.repository_name, + imageManifest=json.dumps( + { + "schemaVersion": 2, + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "repositoryName": self.repository_name, + "imageTag": self.image_tag, + }, + ), + imageTag=self.image_tag, + ) + + self.create_task_definition( + family=f"{TEST_APPLICATION_ID}-preflight-{TEST_ENVIRONMENT}", + tags=[ + self.pulumi_tag, + {"key": "Name", "value": f"{TEST_APPLICATION_ID}-preflight-{TEST_ENVIRONMENT}"}, + ], + ) + + self.create_task_definition( + family=f"{TEST_APPLICATION_ID}-local-exec-{TEST_ENVIRONMENT}", + tags=[ + self.pulumi_tag, + {"key": "Name", "value": f"{TEST_APPLICATION_ID}-local-exec-{TEST_ENVIRONMENT}"}, + ], + ) + self.create_task_definition( + family=f"{TEST_APPLICATION_ID}-{TEST_ENVIRONMENT}", + tags=[ + self.pulumi_tag, + {"key": "Name", "value": f"{TEST_APPLICATION_ID}-{TEST_ENVIRONMENT}"}, + ], + ) + + @staticmethod + def make_args(command_args: dict[str, Any]) -> str: + return " ".join([f"{key} {value}" for key, value in command_args.items()]) + + def create_task_definition(self, family: str, tags: Sequence[dict[str, str]]) -> dict: + return self.ecs_client.register_task_definition( + family=family, + requiresCompatibilities=["FARGATE"], + containerDefinitions=[self.container_definition], + networkMode="awsvpc", + runtimePlatform={"cpuArchitecture": "X86_64", "operatingSystemFamily": "LINUX"}, + tags=tags, + ) + + def test_allow_feature_branch_wrong_environment(self): + result = self.runner.invoke( + cmd_ecs_deploy, + args=f"{self.make_args(self.pulumi_command_args | {"--environment": "live"})} " + f"--allow-feature-branch-deployment", + ) + self.assertIsInstance(result.exception, RuntimeError) + self.assertEqual(result.exit_code, 1) + + @patch( + "actions_helper.commands.create_task_definition.KEYS_TO_DELETE_FROM_TASK_DEFINITION", + TEST_KEYS_TO_DELETE_FROM_TASK_DEFINITION, + ) + def test_cmd_ecs_deploy_without_preflight(self): + with ( + patch("actions_helper.main.run_preflight_container") as run_preflight_mock, + patch("actions_helper.main.deregister_task_definition") as deregister_task_definition_mock, + ): + setup_ecs_service( + ecs_client=self.ecs_client, + ec2_client=boto3.resource("ec2", region_name=TEST_AWS_DEFAULT_REGION), + ) + result = self.runner.invoke(cmd_ecs_deploy, args=self.make_args(self.pulumi_command_args)) + + run_preflight_mock.assert_not_called() + deregister_task_definition_mock.assert_called() + self.assertEqual(result.exit_code, 0) + + @patch( + "actions_helper.commands.create_task_definition.KEYS_TO_DELETE_FROM_TASK_DEFINITION", + TEST_KEYS_TO_DELETE_FROM_TASK_DEFINITION, + ) + def test_cmd_ecs_deploy_with_preflight(self): + with ( + patch("actions_helper.main.run_preflight_container") as run_preflight_mock, + patch("actions_helper.main.deregister_task_definition") as deregister_task_definition_mock, + ): + setup_ecs_service( + ecs_client=self.ecs_client, + ec2_client=boto3.resource("ec2", region_name=TEST_AWS_DEFAULT_REGION), + ) + result = self.runner.invoke( + cmd_ecs_deploy, + args=self.make_args(self.pulumi_command_args | {"--run-preflight": True}), + ) + run_preflight_mock.assert_called() + deregister_task_definition_mock.assert_called() + self.assertEqual(result.exit_code, 0) diff --git a/tests/test_run_preflight.py b/tests/test_run_preflight.py new file mode 100644 index 0000000..3d16674 --- /dev/null +++ b/tests/test_run_preflight.py @@ -0,0 +1,166 @@ +import json +import unittest +from typing import Any, Sequence +from unittest.mock import patch + +import boto3 +from botocore.client import BaseClient +from moto import mock_aws +from moto.ec2 import utils as ec2_utils +from moto.moto_api import state_manager + +from actions_helper.commands.run_preflight import run_preflight_container +from actions_helper.utils import PLACEHOLDER_TEXT +from tests.utils import ( + TEST_APPLICATION_ID, + TEST_AWS_DEFAULT_REGION, + TEST_CLUSTER, + TEST_KEYS_TO_DELETE_FROM_TASK_DEFINITION, + TEST_SERVICE, +) + + +def setup_ecs_service(ecs_client: BaseClient, ec2_client: BaseClient): + vpc = ec2_client.create_vpc(CidrBlock="10.0.0.0/16") + subnet = ec2_client.create_subnet(VpcId=vpc.id, CidrBlock="10.0.0.0/18") + security_group = ec2_client.create_security_group(VpcId=vpc.id, GroupName="test-ecs", Description="moto ecs") + + ecs_client.create_cluster(clusterName=TEST_CLUSTER) + (test_instance,) = ec2_client.create_instances(ImageId="ami-12c6146b", MinCount=1, MaxCount=1) + ecs_client.register_container_instance( + cluster=TEST_CLUSTER, + instanceIdentityDocument=json.dumps(ec2_utils.generate_instance_identity_document(test_instance)), + ) + ecs_client.create_service( + cluster=TEST_CLUSTER, + serviceName=TEST_SERVICE, + desiredCount=2, + loadBalancers=[], + deploymentController={"type": "ECS"}, + launchType="FARGATE", + networkConfiguration={"awsvpcConfiguration": {"subnets": [subnet.id], "securityGroups": [security_group.id]}}, + ) + + return subnet, security_group + + +def patch_network_config(*arg, **kwarg): + subnet, security_group = setup_ecs_service( + ecs_client=boto3.Session(region_name=TEST_AWS_DEFAULT_REGION).client("ecs"), + ec2_client=boto3.resource("ec2", region_name=TEST_AWS_DEFAULT_REGION), + ) + services = boto3.Session(region_name=TEST_AWS_DEFAULT_REGION).client("ecs").describe_services(*arg, **kwarg) + return { + "services": [ + service + | { + "networkConfiguration": { + "awsvpcConfiguration": {"subnets": [subnet.id], "securityGroups": [security_group.id]}, + }, + } + for service in services["services"] + ], + } + + +@mock_aws +@patch( + "actions_helper.commands.create_task_definition.KEYS_TO_DELETE_FROM_TASK_DEFINITION", + TEST_KEYS_TO_DELETE_FROM_TASK_DEFINITION, +) +class RunPreflightTestCase(unittest.TestCase): + def setUp(self): + self.ecs_client = boto3.Session(region_name=TEST_AWS_DEFAULT_REGION).client("ecs") + self.name_tag = {"key": "Name", "value": TEST_APPLICATION_ID} + self.image_uri = "docker/hello-world:latest" + self.container_definition = { + "name": "web", + "image": self.image_uri, + "cpu": 1024, + "memory": 400, + } + + self.create_task_definition( + container_definitions=[self.container_definition | {"image": PLACEHOLDER_TEXT}], + tags=[{"key": "created_by", "value": "Pulumi"}, self.name_tag], + ) + self.gh_action_task_definition = self.create_task_definition( + container_definitions=[self.container_definition], + tags=[{"key": "created_by", "value": "GitHub Actions Deployment"}, self.name_tag], + ) + + def create_task_definition( + self, + tags: Sequence[dict[str, str]], + container_definitions: Sequence[dict[str, Any]], + ) -> dict: + return self.ecs_client.register_task_definition( + family=TEST_APPLICATION_ID, + containerDefinitions=container_definitions, + tags=tags, + ) + + def test_failed_run_preflight(self): + def patch_task_container_to_fail(*arg, **kwarg): + tasks = boto3.Session(region_name=TEST_AWS_DEFAULT_REGION).client("ecs").describe_tasks(*arg, **kwarg) + return { + "tasks": [ + task | {"containers": [{"exitCode": 1, "reason": "curl command not found"}]} + for task in tasks["tasks"] + ], + } + + with ( + self.subTest("Failed preflight"), + patch.object(self.ecs_client, "describe_services", side_effect=patch_network_config), + patch.object(self.ecs_client, "describe_tasks", side_effect=patch_task_container_to_fail), + self.assertRaises(SystemExit), + ): + state_manager.set_transition( + model_name="ecs::task", + transition={"progression": "immediate"}, + ) + + run_preflight_container( + ecs_client=self.ecs_client, + application_id=TEST_APPLICATION_ID, + cluster=TEST_CLUSTER, + service=TEST_SERVICE, + image_uri=self.image_uri, + deployment_tag="GitHub Actions Deployment", + ) + + def test_run_preflight(self): + def patch_task_container(*arg, **kwarg): + tasks = boto3.Session(region_name=TEST_AWS_DEFAULT_REGION).client("ecs").describe_tasks(*arg, **kwarg) + return {"tasks": [task | {"containers": [{"exitCode": 0, "reason": ""}]} for task in tasks["tasks"]]} + + with ( + self.subTest("Successful preflight"), + patch.object(self.ecs_client, "describe_services", side_effect=patch_network_config), + patch.object(self.ecs_client, "describe_tasks", side_effect=patch_task_container), + ): + state_manager.set_transition(model_name="ecs::task", transition={"progression": "immediate"}) + output = run_preflight_container( + ecs_client=self.ecs_client, + application_id=TEST_APPLICATION_ID, + cluster=TEST_CLUSTER, + service=TEST_SERVICE, + image_uri=self.image_uri, + deployment_tag="GitHub Actions Deployment", + ) + + self.assertEqual( + output.previous_task_definition_arn, + self.gh_action_task_definition["taskDefinition"]["taskDefinitionArn"], + ) + self.assertTrue( + output.latest_task_definition_arn.endswith( + f"{self.gh_action_task_definition["taskDefinition"]["revision"] + 1}", + ), + ) + + response = self.ecs_client.describe_tasks(cluster=TEST_CLUSTER, tasks=[output.preflight_task_arn]) + self.assertEqual(len(response["tasks"]), 1) + self.assertEqual(response["tasks"][0]["desiredStatus"], "RUNNING") + self.assertEqual(response["tasks"][0]["lastStatus"], "STOPPED") diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..9b1afdb --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,11 @@ +TEST_AWS_DEFAULT_REGION = "us-east-1" +TEST_APPLICATION_ID = "foo" +TEST_CLUSTER = "dev" +TEST_SERVICE = f"{TEST_APPLICATION_ID}-{TEST_CLUSTER}" + +TEST_KEYS_TO_DELETE_FROM_TASK_DEFINITION = [ + "taskDefinitionArn", + "revision", + "status", + "compatibilities", +]