// The restart signal: one tiny object per app that a restart REQUEST touches, // so a box can learn "cycle this app's processes" from a single cheap etag — // the pull-based answer to issue #113 (a member must be able to cycle a wedged // instance with no version and no config to push, and with no box access at // all). Deliberately the same shape as appconfig's config-gen object, because // it is the same mechanism one step sideways: a generation object nobody // signs, watched by etag, whose movement means "do the thing", never "trust // this content". // // Bucket layout addition: // // apps///restart-gen.json CAS-guarded, schema-1 generation object package release import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "time" ) // RestartGeneration is the object `carlos restart` touches — // apps///restart-gen.json. Change detection is by ETAG, never by // content: the fields exist so a human debugging "why did this bounce?" can // see when the last request went out, and so each touch's bytes are distinct // (DirStore's etag is content-derived, so a byte-identical rewrite would not // move it, and the movement IS the signal). // // It carries no authority whatsoever, which is what makes an unsigned object // safe here: the worst a forged touch can do is restart processes that were // already running the version their own signed pointer names. Nothing about // what runs is decided by this file. // // ABSENT IS LEGAL FOREVER. An app that has never been restarted this way has // no object and never will until someone asks; boxes read the absence, record // it, and go on. Old boxes that predate this feature ignore the key entirely // — the wire shape is purely additive, and nothing that parses today's app // prefix parses this. // // It is a SIBLING object rather than a field on anything existing for // appconfig.Generation's reason: a field would make every watcher re-read and // re-parse a whole manifest to learn one bit, and would race the very write // it signals. type RestartGeneration struct { Schema int `json:"schema"` // 1 TouchedAt string `json:"touched_at"` Nonce string `json:"nonce"` } // RestartGenKey is the bucket key of app's restart-generation object, derived // from AppPrefix like every other per-app key: everything an app owns sits // under one account-scoped prefix, permanently. func RestartGenKey(acct, app string) string { return AppPrefix(acct, app) + "restart-gen.json" } // TouchRestartGen bumps app's restart generation — the request every box // serving that app converges on within seconds. // // A CAS conflict is SUCCESS, not an error: it means another writer touched the // object between our read and our write, so the signal this function exists to // fire has already fired. Retrying would only produce a second, redundant // request — and a second restart of processes that are cycling right now is // the one outcome a restart verb must not manufacture out of a race. // // os.ErrNotExist on the read means first touch, so the write is create-only // (etag ""), which is what makes the conflict carve-out safe: two first // touches race, one wins, both callers are correct — both asked for a restart, // and exactly one restart is what the box will do. func claimRawTouchRestartGen(ctx context.Context, s Store, acct, app string) error { if !ValidAccount(acct) { return fmt.Errorf("restart: invalid account %q", acct) } if !ValidApp(app) { return fmt.Errorf("restart: invalid app %q", app) } key := RestartGenKey(acct, app) _, etag, err := s.Get(ctx, key) if err != nil && !errors.Is(err, os.ErrNotExist) { return err } if errors.Is(err, os.ErrNotExist) { etag = "" } nonce := make([]byte, 16) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return err } raw, err := json.Marshal(RestartGeneration{ Schema: 1, TouchedAt: time.Now().UTC().Format(time.RFC3339), Nonce: hex.EncodeToString(nonce), }) if err != nil { return err } if _, err := s.Put(ctx, key, raw, etag); err != nil { if errors.Is(err, ErrCASConflict) { return nil } return err } return nil } func TouchRestartGen(ctx context.Context, s Store, acct, app string) (claimResult0 error) { claimResult0 = WithAppMutation(ctx, s, acct, app, "release.TouchRestartGen", func(claimContext context.Context) error { claimResult0 = claimRawTouchRestartGen(claimContext, s, acct, app) return claimResult0 }) return }