carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// canary.go fills the `carlos canary` verb main.go reserved and left exiting
2// "not implemented". It is sugar, not a subsystem: canarying an instance app
3// has worked all along — go.tito.io follows canary/platform-adoption, pinned
4// as a live production channel in internal/release/laddercheck_test.go — but
5// it took `carlos instances create` + `ship` + `promote` and a hostname the
6// member had to construct themselves from README's scheme. A September 2026
7// session read this file's old `not implemented` alongside README's "canary
8// hosts are minted for static apps only" and concluded the platform could
9// not canary an instance app at all; both statements were true and narrow,
10// and together they read as a blanket no. This closes that gap by composing
11// calls that already exist.
12//
13// Three verbs, because AGENTS.md's design rule ("every carlos command is
14// designed for a user with no access to the underlying infra") means a
15// canary you can create but cannot enumerate or tear down is half a feature
16// — especially one carrying a seven-day lease.
17//
18// What it deliberately does NOT do is split traffic. This is a URL per
19// canary, not a percentage of production traffic on one hostname; weighted
20// routing would be real work in internal/edge, and nothing here pretends
21// otherwise.
22package main
23
24import (
25 "context"
26 "errors"
27 "flag"
28 "fmt"
29 "log"
30 "os/exec"
31 "strings"
32 "time"
33
34 "github.com/carlosframework/platform/internal/appconfig"
35 "github.com/carlosframework/platform/internal/release"
36)
37
38const canaryUsage = `usage: carlos canary [deploy] --app <a> [--name <n>] [--version <sha>] [--kind binary|static]
39 [--region <code>] [--environment <e>] [--target <goos-goarch>] [--label <l>]
40 [--timeout 120s] <files-or-site-dir>
41 carlos canary ls --app <a>
42 carlos canary rm --app <a> --name <n>
43
44Ships a version and serves it on its own host, following its own canary/<name>
45channel, without touching what the app's other instances serve. The bare form
46is deploy.
47
48--name defaults to the current git branch, slugified. The canary's host is
49<name>-<app>.<your account's platform domain>, and its channel is
50canary/<name>.
51
52--environment binds the canary to one of the app's named config bundles.
53Reach for it whenever the app carries config: bundles are per-app, so without
54one the canary serves values naming the app's MAIN host, and anything keyed to
55a single origin — the Origin header the app compares against, magic-link URLs,
56a WebAuthn RP ID, the cookie Secure flag — refuses requests on the canary host
57while GETs look healthy. "carlos env --app <a> --environment <e> KEY=V" writes
58a bundle; this flag points the canary at it.
59
60Promotion into a canary is never refused, and a canary costs no rung: a build
61that has been on one still enters the pipeline as a fresh build, so canarying
62and then promoting to edge is the ordinary path, not a bypass.
63
64Canaries carry a seven-day lease, refreshed by every re-promote. "canary ls"
65shows what is left of it.`
66
67// canaryWatchEvery is how often the deploy leg re-probes the canary host.
68// The same 2s deploy.go uses — and it is not a coincidence that canary/*
69// routes converge on the box's 2s fast loop rather than the 5-minute tick
70// (internal/host/agent.go, isCanaryRoute): there is no point polling slower
71// than the thing being watched can change.
72const canaryWatchEvery = 2 * time.Second
73
74// runCanary dispatches the sub-verbs. They are positional words that must
75// never reach fs.Parse, the same shape runInstances uses (cli.go) — and the
76// bare form is deploy, so `carlos canary --app jam ./jam` works without the
77// word.
78func runCanary(args []string) {
79 if len(args) > 0 {
80 switch args[0] {
81 case "ls", "list":
82 runCanaryList(args[1:])
83 return
84 case "rm", "delete":
85 runCanaryRemove(args[1:])
86 return
87 case "deploy":
88 runCanaryDeploy(args[1:])
89 return
90 }
91 }
92 runCanaryDeploy(args)
93}
94
95// ---- names, channels and hosts -------------------------------------------
96
97// canarySlug turns a git branch into a canary name. The name has to satisfy
98// two alphabets at once, because it is both the channel's second segment
99// (release.ValidChannel: [a-zA-Z0-9._-], 64) and the leftmost label of the
100// canary host. The DNS label is the stricter of the two and is therefore the
101// one enforced: lowercase, [a-z0-9-] only, 63 max, no leading or trailing
102// separator.
103//
104// The dot is the trap worth naming. It is legal in a channel segment, so
105// "v1.2.3" would pass ValidChannel happily — and then mint the host
106// "v1.2.3.jam.bab.oncarlos.com", which is not the app's canary at a deeper
107// label, it is a different name entirely, outside the wildcard the
108// deployment's DNS actually holds. So dots are replaced like any other
109// out-of-alphabet byte.
110func canarySlug(branch string) string {
111 var b strings.Builder
112 sep := false
113 for _, r := range strings.ToLower(strings.TrimSpace(branch)) {
114 if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
115 b.WriteRune(r)
116 sep = false
117 continue
118 }
119 // Collapse any run of out-of-alphabet bytes to a single separator,
120 // and never open with one — so "feature/new-queue" and
121 // "feature//new-queue" name the same canary rather than two.
122 if !sep && b.Len() > 0 {
123 b.WriteByte('-')
124 sep = true
125 }
126 }
127 s := b.String()
128 if len(s) > 63 {
129 s = s[:63]
130 }
131 // Trimmed AFTER truncation as well as during the walk: cutting at 63 can
132 // land mid-separator, and "foo-" is a valid channel segment but an
133 // invalid DNS label — exactly the split this function exists to close.
134 return strings.Trim(s, "-")
135}
136
137// validCanaryName reports whether s can be both a DNS label and a channel
138// segment. Written out rather than delegated to a regexp so the two
139// constraints it merges stay visible at the point of refusal.
140func validCanaryName(s string) bool {
141 if s == "" || len(s) > 63 {
142 return false
143 }
144 if s[0] == '-' || s[len(s)-1] == '-' {
145 return false
146 }
147 for i := 0; i < len(s); i++ {
148 c := s[i]
149 if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' {
150 continue
151 }
152 return false
153 }
154 return true
155}
156
157// canaryNameFrom resolves the canary's name from --name and the current git
158// branch, in that order.
159//
160// A --name given explicitly is refused rather than repaired. Silently
161// rewriting a name someone deliberately typed is how you end up promoting to
162// a channel you never asked for and watching a host you never named — the
163// branch fallback is a convenience and gets slugified, a typed flag is an
164// instruction and gets checked.
165func canaryNameFrom(flagName, branch string) (string, error) {
166 if flagName != "" {
167 if !validCanaryName(flagName) {
168 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)
169 }
170 return flagName, nil
171 }
172 // "HEAD" is what `git rev-parse --abbrev-ref HEAD` reports on a detached
173 // checkout. Slugging it would mint canary/head — a plausible-looking
174 // channel that means nothing and that a second detached deploy from an
175 // unrelated commit would silently reuse.
176 if branch == "" || branch == "HEAD" {
177 return "", errors.New("canary: no branch to name this canary after — pass --name <n>")
178 }
179 name := canarySlug(branch)
180 if name == "" {
181 return "", fmt.Errorf("canary: branch %q has nothing usable as a name — pass --name <n>", branch)
182 }
183 return name, nil
184}
185
186// canaryChannel is the channel a named canary follows. The canary/ prefix is
187// load-bearing in three places that must all agree: the ladder's
188// unconditional "no gate in" (internal/release/ladder.go), the box's fast
189// converge loop (internal/host/agent.go, isCanaryRoute) and the seven-day
190// lease (internal/release/canary_ttl.go). A bare "canary" is none of those.
191func canaryChannel(name string) string { return release.RungCanary + "/" + name }
192
193// canaryNameOf is canaryChannel's inverse, for the listing: the name is what
194// a member types into `canary rm`, so `canary ls` has to be able to recover
195// it from the channel it prints. "" for anything that is not a canary/*
196// channel.
197func canaryNameOf(channel string) string {
198 prefix := release.RungCanary + "/"
199 if !strings.HasPrefix(channel, prefix) {
200 return ""
201 }
202 return strings.TrimPrefix(channel, prefix)
203}
204
205// canaryHost is <canary>-<app>.<account-sqid>.<apps-domain>: ONE label before
206// the domain. domain is the instance manifest's own Domain — already
207// "<sqid>.<apps-domain>", the same value deploy's mintPlatformHost builds
208// "<app>.<domain>" from — so this never composes an apps domain by hand.
209//
210// One label because the instance create door says so
211// (provision.ValidHost): the host becomes a bucket key segment and, on the
212// box, the <host>.sock and <host>.db path segments, and that rail is a
213// tenancy boundary this verb does not get to widen. The first version of
214// this joined name, app and domain with dots, which the door refused for
215// every create from #384 until discussion 207. The domains pass still mints
216// its own <canary>.<app>.<sqid>.<apps-domain> alias for a STATIC app's
217// canary pointer (internal/domains); that is a route it writes itself, not an
218// instance record through the door, and is a different host.
219//
220// The dash is not a separator anything parses back. ls and rm find a canary
221// by its channel (canaryNameOf), never by taking its host apart, so
222// "foo-bar" meaning canary foo of app bar or canary "foo-bar" of an app
223// of its own is not a question this code ever asks. What the join can do is
224// land two different (canary, app) pairs, or a canary and another app's
225// apex, on one label. The create door refuses those (apex.go: a label naming
226// another claimed app, and hostHolder for a host another app already
227// holds), and canaryRecordPlan refuses a host that already carries one of
228// this app's own non-canary instances.
229//
230// The label has to fit 63 bytes with the dash: see canaryLabelTooLong.
231func canaryHost(name, app, domain string) string {
232 return name + "-" + app + "." + domain
233}
234
235// canaryLabelFits reports whether name and app share one hostname label.
236func canaryLabelFits(name, app string) bool {
237 return len(name)+1+len(app) <= 63
238}
239
240// canaryHostRefusal is "" when canaryHost(name, app, ...) is a valid single
241// label, and otherwise the sentence saying why not. name is already a valid
242// canary name (validCanaryName); app is any name the platform accepts
243// (controlname.ValidApp), and only the length and a trailing dash can still
244// make the joined label invalid. Every accepted app name therefore maps one
245// of two ways, never to a host the create door will refuse for a reason
246// choosing another --name cannot fix.
247func canaryHostRefusal(name, app string) string {
248 // Refused, not repaired. Trimming the dash would map app "jam-" onto the
249 // label app "jam" already uses for the same canary name, and a mapping
250 // that quietly shares a host between two apps is the failure this whole
251 // fix exists to keep out. A leading dash cannot occur: an app name starts
252 // with a letter and the canary name is validated.
253 if strings.HasSuffix(app, "-") {
254 return canaryAppEndsInDash(app)
255 }
256 if !canaryLabelFits(name, app) {
257 return canaryLabelTooLong(name, app)
258 }
259 return ""
260}
261
262// canaryAppEndsInDash refuses an app whose name ends in a dash: app names
263// may, a hostname label may not, so no canary name can make a host for it.
264func canaryAppEndsInDash(app string) string {
265 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)
266}
267
268// gitBranch is the branch --name defaults to. Unlike gitShortHead it does not
269// exit on failure: outside a git repository a canary is still perfectly
270// deployable with an explicit --name, and canaryNameFrom gives a better
271// refusal than "not in a git repository" would.
272func gitBranch() string {
273 out, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output()
274 if err != nil {
275 return ""
276 }
277 return strings.TrimSpace(string(out))
278}
279
280// ---- records --------------------------------------------------------------
281
282// canaryPlan is what the deploy leg has to do to the instance records before
283// it ships.
284type canaryPlan int
285
286const (
287 canaryPlanCreate canaryPlan = iota // no record on the canary host yet
288 canaryPlanRechannel // the host exists, following something else
289 canaryPlanReady // already correct — write nothing
290 canaryPlanTaken // the host holds something that is not a canary
291)
292
293func (p canaryPlan) String() string {
294 switch p {
295 case canaryPlanCreate:
296 return "create"
297 case canaryPlanRechannel:
298 return "rechannel"
299 case canaryPlanTaken:
300 return "taken"
301 default:
302 return "ready"
303 }
304}
305
306// canaryRegionCheck is spec §11's canary --region rule, run before ANY
307// write, so the answer never depends on whether the canary exists yet
308// (codex R4-2, plan-R2 4, plan-R3 1):
309// - the flag needs a console that can say whether a fleet governs; a
310// failed read, an older console with no app_default, or one that
311// could not read the placement or the stored region (either flag
312// false, R9a), refuses it on every run (C30b),
313// because the create door would silently replace it with the fleet;
314// - a fleet-placed app refuses the flag every time (C30);
315// - the canary's OWN record is then read by host, through get (one
316// GET …/instances/{host}), never inferred from a capped list that
317// skips records it cannot read. A host the console cannot name (no
318// instance_domain) or a read that fails refuses (C30c); a record in
319// another region refuses (C25), named by place.
320//
321// It returns what it read (codex plan-R5 1): the host, which the caller
322// holds the later instancesEnable answer to, and the record and whether it
323// exists, which decide create, rechannel or ready (canaryPlanFor).
324func canaryRegionCheck(res apiRegionsResult, readErr error, app, name, asked string, get func(host string) (apiInstanceRecord, bool, error)) (canaryPreflight, error) {
325 if asked == "" {
326 return canaryPreflight{}, nil
327 }
328 // Both flags: effective is only stated when the stored region was read
329 // too (R9a), and the fleet check below reads effective (codex plan-R4 1).
330 if readErr != nil || res.AppDefault == nil || !res.AppDefault.PlacementKnown || !res.AppDefault.RegionKnown {
331 return canaryPreflight{}, errors.New(canaryFleetUnknown(app))
332 }
333 if strings.HasPrefix(res.AppDefault.Effective, "fleet/") {
334 return canaryPreflight{}, errors.New(canaryRegionOnFleet(app, strings.TrimPrefix(res.AppDefault.Effective, "fleet/")))
335 }
336 if res.AppDefault.InstanceDomain == "" {
337 return canaryPreflight{}, errors.New(canaryRegionUnconfirmed(name))
338 }
339 host := canaryHost(name, app, res.AppDefault.InstanceDomain)
340 rec, found, err := get(host)
341 if err != nil {
342 return canaryPreflight{}, errors.New(canaryRegionUnconfirmed(name))
343 }
344 if found {
345 have := rec.Region
346 if have == "" {
347 have = res.Default
348 }
349 if have != asked {
350 return canaryPreflight{}, errors.New(canaryRegionMismatch(host, res.nameOf(have)))
351 }
352 }
353 return canaryPreflight{Host: host, Rec: rec, Found: found}, nil
354}
355
356// canaryPreflight is what canaryRegionCheck read. Host is "" when --region
357// was not given; then nothing was read.
358type canaryPreflight struct {
359 Host string
360 Rec apiInstanceRecord
361 Found bool
362}
363
364// canaryPlanFor decides create, rechannel or ready. With --region the
365// direct read decides (codex plan-R5 1): the list is capped at 500 and
366// skips records it cannot read, so it can miss a canary that exists, and
367// creating it again would fail. Without --region the list decides, as
368// before.
369func canaryPlanFor(pre canaryPreflight, recs []apiInstanceRecord, host, channel string) canaryPlan {
370 if pre.Host == "" {
371 return canaryRecordPlan(recs, host, channel)
372 }
373 if !pre.Found {
374 return canaryPlanCreate
375 }
376 return canaryRecordPlan([]apiInstanceRecord{pre.Rec}, host, channel)
377}
378
379// canaryRecordPlan decides between the three. The Ready case is the one worth
380// having: re-canarying the same branch is the common act, and it must not
381// PATCH a record that already says exactly what it should.
382func canaryRecordPlan(recs []apiInstanceRecord, host, channel string) canaryPlan {
383 for _, r := range recs {
384 if r.Host != host {
385 continue
386 }
387 if r.Channel == channel {
388 return canaryPlanReady
389 }
390 // A canary host is one label now, so it can land on an ordinary
391 // instance of this very app ("probe-titogo" for canary probe, and a
392 // customer instance of that name). Rechannelling is for moving a
393 // canary between canary/* channels; it must never carry somebody's
394 // instance onto one.
395 if canaryNameOf(r.Channel) == "" {
396 return canaryPlanTaken
397 }
398 return canaryPlanRechannel
399 }
400 return canaryPlanCreate
401}
402
403// canaryHostChannel is the channel of the record sitting on host, from the
404// same source canaryPlanFor decided from, or "" when there is none.
405func canaryHostChannel(pre canaryPreflight, recs []apiInstanceRecord, host string) string {
406 if pre.Host != "" {
407 if pre.Found {
408 return pre.Rec.Channel
409 }
410 return ""
411 }
412 for _, r := range recs {
413 if r.Host == host {
414 return r.Channel
415 }
416 }
417 return ""
418}
419
420// canaryRecords filters an app's instance records down to its canaries. The
421// prefix test — not a bare "canary" — is the same boundary
422// internal/host/agent.go's isCanaryRoute draws, and for the same reason: a
423// channel literally named "canary" carries no lease and no fast loop, so
424// listing it here as a canary would misreport both.
425func canaryRecords(recs []apiInstanceRecord) []apiInstanceRecord {
426 var out []apiInstanceRecord
427 for _, r := range recs {
428 if canaryNameOf(r.Channel) != "" {
429 out = append(out, r)
430 }
431 }
432 return out
433}
434
435// canaryRecordForChannel finds the one record following channel. An app can
436// have several canaries live at once, so rm has to name exactly one.
437func canaryRecordForChannel(recs []apiInstanceRecord, channel string) (apiInstanceRecord, bool) {
438 for _, r := range recs {
439 if r.Channel == channel {
440 return r, true
441 }
442 }
443 return apiInstanceRecord{}, false
444}
445
446// ---- the lease ------------------------------------------------------------
447
448// canaryLeaseLabel renders what is left of a canary's seven-day lease for the
449// listing, or how far past it the canary has drifted.
450//
451// An unreadable or absent timestamp prints "—", never "expired" and never a
452// full lease: "we could not tell" and "there is a week left" are different
453// claims, and this only ever makes the first one when it cannot make the
454// second. Same discipline countExpiredCanaries applies box-side, where an
455// unresolvable pointer nils the whole count rather than undercounting it.
456//
457// Past the lease it defers to release.FormatCanaryAge — the one formatter the
458// adopt pass's nag and the console's badge both call — so a third surface
459// cannot start disagreeing with those two about what a duration means.
460func canaryLeaseLabel(promotedAt string, now time.Time) string {
461 if strings.TrimSpace(promotedAt) == "" {
462 return "—"
463 }
464 t, err := time.Parse(time.RFC3339, promotedAt)
465 if err != nil || t.IsZero() {
466 return "—"
467 }
468 left := release.CanaryTTL - now.Sub(t)
469 if left <= 0 {
470 return "expired " + release.FormatCanaryAge(-left)
471 }
472 // Rounded to the hour before the day/hour split so a canary promoted
473 // moments ago reads "7d left" rather than the "6d left" that truncating
474 // 6d23h59m would give — the lease is seven days, and the first line a
475 // member sees after promoting should say so.
476 left = left.Round(time.Hour)
477 switch {
478 case left < time.Hour:
479 return "<1h left"
480 case left < 24*time.Hour:
481 return fmt.Sprintf("%dh left", int(left.Hours()))
482 default:
483 return fmt.Sprintf("%dd left", int(left.Hours())/24)
484 }
485}
486
487// ---- carlos canary [deploy] ----------------------------------------------
488
489// runCanaryDeploy is the composed verb: make sure the canary's own instance
490// record exists and follows canary/<name>, ship, promote, and then prove it
491// by watching the canary host's own X-Carlos-Version — never the app's
492// stable host, which this verb must leave untouched.
493func runCanaryDeploy(args []string) {
494 fs := flag.NewFlagSet("canary", flag.ExitOnError)
495 app := appFlag(fs, "app to canary")
496 name := fs.String("name", "", "canary name (default: the current git branch, slugified)")
497 acctRaw, acctIDRaw, _ := accountFlags(fs)
498 consoleFlag := consoleOption(fs, "console to deploy through (default: the only logged-in one)")
499 kind := fs.String("kind", "binary", "binary | static")
500 regionFlag := fs.String("region", "", flagRegionCanary)
501 environment := fs.String("environment", "", "named config bundle the canary binds to (default: the app's default bundle)")
502 ver := versionFlag(fs, "version (default: git rev-parse --short HEAD)")
503 target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)")
504 label := fs.String("label", "", "one line for humans — shows up in carlos releases and in the console")
505 timeout := fs.Duration("timeout", defaultDeployTimeout, "how long to watch the canary URL before failing")
506 fs.Usage = flagUsage(fs, canaryUsage)
507 fs.Parse(args)
508
509 // The artifact check comes FIRST, before anything that can fail for its
510 // own reasons. gitShortHead exits with "not in a git repository — pass
511 // --version", which is a true sentence and a baffling answer to a bare
512 // `carlos canary` typed to see what the verb wants.
513 if fs.NArg() == 0 {
514 log.Fatal(canaryUsage)
515 }
516 if *ver == "" {
517 *ver = gitShortHead()
518 }
519 canary, err := canaryNameFrom(strings.TrimSpace(*name), gitBranch())
520 if err != nil {
521 log.Fatal(err)
522 }
523
524 // API mode only, exactly like carlos deploy: an instance record is a
525 // console-authorized write, and this verb has to complete the job with
526 // no box command anywhere in it.
527 sess := requireDeployAPISession(*consoleFlag)
528 printUsing(stdout, sess) // once, here — runShipAPI's own banner is suppressed below
529 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
530 app2 := resolvedApp(sess, acct, *app, canaryUsage)
531 ctx := context.Background()
532
533 channel := canaryChannel(canary)
534
535 // The name and the app share ONE hostname label (canaryHost), so this is
536 // a refusal before the first read, let alone a write.
537 if why := canaryHostRefusal(canary, app2); why != "" {
538 log.Fatalf("canary: %s", why)
539 }
540
541 // --region is checked with reads only, before the first write (the
542 // instancesEnable PUT below): nothing changes when it is refused.
543 var pre canaryPreflight
544 if asked := strings.TrimSpace(*regionFlag); asked != "" {
545 res, rerr := sess.appRegions(ctx, acct, app2)
546 get := func(h string) (apiInstanceRecord, bool, error) { return sess.instanceGet(ctx, acct, app2, h) }
547 var err error
548 if pre, err = canaryRegionCheck(res, rerr, app2, canary, asked, get); err != nil {
549 log.Fatalf("canary: %v", err)
550 }
551 }
552
553 // instancesEnable is idempotent and is the ONLY source of the domain
554 // suffix — nothing here composes an apps domain by hand.
555 m, err := sess.instancesEnable(ctx, acct, app2, nil, nil, nil)
556 if err != nil {
557 log.Fatalf("canary: enable instances for %s: %v", app2, err)
558 }
559 host := canaryHost(canary, app2, m.Domain)
560 if pre.Host != "" && host != pre.Host {
561 // The PUT named a different domain from the one the check read
562 // under: the canary checked is not the one about to be written.
563 log.Fatalf("canary: %s", canaryRegionUnconfirmed(canary))
564 }
565
566 recs, _, _, err := sess.instanceList(ctx, acct, app2, "", "")
567 if err != nil {
568 log.Fatalf("canary: list instances for %s: %v", app2, err)
569 }
570
571 // A static canary needs a static record; a binary one takes the default
572 // route class, which provision.Record spells as the empty string.
573 recKind := ""
574 if *kind == "static" {
575 recKind = "static"
576 }
577
578 env := strings.TrimSpace(*environment)
579 if env != "" && !appconfig.ValidEnvironmentName(env) {
580 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)
581 }
582
583 pal := colorFor(stdout)
584 // One decision, used twice below: with --region it comes from the
585 // direct read of the canary's own record (codex plan-R5 1).
586 plan := canaryPlanFor(pre, recs, host, channel)
587 switch plan {
588 case canaryPlanTaken:
589 log.Fatalf("canary: %s", canaryHostTaken(host, canaryHostChannel(pre, recs, host)))
590 case canaryPlanCreate:
591 rec, cerr := sess.instanceCreate(ctx, acct, app2, host, strings.TrimSpace(*regionFlag), channel, recKind, env, nil, nil, nil)
592 if cerr != nil {
593 log.Fatalf("canary: create %s: %v", host, cerr)
594 }
595 fmt.Fprintf(stdout, "%s %s\n", pal.ok("canary host"), instanceCreatedLine(rec, environmentSuffix(rec.Environment)))
596 case canaryPlanRechannel:
597 // set-channel, not `carlos route --channel`: the record is where a
598 // provisioned route's channel lives, and the reconciler reverts the
599 // other one within seconds (instances_cli.go, runInstancesSetChannel).
600 rec, serr := sess.instanceSetChannel(ctx, acct, app2, host, channel)
601 if serr != nil {
602 log.Fatalf("canary: move %s onto %s: %v", host, channel, serr)
603 }
604 fmt.Fprintf(stdout, "%s %s (now on %s)\n", pal.ok("canary host"), rec.Host, rec.Channel)
605 case canaryPlanReady:
606 mutedf(stdout, "canary host %s already follows %s\n", host, channel)
607 }
608
609 // --environment on an EXISTING record is its own write: create carried
610 // it above, but the two converge paths did not, and a re-canary that
611 // silently kept yesterday's binding is the failure this flag exists to
612 // prevent. Only ever sent when the flag was actually typed — an omitted
613 // flag must leave a deliberate binding alone rather than clear it.
614 if flagWasSet(fs, "environment") && plan != canaryPlanCreate {
615 rec, eerr := sess.instanceSetEnvironment(ctx, acct, app2, host, env)
616 if eerr != nil {
617 log.Fatalf("canary: bind %s to environment %q: %v", host, env, eerr)
618 }
619 if rec.Environment == "" {
620 fmt.Fprintf(stdout, "%s %s to the app's default bundle\n", pal.ok("bound"), rec.Host)
621 } else {
622 fmt.Fprintf(stdout, "%s %s to environment %s\n", pal.ok("bound"), rec.Host, rec.Environment)
623 }
624 }
625
626 // The one thing this verb cannot do for you, said once, at the moment it
627 // is actionable. Config is per-app: with no bundle of its own the canary
628 // serves the app's default one, and for anything that keys off a single
629 // origin scalar — the Origin header the framework compares against,
630 // magic-link URLs, a WebAuthn RP ID, the cookie Secure flag — those
631 // values name the app's OTHER host. GETs look perfectly healthy and
632 // every POST fails closed, which is the worst way for this to be
633 // discovered.
634 if env == "" && recKind != "static" {
635 mutedf(stdout, "no --environment: this canary serves %s's default config, so anything keyed to the app's\n"+
636 " main origin (CORS, magic links, WebAuthn, cookie flags) will refuse requests on this host.\n"+
637 " carlos env --app %s --environment <e> writes a bundle; --environment <e> binds it here.\n", app2, app2)
638 }
639
640 // printBanner false: printUsing above already said it once.
641 if _, err = runShipAPI(sess, acct, fs, app2, *ver, *kind, *target, *label, "", nil, false, false); err != nil {
642 log.Fatalf("canary: ship: %v", err)
643 }
644
645 // Never refused — on any app, from any rung, pipelined or not. hotfix is
646 // false because there is no ladder rule here to bypass.
647 if _, err = sess.promote(ctx, acct, app2, *ver, channel, false, false); err != nil {
648 log.Fatalf("canary: promote %s to %s: %v", *ver, channel, err)
649 }
650 fmt.Fprintf(stdout, "%s %s to %s\n", pal.ok("promoted"), *ver, channel)
651
652 targets := []watchTarget{{Host: host, Origin: watchOrigin(host)}}
653 if err = watchVersion(stdout, targets, *ver, *timeout, canaryWatchEvery, nil); err != nil {
654 log.Fatalf("canary: %v", err)
655 }
656 fmt.Fprintf(stdout, "\n%s https://%s\n", pal.ok("canary live at"), host)
657 mutedf(stdout, "promote it onward when you're happy — it costs no rung: carlos promote --app %s %s <channel>\n", app2, *ver)
658}
659
660// ---- carlos canary ls -----------------------------------------------------
661
662// runCanaryList prints this app's canaries, with what is left of each lease.
663// The lease column is the reason this is its own verb rather than a filter on
664// `carlos instances list`: the hazard the TTL guards against is not a canary
665// in production, it is one nobody remembers creating.
666func runCanaryList(args []string) {
667 fs := flag.NewFlagSet("canary ls", flag.ExitOnError)
668 app := appFlag(fs, "app whose canaries to list")
669 acctRaw, acctIDRaw, _ := accountFlags(fs)
670 consoleFlag := consoleOption(fs, "console to read through (default: the only logged-in one)")
671 fs.Usage = flagUsage(fs, canaryUsage)
672 fs.Parse(args)
673
674 sess := requireAPISession("canary ls", *consoleFlag)
675 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
676 app2 := resolvedApp(sess, acct, *app, canaryUsage)
677 ctx := context.Background()
678
679 recs, _, _, err := sess.instanceList(ctx, acct, app2, "", "")
680 if err != nil {
681 log.Fatalf("canary ls: %v", err)
682 }
683 canaries := canaryRecords(recs)
684 if len(canaries) == 0 {
685 mutedf(stdout, "no canaries on %s — carlos canary --app %s <artifact> starts one\n", app2, app2)
686 return
687 }
688
689 // The lease lives on the channel pointer, not the instance record, so the
690 // listing joins the two. A channels read that fails is degraded to "—"
691 // rather than failing the whole listing: the hosts and channels below are
692 // still true and still useful without it.
693 promoted := map[string]string{}
694 if chans, cerr := sess.channels(ctx, acct, app2); cerr != nil {
695 mutedf(stdout, "could not read channel pointers (%v) — lease column omitted\n", cerr)
696 } else {
697 for _, c := range chans.Channels {
698 promoted[c.Channel] = c.PromotedAt
699 }
700 }
701
702 now := time.Now()
703 w := newTable(stdout)
704 // CONFIG earns its column: "default" on a canary standing beside a live
705 // instance is the state in which every POST to that host fails closed,
706 // and it is invisible from the outside — the host serves GETs perfectly.
707 fmt.Fprintln(w, "NAME\tHOST\tCHANNEL\tVERSION\tCONFIG\tLEASE")
708 for _, r := range canaries {
709 version := r.StagedVersion
710 if version == "" {
711 version = "—"
712 }
713 env := r.Environment
714 if env == "" {
715 env = "default"
716 }
717 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
718 canaryNameOf(r.Channel), r.Host, r.Channel, version, env, canaryLeaseLabel(promoted[r.Channel], now))
719 }
720 if err := w.Flush(); err != nil {
721 log.Fatalf("canary ls: %v", err)
722 }
723}
724
725// ---- carlos canary rm -----------------------------------------------------
726
727// runCanaryRemove deletes a canary's instance record — its host stops being
728// served and its process goes away.
729//
730// It does NOT delete the channel pointer, because the platform has no such
731// operation: a pointer is a signed object in the deployment bucket, and
732// canary/<name> simply stops being followed by anything and lapses at the end
733// of its lease. Saying so is the point — a command that let a member believe
734// the channel was gone would be lying about what it did, and the next
735// `carlos channels` would contradict it.
736func runCanaryRemove(args []string) {
737 fs := flag.NewFlagSet("canary rm", flag.ExitOnError)
738 app := appFlag(fs, "app the canary belongs to")
739 name := fs.String("name", "", "canary name to remove")
740 acctRaw, acctIDRaw, _ := accountFlags(fs)
741 consoleFlag := consoleOption(fs, "console to remove through (default: the only logged-in one)")
742 fs.Usage = flagUsage(fs, canaryUsage)
743 fs.Parse(args)
744
745 canary := strings.TrimSpace(*name)
746 if canary == "" {
747 log.Fatal("carlos canary rm: --name <n> is required\n\n" + canaryUsage)
748 }
749 if !validCanaryName(canary) {
750 log.Fatalf("carlos canary rm: %q is not a canary name", canary)
751 }
752
753 sess := requireAPISession("canary rm", *consoleFlag)
754 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
755 app2 := resolvedApp(sess, acct, *app, canaryUsage)
756 ctx := context.Background()
757
758 recs, _, _, err := sess.instanceList(ctx, acct, app2, "", "")
759 if err != nil {
760 log.Fatalf("canary rm: %v", err)
761 }
762 channel := canaryChannel(canary)
763 rec, ok := canaryRecordForChannel(recs, channel)
764 if !ok {
765 log.Fatalf("carlos canary rm: %s has no canary named %q — carlos canary ls --app %s lists them", app2, canary, app2)
766 }
767 if err := sess.instanceDelete(ctx, acct, app2, rec.Host, ""); err != nil {
768 log.Fatalf("canary rm: delete %s: %v", rec.Host, err)
769 }
770 fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("removed"), rec.Host)
771 mutedf(stdout, "%s itself remains and lapses at the end of its lease — nothing follows it now\n", channel)
772}
773
774// ---- refusals -------------------------------------------------------------
775
776// canaryLabelTooLong refuses a canary whose name and app do not fit one
777// hostname label together. The host is one label, <canary>-<app>, because the
778// create door takes exactly one label before the account's domain; a DNS
779// label holds 63 bytes, and the dash is one of them.
780func canaryLabelTooLong(name, app string) string {
781 return fmt.Sprintf("the host label %s-%s would be %d characters, and a hostname label holds at most 63. Use a shorter --name.",
782 name, app, len(name)+1+len(app))
783}
784
785// canaryHostTaken refuses a canary whose host already carries something
786// that is not a canary. channel is what that record follows.
787func canaryHostTaken(host, channel string) string {
788 return fmt.Sprintf("%s already exists and follows %s, which is not a canary channel. Choose another --name.", host, channel)
789}
790