carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package hostactors
2
3import (
4 "context"
5 "crypto/ed25519"
6 "encoding/json"
7 "errors"
8 "math"
9 "os"
10 "reflect"
11 "regexp"
12 "slices"
13 "strings"
14 "time"
15
16 "github.com/carlosframework/platform/internal/boxid"
17 "github.com/carlosframework/platform/internal/hostquarantine"
18 "github.com/carlosframework/platform/internal/regionname"
19 "github.com/carlosframework/platform/internal/release"
20)
21
22const Key = "control/host-actors.json"
23
24var ErrRoster = errors.New("hostactors: roster authority refused")
25var token = regexp.MustCompile(`^[0-9a-f]{32}$`)
26
27type Cloud = boxid.Cloud
28type Box struct {
29 EnrolledAt time.Time `json:"enrolled_at,omitzero"`
30 CensusPublicKey ed25519.PublicKey `json:"census_public_key,omitempty"`
31 Enrollment string `json:"enrollment"`
32 Namespace string `json:"namespace"`
33 BoxID string `json:"box_id"`
34 Label string `json:"label"`
35 Region string `json:"region"`
36 Pool string `json:"pool"`
37 Cloud Cloud `json:"cloud"`
38 EvidenceSHA256 string `json:"evidence_sha256"`
39 State string `json:"state"`
40 QuarantineRevision uint64 `json:"quarantine_revision"`
41 LossIDs []string `json:"loss_ids"`
42 // Group is the Auto Scaling group automatic enrollment admitted the box
43 // from (the region's GroupName then); "" for a box an operator
44 // enrolled. It never changes.
45 Group string `json:"group,omitempty"`
46 // Role is the box's placement role (roles.go): "" (default), legacy or
47 // platform. It is the one field besides the state fields a roster
48 // write may change on an enrolled box.
49 Role string `json:"role,omitempty"`
50}
51type Console struct {
52 EnrolledAt time.Time `json:"enrolled_at,omitzero"`
53 Compute *ComputeBinding `json:"compute,omitempty"`
54 Enrollment string `json:"enrollment"`
55 InstanceID string `json:"instance_id"`
56 Namespaces []string `json:"namespaces"`
57 DeploymentBucket string `json:"deployment_bucket"`
58}
59type Reservation struct {
60 Publication *release.SystemPublication `json:"publication,omitempty"`
61 RequestSHA256 string `json:"request_sha256,omitempty"`
62 UpdateMode string `json:"update_mode,omitempty"`
63 ID string `json:"id"`
64 Host string `json:"host"`
65 OpID string `json:"op_id"`
66 ExpectedClaimETag string `json:"expected_claim_etag"`
67 FloorSHA256 string `json:"floor_sha256"`
68 Actors map[string]string `json:"actors"`
69 Releases map[string]Report `json:"releases"`
70 Hashes map[string][]string `json:"hashes,omitempty"`
71}
72type Roster struct {
73 HomeRegion string `json:"home_region,omitempty"`
74 RequestLimitNS int64 `json:"request_limit_ns,omitempty"`
75 EnrollmentAttempts map[string]EnrollmentAttempt `json:"enrollment_attempts,omitempty"`
76 Publication string `json:"publication,omitempty"`
77 FloorSHA256 string `json:"floor_sha256,omitempty"`
78 Regions map[string]Region `json:"regions,omitempty"`
79 V int `json:"v"`
80 DeploymentBucket string `json:"deployment_bucket"`
81 Revision uint64 `json:"revision"`
82 Boxes map[string]Box `json:"boxes"`
83 Consoles map[string]Console `json:"consoles"`
84 Holds map[string]Reservation `json:"holds"`
85 Activations map[string]Reservation `json:"activations"`
86}
87type Observed struct {
88 Roster Roster
89 ETag string
90}
91type Directory struct {
92 Store release.Store
93 PublicKey ed25519.PublicKey
94 Bucket string
95}
96
97func segment(s string) bool {
98 return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\\x00")
99}
100func (r Roster) Validate(bucket string) error {
101 if r.V != 1 || bucket == "" || r.DeploymentBucket != bucket || r.Revision == 0 || r.Boxes == nil || r.Consoles == nil || r.Holds == nil || r.Activations == nil {
102 return ErrRoster
103 }
104 if r.HomeRegion != "" && regionname.PlacementRegion(r.HomeRegion) != r.HomeRegion {
105 return ErrRoster
106 }
107 if r.Publication != "" && r.Publication != "preparing" && r.Publication != "published" {
108 return ErrRoster
109 }
110 if (r.Publication == "") != (r.FloorSHA256 == "") {
111 return ErrRoster
112 }
113 ids := map[string]bool{}
114 clouds := map[Cloud]bool{}
115 for key, b := range r.Boxes {
116 if len(b.CensusPublicKey) != 0 && len(b.CensusPublicKey) != ed25519.PublicKeySize {
117 return ErrRoster
118 }
119 if !segment(key) || b.BoxID != key || !token.MatchString(b.Enrollment) || ids[b.Enrollment] || b.Namespace == "" || b.Label == "" || b.Region == "" || b.Pool == "" || b.Cloud.Provider != "aws" || b.Cloud.Account == "" || b.Cloud.Region == "" || b.Cloud.InstanceID == "" || len(b.EvidenceSHA256) != 64 || clouds[b.Cloud] {
120 return ErrRoster
121 }
122 if b.State != "active" && b.State != "quarantined" && b.State != "retired" {
123 return ErrRoster
124 }
125 if !validBoxRole(b.Role) {
126 return ErrRoster
127 }
128 if b.State == "quarantined" && (b.QuarantineRevision == 0 || len(b.LossIDs) == 0) {
129 return ErrRoster
130 }
131 ids[b.Enrollment] = true
132 clouds[b.Cloud] = true
133 }
134 for _, g := range r.Regions {
135 if !validGroupRole(g.Role) || (g.Role != RoleDefault && g.GroupName == "") {
136 return ErrRoster
137 }
138 }
139 for id, c := range r.Consoles {
140 if !segment(id) || !token.MatchString(c.Enrollment) || ids[c.Enrollment] || c.InstanceID != id || c.DeploymentBucket != bucket || len(c.Namespaces) == 0 {
141 return ErrRoster
142 }
143 if c.Compute != nil {
144 b, ok := r.Boxes[c.Compute.Box]
145 if !ok || b.Enrollment != c.Compute.Enrollment {
146 return ErrRoster
147 }
148 }
149 ids[c.Enrollment] = true
150 }
151 for _, reservations := range []map[string]Reservation{r.Holds, r.Activations} {
152 for id, h := range reservations {
153 if !segment(id) || h.ID != id || len(h.FloorSHA256) != 64 || len(h.Actors) == 0 || len(h.Releases) != len(h.Actors) {
154 return ErrRoster
155 }
156 if h.Publication != nil && (h.Publication.ID != id || len(h.RequestSHA256) != 64 || (h.UpdateMode != "promote" && h.UpdateMode != "rollback")) {
157 return ErrRoster
158 }
159 for key, enrollment := range h.Actors {
160 expected := ""
161 if ref, ok := strings.CutPrefix(key, "box:"); ok {
162 expected = r.Boxes[ref].Enrollment
163 }
164 if ref, ok := strings.CutPrefix(key, "console:"); ok {
165 expected = r.Consoles[ref].Enrollment
166 }
167 if expected != enrollment || !ids[enrollment] || h.Releases[key].Enrollment != enrollment {
168 return ErrRoster
169 }
170 }
171 }
172 }
173 for _, h := range r.Holds {
174 for _, a := range r.Activations {
175 if overlaps(h, a) {
176 return ErrRoster
177 }
178 }
179 }
180 return nil
181}
182func (d Directory) Read(ctx context.Context) (Observed, error) {
183 if d.Store == nil {
184 return Observed{}, ErrRoster
185 }
186 raw, etag, err := d.Store.Get(ctx, Key)
187 if err != nil {
188 return Observed{}, err
189 }
190 var r Roster
191 if etag == "" || release.VerifyRootEnvelope(d.PublicKey, raw, &r) != nil || r.Validate(d.Bucket) != nil {
192 return Observed{}, ErrRoster
193 }
194 return Observed{r, etag}, nil
195}
196func (d Directory) Lookup(ctx context.Context, box string) (hostquarantine.Actor, error) {
197 o, err := d.Read(ctx)
198 if err != nil {
199 return hostquarantine.Actor{}, err
200 }
201 b, ok := o.Roster.Boxes[box]
202 if !ok {
203 return hostquarantine.Actor{}, ErrRoster
204 }
205 fenced, err := d.TerminationFence(ctx, o, box, time.Now())
206 if err != nil || fenced {
207 return hostquarantine.Actor{}, ErrRoster
208 }
209 return hostquarantine.Actor{Enrollment: b.Enrollment, State: b.State, QuarantineRevision: b.QuarantineRevision}, nil
210}
211func (d Directory) Replace(ctx context.Context, sg release.Signer, before Observed, next Roster) (Observed, error) {
212 // The home designation is write-once only with host claims on: claim
213 // operations bind it, so changing it mid-flight would re-point their
214 // alias. With claims off the roster behaves exactly as it always did.
215 claimsOn := strings.TrimSpace(os.Getenv("CARLOS_HOST_CLAIM")) != ""
216 if before.ETag == "" || before.Roster.Revision == math.MaxUint64 ||
217 claimsOn && before.Roster.HomeRegion != "" && next.HomeRegion != before.Roster.HomeRegion {
218 return Observed{}, ErrRoster
219 }
220 // Assign new enrollment times inside the signed roster CAS. Older entries
221 // keep an unknown time; replacing it with today's time would fabricate evidence.
222 next = Clone(next)
223 var now time.Time
224 if claimsOn {
225 now = time.Now().UTC()
226 }
227 for ref, b := range next.Boxes {
228 if _, exists := before.Roster.Boxes[ref]; !exists {
229 if !b.EnrolledAt.IsZero() {
230 return Observed{}, ErrRoster
231 }
232 b.EnrolledAt = now
233 next.Boxes[ref] = b
234 }
235 }
236 for id, c := range next.Consoles {
237 if old, exists := before.Roster.Consoles[id]; exists && old.Enrollment == c.Enrollment {
238 if !old.EnrolledAt.Equal(c.EnrolledAt) {
239 return Observed{}, ErrRoster
240 }
241 } else {
242 if !c.EnrolledAt.IsZero() {
243 return Observed{}, ErrRoster
244 }
245 c.EnrolledAt = now
246 next.Consoles[id] = c
247 }
248 }
249 if err := d.checkMembership(ctx, before.Roster, next); err != nil {
250 return Observed{}, err
251 }
252 next.Revision = before.Roster.Revision + 1
253 for ref, old := range before.Roster.Boxes {
254 b, ok := next.Boxes[ref]
255 if !ok {
256 return Observed{}, ErrRoster
257 }
258 a, z := old, b
259 a.State = ""
260 z.State = ""
261 a.QuarantineRevision = 0
262 z.QuarantineRevision = 0
263 a.LossIDs = nil
264 z.LossIDs = nil
265 a.Role = ""
266 z.Role = ""
267 if !reflect.DeepEqual(a, z) || b.QuarantineRevision < old.QuarantineRevision {
268 return Observed{}, ErrRoster
269 }
270 if old.State == "retired" && b.State != "retired" {
271 return Observed{}, ErrRoster
272 }
273 }
274 if next.Validate(d.Bucket) != nil {
275 return Observed{}, ErrRoster
276 }
277 wire, err := release.SignRootEnvelope(sg, next)
278 if err != nil {
279 return Observed{}, err
280 }
281 etag, err := d.Store.Put(ctx, Key, wire, before.ETag)
282 if err != nil {
283 return Observed{}, err
284 }
285 return Observed{next, etag}, nil
286}
287func Clone(r Roster) Roster {
288 b, _ := json.Marshal(r)
289 var out Roster
290 _ = json.Unmarshal(b, &out)
291 return out
292}
293func (d Directory) Quarantine(ctx context.Context, sg release.Signer, o Observed, box, enrollment, loss string) (Observed, error) {
294 r := Clone(o.Roster)
295 b, ok := r.Boxes[box]
296 if !ok || b.Enrollment != enrollment || b.State == "retired" || loss == "" || b.QuarantineRevision == math.MaxUint64 {
297 return Observed{}, ErrRoster
298 }
299 if slices.Contains(b.LossIDs, loss) {
300 return o, nil
301 }
302 b.State = "quarantined"
303 b.QuarantineRevision++
304 b.LossIDs = append(b.LossIDs, loss)
305 r.Boxes[box] = b
306 return d.Replace(ctx, sg, o, r)
307}
308func (d Directory) Resolve(ctx context.Context, name string) (string, Box, error) {
309 o, err := d.Read(ctx)
310 if err != nil {
311 return "", Box{}, err
312 }
313 if b, ok := o.Roster.Boxes[name]; ok {
314 return name, b, nil
315 }
316 var ref string
317 var result Box
318 for key, b := range o.Roster.Boxes {
319 if b.Label == name && b.State != "retired" {
320 if ref != "" {
321 return "", Box{}, ErrRoster
322 }
323 ref = key
324 result = b
325 }
326 }
327 if ref == "" {
328 return "", Box{}, ErrRoster
329 }
330 return ref, result, nil
331}
332func overlaps(a, b Reservation) bool {
333 for _, x := range a.Actors {
334 for _, y := range b.Actors {
335 if x == y {
336 return true
337 }
338 }
339 }
340 return false
341}
342
343func (d Directory) Initialize(ctx context.Context, sg release.Signer) error {
344 r := Roster{V: 1, DeploymentBucket: d.Bucket, Revision: 1, Boxes: map[string]Box{}, Consoles: map[string]Console{}, Holds: map[string]Reservation{}, Activations: map[string]Reservation{}}
345 if err := r.Validate(d.Bucket); err != nil {
346 return err
347 }
348 wire, err := release.SignRootEnvelope(sg, r)
349 if err != nil {
350 return err
351 }
352 return release.CreateExact(ctx, d.Store, Key, wire)
353}
354
355func (r Roster) MatchesHome(home string) bool {
356 return home == r.HomeRegion && (home == "" || regionname.PlacementRegion(home) == home)
357}
358