| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/controlname" |
| 10 | "github.com/carlosframework/platform/internal/provision" |
| 11 | "github.com/carlosframework/platform/internal/release" |
| 12 | ) |
| 13 | |
| 14 | // A canary name has to satisfy TWO alphabets at once: release.ValidChannel's |
| 15 | // segment ([a-zA-Z0-9._-], 64) and a DNS label, because the same string is |
| 16 | // both the channel's second segment and the leftmost label of the canary |
| 17 | // host. The DNS label is the stricter of the two, so it is the one enforced |
| 18 | // — lowercase, no dots (a dot would silently add a level to the host). |
| 19 | func TestCanarySlugTurnsBranchesIntoDNSLabels(t *testing.T) { |
| 20 | for _, tc := range []struct{ branch, want string }{ |
| 21 | {"new-queue", "new-queue"}, |
| 22 | {"feature/new-queue", "feature-new-queue"}, |
| 23 | {"PAUL/JAM-42", "paul-jam-42"}, |
| 24 | {"paul@example.com/fix", "paul-example-com-fix"}, |
| 25 | {"a//b", "a-b"}, |
| 26 | {"--leading-and-trailing--", "leading-and-trailing"}, |
| 27 | {"v1.2.3", "v1-2-3"}, |
| 28 | {"", ""}, |
| 29 | {"///", ""}, |
| 30 | } { |
| 31 | if got := canarySlug(tc.branch); got != tc.want { |
| 32 | t.Errorf("canarySlug(%q) = %q, want %q", tc.branch, got, tc.want) |
| 33 | } |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | // 63 is the DNS label cap, and it is tighter than the channel segment's 64, |
| 38 | // so it is the one that binds. A slug that overran it would mint a host the |
| 39 | // edge could never serve. |
| 40 | func TestCanarySlugTruncatesToTheDNSLabelCap(t *testing.T) { |
| 41 | got := canarySlug(strings.Repeat("x", 100)) |
| 42 | if len(got) != 63 { |
| 43 | t.Fatalf("len = %d, want 63", len(got)) |
| 44 | } |
| 45 | if !validCanaryName(got) { |
| 46 | t.Errorf("a truncated slug %q is not a valid canary name", got) |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | // Whatever the slugger produces must satisfy the platform's own channel |
| 51 | // check too — the two alphabets are only useful if the tighter one is |
| 52 | // genuinely inside the looser one. |
| 53 | func TestCanarySlugIsAlwaysAValidChannel(t *testing.T) { |
| 54 | for _, branch := range []string{"feature/new-queue", "PAUL/JAM-42", "v1.2.3", strings.Repeat("ab/", 40)} { |
| 55 | name := canarySlug(branch) |
| 56 | if !release.ValidChannel(canaryChannel(name)) { |
| 57 | t.Errorf("canarySlug(%q) = %q, which makes an invalid channel", branch, name) |
| 58 | } |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | // Truncation must not leave a trailing separator: "canary/foo-" is a valid |
| 63 | // segment but an invalid DNS label, and the host is built from the same |
| 64 | // string. |
| 65 | func TestCanarySlugTruncationDoesNotStrandASeparator(t *testing.T) { |
| 66 | got := canarySlug(strings.Repeat("ab/", 40)) |
| 67 | if strings.HasSuffix(got, "-") { |
| 68 | t.Errorf("slug %q ends in a separator", got) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | // A detached HEAD reports the literal "HEAD". Minting canary/head off that |
| 73 | // would be a silent wrong answer, so it is refused and names the way out. |
| 74 | func TestCanaryNameRefusesDetachedHead(t *testing.T) { |
| 75 | _, err := canaryNameFrom("", "HEAD") |
| 76 | if err == nil { |
| 77 | t.Fatal("a detached HEAD was accepted; want a refusal") |
| 78 | } |
| 79 | if !strings.Contains(err.Error(), "--name") { |
| 80 | t.Errorf("refusal %q does not name --name", err) |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | func TestCanaryNameFlagBeatsTheBranch(t *testing.T) { |
| 85 | got, err := canaryNameFrom("chosen", "some-branch") |
| 86 | if err != nil { |
| 87 | t.Fatalf("canaryNameFrom: %v", err) |
| 88 | } |
| 89 | if got != "chosen" { |
| 90 | t.Errorf("name = %q, want chosen — --name must win over the branch", got) |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | // An explicit --name is taken as typed, never slugified: silently rewriting |
| 95 | // what someone deliberately asked for is how you end up promoting to a |
| 96 | // channel you never named. |
| 97 | func TestCanaryNameFlagIsRefusedNotRepaired(t *testing.T) { |
| 98 | _, err := canaryNameFrom("feature/new-queue", "") |
| 99 | if err == nil { |
| 100 | t.Fatal("--name feature/new-queue was accepted; want a refusal") |
| 101 | } |
| 102 | if !strings.Contains(err.Error(), "feature/new-queue") { |
| 103 | t.Errorf("refusal %q does not quote the name given", err) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | // No --name and no usable branch: refuse, rather than invent one. |
| 108 | func TestCanaryNameWithNothingToGoOnIsRefused(t *testing.T) { |
| 109 | _, err := canaryNameFrom("", "") |
| 110 | if err == nil { |
| 111 | t.Fatal("an empty name and empty branch were accepted; want a refusal") |
| 112 | } |
| 113 | if !strings.Contains(err.Error(), "--name") { |
| 114 | t.Errorf("refusal %q does not name --name", err) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | // The host is <canary>-<app>.<account-sqid>.<apps-domain>: one label before |
| 119 | // the domain, because the instance create door takes exactly one (discussion |
| 120 | // 207). The manifest's domain is already "<sqid>.<apps-domain>", the same |
| 121 | // value deploy's mintPlatformHost builds "<app>.<domain>" from. |
| 122 | func TestCanaryHostIsOneLabelBeforeTheDomain(t *testing.T) { |
| 123 | const domain = "bab.oncarlos.com" |
| 124 | got := canaryHost("new-queue", "jam", domain) |
| 125 | want := "new-queue-jam.bab.oncarlos.com" |
| 126 | if got != want { |
| 127 | t.Errorf("canaryHost = %q, want %q", got, want) |
| 128 | } |
| 129 | // The create door's own predicate, not a restatement of it. |
| 130 | if !provision.ValidHost(got, domain) { |
| 131 | t.Errorf("provision.ValidHost(%q, %q) = false: the create door would refuse this canary", got, domain) |
| 132 | } |
| 133 | // The widest label that fits: name + "-" + app = 63. |
| 134 | name := strings.Repeat("n", 63-1-len("jam")) |
| 135 | if h := canaryHost(name, "jam", domain); !canaryLabelFits(name, "jam") || !provision.ValidHost(h, domain) { |
| 136 | t.Errorf("a 63-byte label was refused: fits=%v host=%q", canaryLabelFits(name, "jam"), h) |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | // name + "-" + app is one hostname label: 63 bytes, the dash counted. A name |
| 141 | // validCanaryName accepts on its own can still be too long beside the app. |
| 142 | func TestCanaryLabelFitsOneLabel(t *testing.T) { |
| 143 | for _, tc := range []struct { |
| 144 | name, app string |
| 145 | want bool |
| 146 | }{ |
| 147 | {"new-queue", "jam", true}, |
| 148 | {strings.Repeat("n", 59), "jam", true}, // 59 + 1 + 3 = 63 |
| 149 | {strings.Repeat("n", 60), "jam", false}, // 64 |
| 150 | {strings.Repeat("n", 63), "a", false}, // a valid name, no room for the app |
| 151 | {"a", strings.Repeat("p", 61), true}, // 1 + 1 + 61 = 63 |
| 152 | {"a", strings.Repeat("p", 62), false}, |
| 153 | } { |
| 154 | if got := canaryLabelFits(tc.name, tc.app); got != tc.want { |
| 155 | t.Errorf("canaryLabelFits(%d bytes, %d bytes) = %v, want %v", len(tc.name), len(tc.app), got, tc.want) |
| 156 | } |
| 157 | } |
| 158 | msg := canaryLabelTooLong(strings.Repeat("n", 60), "jam") |
| 159 | for _, want := range []string{"64 characters", "63", "--name"} { |
| 160 | if !strings.Contains(msg, want) { |
| 161 | t.Errorf("refusal %q does not say %q", msg, want) |
| 162 | } |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | func TestCanaryChannelIsNamespaced(t *testing.T) { |
| 167 | if got := canaryChannel("new-queue"); got != "canary/new-queue" { |
| 168 | t.Errorf("canaryChannel = %q, want canary/new-queue", got) |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | // Step 4 of the deploy: create, re-channel, or write nothing at all. The |
| 173 | // no-write case matters — a redeploy of the same canary must not PATCH a |
| 174 | // record that is already correct. |
| 175 | func TestCanaryRecordPlan(t *testing.T) { |
| 176 | const host = "new-queue-jam.bab.oncarlos.com" |
| 177 | const ch = "canary/new-queue" |
| 178 | other := apiInstanceRecord{Host: "jam.bab.oncarlos.com", Channel: "stable"} |
| 179 | |
| 180 | for _, tc := range []struct { |
| 181 | name string |
| 182 | recs []apiInstanceRecord |
| 183 | want canaryPlan |
| 184 | }{ |
| 185 | {"no records at all", nil, canaryPlanCreate}, |
| 186 | {"only the app's stable instance", []apiInstanceRecord{other}, canaryPlanCreate}, |
| 187 | {"canary host already on the canary channel", |
| 188 | []apiInstanceRecord{other, {Host: host, Channel: ch}}, canaryPlanReady}, |
| 189 | {"canary host parked on another channel", |
| 190 | []apiInstanceRecord{other, {Host: host, Channel: "canary/old"}}, canaryPlanRechannel}, |
| 191 | // One label now: the canary's host can be an ordinary instance of |
| 192 | // the same app. Rechannelling is for canaries; an instance that is |
| 193 | // not on a canary/* channel is refused, whatever it follows. |
| 194 | {"an instance of the app on the canary's host", |
| 195 | []apiInstanceRecord{{Host: host, Channel: "stable"}}, canaryPlanTaken}, |
| 196 | {"a bare canary channel is not a canary/* channel", |
| 197 | []apiInstanceRecord{{Host: host, Channel: "canary"}}, canaryPlanTaken}, |
| 198 | } { |
| 199 | t.Run(tc.name, func(t *testing.T) { |
| 200 | if got := canaryRecordPlan(tc.recs, host, ch); got != tc.want { |
| 201 | t.Errorf("plan = %v, want %v", got, tc.want) |
| 202 | } |
| 203 | }) |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | // ls reports what is left of the seven-day lease, and says plainly when one |
| 208 | // has lapsed — that is the whole reason the TTL is visible at all. |
| 209 | func TestCanaryLeaseLabel(t *testing.T) { |
| 210 | now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) |
| 211 | rfc := func(d time.Duration) string { return now.Add(-d).Format(time.RFC3339) } |
| 212 | |
| 213 | for _, tc := range []struct{ name, at, want string }{ |
| 214 | {"promoted moments ago", rfc(time.Minute), "7d left"}, |
| 215 | {"one day in", rfc(24 * time.Hour), "6d left"}, |
| 216 | {"final hours", rfc(7*24*time.Hour - 3*time.Hour), "3h left"}, |
| 217 | {"just lapsed", rfc(7*24*time.Hour + 30*time.Minute), "expired <1h"}, |
| 218 | {"long lapsed", rfc(9 * 24 * time.Hour), "expired 2d"}, |
| 219 | {"never promoted", "", "—"}, |
| 220 | {"unparseable", "last tuesday", "—"}, |
| 221 | } { |
| 222 | t.Run(tc.name, func(t *testing.T) { |
| 223 | if got := canaryLeaseLabel(tc.at, now); got != tc.want { |
| 224 | t.Errorf("canaryLeaseLabel(%q) = %q, want %q", tc.at, got, tc.want) |
| 225 | } |
| 226 | }) |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | // rm must delete the record for the canary it was asked about and no other — |
| 231 | // an app can have several canaries live at once. |
| 232 | func TestCanaryRecordForNamePicksExactlyOne(t *testing.T) { |
| 233 | recs := []apiInstanceRecord{ |
| 234 | {Host: "jam.bab.oncarlos.com", Channel: "stable"}, |
| 235 | {Host: "old-jam.bab.oncarlos.com", Channel: "canary/old"}, |
| 236 | {Host: "new-queue-jam.bab.oncarlos.com", Channel: "canary/new-queue"}, |
| 237 | } |
| 238 | got, ok := canaryRecordForChannel(recs, "canary/new-queue") |
| 239 | if !ok { |
| 240 | t.Fatal("no record found for canary/new-queue") |
| 241 | } |
| 242 | if got.Host != "new-queue-jam.bab.oncarlos.com" { |
| 243 | t.Errorf("host = %q, want the new-queue record", got.Host) |
| 244 | } |
| 245 | if _, ok := canaryRecordForChannel(recs, "canary/nope"); ok { |
| 246 | t.Error("a canary that does not exist was reported found") |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | // ls lists canaries, not every instance the app has. |
| 251 | func TestCanaryRecordsFiltersToCanaryChannels(t *testing.T) { |
| 252 | recs := []apiInstanceRecord{ |
| 253 | {Host: "jam.bab.oncarlos.com", Channel: "stable"}, |
| 254 | {Host: "old-jam.bab.oncarlos.com", Channel: "canary/old"}, |
| 255 | {Host: "eu-jam.bab.oncarlos.com", Channel: "edge"}, |
| 256 | {Host: "new-queue-jam.bab.oncarlos.com", Channel: "canary/new-queue"}, |
| 257 | // A bare "canary" is NOT a canary/* channel — the same boundary |
| 258 | // internal/host/agent.go's isCanaryRoute draws. |
| 259 | {Host: "bare-jam.bab.oncarlos.com", Channel: "canary"}, |
| 260 | } |
| 261 | got := canaryRecords(recs) |
| 262 | if len(got) != 2 { |
| 263 | t.Fatalf("got %d canary records, want 2: %+v", len(got), got) |
| 264 | } |
| 265 | if got[0].Channel != "canary/new-queue" && got[0].Channel != "canary/old" { |
| 266 | t.Errorf("unexpected first row %+v", got[0]) |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | // The name is what a member types into rm, so ls has to be able to derive it |
| 271 | // back out of the channel it lists. |
| 272 | func TestCanaryNameOfChannel(t *testing.T) { |
| 273 | if got := canaryNameOf("canary/new-queue"); got != "new-queue" { |
| 274 | t.Errorf("canaryNameOf = %q, want new-queue", got) |
| 275 | } |
| 276 | if got := canaryNameOf("stable"); got != "" { |
| 277 | t.Errorf("canaryNameOf(stable) = %q, want empty", got) |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | // The whole point of the change: main.go's canary case dispatches instead of |
| 282 | // exiting "not implemented". internal/docsite's TopLevelCommands scan already |
| 283 | // insists the verb has a doc heading, but it cannot tell a real dispatch from |
| 284 | // a stub — and a stub with a heading is exactly the state that made a session |
| 285 | // conclude the platform could not canary at all. |
| 286 | func TestCanaryIsDispatchedNotStubbed(t *testing.T) { |
| 287 | src, err := os.ReadFile("main.go") |
| 288 | if err != nil { |
| 289 | t.Fatalf("read main.go: %v", err) |
| 290 | } |
| 291 | _, after, ok := strings.Cut(string(src), "case \"canary\":") |
| 292 | if !ok { |
| 293 | t.Fatal("main.go no longer dispatches on \"canary\"") |
| 294 | } |
| 295 | body, _, _ := strings.Cut(after, "case \"") |
| 296 | if !strings.Contains(body, "runCanary(args)") { |
| 297 | t.Errorf("the canary case does not call runCanary: %q", body) |
| 298 | } |
| 299 | if strings.Contains(body, "not implemented") { |
| 300 | t.Error("the canary case still exits \"not implemented\"") |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | // canary is a member verb now, not a reserved word, so it takes the |
| 305 | // once-a-day update hint like deploy and ship do. |
| 306 | func TestCanaryTakesTheUpdateHint(t *testing.T) { |
| 307 | if noHintVerbs["canary"] { |
| 308 | t.Error("canary is still in noHintVerbs — it was listed there while it was a stub") |
| 309 | } |
| 310 | } |
| 311 | |
| 312 | // The refusal names the channel the record on the host follows, from the |
| 313 | // same source the plan was decided from: the direct read under --region, the |
| 314 | // list otherwise. |
| 315 | func TestCanaryHostChannelReadsWhatThePlanDecidedFrom(t *testing.T) { |
| 316 | const host = "probe-jam.bab.oncarlos.com" |
| 317 | recs := []apiInstanceRecord{{Host: host, Channel: "stable"}, {Host: "other-jam.bab.oncarlos.com", Channel: "edge"}} |
| 318 | if got := canaryHostChannel(canaryPreflight{}, recs, host); got != "stable" { |
| 319 | t.Errorf("from the list = %q, want stable", got) |
| 320 | } |
| 321 | pre := canaryPreflight{Host: host, Rec: apiInstanceRecord{Host: host, Channel: "beta"}, Found: true} |
| 322 | if got := canaryHostChannel(pre, recs, host); got != "beta" { |
| 323 | t.Errorf("from the direct read = %q, want beta", got) |
| 324 | } |
| 325 | if got := canaryHostChannel(canaryPreflight{Host: host}, recs, host); got != "" { |
| 326 | t.Errorf("direct read found nothing, got %q", got) |
| 327 | } |
| 328 | if msg := canaryHostTaken(host, "stable"); !strings.Contains(msg, host) || !strings.Contains(msg, "stable") { |
| 329 | t.Errorf("refusal %q names neither the host nor its channel", msg) |
| 330 | } |
| 331 | } |
| 332 | |
| 333 | // Every app name the platform accepts has to map to a canary host that is |
| 334 | // either valid or refused up front. App names allow a trailing dash |
| 335 | // (controlname.ValidApp: ^[a-z][a-z0-9-]{0,31}$) and DNS labels do not, so |
| 336 | // app "jam-" would make "probe-jam-" for EVERY canary name, and choosing |
| 337 | // another --name cannot help. Exhaustive over a small alphabet that holds |
| 338 | // every character class and the dash positions: when the refusal is empty |
| 339 | // the host must pass the create door's own predicate. |
| 340 | func TestCanaryHostIsValidOrRefusedForEveryAppName(t *testing.T) { |
| 341 | const domain = "bab.oncarlos.com" |
| 342 | var words func(n int) []string |
| 343 | words = func(n int) []string { |
| 344 | if n == 0 { |
| 345 | return []string{""} |
| 346 | } |
| 347 | var out []string |
| 348 | for _, w := range words(n - 1) { |
| 349 | for _, c := range "a1-" { |
| 350 | out = append(out, w+string(c)) |
| 351 | } |
| 352 | } |
| 353 | return out |
| 354 | } |
| 355 | var names, apps []string |
| 356 | for n := 1; n <= 4; n++ { |
| 357 | for _, w := range words(n) { |
| 358 | if validCanaryName(w) { |
| 359 | names = append(names, w) |
| 360 | } |
| 361 | if controlname.ValidApp(w) { |
| 362 | apps = append(apps, w) |
| 363 | } |
| 364 | } |
| 365 | } |
| 366 | // A real-length app ending in a dash, and the one at the 32-byte limit. |
| 367 | apps = append(apps, "jam-", strings.Repeat("a", 31)+"-", strings.Repeat("a", 32)) |
| 368 | refused, accepted := 0, 0 |
| 369 | for _, app := range apps { |
| 370 | for _, name := range names { |
| 371 | why := canaryHostRefusal(name, app) |
| 372 | ok := provision.ValidHost(canaryHost(name, app, domain), domain) |
| 373 | switch { |
| 374 | case why == "" && !ok: |
| 375 | t.Fatalf("app %q, canary %q: host %q is not valid and nothing refused it", app, name, canaryHost(name, app, domain)) |
| 376 | case why != "" && ok && canaryLabelFits(name, app): |
| 377 | t.Fatalf("app %q, canary %q: refused (%s) though %q is a valid host", app, name, why, canaryHost(name, app, domain)) |
| 378 | } |
| 379 | if why == "" { |
| 380 | accepted++ |
| 381 | } else { |
| 382 | refused++ |
| 383 | } |
| 384 | } |
| 385 | } |
| 386 | if accepted == 0 || refused == 0 { |
| 387 | t.Fatalf("the sweep saw %d accepted and %d refused; it proves nothing", accepted, refused) |
| 388 | } |
| 389 | } |
| 390 | |