// 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: the // console write itself wakes nothing, and it is the box's own runner that // wakes a hibernating instance when its tick is due — within ~15s of a // `run`, or at the next natural occurrence of a schedule. // // 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 [flags] ls --app [flags] this app's declared schedules, plus what each instance reports it will do next and what happened last set --app --name (--every | --cron "<5 fields>") --path

[flags] declare a schedule, or replace one already declared under the same name rm --app
--name [flags] remove a declared schedule run --app --name [flags] request an out-of-band run on every instance now, on top of its normal schedule --account account (sqid or name; default "ops") --account-id account sqid, matched as a sqid ONLY --console 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. The write itself wakes nothing; a box's runner wakes a hibernating instance when its tick is due (within ~15s of a run, or at the schedule's next occurrence). ` const scheduleLsUsage = `usage: carlos schedule ls --app [--account ] [--account-id ] [--console ] ` const scheduleSetUsage = `usage: carlos schedule set --app --name (--every | --cron "<5 fields>") --path

[--account
] [--account-id ] [--console ] ` const scheduleRmUsage = `usage: carlos schedule rm --app --name [--account ] [--account-id ] [--console ] ` const scheduleRunUsage = `usage: carlos schedule run --app --name [--account ] [--account-id ] [--console ] ` // 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 .") } return sess } func runScheduleLs(args []string) { fs := flag.NewFlagSet("schedule ls", flag.ExitOnError) app := appFlag(fs, "app whose schedules to list") acctRaw, acctIDRaw, _ := accountFlags(fs) consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)") fs.Usage = flagUsage(fs, scheduleLsUsage) 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 := appFlag(fs, "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 := consoleOption(fs, "console to record through (default: the only logged-in one)") fs.Usage = flagUsage(fs, scheduleSetUsage) 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 := appFlag(fs, "app the schedule belongs to") name := fs.String("name", "", "schedule name to remove") acctRaw, acctIDRaw, _ := accountFlags(fs) consoleFlag := consoleOption(fs, "console to remove through (default: the only logged-in one)") fs.Usage = flagUsage(fs, scheduleRmUsage) 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 := appFlag(fs, "app the schedule belongs to") name := fs.String("name", "", "schedule name to run now") acctRaw, acctIDRaw, _ := accountFlags(fs) consoleFlag := consoleOption(fs, "console to run through (default: the only logged-in one)") fs.Usage = flagUsage(fs, scheduleRunUsage) 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) res, err := sess.scheduleRun(context.Background(), acct, appName, scheduleName) if err != nil { log.Fatalf("schedule run: %v", err) } // The CANONICAL account/app the console echoed back, ls/set/rm's own // rule — the segment this terminal resolved and sent may have been a // name, not the sqid the console actually stores. 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"), res.Name, res.Account, res.App, res.App) } // 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 " for a future instant, " 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 --every 6h --path /jobs/\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)) // A once-timer is the instance's own (registered off its control // socket) and may share a name with a declared schedule, so it is // labelled — a member reading two "remind" lines must be able to tell // which is which. labels := make([]string, len(inst.Timers)) nameW := 0 for i, t := range inst.Timers { labels[i] = t.Name if t.Kind == schedule.KindOnce { labels[i] += " (once)" } if n := len(labels[i]); n > nameW { nameW = n } } for i, t := range inst.Timers { next := relFire(t.NextAt, now) last := relFire(t.LastAt, now) switch { case t.LastAt != 0 && t.LastStatus != "": last += fmt.Sprintf(" → %s in %.1fs", t.LastStatus, float64(t.LastMs)/1000) case t.LastStatus != "": // A status with no fire behind it is a condition the box put on // the row ("never fires" — a parked cron), not an outcome. last += " (" + t.LastStatus + ")" } fmt.Fprintf(w, " %-*snext %s last %s\n", nameW+2, labels[i], next, last) if t.HoldsAwake { // The box worked this out; we only pass it on. Worth a whole // line rather than a marker on the row: the member's next // question is "so what?", and the answer is what they are being // billed for. fmt.Fprintf(w, " %-*sthis fires more often than the idle window, so the instance never hibernates between ticks\n", nameW+2, "") } } } // 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) }