host: the hourly adopt pass arms the self-update units if the roll script did not
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
3 files changed,
+107
−0
internal/host/agent.go+5 −0internal/host/systemunits.go+47 −0internal/host/systemunits_test.go+55 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 4836f8f05..b53148eb6 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -2856,6 +2856,11 @@ func RunAdopt(args []string) { |
| restartOnly := fs.Bool("restart-only", false, "cycle the routes of apps whose restart generation moved, and nothing else: the agent-requested pass (carlos-restart.service)") |
| fs.Usage = cliflag.Usage(fs, "") |
| _ = fs.Parse(args) |
| + // 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) |
| + } |
| // openServiceRegistry, not registry.Open: this pass runs as root |
| // (carlos-adopt/-stage/-restart/-config-sync have no User=), opening the |
| // registry is what creates its -wal/-shm, and the helper hands them back |
diff --git a/internal/host/systemunits.go b/internal/host/systemunits.go| new file mode 100644 |
| index 000000000..237e9bcb3 |
| --- /dev/null |
| +++ b/internal/host/systemunits.go |
| @@ -0,0 +1,47 @@ |
| +package host |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "strings" |
| +) |
| + |
| +var systemUnitFiles = []string{"carlos-system-update.path", "carlos-system-update.service", "carlos-system-recover.service"} |
| + |
| +// 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)) { |
| + unitDir := filepath.Join(etcDir, "systemd", "system") |
| + 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 |
| + } |
| + for _, u := range systemUnitFiles { |
| + b, err := unitsFS.ReadFile("units/" + u) |
| + if err != nil { |
| + logf("adopt: system units: %v", err) |
| + return |
| + } |
| + if err := writeFileDurable(filepath.Join(unitDir, u), b, 0o644); err != nil { |
| + logf("adopt: system units: %v", err) |
| + return |
| + } |
| + } |
| + for _, args := range systemctlPlan([]string{systemUpdatePathUnit, "carlos-system-recover.service"}) { |
| + if _, err := systemctl(args...); err != nil { |
| + logf("adopt: system units: systemctl %s: %v", strings.Join(args, " "), err) |
| + return |
| + } |
| + } |
| + logf("adopt: self-update units installed and armed") |
| +} |
diff --git a/internal/host/systemunits_test.go b/internal/host/systemunits_test.go| new file mode 100644 |
| index 000000000..96f1408b3 |
| --- /dev/null |
| +++ b/internal/host/systemunits_test.go |
| @@ -0,0 +1,55 @@ |
| +package host |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestEnsureSystemUnitsInstallsAndArmsWhatIsMissing(t *testing.T) { |
| + etc := t.TempDir() |
| + os.MkdirAll(filepath.Join(etc, "systemd", "system"), 0o755) |
| + var calls []string |
| + systemctl := func(args ...string) (string, error) { |
| + calls = append(calls, strings.Join(args, " ")) |
| + switch args[0] { |
| + case "is-active": |
| + return "inactive\n", nil |
| + case "is-enabled": |
| + return "disabled\n", nil |
| + } |
| + return "", nil |
| + } |
| + ensureSystemUnits(etc, 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) |
| + } |
| + } |
| + joined := strings.Join(calls, "\n") |
| + for _, want := range []string{"daemon-reload", "enable carlos-system-update.path carlos-system-recover.service", "start carlos-system-update.path"} { |
| + if !strings.Contains(joined, want) { |
| + t.Errorf("calls lack %q:\n%s", want, joined) |
| + } |
| + } |
| + if strings.Contains(joined, "start carlos-system-recover.service") || strings.Contains(joined, "start carlos-system-update.service") { |
| + t.Fatal("a service was started") |
| + } |
| + // All present and armed: no writes, no systemctl beyond the two checks. |
| + calls = nil |
| + systemctl2 := func(args ...string) (string, error) { |
| + calls = append(calls, strings.Join(args, " ")) |
| + switch args[0] { |
| + case "is-active": |
| + return "active\n", nil |
| + case "is-enabled": |
| + return "enabled\n", nil |
| + } |
| + return "", nil |
| + } |
| + ensureSystemUnits(etc, systemctl2, t.Logf) |
| + if len(calls) != 2 { |
| + t.Fatalf("an armed box got %v", calls) |
| + } |
| +} |