| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "log" |
| 8 | "net/http" |
| 9 | "sort" |
| 10 | "strings" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/seeds" |
| 14 | "github.com/carlosframework/platform/internal/status" |
| 15 | ) |
| 16 | |
| 17 | // seedRegionReady is the check both seed doors make before writing any seed |
| 18 | // state, even awaiting-upload (seeded-instances spec §3.3, §6.3): this |
| 19 | // console runs without claim control, the seeds floor is published, and |
| 20 | // every box in region has armed it, excluded or vouched for its |
| 21 | // carlos.prev, runs nested, and has no claim control. region is the record |
| 22 | // vocabulary ("" is the home region); the home region's name is accepted |
| 23 | // too. nil means go ahead. |
| 24 | // |
| 25 | // oneBox is the import door's extra rule: the swap, its journal and the |
| 26 | // rollback live on the one box holding the legacy row, and a second box in |
| 27 | // the region could finish an aborted import without its journal while the |
| 28 | // swapping box is still rolling back. Until a multi-box design exists, an |
| 29 | // import refuses a region with more than one box. |
| 30 | // |
| 31 | // Every missing, zero or unparseable field is NOT ready. An old box's edges |
| 32 | // file reads as zeros, so its protocol is checked before anything else: a |
| 33 | // box whose own local floor is unreadable publishes "" for its floor, and |
| 34 | // must never be read as ready. |
| 35 | func (s *Server) seedRegionReady(ctx context.Context, region string, oneBox bool) *doorRefusal { |
| 36 | region = s.homeRegionIn(region) |
| 37 | if s.cfg.HostClaim { |
| 38 | return seedClaimConsoleRefusal() |
| 39 | } |
| 40 | floor, err := seeds.ReadFloor(ctx, s.cfg.Releases) |
| 41 | if err == nil && floor != nil { |
| 42 | // A floor PublishFloor would never write (a hand-edited or |
| 43 | // truncated object) is as good as unreadable: an empty min_release |
| 44 | // would match every box that could not read its own floor. |
| 45 | if ok, perr := seeds.ReleaseAtOrAbove(floor.MinRelease, floor.MinRelease); !ok || perr != nil { |
| 46 | err = fmt.Errorf("published floor has no usable min_release %q", floor.MinRelease) |
| 47 | } |
| 48 | } |
| 49 | if err != nil { |
| 50 | log.Printf("console: seeds floor: %v", err) |
| 51 | return &doorRefusal{http.StatusServiceUnavailable, "could not read the seeds floor. Try again."} |
| 52 | } |
| 53 | if floor == nil { |
| 54 | return &doorRefusal{http.StatusConflict, "This deployment has no seeds floor yet. An operator publishes it with carlos system seeds-floor publish."} |
| 55 | } |
| 56 | minProtocol := max(seeds.RequiredProtocol, floor.Min) |
| 57 | name := s.homeRegionOut(region) |
| 58 | if name == "" { |
| 59 | name = "the home region" |
| 60 | } |
| 61 | unconfirmed := &doorRefusal{http.StatusServiceUnavailable, fmt.Sprintf("could not confirm every box in %s. Try again in a minute.", name)} |
| 62 | snap, err := s.liveEdges(ctx) |
| 63 | if err != nil || !snap.EvidenceComplete() { |
| 64 | if err != nil { |
| 65 | log.Printf("console: seeds readiness: edges: %v", err) |
| 66 | } |
| 67 | return unconfirmed |
| 68 | } |
| 69 | var inRegion, below []string |
| 70 | for _, f := range snap.Files { |
| 71 | if s.homeRegionIn(f.Placement) != region { |
| 72 | continue |
| 73 | } |
| 74 | inRegion = append(inRegion, f.Box) |
| 75 | if f.SeedsProtocol < minProtocol { |
| 76 | below = append(below, f.Box) |
| 77 | continue |
| 78 | } |
| 79 | prevOK := f.PrevRelease == "none" || f.PrevSeedsAllowedFor == floor.MinRelease |
| 80 | rootRefused := (f.MovesFloorState != "" || f.MovesFloorGeneration != 0 || f.SeedsFloorState != "" || f.SeedsFloorGeneration != 0) && (f.SeedsFloorState != "accepted" || f.SeedsFloorGeneration == 0) |
| 81 | if rootRefused || f.SeedsFloorMinRelease != floor.MinRelease || !prevOK { |
| 82 | below = append(below, f.Box) |
| 83 | } |
| 84 | } |
| 85 | if len(inRegion) == 0 { |
| 86 | return unconfirmed |
| 87 | } |
| 88 | if oneBox && len(inRegion) > 1 { |
| 89 | return &doorRefusal{http.StatusConflict, fmt.Sprintf("%s has more than one box. An import needs a region with one box for now.", name)} |
| 90 | } |
| 91 | if len(below) > 0 { |
| 92 | return &doorRefusal{http.StatusConflict, fmt.Sprintf("%s in %s are below the seeds floor. Roll the platform there, then publish the seeds floor again.", boxList(below), name)} |
| 93 | } |
| 94 | // The platform's own heartbeats, the set complete() vouched for. Never |
| 95 | // systemBoxes: it appends every customer fleet's heartbeats, and a fleet |
| 96 | // box sharing a label could stand in for a platform box. The join is |
| 97 | // the edges file's box against the heartbeat's Label, the pair |
| 98 | // readLiveEdgesSnapshot joins on. A label two heartbeats claim is |
| 99 | // ambiguous, so unconfirmed. |
| 100 | boxes, err := status.LoadBoxes(ctx, s.cfg.Releases) |
| 101 | if err != nil { |
| 102 | log.Printf("console: seeds readiness: boxes: %v", err) |
| 103 | return unconfirmed |
| 104 | } |
| 105 | byLabel := map[string]status.Box{} |
| 106 | dup := map[string]bool{} |
| 107 | for _, b := range boxes { |
| 108 | if _, seen := byLabel[b.Label]; seen { |
| 109 | dup[b.Label] = true |
| 110 | } |
| 111 | byLabel[b.Label] = b |
| 112 | } |
| 113 | // A region box with no heartbeat, a stale one, or one with no layout |
| 114 | // report is unconfirmed, never nested. The refusal stays generic; the |
| 115 | // log names the box, since a retired box whose edges file nobody |
| 116 | // deleted refuses here until someone does. |
| 117 | now := time.Now() |
| 118 | var flat, claimed []string |
| 119 | for _, label := range inRegion { |
| 120 | b, ok := byLabel[label] |
| 121 | why := "" |
| 122 | switch { |
| 123 | case !ok: |
| 124 | why = "no heartbeat" |
| 125 | case dup[label]: |
| 126 | why = "more than one heartbeat with this label" |
| 127 | case status.BoxStale(b, now, status.SnapshotPeriod): |
| 128 | why = "stale heartbeat, generated " + b.GeneratedAt.UTC().Format(time.RFC3339) |
| 129 | case b.Metrics == nil || b.Metrics.Layout == nil: |
| 130 | why = "no layout report" |
| 131 | } |
| 132 | if why != "" { |
| 133 | log.Printf("console: seeds readiness: box %q in region %q is unconfirmed: %s", label, name, why) |
| 134 | return unconfirmed |
| 135 | } |
| 136 | if b.Metrics.Layout.Mode != "nested" { |
| 137 | flat = append(flat, label) |
| 138 | } |
| 139 | if b.Metrics.HostClaimEnabled { |
| 140 | claimed = append(claimed, label) |
| 141 | } |
| 142 | } |
| 143 | if len(flat) > 0 { |
| 144 | return &doorRefusal{http.StatusConflict, fmt.Sprintf("%s in %s do not run instances nested yet. A seeded instance needs every box there nested.", boxList(flat), name)} |
| 145 | } |
| 146 | if len(claimed) > 0 { |
| 147 | return &doorRefusal{http.StatusConflict, fmt.Sprintf("%s in %s run with claim control on. Seeded instances are not supported there yet.", boxList(claimed), name)} |
| 148 | } |
| 149 | return nil |
| 150 | } |
| 151 | |
| 152 | // seedClaimConsoleRefusal is the seed doors' answer on a console with claim |
| 153 | // control on. The --from door also gives it for a source whose claim could |
| 154 | // not be read, which seedRegionReady's own check makes unreachable today. |
| 155 | func seedClaimConsoleRefusal() *doorRefusal { |
| 156 | return &doorRefusal{http.StatusConflict, "This console runs with claim control on. Seeded instances are not supported here yet."} |
| 157 | } |
| 158 | |
| 159 | // boxList is the refusals' box names: sorted, comma-joined. |
| 160 | func boxList(boxes []string) string { |
| 161 | sort.Strings(boxes) |
| 162 | return strings.Join(boxes, ", ") |
| 163 | } |
| 164 | |
| 165 | // handleAPISystemSeedsFloor publishes the seeds floor (spec §3.3, §10 step |
| 166 | // 2). The update doors' authority: an ops account owner or an address on |
| 167 | // CARLOS_SYSTEM_OPERATORS (apiSystemRoller). |
| 168 | func (s *Server) handleAPISystemSeedsFloor(w http.ResponseWriter, r *http.Request) { |
| 169 | addr, _, ok := s.apiSystemRoller(w, r) |
| 170 | if !ok { |
| 171 | return |
| 172 | } |
| 173 | // A console with no deployment bucket has nowhere to publish: the |
| 174 | // handler's own failure sentence, not a panic (final review carry item). |
| 175 | if s.cfg.Releases == nil { |
| 176 | jsonError(w, http.StatusServiceUnavailable, "could not publish the seeds floor. Try again.") |
| 177 | return |
| 178 | } |
| 179 | var req struct { |
| 180 | MinRelease string `json:"min_release"` |
| 181 | } |
| 182 | if !readJSON(w, r, &req) { |
| 183 | return |
| 184 | } |
| 185 | // The v prefix is required here: ReleaseAtOrAbove tolerates its absence, |
| 186 | // but the floor is stored and compared as typed, so "0.27.0" would sit in |
| 187 | // the record beside every "v0.27.0" the boxes report. |
| 188 | if ok, err := seeds.ReleaseAtOrAbove(req.MinRelease, req.MinRelease); !strings.HasPrefix(req.MinRelease, "v") || !ok || err != nil { |
| 189 | jsonError(w, http.StatusBadRequest, "min_release must be a release such as v0.27.0") |
| 190 | return |
| 191 | } |
| 192 | err := seeds.PublishFloor(r.Context(), s.cfg.Releases, seeds.Floor{Min: seeds.RequiredProtocol, MinRelease: req.MinRelease}) |
| 193 | var lower *seeds.FloorLowerError |
| 194 | switch { |
| 195 | case errors.As(err, &lower): |
| 196 | jsonError(w, http.StatusConflict, fmt.Sprintf("the seeds floor is already %s. A floor only rises.", lower.Current)) |
| 197 | return |
| 198 | case err != nil: |
| 199 | log.Printf("console: publish seeds floor: %v", err) |
| 200 | jsonError(w, http.StatusBadGateway, "could not publish the seeds floor. Try again.") |
| 201 | return |
| 202 | } |
| 203 | s.auditSystem(addr, "seeds-floor-publish", fmt.Sprintf("published the seeds floor at %s", req.MinRelease)) |
| 204 | writeJSON(w, http.StatusOK, map[string]string{"min_release": req.MinRelease}) |
| 205 | } |
| 206 | |