carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package claimactivation
2
3import (
4 "bytes"
5 "context"
6 "crypto/ed25519"
7 "encoding/json"
8 "errors"
9 "os"
10 "reflect"
11 "regexp"
12 "slices"
13 "time"
14
15 "github.com/carlosframework/platform/internal/boxid"
16 "github.com/carlosframework/platform/internal/hostactors"
17 "github.com/carlosframework/platform/internal/hostbox"
18 "github.com/carlosframework/platform/internal/hostfloor"
19 "github.com/carlosframework/platform/internal/hostops"
20 "github.com/carlosframework/platform/internal/release"
21)
22
23var ErrActivation = errors.New("claim activation: phase, revision, or actor inputs refused")
24
25type Plan struct {
26 Phase string `json:"phase"`
27 Bucket string `json:"bucket"`
28 ExpectedRevision uint64 `json:"expected_revision"`
29 Regions map[string]hostactors.Region `json:"regions,omitempty"`
30 HomeRegion string `json:"home_region,omitempty"`
31 Box *BoxInput `json:"box,omitempty"`
32 Console *hostactors.Console `json:"console,omitempty"`
33 Floor *hostfloor.Floor `json:"floor,omitempty"`
34 Binding *hostactors.CensusKeyBinding `json:"binding,omitempty"`
35 Proof []byte `json:"proof,omitempty"`
36 Identity *IdentityInput `json:"identity,omitempty"`
37 Backfill *BackfillInput `json:"backfill,omitempty"`
38}
39
40// IdentityInput is publish-identity's plan: a release tag, its moves
41// protocol, the release's signed checksum list (SHA256SUMS.txt and its .sig,
42// byte for byte) and the console release whose binary the identity names.
43// No sha256 is typed: the router's comes from the signed sums, the
44// console's from the console app's signed channel pointer and the manifest
45// digest it signs.
46type IdentityInput struct {
47 ReleaseTag string `json:"release_tag"`
48 MovesProtocol int `json:"moves_protocol"`
49 Sums string `json:"sha256sums"`
50 SumsSignature string `json:"sha256sums_sig"`
51 Console ConsoleRelease `json:"console"`
52}
53
54// ConsoleRelease selects one shipped console build by its version (the
55// console build sha) on a channel whose signed pointer names it.
56type ConsoleRelease struct {
57 Account string `json:"account"`
58 App string `json:"app"`
59 Channel string `json:"channel"`
60 Version string `json:"version"`
61}
62type BoxInput struct {
63 Reference string `json:"reference"`
64 Namespace string `json:"namespace"`
65 Label string `json:"label"`
66 Region string `json:"region"`
67 Pool string `json:"pool"`
68 AWSRegion string `json:"aws_region"`
69 InstanceID string `json:"instance_id"`
70 Operator string `json:"operator"`
71}
72
73type Write struct {
74 Key string `json:"key"`
75 BeforeETag string `json:"before_etag"`
76 SHA256 string `json:"sha256"`
77 Payload json.RawMessage `json:"payload"`
78}
79type Result struct {
80 DryRun bool `json:"dry_run"`
81 Revision uint64 `json:"revision"`
82 Writes []Write `json:"writes"`
83 // sign-policy and publish-identity only. FloorSHA256 is the policy's
84 // floor digest; IdentityKey and Release are the identity's key and the
85 // release it names, as read back. Output is the exact file the phase
86 // produces (the armed policy, or the published identity's stored bytes),
87 // set only when the phase is applied. It is never part of the JSON: the
88 // command writes it to --out unchanged, because re-indenting either would
89 // change bytes guard-arm verifies.
90 FloorSHA256 string `json:"floor_sha256,omitempty"`
91 IdentityKey string `json:"identity_key,omitempty"`
92 Release *release.SystemRelease `json:"release,omitempty"`
93 OutputSHA256 string `json:"output_sha256,omitempty"`
94 Output []byte `json:"-"`
95 // The backfill phases only: the signed plan as read
96 // back after the write (on a dry run, the plan that would be written).
97 Backfill *BackfillSummary `json:"backfill,omitempty"`
98}
99type Runner struct {
100 Directory hostactors.Directory
101 Signer release.Signer
102 // SumsKey verifies a release's SHA256SUMS.txt (base64 ed25519). The
103 // command sets the vendor key the carlos binary embeds; an empty key
104 // refuses publish-identity.
105 SumsKey string
106 // ConsoleBuild reads a console artefact's version.Build stamp; nil is
107 // release.ArtifactBuild. A test seam only.
108 ConsoleBuild func([]byte) string
109}
110
111// IdentityPlatform is the one platform publish-identity names: the dev
112// trial's boxes are linux-arm64.
113const IdentityPlatform = "linux-arm64"
114
115// ConsoleProgram is the Go main package a console release must report when
116// its manifest records one (console release defences D2). It refuses the
117// 2026-10-06 shape, the carlos CLI shipped as the console.
118const ConsoleProgram = "github.com/carlosframework/platform/cmd/console"
119
120func (r Runner) Run(ctx context.Context, p Plan, dry bool) (Result, error) {
121 result := Result{DryRun: dry, Writes: []Write{}}
122 if p.Bucket == "" || p.Bucket != r.Directory.Bucket || !bytes.Equal(r.Signer.RootPublicKey(), r.Directory.PublicKey) {
123 return result, ErrActivation
124 }
125 d := r.Directory
126 o, err := d.Read(ctx)
127 if p.Phase == "initialize" {
128 if p.ExpectedRevision != 0 {
129 return result, ErrActivation
130 }
131 if err == nil {
132 if o.Roster.Revision != 1 || o.Roster.HomeRegion != "" || o.Roster.RequestLimitNS != 0 || o.Roster.Publication != "" || len(o.Roster.Boxes) != 0 || len(o.Roster.Consoles) != 0 || len(o.Roster.Regions) != 0 || len(o.Roster.Holds) != 0 || len(o.Roster.Activations) != 0 {
133 return result, ErrActivation
134 }
135 result.Revision = 1
136 return result, nil
137 }
138 if !errors.Is(err, os.ErrNotExist) {
139 return result, err
140 }
141 } else if err != nil {
142 return result, err
143 } else if p.ExpectedRevision == 0 || o.Roster.Revision != p.ExpectedRevision {
144 return result, ErrActivation
145 }
146 allowed := map[string]bool{}
147 switch p.Phase {
148 case "initialize", "configure", "home", "enroll-box", "enroll-console", "bind-key":
149 allowed[hostactors.Key] = true
150 case "invite-box":
151 if p.Box != nil {
152 allowed["control/host-actor-invitations/"+p.Box.Reference+".json"] = true
153 }
154 case "invite-console":
155 if p.Console != nil {
156 allowed["control/host-console-invitations/"+p.Console.InstanceID+".json"] = true
157 }
158 case "publish-floor":
159 allowed[hostactors.Key] = true
160 allowed[hostfloor.Key] = true
161 allowed[hostfloor.AuthKey] = true
162 case "publish-identity":
163 if p.Identity != nil {
164 if key, e := release.IdentityKey(p.Identity.ReleaseTag); e == nil {
165 allowed[key] = true
166 }
167 }
168 case "sign-policy":
169 // Writes nothing: the policy goes to the operator's file.
170 case "backfill-collect", "backfill-adopt", "backfill-done":
171 allowed[hostops.BackfillKey] = true
172 }
173 store := &checkedStore{allowed: allowed, Store: d.Store, pub: d.PublicKey, bucket: d.Bucket, expected: o.ETag, dry: dry, overlay: map[string]overlayObject{}, writes: &result.Writes}
174 d.Store = store
175 if p.Phase != "initialize" && p.Phase != "configure" && o.Roster.RequestLimitNS != int64(hostbox.RequestLimit) {
176 return result, ErrActivation
177 }
178 switch p.Phase {
179 case "initialize":
180 err = d.Initialize(ctx, r.Signer)
181 case "configure":
182 if len(p.Regions) == 0 || o.Roster.Publication != "" || len(o.Roster.Boxes) != 0 || len(o.Roster.Consoles) != 0 {
183 return result, ErrActivation
184 }
185 for name, region := range p.Regions {
186 if name != "ie" || region.AWSRegion != "eu-west-1" || !regexp.MustCompile(`^[0-9]{12}$`).MatchString(region.Account) || len(region.Namespaces) == 0 || len(region.Pools) == 0 {
187 return result, ErrActivation
188 }
189 }
190 next := hostactors.Clone(o.Roster)
191 next.Regions = p.Regions
192 next.RequestLimitNS = int64(hostbox.RequestLimit)
193 if !reflect.DeepEqual(next, o.Roster) {
194 _, err = d.Replace(ctx, r.Signer, o, next)
195 }
196 case "home":
197 if p.HomeRegion != "ie" || o.Roster.Regions[p.HomeRegion].AWSRegion != "eu-west-1" || o.Roster.HomeRegion != "" && o.Roster.HomeRegion != p.HomeRegion {
198 return result, ErrActivation
199 }
200 if o.Roster.HomeRegion != p.HomeRegion {
201 next := hostactors.Clone(o.Roster)
202 next.HomeRegion = p.HomeRegion
203 _, err = d.Replace(ctx, r.Signer, o, next)
204 }
205 case "invite-box", "enroll-box":
206 if p.Box == nil {
207 return result, ErrActivation
208 }
209 in := hostactors.EnrollmentRequest{Reference: p.Box.Reference, Namespace: p.Box.Namespace, Label: p.Box.Label, Region: p.Box.Region, Pool: p.Box.Pool, AWSRegion: p.Box.AWSRegion, TofuInstanceID: p.Box.InstanceID, Operator: p.Box.Operator, ExistingReference: true}
210 if !boxid.ValidBox(in.Reference) || in.TofuInstanceID == "" || in.Operator == "" || in.Label == "" || in.Region != "ie" || in.Namespace == "" || in.Pool == "" {
211 return result, ErrActivation
212 }
213 region, ok := o.Roster.Regions[in.Region]
214 if !ok || region.AWSRegion != "eu-west-1" || in.AWSRegion != "" && in.AWSRegion != region.AWSRegion {
215 return result, ErrActivation
216 }
217 if !slices.Contains(region.Namespaces, in.Namespace) || !slices.Contains(region.Pools, in.Pool) {
218 return result, ErrActivation
219 }
220 if old, exists := o.Roster.Boxes[in.Reference]; exists && (old.Label != in.Label || old.State != "active" || old.Namespace != in.Namespace || old.Region != in.Region || old.Pool != in.Pool || old.Cloud.InstanceID != in.TofuInstanceID) {
221 return result, ErrActivation
222 }
223 raw, e := boxid.Load(ctx, d.Store, in.Reference)
224 if e != nil {
225 return result, e
226 }
227 certs, e := boxid.EmbeddedCerts()
228 if e != nil {
229 return result, e
230 }
231 expect := boxid.Expect{Box: in.Reference, AWSRegion: region.AWSRegion, TofuInstanceID: in.TofuInstanceID}
232 for ref, b := range o.Roster.Boxes {
233 expect.Roster = append(expect.Roster, boxid.Enrolled{Box: ref, Cloud: b.Cloud})
234 }
235 if _, e = (boxid.Verifier{Account: region.Account, Certs: certs}).Enroll(raw, expect); e != nil {
236 return result, e
237 }
238 if p.Phase == "invite-box" {
239 _, err = d.Invite(ctx, r.Signer, in.Reference, raw)
240 } else {
241 _, err = d.EnrollAWS(ctx, r.Signer, o, in, raw)
242 }
243 case "invite-console", "enroll-console":
244 if p.Console == nil || p.Console.Compute == nil {
245 return result, ErrActivation
246 }
247 c := *p.Console
248 b, ok := o.Roster.Boxes[c.Compute.Box]
249 if !ok || b.State != "active" || b.Enrollment != c.Compute.Enrollment || b.Region != "ie" || c.DeploymentBucket != d.Bucket {
250 return result, ErrActivation
251 }
252 if old, ok := o.Roster.Consoles[c.InstanceID]; ok {
253 old.EnrolledAt = c.EnrolledAt
254 if !reflect.DeepEqual(old, c) {
255 return result, ErrActivation
256 }
257 break
258 }
259 if p.Phase == "invite-console" {
260 err = d.InviteConsole(ctx, r.Signer, c)
261 } else {
262 invited, e := d.InvitedConsole(ctx, o, c.InstanceID)
263 if e != nil || !reflect.DeepEqual(invited, c) {
264 return result, ErrActivation
265 }
266 next := hostactors.Clone(o.Roster)
267 next.Consoles[c.InstanceID] = c
268 _, err = d.Replace(ctx, r.Signer, o, next)
269 }
270 case "publish-floor":
271 if p.Floor == nil {
272 return result, ErrActivation
273 }
274 e := hostactors.ExactEvidence{Store: d.Store, PublicKey: d.PublicKey, Boxes: d.PrivateBoxReport}
275 err = d.PublishFloor(ctx, r.Signer, *p.Floor, e, time.Now())
276 if err == nil {
277 _, err = hostfloor.Read(ctx, d.Store, d.PublicKey, d.Bucket)
278 }
279 case "bind-key":
280 if p.Binding == nil || p.Binding.Bucket != p.Bucket {
281 return result, ErrActivation
282 }
283 _, err = d.BindCensusKey(ctx, r.Signer, o, *p.Binding, p.Proof)
284 case "sign-policy":
285 if p.Floor == nil {
286 return result, ErrActivation
287 }
288 err = r.signPolicy(ctx, d, o, *p.Floor, dry, &result)
289 case "publish-identity":
290 if p.Identity == nil {
291 return result, ErrActivation
292 }
293 err = r.publishIdentity(ctx, d, *p.Identity, dry, &result)
294 case "backfill-collect":
295 if p.Backfill == nil {
296 return result, ErrActivation
297 }
298 err = r.backfillCollect(ctx, d, o, *p.Backfill, &result)
299 case "backfill-adopt":
300 if p.Backfill == nil {
301 return result, ErrActivation
302 }
303 err = r.backfillAdopt(ctx, d, o, *p.Backfill, &result)
304 case "backfill-done":
305 if p.Backfill == nil {
306 return result, ErrActivation
307 }
308 err = r.backfillDone(ctx, d, o, *p.Backfill, &result)
309 default:
310 return result, ErrActivation
311 }
312 if err != nil {
313 return result, err
314 }
315 after, err := d.Read(ctx)
316 if err != nil {
317 return result, err
318 }
319 if after.ETag != store.expected {
320 return result, ErrActivation
321 }
322 result.Revision = after.Roster.Revision
323 if p.Phase != "initialize" && after.Roster.RequestLimitNS != int64(hostbox.RequestLimit) {
324 return result, ErrActivation
325 }
326 return result, nil
327}
328
329type overlayObject struct {
330 raw []byte
331 etag string
332}
333type checkedStore struct {
334 allowed map[string]bool
335 release.Store
336 pub ed25519.PublicKey
337 bucket, expected string
338 dry bool
339 overlay map[string]overlayObject
340 writes *[]Write
341}
342
343func (s *checkedStore) Get(ctx context.Context, key string) ([]byte, string, error) {
344 if v, ok := s.overlay[key]; ok {
345 return append([]byte(nil), v.raw...), v.etag, nil
346 }
347 return s.Store.Get(ctx, key)
348}
349func (s *checkedStore) Delete(context.Context, string) error { return ErrActivation }
350func (s *checkedStore) Put(ctx context.Context, key string, raw []byte, before string) (string, error) {
351 if !s.allowed[key] {
352 return "", ErrActivation
353 }
354 // Recheck the roster before every write, including non-roster objects.
355 _, current, e := s.Get(ctx, hostactors.Key)
356 if e != nil && !errors.Is(e, os.ErrNotExist) {
357 return "", e
358 }
359 if current != s.expected || key == hostactors.Key && before != s.expected {
360 return "", ErrActivation
361 }
362 var payload json.RawMessage
363 if key == hostfloor.Key {
364 payload = append([]byte(nil), raw...)
365 } else if release.VerifyRootEnvelope(s.pub, raw, &payload) != nil {
366 return "", ErrActivation
367 }
368 var tag string
369 if s.dry {
370 _, old, e := s.Get(ctx, key)
371 if e != nil && !errors.Is(e, os.ErrNotExist) {
372 return "", e
373 }
374 if old != before {
375 return "", release.ErrCASConflict
376 }
377 tag = hostfloor.Digest(raw)
378 s.overlay[key] = overlayObject{append([]byte(nil), raw...), tag}
379 } else {
380 tag, e = s.Store.Put(ctx, key, raw, before)
381 if e != nil {
382 got, readTag, readErr := s.Get(ctx, key)
383 if readErr != nil || readTag == "" || !bytes.Equal(got, raw) {
384 return "", errors.Join(e, readErr)
385 }
386 tag = readTag
387 }
388 }
389 got, readTag, e := s.Get(ctx, key)
390 if e != nil || tag == "" || tag != readTag || !bytes.Equal(got, raw) {
391 return "", ErrActivation
392 }
393 if key == hostfloor.Key {
394 if _, e = hostfloor.Read(ctx, s, s.pub, s.bucket); e != nil {
395 return "", e
396 }
397 } else {
398 var verified json.RawMessage
399 if release.VerifyRootEnvelope(s.pub, got, &verified) != nil {
400 return "", ErrActivation
401 }
402 }
403 if key == hostactors.Key {
404 s.expected = tag
405 }
406 *s.writes = append(*s.writes, Write{Key: key, BeforeETag: before, SHA256: hostfloor.Digest(raw), Payload: payload})
407 return tag, nil
408}
409