carlos / platform Public

host: real unit ops for the config pass (batched show, daemon-reload, restart, flock)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev e8fab7000d17ae7eb9cd9545a647a7564b95faa7 parent 4ce1b88
3 files changed, +177 −8
  • internal/host/agent.go +7 −8
  • internal/host/unitops.go +122 −0
  • internal/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)
+ }
+}