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 @@ -49,6 +49,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
68 changes: 62 additions & 6 deletions scripts/build.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env bun

import { $ } from "bun";
import { existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { runWithExitCode } from "../src/runnable";

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

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

// Kept out of the bundle so the toolkit stays a real directory in node_modules at
// runtime: it reads its bootstrap template from its own package directory, which a
// bundle would rewrite to this machine's absolute path. The npm package declares it
// as a dependency, so installing the CLI installs it.
const EXTERNAL = ["@aws-cdk/toolkit-lib"];

// A compiled executable has no node_modules, so the template the toolkit would read
// from its package directory is embedded instead. See loadBootstrapTemplate.
const BOOTSTRAP_TEMPLATE = ["lib", "api", "bootstrap", "bootstrap-template.yaml"];

// 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 All @@ -26,14 +37,54 @@ function assetLoaderPlugin(): Bun.BunPlugin {
return {
name: "asset-file-loader",
setup(build) {
build.onLoad({ filter: /src[/\\]assets[/\\]/ }, async ({ path }) => ({
contents: await Bun.file(path).bytes(),
loader: "file",
}));
build.onLoad(
{ filter: /src[/\\]assets[/\\]|bootstrap-template\.yaml$/ },
async ({ path }) => ({
contents: await Bun.file(path).bytes(),
loader: "file",
}),
);
},
};
}

/**
* Absolute path of the toolkit's own bootstrap template.
*
* Resolved from the installed package rather than a copy in this repo, so the
* embedded template is always the one the toolkit being compiled in expects.
*/
function bootstrapTemplate(): string {
const manifest = Bun.resolveSync("@aws-cdk/toolkit-lib/package.json", REPO_ROOT);
const template = join(resolve(manifest, ".."), ...BOOTSTRAP_TEMPLATE);
if (!existsSync(template)) {
throw new Error(
`@aws-cdk/toolkit-lib no longer ships ${BOOTSTRAP_TEMPLATE.join("/")}; ` +
`bootstrap in a compiled executable reads the embedded copy, so this must be found. Looked in ${template}`,
);
}
return template;
}

/**
* Fail loudly unless the compiled executable carries the bootstrap template's bytes.
*
* Nothing reads that template until someone bootstraps an AWS account, so an
* executable that lost it looks healthy in every build check and fails in a user's
* first deploy instead.
*/
async function assertTemplateIsEmbedded(outfile: string, template: string): Promise<void> {
const [executable, bytes] = await Promise.all([
Bun.file(outfile).bytes(),
Bun.file(template).bytes(),
]);
if (!Buffer.from(executable).includes(bytes)) {
throw new Error(
`${outfile} does not carry ${BOOTSTRAP_TEMPLATE.join("/")}, so bootstrap would fail wherever it runs`,
);
}
}

/** Fail loudly on a non-UTF-8 asset — the source reads every asset as text. */
async function assertAssetsAreText(assets: string[]): Promise<void> {
const decoder = new TextDecoder("utf-8", { fatal: true });
Expand All @@ -54,6 +105,7 @@ async function bundle(): Promise<void> {
outdir: DIST,
target: "node",
minify: MINIFY,
external: EXTERNAL,
});

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

const template = bootstrapTemplate();
await Bun.build({
entrypoints: [ENTRYPOINT, ...assets],
entrypoints: [ENTRYPOINT, ...assets, template],
compile: { target: target as Bun.Build.CompileTarget, outfile },
minify: MINIFY,
root: REPO_ROOT,
naming: { asset: ASSET_NAMING },
plugins: [assetLoaderPlugin()],
});
console.log(`Compiled ${target} → ${outfile} (${assets.length} assets embedded)`);
await assertTemplateIsEmbedded(outfile, template);
console.log(
`Compiled ${target} → ${outfile} (${assets.length} assets embedded, plus the bootstrap template)`,
);
}

process.exit(
Expand Down
62 changes: 62 additions & 0 deletions src/core/project/assembly.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Reads the cloud assembly `build` synthesized, so deploy can find the stack that
// belongs to a deployment target.
import { existsSync } from "node:fs";
import { join } from "node:path";
import z from "zod";
import { ProjectStateError } from "../../errors/errors";
import type { ReadWriteJson } from "../../io";

// The tag the generated CDK app puts on every stack, naming the deployment target
// the stack was synthesized for. Selecting on it means the CLI never has to
// reproduce the app's stack-naming convention, so a project that renames its
// stacks still deploys.
const TARGET_TAG = "agentcore:target-name";

const STACK_ARTIFACT = "aws:cloudformation:stack";

// Only the parts of the manifest deploy reads. Artifacts are keyed by their
// hierarchical id, which is what the CDK toolkit matches stack patterns against.
const AssemblyManifestSchema = z.object({
artifacts: z
.record(
z.string(),
z.object({
type: z.string(),
properties: z.object({ tags: z.record(z.string(), z.string()).optional() }).optional(),
}),
)
.default({}),
});

/**
* The name of the stack in the synthesized assembly that belongs to `target`.
*
* The generated CDK app synthesizes one stack per deployment target and tags each
* with the target's name, so deploy asks the assembly which stack to ship rather
* than deriving the name itself and hoping the two agree.
*/
export async function stackForTarget(

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.

shouldn't the synth output the stack name? Is there a reason we need to re-derive it here?

json: ReadWriteJson,
assemblyDirectory: string,
target: string,
): Promise<string> {
const path = join(assemblyDirectory, "manifest.json");
// deploy synthesizes immediately before this, so a missing manifest means synth
// wrote somewhere else entirely rather than that the user skipped a step.
if (!existsSync(path)) {
throw new ProjectStateError(`No synthesized cloud assembly was found at ${path}.`);
}

const manifest = await json.read(path, AssemblyManifestSchema);
const stacks = Object.entries(manifest.artifacts).filter(
([, artifact]) => artifact.type === STACK_ARTIFACT,
);
const match = stacks.find(([, artifact]) => artifact.properties?.tags?.[TARGET_TAG] === target);
if (!match) {
throw new ProjectStateError(
`The synthesized cloud assembly has no stack for deployment target '${target}'. ` +
`${path} defines ${stacks.length} stack(s), none tagged ${TARGET_TAG}='${target}'.`,
);
}
return match[0];
}
Loading