schedule: control-socket verbs for once-timers, bearer-gated
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+274
−0
internal/schedule/control.go+147 −0internal/schedule/control_test.go+127 −0
diff --git a/internal/schedule/control.go b/internal/schedule/control.go| new file mode 100644 |
| index 000000000..693417597 |
| --- /dev/null |
| +++ b/internal/schedule/control.go |
| @@ -0,0 +1,147 @@ |
| +package schedule |
| + |
| +import ( |
| + "crypto/sha256" |
| + "crypto/subtle" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "os" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// TimerStore is the registry subset the control channel needs. |
| +type TimerStore interface { |
| + UpsertTimer(registry.Timer) error |
| + DeleteTimer(host, name string) error |
| + GetTimer(host, name string) (*registry.Timer, error) |
| + TimersFor(host string) ([]registry.Timer, error) |
| + CountTimers(host, kind string) (int, error) |
| +} |
| + |
| +// Mount 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.ListenPurge gives — and every verb needs |
| +// the host's own CARLOS_ADMIN_TOKEN as a bearer, which is what closes the |
| +// same-uid-sibling gap purge.go documents: a sibling can compute this |
| +// socket's path, but not this instance's token. |
| +func Mount(mux *http.ServeMux, host string, store TimerStore, token func() (string, error), now func() time.Time) { |
| + authed := func(fn http.HandlerFunc) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + want, err := token() |
| + if err != nil || want == "" { |
| + http.Error(w, "unauthorized", http.StatusUnauthorized) |
| + return |
| + } |
| + got := sha256.Sum256([]byte(r.Header.Get("Authorization"))) |
| + exp := sha256.Sum256([]byte("Bearer " + want)) |
| + if subtle.ConstantTimeCompare(got[:], exp[:]) != 1 { |
| + http.Error(w, "unauthorized", http.StatusUnauthorized) |
| + return |
| + } |
| + fn(w, r) |
| + } |
| + } |
| + |
| + mux.HandleFunc("PUT /schedule/{name}", authed(func(w http.ResponseWriter, r *http.Request) { |
| + name := r.PathValue("name") |
| + if !ValidName(name) { |
| + http.Error(w, "name: want [a-z0-9][a-z0-9-]{0,31}", http.StatusBadRequest) |
| + return |
| + } |
| + var body struct { |
| + At string `json:"at"` |
| + Path string `json:"path"` |
| + } |
| + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil { |
| + http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest) |
| + return |
| + } |
| + atT, err := time.Parse(time.RFC3339, body.At) |
| + if err != nil { |
| + http.Error(w, "at: want RFC3339, e.g. 2026-09-01T08:00:00Z", http.StatusBadRequest) |
| + return |
| + } |
| + if atT.After(now().Add(MaxOnceAhead)) { |
| + http.Error(w, "at: more than 400 days ahead", http.StatusBadRequest) |
| + return |
| + } |
| + if err := ValidPath(body.Path); err != nil { |
| + http.Error(w, err.Error(), http.StatusBadRequest) |
| + return |
| + } |
| + existing, err := store.GetTimer(host, name) |
| + switch { |
| + case err == nil && existing.Kind != KindOnce: |
| + http.Error(w, "name is a declared schedule", http.StatusConflict) |
| + return |
| + case err != nil && !errors.Is(err, os.ErrNotExist): |
| + http.Error(w, "registry error", http.StatusInternalServerError) |
| + return |
| + case err != nil: |
| + n, cerr := store.CountTimers(host, KindOnce) |
| + if cerr != nil { |
| + http.Error(w, "registry error", http.StatusInternalServerError) |
| + return |
| + } |
| + if n >= MaxOncePerHost { |
| + http.Error(w, "too many one-shot timers (limit 1000) — delete finished ones or use a schedule", http.StatusTooManyRequests) |
| + return |
| + } |
| + } |
| + sp := Spec{Kind: KindOnce, At: atT.UTC()} |
| + t := registry.Timer{Host: host, Name: name, Kind: KindOnce, Spec: sp.String(), Path: body.Path, NextAt: atT.Unix(), CreatedAt: now().Unix()} |
| + if err := store.UpsertTimer(t); err != nil { |
| + http.Error(w, "registry error", http.StatusInternalServerError) |
| + return |
| + } |
| + row, err := store.GetTimer(host, name) |
| + if err != nil { |
| + http.Error(w, "registry error", http.StatusInternalServerError) |
| + return |
| + } |
| + writeTimer(w, *row) |
| + })) |
| + |
| + mux.HandleFunc("DELETE /schedule/{name}", authed(func(w http.ResponseWriter, r *http.Request) { |
| + name := r.PathValue("name") |
| + if !ValidName(name) { |
| + http.Error(w, "bad name", http.StatusBadRequest) |
| + return |
| + } |
| + if row, err := store.GetTimer(host, name); err == nil && row.Kind != KindOnce { |
| + http.Error(w, "name is a declared schedule — remove it with carlos schedule rm", http.StatusConflict) |
| + return |
| + } |
| + if err := store.DeleteTimer(host, name); err != nil { |
| + http.Error(w, "registry error", http.StatusInternalServerError) |
| + return |
| + } |
| + w.WriteHeader(http.StatusNoContent) |
| + })) |
| + |
| + mux.HandleFunc("GET /schedule", authed(func(w http.ResponseWriter, r *http.Request) { |
| + rows, err := store.TimersFor(host) |
| + if err != nil { |
| + http.Error(w, "registry error", http.StatusInternalServerError) |
| + return |
| + } |
| + out := struct { |
| + Timers []TimerStatus `json:"timers"` |
| + }{Timers: []TimerStatus{}} |
| + for _, row := range rows { |
| + out.Timers = append(out.Timers, toStatus(row)) |
| + } |
| + w.Header().Set("Content-Type", "application/json") |
| + json.NewEncoder(w).Encode(out) |
| + })) |
| +} |
| + |
| +// writeTimer writes one registry row as its wire TimerStatus (toStatus is |
| +// converge.go's — one row-to-status mapping for the whole package). |
| +func writeTimer(w http.ResponseWriter, r registry.Timer) { |
| + w.Header().Set("Content-Type", "application/json") |
| + json.NewEncoder(w).Encode(toStatus(r)) |
| +} |
diff --git a/internal/schedule/control_test.go b/internal/schedule/control_test.go| new file mode 100644 |
| index 000000000..3349da0d0 |
| --- /dev/null |
| +++ b/internal/schedule/control_test.go |
| @@ -0,0 +1,127 @@ |
| +package schedule |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func controlFixture(t *testing.T) (*registry.Registry, *http.ServeMux) { |
| + t.Helper() |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "r.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + mux := http.NewServeMux() |
| + Mount(mux, "a.bab.oncarlos.com", reg, func() (string, error) { return "tok", nil }, func() time.Time { return at("2026-08-23T13:07:00Z") }) |
| + return reg, mux |
| +} |
| + |
| +func do(mux *http.ServeMux, method, path, auth, body string) *httptest.ResponseRecorder { |
| + req := httptest.NewRequest(method, path, strings.NewReader(body)) |
| + if auth != "" { |
| + req.Header.Set("Authorization", auth) |
| + } |
| + rec := httptest.NewRecorder() |
| + mux.ServeHTTP(rec, req) |
| + return rec |
| +} |
| + |
| +func TestControlRequiresTheHostsToken(t *testing.T) { |
| + _, mux := controlFixture(t) |
| + for _, auth := range []string{"", "Bearer wrong", "Basic tok"} { |
| + if rec := do(mux, "PUT", "/schedule/r", auth, `{"at":"2026-09-01T08:00:00Z","path":"/r"}`); rec.Code != http.StatusUnauthorized { |
| + t.Errorf("auth %q → %d, want 401", auth, rec.Code) |
| + } |
| + if rec := do(mux, "GET", "/schedule", auth, ""); rec.Code != http.StatusUnauthorized { |
| + t.Errorf("GET auth %q → %d", auth, rec.Code) |
| + } |
| + } |
| +} |
| + |
| +func TestControlPutGetDelete(t *testing.T) { |
| + reg, mux := controlFixture(t) |
| + rec := do(mux, "PUT", "/schedule/remind", "Bearer tok", `{"at":"2026-09-01T08:00:00Z","path":"/jobs/remind"}`) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("put = %d %s", rec.Code, rec.Body) |
| + } |
| + var ts TimerStatus |
| + json.Unmarshal(rec.Body.Bytes(), &ts) |
| + if ts.Kind != KindOnce || ts.NextAt != at("2026-09-01T08:00:00Z").Unix() || ts.Path != "/jobs/remind" || ts.Spec != "at "+itoa(ts.NextAt) { |
| + t.Fatalf("put body = %+v", ts) |
| + } |
| + tm, _ := reg.GetTimer("a.bab.oncarlos.com", "remind") |
| + if tm == nil || tm.Kind != KindOnce { |
| + t.Fatalf("row = %+v", tm) |
| + } |
| + // Upsert by name moves the time. |
| + do(mux, "PUT", "/schedule/remind", "Bearer tok", `{"at":"2026-09-02T08:00:00Z","path":"/jobs/remind"}`) |
| + tm, _ = reg.GetTimer("a.bab.oncarlos.com", "remind") |
| + if tm.NextAt != at("2026-09-02T08:00:00Z").Unix() { |
| + t.Fatalf("upsert did not move: %+v", tm) |
| + } |
| + rec = do(mux, "GET", "/schedule", "Bearer tok", "") |
| + var list struct{ Timers []TimerStatus } |
| + json.Unmarshal(rec.Body.Bytes(), &list) |
| + if len(list.Timers) != 1 || list.Timers[0].Name != "remind" { |
| + t.Fatalf("list = %+v", list) |
| + } |
| + if rec := do(mux, "DELETE", "/schedule/remind", "Bearer tok", ""); rec.Code != http.StatusNoContent { |
| + t.Fatalf("delete = %d", rec.Code) |
| + } |
| + if rec := do(mux, "DELETE", "/schedule/remind", "Bearer tok", ""); rec.Code != http.StatusNoContent { |
| + t.Fatalf("second delete = %d, want 204 (idempotent)", rec.Code) |
| + } |
| +} |
| + |
| +func TestControlRefusals(t *testing.T) { |
| + reg, mux := controlFixture(t) |
| + reg.UpsertTimer(registry.Timer{Host: "a.bab.oncarlos.com", Name: "sync", Kind: KindSchedule, Spec: "every 6h0m0s", Path: "/s", NextAt: 1}) |
| + cases := []struct { |
| + name, body string |
| + want int |
| + }{ |
| + {"sync", `{"at":"2026-09-01T08:00:00Z","path":"/p"}`, http.StatusConflict}, |
| + {"Bad", `{"at":"2026-09-01T08:00:00Z","path":"/p"}`, http.StatusBadRequest}, |
| + {"ok", `{"at":"2026-09-01T08:00:00Z","path":"p"}`, http.StatusBadRequest}, |
| + {"ok", `{"at":"soon","path":"/p"}`, http.StatusBadRequest}, |
| + {"ok", `{"at":"2028-09-01T08:00:00Z","path":"/p"}`, http.StatusBadRequest}, // > 400 days ahead |
| + {"ok", `not json`, http.StatusBadRequest}, |
| + } |
| + for _, c := range cases { |
| + if rec := do(mux, "PUT", "/schedule/"+c.name, "Bearer tok", c.body); rec.Code != c.want { |
| + t.Errorf("PUT %s %s → %d, want %d (%s)", c.name, c.body, rec.Code, c.want, rec.Body) |
| + } |
| + } |
| + // A past `at` is accepted and fires on the next tick. |
| + if rec := do(mux, "PUT", "/schedule/past", "Bearer tok", `{"at":"2020-01-01T00:00:00Z","path":"/p"}`); rec.Code != http.StatusOK { |
| + t.Fatalf("past at → %d", rec.Code) |
| + } |
| + if tm, _ := reg.GetTimer("a.bab.oncarlos.com", "past"); tm.NextAt != at("2020-01-01T00:00:00Z").Unix() { |
| + t.Fatalf("past row = %+v", tm) |
| + } |
| +} |
| + |
| +func TestControlCapsOncePerHost(t *testing.T) { |
| + reg, mux := controlFixture(t) |
| + for i := 0; i < MaxOncePerHost; i++ { |
| + reg.UpsertTimer(registry.Timer{Host: "a.bab.oncarlos.com", Name: "n" + itoa(int64(i)), Kind: KindOnce, Spec: "at 1", Path: "/p", NextAt: 1}) |
| + } |
| + if rec := do(mux, "PUT", "/schedule/over", "Bearer tok", `{"at":"2026-09-01T08:00:00Z","path":"/p"}`); rec.Code != http.StatusTooManyRequests { |
| + t.Fatalf("over cap → %d", rec.Code) |
| + } |
| + // Replacing an existing name is not a new row and must still work. |
| + if rec := do(mux, "PUT", "/schedule/n1", "Bearer tok", `{"at":"2026-09-01T08:00:00Z","path":"/p"}`); rec.Code != http.StatusOK { |
| + t.Fatalf("replace at cap → %d", rec.Code) |
| + } |
| +} |
| + |
| +func itoa(n int64) string { return strconv.FormatInt(n, 10) } |