| index 8cbaf7fed..9e4420e51 100644 |
| --- a/internal/appconfig/instances_test.go |
| +++ b/internal/appconfig/instances_test.go |
| @@ -1,11 +1,18 @@ |
| package appconfig |
| |
| import ( |
| + "bytes" |
| "context" |
| + "crypto/ecdh" |
| "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| "strings" |
| + "sync" |
| "testing" |
| |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| |
| @@ -374,3 +381,198 @@ func TestInstanceOverlaysListsOnlyThisAppsHosts(t *testing.T) { |
| t.Fatal("another app's overlay leaked into this app's index") |
| } |
| } |
| + |
| +// countingStore counts the reads and writes a pass makes, per key, so a |
| +// test can pin the COST of a feature and not just its output. The instance |
| +// layer's promise is one extra listing per app and one extra Get per |
| +// instance that actually has an overlay, which is a claim only a counter |
| +// can keep honest. |
| +type countingStore struct { |
| + release.Store |
| + mu sync.Mutex |
| + gets map[string]int |
| + lists map[string]int |
| + puts map[string]int |
| +} |
| + |
| +func newCountingStore(s release.Store) *countingStore { |
| + return &countingStore{Store: s, gets: map[string]int{}, lists: map[string]int{}, puts: map[string]int{}} |
| +} |
| + |
| +func (c *countingStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + c.mu.Lock() |
| + c.gets[key]++ |
| + c.mu.Unlock() |
| + return c.Store.Get(ctx, key) |
| +} |
| + |
| +func (c *countingStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + c.mu.Lock() |
| + c.lists[prefix]++ |
| + c.mu.Unlock() |
| + return c.Store.List(ctx, prefix) |
| +} |
| + |
| +func (c *countingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + c.mu.Lock() |
| + c.puts[key]++ |
| + c.mu.Unlock() |
| + return c.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +func (c *countingStore) n(m map[string]int, k string) int { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + return m[k] |
| +} |
| + |
| +// syncInstanceKit is the unit-path fixture these tests share: a store, a |
| +// registry, an env dir and a parsed key pair. |
| +func syncInstanceKit(t *testing.T) (context.Context, release.Store, *registry.Registry, string, *ecdh.PrivateKey) { |
| + t.Helper() |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := testRegistry(t) |
| + priv, _, err := GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + privKey, err := ParsePrivateKey(priv, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePrivateKey: %v", err) |
| + } |
| + return ctx, s, reg, t.TempDir(), privKey |
| +} |
| + |
| +func unitRoute(t *testing.T, reg *registry.Registry, host, app, acct, env string) { |
| + t.Helper() |
| + if err := reg.Upsert(registry.Route{ |
| + Host: host, Kind: "instance", App: app, Account: acct, Status: "ready", |
| + Environment: env, Backing: registry.BackingUnit, |
| + }); err != nil { |
| + t.Fatalf("Upsert %s: %v", host, err) |
| + } |
| +} |
| + |
| +// The delivery this layer exists for: one instance of an app gets a value |
| +// its siblings never see. The sibling's file is the app merge, untouched. |
| +func TestSyncRendersTheInstanceOverlayForItsOwnRouteOnly(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", "") |
| + unitRoute(t, reg, "cart.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) |
| + } |
| + book, err := os.ReadFile(filepath.Join(envDir, "book.example.com.env")) |
| + if err != nil { |
| + t.Fatalf("read book env: %v", err) |
| + } |
| + if !bytes.Contains(book, []byte("TENANT=book\n")) || !bytes.Contains(book, []byte("APP_WIDE=yes\n")) { |
| + t.Fatalf("the overlaid route's file is wrong:\n%s", book) |
| + } |
| + cart, err := os.ReadFile(filepath.Join(envDir, "cart.example.com.env")) |
| + if err != nil { |
| + t.Fatalf("read cart env: %v", err) |
| + } |
| + if bytes.Contains(cart, []byte("book")) || !bytes.Contains(cart, []byte("TENANT=none\n")) { |
| + t.Fatalf("a sibling saw another instance's config:\n%s", cart) |
| + } |
| + |
| + // The sweep and the single-shot API must agree about what a host gets. |
| + // The sweeps resolve through the group cache and never call |
| + // LoadMergedHost, so this is the only thing holding the two together. |
| + want, _, err := LoadMergedHost(ctx, s, "bab", "titogo", "", "book.example.com") |
| + if err != nil { |
| + t.Fatalf("LoadMergedHost: %v", err) |
| + } |
| + rendered, err := RenderEnv(want, priv) |
| + if err != nil { |
| + t.Fatalf("RenderEnv: %v", err) |
| + } |
| + if !bytes.Contains(book, rendered) { |
| + t.Fatalf("the pass and LoadMergedHost disagree:\npass:\n%s\nLoadMergedHost:\n%s", book, rendered) |
| + } |
| +} |
| + |
| +// The cost. One listing per app, a Get for the instance that has an |
| +// overlay, and NOTHING for the instance that does not. |
| +func TestSyncCostsOneListingPerAppAndOneGetPerOverlay(t *testing.T) { |
| + ctx, base, reg, envDir, priv := syncInstanceKit(t) |
| + if err := SetEnv(ctx, base, "bab", "titogo", map[string]string{"APP_WIDE": "yes"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + if _, _, err := ApplyInstance(ctx, base, "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", "") |
| + unitRoute(t, reg, "cart.example.com", "titogo", "bab", "") |
| + unitRoute(t, reg, "desk.example.com", "titogo", "bab", "") |
| + |
| + s := newCountingStore(base) |
| + if err := Sync(ctx, reg, s, priv, envDir, func(string) error { return nil }, nil); err != nil { |
| + t.Fatalf("Sync: %v", err) |
| + } |
| + if got := s.n(s.lists, InstancesConfigPrefix("bab", "titogo")); got != 1 { |
| + t.Fatalf("instance overlay listings = %d, want exactly 1 for three routes of one app", got) |
| + } |
| + if got := s.n(s.gets, InstanceConfigKey("bab", "titogo", "book.example.com")); got != 1 { |
| + t.Fatalf("overlay Gets for book = %d, want 1", got) |
| + } |
| + for _, host := range []string{"cart.example.com", "desk.example.com"} { |
| + if got := s.n(s.gets, InstanceConfigKey("bab", "titogo", host)); got != 0 { |
| + t.Fatalf("overlay Gets for %s = %d, want none: the listing already said it has no overlay", host, got) |
| + } |
| + } |
| +} |
| + |
| +// A listing that fails is not a determination. The route keeps its file and |
| +// the pass says so, the same contract a bundle fetch failure already has. |
| +func TestSyncLeavesTheFileAloneWhenTheOverlayListingFails(t *testing.T) { |
| + ctx, base, reg, envDir, priv := syncInstanceKit(t) |
| + if err := SetEnv(ctx, base, "bab", "titogo", map[string]string{"APP_WIDE": "yes"}); err != nil { |
| + t.Fatalf("SetEnv: %v", err) |
| + } |
| + unitRoute(t, reg, "book.example.com", "titogo", "bab", "") |
| + path := filepath.Join(envDir, "book.example.com.env") |
| + if err := os.WriteFile(path, []byte("APP_WIDE=stale\n"), 0o600); err != nil { |
| + t.Fatalf("stage file: %v", err) |
| + } |
| + failing := &failingListStore{Store: base, failPrefix: InstancesConfigPrefix("bab", "titogo"), |
| + err: errors.New("s3: transient 503 SlowDown")} |
| + out := captureStdout(t, func() { |
| + if err := Sync(ctx, reg, failing, priv, envDir, func(string) error { return nil }, nil); err != nil { |
| + t.Fatalf("Sync: %v", err) |
| + } |
| + }) |
| + cur, err := os.ReadFile(path) |
| + if err != nil || string(cur) != "APP_WIDE=stale\n" { |
| + t.Fatalf("the file moved on a listing failure: %q %v", cur, err) |
| + } |
| + if !strings.Contains(out, "book.example.com") || !strings.Contains(out, "left as-is") { |
| + t.Fatalf("the pass was silent about an unresolved instance layer:\n%s", out) |
| + } |
| +} |
| + |
| +// failingListStore makes List fail for exactly one prefix, the listing |
| +// shape of failingKeyStore (syncexec_test.go). |
| +type failingListStore struct { |
| + release.Store |
| + failPrefix string |
| + err error |
| +} |
| + |
| +func (f *failingListStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + if prefix == f.failPrefix { |
| + return nil, f.err |
| + } |
| + return f.Store.List(ctx, prefix) |
| +} |