hostclaim: strict Read and the single Update (central gen, host-bound, never delete)
Co-Authored-By: Codex (gpt-6-astra) <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+178
−0
internal/hostclaim/store.go+114 −0internal/hostclaim/store_test.go+64 −0
diff --git a/internal/hostclaim/store.go b/internal/hostclaim/store.go| new file mode 100644 |
| index 000000000..ac05ddb33 |
| --- /dev/null |
| +++ b/internal/hostclaim/store.go |
| @@ -0,0 +1,114 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "math" |
| + "os" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +var ErrConflict = fmt.Errorf("hostclaim: claim changed since it was read: %w", release.ErrCASConflict) |
| + |
| +type Observed struct { |
| + Host string |
| + Claim *Claim |
| + ETag string |
| + Absent bool |
| +} |
| + |
| +func decodeStrict(body []byte) (*Claim, error) { |
| + dec := json.NewDecoder(bytes.NewReader(body)) |
| + dec.DisallowUnknownFields() |
| + var c Claim |
| + if err := dec.Decode(&c); err != nil { |
| + return nil, fmt.Errorf("%w: %v", ErrMalformed, err) |
| + } |
| + if _, err := dec.Token(); err != io.EOF { |
| + return nil, fmt.Errorf("%w: trailing data", ErrMalformed) |
| + } |
| + return &c, nil |
| +} |
| + |
| +func Read(ctx context.Context, s release.Store, host string) (Observed, error) { |
| + key, err := Key(host) |
| + if err != nil { |
| + return Observed{}, err |
| + } |
| + body, etag, err := s.Get(ctx, key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return Observed{Host: host, Absent: true}, nil |
| + } |
| + if err != nil { |
| + return Observed{}, err // C6: an error is never absence |
| + } |
| + c, err := decodeStrict(body) |
| + if err != nil { |
| + return Observed{}, err |
| + } |
| + if c.Host != host { |
| + return Observed{}, fmt.Errorf("%w: host mismatch", ErrMalformed) |
| + } |
| + if err := c.Validate(); err != nil { |
| + return Observed{}, err |
| + } |
| + return Observed{Host: host, Claim: c, ETag: etag}, nil |
| +} |
| + |
| +func Update(ctx context.Context, s release.Store, obs Observed, tr func(*Claim) error) (Observed, error) { |
| + key, err := Key(obs.Host) |
| + if err != nil { |
| + return Observed{}, err |
| + } |
| + var next *Claim |
| + var oldGen uint64 |
| + if obs.Absent { |
| + next = &Claim{V: schemaVersion, Host: obs.Host} |
| + } else { |
| + if obs.Claim == nil || obs.Claim.Host != obs.Host { |
| + return Observed{}, fmt.Errorf("%w: observation not bound to %s", ErrMalformed, obs.Host) |
| + } |
| + raw, err := json.Marshal(obs.Claim) |
| + if err != nil { |
| + return Observed{}, err |
| + } |
| + if next, err = decodeStrict(raw); err != nil { |
| + return Observed{}, err |
| + } |
| + oldGen = obs.Claim.Gen |
| + } |
| + if err := tr(next); err != nil { |
| + return Observed{}, err |
| + } |
| + if next.Host != obs.Host || next.Gen != oldGen { |
| + return Observed{}, fmt.Errorf("%w: a transition may not change host or gen", ErrMalformed) |
| + } |
| + if oldGen == math.MaxUint64 { |
| + return Observed{}, fmt.Errorf("%w: gen overflow", ErrMalformed) |
| + } |
| + next.Gen = oldGen + 1 |
| + if err := next.Validate(); err != nil { |
| + return Observed{}, err |
| + } |
| + body, err := json.Marshal(next) |
| + if err != nil { |
| + return Observed{}, err |
| + } |
| + ifMatch := obs.ETag |
| + if obs.Absent { |
| + ifMatch = "" // the host's very first claim (C5a) |
| + } |
| + etag, err := s.Put(ctx, key, body, ifMatch) |
| + if errors.Is(err, release.ErrCASConflict) { |
| + return Observed{}, ErrConflict |
| + } |
| + if err != nil { |
| + return Observed{}, err |
| + } |
| + return Observed{Host: obs.Host, Claim: next, ETag: etag}, nil |
| +} |
diff --git a/internal/hostclaim/store_test.go b/internal/hostclaim/store_test.go| new file mode 100644 |
| index 000000000..1485cf956 |
| --- /dev/null |
| +++ b/internal/hostclaim/store_test.go |
| @@ -0,0 +1,64 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestReadRefusesMalformed(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + obs, err := Read(ctx, s, testHost) |
| + if err != nil || !obs.Absent || obs.Host != testHost { |
| + t.Fatalf("absent read = %+v, %v", obs, err) |
| + } |
| + key, _ := Key(testHost) |
| + good := `{"v":2,"host":"` + testHost + `","gen":1,"op":{},"touched":{"complete":false}}` |
| + for name, body := range map[string]string{ |
| + "unknown field": `{"v":2,"host":"` + testHost + `","gen":1,"op":{},"touched":{"complete":false},"surprise":1}`, |
| + "trailing data": good + ` {"v":2}`, |
| + "wrong host": `{"v":2,"host":"other.bab.oncarlos.com","gen":1,"op":{},"touched":{"complete":false}}`, |
| + "free with chores": `{"v":2,"host":"` + testHost + `","gen":1,"op":{},"touched":{"complete":false},` + |
| + `"obligations":[{"id":"o","op":"x","kind":"park-local","box":"b","retirement":"r","owner":"box:b"}]}`, |
| + "unknown chore kind": `{"v":2,"host":"` + testHost + `","gen":1,"op":{"kind":"purging","id":"p"},"touched":{"complete":false},` + |
| + `"obligations":[{"id":"o","op":"x","kind":"teleport","region":"ie","retirement":"r","owner":"region:ie"}]}`, |
| + } { |
| + s2 := release.NewDirStore(t.TempDir()) |
| + if _, err := s2.Put(ctx, key, []byte(body), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := Read(ctx, s2, testHost); !errors.Is(err, ErrMalformed) { |
| + t.Errorf("%s: read = %v, want ErrMalformed", name, err) |
| + } |
| + } |
| +} |
| + |
| +func TestUpdateDerivesGenAndBindsHost(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + obs, _ := Read(ctx, s, testHost) |
| + noop := func(c *Claim) error { return nil } |
| + o1, err := Update(ctx, s, obs, noop) |
| + if err != nil || o1.Claim.Gen != 1 || o1.Claim.Host != testHost { |
| + t.Fatalf("first write = %+v, %v", o1, err) |
| + } |
| + if _, err := Update(ctx, s, obs, noop); !errors.Is(err, ErrConflict) { |
| + t.Fatalf("stale absent write = %v, want ErrConflict (C5a)", err) |
| + } |
| + if _, err := Update(ctx, s, o1, func(c *Claim) error { c.Gen = 99; return nil }); !errors.Is(err, ErrMalformed) { |
| + t.Fatalf("transition changing gen = %v", err) |
| + } |
| + if _, err := Update(ctx, s, o1, func(c *Claim) error { c.Host = "other.bab.oncarlos.com"; return nil }); !errors.Is(err, ErrMalformed) { |
| + t.Fatalf("transition changing host = %v", err) |
| + } |
| + o2, err := Update(ctx, s, o1, noop) |
| + if err != nil || o2.Claim.Gen != 2 { |
| + t.Fatalf("second write = %+v, %v", o2, err) |
| + } |
| + if _, err := Update(ctx, s, o1, noop); !errors.Is(err, ErrConflict) { |
| + t.Fatalf("stale etag write = %v, want ErrConflict", err) |
| + } |
| +} |