release: a restart request is one small object per app
apps/<acct>/<app>/restart-gen.json, appconfig's config-gen object one step sideways: schema-1, CAS-guarded, watched by etag, carrying no authority at all. A CAS conflict is success (the signal already fired, and a second restart manufactured out of a race is the one outcome a restart verb must not produce), and absent is legal forever for every app nobody has ever restarted. Purely additive: old boxes never parse the key. Issue #113.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+242
−0
internal/release/restartgen.go+107 −0internal/release/restartgen_test.go+135 −0
diff --git a/internal/release/restartgen.go b/internal/release/restartgen.go| new file mode 100644 |
| index 000000000..6ca18524f |
| --- /dev/null |
| +++ b/internal/release/restartgen.go |
| @@ -0,0 +1,107 @@ |
| +// 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/<acct>/<app>/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/<acct>/<app>/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 TouchRestartGen(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 |
| +} |
diff --git a/internal/release/restartgen_test.go b/internal/release/restartgen_test.go| new file mode 100644 |
| index 000000000..afdf59581 |
| --- /dev/null |
| +++ b/internal/release/restartgen_test.go |
| @@ -0,0 +1,135 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// restartEtag reads the app's restart-generation etag, "" for absent — the |
| +// exact observation a box's sweep makes, so a test asserting on it asserts on |
| +// the signal itself rather than on this file's internals. |
| +func restartEtag(t *testing.T, s Store, acct, app string) string { |
| + t.Helper() |
| + _, etag, err := s.Get(context.Background(), RestartGenKey(acct, app)) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + return "" |
| + } |
| + t.Fatalf("read restart generation: %v", err) |
| + } |
| + return etag |
| +} |
| + |
| +func TestTouchRestartGenCreatesAndMoves(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if err := TouchRestartGen(ctx, s, "bab", "keymail"); err != nil { |
| + t.Fatalf("first TouchRestartGen: %v", err) |
| + } |
| + raw, first, err := s.Get(ctx, RestartGenKey("bab", "keymail")) |
| + if err != nil { |
| + t.Fatalf("Get after first touch: %v", err) |
| + } |
| + var g RestartGeneration |
| + if err := json.Unmarshal(raw, &g); err != nil { |
| + t.Fatalf("decode restart generation: %v", err) |
| + } |
| + if g.Schema != 1 { |
| + t.Fatalf("Schema = %d, want 1", g.Schema) |
| + } |
| + if _, err := time.Parse(time.RFC3339, g.TouchedAt); err != nil { |
| + t.Fatalf("TouchedAt %q is not RFC3339: %v", g.TouchedAt, err) |
| + } |
| + if nonce, err := hex.DecodeString(g.Nonce); err != nil || len(nonce) == 0 { |
| + t.Fatalf("Nonce = %q, want nonempty hex (decode err %v)", g.Nonce, err) |
| + } |
| + // The etag is the whole point: a second request must be a DIFFERENT |
| + // object, or the sweep watching it sees nothing and nothing restarts. |
| + if err := TouchRestartGen(ctx, s, "bab", "keymail"); err != nil { |
| + t.Fatalf("second TouchRestartGen: %v", err) |
| + } |
| + if second := restartEtag(t, s, "bab", "keymail"); second == first { |
| + t.Fatalf("etag %q unchanged after a second request — the restart signal never fires", second) |
| + } |
| +} |
| + |
| +// racingRestartPutStore lands a different writer's value at key just before |
| +// forwarding the caller's Put, which then fails CAS against the etag the |
| +// caller read — two people (or a person and a retrying CI job) asking for a |
| +// restart at the same moment. |
| +type racingRestartPutStore struct { |
| + Store |
| + key string |
| + done bool |
| +} |
| + |
| +func (r *racingRestartPutStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if key == r.key && !r.done { |
| + r.done = true |
| + other := []byte(`{"schema":1,"touched_at":"2026-01-01T00:00:00Z","nonce":"00112233445566778899aabbccddeeff"}`) |
| + if _, err := r.Store.Put(ctx, key, other, ifMatch); err != nil { |
| + return "", err |
| + } |
| + } |
| + return r.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +func TestTouchRestartGenCASConflictIsSuccess(t *testing.T) { |
| + inner := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if err := TouchRestartGen(ctx, inner, "bab", "keymail"); err != nil { |
| + t.Fatalf("seed TouchRestartGen: %v", err) |
| + } |
| + racing := &racingRestartPutStore{Store: inner, key: RestartGenKey("bab", "keymail")} |
| + if err := TouchRestartGen(ctx, racing, "bab", "keymail"); err != nil { |
| + t.Fatalf("TouchRestartGen on a lost CAS race: %v, want nil (the request already fired)", err) |
| + } |
| + if !racing.done { |
| + t.Fatal("the racing writer never ran — this test proved nothing") |
| + } |
| +} |
| + |
| +// failingRestartPutStore fails every Put with a fixed non-CAS error. |
| +type failingRestartPutStore struct { |
| + Store |
| + err error |
| +} |
| + |
| +func (f *failingRestartPutStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + return "", f.err |
| +} |
| + |
| +// TestTouchRestartGenSurfacesRealPutErrors is the other half of the CAS |
| +// carve-out: ONLY ErrCASConflict is success. Any other store failure means |
| +// nobody asked for anything, and a caller told "requested" would stand there |
| +// waiting for a restart that is never coming. |
| +func TestTouchRestartGenSurfacesRealPutErrors(t *testing.T) { |
| + s := &failingRestartPutStore{Store: NewDirStore(t.TempDir()), err: errors.New("bucket is on fire")} |
| + if err := TouchRestartGen(context.Background(), s, "bab", "keymail"); err == nil { |
| + t.Fatal("TouchRestartGen swallowed a non-CAS Put error, want it surfaced") |
| + } |
| +} |
| + |
| +func TestTouchRestartGenRefusesInvalidNames(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if err := TouchRestartGen(ctx, s, "../etc", "keymail"); err == nil { |
| + t.Fatal("TouchRestartGen accepted an invalid account") |
| + } |
| + if err := TouchRestartGen(ctx, s, "bab", "../etc"); err == nil { |
| + t.Fatal("TouchRestartGen accepted an invalid app") |
| + } |
| +} |
| + |
| +// TestRestartGenKeyIsAnAppSibling pins the key's shape: under the app's own |
| +// account-scoped prefix, beside config-gen.json rather than inside anything. |
| +func TestRestartGenKeyIsAnAppSibling(t *testing.T) { |
| + if got, want := RestartGenKey("bab", "keymail"), "apps/bab/keymail/restart-gen.json"; got != want { |
| + t.Fatalf("RestartGenKey = %q, want %q", got, want) |
| + } |
| +} |