Skip to content

Commit 5e56870

Browse files
committed
fix(project): ship the toolkit's bootstrap template with the artifacts
Bootstrap uploads a CloudFormation template that @aws-cdk/toolkit-lib ships as a file in its own package directory and finds at runtime relative to where that directory sits on disk. Bundling the package rewrote that lookup to the build machine's absolute path, so a released build reached a node_modules that only exists on the machine that built it: the npm bundle failed with ENOENT on the template, and the compiled executables, which have no node_modules at all, failed the same way. Nothing read that template until someone bootstrapped an account, so every build check passed. The bundle keeps the package external, as the pre-refactor CLI's esbuild config did, so node resolves a real @aws-cdk/toolkit-lib -- it is a declared runtime dependency, so installing the CLI installs it. That also returns the bundle to its previous size (32.8 MB back to 6.4 MB) and warm `agentcore --help` to ~0.5s, since the lazy import no longer has a second CDK toolchain inlined behind it to parse. A compiled executable cannot have externals, so the build embeds the template as an asset and bootstrap is pointed at a copy written out for it, in the one mode where the toolkit cannot find its own. The template is read from the installed package rather than vendored here, so it always matches the toolkit being compiled in, and compile fails if the package stops shipping it or if the executable does not carry its bytes -- the check the missing template needed and did not have. Verified on the artifacts: with the toolkit's own template moved aside, the executable bootstraps to the first AWS call, while the bundle under node fails on exactly the ENOENT above.
1 parent 78348f6 commit 5e56870

3 files changed

Lines changed: 199 additions & 14 deletions

File tree

scripts/build.ts

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env bun
22

33
import { $ } from "bun";
4+
import { existsSync } from "node:fs";
45
import { join, resolve } from "node:path";
56
import { runWithExitCode } from "../src/runnable";
67

@@ -11,6 +12,16 @@ const DIST = join(REPO_ROOT, "dist");
1112

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

15+
// Kept out of the bundle so the toolkit stays a real directory in node_modules at
16+
// runtime: it reads its bootstrap template from its own package directory, which a
17+
// bundle would rewrite to this machine's absolute path. The npm package declares it
18+
// as a dependency, so installing the CLI installs it.
19+
const EXTERNAL = ["@aws-cdk/toolkit-lib"];
20+
21+
// A compiled executable has no node_modules, so the template the toolkit would read
22+
// from its package directory is embedded instead. See loadBootstrapTemplate.
23+
const BOOTSTRAP_TEMPLATE = ["lib", "api", "bootstrap", "bootstrap-template.yaml"];
24+
1425
// Shrink whitespace/syntax but keep identifiers: minified names make stack
1526
// traces unreadable and erase error names telemetry keys on.
1627
const MINIFY = { whitespace: true, syntax: true, identifiers: false } as const;
@@ -26,14 +37,54 @@ function assetLoaderPlugin(): Bun.BunPlugin {
2637
return {
2738
name: "asset-file-loader",
2839
setup(build) {
29-
build.onLoad({ filter: /src[/\\]assets[/\\]/ }, async ({ path }) => ({
30-
contents: await Bun.file(path).bytes(),
31-
loader: "file",
32-
}));
40+
build.onLoad(
41+
{ filter: /src[/\\]assets[/\\]|bootstrap-template\.yaml$/ },
42+
async ({ path }) => ({
43+
contents: await Bun.file(path).bytes(),
44+
loader: "file",
45+
}),
46+
);
3347
},
3448
};
3549
}
3650

51+
/**
52+
* Absolute path of the toolkit's own bootstrap template.
53+
*
54+
* Resolved from the installed package rather than a copy in this repo, so the
55+
* embedded template is always the one the toolkit being compiled in expects.
56+
*/
57+
function bootstrapTemplate(): string {
58+
const manifest = Bun.resolveSync("@aws-cdk/toolkit-lib/package.json", REPO_ROOT);
59+
const template = join(resolve(manifest, ".."), ...BOOTSTRAP_TEMPLATE);
60+
if (!existsSync(template)) {
61+
throw new Error(
62+
`@aws-cdk/toolkit-lib no longer ships ${BOOTSTRAP_TEMPLATE.join("/")}; ` +
63+
`bootstrap in a compiled executable reads the embedded copy, so this must be found. Looked in ${template}`,
64+
);
65+
}
66+
return template;
67+
}
68+
69+
/**
70+
* Fail loudly unless the compiled executable carries the bootstrap template's bytes.
71+
*
72+
* Nothing reads that template until someone bootstraps an AWS account, so an
73+
* executable that lost it looks healthy in every build check and fails in a user's
74+
* first deploy instead.
75+
*/
76+
async function assertTemplateIsEmbedded(outfile: string, template: string): Promise<void> {
77+
const [executable, bytes] = await Promise.all([
78+
Bun.file(outfile).bytes(),
79+
Bun.file(template).bytes(),
80+
]);
81+
if (!Buffer.from(executable).includes(bytes)) {
82+
throw new Error(
83+
`${outfile} does not carry ${BOOTSTRAP_TEMPLATE.join("/")}, so bootstrap would fail wherever it runs`,
84+
);
85+
}
86+
}
87+
3788
/** Fail loudly on a non-UTF-8 asset — the source reads every asset as text. */
3889
async function assertAssetsAreText(assets: string[]): Promise<void> {
3990
const decoder = new TextDecoder("utf-8", { fatal: true });
@@ -54,6 +105,7 @@ async function bundle(): Promise<void> {
54105
outdir: DIST,
55106
target: "node",
56107
minify: MINIFY,
108+
external: EXTERNAL,
57109
});
58110

59111
// Mirror assets beside the emitted module for resolveAssetsRoot().
@@ -70,15 +122,19 @@ async function compile(target: string): Promise<void> {
70122
const outfile = join(DIST, "bin", `agentcore-${target.replace(/^bun-/, "")}`);
71123
await $`mkdir -p ${join(DIST, "bin")}`;
72124

125+
const template = bootstrapTemplate();
73126
await Bun.build({
74-
entrypoints: [ENTRYPOINT, ...assets],
127+
entrypoints: [ENTRYPOINT, ...assets, template],
75128
compile: { target: target as Bun.Build.CompileTarget, outfile },
76129
minify: MINIFY,
77130
root: REPO_ROOT,
78131
naming: { asset: ASSET_NAMING },
79132
plugins: [assetLoaderPlugin()],
80133
});
81-
console.log(`Compiled ${target}${outfile} (${assets.length} assets embedded)`);
134+
await assertTemplateIsEmbedded(outfile, template);
135+
console.log(
136+
`Compiled ${target}${outfile} (${assets.length} assets embedded, plus the bootstrap template)`,
137+
);
82138
}
83139

84140
process.exit(

src/io/cdk.test.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { test, expect, describe } from "bun:test";
22
import { mkdtemp, rm } from "node:fs/promises";
3-
import { join } from "node:path";
3+
import { dirname, join } from "node:path";
44
import { tmpdir } from "node:os";
55
import type { IIoHost, IoMessage, IoRequest } from "@aws-cdk/toolkit-lib";
66
// The real toolkit package, so the arguments these tests assert on are the values
77
// the toolkit itself defines rather than ones the tests made up. Imported statically
88
// here, unlike in cdk.ts, because a test file pays no startup cost.
99
import * as toolkit from "@aws-cdk/toolkit-lib";
1010
import {
11+
loadBootstrapTemplate,
1112
loadCdkToolkit,
1213
performCdkOperation,
1314
runCdk,
@@ -24,6 +25,15 @@ function message(level: string, text: string): IoMessage<unknown> {
2425

2526
const lib: CdkToolkitLib = toolkit;
2627

28+
// What a compiled executable holds: the toolkit's template, under the name the
29+
// build's asset naming gives it, beside the assets it also embeds.
30+
function embedded(text: string): (Blob & { name: string })[] {
31+
return [
32+
new File(["unrelated"], "agentcore-assets/src/assets/cdk/cdk.json"),
33+
new File([text], "agentcore-assets/lib/api/bootstrap/bootstrap-template.yaml"),
34+
];
35+
}
36+
2737
async function collect(generator: AsyncGenerator<CdkEvent, void>): Promise<CdkEvent[]> {
2838
const events: CdkEvent[] = [];
2939
for await (const event of generator) events.push(event);
@@ -128,6 +138,10 @@ describe("performCdkOperation", () => {
128138
{ assemblyDirectory: "/unused", region: "us-east-1" },
129139
);
130140

141+
// Nothing is embedded when running from source, so the toolkit reads its own
142+
// template rather than one written out for it.
143+
expect(calls[0]!.args[1]).not.toHaveProperty("source");
144+
131145
expect(calls.map(({ method }) => method)).toEqual(["bootstrap"]);
132146
const [environments, options] = calls[0]!.args as [
133147
{ getEnvironments: () => Promise<{ name: string; account: string; region: string }[]> },
@@ -141,6 +155,52 @@ describe("performCdkOperation", () => {
141155
expect(options.parameters.parameters).toEqual({ createCustomerMasterKey: true });
142156
});
143157

158+
test("bootstraps a compiled executable from the template it has embedded", async () => {
159+
// The released-binary path: no node_modules, so the toolkit cannot find its own
160+
// template and is handed the embedded copy instead.
161+
let templateFile: string | undefined;
162+
let uploaded: string | undefined;
163+
const toolkit = {
164+
bootstrap: async (_environments: unknown, options: { source?: { templateFile: string } }) => {
165+
templateFile = options.source?.templateFile;
166+
// Read here rather than after: the file lives only for the operation.
167+
uploaded = await Bun.file(templateFile!).text();
168+
},
169+
} as unknown as CdkToolkit;
170+
171+
await performCdkOperation(
172+
{ lib, toolkit },
173+
{ kind: "bootstrap", environments: ["aws://111122223333/us-east-1"] },
174+
{ assemblyDirectory: "/unused", region: "us-east-1" },
175+
embedded("Resources: {}"),
176+
);
177+
178+
expect(uploaded).toBe("Resources: {}");
179+
// Cleaned up, so a long-lived process bootstrapping repeatedly leaves nothing behind.
180+
expect(await Bun.file(templateFile!).exists()).toBe(false);
181+
});
182+
183+
test("removes the template it wrote out even when bootstrap fails", async () => {
184+
let templateFile: string | undefined;
185+
const toolkit = {
186+
bootstrap: async (_environments: unknown, options: { source?: { templateFile: string } }) => {
187+
templateFile = options.source?.templateFile;
188+
throw new Error("bootstrap stack rollback complete");
189+
},
190+
} as unknown as CdkToolkit;
191+
192+
await expect(
193+
performCdkOperation(
194+
{ lib, toolkit },
195+
{ kind: "bootstrap", environments: ["aws://111122223333/us-east-1"] },
196+
{ assemblyDirectory: "/unused", region: "us-east-1" },
197+
embedded("Resources: {}"),
198+
),
199+
).rejects.toThrow(/rollback complete/);
200+
201+
expect(await Bun.file(templateFile!).exists()).toBe(false);
202+
});
203+
144204
test("deploys only the named stack, from the assembly build synthesized", async () => {
145205
const { toolkit, calls } = stubToolkit();
146206

@@ -175,6 +235,23 @@ describe("performCdkOperation", () => {
175235
});
176236
});
177237

238+
describe("loadBootstrapTemplate", () => {
239+
test("writes the embedded template where the toolkit can read it", async () => {
240+
const path = await loadBootstrapTemplate(embedded("Resources: {}"));
241+
242+
// A path, not bytes: the toolkit takes a template file to upload.
243+
expect(path).toBeString();
244+
expect(await Bun.file(path!).text()).toBe("Resources: {}");
245+
await rm(dirname(path!), { recursive: true, force: true });
246+
});
247+
248+
test("reports no template when nothing is embedded", async () => {
249+
// Running from source or the bundle, where the toolkit package is a real
250+
// directory on disk and finds its own template.
251+
expect(await loadBootstrapTemplate([])).toBeUndefined();
252+
});
253+
});
254+
178255
describe("loadCdkToolkit", () => {
179256
test("builds a toolkit without needing credentials", async () => {
180257
// Constructing the toolkit resolves no credentials and calls no API, which is

src/io/cdk.ts

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
// Programmatic CDK operations via @aws-cdk/toolkit-lib. Deploy drives the toolkit
22
// in-process rather than shelling out to `npx cdk`, so its progress arrives as
33
// structured messages and its failures as typed errors instead of scraped stdout.
4+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
5+
import { dirname, join } from "node:path";
6+
import { tmpdir } from "node:os";
47
import type { IIoHost, IoMessageLevel, Toolkit } from "@aws-cdk/toolkit-lib";
58

69
/** A message emitted by the toolkit while an operation runs. */
@@ -47,15 +50,52 @@ export type CdkToolkit = Pick<Toolkit, "bootstrap" | "fromAssemblyDirectory" | "
4750
/** The parts of the toolkit package an operation needs, loaded on demand. */
4851
export type CdkToolkitLib = Pick<
4952
typeof import("@aws-cdk/toolkit-lib"),
50-
"BootstrapEnvironments" | "BootstrapStackParameters" | "StackSelectionStrategy"
53+
| "BootstrapEnvironments"
54+
| "BootstrapSource"
55+
| "BootstrapStackParameters"
56+
| "StackSelectionStrategy"
5157
>;
5258

59+
/** An embedded file, whose name Bun's types widen away on Bun.embeddedFiles. */
60+
type NamedBlob = Blob & { readonly name: string };
61+
62+
const BOOTSTRAP_TEMPLATE = "bootstrap-template.yaml";
63+
64+
/** The files compiled into this executable, or none when running from source or a bundle. */
65+
function embeddedFiles(): readonly NamedBlob[] {
66+
return typeof Bun === "undefined" ? [] : (Bun.embeddedFiles as readonly NamedBlob[]);
67+
}
68+
69+
/**
70+
* Writes the embedded bootstrap template to a file the toolkit can read, or returns
71+
* undefined when it should read its own.
72+
*
73+
* Bootstrap uploads a CloudFormation template that the toolkit ships as a file in its
74+
* package directory, found at runtime relative to where that package sits on disk. A
75+
* compiled executable has no node_modules, and the path it was compiled with belongs
76+
* to the build machine, so the build embeds the template and bootstrap is pointed at
77+
* a copy of it. Everywhere else the package is a real directory and finds its own.
78+
*/
79+
export async function loadBootstrapTemplate(
80+
files: readonly NamedBlob[] = embeddedFiles(),
81+
): Promise<string | undefined> {
82+
const template = files.find((file) => file.name.endsWith(BOOTSTRAP_TEMPLATE));
83+
if (!template) return undefined;
84+
85+
const directory = await mkdtemp(join(tmpdir(), "agentcore-bootstrap-"));
86+
const path = join(directory, BOOTSTRAP_TEMPLATE);
87+
await writeFile(path, await template.text());
88+
return path;
89+
}
90+
5391
/**
5492
* Loads the toolkit package and builds a toolkit that reports to `ioHost`.
5593
*
5694
* Loaded here rather than imported at module scope: the toolkit is the heaviest
5795
* dependency in the CLI and `src/io` is reachable from every command, so a static
5896
* import would make even `agentcore --help` pay to load a deploy it is not doing.
97+
* The bundle keeps the package external, so a command that never deploys never
98+
* reads it; a compiled executable has it inlined and pays only to parse it.
5999
*/
60100
export async function loadCdkToolkit(
61101
ioHost: IIoHost,
@@ -72,18 +112,30 @@ export async function loadCdkToolkit(
72112
return { lib, toolkit };
73113
}
74114

75-
/** Performs one operation, awaiting the toolkit call it maps to. */
115+
/**
116+
* Performs one operation, awaiting the toolkit call it maps to.
117+
*
118+
* `files` is what this executable has embedded, taken as an argument so a test can
119+
* exercise the compiled executable's bootstrap path without being one.
120+
*/
76121
export async function performCdkOperation(
77122
{ lib, toolkit }: { lib: CdkToolkitLib; toolkit: CdkToolkit },
78123
operation: CdkOperation,
79124
options: CdkRunOptions,
125+
files: readonly NamedBlob[] = embeddedFiles(),
80126
): Promise<void> {
81127
if (operation.kind === "bootstrap") {
82-
await toolkit.bootstrap(lib.BootstrapEnvironments.fromList(operation.environments), {
83-
// Provisions a customer-managed KMS key for the staging bucket, matching
84-
// the parameters the original CLI bootstraps with.
85-
parameters: lib.BootstrapStackParameters.withExisting({ createCustomerMasterKey: true }),
86-
});
128+
const template = await loadBootstrapTemplate(files);
129+
try {
130+
await toolkit.bootstrap(lib.BootstrapEnvironments.fromList(operation.environments), {
131+
// Provisions a customer-managed KMS key for the staging bucket, matching
132+
// the parameters the original CLI bootstraps with.
133+
parameters: lib.BootstrapStackParameters.withExisting({ createCustomerMasterKey: true }),
134+
...(template && { source: lib.BootstrapSource.customTemplate(template) }),
135+
});
136+
} finally {
137+
if (template) await rm(dirname(template), { recursive: true, force: true });
138+
}
87139
return;
88140
}
89141

0 commit comments

Comments
 (0)