carlos / platform Public

appconfig: pin the unit-path removal, the no-spurious-restart claim and a sharper Get-failure case (plan 4, task 11 review follow-up)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 76c1dc57789f26fa30dc4a6a88356d31bb244dda parent f042623
1 file changed, +167 −10
  • internal/appconfig/instances_test.go +167 −10
diff --git a/internal/appconfig/instances_test.go b/internal/appconfig/instances_test.go
index eeca7a231..4d87cafb1 100644
--- a/internal/appconfig/instances_test.go
+++ b/internal/appconfig/instances_test.go
@@ -558,19 +558,50 @@ func TestSyncCostsOneListingPerAppAndOneGetPerOverlay(t *testing.T) {
// dedup for a failed LISTING must not swallow the second route's Get
// failure — that would leave one of two stale credential files invisible,
// which is exactly what this layer exists to surface.
+//
+// book converges with a valid overlay first, so the case below covers the
+// sharper claim too: an already-delivered route's file survives untouched
+// when its overlay later becomes unreadable, not just that a route that
+// never had a file stays fileless.
func TestSyncReportsEachRoutesOverlayGetFailureSeparately(t *testing.T) {
ctx, s, reg, envDir, priv := syncInstanceKit(t)
if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"APP_WIDE": "yes"}); err != nil {
t.Fatalf("SetEnv: %v", err)
}
+ // Only book is on the box for the baseline pass, so cart genuinely never
+ // has a file at any point — registering it before the baseline would
+ // let it converge on the shared APP_WIDE bundle too, which would blur
+ // the two cases this test is pinning apart.
+ unitRoute(t, reg, "book.example.com", "titogo", "bab", "")
+
+ if _, _, err := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com",
+ map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil); err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ if err := Sync(ctx, reg, s, priv, envDir, func(string) error { return nil }, nil); err != nil {
+ t.Fatalf("Sync (baseline): %v", err)
+ }
+ delivered, err := os.ReadFile(filepath.Join(envDir, "book.example.com.env"))
+ if err != nil {
+ t.Fatalf("read book's file after the baseline pass: %v", err)
+ }
+ if !bytes.Contains(delivered, []byte("TENANT=book\n")) {
+ t.Fatalf("the baseline overlay did not land: %q", delivered)
+ }
+ unitRoute(t, reg, "cart.example.com", "titogo", "bab", "")
+
// Schema 2 is refused by getIfExistsAtETag ("newer than this binary
// understands"), which is a Get-time failure specific to the object
// read, not a listing failure — instanceOverlays only reads keys, never
- // their bodies, so the listing itself succeeds for both hosts.
+ // their bodies, so the listing itself succeeds for both hosts. Deleting
+ // book's valid overlay first, then putting the corrupt one raw (Put with
+ // ifMatch "" is create-only), is what turns an already-delivered route's
+ // overlay unreadable, mid-flight.
+ if err := DeleteInstanceConfig(ctx, s, "bab", "titogo", "book.example.com"); err != nil {
+ t.Fatalf("DeleteInstanceConfig: %v", err)
+ }
putInstanceRaw(t, ctx, s, "bab", "titogo", "book.example.com", &Manifest{Schema: 2, App: "titogo"})
putInstanceRaw(t, ctx, s, "bab", "titogo", "cart.example.com", &Manifest{Schema: 2, App: "titogo"})
- unitRoute(t, reg, "book.example.com", "titogo", "bab", "")
- unitRoute(t, reg, "cart.example.com", "titogo", "bab", "")
out := captureStdout(t, func() {
if err := Sync(ctx, reg, s, priv, envDir, func(string) error { return nil }, nil); err != nil {
@@ -585,13 +616,18 @@ func TestSyncReportsEachRoutesOverlayGetFailureSeparately(t *testing.T) {
if n := strings.Count(out, "instance config unresolved"); n != 2 {
t.Fatalf("instance config unresolved lines = %d, want 2 (one per corrupt overlay), got:\n%s", n, out)
}
- // Neither route had a file before this pass, and a Get failure must
- // leave that exactly as it is: no env file materializes from a resolved
- // group merge that an unresolved overlay was supposed to sit on top of.
- for _, host := range []string{"book.example.com", "cart.example.com"} {
- if _, err := os.Stat(filepath.Join(envDir, host+".env")); !os.IsNotExist(err) {
- t.Fatalf("%s.env exists after an unresolved overlay: %v", host, err)
- }
+ // cart never had a file before this pass, and a Get failure must leave
+ // that exactly as it is: no env file materializes from a resolved group
+ // merge that an unresolved overlay was supposed to sit on top of.
+ if _, err := os.Stat(filepath.Join(envDir, "cart.example.com.env")); !os.IsNotExist(err) {
+ t.Fatalf("cart.example.com.env exists after an unresolved overlay: %v", err)
+ }
+ // book already had a delivered file, and its Get failure must leave that
+ // file exactly as it was: an overlay going unreadable is not licence to
+ // touch a route that already converged on it.
+ cur, err := os.ReadFile(filepath.Join(envDir, "book.example.com.env"))
+ if err != nil || !bytes.Equal(cur, delivered) {
+ t.Fatalf("book's already-delivered file changed after its overlay became unreadable: %q, want %q (err %v)", cur, delivered, err)
}
}
@@ -1179,3 +1215,124 @@ func TestOnePassOverEveryInstanceLayerShape(t *testing.T) {
t.Fatalf("desk was disturbed by a sibling app's delete: %q", got)
}
}
+
+// The unit-path half of the removal claim. TestOnePassOverEveryInstanceLayerShape
+// pins the exec sweep's removal convergence; nothing else in the package
+// runs Sync (root's unit path) after a delete, so without this the claim
+// that a route "goes back to what the bundles alone say" once its overlay
+// is removed is only ever true for SyncExec.
+func TestSyncConvergesBackToTheBundleAfterAnOverlayIsDeleted(t *testing.T) {
+ ctx, s, reg, envDir, priv := syncInstanceKit(t)
+ if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"APP_WIDE": "yes", "TENANT": "none"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ if _, _, err := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com",
+ map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil); err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ unitRoute(t, reg, "book.example.com", "titogo", "bab", "")
+
+ if err := Sync(ctx, reg, s, priv, envDir, func(string) error { return nil }, nil); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+ before, err := os.ReadFile(filepath.Join(envDir, "book.example.com.env"))
+ if err != nil {
+ t.Fatalf("read env file before the delete: %v", err)
+ }
+ if !bytes.Contains(before, []byte("TENANT=book\n")) {
+ t.Fatalf("the overlay did not land before the delete: %q", before)
+ }
+
+ if err := DeleteInstanceConfig(ctx, s, "bab", "titogo", "book.example.com"); err != nil {
+ t.Fatalf("DeleteInstanceConfig: %v", err)
+ }
+ if err := Sync(ctx, reg, s, priv, envDir, func(string) error { return nil }, nil); err != nil {
+ t.Fatalf("Sync (after delete): %v", err)
+ }
+ after, err := os.ReadFile(filepath.Join(envDir, "book.example.com.env"))
+ if err != nil {
+ t.Fatalf("read env file after the delete: %v", err)
+ }
+ if bytes.Contains(after, []byte("TENANT=book\n")) || !bytes.Contains(after, []byte("TENANT=none\n")) {
+ t.Fatalf("the route did not converge back to the bundle alone after the overlay was deleted: %q", after)
+ }
+}
+
+// Every other test in this file passes a no-op restart callback, so a
+// regression that made resolving one route's overlay spuriously mark a
+// different route as changed would still pass the whole suite. This one
+// records which hosts restart is actually called for. The first pass
+// converges both routes from nothing and restarts both once, which is
+// ordinary first-convergence behaviour and not the claim under test; that
+// recording is discarded before the pass that matters, where only book's
+// overlay changes and cart's rendered bytes do not move at all.
+func TestSyncDoesNotRestartAnUnchangedSiblingWhileAnOverlayIsResolved(t *testing.T) {
+ ctx, s, reg, envDir, priv := syncInstanceKit(t)
+ if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"APP_WIDE": "yes", "TENANT": "none"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ unitRoute(t, reg, "book.example.com", "titogo", "bab", "")
+ unitRoute(t, reg, "cart.example.com", "titogo", "bab", "")
+
+ var restarted []string
+ record := func(host string) error {
+ restarted = append(restarted, host)
+ return nil
+ }
+ if err := Sync(ctx, reg, s, priv, envDir, record, nil); err != nil {
+ t.Fatalf("Sync (baseline): %v", err)
+ }
+ restarted = nil
+
+ if _, _, err := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com",
+ map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil); err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ if err := Sync(ctx, reg, s, priv, envDir, record, nil); err != nil {
+ t.Fatalf("Sync (overlay pass): %v", err)
+ }
+ if len(restarted) != 1 || restarted[0] != "book.example.com" {
+ t.Fatalf("restarted = %v, want exactly [book.example.com]: cart has no overlay and its rendered file did not change, so it must not restart", restarted)
+ }
+}
+
+// The exec mirror of the test above, over bounce rather than restart.
+func TestSyncExecDoesNotBounceAnUnchangedSiblingWhileAnOverlayIsResolved(t *testing.T) {
+ ctx, s, reg, dd, privB64, _ := execKit(t)
+ priv, err := ParsePrivateKey(privB64, "test")
+ if err != nil {
+ t.Fatalf("ParsePrivateKey: %v", err)
+ }
+ if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"APP_WIDE": "yes", "TENANT": "none"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ for _, host := range []string{"book.example.com", "cart.example.com"} {
+ if err := reg.Upsert(registry.Route{
+ Host: host, Kind: "instance", App: "titogo", Account: "bab", Status: "ready",
+ Socket: "/run/carlos/" + host + ".sock",
+ }); err != nil {
+ t.Fatalf("Upsert %s: %v", host, err)
+ }
+ }
+
+ var bounced []string
+ record := func(host string) error {
+ bounced = append(bounced, host)
+ return nil
+ }
+ if err := SyncExec(ctx, reg, s, priv, dd, record, "", nil); err != nil {
+ t.Fatalf("SyncExec (baseline): %v", err)
+ }
+ bounced = nil
+
+ if _, _, err := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com",
+ map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil); err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ if err := SyncExec(ctx, reg, s, priv, dd, record, "", nil); err != nil {
+ t.Fatalf("SyncExec (overlay pass): %v", err)
+ }
+ if len(bounced) != 1 || bounced[0] != "book.example.com" {
+ t.Fatalf("bounced = %v, want exactly [book.example.com]: cart has no overlay and its rendered file did not change, so it must not bounce", bounced)
+ }
+}