| new file mode 100644 |
| index 000000000..2eb750047 |
| --- /dev/null |
| +++ b/internal/status/fleet.go |
| @@ -0,0 +1,384 @@ |
| +package status |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "log" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// FleetApp is one opted-in app's public row — name, count, health, and the |
| +// release it's running. Nothing here exists for an app that hasn't set |
| +// StatusNamed; see BuildFleet's apps grouping. |
| +type FleetApp struct { |
| + Name string `json:"name"` |
| + Instances int `json:"instances"` |
| + Healthy bool `json:"healthy"` |
| + Channel string `json:"channel"` |
| + Version string `json:"version"` // short sha as stored on the route |
| +} |
| + |
| +// FleetBox is a Box's public row inside the merged feed: everything a Box |
| +// carries except its own Replication (which only ever votes into the |
| +// fleet-wide Replication, never appears per-box) and its own Capacity |
| +// (same — it only ever votes into Fleet.Capacity), plus Stale, which only |
| +// exists at merge time since a lone Box has no "how long ago" to compare |
| +// against. |
| +type FleetBox struct { |
| + Label string `json:"label"` |
| + Region string `json:"region,omitempty"` |
| + Load *string `json:"load"` |
| + Stale bool `json:"stale"` |
| + Routes RouteCounts `json:"routes"` |
| +} |
| + |
| +// Fleet is the merged feed: status/fleet.json. Every field here is either a |
| +// fleet-wide aggregate, a band word, or behind an app's own opt-in — see the |
| +// design spec's "rule for new fields". |
| +type Fleet struct { |
| + GeneratedAt time.Time `json:"generated_at"` |
| + Boxes []FleetBox `json:"boxes"` |
| + Instances map[string]*int `json:"instances"` // total/static/resident/hibernating/awake/asleep |
| + Capacity struct { |
| + Level *string `json:"level"` |
| + } `json:"capacity"` |
| + Apps []FleetApp `json:"apps"` |
| + AppsUnlisted int `json:"apps_unlisted"` |
| + Services []struct { |
| + Name string `json:"name"` |
| + Up bool `json:"up"` |
| + } `json:"services"` |
| + Replication Replication `json:"replication"` |
| +} |
| + |
| +// capacityRank orders the capacity band words worst-last, so "worst of a |
| +// set of boxes" is just "the box with the highest rank" — built off |
| +// snapshot.go's capacityWords so the two never drift apart into disagreeing |
| +// about which word beats which. |
| +var capacityRank = map[string]int{ |
| + capacityWords[0]: 0, |
| + capacityWords[1]: 1, |
| + capacityWords[2]: 2, |
| +} |
| + |
| +// intPtr is the one-line "give me a pointer to this value" a lot of the |
| +// Instances map construction below needs — Go has no address-of-literal |
| +// operator, so every *int field needs a local to take the address of |
| +// without this helper. |
| +func intPtr(v int) *int { return &v } |
| + |
| +// BuildFleet merges every status/boxes/*.json (stale = GeneratedAt older |
| +// than 3×period), folds the local registry's routes into apps/services, |
| +// and never invents a number a snapshot didn't carry. |
| +func BuildFleet(ctx context.Context, s release.Store, routes []registry.Route, now time.Time, period time.Duration) (*Fleet, error) { |
| + boxes, err := loadBoxes(ctx, s) |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| + f := &Fleet{GeneratedAt: now} |
| + |
| + // stale tracks each box's staleness in lockstep with boxes — computed |
| + // once here and reused by both the per-box Stale field and the |
| + // worst-of/replication aggregates below, which must vote only using |
| + // fresh boxes. |
| + stale := make([]bool, len(boxes)) |
| + for i, b := range boxes { |
| + stale[i] = now.Sub(b.GeneratedAt) > 3*period |
| + f.Boxes = append(f.Boxes, FleetBox{ |
| + Label: b.Label, |
| + Region: b.Region, |
| + Load: b.Load, |
| + Stale: stale[i], |
| + Routes: b.Routes, |
| + }) |
| + } |
| + |
| + f.Instances = sumInstances(boxes) |
| + f.Capacity.Level = worstCapacity(boxes) |
| + f.Replication = worstReplication(boxes, stale) |
| + |
| + apps, unlisted := buildApps(routes) |
| + f.Apps = apps |
| + f.AppsUnlisted = unlisted |
| + f.Services = buildServices(routes) |
| + |
| + return f, nil |
| +} |
| + |
| +// loadBoxes reads every status/boxes/*.json snapshot it can. A snapshot |
| +// that fails to Get or fails to unmarshal is logged and skipped — one box's |
| +// bad write must never take the whole fleet page down, per the plan's |
| +// "corrupt snapshot = logged skip, never fatal" rule. |
| +func loadBoxes(ctx context.Context, s release.Store) ([]Box, error) { |
| + keys, err := s.List(ctx, "status/boxes/") |
| + if err != nil { |
| + return nil, err |
| + } |
| + var boxes []Box |
| + for _, key := range keys { |
| + body, _, err := s.Get(ctx, key) |
| + if err != nil { |
| + log.Printf("status: fleet: skipping %s: read failed: %v", key, err) |
| + continue |
| + } |
| + var b Box |
| + if err := json.Unmarshal(body, &b); err != nil { |
| + log.Printf("status: fleet: skipping %s: corrupt snapshot: %v", key, err) |
| + continue |
| + } |
| + boxes = append(boxes, b) |
| + } |
| + return boxes, nil |
| +} |
| + |
| +// sumInstances adds each RouteCounts component across every box — stale |
| +// boxes included, since staleness is a "trust this less" signal rendered |
| +// loudly per-box, not a reason to silently drop a box's last-known counts |
| +// from the fleet total. Static/Resident/Hibernating are plain ints on Box |
| +// (always known), so their sums are always known too. Awake/Asleep are |
| +// *int on Box (null when a box's socket table was unreadable), so per the |
| +// plan's null-propagation rule, ANY box contributing null nulls the whole |
| +// summed field — a partial sum would read as more certain than it is. |
| +func sumInstances(boxes []Box) map[string]*int { |
| + var totalStatic, totalResident, totalHibernating, awakeSum, asleepSum int |
| + awakeKnown, asleepKnown := true, true |
| + for _, b := range boxes { |
| + totalStatic += b.Routes.Static |
| + totalResident += b.Routes.Resident |
| + totalHibernating += b.Routes.Hibernating |
| + if b.Routes.Awake == nil { |
| + awakeKnown = false |
| + } else { |
| + awakeSum += *b.Routes.Awake |
| + } |
| + if b.Routes.Asleep == nil { |
| + asleepKnown = false |
| + } else { |
| + asleepSum += *b.Routes.Asleep |
| + } |
| + } |
| + instances := map[string]*int{ |
| + "total": intPtr(totalStatic + totalResident + totalHibernating), |
| + "static": intPtr(totalStatic), |
| + "resident": intPtr(totalResident), |
| + "hibernating": intPtr(totalHibernating), |
| + } |
| + if awakeKnown { |
| + instances["awake"] = intPtr(awakeSum) |
| + } else { |
| + instances["awake"] = nil |
| + } |
| + if asleepKnown { |
| + instances["asleep"] = intPtr(asleepSum) |
| + } else { |
| + instances["asleep"] = nil |
| + } |
| + return instances |
| +} |
| + |
| +// worstCapacity picks the worst capacity band among ALL boxes, stale ones |
| +// included: a box that stopped reporting still had a last-known fill level, |
| +// and dropping it from the vote just because its snapshot aged out would |
| +// hide a real "getting full" or "at capacity" reading behind the newer, |
| +// possibly-rosier boxes — exactly the silent-omission failure the plan's |
| +// privacy rules refuse to allow, just pointed at capacity instead of a |
| +// hostname. (Contrast worstReplication below, which the plan explicitly |
| +// scopes to fresh boxes — staleness there means "we don't know", not "we |
| +// know it was bad".) A box with an unknown (nil) band doesn't vote — nil |
| +// never invents a word. All-nil input yields nil. |
| +func worstCapacity(boxes []Box) *string { |
| + var worst *string |
| + worstRank := -1 |
| + for _, b := range boxes { |
| + if b.Capacity == nil { |
| + continue |
| + } |
| + rank, ok := capacityRank[*b.Capacity] |
| + if !ok { |
| + continue // defensive: an unrecognized band word doesn't vote |
| + } |
| + if rank > worstRank { |
| + worstRank = rank |
| + v := *b.Capacity |
| + worst = &v |
| + } |
| + } |
| + return worst |
| +} |
| + |
| +// worstReplication picks the worst replication reading among non-stale |
| +// boxes: unhealthy beats healthy beats unknown, and ties within a band |
| +// break toward the larger lag (still the worse reading). Nothing fresh |
| +// with a known reading yields the zero Replication (both fields nil) — |
| +// unknown, not a guessed "healthy". |
| +func worstReplication(boxes []Box, stale []bool) Replication { |
| + var worst Replication |
| + haveWorst := false |
| + for i, b := range boxes { |
| + if stale[i] || b.Replication.Healthy == nil { |
| + continue |
| + } |
| + switch { |
| + case !haveWorst: |
| + worst = b.Replication |
| + haveWorst = true |
| + case !*b.Replication.Healthy && *worst.Healthy: |
| + // This box is unhealthy and the current worst isn't — a real |
| + // outage always outranks "just some lag". |
| + worst = b.Replication |
| + case *b.Replication.Healthy == *worst.Healthy && |
| + b.Replication.LagSecs != nil && worst.LagSecs != nil && |
| + *b.Replication.LagSecs > *worst.LagSecs: |
| + // Same health band; the larger lag is the worse reading. |
| + worst = b.Replication |
| + } |
| + } |
| + return worst |
| +} |
| + |
| +// routeGroup collects one App name's routes in the order they appeared in |
| +// the input slice — used for both the apps and services groupings below, |
| +// which differ only in which Kind they filter on and how they reduce a |
| +// group down to one published row. |
| +type routeGroup struct { |
| + name string |
| + routes []registry.Route |
| +} |
| + |
| +// groupRoutesByApp buckets routes matching kind into per-App groups, |
| +// preserving first-seen order so output is deterministic without needing a |
| +// separate sort pass. |
| +func groupRoutesByApp(routes []registry.Route, kind string) []routeGroup { |
| + index := map[string]int{} |
| + var groups []routeGroup |
| + for _, r := range routes { |
| + if r.Kind != kind { |
| + continue |
| + } |
| + i, ok := index[r.App] |
| + if !ok { |
| + i = len(groups) |
| + index[r.App] = i |
| + groups = append(groups, routeGroup{name: r.App}) |
| + } |
| + groups[i].routes = append(groups[i].routes, r) |
| + } |
| + return groups |
| +} |
| + |
| +// buildApps groups the registry's "instance" routes by App and publishes |
| +// only groups where EVERY route opted in via StatusNamed — one un-opted |
| +// route keeps its whole app dark, never a partial reveal. Everything else |
| +// folds into the unlisted count so the aggregate is still honest about how |
| +// many apps exist without naming the ones that didn't ask to be named. |
| +func buildApps(routes []registry.Route) ([]FleetApp, int) { |
| + groups := groupRoutesByApp(routes, "instance") |
| + |
| + var apps []FleetApp |
| + for _, g := range groups { |
| + allNamed := true |
| + healthy := true |
| + for _, r := range g.routes { |
| + if !r.StatusNamed { |
| + allNamed = false |
| + } |
| + if r.Status != "ready" { |
| + healthy = false |
| + } |
| + } |
| + if !allNamed { |
| + continue |
| + } |
| + first := g.routes[0] |
| + for _, r := range g.routes[1:] { |
| + if r.Channel != first.Channel || r.Version != first.Version { |
| + log.Printf("status: fleet: app %q routes disagree on channel/version (using first route's %q/%q)", |
| + g.name, first.Channel, first.Version) |
| + break |
| + } |
| + } |
| + apps = append(apps, FleetApp{ |
| + Name: g.name, |
| + Instances: len(g.routes), |
| + Healthy: healthy, |
| + Channel: first.Channel, |
| + Version: first.Version, |
| + }) |
| + } |
| + return apps, len(groups) - len(apps) |
| +} |
| + |
| +// buildServices groups the registry's "service" routes by App and reports |
| +// each as up only if every route in the group is ready. Service names are |
| +// platform-reserved (svc/*), not tenant hostnames, so — unlike apps — |
| +// there's no opt-in gate here. |
| +func buildServices(routes []registry.Route) []struct { |
| + Name string `json:"name"` |
| + Up bool `json:"up"` |
| +} { |
| + groups := groupRoutesByApp(routes, "service") |
| + |
| + var services []struct { |
| + Name string `json:"name"` |
| + Up bool `json:"up"` |
| + } |
| + for _, g := range groups { |
| + up := true |
| + for _, r := range g.routes { |
| + if r.Status != "ready" { |
| + up = false |
| + } |
| + } |
| + services = append(services, struct { |
| + Name string `json:"name"` |
| + Up bool `json:"up"` |
| + }{Name: g.name, Up: up}) |
| + } |
| + return services |
| +} |
| + |
| +// PublishFleet writes fleet.json to status/. Page assets (index.html, the |
| +// stylesheet) are embedded and published alongside it starting in Task 4 — |
| +// this function's single Put is the seam that loop lands in; it isn't a |
| +// TODO so much as the honest scope of what this task builds. |
| +func PublishFleet(ctx context.Context, s release.Store, f *Fleet) error { |
| + body, err := json.MarshalIndent(f, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + return putOverwrite(ctx, s, "status/fleet.json", body) |
| +} |
| + |
| +// putOverwrite lands body at key with last-writer-wins semantics — the same |
| +// read-etag-then-Put dance WriteBox uses, for the same reason: every |
| +// status/ object PublishFleet writes gets republished on the same key each |
| +// pass (fleet.json every 5 minutes, alongside the boxes), and |
| +// release.Store's Put is CAS-only, so a bare create-only Put would fail |
| +// every tick after the first with ErrCASConflict. One retry on a lost race; |
| +// a second conflict is surfaced rather than retried again, since the status |
| +// lease is supposed to make PublishFleet the only writer touching these |
| +// keys — two losses in a row past the lease means something else is wrong. |
| +func putOverwrite(ctx context.Context, s release.Store, key string, body []byte) error { |
| + etag, err := currentEtag(ctx, s, key) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := s.Put(ctx, key, body, etag); err != nil { |
| + if !errors.Is(err, release.ErrCASConflict) { |
| + return err |
| + } |
| + etag, err = currentEtag(ctx, s, key) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := s.Put(ctx, key, body, etag); err != nil { |
| + return err |
| + } |
| + } |
| + return nil |
| +} |