carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# Region HA 1a: Foundations Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Build every part of region HA spec 1 that does not need the host lifecycle claim: box identity records, box evidence and choice, stamp-ordered home map, the ordered host-files publisher, wake-state export, termination-fence verification, the installation-complete marker, and the infrastructure they need.
6
7**Architecture:** Eleven small tasks, each inert on a one-box region. Nothing here changes which box runs an instance: that is plan 1b, which wires these into create, reconcile, the activator and the `relocating` operation once the claim (`internal/claim`, not yet on main) lands. Every piece is a pure function or a store-backed type with its own tests.
8
9**Tech Stack:** Go 1.26, module `github.com/carlosframework/platform`; `release.Store` (create-only and ETag compare-and-swap puts); SQLite registry; `golang.org/x/sys/unix` inotify; OpenTofu modules under `infra/modules/`.
10
11**Spec:** `docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md` (rev 4.2 plus rulings H7 to H9, at 2cb9d12e). Executors read it first: §2 words, §3 invariants, §5.1, §5.4 to §5.7, §5.9, §6.
12
13## Why this is plan 1a and not the whole of spec 1
14
15Spec 1 sits on the host lifecycle claim, which exists only as a spec (rev 6.12 on `design-replica-ownership`; spec 1 was reviewed against rev 6.3) and on move-any-app's install contract (rev 7.12, also unbuilt). There is no `internal/claim` or `internal/hostguard` on main. So spec 1 splits:
16
17- **1a (this plan):** everything with no claim dependency. Mergeable to main now, dormant.
18- **1b (written when the claim's code lands):** create names a box (§5.2), reconcile's three row cases (§5.3), the activator's warm-copy check wired in (§5.5), relocation and the release sequence (§5.8), the multi-box gate (§8), `carlos ops box bind`, the manifest's `files_durability` projection onto routes, and starting the publisher from the agent. Before 1b is written, spec 1 is re-read against the claim's then-current revision.
19
20## Global Constraints
21
22- Nothing in this plan may change behaviour on a one-box region. Every new field is additive and `omitempty`; every reader tolerates its absence (AGENTS.md: "Wire-shape changes are additive").
23- When the edges-file shape changes, every parser changes in the same task: `internal/host`, `internal/edge`, `internal/console`, and check `internal/edgesfile` and `internal/steering` (AGENTS.md: "the consumer list is everything that PARSES the shape").
24- Linux-only syscalls live in `*_linux.go` with `//go:build linux` and a `*_other.go` stub with `//go:build !linux`. CI cross-compiles darwin and windows.
25- Do not add a method to `release.Store` (eleven implementations). Use the existing `Get`/`Put`/`List`/`Delete`; `Put(ctx, key, body, "")` is create-only, a non-empty `ifMatch` is replace-only, and a lost race is `release.ErrCASConflict`.
26- Tests use `release.NewDirStore(t.TempDir())`; inject failures with a struct embedding `release.Store` that overrides one method.
27- No new Go module dependencies. `service/ec2` and `x/sys` are already direct. Do **not** add `service/sqs`, `service/autoscaling` or a PKCS7 library in this plan.
28- No user-facing copy is added here. Log lines are operator-facing and follow the surrounding `agent: …` / `edges: …` style. If a task finds it must add console or CLI copy, stop: that needs the copy-review gate.
29- Commit messages: lowercase `area:` prefix and a sentence stating the behaviour, for example `boxid: a box record verifies only under AWS's signature`. End each with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
30- Go test environment on this machine: use the default Go build cache. Do not set `GOCACHE`, `GOMODCACHE` or `GOTMPDIR` under `/tmp`. Scratch files go under `$TMPDIR`. Unix-socket tests need a short `TMPDIR`.
31- The branch already contains `origin/main` as of 2026-10-05 (merge 014b0ce1). Before that merge `internal/console`'s tests did not build on this branch; if they fail to build again, merge main first.
32- Local test runs need `GOFLAGS=-buildvcs=false` (some console tests build a binary and fail on VCS stamping in a worktree) and, inside the Claude sandbox, `GOCACHE=$TMPDIR/gocache`. Tests that open Unix sockets (`internal/activator`, `internal/edge`) fail inside the sandbox with `socket: operation not permitted`: that is the sandbox, not the change. Run those packages with the sandbox off, or rely on CI.
33- This plan's code was dry-run on 2026-10-05: every new package and both patch tasks were built, cross-built for darwin and windows, and tested, then removed. Two things the dry run could not do are called out where they matter: the real-inotify tests (Task 7, step 10) and the IMDS signature check (Task 1, step 7).
34- Gate before each commit: `go build ./... && go vet ./... && gofmt -l . && go test ./internal/<pkg>/... -count=1`. Full gate before the branch lands: `make test`.
35- Exact values from the spec: continuous deadline **1 second** from the first unpublished modification; repair scan **10 minutes**; polling fallback **1 second**; snapshot interval default **15 minutes**; `files_durability` default **`snapshot`** (H8); capability string **`region-ha-1`**; termination fence accepts **only** state `terminated`.
36
37## Three deliberate departures from the spec's wording
38
39Each keeps the spec's property and is recorded in Task 11 as a spec note.
40
411. **Exact versions by content hash, not S3 version id.** Spec §5.6 says restore fetches "by the exact versions it lists". `release.Store` has no version-id read and the box role has no `s3:GetObjectVersion`. Objects here are immutable, written create-only under a key that contains their SHA-256, and the manifest records each SHA-256. A reader verifies the hash. That pins content exactly without version ids.
422. **Identity signature endpoint.** Spec §5.1 says "PKCS7 signature". IMDS also serves `/latest/dynamic/instance-identity/signature`, a base64 RSA-SHA256 signature over the identity document, verifiable with `crypto/x509` and no PKCS7 library. This plan uses that endpoint. Task 1's last step checks it against a real box before the branch lands.
433. **`snapshot` is a periodic full publish, not a sealed archive.** Spec §5.6 gives the manifest a sealed-archive `base`. Here `hostfiles.SyncDir` stages every difference between the directory and the manifest; run on the snapshot interval it is the `snapshot` setting, with no second format and no whole-directory archive held in memory. The manifest has no `base`.
44
45## Review Focus
46
471. **A restore path that escapes its directory.** A manifest entry named `../x`, `/etc/x` or `a/../../x` must be refused, never written (Task 5).
482. **Non-regular files in the files directory.** Symlinks, sockets, FIFOs and devices must be skipped and counted, never followed or opened (Tasks 5, 6, 7).
493. **A file larger than memory allows.** `release.Store.Put` takes a whole `[]byte`. A file over the 64 MiB cap is skipped and reported, never read whole, and never silently dropped from the manifest (Tasks 5, 7).
504. **A lookalike termination.** An event or describe result for the right instance id in the wrong account or region, or with state `shutting-down`, `stopped` or missing, must not verify (Task 9).
515. **A box record whose id is someone else's.** A validly signed identity document for instance A, stored under box id B, must not verify (Task 1).
52
53## File Structure
54
55| Path | Responsibility |
56|---|---|
57| `internal/boxid/record.go` | The box record type, `Publish` (create-only), `Load` |
58| `internal/boxid/verify.go` | `Verifier`: signature, account, region, id binding, legacy bind |
59| `internal/boxid/certs.go`, `certs/*.pem` | AWS regional signing certificates, embedded |
60| `internal/host/edgesfile.go` | New edges-file fields; `Stamp` on `edgeHost` |
61| `internal/host/headroom_linux.go`, `headroom_other.go` | Reading memory, disk and awake counts |
62| `internal/edge/homemap.go` | `Stamp` in the edge's view; stamp-ordered fold |
63| `internal/console/instancesplacement.go` | New fields in the console's view |
64| `internal/console/boxchoice.go` | `eligibleBoxes`, `chooseBox` |
65| `internal/hostfiles/manifest.go` | Manifest and entry types, keys, path validation |
66| `internal/hostfiles/publisher.go` | Stage, commit (CAS), tombstones |
67| `internal/hostfiles/restore.go` | Restore a directory from a manifest |
68| `internal/hostfiles/sync.go` | `SyncDir`: stage a whole directory's differences; `readStable` |
69| `internal/hostfiles/run.go`, `deadline.go`, `watch_linux.go`, `watch_other.go` | The publish loop, deadline queue, inotify watcher, polling fallback |
70| `internal/registry/timers.go` | `ReplaceTimers` |
71| `internal/hostfiles/wake.go` | Wake state: timers, `NextDue`, `Replication` |
72| `internal/termfence/fence.go` | Fence type, the three evidence sources, `Verify` |
73| `internal/activator/installed.go` | Installation-complete marker and `warmCopyTrusted` |
74| `infra/modules/region-host/*.tf`, `infra/modules/deployment-buckets/*.tf` | Volume flag, termination queue, IAM, bucket policies |
75
76---
77
78### Task 1: Box records and their verification (`internal/boxid`)
79
80**Files:**
81- Create: `internal/boxid/record.go`, `internal/boxid/verify.go`, `internal/boxid/certs.go`, `internal/boxid/certs/README.md`
82- Test: `internal/boxid/boxid_test.go`
83
84**Interfaces:**
85- Consumes: `release.Store`.
86- Produces:
87 ```go
88 package boxid
89 const Prefix = "control/boxes/"
90 func Key(box string) string // Prefix + box + ".json"
91 func BindKey(box string) string // Prefix + box + ".bind.json"
92 type Record struct {
93 V int `json:"v"`
94 Box string `json:"box"`
95 IdentityDocument string `json:"identity_document"` // base64 of the raw IMDS document bytes
96 IdentitySignature string `json:"identity_signature"` // base64 RSA-SHA256, as IMDS serves it
97 WrittenAt string `json:"written_at"`
98 }
99 type Identity struct{ AccountID, Region, InstanceID string }
100 type Bind struct {
101 RecordSHA256 string `json:"record_sha256"`
102 InstanceID string `json:"instance_id"`
103 TofuInstanceID string `json:"tofu_instance_id"`
104 Operator string `json:"operator"`
105 At string `json:"at"`
106 }
107 type Verifier struct {
108 Account string // the deployment's AWS account id
109 Certs map[string]*x509.Certificate // by AWS region
110 }
111 func (v Verifier) Verify(rec Record, raw []byte, bind *Bind) (Identity, error)
112 func Publish(ctx context.Context, s release.Store, rec Record) error // create-only; an identical existing record is success
113 func Load(ctx context.Context, s release.Store, box string) (Record, []byte, *Bind, error) // ErrUnverified if the stored record names another box
114 func EmbeddedCerts() (map[string]*x509.Certificate, error)
115 var ErrUnverified = errors.New("boxid: record does not verify")
116 ```
117 `Bind.RecordSHA256` replaces the spec's `record_version_id` (see "Three deliberate departures").
118
119**Two bindings, both needed.** `Verify` binds the record's `box` field to the signed instance. `Load` binds the storage key to the record's `box` field. Without the second, box A's own valid record copied to `control/boxes/B.json` would load as B and verify as A. `Verify` does not check the region against an expectation: it proves which region the box is in, and the caller compares (Task 3's `eligibleBoxes`, Task 9's `termfence.Verify`).
120
121- [ ] **Step 1: Write the failing tests**
122
123`internal/boxid/boxid_test.go`:
124
125```go
126package boxid
127
128import (
129 "context"
130 "crypto"
131 "crypto/rand"
132 "crypto/rsa"
133 "crypto/sha256"
134 "crypto/x509"
135 "crypto/x509/pkix"
136 "encoding/base64"
137 "encoding/hex"
138 "encoding/json"
139 "errors"
140 "math/big"
141 "testing"
142 "time"
143
144 "github.com/carlosframework/platform/internal/release"
145)
146
147// signer stands in for AWS: one key and certificate per test.
148type signer struct {
149 key *rsa.PrivateKey
150 cert *x509.Certificate
151}
152
153func newSigner(t *testing.T) signer {
154 t.Helper()
155 key, err := rsa.GenerateKey(rand.Reader, 2048)
156 if err != nil {
157 t.Fatal(err)
158 }
159 tmpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test"},
160 NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)}
161 der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
162 if err != nil {
163 t.Fatal(err)
164 }
165 cert, err := x509.ParseCertificate(der)
166 if err != nil {
167 t.Fatal(err)
168 }
169 return signer{key, cert}
170}
171
172func (s signer) record(t *testing.T, box, account, region, instance string) (Record, []byte) {
173 t.Helper()
174 doc, _ := json.Marshal(map[string]string{"accountId": account, "region": region, "instanceId": instance})
175 sum := sha256.Sum256(doc)
176 sig, err := rsa.SignPKCS1v15(rand.Reader, s.key, crypto.SHA256, sum[:])
177 if err != nil {
178 t.Fatal(err)
179 }
180 rec := Record{V: 1, Box: box, IdentityDocument: base64.StdEncoding.EncodeToString(doc),
181 IdentitySignature: base64.StdEncoding.EncodeToString(sig), WrittenAt: "2026-10-05T00:00:00Z"}
182 raw, _ := json.Marshal(rec)
183 return rec, raw
184}
185
186func (s signer) verifier() Verifier {
187 return Verifier{Account: "111122223333", Certs: map[string]*x509.Certificate{"eu-west-1": s.cert}}
188}
189
190func TestAutoScalingBoxVerifiesWhenIDIsItsInstance(t *testing.T) {
191 s := newSigner(t)
192 rec, raw := s.record(t, "i-0abc", "111122223333", "eu-west-1", "i-0abc")
193 id, err := s.verifier().Verify(rec, raw, nil)
194 if err != nil {
195 t.Fatal(err)
196 }
197 if id.InstanceID != "i-0abc" || id.Region != "eu-west-1" || id.AccountID != "111122223333" {
198 t.Fatalf("identity = %+v", id)
199 }
200}
201
202// Review Focus 5: a valid document for instance A under box id B.
203func TestRecordUnderAnotherInstancesIDIsRefused(t *testing.T) {
204 s := newSigner(t)
205 rec, raw := s.record(t, "i-0other", "111122223333", "eu-west-1", "i-0abc")
206 if _, err := s.verifier().Verify(rec, raw, nil); !errors.Is(err, ErrUnverified) {
207 t.Fatalf("err = %v, want ErrUnverified", err)
208 }
209}
210
211func TestRefusals(t *testing.T) {
212 s, other := newSigner(t), newSigner(t)
213 cases := map[string]func() (Record, []byte, Verifier){
214 "wrong account": func() (Record, []byte, Verifier) {
215 r, raw := s.record(t, "i-0abc", "999999999999", "eu-west-1", "i-0abc")
216 return r, raw, s.verifier()
217 },
218 "region with no certificate": func() (Record, []byte, Verifier) {
219 r, raw := s.record(t, "i-0abc", "111122223333", "ap-south-1", "i-0abc")
220 return r, raw, s.verifier()
221 },
222 "signed by someone else": func() (Record, []byte, Verifier) {
223 r, raw := other.record(t, "i-0abc", "111122223333", "eu-west-1", "i-0abc")
224 return r, raw, s.verifier()
225 },
226 "document altered after signing": func() (Record, []byte, Verifier) {
227 r, _ := s.record(t, "i-0abc", "111122223333", "eu-west-1", "i-0abc")
228 doc, _ := json.Marshal(map[string]string{"accountId": "111122223333", "region": "eu-west-1", "instanceId": "i-0zzz"})
229 r.IdentityDocument, r.Box = base64.StdEncoding.EncodeToString(doc), "i-0zzz"
230 raw, _ := json.Marshal(r)
231 return r, raw, s.verifier()
232 },
233 "empty box": func() (Record, []byte, Verifier) {
234 r, raw := s.record(t, "", "111122223333", "eu-west-1", "i-0abc")
235 return r, raw, s.verifier()
236 },
237 }
238 for name, mk := range cases {
239 t.Run(name, func(t *testing.T) {
240 r, raw, v := mk()
241 if _, err := v.Verify(r, raw, nil); !errors.Is(err, ErrUnverified) {
242 t.Fatalf("err = %v, want ErrUnverified", err)
243 }
244 })
245 }
246}
247
248func TestLegacyLabelNeedsABindNamingThisExactRecord(t *testing.T) {
249 s := newSigner(t)
250 rec, raw := s.record(t, "annie-welding", "111122223333", "eu-west-1", "i-0abc")
251 if _, err := s.verifier().Verify(rec, raw, nil); !errors.Is(err, ErrUnverified) {
252 t.Fatalf("no bind: err = %v, want ErrUnverified", err)
253 }
254 sum := sha256.Sum256(raw)
255 good := &Bind{RecordSHA256: hex.EncodeToString(sum[:]), InstanceID: "i-0abc", TofuInstanceID: "i-0abc", Operator: "paul", At: "2026-10-05T00:00:00Z"}
256 if _, err := s.verifier().Verify(rec, raw, good); err != nil {
257 t.Fatalf("good bind: %v", err)
258 }
259 for name, b := range map[string]Bind{
260 "another record's hash": {RecordSHA256: "00", InstanceID: "i-0abc", TofuInstanceID: "i-0abc", Operator: "paul"},
261 "tofu names another box": {RecordSHA256: good.RecordSHA256, InstanceID: "i-0abc", TofuInstanceID: "i-0zzz", Operator: "paul"},
262 "bind names another box": {RecordSHA256: good.RecordSHA256, InstanceID: "i-0zzz", TofuInstanceID: "i-0zzz", Operator: "paul"},
263 "no operator": {RecordSHA256: good.RecordSHA256, InstanceID: "i-0abc", TofuInstanceID: "i-0abc"},
264 } {
265 b := b
266 t.Run(name, func(t *testing.T) {
267 if _, err := s.verifier().Verify(rec, raw, &b); !errors.Is(err, ErrUnverified) {
268 t.Fatalf("err = %v, want ErrUnverified", err)
269 }
270 })
271 }
272}
273
274func TestPublishIsCreateOnlyAndIdempotent(t *testing.T) {
275 ctx, st, s := context.Background(), release.NewDirStore(t.TempDir()), newSigner(t)
276 rec, _ := s.record(t, "i-0abc", "111122223333", "eu-west-1", "i-0abc")
277 if err := Publish(ctx, st, rec); err != nil {
278 t.Fatal(err)
279 }
280 if err := Publish(ctx, st, rec); err != nil {
281 t.Fatalf("republishing the same record: %v", err)
282 }
283 other, _ := s.record(t, "i-0abc", "111122223333", "eu-west-1", "i-0abc")
284 other.WrittenAt = "2027-01-01T00:00:00Z"
285 if err := Publish(ctx, st, other); !errors.Is(err, release.ErrCASConflict) {
286 t.Fatalf("a different record for the same box: err = %v, want ErrCASConflict", err)
287 }
288 got, raw, bind, err := Load(ctx, st, "i-0abc")
289 if err != nil || got.WrittenAt != rec.WrittenAt || len(raw) == 0 || bind != nil {
290 t.Fatalf("Load = %+v, %d bytes, bind %v, err %v", got, len(raw), bind, err)
291 }
292}
293
294func TestLoadReadsABindWhenThereIsOne(t *testing.T) {
295 ctx, st, s := context.Background(), release.NewDirStore(t.TempDir()), newSigner(t)
296 rec, _ := s.record(t, "annie-welding", "111122223333", "eu-west-1", "i-0abc")
297 if err := Publish(ctx, st, rec); err != nil {
298 t.Fatal(err)
299 }
300 body, _ := json.Marshal(Bind{RecordSHA256: "aa", InstanceID: "i-0abc", TofuInstanceID: "i-0abc", Operator: "paul"})
301 if _, err := st.Put(ctx, BindKey("annie-welding"), body, ""); err != nil {
302 t.Fatal(err)
303 }
304 _, _, bind, err := Load(ctx, st, "annie-welding")
305 if err != nil || bind == nil || bind.Operator != "paul" {
306 t.Fatalf("bind = %+v, err %v", bind, err)
307 }
308}
309
310// Review Focus 5, the storage half: box A's own record, unchanged and
311// validly signed, stored under box B's key must not load as B.
312func TestLoadRefusesAnotherBoxesRecordUnderThisKey(t *testing.T) {
313 ctx, st, s := context.Background(), release.NewDirStore(t.TempDir()), newSigner(t)
314 _, raw := s.record(t, "i-0aaa", "111122223333", "eu-west-1", "i-0aaa")
315 if _, err := st.Put(ctx, Key("i-0bbb"), raw, ""); err != nil {
316 t.Fatal(err)
317 }
318 if _, _, _, err := Load(ctx, st, "i-0bbb"); !errors.Is(err, ErrUnverified) {
319 t.Fatalf("err = %v, want ErrUnverified", err)
320 }
321}
322```
323
324- [ ] **Step 2: Run the tests to see them fail**
325
326Run: `go test ./internal/boxid/ -count=1`
327Expected: FAIL to build, `undefined: Record`.
328
329- [ ] **Step 3: Write `record.go`**
330
331```go
332// Package boxid binds the name a claim uses for a box to one AWS instance
333// (region HA spec 1 §5.1). The binding is the box's own AWS-signed identity
334// document, written once. Nothing else is evidence of a box's identity: not
335// an edges file, not a status snapshot.
336package boxid
337
338import (
339 "bytes"
340 "context"
341 "encoding/json"
342 "errors"
343 "fmt"
344 "os"
345
346 "github.com/carlosframework/platform/internal/release"
347)
348
349const Prefix = "control/boxes/"
350
351func Key(box string) string { return Prefix + box + ".json" }
352func BindKey(box string) string { return Prefix + box + ".bind.json" }
353
354type Record struct {
355 V int `json:"v"`
356 Box string `json:"box"`
357 IdentityDocument string `json:"identity_document"`
358 IdentitySignature string `json:"identity_signature"`
359 WrittenAt string `json:"written_at"`
360}
361
362// Bind is an operator's statement that a legacy label belongs to one
363// instance. It names the exact record bytes it vouches for.
364type Bind struct {
365 RecordSHA256 string `json:"record_sha256"`
366 InstanceID string `json:"instance_id"`
367 TofuInstanceID string `json:"tofu_instance_id"`
368 Operator string `json:"operator"`
369 At string `json:"at"`
370}
371
372// Publish writes a box's record create-only. Writing the same bytes again
373// succeeds; a different record for the same box is ErrCASConflict, because
374// a record is never replaced.
375func Publish(ctx context.Context, s release.Store, rec Record) error {
376 body, err := json.Marshal(rec)
377 if err != nil {
378 return fmt.Errorf("boxid: marshal: %w", err)
379 }
380 _, err = s.Put(ctx, Key(rec.Box), body, "")
381 if !errors.Is(err, release.ErrCASConflict) {
382 return err
383 }
384 cur, _, gerr := s.Get(ctx, Key(rec.Box))
385 if gerr != nil {
386 return fmt.Errorf("boxid: %s exists and cannot be read: %w", rec.Box, gerr)
387 }
388 if bytes.Equal(cur, body) {
389 return nil
390 }
391 return fmt.Errorf("boxid: %s already has a different record: %w", rec.Box, release.ErrCASConflict)
392}
393
394// Load returns a box's record, its exact bytes (which a Bind's hash is over)
395// and its bind when one exists. A missing record is os.ErrNotExist-wrapped.
396// A record that names a different box than the key it is stored under is
397// ErrUnverified: the key is part of the binding.
398func Load(ctx context.Context, s release.Store, box string) (Record, []byte, *Bind, error) {
399 raw, _, err := s.Get(ctx, Key(box))
400 if err != nil {
401 return Record{}, nil, nil, err
402 }
403 var rec Record
404 if err := json.Unmarshal(raw, &rec); err != nil {
405 return Record{}, nil, nil, fmt.Errorf("boxid: %s: %w", box, err)
406 }
407 if rec.Box != box {
408 // Another box's record, intact and validly signed, copied under
409 // this name. Verify would accept it as that other box.
410 return Record{}, nil, nil, fmt.Errorf("%w: the record stored for %q names %q", ErrUnverified, box, rec.Box)
411 }
412 braw, _, err := s.Get(ctx, BindKey(box))
413 if errors.Is(err, os.ErrNotExist) {
414 return rec, raw, nil, nil
415 }
416 if err != nil {
417 return Record{}, nil, nil, err
418 }
419 var b Bind
420 if err := json.Unmarshal(braw, &b); err != nil {
421 return Record{}, nil, nil, fmt.Errorf("boxid: %s bind: %w", box, err)
422 }
423 return rec, raw, &b, nil
424}
425```
426
427- [ ] **Step 4: Write `verify.go`**
428
429```go
430package boxid
431
432import (
433 "crypto/sha256"
434 "crypto/x509"
435 "encoding/base64"
436 "encoding/hex"
437 "encoding/json"
438 "errors"
439 "fmt"
440)
441
442// ErrUnverified wraps every reason a record is not evidence of identity.
443var ErrUnverified = errors.New("boxid: record does not verify")
444
445type Identity struct{ AccountID, Region, InstanceID string }
446
447type Verifier struct {
448 Account string
449 Certs map[string]*x509.Certificate
450}
451
452func refuse(format string, a ...any) error {
453 return fmt.Errorf("%w: %s", ErrUnverified, fmt.Sprintf(format, a...))
454}
455
456// Verify checks, in order: the signature under the certificate for the
457// document's own region, the deployment account, and the binding of the
458// record's box id to the signed instance id. A box whose id is its instance
459// id needs nothing more. Any other id is a legacy label and needs a bind
460// that names these exact record bytes and agrees on the instance.
461func (v Verifier) Verify(rec Record, raw []byte, bind *Bind) (Identity, error) {
462 if rec.Box == "" {
463 return Identity{}, refuse("no box id")
464 }
465 doc, err := base64.StdEncoding.DecodeString(rec.IdentityDocument)
466 if err != nil {
467 return Identity{}, refuse("document is not base64")
468 }
469 sig, err := base64.StdEncoding.DecodeString(rec.IdentitySignature)
470 if err != nil {
471 return Identity{}, refuse("signature is not base64")
472 }
473 var d struct {
474 AccountID string `json:"accountId"`
475 Region string `json:"region"`
476 InstanceID string `json:"instanceId"`
477 }
478 if err := json.Unmarshal(doc, &d); err != nil {
479 return Identity{}, refuse("document is not JSON")
480 }
481 cert := v.Certs[d.Region]
482 if cert == nil {
483 return Identity{}, refuse("no signing certificate for region %q", d.Region)
484 }
485 if err := cert.CheckSignature(x509.SHA256WithRSA, doc, sig); err != nil {
486 return Identity{}, refuse("signature: %v", err)
487 }
488 if d.InstanceID == "" {
489 return Identity{}, refuse("document names no instance")
490 }
491 if v.Account == "" || d.AccountID != v.Account {
492 return Identity{}, refuse("account %q is not the deployment's", d.AccountID)
493 }
494 id := Identity{AccountID: d.AccountID, Region: d.Region, InstanceID: d.InstanceID}
495 if rec.Box == d.InstanceID {
496 return id, nil
497 }
498 if bind == nil {
499 return Identity{}, refuse("%q is a legacy label with no operator bind", rec.Box)
500 }
501 sum := sha256.Sum256(raw)
502 switch {
503 case bind.RecordSHA256 != hex.EncodeToString(sum[:]):
504 return Identity{}, refuse("bind is for a different record")
505 case bind.InstanceID != d.InstanceID:
506 return Identity{}, refuse("bind names instance %q, the record %q", bind.InstanceID, d.InstanceID)
507 case bind.TofuInstanceID != d.InstanceID:
508 return Identity{}, refuse("tofu records instance %q, the record %q", bind.TofuInstanceID, d.InstanceID)
509 case bind.Operator == "":
510 return Identity{}, refuse("bind names no operator")
511 }
512 return id, nil
513}
514```
515
516- [ ] **Step 5: Write `certs.go` and add the certificates**
517
518```go
519package boxid
520
521import (
522 "crypto/x509"
523 "embed"
524 "encoding/pem"
525 "fmt"
526 "strings"
527)
528
529//go:embed certs/*.pem
530var certFS embed.FS
531
532// EmbeddedCerts returns AWS's instance-identity signing certificates by
533// region, from certs/<aws-region>.pem. A region with no file has no
534// certificate, so its boxes never verify: that is the safe default.
535func EmbeddedCerts() (map[string]*x509.Certificate, error) {
536 entries, err := certFS.ReadDir("certs")
537 if err != nil {
538 return nil, err
539 }
540 out := map[string]*x509.Certificate{}
541 for _, e := range entries {
542 region, ok := strings.CutSuffix(e.Name(), ".pem")
543 if !ok {
544 continue
545 }
546 raw, err := certFS.ReadFile("certs/" + e.Name())
547 if err != nil {
548 return nil, err
549 }
550 block, _ := pem.Decode(raw)
551 if block == nil {
552 return nil, fmt.Errorf("boxid: certs/%s is not PEM", e.Name())
553 }
554 cert, err := x509.ParseCertificate(block.Bytes)
555 if err != nil {
556 return nil, fmt.Errorf("boxid: certs/%s: %w", e.Name(), err)
557 }
558 out[region] = cert
559 }
560 return out, nil
561}
562```
563
564Download the four regional certificates the flagship uses. They are on the AWS page "AWS public certificates for instance identity signatures", https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/regions-certs.html . For each of `eu-west-1`, `eu-west-2`, `us-east-1` and `ap-southeast-2`, open the region, take the certificate under the **"Base64-encoded signature"** tab (not the PKCS7 or RSA-2048 tabs), and save it verbatim, including the `BEGIN`/`END CERTIFICATE` lines, as `internal/boxid/certs/<region>.pem`.
565
566Write `internal/boxid/certs/README.md`:
567
568```markdown
569AWS's public certificates for the base64-encoded instance identity
570signature (IMDS `/latest/dynamic/instance-identity/signature`), one per
571region, from
572https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/regions-certs.html
573(the "Base64-encoded signature" tab). A region with no file here has no
574certificate, and a box there never verifies. Add a region's file before
575running a second box in it.
576```
577
578Append to `boxid_test.go`:
579
580```go
581func TestEmbeddedCertsCoverTheFlagshipRegions(t *testing.T) {
582 certs, err := EmbeddedCerts()
583 if err != nil {
584 t.Fatal(err)
585 }
586 for _, region := range []string{"eu-west-1", "eu-west-2", "us-east-1", "ap-southeast-2"} {
587 c := certs[region]
588 if c == nil {
589 t.Errorf("no certificate for %s", region)
590 continue
591 }
592 if c.PublicKeyAlgorithm != x509.RSA {
593 t.Errorf("%s: %v key; the base64-signature certificate is RSA (the PKCS7 one is DSA: wrong tab)", region, c.PublicKeyAlgorithm)
594 }
595 }
596}
597```
598
599- [ ] **Step 6: Run the tests**
600
601Run: `go test ./internal/boxid/ -count=1`
602Expected: PASS.
603
604- [ ] **Step 7: Check the signature endpoint against a real box**
605
606This is the one step that needs AWS. On the dev box (ask Paul for the session; do not run it against a production box unprompted), run:
607
608```bash
609TOKEN=$(curl -sX PUT http://169.254.169.254/latest/api/token -H 'X-aws-ec2-metadata-token-ttl-seconds: 60')
610curl -sH "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/dynamic/instance-identity/document > doc.json
611curl -sH "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/dynamic/instance-identity/signature | base64 -d > sig.bin
612openssl x509 -in <region>.pem -pubkey -noout > pub.pem
613openssl dgst -sha256 -verify pub.pem -signature sig.bin doc.json
614```
615
616Expected: `Verified OK`. If it prints `Verification Failure`, stop and report: the certificate tab or the digest is wrong, and `Verify` must not ship until this passes. Record the result in the task's commit message.
617
618- [ ] **Step 8: Commit**
619
620```bash
621git add internal/boxid
622git commit -m "boxid: a box record verifies only under AWS's signature, for its own instance
623
624Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
625```
626
627---
628
629### Task 2: Box evidence in the edges file
630
631**Files:**
632- Modify: `internal/host/edgesfile.go` (struct at `:156-232`, ident at `:638-650`), `internal/console/instancesplacement.go:38`
633- Create: `internal/host/headroom_linux.go`, `internal/host/headroom_other.go`
634- Test: `internal/host/edgesfile_test.go`, `internal/host/headroom_test.go`, `internal/console/regioncandidates_test.go`
635
636**Interfaces:**
637- Consumes: `status.InstanceID() string`; `edgesFilePass(ctx, s, reg, ident, eip, &last)`.
638- Produces, in `internal/host`:
639 ```go
640 type edgeHeadroom struct {
641 MemFreeMiB int64 `json:"mem_free_mib"`
642 DiskFreeMiB int64 `json:"disk_free_mib"`
643 Awake int `json:"awake"`
644 }
645 // new edgesFile fields:
646 BoxID string `json:"box_id,omitempty"`
647 Capabilities []string `json:"capabilities,omitempty"`
648 Draining bool `json:"draining,omitempty"`
649 Headroom *edgeHeadroom `json:"headroom,omitempty"`
650 const capRegionHA1 = "region-ha-1"
651 func edgesFileBoxID(instanceID, label string) string
652 func edgesFileCapabilities(getenv func(string) string) []string
653 func readHeadroom(dataDir string, awake int) *edgeHeadroom
654 func awakeCount(reg *registry.Registry) int
655 // edgesFilePass keeps its signature and calls this with refresh false:
656 func edgesFileWrite(ctx context.Context, s release.Store, reg *registry.Registry, ident edgesFile, eip string, last *[]byte, refresh bool) error
657 ```
658 and in `internal/console`, on `edgesFilePlacementView`:
659 ```go
660 BoxID string `json:"box_id"`
661 Capabilities []string `json:"capabilities"`
662 Draining bool `json:"draining"`
663 Headroom *struct {
664 MemFreeMiB int64 `json:"mem_free_mib"`
665 DiskFreeMiB int64 `json:"disk_free_mib"`
666 Awake int `json:"awake"`
667 } `json:"headroom"`
668 ```
669
670Headroom changes constantly, so it must not defeat the pass's "nothing changed, skip the write" gate. The gate compares a marshalled file with `Generation` blanked; this task blanks `Headroom` in the gate copy too. That alone would leave headroom stale for ever on a quiet box, because the loop's timed pass goes through the same gate. So the pass gains one input, `refresh`, true only on the timed pass: a file that carries headroom is then written even if nothing else changed. A box that publishes no headroom behaves exactly as before.
671
672- [ ] **Step 1: Write the failing tests**
673
674Append to `internal/host/edgesfile_test.go`:
675
676```go
677func TestEdgesFileBoxIDIsTheInstanceIDWhenThereIsOne(t *testing.T) {
678 if got := edgesFileBoxID("i-0abc", "annie-welding"); got != "i-0abc" {
679 t.Fatalf("on EC2: %q", got)
680 }
681 if got := edgesFileBoxID("", "annie-welding"); got != "annie-welding" {
682 t.Fatalf("off EC2: %q", got)
683 }
684}
685
686func TestEdgesFileCapabilityFollowsTheFlag(t *testing.T) {
687 env := func(v string) func(string) string {
688 return func(k string) string {
689 if k == "CARLOS_REGION_HA" {
690 return v
691 }
692 return ""
693 }
694 }
695 if got := edgesFileCapabilities(env("")); len(got) != 0 {
696 t.Fatalf("unset: %v", got)
697 }
698 if got := edgesFileCapabilities(env("1")); len(got) != 1 || got[0] != "region-ha-1" {
699 t.Fatalf("set: %v", got)
700 }
701 if got := edgesFileCapabilities(env("0")); len(got) != 0 {
702 t.Fatalf("\"0\" is off: %v", got)
703 }
704}
705
706func TestEdgesFileRawWireBodyCarriesBoxEvidence(t *testing.T) {
707 s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t)
708 seedRoute(t, reg, "app.example.com")
709 var last []byte
710 ident := edgesFile{Box: "annie", BoxID: "i-0abc", Capabilities: []string{"region-ha-1"},
711 Headroom: &edgeHeadroom{MemFreeMiB: 2210, DiskFreeMiB: 31200, Awake: 14}}
712 if err := edgesFilePass(context.Background(), s, reg, ident, "1.2.3.4", &last); err != nil {
713 t.Fatal(err)
714 }
715 body, _, err := s.Get(context.Background(), "control/edges/annie.json")
716 if err != nil {
717 t.Fatal(err)
718 }
719 var raw map[string]json.RawMessage
720 if err := json.Unmarshal(body, &raw); err != nil {
721 t.Fatal(err)
722 }
723 for key, want := range map[string]string{
724 "box_id": `"i-0abc"`,
725 "capabilities": `["region-ha-1"]`,
726 "headroom": `{"mem_free_mib":2210,"disk_free_mib":31200,"awake":14}`,
727 } {
728 if string(raw[key]) != want {
729 t.Errorf("%s = %s, want %s", key, raw[key], want)
730 }
731 }
732 if _, ok := raw["draining"]; ok {
733 t.Error("draining:false must be omitted")
734 }
735}
736
737func TestEdgesFileOmitsBoxEvidenceWhenAbsent(t *testing.T) {
738 s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t)
739 var last []byte
740 if err := edgesFilePass(context.Background(), s, reg, edgesFile{Box: "annie"}, "1.2.3.4", &last); err != nil {
741 t.Fatal(err)
742 }
743 body, _, _ := s.Get(context.Background(), "control/edges/annie.json")
744 for _, key := range []string{"box_id", "capabilities", "draining", "headroom"} {
745 if bytes.Contains(body, []byte(`"`+key+`"`)) {
746 t.Errorf("%s present in %s", key, body)
747 }
748 }
749}
750
751func TestEdgesFileHeadroomAloneDoesNotRewriteTheFile(t *testing.T) {
752 st := &putCountingStore{Store: release.NewDirStore(t.TempDir())}
753 reg := edgesTestRegistry(t)
754 seedRoute(t, reg, "app.example.com")
755 var last []byte
756 ident := edgesFile{Box: "annie", Headroom: &edgeHeadroom{MemFreeMiB: 100}}
757 if err := edgesFilePass(context.Background(), st, reg, ident, "1.2.3.4", &last); err != nil {
758 t.Fatal(err)
759 }
760 ident.Headroom = &edgeHeadroom{MemFreeMiB: 99}
761 if err := edgesFilePass(context.Background(), st, reg, ident, "1.2.3.4", &last); err != nil {
762 t.Fatal(err)
763 }
764 if st.puts != 1 {
765 t.Fatalf("puts = %d, want 1: a headroom change alone is not a reason to write", st.puts)
766 }
767}
768
769// The loop's timed pass refreshes headroom even when nothing else moved.
770func TestEdgesFileTimedPassRefreshesHeadroom(t *testing.T) {
771 st := &putCountingStore{Store: release.NewDirStore(t.TempDir())}
772 reg := edgesTestRegistry(t)
773 seedRoute(t, reg, "app.example.com")
774 var last []byte
775 ident := edgesFile{Box: "annie", Headroom: &edgeHeadroom{MemFreeMiB: 100}}
776 if err := edgesFileWrite(context.Background(), st, reg, ident, "1.2.3.4", &last, false); err != nil {
777 t.Fatal(err)
778 }
779 ident.Headroom = &edgeHeadroom{MemFreeMiB: 42}
780 if err := edgesFileWrite(context.Background(), st, reg, ident, "1.2.3.4", &last, true); err != nil {
781 t.Fatal(err)
782 }
783 if st.puts != 2 {
784 t.Fatalf("puts = %d, want 2: the timed pass must publish the new reading", st.puts)
785 }
786 body, _, err := st.Get(context.Background(), "control/edges/annie.json")
787 if err != nil {
788 t.Fatal(err)
789 }
790 var f edgesFile
791 if err := json.Unmarshal(body, &f); err != nil || f.Headroom == nil || f.Headroom.MemFreeMiB != 42 {
792 t.Fatalf("published headroom = %+v, err %v", f.Headroom, err)
793 }
794 // A box that publishes no headroom keeps today's behaviour exactly:
795 // a timed pass with nothing changed writes nothing.
796 plain := &putCountingStore{Store: release.NewDirStore(t.TempDir())}
797 var plainLast []byte
798 for i := 0; i < 2; i++ {
799 if err := edgesFileWrite(context.Background(), plain, reg, edgesFile{Box: "betty"}, "1.2.3.4", &plainLast, true); err != nil {
800 t.Fatal(err)
801 }
802 }
803 if plain.puts != 1 {
804 t.Fatalf("puts = %d, want 1 for a box with no headroom", plain.puts)
805 }
806}
807
808// A restart must not rewrite an unchanged file just because the stored
809// copy carries a headroom reading.
810func TestEdgesFileSeededGateIgnoresStoredHeadroom(t *testing.T) {
811 st := &putCountingStore{Store: release.NewDirStore(t.TempDir())}
812 reg := edgesTestRegistry(t)
813 seedRoute(t, reg, "app.example.com")
814 var last []byte
815 ident := edgesFile{Box: "annie", Headroom: &edgeHeadroom{MemFreeMiB: 100}}
816 if err := edgesFilePass(context.Background(), st, reg, ident, "1.2.3.4", &last); err != nil {
817 t.Fatal(err)
818 }
819 seeded := seedEdgesFileGate(context.Background(), st, "annie")
820 ident.Headroom = &edgeHeadroom{MemFreeMiB: 7}
821 if err := edgesFilePass(context.Background(), st, reg, ident, "1.2.3.4", &seeded); err != nil {
822 t.Fatal(err)
823 }
824 if st.puts != 1 {
825 t.Fatalf("puts = %d, want 1", st.puts)
826 }
827}
828```
829
830Create `internal/host/headroom_test.go`:
831
832```go
833package host
834
835import "testing"
836
837func TestParseMemAvailable(t *testing.T) {
838 const meminfo = "MemTotal: 16000000 kB\nMemFree: 500000 kB\nMemAvailable: 2263040 kB\n"
839 if got := parseMemAvailableMiB(meminfo); got != 2210 {
840 t.Fatalf("got %d, want 2210", got)
841 }
842 if got := parseMemAvailableMiB("MemTotal: 1 kB\n"); got != 0 {
843 t.Fatalf("no MemAvailable line: got %d, want 0", got)
844 }
845 if got := parseMemAvailableMiB("MemAvailable: lots kB\n"); got != 0 {
846 t.Fatalf("unparseable: got %d, want 0", got)
847 }
848}
849```
850
851Append to `internal/console/regioncandidates_test.go`:
852
853```go
854func TestEdgesViewParsesBoxEvidenceAndToleratesItsAbsence(t *testing.T) {
855 ctx, s := context.Background(), release.NewDirStore(t.TempDir())
856 with := `{"v":1,"box":"i-0abc","eip":"1.2.3.4","region":"eu-west-2","placement":"uk","hosts":{},"box_id":"i-0abc","capabilities":["region-ha-1"],"draining":true,"headroom":{"mem_free_mib":2210,"disk_free_mib":31200,"awake":14}}`
857 without := `{"v":1,"box":"old-box","eip":"5.6.7.8","region":"eu-west-1","hosts":{}}`
858 for k, b := range map[string]string{"i-0abc": with, "old-box": without} {
859 if _, err := s.Put(ctx, "control/edges/"+k+".json", []byte(b), ""); err != nil {
860 t.Fatal(err)
861 }
862 }
863 snap, err := readEdgesSnapshot(ctx, s)
864 if err != nil || !snap.Complete {
865 t.Fatalf("snap complete=%v err=%v", snap.Complete, err)
866 }
867 by := map[string]edgesFilePlacementView{}
868 for _, f := range snap.Files {
869 by[f.Box] = f
870 }
871 n := by["i-0abc"]
872 if n.BoxID != "i-0abc" || len(n.Capabilities) != 1 || !n.Draining || n.Headroom == nil || n.Headroom.Awake != 14 {
873 t.Fatalf("new box parsed as %+v", n)
874 }
875 o := by["old-box"]
876 if o.BoxID != "" || o.Capabilities != nil || o.Draining || o.Headroom != nil {
877 t.Fatalf("old box parsed as %+v", o)
878 }
879}
880```
881
882- [ ] **Step 2: Run them to see them fail**
883
884Run: `go test ./internal/host/ -run 'EdgesFile|ParseMemAvailable' -count=1`
885Expected: FAIL to build, `undefined: edgesFileBoxID`.
886
887- [ ] **Step 3: Add the fields and helpers to `internal/host/edgesfile.go`**
888
889Add the import (the file does not have it):
890
891```go
892 "github.com/carlosframework/platform/internal/status"
893```
894
895After `SecretsKeyFP` in `edgesFile`:
896
897```go
898 // BoxID is the name claims use for this box (region HA spec 1 §2): the
899 // EC2 instance id, or the label off EC2. It is NOT evidence of identity:
900 // that is the box record (internal/boxid). A reader compares the two and
901 // treats a mismatch as an ineligible box.
902 BoxID string `json:"box_id,omitempty"`
903 // Capabilities gates multi-box regions (spec §8): "region-ha-1" once
904 // CARLOS_REGION_HA is on.
905 Capabilities []string `json:"capabilities,omitempty"`
906 // Draining is set while the placer empties this box (spec 4).
907 Draining bool `json:"draining,omitempty"`
908 // Headroom is what placement chooses by (spec 2). It is refreshed when
909 // the file is written for another reason and on the loop's own tick; a
910 // headroom change alone never forces a write (see edgesFilePass).
911 Headroom *edgeHeadroom `json:"headroom,omitempty"`
912```
913
914Above `edgesFile`'s doc comment:
915
916```go
917// edgeHeadroom is this box's spare capacity at the time the file was
918// written. Zero values mean "could not read", never "none left": a chooser
919// ranks an unreadable box last, it does not exclude it.
920type edgeHeadroom struct {
921 MemFreeMiB int64 `json:"mem_free_mib"`
922 DiskFreeMiB int64 `json:"disk_free_mib"`
923 Awake int `json:"awake"`
924}
925
926const capRegionHA1 = "region-ha-1"
927
928// edgesFileBoxID is the instance id on EC2 and the label everywhere else.
929func edgesFileBoxID(instanceID, label string) string {
930 if instanceID != "" {
931 return instanceID
932 }
933 return label
934}
935
936// edgesFileCapabilities publishes region-ha-1 only when CARLOS_REGION_HA is
937// set to something other than "" or "0".
938func edgesFileCapabilities(getenv func(string) string) []string {
939 switch strings.TrimSpace(getenv("CARLOS_REGION_HA")) {
940 case "", "0":
941 return nil
942 }
943 return []string{capRegionHA1}
944}
945
946// awakeCount is how many routes this box has running now. An error reads
947// as zero: headroom is advisory.
948func awakeCount(reg *registry.Registry) int {
949 routes, err := reg.List()
950 if err != nil {
951 return 0
952 }
953 n := 0
954 for _, r := range routes {
955 if r.Status == "ready" && !r.Hibernate {
956 n++
957 }
958 }
959 return n
960}
961```
962
963`Hibernate` true means the route sleeps between requests, so a ready route with `Hibernate` false is always running. This under-counts hibernating routes that happen to be awake. That is acceptable here: the field is advisory and spec 2 decides what the chooser really needs.
964
965Split the pass in two. Keep `edgesFilePass`'s signature, so none of its many callers in tests change, and move its body into `edgesFileWrite`:
966
967```go
968func edgesFilePass(ctx context.Context, s release.Store, reg *registry.Registry, ident edgesFile, eip string, last *[]byte) error {
969 return edgesFileWrite(ctx, s, reg, ident, eip, last, false)
970}
971
972// edgesFileWrite is edgesFilePass with one more input. refresh is true on
973// the loop's own timed pass: the file is then written even if nothing but
974// its headroom reading changed, so headroom is never more than one timer
975// period stale. On every other pass a headroom change alone is not a
976// reason to write.
977func edgesFileWrite(ctx context.Context, s release.Store, reg *registry.Registry, ident edgesFile, eip string, last *[]byte, refresh bool) error {
978 // ... the existing body of edgesFilePass, unchanged except for the gate below
979```
980
981In that body, replace the gate:
982
983```go
984 f := ident
985 f.V, f.EIP, f.Hosts, f.Generation = 1, eip, hosts, ""
986 gate, err := json.Marshal(f)
987 if err != nil {
988 return fmt.Errorf("edges: marshal: %w", err)
989 }
990 if bytes.Equal(gate, *last) {
991 return nil
992 }
993```
994
995with:
996
997```go
998 f := ident
999 f.V, f.EIP, f.Hosts, f.Generation = 1, eip, hosts, ""
1000 gateCopy := f
1001 gateCopy.Headroom = nil // a changing reading is not, by itself, a reason to write
1002 gate, err := json.Marshal(gateCopy)
1003 if err != nil {
1004 return fmt.Errorf("edges: marshal: %w", err)
1005 }
1006 if bytes.Equal(gate, *last) && !(refresh && f.Headroom != nil) {
1007 return nil
1008 }
1009```
1010
1011Leave the rest as it is: `body` is still marshalled from `f`, which keeps its headroom, and `*last = gate` still stores the headroom-free gate.
1012
1013In `seedEdgesFileGate`, beside the line that clears `Generation`:
1014
1015```go
1016 prior.Generation = ""
1017 prior.Headroom = nil // the gate never includes headroom: see edgesFileWrite
1018```
1019
1020In `edgesFileLoop`, add two fields to the `ident` literal and read the data directory once after it:
1021
1022```go
1023 SecretsKeyFP: edgesFileSecretsFP(os.Getenv),
1024 BoxID: edgesFileBoxID(status.InstanceID(), box),
1025 Capabilities: edgesFileCapabilities(os.Getenv),
1026 }
1027 dataDir := provisionTickConfig(os.Getenv).DataDir
1028```
1029
1030and replace the loop's one call, `err = edgesFilePass(ctx, store, reg, ident, eip, &last)`, with:
1031
1032```go
1033 ident.Headroom = readHeadroom(dataDir, awakeCount(reg))
1034 err = edgesFileWrite(ctx, store, reg, ident, eip, &last, sched.timed)
1035```
1036
1037`sched.timed` is the schedule's own flag for "this pass is the timer's" (`edgesFileSchedule`, set in `due`). The loop already reads it two lines above to decide whether to re-read the public IP.
1038
1039- [ ] **Step 4: Write the headroom readers**
1040
1041`internal/host/headroom_linux.go`:
1042
1043```go
1044//go:build linux
1045
1046package host
1047
1048import (
1049 "os"
1050 "strconv"
1051 "strings"
1052
1053 "golang.org/x/sys/unix"
1054)
1055
1056// readHeadroom reads spare memory from /proc/meminfo and spare disk from
1057// the data directory's filesystem. A reading that fails is left at zero.
1058func readHeadroom(dataDir string, awake int) *edgeHeadroom {
1059 h := &edgeHeadroom{Awake: awake}
1060 if b, err := os.ReadFile("/proc/meminfo"); err == nil {
1061 h.MemFreeMiB = parseMemAvailableMiB(string(b))
1062 }
1063 var st unix.Statfs_t
1064 if err := unix.Statfs(dataDir, &st); err == nil {
1065 h.DiskFreeMiB = int64(st.Bavail) * int64(st.Bsize) / (1 << 20)
1066 }
1067 return h
1068}
1069
1070// parseMemAvailableMiB returns the MemAvailable line in MiB, or 0.
1071func parseMemAvailableMiB(meminfo string) int64 {
1072 for _, line := range strings.Split(meminfo, "\n") {
1073 rest, ok := strings.CutPrefix(line, "MemAvailable:")
1074 if !ok {
1075 continue
1076 }
1077 fields := strings.Fields(rest)
1078 if len(fields) == 0 {
1079 return 0
1080 }
1081 kb, err := strconv.ParseInt(fields[0], 10, 64)
1082 if err != nil {
1083 return 0
1084 }
1085 return kb / 1024
1086 }
1087 return 0
1088}
1089```
1090
1091`internal/host/headroom_other.go`:
1092
1093```go
1094//go:build !linux
1095
1096package host
1097
1098import (
1099 "strconv"
1100 "strings"
1101)
1102
1103// readHeadroom has no readings off Linux: only the awake count.
1104func readHeadroom(dataDir string, awake int) *edgeHeadroom { return &edgeHeadroom{Awake: awake} }
1105
1106func parseMemAvailableMiB(meminfo string) int64 {
1107 for _, line := range strings.Split(meminfo, "\n") {
1108 rest, ok := strings.CutPrefix(line, "MemAvailable:")
1109 if !ok {
1110 continue
1111 }
1112 fields := strings.Fields(rest)
1113 if len(fields) == 0 {
1114 return 0
1115 }
1116 kb, err := strconv.ParseInt(fields[0], 10, 64)
1117 if err != nil {
1118 return 0
1119 }
1120 return kb / 1024
1121 }
1122 return 0
1123}
1124```
1125
1126The parser is duplicated so `headroom_test.go` builds on every platform without a third file. Keep the two bodies identical.
1127
1128- [ ] **Step 5: Add the fields to the console's view**
1129
1130In `internal/console/instancesplacement.go`, after `SecretsKeyFP` in `edgesFilePlacementView`:
1131
1132```go
1133 // Box evidence (region HA spec 1 §5.1). All absent on a writer that
1134 // predates it: BoxID "", Capabilities nil, Draining false, Headroom nil.
1135 BoxID string `json:"box_id"`
1136 Capabilities []string `json:"capabilities"`
1137 Draining bool `json:"draining"`
1138 Headroom *struct {
1139 MemFreeMiB int64 `json:"mem_free_mib"`
1140 DiskFreeMiB int64 `json:"disk_free_mib"`
1141 Awake int `json:"awake"`
1142 } `json:"headroom"`
1143```
1144
1145Run `gofmt -w internal/console/instancesplacement.go`: the new fields change the struct's column alignment, and the gate checks formatting.
1146
1147- [ ] **Step 6: Check the other parsers**
1148
1149Run: `grep -rn "control/edges\|edgesFileView\|edgesFilePrefix" --include=*.go internal/edgesfile internal/steering internal/edge | grep -v _test`
1150
1151For each struct that unmarshals an edges file, confirm it ignores unknown fields (Go's default) and has no `DisallowUnknownFields`. Run `grep -rn "DisallowUnknownFields" internal/edgesfile internal/steering internal/edge internal/console`. Expected: no hit on an edges-file decoder. If there is one, add the four fields to that struct in this task.
1152
1153- [ ] **Step 7: Run the tests**
1154
1155Run: `go test ./internal/host/ ./internal/console/ ./internal/edge/ ./internal/edgesfile/ -count=1 && GOOS=darwin go build ./... && GOOS=windows go build ./...`
1156Expected: PASS, and both cross-builds succeed.
1157
1158- [ ] **Step 8: Commit**
1159
1160```bash
1161git add internal/host internal/console
1162git commit -m "edges: a box publishes its id, the HA capability, draining and headroom
1163
1164Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
1165```
1166
1167---
1168
1169### Task 3: Which boxes are eligible, and which one to choose (`internal/console/boxchoice.go`)
1170
1171**Files:**
1172- Create: `internal/console/boxchoice.go`
1173- Test: `internal/console/boxchoice_test.go`
1174
1175**Interfaces:**
1176- Consumes: `edgesSnapshot`, `edgesFilePlacementView` (with Task 2's fields), `isTrue`, `provision.ValidRegion`, `boxid.Identity`.
1177- Produces:
1178 ```go
1179 type boxCandidate struct {
1180 BoxID string
1181 Region string // AWS region, from the verified box record
1182 MemFreeMiB, DiskFreeMiB int64
1183 Awake int
1184 }
1185 // verified maps box id to its verified identity; a box absent from it is never eligible.
1186 // awsRegion is the AWS region the caller expects for this placement; "" offers nothing.
1187 // lost is the set of box ids a claim has quarantined.
1188 func eligibleBoxes(snap edgesSnapshot, verified map[string]boxid.Identity, placement, awsRegion, homeName, expectedFP string, lost map[string]bool) []boxCandidate
1189 // running is, per box id, how many hosts claims place there and which apps ("acct/app") they belong to.
1190 type boxLoad struct {
1191 Hosts int
1192 Apps map[string]int
1193 }
1194 func chooseBox(cands []boxCandidate, running map[string]boxLoad, app string) (boxCandidate, bool)
1195 ```
1196 `placement` is the instance's region code (`""` for home). Pools are not an input yet: nearest-region's predicate covers the shared pool only (`serves_shared_pool`), and pool-aware candidates are its stated non-goal. Plan 1b decides pooled records.
1197
1198The eligibility predicate is nearest-region's, applied to one box, plus spec 1's additions: a verified record whose instance matches `box_id`, the capability, not draining, not lost.
1199
1200**The region is checked against the signature, not the file.** A box's placement code (`uk`) is its own configuration. Its AWS region is in its signed identity. So the caller passes the AWS region it expects for the placement, and a box is eligible only if its signed region and its file's `region` both equal it. Plan 1b decides where the expected region comes from (nearest-region's `regionCandidate.AWSRegion` is a majority vote over the same unsigned files, which is not enough on its own).
1201
1202**`chooseBox` follows spec §5.2 exactly:** a box that runs no instance of the app is preferred; among boxes equal on that, the fewest claimed hosts; then the lowest box id. It compares whether a box runs the app at all, not how many of it.
1203
1204- [ ] **Step 1: Write the failing tests**
1205
1206`internal/console/boxchoice_test.go`:
1207
1208```go
1209package console
1210
1211import (
1212 "encoding/json"
1213 "testing"
1214
1215 "github.com/carlosframework/platform/internal/boxid"
1216)
1217
1218// haBox is an eligible box's edges file in placement "uk".
1219func haBox(id string, mut func(m map[string]any)) string {
1220 m := map[string]any{
1221 "v": 1, "box": id, "box_id": id, "eip": "1.2.3.4", "region": "eu-west-2", "placement": "uk",
1222 "hosts": map[string]any{}, "hibernation_configured": true, "serves_shared_pool": true,
1223 "accepts_inbound": true, "secrets_key_fp": testKeyFP, "capabilities": []string{"region-ha-1"},
1224 "headroom": map[string]any{"mem_free_mib": 1000, "disk_free_mib": 1000, "awake": 1},
1225 }
1226 if mut != nil {
1227 mut(m)
1228 }
1229 b, _ := json.Marshal(m)
1230 return string(b)
1231}
1232
1233func ids(cs []boxCandidate) []string {
1234 out := make([]string, len(cs))
1235 for i, c := range cs {
1236 out[i] = c.BoxID
1237 }
1238 return out
1239}
1240
1241func verifiedFor(boxes ...string) map[string]boxid.Identity {
1242 out := map[string]boxid.Identity{}
1243 for _, b := range boxes {
1244 out[b] = boxid.Identity{AccountID: "1", Region: "eu-west-2", InstanceID: b}
1245 }
1246 return out
1247}
1248
1249func TestEligibleBoxesAppliesThePredicateOneBoxAtATime(t *testing.T) {
1250 del := func(k string) func(map[string]any) { return func(m map[string]any) { delete(m, k) } }
1251 set := func(k string, v any) func(map[string]any) { return func(m map[string]any) { m[k] = v } }
1252 cases := []struct {
1253 name string
1254 mut func(map[string]any)
1255 want bool
1256 }{
1257 {"all present", nil, true},
1258 {"no dial address", set("eip", ""), false},
1259 {"hibernation unconfigured", set("hibernation_configured", false), false},
1260 {"hibernation unknown", del("hibernation_configured"), false},
1261 {"refuses the shared pool", set("serves_shared_pool", false), false},
1262 {"refuses inbound", set("accepts_inbound", false), false},
1263 {"wrong secrets key", set("secrets_key_fp", "sha256:other"), false},
1264 {"no capability", del("capabilities"), false},
1265 {"draining", set("draining", true), false},
1266 {"another placement", set("placement", "us"), false},
1267 {"box_id disagrees with its record", set("box_id", "i-0zzz"), false},
1268 }
1269 for _, tc := range cases {
1270 t.Run(tc.name, func(t *testing.T) {
1271 snap := views(t, haBox("i-0aaa", tc.mut))
1272 got := eligibleBoxes(snap, verifiedFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, nil)
1273 if (len(got) == 1) != tc.want {
1274 t.Fatalf("eligible = %v, want %v", ids(got), tc.want)
1275 }
1276 })
1277 }
1278}
1279
1280func TestEligibleBoxesNeedsAVerifiedRecordFreshnessAndNotLost(t *testing.T) {
1281 snap := views(t, haBox("i-0aaa", nil), haBox("i-0bbb", nil), haBox("i-0ccc", nil))
1282 snap = markStale(snap, "i-0bbb")
1283 got := eligibleBoxes(snap, verifiedFor("i-0aaa", "i-0bbb"), "uk", "eu-west-2", "ie", testKeyFP, nil)
1284 if len(got) != 1 || got[0].BoxID != "i-0aaa" {
1285 t.Fatalf("got %v, want only i-0aaa (i-0bbb is stale, i-0ccc has no verified record)", ids(got))
1286 }
1287 got = eligibleBoxes(snap, verifiedFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, map[string]bool{"i-0aaa": true})
1288 if len(got) != 0 {
1289 t.Fatalf("a lost box is eligible: %v", ids(got))
1290 }
1291}
1292
1293// TestEligibilityNeverCombinesBoxes is nearest-region's rule for boxes: one
1294// box holding the key and another accepting inbound do not add up to one.
1295func TestEligibleBoxesNeverCombinesTwoHalfBoxes(t *testing.T) {
1296 snap := views(t,
1297 haBox("i-0aaa", func(m map[string]any) { m["secrets_key_fp"] = "" }),
1298 haBox("i-0bbb", func(m map[string]any) { m["accepts_inbound"] = false }))
1299 if got := eligibleBoxes(snap, verifiedFor("i-0aaa", "i-0bbb"), "uk", "eu-west-2", "ie", testKeyFP, nil); len(got) != 0 {
1300 t.Fatalf("got %v, want none", ids(got))
1301 }
1302}
1303
1304func TestEligibleBoxesAnIncompleteSnapshotOffersNothing(t *testing.T) {
1305 snap := views(t, haBox("i-0aaa", nil))
1306 snap.Complete = false
1307 if got := eligibleBoxes(snap, verifiedFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, nil); len(got) != 0 {
1308 t.Fatalf("got %v from an incomplete snapshot", ids(got))
1309 }
1310}
1311
1312func TestEligibleBoxesHomePlacementMatchesTheEmptyCode(t *testing.T) {
1313 home := haBox("i-0aaa", func(m map[string]any) { m["placement"] = "" })
1314 named := haBox("i-0bbb", func(m map[string]any) { m["placement"] = "ie" })
1315 snap := views(t, home, named)
1316 got := eligibleBoxes(snap, verifiedFor("i-0aaa", "i-0bbb"), "", "eu-west-2", "ie", testKeyFP, nil)
1317 if len(got) != 2 {
1318 t.Fatalf("got %v, want both home boxes", ids(got))
1319 }
1320}
1321
1322func TestChooseBox(t *testing.T) {
1323 a, b, c := boxCandidate{BoxID: "i-0aaa"}, boxCandidate{BoxID: "i-0bbb"}, boxCandidate{BoxID: "i-0ccc"}
1324 load := func(hosts int, apps ...string) boxLoad {
1325 l := boxLoad{Hosts: hosts, Apps: map[string]int{}}
1326 for _, x := range apps {
1327 l.Apps[x]++
1328 }
1329 return l
1330 }
1331 if _, ok := chooseBox(nil, nil, "bab/jam"); ok {
1332 t.Fatal("chose from nothing")
1333 }
1334 got, _ := chooseBox([]boxCandidate{a, b}, map[string]boxLoad{"i-0aaa": load(5), "i-0bbb": load(2)}, "bab/jam")
1335 if got.BoxID != "i-0bbb" {
1336 t.Fatalf("fewest hosts: got %s", got.BoxID)
1337 }
1338 got, _ = chooseBox([]boxCandidate{a, b}, map[string]boxLoad{"i-0aaa": load(5), "i-0bbb": load(2, "bab/jam")}, "bab/jam")
1339 if got.BoxID != "i-0aaa" {
1340 t.Fatalf("spread beats load: got %s", got.BoxID)
1341 }
1342 got, ok := chooseBox([]boxCandidate{a, b}, map[string]boxLoad{"i-0aaa": load(5, "bab/jam"), "i-0bbb": load(2, "bab/jam", "bab/jam")}, "bab/jam")
1343 if !ok || got.BoxID != "i-0bbb" {
1344 t.Fatalf("every box runs the app: fewest hosts overall, never a refusal; got %s ok=%v", got.BoxID, ok)
1345 }
1346 got, _ = chooseBox([]boxCandidate{c, b, a}, nil, "bab/jam")
1347 if got.BoxID != "i-0aaa" {
1348 t.Fatalf("a tie breaks by box id: got %s", got.BoxID)
1349 }
1350}
1351
1352// A box whose SIGNED region is not the placement's AWS region is not in
1353// that placement, even when its own file says it is and both regions have
1354// certificates.
1355func TestEligibleBoxesRefusesABoxSignedInAnotherRegion(t *testing.T) {
1356 snap := views(t, haBox("i-0aaa", nil), haBox("i-0bbb", nil))
1357 verified := verifiedFor("i-0aaa", "i-0bbb")
1358 verified["i-0bbb"] = boxid.Identity{AccountID: "1", Region: "us-east-1", InstanceID: "i-0bbb"}
1359 got := eligibleBoxes(snap, verified, "uk", "eu-west-2", "ie", testKeyFP, nil)
1360 if len(got) != 1 || got[0].BoxID != "i-0aaa" {
1361 t.Fatalf("got %v, want only i-0aaa", ids(got))
1362 }
1363 // And a file that advertises another AWS region than the one expected.
1364 snap = views(t, haBox("i-0aaa", func(m map[string]any) { m["region"] = "us-east-1" }))
1365 if got := eligibleBoxes(snap, verifiedFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, nil); len(got) != 0 {
1366 t.Fatalf("got %v, want none", ids(got))
1367 }
1368 if got := eligibleBoxes(views(t, haBox("i-0aaa", nil)), verifiedFor("i-0aaa"), "uk", "", "ie", testKeyFP, nil); len(got) != 0 {
1369 t.Fatalf("got %v with no expected region", ids(got))
1370 }
1371}
1372```
1373
1374- [ ] **Step 2: Run them to see them fail**
1375
1376Run: `go test ./internal/console/ -run 'EligibleBoxes|ChooseBox' -count=1`
1377Expected: FAIL to build, `undefined: eligibleBoxes`.
1378
1379- [ ] **Step 3: Write `boxchoice.go`**
1380
1381```go
1382package console
1383
1384import (
1385 "slices"
1386 "sort"
1387
1388 "github.com/carlosframework/platform/internal/boxid"
1389)
1390
1391// boxCandidate is one box that may be named as a host's executor (region HA
1392// spec 1 §2, "eligible box").
1393type boxCandidate struct {
1394 BoxID string
1395 Region string
1396 MemFreeMiB, DiskFreeMiB int64
1397 Awake int
1398}
1399
1400// eligibleBoxes is regionCandidates' predicate applied to one box at a time,
1401// plus what a multi-box region adds: a verified box record that agrees with
1402// the file's box_id and whose signed region is awsRegion (the AWS region
1403// the caller expects for this placement), the HA capability, not draining,
1404// not lost. Two boxes
1405// that each satisfy half of it never add up to one.
1406//
1407// An incomplete snapshot offers nothing: an unread file could be the one
1408// box that would change the answer.
1409func eligibleBoxes(snap edgesSnapshot, verified map[string]boxid.Identity, placement, awsRegion, homeName, expectedFP string, lost map[string]bool) []boxCandidate {
1410 if !snap.complete() || expectedFP == "" || awsRegion == "" {
1411 return nil
1412 }
1413 var out []boxCandidate
1414 for _, f := range snap.Files {
1415 code := f.Placement
1416 if code == "" || (homeName != "" && code == homeName) {
1417 code = ""
1418 }
1419 want := placement
1420 if homeName != "" && want == homeName {
1421 want = ""
1422 }
1423 id, ok := verified[f.BoxID]
1424 switch {
1425 case code != want:
1426 case !f.Fresh, f.EIP == "", f.BoxID == "":
1427 case !ok || id.InstanceID == "":
1428 case id.Region != awsRegion, f.Region != awsRegion:
1429 // The placement code is the box's own word. The AWS region is
1430 // signed: a box whose identity is in another region is not in
1431 // this placement, whatever its file says.
1432 case !isTrue(f.HibernationConfigured), !isTrue(f.ServesSharedPool), !isTrue(f.AcceptsInbound):
1433 case f.SecretsKeyFP != expectedFP:
1434 case !slices.Contains(f.Capabilities, "region-ha-1"):
1435 case f.Draining, lost[f.BoxID]:
1436 default:
1437 c := boxCandidate{BoxID: f.BoxID, Region: id.Region}
1438 if f.Headroom != nil {
1439 c.MemFreeMiB, c.DiskFreeMiB, c.Awake = f.Headroom.MemFreeMiB, f.Headroom.DiskFreeMiB, f.Headroom.Awake
1440 }
1441 out = append(out, c)
1442 }
1443 }
1444 sort.Slice(out, func(i, j int) bool { return out[i].BoxID < out[j].BoxID })
1445 return out
1446}
1447
1448// boxLoad is what claims already place on a box.
1449type boxLoad struct {
1450 Hosts int
1451 Apps map[string]int // "acct/app" -> hosts of that app
1452}
1453
1454// chooseBox is spec 1 §5.2's interim rule until spec 2: prefer a box that
1455// runs no instance of this app (soft spread: it never refuses), then the
1456// fewest hosts overall, then the lowest box id.
1457func chooseBox(cands []boxCandidate, running map[string]boxLoad, app string) (boxCandidate, bool) {
1458 if len(cands) == 0 {
1459 return boxCandidate{}, false
1460 }
1461 best := cands[0]
1462 for _, c := range cands[1:] {
1463 cl, bl := running[c.BoxID], running[best.BoxID]
1464 cRuns, bRuns := cl.Apps[app] > 0, bl.Apps[app] > 0
1465 switch {
1466 case cRuns != bRuns:
1467 if !cRuns {
1468 best = c
1469 }
1470 case cl.Hosts != bl.Hosts:
1471 if cl.Hosts < bl.Hosts {
1472 best = c
1473 }
1474 case c.BoxID < best.BoxID:
1475 best = c
1476 }
1477 }
1478 return best, true
1479}
1480```
1481
1482- [ ] **Step 4: Run the tests**
1483
1484Run: `go test ./internal/console/ -run 'EligibleBoxes|ChooseBox' -count=1`
1485Expected: PASS.
1486
1487If "box_id disagrees with its record" fails: the test changes the file's `box_id` to `i-0zzz` while `verified` holds only `i-0aaa`, so the lookup misses. That is the intended path; check `verified[f.BoxID]` is keyed by the file's `box_id`, not its `box`.
1488
1489- [ ] **Step 5: Commit**
1490
1491```bash
1492git add internal/console/boxchoice.go internal/console/boxchoice_test.go
1493git commit -m "console: eligible boxes, one at a time, and a choice that spreads an app
1494
1495Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
1496```
1497
1498---
1499
1500### Task 4: The home map follows the newest installation
1501
1502**Files:**
1503- Modify: `internal/host/edgesfile.go` (`edgeHost`, `:41-83`), `internal/edge/homemap.go` (`edgesHostView` `:139`, `homeEntry` `:161`, `foldHomes` `:538-602`)
1504- Test: `internal/edge/homemap_test.go`, `internal/host/edgesfile_test.go`
1505
1506**Interfaces:**
1507- Produces: `edgeHost.Stamp int64 \`json:"stamp,omitempty"\`` (writer), `edgesHostView.Stamp int64 \`json:"stamp,omitempty"\`` (reader), `homeEntry.stamp int64`, and a changed signature: `foldHomes(files map[string]edgesFileView, self string) (map[string]homeEntry, []string)`, where the second result is the sorted list of hosts that two boxes publish as home at the same positive stamp, found over every file including this box's own (`tiedStamps`). Its one caller is `(*homeMap).Refresh`.
1508- Nothing sets `Stamp` yet: plan 1b sets it from the claim's `holder.stamp`. Until then every entry is unstamped and today's rule applies unchanged. That is what makes this task safe to land alone.
1509
1510The rule (spec HA4, §5.4), applied when two files claim one host as a dial target:
15111. A stamped entry beats an unstamped one.
15122. Between two stamped entries, the higher stamp wins.
15133. Between two unstamped entries, today's rule: a route beats an alias, any dial target beats none, otherwise the first box in sorted order.
15144. Equal non-zero stamps cannot happen for two different boxes (a stamp is one claim revision naming one executor). Treat it as rule 3, and log it once per refresh: `foldHomes` returns the hosts it happened to.
1515
1516**No alert for two unstamped entries.** Spec §5.4 asks for one, on the reasoning that it means the multi-box gate was bypassed. That is wrong for today's files: a static site or a public address route runs on every box, so every box publishes it as `home`, unstamped, and that is normal. The edge cannot tell those from relocatable hosts. Alerting here would fire for every static site on every refresh. This plan reports only equal positive stamps; Task 11 records the correction in the spec, and plan 1b decides whether the publisher should mark relocatable entries so the edge can alert on those alone.
1517
1518- [ ] **Step 1: Write the failing tests**
1519
1520Append to `internal/edge/homemap_test.go`:
1521
1522```go
1523func TestHomeMapHigherStampWinsWhateverTheSortOrder(t *testing.T) {
1524 s := meshTestStore(t)
1525 // "annie" sorts first and is the dead box; "betty" holds the newer installation.
1526 putJSON(t, s, "control/edges/annie.json", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"shop.test":{"role":"home","stamp":41}}}`)
1527 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"shop.test":{"role":"home","stamp":57}}}`)
1528 m := newHomeMap(s, "carol")
1529 refreshHomeMap(t, m)
1530 if eip, box, ok := m.Home("shop.test"); !ok || box != "betty" || eip != "2.2.2.2" {
1531 t.Fatalf("Home = %q %q %v, want betty", eip, box, ok)
1532 }
1533 // And the other way round, so it is the stamp and not the name.
1534 putJSON(t, s, "control/edges/annie.json", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"shop.test":{"role":"home","stamp":90}}}`)
1535 refreshHomeMap(t, m)
1536 if _, box, _ := m.Home("shop.test"); box != "annie" {
1537 t.Fatalf("Home box = %q, want annie", box)
1538 }
1539}
1540
1541func TestHomeMapStampedBeatsUnstamped(t *testing.T) {
1542 s := meshTestStore(t)
1543 putJSON(t, s, "control/edges/annie.json", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"shop.test":{"role":"home"}}}`)
1544 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"shop.test":{"role":"home","stamp":3}}}`)
1545 m := newHomeMap(s, "carol")
1546 refreshHomeMap(t, m)
1547 if _, box, _ := m.Home("shop.test"); box != "betty" {
1548 t.Fatalf("Home box = %q, want betty", box)
1549 }
1550}
1551
1552func TestHomeMapUnstampedKeepsTodaysRule(t *testing.T) {
1553 s := meshTestStore(t)
1554 putJSON(t, s, "control/edges/annie.json", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"shop.test":{"role":"home","alias":true}}}`)
1555 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"shop.test":{"role":"home"}}}`)
1556 m := newHomeMap(s, "carol")
1557 refreshHomeMap(t, m)
1558 if _, box, _ := m.Home("shop.test"); box != "betty" {
1559 t.Fatalf("a route still beats an alias: got %q", box)
1560 }
1561 putJSON(t, s, "control/edges/annie.json", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"shop.test":{"role":"home"}}}`)
1562 refreshHomeMap(t, m)
1563 if _, box, _ := m.Home("shop.test"); box != "annie" {
1564 t.Fatalf("two plain routes: first box in sorted order; got %q", box)
1565 }
1566}
1567
1568func TestHomeMapAStampedAliasDoesNotBeatAnUnstampedRoute(t *testing.T) {
1569 s := meshTestStore(t)
1570 putJSON(t, s, "control/edges/annie.json", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"shop.test":{"role":"home"}}}`)
1571 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"shop.test":{"role":"home","alias":true,"stamp":9}}}`)
1572 m := newHomeMap(s, "carol")
1573 refreshHomeMap(t, m)
1574 if _, box, _ := m.Home("shop.test"); box != "annie" {
1575 t.Fatalf("got %q: #287's rule (a route beats an alias) comes before stamps", box)
1576 }
1577}
1578
1579func TestHomeMapAStampedEntryWithNoDialAddressDoesNotWin(t *testing.T) {
1580 s := meshTestStore(t)
1581 putJSON(t, s, "control/edges/annie.json", `{"v":1,"box":"annie","eip":"1.1.1.1","hosts":{"shop.test":{"role":"home","stamp":2}}}`)
1582 putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"","hosts":{"shop.test":{"role":"home","stamp":99}}}`)
1583 m := newHomeMap(s, "carol")
1584 refreshHomeMap(t, m)
1585 if eip, box, ok := m.Home("shop.test"); !ok || box != "annie" || eip != "1.1.1.1" {
1586 t.Fatalf("Home = %q %q %v, want annie: a box nothing can dial is skipped today and stays skipped", eip, box, ok)
1587 }
1588}
1589
1590func file(box, eip string, hosts map[string]edgesHostView) edgesFileView {
1591 return edgesFileView{V: 1, Box: box, EIP: eip, Hosts: hosts}
1592}
1593
1594func TestHomeMapReportsTwoBoxesAtTheSameStamp(t *testing.T) {
1595 files := map[string]edgesFileView{
1596 "control/edges/annie.json": file("annie", "1.1.1.1", map[string]edgesHostView{
1597 "tied.test": {Role: "home", Stamp: 7}, "fine.test": {Role: "home", Stamp: 7}, "plain.test": {Role: "home"}}),
1598 "control/edges/betty.json": file("betty", "2.2.2.2", map[string]edgesHostView{
1599 "tied.test": {Role: "home", Stamp: 7}, "fine.test": {Role: "home", Stamp: 9}, "plain.test": {Role: "home"}}),
1600 }
1601 hosts, tied := foldHomes(files, "carol")
1602 if len(tied) != 1 || tied[0] != "tied.test" {
1603 t.Fatalf("tied = %v, want [tied.test]: unstamped duplicates are normal (static sites run on every box) and are not reported", tied)
1604 }
1605 if hosts["tied.test"].box != "annie" {
1606 t.Fatalf("a tie keeps the first box in sorted order; got %q", hosts["tied.test"].box)
1607 }
1608 if hosts["fine.test"].box != "betty" {
1609 t.Fatalf("fine.test box = %q", hosts["fine.test"].box)
1610 }
1611}
1612
1613// A tie between two boxes is reported even when a third box holds a higher
1614// stamp and wins the dial: the report does not depend on the winner.
1615func TestHomeMapReportsATieBelowTheWinner(t *testing.T) {
1616 files := map[string]edgesFileView{
1617 "control/edges/annie.json": file("annie", "1.1.1.1", map[string]edgesHostView{"h.test": {Role: "home", Stamp: 9}}),
1618 "control/edges/betty.json": file("betty", "2.2.2.2", map[string]edgesHostView{"h.test": {Role: "home", Stamp: 7}}),
1619 "control/edges/carol.json": file("carol", "3.3.3.3", map[string]edgesHostView{"h.test": {Role: "home", Stamp: 7}}),
1620 }
1621 hosts, tied := foldHomes(files, "dave")
1622 if len(tied) != 1 || tied[0] != "h.test" {
1623 t.Fatalf("tied = %v, want [h.test]", tied)
1624 }
1625 if hosts["h.test"].box != "annie" {
1626 t.Fatalf("the highest stamp still wins the dial; got %q", hosts["h.test"].box)
1627 }
1628}
1629
1630// In a two-box region each box excludes itself from routing. It must
1631// still see that it and its neighbour publish one host at one stamp.
1632func TestHomeMapReportsATieWithThisBoxItself(t *testing.T) {
1633 files := map[string]edgesFileView{
1634 "control/edges/annie.json": file("annie", "1.1.1.1", map[string]edgesHostView{"h.test": {Role: "home", Stamp: 7}}),
1635 "control/edges/betty.json": file("betty", "2.2.2.2", map[string]edgesHostView{"h.test": {Role: "home", Stamp: 7}}),
1636 }
1637 hosts, tied := foldHomes(files, "annie")
1638 if len(tied) != 1 || tied[0] != "h.test" {
1639 t.Fatalf("tied = %v, want [h.test]", tied)
1640 }
1641 if hosts["h.test"].box != "betty" {
1642 t.Fatalf("this box is still excluded from its own routing; got %q", hosts["h.test"].box)
1643 }
1644}
1645```
1646
1647Append to `internal/host/edgesfile_test.go`:
1648
1649```go
1650func TestEdgesFileStampIsOmittedWhenZero(t *testing.T) {
1651 b, _ := json.Marshal(edgeHost{Role: "home"})
1652 if bytes.Contains(b, []byte("stamp")) {
1653 t.Fatalf("zero stamp on the wire: %s", b)
1654 }
1655 b, _ = json.Marshal(edgeHost{Role: "home", Stamp: 41})
1656 if !bytes.Contains(b, []byte(`"stamp":41`)) {
1657 t.Fatalf("stamp missing: %s", b)
1658 }
1659}
1660```
1661
1662- [ ] **Step 2: Run them to see them fail**
1663
1664Run: `go test ./internal/edge/ -run 'HomeMap' -count=1`
1665Expected: FAIL to build (`TestHomeMapReportsTwoBoxesAtTheSameStamp` uses `Stamp` and `foldHomes`'s second result). Once the field exists and before the fold changes, `TestHomeMapHigherStampWinsWhateverTheSortOrder` and `TestHomeMapStampedBeatsUnstamped` fail (annie wins by sort order) and the other three stamp tests already pass. `go test ./internal/host/ -run StampIsOmitted` fails to build, `unknown field Stamp`.
1666
1667- [ ] **Step 3: Add the field on both sides**
1668
1669`internal/host/edgesfile.go`, in `edgeHost` after `App`:
1670
1671```go
1672 // Stamp is the claim's holder.stamp for the installation this box is
1673 // serving (region HA spec 1 HA4). 0 = unstamped: no claim, or a writer
1674 // that predates it. Two files claiming one host are settled by the
1675 // higher stamp on the edge (homemap.go foldHomes).
1676 Stamp int64 `json:"stamp,omitempty"`
1677```
1678
1679`internal/edge/homemap.go`, in `edgesHostView` after `App`:
1680
1681```go
1682 // Stamp orders two boxes' claims on one host: see foldHomes.
1683 Stamp int64 `json:"stamp,omitempty"`
1684```
1685
1686and in `homeEntry` after `account, app`:
1687
1688```go
1689 // stamp is the dial winner's installation stamp, 0 when unstamped.
1690 stamp int64
1691```
1692
1693- [ ] **Step 4: Change the fold**
1694
1695`foldHomes` gains a second result. Change its signature:
1696
1697```go
1698func foldHomes(files map[string]edgesFileView, self string) (map[string]homeEntry, []string) {
1699```
1700
1701Set the stamp on the incoming entry. Change:
1702
1703```go
1704 in := homeEntry{eip: f.EIP, box: f.Box, platform: h.Platform && !h.Alias && !h.Wildcard, alias: h.Alias, wildcard: h.Wildcard, dial: h.Role == edgeRoleHome,
1705 account: h.Account, app: h.App}
1706```
1707
1708to:
1709
1710```go
1711 in := homeEntry{eip: f.EIP, box: f.Box, platform: h.Platform && !h.Alias && !h.Wildcard, alias: h.Alias, wildcard: h.Wildcard, dial: h.Role == edgeRoleHome,
1712 account: h.Account, app: h.App, stamp: h.Stamp}
1713```
1714
1715Replace the merge condition:
1716
1717```go
1718 if in.dial && (!prev.dial || (prev.alias && !in.alias)) {
1719 merged.eip, merged.box, merged.alias, merged.dial = in.eip, in.box, in.alias, true
1720 merged.account, merged.app = in.account, in.app
1721 }
1722```
1723
1724with:
1725
1726```go
1727 if dialBeats(in, prev) {
1728 merged.eip, merged.box, merged.alias, merged.dial = in.eip, in.box, in.alias, true
1729 merged.account, merged.app, merged.stamp = in.account, in.app, in.stamp
1730 }
1731```
1732
1733Replace the function's final `return hosts` with `return hosts, tiedStamps(files)`, and add below it:
1734
1735```go
1736// tiedStamps lists, sorted, the hosts that two boxes publish as home at
1737// the same positive stamp. It looks at every file, this box's own
1738// included, and at every pair, not only at whichever entry won the dial:
1739// in a two-box region each box must be able to see that it and its
1740// neighbour disagree, and a third box's higher stamp must not hide a tie
1741// between two others.
1742//
1743// Unstamped duplicates are not reported. They are normal: a static site
1744// or a public address route runs on every box, and every box publishes it.
1745func tiedStamps(files map[string]edgesFileView) []string {
1746 type key struct {
1747 host string
1748 stamp int64
1749 }
1750 boxes := map[key]map[string]bool{}
1751 for _, f := range files {
1752 if f.Box == "" {
1753 continue
1754 }
1755 for host, h := range f.Hosts {
1756 if host == "" || h.Role != edgeRoleHome || h.Stamp <= 0 {
1757 continue
1758 }
1759 k := key{host, h.Stamp}
1760 if boxes[k] == nil {
1761 boxes[k] = map[string]bool{}
1762 }
1763 boxes[k][f.Box] = true
1764 }
1765 }
1766 seen := map[string]bool{}
1767 var tied []string
1768 for k, set := range boxes {
1769 if len(set) > 1 && !seen[k.host] {
1770 seen[k.host] = true
1771 tied = append(tied, k.host)
1772 }
1773 }
1774 slices.Sort(tied)
1775 return tied
1776}
1777
1778// dialBeats reports whether a later file's entry takes the dial from the
1779// one already folded. In order:
1780//
1781// - only a dial target can take it, and any dial target beats none;
1782// - a ROUTE beats an ALIAS whatever else is true (#287);
1783// - between two of the same kind, the newer installation wins: a stamped
1784// entry beats an unstamped one, and a higher stamp beats a lower one
1785// (region HA spec 1 HA4). A dead box's file lingers; its successor's
1786// stamp is always higher, so the stale one loses without any liveness
1787// rule;
1788// - otherwise the earlier file keeps it, which is the first box in
1789// sorted order, as before.
1790func dialBeats(in, prev homeEntry) bool {
1791 if !in.dial {
1792 return false
1793 }
1794 if !prev.dial {
1795 return true
1796 }
1797 if prev.alias != in.alias {
1798 return prev.alias && !in.alias
1799 }
1800 return in.stamp > prev.stamp
1801}
1802```
1803
1804Ties are found by `tiedStamps` in a pass of its own, over every file, rather than inside the fold. The fold skips this box's own file and compares each entry only with the current winner, so it cannot see a tie that involves this box, or one between two boxes that both lose to a third.
1805
1806In `(*homeMap).Refresh`, replace the one call:
1807
1808```go
1809 snap := &homeSnapshot{files: next, hosts: foldHomes(next, m.self)}
1810```
1811
1812with:
1813
1814```go
1815 hosts, tied := foldHomes(next, m.self)
1816 if len(tied) > 0 {
1817 // One claim revision names one executor, so two boxes publishing
1818 // one host at the same stamp is a fault upstream. The fold stays
1819 // deterministic; say so once a refresh.
1820 log.Printf("edge: home map: %d host(s) published by two boxes at the same installation stamp, e.g. %s", len(tied), tied[0])
1821 }
1822 snap := &homeSnapshot{files: next, hosts: hosts}
1823```
1824
1825`homemap.go` already imports `log` and `slices`. An entry with no dial address never reaches `dialBeats` as a dial target: the existing `case edgeRoleHome: if f.EIP == "" { … continue }` skips it first. That is what `TestHomeMapAStampedEntryWithNoDialAddressDoesNotWin` pins.
1826
1827- [ ] **Step 5: Run the tests**
1828
1829Run: `go test ./internal/edge/ ./internal/host/ -count=1`
1830Expected: PASS, including every pre-existing home-map test.
1831
1832- [ ] **Step 6: Commit**
1833
1834```bash
1835git add internal/edge/homemap.go internal/edge/homemap_test.go internal/host/edgesfile.go internal/host/edgesfile_test.go
1836git commit -m "edge: two boxes claiming one host are settled by the newer installation's stamp
1837
1838Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
1839```
1840
1841---
1842### Task 5: The ordered host-files manifest (`internal/hostfiles`)
1843
1844**Files:**
1845- Create: `internal/hostfiles/manifest.go`, `internal/hostfiles/publisher.go`, `internal/hostfiles/restore.go`
1846- Test: `internal/hostfiles/hostfiles_test.go`
1847
1848**Interfaces:**
1849- Consumes: `release.Store`, `release.ErrCASConflict`.
1850- Produces:
1851 ```go
1852 package hostfiles
1853 const MaxFileBytes = 64 << 20
1854 type Entry struct {
1855 Object string `json:"object,omitempty"` // key relative to the prefix
1856 SHA256 string `json:"sha256,omitempty"`
1857 Size int64 `json:"size,omitempty"`
1858 Mode uint32 `json:"mode,omitempty"` // permission bits only
1859 Seq int64 `json:"seq"`
1860 Deleted bool `json:"deleted,omitempty"`
1861 Torn bool `json:"torn,omitempty"` // the file changed while it was read
1862 Skipped string `json:"skipped,omitempty"` // "too-large": present on disk, not published
1863 }
1864 type Manifest struct {
1865 V int `json:"v"`
1866 Seq int64 `json:"seq"`
1867 Entries map[string]Entry `json:"entries"` // files directory, by clean relative path
1868 Platform map[string]Entry `json:"platform"` // platform-managed items, by name; never written into the files directory
1869 }
1870 func ManifestKey(prefix string) string
1871 func CleanPath(p string) (string, error)
1872 var ErrSuperseded = errors.New("hostfiles: another writer published this manifest")
1873 type Publisher struct{ /* unexported */ }
1874 func Open(ctx context.Context, s release.Store, prefix string) (*Publisher, error)
1875 func (p *Publisher) Stage(ctx context.Context, path string, body []byte, mode uint32, torn bool) error
1876 func (p *Publisher) StageSkipped(path, why string) error
1877 func (p *Publisher) StageDelete(path string) error
1878 func (p *Publisher) StagePlatform(ctx context.Context, name string, body []byte) error
1879 func (p *Publisher) Commit(ctx context.Context) (seq int64, err error)
1880 func (p *Publisher) Dirty() bool
1881 func (p *Publisher) Seq() int64 // the last committed seq
1882 func (p *Publisher) Paths() []string // live (not deleted) entry paths, sorted
1883 type RestoreStats struct{ Written, Removed int; Skipped []string }
1884 func Load(ctx context.Context, s release.Store, prefix string) (Manifest, bool, error)
1885 func Restore(ctx context.Context, s release.Store, prefix, dir string) (Manifest, RestoreStats, error)
1886 func ReadPlatform(ctx context.Context, s release.Store, prefix string, m Manifest, name string) ([]byte, error)
1887 ```
1888 `prefix` ends in `/`. Plan 1b supplies `<holder.location>/files/<generation>/`.
1889
1890**How ordering works (spec §5.6).** Every staged change takes the next sequence number. Its bytes go to a new key, `objects/<seq, 16 digits>-<sha256>`, written create-only, so nothing is ever overwritten. Only the manifest says which object is a path's current content, and the manifest is replaced by compare-and-swap. An upload that stalls and lands late creates an object no manifest names. A second writer's commit fails with `ErrSuperseded` instead of overwriting.
1891
1892- [ ] **Step 1: Write the failing tests**
1893
1894`internal/hostfiles/hostfiles_test.go`:
1895
1896```go
1897package hostfiles
1898
1899import (
1900 "context"
1901 "errors"
1902 "os"
1903 "path/filepath"
1904 "reflect"
1905 "strings"
1906 "testing"
1907
1908 "github.com/carlosframework/platform/internal/release"
1909)
1910
1911const pfx = "instances/shop.test/abc/files/g1/"
1912
1913func open(t *testing.T, s release.Store) *Publisher {
1914 t.Helper()
1915 p, err := Open(context.Background(), s, pfx)
1916 if err != nil {
1917 t.Fatal(err)
1918 }
1919 return p
1920}
1921
1922func stage(t *testing.T, p *Publisher, path, body string) {
1923 t.Helper()
1924 if err := p.Stage(context.Background(), path, []byte(body), 0o600, false); err != nil {
1925 t.Fatal(err)
1926 }
1927}
1928
1929func commit(t *testing.T, p *Publisher) int64 {
1930 t.Helper()
1931 seq, err := p.Commit(context.Background())
1932 if err != nil {
1933 t.Fatal(err)
1934 }
1935 return seq
1936}
1937
1938func read(t *testing.T, dir, rel string) string {
1939 t.Helper()
1940 b, err := os.ReadFile(filepath.Join(dir, rel))
1941 if err != nil {
1942 t.Fatal(err)
1943 }
1944 return string(b)
1945}
1946
1947func TestPublishThenRestoreRoundTrips(t *testing.T) {
1948 ctx, s := context.Background(), release.NewDirStore(t.TempDir())
1949 p := open(t, s)
1950 stage(t, p, "instance.key", "k1")
1951 stage(t, p, "uploads/a/b.txt", "hello")
1952 if seq := commit(t, p); seq != 2 {
1953 t.Fatalf("seq = %d, want 2", seq)
1954 }
1955 dir := t.TempDir()
1956 m, st, err := Restore(ctx, s, pfx, dir)
1957 if err != nil {
1958 t.Fatal(err)
1959 }
1960 if m.Seq != 2 || st.Written != 2 {
1961 t.Fatalf("manifest seq %d, written %d", m.Seq, st.Written)
1962 }
1963 if read(t, dir, "instance.key") != "k1" || read(t, dir, "uploads/a/b.txt") != "hello" {
1964 t.Fatal("content differs")
1965 }
1966 fi, _ := os.Stat(filepath.Join(dir, "instance.key"))
1967 if fi.Mode().Perm() != 0o600 {
1968 t.Fatalf("mode = %v, want 0600", fi.Mode().Perm())
1969 }
1970}
1971
1972func TestALaterWriteWinsAndAnUnchangedOneTakesNoSequence(t *testing.T) {
1973 ctx, s := context.Background(), release.NewDirStore(t.TempDir())
1974 p := open(t, s)
1975 stage(t, p, "instance.key", "old")
1976 commit(t, p)
1977 stage(t, p, "instance.key", "new")
1978 stage(t, p, "instance.key", "new") // unchanged: no new sequence number
1979 if seq := commit(t, p); seq != 2 {
1980 t.Fatalf("seq = %d, want 2", seq)
1981 }
1982 dir := t.TempDir()
1983 if _, _, err := Restore(ctx, s, pfx, dir); err != nil {
1984 t.Fatal(err)
1985 }
1986 if got := read(t, dir, "instance.key"); got != "new" {
1987 t.Fatalf("restored %q, want new", got)
1988 }
1989}
1990
1991// A1-4: a stalled upload of the OLD content that lands after the new
1992// content was published must not come back.
1993func TestALateUploadCannotResurrectOldContent(t *testing.T) {
1994 ctx, s := context.Background(), release.NewDirStore(t.TempDir())
1995 p := open(t, s)
1996 stage(t, p, "instance.key", "new")
1997 commit(t, p)
1998 // The late arrival: any object at all under objects/, with a higher number.
1999 if _, err := s.Put(ctx, pfx+"objects/0000000000000099-deadbeef", []byte("old"), ""); err != nil {
2000 t.Fatal(err)
2001 }
2002 dir := t.TempDir()
2003 if _, _, err := Restore(ctx, s, pfx, dir); err != nil {
2004 t.Fatal(err)
2005 }
2006 if got := read(t, dir, "instance.key"); got != "new" {
2007 t.Fatalf("restored %q, want new", got)
2008 }
2009}
2010
2011func TestADeleteIsATombstoneAndRestoreRemovesTheFile(t *testing.T) {
2012 ctx, s := context.Background(), release.NewDirStore(t.TempDir())
2013 p := open(t, s)
2014 stage(t, p, "a.txt", "a")
2015 stage(t, p, "b.txt", "b")
2016 commit(t, p)
2017 dir := t.TempDir()
2018 if _, _, err := Restore(ctx, s, pfx, dir); err != nil {
2019 t.Fatal(err)
2020 }
2021 if err := p.StageDelete("a.txt"); err != nil {
2022 t.Fatal(err)
2023 }
2024 commit(t, p)
2025 _, st, err := Restore(ctx, s, pfx, dir)
2026 if err != nil {
2027 t.Fatal(err)
2028 }
2029 if _, err := os.Stat(filepath.Join(dir, "a.txt")); !os.IsNotExist(err) {
2030 t.Fatalf("a.txt still there: %v", err)
2031 }
2032 if st.Removed != 1 || read(t, dir, "b.txt") != "b" {
2033 t.Fatalf("removed %d", st.Removed)
2034 }
2035 if got := p.Paths(); len(got) != 1 || got[0] != "b.txt" {
2036 t.Fatalf("Paths = %v", got)
2037 }
2038}
2039
2040func TestASecondWriterIsSupersededNotMerged(t *testing.T) {
2041 s := release.NewDirStore(t.TempDir())
2042 a, b := open(t, s), open(t, s)
2043 stage(t, a, "x", "from a")
2044 commit(t, a)
2045 stage(t, b, "x", "from b")
2046 if _, err := b.Commit(context.Background()); !errors.Is(err, ErrSuperseded) {
2047 t.Fatalf("err = %v, want ErrSuperseded", err)
2048 }
2049 dir := t.TempDir()
2050 if _, _, err := Restore(context.Background(), s, pfx, dir); err != nil {
2051 t.Fatal(err)
2052 }
2053 if got := read(t, dir, "x"); got != "from a" {
2054 t.Fatalf("restored %q, want from a", got)
2055 }
2056}
2057
2058func TestAReopenedPublisherContinuesTheSequence(t *testing.T) {
2059 s := release.NewDirStore(t.TempDir())
2060 p := open(t, s)
2061 stage(t, p, "x", "1")
2062 commit(t, p)
2063 q := open(t, s)
2064 stage(t, q, "y", "2")
2065 if seq := commit(t, q); seq != 2 {
2066 t.Fatalf("seq = %d, want 2", seq)
2067 }
2068}
2069
2070// Review Focus 1.
2071func TestCleanPathRefusesEscapes(t *testing.T) {
2072 for _, bad := range []string{"", "/etc/passwd", "../x", "a/../../x", "a/../b", "./a", "a//b", "a/", ".", "..", "a\x00b"} {
2073 if _, err := CleanPath(bad); err == nil {
2074 t.Errorf("CleanPath(%q) accepted", bad)
2075 }
2076 }
2077 for _, ok := range []string{"a", "a/b.txt", ".hidden", "a b/c", `back\slash`} {
2078 if got, err := CleanPath(ok); err != nil || got != ok {
2079 t.Errorf("CleanPath(%q) = %q, %v", ok, got, err)
2080 }
2081 }
2082}
2083
2084func TestStageAndRestoreRefuseAnEscapingPath(t *testing.T) {
2085 ctx, s := context.Background(), release.NewDirStore(t.TempDir())
2086 p := open(t, s)
2087 if err := p.Stage(ctx, "../evil", []byte("x"), 0o600, false); err == nil {
2088 t.Fatal("Stage accepted ../evil")
2089 }
2090 // A manifest written by something else with a hostile path.
2091 body := `{"v":1,"seq":1,"entries":{"../evil":{"object":"objects/0000000000000001-aa","sha256":"aa","seq":1}},"platform":{}}`
2092 hostile := "instances/h/i/files/g/"
2093 if _, err := s.Put(ctx, ManifestKey(hostile), []byte(body), ""); err != nil {
2094 t.Fatal(err)
2095 }
2096 parent := t.TempDir()
2097 dir := filepath.Join(parent, "files")
2098 if err := os.Mkdir(dir, 0o755); err != nil {
2099 t.Fatal(err)
2100 }
2101 if _, _, err := Restore(ctx, s, hostile, dir); err == nil {
2102 t.Fatal("Restore accepted a manifest with ../evil")
2103 }
2104 if _, err := os.Stat(filepath.Join(parent, "evil")); !os.IsNotExist(err) {
2105 t.Fatal("../evil was written")
2106 }
2107}
2108
2109func TestRestoreRefusesContentThatDoesNotMatchItsHash(t *testing.T) {
2110 ctx, s := context.Background(), release.NewDirStore(t.TempDir())
2111 p := open(t, s)
2112 stage(t, p, "x", "good")
2113 commit(t, p)
2114 m, _, err := Load(ctx, s, pfx)
2115 if err != nil {
2116 t.Fatal(err)
2117 }
2118 key := pfx + m.Entries["x"].Object
2119 _, etag, _ := s.Get(ctx, key)
2120 if _, err := s.Put(ctx, key, []byte("tampered"), etag); err != nil {
2121 t.Fatal(err)
2122 }
2123 if _, _, err := Restore(ctx, s, pfx, t.TempDir()); err == nil || !strings.Contains(err.Error(), "sha256") {
2124 t.Fatalf("err = %v, want a sha256 mismatch", err)
2125 }
2126}
2127
2128func TestAnAbsentManifestRestoresNothing(t *testing.T) {
2129 m, st, err := Restore(context.Background(), release.NewDirStore(t.TempDir()), pfx, t.TempDir())
2130 if err != nil || m.Seq != 0 || st.Written != 0 {
2131 t.Fatalf("m=%+v st=%+v err=%v", m, st, err)
2132 }
2133}
2134
2135func TestPlatformItemsAreNotWrittenIntoTheFilesDirectory(t *testing.T) {
2136 ctx, s := context.Background(), release.NewDirStore(t.TempDir())
2137 p := open(t, s)
2138 if err := p.StagePlatform(ctx, "wake.json", []byte(`{"v":1}`)); err != nil {
2139 t.Fatal(err)
2140 }
2141 commit(t, p)
2142 dir := t.TempDir()
2143 m, _, err := Restore(ctx, s, pfx, dir)
2144 if err != nil {
2145 t.Fatal(err)
2146 }
2147 if entries, _ := os.ReadDir(dir); len(entries) != 0 {
2148 t.Fatalf("files directory has %d entries, want 0", len(entries))
2149 }
2150 got, err := ReadPlatform(ctx, s, pfx, m, "wake.json")
2151 if err != nil || string(got) != `{"v":1}` {
2152 t.Fatalf("ReadPlatform = %q, %v", got, err)
2153 }
2154 if _, err := ReadPlatform(ctx, s, pfx, m, "absent"); !errors.Is(err, os.ErrNotExist) {
2155 t.Fatalf("absent item: err = %v, want os.ErrNotExist", err)
2156 }
2157}
2158
2159// Review Focus 3: a skipped file stays in the manifest, visibly.
2160func TestASkippedFileIsRecordedAndReportedOnRestore(t *testing.T) {
2161 ctx, s := context.Background(), release.NewDirStore(t.TempDir())
2162 p := open(t, s)
2163 if err := p.StageSkipped("huge.bin", "too-large"); err != nil {
2164 t.Fatal(err)
2165 }
2166 commit(t, p)
2167 _, st, err := Restore(ctx, s, pfx, t.TempDir())
2168 if err != nil {
2169 t.Fatal(err)
2170 }
2171 if len(st.Skipped) != 1 || st.Skipped[0] != "huge.bin" {
2172 t.Fatalf("Skipped = %v", st.Skipped)
2173 }
2174}
2175
2176// A destination that already holds a symlink pointing elsewhere must not
2177// let a write or a delete reach through it.
2178func TestRestoreNeverWritesOrDeletesThroughASymlink(t *testing.T) {
2179 ctx, s := context.Background(), release.NewDirStore(t.TempDir())
2180 p := open(t, s)
2181 stage(t, p, "sub/x", "ours")
2182 stage(t, p, "gone/y", "temp")
2183 commit(t, p)
2184 if err := p.StageDelete("gone/y"); err != nil {
2185 t.Fatal(err)
2186 }
2187 commit(t, p)
2188
2189 outside, dir := t.TempDir(), t.TempDir()
2190 if err := os.WriteFile(filepath.Join(outside, "y"), []byte("not ours"), 0o600); err != nil {
2191 t.Fatal(err)
2192 }
2193 for _, link := range []string{"sub", "gone"} {
2194 if err := os.Symlink(outside, filepath.Join(dir, link)); err != nil {
2195 t.Fatal(err)
2196 }
2197 }
2198 if _, _, err := Restore(ctx, s, pfx, dir); err != nil {
2199 t.Fatal(err)
2200 }
2201 if _, err := os.Stat(filepath.Join(outside, "x")); !os.IsNotExist(err) {
2202 t.Fatal("a file was written through the symlink")
2203 }
2204 if got := read(t, outside, "y"); got != "not ours" {
2205 t.Fatalf("a file outside was changed to %q", got)
2206 }
2207 if fi, err := os.Lstat(filepath.Join(dir, "sub")); err != nil || !fi.IsDir() {
2208 t.Fatalf("sub is not a real directory: %v %v", fi, err)
2209 }
2210 if got := read(t, dir, "sub/x"); got != "ours" {
2211 t.Fatalf("sub/x = %q", got)
2212 }
2213}
2214
2215func TestRestoreHandlesADirectoryBecomingAFileAndBack(t *testing.T) {
2216 ctx, s := context.Background(), release.NewDirStore(t.TempDir())
2217 p := open(t, s)
2218 stage(t, p, "a/b", "child")
2219 commit(t, p)
2220 prior := t.TempDir() // a copy restored while "a" was a directory
2221 if _, _, err := Restore(ctx, s, pfx, prior); err != nil {
2222 t.Fatal(err)
2223 }
2224
2225 // "a" becomes a file.
2226 if err := p.StageDelete("a/b"); err != nil {
2227 t.Fatal(err)
2228 }
2229 stage(t, p, "a", "now a file")
2230 commit(t, p)
2231 for name, dir := range map[string]string{"an empty destination": t.TempDir(), "a previously restored one": prior} {
2232 if _, _, err := Restore(ctx, s, pfx, dir); err != nil {
2233 t.Fatalf("%s: %v", name, err)
2234 }
2235 if got := read(t, dir, "a"); got != "now a file" {
2236 t.Fatalf("%s: a = %q", name, got)
2237 }
2238 }
2239
2240 // And back: "a" becomes a directory again.
2241 if err := p.StageDelete("a"); err != nil {
2242 t.Fatal(err)
2243 }
2244 stage(t, p, "a/c", "child again")
2245 commit(t, p)
2246 for name, dir := range map[string]string{"an empty destination": t.TempDir(), "a previously restored one": prior} {
2247 if _, _, err := Restore(ctx, s, pfx, dir); err != nil {
2248 t.Fatalf("%s: %v", name, err)
2249 }
2250 if got := read(t, dir, "a/c"); got != "child again" {
2251 t.Fatalf("%s: a/c = %q", name, got)
2252 }
2253 }
2254}
2255
2256func TestRestoreIntoAMissingDirectoryIsAnError(t *testing.T) {
2257 ctx, s := context.Background(), release.NewDirStore(t.TempDir())
2258 p := open(t, s)
2259 stage(t, p, "x", "x")
2260 commit(t, p)
2261 if _, _, err := Restore(ctx, s, pfx, filepath.Join(t.TempDir(), "absent")); err == nil {
2262 t.Fatal("restored into a directory that does not exist")
2263 }
2264}
2265
2266// Plan round 2, finding 3: a restore that returns nil has made its names
2267// durable, and one that could not must say so.
2268func TestRestoreSyncsEveryDirectoryItChangedBeforeReturning(t *testing.T) {
2269 ctx, s := context.Background(), release.NewDirStore(t.TempDir())
2270 p := open(t, s)
2271 stage(t, p, "top.txt", "t")
2272 stage(t, p, "a/b/deep.txt", "d")
2273 stage(t, p, "gone/old.txt", "o")
2274 commit(t, p)
2275 dir := t.TempDir()
2276 if _, _, err := Restore(ctx, s, pfx, dir); err != nil {
2277 t.Fatal(err)
2278 }
2279 if err := p.StageDelete("gone/old.txt"); err != nil {
2280 t.Fatal(err)
2281 }
2282 commit(t, p)
2283
2284 var synced []string
2285 orig := syncDir
2286 t.Cleanup(func() { syncDir = orig })
2287 syncDir = func(root *os.Root, rel string) error {
2288 // Everything must already be in place when the first sync runs.
2289 if len(synced) == 0 {
2290 if _, err := os.Stat(filepath.Join(dir, "gone", "old.txt")); !os.IsNotExist(err) {
2291 t.Error("a directory was synced before the tombstone was applied")
2292 }
2293 }
2294 synced = append(synced, rel)
2295 return orig(root, rel)
2296 }
2297 if _, _, err := Restore(ctx, s, pfx, dir); err != nil {
2298 t.Fatal(err)
2299 }
2300 want := []string{"a/b", "a", "gone", "."}
2301 if !reflect.DeepEqual(synced, want) {
2302 t.Fatalf("synced %v, want %v (deepest first, the root last)", synced, want)
2303 }
2304
2305 syncDir = func(*os.Root, string) error { return errors.New("disk says no") }
2306 if _, _, err := Restore(ctx, s, pfx, dir); err == nil || !strings.Contains(err.Error(), "disk says no") {
2307 t.Fatalf("err = %v; a failed directory sync must fail the restore", err)
2308 }
2309}
2310
2311// Plan round 3: a restore that failed at its directory sync has already
2312// removed the file. The retry finds nothing to remove, and must still
2313// make that removal durable before it reports success.
2314func TestARetriedRestoreStillSyncsADeletionAnEarlierAttemptMade(t *testing.T) {
2315 ctx, s := context.Background(), release.NewDirStore(t.TempDir())
2316 p := open(t, s)
2317 stage(t, p, "gone/old.txt", "o")
2318 commit(t, p)
2319 dir := t.TempDir()
2320 if _, _, err := Restore(ctx, s, pfx, dir); err != nil {
2321 t.Fatal(err)
2322 }
2323 if err := p.StageDelete("gone/old.txt"); err != nil {
2324 t.Fatal(err)
2325 }
2326 commit(t, p)
2327
2328 orig := syncDir
2329 t.Cleanup(func() { syncDir = orig })
2330 syncDir = func(*os.Root, string) error { return errors.New("disk says no") }
2331 if _, _, err := Restore(ctx, s, pfx, dir); err == nil {
2332 t.Fatal("the first attempt must fail")
2333 }
2334 if _, err := os.Stat(filepath.Join(dir, "gone", "old.txt")); !os.IsNotExist(err) {
2335 t.Fatal("the first attempt should have removed the file before failing")
2336 }
2337 var synced []string
2338 syncDir = func(root *os.Root, rel string) error { synced = append(synced, rel); return orig(root, rel) }
2339 _, st, err := Restore(ctx, s, pfx, dir)
2340 if err != nil {
2341 t.Fatal(err)
2342 }
2343 if st.Removed != 0 {
2344 t.Fatalf("Removed = %d; the retry had nothing left to remove", st.Removed)
2345 }
2346 if want := []string{"gone", "."}; !reflect.DeepEqual(synced, want) {
2347 t.Fatalf("the retry synced %v, want %v", synced, want)
2348 }
2349}
2350
2351// A deeper directory replaced by a file: the directories that vanish
2352// under the new file are not synced (they do not exist), and the restore
2353// still succeeds and syncs what survives.
2354func TestRestoreHandlesADeepDirectoryBecomingAFile(t *testing.T) {
2355 ctx, s := context.Background(), release.NewDirStore(t.TempDir())
2356 p := open(t, s)
2357 stage(t, p, "a/b/c", "leaf")
2358 commit(t, p)
2359 dir := t.TempDir()
2360 if _, _, err := Restore(ctx, s, pfx, dir); err != nil {
2361 t.Fatal(err)
2362 }
2363 if err := p.StageDelete("a/b/c"); err != nil {
2364 t.Fatal(err)
2365 }
2366 stage(t, p, "a", "now a file")
2367 commit(t, p)
2368
2369 var synced []string
2370 orig := syncDir
2371 t.Cleanup(func() { syncDir = orig })
2372 syncDir = func(root *os.Root, rel string) error { synced = append(synced, rel); return orig(root, rel) }
2373 for name, d := range map[string]string{"a previously restored destination": dir, "an empty one": t.TempDir()} {
2374 synced = nil
2375 if _, _, err := Restore(ctx, s, pfx, d); err != nil {
2376 t.Fatalf("%s: %v", name, err)
2377 }
2378 if got := read(t, d, "a"); got != "now a file" {
2379 t.Fatalf("%s: a = %q", name, got)
2380 }
2381 if want := []string{"."}; !reflect.DeepEqual(synced, want) {
2382 t.Fatalf("%s: synced %v, want %v: a/b and a are gone, the root records it", name, synced, want)
2383 }
2384 }
2385}
2386```
2387
2388- [ ] **Step 2: Run them to see them fail**
2389
2390Run: `go test ./internal/hostfiles/ -count=1`
2391Expected: FAIL to build, `undefined: Open`.
2392
2393- [ ] **Step 3: Write `manifest.go`**
2394
2395```go
2396// Package hostfiles publishes an instance's files directory, and the small
2397// platform-managed items that travel with it, to object storage in an order
2398// that survives late and duplicate uploads (region HA spec 1 §5.6).
2399//
2400// Nothing is overwritten: each change is a new immutable object, and one
2401// manifest, replaced by compare-and-swap, says which object is current.
2402package hostfiles
2403
2404import (
2405 "context"
2406 "encoding/json"
2407 "errors"
2408 "fmt"
2409 "os"
2410 "path"
2411 "strings"
2412
2413 "github.com/carlosframework/platform/internal/release"
2414)
2415
2416// MaxFileBytes is the largest file published as one object. release.Store
2417// takes a whole body in memory, so a larger file is recorded as skipped
2418// rather than read. Plan 1b lifts this with a streaming put.
2419const MaxFileBytes = 64 << 20
2420
2421type Entry struct {
2422 Object string `json:"object,omitempty"`
2423 SHA256 string `json:"sha256,omitempty"`
2424 Size int64 `json:"size,omitempty"`
2425 Mode uint32 `json:"mode,omitempty"`
2426 Seq int64 `json:"seq"`
2427 Deleted bool `json:"deleted,omitempty"`
2428 Torn bool `json:"torn,omitempty"`
2429 Skipped string `json:"skipped,omitempty"`
2430}
2431
2432type Manifest struct {
2433 V int `json:"v"`
2434 Seq int64 `json:"seq"`
2435 Entries map[string]Entry `json:"entries"`
2436 Platform map[string]Entry `json:"platform"`
2437}
2438
2439func ManifestKey(prefix string) string { return prefix + "manifest.json" }
2440
2441func objectKey(seq int64, sha string) string { return fmt.Sprintf("objects/%016d-%s", seq, sha) }
2442
2443// CleanPath accepts only a relative, already-clean, slash-separated path
2444// that stays inside its root. It never repairs a path: a manifest is
2445// written by this package, so anything else is damage or an attack.
2446func CleanPath(p string) (string, error) {
2447 if p == "" || strings.ContainsRune(p, 0) || strings.HasPrefix(p, "/") {
2448 return "", fmt.Errorf("hostfiles: path %q is not a relative path", p)
2449 }
2450 c := path.Clean(p)
2451 if c != p || c == "." || c == ".." || strings.HasPrefix(c, "../") {
2452 return "", fmt.Errorf("hostfiles: path %q is not clean or leaves its root", p)
2453 }
2454 return c, nil
2455}
2456
2457// Load reads the manifest. The bool is false when there is none yet.
2458func Load(ctx context.Context, s release.Store, prefix string) (Manifest, bool, error) {
2459 m, _, found, err := load(ctx, s, prefix)
2460 return m, found, err
2461}
2462
2463func load(ctx context.Context, s release.Store, prefix string) (Manifest, string, bool, error) {
2464 empty := Manifest{V: 1, Entries: map[string]Entry{}, Platform: map[string]Entry{}}
2465 raw, etag, err := s.Get(ctx, ManifestKey(prefix))
2466 if errors.Is(err, os.ErrNotExist) {
2467 return empty, "", false, nil
2468 }
2469 if err != nil {
2470 return Manifest{}, "", false, err
2471 }
2472 var m Manifest
2473 if err := json.Unmarshal(raw, &m); err != nil {
2474 return Manifest{}, "", false, fmt.Errorf("hostfiles: manifest %s: %w", prefix, err)
2475 }
2476 if m.V != 1 {
2477 return Manifest{}, "", false, fmt.Errorf("hostfiles: manifest %s is version %d; this binary reads 1", prefix, m.V)
2478 }
2479 if m.Entries == nil {
2480 m.Entries = map[string]Entry{}
2481 }
2482 if m.Platform == nil {
2483 m.Platform = map[string]Entry{}
2484 }
2485 return m, etag, true, nil
2486}
2487```
2488
2489- [ ] **Step 4: Write `publisher.go`**
2490
2491```go
2492package hostfiles
2493
2494import (
2495 "context"
2496 "crypto/sha256"
2497 "encoding/hex"
2498 "encoding/json"
2499 "errors"
2500 "fmt"
2501 "sort"
2502 "strings"
2503 "sync"
2504
2505 "github.com/carlosframework/platform/internal/release"
2506)
2507
2508// ErrSuperseded means another writer replaced the manifest. The caller
2509// stops publishing: it is no longer this host's executor, or two
2510// publishers were started for one host.
2511var ErrSuperseded = errors.New("hostfiles: another writer published this manifest")
2512
2513// Publisher stages changes and commits them as one manifest revision. It
2514// is safe for concurrent use.
2515type Publisher struct {
2516 s release.Store
2517 prefix string
2518
2519 mu sync.Mutex
2520 man Manifest
2521 etag string
2522 next int64 // the last sequence number handed out; man.Seq is the last committed
2523 dirty bool
2524}
2525
2526func Open(ctx context.Context, s release.Store, prefix string) (*Publisher, error) {
2527 if !strings.HasSuffix(prefix, "/") {
2528 return nil, fmt.Errorf("hostfiles: prefix %q must end in /", prefix)
2529 }
2530 m, etag, _, err := load(ctx, s, prefix)
2531 if err != nil {
2532 return nil, err
2533 }
2534 return &Publisher{s: s, prefix: prefix, man: m, etag: etag, next: m.Seq}, nil
2535}
2536
2537func sum(body []byte) string {
2538 h := sha256.Sum256(body)
2539 return hex.EncodeToString(h[:])
2540}
2541
2542// put writes body as the next immutable object and returns its entry.
2543// Callers hold p.mu.
2544func (p *Publisher) put(ctx context.Context, body []byte, mode uint32, torn bool) (Entry, error) {
2545 sha := sum(body)
2546 seq := p.next + 1
2547 key := objectKey(seq, sha)
2548 if _, err := p.s.Put(ctx, p.prefix+key, body, ""); err != nil && !errors.Is(err, release.ErrCASConflict) {
2549 // A conflict means this exact key exists: the same sequence number
2550 // and the same hash, left by an attempt that crashed before its
2551 // commit. The bytes are the ones we were about to write.
2552 return Entry{}, err
2553 }
2554 p.next = seq
2555 return Entry{Object: key, SHA256: sha, Size: int64(len(body)), Mode: mode & 0o777, Seq: seq, Torn: torn}, nil
2556}
2557
2558// Stage records path's content. Content identical to what is already
2559// staged or committed takes no sequence number and writes nothing.
2560func (p *Publisher) Stage(ctx context.Context, path string, body []byte, mode uint32, torn bool) error {
2561 clean, err := CleanPath(path)
2562 if err != nil {
2563 return err
2564 }
2565 p.mu.Lock()
2566 defer p.mu.Unlock()
2567 if cur, ok := p.man.Entries[clean]; ok && !cur.Deleted && cur.Skipped == "" &&
2568 cur.SHA256 == sum(body) && cur.Mode == mode&0o777 && cur.Torn == torn {
2569 return nil
2570 }
2571 e, err := p.put(ctx, body, mode, torn)
2572 if err != nil {
2573 return err
2574 }
2575 p.man.Entries[clean], p.dirty = e, true
2576 return nil
2577}
2578
2579// StageSkipped records that path exists but was not published, and why.
2580func (p *Publisher) StageSkipped(path, why string) error {
2581 clean, err := CleanPath(path)
2582 if err != nil {
2583 return err
2584 }
2585 p.mu.Lock()
2586 defer p.mu.Unlock()
2587 if cur, ok := p.man.Entries[clean]; ok && cur.Skipped == why {
2588 return nil
2589 }
2590 p.next++
2591 p.man.Entries[clean], p.dirty = Entry{Seq: p.next, Skipped: why}, true
2592 return nil
2593}
2594
2595// StageDelete records that path is gone. Deleting a path the manifest never
2596// had, or already has as deleted, is a no-op.
2597func (p *Publisher) StageDelete(path string) error {
2598 clean, err := CleanPath(path)
2599 if err != nil {
2600 return err
2601 }
2602 p.mu.Lock()
2603 defer p.mu.Unlock()
2604 if cur, ok := p.man.Entries[clean]; !ok || cur.Deleted {
2605 return nil
2606 }
2607 p.next++
2608 p.man.Entries[clean], p.dirty = Entry{Seq: p.next, Deleted: true}, true
2609 return nil
2610}
2611
2612// StagePlatform records a platform-managed item (wake state, an env file).
2613// It shares the manifest's ordering but is never written into the files
2614// directory by Restore.
2615func (p *Publisher) StagePlatform(ctx context.Context, name string, body []byte) error {
2616 if name == "" || strings.ContainsAny(name, "/\x00") {
2617 return fmt.Errorf("hostfiles: platform item %q is not a plain name", name)
2618 }
2619 p.mu.Lock()
2620 defer p.mu.Unlock()
2621 if cur, ok := p.man.Platform[name]; ok && cur.SHA256 == sum(body) {
2622 return nil
2623 }
2624 e, err := p.put(ctx, body, 0o600, false)
2625 if err != nil {
2626 return err
2627 }
2628 p.man.Platform[name], p.dirty = e, true
2629 return nil
2630}
2631
2632// Commit publishes everything staged as one manifest revision and returns
2633// its sequence number. With nothing staged it returns the current one. A
2634// failed commit leaves the staged changes in place, so the caller retries;
2635// ErrSuperseded is final.
2636func (p *Publisher) Commit(ctx context.Context) (int64, error) {
2637 p.mu.Lock()
2638 defer p.mu.Unlock()
2639 if !p.dirty {
2640 return p.man.Seq, nil
2641 }
2642 out := p.man
2643 out.Seq = p.next
2644 body, err := json.Marshal(out)
2645 if err != nil {
2646 return 0, fmt.Errorf("hostfiles: marshal manifest: %w", err)
2647 }
2648 etag, err := p.s.Put(ctx, ManifestKey(p.prefix), body, p.etag)
2649 if errors.Is(err, release.ErrCASConflict) {
2650 return 0, fmt.Errorf("%w: %s", ErrSuperseded, p.prefix)
2651 }
2652 if err != nil {
2653 return 0, err
2654 }
2655 p.man.Seq, p.etag, p.dirty = out.Seq, etag, false
2656 return out.Seq, nil
2657}
2658
2659func (p *Publisher) Dirty() bool { p.mu.Lock(); defer p.mu.Unlock(); return p.dirty }
2660
2661func (p *Publisher) Seq() int64 { p.mu.Lock(); defer p.mu.Unlock(); return p.man.Seq }
2662
2663// Paths lists every path the manifest holds as present: published or
2664// skipped, not deleted.
2665func (p *Publisher) Paths() []string {
2666 p.mu.Lock()
2667 defer p.mu.Unlock()
2668 var out []string
2669 for path, e := range p.man.Entries {
2670 if !e.Deleted {
2671 out = append(out, path)
2672 }
2673 }
2674 sort.Strings(out)
2675 return out
2676}
2677
2678// pending is the last sequence number handed out, committed or not.
2679func (p *Publisher) pending() int64 {
2680 p.mu.Lock()
2681 defer p.mu.Unlock()
2682 return p.next
2683}
2684```
2685
2686`gofmt` will expand the three one-line methods; let it.
2687
2688- [ ] **Step 5: Write `restore.go`**
2689
2690```go
2691package hostfiles
2692
2693import (
2694 "context"
2695 "crypto/rand"
2696 "encoding/hex"
2697 "errors"
2698 "fmt"
2699 "io/fs"
2700 "os"
2701 "path"
2702 "path/filepath"
2703 "sort"
2704 "strings"
2705
2706 "github.com/carlosframework/platform/internal/release"
2707)
2708
2709// syncDir makes a directory's entries durable. A test seam.
2710var syncDir = func(root *os.Root, rel string) error {
2711 d, err := root.Open(filepath.FromSlash(rel))
2712 if err != nil {
2713 return err
2714 }
2715 defer d.Close()
2716 return d.Sync()
2717}
2718
2719type RestoreStats struct {
2720 Written, Removed int
2721 Skipped []string // paths the manifest holds as present but unpublished
2722}
2723
2724// Restore makes dir match the manifest under prefix: it removes every
2725// tombstoned entry, writes every published one, and reports the skipped
2726// ones. It reads nothing but the manifest and the objects the manifest
2727// names, and verifies each object against the hash the manifest records.
2728//
2729// Every path is validated before anything is touched, so a manifest with
2730// one bad path changes nothing. Every file operation goes through an
2731// os.Root opened on dir, so nothing is written or removed outside it even
2732// if dir already holds a symlink pointing elsewhere.
2733//
2734// When Restore returns nil, what it did is durable: each file was synced
2735// before its rename, and every directory in which a name was created,
2736// replaced or removed has been synced since. A caller may record the
2737// installation as complete on that basis, and on no weaker one.
2738func Restore(ctx context.Context, s release.Store, prefix, dir string) (Manifest, RestoreStats, error) {
2739 var st RestoreStats
2740 m, _, _, err := load(ctx, s, prefix)
2741 if err != nil {
2742 return Manifest{}, st, err
2743 }
2744 var live, dead []string
2745 for p, e := range m.Entries {
2746 if _, err := CleanPath(p); err != nil {
2747 return Manifest{}, st, err
2748 }
2749 if e.Deleted {
2750 dead = append(dead, p)
2751 } else {
2752 live = append(live, p)
2753 }
2754 }
2755 if len(live)+len(dead) == 0 {
2756 return m, st, nil
2757 }
2758 root, err := os.OpenRoot(dir)
2759 if err != nil {
2760 return Manifest{}, st, fmt.Errorf("hostfiles: restore into %s: %w", dir, err)
2761 }
2762 defer root.Close()
2763
2764 // Tombstones first, deepest first, so a directory that became a file
2765 // is emptied before the file is written over it.
2766 // Every directory on the way to a path the manifest speaks of, live or
2767 // tombstoned, is a directory whose entries this restore may have
2768 // changed: now, or in an earlier attempt that removed or created the
2769 // name and then failed before its sync. So the set does not depend on
2770 // what this call happened to find already done.
2771 touched := map[string]bool{".": true}
2772 mark := func(p string) {
2773 for d := path.Dir(p); d != "."; d = path.Dir(d) {
2774 touched[d] = true
2775 }
2776 }
2777 sort.Slice(dead, func(i, j int) bool {
2778 di, dj := strings.Count(dead[i], "/"), strings.Count(dead[j], "/")
2779 if di != dj {
2780 return di > dj
2781 }
2782 return dead[i] < dead[j]
2783 })
2784 for _, p := range dead {
2785 mark(p)
2786 removed, err := removeInside(root, p)
2787 if err != nil {
2788 return Manifest{}, st, fmt.Errorf("hostfiles: remove %s: %w", p, err)
2789 }
2790 if removed {
2791 st.Removed++
2792 }
2793 }
2794 sort.Strings(live)
2795 for _, p := range live {
2796 e := m.Entries[p]
2797 if e.Skipped != "" {
2798 st.Skipped = append(st.Skipped, p)
2799 continue
2800 }
2801 mark(p)
2802 body, err := fetch(ctx, s, prefix, e)
2803 if err != nil {
2804 return Manifest{}, st, fmt.Errorf("hostfiles: %s: %w", p, err)
2805 }
2806 if err := writeInside(root, p, body, os.FileMode(e.Mode)); err != nil {
2807 return Manifest{}, st, fmt.Errorf("hostfiles: write %s: %w", p, err)
2808 }
2809 st.Written++
2810 }
2811 // Sync the directories that survive, deepest first and the root last,
2812 // so a directory is durable before the entry that names it is. One
2813 // that no longer exists as a real directory (it was emptied and then
2814 // replaced by a file, or it sat under a path that became a file) has
2815 // nothing to sync: the directory above it records its going.
2816 dirs := make([]string, 0, len(touched))
2817 for d := range touched {
2818 dirs = append(dirs, d)
2819 }
2820 sort.Slice(dirs, func(i, j int) bool {
2821 if (dirs[i] == ".") != (dirs[j] == ".") {
2822 return dirs[j] == "."
2823 }
2824 di, dj := strings.Count(dirs[i], "/"), strings.Count(dirs[j], "/")
2825 if di != dj {
2826 return di > dj
2827 }
2828 return dirs[i] < dirs[j]
2829 })
2830 for _, d := range dirs {
2831 if d != "." {
2832 if ok, err := realParents(root, d+"/x"); err != nil {
2833 return Manifest{}, st, fmt.Errorf("hostfiles: sync directory %s: %w", d, err)
2834 } else if !ok {
2835 continue
2836 }
2837 }
2838 if err := syncDir(root, d); err != nil {
2839 return Manifest{}, st, fmt.Errorf("hostfiles: sync directory %s: %w", d, err)
2840 }
2841 }
2842 return m, st, nil
2843}
2844
2845// ReadPlatform returns a platform-managed item's bytes, verified. A name
2846// the manifest does not hold is os.ErrNotExist.
2847func ReadPlatform(ctx context.Context, s release.Store, prefix string, m Manifest, name string) ([]byte, error) {
2848 e, ok := m.Platform[name]
2849 if !ok {
2850 return nil, fmt.Errorf("hostfiles: platform item %q: %w", name, os.ErrNotExist)
2851 }
2852 return fetch(ctx, s, prefix, e)
2853}
2854
2855func fetch(ctx context.Context, s release.Store, prefix string, e Entry) ([]byte, error) {
2856 body, _, err := s.Get(ctx, prefix+e.Object)
2857 if err != nil {
2858 return nil, err
2859 }
2860 if got := sum(body); got != e.SHA256 {
2861 return nil, fmt.Errorf("object %s has sha256 %s, the manifest says %s", e.Object, got, e.SHA256)
2862 }
2863 return body, nil
2864}
2865
2866// realParents reports whether every ancestor of rel inside root is a real
2867// directory: not a symlink, not a file, not missing.
2868func realParents(root *os.Root, rel string) (bool, error) {
2869 parts := strings.Split(rel, "/")
2870 cur := ""
2871 for _, part := range parts[:len(parts)-1] {
2872 cur = path.Join(cur, part)
2873 fi, err := root.Lstat(filepath.FromSlash(cur))
2874 if errors.Is(err, fs.ErrNotExist) {
2875 return false, nil
2876 }
2877 if err != nil {
2878 return false, err
2879 }
2880 if !fi.IsDir() {
2881 return false, nil
2882 }
2883 }
2884 return true, nil
2885}
2886
2887// removeInside removes rel if it is a file of ours. A path whose ancestor
2888// is a symlink, a file or missing is not ours to remove and is left alone:
2889// that is how a tombstone for "a/b" behaves once "a" has become a file,
2890// and why a symlinked ancestor never lets a delete reach outside root.
2891func removeInside(root *os.Root, rel string) (bool, error) {
2892 ok, err := realParents(root, rel)
2893 if err != nil || !ok {
2894 return false, err
2895 }
2896 name := filepath.FromSlash(rel)
2897 fi, err := root.Lstat(name)
2898 if errors.Is(err, fs.ErrNotExist) {
2899 return false, nil
2900 }
2901 if err != nil {
2902 return false, err
2903 }
2904 if fi.IsDir() {
2905 // The path is a directory now: a later entry lives under it, and
2906 // the tombstone is for a file that no longer exists.
2907 return false, nil
2908 }
2909 if err := root.Remove(name); err != nil && !errors.Is(err, fs.ErrNotExist) {
2910 return false, err
2911 }
2912 return true, nil
2913}
2914
2915// writeInside writes rel atomically inside root. Anything in the way that
2916// is not a real directory (a file where a directory must be, a symlink)
2917// is removed first, and a directory where the file must be is removed
2918// with its contents: the manifest is the truth about this tree.
2919func writeInside(root *os.Root, rel string, body []byte, mode os.FileMode) error {
2920 if mode == 0 {
2921 mode = 0o600
2922 }
2923 parent := path.Dir(rel)
2924 if parent != "." {
2925 cur := ""
2926 for _, part := range strings.Split(parent, "/") {
2927 cur = path.Join(cur, part)
2928 fi, err := root.Lstat(filepath.FromSlash(cur))
2929 if errors.Is(err, fs.ErrNotExist) {
2930 break
2931 }
2932 if err != nil {
2933 return err
2934 }
2935 if !fi.IsDir() {
2936 if err := root.Remove(filepath.FromSlash(cur)); err != nil {
2937 return err
2938 }
2939 break
2940 }
2941 }
2942 if err := root.MkdirAll(filepath.FromSlash(parent), 0o755); err != nil {
2943 return err
2944 }
2945 }
2946 name := filepath.FromSlash(rel)
2947 if fi, err := root.Lstat(name); err == nil && fi.IsDir() {
2948 if err := root.RemoveAll(name); err != nil {
2949 return err
2950 }
2951 }
2952 var rnd [8]byte
2953 if _, err := rand.Read(rnd[:]); err != nil {
2954 return err
2955 }
2956 tmp := filepath.FromSlash(path.Join(parent, ".carlos-restore-"+hex.EncodeToString(rnd[:])))
2957 f, err := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
2958 if err != nil {
2959 return err
2960 }
2961 done := false
2962 defer func() {
2963 if !done {
2964 f.Close()
2965 root.Remove(tmp)
2966 }
2967 }()
2968 if _, err := f.Write(body); err != nil {
2969 return err
2970 }
2971 if err := f.Chmod(mode); err != nil {
2972 return err
2973 }
2974 if err := f.Sync(); err != nil {
2975 return err
2976 }
2977 if err := f.Close(); err != nil {
2978 return err
2979 }
2980 if err := root.Rename(tmp, name); err != nil {
2981 return err
2982 }
2983 done = true
2984 return nil
2985}
2986```
2987
2988- [ ] **Step 6: Run the tests**
2989
2990Run: `gofmt -w internal/hostfiles && go test ./internal/hostfiles/ -count=1`
2991Expected: PASS.
2992
2993`TestRestoreRefusesContentThatDoesNotMatchItsHash` overwrites an object through the test store with its etag. That is a test-only tamper. In production the publisher only ever creates objects; the bucket policy that makes storage refuse anything else is plan 1b's, because the files prefix depends on the claim's location layout, and it must be in place before the publisher is switched on for any host.
2994
2995`Restore` does every file operation through an `os.Root` opened on the destination (Go 1.24+), so a symlink already sitting in the destination cannot carry a write or a delete outside it. The destination must exist.
2996
2997**A restore that returns nil is durable.** Each file is synced before its rename, and before returning `Restore` syncs every surviving directory on the way to any path the manifest speaks of, live or tombstoned, deepest first and the root last, and fails if any of those syncs fails. The set comes from the manifest, not from what this call happened to change: an earlier attempt may have removed a file and then failed before its sync, and the retry, finding nothing to remove, must still make that removal durable. A directory that no longer exists as a real directory (it sat under a path that became a file) is skipped; the directory above it records its going. Plan 1b writes the installation-complete marker (Task 10) only after `Restore` returns nil; without the directory syncs that marker could outlive the names it vouches for across a crash. `TestRestoreSyncsEveryDirectoryItChangedBeforeReturning` pins the set, the order and the failure; `TestARetriedRestoreStillSyncsADeletionAnEarlierAttemptMade` the retry; `TestRestoreHandlesADeepDirectoryBecomingAFile` the `a/b/c` to `a` case.
2998
2999- [ ] **Step 7: Commit**
3000
3001```bash
3002git add internal/hostfiles
3003git commit -m "hostfiles: immutable objects and one manifest decide which file content is current
3004
3005Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
3006```
3007
3008---
3009
3010### Task 6: Publishing a whole directory (`hostfiles.SyncDir`)
3011
3012**Files:**
3013- Create: `internal/hostfiles/sync.go`, `internal/hostfiles/dir_unix.go`, `internal/hostfiles/walk_unix.go`, `internal/hostfiles/dir_other.go`
3014- Test: `internal/hostfiles/sync_test.go`, `internal/hostfiles/sync_unix_test.go` (both `//go:build unix`)
3015
3016**Interfaces:**
3017- Consumes: Task 5's `Publisher`.
3018- Produces:
3019 ```go
3020 type SyncStats struct {
3021 Published, Deleted int
3022 TooLarge, NotRegular []string // relative paths
3023 }
3024 // SyncDir stages every difference between dir and the manifest: changed
3025 // and new regular files, and a tombstone for each manifest path no longer
3026 // on disk. It does not commit. skip, when non-nil, excludes a relative
3027 // path (and, for a directory, everything under it).
3028 func SyncDir(ctx context.Context, p *Publisher, dir string, skip func(rel string, isDir bool) bool) (SyncStats, error)
3029 // A directory held open by descriptor; nothing beneath it is reached through a symlink.
3030 type dirHandle struct{ /* unexported */ }
3031 func openTree(path string) (*dirHandle, error)
3032 func (d *dirHandle) descend(rel string) (sub *dirHandle, gone bool, err error)
3033 func (d *dirHandle) Close() error
3034 // readStable reads the file name inside d for publishing.
3035 func readStable(d *dirHandle, name string) (body []byte, mode uint32, state fileState, err error)
3036 func syncRoot(ctx context.Context, p *Publisher, root *dirHandle, skip func(rel string, isDir bool) bool) (SyncStats, error)
3037 func skipped(skip func(string, bool) bool, rel string, isDir bool) bool // rel or any directory above it is excluded
3038 // fileState gains: fileUnreadable (exists, could not be read) and fileRetry (replaced mid-look).
3039 // SyncStats gains: Unreadable []string.
3040 ```
3041
3042This one function serves three callers in plan 1b: the `snapshot` setting (run every `files_snapshot_interval`), the continuous publisher's ten-minute repair scan, and the release sequence's "publish every pending mutation" step. That is why this plan has no separate sealed-archive snapshot: a periodic full `SyncDir` gives the `snapshot` setting the same result through the same manifest (recorded as a spec note in Task 11).
3043
3044- [ ] **Step 1: Write the failing tests**
3045
3046`internal/hostfiles/sync_test.go`:
3047
3048```go
3049//go:build unix
3050
3051package hostfiles
3052
3053import (
3054 "context"
3055 "io"
3056 "os"
3057 "path/filepath"
3058 "reflect"
3059 "testing"
3060
3061 "github.com/carlosframework/platform/internal/release"
3062)
3063
3064func write(t *testing.T, dir, rel, body string) {
3065 t.Helper()
3066 full := filepath.Join(dir, rel)
3067 if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
3068 t.Fatal(err)
3069 }
3070 if err := os.WriteFile(full, []byte(body), 0o640); err != nil {
3071 t.Fatal(err)
3072 }
3073}
3074
3075func TestSyncDirPublishesNewChangedAndDeleted(t *testing.T) {
3076 ctx, s, src := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir()
3077 p := open(t, s)
3078 write(t, src, "instance.key", "k")
3079 write(t, src, "uploads/a.txt", "a")
3080 st, err := SyncDir(ctx, p, src, nil)
3081 if err != nil || st.Published != 2 {
3082 t.Fatalf("first sync: %+v, %v", st, err)
3083 }
3084 commit(t, p)
3085
3086 write(t, src, "uploads/a.txt", "a2")
3087 if err := os.Remove(filepath.Join(src, "instance.key")); err != nil {
3088 t.Fatal(err)
3089 }
3090 write(t, src, "new.txt", "n")
3091 st, err = SyncDir(ctx, p, src, nil)
3092 if err != nil || st.Published != 2 || st.Deleted != 1 {
3093 t.Fatalf("second sync: %+v, %v", st, err)
3094 }
3095 commit(t, p)
3096
3097 dst := t.TempDir()
3098 if _, _, err := Restore(ctx, s, pfx, dst); err != nil {
3099 t.Fatal(err)
3100 }
3101 if read(t, dst, "uploads/a.txt") != "a2" || read(t, dst, "new.txt") != "n" {
3102 t.Fatal("content differs")
3103 }
3104 if _, err := os.Stat(filepath.Join(dst, "instance.key")); !os.IsNotExist(err) {
3105 t.Fatal("deleted file restored")
3106 }
3107 fi, _ := os.Stat(filepath.Join(dst, "new.txt"))
3108 if fi.Mode().Perm() != 0o640 {
3109 t.Fatalf("mode = %v, want 0640", fi.Mode().Perm())
3110 }
3111}
3112
3113func TestSyncDirOfAnUnchangedDirectoryStagesNothing(t *testing.T) {
3114 ctx, s, src := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir()
3115 p := open(t, s)
3116 write(t, src, "a", "a")
3117 if _, err := SyncDir(ctx, p, src, nil); err != nil {
3118 t.Fatal(err)
3119 }
3120 commit(t, p)
3121 st, err := SyncDir(ctx, p, src, nil)
3122 if err != nil || st.Published != 0 || st.Deleted != 0 || p.Dirty() {
3123 t.Fatalf("second sync: %+v dirty=%v err=%v", st, p.Dirty(), err)
3124 }
3125}
3126
3127// Review Focus 2: never follow or open what is not a regular file.
3128func TestSyncDirSkipsWhatIsNotARegularFile(t *testing.T) {
3129 ctx, s, src := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir()
3130 outside := filepath.Join(t.TempDir(), "secret")
3131 if err := os.WriteFile(outside, []byte("do not publish"), 0o600); err != nil {
3132 t.Fatal(err)
3133 }
3134 write(t, src, "real.txt", "r")
3135 if err := os.Symlink(outside, filepath.Join(src, "link")); err != nil {
3136 t.Fatal(err)
3137 }
3138 if err := os.Symlink(filepath.Dir(outside), filepath.Join(src, "linkdir")); err != nil {
3139 t.Fatal(err)
3140 }
3141 p := open(t, s)
3142 st, err := SyncDir(ctx, p, src, nil)
3143 if err != nil {
3144 t.Fatal(err)
3145 }
3146 if st.Published != 1 || !reflect.DeepEqual(st.NotRegular, []string{"link", "linkdir"}) {
3147 t.Fatalf("stats = %+v", st)
3148 }
3149 if got := p.Paths(); !reflect.DeepEqual(got, []string{"real.txt"}) {
3150 t.Fatalf("Paths = %v", got)
3151 }
3152}
3153
3154// Review Focus 3: too large is recorded, never read whole, never dropped.
3155func TestSyncDirRecordsAFileOverTheCapWithoutReadingIt(t *testing.T) {
3156 ctx, s, src := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir()
3157 big := filepath.Join(src, "huge.bin")
3158 f, err := os.Create(big)
3159 if err != nil {
3160 t.Fatal(err)
3161 }
3162 if err := f.Truncate(MaxFileBytes + 1); err != nil { // sparse: costs no disk
3163 t.Fatal(err)
3164 }
3165 f.Close()
3166 reads := 0
3167 orig := readAll
3168 readAll = func(r io.Reader) ([]byte, error) { reads++; return orig(r) }
3169 t.Cleanup(func() { readAll = orig })
3170 p := open(t, s)
3171 st, err := SyncDir(ctx, p, src, nil)
3172 if err != nil {
3173 t.Fatal(err)
3174 }
3175 if !reflect.DeepEqual(st.TooLarge, []string{"huge.bin"}) || st.Published != 0 {
3176 t.Fatalf("stats = %+v", st)
3177 }
3178 if reads != 0 {
3179 t.Fatalf("the oversized file was read %d times; it must be refused on its size alone", reads)
3180 }
3181 commit(t, p)
3182 _, rs, err := Restore(ctx, s, pfx, t.TempDir())
3183 if err != nil || !reflect.DeepEqual(rs.Skipped, []string{"huge.bin"}) {
3184 t.Fatalf("restore skipped %v, err %v", rs.Skipped, err)
3185 }
3186}
3187
3188func TestSyncDirHonoursSkip(t *testing.T) {
3189 ctx, s, src := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir()
3190 write(t, src, "keep.txt", "k")
3191 write(t, src, ".files-gen", "marker")
3192 write(t, src, "cache/x", "x")
3193 p := open(t, s)
3194 skip := func(rel string, isDir bool) bool { return rel == ".files-gen" || (isDir && rel == "cache") }
3195 if _, err := SyncDir(ctx, p, src, skip); err != nil {
3196 t.Fatal(err)
3197 }
3198 if got := p.Paths(); !reflect.DeepEqual(got, []string{"keep.txt"}) {
3199 t.Fatalf("Paths = %v", got)
3200 }
3201}
3202
3203func TestSyncDirOfAMissingDirectoryTombstonesNothing(t *testing.T) {
3204 ctx, s, src := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir()
3205 p := open(t, s)
3206 write(t, src, "a", "a")
3207 if _, err := SyncDir(ctx, p, src, nil); err != nil {
3208 t.Fatal(err)
3209 }
3210 commit(t, p)
3211 if _, err := SyncDir(ctx, p, filepath.Join(src, "does-not-exist"), nil); err == nil {
3212 t.Fatal("a missing root must be an error, not an empty directory")
3213 }
3214 if p.Dirty() {
3215 t.Fatal("a missing root staged tombstones")
3216 }
3217}
3218
3219// An excluded directory's files, published before the exclusion applied,
3220// are left as the manifest has them: not republished, not tombstoned.
3221func TestSyncDirDoesNotTombstoneWhatASkippedDirectoryHolds(t *testing.T) {
3222 ctx, s, src := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir()
3223 write(t, src, "cache/x", "x")
3224 write(t, src, "keep.txt", "k")
3225 p := open(t, s)
3226 if _, err := SyncDir(ctx, p, src, nil); err != nil {
3227 t.Fatal(err)
3228 }
3229 commit(t, p)
3230 skip := func(rel string, isDir bool) bool { return isDir && rel == "cache" }
3231 st, err := SyncDir(ctx, p, src, skip)
3232 if err != nil || st.Deleted != 0 || p.Dirty() {
3233 t.Fatalf("stats = %+v dirty=%v err=%v", st, p.Dirty(), err)
3234 }
3235 if got := p.Paths(); !reflect.DeepEqual(got, []string{"cache/x", "keep.txt"}) {
3236 t.Fatalf("Paths = %v", got)
3237 }
3238}
3239```
3240
3241Then `internal/hostfiles/sync_unix_test.go`, for special files, substitution races and unreadable files. `swapAt` uses the seam `betweenLookAndOpen` to swap a file or directory in the gap between its inspection and its open, which makes each race deterministic. Two tests skip where they cannot run: the socket test inside a sandbox that forbids sockets, the device test without root. Every test in this file uses only what Tasks 5 and 6 define; the tests of `publishOne` are Task 7's.
3242
3243```go
3244//go:build unix
3245
3246package hostfiles
3247
3248import (
3249 "context"
3250 "net"
3251 "os"
3252 "path/filepath"
3253 "reflect"
3254 "syscall"
3255 "testing"
3256 "time"
3257
3258 "github.com/carlosframework/platform/internal/release"
3259)
3260
3261// within fails the test if fn has not returned in two seconds: the way a
3262// blocked open shows up.
3263func within(t *testing.T, what string, fn func()) {
3264 t.Helper()
3265 done := make(chan struct{})
3266 go func() { defer close(done); fn() }()
3267 select {
3268 case <-done:
3269 case <-time.After(2 * time.Second):
3270 t.Fatalf("%s did not return: something blocked", what)
3271 }
3272}
3273
3274// swapAt arranges for fn to run once, at the nth pause between a look and
3275// an open (1 is the first).
3276func swapAt(t *testing.T, n int, fn func()) {
3277 t.Helper()
3278 orig, calls := betweenLookAndOpen, 0
3279 t.Cleanup(func() { betweenLookAndOpen = orig })
3280 betweenLookAndOpen = func() {
3281 calls++
3282 if calls == n {
3283 fn()
3284 }
3285 }
3286}
3287
3288func tree(t *testing.T, dir string) *dirHandle {
3289 t.Helper()
3290 d, err := openTree(dir)
3291 if err != nil {
3292 t.Fatal(err)
3293 }
3294 t.Cleanup(func() { d.Close() })
3295 return d
3296}
3297
3298func fifo(t *testing.T, path string) {
3299 t.Helper()
3300 if err := syscall.Mkfifo(path, 0o600); err != nil {
3301 t.Skipf("no FIFOs here: %v", err)
3302 }
3303}
3304
3305// Review Focus 2: special files are skipped, never opened for reading.
3306func TestSyncDirSkipsAFIFO(t *testing.T) {
3307 ctx, s, src := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir()
3308 write(t, src, "real.txt", "r")
3309 fifo(t, filepath.Join(src, "pipe"))
3310 p := open(t, s)
3311 var st SyncStats
3312 within(t, "SyncDir", func() { st, _ = SyncDir(ctx, p, src, nil) })
3313 if st.Published != 1 || !reflect.DeepEqual(st.NotRegular, []string{"pipe"}) {
3314 t.Fatalf("stats = %+v", st)
3315 }
3316}
3317
3318func TestSyncDirSkipsASocket(t *testing.T) {
3319 ctx, s, src := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir()
3320 l, err := net.Listen("unix", filepath.Join(src, "sock"))
3321 if err != nil {
3322 t.Skipf("no Unix sockets here: %v", err)
3323 }
3324 defer l.Close()
3325 p := open(t, s)
3326 var st SyncStats
3327 within(t, "SyncDir", func() { st, _ = SyncDir(ctx, p, src, nil) })
3328 if !reflect.DeepEqual(st.NotRegular, []string{"sock"}) || len(p.Paths()) != 0 {
3329 t.Fatalf("stats = %+v, paths = %v", st, p.Paths())
3330 }
3331}
3332
3333func TestSyncDirSkipsADevice(t *testing.T) {
3334 if os.Getuid() != 0 {
3335 t.Skip("making a device node needs root")
3336 }
3337 ctx, s, src := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir()
3338 if err := syscall.Mknod(filepath.Join(src, "null"), syscall.S_IFCHR|0o600, 1<<8|3); err != nil {
3339 t.Skipf("mknod: %v", err)
3340 }
3341 p := open(t, s)
3342 st, err := SyncDir(ctx, p, src, nil)
3343 if err != nil || !reflect.DeepEqual(st.NotRegular, []string{"null"}) {
3344 t.Fatalf("stats = %+v, err = %v", st, err)
3345 }
3346}
3347
3348// A symlinked directory is never descended into, wherever it points.
3349func TestSyncDirDoesNotDescendASymlinkedDirectory(t *testing.T) {
3350 ctx, s, src, outside := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir(), t.TempDir()
3351 if err := os.WriteFile(filepath.Join(outside, "secret"), []byte("s"), 0o600); err != nil {
3352 t.Fatal(err)
3353 }
3354 write(t, src, "inside/own", "o")
3355 for link, target := range map[string]string{"out": outside, "in": "inside"} {
3356 if err := os.Symlink(target, filepath.Join(src, link)); err != nil {
3357 t.Fatal(err)
3358 }
3359 }
3360 p := open(t, s)
3361 st, err := SyncDir(ctx, p, src, nil)
3362 if err != nil {
3363 t.Fatal(err)
3364 }
3365 if got := p.Paths(); !reflect.DeepEqual(got, []string{"inside/own"}) {
3366 t.Fatalf("Paths = %v", got)
3367 }
3368 if !reflect.DeepEqual(st.NotRegular, []string{"in", "out"}) {
3369 t.Fatalf("NotRegular = %v", st.NotRegular)
3370 }
3371}
3372
3373// A regular file swapped for a FIFO after it was inspected must neither
3374// block the open nor be read.
3375func TestReadStableDoesNotBlockOnAFIFOSwappedIn(t *testing.T) {
3376 dir := t.TempDir()
3377 write(t, dir, "f", "regular")
3378 d := tree(t, dir)
3379 swapAt(t, 1, func() {
3380 full := filepath.Join(dir, "f")
3381 if err := os.Remove(full); err != nil {
3382 t.Error(err)
3383 }
3384 if err := syscall.Mkfifo(full, 0o600); err != nil {
3385 t.Error(err)
3386 }
3387 })
3388 var body []byte
3389 var state fileState
3390 within(t, "readStable", func() { body, _, state, _ = readStable(d, "f") })
3391 if state != fileNotRegular || body != nil {
3392 t.Fatalf("state = %v, %d bytes; want fileNotRegular and nothing read", state, len(body))
3393 }
3394}
3395
3396// A directory swapped for a FIFO between being listed and being entered
3397// must not hang the scan.
3398func TestSyncDirDoesNotBlockOnADirectorySwappedForAFIFO(t *testing.T) {
3399 ctx, s, src := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir()
3400 write(t, src, "d/child", "c")
3401 write(t, src, "z.txt", "z")
3402 swapAt(t, 1, func() { // the first pause is before entering "d"
3403 if err := os.RemoveAll(filepath.Join(src, "d")); err != nil {
3404 t.Error(err)
3405 }
3406 if err := syscall.Mkfifo(filepath.Join(src, "d"), 0o600); err != nil {
3407 t.Error(err)
3408 }
3409 })
3410 p := open(t, s)
3411 var st SyncStats
3412 var err error
3413 within(t, "SyncDir", func() { st, err = SyncDir(ctx, p, src, nil) })
3414 if err != nil {
3415 t.Fatal(err)
3416 }
3417 if !reflect.DeepEqual(st.NotRegular, []string{"d"}) || !reflect.DeepEqual(p.Paths(), []string{"z.txt"}) {
3418 t.Fatalf("stats = %+v, paths = %v", st, p.Paths())
3419 }
3420}
3421
3422// Plan round 2, finding 2: a file that still exists is never tombstoned
3423// just because it could not be read.
3424func TestSyncDirKeepsAnUnreadableFilesPublishedEntry(t *testing.T) {
3425 if os.Getuid() == 0 {
3426 t.Skip("root can read anything")
3427 }
3428 ctx, s, src := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir()
3429 write(t, src, "locked", "v1")
3430 p := open(t, s)
3431 if _, err := SyncDir(ctx, p, src, nil); err != nil {
3432 t.Fatal(err)
3433 }
3434 commit(t, p)
3435 if err := os.Chmod(filepath.Join(src, "locked"), 0); err != nil {
3436 t.Fatal(err)
3437 }
3438 st, err := SyncDir(ctx, p, src, nil)
3439 if err != nil {
3440 t.Fatal(err)
3441 }
3442 if st.Deleted != 0 || p.Dirty() || !reflect.DeepEqual(st.Unreadable, []string{"locked"}) {
3443 t.Fatalf("stats = %+v dirty = %v", st, p.Dirty())
3444 }
3445}
3446
3447// A regular file replaced by another regular file between the look and
3448// the open is neither read nor declared gone: the caller asks again.
3449func TestReadStableAsksAgainWhenARegularFileIsReplacedMidLook(t *testing.T) {
3450 dir := t.TempDir()
3451 write(t, dir, "f", "v1")
3452 d := tree(t, dir)
3453 swapAt(t, 1, func() {
3454 tmp := filepath.Join(dir, "f.new")
3455 if err := os.WriteFile(tmp, []byte("v2"), 0o640); err != nil {
3456 t.Error(err)
3457 }
3458 if err := os.Rename(tmp, filepath.Join(dir, "f")); err != nil {
3459 t.Error(err)
3460 }
3461 })
3462 body, _, state, err := readStable(d, "f")
3463 if err != nil || state != fileRetry || body != nil {
3464 t.Fatalf("state = %v, body = %q, err = %v; want fileRetry and nothing read", state, body, err)
3465 }
3466 body, _, state, err = readStable(d, "f")
3467 if err != nil || state != fileOK || string(body) != "v2" {
3468 t.Fatalf("second look: state = %v, body = %q, err = %v", state, body, err)
3469 }
3470}
3471
3472// And through a scan: the entry the manifest already has is kept.
3473func TestSyncDirKeepsAnEntryWhoseFileWasReplacedMidLook(t *testing.T) {
3474 ctx, s, src := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir()
3475 write(t, src, "f", "v1")
3476 p := open(t, s)
3477 if _, err := SyncDir(ctx, p, src, nil); err != nil {
3478 t.Fatal(err)
3479 }
3480 commit(t, p)
3481 swapAt(t, 1, func() {
3482 tmp := filepath.Join(src, "f.new")
3483 if err := os.WriteFile(tmp, []byte("v2"), 0o640); err != nil {
3484 t.Error(err)
3485 }
3486 if err := os.Rename(tmp, filepath.Join(src, "f")); err != nil {
3487 t.Error(err)
3488 }
3489 })
3490 st, err := SyncDir(ctx, p, src, nil)
3491 if err != nil || st.Deleted != 0 || p.Dirty() {
3492 t.Fatalf("stats = %+v dirty = %v err = %v; the replaced file must be left for the next scan", st, p.Dirty(), err)
3493 }
3494 if _, err := SyncDir(ctx, p, src, nil); err != nil || !p.Dirty() {
3495 t.Fatalf("the next scan did not pick the replacement up: dirty = %v err = %v", p.Dirty(), err)
3496 }
3497}
3498```
3499
3500- [ ] **Step 2: Run them to see them fail**
3501
3502Run: `go test ./internal/hostfiles/ -run 'SyncDir|ReadStable' -count=1`
3503Expected: FAIL to build, `undefined: SyncDir`.
3504
3505- [ ] **Step 3: Write `sync.go`**
3506
3507```go
3508package hostfiles
3509
3510import (
3511 "context"
3512 "fmt"
3513 "io"
3514 "path"
3515 "sort"
3516)
3517
3518type fileState int
3519
3520const (
3521 fileOK fileState = iota
3522 fileTorn
3523 fileGone
3524 fileNotRegular
3525 fileTooLarge
3526 fileUnreadable
3527 fileRetry
3528)
3529
3530// Test seams. betweenLookAndOpen runs after something has been inspected
3531// and before it is opened; readAll is the one place file contents are read.
3532var (
3533 betweenLookAndOpen = func() {}
3534 readAll = io.ReadAll
3535)
3536
3537type SyncStats struct {
3538 Published, Deleted int
3539 TooLarge, NotRegular, Unreadable []string
3540}
3541
3542// skipped reports whether rel, or any directory above it, is excluded.
3543func skipped(skip func(string, bool) bool, rel string, isDir bool) bool {
3544 if skip == nil {
3545 return false
3546 }
3547 if skip(rel, isDir) {
3548 return true
3549 }
3550 for dir := path.Dir(rel); dir != "."; dir = path.Dir(dir) {
3551 if skip(dir, true) {
3552 return true
3553 }
3554 }
3555 return false
3556}
3557
3558// SyncDir stages every difference between dir and the manifest: changed
3559// and new regular files, and a tombstone for each manifest path that is
3560// no longer there. It does not commit. skip, when non-nil, excludes a
3561// relative path and, for a directory, everything under it; an excluded
3562// path is left exactly as the manifest has it.
3563//
3564// A tombstone needs proof of absence. A file that exists but could not be
3565// read keeps its manifest entry and is reported in Unreadable. A root
3566// that cannot be opened is an error and stages nothing: an unreadable
3567// directory is not an empty one.
3568func SyncDir(ctx context.Context, p *Publisher, dir string, skip func(rel string, isDir bool) bool) (SyncStats, error) {
3569 root, err := openTree(dir)
3570 if err != nil {
3571 return SyncStats{}, fmt.Errorf("hostfiles: sync %s: %w", dir, err)
3572 }
3573 defer root.Close()
3574 return syncRoot(ctx, p, root, skip)
3575}
3576
3577func syncRoot(ctx context.Context, p *Publisher, root *dirHandle, skip func(rel string, isDir bool) bool) (SyncStats, error) {
3578 var st SyncStats
3579 seen := map[string]bool{}
3580 err := syncTree(root, p, skip, func(d *dirHandle, name, rel string) error {
3581 if err := ctx.Err(); err != nil {
3582 return err
3583 }
3584 body, mode, state, err := readStable(d, name)
3585 if err != nil {
3586 return err
3587 }
3588 switch state {
3589 case fileGone:
3590 return nil
3591 case fileNotRegular:
3592 st.NotRegular = append(st.NotRegular, rel)
3593 return nil
3594 case fileUnreadable:
3595 seen[rel] = true // it exists: keep what the manifest has
3596 st.Unreadable = append(st.Unreadable, rel)
3597 return nil
3598 case fileRetry:
3599 seen[rel] = true // replaced mid-look: the next scan reads it
3600 return nil
3601 case fileTooLarge:
3602 seen[rel] = true
3603 st.TooLarge = append(st.TooLarge, rel)
3604 return p.StageSkipped(rel, "too-large")
3605 }
3606 seen[rel] = true
3607 before := p.pending()
3608 if err := p.Stage(ctx, rel, body, mode, state == fileTorn); err != nil {
3609 return err
3610 }
3611 if p.pending() != before {
3612 st.Published++
3613 }
3614 return nil
3615 })
3616 if err != nil {
3617 return st, fmt.Errorf("hostfiles: sync %s: %w", root.Name(), err)
3618 }
3619 for _, rel := range p.Paths() {
3620 if seen[rel] || skipped(skip, rel, false) {
3621 continue
3622 }
3623 if err := p.StageDelete(rel); err != nil {
3624 return st, err
3625 }
3626 st.Deleted++
3627 }
3628 sort.Strings(st.TooLarge)
3629 sort.Strings(st.NotRegular)
3630 sort.Strings(st.Unreadable)
3631 return st, nil
3632}
3633```
3634
3635`Publisher.pending()` (in Task 5's `publisher.go`) is the last sequence number handed out; `SyncDir` uses it to count what a `Stage` call really published.
3636
3637`internal/hostfiles/dir_unix.go`:
3638
3639```go
3640//go:build unix
3641
3642package hostfiles
3643
3644import (
3645 "errors"
3646 "io"
3647 "os"
3648 "sort"
3649 "strings"
3650
3651 "golang.org/x/sys/unix"
3652)
3653
3654// dirHandle is a directory held open by descriptor. Everything beneath it
3655// is reached one path component at a time with openat and O_NOFOLLOW, so
3656// no symlink is ever followed, inside the tree or out of it, and a
3657// directory swapped for something else after it was looked at is refused
3658// rather than entered. Every open is non-blocking, so a FIFO swapped in
3659// for a file or a directory cannot hang the publisher.
3660type dirHandle struct{ f *os.File }
3661
3662// openTree opens the root of a files directory. The path to the root is
3663// the platform's own and may contain symlinks; nothing beneath it may.
3664func openTree(path string) (*dirHandle, error) {
3665 fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NONBLOCK, 0)
3666 if err != nil {
3667 return nil, &os.PathError{Op: "open", Path: path, Err: err}
3668 }
3669 return &dirHandle{os.NewFile(uintptr(fd), path)}, nil
3670}
3671
3672func (d *dirHandle) Close() error { return d.f.Close() }
3673func (d *dirHandle) Name() string { return d.f.Name() }
3674
3675// openDir opens the directory name inside d. A name that is not a real
3676// directory (a symlink, a file, a FIFO) fails with ENOTDIR or ELOOP
3677// without blocking; a missing one with ENOENT.
3678func (d *dirHandle) openDir(name string) (*dirHandle, error) {
3679 fd, err := unix.Openat(int(d.f.Fd()), name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC|unix.O_NONBLOCK, 0)
3680 if err != nil {
3681 return nil, &os.PathError{Op: "openat", Path: name, Err: err}
3682 }
3683 return &dirHandle{os.NewFile(uintptr(fd), name)}, nil
3684}
3685
3686// names lists d's entries, sorted.
3687func (d *dirHandle) names() ([]string, error) {
3688 if _, err := d.f.Seek(0, io.SeekStart); err != nil {
3689 return nil, err
3690 }
3691 names, err := d.f.Readdirnames(-1)
3692 if err != nil {
3693 return nil, err
3694 }
3695 sort.Strings(names)
3696 return names, nil
3697}
3698
3699// look inspects name inside d without following a symlink.
3700func (d *dirHandle) look(name string) (unix.Stat_t, error) {
3701 var st unix.Stat_t
3702 err := unix.Fstatat(int(d.f.Fd()), name, &st, unix.AT_SYMLINK_NOFOLLOW)
3703 return st, err
3704}
3705
3706func isDir(st unix.Stat_t) bool { return st.Mode&unix.S_IFMT == unix.S_IFDIR }
3707func isRegular(st unix.Stat_t) bool { return st.Mode&unix.S_IFMT == unix.S_IFREG }
3708
3709func sameFile(a, b unix.Stat_t) bool { return a.Dev == b.Dev && a.Ino == b.Ino }
3710
3711func sameImage(a, b unix.Stat_t) bool {
3712 return sameFile(a, b) && a.Size == b.Size && a.Mtim == b.Mtim
3713}
3714
3715// notADirectory reports the errors openDir gives for a name that exists
3716// but is not a real directory.
3717func notADirectory(err error) bool {
3718 return errors.Is(err, unix.ENOTDIR) || errors.Is(err, unix.ELOOP)
3719}
3720
3721// descend opens the directory rel ("" or "a/b") beneath d, one component
3722// at a time. gone is true when some component is missing or is not a real
3723// directory: nothing of ours lives at a path under it.
3724func (d *dirHandle) descend(rel string) (sub *dirHandle, gone bool, err error) {
3725 cur, owned := d, false
3726 if rel == "" || rel == "." {
3727 return d, false, nil
3728 }
3729 for _, part := range strings.Split(rel, "/") {
3730 betweenLookAndOpen()
3731 next, err := cur.openDir(part)
3732 if owned {
3733 cur.Close()
3734 }
3735 if err != nil {
3736 if errors.Is(err, unix.ENOENT) || notADirectory(err) {
3737 return nil, true, nil
3738 }
3739 return nil, false, err
3740 }
3741 cur, owned = next, true
3742 }
3743 return cur, false, nil
3744}
3745
3746// readStable reads the file name inside d for publishing.
3747//
3748// It reads only what it opened without following a link and found to be
3749// the same regular file it had just inspected. A file over MaxFileBytes is
3750// refused on its size alone, before any read. What it reports:
3751//
3752// - fileGone: nothing by that name.
3753// - fileNotRegular: something by that name that is not a regular file.
3754// - fileUnreadable: a regular file that could not be opened or read. It
3755// still exists, so the caller must keep what the manifest has for it.
3756// - fileRetry: the regular file was replaced by another regular file
3757// between the look and the open. Nothing was read; ask again.
3758// - fileTorn: read, but its size, mtime or identity changed meanwhile.
3759// The bytes are a possibly inconsistent image.
3760func readStable(d *dirHandle, name string) ([]byte, uint32, fileState, error) {
3761 before, err := d.look(name)
3762 if errors.Is(err, unix.ENOENT) {
3763 return nil, 0, fileGone, nil
3764 }
3765 if err != nil {
3766 return nil, 0, fileUnreadable, nil
3767 }
3768 if !isRegular(before) {
3769 return nil, 0, fileNotRegular, nil
3770 }
3771 if before.Size > MaxFileBytes {
3772 return nil, 0, fileTooLarge, nil
3773 }
3774 betweenLookAndOpen()
3775 fd, err := unix.Openat(int(d.f.Fd()), name, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC|unix.O_NONBLOCK, 0)
3776 switch {
3777 case errors.Is(err, unix.ENOENT):
3778 return nil, 0, fileGone, nil
3779 case errors.Is(err, unix.ELOOP):
3780 return nil, 0, fileNotRegular, nil // it became a symlink
3781 case err != nil:
3782 return nil, 0, fileUnreadable, nil
3783 }
3784 f := os.NewFile(uintptr(fd), name)
3785 defer f.Close()
3786 var opened unix.Stat_t
3787 if err := unix.Fstat(fd, &opened); err != nil {
3788 return nil, 0, fileUnreadable, nil
3789 }
3790 if !isRegular(opened) {
3791 return nil, 0, fileNotRegular, nil // swapped for a FIFO, a device, a directory
3792 }
3793 if !sameFile(before, opened) {
3794 return nil, 0, fileRetry, nil
3795 }
3796 if opened.Size > MaxFileBytes {
3797 return nil, 0, fileTooLarge, nil
3798 }
3799 body, err := readAll(io.LimitReader(f, MaxFileBytes+1))
3800 if err != nil {
3801 return nil, 0, fileUnreadable, nil
3802 }
3803 if int64(len(body)) > MaxFileBytes {
3804 return nil, 0, fileTooLarge, nil // it grew past the cap while being read
3805 }
3806 state := fileOK
3807 if after, err := d.look(name); err != nil || !sameImage(before, after) {
3808 state = fileTorn
3809 }
3810 return body, uint32(before.Mode & 0o777), state, nil
3811}
3812```
3813
3814`internal/hostfiles/walk_unix.go`:
3815
3816```go
3817//go:build unix
3818
3819package hostfiles
3820
3821import (
3822 "errors"
3823
3824 "golang.org/x/sys/unix"
3825)
3826
3827// syncTree walks the tree beneath root by descriptor and calls visit for
3828// every entry that is not a directory, with the open directory that holds
3829// it, its name there, and its path relative to root. It never follows a
3830// symlink and never blocks on a FIFO: a directory is entered only through
3831// openDir.
3832func syncTree(root *dirHandle, p *Publisher, skip func(string, bool) bool, visit func(d *dirHandle, name, rel string) error) error {
3833 return walkDir(root, "", skip, visit)
3834}
3835
3836func walkDir(d *dirHandle, prefix string, skip func(string, bool) bool, visit func(d *dirHandle, name, rel string) error) error {
3837 names, err := d.names()
3838 if err != nil {
3839 return err
3840 }
3841 for _, name := range names {
3842 rel := name
3843 if prefix != "" {
3844 rel = prefix + "/" + name
3845 }
3846 st, err := d.look(name)
3847 if errors.Is(err, unix.ENOENT) {
3848 continue
3849 }
3850 if err != nil {
3851 return err
3852 }
3853 if !isDir(st) {
3854 if skipped(skip, rel, false) {
3855 continue
3856 }
3857 if err := visit(d, name, rel); err != nil {
3858 return err
3859 }
3860 continue
3861 }
3862 if skipped(skip, rel, true) {
3863 continue
3864 }
3865 betweenLookAndOpen()
3866 sub, err := d.openDir(name)
3867 if err != nil {
3868 if errors.Is(err, unix.ENOENT) {
3869 continue
3870 }
3871 if notADirectory(err) {
3872 // It was a directory a moment ago and is something else
3873 // now. Let readStable say what: it will not follow or
3874 // block on it either.
3875 if err := visit(d, name, rel); err != nil {
3876 return err
3877 }
3878 continue
3879 }
3880 return err
3881 }
3882 err = walkDir(sub, rel, skip, visit)
3883 sub.Close()
3884 if err != nil {
3885 return err
3886 }
3887 }
3888 return nil
3889}
3890```
3891
3892`internal/hostfiles/dir_other.go`, so the package still builds on Windows, where it restores and reads manifests but does not publish:
3893
3894```go
3895//go:build !unix
3896
3897package hostfiles
3898
3899import "errors"
3900
3901// The publisher reads a files directory by descriptor, without following
3902// links, which needs openat. The agent runs on Linux; elsewhere this
3903// package builds, restores and reads manifests, and does not publish.
3904var errNoPublish = errors.New("hostfiles: publishing a directory is not supported on this OS")
3905
3906type dirHandle struct{}
3907
3908func openTree(path string) (*dirHandle, error) { return nil, errNoPublish }
3909func (d *dirHandle) Close() error { return nil }
3910func (d *dirHandle) Name() string { return "" }
3911
3912func syncTree(d *dirHandle, p *Publisher, skip func(string, bool) bool, visit func(*dirHandle, string, string) error) error {
3913 return errNoPublish
3914}
3915
3916func (d *dirHandle) descend(rel string) (*dirHandle, bool, error) { return nil, false, errNoPublish }
3917
3918func readStable(d *dirHandle, name string) ([]byte, uint32, fileState, error) {
3919 return nil, 0, fileUnreadable, errNoPublish
3920}
3921```
3922
3923Why it is built this way:
3924
3925- **Everything is reached by descriptor, one component at a time.** `openat` with `O_NOFOLLOW` never follows a symlink, whether it points outside the directory or to another part of it (an excluded directory, say). `O_DIRECTORY` makes an open of something that is not a real directory fail at once, and `O_NONBLOCK` stops any open from hanging on a FIFO. So a directory or file swapped for something else after it was looked at is refused, not entered and not waited on. The scan walks with these handles too: `fs.WalkDir` would re-open each directory by name with a plain blocking open.
3926- **A tombstone needs proof of absence.** `readStable` distinguishes five outcomes that are not "here are the bytes", and only two of them stage a delete: the name is gone, or something that is not a regular file stands there. A file that exists but cannot be read (`fileUnreadable`) keeps its manifest entry and is reported. A regular file replaced by another regular file mid-look (`fileRetry`) is read again later.
3927- **Size is checked before any read**, from the first look and again from the opened file. `TestSyncDirRecordsAFileOverTheCapWithoutReadingIt` counts calls to `readAll` and expects none.
3928- **`skipped`** applies an exclusion to a path and to every directory above it, in the walk and in the tombstone pass alike.
3929- **`betweenLookAndOpen`** is a test seam that runs in every gap between inspecting a name and opening it. The substitution tests use it to make each race happen exactly once, on demand.
3930
3931- [ ] **Step 4: Run the tests**
3932
3933Run: `go test ./internal/hostfiles/ -count=1 && GOOS=windows go build ./internal/hostfiles/ && GOOS=darwin go build ./internal/hostfiles/`
3934Expected: PASS, and both cross-builds succeed. Then `go test ./internal/hostfiles/ -run 'Socket|Device' -count=1 -v`: on a machine that allows Unix sockets the socket test must `PASS`, not `SKIP`.
3935
3936- [ ] **Step 5: Commit**
3937
3938```bash
3939git add internal/hostfiles
3940git commit -m "hostfiles: SyncDir stages a directory's differences, and never follows a link
3941
3942Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
3943```
3944
3945---
3946
3947### Task 7: Continuous publishing with a deadline (`hostfiles.Run`)
3948
3949**Files:**
3950- Create: `internal/hostfiles/deadline.go`, `internal/hostfiles/run.go`, `internal/hostfiles/watch_linux.go`, `internal/hostfiles/watch_other.go`
3951- Test: `internal/hostfiles/deadline_test.go`, `internal/hostfiles/run_test.go` and `internal/hostfiles/publish_unix_test.go` (both `//go:build unix`), `internal/hostfiles/watch_linux_test.go`
3952
3953**Interfaces:**
3954- Consumes: `Publisher`, `SyncDir`, `readStable`.
3955- Produces:
3956 ```go
3957 type Options struct {
3958 Deadline time.Duration // first unpublished modification to publish; spec: 1s
3959 Scan time.Duration // repair scan; spec: 10m
3960 Poll time.Duration // full-scan period while a watch is missing; spec: 1s
3961 Skip func(rel string, isDir bool) bool
3962 OnStats func(SyncStats) // called after each full scan; may be nil
3963 }
3964 func DefaultOptions() Options
3965 // Run publishes dir's changes until ctx ends or the manifest is superseded.
3966 func Run(ctx context.Context, p *Publisher, dir string, o Options) error
3967 // internal:
3968 type event struct{ rel string; rescan bool }
3969 type watcher interface {
3970 Events() <-chan event // closes when the watcher can report nothing more
3971 Degraded() bool // some directory may have no working watch
3972 Repair() error // restore full coverage, or say why not
3973 Close() error
3974 }
3975 func newWatcher(root string, skip func(string, bool) bool) (watcher, error) // per-OS
3976 ```
3977
3978Spec §5.6, continuous mode: a file is published within one second of its **first** unpublished modification, whether or not it has been closed; a file still being written is published again at the next deadline; an unstable read is published anyway, flagged; a missing watch means polling every second until coverage returns.
3979
3980Three failure rules, each with its own test:
3981
3982- **Overflow makes coverage uncertain.** A dropped event may have been a directory's creation, which leaves that directory unwatched. So an overflow marks the watcher degraded as well as asking for a scan; the loop then polls and calls `Repair` on every pass until `Repair` succeeds, and scans once more after it does.
3983- **A repair clears only what it could have repaired.** Coverage is two counters, not a flag: `failures` rises on every overflow or failed watch, and `repaired` is set to the value `failures` had when a successful walk began. A failure that arrives during the walk may concern a directory the walk has already passed, so it leaves the two unequal and the watcher still degraded.
3984- **A dead watcher means polling for good.** If the reader stops, its channel closes. The loop sets the channel and the watcher to nil and polls from then on. It must not spin on the closed channel, and `Repair` on a dead watcher must fail.
3985- **The first full scan is part of start-up.** Tests wait for it (`startRun` returns after the first `OnStats`) before they change anything, and the event tests run with scans and polling an hour apart, so only the mechanism under test can satisfy them.
3986
3987- [ ] **Step 1: Write the deadline queue's failing test**
3988
3989`internal/hostfiles/deadline_test.go`:
3990
3991```go
3992package hostfiles
3993
3994import (
3995 "reflect"
3996 "testing"
3997 "time"
3998)
3999
4000func TestQueueArmsOnTheFirstTouchAndLaterTouchesDoNotPushItBack(t *testing.T) {
4001 q := newQueue(time.Second)
4002 t0 := time.Unix(1000, 0)
4003 q.Touch("a", t0)
4004 q.Touch("a", t0.Add(900*time.Millisecond)) // still being written
4005 if got := q.Due(t0.Add(999 * time.Millisecond)); len(got) != 0 {
4006 t.Fatalf("due early: %v", got)
4007 }
4008 if got := q.Due(t0.Add(time.Second)); !reflect.DeepEqual(got, []string{"a"}) {
4009 t.Fatalf("due = %v, want [a]", got)
4010 }
4011 if got := q.Due(t0.Add(2 * time.Second)); len(got) != 0 {
4012 t.Fatalf("due twice: %v", got)
4013 }
4014 q.Touch("a", t0.Add(3*time.Second)) // a new modification arms a new deadline
4015 if got := q.Due(t0.Add(4 * time.Second)); !reflect.DeepEqual(got, []string{"a"}) {
4016 t.Fatalf("second deadline: %v", got)
4017 }
4018}
4019
4020func TestQueueReturnsDuePathsSorted(t *testing.T) {
4021 q := newQueue(time.Second)
4022 t0 := time.Unix(1000, 0)
4023 for _, p := range []string{"c", "a", "b"} {
4024 q.Touch(p, t0)
4025 }
4026 if got := q.Due(t0.Add(time.Second)); !reflect.DeepEqual(got, []string{"a", "b", "c"}) {
4027 t.Fatalf("due = %v", got)
4028 }
4029}
4030```
4031
4032- [ ] **Step 2: Run it to see it fail**
4033
4034Run: `go test ./internal/hostfiles/ -run Queue -count=1`
4035Expected: FAIL to build, `undefined: newQueue`.
4036
4037- [ ] **Step 3: Write `deadline.go`**
4038
4039```go
4040package hostfiles
4041
4042import (
4043 "sort"
4044 "time"
4045)
4046
4047// queue holds, per path, the time by which its first unpublished
4048// modification must be published. A later modification of a path already
4049// waiting does not move its deadline, so a file written without pause is
4050// still published every period.
4051type queue struct {
4052 d time.Duration
4053 due map[string]time.Time
4054}
4055
4056func newQueue(d time.Duration) *queue { return &queue{d: d, due: map[string]time.Time{}} }
4057
4058func (q *queue) Touch(rel string, now time.Time) {
4059 if _, waiting := q.due[rel]; !waiting {
4060 q.due[rel] = now.Add(q.d)
4061 }
4062}
4063
4064// Due removes and returns every path whose deadline has arrived.
4065func (q *queue) Due(now time.Time) []string {
4066 var out []string
4067 for rel, at := range q.due {
4068 if !at.After(now) {
4069 out = append(out, rel)
4070 delete(q.due, rel)
4071 }
4072 }
4073 sort.Strings(out)
4074 return out
4075}
4076```
4077
4078Run: `go test ./internal/hostfiles/ -run Queue -count=1`. Expected: PASS.
4079
4080- [ ] **Step 4: Write the run loop's failing tests, with a fake watcher**
4081
4082`internal/hostfiles/run_test.go`:
4083
4084```go
4085//go:build unix
4086
4087package hostfiles
4088
4089import (
4090 "context"
4091 "errors"
4092 "os"
4093 "path/filepath"
4094 "sync"
4095 "testing"
4096 "time"
4097
4098 "github.com/carlosframework/platform/internal/release"
4099)
4100
4101type fakeWatcher struct {
4102 ch chan event
4103 mu sync.Mutex
4104 degraded bool
4105 repairs int
4106 repairErr error // when nil, Repair succeeds and clears degraded
4107}
4108
4109func newFake() *fakeWatcher { return &fakeWatcher{ch: make(chan event, 64)} }
4110
4111func (f *fakeWatcher) Events() <-chan event { return f.ch }
4112func (f *fakeWatcher) Degraded() bool { f.mu.Lock(); defer f.mu.Unlock(); return f.degraded }
4113func (f *fakeWatcher) Close() error { return nil }
4114func (f *fakeWatcher) Repair() error {
4115 f.mu.Lock()
4116 defer f.mu.Unlock()
4117 f.repairs++
4118 if f.repairErr == nil {
4119 f.degraded = false
4120 }
4121 return f.repairErr
4122}
4123func (f *fakeWatcher) setDegraded(v bool) { f.mu.Lock(); f.degraded = v; f.mu.Unlock() }
4124
4125// eventsOnly publishes on events and nothing else: no repair scan and no
4126// polling can satisfy a test that uses it. polled is the opposite.
4127var (
4128 eventsOnly = Options{Deadline: 40 * time.Millisecond, Scan: time.Hour, Poll: time.Hour}
4129 polled = Options{Deadline: 40 * time.Millisecond, Scan: time.Hour, Poll: 40 * time.Millisecond}
4130)
4131
4132// startRun runs the loop and returns once its first full scan has
4133// finished, so nothing a test does afterwards can be found by that scan.
4134func startRun(t *testing.T, p *Publisher, dir string, w watcher, o Options) (stop func() error) {
4135 t.Helper()
4136 first := make(chan struct{})
4137 var once sync.Once
4138 o.OnStats = func(SyncStats) { once.Do(func() { close(first) }) }
4139 ctx, cancel := context.WithCancel(context.Background())
4140 done := make(chan error, 1)
4141 go func() { done <- run(ctx, p, dir, o, w) }()
4142 select {
4143 case <-first:
4144 case err := <-done:
4145 t.Fatalf("run ended before its first scan: %v", err)
4146 case <-time.After(2 * time.Second):
4147 t.Fatal("run never finished its first scan")
4148 }
4149 return func() error { cancel(); return <-done }
4150}
4151
4152// startRunCounting is startRun with a callback on every full scan.
4153func startRunCounting(t *testing.T, p *Publisher, dir string, w watcher, o Options, onScan func()) (stop func() error) {
4154 t.Helper()
4155 first := make(chan struct{})
4156 var once sync.Once
4157 o.OnStats = func(SyncStats) { onScan(); once.Do(func() { close(first) }) }
4158 ctx, cancel := context.WithCancel(context.Background())
4159 done := make(chan error, 1)
4160 go func() { done <- run(ctx, p, dir, o, w) }()
4161 select {
4162 case <-first:
4163 case <-time.After(2 * time.Second):
4164 t.Fatal("run never finished its first scan")
4165 }
4166 return func() error { cancel(); return <-done }
4167}
4168
4169// eventually polls until ok returns true or two seconds pass.
4170func eventually(t *testing.T, what string, ok func() bool) {
4171 t.Helper()
4172 deadline := time.Now().Add(2 * time.Second)
4173 for time.Now().Before(deadline) {
4174 if ok() {
4175 return
4176 }
4177 time.Sleep(10 * time.Millisecond)
4178 }
4179 t.Fatalf("timed out waiting for: %s", what)
4180}
4181
4182func manifestHas(s release.Store, path, sha string) bool {
4183 m, found, err := Load(context.Background(), s, pfx)
4184 if err != nil || !found {
4185 return false
4186 }
4187 e, ok := m.Entries[path]
4188 return ok && !e.Deleted && e.SHA256 == sha
4189}
4190
4191func TestRunPublishesATouchedFileAfterTheDeadline(t *testing.T) {
4192 s, dir, w := release.NewDirStore(t.TempDir()), t.TempDir(), newFake()
4193 p := open(t, s)
4194 stop := startRun(t, p, dir, w, eventsOnly)
4195 defer stop()
4196 write(t, dir, "instance.key", "k1")
4197 time.Sleep(3 * eventsOnly.Deadline)
4198 if manifestHas(s, "instance.key", sum([]byte("k1"))) {
4199 t.Fatal("published with no event: something other than the event path found it")
4200 }
4201 touched := time.Now()
4202 w.ch <- event{rel: "instance.key"}
4203 eventually(t, "instance.key published", func() bool { return manifestHas(s, "instance.key", sum([]byte("k1"))) })
4204 if took := time.Since(touched); took < eventsOnly.Deadline/2 {
4205 t.Fatalf("published %s after the event; the deadline is %s", took, eventsOnly.Deadline)
4206 }
4207}
4208
4209func TestRunPublishesADeleteAsATombstone(t *testing.T) {
4210 s, dir, w := release.NewDirStore(t.TempDir()), t.TempDir(), newFake()
4211 p := open(t, s)
4212 write(t, dir, "a", "a")
4213 stop := startRun(t, p, dir, w, eventsOnly) // the first scan publishes "a"
4214 defer stop()
4215 eventually(t, "a published", func() bool { return manifestHas(s, "a", sum([]byte("a"))) })
4216 if err := os.Remove(filepath.Join(dir, "a")); err != nil {
4217 t.Fatal(err)
4218 }
4219 w.ch <- event{rel: "a"}
4220 eventually(t, "a tombstoned", func() bool {
4221 m, _, _ := Load(context.Background(), s, pfx)
4222 return m.Entries["a"].Deleted
4223 })
4224}
4225
4226func TestRunRescansOnOverflow(t *testing.T) {
4227 s, dir, w := release.NewDirStore(t.TempDir()), t.TempDir(), newFake()
4228 p := open(t, s)
4229 stop := startRun(t, p, dir, w, eventsOnly)
4230 defer stop()
4231 write(t, dir, "missed.txt", "m") // no event for it: the queue overflowed
4232 time.Sleep(3 * eventsOnly.Deadline)
4233 if manifestHas(s, "missed.txt", sum([]byte("m"))) {
4234 t.Fatal("found before the rescan was asked for")
4235 }
4236 w.ch <- event{rescan: true}
4237 eventually(t, "missed.txt found by the rescan", func() bool { return manifestHas(s, "missed.txt", sum([]byte("m"))) })
4238}
4239
4240// A2-5 residual: with a watch missing, changes are found by polling, not
4241// by waiting for the ten-minute scan.
4242func TestRunPollsWhileTheWatcherIsDegradedAndKeepsTryingToRepair(t *testing.T) {
4243 s, dir, w := release.NewDirStore(t.TempDir()), t.TempDir(), newFake()
4244 w.setDegraded(true)
4245 w.repairErr = errors.New("watch limit reached")
4246 p := open(t, s)
4247 stop := startRun(t, p, dir, w, polled)
4248 defer stop()
4249 write(t, dir, "quiet.txt", "q") // no event at all
4250 eventually(t, "quiet.txt found by polling", func() bool { return manifestHas(s, "quiet.txt", sum([]byte("q"))) })
4251 eventually(t, "more than one repair attempt", func() bool { w.mu.Lock(); defer w.mu.Unlock(); return w.repairs > 1 })
4252}
4253
4254// The other half: once repair succeeds, polling stops. The loop runs
4255// with a short polling period, so if it kept polling the scan count would
4256// keep climbing; it must scan once more for what changed meanwhile and
4257// then go quiet.
4258func TestRunScansOnceAfterARepairThenStopsPolling(t *testing.T) {
4259 s, dir, w := release.NewDirStore(t.TempDir()), t.TempDir(), newFake()
4260 w.setDegraded(true)
4261 w.repairErr = errors.New("not yet")
4262 p := open(t, s)
4263 scans := 0
4264 var mu sync.Mutex
4265 count := func() int { mu.Lock(); defer mu.Unlock(); return scans }
4266 stop := startRunCounting(t, p, dir, w, polled, func() { mu.Lock(); scans++; mu.Unlock() })
4267 defer stop()
4268
4269 // While repair fails, it polls: the count climbs.
4270 n := count()
4271 eventually(t, "polling while degraded", func() bool { return count() >= n+3 })
4272
4273 write(t, dir, "during.txt", "d")
4274 w.mu.Lock()
4275 w.repairErr = nil // the next Repair succeeds and clears degraded
4276 w.mu.Unlock()
4277 eventually(t, "the repair", func() bool { return !w.Degraded() })
4278 eventually(t, "during.txt found by the post-repair scan", func() bool { return manifestHas(s, "during.txt", sum([]byte("d"))) })
4279
4280 time.Sleep(2 * polled.Poll) // let any scan already under way finish
4281 n = count()
4282 time.Sleep(8 * polled.Poll)
4283 if got := count(); got != n {
4284 t.Fatalf("%d more scans in eight polling periods after the repair; polling should have stopped", got-n)
4285 }
4286}
4287
4288// A watcher whose reader dies closes its channel. The loop must fall back
4289// to polling, not spin and not stop publishing.
4290func TestRunPollsAfterTheWatcherStops(t *testing.T) {
4291 s, dir, w := release.NewDirStore(t.TempDir()), t.TempDir(), newFake()
4292 p := open(t, s)
4293 stop := startRun(t, p, dir, w, polled)
4294 defer stop()
4295 close(w.ch)
4296 write(t, dir, "after.txt", "a")
4297 eventually(t, "after.txt found by polling", func() bool { return manifestHas(s, "after.txt", sum([]byte("a"))) })
4298}
4299
4300func TestRunWithNoWatcherPolls(t *testing.T) {
4301 s, dir := release.NewDirStore(t.TempDir()), t.TempDir()
4302 p := open(t, s)
4303 stop := startRun(t, p, dir, nil, polled)
4304 defer stop()
4305 write(t, dir, "x", "x")
4306 eventually(t, "x found by polling", func() bool { return manifestHas(s, "x", sum([]byte("x"))) })
4307}
4308
4309func TestRunStopsWhenSuperseded(t *testing.T) {
4310 s, dir, w := release.NewDirStore(t.TempDir()), t.TempDir(), newFake()
4311 p, other := open(t, s), open(t, s)
4312 ctx, cancel := context.WithCancel(context.Background())
4313 defer cancel()
4314 done := make(chan error, 1)
4315 go func() { done <- run(ctx, p, dir, eventsOnly, w) }()
4316 stage(t, other, "theirs", "t")
4317 commit(t, other)
4318 write(t, dir, "mine", "m")
4319 w.ch <- event{rel: "mine"}
4320 select {
4321 case err := <-done:
4322 if !errors.Is(err, ErrSuperseded) {
4323 t.Fatalf("err = %v, want ErrSuperseded", err)
4324 }
4325 case <-time.After(2 * time.Second):
4326 t.Fatal("run did not stop after another writer published")
4327 }
4328}
4329
4330func TestRunIgnoresAnEventForAPathThatEscapes(t *testing.T) {
4331 s, dir, w := release.NewDirStore(t.TempDir()), t.TempDir(), newFake()
4332 p := open(t, s)
4333 stop := startRun(t, p, dir, w, eventsOnly)
4334 w.ch <- event{rel: "../outside"}
4335 write(t, dir, "ok", "ok")
4336 w.ch <- event{rel: "ok"}
4337 eventually(t, "ok published", func() bool { return manifestHas(s, "ok", sum([]byte("ok"))) })
4338 if err := stop(); !errors.Is(err, context.Canceled) {
4339 t.Fatalf("run ended with %v; a bad path must be skipped, not fatal", err)
4340 }
4341}
4342```
4343
4344`internal/hostfiles/publish_unix_test.go`, for `publishOne`, the function that handles one queued path. These need Task 6's `swapAt` and `tree` helpers and this task's `manifestHas`, which is why they live here and not in Task 6:
4345
4346```go
4347//go:build unix
4348
4349package hostfiles
4350
4351import (
4352 "context"
4353 "os"
4354 "path/filepath"
4355 "testing"
4356
4357 "github.com/carlosframework/platform/internal/release"
4358)
4359
4360// A parent directory swapped for a symlink, to outside or to an excluded
4361// directory inside, must not let a queued path be read through it.
4362func TestPublishOneNeverReadsThroughASwappedAncestor(t *testing.T) {
4363 for name, target := range map[string]string{"a link to outside": "OUTSIDE", "a link to an excluded directory inside": "cache"} {
4364 t.Run(name, func(t *testing.T) {
4365 ctx, s, src, outside := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir(), t.TempDir()
4366 write(t, src, "sub/x", "ours")
4367 write(t, src, "cache/x", "excluded")
4368 if err := os.WriteFile(filepath.Join(outside, "x"), []byte("outside secret"), 0o600); err != nil {
4369 t.Fatal(err)
4370 }
4371 skip := func(rel string, isDir bool) bool { return isDir && rel == "cache" }
4372 p := open(t, s)
4373 if _, err := SyncDir(ctx, p, src, skip); err != nil {
4374 t.Fatal(err)
4375 }
4376 commit(t, p)
4377 if target == "OUTSIDE" {
4378 target = outside
4379 }
4380 swapAt(t, 1, func() { // before "sub" is opened
4381 if err := os.RemoveAll(filepath.Join(src, "sub")); err != nil {
4382 t.Error(err)
4383 }
4384 if err := os.Symlink(target, filepath.Join(src, "sub")); err != nil {
4385 t.Error(err)
4386 }
4387 })
4388 if _, err := publishOne(ctx, p, tree(t, src), "sub/x", skip); err != nil {
4389 t.Fatal(err)
4390 }
4391 commit(t, p)
4392 m, _, _ := Load(ctx, s, pfx)
4393 if e := m.Entries["sub/x"]; !e.Deleted {
4394 t.Fatalf("sub/x = %+v; the directory is gone, so the file is: want a tombstone, and no content read through the link", e)
4395 }
4396 })
4397 }
4398}
4399
4400// A queued path whose file exists but cannot be read is an error, and
4401// what is published for it stays.
4402func TestPublishOneKeepsAnUnreadableFilesPublishedEntry(t *testing.T) {
4403 if os.Getuid() == 0 {
4404 t.Skip("root can read anything")
4405 }
4406 ctx, s, src := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir()
4407 write(t, src, "locked", "v1")
4408 p := open(t, s)
4409 if _, err := SyncDir(ctx, p, src, nil); err != nil {
4410 t.Fatal(err)
4411 }
4412 commit(t, p)
4413 if err := os.Chmod(filepath.Join(src, "locked"), 0); err != nil {
4414 t.Fatal(err)
4415 }
4416 if _, err := publishOne(ctx, p, tree(t, src), "locked", nil); err == nil {
4417 t.Fatal("publishOne reported success for a file it could not read")
4418 }
4419 if p.Dirty() {
4420 t.Fatal("publishOne staged a change for a file it could not read")
4421 }
4422}
4423
4424// A regular file replaced by another regular file mid-look is read again
4425// at the next deadline, not tombstoned.
4426func TestPublishOneRetriesARegularFileReplacedMidLook(t *testing.T) {
4427 ctx, s, src := context.Background(), release.NewDirStore(t.TempDir()), t.TempDir()
4428 write(t, src, "f", "v1")
4429 p := open(t, s)
4430 if _, err := SyncDir(ctx, p, src, nil); err != nil {
4431 t.Fatal(err)
4432 }
4433 commit(t, p)
4434 swapAt(t, 1, func() {
4435 tmp := filepath.Join(src, "f.new")
4436 if err := os.WriteFile(tmp, []byte("v2"), 0o640); err != nil {
4437 t.Error(err)
4438 }
4439 if err := os.Rename(tmp, filepath.Join(src, "f")); err != nil {
4440 t.Error(err)
4441 }
4442 })
4443 again, err := publishOne(ctx, p, tree(t, src), "f", nil)
4444 if err != nil || !again || p.Dirty() {
4445 t.Fatalf("again = %v, err = %v, dirty = %v; want a retry and nothing staged", again, err, p.Dirty())
4446 }
4447 again, err = publishOne(ctx, p, tree(t, src), "f", nil)
4448 if err != nil || again {
4449 t.Fatalf("second look: again = %v, err = %v", again, err)
4450 }
4451 commit(t, p)
4452 if !manifestHas(s, "f", sum([]byte("v2"))) {
4453 t.Fatal("the replacement was not published")
4454 }
4455}
4456```
4457
4458- [ ] **Step 5: Run them to see them fail**
4459
4460Run: `go test ./internal/hostfiles/ -run 'Run|PublishOne' -count=1`
4461Expected: FAIL to build, `undefined: run`.
4462
4463- [ ] **Step 6: Write `run.go`**
4464
4465```go
4466package hostfiles
4467
4468import (
4469 "context"
4470 "errors"
4471 "fmt"
4472 "log"
4473 "path"
4474 "strings"
4475 "time"
4476)
4477
4478type Options struct {
4479 Deadline time.Duration // first unpublished modification to publish
4480 Scan time.Duration // repair scan while every directory is watched
4481 Poll time.Duration // full-scan period while watch coverage is missing
4482 Skip func(rel string, isDir bool) bool
4483 OnStats func(SyncStats) // called after each full scan; may be nil
4484}
4485
4486// DefaultOptions are region HA spec 1 §5.6's values.
4487func DefaultOptions() Options {
4488 return Options{Deadline: time.Second, Scan: 10 * time.Minute, Poll: time.Second}
4489}
4490
4491type event struct {
4492 rel string
4493 rescan bool
4494}
4495
4496// watcher reports changes under one directory. Its event channel closes
4497// when it can report nothing more.
4498type watcher interface {
4499 Events() <-chan event
4500 Degraded() bool // some directory may have no working watch
4501 Repair() error // restore full coverage, or say why not
4502 Close() error
4503}
4504
4505// Run publishes dir's changes until ctx ends (it returns ctx.Err()) or
4506// another writer supersedes the manifest (ErrSuperseded). Where there is
4507// no watcher, or the watcher fails, it polls.
4508func Run(ctx context.Context, p *Publisher, dir string, o Options) error {
4509 w, err := newWatcher(dir, o.Skip)
4510 if err != nil {
4511 log.Printf("hostfiles: no watcher for %s (%v): polling every %s", dir, err, o.Poll)
4512 return run(ctx, p, dir, o, nil)
4513 }
4514 defer w.Close()
4515 return run(ctx, p, dir, o, w)
4516}
4517
4518func run(ctx context.Context, p *Publisher, dir string, o Options, w watcher) error {
4519 root, err := openTree(dir)
4520 if err != nil {
4521 return fmt.Errorf("hostfiles: %s: %w", dir, err)
4522 }
4523 defer root.Close()
4524
4525 var events <-chan event
4526 if w != nil {
4527 events = w.Events()
4528 }
4529 q := newQueue(o.Deadline)
4530 tick := time.NewTicker(o.Deadline / 4)
4531 defer tick.Stop()
4532
4533 needScan := true // the first pass is a full scan: changes made while nothing watched
4534 var lastScan time.Time
4535 for {
4536 select {
4537 case <-ctx.Done():
4538 return ctx.Err()
4539 case ev, open := <-events:
4540 if !open {
4541 // The watcher has stopped for good. A nil channel never
4542 // fires, and with no watcher every pass below polls.
4543 log.Printf("hostfiles: watcher for %s stopped: polling every %s", dir, o.Poll)
4544 events, w = nil, nil
4545 continue
4546 }
4547 if ev.rescan {
4548 needScan = true
4549 } else {
4550 q.Touch(ev.rel, time.Now())
4551 }
4552 continue
4553 case <-tick.C:
4554 }
4555 now := time.Now()
4556 if w != nil && w.Degraded() {
4557 if err := w.Repair(); err != nil {
4558 log.Printf("hostfiles: %s: watch coverage still missing: %v", dir, err)
4559 } else {
4560 needScan = true // whatever changed while coverage was missing
4561 }
4562 }
4563 polling := w == nil || w.Degraded()
4564 switch {
4565 case polling && now.Sub(lastScan) >= o.Poll:
4566 needScan = true
4567 case !polling && now.Sub(lastScan) >= o.Scan:
4568 needScan = true
4569 }
4570 if needScan {
4571 st, err := syncRoot(ctx, p, root, o.Skip)
4572 if err != nil {
4573 if ctx.Err() != nil {
4574 return ctx.Err()
4575 }
4576 log.Printf("hostfiles: %v", err)
4577 } else {
4578 needScan, lastScan = false, now
4579 if o.OnStats != nil {
4580 o.OnStats(st)
4581 }
4582 }
4583 }
4584 for _, rel := range q.Due(now) {
4585 again, err := publishOne(ctx, p, root, rel, o.Skip)
4586 if err != nil {
4587 log.Printf("hostfiles: %s: %v", rel, err)
4588 q.Touch(rel, now) // try again at the next deadline
4589 continue
4590 }
4591 if again {
4592 q.Touch(rel, now)
4593 }
4594 }
4595 if p.Dirty() {
4596 if _, err := p.Commit(ctx); errors.Is(err, ErrSuperseded) {
4597 return err
4598 } else if err != nil {
4599 log.Printf("hostfiles: commit: %v", err) // staged changes stay; the next tick retries
4600 }
4601 }
4602 }
4603}
4604
4605// publishOne stages one path's current state. again is true when the
4606// caller should read the path again at the next deadline: the file changed
4607// while it was read (its image is published, flagged), or it was replaced
4608// mid-look and nothing was read.
4609//
4610// A tombstone is staged only on proof: the name is gone, or something
4611// that is not a regular file now stands there or above it. A file that
4612// exists but could not be read is an error, and its manifest entry stays.
4613func publishOne(ctx context.Context, p *Publisher, root *dirHandle, rel string, skip func(string, bool) bool) (again bool, err error) {
4614 clean, err := CleanPath(rel)
4615 if err != nil {
4616 return false, nil // a path the watcher should never have produced: skip it
4617 }
4618 if skipped(skip, clean, false) {
4619 return false, nil
4620 }
4621 parent, name := path.Split(clean)
4622 dir, gone, err := root.descend(strings.TrimSuffix(parent, "/"))
4623 if err != nil {
4624 return false, err
4625 }
4626 if gone {
4627 return false, p.StageDelete(clean)
4628 }
4629 if dir != root {
4630 defer dir.Close()
4631 }
4632 body, mode, state, err := readStable(dir, name)
4633 if err != nil {
4634 return false, err
4635 }
4636 switch state {
4637 case fileGone, fileNotRegular:
4638 // StageDelete is a no-op for a path the manifest never had, which
4639 // is what a directory's own event is.
4640 return false, p.StageDelete(clean)
4641 case fileUnreadable:
4642 return false, fmt.Errorf("%s exists but could not be read; keeping what is published", clean)
4643 case fileRetry:
4644 return true, nil
4645 case fileTooLarge:
4646 return false, p.StageSkipped(clean, "too-large")
4647 }
4648 return state == fileTorn, p.Stage(ctx, clean, body, mode, state == fileTorn)
4649}
4650```
4651
4652A path that turns out to be a directory arrives as `fileNotRegular` and `StageDelete` is a no-op for a path the manifest never had, so directory events need no special case.
4653
4654- [ ] **Step 7: Write the non-Linux stub**
4655
4656`internal/hostfiles/watch_other.go`:
4657
4658```go
4659//go:build !linux
4660
4661package hostfiles
4662
4663import "errors"
4664
4665// newWatcher has no implementation off Linux; Run polls instead.
4666func newWatcher(root string, skip func(string, bool) bool) (watcher, error) {
4667 return nil, errors.New("no file watcher on this OS")
4668}
4669```
4670
4671- [ ] **Step 8: Run the loop tests**
4672
4673Run: `go test ./internal/hostfiles/ -run 'Run|Queue' -count=1` (on Linux this needs Step 9's file to build; if so, do Step 9 first and run both).
4674Expected: PASS.
4675
4676- [ ] **Step 9: Write the Linux watcher and its test**
4677
4678`internal/hostfiles/watch_linux_test.go`:
4679
4680```go
4681//go:build linux
4682
4683package hostfiles
4684
4685import (
4686 "context"
4687 "os"
4688 "path/filepath"
4689 "sync/atomic"
4690 "testing"
4691 "time"
4692
4693 "golang.org/x/sys/unix"
4694
4695 "github.com/carlosframework/platform/internal/release"
4696)
4697
4698// realWatcher returns a working inotify watcher, or skips the test where
4699// the kernel will not give one (a shared machine at its per-user limit).
4700// CI must not skip: see the plan's Task 7, step 10.
4701func realWatcher(t *testing.T, root string, skip func(string, bool) bool) watcher {
4702 t.Helper()
4703 w, err := newWatcher(root, skip)
4704 if err != nil {
4705 if os.Getenv("CARLOS_REQUIRE_INOTIFY") != "" {
4706 t.Fatalf("inotify is required here: %v", err)
4707 }
4708 t.Skipf("no inotify instance available: %v", err)
4709 }
4710 t.Cleanup(func() { w.Close() })
4711 return w
4712}
4713
4714// startReal runs the loop on a real watcher with scans and polling off,
4715// and returns once the first full scan is done: everything after that is
4716// found by an inotify event or not at all.
4717func startReal(t *testing.T, s release.Store, dir string) {
4718 t.Helper()
4719 w := realWatcher(t, dir, nil)
4720 p := open(t, s)
4721 stop := startRun(t, p, dir, w, Options{Deadline: 50 * time.Millisecond, Scan: time.Hour, Poll: time.Hour})
4722 t.Cleanup(func() { stop() })
4723}
4724
4725func TestWatcherPublishesAFileStillHeldOpen(t *testing.T) {
4726 s, dir := release.NewDirStore(t.TempDir()), t.TempDir()
4727 startReal(t, s, dir)
4728 f, err := os.Create(filepath.Join(dir, "open.log"))
4729 if err != nil {
4730 t.Fatal(err)
4731 }
4732 defer f.Close() // deliberately not closed before the assertion
4733 if _, err := f.WriteString("line one\n"); err != nil {
4734 t.Fatal(err)
4735 }
4736 eventually(t, "open.log published while still open", func() bool {
4737 return manifestHas(s, "open.log", sum([]byte("line one\n")))
4738 })
4739 // The first publication could have come from the file's creation. A
4740 // second write through the same descriptor produces no create, no
4741 // close and no rename: only IN_MODIFY can announce it.
4742 if _, err := f.WriteString("line two\n"); err != nil {
4743 t.Fatal(err)
4744 }
4745 eventually(t, "the second write published, still open", func() bool {
4746 return manifestHas(s, "open.log", sum([]byte("line one\nline two\n")))
4747 })
4748}
4749
4750func TestWatcherSeesAFileInADirectoryCreatedAfterItStarted(t *testing.T) {
4751 s, dir := release.NewDirStore(t.TempDir()), t.TempDir()
4752 startReal(t, s, dir)
4753 write(t, dir, "new/deep/a.txt", "a")
4754 eventually(t, "new/deep/a.txt published", func() bool {
4755 return manifestHas(s, "new/deep/a.txt", sum([]byte("a")))
4756 })
4757 // And a later write inside the new directory is an event, not a scan.
4758 time.Sleep(150 * time.Millisecond)
4759 write(t, dir, "new/deep/b.txt", "b")
4760 eventually(t, "new/deep/b.txt published", func() bool {
4761 return manifestHas(s, "new/deep/b.txt", sum([]byte("b")))
4762 })
4763}
4764
4765func TestWatcherPublishesARenameAsDeleteAndCreate(t *testing.T) {
4766 s, dir := release.NewDirStore(t.TempDir()), t.TempDir()
4767 write(t, dir, "tmp", "v")
4768 startReal(t, s, dir)
4769 if err := os.Rename(filepath.Join(dir, "tmp"), filepath.Join(dir, "final")); err != nil {
4770 t.Fatal(err)
4771 }
4772 eventually(t, "final present and tmp tombstoned", func() bool {
4773 m, _, _ := Load(context.Background(), s, pfx)
4774 return m.Entries["tmp"].Deleted && m.Entries["final"].SHA256 == sum([]byte("v"))
4775 })
4776}
4777
4778func TestWatcherDoesNotWatchASkippedDirectory(t *testing.T) {
4779 root := t.TempDir()
4780 if err := os.MkdirAll(filepath.Join(root, "cache"), 0o755); err != nil {
4781 t.Fatal(err)
4782 }
4783 w := realWatcher(t, root, func(rel string, isDir bool) bool { return isDir && rel == "cache" })
4784 write(t, root, "cache/x", "x")
4785 select {
4786 case ev := <-w.Events():
4787 t.Fatalf("event from a skipped directory: %+v", ev)
4788 case <-time.After(150 * time.Millisecond):
4789 }
4790}
4791
4792// unwatched builds a watcher with one directory that has no watch and no
4793// event on its way: the state an overflow leaves when it swallows that
4794// directory's creation. The directory exists before the watcher starts
4795// and is excluded while the watcher is built, so the kernel never queues
4796// an event that could install the watch later behind the test's back.
4797func unwatched(t *testing.T) (w *inotifyWatcher, root string) {
4798 t.Helper()
4799 root = t.TempDir()
4800 if err := os.MkdirAll(filepath.Join(root, "lost"), 0o755); err != nil {
4801 t.Fatal(err)
4802 }
4803 var building atomic.Bool
4804 building.Store(true)
4805 w = realWatcher(t, root, func(rel string, isDir bool) bool {
4806 return building.Load() && isDir && rel == "lost"
4807 }).(*inotifyWatcher)
4808 building.Store(false)
4809 return w, root
4810}
4811
4812// noEventFor fails if an event for rel arrives within 200ms.
4813func noEventFor(t *testing.T, w *inotifyWatcher, rel string) {
4814 t.Helper()
4815 deadline := time.After(200 * time.Millisecond)
4816 for {
4817 select {
4818 case ev := <-w.Events():
4819 if ev.rel == rel {
4820 t.Fatalf("an event for %s, which has no watch", rel)
4821 }
4822 case <-deadline:
4823 return
4824 }
4825 }
4826}
4827
4828func eventFor(t *testing.T, w *inotifyWatcher, rel string) {
4829 t.Helper()
4830 deadline := time.After(2 * time.Second)
4831 for {
4832 select {
4833 case ev := <-w.Events():
4834 if ev.rel == rel {
4835 return
4836 }
4837 case <-deadline:
4838 t.Fatalf("no event for %s", rel)
4839 }
4840 }
4841}
4842
4843// Plan round 1 finding 8: a kernel overflow may have swallowed a
4844// directory's creation, so coverage is uncertain until Repair has walked
4845// the tree again. Only Repair can restore it here: remove the walk from
4846// Repair and the last assertion fails.
4847func TestWatcherOverflowMarksCoverageUncertainUntilRepaired(t *testing.T) {
4848 w, root := unwatched(t)
4849 if w.Degraded() {
4850 t.Fatal("degraded before anything went wrong")
4851 }
4852 write(t, root, "lost/before.txt", "b")
4853 noEventFor(t, w, "lost/before.txt") // proves the directory really is unwatched
4854
4855 w.handle(0, unix.IN_Q_OVERFLOW, "")
4856 if !w.Degraded() {
4857 t.Fatal("an overflow left coverage marked complete")
4858 }
4859 if err := w.Repair(); err != nil {
4860 t.Fatal(err)
4861 }
4862 if w.Degraded() {
4863 t.Fatal("still degraded after a successful repair")
4864 }
4865 write(t, root, "lost/after.txt", "a")
4866 eventFor(t, w, "lost/after.txt")
4867}
4868
4869// Plan round 2 finding 5: a failure that arrives while Repair is walking
4870// may concern a directory the walk has already passed. That Repair must
4871// not clear it.
4872func TestWatcherRepairDoesNotEraseAFailureThatArrivedDuringItsWalk(t *testing.T) {
4873 w, _ := unwatched(t)
4874 w.handle(0, unix.IN_Q_OVERFLOW, "")
4875 orig := afterRepairWalk
4876 t.Cleanup(func() { afterRepairWalk = orig })
4877 afterRepairWalk = func() {
4878 afterRepairWalk = func() {}
4879 w.handle(0, unix.IN_Q_OVERFLOW, "") // a second overflow, after the walk
4880 }
4881 if err := w.Repair(); err != nil {
4882 t.Fatal(err)
4883 }
4884 if !w.Degraded() {
4885 t.Fatal("the repair cleared a failure newer than its walk")
4886 }
4887 if err := w.Repair(); err != nil {
4888 t.Fatal(err)
4889 }
4890 if w.Degraded() {
4891 t.Fatal("a repair with nothing new during its walk left the watcher degraded")
4892 }
4893}
4894
4895func TestAStoppedWatcherCannotBeRepairedAndClosesItsChannel(t *testing.T) {
4896 w := realWatcher(t, t.TempDir(), nil).(*inotifyWatcher)
4897 w.Close()
4898 select {
4899 case _, open := <-w.Events():
4900 if open {
4901 t.Fatal("an event after Close")
4902 }
4903 case <-time.After(2 * time.Second):
4904 t.Fatal("the channel did not close")
4905 }
4906 if err := w.Repair(); err == nil {
4907 t.Fatal("a stopped watcher reported a successful repair")
4908 }
4909 if !w.Degraded() {
4910 t.Fatal("a stopped watcher is not degraded")
4911 }
4912}
4913```
4914
4915`internal/hostfiles/watch_linux.go`:
4916
4917```go
4918//go:build linux
4919
4920package hostfiles
4921
4922import (
4923 "errors"
4924 "fmt"
4925 "io/fs"
4926 "os"
4927 "path/filepath"
4928 "sync"
4929 "sync/atomic"
4930 "unsafe"
4931
4932 "golang.org/x/sys/unix"
4933)
4934
4935const watchMask = unix.IN_MODIFY | unix.IN_CLOSE_WRITE | unix.IN_MOVED_FROM | unix.IN_MOVED_TO |
4936 unix.IN_CREATE | unix.IN_DELETE | unix.IN_ATTRIB
4937
4938type inotifyWatcher struct {
4939 root string
4940 skip func(string, bool) bool
4941 f *os.File // the inotify descriptor, non-blocking, so Close unblocks Read
4942 ch chan event
4943
4944 mu sync.Mutex
4945 wds map[int32]string // watch descriptor -> directory, relative to root ("" is root)
4946
4947 // Coverage is tracked by counting failures, not with a flag. failures
4948 // goes up whenever a directory may have been left unwatched; repaired
4949 // is the count Repair had seen when it started the walk that
4950 // succeeded. A failure that arrives while Repair is walking is
4951 // therefore never erased by that Repair: the two differ again.
4952 failures atomic.Int64
4953 repaired atomic.Int64
4954 dead atomic.Bool // the reader has stopped; no event will ever arrive
4955}
4956
4957// afterRepairWalk runs between Repair's walk and its bookkeeping. A test seam.
4958var afterRepairWalk = func() {}
4959
4960func newWatcher(root string, skip func(string, bool) bool) (watcher, error) {
4961 fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
4962 if err != nil {
4963 return nil, fmt.Errorf("inotify init: %w", err)
4964 }
4965 w := &inotifyWatcher{root: root, skip: skip, f: os.NewFile(uintptr(fd), "inotify"),
4966 ch: make(chan event, 4096), wds: map[int32]string{}}
4967 if err := w.addTree(""); err != nil {
4968 w.failures.Add(1) // partial coverage: Run polls and calls Repair
4969 }
4970 go w.loop()
4971 return w, nil
4972}
4973
4974func (w *inotifyWatcher) Events() <-chan event { return w.ch }
4975func (w *inotifyWatcher) Degraded() bool {
4976 return w.dead.Load() || w.failures.Load() != w.repaired.Load()
4977}
4978func (w *inotifyWatcher) Close() error { return w.f.Close() }
4979
4980// Repair re-adds a watch on every directory. InotifyAddWatch on a path
4981// already watched returns the same descriptor, so this is safe to repeat.
4982// A watcher whose reader has stopped cannot be repaired.
4983func (w *inotifyWatcher) Repair() error {
4984 if w.dead.Load() {
4985 return errors.New("the inotify reader has stopped")
4986 }
4987 seen := w.failures.Load()
4988 if err := w.addTree(""); err != nil {
4989 return err
4990 }
4991 afterRepairWalk()
4992 // Only the failures this walk could have covered are cleared. One that
4993 // arrived during the walk may concern a directory the walk had already
4994 // passed, so it leaves the watcher degraded and Repair runs again.
4995 w.repaired.Store(seen)
4996 return nil
4997}
4998
4999// addTree watches rel and every directory under it. It keeps going after a
5000// failure, so one unreadable directory does not cost the rest their
5001// watches, and returns the first error.
5002func (w *inotifyWatcher) addTree(rel string) error {
5003 var first error
5004 start := filepath.Join(w.root, filepath.FromSlash(rel))
5005 err := filepath.WalkDir(start, func(full string, d fs.DirEntry, err error) error {
5006 if err != nil {
5007 if first == nil {
5008 first = err
5009 }
5010 return nil
5011 }
5012 if !d.IsDir() {
5013 return nil // WalkDir does not follow symlinks, so a linked directory is never watched
5014 }
5015 r, rerr := filepath.Rel(w.root, full)
5016 if rerr != nil {
5017 return nil
5018 }
5019 r = filepath.ToSlash(r)
5020 if r == "." {
5021 r = ""
5022 }
5023 if r != "" && w.skip != nil && w.skip(r, true) {
5024 return filepath.SkipDir
5025 }
5026 wd, aerr := unix.InotifyAddWatch(int(w.f.Fd()), full, watchMask|unix.IN_DONT_FOLLOW|unix.IN_ONLYDIR)
5027 if aerr != nil {
5028 if first == nil {
5029 first = fmt.Errorf("watch %s: %w", full, aerr)
5030 }
5031 return nil
5032 }
5033 w.mu.Lock()
5034 w.wds[int32(wd)] = r
5035 w.mu.Unlock()
5036 return nil
5037 })
5038 if err != nil && first == nil {
5039 first = err
5040 }
5041 return first
5042}
5043
5044func (w *inotifyWatcher) send(ev event) {
5045 select {
5046 case w.ch <- ev:
5047 default:
5048 // Our own buffer is full: the same remedy as a kernel overflow.
5049 // A path was dropped, so only a scan finds it now; and it may
5050 // have been a new directory, so coverage is uncertain too.
5051 w.failures.Add(1)
5052 select {
5053 case <-w.ch:
5054 default:
5055 }
5056 select {
5057 case w.ch <- event{rescan: true}:
5058 default:
5059 }
5060 }
5061}
5062
5063func (w *inotifyWatcher) loop() {
5064 defer close(w.ch)
5065 buf := make([]byte, 64<<10)
5066 for {
5067 n, err := w.f.Read(buf)
5068 if err != nil {
5069 // Closed by us, or broken. Either way nothing more will be
5070 // read: the closed channel tells Run to poll from here on.
5071 w.dead.Store(true)
5072 return
5073 }
5074 for off := 0; off+unix.SizeofInotifyEvent <= n; {
5075 raw := (*unix.InotifyEvent)(unsafe.Pointer(&buf[off]))
5076 nameLen := int(raw.Len)
5077 name := ""
5078 if nameLen > 0 {
5079 b := buf[off+unix.SizeofInotifyEvent : off+unix.SizeofInotifyEvent+nameLen]
5080 for i, c := range b {
5081 if c == 0 {
5082 b = b[:i]
5083 break
5084 }
5085 }
5086 name = string(b)
5087 }
5088 off += unix.SizeofInotifyEvent + nameLen
5089 w.handle(raw.Wd, raw.Mask, name)
5090 }
5091 }
5092}
5093
5094func (w *inotifyWatcher) handle(wd int32, mask uint32, name string) {
5095 if mask&unix.IN_Q_OVERFLOW != 0 {
5096 // Events were dropped, and one of them may have been a new
5097 // directory, which is then unwatched. Coverage is uncertain until
5098 // Repair has walked the tree again.
5099 w.failures.Add(1)
5100 w.send(event{rescan: true})
5101 return
5102 }
5103 w.mu.Lock()
5104 dir, known := w.wds[wd]
5105 if mask&unix.IN_IGNORED != 0 {
5106 delete(w.wds, wd)
5107 }
5108 w.mu.Unlock()
5109 if !known || name == "" {
5110 return
5111 }
5112 rel := name
5113 if dir != "" {
5114 rel = dir + "/" + name
5115 }
5116 if mask&unix.IN_ISDIR != 0 {
5117 if mask&(unix.IN_CREATE|unix.IN_MOVED_TO) != 0 {
5118 if w.skip != nil && w.skip(rel, true) {
5119 return
5120 }
5121 if err := w.addTree(rel); err != nil {
5122 w.failures.Add(1)
5123 }
5124 }
5125 // Files may have appeared in the new directory before its watch
5126 // did, and a directory moved away takes its files with it: only a
5127 // scan finds either.
5128 w.send(event{rescan: true})
5129 return
5130 }
5131 if w.skip != nil && w.skip(rel, false) {
5132 return
5133 }
5134 w.send(event{rel: rel})
5135}
5136```
5137
5138- [ ] **Step 10: Run everything, and the cross-builds**
5139
5140Run: `gofmt -l internal/hostfiles; go vet ./internal/hostfiles/ && go test ./internal/hostfiles/ -count=1 -race && GOOS=darwin go build ./internal/hostfiles/ && GOOS=windows go build ./internal/hostfiles/`
5141Expected: no gofmt output, PASS, both cross-builds succeed.
5142
5143**The inotify tests skip where the kernel refuses an inotify instance**, and that hides them. Check they ran: `go test ./internal/hostfiles/ -run Watcher -count=1 -v | grep -c SKIP` must print `0`. On the shared development machine it will not: `fs.inotify.max_user_instances` is 128 and the many agent sessions there use them all (this is how the plan's own dry run went: every test here passed except the real-inotify ones, which skipped). So run them once somewhere with a free instance, with skipping turned off, before this task is called done:
5144
5145 CARLOS_REQUIRE_INOTIFY=1 go test ./internal/hostfiles/ -run Watcher -count=1 -race -v
5146
5147Expected: seven tests `PASS`, none `SKIP` (every real-inotify test has `Watcher` in its name; `grep -c 'realWatcher\|unwatched(t)' internal/hostfiles/watch_linux_test.go` shows where they get their watcher). The dev box is the place; ask Paul for the session. Until that run is green, say in the task's report that `watch_linux.go` is unverified against a real kernel. Do not raise the machine's inotify limit yourself: that is Paul's call.
5148
5149If `TestWatcherPublishesAFileStillHeldOpen` is flaky under `-race`, do not raise `eventually`'s two-second bound past five seconds: look for a lost event first.
5150
5151- [ ] **Step 11: Commit**
5152
5153```bash
5154git add internal/hostfiles
5155git commit -m "hostfiles: a changed file is published a second after its first change, open or not
5156
5157Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
5158```
5159
5160---
5161
5162### Task 8: Wake state travels with the files (`registry.ReplaceTimers`, `hostfiles/wake.go`)
5163
5164**Files:**
5165- Modify: `internal/registry/timers.go`
5166- Create: `internal/hostfiles/wake.go`
5167- Test: `internal/registry/timers_test.go`, `internal/hostfiles/wake_test.go`
5168
5169**Interfaces:**
5170- Consumes: `registry.Timer` (its `Registration` field is `json:"-"`), `(*Registry).TimersFor(host) ([]Timer, error)`, `(*Registry).Get(host) (*Route, error)`, `SetNextDue(host string, due int64) error`, `SetReplication(host, class string) error`, `registry.ValidReplication`, `Publisher.StagePlatform`, `ReadPlatform`.
5171- Produces:
5172 ```go
5173 // registry
5174 func (r *Registry) ReplaceTimers(host string, rows []Timer) error
5175 // hostfiles
5176 const WakeItem = "wake.json"
5177 type WakeTimer struct {
5178 Name, Kind, Spec, Path string
5179 NextAt, LastAt int64
5180 LastStatus string
5181 LastMs int64
5182 Attempts int
5183 CreatedAt, DueAt int64
5184 Registration string
5185 }
5186 type WakeState struct {
5187 V int `json:"v"`
5188 Host string `json:"host"`
5189 Timers []WakeTimer `json:"timers"`
5190 NextDue int64 `json:"next_due"`
5191 Replication string `json:"replication"`
5192 }
5193 func ExportWake(reg *registry.Registry, host string) (WakeState, error)
5194 func ImportWake(reg *registry.Registry, host string, ws WakeState) error
5195 func StageWake(ctx context.Context, p *Publisher, ws WakeState) error
5196 func LoadWake(ctx context.Context, s release.Store, prefix string, m Manifest) (WakeState, bool, error)
5197 ```
5198
5199Spec §5.7: the wake state is the host's timer rows with their bookkeeping, the route's `NextDue`, and its `Replication` class. Completed once-timers (`kind == "once"` with `next_at == 0`) are history and are left out, as the moves spec's digest leaves them out, so the runner's daily prune never changes what is exported. An occurrence that fell due while no box ran the host fires once after import, because `DueTimers` selects by the imported `next_at`.
5200
5201- [ ] **Step 1: Write the failing registry test**
5202
5203Append to `internal/registry/timers_test.go`:
5204
5205```go
5206func TestReplaceTimersSwapsAHostsRowsExactly(t *testing.T) {
5207 r := openTimersReg(t)
5208 for _, tm := range []Timer{
5209 {Host: "a.test", Name: "old", Kind: "schedule", Spec: "@every 1m", Path: "/t", NextAt: 100, CreatedAt: 1},
5210 {Host: "b.test", Name: "other", Kind: "schedule", Spec: "@every 1m", Path: "/t", NextAt: 100, CreatedAt: 1},
5211 } {
5212 if err := r.UpsertTimer(tm); err != nil {
5213 t.Fatal(err)
5214 }
5215 }
5216 rows := []Timer{
5217 {Host: "a.test", Name: "nightly", Kind: "schedule", Spec: "0 3 * * *", Path: "/n", NextAt: 5000, LastAt: 4000,
5218 LastStatus: "ok", LastMs: 12, Attempts: 2, CreatedAt: 10, DueAt: 4900, Registration: "reg-nightly"},
5219 {Host: "a.test", Name: "once-1", Kind: "once", Spec: "2026-10-06T00:00:00Z", Path: "/o", NextAt: 6000, CreatedAt: 11, DueAt: 6000, Registration: "reg-once"},
5220 }
5221 if err := r.ReplaceTimers("a.test", rows); err != nil {
5222 t.Fatal(err)
5223 }
5224 got, err := r.TimersFor("a.test")
5225 if err != nil {
5226 t.Fatal(err)
5227 }
5228 if len(got) != 2 {
5229 t.Fatalf("a.test has %d timers, want 2: %+v", len(got), got)
5230 }
5231 n := got[0] // ordered by name: nightly, once-1
5232 if n.Name != "nightly" || n.NextAt != 5000 || n.LastAt != 4000 || n.LastStatus != "ok" || n.LastMs != 12 ||
5233 n.Attempts != 2 || n.CreatedAt != 10 || n.DueAt != 4900 || n.Registration != "reg-nightly" {
5234 t.Fatalf("bookkeeping not preserved: %+v", n)
5235 }
5236 if other, _ := r.TimersFor("b.test"); len(other) != 1 {
5237 t.Fatalf("b.test has %d timers, want 1: another host's rows were touched", len(other))
5238 }
5239}
5240
5241func TestReplaceTimersIsAllOrNothing(t *testing.T) {
5242 r := openTimersReg(t)
5243 if err := r.UpsertTimer(Timer{Host: "a.test", Name: "keep", Kind: "schedule", Spec: "@every 1m", Path: "/t", NextAt: 100, CreatedAt: 1}); err != nil {
5244 t.Fatal(err)
5245 }
5246 // Two rows with one name: the delete and the first insert succeed, the
5247 // second insert breaks the primary key. Without a transaction the host
5248 // would be left with "dup" and without "keep".
5249 dup := []Timer{
5250 {Host: "a.test", Name: "dup", Kind: "schedule", Spec: "@every 1m", Path: "/t", NextAt: 1, CreatedAt: 1},
5251 {Host: "a.test", Name: "dup", Kind: "schedule", Spec: "@every 2m", Path: "/t", NextAt: 1, CreatedAt: 1},
5252 }
5253 if err := r.ReplaceTimers("a.test", dup); err == nil {
5254 t.Fatal("two rows with one name were accepted")
5255 }
5256 got, _ := r.TimersFor("a.test")
5257 if len(got) != 1 || got[0].Name != "keep" {
5258 t.Fatalf("a failed replace changed the rows: %+v", got)
5259 }
5260}
5261
5262func TestReplaceTimersRefusesARowForAnotherHost(t *testing.T) {
5263 r := openTimersReg(t)
5264 rows := []Timer{{Host: "elsewhere.test", Name: "y", Kind: "schedule", Spec: "@every 1m", Path: "/t", NextAt: 1, CreatedAt: 1}}
5265 if err := r.ReplaceTimers("a.test", rows); err == nil {
5266 t.Fatal("a row for another host was accepted")
5267 }
5268}
5269
5270func TestReplaceTimersWithNoRowsClearsTheHost(t *testing.T) {
5271 r := openTimersReg(t)
5272 if err := r.UpsertTimer(Timer{Host: "a.test", Name: "gone", Kind: "schedule", Spec: "@every 1m", Path: "/t", NextAt: 100, CreatedAt: 1}); err != nil {
5273 t.Fatal(err)
5274 }
5275 if err := r.ReplaceTimers("a.test", nil); err != nil {
5276 t.Fatal(err)
5277 }
5278 if got, _ := r.TimersFor("a.test"); len(got) != 0 {
5279 t.Fatalf("rows left: %+v", got)
5280 }
5281}
5282```
5283
5284- [ ] **Step 2: Run it to see it fail**
5285
5286Run: `go test ./internal/registry/ -run ReplaceTimers -count=1`
5287Expected: FAIL to build, `r.ReplaceTimers undefined`.
5288
5289- [ ] **Step 3: Add `ReplaceTimers` to `internal/registry/timers.go`**
5290
5291Add after `UpsertTimer`:
5292
5293```go
5294// ReplaceTimers makes host's timer rows exactly rows, in one transaction,
5295// keeping every column as given: next_at, due_at, attempts and
5296// registration included. It is how an installation imports the wake state
5297// it was handed (region HA spec 1 §5.7, moves spec §9.4).
5298//
5299// UpsertTimer is the wrong tool for that: it mints a fresh registration
5300// and resets the runner's bookkeeping on a rearm.
5301func (r *Registry) ReplaceTimers(host string, rows []Timer) error {
5302 for _, t := range rows {
5303 if t.Host != host {
5304 return fmt.Errorf("registry: timer %q is for host %q, not %q", t.Name, t.Host, host)
5305 }
5306 }
5307 tx, err := r.sql.Begin()
5308 if err != nil {
5309 return err
5310 }
5311 defer tx.Rollback()
5312 if _, err := tx.Exec(`DELETE FROM timers WHERE host = ?`, host); err != nil {
5313 return err
5314 }
5315 for _, t := range rows {
5316 if _, err := tx.Exec(`INSERT INTO timers (`+timerCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
5317 t.Host, t.Name, t.Kind, t.Spec, t.Path, t.NextAt, t.LastAt, t.LastStatus, t.LastMs, t.Attempts, t.CreatedAt, t.DueAt, t.Registration); err != nil {
5318 return fmt.Errorf("registry: insert timer %q: %w", t.Name, err)
5319 }
5320 }
5321 return tx.Commit()
5322}
5323```
5324
5325If `timers.go` does not already import `fmt`, add it.
5326
5327Run: `go test ./internal/registry/ -count=1`. Expected: PASS.
5328
5329- [ ] **Step 4: Write the failing wake-state tests**
5330
5331`internal/hostfiles/wake_test.go`:
5332
5333```go
5334package hostfiles
5335
5336import (
5337 "context"
5338 "path/filepath"
5339 "testing"
5340
5341 "github.com/carlosframework/platform/internal/registry"
5342 "github.com/carlosframework/platform/internal/release"
5343)
5344
5345func wakeReg(t *testing.T) *registry.Registry {
5346 t.Helper()
5347 r, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
5348 if err != nil {
5349 t.Fatal(err)
5350 }
5351 t.Cleanup(func() { r.Close() })
5352 return r
5353}
5354
5355func seedHost(t *testing.T, r *registry.Registry, host string) {
5356 t.Helper()
5357 if err := r.Upsert(registry.Route{Host: host, Kind: "service", Addr: "10.0.0.1:3000", Status: "ready", Hibernate: true}); err != nil {
5358 t.Fatal(err)
5359 }
5360}
5361
5362func TestWakeStateRoundTripsThroughTheManifest(t *testing.T) {
5363 ctx, s := context.Background(), release.NewDirStore(t.TempDir())
5364 src, dst := wakeReg(t), wakeReg(t)
5365 seedHost(t, src, "shop.test")
5366 seedHost(t, dst, "shop.test")
5367 if err := src.ReplaceTimers("shop.test", []registry.Timer{
5368 {Host: "shop.test", Name: "nightly", Kind: "schedule", Spec: "0 3 * * *", Path: "/n", NextAt: 5000, LastAt: 4000,
5369 LastStatus: "ok", Attempts: 1, CreatedAt: 10, DueAt: 4900, Registration: "reg-n"},
5370 {Host: "shop.test", Name: "remind", Kind: "once", Spec: "2026-10-06T00:00:00Z", Path: "/r", NextAt: 6000, CreatedAt: 11, DueAt: 6000, Registration: "reg-r"},
5371 {Host: "shop.test", Name: "done", Kind: "once", Spec: "2026-10-01T00:00:00Z", Path: "/d", NextAt: 0, LastAt: 3000, CreatedAt: 9, Registration: "reg-d"},
5372 }); err != nil {
5373 t.Fatal(err)
5374 }
5375 if err := src.SetNextDue("shop.test", 7777); err != nil {
5376 t.Fatal(err)
5377 }
5378 if err := src.SetReplication("shop.test", registry.ReplicationBatched); err != nil {
5379 t.Fatal(err)
5380 }
5381
5382 ws, err := ExportWake(src, "shop.test")
5383 if err != nil {
5384 t.Fatal(err)
5385 }
5386 if len(ws.Timers) != 2 {
5387 t.Fatalf("exported %d timers, want 2: a finished once-timer is history, not wake state", len(ws.Timers))
5388 }
5389 p := open(t, s)
5390 if err := StageWake(ctx, p, ws); err != nil {
5391 t.Fatal(err)
5392 }
5393 commit(t, p)
5394
5395 m, _, err := Load(ctx, s, pfx)
5396 if err != nil {
5397 t.Fatal(err)
5398 }
5399 got, found, err := LoadWake(ctx, s, pfx, m)
5400 if err != nil || !found {
5401 t.Fatalf("LoadWake found=%v err=%v", found, err)
5402 }
5403 if err := ImportWake(dst, "shop.test", got); err != nil {
5404 t.Fatal(err)
5405 }
5406 rows, _ := dst.TimersFor("shop.test")
5407 if len(rows) != 2 || rows[0].Name != "nightly" || rows[0].Registration != "reg-n" || rows[0].NextAt != 5000 || rows[0].Attempts != 1 {
5408 t.Fatalf("imported timers: %+v", rows)
5409 }
5410 rt, err := dst.Get("shop.test")
5411 if err != nil || rt == nil {
5412 t.Fatalf("route: %v %v", rt, err)
5413 }
5414 if rt.NextDue != 7777 || rt.Replication != registry.ReplicationBatched {
5415 t.Fatalf("NextDue=%d Replication=%q", rt.NextDue, rt.Replication)
5416 }
5417}
5418
5419func TestStageWakeOfUnchangedStateStagesNothing(t *testing.T) {
5420 ctx, s := context.Background(), release.NewDirStore(t.TempDir())
5421 p := open(t, s)
5422 ws := WakeState{V: 1, Host: "shop.test", NextDue: 5}
5423 if err := StageWake(ctx, p, ws); err != nil {
5424 t.Fatal(err)
5425 }
5426 commit(t, p)
5427 if err := StageWake(ctx, p, ws); err != nil {
5428 t.Fatal(err)
5429 }
5430 if p.Dirty() {
5431 t.Fatal("unchanged wake state was staged again")
5432 }
5433}
5434
5435func TestLoadWakeWithNoneReportsNotFound(t *testing.T) {
5436 ctx, s := context.Background(), release.NewDirStore(t.TempDir())
5437 m, _, _ := Load(ctx, s, pfx)
5438 if _, found, err := LoadWake(ctx, s, pfx, m); found || err != nil {
5439 t.Fatalf("found=%v err=%v", found, err)
5440 }
5441}
5442
5443// A manifest that names wake state whose object has gone is lost data,
5444// not a host without timers.
5445func TestLoadWakeWithItsObjectMissingIsAnError(t *testing.T) {
5446 ctx, s := context.Background(), release.NewDirStore(t.TempDir())
5447 p := open(t, s)
5448 if err := StageWake(ctx, p, WakeState{V: 1, Host: "shop.test", NextDue: 5}); err != nil {
5449 t.Fatal(err)
5450 }
5451 commit(t, p)
5452 m, _, err := Load(ctx, s, pfx)
5453 if err != nil {
5454 t.Fatal(err)
5455 }
5456 if err := s.Delete(ctx, pfx+m.Platform[WakeItem].Object); err != nil {
5457 t.Fatal(err)
5458 }
5459 if _, found, err := LoadWake(ctx, s, pfx, m); err == nil || found {
5460 t.Fatalf("found = %v, err = %v; a missing wake object must be an error", found, err)
5461 }
5462}
5463
5464func TestImportWakeRefusesAnotherHostsState(t *testing.T) {
5465 r := wakeReg(t)
5466 seedHost(t, r, "shop.test")
5467 if err := ImportWake(r, "shop.test", WakeState{V: 1, Host: "other.test"}); err == nil {
5468 t.Fatal("imported another host's wake state")
5469 }
5470}
5471
5472func TestImportWakeRefusesAnUnknownReplicationClassAndChangesNothing(t *testing.T) {
5473 r := wakeReg(t)
5474 seedHost(t, r, "shop.test")
5475 if err := r.ReplaceTimers("shop.test", []registry.Timer{{Host: "shop.test", Name: "keep", Kind: "schedule", Spec: "@every 1m", Path: "/k", NextAt: 1, CreatedAt: 1}}); err != nil {
5476 t.Fatal(err)
5477 }
5478 err := ImportWake(r, "shop.test", WakeState{V: 1, Host: "shop.test", Replication: "hourly"})
5479 if err == nil {
5480 t.Fatal("accepted replication class \"hourly\"")
5481 }
5482 if rows, _ := r.TimersFor("shop.test"); len(rows) != 1 {
5483 t.Fatalf("a refused import changed the timers: %+v", rows)
5484 }
5485}
5486
5487func TestExportWakeOfAnUnknownHostIsAnError(t *testing.T) {
5488 if _, err := ExportWake(wakeReg(t), "nobody.test"); err == nil {
5489 t.Fatal("exported wake state for a host with no route")
5490 }
5491}
5492
5493func TestImportWakeOfTheDefaultClassReplacesAnotherClass(t *testing.T) {
5494 r := wakeReg(t)
5495 seedHost(t, r, "shop.test")
5496 if err := r.SetReplication("shop.test", registry.ReplicationDaily); err != nil {
5497 t.Fatal(err)
5498 }
5499 if err := ImportWake(r, "shop.test", WakeState{V: 1, Host: "shop.test", Replication: ""}); err != nil {
5500 t.Fatal(err)
5501 }
5502 rt, err := r.Get("shop.test")
5503 if err != nil || rt == nil {
5504 t.Fatalf("route: %v %v", rt, err)
5505 }
5506 if rt.Replication != "" {
5507 t.Fatalf("Replication = %q, want the default", rt.Replication)
5508 }
5509}
5510```
5511
5512- [ ] **Step 5: Run them to see them fail**
5513
5514Run: `go test ./internal/hostfiles/ -run Wake -count=1`
5515Expected: FAIL to build, `undefined: ExportWake`.
5516
5517- [ ] **Step 6: Write `wake.go`**
5518
5519```go
5520package hostfiles
5521
5522import (
5523 "context"
5524 "encoding/json"
5525 "fmt"
5526
5527 "github.com/carlosframework/platform/internal/registry"
5528 "github.com/carlosframework/platform/internal/release"
5529)
5530
5531// WakeItem is the wake state's name among the manifest's platform items.
5532const WakeItem = "wake.json"
5533
5534// WakeTimer is registry.Timer on the wire. It exists because
5535// registry.Timer hides Registration from JSON, and an import must keep it:
5536// the runner's delivery bookkeeping is keyed by it.
5537type WakeTimer struct {
5538 Name string `json:"name"`
5539 Kind string `json:"kind"`
5540 Spec string `json:"spec"`
5541 Path string `json:"path"`
5542 NextAt int64 `json:"next_at"`
5543 LastAt int64 `json:"last_at"`
5544 LastStatus string `json:"last_status"`
5545 LastMs int64 `json:"last_ms"`
5546 Attempts int `json:"attempts"`
5547 CreatedAt int64 `json:"created_at"`
5548 DueAt int64 `json:"due_at"`
5549 Registration string `json:"registration"`
5550}
5551
5552// WakeState is everything that decides when a host next runs without a
5553// request: its timers, the app's own next wake, and its replication class
5554// (region HA spec 1 §5.7).
5555type WakeState struct {
5556 V int `json:"v"`
5557 Host string `json:"host"`
5558 Timers []WakeTimer `json:"timers"`
5559 NextDue int64 `json:"next_due"`
5560 Replication string `json:"replication"`
5561}
5562
5563// ExportWake reads host's wake state. A finished once-timer (next_at 0) is
5564// history: it is left out, so the runner's daily prune of such rows never
5565// changes what is exported.
5566func ExportWake(reg *registry.Registry, host string) (WakeState, error) {
5567 rt, err := reg.Get(host)
5568 if err != nil {
5569 return WakeState{}, err
5570 }
5571 if rt == nil {
5572 return WakeState{}, fmt.Errorf("hostfiles: wake state for %s: no route", host)
5573 }
5574 rows, err := reg.TimersFor(host)
5575 if err != nil {
5576 return WakeState{}, err
5577 }
5578 ws := WakeState{V: 1, Host: host, Timers: []WakeTimer{}, NextDue: rt.NextDue, Replication: rt.Replication}
5579 for _, t := range rows {
5580 if t.Kind == "once" && t.NextAt == 0 {
5581 continue
5582 }
5583 ws.Timers = append(ws.Timers, WakeTimer{Name: t.Name, Kind: t.Kind, Spec: t.Spec, Path: t.Path,
5584 NextAt: t.NextAt, LastAt: t.LastAt, LastStatus: t.LastStatus, LastMs: t.LastMs, Attempts: t.Attempts,
5585 CreatedAt: t.CreatedAt, DueAt: t.DueAt, Registration: t.Registration})
5586 }
5587 return ws, nil
5588}
5589
5590// ImportWake makes host's wake state exactly ws. It validates everything
5591// before it writes anything.
5592func ImportWake(reg *registry.Registry, host string, ws WakeState) error {
5593 if ws.V != 1 {
5594 return fmt.Errorf("hostfiles: wake state version %d; this binary reads 1", ws.V)
5595 }
5596 if ws.Host != host {
5597 return fmt.Errorf("hostfiles: wake state is for %q, not %q", ws.Host, host)
5598 }
5599 if !registry.ValidReplication(ws.Replication) {
5600 return fmt.Errorf("hostfiles: wake state for %s names replication class %q", host, ws.Replication)
5601 }
5602 rows := make([]registry.Timer, 0, len(ws.Timers))
5603 for _, t := range ws.Timers {
5604 rows = append(rows, registry.Timer{Host: host, Name: t.Name, Kind: t.Kind, Spec: t.Spec, Path: t.Path,
5605 NextAt: t.NextAt, LastAt: t.LastAt, LastStatus: t.LastStatus, LastMs: t.LastMs, Attempts: t.Attempts,
5606 CreatedAt: t.CreatedAt, DueAt: t.DueAt, Registration: t.Registration})
5607 }
5608 if err := reg.ReplaceTimers(host, rows); err != nil {
5609 return err
5610 }
5611 if err := reg.SetNextDue(host, ws.NextDue); err != nil {
5612 return err
5613 }
5614 // Always, the empty class included: "" is the default rung, and a
5615 // destination left on its previous class would not be the state that
5616 // was exported.
5617 return reg.SetReplication(host, ws.Replication)
5618}
5619
5620// StageWake stages the wake state as a platform item. Unchanged state
5621// stages nothing.
5622func StageWake(ctx context.Context, p *Publisher, ws WakeState) error {
5623 body, err := json.Marshal(ws)
5624 if err != nil {
5625 return fmt.Errorf("hostfiles: marshal wake state: %w", err)
5626 }
5627 return p.StagePlatform(ctx, WakeItem, body)
5628}
5629
5630// LoadWake reads the wake state a manifest carries. The bool is false only
5631// when the manifest has no wake entry at all. A manifest that names wake
5632// state whose object is missing or damaged is an error: that is lost
5633// timers, not a host with none, and the caller must not install as if it
5634// were.
5635func LoadWake(ctx context.Context, s release.Store, prefix string, m Manifest) (WakeState, bool, error) {
5636 if _, ok := m.Platform[WakeItem]; !ok {
5637 return WakeState{}, false, nil
5638 }
5639 body, err := ReadPlatform(ctx, s, prefix, m, WakeItem)
5640 if err != nil {
5641 return WakeState{}, false, fmt.Errorf("hostfiles: the manifest names wake state that cannot be read: %w", err)
5642 }
5643 var ws WakeState
5644 if err := json.Unmarshal(body, &ws); err != nil {
5645 return WakeState{}, false, fmt.Errorf("hostfiles: wake state: %w", err)
5646 }
5647 return ws, true, nil
5648}
5649```
5650
5651- [ ] **Step 7: Check the registry facts the code relies on**
5652
5653The dry run confirmed both, against main at 014b0ce1; confirm them again if `registry.go` has changed since:
5654
5655- `registry.ValidReplication("")` is true: `""` is the default rung.
5656- `(*Registry).SetReplication(host, "")` succeeds and clears the class. `ImportWake` calls it unconditionally, so importing the default class onto a route that had `daily` leaves it on the default. `TestImportWakeOfTheDefaultClassReplacesAnotherClass` pins that.
5657
5658Run: `grep -n "func ValidReplication" -A8 internal/registry/registry.go; grep -n "func (r \*Registry) SetReplication" -A14 internal/registry/registry.go`
5659
5660- [ ] **Step 8: Run the tests**
5661
5662Run: `go test ./internal/hostfiles/ ./internal/registry/ -count=1`
5663Expected: PASS.
5664
5665- [ ] **Step 9: Commit**
5666
5667```bash
5668git add internal/registry/timers.go internal/registry/timers_test.go internal/hostfiles/wake.go internal/hostfiles/wake_test.go
5669git commit -m "hostfiles: a host's timers, next wake and replication class travel with its files
5670
5671Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
5672```
5673
5674---
5675
5676### Task 9: Termination fences (`internal/termfence`)
5677
5678**Files:**
5679- Create: `internal/termfence/fence.go`
5680- Test: `internal/termfence/fence_test.go`
5681
5682**Interfaces:**
5683- Consumes: `boxid.Identity`; `github.com/aws/aws-sdk-go-v2/service/ec2` and its `types` (already a dependency).
5684- Produces:
5685 ```go
5686 package termfence
5687 const (
5688 ViaDescribe = "describe"
5689 ViaEvent = "event"
5690 )
5691 type Observation struct {
5692 Via string
5693 Found bool // the source said something about this instance
5694 InstanceID string
5695 Account string
5696 Region string
5697 State string // EC2's state name, verbatim
5698 EventID string
5699 }
5700 type Fence struct {
5701 Box string `json:"box"`
5702 InstanceID string `json:"instance_id"`
5703 Account string `json:"account"`
5704 AWSRegion string `json:"aws_region"`
5705 State string `json:"state"`
5706 ObservedAt string `json:"observed_at"`
5707 Via string `json:"via"`
5708 EventID string `json:"event_id,omitempty"`
5709 }
5710 var ErrNotEvidence = errors.New("termfence: not evidence of termination")
5711 func FromDescribe(out *ec2.DescribeInstancesOutput, instanceID, region string) Observation
5712 func FromEvent(body []byte) (Observation, error)
5713 func Verify(box string, id boxid.Identity, o Observation, now time.Time) (Fence, error)
5714 ```
5715
5716Spec §5.9 and HA7. Only `terminated` counts. A missing instance, an error, another account or another region is never evidence. This task builds the two observation sources that exist today; the third (Auto Scaling activity history) is off until spec 4 verifies it on dev, so it is not built here. Reading the queue and calling EC2 belong to the placer (spec 3): this package is pure.
5717
5718- [ ] **Step 1: Write the failing tests**
5719
5720`internal/termfence/fence_test.go`:
5721
5722```go
5723package termfence
5724
5725import (
5726 "errors"
5727 "testing"
5728 "time"
5729
5730 "github.com/aws/aws-sdk-go-v2/aws"
5731 "github.com/aws/aws-sdk-go-v2/service/ec2"
5732 "github.com/aws/aws-sdk-go-v2/service/ec2/types"
5733
5734 "github.com/carlosframework/platform/internal/boxid"
5735)
5736
5737var (
5738 id = boxid.Identity{AccountID: "111122223333", Region: "eu-west-1", InstanceID: "i-0abc"}
5739 now = time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
5740)
5741
5742func describe(owner, instance string, state types.InstanceStateName) *ec2.DescribeInstancesOutput {
5743 return &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{
5744 OwnerId: aws.String(owner),
5745 Instances: []types.Instance{{InstanceId: aws.String(instance), State: &types.InstanceState{Name: state}}},
5746 }}}
5747}
5748
5749func TestATerminatedInstanceOfThisBoxIsAFence(t *testing.T) {
5750 o := FromDescribe(describe("111122223333", "i-0abc", types.InstanceStateNameTerminated), "i-0abc", "eu-west-1")
5751 f, err := Verify("i-0abc", id, o, now)
5752 if err != nil {
5753 t.Fatal(err)
5754 }
5755 want := Fence{Box: "i-0abc", InstanceID: "i-0abc", Account: "111122223333", AWSRegion: "eu-west-1",
5756 State: "terminated", ObservedAt: "2026-10-05T12:00:00Z", Via: "describe"}
5757 if f != want {
5758 t.Fatalf("fence = %+v\nwant %+v", f, want)
5759 }
5760}
5761
5762// Review Focus 4: every lookalike is refused.
5763func TestLookalikesAreNotEvidence(t *testing.T) {
5764 cases := map[string]Observation{
5765 "stopped": FromDescribe(describe("111122223333", "i-0abc", types.InstanceStateNameStopped), "i-0abc", "eu-west-1"),
5766 "stopping": FromDescribe(describe("111122223333", "i-0abc", types.InstanceStateNameStopping), "i-0abc", "eu-west-1"),
5767 "shutting-down": FromDescribe(describe("111122223333", "i-0abc", types.InstanceStateNameShuttingDown), "i-0abc", "eu-west-1"),
5768 "running": FromDescribe(describe("111122223333", "i-0abc", types.InstanceStateNameRunning), "i-0abc", "eu-west-1"),
5769 "another account": FromDescribe(describe("999999999999", "i-0abc", types.InstanceStateNameTerminated), "i-0abc", "eu-west-1"),
5770 "another region": FromDescribe(describe("111122223333", "i-0abc", types.InstanceStateNameTerminated), "i-0abc", "us-east-1"),
5771 "another instance": FromDescribe(describe("111122223333", "i-0zzz", types.InstanceStateNameTerminated), "i-0zzz", "eu-west-1"),
5772 "not in the reply": FromDescribe(describe("111122223333", "i-0zzz", types.InstanceStateNameTerminated), "i-0abc", "eu-west-1"),
5773 "empty reply": FromDescribe(&ec2.DescribeInstancesOutput{}, "i-0abc", "eu-west-1"),
5774 "nil reply": FromDescribe(nil, "i-0abc", "eu-west-1"),
5775 "no state": FromDescribe(&ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{OwnerId: aws.String("111122223333"), Instances: []types.Instance{{InstanceId: aws.String("i-0abc")}}}}}, "i-0abc", "eu-west-1"),
5776 "unknown source": {Via: "cloudtrail", Found: true, InstanceID: "i-0abc", Account: "111122223333", Region: "eu-west-1", State: "terminated"},
5777 "zero observation": {},
5778 }
5779 for name, o := range cases {
5780 t.Run(name, func(t *testing.T) {
5781 if f, err := Verify("i-0abc", id, o, now); !errors.Is(err, ErrNotEvidence) {
5782 t.Fatalf("fence = %+v, err = %v; want ErrNotEvidence", f, err)
5783 }
5784 })
5785 }
5786}
5787
5788func TestAnUnverifiedIdentityIsNeverFenced(t *testing.T) {
5789 // A found, terminated observation whose every coordinate is empty, so
5790 // it MATCHES an empty identity field for field. Only the guard on the
5791 // identity itself can refuse it.
5792 o := Observation{Via: ViaDescribe, Found: true, State: "terminated"}
5793 if _, err := Verify("i-0abc", boxid.Identity{}, o, now); !errors.Is(err, ErrNotEvidence) {
5794 t.Fatalf("err = %v: an empty identity matched an empty observation", err)
5795 }
5796 if _, err := Verify("", id, Observation{Via: ViaDescribe, Found: true, InstanceID: "i-0abc", Account: "111122223333", Region: "eu-west-1", State: "terminated"}, now); !errors.Is(err, ErrNotEvidence) {
5797 t.Fatalf("err = %v: a fence with no box name", err)
5798 }
5799}
5800
5801const terminatedEvent = `{"version":"0","id":"ev-123","detail-type":"EC2 Instance State-change Notification","source":"aws.ec2","account":"111122223333","time":"2026-10-05T11:59:00Z","region":"eu-west-1","resources":["arn:aws:ec2:eu-west-1:111122223333:instance/i-0abc"],"detail":{"instance-id":"i-0abc","state":"terminated"}}`
5802
5803func TestATerminatedEventIsAFence(t *testing.T) {
5804 o, err := FromEvent([]byte(terminatedEvent))
5805 if err != nil {
5806 t.Fatal(err)
5807 }
5808 f, err := Verify("i-0abc", id, o, now)
5809 if err != nil {
5810 t.Fatal(err)
5811 }
5812 if f.Via != "event" || f.EventID != "ev-123" || f.State != "terminated" {
5813 t.Fatalf("fence = %+v", f)
5814 }
5815}
5816
5817func TestEventsThatAreNotEC2StateChangesAreRefused(t *testing.T) {
5818 for name, body := range map[string]string{
5819 "not JSON": `terminated`,
5820 "another source": `{"id":"e","detail-type":"EC2 Instance State-change Notification","source":"my.app","account":"111122223333","region":"eu-west-1","detail":{"instance-id":"i-0abc","state":"terminated"}}`,
5821 "another detail-type": `{"id":"e","detail-type":"EC2 Spot Instance Interruption Warning","source":"aws.ec2","account":"111122223333","region":"eu-west-1","detail":{"instance-id":"i-0abc","state":"terminated"}}`,
5822 "no event id": `{"detail-type":"EC2 Instance State-change Notification","source":"aws.ec2","account":"111122223333","region":"eu-west-1","detail":{"instance-id":"i-0abc","state":"terminated"}}`,
5823 } {
5824 t.Run(name, func(t *testing.T) {
5825 if _, err := FromEvent([]byte(body)); err == nil {
5826 t.Fatal("accepted")
5827 }
5828 })
5829 }
5830}
5831
5832func TestAShuttingDownEventIsNotEvidence(t *testing.T) {
5833 body := `{"id":"e","detail-type":"EC2 Instance State-change Notification","source":"aws.ec2","account":"111122223333","region":"eu-west-1","detail":{"instance-id":"i-0abc","state":"shutting-down"}}`
5834 o, err := FromEvent([]byte(body))
5835 if err != nil {
5836 t.Fatal(err)
5837 }
5838 if _, err := Verify("i-0abc", id, o, now); !errors.Is(err, ErrNotEvidence) {
5839 t.Fatalf("err = %v, want ErrNotEvidence", err)
5840 }
5841}
5842
5843func TestALegacyLabelFencesByItsBoundInstance(t *testing.T) {
5844 o := FromDescribe(describe("111122223333", "i-0abc", types.InstanceStateNameTerminated), "i-0abc", "eu-west-1")
5845 f, err := Verify("annie-welding", id, o, now)
5846 if err != nil || f.Box != "annie-welding" || f.InstanceID != "i-0abc" {
5847 t.Fatalf("fence = %+v, err = %v", f, err)
5848 }
5849}
5850```
5851
5852- [ ] **Step 2: Run them to see them fail**
5853
5854Run: `go test ./internal/termfence/ -count=1`
5855Expected: FAIL to build, `undefined: FromDescribe`.
5856
5857- [ ] **Step 3: Write `fence.go`**
5858
5859```go
5860// Package termfence decides whether an observation proves that a box's
5861// machine has been terminated (region HA spec 1 §5.9, invariant HA7).
5862//
5863// It is pure: something else calls EC2 or reads the event queue and hands
5864// the result here. The rule is narrow on purpose. Only the state
5865// "terminated" counts, only for the instance, account and region that the
5866// box's AWS-signed record names, and silence is never evidence.
5867package termfence
5868
5869import (
5870 "encoding/json"
5871 "errors"
5872 "fmt"
5873 "time"
5874
5875 "github.com/aws/aws-sdk-go-v2/aws"
5876 "github.com/aws/aws-sdk-go-v2/service/ec2"
5877
5878 "github.com/carlosframework/platform/internal/boxid"
5879)
5880
5881const (
5882 ViaDescribe = "describe"
5883 ViaEvent = "event"
5884
5885 stateTerminated = "terminated"
5886 eventSource = "aws.ec2"
5887 eventDetailType = "EC2 Instance State-change Notification"
5888)
5889
5890// ErrNotEvidence wraps every reason an observation does not fence a box.
5891var ErrNotEvidence = errors.New("termfence: not evidence of termination")
5892
5893// Observation is what one source said about one instance. Found is false
5894// when the source said nothing about it: an empty reply, another instance.
5895type Observation struct {
5896 Via string
5897 Found bool
5898 InstanceID string
5899 Account string
5900 Region string
5901 State string
5902 EventID string
5903}
5904
5905// Fence is the record a relocation journals in place of stop evidence.
5906type Fence struct {
5907 Box string `json:"box"`
5908 InstanceID string `json:"instance_id"`
5909 Account string `json:"account"`
5910 AWSRegion string `json:"aws_region"`
5911 State string `json:"state"`
5912 ObservedAt string `json:"observed_at"`
5913 Via string `json:"via"`
5914 EventID string `json:"event_id,omitempty"`
5915}
5916
5917// FromDescribe reads one instance out of a DescribeInstances reply. region
5918// is the region the call was made in: the reply does not carry it. A reply
5919// that does not mention instanceID yields Found false, which Verify
5920// refuses: AWS forgets a terminated instance within about an hour, so
5921// absence proves nothing.
5922func FromDescribe(out *ec2.DescribeInstancesOutput, instanceID, region string) Observation {
5923 o := Observation{Via: ViaDescribe, Region: region}
5924 if out == nil || instanceID == "" {
5925 return o
5926 }
5927 for _, r := range out.Reservations {
5928 for _, inst := range r.Instances {
5929 if aws.ToString(inst.InstanceId) != instanceID {
5930 continue
5931 }
5932 o.Found, o.InstanceID, o.Account = true, instanceID, aws.ToString(r.OwnerId)
5933 if inst.State != nil {
5934 o.State = string(inst.State.Name)
5935 }
5936 return o
5937 }
5938 }
5939 return o
5940}
5941
5942// FromEvent reads an EventBridge "EC2 Instance State-change Notification".
5943// It refuses anything else: the queue's policy admits only the rule that
5944// forwards these, and this is the second check.
5945func FromEvent(body []byte) (Observation, error) {
5946 var ev struct {
5947 ID string `json:"id"`
5948 DetailType string `json:"detail-type"`
5949 Source string `json:"source"`
5950 Account string `json:"account"`
5951 Region string `json:"region"`
5952 Detail struct {
5953 InstanceID string `json:"instance-id"`
5954 State string `json:"state"`
5955 } `json:"detail"`
5956 }
5957 if err := json.Unmarshal(body, &ev); err != nil {
5958 return Observation{}, fmt.Errorf("termfence: event is not JSON: %w", err)
5959 }
5960 switch {
5961 case ev.Source != eventSource:
5962 return Observation{}, fmt.Errorf("termfence: event source %q is not %s", ev.Source, eventSource)
5963 case ev.DetailType != eventDetailType:
5964 return Observation{}, fmt.Errorf("termfence: event detail-type %q is not a state change", ev.DetailType)
5965 case ev.ID == "":
5966 return Observation{}, errors.New("termfence: event has no id")
5967 }
5968 return Observation{Via: ViaEvent, Found: ev.Detail.InstanceID != "", InstanceID: ev.Detail.InstanceID,
5969 Account: ev.Account, Region: ev.Region, State: ev.Detail.State, EventID: ev.ID}, nil
5970}
5971
5972func refuse(format string, a ...any) error {
5973 return fmt.Errorf("%w: %s", ErrNotEvidence, fmt.Sprintf(format, a...))
5974}
5975
5976// Verify returns a fence for box when o proves that the instance in id,
5977// the box's verified identity, is terminated.
5978func Verify(box string, id boxid.Identity, o Observation, now time.Time) (Fence, error) {
5979 switch {
5980 case box == "" || id.InstanceID == "" || id.AccountID == "" || id.Region == "":
5981 return Fence{}, refuse("the box has no verified identity")
5982 case o.Via != ViaDescribe && o.Via != ViaEvent:
5983 return Fence{}, refuse("source %q is not one this binary accepts", o.Via)
5984 case !o.Found:
5985 return Fence{}, refuse("the source said nothing about %s; absence is not termination", id.InstanceID)
5986 case o.InstanceID != id.InstanceID:
5987 return Fence{}, refuse("observed %s, the box is %s", o.InstanceID, id.InstanceID)
5988 case o.Account != id.AccountID:
5989 return Fence{}, refuse("observed in account %q, the box is in %q", o.Account, id.AccountID)
5990 case o.Region != id.Region:
5991 return Fence{}, refuse("observed in region %q, the box is in %q", o.Region, id.Region)
5992 case o.State != stateTerminated:
5993 return Fence{}, refuse("state is %q; only %q counts", o.State, stateTerminated)
5994 }
5995 return Fence{Box: box, InstanceID: o.InstanceID, Account: o.Account, AWSRegion: o.Region, State: o.State,
5996 ObservedAt: now.UTC().Format(time.RFC3339), Via: o.Via, EventID: o.EventID}, nil
5997}
5998```
5999
6000- [ ] **Step 4: Run the tests**
6001
6002Run: `gofmt -w internal/termfence && go test ./internal/termfence/ -count=1`
6003Expected: PASS.
6004
6005- [ ] **Step 5: Commit**
6006
6007```bash
6008git add internal/termfence
6009git commit -m "termfence: only a terminated state, for the box's own verified instance, is a fence
6010
6011Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
6012```
6013
6014---
6015
6016### Task 10: The installation-complete marker (`internal/activator/installed.go`)
6017
6018**Files:**
6019- Create: `internal/activator/installed.go`
6020- Test: `internal/activator/installed_test.go`
6021
6022**Interfaces:**
6023- Produces:
6024 ```go
6025 type InstallMarker struct {
6026 Incarnation string `json:"incarnation"`
6027 Generation string `json:"generation"`
6028 Stamp int64 `json:"stamp"`
6029 DBMarker string `json:"db_marker"`
6030 FilesSeq int64 `json:"files_seq"`
6031 }
6032 // Installation is what the claim says is current for a host.
6033 type Installation struct {
6034 Incarnation string
6035 Generation string
6036 Stamp int64
6037 }
6038 func InstallMarkerPath(dataDir, host string) string // <dataDir>/.installed/<InstanceID(host)>.json
6039 func WriteInstallMarker(dataDir, host string, m InstallMarker) error
6040 func ReadInstallMarker(dataDir, host string) (InstallMarker, bool, error)
6041 func RemoveInstallMarker(dataDir, host string) error
6042 func WarmCopyTrusted(want Installation, activeStamp int64, m InstallMarker, found bool, dbMarker string) bool
6043 ```
6044
6045Spec §5.5 and HA3: authority (the box's `active_stamp`, from the claim) and evidence (this marker) are separate, and a box skips restore only when both match. This task builds the marker and the decision. It does **not** change `ExecBackend.Restore`: wiring it in needs the claim's fresh read and host guard, which is plan 1b. Until then `WarmCopyTrusted` has no caller outside its tests.
6046
6047The marker's file name uses `provision.InstanceID(host)`, as the nested layout does, so a long host name cannot exceed the file-name limit.
6048
6049- [ ] **Step 1: Write the failing tests**
6050
6051`internal/activator/installed_test.go`:
6052
6053```go
6054package activator
6055
6056import (
6057 "os"
6058 "path/filepath"
6059 "strings"
6060 "testing"
6061)
6062
6063func TestInstallMarkerRoundTrips(t *testing.T) {
6064 dir := t.TempDir()
6065 if _, found, err := ReadInstallMarker(dir, "shop.test"); found || err != nil {
6066 t.Fatalf("before any write: found=%v err=%v", found, err)
6067 }
6068 m := InstallMarker{Incarnation: "inc1", Generation: "g-op7", Stamp: 41, DBMarker: "dbm", FilesSeq: 1882}
6069 if err := WriteInstallMarker(dir, "shop.test", m); err != nil {
6070 t.Fatal(err)
6071 }
6072 got, found, err := ReadInstallMarker(dir, "shop.test")
6073 if err != nil || !found || got != m {
6074 t.Fatalf("got %+v found=%v err=%v", got, found, err)
6075 }
6076 m.Stamp = 57
6077 if err := WriteInstallMarker(dir, "shop.test", m); err != nil {
6078 t.Fatal(err)
6079 }
6080 if got, _, _ := ReadInstallMarker(dir, "shop.test"); got.Stamp != 57 {
6081 t.Fatalf("a second write did not replace the first: %+v", got)
6082 }
6083 if err := RemoveInstallMarker(dir, "shop.test"); err != nil {
6084 t.Fatal(err)
6085 }
6086 if err := RemoveInstallMarker(dir, "shop.test"); err != nil {
6087 t.Fatalf("removing an absent marker: %v", err)
6088 }
6089 if _, found, _ := ReadInstallMarker(dir, "shop.test"); found {
6090 t.Fatal("marker still there")
6091 }
6092}
6093
6094func TestInstallMarkerLeavesNoTemporaryFile(t *testing.T) {
6095 dir := t.TempDir()
6096 if err := WriteInstallMarker(dir, "shop.test", InstallMarker{Stamp: 1}); err != nil {
6097 t.Fatal(err)
6098 }
6099 entries, err := os.ReadDir(filepath.Join(dir, ".installed"))
6100 if err != nil {
6101 t.Fatal(err)
6102 }
6103 if len(entries) != 1 {
6104 t.Fatalf("%d files in .installed, want 1", len(entries))
6105 }
6106}
6107
6108func TestInstallMarkerForALongHostHasAShortFileName(t *testing.T) {
6109 host := strings.Repeat("a", 200) + ".example.com"
6110 p := InstallMarkerPath(t.TempDir(), host)
6111 if n := len(filepath.Base(p)); n > 64 {
6112 t.Fatalf("file name is %d bytes", n)
6113 }
6114}
6115
6116func TestADamagedMarkerIsAnErrorNotAbsence(t *testing.T) {
6117 dir := t.TempDir()
6118 if err := WriteInstallMarker(dir, "shop.test", InstallMarker{Stamp: 1}); err != nil {
6119 t.Fatal(err)
6120 }
6121 if err := os.WriteFile(InstallMarkerPath(dir, "shop.test"), []byte("{half"), 0o600); err != nil {
6122 t.Fatal(err)
6123 }
6124 if _, found, err := ReadInstallMarker(dir, "shop.test"); err == nil || found {
6125 t.Fatalf("found=%v err=%v: a damaged marker must be an error", found, err)
6126 }
6127}
6128
6129func TestWarmCopyTrusted(t *testing.T) {
6130 want := Installation{Incarnation: "inc1", Generation: "g-op7", Stamp: 41}
6131 good := InstallMarker{Incarnation: "inc1", Generation: "g-op7", Stamp: 41, DBMarker: "dbm", FilesSeq: 9}
6132 if !WarmCopyTrusted(want, 41, good, true, "dbm") {
6133 t.Fatal("a complete, current installation was not trusted")
6134 }
6135 for name, tc := range map[string]struct {
6136 active int64
6137 m InstallMarker
6138 found bool
6139 db string
6140 }{
6141 "no marker: crashed before the install finished": {41, InstallMarker{}, false, "dbm"},
6142 "authority without evidence": {41, InstallMarker{Incarnation: "inc1", Generation: "g-op7", Stamp: 40, DBMarker: "dbm"}, true, "dbm"},
6143 "evidence without authority": {40, good, true, "dbm"},
6144 "no authority at all": {0, good, true, "dbm"},
6145 "another generation": {41, InstallMarker{Incarnation: "inc1", Generation: "g-op6", Stamp: 41, DBMarker: "dbm"}, true, "dbm"},
6146 "another incarnation": {41, InstallMarker{Incarnation: "inc0", Generation: "g-op7", Stamp: 41, DBMarker: "dbm"}, true, "dbm"},
6147 "the database is not the one installed": {41, good, true, "other"},
6148 "the database has no marker": {41, good, true, ""},
6149 } {
6150 t.Run(name, func(t *testing.T) {
6151 if WarmCopyTrusted(want, tc.active, tc.m, tc.found, tc.db) {
6152 t.Fatal("trusted")
6153 }
6154 })
6155 }
6156 if WarmCopyTrusted(Installation{}, 0, InstallMarker{}, true, "") {
6157 t.Fatal("an empty claim matched an empty marker")
6158 }
6159}
6160```
6161
6162- [ ] **Step 2: Run them to see them fail**
6163
6164Run: `go test ./internal/activator/ -run 'InstallMarker|DamagedMarker|WarmCopyTrusted' -count=1`
6165Expected: FAIL to build, `undefined: ReadInstallMarker`.
6166
6167- [ ] **Step 3: Write `installed.go`**
6168
6169```go
6170package activator
6171
6172import (
6173 "encoding/json"
6174 "errors"
6175 "fmt"
6176 "os"
6177 "path/filepath"
6178
6179 "github.com/carlosframework/platform/internal/provision"
6180)
6181
6182// InstallMarker is a box's own record that it finished installing one
6183// generation of a host: the database restored and synced, the files
6184// applied, the wake state imported (region HA spec 1 §5.5). It is written
6185// last, so its presence vouches for all of them.
6186//
6187// It is evidence, not authority. Authority is the claim naming this box,
6188// which the box holds as its active stamp. A box that crashed mid-install
6189// has authority and no marker, and must restore.
6190type InstallMarker struct {
6191 Incarnation string `json:"incarnation"`
6192 Generation string `json:"generation"`
6193 Stamp int64 `json:"stamp"`
6194 DBMarker string `json:"db_marker"`
6195 FilesSeq int64 `json:"files_seq"`
6196}
6197
6198// Installation is what the claim says is current for a host.
6199type Installation struct {
6200 Incarnation string
6201 Generation string
6202 Stamp int64
6203}
6204
6205func InstallMarkerPath(dataDir, host string) string {
6206 return filepath.Join(dataDir, ".installed", provision.InstanceID(host)+".json")
6207}
6208
6209// WriteInstallMarker replaces the marker atomically and syncs both the
6210// file and its directory, so a marker that is read back was fully written.
6211func WriteInstallMarker(dataDir, host string, m InstallMarker) error {
6212 path := InstallMarkerPath(dataDir, host)
6213 dir := filepath.Dir(path)
6214 if err := os.MkdirAll(dir, 0o700); err != nil {
6215 return err
6216 }
6217 body, err := json.Marshal(m)
6218 if err != nil {
6219 return err
6220 }
6221 tmp, err := os.CreateTemp(dir, ".marker-*")
6222 if err != nil {
6223 return err
6224 }
6225 defer os.Remove(tmp.Name())
6226 if _, err := tmp.Write(body); err != nil {
6227 tmp.Close()
6228 return err
6229 }
6230 if err := tmp.Sync(); err != nil {
6231 tmp.Close()
6232 return err
6233 }
6234 if err := tmp.Close(); err != nil {
6235 return err
6236 }
6237 if err := os.Rename(tmp.Name(), path); err != nil {
6238 return err
6239 }
6240 d, err := os.Open(dir)
6241 if err != nil {
6242 return err
6243 }
6244 defer d.Close()
6245 return d.Sync()
6246}
6247
6248// ReadInstallMarker returns the marker and whether there is one. A marker
6249// that exists but cannot be parsed is an error, never "absent": the caller
6250// restores either way, but it must not mistake damage for a clean slate.
6251func ReadInstallMarker(dataDir, host string) (InstallMarker, bool, error) {
6252 body, err := os.ReadFile(InstallMarkerPath(dataDir, host))
6253 if errors.Is(err, os.ErrNotExist) {
6254 return InstallMarker{}, false, nil
6255 }
6256 if err != nil {
6257 return InstallMarker{}, false, err
6258 }
6259 var m InstallMarker
6260 if err := json.Unmarshal(body, &m); err != nil {
6261 return InstallMarker{}, false, fmt.Errorf("activator: install marker for %s: %w", host, err)
6262 }
6263 return m, true, nil
6264}
6265
6266func RemoveInstallMarker(dataDir, host string) error {
6267 err := os.Remove(InstallMarkerPath(dataDir, host))
6268 if errors.Is(err, os.ErrNotExist) {
6269 return nil
6270 }
6271 return err
6272}
6273
6274// WarmCopyTrusted is invariant HA3: a box may skip restore and serve its
6275// local copy only when it has authority for the current installation
6276// (activeStamp equals the claim's stamp) AND evidence that it finished
6277// installing exactly that one (the marker matches the claim, and the
6278// database on disk is the one the marker was written for).
6279//
6280// dbMarker is the generation marker read from beside the local database.
6281// Every empty value fails: nothing matches by being absent on both sides.
6282func WarmCopyTrusted(want Installation, activeStamp int64, m InstallMarker, found bool, dbMarker string) bool {
6283 if !found || want.Stamp == 0 || want.Incarnation == "" || want.Generation == "" || dbMarker == "" {
6284 return false
6285 }
6286 return activeStamp == want.Stamp &&
6287 m.Stamp == want.Stamp &&
6288 m.Incarnation == want.Incarnation &&
6289 m.Generation == want.Generation &&
6290 m.DBMarker == dbMarker
6291}
6292```
6293
6294- [ ] **Step 4: Run the tests**
6295
6296Run: `go test ./internal/activator/ -run 'InstallMarker|DamagedMarker|WarmCopyTrusted' -count=1 && GOOS=windows go build ./internal/activator/`
6297Expected: PASS and the cross-build succeeds.
6298
6299If the Windows build fails on the directory `Sync`, it is a compile error only if the code is wrong: `(*os.File).Sync` exists on every OS. A runtime failure syncing a directory on Windows does not matter here, because the agent runs on Linux.
6300
6301- [ ] **Step 5: Commit**
6302
6303```bash
6304git add internal/activator/installed.go internal/activator/installed_test.go
6305git commit -m "activator: an installation-complete marker, and the rule for trusting a local copy
6306
6307Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
6308```
6309
6310---
6311
6312### Task 11: Infrastructure, the spec note, and the branch gate
6313
6314**Files:**
6315- Modify: `infra/modules/region-host/main.tf` (instance at `:461`, role policy `host_buckets` at `:139`), `infra/modules/region-host/outputs.tf`, `infra/modules/region-host/README.md`
6316- Modify: `infra/modules/deployment-buckets/main.tf`, `infra/modules/deployment-buckets/variables.tf`
6317- Modify: `docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md`
6318
6319**Interfaces:**
6320- Produces, from `region-host`: outputs `instance_id` (if not already there) and `termination_queue_url`. From `deployment-buckets`: variable `operator_role_arns` (list of strings, default `[]`).
6321
6322Nothing here is applied. The flagship's root lives in another repository and consumes these modules at a release tag; applying is a separate, gated operation. Two cautions from past incidents, both binding on this task:
6323
6324- A whole-root apply of the flagship restarts every box (user-data drift). This task changes no `user_data`.
6325- A Deny on these buckets once covered `s3:DeleteObject` and broke every lease release for the full 15-minute TTL. Every Deny added here names the `control/boxes/` prefix and nothing wider.
6326
6327- [ ] **Step 1: Make the root volume die with the instance**
6328
6329In `infra/modules/region-host/main.tf`, in `aws_instance.host`'s `root_block_device`:
6330
6331```hcl
6332 root_block_device {
6333 volume_size = var.root_volume_size
6334 volume_type = "gp3"
6335 encrypted = true
6336 # A terminated box must leave no disk behind: termination is the proof
6337 # that its instances may run elsewhere (region HA spec 1 §5.9).
6338 delete_on_termination = true
6339 }
6340```
6341
6342`true` is AWS's default for a root volume, so this changes nothing on a box created by this module; it pins the property so it cannot be lost.
6343
6344- [ ] **Step 2: Add the termination queue**
6345
6346Append to `infra/modules/region-host/main.tf`:
6347
6348```hcl
6349# --- Termination evidence (region HA spec 1 §5.9) ---------------------------
6350# EC2 tells EventBridge when an instance in this account and region reaches
6351# "terminated"; the rule forwards that to a queue the region's boxes read.
6352# Delivery is best-effort on AWS's side: the queue keeps what arrived, and a
6353# box that finds nothing here still asks EC2 directly.
6354
6355data "aws_caller_identity" "current" {}
6356
6357resource "aws_sqs_queue" "terminations" {
6358 name = "${var.iam_name}-terminations"
6359 message_retention_seconds = 1209600 # 14 days, the maximum
6360 sqs_managed_sse_enabled = true
6361}
6362
6363resource "aws_cloudwatch_event_rule" "terminations" {
6364 name = "${var.iam_name}-terminations"
6365 description = "EC2 instances reaching terminated, for region HA failover."
6366 event_pattern = jsonencode({
6367 source = ["aws.ec2"]
6368 "detail-type" = ["EC2 Instance State-change Notification"]
6369 detail = { state = ["terminated"] }
6370 })
6371}
6372
6373resource "aws_cloudwatch_event_target" "terminations" {
6374 rule = aws_cloudwatch_event_rule.terminations.name
6375 arn = aws_sqs_queue.terminations.arn
6376}
6377
6378# Only this rule may put a message on the queue. A message is evidence, so
6379# who can write one matters as much as who can read one.
6380#
6381# The Allow alone is not enough: a principal in this account with its own
6382# sqs:SendMessage grant (an admin role, a wildcard policy) could still post
6383# a forged event, because an identity policy and a queue policy add up.
6384# The Deny closes that. It matches every send whose source is not this
6385# rule, including one with no source at all, which is what a direct
6386# SendMessage from a role looks like.
6387resource "aws_sqs_queue_policy" "terminations" {
6388 queue_url = aws_sqs_queue.terminations.id
6389 policy = jsonencode({
6390 Version = "2012-10-17"
6391 Statement = [
6392 {
6393 Sid = "TheTerminationRuleMaySend"
6394 Effect = "Allow"
6395 Principal = { Service = "events.amazonaws.com" }
6396 Action = "sqs:SendMessage"
6397 Resource = aws_sqs_queue.terminations.arn
6398 Condition = { ArnEquals = { "aws:SourceArn" = aws_cloudwatch_event_rule.terminations.arn } }
6399 },
6400 {
6401 Sid = "NobodyElseMaySend"
6402 Effect = "Deny"
6403 Principal = "*"
6404 Action = "sqs:SendMessage"
6405 Resource = aws_sqs_queue.terminations.arn
6406 Condition = { ArnNotEquals = { "aws:SourceArn" = aws_cloudwatch_event_rule.terminations.arn } }
6407 },
6408 ]
6409 })
6410}
6411
6412resource "aws_iam_role_policy" "host_terminations" {
6413 name = "${var.iam_name}-terminations"
6414 role = aws_iam_role.host.id
6415 policy = jsonencode({
6416 Version = "2012-10-17"
6417 Statement = [{
6418 Effect = "Allow"
6419 Action = ["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"]
6420 Resource = aws_sqs_queue.terminations.arn
6421 }]
6422 })
6423}
6424```
6425
6426First run `grep -n 'data "aws_caller_identity"' infra/modules/region-host/*.tf`. If the module already declares one, delete the declaration above (it is not otherwise used here) rather than adding a second.
6427
6428`tofu validate` cannot prove a policy's effect. Add this to the module README's new section (Step 6) as the check to run once after the first apply, from an administrator's session, and record its result on the discussion that tracks the roll:
6429
6430 aws sqs send-message --queue-url <termination_queue_url> --message-body '{"forged":true}'
6431
6432Expected: `AccessDenied`. If it succeeds, the queue is not evidence: stop, and do not enable the placer (spec 3) against it. Then terminate a throwaway instance in the region and confirm its event arrives with `aws sqs receive-message`.
6433
6434The box role already has `ec2:DescribeInstances` on `*` for the infra inventory (`main.tf`, the read-only statement in `host_buckets`). Spec §6 asks for an `ec2:Region` condition on it. Do **not** narrow the existing statement in this task: the inventory reads other regions. Record it as an open item in the README text below; plan 1b decides it with the placer.
6435
6436- [ ] **Step 3: Add the outputs**
6437
6438Run `grep -n 'output "instance_id"' infra/modules/region-host/outputs.tf`. Add whichever of these is missing to `outputs.tf`:
6439
6440```hcl
6441output "instance_id" {
6442 description = "The box's EC2 instance id. `carlos ops box bind` checks a legacy box's record against this."
6443 value = aws_instance.host.id
6444}
6445
6446output "termination_queue_url" {
6447 description = "The queue that receives this region's EC2 terminated events."
6448 value = aws_sqs_queue.terminations.id
6449}
6450```
6451
6452- [ ] **Step 4: Protect box records in the deployment bucket**
6453
6454Append to `infra/modules/deployment-buckets/variables.tf`:
6455
6456```hcl
6457variable "operator_role_arns" {
6458 description = "IAM role ARNs of human operators. Only these may write a box bind (control/boxes/*.bind.json). Empty means nobody can: the safe default until operators are named."
6459 type = list(string)
6460 default = []
6461}
6462```
6463
6464Append to `infra/modules/deployment-buckets/main.tf`:
6465
6466```hcl
6467# --- Box records (region HA spec 1 §5.1) -----------------------------------
6468# control/boxes/<id>.json binds a box's name to one AWS instance, and
6469# control/boxes/<id>.bind.json is an operator's word for a legacy label.
6470# Both are written once and never changed. Storage enforces that, so no
6471# bug or stolen box credential can rebind a name to another machine.
6472#
6473# EVERY statement here names control/boxes/ and nothing wider. A broad Deny
6474# on this bucket has broken lease releases before.
6475
6476resource "aws_s3_bucket_policy" "deployment_box_records" {
6477 bucket = aws_s3_bucket.deployment.id
6478 policy = jsonencode({
6479 Version = "2012-10-17"
6480 Statement = [
6481 {
6482 # A record or bind may be created, never replaced. The qualifier
6483 # leaves multipart preparation alone and still covers its completion.
6484 Sid = "BoxRecordsAreCreateOnly"
6485 Effect = "Deny"
6486 Principal = "*"
6487 Action = "s3:PutObject"
6488 Resource = "${aws_s3_bucket.deployment.arn}/control/boxes/*"
6489 Condition = {
6490 Null = { "s3:if-none-match" = "true" }
6491 Bool = { "s3:ObjectCreationOperation" = "true" }
6492 }
6493 },
6494 {
6495 Sid = "BoxRecordsAreNeverDeleted"
6496 Effect = "Deny"
6497 Principal = "*"
6498 Action = ["s3:DeleteObject", "s3:DeleteObjectVersion"]
6499 Resource = "${aws_s3_bucket.deployment.arn}/control/boxes/*"
6500 },
6501 {
6502 # With no operators named, the list is one ARN nobody has, so the
6503 # Deny covers everyone.
6504 Sid = "OnlyOperatorsWriteBinds"
6505 Effect = "Deny"
6506 Principal = "*"
6507 Action = "s3:PutObject"
6508 Resource = "${aws_s3_bucket.deployment.arn}/control/boxes/*.bind.json"
6509 Condition = {
6510 ArnNotLike = {
6511 "aws:PrincipalArn" = length(var.operator_role_arns) > 0 ? var.operator_role_arns : ["arn:aws:iam::000000000000:role/nobody"]
6512 }
6513 }
6514 },
6515 ]
6516 })
6517}
6518```
6519
6520Before adding it, run `grep -rn "aws_s3_bucket_policy" infra/modules/`. The report found no bucket policy in this module. If one exists for the deployment bucket elsewhere, a bucket takes only one policy: merge these three statements into it instead of creating a second resource.
6521
6522- [ ] **Step 5: Validate both modules**
6523
6524Run:
6525
6526```bash
6527(cd infra/modules/region-host && tofu fmt -check && tofu init -backend=false -input=false >/dev/null && tofu validate)
6528(cd infra/modules/deployment-buckets && tofu fmt -check && tofu init -backend=false -input=false >/dev/null && tofu validate)
6529```
6530
6531Expected: `Success! The configuration is valid.` twice. `tofu init` downloads the AWS provider: it needs network access to `registry.opentofu.org` and `github.com`, and writes a `.terraform` directory in each module. Delete both `.terraform` directories and any `.terraform.lock.hcl` this created before committing (`git status` must show only the `.tf` and `.md` changes). If `tofu fmt -check` lists a file, run `tofu fmt` on it.
6532
6533- [ ] **Step 6: Document the module changes**
6534
6535Append to `infra/modules/region-host/README.md`:
6536
6537```markdown
6538## Termination evidence
6539
6540The module creates a queue, `<iam_name>-terminations`, that receives an
6541event whenever an EC2 instance in this account and region reaches
6542`terminated`. Region HA reads it to prove a dead box is gone before its
6543instances run elsewhere. Only the module's own EventBridge rule can write
6544to the queue, and the box role can only read and delete from it.
6545
6546After the first apply, check that nobody but the rule can write to it.
6547From an administrator's session:
6548
6549 aws sqs send-message --queue-url <termination_queue_url> --message-body '{"forged":true}'
6550
6551must fail with `AccessDenied`. Then terminate a throwaway instance in the
6552region and confirm its event arrives.
6553
6554The root volume is deleted when the instance is terminated. Region HA
6555depends on that: a terminated box must leave no disk behind.
6556
6557Open item for region HA plan 1b: the box role's `ec2:DescribeInstances`
6558is unconditioned, because the infrastructure inventory reads every
6559region. The HA spec asks for an `ec2:Region` condition on the call the
6560placer makes. Decide there whether that is a second, narrower statement.
6561```
6562
6563- [ ] **Step 7: Record this plan's three departures in the spec**
6564
6565Append to `docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md`:
6566
6567```markdown
6568
6569## 18. Notes from plan 1a (2026-10-05)
6570
6571Plan `docs/superpowers/plans/2026-10-05-region-ha-1a-foundations.md` builds
6572the parts of this spec that need no claim. It keeps every property above
6573and changes three mechanisms:
6574
65751. **Exact content by hash, not by S3 version id (§5.6, §5.1).** The store
6576 abstraction has no version-id read and the box role has no
6577 `s3:GetObjectVersion`. Objects are immutable, their key contains their
6578 SHA-256, the manifest records it, and a reader verifies it. A bind
6579 names its record by `record_sha256` in place of `record_version_id`.
65802. **The identity signature (§5.1).** The record carries IMDS's
6581 base64 RSA-SHA256 signature (`/latest/dynamic/instance-identity/signature`),
6582 verified with the regional certificate, in place of the PKCS7 form.
6583 Same document, same signer, no new dependency.
65843. **`snapshot` is a periodic full publish, not a sealed archive (§5.6).**
6585 `hostfiles.SyncDir` stages every difference between the directory and
6586 the manifest. Run on the snapshot interval it gives the `snapshot`
6587 setting; run every ten minutes it is the continuous setting's repair
6588 scan; run before stop evidence it is the release flush. The manifest
6589 therefore has no `base` or `base_seq`, and restore reads one manifest
6590 and the objects it names.
6591
6592Two corrections to the spec, found while planning:
6593
6594- **§5.4's alert for two unstamped entries is dropped.** Static sites and
6595 public address routes run on every box, so every box publishes them as
6596 `home`, unstamped: that is normal, and the edge cannot tell them from
6597 relocatable hosts. The edge reports only two boxes at the same positive
6598 stamp. Plan 1b decides whether the publisher marks relocatable entries
6599 so the edge can alert on those alone.
6600- **§5.1's region check has two halves.** `boxid.Verify` proves which
6601 region a box is in. Whoever uses that compares it with the region it
6602 expects: `eligibleBoxes` takes the placement's AWS region and refuses a
6603 box whose signed region differs, and `termfence.Verify` compares the
6604 observation's region with the signed one. Plan 1b must supply the
6605 expected region from something better than a majority vote of the
6606 unsigned edges files.
6607
6608Two limits to lift in plan 1b: a file over 64 MiB is recorded in the
6609manifest as skipped and is not published (the store takes a whole body in
6610memory), and the third termination-evidence source (Auto Scaling activity
6611history) is not built until spec 4 verifies it.
6612```
6613
6614- [ ] **Step 8: Run the whole gate**
6615
6616Run: `go build ./... && make test`
6617Expected: `gofmt -l .: clean` and every package `ok`. `make test` takes up to thirty minutes. Then the cross-builds CI runs: `GOOS=darwin go build ./... && GOOS=windows go build ./... && GOOS=linux GOARCH=arm64 go build ./...`. Expected: all three succeed.
6618
6619If a package unrelated to this plan fails, do not fix it here and do not skip it: run that package alone, confirm it also fails on `origin/main`, and report both results.
6620
6621- [ ] **Step 9: Commit**
6622
6623```bash
6624git add infra/modules/region-host infra/modules/deployment-buckets docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md
6625git commit -m "infra: a termination queue per region, and box records that storage will not let anyone rewrite
6626
6627Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
6628```
6629
6630- [ ] **Step 10: Push and read CI**
6631
6632```bash
6633git push origin design-region-ha
6634amadan ci status carlos/platform -branch design-region-ha
6635```
6636
6637Expected: exit 0 (pass). Exit 2 is pending: wait and read it again. Exit 1 is a failure: read the log, fix it, and do not ask for the branch to be merged. Merging is Paul's call, through `amadan branch merge`, never a push to `main`.
6638
6639---
6640
6641## What plan 1b picks up
6642
6643Written when `internal/claim` and `internal/hostguard` are on main, after re-reading spec 1 against the claim's then-current revision:
6644
66451. Create names a box: `chooseBox(eligibleBoxes(…))` in the claim's create step (§5.2), with `verified` built from `boxid.Load` and `Verifier.Verify`.
66462. Reconcile's three row cases and the ten-minute sweep (§5.3).
66473. The edges-file pass sets `edgeHost.Stamp` from the claim, and publishes `home` only for the executor (§5.4).
66484. `ExecBackend.Restore` consults `WarmCopyTrusted` under the host guard (§5.5).
66495. `files_durability` and `files_snapshot_interval` on the app manifest, projected onto the route as Seed is, and the agent starting `hostfiles.Run` or a periodic `SyncDir` per host (§5.6), with the files directory's location and skip list for both layouts.
66506. The agent publishing its box record at start, and `carlos ops box bind` (§5.1), with `docs/site/cli.md`.
66517. The `relocating` operation, the release sequence and the pinned recovery set (§5.8).
66528. The multi-box gate and the HA floor (§8).
66539. A streaming put, lifting the 64 MiB limit; the data bucket's conditional-write policy for the files prefix (§6), in place before the publisher is switched on for any host.
665410. Where the expected AWS region for a placement comes from (Task 3), and whether relocatable entries are marked so the edge can alert on unstamped duplicates of those alone (Task 4).
6655
6656## Astra plan round 1: dispositions
6657
6658Findings are in `CODEX-PLAN-REGION-HA-1A-R1.md`. All nineteen are accepted. After them the plan's code was dry-run: built, cross-built and tested, then removed.
6659
6660| # | Finding | Disposition |
6661|---|---|---|
6662| 1 | P1 Task 2: `status` not imported | fixed: the import is a named step |
6663| 2 | P1 Task 1: storage key not bound to the record | fixed: `Load` refuses a record that names another box; `TestLoadRefusesAnotherBoxesRecordUnderThisKey` |
6664| 3 | P1 Tasks 1, 3: a wrong but supported region accepted | fixed: `eligibleBoxes` takes the expected AWS region and compares the signed one; test with two certified regions |
6665| 4 | P1 Task 6: `readStable` follows a swapped ancestor, blocks on a swapped FIFO | fixed: `os.Root`, a non-blocking open, identity check after open; deterministic tests through a seam |
6666| 5 | P1 Task 5: restore writes or deletes through a symlink | fixed: every restore operation goes through an `os.Root`; symlinked ancestors are replaced or left alone, never followed |
6667| 6 | P1 Task 11: queue policy does not exclude other senders | fixed: an explicit Deny for any other source; a post-apply check in the README |
6668| 7 | P2 Task 5: directory and file transitions | fixed: tombstones first, deepest first; obstructions removed; tested both ways into empty and previously restored directories |
6669| 8 | P2 Task 7: overflow leaves a directory unwatched | fixed: overflow marks coverage degraded until `Repair` succeeds; tested |
6670| 9 | P2 Task 7: a dead watcher disables polling | fixed: a closed channel switches the loop to polling for good; `Repair` fails on a dead watcher; tested |
6671| 10 | P2 Task 2: headroom never refreshed on the timer | fixed: `edgesFileWrite(…, refresh)`; the timed pass writes when the file carries headroom |
6672| 11 | P2 Task 8: default replication class not applied | fixed: `SetReplication` is always called; tested |
6673| 12 | P2 Task 3: `chooseBox` used a different spread rule | fixed: runs-the-app or not, then total hosts, then id |
6674| 13 | P2 Task 6: a skipped directory's children tombstoned | fixed: `skipped` checks every ancestor in both passes; tested |
6675| 14 | P2 Task 7: the first scan masked the tests | fixed: `startRun` waits for the first scan; event tests run with scans and polling an hour apart and assert nothing is published before the event |
6676| 15 | P2 Task 6: Review Focus 2 and 3 partly tested | fixed: FIFO, swapped FIFO, swapped ancestor, symlinked directory; the oversize test counts reads and expects none |
6677| 16 | P2 Task 8: the all-or-nothing test never reached the transaction | fixed: duplicate names fail the second insert inside the transaction |
6678| 17 | P2 Task 9: a test passed for the wrong reason | fixed: a found, terminated, all-empty observation against an empty identity |
6679| 18 | P2 Task 4: conflict diagnostics missing | fixed for equal positive stamps (`foldHomes` returns them, `Refresh` logs once). The unstamped alert is dropped as wrong, with the reason, and recorded as a spec correction |
6680| 19 | P3 Task 5: storage protection attributed to Task 11 | fixed: it is plan 1b's, and must precede switching the publisher on |
6681
6682**What the dry run could not check**, and the plan says so at each place: the six tests that need a real inotify instance (the development machine is at its per-user limit of 128), and the IMDS signature against a real box.
6683
6684## Astra plan round 2: dispositions
6685
6686Findings are in `CODEX-PLAN-REGION-HA-1A-R2.md`. Round 2 marked twelve of round 1's nineteen resolved and raised seven. All seven, and the residuals, are accepted. The fixes were dry-run again.
6687
6688| # | Finding | Disposition |
6689|---|---|---|
6690| 1 | P1 Task 6: the walk can block on a directory swapped for a FIFO | fixed: the walk is by descriptor (`walk_unix.go`), entering a directory only through `openat` with `O_DIRECTORY|O_NOFOLLOW|O_NONBLOCK`; `TestSyncDirDoesNotBlockOnADirectorySwappedForAFIFO` |
6691| 2 | P1 Tasks 6, 7: read failures became tombstones | fixed: `fileUnreadable` keeps the manifest entry and is reported; `fileRetry` for a regular file replaced mid-look; a tombstone only on proof. Two tests |
6692| 3 | P1 Task 5: restore not crash-durable | fixed: every directory in which a name changed is synced before `Restore` returns, deepest first, and a failed sync fails the restore; tested through a seam |
6693| 4 | P2 Tasks 6, 7: in-root ancestor symlinks bypass exclusions | fixed by the same descriptor traversal: no symlink is followed anywhere. `TestPublishOneNeverReadsThroughASwappedAncestor` covers a link to outside and a link to an excluded directory, through the queued-event path |
6694| 5 | P2 Task 7: a repair can erase a newer failure | fixed: `failures` and `repaired` counters; tested with a failure injected after the walk |
6695| 6 | P2 Task 4: tie reporting depended on self and on the winner | fixed: `tiedStamps` looks at every file and every pair; tests for `9, 7, 7` and for a tie with this box itself |
6696| 7 | P2 Task 7: two repair tests could pass with their mechanism off | fixed: the scan-count test polls at 40 ms, proves polling while degraded, then requires no scan across eight periods after the repair; the overflow test builds an unwatched directory with no pending kernel event, proves it is unwatched, and only then repairs |
6697| R1-4 | residual: in-root links | finding 4 above |
6698| R1-8 | residual: overflow repair | findings 5 and 7 above |
6699| R1-15 | residual: socket and device cases | added; they skip where the sandbox forbids sockets or the user is not root. The socket test was run outside the sandbox and passes |
6700| R1-18 | residual: diagnostics | finding 6 above |
6701
6702**Still not run:** the seven tests that need a real inotify instance (the machine is at its per-user limit), and the IMDS signature check.
6703
6704## Astra plan round 3: dispositions
6705
6706Findings are in `CODEX-PLAN-REGION-HA-1A-R3.md`. Round 3 marked every round 2 finding and round 1 residual resolved except one part of restore durability, and raised four. All five are accepted and were dry-run. This time the dry run also built and tested Task 5's files alone and Tasks 5 and 6's alone, to check each task's gate passes in order.
6707
6708| # | Finding | Disposition |
6709|---|---|---|
6710| R2-3 | P1 Task 5: a retried restore can skip the sync for a deletion an earlier attempt made | fixed: the directories to sync come from the manifest's paths, live and tombstoned, not from what this call changed; tested by failing the first sync and requiring the retry to perform it |
6711| 1 | P1 Task 6: its tests used `publishOne` and `manifestHas` from Task 7 | fixed: those tests moved to Task 7's `publish_unix_test.go`; Task 6 keeps `SyncDir` and `readStable` tests of the same behaviours |
6712| 2 | P2 Task 5: syncing a directory that vanished under a replacement file | fixed: only directories that still exist as real directories are synced; tested with `a/b/c` becoming `a` |
6713| 3 | P2 Task 7: the held-open test did not need `IN_MODIFY` | fixed: a second write through the same open descriptor must be published |
6714| 4 | P2 Task 7: `-run Watcher` missed one real-inotify test | fixed: the test is renamed so all seven match |
6715
6716## Astra plan round 4: dispositions
6717
6718Findings are in `CODEX-PLAN-REGION-HA-1A-R4.md`. Round 4 marked every outstanding finding resolved, found no task that uses a later task's identifier, and raised one.
6719
6720| # | Finding | Disposition |
6721|---|---|---|
6722| 1 | P2 Task 8: wake state named by the manifest but missing its object read as "no wake state" | fixed: `LoadWake` reports absence only when the manifest has no wake entry; any failure to read a named entry is an error; `TestLoadWakeWithItsObjectMissingIsAnError` |
6723
6724## After the build (2026-10-06)
6725
6726The plan was built task by task with a review after each, then a whole-branch review and one fix wave. Where the code now differs from the text above, the code is right:
6727
6728- **`box_id` is the box's label on every box** (final review, I2). `edgesFileBoxID` and the instance-id read at loop arm were removed. Spec §2 keeps labels for boxes that predate region HA; plan 1b adds instance ids for Auto Scaling boxes from a positive signal, and must write box records under the label.
6729- **Headroom is published only when `CARLOS_REGION_HA` is on** (`headroomFor`), so a one-box region never rewrites its edges file for headroom alone. `parseMemAvailableMiB` lives once, in `headroom.go`.
6730- **`Publisher.Commit` remembers every failed attempt since the last success** and adopts whichever one storage holds, so a lost response is never a false `ErrSuperseded`, even with stages in between or two failures in a row.
6731- **`Restore`** masks modes to permission bits, resolves an entry that is both a live path and the ancestor of another by keeping the higher seq (`RestoreStats.Superseded`), and refuses any entry whose object key, hash or size is not one this package could have written.
6732- **`descend`** refuses `""`, `.` and `..` components. The run loop retries repair and failing scans at most once per poll and logs only transitions; zero `Options` take the defaults.
6733- **`ImportWake`** checks the route exists before it writes.
6734
6735**Add to "What plan 1b picks up":**
6736
673711. One inotify instance per agent rather than per host, and no re-hash of a file whose size, mtime and inode match what was last staged (final review, I4). As built, a box with many continuous hosts exhausts `max_user_instances` and falls back to polling full scans.
673812. A `MaxFileBytes` cap in `StagePlatform`, matching `Restore`'s refusal; a rate limit on `Commit` during a manifest outage.
673913. The box-record writer (`boxid.Publish` at agent start) and `carlos ops box bind`, keyed by label.
674014. Box ids must not contain `.` (`Key("x.bind") == BindKey("x")`); the termination queue is one per box (per `region-host` instance), not one per region.
674115. Before the first apply of the deployment-buckets module: check each deployment bucket for an existing bucket policy (`aws s3api get-bucket-policy`) and the consuming root for another `aws_s3_bucket_policy`. A bucket holds one policy, and this resource replaces it whole.
6742