diff --git a/infra/cdk/README.md b/infra/cdk/README.md index 364780af..ba46bf4b 100644 --- a/infra/cdk/README.md +++ b/infra/cdk/README.md @@ -1,34 +1,45 @@ # Infrastructure & CI/CD — Sea Haven SHOC frontend -AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo, -deployed through the org's **reusable** GitHub Actions workflow. +AWS hosting for the Vite SPA, defined as an **AWS CDK** app local to this repo. +Infrastructure deploys are administrator-run; GitHub Actions publishes content +only. + +> **Dev is being adopted into HCP Terraform (SH-300).** The dev stack +> `shoc-frontend-dev` is in the retain/transfer sequence described under +> [Terraform adoption mode](#terraform-adoption-mode) and in +> [`terraform/README.md`](../terraform/README.md). Do not run a plain +> `cdk deploy` against dev while that sequence is in progress. Staging is +> unaffected and stays on this CDK path (SH-287 tracks its cutover). - **Hosting:** private S3 bucket (origin) + CloudFront, served on the custom domain **`dev.seahaven.com`** (ACM `*.seahaven.com`, Route 53 apex alias). - **API:** the SPA calls the backend **directly** over HTTPS at `https://api.dev.seahaven.com/api` (`VITE_API_URL`, cross-origin; the backend allows CORS). CloudFront serves static content only — no `/api` proxy. -- Domain/cert/zone values live in `cdk.json` context so the CI `cdk deploy` - picks them up with no flags. `VITE_API_URL` is baked into the build, so it's +- Domain/cert/zone values live in `cdk.json` context so `cdk deploy` picks + them up with no flags. `VITE_API_URL` is baked into the build, so it's per-environment (see the note under "Adding staging / prod"). - **Auth:** GitHub Actions → AWS via **OIDC** (no long-lived keys) -- **CD workflow:** `.github/workflows/deploy.yml` is a thin caller of the org's - `Sea-Haven-Industries/.github` → `cd-cdk.yaml`. That workflow runs `cdk deploy` - (provisions infra) then `scripts/deploy-web.sh` (builds + uploads the SPA). +- **Content workflows:** `.github/workflows/deploy.yml` (dev, + `workflow_dispatch` only during adoption) and `deploy-staging.yml` (push to + `staging`) run `scripts/deploy-web.sh` as the environment's pinned deploy + role. Neither runs `cdk deploy`. The org reusable `cd-cdk.yaml` caller was + retired with the adoption PR. - **Infra is local to this repo** (CDK in `infra/cdk`); the deploy role is created by this stack, not added to the central `oidc-deploy-roles.yaml`. -- **Environments:** `dev` (push to `dev`, via the org reusable workflow) and - `staging` (push to `staging`, via the standalone `deploy-staging.yml`). ``` infra/cdk/ - bin/app.ts entry point (reads -c context) - lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role -scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation + bin/app.ts entry point (reads -c context) + lib/frontend-stack.ts S3 + CloudFront + OAC + OIDC deploy role + lib/retain-for-terraform-adoption.ts adoption-mode aspect (Retain + condition) + test/frontend-stack.test.mjs template assertions for both modes +scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation .github/workflows/ - ci.yaml quality gates (lint / build / test / e2e) - deploy.yml caller of the org reusable cd-cdk.yaml (push to dev) - deploy-staging.yml standalone staging deploy (push to staging) + ci.yaml quality gates (lint / build / test / governance) + terraform-isolation.yaml PRs may not mix terraform/** with app code + deploy.yml dev content publish (workflow_dispatch on dev) + deploy-staging.yml standalone staging deploy (push to staging) ``` ## What the stack creates @@ -40,12 +51,67 @@ scripts/deploy-web.sh build SPA -> s3 sync -> CloudFront invalidation | CloudFront Function (viewer request) | SPA routing: rewrites extensionless paths to `/index.html` (scoped to the S3 behavior, so it never touches `/api`) | | IAM role `githubdeploy-shoc-frontend-new-dev` | assumed by GitHub Actions via OIDC, scoped to `repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev` | -The whole `cd-cdk.yaml` job runs as that role, so it holds: `sts:AssumeRole` on -`cdk-hnb659fds-*` (for `cdk deploy`), `cloudformation:DescribeStacks` (cd-cdk's -pre-flight/health-check + output reads), read/write on the bucket (`s3 sync`), -and `cloudfront:CreateInvalidation` (cache bust). The OIDC **provider** is a -singleton account resource — the stack only _imports_ it (created in step 2), -so `cdk destroy` can't delete a resource shared by other roles. +The dev role's inline policy still carries the legacy `cd-cdk.yaml` grants: +`sts:AssumeRole` on `cdk-hnb659fds-*`, `cloudformation:DescribeStacks`, +read/write on the bucket (`s3 sync`), and `cloudfront:CreateInvalidation`. It +is left byte-identical on purpose so the Terraform import is a no-op; the +Terraform content-CD change narrows it. The OIDC **provider** is a singleton +account resource — the stack only _imports_ it (created in step 2), so +`cdk destroy` can't delete a resource shared by other roles. + +## Terraform adoption mode + +`-c retainForTerraformAdoption=true` switches the stack into the safety mode +used only while HCP Terraform adopts the dev resources. It is off by default +and ordinary synthesis is unchanged (`test/frontend-stack.test.mjs` asserts +both). In adoption mode the stack: + +- pins the origin ID CloudFormation generated for the live distribution + (`shocfrontenddevDistributionOrigin10CCD0EE1`) so the update is + metadata-only; environments without a verified value fail synthesis +- attaches the `seahaven-org-baseline` permissions boundary + `shoc-frontend-new-dev-deploy-boundary` and the + `HcpTerraformWorkspace=shoc-frontend-new-dev` tag to the deploy role +- narrows the OIDC subject condition from `StringLike` to `StringEquals` on the + same exact value +- applies `DeletionPolicy: Retain` and `UpdateReplacePolicy: Retain` to the 13 + transferred resources (bucket, bucket policy, distribution, OAC, SPA + function, A and AAAA records, deploy role, inline policy) and to + `SiteBucket/AutoDeleteObjectsCustomResource`; the auto-delete provider + Lambda and role stay unretained +- adds the required `ManageSiteInfrastructure` parameter (`true|false`, no + default) and conditions those same resources and every output on it +- emits `TerraformImport*` outputs carrying the exact import IDs + +`ManageSiteInfrastructure` has no default, so every adoption-mode deploy must +state the ownership phase: + +```bash +cd infra/cdk && npm ci + +# Phase 1, before the Terraform import: keep the resources in the stack and +# install Retain on them. Update-only change set. +npx cdk deploy shoc-frontend-dev \ + -c retainForTerraformAdoption=true \ + --parameters ManageSiteInfrastructure=true + +# Phase 2, after the controlled Terraform apply and its no-op plan: relinquish +# ownership. Expect DELETE_SKIPPED on the 13 resources and the custom resource. +npx cdk deploy shoc-frontend-dev \ + -c retainForTerraformAdoption=true \ + --parameters ManageSiteInfrastructure=false +``` + +Both deploys must use the same reviewed SHA. Review the change set before +confirming: Phase 1 must show no create, delete, or replace. After the +`false` deploy succeeds, `ManageSiteInfrastructure=true` must never be used +again. If the `true` deploy rolls back, inspect the stack resources and the +live bucket before retrying; retained resources can outlive a failed update and +must not be cleaned up automatically. Never delete the auto-delete custom +resource while its handler can still empty the versioned bucket. + +Local checks (`npm run test:infra` from the repo root) build the app, run the +template assertions, and synthesize both modes. --- @@ -102,48 +168,31 @@ cd infra/cdk npx cdk deploy ``` -Note the `DeployRoleArn` output. Then push the first content (or just push to -`dev` and let CI do everything from here on): +Note the `DeployRoleArn` output. Then publish the first content manually: ```bash # from repo root, optional manual first content publish: STACK_NAME=shoc-frontend-dev AWS_REGION=us-east-1 bash scripts/deploy-web.sh ``` -### 6. Set the one GitHub secret +### 6. Content deploys -`cd-cdk.yaml` takes the role ARN as a **secret** (not a variable): - -```bash -REPO=Sea-Haven-Industries/shoc-frontend-new -gh secret set AWS_DEPLOY_ROLE_ARN --repo "$REPO" \ - --body "arn:aws:iam:::role/githubdeploy-shoc-frontend-new-dev" -``` - -(Or **Settings → Secrets and variables → Actions → Secrets**.) - -### 7. From now on: push to `dev` - -```bash -git push origin dev -``` - -`ci.yml` runs the quality gates and `deploy.yml` calls `cd-cdk.yaml`, which runs -`cdk deploy` then `scripts/deploy-web.sh`. Watch the **Actions** tab, then open -the `SiteUrl` output. - -> First-run verification: this first push is what actually exercises the role's -> permissions and the OIDC trust through the reusable workflow (the local -> bootstrap used admin creds and tested none of that). Watch for -> credential/OIDC errors and a green post-deploy step. +The deploy role ARN is deterministic and pinned in +`.github/workflows/deploy.yml` (no `AWS_DEPLOY_ROLE_ARN` secret). During the +Terraform adoption, dev content deploys run only through **Actions → Deploy dev +content → Run workflow** on `dev`. The workflow runs `npm run verify`, assumes +`githubdeploy-shoc-frontend-new-dev`, runs `scripts/deploy-web.sh` against the +pinned bucket and distribution, uploads source maps, and verifies the served +`index.html` matches the build. Automatic push-to-`dev` releases return with the +Terraform content-CD change. --- ## Staging environment (same account, exact OIDC subject) -Staging lives in the same AWS account (396287094661) but deploys through its -own standalone workflow, `.github/workflows/deploy-staging.yml`, not the org -reusable `cd-cdk.yaml`: +Staging lives in the same AWS account (396287094661) and deploys through its +own standalone workflow, `.github/workflows/deploy-staging.yml`, on push to +`staging`: - **Trust:** with `-c githubEnvironment=staging`, the stack's deploy role (`githubdeploy-shoc-frontend-new-staging`) trusts ONLY the exact GitHub @@ -212,10 +261,11 @@ for prod. - **Teardown:** `npx cdk destroy`. The bucket uses `RemovalPolicy.DESTROY` + `autoDeleteObjects` (dev artifacts are reproducible) — change this for prod. -- **CI and CD both fire on push to `dev` and `staging`** in parallel (staging - differs only in that its CD workflow also runs `npm run verify` itself - before deploying); a red-CI commit still deploys on `dev` (matches the - org's push-time-CD model). Gating dev deploy on CI is a follow-up, not part - of enabling CICD. + Never run it against dev during or after the Terraform adoption: the + adoption-mode stack retains the transferred resources, and after Phase 2 + Terraform owns them. +- **CI and staging CD both fire on push to `staging`** in parallel; the + staging CD workflow runs `npm run verify` itself before deploying. Dev has + no push-triggered deploy during the adoption. - **npm is pinned to v11.16.0**; the committed `package-lock.json` uses lockfileVersion 3, matching the Node 24 / npm 11 CI environment. diff --git a/infra/cdk/bin/app.ts b/infra/cdk/bin/app.ts index 876463fe..afb51ff9 100644 --- a/infra/cdk/bin/app.ts +++ b/infra/cdk/bin/app.ts @@ -25,6 +25,12 @@ const certificateArn = app.node.tryGetContext("certificateArn") ?? ""; const hostedZoneId = app.node.tryGetContext("hostedZoneId") ?? ""; const hostedZoneName = app.node.tryGetContext("hostedZoneName") ?? ""; +// Terraform adoption safety mode (see infra/cdk/README.md). Adds the required +// ManageSiteInfrastructure parameter and Retain policies on the transferred +// resources. Off by default so ordinary synthesis is unchanged. +const retainForTerraformAdoption = + String(app.node.tryGetContext("retainForTerraformAdoption") ?? "false").toLowerCase() === "true"; + // Staging and beyond protect their stacks from accidental deletion; dev // stays teardown-friendly (its artifacts are reproducible). CDK applies this // at deploy time — it is not part of the synthesized template. @@ -40,6 +46,7 @@ const stack = new FrontendStack(app, `shoc-frontend-${envName}`, { certificateArn, hostedZoneId, hostedZoneName, + retainForTerraformAdoption, env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION ?? "us-east-1", diff --git a/infra/cdk/lib/frontend-stack.ts b/infra/cdk/lib/frontend-stack.ts index dda5f67e..bce19e98 100644 --- a/infra/cdk/lib/frontend-stack.ts +++ b/infra/cdk/lib/frontend-stack.ts @@ -1,4 +1,16 @@ -import { Duration, RemovalPolicy, Stack, StackProps, CfnOutput } from "aws-cdk-lib"; +import { + Aspects, + CfnCondition, + CfnOutput, + CfnParameter, + CfnResource, + Duration, + Fn, + RemovalPolicy, + Stack, + StackProps, + Tags, +} from "aws-cdk-lib"; import { Construct } from "constructs"; import * as s3 from "aws-cdk-lib/aws-s3"; import * as cloudfront from "aws-cdk-lib/aws-cloudfront"; @@ -7,6 +19,7 @@ import * as iam from "aws-cdk-lib/aws-iam"; import * as acm from "aws-cdk-lib/aws-certificatemanager"; import * as route53 from "aws-cdk-lib/aws-route53"; import * as targets from "aws-cdk-lib/aws-route53-targets"; +import { RetainForTerraformAdoption } from "./retain-for-terraform-adoption"; export interface FrontendStackProps extends StackProps { /** Environment label, e.g. "dev". Used in names/tags. */ @@ -42,6 +55,11 @@ export interface FrontendStackProps extends StackProps { readonly hostedZoneId: string; /** Name of the hosted zone above, e.g. "dev.seahaven.com". */ readonly hostedZoneName: string; + /** + * Opt-in safety mode used only during the reviewed Terraform adoption. + * Normal dev/staging synthesis remains unchanged when false. + */ + readonly retainForTerraformAdoption?: boolean; } /** @@ -50,11 +68,10 @@ export interface FrontendStackProps extends StackProps { * - CloudFront distribution (HTTPS, SPA deep-link fallback) * - a GitHub Actions OIDC deploy role * - * Content (the built `dist/`) is NOT uploaded here. The org's reusable - * `cd-cdk.yaml` workflow runs `scripts/deploy-web.sh` after `cdk deploy` to - * build the SPA, sync it to this bucket, and invalidate CloudFront — so this - * stack only owns the infrastructure, and the deploy role carries the - * permissions those post-deploy steps need. + * Content (the built `dist/`) is NOT uploaded here. Manual environment + * workflows run `scripts/deploy-web.sh` independently of infrastructure + * changes, so this stack only owns infrastructure and the deploy role carries + * content-publication permissions. */ export class FrontendStack extends Stack { constructor(scope: Construct, id: string, props: FrontendStackProps) { @@ -69,8 +86,23 @@ export class FrontendStack extends Stack { certificateArn, hostedZoneId, hostedZoneName, + retainForTerraformAdoption = false, } = props; + const manageSiteInfrastructureCondition = retainForTerraformAdoption + ? new CfnCondition(this, "ManageSiteInfrastructureCondition", { + expression: Fn.conditionEquals( + new CfnParameter(this, "ManageSiteInfrastructure", { + type: "String", + allowedValues: ["true", "false"], + description: + "Set true only before Terraform adoption. After ownership transfer, always reuse false.", + }).valueAsString, + "true", + ), + }) + : undefined; + const hasCustomDomain = domainNames.length > 0; if (hasCustomDomain && !certificateArn) { throw new Error( @@ -114,6 +146,16 @@ export class FrontendStack extends Stack { // --- CloudFront: serves the static SPA from S3 ------------------------- // The SPA calls the backend directly at its absolute HTTPS URL // (VITE_API_URL, cross-origin), so CloudFront hosts only static content. + // Adoption mode pins the origin ID CloudFormation generated for the live + // distribution so the retention deploy is a metadata-only update. Only + // environments with a read-back-verified value may enter adoption mode. + const adoptionOriginIds: Record = { + dev: "shocfrontenddevDistributionOrigin10CCD0EE1", + }; + const originId = retainForTerraformAdoption ? adoptionOriginIds[envName] : undefined; + if (retainForTerraformAdoption && !originId) { + throw new Error(`No verified Terraform adoption origin ID exists for ${envName}.`); + } const distribution = new cloudfront.Distribution(this, "Distribution", { comment: `SeaHaven SHOC frontend (${envName})`, defaultRootObject: "index.html", @@ -130,7 +172,9 @@ export class FrontendStack extends Stack { : undefined, defaultBehavior: { // withOriginAccessControl wires up OAC + the bucket policy automatically. - origin: origins.S3BucketOrigin.withOriginAccessControl(bucket), + origin: origins.S3BucketOrigin.withOriginAccessControl(bucket, { + originId, + }), viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED, allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS, @@ -158,9 +202,9 @@ export class FrontendStack extends Stack { // Trust conditions for the OIDC principal. With a GitHub environment // (staging): exact StringEquals match on both aud and the environment // subject — the staging workflow declares `environment: staging`, so only - // runs in that environment can assume the role. Without one (dev): keep - // the branch-ref trust, where StringLike scopes `sub` to pushes on the - // deploy branch (reusable-workflow runs still carry the caller-based sub). + // runs in that environment can assume the role. Normal dev synthesis keeps + // the current branch-ref StringLike trust. The adoption prerequisite + // narrows that already-exact value to StringEquals before Terraform import. const oidcConditions = githubEnvironment ? { StringEquals: { @@ -168,30 +212,48 @@ export class FrontendStack extends Stack { "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:environment:${githubEnvironment}`, }, } - : { - StringEquals: { - "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", - }, - StringLike: { - // Tightly scoped: only pushes to this repo's deploy branch. For a - // reusable-workflow run the OIDC `sub` is still caller-based, so this - // matches even though the deploy job lives in the `.github` repo. - "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`, - }, - }; + : retainForTerraformAdoption + ? { + StringEquals: { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", + "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`, + }, + } + : { + StringEquals: { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", + }, + StringLike: { + // Tightly scoped: only pushes to this repo's deploy branch. For a + // reusable-workflow run the OIDC `sub` is still caller-based, so this + // matches even though the deploy job lives in the `.github` repo. + "token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`, + }, + }; + + const deployPermissionsBoundary = retainForTerraformAdoption + ? iam.ManagedPolicy.fromManagedPolicyArn( + this, + "GithubDeployPermissionsBoundary", + `arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`, + ) + : undefined; const deployRole = new iam.Role(this, "GithubDeployRole", { roleName: `githubdeploy-shoc-frontend-new-${envName}`, description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`, maxSessionDuration: Duration.hours(1), assumedBy: new iam.OpenIdConnectPrincipal(provider, oidcConditions), + permissionsBoundary: deployPermissionsBoundary, }); + if (retainForTerraformAdoption) { + Tags.of(deployRole).add("HcpTerraformWorkspace", `shoc-frontend-new-${envName}`); + } - // Dev's reusable CDK workflow needs the shared bootstrap roles. Staging is - // intentionally narrower: its recurring promotion workflow only publishes - // application assets to this stack's bucket/distribution. Infrastructure - // changes remain an administrator-run CDK operation, so the staging OIDC - // role cannot inherit the bootstrap roles' account-wide deployment power. + // Preserve dev's legacy CDK capability until the reviewed adoption update + // replaces this inline policy. Staging is intentionally narrower: its + // content role only publishes application assets to this stack's + // bucket/distribution. Infrastructure changes remain administrator-run. if (!githubEnvironment) { deployRole.addToPolicy( new iam.PolicyStatement({ @@ -224,6 +286,8 @@ export class FrontendStack extends Stack { // --- DNS: point the custom domain at CloudFront ------------------------ // Only when a hosted zone is supplied (it must be in THIS account). Creates // A + AAAA aliases; for the zone apex, recordName is the zone itself. + let aliasA: route53.ARecord | undefined; + let aliasAaaa: route53.AaaaRecord | undefined; if (hostedZoneId && hasCustomDomain) { const zone = route53.HostedZone.fromHostedZoneAttributes(this, "Zone", { hostedZoneId, @@ -233,31 +297,169 @@ export class FrontendStack extends Stack { // apex record when the domain equals the zone name. const recordName = domainNames[0] === hostedZoneName ? undefined : domainNames[0]; - new route53.ARecord(this, "AliasA", { zone, recordName, target }); - new route53.AaaaRecord(this, "AliasAAAA", { zone, recordName, target }); + aliasA = new route53.ARecord(this, "AliasA", { zone, recordName, target }); + aliasAaaa = new route53.AaaaRecord(this, "AliasAAAA", { + zone, + recordName, + target, + }); } + const gateOutput = (output: CfnOutput): CfnOutput => { + if (manageSiteInfrastructureCondition) { + output.condition = manageSiteInfrastructureCondition; + } + return output; + }; + // --- Outputs ----------------------------------------------------------- // scripts/deploy-web.sh reads BucketName + DistributionId from these. - new CfnOutput(this, "SiteUrl", { - value: hasCustomDomain - ? `https://${domainNames[0]}` - : `https://${distribution.distributionDomainName}`, - description: "Public URL of the deployed SPA", - }); - new CfnOutput(this, "DistributionDomainName", { - value: distribution.distributionDomainName, - description: "CloudFront domain — point the custom-domain DNS record here", - }); - new CfnOutput(this, "BucketName", { - value: bucket.bucketName, - }); - new CfnOutput(this, "DistributionId", { - value: distribution.distributionId, - }); - new CfnOutput(this, "DeployRoleArn", { - value: deployRole.roleArn, - description: "-> GitHub repo secret AWS_DEPLOY_ROLE_ARN", - }); + gateOutput( + new CfnOutput(this, "SiteUrl", { + value: hasCustomDomain + ? `https://${domainNames[0]}` + : `https://${distribution.distributionDomainName}`, + description: "Public URL of the deployed SPA", + }), + ); + gateOutput( + new CfnOutput(this, "DistributionDomainName", { + value: distribution.distributionDomainName, + description: "CloudFront domain — point the custom-domain DNS record here", + }), + ); + gateOutput( + new CfnOutput(this, "BucketName", { + value: bucket.bucketName, + }), + ); + gateOutput( + new CfnOutput(this, "DistributionId", { + value: distribution.distributionId, + }), + ); + gateOutput( + new CfnOutput(this, "DeployRoleArn", { + value: deployRole.roleArn, + description: "Pinned GitHub OIDC content-deployment role", + }), + ); + + if (retainForTerraformAdoption) { + const originAccessControl = distribution.node + .findAll() + .find( + (node): node is cloudfront.CfnOriginAccessControl => + node instanceof cloudfront.CfnOriginAccessControl, + ); + if (!originAccessControl || !aliasA || !aliasAaaa) { + throw new Error("Terraform adoption outputs require an OAC and managed A/AAAA records."); + } + const originAccessControlConfig = + originAccessControl.originAccessControlConfig as cloudfront.CfnOriginAccessControl.OriginAccessControlConfigProperty; + + const rolePolicy = deployRole.node + .findAll() + .find((node): node is iam.Policy => node instanceof iam.Policy); + const autoDeleteProviderRole = this.node + .findAll() + .find( + (node): node is CfnResource => + node instanceof CfnResource && + node.cfnResourceType === "AWS::IAM::Role" && + node.node.path.endsWith("/Custom::S3AutoDeleteObjectsCustomResourceProvider/Role"), + ); + if (!rolePolicy || !autoDeleteProviderRole) { + throw new Error("Terraform adoption outputs require deploy and auto-delete roles."); + } + + const recordName = domainNames[0]; + gateOutput( + new CfnOutput(this, "TerraformWorkspaceTag", { + value: `shoc-frontend-new-${envName}`, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformDeployBoundaryArn", { + value: `arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`, + }), + ); + gateOutput(new CfnOutput(this, "TerraformImportBucket", { value: bucket.bucketName })); + gateOutput( + new CfnOutput(this, "TerraformImportBucketPolicy", { + value: bucket.bucketName, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformImportDistribution", { + value: distribution.distributionId, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformImportOriginAccessControl", { + value: originAccessControl.attrId, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformOriginAccessControlName", { + value: originAccessControlConfig.name, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformOriginAccessControlDescription", { + value: "EMPTY_STRING", + description: "Use an empty Terraform string because the generated OAC has no description", + }), + ); + gateOutput( + new CfnOutput(this, "TerraformDistributionOriginId", { + value: originId!, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformImportSpaRewriteFunction", { + value: spaRewrite.functionName, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformImportAliasA", { + value: `${hostedZoneId}_${recordName}_A`, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformImportAliasAAAA", { + value: `${hostedZoneId}_${recordName}_AAAA`, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformImportDeployRole", { + value: deployRole.roleName, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformImportDeployRolePolicy", { + value: `${deployRole.roleName}:${rolePolicy.policyName}`, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformDeployInlinePolicyName", { + value: rolePolicy.policyName, + }), + ); + gateOutput( + new CfnOutput(this, "TerraformBucketAutoDeleteHelperRoleArn", { + value: autoDeleteProviderRole.getAtt("Arn").toString(), + }), + ); + gateOutput( + new CfnOutput(this, "TerraformRetainedAutoDeleteCustomResource", { + value: "SiteBucket/AutoDeleteObjectsCustomResource", + description: + "CloudFormation custom resource retained to prevent bucket emptying during detachment", + }), + ); + + Aspects.of(this).add(new RetainForTerraformAdoption(manageSiteInfrastructureCondition)); + } } } diff --git a/infra/cdk/lib/retain-for-terraform-adoption.ts b/infra/cdk/lib/retain-for-terraform-adoption.ts new file mode 100644 index 00000000..c02aa3e9 --- /dev/null +++ b/infra/cdk/lib/retain-for-terraform-adoption.ts @@ -0,0 +1,63 @@ +import { CfnCondition, CfnDeletionPolicy, CfnResource, IAspect } from "aws-cdk-lib"; +import { IConstruct } from "constructs"; + +const TRANSFERRED_RESOURCE_TYPES = new Set([ + "AWS::S3::Bucket", + "AWS::S3::BucketPolicy", + "AWS::CloudFront::Distribution", + "AWS::CloudFront::Function", + "AWS::CloudFront::OriginAccessControl", + "AWS::Route53::RecordSet", +]); + +function isTransferredResource(resource: CfnResource): boolean { + if (TRANSFERRED_RESOURCE_TYPES.has(resource.cfnResourceType)) { + return true; + } + + if ( + resource.cfnResourceType === "Custom::S3AutoDeleteObjects" && + resource.node.path.includes("/SiteBucket/AutoDeleteObjectsCustomResource") + ) { + return true; + } + + return ( + (resource.cfnResourceType === "AWS::IAM::Role" || + resource.cfnResourceType === "AWS::IAM::Policy") && + resource.node.path.includes("/GithubDeployRole") + ); +} + +/** + * Retains only the resources in the approved Terraform transfer set. + * + * The bucket auto-delete custom resource is intentionally retained while the + * generated provider Lambda, role, log group, and CDK metadata remain excluded. + * When a management condition is supplied, those same resources share it so + * CloudFormation can later relinquish them without deleting them. + */ +export class RetainForTerraformAdoption implements IAspect { + constructor(private readonly manageCondition?: CfnCondition) {} + + public visit(node: IConstruct): void { + if (!(node instanceof CfnResource) || !isTransferredResource(node)) { + return; + } + + // Keep the L2 bucket's configured DESTROY policy visible to its + // AutoDeleteObjects validator while overriding the emitted CloudFormation + // resource. This preserves the custom resource and retains both together. + if (node.cfnResourceType === "AWS::S3::Bucket") { + node.addOverride("DeletionPolicy", "Retain"); + node.addOverride("UpdateReplacePolicy", "Retain"); + } else { + node.cfnOptions.deletionPolicy = CfnDeletionPolicy.RETAIN; + node.cfnOptions.updateReplacePolicy = CfnDeletionPolicy.RETAIN; + } + + if (this.manageCondition) { + node.cfnOptions.condition = this.manageCondition; + } + } +} diff --git a/infra/cdk/package.json b/infra/cdk/package.json index b490e706..4529259f 100644 --- a/infra/cdk/package.json +++ b/infra/cdk/package.json @@ -11,7 +11,9 @@ }, "scripts": { "build": "tsc", + "test": "npm run build && node --test test/*.test.mjs", "synth": "cdk synth", + "synth:adoption": "cdk synth -c retainForTerraformAdoption=true --parameters ManageSiteInfrastructure=true", "diff": "cdk diff", "deploy": "cdk deploy" }, diff --git a/infra/cdk/test/frontend-stack.test.mjs b/infra/cdk/test/frontend-stack.test.mjs new file mode 100644 index 00000000..f285557d --- /dev/null +++ b/infra/cdk/test/frontend-stack.test.mjs @@ -0,0 +1,225 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { test } from "node:test"; + +const require = createRequire(import.meta.url); +const { App } = require("aws-cdk-lib"); +const { Template } = require("aws-cdk-lib/assertions"); +const { FrontendStack } = require("../lib/frontend-stack.js"); + +const account = "396287094661"; +const region = "us-east-1"; +const DEV_ROLE = "githubdeploy-shoc-frontend-new-dev"; +const DEV_ORIGIN_ID = "shocfrontenddevDistributionOrigin10CCD0EE1"; +const CONDITION = "ManageSiteInfrastructureCondition"; + +const RETAINED_TYPES = new Set([ + "AWS::S3::Bucket", + "AWS::S3::BucketPolicy", + "AWS::CloudFront::Distribution", + "AWS::CloudFront::Function", + "AWS::CloudFront::OriginAccessControl", + "AWS::Route53::RecordSet", + "Custom::S3AutoDeleteObjects", +]); + +function devTemplate(retainForTerraformAdoption, overrides = {}) { + const app = new App(); + const stack = new FrontendStack(app, "shoc-frontend-dev", { + envName: "dev", + githubRepo: "Sea-Haven-Industries/shoc-frontend-new", + deployBranch: "dev", + domainNames: ["dev.seahaven.com"], + certificateArn: `arn:aws:acm:${region}:${account}:certificate/2b78e74f-7b65-4b82-a413-7a498b102f00`, + hostedZoneId: "Z07671212N75U4YLPWZR8", + hostedZoneName: "dev.seahaven.com", + retainForTerraformAdoption, + env: { account, region }, + ...overrides, + }); + return Template.fromStack(stack).toJSON(); +} + +function entriesByType(template, type) { + return Object.entries(template.Resources).filter(([, resource]) => resource.Type === type); +} + +function isTransferred(logicalId, resource) { + const isDeployRoleResource = + (resource.Type === "AWS::IAM::Role" && resource.Properties.RoleName === DEV_ROLE) || + (resource.Type === "AWS::IAM::Policy" && logicalId.startsWith("GithubDeployRole")); + return RETAINED_TYPES.has(resource.Type) || isDeployRoleResource; +} + +test("adoption mode emits the 13 transferred resources plus the auto-delete custom resource", () => { + const template = devTemplate(true); + assert.equal(entriesByType(template, "AWS::S3::Bucket").length, 1); + assert.equal(entriesByType(template, "AWS::S3::BucketPolicy").length, 1); + assert.equal(entriesByType(template, "AWS::CloudFront::Distribution").length, 1); + assert.equal(entriesByType(template, "AWS::CloudFront::OriginAccessControl").length, 1); + assert.equal(entriesByType(template, "AWS::CloudFront::Function").length, 1); + assert.equal(entriesByType(template, "AWS::Route53::RecordSet").length, 2); + assert.equal(entriesByType(template, "Custom::S3AutoDeleteObjects").length, 1); + const transferred = Object.entries(template.Resources).filter(([id, resource]) => + isTransferred(id, resource), + ); + // Bucket, bucket policy, distribution, OAC, function, A, AAAA, role, inline + // policy = 9 CloudFormation resources (Terraform splits the bucket into 6 + // addresses) plus the retained custom resource. + assert.equal(transferred.length, 10); +}); + +test("adoption mode preserves the live dev identifiers", () => { + const template = devTemplate(true); + const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1]; + assert.equal(bucket.Properties.BucketName, "seahaven-shoc-frontend-dev"); + assert.equal(bucket.Properties.VersioningConfiguration.Status, "Enabled"); + assert.ok( + bucket.Properties.Tags.some( + (tag) => tag.Key === "aws-cdk:auto-delete-objects" && tag.Value === "true", + ), + ); + + const distribution = entriesByType(template, "AWS::CloudFront::Distribution")[0][1]; + assert.equal(distribution.Properties.DistributionConfig.Origins[0].Id, DEV_ORIGIN_ID); + assert.equal( + distribution.Properties.DistributionConfig.DefaultCacheBehavior.TargetOriginId, + DEV_ORIGIN_ID, + ); + + const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find( + ([, resource]) => resource.Properties.RoleName === DEV_ROLE, + ); + assert.equal( + deployRole.Properties.PermissionsBoundary, + `arn:aws:iam::${account}:policy/shoc-frontend-new-dev-deploy-boundary`, + ); + assert.ok( + deployRole.Properties.Tags.some( + (tag) => tag.Key === "HcpTerraformWorkspace" && tag.Value === "shoc-frontend-new-dev", + ), + ); + const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition; + assert.equal( + condition.StringEquals["token.actions.githubusercontent.com:sub"], + "repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev", + ); + assert.equal(condition.StringLike, undefined); + + // Legacy inline policy stays byte-compatible with the live document. + const [, inlinePolicy] = entriesByType(template, "AWS::IAM::Policy").find(([id]) => + id.startsWith("GithubDeployRole"), + ); + const sids = inlinePolicy.Properties.PolicyDocument.Statement.map((s) => s.Sid); + assert.deepEqual(sids, [ + "AssumeCdkBootstrapRoles", + "DescribeStack", + undefined, + "InvalidateDistribution", + ]); + + for (const output of [ + "TerraformWorkspaceTag", + "TerraformDeployBoundaryArn", + "TerraformImportBucket", + "TerraformImportBucketPolicy", + "TerraformImportDistribution", + "TerraformImportOriginAccessControl", + "TerraformOriginAccessControlName", + "TerraformOriginAccessControlDescription", + "TerraformDistributionOriginId", + "TerraformImportSpaRewriteFunction", + "TerraformImportAliasA", + "TerraformImportAliasAAAA", + "TerraformImportDeployRole", + "TerraformImportDeployRolePolicy", + "TerraformDeployInlinePolicyName", + "TerraformBucketAutoDeleteHelperRoleArn", + "TerraformRetainedAutoDeleteCustomResource", + ]) { + assert.ok(template.Outputs[output], `missing output ${output}`); + } + assert.equal( + template.Outputs.TerraformImportAliasA.Value, + "Z07671212N75U4YLPWZR8_dev.seahaven.com_A", + ); + assert.equal(template.Outputs.TerraformDistributionOriginId.Value, DEV_ORIGIN_ID); +}); + +test("adoption mode retains exactly the transferred resources", () => { + const template = devTemplate(true); + for (const [logicalId, resource] of Object.entries(template.Resources)) { + if (isTransferred(logicalId, resource)) { + assert.equal(resource.DeletionPolicy, "Retain", logicalId); + assert.equal(resource.UpdateReplacePolicy, "Retain", logicalId); + } else { + assert.notEqual(resource.DeletionPolicy, "Retain", logicalId); + assert.notEqual(resource.UpdateReplacePolicy, "Retain", logicalId); + } + } + // The auto-delete provider Lambda, role, and log group stay unretained. + for (const type of ["AWS::Lambda::Function", "AWS::Logs::LogGroup"]) { + for (const [, resource] of entriesByType(template, type)) { + assert.notEqual(resource.DeletionPolicy, "Retain"); + } + } + const providerRoles = entriesByType(template, "AWS::IAM::Role").filter( + ([, resource]) => resource.Properties.RoleName !== DEV_ROLE, + ); + assert.equal(providerRoles.length, 1); + assert.notEqual(providerRoles[0][1].DeletionPolicy, "Retain"); +}); + +test("adoption mode requires ManageSiteInfrastructure and gates transferred resources and outputs", () => { + const template = devTemplate(true); + const parameter = template.Parameters.ManageSiteInfrastructure; + assert.ok(parameter); + assert.equal(parameter.Type, "String"); + assert.deepEqual(parameter.AllowedValues, ["true", "false"]); + assert.equal(parameter.Default, undefined); + assert.ok(template.Conditions[CONDITION]); + + for (const [logicalId, resource] of Object.entries(template.Resources)) { + if (isTransferred(logicalId, resource)) { + assert.equal(resource.Condition, CONDITION, logicalId); + } else { + assert.notEqual(resource.Condition, CONDITION, logicalId); + } + } + for (const [outputName, output] of Object.entries(template.Outputs)) { + assert.equal(output.Condition, CONDITION, outputName); + } +}); + +test("normal mode is unchanged: destructive cleanup, StringLike trust, no boundary, tag, or parameter", () => { + const template = devTemplate(false); + const bucket = entriesByType(template, "AWS::S3::Bucket")[0][1]; + assert.equal(bucket.DeletionPolicy, "Delete"); + assert.equal(bucket.UpdateReplacePolicy, "Delete"); + const customResource = entriesByType(template, "Custom::S3AutoDeleteObjects")[0][1]; + assert.notEqual(customResource.DeletionPolicy, "Retain"); + + const [, deployRole] = entriesByType(template, "AWS::IAM::Role").find( + ([, resource]) => resource.Properties.RoleName === DEV_ROLE, + ); + assert.equal(deployRole.Properties.PermissionsBoundary, undefined); + assert.ok(!deployRole.Properties.Tags?.some((tag) => tag.Key === "HcpTerraformWorkspace")); + const condition = deployRole.Properties.AssumeRolePolicyDocument.Statement[0].Condition; + assert.equal( + condition.StringLike["token.actions.githubusercontent.com:sub"], + "repo:Sea-Haven-Industries/shoc-frontend-new:ref:refs/heads/dev", + ); + assert.equal(template.Outputs.TerraformWorkspaceTag, undefined); + assert.equal(template.Parameters?.ManageSiteInfrastructure, undefined); + assert.equal(template.Conditions?.[CONDITION], undefined); + for (const resource of Object.values(template.Resources)) { + assert.equal(resource.Condition, undefined); + } +}); + +test("adoption mode refuses an environment without a verified origin ID", () => { + assert.throws( + () => devTemplate(true, { envName: "staging" }), + /No verified Terraform adoption origin ID exists for staging/, + ); +});