carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev 37ad475681e5eb81647a9c1a4abdf6c3709c7a27 parent 27ec15a
5 files changed, +162 −2
  • apimode.go +26 −0
  • apimode_test.go +52 −0
  • cli.go +8 −2
  • instances_cli.go +59 −0
  • instances_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"})