zdd plan 3 task 8: a retiring generation's schedule writes get 409 after the switch
schedule.MountGated gates PUT and DELETE on a retired func; Service.RetiredControl names the retiring generation's control socket; ExecBackend.Retired is wired in agent.go. Reads and the bearer check are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+313
−4
internal/activator/backend_exec.go+7 −1internal/activator/genbackend.go+9 −0internal/activator/opset.go+25 −0internal/activator/prepare.go+3 −1internal/activator/swap_schedule_test.go+156 −0internal/host/agent.go+5 −0internal/schedule/control.go+27 −2internal/schedule/control_gate_test.go+81 −0
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index 688c29db0..de6d5644d 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -180,6 +180,12 @@ type ExecBackend struct { |
| // nested run dir and log socket beneath it, so a test can exercise the |
| // nested layout without writing to /run/carlos. |
| RunDirOverride string |
| + // Retired answers whether the control socket at control belongs to a |
| + // generation of host that the activator has switched away from |
| + // (zero-downtime spec §7.10). Its schedule writes are then refused. |
| + // Wired to Service.RetiredControl by the agent. Nil (tests, a box |
| + // before this wiring) refuses nothing. |
| + Retired func(host, control string) bool |
| // Strict, when non-nil and true for a host, makes every spawn of that |
| // host strict (moves design §5.3): the child starts only inside a group |
| // prepareStrict prepared, which is drained (KillAndEmpty) and closed |
| @@ -1372,7 +1378,7 @@ func (b *ExecBackend) spawnChildFrom(ctx context.Context, rt registry.Route, soc |
| var mount func(*http.ServeMux) |
| if b.Timers != nil { |
| mount = func(mux *http.ServeMux) { |
| - schedule.Mount(mux, rt.Host, b.Timers, tokenReader, time.Now) |
| + schedule.MountGated(mux, rt.Host, b.Timers, tokenReader, time.Now, b.retiredGate(rt.Host, controlSocket)) |
| } |
| } |
| ctl, err := edge.ListenControl(controlSocket, rt.Host, b.Cache, mount) |
diff --git a/internal/activator/genbackend.go b/internal/activator/genbackend.go| index 99a017f45..df1e2657c 100644 |
| --- a/internal/activator/genbackend.go |
| +++ b/internal/activator/genbackend.go |
| @@ -296,3 +296,12 @@ func (b *SplitBackend) KillSidecar(ctx context.Context, rt registry.Route) error |
| } |
| return nil |
| } |
| + |
| +// retiredGate is MountGated's gate for the control socket at control. It |
| +// is nil when the backend has no Retired seam. |
| +func (b *ExecBackend) retiredGate(host, control string) func() bool { |
| + if b.Retired == nil { |
| + return nil |
| + } |
| + return func() bool { return b.Retired(host, control) } |
| +} |
diff --git a/internal/activator/opset.go b/internal/activator/opset.go| index 75519b7b1..096d69f94 100644 |
| --- a/internal/activator/opset.go |
| +++ b/internal/activator/opset.go |
| @@ -493,3 +493,28 @@ func (a *Service) reapUnprovenWake(host string, u *unprovenWake) { |
| u.busy = false |
| a.mu.Unlock() |
| } |
| + |
| +// RetiredControl reports whether control is the control socket of a |
| +// generation of host that has been switched away from and is still |
| +// draining (zero-downtime spec §7.10). Its schedule writes are refused |
| +// from the switch on. The current generation, a successor and a |
| +// hibernating generation are never retired. Once a retiring generation |
| +// has left the set its listener is closed, so the answer stops mattering. |
| +// With swaps off nothing is ever retired. |
| +func (a *Service) RetiredControl(host, control string) bool { |
| + if !a.gens { |
| + return false |
| + } |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + e := a.entries[host] |
| + if e == nil { |
| + return false |
| + } |
| + for _, m := range e.ops.members { |
| + if m.role == roleRetiring && m.gen.Control == control { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
diff --git a/internal/activator/prepare.go b/internal/activator/prepare.go| index 929aea921..4c4acff25 100644 |
| --- a/internal/activator/prepare.go |
| +++ b/internal/activator/prepare.go |
| @@ -138,7 +138,9 @@ func (b *ExecBackend) listenControl(rt registry.Route, control, envPath string) |
| } |
| var mount func(*http.ServeMux) |
| if b.Timers != nil { |
| - mount = func(mux *http.ServeMux) { schedule.Mount(mux, rt.Host, b.Timers, tokenReader, time.Now) } |
| + mount = func(mux *http.ServeMux) { |
| + schedule.MountGated(mux, rt.Host, b.Timers, tokenReader, time.Now, b.retiredGate(rt.Host, control)) |
| + } |
| } |
| ctl, err := edge.ListenControl(control, rt.Host, b.Cache, mount) |
| if err != nil { |
diff --git a/internal/activator/swap_schedule_test.go b/internal/activator/swap_schedule_test.go| new file mode 100644 |
| index 000000000..ffe49d860 |
| --- /dev/null |
| +++ b/internal/activator/swap_schedule_test.go |
| @@ -0,0 +1,156 @@ |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "strings" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/schedule" |
| +) |
| + |
| +// RetiredControl names the retiring generation's control socket and nothing |
| +// else (spec §7.10). The successor's writes apply while it is checked. A |
| +// hibernating stop after the swap leaves the current generation's writes |
| +// open. |
| +func TestRetiredControlNamesOnlyTheRetiringGeneration(t *testing.T) { |
| + f := newSwapFixture(t) |
| + duringHealth := map[string]bool{} |
| + f.a.genHealth = func(_ context.Context, g Gen) error { |
| + duringHealth["successor"] = f.a.RetiredControl(f.host, g.Control) |
| + duringHealth["incumbent"] = f.a.RetiredControl(f.host, f.incumbent.Control) |
| + return nil |
| + } |
| + gate := make(chan struct{}) |
| + f.b.lastProc().setStopGate(gate) // hold the retiring generation's drain open |
| + results := make(chan SwapResult, 1) |
| + go func() { results <- f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest) }() |
| + waitFor(t, "the switch", func() bool { return indexPrefix(f.b.events(), "switch:") >= 0 }) |
| + if duringHealth["successor"] || duringHealth["incumbent"] { |
| + t.Fatalf("before the switch: %v, want both open", duringHealth) |
| + } |
| + succ, _ := f.b.lastGen() |
| + if !f.a.RetiredControl(f.host, f.incumbent.Control) { |
| + t.Fatal("the retiring generation's control socket is still open for writes after the switch") |
| + } |
| + if f.a.RetiredControl(f.host, succ.Control) { |
| + t.Fatal("the current generation's writes are refused") |
| + } |
| + close(gate) |
| + if res := <-results; res.Outcome != SwapDone { |
| + t.Fatalf("Swap = %+v", res) |
| + } |
| + if f.a.RetiredControl(f.host, f.incumbent.Control) { |
| + t.Fatal("a generation that has left the set is still reported retired") |
| + } |
| + // A hibernating stop is unchanged: the stopping generation is current. |
| + stopGate := make(chan struct{}) |
| + _, cur := f.b.lastGen() |
| + cur.setStopGate(stopGate) |
| + f.clk.advance(2 * time.Hour) |
| + f.a.sweepOnce() |
| + waitFor(t, "the hibernating stop", func() bool { |
| + f.a.mu.Lock() |
| + defer f.a.mu.Unlock() |
| + e := f.a.entries[f.host] |
| + return e != nil && e.state == actStopping |
| + }) |
| + if f.a.RetiredControl(f.host, succ.Control) { |
| + t.Fatal("a hibernating generation's writes are refused") |
| + } |
| + close(stopGate) |
| +} |
| + |
| +// Flag off: nothing is ever retired. The entry carries a retiring member on |
| +// the very control socket that would be gated with swaps on, so a pass here |
| +// is the flag's doing and not an empty set's. |
| +func TestRetiredControlIsFalseWithSwapOff(t *testing.T) { |
| + f := newSwapFixture(t) |
| + retiring := &genMember{gen: Gen{N: 77, Build: "v1", Control: "/run/carlos/g/old.sock.control"}, role: roleRetiring, done: make(chan struct{})} |
| + f.a.mu.Lock() |
| + f.a.addMemberLocked(f.a.entries[f.host], retiring) |
| + f.a.mu.Unlock() |
| + t.Cleanup(func() { |
| + f.a.mu.Lock() |
| + delete(f.a.entries[f.host].ops.members, retiring.gen.N) |
| + f.a.mu.Unlock() |
| + }) |
| + if !f.a.RetiredControl(f.host, retiring.gen.Control) { |
| + t.Fatal("control: with swaps on, a retiring member's socket is not reported retired") |
| + } |
| + f.a.gens = false |
| + if f.a.RetiredControl(f.host, retiring.gen.Control) { |
| + t.Fatal("RetiredControl reported true with swaps off") |
| + } |
| +} |
| + |
| +func TestExecBackendRetiredGate(t *testing.T) { |
| + b := testExecBackend(t) |
| + if b.retiredGate("h.example.com", "/c") != nil { |
| + t.Fatal("a backend with no Retired seam gated the schedule verbs") |
| + } |
| + var gotHost, gotControl string |
| + b.Retired = func(h, c string) bool { gotHost, gotControl = h, c; return true } |
| + if !b.retiredGate("h.example.com", "/run/carlos/g/abc.7.sock.control")() || gotHost != "h.example.com" || gotControl != "/run/carlos/g/abc.7.sock.control" { |
| + t.Fatalf("gate asked %q %q", gotHost, gotControl) |
| + } |
| +} |
| + |
| +// The seam reaches the real control socket of a spawned child: the verbs |
| +// are mounted through the gate, the gate is asked about this host and this |
| +// control path, and once it says retired a write is a 409 while a read |
| +// still answers. |
| +func TestExecBackendStartGatesTheControlSocketWrites(t *testing.T) { |
| + b := testExecBackend(t) |
| + b.Timers = &fakeTimerStore{} |
| + var retired atomic.Bool |
| + var askedHost, askedControl atomic.Value |
| + b.Retired = func(host, control string) bool { |
| + askedHost.Store(host) |
| + askedControl.Store(control) |
| + return retired.Load() |
| + } |
| + ctl, token, stop := startAndReachControlSocket(t, b, "gated.example.com") |
| + defer stop() |
| + |
| + send := func(method, path, body string) (int, string) { |
| + req, err := http.NewRequest(method, "http://carlos"+path, strings.NewReader(body)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + resp, err := socketClient(ctl).Do(req) |
| + if err != nil { |
| + t.Fatalf("dial control socket %s: %v", ctl, err) |
| + } |
| + defer resp.Body.Close() |
| + var sb strings.Builder |
| + buf := make([]byte, 512) |
| + for { |
| + n, err := resp.Body.Read(buf) |
| + sb.Write(buf[:n]) |
| + if err != nil { |
| + break |
| + } |
| + } |
| + return resp.StatusCode, sb.String() |
| + } |
| + if code, _ := send("DELETE", "/schedule/remind", ""); code != http.StatusNoContent { |
| + t.Fatalf("open DELETE = %d, want 204", code) |
| + } |
| + retired.Store(true) |
| + if code, msg := send("DELETE", "/schedule/remind", ""); code != http.StatusConflict || !strings.Contains(msg, schedule.RetiredWriteMessage) { |
| + t.Fatalf("retired DELETE = %d %q, want 409", code, msg) |
| + } |
| + if code, msg := send("PUT", "/schedule/remind", `{"at":"2026-09-01T08:00:00Z","path":"/r"}`); code != http.StatusConflict || !strings.Contains(msg, schedule.RetiredWriteMessage) { |
| + t.Fatalf("retired PUT = %d %q, want 409", code, msg) |
| + } |
| + if code, _ := send("GET", "/schedule", ""); code != http.StatusOK { |
| + t.Fatalf("retired GET = %d, want 200", code) |
| + } |
| + if askedHost.Load() != "gated.example.com" || askedControl.Load() != ctl { |
| + t.Fatalf("gate asked %v %v, want gated.example.com %s", askedHost.Load(), askedControl.Load(), ctl) |
| + } |
| +} |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 738c89d71..b390b1234 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -399,6 +399,11 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| // where the pass reads release.GenerationsPath from. Set before |
| // Start and before any wake. |
| svc.GenerationsFile = release.GenerationsPath(dd) |
| + // A retiring generation's schedule writes are refused once a swap |
| + // has switched away from it (zero-downtime spec §7.10). Wired before |
| + // Start, like Routes. With swaps off nothing is ever retired, so |
| + // every write applies as before. |
| + eb.Retired = svc.RetiredControl |
| if fleetStore != nil { |
| // MUST be assigned before Start() below spawns the sweeper |
| // goroutine, which reads svc.OnWake on every successful wake from |
diff --git a/internal/schedule/control.go b/internal/schedule/control.go| index acd30bd2f..5738e952f 100644 |
| --- a/internal/schedule/control.go |
| +++ b/internal/schedule/control.go |
| @@ -25,7 +25,17 @@ type TimerStore interface { |
| CountTimers(host, kind string) (int, error) |
| } |
| -// Mount adds the once-timer verbs to an instance's control socket (spec |
| +// RetiredWriteMessage is the 409 body a retiring generation's schedule |
| +// writes get once the switch has happened (zero-downtime spec §7.10). App |
| +// developers read it in their logs; it is copy-reviewed. |
| +const RetiredWriteMessage = "this process has been replaced; the new process owns the schedule" |
| + |
| +// Mount is MountGated with no gate. |
| +func Mount(mux *http.ServeMux, host string, store TimerStore, token func() (string, error), now func() time.Time) { |
| + MountGated(mux, host, store, token, now, nil) |
| +} |
| + |
| +// MountGated adds the once-timer verbs to an instance's control socket (spec |
| // §7.2). host is fixed here and never read from a request — the |
| // confused-deputy guarantee edge.ListenControl gives — and every verb needs |
| // the host's own CARLOS_ADMIN_TOKEN as a bearer. Be precise about what that |
| @@ -38,7 +48,14 @@ type TimerStore interface { |
| // with host B's token could already dial B's own socket with it; these |
| // verbs add reach (a platform wake plus a POST at a chosen time and path), |
| // not privilege — and per-instance uids are the real fix. |
| -func Mount(mux *http.ServeMux, host string, store TimerStore, token func() (string, error), now func() time.Time) { |
| +// |
| +// retired gates the two writing verbs: while it reports true, PUT and |
| +// DELETE answer 409 and change nothing. A generation the activator has |
| +// switched away from must not rewrite timers the successor now owns, which |
| +// is what its reassert-at-shutdown would do (zero-downtime spec §7.10). |
| +// Reads stay open, and the bearer is checked first. A nil retired is Mount |
| +// exactly. |
| +func MountGated(mux *http.ServeMux, host string, store TimerStore, token func() (string, error), now func() time.Time, retired func() bool) { |
| authed := func(fn http.HandlerFunc) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| want, err := token() |
| @@ -64,6 +81,10 @@ func Mount(mux *http.ServeMux, host string, store TimerStore, token func() (stri |
| })) |
| mux.HandleFunc("PUT /schedule/{name}", authed(func(w http.ResponseWriter, r *http.Request) { |
| + if retired != nil && retired() { |
| + http.Error(w, RetiredWriteMessage, http.StatusConflict) |
| + return |
| + } |
| name := r.PathValue("name") |
| if !ValidName(name) { |
| http.Error(w, "name: want [a-z0-9][a-z0-9-]{0,31}", http.StatusBadRequest) |
| @@ -137,6 +158,10 @@ func Mount(mux *http.ServeMux, host string, store TimerStore, token func() (stri |
| })) |
| mux.HandleFunc("DELETE /schedule/{name}", authed(func(w http.ResponseWriter, r *http.Request) { |
| + if retired != nil && retired() { |
| + http.Error(w, RetiredWriteMessage, http.StatusConflict) |
| + return |
| + } |
| name := r.PathValue("name") |
| if !ValidName(name) { |
| http.Error(w, "bad name", http.StatusBadRequest) |
diff --git a/internal/schedule/control_gate_test.go b/internal/schedule/control_gate_test.go| new file mode 100644 |
| index 000000000..9a180a3b7 |
| --- /dev/null |
| +++ b/internal/schedule/control_gate_test.go |
| @@ -0,0 +1,81 @@ |
| +package schedule |
| + |
| +import ( |
| + "net/http" |
| + "strings" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// A retiring generation's writes are refused with 409 once the switch has |
| +// happened (zero-downtime spec §7.10). The successor's writes apply. Reads |
| +// stay open, and the bearer is still checked first. |
| +func TestMountGatedRefusesARetiredGenerationsWrites(t *testing.T) { |
| + reg, _ := controlFixture(t) |
| + var retired atomic.Bool |
| + mux := http.NewServeMux() |
| + MountGated(mux, "a.bab.oncarlos.com", reg, func() (string, error) { return "tok", nil }, |
| + func() time.Time { return at("2026-08-23T13:07:00Z") }, retired.Load) |
| + body := `{"at":"2026-09-01T08:00:00Z","path":"/jobs/remind"}` |
| + if rec := do(mux, "PUT", "/schedule/remind", "Bearer tok", body); rec.Code != http.StatusOK { |
| + t.Fatalf("current generation's write = %d %s, want 200", rec.Code, rec.Body) |
| + } |
| + retired.Store(true) |
| + if rec := do(mux, "PUT", "/schedule/remind", "Bearer tok", body); rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), RetiredWriteMessage) { |
| + t.Fatalf("retired PUT = %d %q, want 409 with RetiredWriteMessage", rec.Code, rec.Body) |
| + } |
| + if rec := do(mux, "DELETE", "/schedule/remind", "Bearer tok", ""); rec.Code != http.StatusConflict { |
| + t.Fatalf("retired DELETE = %d, want 409", rec.Code) |
| + } |
| + if row, err := reg.GetTimer("a.bab.oncarlos.com", "remind"); err != nil || row == nil { |
| + t.Fatalf("a refused DELETE removed the timer: %v", err) |
| + } |
| + if rec := do(mux, "GET", "/schedule", "Bearer tok", ""); rec.Code != http.StatusOK { |
| + t.Fatalf("retired GET = %d, want 200", rec.Code) |
| + } |
| + if rec := do(mux, "PUT", "/schedule/remind", "Bearer wrong", body); rec.Code != http.StatusUnauthorized { |
| + t.Fatalf("a retired generation's bad bearer = %d, want 401", rec.Code) |
| + } |
| + if rec := do(mux, "DELETE", "/schedule/remind", "Bearer wrong", ""); rec.Code != http.StatusUnauthorized { |
| + t.Fatalf("a retired generation's bad bearer on DELETE = %d, want 401", rec.Code) |
| + } |
| +} |
| + |
| +// A refused PUT changes nothing: the row the successor wrote survives a |
| +// retiring generation's attempt to overwrite it. |
| +func TestMountGatedRefusedPutLeavesTheRowAlone(t *testing.T) { |
| + reg, _ := controlFixture(t) |
| + var retired atomic.Bool |
| + mux := http.NewServeMux() |
| + MountGated(mux, "a.bab.oncarlos.com", reg, func() (string, error) { return "tok", nil }, |
| + func() time.Time { return at("2026-08-23T13:07:00Z") }, retired.Load) |
| + if rec := do(mux, "PUT", "/schedule/remind", "Bearer tok", `{"at":"2026-09-01T08:00:00Z","path":"/jobs/new"}`); rec.Code != http.StatusOK { |
| + t.Fatalf("PUT = %d %s", rec.Code, rec.Body) |
| + } |
| + retired.Store(true) |
| + if rec := do(mux, "PUT", "/schedule/remind", "Bearer tok", `{"at":"2026-09-02T08:00:00Z","path":"/jobs/old"}`); rec.Code != http.StatusConflict { |
| + t.Fatalf("retired PUT = %d, want 409", rec.Code) |
| + } |
| + row, err := reg.GetTimer("a.bab.oncarlos.com", "remind") |
| + if err != nil || row.Path != "/jobs/new" { |
| + t.Fatalf("a refused PUT changed the row: %+v, %v", row, err) |
| + } |
| +} |
| + |
| +// A nil gate is Mount exactly: both writing verbs apply. |
| +func TestMountGatedNilIsMount(t *testing.T) { |
| + reg, _ := controlFixture(t) |
| + mux := http.NewServeMux() |
| + MountGated(mux, "a.bab.oncarlos.com", reg, func() (string, error) { return "tok", nil }, |
| + func() time.Time { return at("2026-08-23T13:07:00Z") }, nil) |
| + if rec := do(mux, "PUT", "/schedule/remind", "Bearer tok", `{"at":"2026-09-01T08:00:00Z","path":"/r"}`); rec.Code != http.StatusOK { |
| + t.Fatalf("PUT = %d", rec.Code) |
| + } |
| + if rec := do(mux, "DELETE", "/schedule/remind", "Bearer tok", ""); rec.Code != http.StatusNoContent { |
| + t.Fatalf("DELETE = %d, want 204", rec.Code) |
| + } |
| + if _, err := reg.GetTimer("a.bab.oncarlos.com", "remind"); err == nil { |
| + t.Fatal("a nil gate's DELETE left the timer in place") |
| + } |
| +} |