| 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. |
| 22 | package main |
| 23 | |
| 24 | import ( |
| 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 | |
| 38 | const 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 | |
| 44 | Ships a version and serves it on its own host, following its own canary/<name> |
| 45 | channel, without touching what the app's other instances serve. The bare form |
| 46 | is 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 |
| 50 | canary/<name>. |
| 51 | |
| 52 | --environment binds the canary to one of the app's named config bundles. |
| 53 | Reach for it whenever the app carries config: bundles are per-app, so without |
| 54 | one the canary serves values naming the app's MAIN host, and anything keyed to |
| 55 | a single origin — the Origin header the app compares against, magic-link URLs, |
| 56 | a WebAuthn RP ID, the cookie Secure flag — refuses requests on the canary host |
| 57 | while GETs look healthy. "carlos env --app <a> --environment <e> KEY=V" writes |
| 58 | a bundle; this flag points the canary at it. |
| 59 | |
| 60 | Promotion into a canary is never refused, and a canary costs no rung: a build |
| 61 | that has been on one still enters the pipeline as a fresh build, so canarying |
| 62 | and then promoting to edge is the ordinary path, not a bypass. |
| 63 | |
| 64 | Canaries carry a seven-day lease, refreshed by every re-promote. "canary ls" |
| 65 | shows 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. |
| 72 | const 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. |
| 78 | func 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. |
| 110 | func 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. |
| 140 | func 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. |
| 165 | func 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. |
| 191 | func 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. |
| 197 | func 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. |
| 231 | func canaryHost(name, app, domain string) string { |
| 232 | return name + "-" + app + "." + domain |
| 233 | } |
| 234 | |
| 235 | // canaryLabelFits reports whether name and app share one hostname label. |
| 236 | func 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. |
| 247 | func 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. |
| 264 | func 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. |
| 272 | func 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. |
| 284 | type canaryPlan int |
| 285 | |
| 286 | const ( |
| 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 | |
| 293 | func (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). |
| 324 | func 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. |
| 358 | type 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. |
| 369 | func 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. |
| 382 | func 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. |
| 405 | func 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. |
| 425 | func 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. |
| 437 | func 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. |
| 460 | func 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. |
| 493 | func 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. |
| 666 | func 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. |
| 736 | func 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. |
| 780 | func 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. |
| 787 | func 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 | |