carlos / platform Public

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
Paul Campbell pushed by paul@keymail.dev 3f38513750242c69b257b015efdd24fbf9c03612 parent 2275a5b
3 files changed, +107 −0
  • internal/host/agent.go +5 −0
  • internal/host/systemunits.go +47 −0
  • internal/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)
+ }
+}