provision: record lifetime fields, the owned host incarnation slot, the tombstone form and the lifetime verdict
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 files changed,
+489
−9
internal/provision/lifetime.go+178 −0internal/provision/lifetime_test.go+265 −0internal/provision/plan9_b8_census_test.go+4 −1internal/provision/provision.go+21 −8internal/provision/provision_test.go+21 −0
diff --git a/internal/provision/lifetime.go b/internal/provision/lifetime.go| new file mode 100644 |
| index 000000000..9e1d4848f |
| --- /dev/null |
| +++ b/internal/provision/lifetime.go |
| @@ -0,0 +1,178 @@ |
| +package provision |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "strconv" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The host incarnation slot is the durable lifetime of a hostname on a |
| +// claims-off deployment: control/host-incarnations/<host>.json names the |
| +// record key (account and app) that owns the hostname and the incarnation |
| +// number of that lifetime. A record is trusted only while LifetimeOf says |
| +// the slot still vouches for it. The slot is written only by conditional |
| +// writes, so two creators of one hostname cannot both win. |
| + |
| +const ( |
| + // SlotLive is a lifetime in progress; SlotRetired is one that ended. |
| + SlotLive = "live" |
| + SlotRetired = "retired" |
| +) |
| + |
| +// HostIncarnation is the body of a host incarnation slot. Account and App |
| +// are the record key the lifetime belongs to. RecordETag is set only by a |
| +// migration slot, binding it to the one record body it was written for. |
| +type HostIncarnation struct { |
| + Incarnation uint64 `json:"incarnation"` |
| + State string `json:"state"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Intent string `json:"intent,omitempty"` |
| + RecordETag string `json:"record_etag,omitempty"` |
| +} |
| + |
| +// Valid reports whether the slot is a well-formed body. |
| +func (h HostIncarnation) Valid() bool { |
| + return h.Incarnation > 0 && (h.State == SlotLive || h.State == SlotRetired) && |
| + h.Account != "" && h.App != "" |
| +} |
| + |
| +// HostIncarnationKey is the slot's bucket key, or "" for a host that fails |
| +// ValidHostShape. |
| +func HostIncarnationKey(host string) string { |
| + if !ValidHostShape(host) { |
| + return "" |
| + } |
| + return "control/host-incarnations/" + host + ".json" |
| +} |
| + |
| +// ErrSlotMalformed marks a slot body that does not decode or is not Valid. |
| +var ErrSlotMalformed = errors.New("provision: host incarnation slot is malformed") |
| + |
| +// ReadHostIncarnation returns the slot and the ETag it was read under, or |
| +// nil, "", nil when no slot exists. |
| +func ReadHostIncarnation(ctx context.Context, s release.Store, host string) (*HostIncarnation, string, error) { |
| + key := HostIncarnationKey(host) |
| + if key == "" { |
| + return nil, "", fmt.Errorf("provision: host incarnation slot: invalid host %q", host) |
| + } |
| + raw, etag, err := s.Get(ctx, key) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, "", nil |
| + } |
| + return nil, "", err |
| + } |
| + var slot HostIncarnation |
| + if err := json.Unmarshal(raw, &slot); err != nil { |
| + return nil, "", fmt.Errorf("%w: %s: %v", ErrSlotMalformed, key, err) |
| + } |
| + if !slot.Valid() { |
| + return nil, "", fmt.Errorf("%w: %s: failed validation", ErrSlotMalformed, key) |
| + } |
| + return &slot, etag, nil |
| +} |
| + |
| +// WriteHostIncarnation writes the slot conditionally and returns the new |
| +// ETag. ifMatch == "" is create-only (a lost race is release.ErrCASConflict); |
| +// otherwise the write is guarded by If-Match. |
| +func WriteHostIncarnation(ctx context.Context, s release.Store, host string, slot HostIncarnation, ifMatch string) (string, error) { |
| + key := HostIncarnationKey(host) |
| + if key == "" { |
| + return "", fmt.Errorf("provision: host incarnation slot: invalid host %q", host) |
| + } |
| + if !slot.Valid() { |
| + return "", fmt.Errorf("%w: refusing to write an invalid slot for %s", ErrSlotMalformed, host) |
| + } |
| + body, err := json.Marshal(slot) |
| + if err != nil { |
| + return "", err |
| + } |
| + return s.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +// LifetimeVerdict is LifetimeOf's answer. LifetimeCorrupt and |
| +// LifetimeUnreadable are for callers that could not read the record or the |
| +// slot; LifetimeOf itself answers only valid or stale. |
| +type LifetimeVerdict int |
| + |
| +const ( |
| + LifetimeValid LifetimeVerdict = iota |
| + LifetimeStale |
| + LifetimeCorrupt |
| + LifetimeUnreadable |
| +) |
| + |
| +// LifetimeOf decides whether the slot vouches for the record read under |
| +// recordETag. Pure: it reads nothing. |
| +func LifetimeOf(rec *Record, recordETag string, slot *HostIncarnation) LifetimeVerdict { |
| + if rec == nil || rec.Retired { |
| + return LifetimeStale |
| + } |
| + owned := slot != nil && slot.Account == rec.Account && slot.App == rec.App |
| + switch { |
| + case rec.Incarnation == 0 && rec.IncarnationMigrating == "": |
| + if slot == nil { |
| + return LifetimeValid |
| + } |
| + case rec.IncarnationMigrating != "": |
| + if slot == nil { |
| + return LifetimeValid |
| + } |
| + if slot.Incarnation == 1 && slot.State == SlotLive && owned && |
| + slot.Intent == "migration "+rec.IncarnationMigrating && slot.RecordETag == recordETag { |
| + return LifetimeValid |
| + } |
| + default: |
| + if owned && slot.State == SlotLive && slot.Incarnation == rec.Incarnation { |
| + return LifetimeValid |
| + } |
| + } |
| + return LifetimeStale |
| +} |
| + |
| +// TombstoneBody turns a record body into its tombstone: schema, host, |
| +// account, app, incarnation and record_seq (the last two only when present) |
| +// are kept, retired is set, everything else is dropped. Write it with |
| +// WriteRecord against before, so record_seq advances by one. |
| +func TombstoneBody(before []byte) ([]byte, error) { |
| + fields, _, err := RecordSequence(before) |
| + if err != nil { |
| + return nil, err |
| + } |
| + out := map[string]json.RawMessage{} |
| + for _, name := range []string{"schema", "host", "account", "app", "incarnation", "record_seq"} { |
| + if v, ok := fields[name]; ok { |
| + out[name] = v |
| + } |
| + } |
| + out["retired"] = json.RawMessage(strconv.FormatBool(true)) |
| + return json.Marshal(out) |
| +} |
| + |
| +// ErrRecordCorrupt marks a record body that does not decode or disagrees |
| +// with its own path. |
| +var ErrRecordCorrupt = errors.New("provision: record is corrupt") |
| + |
| +// GetRecordRaw reads one record including a tombstone. A corrupt body |
| +// returns ErrRecordCorrupt with raw and etag still set, so a caller can act |
| +// on exactly what it observed. A store error returns no bytes. |
| +func GetRecordRaw(ctx context.Context, s release.Store, acct, app, host string) (rec *Record, raw []byte, etag string, err error) { |
| + raw, etag, err = s.Get(ctx, RecordKey(acct, app, host)) |
| + if err != nil { |
| + return nil, nil, "", err |
| + } |
| + decoded, err := DecodeRecord(raw) |
| + if err != nil { |
| + return nil, raw, etag, fmt.Errorf("provision: record %s/%s/%s: %w: %w", acct, app, host, ErrRecordCorrupt, err) |
| + } |
| + if decoded.Account != acct || decoded.App != app || decoded.Host != host { |
| + return nil, raw, etag, fmt.Errorf("provision: record %s/%s/%s: %w: record disagrees with its own path", acct, app, host, ErrRecordCorrupt) |
| + } |
| + return &decoded, raw, etag, nil |
| +} |
diff --git a/internal/provision/lifetime_test.go b/internal/provision/lifetime_test.go| new file mode 100644 |
| index 000000000..81db68557 |
| --- /dev/null |
| +++ b/internal/provision/lifetime_test.go |
| @@ -0,0 +1,265 @@ |
| +package provision |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "sort" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestLifetimeOf(t *testing.T) { |
| + live := func(inc uint64) *HostIncarnation { |
| + return &HostIncarnation{Incarnation: inc, State: SlotLive, Account: "bab", App: "shop"} |
| + } |
| + migration := func(marker, etag string) *HostIncarnation { |
| + return &HostIncarnation{Incarnation: 1, State: SlotLive, Account: "bab", App: "shop", |
| + Intent: "migration " + marker, RecordETag: etag} |
| + } |
| + base := Record{Schema: 1, Host: "shop.example.com", App: "shop", Account: "bab"} |
| + with := func(f func(*Record)) *Record { r := base; f(&r); return &r } |
| + |
| + retiredSlot := live(3) |
| + retiredSlot.State = SlotRetired |
| + otherApp := live(3) |
| + otherApp.App = "other" |
| + |
| + cases := []struct { |
| + name string |
| + rec *Record |
| + etag string |
| + slot *HostIncarnation |
| + want LifetimeVerdict |
| + }{ |
| + {"retired record, no slot", with(func(r *Record) { r.Retired = true; r.Incarnation = 3 }), "e", nil, LifetimeStale}, |
| + {"retired record, live slot", with(func(r *Record) { r.Retired = true; r.Incarnation = 3 }), "e", live(3), LifetimeStale}, |
| + {"fieldless, no slot", &base, "e", nil, LifetimeValid}, |
| + {"fieldless, slot present", &base, "e", live(1), LifetimeStale}, |
| + {"marked, no slot", with(func(r *Record) { r.IncarnationMigrating = "m1" }), "e", nil, LifetimeValid}, |
| + {"marked, matching migration slot", with(func(r *Record) { r.IncarnationMigrating = "m1" }), "e", migration("m1", "e"), LifetimeValid}, |
| + {"marked, different etag", with(func(r *Record) { r.IncarnationMigrating = "m1" }), "e2", migration("m1", "e"), LifetimeStale}, |
| + {"marked, different marker", with(func(r *Record) { r.IncarnationMigrating = "m2" }), "e", migration("m1", "e"), LifetimeStale}, |
| + {"marked, plain live slot", with(func(r *Record) { r.IncarnationMigrating = "m1" }), "e", live(1), LifetimeStale}, |
| + {"marked, migration slot of another app", with(func(r *Record) { r.IncarnationMigrating = "m1" }), "e", |
| + &HostIncarnation{Incarnation: 1, State: SlotLive, Account: "bab", App: "other", Intent: "migration m1", RecordETag: "e"}, LifetimeStale}, |
| + {"incarnation, matching live slot", with(func(r *Record) { r.Incarnation = 3 }), "e", live(3), LifetimeValid}, |
| + {"incarnation, no slot", with(func(r *Record) { r.Incarnation = 3 }), "e", nil, LifetimeStale}, |
| + {"incarnation, other number", with(func(r *Record) { r.Incarnation = 3 }), "e", live(4), LifetimeStale}, |
| + {"incarnation, retired slot", with(func(r *Record) { r.Incarnation = 3 }), "e", retiredSlot, LifetimeStale}, |
| + {"incarnation, slot owned by another app", with(func(r *Record) { r.Incarnation = 3 }), "e", otherApp, LifetimeStale}, |
| + } |
| + for _, c := range cases { |
| + if got := LifetimeOf(c.rec, c.etag, c.slot); got != c.want { |
| + t.Errorf("%s: LifetimeOf = %d, want %d", c.name, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +func TestHostIncarnationKey(t *testing.T) { |
| + if got := HostIncarnationKey("shop.example.com"); got != "control/host-incarnations/shop.example.com.json" { |
| + t.Errorf("key = %q", got) |
| + } |
| + for _, bad := range []string{"", "../x", "a/b", "UPPER space"} { |
| + if got := HostIncarnationKey(bad); got != "" { |
| + t.Errorf("HostIncarnationKey(%q) = %q, want empty", bad, got) |
| + } |
| + } |
| +} |
| + |
| +func TestHostIncarnationWrites(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + const host = "shop.example.com" |
| + slot := HostIncarnation{Incarnation: 1, State: SlotLive, Account: "bab", App: "shop"} |
| + |
| + if got, etag, err := ReadHostIncarnation(ctx, s, host); got != nil || etag != "" || err != nil { |
| + t.Fatalf("absent read = %v %q %v, want nil, empty, nil", got, etag, err) |
| + } |
| + etag1, err := WriteHostIncarnation(ctx, s, host, slot, "") |
| + if err != nil || etag1 == "" { |
| + t.Fatalf("create-only: %q %v", etag1, err) |
| + } |
| + if _, err := WriteHostIncarnation(ctx, s, host, slot, ""); !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("second create-only err = %v, want ErrCASConflict", err) |
| + } |
| + got, readTag, err := ReadHostIncarnation(ctx, s, host) |
| + if err != nil || got == nil || *got != slot || readTag != etag1 { |
| + t.Fatalf("read = %+v %q %v", got, readTag, err) |
| + } |
| + retired := slot |
| + retired.State = SlotRetired |
| + etag2, err := WriteHostIncarnation(ctx, s, host, retired, etag1) |
| + if err != nil { |
| + t.Fatalf("If-Match with the returned ETag: %v", err) |
| + } |
| + if _, err := WriteHostIncarnation(ctx, s, host, slot, etag1); !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("stale ETag err = %v, want ErrCASConflict", err) |
| + } |
| + if got, _, _ := ReadHostIncarnation(ctx, s, host); got == nil || got.State != SlotRetired { |
| + t.Fatalf("stale write changed the slot: %+v", got) |
| + } |
| + _ = etag2 |
| + |
| + for _, bad := range []HostIncarnation{ |
| + {State: SlotLive, Account: "bab", App: "shop"}, |
| + {Incarnation: 1, State: "bogus", Account: "bab", App: "shop"}, |
| + {Incarnation: 1, State: SlotLive, App: "shop"}, |
| + {Incarnation: 1, State: SlotLive, Account: "bab"}, |
| + } { |
| + if _, err := WriteHostIncarnation(ctx, s, "other.example.com", bad, ""); err == nil { |
| + t.Errorf("invalid slot %+v was written", bad) |
| + } |
| + } |
| + if keys, _ := s.List(ctx, "control/host-incarnations/other"); len(keys) != 0 { |
| + t.Errorf("an invalid slot left objects behind: %v", keys) |
| + } |
| + |
| + for _, body := range []string{`not json`, `{"incarnation":0,"state":"live","account":"bab","app":"shop"}`} { |
| + if _, err := s.Put(ctx, HostIncarnationKey("bad.example.com"), []byte(body), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := ReadHostIncarnation(ctx, s, "bad.example.com"); !errors.Is(err, ErrSlotMalformed) { |
| + t.Errorf("body %q read err = %v, want ErrSlotMalformed", body, err) |
| + } |
| + if err := s.Delete(ctx, HostIncarnationKey("bad.example.com")); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| +} |
| + |
| +func TestTombstoneBodyKeepsTheSequence(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + rec := Record{Schema: 1, Host: "shop.example.com", Slug: "shop", App: "shop", Account: "bab", |
| + Pool: "p", Region: "ie", Channel: "stable", Gate: "hash", Incarnation: 2, MoveSeq: 5, RecordSeq: 7, |
| + Replicas: []string{"uk"}} |
| + before, err := json.Marshal(rec) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key := RecordKey("bab", "shop", "shop.example.com") |
| + etag, err := s.Put(ctx, key, before, "") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + body, err := TombstoneBody(before) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var fields map[string]json.RawMessage |
| + if err := json.Unmarshal(body, &fields); err != nil { |
| + t.Fatal(err) |
| + } |
| + var names []string |
| + for k := range fields { |
| + names = append(names, k) |
| + } |
| + sort.Strings(names) |
| + if got := strings.Join(names, ","); got != "account,app,host,incarnation,record_seq,retired,schema" { |
| + t.Fatalf("tombstone keys = %s", got) |
| + } |
| + if string(fields["record_seq"]) != "7" || string(fields["retired"]) != "true" || string(fields["incarnation"]) != "2" { |
| + t.Errorf("tombstone = %s", body) |
| + } |
| + if _, _, err := WriteRecord(ctx, s, key, before, body, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _, err := s.Get(ctx, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var written Record |
| + if err := json.Unmarshal(raw, &written); err != nil { |
| + t.Fatal(err) |
| + } |
| + if written.RecordSeq != 8 || !written.Retired { |
| + t.Errorf("written tombstone = %s, want record_seq 8 and retired", raw) |
| + } |
| + |
| + legacy := []byte(`{"schema":1,"host":"shop.example.com","slug":"shop","app":"shop","account":"bab","region":"ie"}`) |
| + body, err = TombstoneBody(legacy) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fields = nil |
| + if err := json.Unmarshal(body, &fields); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(fields) != 5 { |
| + // schema, host, account, app, retired: no incarnation, no sequence. |
| + t.Errorf("legacy tombstone = %s, want five keys", body) |
| + } |
| + if _, ok := fields["record_seq"]; ok { |
| + t.Errorf("legacy tombstone grew a sequence: %s", body) |
| + } |
| + |
| + if _, err := TombstoneBody([]byte(`not json`)); err == nil { |
| + t.Error("TombstoneBody accepted a body that is not JSON") |
| + } |
| +} |
| + |
| +func TestGetRecordHidesATombstone(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + const host = "shop.example.com" |
| + key := RecordKey("bab", "shop", host) |
| + before := []byte(`{"schema":1,"host":"shop.example.com","slug":"shop","app":"shop","account":"bab","incarnation":2,"record_seq":3}`) |
| + etag, err := s.Put(ctx, key, before, "") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec, _, err := GetRecordETag(ctx, s, "bab", "shop", host); err != nil || rec.Incarnation != 2 { |
| + t.Fatalf("live read = %+v %v", rec, err) |
| + } |
| + body, err := TombstoneBody(before) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := WriteRecord(ctx, s, key, before, body, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := GetRecordETag(ctx, s, "bab", "shop", host); !errors.Is(err, os.ErrNotExist) { |
| + t.Errorf("GetRecordETag on a tombstone err = %v, want os.ErrNotExist", err) |
| + } |
| + if _, err := GetRecord(ctx, s, "bab", "shop", host); !errors.Is(err, os.ErrNotExist) { |
| + t.Errorf("GetRecord on a tombstone err = %v, want os.ErrNotExist", err) |
| + } |
| + rec, raw, tag, err := GetRecordRaw(ctx, s, "bab", "shop", host) |
| + if err != nil || rec == nil || !rec.Retired || rec.Incarnation != 2 || len(raw) == 0 || tag == "" { |
| + t.Errorf("GetRecordRaw = %+v %s %q %v, want the tombstone", rec, raw, tag, err) |
| + } |
| + if _, _, _, err := GetRecordRaw(ctx, s, "bab", "shop", "absent.example.com"); !errors.Is(err, os.ErrNotExist) { |
| + t.Errorf("GetRecordRaw on an absent record err = %v, want os.ErrNotExist", err) |
| + } |
| +} |
| + |
| +func TestGetRecordRawReportsCorruptWithTheETag(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + cases := map[string]string{ |
| + "not json": `not json`, |
| + "wrong host": `{"schema":1,"host":"other.example.com","app":"shop","account":"bab"}`, |
| + "wrong account": `{"schema":1,"host":"shop.example.com","app":"shop","account":"zzz"}`, |
| + } |
| + for name, body := range cases { |
| + key := RecordKey("bab", "shop", "shop.example.com") |
| + etag, err := s.Put(ctx, key, []byte(body), "") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec, raw, tag, err := GetRecordRaw(ctx, s, "bab", "shop", "shop.example.com") |
| + if !errors.Is(err, ErrRecordCorrupt) || rec != nil || string(raw) != body || tag != etag { |
| + t.Errorf("%s: GetRecordRaw = %+v %q %q %v, want ErrRecordCorrupt with raw and etag", name, rec, raw, tag, err) |
| + } |
| + if _, _, err := GetRecordETag(ctx, s, "bab", "shop", "shop.example.com"); err == nil { |
| + t.Errorf("%s: GetRecordETag accepted a corrupt record", name) |
| + } |
| + if err := s.Delete(ctx, key); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| +} |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 094df27bd..fb59372d7 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -984,7 +984,10 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/provision/provision.go", "DeleteRecord", "key-site", "key:RecordKey", "67e8f77d784d3e84626f2856bd45d219759ba4b603bc3daebbb9e315131c4945"}, |
| // Re-reviewed at the merge of carlos-run into seeded-instances (2026-10-13): main's body plus one refusal: a change to the record's seed is ErrSeedProtected, as in updateRecordSeeding; the write is still WriteRecord. |
| {"internal/provision/provision.go", "claimRawUpdateRecord", "record-writer", "key:RecordKey,key:WriteRecord,key:claimRawUpdateRecord", "9ecb91710e7460a8a91ad6299b3082ace6a8a38ba21b2edfef0199e6ab05d3e4"}, |
| - {"internal/provision/provision.go", "GetRecordETag", "key-site", "key:RecordKey", "d9f71e5a52091dce7ea6b6aa3e5d725456d5944efa180727674e7df0006d4fa5"}, |
| + // GetRecordETag no longer builds the key itself: it reads through GetRecordRaw (lifetime.go) and hides a tombstone. |
| + {"internal/provision/lifetime.go", "GetRecordRaw", "key-site", "key:RecordKey", "e4c422472e601a14496af407c4f9d0535a48809964d6dfbf6b06a7c2e125f077"}, |
| + // Writes control/host-incarnations/<host>.json by CAS (create-only or If-Match); the slot is not a record key. |
| + {"internal/provision/lifetime.go", "WriteHostIncarnation", "nonrecord", "sel:Put", "c5f2719a53447c0477706600081a9846444071f39b85759a463d1ee757dc760e"}, |
| // Re-reviewed at the merge of carlos-run into seeded-instances (2026-10-13): the host-list loop is factored into recordHosts, shared with ListRecordsStrict; reads only. |
| {"internal/provision/provision.go", "ListRecordsComplete", "key-site", "key:RecordsPrefix", "3231a8ccb6a058d899b296c9bf7420575e91b73875dc2daf2c1405ce21969487"}, |
| {"internal/provision/provision.go", "InstanceDir", "key-site", "lit:instances", "4defc7f996c83afe8d2d2efddd31f4de79d94a5c84e7ee99e8d639ca03d7bf81"}, |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index a5657b0cb..57c8fa916 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -172,6 +172,23 @@ type Record struct { |
| // (reconcile.go) and read by the edge on every request. Spec docs/superpowers/specs/2026-09-07-password-gate-design.md. |
| Gate string `json:"gate,omitempty"` |
| + // --- lifetime fields (self-serve moves, lifetime.go) --- |
| + |
| + // Incarnation names which lifetime of this hostname the record belongs |
| + // to; the owned slot control/host-incarnations/<host>.json carries the |
| + // same number while the lifetime is live. Zero with no marker is a |
| + // fieldless legacy record. A counter, never reused. |
| + Incarnation uint64 `json:"incarnation,omitempty"` |
| + // IncarnationMigrating is the marker a record carries while it is being |
| + // moved into a slot-governed lifetime; LifetimeOf checks it against the |
| + // migration slot's intent. |
| + IncarnationMigrating string `json:"incarnation_migrating,omitempty"` |
| + // MoveSeq counts completed moves of the hostname inside one lifetime. |
| + MoveSeq uint64 `json:"move_seq,omitempty"` |
| + // Retired marks a tombstone: the lifetime is over and the record keeps |
| + // only its identity and counters. GetRecord hides it; GetRecordRaw sees it. |
| + Retired bool `json:"retired,omitempty"` |
| + |
| // --- replica fields (carlos/platform discussion #162, edge checkout) --- |
| // Role is what this instance IS within its app: "" (the default every |
| @@ -2775,18 +2792,14 @@ func GetRecord(ctx context.Context, s release.Store, acct, app, host string) (*R |
| // (UpdateRecord), not against whatever is there at write time. Same seam, |
| // same reason, as GetManifestETag beside PutManifestIfMatch. |
| func GetRecordETag(ctx context.Context, s release.Store, acct, app, host string) (*Record, string, error) { |
| - raw, etag, err := s.Get(ctx, RecordKey(acct, app, host)) |
| + rec, _, etag, err := GetRecordRaw(ctx, s, acct, app, host) |
| if err != nil { |
| return nil, "", err |
| } |
| - rec, err := DecodeRecord(raw) |
| - if err != nil { |
| - return nil, "", fmt.Errorf("provision: record %s/%s/%s: %w", acct, app, host, err) |
| - } |
| - if rec.Account != acct || rec.App != app || rec.Host != host { |
| - return nil, "", fmt.Errorf("provision: record %s/%s/%s: record disagrees with its own path", acct, app, host) |
| + if rec.Retired { |
| + return nil, "", fmt.Errorf("provision: record %s/%s/%s: retired: %w", acct, app, host, os.ErrNotExist) |
| } |
| - return &rec, etag, nil |
| + return rec, etag, nil |
| } |
| // recordHosts picks the record hosts out of a listing of RecordsPrefix: |
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go| index 596506be4..81591bab8 100644 |
| --- a/internal/provision/provision_test.go |
| +++ b/internal/provision/provision_test.go |
| @@ -58,6 +58,27 @@ func TestRecordWireShapeIsAdditive(t *testing.T) { |
| if !rec.HibernateOrDefault() { |
| t.Error("HibernateOrDefault() = false, want true — reconcile mints Hibernate:true for these today") |
| } |
| + // The lifetime fields: a legacy record is fieldless, and a record that |
| + // carries none of them marshals none of them. |
| + if rec.Incarnation != 0 || rec.IncarnationMigrating != "" || rec.MoveSeq != 0 || rec.Retired { |
| + t.Errorf("lifetime fields = %d %q %d %v, want all zero", rec.Incarnation, rec.IncarnationMigrating, rec.MoveSeq, rec.Retired) |
| + } |
| + body, err := json.Marshal(rec) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, key := range []string{"incarnation", "incarnation_migrating", "move_seq", "retired"} { |
| + if strings.Contains(string(body), `"`+key+`"`) { |
| + t.Errorf("marshalled legacy record carries %q: %s", key, body) |
| + } |
| + } |
| + var with Record |
| + if err := json.Unmarshal([]byte(`{"incarnation":18446744073709551615,"incarnation_migrating":"m1","move_seq":4,"retired":true}`), &with); err != nil { |
| + t.Fatal(err) |
| + } |
| + if with.Incarnation != ^uint64(0) || with.IncarnationMigrating != "m1" || with.MoveSeq != 4 || !with.Retired { |
| + t.Errorf("lifetime fields did not round trip: %+v", with) |
| + } |
| } |
| // The console rewrites records (plan 2's PUT). A rewrite must not sprout |