// 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/appconfig" "github.com/carlosframework/platform/internal/release" ) const canaryUsage = `usage: carlos canary [deploy] --app [--name ] [--version ] [--kind binary|static] [--region ] [--environment ] [--target ] [--label ] [--timeout 120s] carlos canary ls --app carlos canary rm --app --name Ships a version and serves it on its own host, following its own canary/ 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 -., and its channel is canary/. --environment binds the canary to one of the app's named config bundles. Reach for it whenever the app carries config: bundles are per-app, so without one the canary serves values naming the app's MAIN host, and anything keyed to a single origin — the Origin header the app compares against, magic-link URLs, a WebAuthn RP ID, the cookie Secure flag — refuses requests on the canary host while GETs look healthy. "carlos env --app --environment KEY=V" writes a bundle; this flag points the canary at it. 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 ") } name := canarySlug(branch) if name == "" { return "", fmt.Errorf("canary: branch %q has nothing usable as a name — pass --name ", 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 is -..: ONE label before // the domain. domain is the instance manifest's own Domain — already // ".", the same value deploy's mintPlatformHost builds // "." from — so this never composes an apps domain by hand. // // One label because the instance create door says so // (provision.ValidHost): the host becomes a bucket key segment and, on the // box, the .sock and .db path segments, and that rail is a // tenancy boundary this verb does not get to widen. The first version of // this joined name, app and domain with dots, which the door refused for // every create from #384 until discussion 207. The domains pass still mints // its own ... alias for a STATIC app's // canary pointer (internal/domains); that is a route it writes itself, not an // instance record through the door, and is a different host. // // The dash is not a separator anything parses back. ls and rm find a canary // by its channel (canaryNameOf), never by taking its host apart, so // "foo-bar" meaning canary foo of app bar or canary "foo-bar" of an app // of its own is not a question this code ever asks. What the join can do is // land two different (canary, app) pairs, or a canary and another app's // apex, on one label. The create door refuses those (apex.go: a label naming // another claimed app, and hostHolder for a host another app already // holds), and canaryRecordPlan refuses a host that already carries one of // this app's own non-canary instances. // // The label has to fit 63 bytes with the dash: see canaryLabelTooLong. func canaryHost(name, app, domain string) string { return name + "-" + app + "." + domain } // canaryLabelFits reports whether name and app share one hostname label. func canaryLabelFits(name, app string) bool { return len(name)+1+len(app) <= 63 } // canaryHostRefusal is "" when canaryHost(name, app, ...) is a valid single // label, and otherwise the sentence saying why not. name is already a valid // canary name (validCanaryName); app is any name the platform accepts // (controlname.ValidApp), and only the length and a trailing dash can still // make the joined label invalid. Every accepted app name therefore maps one // of two ways, never to a host the create door will refuse for a reason // choosing another --name cannot fix. func canaryHostRefusal(name, app string) string { // Refused, not repaired. Trimming the dash would map app "jam-" onto the // label app "jam" already uses for the same canary name, and a mapping // that quietly shares a host between two apps is the failure this whole // fix exists to keep out. A leading dash cannot occur: an app name starts // with a letter and the canary name is validated. if strings.HasSuffix(app, "-") { return canaryAppEndsInDash(app) } if !canaryLabelFits(name, app) { return canaryLabelTooLong(name, app) } return "" } // canaryAppEndsInDash refuses an app whose name ends in a dash: app names // may, a hostname label may not, so no canary name can make a host for it. func canaryAppEndsInDash(app string) string { return fmt.Sprintf("the app name %s ends in a dash, and a hostname label cannot, so no canary host can be built for it. Rename the app to use canaries.", app) } // 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 canaryPlanTaken // the host holds something that is not a canary ) func (p canaryPlan) String() string { switch p { case canaryPlanCreate: return "create" case canaryPlanRechannel: return "rechannel" case canaryPlanTaken: return "taken" default: return "ready" } } // canaryRegionCheck is spec §11's canary --region rule, run before ANY // write, so the answer never depends on whether the canary exists yet // (codex R4-2, plan-R2 4, plan-R3 1): // - the flag needs a console that can say whether a fleet governs; a // failed read, an older console with no app_default, or one that // could not read the placement or the stored region (either flag // false, R9a), refuses it on every run (C30b), // because the create door would silently replace it with the fleet; // - a fleet-placed app refuses the flag every time (C30); // - the canary's OWN record is then read by host, through get (one // GET …/instances/{host}), never inferred from a capped list that // skips records it cannot read. A host the console cannot name (no // instance_domain) or a read that fails refuses (C30c); a record in // another region refuses (C25), named by place. // // It returns what it read (codex plan-R5 1): the host, which the caller // holds the later instancesEnable answer to, and the record and whether it // exists, which decide create, rechannel or ready (canaryPlanFor). func canaryRegionCheck(res apiRegionsResult, readErr error, app, name, asked string, get func(host string) (apiInstanceRecord, bool, error)) (canaryPreflight, error) { if asked == "" { return canaryPreflight{}, nil } // Both flags: effective is only stated when the stored region was read // too (R9a), and the fleet check below reads effective (codex plan-R4 1). if readErr != nil || res.AppDefault == nil || !res.AppDefault.PlacementKnown || !res.AppDefault.RegionKnown { return canaryPreflight{}, errors.New(canaryFleetUnknown(app)) } if strings.HasPrefix(res.AppDefault.Effective, "fleet/") { return canaryPreflight{}, errors.New(canaryRegionOnFleet(app, strings.TrimPrefix(res.AppDefault.Effective, "fleet/"))) } if res.AppDefault.InstanceDomain == "" { return canaryPreflight{}, errors.New(canaryRegionUnconfirmed(name)) } host := canaryHost(name, app, res.AppDefault.InstanceDomain) rec, found, err := get(host) if err != nil { return canaryPreflight{}, errors.New(canaryRegionUnconfirmed(name)) } if found { have := rec.Region if have == "" { have = res.Default } if have != asked { return canaryPreflight{}, errors.New(canaryRegionMismatch(host, res.nameOf(have))) } } return canaryPreflight{Host: host, Rec: rec, Found: found}, nil } // canaryPreflight is what canaryRegionCheck read. Host is "" when --region // was not given; then nothing was read. type canaryPreflight struct { Host string Rec apiInstanceRecord Found bool } // canaryPlanFor decides create, rechannel or ready. With --region the // direct read decides (codex plan-R5 1): the list is capped at 500 and // skips records it cannot read, so it can miss a canary that exists, and // creating it again would fail. Without --region the list decides, as // before. func canaryPlanFor(pre canaryPreflight, recs []apiInstanceRecord, host, channel string) canaryPlan { if pre.Host == "" { return canaryRecordPlan(recs, host, channel) } if !pre.Found { return canaryPlanCreate } return canaryRecordPlan([]apiInstanceRecord{pre.Rec}, host, channel) } // 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 } // A canary host is one label now, so it can land on an ordinary // instance of this very app ("probe-titogo" for canary probe, and a // customer instance of that name). Rechannelling is for moving a // canary between canary/* channels; it must never carry somebody's // instance onto one. if canaryNameOf(r.Channel) == "" { return canaryPlanTaken } return canaryPlanRechannel } return canaryPlanCreate } // canaryHostChannel is the channel of the record sitting on host, from the // same source canaryPlanFor decided from, or "" when there is none. func canaryHostChannel(pre canaryPreflight, recs []apiInstanceRecord, host string) string { if pre.Host != "" { if pre.Found { return pre.Rec.Channel } return "" } for _, r := range recs { if r.Host == host { return r.Channel } } return "" } // 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/, 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 := consoleOption(fs, "console to deploy through (default: the only logged-in one)") kind := fs.String("kind", "binary", "binary | static") regionFlag := fs.String("region", "", flagRegionCanary) environment := fs.String("environment", "", "named config bundle the canary binds to (default: the app's default bundle)") 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) // The name and the app share ONE hostname label (canaryHost), so this is // a refusal before the first read, let alone a write. if why := canaryHostRefusal(canary, app2); why != "" { log.Fatalf("canary: %s", why) } // --region is checked with reads only, before the first write (the // instancesEnable PUT below): nothing changes when it is refused. var pre canaryPreflight if asked := strings.TrimSpace(*regionFlag); asked != "" { res, rerr := sess.appRegions(ctx, acct, app2) get := func(h string) (apiInstanceRecord, bool, error) { return sess.instanceGet(ctx, acct, app2, h) } var err error if pre, err = canaryRegionCheck(res, rerr, app2, canary, asked, get); err != nil { log.Fatalf("canary: %v", err) } } // 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, nil) if err != nil { log.Fatalf("canary: enable instances for %s: %v", app2, err) } host := canaryHost(canary, app2, m.Domain) if pre.Host != "" && host != pre.Host { // The PUT named a different domain from the one the check read // under: the canary checked is not the one about to be written. log.Fatalf("canary: %s", canaryRegionUnconfirmed(canary)) } 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" } env := strings.TrimSpace(*environment) if env != "" && !appconfig.ValidEnvironmentName(env) { log.Fatalf("canary: %q is not a config environment — want [a-z0-9-]{1,32}; carlos env environments --app %s lists this app's", env, app2) } pal := colorFor(stdout) // One decision, used twice below: with --region it comes from the // direct read of the canary's own record (codex plan-R5 1). plan := canaryPlanFor(pre, recs, host, channel) switch plan { case canaryPlanTaken: log.Fatalf("canary: %s", canaryHostTaken(host, canaryHostChannel(pre, recs, host))) case canaryPlanCreate: rec, cerr := sess.instanceCreate(ctx, acct, app2, host, strings.TrimSpace(*regionFlag), channel, recKind, env, nil, nil, nil) if cerr != nil { log.Fatalf("canary: create %s: %v", host, cerr) } fmt.Fprintf(stdout, "%s %s\n", pal.ok("canary host"), instanceCreatedLine(rec, environmentSuffix(rec.Environment))) 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) } // --environment on an EXISTING record is its own write: create carried // it above, but the two converge paths did not, and a re-canary that // silently kept yesterday's binding is the failure this flag exists to // prevent. Only ever sent when the flag was actually typed — an omitted // flag must leave a deliberate binding alone rather than clear it. if flagWasSet(fs, "environment") && plan != canaryPlanCreate { rec, eerr := sess.instanceSetEnvironment(ctx, acct, app2, host, env) if eerr != nil { log.Fatalf("canary: bind %s to environment %q: %v", host, env, eerr) } if rec.Environment == "" { fmt.Fprintf(stdout, "%s %s to the app's default bundle\n", pal.ok("bound"), rec.Host) } else { fmt.Fprintf(stdout, "%s %s to environment %s\n", pal.ok("bound"), rec.Host, rec.Environment) } } // The one thing this verb cannot do for you, said once, at the moment it // is actionable. Config is per-app: with no bundle of its own the canary // serves the app's default one, and for anything that keys off a single // origin scalar — the Origin header the framework compares against, // magic-link URLs, a WebAuthn RP ID, the cookie Secure flag — those // values name the app's OTHER host. GETs look perfectly healthy and // every POST fails closed, which is the worst way for this to be // discovered. if env == "" && recKind != "static" { mutedf(stdout, "no --environment: this canary serves %s's default config, so anything keyed to the app's\n"+ " main origin (CORS, magic links, WebAuthn, cookie flags) will refuse requests on this host.\n"+ " carlos env --app %s --environment writes a bundle; --environment binds it here.\n", app2, app2) } // printBanner false: printUsing above already said it once. if _, err = runShipAPI(sess, acct, fs, app2, *ver, *kind, *target, *label, "", nil, false, 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, 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 \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 := consoleOption(fs, "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 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) // CONFIG earns its column: "default" on a canary standing beside a live // instance is the state in which every POST to that host fails closed, // and it is invisible from the outside — the host serves GETs perfectly. fmt.Fprintln(w, "NAME\tHOST\tCHANNEL\tVERSION\tCONFIG\tLEASE") for _, r := range canaries { version := r.StagedVersion if version == "" { version = "—" } env := r.Environment if env == "" { env = "default" } fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", canaryNameOf(r.Channel), r.Host, r.Channel, version, env, 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/ 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 := consoleOption(fs, "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 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) } // ---- refusals ------------------------------------------------------------- // canaryLabelTooLong refuses a canary whose name and app do not fit one // hostname label together. The host is one label, -, because the // create door takes exactly one label before the account's domain; a DNS // label holds 63 bytes, and the dash is one of them. func canaryLabelTooLong(name, app string) string { return fmt.Sprintf("the host label %s-%s would be %d characters, and a hostname label holds at most 63. Use a shorter --name.", name, app, len(name)+1+len(app)) } // canaryHostTaken refuses a canary whose host already carries something // that is not a canary. channel is what that record follows. func canaryHostTaken(host, channel string) string { return fmt.Sprintf("%s already exists and follows %s, which is not a canary channel. Choose another --name.", host, channel) }