appconfig: SyncUnits prepares stamped routes behind a lock and the root-write rail
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3 files changed,
+522
−3
internal/appconfig/sync.go+39 −3internal/appconfig/unitsync.go+185 −0internal/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") |
| + } |
| +} |