Skip to content

Commit a9c153d

Browse files
authored
feat(cloud): POST /api/v1/apps/[id]/deploy + GET …/deploy/status (#7804)
* chore(cloud-api): fix codegen path separators on Windows `_generate-router.mjs` was leaving Windows-style backslash separators in the result of `path.relative()`, which made `split("/")` collapse every route path to just "/api" when run from a Windows host. Normalize the relative path to forward slashes in `fileToHttpPaths`, `importIdent`, and `importPath` so the generator produces the same output regardless of host OS. No-op on POSIX. * feat(cloud-shared): app-deployments service Backs the new `POST /api/v1/apps/:id/deploy` and `GET /api/v1/apps/:id/deploy/status` routes. The persisted source of truth is the `apps` table itself — `deployment_status`, `production_url`, and `last_deployed_at` columns added in migration 0007. A deployment is identified by `<appId>:<last_deployed_at_iso>` so the CLI can correlate POST → GET polls without a separate `deployments` table. Pure helpers (status enum mapping, deployment-id composition) live in `app-deployments-helpers.ts` so unit tests can import them without pulling in the Drizzle-backed `appsService` and the rest of the runtime. When a real build/upload service (Vercel or otherwise) lands, this service becomes the integration boundary — callers will not need to change. * feat(cloud): POST /api/v1/apps/[id]/deploy + GET …/deploy/status Completes the cloud half of `elizaos deploy` (PR #7786). The CLI keel from that PR drives this endpoint pair: build → upload → POST /deploy → attach domain → poll GET /deploy/status every 5s until READY or ERROR (10-minute cap on the CLI side). Both routes follow the existing `apps/[id]/domains/route.ts` pattern exactly: - Hono handler with `requireUserOrApiKeyWithOrg(c)` for auth - 404 when the app does not exist, 403 when the caller is authed but not the owning org - thin wrapper over `appDeploymentsService`; ownership invariant is enforced at the route layer (mirrors the domains/managed-domains split) Request schema lives in a sibling `schema.ts` so unit tests can import it without pulling in the route's `@/lib/*` aliased imports. Pure-helper tests under `packages/cloud-api/__tests__/` cover: - empty body acceptance - full body shape acceptance - repoUrl / ref / env value validation - every enum value of the persisted → public status map - deployment-id composition (with and without `last_deployed_at`) Route-level happy-path / auth / not-found scenarios are exercised by the existing e2e suite once `TEST_API_BASE_URL` is set. * fix(cloud-shared): import AppDeploymentStatus from schema + drop dead QUEUED variant Greptile P2 cleanup on PR #7804. `AppDeploymentStatus` was redefined locally in app-deployments-helpers despite being exported from `db/schemas/apps`, creating silent drift risk if the persisted enum gained a new value (e.g. "cancelling"). Now re-exported via a type-only import so Drizzle runtime is not pulled in. `QUEUED` was in the public `DeploymentStatus` union but no code path can ever emit it — PERSISTED_TO_PUBLIC only produces BUILDING / READY / ERROR / DRAFT. Removed from the public contract. The 3 remaining P2s (double getById, concurrent build race, surfacing error field) are design-sized and deferred to the follow-up build-pipeline wiring PR. * fix(cloud): include startedAt: null in deploy status no-record branch The non-null record branch always returns `startedAt`, but the no-record branch omitted it entirely. A typed CLI client that reads `response.startedAt` unconditionally would receive `undefined` in the no-prior-deployment case with no TypeScript error at the call site (plain JSON). Keep the response shape uniform. Addresses Greptile round-2 P1 on PR #7804. * fix(cloud): reject POST /deploy with 409 when a build is already in flight Two concurrent CLI invocations of `elizaos deploy` both went through unguarded: createDeployment stamped `last_deployed_at` twice, the loser's `deploymentId` was immediately stale, and its subsequent GET /status polls would return the winner's record without any signal that the original POST got co-opted. Adds `assertDeployable` helper that throws ApiError(409) when the app's persisted `deployment_status` is already "building". `createDeployment` calls it after a (cache-hot) `getById`, so the race surfaces to the caller as a 409 instead of silently overwriting. This is a check-then-act guard, not a DB-level lock — a fully race- free path would do a conditional UPDATE in the apps repo. For the realistic CLI cadence (invocations seconds apart) this is sufficient. Addresses Greptile round-1 P2 on PR #7804. 8/8 helper unit tests green.
1 parent 8a55615 commit a9c153d

9 files changed

Lines changed: 517 additions & 4 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* Pure-helper tests for the app-deployments service + deploy route schema.
3+
*
4+
* Mirrors the contract these two routes wire together:
5+
* POST /api/v1/apps/:id/deploy
6+
* GET /api/v1/apps/:id/deploy/status
7+
*
8+
* Route-level happy-path / auth / not-found scenarios are exercised by the
9+
* existing e2e suite under packages/cloud-api/test/e2e (which has live
10+
* Postgres + Redis). The helpers below are pure and run without any I/O.
11+
*/
12+
13+
import { describe, expect, test } from "bun:test";
14+
15+
import {
16+
deploymentIdFor,
17+
publicStatusFor,
18+
} from "@elizaos/cloud-shared/lib/services/app-deployments-helpers.ts";
19+
import { DeployBodySchema } from "../v1/apps/[id]/deploy/schema";
20+
21+
describe("DeployBodySchema", () => {
22+
test("accepts an empty body — all fields are optional", () => {
23+
const parsed = DeployBodySchema.safeParse({});
24+
expect(parsed.success).toBe(true);
25+
});
26+
27+
test("accepts the full body shape", () => {
28+
const parsed = DeployBodySchema.safeParse({
29+
repoUrl: "https://github.com/2-A-M/example",
30+
ref: "main",
31+
dockerfile: "./Dockerfile",
32+
env: { FOO: "bar", BAZ: "qux" },
33+
});
34+
expect(parsed.success).toBe(true);
35+
if (parsed.success) {
36+
expect(parsed.data.repoUrl).toBe("https://github.com/2-A-M/example");
37+
expect(parsed.data.ref).toBe("main");
38+
expect(parsed.data.dockerfile).toBe("./Dockerfile");
39+
expect(parsed.data.env).toEqual({ FOO: "bar", BAZ: "qux" });
40+
}
41+
});
42+
43+
test("rejects a non-URL repoUrl", () => {
44+
const parsed = DeployBodySchema.safeParse({ repoUrl: "not-a-url" });
45+
expect(parsed.success).toBe(false);
46+
});
47+
48+
test("rejects an empty ref string", () => {
49+
const parsed = DeployBodySchema.safeParse({ ref: "" });
50+
expect(parsed.success).toBe(false);
51+
});
52+
53+
test("rejects an env entry with a non-string value", () => {
54+
const parsed = DeployBodySchema.safeParse({
55+
env: { FOO: 42 as unknown as string },
56+
});
57+
expect(parsed.success).toBe(false);
58+
});
59+
});
60+
61+
describe("publicStatusFor", () => {
62+
test("maps every persisted enum value to a public status", () => {
63+
expect(publicStatusFor("draft")).toBe("DRAFT");
64+
expect(publicStatusFor("building")).toBe("BUILDING");
65+
expect(publicStatusFor("deploying")).toBe("BUILDING");
66+
expect(publicStatusFor("deployed")).toBe("READY");
67+
expect(publicStatusFor("failed")).toBe("ERROR");
68+
});
69+
});
70+
71+
describe("deploymentIdFor", () => {
72+
test("composes `${appId}:${lastDeployedAt.toISOString()}`", () => {
73+
const id = deploymentIdFor({
74+
id: "abc-123",
75+
last_deployed_at: new Date("2026-05-19T12:00:00.000Z"),
76+
});
77+
expect(id).toBe("abc-123:2026-05-19T12:00:00.000Z");
78+
});
79+
80+
test("uses the literal '0' when last_deployed_at is null", () => {
81+
const id = deploymentIdFor({
82+
id: "abc-123",
83+
last_deployed_at: null,
84+
});
85+
expect(id).toBe("abc-123:0");
86+
});
87+
});

packages/cloud-api/src/_generate-router.mjs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ function joinHttpPath(segments) {
5555
}
5656

5757
export function fileToHttpPaths(filePath, apiRoot = API_ROOT) {
58-
const rel = relative(apiRoot, filePath);
58+
const rel = relative(apiRoot, filePath).replace(/\\/g, "/");
5959
const segments = rel.split("/").slice(0, -1);
6060
const out = ["/api"];
6161
for (const seg of segments) {
@@ -77,7 +77,9 @@ export function fileToHttpPaths(filePath, apiRoot = API_ROOT) {
7777
}
7878

7979
export function importIdent(filePath, apiRoot = API_ROOT) {
80-
const rel = relative(apiRoot, filePath).replace(/\.tsx?$/, "");
80+
const rel = relative(apiRoot, filePath)
81+
.replace(/\\/g, "/")
82+
.replace(/\.tsx?$/, "");
8183
return (
8284
"_route_" +
8385
rel
@@ -91,7 +93,9 @@ export function importIdent(filePath, apiRoot = API_ROOT) {
9193
}
9294

9395
export function importPath(filePath) {
94-
const rel = relative(__dirname, filePath).replace(/\.tsx?$/, "");
96+
const rel = relative(__dirname, filePath)
97+
.replace(/\\/g, "/")
98+
.replace(/\.tsx?$/, "");
9599
return rel.startsWith(".") ? rel : `./${rel}`;
96100
}
97101

packages/cloud-api/src/_router.generated.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* AUTO-GENERATED by src/_generate-router.mjs - do not edit by hand.
33
* Re-run `bun run codegen` after adding or removing a route.ts file.
44
*
5-
* 556 routes mounted, 0 skipped (still Next-shaped).
5+
* 558 routes mounted, 0 skipped (still Next-shaped).
66
*/
77

88
/* eslint-disable */
@@ -274,6 +274,8 @@ import _route_v1_apps_p_id_charges_p_chargeId_checkout_route from "../v1/apps/[i
274274
import _route_v1_apps_p_id_charges_p_chargeId_route from "../v1/apps/[id]/charges/[chargeId]/route";
275275
import _route_v1_apps_p_id_charges_route from "../v1/apps/[id]/charges/route";
276276
import _route_v1_apps_p_id_chat_route from "../v1/apps/[id]/chat/route";
277+
import _route_v1_apps_p_id_deploy_status_route from "../v1/apps/[id]/deploy/status/route";
278+
import _route_v1_apps_p_id_deploy_route from "../v1/apps/[id]/deploy/route";
277279
import _route_v1_apps_p_id_discord_automation_post_route from "../v1/apps/[id]/discord-automation/post/route";
278280
import _route_v1_apps_p_id_discord_automation_route from "../v1/apps/[id]/discord-automation/route";
279281
import _route_v1_apps_p_id_domains_buy_route from "../v1/apps/[id]/domains/buy/route";
@@ -1258,6 +1260,11 @@ export function mountRoutes(app: Hono<AppEnv>): void {
12581260
);
12591261
app.route("/api/v1/apps/:id/charges", _route_v1_apps_p_id_charges_route);
12601262
app.route("/api/v1/apps/:id/chat", _route_v1_apps_p_id_chat_route);
1263+
app.route(
1264+
"/api/v1/apps/:id/deploy/status",
1265+
_route_v1_apps_p_id_deploy_status_route,
1266+
);
1267+
app.route("/api/v1/apps/:id/deploy", _route_v1_apps_p_id_deploy_route);
12611268
app.route(
12621269
"/api/v1/apps/:id/discord-automation/post",
12631270
_route_v1_apps_p_id_discord_automation_post_route,
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* POST /api/v1/apps/:id/deploy
3+
*
4+
* Kicks off a deploy for the app. Body is fully optional — defaults pull
5+
* from the app's linked GitHub repo and stored env config:
6+
*
7+
* { repoUrl?: string; ref?: string; dockerfile?: string;
8+
* env?: Record<string, string> }
9+
*
10+
* Completes the cloud half of `elizaos deploy` (PR #7786). The CLI keel
11+
* from that PR drives this endpoint: build → upload → POST here →
12+
* attach domain → poll GET /deploy/status until READY or ERROR.
13+
*/
14+
15+
import { Hono } from "hono";
16+
import { failureResponse } from "@/lib/api/cloud-worker-errors";
17+
import { requireUserOrApiKeyWithOrg } from "@/lib/auth/workers-hono-auth";
18+
import { appDeploymentsService } from "@/lib/services/app-deployments";
19+
import { appsService } from "@/lib/services/apps";
20+
import { logger } from "@/lib/utils/logger";
21+
import type { AppEnv } from "@/types/cloud-worker-env";
22+
import { DeployBodySchema } from "./schema";
23+
24+
const app = new Hono<AppEnv>();
25+
26+
app.post("/", async (c) => {
27+
try {
28+
const user = await requireUserOrApiKeyWithOrg(c);
29+
const appId = c.req.param("id");
30+
if (!appId) {
31+
return c.json({ success: false, error: "Missing app id" }, 400);
32+
}
33+
34+
const appRow = await appsService.getById(appId);
35+
if (!appRow) {
36+
return c.json({ success: false, error: "App not found" }, 404);
37+
}
38+
if (appRow.organization_id !== user.organization_id) {
39+
// 403 — the caller is authed but not the owning org.
40+
return c.json({ success: false, error: "Access denied" }, 403);
41+
}
42+
43+
// Body is fully optional — accept an empty/absent body as `{}`.
44+
const rawBody: unknown = await c.req.json().catch(() => ({}));
45+
const parsed = DeployBodySchema.safeParse(rawBody);
46+
if (!parsed.success) {
47+
return c.json(
48+
{
49+
success: false,
50+
error: parsed.error.issues[0]?.message ?? "Invalid request body",
51+
},
52+
400,
53+
);
54+
}
55+
56+
const record = await appDeploymentsService.createDeployment({
57+
appId,
58+
organizationId: user.organization_id,
59+
userId: user.id,
60+
...parsed.data,
61+
});
62+
63+
logger.info("[Deploy POST] deployment queued", {
64+
appId,
65+
deploymentId: record.deploymentId,
66+
userId: user.id,
67+
organizationId: user.organization_id,
68+
});
69+
70+
return c.json(
71+
{
72+
success: true,
73+
deploymentId: record.deploymentId,
74+
status: record.status,
75+
startedAt: record.startedAt,
76+
},
77+
202,
78+
);
79+
} catch (error) {
80+
return failureResponse(c, error);
81+
}
82+
});
83+
84+
export default app;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Request schema for `POST /api/v1/apps/:id/deploy`.
3+
*
4+
* Lives in a sibling module so unit tests can import the schema without
5+
* pulling in the route's `@/lib/*` aliased imports (Bun's test runner does
6+
* not resolve TypeScript path aliases).
7+
*/
8+
import { z } from "zod";
9+
10+
export const DeployBodySchema = z.object({
11+
repoUrl: z.string().url().optional(),
12+
ref: z.string().min(1).max(255).optional(),
13+
dockerfile: z.string().min(1).max(255).optional(),
14+
env: z.record(z.string(), z.string()).optional(),
15+
});
16+
17+
export type DeployBody = z.infer<typeof DeployBodySchema>;
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* GET /api/v1/apps/:id/deploy/status
3+
*
4+
* Returns the latest deployment record for the app. Polled by the
5+
* `elizaos deploy` CLI (PR #7786) every ~5s until status is READY or
6+
* ERROR (10-minute cap on the CLI side).
7+
*/
8+
9+
import { Hono } from "hono";
10+
import { failureResponse } from "@/lib/api/cloud-worker-errors";
11+
import { requireUserOrApiKeyWithOrg } from "@/lib/auth/workers-hono-auth";
12+
import { appDeploymentsService } from "@/lib/services/app-deployments";
13+
import { appsService } from "@/lib/services/apps";
14+
import type { AppEnv } from "@/types/cloud-worker-env";
15+
16+
const app = new Hono<AppEnv>();
17+
18+
app.get("/", async (c) => {
19+
try {
20+
const user = await requireUserOrApiKeyWithOrg(c);
21+
const appId = c.req.param("id");
22+
if (!appId) {
23+
return c.json({ success: false, error: "Missing app id" }, 400);
24+
}
25+
26+
const appRow = await appsService.getById(appId);
27+
if (!appRow) {
28+
return c.json({ success: false, error: "App not found" }, 404);
29+
}
30+
if (appRow.organization_id !== user.organization_id) {
31+
return c.json({ success: false, error: "Access denied" }, 403);
32+
}
33+
34+
const record = await appDeploymentsService.getLatestDeployment(appId);
35+
if (!record) {
36+
return c.json({
37+
success: true,
38+
deploymentId: null,
39+
status: "DRAFT" as const,
40+
vercelUrl: null,
41+
error: null,
42+
startedAt: null,
43+
});
44+
}
45+
46+
return c.json({
47+
success: true,
48+
deploymentId: record.deploymentId,
49+
status: record.status,
50+
vercelUrl: record.vercelUrl,
51+
error: record.error,
52+
startedAt: record.startedAt,
53+
});
54+
} catch (error) {
55+
return failureResponse(c, error);
56+
}
57+
});
58+
59+
export default app;
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* Covers the pure helpers backing the app-deployments service:
3+
*
4+
* - `publicStatusFor` — db enum → CLI-facing status
5+
* - `deploymentIdFor` — `<appId>:<iso-timestamp>` formatter
6+
* - `assertDeployable` — 409 guard against concurrent deploys
7+
*/
8+
import { describe, expect, test } from "bun:test";
9+
import { ApiError } from "../../api/cloud-worker-errors";
10+
import {
11+
assertDeployable,
12+
deploymentIdFor,
13+
publicStatusFor,
14+
} from "../app-deployments-helpers";
15+
16+
describe("publicStatusFor", () => {
17+
test("maps draft to DRAFT", () => {
18+
expect(publicStatusFor("draft")).toBe("DRAFT");
19+
});
20+
test("collapses building and deploying to BUILDING", () => {
21+
expect(publicStatusFor("building")).toBe("BUILDING");
22+
expect(publicStatusFor("deploying")).toBe("BUILDING");
23+
});
24+
test("maps deployed to READY", () => {
25+
expect(publicStatusFor("deployed")).toBe("READY");
26+
});
27+
test("maps failed to ERROR", () => {
28+
expect(publicStatusFor("failed")).toBe("ERROR");
29+
});
30+
});
31+
32+
describe("deploymentIdFor", () => {
33+
test("uses ISO timestamp when last_deployed_at is set", () => {
34+
const ts = new Date("2026-05-19T15:00:00.000Z");
35+
expect(deploymentIdFor({ id: "app_1", last_deployed_at: ts })).toBe(
36+
"app_1:2026-05-19T15:00:00.000Z",
37+
);
38+
});
39+
test("uses 0 sentinel when last_deployed_at is null", () => {
40+
expect(deploymentIdFor({ id: "app_2", last_deployed_at: null })).toBe("app_2:0");
41+
});
42+
});
43+
44+
describe("assertDeployable", () => {
45+
test("throws ApiError(409) when status is building", () => {
46+
expect(() => assertDeployable({ deployment_status: "building" })).toThrow(ApiError);
47+
try {
48+
assertDeployable({ deployment_status: "building" });
49+
} catch (err) {
50+
expect(err).toBeInstanceOf(ApiError);
51+
expect((err as ApiError).status).toBe(409);
52+
expect((err as ApiError).code).toBe("session_not_ready");
53+
}
54+
});
55+
56+
test("does not throw for draft / deployed / failed / deploying", () => {
57+
expect(() => assertDeployable({ deployment_status: "draft" })).not.toThrow();
58+
expect(() => assertDeployable({ deployment_status: "deployed" })).not.toThrow();
59+
expect(() => assertDeployable({ deployment_status: "failed" })).not.toThrow();
60+
// `deploying` is the immediate-after-build state — callers may want to
61+
// retry from a fresh deploy after a deploy-side failure during upload,
62+
// so we don't reject it here.
63+
expect(() => assertDeployable({ deployment_status: "deploying" })).not.toThrow();
64+
});
65+
});

0 commit comments

Comments
 (0)