mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 23:13:12 +00:00
398 lines
16 KiB
TypeScript
398 lines
16 KiB
TypeScript
import {
|
|
Aspects,
|
|
CfnOutput,
|
|
CfnResource,
|
|
Duration,
|
|
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 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 adoptionOriginIds: Record<string, string> = {
|
|
dev: "shocfrontenddevDistributionOrigin10CCD0EE1",
|
|
staging: "shocfrontendstagingDistributionOrigin16E4628FC",
|
|
"tf-poc": "shoc-frontend-tf-poc-origin",
|
|
};
|
|
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,
|
|
});
|
|
}
|
|
|
|
// --- 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: "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];
|
|
new CfnOutput(this, "TerraformWorkspaceTag", {
|
|
value: `shoc-frontend-new-${envName}`,
|
|
});
|
|
new CfnOutput(this, "TerraformDeployBoundaryArn", {
|
|
value: `arn:aws:iam::${this.account}:policy/shoc-frontend-new-${envName}-deploy-boundary`,
|
|
});
|
|
new CfnOutput(this, "TerraformImportBucket", { value: bucket.bucketName });
|
|
new CfnOutput(this, "TerraformImportBucketPolicy", {
|
|
value: bucket.bucketName,
|
|
});
|
|
new CfnOutput(this, "TerraformImportDistribution", {
|
|
value: distribution.distributionId,
|
|
});
|
|
new CfnOutput(this, "TerraformImportOriginAccessControl", {
|
|
value: originAccessControl.attrId,
|
|
});
|
|
new CfnOutput(this, "TerraformOriginAccessControlName", {
|
|
value: originAccessControlConfig.name,
|
|
});
|
|
new CfnOutput(this, "TerraformOriginAccessControlDescription", {
|
|
value: "EMPTY_STRING",
|
|
description: "Use an empty Terraform string because the generated OAC has no description",
|
|
});
|
|
new CfnOutput(this, "TerraformDistributionOriginId", {
|
|
value: originId!,
|
|
});
|
|
new CfnOutput(this, "TerraformImportSpaRewriteFunction", {
|
|
value: spaRewrite.functionName,
|
|
});
|
|
new CfnOutput(this, "TerraformImportAliasA", {
|
|
value: `${hostedZoneId}_${recordName}_A`,
|
|
});
|
|
new CfnOutput(this, "TerraformImportAliasAAAA", {
|
|
value: `${hostedZoneId}_${recordName}_AAAA`,
|
|
});
|
|
new CfnOutput(this, "TerraformImportDeployRole", {
|
|
value: deployRole.roleName,
|
|
});
|
|
new CfnOutput(this, "TerraformImportDeployRolePolicy", {
|
|
value: `${deployRole.roleName}:${rolePolicy.policyName}`,
|
|
});
|
|
new CfnOutput(this, "TerraformDeployInlinePolicyName", {
|
|
value: rolePolicy.policyName,
|
|
});
|
|
new CfnOutput(this, "TerraformBucketAutoDeleteHelperRoleArn", {
|
|
value: autoDeleteProviderRole.getAtt("Arn").toString(),
|
|
});
|
|
new CfnOutput(this, "TerraformRetainedAutoDeleteCustomResource", {
|
|
value: "SiteBucket/AutoDeleteObjectsCustomResource",
|
|
description:
|
|
"CloudFormation custom resource retained to prevent bucket emptying during detachment",
|
|
});
|
|
|
|
Aspects.of(this).add(new RetainForTerraformAdoption());
|
|
}
|
|
}
|
|
}
|