carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 29edf1dde60f697a49dea054a79c55570803e991 parent 0bf304f
5 files changed, +464 −8
  • apimode.go +27 −0
  • canary.go +99 −5
  • canary_region_e2e_test.go +190 −0
  • canary_region_test.go +135 −0
  • instances_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.