carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 6e9e84bd881bbe37eee3cade673331ae02fb6842 parent 96abda9
6 files changed, +373 −27
  • internal/provision/plan9_b8_census_test.go +4 −1
  • internal/release/hostretry_test.go +7 −6
  • internal/release/hostretrylog.go +90 −0
  • internal/release/hostretrylog_test.go +225 −0
  • internal/release/restartscope.go +43 −14
  • internal/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)
}