retries: the host-retry log (apps/<acct>/<app>/host-retry.json); the restart-scope log takes no retry or signature
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+373
−27
internal/provision/plan9_b8_census_test.go+4 −1internal/release/hostretry_test.go+7 −6internal/release/hostretrylog.go+90 −0internal/release/hostretrylog_test.go+225 −0internal/release/restartscope.go+43 −14internal/release/restartscope_test.go+4 −6
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index a290e32e3..48a98a00c 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -931,7 +931,10 @@ var plan9B8CensusInventory = []censusEntry{ |
| // Writes a signed channels/<channel>.json fast-track pointer under the validated account and app. |
| {"internal/release/release.go", "claimRawFastTrack", "nonrecord", "sel:Put", "de483b9f637ca8086618bb059900d800aa6422765a4245dca28b2d9730883de9"}, |
| {"internal/release/restartgen.go", "claimRawTouchRestartGen", "nonrecord", "sel:Put", "4a2797646f540e3d02798e7c5f427afa582986ea9a0818492f90a58f684a5ce0"}, |
| - {"internal/release/restartscope.go", "claimRawAppendRestartScope", "nonrecord", "sel:Put", "c1d51642d68a73078cf40e9f68f9c3cd9959836855b85b8347c3f7efc6b13201"}, |
| + // The one CAS append of both request logs: apps/<acct>/<app>/restart-scope.json |
| + // (claimRawAppendRestartScope) and apps/<acct>/<app>/host-retry.json |
| + // (claimRawAppendHostRetry), each validating the account, app and entry first. |
| + {"internal/release/restartscope.go", "claimRawAppendRequestLog", "nonrecord", "sel:Put", "9a866d77b795361a342aa7fcdb84ef2a3647f24e269303f6669fe3f1c41e2670"}, |
| {"internal/release/retention.go", "claimRawSetRetention", "nonrecord", "sel:Put", "05033e1266623712efe57b39ce282ecddf76d2b774e343df4dc5f81375d11662"}, |
| {"internal/release/serial.go", "BumpSerial", "nonrecord", "sel:Put", "8b304349553e0daf5ac2affcf4bc5e2abc9ce1a6b3423f624e41f6bd80c8ccf0"}, |
| {"internal/release/sidecarwake.go", "claimRawTouchSidecarWake", "nonrecord", "sel:Put", "2cf98594adc5096b6a1c69e561e32aabb16341134e226d73467110a6ae3fc686"}, |
diff --git a/internal/release/hostretry_test.go b/internal/release/hostretry_test.go| index 55fa6e846..6707f2b50 100644 |
| --- a/internal/release/hostretry_test.go |
| +++ b/internal/release/hostretry_test.go |
| @@ -1,6 +1,7 @@ |
| package release |
| import ( |
| + "context" |
| "crypto/ed25519" |
| "encoding/base64" |
| "encoding/json" |
| @@ -150,15 +151,15 @@ func tamperHostRetry(t *testing.T, wire string, edit func(map[string]any)) strin |
| return string(out) |
| } |
| -func TestRestartScopeSignedOnlyOnARetry(t *testing.T) { |
| - if err := ValidRestartScope(RestartScope{Kind: RestartScopeRetry, Value: "a.example.com", Signed: `{"payload":"x","sig":"y"}`}); err != nil { |
| - t.Fatalf("a signed retry = %v", err) |
| - } |
| +// The restart-scope log takes no signature at all now (grants ride |
| +// host-retry.json, TestValidRestartScopeRefusesARetryAndASignature), and |
| +// the field stays additive for the legacy entries a box still reads. |
| +func TestRestartScopeSignedIsAdditive(t *testing.T) { |
| if err := ValidRestartScope(RestartScope{Kind: RestartScopeHost, Value: "a.example.com", Signed: "x"}); err == nil { |
| t.Fatal("a host restart carrying a signature was valid") |
| } |
| - if err := ValidRestartScope(RestartScope{Kind: RestartScopeRetry, Value: "a.example.com", Signed: strings.Repeat("x", MaxRootEnvelopeBytes+1)}); err == nil { |
| - t.Fatal("an oversized signature was valid") |
| + if _, err := AppendHostRetry(context.Background(), NewDirStore(t.TempDir()), "bab", "blog", "a.example.com", strings.Repeat("x", MaxRootEnvelopeBytes+1)); err == nil { |
| + t.Fatal("an oversized signature was appended") |
| } |
| // The field is additive: an older entry has none, and an older reader's |
| // lax parse steps over it. |
diff --git a/internal/release/hostretrylog.go b/internal/release/hostretrylog.go| new file mode 100644 |
| index 000000000..e9d906814 |
| --- /dev/null |
| +++ b/internal/release/hostretrylog.go |
| @@ -0,0 +1,90 @@ |
| +// The host-retry log: where `carlos instances retry <host>` grants ride |
| +// (zero-downtime spec §8.4, §11; readiness round 2, Task 3). |
| +// |
| +// Bucket layout addition: |
| +// |
| +// apps/<acct>/<app>/host-retry.json CAS-guarded, schema-1 request log |
| +// |
| +// It has the restart-scope log's JSON shape and append (a log of requests, |
| +// oldest first, each with a random id, at most restartScopeKeep kept), and |
| +// every entry is a RestartScopeRetry whose Signed is the console's |
| +// root-signed HostRetryGrant. Grants used to ride the restart-scope log, |
| +// and that is why they no longer do: a console built before |
| +// RestartScope.Signed re-marshals that whole log on every restart it |
| +// appends, which strips the signature from every pending retry in it, and |
| +// the member's retry silently clears nothing. No released binary reads or |
| +// writes this key, so no older console can rewrite it, and no older box |
| +// ever sees a grant. |
| +// |
| +// The box reads it on the serial move, as it reads the restart-scope log |
| +// (internal/host hostRetryPass), and applies every grant still inside its |
| +// window that it has not handled in this process, an agent restart |
| +// included: a grant spent or made stale since is a no-op under the failure |
| +// book's generation rule (rollout.FailureBook.RetryHostAt). |
| +package release |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "os" |
| +) |
| + |
| +// HostRetryKey is the bucket key of app's host-retry log, derived from |
| +// AppPrefix like every other per-app key. |
| +func HostRetryKey(acct, app string) string { return AppPrefix(acct, app) + "host-retry.json" } |
| + |
| +// LoadHostRetries reads app's host-retry log. found=false means the object |
| +// is absent, the ordinary state of almost every app. A newer schema is an |
| +// error, as in LoadRestartScopes: a box that cannot be sure what an entry |
| +// means must do nothing with it. |
| +func LoadHostRetries(ctx context.Context, s Store, acct, app string) (*ScopedRestartGeneration, bool, error) { |
| + key := HostRetryKey(acct, app) |
| + raw, _, err := s.Get(ctx, key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return &ScopedRestartGeneration{Schema: 1}, false, nil |
| + } |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + g, err := parseRestartScopes(key, raw) |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + return g, true, nil |
| +} |
| + |
| +// claimRawAppendHostRetry records one signed retry of host in app's |
| +// host-retry log and returns it. signed is the console's root-signed grant |
| +// (SignHostRetry); an entry without one is refused here, since every box |
| +// would drop it. The append, its CAS retries and its bound are the |
| +// restart-scope log's (claimRawAppendRequestLog). |
| +func claimRawAppendHostRetry(ctx context.Context, s Store, acct, app, host, signed string) (RestartRequest, error) { |
| + if !ValidAccount(acct) { |
| + return RestartRequest{}, fmt.Errorf("retry: invalid account %q", acct) |
| + } |
| + if !ValidApp(app) { |
| + return RestartRequest{}, fmt.Errorf("retry: invalid app %q", app) |
| + } |
| + if err := validScopeValue(RestartScopeRetry, host); err != nil { |
| + return RestartRequest{}, err |
| + } |
| + switch { |
| + case signed == "": |
| + return RestartRequest{}, errors.New("retry: no signed grant; every box would drop it") |
| + case len(signed) > MaxRootEnvelopeBytes: |
| + return RestartRequest{}, errors.New("retry: the signed grant is too large") |
| + } |
| + return claimRawAppendRequestLog(ctx, s, HostRetryKey(acct, app), |
| + RestartScope{Kind: RestartScopeRetry, Value: host, Signed: signed}, "retry") |
| +} |
| + |
| +// AppendHostRetry is claimRawAppendHostRetry under the app's mutation |
| +// claim. |
| +func AppendHostRetry(ctx context.Context, s Store, acct, app, host, signed string) (claimResult0 RestartRequest, claimResult1 error) { |
| + claimResult1 = WithAppMutation(ctx, s, acct, app, "release.AppendHostRetry", func(claimContext context.Context) error { |
| + claimResult0, claimResult1 = claimRawAppendHostRetry(claimContext, s, acct, app, host, signed) |
| + return claimResult1 |
| + }) |
| + return |
| +} |
diff --git a/internal/release/hostretrylog_test.go b/internal/release/hostretrylog_test.go| new file mode 100644 |
| index 000000000..b1df79ebb |
| --- /dev/null |
| +++ b/internal/release/hostretrylog_test.go |
| @@ -0,0 +1,225 @@ |
| +package release |
| + |
| +// The host-retry log (readiness round 2, Task 3; Astra plan 9): signed |
| +// `carlos instances retry` grants ride their own object, which no released |
| +// console reads or rewrites, so an older console that re-marshals the |
| +// restart-scope log cannot strip a grant's signature, and an older box |
| +// never sees one. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestHostRetryKeyIsItsOwnObjectUnderTheAppPrefix(t *testing.T) { |
| + if got, want := HostRetryKey("bab", "titogo"), "apps/bab/titogo/host-retry.json"; got != want { |
| + t.Fatalf("HostRetryKey = %q, want %q", got, want) |
| + } |
| + if HostRetryKey("bab", "titogo") == RestartScopeKey("bab", "titogo") || HostRetryKey("bab", "titogo") == RestartGenKey("bab", "titogo") { |
| + t.Fatal("the host-retry log shares a key with a restart signal") |
| + } |
| +} |
| + |
| +func TestAppendHostRetryRoundTrip(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + pub, priv := hostRetryKey(t) |
| + if g, found, err := LoadHostRetries(ctx, s, "bab", "titogo"); err != nil || found || g == nil || g.Schema != 1 || len(g.Requests) != 0 { |
| + t.Fatalf("absent log = %+v, %v, %v; want an empty schema-1 log, not found", g, found, err) |
| + } |
| + wire, err := SignHostRetry(RootSigner(priv), "bab", "titogo", "a.example.com", "v2", "book-1", 9, time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + first, err := AppendHostRetry(ctx, s, "bab", "titogo", "a.example.com", wire) |
| + if err != nil { |
| + t.Fatalf("append: %v", err) |
| + } |
| + second, err := AppendHostRetry(ctx, s, "bab", "titogo", "b.example.com", wire) |
| + if err != nil { |
| + t.Fatalf("second append: %v", err) |
| + } |
| + if first.ID == "" || first.ID == second.ID { |
| + t.Fatalf("ids %q and %q: want two distinct requests", first.ID, second.ID) |
| + } |
| + if _, err := time.Parse(time.RFC3339, first.RequestedAt); err != nil { |
| + t.Fatalf("RequestedAt %q: %v", first.RequestedAt, err) |
| + } |
| + g, found, err := LoadHostRetries(ctx, s, "bab", "titogo") |
| + if err != nil || !found { |
| + t.Fatalf("load = %v, found %v", err, found) |
| + } |
| + if g.Schema != 1 || len(g.Requests) != 2 || g.Requests[0].ID != first.ID || g.Requests[1].ID != second.ID { |
| + t.Fatalf("log = %+v, want both requests, oldest first", g) |
| + } |
| + r := g.Requests[0] |
| + if r.Scope.Kind != RestartScopeRetry || r.Scope.Value != "a.example.com" || r.Scope.Signed != wire { |
| + t.Fatalf("entry = %+v", r) |
| + } |
| + if _, err := VerifyHostRetry(pub, r.Scope.Signed, "bab", "titogo", "a.example.com", time.Now()); err != nil { |
| + t.Fatalf("the stored grant does not verify: %v", err) |
| + } |
| + // The same JSON shape as the restart-scope log. |
| + raw, _, err := s.Get(ctx, HostRetryKey("bab", "titogo")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var shape struct { |
| + Schema int `json:"schema"` |
| + Requests []struct { |
| + ID string `json:"id"` |
| + RequestedAt string `json:"requested_at"` |
| + Scope struct { |
| + Kind, Value, Signed string |
| + } `json:"scope"` |
| + } `json:"requests"` |
| + } |
| + if err := json.Unmarshal(raw, &shape); err != nil || shape.Schema != 1 || len(shape.Requests) != 2 || shape.Requests[0].Scope.Signed != wire { |
| + t.Fatalf("wire = %s (%v)", raw, err) |
| + } |
| + // Nothing landed in the restart-scope log. |
| + if _, found, _ := LoadRestartScopes(ctx, s, "bab", "titogo"); found { |
| + t.Fatal("a host retry wrote the restart-scope log") |
| + } |
| +} |
| + |
| +func TestAppendHostRetryRefusesWhatNoBoxCouldTake(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + for _, tc := range []struct{ name, acct, app, host, signed string }{ |
| + {"unsigned", "bab", "titogo", "a.example.com", ""}, |
| + {"no host", "bab", "titogo", "", "x"}, |
| + {"bad host", "bab", "titogo", "a\nb", "x"}, |
| + {"oversized grant", "bab", "titogo", "a.example.com", strings.Repeat("x", MaxRootEnvelopeBytes+1)}, |
| + {"bad account", "../etc", "titogo", "a.example.com", "x"}, |
| + {"bad app", "bab", "../x", "a.example.com", "x"}, |
| + } { |
| + if _, err := AppendHostRetry(ctx, s, tc.acct, tc.app, tc.host, tc.signed); err == nil { |
| + t.Errorf("%s: appended", tc.name) |
| + } |
| + } |
| + if _, found, _ := LoadHostRetries(ctx, s, "bab", "titogo"); found { |
| + t.Fatal("a refused append wrote the log") |
| + } |
| +} |
| + |
| +func TestHostRetryLogIsBoundedAndRetriesACASConflict(t *testing.T) { |
| + cs := &conflictOnceStore{Store: NewDirStore(t.TempDir())} |
| + ctx := context.Background() |
| + if _, err := AppendHostRetry(ctx, cs, "bab", "titogo", "a.example.com", "x"); err != nil || cs.conflicts != 1 { |
| + t.Fatalf("append through one conflict = %v (conflicts %d)", err, cs.conflicts) |
| + } |
| + var last RestartRequest |
| + for i := 0; i < restartScopeKeep+5; i++ { |
| + r, err := AppendHostRetry(ctx, cs, "bab", "titogo", "a.example.com", "x") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + last = r |
| + } |
| + g, _, err := LoadHostRetries(ctx, cs, "bab", "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(g.Requests) != restartScopeKeep || g.Newest() != last.ID { |
| + t.Fatalf("log holds %d (newest %q), want %d ending at %q", len(g.Requests), g.Newest(), restartScopeKeep, last.ID) |
| + } |
| +} |
| + |
| +func TestHostRetryLogRefusesANewerSchema(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if _, err := s.Put(ctx, HostRetryKey("bab", "titogo"), []byte(`{"schema":2,"requests":[]}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := LoadHostRetries(ctx, s, "bab", "titogo"); err == nil { |
| + t.Fatal("a schema-2 log parsed") |
| + } |
| + if _, err := AppendHostRetry(ctx, s, "bab", "titogo", "a.example.com", "x"); err == nil { |
| + t.Fatal("appended to a schema-2 log") |
| + } |
| +} |
| + |
| +// Recon D §2 failure (a): a console built before RestartScope.Signed |
| +// re-marshals the whole restart-scope log on every append, and so strips |
| +// every pending grant's signature in it. The grants are not in it any |
| +// more: such a rewrite leaves the host-retry log byte for byte as it was. |
| +func TestARestartScopeRewriteWithoutSignedLeavesHostRetriesAlone(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + _, priv := hostRetryKey(t) |
| + wire, err := SignHostRetry(RootSigner(priv), "bab", "titogo", "a.example.com", "v2", "book-1", 9, time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := AppendHostRetry(ctx, s, "bab", "titogo", "a.example.com", wire); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, beforeTag, err := s.Get(ctx, HostRetryKey("bab", "titogo")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := AppendRestartScope(ctx, s, "bab", "titogo", RestartScope{Kind: RestartScopeHost, Value: "b.example.com"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + // The older console's append: its struct has no Signed. |
| + type oldScope struct { |
| + Kind string `json:"kind"` |
| + Value string `json:"value"` |
| + } |
| + type oldRequest struct { |
| + ID string `json:"id"` |
| + RequestedAt string `json:"requested_at"` |
| + Scope oldScope `json:"scope"` |
| + } |
| + var old struct { |
| + Schema int `json:"schema"` |
| + Requests []oldRequest `json:"requests"` |
| + } |
| + raw, tag, err := s.Get(ctx, RestartScopeKey("bab", "titogo")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := json.Unmarshal(raw, &old); err != nil { |
| + t.Fatal(err) |
| + } |
| + old.Requests = append(old.Requests, oldRequest{ID: "beef", RequestedAt: time.Now().UTC().Format(time.RFC3339), Scope: oldScope{Kind: RestartScopeHost, Value: "c.example.com"}}) |
| + body, _ := json.Marshal(old) |
| + if _, err := s.Put(ctx, RestartScopeKey("bab", "titogo"), body, tag); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, afterTag, err := s.Get(ctx, HostRetryKey("bab", "titogo")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if string(after) != string(before) || afterTag != beforeTag { |
| + t.Fatalf("the host-retry log moved under a restart-scope rewrite:\n%s\n%s", before, after) |
| + } |
| + g, _, err := LoadHostRetries(ctx, s, "bab", "titogo") |
| + if err != nil || len(g.Requests) != 1 || g.Requests[0].Scope.Signed != wire { |
| + t.Fatalf("host retries = %+v, %v; want the signed grant intact", g, err) |
| + } |
| +} |
| + |
| +// No new write can put a retry, or any signature, in the restart-scope log: |
| +// grants ride host-retry.json only (AppendHostRetry). A box still reads a |
| +// signed retry an older console left there, with the same checks. |
| +func TestValidRestartScopeRefusesARetryAndASignature(t *testing.T) { |
| + for _, sc := range []RestartScope{ |
| + {Kind: RestartScopeRetry, Value: "a.example.com"}, |
| + {Kind: RestartScopeRetry, Value: "a.example.com", Signed: `{"payload":"x","sig":"y"}`}, |
| + {Kind: RestartScopeHost, Value: "a.example.com", Signed: "x"}, |
| + {Kind: RestartScopeEnvironment, Value: "relay", Signed: "x"}, |
| + } { |
| + if err := ValidRestartScope(sc); err == nil { |
| + t.Errorf("ValidRestartScope(%+v) accepted it", sc) |
| + } |
| + } |
| + s := NewDirStore(t.TempDir()) |
| + if _, err := AppendRestartScope(context.Background(), s, "bab", "titogo", RestartScope{Kind: RestartScopeRetry, Value: "a.example.com", Signed: "x"}); err == nil { |
| + t.Fatal("a retry was appended to the restart-scope log") |
| + } |
| +} |
diff --git a/internal/release/restartscope.go b/internal/release/restartscope.go| index a503abb36..34a6c214e 100644 |
| --- a/internal/release/restartscope.go |
| +++ b/internal/release/restartscope.go |
| @@ -80,6 +80,12 @@ const ( |
| // restart, a retry lifts a restriction, so the file header's narrowing |
| // argument does not cover it: it acts only on a root-signed grant in |
| // Signed (HostRetryGrant, spec §11), and a box drops one without. |
| + // |
| + // It is the kind of every entry in the host-retry log (AppendHostRetry, |
| + // hostretrylog.go), and no longer written into this one |
| + // (ValidRestartScope): a console built before Signed strips it from every |
| + // entry when it re-marshals this log. A box still verifies and applies a |
| + // signed retry an earlier console left here, with the same checks. |
| RestartScopeRetry = "retry" |
| ) |
| @@ -113,9 +119,11 @@ type RestartScope struct { |
| // Signed is a retry's root-signed grant (HostRetryGrant, spec §11): a |
| // box clears a hold only on a grant that verifies against this entry's |
| // account, app and host, so a box that can write this log cannot forge |
| - // a retry (Astra impl-6 finding 5). Only a retry may carry it. It is |
| - // additive: an older box's lax parse steps over it, and an older |
| - // console's unsigned retry is dropped by every box that checks. |
| + // a retry (Astra impl-6 finding 5). Only a retry carries it, and only in |
| + // the host-retry log is one written now (AppendHostRetry); a legacy |
| + // entry in the restart-scope log may still carry one. It is additive: |
| + // an older box's lax parse steps over it, and an unsigned retry is |
| + // dropped by every box that checks. |
| Signed string `json:"signed,omitempty"` |
| } |
| @@ -168,23 +176,35 @@ func (sc RestartScope) Selects(rt registry.Route) (selected, understood bool) { |
| // could act on. It is the last gate before the bucket, not the first: the |
| // door that took the value from a person has already checked its shape and |
| // answered a typo with what exists. |
| +// |
| +// A retry, and any signature, is refused: retries ride the host-retry log |
| +// (AppendHostRetry), where no console older than it can strip a grant's |
| +// signature by re-marshalling the log. |
| func ValidRestartScope(sc RestartScope) error { |
| switch sc.Kind { |
| - case RestartScopeHost, RestartScopeEnvironment, RestartScopeRetry: |
| + case RestartScopeHost, RestartScopeEnvironment: |
| + case RestartScopeRetry: |
| + return fmt.Errorf("restart scope: a retry is written to the host-retry log, not this one") |
| default: |
| return fmt.Errorf("restart scope: unknown kind %q", sc.Kind) |
| } |
| - if sc.Value == "" { |
| - return fmt.Errorf("restart scope: %s has no value", sc.Kind) |
| - } |
| - if len(sc.Value) > 253 || strings.ContainsAny(sc.Value, "\x00\n\r") { |
| - return fmt.Errorf("restart scope: %s value is not usable", sc.Kind) |
| + if err := validScopeValue(sc.Kind, sc.Value); err != nil { |
| + return err |
| } |
| - if sc.Signed != "" && sc.Kind != RestartScopeRetry { |
| + if sc.Signed != "" { |
| return fmt.Errorf("restart scope: %s carries a signature, which only a retry may", sc.Kind) |
| } |
| - if len(sc.Signed) > MaxRootEnvelopeBytes { |
| - return fmt.Errorf("restart scope: %s signature is too large", sc.Kind) |
| + return nil |
| +} |
| + |
| +// validScopeValue is the shape check every entry's Value passes: present, |
| +// at most a hostname's length, and no NUL or line break. |
| +func validScopeValue(kind, value string) error { |
| + if value == "" { |
| + return fmt.Errorf("restart scope: %s has no value", kind) |
| + } |
| + if len(value) > 253 || strings.ContainsAny(value, "\x00\n\r") { |
| + return fmt.Errorf("restart scope: %s value is not usable", kind) |
| } |
| return nil |
| } |
| @@ -289,7 +309,16 @@ func claimRawAppendRestartScope(ctx context.Context, s Store, acct, app string, |
| if err := ValidRestartScope(scope); err != nil { |
| return RestartRequest{}, err |
| } |
| - key := RestartScopeKey(acct, app) |
| + return claimRawAppendRequestLog(ctx, s, RestartScopeKey(acct, app), scope, "restart") |
| +} |
| + |
| +// claimRawAppendRequestLog is the CAS append both request logs share (the |
| +// restart-scope log and the host-retry log): a fresh random id, the log |
| +// re-read and appended to on a conflict, at most restartScopeKeep entries |
| +// kept, and a log this binary cannot parse refused rather than |
| +// overwritten. verb names the request in the conflict error. The caller |
| +// has validated the account, the app and the scope. |
| +func claimRawAppendRequestLog(ctx context.Context, s Store, key string, scope RestartScope, verb string) (RestartRequest, error) { |
| id := make([]byte, 16) |
| if _, err := io.ReadFull(rand.Reader, id); err != nil { |
| return RestartRequest{}, err |
| @@ -333,7 +362,7 @@ func claimRawAppendRestartScope(ctx context.Context, s Store, acct, app string, |
| } |
| return req, nil |
| } |
| - return RestartRequest{}, fmt.Errorf("restart: %s is being written by someone else — nothing was requested; try again: %w", key, lastErr) |
| + return RestartRequest{}, fmt.Errorf("%s: %s is being written by someone else — nothing was requested; try again: %w", verb, key, lastErr) |
| } |
| func AppendRestartScope(ctx context.Context, s Store, acct, app string, scope RestartScope) (claimResult0 RestartRequest, claimResult1 error) { |
| claimResult1 = WithAppMutation(ctx, s, acct, app, "release.AppendRestartScope", func(claimContext context.Context) error { |
diff --git a/internal/release/restartscope_test.go b/internal/release/restartscope_test.go| index f122255d5..f2154f7c4 100644 |
| --- a/internal/release/restartscope_test.go |
| +++ b/internal/release/restartscope_test.go |
| @@ -300,13 +300,11 @@ func TestAppendRestartScopeRefusesABadName(t *testing.T) { |
| } |
| } |
| -// A retry is a narrowing a box understands only if it was built to: today's |
| -// Selects must not read it as a restart of anything. |
| -func TestRetryScopeIsWritableAndSelectsNothing(t *testing.T) { |
| +// A retry is not a restart: Selects must not read one (an older console's, |
| +// left in this log) as a restart of anything. No new write puts one here |
| +// (TestValidRestartScopeRefusesARetryAndASignature). |
| +func TestRetryScopeSelectsNothing(t *testing.T) { |
| sc := RestartScope{Kind: RestartScopeRetry, Value: "a.example.com"} |
| - if err := ValidRestartScope(sc); err != nil { |
| - t.Fatalf("ValidRestartScope(retry) = %v", err) |
| - } |
| if sel, understood := sc.Selects(registry.Route{Host: "a.example.com"}); sel || understood { |
| t.Fatalf("Selects = %v, %v; a retry must never select a route for a restart", sel, understood) |
| } |