package host
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"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
pub ed25519.PublicKey
calls []string // "systemctl is-active carlos-edge", "run version", …
answer map[string]string // "version:" → stdout; "plan:" → 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.pub = pub
f.a = &systemApplier{
openStore: func(context.Context) (release.Store, ed25519.PublicKey, error) { return f.store, f.pub, nil },
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
}
// With no scripted answer the "binary" speaks for its own
// bytes ("#!/bin/sh\necho carlos vN"), so a rename over it
// changes what it reports — which is what the install does.
if b, err := os.ReadFile(name); err == nil {
if _, rest, ok := strings.Cut(string(b), "echo "); ok {
return strings.SplitN(rest, "\n", 2)[0] + "\n", 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
}
// The two config appliers the install shells out to.
if b := filepath.Base(name); b == "systemd-sysusers" || b == "systemd-tmpfiles" {
return "", 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,
}
// The layout guard opens the registry only for a candidate that
// predates the layout, and closes it; an empty one in the fake data dir
// is a flat box.
f.a.openRegistry = func() (*registry.Registry, error) {
return registry.Open(filepath.Join(f.a.dataDir, "registry.db"))
}
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-instance-control.service carlos-old-only.timer"
f.a.participation = updateParticipationFixture(t)
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) {
t.Parallel()
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.openStore = func(context.Context) (release.Store, ed25519.PublicKey, error) {
return unreadableStore{f.store}, f.pub, nil
}
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(context.Background(), 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(context.Background(), p2, req2); err == nil || !strings.Contains(err.Error(), "sha256") {
t.Fatalf("err = %v", err)
}
if st, err := os.Stat(filepath.Join(f2.a.stagingDir(), "carlos")); err == nil && st.Mode().Perm() != 0o600 {
t.Fatalf("an unverified copy was made executable: mode = %v", st.Mode().Perm())
}
// 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(context.Background(), 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(context.Background(), p4, req4); err == nil {
t.Fatal("followed a symlinked cache file")
}
}
// applyHappy sets up a v1 box with a v2 candidate whose fake bootstrap
// plan adds carlos-new-only.path and drops carlos-old-only.timer.
func applyHappy(t *testing.T) (*applyFixture, systemRequest) {
return applyHappyVersion(t, "v2")
}
// applyHappyVersion is applyHappy with the candidate's version named, for
// the guards that place a version against release.LayoutFloor.
func applyHappyVersion(t *testing.T, version string) (*applyFixture, systemRequest) {
f := newApplyFixture(t, "stable", "v1")
body := []byte("#!/bin/sh\necho carlos " + version + "\n")
req := f.promote(version, body, false)
staged := filepath.Join(f.a.stagingDir(), "carlos")
f.answer["version:"+staged] = "carlos " + version + "\n"
f.answer["plan:"+staged] = `{"enabled":["carlos-edge.service","carlos-adopt.timer","carlos-adopt.path","carlos-stage.path","carlos-restart.path","carlos-system-update.path","carlos-system-recover.service","carlos-new-only.path"],"inert":["carlos-restore-verify.timer"]}`
f.answer["units:"+staged] = "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-instance-control.service carlos-new-only.path"
// The unit v1 owns and v2 drops is on the box, so removing it is a
// thing that can be seen to happen — or not to.
os.WriteFile(filepath.Join(f.a.etcDir, "systemd", "system", "carlos-old-only.timer"), []byte("[Unit]\n# v1\n"), 0o644)
// .prev, once made, answers as v1 and stages v1's units.
prev := systemPrevPath(f.a.optDir)
f.answer["version:"+prev] = "carlos v1\n"
f.answer["plan:"+prev] = f.answer["plan:"+systemBinaryPath(f.a.optDir)]
f.answer["units:"+prev] = f.answer["units:"+systemBinaryPath(f.a.optDir)]
// The on-disk binary answers from its own bytes, so it says v1 until
// the rename and v2 after it — a scripted answer would make the run a
// no-op before it started.
delete(f.answer, "version:"+systemBinaryPath(f.a.optDir))
return f, req
}
func TestApplyInstallsGatesArmsAndRecords(t *testing.T) {
f, req := applyHappy(t)
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
r := f.result()
if r == nil || r.Outcome != systemOutcomeApplied || !r.matches("stable", "v2", req.PromotedAt) {
t.Fatalf("result = %+v", r)
}
ap, _ := readSystemApplied(f.a.optDir)
if ap == nil || ap.Version != "v2" || ap.Channel != "stable" {
t.Fatalf("applied = %+v", ap)
}
if j, _ := readSystemJournal(f.a.optDir); j != nil {
t.Fatalf("journal left open: %+v", j)
}
if _, err := os.Stat(f.a.stagingDir()); err == nil {
t.Fatal("staging/ left behind")
}
bin, _ := os.ReadFile(systemBinaryPath(f.a.optDir))
if !strings.Contains(string(bin), "carlos v2") {
t.Fatal("binary not swapped")
}
prev, _ := os.ReadFile(systemPrevPath(f.a.optDir))
if !strings.Contains(string(prev), "carlos v1") {
t.Fatal(".prev is not the old binary")
}
units := filepath.Join(f.a.etcDir, "systemd", "system")
if _, err := os.Stat(filepath.Join(units, "carlos-new-only.path")); err != nil {
t.Error("new unit not installed")
}
if _, err := os.Stat(filepath.Join(units, "carlos-old-only.timer")); err == nil {
t.Error("removed unit still installed")
}
if _, err := os.Stat(filepath.Join(f.a.etcDir, "sysusers.d", "carlos.conf")); err != nil {
t.Error("sysusers not installed")
}
if _, err := os.Stat(filepath.Join(f.a.etcDir, "tmpfiles.d", "carlos.conf")); err != nil {
t.Error("tmpfiles not installed")
}
if _, err := os.Stat(filepath.Join(f.a.etcDir, "data", "registry.db")); err == nil {
t.Error("staging data/ copied")
}
// Order of the systemctl calls is the spec.
joined := strings.Join(f.calls, "\n")
order := []string{
"systemctl stop carlos-adopt.timer carlos-adopt.path carlos-stage.path carlos-restart.path carlos-adopt.service carlos-config-sync.service carlos-stage.service carlos-restart.service",
"systemctl disable --now carlos-old-only.timer",
"run systemd-sysusers",
"run systemd-tmpfiles --create",
"systemctl daemon-reload",
"systemctl reset-failed carlos-edge",
"systemctl restart carlos-edge",
"systemctl is-active carlos-edge",
"systemctl daemon-reload",
"systemctl enable carlos-edge.service carlos-adopt.timer carlos-adopt.path carlos-stage.path carlos-restart.path carlos-system-update.path carlos-system-recover.service carlos-new-only.path",
"systemctl start carlos-adopt.timer carlos-adopt.path carlos-stage.path carlos-restart.path carlos-system-update.path carlos-new-only.path",
}
at := 0
for _, want := range order {
i := strings.Index(joined[at:], want)
if i < 0 {
t.Fatalf("call order broken: %q not found after position %d in:\n%s", want, at, joined)
}
at += i + len(want)
}
for _, c := range f.calls {
if strings.HasPrefix(c, "systemctl start ") && strings.Contains(c, ".service") {
t.Fatalf("apply started a service: %s", c)
}
}
if strings.Contains(joined, "systemctl enable --now") {
t.Fatal("enable --now is never used")
}
}
func TestApplyGateSoakCatchesAFlap(t *testing.T) {
f, req := applyHappy(t)
// Edge comes up, then restarts once during the soak.
restarts := 0
orig := f.a.ops.systemctl
f.a.ops.systemctl = func(args ...string) (string, error) {
if args[0] == "show" {
restarts++
if restarts > 1 {
return "1\n2000\n", nil
}
}
return orig(args...)
}
err := f.a.Run(context.Background(), false)
if err == nil {
t.Fatal("a flapping edge passed the gate")
}
r := f.result()
if r == nil || !r.matches("stable", "v2", req.PromotedAt) || r.Outcome == systemOutcomeApplied {
t.Fatalf("result = %+v", r)
}
}
func TestApplyPrevIsRefreshedOnlyWhenTheDiskIsKnownGood(t *testing.T) {
// applied.json names v0 but the disk says v1 (a rollback-failed left a
// bad v1 in place, .prev is the good v0): .prev must be left alone.
f, _ := applyHappy(t)
os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v0\n"), 0o755)
f.answer["version:"+systemPrevPath(f.a.optDir)] = "carlos v0\n"
writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "applied.json"), systemApplied{Version: "v0", Channel: "stable", At: f.now}, 0o644)
_ = f.a.Run(context.Background(), false)
prev, _ := os.ReadFile(systemPrevPath(f.a.optDir))
if !strings.Contains(string(prev), "carlos v0") {
t.Fatal(".prev was overwritten by a binary that never passed a gate")
}
}
func TestApplyPrevUnitsComeFromPrevNotFromDisk(t *testing.T) {
f, _ := applyHappy(t)
os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v0\n"), 0o755)
f.answer["version:"+systemPrevPath(f.a.optDir)] = "carlos v0\n"
f.answer["plan:"+systemPrevPath(f.a.optDir)] = `{"enabled":["carlos-edge.service"],"inert":[]}`
f.answer["units:"+systemPrevPath(f.a.optDir)] = "carlos-edge.service carlos-v0-only.timer"
writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "applied.json"), systemApplied{Version: "v0", Channel: "stable", At: f.now}, 0o644)
// Inspect the journal mid-flight by failing the gate.
f.edgeUp = false
_ = f.a.Run(context.Background(), false)
// units_removed must be computed against .prev's set (v0-only.timer),
// not the on-disk v1's (old-only.timer).
found := false
for _, c := range f.calls {
if c == "run carlos.prev bootstrap -root "+filepath.Join(f.a.stagingDir(), "prev")+" -plan-json" {
found = true
}
}
if !found {
t.Fatalf("prev units not staged from carlos.prev:\n%s", strings.Join(f.calls, "\n"))
}
// And units_removed follows from that set: v0's timer goes, and the
// on-disk v1's — a build nothing here is restoring — is left alone.
if !f.called("systemctl disable --now carlos-v0-only.timer") {
t.Errorf("carlos-v0-only.timer not removed:\n%s", strings.Join(f.calls, "\n"))
}
if f.called("systemctl disable --now carlos-old-only.timer") {
t.Errorf("removed a unit computed against the on-disk build:\n%s", strings.Join(f.calls, "\n"))
}
}
func TestApplyPrevIsRefreshedWhenAppliedNamesTheDisk(t *testing.T) {
// The ordinary box: applied.json names the binary that is on disk, so
// that binary passed a gate here and is the one to fall back to.
f, _ := applyHappy(t)
os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v0\n"), 0o755)
writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "applied.json"), systemApplied{Version: "v1", Channel: "stable", At: f.now}, 0o644)
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
prev, _ := os.ReadFile(systemPrevPath(f.a.optDir))
if !strings.Contains(string(prev), "carlos v1") {
t.Fatalf(".prev = %q, want the v1 the box had applied", prev)
}
}
func TestApplyPrevIsLeftAloneWhenNothingWasEverApplied(t *testing.T) {
// No applied.json, but a .prev exists: the box's history is unknown, so
// the untested disk binary does not get to become the fallback.
f, _ := applyHappy(t)
os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v0\n"), 0o755)
f.answer["version:"+systemPrevPath(f.a.optDir)] = "carlos v0\n"
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
prev, _ := os.ReadFile(systemPrevPath(f.a.optDir))
if !strings.Contains(string(prev), "carlos v0") {
t.Fatalf(".prev = %q, want the v0 that was already there", prev)
}
}
func TestApplyGateDemandsZeroRestartsNotJustAStillOne(t *testing.T) {
// The edge crashed once and then settled: nothing moves across the
// soak, but NRestarts is not 0 and the build is not one to keep.
f, req := applyHappy(t)
f.nrestarts = "1"
if err := f.a.Run(context.Background(), false); err == nil {
t.Fatal("an edge that had already crash-restarted passed the gate")
}
r := f.result()
if r == nil || !r.matches("stable", "v2", req.PromotedAt) || r.Outcome == systemOutcomeApplied {
t.Fatalf("result = %+v", r)
}
}
func TestApplyRecordsAnArmingFailureAgainstTheApply(t *testing.T) {
// The gate passed, so the roll stands; but `enable` is one batched
// call, and a box left unarmed must not look like a clean apply.
f, req := applyHappy(t)
orig := f.a.ops.systemctl
f.a.ops.systemctl = func(args ...string) (string, error) {
if args[0] == "enable" {
orig(args...)
return "", errors.New("Failed to enable unit carlos-new-only.path")
}
return orig(args...)
}
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
r := f.result()
if r == nil || r.Outcome != systemOutcomeApplied || !r.matches("stable", "v2", req.PromotedAt) {
t.Fatalf("result = %+v", r)
}
if !strings.Contains(r.Reason, "armed with errors") {
t.Fatalf("reason = %q, want the arming failure recorded", r.Reason)
}
if ap, _ := readSystemApplied(f.a.optDir); ap == nil || ap.Version != "v2" {
t.Fatalf("applied = %+v (the gate passed; the roll stands)", ap)
}
}
func TestApplyRollsBackWhenTheGateFails(t *testing.T) {
f, req := applyHappy(t)
// v2 never comes up; v1 does — which is what makes this a rollback and
// not a rollback-failed. The fake edge answers for whatever binary is
// on disk when the restart is issued.
f.edgeUp = false
orig := f.a.ops.systemctl
f.a.ops.systemctl = func(args ...string) (string, error) {
if args[0] == "restart" {
if b, _ := os.ReadFile(systemBinaryPath(f.a.optDir)); strings.Contains(string(b), "carlos v1") {
f.edgeUp = true
}
}
return orig(args...)
}
err := f.a.Run(context.Background(), false)
if err == nil {
t.Fatal("a failed gate must exit non-zero")
}
r := f.result()
if r == nil || r.Outcome != systemOutcomeRolledBack || !r.matches("stable", "v2", req.PromotedAt) || !strings.Contains(r.Reason, "gate") {
t.Fatalf("result = %+v", r)
}
bin, _ := os.ReadFile(systemBinaryPath(f.a.optDir))
if !strings.Contains(string(bin), "carlos v1") {
t.Fatal("binary not restored")
}
if _, err := os.Stat(systemPrevPath(f.a.optDir)); err != nil {
t.Fatal(".prev consumed by the rollback")
}
units := filepath.Join(f.a.etcDir, "systemd", "system")
if _, err := os.Stat(filepath.Join(units, "carlos-new-only.path")); err == nil {
t.Error("added unit survived the rollback")
}
if _, err := os.Stat(filepath.Join(units, "carlos-old-only.timer")); err != nil {
t.Error("removed unit not restored")
}
if ap, _ := readSystemApplied(f.a.optDir); ap != nil {
t.Fatalf("applied.json written by a rollback: %+v", ap)
}
if j, _ := readSystemJournal(f.a.optDir); j != nil {
t.Fatal("journal left open after rollback")
}
joined := strings.Join(f.calls, "\n")
if !strings.Contains(joined, "systemctl disable --now carlos-new-only.path") {
t.Error("added unit not disabled")
}
// The install already staged carlos.prev; the rollback reuses it rather
// than re-running a bootstrap on a box that is already unhealthy.
if n := strings.Count(joined, "run carlos.prev bootstrap"); n != 1 {
t.Errorf("carlos.prev bootstrapped %d times, want 1:\n%s", n, joined)
}
// reset-failed precedes the rollback's restart too, and the previous
// plan is armed afterwards.
i := strings.LastIndex(joined, "systemctl reset-failed carlos-edge")
k := strings.LastIndex(joined, "systemctl restart carlos-edge")
m := strings.LastIndex(joined, "systemctl start carlos-adopt.timer")
if !(i < k && k < m) {
t.Fatalf("rollback order wrong:\n%s", joined)
}
}
// The relay rides the platform binary, so a self-update has to bounce it
// onto the new build the way it bounces the edge — after the edge, and with
// reset-failed then restart. `restart`, not `try-restart`: an unarmed box is
// kept relay-less by the unit's own ConditionPathExists (bootstrap starts
// the unit outright and relies on the same thing), while try-restart after a
// reset-failed cannot revive a relay that crash-looped at boot, because
// reset-failed has just made it inactive and try-restart skips an inactive
// unit (relay spec §3).
func TestApplyRestartsTheRelayAfterTheEdge(t *testing.T) {
f, _ := applyHappy(t)
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
joined := strings.Join(f.calls, "\n")
edge := strings.Index(joined, "systemctl restart carlos-edge")
reset := strings.Index(joined, "systemctl reset-failed carlos-relay")
restart := strings.Index(joined, "systemctl restart carlos-relay")
switch {
case reset < 0 || restart < 0:
t.Fatalf("relay never restarted:\n%s", joined)
case !(edge < reset && reset < restart):
t.Fatalf("want restart carlos-edge, then reset-failed then restart carlos-relay:\n%s", joined)
}
// try-restart is the bug this test now guards against: it would leave a
// failed relay down until the next update.
if strings.Contains(joined, "systemctl try-restart carlos-relay") {
t.Errorf("relay try-restarted, which cannot revive a failed unit:\n%s", joined)
}
}
// Discussion #256: the root instance-control service execs the platform
// binary and stays up for weeks, so an install that leaves it running the
// previous build leaves every nested wake failing at namespace setup. It
// is restarted after the new units are in place (daemon-reload) and before
// the edge, so the new agent never meets an old control service.
func TestApplyRestartsTheInstanceControlServiceBeforeTheEdge(t *testing.T) {
f, _ := applyHappy(t)
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
joined := strings.Join(f.calls, "\n")
reload := strings.Index(joined, "systemctl daemon-reload")
reset := strings.Index(joined, "systemctl reset-failed carlos-instance-control.service")
restart := strings.Index(joined, "systemctl restart carlos-instance-control.service")
edge := strings.Index(joined, "systemctl restart carlos-edge")
switch {
case reset < 0 || restart < 0:
t.Fatalf("instance-control never restarted:\n%s", joined)
case !(reload < reset && reset < restart && restart < edge):
t.Fatalf("want daemon-reload, then reset-failed and restart carlos-instance-control.service, then restart carlos-edge:\n%s", joined)
}
if strings.Contains(joined, "systemctl try-restart carlos-instance-control.service") {
t.Errorf("try-restart cannot start an inactive (socket-activated, never connected) service:\n%s", joined)
}
}
// A control service that will not restart fails the install, and the
// rollback restarts it again onto the previous binary (so the restart is
// attempted twice over the run), rather than leaving a new edge beside a
// control service of unknown vintage.
func TestApplyInstanceControlRestartFailureRollsBack(t *testing.T) {
f, req := applyHappy(t)
orig := f.a.ops.systemctl
failed := false
f.a.ops.systemctl = func(args ...string) (string, error) {
out, err := orig(args...)
if args[0] == "restart" && args[1] == systemControlUnit && !failed {
failed = true
return "", errors.New("Job for carlos-instance-control.service failed")
}
return out, err
}
if err := f.a.Run(context.Background(), false); err == nil {
t.Fatal("a control service that would not restart passed the install")
}
r := f.result()
if r == nil || r.Outcome != systemOutcomeRolledBack || !r.matches("stable", "v2", req.PromotedAt) || !strings.Contains(r.Reason, "instance-control restart") {
t.Fatalf("result = %+v", r)
}
if n := strings.Count(strings.Join(f.calls, "\n"), "systemctl restart carlos-instance-control.service"); n != 2 {
t.Errorf("control service restarted %d times, want 2 (install then rollback):\n%s", n, strings.Join(f.calls, "\n"))
}
if bin, _ := os.ReadFile(systemBinaryPath(f.a.optDir)); !strings.Contains(string(bin), "carlos v1") {
t.Fatal("binary not rolled back")
}
}
// The rollback of a failed gate restarts the control service onto the
// previous binary too, before the edge, for the same reason the relay is.
func TestApplyRollbackRestartsTheInstanceControlService(t *testing.T) {
f, _ := applyHappy(t)
f.edgeUp = false
orig := f.a.ops.systemctl
f.a.ops.systemctl = func(args ...string) (string, error) {
if args[0] == "restart" && args[1] == systemEdgeUnit {
if b, _ := os.ReadFile(systemBinaryPath(f.a.optDir)); strings.Contains(string(b), "carlos v1") {
f.edgeUp = true
}
}
return orig(args...)
}
if err := f.a.Run(context.Background(), false); err == nil {
t.Fatal("a failed gate must exit non-zero")
}
joined := strings.Join(f.calls, "\n")
edge := strings.LastIndex(joined, "systemctl restart carlos-edge")
ctl := strings.LastIndex(joined, "systemctl restart carlos-instance-control.service")
if ctl < 0 || ctl > edge {
t.Fatalf("the rollback left the control service on the withdrawn build, or restarted it after the edge:\n%s", joined)
}
if n := strings.Count(joined, "systemctl restart carlos-instance-control.service"); n != 2 {
t.Errorf("control service restarted %d times, want 2 (apply then rollback):\n%s", n, joined)
}
}
// A candidate that ships no control service (a downgrade below the
// isolated-exec releases) has nothing to restart: installUnits has just
// disabled and removed the unit, and a restart would fail the install.
func TestApplyDoesNotRestartAControlServiceTheCandidateLacks(t *testing.T) {
f, req := applyHappy(t)
staged := filepath.Join(f.a.stagingDir(), "carlos")
f.answer["units:"+staged] = strings.ReplaceAll(f.answer["units:"+staged], " carlos-instance-control.service", "")
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied || !r.matches("stable", "v2", req.PromotedAt) {
t.Fatalf("result = %+v", r)
}
joined := strings.Join(f.calls, "\n")
if !strings.Contains(joined, "systemctl disable --now carlos-instance-control.service") {
t.Fatalf("the unit the candidate lacks was not disabled:\n%s", joined)
}
if strings.Contains(joined, "systemctl restart carlos-instance-control.service") {
t.Fatalf("restarted a unit the candidate removed:\n%s", joined)
}
}
// A relay that cannot be restarted is not a failed update. The commonest
// case is the ordinary one — a box with no masters file, whose unit systemd
// skips on its condition and may well answer about — and the edge, the
// binary and the gate are all fine.
func TestApplyRelayRestartFailureDoesNotFailTheUpdate(t *testing.T) {
f, req := applyHappy(t)
orig := f.a.ops.systemctl
f.a.ops.systemctl = func(args ...string) (string, error) {
if args[0] == "restart" && args[1] == systemRelayUnit {
orig(args...) // still recorded
return "", errors.New("Unit carlos-relay.service is not loaded")
}
return orig(args...)
}
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatalf("a relay that would not restart failed the whole update: %v", err)
}
r := f.result()
if r == nil || r.Outcome != systemOutcomeApplied || !r.matches("stable", "v2", req.PromotedAt) {
t.Fatalf("result = %+v", r)
}
if !f.called("systemctl restart " + systemRelayUnit) {
t.Fatalf("restart never attempted:\n%s", strings.Join(f.calls, "\n"))
}
}
// And the rollback bounces it too: the box is going back to the previous
// binary, and leaving the relay on the withdrawn one is the same bug the
// edge's own rollback restart exists to avoid.
func TestApplyRollbackRestartsTheRelay(t *testing.T) {
f, _ := applyHappy(t)
f.edgeUp = false
orig := f.a.ops.systemctl
f.a.ops.systemctl = func(args ...string) (string, error) {
if args[0] == "restart" {
if b, _ := os.ReadFile(systemBinaryPath(f.a.optDir)); strings.Contains(string(b), "carlos v1") {
f.edgeUp = true
}
}
return orig(args...)
}
if err := f.a.Run(context.Background(), false); err == nil {
t.Fatal("a failed gate must exit non-zero")
}
joined := strings.Join(f.calls, "\n")
edge := strings.LastIndex(joined, "systemctl restart carlos-edge")
relay := strings.LastIndex(joined, "systemctl restart carlos-relay")
if relay < 0 || relay < edge {
t.Fatalf("the rollback left the relay on the withdrawn build:\n%s", joined)
}
// Twice over the whole run: once for the apply's restart, once for the
// rollback's — not just the apply's, which would pass a LastIndex check
// by accident if the rollback did nothing.
if n := strings.Count(joined, "systemctl restart carlos-relay"); n != 2 {
t.Errorf("relay restarted %d times, want 2 (apply then rollback):\n%s", n, joined)
}
}
func TestApplyRollbackFailedLeavesPrevAndAppliedAlone(t *testing.T) {
t.Parallel()
f, req := applyHappy(t)
writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "applied.json"), systemApplied{Version: "v1", Channel: "stable", At: f.now}, 0o644)
f.edgeUp = false // never comes up, on either binary
if err := f.a.Run(context.Background(), false); err == nil {
t.Fatal("must exit non-zero")
}
r := f.result()
if r == nil || r.Outcome != systemOutcomeRollbackFailed || !r.matches("stable", "v2", req.PromotedAt) {
t.Fatalf("result = %+v", r)
}
ap, _ := readSystemApplied(f.a.optDir)
if ap == nil || ap.Version != "v1" {
t.Fatalf("applied = %+v, must still name v1", ap)
}
prev, _ := os.ReadFile(systemPrevPath(f.a.optDir))
if !strings.Contains(string(prev), "carlos v1") {
t.Fatal(".prev damaged")
}
// A second apply of a NEW version must not refresh .prev from the
// unproven disk binary. The box a failed rollback leaves behind is the
// one that makes that matter: applied.json still names v1, and the v2
// that never passed a gate is what is on disk.
os.WriteFile(systemBinaryPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v2\n"), 0o755)
f.answer["version:"+systemBinaryPath(f.a.optDir)] = "carlos v2\n"
os.Remove(systemRequestPath(f.a.dataDir))
f.promote("v3", []byte("#!/bin/sh\necho carlos v3\n"), false)
f.answer["version:"+filepath.Join(f.a.stagingDir(), "carlos")] = "carlos v3\n"
f.answer["plan:"+filepath.Join(f.a.stagingDir(), "carlos")] = f.answer["plan:"+systemPrevPath(f.a.optDir)]
f.answer["units:"+filepath.Join(f.a.stagingDir(), "carlos")] = f.answer["units:"+systemPrevPath(f.a.optDir)]
_ = f.a.Run(context.Background(), false)
prev2, _ := os.ReadFile(systemPrevPath(f.a.optDir))
if string(prev2) != string(prev) {
t.Fatal(".prev refreshed after rollback-failed")
}
}
func TestRecoveryRollsBackFilesOnlyAtBootAndRestartsAtRuntime(t *testing.T) {
for _, phase := range []string{systemPhaseInstalling, systemPhaseInstalled, systemPhaseUnits, systemPhaseRestarted} {
t.Run(phase, func(t *testing.T) {
f, _ := applyHappy(t)
// Simulate: v2 installed, journal open at phase, box rebooted.
os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v1\n"), 0o755)
os.WriteFile(systemBinaryPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v2\n"), 0o755)
os.WriteFile(filepath.Join(f.a.etcDir, "systemd", "system", "carlos-new-only.path"), []byte("x"), 0o644)
writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "journal.json"),
systemJournal{Phase: phase, Target: "v2", PrevBuild: "v1", UnitsAdded: []string{"carlos-new-only.path"}, UnitsRemoved: []string{"carlos-old-only.timer"}, StartedAt: f.now}, 0o600)
f.edgeStartedThisBoot = false
if err := f.a.Run(context.Background(), true); err != nil {
t.Fatal(err)
}
bin, _ := os.ReadFile(systemBinaryPath(f.a.optDir))
if !strings.Contains(string(bin), "carlos v1") {
t.Fatal("binary not restored")
}
if _, err := os.Stat(filepath.Join(f.a.etcDir, "systemd", "system", "carlos-new-only.path")); err == nil {
t.Error("added unit survived")
}
if _, err := os.Stat(filepath.Join(f.a.etcDir, "systemd", "system", "carlos-old-only.timer")); err != nil {
t.Error("removed unit not restored")
}
joined := strings.Join(f.calls, "\n")
if strings.Contains(joined, "systemctl restart carlos-edge") || strings.Contains(joined, "systemctl start ") || strings.Contains(joined, "systemctl restart carlos-instance-control") {
t.Fatalf("boot recovery restarted or started something:\n%s", joined)
}
if !strings.Contains(joined, "systemctl enable carlos-edge.service") {
t.Fatal("previous plan not enabled for the boot")
}
if r := f.result(); r == nil || r.Outcome != systemOutcomeRolledBack || !strings.Contains(r.Reason, phase) {
t.Fatalf("result = %+v", r)
}
if j, _ := readSystemJournal(f.a.optDir); j != nil {
t.Fatal("journal left open")
}
})
}
// Runtime (OnFailure=): same, plus a non-blocking restart and the
// previous plan's timers/paths started.
f, _ := applyHappy(t)
os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v1\n"), 0o755)
writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "journal.json"),
systemJournal{Phase: systemPhaseUnits, Target: "v2", PrevBuild: "v1", StartedAt: f.now}, 0o600)
f.edgeStartedThisBoot = true
if err := f.a.Run(context.Background(), true); err != nil {
t.Fatal(err)
}
joined := strings.Join(f.calls, "\n")
for _, want := range []string{"systemctl stop carlos-adopt.timer", "systemctl reset-failed carlos-edge", "systemctl restart --no-block carlos-edge", "systemctl start carlos-adopt.timer"} {
if !strings.Contains(joined, want) {
t.Errorf("runtime recovery lacks %q:\n%s", want, joined)
}
}
// The control service goes back to the previous binary with the edge
// (#256), and before it; at boot (above) neither is touched.
ctl := strings.Index(joined, "systemctl restart carlos-instance-control.service")
edge := strings.Index(joined, "systemctl restart --no-block carlos-edge")
if ctl < 0 || ctl > edge {
t.Errorf("runtime recovery does not restart the control service before the edge:\n%s", joined)
}
}
func TestRecoveryFinishesForwardFromGated(t *testing.T) {
f, req := applyHappy(t)
os.WriteFile(systemBinaryPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v2\n"), 0o755)
writeJSONDurable(systemRequestPath(f.a.dataDir), req, 0o644)
writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "journal.json"),
systemJournal{Phase: systemPhaseGated, Target: "v2", PrevBuild: "v1", StartedAt: f.now}, 0o600)
if err := f.a.Run(context.Background(), true); err != nil {
t.Fatal(err)
}
bin, _ := os.ReadFile(systemBinaryPath(f.a.optDir))
if !strings.Contains(string(bin), "carlos v2") {
t.Fatal("a gated apply was undone")
}
ap, _ := readSystemApplied(f.a.optDir)
if ap == nil || ap.Version != "v2" {
t.Fatalf("applied = %+v", ap)
}
if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied {
t.Fatalf("result = %+v", r)
}
}
func TestRecoveryRemovesTheJournalLast(t *testing.T) {
// The restore is done and the result is the last thing before the
// journal goes: a restart that fails after the files are back leaves
// both the journal and no result at all for the next run to redo.
f, _ := applyHappy(t)
os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v1\n"), 0o755)
writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "journal.json"),
systemJournal{Phase: systemPhaseInstalled, Target: "v2", PrevBuild: "v1", StartedAt: f.now}, 0o600)
f.edgeStartedThisBoot = true
orig := f.a.ops.systemctl
f.a.ops.systemctl = func(args ...string) (string, error) {
if args[0] == "restart" {
return "", errors.New("job for carlos-edge.service failed")
}
return orig(args...)
}
if err := f.a.Run(context.Background(), true); err == nil {
t.Fatal("a failed recovery must exit non-zero")
}
if j, _ := readSystemJournal(f.a.optDir); j == nil {
t.Fatal("journal removed although recovery did not complete")
}
if r := f.result(); r != nil {
t.Fatalf("result written before the restart was issued: %+v", r)
}
bin, _ := os.ReadFile(systemBinaryPath(f.a.optDir))
if !strings.Contains(string(bin), "carlos v1") {
t.Fatal("the restore itself did not happen")
}
}
func TestRecoveryKeepsTheJournalWhenTheRestoreFails(t *testing.T) {
// systemd will not reload, so the units are not back: the journal stays
// open and the next run recovers again.
f, _ := applyHappy(t)
os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v1\n"), 0o755)
writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "journal.json"),
systemJournal{Phase: systemPhaseInstalled, Target: "v2", PrevBuild: "v1", StartedAt: f.now}, 0o600)
orig := f.a.ops.systemctl
f.a.ops.systemctl = func(args ...string) (string, error) {
if args[0] == "daemon-reload" {
return "", errors.New("systemd busy")
}
return orig(args...)
}
if err := f.a.Run(context.Background(), true); err == nil {
t.Fatal("a failed recovery must exit non-zero")
}
if j, _ := readSystemJournal(f.a.optDir); j == nil {
t.Fatal("journal removed although recovery did not complete")
}
}
func TestRecoveryAnswersTheJournalsVersionNotALaterRequest(t *testing.T) {
// The agent has moved on to v3 while a v2 apply lay interrupted. The
// result belongs to v2 — v3 has not been tried at all.
f, _ := applyHappy(t)
started := f.now
os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v1\n"), 0o755)
writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "journal.json"),
systemJournal{Phase: systemPhaseUnits, Target: "v2", PrevBuild: "v1", StartedAt: started}, 0o600)
v3 := f.promote("v3", []byte("#!/bin/sh\necho carlos v3\n"), false)
if err := f.a.Run(context.Background(), true); err != nil {
t.Fatal(err)
}
r := f.result()
if r == nil || r.Version != "v2" || !r.PromotedAt.Equal(started) {
t.Fatalf("result = %+v, want the v2 the journal names at %s", r, started)
}
if r.PromotedAt.Equal(v3.PromotedAt) {
t.Fatal("the recovery answered for the request the agent wrote later")
}
}
func TestRecoveryAnswersTheInterruptedPromoteNotTheRePromote(t *testing.T) {
t.Parallel()
// v2 was interrupted; the operator re-promoted the same v2. The
// recovery's result must name the promote it recovered, or the retry
// arrives at a box that has already recorded it as rolled back.
f, first := applyHappy(t)
os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v1\n"), 0o755)
writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "journal.json"),
systemJournal{Phase: systemPhaseUnits, Target: "v2", PromotedAt: first.PromotedAt, PrevBuild: "v1", StartedAt: f.now}, 0o600)
time.Sleep(1100 * time.Millisecond) // promoted_at has second resolution
again := f.promote("v2", []byte("#!/bin/sh\necho carlos v2\n"), false)
if again.PromotedAt.Equal(first.PromotedAt) {
t.Fatal("fixture: the re-promote must move promoted_at")
}
if err := f.a.Run(context.Background(), true); err != nil {
t.Fatal(err)
}
r := f.result()
if r == nil || r.Outcome != systemOutcomeRolledBack || !r.matches("stable", "v2", first.PromotedAt) {
t.Fatalf("result = %+v, want the promote the journal names (%s)", r, first.PromotedAt)
}
// And the re-promote is a tuple of its own: the terminal-tuple guard
// does not refuse it.
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied || !r.matches("stable", "v2", again.PromotedAt) {
t.Fatalf("result = %+v, the operator's re-promote must be applied", r)
}
}
func TestRecoveryFromGatedRollsBackWhenTheBinaryIsNotTheGatedOne(t *testing.T) {
// The journal says v2 passed its gate, but v1 is on disk: whatever put
// it there, this is a pre-gate state and going forward would record an
// apply of a build that is not installed.
f, _ := applyHappy(t)
os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v1\n"), 0o755)
writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "journal.json"),
systemJournal{Phase: systemPhaseGated, Target: "v2", PrevBuild: "v1", StartedAt: f.now}, 0o600)
if err := f.a.Run(context.Background(), true); err != nil {
t.Fatal(err)
}
if ap, _ := readSystemApplied(f.a.optDir); ap != nil {
t.Fatalf("applied.json written for a build that is not on disk: %+v", ap)
}
if r := f.result(); r == nil || r.Outcome != systemOutcomeRolledBack {
t.Fatalf("result = %+v", r)
}
}
func TestRollbackThatCannotRestoreKeepsTheJournalForTheNextRun(t *testing.T) {
f, _ := applyHappy(t)
f.edgeUp = false
prevDir := filepath.Join(f.a.stagingDir(), "prev")
prevPlan := f.answer["plan:"+systemPrevPath(f.a.optDir)]
stops := 0
orig := f.a.ops.systemctl
f.a.ops.systemctl = func(args ...string) (string, error) {
// The rollback's own quiesce: by then the staging is gone and
// carlos.prev will not bootstrap, so the restore stops after the
// binary and before the units.
if args[0] == "stop" {
if stops++; stops == 2 {
os.RemoveAll(prevDir)
delete(f.answer, "plan:"+systemPrevPath(f.a.optDir))
}
}
return orig(args...)
}
if err := f.a.Run(context.Background(), false); err == nil {
t.Fatal("a failed rollback must exit non-zero")
}
if r := f.result(); r == nil || r.Outcome != systemOutcomeRollbackFailed {
t.Fatalf("result = %+v", r)
}
if j, _ := readSystemJournal(f.a.optDir); j == nil {
t.Fatal("journal removed although the restore never finished")
}
units := filepath.Join(f.a.etcDir, "systemd", "system")
if _, err := os.Stat(filepath.Join(units, "carlos-new-only.path")); err != nil {
t.Fatal("precondition: the failed build's units are what is still installed")
}
// The next run recovers what the rollback could not: the bootstrap
// works again, and the previous build's units go back.
f.answer["plan:"+systemPrevPath(f.a.optDir)] = prevPlan
if err := f.a.Run(context.Background(), true); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(units, "carlos-new-only.path")); err == nil {
t.Error("added unit survived the recovery")
}
if _, err := os.Stat(filepath.Join(units, "carlos-old-only.timer")); err != nil {
t.Error("removed unit not restored by the recovery")
}
if j, _ := readSystemJournal(f.a.optDir); j != nil {
t.Error("journal left open after a complete recovery")
}
}
func TestARecoveredRunDoesNotThenApplyTheRequest(t *testing.T) {
// The v2 request is still on disk, and the recovery has just rolled v2
// back. Applying it again in the same run would overwrite the result
// the agent is waiting for and restart the edge a second time.
f, _ := applyHappy(t)
os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v1\n"), 0o755)
os.WriteFile(systemBinaryPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v2\n"), 0o755)
writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "journal.json"),
systemJournal{Phase: systemPhaseUnits, Target: "v2", PrevBuild: "v1", StartedAt: f.now}, 0o600)
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
if r := f.result(); r == nil || r.Outcome != systemOutcomeRolledBack {
t.Fatalf("result = %+v", r)
}
if ap, _ := readSystemApplied(f.a.optDir); ap != nil {
t.Fatalf("the request was applied on top of the recovery: %+v", ap)
}
bin, _ := os.ReadFile(systemBinaryPath(f.a.optDir))
if !strings.Contains(string(bin), "carlos v1") {
t.Fatal("the binary is not the one the recovery restored")
}
}
func TestARolledBackTupleIsNotAppliedAgain(t *testing.T) {
f, req := applyHappy(t)
results := filepath.Join(systemRootDir(f.a.optDir), "result.json")
writeJSONDurable(results, systemResult{Channel: "stable", Version: req.Version, PromotedAt: req.PromotedAt,
Outcome: systemOutcomeRolledBack, Reason: "gate: carlos-edge is failed", At: f.now}, 0o644)
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
if len(f.calls) != 0 {
t.Fatalf("a build that already rolled back here was applied again: %v", f.calls)
}
if r := f.result(); r == nil || r.Outcome != systemOutcomeRolledBack {
t.Fatalf("result = %+v, the standing outcome must be left alone", r)
}
// A refusal is not terminal: the same tuple is tried again.
writeJSONDurable(results, systemResult{Channel: "stable", Version: req.Version, PromotedAt: req.PromotedAt,
Outcome: systemOutcomeRefused, Reason: "seen.json unreadable", At: f.now}, 0o644)
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied {
t.Fatalf("result = %+v, a refused tuple must stay retryable", r)
}
}
// --recover runs at boot, before the network is up and on a box whose
// environment may be no more than /etc/carlos/host.env: it must never need
// a deploy store or CARLOS_RELEASE_PUBKEY. The seam says so out loud —
// opening the store at all fails the test.
func TestRecoverNeedsNeitherStoreNorPubkey(t *testing.T) {
f, _ := applyHappy(t)
f.a.openStore = func(context.Context) (release.Store, ed25519.PublicKey, error) {
t.Error("--recover opened the deploy store")
return nil, nil, errors.New("no store on this box")
}
os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v1\n"), 0o755)
os.WriteFile(systemBinaryPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v2\n"), 0o755)
writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "journal.json"),
systemJournal{Phase: systemPhaseUnits, Target: "v2", PrevBuild: "v1", StartedAt: f.now}, 0o600)
if err := f.a.Run(context.Background(), true); err != nil {
t.Fatal(err)
}
bin, _ := os.ReadFile(systemBinaryPath(f.a.optDir))
if !strings.Contains(string(bin), "carlos v1") {
t.Fatalf("binary = %q, want the .prev restored", bin)
}
// And a --recover with nothing to recover — the every-boot case — is
// just as storeless.
f2, _ := applyHappy(t)
f2.a.openStore = func(context.Context) (release.Store, ed25519.PublicKey, error) {
t.Error("a no-op --recover opened the deploy store")
return nil, nil, errors.New("no store on this box")
}
if err := f2.a.Run(context.Background(), true); err != nil {
t.Fatal(err)
}
if f2.result() != nil {
t.Fatalf("a --recover with no journal wrote a result: %+v", f2.result())
}
}
// --recover waits for the apply lock, but not forever. carlos-system-recover
// is ordered Before=carlos-edge.service, so a recover start job queued while
// an apply is mid-`systemctl restart carlos-edge` has systemd holding the
// restart for the recovery — and a recovery blocking on the lock would hold
// it for the apply. Both would sit there until their unit timeouts. It waits
// half a minute and stands down; the apply recovers at its own start.
func TestRecoverGivesUpOnALockAnApplyHolds(t *testing.T) {
f, _ := applyHappy(t)
writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "journal.json"),
systemJournal{Phase: systemPhaseUnits, Target: "v2", PrevBuild: "v1", StartedAt: f.now}, 0o600)
os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v1\n"), 0o755)
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.Skipf("flock unsupported on this platform: %v", err)
}
var logged []string
f.a.ops.logf = func(format string, args ...any) { logged = append(logged, format) }
start := f.now
if err := f.a.Run(context.Background(), true); err != nil {
t.Fatalf("a locked-out --recover must exit 0: %v", err)
}
if waited := f.now.Sub(start); waited != systemRecoverLockWait {
t.Errorf("waited %s, want %s", waited, systemRecoverLockWait)
}
if len(logged) != 1 || !strings.Contains(logged[0], "it recovers at its own start") {
t.Errorf("log = %v", logged)
}
if j, _ := readSystemJournal(f.a.optDir); j == nil {
t.Error("a locked-out --recover closed the journal it never read")
}
if len(f.calls) != 0 {
t.Errorf("a locked-out --recover touched the box: %v", f.calls)
}
// The lock is free again: the next run gets it and does the work.
held.Close()
if err := f.a.Run(context.Background(), true); err != nil {
t.Fatal(err)
}
if j, _ := readSystemJournal(f.a.optDir); j != nil {
t.Error("the recovery that got the lock left the journal open")
}
}
// The carlos.prev backup is NOT journaled, though everything else that
// touches the box is. copyFileDurable is whole-or-nothing, so there is
// nothing to recover from — and a journal here is actively harmful: the
// exec shim in carlos-system-recover.service reads it at the next boot and
// would run a .prev one release behind the box for a whole apply.
//
// A sentinel journal makes the absence visible: decidePrev neither writes
// over it nor removes it. (Nothing puts a journal there in real life —
// recoverIfNeeded has closed any open one long before install runs.)
func TestPrevBackupIsNotJournaled(t *testing.T) {
f, _ := applyHappy(t)
writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "journal.json"),
systemJournal{Phase: "sentinel", Target: "v9", PrevBuild: "v1", StartedAt: f.now}, 0o600)
writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "applied.json"),
systemApplied{Version: "v1", Channel: "stable", At: f.now}, 0o644)
ok, err := f.a.decidePrev(context.Background())
if err != nil || !ok {
t.Fatalf("decidePrev = %v, %v", ok, err)
}
prev, _ := os.ReadFile(systemPrevPath(f.a.optDir))
if !strings.Contains(string(prev), "carlos v1") {
t.Fatalf(".prev = %q, want the known-good v1 backed up", prev)
}
j, err := readSystemJournal(f.a.optDir)
if err != nil {
t.Fatal(err)
}
if j == nil || j.Phase != "sentinel" {
t.Fatalf("journal = %+v, want the sentinel untouched: the backup writes no journal", j)
}
}
// A journal written by the version that DID journal the backup outlives the
// upgrade to the one that does not: recovery still knows the phase, cleans
// the half-written copy, and carries on to the request it was started for.
func TestRecoveryStillHandlesALegacyBackingUpJournal(t *testing.T) {
f, req := applyHappy(t)
tmp := systemPrevPath(f.a.optDir) + ".tmp"
os.WriteFile(tmp, []byte("half a binary"), 0o755)
writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "journal.json"),
systemJournal{Phase: systemPhaseBackingUp, PrevBuild: "v1", StartedAt: f.now}, 0o600)
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(tmp); err == nil {
t.Error("the half-written .prev copy was left behind")
}
if j, _ := readSystemJournal(f.a.optDir); j != nil {
t.Error("the legacy journal was left open")
}
if r := f.result(); r == nil || !r.matches("stable", req.Version, req.PromotedAt) || r.Outcome != systemOutcomeApplied {
t.Fatalf("result = %+v, want the run to carry on to its request", r)
}
}
// TestApplyArmsTheTargetBuildsList is the forward-compatibility half of
// discussion #118. The applier is the OLD binary: when the target build
// introduces a unit that must be started at apply time rather than at the
// next boot, only the target build's own plan knows that. So arm() starts
// what plan.Armed names, even a unit this binary's own rule would skip.
//
// Without this, a unit new to the box is enabled and left inactive — which
// is exactly how carlos-relay.service reached the Ireland box on 2026-09-07
// enabled, gated, armed with a masters file, and not running.
func TestApplyArmsTheTargetBuildsList(t *testing.T) {
_, started := armCalls(t, bootstrapPlan{
Enabled: []string{"carlos-edge.service", "carlos-future.service"},
// A service this binary's armedUnits would never pick.
Armed: []string{"carlos-future.service"},
})
if !slices.Contains(started, "carlos-future.service") {
t.Errorf("apply did not start the unit the target build asked for: started %v", started)
}
if slices.Contains(started, "carlos-edge.service") {
t.Errorf("apply started the edge, which runs this very code: started %v", started)
}
}
// The plan's armed list comes from another build, so arm() does not take it
// on trust: the edge is never started whatever the plan says, and a unit the
// plan arms but does not enable is dropped rather than passed to systemctl,
// because one bad name in the single batched `start` would lose every unit
// in it.
func TestApplyRefusesToArmTheEdgeOrAnUnenabledUnit(t *testing.T) {
_, started := armCalls(t, bootstrapPlan{
Enabled: []string{"carlos-adopt.timer", "carlos-edge.service"},
Armed: []string{"carlos-adopt.timer", "carlos-edge.service", "carlos-never-installed.service"},
})
if slices.Contains(started, "carlos-edge.service") {
t.Errorf("the edge was started from the plan's armed list: started %v", started)
}
if slices.Contains(started, "carlos-never-installed.service") {
t.Errorf("a unit the plan never enabled was handed to systemctl: started %v", started)
}
if !slices.Contains(started, "carlos-adopt.timer") {
t.Errorf("the good unit was lost along with the bad ones: started %v", started)
}
}
// A plan.json from a build older than the Armed field has no armed key. That
// is what a rollback stages (carlos.prev's plan), so the fallback is not a
// theoretical path — arm() must decide locally exactly as it did before.
func TestApplyArmsLocallyWhenPlanPredatesArmed(t *testing.T) {
var plan bootstrapPlan
if err := json.Unmarshal([]byte(`{"enabled":["carlos-adopt.timer","carlos-edge.service","carlos-relay.service"]}`), &plan); err != nil {
t.Fatal(err)
}
if plan.Armed != nil {
t.Fatalf("armed should be absent, got %v", plan.Armed)
}
_, started := armCalls(t, plan)
// Membership, not substring: the relay is batched onto the same start
// line as the timer, and its bare name also appears on the enable line.
// A substring check is how the first version of this test passed
// against a fallback changed to drop the relay — the exact #118 failure
// mode it exists to catch.
for _, want := range []string{"carlos-adopt.timer", "carlos-relay.service"} {
if !slices.Contains(started, want) {
t.Errorf("fallback did not arm %s: started %v", want, started)
}
}
if slices.Contains(started, "carlos-edge.service") {
t.Errorf("fallback started the edge: started %v", started)
}
}
// armCalls runs arm(plan) against a recording systemd and returns the units
// it enabled and the units it started. Both are read as SETS out of the two
// batched systemctl lines: arm puts every unit on one `enable` and every
// started unit on one `start`, so a substring check over the joined log
// cannot tell "start carlos-relay.service" from the same name appearing in
// the enable line, and cannot see a name that is merely batched beside
// another. The first version of these tests got both wrong.
func armCalls(t *testing.T, plan bootstrapPlan) (enabled, started []string) {
t.Helper()
a := &systemApplier{ops: sysOps{
systemctl: func(args ...string) (string, error) {
switch args[0] {
case "enable":
enabled = append(enabled, args[1:]...)
case "start":
started = append(started, args[1:]...)
}
return "", nil
},
logf: func(string, ...any) {},
}}
if err := a.arm(plan); err != nil {
t.Fatal(err)
}
return enabled, started
}
// seedRoutes writes rows into the fixture's registry and closes it, so the
// guard's own open is the only one held when Run reads it.
func (f *applyFixture) seedRoutes(routes ...registry.Route) {
f.t.Helper()
reg, err := registry.Open(filepath.Join(f.a.dataDir, "registry.db"))
if err != nil {
f.t.Fatal(err)
}
defer reg.Close()
for _, rt := range routes {
if err := reg.Upsert(rt); err != nil {
f.t.Fatal(err)
}
}
}
func flatRow(dataDir, host string) registry.Route {
return registry.Route{Host: host, Kind: "instance", App: "tito", Channel: "stable", Hibernate: true, Status: "ready",
DBPath: provision.DefaultDBPath(dataDir, host), Socket: provision.DefaultSocket(host)}
}
func nestedRow(dataDir, host string) registry.Route {
rt := flatRow(dataDir, host)
rt.DBPath, rt.Socket = provision.NestedDBPath(dataDir, host), provision.NestedSocket(host)
return rt
}
// postLayout gives the fixture's candidate the mount unit the layout ships
// with, which is what makes it a release that understands nested rows.
func (f *applyFixture) postLayout() {
staged := filepath.Join(f.a.stagingDir(), "carlos")
f.answer["units:"+staged] += " data-instances.mount"
}
// A pre-layout candidate would remove the mount units and then serve a
// nested row from an empty mountpoint directory, so the apply refuses it
// before anything on the box changes, and names the row in the way.
func TestApplyRefusesAPreLayoutCandidateOverANestedRow(t *testing.T) {
f, req := applyHappy(t)
f.seedRoutes(flatRow(f.a.dataDir, "flat.example.com"), nestedRow(f.a.dataDir, "acme.example.com"))
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)
}
want := "refused: v2 predates the instance layout, and this box still has: acme.example.com. " +
"revert the layout and wait for every row to report flat before installing it"
if r.Reason != want {
t.Fatalf("reason = %q\nwant %q", r.Reason, want)
}
if j, _ := readSystemJournal(f.a.optDir); j != nil {
t.Fatalf("a refused apply wrote a journal: %+v", j)
}
if bin, _ := os.ReadFile(systemBinaryPath(f.a.optDir)); !strings.Contains(string(bin), "carlos v1") {
t.Fatal("a refused apply swapped the binary")
}
if f.called("systemctl stop") || f.called("systemctl restart") {
t.Fatalf("a refused apply touched systemd: %v", f.calls)
}
}
func TestApplyInstallsAPreLayoutCandidateOverFlatRows(t *testing.T) {
f, req := applyHappy(t)
f.seedRoutes(flatRow(f.a.dataDir, "flat.example.com"), flatRow(f.a.dataDir, "acme.example.com"))
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied || !r.matches("stable", "v2", req.PromotedAt) {
t.Fatalf("result = %+v", r)
}
}
// A candidate that ships the mount units understands nested rows, so the
// guard never even opens the registry for it.
func TestApplyInstallsAPostLayoutCandidateOverNestedRows(t *testing.T) {
f, req := applyHappy(t)
f.postLayout()
f.seedRoutes(nestedRow(f.a.dataDir, "acme.example.com"))
opened := false
f.a.openRegistry = func() (*registry.Registry, error) {
opened = true
return nil, errors.New("not reached")
}
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied || !r.matches("stable", "v2", req.PromotedAt) {
t.Fatalf("result = %+v", r)
}
if opened {
t.Error("the guard opened the registry for a candidate that ships the layout")
}
}
// A candidate that ships the mount units but reports a version below
// release.LayoutFloor (v0.20.0 to v0.24.2: mounts, but an edge unit on
// which nested rows cannot wake) is refused over a nested row all the same.
// This is the box's own line of defence against `carlos system update
// --file`, which the console's guard does not judge. Flat rows let it in.
func TestApplyRefusesAMountedButBelowFloorCandidateOverANestedRow(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
rows []registry.Route
want string
reason string
}{
{"nested row", nil, systemOutcomeRefused, "v0.24.2 predates the instance layout, and this box still has: acme.example.com"},
{"flat rows", nil, systemOutcomeApplied, ""},
} {
t.Run(tc.name, func(t *testing.T) {
f, req := applyHappyVersion(t, "v0.24.2")
f.postLayout()
dd := f.a.dataDir
if tc.name == "nested row" {
f.seedRoutes(flatRow(dd, "flat.example.com"), nestedRow(dd, "acme.example.com"))
} else {
f.seedRoutes(flatRow(dd, "flat.example.com"), flatRow(dd, "acme.example.com"))
}
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
r := f.result()
if r == nil || r.Outcome != tc.want || !r.matches("stable", "v0.24.2", req.PromotedAt) {
t.Fatalf("result = %+v, want %s", r, tc.want)
}
if tc.reason != "" && !strings.Contains(r.Reason, tc.reason) {
t.Fatalf("reason = %q, want it to contain %q", r.Reason, tc.reason)
}
})
}
// At the floor the unit set is what counts, and it ships the mounts.
f, req := applyHappyVersion(t, "v0.25.0")
f.postLayout()
f.seedRoutes(nestedRow(f.a.dataDir, "acme.example.com"))
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied || !r.matches("stable", "v0.25.0", req.PromotedAt) {
t.Fatalf("v0.25.0 over a nested row = %+v, want applied", r)
}
}
// A registry the guard cannot read cannot vouch that nothing is nested.
func TestApplyRefusesAPreLayoutCandidateWhenTheRegistryWillNotOpen(t *testing.T) {
f, _ := applyHappy(t)
f.a.openRegistry = func() (*registry.Registry, error) { return nil, errors.New("registry.db: disk I/O error") }
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
r := f.result()
if r == nil || r.Outcome != systemOutcomeRefused || !strings.Contains(r.Reason, "registry.db: disk I/O error") ||
!strings.Contains(r.Reason, "predates the instance layout") {
t.Fatalf("result = %+v", r)
}
if j, _ := readSystemJournal(f.a.optDir); j != nil {
t.Fatalf("a refused apply wrote a journal: %+v", j)
}
}
// Every row a pre-layout release would mishandle is named once: nested by
// path shape whatever its flags, fenced by a journal, and a journal whose
// row is gone. Flat rows are not named.
func TestLayoutReversalIncompleteNamesEveryRowInTheWay(t *testing.T) {
t.Parallel()
dd := t.TempDir()
reg, err := registry.Open(filepath.Join(dd, "registry.db"))
if err != nil {
t.Fatal(err)
}
defer reg.Close()
unflagged := nestedRow(dd, "unflagged.example.com")
unflagged.Hibernate = false
for _, rt := range []registry.Route{
flatRow(dd, "flat.example.com"),
nestedRow(dd, "nested.example.com"),
unflagged,
flatRow(dd, "moving.example.com"),
} {
if err := reg.Upsert(rt); err != nil {
t.Fatal(err)
}
}
os.MkdirAll(filepath.Join(dd, "layout"), 0o700)
for _, h := range []string{"moving.example.com", "gone.example.com"} {
os.WriteFile(provision.LayoutJournalPath(dd, h), []byte("{}"), 0o600)
}
// A journal's temp file is not a journal.
os.WriteFile(filepath.Join(dd, "layout", ".other.example.com.tmp-1"), []byte("{}"), 0o600)
got := layoutReversalIncomplete(reg, dd)
want := []string{"gone.example.com", "moving.example.com", "nested.example.com", "unflagged.example.com"}
if !slices.Equal(got, want) {
t.Fatalf("reasons = %q, want %q", got, want)
}
flatOnly := t.TempDir()
reg2, err := registry.Open(filepath.Join(flatOnly, "registry.db"))
if err != nil {
t.Fatal(err)
}
defer reg2.Close()
if err := reg2.Upsert(flatRow(flatOnly, "flat.example.com")); err != nil {
t.Fatal(err)
}
if got := layoutReversalIncomplete(reg2, flatOnly); len(got) != 0 {
t.Fatalf("a flat box reported %q", got)
}
}
// ---- the moves protocol floor (moves spec §11, Task 13) ----
// floorFixture is applyHappy with release numbers the floor can compare: a
// box running `running` (applied.json names it, so the install refreshes
// carlos.prev from it) and a signed candidate at target. The candidate
// ships the layout's mount unit, so the layout guard never opens the
// registry and every registry open in these tests is the floor's own.
func floorFixture(t *testing.T, running, target string) (*applyFixture, systemRequest) {
t.Helper()
f := newApplyFixture(t, "stable", running)
req := f.promote(target, []byte("#!/bin/sh\necho carlos "+target+"\n"), false)
bin, staged, prev := systemBinaryPath(f.a.optDir), filepath.Join(f.a.stagingDir(), "carlos"), systemPrevPath(f.a.optDir)
f.answer["version:"+staged] = "carlos " + target + "\n"
f.answer["plan:"+staged] = f.answer["plan:"+bin]
f.answer["units:"+staged] = f.answer["units:"+bin] + " " + layoutMountUnit
f.answer["plan:"+prev] = f.answer["plan:"+bin]
f.answer["units:"+prev] = f.answer["units:"+bin]
// Every binary answers `version` from its own bytes from here on.
delete(f.answer, "version:"+bin)
if err := writeJSONDurable(f.a.file("applied.json"), systemApplied{Version: running, Channel: "stable", At: f.now}, 0o644); err != nil {
t.Fatal(err)
}
return f, req
}
// holdRow seeds a row and holds it under a move, as the agent's SetHold does.
func (f *applyFixture) holdRow(host string) {
f.t.Helper()
f.seedRoutes(flatRow(f.a.dataDir, host))
reg, err := registry.Open(filepath.Join(f.a.dataDir, "registry.db"))
if err != nil {
f.t.Fatal(err)
}
defer reg.Close()
if err := reg.SetHold(host, "moving", "op-1"); err != nil {
f.t.Fatal(err)
}
}
// onDisk is what the box's carlos binary says it is.
func (f *applyFixture) onDisk() string { return f.a.onDiskBuild(context.Background()) }
// brokenBox puts a failed install on disk (running), a carlos.prev at prev
// with its sidecar (none when sidecar is ""), and the journal an install
// leaves open after its restart.
func (f *applyFixture) brokenBox(running, prev, sidecar string) systemJournal {
f.t.Helper()
os.WriteFile(systemBinaryPath(f.a.optDir), []byte("#!/bin/sh\necho carlos "+running+"\n"), 0o755)
os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos "+prev+"\n"), 0o755)
if sidecar != "" {
if err := writePrevVersion(f.a.optDir, sidecar); err != nil {
f.t.Fatal(err)
}
}
j := systemJournal{Phase: systemPhaseRestarted, Target: running, PrevBuild: prev, StartedAt: f.now}
if err := f.a.journal(j); err != nil {
f.t.Fatal(err)
}
return j
}
func TestSystemApplyArmsTheFloorBeforeStaging(t *testing.T) {
f, _ := floorFixture(t, "v0.28.0", "v0.29.0")
putFloor(t, f.store, "v0.30.0")
staged := filepath.Join(f.a.stagingDir(), "carlos")
run := f.a.ops.run
f.a.ops.run = func(ctx context.Context, d time.Duration, name string, args ...string) (string, error) {
if name == staged {
t.Errorf("a below-floor candidate was staged and run: %s %v", name, args)
}
return run(ctx, d, name, args...)
}
before, _ := os.ReadFile(systemBinaryPath(f.a.optDir))
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
if lf, err := readLocalFloor(f.a.dataDir); err != nil || lf == nil || lf.MinRelease != "v0.30.0" {
t.Fatalf("local floor = %+v, %v", lf, err)
}
if b, _ := os.ReadFile(filepath.Join(f.a.dataDir, moves.FloorMinReleaseFile)); string(b) != "v0.30.0\n" {
t.Fatalf("floor.min-release = %q", b)
}
r := f.result()
if r == nil || r.Outcome != systemOutcomeRefused || !strings.Contains(r.Reason, "moves protocol floor: v0.29.0 is below v0.30.0") {
t.Fatalf("result = %+v", r)
}
if after, _ := os.ReadFile(systemBinaryPath(f.a.optDir)); !bytes.Equal(before, after) {
t.Fatal("the binary changed")
}
if _, err := os.Stat(systemPrevPath(f.a.optDir)); err == nil {
t.Fatal("a refused install refreshed carlos.prev")
}
if j, _ := readSystemJournal(f.a.optDir); j != nil {
t.Fatal("a refused install opened a journal")
}
}
// Boot recovery runs before the network: the floor it enforces is the one
// on disk, and the version it judges is carlos.prev's sidecar.
func TestBootRecoveryUsesTheArmedFloorOffline(t *testing.T) {
for _, tc := range []struct {
name, sidecar string
restored bool
}{
{"below the floor", "v0.29.0", false},
{"at the floor", "v0.30.0", true},
{"no sidecar", "", false},
} {
t.Run(tc.name, func(t *testing.T) {
f, _ := floorFixture(t, "v0.31.0", "v0.32.0")
f.a.openStore = func(context.Context) (release.Store, ed25519.PublicKey, error) {
return unreadableStore{f.store}, f.pub, nil
}
armLocalFloor(t, f.a.dataDir, "v0.30.0")
prev := tc.sidecar
if prev == "" {
prev = "v0.30.0"
}
f.brokenBox("v0.31.0", prev, tc.sidecar)
f.edgeStartedThisBoot = false
err := f.a.Run(context.Background(), true)
j, _ := readSystemJournal(f.a.optDir)
if tc.restored {
if err != nil || f.onDisk() != "v0.30.0" || j != nil {
t.Fatalf("err %v, on disk %q, journal %+v", err, f.onDisk(), j)
}
return
}
if err == nil || !strings.Contains(err.Error(), "moves protocol floor") {
t.Fatalf("recovery err = %v", err)
}
if f.onDisk() != "v0.31.0" || j == nil {
t.Fatalf("a refused recovery changed the box: on disk %q, journal %+v", f.onDisk(), j)
}
})
}
}
// decidePrev learns carlos.prev's version and judges it before the journal
// is written. The proof of "before": region HA defers this install at the
// step just ahead of the journal, and the two files are already there.
func TestDecidePrevWritesTheSidecarAndVerdictBeforeTheJournal(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name, running string
held, verdict bool
}{
{"at or above the floor", "v0.31.0", false, true},
{"below the floor", "v0.29.0", false, false},
{"a held row", "v0.31.0", true, false},
} {
t.Run(tc.name, func(t *testing.T) {
f, _ := floorFixture(t, tc.running, "v0.32.0")
putFloor(t, f.store, "v0.30.0")
if tc.held {
f.holdRow("app.example.com")
}
// A verdict from an earlier judgement must not outlive this one.
writeFileDurable(filepath.Join(f.a.optDir, moves.PrevAllowedFile), []byte("v0.30.0\n"), 0o644)
f.a.regionHA = true
os.MkdirAll(filepath.Join(f.a.dataDir, hostexecDir, "op-9"), 0o755)
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
if r := f.result(); r == nil || r.Outcome != systemOutcomeDeferred {
t.Fatalf("result = %+v", r)
}
if j, _ := readSystemJournal(f.a.optDir); j != nil {
t.Fatal("journal written")
}
if b, _ := os.ReadFile(filepath.Join(f.a.optDir, moves.PrevVersionFile)); string(b) != tc.running+"\n" {
t.Fatalf("sidecar = %q, want %s", b, tc.running)
}
b, err := os.ReadFile(filepath.Join(f.a.optDir, moves.PrevAllowedFile))
if tc.verdict != (err == nil) || (tc.verdict && string(b) != "v0.30.0\n") {
t.Fatalf("verdict = %q, %v; want present %v", b, err, tc.verdict)
}
})
}
}
// Replacing carlos.prev withdraws its predecessor's sidecar and verdict
// first, so a copy that dies part way leaves a carlos.prev nothing vouches
// for, never the new bytes under the old verdict.
func TestDecidePrevForgetsTheOldVerdictBeforeTheCopy(t *testing.T) {
f, _ := floorFixture(t, "v0.31.0", "v0.32.0")
putFloor(t, f.store, "v0.30.0")
os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v0.30.0\n"), 0o755)
writePrevVersion(f.a.optDir, "v0.30.0")
writeFileDurable(filepath.Join(f.a.optDir, moves.PrevAllowedFile), []byte("v0.30.0\n"), 0o644)
// Prevent publication without depending on the writer's temporary name.
if err := os.Remove(systemPrevPath(f.a.optDir)); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(systemPrevPath(f.a.optDir), 0o755); err != nil {
t.Fatal(err)
}
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
if r := f.result(); r == nil || r.Outcome != systemOutcomeRefused {
t.Fatalf("result = %+v", r)
}
for _, name := range []string{moves.PrevAllowedFile, moves.PrevVersionFile} {
if _, err := os.Stat(filepath.Join(f.a.optDir, name)); err == nil {
t.Errorf("%s survived a failed replacement of carlos.prev", name)
}
}
}
// Moves spec §11 (round-6 finding 5): while a row is held, no activating
// path takes the box below the release it runs, whatever the floor allows.
func TestHeldParticipantRefusesEveryDowngrade(t *testing.T) {
const downgrade = "a participant in a held operation does not downgrade"
for _, held := range []bool{true, false} {
t.Run(fmt.Sprintf("held=%v", held), func(t *testing.T) {
// install
f, _ := floorFixture(t, "v0.32.0", "v0.31.0")
putFloor(t, f.store, "v0.30.0")
if held {
f.holdRow("app.example.com")
}
f.a.Run(context.Background(), false)
r := f.result()
if held && (r == nil || r.Outcome != systemOutcomeRefused || !strings.Contains(r.Reason, downgrade) || f.onDisk() != "v0.32.0") {
t.Fatalf("held install: result %+v, on disk %q", r, f.onDisk())
}
if !held && (r == nil || r.Outcome != systemOutcomeApplied || f.onDisk() != "v0.31.0") {
t.Fatalf("unheld install: result %+v, on disk %q", r, f.onDisk())
}
// rollback
f, req := floorFixture(t, "v0.32.0", "v0.33.0")
armLocalFloor(t, f.a.dataDir, "v0.30.0")
if held {
f.holdRow("app.example.com")
}
j := f.brokenBox("v0.32.0", "v0.31.0", "v0.31.0")
f.a.rollback(context.Background(), req, "gate: test", &j)
r = f.result()
if held && (r == nil || r.Outcome != systemOutcomeRollbackFailed || !strings.Contains(r.Reason, downgrade) || f.onDisk() != "v0.32.0") {
t.Fatalf("held rollback: result %+v, on disk %q", r, f.onDisk())
}
if !held && (r == nil || r.Outcome != systemOutcomeRolledBack || f.onDisk() != "v0.31.0") {
t.Fatalf("unheld rollback: result %+v, on disk %q", r, f.onDisk())
}
// restoreFiles, which recovery calls without rollback
f, _ = floorFixture(t, "v0.32.0", "v0.33.0")
armLocalFloor(t, f.a.dataDir, "v0.30.0")
if held {
f.holdRow("app.example.com")
}
j = f.brokenBox("v0.32.0", "v0.31.0", "v0.31.0")
_, err := f.a.restoreFiles(context.Background(), &j)
if held && (err == nil || !strings.Contains(err.Error(), downgrade) || f.onDisk() != "v0.32.0") {
t.Fatalf("held restore: err %v, on disk %q", err, f.onDisk())
}
if !held && (err != nil || f.onDisk() != "v0.31.0") {
t.Fatalf("unheld restore: err %v, on disk %q", err, f.onDisk())
}
})
}
}
// D13: the floor is armed by publishing its object, not by the switch. A
// deployment that never published it behaves as main, held rows and all,
// and never opens the registry for the floor. Once armed, turning the
// switch off does not disarm it.
func TestFloorIsDormantWithoutTheObject(t *testing.T) {
t.Setenv(moves.SwitchEnv, "")
counting := func(f *applyFixture) *int {
n := 0
open := f.a.openRegistry
f.a.openRegistry = func() (*registry.Registry, error) { n++; return open() }
return &n
}
f, _ := floorFixture(t, "v0.32.0", "v0.31.0")
f.holdRow("app.example.com")
opened := counting(f)
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied || f.onDisk() != "v0.31.0" {
t.Fatalf("install: result %+v, on disk %q", r, f.onDisk())
}
if *opened != 0 {
t.Fatalf("an unarmed install opened the registry %d times", *opened)
}
// The activation guard creates .moves for its lock on every box (fix
// round 2); an unarmed install writes no floor into it.
for _, name := range []string{moves.FloorFile, moves.FloorMinReleaseFile} {
if _, err := os.Stat(filepath.Join(f.a.dataDir, name)); err == nil {
t.Fatalf("an unarmed install wrote %s", name)
}
}
if _, err := os.Stat(filepath.Join(f.a.optDir, moves.PrevAllowedFile)); err == nil {
t.Fatal("an unarmed install wrote a verdict")
}
f, req := floorFixture(t, "v0.32.0", "v0.33.0")
f.holdRow("app.example.com")
opened = counting(f)
j := f.brokenBox("v0.32.0", "v0.31.0", "")
f.a.rollback(context.Background(), req, "gate: test", &j)
if r := f.result(); r == nil || r.Outcome != systemOutcomeRolledBack || f.onDisk() != "v0.31.0" {
t.Fatalf("rollback: result %+v, on disk %q", r, f.onDisk())
}
if *opened != 0 {
t.Fatalf("an unarmed rollback opened the registry %d times", *opened)
}
f, _ = floorFixture(t, "v0.32.0", "v0.33.0")
f.holdRow("app.example.com")
opened = counting(f)
j = f.brokenBox("v0.32.0", "v0.31.0", "")
if _, err := f.a.restoreFiles(context.Background(), &j); err != nil || f.onDisk() != "v0.31.0" {
t.Fatalf("restore: err %v, on disk %q", err, f.onDisk())
}
if *opened != 0 {
t.Fatalf("an unarmed restore opened the registry %d times", *opened)
}
// Armed on disk, the switch still off: a below-floor carlos.prev stays put.
f, _ = floorFixture(t, "v0.32.0", "v0.33.0")
armLocalFloor(t, f.a.dataDir, "v0.30.0")
j = f.brokenBox("v0.32.0", "v0.29.0", "v0.29.0")
if _, err := f.a.restoreFiles(context.Background(), &j); err == nil || !strings.Contains(err.Error(), "moves protocol floor: v0.29.0 is below v0.30.0") {
t.Fatalf("armed restore with the switch off: err %v", err)
}
if f.onDisk() != "v0.32.0" {
t.Fatalf("on disk %q", f.onDisk())
}
}
// duringStaging runs change when the apply runs the staged candidate's
// `version`, which is the middle of staging: after the early activation
// check, before the install puts anything in place.
func (f *applyFixture) duringStaging(change func()) {
staged := filepath.Join(f.a.stagingDir(), "carlos")
run := f.a.ops.run
once := false
f.a.ops.run = func(ctx context.Context, d time.Duration, name string, args ...string) (string, error) {
if name == staged && !once {
once = true
change()
}
return run(ctx, d, name, args...)
}
}
// Fix round 1, finding 1: the early check can go stale while the candidate
// is staged. The install checks again at the rename, under the floor lock
// and with the held rows frozen, and a refusal there leaves the box as it
// was: the binary untouched, no journal, and the quiesced passes re-armed.
func TestActivationIsCheckedAgainAtTheRename(t *testing.T) {
for _, tc := range []struct {
name, running, target, want string
change func(f *applyFixture)
}{
{"a floor raised during staging", "v0.31.0", "v0.32.0", "moves protocol floor: v0.32.0 is below v0.33.0",
func(f *applyFixture) { armLocalFloor(t, f.a.dataDir, "v0.33.0") }},
{"a hold set during staging", "v0.32.0", "v0.31.0", "a participant in a held operation does not downgrade",
func(f *applyFixture) { f.holdRow("app.example.com") }},
} {
t.Run(tc.name, func(t *testing.T) {
f, _ := floorFixture(t, tc.running, tc.target)
putFloor(t, f.store, "v0.30.0")
f.duringStaging(func() { tc.change(f) })
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
r := f.result()
if r == nil || r.Outcome != systemOutcomeRefused || !strings.Contains(r.Reason, tc.want) {
t.Fatalf("result = %+v", r)
}
if f.onDisk() != tc.running {
t.Fatalf("on disk %q, want %s", f.onDisk(), tc.running)
}
if j, _ := readSystemJournal(f.a.optDir); j != nil {
t.Fatalf("journal left open: %+v", j)
}
joined := strings.Join(f.calls, "\n")
if !strings.Contains(joined, "systemctl start carlos-adopt.timer") {
t.Fatalf("the quiesced passes were not re-armed:\n%s", joined)
}
if strings.Contains(joined, "systemctl restart carlos-edge") {
t.Fatalf("a refused install restarted the edge:\n%s", joined)
}
})
}
}
// Fix round 1, finding 2: a gated journal is finished forward only when its
// target is at or above the floor armed now. Below it, recovery goes back
// to carlos.prev, which restoreFiles judges in turn.
func TestForwardRecoveryChecksTheFloor(t *testing.T) {
for _, tc := range []struct {
name, prev, want string
ok bool
}{
{"carlos.prev below the floor too", "v0.30.0", "v0.31.0", false},
{"carlos.prev at the floor", "v0.32.0", "v0.32.0", true},
} {
t.Run(tc.name, func(t *testing.T) {
f, _ := floorFixture(t, "v0.30.0", "v0.31.0")
f.brokenBox("v0.31.0", tc.prev, tc.prev)
f.a.journal(systemJournal{Phase: systemPhaseGated, Target: "v0.31.0", PrevBuild: tc.prev, StartedAt: f.now})
armLocalFloor(t, f.a.dataDir, "v0.32.0")
f.edgeStartedThisBoot = false
err := f.a.Run(context.Background(), true)
if ap, _ := readSystemApplied(f.a.optDir); ap != nil && ap.Version == "v0.31.0" {
t.Fatalf("a below-floor target was finished forward: %+v", ap)
}
j, _ := readSystemJournal(f.a.optDir)
if f.onDisk() != tc.want {
t.Fatalf("on disk %q, want %s", f.onDisk(), tc.want)
}
if tc.ok && (err != nil || j != nil) {
t.Fatalf("err %v, journal %+v", err, j)
}
if !tc.ok && (err == nil || !strings.Contains(err.Error(), "moves protocol floor") || j == nil) {
t.Fatalf("err %v, journal %+v", err, j)
}
})
}
}
// restoreFiles puts carlos.prev in place under the floor lock: a floor
// writer holding it holds the restore off.
func TestRestoreWaitsForTheFloorLock(t *testing.T) {
f, _ := floorFixture(t, "v0.32.0", "v0.33.0")
armLocalFloor(t, f.a.dataDir, "v0.30.0")
j := f.brokenBox("v0.32.0", "v0.31.0", "v0.31.0")
held, free := make(chan struct{}), make(chan struct{})
go withFloorLock(f.a.dataDir, false, func(*movesDir) error {
close(held)
<-free
return nil
})
<-held
done := make(chan error, 1)
go func() { _, err := f.a.restoreFiles(context.Background(), &j); done <- err }()
time.Sleep(300 * time.Millisecond)
// The bytes, not f.onDisk: the fake ops are the restore's while it runs.
if b, _ := os.ReadFile(systemBinaryPath(f.a.optDir)); !strings.Contains(string(b), "v0.32.0") {
close(free)
t.Fatalf("restored while the floor lock was held: %q", b)
}
close(free)
if err := <-done; err != nil || f.onDisk() != "v0.31.0" {
t.Fatalf("after the lock: err %v, on disk %q", err, f.onDisk())
}
}
// The install's rename waits for the floor lock: a floor writer that takes
// it after the journal (at the quiesce) holds the binary where it is.
func TestInstallRenameWaitsForTheFloorLock(t *testing.T) {
f, _ := floorFixture(t, "v0.31.0", "v0.32.0")
putFloor(t, f.store, "v0.30.0")
var duringLock []byte
held := make(chan struct{})
freed := make(chan struct{})
systemctl := f.a.ops.systemctl
once := false
f.a.ops.systemctl = func(args ...string) (string, error) {
if args[0] == "stop" && !once {
once = true
go func() {
defer close(freed)
withFloorLock(f.a.dataDir, false, func(*movesDir) error {
close(held)
time.Sleep(time.Second)
duringLock, _ = os.ReadFile(systemBinaryPath(f.a.optDir))
return nil
})
}()
<-held
}
return systemctl(args...)
}
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
<-freed
if !strings.Contains(string(duringLock), "carlos v0.31.0") {
t.Fatalf("the binary was replaced while the floor lock was held: %q", duringLock)
}
if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied || f.onDisk() != "v0.32.0" {
t.Fatalf("result %+v, on disk %q", r, f.onDisk())
}
}
// ---- fix round 2: one invariant. No executable is renamed into place, and
// no journal is finished, unless the floor and hold check ran under
// floor.lock and WithHoldsFrozen immediately before that step. ----
// unitActivity fakes systemctl's view of the root passes' triggers, so a
// test can see which were active before a step and after it.
func (f *applyFixture) unitActivity(active map[string]bool) {
systemctl := f.a.ops.systemctl
f.a.ops.systemctl = func(args ...string) (string, error) {
switch args[0] {
case "is-active":
if len(args) > 1 && args[1] != systemEdgeUnit {
var out []string
for _, u := range args[1:] {
if active[u] {
out = append(out, "active")
} else {
out = append(out, "inactive")
}
}
f.calls = append(f.calls, "systemctl "+strings.Join(args, " "))
return strings.Join(out, "\n") + "\n", nil
}
case "stop":
for _, u := range args[1:] {
active[u] = false
}
case "start":
for _, u := range args[1:] {
active[u] = true
}
}
return systemctl(args...)
}
}
func copyActivity(m map[string]bool) map[string]bool {
c := map[string]bool{}
for k, v := range m {
c[k] = v
}
return c
}
// First arming, during staging: the agent arms a floor on a box that had
// none when the apply began. The rename refuses.
func TestFirstFloorArmedDuringStagingRefusesTheRename(t *testing.T) {
f, _ := floorFixture(t, "v0.31.0", "v0.32.0")
f.duringStaging(func() {
if err := writeLocalFloor(f.a.dataDir, moves.Floor{Min: 1, MinRelease: "v0.33.0"}); err != nil {
t.Error(err)
}
})
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, "moves protocol floor: v0.32.0 is below v0.33.0") {
t.Fatalf("result = %+v", r)
}
if f.onDisk() != "v0.31.0" {
t.Fatalf("on disk %q", f.onDisk())
}
}
// First arming, between the rule and the rename: the guard holds floor.lock
// even on a box with no floor yet (it creates .moves for it), so the agent's
// first arming waits until the rename is done and cannot slip in between.
func TestFirstArmingWaitsForTheActivationLock(t *testing.T) {
t.Parallel()
f, _ := floorFixture(t, "v0.31.0", "v0.32.0")
armed := make(chan error, 1)
f.a.beforeSwap = func() {
go func() { armed <- writeLocalFloor(f.a.dataDir, moves.Floor{Min: 1, MinRelease: "v0.40.0"}) }()
time.Sleep(300 * time.Millisecond)
if _, err := os.Stat(filepath.Join(f.a.dataDir, moves.FloorFile)); err == nil {
t.Error("a first floor was armed between the activation check and the rename")
}
}
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied {
t.Fatalf("result = %+v", r)
}
if err := <-armed; err != nil {
t.Fatal(err)
}
if lf, _ := readLocalFloor(f.a.dataDir); lf == nil || lf.MinRelease != "v0.40.0" {
t.Fatalf("the agent's arming after the rename = %+v", lf)
}
}
// Forward recovery re-checks at the step it guards: a floor raised while
// recovery stages the gated build's plan stops it being finished forward.
func TestForwardRecoveryRechecksAfterStagePlan(t *testing.T) {
f, _ := floorFixture(t, "v0.30.0", "v0.31.0")
f.brokenBox("v0.31.0", "v0.30.0", "v0.30.0")
f.a.journal(systemJournal{Phase: systemPhaseGated, Target: "v0.31.0", PrevBuild: "v0.30.0", StartedAt: f.now})
armLocalFloor(t, f.a.dataDir, "v0.30.0")
bin := systemBinaryPath(f.a.optDir)
run := f.a.ops.run
f.a.ops.run = func(ctx context.Context, d time.Duration, name string, args ...string) (string, error) {
if name == bin && len(args) > 0 && args[0] == "bootstrap" {
if err := writeLocalFloor(f.a.dataDir, moves.Floor{Min: 1, MinRelease: "v0.32.0"}); err != nil {
t.Error(err)
}
}
return run(ctx, d, name, args...)
}
f.edgeStartedThisBoot = false
err := f.a.Run(context.Background(), true)
if ap, _ := readSystemApplied(f.a.optDir); ap != nil && ap.Version == "v0.31.0" {
t.Fatalf("finished forward below a floor raised during stagePlan: %+v", ap)
}
if err == nil || !strings.Contains(err.Error(), "moves protocol floor") {
t.Fatalf("err = %v", err)
}
if j, _ := readSystemJournal(f.a.optDir); j == nil {
t.Fatal("journal removed")
}
}
// A restore refused after the quiesce (the floor rose during the copy) puts
// the root passes' triggers back as they were, and keeps the journal.
func TestRefusedRestoreLeavesTheRootPassesAsTheyWere(t *testing.T) {
f, _ := floorFixture(t, "v0.32.0", "v0.33.0")
armLocalFloor(t, f.a.dataDir, "v0.30.0")
j := f.brokenBox("v0.32.0", "v0.31.0", "v0.31.0")
active := map[string]bool{"carlos-adopt.timer": true, "carlos-adopt.path": true, "carlos-stage.path": false, "carlos-restart.path": true}
before := copyActivity(active)
f.unitActivity(active)
systemctl := f.a.ops.systemctl
f.a.ops.systemctl = func(args ...string) (string, error) {
out, err := systemctl(args...)
if args[0] == "stop" {
armLocalFloor(t, f.a.dataDir, "v0.33.0")
}
return out, err
}
_, err := f.a.restoreFiles(context.Background(), &j)
if err == nil || !strings.Contains(err.Error(), "moves protocol floor: v0.31.0 is below v0.33.0") {
t.Fatalf("err = %v", err)
}
for u, was := range before {
if active[u] != was {
t.Errorf("%s active = %v after the refusal, was %v", u, active[u], was)
}
}
if f.onDisk() != "v0.32.0" {
t.Fatalf("on disk %q", f.onDisk())
}
if _, err := os.Stat(systemBinaryPath(f.a.optDir) + ".restore"); err == nil {
t.Fatal("the restore copy was left behind")
}
if j, _ := readSystemJournal(f.a.optDir); j == nil {
t.Fatal("the recovery journal was dropped")
}
}
// An install refused at the rename starts the paused triggers again first,
// and drops the journal only once they are started.
func TestRefusedInstallRearmsBeforeDroppingTheJournal(t *testing.T) {
f, _ := floorFixture(t, "v0.31.0", "v0.32.0")
putFloor(t, f.store, "v0.30.0")
active := map[string]bool{"carlos-adopt.timer": true, "carlos-adopt.path": true, "carlos-stage.path": true, "carlos-restart.path": false}
before := copyActivity(active)
f.unitActivity(active)
systemctl := f.a.ops.systemctl
f.a.ops.systemctl = func(args ...string) (string, error) {
if args[0] == "start" {
if j, _ := readSystemJournal(f.a.optDir); j == nil {
t.Error("the journal was dropped before the paused passes were started again")
}
}
return systemctl(args...)
}
f.duringStaging(func() { armLocalFloor(t, f.a.dataDir, "v0.33.0") })
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
if r := f.result(); r == nil || r.Outcome != systemOutcomeRefused {
t.Fatalf("result = %+v", r)
}
for u, was := range before {
if active[u] != was {
t.Errorf("%s active = %v, was %v", u, active[u], was)
}
}
if j, _ := readSystemJournal(f.a.optDir); j != nil {
t.Fatalf("journal left: %+v", j)
}
}
// When starting them again fails, the run says so and fails, the retry
// state stays on disk, and the next run (here the OnFailure recovery)
// starts them. The journal does not stay: its units would pick carlos.prev,
// and an older carlos.prev would restore itself over a binary that never
// changed.
func TestRefusedInstallWhoseRearmFailsSaysSoAndRetries(t *testing.T) {
f, _ := floorFixture(t, "v0.31.0", "v0.32.0")
putFloor(t, f.store, "v0.30.0")
active := map[string]bool{"carlos-adopt.timer": true, "carlos-adopt.path": true, "carlos-stage.path": true, "carlos-restart.path": true}
before := copyActivity(active)
f.unitActivity(active)
failStart := true
systemctl := f.a.ops.systemctl
f.a.ops.systemctl = func(args ...string) (string, error) {
if args[0] == "start" && failStart {
return "", errors.New("unit masked")
}
return systemctl(args...)
}
f.duringStaging(func() { armLocalFloor(t, f.a.dataDir, "v0.33.0") })
err := f.a.Run(context.Background(), false)
if err == nil || !strings.Contains(err.Error(), "could not be started again") {
t.Fatalf("Run err = %v", err)
}
r := f.result()
if r == nil || r.Outcome != systemOutcomeRefused || !strings.Contains(r.Reason, "could not be started again: unit masked") {
t.Fatalf("result = %+v", r)
}
if _, err := os.Stat(f.a.file(rearmPendingFile)); err != nil {
t.Fatalf("no retry state: %v", err)
}
if j, _ := readSystemJournal(f.a.optDir); j != nil {
t.Fatalf("journal kept: %+v", j)
}
if f.onDisk() != "v0.31.0" {
t.Fatalf("on disk %q", f.onDisk())
}
failStart = false
if err := f.a.Run(context.Background(), true); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(f.a.file(rearmPendingFile)); err == nil {
t.Fatal("retry state left after the passes started")
}
for u, was := range before {
if active[u] != was {
t.Errorf("%s active = %v, was %v", u, active[u], was)
}
}
if f.onDisk() != "v0.31.0" {
t.Fatalf("the retry changed the binary: %q", f.onDisk())
}
}
// F15's dormancy rule for the lock: on a box with no floor, an activation
// that cannot create .moves for its lock (a read-only data dir here) logs
// it and goes ahead as main's would.
func TestUnarmedBoxWithoutALockDirUpdatesAsMain(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root ignores the read-only mode")
}
f, _ := floorFixture(t, "v0.31.0", "v0.32.0")
if err := os.Chmod(f.a.dataDir, 0o555); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(f.a.dataDir, 0o755) })
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied || f.onDisk() != "v0.32.0" {
t.Fatalf("result %+v, on disk %q", r, f.onDisk())
}
}
// ---- fix round 3: one guard, both locks, no unlocked path on contention ----
// shortFloorLockWait makes a contended floor.lock time out quickly.
func shortFloorLockWait(t *testing.T) {
old := floorLockWait
floorLockWait = 200 * time.Millisecond
t.Cleanup(func() { floorLockWait = old })
}
// holdFloorLock takes floor.lock as a writer would, without publishing
// anything, until the returned func is called.
func holdFloorLock(t *testing.T, dataDir string) func() {
t.Helper()
held, free, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
go func() {
defer close(done)
if err := withFloorLock(dataDir, true, func(*movesDir) error {
close(held)
<-free
return nil
}); err != nil {
t.Error(err)
}
}()
<-held
return func() { close(free); <-done }
}
// A writer holding floor.lock has not necessarily published yet: a reader
// that times out on it must not go ahead unlocked. The apply is deferred,
// and nothing is activated.
func TestContendedFloorLockDefersTheInstall(t *testing.T) {
shortFloorLockWait(t)
f, _ := floorFixture(t, "v0.31.0", "v0.32.0")
unlock := holdFloorLock(t, f.a.dataDir)
defer unlock()
if err := f.a.Run(context.Background(), false); err != nil {
t.Fatal(err)
}
r := f.result()
if r == nil || r.Outcome != systemOutcomeDeferred || !strings.Contains(r.Reason, "another writer holds the floor lock") {
t.Fatalf("result = %+v", r)
}
if f.onDisk() != "v0.31.0" {
t.Fatalf("activated under a contended lock: on disk %q", f.onDisk())
}
if j, _ := readSystemJournal(f.a.optDir); j != nil {
t.Fatalf("journal left: %+v", j)
}
}
// With a floor armed, the guard freezes the holds even where the version
// being made current is the one already on disk (a journal finish).
func TestForwardFinishFreezesTheHolds(t *testing.T) {
f, _ := floorFixture(t, "v0.30.0", "v0.31.0")
f.brokenBox("v0.31.0", "v0.30.0", "v0.30.0")
f.a.journal(systemJournal{Phase: systemPhaseGated, Target: "v0.31.0", PrevBuild: "v0.30.0", StartedAt: f.now})
armLocalFloor(t, f.a.dataDir, "v0.30.0")
frozen := 0
open := f.a.openRegistry
f.a.openRegistry = func() (*registry.Registry, error) { frozen++; return open() }
f.edgeStartedThisBoot = false
if err := f.a.Run(context.Background(), true); err != nil {
t.Fatal(err)
}
if ap, _ := readSystemApplied(f.a.optDir); ap == nil || ap.Version != "v0.31.0" {
t.Fatalf("not finished forward: %+v", ap)
}
if frozen == 0 {
t.Fatal("the forward finish did not freeze the holds")
}
}
// The backward recovery's own finish (result and journal removal) is
// guarded too: a floor raised after the restore's rename, before the
// finish, keeps the journal.
func TestBackwardRecoveryFinishRechecksTheFloor(t *testing.T) {
f, _ := floorFixture(t, "v0.31.0", "v0.32.0")
armLocalFloor(t, f.a.dataDir, "v0.30.0")
f.brokenBox("v0.31.0", "v0.30.0", "v0.30.0")
systemctl := f.a.ops.systemctl
f.a.ops.systemctl = func(args ...string) (string, error) {
if args[0] == "restart" {
armLocalFloor(t, f.a.dataDir, "v0.31.0")
}
return systemctl(args...)
}
f.edgeStartedThisBoot = true
err := f.a.Run(context.Background(), true)
if err == nil || !strings.Contains(err.Error(), "moves protocol floor: v0.30.0 is below v0.31.0") {
t.Fatalf("err = %v", err)
}
if j, _ := readSystemJournal(f.a.optDir); j == nil {
t.Fatal("the journal was finished below a floor raised before the finish")
}
}
// A restore refused before its rename whose rearm then fails records the
// units it paused (as they were before the quiesce) in rearm-pending.json.
// The next recovery starts them first, and only then refuses again.
func TestRefusedRestoreWhoseRearmFailsIsRetriedFirst(t *testing.T) {
f, _ := floorFixture(t, "v0.32.0", "v0.33.0")
armLocalFloor(t, f.a.dataDir, "v0.30.0")
j := f.brokenBox("v0.32.0", "v0.31.0", "v0.31.0")
active := map[string]bool{"carlos-adopt.timer": true, "carlos-adopt.path": true, "carlos-stage.path": false, "carlos-restart.path": true}
before := copyActivity(active)
f.unitActivity(active)
failStart := true
systemctl := f.a.ops.systemctl
var order []string
f.a.ops.systemctl = func(args ...string) (string, error) {
if args[0] == "start" {
order = append(order, "start")
if failStart {
return "", errors.New("unit masked")
}
}
out, err := systemctl(args...)
if args[0] == "stop" && failStart {
armLocalFloor(t, f.a.dataDir, "v0.33.0")
}
return out, err
}
if _, err := f.a.restoreFiles(context.Background(), &j); err == nil || !strings.Contains(err.Error(), "could not be started again") {
t.Fatalf("err = %v", err)
}
var p rearmPending
if err := readJSONFile(f.a.file(rearmPendingFile), &p); err != nil {
t.Fatalf("no retry state: %v", err)
}
if want := []string{"carlos-adopt.timer", "carlos-adopt.path", "carlos-restart.path"}; !slices.Equal(p.Units, want) {
t.Fatalf("pending units = %v, want %v (as they were before the quiesce)", p.Units, want)
}
failStart = false
f.edgeStartedThisBoot = false
err := f.a.Run(context.Background(), true)
if err == nil || !strings.Contains(err.Error(), "moves protocol floor") {
t.Fatalf("recovery err = %v", err)
}
if len(order) < 2 || order[len(order)-1] != "start" {
t.Fatalf("the recovery did not start the pending passes: %v", order)
}
if _, err := os.Stat(f.a.file(rearmPendingFile)); err == nil {
t.Fatal("retry state left after the passes started")
}
for u, was := range before {
if active[u] != was {
t.Errorf("%s active = %v, was %v", u, active[u], was)
}
}
if j, _ := readSystemJournal(f.a.optDir); j == nil {
t.Fatal("journal dropped")
}
}