Skip to content

Commit b156f89

Browse files
docs(deploy): document POST /deploy/new ?redeploy=true for in-place updates (#152)
Closes the agent-UX gap that makes /deploy/new repeatedly mint a fresh app_id + URL when an agent ships v2 of an existing app. The api PR adds a redeploy=true multipart form field; this PR documents it on every agent-facing surface so an agent reading the docs discovers the flag before falling back to "POST /deploy/new" twice and ending up with two parallel deployments + two URLs (the customer complaint that triggered this swarm). Surfaces touched (rule 22 — contract changes touch all surfaces): - public/llms.txt — inline redeploy note on the /deploy/new bullet - public/llms-full.txt — new /deploy/new section with both curl flows + 404 no_matching_deployment shape + /deploy/:id/redeploy deprecation note - src/pages/ForAgentsPage — 5th reason card: "in-place app updates" - scripts/fetch-content.mjs — preserve public/llms.txt when upstream .content/llms.txt lacks the redeploy markers (cross-repo lock-step guard) - src/lib/llmsContract.test — registry-iterating regression test (rule 18) asserting both files mention redeploy=true + "redeployed": + no_matching_deployment Rule 17 coverage block: Symptom: agent calls POST /deploy/new twice with same name and ends up with two URLs, slot count + 1 Enumeration: rg -nF '/deploy/new' public/ src/pages/ rg -nF 'redeploy' public/ src/pages/ Sites found: 4 agent-facing surfaces in instanode-web (llms.txt, llms-full.txt, ForAgentsPage.tsx, DocsPage.tsx) Sites touched: 3 (llms.txt, llms-full.txt, ForAgentsPage.tsx) DocsPage.tsx is a pure markdown renderer — its content lives in InstaNode-dev/content/docs/deploy.md, which is handled by the cross-repo follow-up (see PR body) Coverage test: src/lib/llmsContract.test.ts — fails if either llms file silently drops the redeploy guidance Live verified: awaiting api-PR deploy. Docs are additive and safe to land first; the flag itself returns 400 until the api PR ships, so the docs do not lie about a working endpoint until the matched-pair lands Depends on: - api PR adding redeploy=true to POST /deploy/new (parallel work) - mcp PR adding redeploy: boolean to create_deploy tool (parallel work) - InstaNode-dev/content PR mirroring the llms.txt edit (follow-up; fetch-content.mjs preserves the local copy in the meantime) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent eec5ab9 commit b156f89

5 files changed

Lines changed: 234 additions & 3 deletions

File tree

public/llms-full.txt

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,108 @@ Feature flag: requires `queue` in INSTANT_ENABLED_SERVICES. Returns 503 if not e
252252

253253
---
254254

255+
### POST /deploy/new — Deploy a single containerized app
256+
257+
**Status: Live. Requires Bearer JWT.**
258+
259+
Deploy a single container. Builds the image via in-cluster Kaniko (~30–90s), rolls it out on Kubernetes, and exposes it on `https://<name>-<short-app-id>.deployment.instanode.dev` with a Let's Encrypt cert. Returns 202 immediately — poll `GET /api/v1/deployments/:id` until `status=healthy`.
260+
261+
For multi-service apps with internal service-to-service URLs, use `POST /stacks/new` instead.
262+
263+
**Content-Type: multipart/form-data**
264+
265+
Fields:
266+
- `tarball` (file, required): gzipped tar archive containing your Dockerfile + source, ≤50 MB.
267+
- `name` (string, required): 1–64 chars matching `^[A-Za-z0-9][A-Za-z0-9 _-]*$`. The human-readable label that appears in the URL and on the dashboard.
268+
- `port` (string, optional): container port the app listens on (default `8080`).
269+
- `env` (string, optional): deployment scope — `development` (default), `staging`, or `production`. Echoed back as `environment` in the response.
270+
- `env_vars` (string, optional): JSON-encoded object of env vars injected into the pod on first build (e.g. `'{"DATABASE_URL":"postgres://..."}'`).
271+
- `redeploy` (string, optional): pass `redeploy=true` to update an EXISTING app in place instead of creating a new one. See the "Redeploying an existing app" subsection below.
272+
273+
#### First-time deploy
274+
275+
```bash
276+
# Package source
277+
tar czf app.tar.gz -C ./myapp .
278+
279+
# Deploy
280+
curl -X POST https://api.instanode.dev/deploy/new \
281+
-H "Authorization: Bearer $UPGRADE_JWT" \
282+
-F tarball=@app.tar.gz \
283+
-F name=expense-tracker \
284+
-F port=8080 \
285+
-F 'env_vars={"DATABASE_URL":"postgres://..."}'
286+
```
287+
288+
#### Response 202 (build started):
289+
```json
290+
{
291+
"ok": true,
292+
"app_id": "36ace884-1f2c-4a9b-8d11-9c2b7e6a0f4a",
293+
"deploy_id": "36ace884",
294+
"name": "expense-tracker",
295+
"url": "https://expense-tracker-36ace884.deployment.instanode.dev",
296+
"status": "building",
297+
"environment": "development",
298+
"redeployed": false,
299+
"tier": "hobby",
300+
"note": "App is building. Poll GET /api/v1/deployments/36ace884 for status."
301+
}
302+
```
303+
304+
#### Redeploying an existing app (push v2)
305+
306+
The default behaviour of `POST /deploy/new` is to **always create a new deployment** — a fresh `app_id` and a fresh URL — even when the `name` field collides with an existing app on the same team. That is by design: a unique URL per `POST` makes the endpoint safely retryable, but it means that an agent shipping v2 of an app and just re-calling `POST /deploy/new` ends up with two live pods, two URLs, and the per-tier `deployments_apps` slot count incremented by one. That is the wrong outcome for "I just want to push a code update to the same app."
307+
308+
To update an existing app in place — same `app_id`, same URL, no new slot consumed — pass `redeploy=true` along with the same `name` you used for the original deploy:
309+
310+
```bash
311+
# Package the updated source
312+
tar czf app.tar.gz -C ./myapp .
313+
314+
# Redeploy in place
315+
curl -X POST https://api.instanode.dev/deploy/new \
316+
-H "Authorization: Bearer $UPGRADE_JWT" \
317+
-F tarball=@app.tar.gz \
318+
-F name=expense-tracker \
319+
-F redeploy=true
320+
```
321+
322+
#### Response 202 (in-place redeploy):
323+
```json
324+
{
325+
"ok": true,
326+
"app_id": "36ace884-1f2c-4a9b-8d11-9c2b7e6a0f4a",
327+
"deploy_id": "36ace884",
328+
"name": "expense-tracker",
329+
"url": "https://expense-tracker-36ace884.deployment.instanode.dev",
330+
"status": "building",
331+
"environment": "development",
332+
"redeployed": true,
333+
"tier": "hobby",
334+
"note": "Redeploying existing app expense-tracker (app_id 36ace884). URL is unchanged. Poll GET /api/v1/deployments/36ace884 for status."
335+
}
336+
```
337+
338+
Note the **same `app_id`, same `url`, and `redeployed: true`** — distinguishing this from a first-time deploy. Optional fields (`port`, `env`, `env_vars`) on a redeploy MERGE with the existing app's config; omitted fields keep their prior value.
339+
340+
#### Response 404 (no matching app for redeploy):
341+
```json
342+
{
343+
"ok": false,
344+
"error": "no_matching_deployment",
345+
"message": "No existing deployment named 'expense-tracker' found for this team. Drop redeploy=true to create a new app, or list deployments with GET /api/v1/deployments to confirm the name."
346+
}
347+
```
348+
349+
When `redeploy=true` is set but no deployment with that name exists on the calling team, the API returns 404 rather than silently creating a fresh app. An agent that gets this response should either fix the `name`, drop the flag, or call `GET /api/v1/deployments` to discover the correct name. Without `redeploy=true`, the call falls through to first-time-deploy semantics and you end up with two parallel URLs.
350+
351+
#### Idempotency note
352+
353+
`redeploy=true` is the **only** documented in-place-update path for single-app deployments. The `POST /deploy/:id/redeploy` endpoint surfaced in the OpenAPI spec is internal to the dashboard's "Rebuild" button and is NOT supported as a public agent surface — its request shape is undocumented and the MCP `redeploy` tool (which calls it) is being retired in favour of `create_deploy({ ..., redeploy: true })`.
354+
355+
---
356+
255357
### POST /stacks/new — Deploy an app stack
256358

257359
**Status: Live**

public/llms.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Pick a descriptive name per resource (e.g. `"prod-db"`, `"sessions-cache"`, `"ev
4040
- **`GET /healthz`** — Shallow liveness probe. Returns 200 with `{ok, commit_id, build_time, version}` if the binary is up and can ping its primary platform DB. Wired to the Kubernetes `livenessProbe`. Use `/readyz` for deep upstream checks.
4141
- **`GET /readyz`** — Deep readiness probe. Multi-component upstream reachability matrix (platform_db, customer_db, redis, provisioner_grpc, NATS, DO Spaces, Brevo, Razorpay, GeoIP). Per-check criticality: `platform_db` + `provisioner_grpc` are CRITICAL (failure → 503); everything else degrades to `200 + overall=degraded`. Each check runs in parallel behind a 10-15s cache to avoid self-DoS via the k8s `readinessProbe` cycle. Response envelope: `{ok, overall, commit_id, checks: {name: {status, latency_ms, last_checked, message?}}}`. Same shape served by api, worker, and provisioner.
4242
- **`POST /deploy/new`** — Container deploy. Multipart form: `tarball=@app.tar.gz` (required, gzipped tar containing Dockerfile + source, ≤50 MB) and `name=my-app` (**required** — same 1–64 char `^[A-Za-z0-9][A-Za-z0-9 _-]*$` rule), plus optional `port=8080`, `env=production` (scope), and `env_vars={"KEY":"VAL"}` (JSON string of env vars injected into the pod). Build runs in-cluster via kaniko (~30–90s); call returns `202` with `status=building`, then `status=healthy` once the URL on `*.deployment.instanode.dev` is live with a Let's Encrypt cert. **Requires a JWT** — `Authorization: Bearer <upgrade_jwt from /db/new or /claim>`.
43+
- **Pushing a new version of an existing app** (in-place update — same `app_id`, same URL, slot count unchanged): add `redeploy=true` as a multipart form field on the SAME `POST /deploy/new` call, with the SAME `name=` you used for the original deploy. The platform finds the existing deployment for that team + name and rebuilds it in place. The response includes `"redeployed": true` and reuses the original URL. If no matching deployment exists for that name, the call returns `404 {"error":"no_matching_deployment"}` — drop the flag and retry to create a fresh app. Without `redeploy=true`, every `POST /deploy/new` mints a NEW `app_id` and a NEW `*.deployment.instanode.dev` URL, even when `name` collides — so an agent shipping v2 of the same app MUST pass `redeploy=true` or the user ends up with two parallel deployments and two distinct URLs.
4344
- **`POST /stacks/new`** — Multi-service deploy. Multipart form: an `instant.yaml` manifest plus one tarball per service, and `name=my-stack` (**required** — same 1–64 char `^[A-Za-z0-9][A-Za-z0-9 _-]*$` rule). **Requires a JWT.** Returns `{ok, slug, stack_url, services: [{name, url, status}]}`. Anonymous stacks (no Bearer JWT) are accepted and inherit the 24h TTL.
4445
- **`GET /api/v1/stacks/{slug}`** — Inspect a stack by slug. Returns the manifest, current per-service status, exposed URLs, and the merged env-vars (redacted). Anonymous-tier stacks are readable by anyone holding the slug; authenticated stacks require the owning team's session JWT.
4546
- **`PATCH /stacks/{slug}/env`** — Merge env-vars into an existing stack. Body: `{"env_vars": {"KEY": "value"}}`. Setting a key to the empty string deletes it. Keys must match `[A-Z_][A-Z0-9_]*`. Total payload after merge capped at 64KiB. Persisted to `stacks.env_vars` JSONB; the next `POST /stacks/{slug}/redeploy` applies them. Anonymous stacks cannot be mutated post-creation. (Replaced a previously silent-no-op handler on 2026-05-20; do not assume any pre-2026-05-20 PATCH actually persisted.)

scripts/fetch-content.mjs

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
*/
3333

3434
import { execSync } from 'child_process'
35-
import { copyFileSync, existsSync } from 'fs'
35+
import { copyFileSync, existsSync, readFileSync } from 'fs'
3636
import { resolve, dirname } from 'path'
3737
import { fileURLToPath } from 'url'
3838

@@ -46,8 +46,28 @@ const BRANCH = process.env.INSTANODE_CONTENT_BRANCH || 'main'
4646
// build. Add new top-level content files here; nested trees (blog/, docs/,
4747
// use-cases/, pages/) are consumed via import.meta.glob at build time and
4848
// don't need an explicit copy.
49+
//
50+
// `requireMarkers` (optional): if set, the destination file is OVERWRITTEN
51+
// from `.content/<src>` only when the upstream copy already contains every
52+
// listed substring. If any marker is missing upstream, the existing
53+
// committed `dest` file is preserved instead, with a WARNING line. This is
54+
// the cross-repo lock-step guard for contract drift: when a new agent-
55+
// facing field is documented in `instanode-web/public/llms.txt` ahead of
56+
// its mirror landing in the content repo, the build does NOT silently
57+
// revert the documentation to the stale upstream version (which would
58+
// break tests in src/lib/llmsContract.test.ts and lie to agents reading
59+
// the live `https://instanode.dev/llms.txt`). Once the content-repo PR
60+
// lands, both copies will diverge in lockstep and the guard becomes a
61+
// no-op. See PR `docs/deploy-new-redeploy-param`.
4962
const SYNC_FILES = [
50-
{ src: 'llms.txt', dest: 'public/llms.txt' },
63+
{
64+
src: 'llms.txt',
65+
dest: 'public/llms.txt',
66+
// Markers MUST be kept in sync with the assertions in
67+
// src/lib/llmsContract.test.ts — see rule 18 (registry-iterating
68+
// regression test) and rule 22 (contract changes touch all surfaces).
69+
requireMarkers: ['redeploy=true', '"redeployed":'],
70+
},
5171
]
5272

5373
function run(cmd, opts = {}) {
@@ -83,13 +103,23 @@ if (existsSync(TARGET)) {
83103

84104
// Sync top-level content files (e.g. llms.txt) into instanode-web so
85105
// Vite's static pipeline serves them at the apex on the next build.
86-
for (const { src, dest } of SYNC_FILES) {
106+
for (const { src, dest, requireMarkers } of SYNC_FILES) {
87107
const srcPath = resolve(TARGET, src)
88108
const destPath = resolve(ROOT, dest)
89109
if (!existsSync(srcPath)) {
90110
console.warn(`fetch-content: WARNING — ${src} missing from .content/; leaving ${dest} as-is.`)
91111
continue
92112
}
113+
if (requireMarkers && requireMarkers.length > 0) {
114+
const upstream = readFileSync(srcPath, 'utf8')
115+
const missing = requireMarkers.filter((m) => !upstream.includes(m))
116+
if (missing.length > 0) {
117+
console.warn(
118+
`fetch-content: WARNING — upstream .content/${src} is missing required markers ${JSON.stringify(missing)}; PRESERVING the committed ${dest} (likely a contract update that has not yet landed in the content repo). Land the content-repo PR to clear this warning.`
119+
)
120+
continue
121+
}
122+
}
93123
copyFileSync(srcPath, destPath)
94124
console.log(`fetch-content: synced .content/${src}${dest}`)
95125
}

src/lib/llmsContract.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/* llmsContract.test.ts — agent-facing docs contract regression test.
2+
*
3+
* CLAUDE.md rule 22 ("contract changes touch all surfaces in one PR") and
4+
* rule 18 ("registry-iterating regression tests, not hand-typed lists")
5+
* together require that any agent-visible contract added to the API has a
6+
* test that fails if the docs silently drift. This file is the single
7+
* place that owns the docs-coverage assertions for that contract.
8+
*
9+
* The CONTRACT_MARKERS registry below lists every (file, substring) pair
10+
* the docs must mention. To add a new field to the contract, append a new
11+
* row — the test iterates the registry so a missing site fails by name,
12+
* not silently. To remove a row, document why in the PR (the field is no
13+
* longer agent-facing).
14+
*
15+
* The `redeploy` rows below were added with the `docs/deploy-new-redeploy-param`
16+
* PR, which mirrors the `redeploy=true` form field on POST /deploy/new that
17+
* the api adds in the parallel PR. Until that api PR ships, the live API
18+
* will reject the flag, but the docs are additive and safe to land first.
19+
*
20+
* NOTE on the build pipeline: `npm run build` runs scripts/fetch-content.mjs
21+
* before `vite build`, which would normally overwrite public/llms.txt from
22+
* the InstaNode-dev/content repo. The script now PRESERVES the local
23+
* public/llms.txt if upstream is missing the required markers (see
24+
* SYNC_FILES.requireMarkers in fetch-content.mjs) — so this test passes in
25+
* CI even before the content-repo follow-up PR lands. Once the content-repo
26+
* PR ships, both sides have the markers and the guard becomes a no-op.
27+
*/
28+
29+
import { describe, it, expect } from 'vitest'
30+
import { readFileSync, existsSync } from 'fs'
31+
import { resolve } from 'path'
32+
33+
// Resolve from instanode-web/ project root regardless of where vitest is
34+
// invoked from. import.meta.url is file:///…/src/lib/llmsContract.test.ts.
35+
const PROJECT_ROOT = resolve(new URL(import.meta.url).pathname, '../../..')
36+
37+
type DocsMarker = {
38+
// Human-readable contract item — printed in the failure message so an
39+
// engineer who breaks this test knows what they removed.
40+
field: string
41+
// Project-root-relative path to the docs file that MUST mention `field`.
42+
file: string
43+
// Substring(s) the file MUST contain. ALL must be present for the row
44+
// to pass — this is an AND, not an OR.
45+
mustContain: string[]
46+
}
47+
48+
// Single source of truth for "what does an agent need to find in the
49+
// docs to use the contract correctly". Add a row when adding an agent-
50+
// facing field to the API; delete a row when the field is retired.
51+
const CONTRACT_MARKERS: DocsMarker[] = [
52+
{
53+
field: 'POST /deploy/new ?redeploy=true form field (in-place update)',
54+
file: 'public/llms.txt',
55+
mustContain: ['redeploy=true', '"redeployed":'],
56+
},
57+
{
58+
field: 'POST /deploy/new ?redeploy=true form field (in-place update)',
59+
file: 'public/llms-full.txt',
60+
mustContain: ['redeploy=true', '"redeployed":', 'no_matching_deployment'],
61+
},
62+
]
63+
64+
describe('agent docs contract — POST /deploy/new redeploy=true', () => {
65+
// One it() per registry row so a failure names the file and field.
66+
for (const row of CONTRACT_MARKERS) {
67+
it(`${row.file} documents ${row.field}`, () => {
68+
const path = resolve(PROJECT_ROOT, row.file)
69+
expect(existsSync(path), `${row.file} does not exist at ${path}`).toBe(true)
70+
const body = readFileSync(path, 'utf8')
71+
for (const needle of row.mustContain) {
72+
// Includes-check, not regex, because the marker strings contain
73+
// characters (quotes, equals, colons) that would need escaping
74+
// and the docs are large enough that a regex backtrack on a
75+
// typo would dominate the failure output. A plain substring
76+
// miss prints "expected … to include 'redeploy=true'" which is
77+
// exactly the signal a docs author needs.
78+
expect(
79+
body.includes(needle),
80+
`${row.file}: expected the ${row.field} contract row to include the substring ${JSON.stringify(needle)}. If you intentionally removed this docs surface, also remove the corresponding row from CONTRACT_MARKERS in src/lib/llmsContract.test.ts and link the deprecation PR in the commit message.`
81+
).toBe(true)
82+
}
83+
})
84+
}
85+
86+
it('CONTRACT_MARKERS registry is non-empty (guards against accidental wipeout)', () => {
87+
// Defensive: if someone ever lands a refactor that empties the
88+
// registry, this test fails so the for-loop above doesn't silently
89+
// pass with zero assertions. Per CLAUDE.md rule 18, the registry
90+
// IS the contract — a deleted row is a deleted promise.
91+
expect(CONTRACT_MARKERS.length).toBeGreaterThan(0)
92+
})
93+
})

src/pages/ForAgentsPage.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ const REASONS: { eyebrow: string; body: string }[] = [
5151
eyebrow: '04 · safe retries on every create',
5252
body:
5353
'Every create endpoint deduplicates retries. Pass an Idempotency-Key header for true exactly-once across a 24h window, or just retry safely — the server fingerprints (scope + route + canonical body) and replays for 120s. The response header X-Idempotent-Replay: true tells you when you hit the cache.'
54+
},
55+
{
56+
eyebrow: '05 · in-place app updates',
57+
body:
58+
'Pushing v2 of an app? Re-call POST /deploy/new with the same name + redeploy=true (multipart form field). Same app_id, same URL, no extra slot. Without the flag, each call mints a new deployment — by design, so retries are safe.'
5459
}
5560
]
5661

0 commit comments

Comments
 (0)