carlos / platform Public

console: schedule doors — list, set, remove, run (Operate)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 9fa030fde1bed6885a6b4a070d61897ba6a97c3d parent 501d0ed
3 files changed, +713 −0
  • internal/console/apischedule.go +283 −0
  • internal/console/apischedule_test.go +419 −0
  • internal/console/authcli.go +11 −0
diff --git a/internal/console/apischedule.go b/internal/console/apischedule.go
new file mode 100644
index 000000000..f6d22c723
--- /dev/null
+++ b/internal/console/apischedule.go
@@ -0,0 +1,283 @@
+package console
+
+// The four `carlos schedule` doors (spec §8): the member-facing way to say
+// "run this path every 6h", to take it back, and to make it fire now — with
+// no box shell, which is what the platform's own rule demands of every
+// command ("every carlos command works for a user with zero infra access").
+//
+// What the console LEARNS by relaying this is schedule names and paths:
+// metadata of exactly the class it already holds for config var names. No
+// payload crosses these doors in either direction — a tick is the box's agent
+// calling an instance it already spawned, and this console never sees one.
+//
+// Why the objects these doors write are UNSIGNED, unlike a channel pointer:
+// nothing executes because schedules.json says so. The declaration only tells
+// a box WHEN to POST to a path the app itself already serves; the code that
+// runs is whatever the signed release put there. A bucket writer who could
+// forge a declaration could already promote an arbitrary release to the same
+// app, which is strictly more (spec §10). So these keys sit beside
+// restart-gen.json in the same trust class: additive, absent-is-legal-forever
+// app metadata that the release-signing key has no opinion about.
+//
+// The serial bump after every write is the delivery mechanism, not
+// decoration — restart's reasoning verbatim. Boxes do not poll declarations;
+// they read them when the serial moves. A declaration written without a bump
+// still converges on the hourly root pass, which is the floor, not the
+// promise `carlos schedule set` makes.
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/carlosframework/platform/internal/schedule"
+)
+
+// apiScheduleList is the one wire shape three of the four doors answer with:
+// what the app has DECLARED, plus what each instance says it will actually do
+// next (spec §4.3). Both arrays are always present — see scheduleAnswer.
+type apiScheduleList struct {
+ Account string `json:"account"`
+ App string `json:"app"`
+ Schedules []schedule.Schedule `json:"schedules"`
+ Instances []schedule.Status `json:"instances"`
+ UpdatedAt string `json:"updated_at"`
+}
+
+// scheduleAnswer builds that shape with `[]` where the store handed back nil.
+// An app that has declared nothing, and one whose boxes have not reported
+// yet, are the COMMON cases here — and `null` for either would make the CLI's
+// range over them a nil-deref-shaped bug at the far end of a release. The
+// empty array says the same thing and says it in a shape the caller can use.
+func scheduleAnswer(acct, app string, d schedule.Declaration, insts []schedule.Status) apiScheduleList {
+ out := apiScheduleList{
+ Account: acct, App: app,
+ Schedules: d.Schedules, Instances: insts, UpdatedAt: d.UpdatedAt,
+ }
+ if out.Schedules == nil {
+ out.Schedules = []schedule.Schedule{}
+ }
+ if out.Instances == nil {
+ out.Instances = []schedule.Status{}
+ }
+ return out
+}
+
+// apiScheduleApp is the shared gate of all four doors, in one place so the
+// ORDER cannot drift between them:
+//
+// token — cliUser's one 401, before any name is resolved.
+// account+app 404 — apiAccountApp: an unknown account, someone else's
+// account and an app that isn't there are one answer, so
+// probing these doors teaches a stranger nothing. A
+// TRASHED app is unreachable for the same reason restart
+// is: the live-apps lookup simply does not answer for it.
+// rate limit — the shared `write` budget, keyed on the ADDRESS like
+// promote's: a budget belongs to the person, not the
+// terminal. Mutating doors only; a read is not rationed.
+// `run` needs it most — its whole effect is to make an
+// app do work now.
+// Releases nil — 503. Every one of these doors IS a bucket object (or a
+// read of one); a console with no bucket must not answer
+// as though a box would ever see it.
+//
+// cannot completes the 503's sentence, so the caller is told what this
+// console cannot do rather than what it is missing.
+func (s *Server) apiScheduleApp(w http.ResponseWriter, r *http.Request, mutating bool, cannot string) (string, *Account, string, bool) {
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return "", nil, "", false
+ }
+ if mutating {
+ p, _ := principalFrom(r.Context())
+ if !s.allowWrite(p, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
+ return "", nil, "", false
+ }
+ }
+ if s.cfg.Releases == nil {
+ jsonError(w, http.StatusServiceUnavailable, "no deployment bucket on this host — "+cannot)
+ return "", nil, "", false
+ }
+ return addr, acct, app, true
+}
+
+// declaredSchedule resolves the {name} segment of the two name-scoped doors.
+//
+// An invalid name and an undeclared one are the SAME answer, and it is
+// http.NotFound — byte for byte what apiAccountApp writes for an app that
+// isn't there. A name is not an account, but a probe is a probe: a member of
+// one account must not be able to tell "you declared nothing called that"
+// from "there is no such app" by the shape of the refusal, and neither must a
+// service credential. An invalid name never reaches the store at all.
+func (s *Server) declaredSchedule(ctx context.Context, w http.ResponseWriter, r *http.Request, acct, app string) (schedule.Schedule, bool) {
+ name := r.PathValue("name")
+ if !schedule.ValidName(name) {
+ http.NotFound(w, r)
+ return schedule.Schedule{}, false
+ }
+ d, _, err := schedule.ReadDeclaration(ctx, s.cfg.Releases, acct, app)
+ if err != nil {
+ // Logged, not returned: a store error carries bucket names, endpoints
+ // and request ids.
+ log.Printf("console: api schedule read %s/%s: %v", acct, app, err)
+ jsonError(w, http.StatusBadGateway, "could not read the schedules — nothing changed; try again")
+ return schedule.Schedule{}, false
+ }
+ sc, found := d.Find(name)
+ if !found {
+ http.NotFound(w, r)
+ return schedule.Schedule{}, false
+ }
+ return sc, true
+}
+
+// handleAPIScheduleList answers `carlos schedule ls`: the declaration, plus
+// every box's report for the app.
+//
+// The two halves answer different questions and both are needed. The
+// declaration says what was ASKED for; the status objects say what each
+// instance is actually going to do next and what happened last time — which
+// is the only honest answer available to a member who cannot log into a box.
+//
+// A report that will not parse does NOT fail the door. ListStatus returns the
+// readable ones plus a joined error, and one box writing rubbish must not
+// blank the list for every other instance of the app; the error is logged so
+// it is findable, and the caller gets what is knowable. The DECLARATION is
+// the other way round: it is the answer, not a contribution to it, so a store
+// error there is a 502.
+//
+// No write budget and no audit line: it is a read.
+func (s *Server) handleAPIScheduleList(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, ok := s.apiScheduleApp(w, r, false, "this console cannot read schedules")
+ if !ok {
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ d, _, err := schedule.ReadDeclaration(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ log.Printf("console: api schedule list %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not read the schedules — try again")
+ return
+ }
+ insts, err := schedule.ListStatus(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ log.Printf("console: api schedule status %s/%s: %v", acct.Sqid, app, err)
+ }
+ writeJSON(w, http.StatusOK, scheduleAnswer(acct.Sqid, app, d, insts))
+}
+
+// handleAPIScheduleSet answers `carlos schedule set`: validate, CAS the
+// schedule into schedules.json by name, announce.
+//
+// Validation runs HERE, before anything is read or written, and answers with
+// the validator's own words. A fixed sentence would make the member guess
+// which of name/every/cron/path was wrong, and the CLI runs the same
+// functions client-side so most typos never reach this door at all.
+//
+// The per-app ceiling is checked against the declaration we are about to
+// modify, so it answers as the member's problem (400, naming the number)
+// rather than as the bucket failure it would look like coming back out of
+// UpsertSchedule. That call enforces the cap again under CAS — this check is
+// for the ANSWER, not for the guarantee.
+//
+// The response carries no instance reports (`instances: []`). Nothing has
+// been delivered yet at the moment this returns: the boxes have been told to
+// look, and `carlos schedule ls` a second later is what shows them looking.
+func (s *Server) handleAPIScheduleSet(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiScheduleApp(w, r, true, "this console cannot record a schedule")
+ if !ok {
+ return
+ }
+ var sc schedule.Schedule
+ if !readJSON(w, r, &sc) {
+ return
+ }
+ if err := sc.Validate(); err != nil {
+ jsonError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ if cur, _, err := schedule.ReadDeclaration(ctx, s.cfg.Releases, acct.Sqid, app); err == nil {
+ if _, replacing := cur.Find(sc.Name); !replacing && len(cur.Schedules) >= schedule.MaxSchedulesPerApp {
+ jsonError(w, http.StatusBadRequest, fmt.Sprintf(
+ "an app may declare at most %d schedules — remove one first", schedule.MaxSchedulesPerApp))
+ return
+ }
+ }
+ d, err := schedule.UpsertSchedule(ctx, s.cfg.Releases, acct.Sqid, app, sc, time.Now())
+ if err != nil {
+ log.Printf("console: api schedule set %s/%s %s: %v", acct.Sqid, app, sc.Name, err)
+ jsonError(w, http.StatusBadGateway, "could not record the schedule — nothing changed; try again")
+ return
+ }
+ s.audit(acct, app, addr, "schedule set", "recorded schedule "+sc.Name+" (cli)")
+ // The declaration is in the bucket; this is what makes boxes LOOK at it.
+ s.bumpSerial(ctx)
+ writeJSON(w, http.StatusOK, scheduleAnswer(acct.Sqid, app, d, nil))
+}
+
+// handleAPIScheduleRemove answers `carlos schedule rm`: drop the named
+// schedule and announce, so the boxes stop firing it within seconds rather
+// than at the next hourly pass.
+//
+// The name is resolved BEFORE the removal, so an undeclared one is the
+// byte-identical 404 with nothing written — removing a name that was never
+// there would otherwise rewrite updated_at and bump the serial for a change
+// that isn't one.
+func (s *Server) handleAPIScheduleRemove(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiScheduleApp(w, r, true, "this console cannot remove a schedule")
+ if !ok {
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ sc, ok := s.declaredSchedule(ctx, w, r, acct.Sqid, app)
+ if !ok {
+ return
+ }
+ d, _, err := schedule.RemoveSchedule(ctx, s.cfg.Releases, acct.Sqid, app, sc.Name, time.Now())
+ if err != nil {
+ log.Printf("console: api schedule rm %s/%s %s: %v", acct.Sqid, app, sc.Name, err)
+ jsonError(w, http.StatusBadGateway, "could not remove the schedule — nothing changed; try again")
+ return
+ }
+ s.audit(acct, app, addr, "schedule rm", "removed schedule "+sc.Name+" (cli)")
+ s.bumpSerial(ctx)
+ writeJSON(w, http.StatusOK, scheduleAnswer(acct.Sqid, app, d, nil))
+}
+
+// handleAPIScheduleRun answers `carlos schedule run`: touch
+// schedule-run.json, whose ETAG MOVEMENT is the signal (never its content),
+// then announce — the same mechanism and the same rules as restart-gen.
+//
+// It answers "requested", not "fired", for restart's reason: what lands here
+// is a request in a bucket. Whether a given instance fires it depends on that
+// box seeing the serial move, and on the instance being awake — hibernating
+// ones are never woken by this.
+func (s *Server) handleAPIScheduleRun(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiScheduleApp(w, r, true, "this console cannot request a run")
+ if !ok {
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ sc, ok := s.declaredSchedule(ctx, w, r, acct.Sqid, app)
+ if !ok {
+ return
+ }
+ if err := schedule.TouchRun(ctx, s.cfg.Releases, acct.Sqid, app, sc.Name, time.Now()); err != nil {
+ log.Printf("console: api schedule run %s/%s %s: %v", acct.Sqid, app, sc.Name, err)
+ jsonError(w, http.StatusBadGateway, "could not request the run — nothing was fired; try again")
+ return
+ }
+ s.audit(acct, app, addr, "schedule run", "requested a run of schedule "+sc.Name+" (cli)")
+ s.bumpSerial(ctx)
+ writeJSON(w, http.StatusOK, map[string]any{
+ "requested": true, "account": acct.Sqid, "app": app, "name": sc.Name,
+ })
+}
diff --git a/internal/console/apischedule_test.go b/internal/console/apischedule_test.go
new file mode 100644
index 000000000..601d8f09b
--- /dev/null
+++ b/internal/console/apischedule_test.go
@@ -0,0 +1,419 @@
+package console
+
+// The four `carlos schedule` doors (spec §8). What is pinned here, in the
+// order it would hurt if lost: a recorded schedule actually lands in the
+// bucket AND is announced (a declaration nobody hears is a schedule that
+// fires an hour late at best); a rejected one lands NOTHING and announces
+// nothing; the list answers with `[]` rather than `null` for an app that has
+// declared nothing (the CLI ranges over both arrays); and a stranger — or a
+// member naming a schedule that does not exist — gets the same bytes back as
+// someone probing for an app that isn't there.
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/schedule"
+)
+
+// scheduleFixture is restartFixture's twin: an app in the ops account and a
+// MEMBER token for it. Declaring recurring work is operating an app, not
+// publishing a release, so member-level is the whole gate.
+func scheduleFixture(t *testing.T, code string) (*consoleFixture, string) {
+ t.Helper()
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
+ t.Fatal(err)
+ }
+ return fx, cliToken(t, fx, "alice@keymail.dev", code)
+}
+
+// declaredSchedules reads schedules.json straight from the bucket — what the
+// boxes will converge from, as opposed to what the door said it did.
+func declaredSchedules(t *testing.T, fx *consoleFixture, app string) (schedule.Declaration, string) {
+ t.Helper()
+ d, etag, err := schedule.ReadDeclaration(context.Background(), fx.rel, release.OpsAccount, app)
+ if err != nil {
+ t.Fatalf("read declaration: %v", err)
+ }
+ return d, etag
+}
+
+// runEtag is what every box watches for `run`: "" when nobody has ever asked.
+func runEtag(t *testing.T, fx *consoleFixture, app string) string {
+ t.Helper()
+ _, etag, err := fx.rel.Get(context.Background(), schedule.RunKey(release.OpsAccount, app))
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return ""
+ }
+ t.Fatalf("read run signal: %v", err)
+ }
+ return etag
+}
+
+func decodeScheduleList(t *testing.T, out []byte) apiScheduleList {
+ t.Helper()
+ var res apiScheduleList
+ if err := json.Unmarshal(out, &res); err != nil {
+ t.Fatalf("schedule body %s: %v", out, err)
+ }
+ return res
+}
+
+// TestAPIScheduleSetRecordsAndAnnouncesIt is the write door whole: the object
+// a box converges from holds the schedule, the serial says "look now", and
+// the answer names the canonical account and the declaration as stored.
+func TestAPIScheduleSetRecordsAndAnnouncesIt(t *testing.T) {
+ fx, token := scheduleFixture(t, "code-schedule-set")
+ if _, etag := declaredSchedules(t, fx, "titogo"); etag != "" {
+ t.Fatalf("schedules.json exists before any request: %q", etag)
+ }
+
+ body := `{"name":"sync","every":"6h","path":"/jobs/sync"}`
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token, body)
+ if status != http.StatusOK {
+ t.Fatalf("set = %d %s, want 200", status, out)
+ }
+ res := decodeScheduleList(t, out)
+ if res.Account != "bab" || res.App != "titogo" {
+ t.Fatalf("set body = %+v, want bab/titogo", res)
+ }
+ if len(res.Schedules) != 1 || res.Schedules[0].Name != "sync" || res.Schedules[0].Path != "/jobs/sync" {
+ t.Fatalf("set body schedules = %+v, want the one just recorded", res.Schedules)
+ }
+ if res.UpdatedAt == "" {
+ t.Error("set body carries no updated_at — the CLI has nothing to show for the write")
+ }
+ // The write door answers about the DECLARATION; instance reports belong
+ // to the list door, and must be [] rather than null either way.
+ if res.Instances == nil {
+ t.Error("set body instances is null; the CLI ranges over it")
+ }
+
+ d, etag := declaredSchedules(t, fx, "titogo")
+ if etag == "" {
+ t.Fatal("no schedules.json in the bucket after a 200 — no box will ever converge it")
+ }
+ sc, ok := d.Find("sync")
+ if !ok {
+ t.Fatalf("bucket declaration = %+v, want sync in it", d.Schedules)
+ }
+ if sc.Path != "/jobs/sync" || sc.Every != "6h0m0s" {
+ t.Errorf("stored schedule = %+v, want the normalised every/path", sc)
+ }
+ if got := serial(t, fx); got != 1 {
+ t.Fatalf("serial after a set = %d, want 1 (otherwise boxes wait out the hourly floor)", got)
+ }
+
+ // A second set REPLACES by name rather than appending, and announces again.
+ status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token,
+ `{"name":"sync","cron":"0 8 * * *","path":"/jobs/sync2"}`)
+ if status != http.StatusOK {
+ t.Fatalf("second set = %d %s", status, out)
+ }
+ d, _ = declaredSchedules(t, fx, "titogo")
+ if len(d.Schedules) != 1 || d.Schedules[0].Path != "/jobs/sync2" || d.Schedules[0].Every != "" {
+ t.Fatalf("declaration after replace = %+v, want one schedule on cron", d.Schedules)
+ }
+ if got := serial(t, fx); got != 2 {
+ t.Fatalf("serial after two sets = %d, want 2", got)
+ }
+}
+
+// TestAPIScheduleSetRefusesAnImpossibleInterval: validation happens before
+// anything is written, so a typo costs a round trip and nothing else — no
+// object, no announcement, and the validator's own words back (a fixed
+// sentence would make the member guess which field was wrong).
+func TestAPIScheduleSetRefusesAnImpossibleInterval(t *testing.T) {
+ fx, token := scheduleFixture(t, "code-schedule-set-bad")
+
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token,
+ `{"name":"sync","every":"30s","path":"/jobs/sync"}`)
+ if status != http.StatusBadRequest {
+ t.Fatalf("set every=30s = %d %s, want 400", status, out)
+ }
+ if !bytes.Contains(out, []byte("shortest interval")) {
+ t.Errorf("400 body = %s, want the validator's sentence", out)
+ }
+ if _, etag := declaredSchedules(t, fx, "titogo"); etag != "" {
+ t.Error("a refused schedule was written to the bucket")
+ }
+ if got := serial(t, fx); got != 0 {
+ t.Fatalf("serial after a refused set = %d, want 0 — the fleet was woken for nothing", got)
+ }
+}
+
+// TestAPIScheduleSetRefusesPastTheCap: the per-app ceiling answers as the
+// member's problem (400, with the number), not as a bucket failure.
+func TestAPIScheduleSetRefusesPastTheCap(t *testing.T) {
+ fx, token := scheduleFixture(t, "code-schedule-cap")
+ ctx, now := context.Background(), time.Now()
+ for i := 0; i < schedule.MaxSchedulesPerApp; i++ {
+ sc := schedule.Schedule{Name: "job-" + string(rune('a'+i)), Every: "1h", Path: "/jobs/x"}
+ if _, err := schedule.UpsertSchedule(ctx, fx.rel, release.OpsAccount, "titogo", sc, now); err != nil {
+ t.Fatalf("seed %d: %v", i, err)
+ }
+ }
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token,
+ `{"name":"onemore","every":"1h","path":"/jobs/x"}`)
+ if status != http.StatusBadRequest {
+ t.Fatalf("set past the cap = %d %s, want 400", status, out)
+ }
+ if !bytes.Contains(out, []byte("at most")) {
+ t.Errorf("cap refusal = %s, want it to name the ceiling", out)
+ }
+ if got := serial(t, fx); got != 0 {
+ t.Fatalf("serial after a refused set = %d, want 0", got)
+ }
+}
+
+// TestAPIScheduleListJoinsDeclarationAndInstanceReports: the declaration says
+// what was ASKED; the status objects say what each instance will actually do
+// next. `carlos schedule ls` is only honest for a member with no box access
+// if this door carries both.
+func TestAPIScheduleListJoinsDeclarationAndInstanceReports(t *testing.T) {
+ fx, token := scheduleFixture(t, "code-schedule-ls")
+
+ // An app that has declared nothing answers with EMPTY ARRAYS, never null.
+ status, raw := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token)
+ if status != http.StatusOK {
+ t.Fatalf("list before anything = %d %s, want 200", status, raw)
+ }
+ if !bytes.Contains([]byte(raw), []byte(`"schedules":[]`)) || !bytes.Contains([]byte(raw), []byte(`"instances":[]`)) {
+ t.Fatalf("empty list body = %s, want [] for both arrays (the CLI ranges over them)", raw)
+ }
+
+ if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token,
+ `{"name":"sync","every":"6h","path":"/jobs/sync"}`); status != http.StatusOK {
+ t.Fatalf("set = %d %s", status, out)
+ }
+ // A box's report, written the way a box writes it.
+ st := schedule.Status{
+ Host: "x.bab.oncarlos.com",
+ Box: "carlos-ie-1",
+ ReportedAt: time.Now().UTC().Format(time.RFC3339),
+ Timers: []schedule.TimerStatus{{
+ Name: "sync", Kind: "schedule", Spec: "every 6h0m0s", Path: "/jobs/sync",
+ NextAt: 1787004000, LastAt: 1786982400, LastStatus: "200", LastMs: 3210,
+ }},
+ }
+ if err := schedule.WriteStatus(context.Background(), fx.rel, release.OpsAccount, "titogo", st); err != nil {
+ t.Fatal(err)
+ }
+
+ status, raw = getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token)
+ if status != http.StatusOK {
+ t.Fatalf("list = %d %s, want 200", status, raw)
+ }
+ res := decodeScheduleList(t, []byte(raw))
+ if len(res.Schedules) != 1 || res.Schedules[0].Name != "sync" {
+ t.Fatalf("list schedules = %+v, want the declaration", res.Schedules)
+ }
+ if len(res.Instances) != 1 {
+ t.Fatalf("list instances = %+v, want the one report a box wrote", res.Instances)
+ }
+ got := res.Instances[0]
+ if got.Host != "x.bab.oncarlos.com" || got.Box != "carlos-ie-1" {
+ t.Errorf("instance report = %+v, want the box's own", got)
+ }
+ if len(got.Timers) != 1 || got.Timers[0].NextAt != 1787004000 || got.Timers[0].LastStatus != "200" {
+ t.Errorf("instance timers = %+v, want the box's next/last verbatim", got.Timers)
+ }
+}
+
+// TestAPIScheduleRemoveDropsAndAnnounces, and the 404 for a name that was
+// never declared is the SAME BYTES as the 404 for an app that is not there:
+// a name is not an account, but a probe is a probe.
+func TestAPIScheduleRemoveDropsAndAnnounces(t *testing.T) {
+ fx, token := scheduleFixture(t, "code-schedule-rm")
+ if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token,
+ `{"name":"sync","every":"6h","path":"/jobs/sync"}`); status != http.StatusOK {
+ t.Fatalf("set = %d %s", status, out)
+ }
+
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules/sync/delete", token, "")
+ if status != http.StatusOK {
+ t.Fatalf("delete = %d %s, want 200", status, out)
+ }
+ res := decodeScheduleList(t, out)
+ if len(res.Schedules) != 0 {
+ t.Errorf("delete body schedules = %+v, want none left", res.Schedules)
+ }
+ if res.Schedules == nil {
+ t.Error("delete body schedules is null; the CLI ranges over it")
+ }
+ d, _ := declaredSchedules(t, fx, "titogo")
+ if _, ok := d.Find("sync"); ok {
+ t.Error("sync is still in the bucket declaration after a 200")
+ }
+ if got := serial(t, fx); got != 2 {
+ t.Fatalf("serial after set+delete = %d, want 2", got)
+ }
+
+ // An undeclared name, byte-for-byte against an app that is not there.
+ unknownName, nameBody := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules/nosuch/delete", map[string]string{}, token)
+ unknownApp, appBody := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/nosuchapp/schedules/sync/delete", map[string]string{}, token)
+ if unknownName != http.StatusNotFound || unknownApp != http.StatusNotFound {
+ t.Fatalf("unknown name = %d, unknown app = %d, want 404 both", unknownName, unknownApp)
+ }
+ if !bytes.Equal([]byte(nameBody), []byte(appBody)) {
+ t.Errorf("undeclared-name 404 body %q differs from unknown-app 404 body %q", nameBody, appBody)
+ }
+ // An invalid name is the same answer: nothing about it reaches the store.
+ if code, body := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules/NOT_A_NAME/delete", map[string]string{}, token); code != http.StatusNotFound || body != appBody {
+ t.Errorf("invalid name = %d %q, want the unknown-app 404", code, body)
+ }
+ if got := serial(t, fx); got != 2 {
+ t.Fatalf("serial after three refusals = %d, want 2 — a refusal must not wake the fleet", got)
+ }
+}
+
+// TestAPIScheduleRunTouchesTheSignal: the etag movement IS the signal, so a
+// second run must move it again, and a run for a name nobody declared must
+// not touch it at all.
+func TestAPIScheduleRunTouchesTheSignal(t *testing.T) {
+ fx, token := scheduleFixture(t, "code-schedule-run")
+ if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token,
+ `{"name":"sync","every":"6h","path":"/jobs/sync"}`); status != http.StatusOK {
+ t.Fatalf("set = %d %s", status, out)
+ }
+ if got := runEtag(t, fx, "titogo"); got != "" {
+ t.Fatalf("run signal exists before any run: %q", got)
+ }
+
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules/sync/run", token, "")
+ if status != http.StatusOK {
+ t.Fatalf("run = %d %s, want 200", status, out)
+ }
+ var res struct {
+ Requested bool `json:"requested"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Name string `json:"name"`
+ }
+ if err := json.Unmarshal(out, &res); err != nil {
+ t.Fatalf("run body %s: %v", out, err)
+ }
+ if !res.Requested || res.Account != "bab" || res.App != "titogo" || res.Name != "sync" {
+ t.Fatalf("run body = %+v, want requested for bab/titogo sync", res)
+ }
+ first := runEtag(t, fx, "titogo")
+ if first == "" {
+ t.Fatal("no run signal in the bucket after a 200 — no box will ever fire it")
+ }
+ if got := serial(t, fx); got != 2 {
+ t.Fatalf("serial after set+run = %d, want 2", got)
+ }
+
+ if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules/sync/run", token, ""); status != http.StatusOK {
+ t.Fatalf("second run = %d %s", status, out)
+ }
+ if second := runEtag(t, fx, "titogo"); second == first {
+ t.Fatalf("run etag %q unchanged after a second run — change detection is by etag", second)
+ }
+ before := runEtag(t, fx, "titogo")
+
+ // Undeclared: the byte-identical 404, and the signal stays where it was.
+ code, body := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules/nosuch/run", map[string]string{}, token)
+ _, appBody := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/nosuchapp/schedules/sync/run", map[string]string{}, token)
+ if code != http.StatusNotFound {
+ t.Fatalf("run of an undeclared name = %d %s, want 404", code, body)
+ }
+ if !bytes.Equal([]byte(body), []byte(appBody)) {
+ t.Errorf("undeclared-name 404 body %q differs from unknown-app 404 body %q", body, appBody)
+ }
+ if after := runEtag(t, fx, "titogo"); after != before {
+ t.Errorf("a refused run touched the signal: %q -> %q", before, after)
+ }
+ if got := serial(t, fx); got != 3 {
+ t.Fatalf("serial after two runs and a refusal = %d, want 3", got)
+ }
+}
+
+// TestAPIScheduleStrangerLearnsNothing: a non-member's refusal is
+// byte-identical with an unknown app's on the read door, the same gate in the
+// same place as every other door here.
+func TestAPIScheduleStrangerLearnsNothing(t *testing.T) {
+ fx, _ := scheduleFixture(t, "code-schedule-stranger")
+ eve := cliToken(t, fx, "eve@keymail.dev", "code-schedule-eve")
+
+ status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", eve)
+ if status != http.StatusNotFound {
+ t.Fatalf("list as non-member = %d, want 404", status)
+ }
+ _, probe := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/nosuchapp/schedules", eve)
+ if !bytes.Equal([]byte(body), []byte(probe)) {
+ t.Errorf("non-member 404 body %q differs from unknown-app 404 body %q", body, probe)
+ }
+ if body != "404 page not found\n" {
+ t.Errorf("list as non-member body = %q, want the stdlib http.NotFound output", body)
+ }
+ if status, _ := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", ""); status != http.StatusUnauthorized {
+ t.Errorf("list with no token = %d, want 401", status)
+ }
+}
+
+// TestAPIScheduleWritesAreRateLimited: all three mutating doors spend the
+// shared per-person write budget, before they touch the bucket. `run` is the
+// one that most needs it — its whole effect is to make an app do work now.
+func TestAPIScheduleWritesAreRateLimited(t *testing.T) {
+ fx, token := scheduleFixture(t, "code-schedule-limit")
+ fx.srv.cli.write = newRateLimiter(1, time.Minute)
+ // The one request the budget allows, which also leaves `sync` declared —
+ // so the three refusals below are the BUDGET refusing, not a missing name.
+ if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token,
+ `{"name":"sync","every":"6h","path":"/jobs/sync"}`); status != http.StatusOK {
+ t.Fatalf("set = %d %s", status, out)
+ }
+
+ for _, u := range []string{
+ "/api/cli/apps/bab/titogo/schedules",
+ "/api/cli/apps/bab/titogo/schedules/sync/delete",
+ "/api/cli/apps/bab/titogo/schedules/sync/run",
+ } {
+ if status, out := postBody(t, fx.ts.URL+u, token, `{"name":"sync","every":"6h","path":"/jobs/sync"}`); status != http.StatusTooManyRequests {
+ t.Errorf("%s on an exhausted budget = %d %s, want 429", u, status, out)
+ }
+ }
+ // The READ door is unlimited, like every other read here.
+ if status, _ := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/schedules", token); status != http.StatusOK {
+ t.Errorf("list on an exhausted write budget = %d, want 200", status)
+ }
+}
+
+// TestAPIScheduleWithoutABucketIs503: the declaration IS a bucket object. A
+// console with nowhere to put it must say so rather than answer as if a box
+// would ever see it.
+func TestAPIScheduleWithoutABucketIs503(t *testing.T) {
+ fx, token := scheduleFixture(t, "code-schedule-nobucket")
+ fx.srv.cfg.Releases = nil
+
+ for _, c := range []struct{ method, path string }{
+ {http.MethodGet, "/api/cli/apps/bab/titogo/schedules"},
+ {http.MethodPost, "/api/cli/apps/bab/titogo/schedules"},
+ {http.MethodPost, "/api/cli/apps/bab/titogo/schedules/sync/delete"},
+ {http.MethodPost, "/api/cli/apps/bab/titogo/schedules/sync/run"},
+ } {
+ var status int
+ if c.method == http.MethodGet {
+ status, _ = getRaw(t, fx.ts.URL+c.path, token)
+ } else {
+ status, _ = postBody(t, fx.ts.URL+c.path, token, `{"name":"sync","every":"6h","path":"/jobs/sync"}`)
+ }
+ if status != http.StatusServiceUnavailable {
+ t.Errorf("%s %s with no bucket = %d, want 503", c.method, c.path, status)
+ }
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index e38a454c0..292d58812 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -482,6 +482,17 @@ func (s *Server) registerCLIRoutes() {
// promote and rollback, and the only one whose subject is a running
// process rather than a pointer.
s.cliRoute("POST /api/cli/apps/{account}/{app}/restart", svcrole.Allow(svcrole.Operate), s.handleAPIRestart)
+ // `carlos schedule` (apischedule.go, scheduled-work spec §8): declare
+ // recurring work, take it back, and make it fire now. The RESTART class,
+ // not the publish one — operating a running app rather than moving a
+ // version pointer — so the same Operate role reaches all four. `delete`
+ // and `run` are literal segments after the {name} wildcard, so a schedule
+ // named "delete" is still just a name; the mux prefers the literal
+ // regardless of registration order.
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/schedules", svcrole.Allow(svcrole.Operate), s.handleAPIScheduleList)
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/schedules", svcrole.Allow(svcrole.Operate), s.handleAPIScheduleSet)
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/schedules/{name}/delete", svcrole.Allow(svcrole.Operate), s.handleAPIScheduleRemove)
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/schedules/{name}/run", svcrole.Allow(svcrole.Operate), s.handleAPIScheduleRun)
// `carlos releases retention` (issue #136): a member's ambient-prune
// policy — read, set, clear, preview. Same identity gates as every
// door here; the safety list in release.PruneEligible is what keeps a