| new file mode 100644 |
| index 000000000..26ffb937d |
| --- /dev/null |
| +++ b/internal/host/systemapply_test.go |
| @@ -0,0 +1,369 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// applyFixture is a fake box: temp opt/data/etc roots, a DirStore with a |
| +// signed pointer, and a sysOps that records every call and answers like a |
| +// healthy systemd. The candidate "binary" is answered by the fake run. |
| +type applyFixture struct { |
| + t *testing.T |
| + a *systemApplier |
| + store *release.DirStore |
| + priv ed25519.PrivateKey |
| + calls []string // "systemctl is-active carlos-edge", "run <base> version", … |
| + answer map[string]string // "version:<path>" → stdout; "plan:<path>" → JSON plan |
| + now time.Time |
| + edgeUp bool |
| + nrestarts string |
| + activeEnter string |
| + probeErr error |
| + edgeStartedThisBoot bool |
| +} |
| + |
| +func newApplyFixture(t *testing.T, channel, build string) *applyFixture { |
| + t.Helper() |
| + pub, priv, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f := &applyFixture{t: t, store: release.NewDirStore(t.TempDir()), priv: priv, answer: map[string]string{}, |
| + now: time.Date(2026, 9, 6, 11, 0, 0, 0, time.UTC), edgeUp: true, nrestarts: "0", activeEnter: "1000", edgeStartedThisBoot: true} |
| + f.a = &systemApplier{ |
| + store: f.store, pub: pub, channel: channel, |
| + optDir: t.TempDir(), dataDir: t.TempDir(), etcDir: t.TempDir(), build: build, |
| + platform: "linux-arm64", |
| + } |
| + f.a.ops = sysOps{ |
| + systemctl: func(args ...string) (string, error) { |
| + f.calls = append(f.calls, "systemctl "+strings.Join(args, " ")) |
| + switch { |
| + case args[0] == "is-active" && args[1] == systemEdgeUnit: |
| + if f.edgeUp { |
| + return "active\n", nil |
| + } |
| + return "failed\n", errors.New("inactive") |
| + case args[0] == "show": |
| + return f.nrestarts + "\n" + f.activeEnter + "\n", nil |
| + } |
| + return "", nil |
| + }, |
| + run: func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) { |
| + f.calls = append(f.calls, "run "+filepath.Base(name)+" "+strings.Join(args, " ")) |
| + if len(args) == 1 && args[0] == "version" { |
| + if v, ok := f.answer["version:"+name]; ok { |
| + return v, nil |
| + } |
| + return "", errors.New("no such binary") |
| + } |
| + if len(args) >= 3 && args[0] == "bootstrap" && args[1] == "-root" { |
| + plan, ok := f.answer["plan:"+name] |
| + if !ok { |
| + return "", errors.New("bootstrap failed") |
| + } |
| + units := strings.Split(f.answer["units:"+name], " ") |
| + dir := filepath.Join(args[2], "etc", "systemd", "system") |
| + os.MkdirAll(dir, 0o755) |
| + for _, u := range units { |
| + if u != "" { |
| + os.WriteFile(filepath.Join(dir, u), []byte("[Unit]\n# "+filepath.Base(name)+"\n"), 0o644) |
| + } |
| + } |
| + os.MkdirAll(filepath.Join(args[2], "etc", "sysusers.d"), 0o755) |
| + os.WriteFile(filepath.Join(args[2], "etc", "sysusers.d", "carlos.conf"), []byte("g carlos-sockets -\n"), 0o644) |
| + os.MkdirAll(filepath.Join(args[2], "etc", "tmpfiles.d"), 0o755) |
| + os.WriteFile(filepath.Join(args[2], "etc", "tmpfiles.d", "carlos.conf"), []byte("d /run/carlos\n"), 0o644) |
| + os.MkdirAll(filepath.Join(args[2], "data"), 0o755) |
| + os.WriteFile(filepath.Join(args[2], "data", "registry.db"), nil, 0o640) |
| + return plan, nil |
| + } |
| + return "", errors.New("unexpected command") |
| + }, |
| + now: func() time.Time { return f.now }, |
| + sleep: func(d time.Duration) { f.now = f.now.Add(d) }, |
| + probeEdge: func() error { return f.probeErr }, |
| + edgeStartedThisBoot: func() bool { return f.edgeStartedThisBoot }, |
| + logf: t.Logf, |
| + } |
| + os.MkdirAll(systemRootDir(f.a.optDir), 0o755) |
| + os.MkdirAll(filepath.Join(f.a.etcDir, "systemd", "system"), 0o755) |
| + // The on-disk binary answers `version` with build. |
| + os.WriteFile(systemBinaryPath(f.a.optDir), []byte("#!/bin/sh\necho carlos "+build+"\n"), 0o755) |
| + f.answer["version:"+systemBinaryPath(f.a.optDir)] = "carlos " + build + "\n" |
| + f.answer["plan:"+systemBinaryPath(f.a.optDir)] = `{"enabled":["carlos-edge.service","carlos-adopt.timer","carlos-adopt.path","carlos-stage.path","carlos-restart.path","carlos-system-update.path","carlos-system-recover.service"],"inert":["carlos-restore-verify.timer"]}` |
| + f.answer["units:"+systemBinaryPath(f.a.optDir)] = "carlos-edge.service carlos-adopt.timer carlos-adopt.path carlos-stage.path carlos-restart.path carlos-system-update.path carlos-system-update.service carlos-system-recover.service carlos-old-only.timer" |
| + return f |
| +} |
| + |
| +// promote signs a pointer for f.a.channel naming version with body as the |
| +// linux-arm64 artifact, stages body into the agent's cache, and writes the |
| +// request the agent would have written. Returns the request. |
| +func (f *applyFixture) promote(version string, body []byte, noop bool) systemRequest { |
| + f.t.Helper() |
| + sum := sha256.Sum256(body) |
| + rel := release.SystemRelease{Version: version, Artifacts: map[string]release.SystemArtifact{ |
| + "linux-arm64": {Key: release.SystemArtifactKey(version, "linux-arm64"), SHA256: hex.EncodeToString(sum[:])}}} |
| + f.store.Put(context.Background(), rel.Artifacts["linux-arm64"].Key, body, "") |
| + p, err := release.PromoteSystem(context.Background(), f.store, release.RootSigner(f.priv), "router", f.a.channel, rel, release.SystemPromoteOptions{}) |
| + if err != nil { |
| + f.t.Fatal(err) |
| + } |
| + req := systemRequest{Version: version, PromotedAt: p.PromotedAt, NoOp: noop, SeenAt: f.now} |
| + os.MkdirAll(systemDataDir(f.a.dataDir), 0o755) |
| + writeJSONDurable(systemRequestPath(f.a.dataDir), req, 0o644) |
| + if !noop { |
| + os.MkdirAll(filepath.Dir(systemCachePath(f.a.dataDir, version)), 0o755) |
| + os.WriteFile(systemCachePath(f.a.dataDir, version), body, 0o644) |
| + } |
| + return req |
| +} |
| + |
| +func (f *applyFixture) result() *systemResult { r, _ := readSystemResult(f.a.optDir); return r } |
| + |
| +func (f *applyFixture) called(prefix string) bool { |
| + for _, c := range f.calls { |
| + if strings.HasPrefix(c, prefix) { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +func TestApplyNoRequestIsNothingToDo(t *testing.T) { |
| + f := newApplyFixture(t, "stable", "v1") |
| + if err := f.a.Run(context.Background(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if f.result() != nil || len(f.calls) != 0 { |
| + t.Fatalf("result=%+v calls=%v", f.result(), f.calls) |
| + } |
| +} |
| + |
| +func TestApplyLockKeepsTwoRunsApart(t *testing.T) { |
| + f := newApplyFixture(t, "stable", "v1") |
| + f.promote("v2", []byte("x"), false) |
| + // Hold the lock as another process would. |
| + held, err := os.OpenFile(f.a.lockPath(), os.O_CREATE|os.O_RDWR, 0o600) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer held.Close() |
| + if err := flockExclusiveNonblock(held); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.a.Run(context.Background(), false); err != nil { |
| + t.Fatalf("a locked-out path run must exit 0 quietly: %v", err) |
| + } |
| + if f.result() != nil { |
| + t.Fatal("a locked-out run wrote a result") |
| + } |
| +} |
| + |
| +func TestApplyRequestIsAHintNotAnInstruction(t *testing.T) { |
| + f := newApplyFixture(t, "stable", "v1") |
| + // A symlinked parent under /data. |
| + os.MkdirAll(filepath.Join(f.a.dataDir, "elsewhere"), 0o755) |
| + os.Symlink(filepath.Join(f.a.dataDir, "elsewhere"), filepath.Join(f.a.dataDir, "system")) |
| + os.MkdirAll(filepath.Join(f.a.dataDir, "elsewhere", "router"), 0o755) |
| + os.WriteFile(filepath.Join(f.a.dataDir, "elsewhere", "router", "requested.json"), []byte(`{"version":"v2"}`), 0o644) |
| + if _, err := f.a.readRequest(); err == nil { |
| + t.Fatal("followed a symlinked parent under /data") |
| + } |
| + os.Remove(filepath.Join(f.a.dataDir, "system")) |
| + // A symlinked request file. |
| + os.MkdirAll(systemDataDir(f.a.dataDir), 0o755) |
| + os.WriteFile(filepath.Join(f.a.dataDir, "real.json"), []byte(`{"version":"v2"}`), 0o644) |
| + os.Symlink(filepath.Join(f.a.dataDir, "real.json"), systemRequestPath(f.a.dataDir)) |
| + if _, err := f.a.readRequest(); err == nil { |
| + t.Fatal("followed a symlinked request") |
| + } |
| + os.Remove(systemRequestPath(f.a.dataDir)) |
| + // A traversal version. |
| + writeJSONDurable(systemRequestPath(f.a.dataDir), systemRequest{Version: "../../etc/passwd", PromotedAt: f.now}, 0o644) |
| + if _, err := f.a.readRequest(); err == nil { |
| + t.Fatal("accepted a traversal version") |
| + } |
| + // Oversized. |
| + os.WriteFile(systemRequestPath(f.a.dataDir), []byte(`{"version":"`+strings.Repeat("a", 5000)+`"}`), 0o644) |
| + if _, err := f.a.readRequest(); err == nil { |
| + t.Fatal("accepted a 5 KiB request") |
| + } |
| +} |
| + |
| +func TestApplyChannelComesFromRootNotTheRequest(t *testing.T) { |
| + // The box is on stable; a valid CANARY pointer exists and the agent |
| + // requests its version. Root reads control/system/router/stable.json |
| + // (missing) and refuses — a signed canary promote does not authorise a |
| + // stable box. |
| + f := newApplyFixture(t, "stable", "v1") |
| + f.a.channel = "canary" |
| + req := f.promote("v2", []byte("x"), false) // signs the CANARY pointer |
| + f.a.channel = "stable" |
| + if err := f.a.Run(context.Background(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + r := f.result() |
| + if r == nil || r.Outcome != systemOutcomeRefused || !r.matches("stable", "v2", req.PromotedAt) { |
| + t.Fatalf("result = %+v", r) |
| + } |
| + if _, err := os.Stat(systemBinaryPath(f.a.optDir) + ".prev"); err == nil { |
| + t.Fatal("touched the box") |
| + } |
| +} |
| + |
| +func TestApplyStaleTupleIsRefusedAgainstTheRequestsTuple(t *testing.T) { |
| + f := newApplyFixture(t, "stable", "v1") |
| + old := f.promote("v2", []byte("x"), false) |
| + time.Sleep(1100 * time.Millisecond) |
| + f.promote("v3", []byte("y"), false) |
| + // Put the OLD request back: the agent's spread started under v2. |
| + writeJSONDurable(systemRequestPath(f.a.dataDir), old, 0o644) |
| + if err := f.a.Run(context.Background(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + r := f.result() |
| + if r == nil || r.Outcome != systemOutcomeRefused || r.Version != "v2" || !r.PromotedAt.Equal(old.PromotedAt) { |
| + t.Fatalf("result = %+v (must name the REQUEST's tuple so v3 is not suppressed)", r) |
| + } |
| +} |
| + |
| +func TestApplyReplayGuardIsPerChannelAndEqualIsAllowed(t *testing.T) { |
| + f := newApplyFixture(t, "stable", "v1") |
| + req := f.promote("v2", []byte("x"), true) |
| + // Older than the watermark → refused, seen unchanged. |
| + writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "seen.json"), systemSeen{"stable": req.PromotedAt.Add(time.Hour), "canary": time.Time{}}, 0o644) |
| + if err := f.a.Run(context.Background(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if r := f.result(); r == nil || r.Outcome != systemOutcomeRefused || !strings.Contains(r.Reason, "replay") { |
| + t.Fatalf("result = %+v", r) |
| + } |
| + // Equal → allowed (a retry of the same promote). |
| + writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "seen.json"), systemSeen{"stable": req.PromotedAt}, 0o644) |
| + if err := f.a.Run(context.Background(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if r := f.result(); r == nil || r.Outcome != systemOutcomeNoted { |
| + t.Fatalf("result = %+v", r) |
| + } |
| + // Another channel's newer watermark does not block this channel. |
| + writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "seen.json"), systemSeen{"canary": req.PromotedAt.Add(time.Hour)}, 0o644) |
| + if err := f.a.Run(context.Background(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if r := f.result(); r == nil || r.Outcome != systemOutcomeNoted { |
| + t.Fatalf("result = %+v", r) |
| + } |
| + seen, _ := readSystemSeen(f.a.optDir) |
| + if !seen["stable"].Equal(req.PromotedAt) { |
| + t.Fatalf("seen not advanced: %v", seen) |
| + } |
| +} |
| + |
| +func TestApplyNoOpNotesAndTouchesNothing(t *testing.T) { |
| + f := newApplyFixture(t, "stable", "v2") |
| + f.promote("v2", []byte("x"), true) |
| + if err := f.a.Run(context.Background(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if r := f.result(); r == nil || r.Outcome != systemOutcomeNoted { |
| + t.Fatalf("result = %+v", r) |
| + } |
| + if _, err := os.Stat(f.a.stagingDir()); err == nil { |
| + t.Fatal("a no-op created staging/") |
| + } |
| + if len(f.calls) != 0 { |
| + t.Fatalf("a no-op ran commands: %v", f.calls) |
| + } |
| +} |
| + |
| +func TestApplyStoreErrorIsDeferredNotRefused(t *testing.T) { |
| + f := newApplyFixture(t, "stable", "v1") |
| + req := f.promote("v2", []byte("x"), false) |
| + f.a.store = unreadableStore{f.store} |
| + if err := f.a.Run(context.Background(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if r := f.result(); r == nil || r.Outcome != systemOutcomeDeferred || !r.matches("stable", "v2", req.PromotedAt) { |
| + t.Fatalf("result = %+v", r) |
| + } |
| +} |
| + |
| +// unreadableStore is the bucket-outage fixture for reads: the door is |
| +// restarting, so the pointer cannot be fetched at all. |
| +type unreadableStore struct{ release.Store } |
| + |
| +func (unreadableStore) Get(context.Context, string) ([]byte, string, error) { |
| + return nil, "", errors.New("door restarting") |
| +} |
| + |
| +func TestApplyStagesTheCandidateFromARootOwnedCopy(t *testing.T) { |
| + f := newApplyFixture(t, "stable", "v1") |
| + body := []byte("#!/bin/sh\necho carlos v2\n") |
| + f.promote("v2", body, false) |
| + f.answer["version:"+filepath.Join(f.a.stagingDir(), "carlos")] = "carlos v2\n" |
| + p, err := f.a.verifyPointer(context.Background()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req, _ := f.a.readRequest() |
| + staged, err := f.a.stageCandidate(p, req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + st, _ := os.Stat(staged) |
| + if st.Mode().Perm() != 0o755 { |
| + t.Fatalf("staged mode = %v after verification", st.Mode().Perm()) |
| + } |
| + if dst, _ := os.Stat(f.a.stagingDir()); dst.Mode().Perm() != 0o700 { |
| + t.Fatalf("staging dir mode = %v", dst.Mode().Perm()) |
| + } |
| + // Swapping the cache after staging changes nothing about the copy. |
| + os.WriteFile(systemCachePath(f.a.dataDir, "v2"), []byte("evil"), 0o644) |
| + got, _ := os.ReadFile(staged) |
| + if string(got) != string(body) { |
| + t.Fatal("staged copy follows the cache") |
| + } |
| + // A sha mismatch is refused BEFORE the copy is made executable and |
| + // staging/ is cleared. |
| + f2 := newApplyFixture(t, "stable", "v1") |
| + f2.promote("v2", body, false) |
| + os.WriteFile(systemCachePath(f2.a.dataDir, "v2"), []byte("evil"), 0o644) |
| + p2, _ := f2.a.verifyPointer(context.Background()) |
| + req2, _ := f2.a.readRequest() |
| + if _, err := f2.a.stageCandidate(p2, req2); err == nil || !strings.Contains(err.Error(), "sha256") { |
| + t.Fatalf("err = %v", err) |
| + } |
| + // A candidate that lies about its version is refused. |
| + f3 := newApplyFixture(t, "stable", "v1") |
| + f3.promote("v2", body, false) |
| + f3.answer["version:"+filepath.Join(f3.a.stagingDir(), "carlos")] = "carlos v9\n" |
| + p3, _ := f3.a.verifyPointer(context.Background()) |
| + req3, _ := f3.a.readRequest() |
| + if _, err := f3.a.stageCandidate(p3, req3); err == nil || !strings.Contains(err.Error(), "reports") { |
| + t.Fatalf("err = %v", err) |
| + } |
| + // A symlinked cache file is refused. |
| + f4 := newApplyFixture(t, "stable", "v1") |
| + f4.promote("v2", body, false) |
| + os.Remove(systemCachePath(f4.a.dataDir, "v2")) |
| + os.WriteFile(filepath.Join(f4.a.dataDir, "real"), body, 0o644) |
| + os.Symlink(filepath.Join(f4.a.dataDir, "real"), systemCachePath(f4.a.dataDir, "v2")) |
| + p4, _ := f4.a.verifyPointer(context.Background()) |
| + req4, _ := f4.a.readRequest() |
| + if _, err := f4.a.stageCandidate(p4, req4); err == nil { |
| + t.Fatal("followed a symlinked cache file") |
| + } |
| +} |