carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "log"
8 "net/http"
9
10 "github.com/carlosframework/platform/internal/hostactors"
11 "github.com/carlosframework/platform/internal/release"
12)
13
14// Box and group roles (region HA spec 2 §7, A11-1): `carlos system box role
15// <box> <role>` and `carlos system group role <region> <role>`. A legacy or
16// platform box takes no new instance, relocation or move; it keeps the
17// ones it has (H25). A group's role is every box it enrolled, now and
18// later. The roles live in the signed roster, so the console, the only
19// holder of the root key, is the only writer.
20//
21// Operator-only (apiSystemRoller, the update doors' authority), registered
22// only with host claims on, and answering 404 unless CARLOS_REGION_HA is on
23// too: with region HA off nothing reads a role.
24
25const (
26 boxRoleRefusal = "role must be default, legacy or platform"
27 groupRoleRefusal = "a group's role must be default or legacy"
28 boxRosterUnread = "could not read the box roster. Try again."
29 boxRosterChanged = "the box roster changed while the role was being saved, and nothing was changed. Try again."
30 boxRoleWriteFailure = "could not save the role. Try again."
31 boxRosterBusy = "the box roster can't be changed right now. Try again later."
32)
33
34func noEnrolledBox(name string) string { return fmt.Sprintf("no enrolled box is named %s", name) }
35
36func noGroupIn(region string) string { return fmt.Sprintf("%s has no group", region) }
37
38// boxRoleAudit and groupRoleAudit are the detail of the ops-account audit
39// row a role change writes, as the sibling operator doors write theirs.
40func boxRoleAudit(box, role string) string { return fmt.Sprintf("gave %s the %s role", box, role) }
41
42func groupRoleAudit(region, role string) string {
43 return fmt.Sprintf("gave the group in %s and its boxes the %s role", region, role)
44}
45
46func retiredBoxRole(box string) string { return fmt.Sprintf("%s is retired, so it takes no role", box) }
47
48// boxRoster reads the signed roster with the root signer that writes it,
49// for the role doors alone, which have already let only an operator in
50// (roleOperator). It is a read, of the one roster every reader reads
51// (control/host-actors.json through hostactors.Directory). Census ruling
52// region-ha-2a's conditions hold it to that: operator-only, read-only, the
53// same roster, and no role it reads feeds root's claim authority (roles
54// are read by the placement chooser alone).
55func (s *Server) boxRoster(ctx context.Context) (hostactors.Directory, release.Signer, hostactors.Observed, error) {
56 if s.cfg.Releases == nil || s.cfg.Bucket == "" || len(s.cfg.Key) == 0 {
57 return hostactors.Directory{}, release.Signer{}, hostactors.Observed{}, errors.New("console: no deployment bucket or root key")
58 }
59 signer := release.RootSigner(s.cfg.Key)
60 dir := hostactors.Directory{Store: s.cfg.Releases, PublicKey: signer.RootPublicKey(), Bucket: s.cfg.Bucket}
61 o, err := dir.Read(ctx)
62 return dir, signer, o, err
63}
64
65// rosterRef finds a box by reference, or by the one label no retired box
66// shares (hostactors.Directory.Resolve's rule, over a roster in hand).
67func rosterRef(r hostactors.Roster, name string) (string, bool) {
68 if _, ok := r.Boxes[name]; ok {
69 return name, true
70 }
71 found := ""
72 for ref, b := range r.Boxes {
73 if b.Label == name && b.State != "retired" {
74 if found != "" {
75 return "", false
76 }
77 found = ref
78 }
79 }
80 return found, found != ""
81}
82
83// roleOperator is the role doors' gate: an operator, with region HA on.
84func (s *Server) roleOperator(w http.ResponseWriter, r *http.Request) (string, bool) {
85 addr, _, ok := s.apiSystemRoller(w, r)
86 if !ok {
87 return "", false
88 }
89 if !s.cfg.RegionHA {
90 http.NotFound(w, r)
91 return "", false
92 }
93 return addr, true
94}
95
96// roleWriteError answers a roster write that did not land. Only a lost
97// compare-and-swap is "the roster changed while it was being saved". Any
98// other roster refusal (a membership change in progress, a host floor
99// mismatch, a roster that does not validate) is logged with its real cause
100// and answered as one that cannot be changed right now.
101func roleWriteError(w http.ResponseWriter, what string, err error) {
102 if errors.Is(err, release.ErrCASConflict) {
103 jsonError(w, http.StatusConflict, boxRosterChanged)
104 return
105 }
106 if errors.Is(err, hostactors.ErrRoster) {
107 log.Printf("console: %s: roster refused: %v", what, err)
108 jsonError(w, http.StatusConflict, boxRosterBusy)
109 return
110 }
111 log.Printf("console: %s: %v", what, err)
112 jsonError(w, http.StatusServiceUnavailable, boxRoleWriteFailure)
113}
114
115func (s *Server) handleAPISystemBoxRole(w http.ResponseWriter, r *http.Request) {
116 by, ok := s.roleOperator(w, r)
117 if !ok {
118 return
119 }
120 var req struct {
121 Role string `json:"role"`
122 }
123 if !readJSON(w, r, &req) {
124 return
125 }
126 role, err := hostactors.ParseBoxRole(req.Role)
127 if err != nil {
128 jsonError(w, http.StatusBadRequest, boxRoleRefusal)
129 return
130 }
131 dir, signer, o, err := s.boxRoster(r.Context())
132 if err != nil {
133 log.Printf("console: box role: roster: %v", err)
134 jsonError(w, http.StatusServiceUnavailable, boxRosterUnread)
135 return
136 }
137 name := r.PathValue("box")
138 ref, ok := rosterRef(o.Roster, name)
139 if !ok {
140 jsonError(w, http.StatusNotFound, noEnrolledBox(name))
141 return
142 }
143 if o.Roster.Boxes[ref].State == "retired" {
144 jsonError(w, http.StatusConflict, retiredBoxRole(ref))
145 return
146 }
147 next, err := dir.SetBoxRole(r.Context(), signer, o, ref, role)
148 if err != nil {
149 roleWriteError(w, "box role "+ref, err)
150 return
151 }
152 log.Printf("console: box %s has the %s role (by %s, roster revision %d)", ref, hostactors.RoleName(role), by, next.Roster.Revision)
153 if next.Roster.Revision != o.Roster.Revision {
154 s.auditSystem(by, "box-role", boxRoleAudit(ref, hostactors.RoleName(role)))
155 }
156 writeJSON(w, http.StatusOK, map[string]string{"box": ref, "role": hostactors.RoleName(role)})
157}
158
159func (s *Server) handleAPISystemGroupRole(w http.ResponseWriter, r *http.Request) {
160 by, ok := s.roleOperator(w, r)
161 if !ok {
162 return
163 }
164 var req struct {
165 Role string `json:"role"`
166 }
167 if !readJSON(w, r, &req) {
168 return
169 }
170 role, err := hostactors.ParseGroupRole(req.Role)
171 if err != nil {
172 jsonError(w, http.StatusBadRequest, groupRoleRefusal)
173 return
174 }
175 dir, signer, o, err := s.boxRoster(r.Context())
176 if err != nil {
177 log.Printf("console: group role: roster: %v", err)
178 jsonError(w, http.StatusServiceUnavailable, boxRosterUnread)
179 return
180 }
181 region := r.PathValue("region")
182 g, ok := o.Roster.Regions[region]
183 if !ok || g.GroupName == "" {
184 jsonError(w, http.StatusNotFound, noGroupIn(region))
185 return
186 }
187 next, boxes, err := dir.SetGroupRole(r.Context(), signer, o, region, role)
188 if err != nil {
189 roleWriteError(w, "group role "+region, err)
190 return
191 }
192 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)
193 if next.Roster.Revision != o.Roster.Revision {
194 s.auditSystem(by, "group-role", groupRoleAudit(region, hostactors.RoleName(role)))
195 }
196 if boxes == nil {
197 boxes = []string{}
198 }
199 writeJSON(w, http.StatusOK, map[string]any{"region": region, "group": g.GroupName, "role": hostactors.RoleName(role), "boxes": boxes})
200}
201