carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Command moveprobe is the moves rehearsal probe (moves spec §14.4): a small
2// app shaped like a customer app, deployed by the rehearsal runbook so that a
3// move can be shown to carry every kind of state an instance has.
4//
5// It holds state in each place an instance can:
6//
7// - the database: a background loop writes a row every 500 ms, a forked
8// worker writes one every second, and timer fires write one each;
9// - the files directory: instance.key, written at first boot and checked
10// against the database at every boot;
11// - the platform's instance key (CARLOS_INSTANCE_KEY), fingerprinted at
12// first boot and checked at every boot;
13// - its store binding (MOVEPROBE_*): an object written at boot and read
14// back on every readiness check;
15// - a sealed secret (MOVEPROBE_SECRET);
16// - timers: once-timers armed through the instance control socket, and a
17// declared schedule set by the operator with `carlos schedule set`.
18//
19// The exec backend starts it as `moveprobe --socket <path> --db <path>`
20// (internal/activator/backend_exec.go, spawnChildFrom). The files directory
21// is the database path without ".db" (the flat layout). "/" answers what
22// "/ready" answers, because a move probes "/" when moves.ready_path is unset.
23//
24// MOVEPROBE_FAIL_READY_IN=<region>[,after-write], read at startup, makes the
25// probe fail its readiness in the named region. With no suffix (before-write)
26// it writes nothing at all: no database open, no file, no worker, no socket
27// call. With ",after-write" it writes one background row, lets its worker
28// write, and then fails readiness. A box with no CARLOS_REGION is region
29// "home".
30//
31// The README beside this file has the build and deploy lines.
32package main
33
34import (
35 "bufio"
36 "bytes"
37 "context"
38 "crypto/rand"
39 "crypto/sha256"
40 "crypto/subtle"
41 "database/sql"
42 "encoding/hex"
43 "encoding/json"
44 "errors"
45 "flag"
46 "fmt"
47 "io"
48 "io/fs"
49 "net"
50 "net/http"
51 "net/url"
52 "os"
53 "os/exec"
54 "os/signal"
55 "path/filepath"
56 "strconv"
57 "strings"
58 "sync"
59 "syscall"
60 "time"
61
62 "github.com/aws/aws-sdk-go-v2/aws"
63 "github.com/aws/aws-sdk-go-v2/service/s3"
64
65 _ "modernc.org/sqlite"
66)
67
68const (
69 bgEvery = 500 * time.Millisecond
70 workerEvery = time.Second
71 // workerGrace is how long the worker keeps writing after its parent has
72 // gone. Long enough to outlive the main through a cut's stop and stop
73 // evidence (seconds); short enough that an ordinary sleep, which stops
74 // only the main, does not leave it writing for ever.
75 workerGrace = 60 * time.Second
76 // workerStale is how long the main waits for the worker's next status
77 // line (one a second) before readiness fails.
78 workerStale = 3 * time.Second
79 storeWait = 10 * time.Second
80 // storeCacheFor is how long one readiness store check answers for:
81 // anonymous /ready traffic costs at most one check per window.
82 storeCacheFor = 5 * time.Second
83
84 modeNormal = "normal"
85 modeBeforeWrite = "before-write"
86 modeAfterWrite = "after-write"
87)
88
89// workerGraceOverride, set at link time (-ldflags "-X
90// main.workerGraceOverride=8s"), replaces workerGrace. Only the tests set
91// it, to watch the worker leave after seconds rather than a minute; a build
92// that does not set it keeps workerGrace.
93var workerGraceOverride string
94
95// orphanGrace is workerGrace, or the link-time override when one is set.
96func orphanGrace() time.Duration {
97 if d, err := time.ParseDuration(workerGraceOverride); err == nil && d > 0 {
98 return d
99 }
100 return workerGrace
101}
102
103func main() {
104 if len(os.Args) > 1 && os.Args[1] == "worker" {
105 os.Exit(runWorker(os.Args[2:]))
106 }
107 os.Exit(run(os.Args[1:]))
108}
109
110func logf(format string, args ...any) {
111 fmt.Fprintf(os.Stderr, "moveprobe: "+format+"\n", args...)
112}
113
114// region is CARLOS_REGION, or "home" when the box has no placement code and
115// the agent leaves the variable out.
116func region() (name, from string) {
117 if r := strings.TrimSpace(os.Getenv("CARLOS_REGION")); r != "" {
118 return r, "CARLOS_REGION"
119 }
120 return "home", "unset, treated as home"
121}
122
123// failMode reads MOVEPROBE_FAIL_READY_IN for this region. A suffix other
124// than after-write is read as before-write, the mode that writes nothing.
125func failMode(spec, here string) string {
126 spec = strings.TrimSpace(spec)
127 if spec == "" {
128 return modeNormal
129 }
130 where, suffix, hasSuffix := strings.Cut(spec, ",")
131 if strings.TrimSpace(where) != here {
132 return modeNormal
133 }
134 if hasSuffix && strings.TrimSpace(suffix) == modeAfterWrite {
135 return modeAfterWrite
136 }
137 if hasSuffix {
138 logf("MOVEPROBE_FAIL_READY_IN suffix %q is not after-write; using before-write", suffix)
139 }
140 return modeBeforeWrite
141}
142
143func run(args []string) int {
144 fl := flag.NewFlagSet("moveprobe", flag.ContinueOnError)
145 sock := fl.String("socket", "", "unix socket to listen on")
146 dbPath := fl.String("db", "", "the instance database")
147 if err := fl.Parse(args); err != nil {
148 return 2
149 }
150 if *sock == "" || *dbPath == "" {
151 logf("--socket and --db are required")
152 return 2
153 }
154 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
155 defer stop()
156
157 here, from := region()
158 mode := failMode(os.Getenv("MOVEPROBE_FAIL_READY_IN"), here)
159 if mode == modeBeforeWrite {
160 logf("before-write mode in region %s: nothing is written", here)
161 return serve(ctx, *sock, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
162 http.Error(w, "before-write mode: this box writes nothing", http.StatusServiceUnavailable)
163 }))
164 }
165
166 a, err := boot(ctx, *dbPath, here, from, mode)
167 if err != nil {
168 logf("%v", err)
169 return 1
170 }
171 defer a.db.Close()
172
173 loopCtx, stopLoop := context.WithCancel(ctx)
174 defer stopLoop()
175 if mode == modeAfterWrite {
176 // The mode's whole promise is this row; without it the rollback
177 // would have nothing to find, so the app does not start.
178 if err := a.insertSeq("bg"); err != nil {
179 logf("after-write: the bg row did not commit: %v", err)
180 return 1
181 }
182 } else {
183 go a.bgLoop(loopCtx)
184 }
185 a.forkWorker()
186 return serve(ctx, *sock, a.routes())
187}
188
189// serve listens on sock until ctx ends, then shuts down.
190func serve(ctx context.Context, sock string, h http.Handler) int {
191 if fi, err := os.Lstat(sock); err == nil && fi.Mode()&fs.ModeSocket != 0 {
192 os.Remove(sock)
193 }
194 ln, err := net.Listen("unix", sock)
195 if err != nil {
196 logf("listen: %v", err)
197 return 1
198 }
199 srv := &http.Server{Handler: h, ReadHeaderTimeout: 10 * time.Second}
200 errc := make(chan error, 1)
201 go func() { errc <- srv.Serve(ln) }()
202 select {
203 case err := <-errc:
204 logf("serve: %v", err)
205 return 1
206 case <-ctx.Done():
207 }
208 shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
209 defer cancel()
210 srv.Shutdown(shutCtx)
211 return 0
212}
213
214type app struct {
215 db *sql.DB
216 dbPath string
217 filesDir string
218 host string
219 region string
220 regionFrom string
221 mode string
222 bootID string
223
224 fileKey string // meta instance_key; empty when the boot could not settle it
225 fileKeyErr error // set at boot when the file and the database disagree
226 platformFP string // SHA-256 of CARLOS_INSTANCE_KEY
227 platErr error
228
229 store *s3.Client
230 storeErr error
231 bucket string
232 objKey string
233 objBody string
234
235 // The readiness store check: one in flight at a time, its result kept
236 // for storeCacheFor.
237 storeMu sync.Mutex
238 storeRunning chan struct{} // closed when the running check finishes
239 storeAt time.Time // when the cached result was taken
240 storeResult error
241
242 mu sync.Mutex
243 objPut bool
244 workerPID int
245 // The worker reports every write over a pipe: how many committed and
246 // how many failed since this start, how many of the latest failed in a
247 // row, and the last error.
248 workerCommitted int64
249 workerFailed int64
250 workerInARow int64
251 workerLastErr string
252 workerLastAt time.Time // the latest status line
253 workerExit string // why the worker is gone; empty while it runs
254
255 // control is the one client for the instance control socket, nil when
256 // the agent delivered none.
257 control *http.Client
258}
259
260func openDB(path string) (*sql.DB, error) {
261 db, err := sql.Open("sqlite", "file:"+url.PathEscape(path)+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
262 if err != nil {
263 return nil, err
264 }
265 db.SetMaxOpenConns(1)
266 return db, nil
267}
268
269func boot(ctx context.Context, dbPath, here, from, mode string) (*app, error) {
270 id := make([]byte, 8)
271 if _, err := rand.Read(id); err != nil {
272 return nil, err
273 }
274 a := &app{
275 dbPath: dbPath,
276 filesDir: strings.TrimSuffix(dbPath, ".db"),
277 host: os.Getenv("CARLOS_HOST"),
278 region: here,
279 regionFrom: from,
280 mode: mode,
281 bootID: hex.EncodeToString(id),
282 }
283 db, err := openDB(dbPath)
284 if err != nil {
285 return nil, fmt.Errorf("open %s: %w", dbPath, err)
286 }
287 a.db = db
288 for _, q := range []string{
289 `CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`,
290 `CREATE TABLE IF NOT EXISTS rows (
291 id INTEGER PRIMARY KEY AUTOINCREMENT,
292 kind TEXT NOT NULL,
293 name TEXT NOT NULL DEFAULT '',
294 seq INTEGER NOT NULL DEFAULT 0,
295 due_at INTEGER NOT NULL DEFAULT 0,
296 fired_at INTEGER NOT NULL,
297 region TEXT NOT NULL)`,
298 `CREATE INDEX IF NOT EXISTS rows_kind_seq ON rows (kind, seq)`,
299 } {
300 if _, err := db.Exec(q); err != nil {
301 db.Close()
302 return nil, fmt.Errorf("schema: %w", err)
303 }
304 }
305 if err := os.MkdirAll(a.filesDir, 0o700); err != nil {
306 db.Close()
307 return nil, fmt.Errorf("files directory: %w", err)
308 }
309 if err := a.settleFileKey(); err != nil {
310 db.Close()
311 return nil, err
312 }
313 if err := a.settlePlatformKey(); err != nil {
314 db.Close()
315 return nil, err
316 }
317 a.setupStore()
318 if sock := os.Getenv("CARLOS_CONTROL_SOCKET"); sock != "" {
319 a.control = &http.Client{Timeout: 10 * time.Second, Transport: &http.Transport{
320 DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
321 var d net.Dialer
322 return d.DialContext(ctx, "unix", sock)
323 },
324 }}
325 }
326 if a.storeErr == nil {
327 putCtx, cancel := context.WithTimeout(ctx, storeWait)
328 if err := a.putObject(putCtx); err != nil {
329 logf("store: %v", err)
330 }
331 cancel()
332 }
333 return a, nil
334}
335
336func (a *app) meta(key string) (string, bool, error) {
337 var v string
338 err := a.db.QueryRow(`SELECT value FROM meta WHERE key = ?`, key).Scan(&v)
339 if errors.Is(err, sql.ErrNoRows) {
340 return "", false, nil
341 }
342 return v, err == nil, err
343}
344
345func (a *app) keyPath() string { return filepath.Join(a.filesDir, "instance.key") }
346
347// settleFileKey writes instance.key at first boot and records it in meta;
348// on a later boot it remembers meta's value for the readiness check. A file
349// with no database key is a mismatch, never a reason to adopt the file.
350func (a *app) settleFileKey() error {
351 v, found, err := a.meta("instance_key")
352 if err != nil {
353 return fmt.Errorf("meta: %w", err)
354 }
355 if found {
356 a.fileKey = v
357 return nil
358 }
359 if _, err := os.Lstat(a.keyPath()); err == nil {
360 a.fileKeyErr = errors.New("key mismatch: instance.key exists but the database has no key")
361 return nil
362 }
363 raw := make([]byte, 32)
364 if _, err := rand.Read(raw); err != nil {
365 return err
366 }
367 key := hex.EncodeToString(raw)
368 f, err := os.OpenFile(a.keyPath(), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
369 if err != nil {
370 return fmt.Errorf("instance.key: %w", err)
371 }
372 if _, err := f.WriteString(key + "\n"); err != nil {
373 f.Close()
374 return fmt.Errorf("instance.key: %w", err)
375 }
376 if err := f.Sync(); err != nil {
377 f.Close()
378 return fmt.Errorf("instance.key: %w", err)
379 }
380 if err := f.Close(); err != nil {
381 return fmt.Errorf("instance.key: %w", err)
382 }
383 if _, err := a.db.Exec(`INSERT INTO meta (key, value) VALUES ('instance_key', ?)`, key); err != nil {
384 return fmt.Errorf("meta: %w", err)
385 }
386 a.fileKey = key
387 return nil
388}
389
390// settlePlatformKey records CARLOS_INSTANCE_KEY's fingerprint the first time
391// it is seen and holds every later boot to it.
392func (a *app) settlePlatformKey() error {
393 k := os.Getenv("CARLOS_INSTANCE_KEY")
394 if k == "" {
395 a.platErr = errors.New("CARLOS_INSTANCE_KEY is not set")
396 return nil
397 }
398 a.platformFP = fingerprint(k)
399 v, found, err := a.meta("platform_key_sha256")
400 if err != nil {
401 return fmt.Errorf("meta: %w", err)
402 }
403 if !found {
404 if _, err := a.db.Exec(`INSERT INTO meta (key, value) VALUES ('platform_key_sha256', ?)`, a.platformFP); err != nil {
405 return fmt.Errorf("meta: %w", err)
406 }
407 return nil
408 }
409 if v != a.platformFP {
410 a.platErr = errors.New("CARLOS_INSTANCE_KEY differs from the one seen at first boot")
411 }
412 return nil
413}
414
415func fingerprint(s string) string {
416 sum := sha256.Sum256([]byte(s))
417 return hex.EncodeToString(sum[:])
418}
419
420// fileKeyNow is instance.key as it is on disk now, trimmed.
421func (a *app) fileKeyNow() (string, error) {
422 b, err := os.ReadFile(a.keyPath())
423 if err != nil {
424 return "", err
425 }
426 return strings.TrimSpace(string(b)), nil
427}
428
429func (a *app) checkFileKey() error {
430 if a.fileKeyErr != nil {
431 return a.fileKeyErr
432 }
433 got, err := a.fileKeyNow()
434 switch {
435 case errors.Is(err, fs.ErrNotExist):
436 return errors.New("key mismatch: instance.key is missing")
437 case err != nil:
438 return fmt.Errorf("key mismatch: instance.key: %v", err)
439 case subtle.ConstantTimeCompare([]byte(got), []byte(a.fileKey)) != 1:
440 return errors.New("key mismatch: instance.key differs from the database")
441 }
442 return nil
443}
444
445// ---- the store ----------------------------------------------------------------
446
447type storeConfig struct {
448 bucket, region, endpoint, accessKey, secretKey string
449}
450
451// storeFromEnv reads the MOVEPROBE binding. The endpoint is optional: the
452// platform delivers it only when the deployment has one, and never on AWS.
453func storeFromEnv() (storeConfig, error) {
454 c := storeConfig{
455 bucket: os.Getenv("MOVEPROBE_BUCKET"),
456 region: os.Getenv("MOVEPROBE_REGION"),
457 endpoint: os.Getenv("MOVEPROBE_ENDPOINT"),
458 accessKey: os.Getenv("MOVEPROBE_ACCESS_KEY"),
459 secretKey: os.Getenv("MOVEPROBE_SECRET_KEY"),
460 }
461 var missing []string
462 for _, v := range []struct{ name, val string }{
463 {"MOVEPROBE_BUCKET", c.bucket},
464 {"MOVEPROBE_REGION", c.region},
465 {"MOVEPROBE_ACCESS_KEY", c.accessKey},
466 {"MOVEPROBE_SECRET_KEY", c.secretKey},
467 } {
468 if v.val == "" {
469 missing = append(missing, v.name)
470 }
471 }
472 if len(missing) > 0 {
473 return c, fmt.Errorf("store: %s not set", strings.Join(missing, ", "))
474 }
475 return c, nil
476}
477
478// newStoreClient builds the S3 client from the binding's keys alone. It
479// never loads the default credential chain: the app shares the agent's uid
480// on a flat box, so the chain could find the box's own role and read the
481// object back without the binding. Path-style only with an endpoint, as
482// objstore.S3ClientFor does.
483func newStoreClient(c storeConfig) *s3.Client {
484 return s3.New(s3.Options{
485 Region: c.region,
486 Credentials: aws.CredentialsProviderFunc(func(context.Context) (aws.Credentials, error) {
487 return aws.Credentials{AccessKeyID: c.accessKey, SecretAccessKey: c.secretKey, Source: "MOVEPROBE"}, nil
488 }),
489 BaseEndpoint: func() *string {
490 if c.endpoint == "" {
491 return nil
492 }
493 return aws.String(c.endpoint)
494 }(),
495 UsePathStyle: c.endpoint != "",
496 RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired,
497 ResponseChecksumValidation: aws.ResponseChecksumValidationWhenRequired,
498 })
499}
500
501func (a *app) setupStore() {
502 c, err := storeFromEnv()
503 if err != nil {
504 a.storeErr = err
505 return
506 }
507 a.store = newStoreClient(c)
508 a.bucket = c.bucket
509 a.objKey = "probe/" + a.bootID + ".txt"
510 a.objBody = fmt.Sprintf("moveprobe boot %s host %s region %s at %s\n", a.bootID, a.host, a.region, time.Now().UTC().Format(time.RFC3339))
511}
512
513func (a *app) putObject(ctx context.Context) error {
514 _, err := a.store.PutObject(ctx, &s3.PutObjectInput{
515 Bucket: aws.String(a.bucket),
516 Key: aws.String(a.objKey),
517 Body: bytes.NewReader([]byte(a.objBody)),
518 })
519 if err != nil {
520 return fmt.Errorf("put %s: %w", a.objKey, err)
521 }
522 a.mu.Lock()
523 a.objPut = true
524 a.mu.Unlock()
525 return nil
526}
527
528func (a *app) getObject(ctx context.Context) (string, error) {
529 out, err := a.store.GetObject(ctx, &s3.GetObjectInput{Bucket: aws.String(a.bucket), Key: aws.String(a.objKey)})
530 if err != nil {
531 return "", fmt.Errorf("get %s: %w", a.objKey, err)
532 }
533 defer out.Body.Close()
534 b, err := io.ReadAll(io.LimitReader(out.Body, 1<<16))
535 if err != nil {
536 return "", fmt.Errorf("get %s: %w", a.objKey, err)
537 }
538 return string(b), nil
539}
540
541// checkStore puts the boot's object if the boot could not, then reads it
542// back.
543func (a *app) checkStore(ctx context.Context) error {
544 if a.storeErr != nil {
545 return a.storeErr
546 }
547 ctx, cancel := context.WithTimeout(ctx, storeWait)
548 defer cancel()
549 a.mu.Lock()
550 put := a.objPut
551 a.mu.Unlock()
552 if !put {
553 if err := a.putObject(ctx); err != nil {
554 return fmt.Errorf("store: %v", err)
555 }
556 }
557 got, err := a.getObject(ctx)
558 if err != nil {
559 return fmt.Errorf("store: %v", err)
560 }
561 if got != a.objBody {
562 return fmt.Errorf("store: %s reads back different bytes", a.objKey)
563 }
564 return nil
565}
566
567// storeVerdict is checkStore for readiness, coalesced: a result younger than
568// storeCacheFor is reused, and a request that arrives while a check runs
569// waits for that check instead of starting another. The check itself runs
570// on its own deadline, so one caller giving up does not cut it short for the
571// others.
572func (a *app) storeVerdict(ctx context.Context) error {
573 a.storeMu.Lock()
574 if !a.storeAt.IsZero() && time.Since(a.storeAt) < storeCacheFor {
575 err := a.storeResult
576 a.storeMu.Unlock()
577 return err
578 }
579 if running := a.storeRunning; running != nil {
580 a.storeMu.Unlock()
581 select {
582 case <-running:
583 case <-ctx.Done():
584 return fmt.Errorf("store: %v", ctx.Err())
585 }
586 a.storeMu.Lock()
587 err := a.storeResult
588 a.storeMu.Unlock()
589 return err
590 }
591 done := make(chan struct{})
592 a.storeRunning = done
593 a.storeMu.Unlock()
594
595 err := a.checkStore(context.Background())
596
597 a.storeMu.Lock()
598 a.storeResult, a.storeAt, a.storeRunning = err, time.Now(), nil
599 a.storeMu.Unlock()
600 close(done)
601 return err
602}
603
604// ---- rows ---------------------------------------------------------------------
605
606// insertSeq writes the next row of a sequenced kind. The sequence is taken
607// inside the insert, so two writers (a main and a worker from an earlier
608// boot, or two generations) never take the same number.
609func (a *app) insertSeq(kind string) error {
610 return insertSeq(a.db, kind, a.region)
611}
612
613func insertSeq(db *sql.DB, kind, region string) error {
614 _, err := db.Exec(`INSERT INTO rows (kind, seq, fired_at, region)
615 SELECT ?, coalesce(max(seq), 0) + 1, ?, ? FROM rows WHERE kind = ?`,
616 kind, time.Now().Unix(), region, kind)
617 return err
618}
619
620func (a *app) bgLoop(ctx context.Context) {
621 t := time.NewTicker(bgEvery)
622 defer t.Stop()
623 for {
624 if err := a.insertSeq("bg"); err != nil {
625 logf("bg: %v", err)
626 }
627 select {
628 case <-ctx.Done():
629 return
630 case <-t.C:
631 }
632 }
633}
634
635// ---- the worker ---------------------------------------------------------------
636
637// forkWorker starts the worker in its own session, with its stdio on
638// /dev/null, so it neither shares the main's signals nor holds the pipes the
639// backend reads the main's output from. A pipe of the main's own (the
640// worker's fd 3) carries the outcome of each of the worker's writes.
641func (a *app) forkWorker() {
642 status, statusW, err := os.Pipe()
643 if err != nil {
644 logf("worker: %v", err)
645 return
646 }
647 cmd := exec.Command(os.Args[0], "worker", "--db", a.dbPath, "--parent", strconv.Itoa(os.Getpid()), "--status-fd", "3")
648 cmd.SysProcAttr = detachAttr()
649 cmd.ExtraFiles = []*os.File{statusW}
650 err = cmd.Start()
651 statusW.Close()
652 if err != nil {
653 status.Close()
654 logf("worker: %v", err)
655 a.mu.Lock()
656 a.workerExit = "did not start: " + err.Error()
657 a.mu.Unlock()
658 return
659 }
660 a.mu.Lock()
661 a.workerPID = cmd.Process.Pid
662 a.mu.Unlock()
663 go func() {
664 err := cmd.Wait()
665 how := "exit status 0"
666 if err != nil {
667 how = err.Error()
668 }
669 a.mu.Lock()
670 a.workerExit = "exited (" + how + ")"
671 a.mu.Unlock()
672 }()
673 go a.readWorkerStatus(status)
674}
675
676// readWorkerStatus reads the worker's lines: "ok", or "fail <error>".
677func (a *app) readWorkerStatus(r *os.File) {
678 defer r.Close()
679 sc := bufio.NewScanner(r)
680 for sc.Scan() {
681 line := sc.Text()
682 a.mu.Lock()
683 a.workerLastAt = time.Now()
684 if msg, failed := strings.CutPrefix(line, "fail "); failed {
685 a.workerFailed++
686 a.workerInARow++
687 a.workerLastErr = msg
688 } else {
689 a.workerCommitted++
690 a.workerInARow = 0
691 }
692 a.mu.Unlock()
693 }
694 // The pipe closes when the worker exits; Wait's own account, when it
695 // comes, replaces this one.
696 a.mu.Lock()
697 if a.workerExit == "" {
698 a.workerExit = "exited (its status pipe closed)"
699 }
700 a.mu.Unlock()
701}
702
703// checkWorker holds readiness to the worker: it is still running and has
704// sent a status line within workerStale, at least one of its rows has
705// committed since this start, and its latest write succeeded.
706func (a *app) checkWorker() error {
707 a.mu.Lock()
708 defer a.mu.Unlock()
709 switch {
710 case a.workerExit != "":
711 return errors.New("worker: " + a.workerExit)
712 case !a.workerLastAt.IsZero() && time.Since(a.workerLastAt) > workerStale:
713 return fmt.Errorf("worker: no status for %ds", int(time.Since(a.workerLastAt).Seconds()))
714 case a.workerCommitted == 0 && a.workerInARow > 0:
715 return fmt.Errorf("worker: no row committed since start; last error: %s", a.workerLastErr)
716 case a.workerCommitted == 0:
717 return errors.New("worker: no row committed since start")
718 case a.workerInARow > 0:
719 return fmt.Errorf("worker: the last write failed: %s", a.workerLastErr)
720 }
721 return nil
722}
723
724// runWorker writes a worker row every second. Once its parent has gone it
725// keeps writing for workerGrace (orphanGrace) and then exits.
726func runWorker(args []string) int {
727 fl := flag.NewFlagSet("moveprobe worker", flag.ContinueOnError)
728 dbPath := fl.String("db", "", "the instance database")
729 parent := fl.Int("parent", 0, "the main's pid")
730 statusFD := fl.Int("status-fd", 0, "a pipe to report each write on (0: none)")
731 if err := fl.Parse(args); err != nil || *dbPath == "" || *parent <= 0 {
732 return 2
733 }
734 here, _ := region()
735 // Writes to the pipe fail once the main has gone; they are ignored.
736 report := func(err error) {}
737 if *statusFD > 0 {
738 status := os.NewFile(uintptr(*statusFD), "status")
739 defer status.Close()
740 report = func(err error) {
741 if err != nil {
742 fmt.Fprintf(status, "fail %s\n", strings.ReplaceAll(err.Error(), "\n", " "))
743 } else {
744 fmt.Fprintln(status, "ok")
745 }
746 }
747 }
748 db, err := openDB(*dbPath)
749 if err != nil {
750 report(err)
751 return 1
752 }
753 defer db.Close()
754 grace := orphanGrace()
755 var orphaned time.Time
756 t := time.NewTicker(workerEvery)
757 defer t.Stop()
758 for {
759 if orphaned.IsZero() && os.Getppid() != *parent {
760 orphaned = time.Now()
761 }
762 if !orphaned.IsZero() && time.Since(orphaned) >= grace {
763 return 0
764 }
765 report(insertSeq(db, "worker", here))
766 <-t.C
767 }
768}
769
770// ---- HTTP ---------------------------------------------------------------------
771
772func (a *app) routes() http.Handler {
773 mux := http.NewServeMux()
774 mux.HandleFunc("/{$}", a.handleReady)
775 mux.HandleFunc("/ready", a.handleReady)
776 mux.HandleFunc("GET /report", a.bearer(os.Getenv("MOVEPROBE_SECRET"), a.handleReport))
777 mux.HandleFunc("POST /arm-once", a.bearer(os.Getenv("MOVEPROBE_SECRET"), a.handleArmOnce))
778 // The platform's runner signs each fire with the instance's admin token.
779 mux.HandleFunc("POST /tick", a.bearer(os.Getenv("CARLOS_ADMIN_TOKEN"), a.fired("tick")))
780 mux.HandleFunc("POST /once", a.bearer(os.Getenv("CARLOS_ADMIN_TOKEN"), a.fired("once")))
781 return mux
782}
783
784// bearer admits a request carrying "Authorization: Bearer <want>". An empty
785// want admits nothing.
786func (a *app) bearer(want string, next http.HandlerFunc) http.HandlerFunc {
787 return func(w http.ResponseWriter, r *http.Request) {
788 got := sha256.Sum256([]byte(r.Header.Get("Authorization")))
789 exp := sha256.Sum256([]byte("Bearer " + want))
790 if want == "" || subtle.ConstantTimeCompare(got[:], exp[:]) != 1 {
791 http.Error(w, "unauthorized", http.StatusUnauthorized)
792 return
793 }
794 next(w, r)
795 }
796}
797
798// readiness is /ready's answer: every check that fails, or ready.
799func (a *app) readiness(ctx context.Context) (int, string) {
800 if a.mode == modeAfterWrite {
801 return http.StatusServiceUnavailable, "after-write mode: not ready here"
802 }
803 var fails []string
804 if err := a.checkFileKey(); err != nil {
805 fails = append(fails, err.Error())
806 }
807 if a.platErr != nil {
808 fails = append(fails, a.platErr.Error())
809 }
810 if os.Getenv("MOVEPROBE_SECRET") == "" {
811 fails = append(fails, "MOVEPROBE_SECRET is not set")
812 }
813 if err := a.storeVerdict(ctx); err != nil {
814 fails = append(fails, err.Error())
815 }
816 if err := a.checkWorker(); err != nil {
817 fails = append(fails, err.Error())
818 }
819 if len(fails) > 0 {
820 return http.StatusInternalServerError, strings.Join(fails, "; ")
821 }
822 return http.StatusOK, "ready"
823}
824
825func (a *app) handleReady(w http.ResponseWriter, r *http.Request) {
826 code, msg := a.readiness(r.Context())
827 if code != http.StatusOK {
828 http.Error(w, msg, code)
829 return
830 }
831 fmt.Fprintln(w, msg)
832}
833
834func (a *app) fired(kind string) http.HandlerFunc {
835 return func(w http.ResponseWriter, r *http.Request) {
836 name := r.Header.Get("X-Carlos-Schedule")
837 due, err := strconv.ParseInt(r.Header.Get("X-Carlos-Schedule-At"), 10, 64)
838 if name == "" || err != nil {
839 http.Error(w, "want X-Carlos-Schedule and X-Carlos-Schedule-At", http.StatusBadRequest)
840 return
841 }
842 if _, err := a.db.Exec(`INSERT INTO rows (kind, name, due_at, fired_at, region) VALUES (?, ?, ?, ?, ?)`,
843 kind, name, due, time.Now().Unix(), a.region); err != nil {
844 http.Error(w, "database: "+err.Error(), http.StatusInternalServerError)
845 return
846 }
847 fmt.Fprintln(w, "ok")
848 }
849}
850
851// handleArmOnce registers once-<n> for /once, due in the given seconds,
852// through the instance control socket.
853func (a *app) handleArmOnce(w http.ResponseWriter, r *http.Request) {
854 in, err := strconv.ParseInt(r.URL.Query().Get("in"), 10, 64)
855 if err != nil || in < 0 || in > 400*24*3600 {
856 http.Error(w, "in: want whole seconds, at most 400 days", http.StatusBadRequest)
857 return
858 }
859 if a.control == nil {
860 http.Error(w, "no control socket: CARLOS_CONTROL_SOCKET is not set", http.StatusServiceUnavailable)
861 return
862 }
863 token := os.Getenv("CARLOS_ADMIN_TOKEN")
864 if token == "" {
865 http.Error(w, "CARLOS_ADMIN_TOKEN is not set", http.StatusServiceUnavailable)
866 return
867 }
868 var n int64
869 if err := a.db.QueryRow(`INSERT INTO meta (key, value) VALUES ('once_seq', '1')
870 ON CONFLICT (key) DO UPDATE SET value = CAST(value AS INTEGER) + 1
871 RETURNING CAST(value AS INTEGER)`).Scan(&n); err != nil {
872 http.Error(w, "database: "+err.Error(), http.StatusInternalServerError)
873 return
874 }
875 name := fmt.Sprintf("once-%d", n)
876 at := time.Now().Add(time.Duration(in) * time.Second).UTC().Truncate(time.Second)
877 body, _ := json.Marshal(map[string]string{"at": at.Format(time.RFC3339), "path": "/once"})
878
879 ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
880 defer cancel()
881 req, err := http.NewRequestWithContext(ctx, http.MethodPut, "http://carlos/schedule/"+name, bytes.NewReader(body))
882 if err != nil {
883 http.Error(w, "control socket: "+err.Error(), http.StatusInternalServerError)
884 return
885 }
886 req.Header.Set("Authorization", "Bearer "+token)
887 req.Header.Set("Content-Type", "application/json")
888 resp, err := a.control.Do(req)
889 if err != nil {
890 http.Error(w, "control socket: "+err.Error(), http.StatusBadGateway)
891 return
892 }
893 // Drained before close, so the connection goes back for the next arm.
894 defer func() {
895 io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
896 resp.Body.Close()
897 }()
898 reply, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
899 if resp.StatusCode/100 != 2 {
900 http.Error(w, fmt.Sprintf("control socket: %d %s", resp.StatusCode, strings.TrimSpace(string(reply))), http.StatusBadGateway)
901 return
902 }
903 w.Header().Set("Content-Type", "application/json")
904 json.NewEncoder(w).Encode(map[string]any{"name": name, "at": at.Format(time.RFC3339), "due_at": at.Unix()})
905}
906
907type rowCount struct {
908 Kind string `json:"kind"`
909 Region string `json:"region"`
910 Count int64 `json:"count"`
911 MinSeq int64 `json:"min_seq"`
912 MaxSeq int64 `json:"max_seq"`
913}
914
915type firedCount struct {
916 Kind string `json:"kind"`
917 Name string `json:"name"`
918 DueAt int64 `json:"due_at"`
919 Count int64 `json:"count"`
920}
921
922type objectReport struct {
923 Key string `json:"key"`
924 Body string `json:"body"`
925 Error string `json:"error,omitempty"`
926}
927
928type report struct {
929 Host string `json:"host"`
930 Region string `json:"region"`
931 RegionFrom string `json:"region_from"`
932 Mode string `json:"mode"`
933 BootID string `json:"boot_id"`
934 Rows []rowCount `json:"rows"`
935 Fired []firedCount `json:"fired"`
936 FileKeySHA256 string `json:"file_key_sha256"`
937 PlatformKeySHA256 string `json:"platform_key_sha256"`
938 Object objectReport `json:"object"`
939 HasSecret bool `json:"has_secret"`
940 WorkerPID int `json:"worker_pid"`
941 WorkerCommitted int64 `json:"worker_writes_committed"`
942 WorkerFailed int64 `json:"worker_writes_failed"`
943 WorkerInARow int64 `json:"worker_failed_in_a_row"`
944 WorkerLastError string `json:"worker_last_error"`
945 WorkerExit string `json:"worker_exit"`
946 WorkerLastStatus int64 `json:"worker_last_status_at"`
947}
948
949// counts reads the row counts and the timer fires. Any failed query, scan
950// or read part way through is an error, never a shorter list.
951func (a *app) counts(ctx context.Context) ([]rowCount, []firedCount, error) {
952 rowsOut := []rowCount{}
953 err := a.query(ctx, `SELECT kind, region, count(*), min(seq), max(seq) FROM rows GROUP BY kind, region ORDER BY kind, region`,
954 func(rows *sql.Rows) error {
955 var c rowCount
956 if err := rows.Scan(&c.Kind, &c.Region, &c.Count, &c.MinSeq, &c.MaxSeq); err != nil {
957 return err
958 }
959 rowsOut = append(rowsOut, c)
960 return nil
961 })
962 if err != nil {
963 return nil, nil, err
964 }
965 fired := []firedCount{}
966 err = a.query(ctx, `SELECT kind, name, due_at, count(*) FROM rows WHERE kind IN ('tick', 'once') GROUP BY kind, name, due_at ORDER BY kind, name, due_at`,
967 func(rows *sql.Rows) error {
968 var c firedCount
969 if err := rows.Scan(&c.Kind, &c.Name, &c.DueAt, &c.Count); err != nil {
970 return err
971 }
972 fired = append(fired, c)
973 return nil
974 })
975 if err != nil {
976 return nil, nil, err
977 }
978 return rowsOut, fired, nil
979}
980
981func (a *app) query(ctx context.Context, q string, each func(*sql.Rows) error) error {
982 rows, err := a.db.QueryContext(ctx, q)
983 if err != nil {
984 return err
985 }
986 defer rows.Close()
987 for rows.Next() {
988 if err := each(rows); err != nil {
989 return err
990 }
991 }
992 return rows.Err()
993}
994
995func (a *app) handleReport(w http.ResponseWriter, r *http.Request) {
996 rep := report{
997 Host: a.host, Region: a.region, RegionFrom: a.regionFrom, Mode: a.mode, BootID: a.bootID,
998 PlatformKeySHA256: a.platformFP,
999 HasSecret: os.Getenv("MOVEPROBE_SECRET") != "",
1000 }
1001 a.mu.Lock()
1002 rep.WorkerPID = a.workerPID
1003 rep.WorkerCommitted, rep.WorkerFailed = a.workerCommitted, a.workerFailed
1004 rep.WorkerInARow, rep.WorkerLastError = a.workerInARow, a.workerLastErr
1005 rep.WorkerExit = a.workerExit
1006 if !a.workerLastAt.IsZero() {
1007 rep.WorkerLastStatus = a.workerLastAt.Unix()
1008 }
1009 a.mu.Unlock()
1010 if k, err := a.fileKeyNow(); err == nil {
1011 rep.FileKeySHA256 = fingerprint(k)
1012 }
1013
1014 var err error
1015 if rep.Rows, rep.Fired, err = a.counts(r.Context()); err != nil {
1016 http.Error(w, "report: "+err.Error(), http.StatusInternalServerError)
1017 return
1018 }
1019
1020 rep.Object.Key = a.objKey
1021 if a.storeErr != nil {
1022 rep.Object.Error = a.storeErr.Error()
1023 } else {
1024 ctx, cancel := context.WithTimeout(r.Context(), storeWait)
1025 body, err := a.getObject(ctx)
1026 cancel()
1027 rep.Object.Body = body
1028 if err != nil {
1029 rep.Object.Error = err.Error()
1030 }
1031 }
1032 w.Header().Set("Content-Type", "application/json")
1033 enc := json.NewEncoder(w)
1034 enc.SetIndent("", " ")
1035 enc.Encode(rep)
1036}
1037