package console import ( "context" "errors" "fmt" "log" "net/http" "sort" "strings" "time" "github.com/carlosframework/platform/internal/seeds" "github.com/carlosframework/platform/internal/status" ) // seedRegionReady is the check both seed doors make before writing any seed // state, even awaiting-upload (seeded-instances spec §3.3, §6.3): this // console runs without claim control, the seeds floor is published, and // every box in region has armed it, excluded or vouched for its // carlos.prev, runs nested, and has no claim control. region is the record // vocabulary ("" is the home region); the home region's name is accepted // too. nil means go ahead. // // oneBox is the import door's extra rule: the swap, its journal and the // rollback live on the one box holding the legacy row, and a second box in // the region could finish an aborted import without its journal while the // swapping box is still rolling back. Until a multi-box design exists, an // import refuses a region with more than one box. // // Every missing, zero or unparseable field is NOT ready. An old box's edges // file reads as zeros, so its protocol is checked before anything else: a // box whose own local floor is unreadable publishes "" for its floor, and // must never be read as ready. func (s *Server) seedRegionReady(ctx context.Context, region string, oneBox bool) *doorRefusal { region = s.homeRegionIn(region) if s.cfg.HostClaim { return seedClaimConsoleRefusal() } floor, err := seeds.ReadFloor(ctx, s.cfg.Releases) if err == nil && floor != nil { // A floor PublishFloor would never write (a hand-edited or // truncated object) is as good as unreadable: an empty min_release // would match every box that could not read its own floor. if ok, perr := seeds.ReleaseAtOrAbove(floor.MinRelease, floor.MinRelease); !ok || perr != nil { err = fmt.Errorf("published floor has no usable min_release %q", floor.MinRelease) } } if err != nil { log.Printf("console: seeds floor: %v", err) return &doorRefusal{http.StatusServiceUnavailable, "could not read the seeds floor. Try again."} } if floor == nil { return &doorRefusal{http.StatusConflict, "This deployment has no seeds floor yet. An operator publishes it with carlos system seeds-floor publish."} } minProtocol := max(seeds.RequiredProtocol, floor.Min) name := s.homeRegionOut(region) if name == "" { name = "the home region" } unconfirmed := &doorRefusal{http.StatusServiceUnavailable, fmt.Sprintf("could not confirm every box in %s. Try again in a minute.", name)} snap, err := s.liveEdges(ctx) if err != nil || !snap.EvidenceComplete() { if err != nil { log.Printf("console: seeds readiness: edges: %v", err) } return unconfirmed } var inRegion, below []string for _, f := range snap.Files { if s.homeRegionIn(f.Placement) != region { continue } inRegion = append(inRegion, f.Box) if f.SeedsProtocol < minProtocol { below = append(below, f.Box) continue } prevOK := f.PrevRelease == "none" || f.PrevSeedsAllowedFor == floor.MinRelease rootRefused := (f.MovesFloorState != "" || f.MovesFloorGeneration != 0 || f.SeedsFloorState != "" || f.SeedsFloorGeneration != 0) && (f.SeedsFloorState != "accepted" || f.SeedsFloorGeneration == 0) if rootRefused || f.SeedsFloorMinRelease != floor.MinRelease || !prevOK { below = append(below, f.Box) } } if len(inRegion) == 0 { return unconfirmed } if oneBox && len(inRegion) > 1 { return &doorRefusal{http.StatusConflict, fmt.Sprintf("%s has more than one box. An import needs a region with one box for now.", name)} } if len(below) > 0 { 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)} } // The platform's own heartbeats, the set complete() vouched for. Never // systemBoxes: it appends every customer fleet's heartbeats, and a fleet // box sharing a label could stand in for a platform box. The join is // the edges file's box against the heartbeat's Label, the pair // readLiveEdgesSnapshot joins on. A label two heartbeats claim is // ambiguous, so unconfirmed. boxes, err := status.LoadBoxes(ctx, s.cfg.Releases) if err != nil { log.Printf("console: seeds readiness: boxes: %v", err) return unconfirmed } byLabel := map[string]status.Box{} dup := map[string]bool{} for _, b := range boxes { if _, seen := byLabel[b.Label]; seen { dup[b.Label] = true } byLabel[b.Label] = b } // A region box with no heartbeat, a stale one, or one with no layout // report is unconfirmed, never nested. The refusal stays generic; the // log names the box, since a retired box whose edges file nobody // deleted refuses here until someone does. now := time.Now() var flat, claimed []string for _, label := range inRegion { b, ok := byLabel[label] why := "" switch { case !ok: why = "no heartbeat" case dup[label]: why = "more than one heartbeat with this label" case status.BoxStale(b, now, status.SnapshotPeriod): why = "stale heartbeat, generated " + b.GeneratedAt.UTC().Format(time.RFC3339) case b.Metrics == nil || b.Metrics.Layout == nil: why = "no layout report" } if why != "" { log.Printf("console: seeds readiness: box %q in region %q is unconfirmed: %s", label, name, why) return unconfirmed } if b.Metrics.Layout.Mode != "nested" { flat = append(flat, label) } if b.Metrics.HostClaimEnabled { claimed = append(claimed, label) } } if len(flat) > 0 { 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)} } if len(claimed) > 0 { return &doorRefusal{http.StatusConflict, fmt.Sprintf("%s in %s run with claim control on. Seeded instances are not supported there yet.", boxList(claimed), name)} } return nil } // seedClaimConsoleRefusal is the seed doors' answer on a console with claim // control on. The --from door also gives it for a source whose claim could // not be read, which seedRegionReady's own check makes unreachable today. func seedClaimConsoleRefusal() *doorRefusal { return &doorRefusal{http.StatusConflict, "This console runs with claim control on. Seeded instances are not supported here yet."} } // boxList is the refusals' box names: sorted, comma-joined. func boxList(boxes []string) string { sort.Strings(boxes) return strings.Join(boxes, ", ") } // handleAPISystemSeedsFloor publishes the seeds floor (spec §3.3, §10 step // 2). The update doors' authority: an ops account owner or an address on // CARLOS_SYSTEM_OPERATORS (apiSystemRoller). func (s *Server) handleAPISystemSeedsFloor(w http.ResponseWriter, r *http.Request) { addr, _, ok := s.apiSystemRoller(w, r) if !ok { return } // A console with no deployment bucket has nowhere to publish: the // handler's own failure sentence, not a panic (final review carry item). if s.cfg.Releases == nil { jsonError(w, http.StatusServiceUnavailable, "could not publish the seeds floor. Try again.") return } var req struct { MinRelease string `json:"min_release"` } if !readJSON(w, r, &req) { return } // The v prefix is required here: ReleaseAtOrAbove tolerates its absence, // but the floor is stored and compared as typed, so "0.27.0" would sit in // the record beside every "v0.27.0" the boxes report. if ok, err := seeds.ReleaseAtOrAbove(req.MinRelease, req.MinRelease); !strings.HasPrefix(req.MinRelease, "v") || !ok || err != nil { jsonError(w, http.StatusBadRequest, "min_release must be a release such as v0.27.0") return } err := seeds.PublishFloor(r.Context(), s.cfg.Releases, seeds.Floor{Min: seeds.RequiredProtocol, MinRelease: req.MinRelease}) var lower *seeds.FloorLowerError switch { case errors.As(err, &lower): jsonError(w, http.StatusConflict, fmt.Sprintf("the seeds floor is already %s. A floor only rises.", lower.Current)) return case err != nil: log.Printf("console: publish seeds floor: %v", err) jsonError(w, http.StatusBadGateway, "could not publish the seeds floor. Try again.") return } s.auditSystem(addr, "seeds-floor-publish", fmt.Sprintf("published the seeds floor at %s", req.MinRelease)) writeJSON(w, http.StatusOK, map[string]string{"min_release": req.MinRelease}) }