Merge pull request #134 from carlosframework/worktree-versions-sort-then-fetch
console/CLI releases: rank versions by manifest stamp before reading manifests
8 files changed,
+529
−34
cli.go+12 −18internal/console/view.go+9 −9internal/console/view_test.go+119 −0internal/release/candidates.go+101 −0internal/release/store.go+71 −7internal/release/store_test.go+45 −0internal/release/versions_test.go+95 −0releases_local_test.go+77 −0
diff --git a/cli.go b/cli.go| index aeca97f16..64b460d6a 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -2198,19 +2198,21 @@ type releaseRow struct { |
| // screen of them. |
| const releasesCap = 20 |
| -// localReleases is the direct-mode half of `carlos releases`: List the |
| -// release dirs, read each manifest, join the tags, newest first. |
| +// localReleases is the direct-mode half of `carlos releases`: pick the |
| +// releases worth reading, read those manifests, join the tags, newest first. |
| // |
| -// It is a local reimplementation of console.Versions rather than a call to |
| -// it, deliberately: the CLI does not import internal/console today — that |
| -// package carries templates, sessions, a registry and a whole HTTP server — |
| -// and twenty lines of List + GetManifest is a far smaller thing to own than |
| -// that dependency. A manifest that will not read is a row with Err set, |
| +// It is a local sibling of console.Versions rather than a call to it, |
| +// deliberately: the CLI does not import internal/console — that package |
| +// carries templates, sessions, a registry and a whole HTTP server. What the |
| +// two DO share is release.VersionCandidates, so both surfaces pick the same |
| +// releasesCap versions (by manifest mod time, when the store carries stamps |
| +// — a bare *S3Store or *DirStore, which is all openStore hands out) and read |
| +// nothing else; the two modes of this one command agree about what "the |
| +// latest release" is. A manifest that will not read is a row with Err set, |
| // exactly as it is on the console's app page and in runChannels above, not a |
| // failure of the whole listing. |
| func localReleases(ctx context.Context, s release.Store, acct, app string) ([]releaseRow, error) { |
| - prefix := release.ReleasesPrefix(acct, app) |
| - keys, err := s.List(ctx, prefix) |
| + cands, _, err := release.VersionCandidates(ctx, s, acct, app, releasesCap) |
| if err != nil { |
| return nil, err |
| } |
| @@ -2222,16 +2224,8 @@ func localReleases(ctx context.Context, s release.Store, acct, app string) ([]re |
| for _, t := range tags { |
| byRelease[t.Release] = t.Version |
| } |
| - seen := map[string]bool{} |
| var out []releaseRow |
| - for _, k := range keys { |
| - // Every artifact of a release shares its dir, so the version is the |
| - // first segment after the prefix and each one is taken once. |
| - v, _, ok := strings.Cut(strings.TrimPrefix(k, prefix), "/") |
| - if !ok || v == "" || seen[v] { |
| - continue |
| - } |
| - seen[v] = true |
| + for _, v := range cands { |
| m, err := release.GetManifest(ctx, s, acct, app, v) |
| if err != nil { |
| out = append(out, releaseRow{Version: v, Tag: byRelease[v], Err: err.Error()}) |
diff --git a/internal/console/view.go b/internal/console/view.go| index 58075ce25..6b57bddcb 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -714,21 +714,21 @@ const tagsCap = 20 |
| // apps/<app>/releases/ with each manifest's kind and build stamp — newest |
| // first by built_at (best effort: RFC3339 parses win, then version string), |
| // capped at versionsCap. Unreadable manifests degrade to a row error. |
| +// |
| +// Manifests are read only for the versionsCap versions that will render: |
| +// release.VersionCandidates ranks the listing by manifest mod time when the |
| +// store carries stamps (both real stores do), and this fetches just the |
| +// survivors — fetch-then-sort made this page O(every version ever shipped) |
| +// in sequential bucket GETs, ~8s at tito's 318. A plain Store (test fakes |
| +// only) still yields every version, fetched and sorted exactly as before. |
| func Versions(s release.Store, acct, app string) ([]VersionView, error) { |
| ctx := context.Background() |
| - prefix := release.ReleasesPrefix(acct, app) |
| - keys, err := s.List(ctx, prefix) |
| + cands, _, err := release.VersionCandidates(ctx, s, acct, app, versionsCap) |
| if err != nil { |
| return nil, err |
| } |
| - seen := map[string]bool{} |
| var out []VersionView |
| - for _, k := range keys { |
| - v, _, ok := strings.Cut(strings.TrimPrefix(k, prefix), "/") |
| - if !ok || v == "" || seen[v] { |
| - continue |
| - } |
| - seen[v] = true |
| + for _, v := range cands { |
| m, err := release.GetManifest(ctx, s, acct, app, v) |
| if err != nil { |
| out = append(out, VersionView{Version: v, Err: err.Error()}) |
diff --git a/internal/console/view_test.go b/internal/console/view_test.go| index 76b22f46a..97242954a 100644 |
| --- a/internal/console/view_test.go |
| +++ b/internal/console/view_test.go |
| @@ -288,6 +288,125 @@ func TestVersionsDegradesBadManifest(t *testing.T) { |
| } |
| } |
| +// countingModStore is the production store shape in miniature — a bare |
| +// DirStore, ListMod and all (struct embedding, so the method survives where |
| +// deletableStore's interface embedding would drop it) — counting manifest |
| +// reads. |
| +type countingModStore struct { |
| + *release.DirStore |
| + gets int |
| +} |
| + |
| +func (c *countingModStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + c.gets++ |
| + return c.DirStore.Get(ctx, key) |
| +} |
| + |
| +// shipAt ships a real release (manifest and all — GetManifest's validation |
| +// gate must accept these rows) and pins every object's mod time so the |
| +// ranking key is under the test's control. |
| +func shipAt(t *testing.T, store release.Store, dir, v string, mod time.Time) { |
| + t.Helper() |
| + ctx := context.Background() |
| + m := release.Manifest{ |
| + Schema: 1, App: "kass", Version: v, Kind: "binary", BuiltAt: mod.Format(time.RFC3339), |
| + Artifacts: map[string]release.Artifact{"linux-amd64": {File: "kass"}}, |
| + } |
| + if err := release.Ship(ctx, store, release.OpsAccount, m, map[string][]byte{"linux-amd64": []byte("binary " + v)}); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, key := range []string{ |
| + release.ManifestKey(release.OpsAccount, "kass", v), |
| + release.ArtifactKey(release.OpsAccount, "kass", v, "kass"), |
| + } { |
| + p := filepath.Join(dir, filepath.FromSlash(key)) |
| + if _, err := os.Stat(p); err != nil { |
| + continue // artifact filename differs per Ship's layout; manifest is the one that must rank |
| + } |
| + if err := os.Chtimes(p, mod, mod); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| +} |
| + |
| +// TestVersionsBoundsManifestReads: when the store can rank keys by recency |
| +// (ModLister — both real stores), Versions reads only the manifests it will |
| +// render, and picks them by manifest MOD TIME, not by name — version names |
| +// here are numbered AGAINST recency (v01 shipped last), so a selection that |
| +// quietly ranked by name would return the wrong rows. Fetch-then-sort made |
| +// the app page O(every version ever shipped) in sequential bucket GETs — |
| +// tito's titogo reached 318 versions and ~8s per page render before this |
| +// was bounded. |
| +func TestVersionsBoundsManifestReads(t *testing.T) { |
| + dir := t.TempDir() |
| + store := &countingModStore{DirStore: release.NewDirStore(dir)} |
| + base := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) |
| + const shipped = versionsCap + 5 |
| + for i := 1; i <= shipped; i++ { |
| + // v01 gets the NEWEST stamp, v25 the oldest. |
| + shipAt(t, store, dir, fmt.Sprintf("v%02d", i), base.Add(time.Duration(shipped-i)*time.Minute)) |
| + } |
| + store.gets = 0 // Ship reads during setup don't count |
| + vs, err := Versions(store, release.OpsAccount, "kass") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(vs) != versionsCap { |
| + t.Fatalf("len = %d, want versionsCap %d", len(vs), versionsCap) |
| + } |
| + // The newest versionsCap ships, newest first: v01..v20. v21..v25 (the |
| + // oldest ships, despite their bigger numbers) are the ones cut. |
| + for i := 0; i < versionsCap; i++ { |
| + want := fmt.Sprintf("v%02d", i+1) |
| + if vs[i].Version != want { |
| + t.Fatalf("vs[%d] = %s, want %s (rank by mod time, not name)", i, vs[i].Version, want) |
| + } |
| + } |
| + // Real rendered rows, not error rows: the fixture manifests must survive |
| + // GetManifest's validation. |
| + if vs[0].Kind != "binary" || vs[0].BuiltAt == "" || vs[0].Err != "" { |
| + t.Fatalf("vs[0] = %+v, want a rendered release row", vs[0]) |
| + } |
| + if store.gets != versionsCap { |
| + t.Fatalf("read %d manifests for %d shipped versions, want %d — rank by mod time BEFORE reading", store.gets, shipped, versionsCap) |
| + } |
| +} |
| + |
| +// TestVersionsRankedPathDegrades: the ranked path keeps the fallback path's |
| +// degradation contract — a junk manifest and a ship that died before its |
| +// manifest each cost one row (Err set, sorted last by their unparseable |
| +// built_at), never the listing. |
| +func TestVersionsRankedPathDegrades(t *testing.T) { |
| + dir := t.TempDir() |
| + store := &countingModStore{DirStore: release.NewDirStore(dir)} |
| + ctx := context.Background() |
| + base := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) |
| + shipAt(t, store, dir, "good1", base) |
| + shipAt(t, store, dir, "good2", base.Add(time.Hour)) |
| + if _, err := store.Put(ctx, release.ManifestKey(release.OpsAccount, "kass", "junk1"), []byte("junk"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := store.Put(ctx, release.ArtifactKey(release.OpsAccount, "kass", "dead1", "kass"), []byte("bin"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + vs, err := Versions(store, release.OpsAccount, "kass") |
| + if err != nil { |
| + t.Fatalf("Versions must not fail on bad rows: %v", err) |
| + } |
| + if len(vs) != 4 { |
| + t.Fatalf("versions = %+v, want all 4 rows", vs) |
| + } |
| + // Healthy rows first (newest built_at first), degraded rows last. |
| + if vs[0].Version != "good2" || vs[1].Version != "good1" { |
| + t.Fatalf("healthy rows = %s, %s; want good2, good1", vs[0].Version, vs[1].Version) |
| + } |
| + for _, i := range []int{2, 3} { |
| + if vs[i].Err == "" { |
| + t.Fatalf("vs[%d] = %+v, want a degraded row", i, vs[i]) |
| + } |
| + } |
| +} |
| + |
| func TestVersionsEmptyApp(t *testing.T) { |
| _, store, _, _ := buildFixtures(t) |
| vs, err := Versions(store, release.OpsAccount, "ghost") |
diff --git a/internal/release/candidates.go b/internal/release/candidates.go| new file mode 100644 |
| index 000000000..d95fb5ecf |
| --- /dev/null |
| +++ b/internal/release/candidates.go |
| @@ -0,0 +1,101 @@ |
| +// Version-candidate selection: which of an app's shipped versions are worth |
| +// reading manifests for, decided from the listing alone. Shared by the |
| +// console's app page and `carlos releases` direct mode, so the two surfaces |
| +// agree about what "the latest releases" are. |
| +package release |
| + |
| +import ( |
| + "context" |
| + "sort" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// VersionCandidates lists the distinct versions under an app's releases |
| +// prefix, in the order manifests should be read. |
| +// |
| +// With a ModLister (both real stores), the answer is RANKED: the newest max |
| +// versions by each manifest.json object's own last-modified stamp, newest |
| +// first. The stamp is written at ship time, so it is a best-effort stand-in |
| +// for the manifest's built_at — best-effort meaning: S3 stamps are |
| +// second-granularity (same-second ships tie, broken deterministically by |
| +// version name descending, the final-sort tie-break every caller already |
| +// uses), and `accounts migrate` re-stamps the whole pre-migration backlog at |
| +// migration time. A caller then reads only the manifests it will render — |
| +// fetch-then-sort read every manifest ever shipped, and a CI-shipped app |
| +// (tito's titogo, 318 versions) took ~8s of sequential bucket GETs per page |
| +// render that way. |
| +// |
| +// A version with no manifest.json ranks by its newest object instead — an |
| +// in-flight ship (artifacts land before the manifest, Ship's write order) |
| +// briefly occupies a slot as the error row its fetch produces, and a ship |
| +// that died mid-write stays visible as one. Deliberate: the old sort-last |
| +// behavior silently hid an aborted ship forever once an app had max readable |
| +// versions, and an operator whose ship died wants the wreckage on the page, |
| +// not vanished. |
| +// |
| +// With a plain Store (test fakes; no production caller — cmd/console and the |
| +// CLI both hand over a bare *S3Store or *DirStore), the answer is every |
| +// version, unranked and uncapped, in listing order: the caller keeps its |
| +// fetch-everything path, byte-identical to the pre-ModLister behavior. |
| +func VersionCandidates(ctx context.Context, s Store, acct, app string, max int) (versions []string, ranked bool, err error) { |
| + prefix := ReleasesPrefix(acct, app) |
| + type cand struct { |
| + version string |
| + mod time.Time // manifest.json's stamp once seen; else newest object's |
| + manifest bool |
| + } |
| + var cands []cand |
| + index := map[string]int{} |
| + note := func(key string, mod time.Time) { |
| + v, rest, ok := strings.Cut(strings.TrimPrefix(key, prefix), "/") |
| + if !ok || v == "" { |
| + return |
| + } |
| + isManifest := rest == "manifest.json" |
| + i, seen := index[v] |
| + if !seen { |
| + index[v] = len(cands) |
| + cands = append(cands, cand{version: v, mod: mod, manifest: isManifest}) |
| + return |
| + } |
| + switch { |
| + case isManifest: |
| + cands[i].mod, cands[i].manifest = mod, true |
| + case !cands[i].manifest && mod.After(cands[i].mod): |
| + cands[i].mod = mod |
| + } |
| + } |
| + ml, ranked := s.(ModLister) |
| + if ranked { |
| + keys, err := ml.ListMod(ctx, prefix) |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + for _, km := range keys { |
| + note(km.Key, km.Mod) |
| + } |
| + sort.SliceStable(cands, func(i, j int) bool { |
| + if !cands[i].mod.Equal(cands[j].mod) { |
| + return cands[i].mod.After(cands[j].mod) |
| + } |
| + return cands[i].version > cands[j].version |
| + }) |
| + if len(cands) > max { |
| + cands = cands[:max] |
| + } |
| + } else { |
| + keys, err := s.List(ctx, prefix) |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + for _, k := range keys { |
| + note(k, time.Time{}) |
| + } |
| + } |
| + versions = make([]string, len(cands)) |
| + for i, c := range cands { |
| + versions[i] = c.version |
| + } |
| + return versions, ranked, nil |
| +} |
diff --git a/internal/release/store.go b/internal/release/store.go| index f672d74e4..ca6166cc1 100644 |
| --- a/internal/release/store.go |
| +++ b/internal/release/store.go |
| @@ -19,6 +19,7 @@ import ( |
| "sort" |
| "strings" |
| "sync" |
| + "time" |
| "github.com/aws/aws-sdk-go-v2/aws" |
| awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| @@ -53,6 +54,30 @@ type Store interface { |
| Delete(ctx context.Context, key string) error |
| } |
| +// KeyMod pairs a listed key with the object's last-modified stamp. |
| +type KeyMod struct { |
| + Key string |
| + Mod time.Time |
| +} |
| + |
| +// ModLister is the optional Store extension for listings that carry each |
| +// object's last-modified time — information both real stores already have in |
| +// hand (S3 returns it with every ListObjectsV2 entry; DirStore has the file |
| +// mtime) and List throws away. It exists so a caller ranking objects by |
| +// recency can decide WHICH bodies to read before reading any: the app page's |
| +// release list was O(every version ever shipped) in bucket GETs without it. |
| +// Optional rather than a Store method so the many test fakes that implement |
| +// Store stay valid; callers type-assert and fall back to List. |
| +type ModLister interface { |
| + ListMod(ctx context.Context, prefix string) ([]KeyMod, error) |
| +} |
| + |
| +// Both real stores answer ListMod; only fakes fall back. |
| +var ( |
| + _ ModLister = (*DirStore)(nil) |
| + _ ModLister = (*S3Store)(nil) |
| +) |
| + |
| // ---- DirStore --------------------------------------------------------------- |
| // DirStore keeps objects as files under a root directory — the test double |
| @@ -140,10 +165,22 @@ func (d *DirStore) Put(_ context.Context, key string, body []byte, ifMatch strin |
| return etagOf(body), nil |
| } |
| -func (d *DirStore) List(_ context.Context, prefix string) ([]string, error) { |
| +// List is ListMod minus the mtimes — one walk owns the prefix filter and the |
| +// .tmp exclusion, so the two listings cannot drift. |
| +func (d *DirStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + kms, err := d.ListMod(ctx, prefix) |
| + out := make([]string, len(kms)) |
| + for i, km := range kms { |
| + out[i] = km.Key |
| + } |
| + return out, err |
| +} |
| + |
| +// ListMod is the listing with each file's mtime alongside, key-sorted. |
| +func (d *DirStore) ListMod(_ context.Context, prefix string) ([]KeyMod, error) { |
| d.mu.Lock() |
| defer d.mu.Unlock() |
| - var out []string |
| + var out []KeyMod |
| err := filepath.WalkDir(d.root, func(p string, de fs.DirEntry, err error) error { |
| if err != nil { |
| if errors.Is(err, fs.ErrNotExist) { |
| @@ -159,12 +196,24 @@ func (d *DirStore) List(_ context.Context, prefix string) ([]string, error) { |
| return err |
| } |
| key := filepath.ToSlash(rel) |
| - if strings.HasPrefix(key, prefix) { |
| - out = append(out, key) |
| + if !strings.HasPrefix(key, prefix) { |
| + return nil |
| + } |
| + info, err := de.Info() |
| + if err != nil { |
| + // Vanished between readdir and stat — another process's delete |
| + // racing this walk (in-process writers all hold d.mu). A file |
| + // that is gone is not listed; it does not break the listing. |
| + // Untestable deterministically, so pinned by comment alone. |
| + if errors.Is(err, fs.ErrNotExist) { |
| + return nil |
| + } |
| + return err |
| } |
| + out = append(out, KeyMod{Key: key, Mod: info.ModTime()}) |
| return nil |
| }) |
| - sort.Strings(out) |
| + sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) |
| return out, err |
| } |
| @@ -326,8 +375,23 @@ func (s *S3Store) Put(ctx context.Context, key string, body []byte, ifMatch stri |
| return aws.ToString(out.ETag), nil |
| } |
| +// List is ListMod minus the stamps — one paginator loop serves both. |
| func (s *S3Store) List(ctx context.Context, prefix string) ([]string, error) { |
| - var out []string |
| + kms, err := s.ListMod(ctx, prefix) |
| + if err != nil { |
| + return nil, err |
| + } |
| + out := make([]string, len(kms)) |
| + for i, km := range kms { |
| + out[i] = km.Key |
| + } |
| + return out, nil |
| +} |
| + |
| +// ListMod is the listing keeping the LastModified S3 already sends with |
| +// every entry. |
| +func (s *S3Store) ListMod(ctx context.Context, prefix string) ([]KeyMod, error) { |
| + var out []KeyMod |
| p := awss3.NewListObjectsV2Paginator(s.Client, &awss3.ListObjectsV2Input{ |
| Bucket: &s.Bucket, |
| Prefix: &prefix, |
| @@ -338,7 +402,7 @@ func (s *S3Store) List(ctx context.Context, prefix string) ([]string, error) { |
| return nil, err |
| } |
| for _, o := range page.Contents { |
| - out = append(out, aws.ToString(o.Key)) |
| + out = append(out, KeyMod{Key: aws.ToString(o.Key), Mod: aws.ToTime(o.LastModified)}) |
| } |
| } |
| return out, nil |
diff --git a/internal/release/store_test.go b/internal/release/store_test.go| index a7a5f7726..417e6efeb 100644 |
| --- a/internal/release/store_test.go |
| +++ b/internal/release/store_test.go |
| @@ -5,6 +5,7 @@ import ( |
| "errors" |
| "fmt" |
| "os" |
| + "path/filepath" |
| "strings" |
| "testing" |
| "time" |
| @@ -172,6 +173,50 @@ func TestDirStoreListDirs(t *testing.T) { |
| } |
| } |
| +// TestDirStoreListMod: the mod-time-aware listing answers exactly like List |
| +// — same keys, same order, same prefix filter — with each key's mtime |
| +// alongside, so a caller can rank by recency without reading a single body. |
| +func TestDirStoreListMod(t *testing.T) { |
| + dir := t.TempDir() |
| + s := NewDirStore(dir) |
| + ctx := context.Background() |
| + older := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC) |
| + newer := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC) |
| + for k, mod := range map[string]time.Time{ |
| + "apps/bab/kass/releases/v1/manifest.json": older, |
| + "apps/bab/kass/releases/v2/manifest.json": newer, |
| + "apps/bab/kass/channels/stable.json": newer, // outside the prefix |
| + } { |
| + if _, err := s.Put(ctx, k, []byte(`{}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chtimes(filepath.Join(dir, filepath.FromSlash(k)), mod, mod); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + got, err := s.ListMod(ctx, "apps/bab/kass/releases/") |
| + if err != nil { |
| + t.Fatalf("ListMod: %v", err) |
| + } |
| + want := []KeyMod{ |
| + {Key: "apps/bab/kass/releases/v1/manifest.json", Mod: older}, |
| + {Key: "apps/bab/kass/releases/v2/manifest.json", Mod: newer}, |
| + } |
| + if len(got) != len(want) { |
| + t.Fatalf("ListMod = %+v, want %+v", got, want) |
| + } |
| + for i := range want { |
| + if got[i].Key != want[i].Key || !got[i].Mod.Equal(want[i].Mod) { |
| + t.Fatalf("ListMod[%d] = %+v, want %+v", i, got[i], want[i]) |
| + } |
| + } |
| + // A missing prefix lists empty like an empty bucket, not an error. |
| + empty, err := s.ListMod(ctx, "apps/nosuch/") |
| + if err != nil || len(empty) != 0 { |
| + t.Fatalf("ListMod of a missing prefix = %v, %v; want empty and no error", empty, err) |
| + } |
| +} |
| + |
| // TestS3StoreLive exercises the production store against a real bucket — |
| // only when CARLOS_TEST_S3_BUCKET is set; the type otherwise just compiles. |
| func TestS3StoreLive(t *testing.T) { |
diff --git a/internal/release/versions_test.go b/internal/release/versions_test.go| index ceb3c3437..267b6e41a 100644 |
| --- a/internal/release/versions_test.go |
| +++ b/internal/release/versions_test.go |
| @@ -1,6 +1,7 @@ |
| package release |
| import ( |
| + "context" |
| "encoding/json" |
| "errors" |
| "net" |
| @@ -344,3 +345,97 @@ func TestReadStagedManifestRefusesAnIdentityMismatch(t *testing.T) { |
| t.Fatal("a sibling naming a different version than its path was accepted") |
| } |
| } |
| + |
| +// modStamp writes key and pins its mtime — candidate ranking reads nothing |
| +// else. |
| +func modStamp(t *testing.T, s *DirStore, dir, key string, mod time.Time) { |
| + t.Helper() |
| + if _, err := s.Put(context.Background(), key, []byte(`x`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chtimes(filepath.Join(dir, filepath.FromSlash(key)), mod, mod); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// TestVersionCandidatesRanked: with a mod-aware store the candidates are the |
| +// newest max versions by each manifest object's own stamp — the mod order |
| +// here deliberately DISAGREES with version-name order, so a selection that |
| +// quietly ranked by name would fail — and a version whose ship never wrote a |
| +// manifest ranks by its newest artifact, so an aborted ship surfaces (as the |
| +// caller's error row) rather than vanishing. |
| +func TestVersionCandidatesRanked(t *testing.T) { |
| + dir := t.TempDir() |
| + s := NewDirStore(dir) |
| + base := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) |
| + for v, mod := range map[string]time.Time{ |
| + "aa": base.Add(3 * time.Hour), // newest ship, first lexically too |
| + "zz": base.Add(2 * time.Hour), // second-newest despite sorting last by name |
| + "cc": base.Add(1 * time.Hour), |
| + "bb": base, // oldest — the one the cap must cut |
| + } { |
| + modStamp(t, s, dir, ManifestKey("bab", "kass", v), mod) |
| + // An artifact NEWER than its own manifest must not outrank the |
| + // manifest's stamp. |
| + modStamp(t, s, dir, ArtifactKey("bab", "kass", v, "linux-arm64"), mod.Add(30*time.Minute)) |
| + } |
| + // bb's ARTIFACT ends up newer than cc's MANIFEST: a ranking that took |
| + // the max over all of a version's objects instead of letting the |
| + // manifest's own stamp win would rank bb (100m) over cc (60m) and evict |
| + // cc from the want below — this line is what pins the rule. |
| + bbArtifact := filepath.Join(dir, filepath.FromSlash(ArtifactKey("bab", "kass", "bb", "linux-arm64"))) |
| + if err := os.Chtimes(bbArtifact, base.Add(100*time.Minute), base.Add(100*time.Minute)); err != nil { |
| + t.Fatal(err) |
| + } |
| + // A ship that died before manifest.json: artifact only, newest of all. |
| + modStamp(t, s, dir, ArtifactKey("bab", "kass", "dead1", "linux-arm64"), base.Add(4*time.Hour)) |
| + |
| + got, ranked, err := VersionCandidates(context.Background(), s, "bab", "kass", 4) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !ranked { |
| + t.Fatal("DirStore is a ModLister — candidates must come back ranked") |
| + } |
| + want := []string{"dead1", "aa", "zz", "cc"} |
| + if len(got) != len(want) { |
| + t.Fatalf("candidates = %v, want %v", got, want) |
| + } |
| + for i := range want { |
| + if got[i] != want[i] { |
| + t.Fatalf("candidates = %v, want %v", got, want) |
| + } |
| + } |
| +} |
| + |
| +// plainStore hides ListMod behind the bare interface, the shape every test |
| +// fake has. |
| +type plainStore struct{ Store } |
| + |
| +// TestVersionCandidatesFallback: a store without ListMod answers every |
| +// version, unranked and uncapped, in listing order — the caller's |
| +// fetch-everything path, byte-identical to the pre-ModLister behavior. |
| +func TestVersionCandidatesFallback(t *testing.T) { |
| + dir := t.TempDir() |
| + s := NewDirStore(dir) |
| + base := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) |
| + for i, v := range []string{"v1", "v2", "v3"} { |
| + modStamp(t, s, dir, ManifestKey("bab", "kass", v), base.Add(time.Duration(i)*time.Hour)) |
| + } |
| + got, ranked, err := VersionCandidates(context.Background(), plainStore{s}, "bab", "kass", 2) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if ranked { |
| + t.Fatal("a bare Store cannot rank") |
| + } |
| + want := []string{"v1", "v2", "v3"} // listing order, all of them — no cap |
| + if len(got) != len(want) { |
| + t.Fatalf("candidates = %v, want %v", got, want) |
| + } |
| + for i := range want { |
| + if got[i] != want[i] { |
| + t.Fatalf("candidates = %v, want %v", got, want) |
| + } |
| + } |
| +} |
diff --git a/releases_local_test.go b/releases_local_test.go| new file mode 100644 |
| index 000000000..556133f50 |
| --- /dev/null |
| +++ b/releases_local_test.go |
| @@ -0,0 +1,77 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// manifestCountingStore is the production store shape in miniature — a bare |
| +// DirStore, ListMod and all (struct embedding keeps the method) — counting |
| +// manifest reads specifically, so the tag reads ListTags does don't muddy |
| +// the assertion. |
| +type manifestCountingStore struct { |
| + *release.DirStore |
| + manifestGets int |
| +} |
| + |
| +func (c *manifestCountingStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if strings.HasSuffix(key, "/manifest.json") { |
| + c.manifestGets++ |
| + } |
| + return c.DirStore.Get(ctx, key) |
| +} |
| + |
| +// TestLocalReleasesBoundsManifestReads: `carlos releases` direct mode reads |
| +// only the manifests it will print, chosen by manifest mod time exactly as |
| +// the console does (release.VersionCandidates is shared) — version names |
| +// here are numbered AGAINST recency so a name-ranked selection fails, and a |
| +// fetch-everything regression fails the read count. |
| +func TestLocalReleasesBoundsManifestReads(t *testing.T) { |
| + dir := t.TempDir() |
| + store := &manifestCountingStore{DirStore: release.NewDirStore(dir)} |
| + ctx := context.Background() |
| + base := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) |
| + const shipped = releasesCap + 5 |
| + for i := 1; i <= shipped; i++ { |
| + v := fmt.Sprintf("v%02d", i) |
| + mod := base.Add(time.Duration(shipped-i) * time.Minute) // v01 newest |
| + m := release.Manifest{ |
| + Schema: 1, App: "kass", Version: v, Kind: "binary", BuiltAt: mod.Format(time.RFC3339), |
| + Artifacts: map[string]release.Artifact{"linux-amd64": {File: "kass"}}, |
| + } |
| + if err := release.Ship(ctx, store, release.OpsAccount, m, map[string][]byte{"linux-amd64": []byte("bin " + v)}); err != nil { |
| + t.Fatal(err) |
| + } |
| + key := release.ManifestKey(release.OpsAccount, "kass", v) |
| + if err := os.Chtimes(filepath.Join(dir, filepath.FromSlash(key)), mod, mod); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + store.manifestGets = 0 // Ship reads during setup don't count |
| + rows, err := localReleases(ctx, store, release.OpsAccount, "kass") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(rows) != releasesCap { |
| + t.Fatalf("len = %d, want releasesCap %d", len(rows), releasesCap) |
| + } |
| + for i := 0; i < releasesCap; i++ { |
| + want := fmt.Sprintf("v%02d", i+1) |
| + if rows[i].Version != want { |
| + t.Fatalf("rows[%d] = %s, want %s (rank by mod time, not name)", i, rows[i].Version, want) |
| + } |
| + } |
| + if rows[0].Kind != "binary" || rows[0].Err != "" { |
| + t.Fatalf("rows[0] = %+v, want a rendered release row", rows[0]) |
| + } |
| + if store.manifestGets != releasesCap { |
| + t.Fatalf("read %d manifests for %d shipped versions, want %d", store.manifestGets, shipped, releasesCap) |
| + } |
| +} |