cli: carlos instances set-channel, and the box refusal now names it
The member-facing half. set-channel sits beside set-upstreams because it is the same act — change one field of one record — and the record is where a route's channel lives. A gated move exits 0 naming where to finish rather than failing: the change is recorded and waiting for people, which is deploy's shape for a proposed promote, not an error. carlos route -channel already explained why a provisioned row cannot be edited on the box. It now also says where the member should go instead, which was the missing half of that refusal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5 files changed,
+162
−2
apimode.go+26 −0apimode_test.go+52 −0cli.go+8 −2instances_cli.go+59 −0instances_e2e_test.go+17 −0
diff --git a/apimode.go b/apimode.go| index 84fedc3cd..87c1322ad 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -2212,6 +2212,32 @@ func (s *apiSession) instanceCreate(ctx context.Context, a apiAcct, app, host, r |
| return out, nil |
| } |
| +// instanceSetChannel moves one route onto a different release channel — |
| +// `carlos instances set-channel`, the CLI front over |
| +// handleAPIInstanceChannel. A gated move comes back as a 202 the same way a |
| +// gated promote does, so the caller gets an *apiProposedError naming where |
| +// to finish rather than a refusal that reads like a failure. |
| +func (s *apiSession) instanceSetChannel(ctx context.Context, a apiAcct, app, host, channel string) (apiInstanceRecord, error) { |
| + in := struct { |
| + Channel string `json:"channel"` |
| + }{channel} |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)+"/channel"), in) |
| + if err != nil { |
| + return apiInstanceRecord{}, err |
| + } |
| + if status == http.StatusAccepted { |
| + return apiInstanceRecord{}, proposedFromBody(body) |
| + } |
| + if status != http.StatusOK { |
| + return apiInstanceRecord{}, apiErrorFromBody(status, body) |
| + } |
| + var out apiInstanceRecord |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return apiInstanceRecord{}, fmt.Errorf("instances set-channel: unreadable response: %w", err) |
| + } |
| + return out, nil |
| +} |
| + |
| // instanceSetUpstreams asks the console to repoint one instance's upstream |
| // set and/or its probe — `carlos instances set-upstreams`, the CLI front |
| // over handleAPIInstanceUpdate's PATCH (Task 7, issue #152). |
diff --git a/apimode_test.go b/apimode_test.go| index a8eea9d3f..c6f59418d 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -3778,3 +3778,55 @@ func TestAPISessionInstanceCreateSendsKind(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// TestAPISessionInstanceSetChannel pins the move client: the channel on the |
| +// wire, the record back on a 200, and — the case that must not read as a |
| +// failure — a 202 decoded as a PROPOSAL naming where to finish. |
| +func TestAPISessionInstanceSetChannel(t *testing.T) { |
| + t.Run("lands", func(t *testing.T) { |
| + var got map[string]any |
| + var gotPath string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath = r.URL.Path |
| + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { |
| + t.Errorf("decode body: %v", err) |
| + } |
| + io.WriteString(w, `{"host":"web.bab.oncarlos.test","channel":"edge","kind":"instance"}`) |
| + })) |
| + defer ts.Close() |
| + |
| + rec, err := newTestSession(ts.URL).instanceSetChannel(context.Background(), |
| + apiAcct{Seg: "bab"}, "mysite", "web.bab.oncarlos.test", "edge") |
| + if err != nil { |
| + t.Fatalf("instanceSetChannel: %v", err) |
| + } |
| + if got["channel"] != "edge" { |
| + t.Fatalf("wire channel = %#v", got["channel"]) |
| + } |
| + if !strings.HasSuffix(gotPath, "/instances/web.bab.oncarlos.test/channel") { |
| + t.Fatalf("path = %q, want the per-host channel door", gotPath) |
| + } |
| + if rec.Channel != "edge" { |
| + t.Fatalf("decoded Channel = %q", rec.Channel) |
| + } |
| + }) |
| + |
| + t.Run("gated move is a proposal, not a failure", func(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusAccepted) |
| + io.WriteString(w, `{"result":"proposed","pending_id":"pc1","needed":2,"confirmed":0,`+ |
| + `"console_url":"https://console.example/a/bab/apps/mysite"}`) |
| + })) |
| + defer ts.Close() |
| + |
| + _, err := newTestSession(ts.URL).instanceSetChannel(context.Background(), |
| + apiAcct{Seg: "bab"}, "mysite", "web.bab.oncarlos.test", "edge") |
| + var prop *apiProposedError |
| + if !errors.As(err, &prop) { |
| + t.Fatalf("err = %v, want an *apiProposedError", err) |
| + } |
| + if prop.Needed != 2 || prop.ConsoleURL == "" { |
| + t.Fatalf("proposal = %+v, want the quorum and where to finish", prop) |
| + } |
| + }) |
| +} |
diff --git a/cli.go b/cli.go| index 35b914db7..c7c919307 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -287,6 +287,9 @@ func runInstances(args []string) { |
| case "set-upstreams": |
| runInstancesSetUpstreams(args[1:]) |
| return |
| + case "set-channel": |
| + runInstancesSetChannel(args[1:]) |
| + return |
| } |
| } |
| fs := flag.NewFlagSet("instances", flag.ExitOnError) |
| @@ -781,8 +784,11 @@ func runRoute(args []string) { |
| chanVerdicts[i] = verdict{rt: rt, from: from, kind: vRecordOwned, note: fmt.Sprintf( |
| "provisioned route on %s — its channel is owned by the instance record at\n"+ |
| "apps/%s/%s/instances/%s.json, which the provisioning reconciler converges onto this row\n"+ |
| - "on every pass; a change here is reverted within seconds of the next serial bump.", |
| - from, release.RouteAccount(rt), rt.App, rt.Host)} |
| + "on every pass; a change here is reverted within seconds of the next serial bump.\n"+ |
| + "Change it where it lives:\n"+ |
| + " carlos instances set-channel -app %s -host %s -channel %s", |
| + from, release.RouteAccount(rt), rt.App, rt.Host, |
| + rt.App, rt.Host, *channel)} |
| default: |
| chanVerdicts[i] = verdict{rt: rt, from: from, kind: vNeedsCheck} |
| anyNeedsCheck = true |
diff --git a/instances_cli.go b/instances_cli.go| index 5e0f145e3..f4859da26 100644 |
| --- a/instances_cli.go |
| +++ b/instances_cli.go |
| @@ -23,6 +23,7 @@ package main |
| import ( |
| "context" |
| + "errors" |
| "flag" |
| "fmt" |
| "log" |
| @@ -209,6 +210,7 @@ const instancesRecordsUsage = `usage: carlos instances create -app <a> -host <h> |
| carlos instances delete -app <a> -host <h> |
| carlos instances set-upstreams -app <a> -host <h> [-addrs <a:p,...>] |
| [-health-path <p>] [-health-status <n>] |
| + carlos instances set-channel -app <a> -host <h> -channel <c> |
| Signed-in only (carlos auth login): instance records are console-authorized |
| writes — the console checks the host against the app's manifest and the |
| @@ -241,6 +243,12 @@ list-only flags: |
| -health <slug> only this state: running, serving, setting-up, asleep, |
| no-readings, not-responding, failed |
| +set-channel moves ONE route onto a different release channel. The channel |
| +lives in the instance record, which the box converges the route from every |
| +pass — so this is the verb that changes it, not "carlos route -channel", |
| +which edits a row the next reconcile overwrites. A move that the app's |
| +pipeline gates is proposed and finished in the console, like a promote. |
| + |
| set-upstreams-only flags: |
| -addrs <a:p,...> comma-separated TCP upstream set, host:port each, |
| @@ -591,3 +599,54 @@ func runSteering(args []string) { |
| mutedf(stdout, " the record and its registry row update within seconds of the next converge tick;") |
| mutedf(stdout, " the fleet only ACTS on this once a deployment arms its Route53 steering converger") |
| } |
| + |
| +// runInstancesSetChannel is `carlos instances set-channel` — move one route |
| +// onto a different release channel, the door that did not exist when |
| +// hello.bdn landed on stable and could not be moved off it without a raw |
| +// bucket write. |
| +// |
| +// The record is where a route's channel LIVES (the provisioning reconciler |
| +// converges the registry row from it every pass), which is why this is an |
| +// instances verb and not `carlos route -channel`: that one edits a row the |
| +// next reconcile overwrites, and says so. |
| +func runInstancesSetChannel(args []string) { |
| + fs := flag.NewFlagSet("instances set-channel", flag.ExitOnError) |
| + app := fs.String("app", "", "app the instance belongs to") |
| + host := fs.String("host", "", "instance host whose channel to change") |
| + channel := fs.String("channel", "", "release channel the route should follow") |
| + consoleFlag := fs.String("console", "", "console to act through (default: the only logged-in one)") |
| + acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| + fs.Parse(args) |
| + if *host == "" || *channel == "" { |
| + log.Fatal(instancesRecordsUsage) |
| + } |
| + sess := requireAPISession("set-channel", *consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage) |
| + rec, err := sess.instanceSetChannel(context.Background(), acct, app2, |
| + strings.TrimSpace(*host), strings.TrimSpace(*channel)) |
| + if err != nil { |
| + // A gated move is not a failure: the change is recorded and waiting |
| + // for people, so this exits 0 naming where to finish — the shape |
| + // `carlos deploy` uses for a proposed promote. |
| + var prop *apiProposedError |
| + if errors.As(err, &prop) { |
| + fmt.Fprintf(stdout, "moving %s to %s is proposed and needs %d confirmation(s) at %s\n", |
| + strings.TrimSpace(*host), strings.TrimSpace(*channel), prop.Needed, prop.ConsoleURL) |
| + return |
| + } |
| + log.Fatalf("instances set-channel: %v", err) |
| + } |
| + fmt.Fprintf(stdout, "%s %s to %s\n", colorFor(stdout).ok("moved"), rec.Host, rec.Channel) |
| + if rec.Kind == "static" { |
| + // The one wait worth naming: a static route trusts a resolved |
| + // channel pointer for a while before re-reading it, so the URL can |
| + // lag the record. |
| + mutedf(stdout, "a static route re-checks its channel about every %ds, so the URL can take that long to flip\n", |
| + int(staticRecheckWindow.Seconds())) |
| + } else { |
| + mutedf(stdout, " the change lands on its box within seconds of the next converge tick") |
| + } |
| +} |
diff --git a/instances_e2e_test.go b/instances_e2e_test.go| index 19edb52a0..35c15bacf 100644 |
| --- a/instances_e2e_test.go |
| +++ b/instances_e2e_test.go |
| @@ -134,6 +134,23 @@ func TestE2EInstancesThroughConsole(t *testing.T) { |
| t.Fatalf("record = %+v, want kind static", staticRec) |
| } |
| + // --- set-channel: the record is where a route's channel lives ---------- |
| + buf.Reset() |
| + runInstancesSetChannel([]string{"-app", "titogo", "-account", "ops", "-host", host, "-channel", "edge"}) |
| + if want := "moved " + host + " to edge"; !strings.Contains(buf.String(), want) { |
| + t.Fatalf("set-channel stdout = %q, want %q", buf.String(), want) |
| + } |
| + moved, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", host) |
| + if err != nil { |
| + t.Fatalf("record after set-channel: %v", err) |
| + } |
| + if moved.Channel != "edge" { |
| + t.Fatalf("record channel = %q, want edge", moved.Channel) |
| + } |
| + // And back, so the rest of this test still sees the channel it set up. |
| + buf.Reset() |
| + runInstancesSetChannel([]string{"-app", "titogo", "-account", "ops", "-host", host, "-channel", "beta"}) |
| + |
| // --- list ---------------------------------------------------------------- |
| buf.Reset() |
| runInstancesList([]string{"-app", "titogo", "-account", "ops"}) |