| new file mode 100644 |
| index 000000000..ec7d5d65e |
| --- /dev/null |
| +++ b/internal/status/snapshot.go |
| @@ -0,0 +1,227 @@ |
| +// Package status is the fleet status page's writer half: per-box snapshots |
| +// that carry only what the public commitments allow — aggregate counts and |
| +// band words, never ratios, hostnames, or machine ids. Privacy is enforced |
| +// HERE, in the process that does the counting and banding, not downstream — |
| +// a merge step or a page template can only leak what this package already |
| +// let through. |
| +package status |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/json" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Probes carries everything CollectBox may not read from the registry — |
| +// each nil means "unknown", and unknown publishes as null, never a guess. |
| +type Probes struct { |
| + MachineID string // hashed into the label; never serialized |
| + Region string // operator-set, coarse; "" omits the field |
| + MemFrac *float64 // 0..1 memory utilization |
| + DiskFrac *float64 // 0..1 proximity to the disk floor |
| + Listening func(sock string) *bool // kernel socket table read; nil fn = unknown |
| + LagSecs *int64 // seconds since litestream last progressed |
| +} |
| + |
| +// RouteCounts is aggregate-only: no host, no socket path, just how many. |
| +// Awake/Asleep stay null together whenever the socket table can't be read in |
| +// full — partial knowledge published as a count would read as more certain |
| +// than it is. |
| +type RouteCounts struct { |
| + Static, Resident, Hibernating int |
| + Awake, Asleep *int // null when the socket table is unreadable |
| +} |
| + |
| +// Replication summarizes litestream's furthest-behind lag across this box's |
| +// routes, banded to a healthy bool rather than the raw seconds — the raw |
| +// number is still carried for the console's private view, but the public |
| +// commitment is only ever "healthy" or "not". |
| +type Replication struct { |
| + Healthy *bool `json:"healthy"` |
| + LagSecs *int64 `json:"lag_seconds"` |
| +} |
| + |
| +// Box is one box's self-report: what CollectBox produces and WriteBox |
| +// serializes. Every field here has already had its needles pulled — there is |
| +// no hostname, machine id, or raw ratio anywhere in this type. |
| +type Box struct { |
| + GeneratedAt time.Time `json:"generated_at"` |
| + Label string `json:"label"` |
| + Region string `json:"region,omitempty"` |
| + Routes RouteCounts `json:"routes"` |
| + Load *string `json:"load"` // comfortable|busy|critical |
| + Capacity *string `json:"capacity"` // plenty|getting-full|at-capacity |
| + Replication Replication `json:"replication"` |
| +} |
| + |
| +// replHealthyWithin is the lag under which replication counts as healthy. |
| +// 15 minutes: litestream's own replicate interval is seconds, so anything |
| +// beyond a quarter hour behind means the replica loop has actually stalled |
| +// (network partition, disk full, credential rot) rather than just being |
| +// mid-batch — a threshold loose enough not to flap on ordinary jitter, tight |
| +// enough to catch a real outage inside one on-call check-in. |
| +const replHealthyWithin = 15 * time.Minute |
| + |
| +// loadWords and capacityWords band a fraction into a public word — never a |
| +// number, per the fleet status page's public commitments (see the plan's |
| +// privacy rules). The three words are ordered low, medium, high. |
| +var loadWords = [3]string{"comfortable", "busy", "critical"} |
| +var capacityWords = [3]string{"plenty", "getting-full", "at-capacity"} |
| + |
| +// bandBoundaryLow and bandBoundaryHigh are the two cut points shared by every |
| +// banded fraction: below the low boundary is the first word, at-or-below the |
| +// high boundary is the second, above it the third. Named constants so the |
| +// 0.70/0.90 split — a decision the operator made once — isn't a magic number |
| +// re-typed at each call site. |
| +const ( |
| + bandBoundaryLow = 0.70 |
| + bandBoundaryHigh = 0.90 |
| +) |
| + |
| +// bandWord turns a possibly-unknown fraction into one of three words. nil in |
| +// (unknown) means nil out — CollectBox must never invent a band for a |
| +// quantity it never measured. |
| +func bandWord(f *float64, words [3]string) *string { |
| + if f == nil { |
| + return nil |
| + } |
| + var w string |
| + switch { |
| + case *f < bandBoundaryLow: |
| + w = words[0] |
| + case *f <= bandBoundaryHigh: |
| + w = words[1] |
| + default: |
| + w = words[2] |
| + } |
| + return &w |
| +} |
| + |
| +// labelNames and labelActions are the two word lists Label indexes into — |
| +// Eleven's "ada-stargazing" shape: a first name and a gerund action, picked |
| +// to read as pleasant and give away nothing about the machine they name. |
| +// Deliberately small const slices (not generated, not fetched) so the label |
| +// space and its stability are auditable at a glance. |
| +var labelNames = [40]string{ |
| + "ada", "grace", "alan", "linus", "margaret", "dennis", "barbara", "ken", |
| + "radia", "vint", "tim", "hedy", "kathleen", "edsger", "donald", "john", |
| + "claude", "marvin", "shafi", "leslie", "whitfield", "adi", "ron", "sophie", |
| + "joan", "frances", "betty", "jean", "mary", "annie", "katherine", "dorothy", |
| + "ada", "elena", "priya", "yuki", "noor", "amara", "leo", "iris", |
| +} |
| +var labelActions = [40]string{ |
| + "stargazing", "birdwatching", "gardening", "sketching", "kayaking", |
| + "orienteering", "foraging", "woodworking", "beekeeping", "cycling", |
| + "origami", "brewing", "quilting", "hiking", "juggling", "puzzling", |
| + "weaving", "sailing", "climbing", "cartography", "fermenting", "welding", |
| + "knitting", "pottery", "birding", "spelunking", "kitesurfing", "canoeing", |
| + "tinkering", "letterpress", "printmaking", "bookbinding", "beachcombing", |
| + "fossicking", "aquascaping", "papermaking", "clockmaking", "lockpicking", |
| + "stenciling", "marquetry", |
| +} |
| + |
| +// Label derives a stable, opaque "name-gerund" label from a machine id — the |
| +// same shape as Eleven's ada-stargazing box names. sha256 rather than a |
| +// weaker hash: the machine id must never be recoverable from the label by |
| +// brute force over any plausible id space (hostnames, instance ids), and the |
| +// label is public. |
| +func Label(machineID string) string { |
| + sum := sha256.Sum256([]byte(machineID)) |
| + name := labelNames[int(sum[0])%len(labelNames)] |
| + action := labelActions[int(sum[1])%len(labelActions)] |
| + return name + "-" + action |
| +} |
| + |
| +// CollectBox turns a box's live registry rows and probe results into the |
| +// public Box for that box. It is the ONLY place fractions get banded to |
| +// words, sockets get counted instead of named, and the machine id gets |
| +// hashed instead of shipped — every value this function returns is already |
| +// safe to publish as-is. |
| +func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| + var counts RouteCounts |
| + var awakeKnown = true |
| + awake, asleep := 0, 0 |
| + |
| + for _, r := range routes { |
| + switch { |
| + case r.Kind == "static": |
| + counts.Static++ |
| + case r.Hibernate: |
| + counts.Hibernating++ |
| + if p.Listening == nil { |
| + awakeKnown = false |
| + continue |
| + } |
| + isListening := p.Listening(r.Socket) |
| + if isListening == nil { |
| + awakeKnown = false |
| + continue |
| + } |
| + if *isListening { |
| + awake++ |
| + } else { |
| + asleep++ |
| + } |
| + default: |
| + counts.Resident++ |
| + } |
| + } |
| + if awakeKnown && p.Listening != nil { |
| + counts.Awake, counts.Asleep = &awake, &asleep |
| + } |
| + |
| + // Capacity is the worst-of DiskFrac/MemFrac — whichever known fraction is |
| + // closer to full drives the word, because a box that's fine on memory but |
| + // out of disk is still a box about to have a bad day. |
| + var worst *float64 |
| + switch { |
| + case p.DiskFrac != nil && p.MemFrac != nil: |
| + if *p.DiskFrac >= *p.MemFrac { |
| + worst = p.DiskFrac |
| + } else { |
| + worst = p.MemFrac |
| + } |
| + case p.DiskFrac != nil: |
| + worst = p.DiskFrac |
| + case p.MemFrac != nil: |
| + worst = p.MemFrac |
| + } |
| + |
| + var healthy *bool |
| + if p.LagSecs != nil { |
| + h := *p.LagSecs < int64(replHealthyWithin.Seconds()) |
| + healthy = &h |
| + } |
| + |
| + return Box{ |
| + GeneratedAt: now, |
| + Label: Label(p.MachineID), |
| + Region: p.Region, |
| + Routes: counts, |
| + Load: bandWord(p.MemFrac, loadWords), |
| + Capacity: bandWord(worst, capacityWords), |
| + Replication: Replication{Healthy: healthy, LagSecs: p.LagSecs}, |
| + } |
| +} |
| + |
| +// WriteBox marshals a Box indented (human-diffable in the bucket) and Puts it |
| +// at its label-derived key with ifMatch "", per this task's contract. Note |
| +// for whoever wires the recurring publish loop (Task 3+): release.Store's |
| +// ifMatch "" is create-only (If-None-Match: *) per store.go, so a second |
| +// WriteBox for the same label — expected every 5 minutes per the plan — will |
| +// return ErrCASConflict once the object exists. Overwriting on a schedule |
| +// needs either a Get-then-Put-with-etag retry loop or a Delete-then-Put |
| +// around this call; WriteBox itself stays a thin, single-attempt Put so its |
| +// behavior is exactly what Task 2's contract specifies. |
| +func WriteBox(ctx context.Context, s release.Store, b Box) error { |
| + body, err := json.MarshalIndent(b, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + _, err = s.Put(ctx, "status/boxes/"+b.Label+".json", body, "") |
| + return err |
| +} |