canary: fill the reserved verb with the sugar over what already works (#384)
Canarying an instance app has worked all along — go.tito.io follows canary/platform-adoption, pinned as a live production channel in internal/release/laddercheck_test.go — but it took `carlos instances create` + `ship` + `promote` and a hostname the member had to build themselves out of README's scheme. That gap has now cost a session. Reading `carlos canary`'s `not implemented` next to README's "canary hosts are minted for static apps only" produced the conclusion that CARLOS could not canary an instance app at all. Both statements are true and narrow; together they read as a blanket no. Neither is about canaries — the second is about automatic hostname minting, and it refuses precisely because a minted host for an instance app could only alias the app's stable process. So: three verbs, composing calls that already exist. carlos canary [deploy] --app <a> [--name <n>] <artifact> carlos canary ls --app <a> carlos canary rm --app <a> --name <n> deploy creates the canary's own instance record if it needs to (or moves an existing one onto the channel, or writes nothing when it is already right), ships, promotes, and waits on the canary host's own X-Carlos-Version — never the app's stable host. ls and rm are not optional extras. AGENTS.md's rule is that every carlos command completes the job with no box access, and a canary you can create but cannot enumerate or tear down fails that, especially one carrying a seven-day lease. ls joins the records to the channel pointers to show what is left of each. The name defaults to the current git branch, slugified. It has to satisfy two alphabets at once — a channel segment and a DNS label — and the label is the stricter, so it binds. The dot is the trap: legal in a channel segment, so "v1.2.3" would pass ValidChannel and then mint a host outside the deployment's wildcard. An explicit --name is refused rather than repaired; silently rewriting a name someone typed is how you promote to a channel you never asked for. Not included, and said plainly in the docs: traffic splitting. A canary is a URL, not a percentage of production traffic on the app's own hostname. That would be real work in internal/edge. Also drops canary from noHintVerbs — it was listed there as a reserved word, and is a member verb now. Claude-Session: https://claude.ai/code/session_015jcPXsYTT3ySVTrrv6gHJK
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+952
−10
canary.go+534 −0canary_test.go+266 −0docs/site/cli.md+40 −8docs/superpowers/plans/2026-09-04-carlos-canary.md+108 −0main.go+3 −1update.go+1 −1
CI passed — run details
diff --git a/canary.go b/canary.go| new file mode 100644 |
| index 000000000..950d88c07 |
| --- /dev/null |
| +++ b/canary.go |
| @@ -0,0 +1,534 @@ |
| +// canary.go fills the `carlos canary` verb main.go reserved and left exiting |
| +// "not implemented". It is sugar, not a subsystem: canarying an instance app |
| +// has worked all along — go.tito.io follows canary/platform-adoption, pinned |
| +// as a live production channel in internal/release/laddercheck_test.go — but |
| +// it took `carlos instances create` + `ship` + `promote` and a hostname the |
| +// member had to construct themselves from README's scheme. A September 2026 |
| +// session read this file's old `not implemented` alongside README's "canary |
| +// hosts are minted for static apps only" and concluded the platform could |
| +// not canary an instance app at all; both statements were true and narrow, |
| +// and together they read as a blanket no. This closes that gap by composing |
| +// calls that already exist. |
| +// |
| +// Three verbs, because AGENTS.md's design rule ("every carlos command is |
| +// designed for a user with no access to the underlying infra") means a |
| +// canary you can create but cannot enumerate or tear down is half a feature |
| +// — especially one carrying a seven-day lease. |
| +// |
| +// What it deliberately does NOT do is split traffic. This is a URL per |
| +// canary, not a percentage of production traffic on one hostname; weighted |
| +// routing would be real work in internal/edge, and nothing here pretends |
| +// otherwise. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "flag" |
| + "fmt" |
| + "log" |
| + "os/exec" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const canaryUsage = `usage: carlos canary [deploy] --app <a> [--name <n>] [--version <sha>] [--kind binary|static] |
| + [--target <goos-goarch>] [--label <l>] [--timeout 120s] <files-or-site-dir> |
| + carlos canary ls --app <a> |
| + carlos canary rm --app <a> --name <n> |
| + |
| +Ships a version and serves it on its own host, following its own canary/<name> |
| +channel, without touching what the app's other instances serve. The bare form |
| +is deploy. |
| + |
| +--name defaults to the current git branch, slugified. The canary's host is |
| +<name>.<app>.<your account's platform domain>, and its channel is |
| +canary/<name>. |
| + |
| +Promotion into a canary is never refused, and a canary costs no rung: a build |
| +that has been on one still enters the pipeline as a fresh build, so canarying |
| +and then promoting to edge is the ordinary path, not a bypass. |
| + |
| +Canaries carry a seven-day lease, refreshed by every re-promote. "canary ls" |
| +shows what is left of it.` |
| + |
| +// canaryWatchEvery is how often the deploy leg re-probes the canary host. |
| +// The same 2s deploy.go uses — and it is not a coincidence that canary/* |
| +// routes converge on the box's 2s fast loop rather than the 5-minute tick |
| +// (internal/host/agent.go, isCanaryRoute): there is no point polling slower |
| +// than the thing being watched can change. |
| +const canaryWatchEvery = 2 * time.Second |
| + |
| +// runCanary dispatches the sub-verbs. They are positional words that must |
| +// never reach fs.Parse, the same shape runInstances uses (cli.go) — and the |
| +// bare form is deploy, so `carlos canary --app jam ./jam` works without the |
| +// word. |
| +func runCanary(args []string) { |
| + if len(args) > 0 { |
| + switch args[0] { |
| + case "ls", "list": |
| + runCanaryList(args[1:]) |
| + return |
| + case "rm", "delete": |
| + runCanaryRemove(args[1:]) |
| + return |
| + case "deploy": |
| + runCanaryDeploy(args[1:]) |
| + return |
| + } |
| + } |
| + runCanaryDeploy(args) |
| +} |
| + |
| +// ---- names, channels and hosts ------------------------------------------- |
| + |
| +// canarySlug turns a git branch into a canary name. The name has to satisfy |
| +// two alphabets at once, because it is both the channel's second segment |
| +// (release.ValidChannel: [a-zA-Z0-9._-], 64) and the leftmost label of the |
| +// canary host. The DNS label is the stricter of the two and is therefore the |
| +// one enforced: lowercase, [a-z0-9-] only, 63 max, no leading or trailing |
| +// separator. |
| +// |
| +// The dot is the trap worth naming. It is legal in a channel segment, so |
| +// "v1.2.3" would pass ValidChannel happily — and then mint the host |
| +// "v1.2.3.jam.bab.oncarlos.com", which is not the app's canary at a deeper |
| +// label, it is a different name entirely, outside the wildcard the |
| +// deployment's DNS actually holds. So dots are replaced like any other |
| +// out-of-alphabet byte. |
| +func canarySlug(branch string) string { |
| + var b strings.Builder |
| + sep := false |
| + for _, r := range strings.ToLower(strings.TrimSpace(branch)) { |
| + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { |
| + b.WriteRune(r) |
| + sep = false |
| + continue |
| + } |
| + // Collapse any run of out-of-alphabet bytes to a single separator, |
| + // and never open with one — so "feature/new-queue" and |
| + // "feature//new-queue" name the same canary rather than two. |
| + if !sep && b.Len() > 0 { |
| + b.WriteByte('-') |
| + sep = true |
| + } |
| + } |
| + s := b.String() |
| + if len(s) > 63 { |
| + s = s[:63] |
| + } |
| + // Trimmed AFTER truncation as well as during the walk: cutting at 63 can |
| + // land mid-separator, and "foo-" is a valid channel segment but an |
| + // invalid DNS label — exactly the split this function exists to close. |
| + return strings.Trim(s, "-") |
| +} |
| + |
| +// validCanaryName reports whether s can be both a DNS label and a channel |
| +// segment. Written out rather than delegated to a regexp so the two |
| +// constraints it merges stay visible at the point of refusal. |
| +func validCanaryName(s string) bool { |
| + if s == "" || len(s) > 63 { |
| + return false |
| + } |
| + if s[0] == '-' || s[len(s)-1] == '-' { |
| + return false |
| + } |
| + for i := 0; i < len(s); i++ { |
| + c := s[i] |
| + if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' { |
| + continue |
| + } |
| + return false |
| + } |
| + return true |
| +} |
| + |
| +// canaryNameFrom resolves the canary's name from --name and the current git |
| +// branch, in that order. |
| +// |
| +// A --name given explicitly is refused rather than repaired. Silently |
| +// rewriting a name someone deliberately typed is how you end up promoting to |
| +// a channel you never asked for and watching a host you never named — the |
| +// branch fallback is a convenience and gets slugified, a typed flag is an |
| +// instruction and gets checked. |
| +func canaryNameFrom(flagName, branch string) (string, error) { |
| + if flagName != "" { |
| + if !validCanaryName(flagName) { |
| + return "", fmt.Errorf("canary: %q cannot be a canary name — it is both a channel segment and a hostname label, so it must be lowercase letters, digits and dashes only (max 63)", flagName) |
| + } |
| + return flagName, nil |
| + } |
| + // "HEAD" is what `git rev-parse --abbrev-ref HEAD` reports on a detached |
| + // checkout. Slugging it would mint canary/head — a plausible-looking |
| + // channel that means nothing and that a second detached deploy from an |
| + // unrelated commit would silently reuse. |
| + if branch == "" || branch == "HEAD" { |
| + return "", errors.New("canary: no branch to name this canary after — pass --name <n>") |
| + } |
| + name := canarySlug(branch) |
| + if name == "" { |
| + return "", fmt.Errorf("canary: branch %q has nothing usable as a name — pass --name <n>", branch) |
| + } |
| + return name, nil |
| +} |
| + |
| +// canaryChannel is the channel a named canary follows. The canary/ prefix is |
| +// load-bearing in three places that must all agree: the ladder's |
| +// unconditional "no gate in" (internal/release/ladder.go), the box's fast |
| +// converge loop (internal/host/agent.go, isCanaryRoute) and the seven-day |
| +// lease (internal/release/canary_ttl.go). A bare "canary" is none of those. |
| +func canaryChannel(name string) string { return release.RungCanary + "/" + name } |
| + |
| +// canaryNameOf is canaryChannel's inverse, for the listing: the name is what |
| +// a member types into `canary rm`, so `canary ls` has to be able to recover |
| +// it from the channel it prints. "" for anything that is not a canary/* |
| +// channel. |
| +func canaryNameOf(channel string) string { |
| + prefix := release.RungCanary + "/" |
| + if !strings.HasPrefix(channel, prefix) { |
| + return "" |
| + } |
| + return strings.TrimPrefix(channel, prefix) |
| +} |
| + |
| +// canaryHost builds README's documented scheme, |
| +// <canary>.<app>.<account-sqid>.<apps-domain>. domain is the instance |
| +// manifest's own Domain — already "<sqid>.<apps-domain>", the same value |
| +// deploy's mintPlatformHost builds "<app>.<domain>" from — so this never |
| +// composes an apps domain by hand. |
| +func canaryHost(name, app, domain string) string { |
| + return name + "." + app + "." + domain |
| +} |
| + |
| +// gitBranch is the branch --name defaults to. Unlike gitShortHead it does not |
| +// exit on failure: outside a git repository a canary is still perfectly |
| +// deployable with an explicit --name, and canaryNameFrom gives a better |
| +// refusal than "not in a git repository" would. |
| +func gitBranch() string { |
| + out, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output() |
| + if err != nil { |
| + return "" |
| + } |
| + return strings.TrimSpace(string(out)) |
| +} |
| + |
| +// ---- records -------------------------------------------------------------- |
| + |
| +// canaryPlan is what the deploy leg has to do to the instance records before |
| +// it ships. |
| +type canaryPlan int |
| + |
| +const ( |
| + canaryPlanCreate canaryPlan = iota // no record on the canary host yet |
| + canaryPlanRechannel // the host exists, following something else |
| + canaryPlanReady // already correct — write nothing |
| +) |
| + |
| +func (p canaryPlan) String() string { |
| + switch p { |
| + case canaryPlanCreate: |
| + return "create" |
| + case canaryPlanRechannel: |
| + return "rechannel" |
| + default: |
| + return "ready" |
| + } |
| +} |
| + |
| +// canaryRecordPlan decides between the three. The Ready case is the one worth |
| +// having: re-canarying the same branch is the common act, and it must not |
| +// PATCH a record that already says exactly what it should. |
| +func canaryRecordPlan(recs []apiInstanceRecord, host, channel string) canaryPlan { |
| + for _, r := range recs { |
| + if r.Host != host { |
| + continue |
| + } |
| + if r.Channel == channel { |
| + return canaryPlanReady |
| + } |
| + return canaryPlanRechannel |
| + } |
| + return canaryPlanCreate |
| +} |
| + |
| +// canaryRecords filters an app's instance records down to its canaries. The |
| +// prefix test — not a bare "canary" — is the same boundary |
| +// internal/host/agent.go's isCanaryRoute draws, and for the same reason: a |
| +// channel literally named "canary" carries no lease and no fast loop, so |
| +// listing it here as a canary would misreport both. |
| +func canaryRecords(recs []apiInstanceRecord) []apiInstanceRecord { |
| + var out []apiInstanceRecord |
| + for _, r := range recs { |
| + if canaryNameOf(r.Channel) != "" { |
| + out = append(out, r) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// canaryRecordForChannel finds the one record following channel. An app can |
| +// have several canaries live at once, so rm has to name exactly one. |
| +func canaryRecordForChannel(recs []apiInstanceRecord, channel string) (apiInstanceRecord, bool) { |
| + for _, r := range recs { |
| + if r.Channel == channel { |
| + return r, true |
| + } |
| + } |
| + return apiInstanceRecord{}, false |
| +} |
| + |
| +// ---- the lease ------------------------------------------------------------ |
| + |
| +// canaryLeaseLabel renders what is left of a canary's seven-day lease for the |
| +// listing, or how far past it the canary has drifted. |
| +// |
| +// An unreadable or absent timestamp prints "—", never "expired" and never a |
| +// full lease: "we could not tell" and "there is a week left" are different |
| +// claims, and this only ever makes the first one when it cannot make the |
| +// second. Same discipline countExpiredCanaries applies box-side, where an |
| +// unresolvable pointer nils the whole count rather than undercounting it. |
| +// |
| +// Past the lease it defers to release.FormatCanaryAge — the one formatter the |
| +// adopt pass's nag and the console's badge both call — so a third surface |
| +// cannot start disagreeing with those two about what a duration means. |
| +func canaryLeaseLabel(promotedAt string, now time.Time) string { |
| + if strings.TrimSpace(promotedAt) == "" { |
| + return "—" |
| + } |
| + t, err := time.Parse(time.RFC3339, promotedAt) |
| + if err != nil || t.IsZero() { |
| + return "—" |
| + } |
| + left := release.CanaryTTL - now.Sub(t) |
| + if left <= 0 { |
| + return "expired " + release.FormatCanaryAge(-left) |
| + } |
| + // Rounded to the hour before the day/hour split so a canary promoted |
| + // moments ago reads "7d left" rather than the "6d left" that truncating |
| + // 6d23h59m would give — the lease is seven days, and the first line a |
| + // member sees after promoting should say so. |
| + left = left.Round(time.Hour) |
| + switch { |
| + case left < time.Hour: |
| + return "<1h left" |
| + case left < 24*time.Hour: |
| + return fmt.Sprintf("%dh left", int(left.Hours())) |
| + default: |
| + return fmt.Sprintf("%dd left", int(left.Hours())/24) |
| + } |
| +} |
| + |
| +// ---- carlos canary [deploy] ---------------------------------------------- |
| + |
| +// runCanaryDeploy is the composed verb: make sure the canary's own instance |
| +// record exists and follows canary/<name>, ship, promote, and then prove it |
| +// by watching the canary host's own X-Carlos-Version — never the app's |
| +// stable host, which this verb must leave untouched. |
| +func runCanaryDeploy(args []string) { |
| + fs := flag.NewFlagSet("canary", flag.ExitOnError) |
| + app := appFlag(fs, "app to canary") |
| + name := fs.String("name", "", "canary name (default: the current git branch, slugified)") |
| + acctRaw, acctIDRaw, _ := accountFlags(fs) |
| + consoleFlag := fs.String("console", "", "console to deploy through (default: the only logged-in one)") |
| + kind := fs.String("kind", "binary", "binary | static") |
| + ver := versionFlag(fs, "version (default: git rev-parse --short HEAD)") |
| + target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)") |
| + label := fs.String("label", "", "one line for humans — shows up in carlos releases and in the console") |
| + timeout := fs.Duration("timeout", defaultDeployTimeout, "how long to watch the canary URL before failing") |
| + fs.Usage = flagUsage(fs, canaryUsage) |
| + fs.Parse(args) |
| + |
| + // The artifact check comes FIRST, before anything that can fail for its |
| + // own reasons. gitShortHead exits with "not in a git repository — pass |
| + // --version", which is a true sentence and a baffling answer to a bare |
| + // `carlos canary` typed to see what the verb wants. |
| + if fs.NArg() == 0 { |
| + log.Fatal(canaryUsage) |
| + } |
| + if *ver == "" { |
| + *ver = gitShortHead() |
| + } |
| + canary, err := canaryNameFrom(strings.TrimSpace(*name), gitBranch()) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + |
| + // API mode only, exactly like carlos deploy: an instance record is a |
| + // console-authorized write, and this verb has to complete the job with |
| + // no box command anywhere in it. |
| + sess := requireDeployAPISession(*consoleFlag) |
| + printUsing(stdout, sess) // once, here — runShipAPI's own banner is suppressed below |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, canaryUsage) |
| + ctx := context.Background() |
| + |
| + channel := canaryChannel(canary) |
| + |
| + // instancesEnable is idempotent and is the ONLY source of the domain |
| + // suffix — nothing here composes an apps domain by hand. |
| + m, err := sess.instancesEnable(ctx, acct, app2, nil, nil) |
| + if err != nil { |
| + log.Fatalf("canary: enable instances for %s: %v", app2, err) |
| + } |
| + host := canaryHost(canary, app2, m.Domain) |
| + |
| + recs, _, _, err := sess.instanceList(ctx, acct, app2, "", "") |
| + if err != nil { |
| + log.Fatalf("canary: list instances for %s: %v", app2, err) |
| + } |
| + |
| + // A static canary needs a static record; a binary one takes the default |
| + // route class, which provision.Record spells as the empty string. |
| + recKind := "" |
| + if *kind == "static" { |
| + recKind = "static" |
| + } |
| + |
| + pal := colorFor(stdout) |
| + switch canaryRecordPlan(recs, host, channel) { |
| + case canaryPlanCreate: |
| + rec, cerr := sess.instanceCreate(ctx, acct, app2, host, "", channel, recKind, nil, nil) |
| + if cerr != nil { |
| + log.Fatalf("canary: create %s: %v", host, cerr) |
| + } |
| + fmt.Fprintf(stdout, "%s %s (channel %s)\n", pal.ok("canary host"), rec.Host, rec.Channel) |
| + case canaryPlanRechannel: |
| + // set-channel, not `carlos route --channel`: the record is where a |
| + // provisioned route's channel lives, and the reconciler reverts the |
| + // other one within seconds (instances_cli.go, runInstancesSetChannel). |
| + rec, serr := sess.instanceSetChannel(ctx, acct, app2, host, channel) |
| + if serr != nil { |
| + log.Fatalf("canary: move %s onto %s: %v", host, channel, serr) |
| + } |
| + fmt.Fprintf(stdout, "%s %s (now on %s)\n", pal.ok("canary host"), rec.Host, rec.Channel) |
| + case canaryPlanReady: |
| + mutedf(stdout, "canary host %s already follows %s\n", host, channel) |
| + } |
| + |
| + // printBanner false: printUsing above already said it once. |
| + if _, err = runShipAPI(sess, acct, fs, app2, *ver, *kind, *target, *label, "", nil, false); err != nil { |
| + log.Fatalf("canary: ship: %v", err) |
| + } |
| + |
| + // Never refused — on any app, from any rung, pipelined or not. hotfix is |
| + // false because there is no ladder rule here to bypass. |
| + if _, err = sess.promote(ctx, acct, app2, *ver, channel, false); err != nil { |
| + log.Fatalf("canary: promote %s to %s: %v", *ver, channel, err) |
| + } |
| + fmt.Fprintf(stdout, "%s %s to %s\n", pal.ok("promoted"), *ver, channel) |
| + |
| + targets := []watchTarget{{Host: host, Origin: watchOrigin(host)}} |
| + if err = watchVersion(stdout, targets, *ver, *timeout, canaryWatchEvery, nil); err != nil { |
| + log.Fatalf("canary: %v", err) |
| + } |
| + fmt.Fprintf(stdout, "\n%s https://%s\n", pal.ok("canary live at"), host) |
| + mutedf(stdout, "promote it onward when you're happy — it costs no rung: carlos promote --app %s %s <channel>\n", app2, *ver) |
| +} |
| + |
| +// ---- carlos canary ls ----------------------------------------------------- |
| + |
| +// runCanaryList prints this app's canaries, with what is left of each lease. |
| +// The lease column is the reason this is its own verb rather than a filter on |
| +// `carlos instances list`: the hazard the TTL guards against is not a canary |
| +// in production, it is one nobody remembers creating. |
| +func runCanaryList(args []string) { |
| + fs := flag.NewFlagSet("canary ls", flag.ExitOnError) |
| + app := appFlag(fs, "app whose canaries to list") |
| + acctRaw, acctIDRaw, _ := accountFlags(fs) |
| + consoleFlag := fs.String("console", "", "console to read through (default: the only logged-in one)") |
| + fs.Usage = flagUsage(fs, canaryUsage) |
| + fs.Parse(args) |
| + |
| + sess := requireAPISession("canary ls", *consoleFlag) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, canaryUsage) |
| + ctx := context.Background() |
| + |
| + recs, _, _, err := sess.instanceList(ctx, acct, app2, "", "") |
| + if err != nil { |
| + log.Fatalf("canary ls: %v", err) |
| + } |
| + canaries := canaryRecords(recs) |
| + if len(canaries) == 0 { |
| + mutedf(stdout, "no canaries on %s — carlos canary --app %s <artifact> starts one\n", app2, app2) |
| + return |
| + } |
| + |
| + // The lease lives on the channel pointer, not the instance record, so the |
| + // listing joins the two. A channels read that fails is degraded to "—" |
| + // rather than failing the whole listing: the hosts and channels below are |
| + // still true and still useful without it. |
| + promoted := map[string]string{} |
| + if chans, cerr := sess.channels(ctx, acct, app2); cerr != nil { |
| + mutedf(stdout, "could not read channel pointers (%v) — lease column omitted\n", cerr) |
| + } else { |
| + for _, c := range chans.Channels { |
| + promoted[c.Channel] = c.PromotedAt |
| + } |
| + } |
| + |
| + now := time.Now() |
| + w := newTable(stdout) |
| + fmt.Fprintln(w, "NAME\tHOST\tCHANNEL\tVERSION\tLEASE") |
| + for _, r := range canaries { |
| + version := r.StagedVersion |
| + if version == "" { |
| + version = "—" |
| + } |
| + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", |
| + canaryNameOf(r.Channel), r.Host, r.Channel, version, canaryLeaseLabel(promoted[r.Channel], now)) |
| + } |
| + if err := w.Flush(); err != nil { |
| + log.Fatalf("canary ls: %v", err) |
| + } |
| +} |
| + |
| +// ---- carlos canary rm ----------------------------------------------------- |
| + |
| +// runCanaryRemove deletes a canary's instance record — its host stops being |
| +// served and its process goes away. |
| +// |
| +// It does NOT delete the channel pointer, because the platform has no such |
| +// operation: a pointer is a signed object in the deployment bucket, and |
| +// canary/<name> simply stops being followed by anything and lapses at the end |
| +// of its lease. Saying so is the point — a command that let a member believe |
| +// the channel was gone would be lying about what it did, and the next |
| +// `carlos channels` would contradict it. |
| +func runCanaryRemove(args []string) { |
| + fs := flag.NewFlagSet("canary rm", flag.ExitOnError) |
| + app := appFlag(fs, "app the canary belongs to") |
| + name := fs.String("name", "", "canary name to remove") |
| + acctRaw, acctIDRaw, _ := accountFlags(fs) |
| + consoleFlag := fs.String("console", "", "console to remove through (default: the only logged-in one)") |
| + fs.Usage = flagUsage(fs, canaryUsage) |
| + fs.Parse(args) |
| + |
| + canary := strings.TrimSpace(*name) |
| + if canary == "" { |
| + log.Fatal("carlos canary rm: --name <n> is required\n\n" + canaryUsage) |
| + } |
| + if !validCanaryName(canary) { |
| + log.Fatalf("carlos canary rm: %q is not a canary name", canary) |
| + } |
| + |
| + sess := requireAPISession("canary rm", *consoleFlag) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, canaryUsage) |
| + ctx := context.Background() |
| + |
| + recs, _, _, err := sess.instanceList(ctx, acct, app2, "", "") |
| + if err != nil { |
| + log.Fatalf("canary rm: %v", err) |
| + } |
| + channel := canaryChannel(canary) |
| + rec, ok := canaryRecordForChannel(recs, channel) |
| + if !ok { |
| + log.Fatalf("carlos canary rm: %s has no canary named %q — carlos canary ls --app %s lists them", app2, canary, app2) |
| + } |
| + if err := sess.instanceDelete(ctx, acct, app2, rec.Host); err != nil { |
| + log.Fatalf("canary rm: delete %s: %v", rec.Host, err) |
| + } |
| + fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("removed"), rec.Host) |
| + mutedf(stdout, "%s itself remains and lapses at the end of its lease — nothing follows it now\n", channel) |
| +} |
diff --git a/canary_test.go b/canary_test.go| new file mode 100644 |
| index 000000000..d4f3fe574 |
| --- /dev/null |
| +++ b/canary_test.go |
| @@ -0,0 +1,266 @@ |
| +package main |
| + |
| +import ( |
| + "os" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// A canary name has to satisfy TWO alphabets at once: release.ValidChannel's |
| +// segment ([a-zA-Z0-9._-], 64) and a DNS label, because the same string is |
| +// both the channel's second segment and the leftmost label of the canary |
| +// host. The DNS label is the stricter of the two, so it is the one enforced |
| +// — lowercase, no dots (a dot would silently add a level to the host). |
| +func TestCanarySlugTurnsBranchesIntoDNSLabels(t *testing.T) { |
| + for _, tc := range []struct{ branch, want string }{ |
| + {"new-queue", "new-queue"}, |
| + {"feature/new-queue", "feature-new-queue"}, |
| + {"PAUL/JAM-42", "paul-jam-42"}, |
| + {"paul@example.com/fix", "paul-example-com-fix"}, |
| + {"a//b", "a-b"}, |
| + {"--leading-and-trailing--", "leading-and-trailing"}, |
| + {"v1.2.3", "v1-2-3"}, |
| + {"", ""}, |
| + {"///", ""}, |
| + } { |
| + if got := canarySlug(tc.branch); got != tc.want { |
| + t.Errorf("canarySlug(%q) = %q, want %q", tc.branch, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// 63 is the DNS label cap, and it is tighter than the channel segment's 64, |
| +// so it is the one that binds. A slug that overran it would mint a host the |
| +// edge could never serve. |
| +func TestCanarySlugTruncatesToTheDNSLabelCap(t *testing.T) { |
| + got := canarySlug(strings.Repeat("x", 100)) |
| + if len(got) != 63 { |
| + t.Fatalf("len = %d, want 63", len(got)) |
| + } |
| + if !validCanaryName(got) { |
| + t.Errorf("a truncated slug %q is not a valid canary name", got) |
| + } |
| +} |
| + |
| +// Whatever the slugger produces must satisfy the platform's own channel |
| +// check too — the two alphabets are only useful if the tighter one is |
| +// genuinely inside the looser one. |
| +func TestCanarySlugIsAlwaysAValidChannel(t *testing.T) { |
| + for _, branch := range []string{"feature/new-queue", "PAUL/JAM-42", "v1.2.3", strings.Repeat("ab/", 40)} { |
| + name := canarySlug(branch) |
| + if !release.ValidChannel(canaryChannel(name)) { |
| + t.Errorf("canarySlug(%q) = %q, which makes an invalid channel", branch, name) |
| + } |
| + } |
| +} |
| + |
| +// Truncation must not leave a trailing separator: "canary/foo-" is a valid |
| +// segment but an invalid DNS label, and the host is built from the same |
| +// string. |
| +func TestCanarySlugTruncationDoesNotStrandASeparator(t *testing.T) { |
| + got := canarySlug(strings.Repeat("ab/", 40)) |
| + if strings.HasSuffix(got, "-") { |
| + t.Errorf("slug %q ends in a separator", got) |
| + } |
| +} |
| + |
| +// A detached HEAD reports the literal "HEAD". Minting canary/head off that |
| +// would be a silent wrong answer, so it is refused and names the way out. |
| +func TestCanaryNameRefusesDetachedHead(t *testing.T) { |
| + _, err := canaryNameFrom("", "HEAD") |
| + if err == nil { |
| + t.Fatal("a detached HEAD was accepted; want a refusal") |
| + } |
| + if !strings.Contains(err.Error(), "--name") { |
| + t.Errorf("refusal %q does not name --name", err) |
| + } |
| +} |
| + |
| +func TestCanaryNameFlagBeatsTheBranch(t *testing.T) { |
| + got, err := canaryNameFrom("chosen", "some-branch") |
| + if err != nil { |
| + t.Fatalf("canaryNameFrom: %v", err) |
| + } |
| + if got != "chosen" { |
| + t.Errorf("name = %q, want chosen — --name must win over the branch", got) |
| + } |
| +} |
| + |
| +// An explicit --name is taken as typed, never slugified: silently rewriting |
| +// what someone deliberately asked for is how you end up promoting to a |
| +// channel you never named. |
| +func TestCanaryNameFlagIsRefusedNotRepaired(t *testing.T) { |
| + _, err := canaryNameFrom("feature/new-queue", "") |
| + if err == nil { |
| + t.Fatal("--name feature/new-queue was accepted; want a refusal") |
| + } |
| + if !strings.Contains(err.Error(), "feature/new-queue") { |
| + t.Errorf("refusal %q does not quote the name given", err) |
| + } |
| +} |
| + |
| +// No --name and no usable branch: refuse, rather than invent one. |
| +func TestCanaryNameWithNothingToGoOnIsRefused(t *testing.T) { |
| + _, err := canaryNameFrom("", "") |
| + if err == nil { |
| + t.Fatal("an empty name and empty branch were accepted; want a refusal") |
| + } |
| + if !strings.Contains(err.Error(), "--name") { |
| + t.Errorf("refusal %q does not name --name", err) |
| + } |
| +} |
| + |
| +// The host is README's documented scheme: <canary>.<app>.<account-sqid>.<apps-domain>. |
| +// The manifest's domain is already "<sqid>.<apps-domain>", the same value |
| +// deploy's mintPlatformHost builds "<app>.<domain>" from. |
| +func TestCanaryHostFollowsTheDocumentedScheme(t *testing.T) { |
| + got := canaryHost("new-queue", "jam", "bab.oncarlos.com") |
| + want := "new-queue.jam.bab.oncarlos.com" |
| + if got != want { |
| + t.Errorf("canaryHost = %q, want %q", got, want) |
| + } |
| +} |
| + |
| +func TestCanaryChannelIsNamespaced(t *testing.T) { |
| + if got := canaryChannel("new-queue"); got != "canary/new-queue" { |
| + t.Errorf("canaryChannel = %q, want canary/new-queue", got) |
| + } |
| +} |
| + |
| +// Step 4 of the deploy: create, re-channel, or write nothing at all. The |
| +// no-write case matters — a redeploy of the same canary must not PATCH a |
| +// record that is already correct. |
| +func TestCanaryRecordPlan(t *testing.T) { |
| + const host = "new-queue.jam.bab.oncarlos.com" |
| + const ch = "canary/new-queue" |
| + other := apiInstanceRecord{Host: "jam.bab.oncarlos.com", Channel: "stable"} |
| + |
| + for _, tc := range []struct { |
| + name string |
| + recs []apiInstanceRecord |
| + want canaryPlan |
| + }{ |
| + {"no records at all", nil, canaryPlanCreate}, |
| + {"only the app's stable instance", []apiInstanceRecord{other}, canaryPlanCreate}, |
| + {"canary host already on the canary channel", |
| + []apiInstanceRecord{other, {Host: host, Channel: ch}}, canaryPlanReady}, |
| + {"canary host parked on another channel", |
| + []apiInstanceRecord{other, {Host: host, Channel: "canary/old"}}, canaryPlanRechannel}, |
| + {"canary host somehow on stable", |
| + []apiInstanceRecord{{Host: host, Channel: "stable"}}, canaryPlanRechannel}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + if got := canaryRecordPlan(tc.recs, host, ch); got != tc.want { |
| + t.Errorf("plan = %v, want %v", got, tc.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// ls reports what is left of the seven-day lease, and says plainly when one |
| +// has lapsed — that is the whole reason the TTL is visible at all. |
| +func TestCanaryLeaseLabel(t *testing.T) { |
| + now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) |
| + rfc := func(d time.Duration) string { return now.Add(-d).Format(time.RFC3339) } |
| + |
| + for _, tc := range []struct{ name, at, want string }{ |
| + {"promoted moments ago", rfc(time.Minute), "7d left"}, |
| + {"one day in", rfc(24 * time.Hour), "6d left"}, |
| + {"final hours", rfc(7*24*time.Hour - 3*time.Hour), "3h left"}, |
| + {"just lapsed", rfc(7*24*time.Hour + 30*time.Minute), "expired <1h"}, |
| + {"long lapsed", rfc(9 * 24 * time.Hour), "expired 2d"}, |
| + {"never promoted", "", "—"}, |
| + {"unparseable", "last tuesday", "—"}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + if got := canaryLeaseLabel(tc.at, now); got != tc.want { |
| + t.Errorf("canaryLeaseLabel(%q) = %q, want %q", tc.at, got, tc.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// rm must delete the record for the canary it was asked about and no other — |
| +// an app can have several canaries live at once. |
| +func TestCanaryRecordForNamePicksExactlyOne(t *testing.T) { |
| + recs := []apiInstanceRecord{ |
| + {Host: "jam.bab.oncarlos.com", Channel: "stable"}, |
| + {Host: "old.jam.bab.oncarlos.com", Channel: "canary/old"}, |
| + {Host: "new-queue.jam.bab.oncarlos.com", Channel: "canary/new-queue"}, |
| + } |
| + got, ok := canaryRecordForChannel(recs, "canary/new-queue") |
| + if !ok { |
| + t.Fatal("no record found for canary/new-queue") |
| + } |
| + if got.Host != "new-queue.jam.bab.oncarlos.com" { |
| + t.Errorf("host = %q, want the new-queue record", got.Host) |
| + } |
| + if _, ok := canaryRecordForChannel(recs, "canary/nope"); ok { |
| + t.Error("a canary that does not exist was reported found") |
| + } |
| +} |
| + |
| +// ls lists canaries, not every instance the app has. |
| +func TestCanaryRecordsFiltersToCanaryChannels(t *testing.T) { |
| + recs := []apiInstanceRecord{ |
| + {Host: "jam.bab.oncarlos.com", Channel: "stable"}, |
| + {Host: "old.jam.bab.oncarlos.com", Channel: "canary/old"}, |
| + {Host: "eu.jam.bab.oncarlos.com", Channel: "edge"}, |
| + {Host: "new-queue.jam.bab.oncarlos.com", Channel: "canary/new-queue"}, |
| + // A bare "canary" is NOT a canary/* channel — the same boundary |
| + // internal/host/agent.go's isCanaryRoute draws. |
| + {Host: "bare.jam.bab.oncarlos.com", Channel: "canary"}, |
| + } |
| + got := canaryRecords(recs) |
| + if len(got) != 2 { |
| + t.Fatalf("got %d canary records, want 2: %+v", len(got), got) |
| + } |
| + if got[0].Channel != "canary/new-queue" && got[0].Channel != "canary/old" { |
| + t.Errorf("unexpected first row %+v", got[0]) |
| + } |
| +} |
| + |
| +// The name is what a member types into rm, so ls has to be able to derive it |
| +// back out of the channel it lists. |
| +func TestCanaryNameOfChannel(t *testing.T) { |
| + if got := canaryNameOf("canary/new-queue"); got != "new-queue" { |
| + t.Errorf("canaryNameOf = %q, want new-queue", got) |
| + } |
| + if got := canaryNameOf("stable"); got != "" { |
| + t.Errorf("canaryNameOf(stable) = %q, want empty", got) |
| + } |
| +} |
| + |
| +// The whole point of the change: main.go's canary case dispatches instead of |
| +// exiting "not implemented". internal/docsite's TopLevelCommands scan already |
| +// insists the verb has a doc heading, but it cannot tell a real dispatch from |
| +// a stub — and a stub with a heading is exactly the state that made a session |
| +// conclude the platform could not canary at all. |
| +func TestCanaryIsDispatchedNotStubbed(t *testing.T) { |
| + src, err := os.ReadFile("main.go") |
| + if err != nil { |
| + t.Fatalf("read main.go: %v", err) |
| + } |
| + _, after, ok := strings.Cut(string(src), "case \"canary\":") |
| + if !ok { |
| + t.Fatal("main.go no longer dispatches on \"canary\"") |
| + } |
| + body, _, _ := strings.Cut(after, "case \"") |
| + if !strings.Contains(body, "runCanary(args)") { |
| + t.Errorf("the canary case does not call runCanary: %q", body) |
| + } |
| + if strings.Contains(body, "not implemented") { |
| + t.Error("the canary case still exits \"not implemented\"") |
| + } |
| +} |
| + |
| +// canary is a member verb now, not a reserved word, so it takes the |
| +// once-a-day update hint like deploy and ship do. |
| +func TestCanaryTakesTheUpdateHint(t *testing.T) { |
| + if noHintVerbs["canary"] { |
| + t.Error("canary is still in noHintVerbs — it was listed there while it was a stub") |
| + } |
| +} |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 9de517f37..a48343ca3 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -158,6 +158,45 @@ having no instances to resolve a channel from. |
| carlos deploy --app website --kind static --host www.example.com ./dist |
| ``` |
| +### carlos canary |
| + |
| +Deploy onto a host and a channel belonging to this branch alone, so nothing |
| +the app already serves changes. It creates the canary's instance record if |
| +it needs to, ships, promotes, and then waits on the canary URL's own |
| +`X-Carlos-Version` — the same proof [carlos deploy](/docs/cli#carlos-deploy) |
| +demands, pointed somewhere that cannot disturb production. |
| + |
| +```sh |
| +carlos canary --app jam ./jam |
| +``` |
| + |
| +The name defaults to the current git branch, slugified; `--name` overrides |
| +it. A branch called `new-queue` gives you the channel `canary/new-queue` and |
| +the host `new-queue.jam.<your account>.<apps domain>`. Since the same string |
| +has to be both a channel segment and a hostname label, an explicit `--name` |
| +is checked rather than quietly repaired — lowercase letters, digits and |
| +dashes. |
| + |
| +Promotion into a canary is never refused, on any app, from any rung. Nor |
| +does a canary cost you a rung: canary channels sit outside every pipeline, so |
| +a build that has been on one still enters the pipeline as a fresh build. |
| +Canary, then promote to your entry channel, and the ladder is untouched. |
| + |
| +```sh |
| +carlos canary ls --app jam |
| +carlos canary rm --app jam --name new-queue |
| +``` |
| + |
| +`ls` shows each canary's host, what it is serving, and what is left of its |
| +seven-day lease — refreshed every time you re-promote, so an active canary |
| +never expires and a forgotten one starts saying so. `rm` deletes the |
| +instance record: the host stops being served and the process goes away. The |
| +channel itself remains, followed by nothing, and lapses at the end of its |
| +lease; there is no pointer to delete. |
| + |
| +One thing this is not: traffic splitting. A canary is a URL, not a |
| +percentage of production traffic on the app's own hostname. |
| + |
| ### carlos restart |
| Cycles an app's processes with no new version and no config change. This is |
| @@ -781,17 +820,10 @@ carlos ops restore-verify |
| ## Everything else |
| -`carlos help` belongs to all three roles above; `carlos canary` belongs |
| -to none of them yet. |
| +`carlos help` belongs to all three roles above. |
| ### carlos help |
| Prints the command list, grouped the way this page is. It goes to stdout and |
| exits 0, so `carlos help | less` works. A bare `carlos` with no arguments |
| prints the same text on stderr and exits 2, because that one is a mistake. |
| - |
| -### carlos canary |
| - |
| -Reserved, and not implemented: typing it exits with `not implemented`. |
| -Canary releases today are ordinary channels named `canary/<something>`, |
| -promoted with [carlos promote](/docs/cli#carlos-promote) like any other rung. |
diff --git a/docs/superpowers/plans/2026-09-04-carlos-canary.md b/docs/superpowers/plans/2026-09-04-carlos-canary.md| new file mode 100644 |
| index 000000000..e07fffc47 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-09-04-carlos-canary.md |
| @@ -0,0 +1,108 @@ |
| +# `carlos canary` — filling the reserved verb |
| + |
| +**Date:** 2026-09-04 |
| +**Issue:** the reserved `canary` case in `main.go:131` exits `not implemented`. |
| + |
| +## Why |
| + |
| +CARLOS canaries instance apps today and has all year — `go.tito.io` follows |
| +`canary/platform-adoption`, pinned as a live production channel in |
| +`internal/release/laddercheck_test.go:12`. The machinery is all there: |
| + |
| +- a route's channel lives on its instance record, per host, so two instances |
| + of one app on different channels stage different versions |
| + (`internal/host/agent.go:1549`); |
| +- `canary/*` routes converge on the 2s fast loop, not the 5-minute tick |
| + (`internal/host/agent.go:1502`); |
| +- promotion **into** a canary is unconditional on both the legacy ladder and |
| + an app-defined pipeline (`internal/release/ladder.go:84`, |
| + `internal/release/pipeline.go:328`); |
| +- `VersionStage` only scans pipeline channels, so a build that has been on a |
| + canary still enters the pipeline as fresh — canarying costs no rung. |
| + |
| +What is missing is *sugar*. Today a member types `instances create` + |
| +`ship` + `promote` and works out the hostname themselves. A session in |
| +September 2026 read `carlos canary`'s `not implemented` and README's |
| +"canary hosts are minted for static apps only" and concluded the platform |
| +could not canary an instance app at all. Both statements are true and |
| +narrow; together they read as a blanket no. The verb closes that gap. |
| + |
| +## Surface |
| + |
| +``` |
| +carlos canary [deploy] --app <a> [--name <n>] [--version <sha>] |
| + [--kind binary|static] [--target <goos-goarch>] [--label <l>] |
| + [--timeout 120s] <files-or-site-dir> |
| +carlos canary ls --app <a> |
| +carlos canary rm --app <a> --name <n> |
| +``` |
| + |
| +The bare form is `deploy`, the same way `carlos instances` has a bare form. |
| +Sub-verbs are positional words that never reach `fs.Parse`, matching |
| +`runInstances` (`cli.go:269`). |
| + |
| +API mode only, like `carlos deploy`: an instance record is a |
| +console-authorized write, and this verb must complete the job with no box |
| +access (AGENTS.md, "every `carlos` command is designed for a user with no |
| +access to the underlying infra"). |
| + |
| +## Name, channel and host |
| + |
| +- `--name`, else the current git branch, slugified to a single channel |
| + segment (`internal/release`'s `channelSegRe`: `[a-zA-Z0-9._-]+`, ≤64). |
| +- channel = `canary/<name>`. |
| +- host = `<name>.<app>.<manifest domain>` — exactly the scheme README |
| + already documents: `<canary>.<app>.<account-sqid>.<apps-domain>`. |
| + |
| +A branch that slugifies to nothing, or a `--name` that is not a valid |
| +segment, is refused by name rather than silently repaired. |
| + |
| +## `canary deploy` |
| + |
| +1. Resolve session, account, app (deploy's rules; `requireDeployAPISession`). |
| +2. Resolve the name, build the channel, refuse an invalid one. |
| +3. `instancesEnable` — idempotent, and the only source of the domain suffix. |
| +4. `instanceList`; then exactly one of: |
| + - no record on the canary host → `instanceCreate` with the canary channel; |
| + - record exists on a different channel → `instanceSetChannel`; |
| + - record already on the canary channel → no write at all. |
| +5. `runShipAPI` (banner suppressed; this verb prints its own). |
| +6. `promote` to `canary/<name>` — never refused, on any app, from any rung. |
| +7. `watchVersion` against the canary host only, never the app's stable host. |
| + |
| +Steps 3–4 are what make it sugar rather than a new subsystem: every call |
| +already exists for `carlos instances` and `carlos deploy`. |
| + |
| +## `canary ls` |
| + |
| +Instance records whose channel starts `canary/`, joined to the channel |
| +listing for the lease. Columns: name, host, channel, staged version, and |
| +what is left of the seven-day lease (`release.CanaryTTL`, refreshed on every |
| +re-promote). An expired canary is marked, because a canary that outlives its |
| +reason is the exact hazard the TTL exists to surface. |
| + |
| +## `canary rm` |
| + |
| +Deletes the instance record for `canary/<name>`. It says plainly that the |
| +channel pointer itself remains and expires on its own — there is no pointer |
| +delete in the platform, and a command that implied otherwise would be |
| +lying. |
| + |
| +## Explicitly out of scope |
| + |
| +**Traffic splitting.** This is a URL per canary, not a percentage of |
| +production traffic on one hostname. That would be real work in the edge — |
| +weighted routing, not sugar over existing calls — and nothing here pretends |
| +otherwise. |
| + |
| +## Tests |
| + |
| +- name resolution: `--name` wins; git branch is slugified; an empty or |
| + invalid result is refused by name. |
| +- host construction against a manifest domain. |
| +- the create / re-channel / no-write table for step 4. |
| +- lease arithmetic in `ls`, including an expired canary and a pointer that |
| + cannot be read. |
| +- `rm` targets the record for the named canary and no other. |
| +- the docsite gate: `### carlos canary` still exists, its fenced examples |
| + run real verbs, and every `/docs` link resolves. |
diff --git a/main.go b/main.go| index 71aa9a3c6..4ec5ccee6 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -129,7 +129,7 @@ func main() { |
| case "update": |
| runUpdate(args) |
| case "canary": |
| - log.Fatalf("not implemented: %s", cmd) |
| + runCanary(args) |
| default: |
| usage(os.Stderr) |
| os.Exit(2) |
| @@ -165,6 +165,8 @@ Your apps — ship and run software on a CARLOS deployment |
| the channel is optional when the app has only one |
| ("carlos channels" lists them; "carlos pipeline" orders them) |
| deploy ship + promote + wait until the URL serves the new version |
| + canary the same, onto a host and channel of this branch's own, so |
| + nothing the app already serves changes: deploy | ls | rm |
| restart cycle an app's instances — no new version, no config change |
| schedule recurring or one-shot work per app: ls | set | rm | run |
| store object storage for an app: create | status | grant | rotate |
diff --git a/update.go b/update.go| index ed8c89e4f..07a93ad5c 100644 |
| --- a/update.go |
| +++ b/update.go |
| @@ -76,7 +76,7 @@ func newerVersion(latest, current string) bool { |
| var noHintVerbs = map[string]bool{ |
| "edge": true, "agent": true, "adopt": true, "bootstrap": true, |
| "add": true, "remove": true, "route": true, "ops": true, |
| - "status": true, "canary": true, "vet": true, "release-keygen": true, |
| + "status": true, "vet": true, "release-keygen": true, |
| "version": true, "help": true, "-h": true, "--help": true, |
| "update": true, |
| } |