| 1 | // GET /fleet/architecture — the operator's map of the deployment: how a |
| 2 | // request reaches a process, and everything the account is carrying that |
| 3 | // makes that possible. |
| 4 | // |
| 5 | // The gate is operatorOnly's (server.go): session -> IsOperator -> bare |
| 6 | // http.NotFound on EVERY failure including the store error. A styled refusal |
| 7 | // would confirm the route exists, and this page names hostnames, instance |
| 8 | // ids and the shape of the estate. |
| 9 | // |
| 10 | // Every read soft-fails on its own. The inventory, the fleet aggregate, the |
| 11 | // per-box snapshots, the registry and the rate card are five independent |
| 12 | // reads; any one of them having a bad moment costs its own section and a |
| 13 | // line in the footer, never the page. |
| 14 | package console |
| 15 | |
| 16 | import ( |
| 17 | "crypto/subtle" |
| 18 | "log" |
| 19 | "net/http" |
| 20 | "time" |
| 21 | ) |
| 22 | |
| 23 | func (s *Server) handleArchitecture(w http.ResponseWriter, r *http.Request) { |
| 24 | // Method-checked here rather than by a "GET /path" mux pattern: that |
| 25 | // pattern answers a POST with 405 + Allow, which tells an unauthenticated |
| 26 | // prober this route exists — the one thing operatorOnly's bare 404 is |
| 27 | // there to withhold. HEAD is GET without a body and is served as such. |
| 28 | if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| 29 | http.NotFound(w, r) |
| 30 | return |
| 31 | } |
| 32 | addr, raw, ok := s.operatorOnly(w, r) |
| 33 | if !ok { |
| 34 | return |
| 35 | } |
| 36 | in := ArchInput{Bucket: s.cfg.Bucket} |
| 37 | inv, missing := s.loadInventory(r.Context()) |
| 38 | in.Inv = inv |
| 39 | |
| 40 | // A read that failed is SAID, not only logged. An empty Boxes section |
| 41 | // with nothing beside it reads as "this deployment has no boxes", which |
| 42 | // is the one claim a page whose bucket read just failed has no business |
| 43 | // making. failed names them at the foot; boxesUnread is the same fact |
| 44 | // carried to the place the false claim would otherwise be printed. |
| 45 | var failed []string |
| 46 | var boxesUnread bool |
| 47 | |
| 48 | // Reads two, three and five all go through the deployment bucket; a |
| 49 | // console configured without one (CARLOS_DEPLOYMENT_DIR-less, legal per |
| 50 | // Config.Releases) has no fleet, no host table and no rate card to read, |
| 51 | // and asking anyway would panic rather than degrade. |
| 52 | if s.cfg.Releases != nil { |
| 53 | if f, err := FleetView(r.Context(), s.cfg.Releases); err != nil { |
| 54 | log.Printf("console: architecture: fleet view: %v", err) |
| 55 | failed = append(failed, "the published fleet status could not be read") |
| 56 | boxesUnread = true |
| 57 | } else { |
| 58 | in.Fleet = f |
| 59 | } |
| 60 | if hosts, err := HostRows(r.Context(), s.cfg.Releases, time.Now()); err != nil { |
| 61 | log.Printf("console: architecture: hosts: %v", err) |
| 62 | failed = append(failed, "the per-box snapshots could not be read") |
| 63 | boxesUnread = true |
| 64 | } else { |
| 65 | in.Hosts = hosts |
| 66 | } |
| 67 | // The rate card comes from the SAME loader /fleet/economics uses |
| 68 | // (economicsview.go), so the two pages cannot disagree about what a |
| 69 | // box costs. Its warning is already surfaced on that page; here a bad |
| 70 | // scenario simply means the zero rate card, and every box then reports |
| 71 | // drift — which is the honest answer, not a silent $0. |
| 72 | sc, _ := s.loadActiveScenario(r.Context()) |
| 73 | in.Rates = sc.RateCard |
| 74 | } |
| 75 | if reg, err := openRegistry(s.cfg.Registry); err != nil { |
| 76 | log.Printf("console: architecture: registry: %v", err) |
| 77 | failed = append(failed, "the route registry could not be opened — apps are not named on the boxes below") |
| 78 | } else if routes, err := reg.List(); err != nil { |
| 79 | log.Printf("console: architecture: routes: %v", err) |
| 80 | failed = append(failed, "the routes could not be listed — apps are not named on the boxes below") |
| 81 | } else { |
| 82 | in.Routes = routes |
| 83 | } |
| 84 | |
| 85 | v := BuildArch(in) |
| 86 | // BuildArch defaults a nil inventory to "never"; loadInventory knows |
| 87 | // WHICH of the three states it actually is, and that is the one the page |
| 88 | // must name. |
| 89 | if missing != "" { |
| 90 | v.Missing = missing |
| 91 | } |
| 92 | // Appended to a COPY: v.Partial aliases the inventory's own slice, and |
| 93 | // this page's read failures are not the inventory's. |
| 94 | if len(failed) > 0 { |
| 95 | v.Partial = append(append([]string{}, v.Partial...), failed...) |
| 96 | } |
| 97 | // The pictures are NOT rendered here any more: an estate has one band |
| 98 | // per region and the count is not known until it is built, so the |
| 99 | // template ranges over the bands and each draws itself (ArchBand.SVG). |
| 100 | // The SVG is still built in Go, which is what keeps it deterministic |
| 101 | // and testable as a whole. |
| 102 | p := page{Title: "Architecture", Address: addr, CSRF: raw, |
| 103 | Arch: &v, ArchBoxesUnread: boxesUnread} |
| 104 | p.Flash, p.FlashErr = s.takeFlash(w, r) |
| 105 | s.render(w, http.StatusOK, "architecture", p) |
| 106 | } |
| 107 | |
| 108 | // handleArchitectureRefresh runs one inventory now. Single-flighted |
| 109 | // (infraRefreshing): a leaned-on button must not stack passes against a |
| 110 | // rate-limited API. It deliberately does NOT clear the auth latch — a |
| 111 | // deployment without the IAM grant gets the same honest answer however many |
| 112 | // times the button is pressed. |
| 113 | // |
| 114 | // Method-checked here rather than by a "POST /path" mux pattern, for the |
| 115 | // reason server.go's economics doors give: a 405 + Allow answer to a GET |
| 116 | // would leak that this route exists to someone who merely guessed the URL. |
| 117 | func (s *Server) handleArchitectureRefresh(w http.ResponseWriter, r *http.Request) { |
| 118 | if r.Method != http.MethodPost { |
| 119 | http.NotFound(w, r) |
| 120 | return |
| 121 | } |
| 122 | _, raw, ok := s.operatorOnly(w, r) |
| 123 | if !ok { |
| 124 | return |
| 125 | } |
| 126 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 127 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 128 | return |
| 129 | } |
| 130 | if !s.infraRefreshing.CompareAndSwap(false, true) { |
| 131 | s.setFlash(w, "err", "An inventory is already running.") |
| 132 | http.Redirect(w, r, "/fleet/architecture", http.StatusSeeOther) |
| 133 | return |
| 134 | } |
| 135 | defer s.infraRefreshing.Store(false) |
| 136 | |
| 137 | if err := s.infraPass(r.Context()); err != nil { |
| 138 | log.Printf("console: architecture refresh: %v", err) |
| 139 | s.setFlash(w, "err", "The inventory could not be refreshed.") |
| 140 | } else { |
| 141 | s.setFlash(w, "ok", "Inventory refreshed.") |
| 142 | } |
| 143 | http.Redirect(w, r, "/fleet/architecture", http.StatusSeeOther) |
| 144 | } |
| 145 | |