Skip to content

[316] Converge a dashboard's CloudFormation stack on republish - #348

Draft
CarsonDavis wants to merge 1 commit into
316-dashboard-subpathfrom
316-republish-stack-convergence
Draft

[316] Converge a dashboard's CloudFormation stack on republish#348
CarsonDavis wants to merge 1 commit into
316-dashboard-subpathfrom
316-republish-stack-convergence

Conversation

@CarsonDavis

@CarsonDavis CarsonDavis commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Part of #316 — PR 2 of 3, stacked on #330 (PR 3 is #349). Retarget to development after #330 merges.

Before this, a republish (the update action) only re-uploaded files — the dashboard's CloudFormation stack was created once and never touched again, so an existing dashboard would never receive the edge-function change from #330. This makes republish converge the infrastructure too.

  • UpdateStack on republish — the update action performs CloudFormation UpdateStack and polls to completion. Staleness of the eventually-consistent DescribeStacks reads is keyed on LastUpdatedTime (a read is stale only while the status equals the pre-update status and the timestamp hasn't advanced), so a stale pre-update UPDATE_COMPLETE can't be mistaken for convergence and a fast update can't be mistaken for a timeout. Failure throws carry the last non-empty StackStatusReason seen during the poll.
  • Resilient edges — a concurrent republish (second ECS task hitting is in UPDATE_IN_PROGRESS state and can not be updated) waits for the other task's update to settle and continues with its own upload instead of marking the row failed; UPDATE_FAILED is treated as terminal instead of polling a stuck stack for 30 minutes; a CREATE_FAILED first publish gets the same actionable delete-and-republish guidance as the rollback states.
  • Wiring is test-pinned — a pure planStackWait({action, existing}) decides the wait parameters for both the publish settle branch and the update branch, table-tested so dropping any parameter fails the suite.
  • IAMcloudformation:UpdateStack and cloudfront:UpdateFunction granted in all three layers (task role, Terraform module, permissions boundary), plus cloudfront:GetDistributionConfig in the task role and Terraform module (the boundary already had it), and cloudfront:UpdateOriginAccessControl/GetOriginAccessControlConfig in all three layers so a future template change to the OriginAccessControl can converge instead of failing mid-update. Also deliberately removes cloudformation:DescribeStackEvents from all three layers — verified unused by any code on any branch; failure reasons come from DescribeStacks.
  • Logical-ID pin — a test pins the template's logical IDs so no future edit can replace the distribution and change its domain.

Verification: 1594 unit tests green. The convergence tests were mutation-audited empirically — each guard was reverted (status-equality-only staleness, ignoring LastUpdatedTime, the transition-latch alternative, terminal-only reason reads, UPDATE_FAILED non-terminal, unwired planStackWait params) and the corresponding tests observed to fail.

Post-merge: one republish in the DEV account to verify live stack convergence end to end (the only piece not verifiable locally).

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown

Terraform plan preview — development

Commit 1f719bb · run log

Plan: 2 to add, 2 to change, 2 to destroy.

Show plan

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  ~ update in-place
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # module.mmgis.aws_ecs_task_definition.admin must be replaced
-/+ resource "aws_ecs_task_definition" "admin" {
      ~ arn                      = "arn:aws:ecs:us-west-2:853558080719:task-definition/mmgis-development-admin:14" -> (known after apply)
      ~ arn_without_revision     = "arn:aws:ecs:us-west-2:853558080719:task-definition/mmgis-development-admin" -> (known after apply)
      ~ container_definitions    = jsonencode(
          ~ [
              ~ {
                  ~ image            = "853558080719.dkr.ecr.us-west-2.amazonaws.com/mmgis-development:77d6d61" -> "853558080719.dkr.ecr.us-west-2.amazonaws.com/mmgis-development:32a5515"
                  - mountPoints      = []
                    name             = "mmgis"
                  ~ portMappings     = [
                      ~ {
                          - hostPort      = 8888
                            # (2 unchanged attributes hidden)
                        },
                    ]
                  - systemControls   = []
                  - volumesFrom      = []
                    # (4 unchanged attributes hidden)
                },
            ] # forces replacement
        )
      ~ enable_fault_injection   = false -> (known after apply)
      ~ id                       = "mmgis-development-admin" -> (known after apply)
      ~ revision                 = 14 -> (known after apply)
      - tags                     = {} -> null
        # (13 unchanged attributes hidden)

        # (1 unchanged block hidden)
    }

  # module.mmgis.aws_ecs_task_definition.publish must be replaced
-/+ resource "aws_ecs_task_definition" "publish" {
      ~ arn                      = "arn:aws:ecs:us-west-2:853558080719:task-definition/mmgis-development-publish:14" -> (known after apply)
      ~ arn_without_revision     = "arn:aws:ecs:us-west-2:853558080719:task-definition/mmgis-development-publish" -> (known after apply)
      ~ container_definitions    = jsonencode(
          ~ [
              ~ {
                  ~ image            = "853558080719.dkr.ecr.us-west-2.amazonaws.com/mmgis-development:77d6d61" -> "853558080719.dkr.ecr.us-west-2.amazonaws.com/mmgis-development:32a5515"
                  - mountPoints      = []
                    name             = "mmgis"
                  - portMappings     = []
                  - systemControls   = []
                  - volumesFrom      = []
                    # (5 unchanged attributes hidden)
                },
            ] # forces replacement
        )
      ~ enable_fault_injection   = false -> (known after apply)
      ~ id                       = "mmgis-development-publish" -> (known after apply)
      ~ revision                 = 14 -> (known after apply)
      - tags                     = {} -> null
        # (13 unchanged attributes hidden)

        # (1 unchanged block hidden)
    }

  # module.mmgis.aws_iam_role.publish_task will be updated in-place
  ~ resource "aws_iam_role" "publish_task" {
      ~ description           = "Runtime role for the mmgis-development-publish container (scripts/publish-static.js): create/describe/delete the mmgis-development-dashboard-* stacks and their S3/CloudFront resources, read the shared asset bucket, and read the RDS master secret at connection time to track rotation. No rds-db:connect (password auth). Lean deployment only." -> "Runtime role for the mmgis-development-publish container (scripts/publish-static.js): create/describe/update/delete the mmgis-development-dashboard-* stacks and their S3/CloudFront resources, read the shared asset bucket, and read the RDS master secret at connection time to track rotation. No rds-db:connect (password auth). Lean deployment only."
        id                    = "mmgis-development-publish-task"
        name                  = "mmgis-development-publish-task"
        tags                  = {}
        # (11 unchanged attributes hidden)

        # (1 unchanged block hidden)
    }

  # module.mmgis.aws_iam_role_policy.publish_task will be updated in-place
  ~ resource "aws_iam_role_policy" "publish_task" {
        id          = "mmgis-development-publish-task:mmgis-development-publish-task"
        name        = "mmgis-development-publish-task"
      ~ policy      = jsonencode(
          ~ {
              ~ Statement = [
                  ~ {
                      ~ Action   = [
                            "cloudformation:CreateStack",
                          ~ "cloudformation:DescribeStacks" -> "cloudformation:UpdateStack",
                          ~ "cloudformation:DescribeStackEvents" -> "cloudformation:DescribeStacks",
                            "cloudformation:DeleteStack",
                        ]
                        # (3 unchanged attributes hidden)
                    },
                    {
                        Action   = [
                            "s3:CreateBucket",
                            "s3:DeleteBucket",
                            "s3:GetBucketLocation",
                            "s3:PutBucketPolicy",
                            "s3:DeleteBucketPolicy",
                            "s3:PutBucketPublicAccessBlock",
                            "s3:PutEncryptionConfiguration",
                            "s3:PutBucketTagging",
                        ]
                        Effect   = "Allow"
                        Resource = "arn:aws:s3:::mmgis-development-dashboard-*"
                        Sid      = "DashboardBucketLifecycle"
                    },
                    {
                        Action   = [
                            "s3:PutObject",
                        ]
                        Effect   = "Allow"
                        Resource = "arn:aws:s3:::mmgis-development-dashboard-*/*"
                        Sid      = "DashboardBucketWriteObjects"
                    },
                  ~ {
                      ~ Action   = [
                            # (1 unchanged element hidden)
                            "cloudfront:GetDistribution",
                          + "cloudfront:GetDistributionConfig",
                            "cloudfront:UpdateDistribution",
                            # (5 unchanged elements hidden)
                        ]
                        # (3 unchanged attributes hidden)
                    },
                  ~ {
                      ~ Action   = [
                            # (1 unchanged element hidden)
                            "cloudfront:PublishFunction",
                          + "cloudfront:UpdateFunction",
                            "cloudfront:DescribeFunction",
                            # (5 unchanged elements hidden)
                        ]
                        # (3 unchanged attributes hidden)
                    },
                  ~ {
                      ~ Action   = [
                            # (1 unchanged element hidden)
                            "cloudfront:GetOriginAccessControl",
                          + "cloudfront:GetOriginAccessControlConfig",
                          + "cloudfront:UpdateOriginAccessControl",
                            "cloudfront:DeleteOriginAccessControl",
                        ]
                        # (3 unchanged attributes hidden)
                    },
                    {
                        Action   = [
                            "s3:GetObject",
                        ]
                        Effect   = "Allow"
                        Resource = "arn:aws:s3:::mmgis-development-assets-853558080719/*"
                        Sid      = "ReadSharedAssetObjects"
                    },
                    # (3 unchanged elements hidden)
                ]
                # (1 unchanged attribute hidden)
            }
        )
        # (2 unchanged attributes hidden)
    }

Plan: 2 to add, 2 to change, 2 to destroy.

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown

Terraform plan preview — production

Commit 1f719bb · run log

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

Show plan

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # module.mmgis.aws_cloudfront_origin_access_control.assets will be created
  + resource "aws_cloudfront_origin_access_control" "assets" {
      + arn                               = (known after apply)
      + description                       = "OAC for the production shared asset bucket origin."
      + etag                              = (known after apply)
      + id                                = (known after apply)
      + name                              = "mmgis-production-assets-oac"
      + origin_access_control_origin_type = "s3"
      + signing_behavior                  = "always"
      + signing_protocol                  = "sigv4"
    }

  # module.mmgis.aws_cloudwatch_log_group.admin will be created
  + resource "aws_cloudwatch_log_group" "admin" {
      + arn                         = (known after apply)
      + deletion_protection_enabled = (known after apply)
      + id                          = (known after apply)
      + log_group_class             = (known after apply)
      + name                        = "/ecs/mmgis-production-admin"
      + name_prefix                 = (known after apply)
      + region                      = "us-west-2"
      + retention_in_days           = 30
      + skip_destroy                = false
      + tags_all                    = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
    }

  # module.mmgis.aws_cloudwatch_log_group.publish will be created
  + resource "aws_cloudwatch_log_group" "publish" {
      + arn                         = (known after apply)
      + deletion_protection_enabled = (known after apply)
      + id                          = (known after apply)
      + log_group_class             = (known after apply)
      + name                        = "/ecs/mmgis-production-publish"
      + name_prefix                 = (known after apply)
      + region                      = "us-west-2"
      + retention_in_days           = 30
      + skip_destroy                = false
      + tags_all                    = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
    }

  # module.mmgis.aws_db_instance.this will be created
  + resource "aws_db_instance" "this" {
      + address                               = (known after apply)
      + allocated_storage                     = 20
      + apply_immediately                     = false
      + arn                                   = (known after apply)
      + auto_minor_version_upgrade            = true
      + availability_zone                     = (known after apply)
      + backup_retention_period               = 7
      + backup_target                         = (known after apply)
      + backup_window                         = (known after apply)
      + ca_cert_identifier                    = (known after apply)
      + character_set_name                    = (known after apply)
      + copy_tags_to_snapshot                 = false
      + database_insights_mode                = (known after apply)
      + db_name                               = (known after apply)
      + db_subnet_group_name                  = "mmgis-production-db"
      + dedicated_log_volume                  = false
      + delete_automated_backups              = true
      + deletion_protection                   = true
      + domain_fqdn                           = (known after apply)
      + endpoint                              = (known after apply)
      + engine                                = "postgres"
      + engine_lifecycle_support              = (known after apply)
      + engine_version                        = "17"
      + engine_version_actual                 = (known after apply)
      + final_snapshot_identifier             = "mmgis-production-postgres-final"
      + hosted_zone_id                        = (known after apply)
      + id                                    = (known after apply)
      + identifier                            = "mmgis-production-postgres"
      + identifier_prefix                     = (known after apply)
      + instance_class                        = "db.t3.micro"
      + iops                                  = (known after apply)
      + kms_key_id                            = (known after apply)
      + latest_restorable_time                = (known after apply)
      + license_model                         = (known after apply)
      + listener_endpoint                     = (known after apply)
      + maintenance_window                    = (known after apply)
      + manage_master_user_password           = true
      + master_user_secret                    = (known after apply)
      + master_user_secret_kms_key_id         = "arn:aws:kms:us-west-2:853558080719:key/8b8e2d0c-9ded-488c-a69f-be4bb7a4f8ce"
      + monitoring_interval                   = 0
      + monitoring_role_arn                   = (known after apply)
      + multi_az                              = false
      + nchar_character_set_name              = (known after apply)
      + network_type                          = (known after apply)
      + option_group_name                     = (known after apply)
      + parameter_group_name                  = (known after apply)
      + password_wo                           = (write-only attribute)
      + performance_insights_enabled          = false
      + performance_insights_kms_key_id       = (known after apply)
      + performance_insights_retention_period = (known after apply)
      + port                                  = 5432
      + publicly_accessible                   = false
      + region                                = "us-west-2"
      + replica_mode                          = (known after apply)
      + replicas                              = (known after apply)
      + resource_id                           = (known after apply)
      + skip_final_snapshot                   = false
      + snapshot_identifier                   = (known after apply)
      + status                                = (known after apply)
      + storage_encrypted                     = true
      + storage_throughput                    = (known after apply)
      + storage_type                          = "gp3"
      + tags_all                              = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
      + timezone                              = (known after apply)
      + upgrade_rollout_order                 = (known after apply)
      + username                              = "postgres"
      + vpc_security_group_ids                = (known after apply)
    }

  # module.mmgis.aws_db_subnet_group.this will be created
  + resource "aws_db_subnet_group" "this" {
      + arn                     = (known after apply)
      + description             = "Managed by Terraform"
      + id                      = (known after apply)
      + name                    = "mmgis-production-db"
      + name_prefix             = (known after apply)
      + region                  = "us-west-2"
      + subnet_ids              = [
          + "subnet-03b8a64d4f7ceb0c8",
          + "subnet-0497702c40bd7951c",
        ]
      + supported_network_types = (known after apply)
      + tags_all                = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
      + vpc_id                  = (known after apply)
    }

  # module.mmgis.aws_ecr_repository.this will be created
  + resource "aws_ecr_repository" "this" {
      + arn                  = (known after apply)
      + force_delete         = false
      + id                   = (known after apply)
      + image_tag_mutability = "MUTABLE"
      + name                 = "mmgis-production"
      + region               = "us-west-2"
      + registry_id          = (known after apply)
      + repository_url       = (known after apply)
      + tags_all             = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }

      + image_scanning_configuration {
          + scan_on_push = true
        }
    }

  # module.mmgis.aws_ecs_cluster.this will be created
  + resource "aws_ecs_cluster" "this" {
      + arn      = (known after apply)
      + id       = (known after apply)
      + name     = "mmgis-production"
      + region   = "us-west-2"
      + tags_all = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }

      + setting (known after apply)
    }

  # module.mmgis.aws_ecs_express_gateway_service.admin will be created
  + resource "aws_ecs_express_gateway_service" "admin" {
      + cluster                 = "mmgis-production"
      + cpu                     = "1024"
      + current_deployment      = (known after apply)
      + execution_role_arn      = (known after apply)
      + health_check_path       = "/api/utils/healthcheck"
      + infrastructure_role_arn = (known after apply)
      + ingress_paths           = (known after apply)
      + memory                  = "2048"
      + network_configuration   = [
          + {
              + security_groups = [
                  + (known after apply),
                ]
              + subnets         = [
                  + "subnet-03b8a64d4f7ceb0c8",
                  + "subnet-0497702c40bd7951c",
                ]
            },
        ]
      + region                  = "us-west-2"
      + scaling_target          = (known after apply)
      + service_arn             = (known after apply)
      + service_name            = "mmgis-production-admin"
      + service_revision_arn    = (known after apply)
      + tags_all                = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
      + task_role_arn           = (known after apply)
      + wait_for_steady_state   = false

      + primary_container {
          + aws_logs_configuration = [
              + {
                  + log_group         = "/ecs/mmgis-production-admin"
                  + log_stream_prefix = "mmgis-admin"
                },
            ]
          + container_port         = 8888
          + image                  = (known after apply)

          + environment {
              + name  = "DB_HOST"
              + value = (known after apply)
            }
          + environment {
              + name  = "DB_PORT"
              + value = "5432"
            }
          + environment {
              + name  = "DB_USER"
              + value = "postgres"
            }
          + environment {
              + name  = "DB_NAME"
              + value = "postgres"
            }
          + environment {
              + name  = "DB_SECRET_ARN"
              + value = (known after apply)
            }
          + environment {
              + name  = "MMGIS_DEPLOYMENT_MODE"
              + value = "lean"
            }
          + environment {
              + name  = "DISABLE_FIRST_SIGNUP"
              + value = "true"
            }
          + environment {
              + name  = "ENABLE_MMGIS_WEBSOCKETS"
              + value = "true"
            }
          + environment {
              + name  = "ENABLE_CONFIG_WEBSOCKETS"
              + value = "true"
            }
          + environment {
              + name  = "NODE_ENV"
              + value = "production"
            }
          + environment {
              + name  = "PORT"
              + value = "8888"
            }
          + environment {
              + name  = "AUTH"
              + value = "local"
            }
          + environment {
              + name  = "DB_SSL"
              + value = "true"
            }
          + environment {
              + name  = "DB_SSL_CERT_BASE64"
              + value = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNyakNDQWpTZ0F3SUJBZ0lSQU94dTBJMVF1TUFoSWVzekIzZkpJbGt3Q2dZSUtvWkl6ajBFQXdNd2daWXgKQ3pBSkJnTlZCQVlUQWxWVE1TSXdJQVlEVlFRS0RCbEJiV0Y2YjI0Z1YyVmlJRk5sY25acFkyVnpMQ0JKYm1NdQpNUk13RVFZRFZRUUxEQXBCYldGNmIyNGdVa1JUTVFzd0NRWURWUVFJREFKWFFURXZNQzBHQTFVRUF3d21RVzFoCmVtOXVJRkpFVXlCMWN5MTNaWE4wTFRJZ1VtOXZkQ0JEUVNCRlEwTXpPRFFnUnpFeEVEQU9CZ05WQkFjTUIxTmwKWVhSMGJHVXdJQmNOTWpFd05USTBNakl3TmpVNVdoZ1BNakV5TVRBMU1qUXlNekEyTlRsYU1JR1dNUXN3Q1FZRApWUVFHRXdKVlV6RWlNQ0FHQTFVRUNnd1pRVzFoZW05dUlGZGxZaUJUWlhKMmFXTmxjeXdnU1c1akxqRVRNQkVHCkExVUVDd3dLUVcxaGVtOXVJRkpFVXpFTE1Ba0dBMVVFQ0F3Q1YwRXhMekF0QmdOVkJBTU1Ka0Z0WVhwdmJpQlMKUkZNZ2RYTXRkMlZ6ZEMweUlGSnZiM1FnUTBFZ1JVTkRNemcwSUVjeE1SQXdEZ1lEVlFRSERBZFRaV0YwZEd4bApNSFl3RUFZSEtvWkl6ajBDQVFZRks0RUVBQ0lEWWdBRXo0YnlsUmNHcXFEV2RQN2dRSUlvVEhkQks2Rk50S0gxCjRTa0VJWFJYa1lEbVJ2TDlCY2kxTXVHcnd1dnJrYTVURGo0YjdlK2NzWTBsbEV6SHBLZnE2bkpQRmxqb1lZUDkKdXFIRmt2NzduT3BKSjYzM0tPcjhJeG1lSFc1UlhnclpvMEl3UURBUEJnTlZIUk1CQWY4RUJUQURBUUgvTUIwRwpBMVVkRGdRV0JCUVFpa1Z6OHdtamQ5ZURGUlh6QklVOE9zZWlHekFPQmdOVkhROEJBZjhFQkFNQ0FZWXdDZ1lJCktvWkl6ajBFQXdNRGFBQXdaUUl3ZjA2TWNycHcxTzBFQkxCQnJwODRtMzdOWXRPa0UvMFowTytDN0Q0MXduWGkKRVFkbjZQWFVWZ2REMjNHajgyU3JBakVBa2xoS3MrbGlPMVB0TjE1eWVaUjFJbzk4bkZ2ZStsTHB0YUxha1pjSAoraGZGdVV0Q3FNYmFJOENkdkpsS25QcVQKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQotLS0tLUJFR0lOIENFUlRJRklDQVRFLS0tLS0KTUlJRi9qQ0NBK2FnQXdJQkFnSVFSNzFaOGxUTzVTaithczJqQjdJV1h6QU5CZ2txaGtpRzl3MEJBUXdGQURDQgpsekVMTUFrR0ExVUVCaE1DVlZNeElqQWdCZ05WQkFvTUdVRnRZWHB2YmlCWFpXSWdVMlZ5ZG1salpYTXNJRWx1Cll5NHhFekFSQmdOVkJBc01Da0Z0WVhwdmJpQlNSRk14Q3pBSkJnTlZCQWdNQWxkQk1UQXdMZ1lEVlFRRERDZEIKYldGNmIyNGdVa1JUSUhWekxYZGxjM1F0TWlCU2IyOTBJRU5CSUZKVFFUUXdPVFlnUnpFeEVEQU9CZ05WQkFjTQpCMU5sWVhSMGJHVXdJQmNOTWpFd05USTBNakl3TXpJd1doZ1BNakV5TVRBMU1qUXlNekF6TWpCYU1JR1hNUXN3CkNRWURWUVFHRXdKVlV6RWlNQ0FHQTFVRUNnd1pRVzFoZW05dUlGZGxZaUJUWlhKMmFXTmxjeXdnU1c1akxqRVQKTUJFR0ExVUVDd3dLUVcxaGVtOXVJRkpFVXpFTE1Ba0dBMVVFQ0F3Q1YwRXhNREF1QmdOVkJBTU1KMEZ0WVhwdgpiaUJTUkZNZ2RYTXRkMlZ6ZEMweUlGSnZiM1FnUTBFZ1VsTkJOREE1TmlCSE1URVFNQTRHQTFVRUJ3d0hVMlZoCmRIUnNaVENDQWlJd0RRWUpLb1pJaHZjTkFRRUJCUUFEZ2dJUEFEQ0NBZ29DZ2dJQkFNOTc3YkhJczFXSmlqclMKWFFNZlVPaG1sSmpyMnYwSzBValBsNTJzRTFUSjc2SDh1bW8xeVI0VDdXaGtkOUl3QkhOR0tYQ0p0Sm1Ncjl6cApmQjM4ZUxUdSs1eWRVQVhkRnVacFJNS0JXd1BWZTM3QWRKUktxbjViZVM4SFFqZDNKWEFnR0tVTk51RTkyaXFGCnFpMmZJcUZNcG5KWFdvMEZJVzZzMkRsMnprT1JkN3RIMER5Z2NSaTdsZ1Z4Q3N3MUJKUWhGSm9uM3krSVY4L0YKYm5iVVhTTlNEVW5EVzJFaHZXU0Q4TCt0NGVpWFlzb3poREF6aEJ2b2pweGhQSDlPQjd2cUZZdzVxeEZ4K0cwdApsU0xYNWlXaTFqenpjM1h5R25CNldJblpEVmJ2bnZKNEJHWitkVFJwT0N2c29NSW45Yno0RVFUdnUyNDNjN2FVCkhiUy9rdm5DQVNOdCt6azdDNmxibWFxMEFHTnp0d05qODVPcG4yZW5GY2lXWlZubkovNE9lZWZVV1F4RDBFUHAKU2pFZDlDbjJJSHprQlpySENnK2xXWkpRQktiVVZTMGxMSU1Tc0xRUTZXdlIzOGpZN0QybnhNMUE5M3hXeHdwdApadFFuWVJDVlhINnp0Mk93REFGZVBJbld3eFVqUjV0L3d1M1h4UGdwU2ZybVRpM1dZdHIxd0Z5cEFKODExZS9QCnlCdHN3V1VRNkJOSlF2eStLbk9FZUdmT3dtdGRERllSK0dPQ2Z2Q2loenJLSnJ4T3RISWllZWhSNUl3M2NiWEcKc200cER6Zk1VVnZERHo2QzJNNlBSbEpoaENsYmF0SENqaWs5aHhGWUVzQWxxdFZWSzlweGF6OWk4aE9xU0ZRcQprSlNRc2dXdytvTS9CMkN5amNTcWtTUUV1OFJMQWdNQkFBR2pRakJBTUE4R0ExVWRFd0VCL3dRRk1BTUJBZjh3CkhRWURWUjBPQkJZRUZQbXJkeHBSUmd1M0ljYUI1QlRxbHByY0tkVHNNQTRHQTFVZER3RUIvd1FFQXdJQmhqQU4KQmdrcWhraUc5dzBCQVF3RkFBT0NBZ0VBVmRseFdqUHZWS2t5M2tuOFppemVNNEQrRXNMdzlkV0xhdTJVRC9scwp6d0RDRm9UNmV1YWdWZUNrbnJuK1lFbDdnMjBDUllUOWlhb25Hb01VUHVNUi9jZHRQTDFXL1JmNDBQU3JHZjlxClF1eGF2V2lITEVYT1FUQ3RDYVZaTW9ra3ZqdXVMTkRYeVpuc3RnRUN1aVpFQ1R3aGV4VUY0b2l1aHlHazlvMDEKUU1haXo0SFg0bGdrMG96QUxVdkV6YU5kOWdXRXdEMnFlK3JxOWNRTVRWcTNJQXJVa3ZUSWZ0WlVhVlVNenIwTwplZDErekFzTmE5bkpoVVJKLzZhbkpQSmpiUWdiNXFBMWFzRmNwOVVhTVQxa3UzNlUzZ25SMVQvQmRnRzJqWDNYClVtMFVjYUdOVlBySDF1a0luV1c3NDNweFdRYjcvMnN1bUVFTVZoK2pXYkIxOFNBeUxJNFdJaDRsa3VyZGlmelMKSXVURnA4VEV4K01vdUlTRmh6L3ZKRFdaODR0cW9MVmprRWNQNm9EeXBxOWxGb0V6SERKdjNWMUNZY0lnT3VzVAprMWptOVA3QlhkVEc3VFl6VWFUYjlVU2I2Ymtxa0Q5RXdKQU9TczdESTk0YUU2cnNTd3MyeUFIYXZqQU1mdU1aCnNEQVp2a3FTMlFnMloyK0NJNndVWm43bXprSlhiWm9xUmpEdkNoRFhFQjFtSWh6VlhoaU5XL0NSNVdLVkR2bGoKOXYxc2RHQnloMnBieGNMUXRWYXEvNWNvTTRBTmdwaG9OejNwT1lVUFdIUytKVXJJaXZCWitKb2JqWGN4cjNTTgo5aUR6Y3U1L0ZWVk5icTcrS04vbnZQTW5nVCtnZHVFTjVtK0VCam04R3VrSnltRkcwbTZCRU5SQTBRU0RxWjdrCnpEWT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQotLS0tLUJFR0lOIENFUlRJRklDQVRFLS0tLS0KTUlJRC9qQ0NBdWFnQXdJQkFnSVFSaXdzcEt5ck8weG94RGdTa3FMWmN6QU5CZ2txaGtpRzl3MEJBUXNGQURDQgpsekVMTUFrR0ExVUVCaE1DVlZNeElqQWdCZ05WQkFvTUdVRnRZWHB2YmlCWFpXSWdVMlZ5ZG1salpYTXNJRWx1Cll5NHhFekFSQmdOVkJBc01Da0Z0WVhwdmJpQlNSRk14Q3pBSkJnTlZCQWdNQWxkQk1UQXdMZ1lEVlFRRERDZEIKYldGNmIyNGdVa1JUSUhWekxYZGxjM1F0TWlCU2IyOTBJRU5CSUZKVFFUSXdORGdnUnpFeEVEQU9CZ05WQkFjTQpCMU5sWVhSMGJHVXdJQmNOTWpFd05USTBNakUxT1RBd1doZ1BNakEyTVRBMU1qUXlNalU1TURCYU1JR1hNUXN3CkNRWURWUVFHRXdKVlV6RWlNQ0FHQTFVRUNnd1pRVzFoZW05dUlGZGxZaUJUWlhKMmFXTmxjeXdnU1c1akxqRVQKTUJFR0ExVUVDd3dLUVcxaGVtOXVJRkpFVXpFTE1Ba0dBMVVFQ0F3Q1YwRXhNREF1QmdOVkJBTU1KMEZ0WVhwdgpiaUJTUkZNZ2RYTXRkMlZ6ZEMweUlGSnZiM1FnUTBFZ1VsTkJNakEwT0NCSE1URVFNQTRHQTFVRUJ3d0hVMlZoCmRIUnNaVENDQVNJd0RRWUpLb1pJaHZjTkFRRUJCUUFEZ2dFUEFEQ0NBUW9DZ2dFQkFMNTNKazNHc0tpdSs0YngKakRmc2V2V2J3UENOSjNIMDhacDdHV2h2STNUZ2kzOW9wZkhZdjJrdTJCS0ZqSzhOMkw2UnZOUFNSOHlwbHY1agpZMHRLMFUrWFZObDhvMGliaHFSRGhiVHVoNktMOENGSU5XWXpBYWp1eEZTK0NGMFU2YzFRM3RYTEJkQUx4QTdsCkZsWEo3MVFyUDA2VzMxa1JlN2t2Z3J2TzdxV1UzL096VWY5cVl3NExTaVIxL1ZrdnZSQ1RxY1ZOdzA5Y2x3L00KSmJ3NkZTZ3dlTjY1TTlqN3pQYmpHQVhTSGtYeXhIMUVyaW4yZmErQjlQRTRaRGdYOWNwMkMxREhld1lKUUwvZwpTZXB3d2N1ZFZOUk4xaWJLSDdrcE1yZ1BuYU5JVk54NXNYVnNUams2cTJacVl3M1NWSGVnbHRKcEx5L2NaUmVQCm1saXZGMmtDQXdFQUFhTkNNRUF3RHdZRFZSMFRBUUgvQkFVd0F3RUIvekFkQmdOVkhRNEVGZ1FVbVRjUWQ2bzEKQ3VTNjVNakJyTXdROUpKam1Cd3dEZ1lEVlIwUEFRSC9CQVFEQWdHR01BMEdDU3FHU0liM0RRRUJDd1VBQTRJQgpBUUFLU0RTSXpsOTU2d1ZkZFBUaGYyVkF6SThzeXc5bmdTd3NFSFp2eFZHSEJ2dTVnZzYxOHJEeWd1VkNZWDlMCjRLdy94SnJrNlMzcXhPUzJaRHlCY09wc3JCc2tnYWhERkl1bnpvUlAzYTE4QVJRVnE1NUxWZ2Z3U0RRaXVuY2gKQmQwNWNuRkdMb2lMa1I1cnJrZ1lhUDJmdG4zZ1JCUmFmMHkwUzNKWFoyWEIzc01aeEd4YXZZcTltZmlFY3dCMApMTVRNUTFOWXphaEllRzZKbTNMcVJxUjhIa3pQL1p0cTRkVDJBdFNMdkZlYmJOTWlXcWVxVDdPY1lwOTRIVFlUCnpxcnRhVmRVZzlid3lBVUNEZ3kwR1Y5UkhESWROQU9JblUvNExFRVRvdnJ0dUJVN1oxcTR0Y0hYdk42SGQxSDgKZ01iMG1DRzVJMzkzcVc1aEZzQS9kaUZiCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K"
            }
          + environment {
              + name  = "AWS_REGION"
              + value = "us-west-2"
            }
          + environment {
              + name  = "MMGIS_PUBLISH_ECS_CLUSTER"
              + value = "mmgis-production"
            }
          + environment {
              + name  = "MMGIS_PUBLISH_TASK_DEFINITION"
              + value = "mmgis-production-publish"
            }
          + environment {
              + name  = "MMGIS_PUBLISH_SUBNETS"
              + value = "subnet-03b8a64d4f7ceb0c8,subnet-0497702c40bd7951c"
            }
          + environment {
              + name  = "MMGIS_PUBLISH_SECURITY_GROUPS"
              + value = (known after apply)
            }
          + environment {
              + name  = "MMGIS_PUBLISH_CONTAINER_NAME"
              + value = "mmgis"
            }
          + environment {
              + name  = "MMGIS_SHARED_ASSET_BUCKET"
              + value = "mmgis-production-assets-853558080719"
            }
          + environment {
              + name  = "MMGIS_ENVIRONMENT"
              + value = "production"
            }

          + secret {
              + name       = "DB_PASS"
              + value_from = (known after apply)
            }
          + secret {
              + name       = "SECRET"
              + value_from = (known after apply)
            }
          + secret {
              + name       = "SEED_SUPERADMIN_USERNAME"
              + value_from = (known after apply)
            }
          + secret {
              + name       = "SEED_SUPERADMIN_PASSWORD"
              + value_from = (known after apply)
            }
          + secret {
              + name       = "MAPBOX_TOKEN"
              + value_from = (known after apply)
            }
        }
    }

  # module.mmgis.aws_ecs_task_definition.admin will be created
  + resource "aws_ecs_task_definition" "admin" {
      + arn                      = (known after apply)
      + arn_without_revision     = (known after apply)
      + container_definitions    = (known after apply)
      + cpu                      = "1024"
      + enable_fault_injection   = (known after apply)
      + execution_role_arn       = (known after apply)
      + family                   = "mmgis-production-admin"
      + id                       = (known after apply)
      + memory                   = "2048"
      + network_mode             = "awsvpc"
      + region                   = "us-west-2"
      + requires_compatibilities = [
          + "FARGATE",
        ]
      + revision                 = (known after apply)
      + skip_destroy             = false
      + tags_all                 = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
      + task_role_arn            = (known after apply)
      + track_latest             = false

      + runtime_platform {
          + cpu_architecture        = "X86_64"
          + operating_system_family = "LINUX"
        }
    }

  # module.mmgis.aws_ecs_task_definition.publish will be created
  + resource "aws_ecs_task_definition" "publish" {
      + arn                      = (known after apply)
      + arn_without_revision     = (known after apply)
      + container_definitions    = (known after apply)
      + cpu                      = "2048"
      + enable_fault_injection   = (known after apply)
      + execution_role_arn       = (known after apply)
      + family                   = "mmgis-production-publish"
      + id                       = (known after apply)
      + memory                   = "8192"
      + network_mode             = "awsvpc"
      + region                   = "us-west-2"
      + requires_compatibilities = [
          + "FARGATE",
        ]
      + revision                 = (known after apply)
      + skip_destroy             = false
      + tags_all                 = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
      + task_role_arn            = (known after apply)
      + track_latest             = false

      + runtime_platform {
          + cpu_architecture        = "X86_64"
          + operating_system_family = "LINUX"
        }
    }

  # module.mmgis.aws_iam_role.admin_exec will be created
  + resource "aws_iam_role" "admin_exec" {
      + arn                   = (known after apply)
      + assume_role_policy    = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "sts:AssumeRole"
                      + Condition = {
                          + StringEquals = {
                              + "aws:SourceAccount" = "853558080719"
                            }
                        }
                      + Effect    = "Allow"
                      + Principal = {
                          + Service = "ecs-tasks.amazonaws.com"
                        }
                      + Sid       = "AllowEcsTasksAssume"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + create_date           = (known after apply)
      + description           = "ECS-side role for the mmgis-production-admin task: pull image, write logs, inject admin secrets[]. Lean deployment only."
      + force_detach_policies = false
      + id                    = (known after apply)
      + managed_policy_arns   = (known after apply)
      + max_session_duration  = 3600
      + name                  = "mmgis-production-admin-task-execution"
      + name_prefix           = (known after apply)
      + path                  = "/"
      + permissions_boundary  = "arn:aws:iam::853558080719:policy/mmgis-ci-role-boundary-production"
      + tags_all              = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
      + unique_id             = (known after apply)

      + inline_policy (known after apply)
    }

  # module.mmgis.aws_iam_role.admin_task will be created
  + resource "aws_iam_role" "admin_task" {
      + arn                   = (known after apply)
      + assume_role_policy    = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "sts:AssumeRole"
                      + Condition = {
                          + StringEquals = {
                              + "aws:SourceAccount" = "853558080719"
                            }
                        }
                      + Effect    = "Allow"
                      + Principal = {
                          + Service = "ecs-tasks.amazonaws.com"
                        }
                      + Sid       = "AllowEcsTasksAssume"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + create_date           = (known after apply)
      + description           = "Runtime role for the mmgis-production-admin container: RunTask + PassRole of the publish roles, dashboard stack read/delete + teardown, admin asset upload. Lean deployment only."
      + force_detach_policies = false
      + id                    = (known after apply)
      + managed_policy_arns   = (known after apply)
      + max_session_duration  = 3600
      + name                  = "mmgis-production-admin-task"
      + name_prefix           = (known after apply)
      + path                  = "/"
      + permissions_boundary  = "arn:aws:iam::853558080719:policy/mmgis-ci-role-boundary-production"
      + tags_all              = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
      + unique_id             = (known after apply)

      + inline_policy (known after apply)
    }

  # module.mmgis.aws_iam_role.express_infra will be created
  + resource "aws_iam_role" "express_infra" {
      + arn                   = (known after apply)
      + assume_role_policy    = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "sts:AssumeRole"
                      + Condition = {
                          + StringEquals = {
                              + "aws:SourceAccount" = "853558080719"
                            }
                        }
                      + Effect    = "Allow"
                      + Principal = {
                          + Service = "ecs.amazonaws.com"
                        }
                      + Sid       = "AllowEcsServiceAssume"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + create_date           = (known after apply)
      + description           = "Infrastructure role for the mmgis-production-admin Express service. Trust-only + AWS managed policy; no inline policy. Immutable after service creation. Lean deployment only."
      + force_detach_policies = false
      + id                    = (known after apply)
      + managed_policy_arns   = (known after apply)
      + max_session_duration  = 3600
      + name                  = "mmgis-production-express-infrastructure"
      + name_prefix           = (known after apply)
      + path                  = "/"
      + permissions_boundary  = "arn:aws:iam::853558080719:policy/mmgis-ci-role-boundary-production"
      + tags_all              = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
      + unique_id             = (known after apply)

      + inline_policy (known after apply)
    }

  # module.mmgis.aws_iam_role.publish_exec will be created
  + resource "aws_iam_role" "publish_exec" {
      + arn                   = (known after apply)
      + assume_role_policy    = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "sts:AssumeRole"
                      + Condition = {
                          + StringEquals = {
                              + "aws:SourceAccount" = "853558080719"
                            }
                        }
                      + Effect    = "Allow"
                      + Principal = {
                          + Service = "ecs-tasks.amazonaws.com"
                        }
                      + Sid       = "AllowEcsTasksAssume"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + create_date           = (known after apply)
      + description           = "ECS-side role for the mmgis-production-publish task: pull image, write logs, inject publish secrets[]. Lean deployment only."
      + force_detach_policies = false
      + id                    = (known after apply)
      + managed_policy_arns   = (known after apply)
      + max_session_duration  = 3600
      + name                  = "mmgis-production-publish-task-execution"
      + name_prefix           = (known after apply)
      + path                  = "/"
      + permissions_boundary  = "arn:aws:iam::853558080719:policy/mmgis-ci-role-boundary-production"
      + tags_all              = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
      + unique_id             = (known after apply)

      + inline_policy (known after apply)
    }

  # module.mmgis.aws_iam_role.publish_task will be created
  + resource "aws_iam_role" "publish_task" {
      + arn                   = (known after apply)
      + assume_role_policy    = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "sts:AssumeRole"
                      + Condition = {
                          + StringEquals = {
                              + "aws:SourceAccount" = "853558080719"
                            }
                        }
                      + Effect    = "Allow"
                      + Principal = {
                          + Service = "ecs-tasks.amazonaws.com"
                        }
                      + Sid       = "AllowEcsTasksAssume"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + create_date           = (known after apply)
      + description           = "Runtime role for the mmgis-production-publish container (scripts/publish-static.js): create/describe/update/delete the mmgis-production-dashboard-* stacks and their S3/CloudFront resources, read the shared asset bucket, and read the RDS master secret at connection time to track rotation. No rds-db:connect (password auth). Lean deployment only."
      + force_detach_policies = false
      + id                    = (known after apply)
      + managed_policy_arns   = (known after apply)
      + max_session_duration  = 3600
      + name                  = "mmgis-production-publish-task"
      + name_prefix           = (known after apply)
      + path                  = "/"
      + permissions_boundary  = "arn:aws:iam::853558080719:policy/mmgis-ci-role-boundary-production"
      + tags_all              = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
      + unique_id             = (known after apply)

      + inline_policy (known after apply)
    }

  # module.mmgis.aws_iam_role_policy.admin_exec will be created
  + resource "aws_iam_role_policy" "admin_exec" {
      + id          = (known after apply)
      + name        = "mmgis-production-admin-task-execution"
      + name_prefix = (known after apply)
      + policy      = (known after apply)
      + role        = (known after apply)
    }

  # module.mmgis.aws_iam_role_policy.admin_task will be created
  + resource "aws_iam_role_policy" "admin_task" {
      + id          = (known after apply)
      + name        = "mmgis-production-admin-task"
      + name_prefix = (known after apply)
      + policy      = (known after apply)
      + role        = (known after apply)
    }

  # module.mmgis.aws_iam_role_policy.publish_exec will be created
  + resource "aws_iam_role_policy" "publish_exec" {
      + id          = (known after apply)
      + name        = "mmgis-production-publish-task-execution"
      + name_prefix = (known after apply)
      + policy      = (known after apply)
      + role        = (known after apply)
    }

  # module.mmgis.aws_iam_role_policy.publish_task will be created
  + resource "aws_iam_role_policy" "publish_task" {
      + id          = (known after apply)
      + name        = "mmgis-production-publish-task"
      + name_prefix = (known after apply)
      + policy      = (known after apply)
      + role        = (known after apply)
    }

  # module.mmgis.aws_iam_role_policy_attachment.express_infra will be created
  + resource "aws_iam_role_policy_attachment" "express_infra" {
      + id         = (known after apply)
      + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSInfrastructureRoleforExpressGatewayServices"
      + role       = "mmgis-production-express-infrastructure"
    }

  # module.mmgis.aws_s3_bucket.assets will be created
  + resource "aws_s3_bucket" "assets" {
      + acceleration_status         = (known after apply)
      + acl                         = (known after apply)
      + arn                         = (known after apply)
      + bucket                      = "mmgis-production-assets-853558080719"
      + bucket_domain_name          = (known after apply)
      + bucket_namespace            = (known after apply)
      + bucket_prefix               = (known after apply)
      + bucket_region               = (known after apply)
      + bucket_regional_domain_name = (known after apply)
      + force_destroy               = false
      + hosted_zone_id              = (known after apply)
      + id                          = (known after apply)
      + object_lock_enabled         = (known after apply)
      + policy                      = (known after apply)
      + region                      = "us-west-2"
      + request_payer               = (known after apply)
      + tags_all                    = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
      + website_domain              = (known after apply)
      + website_endpoint            = (known after apply)

      + cors_rule (known after apply)

      + grant (known after apply)

      + lifecycle_rule (known after apply)

      + logging (known after apply)

      + object_lock_configuration (known after apply)

      + replication_configuration (known after apply)

      + server_side_encryption_configuration (known after apply)

      + versioning (known after apply)

      + website (known after apply)
    }

  # module.mmgis.aws_s3_bucket_public_access_block.assets will be created
  + resource "aws_s3_bucket_public_access_block" "assets" {
      + block_public_acls       = true
      + block_public_policy     = true
      + bucket                  = (known after apply)
      + id                      = (known after apply)
      + ignore_public_acls      = true
      + region                  = "us-west-2"
      + restrict_public_buckets = true
    }

  # module.mmgis.aws_s3_bucket_server_side_encryption_configuration.assets will be created
  + resource "aws_s3_bucket_server_side_encryption_configuration" "assets" {
      + bucket = (known after apply)
      + id     = (known after apply)
      + region = "us-west-2"

      + rule {
          + blocked_encryption_types = (known after apply)
          + bucket_key_enabled       = (known after apply)

          + apply_server_side_encryption_by_default {
              + kms_master_key_id = (known after apply)
              + sse_algorithm     = "AES256"
            }
        }
    }

  # module.mmgis.aws_secretsmanager_secret.dashboards_password will be created
  + resource "aws_secretsmanager_secret" "dashboards_password" {
      + arn                            = (known after apply)
      + description                    = "MMGIS production shared dashboards password (MMGIS_DASHBOARDS_PASSWORD). Value generated by the CI secret bootstrap on first deploy if empty; never overwritten."
      + force_overwrite_replica_secret = false
      + id                             = (known after apply)
      + name                           = "mmgis/production/dashboards-password"
      + name_prefix                    = (known after apply)
      + policy                         = (known after apply)
      + recovery_window_in_days        = 0
      + region                         = "us-west-2"
      + tags_all                       = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }

      + replica (known after apply)
    }

  # module.mmgis.aws_secretsmanager_secret.mapbox_token will be created
  + resource "aws_secretsmanager_secret" "mapbox_token" {
      + arn                            = (known after apply)
      + description                    = "MMGIS production Mapbox token (injected as MAPBOX_TOKEN, admin task only). External credential: hand-set once, excluded from CI secret generation."
      + force_overwrite_replica_secret = false
      + id                             = (known after apply)
      + name                           = "mmgis/production/mapbox-token"
      + name_prefix                    = (known after apply)
      + policy                         = (known after apply)
      + recovery_window_in_days        = 0
      + region                         = "us-west-2"
      + tags_all                       = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }

      + replica (known after apply)
    }

  # module.mmgis.aws_secretsmanager_secret.seed_password will be created
  + resource "aws_secretsmanager_secret" "seed_password" {
      + arn                            = (known after apply)
      + description                    = "MMGIS production superadmin seed password (SEED_SUPERADMIN_PASSWORD). Value generated by the CI secret bootstrap on first deploy if empty; never overwritten."
      + force_overwrite_replica_secret = false
      + id                             = (known after apply)
      + name                           = "mmgis/production/superadmin-password"
      + name_prefix                    = (known after apply)
      + policy                         = (known after apply)
      + recovery_window_in_days        = 0
      + region                         = "us-west-2"
      + tags_all                       = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }

      + replica (known after apply)
    }

  # module.mmgis.aws_secretsmanager_secret.seed_username will be created
  + resource "aws_secretsmanager_secret" "seed_username" {
      + arn                            = (known after apply)
      + description                    = "MMGIS production superadmin seed username (SEED_SUPERADMIN_USERNAME). Value generated by the CI secret bootstrap on first deploy if empty; never overwritten."
      + force_overwrite_replica_secret = false
      + id                             = (known after apply)
      + name                           = "mmgis/production/superadmin-username"
      + name_prefix                    = (known after apply)
      + policy                         = (known after apply)
      + recovery_window_in_days        = 0
      + region                         = "us-west-2"
      + tags_all                       = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }

      + replica (known after apply)
    }

  # module.mmgis.aws_secretsmanager_secret.session will be created
  + resource "aws_secretsmanager_secret" "session" {
      + arn                            = (known after apply)
      + description                    = "MMGIS production express-session secret (injected as env SECRET). Value generated by the CI secret bootstrap on first deploy if empty; never overwritten."
      + force_overwrite_replica_secret = false
      + id                             = (known after apply)
      + name                           = "mmgis/production/session-secret"
      + name_prefix                    = (known after apply)
      + policy                         = (known after apply)
      + recovery_window_in_days        = 0
      + region                         = "us-west-2"
      + tags_all                       = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }

      + replica (known after apply)
    }

  # module.mmgis.aws_security_group.rds will be created
  + resource "aws_security_group" "rds" {
      + arn                    = (known after apply)
      + description            = "MMGIS production RDS: ingress 5432 from the task SG only."
      + egress                 = (known after apply)
      + id                     = (known after apply)
      + ingress                = [
          + {
              + cidr_blocks      = []
              + description      = "PostgreSQL from the admin/publish task SG."
              + from_port        = 5432
              + ipv6_cidr_blocks = []
              + prefix_list_ids  = []
              + protocol         = "tcp"
              + security_groups  = (known after apply)
              + self             = false
              + to_port          = 5432
            },
        ]
      + name                   = "mmgis-production-rds-sg"
      + name_prefix            = (known after apply)
      + owner_id               = (known after apply)
      + region                 = "us-west-2"
      + revoke_rules_on_delete = false
      + tags                   = {
          + "Name" = "mmgis-production-rds-sg"
        }
      + tags_all               = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Name"        = "mmgis-production-rds-sg"
          + "Project"     = "MMGIS"
        }
      + vpc_id                 = "vpc-0512162c42da5e645"
    }

  # module.mmgis.aws_security_group.service will be created
  + resource "aws_security_group" "service" {
      + arn                    = (known after apply)
      + description            = "MMGIS production admin + publish tasks: ingress 8888 from the in-VPC ALB, egress all."
      + egress                 = [
          + {
              + cidr_blocks      = [
                  + "0.0.0.0/0",
                ]
              + description      = "All outbound (image pull, AWS APIs, webhooks via NAT)."
              + from_port        = 0
              + ipv6_cidr_blocks = []
              + prefix_list_ids  = []
              + protocol         = "-1"
              + security_groups  = []
              + self             = false
              + to_port          = 0
            },
        ]
      + id                     = (known after apply)
      + ingress                = [
          + {
              + cidr_blocks      = [
                  + "10.41.0.0/16",
                ]
              + description      = "App port from the ECS-managed ALB (in-VPC)."
              + from_port        = 8888
              + ipv6_cidr_blocks = []
              + prefix_list_ids  = []
              + protocol         = "tcp"
              + security_groups  = []
              + self             = false
              + to_port          = 8888
            },
        ]
      + name                   = "mmgis-production-service-sg"
      + name_prefix            = (known after apply)
      + owner_id               = (known after apply)
      + region                 = "us-west-2"
      + revoke_rules_on_delete = false
      + tags                   = {
          + "Name" = "mmgis-production-service-sg"
        }
      + tags_all               = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Name"        = "mmgis-production-service-sg"
          + "Project"     = "MMGIS"
        }
      + vpc_id                 = "vpc-0512162c42da5e645"
    }

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

Changes to Outputs:
  + express_ingress_paths         = (known after apply)
  + express_service_arn           = (known after apply)
  + rds_managed_master_secret_arn = (known after apply)
  + workflow_variables            = {
      + ADMIN_TASK_FAMILY   = "mmgis-production-admin"
      + AWS_REGION          = "us-west-2"
      + ECR_REPOSITORY      = "mmgis-production"
      + ECS_CLUSTER         = "mmgis-production"
      + ECS_SERVICE         = "mmgis-production-admin"
      + PUBLISH_TASK_FAMILY = "mmgis-production-publish"
    }

The `update` action only re-uploaded files, so a dashboard created before an
edge-function change could never receive it: the stack was created once and
never updated. Update now renders the current template, calls UpdateStack, and
polls to UPDATE_COMPLETE — treating "No updates are to be performed" as an
up-to-date no-op — and the publish action reuses that same describe so a
re-run over a stack mid-operation waits it out instead of dying on
AlreadyExistsException. The publish task role, the Terraform module, and the
permissions boundary each grant cloudformation:UpdateStack plus the
CloudFront read-back/update actions the converge needs; DescribeStackEvents,
which nothing calls, is dropped. A test pins the template's logical IDs and
the bucket's anonymity, since renaming either would make UpdateStack REPLACE
the distribution and mint a new domain.

The wait itself has to survive DescribeStacks' eventual consistency: the first
polls after an UpdateStack can still report the pre-update status, which for
the ordinary republish IS UPDATE_COMPLETE. waitForStack therefore takes the
prior { status, lastUpdatedTime } and treats a poll as stale only while it
matches BOTH — an advanced LastUpdatedTime is positive proof this update
landed, so an update that starts and finishes inside one poll interval is
recognized rather than polled to a false timeout, and a rollback back to the
resting status still fails with its reason. That reason is the last non-empty
StackStatusReason seen while polling, because CloudFormation puts it on the
in-progress rollback and leaves the terminal status empty. UPDATE_FAILED joins
the terminal statuses so a stuck stack throws instead of being polled for
thirty minutes, and CREATE_FAILED — where this code's OnFailure "DO_NOTHING"
leaves a failed first publish — joins the statuses that get the
delete-and-republish guidance.

Two republish clicks start two ECS tasks, and CloudFormation rejects the
loser's UpdateStack outright. Rather than mark that row failed after the other
task succeeded, the update path recognizes the rejection, waits the in-flight
operation out, and carries on to the upload, so a double republish stays
harmlessly last-write-wins. planStackWait() derives the wait parameters for
each of these cases, so the wiring between the two is table-tested rather than
implicit in the call sites.
@CarsonDavis
CarsonDavis force-pushed the 316-republish-stack-convergence branch from 6bb5bf6 to 1f719bb Compare August 27, 2026 18:08
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