package release import ( "bytes" "context" "errors" "fmt" "log" "os" "path/filepath" "strings" "sync/atomic" "testing" ) const bumpTestKey = "apps/bab/shop/instances/h.json" const bumpTestScope = "apps/bab/shop" func indexedDirStore(t *testing.T) *DirStore { t.Helper() d := NewDirStore(t.TempDir()) d.EnableChangeIndex() return d } func scopeGen(t *testing.T, s Store, scope string) uint64 { t.Helper() idx, _, err := ReadChangeIndex(context.Background(), s) if err != nil { t.Fatal(err) } return idx.Entries[scope] } func TestStoreBumpsAfterEveryMutation(t *testing.T) { t.Parallel() ctx := context.Background() d := indexedDirStore(t) want := uint64(0) step := func(name string, err error) { t.Helper() if err != nil { t.Fatalf("%s: %v", name, err) } want++ if got := scopeGen(t, d, bumpTestScope); got != want { t.Fatalf("after %s: gen = %d, want %d", name, got, want) } } etag, err := d.Put(ctx, bumpTestKey, []byte("one"), "") step("Put", err) etag, err = d.PutFrom(ctx, bumpTestKey, bytes.NewReader([]byte("two")), 3, etag) step("PutFrom", err) step("DeleteIfMatch", DeleteIfMatch(ctx, d, bumpTestKey, etag)) _, err = d.Put(ctx, bumpTestKey, []byte("three"), "") step("Put again", err) step("Delete", d.Delete(ctx, bumpTestKey)) } func TestStoreDoesNotBumpFailedMutation(t *testing.T) { t.Parallel() ctx := context.Background() d := indexedDirStore(t) if _, err := d.Put(ctx, bumpTestKey, []byte("one"), ""); err != nil { t.Fatal(err) } if _, err := d.Put(ctx, bumpTestKey, []byte("two"), "stale"); !errors.Is(err, ErrCASConflict) { t.Fatalf("stale Put = %v, want ErrCASConflict", err) } if _, err := d.PutFrom(ctx, bumpTestKey, bytes.NewReader([]byte("two")), 3, "stale"); !errors.Is(err, ErrCASConflict) { t.Fatalf("stale PutFrom = %v, want ErrCASConflict", err) } if err := DeleteIfMatch(ctx, d, bumpTestKey, "stale"); !errors.Is(err, ErrCASConflict) { t.Fatalf("stale DeleteIfMatch = %v, want ErrCASConflict", err) } if got := scopeGen(t, d, bumpTestScope); got != 1 { t.Fatalf("gen = %d after one success and three refusals, want 1", got) } } func TestStoreDoesNotBumpUnscopedKeys(t *testing.T) { t.Parallel() ctx := context.Background() d := indexedDirStore(t) for _, k := range []string{"apps/bab/shop/logs/box/x", "control/serial", "status/boxes/a.json", "releases/x"} { if _, err := d.Put(ctx, k, []byte("x"), ""); err != nil { t.Fatal(err) } if err := d.Delete(ctx, k); err != nil { t.Fatal(err) } } if _, _, err := d.Get(ctx, ChangeIndexKey); !errors.Is(err, os.ErrNotExist) { t.Fatalf("index object after unscoped writes: err = %v, want not-exist", err) } // Writing the index itself never recurses or bumps. if _, err := d.Put(ctx, ChangeIndexKey, []byte(`{"schema":1,"entries":{}}`), ""); err != nil { t.Fatal(err) } if got := scopeGen(t, d, bumpTestScope); got != 0 { t.Fatalf("gen = %d after writing the index key, want 0", got) } } func TestStoreBumpFailureDoesNotFailWrite(t *testing.T) { ctx := context.Background() d := indexedDirStore(t) // A directory where the index object belongs: reading it fails with // something other than not-exist, so every bump attempt errors. if err := os.MkdirAll(filepath.Join(d.root, filepath.FromSlash(ChangeIndexKey)), 0o755); err != nil { t.Fatal(err) } var logs bytes.Buffer logTo(t, &logs) if _, err := d.Put(ctx, bumpTestKey, []byte("one"), ""); err != nil { t.Fatalf("Put failed because the bump did: %v", err) } body, _, err := d.Get(ctx, bumpTestKey) if err != nil || string(body) != "one" { t.Fatalf("record = %q, %v; want written", body, err) } if !strings.Contains(logs.String(), "release: change index bump "+bumpTestScope+":") { t.Fatalf("bump failure was not logged: %q", logs.String()) } } func TestStoreBumpDetachedFromCallerCancel(t *testing.T) { t.Parallel() d := indexedDirStore(t) ctx, cancel := context.WithCancel(context.Background()) var seenCancelled atomic.Bool get := func(c context.Context, k string) ([]byte, string, error) { if c.Err() != nil { seenCancelled.Store(true) } return d.Get(c, k) } put := func(c context.Context, k string, b []byte, m string) (string, error) { if c.Err() != nil { seenCancelled.Store(true) } return d.putRaw(c, k, b, m) } // The caller's request ends the moment its write returns. cancel() bumpAfterMutation(ctx, true, bumpTestKey, get, put) if seenCancelled.Load() { t.Fatal("the bump ran under the caller's cancelled context") } if got := scopeGen(t, d, bumpTestScope); got != 1 { t.Fatalf("gen = %d after a bump with a cancelled caller, want 1", got) } // And through the store: a cancelled context still writes (DirStore does // not look at it) and still bumps. cctx, ccancel := context.WithCancel(context.Background()) ccancel() if _, err := d.Put(cctx, bumpTestKey, []byte("x"), ""); err != nil { t.Fatal(err) } if got := scopeGen(t, d, bumpTestScope); got != 2 { t.Fatalf("gen = %d, want 2", got) } } func TestStoreWithoutEnableNeverBumps(t *testing.T) { t.Parallel() ctx := context.Background() d := NewDirStore(t.TempDir()) etag, err := d.Put(ctx, bumpTestKey, []byte("one"), "") if err != nil { t.Fatal(err) } if _, err := d.PutFrom(ctx, bumpTestKey, bytes.NewReader([]byte("two")), 3, etag); err != nil { t.Fatal(err) } if err := d.Delete(ctx, bumpTestKey); err != nil { t.Fatal(err) } if _, _, err := d.Get(ctx, ChangeIndexKey); !errors.Is(err, os.ErrNotExist) { t.Fatalf("index object on a store that never enabled it: err = %v", err) } } func TestStoreCapabilitiesPreserved(t *testing.T) { t.Parallel() d := NewDirStore(t.TempDir()) d.EnableChangeIndex() var s Store = d _, ok1 := s.(ConditionalDeleter) _, ok2 := s.(PutStreamer) _, ok3 := s.(Opener) _, ok4 := s.(SizeLister) _, ok5 := s.(PageLister) _, ok6 := s.(ModLister) if !(ok1 && ok2 && ok3 && ok4 && ok5 && ok6) { t.Fatal("index-enabled DirStore lost an extension") } var s3 Store = &S3Store{} _, ok1 = s3.(ConditionalDeleter) _, ok2 = s3.(PutStreamer) _, ok3 = s3.(Opener) _, ok4 = s3.(SizeLister) _, ok5 = s3.(PageLister) _, ok6 = s3.(ModLister) if !(ok1 && ok2 && ok3 && ok4 && ok5 && ok6) { t.Fatal("S3Store lost an extension") } } // TestPurgeBumpsBounded: a purge deletes many keys under one app. Each delete // is its own mutation and costs exactly one index CAS, so the generation lands // on the delete count and the write amplification is one put per delete. func TestPurgeBumpsBounded(t *testing.T) { t.Parallel() ctx := context.Background() d := indexedDirStore(t) const n = 300 for i := 0; i < n; i++ { if err := d.Delete(ctx, fmt.Sprintf("apps/bab/shop/versions/%03d.json", i)); err != nil { t.Fatal(err) } } if got := scopeGen(t, d, bumpTestScope); got != n { t.Fatalf("gen = %d after %d deletes, want %d", got, n, n) } // One bump is one CAS: count the index puts the hook issues over the raw backend. var puts atomic.Int64 put := func(c context.Context, k string, b []byte, m string) (string, error) { puts.Add(1) return d.putRaw(c, k, b, m) } for i := 0; i < 20; i++ { bumpAfterMutation(ctx, true, bumpTestKey, d.Get, put) } if got := puts.Load(); got != 20 { t.Fatalf("20 bumps issued %d index puts, want exactly one each", got) } } // logTo captures the standard logger for one serial test. func logTo(t *testing.T, w *bytes.Buffer) { t.Helper() log.SetOutput(w) t.Cleanup(func() { log.SetOutput(os.Stderr) }) }