region HA 2a: edges files carry total memory and disk
Also pins that a quiet region HA box's timed pass rewrites an unchanged body (so its Generation stays inside MaxEdgesAge) and that a box without region HA never does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+110
−2
internal/host/edgesfile.go+5 −0internal/host/edgesfile_test.go+34 −0internal/host/headroom.go+17 −1internal/host/headroom_test.go+52 −1internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv+2 −0
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go| index bc848fa75..b0112d9f1 100644 |
| --- a/internal/host/edgesfile.go |
| +++ b/internal/host/edgesfile.go |
| @@ -151,6 +151,11 @@ type edgeHeadroom struct { |
| MemFreeMiB int64 `json:"mem_free_mib"` |
| DiskFreeMiB int64 `json:"disk_free_mib"` |
| Awake int `json:"awake"` |
| + // MemTotalMiB and DiskTotalMiB are what the free figures are shares |
| + // of (region HA spec 2 §7). Additive and omitted when 0, a reading |
| + // that failed: a reader takes absent and 0 alike, as unknown. |
| + MemTotalMiB int64 `json:"mem_total_mib,omitempty"` |
| + DiskTotalMiB int64 `json:"disk_total_mib,omitempty"` |
| } |
| const capRegionHA1 = "region-ha-1" |
diff --git a/internal/host/edgesfile_test.go b/internal/host/edgesfile_test.go| index 8e771b148..ca4a00771 100644 |
| --- a/internal/host/edgesfile_test.go |
| +++ b/internal/host/edgesfile_test.go |
| @@ -1547,3 +1547,37 @@ func TestEdgesFilePublishesMovesProtocolFloorAndPrev(t *testing.T) { |
| t.Fatalf("no verdict, yet published %s", body) |
| } |
| } |
| + |
| +// Region HA spec 2 §7 reads a box's edges file as stale after 15 minutes |
| +// (boxchoice.MaxEdgesAge), and the gate clears Generation, so a quiet box |
| +// stays placeable only if its timed pass rewrites an UNCHANGED body. With |
| +// headroom published (region HA on) it does, every timed pass (at most six |
| +// minutes apart); an untimed pass with nothing changed does not; and with |
| +// region HA off (no headroom) nothing is ever rewritten. |
| +func TestEdgesFileQuietBoxStaysFreshOnTimedPassesOnlyWithRegionHA(t *testing.T) { |
| + ctx, reg := context.Background(), edgesTestRegistry(t) |
| + seedRoute(t, reg, "app.example.com") |
| + run := func(ident edgesFile) (untimedPuts, timedPuts int) { |
| + st := &putCountingStore{Store: release.NewDirStore(t.TempDir())} |
| + var last []byte |
| + if err := edgesFileWrite(ctx, st, reg, ident, "1.2.3.4", &last, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := edgesFileWrite(ctx, st, reg, ident, "1.2.3.4", &last, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + untimedPuts = st.puts |
| + if err := edgesFileWrite(ctx, st, reg, ident, "1.2.3.4", &last, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + return untimedPuts, st.puts |
| + } |
| + on := edgesFile{Box: "annie", Capabilities: []string{capRegionHA1}, |
| + Headroom: &edgeHeadroom{MemFreeMiB: 100, DiskFreeMiB: 100, Awake: 1, MemTotalMiB: 1000, DiskTotalMiB: 1000}} |
| + if untimed, timed := run(on); untimed != 1 || timed != 2 { |
| + t.Fatalf("region HA on: puts = %d untimed, %d after a timed pass; want 1, 2", untimed, timed) |
| + } |
| + if untimed, timed := run(edgesFile{Box: "annie"}); untimed != 1 || timed != 1 { |
| + t.Fatalf("region HA off: puts = %d untimed, %d after a timed pass; want 1, 1", untimed, timed) |
| + } |
| +} |
diff --git a/internal/host/headroom.go b/internal/host/headroom.go| index 756bbf914..405d3b912 100644 |
| --- a/internal/host/headroom.go |
| +++ b/internal/host/headroom.go |
| @@ -14,7 +14,23 @@ func headroomFor(caps []string, dataDir string, reg *registry.Registry) *edgeHea |
| if len(caps) == 0 { |
| return nil |
| } |
| - return readHeadroom(dataDir, awakeCount(reg)) |
| + h := readHeadroom(dataDir, awakeCount(reg)) |
| + h.MemTotalMiB, h.DiskTotalMiB = capacityMiB(readMemBytes, func() (uint64, uint64, error) { return diskBytes(dataDir) }) |
| + return h |
| +} |
| + |
| +// capacityMiB is the box's total memory and its data volume's size, in |
| +// MiB: the totals region HA spec 2's placement policy judges the free |
| +// figures against (§7). A reading that fails is 0, which the policy reads |
| +// as a box it cannot judge, and so does not place on. |
| +func capacityMiB(mem func() (total, avail *int64), disk func() (total, avail uint64, err error)) (memMiB, diskMiB int64) { |
| + if total, _ := mem(); total != nil && *total > 0 { |
| + memMiB = *total / (1 << 20) |
| + } |
| + if total, _, err := disk(); err == nil { |
| + diskMiB = int64(total / (1 << 20)) |
| + } |
| + return memMiB, diskMiB |
| } |
| // parseMemAvailableMiB returns the MemAvailable line in MiB, or 0. |
diff --git a/internal/host/headroom_test.go b/internal/host/headroom_test.go| index 5292dc0ba..738c42155 100644 |
| --- a/internal/host/headroom_test.go |
| +++ b/internal/host/headroom_test.go |
| @@ -1,6 +1,13 @@ |
| package host |
| -import "testing" |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| func TestParseMemAvailable(t *testing.T) { |
| const meminfo = "MemTotal: 16000000 kB\nMemFree: 500000 kB\nMemAvailable: 2263040 kB\n" |
| @@ -24,3 +31,47 @@ func TestHeadroomOnlyPublishedWithTheCapability(t *testing.T) { |
| t.Fatal("flag on: headroom is nil") |
| } |
| } |
| + |
| +// Region HA spec 2 §7: the totals ride beside the free figures, and a |
| +// failed reading is 0, never a guess. |
| +func TestCapacityMiBReadsTotalsAndZeroesFailures(t *testing.T) { |
| + gib := int64(8 << 30) |
| + mem := func() (*int64, *int64) { return &gib, nil } |
| + disk := func() (uint64, uint64, error) { return 100 << 30, 1 << 30, nil } |
| + if m, d := capacityMiB(mem, disk); m != 8192 || d != 102400 { |
| + t.Fatalf("capacity = %d, %d; want 8192, 102400", m, d) |
| + } |
| + noMem := func() (*int64, *int64) { return nil, nil } |
| + noDisk := func() (uint64, uint64, error) { return 0, 0, errors.New("statfs") } |
| + if m, d := capacityMiB(noMem, noDisk); m != 0 || d != 0 { |
| + t.Fatalf("failed readings = %d, %d; want 0, 0", m, d) |
| + } |
| +} |
| + |
| +func TestHeadroomCarriesTheDataVolumeSize(t *testing.T) { |
| + h := headroomFor([]string{capRegionHA1}, t.TempDir(), edgesTestRegistry(t)) |
| + if h == nil || h.DiskTotalMiB <= 0 { |
| + t.Fatalf("headroom %+v: want the data volume's size", h) |
| + } |
| +} |
| + |
| +func TestEdgesFileWireCarriesHeadroomTotals(t *testing.T) { |
| + s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t) |
| + var last []byte |
| + ident := edgesFile{Box: "annie", BoxID: "i-0abc", Capabilities: []string{"region-ha-1"}, |
| + Headroom: &edgeHeadroom{MemFreeMiB: 2210, DiskFreeMiB: 31200, Awake: 14, MemTotalMiB: 7900, DiskTotalMiB: 61000}} |
| + if err := edgesFilePass(context.Background(), s, reg, ident, "1.2.3.4", &last); err != nil { |
| + t.Fatal(err) |
| + } |
| + body, _, err := s.Get(context.Background(), "control/edges/annie.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var raw map[string]json.RawMessage |
| + if err := json.Unmarshal(body, &raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + if want := `{"mem_free_mib":2210,"disk_free_mib":31200,"awake":14,"mem_total_mib":7900,"disk_total_mib":61000}`; string(raw["headroom"]) != want { |
| + t.Fatalf("headroom = %s, want %s", raw["headroom"], want) |
| + } |
| +} |
diff --git a/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv b/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv| index 7edff1385..f9d0fe7f9 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv |
| @@ -2,7 +2,9 @@ |
| # Task 2: the new internal/lbhealth package (the load balancer health state). Envelope-only: no root-read unit changed or was added, nothing runs as root that did not before, and no registry, /data, release or claim read was added, so the legacy seal stands. |
| # Task 3: box and group roles in the signed roster (internal/hostactors roles.go; Box.Role and Box.Group, Region.Role, Replace lets the role change, automatic enrollment inherits the group's role). Envelope-only: Directory.Replace, Roster.Validate and Directory.enroll are not root-read units, no root-read unit changed or was added, and the legacy seal stands. |
| # Task 4: the placement policy (internal/boxchoice policy.go; headroom totals and the edges file's generation carried through Eligible and edgesfile.Snapshot.BoxChoice). Envelope-only: no root-read unit changed or was added, and the legacy seal stands. |
| +# Task 5: the edges file's headroom carries the box's total memory and data volume size (headroomFor and capacityMiB, internal/host/headroom.go; edgeHeadroom's two additive fields). headroomFor calls the existing readMemBytes and diskBytes and adds no read of its own. Envelope-only: readHeadroom and readMemBytes are unchanged, no root-read unit changed or was added, and the legacy seal stands. |
| @coverage internal/lbhealth ad1211a01a4eb3d02cd8ed95cbff0f8b17b3976ce9c0279ebd2cae2f20c07124 |
| @coverage internal/hostactors 6736698380a247764ada687f2476f89382fc32a1183a50c06afe75967d716eab |
| @coverage internal/boxchoice 1bfd561bcb48b51e60e63f99e086fd44d3b1be2160e28b3ed0253a14bbb9ff66 |
| @coverage internal/edgesfile da42c799d62e065817b5cce4f9a3ead1308dbd379615a85a7b14533c16d7e804 |
| +@coverage internal/host ac55f3fa73d0e0c6d5979e446b2accdd2863b06a93c54d2006f6b547f840abd0 |