carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
14package main
15
16import (
17 "context"
18 "flag"
19 "fmt"
20 "io"
21 "log"
22 "strings"
23 "time"
24
25 "github.com/carlosframework/platform/internal/schedule"
26)
27
28const 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
46Signed-in only (carlos auth login): a schedule is a console-authorized write,
47the same shape as carlos restart — boxes act on it within seconds. The write
48itself wakes nothing; a box's runner wakes a hibernating instance when its
49tick is due (within ~15s of a run, or at the schedule's next occurrence).
50`
51
52const scheduleLsUsage = `usage: carlos schedule ls --app <a> [--account <a>] [--account-id <id>] [--console <origin>]
53`
54
55const 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
58const scheduleRmUsage = `usage: carlos schedule rm --app <a> --name <n> [--account <a>] [--account-id <id>] [--console <origin>]
59`
60
61const 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.
67func 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.
92func 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
106func 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
125func 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
174func 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
198func 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.
233func 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.
246func 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.
269func 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.
293func 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.
321func 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.
368func 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