-
Notifications
You must be signed in to change notification settings - Fork 74
feat(project): add CDK Toolkit adapter #2057
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
20f2a7e
3d8be10
517c469
3db4cd7
2e59854
e5af2c4
d5e69ff
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| import { describe, expect, mock, test } from "bun:test"; | ||
| import type { IoMessage, IoRequest } from "@aws-cdk/toolkit-lib"; | ||
| import * as toolkitLib from "@aws-cdk/toolkit-lib"; | ||
| import { createSilentLogger } from "../../../../testing"; | ||
| import { | ||
| createCdkIoHost, | ||
| createCdkRunner, | ||
| loadCdkToolkit, | ||
| performCdkOperation, | ||
| type CdkToolkit, | ||
| type LoadedCdkToolkit, | ||
| } from "./toolkit"; | ||
|
|
||
| function message(text: string): IoMessage<unknown> { | ||
| return { | ||
| time: new Date(0), | ||
| action: "deploy", | ||
| level: "info", | ||
| code: "CDK_TOOLKIT_I0001", | ||
| message: text, | ||
| data: undefined, | ||
| }; | ||
| } | ||
|
|
||
| function loadedToolkit() { | ||
| const calls: { method: string; args: unknown[] }[] = []; | ||
| const toolkit: CdkToolkit = { | ||
| bootstrap: async (...args: Parameters<CdkToolkit["bootstrap"]>) => { | ||
| calls.push({ method: "bootstrap", args }); | ||
| return { environments: [], duration: 0 }; | ||
| }, | ||
| fromAssemblyDirectory: async (...args: Parameters<CdkToolkit["fromAssemblyDirectory"]>) => { | ||
| calls.push({ method: "fromAssemblyDirectory", args }); | ||
| return { produce: async () => ({}) } as never; | ||
| }, | ||
| deploy: async (...args: Parameters<CdkToolkit["deploy"]>) => { | ||
| calls.push({ method: "deploy", args }); | ||
| return { | ||
| stacks: [ | ||
| { | ||
| stackName: "AgentCore-orders-default", | ||
| environment: { account: "111122223333", region: "us-east-1" }, | ||
| stackArn: "arn:aws:cloudformation:us-east-1:111122223333:stack/example/id", | ||
| hierarchicalId: "AgentCore-orders-default", | ||
| outputs: { RuntimeArn: "arn:runtime" }, | ||
| deleteFailures: [], | ||
| }, | ||
| ], | ||
| }; | ||
| }, | ||
| }; | ||
| return { calls, loaded: { lib: toolkitLib, toolkit } as LoadedCdkToolkit }; | ||
| } | ||
|
|
||
| describe("CDK Toolkit IO", () => { | ||
| test("routes notifications to the existing logger at debug", async () => { | ||
| const logger = createSilentLogger(); | ||
| const debug = mock(() => {}); | ||
| logger.debug = debug; | ||
|
|
||
| await createCdkIoHost(logger).notify(message("stack deployment started")); | ||
|
|
||
| expect(debug).toHaveBeenCalledWith("stack deployment started"); | ||
| }); | ||
|
|
||
| test("answers noninteractive requests with their default response", async () => { | ||
| const ioHost = createCdkIoHost(createSilentLogger()); | ||
| const response = await ioHost.requestResponse({ | ||
| ...message("approve security changes"), | ||
| defaultResponse: false, | ||
| } as IoRequest<unknown, boolean>); | ||
|
|
||
| expect(response).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe("performCdkOperation", () => { | ||
| test("bootstraps the requested environments with the existing stack parameters", async () => { | ||
| const { calls, loaded } = loadedToolkit(); | ||
|
|
||
| expect( | ||
| await performCdkOperation( | ||
| loaded, | ||
| { kind: "bootstrap", environments: ["aws://111122223333/us-east-1"] }, | ||
| { assemblyDirectory: "/unused", region: "us-east-1" }, | ||
| ), | ||
| ).toEqual({}); | ||
|
|
||
| expect(calls.map(({ method }) => method)).toEqual(["bootstrap"]); | ||
| const [environments, options] = calls[0]!.args as [ | ||
| { getEnvironments: () => Promise<unknown> }, | ||
| { parameters: { parameters: Record<string, unknown> }; source?: unknown }, | ||
| ]; | ||
| expect(await environments.getEnvironments()).toEqual([ | ||
| { | ||
| name: "aws://111122223333/us-east-1", | ||
| account: "111122223333", | ||
| region: "us-east-1", | ||
| }, | ||
| ]); | ||
| expect(options.parameters.parameters).toEqual({ createCustomerMasterKey: true }); | ||
| expect(options.source).toBeUndefined(); | ||
| }); | ||
|
|
||
| test("passes an explicit bootstrap template to the Toolkit", async () => { | ||
| const { calls, loaded } = loadedToolkit(); | ||
|
|
||
| await performCdkOperation( | ||
| loaded, | ||
| { | ||
| kind: "bootstrap", | ||
| environments: ["aws://111122223333/us-east-1"], | ||
| templateFile: "/tmp/bootstrap-template.yaml", | ||
| }, | ||
| { assemblyDirectory: "/unused", region: "us-east-1" }, | ||
| ); | ||
|
|
||
| expect(calls[0]!.args[1]).toMatchObject({ | ||
| source: { source: "custom", templateFile: "/tmp/bootstrap-template.yaml" }, | ||
| }); | ||
| }); | ||
|
|
||
| test("deploys exactly one named stack from the synthesized assembly", async () => { | ||
| const { calls, loaded } = loadedToolkit(); | ||
|
|
||
| const outputs = await performCdkOperation( | ||
| loaded, | ||
| { kind: "deploy", stackName: "AgentCore-orders-default" }, | ||
| { assemblyDirectory: "/workspace/agentcore/cdk/cdk.out", region: "us-east-1" }, | ||
| ); | ||
|
|
||
| expect(outputs).toEqual({ RuntimeArn: "arn:runtime" }); | ||
| expect(calls.map(({ method }) => method)).toEqual(["fromAssemblyDirectory", "deploy"]); | ||
| expect(calls[0]!.args).toEqual(["/workspace/agentcore/cdk/cdk.out"]); | ||
| expect(calls[1]!.args[1]).toMatchObject({ | ||
| stacks: { | ||
| strategy: toolkitLib.StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE, | ||
| patterns: ["AgentCore-orders-default"], | ||
| }, | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe("Toolkit loading", () => { | ||
| test("constructs the real Toolkit without resolving credentials", async () => { | ||
| const loaded = await loadCdkToolkit(createCdkIoHost(createSilentLogger()), "us-west-2"); | ||
|
|
||
| expect(typeof loaded.toolkit.bootstrap).toBe("function"); | ||
| expect(typeof loaded.toolkit.deploy).toBe("function"); | ||
| }); | ||
|
|
||
| test("loads the Toolkit with the target region for each operation", async () => { | ||
| const { loaded } = loadedToolkit(); | ||
| const regions: string[] = []; | ||
| const runner = createCdkRunner(createSilentLogger(), async (_ioHost, region) => { | ||
| regions.push(region); | ||
| return loaded; | ||
| }); | ||
|
|
||
| await runner( | ||
| { kind: "deploy", stackName: "AgentCore-orders-default" }, | ||
| { assemblyDirectory: "/workspace/cdk.out", region: "eu-west-1" }, | ||
| ); | ||
|
|
||
| expect(regions).toEqual(["eu-west-1"]); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import type { IIoHost, IoMessage, Toolkit } from "@aws-cdk/toolkit-lib"; | ||
| import type { Logger } from "../../../../logging"; | ||
|
|
||
| export type CdkOperation = | ||
| | { kind: "bootstrap"; environments: string[]; templateFile?: string } | ||
| | { kind: "deploy"; stackName: string }; | ||
|
|
||
| export type CdkRunOptions = { | ||
| /** Synthesized cloud assembly used by deploy operations. */ | ||
| assemblyDirectory: string; | ||
| /** Region used for the Toolkit's own AWS SDK calls. */ | ||
| region: string; | ||
| }; | ||
|
|
||
| export type CdkOutputs = Record<string, string>; | ||
|
|
||
| export type CdkRunner = (operation: CdkOperation, options: CdkRunOptions) => Promise<CdkOutputs>; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just curious, did you consider making this a class-based adapter rather than exposing |
||
|
|
||
| export type CdkToolkit = Pick<Toolkit, "bootstrap" | "deploy" | "fromAssemblyDirectory">; | ||
|
|
||
| export type CdkToolkitLib = Pick< | ||
| typeof import("@aws-cdk/toolkit-lib"), | ||
| | "BaseCredentials" | ||
| | "BootstrapEnvironments" | ||
| | "BootstrapSource" | ||
| | "BootstrapStackParameters" | ||
| | "StackSelectionStrategy" | ||
| >; | ||
|
|
||
| export type LoadedCdkToolkit = { | ||
| lib: CdkToolkitLib; | ||
| toolkit: CdkToolkit; | ||
| }; | ||
|
|
||
| export type CdkToolkitLoader = (ioHost: IIoHost, region: string) => Promise<LoadedCdkToolkit>; | ||
|
|
||
| export function createCdkIoHost(logger: Logger): IIoHost { | ||
| const toolkitLogger = logger.child({ component: "cdk-toolkit" }); | ||
| const notify = async (message: IoMessage<unknown>): Promise<void> => { | ||
| toolkitLogger | ||
| .child({ | ||
| action: message.action, | ||
| level: message.level, | ||
| ...(message.code && { code: message.code }), | ||
| }) | ||
| .debug(message.message); | ||
| }; | ||
|
|
||
| return { | ||
| notify, | ||
| requestResponse: async (request) => { | ||
| await notify(request); | ||
| return request.defaultResponse; | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| /** Loads the Toolkit only when a deploy operation needs it. */ | ||
| export const loadCdkToolkit: CdkToolkitLoader = async (ioHost, region) => { | ||
| const lib = await import("@aws-cdk/toolkit-lib"); | ||
| return { | ||
| lib, | ||
| toolkit: new lib.Toolkit({ | ||
| ioHost, | ||
| color: false, | ||
| emojis: false, | ||
| sdkConfig: { | ||
| baseCredentials: lib.BaseCredentials.awsCliCompatible({ defaultRegion: region }), | ||
| }, | ||
| }), | ||
| }; | ||
| }; | ||
|
|
||
| export async function performCdkOperation( | ||
| { lib, toolkit }: LoadedCdkToolkit, | ||
| operation: CdkOperation, | ||
| options: CdkRunOptions, | ||
| ): Promise<CdkOutputs> { | ||
| if (operation.kind === "bootstrap") { | ||
| await toolkit.bootstrap(lib.BootstrapEnvironments.fromList(operation.environments), { | ||
| parameters: lib.BootstrapStackParameters.withExisting({ | ||
| createCustomerMasterKey: true, | ||
| }), | ||
| ...(operation.templateFile && { | ||
| source: lib.BootstrapSource.customTemplate(operation.templateFile), | ||
| }), | ||
| }); | ||
| return {}; | ||
| } | ||
|
|
||
| const source = await toolkit.fromAssemblyDirectory(options.assemblyDirectory); | ||
| const result = await toolkit.deploy(source, { | ||
| stacks: { | ||
| strategy: lib.StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE, | ||
| patterns: [operation.stackName], | ||
| }, | ||
| }); | ||
| return result.stacks[0]?.outputs ?? {}; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggest asserting |
||
| } | ||
|
|
||
| export function createCdkRunner( | ||
| logger: Logger, | ||
| load: CdkToolkitLoader = loadCdkToolkit, | ||
| ): CdkRunner { | ||
| const ioHost = createCdkIoHost(logger); | ||
| return async (operation, options) => { | ||
| const loaded = await load(ioHost, options.region); | ||
| return performCdkOperation(loaded, operation, options); | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,13 @@ | ||
| import type { Project, ProjectEvent } from "../../../handlers/project/types"; | ||
| import type { DeployResult, Project, ProjectEvent } from "../../../handlers/project/types"; | ||
| import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; | ||
|
|
||
| export type DeployBackendInput = { | ||
| /** Fully resolved account and region selected from aws-targets.json. */ | ||
| target: AwsDeploymentTarget; | ||
| }; | ||
|
|
||
| /** Builds the deployable artifacts owned by a project's selected backend. */ | ||
| export interface ProjectBackend { | ||
| build(project: Project): AsyncGenerator<ProjectEvent, void>; | ||
| deploy(project: Project, input: DeployBackendInput): AsyncGenerator<ProjectEvent, DeployResult>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,3 @@ | ||
| export { FsProjectManager } from "./manager"; | ||
| export { CdkBackend, type CdkBackendConfig } from "./backends/cdk"; | ||
| export type { ProjectBackend } from "./backends/types"; | ||
| export type { DeployBackendInput, ProjectBackend } from "./backends/types"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This keeps
@aws-cdk/toolkit-libexternal for the npm bundle, butcompile()below (the six platform binaries) doesn't passexternal. So the moment #2058 makes this reachable fromsrc/index.ts, Bun will statically bundle the toolkit into every standalone binary — and that breaks the exact thing the comment above warns about: the toolkit reads data files relative to its own package dir at runtime (e.g.lib/api/bootstrap/bootstrap-template.yaml), and that path doesn't exist inside a compiled binary. Marking it external incompile()won't rescue it either, since the binaries ship nonode_modulesto resolve against.Since this PR is where both the dependency and the asymmetry come in, I'd rather we settle the compiled-binary deploy story here than discover it in #2058. A few options: embed the toolkit's data files through the existing asset pipeline, make deploy fail with a clear message on compiled binaries, or at the very least narrow the comment to say it only covers the npm bundle.