console: the doors' sentences in shapes the copy gate reads
recordEligibility reads its sentences through its moveCopy parameter, so the kind rail is a method (kindRail) with both forms written out rather than a function field; the seed refusal is seedMoveRefusal beside refuseSeedMove; every self-serve refusal is a literal or a format the gate derives (the in-flight 409 is MV-N43's format, the run 409 is written out).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 files changed,
+45
−34
internal/console/apiinstancemove.go+21 −16internal/console/apiinstancemove_offline.go+2 −2internal/console/seedmove.go+9 −0internal/console/selfserve_door.go+11 −14internal/console/selfserve_door_test.go+2 −2
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go| index c2e546482..299dc11eb 100644 |
| --- a/internal/console/apiinstancemove.go |
| +++ b/internal/console/apiinstancemove.go |
| @@ -84,23 +84,20 @@ func (s *Server) moveGate(w http.ResponseWriter, r *http.Request) (acct *Account |
| // door and the job (self-serve moves plan 2 Task 5) run the same checks on |
| // the record they act on, so every caller refuses one record one way. |
| func recordEligibility(rec *provision.Record, c moveCopy) *offlineRefusal { |
| - refuse := func(msg string) *offlineRefusal { |
| - return &offlineRefusal{status: http.StatusBadRequest, msg: msg} |
| + if k := rec.Kind; k != "" && k != "instance" { |
| + return &offlineRefusal{status: http.StatusBadRequest, msg: c.kindRail(k)} |
| } |
| - switch { |
| - case rec.Kind != "" && rec.Kind != "instance": |
| - return refuse(c.kind(rec.Kind)) |
| - case rec.Addr != "" || len(rec.Addrs) > 0: |
| - return refuse(c.addr) |
| + if rec.Addr != "" || len(rec.Addrs) > 0 { |
| + return &offlineRefusal{status: http.StatusBadRequest, msg: c.addr} |
| } |
| if _, fleet := registry.FleetPlacement(rec.Region); fleet { |
| - return refuse(c.fleet) |
| + return &offlineRefusal{status: http.StatusBadRequest, msg: c.fleet} |
| } |
| - switch { |
| - case rec.Pool != "": |
| - return refuse(c.pool) |
| - case !rec.HibernateOrDefault(): |
| - return refuse(c.hibernate) |
| + if rec.Pool != "" { |
| + return &offlineRefusal{status: http.StatusBadRequest, msg: c.pool} |
| + } |
| + if !rec.HibernateOrDefault() { |
| + return &offlineRefusal{status: http.StatusBadRequest, msg: c.hibernate} |
| } |
| return nil |
| } |
| @@ -111,16 +108,25 @@ func recordEligibility(rec *provision.Record, c moveCopy) *offlineRefusal { |
| // record MV-N47 to MV-N54), which no other door ever speaks. |
| type moveCopy struct { |
| gate gateCopy |
| - kind func(kind string) string |
| addr, fleet, pool, hibernate string |
| } |
| +// kindRail is the kind rail's sentence for a route of kind k, in c's words: |
| +// the Phase 1 sentence for the Phase 1 copy, main's otherwise. Written out |
| +// whole in both forms, so the copy gate reads it through |
| +// recordEligibility's parameter. |
| +func (c moveCopy) kindRail(k string) string { |
| + if c.addr == phase1MoveCopy().addr { |
| + return "only exec-backed instances move. this route is kind " + k |
| + } |
| + return "only exec-backed instances move \u2014 this route is kind " + k |
| +} |
| + |
| // mainMoveCopy returns main's sentences, byte for byte (the dashes are escaped |
| // only in the source). |
| func mainMoveCopy() moveCopy { |
| return moveCopy{ |
| gate: mainGateCopy(), |
| - kind: func(k string) string { return "only exec-backed instances move \u2014 this route is kind " + k }, |
| addr: "an addr-backed route's process is not platform-managed \u2014 nothing to move", |
| fleet: "fleet-placed instances keep state-does-not-follow semantics \u2014 move is for platform regions", |
| pool: "pooled instances do not move in v1 \u2014 their data lives on the pool's own box", |
| @@ -139,7 +145,6 @@ func phase1MoveCopy() moveCopy { |
| tooMany: func(string) string { return "too many instance requests. wait a minute and try again" }, |
| noBucket: "this console has no deployment bucket, so it cannot answer this", |
| }, |
| - kind: func(k string) string { return "only exec-backed instances move. this route is kind " + k }, |
| addr: "an addr-backed route's process is not platform-managed. there is nothing to move", |
| fleet: "fleet-placed instances keep state-does-not-follow semantics. moves are for platform regions", |
| pool: "pooled instances do not move yet. their data lives on the pool's own box", |
diff --git a/internal/console/apiinstancemove_offline.go b/internal/console/apiinstancemove_offline.go| index 075654cda..217907e8b 100644 |
| --- a/internal/console/apiinstancemove_offline.go |
| +++ b/internal/console/apiinstancemove_offline.go |
| @@ -498,8 +498,8 @@ func (s *Server) offlineClaimEvidence(ctx context.Context, acct, app, host strin |
| // (offlineClaimEvidence). The operator start and stage doors, the self-serve |
| // door and the job run it on the record they act on. |
| func (s *Server) offlineRecordChecks(ctx context.Context, acct, app, host string, rec *provision.Record) *offlineRefusal { |
| - if b := rec.Seed.MoveBlock(); b != "" { |
| - return &offlineRefusal{status: http.StatusConflict, msg: provision.SeedMoveSentence(b)} |
| + if ref := seedMoveRefusal(rec); ref != nil { |
| + return ref |
| } |
| if rec.Role != "" { |
| return &offlineRefusal{status: http.StatusBadRequest, msg: "a replica does not move. delete it and create one in the new region"} |
diff --git a/internal/console/seedmove.go b/internal/console/seedmove.go| index f4412dcde..5b8089050 100644 |
| --- a/internal/console/seedmove.go |
| +++ b/internal/console/seedmove.go |
| @@ -26,6 +26,15 @@ func (s *Server) refuseSeedMove(w http.ResponseWriter, rec *provision.Record) bo |
| return false |
| } |
| +// seedMoveRefusal is refuseSeedMove as a refusal, for a check that answers |
| +// later (offlineRecordChecks): the same 409 and the same sentence. |
| +func seedMoveRefusal(rec *provision.Record) *offlineRefusal { |
| + if b := rec.Seed.MoveBlock(); b != "" { |
| + return &offlineRefusal{status: http.StatusConflict, msg: provision.SeedMoveSentence(b)} |
| + } |
| + return nil |
| +} |
| + |
| // placeSeedReadFailed is carlos apps place's answer when the strict read of |
| // the app's records fails, before anything has moved. The listing after the |
| // placement is written keeps main's sentence (placeInstancesFailed). |
diff --git a/internal/console/selfserve_door.go b/internal/console/selfserve_door.go| index 636c3f7dc..0bc22d690 100644 |
| --- a/internal/console/selfserve_door.go |
| +++ b/internal/console/selfserve_door.go |
| @@ -72,10 +72,6 @@ func selfServeNotReady(phase string) *offlineRefusal { |
| return &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("the move is not ready to cut yet. it is %s", phase)} |
| } |
| -func conflict(msg string) *offlineRefusal { |
| - return &offlineRefusal{status: http.StatusConflict, msg: msg} |
| -} |
| - |
| // selfServeDoorHook, when set, is called by the cut and cancel doors |
| // between reading the row and their write ("cut", "abort"), and by the |
| // request door before it migrates a legacy record ("migrate"). A TEST SEAM: the race tests |
| @@ -90,13 +86,13 @@ func selfServeDoorPause(door string) { |
| // selfServeInFlight is the 409 for a move already in flight: the record's |
| // own sentence for its move ("a move to <to> is already <state>", as every |
| -// start transition words it), and for an open row the record does not |
| -// carry yet the same sentence in the view's word for the row. |
| +// start transition words it, MV-N43), and for an open row the record does |
| +// not carry yet the same sentence in the view's word for the row. |
| func selfServeInFlight(rec *provision.Record, open *SelfServeMove) *offlineRefusal { |
| if rec != nil && rec.Move != nil { |
| - return conflict(offlineConflict(fmt.Errorf("%w: a move to %s is already %s", provision.ErrMoveConflict, rec.Move.To, rec.Move.State))) |
| + return &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("a move to %s is already %s", rec.Move.To, rec.Move.State)} |
| } |
| - return conflict(offlineConflict(fmt.Errorf("%w: a move to %s is already %s", provision.ErrMoveConflict, open.To, selfServeRowState(open)))) |
| + return &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("a move to %s is already %s", open.To, selfServeRowState(open))} |
| } |
| // selfServeBusy is the prepare's checks of the record and the journal, in |
| @@ -107,7 +103,8 @@ func selfServeBusy(rec *provision.Record, open *SelfServeMove) *offlineRefusal { |
| return ref |
| } |
| if rec.ActiveRun.Open() { |
| - return conflict((&provision.MoveRunOpenError{Run: *rec.ActiveRun}).Sentence()) |
| + // provision.MoveRunOpenError's sentence, the run family's 409. |
| + return &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("a run is open on this instance: %s. wait for it to finish or cancel it, then move the instance", rec.ActiveRun.ID)} |
| } |
| if rec.Move != nil || open != nil { |
| return selfServeInFlight(rec, open) |
| @@ -174,21 +171,21 @@ func selfServeSourceFacts(m *status.BoxMetrics, host string, inc uint64, now tim |
| } |
| switch { |
| case !ok: |
| - return f, conflict(msgSelfServeUnmeasured) |
| + return f, &offlineRefusal{status: http.StatusConflict, msg: msgSelfServeUnmeasured} |
| case f.OverCap: |
| - return f, conflict(msgSelfServeTooLarge) |
| + return f, &offlineRefusal{status: http.StatusConflict, msg: msgSelfServeTooLarge} |
| case !f.Known: |
| - return f, conflict(msgSelfServeUnmeasured) |
| + return f, &offlineRefusal{status: http.StatusConflict, msg: msgSelfServeUnmeasured} |
| } |
| fresh := func(at int64) bool { |
| age := now.Sub(time.Unix(at, 0)) |
| return at > 0 && age <= selfServeFactsFresh && age >= -selfServeFactsSkew |
| } |
| if f.Incarnation != inc || !fresh(f.CollectedAt) || !fresh(f.ReplicaCollectedAt) { |
| - return f, conflict(msgSelfServeSourceStale) |
| + return f, &offlineRefusal{status: http.StatusConflict, msg: msgSelfServeSourceStale} |
| } |
| if f.Truncated { |
| - return f, conflict(msgSelfServeUnmeasured) |
| + return f, &offlineRefusal{status: http.StatusConflict, msg: msgSelfServeUnmeasured} |
| } |
| return f, nil |
| } |
diff --git a/internal/console/selfserve_door_test.go b/internal/console/selfserve_door_test.go| index ba6031c40..b4eb2f097 100644 |
| --- a/internal/console/selfserve_door_test.go |
| +++ b/internal/console/selfserve_door_test.go |
| @@ -231,14 +231,14 @@ func TestRecordEligibilityIsMoveGatesChecks(t *testing.T) { |
| want string |
| }{ |
| {"an eligible record", func(r *provision.Record) {}, ""}, |
| - {"a service", func(r *provision.Record) { r.Kind = "service" }, c.kind("service")}, |
| + {"a service", func(r *provision.Record) { r.Kind = "service" }, c.kindRail("service")}, |
| {"an addr", func(r *provision.Record) { r.Addr = "127.0.0.1:9000" }, c.addr}, |
| {"addrs", func(r *provision.Record) { r.Addrs = []string{"127.0.0.1:9000"} }, c.addr}, |
| {"a fleet placement", func(r *provision.Record) { r.Region = "fleet/shop" }, c.fleet}, |
| {"a pool", func(r *provision.Record) { r.Pool = "shop" }, c.pool}, |
| {"not hibernating", func(r *provision.Record) { r.Hibernate = &no }, c.hibernate}, |
| // moveGate's order: the first rail a record fails is the answer. |
| - {"a service in a pool", func(r *provision.Record) { r.Kind = "service"; r.Pool = "shop" }, c.kind("service")}, |
| + {"a service in a pool", func(r *provision.Record) { r.Kind = "service"; r.Pool = "shop" }, c.kindRail("service")}, |
| {"an addr in a pool", func(r *provision.Record) { r.Addr = "127.0.0.1:9000"; r.Pool = "shop" }, c.addr}, |
| {"a fleet placement in a pool", func(r *provision.Record) { r.Region = "fleet/shop"; r.Pool = "shop" }, c.fleet}, |
| {"a pool, not hibernating", func(r *provision.Record) { r.Pool = "shop"; r.Hibernate = &no }, c.pool}, |