From 8beb5a4a17467529e370ac37dbf3dc7a4f0afb95 Mon Sep 17 00:00:00 2001 From: senti Date: Sat, 15 Jul 2023 22:40:20 -0700 Subject: [PATCH] feat: let's try static site! --- ant.config.ts | 153 ++++++++++++++++-- .../ant-stack/src/cdk/constructs/api/api.ts | 12 +- .../src/cdk/constructs/api/apiRoute.ts | 32 ++-- .../cdk/constructs/staticSite/staticSite.ts | 34 ++-- .../ant-stack/src/cli/commands/build/api.ts | 5 +- .../ant-stack/src/cli/commands/destroy.ts | 3 +- packages/ant-stack/src/cli/index.ts | 7 +- 7 files changed, 192 insertions(+), 54 deletions(-) diff --git a/ant.config.ts b/ant.config.ts index cc4d864e..d9058bb7 100644 --- a/ant.config.ts +++ b/ant.config.ts @@ -6,12 +6,21 @@ * import * as cdk from 'aws-cdk-lib' * ``` */ + +import path from "node:path"; import { consola } from "consola"; import * as core from "@actions/core"; import * as github from "@actions/github"; -import { App, Stack } from "aws-cdk-lib/core"; +import * as aws_core from "aws-cdk-lib/core"; +import * as aws_certificatemanager from "aws-cdk-lib/aws-certificatemanager"; +import * as aws_cloudfront from "aws-cdk-lib/aws-cloudfront"; +import * as aws_cloudfront_origins from "aws-cdk-lib/aws-cloudfront-origins"; +import * as aws_route53 from "aws-cdk-lib/aws-route53"; +import * as aws_route53_targets from "aws-cdk-lib/aws-route53-targets"; import { Api } from "ant-stack/constructs/api"; import { GitHub } from "ant-stack/constructs/github"; +import { StaticSite } from "ant-stack/constructs/staticSite"; +import { getWorkspaceRoot } from "ant-stack/utils"; /** * @see https://github.com/evanw/esbuild/issues/1921#issuecomment-1491470829 @@ -26,10 +35,10 @@ const __filename = topLevelUrl.fileURLToPath(import.meta.url); const __dirname = topLevelPath.dirname(__filename); `; -export class MyStack extends Stack { +export class ApiStack extends aws_core.Stack { api: Api; - constructor(scope: App, id: string) { + constructor(scope: aws_core.App, id: string) { super(scope, id); this.api = new Api(this, "Api", { @@ -53,18 +62,122 @@ export class MyStack extends Stack { } } +export class DocsStack extends aws_core.Stack { + staticSite: StaticSite; + + cloudFrontTarget: aws_route53_targets.CloudFrontTarget; + + aRecord: aws_route53.ARecord; + + zone: aws_route53.IHostedZone; + + constructor(scope: aws_core.App, id: string) { + if (!process.env.CERTIFICATE_ARN) throw new Error("Certificate ARN not provided. Stop."); + if (!process.env.DATABASE_URL) throw new Error("Database URL not provided. Stop."); + if (!process.env.HOSTED_ZONE_ID) throw new Error("Hosted Zone ID not provided. Stop."); + + let stage = "prod"; + + switch (process.env.NODE_ENV) { + case "production": + stage = "prod"; + break; + + case "staging": + if (!process.env.PR_NUM) + throw new Error("Running in staging environment but no PR number specified. Stop."); + stage = `staging-${process.env.PR_NUM}`; + break; + + case "development": + throw new Error("Cannot deploy stack in development environment. Stop."); + + default: + throw new Error("Invalid environment specified. Stop."); + } + + super(scope, id, { + env: { + region: "us-east-1", + }, + terminationProtection: /*stage === "prod"*/ false, + }); + + const certificateArn = process.env.CERTIFICATE_ARN; + const hostedZoneId = process.env.HOSTED_ZONE_ID ?? "hi"; + const recordName = `${stage === "prod" ? "" : `${stage}-`}docs.api-next`; + const zoneName = "peterportal.org"; + + const workspaceRoot = getWorkspaceRoot(process.cwd()); + + this.staticSite = new StaticSite(this, "Docs", { + assets: path.join(workspaceRoot, "apps", "docs", "build"), + constructs: { + bucketProps() { + return { + bucketName: `${recordName}.${zoneName}`, + removalPolicy: aws_core.RemovalPolicy.DESTROY, + autoDeleteObjects: true, + }; + }, + distributionProps(scope) { + return { + certificate: aws_certificatemanager.Certificate.fromCertificateArn( + scope, + "peterportal-cert", + certificateArn + ), + defaultRootObject: "index.html", + domainNames: [`${recordName}.${zoneName}`], + defaultBehavior: { + origin: new aws_cloudfront_origins.S3Origin(scope.bucket, { + originAccessIdentity: scope.originAccessIdentity, + }), + allowedMethods: aws_cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS, + viewerProtocolPolicy: aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + }, + errorResponses: [ + { + httpStatus: 403, + responseHttpStatus: 200, + responsePagePath: "/index.html", + }, + ], + }; + }, + }, + }); + + this.cloudFrontTarget = new aws_route53_targets.CloudFrontTarget(this.staticSite.distribution); + + this.zone = aws_route53.HostedZone.fromHostedZoneAttributes(this, "peterportal-hosted-zone", { + zoneName, + hostedZoneId, + }); + + this.aRecord = new aws_route53.ARecord(this, `peterportal-api-next-docs-a-record-${stage}`, { + zone: this.zone, + recordName, + target: aws_route53.RecordTarget.fromAlias(this.cloudFrontTarget), + }); + } +} + export default function main() { - const app = new App(); + const app = new aws_core.App(); - const myStack = new MyStack(app, "TestingPpaReleaseCandidateStack"); + const myStack = new ApiStack(app, "TestingPpaReleaseCandidateStack"); - const stack = new Stack(app, "GitHubStuff"); + const stack = new DocsStack(app, "GitHubStuff"); new GitHub(stack, "GitHub", { outputs: { - invokeUrl: { + apiUrl: { value: myStack.api.api.urlForPath(), }, + docsUrl: { + value: stack.aRecord.domainName, + }, }, callbacks: { async onPostDeploy(outputs) { @@ -89,8 +202,16 @@ export default function main() { environment: "staging - api", }); - if (apiDeployment.status !== 201) { - throw new Error("❌ Deployment failed!"); + const docsDeployment = await octokit.rest.repos.createDeployment({ + owner, + repo, + ref, + required_contexts: [], + environment: "staging - docs", + }); + + if (apiDeployment.status !== 201 || docsDeployment.status !== 201) { + throw new Error("❌ Creating deployments failed!"); } const apiDeploymentStatus = await octokit.rest.repos.createDeploymentStatus({ @@ -99,7 +220,17 @@ export default function main() { deployment_id: apiDeployment.data.id, state: "success", description: "Deployment succeeded", - environment_url: outputs.invokeUrl, + environment_url: outputs.apiUrl, + auto_inactive: false, + }); + + const docsDeploymentStatus = await octokit.rest.repos.createDeploymentStatus({ + repo, + owner, + deployment_id: docsDeployment.data.id, + state: "success", + description: "Deployment succeeded", + environment_url: outputs.docsUrl, auto_inactive: false, }); @@ -113,6 +244,8 @@ export default function main() { 🚀 Staging instances deployed! API - ${apiDeploymentStatus.data.environment_url} + +Docs - ${docsDeploymentStatus.data.environment_url} `, }); }, diff --git a/packages/ant-stack/src/cdk/constructs/api/api.ts b/packages/ant-stack/src/cdk/constructs/api/api.ts index b82d5854..c7af8002 100644 --- a/packages/ant-stack/src/cdk/constructs/api/api.ts +++ b/packages/ant-stack/src/cdk/constructs/api/api.ts @@ -64,7 +64,7 @@ export interface DefaultApiRouteConfig extends Pick RestApiProps; + restApiProps?: (scope: Api, id: string) => RestApiProps; } /** @@ -134,13 +134,15 @@ export class Api extends Construct { export async function getApi(initializedApp?: App): Promise { const app = initializedApp ?? (await synthesizeConfig()); - const stacks = app.node.children.find(Stack.isStack); + const stacks = app.node.children.filter(Stack.isStack); if (!stacks) { throw new Error(`No stacks found.`); } - const api = stacks?.node.children.find(Api.isApi); + const stackWithApi = stacks.find((stack) => stack.node.children.some(Api.isApi)); + + const api = stackWithApi?.node.children.find(Api.isApi); if (!api) { throw new Error(`No ${Api.type} construct found.`); @@ -152,8 +154,8 @@ export async function getApi(initializedApp?: App): Promise { /** * Get the API config with the current route at the highest priority (if it exists). */ -export async function getApiRoute(directory: string = process.cwd()) { - const api = await getApi(); +export async function getApiRoute(directory: string = process.cwd(), app?: App) { + const api = await getApi(app); if (!api.routes[directory]) { throw new Error(`No ${ApiRoute.type} found for directory: ${directory}`); diff --git a/packages/ant-stack/src/cdk/constructs/api/apiRoute.ts b/packages/ant-stack/src/cdk/constructs/api/apiRoute.ts index ae783893..ffa24c42 100644 --- a/packages/ant-stack/src/cdk/constructs/api/apiRoute.ts +++ b/packages/ant-stack/src/cdk/constructs/api/apiRoute.ts @@ -109,15 +109,15 @@ export interface ApiRouteRuntimeConfig { */ export interface ApiRouteConstructProps { lambdaIntegrationOptions?: ( - scope: Construct, + scope: ApiRoute, id: string, methodAndRoute: string ) => aws_apigateway.LambdaIntegrationOptions; - functionProps?: (scope: Construct, id: string) => aws_lambda.FunctionProps; + functionProps?: (scope: ApiRoute, id: string) => aws_lambda.FunctionProps; methodOptions?: ( - scope: Construct, + scope: ApiRoute, id: string, methodAndRoute: string ) => aws_apigateway.MethodOptions; @@ -264,19 +264,19 @@ export class ApiRoute extends Construct { .forEach((httpMethod) => { const functionName = `${this.id}-${httpMethod}`.replace(/\//g, "-"); - const functionProps: aws_lambda.FunctionProps = defu( - this.config.constructs.functionProps?.(this, this.id), - { - functionName, - runtime: aws_lambda.Runtime.NODEJS_18_X, - code: aws_lambda.Code.fromAsset(this.config.directory, { exclude: ["node_modules"] }), - handler: path.join(outDirectory, this.outFiles.node.replace(/.js$/, `.${httpMethod}`)), - architecture: aws_lambda.Architecture.ARM_64, - environment: { ...this.config.runtime.environment }, - timeout: aws_core.Duration.seconds(15), - memorySize: 512, - } - ); + const functionProps: aws_lambda.FunctionProps = this.config.constructs.functionProps?.( + this, + this.id + ) ?? { + functionName, + runtime: aws_lambda.Runtime.NODEJS_18_X, + code: aws_lambda.Code.fromAsset(this.config.directory, { exclude: ["node_modules"] }), + handler: path.join(outDirectory, this.outFiles.node.replace(/.js$/, `.${httpMethod}`)), + architecture: aws_lambda.Architecture.ARM_64, + environment: { ...this.config.runtime.environment }, + timeout: aws_core.Duration.seconds(15), + memorySize: 512, + }; const handler = new aws_lambda.Function( this, diff --git a/packages/ant-stack/src/cdk/constructs/staticSite/staticSite.ts b/packages/ant-stack/src/cdk/constructs/staticSite/staticSite.ts index f107879c..530e69ac 100644 --- a/packages/ant-stack/src/cdk/constructs/staticSite/staticSite.ts +++ b/packages/ant-stack/src/cdk/constructs/staticSite/staticSite.ts @@ -1,10 +1,9 @@ -import aws_cloudfront from "aws-cdk-lib/aws-cloudfront"; -import aws_cloudfront_origins from "aws-cdk-lib/aws-cloudfront-origins"; -import aws_iam from "aws-cdk-lib/aws-iam"; -import aws_s3 from "aws-cdk-lib/aws-s3"; -import aws_s3_deployment from "aws-cdk-lib/aws-s3-deployment"; +import * as aws_cloudfront from "aws-cdk-lib/aws-cloudfront"; +import * as aws_cloudfront_origins from "aws-cdk-lib/aws-cloudfront-origins"; +import * as aws_iam from "aws-cdk-lib/aws-iam"; +import * as aws_s3 from "aws-cdk-lib/aws-s3"; +import * as aws_s3_deployment from "aws-cdk-lib/aws-s3-deployment"; import { Construct } from "constructs"; -import { defu } from "defu"; export interface StaticSiteProps { /** @@ -15,21 +14,21 @@ export interface StaticSiteProps { /** * Override props passed to constructs. */ - constructs?: StaticSiteConstructProps; + constructs?: Partial; } export interface StaticSiteConstructProps { - bucketProps?: (scope: Construct, id: string) => aws_s3.BucketProps; + bucketProps: (scope: StaticSite, id: string) => aws_s3.BucketProps; - oaiProps?: (scope: Construct, id: string) => aws_cloudfront.OriginAccessIdentityProps; + oaiProps: (scope: StaticSite, id: string) => aws_cloudfront.OriginAccessIdentityProps; - policyStatementProps?: (scope: Construct, id: string) => aws_iam.PolicyStatementProps; + policyStatementProps: (scope: StaticSite, id: string) => aws_iam.PolicyStatementProps; - originProps?: (scope: Construct, id: string) => aws_cloudfront_origins.S3OriginProps; + originProps: (scope: StaticSite, id: string) => aws_cloudfront_origins.S3OriginProps; - distributionProps?: (scope: Construct, id: string) => aws_cloudfront.DistributionProps; + distributionProps: (scope: StaticSite, id: string) => aws_cloudfront.DistributionProps; - bucketDeploymentProps?: (scope: Construct, id: string) => aws_s3_deployment.BucketDeploymentProps; + bucketDeploymentProps: (scope: StaticSite, id: string) => aws_s3_deployment.BucketDeploymentProps; } export class StaticSite extends Construct { @@ -75,7 +74,7 @@ export class StaticSite extends Construct { }; this.policyStatement = new aws_iam.PolicyStatement( - defu(policyStatementProps, defaultPolicyStatementProps) + policyStatementProps ?? defaultPolicyStatementProps ); this.bucket.addToResourcePolicy(this.policyStatement); @@ -88,13 +87,12 @@ export class StaticSite extends Construct { this.origin = new aws_cloudfront_origins.S3Origin( this.bucket, - defu(originProps, defaultOriginProps) + originProps ?? defaultOriginProps ); const distributionProps = props.constructs?.distributionProps?.(this, id); const defaultDistributionProps: aws_cloudfront.DistributionProps = { - // certificate: this.certificate --> user should figure this out, defaultRootObject: "index.html", defaultBehavior: { origin: this.origin, @@ -106,7 +104,7 @@ export class StaticSite extends Construct { this.distribution = new aws_cloudfront.Distribution( this, `${id}-distribution`, - defu(distributionProps, defaultDistributionProps) + distributionProps ?? defaultDistributionProps ); const bucketDeploymentProps = props.constructs?.bucketDeploymentProps?.(this, id); @@ -121,7 +119,7 @@ export class StaticSite extends Construct { this.bucketDeployment = new aws_s3_deployment.BucketDeployment( this, `${id}-bucket-deployment`, - defu(bucketDeploymentProps, defaultBucketDeploymentProps) + bucketDeploymentProps ?? defaultBucketDeploymentProps ); } } diff --git a/packages/ant-stack/src/cli/commands/build/api.ts b/packages/ant-stack/src/cli/commands/build/api.ts index 2e524d6a..b8937fbb 100644 --- a/packages/ant-stack/src/cli/commands/build/api.ts +++ b/packages/ant-stack/src/cli/commands/build/api.ts @@ -6,12 +6,13 @@ import packageJson from "../../../../package.json"; import { isHttpMethod } from "../../../lambda-core/constants.js"; import { createBunHandler, createNodeHandler } from "../../../lambda-core/internal/handler.js"; import { getNamedExports } from "../../../utils/static-analysis.js"; +import { App } from "aws-cdk-lib/core"; /** * Build stuff. */ -export async function buildApi() { - const apiRoute = await getApiRoute(); +export async function buildApi(app?: App) { + const apiRoute = await getApiRoute(process.cwd(), app); const esbuildOptions = { ...apiRoute.config.runtime.esbuild }; diff --git a/packages/ant-stack/src/cli/commands/destroy.ts b/packages/ant-stack/src/cli/commands/destroy.ts index 3bbeb6e4..66ab6a0e 100644 --- a/packages/ant-stack/src/cli/commands/destroy.ts +++ b/packages/ant-stack/src/cli/commands/destroy.ts @@ -4,6 +4,7 @@ import path from "node:path"; import core from "@actions/core"; import github from "@actions/github"; import { CloudFormationClient } from "@aws-sdk/client-cloudformation"; +import { App } from "aws-cdk-lib/core"; import consola from "consola"; import { getClosestProjectDirectory, waitForStackIdle } from "../../utils"; @@ -16,7 +17,7 @@ const app = `tsx ${appEntry}`; const cdkCommand = ["cdk", "destroy", "--app", app, "*", "--require-approval", "never"]; -export async function destroy() { +export async function destroy(app?: App) { const cfnClient = new CloudFormationClient({}); const GITHUB_TOKEN = process.env.GITHUB_TOKEN ?? core.getInput("GITHUB_TOKEN"); diff --git a/packages/ant-stack/src/cli/index.ts b/packages/ant-stack/src/cli/index.ts index 8c0dc2a5..72027fe0 100644 --- a/packages/ant-stack/src/cli/index.ts +++ b/packages/ant-stack/src/cli/index.ts @@ -42,6 +42,9 @@ async function main() { ], }); + /** + * FIXME: the app is initialized once and prop-drilled everywhere. + */ const app = await synthesizeConfig(); const construct = await detectConstruct(app); @@ -53,7 +56,7 @@ async function main() { switch (argv.command) { case "build": { if (isApi) { - return await buildApi(); + return await buildApi(app); } consola.error(`💀 Unsupported constructs`); @@ -81,7 +84,7 @@ async function main() { } case "destroy": { - return await destroy(); + return await destroy(app); } } }