eleven / ocho Public

server: multi-box Fleet status aggregation via the mesh's stats channel (#822)

fleet.json's hosts.boxes was hardcoded to the local box alone. Extends
the mesh's existing S3 substrate (never a new box-to-box port, per
mesh.go's own membership design) with a stats side-channel: each mesh
member self-publishes its own {label, awake, asleep, load} snapshot —
band words and counts only, matching status.md's disclosure rule word
for word, the underlying memory ratio dying in the same process that
read it — as a sibling S3 object next to its membership heartbeat
(internal/activator/heartbeat.go's PutStats, meshstats.go's reporter).

deploy/status-publish.sh's build_fleet reads every live peer's
snapshot (mesh_peer_boxes, via the AWS CLI — status-publish.sh already
shells out to it) and merges them into hosts.boxes alongside its own
already-local computation, with hosts.count reflecting both. Absent
mesh config keeps today's single-box output byte-identical (verified:
the existing fleet/fleet-getting-full/fleet-at-capacity fixtures
regenerate identically but for the timestamp).

The whimsical box-label algorithm is ported into Go (meshstats.go),
byte-for-byte matching status-publish.sh's box_label — cross-checked
against a real bash-computed example (TestBoxLabelMatchesBash) since
neither side can compute the OTHER box's label (it hashes a machine id
only that box has), so each box must self-report its own.

infra/prod/status.tf grants the status-publisher IAM user read-only
access to the hibernation bucket's "*-stats/" prefix only — never the
actual instance replicas next to it. NOT YET APPLIED (needs a human
`tofu apply` in infra/prod); until then mesh_peer_boxes finds nothing
to read and hosts stays single-box, same as today.
Co-authored-by: Claude <noreply@anthropic.com>
James Adam c4df276c67773ac64c758dac16bcab493a80ff7d parent b891e6b
10 files changed, +568 −20
  • activator.go +18 −0
  • deploy/status-publish.sh +104 −14
  • infra/prod/status.tf +26 −0
  • internal/activator/heartbeat.go +44 −0
  • internal/activator/heartbeat_test.go +25 −1
  • meshstats.go +186 −0
  • meshstats_test.go +126 −0
  • router.go +10 −0
  • test/status-contract.test.mjs +28 −5
  • test/status-fixtures/fleet-multi-host.json +1 −0
diff --git a/activator.go b/activator.go
index 7883039..13f4411 100644
--- a/activator.go
+++ b/activator.go
@@ -450,6 +450,24 @@ func (a *activatorSvc) hasLiveEntry(host string) bool {
return ok
}
+// awakeCount is this box's own count of genuinely serving hibernating
+// children — actAwake only, not actWaking/actStopping transients — for the
+// Fleet status page's per-box awake/asleep split (meshstats.go). Unlike
+// deploy/status-publish.sh's own `ss`-probing of the LOCAL box (an external
+// process with no other view into the router), this is the activator's
+// authoritative in-memory state — no socket probing needed.
+func (a *activatorSvc) awakeCount() int {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ n := 0
+ for _, e := range a.entries {
+ if e.state == actAwake {
+ n++
+ }
+ }
+ return n
+}
+
func (a *activatorSvc) releaseFunc(e *actEntry) func() {
var once sync.Once
return func() {
diff --git a/deploy/status-publish.sh b/deploy/status-publish.sh
index f3ef584..a0afb74 100755
--- a/deploy/status-publish.sh
+++ b/deploy/status-publish.sh
@@ -121,6 +121,17 @@ CLOUD_ENV=${STATUS_CLOUD_ENV:-/data/cloud.env}
# root-owned/unreadable to the lchat user — then the code defaults below apply,
# which are exactly what controld itself defaults to when the vars are unset.
CONTROL_ENV=${STATUS_CONTROL_ENV:-/data/control.env}
+# The router's own env — read (same lchat user, same file the router loads)
+# only for the mesh's bucket/prefix, so this box can find its live peers'
+# self-published stats (mesh_peer_boxes below). Absent LCHAT_HIBERNATE_BUCKET/
+# _PREFIX here just means the mesh isn't configured on this box — hosts stays
+# a single-box array, byte-identical to before multi-box existed.
+ROUTER_ENV=${STATUS_ROUTER_ENV:-/data/router.env}
+# Staleness cutoff for a peer's self-published stats object — three missed
+# beats, mirroring mesh.go's own meshStaleAfter convention, at the STATS
+# cadence (meshstats.go's meshStatsInterval, 60s) rather than the membership
+# heartbeat's faster one.
+MESH_STATS_STALE_SECONDS=${STATUS_MESH_STATS_STALE_SECONDS:-180}
# Box-load + disk inputs (STATUS_ overrides exist for testability; the dry-run
# uses fixture twins instead and never reads live box state).
MEMINFO=${STATUS_MEMINFO:-/proc/meminfo}
@@ -266,6 +277,12 @@ env_int() {
case "$v" in ''|*[!0-9]*) echo "$3" ;; *) echo "$v" ;; esac
}
+# env_str <file> <VAR> — like env_int but for string values (bucket names,
+# prefixes): no numeric coercion, empty when the file or var is absent.
+env_str() {
+ grep -E "^$2=" "$1" 2>/dev/null | head -n1 | cut -d= -f2- || true
+}
+
# disk_free_gb — whole GB free on the data disk (`df -Pk`: POSIX, and avail
# matches controld's Statfs Bavail). Empty on failure — an unknown contributes
# nothing to the band, never a guess.
@@ -323,6 +340,60 @@ box_label() {
printf '%s-%s' "${names[$n]}" "${actions[$a]}"
}
+# mesh_peer_boxes prints a JSON array of every OTHER live mesh box's
+# self-published stats object — {label, awake, asleep, load} — read from the
+# hibernation bucket's stats side-channel (internal/activator/heartbeat.go's
+# PutStats, a sibling object next to the membership heartbeat every mesh
+# member already writes; meshstats.go computes each box's own snapshot on its
+# own slower cadence). Deliberately NOT a new box-to-box port: this rides the
+# same S3 substrate the mesh already uses for membership, exactly the seam
+# mesh.go's own doc comment anticipates ("a future AWS pool" that "may not
+# even share a network"). Empty array — not an error — whenever this box
+# isn't mesh-configured, has no live peers, or any AWS call fails; a missing
+# peer here just means the Fleet page undercounts by one box, never a
+# publish failure.
+mesh_peer_boxes() {
+ if [ -n "$DRY_RUN" ]; then
+ if [ -f "$FIXDIR/mesh-peers.json" ]; then cat "$FIXDIR/mesh-peers.json"; else echo '[]'; fi
+ return
+ fi
+
+ local bucket region prefix
+ bucket=$(env_str "$ROUTER_ENV" LCHAT_HIBERNATE_BUCKET)
+ prefix=$(env_str "$ROUTER_ENV" LCHAT_HIBERNATE_PREFIX)
+ if [ -z "$bucket" ] || [ -z "$prefix" ]; then echo '[]'; return; fi
+ region=$(env_str "$ROUTER_ENV" LCHAT_HIBERNATE_REGION)
+ region=${region:-eu-west-1}
+
+ local listf="$TMP/mesh-stats-objects.json"
+ if ! aws s3api list-objects-v2 --bucket "$bucket" --prefix "${prefix}-stats/" \
+ --region "$region" --output json > "$listf" 2>/dev/null; then
+ echo '[]'; return
+ fi
+
+ # ISO8601 timestamps sort lexicographically, so a plain string compare
+ # against the cutoff is exact — no date-library parsing needed, and a box
+ # whose clock is off only ever misjudges its OWN staleness, same tradeoff
+ # mesh.go's own reader-vs-S3-clock note accepts for membership.
+ local cutoff peers="[]" key lastmod tmp body
+ cutoff=$(date -u -d "-${MESH_STATS_STALE_SECONDS} seconds" +%Y-%m-%dT%H:%M:%S)
+
+ while IFS=$'\t' read -r key lastmod; do
+ [ -n "$key" ] || continue
+ [ "$lastmod" '>' "$cutoff" ] || continue # stale — treat like a dead mesh member
+ tmp="$TMP/mesh-stat-$(printf '%s' "$key" | tr '/' '_').json"
+ aws s3 cp "s3://$bucket/$key" "$tmp" --region "$region" >/dev/null 2>&1 || continue
+ body=$(cat "$tmp" 2>/dev/null) || continue
+ [ -n "$body" ] || continue
+ peers=$(sqj "SELECT json_insert(
+ json('$(printf '%s' "$peers" | sed "s/'/''/g")'), '\$[#]',
+ json('$(printf '%s' "$body" | sed "s/'/''/g")'))")
+ done < <(sqj "SELECT json_extract(value, '\$.Key') || char(9) || json_extract(value, '\$.LastModified')
+ FROM json_each(json_extract(readfile('$listf'), '\$.Contents'))" 2>/dev/null)
+
+ printf '%s' "$peers"
+}
+
build_fleet() {
local out=$1 now total systemd_n hib awake_json asleep_json versions
now=$(now_iso)
@@ -421,19 +492,19 @@ build_fleet() {
local capacity_level
capacity_level=$(band_word "$band")
- # ---- hosts: v1 is the local box alone --------------------------------------
- # Mesh membership isn't enabled on this box, and the status-publisher's S3
- # key is scoped to the status bucket, so it couldn't list mesh heartbeats
- # anyway — multi-box joins here when the mesh goes live (it will need a read
- # grant on the mesh prefix). Labels are whimsical name-action pairs derived
- # from a HASH of the machine id ("ada-stargazing") — stable per box, and
- # never peer URLs, IPs, hostnames, or the machine id itself. Per-box
- # awake/asleep are the fleet-wide counts (one box = the whole fleet),
- # already public above. The optional region is the operator-set coarse
- # continent + country word pair (see PRIVACY) — absent env, absent key,
- # never guessed; escaped like the version strings so a quote can't break
- # out of the SQL literal.
- local boxes region_kv=""
+ # ---- hosts: this box, plus every other live mesh member --------------------
+ # Labels are whimsical name-action pairs derived from a HASH of the machine
+ # id ("ada-stargazing") — stable per box, and never peer URLs, IPs,
+ # hostnames, or the machine id itself. This box's own awake/asleep are the
+ # fleet-wide counts (one box = the whole fleet when the mesh is off, exactly
+ # as before); a peer's are its OWN self-published snapshot (mesh_peer_boxes
+ # above), already banded/counted-only by the time it left that box — never
+ # re-derived or second-guessed here. The optional region is the operator-set
+ # coarse continent + country word pair (see PRIVACY) — absent env, absent
+ # key, never guessed; escaped like the version strings so a quote can't
+ # break out of the SQL literal. Peers don't carry always_on/region (both
+ # stay local-box-only concepts for now — an honest limitation, not a bug).
+ local boxes peer_boxes region_kv=""
# Resolved here, not at file top: DRY_RUN/argv must be parsed first so the
# dry-run's no-live-probes guard actually guards (and the dry-run fixture's
# direct BOX_REGION= assignment still wins over a re-resolve).
@@ -448,6 +519,14 @@ build_fleet() {
'asleep', json('$asleep_json'),
'load', json('$load_json')$region_kv))")
+ peer_boxes=$(mesh_peer_boxes)
+ boxes=$(sqj "SELECT json_group_array(json(value)) FROM (
+ SELECT value FROM json_each('$(printf '%s' "$boxes" | sed "s/'/''/g")')
+ UNION ALL
+ SELECT value FROM json_each('$(printf '%s' "$peer_boxes" | sed "s/'/''/g")'))")
+ local host_count
+ host_count=$(sqj "SELECT json_array_length('$(printf '%s' "$boxes" | sed "s/'/''/g")')")
+
# Assembled by sqlite json_object: the only interpolated values are
# script-controlled numerals/booleans/band-words and the sqlite-built arrays.
sqj "SELECT json_object(
@@ -459,7 +538,7 @@ build_fleet() {
'awake', json('$awake_json'),
'asleep', json('$asleep_json')),
'hosts', json_object(
- 'count', 1,
+ 'count', $host_count,
'boxes', json('$boxes')),
'capacity', json_object(
'level', '$capacity_level'),
@@ -1170,6 +1249,17 @@ EOF
BOX_REGION="EU · Germany"
echo 42 > "$FIXDIR/replication-lag.txt"
+ # Multi-host fixture (STATUS_DRY_MESH_PEER=1): one other live mesh box's
+ # self-published stats snapshot, exactly the shape internal/activator/
+ # heartbeat.go's PutStats writes and mesh_peer_boxes reads back. Default
+ # (unset) keeps hosts.count at 1, byte-identical to the single-box shape —
+ # the mesh's own "absence changes nothing" convention, mirrored here.
+ if [ "${STATUS_DRY_MESH_PEER:-}" = 1 ]; then
+ cat > "$FIXDIR/mesh-peers.json" <<'EOF'
+[{"label":"turing-orienteering","awake":3,"asleep":1,"load":"busy"}]
+EOF
+ fi
+
# Capacity fixtures (control caps + disk + meminfo): one set per public band
# (default plenty; STATUS_DRY_CAPACITY=getting-full|at-capacity), so the
# abstract derivation is testable end-to-end at every level without touching
diff --git a/infra/prod/status.tf b/infra/prod/status.tf
index a445b44..6f82c80 100644
--- a/infra/prod/status.tf
+++ b/infra/prod/status.tf
@@ -107,6 +107,32 @@ data "aws_iam_policy_document" "status_publisher" {
resources = ["${aws_s3_bucket.status.arn}/*"]
}
+ # Read-only access to the mesh's stats side-channel in the hibernation
+ # bucket (hibernate.tf's prod_instances) — deploy/status-publish.sh's
+ # mesh_peer_boxes reads every live mesh peer's self-published capacity
+ # snapshot (band words + counts only — never raw ratios, meshstats.go) to
+ # build fleet.json's multi-host hosts.boxes array. Scoped to the "*-stats/"
+ # sibling prefix internal/activator/heartbeat.go's PutStats writes to, not
+ # the whole bucket: the publisher must never be able to read or restore
+ # actual (ciphertext) instance replicas, only the small stats objects next
+ # to them. No s3:PutObject/DeleteObject here — this credential only reads.
+ statement {
+ sid = "ListMeshStatsPrefix"
+ actions = ["s3:ListBucket"]
+ resources = [aws_s3_bucket.prod_instances.arn]
+ condition {
+ test = "StringLike"
+ variable = "s3:prefix"
+ values = ["*-stats/*"]
+ }
+ }
+
+ statement {
+ sid = "ReadMeshStatsObjects"
+ actions = ["s3:GetObject"]
+ resources = ["${aws_s3_bucket.prod_instances.arn}/*-stats/*"]
+ }
+
# The books page's expenses section: the publisher asks Cost Explorer for
# the account's month-to-date spend (deploy/status-publish.sh, cached — CE
# bills per request). ce:GetCostAndUsage has no resource-level scoping, so
diff --git a/internal/activator/heartbeat.go b/internal/activator/heartbeat.go
index e433d9c..b609276 100644
--- a/internal/activator/heartbeat.go
+++ b/internal/activator/heartbeat.go
@@ -82,6 +82,50 @@ func (s *HeartbeatStore) key(peer string) string {
return path.Join(s.prefix, encodeHeartbeatKey(peer))
}
+// statsKey is a SIBLING object next to the membership heartbeat, under a
+// separate "<prefix>-stats/" path — never mixed into the heartbeat body
+// itself, so the hot membership path (List, polled every meshHeartbeatInterval
+// for HRW placement) never pays to write or parse a body it doesn't need.
+// Stats are read far less often (deploy/status-publish.sh's 5-minute Fleet
+// publish), by a different reader (bash, via the AWS CLI, not this package).
+func (s *HeartbeatStore) statsKey(peer string) string {
+ return path.Join(s.prefix+"-stats", encodeHeartbeatKey(peer))
+}
+
+// BoxStats is one box's self-published capacity snapshot: band words and
+// counts only, matching the Fleet dashboard's public disclosure rule
+// (status.md — "never remaining slot counts... percentages, or cap values").
+// Awake/Asleep are nil when unknown (never guessed, same convention as
+// fleet.json's own awake/asleep). Load is a band word (comfortable/busy/
+// critical) or "" when unknown — never a raw memory ratio.
+type BoxStats struct {
+ // Label is this box's own whimsical, hash-derived name ("ada-stargazing",
+ // meshstats.go's boxLabel — the exact same algorithm and word lists as
+ // deploy/status-publish.sh's box_label, kept in sync by hand: neither
+ // side can compute the OTHER box's label, since it hashes a machine id
+ // only that box has, so each box must self-report the label, never the
+ // id it was derived from.
+ Label string `json:"label"`
+ Awake *int `json:"awake"`
+ Asleep *int `json:"asleep"`
+ Load string `json:"load,omitempty"`
+}
+
+// PutStats writes this box's stats snapshot. Called on its own slower cadence
+// (meshstats.go), independent of the membership beat.
+func (s *HeartbeatStore) PutStats(ctx context.Context, peer string, stats BoxStats) error {
+ body, err := json.Marshal(stats)
+ if err != nil {
+ return err
+ }
+ _, err = s.client.PutObject(ctx, &awss3.PutObjectInput{
+ Bucket: aws.String(s.bucket),
+ Key: aws.String(s.statsKey(peer)),
+ Body: bytes.NewReader(body),
+ })
+ return err
+}
+
// Put writes (or refreshes) this box's heartbeat. The write itself is the
// timestamp — LastModified advances on every beat.
func (s *HeartbeatStore) Put(ctx context.Context, peer string) error {
diff --git a/internal/activator/heartbeat_test.go b/internal/activator/heartbeat_test.go
index 25ff927..72ef1a2 100644
--- a/internal/activator/heartbeat_test.go
+++ b/internal/activator/heartbeat_test.go
@@ -1,6 +1,9 @@
package activator
-import "testing"
+import (
+ "strings"
+ "testing"
+)
// The key IS the membership record (one LIST, no GETs), so the encoding must
// round-trip any peer URL and reject foreign objects.
@@ -21,3 +24,24 @@ func TestHeartbeatKeyRoundTrip(t *testing.T) {
t.Fatal("garbage key leaf decoded without error")
}
}
+
+// statsKey must live under a SIBLING prefix, never collide with (or nest
+// inside) the membership heartbeat's own key — the hot membership List path
+// must never accidentally pick up a stats object.
+func TestStatsKeySibling(t *testing.T) {
+ s := &HeartbeatStore{prefix: "prod"}
+ peer := "https://box-b.internal"
+
+ hbKey := s.key(peer)
+ statsKey := s.statsKey(peer)
+
+ if statsKey == hbKey {
+ t.Fatal("stats key must differ from the membership heartbeat key")
+ }
+ if strings.HasPrefix(statsKey, s.prefix+"/") {
+ t.Fatalf("stats key %q must not live under the plain membership prefix %q", statsKey, s.prefix)
+ }
+ if !strings.HasPrefix(statsKey, s.prefix+"-stats/") {
+ t.Fatalf("stats key %q should live under %q", statsKey, s.prefix+"-stats/")
+ }
+}
diff --git a/meshstats.go b/meshstats.go
new file mode 100644
index 0000000..359181a
--- /dev/null
+++ b/meshstats.go
@@ -0,0 +1,186 @@
+package main
+
+// Per-box capacity stats for the public Fleet status page's multi-host
+// aggregation (deploy/status-publish.sh). Each mesh member self-publishes its
+// own snapshot (internal/activator/heartbeat.go's PutStats, a sibling object
+// next to the membership heartbeat) on its own slower cadence; box A's
+// status-publish run reads every live peer's snapshot via the AWS CLI to
+// build fleet.json's `hosts.boxes` array, alongside its own already-local
+// computation.
+//
+// Deliberately narrow, matching status.md's disclosure rule word-for-word:
+// only awake/asleep counts and a banded load WORD ever leave this box — the
+// underlying memory ratio dies right here, in the same process that read it,
+// never serialized anywhere even over an authenticated internal channel.
+
+import (
+ "bufio"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "log"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "lchat/internal/activator"
+)
+
+// meshStatsInterval paces the stats PutStats — far slower than the
+// membership heartbeat (meshHeartbeatInterval, 15s): capacity changes slowly,
+// and this is visibility, not coordination.
+const meshStatsInterval = 60 * time.Second
+
+// pctBand mirrors deploy/status-publish.sh's pct_band: 0 below 70%, 1 at
+// 70-90%, 2 above 90%. Both sides must band identically, or the same
+// utilization could publish a different word depending which box you asked.
+func pctBand(pct int) int {
+ switch {
+ case pct < 70:
+ return 0
+ case pct <= 90:
+ return 1
+ default:
+ return 2
+ }
+}
+
+// loadWord mirrors status-publish.sh's load_word.
+func loadWord(band int) string {
+ switch band {
+ case 0:
+ return "comfortable"
+ case 1:
+ return "busy"
+ default:
+ return "critical"
+ }
+}
+
+// meminfoPath is a seam for tests — production always reads the real
+// /proc/meminfo, matching status-publish.sh's own $MEMINFO override convention.
+var meminfoPath = "/proc/meminfo"
+
+// memLoadWord reads /proc/meminfo and bands (MemTotal-MemAvailable)/MemTotal,
+// the same formula and thresholds as status-publish.sh's meminfo_kb/pct_band.
+// ok=false when unreadable (a non-Linux dev machine, a stripped-down
+// container) — unknowns publish as absent, never a guessed word.
+func memLoadWord() (word string, ok bool) {
+ f, err := os.Open(meminfoPath)
+ if err != nil {
+ return "", false
+ }
+ defer f.Close()
+
+ var total, avail int64
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ fields := strings.Fields(sc.Text())
+ if len(fields) < 2 {
+ continue
+ }
+ switch strings.TrimSuffix(fields[0], ":") {
+ case "MemTotal":
+ total, _ = strconv.ParseInt(fields[1], 10, 64)
+ case "MemAvailable":
+ avail, _ = strconv.ParseInt(fields[1], 10, 64)
+ }
+ }
+ if total <= 0 {
+ return "", false
+ }
+ pct := int((total - avail) * 100 / total)
+ return loadWord(pctBand(pct)), true
+}
+
+// boxLabelNames/boxLabelActions are IDENTICAL, in the SAME order, to
+// deploy/status-publish.sh's box_label names[]/actions[] arrays — the same
+// hash must produce the same whimsical label whichever side computes it.
+// Keep the two lists in sync by hand; TestBoxLabelMatchesBash pins one
+// cross-checked example against the real bash script's own output.
+var boxLabelNames = []string{
+ "ada", "alan", "grace", "marie", "nikola", "leonardo", "cleopatra", "homer", "sappho",
+ "darwin", "einstein", "curie", "turing", "hopper", "tesla", "gandalf", "frodo", "sherlock", "watson",
+ "ahab", "ishmael", "gatsby", "scout", "atticus", "hermione", "matilda", "mowgli", "merlin", "arthur",
+ "morgana", "beowulf", "odysseus", "penelope", "achilles", "athena", "apollo", "freya", "thor", "loki",
+ "amelia", "boudicca", "cyrano", "quixote", "sancho", "ophelia", "viola", "rosalind", "puck", "oberon",
+ "titania", "darcy", "pip", "scrooge", "nemo", "phileas", "zorro", "heidi", "galileo", "kepler", "newton",
+ "archimedes", "hypatia", "confucius", "rumi", "frida", "vincent", "wolfgang", "ludwig", "clara",
+}
+
+var boxLabelActions = []string{
+ "stargazing", "juggling", "whistling", "painting", "sailing", "gardening",
+ "baking", "skating", "humming", "doodling", "fishing", "knitting", "strolling", "yodeling",
+ "beekeeping", "fencing", "waltzing", "sketching", "brewing", "climbing", "cartwheeling",
+ "birdwatching", "pondering", "inventing", "exploring", "composing", "conducting",
+ "spelunking", "kayaking", "orienteering", "puppeteering", "tinkering",
+}
+
+// machineIDPath is a seam for tests, matching meminfoPath's convention.
+var machineIDPath = "/etc/machine-id"
+
+// boxLabel derives this box's stable, whimsical, anonymous name — the exact
+// port of status-publish.sh's box_label (sha256 of /etc/machine-id, first 16
+// hex chars split into two 8-char slices indexing names/actions). Falls back
+// to the `hostname` command's output if the machine-id file is unreadable,
+// matching bash's `cat "$idfile" 2>/dev/null || hostname` byte-for-byte: a
+// machine-id file carries no trailing newline, but the `hostname` command's
+// output does, so only the fallback path appends one.
+func boxLabel() string {
+ var seed []byte
+ if data, err := os.ReadFile(machineIDPath); err == nil {
+ seed = data
+ } else {
+ hn, _ := os.Hostname()
+ seed = []byte(hn + "\n")
+ }
+ sum := sha256.Sum256(seed)
+ hexSum := hex.EncodeToString(sum[:])[:16]
+ n, _ := strconv.ParseUint(hexSum[:8], 16, 32)
+ a, _ := strconv.ParseUint(hexSum[8:16], 16, 32)
+ return boxLabelNames[int(n)%len(boxLabelNames)] + "-" + boxLabelActions[int(a)%len(boxLabelActions)]
+}
+
+// computeBoxStats builds this box's publishable snapshot: asleep is derived
+// (every hibernating route not currently awake), never separately probed, so
+// the two counts can never disagree with each other.
+func computeBoxStats(reg *Registry, act *activatorSvc) activator.BoxStats {
+ routes, err := reg.list()
+ if err != nil {
+ log.Printf("mesh: stats: list routes: %v", err)
+ return activator.BoxStats{}
+ }
+ hib := 0
+ for _, r := range routes {
+ if r.Kind == "instance" && r.Hibernate {
+ hib++
+ }
+ }
+ awake := act.awakeCount()
+ asleep := hib - awake
+ load, _ := memLoadWord() // "" (omitted) when unknown
+
+ return activator.BoxStats{Label: boxLabel(), Awake: &awake, Asleep: &asleep, Load: load}
+}
+
+// startStatsReporter runs alongside the mesh (wired only when mesh != nil,
+// router.go): computes and PutStats this box's snapshot on meshStatsInterval,
+// for as long as the process runs — like the router's other background
+// loops (e.g. its registry-watcher poll), it has no graceful stop; the
+// process exiting is what stops it.
+func startStatsReporter(store *activator.HeartbeatStore, self string, reg *Registry, act *activatorSvc) {
+ report := func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ if err := store.PutStats(ctx, self, computeBoxStats(reg, act)); err != nil {
+ log.Printf("mesh: stats: publish failed: %v", err)
+ }
+ }
+ report() // one synchronous publish so a box's very first status read isn't stale
+ go func() {
+ for range time.Tick(meshStatsInterval) {
+ report()
+ }
+ }()
+}
diff --git a/meshstats_test.go b/meshstats_test.go
new file mode 100644
index 0000000..2e95d82
--- /dev/null
+++ b/meshstats_test.go
@@ -0,0 +1,126 @@
+package main
+
+// Tests for meshstats.go: the band thresholds must match
+// deploy/status-publish.sh's pct_band/load_word bit-for-bit (both sides
+// compute the same word for the same box, whichever process does it), the
+// whimsical label algorithm must match status-publish.sh's box_label
+// bit-for-bit too (TestBoxLabelMatchesBash pins a real bash-computed
+// example), and computeBoxStats must derive asleep from awake (never probe
+// them separately, so the two counts can't disagree).
+
+import (
+ "os"
+ "path/filepath"
+ "strconv"
+ "testing"
+)
+
+func TestPctBandThresholds(t *testing.T) {
+ cases := []struct {
+ pct int
+ want int
+ }{
+ {0, 0}, {69, 0}, {70, 1}, {90, 1}, {91, 2}, {100, 2},
+ }
+ for _, c := range cases {
+ if got := pctBand(c.pct); got != c.want {
+ t.Errorf("pctBand(%d) = %d, want %d", c.pct, got, c.want)
+ }
+ }
+}
+
+func TestLoadWord(t *testing.T) {
+ if loadWord(0) != "comfortable" || loadWord(1) != "busy" || loadWord(2) != "critical" {
+ t.Fatal("loadWord vocabulary must match status-publish.sh's load_word exactly")
+ }
+}
+
+func TestMemLoadWord(t *testing.T) {
+ dir := t.TempDir()
+ write := func(total, avail int64) {
+ content := ""
+ if total > 0 {
+ content += "MemTotal: " + strconv.FormatInt(total, 10) + " kB\n"
+ }
+ if avail > 0 {
+ content += "MemAvailable: " + strconv.FormatInt(avail, 10) + " kB\n"
+ }
+ if err := os.WriteFile(filepath.Join(dir, "meminfo"), []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ orig := meminfoPath
+ meminfoPath = filepath.Join(dir, "meminfo")
+ defer func() { meminfoPath = orig }()
+
+ write(1000, 500) // 50% used -> comfortable
+ if w, ok := memLoadWord(); !ok || w != "comfortable" {
+ t.Fatalf("50%% used: got %q, %v", w, ok)
+ }
+ write(1000, 250) // 75% used -> busy
+ if w, ok := memLoadWord(); !ok || w != "busy" {
+ t.Fatalf("75%% used: got %q, %v", w, ok)
+ }
+ write(1000, 50) // 95% used -> critical
+ if w, ok := memLoadWord(); !ok || w != "critical" {
+ t.Fatalf("95%% used: got %q, %v", w, ok)
+ }
+
+ meminfoPath = filepath.Join(dir, "does-not-exist")
+ if _, ok := memLoadWord(); ok {
+ t.Fatal("unreadable meminfo should report ok=false, never guess")
+ }
+}
+
+// Cross-checked against the real bash script: running status-publish.sh's own
+// box_label() function against the same seed input produces the identical
+// "phileas-juggling" — pinning that the two word lists and hash-index math
+// haven't drifted apart.
+func TestBoxLabelMatchesBash(t *testing.T) {
+ dir := t.TempDir()
+ idFile := filepath.Join(dir, "machine-id")
+ if err := os.WriteFile(idFile, []byte("test-machine-id-fixture-12345"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ orig := machineIDPath
+ machineIDPath = idFile
+ defer func() { machineIDPath = orig }()
+
+ if got, want := boxLabel(), "phileas-juggling"; got != want {
+ t.Fatalf("boxLabel() = %q, want %q (bash's box_label for the same seed)", got, want)
+ }
+}
+
+// computeBoxStats must derive asleep = hibernating total - awake, never
+// probe them independently, and count only actAwake (not a mid-wake/mid-stop
+// transient) as "awake".
+func TestComputeBoxStats(t *testing.T) {
+ reg, err := openRegistry(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatalf("open registry: %v", err)
+ }
+ for _, h := range []string{"a.11ch.at", "b.11ch.at", "c.11ch.at"} {
+ if err := reg.upsert(Route{Host: h, Kind: "instance", Socket: "/tmp/" + h + ".sock", Hibernate: true}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // A non-hibernating route must not count toward the hibernating total.
+ if err := reg.upsert(Route{Host: "systemd.11ch.at", Kind: "instance", Socket: "/tmp/x.sock", Hibernate: false}); err != nil {
+ t.Fatal(err)
+ }
+
+ act := newActivator(hibernateConfig{Bucket: "test-bucket"}, &fakeBackend{}, reg)
+ act.entries["a.11ch.at"] = &actEntry{host: "a.11ch.at", state: actAwake}
+ act.entries["b.11ch.at"] = &actEntry{host: "b.11ch.at", state: actWaking} // not yet awake
+
+ stats := computeBoxStats(reg, act)
+ if stats.Label == "" {
+ t.Fatal("Label must be populated")
+ }
+ if stats.Awake == nil || *stats.Awake != 1 {
+ t.Fatalf("Awake = %v, want 1 (only actAwake counts)", stats.Awake)
+ }
+ if stats.Asleep == nil || *stats.Asleep != 2 {
+ t.Fatalf("Asleep = %v, want 2 (3 hibernating - 1 awake)", stats.Asleep)
+ }
+}
diff --git a/router.go b/router.go
index 89556b3..ba2404a 100644
--- a/router.go
+++ b/router.go
@@ -19,6 +19,7 @@ import (
"golang.org/x/crypto/acme/autocert"
+ "lchat/internal/activator"
"lchat/internal/bot"
)
@@ -259,6 +260,15 @@ func runRouter(args []string) {
mesh.start()
log.Printf("router: activator mesh on — this box is %s (heartbeats s3://%s/%s every %s, peers stale after %s, placement scoped to %v)",
mesh.self, hc.Bucket, hc.meshPrefix(), mesh.interval, mesh.staleAfter, mesh.domains)
+
+ // This box's own capacity snapshot for the Fleet status page's
+ // multi-host aggregation (meshstats.go) — a separate S3 client on
+ // the same store, since meshSvc's is interface-typed for tests.
+ if statsStore, err := activator.NewHeartbeatStore(context.Background(), hc.Bucket, hc.meshPrefix(), hc.Region); err != nil {
+ log.Printf("router: mesh stats reporter: %v — this box's capacity snapshot won't publish", err)
+ } else {
+ startStatsReporter(statsStore, mesh.self, reg, act)
+ }
}
act.start()
log.Printf("router: hibernation active (bucket %s, prefix %q, idle %s) — flagged instances wake on demand",
diff --git a/test/status-contract.test.mjs b/test/status-contract.test.mjs
index 64fdb78..f6b3a29 100644
--- a/test/status-contract.test.mjs
+++ b/test/status-contract.test.mjs
@@ -40,6 +40,9 @@ const fleet = JSON.parse(read("test/status-fixtures/fleet.json"));
// them) — the abstract derivation is pinned at every level.
const fleetGF = JSON.parse(read("test/status-fixtures/fleet-getting-full.json"));
const fleetAC = JSON.parse(read("test/status-fixtures/fleet-at-capacity.json"));
+// STATUS_DRY_MESH_PEER=1 regenerates this one — a second, live mesh peer's
+// self-published stats merged into hosts.boxes alongside the local box.
+const fleetMH = JSON.parse(read("test/status-fixtures/fleet-multi-host.json"));
const books = JSON.parse(read("test/status-fixtures/books.json"));
const booksDown = JSON.parse(read("test/status-fixtures/books-unavailable.json"));
const booksNoExp = JSON.parse(read("test/status-fixtures/books-no-expenses.json"));
@@ -388,7 +391,10 @@ await test("run fixture folds server_type + grid to the asserted watts→kg math
// capacity fails, so a precise number (remaining slots, GB, percentages, cap
// values) can't ship by accident, and the level/load words themselves must be
// digit-free vocabulary entries.
-const fleetShapes = [["fleet", fleet], ["fleet-getting-full", fleetGF], ["fleet-at-capacity", fleetAC]];
+const fleetShapes = [
+ ["fleet", fleet], ["fleet-getting-full", fleetGF], ["fleet-at-capacity", fleetAC],
+ ["fleet-multi-host", fleetMH],
+];
await test("hosts + capacity carry only the allowed abstract schema", () => {
for (const [name, f] of fleetShapes) {
assert.deepEqual(Object.keys(f.capacity).sort(), ["level"], name + ": capacity keys");
@@ -400,14 +406,19 @@ await test("hosts + capacity carry only the allowed abstract schema", () => {
// region is optional (omitted when the operator sets none). When present
// it must be a coarse public area — continent + country — and must never
// read like an IP or hostname (no dots-and-digits / label.tld shapes).
- const base = ["always_on", "asleep", "awake", "label", "load"];
+ // always_on is likewise optional: a REMOTE mesh peer's self-published
+ // snapshot (mesh_peer_boxes) never carries it — that's a local-box-only
+ // concept — so a peer's key set is the "required" set on its own,
+ // while the LOCAL box always adds always_on.
+ const required = ["asleep", "awake", "label", "load"];
+ const keys = Object.keys(b).sort();
+ const allowed = required.concat(["always_on", "region"]).sort();
+ assert.ok(required.every((k) => keys.includes(k)), name + ": box missing a required key: " + keys);
+ assert.ok(keys.every((k) => allowed.includes(k)), name + ": box carries a disallowed key: " + keys);
if ("region" in b) {
- assert.deepEqual(Object.keys(b).sort(), base.concat("region"), name + ": box keys");
assert.equal(typeof b.region, "string", name + ": region is a string");
assert.ok(!/\d+\.\d+|[a-z0-9-]+\.[a-z]{2,}/i.test(b.region),
name + ": region reads like an IP/hostname: " + b.region);
- } else {
- assert.deepEqual(Object.keys(b).sort(), base, name + ": box keys");
}
// Labels are anonymous ordinals — never a hostname, IP, or URL.
assert.match(b.label, /^[a-z]+-[a-z]+$/, name + ": box label must be a name-action pair");
@@ -420,6 +431,18 @@ await test("hosts + capacity carry only the allowed abstract schema", () => {
}
});
+// The multi-host fixture specifically: a second, live mesh peer merges into
+// hosts.boxes without displacing the local box, and count reflects both.
+await test("multi-host fixture merges the local box with a live mesh peer", () => {
+ assert.equal(fleetMH.hosts.count, 2, "hosts.count reflects both boxes");
+ assert.equal(fleetMH.hosts.boxes.length, 2);
+ const [local, peer] = fleetMH.hosts.boxes;
+ assert.ok("always_on" in local, "the local box still reports always_on");
+ assert.ok(!("always_on" in peer), "a remote peer never reports always_on (a local-box-only concept)");
+ assert.ok(!("region" in peer), "a remote peer never reports region in this fixture");
+ assert.notEqual(local.label, peer.label, "the two boxes must have distinct labels");
+});
+
// Each committed fixture set derives its band — pins the 70/90 thresholds and
// the disk-floor rule in the generator (see dry_run's capacity fixtures).
await test("capacity fixtures derive plenty / getting-full / at-capacity", () => {
diff --git a/test/status-fixtures/fleet-multi-host.json b/test/status-fixtures/fleet-multi-host.json
new file mode 100644
index 0000000..ff1ff87
--- /dev/null
+++ b/test/status-fixtures/fleet-multi-host.json
@@ -0,0 +1 @@
+{"generated_at":"2026-07-13T13:37:03Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"hosts":{"count":2,"boxes":[{"label":"hypatia-sketching","always_on":2,"awake":2,"asleep":2,"load":"comfortable","region":"EU · Germany"},{"label":"turing-orienteering","awake":3,"asleep":1,"load":"busy"}]},"capacity":{"level":"plenty"},"versions":[{"version":"v100","count":3},{"version":"v101","count":2},{"version":"unknown","count":1}],"services":{"home":true,"push":true,"cloud":true,"connect":false},"replication":{"healthy":true,"lag_seconds":42}}