carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The restart signal: one tiny object per app that a restart REQUEST touches,
2// so a box can learn "cycle this app's processes" from a single cheap etag —
3// the pull-based answer to issue #113 (a member must be able to cycle a wedged
4// instance with no version and no config to push, and with no box access at
5// all). Deliberately the same shape as appconfig's config-gen object, because
6// it is the same mechanism one step sideways: a generation object nobody
7// signs, watched by etag, whose movement means "do the thing", never "trust
8// this content".
9//
10// Bucket layout addition:
11//
12// apps/<acct>/<app>/restart-gen.json CAS-guarded, schema-1 generation object
13package release
14
15import (
16 "context"
17 "crypto/rand"
18 "encoding/hex"
19 "encoding/json"
20 "errors"
21 "fmt"
22 "io"
23 "os"
24 "time"
25)
26
27// RestartGeneration is the object `carlos restart` touches —
28// apps/<acct>/<app>/restart-gen.json. Change detection is by ETAG, never by
29// content: the fields exist so a human debugging "why did this bounce?" can
30// see when the last request went out, and so each touch's bytes are distinct
31// (DirStore's etag is content-derived, so a byte-identical rewrite would not
32// move it, and the movement IS the signal).
33//
34// It carries no authority whatsoever, which is what makes an unsigned object
35// safe here: the worst a forged touch can do is restart processes that were
36// already running the version their own signed pointer names. Nothing about
37// what runs is decided by this file.
38//
39// ABSENT IS LEGAL FOREVER. An app that has never been restarted this way has
40// no object and never will until someone asks; boxes read the absence, record
41// it, and go on. Old boxes that predate this feature ignore the key entirely
42// — the wire shape is purely additive, and nothing that parses today's app
43// prefix parses this.
44//
45// It is a SIBLING object rather than a field on anything existing for
46// appconfig.Generation's reason: a field would make every watcher re-read and
47// re-parse a whole manifest to learn one bit, and would race the very write
48// it signals.
49type RestartGeneration struct {
50 Schema int `json:"schema"` // 1
51 TouchedAt string `json:"touched_at"`
52 Nonce string `json:"nonce"`
53}
54
55// RestartGenKey is the bucket key of app's restart-generation object, derived
56// from AppPrefix like every other per-app key: everything an app owns sits
57// under one account-scoped prefix, permanently.
58func RestartGenKey(acct, app string) string { return AppPrefix(acct, app) + "restart-gen.json" }
59
60// TouchRestartGen bumps app's restart generation — the request every box
61// serving that app converges on within seconds.
62//
63// A CAS conflict is SUCCESS, not an error: it means another writer touched the
64// object between our read and our write, so the signal this function exists to
65// fire has already fired. Retrying would only produce a second, redundant
66// request — and a second restart of processes that are cycling right now is
67// the one outcome a restart verb must not manufacture out of a race.
68//
69// os.ErrNotExist on the read means first touch, so the write is create-only
70// (etag ""), which is what makes the conflict carve-out safe: two first
71// touches race, one wins, both callers are correct — both asked for a restart,
72// and exactly one restart is what the box will do.
73func claimRawTouchRestartGen(ctx context.Context, s Store, acct, app string) error {
74 if !ValidAccount(acct) {
75 return fmt.Errorf("restart: invalid account %q", acct)
76 }
77 if !ValidApp(app) {
78 return fmt.Errorf("restart: invalid app %q", app)
79 }
80 key := RestartGenKey(acct, app)
81 _, etag, err := s.Get(ctx, key)
82 if err != nil && !errors.Is(err, os.ErrNotExist) {
83 return err
84 }
85 if errors.Is(err, os.ErrNotExist) {
86 etag = ""
87 }
88 nonce := make([]byte, 16)
89 if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
90 return err
91 }
92 raw, err := json.Marshal(RestartGeneration{
93 Schema: 1,
94 TouchedAt: time.Now().UTC().Format(time.RFC3339),
95 Nonce: hex.EncodeToString(nonce),
96 })
97 if err != nil {
98 return err
99 }
100 if _, err := s.Put(ctx, key, raw, etag); err != nil {
101 if errors.Is(err, ErrCASConflict) {
102 return nil
103 }
104 return err
105 }
106 return nil
107}
108func TouchRestartGen(ctx context.Context, s Store, acct, app string) (claimResult0 error) {
109 claimResult0 = WithAppMutation(ctx, s, acct, app, "release.TouchRestartGen", func(claimContext context.Context) error {
110 claimResult0 = claimRawTouchRestartGen(claimContext, s, acct, app)
111 return claimResult0
112 })
113 return
114}
115