package console import ( "context" "errors" "fmt" "log" "net/http" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/release" ) // Box and group roles (region HA spec 2 ยง7, A11-1): `carlos system box role // ` and `carlos system group role `. A legacy or // platform box takes no new instance, relocation or move; it keeps the // ones it has (H25). A group's role is every box it enrolled, now and // later. The roles live in the signed roster, so the console, the only // holder of the root key, is the only writer. // // Operator-only (apiSystemRoller, the update doors' authority), registered // only with host claims on, and answering 404 unless CARLOS_REGION_HA is on // too: with region HA off nothing reads a role. const ( boxRoleRefusal = "role must be default, legacy or platform" groupRoleRefusal = "a group's role must be default or legacy" boxRosterUnread = "could not read the box roster. Try again." boxRosterChanged = "the box roster changed while the role was being saved, and nothing was changed. Try again." boxRoleWriteFailure = "could not save the role. Try again." boxRosterBusy = "the box roster can't be changed right now. Try again later." ) func noEnrolledBox(name string) string { return fmt.Sprintf("no enrolled box is named %s", name) } func noGroupIn(region string) string { return fmt.Sprintf("%s has no group", region) } // boxRoleAudit and groupRoleAudit are the detail of the ops-account audit // row a role change writes, as the sibling operator doors write theirs. func boxRoleAudit(box, role string) string { return fmt.Sprintf("gave %s the %s role", box, role) } func groupRoleAudit(region, role string) string { return fmt.Sprintf("gave the group in %s and its boxes the %s role", region, role) } func retiredBoxRole(box string) string { return fmt.Sprintf("%s is retired, so it takes no role", box) } // boxRoster reads the signed roster with the root signer that writes it, // for the role doors alone, which have already let only an operator in // (roleOperator). It is a read, of the one roster every reader reads // (control/host-actors.json through hostactors.Directory). Census ruling // region-ha-2a's conditions hold it to that: operator-only, read-only, the // same roster, and no role it reads feeds root's claim authority (roles // are read by the placement chooser alone). func (s *Server) boxRoster(ctx context.Context) (hostactors.Directory, release.Signer, hostactors.Observed, error) { if s.cfg.Releases == nil || s.cfg.Bucket == "" || len(s.cfg.Key) == 0 { return hostactors.Directory{}, release.Signer{}, hostactors.Observed{}, errors.New("console: no deployment bucket or root key") } signer := release.RootSigner(s.cfg.Key) dir := hostactors.Directory{Store: s.cfg.Releases, PublicKey: signer.RootPublicKey(), Bucket: s.cfg.Bucket} o, err := dir.Read(ctx) return dir, signer, o, err } // rosterRef finds a box by reference, or by the one label no retired box // shares (hostactors.Directory.Resolve's rule, over a roster in hand). func rosterRef(r hostactors.Roster, name string) (string, bool) { if _, ok := r.Boxes[name]; ok { return name, true } found := "" for ref, b := range r.Boxes { if b.Label == name && b.State != "retired" { if found != "" { return "", false } found = ref } } return found, found != "" } // roleOperator is the role doors' gate: an operator, with region HA on. func (s *Server) roleOperator(w http.ResponseWriter, r *http.Request) (string, bool) { addr, _, ok := s.apiSystemRoller(w, r) if !ok { return "", false } if !s.cfg.RegionHA { http.NotFound(w, r) return "", false } return addr, true } // roleWriteError answers a roster write that did not land. Only a lost // compare-and-swap is "the roster changed while it was being saved". Any // other roster refusal (a membership change in progress, a host floor // mismatch, a roster that does not validate) is logged with its real cause // and answered as one that cannot be changed right now. func roleWriteError(w http.ResponseWriter, what string, err error) { if errors.Is(err, release.ErrCASConflict) { jsonError(w, http.StatusConflict, boxRosterChanged) return } if errors.Is(err, hostactors.ErrRoster) { log.Printf("console: %s: roster refused: %v", what, err) jsonError(w, http.StatusConflict, boxRosterBusy) return } log.Printf("console: %s: %v", what, err) jsonError(w, http.StatusServiceUnavailable, boxRoleWriteFailure) } func (s *Server) handleAPISystemBoxRole(w http.ResponseWriter, r *http.Request) { by, ok := s.roleOperator(w, r) if !ok { return } var req struct { Role string `json:"role"` } if !readJSON(w, r, &req) { return } role, err := hostactors.ParseBoxRole(req.Role) if err != nil { jsonError(w, http.StatusBadRequest, boxRoleRefusal) return } dir, signer, o, err := s.boxRoster(r.Context()) if err != nil { log.Printf("console: box role: roster: %v", err) jsonError(w, http.StatusServiceUnavailable, boxRosterUnread) return } name := r.PathValue("box") ref, ok := rosterRef(o.Roster, name) if !ok { jsonError(w, http.StatusNotFound, noEnrolledBox(name)) return } if o.Roster.Boxes[ref].State == "retired" { jsonError(w, http.StatusConflict, retiredBoxRole(ref)) return } next, err := dir.SetBoxRole(r.Context(), signer, o, ref, role) if err != nil { roleWriteError(w, "box role "+ref, err) return } log.Printf("console: box %s has the %s role (by %s, roster revision %d)", ref, hostactors.RoleName(role), by, next.Roster.Revision) if next.Roster.Revision != o.Roster.Revision { s.auditSystem(by, "box-role", boxRoleAudit(ref, hostactors.RoleName(role))) } writeJSON(w, http.StatusOK, map[string]string{"box": ref, "role": hostactors.RoleName(role)}) } func (s *Server) handleAPISystemGroupRole(w http.ResponseWriter, r *http.Request) { by, ok := s.roleOperator(w, r) if !ok { return } var req struct { Role string `json:"role"` } if !readJSON(w, r, &req) { return } role, err := hostactors.ParseGroupRole(req.Role) if err != nil { jsonError(w, http.StatusBadRequest, groupRoleRefusal) return } dir, signer, o, err := s.boxRoster(r.Context()) if err != nil { log.Printf("console: group role: roster: %v", err) jsonError(w, http.StatusServiceUnavailable, boxRosterUnread) return } region := r.PathValue("region") g, ok := o.Roster.Regions[region] if !ok || g.GroupName == "" { jsonError(w, http.StatusNotFound, noGroupIn(region)) return } next, boxes, err := dir.SetGroupRole(r.Context(), signer, o, region, role) if err != nil { roleWriteError(w, "group role "+region, err) return } log.Printf("console: the group in %s (%s) and its boxes %v have the %s role (by %s, roster revision %d)", region, g.GroupName, boxes, hostactors.RoleName(role), by, next.Roster.Revision) if next.Roster.Revision != o.Roster.Revision { s.auditSystem(by, "group-role", groupRoleAudit(region, hostactors.RoleName(role))) } if boxes == nil { boxes = []string{} } writeJSON(w, http.StatusOK, map[string]any{"region": region, "group": g.GroupName, "role": hostactors.RoleName(role), "boxes": boxes}) }