feat(cdk): add Terraform adoption retain mode

retainForTerraformAdoption=true adds the required ManageSiteInfrastructure
parameter, conditions the 13 transferred resources and the S3 auto-delete
custom resource on it, applies Retain policies, pins the live dev origin
ID, attaches the deploy boundary and HcpTerraformWorkspace tag, and
narrows the OIDC subject to StringEquals. Normal synthesis is unchanged;
template tests cover both modes.
This commit is contained in:
Adam Moussa 2026-09-10 19:15:11 -04:00
parent 78398482cf
commit 82361e14b5
No known key found for this signature in database
6 changed files with 653 additions and 104 deletions

View file

@ -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::<acct>: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.

View file

@ -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",

View file

@ -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<string, string> = {
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));
}
}
}

View file

@ -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;
}
}
}

View file

@ -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"
},

View file

@ -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/,
);
});