src/riff-raff-yaml-file/index.ts (165 lines of code) (raw):

import { writeFileSync } from "fs"; import path from "path"; import type { App } from "aws-cdk-lib"; import { Token } from "aws-cdk-lib"; import type { CfnAutoScalingGroup } from "aws-cdk-lib/aws-autoscaling"; import { dump } from "js-yaml"; import { GuAutoScalingGroup } from "../constructs/autoscaling"; import { GuStack } from "../constructs/core"; import { GuLambdaFunction } from "../constructs/lambda"; import { HorizontallyScalingDeploymentProperties } from "../experimental/patterns/ec2-app"; import { autoscalingDeployment, uploadAutoscalingArtifact } from "./deployments/autoscaling"; import { cloudFormationDeployment, getAmiParameters, getMinInstancesInServiceParameters, } from "./deployments/cloudformation"; import { updateLambdaDeployment, uploadLambdaArtifact } from "./deployments/lambda"; import { groupByClassNameStackRegionStage } from "./group-by"; import type { GroupedCdkStacks, Region, RiffRaffDeployment, RiffRaffDeploymentName, RiffRaffDeploymentProps, RiffRaffYaml, StackTag, StageTag, } from "./types"; /** * A class that creates a `riff-raff.yaml` file. * * Rather than using this directly, prefer to use [[`GuRoot`]] instead. * * Supports: * - Multiple CloudFormation stacks * - Multiple regions * - Lambda applications * - EC2 (autoscaling) applications * * For lambda applications, 3 deployments will be defined: * 1. Lambda upload (`aws-lambda`, `action: [uploadLambda]`) * 2. CloudFormation deploy (`cloud-formation`) * 3. Lambda update (`aws-lambda`, `action: [updateLambda]`) * * For EC2 applications, 3 deployments will be defined: * 1. Artifact upload (`autoscaling`, `action: [uploadArtifacts]`) * 2. CloudFormation deploy (`cloud-formation`) * 3. Autoscaling deploy (`autoscaling`, `action: [deploy]`) * * It assumes a Riff-Raff bundle structure as follows: * * ``` * . * ├── cdk.out * │ └── MyApplication.template.json * ├── my-application * │ └── my-application.deb * └── my-lambda * └── my-lambda.zip * ``` * * That is, all CloudFormation templates are in a `cdk.out` directory, and there is a directory per app. * Application artifact(s) are in the app directory. * * NOTE: The file extension is decided by you, the above file tree is used for illustration purposes. * * NOTE: Resources will be looked up by tags (Stack, Stage, App). Ensure your CFN stack is tagged appropriately! * * @see https://riffraff.gutools.co.uk/docs/reference/riff-raff.yaml.md * @see https://riffraff.gutools.co.uk/docs/magenta-lib/types */ export class RiffRaffYamlFile { private readonly allCdkStacks: GuStack[]; private readonly allStackTags: StackTag[]; private readonly allStageTags: StageTag[]; private readonly allRegions: Region[]; private readonly outdir: string; /** * The `riff-raff.yaml` file as an object. * * It is useful for specifying additional deployment types that GuCDK does not support. * Consider raising an issue or pull request if you think it should be supported. * * In most cases, you shouldn't need to access this. * No validation is performed on the parameters you provide, so you might get deployment errors. * * @see https://riffraff.gutools.co.uk/docs/magenta-lib/types */ public readonly riffRaffYaml: RiffRaffYaml; private isCdkStackPresent(expectedStack: StackTag, expectedStage: StageTag): boolean { const matches = this.allCdkStacks.find((cdkStack) => { const { stack, stage } = cdkStack; return stack === expectedStack && stage === expectedStage; }); return !!matches; } /** * Check there are the appropriate number of `GuStack`s. * Expect to find an instance for each combination of `stack`, and `stage`. * * If not valid, a message is logged describing what is missing to aid debugging. * * Given the following: * * ```ts * const app = new App(); * * class MyApplicationStack extends GuStack { } * * new MyApplicationStack(app, "App-CODE-deploy", { * env: { * region: "eu-west-1", * }, * stack: "deploy", * stage: "CODE" * }); * * new MyApplicationStack(app, "App-PROD-media-service", { * env: { * region: "eu-west-1", * }, * stack: "media-service", * stage: "PROD", * }); * * new MyApplicationStack(app, "App-PROD-deploy", { * env: { * region: "eu-west-1", * }, * stack: "deploy", * stage: "PROD" * }); * ``` * * This will log a message like this, where ❌ denotes something missing, * specifically there is no `CODE` template for `media-service`. * * ```log * Unable to produce a working riff-raff.yaml file; missing 1 definitions (details below) * * ┌───────────────┬──────┬──────┐ * │ (index) │ CODE │ PROD │ * ├───────────────┼──────┼──────┤ * │ deploy │ '✅' │ '✅' │ * │ media-service │ '❌' │ '✅' │ * └───────────────┴──────┴──────┘ * ``` * * @private */ private validateStacksInApp(): void { type Found = "✅"; type NotFound = "❌"; type AppValidation = Record<StackTag, Record<StageTag, Found | NotFound>>; const { allStackTags, allStageTags } = this; const checks: AppValidation = allStackTags.reduce((accStackTag, stackTag) => { return { ...accStackTag, [stackTag]: allStageTags.reduce((accStageTag, stageTag) => { return { ...accStageTag, [stageTag]: this.isCdkStackPresent(stackTag, stageTag) ? "✅" : "❌", }; }, {}), }; }, {}); const missingDefinitions: Array<Found | NotFound> = Object.values(checks).flatMap((groupedByStackTag) => { return Object.values(groupedByStackTag).filter((_) => _ === "❌"); }); if (missingDefinitions.length > 0) { const message = `Unable to produce a working riff-raff.yaml file; missing ${missingDefinitions.length} definitions`; console.log(`${message} (details below)`); console.table(checks); throw new Error(message); } } private validateAllRegionsAreResolved(): void { const unresolved = this.allRegions.filter((region) => Token.isUnresolved(region)); if (unresolved.length !== 0) { throw new Error(`Unable to produce a working riff-raff.yaml file; all stacks must have an explicit region set`); } } private getLambdas(cdkStack: GuStack): GuLambdaFunction[] { return cdkStack.node.findAll().filter((_) => _ instanceof GuLambdaFunction) as GuLambdaFunction[]; } private getAutoScalingGroups(cdkStack: GuStack): GuAutoScalingGroup[] { return cdkStack.node.findAll().filter((_) => _ instanceof GuAutoScalingGroup) as GuAutoScalingGroup[]; } private getGuStackDependencies(cdkStack: GuStack): GuStack[] { return cdkStack.dependencies.filter((_) => _ instanceof GuStack) as GuStack[]; } // eslint-disable-next-line custom-rules/valid-constructors -- this needs to sit above GuStack on the cdk tree constructor(app: App) { this.allCdkStacks = app.node.findAll().filter((_) => _ instanceof GuStack) as GuStack[]; const allowedStages = new Set(this.allCdkStacks.map(({ stage }) => stage)); this.allStageTags = Array.from(allowedStages); this.allStackTags = Array.from(new Set(this.allCdkStacks.map(({ stack }) => stack))); this.allRegions = Array.from(new Set(this.allCdkStacks.map(({ region }) => region))); this.validateStacksInApp(); this.validateAllRegionsAreResolved(); this.outdir = app.outdir; const deployments = new Map<RiffRaffDeploymentName, RiffRaffDeploymentProps>(); const groupedStacks: GroupedCdkStacks = groupByClassNameStackRegionStage(this.allCdkStacks); Object.values(groupedStacks).forEach((stackTagGroup) => { Object.values(stackTagGroup).forEach((regionGroup) => { Object.values(regionGroup).forEach((stageGroup) => { const stacks: GuStack[] = Object.values(stageGroup).flat(); // The items in `stacks` only differ by stage, so we can just use the first item in the list. const [stack] = stacks; if (!stack) { throw new Error("Unable to produce a working riff-raff.yaml file; there are no stacks!"); } const lambdas = this.getLambdas(stack); const autoscalingGroups = this.getAutoScalingGroups(stack); const artifactUploads: RiffRaffDeployment[] = [ lambdas.filter((_) => !_.withoutArtifactUpload).map(uploadLambdaArtifact), autoscalingGroups.map(uploadAutoscalingArtifact), ].flat(); artifactUploads.forEach(({ name, props }) => deployments.set(name, props)); const parentStacks: RiffRaffDeployment[] = this.getGuStackDependencies(stack).map((x) => cloudFormationDeployment([x], [], this.outdir), ); const cfnDeployment = cloudFormationDeployment(stacks, [...artifactUploads, ...parentStacks], this.outdir); deployments.set(cfnDeployment.name, cfnDeployment.props); const lambdasWithoutAnAlias = lambdas.filter((lambda) => lambda.alias === undefined); // If the Lambda has an alias it is using versioning. When using versioning, there is no need for Riff-Raff // to modify the unpublished version of the function lambdasWithoutAnAlias.forEach((lambda) => { const lambdaDeployment = updateLambdaDeployment(lambda, cfnDeployment); deployments.set(lambdaDeployment.name, lambdaDeployment.props); }); /* Instances in an ASG with an `AutoScalingRollingUpdate` update policy are rotated via CloudFormation. Therefore, they do not need to also perform an `autoscaling` deployment via Riff-Raff. */ const legacyAutoscalingGroups = autoscalingGroups.filter((asg) => { const { cfnOptions } = asg.node.defaultChild as CfnAutoScalingGroup; const { updatePolicy } = cfnOptions; return updatePolicy?.autoScalingRollingUpdate === undefined; }); legacyAutoscalingGroups.forEach((asg) => { const asgDeployment = autoscalingDeployment(asg, cfnDeployment); deployments.set(asgDeployment.name, asgDeployment.props); }); const amiParametersToTags = getAmiParameters(autoscalingGroups); const minInServiceParamMap = HorizontallyScalingDeploymentProperties.getInstance(stack).asgToParamMap; const minInServiceAsgs = autoscalingGroups.filter((asg) => minInServiceParamMap.has(asg.node.id)); const minInstancesInServiceParameters = getMinInstancesInServiceParameters(minInServiceAsgs); deployments.set(cfnDeployment.name, { ...cfnDeployment.props, parameters: { ...cfnDeployment.props.parameters, // only add the `amiParametersToTags` property if there are some ...(autoscalingGroups.length > 0 && { amiParametersToTags }), // only add the `minInstancesInServiceParameters` property if there are some ...(minInServiceAsgs.length > 0 && { minInstancesInServiceParameters }), }, }); }); }); }); this.riffRaffYaml = { allowedStages, deployments, }; } /** * The `riff-raff.yaml` file as a string. * Useful for testing. */ toYAML(): string { // Add support for ES6 Set and Map. See https://github.com/nodeca/js-yaml/issues/436. const replacer = (_key: string, value: unknown) => { if (value instanceof Set) { // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- this is how `js-yaml` is typed return Array.from(value); } if (value instanceof Map) { // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- this is how `js-yaml` is typed return Object.fromEntries(value); } return value; }; return dump(this.riffRaffYaml, { replacer }); } /** * Write the `riff-raff.yaml` file to disk. * It'll be located with the CFN JSON templates generated by `cdk synth`. */ synth(): void { const outPath = path.join(this.outdir, "riff-raff.yaml"); writeFileSync(outPath, this.toYAML()); } }