cli: canary --region checks with reads before any write; refuses identically on fleets and older consoles (nearest-region §11)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+464
−8
apimode.go+27 −0canary.go+99 −5canary_region_e2e_test.go+190 −0canary_region_test.go+135 −0instances_cli.go+13 −3
diff --git a/apimode.go b/apimode.go| index 681c12fda..e9f7d40a3 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -2989,6 +2989,11 @@ type apiInstanceRecord struct { |
| // store afterwards is Environment, and `carlos store status` is what |
| // reads it. |
| PrivateStoreBucket string `json:"private_store_bucket"` |
| + // RegionName and RegionFrom say where the create put the record and how |
| + // that was chosen (nearest-region §9). Empty from a console that predates |
| + // them, and then the CLI prints today's line. |
| + RegionName string `json:"region_name,omitempty"` |
| + RegionFrom string `json:"region_from,omitempty"` |
| } |
| // apiUpstreamState mirrors status.UpstreamState's wire shape (internal/status) |
| @@ -3476,6 +3481,28 @@ func (s *apiSession) instanceDelete(ctx context.Context, a apiAcct, app, host st |
| return nil |
| } |
| +// instanceGet reads ONE instance record (GET …/instances/{host}). found is |
| +// false only on a 404, the console's answer for a host with no record; any |
| +// other failure is an error, never "absent" (canary --region relies on |
| +// that, nearest-region plan-R3 1). |
| +func (s *apiSession) instanceGet(ctx context.Context, a apiAcct, app, host string) (apiInstanceRecord, bool, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host))) |
| + if err != nil { |
| + return apiInstanceRecord{}, false, err |
| + } |
| + if status == http.StatusNotFound { |
| + return apiInstanceRecord{}, false, nil |
| + } |
| + if status != http.StatusOK { |
| + return apiInstanceRecord{}, false, apiErrorFromBody(status, body) |
| + } |
| + var out apiInstanceRecord |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return apiInstanceRecord{}, false, fmt.Errorf("instance: unreadable response: %w", err) |
| + } |
| + return out, true, nil |
| +} |
| + |
| // apiSidecarManifest mirrors the console's sidecars-manifest response |
| // (handleAPISidecarEnable's writeJSON, internal/console/apisidecars.go) — |
| // read back after a write so the confirmation prints what the console |
diff --git a/canary.go b/canary.go| index e295f95f5..503f25563 100644 |
| --- a/canary.go |
| +++ b/canary.go |
| @@ -36,7 +36,7 @@ import ( |
| ) |
| const canaryUsage = `usage: carlos canary [deploy] --app <a> [--name <n>] [--version <sha>] [--kind binary|static] |
| - [--environment <e>] [--target <goos-goarch>] [--label <l>] |
| + [--region <code>] [--environment <e>] [--target <goos-goarch>] [--label <l>] |
| [--timeout 120s] <files-or-site-dir> |
| carlos canary ls --app <a> |
| carlos canary rm --app <a> --name <n> |
| @@ -246,6 +246,79 @@ func (p canaryPlan) String() string { |
| } |
| } |
| +// canaryRegionCheck is spec §11's canary --region rule, run before ANY |
| +// write, so the answer never depends on whether the canary exists yet |
| +// (codex R4-2, plan-R2 4, plan-R3 1): |
| +// - the flag needs a console that can say whether a fleet governs; a |
| +// failed read, an older console with no app_default, or one that |
| +// could not read the placement or the stored region (either flag |
| +// false, R9a), refuses it on every run (C30b), |
| +// because the create door would silently replace it with the fleet; |
| +// - a fleet-placed app refuses the flag every time (C30); |
| +// - the canary's OWN record is then read by host, through get (one |
| +// GET …/instances/{host}), never inferred from a capped list that |
| +// skips records it cannot read. A host the console cannot name (no |
| +// instance_domain) or a read that fails refuses (C30c); a record in |
| +// another region refuses (C25), named by place. |
| +// |
| +// It returns what it read (codex plan-R5 1): the host, which the caller |
| +// holds the later instancesEnable answer to, and the record and whether it |
| +// exists, which decide create, rechannel or ready (canaryPlanFor). |
| +func canaryRegionCheck(res apiRegionsResult, readErr error, app, name, asked string, get func(host string) (apiInstanceRecord, bool, error)) (canaryPreflight, error) { |
| + if asked == "" { |
| + return canaryPreflight{}, nil |
| + } |
| + // Both flags: effective is only stated when the stored region was read |
| + // too (R9a), and the fleet check below reads effective (codex plan-R4 1). |
| + if readErr != nil || res.AppDefault == nil || !res.AppDefault.PlacementKnown || !res.AppDefault.RegionKnown { |
| + return canaryPreflight{}, errors.New(canaryFleetUnknown(app)) |
| + } |
| + if strings.HasPrefix(res.AppDefault.Effective, "fleet/") { |
| + return canaryPreflight{}, errors.New(canaryRegionOnFleet(app, strings.TrimPrefix(res.AppDefault.Effective, "fleet/"))) |
| + } |
| + if res.AppDefault.InstanceDomain == "" { |
| + return canaryPreflight{}, errors.New(canaryRegionUnconfirmed(name)) |
| + } |
| + host := canaryHost(name, app, res.AppDefault.InstanceDomain) |
| + rec, found, err := get(host) |
| + if err != nil { |
| + return canaryPreflight{}, errors.New(canaryRegionUnconfirmed(name)) |
| + } |
| + if found { |
| + have := rec.Region |
| + if have == "" { |
| + have = res.Default |
| + } |
| + if have != asked { |
| + return canaryPreflight{}, errors.New(canaryRegionMismatch(host, res.nameOf(have))) |
| + } |
| + } |
| + return canaryPreflight{Host: host, Rec: rec, Found: found}, nil |
| +} |
| + |
| +// canaryPreflight is what canaryRegionCheck read. Host is "" when --region |
| +// was not given; then nothing was read. |
| +type canaryPreflight struct { |
| + Host string |
| + Rec apiInstanceRecord |
| + Found bool |
| +} |
| + |
| +// canaryPlanFor decides create, rechannel or ready. With --region the |
| +// direct read decides (codex plan-R5 1): the list is capped at 500 and |
| +// skips records it cannot read, so it can miss a canary that exists, and |
| +// creating it again would fail. Without --region the list decides, as |
| +// before. |
| +func canaryPlanFor(pre canaryPreflight, recs []apiInstanceRecord, host, channel string) canaryPlan { |
| + if pre.Host == "" { |
| + return canaryRecordPlan(recs, host, channel) |
| + } |
| + if !pre.Found { |
| + return canaryPlanCreate |
| + } |
| + return canaryRecordPlan([]apiInstanceRecord{pre.Rec}, host, channel) |
| +} |
| + |
| // canaryRecordPlan decides between the three. The Ready case is the one worth |
| // having: re-canarying the same branch is the common act, and it must not |
| // PATCH a record that already says exactly what it should. |
| @@ -342,6 +415,7 @@ func runCanaryDeploy(args []string) { |
| acctRaw, acctIDRaw, _ := accountFlags(fs) |
| consoleFlag := consoleOption(fs, "console to deploy through (default: the only logged-in one)") |
| kind := fs.String("kind", "binary", "binary | static") |
| + regionFlag := fs.String("region", "", flagRegionCanary) |
| environment := fs.String("environment", "", "named config bundle the canary binds to (default: the app's default bundle)") |
| ver := versionFlag(fs, "version (default: git rev-parse --short HEAD)") |
| target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)") |
| @@ -376,6 +450,18 @@ func runCanaryDeploy(args []string) { |
| channel := canaryChannel(canary) |
| + // --region is checked with reads only, before the first write (the |
| + // instancesEnable PUT below): nothing changes when it is refused. |
| + var pre canaryPreflight |
| + if asked := strings.TrimSpace(*regionFlag); asked != "" { |
| + res, rerr := sess.appRegions(ctx, acct, app2) |
| + get := func(h string) (apiInstanceRecord, bool, error) { return sess.instanceGet(ctx, acct, app2, h) } |
| + var err error |
| + if pre, err = canaryRegionCheck(res, rerr, app2, canary, asked, get); err != nil { |
| + log.Fatalf("canary: %v", err) |
| + } |
| + } |
| + |
| // instancesEnable is idempotent and is the ONLY source of the domain |
| // suffix — nothing here composes an apps domain by hand. |
| m, err := sess.instancesEnable(ctx, acct, app2, nil, nil, nil) |
| @@ -383,6 +469,11 @@ func runCanaryDeploy(args []string) { |
| log.Fatalf("canary: enable instances for %s: %v", app2, err) |
| } |
| host := canaryHost(canary, app2, m.Domain) |
| + if pre.Host != "" && host != pre.Host { |
| + // The PUT named a different domain from the one the check read |
| + // under: the canary checked is not the one about to be written. |
| + log.Fatalf("canary: %s", canaryRegionUnconfirmed(canary)) |
| + } |
| recs, _, _, err := sess.instanceList(ctx, acct, app2, "", "") |
| if err != nil { |
| @@ -402,13 +493,16 @@ func runCanaryDeploy(args []string) { |
| } |
| pal := colorFor(stdout) |
| - switch canaryRecordPlan(recs, host, channel) { |
| + // One decision, used twice below: with --region it comes from the |
| + // direct read of the canary's own record (codex plan-R5 1). |
| + plan := canaryPlanFor(pre, recs, host, channel) |
| + switch plan { |
| case canaryPlanCreate: |
| - rec, cerr := sess.instanceCreate(ctx, acct, app2, host, "", channel, recKind, env, nil, nil, nil) |
| + rec, cerr := sess.instanceCreate(ctx, acct, app2, host, strings.TrimSpace(*regionFlag), channel, recKind, env, nil, nil, nil) |
| if cerr != nil { |
| log.Fatalf("canary: create %s: %v", host, cerr) |
| } |
| - fmt.Fprintf(stdout, "%s %s (channel %s%s)\n", pal.ok("canary host"), rec.Host, rec.Channel, environmentSuffix(rec.Environment)) |
| + fmt.Fprintf(stdout, "%s %s\n", pal.ok("canary host"), instanceCreatedLine(rec, environmentSuffix(rec.Environment))) |
| case canaryPlanRechannel: |
| // set-channel, not `carlos route --channel`: the record is where a |
| // provisioned route's channel lives, and the reconciler reverts the |
| @@ -427,7 +521,7 @@ func runCanaryDeploy(args []string) { |
| // silently kept yesterday's binding is the failure this flag exists to |
| // prevent. Only ever sent when the flag was actually typed — an omitted |
| // flag must leave a deliberate binding alone rather than clear it. |
| - if flagWasSet(fs, "environment") && canaryRecordPlan(recs, host, channel) != canaryPlanCreate { |
| + if flagWasSet(fs, "environment") && plan != canaryPlanCreate { |
| rec, eerr := sess.instanceSetEnvironment(ctx, acct, app2, host, env) |
| if eerr != nil { |
| log.Fatalf("canary: bind %s to environment %q: %v", host, env, eerr) |
diff --git a/canary_region_e2e_test.go b/canary_region_e2e_test.go| new file mode 100644 |
| index 000000000..45fa37045 |
| --- /dev/null |
| +++ b/canary_region_e2e_test.go |
| @@ -0,0 +1,190 @@ |
| +// canary_region_e2e_test.go |
| +package main |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/console" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// hasRefusalLine reports whether a whole output line is want, behind at most |
| +// the log package's own timestamp. A swap of two adjacent arguments in the |
| +// sentence is a different line and fails. |
| +func hasRefusalLine(out, want string) bool { |
| + for _, l := range strings.Split(out, "\n") { |
| + l = strings.TrimSpace(l) |
| + if l == want { |
| + return true |
| + } |
| + // "2006/01/02 15:04:05 " is 20 bytes. |
| + if len(l) > 20 && l[4] == '/' && l[7] == '/' && l[10] == ' ' && l[13] == ':' && l[16] == ':' && l[19] == ' ' && l[20:] == want { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// 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" |
| + cases := []struct { |
| + name, mode, region string |
| + fleet, canary bool |
| + ahead int // records whose hosts sort before the canary's |
| + unreadable bool // the canary's record cannot be read |
| + want string |
| + }{ |
| + {"fleet-placed app", "", "uk", true, false, 0, false, canaryRegionOnFleet("titogo", "acme")}, |
| + {"older console", "legacy", "uk", true, false, 0, false, canaryFleetUnknown("titogo")}, |
| + {"placement unknown", "placement-unknown", "uk", false, false, 0, false, canaryFleetUnknown("titogo")}, |
| + {"existing canary elsewhere", "", "ie", false, true, 0, false, canaryRegionMismatch(existing, "uk")}, |
| + // codex plan-R3 1: past the list's 500-record cap, and unreadable |
| + // (a list skips it), the canary's own record still decides. |
| + {"existing canary past the list cap", "", "ie", false, true, 520, false, canaryRegionMismatch(existing, "uk")}, |
| + {"existing canary unreadable", "", "ie", false, true, 0, true, canaryRegionUnconfirmed("probe")}, |
| + // codex plan-R4 1: a fleet-placed app whose stored region the console |
| + // could not read; R9a leaves effective empty, and the flag is refused. |
| + {"fleet, region unknown", "region-unknown", "uk", true, false, 0, false, canaryFleetUnknown("titogo")}, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + e := regionE2EConsole(t, "") |
| + ctx := context.Background() |
| + if c.fleet { |
| + if err := e.cst.SetAppPlacement(console.OpsAccountID, "titogo", "fleet/acme"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + for i := 0; i < c.ahead; i++ { |
| + h := fmt.Sprintf("a%03d.titogo.bab.oncarlos.test", i) |
| + rec := provision.Record{Schema: 1, Host: h, App: "titogo", Account: release.OpsAccount, |
| + Channel: "stable", CreatedAt: time.Now().UTC()} |
| + if err := provision.CreateRecord(ctx, e.store, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if c.canary { |
| + rec := provision.Record{Schema: 1, Host: existing, App: "titogo", Account: release.OpsAccount, |
| + Region: "uk", Channel: "canary/probe", CreatedAt: time.Now().UTC()} |
| + if err := provision.CreateRecord(ctx, e.store, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if c.unreadable { |
| + e.failGet.Store(provision.RecordKey(release.OpsAccount, "titogo", existing)) |
| + } |
| + e.mode.Store(c.mode) |
| + for run := 0; run < 2; run++ { |
| + out, err := e.run(t, "canary", "deploy", "--app", "titogo", "--name", "probe", "--region", c.region, |
| + "--console", e.url, "--version", "abc1234", "--timeout", "1s", artifact(t)) |
| + if err == nil || !hasRefusalLine(out, "canary: "+c.want) { |
| + t.Fatalf("run %d: err=%v out=%s, want the whole line %q", run, err, out, "canary: "+c.want) |
| + } |
| + } |
| + recs, _, err := provision.ListRecords(ctx, e.store, release.OpsAccount, "titogo", 1000) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + wantRecs := c.ahead |
| + if c.canary { |
| + wantRecs++ |
| + } |
| + if len(recs) != wantRecs || e.writes.Load() != 0 || e.releases(t) != 0 { |
| + t.Fatalf("records %d (want %d), writes %d, releases %d; want nothing written", len(recs), wantRecs, e.writes.Load(), e.releases(t)) |
| + } |
| + if c.canary { |
| + rec, err := provision.GetRecord(ctx, e.store, release.OpsAccount, "titogo", existing) |
| + if err != nil || rec.Region != "uk" { |
| + t.Fatalf("the existing canary = %+v %v, want it still in uk", rec, err) |
| + } |
| + } |
| + if _, err := provision.GetManifest(ctx, e.store, release.OpsAccount, "titogo"); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("an instances manifest was written: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// codex plan-R5 1: a same-region canary past the list's 500-record cap is |
| +// the record the command reuses, found by the direct read, never re-created. |
| +// 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" |
| + for _, c := range []struct{ name, channel, env string }{ |
| + {"same channel", "canary/probe", ""}, |
| + {"different channel", "canary/old", ""}, |
| + // The environment bind follows the same decision: an existing |
| + // record gets its own bind write, never a create carrying it. |
| + {"same channel, with --environment", "canary/probe", "beta"}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + e := regionE2EConsole(t, "") |
| + ctx := context.Background() |
| + for i := 0; i < 520; i++ { |
| + rec := provision.Record{Schema: 1, Host: fmt.Sprintf("a%03d.titogo.bab.oncarlos.test", i), App: "titogo", |
| + Account: release.OpsAccount, Channel: "stable", CreatedAt: time.Now().UTC()} |
| + if err := provision.CreateRecord(ctx, e.store, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + rec := provision.Record{Schema: 1, Host: existing, App: "titogo", Account: release.OpsAccount, |
| + Region: "uk", Channel: c.channel, CreatedAt: time.Now().UTC()} |
| + if err := provision.CreateRecord(ctx, e.store, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + args := []string{"canary", "deploy", "--app", "titogo", "--name", "probe", "--region", "uk", |
| + "--console", e.url, "--version", "abc1234", "--timeout", "1s"} |
| + if c.env != "" { |
| + args = append(args, "--environment", c.env) |
| + } |
| + out, _ := e.run(t, append(args, artifact(t))...) |
| + if n := e.creates.Load(); n != 0 { |
| + t.Fatalf("%d create request(s) for a canary that exists:\n%s", n, out) |
| + } |
| + if e.releases(t) == 0 { |
| + t.Fatalf("the run never shipped:\n%s", out) |
| + } |
| + got, err := provision.GetRecord(ctx, e.store, release.OpsAccount, "titogo", existing) |
| + if err != nil || got.Region != "uk" || got.Channel != "canary/probe" || got.Environment != c.env { |
| + t.Fatalf("the canary = %+v %v, want it in uk on canary/probe, environment %q", got, err, c.env) |
| + } |
| + if recs, _, err := provision.ListRecords(ctx, e.store, release.OpsAccount, "titogo", 1000); err != nil || len(recs) != 521 { |
| + t.Fatalf("records = %d %v, want the 521 there were", len(recs), err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// codex plan-R3 6: a successful replica create prints C14 with its place, |
| +// not an older console's "(channel X)". This console has no secrets key, |
| +// so no box is eligible and the place falls back to its code. |
| +func TestInstancesCreateReplicaPrintsItsPlace(t *testing.T) { |
| + e := regionE2EConsole(t, "") |
| + ctx := context.Background() |
| + m := provision.Manifest{Schema: 1, App: "titogo", Domain: "bab.oncarlos.test", Channel: "beta"} |
| + if err := provision.PutManifest(ctx, e.store, release.OpsAccount, "titogo", m); err != nil { |
| + t.Fatal(err) |
| + } |
| + box := `{"v":1,"box":"uk-box","eip":"1.2.3.4","region":"eu-west-2","placement":"uk","hibernation_configured":true,"hosts":{}}` |
| + if _, err := e.store.Put(ctx, "control/edges/uk-box.json", []byte(box), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + const host = "r.bab.oncarlos.test" |
| + out, err := e.run(t, "instances", "create", "--app", "titogo", "--host", host, "--region", "uk", "--role", "replica", "--console", e.url) |
| + if err != nil { |
| + t.Fatalf("create: %v\n%s", err, out) |
| + } |
| + if want := "created instance " + host + " (" + instanceCreatedSuffix("beta", "uk") + ")"; !hasRefusalLine(out, want) { |
| + t.Fatalf("stdout = %q, want the whole line %q", out, want) |
| + } |
| +} |
diff --git a/canary_region_test.go b/canary_region_test.go| new file mode 100644 |
| index 000000000..c286fdc76 |
| --- /dev/null |
| +++ b/canary_region_test.go |
| @@ -0,0 +1,135 @@ |
| +// canary_region_test.go |
| +package main |
| + |
| +import ( |
| + "errors" |
| + "testing" |
| +) |
| + |
| +// records is a fake single-record GET: a host it holds is found, any other |
| +// is a 404 (found false), and err fails every read. |
| +func records(err error, recs ...apiInstanceRecord) (func(string) (apiInstanceRecord, bool, error), *[]string) { |
| + var asked []string |
| + return func(host string) (apiInstanceRecord, bool, error) { |
| + asked = append(asked, host) |
| + if err != nil { |
| + return apiInstanceRecord{}, false, err |
| + } |
| + for _, r := range recs { |
| + if r.Host == host { |
| + return r, true, nil |
| + } |
| + } |
| + return apiInstanceRecord{}, false, nil |
| + }, &asked |
| +} |
| + |
| +func TestCanaryRegionCheck(t *testing.T) { |
| + names := []apiRegionEntry{{Code: "ie", Name: "P-ie"}, {Code: "uk", Name: "P-uk"}} |
| + known := func(d apiAppDefault) *apiAppDefault { |
| + d.RegionKnown, d.PlacementKnown, d.InstanceDomain = true, true, "go.tito.io" |
| + return &d |
| + } |
| + plain := apiRegionsResult{Regions: names, Default: "ie", AppDefault: known(apiAppDefault{Region: "uk", Effective: "uk"})} |
| + fleet := apiRegionsResult{Regions: names, Default: "ie", AppDefault: known(apiAppDefault{Region: "uk", Effective: "fleet/acme", Reason: "fleet"})} |
| + older := apiRegionsResult{Regions: names, Default: "ie"} // no app_default |
| + unread := apiRegionsResult{Regions: names, Default: "ie", AppDefault: &apiAppDefault{Region: "uk", Effective: "uk", RegionKnown: true, Reason: "policy-unreadable"}} |
| + noDomain := apiRegionsResult{Regions: names, Default: "ie", AppDefault: &apiAppDefault{Region: "uk", Effective: "uk", RegionKnown: true, PlacementKnown: true}} |
| + 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" |
| + existingUK := apiInstanceRecord{Host: host, Region: "uk"} |
| + existingHome := apiInstanceRecord{Host: host, Region: ""} |
| + other := apiInstanceRecord{Host: "d.blog.go.tito.io", Region: "uk"} |
| + cases := []struct { |
| + name string |
| + res apiRegionsResult |
| + err error |
| + recs []apiInstanceRecord |
| + readErr error |
| + asked string |
| + want string |
| + }{ |
| + {"no flag", fleet, boom, []apiInstanceRecord{existingUK}, nil, "", ""}, |
| + {"new canary", plain, nil, nil, nil, "uk", ""}, |
| + {"another canary is not this one", plain, nil, []apiInstanceRecord{other}, nil, "ie", ""}, |
| + {"existing, same", plain, nil, []apiInstanceRecord{existingUK}, nil, "uk", ""}, |
| + {"existing home read as its name", plain, nil, []apiInstanceRecord{existingHome}, nil, "ie", ""}, |
| + {"existing, different, named by place", plain, nil, []apiInstanceRecord{existingUK}, nil, "ie", canaryRegionMismatch(host, "P-uk")}, |
| + {"fleet, first run", fleet, nil, nil, nil, "uk", canaryRegionOnFleet("blog", "acme")}, |
| + {"fleet, repeat", fleet, nil, []apiInstanceRecord{existingUK}, nil, "uk", canaryRegionOnFleet("blog", "acme")}, |
| + {"older console, first run", older, nil, nil, nil, "uk", canaryFleetUnknown("blog")}, |
| + {"older console, repeat", older, nil, []apiInstanceRecord{existingUK}, nil, "uk", canaryFleetUnknown("blog")}, |
| + {"read failed", plain, boom, nil, nil, "uk", canaryFleetUnknown("blog")}, |
| + // codex plan-R2 3: a 200 whose placement the console could not read. |
| + {"placement unknown", unread, nil, nil, nil, "uk", canaryFleetUnknown("blog")}, |
| + // codex plan-R3 1: the canary's own record is read, never inferred |
| + // from a list; a failed read or an unknown domain refuses. |
| + {"record read failed", plain, nil, []apiInstanceRecord{existingUK}, boom, "uk", canaryRegionUnconfirmed("c")}, |
| + {"no instance domain", noDomain, nil, nil, nil, "uk", canaryRegionUnconfirmed("c")}, |
| + // codex plan-R4 1, R9a: with the stored region unread, effective is |
| + // empty, so a fleet cannot be seen; the flag is refused either way. |
| + {"region unknown, fleet", regionUnknownFleet, nil, nil, nil, "uk", canaryFleetUnknown("blog")}, |
| + {"region unknown", regionUnknown, nil, nil, nil, "uk", canaryFleetUnknown("blog")}, |
| + } |
| + for _, c := range cases { |
| + get, asked := records(c.readErr, c.recs...) |
| + pre, err := canaryRegionCheck(c.res, c.err, "blog", "c", c.asked, get) |
| + got := "" |
| + if err != nil { |
| + got = err.Error() |
| + } |
| + if got != c.want { |
| + t.Errorf("%s: %q, want %q", c.name, got, c.want) |
| + } |
| + if len(*asked) > 1 || (len(*asked) == 1 && (*asked)[0] != host) { |
| + t.Errorf("%s: read %v, want at most one read of %s", c.name, *asked, host) |
| + } |
| + if c.want == "" && c.asked != "" && pre.Host != host { |
| + t.Errorf("%s: checked %q, want %q", c.name, pre.Host, host) |
| + } |
| + if c.want == "" && c.asked != "" && pre.Found != (len(c.recs) > 0 && c.recs[0].Host == host) { |
| + t.Errorf("%s: found = %v, want the read's own answer", c.name, pre.Found) |
| + } |
| + } |
| +} |
| + |
| +// 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" |
| + listed := []apiInstanceRecord{{Host: host, Channel: ch}} |
| + cases := []struct { |
| + name string |
| + pre canaryPreflight |
| + recs []apiInstanceRecord |
| + want canaryPlan |
| + }{ |
| + {"no --region: the list decides", canaryPreflight{}, listed, canaryPlanReady}, |
| + {"no --region, not listed", canaryPreflight{}, nil, canaryPlanCreate}, |
| + {"read found it, list did not, same channel", canaryPreflight{Host: host, Found: true, Rec: apiInstanceRecord{Host: host, Channel: ch}}, nil, canaryPlanReady}, |
| + {"read found it, list did not, other channel", canaryPreflight{Host: host, Found: true, Rec: apiInstanceRecord{Host: host, Channel: "canary/old"}}, nil, canaryPlanRechannel}, |
| + {"read found none", canaryPreflight{Host: host}, listed, canaryPlanCreate}, |
| + } |
| + for _, c := range cases { |
| + if got := canaryPlanFor(c.pre, c.recs, host, ch); got != c.want { |
| + t.Errorf("%s: %v, want %v", c.name, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +func TestInstanceCreatedLine(t *testing.T) { |
| + r := apiInstanceRecord{Host: "a.go.tito.io", Channel: "stable", RegionName: "P-uk"} |
| + if got := instanceCreatedLine(r, ""); got != "a.go.tito.io ("+instanceCreatedSuffix("stable", "P-uk")+")" { |
| + t.Fatalf("got %q", got) |
| + } |
| + env := environmentSuffix("acme") |
| + if got := instanceCreatedLine(r, env); got != "a.go.tito.io ("+instanceCreatedSuffix("stable", "P-uk")+env+")" { |
| + t.Fatalf("with environment = %q", got) |
| + } |
| + r.RegionName = "" |
| + if got := instanceCreatedLine(r, env); got != "a.go.tito.io (channel stable"+env+")" { |
| + t.Fatalf("an old console's record = %q, want today's line byte for byte", got) |
| + } |
| +} |
diff --git a/instances_cli.go b/instances_cli.go| index 6aaf247a6..87a39de9c 100644 |
| --- a/instances_cli.go |
| +++ b/instances_cli.go |
| @@ -290,7 +290,7 @@ func runInstancesCreate(args []string) { |
| messageFlags := addMutationMessageFlags(fs) |
| app := appFlag(fs, "app the instance belongs to") |
| host := fs.String("host", "", "instance host — one DNS label + \".\" + the app's instance domain") |
| - region := fs.String("region", "", "region code for the record (subject to the manifest's allow-list)") |
| + region := fs.String("region", "", flagRegionInstancesCreate) |
| channel := channelFlag(fs, "release channel the instance follows (default: the manifest's, then this app's other instances, then its pipeline entry)") |
| kind := fs.String("kind", "", "route class: instance (default) | service | static — a static record serves a file tree off the channel pointer and has no process") |
| environment := fs.String("environment", "", "named config bundle this route binds to (default: the app's default bundle) — carlos env environments lists them") |
| @@ -326,14 +326,24 @@ func runInstancesCreate(args []string) { |
| if err != nil { |
| log.Fatalf("instances create: %v", err) |
| } |
| - fmt.Fprintf(stdout, "%s %s (channel %s%s)\n", colorFor(stdout).ok("created instance"), |
| - rec.Host, rec.Channel, environmentSuffix(rec.Environment)) |
| + fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("created instance"), |
| + instanceCreatedLine(rec, environmentSuffix(rec.Environment))) |
| if rec.PrivateStoreBucket != "" { |
| mutedf(stdout, " private storage: bucket %s\n", rec.PrivateStoreBucket) |
| } |
| mutedf(stdout, " the host comes live on the next reconcile pass on its box") |
| } |
| +// instanceCreatedLine is the text after "created instance" or "canary |
| +// host": today's "host (channel X<env>)" byte for byte from an older |
| +// console, C14 when the console names the place. |
| +func instanceCreatedLine(rec apiInstanceRecord, envSuffix string) string { |
| + if rec.RegionName == "" { |
| + return fmt.Sprintf("%s (channel %s%s)", rec.Host, rec.Channel, envSuffix) |
| + } |
| + return fmt.Sprintf("%s (%s%s)", rec.Host, instanceCreatedSuffix(rec.Channel, rec.RegionName), envSuffix) |
| +} |
| + |
| // replicaReqFromFlags builds the create body's replica slice from the CLI's |
| // --role/--replicas pair, or nil when neither flag was given — a nil request |
| // sends neither key, byte-identical to every create before #162 existed. |