carlos / platform Public

Region HA contracts for the claim (L13) and moves sessions, dormant

- hostfiles manifests are version 2: each entry names its object by an
  origin-qualified bucket key, and Restore, ReadPlatform and LoadWake read
  only from an explicit set of admitted origin prefixes (default: the
  manifest's own), validating every entry before touching anything.
- hostfiles.CopyManifest puts a generation's manifest into a new
  generation create-only, retry-safe for a fenced source, pinned by
  expectSeq, without copying objects.
- hostfiles.Publish is the install-time publish: one complete, consistent
  image of a directory or nothing (refuses unreadable, replaced or torn
  files).
- internal/boxchoice exports box eligibility and the executor choice that
  console used privately; console's behaviour is unchanged.

The contracts note is docs/superpowers/notes/2026-10-07-region-ha-contracts.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Squashed from region-ha-contracts into main.
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 337b106216099ae7be9df3019b0ce6f9b6d816c3 parent 383fae0
16 files changed, +1478 −322
  • docs/superpowers/notes/2026-10-07-region-ha-contracts.md +184 −0
  • internal/boxchoice/boxchoice.go +186 −0
  • internal/boxchoice/boxchoice_test.go +261 −0
  • internal/console/boxchoice.go +30 −106
  • internal/console/boxchoice_test.go +26 −130
  • internal/hostfiles/contract.go +142 −0
  • internal/hostfiles/hostfiles_test.go +43 −43
  • internal/hostfiles/manifest.go +55 −3
  • internal/hostfiles/origins_test.go +353 −0
  • internal/hostfiles/publisher.go +7 −6
  • internal/hostfiles/restore.go +69 −25
  • internal/hostfiles/sync.go +11 −0
  • internal/hostfiles/sync_test.go +2 −2
  • internal/hostfiles/sync_unix_test.go +102 −0
  • internal/hostfiles/wake.go +3 −3
  • internal/hostfiles/wake_test.go +4 −4

CI passed — run details

diff --git a/docs/superpowers/notes/2026-10-07-region-ha-contracts.md b/docs/superpowers/notes/2026-10-07-region-ha-contracts.md
new file mode 100644
index 000000000..a5acba97f
--- /dev/null
+++ b/docs/superpowers/notes/2026-10-07-region-ha-contracts.md
@@ -0,0 +1,184 @@
+# Region HA contracts for the claim and moves sessions
+
+Branch `region-ha-contracts`, 2026-10-07. Three contracts that the claim
+session (L13) and the moves session (L15, L17 in
+`2026-10-06-moves-region-ha-conflicts.md`) asked region HA for. All three
+are dormant: no production path calls them yet.
+
+## 1. Origin-qualified object references (claim L13)
+
+Package `internal/hostfiles`.
+
+The manifest format is now version 2 (`hostfiles.ManifestVersion = 2`).
+`Entry.Object` is the object's full key from the bucket root:
+
+ instances/<host>/<inc>/files/<gen>/objects/<16-digit seq>-<64 hex sha256>
+
+The publisher writes it under its own prefix. Readers fetch exactly that
+key and never join it to their own prefix. Version 1 held a key relative
+to the manifest's prefix. The field's meaning changed, so the version was
+bumped. Any manifest that is not version 2 (including 1) is refused by
+`Load`, `Open`, `Restore` and `CopyManifest` with "manifest <prefix> is
+version N; this binary reads 2". No version 1 manifest exists in
+production, so nothing needs migrating.
+
+Signatures:
+
+```go
+func Restore(ctx context.Context, s release.Store, prefix, dir string, origins []string) (Manifest, RestoreStats, error)
+func ReadPlatform(ctx context.Context, s release.Store, prefix string, m Manifest, name string, origins []string) ([]byte, error)
+func LoadWake(ctx context.Context, s release.Store, prefix string, m Manifest, origins []string) (WakeState, bool, error)
+func CopyManifest(ctx context.Context, s release.Store, fromPrefix, toPrefix string, expectSeq int64, origins []string) (Manifest, error)
+var ErrOrigin = errors.New("hostfiles: object origin not admitted")
+var ErrManifestExists = errors.New("hostfiles: the target generation already has a manifest")
+var ErrSourceMoved = errors.New("hostfiles: the source manifest is not at the expected seq")
+```
+
+`prefix` must be a clean relative bucket key ending in `/`. Restore,
+ReadPlatform (and so LoadWake), Open and CopyManifest refuse any other
+prefix before reading anything.
+
+`origins` is the set of generation prefixes that the caller's pin admits.
+nil or empty means the manifest's own prefix only. The set is used exactly
+as passed: a caller that passes a set must include the manifest's own
+prefix if it wants objects published there. Each origin must be a clean
+relative bucket key ending in `/`. If one is not, the call fails before
+anything is read.
+
+What Restore guarantees: it validates every path and every published
+entry before it touches `dir`. If any entry fails, Restore writes and
+removes nothing. An entry's object must:
+
+- be a clean bucket key: relative, no `..`, no `.`, no `//`, no NUL;
+- be exactly `<origin>objects/<%016d Seq>-<SHA256>`, using that entry's
+ own Seq and SHA256, where origin is one of the admitted origins. A key
+ nested deeper under an origin, a key under another host's or another
+ generation's prefix, a relative key in the version 1 style, an absolute
+ key, or a wrong seq or sha is refused. If the key is well formed but its
+ origin is not admitted, the error wraps `ErrOrigin`;
+- hash and size-check as before (`checkEntry`, then `fetch`).
+
+ReadPlatform and LoadWake apply the same rules to platform items.
+
+CopyManifest reads `fromPrefix`'s manifest and validates every entry
+against `origins` (nil means `fromPrefix` alone). A source built by an
+earlier copy references earlier generations, so its caller passes the
+whole chain. CopyManifest then writes the same manifest (entries and Seq),
+with the same qualified references, to `toPrefix`'s manifest. The write is
+create-only. No objects are copied.
+
+`expectSeq` is the source revision the caller's pin admitted. When it is
+not 0 and the source is at any other Seq, the call returns
+`ErrSourceMoved` and writes nothing, on a first attempt or a retry. 0
+copies whatever the source holds.
+
+Errors:
+
+- an absent source is `os.ErrNotExist`, never an empty copy;
+- a source not at a non-zero `expectSeq` is `ErrSourceMoved`;
+- a target already holding exactly the manifest this call would write is
+ success, so a retry is safe;
+- any other target is `ErrManifestExists`, and the target is left as it
+ was;
+- a source equal to the target is refused.
+
+Retry-safety requires a source that cannot commit between attempts: the
+source generation must be fenced or frozen. The claim's admission provides
+this. Against a live source, a retry can find the target holding an older
+revision. It then reports `ErrManifestExists`, or `ErrSourceMoved` when
+`expectSeq` is set. It never reports success for a revision the caller
+did not admit.
+
+The new generation's `Open(toPrefix)` continues the sequence from the
+copy. An unchanged file keeps its old-generation reference. A changed file
+becomes a new object under `toPrefix`.
+
+For the claim's live set: the objects a manifest keeps alive are exactly
+the `Object` keys of its entries and platform items that are neither
+deleted nor skipped. Those keys may lie under earlier generations'
+prefixes.
+
+## 2. Files publish entry point (moves L17)
+
+Moves asked for `files.Publish(ctx, store, location, generation, dir,
+envPath) (manifestVersion string, err error)`. The closest stable call is:
+
+```go
+func Publish(ctx context.Context, s release.Store, prefix, dir string, skip func(rel string, isDir bool) bool) (int64, SyncStats, error)
+```
+
+- `prefix` is the generation's files prefix, `<location>files/<generation>/`.
+ The caller builds it.
+- It runs one `Open`, one `SyncDir` and one `Commit`, and returns the
+ committed manifest sequence number. That number is the manifest version.
+- On success, every regular file in `dir` that `skip` does not exclude is
+ in the manifest. A file larger than `MaxFileBytes` is recorded as
+ skipped and listed in `SyncStats.TooLarge`. Anything that is not a
+ regular file is left out and listed in `SyncStats.NotRegular`.
+- Publish is an install-time contract: it commits a complete and
+ consistent image or nothing. It returns an error and commits nothing
+ when any file could not be read (`SyncStats.Unreadable`), was replaced
+ between the look and the open (`SyncStats.Retry`), or changed while it
+ was read (`SyncStats.Torn`). The caller stops writers and calls Publish
+ again. Objects uploaded by a refused attempt are referenced by no
+ manifest.
+- A prefix that already has a manifest is continued, not replaced. A
+ repeat with nothing changed returns the same sequence number. If another
+ writer's manifest appears between Open and Commit, the error is
+ `ErrSuperseded`.
+- The env file is not part of this call. A caller that wants platform
+ items in the same revision uses `Open`, `StagePlatform`, `SyncDir` and
+ `Commit` directly.
+
+## 3. Eligible-boxes read (moves L15)
+
+New package `internal/boxchoice`. Console's unexported `eligibleBoxes` and
+`chooseBox` are now thin adapters over it, and their behaviour is
+unchanged. The predicate and choice tests moved to the new package.
+Console keeps tests that check its snapshot conversion carries every
+field.
+
+```go
+const Capability = "region-ha-1"
+type Headroom struct{ MemFreeMiB, DiskFreeMiB int64; Awake int }
+type Box struct {
+ Box, BoxID, EIP, Region, Placement string
+ HibernationConfigured, ServesSharedPool, AcceptsInbound *bool
+ SecretsKeyFP string
+ Capabilities []string
+ Draining bool
+ Headroom *Headroom
+ Fresh bool // not on the wire: the caller's liveness verdict
+}
+type Snapshot struct{ Boxes []Box; Complete bool }
+type RosterBox struct{ Placement string; Cloud boxid.Cloud }
+type Candidate struct{ BoxID, Region string; MemFreeMiB, DiskFreeMiB int64; Awake int }
+type Load struct{ Hosts int; Apps map[string]int }
+
+func Eligible(snap Snapshot, roster map[string]RosterBox, placement, awsRegion, homeName, expectedFP string, lost map[string]bool) []Candidate
+func Choose(host string, cands []Candidate, running map[string]Load, app string) (Candidate, bool)
+```
+
+`Box` uses the edges file's JSON tags, so an edges file unmarshals
+straight into it. The caller sets `Fresh` from the box's status snapshot.
+It sets `Snapshot.Complete` only when every listed edges file was read
+and every box's liveness is known.
+
+What Eligible guarantees:
+
+- It returns nothing when the snapshot is incomplete, `expectedFP` is
+ empty or `awsRegion` is empty.
+- It judges each box on that box's own evidence. Two partial boxes never
+ combine into one eligible box.
+- Identity and AWS region come from the roster. The edges file supplies
+ liveness and configuration.
+- The result is sorted by box id.
+
+What Choose guarantees: it prefers a box that does not run `app` (it never
+refuses on that), then the fewest hosts, then the greatest SHA-256 of
+`host + "\x00" + boxID`, then the smallest box id. The answer does not
+depend on the order of the candidates. It returns false only when there
+are no candidates.
+
+Moves' door can call `Eligible` then `Choose` in place of its "lexically
+first box" fallback.
diff --git a/internal/boxchoice/boxchoice.go b/internal/boxchoice/boxchoice.go
new file mode 100644
index 000000000..7308877e7
--- /dev/null
+++ b/internal/boxchoice/boxchoice.go
@@ -0,0 +1,186 @@
+// Package boxchoice decides which boxes in a placement may run a host as
+// its executor, and which of them a new claim should name (region HA spec
+// 1 §2 and §5.2). It is pure: callers read the edges files, the box status
+// snapshots and the roster, and pass what they read.
+//
+// Console's placement door and moves' door both call it, so the two
+// always agree on what "eligible" means.
+package boxchoice
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "slices"
+ "sort"
+
+ "github.com/carlosframework/platform/internal/boxid"
+)
+
+// Capability is the capability an edges file lists when its box can be a
+// region HA executor.
+const Capability = "region-ha-1"
+
+// Headroom is what a box reports it has free.
+type Headroom struct {
+ MemFreeMiB int64 `json:"mem_free_mib"`
+ DiskFreeMiB int64 `json:"disk_free_mib"`
+ Awake int `json:"awake"`
+}
+
+// Box is one box's edges file (control/edges/<box>.json, written by
+// internal/host/edgesfile.go), reduced to what eligibility reads, plus
+// Fresh, which is not on the wire. The JSON tags are the edges file's, so
+// a caller may unmarshal a file straight into a Box.
+//
+// The *bool fields are pointers because absent and false are different
+// facts: absent is a writer that predates the field, and is not eligible.
+type Box struct {
+ Box string `json:"box"`
+ BoxID string `json:"box_id"`
+ EIP string `json:"eip"`
+ Region string `json:"region"`
+ Placement string `json:"placement"`
+ HibernationConfigured *bool `json:"hibernation_configured"`
+ ServesSharedPool *bool `json:"serves_shared_pool"`
+ AcceptsInbound *bool `json:"accepts_inbound"`
+ SecretsKeyFP string `json:"secrets_key_fp"`
+ Capabilities []string `json:"capabilities"`
+ Draining bool `json:"draining"`
+ Headroom *Headroom `json:"headroom"`
+ // Fresh is the caller's verdict that the box's own status snapshot is
+ // still fresh. An edges file outlives its box, so the file alone is
+ // not liveness.
+ Fresh bool `json:"-"`
+}
+
+// Snapshot is one read of every box. Complete is true only when every
+// listed edges file was read and parsed and every box's liveness is known
+// (each live box has a file and each file's Fresh is a known answer). An
+// incomplete snapshot makes nothing eligible: an unread file could be the
+// one box that would change the answer.
+type Snapshot struct {
+ Boxes []Box
+ Complete bool
+}
+
+// RosterBox is what the roster (control/host-actors.json, claim D7) says
+// about one active box: the placement it is enrolled in and the cloud
+// identity enrollment proved from its boxid record (spec §5.1, H11). The
+// caller passes active entries only, keyed by box id.
+type RosterBox struct {
+ Placement string
+ Cloud boxid.Cloud
+}
+
+// Candidate is one box that may be named as a host's executor (region HA
+// spec 1 §2, "eligible box").
+type Candidate struct {
+ BoxID string
+ Region string
+ MemFreeMiB, DiskFreeMiB int64
+ Awake int
+}
+
+// Load is what claims already place on a box.
+type Load struct {
+ Hosts int
+ Apps map[string]int // "acct/app" -> hosts of that app
+}
+
+func isTrue(b *bool) bool { return b != nil && *b }
+
+// Eligible returns the boxes that may run a host placed in placement,
+// sorted by box id. A box qualifies on its own evidence alone: it is
+// fresh, has a dial address and a box id, and has a roster entry for that
+// box id with a complete cloud identity, enrolled in this placement, whose
+// proven AWS region is awsRegion (the region the roster records for this
+// placement), as its file's region also is. Its file says hibernation is
+// configured, it serves the shared pool and accepts inbound, its secrets
+// key fingerprint is expectedFP, and it has Capability. It is not
+// draining and not in lost. Two boxes that each satisfy half of this never
+// add up to one. Identity and region come from the roster; the edges file
+// is evidence of liveness and configuration only (spec §5.1).
+//
+// homeName is the home placement's code: a box whose file or roster entry
+// names it is the home placement, as is the empty code.
+//
+// An incomplete snapshot, an empty expectedFP or an empty awsRegion
+// returns nothing.
+func Eligible(snap Snapshot, roster map[string]RosterBox, placement, awsRegion, homeName, expectedFP string, lost map[string]bool) []Candidate {
+ if !snap.Complete || expectedFP == "" || awsRegion == "" {
+ return nil
+ }
+ home := func(code string) string {
+ if homeName != "" && code == homeName {
+ return ""
+ }
+ return code
+ }
+ var out []Candidate
+ for _, f := range snap.Boxes {
+ want := home(placement)
+ rb, ok := roster[f.BoxID]
+ switch {
+ case home(f.Placement) != want:
+ case !f.Fresh, f.EIP == "", f.BoxID == "":
+ case !ok || !rb.Cloud.Complete():
+ case home(rb.Placement) != want:
+ // The file's placement is the box's own word; the roster's is
+ // the enrollment's. Both must agree.
+ case rb.Cloud.Region != awsRegion, f.Region != awsRegion:
+ // The AWS region was proven at enrollment: a box enrolled in
+ // another region is not in this placement, whatever its file
+ // says.
+ case !isTrue(f.HibernationConfigured), !isTrue(f.ServesSharedPool), !isTrue(f.AcceptsInbound):
+ case f.SecretsKeyFP != expectedFP:
+ case !slices.Contains(f.Capabilities, Capability):
+ case f.Draining, lost[f.BoxID]:
+ default:
+ c := Candidate{BoxID: f.BoxID, Region: rb.Cloud.Region}
+ if f.Headroom != nil {
+ c.MemFreeMiB, c.DiskFreeMiB, c.Awake = f.Headroom.MemFreeMiB, f.Headroom.DiskFreeMiB, f.Headroom.Awake
+ }
+ out = append(out, c)
+ }
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].BoxID < out[j].BoxID })
+ return out
+}
+
+// Choose is spec 1 §5.2's choose() until spec 2 (H12): prefer a box that
+// runs no instance of app (soft spread: it never refuses), then the fewest
+// hosts overall, then the greatest hexadecimal SHA-256 of
+// host + "\x00" + box id (claim P5-D2), then the smallest box id if two
+// hashes are equal. The answer does not depend on the order of cands. The
+// bool is false only when cands is empty.
+func Choose(host string, cands []Candidate, running map[string]Load, app string) (Candidate, bool) {
+ if len(cands) == 0 {
+ return Candidate{}, false
+ }
+ tie := func(ref string) string {
+ sum := sha256.Sum256([]byte(host + "\x00" + ref))
+ return hex.EncodeToString(sum[:])
+ }
+ best := cands[0]
+ for _, c := range cands[1:] {
+ cl, bl := running[c.BoxID], running[best.BoxID]
+ cRuns, bRuns := cl.Apps[app] > 0, bl.Apps[app] > 0
+ switch {
+ case cRuns != bRuns:
+ if !cRuns {
+ best = c
+ }
+ case cl.Hosts != bl.Hosts:
+ if cl.Hosts < bl.Hosts {
+ best = c
+ }
+ case tie(c.BoxID) != tie(best.BoxID):
+ if tie(c.BoxID) > tie(best.BoxID) {
+ best = c
+ }
+ case c.BoxID < best.BoxID:
+ best = c
+ }
+ }
+ return best, true
+}
diff --git a/internal/boxchoice/boxchoice_test.go b/internal/boxchoice/boxchoice_test.go
new file mode 100644
index 000000000..7041ee6ff
--- /dev/null
+++ b/internal/boxchoice/boxchoice_test.go
@@ -0,0 +1,261 @@
+package boxchoice
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/boxid"
+)
+
+const testKeyFP = "sha256:test-key"
+
+// haBox is an eligible box's edges file in placement "uk", as JSON, so the
+// tests also hold Box's tags to the edges file's.
+func haBox(id string, mut func(m map[string]any)) string {
+ m := map[string]any{
+ "v": 1, "box": id, "box_id": id, "eip": "1.2.3.4", "region": "eu-west-2", "placement": "uk",
+ "hosts": map[string]any{}, "hibernation_configured": true, "serves_shared_pool": true,
+ "accepts_inbound": true, "secrets_key_fp": testKeyFP, "capabilities": []string{"region-ha-1"},
+ "headroom": map[string]any{"mem_free_mib": 1000, "disk_free_mib": 1000, "awake": 1},
+ }
+ if mut != nil {
+ mut(m)
+ }
+ b, _ := json.Marshal(m)
+ return string(b)
+}
+
+// views is a complete snapshot of fresh boxes.
+func views(t *testing.T, bodies ...string) Snapshot {
+ t.Helper()
+ snap := Snapshot{Complete: true}
+ for _, body := range bodies {
+ var b Box
+ if err := json.Unmarshal([]byte(body), &b); err != nil {
+ t.Fatal(err)
+ }
+ b.Fresh = true
+ snap.Boxes = append(snap.Boxes, b)
+ }
+ return snap
+}
+
+func markStale(snap Snapshot, box string) Snapshot {
+ for i := range snap.Boxes {
+ if snap.Boxes[i].Box == box {
+ snap.Boxes[i].Fresh = false
+ }
+ }
+ return snap
+}
+
+func ids(cs []Candidate) []string {
+ out := make([]string, len(cs))
+ for i, c := range cs {
+ out[i] = c.BoxID
+ }
+ return out
+}
+
+// rosterFor enrolls each box in placement "uk" as an instance in eu-west-2.
+func rosterFor(boxes ...string) map[string]RosterBox {
+ out := map[string]RosterBox{}
+ for _, b := range boxes {
+ out[b] = RosterBox{Placement: "uk", Cloud: boxid.Cloud{Provider: "aws", Account: "1", Region: "eu-west-2", InstanceID: "i-" + b}}
+ }
+ return out
+}
+
+func TestEligibleAppliesThePredicateOneBoxAtATime(t *testing.T) {
+ del := func(k string) func(map[string]any) { return func(m map[string]any) { delete(m, k) } }
+ set := func(k string, v any) func(map[string]any) { return func(m map[string]any) { m[k] = v } }
+ cases := []struct {
+ name string
+ mut func(map[string]any)
+ want bool
+ }{
+ {"all present", nil, true},
+ {"no dial address", set("eip", ""), false},
+ {"no box id", set("box_id", ""), false},
+ {"hibernation unconfigured", set("hibernation_configured", false), false},
+ {"hibernation unknown", del("hibernation_configured"), false},
+ {"refuses the shared pool", set("serves_shared_pool", false), false},
+ {"shared pool unknown", del("serves_shared_pool"), false},
+ {"refuses inbound", set("accepts_inbound", false), false},
+ {"inbound unknown", del("accepts_inbound"), false},
+ {"wrong secrets key", set("secrets_key_fp", "sha256:other"), false},
+ {"no capability", del("capabilities"), false},
+ {"draining", set("draining", true), false},
+ {"another placement", set("placement", "us"), false},
+ {"box_id names no roster entry", set("box_id", "i-0zzz"), false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ snap := views(t, haBox("i-0aaa", tc.mut))
+ got := Eligible(snap, rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, nil)
+ if (len(got) == 1) != tc.want {
+ t.Fatalf("eligible = %v, want %v", ids(got), tc.want)
+ }
+ })
+ }
+}
+
+func TestEligibleCarriesTheHeadroomAndTheRostersRegion(t *testing.T) {
+ got := Eligible(views(t, haBox("i-0aaa", nil)), rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, nil)
+ want := Candidate{BoxID: "i-0aaa", Region: "eu-west-2", MemFreeMiB: 1000, DiskFreeMiB: 1000, Awake: 1}
+ if len(got) != 1 || got[0] != want {
+ t.Fatalf("got %+v, want %+v", got, want)
+ }
+}
+
+func TestEligibleNeedsARosterEntryFreshnessAndNotLost(t *testing.T) {
+ snap := views(t, haBox("i-0aaa", nil), haBox("i-0bbb", nil), haBox("i-0ccc", nil))
+ snap = markStale(snap, "i-0bbb")
+ got := Eligible(snap, rosterFor("i-0aaa", "i-0bbb"), "uk", "eu-west-2", "ie", testKeyFP, nil)
+ if len(got) != 1 || got[0].BoxID != "i-0aaa" {
+ t.Fatalf("got %v, want only i-0aaa (i-0bbb is stale, i-0ccc has no roster entry)", ids(got))
+ }
+ got = Eligible(snap, rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, map[string]bool{"i-0aaa": true})
+ if len(got) != 0 {
+ t.Fatalf("a lost box is eligible: %v", ids(got))
+ }
+}
+
+// Nearest-region's rule for boxes: one box holding the key and another
+// accepting inbound do not add up to one.
+func TestEligibleNeverCombinesTwoHalfBoxes(t *testing.T) {
+ snap := views(t,
+ haBox("i-0aaa", func(m map[string]any) { m["secrets_key_fp"] = "" }),
+ haBox("i-0bbb", func(m map[string]any) { m["accepts_inbound"] = false }))
+ if got := Eligible(snap, rosterFor("i-0aaa", "i-0bbb"), "uk", "eu-west-2", "ie", testKeyFP, nil); len(got) != 0 {
+ t.Fatalf("got %v, want none", ids(got))
+ }
+}
+
+func TestEligibleFromAnIncompleteSnapshotOrWithoutAKeyOrRegionIsNothing(t *testing.T) {
+ snap := views(t, haBox("i-0aaa", nil))
+ snap.Complete = false
+ if got := Eligible(snap, rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, nil); len(got) != 0 {
+ t.Fatalf("got %v from an incomplete snapshot", ids(got))
+ }
+ if got := Eligible(views(t, haBox("i-0aaa", nil)), rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", "", nil); len(got) != 0 {
+ t.Fatalf("got %v with no expected key", ids(got))
+ }
+ if got := Eligible(views(t, haBox("i-0aaa", nil)), rosterFor("i-0aaa"), "uk", "", "ie", testKeyFP, nil); len(got) != 0 {
+ t.Fatalf("got %v with no expected region", ids(got))
+ }
+}
+
+func TestEligibleHomePlacementMatchesTheEmptyCode(t *testing.T) {
+ home := haBox("i-0aaa", func(m map[string]any) { m["placement"] = "" })
+ named := haBox("i-0bbb", func(m map[string]any) { m["placement"] = "ie" })
+ snap := views(t, home, named)
+ roster := rosterFor("i-0aaa", "i-0bbb")
+ for _, b := range []string{"i-0aaa", "i-0bbb"} {
+ rb := roster[b]
+ rb.Placement = "ie"
+ roster[b] = rb
+ }
+ if got := Eligible(snap, roster, "", "eu-west-2", "ie", testKeyFP, nil); len(got) != 2 {
+ t.Fatalf("got %v, want both home boxes", ids(got))
+ }
+ if got := Eligible(snap, roster, "ie", "eu-west-2", "ie", testKeyFP, nil); len(got) != 2 {
+ t.Fatalf("got %v, want both home boxes by the home's code", ids(got))
+ }
+}
+
+// A box enrolled in another AWS region than the placement's is not in that
+// placement, even when its own file says it is.
+func TestEligibleRefusesABoxEnrolledInAnotherRegion(t *testing.T) {
+ snap := views(t, haBox("i-0aaa", nil), haBox("i-0bbb", nil))
+ roster := rosterFor("i-0aaa", "i-0bbb")
+ roster["i-0bbb"] = RosterBox{Placement: "uk", Cloud: boxid.Cloud{Provider: "aws", Account: "1", Region: "us-east-1", InstanceID: "i-0bbb"}}
+ got := Eligible(snap, roster, "uk", "eu-west-2", "ie", testKeyFP, nil)
+ if len(got) != 1 || got[0].BoxID != "i-0aaa" {
+ t.Fatalf("got %v, want only i-0aaa", ids(got))
+ }
+ // And a file that advertises another AWS region than the one expected.
+ snap = views(t, haBox("i-0aaa", func(m map[string]any) { m["region"] = "us-east-1" }))
+ if got := Eligible(snap, rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, nil); len(got) != 0 {
+ t.Fatalf("got %v, want none", ids(got))
+ }
+}
+
+// The roster's enrolled placement is the enrollment's word and must agree
+// with the file's; an incomplete cloud is no identity at all.
+func TestEligibleNeedsTheRosterToAgree(t *testing.T) {
+ snap := views(t, haBox("i-0aaa", nil), haBox("i-0bbb", nil))
+ roster := rosterFor("i-0aaa", "i-0bbb")
+ roster["i-0aaa"] = RosterBox{Placement: "us", Cloud: roster["i-0aaa"].Cloud}
+ roster["i-0bbb"] = RosterBox{Placement: "uk", Cloud: boxid.Cloud{Provider: "aws", Region: "eu-west-2", InstanceID: "i-0bbb"}}
+ if got := Eligible(snap, roster, "uk", "eu-west-2", "ie", testKeyFP, nil); len(got) != 0 {
+ t.Fatalf("got %v, want none", ids(got))
+ }
+}
+
+func TestEligibleSortsByBoxID(t *testing.T) {
+ snap := views(t, haBox("i-0ccc", nil), haBox("i-0aaa", nil), haBox("i-0bbb", nil))
+ got := ids(Eligible(snap, rosterFor("i-0aaa", "i-0bbb", "i-0ccc"), "uk", "eu-west-2", "ie", testKeyFP, nil))
+ if len(got) != 3 || got[0] != "i-0aaa" || got[1] != "i-0bbb" || got[2] != "i-0ccc" {
+ t.Fatalf("got %v", got)
+ }
+}
+
+func TestChoose(t *testing.T) {
+ a, b := Candidate{BoxID: "i-0aaa"}, Candidate{BoxID: "i-0bbb"}
+ load := func(hosts int, apps ...string) Load {
+ l := Load{Hosts: hosts, Apps: map[string]int{}}
+ for _, x := range apps {
+ l.Apps[x]++
+ }
+ return l
+ }
+ const host = "shop.bab.oncarlos.com"
+ if _, ok := Choose(host, nil, nil, "bab/jam"); ok {
+ t.Fatal("chose from nothing")
+ }
+ got, _ := Choose(host, []Candidate{a, b}, map[string]Load{"i-0aaa": load(5), "i-0bbb": load(2)}, "bab/jam")
+ if got.BoxID != "i-0bbb" {
+ t.Fatalf("fewest hosts: got %s", got.BoxID)
+ }
+ got, _ = Choose(host, []Candidate{a, b}, map[string]Load{"i-0aaa": load(5), "i-0bbb": load(2, "bab/jam")}, "bab/jam")
+ if got.BoxID != "i-0aaa" {
+ t.Fatalf("spread beats load: got %s", got.BoxID)
+ }
+ got, ok := Choose(host, []Candidate{a, b}, map[string]Load{"i-0aaa": load(5, "bab/jam"), "i-0bbb": load(2, "bab/jam", "bab/jam")}, "bab/jam")
+ if !ok || got.BoxID != "i-0bbb" {
+ t.Fatalf("every box runs the app: fewest hosts overall, never a refusal; got %s ok=%v", got.BoxID, ok)
+ }
+}
+
+// H12, claim P5-D2: an equal load breaks by the greatest SHA-256 of
+// host + "\x00" + reference, so the choice differs host by host instead
+// of always landing on the lowest name, and does not depend on the order
+// the candidates arrive in.
+func TestChooseBreaksATieByTheHostsHash(t *testing.T) {
+ cands := []Candidate{{BoxID: "annie-welding"}, {BoxID: "bert-sawing"}, {BoxID: "cleo-mixing"}}
+ want := func(host string) string {
+ best, bestSum := "", ""
+ for _, c := range cands {
+ sum := sha256.Sum256([]byte(host + "\x00" + c.BoxID))
+ if h := hex.EncodeToString(sum[:]); h > bestSum {
+ best, bestSum = c.BoxID, h
+ }
+ }
+ return best
+ }
+ seen := map[string]bool{}
+ for _, host := range []string{"a.bab.oncarlos.com", "b.bab.oncarlos.com", "c.bab.oncarlos.com", "d.bab.oncarlos.com", "e.bab.oncarlos.com", "f.bab.oncarlos.com"} {
+ forward, _ := Choose(host, cands, nil, "bab/jam")
+ backward, _ := Choose(host, []Candidate{cands[2], cands[1], cands[0]}, nil, "bab/jam")
+ if forward.BoxID != want(host) || backward.BoxID != want(host) {
+ t.Fatalf("%s: chose %s and %s, want %s", host, forward.BoxID, backward.BoxID, want(host))
+ }
+ seen[forward.BoxID] = true
+ }
+ if len(seen) < 2 {
+ t.Fatalf("six hosts all chose %v: the tie-break is not per host", seen)
+ }
+}
diff --git a/internal/console/boxchoice.go b/internal/console/boxchoice.go
index 32d8dea64..5c15f0771 100644
--- a/internal/console/boxchoice.go
+++ b/internal/console/boxchoice.go
@@ -1,122 +1,46 @@
package console
import (
- "crypto/sha256"
- "encoding/hex"
- "slices"
- "sort"
-
- "github.com/carlosframework/platform/internal/boxid"
+ "github.com/carlosframework/platform/internal/boxchoice"
)
-// boxCandidate is one box that may be named as a host's executor (region HA
-// spec 1 §2, "eligible box").
-type boxCandidate struct {
- BoxID string
- Region string
- MemFreeMiB, DiskFreeMiB int64
- Awake int
-}
+// The box eligibility predicate and the choice live in internal/boxchoice,
+// the contract console and moves share. These names keep console's own
+// vocabulary.
+type (
+ boxCandidate = boxchoice.Candidate
+ rosterBox = boxchoice.RosterBox
+ boxLoad = boxchoice.Load
+)
-// rosterBox is what the roster (control/host-actors.json, claim D7) says
-// about one active box: the placement it is enrolled in and the cloud
-// identity enrollment proved from its boxid record (spec §5.1, H11). The
-// caller passes active entries only.
-type rosterBox struct {
- Placement string
- Cloud boxid.Cloud
+// eligibleBoxes is boxchoice.Eligible on console's edges snapshot. The
+// snapshot is complete only when both its files and its liveness are
+// (edgesSnapshot.complete).
+func eligibleBoxes(snap edgesSnapshot, roster map[string]rosterBox, placement, awsRegion, homeName, expectedFP string, lost map[string]bool) []boxCandidate {
+ return boxchoice.Eligible(boxSnapshot(snap), roster, placement, awsRegion, homeName, expectedFP, lost)
}
-// eligibleBoxes is regionCandidates' predicate applied to one box at a time,
-// plus what a multi-box region adds: a roster entry for the file's box_id,
-// enrolled in this placement, whose proven cloud is in awsRegion (the AWS
-// region the roster records for this placement), the HA capability, not
-// draining, not lost. Two boxes that each satisfy half of it never add up
-// to one. The edges file is evidence of liveness and configuration only:
-// identity and region come from the roster (spec §5.1).
-//
-// An incomplete snapshot offers nothing: an unread file could be the one
-// box that would change the answer.
-func eligibleBoxes(snap edgesSnapshot, roster map[string]rosterBox, placement, awsRegion, homeName, expectedFP string, lost map[string]bool) []boxCandidate {
- if !snap.complete() || expectedFP == "" || awsRegion == "" {
- return nil
- }
- home := func(code string) string {
- if homeName != "" && code == homeName {
- return ""
- }
- return code
- }
- var out []boxCandidate
+// boxSnapshot carries every field the predicate reads. A field dropped here
+// would silently read as absent, so console's eligibility tests run through
+// this conversion.
+func boxSnapshot(snap edgesSnapshot) boxchoice.Snapshot {
+ out := boxchoice.Snapshot{Complete: snap.complete(), Boxes: make([]boxchoice.Box, 0, len(snap.Files))}
for _, f := range snap.Files {
- want := home(placement)
- rb, ok := roster[f.BoxID]
- switch {
- case home(f.Placement) != want:
- case !f.Fresh, f.EIP == "", f.BoxID == "":
- case !ok || !rb.Cloud.Complete():
- case home(rb.Placement) != want:
- // The file's placement is the box's own word; the roster's is
- // the enrollment's. Both must agree.
- case rb.Cloud.Region != awsRegion, f.Region != awsRegion:
- // The AWS region was proven at enrollment: a box enrolled in
- // another region is not in this placement, whatever its file
- // says.
- case !isTrue(f.HibernationConfigured), !isTrue(f.ServesSharedPool), !isTrue(f.AcceptsInbound):
- case f.SecretsKeyFP != expectedFP:
- case !slices.Contains(f.Capabilities, "region-ha-1"):
- case f.Draining, lost[f.BoxID]:
- default:
- c := boxCandidate{BoxID: f.BoxID, Region: rb.Cloud.Region}
- if f.Headroom != nil {
- c.MemFreeMiB, c.DiskFreeMiB, c.Awake = f.Headroom.MemFreeMiB, f.Headroom.DiskFreeMiB, f.Headroom.Awake
- }
- out = append(out, c)
+ b := boxchoice.Box{
+ Box: f.Box, BoxID: f.BoxID, EIP: f.EIP, Region: f.Region, Placement: f.Placement,
+ HibernationConfigured: f.HibernationConfigured, ServesSharedPool: f.ServesSharedPool,
+ AcceptsInbound: f.AcceptsInbound, SecretsKeyFP: f.SecretsKeyFP, Capabilities: f.Capabilities,
+ Draining: f.Draining, Fresh: f.Fresh,
}
+ if f.Headroom != nil {
+ b.Headroom = &boxchoice.Headroom{MemFreeMiB: f.Headroom.MemFreeMiB, DiskFreeMiB: f.Headroom.DiskFreeMiB, Awake: f.Headroom.Awake}
+ }
+ out.Boxes = append(out.Boxes, b)
}
- sort.Slice(out, func(i, j int) bool { return out[i].BoxID < out[j].BoxID })
return out
}
-// boxLoad is what claims already place on a box.
-type boxLoad struct {
- Hosts int
- Apps map[string]int // "acct/app" -> hosts of that app
-}
-
-// chooseBox is spec 1 §5.2's choose() until spec 2 (H12): prefer a box
-// that runs no instance of this app (soft spread: it never refuses), then
-// the fewest hosts overall, then the greatest hexadecimal SHA-256 of
-// host + "\x00" + box reference (claim P5-D2), then the smallest reference
-// if two hashes are equal.
+// chooseBox is boxchoice.Choose.
func chooseBox(host string, cands []boxCandidate, running map[string]boxLoad, app string) (boxCandidate, bool) {
- if len(cands) == 0 {
- return boxCandidate{}, false
- }
- tie := func(ref string) string {
- sum := sha256.Sum256([]byte(host + "\x00" + ref))
- return hex.EncodeToString(sum[:])
- }
- best := cands[0]
- for _, c := range cands[1:] {
- cl, bl := running[c.BoxID], running[best.BoxID]
- cRuns, bRuns := cl.Apps[app] > 0, bl.Apps[app] > 0
- switch {
- case cRuns != bRuns:
- if !cRuns {
- best = c
- }
- case cl.Hosts != bl.Hosts:
- if cl.Hosts < bl.Hosts {
- best = c
- }
- case tie(c.BoxID) != tie(best.BoxID):
- if tie(c.BoxID) > tie(best.BoxID) {
- best = c
- }
- case c.BoxID < best.BoxID:
- best = c
- }
- }
- return best, true
+ return boxchoice.Choose(host, cands, running, app)
}
diff --git a/internal/console/boxchoice_test.go b/internal/console/boxchoice_test.go
index b2aa98c6d..be3e1a485 100644
--- a/internal/console/boxchoice_test.go
+++ b/internal/console/boxchoice_test.go
@@ -1,21 +1,23 @@
package console
import (
- "crypto/sha256"
- "encoding/hex"
"encoding/json"
"testing"
"github.com/carlosframework/platform/internal/boxid"
)
+// The predicate and the choice are tested in internal/boxchoice. These
+// tests hold console's conversion to it: every field the predicate reads
+// must survive boxSnapshot, or a box would silently read as missing it.
+
// haBox is an eligible box's edges file in placement "uk".
func haBox(id string, mut func(m map[string]any)) string {
m := map[string]any{
"v": 1, "box": id, "box_id": id, "eip": "1.2.3.4", "region": "eu-west-2", "placement": "uk",
"hosts": map[string]any{}, "hibernation_configured": true, "serves_shared_pool": true,
"accepts_inbound": true, "secrets_key_fp": testKeyFP, "capabilities": []string{"region-ha-1"},
- "headroom": map[string]any{"mem_free_mib": 1000, "disk_free_mib": 1000, "awake": 1},
+ "headroom": map[string]any{"mem_free_mib": 1000, "disk_free_mib": 2000, "awake": 3},
}
if mut != nil {
mut(m)
@@ -41,7 +43,7 @@ func rosterFor(boxes ...string) map[string]rosterBox {
return out
}
-func TestEligibleBoxesAppliesThePredicateOneBoxAtATime(t *testing.T) {
+func TestEligibleBoxesCarriesEveryFieldThePredicateReads(t *testing.T) {
del := func(k string) func(map[string]any) { return func(m map[string]any) { delete(m, k) } }
set := func(k string, v any) func(map[string]any) { return func(m map[string]any) { m[k] = v } }
cases := []struct {
@@ -59,6 +61,7 @@ func TestEligibleBoxesAppliesThePredicateOneBoxAtATime(t *testing.T) {
{"no capability", del("capabilities"), false},
{"draining", set("draining", true), false},
{"another placement", set("placement", "us"), false},
+ {"another region", set("region", "us-east-1"), false},
{"box_id names no roster entry", set("box_id", "i-0zzz"), false},
}
for _, tc := range cases {
@@ -70,141 +73,34 @@ func TestEligibleBoxesAppliesThePredicateOneBoxAtATime(t *testing.T) {
}
})
}
-}
-
-func TestEligibleBoxesNeedsARosterEntryFreshnessAndNotLost(t *testing.T) {
- snap := views(t, haBox("i-0aaa", nil), haBox("i-0bbb", nil), haBox("i-0ccc", nil))
- snap = markStale(snap, "i-0bbb")
- got := eligibleBoxes(snap, rosterFor("i-0aaa", "i-0bbb"), "uk", "eu-west-2", "ie", testKeyFP, nil)
- if len(got) != 1 || got[0].BoxID != "i-0aaa" {
- t.Fatalf("got %v, want only i-0aaa (i-0bbb is stale, i-0ccc has no roster entry)", ids(got))
- }
- got = eligibleBoxes(snap, rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, map[string]bool{"i-0aaa": true})
- if len(got) != 0 {
- t.Fatalf("a lost box is eligible: %v", ids(got))
+ got := eligibleBoxes(views(t, haBox("i-0aaa", nil)), rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, nil)
+ if want := (boxCandidate{BoxID: "i-0aaa", Region: "eu-west-2", MemFreeMiB: 1000, DiskFreeMiB: 2000, Awake: 3}); len(got) != 1 || got[0] != want {
+ t.Fatalf("got %+v, want %+v", got, want)
}
-}
-
-// TestEligibilityNeverCombinesBoxes is nearest-region's rule for boxes: one
-// box holding the key and another accepting inbound do not add up to one.
-func TestEligibleBoxesNeverCombinesTwoHalfBoxes(t *testing.T) {
- snap := views(t,
- haBox("i-0aaa", func(m map[string]any) { m["secrets_key_fp"] = "" }),
- haBox("i-0bbb", func(m map[string]any) { m["accepts_inbound"] = false }))
- if got := eligibleBoxes(snap, rosterFor("i-0aaa", "i-0bbb"), "uk", "eu-west-2", "ie", testKeyFP, nil); len(got) != 0 {
- t.Fatalf("got %v, want none", ids(got))
+ if got := eligibleBoxes(markStale(views(t, haBox("i-0aaa", nil)), "i-0aaa"), rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, nil); len(got) != 0 {
+ t.Fatalf("a stale box is eligible: %v", ids(got))
}
}
+// Both halves of console's completeness carry over: an unread edges file,
+// and unknown liveness.
func TestEligibleBoxesAnIncompleteSnapshotOffersNothing(t *testing.T) {
- snap := views(t, haBox("i-0aaa", nil))
- snap.Complete = false
- if got := eligibleBoxes(snap, rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, nil); len(got) != 0 {
- t.Fatalf("got %v from an incomplete snapshot", ids(got))
- }
-}
-
-func TestEligibleBoxesHomePlacementMatchesTheEmptyCode(t *testing.T) {
- home := haBox("i-0aaa", func(m map[string]any) { m["placement"] = "" })
- named := haBox("i-0bbb", func(m map[string]any) { m["placement"] = "ie" })
- snap := views(t, home, named)
- roster := rosterFor("i-0aaa", "i-0bbb")
- for _, b := range []string{"i-0aaa", "i-0bbb"} {
- rb := roster[b]
- rb.Placement = "ie"
- roster[b] = rb
- }
- got := eligibleBoxes(snap, roster, "", "eu-west-2", "ie", testKeyFP, nil)
- if len(got) != 2 {
- t.Fatalf("got %v, want both home boxes", ids(got))
+ for _, bend := range []func(*edgesSnapshot){
+ func(s *edgesSnapshot) { s.Complete = false },
+ func(s *edgesSnapshot) { s.BoxesComplete = false },
+ } {
+ snap := views(t, haBox("i-0aaa", nil))
+ bend(&snap)
+ if got := eligibleBoxes(snap, rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, nil); len(got) != 0 {
+ t.Fatalf("got %v from an incomplete snapshot", ids(got))
+ }
}
}
-func TestChooseBox(t *testing.T) {
+func TestChooseBoxIsBoxchoiceChoose(t *testing.T) {
a, b := boxCandidate{BoxID: "i-0aaa"}, boxCandidate{BoxID: "i-0bbb"}
- load := func(hosts int, apps ...string) boxLoad {
- l := boxLoad{Hosts: hosts, Apps: map[string]int{}}
- for _, x := range apps {
- l.Apps[x]++
- }
- return l
- }
- const host = "shop.bab.oncarlos.com"
- if _, ok := chooseBox(host, nil, nil, "bab/jam"); ok {
- t.Fatal("chose from nothing")
- }
- got, _ := chooseBox(host, []boxCandidate{a, b}, map[string]boxLoad{"i-0aaa": load(5), "i-0bbb": load(2)}, "bab/jam")
- if got.BoxID != "i-0bbb" {
- t.Fatalf("fewest hosts: got %s", got.BoxID)
- }
- got, _ = chooseBox(host, []boxCandidate{a, b}, map[string]boxLoad{"i-0aaa": load(5), "i-0bbb": load(2, "bab/jam")}, "bab/jam")
- if got.BoxID != "i-0aaa" {
- t.Fatalf("spread beats load: got %s", got.BoxID)
- }
- got, ok := chooseBox(host, []boxCandidate{a, b}, map[string]boxLoad{"i-0aaa": load(5, "bab/jam"), "i-0bbb": load(2, "bab/jam", "bab/jam")}, "bab/jam")
+ got, ok := chooseBox("shop.bab.oncarlos.com", []boxCandidate{a, b}, map[string]boxLoad{"i-0aaa": {Hosts: 5}, "i-0bbb": {Hosts: 2}}, "bab/jam")
if !ok || got.BoxID != "i-0bbb" {
- t.Fatalf("every box runs the app: fewest hosts overall, never a refusal; got %s ok=%v", got.BoxID, ok)
- }
-}
-
-// H12, claim P5-D2: an equal load breaks by the greatest SHA-256 of
-// host + "\x00" + reference, so the choice differs host by host instead
-// of always landing on the lowest name, and does not depend on the order
-// the candidates arrive in.
-func TestChooseBoxBreaksATieByTheHostsHash(t *testing.T) {
- cands := []boxCandidate{{BoxID: "annie-welding"}, {BoxID: "bert-sawing"}, {BoxID: "cleo-mixing"}}
- want := func(host string) string {
- best, bestSum := "", ""
- for _, c := range cands {
- sum := sha256.Sum256([]byte(host + "\x00" + c.BoxID))
- if h := hex.EncodeToString(sum[:]); h > bestSum {
- best, bestSum = c.BoxID, h
- }
- }
- return best
- }
- seen := map[string]bool{}
- for _, host := range []string{"a.bab.oncarlos.com", "b.bab.oncarlos.com", "c.bab.oncarlos.com", "d.bab.oncarlos.com", "e.bab.oncarlos.com", "f.bab.oncarlos.com"} {
- forward, _ := chooseBox(host, cands, nil, "bab/jam")
- backward, _ := chooseBox(host, []boxCandidate{cands[2], cands[1], cands[0]}, nil, "bab/jam")
- if forward.BoxID != want(host) || backward.BoxID != want(host) {
- t.Fatalf("%s: chose %s and %s, want %s", host, forward.BoxID, backward.BoxID, want(host))
- }
- seen[forward.BoxID] = true
- }
- if len(seen) < 2 {
- t.Fatalf("six hosts all chose %v: the tie-break is not per host", seen)
- }
-}
-
-// A box enrolled in another AWS region than the placement's is not in that
-// placement, even when its own file says it is.
-func TestEligibleBoxesRefusesABoxEnrolledInAnotherRegion(t *testing.T) {
- snap := views(t, haBox("i-0aaa", nil), haBox("i-0bbb", nil))
- roster := rosterFor("i-0aaa", "i-0bbb")
- roster["i-0bbb"] = rosterBox{Placement: "uk", Cloud: boxid.Cloud{Provider: "aws", Account: "1", Region: "us-east-1", InstanceID: "i-0bbb"}}
- got := eligibleBoxes(snap, roster, "uk", "eu-west-2", "ie", testKeyFP, nil)
- if len(got) != 1 || got[0].BoxID != "i-0aaa" {
- t.Fatalf("got %v, want only i-0aaa", ids(got))
- }
- // And a file that advertises another AWS region than the one expected.
- snap = views(t, haBox("i-0aaa", func(m map[string]any) { m["region"] = "us-east-1" }))
- if got := eligibleBoxes(snap, rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, nil); len(got) != 0 {
- t.Fatalf("got %v, want none", ids(got))
- }
- if got := eligibleBoxes(views(t, haBox("i-0aaa", nil)), rosterFor("i-0aaa"), "uk", "", "ie", testKeyFP, nil); len(got) != 0 {
- t.Fatalf("got %v with no expected region", ids(got))
- }
-}
-
-// The roster's enrolled placement is the enrollment's word and must agree
-// with the file's; an incomplete cloud is no identity at all.
-func TestEligibleBoxesNeedsTheRosterToAgree(t *testing.T) {
- snap := views(t, haBox("i-0aaa", nil), haBox("i-0bbb", nil))
- roster := rosterFor("i-0aaa", "i-0bbb")
- roster["i-0aaa"] = rosterBox{Placement: "us", Cloud: roster["i-0aaa"].Cloud}
- roster["i-0bbb"] = rosterBox{Placement: "uk", Cloud: boxid.Cloud{Provider: "aws", Region: "eu-west-2", InstanceID: "i-0bbb"}}
- if got := eligibleBoxes(snap, roster, "uk", "eu-west-2", "ie", testKeyFP, nil); len(got) != 0 {
- t.Fatalf("got %v, want none", ids(got))
+ t.Fatalf("got %s ok=%v, want i-0bbb", got.BoxID, ok)
}
}
diff --git a/internal/hostfiles/contract.go b/internal/hostfiles/contract.go
new file mode 100644
index 000000000..b08b2fcd5
--- /dev/null
+++ b/internal/hostfiles/contract.go
@@ -0,0 +1,142 @@
+package hostfiles
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// This file holds the entry points other packages call: region HA's claim
+// (CopyManifest, at a new generation's admission) and moves (Publish, at
+// install). Both are dormant: nothing in a production path calls them yet.
+
+// ErrManifestExists means CopyManifest's target already holds a different
+// manifest. The copy wrote nothing.
+var ErrManifestExists = errors.New("hostfiles: the target generation already has a manifest")
+
+// ErrSourceMoved means CopyManifest's source is not at the Seq the caller
+// expected. The copy wrote nothing.
+var ErrSourceMoved = errors.New("hostfiles: the source manifest is not at the expected seq")
+
+// CopyManifest establishes toPrefix's manifest as an exact copy of
+// fromPrefix's: the same sequence number and the same entries, each still
+// naming its object by its origin-qualified key. No object is copied; the
+// new generation's publisher continues the sequence from the copy and
+// writes new objects under toPrefix only.
+//
+// origins are the prefixes the source's entries may reference, as for
+// Restore (nil or empty: fromPrefix alone). A source built by earlier
+// copies references earlier generations, so its caller passes the chain
+// its pin admits. Every entry is validated before anything is written; an
+// absent source is os.ErrNotExist, never an empty copy.
+//
+// expectSeq, when not 0, is the source revision the caller admitted. A
+// source at any other Seq is ErrSourceMoved and nothing is written, on a
+// first attempt or a retry. 0 copies whatever the source holds.
+//
+// The write is create-only. A target that already holds exactly the
+// manifest this call would write (a repeat whose first response was lost)
+// is success; any other target is ErrManifestExists and is left as it was.
+// That retry-safety holds only while the source cannot commit between
+// attempts: the source generation must be fenced or frozen, as the claim's
+// admission makes it. Against a live source a retry may find the target
+// holding an older revision and report ErrManifestExists; with expectSeq
+// set it reports ErrSourceMoved instead, and never copies a revision the
+// caller did not admit.
+func CopyManifest(ctx context.Context, s release.Store, fromPrefix, toPrefix string, expectSeq int64, origins []string) (Manifest, error) {
+ if err := checkPrefix(fromPrefix); err != nil {
+ return Manifest{}, err
+ }
+ if err := checkPrefix(toPrefix); err != nil {
+ return Manifest{}, err
+ }
+ if fromPrefix == toPrefix {
+ return Manifest{}, fmt.Errorf("hostfiles: copy %s onto itself", fromPrefix)
+ }
+ origins, err := admitted(fromPrefix, origins)
+ if err != nil {
+ return Manifest{}, err
+ }
+ m, _, found, err := load(ctx, s, fromPrefix)
+ if err != nil {
+ return Manifest{}, err
+ }
+ if !found {
+ return Manifest{}, fmt.Errorf("hostfiles: copy from %s: %w", fromPrefix, os.ErrNotExist)
+ }
+ if expectSeq != 0 && m.Seq != expectSeq {
+ return Manifest{}, fmt.Errorf("%w: %s is at seq %d, not %d", ErrSourceMoved, fromPrefix, m.Seq, expectSeq)
+ }
+ if err := checkManifest(m, origins); err != nil {
+ return Manifest{}, err
+ }
+ body, err := json.Marshal(m)
+ if err != nil {
+ return Manifest{}, fmt.Errorf("hostfiles: marshal manifest: %w", err)
+ }
+ if _, err := s.Put(ctx, ManifestKey(toPrefix), body, ""); errors.Is(err, release.ErrCASConflict) {
+ cur, _, gerr := s.Get(ctx, ManifestKey(toPrefix))
+ if gerr != nil {
+ return Manifest{}, gerr
+ }
+ if !bytes.Equal(cur, body) {
+ return Manifest{}, fmt.Errorf("%w: %s", ErrManifestExists, toPrefix)
+ }
+ } else if err != nil {
+ return Manifest{}, err
+ }
+ return m, nil
+}
+
+// Publish records dir as prefix's manifest in one revision and returns
+// that revision's sequence number: one SyncDir and one Commit on a fresh
+// Publisher. It is the call moves makes at install, after the directory is
+// in place and before the installation is recorded.
+//
+// skip is SyncDir's: it excludes a relative path and, for a directory,
+// everything under it. On success every regular file in dir that skip does
+// not exclude is in the manifest: published, or recorded as skipped when
+// it is larger than MaxFileBytes (st.TooLarge). Anything that is not a
+// regular file is left out and listed in st.NotRegular.
+//
+// Publish is an install-time contract, so it commits a complete and
+// consistent image or nothing. It returns an error, and commits nothing,
+// when any file could not be read (st.Unreadable), was replaced between
+// the look and the open (st.Retry), or changed while it was read
+// (st.Torn). The manifest would otherwise lack the file or hold bytes that
+// were never the file's state. The caller stops writers and calls Publish
+// again. Objects uploaded by a refused attempt are referenced by no
+// manifest.
+//
+// A prefix that already has a manifest is continued, not replaced: a
+// repeat with nothing changed returns the same sequence number. Another
+// writer's manifest appearing between Open and Commit is ErrSuperseded.
+// Platform items (wake state, an env file) are not part of this call; a
+// caller that needs them in the same revision uses Open, StagePlatform,
+// SyncDir and Commit itself.
+func Publish(ctx context.Context, s release.Store, prefix, dir string, skip func(rel string, isDir bool) bool) (int64, SyncStats, error) {
+ p, err := Open(ctx, s, prefix)
+ if err != nil {
+ return 0, SyncStats{}, err
+ }
+ st, err := SyncDir(ctx, p, dir, skip)
+ if err != nil {
+ return 0, st, err
+ }
+ if len(st.Unreadable) > 0 {
+ return 0, st, fmt.Errorf("hostfiles: publish %s: %d file(s) could not be read: %q", dir, len(st.Unreadable), st.Unreadable)
+ }
+ if len(st.Retry) > 0 {
+ return 0, st, fmt.Errorf("hostfiles: publish %s: %d file(s) were replaced while being looked at: %q", dir, len(st.Retry), st.Retry)
+ }
+ if len(st.Torn) > 0 {
+ return 0, st, fmt.Errorf("hostfiles: publish %s: %d file(s) changed while being read: %q", dir, len(st.Torn), st.Torn)
+ }
+ seq, err := p.Commit(ctx)
+ return seq, st, err
+}
diff --git a/internal/hostfiles/hostfiles_test.go b/internal/hostfiles/hostfiles_test.go
index ca1135232..366d53543 100644
--- a/internal/hostfiles/hostfiles_test.go
+++ b/internal/hostfiles/hostfiles_test.go
@@ -58,7 +58,7 @@ func TestPublishThenRestoreRoundTrips(t *testing.T) {
t.Fatalf("seq = %d, want 2", seq)
}
dir := t.TempDir()
- m, st, err := Restore(ctx, s, pfx, dir)
+ m, st, err := Restore(ctx, s, pfx, dir, nil)
if err != nil {
t.Fatal(err)
}
@@ -85,7 +85,7 @@ func TestALaterWriteWinsAndAnUnchangedOneTakesNoSequence(t *testing.T) {
t.Fatalf("seq = %d, want 2", seq)
}
dir := t.TempDir()
- if _, _, err := Restore(ctx, s, pfx, dir); err != nil {
+ if _, _, err := Restore(ctx, s, pfx, dir, nil); err != nil {
t.Fatal(err)
}
if got := read(t, dir, "instance.key"); got != "new" {
@@ -105,7 +105,7 @@ func TestALateUploadCannotResurrectOldContent(t *testing.T) {
t.Fatal(err)
}
dir := t.TempDir()
- if _, _, err := Restore(ctx, s, pfx, dir); err != nil {
+ if _, _, err := Restore(ctx, s, pfx, dir, nil); err != nil {
t.Fatal(err)
}
if got := read(t, dir, "instance.key"); got != "new" {
@@ -120,14 +120,14 @@ func TestADeleteIsATombstoneAndRestoreRemovesTheFile(t *testing.T) {
stage(t, p, "b.txt", "b")
commit(t, p)
dir := t.TempDir()
- if _, _, err := Restore(ctx, s, pfx, dir); err != nil {
+ if _, _, err := Restore(ctx, s, pfx, dir, nil); err != nil {
t.Fatal(err)
}
if err := p.StageDelete("a.txt"); err != nil {
t.Fatal(err)
}
commit(t, p)
- _, st, err := Restore(ctx, s, pfx, dir)
+ _, st, err := Restore(ctx, s, pfx, dir, nil)
if err != nil {
t.Fatal(err)
}
@@ -152,7 +152,7 @@ func TestASecondWriterIsSupersededNotMerged(t *testing.T) {
t.Fatalf("err = %v, want ErrSuperseded", err)
}
dir := t.TempDir()
- if _, _, err := Restore(context.Background(), s, pfx, dir); err != nil {
+ if _, _, err := Restore(context.Background(), s, pfx, dir, nil); err != nil {
t.Fatal(err)
}
if got := read(t, dir, "x"); got != "from a" {
@@ -193,7 +193,7 @@ func TestStageAndRestoreRefuseAnEscapingPath(t *testing.T) {
t.Fatal("Stage accepted ../evil")
}
// A manifest written by something else with a hostile path.
- body := `{"v":1,"seq":1,"entries":{"../evil":{"object":"objects/0000000000000001-aa","sha256":"aa","seq":1}},"platform":{}}`
+ body := `{"v":2,"seq":1,"entries":{"../evil":{"object":"objects/0000000000000001-aa","sha256":"aa","seq":1}},"platform":{}}`
hostile := "instances/h/i/files/g/"
if _, err := s.Put(ctx, ManifestKey(hostile), []byte(body), ""); err != nil {
t.Fatal(err)
@@ -203,7 +203,7 @@ func TestStageAndRestoreRefuseAnEscapingPath(t *testing.T) {
if err := os.Mkdir(dir, 0o755); err != nil {
t.Fatal(err)
}
- if _, _, err := Restore(ctx, s, hostile, dir); err == nil {
+ if _, _, err := Restore(ctx, s, hostile, dir, nil); err == nil {
t.Fatal("Restore accepted a manifest with ../evil")
}
if _, err := os.Stat(filepath.Join(parent, "evil")); !os.IsNotExist(err) {
@@ -220,18 +220,18 @@ func TestRestoreRefusesContentThatDoesNotMatchItsHash(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- key := pfx + m.Entries["x"].Object
+ key := m.Entries["x"].Object
_, etag, _ := s.Get(ctx, key)
if _, err := s.Put(ctx, key, []byte("tampered"), etag); err != nil {
t.Fatal(err)
}
- if _, _, err := Restore(ctx, s, pfx, t.TempDir()); err == nil || !strings.Contains(err.Error(), "sha256") {
+ if _, _, err := Restore(ctx, s, pfx, t.TempDir(), nil); err == nil || !strings.Contains(err.Error(), "sha256") {
t.Fatalf("err = %v, want a sha256 mismatch", err)
}
}
func TestAnAbsentManifestRestoresNothing(t *testing.T) {
- m, st, err := Restore(context.Background(), release.NewDirStore(t.TempDir()), pfx, t.TempDir())
+ m, st, err := Restore(context.Background(), release.NewDirStore(t.TempDir()), pfx, t.TempDir(), nil)
if err != nil || m.Seq != 0 || st.Written != 0 {
t.Fatalf("m=%+v st=%+v err=%v", m, st, err)
}
@@ -245,18 +245,18 @@ func TestPlatformItemsAreNotWrittenIntoTheFilesDirectory(t *testing.T) {
}
commit(t, p)
dir := t.TempDir()
- m, _, err := Restore(ctx, s, pfx, dir)
+ m, _, err := Restore(ctx, s, pfx, dir, nil)
if err != nil {
t.Fatal(err)
}
if entries, _ := os.ReadDir(dir); len(entries) != 0 {
t.Fatalf("files directory has %d entries, want 0", len(entries))
}
- got, err := ReadPlatform(ctx, s, pfx, m, "wake.json")
+ got, err := ReadPlatform(ctx, s, pfx, m, "wake.json", nil)
if err != nil || string(got) != `{"v":1}` {
t.Fatalf("ReadPlatform = %q, %v", got, err)
}
- if _, err := ReadPlatform(ctx, s, pfx, m, "absent"); !errors.Is(err, os.ErrNotExist) {
+ if _, err := ReadPlatform(ctx, s, pfx, m, "absent", nil); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("absent item: err = %v, want os.ErrNotExist", err)
}
}
@@ -269,7 +269,7 @@ func TestASkippedFileIsRecordedAndReportedOnRestore(t *testing.T) {
t.Fatal(err)
}
commit(t, p)
- _, st, err := Restore(ctx, s, pfx, t.TempDir())
+ _, st, err := Restore(ctx, s, pfx, t.TempDir(), nil)
if err != nil {
t.Fatal(err)
}
@@ -300,7 +300,7 @@ func TestRestoreNeverWritesOrDeletesThroughASymlink(t *testing.T) {
t.Fatal(err)
}
}
- if _, _, err := Restore(ctx, s, pfx, dir); err != nil {
+ if _, _, err := Restore(ctx, s, pfx, dir, nil); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(outside, "x")); !os.IsNotExist(err) {
@@ -323,7 +323,7 @@ func TestRestoreHandlesADirectoryBecomingAFileAndBack(t *testing.T) {
stage(t, p, "a/b", "child")
commit(t, p)
prior := t.TempDir() // a copy restored while "a" was a directory
- if _, _, err := Restore(ctx, s, pfx, prior); err != nil {
+ if _, _, err := Restore(ctx, s, pfx, prior, nil); err != nil {
t.Fatal(err)
}
@@ -334,7 +334,7 @@ func TestRestoreHandlesADirectoryBecomingAFileAndBack(t *testing.T) {
stage(t, p, "a", "now a file")
commit(t, p)
for name, dir := range map[string]string{"an empty destination": t.TempDir(), "a previously restored one": prior} {
- if _, _, err := Restore(ctx, s, pfx, dir); err != nil {
+ if _, _, err := Restore(ctx, s, pfx, dir, nil); err != nil {
t.Fatalf("%s: %v", name, err)
}
if got := read(t, dir, "a"); got != "now a file" {
@@ -349,7 +349,7 @@ func TestRestoreHandlesADirectoryBecomingAFileAndBack(t *testing.T) {
stage(t, p, "a/c", "child again")
commit(t, p)
for name, dir := range map[string]string{"an empty destination": t.TempDir(), "a previously restored one": prior} {
- if _, _, err := Restore(ctx, s, pfx, dir); err != nil {
+ if _, _, err := Restore(ctx, s, pfx, dir, nil); err != nil {
t.Fatalf("%s: %v", name, err)
}
if got := read(t, dir, "a/c"); got != "child again" {
@@ -363,7 +363,7 @@ func TestRestoreIntoAMissingDirectoryIsAnError(t *testing.T) {
p := open(t, s)
stage(t, p, "x", "x")
commit(t, p)
- if _, _, err := Restore(ctx, s, pfx, filepath.Join(t.TempDir(), "absent")); err == nil {
+ if _, _, err := Restore(ctx, s, pfx, filepath.Join(t.TempDir(), "absent"), nil); err == nil {
t.Fatal("restored into a directory that does not exist")
}
}
@@ -378,7 +378,7 @@ func TestRestoreSyncsEveryDirectoryItChangedBeforeReturning(t *testing.T) {
stage(t, p, "gone/old.txt", "o")
commit(t, p)
dir := t.TempDir()
- if _, _, err := Restore(ctx, s, pfx, dir); err != nil {
+ if _, _, err := Restore(ctx, s, pfx, dir, nil); err != nil {
t.Fatal(err)
}
if err := p.StageDelete("gone/old.txt"); err != nil {
@@ -399,7 +399,7 @@ func TestRestoreSyncsEveryDirectoryItChangedBeforeReturning(t *testing.T) {
synced = append(synced, rel)
return orig(root, rel)
}
- if _, _, err := Restore(ctx, s, pfx, dir); err != nil {
+ if _, _, err := Restore(ctx, s, pfx, dir, nil); err != nil {
t.Fatal(err)
}
want := []string{"a/b", "a", "gone", "."}
@@ -408,7 +408,7 @@ func TestRestoreSyncsEveryDirectoryItChangedBeforeReturning(t *testing.T) {
}
syncDir = func(*os.Root, string) error { return errors.New("disk says no") }
- if _, _, err := Restore(ctx, s, pfx, dir); err == nil || !strings.Contains(err.Error(), "disk says no") {
+ if _, _, err := Restore(ctx, s, pfx, dir, nil); err == nil || !strings.Contains(err.Error(), "disk says no") {
t.Fatalf("err = %v; a failed directory sync must fail the restore", err)
}
}
@@ -422,7 +422,7 @@ func TestARetriedRestoreStillSyncsADeletionAnEarlierAttemptMade(t *testing.T) {
stage(t, p, "gone/old.txt", "o")
commit(t, p)
dir := t.TempDir()
- if _, _, err := Restore(ctx, s, pfx, dir); err != nil {
+ if _, _, err := Restore(ctx, s, pfx, dir, nil); err != nil {
t.Fatal(err)
}
if err := p.StageDelete("gone/old.txt"); err != nil {
@@ -433,7 +433,7 @@ func TestARetriedRestoreStillSyncsADeletionAnEarlierAttemptMade(t *testing.T) {
orig := syncDir
t.Cleanup(func() { syncDir = orig })
syncDir = func(*os.Root, string) error { return errors.New("disk says no") }
- if _, _, err := Restore(ctx, s, pfx, dir); err == nil {
+ if _, _, err := Restore(ctx, s, pfx, dir, nil); err == nil {
t.Fatal("the first attempt must fail")
}
if _, err := os.Stat(filepath.Join(dir, "gone", "old.txt")); !os.IsNotExist(err) {
@@ -441,7 +441,7 @@ func TestARetriedRestoreStillSyncsADeletionAnEarlierAttemptMade(t *testing.T) {
}
var synced []string
syncDir = func(root *os.Root, rel string) error { synced = append(synced, rel); return orig(root, rel) }
- _, st, err := Restore(ctx, s, pfx, dir)
+ _, st, err := Restore(ctx, s, pfx, dir, nil)
if err != nil {
t.Fatal(err)
}
@@ -462,7 +462,7 @@ func TestRestoreHandlesADeepDirectoryBecomingAFile(t *testing.T) {
stage(t, p, "a/b/c", "leaf")
commit(t, p)
dir := t.TempDir()
- if _, _, err := Restore(ctx, s, pfx, dir); err != nil {
+ if _, _, err := Restore(ctx, s, pfx, dir, nil); err != nil {
t.Fatal(err)
}
if err := p.StageDelete("a/b/c"); err != nil {
@@ -477,7 +477,7 @@ func TestRestoreHandlesADeepDirectoryBecomingAFile(t *testing.T) {
syncDir = func(root *os.Root, rel string) error { synced = append(synced, rel); return orig(root, rel) }
for name, d := range map[string]string{"a previously restored destination": dir, "an empty one": t.TempDir()} {
synced = nil
- if _, _, err := Restore(ctx, s, pfx, d); err != nil {
+ if _, _, err := Restore(ctx, s, pfx, d, nil); err != nil {
t.Fatalf("%s: %v", name, err)
}
if got := read(t, d, "a"); got != "now a file" {
@@ -554,7 +554,7 @@ func TestRestoreMasksTheModeToPermissionBits(t *testing.T) {
t.Fatal(err)
}
dir := t.TempDir()
- if _, _, err := Restore(ctx, s, pfx, dir); err != nil {
+ if _, _, err := Restore(ctx, s, pfx, dir, nil); err != nil {
t.Fatal(err)
}
fi, err := os.Stat(filepath.Join(dir, "x"))
@@ -573,7 +573,7 @@ func TestOverlappingLiveEntriesKeepTheNewerOne(t *testing.T) {
stage(t, p, "a", "file") // newer than the child
commit(t, p)
dir := t.TempDir()
- _, st, err := Restore(ctx, s, pfx, dir)
+ _, st, err := Restore(ctx, s, pfx, dir, nil)
if err != nil {
t.Fatal(err)
}
@@ -586,7 +586,7 @@ func TestOverlappingLiveEntriesKeepTheNewerOne(t *testing.T) {
stage(t, q, "a/b", "child") // newer than the file
commit(t, q)
dir2 := t.TempDir()
- _, st, err = Restore(ctx, q.s, pfx, dir2)
+ _, st, err = Restore(ctx, q.s, pfx, dir2, nil)
if err != nil {
t.Fatal(err)
}
@@ -621,7 +621,7 @@ func TestThreeDeepOverlapKeepsTheNewestEntry(t *testing.T) {
stage(t, p, "a/b", "two") // the newest
commit(t, p)
dir := t.TempDir()
- _, st, err := Restore(ctx, s, pfx, dir)
+ _, st, err := Restore(ctx, s, pfx, dir, nil)
if err != nil {
t.Fatal(err)
}
@@ -719,22 +719,22 @@ func TestRestoreRefusesAnEntryWhoseObjectOrHashIsMalformedAndTouchesNothing(t *t
"uppercase sha": func(m *Manifest) {
e := m.Entries["a"]
e.SHA256 = strings.ToUpper(e.SHA256)
- e.Object = objectKey(e.Seq, e.SHA256)
+ e.Object = pfx + objectKey(e.Seq, e.SHA256)
m.Entries["a"] = e
},
"short sha": func(m *Manifest) {
e := m.Entries["a"]
- e.SHA256, e.Object = "aa", objectKey(e.Seq, "aa")
+ e.SHA256, e.Object = "aa", pfx+objectKey(e.Seq, "aa")
m.Entries["a"] = e
},
"object elsewhere": func(m *Manifest) {
e := m.Entries["a"]
- e.Object = "../../other/files/g1/" + e.Object
+ e.Object = "instances/other/files/g1/" + objectKey(e.Seq, e.SHA256)
m.Entries["a"] = e
},
"object with another seq": func(m *Manifest) {
e := m.Entries["a"]
- e.Object = objectKey(e.Seq+1, e.SHA256)
+ e.Object = pfx + objectKey(e.Seq+1, e.SHA256)
m.Entries["a"] = e
},
"platform object elsewhere": func(m *Manifest) {
@@ -745,13 +745,13 @@ func TestRestoreRefusesAnEntryWhoseObjectOrHashIsMalformedAndTouchesNothing(t *t
"platform sha not hex": func(m *Manifest) {
e := m.Platform["item"]
e.SHA256 = strings.Repeat("g", 64)
- e.Object = objectKey(e.Seq, e.SHA256)
+ e.Object = pfx + objectKey(e.Seq, e.SHA256)
m.Platform["item"] = e
},
}
for name, bend := range cases {
s, dir := putHostile(t, bend)
- if _, _, err := Restore(context.Background(), s, pfx, dir); err == nil {
+ if _, _, err := Restore(context.Background(), s, pfx, dir, nil); err == nil {
t.Fatalf("%s: Restore accepted the manifest", name)
}
if got := read(t, dir, "gone"); got != "still here" {
@@ -766,7 +766,7 @@ func TestRestoreRefusesAnEntryWhoseObjectOrHashIsMalformedAndTouchesNothing(t *t
func TestReadPlatformRefusesAMalformedEntry(t *testing.T) {
s, _ := putHostile(t, func(m *Manifest) {
e := m.Platform["item"]
- e.Object = "objects/elsewhere"
+ e.Object = pfx + "objects/elsewhere"
m.Platform["item"] = e
})
ctx := context.Background()
@@ -778,7 +778,7 @@ func TestReadPlatformRefusesAMalformedEntry(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if _, err := ReadPlatform(ctx, s, pfx, m, "item"); err == nil {
+ if _, err := ReadPlatform(ctx, s, pfx, m, "item", nil); err == nil {
t.Fatal("ReadPlatform accepted an object key that is not its own")
}
}
@@ -790,7 +790,7 @@ func TestRestoreRefusesAnEntryWhoseSizeIsOutOfRangeAndTouchesNothing(t *testing.
e.Size = size
m.Entries["a"] = e
})
- if _, _, err := Restore(context.Background(), s, pfx, dir); err == nil {
+ if _, _, err := Restore(context.Background(), s, pfx, dir, nil); err == nil {
t.Fatalf("size %d: Restore accepted the manifest", size)
}
if got := read(t, dir, "gone"); got != "still here" {
@@ -809,14 +809,14 @@ func TestFetchRefusesAnObjectWhoseLengthIsNotTheRecordedSize(t *testing.T) {
m.Platform["item"] = p
})
ctx := context.Background()
- if _, _, err := Restore(ctx, s, pfx, dir); err == nil || !strings.Contains(err.Error(), "size") {
+ if _, _, err := Restore(ctx, s, pfx, dir, nil); err == nil || !strings.Contains(err.Error(), "size") {
t.Fatalf("Restore err = %v, want a size mismatch", err)
}
m, _, err := Load(ctx, s, pfx)
if err != nil {
t.Fatal(err)
}
- if _, err := ReadPlatform(ctx, s, pfx, m, "item"); err == nil || !strings.Contains(err.Error(), "size") {
+ if _, err := ReadPlatform(ctx, s, pfx, m, "item", nil); err == nil || !strings.Contains(err.Error(), "size") {
t.Fatalf("ReadPlatform err = %v, want a size mismatch", err)
}
}
diff --git a/internal/hostfiles/manifest.go b/internal/hostfiles/manifest.go
index 80111a6ee..bf441538a 100644
--- a/internal/hostfiles/manifest.go
+++ b/internal/hostfiles/manifest.go
@@ -23,6 +23,13 @@ import (
// rather than read. Plan 1b lifts this with a streaming put.
const MaxFileBytes = 64 << 20
+// Entry is one path's state. Object is origin-qualified: the object's full
+// key from the bucket root, "<origin>objects/<seq>-<sha256>", where origin
+// is the files prefix of the generation that wrote it. A manifest copied
+// into a later generation keeps every Object as it was, so the copy names
+// the same immutable objects and nothing is uploaded again (claim L13).
+// Readers resolve Object exactly as written, never against their own
+// prefix, and only within the origins their caller admits.
type Entry struct {
Object string `json:"object,omitempty"`
SHA256 string `json:"sha256,omitempty"`
@@ -34,6 +41,19 @@ type Entry struct {
Skipped string `json:"skipped,omitempty"`
}
+// ManifestVersion is the manifest format this binary reads and writes.
+//
+// Version 2 made Entry.Object origin-qualified (a full key) where version
+// 1 held a key relative to the manifest's prefix. That changes what an
+// existing field means; it adds nothing a reader could ignore. A version 1
+// reader would join a qualified key to its own prefix and look in the
+// wrong place, and a version 1 manifest copied to another generation
+// would resolve against the copy's prefix. So the version is bumped and
+// every other version is refused. No version 1 manifest was written in
+// production (nothing called the publisher), so there is nothing to
+// migrate.
+const ManifestVersion = 2
+
type Manifest struct {
V int `json:"v"`
Seq int64 `json:"seq"`
@@ -43,8 +63,40 @@ type Manifest struct {
func ManifestKey(prefix string) string { return prefix + "manifest.json" }
+// objectKey is an object's key relative to the origin that wrote it.
func objectKey(seq int64, sha string) string { return fmt.Sprintf("objects/%016d-%s", seq, sha) }
+// ErrOrigin marks an entry whose object lies outside the origins its
+// reader admits, or an origin that is not a well-formed prefix.
+var ErrOrigin = errors.New("hostfiles: object origin not admitted")
+
+// checkPrefix accepts a generation prefix: a clean, relative bucket key
+// ending in "/".
+func checkPrefix(prefix string) error {
+ dir, ok := strings.CutSuffix(prefix, "/")
+ if !ok {
+ return fmt.Errorf("hostfiles: prefix %q must end in /", prefix)
+ }
+ if _, err := CleanPath(dir); err != nil {
+ return fmt.Errorf("hostfiles: prefix %q is not a clean bucket key", prefix)
+ }
+ return nil
+}
+
+// admitted resolves a caller's origins: nil or empty means the manifest's
+// own prefix alone. Every origin must be a well-formed prefix.
+func admitted(prefix string, origins []string) ([]string, error) {
+ if len(origins) == 0 {
+ origins = []string{prefix}
+ }
+ for _, o := range origins {
+ if err := checkPrefix(o); err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrOrigin, err)
+ }
+ }
+ return origins, nil
+}
+
// CleanPath accepts only a relative, already-clean, slash-separated path
// that stays inside its root. It never repairs a path: a manifest is
// written by this package, so anything else is damage or an attack.
@@ -66,7 +118,7 @@ func Load(ctx context.Context, s release.Store, prefix string) (Manifest, bool,
}
func load(ctx context.Context, s release.Store, prefix string) (Manifest, string, bool, error) {
- empty := Manifest{V: 1, Entries: map[string]Entry{}, Platform: map[string]Entry{}}
+ empty := Manifest{V: ManifestVersion, Entries: map[string]Entry{}, Platform: map[string]Entry{}}
raw, etag, err := s.Get(ctx, ManifestKey(prefix))
if errors.Is(err, os.ErrNotExist) {
return empty, "", false, nil
@@ -78,8 +130,8 @@ func load(ctx context.Context, s release.Store, prefix string) (Manifest, string
if err := json.Unmarshal(raw, &m); err != nil {
return Manifest{}, "", false, fmt.Errorf("hostfiles: manifest %s: %w", prefix, err)
}
- if m.V != 1 {
- return Manifest{}, "", false, fmt.Errorf("hostfiles: manifest %s is version %d; this binary reads 1", prefix, m.V)
+ if m.V != ManifestVersion {
+ return Manifest{}, "", false, fmt.Errorf("hostfiles: manifest %s is version %d; this binary reads %d", prefix, m.V, ManifestVersion)
}
if m.Entries == nil {
m.Entries = map[string]Entry{}
diff --git a/internal/hostfiles/origins_test.go b/internal/hostfiles/origins_test.go
new file mode 100644
index 000000000..95efbecab
--- /dev/null
+++ b/internal/hostfiles/origins_test.go
@@ -0,0 +1,353 @@
+package hostfiles
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// writeFile is sync_test.go's write, which is unix-only.
+func writeFile(t *testing.T, dir, rel, body string) {
+ t.Helper()
+ full := filepath.Join(dir, rel)
+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(full, []byte(body), 0o640); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// pfx2 is the next generation of pfx's incarnation.
+const pfx2 = "instances/shop.test/abc/files/g2/"
+
+// publishG1 publishes two files and a platform item under pfx.
+func publishG1(t *testing.T, s release.Store) {
+ t.Helper()
+ p := open(t, s)
+ stage(t, p, "instance.key", "k1")
+ stage(t, p, "uploads/a/b.txt", "hello")
+ if err := p.StagePlatform(context.Background(), "env", []byte("A=1")); err != nil {
+ t.Fatal(err)
+ }
+ commit(t, p)
+}
+
+func TestEntriesCarryTheirOriginQualifiedObjectKey(t *testing.T) {
+ ctx, s := context.Background(), release.NewDirStore(t.TempDir())
+ publishG1(t, s)
+ m, _, err := Load(ctx, s, pfx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if m.V != 2 {
+ t.Fatalf("manifest version = %d, want 2", m.V)
+ }
+ e := m.Entries["instance.key"]
+ if want := pfx + objectKey(e.Seq, e.SHA256); e.Object != want {
+ t.Fatalf("object = %q, want %q", e.Object, want)
+ }
+ if _, _, err := s.Get(ctx, e.Object); err != nil {
+ t.Fatalf("the object is not at its key: %v", err)
+ }
+}
+
+func TestACopiedManifestRestoresTheSameBytesWithoutCopyingObjects(t *testing.T) {
+ ctx, s := context.Background(), release.NewDirStore(t.TempDir())
+ publishG1(t, s)
+ m, err := CopyManifest(ctx, s, pfx, pfx2, 0, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if keys, _ := s.List(ctx, pfx2+"objects/"); len(keys) != 0 {
+ t.Fatalf("the copy wrote objects: %v", keys)
+ }
+ src, _, _ := Load(ctx, s, pfx)
+ if m.Seq != src.Seq || len(m.Entries) != len(src.Entries) {
+ t.Fatalf("copy = %+v, source = %+v", m, src)
+ }
+ dir := t.TempDir()
+ if _, _, err := Restore(ctx, s, pfx2, dir, []string{pfx, pfx2}); err != nil {
+ t.Fatal(err)
+ }
+ if read(t, dir, "instance.key") != "k1" || read(t, dir, "uploads/a/b.txt") != "hello" {
+ t.Fatal("restored the wrong bytes")
+ }
+ got, err := ReadPlatform(ctx, s, pfx2, m, "env", []string{pfx, pfx2})
+ if err != nil || string(got) != "A=1" {
+ t.Fatalf("ReadPlatform = %q, %v", got, err)
+ }
+
+ // The new generation's publisher continues the sequence. A changed file
+ // is a new object under g2, an unchanged one keeps its g1 reference.
+ p, err := Open(ctx, s, pfx2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ stage(t, p, "instance.key", "k1")
+ stage(t, p, "uploads/a/b.txt", "changed")
+ if seq := commit(t, p); seq != src.Seq+1 {
+ t.Fatalf("seq = %d, want %d", seq, src.Seq+1)
+ }
+ m2, _, _ := Load(ctx, s, pfx2)
+ if !strings.HasPrefix(m2.Entries["instance.key"].Object, pfx) || !strings.HasPrefix(m2.Entries["uploads/a/b.txt"].Object, pfx2) {
+ t.Fatalf("entries = %+v", m2.Entries)
+ }
+ dir2 := t.TempDir()
+ if _, _, err := Restore(ctx, s, pfx2, dir2, []string{pfx, pfx2}); err != nil {
+ t.Fatal(err)
+ }
+ if read(t, dir2, "instance.key") != "k1" || read(t, dir2, "uploads/a/b.txt") != "changed" {
+ t.Fatal("restored the wrong bytes after the second generation published")
+ }
+ // The source generation is untouched.
+ if again, _, _ := Load(ctx, s, pfx); again.Seq != src.Seq {
+ t.Fatalf("source seq moved to %d", again.Seq)
+ }
+}
+
+func TestRestoreRefusesAnEntryOutsideTheAllowedOriginsAndTouchesNothing(t *testing.T) {
+ ctx, s := context.Background(), release.NewDirStore(t.TempDir())
+ publishG1(t, s)
+ m, err := CopyManifest(ctx, s, pfx, pfx2, 0, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for name, origins := range map[string][]string{
+ "default (own prefix only)": nil,
+ "another generation only": {"instances/shop.test/abc/files/g0/", pfx2},
+ } {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "instance.key"), []byte("before"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := Restore(ctx, s, pfx2, dir, origins); err == nil || !strings.Contains(err.Error(), "origin") {
+ t.Fatalf("%s: Restore err = %v, want an origin refusal", name, err)
+ }
+ if read(t, dir, "instance.key") != "before" {
+ t.Fatalf("%s: Restore wrote before refusing", name)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "uploads")); !os.IsNotExist(err) {
+ t.Fatalf("%s: Restore created uploads/", name)
+ }
+ if _, err := ReadPlatform(ctx, s, pfx2, m, "env", origins); err == nil {
+ t.Fatalf("%s: ReadPlatform read an object outside its origins", name)
+ }
+ }
+}
+
+func TestRestoreRefusesATamperedObjectKey(t *testing.T) {
+ rel := func(e Entry) string { return objectKey(e.Seq, e.SHA256) }
+ cases := map[string]func(e Entry) string{
+ "wrong seq": func(e Entry) string { return pfx + objectKey(e.Seq+1, e.SHA256) },
+ "wrong sha": func(e Entry) string { return pfx + objectKey(e.Seq, strings.Repeat("0", 64)) },
+ "relative (version 1)": rel,
+ "dot-dot to a sibling gen": func(e Entry) string { return pfx + "../g9/" + rel(e) },
+ "dot-dot inside": func(e Entry) string { return pfx + "objects/../" + rel(e) },
+ "absolute": func(e Entry) string { return "/" + pfx + rel(e) },
+ "another host's prefix": func(e Entry) string { return "instances/other.test/abc/files/g1/" + rel(e) },
+ "nested under the origin": func(e Entry) string { return pfx + "x/" + rel(e) },
+ "double slash": func(e Entry) string { return strings.TrimSuffix(pfx, "/") + "//" + rel(e) },
+ "empty": func(Entry) string { return "" },
+ }
+ for name, key := range cases {
+ for _, platform := range []bool{false, true} {
+ s, dir := putHostile(t, func(m *Manifest) {
+ if platform {
+ e := m.Platform["item"]
+ e.Object = key(e)
+ m.Platform["item"] = e
+ return
+ }
+ e := m.Entries["a"]
+ e.Object = key(e)
+ m.Entries["a"] = e
+ })
+ if _, _, err := Restore(context.Background(), s, pfx, dir, nil); err == nil {
+ t.Fatalf("%s (platform %v): Restore accepted the manifest", name, platform)
+ }
+ if got := read(t, dir, "gone"); got != "still here" {
+ t.Fatalf("%s (platform %v): gone = %q, want it untouched", name, platform, got)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "a")); !os.IsNotExist(err) {
+ t.Fatalf("%s (platform %v): a was written", name, platform)
+ }
+ }
+ }
+}
+
+func TestRestoreRefusesAMalformedOrigin(t *testing.T) {
+ ctx, s := context.Background(), release.NewDirStore(t.TempDir())
+ publishG1(t, s)
+ for _, o := range []string{"", "/", "instances/shop.test/abc/files/g1", "/instances/shop.test/abc/files/g1/",
+ "instances/shop.test/abc/files/../files/g1/", "instances//shop.test/", "./instances/"} {
+ if _, _, err := Restore(ctx, s, pfx, t.TempDir(), []string{o}); err == nil {
+ t.Fatalf("Restore accepted origin %q", o)
+ }
+ }
+}
+
+func TestAManifestOfAnotherVersionIsRefused(t *testing.T) {
+ for _, v := range []int{0, 1, 3} {
+ ctx, s := context.Background(), release.NewDirStore(t.TempDir())
+ body, _ := json.Marshal(map[string]any{"v": v, "seq": 0, "entries": map[string]any{}, "platform": map[string]any{}})
+ if _, err := s.Put(ctx, ManifestKey(pfx), body, ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := Load(ctx, s, pfx); err == nil || !strings.Contains(err.Error(), "version") {
+ t.Fatalf("v%d: Load err = %v", v, err)
+ }
+ if _, err := Open(ctx, s, pfx); err == nil {
+ t.Fatalf("v%d: Open accepted it", v)
+ }
+ if _, _, err := Restore(ctx, s, pfx, t.TempDir(), nil); err == nil {
+ t.Fatalf("v%d: Restore accepted it", v)
+ }
+ if _, err := CopyManifest(ctx, s, pfx, pfx2, 0, nil); err == nil {
+ t.Fatalf("v%d: CopyManifest accepted it", v)
+ }
+ }
+}
+
+func TestCopyManifestIsCreateOnlyAndRetrySafe(t *testing.T) {
+ ctx, s := context.Background(), release.NewDirStore(t.TempDir())
+ publishG1(t, s)
+ if _, err := CopyManifest(ctx, s, pfx, pfx2, 0, nil); err != nil {
+ t.Fatal(err)
+ }
+ // A repeat whose first response was lost finds its own bytes: success.
+ if _, err := CopyManifest(ctx, s, pfx, pfx2, 0, nil); err != nil {
+ t.Fatalf("a repeated copy: %v", err)
+ }
+ // Once g2 has moved on, a copy is refused and changes nothing.
+ p, err := Open(ctx, s, pfx2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ stage(t, p, "new", "n")
+ commit(t, p)
+ before, _, _ := s.Get(ctx, ManifestKey(pfx2))
+ if _, err := CopyManifest(ctx, s, pfx, pfx2, 0, nil); !errors.Is(err, ErrManifestExists) {
+ t.Fatalf("err = %v, want ErrManifestExists", err)
+ }
+ after, _, _ := s.Get(ctx, ManifestKey(pfx2))
+ if string(before) != string(after) {
+ t.Fatal("a refused copy changed the target")
+ }
+}
+
+func TestCopyManifestRefusesAMissingOrInvalidSourceAndWritesNothing(t *testing.T) {
+ ctx, s := context.Background(), release.NewDirStore(t.TempDir())
+ if _, err := CopyManifest(ctx, s, pfx, pfx2, 0, nil); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("absent source: err = %v, want os.ErrNotExist", err)
+ }
+ // g2 copied from g1 references g1; copying g2 on to g3 with only g2
+ // admitted is refused.
+ publishG1(t, s)
+ if _, err := CopyManifest(ctx, s, pfx, pfx2, 0, nil); err != nil {
+ t.Fatal(err)
+ }
+ const pfx3 = "instances/shop.test/abc/files/g3/"
+ if _, err := CopyManifest(ctx, s, pfx2, pfx3, 0, nil); err == nil || !strings.Contains(err.Error(), "origin") {
+ t.Fatalf("err = %v, want an origin refusal", err)
+ }
+ if _, _, err := s.Get(ctx, ManifestKey(pfx3)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("a refused copy wrote a manifest: %v", err)
+ }
+ if _, err := CopyManifest(ctx, s, pfx2, pfx3, 0, []string{pfx, pfx2}); err != nil {
+ t.Fatalf("with g1 admitted: %v", err)
+ }
+ for _, bad := range [][2]string{{pfx, pfx}, {pfx, "instances/shop.test/abc/files/g4"}, {"nope", pfx2}} {
+ if _, err := CopyManifest(ctx, s, bad[0], bad[1], 0, nil); err == nil {
+ t.Fatalf("CopyManifest(%q, %q) accepted", bad[0], bad[1])
+ }
+ }
+}
+
+func TestPublishCommitsTheDirectoryAsOneRevision(t *testing.T) {
+ ctx, s, src := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir()
+ writeFile(t, src, "instance.key", "k1")
+ writeFile(t, src, "uploads/a.txt", "a")
+ writeFile(t, src, "skip/me", "x")
+ skip := func(rel string, isDir bool) bool { return rel == "skip" }
+ seq, st, err := Publish(ctx, s, pfx, src, skip)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if seq != 2 || st.Published != 2 {
+ t.Fatalf("seq = %d, stats = %+v", seq, st)
+ }
+ dir := t.TempDir()
+ m, _, err := Restore(ctx, s, pfx, dir, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if m.Seq != seq || read(t, dir, "instance.key") != "k1" || read(t, dir, "uploads/a.txt") != "a" {
+ t.Fatalf("m = %+v", m)
+ }
+ if _, ok := m.Entries["skip/me"]; ok {
+ t.Fatal("a skipped path was published")
+ }
+ // Publishing again with nothing changed is the same revision.
+ if again, _, err := Publish(ctx, s, pfx, src, skip); err != nil || again != seq {
+ t.Fatalf("again = %d, %v", again, err)
+ }
+ if _, _, err := Publish(ctx, s, pfx, filepath.Join(src, "absent"), nil); err == nil {
+ t.Fatal("published a missing directory")
+ }
+}
+
+// expectSeq pins the copy to the source revision the caller admitted: a
+// source that has moved on is refused, on a first attempt or a retry, and
+// nothing is written.
+func TestCopyManifestRefusesASourceThatIsNotAtTheExpectedSeq(t *testing.T) {
+ ctx, s := context.Background(), release.NewDirStore(t.TempDir())
+ publishG1(t, s)
+ src, _, _ := Load(ctx, s, pfx)
+ if _, err := CopyManifest(ctx, s, pfx, pfx2, src.Seq+1, nil); !errors.Is(err, ErrSourceMoved) {
+ t.Fatalf("err = %v, want ErrSourceMoved", err)
+ }
+ if _, _, err := s.Get(ctx, ManifestKey(pfx2)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("a refused copy wrote a manifest: %v", err)
+ }
+ m, err := CopyManifest(ctx, s, pfx, pfx2, src.Seq, nil)
+ if err != nil || m.Seq != src.Seq {
+ t.Fatalf("m.Seq = %d, err = %v", m.Seq, err)
+ }
+ if _, err := CopyManifest(ctx, s, pfx, pfx2, src.Seq, nil); err != nil {
+ t.Fatalf("a repeat at the same seq: %v", err)
+ }
+ // The source commits again: a retry pinned to the old seq is refused,
+ // and the target keeps the pinned copy.
+ p := open(t, s)
+ stage(t, p, "later", "x")
+ commit(t, p)
+ before, _, _ := s.Get(ctx, ManifestKey(pfx2))
+ if _, err := CopyManifest(ctx, s, pfx, pfx2, src.Seq, nil); !errors.Is(err, ErrSourceMoved) {
+ t.Fatalf("err = %v, want ErrSourceMoved", err)
+ }
+ if after, _, _ := s.Get(ctx, ManifestKey(pfx2)); string(before) != string(after) {
+ t.Fatal("a refused copy changed the target")
+ }
+}
+
+func TestRestoreAndReadPlatformRefuseAMalformedPrefix(t *testing.T) {
+ ctx, s := context.Background(), release.NewDirStore(t.TempDir())
+ publishG1(t, s)
+ m, _, _ := Load(ctx, s, pfx)
+ for _, bad := range []string{"", "instances/shop.test/abc/files/g1", "/" + pfx, "instances/../" + pfx, "instances//x/"} {
+ // Admitting pfx explicitly means only the prefix check can refuse.
+ if _, _, err := Restore(ctx, s, bad, t.TempDir(), []string{pfx}); err == nil {
+ t.Fatalf("Restore accepted prefix %q", bad)
+ }
+ if _, err := ReadPlatform(ctx, s, bad, m, "env", []string{pfx}); err == nil {
+ t.Fatalf("ReadPlatform accepted prefix %q", bad)
+ }
+ }
+}
diff --git a/internal/hostfiles/publisher.go b/internal/hostfiles/publisher.go
index b1708f891..a55621888 100644
--- a/internal/hostfiles/publisher.go
+++ b/internal/hostfiles/publisher.go
@@ -39,8 +39,8 @@ type Publisher struct {
}
func Open(ctx context.Context, s release.Store, prefix string) (*Publisher, error) {
- if !strings.HasSuffix(prefix, "/") {
- return nil, fmt.Errorf("hostfiles: prefix %q must end in /", prefix)
+ if err := checkPrefix(prefix); err != nil {
+ return nil, err
}
m, etag, _, err := load(ctx, s, prefix)
if err != nil {
@@ -54,13 +54,14 @@ func sum(body []byte) string {
return hex.EncodeToString(h[:])
}
-// put writes body as the next immutable object and returns its entry.
-// Callers hold p.mu.
+// put writes body as the next immutable object under this publisher's own
+// prefix and returns its entry, whose Object is the full key. Callers hold
+// p.mu.
func (p *Publisher) put(ctx context.Context, body []byte, mode uint32, torn bool) (Entry, error) {
sha := sum(body)
seq := p.next + 1
- key := objectKey(seq, sha)
- if _, err := p.s.Put(ctx, p.prefix+key, body, ""); err != nil && !errors.Is(err, release.ErrCASConflict) {
+ key := p.prefix + objectKey(seq, sha)
+ if _, err := p.s.Put(ctx, key, body, ""); err != nil && !errors.Is(err, release.ErrCASConflict) {
// A conflict means this exact key exists: the same sequence number
// and the same hash, left by an attempt that crashed before its
// commit. The bytes are the ones we were about to write.
diff --git a/internal/hostfiles/restore.go b/internal/hostfiles/restore.go
index 03da182ce..523f2ee1d 100644
--- a/internal/hostfiles/restore.go
+++ b/internal/hostfiles/restore.go
@@ -10,6 +10,7 @@ import (
"os"
"path"
"path/filepath"
+ "slices"
"sort"
"strings"
@@ -48,25 +49,31 @@ type RestoreStats struct {
// before its rename, and every directory in which a name was created,
// replaced or removed has been synced since. A caller may record the
// installation as complete on that basis, and on no weaker one.
-func Restore(ctx context.Context, s release.Store, prefix, dir string) (Manifest, RestoreStats, error) {
+//
+// origins are the generation prefixes whose objects this restore may read:
+// the set the caller's pin admits. nil or empty means prefix alone. Every
+// entry's object must be exactly "<origin>objects/<seq>-<sha256>" for one
+// of them, with the entry's own Seq and SHA256; any other entry refuses the
+// whole manifest before anything is touched (ErrOrigin when the key is
+// well formed but its origin is not admitted).
+func Restore(ctx context.Context, s release.Store, prefix, dir string, origins []string) (Manifest, RestoreStats, error) {
var st RestoreStats
+ if err := checkPrefix(prefix); err != nil {
+ return Manifest{}, st, err
+ }
+ origins, err := admitted(prefix, origins)
+ if err != nil {
+ return Manifest{}, st, err
+ }
m, _, _, err := load(ctx, s, prefix)
if err != nil {
return Manifest{}, st, err
}
- var live, dead []string
- for name, e := range m.Platform {
- if err := checkEntry(e); err != nil {
- return Manifest{}, st, fmt.Errorf("hostfiles: platform item %q: %w", name, err)
- }
+ if err := checkManifest(m, origins); err != nil {
+ return Manifest{}, st, err
}
+ var live, dead []string
for p, e := range m.Entries {
- if _, err := CleanPath(p); err != nil {
- return Manifest{}, st, err
- }
- if err := checkEntry(e); err != nil {
- return Manifest{}, st, fmt.Errorf("hostfiles: %s: %w", p, err)
- }
if e.Deleted {
dead = append(dead, p)
} else {
@@ -147,7 +154,7 @@ func Restore(ctx context.Context, s release.Store, prefix, dir string) (Manifest
continue
}
mark(p)
- body, err := fetch(ctx, s, prefix, e)
+ body, err := fetch(ctx, s, e, origins)
if err != nil {
return Manifest{}, st, fmt.Errorf("hostfiles: %s: %w", p, err)
}
@@ -191,27 +198,63 @@ func Restore(ctx context.Context, s release.Store, prefix, dir string) (Manifest
}
// ReadPlatform returns a platform-managed item's bytes, verified. A name
-// the manifest does not hold is os.ErrNotExist.
-func ReadPlatform(ctx context.Context, s release.Store, prefix string, m Manifest, name string) ([]byte, error) {
+// the manifest does not hold is os.ErrNotExist. prefix is where m was
+// loaded from; origins are as for Restore (nil or empty: prefix alone).
+func ReadPlatform(ctx context.Context, s release.Store, prefix string, m Manifest, name string, origins []string) ([]byte, error) {
+ if err := checkPrefix(prefix); err != nil {
+ return nil, err
+ }
+ origins, err := admitted(prefix, origins)
+ if err != nil {
+ return nil, err
+ }
e, ok := m.Platform[name]
if !ok {
return nil, fmt.Errorf("hostfiles: platform item %q: %w", name, os.ErrNotExist)
}
- return fetch(ctx, s, prefix, e)
+ return fetch(ctx, s, e, origins)
}
-// checkEntry refuses a published entry whose object is not the one this
-// package would have written for it, or whose size it could never have
-// published. Tombstones and skipped entries name no object.
-func checkEntry(e Entry) error {
+// checkManifest validates every path and every entry before a caller acts
+// on any of them.
+func checkManifest(m Manifest, origins []string) error {
+ for name, e := range m.Platform {
+ if err := checkEntry(e, origins); err != nil {
+ return fmt.Errorf("hostfiles: platform item %q: %w", name, err)
+ }
+ }
+ for p, e := range m.Entries {
+ if _, err := CleanPath(p); err != nil {
+ return err
+ }
+ if err := checkEntry(e, origins); err != nil {
+ return fmt.Errorf("hostfiles: %s: %w", p, err)
+ }
+ }
+ return nil
+}
+
+// checkEntry refuses a published entry whose object is not one this
+// package would have written for it in an admitted origin, or whose size
+// it could never have published. Tombstones and skipped entries name no
+// object. origins are already validated prefixes.
+func checkEntry(e Entry, origins []string) error {
if e.Deleted || e.Skipped != "" {
return nil
}
if len(e.SHA256) != 64 || strings.Trim(e.SHA256, "0123456789abcdef") != "" {
return fmt.Errorf("sha256 %q is not 64 lowercase hex characters", e.SHA256)
}
- if want := objectKey(e.Seq, e.SHA256); e.Object != want {
- return fmt.Errorf("object %q is not %q", e.Object, want)
+ if _, err := CleanPath(e.Object); err != nil {
+ return fmt.Errorf("object %q is not a clean bucket key", e.Object)
+ }
+ want := objectKey(e.Seq, e.SHA256)
+ origin, ok := strings.CutSuffix(e.Object, want)
+ if !ok {
+ return fmt.Errorf("object %q does not end in %q", e.Object, want)
+ }
+ if !slices.Contains(origins, origin) {
+ return fmt.Errorf("%w: object %q lies under %q, not one of %q", ErrOrigin, e.Object, origin, origins)
}
if e.Size < 0 || e.Size > MaxFileBytes {
return fmt.Errorf("size %d is outside 0 to %d", e.Size, MaxFileBytes)
@@ -219,11 +262,12 @@ func checkEntry(e Entry) error {
return nil
}
-func fetch(ctx context.Context, s release.Store, prefix string, e Entry) ([]byte, error) {
- if err := checkEntry(e); err != nil {
+// fetch reads e's object at exactly the key the entry names.
+func fetch(ctx context.Context, s release.Store, e Entry, origins []string) ([]byte, error) {
+ if err := checkEntry(e, origins); err != nil {
return nil, err
}
- body, _, err := s.Get(ctx, prefix+e.Object)
+ body, _, err := s.Get(ctx, e.Object)
if err != nil {
return nil, err
}
diff --git a/internal/hostfiles/sync.go b/internal/hostfiles/sync.go
index 8604a1e1f..474d3e24d 100644
--- a/internal/hostfiles/sync.go
+++ b/internal/hostfiles/sync.go
@@ -27,9 +27,14 @@ var (
readAll = io.ReadAll
)
+// SyncStats reports one pass. Retry lists regular files replaced between
+// the look and the open: nothing was read and nothing staged for them this
+// pass. Torn lists files staged from a read during which the file changed,
+// so their staged bytes may be an inconsistent image.
type SyncStats struct {
Published, Deleted int
TooLarge, NotRegular, Unreadable []string
+ Retry, Torn []string
}
// skipped reports whether rel, or any directory above it, is excluded.
@@ -90,6 +95,7 @@ func syncRoot(ctx context.Context, p *Publisher, root *dirHandle, skip func(rel
return nil
case fileRetry:
seen[rel] = true // replaced mid-look: the next scan reads it
+ st.Retry = append(st.Retry, rel)
return nil
case fileTooLarge:
seen[rel] = true
@@ -104,6 +110,9 @@ func syncRoot(ctx context.Context, p *Publisher, root *dirHandle, skip func(rel
if p.pending() != before {
st.Published++
}
+ if state == fileTorn {
+ st.Torn = append(st.Torn, rel)
+ }
return nil
})
if err != nil {
@@ -121,5 +130,7 @@ func syncRoot(ctx context.Context, p *Publisher, root *dirHandle, skip func(rel
sort.Strings(st.TooLarge)
sort.Strings(st.NotRegular)
sort.Strings(st.Unreadable)
+ sort.Strings(st.Retry)
+ sort.Strings(st.Torn)
return st, nil
}
diff --git a/internal/hostfiles/sync_test.go b/internal/hostfiles/sync_test.go
index 9d79c771f..8a767fe22 100644
--- a/internal/hostfiles/sync_test.go
+++ b/internal/hostfiles/sync_test.go
@@ -47,7 +47,7 @@ func TestSyncDirPublishesNewChangedAndDeleted(t *testing.T) {
commit(t, p)
dst := t.TempDir()
- if _, _, err := Restore(ctx, s, pfx, dst); err != nil {
+ if _, _, err := Restore(ctx, s, pfx, dst, nil); err != nil {
t.Fatal(err)
}
if read(t, dst, "uploads/a.txt") != "a2" || read(t, dst, "new.txt") != "n" {
@@ -131,7 +131,7 @@ func TestSyncDirRecordsAFileOverTheCapWithoutReadingIt(t *testing.T) {
t.Fatalf("the oversized file was read %d times; it must be refused on its size alone", reads)
}
commit(t, p)
- _, rs, err := Restore(ctx, s, pfx, t.TempDir())
+ _, rs, err := Restore(ctx, s, pfx, t.TempDir(), nil)
if err != nil || !reflect.DeepEqual(rs.Skipped, []string{"huge.bin"}) {
t.Fatalf("restore skipped %v, err %v", rs.Skipped, err)
}
diff --git a/internal/hostfiles/sync_unix_test.go b/internal/hostfiles/sync_unix_test.go
index c9c9c974a..33006cc32 100644
--- a/internal/hostfiles/sync_unix_test.go
+++ b/internal/hostfiles/sync_unix_test.go
@@ -4,6 +4,7 @@ package hostfiles
import (
"context"
+ "io"
"net"
"os"
"path/filepath"
@@ -252,3 +253,104 @@ func TestSyncDirKeepsAnEntryWhoseFileWasReplacedMidLook(t *testing.T) {
t.Fatalf("the next scan did not pick the replacement up: dirty = %v err = %v", p.Dirty(), err)
}
}
+
+// Publish is an installation's record of its directory: a file it could
+// not read would be missing from it, so it commits nothing and says so.
+func TestPublishRefusesADirectoryWithAnUnreadableFile(t *testing.T) {
+ if os.Getuid() == 0 {
+ t.Skip("root can read anything")
+ }
+ ctx, s, src := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir()
+ write(t, src, "ok", "v1")
+ write(t, src, "locked", "v1")
+ if err := os.Chmod(filepath.Join(src, "locked"), 0); err != nil {
+ t.Fatal(err)
+ }
+ if _, st, err := Publish(ctx, s, pfx, src, nil); err == nil || !reflect.DeepEqual(st.Unreadable, []string{"locked"}) {
+ t.Fatalf("err = %v, stats = %+v", err, st)
+ }
+ if _, found, err := Load(ctx, s, pfx); found || err != nil {
+ t.Fatalf("a manifest was committed: found = %v, err = %v", found, err)
+ }
+}
+
+// replaceAt swaps src/f for new content at the n-th look-to-open window.
+func replaceAt(t *testing.T, n int, src string) {
+ t.Helper()
+ swapAt(t, n, func() {
+ tmp := filepath.Join(src, "f.new")
+ if err := os.WriteFile(tmp, []byte("v2"), 0o640); err != nil {
+ t.Error(err)
+ }
+ if err := os.Rename(tmp, filepath.Join(src, "f")); err != nil {
+ t.Error(err)
+ }
+ })
+}
+
+// A file replaced mid-look is reported, not silently left out.
+func TestSyncDirReportsAFileReplacedMidLook(t *testing.T) {
+ ctx, s, src := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir()
+ write(t, src, "f", "v1")
+ replaceAt(t, 1, src)
+ st, err := SyncDir(ctx, open(t, s), src, nil)
+ if err != nil || !reflect.DeepEqual(st.Retry, []string{"f"}) {
+ t.Fatalf("stats = %+v err = %v, want Retry [f]", st, err)
+ }
+}
+
+// A first install whose only file was replaced mid-look would commit a
+// manifest without it.
+func TestPublishRefusesADirectoryWithAFileReplacedMidLook(t *testing.T) {
+ ctx, s, src := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir()
+ write(t, src, "ok", "v1")
+ write(t, src, "f", "v1")
+ replaceAt(t, 1, src) // "f" sorts before "ok"
+ if _, st, err := Publish(ctx, s, pfx, src, nil); err == nil || !reflect.DeepEqual(st.Retry, []string{"f"}) {
+ t.Fatalf("err = %v, stats = %+v", err, st)
+ }
+ if _, found, err := Load(ctx, s, pfx); found || err != nil {
+ t.Fatalf("a manifest was committed: found = %v, err = %v", found, err)
+ }
+}
+
+// tearAt makes the read of src/f see the file grow while it is read.
+func tearAt(t *testing.T, src string) {
+ t.Helper()
+ orig := readAll
+ t.Cleanup(func() { readAll = orig })
+ readAll = func(r io.Reader) ([]byte, error) {
+ b, err := orig(r)
+ f, oerr := os.OpenFile(filepath.Join(src, "f"), os.O_APPEND|os.O_WRONLY, 0)
+ if oerr != nil {
+ t.Error(oerr)
+ return b, err
+ }
+ f.Write([]byte("more"))
+ f.Close()
+ return b, err
+ }
+}
+
+func TestSyncDirReportsATornRead(t *testing.T) {
+ ctx, s, src := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir()
+ write(t, src, "f", "v1")
+ tearAt(t, src)
+ st, err := SyncDir(ctx, open(t, s), src, nil)
+ if err != nil || !reflect.DeepEqual(st.Torn, []string{"f"}) {
+ t.Fatalf("stats = %+v err = %v, want Torn [f]", st, err)
+ }
+}
+
+// Publish is an install-time record: a torn image is not committed.
+func TestPublishRefusesATornRead(t *testing.T) {
+ ctx, s, src := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir()
+ write(t, src, "f", "v1")
+ tearAt(t, src)
+ if _, st, err := Publish(ctx, s, pfx, src, nil); err == nil || !reflect.DeepEqual(st.Torn, []string{"f"}) {
+ t.Fatalf("err = %v, stats = %+v", err, st)
+ }
+ if _, found, err := Load(ctx, s, pfx); found || err != nil {
+ t.Fatalf("a manifest was committed: found = %v, err = %v", found, err)
+ }
+}
diff --git a/internal/hostfiles/wake.go b/internal/hostfiles/wake.go
index fae6854da..e603ca023 100644
--- a/internal/hostfiles/wake.go
+++ b/internal/hostfiles/wake.go
@@ -121,12 +121,12 @@ func StageWake(ctx context.Context, p *Publisher, ws WakeState) error {
// when the manifest has no wake entry at all. A manifest that names wake
// state whose object is missing or damaged is an error: that is lost
// timers, not a host with none, and the caller must not install as if it
-// were.
-func LoadWake(ctx context.Context, s release.Store, prefix string, m Manifest) (WakeState, bool, error) {
+// were. origins are as for ReadPlatform.
+func LoadWake(ctx context.Context, s release.Store, prefix string, m Manifest, origins []string) (WakeState, bool, error) {
if _, ok := m.Platform[WakeItem]; !ok {
return WakeState{}, false, nil
}
- body, err := ReadPlatform(ctx, s, prefix, m, WakeItem)
+ body, err := ReadPlatform(ctx, s, prefix, m, WakeItem, origins)
if err != nil {
return WakeState{}, false, fmt.Errorf("hostfiles: the manifest names wake state that cannot be read: %w", err)
}
diff --git a/internal/hostfiles/wake_test.go b/internal/hostfiles/wake_test.go
index 435d1dc1b..aef0a715a 100644
--- a/internal/hostfiles/wake_test.go
+++ b/internal/hostfiles/wake_test.go
@@ -63,7 +63,7 @@ func TestWakeStateRoundTripsThroughTheManifest(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- got, found, err := LoadWake(ctx, s, pfx, m)
+ got, found, err := LoadWake(ctx, s, pfx, m, nil)
if err != nil || !found {
t.Fatalf("LoadWake found=%v err=%v", found, err)
}
@@ -102,7 +102,7 @@ func TestStageWakeOfUnchangedStateStagesNothing(t *testing.T) {
func TestLoadWakeWithNoneReportsNotFound(t *testing.T) {
ctx, s := context.Background(), release.NewDirStore(t.TempDir())
m, _, _ := Load(ctx, s, pfx)
- if _, found, err := LoadWake(ctx, s, pfx, m); found || err != nil {
+ if _, found, err := LoadWake(ctx, s, pfx, m, nil); found || err != nil {
t.Fatalf("found=%v err=%v", found, err)
}
}
@@ -120,10 +120,10 @@ func TestLoadWakeWithItsObjectMissingIsAnError(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if err := s.Delete(ctx, pfx+m.Platform[WakeItem].Object); err != nil {
+ if err := s.Delete(ctx, m.Platform[WakeItem].Object); err != nil {
t.Fatal(err)
}
- if _, found, err := LoadWake(ctx, s, pfx, m); err == nil || found {
+ if _, found, err := LoadWake(ctx, s, pfx, m, nil); err == nil || found {
t.Fatalf("found = %v, err = %v; a missing wake object must be an error", found, err)
}
}