carlos / platform Public

appconfig: reload, verify exactly one env file, restart marked units, report drift

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 04051c4c581907e0775a1c4af42b1d20103e8959 parent 382e386
3 files changed, +241 −1
  • internal/appconfig/sync.go +3 −1
  • internal/appconfig/unitsync.go +89 −0
  • internal/appconfig/unitsync_test.go +149 −0
diff --git a/internal/appconfig/sync.go b/internal/appconfig/sync.go
index 66e10e488..4c2dd609b 100644
--- a/internal/appconfig/sync.go
+++ b/internal/appconfig/sync.go
@@ -202,7 +202,9 @@ func SyncUnits(ctx context.Context, reg *registry.Registry, s release.Store, pri
}
syncHost(rt, m, r.defaultMissing, priv, envDir, restart)
}
- _ = work
+ if ops != nil {
+ applyStamped(envDir, ops, plan, work)
+ }
// An empty route list is never authority to delete anything:
// registry.Open silently creates an empty database when handed a wrong
// path or an unmounted /data, and reaping against that would wipe every
diff --git a/internal/appconfig/unitsync.go b/internal/appconfig/unitsync.go
index e2206ad86..5572e727b 100644
--- a/internal/appconfig/unitsync.go
+++ b/internal/appconfig/unitsync.go
@@ -10,6 +10,7 @@ import (
"bytes"
"crypto/ecdh"
"fmt"
+ "path/filepath"
"sort"
"github.com/carlosframework/platform/internal/registry"
@@ -183,3 +184,91 @@ func prepareStamped(rt registry.Route, unit string, m *Manifest, defaultMissing
w.ok = true
return w
}
+
+// envFilesMatch: the unit's effective EnvironmentFile list must be EXACTLY
+// carlos's one file (spec §3.2 step 4, codex R3-3). Presence or last
+// position is not enough: a later drop-in that resets the list and re-adds
+// a legacy file before carlos's would resurrect an unset key.
+func envFilesMatch(got []string, want string) bool {
+ return len(got) == 1 && got[0] == want
+}
+
+// applyStamped is spec §3.2 steps 3 to 7: one reload if anything is
+// pending, then per marked unit verify and restart, then drift for the
+// rest. Order matters: reload before any verification, verification
+// before any restart, and a unit that failed or was refused this pass is
+// never restarted however old its marker.
+func applyStamped(envDir string, ops *UnitOps, plan stampedPlan, work map[string]unitWork) {
+ pending := listPending(ops.DropInRoot)
+ if len(pending) > 0 {
+ if err := ops.Reload(); err != nil {
+ for _, unit := range pending {
+ fmt.Printf(" %s: daemon-reload failed (%v); reload and restart still owed\n", unit, err)
+ }
+ return
+ }
+ }
+ marked := map[string]bool{}
+ for _, unit := range pending {
+ marked[unit] = true
+ w, prepared := work[unit]
+ _, exists, isMarked, _ := readDropIn(ops.DropInRoot, unit)
+ switch {
+ case prepared && !w.ok:
+ // Prepared this pass and something failed (env write, drop-in
+ // write): the marker is intent that has not been met. Keep it
+ // whether or not a drop-in exists yet (codex R3-1).
+ fmt.Printf(" %s: restart still owed, but delivery to %s failed this pass; retrying next pass\n", unit, w.host)
+ continue
+ case !exists || !isMarked:
+ // Reaped (or never ours): the reload it owed has happened; it
+ // is not owned and is never restarted from here.
+ if err := removePending(ops.DropInRoot, unit); err != nil {
+ fmt.Printf(" %s: clear pending marker: %v\n", unit, err)
+ }
+ continue
+ case !prepared:
+ fmt.Printf(" %s: restart still owed, but its route was refused this pass; retrying next pass\n", unit)
+ continue
+ }
+ states, err := ops.Probe([]string{unit})
+ if err != nil {
+ fmt.Printf(" %s: post-reload probe failed (%v); restart still owed\n", unit, err)
+ continue
+ }
+ want := filepath.Join(envDir, w.host+".env")
+ if !envFilesMatch(states[unit].EnvFiles, want) {
+ fmt.Printf(" %s: delivery blocked: %s reads %v after reload, want exactly [%s]; a later drop-in owns the EnvironmentFile list\n",
+ w.host, unit, states[unit].EnvFiles, want)
+ continue
+ }
+ if err := ops.Restart(unit); err != nil {
+ fmt.Printf(" %s: config updated but restart of %s failed: %v; restart still owed\n", w.host, unit, err)
+ continue
+ }
+ if err := removePending(ops.DropInRoot, unit); err != nil {
+ fmt.Printf(" %s: clear pending marker for %s: %v\n", w.host, unit, err)
+ continue
+ }
+ fmt.Printf(" %s: config updated, %s restarted\n", w.host, unit)
+ }
+ // Drift (step 7): an unmarked, delivered unit whose pre-mutation probe
+ // shows anything but carlos's file is running what the operator
+ // configured. Say so every pass; never fight it.
+ units := make([]string, 0, len(work))
+ for unit := range work {
+ units = append(units, unit)
+ }
+ sort.Strings(units)
+ for _, unit := range units {
+ w := work[unit]
+ if marked[unit] || !w.ok {
+ continue
+ }
+ want := filepath.Join(envDir, w.host+".env")
+ if st, ok := plan.probe[unit]; ok && !envFilesMatch(st.EnvFiles, want) {
+ fmt.Printf(" %s: delivery blocked: %s reads %v, want exactly [%s]; a later drop-in owns the EnvironmentFile list\n",
+ w.host, unit, st.EnvFiles, want)
+ }
+ }
+}
diff --git a/internal/appconfig/unitsync_test.go b/internal/appconfig/unitsync_test.go
index acfce9508..dd6634b00 100644
--- a/internal/appconfig/unitsync_test.go
+++ b/internal/appconfig/unitsync_test.go
@@ -296,3 +296,152 @@ func TestSyncUnitsCarlosAppRoutesUnchanged(t *testing.T) {
t.Fatal("a carlos-app@ route must not touch the unit ops")
}
}
+
+// syncOnce runs one pass and returns its output.
+func syncOnce(t *testing.T, ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, envDir string, f *fakeUnits) string {
+ t.Helper()
+ return captureStdout(t, func() {
+ if err := SyncUnits(ctx, reg, s, priv, envDir, noHostRestart(t), f.ops()); err != nil {
+ t.Fatalf("SyncUnits: %v", err)
+ }
+ })
+}
+
+func TestSyncUnitsReloadsVerifiesRestartsAndClears(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ f.loaded("relay.service", envDir, "relay.example.com")
+ upsertStamped(t, reg, "relay.example.com", "relay.service", nil)
+
+ syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if f.reloads != 1 {
+ t.Fatalf("reloads = %d, want 1", f.reloads)
+ }
+ if len(f.restarted) != 1 || f.restarted[0] != "relay.service" {
+ t.Fatalf("restarted = %v, want [relay.service]", f.restarted)
+ }
+ if hasPending(f.root, "relay.service") {
+ t.Fatal("marker must be cleared after a successful restart")
+ }
+ // Post-reload verification probes the marked unit again.
+ if len(f.probed) != 2 {
+ t.Fatalf("probed = %v, want the rail probe and the post-reload probe", f.probed)
+ }
+
+ // Steady state: nothing happens.
+ f.reloads, f.restarted = 0, nil
+ syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if f.reloads != 0 || len(f.restarted) != 0 {
+ t.Fatalf("second pass: reloads=%d restarted=%v, want none", f.reloads, f.restarted)
+ }
+
+ // A config change marks and restarts again.
+ if err := SetEnv(ctx, s, "bab", "relay", map[string]string{"LOG_LEVEL": "info"}); err != nil {
+ t.Fatal(err)
+ }
+ syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if f.reloads != 1 || len(f.restarted) != 1 {
+ t.Fatalf("after change: reloads=%d restarted=%v", f.reloads, f.restarted)
+ }
+}
+
+func TestSyncUnitsReloadFailureKeepsMarkerAndRetries(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ f.loaded("relay.service", envDir, "relay.example.com")
+ upsertStamped(t, reg, "relay.example.com", "relay.service", nil)
+ f.reloadErr = errors.New("busy")
+ syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if len(f.restarted) != 0 || !hasPending(f.root, "relay.service") {
+ t.Fatalf("reload failure: restarted=%v pending=%v", f.restarted, hasPending(f.root, "relay.service"))
+ }
+ f.reloadErr = nil
+ syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if len(f.restarted) != 1 || hasPending(f.root, "relay.service") {
+ t.Fatalf("retry with identical bytes: restarted=%v pending=%v", f.restarted, hasPending(f.root, "relay.service"))
+ }
+}
+
+func TestSyncUnitsRestartFailureKeepsMarkerAndRetries(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ f.loaded("relay.service", envDir, "relay.example.com")
+ upsertStamped(t, reg, "relay.example.com", "relay.service", nil)
+ f.restartErr["relay.service"] = errors.New("exit 1")
+ syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if !hasPending(f.root, "relay.service") {
+ t.Fatal("restart failure must keep the marker")
+ }
+ delete(f.restartErr, "relay.service")
+ f.restarted = nil
+ syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if len(f.restarted) != 1 || hasPending(f.root, "relay.service") {
+ t.Fatalf("retry: restarted=%v pending=%v", f.restarted, hasPending(f.root, "relay.service"))
+ }
+}
+
+func TestSyncUnitsVerificationMismatchBlocks(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ // Loaded, but the effective list has a legacy file first: a later
+ // drop-in re-added it. "Ends with carlos's file" is not enough.
+ f.states["relay.service"] = UnitState{ID: "relay.service", LoadState: "loaded",
+ EnvFiles: []string{"/data/relay.example.com.config.env", filepath.Join(envDir, "relay.example.com.env")}}
+ upsertStamped(t, reg, "relay.example.com", "relay.service", nil)
+ out := syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if len(f.restarted) != 0 || !hasPending(f.root, "relay.service") {
+ t.Fatalf("mismatch: restarted=%v pending=%v", f.restarted, hasPending(f.root, "relay.service"))
+ }
+ if !strings.Contains(out, "blocked") || !strings.Contains(out, "/data/relay.example.com.config.env") {
+ t.Fatalf("expected a blocked line naming the actual list, got %q", out)
+ }
+}
+
+func TestSyncUnitsDriftIsReportedEveryPassAndNeverRestarted(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ f.loaded("relay.service", envDir, "relay.example.com")
+ upsertStamped(t, reg, "relay.example.com", "relay.service", nil)
+ syncOnce(t, ctx, reg, s, priv, envDir, f) // delivered, cleared
+ f.restarted, f.reloads = nil, 0
+ // An operator's later drop-in took the list back.
+ f.states["relay.service"] = UnitState{ID: "relay.service", LoadState: "loaded", EnvFiles: []string{"/etc/other.env"}}
+ out := syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if len(f.restarted) != 0 || f.reloads != 0 {
+ t.Fatalf("drift must not restart or reload: restarted=%v reloads=%d", f.restarted, f.reloads)
+ }
+ if !strings.Contains(out, "blocked") || !strings.Contains(out, "/etc/other.env") {
+ t.Fatalf("expected a drift line naming the actual list, got %q", out)
+ }
+ if hasPending(f.root, "relay.service") {
+ t.Fatal("drift writes no marker")
+ }
+}
+
+func TestSyncUnitsRefusedRouteKeepsAnEarlierMarker(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ f.loaded("relay.service", envDir, "relay.example.com")
+ upsertStamped(t, reg, "relay.example.com", "relay.service", nil)
+ f.reloadErr = errors.New("busy")
+ syncOnce(t, ctx, reg, s, priv, envDir, f) // marker left behind
+ f.reloadErr = nil
+ // Now the route is refused (a second route names the unit).
+ upsertStamped(t, reg, "b.example.com", "relay.service", nil)
+ syncOnce(t, ctx, reg, s, priv, envDir, f)
+ if len(f.restarted) != 0 || !hasPending(f.root, "relay.service") {
+ t.Fatalf("refused route: restarted=%v pending=%v, want no restart and the marker kept", f.restarted, hasPending(f.root, "relay.service"))
+ }
+}
+
+func TestEnvFilesMatch(t *testing.T) {
+ want := "/etc/carlos/apps/h.env"
+ if !envFilesMatch([]string{want}, want) {
+ t.Fatal("exact single match must pass")
+ }
+ for _, bad := range [][]string{nil, {}, {"/x", want}, {want, "/x"}, {"/x"}} {
+ if envFilesMatch(bad, want) {
+ t.Fatalf("%v must not match", bad)
+ }
+ }
+}