Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,225 changes: 1,157 additions & 68 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"typescript": "^5"
},
"dependencies": {
"@aws-cdk/toolkit-lib": "1.38.2",
"@aws-sdk/client-bedrock-agentcore": "^3.1092.0",
"@aws-sdk/client-bedrock-agentcore-control": "^3.1102.0",
"@aws-sdk/client-cloudwatch-logs": "^3.1092.0",
Expand Down
5 changes: 5 additions & 0 deletions scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ const DIST = join(REPO_ROOT, "dist");

const ASSET_NAMING = "agentcore-assets/[dir]/[name].[ext]";

// The Toolkit reads files relative to its package directory at runtime, so keep
// that package intact when producing the npm bundle.
const EXTERNAL = ["@aws-cdk/toolkit-lib"];

// Shrink whitespace/syntax but keep identifiers: minified names make stack
// traces unreadable and erase error names telemetry keys on.
const MINIFY = { whitespace: true, syntax: true, identifiers: false } as const;
Expand Down Expand Up @@ -54,6 +58,7 @@ async function bundle(): Promise<void> {
outdir: DIST,
target: "node",
minify: MINIFY,
external: EXTERNAL,

Copy link
Copy Markdown
Contributor

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-lib external for the npm bundle, but compile() below (the six platform binaries) doesn't pass external. So the moment #2058 makes this reachable from src/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 in compile() won't rescue it either, since the binaries ship no node_modules to 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.

});

// Mirror assets beside the emitted module for resolveAssetsRoot().
Expand Down
14 changes: 11 additions & 3 deletions src/core/project/backends/cdk.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { ProjectStateError } from "../../../errors/errors";
import type { Project, ProjectEvent } from "../../../handlers/project/types";
import { NotImplementedError, ProjectStateError } from "../../../errors/errors";
import type { DeployResult, Project, ProjectEvent } from "../../../handlers/project/types";
import { requireTool, runProcess, type ProcessRunner } from "../../../io";
import type { Logger } from "../../../logging";
import type { ProjectBackend } from "./types";
import type { DeployBackendInput, ProjectBackend } from "./types";

export type CdkBackendConfig = {
logger: Logger;
Expand Down Expand Up @@ -41,4 +41,12 @@ export class CdkBackend implements ProjectBackend {
onOutput: (chunk) => this.logger.debug(chunk),
});
}

public async *deploy(
_project: Project,
_input: DeployBackendInput,
): AsyncGenerator<ProjectEvent, DeployResult> {
yield* [];
throw new NotImplementedError("CDK project deployment is not implemented yet");
}
}
167 changes: 167 additions & 0 deletions src/core/project/backends/cdk/toolkit.test.ts
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"]);
});
});
110 changes: 110 additions & 0 deletions src/core/project/backends/cdk/toolkit.ts
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>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 CdkRunner as a function? The function works fine here, but I'd like to understand what drove this choice.


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 ?? {};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PATTERN_MUST_MATCH_SINGLE throws on a non-match, so the only way stacks comes back empty is the toolkit's empty-template path — and in 1.38.2 that path will delete an existing stack and still return normally. When that happens, ?? {} quietly turns it into a success with no outputs, and a caller expecting RuntimeArn just gets undefined with nothing pointing at where it went. This branch also isn't covered by a test.

Suggest asserting result.stacks.length === 1 and throwing otherwise, then keeping ?? {} only for the genuine case where a stack is present but has no outputs.

}

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);
};
}
9 changes: 8 additions & 1 deletion src/core/project/backends/types.ts
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>;
}
2 changes: 1 addition & 1 deletion src/core/project/index.tsx
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";
Loading
Loading