| 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 |
| 13 | package release |
| 14 | |
| 15 | import ( |
| 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. |
| 49 | type 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. |
| 58 | func 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. |
| 73 | func 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 | } |
| 108 | func 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 | |