host: the hourly belt takes the apply lock and trusts no broken systemctl
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
3 files changed,
+98
−8
internal/host/agent.go+1 −1internal/host/systemunits.go+49 −5internal/host/systemunits_test.go+48 −2
diff --git a/internal/host/agent.go b/internal/host/agent.go| index b53148eb6..b05370e64 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -2859,7 +2859,7 @@ func RunAdopt(args []string) { |
| // Spec §7 belt 2: arm the self-update relay if the last hand roll did |
| // not. Before the fatal gates below, and never fatal itself. |
| if !*restartOnly { |
| - ensureSystemUnits("/etc", productionSysOps().systemctl, log.Printf) |
| + ensureSystemUnits("/etc", filepath.Join(systemRootDir(release.OptDir()), systemLockFile), productionSysOps().systemctl, log.Printf) |
| } |
| // openServiceRegistry, not registry.Open: this pass runs as root |
| // (carlos-adopt/-stage/-restart/-config-sync have no User=), opening the |
diff --git a/internal/host/systemunits.go b/internal/host/systemunits.go| index 237e9bcb3..0010e6273 100644 |
| --- a/internal/host/systemunits.go |
| +++ b/internal/host/systemunits.go |
| @@ -6,26 +6,70 @@ import ( |
| "strings" |
| ) |
| -var systemUnitFiles = []string{"carlos-system-update.path", "carlos-system-update.service", "carlos-system-recover.service"} |
| +// systemRecoverUnit is carlos-system-recover.service, spelled out once |
| +// rather than repeated as a string literal at each call site below. |
| +const systemRecoverUnit = "carlos-system-recover.service" |
| + |
| +var systemUnitFiles = []string{"carlos-system-update.path", "carlos-system-update.service", systemRecoverUnit} |
| // ensureSystemUnits is the second belt of spec §7: a box the roll script |
| // forgot to arm is armed within the hour by the root adopt pass. It |
| // installs the embedded units when missing and arms them through |
| // systemctlPlan (never starting a service). Non-fatal: it runs before |
| // RunAdopt's registry and pubkey gates, and logs instead of exiting. |
| -func ensureSystemUnits(etcDir string, systemctl func(args ...string) (string, error), logf func(string, ...any)) { |
| +// |
| +// It writes into the same <etc>/systemd/system unit files that `carlos |
| +// system apply`'s installUnits copies, using the same durable-write |
| +// staging name — so it takes the apply's own lock, non-blocking, before |
| +// touching anything. An apply in flight wins; this pass leaves it alone |
| +// rather than risk tearing a unit file mid-copy. lockPath is the apply's |
| +// lock file (systemRootDir(release.OptDir())/lock in production; a temp |
| +// path in tests). |
| +func ensureSystemUnits(etcDir, lockPath string, systemctl func(args ...string) (string, error), logf func(string, ...any)) { |
| + if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { |
| + logf("adopt: system units: %v", err) |
| + return |
| + } |
| + lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) |
| + if err != nil { |
| + logf("adopt: system units: %v", err) |
| + return |
| + } |
| + defer lock.Close() |
| + // Any error here — real contention, or errSystemUnsupported on the |
| + // windows build that has no flock — means don't touch the units: an |
| + // apply may be mid-copy, or this platform can't tell, and either way |
| + // leaving it alone is the safe default. The lock's cadence takes care |
| + // of trying again next tick. |
| + if err := flockExclusiveNonblock(lock); err != nil { |
| + logf("adopt: system units: an apply is running; leaving the self-update units to it") |
| + return |
| + } |
| + |
| unitDir := filepath.Join(etcDir, "systemd", "system") |
| + active, activeErr := systemctl("is-active", systemUpdatePathUnit) |
| + enabled, enabledErr := systemctl("is-enabled", systemRecoverUnit) |
| + if activeErr != nil || enabledErr != nil { |
| + probeErr := activeErr |
| + if probeErr == nil { |
| + probeErr = enabledErr |
| + } |
| + logf("adopt: system units: systemctl unavailable; not touching the self-update units: %v", probeErr) |
| + return |
| + } |
| missing := false |
| for _, u := range systemUnitFiles { |
| if _, err := os.Stat(filepath.Join(unitDir, u)); err != nil { |
| missing = true |
| } |
| } |
| - active, _ := systemctl("is-active", systemUpdatePathUnit) |
| - enabled, _ := systemctl("is-enabled", "carlos-system-recover.service") |
| if !missing && strings.TrimSpace(active) == "active" && strings.TrimSpace(enabled) == "enabled" { |
| return |
| } |
| + if err := os.MkdirAll(unitDir, 0o755); err != nil { |
| + logf("adopt: system units: %v", err) |
| + return |
| + } |
| for _, u := range systemUnitFiles { |
| b, err := unitsFS.ReadFile("units/" + u) |
| if err != nil { |
| @@ -37,7 +81,7 @@ func ensureSystemUnits(etcDir string, systemctl func(args ...string) (string, er |
| return |
| } |
| } |
| - for _, args := range systemctlPlan([]string{systemUpdatePathUnit, "carlos-system-recover.service"}) { |
| + for _, args := range systemctlPlan([]string{systemUpdatePathUnit, systemRecoverUnit}) { |
| if _, err := systemctl(args...); err != nil { |
| logf("adopt: system units: systemctl %s: %v", strings.Join(args, " "), err) |
| return |
diff --git a/internal/host/systemunits_test.go b/internal/host/systemunits_test.go| index 96f1408b3..5fe204f8e 100644 |
| --- a/internal/host/systemunits_test.go |
| +++ b/internal/host/systemunits_test.go |
| @@ -1,6 +1,7 @@ |
| package host |
| import ( |
| + "errors" |
| "os" |
| "path/filepath" |
| "strings" |
| @@ -10,6 +11,7 @@ import ( |
| func TestEnsureSystemUnitsInstallsAndArmsWhatIsMissing(t *testing.T) { |
| etc := t.TempDir() |
| os.MkdirAll(filepath.Join(etc, "systemd", "system"), 0o755) |
| + lockPath := filepath.Join(t.TempDir(), "lock") |
| var calls []string |
| systemctl := func(args ...string) (string, error) { |
| calls = append(calls, strings.Join(args, " ")) |
| @@ -21,7 +23,7 @@ func TestEnsureSystemUnitsInstallsAndArmsWhatIsMissing(t *testing.T) { |
| } |
| return "", nil |
| } |
| - ensureSystemUnits(etc, systemctl, t.Logf) |
| + ensureSystemUnits(etc, lockPath, systemctl, t.Logf) |
| for _, u := range []string{"carlos-system-update.path", "carlos-system-update.service", "carlos-system-recover.service"} { |
| if _, err := os.Stat(filepath.Join(etc, "systemd", "system", u)); err != nil { |
| t.Errorf("%s not installed", u) |
| @@ -48,8 +50,52 @@ func TestEnsureSystemUnitsInstallsAndArmsWhatIsMissing(t *testing.T) { |
| } |
| return "", nil |
| } |
| - ensureSystemUnits(etc, systemctl2, t.Logf) |
| + ensureSystemUnits(etc, lockPath, systemctl2, t.Logf) |
| if len(calls) != 2 { |
| t.Fatalf("an armed box got %v", calls) |
| } |
| } |
| + |
| +func TestEnsureSystemUnitsLeavesAConcurrentApplyAlone(t *testing.T) { |
| + etc := t.TempDir() |
| + os.MkdirAll(filepath.Join(etc, "systemd", "system"), 0o755) |
| + lockPath := filepath.Join(t.TempDir(), "lock") |
| + // Hold the lock as `carlos system apply` would. |
| + held, err := os.OpenFile(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 calls []string |
| + systemctl := func(args ...string) (string, error) { |
| + calls = append(calls, strings.Join(args, " ")) |
| + return "", nil |
| + } |
| + ensureSystemUnits(etc, lockPath, systemctl, t.Logf) |
| + if len(calls) != 0 { |
| + t.Fatalf("a locked-out pass called systemctl: %v", calls) |
| + } |
| + for _, u := range []string{"carlos-system-update.path", "carlos-system-update.service", "carlos-system-recover.service"} { |
| + if _, err := os.Stat(filepath.Join(etc, "systemd", "system", u)); err == nil { |
| + t.Errorf("%s was installed by a locked-out pass", u) |
| + } |
| + } |
| +} |
| + |
| +func TestEnsureSystemUnitsTrustsNoBrokenSystemctl(t *testing.T) { |
| + etc := t.TempDir() |
| + os.MkdirAll(filepath.Join(etc, "systemd", "system"), 0o755) |
| + lockPath := filepath.Join(t.TempDir(), "lock") |
| + systemctl := func(args ...string) (string, error) { |
| + return "", errors.New("systemctl: command not found") |
| + } |
| + ensureSystemUnits(etc, lockPath, systemctl, t.Logf) |
| + for _, u := range []string{"carlos-system-update.path", "carlos-system-update.service", "carlos-system-recover.service"} { |
| + if _, err := os.Stat(filepath.Join(etc, "systemd", "system", u)); err == nil { |
| + t.Errorf("%s was installed despite systemctl being unavailable", u) |
| + } |
| + } |
| +} |