carlos / platform Public

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>
Paul Campbell b0cf7860199d49b6eb13dbcc4f3740e5eddff685 parent 11d71e9
2 files changed, +242 −0
  • internal/release/restartgen.go +107 −0
  • internal/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)
+ }
+}