cli: carlos schedule ls | set | rm | run
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+850
−0
apimode.go+119 −0main.go+5 −0schedule.go+350 −0schedule_test.go+376 −0
diff --git a/apimode.go b/apimode.go| index ead8089ba..41292f243 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -36,6 +36,7 @@ import ( |
| "github.com/carlosframework/platform/internal/applog" |
| "github.com/carlosframework/platform/internal/caps" |
| "github.com/carlosframework/platform/internal/ledger" |
| + "github.com/carlosframework/platform/internal/schedule" |
| ) |
| // apiSession is the resolved API mode: which console, as whom. console and |
| @@ -1013,6 +1014,124 @@ func (s *apiSession) restart(ctx context.Context, a apiAcct, app string) (apiRes |
| return res, nil |
| } |
| +// apiScheduleList mirrors handleAPIScheduleList's wire shape |
| +// (internal/console/apischedule.go's scheduleAnswer) byte for byte — this |
| +// binary never imports internal/console, so the shape is redeclared here |
| +// rather than shared. Three of the four schedule doors answer with this; |
| +// scheduleRun answers with its own small {"requested":...} shape instead, |
| +// since a run touches no declaration. |
| +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"` |
| +} |
| + |
| +// scheduleDoorNotFoundErr is restart's own 404 widening, renamed: ls and |
| +// set carry no {name} segment, so a 404 from either can only be the |
| +// account/app membership gate (apiAccountApp) or a console that predates |
| +// this door at all. |
| +func scheduleDoorNotFoundErr() error { |
| + return fmt.Errorf("%w — or this console predates `carlos schedule` "+ |
| + "(no schedule door on it at all), in which case redeploy the console first", notFoundErr()) |
| +} |
| + |
| +// scheduleNameNotFoundErr widens scheduleDoorNotFoundErr for rm and run: |
| +// their {name} segment adds a THIRD cause behind the same byte-identical |
| +// 404 (declaredSchedule, internal/console/apischedule.go, answers an |
| +// undeclared name with the same http.NotFound apiAccountApp's membership |
| +// gate uses) — a probe must not be able to tell any of the three apart, so |
| +// the message names all of them, cheapest fix first. |
| +func scheduleNameNotFoundErr() error { |
| + return fmt.Errorf("%w, or -name is not declared — or this console predates `carlos schedule` "+ |
| + "(no schedule door on it at all), in which case redeploy the console first", notFoundErr()) |
| +} |
| + |
| +// scheduleList asks the console what app has declared plus what every |
| +// instance last reported (handleAPIScheduleList). A read: no write budget, |
| +// no audit line on the console side. |
| +func (s *apiSession) scheduleList(ctx context.Context, a apiAcct, app string) (apiScheduleList, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, "/schedules")) |
| + if err != nil { |
| + return apiScheduleList{}, err |
| + } |
| + if status != http.StatusOK { |
| + if status == http.StatusNotFound { |
| + return apiScheduleList{}, scheduleDoorNotFoundErr() |
| + } |
| + return apiScheduleList{}, apiErrorFromBody(status, body) |
| + } |
| + var res apiScheduleList |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return apiScheduleList{}, fmt.Errorf("schedule ls: unreadable response: %w", err) |
| + } |
| + return res, nil |
| +} |
| + |
| +// scheduleSet asks the console to declare or replace sc by name |
| +// (handleAPIScheduleSet). The console re-validates sc.Validate() itself — |
| +// this call's caller (schedule.go's runScheduleSet) has already run the |
| +// same check, so most bad requests never reach here at all, but the |
| +// console's answer is what actually lands, never assumed. |
| +func (s *apiSession) scheduleSet(ctx context.Context, a apiAcct, app string, sc schedule.Schedule) (apiScheduleList, error) { |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/schedules"), sc) |
| + if err != nil { |
| + return apiScheduleList{}, err |
| + } |
| + if status != http.StatusOK { |
| + if status == http.StatusNotFound { |
| + return apiScheduleList{}, scheduleDoorNotFoundErr() |
| + } |
| + return apiScheduleList{}, apiErrorFromBody(status, body) |
| + } |
| + var res apiScheduleList |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return apiScheduleList{}, fmt.Errorf("schedule set: unreadable response: %w", err) |
| + } |
| + return res, nil |
| +} |
| + |
| +// scheduleRemove asks the console to drop name (handleAPIScheduleRemove). |
| +func (s *apiSession) scheduleRemove(ctx context.Context, a apiAcct, app, name string) (apiScheduleList, error) { |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/schedules/"+url.PathEscape(name)+"/delete"), struct{}{}) |
| + if err != nil { |
| + return apiScheduleList{}, err |
| + } |
| + if status != http.StatusOK { |
| + if status == http.StatusNotFound { |
| + return apiScheduleList{}, scheduleNameNotFoundErr() |
| + } |
| + return apiScheduleList{}, apiErrorFromBody(status, body) |
| + } |
| + var res apiScheduleList |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return apiScheduleList{}, fmt.Errorf("schedule rm: unreadable response: %w", err) |
| + } |
| + return res, nil |
| +} |
| + |
| +// scheduleRun asks the console to touch name's run signal |
| +// (handleAPIScheduleRun) — TouchRun's etag movement, never its content, is |
| +// the signal every box's converge pass acts on, restart-gen's own |
| +// mechanism. The response carries the console's canonical account/app/name |
| +// too, but there is nothing more for a caller to learn from them than the |
| +// request already knew — this returns error-only, unlike ls/set/rm, which |
| +// return the fuller shape because their caller needs the FULL declaration. |
| +func (s *apiSession) scheduleRun(ctx context.Context, a apiAcct, app, name string) error { |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/schedules/"+url.PathEscape(name)+"/run"), struct{}{}) |
| + if err != nil { |
| + return err |
| + } |
| + if status != http.StatusOK { |
| + if status == http.StatusNotFound { |
| + return scheduleNameNotFoundErr() |
| + } |
| + return apiErrorFromBody(status, body) |
| + } |
| + return nil |
| +} |
| + |
| // apiRetentionResult mirrors the console's retention answer: the policy as |
| // recorded, plus the console's own count of currently-eligible versions — |
| // computed console-side because this terminal has no bucket. Eligible is a |
diff --git a/main.go b/main.go| index 4899e6fc8..5a243494d 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -67,6 +67,8 @@ func main() { |
| runRestart(args) |
| case "steering": |
| runSteering(args) |
| + case "schedule": |
| + runSchedule(args) |
| case "economics": |
| runEconomics(args) |
| case "store": |
| @@ -158,6 +160,9 @@ Your apps — ship and run software on a CARLOS deployment |
| (reaching stable cuts the release's semver tag) |
| deploy ship + promote + wait until the URL serves the new version |
| restart cycle an app's instances — no new version, no config change |
| + schedule recurring work on an app's own instances: ls | set | rm | |
| + run — a schedule POSTs to a path the app itself serves, |
| + on every instance, pull-based like restart |
| store object storage for an app: create | status | grant | rotate |
| email tenant email sending: enable | domains add | status | test | |
| credentials create/list/revoke | rotate | pause | resume |
diff --git a/schedule.go b/schedule.go| new file mode 100644 |
| index 000000000..fff78b7bf |
| --- /dev/null |
| +++ b/schedule.go |
| @@ -0,0 +1,350 @@ |
| +// schedule.go is `carlos schedule ls | set | rm | run` (Task 9, the CLI |
| +// front over internal/console/apischedule.go). Pull-based, restart's shape |
| +// and reason: the console touches one small object (schedules.json, or the |
| +// run-signal touch object beside it), and every box serving the app notices |
| +// within seconds and acts on its own. Nothing is pushed at a box, no |
| +// hibernating instance is woken by any of these four verbs. |
| +// |
| +// The vocabulary follows restart's honesty rule on purpose: `set` says |
| +// "recorded", `rm` says "removed", `run` says "requested" — never "fired" |
| +// or "ran". This terminal is not watching the boxes; it wrote an object and |
| +// the fleet is free to act on it whenever it next looks. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/schedule" |
| +) |
| + |
| +const scheduleUsage = `usage: carlos schedule <ls|set|rm|run> [flags] |
| + |
| + ls -app <a> [flags] |
| + this app's declared schedules, plus what each instance |
| + reports it will do next and what happened last |
| + set -app <a> -name <n> (-every <dur> | -cron "<5 fields>") -path </p> [flags] |
| + declare a schedule, or replace one already declared under |
| + the same name |
| + rm -app <a> -name <n> [flags] |
| + remove a declared schedule |
| + run -app <a> -name <n> [flags] |
| + request an out-of-band run on every instance now, on top |
| + of its normal schedule |
| + |
| + -account <a> account (sqid or name; default "ops") |
| + -account-id <id> account sqid, matched as a sqid ONLY |
| + -console <origin> console to act through (default: the only logged-in one) |
| + |
| +Signed-in only (carlos auth login): a schedule is a console-authorized write, |
| +the same shape as carlos restart — boxes act on it within seconds, and a |
| +hibernating instance is never woken by any of these four verbs. |
| +` |
| + |
| +const scheduleLsUsage = `usage: carlos schedule ls -app <a> [-account <a>] [-account-id <id>] [-console <origin>] |
| +` |
| + |
| +const scheduleSetUsage = `usage: carlos schedule set -app <a> -name <n> (-every <dur> | -cron "<5 fields>") -path </p> [-account <a>] [-account-id <id>] [-console <origin>] |
| +` |
| + |
| +const scheduleRmUsage = `usage: carlos schedule rm -app <a> -name <n> [-account <a>] [-account-id <id>] [-console <origin>] |
| +` |
| + |
| +const scheduleRunUsage = `usage: carlos schedule run -app <a> -name <n> [-account <a>] [-account-id <id>] [-console <origin>] |
| +` |
| + |
| +// runSchedule dispatches the schedule sub-verbs. ls/set/rm/run are |
| +// positional words, not flags, and must never reach fs.Parse — the same |
| +// shape runDomains and runInstances use for their own sub-verbs. |
| +func runSchedule(args []string) { |
| + if len(args) > 0 { |
| + switch args[0] { |
| + case "ls": |
| + runScheduleLs(args[1:]) |
| + return |
| + case "set": |
| + runScheduleSet(args[1:]) |
| + return |
| + case "rm": |
| + runScheduleRm(args[1:]) |
| + return |
| + case "run": |
| + runScheduleRun(args[1:]) |
| + return |
| + } |
| + } |
| + log.Fatal(scheduleUsage) |
| +} |
| + |
| +// requireScheduleAPISession refuses direct-bucket mode with schedule's OWN |
| +// message, requireRestartAPISession's pattern and reason: a refusal that |
| +// names another verb's advice sends people somewhere that cannot help them. |
| +// There is no headless equivalent — the request is a console write with an |
| +// audit row, not a bucket object this CLI could write unaccompanied. |
| +func requireScheduleAPISession(consoleFlag string) *apiSession { |
| + sess, err := resolveMode(consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess == nil { |
| + log.Fatal("carlos schedule works through a console, and this shell is in direct bucket mode\n" + |
| + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). A schedule is a console-authorized\n" + |
| + "write with an audit row and no direct-bucket equivalent. Unset the store env and run:\n" + |
| + "carlos auth login -console <url>.") |
| + } |
| + return sess |
| +} |
| + |
| +func runScheduleLs(args []string) { |
| + fs := flag.NewFlagSet("schedule ls", flag.ExitOnError) |
| + app := fs.String("app", "", "app whose schedules to list") |
| + acctRaw, acctIDRaw, _ := accountFlags(fs) |
| + consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)") |
| + fs.Parse(args) |
| + |
| + sess := requireScheduleAPISession(*consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + name := resolvedApp(sess, acct, *app, scheduleLsUsage) |
| + res, err := sess.scheduleList(context.Background(), acct, name) |
| + if err != nil { |
| + log.Fatalf("schedule ls: %v", err) |
| + } |
| + renderScheduleList(stdout, res, time.Now()) |
| +} |
| + |
| +func runScheduleSet(args []string) { |
| + fs := flag.NewFlagSet("schedule set", flag.ExitOnError) |
| + app := fs.String("app", "", "app the schedule belongs to") |
| + name := fs.String("name", "", "schedule name — [a-z0-9][a-z0-9-]{0,31}") |
| + every := fs.String("every", "", "fire every duration, whole minutes only (1m to 720h) — give this or -cron, not both") |
| + cron := fs.String("cron", "", `fire on a 5-field cron expression ("min hour dom mon dow") — give this or -every, not both`) |
| + path := fs.String("path", "", "absolute path on the app's own instance to POST at each fire") |
| + acctRaw, acctIDRaw, _ := accountFlags(fs) |
| + consoleFlag := fs.String("console", "", "console to record through (default: the only logged-in one)") |
| + fs.Parse(args) |
| + |
| + sc := schedule.Schedule{ |
| + Name: strings.TrimSpace(*name), |
| + Every: strings.TrimSpace(*every), |
| + Cron: strings.TrimSpace(*cron), |
| + Path: strings.TrimSpace(*path), |
| + } |
| + // Client-side gate before the server's, restart's and apps create's |
| + // pattern: a request the console is certain to refuse should cost a |
| + // typo nothing, and the shared write budget (handleAPIScheduleSet's |
| + // allowWrite) is spent per attempt whether or not this ever dials out. |
| + // The console runs the SAME Validate() — most typos never reach it. |
| + if err := sc.Validate(); err != nil { |
| + log.Fatalf("schedule set: %v", err) |
| + } |
| + sp, err := sc.Spec() // Validate already confirmed this succeeds. |
| + if err != nil { |
| + log.Fatalf("schedule set: %v", err) |
| + } |
| + |
| + sess := requireScheduleAPISession(*consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + appName := resolvedApp(sess, acct, *app, scheduleSetUsage) |
| + res, err := sess.scheduleSet(context.Background(), acct, appName, sc) |
| + if err != nil { |
| + log.Fatalf("schedule set: %v", err) |
| + } |
| + // "first fire" is computed from OUR OWN validated spec, not read back |
| + // off the response: the console's answer carries every declared |
| + // schedule (scheduleAnswer), not just the one just set, and this value |
| + // is already known client-side the moment Validate() succeeded. |
| + fmt.Fprintf(stdout, "%s %s for %s/%s — %s → POST %s; first fire at %s (UTC); boxes pick it up within "+ |
| + "seconds — `carlos schedule ls -app %s` shows each instance's next run\n", |
| + colorFor(stdout).ok("recorded"), sc.Name, res.Account, res.App, sp.String(), sc.Path, |
| + sp.Next(time.Now()).UTC().Format(time.RFC3339), res.App) |
| +} |
| + |
| +func runScheduleRm(args []string) { |
| + fs := flag.NewFlagSet("schedule rm", flag.ExitOnError) |
| + app := fs.String("app", "", "app the schedule belongs to") |
| + name := fs.String("name", "", "schedule name to remove") |
| + acctRaw, acctIDRaw, _ := accountFlags(fs) |
| + consoleFlag := fs.String("console", "", "console to remove through (default: the only logged-in one)") |
| + fs.Parse(args) |
| + scheduleName := strings.TrimSpace(*name) |
| + if scheduleName == "" { |
| + log.Fatal(scheduleRmUsage) |
| + } |
| + |
| + sess := requireScheduleAPISession(*consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + appName := resolvedApp(sess, acct, *app, scheduleRmUsage) |
| + res, err := sess.scheduleRemove(context.Background(), acct, appName, scheduleName) |
| + if err != nil { |
| + log.Fatalf("schedule rm: %v", err) |
| + } |
| + fmt.Fprintf(stdout, "%s %s from %s/%s\n", colorFor(stdout).ok("removed"), scheduleName, res.Account, res.App) |
| +} |
| + |
| +func runScheduleRun(args []string) { |
| + fs := flag.NewFlagSet("schedule run", flag.ExitOnError) |
| + app := fs.String("app", "", "app the schedule belongs to") |
| + name := fs.String("name", "", "schedule name to run now") |
| + acctRaw, acctIDRaw, _ := accountFlags(fs) |
| + consoleFlag := fs.String("console", "", "console to run through (default: the only logged-in one)") |
| + fs.Parse(args) |
| + scheduleName := strings.TrimSpace(*name) |
| + if scheduleName == "" { |
| + log.Fatal(scheduleRunUsage) |
| + } |
| + |
| + sess := requireScheduleAPISession(*consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + appName := resolvedApp(sess, acct, *app, scheduleRunUsage) |
| + if err := sess.scheduleRun(context.Background(), acct, appName, scheduleName); err != nil { |
| + log.Fatalf("schedule run: %v", err) |
| + } |
| + // scheduleRun's wire shape (unlike ls/set/rm) answers with no canonical |
| + // account/app to echo — see apimode.go's client method comment — so |
| + // this line reports the SEGMENT this terminal resolved and sent, the |
| + // same value the request itself carried. |
| + fmt.Fprintf(stdout, "%s for %s on %s/%s — fires on every instance within ~15s; watch it with carlos logs -app %s -f\n", |
| + colorFor(stdout).ok("run requested"), scheduleName, acct.Seg, appName, appName) |
| +} |
| + |
| +// relFire renders one fire time (unix seconds, 0 meaning "never" — no run |
| +// yet, or a status report too old to carry one) relative to now: "—" for |
| +// the zero epoch, "in <dur>" for a future instant, "<dur> ago" for a past |
| +// one. Duration granularity is minutes once the gap passes an hour and |
| +// seconds below it — a minute of jitter on a 6-hour interval is noise a |
| +// member does not need; a second of it on a 90-second one is the whole |
| +// story. |
| +func relFire(epoch int64, now time.Time) string { |
| + if epoch == 0 { |
| + return "—" |
| + } |
| + d := time.Unix(epoch, 0).Sub(now) |
| + if d < 0 { |
| + return fmtDur(-d) + " ago" |
| + } |
| + return "in " + fmtDur(d) |
| +} |
| + |
| +// fmtDur is relFire's magnitude formatter — always given a non-negative |
| +// duration. |
| +func fmtDur(d time.Duration) string { |
| + switch { |
| + case d >= time.Hour: |
| + h := int(d / time.Hour) |
| + m := int((d % time.Hour) / time.Minute) |
| + return fmt.Sprintf("%dh%02dm", h, m) |
| + case d >= time.Minute: |
| + m := int(d / time.Minute) |
| + s := int((d % time.Minute) / time.Second) |
| + return fmt.Sprintf("%dm%02ds", m, s) |
| + default: |
| + return fmt.Sprintf("%ds", int(d/time.Second)) |
| + } |
| +} |
| + |
| +// renderScheduleList is `carlos schedule ls`'s rendering half, split out so |
| +// it can be asserted on against a fixed clock without a console. It never |
| +// panics on a nil Schedules or Instances slice, or on a nil Timers slice |
| +// inside one Status — scheduleAnswer (internal/console/apischedule.go) |
| +// always fills `[]` for both top-level arrays, but this still ranges |
| +// safely over nil either way, the same defensiveness printLiveBoxes and |
| +// every other list renderer in this binary applies to a server answer it |
| +// does not fully control. |
| +func renderScheduleList(w io.Writer, res apiScheduleList, now time.Time) { |
| + if len(res.Schedules) == 0 { |
| + fmt.Fprintf(w, "no schedules declared for %s/%s — carlos schedule set -app %s -name <n> -every 6h -path /jobs/<n>\n", |
| + res.Account, res.App, res.App) |
| + } else { |
| + fmt.Fprintf(w, "schedules for %s/%s (updated %s)\n", res.Account, res.App, res.UpdatedAt) |
| + renderScheduleTable(w, res.Schedules) |
| + } |
| + fmt.Fprintln(w) |
| + fmt.Fprintln(w, "instances") |
| + if len(res.Instances) == 0 { |
| + fmt.Fprintln(w, " no instance has reported yet — boxes report within seconds of a schedule landing") |
| + return |
| + } |
| + for _, inst := range res.Instances { |
| + renderScheduleInstance(w, inst, now) |
| + } |
| +} |
| + |
| +// renderScheduleTable prints the NAME/WHEN/PATH block, column widths sized |
| +// to the widest cell in each column (header included) plus a 2-space gap — |
| +// text/tabwriter's own rule, applied by hand here because this table's |
| +// 2-space left margin has to be part of column one's measured width, not a |
| +// separate prefix tabwriter would pad independently. |
| +func renderScheduleTable(w io.Writer, scs []schedule.Schedule) { |
| + nameW, whenW := len("NAME"), len("WHEN") |
| + whens := make([]string, len(scs)) |
| + for i, sc := range scs { |
| + if n := len(sc.Name); n > nameW { |
| + nameW = n |
| + } |
| + sp, err := sc.Spec() |
| + when := sp.String() |
| + if err != nil { |
| + when = "(invalid: " + err.Error() + ")" |
| + } |
| + whens[i] = when |
| + if n := len(when); n > whenW { |
| + whenW = n |
| + } |
| + } |
| + fmt.Fprintf(w, " %-*s%-*s%s\n", nameW+2, "NAME", whenW+2, "WHEN", "PATH") |
| + for i, sc := range scs { |
| + fmt.Fprintf(w, " %-*s%-*s%s\n", nameW+2, sc.Name, whenW+2, whens[i], sc.Path) |
| + } |
| +} |
| + |
| +// renderScheduleInstance prints one instance's block: the host line, then |
| +// one indented line per timer it reported. Unsupported (set only for a |
| +// unit-backed host — converge.go's UnsupportedUnit) short-circuits to a |
| +// single explanatory line with no timers, because a unit-backed instance |
| +// never gets far enough to have any. |
| +func renderScheduleInstance(w io.Writer, inst schedule.Status, now time.Time) { |
| + if inst.Unsupported != "" { |
| + fmt.Fprintf(w, " %s %s\n", inst.Host, inst.Unsupported) |
| + return |
| + } |
| + fmt.Fprintf(w, " %s (box %s, reported %s ago)\n", inst.Host, inst.Box, reportedAgo(inst.ReportedAt, now)) |
| + nameW := 0 |
| + for _, t := range inst.Timers { |
| + if n := len(t.Name); n > nameW { |
| + nameW = n |
| + } |
| + } |
| + for _, t := range inst.Timers { |
| + next := relFire(t.NextAt, now) |
| + last := relFire(t.LastAt, now) |
| + if t.LastAt != 0 && t.LastStatus != "" { |
| + last += fmt.Sprintf(" → %s in %.1fs", t.LastStatus, float64(t.LastMs)/1000) |
| + } |
| + fmt.Fprintf(w, " %-*snext %s last %s\n", nameW+2, t.Name, next, last) |
| + } |
| +} |
| + |
| +// reportedAgo turns a Status.ReportedAt RFC3339 string into fmtDur's "X ago" |
| +// magnitude. A report this binary cannot parse (an old box's odd format, or |
| +// a corrupt object a bad actor wrote) prints the raw string rather than |
| +// panicking or hiding it — the caller still sees SOMETHING was reported. |
| +func reportedAgo(s string, now time.Time) string { |
| + t, err := time.Parse(time.RFC3339, s) |
| + if err != nil { |
| + return s |
| + } |
| + d := now.Sub(t) |
| + if d < 0 { |
| + d = 0 |
| + } |
| + return fmtDur(d) |
| +} |
diff --git a/schedule_test.go b/schedule_test.go| new file mode 100644 |
| index 000000000..c3f30b0b7 |
| --- /dev/null |
| +++ b/schedule_test.go |
| @@ -0,0 +1,376 @@ |
| +package main |
| + |
| +// The CLI half of `carlos schedule` (Task 9): the wire shape each of the |
| +// four doors requires, the renderer `carlos schedule ls` prints through |
| +// (asserted against a fixed clock so it needs no console at all), and the |
| +// one client-side refusal (`-every 30s`) that must never reach the network. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "os/exec" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/schedule" |
| +) |
| + |
| +// TestAPISessionScheduleListWireShape pins handleAPIScheduleList's shape: a |
| +// bearer GET at the app's /schedules path, the account segment carried |
| +// verbatim (the console resolves names server-side, never this client), and |
| +// the canonical account/app the console echoes back read straight through. |
| +func TestAPISessionScheduleListWireShape(t *testing.T) { |
| + var gotPath, gotMethod, gotAuth string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization") |
| + json.NewEncoder(w).Encode(apiScheduleList{ |
| + Account: "bab", App: "keymail", |
| + Schedules: []schedule.Schedule{{Name: "sync", Every: "6h0m0s", Path: "/jobs/sync"}}, |
| + Instances: []schedule.Status{}, |
| + UpdatedAt: "2026-08-23T22:14:09Z", |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + res, err := newTestSession(ts.URL).scheduleList(context.Background(), apiAcct{Seg: "keymail-team"}, "keymail") |
| + if err != nil { |
| + t.Fatalf("scheduleList: %v", err) |
| + } |
| + if gotMethod != http.MethodGet { |
| + t.Fatalf("method = %s, want GET", gotMethod) |
| + } |
| + if gotPath != "/api/cli/apps/keymail-team/keymail/schedules" { |
| + t.Fatalf("path = %s", gotPath) |
| + } |
| + if gotAuth != "Bearer cli-token" { |
| + t.Fatalf("auth = %q", gotAuth) |
| + } |
| + if res.Account != "bab" || res.App != "keymail" { |
| + t.Fatalf("result = %+v, want the console's canonical bab/keymail", res) |
| + } |
| + if len(res.Schedules) != 1 || res.Schedules[0].Name != "sync" { |
| + t.Fatalf("schedules = %+v", res.Schedules) |
| + } |
| +} |
| + |
| +// TestAPISessionScheduleSetWireShape pins handleAPIScheduleSet's shape: a |
| +// bearer POST at /schedules whose body is the schedule.Schedule JSON |
| +// verbatim — the console re-validates it, this client does not reshape it. |
| +func TestAPISessionScheduleSetWireShape(t *testing.T) { |
| + var gotPath, gotMethod, gotAuth string |
| + var gotBody schedule.Schedule |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization") |
| + json.NewDecoder(r.Body).Decode(&gotBody) |
| + json.NewEncoder(w).Encode(apiScheduleList{Account: "bab", App: "keymail", Schedules: []schedule.Schedule{gotBody}, Instances: []schedule.Status{}}) |
| + })) |
| + defer ts.Close() |
| + |
| + sc := schedule.Schedule{Name: "sync", Every: "6h0m0s", Path: "/jobs/sync"} |
| + res, err := newTestSession(ts.URL).scheduleSet(context.Background(), apiAcct{Seg: "bab"}, "keymail", sc) |
| + if err != nil { |
| + t.Fatalf("scheduleSet: %v", err) |
| + } |
| + if gotMethod != http.MethodPost { |
| + t.Fatalf("method = %s, want POST", gotMethod) |
| + } |
| + if gotPath != "/api/cli/apps/bab/keymail/schedules" { |
| + t.Fatalf("path = %s", gotPath) |
| + } |
| + if gotAuth != "Bearer cli-token" { |
| + t.Fatalf("auth = %q", gotAuth) |
| + } |
| + if gotBody != sc { |
| + t.Fatalf("body = %+v, want %+v sent verbatim", gotBody, sc) |
| + } |
| + if res.Account != "bab" || res.App != "keymail" { |
| + t.Fatalf("result = %+v, want the console's canonical bab/keymail", res) |
| + } |
| +} |
| + |
| +// TestAPISessionScheduleRemoveWireShape pins handleAPIScheduleRemove's |
| +// shape: a bearer POST at /schedules/{name}/delete. |
| +func TestAPISessionScheduleRemoveWireShape(t *testing.T) { |
| + var gotPath, gotMethod, gotAuth string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization") |
| + json.NewEncoder(w).Encode(apiScheduleList{Account: "bab", App: "keymail", Schedules: []schedule.Schedule{}, Instances: []schedule.Status{}}) |
| + })) |
| + defer ts.Close() |
| + |
| + res, err := newTestSession(ts.URL).scheduleRemove(context.Background(), apiAcct{Seg: "bab"}, "keymail", "sync") |
| + if err != nil { |
| + t.Fatalf("scheduleRemove: %v", err) |
| + } |
| + if gotMethod != http.MethodPost { |
| + t.Fatalf("method = %s, want POST", gotMethod) |
| + } |
| + if gotPath != "/api/cli/apps/bab/keymail/schedules/sync/delete" { |
| + t.Fatalf("path = %s", gotPath) |
| + } |
| + if gotAuth != "Bearer cli-token" { |
| + t.Fatalf("auth = %q", gotAuth) |
| + } |
| + if res.Account != "bab" || res.App != "keymail" { |
| + t.Fatalf("result = %+v, want the console's canonical bab/keymail", res) |
| + } |
| +} |
| + |
| +// TestAPISessionScheduleRunWireShape pins handleAPIScheduleRun's shape: a |
| +// bearer POST at /schedules/{name}/run. scheduleRun is error-only — the |
| +// response's canonical account/app/name are read by the console side, not |
| +// by this client — so this test asserts on the wire, not on a return value. |
| +func TestAPISessionScheduleRunWireShape(t *testing.T) { |
| + var gotPath, gotMethod, gotAuth string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization") |
| + json.NewEncoder(w).Encode(map[string]any{"requested": true, "account": "bab", "app": "keymail", "name": "sync"}) |
| + })) |
| + defer ts.Close() |
| + |
| + if err := newTestSession(ts.URL).scheduleRun(context.Background(), apiAcct{Seg: "bab"}, "keymail", "sync"); err != nil { |
| + t.Fatalf("scheduleRun: %v", err) |
| + } |
| + if gotMethod != http.MethodPost { |
| + t.Fatalf("method = %s, want POST", gotMethod) |
| + } |
| + if gotPath != "/api/cli/apps/bab/keymail/schedules/sync/run" { |
| + t.Fatalf("path = %s", gotPath) |
| + } |
| + if gotAuth != "Bearer cli-token" { |
| + t.Fatalf("auth = %q", gotAuth) |
| + } |
| +} |
| + |
| +// TestAPISessionScheduleDoorNotFound404 covers ls/set (no {name} segment): |
| +// the byte-identical 404 apiAccountApp gives an unknown account, someone |
| +// else's account, an app that isn't there, OR an old console with no |
| +// schedule door at all — nothing in the response tells those apart, so the |
| +// message must name every one. |
| +func TestAPISessionScheduleDoorNotFound404(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + http.NotFound(w, r) |
| + })) |
| + defer ts.Close() |
| + |
| + _, err := newTestSession(ts.URL).scheduleList(context.Background(), apiAcct{Seg: "bab"}, "keymail") |
| + if err == nil { |
| + t.Fatal("a 404 must error") |
| + } |
| + if !strings.Contains(err.Error(), "-account") { |
| + t.Fatalf("error %q drops the membership explanation", err) |
| + } |
| + if !strings.Contains(err.Error(), "predates") || !strings.Contains(err.Error(), "carlos schedule") { |
| + t.Fatalf("error %q does not name the old-console cause", err) |
| + } |
| +} |
| + |
| +// TestAPISessionScheduleNameNotFound404 covers rm/run's {name} segment: the |
| +// SAME byte-identical 404 (declaredSchedule) also means "-name was never |
| +// declared" — a third cause the ls/set message cannot know to mention. |
| +func TestAPISessionScheduleNameNotFound404(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + http.NotFound(w, r) |
| + })) |
| + defer ts.Close() |
| + |
| + _, err := newTestSession(ts.URL).scheduleRemove(context.Background(), apiAcct{Seg: "bab"}, "keymail", "ghost") |
| + if err == nil { |
| + t.Fatal("a 404 must error") |
| + } |
| + if !strings.Contains(err.Error(), "-name is not declared") { |
| + t.Fatalf("error %q drops the undeclared-name explanation", err) |
| + } |
| + if !strings.Contains(err.Error(), "-account") || !strings.Contains(err.Error(), "predates") { |
| + t.Fatalf("error %q drops the other two causes", err) |
| + } |
| +} |
| + |
| +// TestAPISessionScheduleServerErrorSurfacesVerbatim: a door that answers |
| +// with a real diagnosis (the shared write budget, an over-cap declaration) |
| +// must reach the terminal in the console's own words. |
| +func TestAPISessionScheduleServerErrorSurfacesVerbatim(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusTooManyRequests) |
| + json.NewEncoder(w).Encode(map[string]string{"error": "too many requests — wait a minute and try again"}) |
| + })) |
| + defer ts.Close() |
| + |
| + _, err := newTestSession(ts.URL).scheduleSet(context.Background(), apiAcct{Seg: "bab"}, "keymail", schedule.Schedule{Name: "sync", Every: "1h", Path: "/jobs/sync"}) |
| + if err == nil || !strings.Contains(err.Error(), "wait a minute") { |
| + t.Fatalf("schedule set 429 = %v, want the console's own text", err) |
| + } |
| +} |
| + |
| +// TestRenderScheduleList pins the exact layout the brief specifies: the |
| +// declared-schedules table, a blank line, then one block per instance — |
| +// against a FIXED clock, so relative times ("next in 4h52m") are |
| +// deterministic instead of racing wall-clock time. |
| +func TestRenderScheduleList(t *testing.T) { |
| + now := time.Date(2026, 8, 23, 22, 14, 21, 0, time.UTC) // matches the brief's "reported 12s ago" |
| + res := apiScheduleList{ |
| + Account: "bab", App: "app", |
| + UpdatedAt: "2026-08-23T22:14:09Z", |
| + Schedules: []schedule.Schedule{ |
| + {Name: "reminders", Cron: "0 8 * * *", Path: "/jobs/reminders"}, |
| + {Name: "sync", Every: "6h0m0s", Path: "/jobs/sync"}, |
| + }, |
| + Instances: []schedule.Status{ |
| + { |
| + Host: "a.bab.oncarlos.com", Box: "carlos-ie-1", |
| + ReportedAt: now.Add(-12 * time.Second).Format(time.RFC3339), |
| + Timers: []schedule.TimerStatus{ |
| + { |
| + Name: "sync", |
| + NextAt: now.Add(4*time.Hour + 52*time.Minute).Unix(), |
| + LastAt: now.Add(-(1*time.Hour + 8*time.Minute)).Unix(), |
| + LastStatus: "204", |
| + LastMs: 3200, |
| + }, |
| + { |
| + Name: "reminders", |
| + NextAt: now.Add(9*time.Hour + 53*time.Minute).Unix(), |
| + }, |
| + }, |
| + }, |
| + { |
| + Host: "u.bab.oncarlos.com", |
| + Unsupported: schedule.UnsupportedUnit, |
| + }, |
| + }, |
| + } |
| + |
| + var buf strings.Builder |
| + renderScheduleList(&buf, res, now) |
| + |
| + want := "schedules for bab/app (updated 2026-08-23T22:14:09Z)\n" + |
| + " NAME WHEN PATH\n" + |
| + " reminders cron 0 8 * * * /jobs/reminders\n" + |
| + " sync every 6h0m0s /jobs/sync\n" + |
| + "\n" + |
| + "instances\n" + |
| + " a.bab.oncarlos.com (box carlos-ie-1, reported 12s ago)\n" + |
| + " sync next in 4h52m last 1h08m ago → 204 in 3.2s\n" + |
| + " reminders next in 9h53m last —\n" + |
| + " u.bab.oncarlos.com unit-backed instance — scheduled work delivers to exec-backed instances in this release\n" |
| + if got := buf.String(); got != want { |
| + t.Fatalf("renderScheduleList =\n%s\nwant\n%s", got, want) |
| + } |
| +} |
| + |
| +// TestRenderScheduleListEmpty pins the two empty-state sentences: no |
| +// schedules declared, and no instance has reported. |
| +func TestRenderScheduleListEmpty(t *testing.T) { |
| + now := time.Date(2026, 8, 23, 22, 14, 21, 0, time.UTC) |
| + res := apiScheduleList{Account: "bab", App: "app"} |
| + |
| + var buf strings.Builder |
| + renderScheduleList(&buf, res, now) |
| + |
| + want := "no schedules declared for bab/app — carlos schedule set -app app -name <n> -every 6h -path /jobs/<n>\n" + |
| + "\n" + |
| + "instances\n" + |
| + " no instance has reported yet — boxes report within seconds of a schedule landing\n" |
| + if got := buf.String(); got != want { |
| + t.Fatalf("renderScheduleList (empty) =\n%s\nwant\n%s", got, want) |
| + } |
| +} |
| + |
| +// TestRenderScheduleListNilSlices proves the renderer never panics on a nil |
| +// Schedules/Instances/Timers — the shape a pre-scheduleAnswer console (or a |
| +// hand-built apiScheduleList{} in a test) can still hand it, even though |
| +// scheduleAnswer itself always fills `[]`. |
| +func TestRenderScheduleListNilSlices(t *testing.T) { |
| + var buf strings.Builder |
| + renderScheduleList(&buf, apiScheduleList{Account: "bab", App: "app"}, time.Now()) |
| + if buf.Len() == 0 { |
| + t.Fatal("renderScheduleList wrote nothing") |
| + } |
| +} |
| + |
| +// TestScheduleVerbRefusals drives the real binary for the ways `carlos |
| +// schedule` stops before it ever dials anything: direct bucket mode, and |
| +// -every 30s (schedule.ValidEvery's "whole minutes only" rule) — run with |
| +// NO console configured at all, so a passing test proves the validation |
| +// error is what stopped it, not a session failure that happened to look |
| +// similar. Both run through carlosTestEnv with a scratch cmd.Dir — the |
| +// repo rule — so a logged-in laptop's real credential and console can |
| +// never be what this test exercises. One buildCarlos call covers both, |
| +// restart_test.go's TestRestartVerbRefusals shape, because the build is |
| +// the expensive part and everything after it is a fast subprocess run. |
| +func TestScheduleVerbRefusals(t *testing.T) { |
| + bin := buildCarlos(t) |
| + scratch := t.TempDir() |
| + run := func(env []string, args ...string) (string, error) { |
| + t.Helper() |
| + cmd := exec.Command(bin, args...) |
| + cmd.Env = carlosTestEnv(t, env...) |
| + cmd.Dir = scratch |
| + out, err := cmd.CombinedOutput() |
| + return string(out), err |
| + } |
| + |
| + // Direct bucket mode: refused in schedule's OWN words, naming the |
| + // login that fixes it — never another verb's advice. |
| + out, err := run([]string{"CARLOS_DEPLOYMENT_DIR=" + t.TempDir()}, "schedule", "ls", "-app", "keymail") |
| + if err == nil { |
| + t.Fatalf("schedule ls in direct bucket mode exited 0:\n%s", out) |
| + } |
| + if !strings.Contains(out, "carlos schedule works through a console") || |
| + !strings.Contains(out, "carlos auth login") { |
| + t.Fatalf("direct-mode refusal = %q, want schedule's own message and the login fix", out) |
| + } |
| + |
| + // -every 30s fails schedule.ValidEvery ("shortest interval is 1m" — 30s |
| + // is under MinEvery, checked before the whole-minutes rule) entirely |
| + // client-side, in schedule.go's runScheduleSet, BEFORE |
| + // requireScheduleAPISession ever runs — so this must fail on the |
| + // validation message even with no console configured at all, never on |
| + // "no deployment target configured" or a login prompt. |
| + out, err = run(nil, "schedule", "set", "-app", "keymail", "-name", "sync", "-every", "30s", "-path", "/jobs/sync") |
| + if err == nil { |
| + t.Fatalf("schedule set -every 30s exited 0:\n%s", out) |
| + } |
| + if !strings.Contains(out, "shortest interval is 1m") { |
| + t.Fatalf("schedule set -every 30s = %q, want the ValidEvery refusal, unreached network", out) |
| + } |
| + if strings.Contains(out, "no deployment target") || strings.Contains(out, "auth login") { |
| + t.Fatalf("schedule set -every 30s = %q, reached session resolution before validating", out) |
| + } |
| + |
| + // -every 90s is >= the 1m floor but not a whole minute — the OTHER |
| + // ValidEvery clause ("whole minutes only"), also unreached network. |
| + out, err = run(nil, "schedule", "set", "-app", "keymail", "-name", "sync", "-every", "90s", "-path", "/jobs/sync") |
| + if err == nil { |
| + t.Fatalf("schedule set -every 90s exited 0:\n%s", out) |
| + } |
| + if !strings.Contains(out, "whole minutes only") { |
| + t.Fatalf("schedule set -every 90s = %q, want the whole-minutes refusal", out) |
| + } |
| + |
| + // Giving both -every and -cron is the same client-side gate |
| + // (Schedule.Spec's "give -every or -cron, not both"), also unreached |
| + // network. |
| + out, err = run(nil, "schedule", "set", "-app", "keymail", "-name", "sync", "-every", "1h", "-cron", "0 8 * * *", "-path", "/jobs/sync") |
| + if err == nil { |
| + t.Fatalf("schedule set with both -every and -cron exited 0:\n%s", out) |
| + } |
| + if !strings.Contains(out, "not both") { |
| + t.Fatalf("schedule set both = %q, want the give-one-or-the-other refusal", out) |
| + } |
| + |
| + // The usage line lands under "Your apps", after restart — carlos help |
| + // is the one place a member finds a verb exists at all. |
| + out, err = run(nil, "help") |
| + if err != nil { |
| + t.Fatalf("carlos help exited nonzero:\n%s", out) |
| + } |
| + restartAt := strings.Index(out, "\n restart ") |
| + scheduleAt := strings.Index(out, "\n schedule ") |
| + if restartAt == -1 || scheduleAt == -1 || scheduleAt < restartAt { |
| + t.Fatalf("carlos help does not list schedule after restart:\n%s", out) |
| + } |
| +} |