package console // The account-level Fleet tab: GET /a/{sqid}/fleet, the page that answers // "where does this account's work actually run?". // // It is a DIFFERENT page from the two that already carry the word fleet: // handleFleet (/fleet) is the operator's global platform status page, and // handleFleetPage (/a/{sqid}/fleets/{fleet}) is one of this account's own // fleets in detail. This one sits between them — member-readable, account // scoped, and the only place the shared platform fleet and the account's own // boxes are shown as two halves of one question. // // Two rules shape everything below: // // D2 — the shared fleet is a PRESENTATION, not a registry object. There is // no "shared fleet" row anywhere; the card is assembled from // status/fleet.json, the same PUBLIC aggregate the status page renders. The // raw status/boxes/* snapshots are deliberately NOT read here: they carry // per-box operational detail (release ids, load, memory) that the operator // page is gated to show and a tenant's page is not. // // Soft-fail everywhere — the overview.go/loadFleetPage posture. This page // makes three independent reads (the aggregate, the account's routes, its // fleets); any one of them having a bad moment costs that section and a // flash, never the page. import ( "context" "log" "net/http" "sort" "github.com/carlosframework/platform/internal/registry" ) // sharedFleetCard is the platform's own fleet as this account sees it: // how many boxes are reporting, where they are, and whether any of THIS // account's apps are running on them. // // Boxes/Regions come from the public aggregate and are the same for every // viewer; Active/PlatformApps are this account's own placement split and are // not. Nothing here is per-box: a tenant learns that the shared fleet exists // and roughly where it is, never which machine is which. type sharedFleetCard struct { Title string Active bool // at least one of this account's apps runs here Regions []string // distinct, sorted; a box with no region is simply absent Boxes int PlatformApps int // this account's distinct apps NOT placed on a fleet // PlacementKnown is false when the read that decides Active/PlatformApps // failed. Both fields then hold their zero values, which read as the // POSITIVE claim "none of your apps run on the shared fleet" — a claim // this account's routes were never actually consulted for. The card // renders neither verdict when this is false: "we could not check" and // "we checked, and no" are different sentences, and only one of them is // true after a registry outage. PlacementKnown bool } // accountFleetCard is one of the account's own fleets as the index row it // links through to the detail page with. Counts come from loadFleetPage — // the same function the detail page itself uses — so a row and the page it // clicks through to can never disagree about how many boxes a fleet has. type accountFleetCard struct { Name string Boxes int Reachable int Apps int } // assembleAccountFleet builds the tab's two halves: the shared platform // fleet from status/fleet.json, and the account's own fleets from the // registry. warn carries the FIRST read that failed, for a flash banner — // every failure degrades its own section to empty and nothing else. // // fleetsKnown is the fleets index's twin of sharedFleetCard.PlacementKnown, // and exists for the identical reason: a nil slice is the SAME value for // "this account has no fleets" and "the read that would have listed them // failed", and the first of those is a positive claim ("No fleets of your // own yet") that a failed registry read has not earned. False means the // section renders "unknown", never "none". func (s *Server) assembleAccountFleet(ctx context.Context, acct *Account) (card *sharedFleetCard, fleets []accountFleetCard, fleetsKnown bool, warn string) { card = &sharedFleetCard{Title: "Shared fleet"} // A console with no deployment bucket configured has no aggregate to // read at all — that is the empty card, not an error. if s.cfg.Releases != nil { f, err := FleetView(ctx, s.cfg.Releases) switch { case err != nil: // A read that FAILED is not the same claim as "no box is // reporting" — the card stays empty either way, but only this // branch admits the difference, rather than telling a tenant the // platform has no boxes because S3 was slow. (FleetView already // folds a not-yet-published object into (nil, nil), so reaching // here means a real failure.) log.Printf("console: account fleet %s: fleet view: %v", acct.Sqid, err) warn = "Could not read the shared fleet's status." case f != nil: card.Boxes = len(f.Boxes) seen := map[string]bool{} for _, b := range f.Boxes { if b.Region != "" && !seen[b.Region] { seen[b.Region] = true card.Regions = append(card.Regions, b.Region) } } sort.Strings(card.Regions) } } reg, err := openRegistry(s.cfg.Registry) if err != nil { log.Printf("console: account fleet %s: open registry: %v", acct.Sqid, err) if warn == "" { warn = "Could not reach the fleet registry." } return card, nil, false, warn } // The placement split. A route whose Region parses as a fleet placement // runs on that fleet; everything else — "" and any future residency code // FleetPlacement declines — runs on the shared fleet, which is exactly // the fallback the platform itself applies. Counted by distinct APP, not // by route, because "3 of your apps run here" is the sentence a tenant // can act on; a host count is noise on this page. // // rt.Account is compared strictly, the same test loadFleetPage and // overview.go make: an unstamped row is not this account's to claim on a // page that goes on to say "of YOUR apps". routes, err := reg.List() if err != nil { log.Printf("console: account fleet %s: list routes: %v", acct.Sqid, err) if warn == "" { warn = "Could not read where your apps are running." } } else { platformApps := map[string]bool{} for _, rt := range routes { if rt.Account != acct.Sqid { continue } if _, placed := registry.FleetPlacement(rt.Region); !placed { platformApps[rt.App] = true } } card.PlatformApps = len(platformApps) card.Active = card.PlatformApps > 0 card.PlacementKnown = true } rows, err := reg.ListFleets() if err != nil { log.Printf("console: account fleet %s: list fleets: %v", acct.Sqid, err) if warn == "" { warn = "Could not list your fleets." } return card, nil, false, warn } for i := range rows { // Fleet names are one flat, cross-account namespace (fleets.go's // package doc comment), so this filter is the ONLY thing standing // between this index and another account's fleet names. &fleets[i] // rather than a ranged copy's address: loadFleetPage takes a pointer, // and one that outlived a loop variable would be a bug waiting for // the day this function keeps it. f := &rows[i] if f.Account != acct.Sqid { continue } boxes, apps, fleetWarn := s.loadFleetPage(ctx, reg, acct, f) if fleetWarn != "" && warn == "" { // loadFleetPage's own wording says "this fleet", which has no // referent on an index of several; say what it costs HERE. warn = "Some fleet details could not be read — the counts below may be incomplete." } c := accountFleetCard{Name: f.Name, Boxes: len(boxes), Apps: len(apps)} for _, b := range boxes { if b.Reachable { c.Reachable++ } } fleets = append(fleets, c) } return card, fleets, true, warn } // handleAccountFleetTab serves GET /a/{sqid}/fleet. Membership in the // account is the whole gate: the page is a read, so a member sees exactly // what an owner does (there is no mutation on it to withhold). func (s *Server) handleAccountFleetTab(w http.ResponseWriter, r *http.Request) { addr, raw, acct, role, ok := s.account(w, r) if !ok { return } p := s.accountPage(w, r, addr, raw, acct, role) p.Title = "Fleet" p.ActiveNav = "fleet" shared, own, ownKnown, warn := s.assembleAccountFleet(r.Context(), acct) p.SharedFleet = shared p.AccountFleets = own p.FleetsKnown = ownKnown if warn != "" && p.FlashErr == "" { p.FlashErr = warn } s.render(w, http.StatusOK, "account_fleet", p) } // handleFleetBYO serves GET /a/{sqid}/fleet/byo: the "how to add and harden // a box" page the Fleet tab's button links to. It is static, per-deployment // content — the account only personalizes the eyebrow and header, same as // every other account page — so the handler is handleAccountFleetTab minus // the shared-fleet/own-fleets assembly: gate, page scaffold, render. func (s *Server) handleFleetBYO(w http.ResponseWriter, r *http.Request) { addr, raw, acct, role, ok := s.account(w, r) if !ok { return } p := s.accountPage(w, r, addr, raw, acct, role) p.Title = "Bring your own fleet" p.ActiveNav = "fleet" s.render(w, http.StatusOK, "fleet_byo", p) }