| index 94f488a19..3708c290f 100644 |
| --- a/internal/appconfig/instances_test.go |
| +++ b/internal/appconfig/instances_test.go |
| @@ -817,3 +817,151 @@ func TestSyncExecNeverReadsExecDeliveryFromTheInstanceLayer(t *testing.T) { |
| t.Fatalf("the overlay's vars did not land once the app opted in:\n%s", cur) |
| } |
| } |
| + |
| +// The exec mirror of TestSyncReportsEachRoutesOverlayGetFailureSeparately: |
| +// two independently corrupt overlays are two independent failures, not one. |
| +// The listing itself succeeds (both hosts are named), so the "once per app" |
| +// dedup for a failed LISTING must not swallow the second route's Get |
| +// failure — that would leave one of two stale config files invisible, |
| +// which is exactly what this layer exists to surface. |
| +func TestSyncExecReportsEachRoutesOverlayGetFailureSeparately(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"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + // 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. |
| + 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"}) |
| + 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) |
| + } |
| + } |
| + |
| + out := captureStdout(t, func() { |
| + if err := SyncExec(ctx, reg, s, priv, dd, func(string) error { return nil }, "", nil); err != nil { |
| + t.Fatalf("SyncExec: %v", err) |
| + } |
| + }) |
| + for _, host := range []string{"book.example.com", "cart.example.com"} { |
| + if !strings.Contains(out, host) { |
| + t.Fatalf("no line for %s's overlay failure, one route's stale file went unreported:\n%s", host, out) |
| + } |
| + } |
| + 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 config 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(dd, host+".config.env")); !os.IsNotExist(err) { |
| + t.Fatalf("%s.config.env exists after an unresolved overlay: %v", host, err) |
| + } |
| + } |
| +} |
| + |
| +// An app with no default bundle at all, and one instance with an overlay. |
| +// The overlay is the whole configuration of that route, so its file is |
| +// written, not removed — while its sibling, which has nothing anywhere, is |
| +// reaped exactly as it is today. |
| +func TestSyncExecConvergesAnOverlayAloneInsteadOfRemoving(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 := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com", |
| + map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil); err != nil { |
| + t.Fatalf("ApplyInstance: %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) |
| + } |
| + if err := os.WriteFile(filepath.Join(dd, host+".config.env"), []byte("OLD=1\n"), 0o600); err != nil { |
| + t.Fatalf("stage %s: %v", host, err) |
| + } |
| + } |
| + if err := SyncExec(ctx, reg, s, priv, dd, func(string) error { return nil }, "", nil); err != nil { |
| + t.Fatalf("SyncExec: %v", err) |
| + } |
| + book, err := os.ReadFile(filepath.Join(dd, "book.example.com.config.env")) |
| + if err != nil { |
| + t.Fatalf("the overlaid route's file was removed: %v", err) |
| + } |
| + if !bytes.Contains(book, []byte("TENANT=book\n")) || bytes.Contains(book, []byte("OLD=1")) { |
| + t.Fatalf("the overlay-alone render did not converge:\n%s", book) |
| + } |
| + if _, err := os.Stat(filepath.Join(dd, "cart.example.com.config.env")); !os.IsNotExist(err) { |
| + t.Fatalf("a route with no config anywhere kept its file: %v", err) |
| + } |
| +} |
| + |
| +// The mass-removal guard, with an overlay in the middle of it. Two apps |
| +// whose manifests all read absent still freeze every removal on the box, |
| +// and the one route that has an overlay still converges: writing a file a |
| +// bundle asked for is not a removal, and the guard exists to stop |
| +// deletions, not delivery. |
| +func TestSyncExecMassRemovalGuardHoldsRemovalsButNotOverlays(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 := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com", |
| + map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil); err != nil { |
| + t.Fatalf("ApplyInstance: %v", err) |
| + } |
| + routes := map[string]string{ |
| + "book.example.com": "titogo", |
| + "cart.example.com": "titogo", |
| + "desk.example.com": "eleven", |
| + } |
| + for host, app := range routes { |
| + if err := reg.Upsert(registry.Route{ |
| + Host: host, Kind: "instance", App: app, Account: "bab", Status: "ready", |
| + Socket: "/run/carlos/" + host + ".sock", |
| + }); err != nil { |
| + t.Fatalf("Upsert %s: %v", host, err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dd, host+".config.env"), []byte("OLD=1\n"), 0o600); err != nil { |
| + t.Fatalf("stage %s: %v", host, err) |
| + } |
| + } |
| + out := captureStdout(t, func() { |
| + if err := SyncExec(ctx, reg, s, priv, dd, func(string) error { return nil }, "", nil); err != nil { |
| + t.Fatalf("SyncExec: %v", err) |
| + } |
| + }) |
| + if !strings.Contains(out, "refusing to remove any config files this pass") { |
| + t.Fatalf("the mass-removal guard did not fire:\n%s", out) |
| + } |
| + for _, host := range []string{"cart.example.com", "desk.example.com"} { |
| + cur, err := os.ReadFile(filepath.Join(dd, host+".config.env")) |
| + if err != nil || string(cur) != "OLD=1\n" { |
| + t.Fatalf("%s lost its file while the guard was up: %q %v", host, cur, err) |
| + } |
| + } |
| + book, err := os.ReadFile(filepath.Join(dd, "book.example.com.config.env")) |
| + if err != nil { |
| + t.Fatalf("the overlaid route was frozen by a guard about removals: %v", err) |
| + } |
| + if !bytes.Contains(book, []byte("TENANT=book\n")) { |
| + t.Fatalf("the overlay did not converge under the guard:\n%s", book) |
| + } |
| +} |