| 1 | // Version-candidate selection: which of an app's shipped versions are worth |
| 2 | // reading manifests for, decided from the listing alone. Shared by the |
| 3 | // console's app page and `carlos releases` direct mode, so the two surfaces |
| 4 | // agree about what "the latest releases" are. |
| 5 | package release |
| 6 | |
| 7 | import ( |
| 8 | "context" |
| 9 | "sort" |
| 10 | "strings" |
| 11 | "time" |
| 12 | ) |
| 13 | |
| 14 | // VersionCandidates lists the distinct versions under an app's releases |
| 15 | // prefix, in the order manifests should be read. |
| 16 | // |
| 17 | // With a ModLister (both real stores), the answer is RANKED: the newest max |
| 18 | // versions by each manifest.json object's own last-modified stamp, newest |
| 19 | // first. The stamp is written at ship time, so it is a best-effort stand-in |
| 20 | // for the manifest's built_at — best-effort meaning: S3 stamps are |
| 21 | // second-granularity (same-second ships tie, broken deterministically by |
| 22 | // version name descending, the final-sort tie-break every caller already |
| 23 | // uses), and `accounts migrate` re-stamps the whole pre-migration backlog at |
| 24 | // migration time. A caller then reads only the manifests it will render — |
| 25 | // fetch-then-sort read every manifest ever shipped, and a CI-shipped app |
| 26 | // (tito's titogo, 318 versions) took ~8s of sequential bucket GETs per page |
| 27 | // render that way. |
| 28 | // |
| 29 | // A version with no manifest.json ranks by its newest object instead — an |
| 30 | // in-flight ship (artifacts land before the manifest, Ship's write order) |
| 31 | // briefly occupies a slot as the error row its fetch produces, and a ship |
| 32 | // that died mid-write stays visible as one. Deliberate: the old sort-last |
| 33 | // behavior silently hid an aborted ship forever once an app had max readable |
| 34 | // versions, and an operator whose ship died wants the wreckage on the page, |
| 35 | // not vanished. |
| 36 | // |
| 37 | // With a plain Store (test fakes; no production caller — cmd/console and the |
| 38 | // CLI both hand over a bare *S3Store or *DirStore), the answer is every |
| 39 | // version, unranked and uncapped, in listing order: the caller keeps its |
| 40 | // fetch-everything path, byte-identical to the pre-ModLister behavior. |
| 41 | func VersionCandidates(ctx context.Context, s Store, acct, app string, max int) (versions []string, ranked bool, err error) { |
| 42 | prefix := ReleasesPrefix(acct, app) |
| 43 | var cands []versionStamp |
| 44 | ml, ranked := s.(ModLister) |
| 45 | if ranked { |
| 46 | cands, err = rankedVersionStamps(ctx, ml, prefix) |
| 47 | if err != nil { |
| 48 | return nil, false, err |
| 49 | } |
| 50 | if len(cands) > max { |
| 51 | cands = cands[:max] |
| 52 | } |
| 53 | } else { |
| 54 | keys, err := s.List(ctx, prefix) |
| 55 | if err != nil { |
| 56 | return nil, false, err |
| 57 | } |
| 58 | index := map[string]int{} |
| 59 | for _, k := range keys { |
| 60 | cands = noteKey(cands, index, prefix, k, time.Time{}) |
| 61 | } |
| 62 | } |
| 63 | versions = make([]string, len(cands)) |
| 64 | for i, c := range cands { |
| 65 | versions[i] = c.version |
| 66 | } |
| 67 | return versions, ranked, nil |
| 68 | } |
| 69 | |
| 70 | // versionStamp is one distinct version under a releases prefix with the |
| 71 | // stamp candidate ranking uses: manifest.json's own mod time once seen, |
| 72 | // else the newest object's. |
| 73 | type versionStamp struct { |
| 74 | version string |
| 75 | mod time.Time |
| 76 | manifest bool |
| 77 | } |
| 78 | |
| 79 | // noteKey folds one listed key into the per-version aggregation — the |
| 80 | // manifest-stamp-wins rule VersionCandidates has always applied, shared so |
| 81 | // prune's selector and the render path can never disagree about a |
| 82 | // version's age. |
| 83 | func noteKey(cands []versionStamp, index map[string]int, prefix, key string, mod time.Time) []versionStamp { |
| 84 | v, rest, ok := strings.Cut(strings.TrimPrefix(key, prefix), "/") |
| 85 | if !ok || v == "" { |
| 86 | return cands |
| 87 | } |
| 88 | isManifest := rest == "manifest.json" |
| 89 | i, seen := index[v] |
| 90 | if !seen { |
| 91 | index[v] = len(cands) |
| 92 | return append(cands, versionStamp{version: v, mod: mod, manifest: isManifest}) |
| 93 | } |
| 94 | switch { |
| 95 | case isManifest: |
| 96 | cands[i].mod, cands[i].manifest = mod, true |
| 97 | case !cands[i].manifest && mod.After(cands[i].mod): |
| 98 | cands[i].mod = mod |
| 99 | } |
| 100 | return cands |
| 101 | } |
| 102 | |
| 103 | // rankedVersionStamps lists EVERY distinct version under prefix from a |
| 104 | // mod-aware store, newest first by the manifest-stamp-wins rule, ties |
| 105 | // broken by version name descending. Uncapped: VersionCandidates caps for |
| 106 | // rendering; PruneEligible needs the whole history. |
| 107 | func rankedVersionStamps(ctx context.Context, ml ModLister, prefix string) ([]versionStamp, error) { |
| 108 | keys, err := ml.ListMod(ctx, prefix) |
| 109 | if err != nil { |
| 110 | return nil, err |
| 111 | } |
| 112 | var cands []versionStamp |
| 113 | index := map[string]int{} |
| 114 | for _, km := range keys { |
| 115 | cands = noteKey(cands, index, prefix, km.Key, km.Mod) |
| 116 | } |
| 117 | sort.SliceStable(cands, func(i, j int) bool { |
| 118 | if !cands[i].mod.Equal(cands[j].mod) { |
| 119 | return cands[i].mod.After(cands[j].mod) |
| 120 | } |
| 121 | return cands[i].version > cands[j].version |
| 122 | }) |
| 123 | return cands, nil |
| 124 | } |
| 125 | |