From 6ef8e9ba2a105fc78bf975255eb0ae3f6c1f1b38 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:18:44 -0400 Subject: [PATCH] feat(terraform): complete dev environment adoption (SH-300) (#99) * feat(terraform): complete dev environment adoption * fix(terraform): preserve dev release permissions --- infra/cdk/README.md | 29 ++++++++++++ infra/cdk/deploy-dev-stack.ts | 47 +++++++++++++++++-- terraform/live/README.md | 38 ++++++++++----- terraform/live/dev/main.tf | 4 +- .../live/modules/environment-owned/main.tf | 4 +- .../modules/environment-owned/variables.tf | 5 +- 6 files changed, 104 insertions(+), 23 deletions(-) diff --git a/infra/cdk/README.md b/infra/cdk/README.md index 8c77048..5ab4d24 100644 --- a/infra/cdk/README.md +++ b/infra/cdk/README.md @@ -187,6 +187,32 @@ npm run deploy # deploy the stack (requires AWS) All commands run from `infra/cdk/`. +## Terraform ownership transfer + +`ManageGithubDeployRole` deliberately has no default. Every CDK deployment must +state the intended ownership phase: + +```bash +# Before the controlled Terraform apply: install Retain on the role and policy. +npx cdk deploy shoc-backend-deploy-dev \ + --parameters shoc-backend-deploy-dev:ManageGithubDeployRole=true + +# After Terraform succeeds and live verification passes: relinquish ownership. +npx cdk deploy shoc-backend-deploy-dev \ + --parameters shoc-backend-deploy-dev:ManageGithubDeployRole=false +``` + +Both deployments must use the same reviewed SHA. The first keeps the role and +generated inline policy under CloudFormation while adding retention metadata. +The second removes both resources from CloudFormation ownership while retaining +them live for Terraform. After the second deployment succeeds, +`ManageGithubDeployRole=true` must never be used again. + +Omitting the parameter fails closed before deployment. If the `true` deployment +rolls back, inspect the stack resources and live role/policy before retrying; +retained resources can outlive a failed update and must not be cleaned up +automatically. + ## CI integration `npm run synth` is the deterministic local/CI validation. After synth, inspect @@ -194,6 +220,9 @@ All commands run from `infra/cdk/`. `AWS::IAM::Role`: - Trust policy `StringEquals` matches the exact audience and subject above. +- The role, generated `AWS::IAM::Policy`, and role ARN output share the + `ManageGithubDeployRoleCondition`; both resources use `DeletionPolicy` and + `UpdateReplacePolicy` set to `Retain`. - The inline policy contains no `Resource: "*"` mutation action and no service outside `elasticbeanstalk` / `s3` / `cloudformation` / `ec2` / `elasticloadbalancing` / `autoscaling`. CloudFormation discovery and diff --git a/infra/cdk/deploy-dev-stack.ts b/infra/cdk/deploy-dev-stack.ts index 6ec101d..87bad48 100644 --- a/infra/cdk/deploy-dev-stack.ts +++ b/infra/cdk/deploy-dev-stack.ts @@ -14,6 +14,27 @@ export class DeployDevStack extends cdk.Stack { constructor(scope: Construct, id: string, props: cdk.StackProps = {}) { super(scope, id, props); + const manageGithubDeployRole = new cdk.CfnParameter( + this, + 'ManageGithubDeployRole', + { + type: 'String', + allowedValues: ['true', 'false'], + description: + 'Set true only before Terraform adoption. After ownership transfer, always reuse false.', + }, + ); + const manageGithubDeployRoleCondition = new cdk.CfnCondition( + this, + 'ManageGithubDeployRoleCondition', + { + expression: cdk.Fn.conditionEquals( + manageGithubDeployRole.valueAsString, + 'true', + ), + }, + ); + const applicationArn = `arn:aws:elasticbeanstalk:${REGION}:${ACCOUNT_ID}:application/${APPLICATION_NAME}`; const environmentArn = `arn:aws:elasticbeanstalk:${REGION}:${ACCOUNT_ID}:environment/${APPLICATION_NAME}/${ENVIRONMENT_NAME}`; const oidcProviderArn = `arn:aws:iam::${ACCOUNT_ID}:oidc-provider/token.actions.githubusercontent.com`; @@ -38,6 +59,7 @@ export class DeployDevStack extends cdk.Stack { const cfnRole = deployRole.node.defaultChild as iam.CfnRole; cfnRole.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN; cfnRole.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN; + cfnRole.cfnOptions.condition = manageGithubDeployRoleCondition; deployRole.addToPolicy( new iam.PolicyStatement({ @@ -134,10 +156,25 @@ export class DeployDevStack extends cdk.Stack { }), ); - new cdk.CfnOutput(this, 'GithubDeployRoleArn', { - value: deployRole.roleArn, - description: 'ARN of the GitHub OIDC deploy role for shoc-backend dev.', - exportName: 'shoc-backend-deploy-dev-role-arn', - }); + const defaultPolicy = deployRole.node.findChild( + 'DefaultPolicy', + ) as iam.Policy; + defaultPolicy.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN); + const cfnDefaultPolicy = defaultPolicy.node.defaultChild as iam.CfnPolicy; + cfnDefaultPolicy.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN; + cfnDefaultPolicy.cfnOptions.updateReplacePolicy = + cdk.CfnDeletionPolicy.RETAIN; + cfnDefaultPolicy.cfnOptions.condition = manageGithubDeployRoleCondition; + + const githubDeployRoleArn = new cdk.CfnOutput( + this, + 'GithubDeployRoleArn', + { + value: deployRole.roleArn, + description: 'ARN of the GitHub OIDC deploy role for shoc-backend dev.', + exportName: 'shoc-backend-deploy-dev-role-arn', + }, + ); + githubDeployRoleArn.condition = manageGithubDeployRoleCondition; } } diff --git a/terraform/live/README.md b/terraform/live/README.md index e7379c8..c6e4880 100644 --- a/terraform/live/README.md +++ b/terraform/live/README.md @@ -94,8 +94,10 @@ tf-poc rehearsal has completed both phases and therefore pins creates, deletes, replacements, and managed resource types outside the approved environment-owned boundary. 4. Apply the no-op import only after review. -5. Change the environment root to `adoption_complete=true` in a reviewed code - change, then review the controlled in-place role and policy update: +5. Change the environment root to `adoption_complete=true` and + `manage_eb_settings=true` in a reviewed code change, then review the + controlled in-place role metadata, secret metadata, and Elastic Beanstalk + update: ```bash # Dev example. Omit any address that is not updating. @@ -103,22 +105,32 @@ tf-poc rehearsal has completed both phases and therefore pins --allow-update-address module.environment.aws_iam_instance_profile.runtime \ --allow-update-address module.environment.aws_iam_role.runtime \ --allow-update-address module.environment.aws_iam_role.github_deploy \ - --allow-update-address module.environment.aws_iam_role_policy.github_deploy \ - --allow-update-address module.environment.aws_secretsmanager_secret.app_config + --allow-update-address module.environment.aws_secretsmanager_secret.app_config \ + --allow-update-address module.environment.aws_elastic_beanstalk_environment.this ``` 6. Apply only when every update address is named on the command line and the - plan contains no create, delete, or replacement action. + plan contains no create, delete, or replacement action. The dev direct ALB + alias remains pinned during this phase and must not update. + +The same reviewed change prepares the legacy dev CDK stack for ownership +transfer. Before the Terraform apply, deploy `shoc-backend-deploy-dev` with +`ManageGithubDeployRole=true` so both the role and generated inline-policy +resource carry `Retain`. After Terraform succeeds and live verification passes, +deploy the same reviewed SHA with `ManageGithubDeployRole=false`. This removes +both resources from CloudFormation ownership without deleting them. Never use +`ManageGithubDeployRole=true` again after that transfer. The reviewed `adoption_complete=true` change updates ownership tags on IAM -roles, instance profiles, and app-config secrets, and narrows the dev role to -the staging-style S3 bucket and application prefix. Elastic Beanstalk -environment tags remain at their imported values. EB accepts an added -`ManagedBy` tag request but can fail the asynchronous service-managed -CloudFormation propagation after Terraform reports success. Terraform still -manages the declared EB settings. Deploy-role descriptions and immutable -`HcpTerraformWorkspace` tags remain unchanged. Read-only AWS APIs retain -`Resource = "*"` only where AWS does not support resource-level permissions. +roles, instance profiles, and app-config secrets. Dev retains the proven GitHub +Elastic Beanstalk release policy until application CD is migrated in a separate +reviewed change; infrastructure adoption must not silently break the current +manual release path. Elastic Beanstalk environment tags remain at their imported +values. Terraform manages the declared EB settings. Secret values remain +out-of-band even after the secret shell receives `ManagedBy=terraform`. +Deploy-role descriptions and immutable `HcpTerraformWorkspace` tags remain +unchanged. Read-only AWS APIs retain `Resource = "*"` only where AWS does not +support resource-level permissions. ## POC retained identifiers diff --git a/terraform/live/dev/main.tf b/terraform/live/dev/main.tf index 6391581..64f2aeb 100644 --- a/terraform/live/dev/main.tf +++ b/terraform/live/dev/main.tf @@ -26,8 +26,8 @@ module "environment" { aws_account_id = local.aws_account_id aws_region = local.aws_region environment = "dev" - adoption_complete = false - manage_eb_settings = false + adoption_complete = true + manage_eb_settings = true eb_application_name = local.eb_application_name eb_environment_name = local.eb_environment_name eb_environment_id = local.eb_environment_id diff --git a/terraform/live/modules/environment-owned/main.tf b/terraform/live/modules/environment-owned/main.tf index d4e4f27..3a2522e 100644 --- a/terraform/live/modules/environment-owned/main.tf +++ b/terraform/live/modules/environment-owned/main.tf @@ -9,7 +9,7 @@ locals { environment_arn = "arn:aws:elasticbeanstalk:${var.aws_region}:${var.aws_account_id}:environment/${var.eb_application_name}/${var.eb_environment_name}" environment_stack_name = "awseb-${var.eb_environment_id}-stack" eb_bucket_name = "elasticbeanstalk-${var.aws_region}-${var.aws_account_id}" - use_legacy_s3_policy = !var.adoption_complete && var.legacy_dev_s3_policy + use_legacy_s3_policy = var.legacy_dev_s3_policy app_config_secret_pattern = "arn:aws:secretsmanager:${var.aws_region}:${var.aws_account_id}:secret:${var.app_config_secret_name}-*" } @@ -141,6 +141,8 @@ resource "aws_iam_instance_profile" "runtime" { } resource "aws_secretsmanager_secret" "app_config" { + # Terraform owns the secret shell and metadata only. Values remain out of + # band and must never be declared in this resource or its callers. name = var.app_config_secret_name description = var.metadata_before_adoption.app_config_description tags = var.adoption_complete ? merge(var.metadata_before_adoption.app_config_tags, { ManagedBy = "terraform" }) : var.metadata_before_adoption.app_config_tags diff --git a/terraform/live/modules/environment-owned/variables.tf b/terraform/live/modules/environment-owned/variables.tf index 49a6066..ecad6eb 100644 --- a/terraform/live/modules/environment-owned/variables.tf +++ b/terraform/live/modules/environment-owned/variables.tf @@ -190,8 +190,9 @@ variable "github_deploy_policy_name" { } variable "legacy_dev_s3_policy" { - type = bool - default = false + type = bool + description = "Retain the proven GitHub Elastic Beanstalk release policy until application CD is migrated separately." + default = false } variable "hosted_zone_id" {