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
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");
}
}
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";
40 changes: 40 additions & 0 deletions src/core/project/manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { join, relative } from "node:path";
import type {
AddResourceInput,
CreateProjectInput,
DeployProjectInput,
DeployResult,
ResolveProjectInput,
Project,
ProjectManager,
Expand Down Expand Up @@ -33,6 +35,9 @@ import type { HarnessSpecSchema } from "../../projectSchemas/harness";
import z from "zod";
import { CdkBackend } from "./backends/cdk";
import type { ProjectBackend } from "./backends/types";
import { AwsDeploymentTargetsSchema } from "../../projectSchemas/aws-targets";

const TARGETS_EXAMPLE = '[{ "name": "default", "account": "111122223333", "region": "us-east-1" }]';

type ProjectManagerConfig = {
logger: Logger;
Expand Down Expand Up @@ -282,6 +287,41 @@ export class FsProjectManager implements ProjectManager {
yield* this.backendFor(project).build(project);
}

// Resolves the named target from aws-targets.json before handing off, so the
// backend receives a fully resolved account and region and never has to know
// how targets are stored. The backend owns everything after that point.
public async *deploy(
project: Project,
input: DeployProjectInput,
): AsyncGenerator<ProjectEvent, DeployResult> {
const targetsPath = join(project.rootPath, "agentcore", "aws-targets.json");
if (!existsSync(targetsPath)) {
throw new ProjectStateError(
`No deployment targets are configured for project '${project.name}'. ` +
`Add ${targetsPath}, for example:\n\n${TARGETS_EXAMPLE}`,
);
}

const targets = await this.json.read(targetsPath, AwsDeploymentTargetsSchema);
Comment thread
notgitika marked this conversation as resolved.

if (targets.length === 0) {
throw new ProjectStateError(
`No deployment targets are configured for project '${project.name}'. ` +
`Add at least one to ${targetsPath}, for example:\n\n${TARGETS_EXAMPLE}`,
);
}

const target = targets.find((candidate) => candidate.name === input.target);
if (!target) {
throw new ProjectStateError(
`Project '${project.name}' has no deployment target named '${input.target}'. ` +
`${targetsPath} defines: ${targets.map(({ name }) => name).join(", ")}.`,
);
}

return yield* this.backendFor(project).deploy(project, { target });
}

private backendFor(project: Project): ProjectBackend {
const backend = this.backends[project.spec.managedBy];
if (!backend) {
Expand Down
24 changes: 14 additions & 10 deletions src/errors/errors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,17 +107,21 @@ export class SourceResolutionError extends InputValidationError {
}
}

type DeserializationErrorOptions = Omit<AgentCoreCLIErrorOptions, "source"> & {
/**
* Why the file could not be read. Required because the root handler prints
* only `error.message`: a detail left in `cause` never reaches the user, and
* these files are hand-edited, so naming the file without naming the bad
* field leaves them nothing to act on.
*/
details: string;
};

export class DeserializationError extends AgentCoreCLIError {
constructor(
path: string,
options?: Omit<AgentCoreCLIErrorOptions, "source"> & {
/** Rendered reasons the payload was rejected, appended so the user sees which field to fix. */
detail?: string;
},
) {
const detail = options?.detail ? `\n${options.detail}` : "";
super(`Failed to deserialize file at "${path}"${detail}`, {
...options,
constructor(path: string, options: DeserializationErrorOptions) {
const { details, ...errorOptions } = options;
super(`Failed to deserialize file at "${path}":\n\n${details}`, {
...errorOptions,
source: ERROR_SOURCE.USER,
});
this.name = "DeserializationError";
Expand Down
195 changes: 195 additions & 0 deletions src/handlers/project/deploy/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createRootHandler } from "../../index";
import {
createSilentLogger,
TestCoreClient,
TestGlobalConfigAccessor,
testIO,
} from "../../../testing";
import type { DeployBackendInput, ProjectBackend } from "../../../core/project";
import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets";
import type { DeployResult, Project, ProjectEvent } from "../types";

const DEFAULT_TARGET: AwsDeploymentTarget = {
name: "default",
account: "111122223333",
region: "us-east-1",
};
const STAGING_TARGET: AwsDeploymentTarget = {
name: "staging",
account: "444455556666",
region: "eu-west-1",
};
const TARGETS = [DEFAULT_TARGET, STAGING_TARGET];

/**
* A ProjectBackend that deploys successfully, which CdkBackend cannot do until
* CDK deployment is implemented. Stubbing the backend rather than the whole
* manager keeps the real FsProjectManager in the path, so target resolution and
* withProject run for real.
*/
function fakeBackend(result: DeployResult, events: ProjectEvent[] = []) {
const calls: { project: Project; input: DeployBackendInput }[] = [];
const backend: ProjectBackend = {
async *build() {},
async *deploy(project, input) {
calls.push({ project, input });
yield* events;
return result;
},
};
return { calls, backend };
}

function testDeployCommand(result: DeployResult, events: ProjectEvent[] = []) {
const io = testIO();
const fake = fakeBackend(result, events);
const core = new TestCoreClient({ backends: { CDK: fake.backend } });
const root = createRootHandler(core, {
io: io.io,
globalConfigAccessor: new TestGlobalConfigAccessor(),
logger: createSilentLogger(),
});

return {
...fake,
io,
run: (args: string[] = []) => root.route(["node", "agentcore", "project", "deploy", ...args]),
create: (args: string[]) => root.route(["node", "agentcore", "project", ...args]),
};
}

const originalCwd = process.cwd();
const tempDirectories: string[] = [];

async function inTempDirectory(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), "agentcore-deploy-"));
tempDirectories.push(directory);
process.chdir(directory);
// cwd is the realpath (macOS tmpdir lives behind a /var -> /private/var
// symlink), matching the paths the manager derives from process.cwd().
return process.cwd();
}

afterEach(async () => {
process.chdir(originalCwd);
await Promise.all(
tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
);
});

/** Scaffolds a project whose aws-targets.json holds exactly `contents`, and cds into it. */
async function inProjectWithTargets(
subject: ReturnType<typeof testDeployCommand>,
contents: string = JSON.stringify(TARGETS),
): Promise<string> {
const directory = await inTempDirectory();
await subject.create(["create", "--name", "orders", "--skip-install", "--skip-git"]);
const projectRoot = join(directory, "orders");
await writeFile(join(projectRoot, "agentcore", "aws-targets.json"), contents);
process.chdir(projectRoot);
return projectRoot;
}

describe("project deploy handler", () => {
test("defaults to the default target and keeps progress off stdout", async () => {
const subject = testDeployCommand(
{ outputs: { ZetaUrl: "https://zeta.example", AlphaArn: "arn:alpha" } },
[{ message: "Preparing deployment" }, { message: "Deploying stack" }],
);
await inProjectWithTargets(subject);

await subject.run();

expect(subject.calls.map(({ input }) => input)).toEqual([{ target: DEFAULT_TARGET }]);
expect(subject.io.stderr()).toContain("Preparing deployment\nDeploying stack");
expect(subject.io.stderr()).toContain("Deployed project 'orders' to target 'default'");
expect(subject.io.stdout()).toBe("AlphaArn: arn:alpha\nZetaUrl: https://zeta.example");
});

test("passes an explicit target and renders the result as JSON", async () => {
const result = { outputs: { ServiceUrl: "https://service.example" } };
const subject = testDeployCommand(result);
await inProjectWithTargets(subject);

await subject.run(["--target", "staging", "--json"]);

expect(subject.calls.map(({ input }) => input)).toEqual([{ target: STAGING_TARGET }]);
expect(JSON.parse(subject.io.stdout())).toEqual(result);
});

test("rejects an unknown target without invoking the backend", async () => {
const subject = testDeployCommand({ outputs: {} });
await inProjectWithTargets(subject);

await expect(subject.run(["--target", "nope"])).rejects.toThrow(
/no deployment target named 'nope'/,
);
expect(subject.calls).toEqual([]);
});

test("requires deployment targets to be configured", async () => {
const subject = testDeployCommand({ outputs: {} });
await inProjectWithTargets(subject, JSON.stringify([]));

await expect(subject.run()).rejects.toThrow(/No deployment targets are configured/);
expect(subject.calls).toEqual([]);
});
});

/** The message the user would see on stderr, since the reporter prints only that. */
async function messageFrom(command: Promise<void>): Promise<string> {
try {
await command;
} catch (error) {
return (error as Error).message;
}
throw new Error("expected the command to fail");
}

describe("project deploy reports which field of aws-targets.json is wrong", () => {
test("names the offending field for an unsupported region", async () => {
const subject = testDeployCommand({ outputs: {} });
await inProjectWithTargets(
subject,
JSON.stringify([{ name: "default", account: "111122223333", region: "us-east-11" }]),
);

const message = await messageFrom(subject.run());

expect(message).toContain("aws-targets.json");
expect(message).toContain("at [0].region");
expect(message).toContain('"us-east-1"');
expect(subject.calls).toEqual([]);
});

test("surfaces the duplicate target name", async () => {
const subject = testDeployCommand({ outputs: {} });
await inProjectWithTargets(subject, JSON.stringify([DEFAULT_TARGET, DEFAULT_TARGET]));

await expect(subject.run()).rejects.toThrow(/Duplicate deployment target name: default/);
expect(subject.calls).toEqual([]);
});

test("surfaces the account id rule", async () => {
const subject = testDeployCommand({ outputs: {} });
await inProjectWithTargets(
subject,
JSON.stringify([{ name: "default", account: "123", region: "us-east-1" }]),
);

await expect(subject.run()).rejects.toThrow(/AWS account ID must be exactly 12 digits/);
expect(subject.calls).toEqual([]);
});

test("surfaces the parse error for malformed json", async () => {
const subject = testDeployCommand({ outputs: {} });
await inProjectWithTargets(subject, '[{ "name": "default", }]');

await expect(subject.run()).rejects.toThrow(/JSON Parse error/);
expect(subject.calls).toEqual([]);
});
});
45 changes: 40 additions & 5 deletions src/handlers/project/deploy/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,46 @@
import { createHandler } from "../../../router";
import { NotImplementedError } from "../../../errors";
import z from "zod";
import type { AppIO } from "../../../io";
import { createHandler, flag, ProjectKey } from "../../../router";
import { JsonRendererKey } from "../../../tui";
import { JsonKey } from "../../keys";
import type { ProjectManager } from "../types";

export const createDeployProjectHandler = () =>
type DeployProjectHandlerConfig = {
projectManager: ProjectManager;
io: AppIO;
};

export const createDeployProjectHandler = (config: DeployProjectHandlerConfig) =>
createHandler({
name: "deploy",
description: "deploy the project to AWS",
handle: async () => {
throw new NotImplementedError("agentcore project deploy is not implemented yet");
flags: [
flag("target", "name of the aws-targets.json entry to deploy", z.string().default("default")),
],
handle: async (ctx, flags) => {
// withProject has already resolved the enclosing project.
const project = ctx.require(ProjectKey);

// Progress goes to stderr, keeping stdout for machine output. Driven by
// hand rather than `for await` because the outputs we render below are the
// generator's return value, which `for await` discards.
const deployment = config.projectManager.deploy(project, { target: flags.target });
let next = await deployment.next();
while (!next.done) {
config.io.stderr.write(`${next.value.message}\n`);
next = await deployment.next();
}
const result = next.value;

config.io.stderr.write(`Deployed project '${project.name}' to target '${flags.target}'\n`);
if (ctx.require(JsonKey)) {
ctx.require(JsonRendererKey).renderJson(result);
return;
}
for (const [key, value] of Object.entries(result.outputs).sort(([a], [b]) =>
a.localeCompare(b),
)) {
config.io.stdout.write(`${key}: ${value}\n`);
}
},
});
Loading
Loading