| 1 | // schedule.go is `carlos schedule ls | set | rm | run` (Task 9, the CLI |
| 2 | // front over internal/console/apischedule.go). Pull-based, restart's shape |
| 3 | // and reason: the console touches one small object (schedules.json, or the |
| 4 | // run-signal touch object beside it), and every box serving the app notices |
| 5 | // within seconds and acts on its own. Nothing is pushed at a box: the |
| 6 | // console write itself wakes nothing, and it is the box's own runner that |
| 7 | // wakes a hibernating instance when its tick is due — within ~15s of a |
| 8 | // `run`, or at the next natural occurrence of a schedule. |
| 9 | // |
| 10 | // The vocabulary follows restart's honesty rule on purpose: `set` says |
| 11 | // "recorded", `rm` says "removed", `run` says "requested" — never "fired" |
| 12 | // or "ran". This terminal is not watching the boxes; it wrote an object and |
| 13 | // the fleet is free to act on it whenever it next looks. |
| 14 | package main |
| 15 | |
| 16 | import ( |
| 17 | "context" |
| 18 | "flag" |
| 19 | "fmt" |
| 20 | "io" |
| 21 | "log" |
| 22 | "strings" |
| 23 | "time" |
| 24 | |
| 25 | "github.com/carlosframework/platform/internal/schedule" |
| 26 | ) |
| 27 | |
| 28 | const scheduleUsage = `usage: carlos schedule <ls|set|rm|run> [flags] |
| 29 | |
| 30 | ls --app <a> [flags] |
| 31 | this app's declared schedules, plus what each instance |
| 32 | reports it will do next and what happened last |
| 33 | set --app <a> --name <n> (--every <dur> | --cron "<5 fields>") --path </p> [flags] |
| 34 | declare a schedule, or replace one already declared under |
| 35 | the same name |
| 36 | rm --app <a> --name <n> [flags] |
| 37 | remove a declared schedule |
| 38 | run --app <a> --name <n> [flags] |
| 39 | request an out-of-band run on every instance now, on top |
| 40 | of its normal schedule |
| 41 | |
| 42 | --account <a> account (sqid or name; default "ops") |
| 43 | --account-id <id> account sqid, matched as a sqid ONLY |
| 44 | --console <origin> console to act through (default: the only logged-in one) |
| 45 | |
| 46 | Signed-in only (carlos auth login): a schedule is a console-authorized write, |
| 47 | the same shape as carlos restart — boxes act on it within seconds. The write |
| 48 | itself wakes nothing; a box's runner wakes a hibernating instance when its |
| 49 | tick is due (within ~15s of a run, or at the schedule's next occurrence). |
| 50 | ` |
| 51 | |
| 52 | const scheduleLsUsage = `usage: carlos schedule ls --app <a> [--account <a>] [--account-id <id>] [--console <origin>] |
| 53 | ` |
| 54 | |
| 55 | const scheduleSetUsage = `usage: carlos schedule set --app <a> --name <n> (--every <dur> | --cron "<5 fields>") --path </p> [--account <a>] [--account-id <id>] [--console <origin>] |
| 56 | ` |
| 57 | |
| 58 | const scheduleRmUsage = `usage: carlos schedule rm --app <a> --name <n> [--account <a>] [--account-id <id>] [--console <origin>] |
| 59 | ` |
| 60 | |
| 61 | const scheduleRunUsage = `usage: carlos schedule run --app <a> --name <n> [--account <a>] [--account-id <id>] [--console <origin>] |
| 62 | ` |
| 63 | |
| 64 | // runSchedule dispatches the schedule sub-verbs. ls/set/rm/run are |
| 65 | // positional words, not flags, and must never reach fs.Parse — the same |
| 66 | // shape runDomains and runInstances use for their own sub-verbs. |
| 67 | func runSchedule(args []string) { |
| 68 | if len(args) > 0 { |
| 69 | switch args[0] { |
| 70 | case "ls": |
| 71 | runScheduleLs(args[1:]) |
| 72 | return |
| 73 | case "set": |
| 74 | runScheduleSet(args[1:]) |
| 75 | return |
| 76 | case "rm": |
| 77 | runScheduleRm(args[1:]) |
| 78 | return |
| 79 | case "run": |
| 80 | runScheduleRun(args[1:]) |
| 81 | return |
| 82 | } |
| 83 | } |
| 84 | log.Fatal(scheduleUsage) |
| 85 | } |
| 86 | |
| 87 | // requireScheduleAPISession refuses direct-bucket mode with schedule's OWN |
| 88 | // message, requireRestartAPISession's pattern and reason: a refusal that |
| 89 | // names another verb's advice sends people somewhere that cannot help them. |
| 90 | // There is no headless equivalent — the request is a console write with an |
| 91 | // audit row, not a bucket object this CLI could write unaccompanied. |
| 92 | func requireScheduleAPISession(consoleFlag string) *apiSession { |
| 93 | sess, err := resolveMode(consoleFlag) |
| 94 | if err != nil { |
| 95 | log.Fatal(err) |
| 96 | } |
| 97 | if sess == nil { |
| 98 | log.Fatal("carlos schedule works through a console, and this shell is in direct bucket mode\n" + |
| 99 | "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). A schedule is a console-authorized\n" + |
| 100 | "write with an audit row and no direct-bucket equivalent. Unset the store env and run:\n" + |
| 101 | "carlos auth login --console <url>.") |
| 102 | } |
| 103 | return sess |
| 104 | } |
| 105 | |
| 106 | func runScheduleLs(args []string) { |
| 107 | fs := flag.NewFlagSet("schedule ls", flag.ExitOnError) |
| 108 | app := appFlag(fs, "app whose schedules to list") |
| 109 | acctRaw, acctIDRaw, _ := accountFlags(fs) |
| 110 | consoleFlag := consoleOption(fs, "console to list through (default: the only logged-in one)") |
| 111 | fs.Usage = flagUsage(fs, scheduleLsUsage) |
| 112 | fs.Parse(args) |
| 113 | |
| 114 | sess := requireScheduleAPISession(*consoleFlag) |
| 115 | printUsing(stdout, sess) |
| 116 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 117 | name := resolvedApp(sess, acct, *app, scheduleLsUsage) |
| 118 | res, err := sess.scheduleList(context.Background(), acct, name) |
| 119 | if err != nil { |
| 120 | log.Fatalf("schedule ls: %v", err) |
| 121 | } |
| 122 | renderScheduleList(stdout, res, time.Now()) |
| 123 | } |
| 124 | |
| 125 | func runScheduleSet(args []string) { |
| 126 | fs := flag.NewFlagSet("schedule set", flag.ExitOnError) |
| 127 | app := appFlag(fs, "app the schedule belongs to") |
| 128 | name := fs.String("name", "", "schedule name — [a-z0-9][a-z0-9-]{0,31}") |
| 129 | every := fs.String("every", "", "fire every duration, whole minutes only (1m to 720h) — give this or --cron, not both") |
| 130 | cron := fs.String("cron", "", `fire on a 5-field cron expression ("min hour dom mon dow") — give this or --every, not both`) |
| 131 | path := fs.String("path", "", "absolute path on the app's own instance to POST at each fire") |
| 132 | acctRaw, acctIDRaw, _ := accountFlags(fs) |
| 133 | consoleFlag := consoleOption(fs, "console to record through (default: the only logged-in one)") |
| 134 | fs.Usage = flagUsage(fs, scheduleSetUsage) |
| 135 | fs.Parse(args) |
| 136 | |
| 137 | sc := schedule.Schedule{ |
| 138 | Name: strings.TrimSpace(*name), |
| 139 | Every: strings.TrimSpace(*every), |
| 140 | Cron: strings.TrimSpace(*cron), |
| 141 | Path: strings.TrimSpace(*path), |
| 142 | } |
| 143 | // Client-side gate before the server's, restart's and apps create's |
| 144 | // pattern: a request the console is certain to refuse should cost a |
| 145 | // typo nothing, and the shared write budget (handleAPIScheduleSet's |
| 146 | // allowWrite) is spent per attempt whether or not this ever dials out. |
| 147 | // The console runs the SAME Validate() — most typos never reach it. |
| 148 | if err := sc.Validate(); err != nil { |
| 149 | log.Fatalf("schedule set: %v", err) |
| 150 | } |
| 151 | sp, err := sc.Spec() // Validate already confirmed this succeeds. |
| 152 | if err != nil { |
| 153 | log.Fatalf("schedule set: %v", err) |
| 154 | } |
| 155 | |
| 156 | sess := requireScheduleAPISession(*consoleFlag) |
| 157 | printUsing(stdout, sess) |
| 158 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 159 | appName := resolvedApp(sess, acct, *app, scheduleSetUsage) |
| 160 | res, err := sess.scheduleSet(context.Background(), acct, appName, sc) |
| 161 | if err != nil { |
| 162 | log.Fatalf("schedule set: %v", err) |
| 163 | } |
| 164 | // "first fire" is computed from OUR OWN validated spec, not read back |
| 165 | // off the response: the console's answer carries every declared |
| 166 | // schedule (scheduleAnswer), not just the one just set, and this value |
| 167 | // is already known client-side the moment Validate() succeeded. |
| 168 | fmt.Fprintf(stdout, "%s %s for %s/%s — %s → POST %s; first fire at %s (UTC); boxes pick it up within "+ |
| 169 | "seconds — `carlos schedule ls --app %s` shows each instance's next run\n", |
| 170 | colorFor(stdout).ok("recorded"), sc.Name, res.Account, res.App, sp.String(), sc.Path, |
| 171 | sp.Next(time.Now()).UTC().Format(time.RFC3339), res.App) |
| 172 | } |
| 173 | |
| 174 | func runScheduleRm(args []string) { |
| 175 | fs := flag.NewFlagSet("schedule rm", flag.ExitOnError) |
| 176 | app := appFlag(fs, "app the schedule belongs to") |
| 177 | name := fs.String("name", "", "schedule name to remove") |
| 178 | acctRaw, acctIDRaw, _ := accountFlags(fs) |
| 179 | consoleFlag := consoleOption(fs, "console to remove through (default: the only logged-in one)") |
| 180 | fs.Usage = flagUsage(fs, scheduleRmUsage) |
| 181 | fs.Parse(args) |
| 182 | scheduleName := strings.TrimSpace(*name) |
| 183 | if scheduleName == "" { |
| 184 | log.Fatal(scheduleRmUsage) |
| 185 | } |
| 186 | |
| 187 | sess := requireScheduleAPISession(*consoleFlag) |
| 188 | printUsing(stdout, sess) |
| 189 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 190 | appName := resolvedApp(sess, acct, *app, scheduleRmUsage) |
| 191 | res, err := sess.scheduleRemove(context.Background(), acct, appName, scheduleName) |
| 192 | if err != nil { |
| 193 | log.Fatalf("schedule rm: %v", err) |
| 194 | } |
| 195 | fmt.Fprintf(stdout, "%s %s from %s/%s\n", colorFor(stdout).ok("removed"), scheduleName, res.Account, res.App) |
| 196 | } |
| 197 | |
| 198 | func runScheduleRun(args []string) { |
| 199 | fs := flag.NewFlagSet("schedule run", flag.ExitOnError) |
| 200 | app := appFlag(fs, "app the schedule belongs to") |
| 201 | name := fs.String("name", "", "schedule name to run now") |
| 202 | acctRaw, acctIDRaw, _ := accountFlags(fs) |
| 203 | consoleFlag := consoleOption(fs, "console to run through (default: the only logged-in one)") |
| 204 | fs.Usage = flagUsage(fs, scheduleRunUsage) |
| 205 | fs.Parse(args) |
| 206 | scheduleName := strings.TrimSpace(*name) |
| 207 | if scheduleName == "" { |
| 208 | log.Fatal(scheduleRunUsage) |
| 209 | } |
| 210 | |
| 211 | sess := requireScheduleAPISession(*consoleFlag) |
| 212 | printUsing(stdout, sess) |
| 213 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 214 | appName := resolvedApp(sess, acct, *app, scheduleRunUsage) |
| 215 | res, err := sess.scheduleRun(context.Background(), acct, appName, scheduleName) |
| 216 | if err != nil { |
| 217 | log.Fatalf("schedule run: %v", err) |
| 218 | } |
| 219 | // The CANONICAL account/app the console echoed back, ls/set/rm's own |
| 220 | // rule — the segment this terminal resolved and sent may have been a |
| 221 | // name, not the sqid the console actually stores. |
| 222 | fmt.Fprintf(stdout, "%s for %s on %s/%s — fires on every instance within ~15s; watch it with carlos logs --app %s -f\n", |
| 223 | colorFor(stdout).ok("run requested"), res.Name, res.Account, res.App, res.App) |
| 224 | } |
| 225 | |
| 226 | // relFire renders one fire time (unix seconds, 0 meaning "never" — no run |
| 227 | // yet, or a status report too old to carry one) relative to now: "—" for |
| 228 | // the zero epoch, "in <dur>" for a future instant, "<dur> ago" for a past |
| 229 | // one. Duration granularity is minutes once the gap passes an hour and |
| 230 | // seconds below it — a minute of jitter on a 6-hour interval is noise a |
| 231 | // member does not need; a second of it on a 90-second one is the whole |
| 232 | // story. |
| 233 | func relFire(epoch int64, now time.Time) string { |
| 234 | if epoch == 0 { |
| 235 | return "—" |
| 236 | } |
| 237 | d := time.Unix(epoch, 0).Sub(now) |
| 238 | if d < 0 { |
| 239 | return fmtDur(-d) + " ago" |
| 240 | } |
| 241 | return "in " + fmtDur(d) |
| 242 | } |
| 243 | |
| 244 | // fmtDur is relFire's magnitude formatter — always given a non-negative |
| 245 | // duration. |
| 246 | func fmtDur(d time.Duration) string { |
| 247 | switch { |
| 248 | case d >= time.Hour: |
| 249 | h := int(d / time.Hour) |
| 250 | m := int((d % time.Hour) / time.Minute) |
| 251 | return fmt.Sprintf("%dh%02dm", h, m) |
| 252 | case d >= time.Minute: |
| 253 | m := int(d / time.Minute) |
| 254 | s := int((d % time.Minute) / time.Second) |
| 255 | return fmt.Sprintf("%dm%02ds", m, s) |
| 256 | default: |
| 257 | return fmt.Sprintf("%ds", int(d/time.Second)) |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | // renderScheduleList is `carlos schedule ls`'s rendering half, split out so |
| 262 | // it can be asserted on against a fixed clock without a console. It never |
| 263 | // panics on a nil Schedules or Instances slice, or on a nil Timers slice |
| 264 | // inside one Status — scheduleAnswer (internal/console/apischedule.go) |
| 265 | // always fills `[]` for both top-level arrays, but this still ranges |
| 266 | // safely over nil either way, the same defensiveness printLiveBoxes and |
| 267 | // every other list renderer in this binary applies to a server answer it |
| 268 | // does not fully control. |
| 269 | func renderScheduleList(w io.Writer, res apiScheduleList, now time.Time) { |
| 270 | if len(res.Schedules) == 0 { |
| 271 | fmt.Fprintf(w, "no schedules declared for %s/%s — carlos schedule set --app %s --name <n> --every 6h --path /jobs/<n>\n", |
| 272 | res.Account, res.App, res.App) |
| 273 | } else { |
| 274 | fmt.Fprintf(w, "schedules for %s/%s (updated %s)\n", res.Account, res.App, res.UpdatedAt) |
| 275 | renderScheduleTable(w, res.Schedules) |
| 276 | } |
| 277 | fmt.Fprintln(w) |
| 278 | fmt.Fprintln(w, "instances") |
| 279 | if len(res.Instances) == 0 { |
| 280 | fmt.Fprintln(w, " no instance has reported yet — boxes report within seconds of a schedule landing") |
| 281 | return |
| 282 | } |
| 283 | for _, inst := range res.Instances { |
| 284 | renderScheduleInstance(w, inst, now) |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | // renderScheduleTable prints the NAME/WHEN/PATH block, column widths sized |
| 289 | // to the widest cell in each column (header included) plus a 2-space gap — |
| 290 | // text/tabwriter's own rule, applied by hand here because this table's |
| 291 | // 2-space left margin has to be part of column one's measured width, not a |
| 292 | // separate prefix tabwriter would pad independently. |
| 293 | func renderScheduleTable(w io.Writer, scs []schedule.Schedule) { |
| 294 | nameW, whenW := len("NAME"), len("WHEN") |
| 295 | whens := make([]string, len(scs)) |
| 296 | for i, sc := range scs { |
| 297 | if n := len(sc.Name); n > nameW { |
| 298 | nameW = n |
| 299 | } |
| 300 | sp, err := sc.Spec() |
| 301 | when := sp.String() |
| 302 | if err != nil { |
| 303 | when = "(invalid: " + err.Error() + ")" |
| 304 | } |
| 305 | whens[i] = when |
| 306 | if n := len(when); n > whenW { |
| 307 | whenW = n |
| 308 | } |
| 309 | } |
| 310 | fmt.Fprintf(w, " %-*s%-*s%s\n", nameW+2, "NAME", whenW+2, "WHEN", "PATH") |
| 311 | for i, sc := range scs { |
| 312 | fmt.Fprintf(w, " %-*s%-*s%s\n", nameW+2, sc.Name, whenW+2, whens[i], sc.Path) |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | // renderScheduleInstance prints one instance's block: the host line, then |
| 317 | // one indented line per timer it reported. Unsupported (set only for a |
| 318 | // unit-backed host — converge.go's UnsupportedUnit) short-circuits to a |
| 319 | // single explanatory line with no timers, because a unit-backed instance |
| 320 | // never gets far enough to have any. |
| 321 | func renderScheduleInstance(w io.Writer, inst schedule.Status, now time.Time) { |
| 322 | if inst.Unsupported != "" { |
| 323 | fmt.Fprintf(w, " %s %s\n", inst.Host, inst.Unsupported) |
| 324 | return |
| 325 | } |
| 326 | fmt.Fprintf(w, " %s (box %s, reported %s ago)\n", inst.Host, inst.Box, reportedAgo(inst.ReportedAt, now)) |
| 327 | // A once-timer is the instance's own (registered off its control |
| 328 | // socket) and may share a name with a declared schedule, so it is |
| 329 | // labelled — a member reading two "remind" lines must be able to tell |
| 330 | // which is which. |
| 331 | labels := make([]string, len(inst.Timers)) |
| 332 | nameW := 0 |
| 333 | for i, t := range inst.Timers { |
| 334 | labels[i] = t.Name |
| 335 | if t.Kind == schedule.KindOnce { |
| 336 | labels[i] += " (once)" |
| 337 | } |
| 338 | if n := len(labels[i]); n > nameW { |
| 339 | nameW = n |
| 340 | } |
| 341 | } |
| 342 | for i, t := range inst.Timers { |
| 343 | next := relFire(t.NextAt, now) |
| 344 | last := relFire(t.LastAt, now) |
| 345 | switch { |
| 346 | case t.LastAt != 0 && t.LastStatus != "": |
| 347 | last += fmt.Sprintf(" → %s in %.1fs", t.LastStatus, float64(t.LastMs)/1000) |
| 348 | case t.LastStatus != "": |
| 349 | // A status with no fire behind it is a condition the box put on |
| 350 | // the row ("never fires" — a parked cron), not an outcome. |
| 351 | last += " (" + t.LastStatus + ")" |
| 352 | } |
| 353 | fmt.Fprintf(w, " %-*snext %s last %s\n", nameW+2, labels[i], next, last) |
| 354 | if t.HoldsAwake { |
| 355 | // The box worked this out; we only pass it on. Worth a whole |
| 356 | // line rather than a marker on the row: the member's next |
| 357 | // question is "so what?", and the answer is what they are being |
| 358 | // billed for. |
| 359 | fmt.Fprintf(w, " %-*sthis fires more often than the idle window, so the instance never hibernates between ticks\n", nameW+2, "") |
| 360 | } |
| 361 | } |
| 362 | } |
| 363 | |
| 364 | // reportedAgo turns a Status.ReportedAt RFC3339 string into fmtDur's "X ago" |
| 365 | // magnitude. A report this binary cannot parse (an old box's odd format, or |
| 366 | // a corrupt object a bad actor wrote) prints the raw string rather than |
| 367 | // panicking or hiding it — the caller still sees SOMETHING was reported. |
| 368 | func reportedAgo(s string, now time.Time) string { |
| 369 | t, err := time.Parse(time.RFC3339, s) |
| 370 | if err != nil { |
| 371 | return s |
| 372 | } |
| 373 | d := now.Sub(t) |
| 374 | if d < 0 { |
| 375 | d = 0 |
| 376 | } |
| 377 | return fmtDur(d) |
| 378 | } |
| 379 | |