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.
16 files changed,
+1478
−322
docs/superpowers/notes/2026-10-07-region-ha-contracts.md+184 −0internal/boxchoice/boxchoice.go+186 −0internal/boxchoice/boxchoice_test.go+261 −0internal/console/boxchoice.go+30 −106internal/console/boxchoice_test.go+26 −130internal/hostfiles/contract.go+142 −0internal/hostfiles/hostfiles_test.go+43 −43internal/hostfiles/manifest.go+55 −3internal/hostfiles/origins_test.go+353 −0internal/hostfiles/publisher.go+7 −6internal/hostfiles/restore.go+69 −25internal/hostfiles/sync.go+11 −0internal/hostfiles/sync_test.go+2 −2internal/hostfiles/sync_unix_test.go+102 −0internal/hostfiles/wake.go+3 −3internal/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) |
| } |
| } |