canary: build a one-label host, <canary>-<app>.<domain>
carlos canary could not create a canary since #384: canaryHost joined canary, app and domain with dots, and the instance create door takes exactly one label before the domain (provision.ValidHost, a tenancy boundary, left as it is). Rechannel and set-environment only check the host's shape, so reusing an existing canary worked and only create failed. The host is now <canary>-<app>.<domain>. Because the label is shared: - a name and app that do not fit 63 bytes together are refused before any read or write, with the numbers in the message; - a record already on the host that is not on a canary/* channel (an ordinary instance of the same app can now be there) is refused rather than rechannelled; - ls and rm find canaries by channel, not by host, so they need no change. The e2e tests drive the real CLI against a real console through create, re-run, ls and rm, with and without --region. Tests that fixed the old two-label host move to the one-label one. Refs discussion 207.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+305
−28
canary.go+86 −7canary_create_e2e_test.go+134 −0canary_region_e2e_test.go+2 −2canary_region_test.go+3 −3canary_test.go+80 −16
diff --git a/canary.go b/canary.go| index 503f25563..fb3df980b 100644 |
| --- a/canary.go |
| +++ b/canary.go |
| @@ -46,7 +46,7 @@ channel, without touching what the app's other instances serve. The bare form |
| is deploy. |
| --name defaults to the current git branch, slugified. The canary's host is |
| -<name>.<app>.<your account's platform domain>, and its channel is |
| +<name>-<app>.<your account's platform domain>, and its channel is |
| canary/<name>. |
| --environment binds the canary to one of the app's named config bundles. |
| @@ -202,13 +202,39 @@ func canaryNameOf(channel string) string { |
| return strings.TrimPrefix(channel, prefix) |
| } |
| -// canaryHost builds README's documented scheme, |
| -// <canary>.<app>.<account-sqid>.<apps-domain>. domain is the instance |
| -// manifest's own Domain — already "<sqid>.<apps-domain>", the same value |
| -// deploy's mintPlatformHost builds "<app>.<domain>" from — so this never |
| -// composes an apps domain by hand. |
| +// canaryHost is <canary>-<app>.<account-sqid>.<apps-domain>: ONE label before |
| +// the domain. domain is the instance manifest's own Domain — already |
| +// "<sqid>.<apps-domain>", the same value deploy's mintPlatformHost builds |
| +// "<app>.<domain>" from — so this never composes an apps domain by hand. |
| +// |
| +// One label because the instance create door says so |
| +// (provision.ValidHost): the host becomes a bucket key segment and, on the |
| +// box, the <host>.sock and <host>.db path segments, and that rail is a |
| +// tenancy boundary this verb does not get to widen. The first version of |
| +// this joined name, app and domain with dots, which the door refused for |
| +// every create from #384 until discussion 207. The domains pass still mints |
| +// its own <canary>.<app>.<sqid>.<apps-domain> alias for a STATIC app's |
| +// canary pointer (internal/domains); that is a route it writes itself, not an |
| +// instance record through the door, and is a different host. |
| +// |
| +// The dash is not a separator anything parses back. ls and rm find a canary |
| +// by its channel (canaryNameOf), never by taking its host apart, so |
| +// "foo-bar" meaning canary foo of app bar or canary "foo-bar" of an app |
| +// of its own is not a question this code ever asks. What the join can do is |
| +// land two different (canary, app) pairs, or a canary and another app's |
| +// apex, on one label. The create door refuses those (apex.go: a label naming |
| +// another claimed app, and hostHolder for a host another app already |
| +// holds), and canaryRecordPlan refuses a host that already carries one of |
| +// this app's own non-canary instances. |
| +// |
| +// The label has to fit 63 bytes with the dash: see canaryLabelTooLong. |
| func canaryHost(name, app, domain string) string { |
| - return name + "." + app + "." + domain |
| + return name + "-" + app + "." + domain |
| +} |
| + |
| +// canaryLabelFits reports whether name and app share one hostname label. |
| +func canaryLabelFits(name, app string) bool { |
| + return len(name)+1+len(app) <= 63 |
| } |
| // gitBranch is the branch --name defaults to. Unlike gitShortHead it does not |
| @@ -233,6 +259,7 @@ const ( |
| canaryPlanCreate canaryPlan = iota // no record on the canary host yet |
| canaryPlanRechannel // the host exists, following something else |
| canaryPlanReady // already correct — write nothing |
| + canaryPlanTaken // the host holds something that is not a canary |
| ) |
| func (p canaryPlan) String() string { |
| @@ -241,6 +268,8 @@ func (p canaryPlan) String() string { |
| return "create" |
| case canaryPlanRechannel: |
| return "rechannel" |
| + case canaryPlanTaken: |
| + return "taken" |
| default: |
| return "ready" |
| } |
| @@ -330,11 +359,36 @@ func canaryRecordPlan(recs []apiInstanceRecord, host, channel string) canaryPlan |
| if r.Channel == channel { |
| return canaryPlanReady |
| } |
| + // A canary host is one label now, so it can land on an ordinary |
| + // instance of this very app ("probe-titogo" for canary probe, and a |
| + // customer instance of that name). Rechannelling is for moving a |
| + // canary between canary/* channels; it must never carry somebody's |
| + // instance onto one. |
| + if canaryNameOf(r.Channel) == "" { |
| + return canaryPlanTaken |
| + } |
| return canaryPlanRechannel |
| } |
| return canaryPlanCreate |
| } |
| +// canaryHostChannel is the channel of the record sitting on host, from the |
| +// same source canaryPlanFor decided from, or "" when there is none. |
| +func canaryHostChannel(pre canaryPreflight, recs []apiInstanceRecord, host string) string { |
| + if pre.Host != "" { |
| + if pre.Found { |
| + return pre.Rec.Channel |
| + } |
| + return "" |
| + } |
| + for _, r := range recs { |
| + if r.Host == host { |
| + return r.Channel |
| + } |
| + } |
| + return "" |
| +} |
| + |
| // canaryRecords filters an app's instance records down to its canaries. The |
| // prefix test — not a bare "canary" — is the same boundary |
| // internal/host/agent.go's isCanaryRoute draws, and for the same reason: a |
| @@ -450,6 +504,12 @@ func runCanaryDeploy(args []string) { |
| channel := canaryChannel(canary) |
| + // The name and the app share ONE hostname label (canaryHost), so this is |
| + // a refusal before the first read, let alone a write. |
| + if !canaryLabelFits(canary, app2) { |
| + log.Fatalf("canary: %s", canaryLabelTooLong(canary, app2)) |
| + } |
| + |
| // --region is checked with reads only, before the first write (the |
| // instancesEnable PUT below): nothing changes when it is refused. |
| var pre canaryPreflight |
| @@ -497,6 +557,8 @@ func runCanaryDeploy(args []string) { |
| // direct read of the canary's own record (codex plan-R5 1). |
| plan := canaryPlanFor(pre, recs, host, channel) |
| switch plan { |
| + case canaryPlanTaken: |
| + log.Fatalf("canary: %s", canaryHostTaken(host, canaryHostChannel(pre, recs, host))) |
| case canaryPlanCreate: |
| rec, cerr := sess.instanceCreate(ctx, acct, app2, host, strings.TrimSpace(*regionFlag), channel, recKind, env, nil, nil, nil) |
| if cerr != nil { |
| @@ -680,3 +742,20 @@ func runCanaryRemove(args []string) { |
| fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("removed"), rec.Host) |
| mutedf(stdout, "%s itself remains and lapses at the end of its lease — nothing follows it now\n", channel) |
| } |
| + |
| +// ---- refusals ------------------------------------------------------------- |
| + |
| +// canaryLabelTooLong refuses a canary whose name and app do not fit one |
| +// hostname label together. The host is one label, <canary>-<app>, because the |
| +// create door takes exactly one label before the account's domain; a DNS |
| +// label holds 63 bytes, and the dash is one of them. |
| +func canaryLabelTooLong(name, app string) string { |
| + return fmt.Sprintf("the host label %s-%s would be %d characters, and a hostname label holds at most 63. Use a shorter --name.", |
| + name, app, len(name)+1+len(app)) |
| +} |
| + |
| +// canaryHostTaken refuses a canary whose host already carries something |
| +// that is not a canary. channel is what that record follows. |
| +func canaryHostTaken(host, channel string) string { |
| + return fmt.Sprintf("%s already exists and follows %s, which is not a canary channel. Choose another --name.", host, channel) |
| +} |
diff --git a/canary_create_e2e_test.go b/canary_create_e2e_test.go| new file mode 100644 |
| index 000000000..0b1d222df |
| --- /dev/null |
| +++ b/canary_create_e2e_test.go |
| @@ -0,0 +1,134 @@ |
| +// canary_create_e2e_test.go |
| +package main |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Discussion 207: `carlos canary` has not been able to CREATE a canary since |
| +// #384, because canaryHost built <canary>.<app>.<domain> and the create door |
| +// takes one label before the domain. These drive the real CLI against a real |
| +// console, which is what the canary tests until now never did: every one of |
| +// them either refused before the create or found the record already there. |
| +// |
| +// The run itself ends in a watch timeout, since nothing serves the host in |
| +// a test; what is asserted is the record the create wrote. |
| +func TestCanaryCreateMakesAOneLabelHost(t *testing.T) { |
| + for _, region := range []string{"", "ie"} { |
| + name := "no region flag" |
| + if region != "" { |
| + name = "region flag" |
| + } |
| + t.Run(name, func(t *testing.T) { |
| + e := regionE2EConsole(t, "") |
| + ctx := context.Background() |
| + const host = "probe-titogo.bab.oncarlos.test" |
| + args := []string{"canary", "deploy", "--app", "titogo", "--name", "probe", |
| + "--console", e.url, "--version", "abc1234", "--timeout", "1s"} |
| + if region != "" { |
| + args = append(args, "--region", region) |
| + } |
| + out, _ := e.run(t, append(args, artifact(t))...) |
| + if strings.Contains(out, "invalid host") { |
| + t.Fatalf("the create door refused the canary's host:\n%s", out) |
| + } |
| + if n := e.creates.Load(); n != 1 { |
| + t.Fatalf("%d create request(s), want 1:\n%s", n, out) |
| + } |
| + rec, err := provision.GetRecord(ctx, e.store, release.OpsAccount, "titogo", host) |
| + if err != nil || rec.Channel != "canary/probe" { |
| + t.Fatalf("record at %s = %+v %v, want it on canary/probe\n%s", host, rec, err, out) |
| + } |
| + if _, err := provision.GetRecord(ctx, e.store, release.OpsAccount, "titogo", "probe.titogo.bab.oncarlos.test"); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("a two-label record exists: %v", err) |
| + } |
| + if !strings.Contains(out, "canary live at") && !strings.Contains(out, host) { |
| + t.Fatalf("the output never names the canary host %s:\n%s", host, out) |
| + } |
| + |
| + // A second run finds it by that host: nothing to create. |
| + out, _ = e.run(t, append(args, artifact(t))...) |
| + if n := e.creates.Load(); n != 1 { |
| + t.Fatalf("the re-run created again (%d creates):\n%s", n, out) |
| + } |
| + |
| + // ls reads it back, and rm removes it by name. |
| + out, err = e.run(t, "canary", "ls", "--app", "titogo", "--console", e.url) |
| + if err != nil || !strings.Contains(out, host) || !strings.Contains(out, "canary/probe") { |
| + t.Fatalf("canary ls = %v\n%s, want the one-label host", err, out) |
| + } |
| + out, err = e.run(t, "canary", "rm", "--app", "titogo", "--name", "probe", "--console", e.url) |
| + if err != nil || !strings.Contains(out, "removed "+host) { |
| + t.Fatalf("canary rm = %v\n%s, want it to remove %s", err, out, host) |
| + } |
| + if _, err := provision.GetRecord(ctx, e.store, release.OpsAccount, "titogo", host); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("the record survived rm: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// The canary name and the app share one 63-byte label now. A name that fits |
| +// on its own but not beside the app is refused before any write, with the |
| +// numbers in it. |
| +func TestCanaryTooLongForOneLabelRefusesBeforeAnyWrite(t *testing.T) { |
| + e := regionE2EConsole(t, "") |
| + long := strings.Repeat("a", 58) // 58 + "-" + "titogo" = 65 |
| + want := "canary: " + canaryLabelTooLong(long, "titogo") |
| + for run := 0; run < 2; run++ { |
| + out, err := e.run(t, "canary", "deploy", "--app", "titogo", "--name", long, |
| + "--console", e.url, "--version", "abc1234", "--timeout", "1s", artifact(t)) |
| + if err == nil || !hasRefusalLine(out, want) { |
| + t.Fatalf("run %d: err=%v out=%s, want the whole line %q", run, err, out, want) |
| + } |
| + } |
| + if e.writes.Load() != 0 || e.releases(t) != 0 { |
| + t.Fatalf("writes %d, releases %d; want nothing written", e.writes.Load(), e.releases(t)) |
| + } |
| + |
| + // The limit itself fits: 56 + 1 + 6 = 63. |
| + fits := strings.Repeat("a", 56) |
| + out, _ := e.run(t, "canary", "deploy", "--app", "titogo", "--name", fits, |
| + "--console", e.url, "--version", "abc1234", "--timeout", "1s", artifact(t)) |
| + if _, err := provision.GetRecord(context.Background(), e.store, release.OpsAccount, "titogo", fits+"-titogo.bab.oncarlos.test"); err != nil { |
| + t.Fatalf("a 63-byte canary label was not created: %v\n%s", err, out) |
| + } |
| +} |
| + |
| +// Flattening puts a canary where an ordinary instance of the same app can |
| +// already be: app "titogo" with a customer instance "probe-titogo". Before |
| +// the flattening a canary host was always one label deeper than any |
| +// instance, so reusing "the record on this host" could only ever reuse a |
| +// canary. Now it could hand a customer's instance to the canary channel, so |
| +// a record that is not on a canary/* channel is refused and left as it is. |
| +func TestCanaryDoesNotTakeOverAnInstanceAtItsHost(t *testing.T) { |
| + e := regionE2EConsole(t, "") |
| + ctx := context.Background() |
| + const host = "probe-titogo.bab.oncarlos.test" |
| + rec := provision.Record{Schema: 1, Host: host, App: "titogo", Account: release.OpsAccount, |
| + Channel: "stable", CreatedAt: time.Now().UTC()} |
| + if err := provision.CreateRecord(ctx, e.store, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + want := "canary: " + canaryHostTaken(host, "stable") |
| + out, err := e.run(t, "canary", "deploy", "--app", "titogo", "--name", "probe", |
| + "--console", e.url, "--version", "abc1234", "--timeout", "1s", artifact(t)) |
| + if err == nil || !hasRefusalLine(out, want) { |
| + t.Fatalf("err=%v out=%s, want the whole line %q", err, out, want) |
| + } |
| + got, err := provision.GetRecord(ctx, e.store, release.OpsAccount, "titogo", host) |
| + if err != nil || got.Channel != "stable" { |
| + t.Fatalf("the instance = %+v %v, want it still on stable", got, err) |
| + } |
| + if e.releases(t) != 0 { |
| + t.Fatal("a refused canary shipped a release") |
| + } |
| +} |
diff --git a/canary_region_e2e_test.go b/canary_region_e2e_test.go| index 45fa37045..0244181b5 100644 |
| --- a/canary_region_e2e_test.go |
| +++ b/canary_region_e2e_test.go |
| @@ -35,7 +35,7 @@ func hasRefusalLine(out, want string) bool { |
| // Two identical runs per case: the same refusal both times, and no write, |
| // no new record, no manifest and no upload, before or after. |
| func TestCanaryRegionRefusesBeforeAnyWrite(t *testing.T) { |
| - const existing = "probe.titogo.bab.oncarlos.test" |
| + const existing = "probe-titogo.bab.oncarlos.test" |
| cases := []struct { |
| name, mode, region string |
| fleet, canary bool |
| @@ -119,7 +119,7 @@ func TestCanaryRegionRefusesBeforeAnyWrite(t *testing.T) { |
| // On its own channel it is left as it is; on another it is rechannelled. |
| // Either way there is no create request and the run goes on to ship. |
| func TestCanaryRegionReusesACanaryPastTheListCap(t *testing.T) { |
| - const existing = "probe.titogo.bab.oncarlos.test" |
| + const existing = "probe-titogo.bab.oncarlos.test" |
| for _, c := range []struct{ name, channel, env string }{ |
| {"same channel", "canary/probe", ""}, |
| {"different channel", "canary/old", ""}, |
diff --git a/canary_region_test.go b/canary_region_test.go| index c286fdc76..cd9a05e93 100644 |
| --- a/canary_region_test.go |
| +++ b/canary_region_test.go |
| @@ -38,10 +38,10 @@ func TestCanaryRegionCheck(t *testing.T) { |
| regionUnknownFleet := apiRegionsResult{Regions: names, Default: "ie", AppDefault: &apiAppDefault{PlacementKnown: true, Reason: "fleet", InstanceDomain: "go.tito.io"}} |
| regionUnknown := apiRegionsResult{Regions: names, Default: "ie", AppDefault: &apiAppDefault{PlacementKnown: true, InstanceDomain: "go.tito.io"}} |
| boom := errors.New("timeout") |
| - const host = "c.blog.go.tito.io" |
| + const host = "c-blog.go.tito.io" |
| existingUK := apiInstanceRecord{Host: host, Region: "uk"} |
| existingHome := apiInstanceRecord{Host: host, Region: ""} |
| - other := apiInstanceRecord{Host: "d.blog.go.tito.io", Region: "uk"} |
| + other := apiInstanceRecord{Host: "d-blog.go.tito.io", Region: "uk"} |
| cases := []struct { |
| name string |
| res apiRegionsResult |
| @@ -98,7 +98,7 @@ func TestCanaryRegionCheck(t *testing.T) { |
| // codex plan-R5 1: with --region, the direct read decides the plan, even |
| // when the capped list does not show the record at all. |
| func TestCanaryPlanFor(t *testing.T) { |
| - const host, ch = "c.blog.go.tito.io", "canary/c" |
| + const host, ch = "c-blog.go.tito.io", "canary/c" |
| listed := []apiInstanceRecord{{Host: host, Channel: ch}} |
| cases := []struct { |
| name string |
diff --git a/canary_test.go b/canary_test.go| index d4f3fe574..2fd6f55c2 100644 |
| --- a/canary_test.go |
| +++ b/canary_test.go |
| @@ -6,6 +6,7 @@ import ( |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -113,15 +114,52 @@ func TestCanaryNameWithNothingToGoOnIsRefused(t *testing.T) { |
| } |
| } |
| -// The host is README's documented scheme: <canary>.<app>.<account-sqid>.<apps-domain>. |
| -// The manifest's domain is already "<sqid>.<apps-domain>", the same value |
| -// deploy's mintPlatformHost builds "<app>.<domain>" from. |
| -func TestCanaryHostFollowsTheDocumentedScheme(t *testing.T) { |
| - got := canaryHost("new-queue", "jam", "bab.oncarlos.com") |
| - want := "new-queue.jam.bab.oncarlos.com" |
| +// The host is <canary>-<app>.<account-sqid>.<apps-domain>: one label before |
| +// the domain, because the instance create door takes exactly one (discussion |
| +// 207). The manifest's domain is already "<sqid>.<apps-domain>", the same |
| +// value deploy's mintPlatformHost builds "<app>.<domain>" from. |
| +func TestCanaryHostIsOneLabelBeforeTheDomain(t *testing.T) { |
| + const domain = "bab.oncarlos.com" |
| + got := canaryHost("new-queue", "jam", domain) |
| + want := "new-queue-jam.bab.oncarlos.com" |
| if got != want { |
| t.Errorf("canaryHost = %q, want %q", got, want) |
| } |
| + // The create door's own predicate, not a restatement of it. |
| + if !provision.ValidHost(got, domain) { |
| + t.Errorf("provision.ValidHost(%q, %q) = false: the create door would refuse this canary", got, domain) |
| + } |
| + // The widest label that fits: name + "-" + app = 63. |
| + name := strings.Repeat("n", 63-1-len("jam")) |
| + if h := canaryHost(name, "jam", domain); !canaryLabelFits(name, "jam") || !provision.ValidHost(h, domain) { |
| + t.Errorf("a 63-byte label was refused: fits=%v host=%q", canaryLabelFits(name, "jam"), h) |
| + } |
| +} |
| + |
| +// name + "-" + app is one hostname label: 63 bytes, the dash counted. A name |
| +// validCanaryName accepts on its own can still be too long beside the app. |
| +func TestCanaryLabelFitsOneLabel(t *testing.T) { |
| + for _, tc := range []struct { |
| + name, app string |
| + want bool |
| + }{ |
| + {"new-queue", "jam", true}, |
| + {strings.Repeat("n", 59), "jam", true}, // 59 + 1 + 3 = 63 |
| + {strings.Repeat("n", 60), "jam", false}, // 64 |
| + {strings.Repeat("n", 63), "a", false}, // a valid name, no room for the app |
| + {"a", strings.Repeat("p", 61), true}, // 1 + 1 + 61 = 63 |
| + {"a", strings.Repeat("p", 62), false}, |
| + } { |
| + if got := canaryLabelFits(tc.name, tc.app); got != tc.want { |
| + t.Errorf("canaryLabelFits(%d bytes, %d bytes) = %v, want %v", len(tc.name), len(tc.app), got, tc.want) |
| + } |
| + } |
| + msg := canaryLabelTooLong(strings.Repeat("n", 60), "jam") |
| + for _, want := range []string{"64 characters", "63", "--name"} { |
| + if !strings.Contains(msg, want) { |
| + t.Errorf("refusal %q does not say %q", msg, want) |
| + } |
| + } |
| } |
| func TestCanaryChannelIsNamespaced(t *testing.T) { |
| @@ -134,7 +172,7 @@ func TestCanaryChannelIsNamespaced(t *testing.T) { |
| // no-write case matters — a redeploy of the same canary must not PATCH a |
| // record that is already correct. |
| func TestCanaryRecordPlan(t *testing.T) { |
| - const host = "new-queue.jam.bab.oncarlos.com" |
| + const host = "new-queue-jam.bab.oncarlos.com" |
| const ch = "canary/new-queue" |
| other := apiInstanceRecord{Host: "jam.bab.oncarlos.com", Channel: "stable"} |
| @@ -149,8 +187,13 @@ func TestCanaryRecordPlan(t *testing.T) { |
| []apiInstanceRecord{other, {Host: host, Channel: ch}}, canaryPlanReady}, |
| {"canary host parked on another channel", |
| []apiInstanceRecord{other, {Host: host, Channel: "canary/old"}}, canaryPlanRechannel}, |
| - {"canary host somehow on stable", |
| - []apiInstanceRecord{{Host: host, Channel: "stable"}}, canaryPlanRechannel}, |
| + // One label now: the canary's host can be an ordinary instance of |
| + // the same app. Rechannelling is for canaries; an instance that is |
| + // not on a canary/* channel is refused, whatever it follows. |
| + {"an instance of the app on the canary's host", |
| + []apiInstanceRecord{{Host: host, Channel: "stable"}}, canaryPlanTaken}, |
| + {"a bare canary channel is not a canary/* channel", |
| + []apiInstanceRecord{{Host: host, Channel: "canary"}}, canaryPlanTaken}, |
| } { |
| t.Run(tc.name, func(t *testing.T) { |
| if got := canaryRecordPlan(tc.recs, host, ch); got != tc.want { |
| @@ -188,14 +231,14 @@ func TestCanaryLeaseLabel(t *testing.T) { |
| func TestCanaryRecordForNamePicksExactlyOne(t *testing.T) { |
| recs := []apiInstanceRecord{ |
| {Host: "jam.bab.oncarlos.com", Channel: "stable"}, |
| - {Host: "old.jam.bab.oncarlos.com", Channel: "canary/old"}, |
| - {Host: "new-queue.jam.bab.oncarlos.com", Channel: "canary/new-queue"}, |
| + {Host: "old-jam.bab.oncarlos.com", Channel: "canary/old"}, |
| + {Host: "new-queue-jam.bab.oncarlos.com", Channel: "canary/new-queue"}, |
| } |
| got, ok := canaryRecordForChannel(recs, "canary/new-queue") |
| if !ok { |
| t.Fatal("no record found for canary/new-queue") |
| } |
| - if got.Host != "new-queue.jam.bab.oncarlos.com" { |
| + if got.Host != "new-queue-jam.bab.oncarlos.com" { |
| t.Errorf("host = %q, want the new-queue record", got.Host) |
| } |
| if _, ok := canaryRecordForChannel(recs, "canary/nope"); ok { |
| @@ -207,12 +250,12 @@ func TestCanaryRecordForNamePicksExactlyOne(t *testing.T) { |
| func TestCanaryRecordsFiltersToCanaryChannels(t *testing.T) { |
| recs := []apiInstanceRecord{ |
| {Host: "jam.bab.oncarlos.com", Channel: "stable"}, |
| - {Host: "old.jam.bab.oncarlos.com", Channel: "canary/old"}, |
| - {Host: "eu.jam.bab.oncarlos.com", Channel: "edge"}, |
| - {Host: "new-queue.jam.bab.oncarlos.com", Channel: "canary/new-queue"}, |
| + {Host: "old-jam.bab.oncarlos.com", Channel: "canary/old"}, |
| + {Host: "eu-jam.bab.oncarlos.com", Channel: "edge"}, |
| + {Host: "new-queue-jam.bab.oncarlos.com", Channel: "canary/new-queue"}, |
| // A bare "canary" is NOT a canary/* channel — the same boundary |
| // internal/host/agent.go's isCanaryRoute draws. |
| - {Host: "bare.jam.bab.oncarlos.com", Channel: "canary"}, |
| + {Host: "bare-jam.bab.oncarlos.com", Channel: "canary"}, |
| } |
| got := canaryRecords(recs) |
| if len(got) != 2 { |
| @@ -264,3 +307,24 @@ func TestCanaryTakesTheUpdateHint(t *testing.T) { |
| t.Error("canary is still in noHintVerbs — it was listed there while it was a stub") |
| } |
| } |
| + |
| +// The refusal names the channel the record on the host follows, from the |
| +// same source the plan was decided from: the direct read under --region, the |
| +// list otherwise. |
| +func TestCanaryHostChannelReadsWhatThePlanDecidedFrom(t *testing.T) { |
| + const host = "probe-jam.bab.oncarlos.com" |
| + recs := []apiInstanceRecord{{Host: host, Channel: "stable"}, {Host: "other-jam.bab.oncarlos.com", Channel: "edge"}} |
| + if got := canaryHostChannel(canaryPreflight{}, recs, host); got != "stable" { |
| + t.Errorf("from the list = %q, want stable", got) |
| + } |
| + pre := canaryPreflight{Host: host, Rec: apiInstanceRecord{Host: host, Channel: "beta"}, Found: true} |
| + if got := canaryHostChannel(pre, recs, host); got != "beta" { |
| + t.Errorf("from the direct read = %q, want beta", got) |
| + } |
| + if got := canaryHostChannel(canaryPreflight{Host: host}, recs, host); got != "" { |
| + t.Errorf("direct read found nothing, got %q", got) |
| + } |
| + if msg := canaryHostTaken(host, "stable"); !strings.Contains(msg, host) || !strings.Contains(msg, "stable") { |
| + t.Errorf("refusal %q names neither the host nor its channel", msg) |
| + } |
| +} |