Skip to content

fix(deploy): run the commit's image digest and fail on ECS rollback - #187

Merged
Alexanderamiri merged 2 commits into
mainfrom
fix/deploy-pinned-image
Oct 31, 2026
Merged

Alexanderamiri merged 2 commits into
mainfrom
fix/deploy-pinned-image

Conversation

@Alexanderamiri

Copy link
Copy Markdown
Member

Problem

Three deploy-correctness findings from the DX audit:

  • 7. Deploys could run the wrong image. Task definitions point at the ECR repo URL, which means mutable :latest. docker-build runs after tf-apply, so when tf-apply changed the task definition ECS started a deployment that resolved the old :latest. ecs-deploy.sh then saw more than one deployment, skipped --force-new-deployment and only waited, so the new image never ran. Separately, latest and main-* tags were pushed only when the branch was literally main or master, although javabin.yml deploys from whatever the default branch is.
  • 16. A rollback showed up as a green deploy. aws ecs wait services-stable passes after a circuit-breaker rollback. The 2027.javazone.no run 41025435178 is green, but the service has rolloutState FAILED and 0/1 tasks running.
  • 17. Every new app's first deploy failed. tf-apply created the service pointing at an image that didn't exist yet, so the first ECS deployment failed (circuit breaker, SERVICE_TASK_START_IMPAIRED alert to Slack) before docker-build pushed anything.

Change

  • scripts/ecs-deploy.sh (rewritten): takes the family's latest ACTIVE revision, which is Terraform's newest or the last one this script registered from it. It registers a copy with the app container pinned to IMAGE (repo@sha256:…) and copies the base revision's tags. Then it updates the service to that revision, with the desired count from the service tag javabin:desired-count, and polls until that deployment is COMPLETED on the new revision (timeout 20 min). If the deployment rolls back, ends FAILED or times out, the script prints the service events, the stopped tasks' stopCode/stoppedReason, each container's exit code and reason, and the log group, then fails the job. If a later run's newer revision replaced the deployment, the script passes with a warning.
  • docker-build.yml: new image output ({repo}@{digest} from build-push-action). latest and main-* tags now follow github.event.repository.default_branch. The main- prefix stays so the ECR lifecycle rule still applies.
  • ecs-deploy.yml / javabin.yml: pass image through. image_tag is still accepted but unused.
  • terraform/modules/ecs-service: the service is created with desired_count = 0, has lifecycle { ignore_changes = [task_definition, desired_count] }, and is tagged javabin:desired-count = var.desired_count. A new service has nothing to start until ecs-deploy gives it a pinned image and its count. tf-apply never starts a deployment by itself any more.
  • terraform/platform/iam (ci_deploy_ecs): grants the deploy role ecs:ListTagsForResource and ecs:DescribeTasks (team-tag scoped), ecs:ListTasks (scoped to the platform cluster), and ecs:TagResource only when ecs:CreateAction = RegisterTaskDefinition. Tag-resource authorization is on in the account.
  • Docs: docs/reusable-workflows.md, docs/reusable-modules.md, docs/app-yaml-reference.md, docs/cost-management.md and CLAUDE.md are updated. The generator gets a comment on the image line; its output is unchanged.

The plan-review and apply-gate flow is unchanged. ecs-deploy still runs after tf-apply and only when docker-build succeeds.

Transition for existing apps

Live state (read-only check): cupcake-cupcake:4, cupcake-cupcake-backend:1, jzwatch-jz-watch:1 and javazoneweb-javazone-2027:1 all run the generated module with the untagged repo URL. testteam-platform-test-app:1 runs :latest from legacy inline TF.

  • Generated apps: the next plan shows only an in-place tag addition on the service. ignore_changes keeps the live count and revision, so there is no redeploy from tf-apply. The following ecs-deploy registers the first pinned revision from the current one. The jq transform was checked against the live cupcake-cupcake:4: every remaining key is a valid RegisterTaskDefinition parameter.
  • Before the IAM change is applied: the script and workflows go live at merge (@main), but the IAM change waits for the platform pipeline. Until then the script registers untagged revisions, keeps the current desired count, and reports that it cannot list stopped tasks. The deploy itself still works with today's permissions (DescribeTaskDefinition, RegisterTaskDefinition, UpdateService, PassRole).
  • platform-test-app has legacy inline TF without ignore_changes. Each of its plans will show the service drifting back to its own revision until it is regenerated onto the modules. It still works, but expect a plan diff on every run.

Verification

  • python3 -m unittest discover -s scripts/tests: 149 tests OK. The 16 new tests in test_ecs_deploy.py run the script against a fake aws and cover:
    • the pinned copy and which fields are stripped
    • re-pinning a pinned revision, and leaving sidecars from other repos alone
    • desired count from the tag, and no count when the tag is missing
    • in-progress deployments
    • completion, rollback with stopped-task diagnostics, FAILED without rollback, wrong task definition, timeout
    • a later deploy superseding this one
    • missing tag or describe permissions
  • python3 -m unittest discover -s terraform/lambda-src/tests: 219 OK
  • terraform fmt -recursive -check, plus terraform validate for modules/ecs-service and terraform/platform
  • shellcheck -s sh scripts/ecs-deploy.sh and actionlint on the three workflows: clean

Not exercised end to end: no AWS writes from here. The first app run after merge and platform apply is the real test.

🤖 Generated with Claude Code

Every deploy registers a task definition revision pinned to the digest
docker-build pushed, instead of forcing a deployment of mutable :latest
(which tf-apply could start first with the old image, and which made
ecs-deploy skip the force). ecs-deploy then requires the deployment's
rolloutState COMPLETED on that revision and prints stopped-task reasons,
container exit codes and the log group on rollback, failure or timeout.

The ecs-service module creates the service with 0 tasks and ignores
task_definition and desired_count afterwards; ecs-deploy sets the count
from the javabin:desired-count tag, so a new app's first deploy no longer
starts tasks before its image exists. latest/date tags follow the repo's
default branch instead of a literal main/master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Alexanderamiri
Alexanderamiri requested a review from a team as a code owner October 31, 2026 12:11
@github-actions

Copy link
Copy Markdown

Terraform Plan

🚧 Changes detected — Plan: 0 to add, 4 to change, 0 to destroy.

Plan output
Acquiring state lock. This may take a few moments...
aws_ssm_parameter.atlassian_org_id: Refreshing state... [id=/javabin/platform/atlassian-org-id]
aws_ssm_parameter.atlassian_api_key: Refreshing state... [id=/javabin/platform/atlassian-api-key]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # module.iam.aws_iam_role_policy.ci_deploy_ecs["cupcake"] will be updated in-place
  ~ resource "aws_iam_role_policy" "ci_deploy_ecs" {
        id     = "javabin-ci-deploy-cupcake:ecs-deploy"
        name   = "ecs-deploy"
      ~ policy = jsonencode(
          ~ {
              ~ Statement = [
                  ~ {
                      ~ Action    = [
                            # (1 unchanged element hidden)
                            "ecs:DescribeServices",
                          + "ecs:ListTagsForResource",
                          + "ecs:DescribeTasks",
                        ]
                        # (4 unchanged attributes hidden)
                    },
                  + {
                      + Action    = [
                          + "ecs:ListTasks",
                        ]
                      + Condition = {
                          + ArnEquals = {
                              + "ecs:cluster" = "arn:aws:ecs:eu-central-1:553637109631:cluster/javabin-platform"
                            }
                        }
                      + Effect    = "Allow"
                      + Resource  = "*"
                      + Sid       = "EcsListTasks"
                    },
                  + {
                      + Action    = [
                          + "ecs:TagResource",
                        ]
                      + Condition = {
                          + StringEquals = {
                              + "ecs:CreateAction" = "RegisterTaskDefinition"
                            }
                        }
                      + Effect    = "Allow"
                      + Resource  = "*"
                      + Sid       = "EcsTagOnRegister"
                    },
                    {
                        Action   = [
                            "ecs:DescribeTaskDefinition",
                            "ecs:RegisterTaskDefinition",
                            "ecs:DeregisterTaskDefinition",
                        ]
                        Effect   = "Allow"
                        Resource = "*"
                        Sid      = "EcsTaskDefinitionManagement"
                    },
                    # (1 unchanged element hidden)
                ]
                # (1 unchanged attribute hidden)
            }
        )
        # (1 unchanged attribute hidden)
    }

  # module.iam.aws_iam_role_policy.ci_deploy_ecs["javazoneweb"] will be updated in-place
  ~ resource "aws_iam_role_policy" "ci_deploy_ecs" {
        id     = "javabin-ci-deploy-javazoneweb:ecs-deploy"
        name   = "ecs-deploy"
      ~ policy = jsonencode(
          ~ {
              ~ Statement = [
                  ~ {
                      ~ Action    = [
                            # (1 unchanged element hidden)
                            "ecs:DescribeServices",
                          + "ecs:ListTagsForResource",
                          + "ecs:DescribeTasks",
                        ]
                        # (4 unchanged attributes hidden)
                    },
                  + {
                      + Action    = [
                          + "ecs:ListTasks",
                        ]
                      + Condition = {
                          + ArnEquals = {
                              + "ecs:cluster" = "arn:aws:ecs:eu-central-1:553637109631:cluster/javabin-platform"
                            }
                        }
                      + Effect    = "Allow"
                      + Resource  = "*"
                      + Sid       = "EcsListTasks"
                    },
                  + {
                      + Action    = [
                          + "ecs:TagResource",
                        ]
                      + Condition = {
                          + StringEquals = {
                              + "ecs:CreateAction" = "RegisterTaskDefinition"
                            }
                        }
                      + Effect    = "Allow"
                      + Resource  = "*"
                      + Sid       = "EcsTagOnRegister"
                    },
                    {
                        Action   = [
                            "ecs:DescribeTaskDefinition",
                            "ecs:RegisterTaskDefinition",
                            "ecs:DeregisterTaskDefinition",
                        ]
                        Effect   = "Allow"
                        Resource = "*"
                        Sid      = "EcsTaskDefinitionManagement"
                    },
                    # (1 unchanged element hidden)
                ]
                # (1 unchanged attribute hidden)
            }
        )
        # (1 unchanged attribute hidden)
    }

  # module.iam.aws_iam_role_policy.ci_deploy_ecs["jzwatch"] will be updated in-place
  ~ resource "aws_iam_role_policy" "ci_deploy_ecs" {
        id     = "javabin-ci-deploy-jzwatch:ecs-deploy"
        name   = "ecs-deploy"
      ~ policy = jsonencode(
          ~ {
              ~ Statement = [
                  ~ {
                      ~ Action    = [
                            # (1 unchanged element hidden)
                            "ecs:DescribeServices",
                          + "ecs:ListTagsForResource",
                          + "ecs:DescribeTasks",
                        ]
                        # (4 unchanged attributes hidden)
                    },
                  + {
                      + Action    = [
                          + "ecs:ListTasks",
                        ]
                      + Condition = {
                          + ArnEquals = {
                              + "ecs:cluster" = "arn:aws:ecs:eu-central-1:553637109631:cluster/javabin-platform"
                            }
                        }
                      + Effect    = "Allow"
                      + Resource  = "*"
                      + Sid       = "EcsListTasks"
                    },
                  + {
                      + Action    = [
                          + "ecs:TagResource",
                        ]
                      + Condition = {
                          + StringEquals = {
                              + "ecs:CreateAction" = "RegisterTaskDefinition"
                            }
                        }
                      + Effect    = "Allow"
                      + Resource  = "*"
                      + Sid       = "EcsTagOnRegister"
                    },
                    {
                        Action   = [
                            "ecs:DescribeTaskDefinition",
                            "ecs:RegisterTaskDefinition",
                            "ecs:DeregisterTaskDefinition",
                        ]
                        Effect   = "Allow"
                        Resource = "*"
                        Sid      = "EcsTaskDefinitionManagement"
                    },
                    # (1 unchanged element hidden)
                ]
                # (1 unchanged attribute hidden)
            }
        )
        # (1 unchanged attribute hidden)
    }

  # module.iam.aws_iam_role_policy.ci_deploy_ecs["testteam"] will be updated in-place
  ~ resource "aws_iam_role_policy" "ci_deploy_ecs" {
        id     = "javabin-ci-deploy-testteam:ecs-deploy"
        name   = "ecs-deploy"
      ~ policy = jsonencode(
          ~ {
              ~ Statement = [
                  ~ {
                      ~ Action    = [
                            # (1 unchanged element hidden)
                            "ecs:DescribeServices",
                          + "ecs:ListTagsForResource",
                          + "ecs:DescribeTasks",
                        ]
                        # (4 unchanged attributes hidden)
                    },
                  + {
                      + Action    = [
                          + "ecs:ListTasks",
                        ]
                      + Condition = {
                          + ArnEquals = {
                              + "ecs:cluster" = "arn:aws:ecs:eu-central-1:553637109631:cluster/javabin-platform"
                            }
                        }
                      + Effect    = "Allow"
                      + Resource  = "*"
                      + Sid       = "EcsListTasks"
                    },
                  + {
                      + Action    = [
                          + "ecs:TagResource",
                        ]
                      + Condition = {
                          + StringEquals = {
                              + "ecs:CreateAction" = "RegisterTaskDefinition"
                            }
                        }
                      + Effect    = "Allow"
                      + Resource  = "*"
                      + Sid       = "EcsTagOnRegister"
                    },
                    {
                        Action   = [
                            "ecs:DescribeTaskDefinition",
                            "ecs:RegisterTaskDefinition",
                            "ecs:DeregisterTaskDefinition",
                        ]
                        Effect   = "Allow"
                        Resource = "*"
                        Sid      = "EcsTaskDefinitionManagement"
                    },
                    # (1 unchanged element hidden)
                ]
                # (1 unchanged attribute hidden)
            }
        )
        # (1 unchanged attribute hidden)
    }

Plan: 0 to add, 4 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Saved the plan to: tfplan

To perform exactly these actions, run the following command to apply:
    terraform apply "tfplan"

LLM Review

Risk: 🟡 MEDIUM

Plan updates 4 CI deploy IAM role inline policies (ecs-deploy) for the cupcake, javazoneweb, jzwatch and testteam roles to add ECS read/tag/task-definition permissions; no creates or destroys.

  • 🔒 [security] Each of the 4 ci-deploy inline policies gains new Allow statements: ecs:ListTasks (scoped by ArnEquals ecs:cluster to javabin-platform), ecs:TagResource (only when ecs:CreateAction=RegisterTaskDefinition), and ecs:DescribeTaskDefinition/RegisterTaskDefinition/DeregisterTaskDefinition on Resource "". Also ecs:ListTagsForResource and ecs:DescribeTasks are added to an existing statement, unscoped to a cluster. Resource "" on task-definition actions is broad, though RegisterTaskDefinition cannot be resource-scoped well in ECS, so this is commonly accepted.
  • 🔒 [security] DeregisterTaskDefinition is newly granted and lets a CI role deregister task definitions across the account. Worth confirming it is needed for the deploy pipeline.
  • 💥 [destruction] No resources are created or destroyed. Each change is an in-place update of an aws_iam_role_policy, so there is no outage or data-loss risk from this plan.
  • 💰 [cost] No new billable resources. IAM policy changes carry no direct cost.
  • ✅ [routine] Changes are consistent across all four role instances (same statements added), suggesting a deliberate module-level update. No Deny statements or public access changes are present, and nothing is opened to the internet.

…holds

The deploy credentials last 900 s but ecs-deploy waited up to 1200 s, so a
slow rollout died on ExpiredToken without diagnostics. The timeout is now
720 s from the start of the step, and an AWS error while polling fails the
job with the last known service events, stopped tasks and log group.

Revisions take the service's tags when the base revision has none, so one
untagged revision no longer makes every later one untagged, and carry
managed-by=ecs-deploy instead of the base's managed-by=terraform.

budget-enforcer sets javabin:desired-count to 0 on the services it scales
down, so app pushes keep them at 0 until a reviewed tf-apply writes the
count back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Alexanderamiri

Copy link
Copy Markdown
Member Author

Review follow-up in c689b48. I fixed all four findings, and none of them turned out to be wrong.

  1. The deploy credentials expired before the timeout (major). Fixed.
    • DEPLOY_TIMEOUT now defaults to 720 s and counts from the start of the step. That is inside the broker's 900 s DEPLOY_DURATION, with margin left for the diagnostics.
    • A failed describe-services while polling no longer exits bare under set -e. The job fails with ::error::Deployment ... could not be checked: describe-services failed: <aws error> and prints the last good service events, the stopped tasks (if the credentials still work) and the log group.
    • A test fails if the default timeout comes within 120 s of the broker's duration.
    • docs/reusable-workflows.md now says 12 minutes.
    • I did not raise the broker duration, because the deploy role's session limit and the credential scope belong to another area.
  2. One untagged revision left every later revision untagged. Fixed. When the base revision has no readable tags, the script takes the service's tags (team, service, repo, environment), which the role can read under the aws:ResourceTag/team condition.
  3. ecs-deploy undid the budget enforcer. Fixed instead of accepted.
    • budget_enforcer now also sets javabin:desired-count=0 on the team's services, including services not deployed yet. Its role gets ecs:TagResource on cluster services.
    • ecs-deploy then deploys with 0 tasks and warns.
    • Only a tf-apply brings the count back: its plan shows "javabin:desired-count" = "0" -> "N", the same reviewed step that desired_count 0 -> 1 used to be.
    • docs/cost-management.md, the Slack restore hint and CLAUDE.md are updated.
  4. managed-by=terraform on revisions Terraform does not manage. Fixed. Copied tags drop managed-by and the javabin:/aws: keys, then add managed-by=ecs-deploy. I did not add deregistration of old revisions. ACTIVE revisions cost nothing and are well below the ECS limits, and deregistering also removes the rollback targets.

Verification:

  • python3 -m unittest discover -s scripts/tests: 155 tests OK. New tests cover expired credentials while waiting, an untagged base falling back to the service tags, the managed-by override, a budget-held service staying at 0, and the timeout staying under the credential lifetime.
  • python3 -m unittest discover -s terraform/lambda-src/tests: 222 tests OK, including a new test_budget_enforcer.py.
  • shellcheck, terraform fmt -check and terraform validate pass.

Rollout order: the iam/lambdas apply has to land before the scripts take effect, as before. Until then the tag reads fail, and revisions are registered untagged but self-heal through the service-tag fallback.

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown

Terraform Plan

🚧 Changes detected — Plan: 0 to add, 6 to change, 0 to destroy.

Plan output
Acquiring state lock. This may take a few moments...
aws_ssm_parameter.atlassian_org_id: Refreshing state... [id=/javabin/platform/atlassian-org-id]
aws_ssm_parameter.atlassian_api_key: Refreshing state... [id=/javabin/platform/atlassian-api-key]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # module.iam.aws_iam_role_policy.ci_deploy_ecs["cupcake"] will be updated in-place
  ~ resource "aws_iam_role_policy" "ci_deploy_ecs" {
        id     = "javabin-ci-deploy-cupcake:ecs-deploy"
        name   = "ecs-deploy"
      ~ policy = jsonencode(
          ~ {
              ~ Statement = [
                  ~ {
                      ~ Action    = [
                            # (1 unchanged element hidden)
                            "ecs:DescribeServices",
                          + "ecs:ListTagsForResource",
                          + "ecs:DescribeTasks",
                        ]
                        # (4 unchanged attributes hidden)
                    },
                  + {
                      + Action    = [
                          + "ecs:ListTasks",
                        ]
                      + Condition = {
                          + ArnEquals = {
                              + "ecs:cluster" = "arn:aws:ecs:eu-central-1:553637109631:cluster/javabin-platform"
                            }
                        }
                      + Effect    = "Allow"
                      + Resource  = "*"
                      + Sid       = "EcsListTasks"
                    },
                  + {
                      + Action    = [
                          + "ecs:TagResource",
                        ]
                      + Condition = {
                          + StringEquals = {
                              + "ecs:CreateAction" = "RegisterTaskDefinition"
                            }
                        }
                      + Effect    = "Allow"
                      + Resource  = "*"
                      + Sid       = "EcsTagOnRegister"
                    },
                    {
                        Action   = [
                            "ecs:DescribeTaskDefinition",
                            "ecs:RegisterTaskDefinition",
                            "ecs:DeregisterTaskDefinition",
                        ]
                        Effect   = "Allow"
                        Resource = "*"
                        Sid      = "EcsTaskDefinitionManagement"
                    },
                    # (1 unchanged element hidden)
                ]
                # (1 unchanged attribute hidden)
            }
        )
        # (1 unchanged attribute hidden)
    }

  # module.iam.aws_iam_role_policy.ci_deploy_ecs["javazoneweb"] will be updated in-place
  ~ resource "aws_iam_role_policy" "ci_deploy_ecs" {
        id     = "javabin-ci-deploy-javazoneweb:ecs-deploy"
        name   = "ecs-deploy"
      ~ policy = jsonencode(
          ~ {
              ~ Statement = [
                  ~ {
                      ~ Action    = [
                            # (1 unchanged element hidden)
                            "ecs:DescribeServices",
                          + "ecs:ListTagsForResource",
                          + "ecs:DescribeTasks",
                        ]
                        # (4 unchanged attributes hidden)
                    },
                  + {
                      + Action    = [
                          + "ecs:ListTasks",
                        ]
                      + Condition = {
                          + ArnEquals = {
                              + "ecs:cluster" = "arn:aws:ecs:eu-central-1:553637109631:cluster/javabin-platform"
                            }
                        }
                      + Effect    = "Allow"
                      + Resource  = "*"
                      + Sid       = "EcsListTasks"
                    },
                  + {
                      + Action    = [
                          + "ecs:TagResource",
                        ]
                      + Condition = {
                          + StringEquals = {
                              + "ecs:CreateAction" = "RegisterTaskDefinition"
                            }
                        }
                      + Effect    = "Allow"
                      + Resource  = "*"
                      + Sid       = "EcsTagOnRegister"
                    },
                    {
                        Action   = [
                            "ecs:DescribeTaskDefinition",
                            "ecs:RegisterTaskDefinition",
                            "ecs:DeregisterTaskDefinition",
                        ]
                        Effect   = "Allow"
                        Resource = "*"
                        Sid      = "EcsTaskDefinitionManagement"
                    },
                    # (1 unchanged element hidden)
                ]
                # (1 unchanged attribute hidden)
            }
        )
        # (1 unchanged attribute hidden)
    }

  # module.iam.aws_iam_role_policy.ci_deploy_ecs["jzwatch"] will be updated in-place
  ~ resource "aws_iam_role_policy" "ci_deploy_ecs" {
        id     = "javabin-ci-deploy-jzwatch:ecs-deploy"
        name   = "ecs-deploy"
      ~ policy = jsonencode(
          ~ {
              ~ Statement = [
                  ~ {
                      ~ Action    = [
                            # (1 unchanged element hidden)
                            "ecs:DescribeServices",
                          + "ecs:ListTagsForResource",
                          + "ecs:DescribeTasks",
                        ]
                        # (4 unchanged attributes hidden)
                    },
                  + {
                      + Action    = [
                          + "ecs:ListTasks",
                        ]
                      + Condition = {
                          + ArnEquals = {
                              + "ecs:cluster" = "arn:aws:ecs:eu-central-1:553637109631:cluster/javabin-platform"
                            }
                        }
                      + Effect    = "Allow"
                      + Resource  = "*"
                      + Sid       = "EcsListTasks"
                    },
                  + {
                      + Action    = [
                          + "ecs:TagResource",
                        ]
                      + Condition = {
                          + StringEquals = {
                              + "ecs:CreateAction" = "RegisterTaskDefinition"
                            }
                        }
                      + Effect    = "Allow"
                      + Resource  = "*"
                      + Sid       = "EcsTagOnRegister"
                    },
                    {
                        Action   = [
                            "ecs:DescribeTaskDefinition",
                            "ecs:RegisterTaskDefinition",
                            "ecs:DeregisterTaskDefinition",
                        ]
                        Effect   = "Allow"
                        Resource = "*"
                        Sid      = "EcsTaskDefinitionManagement"
                    },
                    # (1 unchanged element hidden)
                ]
                # (1 unchanged attribute hidden)
            }
        )
        # (1 unchanged attribute hidden)
    }

  # module.iam.aws_iam_role_policy.ci_deploy_ecs["testteam"] will be updated in-place
  ~ resource "aws_iam_role_policy" "ci_deploy_ecs" {
        id     = "javabin-ci-deploy-testteam:ecs-deploy"
        name   = "ecs-deploy"
      ~ policy = jsonencode(
          ~ {
              ~ Statement = [
                  ~ {
                      ~ Action    = [
                            # (1 unchanged element hidden)
                            "ecs:DescribeServices",
                          + "ecs:ListTagsForResource",
                          + "ecs:DescribeTasks",
                        ]
                        # (4 unchanged attributes hidden)
                    },
                  + {
                      + Action    = [
                          + "ecs:ListTasks",
                        ]
                      + Condition = {
                          + ArnEquals = {
                              + "ecs:cluster" = "arn:aws:ecs:eu-central-1:553637109631:cluster/javabin-platform"
                            }
                        }
                      + Effect    = "Allow"
                      + Resource  = "*"
                      + Sid       = "EcsListTasks"
                    },
                  + {
                      + Action    = [
                          + "ecs:TagResource",
                        ]
                      + Condition = {
                          + StringEquals = {
                              + "ecs:CreateAction" = "RegisterTaskDefinition"
                            }
                        }
                      + Effect    = "Allow"
                      + Resource  = "*"
                      + Sid       = "EcsTagOnRegister"
                    },
                    {
                        Action   = [
                            "ecs:DescribeTaskDefinition",
                            "ecs:RegisterTaskDefinition",
                            "ecs:DeregisterTaskDefinition",
                        ]
                        Effect   = "Allow"
                        Resource = "*"
                        Sid      = "EcsTaskDefinitionManagement"
                    },
                    # (1 unchanged element hidden)
                ]
                # (1 unchanged attribute hidden)
            }
        )
        # (1 unchanged attribute hidden)
    }

  # module.lambdas.aws_iam_role_policy.budget_enforcer will be updated in-place
  ~ resource "aws_iam_role_policy" "budget_enforcer" {
        id     = "javabin-budget-enforcer:javabin-budget-enforcer"
        name   = "javabin-budget-enforcer"
      ~ policy = jsonencode(
          ~ {
              ~ Statement = [
                    # (2 unchanged elements hidden)
                    {
                        Action   = "ecs:ListServices"
                        Effect   = "Allow"
                        Resource = "arn:aws:ecs:eu-central-1:553637109631:cluster/javabin-platform"
                        Sid      = "ECSListServicesUnconstrained"
                    },
                  ~ {
                      ~ Action   = "ecs:UpdateService" -> [
                          + "ecs:UpdateService",
                          + "ecs:TagResource",
                        ]
                        # (3 unchanged attributes hidden)
                    },
                ]
                # (1 unchanged attribute hidden)
            }
        )
        # (1 unchanged attribute hidden)
    }

  # module.lambdas.aws_lambda_function.budget_enforcer will be updated in-place
  ~ resource "aws_lambda_function" "budget_enforcer" {
        id                             = "javabin-budget-enforcer"
      ~ last_modified                  = "2026-03-26T20:01:54.976+0000" -> (known after apply)
      ~ source_code_hash               = "i3E4tI5mT0qCS601izSv52QOgwAndKfcZ3JkLzxTNis=" -> "lM6wzIndpyMp1ZmU5nft0SteLorI7ppyEyOYIL/2hjU="
        tags                           = {}
        # (21 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

Plan: 0 to add, 6 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Saved the plan to: tfplan

To perform exactly these actions, run the following command to apply:
    terraform apply "tfplan"

LLM Review

Risk: 🟢 LOW

Plan updates 6 existing resources in place (0 add, 0 destroy): five IAM policy changes for CI deploy roles and the budget enforcer, plus a Lambda code update; no creations, deletions, or public exposure.

  • 🔒 [security] The ci-deploy ECS policies for cupcake, javazoneweb, jzwatch and testteam (4 roles) gain new Allow statements: ecs:ListTasks (scoped by ArnEquals ecs:cluster to javabin-platform), ecs:TagResource (only when ecs:CreateAction is RegisterTaskDefinition), and ecs:ListTagsForResource and ecs:DescribeTasks added to an existing statement. These are read-level or narrowly conditioned grants. The EcsTaskDefinitionManagement statement is unchanged in this diff, and Resource is "*" for these task-definition actions, which is worth noting but is not new.
  • 🔒 [security] The budget-enforcer role policy gains ecs:UpdateService and ecs:TagResource on the statement shown as a changed Action. The diff shows the full list (not a + marker on an unchanged Effect), and the enclosing Effect is not shown, so it is most likely an Allow on the existing ECS update statement. Confirm the Effect and Resource scope in the full policy. The change expands what the budget Lambda can do to services in javabin-platform, so it is worth a quick check.
  • ✅ [routine] The budget-enforcer Lambda function is updated in place with a new source_code_hash only. No config, runtime, or role change is shown; this is a routine code deploy.
  • 💰 [cost] No new billable resources (NAT gateways, RDS, load balancers, or similar) are created. The only cost-relevant effect is that the budget enforcer now has permission to update and tag ECS services, which could be used to scale or stop services; this matches its purpose.
  • 💥 [destruction] Zero resources are destroyed or replaced. All six changes are in-place updates, so there is no data-loss or downtime risk from this plan.

@Alexanderamiri
Alexanderamiri merged commit 9869d65 into main Oct 31, 2026
4 checks passed
@Alexanderamiri
Alexanderamiri deleted the fix/deploy-pinned-image branch October 31, 2026 13:11
Alexanderamiri added a commit that referenced this pull request Oct 31, 2026
Combine the suspension marker with main's javabin:desired-count tag (#187):
ecs-deploy now scales to the tag and Terraform ignores desired_count, so the
ecs-service module plans the tag as "0" while the team is suspended. The
enforcer pins the tag to 0 on suspend and reconcile, records the previous
value in the marker, and restores it on lift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant