instances: -kind reaches the door the record shape already had
handleAPIInstanceCreate has read a kind since the route-records design, and its own comment anticipates the static case — a "kind":"static" body cannot reach for a hostname outside the app's own domain. The CLI never sent one, so every record it wrote was an instance. The console-side test is a PIN, not a red-green cycle: kind:static at an app's own apex already worked. What it pins is the pair — kind AND an explicit channel — because the mint depends on both and an omitted channel falls to stable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5 files changed,
+124
−5
apimode.go+3 −2apimode_test.go+47 −2instances_cli.go+9 −1instances_e2e_test.go+15 −0internal/console/apiinstances_test.go+50 −0
diff --git a/apimode.go b/apimode.go| index 661fd08a3..a29d61d04 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -2164,14 +2164,15 @@ type instanceProbeReq struct { |
| // upstream (kind defaults to "instance", which needs no addr at all), and |
| // probe nil means unprobed — the same "absent, not zero" contract the wire |
| // carries for every optional field on this door. |
| -func (s *apiSession) instanceCreate(ctx context.Context, a apiAcct, app, host, region, channel string, addrs []string, probe *instanceProbeReq) (apiInstanceRecord, error) { |
| +func (s *apiSession) instanceCreate(ctx context.Context, a apiAcct, app, host, region, channel, kind string, addrs []string, probe *instanceProbeReq) (apiInstanceRecord, error) { |
| in := struct { |
| Host string `json:"host"` |
| Region string `json:"region,omitempty"` |
| Channel string `json:"channel,omitempty"` |
| + Kind string `json:"kind,omitempty"` |
| Addrs []string `json:"addrs,omitempty"` |
| Probe *instanceProbeReq `json:"probe,omitempty"` |
| - }{host, region, channel, addrs, probe} |
| + }{host, region, channel, kind, addrs, probe} |
| status, body, err := s.postJSON(ctx, apiPath(a, app, "/instances"), in) |
| if err != nil { |
| return apiInstanceRecord{}, err |
diff --git a/apimode_test.go b/apimode_test.go| index 941e12fd9..a8eea9d3f 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -771,7 +771,7 @@ func TestInstanceCreateSendsAddrsAndProbe(t *testing.T) { |
| s := newTestSession(ts.URL) |
| rec, err := s.instanceCreate(context.Background(), apiAcct{Seg: "bah"}, "app", |
| - "a.example.com", "", "", []string{"10.0.0.1:8090", "10.0.0.2:8090"}, |
| + "a.example.com", "", "", "", []string{"10.0.0.1:8090", "10.0.0.2:8090"}, |
| &instanceProbeReq{Path: "/healthz", Status: 200}) |
| if err != nil { |
| t.Fatalf("instanceCreate: %v", err) |
| @@ -799,7 +799,7 @@ func TestInstanceCreateSendsAddrsAndProbe(t *testing.T) { |
| // No addrs/probe given at all: neither key crosses the wire. |
| gotBody = "" |
| if _, err := s.instanceCreate(context.Background(), apiAcct{Seg: "bah"}, "app", |
| - "b.example.com", "", "", nil, nil); err != nil { |
| + "b.example.com", "", "", "", nil, nil); err != nil { |
| t.Fatalf("instanceCreate (no addrs/probe): %v", err) |
| } |
| if strings.Contains(gotBody, "addrs") || strings.Contains(gotBody, "probe") { |
| @@ -3733,3 +3733,48 @@ func TestResolvedAccountExplicitFlagsBypass(t *testing.T) { |
| t.Fatalf("explicit flags must never consult the endpoint; hits = %d", *hits) |
| } |
| } |
| + |
| +// TestAPISessionInstanceCreateSendsKind pins the field the static mint |
| +// depends on: the door has read a "kind" since the route-records design, but |
| +// this client never sent one, so every record it wrote was an instance. |
| +// Empty stays absent from the wire — the door's own "empty means instance" |
| +// contract, not a value this client invents. |
| +func TestAPISessionInstanceCreateSendsKind(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + kind string |
| + wantKind any |
| + }{ |
| + {"static", "static", "static"}, |
| + {"empty is omitted", "", nil}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + var got map[string]any |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { |
| + t.Errorf("decode body: %v", err) |
| + } |
| + w.WriteHeader(http.StatusCreated) |
| + io.WriteString(w, `{"host":"mysite.bab.oncarlos.test","kind":"static","channel":"edge"}`) |
| + })) |
| + defer ts.Close() |
| + |
| + rec, err := newTestSession(ts.URL).instanceCreate(context.Background(), |
| + apiAcct{Seg: "bab"}, "mysite", "mysite.bab.oncarlos.test", "", "edge", tc.kind, nil, nil) |
| + if err != nil { |
| + t.Fatalf("instanceCreate: %v", err) |
| + } |
| + if got["kind"] != tc.wantKind { |
| + t.Fatalf("wire kind = %#v, want %#v", got["kind"], tc.wantKind) |
| + } |
| + // The channel is the rail: an omitted one is defaulted to stable |
| + // by the door, and no door can change a record's channel after. |
| + if got["channel"] != "edge" { |
| + t.Fatalf("wire channel = %#v, want an explicit %q", got["channel"], "edge") |
| + } |
| + if rec.Kind != "static" { |
| + t.Fatalf("decoded Kind = %q", rec.Kind) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/instances_cli.go b/instances_cli.go| index 951f87137..5e0f145e3 100644 |
| --- a/instances_cli.go |
| +++ b/instances_cli.go |
| @@ -203,6 +203,7 @@ func instancesEnable(ctx context.Context, s release.Store, acct, app string, m p |
| } |
| const instancesRecordsUsage = `usage: carlos instances create -app <a> -host <h> [-region <r>] [-channel <c>] |
| + [-kind instance|service|static] |
| [-addr <a:p,...>] [-health-path <p>] [-health-status <n>] |
| carlos instances list -app <a> [-match <substr>] [-health <slug>] |
| carlos instances delete -app <a> -host <h> |
| @@ -219,6 +220,11 @@ box's reconcile pass materializes the route. Common flags: |
| create-only flags: |
| + -kind <k> route class: instance (default) | service | static. |
| + A static record serves a file tree straight off |
| + its channel pointer — no process, no upstream — |
| + which is what "carlos deploy -kind static" mints |
| + for an app's platform host |
| -addr <a:p,...> comma-separated TCP upstream set, host:port each |
| (e.g. 10.0.0.1:8090,10.0.0.2:8090); omitted means |
| the platform's own default upstream |
| @@ -282,6 +288,7 @@ func runInstancesCreate(args []string) { |
| host := fs.String("host", "", "instance host — one DNS label + \".\" + the app's instance domain") |
| region := fs.String("region", "", "region code for the record (subject to the manifest's allow-list)") |
| channel := fs.String("channel", "", "release channel the instance follows (default: the manifest's, then stable)") |
| + kind := fs.String("kind", "", "route class: instance (default) | service | static — a static record serves a file tree off the channel pointer and has no process") |
| addr := fs.String("addr", "", "comma-separated TCP upstream set, host:port each (e.g. 10.0.0.1:8090,10.0.0.2:8090); omitted means the platform's own default upstream") |
| healthPath := fs.String("health-path", "", "states the whole probe alongside -health-status: request path (default \"/\"); honoured for a single -addr too") |
| healthStatus := fs.Int("health-status", 0, "probe's expected response status (default 200); fields neither flag names take the platform's own defaults") |
| @@ -301,7 +308,8 @@ func runInstancesCreate(args []string) { |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage) |
| rec, err := sess.instanceCreate(context.Background(), acct, app2, |
| - strings.TrimSpace(*host), strings.TrimSpace(*region), strings.TrimSpace(*channel), addrs, probe) |
| + strings.TrimSpace(*host), strings.TrimSpace(*region), strings.TrimSpace(*channel), |
| + strings.TrimSpace(*kind), addrs, probe) |
| if err != nil { |
| log.Fatalf("instances create: %v", err) |
| } |
diff --git a/instances_e2e_test.go b/instances_e2e_test.go| index 12968ff74..19edb52a0 100644 |
| --- a/instances_e2e_test.go |
| +++ b/instances_e2e_test.go |
| @@ -119,6 +119,21 @@ func TestE2EInstancesThroughConsole(t *testing.T) { |
| t.Fatalf("record = %+v, want beta/titogo created by %s", rec, addr) |
| } |
| + // --- create -kind static: the flag reaches the record ------------------ |
| + // The shape `carlos deploy` mints for a static app's platform host. Here |
| + // only to prove the FLAG is wired to the wire field the door has always |
| + // read; the door's own acceptance of it is pinned console-side. |
| + staticHost := "site." + domain |
| + buf.Reset() |
| + runInstancesCreate([]string{"-app", "titogo", "-account", "ops", "-host", staticHost, "-kind", "static"}) |
| + staticRec, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", staticHost) |
| + if err != nil { |
| + t.Fatalf("record after create -kind static: %v", err) |
| + } |
| + if staticRec.Kind != "static" { |
| + t.Fatalf("record = %+v, want kind static", staticRec) |
| + } |
| + |
| // --- list ---------------------------------------------------------------- |
| buf.Reset() |
| runInstancesList([]string{"-app", "titogo", "-account", "ops"}) |
diff --git a/internal/console/apiinstances_test.go b/internal/console/apiinstances_test.go| index 8da4b3b74..259ee6431 100644 |
| --- a/internal/console/apiinstances_test.go |
| +++ b/internal/console/apiinstances_test.go |
| @@ -1978,3 +1978,53 @@ func TestAPIInstanceStoreFailure(t *testing.T) { |
| t.Fatalf("delete refusal leaks the store's error text: %s", out) |
| } |
| } |
| + |
| +// TestAPIInstanceCreateStaticAtOwnApex pins the shape `carlos deploy -kind |
| +// static` mints for an app with no route: a kind:static record at the app's |
| +// OWN platform apex, carrying an explicit channel. |
| +// |
| +// The channel is the half worth a test of its own. An omitted channel is |
| +// defaulted to the manifest's, then to "stable" — and a fresh app's pipeline |
| +// entry is "edge", so a mint that let the default stand would write a route |
| +// that follows a channel nothing ever promotes to, with no door able to |
| +// change it afterwards (the hello.bdn incident, 2026-08-22). |
| +func TestAPIInstanceCreateStaticAtOwnApex(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-inst-static-apex") |
| + fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + if status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), member, `{}`); status != http.StatusOK { |
| + t.Fatalf("enable = %d %s", status, out) |
| + } |
| + host := "titogo." + fx.sqid + ".oncarlos.test" |
| + |
| + status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, |
| + `{"host":"`+host+`","kind":"static","channel":"edge"}`) |
| + if status != http.StatusCreated { |
| + t.Fatalf("static apex create = %d %s, want 201", status, out) |
| + } |
| + var rec provision.Record |
| + if err := json.Unmarshal([]byte(out), &rec); err != nil { |
| + t.Fatalf("create body %s: %v", out, err) |
| + } |
| + if rec.Kind != "static" { |
| + t.Fatalf("echoed Kind = %q, want static", rec.Kind) |
| + } |
| + if rec.Channel != "edge" { |
| + t.Fatalf("echoed Channel = %q, want the requested edge — an omitted channel falls to stable", rec.Channel) |
| + } |
| + |
| + // The bucket copy is what the reconcile pass mints the route from, so it |
| + // is the one that has to carry both. |
| + stored, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "titogo", host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if stored.Kind != "static" || stored.Channel != "edge" { |
| + t.Fatalf("stored record = %+v, want kind static on channel edge", stored) |
| + } |
| + // A static record with no upstream and no unit is the shape ValidateShape |
| + // requires; anything the door let through that carried one would wedge |
| + // the host. |
| + if len(stored.Addrs) != 0 || stored.Unit != "" { |
| + t.Fatalf("stored record = %+v, want no upstream and no unit", stored) |
| + } |
| +} |