shoc-frontend-new/infra/cdk/lib/frontend-stack.ts
Adam Moussa 82361e14b5
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.
2026-09-10 19:15:11 -04:00

465 lines
18 KiB
TypeScript

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";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
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. */
readonly envName: string;
/** GitHub repo in owner/name form, for OIDC trust scoping. */
readonly githubRepo: string;
/** Git branch whose pushes may deploy (OIDC sub is scoped to this ref). */
readonly deployBranch: string;
/**
* GitHub Actions environment name (e.g. "staging"). When set, the OIDC
* trust uses the EXACT environment subject
* `repo:<owner/name>:environment:<env>` (StringEquals) instead of the
* deploy-branch ref match below. Unset = dev-style branch-ref trust.
*/
readonly githubEnvironment?: string;
/**
* Custom domain(s) for the distribution, e.g. ["dev.seahaven.com"].
* Empty = serve on the default *.cloudfront.net domain.
*/
readonly domainNames: string[];
/**
* ARN of an ACM certificate (us-east-1, SAME account as this stack) covering
* `domainNames`. Required when `domainNames` is non-empty. CloudFront cannot
* use a certificate from another account, so for Option B the cert must live
* in whichever account this stack deploys to.
*/
readonly certificateArn: string;
/**
* Route 53 hosted zone (in THIS account) to create the custom-domain alias
* record in. Empty = don't manage DNS (add the record manually). When set,
* hostedZoneName must also be provided.
*/
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;
}
/**
* Static SPA hosting for the Sea Haven SHOC frontend:
* - private S3 bucket (no public access; CloudFront reads it via OAC)
* - CloudFront distribution (HTTPS, SPA deep-link fallback)
* - a GitHub Actions OIDC deploy role
*
* 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) {
super(scope, id, props);
const {
envName,
githubRepo,
deployBranch,
githubEnvironment = "",
domainNames,
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(
"certificateArn is required when domainNames is set (ACM cert must be in us-east-1, same account).",
);
}
// --- Origin bucket: private, encrypted, no public access ----------------
const bucket = new s3.Bucket(this, "SiteBucket", {
bucketName: `seahaven-shoc-frontend-${envName}`,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_ENFORCED,
encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true,
versioned: true,
// dev artifacts are reproducible from the build — safe to tear down.
removalPolicy: RemovalPolicy.DESTROY,
autoDeleteObjects: true,
});
// SPA client-side routing: rewrite extensionless paths (e.g. /work-orders)
// to /index.html so deep links resolve. Done with a CloudFront Function
// rather than customErrorResponses so real asset 404s stay 404s.
const spaRewrite = new cloudfront.Function(this, "SpaRewrite", {
comment: "SPA routing: rewrite extensionless paths to /index.html",
code: cloudfront.FunctionCode.fromInline(
[
"function handler(event) {",
" var request = event.request;",
" var uri = request.uri;",
" // No file extension after the last slash -> a client-side route.",
" if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {",
" request.uri = '/index.html';",
" }",
" return request;",
"}",
].join("\n"),
),
});
// --- 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",
priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
// Option B: serve on the custom domain(s) with the ACM cert. When unset,
// CloudFront uses its default *.cloudfront.net domain + certificate.
domainNames: hasCustomDomain ? domainNames : undefined,
certificate: hasCustomDomain
? acm.Certificate.fromCertificateArn(this, "Certificate", certificateArn)
: undefined,
minimumProtocolVersion: hasCustomDomain
? cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021
: undefined,
defaultBehavior: {
// withOriginAccessControl wires up OAC + the bucket policy automatically.
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket, {
originId,
}),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
compress: true,
functionAssociations: [
{
function: spaRewrite,
eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
},
],
},
});
// --- GitHub Actions OIDC deploy role -----------------------------------
// The OIDC provider is a singleton account-global resource, created once
// out-of-band (see README step 2) — we only IMPORT it here so this stack's
// lifecycle (including `cdk destroy`) never deletes a resource shared by
// every role in the account.
const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
this,
"GitHubOidcProvider",
`arn:aws:iam::${this.account}:oidc-provider/token.actions.githubusercontent.com`,
);
// 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. 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: {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:environment:${githubEnvironment}`,
},
}
: 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}`);
}
// 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({
sid: "AssumeCdkBootstrapRoles",
actions: ["sts:AssumeRole"],
resources: [`arn:aws:iam::${this.account}:role/cdk-hnb659fds-*`],
}),
);
}
deployRole.addToPolicy(
new iam.PolicyStatement({
sid: "DescribeStack",
actions: ["cloudformation:DescribeStacks"],
resources: [
`arn:aws:cloudformation:${this.region}:${this.account}:stack/${this.stackName}/*`,
],
}),
);
bucket.grantReadWrite(deployRole);
deployRole.addToPolicy(
new iam.PolicyStatement({
sid: "InvalidateDistribution",
actions: ["cloudfront:CreateInvalidation", "cloudfront:GetInvalidation"],
resources: [
`arn:aws:cloudfront::${this.account}:distribution/${distribution.distributionId}`,
],
}),
);
// --- 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,
zoneName: hostedZoneName,
});
const target = route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution));
// apex record when the domain equals the zone name.
const recordName = domainNames[0] === hostedZoneName ? undefined : domainNames[0];
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.
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));
}
}
}