carlos / platform Public

appconfig: SyncUnits prepares stamped routes behind a lock and the root-write rail

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev df285e895da7136301f479f19a59eb78093bbbc3 parent b9baaad
3 files changed, +522 −3
  • internal/appconfig/sync.go +39 −3
  • internal/appconfig/unitsync.go +185 −0
  • internal/appconfig/unitsync_test.go +298 −0
diff --git a/internal/appconfig/sync.go b/internal/appconfig/sync.go
index 805146ac5..be42a73a0 100644
--- a/internal/appconfig/sync.go
+++ b/internal/appconfig/sync.go
@@ -34,16 +34,36 @@ const DefaultEnvDir = "/etc/carlos/apps"
// release.Pass: a fetch or resolve failure for one app+environment logs and
// continues rather than aborting the whole sweep.
//
+// Sync is SyncUnits with no unit ops: the carlos-app@ half only. Kept as
+// the name every existing test and the docs use; production (RunAdopt)
+// calls SyncUnits with the real ops.
+func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, envDir string, restart func(host string) error) error {
+ return SyncUnits(ctx, reg, s, priv, envDir, restart, nil)
+}
+
// The cache key carries Environment as well as (Account, App) because two
// routes of ONE app bound to different environments resolve to different
// manifests (pipelines design §4a): keying on (acct, app) alone would render
// whichever route was seen first into both files. Distinct environments cost
// one extra resolve each, still one pass over the routes.
-func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, envDir string, restart func(host string) error) error {
+func SyncUnits(ctx context.Context, reg *registry.Registry, s release.Store, priv *ecdh.PrivateKey, envDir string, restart func(host string) error, ops *UnitOps) error {
routes, err := reg.List()
if err != nil {
return err
}
+ if ops != nil && ops.Lock != nil {
+ unlock, err := ops.Lock()
+ if err != nil {
+ fmt.Printf(" config sync: another pass holds the lock (%v); nothing touched this pass\n", err)
+ return nil
+ }
+ defer unlock()
+ }
+ var plan stampedPlan
+ if ops != nil {
+ plan = planStamped(routes, ops)
+ }
+ work := map[string]unitWork{} // by unit
type groupKey struct{ acct, app, env string }
// A resolution is one of three states, and they are NOT interchangeable:
// a manifest to converge to; "this app was never configured" (nil, nil),
@@ -97,7 +117,7 @@ func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ec
return d
}
for _, rt := range routes {
- if !syncable(rt) {
+ if !unitDelivered(rt) {
continue
}
k := groupKey{release.RouteAccount(rt), rt.App, rt.Environment}
@@ -134,7 +154,7 @@ func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ec
resolved[k] = resolution{m: m, defaultMissing: !def.found}
}
for _, rt := range routes {
- if !syncable(rt) {
+ if !unitDelivered(rt) {
continue
}
r := resolved[groupKey{release.RouteAccount(rt), rt.App, rt.Environment}]
@@ -153,6 +173,21 @@ func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ec
if m == nil {
continue
}
+ if Stamped(rt) {
+ // A stamped route is delivered here, by unit, never through the
+ // host-keyed restart callback (spec §3.2, codex R1-12).
+ if ops == nil {
+ fmt.Printf(" %s: stamped %s — no unit ops on this pass, skipped\n", rt.Host, rt.Unit)
+ continue
+ }
+ if reason, bad := plan.refused[rt.Host]; bad {
+ fmt.Printf(" %s: delivery blocked: %s\n", rt.Host, reason)
+ continue
+ }
+ unit := plan.unitOf[rt.Host]
+ work[unit] = prepareStamped(rt, unit, m, r.defaultMissing, priv, envDir, ops)
+ continue
+ }
// Only unit-backed routes read /etc/carlos/apps — an exec-backed
// route's env comes from <DataDir>/<host>.env, which the activator
// owns (it also carries CARLOS_ADMIN_TOKEN, so it can't be blindly
@@ -167,6 +202,7 @@ func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ec
}
syncHost(rt, m, r.defaultMissing, priv, envDir, restart)
}
+ _ = 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
new file mode 100644
index 000000000..e2206ad86
--- /dev/null
+++ b/internal/appconfig/unitsync.go
@@ -0,0 +1,185 @@
+// The stamped half of the root config pass (spec §3.2): for a route that
+// names its unit, render the env file, make the unit read it through a
+// marker-owned drop-in, and restart the unit through a pending marker that
+// survives any failure between the two. Everything that touches systemd
+// comes in through UnitOps, so the whole protocol runs in tests against a
+// temp dir and a fake.
+package appconfig
+
+import (
+ "bytes"
+ "crypto/ecdh"
+ "fmt"
+ "sort"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// UnitState is one unit's answer to `systemctl show -p Id,LoadState,
+// EnvironmentFiles`. EnvFiles are paths only, in order, with systemd's
+// "(ignore_errors=yes)" annotation already stripped by the prober.
+type UnitState struct {
+ ID string
+ LoadState string
+ EnvFiles []string
+}
+
+// UnitOps is the root pass's seam onto systemd and the filesystem it may
+// not touch in tests. A nil *UnitOps means "this pass has no unit ops":
+// stamped routes are skipped with a log line, carlos-app@ routes are
+// delivered exactly as before.
+type UnitOps struct {
+ // DropInRoot is /etc/systemd/system in production.
+ DropInRoot string
+ // Lock serialises config passes across processes (spec §3.2, codex
+ // R2-3). An error means another pass holds it: do nothing this pass.
+ Lock func() (unlock func(), err error)
+ // Probe answers Id, LoadState and EnvironmentFiles for every unit in
+ // one systemctl invocation. A missing unit answers LoadState
+ // "not-found". An error means the probe itself failed and nothing
+ // about any unit is known this pass.
+ Probe func(units []string) (map[string]UnitState, error)
+ // Reload is `systemctl daemon-reload`.
+ Reload func() error
+ // Restart is `systemctl restart <unit>` — restart, not try-restart:
+ // a marked unit owes a restart even when it is inactive.
+ Restart func(unit string) error
+}
+
+// stampedPlan is what the pass decided about the stamped routes on this
+// box before touching anything: which unit each delivered host maps to,
+// and why each refused host was refused.
+type stampedPlan struct {
+ // unitOf maps a delivered host to its (canonical) unit.
+ unitOf map[string]string
+ // refused maps a host to its reason; printed per route in the loop.
+ refused map[string]string
+ // probe is the pre-mutation probe answer for every delivered unit.
+ probe map[string]UnitState
+}
+
+// planStamped applies the rail (spec §3.2) to every stamped route in
+// routes, in this order: shape, unit name, hostname, one-route-per-unit,
+// then the batched systemd probe (loaded, and the stamp is the canonical
+// name). Refusals are collected, never printed here: the route loop prints
+// them beside the route so a log reads in one place.
+func planStamped(routes []registry.Route, ops *UnitOps) stampedPlan {
+ p := stampedPlan{unitOf: map[string]string{}, refused: map[string]string{}, probe: map[string]UnitState{}}
+ byUnit := map[string][]string{}
+ for _, rt := range routes {
+ if !Stamped(rt) {
+ continue
+ }
+ if err := StampShape(rt); err != nil {
+ p.refused[rt.Host] = err.Error()
+ continue
+ }
+ if err := DeliverableUnitName(rt.Unit); err != nil {
+ p.refused[rt.Host] = err.Error()
+ continue
+ }
+ if !BareHostname(rt.Host) {
+ p.refused[rt.Host] = fmt.Sprintf("%q is not a bare hostname; refusing to write it into a systemd drop-in", rt.Host)
+ continue
+ }
+ byUnit[rt.Unit] = append(byUnit[rt.Unit], rt.Host)
+ }
+ var units []string
+ for unit, hosts := range byUnit {
+ if len(hosts) > 1 {
+ sort.Strings(hosts)
+ for _, h := range hosts {
+ p.refused[h] = fmt.Sprintf("two or more routes name %s (%v); one unit takes one route's config", unit, hosts)
+ }
+ continue
+ }
+ units = append(units, unit)
+ }
+ sort.Strings(units)
+ if len(units) == 0 {
+ return p
+ }
+ states, err := ops.Probe(units)
+ if err != nil {
+ for _, unit := range units {
+ p.refused[byUnit[unit][0]] = fmt.Sprintf("systemd probe failed (%v); nothing is known about %s this pass", err, unit)
+ }
+ return p
+ }
+ for _, unit := range units {
+ host := byUnit[unit][0]
+ st, ok := states[unit]
+ switch {
+ case !ok || st.LoadState != "loaded":
+ p.refused[host] = fmt.Sprintf("%s is not loaded by systemd (%s); install the unit and the route converges on the next pass", unit, st.LoadState)
+ case st.ID != unit:
+ p.refused[host] = fmt.Sprintf("%s is an alias of %s; stamp the canonical unit name", unit, st.ID)
+ default:
+ p.unitOf[host] = unit
+ p.probe[unit] = st
+ }
+ }
+ return p
+}
+
+// unitWork is what one delivered unit's preparation left behind, for the
+// reload/restart half to act on.
+type unitWork struct {
+ host string
+ // ok: both files converged this pass (changed or unchanged). False
+ // means a refusal or write failure was printed and this unit may be
+ // reloaded but must not be restarted this pass.
+ ok bool
+}
+
+// prepareStamped converges one stamped route's env file and drop-in
+// (spec §3.2 step 1): compare both first, write the pending marker before
+// any mutation, then the env file, then the drop-in. Returns ok as
+// unitWork.ok describes.
+func prepareStamped(rt registry.Route, unit string, m *Manifest, defaultMissing bool, priv *ecdh.PrivateKey, envDir string, ops *UnitOps) unitWork {
+ w := unitWork{host: rt.Host}
+ path, rendered, cur, existed, ok := prepareHost(rt, m, defaultMissing, priv, envDir)
+ if !ok {
+ return w
+ }
+ if err := ensureDropInDir(ops.DropInRoot, unit); err != nil {
+ fmt.Printf(" %s: drop-in directory for %s: %v\n", rt.Host, unit, err)
+ return w
+ }
+ curDrop, dropExists, marked, err := readDropIn(ops.DropInRoot, unit)
+ if err != nil {
+ fmt.Printf(" %s: read drop-in for %s: %v\n", rt.Host, unit, err)
+ return w
+ }
+ if dropExists && !marked {
+ fmt.Printf(" %s: %s holds a %s that is %v; delivery is blocked until it is moved\n",
+ rt.Host, dropInDir(ops.DropInRoot, unit), DropInFile, errNotOurs)
+ return w
+ }
+ wantDrop := renderDropIn(envDir, rt.Host)
+ envChanged := !(existed && bytes.Equal(cur, rendered))
+ dropChanged := !(dropExists && bytes.Equal(curDrop, wantDrop))
+ if !envChanged && !dropChanged {
+ w.ok = true
+ return w
+ }
+ if err := writePending(ops.DropInRoot, unit); err != nil {
+ fmt.Printf(" %s: record pending restart for %s: %v (nothing written)\n", rt.Host, unit, err)
+ return w
+ }
+ if envChanged && !commitHost(rt.Host, path, rendered) {
+ return w
+ }
+ if dropChanged {
+ if err := writeDropIn(ops.DropInRoot, unit, wantDrop); err != nil {
+ fmt.Printf(" %s: write drop-in for %s: %v\n", rt.Host, unit, err)
+ return w
+ }
+ fmt.Printf(" %s: drop-in written for %s\n", rt.Host, unit)
+ }
+ if envChanged {
+ fmt.Printf(" %s: config updated for %s\n", rt.Host, unit)
+ }
+ w.ok = true
+ return w
+}
diff --git a/internal/appconfig/unitsync_test.go b/internal/appconfig/unitsync_test.go
new file mode 100644
index 000000000..acfce9508
--- /dev/null
+++ b/internal/appconfig/unitsync_test.go
@@ -0,0 +1,298 @@
+package appconfig
+
+import (
+ "context"
+ "crypto/ecdh"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// fakeUnits is the UnitOps seam with everything recorded: what was probed,
+// reloaded and restarted, and what the probe answers.
+type fakeUnits struct {
+ root string
+ states map[string]UnitState
+ probeErr error
+ reloadErr error
+ restartErr map[string]error
+ lockErr error
+ probed [][]string
+ reloads int
+ restarted []string
+}
+
+func newFakeUnits(t *testing.T) *fakeUnits {
+ t.Helper()
+ return &fakeUnits{root: t.TempDir(), states: map[string]UnitState{}, restartErr: map[string]error{}}
+}
+
+// loaded answers the probe for a healthy unit whose effective env list is
+// exactly carlos's file — the steady state after a successful pass.
+func (f *fakeUnits) loaded(unit, envDir, host string) {
+ f.states[unit] = UnitState{ID: unit, LoadState: "loaded", EnvFiles: []string{filepath.Join(envDir, host+".env")}}
+}
+
+func (f *fakeUnits) ops() *UnitOps {
+ return &UnitOps{
+ DropInRoot: f.root,
+ Lock: func() (func(), error) {
+ if f.lockErr != nil {
+ return nil, f.lockErr
+ }
+ return func() {}, nil
+ },
+ Probe: func(units []string) (map[string]UnitState, error) {
+ f.probed = append(f.probed, append([]string(nil), units...))
+ if f.probeErr != nil {
+ return nil, f.probeErr
+ }
+ out := map[string]UnitState{}
+ for _, u := range units {
+ if st, ok := f.states[u]; ok {
+ out[u] = st
+ } else {
+ out[u] = UnitState{ID: u, LoadState: "not-found"}
+ }
+ }
+ return out, nil
+ },
+ Reload: func() error { f.reloads++; return f.reloadErr },
+ Restart: func(unit string) error {
+ f.restarted = append(f.restarted, unit)
+ return f.restartErr[unit]
+ },
+ }
+}
+
+// unitKit: a store with one configured app "relay" (LOG_LEVEL=debug), a
+// registry, an env dir and the secrets key.
+func unitKit(t *testing.T) (ctx context.Context, s release.Store, reg *registry.Registry, envDir string, priv *ecdh.PrivateKey) {
+ t.Helper()
+ s = release.NewDirStore(t.TempDir())
+ ctx = context.Background()
+ reg = testRegistry(t)
+ envDir = t.TempDir()
+ p, _, err := GenerateKey()
+ if err != nil {
+ t.Fatalf("GenerateKey: %v", err)
+ }
+ priv, err = ParsePrivateKey(p, "test")
+ if err != nil {
+ t.Fatalf("ParsePrivateKey: %v", err)
+ }
+ if err := SetEnv(ctx, s, "bab", "relay", map[string]string{"LOG_LEVEL": "debug"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ return
+}
+
+func upsertStamped(t *testing.T, reg *registry.Registry, host, unit string, mut func(*registry.Route)) {
+ t.Helper()
+ rt := registry.Route{Host: host, Kind: "instance", App: "relay", Account: "bab",
+ Status: "ready", Addr: "127.0.0.1:8451", Unit: unit}
+ if mut != nil {
+ mut(&rt)
+ }
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatalf("Upsert %s: %v", host, err)
+ }
+}
+
+func noHostRestart(t *testing.T) func(string) error {
+ return func(h string) error {
+ t.Fatalf("host-keyed restart called for %s; a stamped route restarts by unit", h)
+ return nil
+ }
+}
+
+func TestSyncUnitsPreparesAStampedRoute(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ f.states["relay.service"] = UnitState{ID: "relay.service", LoadState: "loaded"}
+ upsertStamped(t, reg, "relay.example.com", "relay.service", nil)
+
+ if err := SyncUnits(ctx, reg, s, priv, envDir, noHostRestart(t), f.ops()); err != nil {
+ t.Fatalf("SyncUnits: %v", err)
+ }
+ got, err := os.ReadFile(filepath.Join(envDir, "relay.example.com.env"))
+ if err != nil || string(got) != "LOG_LEVEL=debug\nCARLOS_PURGE=header\n" {
+ t.Fatalf("env file = %q, %v", got, err)
+ }
+ cur, exists, marked, _ := readDropIn(f.root, "relay.service")
+ if !exists || !marked || string(cur) != string(renderDropIn(envDir, "relay.example.com")) {
+ t.Fatalf("drop-in exists=%v marked=%v body=%q", exists, marked, cur)
+ }
+ if len(f.probed) < 1 || strings.Join(f.probed[0], ",") != "relay.service" {
+ t.Fatalf("probed = %v, want [[relay.service]] first", f.probed)
+ }
+}
+
+func TestSyncUnitsWithNilOpsSkipsStampedRoutes(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ upsertStamped(t, reg, "relay.example.com", "relay.service", nil)
+ out := captureStdout(t, func() {
+ if err := SyncUnits(ctx, reg, s, priv, envDir, noHostRestart(t), nil); err != nil {
+ t.Fatalf("SyncUnits: %v", err)
+ }
+ })
+ if _, err := os.Stat(filepath.Join(envDir, "relay.example.com.env")); !os.IsNotExist(err) {
+ t.Fatal("a pass with no unit ops must not render for a stamped route")
+ }
+ if !strings.Contains(out, "relay.example.com") || !strings.Contains(out, "no unit ops") {
+ t.Fatalf("expected a skip line naming the host, got %q", out)
+ }
+}
+
+func TestSyncUnitsDoesNothingWithoutTheLock(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ f.lockErr = errors.New("held elsewhere")
+ upsertStamped(t, reg, "relay.example.com", "relay.service", nil)
+ if err := reg.Upsert(registry.Route{Host: "km.example.com", Kind: "instance", App: "relay", Account: "bab",
+ Status: "ready", Backing: registry.BackingUnit}); err != nil {
+ t.Fatal(err)
+ }
+ restarted := 0
+ if err := SyncUnits(ctx, reg, s, priv, envDir, func(string) error { restarted++; return nil }, f.ops()); err != nil {
+ t.Fatalf("SyncUnits: %v", err)
+ }
+ ents, _ := os.ReadDir(envDir)
+ if len(ents) != 0 || restarted != 0 || len(f.probed) != 0 {
+ t.Fatalf("a pass that cannot lock must touch nothing: files=%d restarts=%d probes=%d", len(ents), restarted, len(f.probed))
+ }
+}
+
+func TestSyncUnitsRefusesBadShapesNamesAndHosts(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ for _, u := range []string{"sock.service", "hib.service", "carlos-app@.service", "carlos-.service", "alias.service", "gone.service"} {
+ f.states[u] = UnitState{ID: u, LoadState: "loaded"}
+ }
+ f.states["alias.service"] = UnitState{ID: "real.service", LoadState: "loaded"}
+ f.states["gone.service"] = UnitState{ID: "gone.service", LoadState: "not-found"}
+ upsertStamped(t, reg, "sock.example.com", "sock.service", func(r *registry.Route) { r.Socket = "/run/x.sock"; r.Addr = "" })
+ upsertStamped(t, reg, "hib.example.com", "hib.service", func(r *registry.Route) { r.Hibernate = true })
+ upsertStamped(t, reg, "tmpl.example.com", "carlos-app@.service", nil)
+ upsertStamped(t, reg, "pref.example.com", "carlos-.service", nil)
+ upsertStamped(t, reg, "alias.example.com", "alias.service", nil)
+ upsertStamped(t, reg, "gone.example.com", "gone.service", nil)
+
+ out := captureStdout(t, func() {
+ if err := SyncUnits(ctx, reg, s, priv, envDir, noHostRestart(t), f.ops()); err != nil {
+ t.Fatalf("SyncUnits: %v", err)
+ }
+ })
+ ents, _ := os.ReadDir(envDir)
+ if len(ents) != 0 {
+ t.Fatalf("refused routes must render nothing, got %v", ents)
+ }
+ if dirs, _ := os.ReadDir(f.root); len(dirs) != 0 {
+ t.Fatalf("refused routes must get no drop-in directory, got %v", dirs)
+ }
+ for _, want := range []string{"sock.example.com", "hib.example.com", "tmpl.example.com", "pref.example.com",
+ "alias.example.com", "real.service", "gone.example.com"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("refusal output lacks %q:\n%s", want, out)
+ }
+ }
+ // Only the routes that passed the shape and name rails reach the probe.
+ if len(f.probed) != 1 || strings.Join(f.probed[0], ",") != "alias.service,gone.service" {
+ t.Fatalf("probed = %v, want the two rail-passing units", f.probed)
+ }
+}
+
+func TestSyncUnitsRefusesTwoRoutesOnOneUnit(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ f.states["relay.service"] = UnitState{ID: "relay.service", LoadState: "loaded"}
+ upsertStamped(t, reg, "a.example.com", "relay.service", nil)
+ upsertStamped(t, reg, "b.example.com", "relay.service", nil)
+ out := captureStdout(t, func() {
+ if err := SyncUnits(ctx, reg, s, priv, envDir, noHostRestart(t), f.ops()); err != nil {
+ t.Fatalf("SyncUnits: %v", err)
+ }
+ })
+ if ents, _ := os.ReadDir(envDir); len(ents) != 0 {
+ t.Fatalf("neither route may be delivered, got %v", ents)
+ }
+ if !strings.Contains(out, "a.example.com") || !strings.Contains(out, "b.example.com") {
+ t.Fatalf("both hosts must be named:\n%s", out)
+ }
+}
+
+func TestSyncUnitsRefusesAnUnmarkedDropIn(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ f.states["relay.service"] = UnitState{ID: "relay.service", LoadState: "loaded"}
+ upsertStamped(t, reg, "relay.example.com", "relay.service", nil)
+ os.MkdirAll(dropInDir(f.root, "relay.service"), 0o755)
+ foreign := []byte("[Service]\nEnvironmentFile=/x\n")
+ os.WriteFile(filepath.Join(dropInDir(f.root, "relay.service"), DropInFile), foreign, 0o644)
+ out := captureStdout(t, func() {
+ if err := SyncUnits(ctx, reg, s, priv, envDir, noHostRestart(t), f.ops()); err != nil {
+ t.Fatalf("SyncUnits: %v", err)
+ }
+ })
+ cur, _, _, _ := readDropIn(f.root, "relay.service")
+ if string(cur) != string(foreign) {
+ t.Fatal("an unmarked file must never be replaced")
+ }
+ if hasPending(f.root, "relay.service") {
+ t.Fatal("no marker may be written for a blocked route")
+ }
+ if !strings.Contains(out, "not carlos's file") {
+ t.Fatalf("expected the blocked line, got %q", out)
+ }
+}
+
+func TestSyncUnitsWritesMarkerBeforeFiles(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ f.states["relay.service"] = UnitState{ID: "relay.service", LoadState: "loaded"}
+ upsertStamped(t, reg, "relay.example.com", "relay.service", nil)
+ // Make the env dir unwritable so the env write fails after the marker.
+ if os.Geteuid() == 0 {
+ t.Skip("root ignores directory modes")
+ }
+ os.Chmod(envDir, 0o500)
+ t.Cleanup(func() { os.Chmod(envDir, 0o700) })
+ out := captureStdout(t, func() {
+ if err := SyncUnits(ctx, reg, s, priv, envDir, noHostRestart(t), f.ops()); err != nil {
+ t.Fatalf("SyncUnits: %v", err)
+ }
+ })
+ if !hasPending(f.root, "relay.service") {
+ t.Fatal("the marker records intent before mutation and must survive a failed write")
+ }
+ if len(f.restarted) != 0 {
+ t.Fatal("a failed preparation must not restart")
+ }
+ if !strings.Contains(out, "relay.example.com") {
+ t.Fatalf("expected a failure line, got %q", out)
+ }
+}
+
+func TestSyncUnitsCarlosAppRoutesUnchanged(t *testing.T) {
+ ctx, s, reg, envDir, priv := unitKit(t)
+ f := newFakeUnits(t)
+ if err := reg.Upsert(registry.Route{Host: "km.example.com", Kind: "instance", App: "relay", Account: "bab",
+ Status: "ready", Backing: registry.BackingUnit}); err != nil {
+ t.Fatal(err)
+ }
+ var restarted []string
+ if err := SyncUnits(ctx, reg, s, priv, envDir, func(h string) error { restarted = append(restarted, h); return nil }, f.ops()); err != nil {
+ t.Fatalf("SyncUnits: %v", err)
+ }
+ if len(restarted) != 1 || restarted[0] != "km.example.com" {
+ t.Fatalf("restarted = %v, want [km.example.com]", restarted)
+ }
+ if len(f.probed) != 0 || len(f.restarted) != 0 {
+ t.Fatal("a carlos-app@ route must not touch the unit ops")
+ }
+}