shoc-frontend-new/infra/cdk/lib/frontend-stack.ts
Alexandre Brandizzi 6fa2fea602 ci(deploy): add AWS S3 + CloudFront CD pipeline
Provision the frontend's AWS hosting as a CDK app local to this repo
(private S3 + OAC, CloudFront with SPA 403/404 -> index.html fallback) and a
GitHub OIDC deploy role scoped to the dev branch. Deploy on push to dev via
the org reusable cd-cdk.yaml: cdk deploy provisions infra, then
scripts/deploy-web.sh builds the SPA, syncs to S3 (immutable hashed assets,
no-cache index.html) and invalidates CloudFront.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:39:53 -03:00

158 lines
6.5 KiB
TypeScript

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";
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;
}
/**
* Static SPA hosting for the SeaHaven 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 } = props;
// --- 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,
});
// --- CloudFront: OAC origin, HTTPS, SPA fallback -----------------------
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,
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,
},
// With a private bucket + OAC, a missing key returns 403 (not 404), so the
// 403 -> index.html mapping is what makes react-router deep links work on
// hard refresh. 404 is mapped too for completeness.
errorResponses: [
{
httpStatus: 403,
responseHttpStatus: 200,
responsePagePath: "/index.html",
ttl: Duration.seconds(0),
},
{
httpStatus: 404,
responseHttpStatus: 200,
responsePagePath: "/index.html",
ttl: Duration.seconds(0),
},
],
});
// --- 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}`,
],
}),
);
// --- Outputs -----------------------------------------------------------
// scripts/deploy-web.sh reads BucketName + DistributionId from these.
new CfnOutput(this, "SiteUrl", {
value: `https://${distribution.distributionDomainName}`,
description: "Public URL of the deployed SPA",
});
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",
});
}
}