carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 677cd3d4aa5f6f69694e958eaa19e8c9a5e72236 parent c8ee078
5 files changed, +305 −28
  • canary.go +86 −7
  • canary_create_e2e_test.go +134 −0
  • canary_region_e2e_test.go +2 −2
  • canary_region_test.go +3 −3
  • canary_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)
+ }
+}