diff --git a/README.md b/README.md index 5a382ad..ddf7563 100644 --- a/README.md +++ b/README.md @@ -119,12 +119,12 @@ to reach for this MCP, see . | `create_queue` | `POST /queue/new` — Provision a NATS JetStream queue (scoped subject namespace). Returns `connection_url` + `note`/`upgrade`. `name` required. | | `create_storage` | `POST /storage/new` — Provision an S3-compatible bucket prefix (DigitalOcean Spaces). Returns endpoint, access keys, prefix + `note`/`upgrade`. `name` required. | | `create_webhook` | `POST /webhook/new` — Provision an inbound webhook receiver URL. Returns `receive_url` + `note`/`upgrade`. `name` required. | -| `create_deploy` | `POST /deploy/new` — Upload a base64 gzip tarball (with Dockerfile) and deploy a container. Returns `deploy_id`, `status`, `url`, `build_logs_url`. `name` required. Requires `INSTANODE_TOKEN`. | +| `create_deploy` | `POST /deploy/new` — Upload a base64 gzip tarball (with Dockerfile) and deploy a container. Returns `deploy_id`, `status`, `url`, `build_logs_url`. `name` required. Pass `redeploy: true` (with the SAME `name`) to update an existing deployment IN PLACE (same app_id + URL). Requires `INSTANODE_TOKEN`. | | `create_stack` | `POST /stacks/new` — Multi-service bundle. Upload an `instant.yaml` manifest plus one base64 gzip tarball per service; returns `stack_id`, per-service URLs, and the 24h-TTL claim block on the anonymous tier. **Anonymous-friendly** (the wedge). `name`, `manifest`, `service_tarballs` required. | | `get_stack` | `GET /stacks/{stack_id}` — Poll a stack's per-service status + URLs. Anonymous-friendly. `stack_id` required. | | `list_deployments`| `GET /api/v1/deployments` — List all deployments on the caller's team. Requires `INSTANODE_TOKEN`. | | `get_deployment` | `GET /api/v1/deployments/:id` — Fetch one deployment (poll until `status="running"`). Requires `INSTANODE_TOKEN`. | -| `redeploy` | `POST /deploy/:id/redeploy` — Rebuild + rolling update an existing deployment. Requires `INSTANODE_TOKEN`. | +| `redeploy` | `POST /deploy/:id/redeploy` — Push updated code to an existing deployment BY ID. Same URL, new build. Requires `tarball_base64` (same shape as `create_deploy`) — the api never reuses the original tarball. For the more common "update by name" path prefer `create_deploy({ name, redeploy: true, tarball_base64 })`. Requires `INSTANODE_TOKEN`. | | `delete_deployment` | `DELETE /deploy/:id` — Tear down a running deployment. Irreversible. Requires `INSTANODE_TOKEN`. | | `claim_resource` | Helper — turn an `upgrade_jwt` from any `create_*` response into the dashboard claim URL the user should click. No API call. No auth required. | | `claim_token` | `POST /claim` — Programmatic claim: attach an anonymous resource to the authenticated account using its `upgrade_jwt` + `email`. No auth required. | @@ -186,6 +186,35 @@ params, which the agent host may log. `get_deployment({ id: deploy_id })` every few seconds until status flips to `"running"` (typical: ~30s). At that point the `url` field is the live URL. +### Updating an existing deployment (same URL, new build) + +To ship v2 of an app you already deployed without changing the URL or +`app_id`, call `create_deploy` again with the **same `name`** plus +`redeploy: true`: + +```json +{ + "tarball_base64": "...", + "name": "my-app", + "redeploy": true +} +``` + +The api finds the existing deployment by `(team_id, name)` and updates it +in place — same `app_id`, same `*.deployment.instanode.dev` URL, status +flips back to `building` while the new image rolls out. + +Without `redeploy: true`, calling `create_deploy` with a name you've used +before mints a **new** `app_id` and a **new** URL (the legacy behaviour). +This is the trap that caused the AGENT-UX issue where agents ended up +with two live deployments + two URLs for the same app. + +The standalone `redeploy` tool (by `id`, not `name`) still works and also +requires a `tarball_base64` — the api never reuses the original tarball. +Prefer the `create_deploy({ name, redeploy: true })` path when you have +the name; use `redeploy({ id, tarball_base64 })` when you only have the +deploy id. + ### Private deploys Set `private: true` and pass `allowed_ips` to restrict access to specific IPs diff --git a/src/client.ts b/src/client.ts index e116733..9b1f404 100644 --- a/src/client.ts +++ b/src/client.ts @@ -314,6 +314,21 @@ export interface CreateDeployParams { * shape (e.g. `allowed_cidrs`), reconcile post-merge. */ allowed_ips?: string[]; + /** + * In-place redeploy flag (api PR feat/deploy-new-redeploy-in-place). + * When true AND `name` matches an existing deployment on the caller's team, + * the api updates that deployment IN PLACE (same app_id, same URL) instead + * of minting a fresh one. Default false → preserves the legacy "always mint + * a new app_id" behaviour. This closes the AGENT-UX gap where an agent + * shipping v2 of an existing app ended up with two live URLs. + * + * Forward compatibility: when sent against an api that doesn't yet + * understand the field, the multipart form value is silently ignored by + * Fiber's MultipartForm parser → behaves like the legacy path. Safe to + * ship from MCP before the api PR lands; the user only sees in-place + * redeploy behaviour once the api side is in prod. + */ + redeploy?: boolean; } /** @@ -956,6 +971,15 @@ export class InstantClient { if (params.allowed_ips && params.allowed_ips.length > 0) { form.append("allowed_ips", JSON.stringify(params.allowed_ips)); } + // Redeploy-in-place opt-in (api PR feat/deploy-new-redeploy-in-place). + // Only forward when explicitly true — omitting the field keeps the api + // on the legacy "mint a new app_id" path, preserving existing behaviour + // for every caller that hasn't asked for in-place. Sending "false" + // would also work server-side, but omitting it makes the wire trace + // identical to pre-fix MCP versions for unaffected callers. + if (params.redeploy === true) { + form.append("redeploy", "true"); + } // Merge resource_bindings into env_vars. The api treats every value // either as plaintext, a vault://env/KEY ref, or — for deploy bindings — @@ -1083,20 +1107,47 @@ export class InstantClient { /** * POST /deploy/:id/redeploy — rebuild + rolling update an existing app. * - * The live API returns a bare 202 with no body (see openapi.json). Earlier - * versions of this client typed the response as DeployGetResult and the - * tool handler dereferenced `result.item.app_id`, throwing - * "Cannot read properties of undefined (reading 'app_id')" on every real - * call. BugBash B16 F1 (regression of task #170): the empty-body now - * resolves to `{ok: true}` via the request() empty-2xx sentinel; this + * The api handler REQUIRES a fresh tarball multipart file part + * (deploy.go:1245 `missing_tarball`); there is no tarball reuse anywhere + * server-side. The previous bodyless version of this method always 400'd + * with "Multipart field 'tarball' is required" — see AGENT-UX.md Path B. + * + * `tarball_base64` is the same shape `createDeploy()` accepts: base64- + * encoded gzip tar (Dockerfile + source), capped at 50 MiB after decode. + * The 50 MiB ceiling is enforced client-side BEFORE the upload so an + * oversized payload fails fast with a clear error instead of round- + * tripping multiple MB of base64 to the api. + * + * The live api returns a bare 202 with no body (see openapi.json). The + * request() empty-2xx sentinel resolves it to `{ok: true}`; this * helper layers the caller-supplied id on top so the tool handler has a * stable surface to read. */ - async redeploy(id: string): Promise { - const raw = await this.request( - "POST", + async redeploy(id: string, tarballBase64: string): Promise { + const form = new FormData(); + + const tarball = Buffer.from(tarballBase64, "base64"); + + // Mirror the createDeploy guard — fail BEFORE opening a multipart + // connection on an oversized payload. The api enforces 50 MiB + // (deploy.go:1249 tarball_too_large); pre-empting it here surfaces a + // precise error and avoids bandwidth burn. + if (tarball.byteLength > MAX_TARBALL_BYTES) { + throw new Error( + `Tarball is too large: ${tarball.byteLength.toLocaleString()} bytes ` + + `(decoded). The api accepts at most ${MAX_TARBALL_BYTES.toLocaleString()} ` + + `bytes (50 MiB). Shrink the tarball: include only what \`docker build\` ` + + `needs — exclude node_modules, .git, build artifacts, large media files. ` + + `Add a .dockerignore to your project root.` + ); + } + + const blob = new Blob([tarball], { type: "application/gzip" }); + form.append("tarball", blob, "app.tar.gz"); + + const raw = await this.requestMultipart( `/deploy/${encodeURIComponent(id)}/redeploy`, - undefined, + form, { requireAuth: true } ); return { diff --git a/src/index.ts b/src/index.ts index f75bea6..1e6b9da 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,9 @@ * create_storage — provision an S3-compatible object storage bucket prefix * create_webhook — provision an inbound webhook receiver URL * create_deploy — upload a base64 gzip tarball (Dockerfile + source) and - * deploy a container; returns a public URL in ~30s + * deploy a container; returns a public URL in ~30s. + * Pass `redeploy: true` with the same name to update an + * existing deployment IN PLACE (same app_id + URL). * * claim_resource — turn an anonymous upgrade JWT into the dashboard claim URL * the agent should direct the user to (no API call — pure helper) @@ -25,7 +27,10 @@ * * list_deployments — list all deployments for the caller's team * get_deployment — fetch a deployment by app id (for polling build status) - * redeploy — trigger a rebuild + rolling update of an existing app + * redeploy — push updated code to an existing deployment by id; + * requires a fresh tarball (api never reuses the original). + * Prefer `create_deploy({name, redeploy:true})` when you + * have the name; use this when you only have the deploy id. * delete_deployment — tear down a running deployment * * Every create_* tool surfaces the API's `note` and `upgrade` fields so the @@ -940,7 +945,7 @@ agent can route the user to the dashboard instead of guessing.`, server.tool( "create_deploy", - `Create a new deploy. Optionally set \`private: true\` + \`allowed_ips: ['1.2.3.4', '10.0.0.0/8']\` to restrict access to specific IPs. Requires Pro tier or higher. Useful when an agent is asked to deploy a CRM, internal dashboard, or staging app that should only be reachable by the user. + `Create a new deploy — OR set \`redeploy: true\` to update an existing deployment with the same name (preserves app_id + URL). Optionally set \`private: true\` + \`allowed_ips: ['1.2.3.4', '10.0.0.0/8']\` to restrict access to specific IPs. Requires Pro tier or higher. Useful when an agent is asked to deploy a CRM, internal dashboard, or staging app that should only be reachable by the user. Deploys a containerized application on instanode.dev (POST /deploy/new). @@ -950,6 +955,13 @@ deploys + returns a public URL in ~30s. Build is asynchronous: the initial response carries status="building"; poll 'get_deployment' with the returned 'deploy_id' until status becomes "running" or "failed". +In-place update (redeploy:true): when you ship v2 of an existing app, pass +the SAME 'name' plus 'redeploy: true'. The api updates that deployment in +place — same app_id, same *.deployment.instanode.dev URL — instead of +minting a fresh one. Default behaviour (redeploy omitted or false) always +creates a new deployment and a new URL. This closes the AGENT-UX trap where +shipping v2 with the same name left two live deployments + two URLs. + Tarball construction (agent side, runtime depends on language): tar = subprocess.check_output(["tar", "czf", "-", "-C", project_dir, "."]) tarball_base64 = base64.b64encode(tar).decode() @@ -1066,6 +1078,20 @@ Requires INSTANODE_TOKEN (anonymous tier cannot deploy).`, .describe( "IP / CIDR allowlist enforced at the Ingress when 'private' is true. Examples: ['1.2.3.4', '10.0.0.0/8', '203.0.113.42/32']. Required when private=true; ignored otherwise. Max 256 entries; each must parse as IPv4/IPv6 address or CIDR." ), + // In-place redeploy opt-in (api PR feat/deploy-new-redeploy-in-place). + // Sent to the api as a multipart form field — when true, the api looks + // up an existing deployment by (team_id, name) and updates it in place + // (same app_id, same URL) instead of minting a fresh one. Default false + // preserves the existing "always create a new deployment" behaviour. + // Note: the api PR must be in prod before this flag does anything; on + // an older api the field is silently ignored by Fiber's form parser + // (caller sees legacy behaviour, no error). + redeploy: z + .boolean() + .optional() + .describe( + "Set true to update an existing deployment with the same name (preserves app_id + URL). Default false → creates a new deployment with a fresh app_id and URL. Use redeploy:true when shipping a new version of an app you've already deployed." + ), }, // BUG-MCP-021: enforce the documented private+allowed_ips coupling // client-side. The API rejects (private=true, allowed_ips=[]) with a 400, @@ -1442,20 +1468,43 @@ Requires INSTANODE_TOKEN.`, server.tool( "redeploy", - `Trigger a rebuild + rolling update of an existing deployment -(POST /deploy/:id/redeploy). Useful after updating env vars via the -dashboard, rotating a vault secret, or when the underlying image needs -a refresh. The tarball from the original deploy is reused. + `Push updated code to an existing deployment by app id. Same URL, new build +(POST /deploy/:id/redeploy). + +Use this when you already know the deploy_id and want to ship a code change +without touching the URL or app_id. For the more common "I have the name, I +want to update the app I just shipped" path, prefer +create_deploy({ name, tarball_base64, redeploy: true }) — that resolves the +deployment by name and is the AGENT-UX-recommended path. + +The api REQUIRES a fresh tarball — there is no server-side tarball reuse +(the earlier tool description claiming reuse was wrong and caused every +real call to fail with 400 missing_tarball). Pass a base64-encoded gzip +tar of the project (Dockerfile + source), same shape as create_deploy. Status flips back to "building"; poll get_deployment until it returns -to "running". +to "running" (~30s typical). Requires INSTANODE_TOKEN.`, { // BUG-MCP-025: validate UUID client-side. id: uuidSchema.describe("Deployment app id (returned as 'deploy_id' by create_deploy)."), + // T-redeploy-fix: tarball is required. The api handler at + // deploy.go:1245 returns 400 missing_tarball without it; the previous + // tool schema omitted this field and the description lied about + // tarball reuse, making every real call 400. + tarball_base64: z + .string() + .min(1) + .max( + 70 * 1024 * 1024, + "tarball_base64: encoded payload exceeds 70 MiB (≈50 MiB decoded). Shrink the tarball — strip .git, node_modules, build artifacts." + ) + .describe( + "Base64-encoded gzip tarball of the project directory (must include a Dockerfile at the root). <50 MB after decode (≈70 MiB encoded). Same shape as create_deploy.tarball_base64." + ), }, - async ({ id }) => { + async ({ id, tarball_base64 }) => { try { // BugBash B16 F1 (regression of task #170): /deploy/:id/redeploy returns // a bare 202 with no body — the previous handler dereferenced @@ -1463,7 +1512,7 @@ Requires INSTANODE_TOKEN.`, // undefined (reading 'app_id')". client.redeploy() now resolves to // {ok, id, status, message} with safe fallbacks so the handler stays // alive even when the body is empty. - const result = await client.redeploy(id); + const result = await client.redeploy(id, tarball_base64); const appId = result.id ?? id; const lines = [ `Redeploy accepted for ${appId}.`, diff --git a/test/client-unit.test.ts b/test/client-unit.test.ts index 421322a..866058a 100644 --- a/test/client-unit.test.ts +++ b/test/client-unit.test.ts @@ -145,7 +145,8 @@ describe("InstantClient — unit-level branch coverage", () => { stubFetch(() => new Response("", { status: 202 })); process.env["INSTANODE_TOKEN"] = "tok_xyz"; const c = new InstantClient({ baseURL: "https://example.test" }); - const res = await c.redeploy("dep-123"); + const tiny = Buffer.from("hello").toString("base64"); + const res = await c.redeploy("dep-123", tiny); assert.equal(res.ok, true); assert.equal(res.id, "dep-123"); assert.equal(res.status, "building"); @@ -712,7 +713,8 @@ describe("InstantClient — unit-level branch coverage", () => { ); process.env["INSTANODE_TOKEN"] = "tok_xyz"; const c = new InstantClient({ baseURL: "https://example.test" }); - const r = await c.redeploy("dep-9"); + const tiny = Buffer.from("hello").toString("base64"); + const r = await c.redeploy("dep-9", tiny); assert.equal(r.id, "dep-9"); assert.equal(r.status, "rebuilding"); assert.equal(r.message, "kicked"); @@ -1027,8 +1029,9 @@ describe("InstantClient — unit-level branch coverage", () => { it("redeploy → AuthRequiredError when token is unset", async () => { const c = new InstantClient({ baseURL: "https://example.test" }); + const tiny = Buffer.from("hello").toString("base64"); await assert.rejects( - () => c.redeploy("dep"), + () => c.redeploy("dep", tiny), (err: unknown) => err instanceof AuthRequiredError ); }); @@ -1492,6 +1495,205 @@ describe("InstantClient — createStack / getStack (the CEO wedge)", () => { }); }); +describe("redeploy-in-place wiring (fix/mcp-redeploy-in-place)", () => { + beforeEach(() => { + delete process.env["INSTANODE_TOKEN"]; + delete process.env["INSTANODE_API_URL"]; + }); + + afterEach(() => { + restoreFetch(); + delete process.env["INSTANODE_TOKEN"]; + delete process.env["INSTANODE_API_URL"]; + }); + + it("createDeploy with redeploy:true appends `redeploy=true` to the multipart form", async () => { + process.env["INSTANODE_TOKEN"] = "tok_xyz"; + const c = new InstantClient({ baseURL: "https://example.test" }); + const tiny = Buffer.from("hello").toString("base64"); + + let formText = ""; + stubFetch(async (_input: any, init?: any) => { + const blob = init.body as any; + if (blob && typeof blob.text === "function") { + formText = await blob.text(); + } + return new Response( + JSON.stringify({ + ok: true, + item: { + id: "i", + app_id: "a-redep-1", + token: "t", + port: 8080, + tier: "pro", + status: "building", + url: "", + }, + }), + { status: 202, headers: { "content-type": "application/json" } } + ); + }); + + await c.createDeploy({ + tarball_base64: tiny, + name: "in-place-app", + redeploy: true, + }); + + // The form must carry a `redeploy=true` text field. If the runtime + // doesn't expose blob.text() we skip the body check (the call alone + // proves the path is wired). + if (formText.length > 0) { + assert.match(formText, /name="redeploy"/); + assert.match(formText, /\r\n\r\ntrue\r\n/); + } + }); + + it("createDeploy without redeploy does NOT send the `redeploy` form field (preserves legacy behaviour)", async () => { + process.env["INSTANODE_TOKEN"] = "tok_xyz"; + const c = new InstantClient({ baseURL: "https://example.test" }); + const tiny = Buffer.from("hello").toString("base64"); + + let formText = ""; + stubFetch(async (_input: any, init?: any) => { + const blob = init.body as any; + if (blob && typeof blob.text === "function") { + formText = await blob.text(); + } + return new Response( + JSON.stringify({ + ok: true, + item: { + id: "i", + app_id: "a-legacy-1", + token: "t", + port: 8080, + tier: "pro", + status: "building", + url: "", + }, + }), + { status: 202, headers: { "content-type": "application/json" } } + ); + }); + + await c.createDeploy({ + tarball_base64: tiny, + name: "legacy-app", + }); + + if (formText.length > 0) { + // The `redeploy` field must not appear in the multipart body. If it + // sneaks in (e.g. via a default), an old api would treat a no-name + // case as a no-op or get confused — keep the wire identical to the + // pre-fix shape for callers that didn't ask for in-place. + assert.ok(!/name="redeploy"/.test(formText), `unexpected redeploy field: ${formText}`); + } + }); + + it("createDeploy with redeploy:false does NOT send the `redeploy` form field either", async () => { + process.env["INSTANODE_TOKEN"] = "tok_xyz"; + const c = new InstantClient({ baseURL: "https://example.test" }); + const tiny = Buffer.from("hello").toString("base64"); + + let formText = ""; + stubFetch(async (_input: any, init?: any) => { + const blob = init.body as any; + if (blob && typeof blob.text === "function") { + formText = await blob.text(); + } + return new Response( + JSON.stringify({ + ok: true, + item: { + id: "i", + app_id: "a-legacy-2", + token: "t", + port: 8080, + tier: "pro", + status: "building", + url: "", + }, + }), + { status: 202, headers: { "content-type": "application/json" } } + ); + }); + + await c.createDeploy({ + tarball_base64: tiny, + name: "explicit-false-app", + redeploy: false, + }); + + if (formText.length > 0) { + assert.ok(!/name="redeploy"/.test(formText), `unexpected redeploy field on explicit false: ${formText}`); + } + }); + + it("redeploy(id, tarball) POSTs multipart to /deploy/:id/redeploy carrying the tarball file part", async () => { + process.env["INSTANODE_TOKEN"] = "tok_xyz"; + const c = new InstantClient({ baseURL: "https://example.test" }); + const tiny = Buffer.from("FROM scratch\n").toString("base64"); + + let calledURL = ""; + let calledMethod = ""; + let formText = ""; + let contentType = ""; + stubFetch(async (input: any, init?: any) => { + calledURL = typeof input === "string" ? input : input.url; + calledMethod = String(init?.method ?? ""); + const ctRaw = init?.headers?.["Content-Type"] ?? init?.headers?.["content-type"] ?? ""; + contentType = typeof ctRaw === "string" ? ctRaw : ""; + const blob = init.body as any; + if (blob && typeof blob.text === "function") { + formText = await blob.text(); + } + return new Response("", { status: 202 }); + }); + + const r = await c.redeploy("dep-tar-1", tiny); + assert.equal(r.ok, true); + assert.equal(r.id, "dep-tar-1"); + assert.equal(r.status, "building"); + assert.match(calledURL, /\/deploy\/dep-tar-1\/redeploy$/); + assert.equal(calledMethod, "POST"); + // fetch fills in Content-Type for FormData when it's not set explicitly; + // requestMultipart() intentionally omits the header so undici stamps the + // multipart boundary. Either way the wire is multipart — verified via + // the body shape (file part with filename) which we check below. + void contentType; + if (formText.length > 0) { + assert.match(formText, /name="tarball"/); + assert.match(formText, /filename="app\.tar\.gz"/); + } + }); + + it("redeploy rejects oversized tarballs CLIENT-SIDE before any fetch (mirrors createDeploy cap)", async () => { + process.env["INSTANODE_TOKEN"] = "tok_xyz"; + const c = new InstantClient({ baseURL: "https://example.test" }); + const big = Buffer.alloc(60 * 1024 * 1024, 0xff).toString("base64"); + + let fetched = false; + stubFetch(() => { fetched = true; return new Response("ok", { status: 200 }); }); + + await assert.rejects( + () => c.redeploy("dep-huge", big), + (err: unknown) => /too large/i.test((err as Error).message) + ); + assert.equal(fetched, false, "fetch should never be reached for oversized redeploy tarballs"); + }); + + it("redeploy without INSTANODE_TOKEN throws AuthRequiredError (requireAuth gate)", async () => { + const c = new InstantClient({ baseURL: "https://example.test" }); + const tiny = Buffer.from("hello").toString("base64"); + await assert.rejects( + () => c.redeploy("dep-noauth", tiny), + (err: unknown) => err instanceof AuthRequiredError + ); + }); +}); + describe("ApiError + AuthRequiredError shapes", () => { it("AuthRequiredError carries the canonical message + name", () => { const e = new AuthRequiredError(); diff --git a/test/integration.test.ts b/test/integration.test.ts index de20a4c..f8f80a5 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -680,8 +680,14 @@ describe("instanode-mcp integration suite", () => { const listed = await client.callTool({ name: "list_deployments", arguments: {} }); assert.ok(resultText(listed).includes(appId), "deployment missing from list_deployments"); - // redeploy — status flips back to building - const redeployed = await client.callTool({ name: "redeploy", arguments: { id: appId } }); + // redeploy — status flips back to building. The fix that landed + // alongside this test now requires a tarball multipart on + // /deploy/:id/redeploy (mirroring the real api contract; the + // previous bodyless call always 400'd missing_tarball in prod). + const redeployed = await client.callTool({ + name: "redeploy", + arguments: { id: appId, tarball_base64: fakeTarballBase64() }, + }); assert.ok(/Status:\s+building/.test(resultText(redeployed)), "redeploy did not reset status to building"); // delete — MANDATORY teardown @@ -715,9 +721,13 @@ describe("instanode-mcp integration suite", () => { const { client, close } = await connectClient(mock.url, "valid"); try { // BUG-MCP-025: see above — UUID-shaped + unknown. + // tarball_base64 is now required (real api: deploy.go:1245). const res = await client.callTool({ name: "redeploy", - arguments: { id: "00000000-0000-4000-8000-000000000404" }, + arguments: { + id: "00000000-0000-4000-8000-000000000404", + tarball_base64: fakeTarballBase64(), + }, }); assert.ok(/404|not found/i.test(resultText(res)), "redeploy did not surface a 404"); } finally { @@ -864,7 +874,10 @@ describe("instanode-mcp integration suite", () => { // The act: redeploy must not throw, must include a clear "Redeploy // accepted" headline, and must NOT contain any sign of an undefined // dereference (the old failure mode). - const res = await client.callTool({ name: "redeploy", arguments: { id: appId } }); + const res = await client.callTool({ + name: "redeploy", + arguments: { id: appId, tarball_base64: fakeTarballBase64() }, + }); const text = resultText(res); assert.ok(text.includes("Redeploy accepted"), `expected a clean redeploy headline:\n${text}`); assert.ok(text.includes(appId), `expected the redeploy output to echo the id ${appId}:\n${text}`); @@ -1090,6 +1103,10 @@ describe("instanode-mcp integration suite", () => { it("POST /deploy/:id/redeploy returns a bare 202 with no body (matches openapi.json)", async () => { // T17 P0-1: the prior mock returned {ok, item: deployment} on 202, // letting the broken redeploy client pass tests against fiction. + // fix/mcp-redeploy-in-place: the mock now also enforces the api's + // missing_tarball contract (deploy.go:1245), so this raw fetch must + // post multipart with a tarball file part to get the 202 — same + // shape as the real api. const { client, close } = await connectClient(mock.url, "valid"); let appId = ""; try { @@ -1100,9 +1117,14 @@ describe("instanode-mcp integration suite", () => { appId = /Deploy ID:\s+(\S+)/.exec(resultText(created))![1]; // Direct fetch — bypass the mcp client to inspect the raw response. + const form = new FormData(); + const tarball = Buffer.from(fakeTarballBase64(), "base64"); + const blob = new Blob([tarball], { type: "application/gzip" }); + form.append("tarball", blob, "app.tar.gz"); const resp = await fetch(`${mock.url}/deploy/${appId}/redeploy`, { method: "POST", headers: { Authorization: `Bearer ${VALID_TOKEN}` }, + body: form, }); assert.equal(resp.status, 202, `expected 202, got ${resp.status}`); const body = await resp.text(); @@ -1119,6 +1141,37 @@ describe("instanode-mcp integration suite", () => { } }); + it("POST /deploy/:id/redeploy WITHOUT a tarball returns 400 missing_tarball (real api contract)", async () => { + // fix/mcp-redeploy-in-place: mirror api/internal/handlers/deploy.go:1245 + // — the prior mock accepted bodyless calls, masking the bug where + // the standalone redeploy MCP tool sent no tarball and always 400'd. + const { client, close } = await connectClient(mock.url, "valid"); + let appId = ""; + try { + const created = await client.callTool({ + name: "create_deploy", + arguments: { tarball_base64: fakeTarballBase64(), name: "it-mock-missing-tar" }, + }); + appId = /Deploy ID:\s+(\S+)/.exec(resultText(created))![1]; + + const resp = await fetch(`${mock.url}/deploy/${appId}/redeploy`, { + method: "POST", + headers: { Authorization: `Bearer ${VALID_TOKEN}` }, + }); + assert.equal(resp.status, 400, `expected 400 missing_tarball, got ${resp.status}`); + const body = (await resp.json()) as { error?: string; message?: string }; + assert.equal(body.error, "invalid_form", `unexpected error: ${JSON.stringify(body)}`); + } finally { + await close(); + } + const { client: c2, close: close2 } = await connectClient(mock.url, "valid"); + try { + await c2.callTool({ name: "delete_deployment", arguments: { id: appId } }); + } finally { + await close2(); + } + }); + it("POST /api/v1/auth/api-keys returns 403 pat_cannot_mint_pat when the caller is a PAT", async () => { // T17 P0-2: the prior mock unconditionally returned 201 — masked the // entire PAT-creating-PAT failure mode. diff --git a/test/mock-api.ts b/test/mock-api.ts index a6e0a00..041567d 100644 --- a/test/mock-api.ts +++ b/test/mock-api.ts @@ -658,6 +658,40 @@ async function route(req: IncomingMessage, res: ServerResponse, state: State): P } } + // In-place redeploy support (api PR feat/deploy-new-redeploy-in-place): + // when the multipart form carries `redeploy=true` AND there is an + // existing deployment with the same `name` on the caller's team, the + // api updates that deployment IN PLACE — same app_id, same URL — and + // returns 202 with the existing item (status flipped back to building). + // The mock matches by name across all live deployments since it has + // no real team model. + const wantInPlace = fields["redeploy"] === "true"; + const reqName = fields["name"] ?? ""; + if (wantInPlace && reqName !== "") { + for (const existing of state.deployments.values()) { + if (existing.status === "deleted") continue; + // The mock stamps the user-supplied name into env["_name"] on + // create-new (see below) so subsequent redeploy-by-name lookups + // resolve without a separate team/name index. Real api uses the + // (team_id, name) primary key — the mock doesn't model teams. + if ((existing.env["_name"] ?? "") !== reqName) continue; + // Update in place — status flips to building, URL cleared until + // the next get_deployment poll flips it back to running. + existing.status = "building"; + existing.url = ""; + existing.env = { ...envVars, _name: reqName }; + existing.updated_at = nowIso(); + state.deployCalls += 1; + sendJSON(res, 202, { + ok: true, + item: existing, + note: "In-place redeploy started — poll get_deployment until status=running.", + }); + return; + } + // Fall through to create-new when no existing deployment matches. + } + // BUG-MCP-025: app_id is now validated as a UUID on the get/redeploy/ // delete paths, matching the real API contract. The previous // `app-{shortid}` mock id silently passed because the schema was a @@ -673,7 +707,7 @@ async function route(req: IncomingMessage, res: ServerResponse, state: State): P tier: effectiveTier, status: "building", url: "", - env: envVars, + env: { ...envVars, _name: reqName }, environment: fields["env"] ?? "production", private: isPrivate, allowed_ips: allowedIps, @@ -724,10 +758,12 @@ async function route(req: IncomingMessage, res: ServerResponse, state: State): P } // ── POST /deploy/:id/redeploy ────────────────────────────────────────────── - // Per openapi.json: bare 202 response, NO body schema. The previous mock returned - // {ok, item: deployment} — that masked a real prod bug where the MCP client typed - // the response as DeployGetResult and dereferenced .item.app_id, throwing on the - // empty-body 202 from the real API. T17 P0-1. + // Per the real api (deploy.go:1245 missing_tarball): /deploy/:id/redeploy + // REQUIRES a multipart `tarball` file part. The previous bodyless contract + // was a bug — the api always rejected with 400 missing_tarball in prod. + // The mock now enforces the real contract so the MCP client wiring + // (multipart upload from the standalone redeploy tool) is exercised end- + // to-end. Per openapi.json the response is a bare 202 with no body. if (method === "POST" && /^\/deploy\/[^/]+\/redeploy$/.test(path)) { if (!authed) { sendJSON(res, 401, errorEnvelope({ error: "unauthorized", message: "bearer token required" })); @@ -739,6 +775,26 @@ async function route(req: IncomingMessage, res: ServerResponse, state: State): P sendJSON(res, 404, errorEnvelope({ error: "not_found", message: "deployment not found" })); return; } + const ct = req.headers["content-type"] ?? ""; + const ctStr = Array.isArray(ct) ? ct[0] : ct; + if (!ctStr.startsWith("multipart/form-data")) { + sendJSON( + res, + 400, + errorEnvelope({ error: "invalid_form", message: "Request must be multipart/form-data with a 'tarball' field" }) + ); + return; + } + const raw = await readBody(req); + const { hasTarball } = parseMultipart(raw, ctStr); + if (!hasTarball) { + sendJSON( + res, + 400, + errorEnvelope({ error: "missing_tarball", message: "Multipart field 'tarball' is required" }) + ); + return; + } deployment.status = "building"; deployment.url = ""; deployment.updated_at = nowIso(); diff --git a/test/tools-unit.test.ts b/test/tools-unit.test.ts index d04dc76..fb4a102 100644 --- a/test/tools-unit.test.ts +++ b/test/tools-unit.test.ts @@ -272,7 +272,7 @@ describe("tool handlers — auth-gated paths surface the auth-required message", }); it("redeploy → unauthenticated returns the canonical auth-required text", async () => { - const res = await handlerFor("redeploy")({ id: "dep" }); + const res = await handlerFor("redeploy")({ id: "dep", tarball_base64: tarballBase64() }); const text = flat(res); assert.match(text, /requires authentication/i); }); @@ -499,7 +499,7 @@ describe("tool handlers — deployment lifecycle", () => { }); const appId = /Deploy ID:\s+(\S+)/.exec(flat(created))![1]; - const re = await handlerFor("redeploy")({ id: appId }); + const re = await handlerFor("redeploy")({ id: appId, tarball_base64: tarballBase64() }); const text = flat(re); assert.match(text, /Redeploy accepted for/); assert.match(text, new RegExp(appId)); @@ -524,7 +524,10 @@ describe("tool handlers — deployment lifecycle", () => { }); it("redeploy → 404 surfaces the formatError envelope", async () => { - const res = await handlerFor("redeploy")({ id: "app-does-not-exist" }); + const res = await handlerFor("redeploy")({ + id: "app-does-not-exist", + tarball_base64: tarballBase64(), + }); const text = flat(res); assert.match(text, /instanode\.dev error \(404/); }); @@ -1047,7 +1050,7 @@ describe("tool handlers — optional-field absent branches", () => { status: 202, headers: { "content-type": "application/json" }, })) as typeof globalThis.fetch; - const res = await handlerFor("redeploy")({ id: "fallback-id" }); + const res = await handlerFor("redeploy")({ id: "fallback-id", tarball_base64: tarballBase64() }); const text = flat(res); assert.match(text, /Redeploy accepted for fallback-id/); assert.match(text, /Status:\s+building/); @@ -1060,7 +1063,7 @@ describe("tool handlers — optional-field absent branches", () => { JSON.stringify({ ok: true, id: "dep-m", status: "building", message: "queued for rebuild" }), { status: 202, headers: { "content-type": "application/json" } } )) as typeof globalThis.fetch; - const res = await handlerFor("redeploy")({ id: "dep-m" }); + const res = await handlerFor("redeploy")({ id: "dep-m", tarball_base64: tarballBase64() }); const text = flat(res); assert.match(text, /Message: queued for rebuild/); }); @@ -1569,3 +1572,80 @@ describe("tool handlers — env passthrough on every provisioning tool (CLI-MCP assert.equal(mock.provisionCount(), beforeCount + 1); }); }); + +describe("redeploy-in-place tool handlers (fix/mcp-redeploy-in-place)", () => { + it("create_deploy with redeploy:true + same name updates IN PLACE (same app_id)", async () => { + process.env["INSTANODE_TOKEN"] = VALID_TOKEN; + const created = await handlerFor("create_deploy")({ + tarball_base64: tarballBase64(), + name: "u-inplace-app", + }); + const firstAppId = /Deploy ID:\s+(\S+)/.exec(flat(created))![1]; + + const updated = await handlerFor("create_deploy")({ + tarball_base64: tarballBase64(), + name: "u-inplace-app", + redeploy: true, + }); + const secondAppId = /Deploy ID:\s+(\S+)/.exec(flat(updated))![1]; + + // Same app_id → same URL slot, no orphan deployment created. + assert.equal(secondAppId, firstAppId, "redeploy:true must reuse the existing app_id"); + + await handlerFor("delete_deployment")({ id: firstAppId }); + }); + + it("create_deploy WITHOUT redeploy + same name mints a FRESH app_id (legacy behaviour)", async () => { + process.env["INSTANODE_TOKEN"] = VALID_TOKEN; + const created = await handlerFor("create_deploy")({ + tarball_base64: tarballBase64(), + name: "u-legacy-app", + }); + const firstAppId = /Deploy ID:\s+(\S+)/.exec(flat(created))![1]; + + const second = await handlerFor("create_deploy")({ + tarball_base64: tarballBase64(), + name: "u-legacy-app", + }); + const secondAppId = /Deploy ID:\s+(\S+)/.exec(flat(second))![1]; + + assert.notEqual(secondAppId, firstAppId, "omitting redeploy must mint a fresh app_id"); + + await handlerFor("delete_deployment")({ id: firstAppId }); + await handlerFor("delete_deployment")({ id: secondAppId }); + }); + + it("create_deploy with redeploy:true but NEW name falls through to create-new", async () => { + process.env["INSTANODE_TOKEN"] = VALID_TOKEN; + const created = await handlerFor("create_deploy")({ + tarball_base64: tarballBase64(), + name: "u-new-with-redep-true", + redeploy: true, + }); + const text = flat(created); + const appId = /Deploy ID:\s+(\S+)/.exec(text)![1]; + assert.ok(appId.length > 0, "expected an app id even when no prior deployment exists"); + + await handlerFor("delete_deployment")({ id: appId }); + }); + + it("standalone redeploy tool now requires tarball_base64 — sends multipart to /deploy/:id/redeploy", async () => { + process.env["INSTANODE_TOKEN"] = VALID_TOKEN; + const created = await handlerFor("create_deploy")({ + tarball_base64: tarballBase64(), + name: "u-standalone-redep", + }); + const appId = /Deploy ID:\s+(\S+)/.exec(flat(created))![1]; + + const re = await handlerFor("redeploy")({ + id: appId, + tarball_base64: tarballBase64(), + }); + const text = flat(re); + assert.match(text, /Redeploy accepted for/); + assert.match(text, new RegExp(appId)); + assert.match(text, /Status:\s+building/); + + await handlerFor("delete_deployment")({ id: appId }); + }); +});