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/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/docker-compose/infrastructure/compose.yaml b/docker-compose/infrastructure/compose.yaml index 96e3773ea7..b5104d15d2 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: @@ -29,11 +30,22 @@ 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 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 + # Host repo as docker build context for backend/frontend images. + - ../..:/repo:ro networks: infrastructure-nest: 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/docker/localstack/Dockerfile b/docker/localstack/Dockerfile index c45042b72d..be751b0b78 100644 --- a/docker/localstack/Dockerfile +++ b/docker/localstack/Dockerfile @@ -1 +1 @@ -FROM localstack/localstack:2026.7.1@sha256:bdc261f58767dda5385800cfcefae7e8036f91428f26117b29c4572a7a9bbdc9 +FROM localstack/localstack:2026.7.4@sha256:f7b778d03717b58c3adce81a740bfafff5c6f9d639159bbf08da557c9ac1b513 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/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 diff --git a/infrastructure/make/run.mk b/infrastructure/make/run.mk index 0568a5ceaf..fcbea4b7c7 100644 --- a/infrastructure/make/run.mk +++ b/infrastructure/make/run.mk @@ -1,10 +1,13 @@ -.PHONY: infrastructure-up +.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 -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,9 +15,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 $$?; \ - COMPOSE_BAKE=true DOCKER_BUILDKIT=1 \ - $(INFRASTRUCTURE_COMPOSE) \ - -f docker-compose/infrastructure/compose.deploy.yaml \ - up + 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/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 diff --git a/infrastructure/poetry.lock b/infrastructure/poetry.lock index 29f92f21c8..f426cc49ed 100644 --- a/infrastructure/poetry.lock +++ b/infrastructure/poetry.lock @@ -1,5 +1,43 @@ # 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" +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" @@ -22,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] @@ -46,12 +84,24 @@ 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 = "docutils" +version = "0.19" +description = "Docutils -- Python Documentation Utilities" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "docutils-0.19-py3-none-any.whl", hash = "sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc"}, + {file = "docutils-0.19.tar.gz", hash = "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6"}, +] [[package]] name = "iniconfig" @@ -140,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" @@ -208,6 +270,89 @@ files = [ lark = ">=1.1.5,<2.0" regex = ">=2024.4.16" +[[package]] +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {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]] name = "regex" version = "2026.7.19" @@ -332,6 +477,21 @@ files = [ {file = "regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5"}, ] +[[package]] +name = "rsa" +version = "4.7.2" +description = "Pure-Python RSA implementation" +optional = false +python-versions = ">=3.5, <4" +groups = ["main"] +files = [ + {file = "rsa-4.7.2-py3-none-any.whl", hash = "sha256:78f9a9bf4e7be0c5ded4583326e7461e3a3c5aae24073648b4bdfa797d78c9d2"}, + {file = "rsa-4.7.2.tar.gz", hash = "sha256:9d689e6ca1b3038bc82bf8d23e944b6b6037bc02301a574935b2dd946e0353b9"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + [[package]] name = "s3transfer" version = "0.19.2" @@ -403,4 +563,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 = "a37484c34a5165aa04c7304babc3041cd341fb0db257693457c42c0b5caa9b89" diff --git a/infrastructure/pyproject.toml b/infrastructure/pyproject.toml index d3971ee3e3..e64ef0648a 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 = "1.45.62" +dependencies.awscli-local = "0.22.2" dependencies.terraform-local = "0.26.0" group.test.dependencies.pytest = "9.0.3" 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/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/deploy_runner.py b/infrastructure/scripts/deploy_runner.py index 31d72413d4..e7386f6d94 100644 --- a/infrastructure/scripts/deploy_runner.py +++ b/infrastructure/scripts/deploy_runner.py @@ -1,15 +1,26 @@ -"""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__) +LOCALSTACK_TFBACKEND = "terraform.localstack.tfbackend" +LOCALSTACK_TFVARS = "terraform.localstack.tfvars" + class InfrastructureDeployRunner: """Infrastructure deployment orchestrator.""" @@ -19,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. @@ -26,12 +38,92 @@ 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, + *, + 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. + + """ + 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: + 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 / STATE_DIR + 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", + f"-var-file={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.""" @@ -51,35 +143,217 @@ def deploy(self) -> None: self.commands.require("tflocal") self.localstack.wait_ready() - 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"), ): - init_result = self.commands.run( - "tflocal", - f"-chdir={live_dir}", - "init", - "-backend-config=terraform.localstack.tfbackend", - "-input=false", - "-reconfigure", - check=False, + self.apply_state() + 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. + + 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.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()}, ) - if init_result.returncode != 0: - message = f"terraform init failed in {live_dir}" - raise RunnerError(message) + # 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"], + ) - apply_result = self.commands.run( - "tflocal", - f"-chdir={live_dir}", - "apply", - "-auto-approve", - "-input=false", - "-var-file=terraform.localstack.tfvars", - check=False, + 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 apply_result.returncode != 0: - message = f"terraform apply failed in {live_dir}" + if stop_result.returncode != 0: + message = f"awslocal ecs stop-task failed for {arn}: {stop_result.stderr}" raise RunnerError(message) - logger.info("Deployment on LocalStack successful!") 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/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/state/README.md b/infrastructure/state/README.md index fc18faef8f..d0701075b6 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 cd09bd50ad..f90531a2a7 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'." } } 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, ) diff --git a/infrastructure/tests/scripts/deploy_runner_test.py b/infrastructure/tests/scripts/deploy_runner_test.py index d10e59b0c2..961abc23ec 100644 --- a/infrastructure/tests/scripts/deploy_runner_test.py +++ b/infrastructure/tests/scripts/deploy_runner_test.py @@ -8,11 +8,37 @@ 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.images import ImageManager 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") +FAKE_CREDENTIAL = "test" + + +def assert_aws_env_unset() -> None: + for var in AWS_ENV_VARS: + assert var not in os.environ + + +def build_runner( + commands: MagicMock, + localstack: MagicMock, + *, + images: MagicMock | None = None, +) -> InfrastructureDeployRunner: + return InfrastructureDeployRunner( + root_dir=Path("/repo"), + commands=commands, + images=images, + localstack=localstack, + ) class TestInfrastructureDeployRunner: @@ -48,32 +74,58 @@ 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 + commands.run.return_value = MagicMock(returncode=0) + runner = build_runner(commands, MagicMock(spec=LocalStack)) - captured: dict[str, str] = {} + runner.apply_state() - 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) + state_dir = str(Path("/repo") / "infrastructure" / "state") + 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", + f"-var-file={LOCALSTACK_TFVARS}", + check=False, + ), + ] + ) - commands.run.side_effect = capture + 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)) - runner = InfrastructureDeployRunner( - root_dir=Path("/repo"), - commands=commands, - localstack=localstack, - ) + with pytest.raises(RunnerError, match="terraform init failed"): + runner.apply_state() - runner.deploy() + 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)) - commands.require.assert_called_once_with("tflocal") - localstack.wait_ready.assert_called_once() + 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( @@ -82,7 +134,7 @@ def capture(*_args: object, **_kwargs: object) -> MagicMock: "tflocal", f"-chdir={live_dir}", "init", - "-backend-config=terraform.localstack.tfbackend", + f"-backend-config={LOCALSTACK_TFBACKEND}", "-input=false", "-reconfigure", check=False, @@ -93,73 +145,351 @@ def capture(*_args: object, **_kwargs: object) -> MagicMock: "apply", "-auto-approve", "-input=false", - "-var-file=terraform.localstack.tfvars", + f"-var-file={LOCALSTACK_TFVARS}", check=False, ), ] ) - fake_credential = "test" - assert captured["AWS_ACCESS_KEY_ID"] == fake_credential - assert captured["AWS_SECRET_ACCESS_KEY"] == fake_credential + + 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) + runner = build_runner(commands, MagicMock(spec=LocalStack)) + + with pytest.raises(RunnerError, match="terraform init failed"): + runner.apply_live() + + def test_apply_live_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_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) + localstack = MagicMock(spec=LocalStack) + localstack.api_url = LOCALSTACK_ENDPOINT_URL + runner = build_runner(commands, localstack) + + call_order: list[str] = [] + captured: dict[str, str] = {} + + 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_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) localstack = MagicMock(spec=LocalStack) localstack.wait_ready.side_effect = RunnerError("localstack down") - - runner = InfrastructureDeployRunner( - root_dir=Path("/repo"), - commands=commands, - localstack=localstack, - ) + runner = build_runner(commands, localstack) with pytest.raises(RunnerError, match="localstack down"): runner.deploy() 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_deploy_raises_runner_error_when_init_fails(self) -> None: + def test_refresh_pushes_images_applies_live_and_restarts_service_tasks(self) -> None: commands = MagicMock(spec=CommandRunner) - commands.run.return_value = MagicMock(returncode=1) localstack = MagicMock(spec=LocalStack) localstack.api_url = LOCALSTACK_ENDPOINT_URL + images = MagicMock(spec=ImageManager) + runner = build_runner(commands, localstack, images=images) - runner = InfrastructureDeployRunner( - root_dir=Path("/repo"), - commands=commands, - localstack=localstack, + tag = "1735000000" + captured: dict[str, str] = {} + + def record_live(**_kwargs) -> None: + for var in AWS_ENV_VARS: + captured[var] = os.environ[var] + + 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_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() - with pytest.raises(RunnerError, match="terraform init failed"): - runner.deploy() + 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 - 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 - @patch.dict(os.environ, {}, clear=True) - def test_deploy_raises_runner_error_when_apply_fails(self) -> None: + def test_restart_service_task_stops_then_runs_new_task(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 = build_runner(commands, MagicMock(spec=LocalStack)) - runner = InfrastructureDeployRunner( - root_dir=Path("/repo"), - commands=commands, - localstack=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" - with pytest.raises(RunnerError, match="terraform apply failed"): - runner.deploy() + 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() - 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 + commands.run.assert_not_called() 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") 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")