From db0ae4cae6908dc57a220f21447ce5840ab7d0e7 Mon Sep 17 00:00:00 2001 From: Rudransh Shrivastava Date: Sat, 15 Aug 2026 17:31:36 +0530 Subject: [PATCH 01/16] add append_random_resource_name_suffix and add localstack to env also add example tfvars file for localstack environment Signed-off-by: Rudransh Shrivastava --- infrastructure/state/README.md | 1 + infrastructure/state/main.tf | 5 +++-- .../state/terraform.localstack.tfvars.example | 2 ++ infrastructure/state/variables.tf | 10 ++++++++-- 4 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 infrastructure/state/terraform.localstack.tfvars.example diff --git a/infrastructure/state/README.md b/infrastructure/state/README.md index 2a2a6d5289..db94bdc31b 100644 --- a/infrastructure/state/README.md +++ b/infrastructure/state/README.md @@ -135,6 +135,7 @@ Use the following inline permissions for the `nest-state` IAM User | Name | Description | Type | Default | Required | | ---- | ----------- | ---- | ------- | :------: | | [abort\_incomplete\_multipart\_upload\_days](#input\_abort\_incomplete\_multipart\_upload\_days) | The number of days after which an incomplete multipart upload is aborted. | `number` | `7` | no | +| [append\_random\_resource\_name\_suffix](#input\_append\_random\_resource\_name\_suffix) | Whether to append a random hex suffix to resource names. | `bool` | `true` | no | | [aws\_region](#input\_aws\_region) | The AWS region to deploy resources in. | `string` | `"us-east-2"` | no | | [expire\_log\_days](#input\_expire\_log\_days) | The number of days to expire logs after. | `number` | `90` | no | | [noncurrent\_version\_expiration\_days](#input\_noncurrent\_version\_expiration\_days) | The number of days an object is noncurrent before it is expired. | `number` | `30` | no | diff --git a/infrastructure/state/main.tf b/infrastructure/state/main.tf index a8acb76f33..2ecd5d7253 100644 --- a/infrastructure/state/main.tf +++ b/infrastructure/state/main.tf @@ -19,6 +19,7 @@ locals { Project = var.project_name } state_environments = toset(var.state_environments) + bucket_suffix = var.append_random_resource_name_suffix ? "-${random_id.suffix.hex}" : "" } module "kms" { @@ -92,7 +93,7 @@ data "aws_iam_policy_document" "state_https_only" { resource "aws_s3_bucket" "logs" { # NOSONAR for_each = local.state_environments - bucket = "${var.project_name}-${each.key}-terraform-state-logs-${random_id.suffix.hex}" + bucket = "${var.project_name}-${each.key}-terraform-state-logs${local.bucket_suffix}" tags = merge(local.common_tags, { Environment = each.key Name = "${var.project_name}-${each.key}-terraform-state-logs" @@ -166,7 +167,7 @@ resource "aws_s3_bucket_versioning" "logs" { resource "aws_s3_bucket" "state" { # NOSONAR for_each = local.state_environments - bucket = "${var.project_name}-${each.key}-terraform-state-${random_id.suffix.hex}" + bucket = "${var.project_name}-${each.key}-terraform-state${local.bucket_suffix}" object_lock_enabled = true tags = merge(local.common_tags, { Environment = each.key diff --git a/infrastructure/state/terraform.localstack.tfvars.example b/infrastructure/state/terraform.localstack.tfvars.example new file mode 100644 index 0000000000..62f8f8cd63 --- /dev/null +++ b/infrastructure/state/terraform.localstack.tfvars.example @@ -0,0 +1,2 @@ +append_random_resource_name_suffix = false +state_environments = ["localstack"] diff --git a/infrastructure/state/variables.tf b/infrastructure/state/variables.tf index b9c4e55556..4af1ab7f74 100644 --- a/infrastructure/state/variables.tf +++ b/infrastructure/state/variables.tf @@ -4,6 +4,12 @@ variable "abort_incomplete_multipart_upload_days" { default = 7 } +variable "append_random_resource_name_suffix" { + description = "Whether to append a random hex suffix to resource names." + type = bool + default = true +} + variable "aws_region" { description = "The AWS region to deploy resources in." type = string @@ -35,8 +41,8 @@ variable "state_environments" { validation { condition = alltrue([ - for env in var.state_environments : contains(["bootstrap", "staging", "production"], env) + for env in var.state_environments : contains(["bootstrap", "localstack", "staging", "production"], env) ]) - error_message = "Each environment must be 'bootstrap', 'staging' or 'production'." + error_message = "Each environment must be 'bootstrap', 'localstack', 'staging' or 'production'." } } From fc5fcd8fd98a88e09dd4cad16b30259aebfb6b3c Mon Sep 17 00:00:00 2001 From: Rudransh Shrivastava Date: Sun, 16 Aug 2026 21:43:42 +0530 Subject: [PATCH 02/16] bump localstack to 2026.7.4 to patch bugs Signed-off-by: Rudransh Shrivastava --- docker/localstack/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/localstack/Dockerfile b/docker/localstack/Dockerfile index fdea967391..be751b0b78 100644 --- a/docker/localstack/Dockerfile +++ b/docker/localstack/Dockerfile @@ -1 +1 @@ -FROM localstack/localstack:2026.7.0@sha256:2a81e5da4c32bb53e8d86e92050a12937f9be1915c5a4afad0931f75c112fc7e +FROM localstack/localstack:2026.7.4@sha256:f7b778d03717b58c3adce81a740bfafff5c6f9d639159bbf08da557c9ac1b513 From 56dda3888a428c15d7a0e96b939492dbf60a0738 Mon Sep 17 00:00:00 2001 From: Rudransh Shrivastava Date: Sun, 16 Aug 2026 21:44:07 +0530 Subject: [PATCH 03/16] expose 443 to use HTTPS Signed-off-by: Rudransh Shrivastava --- docker-compose/infrastructure/compose.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose/infrastructure/compose.yaml b/docker-compose/infrastructure/compose.yaml index 96e3773ea7..7109c1839f 100644 --- a/docker-compose/infrastructure/compose.yaml +++ b/docker-compose/infrastructure/compose.yaml @@ -16,6 +16,7 @@ services: networks: - infrastructure-nest ports: + - 443:443 - 4510-4559:4510-4559 - 4566:4566 volumes: From 34fde20552cd85c076363b72c32150dec9d72594 Mon Sep 17 00:00:00 2001 From: Rudransh Shrivastava Date: Sun, 16 Aug 2026 21:44:48 +0530 Subject: [PATCH 04/16] add s3 localstack endpoint and use_path_style Signed-off-by: Rudransh Shrivastava --- infrastructure/live/terraform.localstack.tfbackend.example | 2 ++ 1 file changed, 2 insertions(+) diff --git a/infrastructure/live/terraform.localstack.tfbackend.example b/infrastructure/live/terraform.localstack.tfbackend.example index 89f88ea55e..6a9e6a0b08 100644 --- a/infrastructure/live/terraform.localstack.tfbackend.example +++ b/infrastructure/live/terraform.localstack.tfbackend.example @@ -1,3 +1,5 @@ bucket = "nest-localstack-terraform-state" +endpoints = { s3 = "http://localstack:4566" } key = "localstack/terraform.tfstate" region = "us-east-1" +use_path_style = true From d63cfc8e6b84ccff7282c19c1c89caf02db653d8 Mon Sep 17 00:00:00 2001 From: Rudransh Shrivastava Date: Sun, 16 Aug 2026 21:56:53 +0530 Subject: [PATCH 05/16] apply state/ before applying live/ This runs the empty infrastructure successfully. Signed-off-by: Rudransh Shrivastava --- infrastructure/scripts/deploy_runner.py | 26 ++++++ .../tests/scripts/deploy_runner_test.py | 87 ++++++++++++++++--- 2 files changed, 100 insertions(+), 13 deletions(-) diff --git a/infrastructure/scripts/deploy_runner.py b/infrastructure/scripts/deploy_runner.py index 31d72413d4..fdcde06465 100644 --- a/infrastructure/scripts/deploy_runner.py +++ b/infrastructure/scripts/deploy_runner.py @@ -51,12 +51,38 @@ def deploy(self) -> None: self.commands.require("tflocal") self.localstack.wait_ready() + state_dir = self.root_dir / "infrastructure" / "state" live_dir = self.root_dir / "infrastructure" / "live" with ( set_temporary_env("AWS_ACCESS_KEY_ID", "test"), set_temporary_env("AWS_ENDPOINT_URL", self.localstack.api_url), set_temporary_env("AWS_SECRET_ACCESS_KEY", "test"), ): + state_init_result = self.commands.run( + "tflocal", + f"-chdir={state_dir}", + "init", + "-input=false", + "-reconfigure", + check=False, + ) + if state_init_result.returncode != 0: + message = f"terraform init failed in {state_dir}" + raise RunnerError(message) + + state_apply_result = self.commands.run( + "tflocal", + f"-chdir={state_dir}", + "apply", + "-auto-approve", + "-input=false", + "-var-file=terraform.localstack.tfvars", + check=False, + ) + if state_apply_result.returncode != 0: + message = f"terraform apply failed in {state_dir}" + raise RunnerError(message) + init_result = self.commands.run( "tflocal", f"-chdir={live_dir}", diff --git a/infrastructure/tests/scripts/deploy_runner_test.py b/infrastructure/tests/scripts/deploy_runner_test.py index d10e59b0c2..b6a90aee65 100644 --- a/infrastructure/tests/scripts/deploy_runner_test.py +++ b/infrastructure/tests/scripts/deploy_runner_test.py @@ -13,6 +13,12 @@ from scripts.localstack import LocalStack LOCALSTACK_ENDPOINT_URL = "http://localstack:4566" # NOSONAR: Test-only LocalStack HTTP. +AWS_ENV_VARS = ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_ENDPOINT_URL") + + +def assert_aws_env_unset() -> None: + for var in AWS_ENV_VARS: + assert var not in os.environ class TestInfrastructureDeployRunner: @@ -75,9 +81,27 @@ def capture(*_args: object, **_kwargs: object) -> MagicMock: commands.require.assert_called_once_with("tflocal") localstack.wait_ready.assert_called_once() + state_dir = str(Path("/repo") / "infrastructure" / "state") live_dir = str(Path("/repo") / "infrastructure" / "live") commands.run.assert_has_calls( [ + call( + "tflocal", + f"-chdir={state_dir}", + "init", + "-input=false", + "-reconfigure", + check=False, + ), + call( + "tflocal", + f"-chdir={state_dir}", + "apply", + "-auto-approve", + "-input=false", + "-var-file=terraform.localstack.tfvars", + check=False, + ), call( "tflocal", f"-chdir={live_dir}", @@ -102,9 +126,7 @@ def capture(*_args: object, **_kwargs: object) -> MagicMock: assert captured["AWS_ACCESS_KEY_ID"] == fake_credential assert captured["AWS_SECRET_ACCESS_KEY"] == fake_credential assert captured["AWS_ENDPOINT_URL"] == LOCALSTACK_ENDPOINT_URL - assert "AWS_ACCESS_KEY_ID" not in os.environ - assert "AWS_SECRET_ACCESS_KEY" not in os.environ - assert "AWS_ENDPOINT_URL" not in os.environ + assert_aws_env_unset() def test_deploy_propagates_wait_ready_failure(self) -> None: commands = MagicMock(spec=CommandRunner) @@ -123,7 +145,7 @@ def test_deploy_propagates_wait_ready_failure(self) -> None: commands.run.assert_not_called() @patch.dict(os.environ, {}, clear=True) - def test_deploy_raises_runner_error_when_init_fails(self) -> None: + def test_deploy_raises_runner_error_when_state_init_fails(self) -> None: commands = MagicMock(spec=CommandRunner) commands.run.return_value = MagicMock(returncode=1) localstack = MagicMock(spec=LocalStack) @@ -138,13 +160,10 @@ def test_deploy_raises_runner_error_when_init_fails(self) -> None: with pytest.raises(RunnerError, match="terraform init failed"): runner.deploy() - assert commands.run.call_count == 1 - assert "AWS_ACCESS_KEY_ID" not in os.environ - assert "AWS_SECRET_ACCESS_KEY" not in os.environ - assert "AWS_ENDPOINT_URL" not in os.environ + assert_aws_env_unset() @patch.dict(os.environ, {}, clear=True) - def test_deploy_raises_runner_error_when_apply_fails(self) -> None: + def test_deploy_raises_runner_error_when_state_apply_fails(self) -> None: commands = MagicMock(spec=CommandRunner) commands.run.side_effect = [MagicMock(returncode=0), MagicMock(returncode=1)] localstack = MagicMock(spec=LocalStack) @@ -159,7 +178,49 @@ def test_deploy_raises_runner_error_when_apply_fails(self) -> None: with pytest.raises(RunnerError, match="terraform apply failed"): runner.deploy() - assert commands.run.call_count == 2 - assert "AWS_ACCESS_KEY_ID" not in os.environ - assert "AWS_SECRET_ACCESS_KEY" not in os.environ - assert "AWS_ENDPOINT_URL" not in os.environ + assert_aws_env_unset() + + @patch.dict(os.environ, {}, clear=True) + def test_deploy_raises_runner_error_when_live_init_fails(self) -> None: + commands = MagicMock(spec=CommandRunner) + commands.run.side_effect = [ + MagicMock(returncode=0), + MagicMock(returncode=0), + MagicMock(returncode=1), + ] + localstack = MagicMock(spec=LocalStack) + localstack.api_url = LOCALSTACK_ENDPOINT_URL + + runner = InfrastructureDeployRunner( + root_dir=Path("/repo"), + commands=commands, + localstack=localstack, + ) + + with pytest.raises(RunnerError, match="terraform init failed"): + runner.deploy() + + assert_aws_env_unset() + + @patch.dict(os.environ, {}, clear=True) + def test_deploy_raises_runner_error_when_live_apply_fails(self) -> None: + commands = MagicMock(spec=CommandRunner) + commands.run.side_effect = [ + MagicMock(returncode=0), + MagicMock(returncode=0), + MagicMock(returncode=0), + MagicMock(returncode=1), + ] + localstack = MagicMock(spec=LocalStack) + localstack.api_url = LOCALSTACK_ENDPOINT_URL + + runner = InfrastructureDeployRunner( + root_dir=Path("/repo"), + commands=commands, + localstack=localstack, + ) + + with pytest.raises(RunnerError, match="terraform apply failed"): + runner.deploy() + + assert_aws_env_unset() From c9471cbd11971ec6509ab2966f381840f6bd7ea1 Mon Sep 17 00:00:00 2001 From: Rudransh Shrivastava Date: Sun, 16 Aug 2026 22:20:37 +0530 Subject: [PATCH 06/16] update make targets to use --wait localstack Signed-off-by: Rudransh Shrivastava --- docker-compose/infrastructure/compose.deploy.yaml | 3 --- docker-compose/infrastructure/compose.integration.yaml | 3 --- infrastructure/make/run.mk | 6 ++---- infrastructure/make/test.mk | 10 ++-------- 4 files changed, 4 insertions(+), 18 deletions(-) delete mode 100644 docker-compose/infrastructure/compose.deploy.yaml delete mode 100644 docker-compose/infrastructure/compose.integration.yaml diff --git a/docker-compose/infrastructure/compose.deploy.yaml b/docker-compose/infrastructure/compose.deploy.yaml deleted file mode 100644 index 07c0466654..0000000000 --- a/docker-compose/infrastructure/compose.deploy.yaml +++ /dev/null @@ -1,3 +0,0 @@ -services: - runner: - command: [python, -m, scripts.run_deploy] diff --git a/docker-compose/infrastructure/compose.integration.yaml b/docker-compose/infrastructure/compose.integration.yaml deleted file mode 100644 index 56d1d2a268..0000000000 --- a/docker-compose/infrastructure/compose.integration.yaml +++ /dev/null @@ -1,3 +0,0 @@ -services: - runner: - command: [python, -m, scripts.run_tests, --integration] diff --git a/infrastructure/make/run.mk b/infrastructure/make/run.mk index 0568a5ceaf..da9f5c22f2 100644 --- a/infrastructure/make/run.mk +++ b/infrastructure/make/run.mk @@ -14,7 +14,5 @@ infrastructure-up: ## Start LocalStack and deploy infrastructure exit 1; \ fi; \ $(MAKE) infrastructure-image-build || exit $$?; \ - COMPOSE_BAKE=true DOCKER_BUILDKIT=1 \ - $(INFRASTRUCTURE_COMPOSE) \ - -f docker-compose/infrastructure/compose.deploy.yaml \ - up + $(INFRASTRUCTURE_COMPOSE) up --wait localstack || exit $$?; \ + $(INFRASTRUCTURE_COMPOSE) run --rm runner python -m scripts.run_deploy diff --git a/infrastructure/make/test.mk b/infrastructure/make/test.mk index b066270286..4f52dc03c2 100644 --- a/infrastructure/make/test.mk +++ b/infrastructure/make/test.mk @@ -44,12 +44,6 @@ infrastructure-test-integration: $(MAKE) infrastructure-image-build || exit $$?; \ status=0; \ trap '$(INFRASTRUCTURE_COMPOSE) down --remove-orphans >/dev/null 2>&1 || true' EXIT; \ - COMPOSE_BAKE=true DOCKER_BUILDKIT=1 \ - $(INFRASTRUCTURE_COMPOSE) \ - -f docker-compose/infrastructure/compose.integration.yaml \ - up \ - --abort-on-container-exit \ - --build \ - --exit-code-from runner \ - || status=$$?; \ + $(INFRASTRUCTURE_COMPOSE) up --wait localstack || exit $$?; \ + $(INFRASTRUCTURE_COMPOSE) run --rm runner python -m scripts.run_tests --integration || status=$$?; \ exit $$status From d415a5ea653d12990bf2a92222fb627a97467900 Mon Sep 17 00:00:00 2001 From: Rudransh Shrivastava Date: Sat, 22 Aug 2026 21:26:59 +0530 Subject: [PATCH 07/16] refactor terraform apply calls in reusable methods Signed-off-by: Rudransh Shrivastava --- infrastructure/make/run.mk | 15 +- infrastructure/scripts/deploy_runner.py | 125 +++++++----- infrastructure/scripts/run_deploy.py | 12 +- .../tests/scripts/deploy_runner_test.py | 178 ++++++++---------- .../tests/scripts/run_deploy_test.py | 17 +- 5 files changed, 186 insertions(+), 161 deletions(-) diff --git a/infrastructure/make/run.mk b/infrastructure/make/run.mk index da9f5c22f2..6eeef6fab0 100644 --- a/infrastructure/make/run.mk +++ b/infrastructure/make/run.mk @@ -1,10 +1,10 @@ -.PHONY: infrastructure-up +.PHONY: infrastructure-check-auth-token infrastructure-refresh infrastructure-up INFRASTRUCTURE_COMPOSE = docker compose \ --project-name nest-infrastructure \ -f docker-compose/infrastructure/compose.yaml -infrastructure-up: ## Start LocalStack and deploy infrastructure +infrastructure-check-auth-token: @if [ -z "$$LOCALSTACK_AUTH_TOKEN" ]; then \ if [ -t 2 ]; then \ printf '\033[1;31mError:\033[0m LOCALSTACK_AUTH_TOKEN is not set.\n' >&2; \ @@ -12,7 +12,14 @@ infrastructure-up: ## Start LocalStack and deploy infrastructure echo "Error: LOCALSTACK_AUTH_TOKEN is not set." >&2; \ fi; \ exit 1; \ - fi; \ - $(MAKE) infrastructure-image-build || exit $$?; \ + fi + +infrastructure-refresh: infrastructure-check-auth-token ## Refresh an existing deployment on LocalStack + @$(MAKE) infrastructure-image-build || exit $$?; \ + $(INFRASTRUCTURE_COMPOSE) up --wait localstack || exit $$?; \ + $(INFRASTRUCTURE_COMPOSE) run --rm runner python -m scripts.run_deploy --refresh + +infrastructure-up: infrastructure-check-auth-token ## Start LocalStack and deploy infrastructure + @$(MAKE) infrastructure-image-build || exit $$?; \ $(INFRASTRUCTURE_COMPOSE) up --wait localstack || exit $$?; \ $(INFRASTRUCTURE_COMPOSE) run --rm runner python -m scripts.run_deploy diff --git a/infrastructure/scripts/deploy_runner.py b/infrastructure/scripts/deploy_runner.py index fdcde06465..61d9f47c38 100644 --- a/infrastructure/scripts/deploy_runner.py +++ b/infrastructure/scripts/deploy_runner.py @@ -33,6 +33,73 @@ def __init__( self.commands = commands or CommandRunner() self.localstack = localstack or LocalStack(self.commands) + def apply_live(self) -> None: + """Initialize and apply the live/ Terraform configuration. + + Raises: + RunnerError: If a Terraform command exits with a non-zero status. + + """ + live_dir = self.root_dir / "infrastructure" / "live" + init_result = self.commands.run( + "tflocal", + f"-chdir={live_dir}", + "init", + "-backend-config=terraform.localstack.tfbackend", + "-input=false", + "-reconfigure", + check=False, + ) + if init_result.returncode != 0: + message = f"terraform init failed in {live_dir}" + raise RunnerError(message) + + apply_result = self.commands.run( + "tflocal", + f"-chdir={live_dir}", + "apply", + "-auto-approve", + "-input=false", + "-var-file=terraform.localstack.tfvars", + check=False, + ) + if apply_result.returncode != 0: + message = f"terraform apply failed in {live_dir}" + raise RunnerError(message) + + def apply_state(self) -> None: + """Initialize and apply the state/ Terraform configuration. + + Raises: + RunnerError: If a Terraform command exits with a non-zero status. + + """ + state_dir = self.root_dir / "infrastructure" / "state" + init_result = self.commands.run( + "tflocal", + f"-chdir={state_dir}", + "init", + "-input=false", + "-reconfigure", + check=False, + ) + if init_result.returncode != 0: + message = f"terraform init failed in {state_dir}" + raise RunnerError(message) + + apply_result = self.commands.run( + "tflocal", + f"-chdir={state_dir}", + "apply", + "-auto-approve", + "-input=false", + "-var-file=terraform.localstack.tfvars", + check=False, + ) + if apply_result.returncode != 0: + message = f"terraform apply failed in {state_dir}" + raise RunnerError(message) + def configure_environment(self) -> None: """Change to the repo root and configure the Terraform plugin cache.""" chdir_repository_root(self.root_dir) @@ -51,61 +118,15 @@ def deploy(self) -> None: self.commands.require("tflocal") self.localstack.wait_ready() - state_dir = self.root_dir / "infrastructure" / "state" - live_dir = self.root_dir / "infrastructure" / "live" with ( set_temporary_env("AWS_ACCESS_KEY_ID", "test"), set_temporary_env("AWS_ENDPOINT_URL", self.localstack.api_url), set_temporary_env("AWS_SECRET_ACCESS_KEY", "test"), ): - state_init_result = self.commands.run( - "tflocal", - f"-chdir={state_dir}", - "init", - "-input=false", - "-reconfigure", - check=False, - ) - if state_init_result.returncode != 0: - message = f"terraform init failed in {state_dir}" - raise RunnerError(message) - - state_apply_result = self.commands.run( - "tflocal", - f"-chdir={state_dir}", - "apply", - "-auto-approve", - "-input=false", - "-var-file=terraform.localstack.tfvars", - check=False, - ) - if state_apply_result.returncode != 0: - message = f"terraform apply failed in {state_dir}" - raise RunnerError(message) - - init_result = self.commands.run( - "tflocal", - f"-chdir={live_dir}", - "init", - "-backend-config=terraform.localstack.tfbackend", - "-input=false", - "-reconfigure", - check=False, - ) - if init_result.returncode != 0: - message = f"terraform init failed in {live_dir}" - raise RunnerError(message) - - apply_result = self.commands.run( - "tflocal", - f"-chdir={live_dir}", - "apply", - "-auto-approve", - "-input=false", - "-var-file=terraform.localstack.tfvars", - check=False, - ) - if apply_result.returncode != 0: - message = f"terraform apply failed in {live_dir}" - raise RunnerError(message) + self.apply_state() + self.apply_live() logger.info("Deployment on LocalStack successful!") + + def refresh(self) -> None: + """Orchestrate a deployment refresh.""" + logger.info("Refresh is not implemented yet.") diff --git a/infrastructure/scripts/run_deploy.py b/infrastructure/scripts/run_deploy.py index 593e6dc6c5..b7c958433c 100644 --- a/infrastructure/scripts/run_deploy.py +++ b/infrastructure/scripts/run_deploy.py @@ -15,13 +15,21 @@ def main() -> None: logging.basicConfig(level=logging.INFO, format="%(message)s") parser = argparse.ArgumentParser(description="Infrastructure deployment runner") - parser.parse_args() + parser.add_argument( + "--refresh", + action="store_true", + help="Refresh an existing deployment", + ) + args = parser.parse_args() runner = InfrastructureDeployRunner() runner.configure_environment() try: - runner.deploy() + if args.refresh: + runner.refresh() + else: + runner.deploy() except RunnerError as exc: sys.stderr.write(f"Error: {exc}\n") sys.exit(1) diff --git a/infrastructure/tests/scripts/deploy_runner_test.py b/infrastructure/tests/scripts/deploy_runner_test.py index b6a90aee65..0d5693478c 100644 --- a/infrastructure/tests/scripts/deploy_runner_test.py +++ b/infrastructure/tests/scripts/deploy_runner_test.py @@ -14,6 +14,7 @@ LOCALSTACK_ENDPOINT_URL = "http://localstack:4566" # NOSONAR: Test-only LocalStack HTTP. AWS_ENV_VARS = ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_ENDPOINT_URL") +FAKE_CREDENTIAL = "test" def assert_aws_env_unset() -> None: @@ -21,6 +22,14 @@ def assert_aws_env_unset() -> None: assert var not in os.environ +def build_runner(commands: MagicMock, localstack: MagicMock) -> InfrastructureDeployRunner: + return InfrastructureDeployRunner( + root_dir=Path("/repo"), + commands=commands, + localstack=localstack, + ) + + class TestInfrastructureDeployRunner: """Tests for ``InfrastructureDeployRunner`` orchestration.""" @@ -54,35 +63,14 @@ def test_configure_environment_swallows_cache_failure( assert "TF_PLUGIN_CACHE_DIR" not in os.environ assert "Could not configure TF_PLUGIN_CACHE_DIR" in caplog.text - @patch.dict(os.environ, {}, clear=True) - def test_deploy_runs_init_and_apply(self) -> None: + def test_apply_state_runs_init_and_apply(self) -> None: commands = MagicMock(spec=CommandRunner) - localstack = MagicMock(spec=LocalStack) - localstack.api_url = LOCALSTACK_ENDPOINT_URL - - captured: dict[str, str] = {} - - def capture(*_args: object, **_kwargs: object) -> MagicMock: - captured.setdefault("AWS_ACCESS_KEY_ID", os.environ["AWS_ACCESS_KEY_ID"]) - captured.setdefault("AWS_ENDPOINT_URL", os.environ["AWS_ENDPOINT_URL"]) - captured.setdefault("AWS_SECRET_ACCESS_KEY", os.environ["AWS_SECRET_ACCESS_KEY"]) - return MagicMock(returncode=0) - - commands.run.side_effect = capture - - runner = InfrastructureDeployRunner( - root_dir=Path("/repo"), - commands=commands, - localstack=localstack, - ) + commands.run.return_value = MagicMock(returncode=0) + runner = build_runner(commands, MagicMock(spec=LocalStack)) - runner.deploy() - - commands.require.assert_called_once_with("tflocal") - localstack.wait_ready.assert_called_once() + runner.apply_state() state_dir = str(Path("/repo") / "infrastructure" / "state") - live_dir = str(Path("/repo") / "infrastructure" / "live") commands.run.assert_has_calls( [ call( @@ -102,6 +90,35 @@ def capture(*_args: object, **_kwargs: object) -> MagicMock: "-var-file=terraform.localstack.tfvars", check=False, ), + ] + ) + + def test_apply_state_raises_when_init_fails(self) -> None: + commands = MagicMock(spec=CommandRunner) + commands.run.return_value = MagicMock(returncode=1) + runner = build_runner(commands, MagicMock(spec=LocalStack)) + + with pytest.raises(RunnerError, match="terraform init failed"): + runner.apply_state() + + def test_apply_state_raises_when_apply_fails(self) -> None: + commands = MagicMock(spec=CommandRunner) + commands.run.side_effect = [MagicMock(returncode=0), MagicMock(returncode=1)] + runner = build_runner(commands, MagicMock(spec=LocalStack)) + + with pytest.raises(RunnerError, match="terraform apply failed"): + runner.apply_state() + + def test_apply_live_runs_init_and_apply(self) -> None: + commands = MagicMock(spec=CommandRunner) + commands.run.return_value = MagicMock(returncode=0) + runner = build_runner(commands, MagicMock(spec=LocalStack)) + + runner.apply_live() + + live_dir = str(Path("/repo") / "infrastructure" / "live") + commands.run.assert_has_calls( + [ call( "tflocal", f"-chdir={live_dir}", @@ -122,105 +139,64 @@ def capture(*_args: object, **_kwargs: object) -> MagicMock: ), ] ) - fake_credential = "test" - assert captured["AWS_ACCESS_KEY_ID"] == fake_credential - assert captured["AWS_SECRET_ACCESS_KEY"] == fake_credential - assert captured["AWS_ENDPOINT_URL"] == LOCALSTACK_ENDPOINT_URL - assert_aws_env_unset() - - def test_deploy_propagates_wait_ready_failure(self) -> None: - commands = MagicMock(spec=CommandRunner) - localstack = MagicMock(spec=LocalStack) - localstack.wait_ready.side_effect = RunnerError("localstack down") - - runner = InfrastructureDeployRunner( - root_dir=Path("/repo"), - commands=commands, - localstack=localstack, - ) - - with pytest.raises(RunnerError, match="localstack down"): - runner.deploy() - commands.run.assert_not_called() - - @patch.dict(os.environ, {}, clear=True) - def test_deploy_raises_runner_error_when_state_init_fails(self) -> None: + def test_apply_live_raises_when_init_fails(self) -> None: commands = MagicMock(spec=CommandRunner) commands.run.return_value = MagicMock(returncode=1) - localstack = MagicMock(spec=LocalStack) - localstack.api_url = LOCALSTACK_ENDPOINT_URL - - runner = InfrastructureDeployRunner( - root_dir=Path("/repo"), - commands=commands, - localstack=localstack, - ) + runner = build_runner(commands, MagicMock(spec=LocalStack)) with pytest.raises(RunnerError, match="terraform init failed"): - runner.deploy() + runner.apply_live() - assert_aws_env_unset() - - @patch.dict(os.environ, {}, clear=True) - def test_deploy_raises_runner_error_when_state_apply_fails(self) -> None: + def test_apply_live_raises_when_apply_fails(self) -> None: commands = MagicMock(spec=CommandRunner) commands.run.side_effect = [MagicMock(returncode=0), MagicMock(returncode=1)] - localstack = MagicMock(spec=LocalStack) - localstack.api_url = LOCALSTACK_ENDPOINT_URL - - runner = InfrastructureDeployRunner( - root_dir=Path("/repo"), - commands=commands, - localstack=localstack, - ) + runner = build_runner(commands, MagicMock(spec=LocalStack)) with pytest.raises(RunnerError, match="terraform apply failed"): - runner.deploy() - - assert_aws_env_unset() + runner.apply_live() @patch.dict(os.environ, {}, clear=True) - def test_deploy_raises_runner_error_when_live_init_fails(self) -> None: + def test_deploy_calls_apply_state_then_apply_live_inside_aws_env(self) -> None: commands = MagicMock(spec=CommandRunner) - commands.run.side_effect = [ - MagicMock(returncode=0), - MagicMock(returncode=0), - MagicMock(returncode=1), - ] localstack = MagicMock(spec=LocalStack) localstack.api_url = LOCALSTACK_ENDPOINT_URL + runner = build_runner(commands, localstack) - runner = InfrastructureDeployRunner( - root_dir=Path("/repo"), - commands=commands, - localstack=localstack, - ) + call_order: list[str] = [] + captured: dict[str, str] = {} - with pytest.raises(RunnerError, match="terraform init failed"): + def record_state() -> None: + call_order.append("apply_state") + for var in AWS_ENV_VARS: + captured[var] = os.environ[var] + + def record_live() -> None: + call_order.append("apply_live") + + with ( + patch.object(runner, "apply_state", side_effect=record_state) as mock_state, + patch.object(runner, "apply_live", side_effect=record_live) as mock_live, + ): runner.deploy() + commands.require.assert_called_once_with("tflocal") + localstack.wait_ready.assert_called_once() + mock_state.assert_called_once_with() + mock_live.assert_called_once_with() + assert call_order == ["apply_state", "apply_live"] + assert captured["AWS_ACCESS_KEY_ID"] == FAKE_CREDENTIAL + assert captured["AWS_SECRET_ACCESS_KEY"] == FAKE_CREDENTIAL + assert captured["AWS_ENDPOINT_URL"] == LOCALSTACK_ENDPOINT_URL assert_aws_env_unset() - @patch.dict(os.environ, {}, clear=True) - def test_deploy_raises_runner_error_when_live_apply_fails(self) -> None: + def test_deploy_propagates_wait_ready_failure(self) -> None: commands = MagicMock(spec=CommandRunner) - commands.run.side_effect = [ - MagicMock(returncode=0), - MagicMock(returncode=0), - MagicMock(returncode=0), - MagicMock(returncode=1), - ] localstack = MagicMock(spec=LocalStack) - localstack.api_url = LOCALSTACK_ENDPOINT_URL - - runner = InfrastructureDeployRunner( - root_dir=Path("/repo"), - commands=commands, - localstack=localstack, - ) + localstack.wait_ready.side_effect = RunnerError("localstack down") + runner = build_runner(commands, localstack) - with pytest.raises(RunnerError, match="terraform apply failed"): + with pytest.raises(RunnerError, match="localstack down"): runner.deploy() - assert_aws_env_unset() + commands.run.assert_not_called() diff --git a/infrastructure/tests/scripts/run_deploy_test.py b/infrastructure/tests/scripts/run_deploy_test.py index 32a74bb739..43ed50bb63 100644 --- a/infrastructure/tests/scripts/run_deploy_test.py +++ b/infrastructure/tests/scripts/run_deploy_test.py @@ -1,5 +1,6 @@ """Tests for ``scripts.run_deploy`` CLI.""" +from argparse import Namespace from unittest.mock import MagicMock, patch from scripts import run_deploy @@ -13,11 +14,23 @@ class TestRunDeployMain: def test_main_invokes_configure_and_deploy(self, mock_runner_cls: MagicMock) -> None: mock_runner = mock_runner_cls.return_value - with patch("argparse.ArgumentParser.parse_args"): + with patch("argparse.ArgumentParser.parse_args", return_value=Namespace(refresh=False)): run_deploy.main() mock_runner.configure_environment.assert_called_once() mock_runner.deploy.assert_called_once() + mock_runner.refresh.assert_not_called() + + @patch("scripts.run_deploy.InfrastructureDeployRunner") + def test_main_invokes_refresh_when_flag_set(self, mock_runner_cls: MagicMock) -> None: + mock_runner = mock_runner_cls.return_value + + with patch("argparse.ArgumentParser.parse_args", return_value=Namespace(refresh=True)): + run_deploy.main() + + mock_runner.configure_environment.assert_called_once() + mock_runner.refresh.assert_called_once() + mock_runner.deploy.assert_not_called() @patch("scripts.run_deploy.InfrastructureDeployRunner") @patch("sys.exit") @@ -31,7 +44,7 @@ def test_main_handles_test_runner_error( mock_runner = mock_runner_cls.return_value mock_runner.deploy.side_effect = RunnerError("boom") - with patch("argparse.ArgumentParser.parse_args"): + with patch("argparse.ArgumentParser.parse_args", return_value=Namespace(refresh=False)): run_deploy.main() mock_stderr_write.assert_any_call("Error: boom\n") From fb91738514c189191118a722a60e2a43c74ef0d4 Mon Sep 17 00:00:00 2001 From: Rudransh Shrivastava Date: Sat, 22 Aug 2026 21:39:57 +0530 Subject: [PATCH 08/16] implement refresh() runs apply_live() Signed-off-by: Rudransh Shrivastava --- infrastructure/scripts/deploy_runner.py | 18 ++++++++-- .../tests/scripts/deploy_runner_test.py | 35 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/infrastructure/scripts/deploy_runner.py b/infrastructure/scripts/deploy_runner.py index 61d9f47c38..03c6ba49f2 100644 --- a/infrastructure/scripts/deploy_runner.py +++ b/infrastructure/scripts/deploy_runner.py @@ -128,5 +128,19 @@ def deploy(self) -> None: logger.info("Deployment on LocalStack successful!") def refresh(self) -> None: - """Orchestrate a deployment refresh.""" - logger.info("Refresh is not implemented yet.") + """Orchestrate a deployment refresh. + + Raises: + RunnerError: If a Terraform command exits with a non-zero status. + + """ + self.commands.require("tflocal") + self.localstack.wait_ready() + + with ( + set_temporary_env("AWS_ACCESS_KEY_ID", "test"), + set_temporary_env("AWS_ENDPOINT_URL", self.localstack.api_url), + set_temporary_env("AWS_SECRET_ACCESS_KEY", "test"), + ): + self.apply_live() + logger.info("Deployment on LocalStack successful!") diff --git a/infrastructure/tests/scripts/deploy_runner_test.py b/infrastructure/tests/scripts/deploy_runner_test.py index 0d5693478c..65b977d03c 100644 --- a/infrastructure/tests/scripts/deploy_runner_test.py +++ b/infrastructure/tests/scripts/deploy_runner_test.py @@ -200,3 +200,38 @@ def test_deploy_propagates_wait_ready_failure(self) -> None: runner.deploy() commands.run.assert_not_called() + + @patch.dict(os.environ, {}, clear=True) + def test_refresh_calls_apply_live_inside_aws_env(self) -> None: + commands = MagicMock(spec=CommandRunner) + localstack = MagicMock(spec=LocalStack) + localstack.api_url = LOCALSTACK_ENDPOINT_URL + runner = build_runner(commands, localstack) + + captured: dict[str, str] = {} + + def record_live() -> None: + for var in AWS_ENV_VARS: + captured[var] = os.environ[var] + + with patch.object(runner, "apply_live", side_effect=record_live) as mock_live: + runner.refresh() + + commands.require.assert_called_once_with("tflocal") + localstack.wait_ready.assert_called_once() + mock_live.assert_called_once_with() + assert captured["AWS_ACCESS_KEY_ID"] == FAKE_CREDENTIAL + assert captured["AWS_SECRET_ACCESS_KEY"] == FAKE_CREDENTIAL + assert captured["AWS_ENDPOINT_URL"] == LOCALSTACK_ENDPOINT_URL + assert_aws_env_unset() + + def test_refresh_propagates_wait_ready_failure(self) -> None: + commands = MagicMock(spec=CommandRunner) + localstack = MagicMock(spec=LocalStack) + localstack.wait_ready.side_effect = RunnerError("localstack down") + runner = build_runner(commands, localstack) + + with pytest.raises(RunnerError, match="localstack down"): + runner.refresh() + + commands.run.assert_not_called() From 7dc308816224e0373c14aa6c0336595a20cd38e2 Mon Sep 17 00:00:00 2001 From: Rudransh Shrivastava Date: Sat, 22 Aug 2026 21:58:33 +0530 Subject: [PATCH 09/16] extract tfbackend and tfvars in constant Signed-off-by: Rudransh Shrivastava --- infrastructure/scripts/deploy_runner.py | 9 ++++++--- infrastructure/tests/scripts/deploy_runner_test.py | 12 ++++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/infrastructure/scripts/deploy_runner.py b/infrastructure/scripts/deploy_runner.py index 03c6ba49f2..b67b07c8e9 100644 --- a/infrastructure/scripts/deploy_runner.py +++ b/infrastructure/scripts/deploy_runner.py @@ -10,6 +10,9 @@ logger = logging.getLogger(__name__) +LOCALSTACK_TFBACKEND = "terraform.localstack.tfbackend" +LOCALSTACK_TFVARS = "terraform.localstack.tfvars" + class InfrastructureDeployRunner: """Infrastructure deployment orchestrator.""" @@ -45,7 +48,7 @@ def apply_live(self) -> None: "tflocal", f"-chdir={live_dir}", "init", - "-backend-config=terraform.localstack.tfbackend", + f"-backend-config={LOCALSTACK_TFBACKEND}", "-input=false", "-reconfigure", check=False, @@ -60,7 +63,7 @@ def apply_live(self) -> None: "apply", "-auto-approve", "-input=false", - "-var-file=terraform.localstack.tfvars", + f"-var-file={LOCALSTACK_TFVARS}", check=False, ) if apply_result.returncode != 0: @@ -93,7 +96,7 @@ def apply_state(self) -> None: "apply", "-auto-approve", "-input=false", - "-var-file=terraform.localstack.tfvars", + f"-var-file={LOCALSTACK_TFVARS}", check=False, ) if apply_result.returncode != 0: diff --git a/infrastructure/tests/scripts/deploy_runner_test.py b/infrastructure/tests/scripts/deploy_runner_test.py index 65b977d03c..d8eea3fd69 100644 --- a/infrastructure/tests/scripts/deploy_runner_test.py +++ b/infrastructure/tests/scripts/deploy_runner_test.py @@ -8,7 +8,11 @@ import pytest from scripts.commands import CommandRunner -from scripts.deploy_runner import InfrastructureDeployRunner +from scripts.deploy_runner import ( + LOCALSTACK_TFBACKEND, + LOCALSTACK_TFVARS, + InfrastructureDeployRunner, +) from scripts.errors import RunnerError from scripts.localstack import LocalStack @@ -87,7 +91,7 @@ def test_apply_state_runs_init_and_apply(self) -> None: "apply", "-auto-approve", "-input=false", - "-var-file=terraform.localstack.tfvars", + f"-var-file={LOCALSTACK_TFVARS}", check=False, ), ] @@ -123,7 +127,7 @@ def test_apply_live_runs_init_and_apply(self) -> None: "tflocal", f"-chdir={live_dir}", "init", - "-backend-config=terraform.localstack.tfbackend", + f"-backend-config={LOCALSTACK_TFBACKEND}", "-input=false", "-reconfigure", check=False, @@ -134,7 +138,7 @@ def test_apply_live_runs_init_and_apply(self) -> None: "apply", "-auto-approve", "-input=false", - "-var-file=terraform.localstack.tfvars", + f"-var-file={LOCALSTACK_TFVARS}", check=False, ), ] From 16d4f34c85526712d2fde2539cbbfd64cd24ea39 Mon Sep 17 00:00:00 2001 From: Rudransh Shrivastava Date: Sun, 23 Aug 2026 12:11:02 +0530 Subject: [PATCH 10/16] add awscli-local and docker Signed-off-by: Rudransh Shrivastava --- infrastructure/poetry.lock | 227 +++++++++++++++++++++++++++++++++- infrastructure/pyproject.toml | 2 + 2 files changed, 228 insertions(+), 1 deletion(-) diff --git a/infrastructure/poetry.lock b/infrastructure/poetry.lock index 29f92f21c8..37e916cded 100644 --- a/infrastructure/poetry.lock +++ b/infrastructure/poetry.lock @@ -1,5 +1,23 @@ # This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. +[[package]] +name = "awscli-local" +version = "0.22.2" +description = "Thin wrapper around the \"aws\" command line interface for use with LocalStack" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "awscli_local-0.22.2-py3-none-any.whl", hash = "sha256:1901ebef343ba8cbde06f9f6406b5415ef53e93296718c2c31c9d49b0b09bd9d"}, + {file = "awscli_local-0.22.2.tar.gz", hash = "sha256:07c532c372753bf5f15426451dc91d6eec9de8779748049329a9a882bdac8a0b"}, +] + +[package.dependencies] +localstack-client = "*" + +[package.extras] +ver1 = ["awscli"] + [[package]] name = "boto3" version = "1.43.55" @@ -40,6 +58,121 @@ urllib3 = ">=1.25.4,<2.2.0 || >2.2.0,<3" [package.extras] crt = ["awscrt (==0.36.0)"] +[[package]] +name = "certifi" +version = "2026.7.22" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"}, + {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win32.whl", hash = "sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win_amd64.whl", hash = "sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win_arm64.whl", hash = "sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe"}, + {file = "charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5"}, + {file = "charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b"}, +] + [[package]] name = "colorama" version = "0.4.6" @@ -53,6 +186,44 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "docker" +version = "7.2.0" +description = "A Python library for the Docker Engine API." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "docker-7.2.0-py3-none-any.whl", hash = "sha256:a3f45fdeb9165e2d25d9a1d02ddf3bc70fb572cf5ebbf9b58558c22caf29b71f"}, + {file = "docker-7.2.0.tar.gz", hash = "sha256:cebb93773d334f778e023a7ee352a8d6e13ab1bd3b863a4d4a59dec897df43ac"}, +] + +[package.dependencies] +pywin32 = {version = ">=304", markers = "sys_platform == \"win32\""} +requests = ">=2.26.0" +urllib3 = ">=1.26.0" + +[package.extras] +dev = ["coverage (==7.2.7)", "pytest (==7.4.2)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.1.0)", "ruff (==0.1.8)"] +docs = ["myst-parser (==0.18.0)", "sphinx (==5.1.1)"] +ssh = ["paramiko (>=2.4.3)"] +websockets = ["websocket-client (>=1.3.0)"] + +[[package]] +name = "idna" +version = "3.18" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, +] + +[package.extras] +all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + [[package]] name = "iniconfig" version = "2.3.0" @@ -208,6 +379,38 @@ files = [ lark = ">=1.1.5,<2.0" regex = ">=2024.4.16" +[[package]] +name = "pywin32" +version = "312" +description = "Python for Windows Extensions" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e"}, + {file = "pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db"}, + {file = "pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd"}, + {file = "pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c"}, + {file = "pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a"}, + {file = "pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47"}, + {file = "pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b"}, + {file = "pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc"}, + {file = "pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950"}, + {file = "pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c"}, + {file = "pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9"}, + {file = "pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831"}, + {file = "pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b"}, + {file = "pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e"}, + {file = "pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa"}, + {file = "pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed"}, + {file = "pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5"}, + {file = "pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9"}, + {file = "pywin32-312-cp39-cp39-win32.whl", hash = "sha256:d620900033cc7531e50727c3c8333091df5dd3ffe6d68cdca38c03f5821408d5"}, + {file = "pywin32-312-cp39-cp39-win_amd64.whl", hash = "sha256:dc90147579a905b8635e1b0ec6514967dcb07e6e0d9c42f1477feef14cac23bb"}, + {file = "pywin32-312-cp39-cp39-win_arm64.whl", hash = "sha256:02ebca0f0242b75292e218065004310d6a477407c09fa449bfe4f6022bc0c0fc"}, +] + [[package]] name = "regex" version = "2026.7.19" @@ -332,6 +535,28 @@ files = [ {file = "regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5"}, ] +[[package]] +name = "requests" +version = "2.34.2" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, + {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, +] + +[package.dependencies] +certifi = ">=2023.5.7" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.26,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] + [[package]] name = "s3transfer" version = "0.19.2" @@ -403,4 +628,4 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "2178d7dd91e0e8284bcb0b9a015d83bb4d0b54b343793b75c42c64f29db6d736" +content-hash = "70ee807a5c5b04cf244154a5205bb9f9e4db5ad0aa0ec30a44611841bb9e467e" diff --git a/infrastructure/pyproject.toml b/infrastructure/pyproject.toml index d3971ee3e3..163ff5dd2e 100644 --- a/infrastructure/pyproject.toml +++ b/infrastructure/pyproject.toml @@ -10,6 +10,8 @@ license = "MIT" authors = [ "Arkadii Yakovets " ] packages = [ { include = "scripts" } ] dependencies.python = "^3.13" +dependencies.awscli-local = "0.22.2" +dependencies.docker = "7.2.0" dependencies.terraform-local = "0.26.0" group.test.dependencies.pytest = "9.0.3" From 72a87385661ebfe5011b0151f7249f2f278eb63c Mon Sep 17 00:00:00 2001 From: Rudransh Shrivastava Date: Sun, 23 Aug 2026 12:14:59 +0530 Subject: [PATCH 11/16] mount docker socket to runner Signed-off-by: Rudransh Shrivastava --- docker-compose/infrastructure/compose.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose/infrastructure/compose.yaml b/docker-compose/infrastructure/compose.yaml index 7109c1839f..77d21c3648 100644 --- a/docker-compose/infrastructure/compose.yaml +++ b/docker-compose/infrastructure/compose.yaml @@ -35,6 +35,8 @@ services: pull_policy: never volumes: - infrastructure-terraform-plugin-cache:/home/owasp/.terraform.d/plugin-cache + # Host Docker socket so runner can build containers (Backend, Frontend, etc.). + - /var/run/docker.sock:/var/run/docker.sock networks: infrastructure-nest: From 67172b840b2fad982746b07e9517d6ab4da8b33f Mon Sep 17 00:00:00 2001 From: Rudransh Shrivastava Date: Sun, 23 Aug 2026 19:15:32 +0530 Subject: [PATCH 12/16] remove docker and add awscli and update Dockerfile also add note about copying code. Signed-off-by: Rudransh Shrivastava --- docker/infrastructure/Dockerfile | 6 + infrastructure/poetry.lock | 321 ++++++++++++------------------- infrastructure/pyproject.toml | 2 +- 3 files changed, 135 insertions(+), 194 deletions(-) diff --git a/docker/infrastructure/Dockerfile b/docker/infrastructure/Dockerfile index e1f91bc2ca..eb004ba0d3 100644 --- a/docker/infrastructure/Dockerfile +++ b/docker/infrastructure/Dockerfile @@ -44,6 +44,8 @@ FROM python:3.13.14-alpine3.23@sha256:9fdbf2e3e82628351513560b121e2ee6ce31cac212 ARG OWASP_GID ARG OWASP_UID +RUN apk --no-cache add docker-cli docker-cli-buildx + RUN addgroup -S -g ${OWASP_GID} owasp && \ adduser -S -h /home/owasp -u ${OWASP_UID} -G owasp owasp && \ mkdir -p \ @@ -70,6 +72,10 @@ USER owasp COPY --from=terraform --chmod=555 --chown=root:root /bin/terraform /usr/local/bin/terraform COPY --from=builder --chown=owasp:owasp /home/owasp /home/owasp +# Infrastructure sources are copied (not mounted) so tflocal can write .terraform/ +# and LocalStack override files inside the container without polluting the host. +# A read-only mount would fail those writes and a read-write mount would leak them +# to the host. Copying keeps writes scoped to the container lifetime. COPY --chown=owasp:owasp infrastructure/bootstrap /home/owasp/infrastructure/bootstrap COPY --chown=owasp:owasp infrastructure/live /home/owasp/infrastructure/live COPY --chown=owasp:owasp infrastructure/modules /home/owasp/infrastructure/modules diff --git a/infrastructure/poetry.lock b/infrastructure/poetry.lock index 37e916cded..f426cc49ed 100644 --- a/infrastructure/poetry.lock +++ b/infrastructure/poetry.lock @@ -1,5 +1,25 @@ # This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. +[[package]] +name = "awscli" +version = "1.45.62" +description = "Universal Command Line Environment for AWS." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "awscli-1.45.62-py3-none-any.whl", hash = "sha256:712226a768bd0941e5e5511938dfd5f3576525b59ea576e9ef1206d6f9bc15e5"}, + {file = "awscli-1.45.62.tar.gz", hash = "sha256:8754f4ae5e14bb7f2fa273096678c6706cf4bea48f08ff4a93c6aa502ea1349c"}, +] + +[package.dependencies] +botocore = "1.43.62" +colorama = ">=0.2.5,<0.4.7" +docutils = ">=0.18.1,<=0.19" +PyYAML = ">=3.10,<6.1" +rsa = ">=3.1.2,<4.8" +s3transfer = ">=0.19.0,<0.20.0" + [[package]] name = "awscli-local" version = "0.22.2" @@ -40,14 +60,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.43.55" +version = "1.43.62" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "botocore-1.43.55-py3-none-any.whl", hash = "sha256:b7ceb3070dffb64cc1cfdda3c32db538eb143c46ad9e9e781b0d8f90c9246f37"}, - {file = "botocore-1.43.55.tar.gz", hash = "sha256:46e8d9f457a804948abf45a22add963ebaaa6c2765c2f1c26ff3b534309d7a58"}, + {file = "botocore-1.43.62-py3-none-any.whl", hash = "sha256:76de153de1ba3e242b2e6df6a13ab8a3fb35d17db562462969e661457b63166e"}, + {file = "botocore-1.43.62.tar.gz", hash = "sha256:94efc419c9f0f41dc2415e4b6b62f04ae21b3ce3930fac47214c4d3f361ea8b8"}, ] [package.dependencies] @@ -58,172 +78,31 @@ urllib3 = ">=1.25.4,<2.2.0 || >2.2.0,<3" [package.extras] crt = ["awscrt (==0.36.0)"] -[[package]] -name = "certifi" -version = "2026.7.22" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"}, - {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.9" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-win32.whl", hash = "sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-win_amd64.whl", hash = "sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-win_arm64.whl", hash = "sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe"}, - {file = "charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5"}, - {file = "charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b"}, -] - [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["test"] -markers = "sys_platform == \"win32\"" +groups = ["main", "test"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {test = "sys_platform == \"win32\""} [[package]] -name = "docker" -version = "7.2.0" -description = "A Python library for the Docker Engine API." +name = "docutils" +version = "0.19" +description = "Docutils -- Python Documentation Utilities" optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" groups = ["main"] files = [ - {file = "docker-7.2.0-py3-none-any.whl", hash = "sha256:a3f45fdeb9165e2d25d9a1d02ddf3bc70fb572cf5ebbf9b58558c22caf29b71f"}, - {file = "docker-7.2.0.tar.gz", hash = "sha256:cebb93773d334f778e023a7ee352a8d6e13ab1bd3b863a4d4a59dec897df43ac"}, + {file = "docutils-0.19-py3-none-any.whl", hash = "sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc"}, + {file = "docutils-0.19.tar.gz", hash = "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6"}, ] -[package.dependencies] -pywin32 = {version = ">=304", markers = "sys_platform == \"win32\""} -requests = ">=2.26.0" -urllib3 = ">=1.26.0" - -[package.extras] -dev = ["coverage (==7.2.7)", "pytest (==7.4.2)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.1.0)", "ruff (==0.1.8)"] -docs = ["myst-parser (==0.18.0)", "sphinx (==5.1.1)"] -ssh = ["paramiko (>=2.4.3)"] -websockets = ["websocket-client (>=1.3.0)"] - -[[package]] -name = "idna" -version = "3.18" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, - {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, -] - -[package.extras] -all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - [[package]] name = "iniconfig" version = "2.3.0" @@ -311,6 +190,18 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "pyasn1" +version = "0.6.4" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b"}, + {file = "pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81"}, +] + [[package]] name = "pygments" version = "2.20.0" @@ -380,35 +271,86 @@ lark = ">=1.1.5,<2.0" regex = ">=2024.4.16" [[package]] -name = "pywin32" -version = "312" -description = "Python for Windows Extensions" +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.8" groups = ["main"] -markers = "sys_platform == \"win32\"" files = [ - {file = "pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e"}, - {file = "pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db"}, - {file = "pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd"}, - {file = "pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c"}, - {file = "pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a"}, - {file = "pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47"}, - {file = "pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b"}, - {file = "pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc"}, - {file = "pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950"}, - {file = "pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c"}, - {file = "pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9"}, - {file = "pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831"}, - {file = "pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b"}, - {file = "pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e"}, - {file = "pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa"}, - {file = "pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed"}, - {file = "pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5"}, - {file = "pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9"}, - {file = "pywin32-312-cp39-cp39-win32.whl", hash = "sha256:d620900033cc7531e50727c3c8333091df5dd3ffe6d68cdca38c03f5821408d5"}, - {file = "pywin32-312-cp39-cp39-win_amd64.whl", hash = "sha256:dc90147579a905b8635e1b0ec6514967dcb07e6e0d9c42f1477feef14cac23bb"}, - {file = "pywin32-312-cp39-cp39-win_arm64.whl", hash = "sha256:02ebca0f0242b75292e218065004310d6a477407c09fa449bfe4f6022bc0c0fc"}, + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] [[package]] @@ -536,26 +478,19 @@ files = [ ] [[package]] -name = "requests" -version = "2.34.2" -description = "Python HTTP for Humans." +name = "rsa" +version = "4.7.2" +description = "Pure-Python RSA implementation" optional = false -python-versions = ">=3.10" +python-versions = ">=3.5, <4" groups = ["main"] files = [ - {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, - {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, + {file = "rsa-4.7.2-py3-none-any.whl", hash = "sha256:78f9a9bf4e7be0c5ded4583326e7461e3a3c5aae24073648b4bdfa797d78c9d2"}, + {file = "rsa-4.7.2.tar.gz", hash = "sha256:9d689e6ca1b3038bc82bf8d23e944b6b6037bc02301a574935b2dd946e0353b9"}, ] [package.dependencies] -certifi = ">=2023.5.7" -charset_normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.26,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] +pyasn1 = ">=0.1.3" [[package]] name = "s3transfer" @@ -628,4 +563,4 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "70ee807a5c5b04cf244154a5205bb9f9e4db5ad0aa0ec30a44611841bb9e467e" +content-hash = "a37484c34a5165aa04c7304babc3041cd341fb0db257693457c42c0b5caa9b89" diff --git a/infrastructure/pyproject.toml b/infrastructure/pyproject.toml index 163ff5dd2e..e64ef0648a 100644 --- a/infrastructure/pyproject.toml +++ b/infrastructure/pyproject.toml @@ -10,8 +10,8 @@ license = "MIT" authors = [ "Arkadii Yakovets " ] packages = [ { include = "scripts" } ] dependencies.python = "^3.13" +dependencies.awscli = "1.45.62" dependencies.awscli-local = "0.22.2" -dependencies.docker = "7.2.0" dependencies.terraform-local = "0.26.0" group.test.dependencies.pytest = "9.0.3" From 86bf40b35cfe6e1ecddc27dc1207d84349229d45 Mon Sep 17 00:00:00 2001 From: Rudransh Shrivastava Date: Sun, 23 Aug 2026 19:29:30 +0530 Subject: [PATCH 13/16] grant access to docker socket with linux and macos support Signed-off-by: Rudransh Shrivastava --- docker-compose/infrastructure/compose.yaml | 9 +++++++++ infrastructure/make/run.mk | 3 +++ 2 files changed, 12 insertions(+) diff --git a/docker-compose/infrastructure/compose.yaml b/docker-compose/infrastructure/compose.yaml index 77d21c3648..b5104d15d2 100644 --- a/docker-compose/infrastructure/compose.yaml +++ b/docker-compose/infrastructure/compose.yaml @@ -30,6 +30,13 @@ services: environment: LOCALSTACK_AUTH_TOKEN: ${LOCALSTACK_AUTH_TOKEN} LOCALSTACK_HOST: localhost + group_add: + # Grant access to the mounted docker socket. Its in-container GID varies: + # Linux exposes the host docker group's GID; macOS runtimes (Docker Desktop, + # OrbStack, colima, etc) proxy the socket through a Linux VM and it typically + # shows up as root:root inside the container. + - '0' + - ${DOCKER_GID} image: nest-infrastructure network_mode: service:localstack pull_policy: never @@ -37,6 +44,8 @@ services: - infrastructure-terraform-plugin-cache:/home/owasp/.terraform.d/plugin-cache # Host Docker socket so runner can build containers (Backend, Frontend, etc.). - /var/run/docker.sock:/var/run/docker.sock + # Host repo as docker build context for backend/frontend images. + - ../..:/repo:ro networks: infrastructure-nest: diff --git a/infrastructure/make/run.mk b/infrastructure/make/run.mk index 6eeef6fab0..fcbea4b7c7 100644 --- a/infrastructure/make/run.mk +++ b/infrastructure/make/run.mk @@ -1,5 +1,8 @@ .PHONY: infrastructure-check-auth-token infrastructure-refresh infrastructure-up +DOCKER_GID := $(shell stat -c '%g' /var/run/docker.sock 2>/dev/null || stat -f '%g' /var/run/docker.sock) +export DOCKER_GID + INFRASTRUCTURE_COMPOSE = docker compose \ --project-name nest-infrastructure \ -f docker-compose/infrastructure/compose.yaml From 99bca3f03a1f3ea8a7fc40102f595b2f3dcb36d7 Mon Sep 17 00:00:00 2001 From: Rudransh Shrivastava Date: Sun, 23 Aug 2026 19:31:53 +0530 Subject: [PATCH 14/16] add stdin support to commend runner Signed-off-by: Rudransh Shrivastava --- infrastructure/scripts/commands.py | 3 +++ infrastructure/tests/scripts/commands_test.py | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/infrastructure/scripts/commands.py b/infrastructure/scripts/commands.py index 982e9623f2..8fa7b7d382 100644 --- a/infrastructure/scripts/commands.py +++ b/infrastructure/scripts/commands.py @@ -35,6 +35,7 @@ def run( *args: str, check: bool = False, capture_output: bool = False, + stdin_input: str | None = None, ) -> subprocess.CompletedProcess[str]: """Run a command. @@ -44,6 +45,7 @@ def run( check (bool): Whether to raise an exception if the command exits with a non-zero status. capture_output (bool): Whether to capture stdout and stderr. + stdin_input (str, optional): String written to the command's stdin. Returns: subprocess.CompletedProcess[str]: The result of the executed command. @@ -54,5 +56,6 @@ def run( [executable, *args], check=check, capture_output=capture_output, + input=stdin_input, text=True, ) diff --git a/infrastructure/tests/scripts/commands_test.py b/infrastructure/tests/scripts/commands_test.py index 706656a6c0..c83ecb7d5a 100644 --- a/infrastructure/tests/scripts/commands_test.py +++ b/infrastructure/tests/scripts/commands_test.py @@ -43,6 +43,7 @@ def test_run_resolves_executable_and_args( ["/usr/bin/terraform-run", "init", "-backend=false"], check=False, capture_output=False, + input=None, text=True, ) @@ -59,6 +60,24 @@ def test_run_forwards_check_and_capture_output( ["/usr/bin/docker", "info"], check=True, capture_output=True, + input=None, + text=True, + ) + + @patch("subprocess.run") + @patch("shutil.which") + def test_run_forwards_stdin_input( + self, + mock_which: MagicMock, + mock_run: MagicMock, + ) -> None: + mock_which.return_value = "/usr/bin/docker" + CommandRunner().run("docker", "login", stdin_input="secret") + mock_run.assert_called_once_with( + ["/usr/bin/docker", "login"], + check=False, + capture_output=False, + input="secret", text=True, ) From 506ffeb2d03daa68147ffcf7e7b55988c5c6241a Mon Sep 17 00:00:00 2001 From: Rudransh Shrivastava Date: Sun, 23 Aug 2026 20:01:45 +0530 Subject: [PATCH 15/16] add image management and orchestration Add .env.localstack; this file contains only NEXT_PUBLIC_* environment variables. This file may be updated in the future to use a Route53 DNS. It's required because frontend production image injects these variables at build time. Also add constants.py Signed-off-by: Rudransh Shrivastava --- .gitignore | 1 + frontend/.env.localstack | 11 + infrastructure/scripts/constants.py | 7 + infrastructure/scripts/images.py | 170 ++++++++++++++++ infrastructure/tests/scripts/images_test.py | 211 ++++++++++++++++++++ 5 files changed, 400 insertions(+) create mode 100644 frontend/.env.localstack create mode 100644 infrastructure/scripts/constants.py create mode 100644 infrastructure/scripts/images.py create mode 100644 infrastructure/tests/scripts/images_test.py diff --git a/.gitignore b/.gitignore index 670e6f0c40..43c1a4dbe5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ __pycache__ !.env.e2e !.env.e2e-tests !.env.fuzz-tests +!.env.localstack !.env.unit-tests .github/instructions/snyk_rules.instructions.md .idea diff --git a/frontend/.env.localstack b/frontend/.env.localstack new file mode 100644 index 0000000000..cce9ef5ea1 --- /dev/null +++ b/frontend/.env.localstack @@ -0,0 +1,11 @@ +NEXT_PUBLIC_API_URL=/ +NEXT_PUBLIC_CSRF_URL=/csrf/ +NEXT_PUBLIC_ENVIRONMENT=production +NEXT_PUBLIC_GRAPHQL_URL=/graphql/ +NEXT_PUBLIC_GTM_ID= +NEXT_PUBLIC_IDX_URL=/idx/ +NEXT_PUBLIC_IS_PROJECT_HEALTH_ENABLED=true +NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com +NEXT_PUBLIC_POSTHOG_KEY= +NEXT_PUBLIC_RELEASE_VERSION= +NEXT_PUBLIC_SENTRY_DSN= diff --git a/infrastructure/scripts/constants.py b/infrastructure/scripts/constants.py new file mode 100644 index 0000000000..81d2404cbc --- /dev/null +++ b/infrastructure/scripts/constants.py @@ -0,0 +1,7 @@ +"""Shared constants for infrastructure scripts.""" + +from pathlib import Path + +LIVE_DIR = Path("infrastructure/live") +SOURCE_REPO_DIR = Path("/repo") +STATE_DIR = Path("infrastructure/state") diff --git a/infrastructure/scripts/images.py b/infrastructure/scripts/images.py new file mode 100644 index 0000000000..9520703a0b --- /dev/null +++ b/infrastructure/scripts/images.py @@ -0,0 +1,170 @@ +"""ECR image management and orchestration.""" + +from pathlib import Path + +from scripts.commands import CommandRunner +from scripts.constants import LIVE_DIR, SOURCE_REPO_DIR +from scripts.errors import RunnerError +from scripts.localstack import LocalStack + +IMAGE_CONFIG = { + "backend": {"target": "backend", "buildargs": None}, + "frontend": {"target": None, "buildargs": {"ENV_FILE": ".env.localstack"}}, +} + + +class ImageManager: + """ECR image manager.""" + + def __init__( + self, + root_dir: Path | None = None, + *, + commands: CommandRunner | None = None, + localstack: LocalStack | None = None, + ) -> None: + """Initialize the ECR image manager. + + Args: + root_dir (Path, optional): The root directory of the project. + commands (CommandRunner, optional): Command runner instance. + localstack (LocalStack, optional): LocalStack manager instance. + + """ + self.root_dir = root_dir or Path(__file__).resolve().parent.parent.parent + self.commands = commands or CommandRunner() + self.localstack = localstack or LocalStack(self.commands) + + def build(self, service: str, tag: str) -> None: + """Build the Docker image for a service. + + Args: + service (str): The service name (must be a key of IMAGE_CONFIG). + tag (str): The tag to apply to the built image. + + Raises: + RunnerError: If docker build exits with a non-zero status. + + """ + self.commands.require("docker") + config = IMAGE_CONFIG[service] + args = [ + "buildx", + "build", + "--load", + "--file", + str(SOURCE_REPO_DIR / "docker" / service / "Dockerfile"), + "--tag", + f"{self.repository_url(service)}:{tag}", + ] + if config["target"] is not None: + args += ["--target", config["target"]] + for key, value in (config["buildargs"] or {}).items(): + args += ["--build-arg", f"{key}={value}"] + args.append(str(SOURCE_REPO_DIR / service)) + + result = self.commands.run("docker", *args) + if result.returncode != 0: + message = f"docker build failed for {service}" + raise RunnerError(message) + + def login(self) -> None: + """Authenticate the Docker CLI against ECR. + + Raises: + RunnerError: If awslocal or docker login fails. + + """ + self.commands.require("awslocal") + self.commands.require("docker") + + password_result = self.commands.run( + "awslocal", + "ecr", + "get-login-password", + capture_output=True, + ) + if password_result.returncode != 0: + message = ( + "awslocal ecr get-login-password failed " + f"(rc={password_result.returncode}): " + f"stdout={password_result.stdout!r} stderr={password_result.stderr!r}" + ) + raise RunnerError(message) + + login_result = self.commands.run( + "docker", + "login", + "--username", + "AWS", + "--password-stdin", + self.registry_url(), + capture_output=True, + stdin_input=password_result.stdout, + ) + if login_result.returncode != 0: + message = f"docker login failed: {login_result.stderr}" + raise RunnerError(message) + + def push(self, service: str, tag: str) -> None: + """Push the tagged image for a service to ECR. + + Args: + service (str): The service name. + tag (str): The tag to push. + + Raises: + RunnerError: If docker push exits with a non-zero status. + + """ + self.commands.require("docker") + result = self.commands.run( + "docker", + "push", + f"{self.repository_url(service)}:{tag}", + ) + if result.returncode != 0: + message = f"docker push failed for {service}" + raise RunnerError(message) + + def registry_url(self) -> str: + """Return the ECR registry host shared by all service repositories. + + Returns: + str: The ECR registry host (e.g. ".dkr.ecr...."). + + Raises: + RunnerError: If the underlying Terraform output command fails. + + """ + # Any service's repository URL yields the same registry host. + any_service = next(iter(IMAGE_CONFIG)) + return self.repository_url(any_service).split("/", 1)[0] + + def repository_url(self, service: str) -> str: + """Return the ECR repository URL for a service from Terraform output. + + Args: + service (str): The service name (e.g., "backend", "frontend"). + + Returns: + str: The full ECR repository URL. + + Raises: + RunnerError: If the Terraform output command fails. + + """ + self.commands.require("terraform") + live_dir = self.root_dir / LIVE_DIR + result = self.commands.run( + "terraform", + f"-chdir={live_dir}", + "output", + "-raw", + f"{service}_ecr_repository_url", + capture_output=True, + ) + if result.returncode != 0: + message = f"terraform output {service}_ecr_repository_url failed: {result.stderr}" + raise RunnerError(message) + return result.stdout.strip() diff --git a/infrastructure/tests/scripts/images_test.py b/infrastructure/tests/scripts/images_test.py new file mode 100644 index 0000000000..91415b7de3 --- /dev/null +++ b/infrastructure/tests/scripts/images_test.py @@ -0,0 +1,211 @@ +"""Tests for ``scripts.images``.""" + +from pathlib import Path +from unittest.mock import MagicMock, call + +import pytest + +from scripts.commands import CommandRunner +from scripts.constants import SOURCE_REPO_DIR +from scripts.errors import RunnerError +from scripts.images import IMAGE_CONFIG, ImageManager +from scripts.localstack import LocalStack + +REGISTRY_HOST = "000000000000.dkr.ecr.us-east-1.amazonaws.com" +BACKEND_REPO_URL = f"{REGISTRY_HOST}/nest-production-backend" + + +def build_manager(commands: MagicMock | None = None) -> ImageManager: + return ImageManager( + root_dir=Path("/repo-root"), + commands=commands or MagicMock(spec=CommandRunner), + localstack=MagicMock(spec=LocalStack), + ) + + +class TestImageConfig: + """Tests for the ``IMAGE_CONFIG`` module-level mapping.""" + + def test_backend_config(self) -> None: + assert IMAGE_CONFIG["backend"] == {"target": "backend", "buildargs": None} + + def test_frontend_config(self) -> None: + assert IMAGE_CONFIG["frontend"] == { + "target": None, + "buildargs": {"ENV_FILE": ".env.localstack"}, + } + + +class TestImageManager: + """Tests for ``ImageManager`` behavior.""" + + def test_build_shells_out_to_docker_buildx_with_backend_config(self) -> None: + commands = MagicMock(spec=CommandRunner) + commands.run.side_effect = [ + MagicMock(returncode=0, stdout=BACKEND_REPO_URL), + MagicMock(returncode=0), + ] + manager = build_manager(commands=commands) + + manager.build("backend", "tag-1") + + assert commands.run.call_args_list[-1] == call( + "docker", + "buildx", + "build", + "--load", + "--file", + str(SOURCE_REPO_DIR / "docker" / "backend" / "Dockerfile"), + "--tag", + f"{BACKEND_REPO_URL}:tag-1", + "--target", + "backend", + str(SOURCE_REPO_DIR / "backend"), + ) + + def test_build_shells_out_to_docker_buildx_with_frontend_config(self) -> None: + commands = MagicMock(spec=CommandRunner) + frontend_repo = f"{REGISTRY_HOST}/nest-production-frontend" + commands.run.side_effect = [ + MagicMock(returncode=0, stdout=frontend_repo), + MagicMock(returncode=0), + ] + manager = build_manager(commands=commands) + + manager.build("frontend", "tag-1") + + assert commands.run.call_args_list[-1] == call( + "docker", + "buildx", + "build", + "--load", + "--file", + str(SOURCE_REPO_DIR / "docker" / "frontend" / "Dockerfile"), + "--tag", + f"{frontend_repo}:tag-1", + "--build-arg", + f"ENV_FILE={IMAGE_CONFIG['frontend']['buildargs']['ENV_FILE']}", + str(SOURCE_REPO_DIR / "frontend"), + ) + + def test_build_raises_when_docker_build_fails(self) -> None: + commands = MagicMock(spec=CommandRunner) + commands.run.side_effect = [ + MagicMock(returncode=0, stdout=BACKEND_REPO_URL), + MagicMock(returncode=1), + ] + manager = build_manager(commands=commands) + + with pytest.raises(RunnerError, match="docker build failed for backend"): + manager.build("backend", "tag-1") + + def test_login_pipes_awslocal_password_into_docker_login(self) -> None: + commands = MagicMock(spec=CommandRunner) + commands.run.side_effect = [ + MagicMock(returncode=0, stdout="password-123\n"), + MagicMock(returncode=0, stdout=BACKEND_REPO_URL), + MagicMock(returncode=0), + ] + manager = build_manager(commands=commands) + + manager.login() + + commands.require.assert_any_call("awslocal") + commands.require.assert_any_call("docker") + assert commands.run.call_args_list[0] == call( + "awslocal", + "ecr", + "get-login-password", + capture_output=True, + ) + assert commands.run.call_args_list[-1] == call( + "docker", + "login", + "--username", + "AWS", + "--password-stdin", + REGISTRY_HOST, + capture_output=True, + stdin_input="password-123\n", + ) + + def test_login_raises_when_awslocal_fails(self) -> None: + commands = MagicMock(spec=CommandRunner) + commands.run.return_value = MagicMock(returncode=1, stdout="", stderr="boom") + manager = build_manager(commands=commands) + + with pytest.raises(RunnerError, match="awslocal ecr get-login-password"): + manager.login() + + def test_login_raises_when_docker_login_fails(self) -> None: + commands = MagicMock(spec=CommandRunner) + commands.run.side_effect = [ + MagicMock(returncode=0, stdout="password-123\n"), + MagicMock(returncode=0, stdout=BACKEND_REPO_URL), + MagicMock(returncode=1, stderr="unauthorized"), + ] + manager = build_manager(commands=commands) + + with pytest.raises(RunnerError, match="docker login failed"): + manager.login() + + def test_push_shells_out_to_docker_push(self) -> None: + commands = MagicMock(spec=CommandRunner) + commands.run.side_effect = [ + MagicMock(returncode=0, stdout=BACKEND_REPO_URL), + MagicMock(returncode=0), + ] + manager = build_manager(commands=commands) + + manager.push("backend", "tag-1") + + assert commands.run.call_args_list[-1] == call( + "docker", + "push", + f"{BACKEND_REPO_URL}:tag-1", + ) + + def test_push_raises_when_docker_push_fails(self) -> None: + commands = MagicMock(spec=CommandRunner) + commands.run.side_effect = [ + MagicMock(returncode=0, stdout=BACKEND_REPO_URL), + MagicMock(returncode=1), + ] + manager = build_manager(commands=commands) + + with pytest.raises(RunnerError, match="docker push failed for backend"): + manager.push("backend", "tag-1") + + def test_registry_url_derives_host_from_backend_repository(self) -> None: + commands = MagicMock(spec=CommandRunner) + commands.run.return_value = MagicMock(returncode=0, stdout=BACKEND_REPO_URL) + manager = build_manager(commands=commands) + + assert manager.registry_url() == REGISTRY_HOST + + def test_repository_url_shells_out_to_terraform_output(self) -> None: + commands = MagicMock(spec=CommandRunner) + commands.run.return_value = MagicMock(returncode=0, stdout=f"{BACKEND_REPO_URL}\n") + manager = build_manager(commands=commands) + + result = manager.repository_url("backend") + + commands.require.assert_called_once_with("terraform") + live_dir = str(Path("/repo-root") / "infrastructure" / "live") + commands.run.assert_called_once_with( + "terraform", + f"-chdir={live_dir}", + "output", + "-raw", + "backend_ecr_repository_url", + capture_output=True, + ) + assert result == BACKEND_REPO_URL + + def test_repository_url_raises_when_terraform_fails(self) -> None: + commands = MagicMock(spec=CommandRunner) + commands.run.return_value = MagicMock(returncode=1, stdout="", stderr="err") + manager = build_manager(commands=commands) + + with pytest.raises(RunnerError, match="terraform output backend_ecr_repository_url"): + manager.repository_url("backend") From 2e0b6de04026f0c2e0ecfe58028da25145425399 Mon Sep 17 00:00:00 2001 From: Rudransh Shrivastava Date: Sun, 23 Aug 2026 21:35:00 +0530 Subject: [PATCH 16/16] add push images step and implement refresh Restart service tasks manually as LocalStack does not deploy them when task definition gets updated. This might be a bug upstream as update-service --force-new-deployment does not work either. Signed-off-by: Rudransh Shrivastava --- infrastructure/scripts/deploy_runner.py | 250 ++++++++++++++-- .../tests/scripts/deploy_runner_test.py | 266 +++++++++++++++++- 2 files changed, 490 insertions(+), 26 deletions(-) diff --git a/infrastructure/scripts/deploy_runner.py b/infrastructure/scripts/deploy_runner.py index b67b07c8e9..e7386f6d94 100644 --- a/infrastructure/scripts/deploy_runner.py +++ b/infrastructure/scripts/deploy_runner.py @@ -1,12 +1,20 @@ -"""Infrastructure deployment orchestration utilities.""" +"""Infrastructure deployment orchestration.""" +import json import logging +import time from pathlib import Path from scripts.commands import CommandRunner +from scripts.constants import LIVE_DIR, STATE_DIR from scripts.errors import RunnerError +from scripts.images import IMAGE_CONFIG, ImageManager from scripts.localstack import LocalStack -from scripts.utils import chdir_repository_root, configure_terraform_cache, set_temporary_env +from scripts.utils import ( + chdir_repository_root, + configure_terraform_cache, + set_temporary_env, +) logger = logging.getLogger(__name__) @@ -22,6 +30,7 @@ def __init__( root_dir: Path | None = None, *, commands: CommandRunner | None = None, + images: ImageManager | None = None, localstack: LocalStack | None = None, ) -> None: """Initialize the infrastructure deployment orchestrator. @@ -29,41 +38,54 @@ def __init__( Args: root_dir (Path, optional): The root directory of the project. commands (CommandRunner, optional): Command runner instance. + images (ImageManager, optional): ECR image manager instance. localstack (LocalStack, optional): LocalStack manager instance. """ self.root_dir = root_dir or Path(__file__).resolve().parent.parent.parent self.commands = commands or CommandRunner() self.localstack = localstack or LocalStack(self.commands) + self.images = images or ImageManager( + self.root_dir, + commands=self.commands, + localstack=self.localstack, + ) - def apply_live(self) -> None: + def apply_live( + self, + *, + refresh: bool = True, + var_overrides: dict[str, str] | None = None, + ) -> None: """Initialize and apply the live/ Terraform configuration. + Args: + refresh (bool): Whether Terraform should refresh state from the + real infrastructure before planning. Defaults to True. + var_overrides (dict[str, str], optional): Terraform variable + values that take precedence over the tfvars file. + Raises: RunnerError: If a Terraform command exits with a non-zero status. """ - live_dir = self.root_dir / "infrastructure" / "live" - init_result = self.commands.run( - "tflocal", - f"-chdir={live_dir}", - "init", - f"-backend-config={LOCALSTACK_TFBACKEND}", - "-input=false", - "-reconfigure", - check=False, - ) - if init_result.returncode != 0: - message = f"terraform init failed in {live_dir}" - raise RunnerError(message) - - apply_result = self.commands.run( + self.init_live() + live_dir = self.root_dir / LIVE_DIR + apply_args = [ "tflocal", f"-chdir={live_dir}", "apply", "-auto-approve", "-input=false", f"-var-file={LOCALSTACK_TFVARS}", + ] + for key, value in (var_overrides or {}).items(): + apply_args += ["-var", f"{key}={value}"] + if not refresh: + apply_args.append("-refresh=false") + + apply_result = self.commands.run( + *apply_args, check=False, ) if apply_result.returncode != 0: @@ -77,7 +99,7 @@ def apply_state(self) -> None: RunnerError: If a Terraform command exits with a non-zero status. """ - state_dir = self.root_dir / "infrastructure" / "state" + state_dir = self.root_dir / STATE_DIR init_result = self.commands.run( "tflocal", f"-chdir={state_dir}", @@ -130,6 +152,41 @@ def deploy(self) -> None: self.apply_live() logger.info("Deployment on LocalStack successful!") + def init_live(self) -> None: + """Initialize Terraform for the live/ configuration. + + Raises: + RunnerError: If terraform init exits with a non-zero status. + + """ + live_dir = self.root_dir / LIVE_DIR + result = self.commands.run( + "tflocal", + f"-chdir={live_dir}", + "init", + f"-backend-config={LOCALSTACK_TFBACKEND}", + "-input=false", + "-reconfigure", + check=False, + ) + if result.returncode != 0: + message = f"terraform init failed in {live_dir}" + raise RunnerError(message) + + def push_images(self) -> dict[str, str]: + """Build and push all service images to ECR. + + Returns: + dict[str, str]: Mapping of service name to the pushed image tag. + + """ + self.images.login() + tag = str(int(time.time())) + for service in IMAGE_CONFIG: + self.images.build(service, tag) + self.images.push(service, tag) + return dict.fromkeys(IMAGE_CONFIG, tag) + def refresh(self) -> None: """Orchestrate a deployment refresh. @@ -145,5 +202,158 @@ def refresh(self) -> None: set_temporary_env("AWS_ENDPOINT_URL", self.localstack.api_url), set_temporary_env("AWS_SECRET_ACCESS_KEY", "test"), ): - self.apply_live() + self.init_live() + tags = self.push_images() + # TODO(rudransh-shrivastava): LocalStack updates port mappings, + # computed endpoints, and more to its preferred configuration after terraform apply. + # use -refresh=false to prevent updating them back. + # Ideal fix is to pass LocalStack preferred configuration via tfvars. + self.apply_live( + refresh=False, + var_overrides={f"{svc}_image_tag": tag for svc, tag in tags.items()}, + ) + # LocalStack's ECS scheduler does not spawn tasks after service updates. + # Stop existing tasks and run new tasks. + for service in IMAGE_CONFIG: + self.restart_service_task( + cluster=f"nest-production-{service}-cluster", + service=f"nest-production-{service}-service", + ) logger.info("Deployment on LocalStack successful!") + + def restart_service_task(self, *, cluster: str, service: str) -> str: + """Stop existing tasks in a cluster and start a fresh task for a service. + + Args: + cluster (str): The ECS cluster hosting the service. + service (str): The ECS service name. + + Returns: + str: The ARN of the newly started task. + + Raises: + RunnerError: If any awslocal command fails. + + """ + self.commands.require("awslocal") + self.stop_cluster_tasks(cluster) + result = self.commands.run( + "awslocal", + "ecs", + "describe-services", + "--cluster", + cluster, + "--services", + service, + capture_output=True, + ) + if result.returncode != 0: + message = f"awslocal ecs describe-services failed: {result.stderr}" + raise RunnerError(message) + + service_data = json.loads(result.stdout)["services"][0] + network = service_data["networkConfiguration"]["awsvpcConfiguration"] + return self.run_task( + cluster=cluster, + task_definition=service_data["taskDefinition"], + subnets=network["subnets"], + security_groups=network["securityGroups"], + ) + + def run_task( + self, + *, + cluster: str, + task_definition: str, + subnets: list[str], + security_groups: list[str], + ) -> str: + """Start a single ECS task and return its ARN. + + Args: + cluster (str): The ECS cluster name or ARN. + task_definition (str): The task definition family or ARN. + subnets (list[str]): Subnet IDs for the task network configuration. + security_groups (list[str]): Security group IDs for the task network configuration. + + Returns: + str: The ARN of the started task. + + Raises: + RunnerError: If awslocal ecs run-task fails. + + """ + self.commands.require("awslocal") + network = ( + "awsvpcConfiguration={" + f"subnets=[{','.join(subnets)}]," + f"securityGroups=[{','.join(security_groups)}]," + "assignPublicIp=ENABLED" + "}" + ) + result = self.commands.run( + "awslocal", + "ecs", + "run-task", + "--cluster", + cluster, + "--task-definition", + task_definition, + "--launch-type", + "FARGATE", + "--network-configuration", + network, + "--query", + "tasks[0].taskArn", + "--output", + "text", + capture_output=True, + ) + if result.returncode != 0: + message = f"awslocal ecs run-task failed: {result.stderr}" + raise RunnerError(message) + return result.stdout.strip() + + def stop_cluster_tasks(self, cluster: str) -> None: + """Stop all RUNNING tasks in a cluster. + + Args: + cluster (str): The ECS cluster name or ARN. + + Raises: + RunnerError: If awslocal ecs list-tasks or stop-task fails. + + """ + self.commands.require("awslocal") + list_result = self.commands.run( + "awslocal", + "ecs", + "list-tasks", + "--cluster", + cluster, + "--desired-status", + "RUNNING", + "--query", + "taskArns", + "--output", + "text", + capture_output=True, + ) + if list_result.returncode != 0: + message = f"awslocal ecs list-tasks failed: {list_result.stderr}" + raise RunnerError(message) + + for arn in list_result.stdout.split(): + stop_result = self.commands.run( + "awslocal", + "ecs", + "stop-task", + "--cluster", + cluster, + "--task", + arn, + capture_output=True, + ) + if stop_result.returncode != 0: + message = f"awslocal ecs stop-task failed for {arn}: {stop_result.stderr}" + raise RunnerError(message) diff --git a/infrastructure/tests/scripts/deploy_runner_test.py b/infrastructure/tests/scripts/deploy_runner_test.py index d8eea3fd69..961abc23ec 100644 --- a/infrastructure/tests/scripts/deploy_runner_test.py +++ b/infrastructure/tests/scripts/deploy_runner_test.py @@ -14,6 +14,7 @@ InfrastructureDeployRunner, ) from scripts.errors import RunnerError +from scripts.images import ImageManager from scripts.localstack import LocalStack LOCALSTACK_ENDPOINT_URL = "http://localstack:4566" # NOSONAR: Test-only LocalStack HTTP. @@ -26,10 +27,16 @@ def assert_aws_env_unset() -> None: assert var not in os.environ -def build_runner(commands: MagicMock, localstack: MagicMock) -> InfrastructureDeployRunner: +def build_runner( + commands: MagicMock, + localstack: MagicMock, + *, + images: MagicMock | None = None, +) -> InfrastructureDeployRunner: return InfrastructureDeployRunner( root_dir=Path("/repo"), commands=commands, + images=images, localstack=localstack, ) @@ -144,6 +151,57 @@ def test_apply_live_runs_init_and_apply(self) -> None: ] ) + def test_apply_live_appends_refresh_false_when_refresh_disabled(self) -> None: + commands = MagicMock(spec=CommandRunner) + commands.run.return_value = MagicMock(returncode=0) + runner = build_runner(commands, MagicMock(spec=LocalStack)) + + runner.apply_live(refresh=False) + + live_dir = str(Path("/repo") / "infrastructure" / "live") + commands.run.assert_has_calls( + [ + call( + "tflocal", + f"-chdir={live_dir}", + "apply", + "-auto-approve", + "-input=false", + f"-var-file={LOCALSTACK_TFVARS}", + "-refresh=false", + check=False, + ), + ] + ) + + def test_apply_live_appends_var_overrides_after_var_file(self) -> None: + commands = MagicMock(spec=CommandRunner) + commands.run.return_value = MagicMock(returncode=0) + runner = build_runner(commands, MagicMock(spec=LocalStack)) + + runner.apply_live( + var_overrides={"backend_image_tag": "tag-1", "frontend_image_tag": "tag-2"} + ) + + live_dir = str(Path("/repo") / "infrastructure" / "live") + commands.run.assert_has_calls( + [ + call( + "tflocal", + f"-chdir={live_dir}", + "apply", + "-auto-approve", + "-input=false", + f"-var-file={LOCALSTACK_TFVARS}", + "-var", + "backend_image_tag=tag-1", + "-var", + "frontend_image_tag=tag-2", + check=False, + ), + ] + ) + def test_apply_live_raises_when_init_fails(self) -> None: commands = MagicMock(spec=CommandRunner) commands.run.return_value = MagicMock(returncode=1) @@ -160,6 +218,32 @@ def test_apply_live_raises_when_apply_fails(self) -> None: with pytest.raises(RunnerError, match="terraform apply failed"): runner.apply_live() + def test_init_live_runs_terraform_init(self) -> None: + commands = MagicMock(spec=CommandRunner) + commands.run.return_value = MagicMock(returncode=0) + runner = build_runner(commands, MagicMock(spec=LocalStack)) + + runner.init_live() + + live_dir = str(Path("/repo") / "infrastructure" / "live") + commands.run.assert_called_once_with( + "tflocal", + f"-chdir={live_dir}", + "init", + f"-backend-config={LOCALSTACK_TFBACKEND}", + "-input=false", + "-reconfigure", + check=False, + ) + + def test_init_live_raises_when_init_fails(self) -> None: + commands = MagicMock(spec=CommandRunner) + commands.run.return_value = MagicMock(returncode=1) + runner = build_runner(commands, MagicMock(spec=LocalStack)) + + with pytest.raises(RunnerError, match="terraform init failed"): + runner.init_live() + @patch.dict(os.environ, {}, clear=True) def test_deploy_calls_apply_state_then_apply_live_inside_aws_env(self) -> None: commands = MagicMock(spec=CommandRunner) @@ -205,30 +289,200 @@ def test_deploy_propagates_wait_ready_failure(self) -> None: commands.run.assert_not_called() + def test_push_images_logs_in_and_builds_and_pushes_each_service(self) -> None: + commands = MagicMock(spec=CommandRunner) + images = MagicMock(spec=ImageManager) + runner = build_runner(commands, MagicMock(spec=LocalStack), images=images) + + result = runner.push_images() + + images.login.assert_called_once_with() + assert set(result) == {"backend", "frontend"} + tag = result["backend"] + assert result["frontend"] == tag + for service in ("backend", "frontend"): + images.build.assert_any_call(service, tag) + images.push.assert_any_call(service, tag) + @patch.dict(os.environ, {}, clear=True) - def test_refresh_calls_apply_live_inside_aws_env(self) -> None: + def test_refresh_pushes_images_applies_live_and_restarts_service_tasks(self) -> None: commands = MagicMock(spec=CommandRunner) localstack = MagicMock(spec=LocalStack) localstack.api_url = LOCALSTACK_ENDPOINT_URL - runner = build_runner(commands, localstack) + images = MagicMock(spec=ImageManager) + runner = build_runner(commands, localstack, images=images) + tag = "1735000000" captured: dict[str, str] = {} - def record_live() -> None: + def record_live(**_kwargs) -> None: for var in AWS_ENV_VARS: captured[var] = os.environ[var] - with patch.object(runner, "apply_live", side_effect=record_live) as mock_live: + with ( + patch.object(runner, "init_live") as mock_init, + patch.object( + runner, + "push_images", + return_value={"backend": tag, "frontend": tag}, + ) as mock_push, + patch.object(runner, "apply_live", side_effect=record_live) as mock_live, + patch.object(runner, "restart_service_task") as mock_restart, + ): runner.refresh() commands.require.assert_called_once_with("tflocal") localstack.wait_ready.assert_called_once() - mock_live.assert_called_once_with() + mock_init.assert_called_once_with() + mock_push.assert_called_once_with() + mock_live.assert_called_once_with( + refresh=False, + var_overrides={"backend_image_tag": tag, "frontend_image_tag": tag}, + ) + mock_restart.assert_any_call( + cluster="nest-production-backend-cluster", + service="nest-production-backend-service", + ) + mock_restart.assert_any_call( + cluster="nest-production-frontend-cluster", + service="nest-production-frontend-service", + ) assert captured["AWS_ACCESS_KEY_ID"] == FAKE_CREDENTIAL assert captured["AWS_SECRET_ACCESS_KEY"] == FAKE_CREDENTIAL assert captured["AWS_ENDPOINT_URL"] == LOCALSTACK_ENDPOINT_URL assert_aws_env_unset() + def test_run_task_shells_out_to_awslocal_and_returns_arn(self) -> None: + commands = MagicMock(spec=CommandRunner) + commands.run.return_value = MagicMock( + returncode=0, + stdout="arn:aws:ecs:us-east-1:000000000000:task/cluster/abc-123\n", + ) + runner = build_runner(commands, MagicMock(spec=LocalStack)) + + arn = runner.run_task( + cluster="c", + task_definition="td", + subnets=["sub-1", "sub-2"], + security_groups=["sg-1"], + ) + + commands.require.assert_called_once_with("awslocal") + commands.run.assert_called_once_with( + "awslocal", + "ecs", + "run-task", + "--cluster", + "c", + "--task-definition", + "td", + "--launch-type", + "FARGATE", + "--network-configuration", + "awsvpcConfiguration={subnets=[sub-1,sub-2],securityGroups=[sg-1],assignPublicIp=ENABLED}", + "--query", + "tasks[0].taskArn", + "--output", + "text", + capture_output=True, + ) + assert arn == "arn:aws:ecs:us-east-1:000000000000:task/cluster/abc-123" + + def test_run_task_raises_when_awslocal_fails(self) -> None: + commands = MagicMock(spec=CommandRunner) + commands.run.return_value = MagicMock(returncode=1, stderr="boom") + runner = build_runner(commands, MagicMock(spec=LocalStack)) + + with pytest.raises(RunnerError, match="awslocal ecs run-task failed"): + runner.run_task( + cluster="c", + task_definition="td", + subnets=["s"], + security_groups=["sg"], + ) + + def test_stop_cluster_tasks_lists_and_stops_each(self) -> None: + commands = MagicMock(spec=CommandRunner) + commands.run.side_effect = [ + MagicMock(returncode=0, stdout="arn-1\tarn-2\n"), + MagicMock(returncode=0), + MagicMock(returncode=0), + ] + runner = build_runner(commands, MagicMock(spec=LocalStack)) + + runner.stop_cluster_tasks("c") + + commands.require.assert_called_once_with("awslocal") + assert commands.run.call_args_list[0] == call( + "awslocal", + "ecs", + "list-tasks", + "--cluster", + "c", + "--desired-status", + "RUNNING", + "--query", + "taskArns", + "--output", + "text", + capture_output=True, + ) + assert commands.run.call_args_list[1] == call( + "awslocal", + "ecs", + "stop-task", + "--cluster", + "c", + "--task", + "arn-1", + capture_output=True, + ) + assert commands.run.call_args_list[2] == call( + "awslocal", + "ecs", + "stop-task", + "--cluster", + "c", + "--task", + "arn-2", + capture_output=True, + ) + + def test_stop_cluster_tasks_no_op_when_no_running_tasks(self) -> None: + commands = MagicMock(spec=CommandRunner) + commands.run.return_value = MagicMock(returncode=0, stdout="\n") + runner = build_runner(commands, MagicMock(spec=LocalStack)) + + runner.stop_cluster_tasks("c") + + assert commands.run.call_count == 1 + + def test_restart_service_task_stops_then_runs_new_task(self) -> None: + commands = MagicMock(spec=CommandRunner) + runner = build_runner(commands, MagicMock(spec=LocalStack)) + + describe_json = ( + '{"services":[{"taskDefinition":"td-arn",' + '"networkConfiguration":{"awsvpcConfiguration":' + '{"subnets":["sub-1"],"securityGroups":["sg-1"],"assignPublicIp":"DISABLED"}}}]}' + ) + commands.run.return_value = MagicMock(returncode=0, stdout=describe_json) + + with ( + patch.object(runner, "stop_cluster_tasks") as mock_stop, + patch.object(runner, "run_task", return_value="new-arn") as mock_run, + ): + arn = runner.restart_service_task(cluster="c", service="s") + + mock_stop.assert_called_once_with("c") + mock_run.assert_called_once_with( + cluster="c", + task_definition="td-arn", + subnets=["sub-1"], + security_groups=["sg-1"], + ) + assert arn == "new-arn" + def test_refresh_propagates_wait_ready_failure(self) -> None: commands = MagicMock(spec=CommandRunner) localstack = MagicMock(spec=LocalStack)