console: resolve the app region inside claimApp; API create takes region and timezone (nearest-region §5.5, §8.1, §9.1, R9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+416
−13
internal/console/apicli.go+31 −5internal/console/apicli_test.go+1 −1internal/console/apps_claim_test.go+4 −4internal/console/regionclaim.go+142 −0internal/console/regionclaim_test.go+228 −0internal/console/server.go+10 −3
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index 805bea56d..a4b740f63 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -361,6 +361,8 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) { |
| Place string `json:"place"` |
| ControlPlane bool `json:"control_plane"` |
| BuildProbe string `json:"build_probe"` |
| + Region string `json:"region"` |
| + Timezone string `json:"timezone"` |
| } |
| if !readJSON(w, r, &req) { |
| return |
| @@ -407,12 +409,23 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| } |
| + // The region is resolved inside claimApp, once it knows the claim is not |
| + // an adoption. The timezone and the address only ever feed that guess. |
| + 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 |
| + } |
| // ownerClaim: a PERSON who owns this account may adopt objects already |
| // under its own prefix (#79). A service principal never may — it holds no |
| // account role, and handing a CI credential the power to adopt release |
| // history is exactly the widening svcrole exists to prevent. |
| ownerClaim := p.Kind == PrincipalUser && acct.Role == RoleOwner |
| - outcome, placed, held := s.claimApp(r.Context(), acct, name, addr, place, ownerClaim) |
| + outcome, placed, held := s.claimApp(r.Context(), acct, name, addr, place, ownerClaim, rq) |
| switch outcome { |
| case claimInvalid: |
| jsonError(w, http.StatusBadRequest, |
| @@ -458,12 +471,25 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) { |
| if err != nil { |
| log.Printf("console: claim %s/%s: read back flags: %v", acct.Sqid, name, err) |
| } |
| + 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, ferr := readEdgesSnapshot(r.Context(), s.cfg.Releases) |
| + cands := homeOnlyCandidates() |
| + if ferr == nil { |
| + cands = s.regionCands(files) |
| + } |
| writeJSON(w, http.StatusOK, map[string]any{ |
| "account": acct.Sqid, "app": name, |
| - "url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(name), |
| - "placement": placed, |
| - "control_plane": gotCP, |
| - "build_probe": gotProbe, |
| + "url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(name), |
| + "placement": placed, |
| + "control_plane": gotCP, |
| + "build_probe": gotProbe, |
| + "region": s.homeRegionOut(gotRegion), |
| + "region_name": s.placeOf(gotRegion, cands), |
| + "region_source": gotSource, |
| + "regions_available": eligibleCount(cands), |
| }) |
| default: |
| // Any future claimOutcome lands here rather than silently 200ing |
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go| index 3b61d6e36..33a57533e 100644 |
| --- a/internal/console/apicli_test.go |
| +++ b/internal/console/apicli_test.go |
| @@ -2501,7 +2501,7 @@ func TestClaimAppPlacementWriteFailureDoesNotUndoClaim(t *testing.T) { |
| } |
| acct := &Account{ID: OpsAccountID, Sqid: fx.sqid} |
| - outcome, placed, _ := fx.srv.claimApp(context.Background(), acct, "titogo", "paul@keymail.dev", "fleet/pi", false) |
| + outcome, placed, _ := fx.srv.claimApp(context.Background(), acct, "titogo", "paul@keymail.dev", "fleet/pi", false, regionReq{}) |
| if outcome != claimOK { |
| t.Fatalf("claimApp outcome = %v, want claimOK — the claim itself must still succeed", outcome) |
| } |
diff --git a/internal/console/apps_claim_test.go b/internal/console/apps_claim_test.go| index aa958c0dd..ec0b0c60d 100644 |
| --- a/internal/console/apps_claim_test.go |
| +++ b/internal/console/apps_claim_test.go |
| @@ -115,7 +115,7 @@ func TestOwnerClaimsAppWhoseObjectsExist(t *testing.T) { |
| fx := newConsoleFixture(t) |
| seedBucketApp(t, fx, "bab", "console") |
| acct := opsAccount(t, fx) |
| - outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "console", "paul@keymail.dev", "", true) |
| + outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "console", "paul@keymail.dev", "", true, regionReq{}) |
| if outcome != claimOK { |
| t.Fatalf("owner claim over existing objects = %v, want claimOK", outcome) |
| } |
| @@ -131,7 +131,7 @@ func TestNonOwnerStillRefusedOverExistingObjects(t *testing.T) { |
| fx := newConsoleFixture(t) |
| seedBucketApp(t, fx, "bab", "console") |
| acct := opsAccount(t, fx) |
| - outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "console", "member@keymail.dev", "", false) |
| + outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "console", "member@keymail.dev", "", false, regionReq{}) |
| if outcome != claimTaken { |
| t.Fatalf("non-owner claim over existing objects = %v, want claimTaken", outcome) |
| } |
| @@ -147,7 +147,7 @@ func TestLegacyPrefixStillBlocksAnOwner(t *testing.T) { |
| t.Fatal(err) |
| } |
| acct := opsAccount(t, fx) |
| - outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "oldsite", "paul@keymail.dev", "", true) |
| + outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "oldsite", "paul@keymail.dev", "", true, regionReq{}) |
| if outcome != claimTaken { |
| t.Fatalf("owner claim over a LEGACY prefix = %v, want claimTaken — those objects are owed a migrate", outcome) |
| } |
| @@ -162,7 +162,7 @@ func TestGrantStillBlocksAnOwner(t *testing.T) { |
| t.Fatal(err) |
| } |
| acct := opsAccount(t, fx) |
| - outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "derp", "paul@keymail.dev", "", true) |
| + outcome, _, _ := fx.srv.claimApp(context.Background(), acct, "derp", "paul@keymail.dev", "", true, regionReq{}) |
| if outcome != claimTaken { |
| t.Fatalf("owner claim over a GRANTED name = %v, want claimTaken", outcome) |
| } |
diff --git a/internal/console/regionclaim.go b/internal/console/regionclaim.go| new file mode 100644 |
| index 000000000..e5c32cafc |
| --- /dev/null |
| +++ b/internal/console/regionclaim.go |
| @@ -0,0 +1,142 @@ |
| +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). ip is request-local and only set for a person. The |
| +// timezone and the address go into the guess and nowhere else: never |
| +// logged, stored, audited or echoed (spec §6.5). |
| +type regionReq struct { |
| + explicit string // a person's choice, record vocabulary |
| + hasExplicit bool |
| + tz string |
| + ip net.IP // request-local, person requests only |
| +} |
| + |
| +// validateExplicitRegion checks a person's explicit region as the instance |
| +// door checks one (spec §8.1): ValidRegion; armed and hosting unless the |
| +// gate is dormant; not provably without the secrets key (R9). The home's |
| +// name normalizes to "". A refusal lists exactly the codes this same check |
| +// would accept now (codex plan-R4 2). |
| +func (s *Server) validateExplicitRegion(ctx context.Context, raw string) (string, *doorRefusal) { |
| + code := s.homeRegionIn(strings.TrimSpace(raw)) |
| + if code == "" { |
| + return "", nil |
| + } |
| + gate := s.readExplicitGate(ctx) |
| + switch gate.refusal(code) { |
| + case "": |
| + return code, nil |
| + case "unknown": |
| + return "", &doorRefusal{http.StatusBadRequest, msgRegionUnknown(code, s.explicitChoices(gate))} |
| + case "not-armed": |
| + return "", &doorRefusal{http.StatusBadRequest, msgRegionNotArmed(code, s.explicitChoices(gate))} |
| + } |
| + return "", &doorRefusal{http.StatusBadRequest, msgNoSecretsKey(code)} |
| +} |
| + |
| +// explicitGate is one read of what an explicit app region must pass. |
| +type explicitGate struct { |
| + caps map[string]placementCapability |
| + published bool |
| + cands []regionCandidate // nil when the edges read failed: R9 cannot refuse |
| +} |
| + |
| +func (s *Server) readExplicitGate(ctx context.Context) explicitGate { |
| + g := explicitGate{} |
| + g.caps, g.published = placements(ctx, s.cfg.Releases) |
| + if snap, err := readEdgesSnapshot(ctx, s.cfg.Releases); err == nil { |
| + g.cands = s.regionCands(snap) |
| + } |
| + return g |
| +} |
| + |
| +// refusal is "" when an explicit, record-vocabulary code passes, else |
| +// which bar it fails: "unknown", "not-armed" or "no-key". |
| +func (g explicitGate) refusal(code string) string { |
| + switch { |
| + case code == "": |
| + return "" |
| + case !provision.ValidRegion(code): |
| + return "unknown" |
| + case g.published && !(g.caps[code].Reachable && g.caps[code].Hosting): |
| + return "not-armed" |
| + } |
| + if c, ok := findCandidate(g.cands, code); ok && c.NoKeyProven { |
| + return "no-key" |
| + } |
| + return "" |
| +} |
| + |
| +// explicitChoices is the home's name plus every published code that |
| +// refusal passes, sorted: never a code this door would refuse. |
| +func (s *Server) explicitChoices(g explicitGate) []string { |
| + set := map[string]bool{} |
| + if s.cfg.HomeRegion != "" { |
| + set[s.cfg.HomeRegion] = true |
| + } |
| + for code := range g.caps { |
| + if c := s.homeRegionIn(code); c != "" && g.refusal(c) == "" { |
| + set[c] = true |
| + } |
| + } |
| + out := make([]string, 0, len(set)) |
| + for c := range set { |
| + out = append(out, c) |
| + } |
| + sort.Strings(out) |
| + return out |
| +} |
| + |
| +// resolveNewAppRegion is spec §5.5 and §5.6 for a NEW row. A guess must |
| +// pass the same predicate that builds the C18 and C19 lists |
| +// (explicitGate.refusal), so no door ever stores a region another door |
| +// would refuse to list. |
| +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" |
| + } |
| + g := s.readExplicitGate(ctx) |
| + if g.cands == nil { |
| + return "", "default" |
| + } |
| + guess, ok := guessRegion(g.cands, func(code string) bool { return g.refusal(code) == "" }, |
| + guessInput{TZ: rq.tz, IP: rq.ip}, s.cfg.GeoIP) |
| + if !ok { |
| + return "", "default" |
| + } |
| + return guess.Code, guess.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). |
| +// A service or app principal is a server; its address says nothing about |
| +// where a person is. |
| +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)) |
| +} |
diff --git a/internal/console/regionclaim_test.go b/internal/console/regionclaim_test.go| new file mode 100644 |
| index 000000000..7f3914d7e |
| --- /dev/null |
| +++ b/internal/console/regionclaim_test.go |
| @@ -0,0 +1,228 @@ |
| +package console |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/geo" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +func regionFixture(t *testing.T) (*consoleFixture, string) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.AppRegions, fx.srv.cfg.HomeRegion, fx.srv.cfg.SecretsPub = true, "ie", testKeyPub |
| + setEdges(t, fx, map[string]string{"ie-box": homeBox, "uk-box": ukEligible}) |
| + return fx, cliToken(t, fx, "paul@keymail.dev", "code-region-claim") |
| +} |
| + |
| +func newAppURL(fx *consoleFixture) string { return fx.ts.URL + "/api/cli/apps/bab/new" } |
| + |
| +// postJSONFrom is postJSON with an X-Forwarded-For, which the console |
| +// trusts from the loopback edge (authcli.go clientIP). |
| +func postJSONFrom(t *testing.T, u string, body any, bearer, xff string) (int, map[string]any) { |
| + t.Helper() |
| + raw, _ := json.Marshal(body) |
| + req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(raw)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Header.Set("Content-Type", "application/json") |
| + req.Header.Set("Authorization", "Bearer "+bearer) |
| + req.Header.Set("X-Forwarded-For", xff) |
| + return doJSON(t, req) |
| +} |
| + |
| +func errOf(res map[string]any) string { s, _ := res["error"].(string); return s } |
| + |
| +var ukPlace = geo.PlaceName("eu-west-2", "uk") |
| + |
| +func TestAPICreateAppResolvesRegion(t *testing.T) { |
| + fx, token := regionFixture(t) |
| + status, res := postJSON(t, newAppURL(fx), map[string]string{"name": "blog", "timezone": "Europe/London"}, token) |
| + if status != http.StatusOK || res["region"] != "uk" || res["region_source"] != "timezone" || |
| + res["region_name"] != ukPlace || 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, from loopback with no forwarded address. |
| + if _, res = postJSON(t, newAppURL(fx), map[string]string{"name": "old"}, token); res["region"] != "ie" || res["region_source"] != "default" { |
| + t.Fatalf("old body = %v", res) |
| + } |
| + // An explicit home name is a choice, stored as "". |
| + postJSON(t, newAppURL(fx), map[string]string{"name": "athome", "region": "ie"}, token) |
| + if r, src, _ := fx.st.AppRegion(OpsAccountID, "athome"); r != "" || src != "chosen" { |
| + t.Fatalf("explicit home = %q %q", r, src) |
| + } |
| +} |
| + |
| +// The old-body path through the real door: a person's forwarded address |
| +// is located; a service principal's never is (spec §6.2). |
| +func TestAPICreateAppLocatesOnlyAPerson(t *testing.T) { |
| + fx, token := regionFixture(t) |
| + fx.srv.cfg.GeoIP = fakeLocator{"81.2.69.142": {Lat: 51.5, Lon: -0.12}} |
| + if status, res := postJSONFrom(t, newAppURL(fx), map[string]string{"name": "person"}, token, "81.2.69.142"); status != http.StatusOK || |
| + res["region"] != "uk" || res["region_source"] != "ip" { |
| + t.Fatalf("person = %d %v", status, res) |
| + } |
| + svc := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish) |
| + if status, res := postJSONFrom(t, newAppURL(fx), map[string]string{"name": "hubapp"}, svc, "81.2.69.142"); status != http.StatusOK || |
| + res["region"] != "ie" || res["region_source"] != "default" { |
| + t.Fatalf("service = %d %v, want no IP step", status, res) |
| + } |
| +} |
| + |
| +func TestAPICreateAppRefusesBadExplicitRegions(t *testing.T) { |
| + fx, token := regionFixture(t) |
| + // nz cannot host; au's current-writer box lacks the key (R9). |
| + auNoKey := edgeJSON("au-box", "9.9.9.8", "ap-southeast-2", "au", edgeOpts{hib: bp(true), shared: bp(true), inbound: bp(true)}) |
| + setEdges(t, fx, map[string]string{"nz-box": nzNoHosting, "au-box": auNoKey}) |
| + // codex plan-R4 2: each list names exactly what this door would take. |
| + choices := []string{"ie", "uk"} |
| + cases := []struct{ name, region, want string }{ |
| + {"xa", "Bad/Code", msgRegionUnknown("Bad/Code", choices)}, |
| + {"xb", "nz", msgRegionNotArmed("nz", choices)}, |
| + {"xc", "au", msgNoSecretsKey("au")}, |
| + } |
| + for _, c := range cases { |
| + status, res := postJSON(t, newAppURL(fx), map[string]string{"name": c.name, "region": c.region}, token) |
| + if status != http.StatusBadRequest || errOf(res) != c.want { |
| + t.Errorf("region %q = %d %q, want 400 %q", c.region, status, errOf(res), c.want) |
| + } |
| + if has, _ := fx.st.HasAppIn(OpsAccountID, c.name); has { |
| + t.Errorf("a refused region still claimed %s", c.name) |
| + } |
| + } |
| + // Every advertised choice is accepted by the same door, same fixture. |
| + for i, code := range choices { |
| + name := fmt.Sprintf("alt%d", i) |
| + if status, res := postJSON(t, newAppURL(fx), map[string]string{"name": name, "region": code}, token); status != http.StatusOK { |
| + t.Errorf("advertised %q refused: %d %v", code, status, res) |
| + } |
| + } |
| + // R9: a region whose current-writer boxes lack the key. |
| + setEdges(t, fx, map[string]string{"uk-box": ukNoKey}) |
| + if status, res := postJSON(t, newAppURL(fx), map[string]string{"name": "xd", "region": "uk"}, token); status != http.StatusBadRequest || errOf(res) != msgNoSecretsKey("uk") { |
| + t.Fatalf("keyless uk = %d %v, want C31", status, res) |
| + } |
| +} |
| + |
| +func TestAPICreateAppSwitchOffStoresTheHome(t *testing.T) { |
| + fx, token := regionFixture(t) |
| + fx.srv.cfg.AppRegions = false |
| + _, res := postJSON(t, newAppURL(fx), map[string]string{"name": "off", "region": "uk", "timezone": "Europe/London"}, token) |
| + 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, "" |
| + // PF9: the second half must not pass on a failed create. |
| + if status, res := postJSON(t, newAppURL(fx), map[string]string{"name": "unnamed", "region": "uk"}, token); status != http.StatusOK { |
| + t.Fatalf("unnamed-home create = %d %v", status, res) |
| + } |
| + if r, _, _ := fx.st.AppRegion(OpsAccountID, "unnamed"); r != "" { |
| + t.Fatalf("unnamed home stored %q", r) |
| + } |
| +} |
| + |
| +func TestClaimAdoptionDiscardsTheGuess(t *testing.T) { |
| + fx, _ := regionFixture(t) |
| + acct := &Account{ID: OpsAccountID, Sqid: "bab"} |
| + seedBucketApp(t, fx, "bab", "oldsite") |
| + if out, _, _ := fx.srv.claimApp(context.Background(), acct, "oldsite", "paul@keymail.dev", "", true, regionReq{tz: "Europe/London"}); 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", 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) |
| + } |
| +} |
| + |
| +// codex plan-R6 1, spec §6.5: every successful creation logs exactly one |
| +// region line, naming what was stored, whatever the switch says: on, off, |
| +// and on with an unnamed home (which keeps assignment off). |
| +func TestClaimLogsOneRegionLineInEverySwitchState(t *testing.T) { |
| + for _, c := range []struct { |
| + name, app, home string |
| + on bool |
| + stored, source string |
| + line string |
| + }{ |
| + {"assignment on", "on1", "ie", true, "uk", "chosen", "console: app region bab/on1: uk (chosen)"}, |
| + {"assignment off", "off1", "ie", false, "", "default", "console: app region bab/off1: ie (default)"}, |
| + {"on, home unnamed", "unnamed1", "", true, "", "default", "console: app region bab/unnamed1: (default)"}, |
| + } { |
| + fx, token := regionFixture(t) |
| + fx.srv.cfg.AppRegions, fx.srv.cfg.HomeRegion = c.on, c.home |
| + var logs bytes.Buffer |
| + restore := captureLog(&logs) |
| + status, res := postJSON(t, newAppURL(fx), map[string]string{"name": c.app, "region": "uk"}, token) |
| + restore() |
| + if status != http.StatusOK { |
| + t.Fatalf("%s: create = %d %v", c.name, status, res) |
| + } |
| + if r, src, _ := fx.st.AppRegion(OpsAccountID, c.app); r != c.stored || src != c.source { |
| + t.Fatalf("%s: stored %q %q, want %q %q", c.name, r, src, c.stored, c.source) |
| + } |
| + if n := strings.Count(logs.String(), "console: app region bab/"+c.app+":"); n != 1 || !strings.Contains(logs.String(), c.line+"\n") { |
| + t.Errorf("%s: %d region line(s) in %q, want exactly %q", c.name, n, logs.String(), c.line) |
| + } |
| + } |
| +} |
| + |
| +// Review Focus 5: a competing insert that lands AFTER the region was |
| +// resolved loses the claim: no row of ours, no region log line. |
| +func TestClaimLostAfterResolutionWritesNothing(t *testing.T) { |
| + fx, token := regionFixture(t) |
| + fx.srv.beforeClaimInsert = func() { |
| + if _, err := fx.st.CreateAppInRegion(OpsAccountID, "racy", "rival@k.dev", "", "chosen", time.Now()); err != nil { |
| + t.Error(err) |
| + } |
| + } |
| + var logs bytes.Buffer |
| + restore := captureLog(&logs) |
| + status, _ := postJSON(t, newAppURL(fx), map[string]string{"name": "racy", "region": "uk"}, token) |
| + restore() |
| + if status != http.StatusConflict { |
| + t.Fatalf("lost race = %d, want 409", status) |
| + } |
| + if r, src, _ := fx.st.AppRegion(OpsAccountID, "racy"); r != "" || src != "chosen" { |
| + t.Fatalf("the rival's row changed: %q %q", r, src) |
| + } |
| + if strings.Contains(logs.String(), "app region bab/racy") { |
| + t.Fatalf("a lost claim logged a region line: %s", logs.String()) |
| + } |
| +} |
| + |
| +// Review Focus 5: a held apex refuses before resolution, so nothing is |
| +// written or logged. |
| +func TestClaimHeldApexWritesNothing(t *testing.T) { |
| + fx, token := regionFixture(t) |
| + fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + fx.addRoute(t, registry.Route{Host: "beta." + fx.sqid + ".oncarlos.test", App: "kass", Account: fx.sqid, Kind: "instance"}) |
| + var logs bytes.Buffer |
| + restore := captureLog(&logs) |
| + status, _ := postJSON(t, newAppURL(fx), map[string]string{"name": "beta", "timezone": "Europe/London"}, token) |
| + restore() |
| + if status != http.StatusConflict { |
| + t.Fatalf("held apex = %d, want 409", status) |
| + } |
| + if has, _ := fx.st.HasAppIn(OpsAccountID, "beta"); has { |
| + t.Fatal("beta was created") |
| + } |
| + if strings.Contains(logs.String(), "app region bab/beta") { |
| + t.Fatalf("a refused claim logged a region line: %s", logs.String()) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 08434c849..0c3a38f71 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -3524,7 +3524,7 @@ func ownerExempt(prefix, ownPrefix string) bool { return prefix == ownPrefix } |
| // |
| // held is set only with claimApexHeld: who holds the name's platform apex, so |
| // a door can say so without asking again. |
| -func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool) (outcome claimOutcome, placed string, held *apexHold) { |
| +func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool, rq regionReq) (outcome claimOutcome, placed string, held *apexHold) { |
| if !release.ValidApp(name) { |
| return claimInvalid, "", nil |
| } |
| @@ -3608,13 +3608,20 @@ func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placem |
| } |
| return claimTaken, "", nil |
| } |
| - created, err := s.store.CreateAppIn(acct.ID, name, addr, time.Now()) |
| + region, source := s.resolveNewAppRegion(ctx, rq, adoptingExistingApp) |
| + if s.beforeClaimInsert != nil { |
| + s.beforeClaimInsert() |
| + } |
| + 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 |
| } |
| + // Spec §6.5: one line per creation, in every switch state (codex |
| + // plan-R6 1). Only reached once the row is ours. |
| + log.Printf("console: app region %s/%s: %s (%s)", acct.Sqid, name, s.homeRegionOut(region), source) |
| if placement != "" { |
| if err := s.store.SetAppPlacement(acct.ID, name, placement); err != nil { |
| // Best-effort, like ensureAccountManifest below: the claim itself |
| @@ -3684,7 +3691,7 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) { |
| // The browser door never places at create time, so the second return |
| // (what actually stuck) is always "" here and there is nothing to echo |
| // it to — this form has no placement concept at all. |
| - outcome, _, held := s.claimApp(r.Context(), acct, name, addr, "", role == RoleOwner) |
| + outcome, _, held := s.claimApp(r.Context(), acct, name, addr, "", role == RoleOwner, regionReq{}) |
| switch outcome { |
| case claimInvalid: |
| fail("Invalid app name — lowercase letters, digits, and dashes, starting with a letter.") |