carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1//go:build linux
2
3package host
4
5import (
6 "bytes"
7 "context"
8 "crypto/ed25519"
9 "crypto/rand"
10 "crypto/sha256"
11 "encoding/base64"
12 "encoding/hex"
13 "encoding/json"
14 "errors"
15 "flag"
16 "os"
17 "path/filepath"
18 "strings"
19 "sync"
20 "sync/atomic"
21 "testing"
22
23 "github.com/carlosframework/platform/internal/claimactivation"
24 "github.com/carlosframework/platform/internal/hostactors"
25 "github.com/carlosframework/platform/internal/hostfloor"
26 "github.com/carlosframework/platform/internal/publicrelease"
27 "github.com/carlosframework/platform/internal/release"
28)
29
30func artifactSHA(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) }
31
32// artifactWorld is a fresh deployment ready for sign-policy and
33// publish-identity: roster initialized and configured, the console shipped
34// and promoted with pointer digests on.
35type artifactWorld struct {
36 r claimactivation.Runner
37 store release.Store
38 pub ed25519.PublicKey
39 router []byte
40 sums string
41 sig string
42}
43
44func newArtifactWorld(t *testing.T) *artifactWorld {
45 t.Helper()
46 ctx := t.Context()
47 store := release.NewDirStore(t.TempDir())
48 pub, key, _ := ed25519.GenerateKey(rand.Reader)
49 sumsPub, sumsKey, _ := ed25519.GenerateKey(rand.Reader)
50 w := &artifactWorld{store: store, pub: pub, router: []byte("carlos v0.28.2"),
51 r: claimactivation.Runner{Directory: hostactors.Directory{Store: store, PublicKey: pub, Bucket: "bucket"}, Signer: release.RootSigner(key), SumsKey: base64.StdEncoding.EncodeToString(sumsPub), ConsoleBuild: func([]byte) string { return "v0.28.2" }}}
52 w.run(t, claimactivation.Plan{Phase: "initialize"})
53 w.run(t, claimactivation.Plan{Phase: "configure", Regions: map[string]hostactors.Region{"ie": {Account: "123456789012", AWSRegion: "eu-west-1", Namespaces: []string{"platform"}, Pools: []string{"console"}}}})
54 if err := release.WriteDigestMarker(ctx, store, w.r.Signer, "bab", "test"); err != nil {
55 t.Fatal(err)
56 }
57 m := release.Manifest{App: "console", Version: "abc1234", Kind: "binary", Artifacts: map[string]release.Artifact{"linux-arm64": {}}}
58 if err := release.Ship(ctx, store, "bab", m, map[string][]byte{"linux-arm64": []byte("console build")}); err != nil {
59 t.Fatal(err)
60 }
61 if err := w.r.Signer.Promote(ctx, store, "bab", "console", "canary/console", "abc1234"); err != nil {
62 t.Fatal(err)
63 }
64 w.sums = artifactSHA(w.router) + " carlos-linux-arm64\n" + artifactSHA([]byte("amd64 deb")) + " carlos_0.28.2_amd64.deb\n" + artifactSHA([]byte("arm64 deb")) + " carlos_0.28.2_arm64.deb\n"
65 w.sig = base64.StdEncoding.EncodeToString(ed25519.Sign(sumsKey, []byte(w.sums)))
66 return w
67}
68
69// plan fills in the bucket and the current roster revision.
70func (w *artifactWorld) plan(t *testing.T, p claimactivation.Plan) claimactivation.Plan {
71 t.Helper()
72 p.Bucket = "bucket"
73 if o, err := w.r.Directory.Read(t.Context()); err == nil {
74 p.ExpectedRevision = o.Roster.Revision
75 }
76 return p
77}
78
79func (w *artifactWorld) run(t *testing.T, p claimactivation.Plan) claimactivation.Result {
80 t.Helper()
81 result, err := w.r.Run(t.Context(), w.plan(t, p), false)
82 if err != nil {
83 t.Fatal(p.Phase, err)
84 }
85 return result
86}
87
88func (w *artifactWorld) policyPlan(t *testing.T) claimactivation.Plan {
89 floor := hostfloor.Floor{Min: 1, MinRelease: "v0.28.2"}
90 return w.plan(t, claimactivation.Plan{Phase: "sign-policy", Floor: &floor})
91}
92
93func (w *artifactWorld) identityPlan(t *testing.T) claimactivation.Plan {
94 return w.plan(t, claimactivation.Plan{Phase: "publish-identity", Identity: &claimactivation.IdentityInput{ReleaseTag: "v0.28.2", MovesProtocol: 1, Sums: w.sums, SumsSignature: w.sig,
95 Console: claimactivation.ConsoleRelease{Account: "bab", App: "console", Channel: "canary/console", Version: "abc1234"}}})
96}
97
98// artifactPhases runs sign-policy and publish-identity on a fresh deployment
99// and returns their outputs, the deployment key and the router build's hash.
100func artifactPhases(t *testing.T) (policy, identity []byte, pub ed25519.PublicKey, router []byte) {
101 t.Helper()
102 w := newArtifactWorld(t)
103 policy = w.run(t, w.policyPlan(t)).Output
104 identity = w.run(t, w.identityPlan(t)).Output
105 return policy, identity, w.pub, w.router
106}
107
108// writeOut reserves name under dir and writes raw to it, as claims run
109// --out does.
110func writeOut(dir *os.File, name string, raw []byte, dry bool) error {
111 out, err := reserveActivationOutputAt(dir, name, dry)
112 if err != nil {
113 return err
114 }
115 err = out.finish(raw)
116 out.close()
117 return err
118}
119
120func openDir(t *testing.T) (*os.File, string) {
121 t.Helper()
122 path := t.TempDir()
123 dir, err := os.Open(path)
124 if err != nil {
125 t.Fatal(err)
126 }
127 t.Cleanup(func() { dir.Close() })
128 return dir, path
129}
130
131// C4: the phases' files are written byte for byte, 0600 and exclusively,
132// and guard-arm's own parse accepts exactly those bytes. A re-indented
133// policy (what jq would print) is refused, which is why nothing may pass
134// through jq.
135func TestActivationOutputRoundTripsIntoGuardArm(t *testing.T) {
136 policy, identity, pub, router := artifactPhases(t)
137 dir, dirPath := openDir(t)
138 var err error
139 for name, raw := range map[string][]byte{"policy.json": policy, "identity.json": identity} {
140 if err = writeOut(dir, name, raw, true); err != nil {
141 t.Fatal("dry run", name, err)
142 }
143 if _, err = os.Lstat(filepath.Join(dirPath, name)); !os.IsNotExist(err) {
144 t.Fatal("dry run wrote", name, err)
145 }
146 if err = writeOut(dir, name, raw, false); err != nil {
147 t.Fatal(name, err)
148 }
149 st, err := os.Lstat(filepath.Join(dirPath, name))
150 if err != nil || st.Mode() != 0o600 {
151 t.Fatal(name, st, err)
152 }
153 if err = writeOut(dir, name, raw, false); err == nil {
154 t.Fatal("replaced an existing", name)
155 }
156 if err = writeOut(dir, name, raw, true); err == nil {
157 t.Fatal("dry run accepted an existing", name)
158 }
159 }
160 policyFile, _ := os.ReadFile(filepath.Join(dirPath, "policy.json"))
161 identityFile, _ := os.ReadFile(filepath.Join(dirPath, "identity.json"))
162 if !bytes.Equal(policyFile, policy) || !bytes.Equal(identityFile, identity) {
163 t.Fatal("files are not the phase output")
164 }
165 p, f, err := guardArmInputs(policyFile, identityFile, pub, "bucket", "v0.28.2", "linux-arm64", artifactSHA(router))
166 if err != nil || p.Mode != "armed" || f.Floor.MinRelease != "v0.28.2" {
167 t.Fatal("guard-arm refused the written files", err)
168 }
169 var indented bytes.Buffer
170 if err = json.Indent(&indented, policyFile, "", " "); err != nil {
171 t.Fatal(err)
172 }
173 if _, _, err = guardArmInputs(indented.Bytes(), identityFile, pub, "bucket", "v0.28.2", "linux-arm64", artifactSHA(router)); err == nil {
174 t.Fatal("guard-arm accepted a re-indented policy")
175 }
176 for name, args := range map[string][]string{
177 "other build": {"v0.28.1", "linux-arm64", artifactSHA(router)},
178 "other platform": {"v0.28.2", "linux-amd64", artifactSHA(router)},
179 "other image": {"v0.28.2", "linux-arm64", strings.Repeat("0", 64)},
180 } {
181 if _, _, err = guardArmInputs(policyFile, identityFile, pub, "bucket", args[0], args[1], args[2]); err == nil {
182 t.Fatal("guard-arm accepted", name)
183 }
184 }
185 for _, name := range []string{"", ".", "..", "a/b"} {
186 if err = writeOut(dir, name, policy, false); err == nil {
187 t.Fatalf("wrote %q", name)
188 }
189 }
190}
191
192func TestConsoleInstallTargetNeedsThePublishedIdentity(t *testing.T) {
193 _, identity, pub, _ := artifactPhases(t)
194 tag, sha, err := consoleInstallTarget(identity, identity, pub, "v0.28.2", "linux-arm64")
195 if err != nil || tag != "v0.28.2" || sha != artifactSHA([]byte("console build")) {
196 t.Fatal(tag, sha, err)
197 }
198 other, _, _ := ed25519.GenerateKey(rand.Reader)
199 for name, tc := range map[string]struct {
200 staged, published []byte
201 pub ed25519.PublicKey
202 build, platform string
203 }{
204 "not published": {identity, nil, pub, "v0.28.2", "linux-arm64"},
205 "other bytes": {identity, append(append([]byte(nil), identity...), '\n'), pub, "v0.28.2", "linux-arm64"},
206 "other key": {identity, identity, other, "v0.28.2", "linux-arm64"},
207 "other build": {identity, identity, pub, "v0.28.1", "linux-arm64"},
208 "other platform": {identity, identity, pub, "v0.28.2", "linux-amd64"},
209 "not identity": {[]byte("{}"), []byte("{}"), pub, "v0.28.2", "linux-arm64"},
210 } {
211 if _, _, err := consoleInstallTarget(tc.staged, tc.published, tc.pub, tc.build, tc.platform); err == nil {
212 t.Fatal("accepted", name)
213 }
214 }
215}
216
217func TestInstallConsoleRefusesWithoutRoot(t *testing.T) {
218 if os.Geteuid() == 0 {
219 t.Skip("runs as non-root")
220 }
221 src := filepath.Join(t.TempDir(), "console")
222 if err := os.WriteFile(src, []byte("console build"), 0o755); err != nil {
223 t.Fatal(err)
224 }
225 dir := filepath.Join(t.TempDir(), "protected")
226 for _, dry := range []bool{true, false} {
227 if _, err := installProtectedConsole(dir, src, artifactSHA([]byte("console build")), dry); err == nil {
228 t.Fatal("installed without root")
229 }
230 }
231 if _, err := os.Lstat(dir); !os.IsNotExist(err) {
232 t.Fatal("refusal created the directory", err)
233 }
234}
235
236func TestInstallConsoleUpgradeRefusesWithoutRoot(t *testing.T) {
237 if os.Geteuid() == 0 {
238 t.Skip("runs as non-root")
239 }
240 body := []byte("console build")
241 src := filepath.Join(t.TempDir(), "console")
242 if err := os.WriteFile(src, body, 0o755); err != nil {
243 t.Fatal(err)
244 }
245 dir := filepath.Join(t.TempDir(), "protected")
246 for _, dry := range []bool{true, false} {
247 if _, err := upgradeProtectedConsole(dir, src, artifactSHA(body), artifactSHA([]byte("old")), dry); err == nil {
248 t.Fatal("upgraded without root")
249 }
250 }
251 if _, err := os.Lstat(dir); !os.IsNotExist(err) {
252 t.Fatal("refusal created the directory", err)
253 }
254}
255
256// consoleDigest refuses more than consoleMaxBytes, by size or by bytes read
257// (a pipe reports size 0), and an upgrade refuses a source path inside the
258// console directory before reading anything.
259func TestConsoleDigestRefusesOversized(t *testing.T) {
260 t.Parallel()
261 big := filepath.Join(t.TempDir(), "big")
262 if err := os.WriteFile(big, nil, 0o755); err != nil {
263 t.Fatal(err)
264 }
265 if err := os.Truncate(big, consoleMaxBytes+2); err != nil {
266 t.Fatal(err)
267 }
268 f, err := os.Open(big)
269 if err != nil {
270 t.Fatal(err)
271 }
272 defer f.Close()
273 if _, err := consoleDigest(f); err == nil {
274 t.Fatal("hashed an oversized file")
275 }
276 r, w, err := os.Pipe()
277 if err != nil {
278 t.Fatal(err)
279 }
280 defer r.Close()
281 go func() {
282 buf := make([]byte, 1<<20)
283 for n := int64(0); n <= consoleMaxBytes; n += int64(len(buf)) {
284 if _, err := w.Write(buf); err != nil {
285 break
286 }
287 }
288 w.Close()
289 }()
290 if _, err := consoleDigest(r); err == nil {
291 t.Fatal("hashed an oversized stream")
292 }
293 small := filepath.Join(t.TempDir(), "small")
294 if err := os.WriteFile(small, []byte("console build"), 0o755); err != nil {
295 t.Fatal(err)
296 }
297 s, err := os.Open(small)
298 if err != nil {
299 t.Fatal(err)
300 }
301 defer s.Close()
302 if got, err := consoleDigest(s); err != nil || got != artifactSHA([]byte("console build")) {
303 t.Fatal(got, err)
304 }
305}
306
307// --upgrade and --from are install-console's, and come together; anywhere
308// else they are refused before any input is read, as unknown flags were.
309func TestClaimsUpgradeFlagsBelongToInstallConsole(t *testing.T) {
310 sha := artifactSHA([]byte("old"))
311 for _, args := range [][]string{
312 {"install-console", "--upgrade"},
313 {"install-console", "--from", sha},
314 {"install-console", "--upgrade=false", "--from", sha},
315 {"install-console", "--upgrade", "--from", ""},
316 {"install-console", "--upgrade=false"},
317 {"inspect", "--upgrade", "--from", sha},
318 {"console-report", "--from", sha},
319 } {
320 var out bytes.Buffer
321 if err := runSystemClaims(args, &out); !errors.Is(err, errActivation) || out.Len() != 0 {
322 t.Fatal("accepted", args, err, out.String())
323 }
324 }
325 parse := func(args ...string) *flag.FlagSet {
326 fs := flag.NewFlagSet("claims", flag.ContinueOnError)
327 fs.Bool("upgrade", false, "")
328 fs.String("from", "", "")
329 fs.String("source", "", "")
330 if err := fs.Parse(args); err != nil {
331 t.Fatal(err)
332 }
333 return fs
334 }
335 for _, tc := range []struct {
336 command string
337 args []string
338 upgrade bool
339 from string
340 refused bool
341 }{
342 {"install-console", []string{"--source", "x"}, false, "", false},
343 {"inspect", nil, false, "", false},
344 {"install-console", []string{"--upgrade", "--from", sha}, true, sha, false},
345 {"install-console", []string{"--upgrade"}, true, "", true},
346 {"install-console", []string{"--from", sha}, false, sha, true},
347 {"install-console", []string{"--upgrade=false"}, false, "", true},
348 {"install-console", []string{"--upgrade", "--from", ""}, true, "", true},
349 {"inspect", []string{"--upgrade", "--from", sha}, true, sha, true},
350 {"install-control", []string{"--from", sha}, false, sha, true},
351 {"install-control", []string{"--upgrade"}, true, "", true},
352 } {
353 if got := upgradeFlagsRefused(tc.command, parse(tc.args...), tc.upgrade, tc.from); got != tc.refused {
354 t.Fatal(tc.command, tc.args, "refused", got)
355 }
356 }
357}
358
359// claims run verifies release checksum lists with the vendor key the
360// binary embeds, never a key from its inputs.
361func TestActivationRunnerUsesTheVendorSumsKey(t *testing.T) {
362 pub, key, _ := ed25519.GenerateKey(rand.Reader)
363 r := activationRunner(release.NewDirStore(t.TempDir()), pub, "bucket", key)
364 if r.SumsKey != publicrelease.Default().PubKey || r.SumsKey != "R+EO3qQ47VsuKl9Qq/pkDXBucPYvzp5lU2ut4zpnJPc=" {
365 t.Fatal("sums key", r.SumsKey)
366 }
367}
368
369func TestClaimsUsageNamesTheArtifactCommands(t *testing.T) {
370 for _, want := range []string{"install-console --identity FILE --source FILE --bucket B --aws-region R --public-key K [--upgrade --from SHA256]\n", "[--out FILE]"} {
371 if !strings.Contains(activationClaimsUsage, want) {
372 t.Fatal("usage lacks", want)
373 }
374 }
375}
376
377// gatedStore holds its first Put until release is closed, after telling
378// reached, and counts Puts.
379type gatedStore struct {
380 release.Store
381 reached, release chan struct{}
382 once sync.Once
383 puts atomic.Int32
384}
385
386func (s *gatedStore) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) {
387 s.puts.Add(1)
388 s.once.Do(func() { close(s.reached); <-s.release })
389 return s.Store.Put(ctx, key, raw, etag)
390}
391
392// F4: two invocations naming the same --out. The first has reserved the
393// path and is inside its identity PUT; the second fails its reservation
394// before its phase runs and sends the bucket nothing. The first then
395// finishes and the file is its published identity.
396func TestActivationOutputCompetingInvocations(t *testing.T) {
397 w := newArtifactWorld(t)
398 dir, dirPath := openDir(t)
399 winner := &gatedStore{Store: w.store, reached: make(chan struct{}), release: make(chan struct{})}
400 loser := &gatedStore{Store: w.store, reached: make(chan struct{}), release: make(chan struct{})}
401 close(loser.release)
402 plan := w.identityPlan(t)
403 invoke := func(store *gatedStore) (claimactivation.Result, *putAttempts, error) {
404 attempts := &putAttempts{Store: store}
405 out, err := reserveActivationOutputAt(dir, "identity.json", false)
406 if err != nil {
407 return claimactivation.Result{}, attempts, err
408 }
409 result, err := runProducingPhase(t.Context(), w.r, attempts, plan, out, false)
410 return result, attempts, err
411 }
412 type outcome struct {
413 result claimactivation.Result
414 err error
415 }
416 done := make(chan outcome)
417 go func() {
418 result, _, err := invoke(winner)
419 done <- outcome{result, err}
420 }()
421 <-winner.reached
422 if _, attempts, err := invoke(loser); err == nil || attempts.attempted.Load() || loser.puts.Load() != 0 {
423 t.Fatal("the loser published or was not refused", err, loser.puts.Load())
424 }
425 close(winner.release)
426 got := <-done
427 if got.err != nil {
428 t.Fatal(got.err)
429 }
430 file, err := os.ReadFile(filepath.Join(dirPath, "identity.json"))
431 stored, _, serr := w.store.Get(t.Context(), "control/system/router/releases/v0.28.2.json")
432 if err != nil || serr != nil || !bytes.Equal(file, stored) || !bytes.Equal(file, got.result.Output) {
433 t.Fatal("winner's file is not the published identity", err, serr)
434 }
435}
436
437// F5: a failure keeps the reservation, never removes it, and the error
438// names it and asks for a new --out path. A failure before any bucket write
439// says only that. Nothing is removed even when the entry was replaced: the
440// write refuses and the replacement stays.
441func TestActivationOutputFailureKeepsTheReservation(t *testing.T) {
442 w := newArtifactWorld(t)
443 dir, dirPath := openDir(t)
444 path := filepath.Join(dirPath, "policy.json")
445 stale := w.policyPlan(t)
446 stale.ExpectedRevision--
447 out, err := reserveOutput(dir, "policy.json", path, false)
448 if err != nil {
449 t.Fatal(err)
450 }
451 attempts := &putAttempts{Store: w.store}
452 _, err = runProducingPhase(t.Context(), w.r, attempts, stale, out, false)
453 if err == nil || attempts.attempted.Load() || !strings.HasSuffix(err.Error(), "; "+path+" was kept. run it again with a new --out path") {
454 t.Fatal("stale plan", err)
455 }
456 if st, err := os.Lstat(path); err != nil || st.Size() != 0 || st.Mode() != 0o600 {
457 t.Fatal("reservation not kept", st, err)
458 }
459 if _, err = reserveOutput(dir, "policy.json", path, false); err == nil {
460 t.Fatal("rerun on the kept path accepted")
461 }
462 // The dry run reserves nothing, so it has nothing to keep.
463 other := filepath.Join(dirPath, "dry.json")
464 out, err = reserveOutput(dir, "dry.json", other, true)
465 if err != nil {
466 t.Fatal(err)
467 }
468 if _, err = runProducingPhase(t.Context(), w.r, &putAttempts{Store: w.store}, stale, out, true); err == nil || strings.Contains(err.Error(), "was kept") {
469 t.Fatal("dry run", err)
470 }
471 if _, err = os.Lstat(other); !os.IsNotExist(err) {
472 t.Fatal("dry run created its output", err)
473 }
474 // A replaced entry: the write refuses and nothing is removed.
475 replaced := filepath.Join(dirPath, "identity.json")
476 out, err = reserveOutput(dir, "identity.json", replaced, false)
477 if err != nil {
478 t.Fatal(err)
479 }
480 if err = os.Rename(replaced, replaced+".moved"); err != nil {
481 t.Fatal(err)
482 }
483 if err = os.WriteFile(replaced, []byte("someone else's"), 0o600); err != nil {
484 t.Fatal(err)
485 }
486 _, err = runProducingPhase(t.Context(), w.r, &putAttempts{Store: w.store}, w.policyPlan(t), out, false)
487 if err == nil || !strings.HasSuffix(err.Error(), replaced+" was kept. run it again with a new --out path") {
488 t.Fatal("finished into a replaced entry", err)
489 }
490 if b, err := os.ReadFile(replaced); err != nil || string(b) != "someone else's" {
491 t.Fatal("the replacement changed", err)
492 }
493 if _, err = os.Lstat(replaced + ".moved"); err != nil {
494 t.Fatal("the reservation was removed", err)
495 }
496}
497
498// failingReadback commits Puts to key and then fails every Get of key, as
499// when a create reaches the bucket and its readback is lost.
500type failingReadback struct {
501 release.Store
502 key string
503 sent atomic.Bool
504}
505
506func (s *failingReadback) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) {
507 tag, err := s.Store.Put(ctx, key, raw, etag)
508 if key == s.key {
509 s.sent.Store(true)
510 }
511 return tag, err
512}
513
514func (s *failingReadback) Get(ctx context.Context, key string) ([]byte, string, error) {
515 if key == s.key && s.sent.Load() {
516 return nil, "", errors.New("readback lost")
517 }
518 return s.Store.Get(ctx, key)
519}
520
521// A failure after the identity reached the bucket keeps the reserved file
522// and says so.
523func TestActivationOutputKeptAfterPublication(t *testing.T) {
524 w := newArtifactWorld(t)
525 dir, dirPath := openDir(t)
526 out, err := reserveOutput(dir, "identity.json", filepath.Join(dirPath, "identity.json"), false)
527 if err != nil {
528 t.Fatal(err)
529 }
530 key := "control/system/router/releases/v0.28.2.json"
531 _, err = runProducingPhase(t.Context(), w.r, &putAttempts{Store: &failingReadback{Store: w.store, key: key}}, w.identityPlan(t), out, false)
532 if err == nil || !strings.Contains(err.Error(), "the phase wrote to the bucket before it failed, so "+out.path+" was kept. run it again with a new --out path") {
533 t.Fatal("failure after publication", err)
534 }
535 if _, err = os.Lstat(out.path); err != nil {
536 t.Fatal("reservation removed after publication", err)
537 }
538 if _, _, err = w.store.Get(t.Context(), key); err != nil {
539 t.Fatal("identity not published", err)
540 }
541}
542