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", } // boxRegion resolves this box's coarse region for peer publishing, straight // from LCHAT_BOX_REGION — an explicit operator override only. Unlike // status-publish.sh's own box_region() (which prefers auto-detecting via // Hetzner/AWS instance metadata for the LOCAL box, falling back to a hand-set // override), a mesh peer publishes here from the Go binary, which has no // metadata-probing code of its own — so this box's operator sets the string // once in its router.env, same coarse "continent · country" shape, and it // rides through status-publish.sh's mesh_peer_boxes unchanged (that merge is // already a raw passthrough of the whole published object). Empty env means // omitted, never guessed. func boxRegion() string { return os.Getenv("LCHAT_BOX_REGION") } // 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. // // hib only counts routes THIS box currently HRW-owns (mesh.owns — nil mesh, // e.g. in tests that don't exercise the mesh, counts everything). Without // this filter, a mesh-replicated route is known to (and counted by) every // peer it's been seeded onto, so two boxes would each report it — correctly, // from each one's own local blind spot — and the Fleet page's per-box rows // would visibly overlap (the same instance reads as "asleep" on more than one // host, with no way to tell it's the same instance). Filtering to the sole // current owner makes every box's row a genuine, non-overlapping partition of // the fleet: an out-of-scope route (mesh.owns always true for those, since // only one box was ever going to have its row anyway) still counts locally, // exactly as before mesh placement existed. func computeBoxStats(reg *Registry, act *activatorSvc, mesh *meshSvc) 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 { continue } if mesh != nil && !mesh.owns(r.Host) { continue // a live peer owns it — that box's own snapshot counts it, not this one } hib++ } awake := act.awakeCount() asleep := hib - awake load, _ := memLoadWord() // "" (omitted) when unknown return activator.BoxStats{Label: boxLabel(), Awake: &awake, Asleep: &asleep, Load: load, Region: boxRegion()} } // 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, mesh *meshSvc) { report := func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := store.PutStats(ctx, self, computeBoxStats(reg, act, mesh)); 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() } }() }