docs: nearest-region default implementation plan (placement, R8)
25 tasks implementing the approved spec rev 5: box eligibility fields, internal/geo (points, nearest, tz, AWS region table, optional GeoIP), console store/config/candidates/guess/claim/picker/door/regions endpoints/instances tab/drift log, CLI timezone and --region on apps create, deploy and canary, the region service-gate script, docs, and a whole-branch gate. Five copy tasks end at copy-review gates; the branch does not land before all are approved. Phase L stays out (R8).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+5490
−0
docs/superpowers/plans/2026-10-02-nearest-region-default.md+5490 −0
diff --git a/docs/superpowers/plans/2026-10-02-nearest-region-default.md b/docs/superpowers/plans/2026-10-02-nearest-region-default.md| new file mode 100644 |
| index 000000000..08aee2e8a |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-10-02-nearest-region-default.md |
| @@ -0,0 +1,5490 @@ |
| +# Nearest Region by Default: Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Give every new app a region at creation, preselected from a timezone-then-IP guess, and have new instances default to that region, without changing existing apps, older CLIs, or DNS. |
| + |
| +**Architecture:** |
| + |
| +- **Boxes** publish two more facts in their edges file, `serves_shared_pool` |
| + and `accepts_inbound`. These let the console tell which regions can mint an |
| + ordinary instance. |
| +- **A new package, `internal/geo`,** turns timezones and IPs into points and |
| + picks the great-circle nearest eligible region. |
| +- **The console** stores `region` and `region_source` on the `apps` row and |
| + resolves them inside `claimApp`, after it knows whether the claim is an |
| + adoption. It fills an empty instance region from the app's region, before |
| + the existing checks. It adds an `app_default` block to `GET …/regions` and |
| + a POST guess endpoint. |
| +- **The CLI** sends a timezone hint and gains `--region` where the spec says. |
| + |
| +All non-home writes sit behind `CONSOLE_APP_REGIONS=on` together with a named |
| +home. |
| + |
| +**Tech Stack:** |
| + |
| +- Go 1.26 and SQLite (modernc). |
| +- An embedded IANA tzdata `zone.tab` and its links. |
| +- `github.com/oschwald/maxminddb-golang` v1.13.1 for the optional |
| + `CONSOLE_GEOIP_DB`. |
| +- `net/http` handlers and `html/template` console pages. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-10-01-nearest-region-default-design.md` |
| +(rev 5, approved by Paul 2026-10-02; Astra round 5: ready for planning). Read |
| +it beside this plan. Section numbers below (§N) are the spec's. |
| + |
| +## Global Constraints |
| + |
| +- **Scope is placement only (R8).** No DNS writes, no `Steering` stamp, no |
| + converger change, and no `CARLOS_STEERING_ZONE_ID`. Phase L is a separate |
| + spec. |
| +- **Nothing writes a non-home app region** unless `CONSOLE_APP_REGIONS=on` |
| + **and** `CONSOLE_HOME_REGION` is set (§5.6). If the switch is on and the |
| + home is unnamed, log once at startup and behave as off. |
| +- **With the switch off, the door's fill still runs** for rows that already |
| + hold a region (§5.6). |
| +- **`region` is stored in the record vocabulary: `""` is the home** (§5.1). |
| + Normalize with `homeRegionIn` on the way in and `homeRegionOut` on the way |
| + out. |
| +- **`region_source` ∈ {`chosen`, `timezone`, `ip`, `default`, `""`}.** |
| + Nothing branches on it. |
| +- **Never stored, logged, audited, put in a URL or echoed in a response:** a |
| + timezone, an IP, or a coordinate (§6.5). Hints travel only in POST bodies. |
| +- **Every non-null guess passes** |
| + `instanceRegionAllowed(m.Regions, homeRegionIn(code))`. A home fallback that |
| + fails it gives `null` (§6.4). |
| +- **Automatic choices need positive eligibility**: reachable, and |
| + `hibernation_configured`, `serves_shared_pool` and `accepts_inbound` all |
| + explicitly `true` on one box. Explicit requests keep today's gate (§6.1). |
| +- **App-derived defaults never pass on the dormant gate.** An edges read |
| + failure is 503 C28 (§7). |
| +- **Pooled manifests and fleet-placed apps get no instance guess** (§6.1, |
| + codex R4-1). |
| +- **The app-create fallback.** If the app-create resolution cannot read the |
| + edges files, it falls back to `""`/`default` (§6.1). |
| +- **Wire changes are additive.** New request fields are optional, and new |
| + response fields are `omitempty` where absence is meaningful |
| + (AGENTS.md, "Wire-shape changes are additive"). |
| +- **All user-facing text comes from the copy tasks** (Tasks 6, 7, 18, 24 and |
| + the place names in Task 4). Drafts are verbatim from spec §19. **The |
| + branch is not landed until Paul has approved every copy task through the |
| + copy-review skill and the approved text is applied verbatim.** Code tasks |
| + reference the copy identifiers, never literals. |
| +- **No em dashes in any user-facing string**: a full stop and a new sentence |
| + instead. |
| +- **Gate.** `go build ./...`, `go vet ./...`, `gofmt -l .` (must be empty) and |
| + `go test ./... -count=1` are clean before every commit that touches Go |
| + (`make test` runs all of it). |
| +- **Cross-compile gate.** `GOOS=darwin go build .`, |
| + `GOOS=windows go build .` and `GOOS=linux GOARCH=arm64 go build ./...` must |
| + pass. The CLI code added here uses only portable calls and no build tags. |
| +- **Test environment.** |
| + - Use the default Go cache. Inside the sandbox, use `GOCACHE=$TMPDIR/gocache` |
| + (on disk) and `GOFLAGS=-mod=mod`. Never put a Go cache under `/tmp`. |
| + - Unix-socket tests need `TMPDIR=/tmp/g` (`mkdir -p /tmp/g`). |
| + - Console tests need an on-disk `TMPDIR` (for example `$TMPDIR` inside the |
| + sandbox), because an empty `/tmp/.git` breaks Go's VCS stamping. |
| +- **CLI tests that shell out to the carlos binary** build their env through |
| + `carlosTestEnv` and set `cmd.Dir` to a scratch dir (AGENTS.md). |
| +- **Flags are documented.** `docs/site/cli.md` documents every new flag. No |
| + new commands are added, so the docsite heading gate is unaffected. |
| +- **Commits.** End every commit message with |
| + `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. Do not push. |
| + Run `git config` only with `--worktree`. |
| + |
| +## Review Focus |
| + |
| +The five inputs most likely to bite a person that no spec-derived test covers. |
| +Each is pinned by a test in the task named. |
| + |
| +1. **Real-machine timezone text.** An `/etc/timezone` with a trailing newline |
| + or spaces, `TZ=":"`, `TZ="localtime"` and `TZ="posixrules"` must give a |
| + clean name or nothing, never a malformed hint. Pinned in Task 19 and |
| + Task 3. |
| +2. **IPv6 and IPv4-mapped clients.** `::ffff:81.2.69.142` locates exactly as |
| + `81.2.69.142`. Private IPv6 (`fc00::/7`, `fe80::/10`) and loopback `::1` |
| + never reach the reader. Pinned in Task 5. |
| +3. **A region that stops being eligible between rendering the new-app page and |
| + submitting it.** The POST re-renders with C5 and writes no app row. Pinned |
| + in Task 13. |
| +4. **Garbage hints at the guess endpoint.** `"ip":"not-an-ip"` and a |
| + 10 KB `"tz"` are treated as absent: a 200 with whatever the rest yields, |
| + never a 400 or 500. Pinned in Task 15. |
| +5. **A claim that fails after resolution.** A name that is taken or an apex |
| + that is held writes no row, and logs no `app region` line. Pinned in |
| + Task 12. |
| + |
| +--- |
| + |
| +## File map |
| + |
| +| File | Responsibility | Tasks | |
| +| --- | --- | --- | |
| +| `internal/provision/reconcile.go` | `ReconcileConfig.SharedInstanceEligibility()` | 1 | |
| +| `internal/host/edgesfile.go` | publish `serves_shared_pool`, `accepts_inbound` | 1 | |
| +| `internal/geo/point.go`, `nearest.go` | points, haversine, nearest-with-ties | 2 | |
| +| `internal/geo/tz.go`, `data/*` | embedded `zone.tab`, links, version; `ZonePoint`, `CountryPoint` | 3 | |
| +| `scripts/update-tzgeo.sh` | refresh the embedded tz files from IANA | 3 | |
| +| `internal/geo/awsregions.go` | AWS region → point and place name (copy C23) | 4 | |
| +| `internal/geo/mmdb.go` | optional GeoIP `Locator` | 5 | |
| +| `internal/console/regioncopy.go` | console page copy (C1, C2, C3, C5, C7, C8, C21, C24) | 6 | |
| +| `internal/console/regionmsgs.go` | API messages (C16 to C20, C26 to C29) | 7 | |
| +| `internal/console/store.go` | columns, rebuild list, `CreateAppInRegion`, `AppRegion`, `NonHomeAppRegions` | 8 | |
| +| `internal/console/server.go` (Config), `cmd/console/main.go` | `AppRegions`, `GeoIP`, `appRegionsOn` | 9 | |
| +| `internal/console/regioncandidates.go` | strict edges read, candidates, eligibility, policy | 10 | |
| +| `internal/console/regionguess.go` | `guessRegion` and its invariant | 11 | |
| +| `internal/console/regionclaim.go`, `server.go`, `apicli.go` | `regionReq`, explicit validation, resolution in `claimApp`, the API create door | 12 | |
| +| `internal/console/regionpicker.go`, `templates/app_new.html`, `server.go` | the new-app picker, POST and browser guess route | 13 | |
| +| `internal/console/apiinstances.go` | the fill, app-derived eligibility, the replica-overlap refusal, response fields | 14 | |
| +| `internal/console/apiregions.go`, `authcli.go` | `app_default`, names, `POST …/regions/guess` | 15 | |
| +| `templates/app_instances.html`, `server.go` | the Instances tab lines | 16 | |
| +| `internal/console/regiondrift.go`, `cmd/console/main.go` | roll-forward discrepancy log | 17 | |
| +| `region_copy.go` (root) | CLI copy (C9 to C15, C13a, C13b, C22, C25, C30) | 18 | |
| +| `timezone.go` (root) | timezone detection | 19 | |
| +| `apimode.go`, `apps.go`, `deploy.go` | create-app wire, `apps create --region`, onboarding line | 20 | |
| +| `deploy.go`, `apimode.go` | `deploy --region` preflight on an existing app | 21 | |
| +| `canary.go`, `instances_cli.go` | `canary --region`, instance output | 22 | |
| +| `docs/site/cli.md`, `README.md`, `scripts/region-service-gate.sh` | docs, release floor, service-gate script | 23, 24 | |
| + |
| +Task order is dependency order. Tasks 6, 7, 18 and 24 are copy tasks. Each |
| +ends at a copy-review gate. Later code tasks may proceed against the draft |
| +identifiers while review runs, but the branch does not land until every |
| +gate has been approved and applied. |
| + |
| +--- |
| + |
| +### Task 1: Boxes publish shared-pool and inbound eligibility |
| + |
| +**Files:** |
| +- Modify: `internal/provision/reconcile.go` (beside `poolServed`, `:1339`) |
| +- Modify: `internal/host/edgesfile.go:155-206` (struct), `:600-611` (ident in `edgesFileLoop`) |
| +- Test: `internal/provision/reconcile_eligibility_test.go` (create), `internal/host/edgesfile_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `provision.ReconcileConfig{Pools []string; Inbound string}`, |
| + `provision.InboundNone`, `poolServed`, and `provisionTickConfig(getenv) |
| + provision.ReconcileConfig` (`internal/host/agent.go:2030`). |
| +- Produces: |
| + - `func (cfg ReconcileConfig) SharedInstanceEligibility() (sharedPool, inbound bool)`; |
| + - `edgesFile.ServesSharedPool bool` (`json:"serves_shared_pool"`, not |
| + omitempty); |
| + - `edgesFile.AcceptsInbound bool` (`json:"accepts_inbound"`, not |
| + omitempty). |
| + |
| +- [ ] **Step 1: Write the failing provision test** |
| + |
| +```go |
| +// internal/provision/reconcile_eligibility_test.go |
| +package provision |
| + |
| +import "testing" |
| + |
| +// SharedInstanceEligibility must be the reconciler's own predicates, never a |
| +// second reading of the environment (spec §6.1, codex R3-3). |
| +func TestSharedInstanceEligibilityIsTheMintPredicate(t *testing.T) { |
| + cases := []struct { |
| + pools []string |
| + inbound string |
| + shared, accept bool |
| + }{ |
| + {nil, "", true, true}, |
| + {[]string{"tito"}, "", false, true}, |
| + {[]string{"", "tito"}, "", true, true}, |
| + {nil, InboundNone, true, false}, |
| + {[]string{"tito"}, InboundNone, false, false}, |
| + } |
| + for _, c := range cases { |
| + cfg := ReconcileConfig{Pools: c.pools, Inbound: c.inbound} |
| + shared, inbound := cfg.SharedInstanceEligibility() |
| + if shared != poolServed(c.pools, "") || inbound != (c.inbound != InboundNone) { |
| + t.Fatalf("%+v: eligibility diverges from the mint predicates", c) |
| + } |
| + if shared != c.shared || inbound != c.accept { |
| + t.Fatalf("%+v: got (%v, %v), want (%v, %v)", c, shared, inbound, c.shared, c.accept) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run it to verify it fails** |
| + |
| +Run: `go test ./internal/provision -run TestSharedInstanceEligibilityIsTheMintPredicate -count=1` |
| +Expected: FAIL, `cfg.SharedInstanceEligibility undefined`. |
| + |
| +- [ ] **Step 3: Implement it beside `poolServed`** |
| + |
| +```go |
| +// SharedInstanceEligibility answers, from this box's own reconcile config, |
| +// whether it mints an ORDINARY instance record: one in the shared "" |
| +// pool, needing inbound service. The edges file publishes both answers |
| +// (serves_shared_pool, accepts_inbound) so the console never offers an |
| +// automatic choice of a region whose boxes would refuse the record |
| +// (nearest-region spec §6.1). It is the reconciler's own predicates — |
| +// poolServed and the CARLOS_INBOUND=none refusal at reconcile.go:925 — so |
| +// publication and minting cannot disagree. |
| +func (cfg ReconcileConfig) SharedInstanceEligibility() (sharedPool, inbound bool) { |
| + return poolServed(cfg.Pools, ""), cfg.Inbound != InboundNone |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run it to verify it passes** |
| + |
| +Run: `go test ./internal/provision -run TestSharedInstanceEligibilityIsTheMintPredicate -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Write the failing edges-file tests** |
| + |
| +Append to `internal/host/edgesfile_test.go`: |
| + |
| +```go |
| +// The two eligibility fields are NOT omitempty: absent means "a writer |
| +// that predates the field" (unknown), false means "no" (spec §6.1). |
| +func TestEdgesFileRawWireBodyEligibilityFields(t *testing.T) { |
| + for _, want := range []bool{true, false} { |
| + s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t) |
| + seedRoute(t, reg, "app.example.com") |
| + var last []byte |
| + ident := edgesFile{Box: "quiet-harbour", Region: "eu-west-2", Placement: "uk", |
| + ServesSharedPool: want, AcceptsInbound: want} |
| + if err := edgesFilePass(context.Background(), s, reg, ident, "203.0.113.10", &last); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, _, err := s.Get(context.Background(), "control/edges/quiet-harbour.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var raw map[string]any |
| + if err := json.Unmarshal(got, &raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, k := range []string{"serves_shared_pool", "accepts_inbound"} { |
| + v, present := raw[k] |
| + if !present || v != want { |
| + t.Fatalf("%s = %v (present %v), want %v always published", k, v, present, want) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// What the loop publishes is the reconcile config's own answer, for every |
| +// spelling an operator might write. CARLOS_POOLS=",tito" parses to |
| +// ["tito"] (agent.go's parser drops empty elements), so it does NOT serve |
| +// the shared pool and must publish false (codex R3-3). |
| +func TestEdgesFileEligibilityFollowsTheReconcileConfig(t *testing.T) { |
| + cases := []struct { |
| + pools, inbound string |
| + shared, accept bool |
| + }{ |
| + {"", "", true, true}, |
| + {"tito", "", false, true}, |
| + {",tito", "", false, true}, |
| + {"", "none", true, false}, |
| + {"tito", "NONE", false, false}, |
| + } |
| + for _, c := range cases { |
| + getenv := func(k string) string { |
| + switch k { |
| + case "CARLOS_POOLS": |
| + return c.pools |
| + case "CARLOS_INBOUND": |
| + return c.inbound |
| + } |
| + return "" |
| + } |
| + shared, inbound := edgesFileEligibility(getenv) |
| + cfg := provisionTickConfig(getenv) |
| + wantShared, wantInbound := cfg.SharedInstanceEligibility() |
| + if shared != wantShared || inbound != wantInbound { |
| + t.Fatalf("%+v: published (%v, %v) but the reconciler says (%v, %v)", c, shared, inbound, wantShared, wantInbound) |
| + } |
| + if shared != c.shared || inbound != c.accept { |
| + t.Fatalf("%+v: got (%v, %v), want (%v, %v)", c, shared, inbound, c.shared, c.accept) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 6: Run them to verify they fail** |
| + |
| +Run: `TMPDIR=/tmp/g go test ./internal/host -run 'TestEdgesFile(RawWireBodyEligibilityFields|EligibilityFollowsTheReconcileConfig)' -count=1` |
| +Expected: FAIL, unknown fields `ServesSharedPool` and `AcceptsInbound`, and |
| +`edgesFileEligibility` undefined. |
| + |
| +- [ ] **Step 7: Implement the fields, the helper and the loop wiring** |
| + |
| +In `edgesFile`, after `HibernationConfigured`: |
| + |
| +```go |
| + // ServesSharedPool and AcceptsInbound are this box's own answers to |
| + // "would my reconcile pass mint an ORDINARY instance record" — one in |
| + // the shared "" pool, needing inbound service (nearest-region spec |
| + // §6.1). Deliberately NOT omitempty, HibernationConfigured's precedent: |
| + // absent is a writer that predates the field (unknown), false is a box |
| + // that said no. The console needs both positively true before it makes |
| + // an AUTOMATIC choice of this box's region; explicit requests ignore |
| + // them. Computed from the same ReconcileConfig the reconcile pass uses |
| + // (edgesFileEligibility), never a second reading of the environment. |
| + ServesSharedPool bool `json:"serves_shared_pool"` |
| + AcceptsInbound bool `json:"accepts_inbound"` |
| +``` |
| + |
| +Below `edgesFileRegion`: |
| + |
| +```go |
| +// edgesFileEligibility derives the two published eligibility answers from |
| +// the reconcile config this box actually runs with — provisionTickConfig, |
| +// the same parse the provision tick uses — so a spelling the reconciler |
| +// reads one way can never be published another. |
| +func edgesFileEligibility(getenv func(string) string) (sharedPool, inbound bool) { |
| + return provisionTickConfig(getenv).SharedInstanceEligibility() |
| +} |
| +``` |
| + |
| +In `edgesFileLoop`, build the ident with the two new fields: |
| + |
| +```go |
| + sharedPool, inbound := edgesFileEligibility(os.Getenv) |
| + ident := edgesFile{ |
| + Box: box, |
| + Region: region, |
| + Placement: strings.TrimSpace(os.Getenv("CARLOS_REGION")), |
| + DataBucket: strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_BUCKET")), |
| + DataBucketRegion: strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_REGION")), |
| + // Same env, same trim, one line apart, deliberately: this boolean |
| + // must never disagree with the bucket beside it, and the way to |
| + // guarantee that is to derive both from the one read rather than to |
| + // ask two questions that could drift. |
| + HibernationConfigured: strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_BUCKET")) != "", |
| + ServesSharedPool: sharedPool, |
| + AcceptsInbound: inbound, |
| + } |
| +``` |
| + |
| +- [ ] **Step 8: Run the host package to verify it passes** |
| + |
| +Run: `mkdir -p /tmp/g && TMPDIR=/tmp/g go test ./internal/host ./internal/provision -count=1` |
| +Expected: PASS. In particular, `TestEdgesFileRawWireBodyMatchesSchema` still |
| +passes. If it pins the full key set, add the two keys to its expected set, |
| +because the schema really did grow. |
| + |
| +- [ ] **Step 9: Cross-compile, then commit** |
| + |
| +Run: `GOOS=darwin go build . && GOOS=windows go build . && GOOS=linux GOARCH=arm64 go build ./...` |
| +Expected: all succeed, because `internal/host` is linked into the CLI. |
| + |
| +```bash |
| +git add internal/provision/reconcile.go internal/provision/reconcile_eligibility_test.go internal/host/edgesfile.go internal/host/edgesfile_test.go |
| +git commit -m "edges file: publish serves_shared_pool and accepts_inbound (nearest-region §6.1) |
| + |
| +Computed from the reconcile config the box mints with, so an automatic |
| +region choice can never land on a box that refuses ordinary instances. |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: `internal/geo` points, distance and the nearest pick |
| + |
| +**Files:** |
| +- Create: `internal/geo/point.go`, `internal/geo/nearest.go` |
| +- Test: `internal/geo/nearest_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `LookupAWSRegion` from Task 4. To keep this task testable on its |
| + own, `Nearest` takes a `pointOf func(awsRegion string) (Point, bool)` |
| + parameter, and Task 4 adds the exported wrapper. |
| +- Produces: |
| + - `type Point struct{ Lat, Lon float64 }`; |
| + - `func DistanceKm(a, b Point) float64`; |
| + - `type Candidate struct{ Code string; Home bool; AWSRegion string }`; |
| + - `func NearestWith(p Point, cands []Candidate, pointOf func(string) (Point, bool)) (code string, ok bool)`. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +// internal/geo/nearest_test.go |
| +package geo |
| + |
| +import ( |
| + "math" |
| + "testing" |
| +) |
| + |
| +var testPoints = map[string]Point{ |
| + "eu-west-1": {53.35, -6.26}, |
| + "eu-west-2": {51.51, -0.13}, |
| + "ap-southeast-2": {-33.87, 151.21}, |
| +} |
| + |
| +func testPointOf(r string) (Point, bool) { p, ok := testPoints[r]; return p, ok } |
| + |
| +func TestDistanceKmDublinLondon(t *testing.T) { |
| + d := DistanceKm(testPoints["eu-west-1"], testPoints["eu-west-2"]) |
| + if math.Abs(d-464) > 5 { |
| + t.Fatalf("Dublin to London = %.1f km, want about 464", d) |
| + } |
| +} |
| + |
| +func TestNearest(t *testing.T) { |
| + london := Point{51.5, -0.12} |
| + sydney := Point{-33.9, 151.2} |
| + home := Candidate{Code: "", Home: true, AWSRegion: "eu-west-1"} |
| + uk := Candidate{Code: "uk", AWSRegion: "eu-west-2"} |
| + au := Candidate{Code: "au", AWSRegion: "ap-southeast-2"} |
| + |
| + cases := []struct { |
| + name string |
| + p Point |
| + cands []Candidate |
| + want string |
| + wantOK bool |
| + }{ |
| + {"single", london, []Candidate{home}, "", true}, |
| + {"clear winner", london, []Candidate{home, uk, au}, "uk", true}, |
| + {"far side", sydney, []Candidate{home, uk, au}, "au", true}, |
| + {"home with no point gives no answer", london, |
| + []Candidate{{Code: "", Home: true, AWSRegion: ""}, uk}, "", false}, |
| + {"non-home with no point is left out", london, |
| + []Candidate{home, {Code: "zz", AWSRegion: "xx-nowhere-1"}}, "", true}, |
| + {"empty", london, nil, "", false}, |
| + {"tie goes to the home", testPoints["eu-west-2"], |
| + []Candidate{{Code: "uk2", AWSRegion: "eu-west-2"}, {Code: "", Home: true, AWSRegion: "eu-west-2"}}, "", true}, |
| + {"non-home tie goes to the smaller code", testPoints["eu-west-2"], |
| + []Candidate{{Code: "uk2", AWSRegion: "eu-west-2"}, {Code: "uk", AWSRegion: "eu-west-2"}}, "uk", true}, |
| + } |
| + for _, c := range cases { |
| + got, ok := NearestWith(c.p, c.cands, testPointOf) |
| + if got != c.want || ok != c.wantOK { |
| + t.Errorf("%s: got (%q, %v), want (%q, %v)", c.name, got, ok, c.want, c.wantOK) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run them to verify they fail** |
| + |
| +Run: `go test ./internal/geo -count=1` |
| +Expected: FAIL, because the package does not exist. |
| + |
| +- [ ] **Step 3: Implement `point.go`** |
| + |
| +```go |
| +// Package geo turns a timezone or an IP into a point on the globe and picks |
| +// the nearest of a set of AWS regions (nearest-region spec §6). It holds |
| +// points only for the length of one call: nothing here stores, logs or |
| +// returns a caller's coordinates. |
| +package geo |
| + |
| +import "math" |
| + |
| +// Point is a latitude and longitude in degrees. |
| +type Point struct{ Lat, Lon float64 } |
| + |
| +// earthRadiusKm is the IUGG mean Earth radius. |
| +const earthRadiusKm = 6371.0088 |
| + |
| +// DistanceKm is the great-circle (haversine) distance between a and b. |
| +func DistanceKm(a, b Point) float64 { |
| + rad := func(d float64) float64 { return d * math.Pi / 180 } |
| + dLat := rad(b.Lat - a.Lat) |
| + dLon := rad(b.Lon - a.Lon) |
| + h := math.Sin(dLat/2)*math.Sin(dLat/2) + |
| + math.Cos(rad(a.Lat))*math.Cos(rad(b.Lat))*math.Sin(dLon/2)*math.Sin(dLon/2) |
| + return 2 * earthRadiusKm * math.Asin(math.Sqrt(h)) |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Implement `nearest.go`** |
| + |
| +```go |
| +package geo |
| + |
| +import "math" |
| + |
| +// Candidate is one region a guess may pick. Code is in the record |
| +// vocabulary ("" is the deployment's home); the caller has already |
| +// filtered to eligible, policy-allowed candidates. |
| +type Candidate struct { |
| + Code string |
| + Home bool |
| + AWSRegion string |
| +} |
| + |
| +// NearestWith returns the candidate nearest p, measured to each |
| +// candidate's AWS region through pointOf. |
| +// |
| +// - A candidate whose region has no point is left out. |
| +// - If the HOME is among cands and has no point, ok is false: picking |
| +// among the rest could choose a far region over an unmeasured near |
| +// home, and the home is where today's behaviour lives (spec §6.3). |
| +// - Distances are compared in whole kilometres. A tie goes to the home, |
| +// then to the lexically smallest code, so every console answers alike. |
| +func NearestWith(p Point, cands []Candidate, pointOf func(string) (Point, bool)) (code string, ok bool) { |
| + best := -1 |
| + bestKm := math.MaxInt |
| + for i, c := range cands { |
| + cp, has := pointOf(c.AWSRegion) |
| + if !has { |
| + if c.Home { |
| + return "", false |
| + } |
| + continue |
| + } |
| + km := int(math.Round(DistanceKm(p, cp))) |
| + switch { |
| + case best < 0, km < bestKm: |
| + case km == bestKm && better(c, cands[best]): |
| + default: |
| + continue |
| + } |
| + best, bestKm = i, km |
| + } |
| + if best < 0 { |
| + return "", false |
| + } |
| + return cands[best].Code, true |
| +} |
| + |
| +// better breaks an exact tie: the home first, then the smaller code. |
| +func better(a, b Candidate) bool { |
| + if a.Home != b.Home { |
| + return a.Home |
| + } |
| + return a.Code < b.Code |
| +} |
| +``` |
| + |
| +- [ ] **Step 5: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/geo -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add internal/geo/point.go internal/geo/nearest.go internal/geo/nearest_test.go |
| +git commit -m "geo: haversine and the nearest-region pick with deterministic ties (nearest-region §6.3) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 3: `internal/geo` timezones and country points |
| + |
| +**Files:** |
| +- Create: `internal/geo/data/zone.tab`, `internal/geo/data/links.txt`, `internal/geo/data/VERSION` |
| +- Create: `internal/geo/tz.go`, `scripts/update-tzgeo.sh` |
| +- Test: `internal/geo/tz_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `Point` from Task 2. |
| +- Produces: |
| + - `func ZonePoint(name string) (Point, bool)`; |
| + - `func CountryPoint(iso2 string) (Point, bool)`; |
| + - `func TZDataVersion() string`. |
| + |
| +- [ ] **Step 1: Write the refresh script** |
| + |
| +```bash |
| +#!/usr/bin/env bash |
| +# scripts/update-tzgeo.sh <version>, e.g. 2026b |
| +# |
| +# Refreshes internal/geo/data from one IANA tzdata release |
| +# (nearest-region spec §6.2). zone.tab carries the coordinates; the Link |
| +# lines resolve the old names browsers still report (Asia/Calcutta). Run it |
| +# as a release-checklist step beside the Go toolchain bump; a stale table |
| +# only misses zone names newer than it, and those fall through to IP. |
| +set -euo pipefail |
| +v="${1:?usage: update-tzgeo.sh <tzdata version, e.g. 2026b>}" |
| +here="$(cd "$(dirname "$0")/.." && pwd)" |
| +out="$here/internal/geo/data" |
| +work="$(mktemp -d "${TMPDIR:-/tmp}/tzgeo.XXXXXX")" |
| +trap 'rm -rf "$work"' EXIT |
| +base="https://data.iana.org/time-zones/releases" |
| +curl -fsSL -o "$work/tzdata.tar.gz" "$base/tzdata$v.tar.gz" |
| +curl -fsSL -o "$work/tzdata.tar.gz.asc" "$base/tzdata$v.tar.gz.asc" |
| +if command -v gpg >/dev/null; then |
| + gpg --verify "$work/tzdata.tar.gz.asc" "$work/tzdata.tar.gz" |
| +else |
| + echo "gpg not found: signature NOT checked" >&2 |
| + exit 1 |
| +fi |
| +tar -xzf "$work/tzdata.tar.gz" -C "$work" |
| +mkdir -p "$out" |
| +cp "$work/zone.tab" "$out/zone.tab" |
| +awk '$1 == "Link" { print $2, $3 }' \ |
| + "$work"/africa "$work"/antarctica "$work"/asia "$work"/australasia \ |
| + "$work"/backward "$work"/etcetera "$work"/europe "$work"/northamerica \ |
| + "$work"/southamerica | sort -u > "$out/links.txt" |
| +printf '%s\n' "$v" > "$out/VERSION" |
| +echo "internal/geo/data refreshed to tzdata $v" |
| +``` |
| + |
| +`chmod +x scripts/update-tzgeo.sh`. |
| + |
| +- [ ] **Step 2: Generate the data** |
| + |
| +Run: `scripts/update-tzgeo.sh 2026b`. This needs network access to |
| +`data.iana.org` and `gpg` with the IANA key; outside the sandbox, use |
| +`dangerouslyDisableSandbox` for this one command. |
| + |
| +Expected: three files in `internal/geo/data/`. `links.txt` has lines like |
| +`Asia/Kolkata Asia/Calcutta`, and `VERSION` contains `2026b`. |
| + |
| +- [ ] **Step 3: Write the failing tests** |
| + |
| +```go |
| +// internal/geo/tz_test.go |
| +package geo |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestZonePoint(t *testing.T) { |
| + mapped := []string{ |
| + "Europe/London", "Europe/Dublin", "Europe/Amsterdam", |
| + "Asia/Kolkata", "Asia/Calcutta", |
| + "America/Argentina/Buenos_Aires", "europe/london", |
| + } |
| + for _, z := range mapped { |
| + if _, ok := ZonePoint(z); !ok { |
| + t.Errorf("ZonePoint(%q) unmapped, want a point", z) |
| + } |
| + } |
| + a, _ := ZonePoint("Asia/Calcutta") |
| + b, _ := ZonePoint("Asia/Kolkata") |
| + if a != b { |
| + t.Errorf("Asia/Calcutta %v != Asia/Kolkata %v; the link must resolve to one point", a, b) |
| + } |
| + unmapped := []string{ |
| + "", "UTC", "Etc/UTC", "Etc/GMT+5", "GMT", "UCT", "Universal", "Zulu", |
| + "Factory", "Local", "localtime", "posixrules", "../etc/passwd", |
| + "Europe/London/Extra/Deep", strings.Repeat("A", 65), "Nowhere/Atlantis", |
| + } |
| + for _, z := range unmapped { |
| + if p, ok := ZonePoint(z); ok { |
| + t.Errorf("ZonePoint(%q) = %v, want unmappable", z, p) |
| + } |
| + } |
| + l, _ := ZonePoint("Europe/London") |
| + if l.Lat < 51 || l.Lat > 52 || l.Lon > 0.5 || l.Lon < -0.5 { |
| + t.Errorf("Europe/London = %v, want about 51.5, -0.12", l) |
| + } |
| +} |
| + |
| +// A link cycle must terminate (at most 4 hops) rather than hang. |
| +func TestZonePointLinkLoopTerminates(t *testing.T) { |
| + links := map[string]string{"a/x": "a/y", "a/y": "a/x"} |
| + if _, ok := resolveLinks("a/x", links, map[string]Point{}); ok { |
| + t.Fatal("a link loop resolved to a point") |
| + } |
| +} |
| + |
| +// Every country with more than one zone.tab row needs an override naming |
| +// its principal zone: a tzdata refresh that gives a country a second row |
| +// fails here instead of silently averaging a continent (spec §6.2). |
| +func TestCountryOverridesCoverEveryMultiZoneCountry(t *testing.T) { |
| + for cc, n := range zoneRowsPerCountry() { |
| + if n < 2 { |
| + continue |
| + } |
| + zone, ok := countryOverride[cc] |
| + if !ok { |
| + t.Errorf("country %s has %d zone.tab rows and no override", cc, n) |
| + continue |
| + } |
| + if _, ok := ZonePoint(zone); !ok { |
| + t.Errorf("override for %s names %q, which is not in zone.tab", cc, zone) |
| + } |
| + } |
| + for cc := range countryOverride { |
| + if zoneRowsPerCountry()[cc] < 2 { |
| + t.Errorf("override for %s is unused: it has fewer than two rows", cc) |
| + } |
| + } |
| +} |
| + |
| +func TestCountryPoint(t *testing.T) { |
| + for _, cc := range []string{"GB", "IE", "US", "AU", "IN", "gb"} { |
| + if _, ok := CountryPoint(cc); !ok { |
| + t.Errorf("CountryPoint(%q) unmapped", cc) |
| + } |
| + } |
| + us, _ := CountryPoint("US") |
| + ny, _ := ZonePoint("America/New_York") |
| + if us != ny { |
| + t.Errorf("US = %v, want its principal zone New York %v", us, ny) |
| + } |
| + if _, ok := CountryPoint("ZZ"); ok { |
| + t.Error("CountryPoint(ZZ) mapped") |
| + } |
| +} |
| + |
| +func TestTZDataVersion(t *testing.T) { |
| + if v := TZDataVersion(); v == "" || strings.ContainsAny(v, " \n") { |
| + t.Fatalf("TZDataVersion = %q", v) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run them to verify they fail** |
| + |
| +Run: `go test ./internal/geo -run 'TestZonePoint|TestCountry|TestTZDataVersion' -count=1` |
| +Expected: FAIL, undefined `ZonePoint`. |
| + |
| +- [ ] **Step 5: Implement `tz.go`** |
| + |
| +```go |
| +package geo |
| + |
| +import ( |
| + _ "embed" |
| + "regexp" |
| + "strconv" |
| + "strings" |
| + "sync" |
| +) |
| + |
| +//go:embed data/zone.tab |
| +var zoneTab string |
| + |
| +//go:embed data/links.txt |
| +var linksTxt string |
| + |
| +//go:embed data/VERSION |
| +var tzVersion string |
| + |
| +// TZDataVersion is the IANA release the embedded tables came from. |
| +func TZDataVersion() string { return strings.TrimSpace(tzVersion) } |
| + |
| +var ( |
| + tzOnce sync.Once |
| + zonePoints map[string]Point // lowercased zone name -> point |
| + zoneLinks map[string]string |
| + countryOne map[string]Point // countries with exactly one row |
| + rowsPerCC map[string]int |
| +) |
| + |
| +// zoneShape is spec §6.2 step 1: at most 64 bytes, one to three |
| +// "/"-separated segments of [A-Za-z0-9_+-]. |
| +var zoneShape = regexp.MustCompile(`^[A-Za-z0-9_+\-]+(/[A-Za-z0-9_+\-]+){0,2}$`) |
| + |
| +// notAPlace names an offset or a machine, not a place (R3). |
| +var notAPlace = map[string]bool{ |
| + "": true, "local": true, "localtime": true, "posixrules": true, |
| + "utc": true, "uct": true, "gmt": true, "universal": true, "zulu": true, |
| + "factory": true, |
| +} |
| + |
| +func loadTZ() { |
| + zonePoints = map[string]Point{} |
| + countryOne = map[string]Point{} |
| + rowsPerCC = map[string]int{} |
| + for _, line := range strings.Split(zoneTab, "\n") { |
| + if line == "" || strings.HasPrefix(line, "#") { |
| + continue |
| + } |
| + f := strings.Split(line, "\t") |
| + if len(f) < 3 { |
| + continue |
| + } |
| + p, ok := parseISO6709(f[1]) |
| + if !ok { |
| + continue |
| + } |
| + zonePoints[strings.ToLower(f[2])] = p |
| + cc := strings.ToUpper(f[0]) |
| + rowsPerCC[cc]++ |
| + countryOne[cc] = p |
| + } |
| + zoneLinks = map[string]string{} |
| + for _, line := range strings.Split(linksTxt, "\n") { |
| + f := strings.Fields(line) |
| + if len(f) == 2 { |
| + zoneLinks[strings.ToLower(f[1])] = strings.ToLower(f[0]) |
| + } |
| + } |
| +} |
| + |
| +// ZonePoint maps an IANA zone name to its principal location, following |
| +// tzdata links (at most 4 hops). It refuses anything that is not a place. |
| +func ZonePoint(name string) (Point, bool) { |
| + tzOnce.Do(loadTZ) |
| + name = strings.TrimSpace(name) |
| + if len(name) > 64 || !zoneShape.MatchString(name) { |
| + return Point{}, false |
| + } |
| + low := strings.ToLower(name) |
| + if notAPlace[low] || strings.HasPrefix(low, "etc/") { |
| + return Point{}, false |
| + } |
| + return resolveLinks(low, zoneLinks, zonePoints) |
| +} |
| + |
| +func resolveLinks(name string, links map[string]string, points map[string]Point) (Point, bool) { |
| + for hop := 0; hop <= 4; hop++ { |
| + if p, ok := points[name]; ok { |
| + return p, true |
| + } |
| + next, ok := links[name] |
| + if !ok { |
| + return Point{}, false |
| + } |
| + name = next |
| + } |
| + return Point{}, false |
| +} |
| + |
| +// countryOverride names the principal zone of every country with more |
| +// than one zone.tab row (tzdata 2026b). TestCountryOverridesCoverEvery |
| +// MultiZoneCountry fails when a refresh adds a multi-row country. |
| +var countryOverride = map[string]string{ |
| + "AQ": "Antarctica/McMurdo", "AR": "America/Argentina/Buenos_Aires", |
| + "AU": "Australia/Sydney", "BR": "America/Sao_Paulo", "CA": "America/Toronto", |
| + "CD": "Africa/Kinshasa", "CL": "America/Santiago", "CN": "Asia/Shanghai", |
| + "CY": "Asia/Nicosia", "DE": "Europe/Berlin", "EC": "America/Guayaquil", |
| + "ES": "Europe/Madrid", "FM": "Pacific/Pohnpei", "GL": "America/Nuuk", |
| + "ID": "Asia/Jakarta", "KI": "Pacific/Tarawa", "KZ": "Asia/Almaty", |
| + "MH": "Pacific/Majuro", "MN": "Asia/Ulaanbaatar", "MX": "America/Mexico_City", |
| + "MY": "Asia/Kuala_Lumpur", "NZ": "Pacific/Auckland", "PF": "Pacific/Tahiti", |
| + "PG": "Pacific/Port_Moresby", "PS": "Asia/Gaza", "PT": "Europe/Lisbon", |
| + "RU": "Europe/Moscow", "UA": "Europe/Kyiv", "UM": "Pacific/Wake", |
| + "US": "America/New_York", "UZ": "Asia/Tashkent", |
| +} |
| + |
| +// CountryPoint maps an ISO 3166-1 alpha-2 code to a point: its only |
| +// zone.tab row, or its override's zone. |
| +func CountryPoint(iso2 string) (Point, bool) { |
| + tzOnce.Do(loadTZ) |
| + cc := strings.ToUpper(strings.TrimSpace(iso2)) |
| + if zone, ok := countryOverride[cc]; ok { |
| + return ZonePoint(zone) |
| + } |
| + if rowsPerCC[cc] == 1 { |
| + return countryOne[cc], true |
| + } |
| + return Point{}, false |
| +} |
| + |
| +func zoneRowsPerCountry() map[string]int { tzOnce.Do(loadTZ); return rowsPerCC } |
| + |
| +// parseISO6709 reads zone.tab's ±DDMM±DDDMM or ±DDMMSS±DDDMMSS. |
| +func parseISO6709(s string) (Point, bool) { |
| + i := strings.IndexAny(s[1:], "+-") + 1 |
| + if i <= 0 { |
| + return Point{}, false |
| + } |
| + lat, ok1 := dms(s[:i], 2) |
| + lon, ok2 := dms(s[i:], 3) |
| + return Point{lat, lon}, ok1 && ok2 |
| +} |
| + |
| +func dms(s string, degDigits int) (float64, bool) { |
| + sign := 1.0 |
| + if s[0] == '-' { |
| + sign = -1 |
| + } |
| + d := s[1:] |
| + if len(d) != degDigits+2 && len(d) != degDigits+4 { |
| + return 0, false |
| + } |
| + deg, err1 := strconv.Atoi(d[:degDigits]) |
| + min, err2 := strconv.Atoi(d[degDigits : degDigits+2]) |
| + sec := 0 |
| + var err3 error |
| + if len(d) == degDigits+4 { |
| + sec, err3 = strconv.Atoi(d[degDigits+2:]) |
| + } |
| + if err1 != nil || err2 != nil || err3 != nil { |
| + return 0, false |
| + } |
| + return sign * (float64(deg) + float64(min)/60 + float64(sec)/3600), true |
| +} |
| +``` |
| + |
| +- [ ] **Step 6: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/geo -count=1` |
| +Expected: PASS. If `TestCountryOverridesCoverEveryMultiZoneCountry` names a |
| +country, the downloaded tzdata differs from 2026b. Add or remove that |
| +override using the principal zone, which is the country's capital or largest |
| +city. |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +git add internal/geo/tz.go internal/geo/tz_test.go internal/geo/data scripts/update-tzgeo.sh |
| +git commit -m "geo: embedded IANA zone.tab and links, zone and country points (nearest-region §6.2) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 4: AWS region table and place names (copy C23, copy-review gate) |
| + |
| +**Files:** |
| +- Create: `internal/geo/awsregions.go` |
| +- Test: `internal/geo/awsregions_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `Point`, `NearestWith` from Task 2. |
| +- Produces: |
| + - `type AWSRegion struct{ Point; Place string }`; |
| + - `func LookupAWSRegion(code string) (AWSRegion, bool)`; |
| + - `func Nearest(p Point, cands []Candidate) (string, bool)`, which is |
| + `NearestWith` over this table; |
| + - `func PlaceName(awsRegion, fallback string) string`. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +// internal/geo/awsregions_test.go |
| +package geo |
| + |
| +import ( |
| + "regexp" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestAWSRegionTable(t *testing.T) { |
| + shape := regexp.MustCompile(`^[a-z]{2}(-[a-z]+)+-\d$`) |
| + for code, r := range awsRegions { |
| + if !shape.MatchString(code) { |
| + t.Errorf("%s is not an AWS region code", code) |
| + } |
| + if r.Lat < -90 || r.Lat > 90 || r.Lon < -180 || r.Lon > 180 { |
| + t.Errorf("%s: %v out of range", code, r.Point) |
| + } |
| + if r.Place == "" || strings.Contains(r.Place, "—") { |
| + t.Errorf("%s: place %q empty or contains an em dash", code, r.Place) |
| + } |
| + } |
| + if len(awsRegions) != 34 { |
| + t.Errorf("%d regions, want the 34 of spec C23", len(awsRegions)) |
| + } |
| + if r, _ := LookupAWSRegion("eu-west-2"); r.Place != "UK · London" { |
| + t.Errorf("eu-west-2 place = %q", r.Place) |
| + } |
| + if PlaceName("xx-nowhere-1", "zz") != "zz" || PlaceName("", "ie") != "ie" { |
| + t.Error("PlaceName must fall back to the given code") |
| + } |
| +} |
| + |
| +func TestNearestUsesTheTable(t *testing.T) { |
| + london, _ := ZonePoint("Europe/London") |
| + got, ok := Nearest(london, []Candidate{ |
| + {Code: "", Home: true, AWSRegion: "eu-west-1"}, |
| + {Code: "uk", AWSRegion: "eu-west-2"}, |
| + }) |
| + if !ok || got != "uk" { |
| + t.Fatalf("Nearest from London = (%q, %v), want uk", got, ok) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run them to verify they fail** |
| + |
| +Run: `go test ./internal/geo -run 'TestAWSRegionTable|TestNearestUsesTheTable' -count=1` |
| +Expected: FAIL, undefined `awsRegions`. |
| + |
| +- [ ] **Step 3: Implement the table, with place names verbatim from spec C23** |
| + |
| +```go |
| +package geo |
| + |
| +// AWSRegion is one AWS commercial region's metro-area point and the place |
| +// name every surface shows for it (spec §14, copy C23: pending Paul's copy |
| +// review — do not change a Place without it). |
| +// |
| +// Source: AWS's official region list |
| +// (https://docs.aws.amazon.com/global-infrastructure/latest/regions/aws-regions.html) |
| +// as of 2026-10-01. Coordinates are each region's metro area, checked by |
| +// hand; AWS publishes no facility locations, and tens of kilometres never |
| +// change a choice between regions. |
| +type AWSRegion struct { |
| + Point |
| + Place string |
| +} |
| + |
| +var awsRegions = map[string]AWSRegion{ |
| + "us-east-1": {Point{39.04, -77.49}, "US East · N. Virginia"}, |
| + "us-east-2": {Point{39.96, -83.00}, "US East · Ohio"}, |
| + "us-west-1": {Point{37.77, -122.42}, "US West · N. California"}, |
| + "us-west-2": {Point{45.84, -119.70}, "US West · Oregon"}, |
| + "ca-central-1": {Point{45.50, -73.57}, "Canada · Montréal"}, |
| + "ca-west-1": {Point{51.05, -114.07}, "Canada · Calgary"}, |
| + "mx-central-1": {Point{20.59, -100.39}, "Mexico · Querétaro"}, |
| + "sa-east-1": {Point{-23.55, -46.63}, "Brazil · São Paulo"}, |
| + "eu-west-1": {Point{53.35, -6.26}, "Ireland · Dublin"}, |
| + "eu-west-2": {Point{51.51, -0.13}, "UK · London"}, |
| + "eu-west-3": {Point{48.86, 2.35}, "France · Paris"}, |
| + "eu-central-1": {Point{50.11, 8.68}, "Germany · Frankfurt"}, |
| + "eu-central-2": {Point{47.37, 8.54}, "Switzerland · Zurich"}, |
| + "eu-south-1": {Point{45.46, 9.19}, "Italy · Milan"}, |
| + "eu-south-2": {Point{41.65, -0.89}, "Spain · Aragón"}, |
| + "eu-north-1": {Point{59.33, 18.07}, "Sweden · Stockholm"}, |
| + "il-central-1": {Point{32.09, 34.78}, "Israel · Tel Aviv"}, |
| + "me-south-1": {Point{26.23, 50.59}, "Bahrain"}, |
| + "me-central-1": {Point{25.20, 55.27}, "UAE"}, |
| + "af-south-1": {Point{-33.92, 18.42}, "South Africa · Cape Town"}, |
| + "ap-south-1": {Point{19.08, 72.88}, "India · Mumbai"}, |
| + "ap-south-2": {Point{17.39, 78.49}, "India · Hyderabad"}, |
| + "ap-east-1": {Point{22.32, 114.17}, "Hong Kong"}, |
| + "ap-east-2": {Point{25.03, 121.57}, "Taiwan · Taipei"}, |
| + "ap-southeast-1": {Point{1.35, 103.82}, "Singapore"}, |
| + "ap-southeast-2": {Point{-33.87, 151.21}, "Australia · Sydney"}, |
| + "ap-southeast-3": {Point{-6.21, 106.85}, "Indonesia · Jakarta"}, |
| + "ap-southeast-4": {Point{-37.81, 144.96}, "Australia · Melbourne"}, |
| + "ap-southeast-5": {Point{3.14, 101.69}, "Malaysia"}, |
| + "ap-southeast-6": {Point{-36.85, 174.76}, "New Zealand · Auckland"}, |
| + "ap-southeast-7": {Point{13.76, 100.50}, "Thailand · Bangkok"}, |
| + "ap-northeast-1": {Point{35.68, 139.69}, "Japan · Tokyo"}, |
| + "ap-northeast-2": {Point{37.57, 126.98}, "South Korea · Seoul"}, |
| + "ap-northeast-3": {Point{34.69, 135.50}, "Japan · Osaka"}, |
| +} |
| + |
| +// LookupAWSRegion finds a region in the table. |
| +func LookupAWSRegion(code string) (AWSRegion, bool) { |
| + r, ok := awsRegions[code] |
| + return r, ok |
| +} |
| + |
| +func tablePoint(code string) (Point, bool) { |
| + r, ok := awsRegions[code] |
| + return r.Point, ok |
| +} |
| + |
| +// Nearest is NearestWith over the shipped table. |
| +func Nearest(p Point, cands []Candidate) (string, bool) { |
| + return NearestWith(p, cands, tablePoint) |
| +} |
| + |
| +// PlaceName is the table's place for awsRegion, or fallback (the bare |
| +// placement code) for a region the table does not know. |
| +func PlaceName(awsRegion, fallback string) string { |
| + if r, ok := awsRegions[awsRegion]; ok { |
| + return r.Place |
| + } |
| + return fallback |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/geo -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Check the coordinates by hand** |
| + |
| +For each row, confirm that the coordinates fall within the named metro area |
| +(an atlas or any map is enough). Confirm that the 34 codes match AWS's |
| +current list. Correct any row that does not, and re-run step 4. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add internal/geo/awsregions.go internal/geo/awsregions_test.go |
| +git commit -m "geo: AWS region table with place names (draft copy C23, pending review) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +- [ ] **Step 7: Copy-review gate (C23)** |
| + |
| +Invoke the `copy-review` skill with the 34 `Place` strings and the context |
| +"region picker option labels and every surface that names a region". Wait |
| +for Paul's edits. Apply them verbatim to `awsregions.go`, re-run step 4 |
| +(adjust the `eu-west-2` assertion if its text changed), and commit as |
| +`geo: place names as approved in copy review`. **The branch does not land |
| +before this gate closes.** |
| + |
| +--- |
| + |
| +### Task 5: Optional GeoIP locator |
| + |
| +**Files:** |
| +- Create: `internal/geo/mmdb.go` |
| +- Create: `internal/geo/testdata/GeoIP2-City-Test.mmdb`, `internal/geo/testdata/GeoIP2-Country-Test.mmdb`, `internal/geo/testdata/README` |
| +- Modify: `go.mod`, `go.sum` |
| +- Test: `internal/geo/mmdb_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `Point`, `CountryPoint` from Task 3. |
| +- Produces: |
| + - `type Locator interface{ Locate(ip net.IP) (Point, bool) }`; |
| + - `func OpenMMDB(path string) (*MMDB, error)`; |
| + - `func (m *MMDB) Locate(ip net.IP) (Point, bool)`; |
| + - `func (m *MMDB) Close() error`; |
| + - `func Locatable(ip net.IP) bool`. |
| + |
| +- [ ] **Step 1: Add the dependency and the fixtures (network)** |
| + |
| +Run, with network (outside the sandbox if needed): |
| + |
| +```bash |
| +go get github.com/oschwald/maxminddb-golang@v1.13.1 |
| +mkdir -p internal/geo/testdata |
| +base=https://raw.githubusercontent.com/maxmind/MaxMind-DB/main/test-data |
| +curl -fsSL -o internal/geo/testdata/GeoIP2-City-Test.mmdb "$base/GeoIP2-City-Test.mmdb" |
| +curl -fsSL -o internal/geo/testdata/GeoIP2-Country-Test.mmdb "$base/GeoIP2-Country-Test.mmdb" |
| +git -C . ls-remote https://github.com/maxmind/MaxMind-DB HEAD |
| +``` |
| + |
| +Write `internal/geo/testdata/README`: |
| + |
| +``` |
| +GeoIP2-City-Test.mmdb and GeoIP2-Country-Test.mmdb are MaxMind's published |
| +test databases from github.com/maxmind/MaxMind-DB (test-data/), dual |
| +licensed MIT or Apache-2.0. Pinned at commit <the HEAD sha printed above>. |
| +They contain test records only, not real geolocation data. |
| +``` |
| + |
| +Replace `<the HEAD sha printed above>` with the actual sha before committing. |
| + |
| +- [ ] **Step 2: Write the failing tests** |
| + |
| +```go |
| +// internal/geo/mmdb_test.go |
| +package geo |
| + |
| +import ( |
| + "net" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +func TestOpenMMDBRefusesNonDatabases(t *testing.T) { |
| + dir := t.TempDir() |
| + missing := filepath.Join(dir, "missing.mmdb") |
| + junk := filepath.Join(dir, "junk.mmdb") |
| + os.WriteFile(junk, []byte("not a maxmind database"), 0o600) |
| + city, _ := os.ReadFile("testdata/GeoIP2-City-Test.mmdb") |
| + truncated := filepath.Join(dir, "truncated.mmdb") |
| + os.WriteFile(truncated, city[:len(city)/3], 0o600) |
| + for _, p := range []string{missing, junk, truncated} { |
| + if m, err := OpenMMDB(p); err == nil { |
| + m.Close() |
| + t.Errorf("OpenMMDB(%s) succeeded, want an error", filepath.Base(p)) |
| + } |
| + } |
| +} |
| + |
| +func TestMMDBCityRecordGivesItsLocation(t *testing.T) { |
| + m, err := OpenMMDB("testdata/GeoIP2-City-Test.mmdb") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer m.Close() |
| + p, ok := m.Locate(net.ParseIP("81.2.69.142")) |
| + if !ok || p.Lat < 51 || p.Lat > 52 { |
| + t.Fatalf("81.2.69.142 = (%v, %v), want a London point", p, ok) |
| + } |
| + // Review Focus 2: an IPv4-mapped IPv6 address is the same client. |
| + mapped, ok2 := m.Locate(net.ParseIP("::ffff:81.2.69.142")) |
| + if !ok2 || mapped != p { |
| + t.Fatalf("::ffff:81.2.69.142 = (%v, %v), want %v", mapped, ok2, p) |
| + } |
| +} |
| + |
| +func TestMMDBCountryOnlyRecordGivesTheCountryPoint(t *testing.T) { |
| + m, err := OpenMMDB("testdata/GeoIP2-Country-Test.mmdb") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer m.Close() |
| + p, ok := m.Locate(net.ParseIP("81.2.69.142")) |
| + gb, _ := CountryPoint("GB") |
| + if !ok || p != gb { |
| + t.Fatalf("country-only 81.2.69.142 = (%v, %v), want GB's point %v", p, ok, gb) |
| + } |
| +} |
| + |
| +// Review Focus 2: addresses that locate nothing never reach the reader. |
| +func TestLocatableRefusesNonPublicAddresses(t *testing.T) { |
| + for _, s := range []string{ |
| + "127.0.0.1", "::1", "10.1.2.3", "192.168.0.1", "172.16.0.1", |
| + "100.64.0.1", "169.254.1.1", "fe80::1", "fd00::1", "0.0.0.0", "::", |
| + } { |
| + if Locatable(net.ParseIP(s)) { |
| + t.Errorf("Locatable(%s) = true", s) |
| + } |
| + } |
| + if !Locatable(net.ParseIP("81.2.69.142")) || !Locatable(net.ParseIP("2001:4860::1")) { |
| + t.Error("a public address must be locatable") |
| + } |
| + var m *MMDB |
| + if _, ok := m.Locate(net.ParseIP("81.2.69.142")); ok { |
| + t.Error("a nil *MMDB located an address") |
| + } |
| +} |
| +``` |
| + |
| +If 81.2.69.142 is absent from `GeoIP2-Country-Test.mmdb`, pick any address |
| +that the fixture's source JSON |
| +(`source-data/GeoIP2-Country-Test.json` in the same repository and commit) |
| +maps to a country, and assert that country's `CountryPoint`. |
| + |
| +- [ ] **Step 3: Run them to verify they fail** |
| + |
| +Run: `go test ./internal/geo -run 'MMDB|Locatable' -count=1` |
| +Expected: FAIL, undefined `OpenMMDB`. |
| + |
| +- [ ] **Step 4: Implement `mmdb.go`** |
| + |
| +```go |
| +package geo |
| + |
| +import ( |
| + "log" |
| + "net" |
| + "sync" |
| + "time" |
| + |
| + "github.com/oschwald/maxminddb-golang" |
| +) |
| + |
| +// Locator turns a client address into a point. A nil Locator, or a nil |
| +// *MMDB, locates nothing: the IP step is optional (spec §6.2). |
| +type Locator interface { |
| + Locate(ip net.IP) (Point, bool) |
| +} |
| + |
| +// MMDB is an operator-supplied MaxMind-format database |
| +// (CONSOLE_GEOIP_DB): GeoLite2/GeoIP2 City or Country, or DB-IP City or |
| +// Country Lite. Opened once, memory-mapped. |
| +type MMDB struct{ r *maxminddb.Reader } |
| + |
| +// OpenMMDB opens and checks the file. An error means "IP step off"; the |
| +// console logs it once and starts anyway. |
| +func OpenMMDB(path string) (*MMDB, error) { |
| + r, err := maxminddb.Open(path) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if err := r.Verify(); err != nil { |
| + r.Close() |
| + return nil, err |
| + } |
| + return &MMDB{r: r}, nil |
| +} |
| + |
| +// Close releases the mapping. |
| +func (m *MMDB) Close() error { |
| + if m == nil { |
| + return nil |
| + } |
| + return m.r.Close() |
| +} |
| + |
| +type mmdbRecord struct { |
| + Location struct { |
| + Latitude *float64 `maxminddb:"latitude"` |
| + Longitude *float64 `maxminddb:"longitude"` |
| + } `maxminddb:"location"` |
| + Country struct { |
| + ISOCode string `maxminddb:"iso_code"` |
| + } `maxminddb:"country"` |
| + RegisteredCountry struct { |
| + ISOCode string `maxminddb:"iso_code"` |
| + } `maxminddb:"registered_country"` |
| +} |
| + |
| +var ( |
| + lookupErrMu sync.Mutex |
| + lookupErrLast time.Time |
| +) |
| + |
| +// Locate returns the record's location (City databases), else its |
| +// country's point, else its registered country's. Nothing about the |
| +// address or the answer is logged. |
| +func (m *MMDB) Locate(ip net.IP) (Point, bool) { |
| + if m == nil || !Locatable(ip) { |
| + return Point{}, false |
| + } |
| + if v4 := ip.To4(); v4 != nil { |
| + ip = v4 |
| + } |
| + var rec mmdbRecord |
| + if err := m.r.Lookup(ip, &rec); err != nil { |
| + lookupErrMu.Lock() |
| + if time.Since(lookupErrLast) > time.Minute { |
| + lookupErrLast = time.Now() |
| + log.Printf("geo: GeoIP lookup failed (treated as a miss): %v", err) |
| + } |
| + lookupErrMu.Unlock() |
| + return Point{}, false |
| + } |
| + if rec.Location.Latitude != nil && rec.Location.Longitude != nil { |
| + return Point{*rec.Location.Latitude, *rec.Location.Longitude}, true |
| + } |
| + if p, ok := CountryPoint(rec.Country.ISOCode); ok { |
| + return p, true |
| + } |
| + return CountryPoint(rec.RegisteredCountry.ISOCode) |
| +} |
| + |
| +var cgnat = &net.IPNet{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)} |
| + |
| +// Locatable is false for addresses that say nothing about where a person |
| +// is: loopback, private, link-local, CGNAT and unspecified. |
| +func Locatable(ip net.IP) bool { |
| + if ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || |
| + ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || cgnat.Contains(ip) { |
| + return false |
| + } |
| + return true |
| +} |
| +``` |
| + |
| +- [ ] **Step 5: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/geo -count=1 && go mod tidy && git diff --stat go.mod go.sum` |
| +Expected: PASS. `go.mod` gains `github.com/oschwald/maxminddb-golang v1.13.1` |
| +and nothing else beyond its `golang.org/x/sys` requirement. |
| + |
| +- [ ] **Step 6: Cross-compile, then commit** |
| + |
| +Run: `GOOS=darwin go build ./... && GOOS=windows go build ./... && GOOS=linux GOARCH=arm64 go build ./...` |
| +Expected: all succeed. |
| + |
| +```bash |
| +git add go.mod go.sum internal/geo/mmdb.go internal/geo/mmdb_test.go internal/geo/testdata |
| +git commit -m "geo: optional MaxMind-format GeoIP locator (nearest-region §6.2) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 6: Console page copy (C1, C2, C3, C5, C7, C8, C21, C24; copy-review gate) |
| + |
| +**Files:** |
| +- Create: `internal/console/regioncopy.go` |
| +- Test: `internal/console/regioncopy_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: `var regionCopy regionCopyStrings` with fields `PickerLabel`, |
| + `PickerHelp`, `AutoOption`, `Unavailable`, `StartsIn`, `CantStart`, |
| + `AuditCreated` and `Attribution`, plus |
| + `func (p page) RegionCopy() regionCopyStrings`. Format verbs are `%s`, in |
| + the order the comment states. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +// internal/console/regioncopy_test.go |
| +package console |
| + |
| +import ( |
| + "fmt" |
| + "reflect" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// Every string is a draft from spec §19 until copy review approves it, |
| +// and none may carry an em dash. |
| +func TestRegionCopyHasNoEmDashesAndRendersItsVerbs(t *testing.T) { |
| + v := reflect.ValueOf(regionCopy) |
| + for i := 0; i < v.NumField(); i++ { |
| + s := v.Field(i).String() |
| + if s == "" || strings.Contains(s, "—") { |
| + t.Errorf("%s = %q: empty or has an em dash", v.Type().Field(i).Name, s) |
| + } |
| + } |
| + for _, s := range []string{ |
| + fmt.Sprintf(regionCopy.AutoOption, "UK · London"), |
| + fmt.Sprintf(regionCopy.StartsIn, "UK · London"), |
| + fmt.Sprintf(regionCopy.CantStart, "UK · London"), |
| + fmt.Sprintf(regionCopy.AuditCreated, "blog", "UK · London"), |
| + } { |
| + if strings.Contains(s, "%!") { |
| + t.Errorf("bad verb: %q", s) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run it to verify it fails** |
| + |
| +Run: `TMPDIR=$TMPDIR go test ./internal/console -run TestRegionCopy -count=1` |
| +Expected: FAIL, undefined `regionCopy`. |
| + |
| +- [ ] **Step 3: Write the drafts, verbatim from spec §19** |
| + |
| +```go |
| +package console |
| + |
| +// regionCopy is every console-page string the nearest-region feature |
| +// shows (spec §19). DRAFTS pending Paul's copy review: change none of them |
| +// without it. Verbs are %s in the order each comment names. |
| +type regionCopyStrings struct { |
| + PickerLabel string // C1 |
| + PickerHelp string // C2 |
| + AutoOption string // C3: place |
| + Unavailable string // C5 |
| + StartsIn string // C7: place |
| + CantStart string // C8: place |
| + AuditCreated string // C21: app name, place |
| + Attribution string // C24 (linked to https://db-ip.com) |
| +} |
| + |
| +var regionCopy = regionCopyStrings{ |
| + PickerLabel: "Region", |
| + PickerHelp: "Where this app's instances run. We picked the region nearest to you. Choose another if most of your users are somewhere else.", |
| + AutoOption: "%s (nearest to you)", |
| + Unavailable: "That region can't take new apps right now. Choose another.", |
| + StartsIn: "New instances start in %s, this app's region.", |
| + CantStart: "New instances can't start in %s, this app's region, right now. Pass a region to place one elsewhere.", |
| + AuditCreated: "created app %s in %s", |
| + Attribution: "IP location by DB-IP.", |
| +} |
| + |
| +func (p page) RegionCopy() regionCopyStrings { return regionCopy } |
| +``` |
| + |
| +- [ ] **Step 4: Run it to verify it passes** |
| + |
| +Run: `go test ./internal/console -run TestRegionCopy -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/console/regioncopy.go internal/console/regioncopy_test.go |
| +git commit -m "console: region page copy (drafts C1-C8, C21, C24, pending review) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +- [ ] **Step 6: Copy-review gate** |
| + |
| +Invoke `copy-review` with the eight strings, each with its spec id and where |
| +it appears. C24 also needs DB-IP's required attribution wording confirmed |
| +against <https://db-ip.com/db/lite.php>. Apply Paul's edits verbatim, re-run |
| +step 4, and commit as `console: region page copy as approved`. **Do not land |
| +the branch before this closes.** |
| + |
| +--- |
| + |
| +### Task 7: Console API messages (C16 to C20, C26 to C29; copy-review gate) |
| + |
| +**Files:** |
| +- Create: `internal/console/regionmsgs.go` |
| +- Test: `internal/console/regionmsgs_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: |
| + |
| +```go |
| +func msgAppRegionUnavailable(code, reason string, can []string) string // C16 |
| +func msgAppRegionNotAllowed(code string, allowed []string) string // C17 |
| +func msgRegionUnknown(code string, choices []string) string // C18 |
| +func msgRegionNotArmed(code string, choices []string) string // C19 |
| +const msgAppRegionUnreadable = "…" // C20 |
| +func msgReplicaOverlap(code string) string // C26 |
| +const msgPolicyUnreadable = "…" // C27 |
| +func msgAppRegionUnconfirmed(code string) string // C28 |
| +func msgPlacementDiscrepancy(host, app, code string) string // C29 |
| +func reasonText(reason string) string // "absent" | "not-hosting" | "not-eligible" |
| +``` |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +// internal/console/regionmsgs_test.go |
| +package console |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestRegionMessagesAreDraftCopyWithoutEmDashes(t *testing.T) { |
| + all := []string{ |
| + msgAppRegionUnavailable("uk", "absent", []string{"ie", "au"}), |
| + msgAppRegionNotAllowed("uk", []string{"ie"}), |
| + msgRegionUnknown("zz", []string{"ie", "uk"}), |
| + msgRegionNotArmed("uk", []string{"ie"}), |
| + msgAppRegionUnreadable, |
| + msgReplicaOverlap("uk"), |
| + msgPolicyUnreadable, |
| + msgAppRegionUnconfirmed("uk"), |
| + msgPlacementDiscrepancy("a.go.tito.io", "blog", "uk"), |
| + } |
| + for _, s := range all { |
| + if s == "" || strings.Contains(s, "—") || strings.Contains(s, "%!") { |
| + t.Errorf("bad message %q", s) |
| + } |
| + } |
| + if !strings.Contains(all[0], "no box runs there now") || !strings.Contains(all[0], "ie, au") { |
| + t.Errorf("C16 = %q, want the readable reason and the list", all[0]) |
| + } |
| + for r, want := range map[string]string{ |
| + "absent": "no box runs there now", |
| + "not-hosting": "boxes there can't start instances", |
| + "not-eligible": "boxes there don't take ordinary instances", |
| + } { |
| + if reasonText(r) != want { |
| + t.Errorf("reasonText(%s) = %q", r, reasonText(r)) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run it to verify it fails** |
| + |
| +Run: `go test ./internal/console -run TestRegionMessages -count=1` |
| +Expected: FAIL, undefined. |
| + |
| +- [ ] **Step 3: Write the drafts, verbatim from spec §19** |
| + |
| +```go |
| +package console |
| + |
| +import ( |
| + "fmt" |
| + "strings" |
| +) |
| + |
| +// The nearest-region feature's API and operator messages (spec §19). |
| +// DRAFTS pending Paul's copy review: change none of them without it. |
| + |
| +func reasonText(reason string) string { |
| + switch reason { |
| + case "absent": |
| + return "no box runs there now" |
| + case "not-hosting": |
| + return "boxes there can't start instances" |
| + case "not-eligible": |
| + return "boxes there don't take ordinary instances" |
| + } |
| + return reason |
| +} |
| + |
| +// C16 |
| +func msgAppRegionUnavailable(code, reason string, can []string) string { |
| + return fmt.Sprintf("this app's region %s can't take new instances right now (%s). Pass a region to place this instance elsewhere. Regions that can: %s.", |
| + code, reasonText(reason), strings.Join(can, ", ")) |
| +} |
| + |
| +// C17 |
| +func msgAppRegionNotAllowed(code string, allowed []string) string { |
| + return fmt.Sprintf("this app's region %s is not one its instances may use. Allowed: %s. Pass a region to choose one.", |
| + code, strings.Join(allowed, ", ")) |
| +} |
| + |
| +// C18 |
| +func msgRegionUnknown(code string, choices []string) string { |
| + return fmt.Sprintf("region %s is not one this platform runs in. Choose from: %s.", code, strings.Join(choices, ", ")) |
| +} |
| + |
| +// C19 |
| +func msgRegionNotArmed(code string, choices []string) string { |
| + return fmt.Sprintf("region %s can't take new apps right now. Choose from: %s.", code, strings.Join(choices, ", ")) |
| +} |
| + |
| +// C20 |
| +const msgAppRegionUnreadable = "could not read this app's region. Try again." |
| + |
| +// C26 |
| +func msgReplicaOverlap(code string) string { |
| + return fmt.Sprintf("this app's region %s is also one of this instance's replica placements. Pass a region for the instance, or drop %s from replicas.", code, code) |
| +} |
| + |
| +// C27 |
| +const msgPolicyUnreadable = "could not read this app's instance policy, so there is no region suggestion. Try again." |
| + |
| +// C28 |
| +func msgAppRegionUnconfirmed(code string) string { |
| + return fmt.Sprintf("could not confirm that this app's region %s can take new instances. Try again, or pass a region.", code) |
| +} |
| + |
| +// C29: an operator log line. |
| +func msgPlacementDiscrepancy(host, app, code string) string { |
| + return fmt.Sprintf("possible placement discrepancy: %s of %s has no region, but %s's region is %s. It may be a deliberate home override; review it.", host, app, app, code) |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run it to verify it passes** |
| + |
| +Run: `go test ./internal/console -run TestRegionMessages -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/console/regionmsgs.go internal/console/regionmsgs_test.go |
| +git commit -m "console: region API messages (drafts C16-C20, C26-C29, pending review) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +- [ ] **Step 6: Copy-review gate** |
| + |
| +Invoke `copy-review` with the nine messages and the three reason phrases. |
| +Apply Paul's edits verbatim, update the test's expected phrases if they |
| +changed, re-run step 4, and commit as `console: region API messages as |
| +approved`. **Do not land the branch before this closes.** |
| + |
| +--- |
| + |
| +### Task 8: Store columns, rebuild list and accessors |
| + |
| +**Files:** |
| +- Modify: `internal/console/store.go` (ALTER list ending `:386`, `migrateAppsKey` `:487-505`, `CreateAppIn` `:1349`) |
| +- Test: `internal/console/store_region_test.go` (create) |
| + |
| +**Interfaces:** |
| +- Produces: |
| + |
| +```go |
| +func (s *Store) CreateAppInRegion(accountID int64, app, createdBy, region, source string, now time.Time) (created bool, err error) |
| +func (s *Store) AppRegion(accountID int64, app string) (region, source string, err error) |
| +type nonHomeAppRegion struct{ Sqid, App, Region string; CreatedAt time.Time } |
| +func (s *Store) NonHomeAppRegions() ([]nonHomeAppRegion, error) |
| +``` |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +// internal/console/store_region_test.go |
| +package console |
| + |
| +import ( |
| + "database/sql" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + _ "modernc.org/sqlite" |
| +) |
| + |
| +func TestAppRegionRoundTripAndDefaults(t *testing.T) { |
| + s := openTestStore(t) |
| + now := time.Now() |
| + if ok, err := s.CreateAppInRegion(OpsAccountID, "blog", "p@k.dev", "uk", "timezone", now); err != nil || !ok { |
| + t.Fatalf("CreateAppInRegion = %v %v", ok, err) |
| + } |
| + if r, src, err := s.AppRegion(OpsAccountID, "blog"); err != nil || r != "uk" || src != "timezone" { |
| + t.Fatalf("AppRegion = %q %q %v", r, src, err) |
| + } |
| + if ok, _ := s.CreateAppIn(OpsAccountID, "old", "p@k.dev", now); !ok { |
| + t.Fatal("CreateAppIn failed") |
| + } |
| + if r, src, _ := s.AppRegion(OpsAccountID, "old"); r != "" || src != "" { |
| + t.Fatalf("CreateAppIn row = %q %q, want the home and no source", r, src) |
| + } |
| + if r, src, err := s.AppRegion(OpsAccountID, "missing"); err != nil || r != "" || src != "" { |
| + t.Fatalf("missing row = %q %q %v, want empty and no error", r, src, err) |
| + } |
| + // A lost claim race writes nothing (Review Focus 5's store half). |
| + if ok, _ := s.CreateAppInRegion(OpsAccountID, "blog", "x@k.dev", "au", "ip", now); ok { |
| + t.Fatal("a second claim of the same name succeeded") |
| + } |
| + if r, _, _ := s.AppRegion(OpsAccountID, "blog"); r != "uk" { |
| + t.Fatalf("a lost claim changed the region to %q", r) |
| + } |
| +} |
| + |
| +func TestNonHomeAppRegions(t *testing.T) { |
| + s := openTestStore(t) |
| + now := time.Now() |
| + s.CreateAppInRegion(OpsAccountID, "blog", "p@k.dev", "uk", "chosen", now) |
| + s.CreateAppInRegion(OpsAccountID, "home", "p@k.dev", "", "default", now) |
| + rows, err := s.NonHomeAppRegions() |
| + if err != nil || len(rows) != 1 || rows[0].App != "blog" || rows[0].Region != "uk" || rows[0].Sqid != "bab" { |
| + t.Fatalf("NonHomeAppRegions = %+v %v", rows, err) |
| + } |
| +} |
| + |
| +// The columns survive every database shape the console may open: one from |
| +// before them, and one from before migrateAppsKey (spec §5.2). And |
| +// migrate runs twice without error. |
| +func TestRegionColumnsSurviveTheAppsKeyRebuild(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "console.db") |
| + db, err := sql.Open("sqlite", path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // The pre-accounts shape: PRIMARY KEY(app), no later columns. |
| + if _, err := db.Exec(`CREATE TABLE apps (app TEXT PRIMARY KEY, created_by TEXT NOT NULL, created_at INTEGER NOT NULL); |
| +INSERT INTO apps VALUES ('legacy', 'p@k.dev', 1);`); err != nil { |
| + t.Fatal(err) |
| + } |
| + db.Close() |
| + for i := 0; i < 2; i++ { |
| + s, err := Open(path) |
| + if err != nil { |
| + t.Fatalf("open %d: %v", i, err) |
| + } |
| + r, src, err := s.AppRegion(OpsAccountID, "legacy") |
| + if err != nil || r != "" || src != "" { |
| + t.Fatalf("open %d: legacy row = %q %q %v", i, r, src, err) |
| + } |
| + if ok, err := s.CreateAppInRegion(OpsAccountID, "new"+string(rune('a'+i)), "p@k.dev", "uk", "chosen", time.Now()); err != nil || !ok { |
| + t.Fatalf("open %d: CreateAppInRegion = %v %v", i, ok, err) |
| + } |
| + s.Close() |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run them to verify they fail** |
| + |
| +Run: `go test ./internal/console -run 'TestAppRegion|TestNonHomeAppRegions|TestRegionColumns' -count=1` |
| +Expected: FAIL, undefined `CreateAppInRegion`. |
| + |
| +- [ ] **Step 3: Add the columns to the ALTER list** |
| + |
| +Directly after the `build_probe` ALTER: |
| + |
| +```go |
| + // App region (nearest-region spec §5.1): the placement code new |
| + // instances default to, in the RECORD vocabulary — "" is the |
| + // deployment's home, which is also what every existing row reads, so |
| + // the column default IS the backfill (§5.4). region_source is a label |
| + // for people and logs: chosen | timezone | ip | default | "" (the row |
| + // predates the feature). Nothing branches on it. No timezone, IP or |
| + // coordinate is ever stored. |
| + `ALTER TABLE apps ADD COLUMN region TEXT NOT NULL DEFAULT '';`, |
| + `ALTER TABLE apps ADD COLUMN region_source TEXT NOT NULL DEFAULT '';`, |
| +``` |
| + |
| +- [ ] **Step 4: Carry them through the rebuild** |
| + |
| +In `migrateAppsKey`, replace the DDL and copy statements: |
| + |
| +```go |
| + `CREATE TABLE apps_new ( |
| + app TEXT NOT NULL, |
| + account_id INTEGER NOT NULL DEFAULT 1, |
| + created_by TEXT NOT NULL, |
| + created_at INTEGER NOT NULL, |
| + deleted_at INTEGER NOT NULL DEFAULT 0, |
| + placement TEXT NOT NULL DEFAULT '', |
| + control_plane INTEGER NOT NULL DEFAULT 0, |
| + build_probe TEXT NOT NULL DEFAULT '', |
| + region TEXT NOT NULL DEFAULT '', |
| + region_source TEXT NOT NULL DEFAULT '', |
| + PRIMARY KEY (account_id, app) |
| +);`, |
| + // The ALTERs above run before migrateAppsKey in migrate(), so |
| + // deleted_at, placement, control_plane, build_probe, region and |
| + // region_source already exist on the old table by the time this |
| + // rebuild copies them — a row already trashed, already placed, |
| + // already the control plane, already carrying a build probe or an |
| + // app region, when the rebuild runs must not have that state |
| + // silently dropped (nearest-region spec §5.2). |
| + `INSERT INTO apps_new (app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe, region, region_source) |
| + SELECT app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe, region, region_source FROM apps;`, |
| +``` |
| + |
| +- [ ] **Step 5: Add the accessors** |
| + |
| +Replace `CreateAppIn`'s body and add the new methods beside it: |
| + |
| +```go |
| +func (s *Store) CreateAppIn(accountID int64, app, createdBy string, now time.Time) (created bool, err error) { |
| + return s.CreateAppInRegion(accountID, app, createdBy, "", "", now) |
| +} |
| + |
| +// CreateAppInRegion claims an app name with its region in the SAME |
| +// insert: an app region has no repair verb, so a separate best-effort |
| +// write that failed would leave the app at the home for good, silently |
| +// (nearest-region spec §5.3). region is in the record vocabulary. |
| +func (s *Store) CreateAppInRegion(accountID int64, app, createdBy, region, source string, now time.Time) (created bool, err error) { |
| + res, err := s.db.Exec(`INSERT INTO apps (app, account_id, created_by, created_at, region, region_source) |
| +VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(account_id, app) DO NOTHING`, |
| + app, accountID, createdBy, now.Unix(), region, source) |
| + if err != nil { |
| + return false, err |
| + } |
| + n, err := res.RowsAffected() |
| + return n > 0, err |
| +} |
| + |
| +// AppRegion reads an app's region and its source label. A missing row |
| +// reads as the home with no source, the same "absence is the default" |
| +// answer AppPlacement gives. |
| +func (s *Store) AppRegion(accountID int64, app string) (region, source string, err error) { |
| + err = s.db.QueryRow(`SELECT region, region_source FROM apps WHERE account_id = ? AND app = ?`, accountID, app). |
| + Scan(®ion, &source) |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return "", "", nil |
| + } |
| + return region, source, err |
| +} |
| + |
| +// nonHomeAppRegion is one live app holding a region other than the home, |
| +// for the roll-forward discrepancy log (spec §5.7). |
| +type nonHomeAppRegion struct { |
| + Sqid, App, Region string |
| + CreatedAt time.Time |
| +} |
| + |
| +func (s *Store) NonHomeAppRegions() ([]nonHomeAppRegion, error) { |
| + rows, err := s.db.Query(`SELECT a.sqid, p.app, p.region, p.created_at FROM apps p |
| +JOIN accounts a ON a.id = p.account_id |
| +WHERE p.region != '' AND p.deleted_at = 0 ORDER BY a.sqid, p.app`) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []nonHomeAppRegion |
| + for rows.Next() { |
| + var r nonHomeAppRegion |
| + var created int64 |
| + if err := rows.Scan(&r.Sqid, &r.App, &r.Region, &created); err != nil { |
| + return nil, err |
| + } |
| + r.CreatedAt = time.Unix(created, 0) |
| + out = append(out, r) |
| + } |
| + return out, rows.Err() |
| +} |
| +``` |
| + |
| +- [ ] **Step 6: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/console -run 'TestAppRegion|TestNonHomeAppRegions|TestRegionColumns|TestOwners|TestCreateApp' -count=1` |
| +Expected: PASS. Then run the whole package: |
| +`go test ./internal/console -count=1`. Expected: PASS. |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +git add internal/console/store.go internal/console/store_region_test.go |
| +git commit -m "console store: app region columns, carried through the apps-key rebuild (nearest-region §5) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 9: Console config, the switch and the named-home rule |
| + |
| +**Files:** |
| +- Modify: `internal/console/server.go` (`Config`, near `HomeRegion` `:477-484`; `NewServer`) |
| +- Modify: `cmd/console/main.go:278` (beside `HomeRegion`) |
| +- Test: `internal/console/regionconfig_test.go` (create), `cmd/console/main_test.go` (append) |
| + |
| +**Interfaces:** |
| +- Consumes: `geo.Locator`, `geo.OpenMMDB` (Task 5). |
| +- Produces: |
| + - `Config.AppRegions bool`; |
| + - `Config.GeoIP geo.Locator`; |
| + - `func (s *Server) appRegionsOn() bool`; |
| + - `func openGeoIP(path string) geo.Locator` in `cmd/console`. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +// internal/console/regionconfig_test.go |
| +package console |
| + |
| +import "testing" |
| + |
| +// Non-home app regions are written only with the switch on AND a named |
| +// home (spec §5.6, codex 3). |
| +func TestAppRegionsOnNeedsANamedHome(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + for _, c := range []struct { |
| + on bool |
| + home string |
| + want bool |
| + }{{false, "ie", false}, {true, "", false}, {true, "ie", true}} { |
| + fx.srv.cfg.AppRegions, fx.srv.cfg.HomeRegion = c.on, c.home |
| + if got := fx.srv.appRegionsOn(); got != c.want { |
| + t.Errorf("AppRegions=%v HomeRegion=%q: appRegionsOn = %v, want %v", c.on, c.home, got, c.want) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +Append to `cmd/console/main_test.go`: |
| + |
| +```go |
| +func TestOpenGeoIPIsOptionalAndNeverFatal(t *testing.T) { |
| + if openGeoIP("") != nil { |
| + t.Fatal("unset CONSOLE_GEOIP_DB must mean no locator") |
| + } |
| + if openGeoIP(filepath.Join(t.TempDir(), "missing.mmdb")) != nil { |
| + t.Fatal("an unopenable file must mean no locator, not a crash") |
| + } |
| +} |
| + |
| +func TestAppRegionsSwitchParses(t *testing.T) { |
| + for v, want := range map[string]bool{"on": true, "ON": true, " on ": true, "": false, "off": false, "true": false} { |
| + if got := appRegionsSwitch(v); got != want { |
| + t.Errorf("appRegionsSwitch(%q) = %v, want %v", v, got, want) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +Add `"path/filepath"` to that file's imports if it is not already there. |
| + |
| +- [ ] **Step 2: Run them to verify they fail** |
| + |
| +Run: `go test ./internal/console -run TestAppRegionsOn -count=1; go test ./cmd/console -run 'OpenGeoIP|AppRegionsSwitch' -count=1` |
| +Expected: FAIL, undefined. |
| + |
| +- [ ] **Step 3: Add the Config fields and the predicate** |
| + |
| +In `Config`, after `HomeRegion`: |
| + |
| +```go |
| + // AppRegions is CONSOLE_APP_REGIONS=on (nearest-region spec §5.6): the |
| + // switch that lets new apps be given a non-home region. Off by default. |
| + // Off never stops the create door's fill for rows that already hold a |
| + // region — it stops new promises, never broken old ones. |
| + AppRegions bool |
| + // GeoIP is the optional CONSOLE_GEOIP_DB locator; nil means the IP step |
| + // of the region guess is off (spec §6.2). |
| + GeoIP geo.Locator |
| +``` |
| + |
| +Import `"github.com/carlosframework/platform/internal/geo"` in `server.go`. |
| +Add the predicate in a new file `internal/console/regionconfig.go`: |
| + |
| +```go |
| +package console |
| + |
| +// appRegionsOn is spec §5.6's two-part rule: the switch AND a named home. |
| +// Without a named home, "" is the only way to say "home" and also means |
| +// "no preference", so nobody could choose the home over a non-home app |
| +// default — the switch is treated as off. |
| +func (s *Server) appRegionsOn() bool { |
| + return s.cfg.AppRegions && s.cfg.HomeRegion != "" |
| +} |
| +``` |
| + |
| +In `NewServer`, after the config is stored on the server, log once: |
| + |
| +```go |
| + if cfg.AppRegions && cfg.HomeRegion == "" { |
| + log.Printf("console: CONSOLE_APP_REGIONS=on but CONSOLE_HOME_REGION is unset — app regions stay off until the home is named (nearest-region spec §5.6)") |
| + } |
| +``` |
| + |
| +- [ ] **Step 4: Wire the environment in `cmd/console/main.go`** |
| + |
| +Beside `HomeRegion:` in the `console.Config` literal: |
| + |
| +```go |
| + AppRegions: appRegionsSwitch(os.Getenv("CONSOLE_APP_REGIONS")), |
| + GeoIP: openGeoIP(strings.TrimSpace(os.Getenv("CONSOLE_GEOIP_DB"))), |
| +``` |
| + |
| +Add these helpers near `envOr`: |
| + |
| +```go |
| +// appRegionsSwitch reads CONSOLE_APP_REGIONS: exactly "on" (any case, |
| +// trimmed) turns app regions on; anything else is off. |
| +func appRegionsSwitch(v string) bool { return strings.EqualFold(strings.TrimSpace(v), "on") } |
| + |
| +// openGeoIP opens the optional CONSOLE_GEOIP_DB. Unset, unopenable or not |
| +// an mmdb means the IP step is off — logged once, never fatal: a missing |
| +// geo file must never cost the console (nearest-region spec §6.2). |
| +func openGeoIP(path string) geo.Locator { |
| + if path == "" { |
| + return nil |
| + } |
| + m, err := geo.OpenMMDB(path) |
| + if err != nil { |
| + log.Printf("console: CONSOLE_GEOIP_DB %s is unusable, so the IP step of region guesses is off: %v", path, err) |
| + return nil |
| + } |
| + log.Printf("console: GeoIP database loaded from %s", path) |
| + return m |
| +} |
| +``` |
| + |
| +`openGeoIP` must return an untyped `nil` on both failure paths, never a nil |
| +`*geo.MMDB` inside the interface. The test asserts `== nil`. |
| + |
| +- [ ] **Step 5: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/console -run TestAppRegionsOn -count=1 && go test ./cmd/console -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add internal/console/server.go internal/console/regionconfig.go internal/console/regionconfig_test.go cmd/console/main.go cmd/console/main_test.go |
| +git commit -m "console: CONSOLE_APP_REGIONS switch, named-home rule, optional CONSOLE_GEOIP_DB (nearest-region §5.6, §6.2) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 10: Strict edges read, candidates, eligibility and policy |
| + |
| +**Files:** |
| +- Modify: `internal/console/instancesplacement.go` (`edgesFilePlacementView` `:35-55`, beside `readEdgesFiles` `:83`) |
| +- Create: `internal/console/regioncandidates.go` |
| +- Test: `internal/console/regioncandidates_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `provision.ValidRegion`, `provision.GetManifest`, `s.appPlacement` |
| + (`fleets.go:479`), `openRegistry`, and `instanceRegionAllowed` |
| + (`apiinstances.go:1797`). |
| +- Produces: |
| + |
| +```go |
| +func readEdgesFilesStrict(ctx context.Context, s release.Store) ([]edgesFilePlacementView, error) |
| +type regionCandidate struct { |
| + Code string // record vocabulary; "" is the home |
| + Home bool |
| + AWSRegion string |
| + Reachable, Hosting, Eligible bool |
| +} |
| +func regionCandidates(files []edgesFilePlacementView, homeName string) []regionCandidate |
| +func homeOnlyCandidates() []regionCandidate |
| +func (c regionCandidate) unavailableReason() string // "", "absent", "not-hosting", "not-eligible" |
| +func findCandidate(cands []regionCandidate, code string) (regionCandidate, bool) |
| +type policyState int // policyAbsent, policyKnown, policyUnreadable |
| +type regionPolicy struct { |
| + State policyState |
| + M *provision.Manifest // nil unless policyKnown |
| + Placed string // fleet placement; "" when unplaced |
| +} |
| +func (s *Server) appPolicy(ctx context.Context, acct *Account, app string) regionPolicy |
| +func (p regionPolicy) allows(code string) bool // instanceRegionAllowed on the normalized code |
| +``` |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +// internal/console/regioncandidates_test.go |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func edgeView(t *testing.T, body string) edgesFilePlacementView { |
| + t.Helper() |
| + var f edgesFilePlacementView |
| + if err := json.Unmarshal([]byte(body), &f); err != nil { |
| + t.Fatal(err) |
| + } |
| + return f |
| +} |
| + |
| +const ( |
| + homeBox = `{"v":1,"box":"ie-box","eip":"5.6.7.8","region":"eu-west-1","hibernation_configured":true,"serves_shared_pool":true,"accepts_inbound":true,"hosts":{}}` |
| + ukEligible = `{"v":1,"box":"uk-box","eip":"1.2.3.4","region":"eu-west-2","placement":"uk","hibernation_configured":true,"serves_shared_pool":true,"accepts_inbound":true,"hosts":{}}` |
| + auPoolOnly = `{"v":1,"box":"au-box","eip":"9.9.9.9","region":"ap-southeast-2","placement":"au","hibernation_configured":true,"serves_shared_pool":false,"accepts_inbound":true,"hosts":{}}` |
| + usNoInbound = `{"v":1,"box":"us-box","eip":"8.8.4.4","region":"us-east-1","placement":"us","hibernation_configured":true,"serves_shared_pool":true,"accepts_inbound":false,"hosts":{}}` |
| + caOldWriter = `{"v":1,"box":"ca-box","eip":"7.7.7.7","region":"ca-central-1","placement":"ca","hosts":{}}` |
| + nzNoHosting = `{"v":1,"box":"nz-box","eip":"6.6.6.6","region":"ap-southeast-6","placement":"nz","hibernation_configured":false,"serves_shared_pool":true,"accepts_inbound":true,"hosts":{}}` |
| + jpNoEIP = `{"v":1,"box":"jp-box","eip":"","region":"ap-northeast-1","placement":"jp","hibernation_configured":true,"serves_shared_pool":true,"accepts_inbound":true,"hosts":{}}` |
| + fleetBoxFile = `{"v":1,"box":"fl-box","eip":"4.4.4.4","region":"eu-west-2","placement":"fleet/acme","hibernation_configured":true,"serves_shared_pool":true,"accepts_inbound":true,"hosts":{}}` |
| +) |
| + |
| +func TestRegionCandidatesNeedPositiveEligibility(t *testing.T) { |
| + var files []edgesFilePlacementView |
| + for _, b := range []string{homeBox, ukEligible, auPoolOnly, usNoInbound, caOldWriter, nzNoHosting, jpNoEIP, fleetBoxFile} { |
| + files = append(files, edgeView(t, b)) |
| + } |
| + cands := regionCandidates(files, "ie") |
| + want := map[string]string{"": "", "uk": "", "au": "not-eligible", "us": "not-eligible", |
| + "ca": "not-eligible", "nz": "not-hosting", "jp": "absent"} |
| + if len(cands) != len(want) { |
| + t.Fatalf("candidates = %+v, want exactly %v (no fleet code)", cands, want) |
| + } |
| + for code, reason := range want { |
| + c, ok := findCandidate(cands, code) |
| + if !ok { |
| + t.Fatalf("no candidate %q", code) |
| + } |
| + if got := c.unavailableReason(); got != reason { |
| + t.Errorf("%q: reason %q, want %q", code, got, reason) |
| + } |
| + } |
| + h, _ := findCandidate(cands, "") |
| + if !h.Home || h.AWSRegion != "eu-west-1" || !h.Eligible { |
| + t.Errorf("home = %+v", h) |
| + } |
| + if _, ok := findCandidate(cands, "zz"); ok { |
| + t.Error("an unpublished code was found") |
| + } |
| +} |
| + |
| +// A box publishing the home's NAME is the home, not a second region. |
| +func TestRegionCandidatesFoldTheHomesName(t *testing.T) { |
| + coded := `{"v":1,"box":"ie2","eip":"5.6.7.9","region":"eu-west-1","placement":"ie","hibernation_configured":true,"serves_shared_pool":true,"accepts_inbound":true,"hosts":{}}` |
| + cands := regionCandidates([]edgesFilePlacementView{edgeView(t, homeBox), edgeView(t, coded)}, "ie") |
| + if len(cands) != 1 || !cands[0].Home { |
| + t.Fatalf("candidates = %+v, want the home alone", cands) |
| + } |
| +} |
| + |
| +type failingList struct{ release.Store } |
| + |
| +func (failingList) List(context.Context, string) ([]string, error) { |
| + return nil, errors.New("bucket unreachable") |
| +} |
| + |
| +func TestReadEdgesFilesStrictReportsListFailure(t *testing.T) { |
| + if _, err := readEdgesFilesStrict(context.Background(), failingList{}); err == nil { |
| + t.Fatal("a failed listing must be an error, not an empty answer (codex R2-2)") |
| + } |
| + files, err := readEdgesFilesStrict(context.Background(), release.NewDirStore(t.TempDir())) |
| + if err != nil || len(files) != 0 { |
| + t.Fatalf("an empty prefix = %v %v, want no files and no error", files, err) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run them to verify they fail** |
| + |
| +Run: `go test ./internal/console -run 'TestRegionCandidates|TestReadEdgesFilesStrict' -count=1` |
| +Expected: FAIL, undefined. |
| + |
| +- [ ] **Step 3: Extend the view and add the strict read** |
| + |
| +In `edgesFilePlacementView`, after `HibernationConfigured`: |
| + |
| +```go |
| + // ServesSharedPool and AcceptsInbound (internal/host/edgesfile.go): |
| + // POINTERS for HibernationConfigured's reason — absent is a writer that |
| + // predates the fields (unknown), false is a box that said no. Automatic |
| + // region choices need both positively true (nearest-region spec §6.1). |
| + ServesSharedPool *bool `json:"serves_shared_pool"` |
| + AcceptsInbound *bool `json:"accepts_inbound"` |
| +``` |
| + |
| +Refactor `readEdgesFiles` to share a body with the strict read: |
| + |
| +```go |
| +func readEdgesFiles(ctx context.Context, s release.Store) []edgesFilePlacementView { |
| + if s == nil { |
| + return nil |
| + } |
| + out, err := readEdgesFilesStrict(ctx, s) |
| + if err != nil { |
| + log.Printf("console: api instances: could not list %s for the placement join: %v", edgesFilePrefix, err) |
| + return nil |
| + } |
| + return out |
| +} |
| + |
| +// readEdgesFilesStrict is readEdgesFiles that REPORTS a failed listing |
| +// instead of answering empty: the nearest-region door must tell "could not |
| +// read" (503, C28) from "no box publishes this code" (codex R2-2). |
| +// Unreadable or unparsable single files are still skipped, as before. |
| +func readEdgesFilesStrict(ctx context.Context, s release.Store) ([]edgesFilePlacementView, error) { |
| + keys, err := s.List(ctx, edgesFilePrefix) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var out []edgesFilePlacementView |
| + for _, key := range keys { |
| + name, ok := strings.CutPrefix(key, edgesFilePrefix) |
| + if !ok || !strings.HasSuffix(name, ".json") || strings.Contains(name, "/") { |
| + continue |
| + } |
| + body, _, err := s.Get(ctx, key) |
| + if err != nil { |
| + log.Printf("console: api instances: could not read %s for the placement join: %v", key, err) |
| + continue |
| + } |
| + var f edgesFilePlacementView |
| + if err := json.Unmarshal(body, &f); err != nil || f.V < 1 || f.Box == "" { |
| + log.Printf("console: api instances: skipping %s in the placement join: not a versioned edges file", key) |
| + continue |
| + } |
| + out = append(out, f) |
| + } |
| + return out, nil |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Implement `regioncandidates.go`** |
| + |
| +```go |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "log" |
| + "os" |
| + "sort" |
| + "sync" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// disagreeSaid throttles majority's disagreement log to once per code. |
| +var disagreeSaid sync.Map |
| + |
| +// regionCandidate is one region an automatic choice may land in |
| +// (nearest-region spec §6.1). Code is in the record vocabulary: "" is the |
| +// home. |
| +type regionCandidate struct { |
| + Code string |
| + Home bool |
| + AWSRegion string |
| + Reachable bool // some box publishing the code has a dial address |
| + Hosting bool // ... and its activator configured |
| + Eligible bool // ... and serves the shared pool and accepts inbound, all POSITIVELY |
| +} |
| + |
| +// unavailableReason is "" for an eligible candidate, else the first bar |
| +// it fails (the C16 vocabulary). |
| +func (c regionCandidate) unavailableReason() string { |
| + switch { |
| + case c.Eligible: |
| + return "" |
| + case !c.Reachable: |
| + return "absent" |
| + case !c.Hosting: |
| + return "not-hosting" |
| + } |
| + return "not-eligible" |
| +} |
| + |
| +func isTrue(b *bool) bool { return b != nil && *b } |
| + |
| +// regionCandidates folds the edges files into candidates. Positive |
| +// eligibility per box: reachable, and hibernation_configured, |
| +// serves_shared_pool and accepts_inbound all true on ONE box. Unknown is |
| +// not yes here — the reverse of placements' generosity, which is right |
| +// for an explicit request and wrong for an automatic one (§6.1). The |
| +// home is exempt: it is where every legacy create already lands. |
| +func regionCandidates(files []edgesFilePlacementView, homeName string) []regionCandidate { |
| + type acc struct { |
| + c regionCandidate |
| + regions map[string]int |
| + } |
| + by := map[string]*acc{} |
| + get := func(code string, home bool) *acc { |
| + a, ok := by[code] |
| + if !ok { |
| + a = &acc{c: regionCandidate{Code: code, Home: home}, regions: map[string]int{}} |
| + by[code] = a |
| + } |
| + return a |
| + } |
| + homeA := get("", true) |
| + homeA.c.Reachable, homeA.c.Hosting, homeA.c.Eligible = true, true, true |
| + for _, f := range files { |
| + home := f.Placement == "" || (homeName != "" && f.Placement == homeName) |
| + if !home && !provision.ValidRegion(f.Placement) { |
| + continue // fleet/<name> and malformed codes are never candidates |
| + } |
| + code := f.Placement |
| + if home { |
| + code = "" |
| + } |
| + a := get(code, home) |
| + if f.EIP == "" { |
| + continue |
| + } |
| + // Hosting keeps placements' "unknown is not no" rule (an absent |
| + // hibernation_configured is a writer predating the field); only |
| + // ELIGIBILITY demands all three facts positively. |
| + hosting := isTrue(f.HibernationConfigured) || f.HibernationConfigured == nil |
| + eligible := isTrue(f.HibernationConfigured) && isTrue(f.ServesSharedPool) && isTrue(f.AcceptsInbound) |
| + if !home { |
| + a.c.Reachable = true |
| + a.c.Hosting = a.c.Hosting || hosting |
| + a.c.Eligible = a.c.Eligible || eligible |
| + } |
| + if f.Region != "" && (home || eligible) { |
| + a.regions[f.Region]++ |
| + } |
| + } |
| + out := make([]regionCandidate, 0, len(by)) |
| + for _, a := range by { |
| + a.c.AWSRegion = majority(a.c.Code, a.regions) |
| + out = append(out, a.c) |
| + } |
| + sort.Slice(out, func(i, j int) bool { return out[i].Code < out[j].Code }) |
| + return out |
| +} |
| + |
| +// majority picks the most common AWS region, ties to the lexically |
| +// smallest, and logs a disagreement once per code per process. |
| +func majority(code string, regions map[string]int) string { |
| + best, n := "", 0 |
| + for r, c := range regions { |
| + if c > n || (c == n && r < best) { |
| + best, n = r, c |
| + } |
| + } |
| + if len(regions) > 1 { |
| + if _, said := disagreeSaid.LoadOrStore(code, true); !said { |
| + log.Printf("console: boxes for region %q disagree on their AWS region %v; using %s", code, regions, best) |
| + } |
| + } |
| + return best |
| +} |
| + |
| +func homeOnlyCandidates() []regionCandidate { |
| + return []regionCandidate{{Code: "", Home: true, Reachable: true, Hosting: true, Eligible: true}} |
| +} |
| + |
| +func findCandidate(cands []regionCandidate, code string) (regionCandidate, bool) { |
| + for _, c := range cands { |
| + if c.Code == code { |
| + return c, true |
| + } |
| + } |
| + return regionCandidate{}, false |
| +} |
| + |
| +type policyState int |
| + |
| +const ( |
| + policyAbsent policyState = iota |
| + policyKnown |
| + policyUnreadable |
| +) |
| + |
| +// regionPolicy is what the app's manifest and placement say about where an |
| +// instance may land (spec §6.1, codex 6, codex R4-1). |
| +type regionPolicy struct { |
| + State policyState |
| + M *provision.Manifest |
| + Placed string |
| +} |
| + |
| +// appPolicy reads the manifest directly — never instanceManifest, which |
| +// answers the caller itself — and the fleet placement. Any failure is |
| +// policyUnreadable: an unrestricted policy is never invented. |
| +func (s *Server) appPolicy(ctx context.Context, acct *Account, app string) regionPolicy { |
| + var p regionPolicy |
| + m, err := provision.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app) |
| + switch { |
| + case err == nil: |
| + p.State, p.M = policyKnown, m |
| + case errors.Is(err, os.ErrNotExist): |
| + p.State = policyAbsent |
| + default: |
| + log.Printf("console: region policy %s/%s: read manifest: %v", acct.Sqid, app, err) |
| + return regionPolicy{State: policyUnreadable} |
| + } |
| + reg, err := openRegistry(s.cfg.Registry) |
| + if err != nil { |
| + log.Printf("console: region policy %s/%s: open registry: %v", acct.Sqid, app, err) |
| + return regionPolicy{State: policyUnreadable} |
| + } |
| + placed, err := s.appPlacement(reg, acct, app) |
| + if err != nil { |
| + log.Printf("console: region policy %s/%s: placement: %v", acct.Sqid, app, err) |
| + return regionPolicy{State: policyUnreadable} |
| + } |
| + p.Placed = placed |
| + return p |
| +} |
| + |
| +// pooled reports a known manifest naming a pool. |
| +func (p regionPolicy) pooled() bool { return p.State == policyKnown && p.M.Pool != "" } |
| + |
| +// allows is the door's own allow-list predicate on a record-vocabulary code. |
| +func (p regionPolicy) allows(code string) bool { |
| + if p.State != policyKnown { |
| + return p.State == policyAbsent |
| + } |
| + return instanceRegionAllowed(p.M.Regions, code) |
| +} |
| +``` |
| + |
| +Run `gofmt -l internal/console`. It must print nothing. |
| + |
| +- [ ] **Step 5: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/console -run 'TestRegionCandidates|TestReadEdgesFilesStrict|TestPlacements' -count=1` |
| +Expected: PASS. In particular, `TestPlacements*` must still pass after the |
| +`readEdgesFiles` refactor. |
| + |
| +- [ ] **Step 6: Add the policy test** |
| + |
| +```go |
| +func TestAppPolicyStates(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-policy") |
| + acct := &Account{ID: OpsAccountID, Sqid: "bab"} |
| + ctx := context.Background() |
| + if p := fx.srv.appPolicy(ctx, acct, "titogo"); p.State != policyAbsent || !p.allows("uk") { |
| + t.Fatalf("no manifest = %+v, want absent and unrestricted", p) |
| + } |
| + m := titoManifest() |
| + m.Pool, m.Regions = "", []string{"uk"} |
| + enableInstances(t, fx, "titogo", m) |
| + p := fx.srv.appPolicy(ctx, acct, "titogo") |
| + if p.State != policyKnown || p.allows("") || !p.allows("uk") || p.pooled() { |
| + t.Fatalf("known policy = %+v", p) |
| + } |
| + m.Pool = "tito" |
| + enableInstances(t, fx, "titogo", m) |
| + if !fx.srv.appPolicy(ctx, acct, "titogo").pooled() { |
| + t.Fatal("a pooled manifest must read as pooled") |
| + } |
| + if err := fx.st.SetAppPlacement(OpsAccountID, "titogo", "fleet/acme"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if p := fx.srv.appPolicy(ctx, acct, "titogo"); p.Placed != "fleet/acme" { |
| + t.Fatalf("placed = %q", p.Placed) |
| + } |
| +} |
| +``` |
| + |
| +Run: `go test ./internal/console -run TestAppPolicyStates -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +git add internal/console/instancesplacement.go internal/console/regioncandidates.go internal/console/regioncandidates_test.go |
| +git commit -m "console: region candidates with positive per-box eligibility, strict edges read, policy states (nearest-region §6.1) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 11: The guess and its invariant |
| + |
| +**Files:** |
| +- Create: `internal/console/regionguess.go` |
| +- Test: `internal/console/regionguess_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `regionCandidate`, `regionPolicy.allows` (Task 10), `geo.ZonePoint`, |
| + `geo.Nearest`, `geo.Candidate` and `geo.Locator` (Tasks 2 to 5). |
| +- Produces: |
| + |
| +```go |
| +type guessInput struct{ TZ string; IP net.IP } |
| +type guessResult struct{ Code, Source string } // Code: record vocabulary |
| +func guessRegion(cands []regionCandidate, allows func(code string) bool, in guessInput, loc geo.Locator) (guessResult, bool) |
| +``` |
| + |
| +- [ ] **Step 1: Write the failing tests, including the property test** |
| + |
| +```go |
| +// internal/console/regionguess_test.go |
| +package console |
| + |
| +import ( |
| + "math/rand/v2" |
| + "net" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/geo" |
| +) |
| + |
| +type fakeLocator map[string]geo.Point |
| + |
| +func (f fakeLocator) Locate(ip net.IP) (geo.Point, bool) { |
| + p, ok := f[ip.String()] |
| + return p, ok |
| +} |
| + |
| +func allowAll(string) bool { return true } |
| + |
| +var ( |
| + cHome = regionCandidate{Code: "", Home: true, AWSRegion: "eu-west-1", Reachable: true, Hosting: true, Eligible: true} |
| + cUK = regionCandidate{Code: "uk", AWSRegion: "eu-west-2", Reachable: true, Hosting: true, Eligible: true} |
| + cAU = regionCandidate{Code: "au", AWSRegion: "ap-southeast-2", Reachable: true, Hosting: true, Eligible: true} |
| +) |
| + |
| +func TestGuessRegionOrder(t *testing.T) { |
| + loc := fakeLocator{"81.2.69.142": {-33.9, 151.2}} // an IP that locates to Sydney |
| + cands := []regionCandidate{cHome, cUK, cAU} |
| + cases := []struct { |
| + name string |
| + in guessInput |
| + want guessResult |
| + }{ |
| + {"timezone first", guessInput{TZ: "Europe/London", IP: net.ParseIP("81.2.69.142")}, guessResult{"uk", "timezone"}}, |
| + {"UTC falls to IP", guessInput{TZ: "Etc/UTC", IP: net.ParseIP("81.2.69.142")}, guessResult{"au", "ip"}}, |
| + {"nothing maps", guessInput{TZ: "Etc/UTC"}, guessResult{"", "default"}}, |
| + } |
| + for _, c := range cases { |
| + got, ok := guessRegion(cands, allowAll, c.in, loc) |
| + if !ok || got != c.want { |
| + t.Errorf("%s: got (%+v, %v), want %+v", c.name, got, ok, c.want) |
| + } |
| + } |
| + // An ineligible candidate nearer than every eligible one is never picked. |
| + auOff := cAU |
| + auOff.Eligible = false |
| + got, _ := guessRegion([]regionCandidate{cHome, cUK, auOff}, allowAll, guessInput{TZ: "Australia/Sydney"}, nil) |
| + if got.Code == "au" { |
| + t.Fatal("picked an ineligible region") |
| + } |
| +} |
| + |
| +// Spec §6.4, codex R3-4: an allow-list of only uk, with the home's |
| +// coordinates missing, gives uk when the hint maps and null when not. |
| +func TestGuessRegionNeverReturnsADisallowedHome(t *testing.T) { |
| + noPointHome := cHome |
| + noPointHome.AWSRegion = "" |
| + onlyUK := func(code string) bool { return code == "uk" } |
| + cands := []regionCandidate{noPointHome, cUK} |
| + if got, ok := guessRegion(cands, onlyUK, guessInput{TZ: "Europe/London"}, nil); !ok || got.Code != "uk" { |
| + t.Fatalf("mapped hint = (%+v, %v), want uk", got, ok) |
| + } |
| + if got, ok := guessRegion(cands, onlyUK, guessInput{TZ: "Etc/UTC"}, nil); ok { |
| + t.Fatalf("unmappable hint = %+v, want no guess (never the excluded home)", got) |
| + } |
| + if got, ok := guessRegion(homeOnlyCandidates(), onlyUK, guessInput{TZ: "Europe/London"}, nil); ok { |
| + t.Fatalf("edges-read failure = %+v, want no guess", got) |
| + } |
| +} |
| + |
| +// The invariant over generated policies, candidate sets, missing |
| +// coordinates and hints: every non-null guess passes the policy, and a |
| +// home fallback appears only when the home is allowed. |
| +func TestGuessRegionInvariantProperty(t *testing.T) { |
| + codes := []string{"", "uk", "au", "us", "jp"} |
| + regions := []string{"eu-west-1", "eu-west-2", "ap-southeast-2", "us-east-1", "", "xx-nowhere-1"} |
| + zones := []string{"Europe/London", "Australia/Sydney", "America/New_York", "Etc/UTC", "", "Asia/Tokyo"} |
| + r := rand.New(rand.NewPCG(1, 2)) |
| + for i := 0; i < 20000; i++ { |
| + var cands []regionCandidate |
| + for _, code := range codes { |
| + if code != "" && r.IntN(3) == 0 { |
| + continue |
| + } |
| + cands = append(cands, regionCandidate{Code: code, Home: code == "", |
| + AWSRegion: regions[r.IntN(len(regions))], Reachable: true, Hosting: true, |
| + Eligible: code == "" || r.IntN(4) != 0}) |
| + } |
| + allowed := map[string]bool{} |
| + for _, code := range codes { |
| + allowed[code] = r.IntN(2) == 0 |
| + } |
| + allows := func(code string) bool { return allowed[code] } |
| + got, ok := guessRegion(cands, allows, guessInput{TZ: zones[r.IntN(len(zones))]}, nil) |
| + if !ok { |
| + continue |
| + } |
| + if !allows(got.Code) { |
| + t.Fatalf("case %d: guess %+v fails the policy %v", i, got, allowed) |
| + } |
| + c, found := findCandidate(cands, got.Code) |
| + if !found || !c.Eligible { |
| + t.Fatalf("case %d: guess %+v is not an eligible candidate", i, got) |
| + } |
| + } |
| +} |
| + |
| +// Review Focus 4's core half: a malformed hint is absent, never a panic. |
| +func TestGuessRegionToleratesGarbageHints(t *testing.T) { |
| + big := make([]byte, 10<<10) |
| + for i := range big { |
| + big[i] = 'a' |
| + } |
| + got, ok := guessRegion([]regionCandidate{cHome, cUK}, allowAll, guessInput{TZ: string(big)}, fakeLocator{}) |
| + if !ok || got != (guessResult{"", "default"}) { |
| + t.Fatalf("garbage tz = (%+v, %v), want the home default", got, ok) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run them to verify they fail** |
| + |
| +Run: `go test ./internal/console -run TestGuessRegion -count=1` |
| +Expected: FAIL, undefined `guessRegion`. |
| + |
| +- [ ] **Step 3: Implement `regionguess.go`** |
| + |
| +```go |
| +package console |
| + |
| +import ( |
| + "net" |
| + |
| + "github.com/carlosframework/platform/internal/geo" |
| +) |
| + |
| +// guessInput carries a request's location hints for ONE call. Neither |
| +// field is ever stored, logged, audited or echoed (spec §6.5). |
| +type guessInput struct { |
| + TZ string |
| + IP net.IP |
| +} |
| + |
| +// guessResult is a guess in the record vocabulary ("" is the home) and its |
| +// source word: timezone | ip | default. |
| +type guessResult struct{ Code, Source string } |
| + |
| +// guessRegion is spec §6.4: |
| +// |
| +// 1. filter candidates to eligible ones the policy allows; |
| +// 2. nearest among those with a point, from the timezone, else the IP; |
| +// 3. else the home, ONLY if the home is itself a filtered candidate; |
| +// 4. else no guess. |
| +// |
| +// So every non-null answer, the home fallback included, passes allows |
| +// (codex R3-4). geo.Nearest refuses when the home is a candidate with no |
| +// point, which is what sends a mapped hint to the home default then. |
| +func guessRegion(cands []regionCandidate, allows func(code string) bool, in guessInput, loc geo.Locator) (guessResult, bool) { |
| + var pick []geo.Candidate |
| + homeIn := false |
| + for _, c := range cands { |
| + if !c.Eligible || !allows(c.Code) { |
| + continue |
| + } |
| + pick = append(pick, geo.Candidate{Code: c.Code, Home: c.Home, AWSRegion: c.AWSRegion}) |
| + homeIn = homeIn || c.Home |
| + } |
| + if len(pick) == 0 { |
| + return guessResult{}, false |
| + } |
| + if p, ok := geo.ZonePoint(in.TZ); ok { |
| + if code, ok := geo.Nearest(p, pick); ok { |
| + return guessResult{code, "timezone"}, true |
| + } |
| + } else if loc != nil && in.IP != nil { |
| + if p, ok := loc.Locate(in.IP); ok { |
| + if code, ok := geo.Nearest(p, pick); ok { |
| + return guessResult{code, "ip"}, true |
| + } |
| + } |
| + } |
| + if homeIn { |
| + return guessResult{"", "default"}, true |
| + } |
| + return guessResult{}, false |
| +} |
| +``` |
| + |
| +R3 says IP is the fallback when the timezone is unmappable. A mapped timezone |
| +whose nearest pick fails (because the home has no point) falls to the home |
| +default, not to IP. The `else if` encodes that. |
| + |
| +- [ ] **Step 4: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/console -run TestGuessRegion -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/console/regionguess.go internal/console/regionguess_test.go |
| +git commit -m "console: the region guess, with its policy invariant property-tested (nearest-region §6.4) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 12: `claimApp` resolution and the API create door |
| + |
| +**Files:** |
| +- Create: `internal/console/regionclaim.go` |
| +- Modify: `internal/console/server.go:3466-3567` (`claimApp`), `:3626` (browser call site: passes `regionReq{}` for now; Task 13 fills it) |
| +- Modify: `internal/console/apicli.go:330-470` (`handleAPICreateApp`) |
| +- Modify: `internal/console/apps_claim_test.go:118,134,150,165`, `internal/console/apicli_test.go:2504` (add the `regionReq{}` argument) |
| +- Test: `internal/console/regionclaim_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: Tasks 8 to 11, `s.clientIP` (`authcli.go:876`), `principalFrom`, |
| + `placements`, `hostingSet`, `homeRegionIn` and `homeRegionOut`. |
| +- Produces: |
| + |
| +```go |
| +type regionReq struct { |
| + explicit string // a person's choice, record vocabulary |
| + hasExplicit bool |
| + tz string |
| + ip net.IP // person requests only |
| +} |
| +func (s *Server) validateExplicitRegion(ctx context.Context, raw string) (code string, ref *doorRefusal) |
| +func (s *Server) resolveNewAppRegion(ctx context.Context, rq regionReq, adopting bool) (region, source string) |
| +func (s *Server) placeOf(code string, cands []regionCandidate) string // a place name, or the code under its name |
| +func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool, rq regionReq) (claimOutcome, string, *apexHold) |
| +``` |
| + |
| +`handleAPICreateApp`'s response gains `region`, `region_name`, |
| +`region_source` and `regions_available`. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +// internal/console/regionclaim_test.go |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net" |
| + "net/http" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func regionFixture(t *testing.T) (*consoleFixture, string) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.AppRegions, fx.srv.cfg.HomeRegion = true, "ie" |
| + putEdges(t, fx, map[string]string{"ie-box": homeBox, "uk-box": ukEligible}) |
| + return fx, cliToken(t, fx, "paul@keymail.dev", "code-region-claim") |
| +} |
| + |
| +func createApp(t *testing.T, fx *consoleFixture, token string, body map[string]string) (int, map[string]any) { |
| + t.Helper() |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", body, token) |
| + var res map[string]any |
| + json.Unmarshal([]byte(out), &res) |
| + return status, res |
| +} |
| + |
| +func TestAPICreateAppResolvesRegion(t *testing.T) { |
| + fx, token := regionFixture(t) |
| + status, res := createApp(t, fx, token, map[string]string{"name": "blog", "timezone": "Europe/London"}) |
| + if status != http.StatusOK || res["region"] != "uk" || res["region_source"] != "timezone" || |
| + res["region_name"] != "UK · London" || res["regions_available"] != float64(2) { |
| + t.Fatalf("create = %d %v", status, res) |
| + } |
| + if r, src, _ := fx.st.AppRegion(OpsAccountID, "blog"); r != "uk" || src != "timezone" { |
| + t.Fatalf("stored %q %q", r, src) |
| + } |
| + // An old CLI's body: no timezone, no region. Localhost has no IP to |
| + // locate, so it is the home, named. |
| + _, res = createApp(t, fx, token, map[string]string{"name": "old"}) |
| + if res["region"] != "ie" || res["region_source"] != "default" { |
| + t.Fatalf("old body = %v, want ie/default", res) |
| + } |
| + // An explicit home name is a choice, stored as "". |
| + _, res = createApp(t, fx, token, map[string]string{"name": "athome", "region": "ie"}) |
| + if r, src, _ := fx.st.AppRegion(OpsAccountID, "athome"); r != "" || src != "chosen" || res["region"] != "ie" { |
| + t.Fatalf("explicit home = %q %q %v", r, src, res) |
| + } |
| +} |
| + |
| +func TestAPICreateAppRefusesBadExplicitRegions(t *testing.T) { |
| + fx, token := regionFixture(t) |
| + putEdges(t, fx, map[string]string{"nz-box": nzNoHosting}) |
| + for region, want := range map[string]string{ |
| + "Bad/Code": msgRegionUnknown("Bad/Code", []string{"ie", "nz", "uk"}), |
| + "nz": msgRegionNotArmed("nz", []string{"ie", "uk"}), |
| + } { |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "x" + strings.ToLower(region[:1]), "region": region}, token) |
| + if status != http.StatusBadRequest || !strings.Contains(out, want) { |
| + t.Errorf("region %q = %d %s, want 400 %q", region, status, out, want) |
| + } |
| + } |
| + if has, _ := fx.st.HasAppIn(OpsAccountID, "xb"); has { |
| + t.Fatal("a refused region still claimed the name") |
| + } |
| +} |
| + |
| +func TestAPICreateAppSwitchOffStoresTheHome(t *testing.T) { |
| + fx, token := regionFixture(t) |
| + fx.srv.cfg.AppRegions = false |
| + _, res := createApp(t, fx, token, map[string]string{"name": "off", "region": "uk", "timezone": "Europe/London"}) |
| + if r, src, _ := fx.st.AppRegion(OpsAccountID, "off"); r != "" || src != "default" || res["region"] != "ie" { |
| + t.Fatalf("switch off = %q %q %v", r, src, res) |
| + } |
| + fx.srv.cfg.AppRegions, fx.srv.cfg.HomeRegion = true, "" |
| + createApp(t, fx, token, map[string]string{"name": "unnamed", "region": "uk"}) |
| + if r, _, _ := fx.st.AppRegion(OpsAccountID, "unnamed"); r != "" { |
| + t.Fatalf("unnamed home stored %q", r) |
| + } |
| +} |
| + |
| +// Adoption never changes an existing app's region by guessing (§5.5). |
| +func TestClaimAdoptionDiscardsTheGuess(t *testing.T) { |
| + fx, _ := regionFixture(t) |
| + acct := &Account{ID: OpsAccountID, Sqid: "bab"} |
| + seedBucketApp(t, fx, "bab", "oldsite") |
| + out, _, _ := fx.srv.claimApp(context.Background(), acct, "oldsite", "paul@keymail.dev", "", true, |
| + regionReq{tz: "Europe/London"}) |
| + if out != claimOK { |
| + t.Fatalf("adoption = %v", out) |
| + } |
| + if r, src, _ := fx.st.AppRegion(OpsAccountID, "oldsite"); r != "" || src != "default" { |
| + t.Fatalf("adoption with a guess = %q %q, want the home", r, src) |
| + } |
| + seedBucketApp(t, fx, "bab", "oldsite2") |
| + fx.srv.claimApp(context.Background(), acct, "oldsite2", "paul@keymail.dev", "", true, |
| + regionReq{explicit: "uk", hasExplicit: true}) |
| + if r, src, _ := fx.st.AppRegion(OpsAccountID, "oldsite2"); r != "uk" || src != "chosen" { |
| + t.Fatalf("adoption with an explicit choice = %q %q", r, src) |
| + } |
| +} |
| + |
| +// A service principal is a server: its own address is never located. |
| +func TestResolveNewAppRegionIgnoresIPWithoutAPerson(t *testing.T) { |
| + fx, _ := regionFixture(t) |
| + fx.srv.cfg.GeoIP = fakeLocator{"81.2.69.142": {51.5, -0.12}} |
| + r, src := fx.srv.resolveNewAppRegion(context.Background(), regionReq{}, false) |
| + if r != "" || src != "default" { |
| + t.Fatalf("no ip = %q %q", r, src) |
| + } |
| + r, src = fx.srv.resolveNewAppRegion(context.Background(), regionReq{ip: net.ParseIP("81.2.69.142")}, false) |
| + if r != "uk" || src != "ip" { |
| + t.Fatalf("person ip = %q %q", r, src) |
| + } |
| +} |
| + |
| +// Review Focus 5: a claim that fails after resolution writes no row and no |
| +// region log line. |
| +func TestFailedClaimWritesNoRegion(t *testing.T) { |
| + fx, token := regionFixture(t) |
| + createApp(t, fx, token, map[string]string{"name": "taken", "region": "uk"}) |
| + var logs strings.Builder |
| + restore := captureLog(&logs) |
| + status, _ := createApp(t, fx, token, map[string]string{"name": "taken", "region": "ie"}) |
| + restore() |
| + if status != http.StatusConflict { |
| + t.Fatalf("second claim = %d", status) |
| + } |
| + if r, _, _ := fx.st.AppRegion(OpsAccountID, "taken"); r != "uk" { |
| + t.Fatalf("a failed claim changed the region to %q", r) |
| + } |
| + if strings.Contains(logs.String(), "app region bab/taken") { |
| + t.Fatalf("a failed claim logged a region line: %s", logs.String()) |
| + } |
| +} |
| +``` |
| + |
| +Add a `captureLog` helper to `regionclaim_test.go` if the package has none |
| +(`grep -n 'func captureLog' internal/console/*_test.go`): |
| + |
| +```go |
| +func captureLog(w io.Writer) (restore func()) { |
| + prev, flags := log.Writer(), log.Flags() |
| + log.SetOutput(w) |
| + return func() { log.SetOutput(prev); log.SetFlags(flags) } |
| +} |
| +``` |
| + |
| +Importing `io` and `log` is needed only if you add the helper. |
| + |
| +- [ ] **Step 2: Run them to verify they fail** |
| + |
| +Run: `go test ./internal/console -run 'TestAPICreateApp|TestClaimAdoption|TestResolveNewAppRegion|TestFailedClaim' -count=1` |
| +Expected: FAIL to compile: `claimApp` has the wrong arity, and `regionReq` is |
| +undefined. |
| + |
| +- [ ] **Step 3: Implement `regionclaim.go`** |
| + |
| +```go |
| +package console |
| + |
| +import ( |
| + "context" |
| + "net" |
| + "net/http" |
| + "sort" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/geo" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// regionReq is what a create door hands claimApp: never a resolved region. |
| +// The guess is resolved INSIDE claimApp, after it knows whether this is an |
| +// adoption (spec §5.5, codex 2, codex R2-5). ip is request-local and only |
| +// ever set for a person's request; it is never logged or stored. |
| +type regionReq struct { |
| + explicit string |
| + hasExplicit bool |
| + tz string |
| + ip net.IP |
| +} |
| + |
| +// validateExplicitRegion checks a person's explicit region the way the |
| +// instance door checks one (spec §8.1): ValidRegion, then armed and |
| +// hosting unless the gate is dormant; the home's name normalizes to "". |
| +func (s *Server) validateExplicitRegion(ctx context.Context, raw string) (string, *doorRefusal) { |
| + code := s.homeRegionIn(strings.TrimSpace(raw)) |
| + if code == "" { |
| + return "", nil |
| + } |
| + caps, published := placements(ctx, s.cfg.Releases) |
| + if !provision.ValidRegion(code) { |
| + return "", &doorRefusal{http.StatusBadRequest, msgRegionUnknown(code, s.namedCodes(caps))} |
| + } |
| + if published && !(caps[code].Reachable && caps[code].Hosting) { |
| + return "", &doorRefusal{http.StatusBadRequest, msgRegionNotArmed(code, s.namedCodes(hostingCaps(caps)))} |
| + } |
| + return code, nil |
| +} |
| + |
| +// namedCodes lists the home's name plus every code in caps, sorted. |
| +func (s *Server) namedCodes(caps map[string]placementCapability) []string { |
| + set := map[string]bool{} |
| + if s.cfg.HomeRegion != "" { |
| + set[s.cfg.HomeRegion] = true |
| + } |
| + for code := range caps { |
| + if provision.ValidRegion(code) { |
| + set[code] = true |
| + } |
| + } |
| + out := make([]string, 0, len(set)) |
| + for c := range set { |
| + out = append(out, c) |
| + } |
| + sort.Strings(out) |
| + return out |
| +} |
| + |
| +func hostingCaps(caps map[string]placementCapability) map[string]placementCapability { |
| + out := map[string]placementCapability{} |
| + for code, c := range caps { |
| + if c.Reachable && c.Hosting { |
| + out[code] = c |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// resolveNewAppRegion is spec §5.5 and §5.6 for a NEW row. |
| +func (s *Server) resolveNewAppRegion(ctx context.Context, rq regionReq, adopting bool) (region, source string) { |
| + if !s.appRegionsOn() { |
| + return "", "default" |
| + } |
| + if rq.hasExplicit { |
| + return rq.explicit, "chosen" |
| + } |
| + if adopting { |
| + return "", "default" |
| + } |
| + files, err := readEdgesFilesStrict(ctx, s.cfg.Releases) |
| + if err != nil { |
| + return "", "default" |
| + } |
| + g, ok := guessRegion(regionCandidates(files, s.cfg.HomeRegion), func(string) bool { return true }, |
| + guessInput{TZ: rq.tz, IP: rq.ip}, s.cfg.GeoIP) |
| + if !ok { |
| + return "", "default" |
| + } |
| + return g.Code, g.Source |
| +} |
| + |
| +// placeOf names a record-vocabulary code: its AWS region's place, else the |
| +// code under its name. |
| +func (s *Server) placeOf(code string, cands []regionCandidate) string { |
| + c, _ := findCandidate(cands, code) |
| + return geo.PlaceName(c.AWSRegion, s.homeRegionOut(code)) |
| +} |
| + |
| +// personIP is the request's own address, for a person only (spec §6.2). |
| +func (s *Server) personIP(r *http.Request) net.IP { |
| + if p, ok := principalFrom(r.Context()); ok && p.Kind != PrincipalUser { |
| + return nil |
| + } |
| + return net.ParseIP(s.clientIP(r)) |
| +} |
| + |
| +func eligibleCount(cands []regionCandidate) int { |
| + n := 0 |
| + for _, c := range cands { |
| + if c.Eligible { |
| + n++ |
| + } |
| + } |
| + return n |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Thread `regionReq` through `claimApp`** |
| + |
| +Change the signature, and replace the `CreateAppIn` call at `server.go:3550`: |
| + |
| +```go |
| +func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool, rq regionReq) (outcome claimOutcome, placed string, held *apexHold) { |
| +``` |
| + |
| +```go |
| + region, source := s.resolveNewAppRegion(ctx, rq, adoptingExistingApp) |
| + created, err := s.store.CreateAppInRegion(acct.ID, name, addr, region, source, time.Now()) |
| + if err != nil { |
| + return claimCreateFailed, "", nil |
| + } |
| + if !created { // lost a claim race after the checks |
| + return claimTaken, "", nil |
| + } |
| + if s.appRegionsOn() { |
| + log.Printf("console: app region %s/%s: %s (%s)", acct.Sqid, name, s.homeRegionOut(region), source) |
| + } |
| +``` |
| + |
| +The region is resolved here, after every refusal above it, so a refused |
| +claim resolves nothing and logs nothing (Review Focus 5). Update the existing |
| +callers and tests by adding `, regionReq{}` as the last argument: |
| +`server.go:3626`, `apps_claim_test.go:118,134,150,165` and |
| +`apicli_test.go:2504`. |
| + |
| +- [ ] **Step 5: Extend `handleAPICreateApp`** |
| + |
| +Add the fields to the request struct: |
| + |
| +```go |
| + Region string `json:"region"` |
| + Timezone string `json:"timezone"` |
| +``` |
| + |
| +After the `place` validation block and before `ownerClaim`: |
| + |
| +```go |
| + rq := regionReq{tz: req.Timezone, ip: s.personIP(r)} |
| + if strings.TrimSpace(req.Region) != "" { |
| + code, ref := s.validateExplicitRegion(r.Context(), req.Region) |
| + if ref != nil { |
| + jsonError(w, ref.Status, ref.Msg) |
| + return |
| + } |
| + rq.explicit, rq.hasExplicit = code, true |
| + } |
| +``` |
| + |
| +Pass `rq` as the last argument of `claimApp`. In the `claimOK` branch, read |
| +the region back and add it to the response map: |
| + |
| +```go |
| + gotRegion, gotSource, err := s.store.AppRegion(acct.ID, name) |
| + if err != nil { |
| + log.Printf("console: claim %s/%s: read back region: %v", acct.Sqid, name, err) |
| + } |
| + files, _ := readEdgesFilesStrict(r.Context(), s.cfg.Releases) |
| + cands := regionCandidates(files, s.cfg.HomeRegion) |
| +``` |
| + |
| +```go |
| + "region": s.homeRegionOut(gotRegion), |
| + "region_name": s.placeOf(gotRegion, cands), |
| + "region_source": gotSource, |
| + "regions_available": eligibleCount(cands), |
| +``` |
| + |
| +When `files` failed to read, `cands` holds only the home, so |
| +`regions_available` is 1 and the CLI stays silent. |
| + |
| +- [ ] **Step 6: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/console -count=1` |
| +Expected: PASS, including every existing claim test. |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +git add internal/console/regionclaim.go internal/console/regionclaim_test.go internal/console/server.go internal/console/apicli.go internal/console/apps_claim_test.go internal/console/apicli_test.go |
| +git commit -m "console: resolve the app region inside claimApp; API create takes region and timezone (nearest-region §5.5, §8.1, §9.1) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 13: The new-app page picker, POST and browser guess route |
| + |
| +**Files:** |
| +- Create: `internal/console/regionpicker.go` |
| +- Modify: `internal/console/server.go` (`page` struct `:1350`, `handleNewAppForm` `:3586`, `handleNewApp` `:3607`, routes `:1152-1153`) |
| +- Modify: `internal/console/templates/app_new.html` |
| +- Test: `internal/console/regionpicker_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: Tasks 6, 10, 11 and 12. |
| +- Produces: |
| + |
| +```go |
| +type regionOption struct{ Value, Label string } |
| +type regionPickerView struct{ Auto string; Options []regionOption; Attribution bool } |
| +func (s *Server) newAppPicker(ctx context.Context, ip net.IP) *regionPickerView // nil = no picker |
| +func (s *Server) handleNewAppRegion(w http.ResponseWriter, r *http.Request) // POST /a/{sqid}/apps/new/region |
| +const autoRegion = "@auto" |
| +``` |
| + |
| +The `page` struct gains the field `RegionPicker *regionPickerView`. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +// internal/console/regionpicker_test.go |
| +package console |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestNewAppPageWithoutPickerIsUnchanged(t *testing.T) { |
| + fx := newConsoleFixture(t) // switch off |
| + c := signInPaul(t, fx) |
| + _, _, body := get(t, c, fx.u("/apps/new")) |
| + if strings.Contains(body, `name="region"`) { |
| + t.Fatal("switch off rendered a region picker") |
| + } |
| +} |
| + |
| +func TestNewAppPickerAndAutoChoice(t *testing.T) { |
| + fx, _ := regionFixture(t) |
| + c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-picker") |
| + _, _, body := get(t, c, fx.u("/apps/new")) |
| + for _, want := range []string{`name="region"`, `value="@auto"`, `value="uk"`, `value="ie"`, "UK · London", regionCopy.PickerLabel} { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("picker missing %q", want) |
| + } |
| + } |
| + // @auto with a London timezone resolves server-side. |
| + postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"auto1"}, "region": {"@auto"}, "tz": {"Europe/London"}}) |
| + if r, src, _ := fx.st.AppRegion(OpsAccountID, "auto1"); r != "uk" || src != "timezone" { |
| + t.Fatalf("@auto = %q %q", r, src) |
| + } |
| + // A code is a choice. |
| + postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"chose"}, "region": {"ie"}, "tz": {"Europe/London"}}) |
| + if r, src, _ := fx.st.AppRegion(OpsAccountID, "chose"); r != "" || src != "chosen" { |
| + t.Fatalf("explicit ie = %q %q", r, src) |
| + } |
| +} |
| + |
| +// A region and a home both NAMED "auto" stay distinguishable from @auto |
| +// (codex R2-3). |
| +func TestRegionNamedAutoIsNotTheSentinel(t *testing.T) { |
| + fx, _ := regionFixture(t) |
| + fx.srv.cfg.HomeRegion = "auto" |
| + c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-auto-name") |
| + postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"homeauto"}, "region": {"auto"}}) |
| + if r, src, _ := fx.st.AppRegion(OpsAccountID, "homeauto"); r != "" || src != "chosen" { |
| + t.Fatalf("explicit home named auto = %q %q, want a choice of the home", r, src) |
| + } |
| +} |
| + |
| +// Review Focus 3: a region that became ineligible between render and POST. |
| +func TestNewAppPostWithANoLongerEligibleRegion(t *testing.T) { |
| + fx, _ := regionFixture(t) |
| + c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-stale") |
| + get(t, c, fx.u("/apps/new")) |
| + putEdges(t, fx, map[string]string{"uk-box": auPoolOnly}) // uk-box's file now says something else |
| + status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"stale"}, "region": {"uk"}}) |
| + if status != http.StatusSeeOther || loc != fx.path("/apps/new") { |
| + t.Fatalf("stale region = %d %q, want back to the form", status, loc) |
| + } |
| + if _, _, body := get(t, c, fx.u("/apps/new")); !strings.Contains(body, regionCopy.Unavailable) { |
| + t.Fatal("no C5 flash") |
| + } |
| + if has, _ := fx.st.HasAppIn(OpsAccountID, "stale"); has { |
| + t.Fatal("a refused region still created the app") |
| + } |
| +} |
| + |
| +func TestNewAppRegionRouteNeedsSessionAndCSRF(t *testing.T) { |
| + fx, _ := regionFixture(t) |
| + c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-route") |
| + status, _, body := postForm(t, c, fx.u("/apps/new/region"), url.Values{"csrf": {csrf}, "tz": {"Europe/London"}}) |
| + var res map[string]string |
| + json.Unmarshal([]byte(body), &res) |
| + if status != http.StatusOK || res["code"] != "uk" || res["source"] != "timezone" || res["name"] != "UK · London" { |
| + t.Fatalf("guess route = %d %s", status, body) |
| + } |
| + if status, _, _ := postForm(t, c, fx.u("/apps/new/region"), url.Values{"csrf": {"wrong"}, "tz": {"Europe/London"}}); status != http.StatusForbidden { |
| + t.Fatalf("bad csrf = %d, want 403", status) |
| + } |
| + if status, _, _ := postForm(t, newBrowser(t), fx.u("/apps/new/region"), url.Values{"tz": {"Europe/London"}}); status == http.StatusOK { |
| + t.Fatal("no session got a guess") |
| + } |
| +} |
| +``` |
| + |
| +`signInPaul` (`server_test.go:450`) returns just the signed-in client. |
| +`signInAs` (`svcpage_test.go:59`) also returns the CSRF token. |
| + |
| +- [ ] **Step 2: Run them to verify they fail** |
| + |
| +Run: `go test ./internal/console -run 'TestNewApp|TestRegionNamedAuto' -count=1` |
| +Expected: FAIL. |
| + |
| +- [ ] **Step 3: Implement `regionpicker.go`** |
| + |
| +```go |
| +package console |
| + |
| +import ( |
| + "context" |
| + "crypto/subtle" |
| + "fmt" |
| + "net" |
| + "net/http" |
| + "strings" |
| +) |
| + |
| +// autoRegion is the picker's "let the server guess" value. "@" is outside |
| +// the placement-code grammar ^[a-z0-9-]{0,16}$, so no real region — not |
| +// even one named "auto" — can collide with it (codex R2-3). |
| +const autoRegion = "@auto" |
| + |
| +type regionOption struct{ Value, Label string } |
| + |
| +type regionPickerView struct { |
| + Auto string // the @auto option's label (C3) |
| + Options []regionOption |
| + Attribution bool // C24, when the IP step is configured |
| +} |
| + |
| +// newAppPicker builds the new-app picker, or nil for no picker: switch |
| +// off, an edges read failure, or fewer than two eligible regions (§10). |
| +// The server's guess here is IP, then home: a render cannot see the |
| +// browser's timezone; the page script relabels the @auto option. |
| +func (s *Server) newAppPicker(ctx context.Context, ip net.IP) *regionPickerView { |
| + if !s.appRegionsOn() { |
| + return nil |
| + } |
| + files, err := readEdgesFilesStrict(ctx, s.cfg.Releases) |
| + if err != nil { |
| + return nil |
| + } |
| + cands := regionCandidates(files, s.cfg.HomeRegion) |
| + if eligibleCount(cands) < 2 { |
| + return nil |
| + } |
| + g, _ := guessRegion(cands, func(string) bool { return true }, guessInput{IP: ip}, s.cfg.GeoIP) |
| + v := ®ionPickerView{ |
| + Auto: fmt.Sprintf(regionCopy.AutoOption, s.placeOf(g.Code, cands)), |
| + Attribution: s.cfg.GeoIP != nil, |
| + } |
| + for _, c := range cands { |
| + if c.Eligible { |
| + v.Options = append(v.Options, regionOption{Value: s.homeRegionOut(c.Code), Label: s.placeOf(c.Code, cands)}) |
| + } |
| + } |
| + return v |
| +} |
| + |
| +// pickedRegion turns the form's region field into a regionReq. ok=false |
| +// means the submitted code is not an eligible region now (C5). |
| +func (s *Server) pickedRegion(ctx context.Context, r *http.Request) (regionReq, bool) { |
| + rq := regionReq{tz: r.FormValue("tz"), ip: s.personIP(r)} |
| + v := strings.TrimSpace(r.FormValue("region")) |
| + if v == "" || v == autoRegion { |
| + return rq, true |
| + } |
| + files, err := readEdgesFilesStrict(ctx, s.cfg.Releases) |
| + if err != nil { |
| + return rq, false |
| + } |
| + code := s.homeRegionIn(v) |
| + c, found := findCandidate(regionCandidates(files, s.cfg.HomeRegion), code) |
| + if !found || !c.Eligible { |
| + return rq, false |
| + } |
| + rq.explicit, rq.hasExplicit = code, true |
| + return rq, true |
| +} |
| + |
| +// handleNewAppRegion is the page script's guess (spec §9.5): a POST with |
| +// csrf and tz, session-gated, writing nothing. The hint never leaves the |
| +// request (§6.5). |
| +func (s *Server) handleNewAppRegion(w http.ResponseWriter, r *http.Request) { |
| + _, raw, _, _, ok := s.account(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + jsonError(w, http.StatusForbidden, "Cross-site request blocked.") |
| + return |
| + } |
| + files, err := readEdgesFilesStrict(r.Context(), s.cfg.Releases) |
| + cands := homeOnlyCandidates() |
| + if err == nil { |
| + cands = regionCandidates(files, s.cfg.HomeRegion) |
| + } |
| + g, ok := guessRegion(cands, func(string) bool { return true }, guessInput{TZ: r.FormValue("tz"), IP: s.personIP(r)}, s.cfg.GeoIP) |
| + if !ok { |
| + g = guessResult{"", "default"} |
| + } |
| + writeJSON(w, http.StatusOK, map[string]string{ |
| + "code": s.homeRegionOut(g.Code), "name": s.placeOf(g.Code, cands), "source": g.Source, |
| + }) |
| +} |
| +``` |
| + |
| +The CSRF refusal body reuses the console's existing words for this refusal, |
| +from `handleNewApp`. If copy review renames that string, use the new text in |
| +both places. |
| + |
| +- [ ] **Step 4: Wire the routes, the page field, the GET and the POST** |
| + |
| +Next to the existing `apps/new` routes at `server.go:1152-1153`: |
| + |
| +```go |
| + s.mux.HandleFunc("POST /a/{sqid}/apps/new/region", s.handleNewAppRegion) |
| +``` |
| + |
| +In `page`: `RegionPicker *regionPickerView`. |
| + |
| +In `handleNewAppForm`, before `render`: |
| + |
| +```go |
| + p.RegionPicker = s.newAppPicker(r.Context(), s.personIP(r)) |
| +``` |
| + |
| +In `handleNewApp`, after the CSRF check and the `name` read: |
| + |
| +```go |
| + rq, regionOK := s.pickedRegion(r.Context(), r) |
| + if !regionOK { |
| + fail(regionCopy.Unavailable) |
| + return |
| + } |
| + outcome, _, held := s.claimApp(r.Context(), acct, name, addr, "", role == RoleOwner, rq) |
| +``` |
| + |
| +In the `claimOK` branch, replace the audit line: |
| + |
| +```go |
| + detail := "created app " + name |
| + if s.appRegionsOn() { |
| + region, _, _ := s.store.AppRegion(acct.ID, name) |
| + files, _ := readEdgesFilesStrict(r.Context(), s.cfg.Releases) |
| + detail = fmt.Sprintf(regionCopy.AuditCreated, name, s.placeOf(region, regionCandidates(files, s.cfg.HomeRegion))) |
| + } |
| + s.audit(acct, name, addr, "create", detail) |
| +``` |
| + |
| +- [ ] **Step 5: Add the picker to `app_new.html`** |
| + |
| +Inside the `<form>`, after the name `<input>` and before the button: |
| + |
| +```html |
| + {{with .RegionPicker}} |
| + <label for="app-region">{{$.RegionCopy.PickerLabel}}</label> |
| + <select id="app-region" name="region"> |
| + <option value="@auto" selected>{{.Auto}}</option> |
| + {{range .Options}}<option value="{{.Value}}">{{.Label}}</option>{{end}} |
| + </select> |
| + <input type="hidden" name="tz" value=""> |
| + <p class="small">{{$.RegionCopy.PickerHelp}}</p> |
| + {{if .Attribution}}<p class="small"><a href="https://db-ip.com">{{$.RegionCopy.Attribution}}</a></p>{{end}} |
| + <script> |
| + // Enhancement only: with JS off the server's IP-or-home guess stands. |
| + // Fills the tz hint and relabels ONLY the @auto option; its value stays |
| + // "@auto" so the server still knows the person did not choose. |
| + (function () { |
| + var f = document.currentScript.closest("form"), sel = f.elements.region, tz = ""; |
| + try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ""; } catch (e) {} |
| + f.elements.tz.value = tz; |
| + if (!tz) return; |
| + var body = new URLSearchParams({ csrf: f.elements.csrf.value, tz: tz }); |
| + fetch("{{$.Base}}/apps/new/region", { method: "POST", body: body, credentials: "same-origin" }) |
| + .then(function (r) { return r.ok ? r.json() : null; }) |
| + .then(function (g) { |
| + if (!g || sel.value !== "@auto") return; |
| + sel.options[0].text = sel.options[0].text.replace(/^.*?( \([^)]*\))$/, g.name + "$1"); |
| + }) |
| + .catch(function () {}); |
| + })(); |
| + </script> |
| + {{end}} |
| +``` |
| + |
| +The relabel keeps C3's suffix and swaps in the guessed place. If copy review |
| +changes C3's shape, replace the regex with |
| +`sel.options[0].text = "{{printf $.RegionCopy.AutoOption "%PLACE%"}}".replace("%PLACE%", g.name);`. |
| + |
| +- [ ] **Step 6: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/console -count=1` |
| +Expected: PASS, including every existing new-app test (the switch-off form is |
| +unchanged). |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +git add internal/console/regionpicker.go internal/console/regionpicker_test.go internal/console/server.go internal/console/templates/app_new.html |
| +git commit -m "console: new-app region picker with @auto, browser guess route (nearest-region §10, §9.5) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 14: The instance create door: fill, eligibility and response |
| + |
| +**Files:** |
| +- Modify: `internal/console/apiinstances.go` (`apiRecordView` `:535`, `:763-799`, `:1066`) |
| +- Test: `internal/console/regionfill_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: Tasks 7, 8 and 10, `s.store.AppRegion`, and `findCandidate`. |
| +- Produces: `apiRecordView.RegionFrom string` (`json:"region_from,omitempty"`) |
| + and `apiRecordView.RegionName string` (`json:"region_name,omitempty"`). |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +// internal/console/regionfill_test.go |
| +package console |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +type fillRec struct { |
| + provision.Record |
| + RegionFrom string `json:"region_from"` |
| + RegionName string `json:"region_name"` |
| +} |
| + |
| +func fillFixture(t *testing.T, appRegion string) (*consoleFixture, string, string) { |
| + t.Helper() |
| + fx, token := instanceFixture(t, "code-fill-"+appRegion) |
| + fx.srv.cfg.HomeRegion = "ie" |
| + m := titoManifest() |
| + m.Pool, m.Regions = "", nil |
| + enableInstances(t, fx, "titogo", m) |
| + if _, err := fx.st.db.Exec(`UPDATE apps SET region = ?, region_source = 'chosen' WHERE app = 'titogo'`, appRegion); err != nil { |
| + t.Fatal(err) |
| + } |
| + putEdges(t, fx, map[string]string{"ie-box": homeBox, "uk-box": ukEligible}) |
| + return fx, token, instancesURL(fx, "bab", "titogo") |
| +} |
| + |
| +func create(t *testing.T, u, token, body string) (int, fillRec, string) { |
| + t.Helper() |
| + status, _, out := rawReq(t, http.MethodPost, u, token, body) |
| + var rec fillRec |
| + json.Unmarshal([]byte(out), &rec) |
| + return status, rec, out |
| +} |
| + |
| +func TestFillFromTheAppRegion(t *testing.T) { |
| + fx, token, u := fillFixture(t, "uk") |
| + _ = fx |
| + status, rec, out := create(t, u, token, `{"host":"a.go.tito.io"}`) |
| + if status != http.StatusCreated || rec.Region != "uk" || rec.RegionFrom != "app" || rec.RegionName != "UK · London" { |
| + t.Fatalf("fill = %d %s", status, out) |
| + } |
| + _, rec, _ = create(t, u, token, `{"host":"b.go.tito.io","region":"ie"}`) |
| + if rec.Region != "" || rec.RegionFrom != "request" { |
| + t.Fatalf("explicit home = %+v", rec) |
| + } |
| + for _, body := range []string{ |
| + `{"host":"c.go.tito.io","kind":"static"}`, |
| + `{"host":"d.go.tito.io","kind":"service","addrs":["10.0.0.4:8080"]}`, |
| + } { |
| + if _, rec, out := create(t, u, token, body); rec.Region != "" { |
| + t.Errorf("%s was filled: %s", body, out) |
| + } |
| + } |
| + status, _, out = create(t, u, token, `{"host":"e.go.tito.io","role":"replica"}`) |
| + if status != http.StatusBadRequest || !strings.Contains(out, "role replica needs a placement") { |
| + t.Errorf("replica without a region = %d %s, want the existing refusal word for word", status, out) |
| + } |
| +} |
| + |
| +// App region "" is byte-identical to the pre-change door. |
| +func TestFillWithAHomeAppRegionChangesNothing(t *testing.T) { |
| + _, token, u := fillFixture(t, "") |
| + _, rec, _ := create(t, u, token, `{"host":"a.go.tito.io"}`) |
| + if rec.Region != "" || rec.RegionFrom != "" { |
| + t.Fatalf("home app = %+v", rec) |
| + } |
| +} |
| + |
| +// codex R2-2: dormancy keeps explicit requests, never app-derived ones. |
| +func TestAppDerivedNeedsPositiveEligibility(t *testing.T) { |
| + fx, token, u := fillFixture(t, "uk") |
| + if err := fx.rel.Delete(t.Context(), "control/edges/uk-box.json"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.rel.Delete(t.Context(), "control/edges/ie-box.json"); err != nil { |
| + t.Fatal(err) |
| + } |
| + // No placement published at all: explicit uk passes as today... |
| + if status, _, out := create(t, u, token, `{"host":"x.go.tito.io","region":"uk"}`); status != http.StatusCreated { |
| + t.Fatalf("explicit uk under dormancy = %d %s", status, out) |
| + } |
| + // ...app-derived uk does not. |
| + status, _, out := create(t, u, token, `{"host":"y.go.tito.io"}`) |
| + if status != http.StatusBadRequest || !strings.Contains(out, reasonText("absent")) { |
| + t.Fatalf("app-derived uk under dormancy = %d %s, want C16 absent", status, out) |
| + } |
| +} |
| + |
| +func TestAppDerivedEdgesReadFailureIs503(t *testing.T) { |
| + fx, token, u := fillFixture(t, "uk") |
| + fx.srv.cfg.Releases = failingList{fx.rel} |
| + status, _, out := create(t, u, token, `{"host":"y.go.tito.io"}`) |
| + if status != http.StatusServiceUnavailable || !strings.Contains(out, msgAppRegionUnconfirmed("uk")) { |
| + t.Fatalf("edges failure = %d %s, want 503 C28", status, out) |
| + } |
| +} |
| + |
| +func TestAppDerivedRefusals(t *testing.T) { |
| + fx, token, u := fillFixture(t, "uk") |
| + m := titoManifest() |
| + m.Pool, m.Regions = "", []string{"au"} |
| + enableInstances(t, fx, "titogo", m) |
| + if status, _, out := create(t, u, token, `{"host":"y.go.tito.io"}`); status != http.StatusBadRequest || |
| + !strings.Contains(out, msgAppRegionNotAllowed("uk", []string{"au"})) { |
| + t.Fatalf("allow-list = %d %s, want C17", status, out) |
| + } |
| + m.Regions = nil |
| + enableInstances(t, fx, "titogo", m) |
| + if status, _, out := create(t, u, token, `{"host":"z.go.tito.io","replicas":["uk"]}`); status != http.StatusBadRequest || |
| + !strings.Contains(out, msgReplicaOverlap("uk")) { |
| + t.Fatalf("replica overlap = %d %s, want C26", status, out) |
| + } |
| +} |
| + |
| +// The switch off never breaks a promise already made (§5.6). |
| +func TestFillRunsWithTheSwitchOff(t *testing.T) { |
| + fx, token, u := fillFixture(t, "uk") |
| + fx.srv.cfg.AppRegions = false |
| + if _, rec, _ := create(t, u, token, `{"host":"a.go.tito.io"}`); rec.Region != "uk" { |
| + t.Fatalf("switch off = %+v, want the stored uk", rec) |
| + } |
| +} |
| +``` |
| + |
| +`fx.st.db` is the `Store`'s own `*sql.DB` field, reachable because the test |
| +is in package `console`. |
| + |
| +- [ ] **Step 2: Run them to verify they fail** |
| + |
| +Run: `go test ./internal/console -run 'TestFill|TestAppDerived' -count=1` |
| +Expected: FAIL. |
| + |
| +- [ ] **Step 3: Implement the fill, replacing `apiinstances.go:772`** |
| + |
| +```go |
| + // The app's region is the default for a create that names none |
| + // (nearest-region spec §7, R2). It runs BEFORE every check an explicit |
| + // region meets, so a defaulted region meets them all — and it never |
| + // runs for a fleet-placed app, a pooled manifest, a replica (which must |
| + // name its placement), or anything but a socket-backed instance. |
| + raw := strings.TrimSpace(req.Region) |
| + regionFrom := "" |
| + if raw != "" { |
| + regionFrom = "request" |
| + } |
| + if raw == "" && placed == "" && m.Pool == "" && strings.TrimSpace(req.Role) == "" && |
| + (provision.Record{Kind: strings.TrimSpace(req.Kind), Addrs: req.Addrs}).DerivesPaths() { |
| + appRegion, _, err := s.store.AppRegion(acct.ID, app) |
| + if err != nil { |
| + log.Printf("console: api instances create %s/%s: read app region: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, msgAppRegionUnreadable) |
| + return |
| + } |
| + if appRegion != "" { |
| + raw, regionFrom = appRegion, "app" |
| + } |
| + } |
| + if placed != "" { |
| + regionFrom = "fleet" |
| + } |
| + region := s.homeRegionIn(raw) |
| + // An APP-DERIVED region needs positive eligibility and never rides the |
| + // dormant gate below (codex R2-2): an explicit request keeps today's |
| + // gate, an automatic default must be proven. |
| + if regionFrom == "app" { |
| + files, err := readEdgesFilesStrict(ctx, s.cfg.Releases) |
| + if err != nil { |
| + log.Printf("console: api instances create %s/%s: read edges: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusServiceUnavailable, msgAppRegionUnconfirmed(s.homeRegionOut(region))) |
| + return |
| + } |
| + cands := regionCandidates(files, s.cfg.HomeRegion) |
| + reason := "absent" |
| + if c, ok := findCandidate(cands, region); ok { |
| + reason = c.unavailableReason() |
| + } |
| + if reason != "" { |
| + var can []string |
| + for _, c := range cands { |
| + if c.Eligible { |
| + can = append(can, s.homeRegionOut(c.Code)) |
| + } |
| + } |
| + jsonError(w, http.StatusBadRequest, msgAppRegionUnavailable(s.homeRegionOut(region), reason, can)) |
| + return |
| + } |
| + if !instanceRegionAllowed(m.Regions, region) { |
| + jsonError(w, http.StatusBadRequest, msgAppRegionNotAllowed(s.homeRegionOut(region), m.Regions)) |
| + return |
| + } |
| + for _, rep := range req.Replicas { |
| + if s.homeRegionIn(strings.TrimSpace(rep)) == region { |
| + jsonError(w, http.StatusBadRequest, msgReplicaOverlap(s.homeRegionOut(region))) |
| + return |
| + } |
| + } |
| + } |
| +``` |
| + |
| +`req.Replicas` is the embedded `replicaFields.Replicas` (`apiinstances.go:560`). |
| + |
| +- [ ] **Step 4: Fill the response fields** |
| + |
| +In `apiRecordView`: |
| + |
| +```go |
| + // RegionFrom and RegionName say how this create chose its region and |
| + // what the place is called (nearest-region spec §7) — facts about this |
| + // request, not part of the record, like PrivateStoreBucket. |
| + RegionFrom string `json:"region_from,omitempty"` |
| + RegionName string `json:"region_name,omitempty"` |
| +``` |
| + |
| +At `:1066`, after `out := recordView(rec)`: |
| + |
| +```go |
| + out.RegionFrom = regionFrom |
| + if rec.Role == "" && placed == "" { |
| + out.RegionName = s.placeOf(rec.Region, regionCandidates(readEdgesFiles(ctx, s.cfg.Releases), s.cfg.HomeRegion)) |
| + } |
| +``` |
| + |
| +- [ ] **Step 5: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/console -count=1` |
| +Expected: PASS, including `TestAPIInstanceCreate*` and the placement-door |
| +tests. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add internal/console/apiinstances.go internal/console/regionfill_test.go |
| +git commit -m "console: instance door fills the app region, proves app-derived eligibility (nearest-region §7) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 15: `app_default`, place names and the guess endpoint |
| + |
| +**Files:** |
| +- Modify: `internal/console/apiregions.go` |
| +- Modify: `internal/console/authcli.go:586` (register the guess route beside `/regions`) |
| +- Create: `internal/console/regiondefault.go` |
| +- Test: `internal/console/regiondefault_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: Tasks 7, 10, 11 and 12. |
| +- Produces: |
| + |
| +```go |
| +type appDefaultView struct { |
| + Region string `json:"region"` |
| + Name string `json:"name"` |
| + Source string `json:"source"` |
| + Effective string `json:"effective"` |
| + Available *bool `json:"available,omitempty"` |
| + Reason string `json:"reason,omitempty"` |
| +} |
| +func (s *Server) appDefault(ctx context.Context, acct *Account, app string) appDefaultView |
| +func (s *Server) handleAPIRegionGuess(w http.ResponseWriter, r *http.Request) // POST …/regions/guess |
| +``` |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +// internal/console/regiondefault_test.go |
| +package console |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +func regionsURL(fx *consoleFixture) string { |
| + return fx.ts.URL + "/api/cli/apps/bab/titogo/regions" |
| +} |
| + |
| +func TestRegionsKeepsLegacyFieldsAndAddsAppDefault(t *testing.T) { |
| + fx, token, _ := fillFixture(t, "uk") |
| + status, _, out := rawReq(t, http.MethodGet, regionsURL(fx), token, "") |
| + var res struct { |
| + Regions []struct { |
| + Code, AWSRegion, Name string |
| + Armed bool |
| + } `json:"regions"` |
| + Default string `json:"default"` |
| + AppDefault appDefaultView `json:"app_default"` |
| + } |
| + json.Unmarshal([]byte(out), &res) |
| + if status != http.StatusOK || res.Default != "ie" { |
| + t.Fatalf("legacy default = %d %s", status, out) |
| + } |
| + if res.AppDefault.Region != "uk" || res.AppDefault.Effective != "uk" || res.AppDefault.Available == nil || |
| + !*res.AppDefault.Available || res.AppDefault.Name != "UK · London" { |
| + t.Fatalf("app_default = %+v", res.AppDefault) |
| + } |
| + if !strings.Contains(out, `"aws_region":"eu-west-2"`) || !strings.Contains(out, `"name":"UK · London"`) { |
| + t.Fatalf("entries lack names: %s", out) |
| + } |
| +} |
| + |
| +func TestAppDefaultReasons(t *testing.T) { |
| + fx, _, _ := fillFixture(t, "uk") |
| + acct := &Account{ID: OpsAccountID, Sqid: "bab"} |
| + ctx := t.Context() |
| + fx.rel.Delete(ctx, "control/edges/uk-box.json") |
| + if d := fx.srv.appDefault(ctx, acct, "titogo"); d.Reason != "absent" || d.Available == nil || *d.Available || d.Effective != "uk" { |
| + t.Fatalf("absent = %+v (a code no box publishes is still reported)", d) |
| + } |
| + m := titoManifest() |
| + m.Pool, m.Regions = "tito", nil |
| + enableInstances(t, fx, "titogo", m) |
| + if d := fx.srv.appDefault(ctx, acct, "titogo"); d.Reason != "pooled" || d.Effective != "ie" || d.Region != "uk" { |
| + t.Fatalf("pooled = %+v", d) |
| + } |
| + m.Pool = "" |
| + enableInstances(t, fx, "titogo", m) |
| + fx.st.SetAppPlacement(OpsAccountID, "titogo", "fleet/acme") |
| + if d := fx.srv.appDefault(ctx, acct, "titogo"); d.Reason != "fleet" || d.Effective != "fleet/acme" || d.Region != "uk" || d.Available != nil { |
| + t.Fatalf("fleet = %+v (codex R2-4)", d) |
| + } |
| +} |
| + |
| +func guessURL(fx *consoleFixture) string { return regionsURL(fx) + "/guess" } |
| + |
| +func TestRegionGuessEndpoint(t *testing.T) { |
| + fx, token, _ := fillFixture(t, "") |
| + status, _, out := rawReq(t, http.MethodPost, guessURL(fx), token, `{"tz":"Europe/London"}`) |
| + if status != http.StatusOK || !strings.Contains(out, `"code":"uk"`) || !strings.Contains(out, `"source":"timezone"`) { |
| + t.Fatalf("guess = %d %s", status, out) |
| + } |
| + // Review Focus 4: garbage hints are absent, never an error. |
| + big := strings.Repeat("a", 10<<10) |
| + status, _, out = rawReq(t, http.MethodPost, guessURL(fx), token, `{"ip":"not-an-ip","tz":"`+big+`"}`) |
| + if status != http.StatusOK || !strings.Contains(out, `"source":"default"`) { |
| + t.Fatalf("garbage = %d %s", status, out) |
| + } |
| + // Fleet-placed: null (codex R4-1). |
| + fx.st.SetAppPlacement(OpsAccountID, "titogo", "fleet/acme") |
| + if _, _, out := rawReq(t, http.MethodPost, guessURL(fx), token, `{"tz":"Europe/London"}`); !strings.Contains(out, `"guess":null`) { |
| + t.Fatalf("fleet guess = %s", out) |
| + } |
| +} |
| + |
| +func TestRegionGuessPooledIsNullAndUnreadableIs502(t *testing.T) { |
| + fx, token, _ := fillFixture(t, "") |
| + m := titoManifest() |
| + m.Pool = "tito" |
| + enableInstances(t, fx, "titogo", m) |
| + if _, _, out := rawReq(t, http.MethodPost, guessURL(fx), token, `{"tz":"Europe/London"}`); !strings.Contains(out, `"guess":null`) { |
| + t.Fatalf("pooled guess = %s", out) |
| + } |
| + fx.rel.Put(t.Context(), provision.ManifestKey("bab", "titogo"), []byte("{not json"), "") |
| + status, _, out := rawReq(t, http.MethodPost, guessURL(fx), token, `{"tz":"Europe/London"}`) |
| + if status != http.StatusBadGateway || !strings.Contains(out, msgPolicyUnreadable) { |
| + t.Fatalf("unreadable = %d %s, want 502 C27", status, out) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run them to verify they fail** |
| + |
| +Run: `go test ./internal/console -run 'TestRegionsKeeps|TestAppDefault|TestRegionGuess' -count=1` |
| +Expected: FAIL. |
| + |
| +- [ ] **Step 3: Implement `regiondefault.go`** |
| + |
| +```go |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net" |
| + "net/http" |
| + "strings" |
| +) |
| + |
| +// appDefaultView is §9.3's app_default: where a create naming no region |
| +// lands for THIS app, and whether it can land there now. region is the |
| +// STORED region (what deploy --region compares against); effective is |
| +// where a create actually lands — the fleet itself for a placed app, |
| +// never the platform home (codex R2-4). |
| +type appDefaultView struct { |
| + Region string `json:"region"` |
| + Name string `json:"name"` |
| + Source string `json:"source"` |
| + Effective string `json:"effective"` |
| + Available *bool `json:"available,omitempty"` |
| + Reason string `json:"reason,omitempty"` |
| +} |
| + |
| +func boolp(b bool) *bool { return &b } |
| + |
| +func (s *Server) appDefault(ctx context.Context, acct *Account, app string) appDefaultView { |
| + stored, source, err := s.store.AppRegion(acct.ID, app) |
| + files, ferr := readEdgesFilesStrict(ctx, s.cfg.Releases) |
| + cands := homeOnlyCandidates() |
| + if ferr == nil { |
| + cands = regionCandidates(files, s.cfg.HomeRegion) |
| + } |
| + v := appDefaultView{Region: s.homeRegionOut(stored), Name: s.placeOf(stored, cands), Source: source} |
| + if err != nil { |
| + v.Reason = "policy-unreadable" |
| + return v |
| + } |
| + pol := s.appPolicy(ctx, acct, app) |
| + switch { |
| + case pol.State == policyUnreadable: |
| + v.Effective, v.Reason = s.homeRegionOut(stored), "policy-unreadable" |
| + return v |
| + case pol.Placed != "": |
| + v.Effective, v.Reason = pol.Placed, "fleet" |
| + return v |
| + case pol.pooled(): |
| + v.Effective, v.Reason, v.Available = s.homeRegionOut(""), "pooled", boolp(true) |
| + return v |
| + } |
| + v.Effective = s.homeRegionOut(stored) |
| + if ferr != nil { |
| + v.Reason = "edges-unreadable" |
| + return v |
| + } |
| + reason := "absent" |
| + if c, ok := findCandidate(cands, stored); ok { |
| + reason = c.unavailableReason() |
| + } |
| + if reason == "" && !pol.allows(stored) { |
| + reason = "not-allowed" |
| + } |
| + v.Reason, v.Available = reason, boolp(reason == "") |
| + return v |
| +} |
| + |
| +// handleAPIRegionGuess is POST …/regions/guess (spec §9.4). Hints come |
| +// only in the body, so no redirect can echo them (codex 8). A person's |
| +// request with no ip uses its own address; a service principal's never does. |
| +func (s *Server) handleAPIRegionGuess(w http.ResponseWriter, r *http.Request) { |
| + _, acct, app, ok := s.instanceGate(w, r) |
| + if !ok { |
| + return |
| + } |
| + var body struct { |
| + TZ string `json:"tz"` |
| + IP string `json:"ip"` |
| + } |
| + _ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&body) |
| + pol := s.appPolicy(r.Context(), acct, app) |
| + if pol.State == policyUnreadable { |
| + jsonError(w, http.StatusBadGateway, msgPolicyUnreadable) |
| + return |
| + } |
| + if pol.Placed != "" || pol.pooled() { |
| + writeJSON(w, http.StatusOK, map[string]any{"guess": nil}) |
| + return |
| + } |
| + ip := net.ParseIP(strings.TrimSpace(body.IP)) |
| + if ip == nil { |
| + ip = s.personIP(r) |
| + } |
| + files, err := readEdgesFilesStrict(r.Context(), s.cfg.Releases) |
| + cands := homeOnlyCandidates() |
| + if err == nil { |
| + cands = regionCandidates(files, s.cfg.HomeRegion) |
| + } |
| + g, ok := guessRegion(cands, pol.allows, guessInput{TZ: body.TZ, IP: ip}, s.cfg.GeoIP) |
| + if !ok { |
| + writeJSON(w, http.StatusOK, map[string]any{"guess": nil}) |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"guess": map[string]string{ |
| + "code": s.homeRegionOut(g.Code), "name": s.placeOf(g.Code, cands), "source": g.Source, |
| + }}) |
| +} |
| +``` |
| + |
| +A decode error (malformed JSON) leaves `body` partly filled, which is the |
| +"garbage is absent" rule from Review Focus 4. |
| + |
| +- [ ] **Step 4: Extend `handleAPIRegions` and register the route** |
| + |
| +In `apiregions.go`, give `region` the new fields, and fill them from the |
| +edges files: |
| + |
| +```go |
| + type region struct { |
| + Code string `json:"code"` |
| + Armed bool `json:"armed"` |
| + AWSRegion string `json:"aws_region,omitempty"` |
| + Name string `json:"name,omitempty"` |
| + } |
| + set, _ := armedPlacements(r.Context(), s.cfg.Releases) |
| + cands := regionCandidates(readEdgesFiles(r.Context(), s.cfg.Releases), s.cfg.HomeRegion) |
| + named := func(code string) region { |
| + c, _ := findCandidate(cands, s.homeRegionIn(code)) |
| + return region{Code: code, AWSRegion: c.AWSRegion, Name: s.placeOf(s.homeRegionIn(code), cands)} |
| + } |
| + out := make([]region, 0, len(set)+1) |
| + if s.cfg.HomeRegion != "" { |
| + h := named(s.cfg.HomeRegion) |
| + h.Armed = true |
| + out = append(out, h) |
| + delete(set, s.cfg.HomeRegion) |
| + } |
| + for code, armed := range set { |
| + e := named(code) |
| + e.Armed = armed |
| + out = append(out, e) |
| + } |
| + sort.Slice(out, func(i, j int) bool { return out[i].Code < out[j].Code }) |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "regions": out, "default": s.cfg.HomeRegion, |
| + "app_default": s.appDefault(r.Context(), acct, app), |
| + }) |
| +``` |
| + |
| +Change the gate line to `_, acct, app, ok := s.instanceGate(w, r)`. In |
| +`authcli.go`, beside the `/regions` route: |
| + |
| +```go |
| + // The per-caller region guess (nearest-region spec §9.4): POST so the |
| + // hints never ride a URL a redirect could echo. |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/regions/guess", svcrole.Allow(svcrole.Instance), s.handleAPIRegionGuess) |
| +``` |
| + |
| +- [ ] **Step 5: Add the privacy assertion** |
| + |
| +Append to `regiondefault_test.go`: |
| + |
| +```go |
| +// codex 8: with a canonical-host redirect configured, a guess POST's hints |
| +// appear in no Location, no body, no log and no audit row. |
| +func TestRegionGuessHintsNeverLeakThroughARedirect(t *testing.T) { |
| + fx, token, _ := fillFixture(t, "") |
| + fx.srv.redirects = map[string]bool{"old.example": true} |
| + var logs strings.Builder |
| + restore := captureLog(&logs) |
| + defer restore() |
| + req, _ := http.NewRequest(http.MethodPost, guessURL(fx), strings.NewReader(`{"tz":"Pacific/Chatham","ip":"203.0.113.77"}`)) |
| + req.Host = "old.example" |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + res, err := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + body := make([]byte, 64<<10) |
| + n, _ := res.Body.Read(body) |
| + res.Body.Close() |
| + everything := res.Header.Get("Location") + string(body[:n]) + logs.String() |
| + rawReq(t, http.MethodPost, guessURL(fx), token, `{"tz":"Pacific/Chatham","ip":"203.0.113.77"}`) |
| + everything += logs.String() |
| + events, err := fx.st.Events(OpsAccountID, "titogo", 50) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, e := range events { |
| + everything += e.Detail + e.Message |
| + } |
| + for _, secret := range []string{"Chatham", "203.0.113.77"} { |
| + if strings.Contains(everything, secret) { |
| + t.Fatalf("hint %q leaked: %s", secret, everything) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +`fx.srv.redirects` is the normalized host set `ServeHTTP` reads |
| +(`server.go:723`, `:1306`). `Event` carries both `Detail` and `Message` |
| +(`store.go:2066`). |
| + |
| +- [ ] **Step 6: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/console -count=1` |
| +Expected: PASS, including the existing `/regions` tests. The legacy |
| +`regions` and `default` fields must be unchanged. |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +git add internal/console/regiondefault.go internal/console/regiondefault_test.go internal/console/apiregions.go internal/console/authcli.go |
| +git commit -m "console: app_default, place names, POST regions/guess (nearest-region §9.3, §9.4) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 16: The Instances tab lines |
| + |
| +**Files:** |
| +- Modify: `internal/console/server.go` (`page` struct, `handleAppInstances` `:2933-2965`) |
| +- Modify: `internal/console/templates/app_instances.html:60-78` |
| +- Test: `internal/console/regiondefault_test.go` (append) |
| + |
| +**Interfaces:** |
| +- Consumes: `s.appDefault` (Task 15) and `regionCopy` (Task 6). |
| +- Produces: `page.RegionLine string` and `page.RegionLineWarn bool`. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +func TestInstancesTabRegionLines(t *testing.T) { |
| + fx, _, _ := fillFixture(t, "uk") |
| + fx.srv.cfg.AppRegions = true |
| + c := signInPaul(t, fx) |
| + _, _, body := get(t, c, fx.u("/apps/titogo/instances")) |
| + if !strings.Contains(body, fmt.Sprintf(regionCopy.StartsIn, "UK · London")) { |
| + t.Fatalf("no C7 line") |
| + } |
| + fx.rel.Delete(t.Context(), "control/edges/uk-box.json") |
| + _, _, body = get(t, c, fx.u("/apps/titogo/instances")) |
| + // With no box publishing uk there is no AWS region to name it by, so |
| + // the place falls back to the bare code (spec §14). |
| + if !strings.Contains(body, fmt.Sprintf(regionCopy.CantStart, "uk")) { |
| + t.Fatalf("no C8 line when the region is absent (codex 7)") |
| + } |
| + fx.st.SetAppPlacement(OpsAccountID, "titogo", "fleet/acme") |
| + _, _, body = get(t, c, fx.u("/apps/titogo/instances")) |
| + if strings.Contains(body, "this app's region") { |
| + t.Fatal("a fleet-placed app showed a region line") |
| + } |
| +} |
| +``` |
| + |
| +Add `"fmt"` to the file's imports. Paul owns the ops account in the |
| +fixture, so he sees `titogo`, which `instanceFixture` claims there. |
| + |
| +- [ ] **Step 2: Run it to verify it fails** |
| + |
| +Run: `go test ./internal/console -run TestInstancesTabRegionLines -count=1` |
| +Expected: FAIL. |
| + |
| +- [ ] **Step 3: Implement it** |
| + |
| +Add to `page`: |
| + |
| +```go |
| + // RegionLine is the Instances tab's one-line app-region note (C7 or |
| + // C8, nearest-region spec §10); "" shows nothing. RegionLineWarn marks C8. |
| + RegionLine string |
| + RegionLineWarn bool |
| +``` |
| + |
| +In `handleAppInstances`, after the placement section: |
| + |
| +```go |
| + if d := s.appDefault(r.Context(), acct, p.AppName); d.Reason != "fleet" && d.Reason != "pooled" { |
| + files, _ := readEdgesFilesStrict(r.Context(), s.cfg.Releases) |
| + switch { |
| + case d.Available != nil && !*d.Available: |
| + p.RegionLine, p.RegionLineWarn = fmt.Sprintf(regionCopy.CantStart, d.Name), true |
| + case d.Region != s.cfg.HomeRegion || eligibleCount(regionCandidates(files, s.cfg.HomeRegion)) >= 2: |
| + p.RegionLine = fmt.Sprintf(regionCopy.StartsIn, d.Name) |
| + } |
| + } |
| +``` |
| + |
| +In `app_instances.html`, at the top of the Placement section after |
| +`<h2>Placement</h2>`: |
| + |
| +```html |
| +{{if .RegionLine}}<p{{if .RegionLineWarn}} class="banner error"{{end}}>{{.RegionLine}}</p>{{end}} |
| +``` |
| + |
| +- [ ] **Step 4: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/console -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/console/server.go internal/console/templates/app_instances.html internal/console/regiondefault_test.go |
| +git commit -m "console: Instances tab says where new instances start, or that they can't (nearest-region §10) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 17: Roll-forward discrepancy log |
| + |
| +**Files:** |
| +- Create: `internal/console/regiondrift.go` |
| +- Modify: `cmd/console/main.go` (call once after `NewServer`) |
| +- Test: `internal/console/regiondrift_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `Store.NonHomeAppRegions` (Task 8), `provision.ListRecords`, and |
| + `msgPlacementDiscrepancy` (Task 7). |
| +- Produces: `func (s *Server) LogPlacementDiscrepancies(ctx context.Context)`. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +// internal/console/regiondrift_test.go |
| +package console |
| + |
| +import ( |
| + "context" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// codex R2-6: shapes that never inherit the app default are not listed; |
| +// a no-region socket-backed instance created after the app is. |
| +func TestPlacementDiscrepancyLog(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.HomeRegion = "ie" |
| + created := time.Now().Add(-time.Hour) |
| + fx.st.CreateAppInRegion(OpsAccountID, "blog", "p@k.dev", "uk", "chosen", created) |
| + ctx := context.Background() |
| + put := func(r provision.Record) { |
| + r.Schema, r.App, r.Account, r.Channel, r.CreatedAt = 1, "blog", release.OpsAccount, "stable", time.Now() |
| + if err := provision.CreateRecord(ctx, fx.rel, r); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + put(provision.Record{Host: "blog.go.tito.io", Kind: "static"}) |
| + put(provision.Record{Host: "svc.go.tito.io", Kind: "service", Addrs: []string{"10.0.0.4:8080"}}) |
| + put(provision.Record{Host: "r.go.tito.io", Region: "au", Role: "replica"}) |
| + put(provision.Record{Host: "pooled.go.tito.io", Pool: "tito"}) |
| + put(provision.Record{Host: "uk.go.tito.io", Region: "uk"}) |
| + put(provision.Record{Host: "drift.go.tito.io"}) |
| + var logs strings.Builder |
| + restore := captureLog(&logs) |
| + fx.srv.LogPlacementDiscrepancies(ctx) |
| + restore() |
| + out := logs.String() |
| + if !strings.Contains(out, msgPlacementDiscrepancy("drift.go.tito.io", "blog", "uk")) { |
| + t.Fatalf("drift not listed: %s", out) |
| + } |
| + for _, quiet := range []string{"blog.go.tito.io", "svc.go.tito.io", "r.go.tito.io", "pooled.go.tito.io", "uk.go.tito.io"} { |
| + if strings.Contains(out, quiet+" of") { |
| + t.Errorf("%s listed", quiet) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +`provision.CreateRecord` is a plain create-only write (`provision.go:820`), |
| +so these fixture records need no manifest. |
| + |
| +- [ ] **Step 2: Run it to verify it fails** |
| + |
| +Run: `go test ./internal/console -run TestPlacementDiscrepancyLog -count=1` |
| +Expected: FAIL. |
| + |
| +- [ ] **Step 3: Implement `regiondrift.go`** |
| + |
| +```go |
| +package console |
| + |
| +import ( |
| + "context" |
| + "log" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// discrepancyScanCap bounds one app's record listing. ListRecords treats |
| +// max=0 as "list nothing", so the cap must be explicit. |
| +const discrepancyScanCap = 10000 |
| + |
| +// LogPlacementDiscrepancies is spec §5.7 step 4, run once at console |
| +// start. For each app holding a non-home region, it lists records with an |
| +// empty region created after the app — the shape a console rolled back |
| +// below the floor would have written. Shapes that never inherit the app |
| +// default are skipped (static, addr-backed, replica, pooled, fleet). A |
| +// deliberate home override looks the same and the record keeps no |
| +// provenance, so each line is a CANDIDATE for review, worded as one (C29). |
| +func (s *Server) LogPlacementDiscrepancies(ctx context.Context) { |
| + if s.cfg.Releases == nil { |
| + return |
| + } |
| + apps, err := s.store.NonHomeAppRegions() |
| + if err != nil { |
| + log.Printf("console: placement discrepancy check: %v", err) |
| + return |
| + } |
| + for _, a := range apps { |
| + recs, truncated, err := provision.ListRecords(ctx, s.cfg.Releases, a.Sqid, a.App, discrepancyScanCap) |
| + if err != nil { |
| + log.Printf("console: placement discrepancy check %s/%s: %v", a.Sqid, a.App, err) |
| + continue |
| + } |
| + if truncated { |
| + log.Printf("console: placement discrepancy check %s/%s: only the first %d records were checked", a.Sqid, a.App, discrepancyScanCap) |
| + } |
| + for _, r := range recs { |
| + if r.Region != "" || r.Role != "" || r.Pool != "" || !r.DerivesPaths() || r.CreatedAt.Before(a.CreatedAt) { |
| + continue |
| + } |
| + log.Print("console: " + msgPlacementDiscrepancy(r.Host, a.App, a.Region)) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Call it at startup** |
| + |
| +In `cmd/console/main.go`, after `srv` is built and before serving: |
| + |
| +```go |
| + // Spec §5.7: after any roll, list possible placement discrepancies for |
| + // operator review. Read-only; never blocks startup. |
| + go srv.LogPlacementDiscrepancies(context.Background()) |
| +``` |
| + |
| +- [ ] **Step 5: Run the tests to verify they pass** |
| + |
| +Run: `go test ./internal/console ./cmd/console -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add internal/console/regiondrift.go internal/console/regiondrift_test.go cmd/console/main.go |
| +git commit -m "console: list possible placement discrepancies on start (nearest-region §5.7) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 18: CLI copy (C9 to C15, C13a, C13b, C15b, C22, C25, C30; copy-review gate) |
| + |
| +**Files:** |
| +- Create: `region_copy.go` (root, package `main`) |
| +- Test: `region_copy_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: |
| + |
| +```go |
| +func regionCreatedLine(place, source string) string // C9 to C12 |
| +const regionNotRecordedOldConsole = "…" // C13a |
| +func regionNotAppliedSwitchOff(place string) string // C13b |
| +func instanceCreatedSuffix(channel, place string) string // C14: "channel X, PLACE" |
| +func deployRegionMismatch(app, place string) string // C15 |
| +func deployRegionUnchecked(app string, err error) string // C15b |
| +func canaryRegionMismatch(host, place string) string // C25 |
| +func canaryRegionOnFleet(app, fleet string) string // C30 |
| +const flagRegionAppsCreate, flagRegionDeploy, flagRegionCanary, flagRegionInstancesCreate = "…", "…", "…", "…" // C22 |
| +``` |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +// region_copy_test.go |
| +package main |
| + |
| +import ( |
| + "errors" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestRegionCLICopy(t *testing.T) { |
| + want := map[string]string{ |
| + "timezone": "Region: UK · London (from your timezone). Use --region to choose another.", |
| + "ip": "Region: UK · London (from your network location). Use --region to choose another.", |
| + "default": "Region: UK · London (the platform default). Use --region to choose another.", |
| + "chosen": "Region: UK · London.", |
| + } |
| + for src, w := range want { |
| + if got := regionCreatedLine("UK · London", src); got != w { |
| + t.Errorf("%s: %q, want %q", src, got, w) |
| + } |
| + } |
| + all := []string{ |
| + regionNotRecordedOldConsole, regionNotAppliedSwitchOff("Ireland · Dublin"), |
| + instanceCreatedSuffix("stable", "UK · London"), deployRegionMismatch("blog", "UK · London"), |
| + deployRegionUnchecked("blog", errors.New("timeout")), |
| + canaryRegionMismatch("c.go.tito.io", "UK · London"), canaryRegionOnFleet("blog", "acme"), |
| + flagRegionAppsCreate, flagRegionDeploy, flagRegionCanary, flagRegionInstancesCreate, |
| + } |
| + for _, s := range all { |
| + if s == "" || strings.Contains(s, "—") || strings.Contains(s, "%!") { |
| + t.Errorf("bad copy %q", s) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run it to verify it fails** |
| + |
| +Run: `go test . -run TestRegionCLICopy -count=1` |
| +Expected: FAIL. |
| + |
| +- [ ] **Step 3: Write the drafts, verbatim from spec §19** |
| + |
| +```go |
| +package main |
| + |
| +import "fmt" |
| + |
| +// The nearest-region CLI strings (spec §19). DRAFTS pending Paul's copy |
| +// review: change none of them without it. |
| + |
| +// C9 to C12, by source word. |
| +func regionCreatedLine(place, source string) string { |
| + switch source { |
| + case "timezone": |
| + return fmt.Sprintf("Region: %s (from your timezone). Use --region to choose another.", place) |
| + case "ip": |
| + return fmt.Sprintf("Region: %s (from your network location). Use --region to choose another.", place) |
| + case "chosen": |
| + return fmt.Sprintf("Region: %s.", place) |
| + } |
| + return fmt.Sprintf("Region: %s (the platform default). Use --region to choose another.", place) |
| +} |
| + |
| +// C13a |
| +const regionNotRecordedOldConsole = "This console does not record an app region yet, so --region was not applied." |
| + |
| +// C13b |
| +func regionNotAppliedSwitchOff(place string) string { |
| + return fmt.Sprintf("This console is not assigning app regions yet, so --region was not applied. This app's instances start in %s.", place) |
| +} |
| + |
| +// C14: the parenthesis content of "created instance {host} (...)". |
| +func instanceCreatedSuffix(channel, place string) string { |
| + return fmt.Sprintf("channel %s, %s", channel, place) |
| +} |
| + |
| +// C15 |
| +func deployRegionMismatch(app, place string) string { |
| + return fmt.Sprintf("%s already runs in %s. --region only applies when deploy creates an app. To place one instance elsewhere, use carlos instances create --region.", app, place) |
| +} |
| + |
| +// C15b: not in spec §19; added by this plan for the case where the |
| +// console cannot answer. Muted operator note, deploy continues. |
| +func deployRegionUnchecked(app string, err error) string { |
| + return fmt.Sprintf("could not read %s's region (%v), so --region was not checked.", app, err) |
| +} |
| + |
| +// C25 |
| +func canaryRegionMismatch(host, place string) string { |
| + return fmt.Sprintf("canary %s already runs in %s. --region applies only when canary creates the host. Run carlos canary remove first to start it somewhere else.", host, place) |
| +} |
| + |
| +// C30 |
| +func canaryRegionOnFleet(app, fleet string) string { |
| + return fmt.Sprintf("%s runs on your fleet %s, which decides where every instance runs, so --region does not apply. Run the command without --region.", app, fleet) |
| +} |
| + |
| +// C22: flag help. |
| +const ( |
| + flagRegionAppsCreate = "region this app's instances start in (default: the region nearest you)" |
| + flagRegionDeploy = "region for the app this deploy creates (default: the region nearest you)" |
| + flagRegionCanary = "region for the canary instance (default: the app's region)" |
| + flagRegionInstancesCreate = "region for this instance (default: the app's region, subject to the manifest's allow-list)" |
| +) |
| +``` |
| + |
| +- [ ] **Step 4: Run it to verify it passes** |
| + |
| +Run: `go test . -run TestRegionCLICopy -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add region_copy.go region_copy_test.go |
| +git commit -m "cli: region copy (drafts C9-C15, C22, C25, C30, pending review) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +- [ ] **Step 6: Copy-review gate** |
| + |
| +Invoke `copy-review` with every string above, each with its id and where it |
| +prints. Flag C15b as new (not drafted in the spec). Apply Paul's edits verbatim, update the test's `want` map, re-run |
| +step 4, and commit as `cli: region copy as approved`. **Do not land the |
| +branch before this closes.** |
| + |
| +--- |
| + |
| +### Task 19: Timezone detection |
| + |
| +**Files:** |
| +- Create: `timezone.go` (root) |
| +- Test: `timezone_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: |
| + - `func localTimezone(getenv func(string) string, readlink func(string) (string, error), readFile func(string) ([]byte, error)) string`; |
| + - `func detectTimezone() string`, which wraps `localTimezone` with the `os` |
| + functions. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +// timezone_test.go |
| +package main |
| + |
| +import ( |
| + "errors" |
| + "testing" |
| +) |
| + |
| +func TestLocalTimezone(t *testing.T) { |
| + noLink := func(string) (string, error) { return "", errors.New("no") } |
| + noFile := func(string) ([]byte, error) { return nil, errors.New("no") } |
| + env := func(v string) func(string) string { |
| + return func(k string) string { |
| + if k == "TZ" { |
| + return v |
| + } |
| + return "" |
| + } |
| + } |
| + cases := []struct { |
| + name string |
| + getenv func(string) string |
| + readlink func(string) (string, error) |
| + readFile func(string) ([]byte, error) |
| + want string |
| + }{ |
| + {"TZ plain", env("Europe/London"), noLink, noFile, "Europe/London"}, |
| + {"TZ colon", env(":Europe/London"), noLink, noFile, "Europe/London"}, |
| + {"TZ path", env("/usr/share/zoneinfo/Asia/Kolkata"), noLink, noFile, "Asia/Kolkata"}, |
| + {"localtime link (linux)", env(""), func(string) (string, error) { return "/usr/share/zoneinfo/Europe/Dublin", nil }, noFile, "Europe/Dublin"}, |
| + {"localtime link (macOS)", env(""), func(string) (string, error) { return "/var/db/timezone/zoneinfo/Australia/Sydney", nil }, noFile, "Australia/Sydney"}, |
| + {"etc/timezone", env(""), noLink, func(string) ([]byte, error) { return []byte("Europe/Paris\n"), nil }, "Europe/Paris"}, |
| + // Review Focus 1: real-machine noise is a clean name or nothing. |
| + {"etc/timezone padded", env(""), noLink, func(string) ([]byte, error) { return []byte(" America/New_York \r\n# trailing\n"), nil }, "America/New_York"}, |
| + {"TZ bare colon", env(":"), noLink, noFile, ""}, |
| + {"TZ localtime", env("localtime"), noLink, noFile, ""}, |
| + {"TZ posixrules path", env("/usr/share/zoneinfo/posixrules"), noLink, noFile, ""}, |
| + {"TZ POSIX rule string", env("EST5EDT,M3.2.0,M11.1.0"), noLink, noFile, ""}, |
| + {"nothing", env(""), noLink, noFile, ""}, |
| + } |
| + for _, c := range cases { |
| + if got := localTimezone(c.getenv, c.readlink, c.readFile); got != c.want { |
| + t.Errorf("%s: %q, want %q", c.name, got, c.want) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run them to verify they fail** |
| + |
| +Run: `go test . -run TestLocalTimezone -count=1` |
| +Expected: FAIL. |
| + |
| +- [ ] **Step 3: Implement `timezone.go`** |
| + |
| +```go |
| +package main |
| + |
| +import ( |
| + "os" |
| + "regexp" |
| + "strings" |
| +) |
| + |
| +// tzName is the shape of an IANA zone name the console will accept (spec |
| +// §6.2 step 1). Anything else is not sent: the console then falls back to |
| +// the request's IP, then the home. |
| +var tzName = regexp.MustCompile(`^[A-Za-z0-9_+\-]+(/[A-Za-z0-9_+\-]+){0,2}$`) |
| + |
| +var notAZone = map[string]bool{"localtime": true, "posixrules": true, "Local": true} |
| + |
| +// localTimezone finds this machine's IANA zone name, portably (no build |
| +// tags; the 20-cross gate builds it for darwin, windows and linux-arm64): |
| +// $TZ, else the /etc/localtime symlink target, else /etc/timezone, else "". |
| +// Windows has none of these, so only a valid $TZ is ever sent there. |
| +func localTimezone(getenv func(string) string, readlink func(string) (string, error), readFile func(string) ([]byte, error)) string { |
| + if tz := clean(strings.TrimPrefix(strings.TrimSpace(getenv("TZ")), ":")); tz != "" { |
| + return tz |
| + } |
| + if target, err := readlink("/etc/localtime"); err == nil { |
| + if tz := clean(target); tz != "" { |
| + return tz |
| + } |
| + } |
| + if b, err := readFile("/etc/timezone"); err == nil { |
| + line, _, _ := strings.Cut(string(b), "\n") |
| + return clean(line) |
| + } |
| + return "" |
| +} |
| + |
| +func clean(s string) string { |
| + s = strings.TrimSpace(s) |
| + if _, after, ok := strings.Cut(s, "zoneinfo/"); ok { |
| + s = after |
| + } |
| + if s == "" || strings.HasPrefix(s, "/") || notAZone[s] || len(s) > 64 || !tzName.MatchString(s) { |
| + return "" |
| + } |
| + return s |
| +} |
| + |
| +func detectTimezone() string { return localTimezone(os.Getenv, os.Readlink, os.ReadFile) } |
| +``` |
| + |
| +- [ ] **Step 4: Run them to verify they pass, and cross-compile** |
| + |
| +Run: `go test . -run TestLocalTimezone -count=1 && GOOS=windows go build . && GOOS=darwin go build .` |
| +Expected: PASS, and both builds succeed. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add timezone.go timezone_test.go |
| +git commit -m "cli: portable timezone detection (nearest-region §11) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 20: Create-app wire, `apps create --region` and the onboarding line |
| + |
| +**Files:** |
| +- Modify: `apimode.go:958-1015` (`apiCreateAppResult`, `createApp`) |
| +- Modify: `apps.go:442-510` (`runAppsCreate`) |
| +- Modify: `deploy.go:513-541` (`offerCreateApp`), `deploy.go:36-60` (`runDeploy` flags) |
| +- Modify: `apps_test.go:299-321` and every other `createApp(` call site (`grep -n 'createApp(' *.go`) |
| +- Test: `apps_region_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: Tasks 18 and 19. |
| +- Produces: |
| + - `func (s *apiSession) createApp(ctx context.Context, a apiAcct, app, place string, controlPlane bool, buildProbe, region, timezone string) (apiCreateAppResult, error)`; |
| + - new result fields `Region`, `RegionName`, `RegionSource string` and |
| + `RegionsAvailable int`; |
| + - `func printAppRegion(w io.Writer, res apiCreateAppResult, asked string)`. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +// apps_region_test.go |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| +) |
| + |
| +func TestCreateAppSendsRegionAndTimezone(t *testing.T) { |
| + var got map[string]any |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewDecoder(r.Body).Decode(&got) |
| + json.NewEncoder(w).Encode(map[string]any{"account": "a", "app": "blog", |
| + "region": "uk", "region_name": "UK · London", "region_source": "timezone", "regions_available": 3}) |
| + })) |
| + defer ts.Close() |
| + res, err := newTestSession(ts.URL).createApp(context.Background(), apiAcct{Seg: "a"}, "blog", "", false, "", "", "Europe/London") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got["timezone"] != "Europe/London" { |
| + t.Fatalf("body = %v", got) |
| + } |
| + if _, sent := got["region"]; sent { |
| + t.Fatal("region sent without --region") |
| + } |
| + if res.RegionName != "UK · London" || res.RegionsAvailable != 3 { |
| + t.Fatalf("result = %+v", res) |
| + } |
| +} |
| + |
| +func TestPrintAppRegion(t *testing.T) { |
| + cases := []struct { |
| + res apiCreateAppResult |
| + asked string |
| + want string |
| + }{ |
| + {apiCreateAppResult{Region: "uk", RegionName: "UK · London", RegionSource: "timezone", RegionsAvailable: 2}, "", regionCreatedLine("UK · London", "timezone") + "\n"}, |
| + {apiCreateAppResult{Region: "ie", RegionName: "Ireland · Dublin", RegionSource: "default", RegionsAvailable: 1}, "", ""}, |
| + {apiCreateAppResult{}, "uk", regionNotRecordedOldConsole + "\n"}, |
| + {apiCreateAppResult{Region: "ie", RegionName: "Ireland · Dublin", RegionSource: "default", RegionsAvailable: 2}, "uk", regionNotAppliedSwitchOff("Ireland · Dublin") + "\n"}, |
| + {apiCreateAppResult{}, "", ""}, |
| + } |
| + for i, c := range cases { |
| + var b bytes.Buffer |
| + printAppRegion(&b, c.res, c.asked) |
| + if b.String() != c.want { |
| + t.Errorf("case %d: %q, want %q", i, b.String(), c.want) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run them to verify they fail** |
| + |
| +Run: `go test . -run 'TestCreateAppSendsRegionAndTimezone|TestPrintAppRegion' -count=1` |
| +Expected: FAIL to compile. |
| + |
| +- [ ] **Step 3: Extend the wire** |
| + |
| +In `apiCreateAppResult`: |
| + |
| +```go |
| + // The app region the console STORED (nearest-region spec §9.1), under |
| + // its name. Absent from an older console, which the CLI reads as "no |
| + // app region recorded". |
| + Region string `json:"region"` |
| + RegionName string `json:"region_name"` |
| + RegionSource string `json:"region_source"` |
| + RegionsAvailable int `json:"regions_available"` |
| +``` |
| + |
| +Replace `createApp`'s signature and body struct: |
| + |
| +```go |
| +func (s *apiSession) createApp(ctx context.Context, a apiAcct, app, place string, controlPlane bool, buildProbe, region, timezone string) (apiCreateAppResult, error) { |
| + in := struct { |
| + Name string `json:"name"` |
| + Place string `json:"place,omitempty"` |
| + ControlPlane bool `json:"control_plane,omitempty"` |
| + BuildProbe string `json:"build_probe,omitempty"` |
| + Region string `json:"region,omitempty"` |
| + Timezone string `json:"timezone,omitempty"` |
| + }{app, place, controlPlane, buildProbe, region, timezone} |
| +``` |
| + |
| +Leave the rest of the function unchanged. Update every other call site with |
| +`, "", ""` (tests) or the real values (steps 4 and 5). |
| + |
| +- [ ] **Step 4: Add `printAppRegion` and wire `apps create`** |
| + |
| +In `region_copy.go`'s neighbour, a new file `apps_region.go`: |
| + |
| +```go |
| +package main |
| + |
| +import ( |
| + "fmt" |
| + "io" |
| +) |
| + |
| +// printAppRegion is spec §11's one line after "created app …": nothing on |
| +// a single-region deployment, C13a when an older console ignored --region, |
| +// C13b when a console with the switch off did, else C9 to C12 by source. |
| +func printAppRegion(w io.Writer, res apiCreateAppResult, asked string) { |
| + switch { |
| + case res.Region == "" && res.RegionSource == "": |
| + if asked != "" { |
| + fmt.Fprintln(w, regionNotRecordedOldConsole) |
| + } |
| + case asked != "" && res.Region != asked: |
| + fmt.Fprintln(w, regionNotAppliedSwitchOff(res.RegionName)) |
| + case res.RegionsAvailable >= 2: |
| + fmt.Fprintln(w, regionCreatedLine(res.RegionName, res.RegionSource)) |
| + } |
| +} |
| +``` |
| + |
| +In `runAppsCreate`, register the flag and pass it on: |
| + |
| +```go |
| + region := fs.String("region", "", flagRegionAppsCreate) |
| +``` |
| + |
| +```go |
| + res, err := sess.createApp(context.Background(), acct, strings.TrimSpace(*app), place2, *controlPlane, probe2, |
| + strings.TrimSpace(*region), detectTimezone()) |
| +``` |
| + |
| +After the existing "created app" line at `apps.go:502`: |
| + |
| +```go |
| + printAppRegion(stdout, res, strings.TrimSpace(*region)) |
| +``` |
| + |
| +Add `[--region <code>]` to the usage string at `apps.go:463`. |
| + |
| +- [ ] **Step 5: Wire `deploy`'s onboarding** |
| + |
| +In `runDeploy`'s flags: |
| + |
| +```go |
| + regionFlag := fs.String("region", "", flagRegionDeploy) |
| +``` |
| + |
| +Pass it to `offerCreateApp(ctx, sess, acct, na, strings.TrimSpace(*regionFlag))`. |
| +Change that function's signature to take `region string`, and its create call |
| +and output to: |
| + |
| +```go |
| + res, err := sess.createApp(ctx, acct, name, "", false, "", region, detectTimezone()) |
| + if err != nil { |
| + log.Fatalf("deploy: create app: %v", err) |
| + } |
| + fmt.Fprintf(stdout, "%s %s in account %s\n", colorFor(stdout).ok("created app"), res.App, res.Account) |
| + printAppRegion(stdout, res, region) |
| + return res.App |
| +``` |
| + |
| +Add `[--region <code>]` to `runDeploy`'s usage string. |
| + |
| +- [ ] **Step 6: Run the root package tests** |
| + |
| +Run: `go test . -count=1 -timeout 30m` |
| +Expected: PASS. The e2e tests shell out through `carlosTestEnv`, so nothing |
| +reaches a real console. |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +git add apimode.go apps.go apps_region.go apps_region_test.go deploy.go apps_test.go |
| +git commit -m "cli: apps create and deploy onboarding send a timezone, take --region, print the region line (nearest-region §11) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 21: `deploy --region` on an existing app |
| + |
| +**Files:** |
| +- Modify: `apimode.go` (add `appRegions`) |
| +- Modify: `deploy.go` (`runDeploy`, after the app is resolved and before anything ships) |
| +- Test: `deploy_region_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `deployRegionMismatch` (Task 18). |
| +- Produces: |
| + |
| +```go |
| +type apiAppDefault struct { |
| + Region, Name, Source, Effective, Reason string |
| + Available *bool |
| +} |
| +type apiRegionsResult struct { |
| + Default string `json:"default"` |
| + AppDefault *apiAppDefault `json:"app_default"` |
| +} |
| +func (s *apiSession) appRegions(ctx context.Context, a apiAcct, app string) (apiRegionsResult, error) |
| +func deployRegionPreflight(res apiRegionsResult, app, asked string) error // nil = proceed |
| +``` |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +// deploy_region_test.go |
| +package main |
| + |
| +import "testing" |
| + |
| +func TestDeployRegionPreflight(t *testing.T) { |
| + uk := &apiAppDefault{Region: "uk", Name: "UK · London", Effective: "uk"} |
| + fleet := &apiAppDefault{Region: "uk", Name: "UK · London", Effective: "fleet/acme", Reason: "fleet"} |
| + cases := []struct { |
| + res apiRegionsResult |
| + asked string |
| + wantErr bool |
| + }{ |
| + {apiRegionsResult{AppDefault: uk}, "", false}, |
| + {apiRegionsResult{AppDefault: uk}, "uk", false}, |
| + {apiRegionsResult{AppDefault: uk}, "ie", true}, |
| + {apiRegionsResult{AppDefault: nil}, "ie", false}, // an old console: skipped |
| + // codex R2-4: a fleet-placed app is judged by its STORED region. |
| + {apiRegionsResult{AppDefault: fleet}, "uk", false}, |
| + {apiRegionsResult{AppDefault: fleet}, "ie", true}, |
| + } |
| + for i, c := range cases { |
| + err := deployRegionPreflight(c.res, "blog", c.asked) |
| + if (err != nil) != c.wantErr { |
| + t.Errorf("case %d: err = %v, wantErr %v", i, err, c.wantErr) |
| + } |
| + if err != nil && err.Error() != deployRegionMismatch("blog", c.res.AppDefault.Name) { |
| + t.Errorf("case %d: message %q", i, err) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run it to verify it fails** |
| + |
| +Run: `go test . -run TestDeployRegionPreflight -count=1` |
| +Expected: FAIL. |
| + |
| +- [ ] **Step 3: Implement it** |
| + |
| +In `apimode.go`: |
| + |
| +```go |
| +// apiAppDefault and apiRegionsResult are the CLI's view of GET |
| +// …/regions (nearest-region spec §9.3). AppDefault is nil from a console |
| +// that predates it. |
| +type apiAppDefault struct { |
| + Region string `json:"region"` |
| + Name string `json:"name"` |
| + Source string `json:"source"` |
| + Effective string `json:"effective"` |
| + Reason string `json:"reason"` |
| + Available *bool `json:"available"` |
| +} |
| + |
| +type apiRegionsResult struct { |
| + Default string `json:"default"` |
| + AppDefault *apiAppDefault `json:"app_default"` |
| +} |
| + |
| +func (s *apiSession) appRegions(ctx context.Context, a apiAcct, app string) (apiRegionsResult, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, "/regions")) |
| + if err != nil { |
| + return apiRegionsResult{}, err |
| + } |
| + if status != http.StatusOK { |
| + return apiRegionsResult{}, apiErrorFromBody(status, body) |
| + } |
| + var out apiRegionsResult |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return apiRegionsResult{}, fmt.Errorf("regions: unreadable response: %w", err) |
| + } |
| + return out, nil |
| +} |
| +``` |
| + |
| +In `deploy.go`: |
| + |
| +```go |
| +// deployRegionPreflight is spec §11: --region on an EXISTING app compares |
| +// against the app's STORED region — silent if equal, refused before |
| +// anything ships if not. Skipped against a console with no app_default. |
| +func deployRegionPreflight(res apiRegionsResult, app, asked string) error { |
| + if asked == "" || res.AppDefault == nil || res.AppDefault.Region == asked { |
| + return nil |
| + } |
| + return errors.New(deployRegionMismatch(app, res.AppDefault.Name)) |
| +} |
| +``` |
| + |
| +In `runDeploy`, declare `createdNow := false` just before the |
| +`if *app == "" {` block, and set it in the onboarding case: |
| + |
| +```go |
| + case errors.As(aerr, &na) && interactiveTerminal(): |
| + *app = offerCreateApp(ctx, sess, acct, na, strings.TrimSpace(*regionFlag)) |
| + newly["app"] = *app |
| + createdNow = true |
| +``` |
| + |
| +Then, straight after that block closes and before `readCapabilities` and any |
| +ship: |
| + |
| +```go |
| + if r := strings.TrimSpace(*regionFlag); r != "" && !createdNow { |
| + res, err := sess.appRegions(ctx, acct, *app) |
| + if err != nil { |
| + mutedf(stdout, "%s\n", deployRegionUnchecked(*app, err)) |
| + } else if err := deployRegionPreflight(res, *app, r); err != nil { |
| + log.Fatalf("deploy: %v", err) |
| + } |
| + } |
| +``` |
| + |
| +- [ ] **Step 4: Run it to verify it passes** |
| + |
| +Run: `go test . -run 'TestDeployRegionPreflight|TestDeploy' -count=1 -timeout 30m` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add apimode.go deploy.go deploy_region_test.go |
| +git commit -m "cli: deploy --region on an existing app compares the stored region before shipping (nearest-region §11) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 22: `canary --region` and the instance output |
| + |
| +**Files:** |
| +- Modify: `canary.go:338-470` (`runCanaryDeploy`) |
| +- Modify: `instances_cli.go:288-330` (flag help, output) |
| +- Modify: `apimode.go` (`apiInstanceRecord`: add `RegionName`, `RegionFrom`) |
| +- Test: `canary_region_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: Tasks 18 and 21 (`appRegions`, `canaryRegionOnFleet`, |
| + `canaryRegionMismatch`, `instanceCreatedSuffix`). |
| +- Produces: |
| + - `func canaryRegionCheck(res apiRegionsResult, recs []apiInstanceRecord, app, host, asked string) error`; |
| + - `func instanceCreatedLine(rec apiInstanceRecord, envSuffix string) string`. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +// canary_region_test.go |
| +package main |
| + |
| +import "testing" |
| + |
| +func TestCanaryRegionCheck(t *testing.T) { |
| + plain := apiRegionsResult{Default: "ie", AppDefault: &apiAppDefault{Region: "uk", Effective: "uk", Name: "UK · London"}} |
| + fleet := apiRegionsResult{Default: "ie", AppDefault: &apiAppDefault{Region: "uk", Effective: "fleet/acme", Reason: "fleet"}} |
| + existingUK := []apiInstanceRecord{{Host: "c.go.tito.io", Region: "uk"}} |
| + existingHome := []apiInstanceRecord{{Host: "c.go.tito.io", Region: ""}} |
| + cases := []struct { |
| + name string |
| + res apiRegionsResult |
| + recs []apiInstanceRecord |
| + asked string |
| + want string |
| + }{ |
| + {"no flag", fleet, existingUK, "", ""}, |
| + {"new canary", plain, nil, "uk", ""}, |
| + {"existing, same", plain, existingUK, "uk", ""}, |
| + {"existing home read as its name", plain, existingHome, "ie", ""}, |
| + {"existing, different", plain, existingUK, "ie", canaryRegionMismatch("c.go.tito.io", "uk")}, |
| + // codex R4-2: refused identically before and after the canary exists. |
| + {"fleet, first run", fleet, nil, "uk", canaryRegionOnFleet("blog", "acme")}, |
| + {"fleet, repeat", fleet, existingUK, "uk", canaryRegionOnFleet("blog", "acme")}, |
| + } |
| + for _, c := range cases { |
| + err := canaryRegionCheck(c.res, c.recs, "blog", "c.go.tito.io", c.asked) |
| + got := "" |
| + if err != nil { |
| + got = err.Error() |
| + } |
| + if got != c.want { |
| + t.Errorf("%s: %q, want %q", c.name, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +func TestInstanceCreatedLine(t *testing.T) { |
| + r := apiInstanceRecord{Host: "a.go.tito.io", Channel: "stable", RegionName: "UK · London"} |
| + if got := instanceCreatedLine(r, ""); got != "a.go.tito.io ("+instanceCreatedSuffix("stable", "UK · London")+")" { |
| + t.Fatalf("got %q", got) |
| + } |
| + env := environmentSuffix("acme") |
| + if got := instanceCreatedLine(r, env); got != "a.go.tito.io ("+instanceCreatedSuffix("stable", "UK · London")+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) |
| + } |
| +} |
| +``` |
| + |
| +`apiInstanceRecord`'s host and channel field names must match the struct at |
| +`apimode.go:2840-2870`. If they differ, use the real names. |
| + |
| +- [ ] **Step 2: Run them to verify they fail** |
| + |
| +Run: `go test . -run 'TestCanaryRegionCheck|TestInstanceCreatedLine' -count=1` |
| +Expected: FAIL. |
| + |
| +- [ ] **Step 3: Implement the helpers** |
| + |
| +Add to `apiInstanceRecord`: |
| + |
| +```go |
| + RegionName string `json:"region_name,omitempty"` |
| + RegionFrom string `json:"region_from,omitempty"` |
| +``` |
| + |
| +In `canary.go`: |
| + |
| +```go |
| +// canaryRegionCheck is spec §11's canary --region rule, resolved BEFORE |
| +// the create/rechannel/ready branch so the answer never depends on whether |
| +// the canary exists yet (codex R4-2): a fleet-placed app refuses the flag |
| +// every time (C30); an existing canary in another region refuses (C25); |
| +// otherwise proceed. |
| +func canaryRegionCheck(res apiRegionsResult, recs []apiInstanceRecord, app, host, asked string) error { |
| + if asked == "" { |
| + return nil |
| + } |
| + if d := res.AppDefault; d != nil && strings.HasPrefix(d.Effective, "fleet/") { |
| + return errors.New(canaryRegionOnFleet(app, strings.TrimPrefix(d.Effective, "fleet/"))) |
| + } |
| + for _, r := range recs { |
| + if r.Host != host { |
| + continue |
| + } |
| + have := r.Region |
| + if have == "" { |
| + have = res.Default |
| + } |
| + if have != asked { |
| + return errors.New(canaryRegionMismatch(host, have)) |
| + } |
| + } |
| + return nil |
| +} |
| +``` |
| + |
| +In `instances_cli.go`: |
| + |
| +```go |
| +// 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. envSuffix is |
| +// environmentSuffix(rec.Environment), which has always rendered inside the |
| +// parenthesis. |
| +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) |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Wire the flags and the output** |
| + |
| +In `runCanaryDeploy`, register the flag: |
| + |
| +```go |
| + regionFlag := fs.String("region", "", flagRegionCanary) |
| +``` |
| + |
| +After `recs` is listed and before `canaryRecordPlan`: |
| + |
| +```go |
| + if asked := strings.TrimSpace(*regionFlag); asked != "" { |
| + res, err := sess.appRegions(ctx, acct, app2) |
| + if err != nil { |
| + log.Fatalf("canary: read %s's region: %v", app2, err) |
| + } |
| + if err := canaryRegionCheck(res, recs, app2, host, asked); err != nil { |
| + log.Fatalf("canary: %v", err) |
| + } |
| + } |
| +``` |
| + |
| +In the create branch, pass the flag: |
| +`sess.instanceCreate(ctx, acct, app2, host, strings.TrimSpace(*regionFlag), channel, recKind, env, nil, nil, nil)`. |
| +Print with the new helper: |
| + |
| +```go |
| + fmt.Fprintf(stdout, "%s %s\n", pal.ok("canary host"), instanceCreatedLine(rec, environmentSuffix(rec.Environment))) |
| +``` |
| + |
| +In `runInstancesCreate`, change the flag help to `flagRegionInstancesCreate`, |
| +and the output line to: |
| + |
| +```go |
| + fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("created instance"), |
| + instanceCreatedLine(rec, environmentSuffix(rec.Environment))) |
| +``` |
| + |
| +- [ ] **Step 5: Run the root tests** |
| + |
| +Run: `go test . -count=1 -timeout 30m` |
| +Expected: PASS, including `canary_test.go` and |
| +`instances_environment_test.go` byte-for-byte output assertions against a |
| +console that sends no `region_name`. |
| + |
| +- [ ] **Step 6: Cross-compile, then commit** |
| + |
| +Run: `GOOS=darwin go build . && GOOS=windows go build . && GOOS=linux GOARCH=arm64 go build ./...` |
| + |
| +```bash |
| +git add canary.go canary_region_test.go instances_cli.go apimode.go |
| +git commit -m "cli: canary --region with fleet precedence, place in instance output (nearest-region §11, codex R4-2) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 23: The region service-gate script |
| + |
| +**Files:** |
| +- Create: `scripts/region-service-gate.sh` |
| +- Test: `region_service_gate_test.go` (root package `main`, which runs with |
| + the repo root as its working directory; a test-only package under |
| + `scripts/` would be a directory with no non-test Go files) |
| + |
| +**Interfaces:** |
| +- Produces: `scripts/region-service-gate.sh <probe-host> <expected-version> [<edge-eip> ...]`. |
| + It exits 0 only when every request returns 2xx with |
| + `X-Carlos-Version == <expected-version>`. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +// region_service_gate_test.go |
| +package main |
| + |
| +import ( |
| + "net" |
| + "net/http" |
| + "net/http/httptest" |
| + "os/exec" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// The gate is spec §17 step 5 (codex R4-3): it must fail on a 404 (the |
| +// edge's unknown-host answer when backhaul has no mesh key) and on a |
| +// version mismatch, and pass only when every path serves the build. |
| +func TestRegionServiceGate(t *testing.T) { |
| + version := "abc1234" |
| + ok := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("X-Carlos-Version", version) |
| + })) |
| + defer ok.Close() |
| + unknown := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + http.NotFound(w, r) |
| + })) |
| + defer unknown.Close() |
| + addr := func(s *httptest.Server) string { h, _, _ := net.SplitHostPort(s.Listener.Addr().String()); return h } |
| + port := func(s *httptest.Server) string { _, p, _ := net.SplitHostPort(s.Listener.Addr().String()); return p } |
| + run := func(srv *httptest.Server, want string) (string, error) { |
| + cmd := exec.Command("./scripts/region-service-gate.sh", "probe.example", want, addr(srv)) |
| + cmd.Env = append(cmd.Environ(), "GATE_PORT="+port(srv), "GATE_INSECURE=1", "GATE_SKIP_PUBLIC=1") |
| + out, err := cmd.CombinedOutput() |
| + return string(out), err |
| + } |
| + if out, err := run(ok, version); err != nil { |
| + t.Fatalf("a serving edge failed the gate: %v %s", err, out) |
| + } |
| + if out, err := run(unknown, version); err == nil || !strings.Contains(out, "FAIL") { |
| + t.Fatalf("an unknown-host 404 passed the gate: %s", out) |
| + } |
| + if out, err := run(ok, "other"); err == nil || !strings.Contains(out, "FAIL") { |
| + t.Fatalf("a version mismatch passed the gate: %s", out) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run it to verify it fails** |
| + |
| +Run: `go test . -run TestRegionServiceGate -count=1` |
| +Expected: FAIL, because the script does not exist. |
| + |
| +- [ ] **Step 3: Write the script** |
| + |
| +```bash |
| +#!/usr/bin/env bash |
| +# scripts/region-service-gate.sh <probe-host> <expected-version> [<edge-eip> ...] |
| +# |
| +# Nearest-region spec §17 step 5 (codex R4-3): before CONSOLE_APP_REGIONS |
| +# goes on, every eligible region must serve a fresh probe instance through |
| +# the REAL public path. Run once per region's probe host: |
| +# - from outside, through public DNS as it stands (skip with GATE_SKIP_PUBLIC=1); |
| +# - through every armed edge that is NOT the instance's box, via --resolve. |
| +# Passes only on 2xx with X-Carlos-Version equal to the deployed build. |
| +# An unknown-host 404 (backhaul with no mesh key), a TLS failure or a |
| +# version mismatch fails. GATE_PORT and GATE_INSECURE exist for tests only. |
| +set -uo pipefail |
| +host="${1:?usage: region-service-gate.sh <probe-host> <expected-version> [<edge-eip> ...]}" |
| +want="${2:?usage: region-service-gate.sh <probe-host> <expected-version> [<edge-eip> ...]}" |
| +shift 2 |
| +port="${GATE_PORT:-443}" |
| +insecure="" |
| +[ "${GATE_INSECURE:-}" = 1 ] && insecure="-k" |
| +fail=0 |
| +check() { # <label> [curl args...] |
| + local label="$1"; shift |
| + local headers status version |
| + # $insecure is unquoted on purpose: empty must expand to nothing (an empty |
| + # array under set -u fails on macOS's bash 3.2). |
| + headers="$(curl -s $insecure -D - -o /dev/null --max-time 20 "$@" "https://$host:$port/")" || { |
| + echo "FAIL $label: request failed"; fail=1; return; } |
| + status="$(printf '%s' "$headers" | awk 'toupper($1) ~ /^HTTP/ {s=$2} END {print s}')" |
| + version="$(printf '%s' "$headers" | awk -F': *' 'tolower($1)=="x-carlos-version" {gsub(/\r/,"",$2); print $2}')" |
| + if [[ "$status" != 2* ]]; then echo "FAIL $label: status $status"; fail=1; return; fi |
| + if [ "$version" != "$want" ]; then echo "FAIL $label: X-Carlos-Version '$version', want '$want'"; fail=1; return; fi |
| + echo "ok $label: $status $version" |
| +} |
| +[ "${GATE_SKIP_PUBLIC:-}" = 1 ] || check "public DNS" |
| +for eip in "$@"; do |
| + check "via edge $eip" --resolve "$host:$port:$eip" |
| +done |
| +exit "$fail" |
| +``` |
| + |
| +`chmod +x scripts/region-service-gate.sh`. |
| + |
| +- [ ] **Step 4: Run it to verify it passes** |
| + |
| +Run: `go test . -run TestRegionServiceGate -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add scripts/region-service-gate.sh region_service_gate_test.go |
| +git commit -m "scripts: region service gate for the production switch (nearest-region §17 step 5) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 24: Docs and release notes (copy-review gate) |
| + |
| +**Files:** |
| +- Modify: `docs/site/cli.md` (`### carlos apps` `:112`, `### carlos deploy` `:211`, `### carlos canary` `:282`, `### carlos instances` `:540`) |
| +- Modify: `README.md` (`## Releasing the platform` `:227`; the console env section; the `STATUS_BOX_REGION` entry `:578`) |
| +- Test: the existing `internal/docsite` gate |
| + |
| +**Interfaces:** |
| +- Consumes: the flags from Tasks 20 to 22, the env from Task 9, and the |
| + script from Task 23. |
| + |
| +- [ ] **Step 1: Draft the `cli.md` additions** |
| + |
| +Under `### carlos apps`, after the existing `carlos apps create` example: |
| + |
| +````markdown |
| +`--region <code>` sets the region the app's instances start in. Without it, |
| +the console picks the region nearest you from your timezone, and the command |
| +prints one line saying which and why: |
| + |
| +``` |
| +carlos apps create --app hello --region uk |
| +``` |
| +```` |
| + |
| +Under `### carlos deploy`: |
| + |
| +```markdown |
| +When `carlos deploy` creates your first app, it sends your timezone so the |
| +console can pick the region nearest you, and prints the choice. `--region |
| +<code>` chooses instead. On an app that already exists, `--region` must |
| +match the app's region, or deploy stops before shipping anything. |
| +``` |
| + |
| +Under `### carlos canary`: |
| + |
| +```markdown |
| +`--region <code>` places a new canary in that region (default: the app's |
| +region). It is refused for an existing canary in another region, and for an |
| +app that runs on one of your fleets. |
| +``` |
| + |
| +Under `### carlos instances`, in the `create` paragraph: |
| + |
| +```markdown |
| +Without `--region`, a new instance starts in the app's region. |
| +``` |
| + |
| +- [ ] **Step 2: Draft the README additions** |
| + |
| +In the console configuration list: |
| + |
| +```markdown |
| +- `CONSOLE_APP_REGIONS`: `on` lets new apps be given a region other than the |
| + home. It needs `CONSOLE_HOME_REGION` set too. With the home unnamed, the |
| + console logs once and leaves app regions off. Default off. |
| +- `CONSOLE_GEOIP_DB`: the path to an operator-supplied MaxMind-format `.mmdb`. |
| + DB-IP Lite is recommended, and it requires the attribution the new-app page |
| + shows. Unset or unusable turns the IP step of the region guess off, and the |
| + console starts either way. Replace the file by atomic rename, then restart. |
| +``` |
| + |
| +In `## Releasing the platform`: |
| + |
| +```markdown |
| +**App regions rollback floor.** Once any app holds a non-home region |
| +(`CONSOLE_APP_REGIONS` has been on), do not roll the console binary below |
| +the first release that understands app regions: an older console places a |
| +no-region create at the home. Name that release here and in its release |
| +notes. The switch is the feature's rollback lever: turning it off stops new |
| +non-home regions and keeps existing ones in force. On every start the console |
| +logs "possible placement discrepancy" lines for review. |
| + |
| +**Turning app regions on.** In order: roll the box release that publishes |
| +`serves_shared_pool` and `accepts_inbound` to every box and confirm every |
| +region's edges files carry them; roll the console with the switch off; on |
| +dev, turn it on and check an app row and an instance record; then, on |
| +production with the switch still off, create one probe instance per eligible |
| +region with an explicit `--region`, deploy a known build, and run |
| +`scripts/region-service-gate.sh <probe-host> <build> <every other edge EIP>` |
| +for each. Turn the switch on only when every region passes. Delete the |
| +probes. Re-run the gate after any period with the switch off and whenever a |
| +new region becomes eligible. |
| +``` |
| + |
| +At the `STATUS_BOX_REGION` entry: |
| + |
| +```markdown |
| + Keep it geographically consistent with the region's place name in the |
| + console's region picker (for example "EU · Ireland" beside |
| + "Ireland · Dublin"); it may stay coarser. |
| +``` |
| + |
| +Beside the instances manifest `regions` documentation, add one sentence: "A |
| +manifest's `regions` allow-list compares the home as `""`, so listing the |
| +home's code does not admit the home." |
| + |
| +- [ ] **Step 3: Run the docsite gate** |
| + |
| +Run: `go test ./internal/docsite -count=1` |
| +Expected: PASS. No new command headings are needed, and the fenced examples |
| +use existing verbs. |
| + |
| +- [ ] **Step 4: Commit** |
| + |
| +```bash |
| +git add docs/site/cli.md README.md |
| +git commit -m "docs: --region flags, CONSOLE_APP_REGIONS, CONSOLE_GEOIP_DB, rollback floor and switch runbook (drafts pending review) |
| + |
| +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| +``` |
| + |
| +- [ ] **Step 5: Copy-review gate** |
| + |
| +Invoke `copy-review` with every paragraph added in steps 1 and 2, plus the |
| +gate script's `ok` and `FAIL` lines from Task 23. Apply Paul's edits |
| +verbatim, re-run step 3, and commit as `docs: region docs as approved`. |
| +**Do not land the branch before this closes.** |
| + |
| +--- |
| + |
| +### Task 25: Whole-branch gate |
| + |
| +**Files:** none new. |
| + |
| +- [ ] **Step 1: Run the full gate** |
| + |
| +Run, from the worktree root, with an on-disk `TMPDIR` for console tests and |
| +`/tmp/g` for socket tests: |
| + |
| +```bash |
| +mkdir -p /tmp/g |
| +go build ./... |
| +GOOS=darwin go build . && GOOS=windows go build . && GOOS=linux GOARCH=arm64 go build ./... |
| +TMPDIR=/tmp/g make test |
| +``` |
| + |
| +Expected: every package passes, and `gofmt -l .: clean`. |
| + |
| +- [ ] **Step 2: Confirm the copy gates closed** |
| + |
| +Run: `git log --oneline | grep -c 'as approved'` |
| +Expected: 5 (Tasks 4, 6, 7, 18 and 24). If fewer, the branch is not ready to |
| +land. |
| + |
| +- [ ] **Step 3: Confirm nothing touches DNS steering** |
| + |
| +Run: `git diff main --stat -- internal/steering internal/host/steering_loop.go infra/ && git diff main -- internal/console/apiinstances.go | grep -n 'Steering'` |
| +Expected: no files under steering or infra, and no added `Steering` |
| +assignments (R8). |
| + |
| +- [ ] **Step 4: Report** |
| + |
| +Write the amadan branch description (`amadan branch describe`). It covers |
| +what changed, the copy-review approvals, and that rollout follows README |
| +"Turning app regions on". Do not push without the person directing this work. |
| + |
| +--- |
| + |
| +## Rollout (operational, after the branch lands; spec §17) |
| + |
| +These are not code tasks. They are listed so that whoever lands the branch |
| +runs them in order. |
| + |
| +1. **Before any roll.** |
| + - Audit production manifests for `regions`. |
| + - Confirm that every box publishes its AWS region. On the flagship, the |
| + Ireland box should publish `region: eu-west-1` and no `placement`. Use |
| + `CARLOS_AWS_REGION` where needed. |
| + - Set `CONSOLE_HOME_REGION` on every console that will turn the switch on. |
| +2. **Box release.** Roll it to every box (Task 1), then confirm each |
| + region's `control/edges/*.json` carries `serves_shared_pool` and |
| + `accepts_inbound`. |
| +3. **Console release with `CONSOLE_APP_REGIONS` unset.** Behaviour is |
| + unchanged, and it can be rolled back freely. |
| +4. **Dev.** Turn the switch on with two eligible regions. Create an app in |
| + the browser and read its row. Create an instance with no region and read |
| + its record. Diff `/regions` for an old app against the pre-roll answer. |
| +5. **Production service gate, switch still off** (Task 23 script). For each |
| + eligible region: |
| + - create a probe instance with an explicit `--region`; |
| + - deploy a known build; |
| + - run the gate from outside and through every other edge. |
| + |
| + Every region must pass. Then delete the probes. |
| +6. **Flagship: turn the switch on.** From here the R7 rollback floor applies. |
| + Name the floor version in the release notes and in the |
| + carloku-infrastructure roll recipe. Optionally, set `CONSOLE_GEOIP_DB`. |
| +7. **CLI release.** |
| +8. **titogo**, in its own repo, adopts `POST …/regions/guess` and the place |
| + names. |
| + |
| +**Do not arm `CARLOS_STEERING_ZONE_ID` anywhere** (spec §13.3). That is the |
| +follow-on spec's. |
| + |
| +--- |
| + |
| +## Self-review |
| + |
| +1. **Spec coverage.** |
| + |
| + | Spec | Covered in | |
| + | --- | --- | |
| + | §5.1 to §5.4 (columns, rebuild, atomic insert, backfill) | Task 8 | |
| + | §5.5 (explicit vs guess, adoption, `@auto`) | Tasks 12, 13 | |
| + | §5.6 (switch, named home) | Tasks 9, 12 | |
| + | §5.7 (floor, discrepancies) | Tasks 17, 24 | |
| + | §6.1 (candidates, eligibility, policy, read failure) | Tasks 1, 10 | |
| + | §6.2 (points) | Tasks 3, 4, 5 | |
| + | §6.3, §6.4 (nearest, invariant) | Tasks 2, 11 | |
| + | §6.5 (privacy) | Task 15's redirect test, Task 12's log test | |
| + | §7 (fill, eligibility, C16, C17, C26, C28, response) | Task 14 | |
| + | §8 (every create path) | Tasks 12, 13, 14, 20, 22 | |
| + | §9.1 to §9.5 | Tasks 12, 14, 15, 13 | |
| + | §9.6 (compatibility) | Tasks 20, 22: old-console assertions | |
| + | §10 | Tasks 13, 16 | |
| + | §11 | Tasks 19 to 22 | |
| + | §12 (fleets, pools, replicas, moves, static, sidecars unchanged) | Tasks 14, 15 | |
| + | §13 (no stamp, gate) | Task 25 step 3 and Rollout | |
| + | §14 | Task 4 and Task 24's `STATUS_BOX_REGION` line | |
| + | §15 failure modes | Spread across the tests named above | |
| + | §16 tests | Each listed test is in its owning task | |
| + | §17 | Task 23, Task 24, Rollout | |
| + | §19 copy | Tasks 4, 6, 7, 18, 24 | |
| + |
| + Moves need no code: a move never touches the app row, and Task 8 adds no |
| + move hook. |
| +2. **Placeholder scan.** Each task gives the code it needs. Where a fact has |
| + to be confirmed in the codebase, the step says how to check it and what |
| + to do if it differs. Those facts are: |
| + - the `fx.st.db` field and `fx.srv.redirects` field names; |
| + - `ListRecords`' `max` semantics; |
| + - a fixture IP in MaxMind's test data; |
| + - the `apiInstanceRecord` field names. |
| +3. **Type consistency.** |
| + - `regionCandidate`, `regionPolicy.allows`, `guessRegion`, |
| + `guessInput`/`guessResult`, `regionReq{explicit, hasExplicit, tz, ip}`, |
| + `appDefaultView` and its CLI mirror `apiAppDefault`, `autoRegion`, |
| + `homeOnlyCandidates`, `findCandidate` and `eligibleCount` are each |
| + defined once and used with the same signatures. |
| + - `createApp` gains `region, timezone` in Task 20, and every caller is |
| + updated there. |
| + - `instanceCreatedLine(rec, envSuffix)` has one signature throughout |
| + Task 22. |
| +4. **Review Focus.** Five items, each with a test in its owning task: Tasks |
| + 19 and 3, Task 5, Task 13, Task 15 (with Task 11's core), and Task 12. |