| 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) |
| + } |
| + } |
| +} |