host: real unit ops for the config pass (batched show, daemon-reload, restart, flock)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3 files changed,
+177
−8
internal/host/agent.go+7 −8internal/host/unitops.go+122 −0internal/host/unitops_test.go+48 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 14d1385a2..611bf39f7 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -2900,13 +2900,12 @@ func RunAdopt(args []string) { |
| // cycle. See rootRestartRoute for why the named-unit arm has to exist on |
| // THIS pass. |
| // |
| - // Only the RELEASE pass can actually reach that arm. appconfig.Sync skips |
| - // every route whose Backing is not BackingUnit before it restarts anything |
| - // (an exec-backed route's config is delivered by the agent's in-process |
| - // loop instead), and a unit-backed route is exactly what `carlos route |
| - // -unit` refuses to stamp — so config sync sees no unit-named route by |
| - // construction. It is handed the same callback anyway because there should |
| - // be ONE restart dispatch on this pass, not two that could drift. |
| + // The release pass reaches that arm for stamped routes. Config sync |
| + // does NOT: appconfig.SyncUnits restarts a stamped route's unit itself, |
| + // bound to the unit it prepared rather than re-read from the row (spec |
| + // 2026-09-07-named-unit-config-delivery-design.md §3.2), and uses this |
| + // callback only for carlos-app@ routes. Both still dispatch through |
| + // makeRestartRoute's arms for what they hand it. |
| restart := rootRestartRoute(reg) |
| // The restart-request half, and the ONE place it dispatches from — |
| // including for exec-backed routes, whose children belong to the agent: |
| @@ -2947,7 +2946,7 @@ func RunAdopt(args []string) { |
| secretsPriv, err := appconfig.ParsePrivateKey(raw, "CARLOS_SECRETS_KEY") |
| if err != nil { |
| log.Printf("adopt: config sync disabled: %v", err) |
| - } else if err := appconfig.Sync(ctx, reg, store, secretsPriv, appconfig.DefaultEnvDir, restart); err != nil { |
| + } else if err := appconfig.SyncUnits(ctx, reg, store, secretsPriv, appconfig.DefaultEnvDir, restart, productionUnitOps(appconfig.DefaultEnvDir)); err != nil { |
| log.Printf("adopt: config sync: %v", err) |
| } |
| } |
diff --git a/internal/host/unitops.go b/internal/host/unitops.go| new file mode 100644 |
| index 000000000..4413de7c8 |
| --- /dev/null |
| +++ b/internal/host/unitops.go |
| @@ -0,0 +1,122 @@ |
| +// The root pass's real UnitOps (spec §3.2): systemctl for the probe, the |
| +// reload and the restart; a flock for the pass. Kept in one file, beside |
| +// the batched-probe shape adopt_advice.go already uses, so what the pass |
| +// runs on a box is readable in one place. |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "fmt" |
| + "log" |
| + "os" |
| + "os/exec" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| +) |
| + |
| +// dropInRoot is where systemd looks for a unit's drop-ins whichever tree |
| +// the unit file itself lives in. |
| +const dropInRoot = "/etc/systemd/system" |
| + |
| +func productionUnitOps(envDir string) *appconfig.UnitOps { |
| + return &appconfig.UnitOps{ |
| + DropInRoot: dropInRoot, |
| + Lock: func() (func(), error) { return lockConfigSync(envDir + "/.sync") }, |
| + Probe: probeUnits, |
| + Reload: daemonReload, |
| + Restart: systemctlRestartUnit, |
| + } |
| +} |
| + |
| +// lockConfigSync is lockRestartMarks's shape for the config pass: an |
| +// exclusive flock on <path>.lock held for the whole of one SyncUnits, so |
| +// carlos-adopt.service and carlos-config-sync.service queue rather than |
| +// interleave inside a config transaction (spec §3.2, codex R2-3). |
| +func lockConfigSync(path string) (unlock func(), err error) { |
| + f, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0o644) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if err := lockFileEx(f); err != nil { |
| + f.Close() |
| + return nil, err |
| + } |
| + return func() { unlockFile(f); f.Close() }, nil |
| +} |
| + |
| +// probeUnits: ONE `systemctl show` for every unit, positional like |
| +// unitsAreActive. The three properties are the whole rail: LoadState says |
| +// the unit exists, Id says whether the stamp is its canonical name, and |
| +// EnvironmentFiles is the effective list the drift check and the |
| +// post-reload verification compare against. |
| +func probeUnits(units []string) (map[string]appconfig.UnitState, error) { |
| + args := append([]string{"show", "-p", "Id,LoadState,EnvironmentFiles"}, units...) |
| + out, err := exec.Command("systemctl", args...).Output() |
| + if err != nil && len(out) == 0 { |
| + return nil, fmt.Errorf("systemctl show: %w", err) |
| + } |
| + return parseSystemctlShow(out, units) |
| +} |
| + |
| +// parseSystemctlShow splits `systemctl show` output into per-unit blocks |
| +// (blank-line separated, in the order asked) and maps them onto units |
| +// positionally. A block count that does not match cannot be attributed and |
| +// is an error, never a guess (the same rule as parseIsActiveBatch). |
| +// EnvironmentFiles may repeat one path per line or list several on one |
| +// line; each entry is "<path> (ignore_errors=yes|no)" and only the path is |
| +// kept. |
| +func parseSystemctlShow(out []byte, units []string) (map[string]appconfig.UnitState, error) { |
| + blocks := bytes.Split(bytes.TrimSpace(out), []byte("\n\n")) |
| + if len(bytes.TrimSpace(out)) == 0 { |
| + blocks = nil |
| + } |
| + if len(blocks) != len(units) { |
| + return nil, fmt.Errorf("systemctl show answered %d unit(s) for %d asked", len(blocks), len(units)) |
| + } |
| + res := make(map[string]appconfig.UnitState, len(units)) |
| + for i, unit := range units { |
| + var st appconfig.UnitState |
| + for _, line := range strings.Split(string(blocks[i]), "\n") { |
| + k, v, ok := strings.Cut(line, "=") |
| + if !ok { |
| + continue |
| + } |
| + switch k { |
| + case "Id": |
| + st.ID = v |
| + case "LoadState": |
| + st.LoadState = v |
| + case "EnvironmentFiles": |
| + for _, f := range strings.Fields(v) { |
| + if strings.HasPrefix(f, "(") { |
| + continue |
| + } |
| + st.EnvFiles = append(st.EnvFiles, f) |
| + } |
| + } |
| + } |
| + res[unit] = st |
| + } |
| + return res, nil |
| +} |
| + |
| +func daemonReload() error { |
| + out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput() |
| + if err != nil { |
| + return fmt.Errorf("daemon-reload: %w: %s", err, strings.TrimSpace(string(out))) |
| + } |
| + return nil |
| +} |
| + |
| +// systemctlRestartUnit is `systemctl restart`, NOT try-restart: a marked |
| +// unit owes a restart even when a previous one left it inactive (spec |
| +// §3.2 step 4). Logs and returns like systemctlTryRestartUnit. |
| +func systemctlRestartUnit(unit string) error { |
| + out, err := exec.Command("systemctl", "restart", unit).CombinedOutput() |
| + if err != nil { |
| + log.Printf("adopt: restart %s: %v: %s", unit, err, strings.TrimSpace(string(out))) |
| + return fmt.Errorf("restart %s: %w: %s", unit, err, strings.TrimSpace(string(out))) |
| + } |
| + return nil |
| +} |
diff --git a/internal/host/unitops_test.go b/internal/host/unitops_test.go| new file mode 100644 |
| index 000000000..982dbc1f0 |
| --- /dev/null |
| +++ b/internal/host/unitops_test.go |
| @@ -0,0 +1,48 @@ |
| +package host |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestParseSystemctlShowBatched(t *testing.T) { |
| + out := []byte("Id=relay.service\nLoadState=loaded\nEnvironmentFiles=/etc/carlos/apps/relay.example.com.env (ignore_errors=yes)\n" + |
| + "\n" + |
| + "Id=real.service\nLoadState=loaded\nEnvironmentFiles=/data/x.env (ignore_errors=no)\nEnvironmentFiles=/etc/carlos/apps/h.env (ignore_errors=yes)\n" + |
| + "\n" + |
| + "Id=gone.service\nLoadState=not-found\nEnvironmentFiles=\n") |
| + got, err := parseSystemctlShow(out, []string{"relay.service", "alias.service", "gone.service"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if st := got["relay.service"]; st.ID != "relay.service" || st.LoadState != "loaded" || |
| + strings.Join(st.EnvFiles, ",") != "/etc/carlos/apps/relay.example.com.env" { |
| + t.Fatalf("relay: %+v", st) |
| + } |
| + // The second block was asked for as alias.service and answered as |
| + // real.service: keyed by the name asked, Id carries the canonical one. |
| + if st := got["alias.service"]; st.ID != "real.service" || strings.Join(st.EnvFiles, ",") != "/data/x.env,/etc/carlos/apps/h.env" { |
| + t.Fatalf("alias: %+v", st) |
| + } |
| + if st := got["gone.service"]; st.LoadState != "not-found" || len(st.EnvFiles) != 0 { |
| + t.Fatalf("gone: %+v", st) |
| + } |
| +} |
| + |
| +func TestParseSystemctlShowCountMismatchIsAnError(t *testing.T) { |
| + if _, err := parseSystemctlShow([]byte("Id=a.service\nLoadState=loaded\n"), []string{"a.service", "b.service"}); err == nil { |
| + t.Fatal("a short batch cannot be attributed positionally and must be an error") |
| + } |
| +} |
| + |
| +func TestParseSystemctlShowSpaceSeparatedEnvFiles(t *testing.T) { |
| + // Some systemd versions print the list on one line. |
| + out := []byte("Id=a.service\nLoadState=loaded\nEnvironmentFiles=/x.env (ignore_errors=no) /y.env (ignore_errors=yes)\n") |
| + got, err := parseSystemctlShow(out, []string{"a.service"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if strings.Join(got["a.service"].EnvFiles, ",") != "/x.env,/y.env" { |
| + t.Fatalf("EnvFiles = %v", got["a.service"].EnvFiles) |
| + } |
| +} |