shoc-frontend-new/infra/cdk/lib/frontend-stack.ts

239 lines
10 KiB
TypeScript
Raw Normal View History

import { Duration, RemovalPolicy, Stack, StackProps, CfnOutput } 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";
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;
/**
* 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;
}
/**
* 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. 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.
*/
export class FrontendStack extends Stack {
constructor(scope: Construct, id: string, props: FrontendStackProps) {
super(scope, id, props);
const {
envName,
githubRepo,
deployBranch,
domainNames,
certificateArn,
hostedZoneId,
hostedZoneName,
} = props;
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.
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),
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`,
);
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, {
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}`,
},
}),
});
// The whole `cd-cdk.yaml` job runs as this role. Permissions it needs:
// 1. assume the CDK bootstrap roles -> `cdk deploy`
// 2. describe the stack -> cd-cdk pre-flight / health-check / output reads
// 3. read/write the bucket -> post-deploy `aws s3 sync`
// 4. invalidate the distribution -> post-deploy cache bust
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.
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];
new route53.ARecord(this, "AliasA", { zone, recordName, target });
new route53.AaaaRecord(this, "AliasAAAA", { zone, recordName, target });
}
// --- 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",
});
}
}