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 "crypto/rand"
8 "encoding/json"
9 "errors"
10 "github.com/carlosframework/platform/internal/hostfloor"
11 "github.com/carlosframework/platform/internal/status"
12 "os"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/carlosframework/platform/internal/hostactors"
18 "github.com/carlosframework/platform/internal/hostbox"
19 "github.com/carlosframework/platform/internal/release"
20)
21
22func runnerFixture(t *testing.T) Runner {
23 t.Helper()
24 pub, key, _ := ed25519.GenerateKey(rand.Reader)
25 return Runner{Directory: hostactors.Directory{Store: release.NewDirStore(t.TempDir()), PublicKey: pub, Bucket: "bucket"}, Signer: release.RootSigner(key)}
26}
27func TestActivationInitializeConfigureAndHome(t *testing.T) {
28 r := runnerFixture(t)
29 ctx := t.Context()
30 p := Plan{Phase: "initialize", Bucket: "bucket"}
31 result, err := r.Run(ctx, p, true)
32 if err != nil || len(result.Writes) != 1 {
33 t.Fatal(result, err)
34 }
35 if _, err = r.Directory.Read(ctx); !errors.Is(err, os.ErrNotExist) {
36 t.Fatal("dry run wrote roster", err)
37 }
38 if _, err = r.Run(ctx, p, false); err != nil {
39 t.Fatal(err)
40 }
41 p = Plan{Phase: "configure", Bucket: "bucket", ExpectedRevision: 1, Regions: map[string]hostactors.Region{"ie": {Account: "123456789012", AWSRegion: "eu-west-1", Namespaces: []string{"platform"}, Pools: []string{"cohort1"}}}}
42 if _, err = r.Run(ctx, p, false); err != nil {
43 t.Fatal(err)
44 }
45 got, _ := r.Directory.Read(ctx)
46 if got.Roster.RequestLimitNS != int64(hostbox.RequestLimit) {
47 t.Fatal("wrong request limit")
48 }
49 if _, err = r.Run(ctx, p, false); err == nil {
50 t.Fatal("stale revision accepted")
51 }
52 p = Plan{Phase: "home", Bucket: "bucket", ExpectedRevision: 2, HomeRegion: "ie"}
53 if _, err = r.Run(ctx, p, false); err != nil {
54 t.Fatal(err)
55 }
56 got, _ = r.Directory.Read(ctx)
57 if got.Roster.HomeRegion != "ie" {
58 t.Fatal("home absent")
59 }
60 p.ExpectedRevision = 3
61 if result, err = r.Run(ctx, p, false); err != nil || len(result.Writes) != 0 {
62 t.Fatal("home replay", result, err)
63 }
64 p.HomeRegion = "uk"
65 if _, err = r.Run(ctx, p, false); err == nil {
66 t.Fatal("home changed")
67 }
68}
69
70type corruptReadback struct {
71 release.Store
72 corrupt bool
73}
74
75func (s *corruptReadback) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) {
76 tag, err := s.Store.Put(ctx, key, raw, etag)
77 s.corrupt = true
78 return tag, err
79}
80func (s *corruptReadback) Get(ctx context.Context, key string) ([]byte, string, error) {
81 raw, tag, err := s.Store.Get(ctx, key)
82 if s.corrupt && key == hostactors.Key {
83 raw = []byte(`{"untrusted":true}`)
84 }
85 return raw, tag, err
86}
87func TestActivationRequiresAuthenticatedReadback(t *testing.T) {
88 r := runnerFixture(t)
89 r.Directory.Store = &corruptReadback{Store: r.Directory.Store}
90 if _, err := r.Run(t.Context(), Plan{Phase: "initialize", Bucket: "bucket"}, false); err == nil {
91 t.Fatal("unverified readback accepted")
92 }
93}
94func TestActivationRefusesUnscopedInputs(t *testing.T) {
95 r := runnerFixture(t)
96 for _, p := range []Plan{{Phase: "initialize", Bucket: "other"}, {Phase: "unknown", Bucket: "bucket"}, {Phase: "enroll-box", Bucket: "bucket"}, {Phase: "bind-key", Bucket: "bucket"}} {
97 if _, err := r.Run(t.Context(), p, false); err == nil {
98 t.Fatal("accepted", p)
99 }
100 }
101}
102
103type lostWriteReply struct {
104 release.Store
105 once bool
106}
107
108func (s *lostWriteReply) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) {
109 tag, err := s.Store.Put(ctx, key, raw, etag)
110 if err == nil && !s.once {
111 s.once = true
112 return "", errors.New("lost reply")
113 }
114 return tag, err
115}
116func TestActivationResumesAfterLostWriteReply(t *testing.T) {
117 r := runnerFixture(t)
118 s := &lostWriteReply{Store: r.Directory.Store}
119 r.Directory.Store = s
120 p := Plan{Phase: "initialize", Bucket: "bucket"}
121 if result, err := r.Run(t.Context(), p, false); err != nil || len(result.Writes) != 1 {
122 t.Fatal("authenticated lost-reply recovery failed", result, err)
123 }
124 if result, err := r.Run(t.Context(), p, false); err != nil || result.Revision != 1 {
125 t.Fatal(result, err)
126 }
127}
128func TestActivationBindRequiresPublishedFloorAndPossession(t *testing.T) {
129 r := runnerFixture(t)
130 ctx := t.Context()
131 if err := r.Directory.Initialize(ctx, r.Signer); err != nil {
132 t.Fatal(err)
133 }
134 o, _ := r.Directory.Read(ctx)
135 next := hostactors.Clone(o.Roster)
136 next.RequestLimitNS = int64(hostbox.RequestLimit)
137 next.Boxes["leo"] = hostactors.Box{Enrollment: strings.Repeat("a", 32), Namespace: "platform", BoxID: "leo", Label: "leo", Region: "ie", Pool: "cohort1", Cloud: hostactors.Cloud{Provider: "aws", Account: "123456789012", Region: "eu-west-1", InstanceID: "i-leo"}, EvidenceSHA256: strings.Repeat("b", 64), State: "active", LossIDs: []string{}}
138 o, err := r.Directory.Replace(ctx, r.Signer, o, next)
139 if err != nil {
140 t.Fatal(err)
141 }
142 pub, key, _ := ed25519.GenerateKey(rand.Reader)
143 binding := hostactors.CensusKeyBinding{Bucket: "bucket", Box: "leo", Enrollment: next.Boxes["leo"].Enrollment, PublicKey: pub}
144 raw, _ := json.Marshal(binding)
145 p := Plan{Phase: "bind-key", Bucket: "bucket", ExpectedRevision: o.Roster.Revision, Binding: &binding, Proof: ed25519.Sign(key, raw)}
146 if _, err = r.Run(ctx, p, false); err == nil {
147 t.Fatal("bound before floor")
148 }
149 floor := hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"}
150 if err = hostfloor.Publish(ctx, r.Directory.Store, r.Signer, "bucket", floor); err != nil {
151 t.Fatal(err)
152 }
153 f, err := hostfloor.Read(ctx, r.Directory.Store, r.Directory.PublicKey, "bucket")
154 if err != nil {
155 t.Fatal(err)
156 }
157 next = hostactors.Clone(o.Roster)
158 next.Publication = "published"
159 next.FloorSHA256 = f.Digest
160 o, err = r.Directory.Replace(ctx, r.Signer, o, next)
161 if err != nil {
162 t.Fatal(err)
163 }
164 p.ExpectedRevision = o.Roster.Revision
165 valid := append([]byte(nil), p.Proof...)
166 p.Proof[0] ^= 1
167 if _, err = r.Run(ctx, p, false); err == nil {
168 t.Fatal("bad possession proof accepted")
169 }
170 p.Proof = valid
171 result, err := r.Run(ctx, p, true)
172 if err != nil || len(result.Writes) != 1 {
173 t.Fatal(result, err)
174 }
175 current, _ := r.Directory.Read(ctx)
176 if len(current.Roster.Boxes["leo"].CensusPublicKey) != 0 {
177 t.Fatal("dry run bound key")
178 }
179 result, err = r.Run(ctx, p, false)
180 if err != nil {
181 t.Fatal(err)
182 }
183 p.ExpectedRevision = result.Revision
184 result, err = r.Run(ctx, p, false)
185 if err != nil || len(result.Writes) != 0 {
186 t.Fatal("binding not resumable", result, err)
187 }
188}
189func TestActivationConsoleInvitationEnrollment(t *testing.T) {
190 r := runnerFixture(t)
191 ctx := t.Context()
192 r.Directory.Initialize(ctx, r.Signer)
193 o, _ := r.Directory.Read(ctx)
194 next := hostactors.Clone(o.Roster)
195 next.RequestLimitNS = int64(hostbox.RequestLimit)
196 next.Boxes["sophie"] = hostactors.Box{Enrollment: strings.Repeat("a", 32), Namespace: "platform", BoxID: "sophie", Label: "sophie", Region: "ie", Pool: "console", Cloud: hostactors.Cloud{Provider: "aws", Account: "123456789012", Region: "eu-west-1", InstanceID: "i-sophie"}, EvidenceSHA256: strings.Repeat("b", 64), State: "active", LossIDs: []string{}}
197 o, err := r.Directory.Replace(ctx, r.Signer, o, next)
198 if err != nil {
199 t.Fatal(err)
200 }
201 c := hostactors.Console{InstanceID: "dev-console", Enrollment: strings.Repeat("c", 32), Namespaces: []string{"platform"}, DeploymentBucket: "bucket", Compute: &hostactors.ComputeBinding{Box: "sophie", Enrollment: next.Boxes["sophie"].Enrollment}}
202 p := Plan{Phase: "enroll-console", Bucket: "bucket", ExpectedRevision: o.Roster.Revision, Console: &c}
203 if _, err = r.Run(ctx, p, false); err == nil {
204 t.Fatal("enrollment without invitation")
205 }
206 p.Phase = "invite-console"
207 if _, err = r.Run(ctx, p, false); err != nil {
208 t.Fatal(err)
209 }
210 p.Phase = "enroll-console"
211 result, err := r.Run(ctx, p, false)
212 if err != nil {
213 t.Fatal(err)
214 }
215 p.ExpectedRevision = result.Revision
216 if result, err = r.Run(ctx, p, false); err != nil || len(result.Writes) != 0 {
217 t.Fatal("replay", result, err)
218 }
219 c.Compute.Box = "leo"
220 if _, err = r.Run(ctx, p, false); err == nil {
221 t.Fatal("wrong compute accepted")
222 }
223}
224
225func TestActivationFloorWaitsForGuardAndComputeReport(t *testing.T) {
226 r := runnerFixture(t)
227 ctx := t.Context()
228 store := r.Directory.Store
229 if err := r.Directory.Initialize(ctx, r.Signer); err != nil {
230 t.Fatal(err)
231 }
232 o, _ := r.Directory.Read(ctx)
233 next := hostactors.Clone(o.Roster)
234 next.RequestLimitNS = int64(hostbox.RequestLimit)
235 en := strings.Repeat("a", 32)
236 hash := strings.Repeat("f", 64)
237 tag := "v1.0.0"
238 next.Boxes["sophie"] = hostactors.Box{Enrollment: en, Namespace: "platform", BoxID: "sophie", Label: "sophie", Region: "ie", Pool: "console", Cloud: hostactors.Cloud{Provider: "aws", Account: "123456789012", Region: "eu-west-1", InstanceID: "i-sophie"}, EvidenceSHA256: strings.Repeat("b", 64), State: "active", LossIDs: []string{}}
239 c := hostactors.Console{InstanceID: "dev-console", Enrollment: strings.Repeat("c", 32), Namespaces: []string{"platform"}, DeploymentBucket: "bucket", Compute: &hostactors.ComputeBinding{Box: "sophie", Enrollment: en}}
240 next.Consoles[c.InstanceID] = c
241 o, err := r.Directory.Replace(ctx, r.Signer, o, next)
242 if err != nil {
243 t.Fatal(err)
244 }
245 floor := hostfloor.Floor{Min: 1, MinRelease: tag}
246 body, _ := json.Marshal(floor)
247 digest := hostfloor.Digest(body)
248 rel := release.SystemRelease{Version: tag, Source: "release:" + tag, ReleaseTag: tag, MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{"linux-amd64": {Key: release.SystemArtifactKey(tag, "linux-amd64"), SHA256: hash}}, ConsoleArtifacts: map[string]release.SystemArtifact{"linux-amd64": {Key: "bootstrap/console/" + tag + "/carlos-linux-amd64", SHA256: hash}}}
249 if _, err = release.PromoteSystem(ctx, store, r.Signer, "router", "stable", rel, release.SystemPromoteOptions{}); err != nil {
250 t.Fatal(err)
251 }
252 wire, _, err := store.Get(ctx, release.SystemPointerKey("router", "stable"))
253 if err != nil {
254 t.Fatal(err)
255 }
256 if err = release.PublishSystemIdentity(ctx, store, r.Directory.PublicKey, wire); err != nil {
257 t.Fatal(err)
258 }
259 now := time.Now().UTC()
260 report := status.Box{GeneratedAt: now, Metrics: &status.BoxMetrics{Enrollment: en, InstanceID: "i-sophie", Release: tag, ReleaseTag: tag, Platform: "linux-amd64", BinarySHA256: hash, MovesProtocol: 1, BootID: "boot", FloorSHA256: digest, FloorGuardReady: true}}
261 put := func(key string, value any) {
262 t.Helper()
263 raw, _ := json.Marshal(value)
264 _, etag, _ := store.Get(ctx, key)
265 if _, err := store.Put(ctx, key, raw, etag); err != nil {
266 t.Fatal(err)
267 }
268 }
269 put("status/boxes/sophie.json", report)
270 cr := hostactors.Report{Compute: c.Compute, Component: "console", GeneratedAt: now, Actor: c.InstanceID, Enrollment: c.Enrollment, InstanceID: c.InstanceID, ProcessID: "console-process", Release: tag, ReleaseTag: tag, Platform: "linux-amd64", BinarySHA256: hash, MovesProtocol: 1, BootID: "boot", FloorSHA256: digest, FloorGuardReady: true}
271 put("status/consoles/dev-console.json", cr)
272 p := Plan{Phase: "publish-floor", Bucket: "bucket", ExpectedRevision: o.Roster.Revision, Floor: &floor}
273 if _, err = r.Run(ctx, p, false); err == nil {
274 t.Fatal("floor published without box process report")
275 }
276 o, _ = r.Directory.Read(ctx)
277 if o.Roster.Publication != "preparing" {
278 t.Fatal("missing resumable preparation")
279 }
280 if _, _, err = store.Get(ctx, hostfloor.Key); !errors.Is(err, os.ErrNotExist) {
281 t.Fatal("floor written before readiness")
282 }
283 report.Metrics.ConsoleProcesses = []status.ConsoleProcess{{ConsoleID: c.InstanceID, Enrollment: c.Enrollment, ProcessID: cr.ProcessID, BinarySHA256: hash}}
284 put("status/boxes/sophie.json", report)
285 p.ExpectedRevision = o.Roster.Revision
286 if result, err := r.Run(ctx, p, true); err != nil || len(result.Writes) != 3 {
287 t.Fatal("floor preview", result, err)
288 }
289 o, _ = r.Directory.Read(ctx)
290 if o.Roster.Publication != "preparing" {
291 t.Fatal("preview published")
292 }
293 // The policy a box arms with, signed offline before publication,
294 // carries exactly the floor and authorization publish-floor writes.
295 signed, err := r.Run(ctx, Plan{Phase: "sign-policy", Bucket: "bucket", ExpectedRevision: o.Roster.Revision, Floor: &floor}, false)
296 if err != nil || len(signed.Writes) != 0 || signed.FloorSHA256 != digest {
297 t.Fatal("sign-policy", signed, err)
298 }
299 var armed hostfloor.Policy
300 if err = json.Unmarshal(signed.Output, &armed); err != nil {
301 t.Fatal(err)
302 }
303 if _, err = r.Run(ctx, p, false); err != nil {
304 t.Fatal(err)
305 }
306 for key, want := range map[string][]byte{hostfloor.Key: armed.Floor, hostfloor.AuthKey: armed.Authorization} {
307 got, _, err := store.Get(ctx, key)
308 if err != nil || !bytes.Equal(got, want) {
309 t.Fatalf("published %s differs from the armed policy: %s vs %s (%v)", key, got, want, err)
310 }
311 }
312 o, _ = r.Directory.Read(ctx)
313 if o.Roster.Publication != "published" {
314 t.Fatal("floor not published")
315 }
316 if _, err = hostfloor.Read(ctx, store, r.Directory.PublicKey, "bucket"); err != nil {
317 t.Fatal("floor readback", err)
318 }
319}
320
321func TestActivationStoreRefusesUnlistedWrites(t *testing.T) {
322 r := runnerFixture(t)
323 writes := []Write{}
324 store := &checkedStore{Store: r.Directory.Store, pub: r.Directory.PublicKey, bucket: "bucket", overlay: map[string]overlayObject{}, writes: &writes}
325 raw, err := release.SignRootEnvelope(r.Signer, struct {
326 OK bool `json:"ok"`
327 }{true})
328 if err != nil {
329 t.Fatal(err)
330 }
331 key := "apps/bab/app/instances/test.example.json"
332 if _, err = store.Put(t.Context(), key, raw, ""); err == nil {
333 t.Fatal("activation transport wrote an unlisted resource")
334 }
335 if _, _, err = store.Get(t.Context(), key); !errors.Is(err, os.ErrNotExist) {
336 t.Fatal("unlisted resource changed", err)
337 }
338}
339