From c2cdbf253046ed4412c7e3557fdf63d4a312e6d2 Mon Sep 17 00:00:00 2001 From: akash1810 Date: Tue, 27 Jan 2026 10:59:26 +0000 Subject: [PATCH] feat(riff-raff.yaml)!: Support generation of a subset of `GuStack`s Add support for the scenario where we create a singleton stack for shared infrastructure alongside application stacks. For example, a singleton stack that provisions an Elastic Container Registry, and a CODE and PROD application stack that pulls images from the registry. Previously, we had to create work-arounds with multiple `App`s. BREAKING CHANGE: `RiffRaffYamlFile` can no longer be directly instantiated Instead of directly instantiating `RiffRaffYamlFile`, please use `RiffRaffYamlFile.fromApp`: ```ts // Before const app = new App(); new RiffRaffYamlFile(app); // After const app = new App(); RiffRaffYamlFile.fromApp(app); ``` --- .changeset/young-pets-fetch.md | 23 ++++ src/constructs/root.ts | 2 +- src/riff-raff-yaml-file/README.md | 33 ++++- src/riff-raff-yaml-file/index.test.ts | 180 +++++++++++++++++++++++--- src/riff-raff-yaml-file/index.ts | 59 +++++++-- 5 files changed, 266 insertions(+), 31 deletions(-) create mode 100644 .changeset/young-pets-fetch.md diff --git a/.changeset/young-pets-fetch.md b/.changeset/young-pets-fetch.md new file mode 100644 index 0000000000..d3ef74e253 --- /dev/null +++ b/.changeset/young-pets-fetch.md @@ -0,0 +1,23 @@ +--- +"@guardian/cdk": major +--- + +Add support for the scenario where we create a singleton stack for shared infrastructure +alongside application stacks. +For example, a singleton stack that provisions an Elastic Container Registry, +and a CODE and PROD application stack that pulls images from the registry. + +Previously, we had to create work-arounds with multiple `App`s. + +BREAKING CHANGE: `RiffRaffYamlFile` can no longer be directly instantiated +Instead of directly instantiating `RiffRaffYamlFile`, please use `RiffRaffYamlFile.fromApp`: + +```ts +// Before +const app = new App(); +new RiffRaffYamlFile(app); + +// After +const app = new App(); +RiffRaffYamlFile.fromApp(app); +``` diff --git a/src/constructs/root.ts b/src/constructs/root.ts index 4499e6d304..d66f207092 100644 --- a/src/constructs/root.ts +++ b/src/constructs/root.ts @@ -30,7 +30,7 @@ import { RiffRaffYamlFile } from "../riff-raff-yaml-file"; */ export class GuRoot extends App { override synth(options?: StageSynthesisOptions): CloudAssembly { - new RiffRaffYamlFile(this).synth(); + RiffRaffYamlFile.fromApp(this).synth(); return super.synth(options); } } diff --git a/src/riff-raff-yaml-file/README.md b/src/riff-raff-yaml-file/README.md index 4c8ab9da2e..1574ea9a35 100644 --- a/src/riff-raff-yaml-file/README.md +++ b/src/riff-raff-yaml-file/README.md @@ -38,6 +38,7 @@ new MyStack(app, "my-stack-PROD", {}); ``` ### Advanced usage +#### Additional deployment types As noted above, only specific deployment types are currently supported. If you want to add additional deployment types, you can do so by instantiating `RiffRaffYamlFile` directly: @@ -54,7 +55,7 @@ const { stack, region } = new MyStack(app, "my-stack", { env: { region: "eu-west-1" }, }); -const riffRaff = new RiffRaffYamlFile(app); +const riffRaff = RiffRaffYamlFile.fromApp(app); const { riffRaffYaml: { deployments } } = riffRaff; deployments.set("upload-my-static-files", { @@ -79,6 +80,36 @@ riffRaff.synth(); When the CDK stack is synthesized, a `riff-raff.yaml` file will be created in the output directory, typically `//cdk/cdk.out`. +#### Multiple Riff-Raff projects in a single repository +If your repository deploys multiple Riff-Raff projects, use `RiffRaffYamlFile.fromStacks` to generate a `riff-raff.yaml` for a selection of `GuStack`s: + +```ts +import { App } from "aws-cdk-lib"; +import { RiffRaffYamlFile } from "@guardian/cdk/lib/riff-raff-yaml-file"; + +const app = new App(); + +const myInfraStack = new MyInfraStack(app, "my-infra-stack", { + stack: "playground", + stage: "INFRA", + env: { region: "eu-west-1" }, +}); + +const myAppStackCODE = new MyAppStack(app, "my-stack-CODE", { + stack: "playground", + stage: "CODE", + env: { region: "eu-west-1" }, +}); +const myAppStackPROD = new MyAppStack(app, "my-stack-PROD", { + stack: "playground", + stage: "CODE", + env: { region: "eu-west-1" }, +}); + +RiffRaffYamlFile.fromStacks([myInfraStack], "playground::core-infra"); // Generates a file to `cdk.out/playground::core-infra/riff-raff.yaml` +RiffRaffYamlFile.fromStacks([myAppStackCODE, myAppStackPROD], "playground::my-app"); // Generates a file to `cdk.out/playground::my-app/riff-raff.yaml` +``` + ## Package layout `RiffRaffYamlFile` assumes CI has uploaded files in the following structure: diff --git a/src/riff-raff-yaml-file/index.test.ts b/src/riff-raff-yaml-file/index.test.ts index 1fdfd0b742..5adfb0d33c 100644 --- a/src/riff-raff-yaml-file/index.test.ts +++ b/src/riff-raff-yaml-file/index.test.ts @@ -43,7 +43,7 @@ describe("The RiffRaffYamlFile class", () => { stage: "CODE", }); - const actual = new RiffRaffYamlFile(app).toYAML(); + const actual = RiffRaffYamlFile.fromApp(app).toYAML(); expect(actual).toMatchInlineSnapshot(` "allowedStages: @@ -114,7 +114,7 @@ describe("The RiffRaffYamlFile class", () => { stage: "CODE", }); - const actual = new RiffRaffYamlFile(app).toYAML(); + const actual = RiffRaffYamlFile.fromApp(app).toYAML(); expect(actual).toMatchInlineSnapshot(` "allowedStages: @@ -180,7 +180,7 @@ describe("The RiffRaffYamlFile class", () => { new MyDatabaseStack(app, "Database-CODE-deploy", { ...region, stack: "deploy", stage: "PROD" }); expect(() => { - new RiffRaffYamlFile(app); + RiffRaffYamlFile.fromApp(app); }).toThrowError("Unable to produce a working riff-raff.yaml file; missing 1 definitions"); // Stack of media-service has no CODE stage }); @@ -190,7 +190,7 @@ describe("The RiffRaffYamlFile class", () => { new MyApplicationStack(app, "App-CODE-deploy", { stack: "deploy", stage: "CODE" }); expect(() => { - new RiffRaffYamlFile(app); + RiffRaffYamlFile.fromApp(app); }).toThrowError("Unable to produce a working riff-raff.yaml file; all stacks must have an explicit region set"); }); @@ -199,7 +199,7 @@ describe("The RiffRaffYamlFile class", () => { class MyApplicationStack extends GuStack {} new MyApplicationStack(app, "App-PROD-deploy", { stack: "deploy", stage: "PROD", env: { region: "eu-west-1" } }); - const actual = new RiffRaffYamlFile(app).toYAML(); + const actual = RiffRaffYamlFile.fromApp(app).toYAML(); // Not sure why we have the extra `"` characters...they don't appear in the resulting file on disk... expect(actual).toMatchInlineSnapshot(` @@ -227,7 +227,7 @@ describe("The RiffRaffYamlFile class", () => { new MyApplicationStack(app, "App-PROD-deploy", { stack: "deploy", stage: "PROD", env: { region: "eu-west-1" } }); new MyApplicationStack(app, "App-CODE-deploy", { stack: "deploy", stage: "CODE", env: { region: "eu-west-1" } }); - const actual = new RiffRaffYamlFile(app).toYAML(); + const actual = RiffRaffYamlFile.fromApp(app).toYAML(); expect(actual).toMatchInlineSnapshot(` "allowedStages: @@ -275,7 +275,7 @@ describe("The RiffRaffYamlFile class", () => { env: { region: "us-east-1" }, }); - const actual = new RiffRaffYamlFile(app).toYAML(); + const actual = RiffRaffYamlFile.fromApp(app).toYAML(); expect(actual).toMatchInlineSnapshot(` "allowedStages: @@ -332,7 +332,7 @@ describe("The RiffRaffYamlFile class", () => { new MyApplicationStack(app, "test-stack", { stack: "test", stage: "TEST", env: { region: "eu-west-1" } }); - const actual = new RiffRaffYamlFile(app).toYAML(); + const actual = RiffRaffYamlFile.fromApp(app).toYAML(); expect(actual).toMatchInlineSnapshot(` "allowedStages: @@ -408,7 +408,7 @@ describe("The RiffRaffYamlFile class", () => { new MyApplicationStack(app, "test-stack", { stack: "test", stage: "TEST", env: { region: "eu-west-1" } }); - const actual = new RiffRaffYamlFile(app).toYAML(); + const actual = RiffRaffYamlFile.fromApp(app).toYAML(); expect(actual).toMatchInlineSnapshot(` "allowedStages: @@ -488,7 +488,7 @@ describe("The RiffRaffYamlFile class", () => { new MyApplicationStack(app, "test-stack", { stack: "test", stage: "TEST", env: { region: "eu-west-1" } }); - const actual = new RiffRaffYamlFile(app).toYAML(); + const actual = RiffRaffYamlFile.fromApp(app).toYAML(); expect(actual).toMatchInlineSnapshot(` "allowedStages: @@ -548,7 +548,7 @@ describe("The RiffRaffYamlFile class", () => { new MyApplicationStack(app, "test-stack", { stack: "test", stage: "TEST", env: { region: "eu-west-1" } }); - const actual = new RiffRaffYamlFile(app).toYAML(); + const actual = RiffRaffYamlFile.fromApp(app).toYAML(); expect(actual).toMatchInlineSnapshot(` "allowedStages: @@ -621,7 +621,7 @@ describe("The RiffRaffYamlFile class", () => { new MyApplicationStack(app, "test-stack", { stack: "test", stage: "TEST", env: { region: "eu-west-1" } }); - const actual = new RiffRaffYamlFile(app).toYAML(); + const actual = RiffRaffYamlFile.fromApp(app).toYAML(); expect(actual).toMatchInlineSnapshot(` "allowedStages: @@ -725,7 +725,7 @@ describe("The RiffRaffYamlFile class", () => { new MyApplicationStack(app, "test-stack-eu-PROD", { stack: "test", stage: "PROD", env: { region: "eu-west-1" } }); new MyApplicationStack(app, "test-stack-us-PROD", { stack: "test", stage: "PROD", env: { region: "us-east-1" } }); - const actual = new RiffRaffYamlFile(app).toYAML(); + const actual = RiffRaffYamlFile.fromApp(app).toYAML(); expect(actual).toMatchInlineSnapshot(` "allowedStages: @@ -954,7 +954,7 @@ describe("The RiffRaffYamlFile class", () => { new MyApplicationStack(app, "test-stack", { stack: "test", stage: "CODE", env: { region: "eu-west-1" } }); - const actual = new RiffRaffYamlFile(app).toYAML(); + const actual = RiffRaffYamlFile.fromApp(app).toYAML(); expect(actual).toMatchInlineSnapshot(` "allowedStages: @@ -1081,7 +1081,7 @@ describe("The RiffRaffYamlFile class", () => { new MyApplicationStack(app, "test-stack", { stack: "test", stage: "CODE", env: { region: "eu-west-1" } }); - const actual = new RiffRaffYamlFile(app).toYAML(); + const actual = RiffRaffYamlFile.fromApp(app).toYAML(); expect(actual).toMatchInlineSnapshot(` "allowedStages: @@ -1147,7 +1147,7 @@ describe("The RiffRaffYamlFile class", () => { env: { region: "eu-west-1" }, }); - const riffraff = new RiffRaffYamlFile(app); + const riffraff = RiffRaffYamlFile.fromApp(app); riffraff.riffRaffYaml.deployments.set("upload-my-static-files", { app: "my-static-site", @@ -1228,7 +1228,7 @@ describe("The RiffRaffYamlFile class", () => { codeStack.addDependency(sharedResources); prodStack.addDependency(sharedResources); - const actual = new RiffRaffYamlFile(app).toYAML(); + const actual = RiffRaffYamlFile.fromApp(app).toYAML(); expect(actual).toMatchInlineSnapshot(` "allowedStages: @@ -1302,7 +1302,7 @@ describe("The RiffRaffYamlFile class", () => { new MyApplicationStack(app, "test-stack", { stack: "test", stage: "TEST", env: { region: "eu-west-1" } }); - const actual = new RiffRaffYamlFile(app).toYAML(); + const actual = RiffRaffYamlFile.fromApp(app).toYAML(); expect(actual).toMatchInlineSnapshot(` "allowedStages: @@ -1381,7 +1381,7 @@ describe("The RiffRaffYamlFile class", () => { new MyApplicationStack(app, "test-stack", { stack: "test", stage: "TEST", env: { region: "eu-west-1" } }); - const actual = new RiffRaffYamlFile(app).toYAML(); + const actual = RiffRaffYamlFile.fromApp(app).toYAML(); expect(actual).toMatchInlineSnapshot(` "allowedStages: @@ -1479,7 +1479,7 @@ describe("The RiffRaffYamlFile class", () => { getTemplateAfterAspectInvocation(guStack); // ...so that the CFN Parameters are added to the template, to then be processed by the `RiffRaffYamlFile` - const actual = new RiffRaffYamlFile(app).toYAML(); + const actual = RiffRaffYamlFile.fromApp(app).toYAML(); const cfnParameterName = getAsgRollingUpdateCfnParameterName(guStack.asg); @@ -1525,4 +1525,144 @@ describe("The RiffRaffYamlFile class", () => { " `); }); + + it("Should support instantiation with a subset of GuStacks from an App", () => { + const app = new App({ outdir: "/tmp/cdk.out" }); + + class MyCoreInfrastructureStack extends GuStack {} + class MyApplicationStack extends GuStack {} + + const props = { + env: { + region: "eu-west-1", + }, + stack: "deploy", + }; + + const infraStack = new MyCoreInfrastructureStack(app, "MyCoreInfrastructure-INFRA", { + ...props, + stage: "INFRA", + }); + const applicationStacks = [ + new MyApplicationStack(app, "MyApp-CODE", { + ...props, + stage: "CODE", + }), + new MyApplicationStack(app, "MyApp-PROD", { + ...props, + stage: "PROD", + }), + ]; + + expect(RiffRaffYamlFile.fromStacks([infraStack], "tools:core-infra").toYAML()).toMatchInlineSnapshot(` + "allowedStages: + - INFRA + deployments: + cfn-eu-west-1-deploy-my-core-infrastructure-stack: + type: cloud-formation + regions: + - eu-west-1 + stacks: + - deploy + app: my-core-infrastructure-stack + contentDirectory: /tmp/cdk.out + parameters: + templateStagePaths: + INFRA: MyCoreInfrastructure-INFRA.template.json + " + `); + + expect(RiffRaffYamlFile.fromStacks(applicationStacks, "tools:my-app").toYAML()).toMatchInlineSnapshot(` + "allowedStages: + - CODE + - PROD + deployments: + cfn-eu-west-1-deploy-my-application-stack: + type: cloud-formation + regions: + - eu-west-1 + stacks: + - deploy + app: my-application-stack + contentDirectory: /tmp/cdk.out + parameters: + templateStagePaths: + CODE: MyApp-CODE.template.json + PROD: MyApp-PROD.template.json + " + `); + }); + + it("Should throw when instantiated from a subset of GuStacks and there are missing stack definitions", () => { + const app = new App({ outdir: "/tmp/cdk.out" }); + + class MyCoreInfrastructureStack extends GuStack {} + class MyApplicationStack extends GuStack {} + + const region = { + env: { + region: "eu-west-1", + }, + }; + + const infraStacks = [ + new MyCoreInfrastructureStack(app, "MyCoreInfrastructure-INFRA-deploy", { + ...region, + stack: "deploy", + stage: "INFRA", + }), + new MyCoreInfrastructureStack(app, "MyCoreInfrastructure-INFRA-media-service", { + ...region, + stack: "media-service", + stage: "INFRA", + }), + ]; + + const applicationStacks = [ + new MyApplicationStack(app, "App-CODE-deploy", { ...region, stack: "deploy", stage: "CODE" }), + new MyApplicationStack(app, "App-PROD-media-service", { + ...region, + stack: "media-service", + stage: "PROD", + }), + new MyApplicationStack(app, "App-PROD-deploy", { ...region, stack: "deploy", stage: "PROD" }), + ]; + + // No error as all stacks with INFRA stage are included + expect(() => RiffRaffYamlFile.fromStacks(infraStacks, "misc:core-infra")).not.toThrow(); + + // Missing an instance of `MyApplication` for stack=media-service stage=CODE + expect(() => { + RiffRaffYamlFile.fromStacks(applicationStacks, "misc:my-app"); + }).toThrow("Unable to produce a working riff-raff.yaml file; missing 1 definitions"); + }); + + it("Should throw when GuStacks have been instantiated from multiple Apps", () => { + const app1 = new App({ outdir: "/tmp/cdk.out" }); + const app2 = new App({ outdir: "/tmp/cdk.out" }); + + class MyApplicationStack extends GuStack {} + + const props = { + env: { + region: "eu-west-1", + }, + stack: "deploy", + }; + + const applicationStacks = [ + new MyApplicationStack(app1, "MyApp-CODE", { + ...props, + stage: "CODE", + }), + new MyApplicationStack(app2, "MyApp-PROD", { + ...props, + stage: "PROD", + }), + ]; + + expect(() => { + RiffRaffYamlFile.fromStacks(applicationStacks, "tools:my-app"); + }).toThrow("Unable to produce a working riff-raff.yaml file; provided stacks do not belong to the same CDK App"); + }); }); diff --git a/src/riff-raff-yaml-file/index.ts b/src/riff-raff-yaml-file/index.ts index 80ec355158..c2974bbd54 100644 --- a/src/riff-raff-yaml-file/index.ts +++ b/src/riff-raff-yaml-file/index.ts @@ -1,6 +1,6 @@ -import { writeFileSync } from "fs"; +import { existsSync, mkdirSync, writeFileSync } from "fs"; import path from "path"; -import type { App } from "aws-cdk-lib"; +import { 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"; @@ -76,6 +76,7 @@ export class RiffRaffYamlFile { private readonly allStageTags: StageTag[]; private readonly allRegions: Region[]; private readonly outdir: string; + private readonly riffRaffProjectName?: string; /** * The `riff-raff.yaml` file as an object. @@ -206,9 +207,11 @@ export class RiffRaffYamlFile { 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[]; + private constructor(app: App, stacks: GuStack[], riffRaffProjectName?: string) { + this.allCdkStacks = stacks; + this.outdir = app.outdir; + this.riffRaffProjectName = riffRaffProjectName; + 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))); @@ -217,8 +220,6 @@ export class RiffRaffYamlFile { this.validateStacksInApp(); this.validateAllRegionsAreResolved(); - this.outdir = app.outdir; - const deployments = new Map(); const groupedStacks: GroupedCdkStacks = groupByClassNameStackRegionStage(this.allCdkStacks); @@ -303,6 +304,39 @@ export class RiffRaffYamlFile { }; } + /** + * Creates a single `riff-raff.yaml` file for all `GuStack`s in the provided `App`. + * If not using `GuRoot`, this is the recommended way to instantiate a `RiffRaffYamlFile`. + */ + public static fromApp(app: App): RiffRaffYamlFile { + const allCdkStacks = app.node.findAll().filter((_) => _ instanceof GuStack) as GuStack[]; + return new RiffRaffYamlFile(app, allCdkStacks); + } + + /** + * Creates a `riff-raff.yaml` file from the provided list `GuStack`s which is likely to be a subset of all stacks in the `App`. + * This is useful if you want to generate multiple `riff-raff.yaml` files; for example if the repository contains multiple Riff-Raff projects. + * @param stacks - the stacks to include in the `riff-raff.yaml` file + * @param riffRaffProjectName - the name of the Riff-Raff project the stacks belong to. The `riff-raff.yaml` file will be generated to this directory _within_ `cdk.out`. + */ + public static fromStacks(stacks: GuStack[], riffRaffProjectName: string): RiffRaffYamlFile { + if (stacks.length === 0) { + throw new Error("Unable to produce a working riff-raff.yaml file; there are no stacks!"); + } + + // Check if all the `GuStack`s have the same parent `App` + const uniqueApps = new Set(stacks.map((stack) => App.of(stack)).filter((app): app is App => app !== undefined)); + + if (uniqueApps.size !== 1) { + throw new Error( + "Unable to produce a working riff-raff.yaml file; provided stacks do not belong to the same CDK App", + ); + } + + const app = Array.from(uniqueApps).at(0)!; // We know this is defined because of the previous checks + return new RiffRaffYamlFile(app, stacks, riffRaffProjectName); + } + /** * The `riff-raff.yaml` file as a string. * Useful for testing. @@ -329,7 +363,14 @@ export class RiffRaffYamlFile { * 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()); + const outputDirectory = path.join(this.outdir, this.riffRaffProjectName ?? ""); + const outputFile = path.join(outputDirectory, "riff-raff.yaml"); + + if (!existsSync(outputDirectory)) { + console.log(`Output directory ${outputDirectory} does not exist; creating it...`); + mkdirSync(outputDirectory); + } + + writeFileSync(outputFile, this.toYAML()); } }