Merge main into console-critique-and-ignore
22 files changed,
+1201
−61
cli.go+12 −18internal/console/bakecontrols_test.go+3 −3internal/console/confirm.go+4 −4internal/console/flow.go+229 −0internal/console/flow_test.go+225 −0internal/console/ladder.go+1 −1internal/console/overview_test.go+63 −2internal/console/server.go+18 −4internal/console/server_test.go+1 −1internal/console/templates/app_instances.html+2 −2internal/console/templates/app_overview.html+11 −4internal/console/templates/app_releases.html+3 −3internal/console/templates/flow.html+42 −0internal/console/templates/ladder.html+3 −3internal/console/templates/layout.html+67 −0internal/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/bakecontrols_test.go b/internal/console/bakecontrols_test.go| index cab5d35d7..e86a9b363 100644 |
| --- a/internal/console/bakecontrols_test.go |
| +++ b/internal/console/bakecontrols_test.go |
| @@ -278,7 +278,7 @@ func TestPromoteEnableBakeFlipsTheFlag(t *testing.T) { |
| // The flash tells the truth about both halves of what just happened: the |
| // flag went on in this action, so the safety-delay note is now accurate. |
| if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Safety delay enabled.") || |
| - !strings.Contains(body, "It goes live on each machine after the safety delay") { |
| + !strings.Contains(body, "It goes live everywhere after the safety delay") { |
| t.Fatalf("enable_bake flash missing its two halves:\n%s", body) |
| } |
| } |
| @@ -333,7 +333,7 @@ func TestPromoteBakeNoteFollowsTheFlag(t *testing.T) { |
| if p := fx.channelVersion(t, "kass", "beta"); p.Version != "v3" { |
| t.Fatalf("beta = %+v, want v3", p) |
| } |
| - if _, _, body := get(t, c, fx.u("/apps/kass")); strings.Contains(body, "It goes live on each machine after the safety delay") { |
| + if _, _, body := get(t, c, fx.u("/apps/kass")); strings.Contains(body, "It goes live everywhere after the safety delay") { |
| t.Fatalf("a flagless app was promised a safety delay:\n%s", body) |
| } |
| @@ -348,7 +348,7 @@ func TestPromoteBakeNoteFollowsTheFlag(t *testing.T) { |
| url.Values{"csrf": {csrf}, "version": {"v4"}, "channel": {"beta"}, "confirmed": {"1"}}); status != http.StatusSeeOther { |
| t.Fatalf("beta promote of v4 = %d:\n%s", status, body) |
| } |
| - if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "It goes live on each machine after the safety delay") { |
| + if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "It goes live everywhere after the safety delay") { |
| t.Fatalf("a production app's held promotion did not mention the window:\n%s", body) |
| } |
| } |
diff --git a/internal/console/confirm.go b/internal/console/confirm.go| index a165103f4..95ad7d7e3 100644 |
| --- a/internal/console/confirm.go |
| +++ b/internal/console/confirm.go |
| @@ -134,7 +134,7 @@ func (s *Server) confirmPromote(w http.ResponseWriter, r *http.Request, addr, ra |
| flag, ferr := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| baking := ferr != nil || (flag != nil && flag.Production) |
| if hold := release.HoldFor(release.DefaultHolds, channel); hold > 0 && baking { |
| - cv.Lines = append(cv.Lines, "This app has the safety delay on: each machine waits "+holdText(hold)+" on "+channel+" before running "+version+".") |
| + cv.Lines = append(cv.Lines, "This app has the safety delay on: each copy waits "+holdText(hold)+" on "+channel+" before running "+version+".") |
| } |
| if stable && ferr == nil && !baking { |
| cv.CheckboxName = "enable_bake" |
| @@ -157,7 +157,7 @@ func (s *Server) confirmRollback(w http.ResponseWriter, r *http.Request, addr, r |
| channel + " serves " + cur + " now.", |
| "Rolling back re-points it to " + target + ", the version before this one.", |
| }, |
| - Note: "Rollbacks skip the safety delay — machines pick it up within a couple of minutes.", |
| + Note: "Rollbacks skip the safety delay — it rolls out within a couple of minutes.", |
| Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/rollback", |
| // target pins what the card promised: the confirmed POST refuses if |
| // the channel's history has moved on since this was rendered. |
| @@ -192,13 +192,13 @@ func (s *Server) confirmFastTrack(w http.ResponseWriter, r *http.Request, addr, |
| cv.Title = "Skip the safety delay for " + cur.Version + " on " + channel + "?" |
| cv.Lines = append(cv.Lines, |
| channel+" holds "+cur.Version+", promoted "+ago(cur.PromotedAt)+", still in its safety delay.", |
| - "Skipping waives the rest of it: machines run "+cur.Version+" within a couple of minutes.") |
| + "Skipping waives the rest of it: "+cur.Version+" rolls out within a couple of minutes.") |
| cv.Button = "Skip the delay for " + cur.Version |
| // Pin the version the card names — the waiver must not land on a |
| // build promoted after this was rendered. |
| cv.Fields = append(cv.Fields, [2]string{"version", cur.Version}) |
| } else { |
| - cv.Lines = append(cv.Lines, "Waives the rest of "+channel+"'s safety delay: machines run its current version within a couple of minutes.") |
| + cv.Lines = append(cv.Lines, "Waives the rest of "+channel+"'s safety delay: its current version rolls out within a couple of minutes.") |
| } |
| s.renderConfirm(w, r, addr, raw, acct, role, app, cv) |
| } |
diff --git a/internal/console/flow.go b/internal/console/flow.go| new file mode 100644 |
| index 000000000..c7c1caea0 |
| --- /dev/null |
| +++ b/internal/console/flow.go |
| @@ -0,0 +1,229 @@ |
| +// The app page's flow diagram: one picture answering "what is going on with |
| +// this app" — a version you shipped, the channels it moves through, the |
| +// addresses each channel feeds, and how each of those is doing. |
| +// |
| +// It exists because the ladder table answered the same question in prose that |
| +// only made sense to someone who already knew the answer (the 2026-08-09 |
| +// clarity critique: recognition-over-recall 1/4, minimalism 1/4). A picture |
| +// of three boxes with an arrow between them says "promote moves it one step" |
| +// without a sentence explaining what promote means. |
| +// |
| +// It is assembled from the app's channel pointers and the SAME tenant-filtered |
| +// instance rows the Running tab renders — deliberately NOT from AppView.Routes |
| +// or the ladder, both of which carry routes stamped to other accounts (apps |
| +// are grouped by name across accounts; see assembleOverview's tenant note). |
| +// Reusing the rows means the diagram cannot draw an address the Running tab |
| +// does not, cannot disagree with it about health, and cannot leak a |
| +// neighbouring tenant's host into this account's picture. |
| +package console |
| + |
| +import ( |
| + "sort" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// FlowView is the whole diagram: the release line every app has, plus the |
| +// bands for channels that sit outside it. |
| +type FlowView struct { |
| + // Show is false when the app has published nothing and serves nothing — |
| + // three empty boxes teach less than the page's own empty state. |
| + Show bool |
| + // Shipped is the newest version in the account's bucket for this app, |
| + // which is where the line starts: it is the one thing on the diagram the |
| + // owner did directly. |
| + Shipped string |
| + // ShippedAt is the build time, parsed from the manifest's RFC3339 stamp; |
| + // zero when there is none or it will not parse, which renders as no date |
| + // rather than as a raw timestamp nobody can read at a glance. |
| + ShippedAt time.Time |
| + // Line is the release path — edge, beta, live — ALWAYS all three, even |
| + // when the later ones hold nothing. A path drawn only as far as the app |
| + // has travelled reads as a path with no further to go. |
| + Line []FlowStage |
| + // Tests are the canary/* channels: named, disposable, no delay. |
| + Tests []FlowStage |
| + // Extra is any other channel an address actually follows. Nothing should |
| + // normally land here — it is the band that keeps the diagram honest, so |
| + // a route on an off-ladder channel is drawn rather than silently omitted |
| + // from a picture that presents itself as the whole story. |
| + Extra []FlowStage |
| +} |
| + |
| +// FlowStage is one box on the diagram: a channel, what it holds, and the |
| +// addresses following it. |
| +type FlowStage struct { |
| + // Name is the label on the box — the word an owner uses ("live"), not |
| + // always the channel's name ("stable"); Channel is the real one. |
| + Name string |
| + Channel string |
| + Version string |
| + // PromotedAt is when this channel last moved; zero when it holds nothing. |
| + PromotedAt time.Time |
| + // Err is one line of bad news about this channel: a pointer that would |
| + // not read, or a channel addresses follow that nothing was published to. |
| + Err string |
| + // Hold is the safety delay this channel imposes on arrival — "~24h" — |
| + // and is empty both when the channel imposes none and when the app has |
| + // the delay switched off. It is the general rule; BakedUntil is the |
| + // live countdown for the version actually sitting here now. |
| + Hold string |
| + BakedUntil time.Time |
| + FastTrack bool |
| + // Carried is whether the version in the box before this one actually |
| + // made it here. It is what the diagram draws as a solid arrow rather |
| + // than a dashed one, and it is the whole reason the picture beats the |
| + // table: "how far has my build got" becomes a thing you see rather than |
| + // three version strings you compare by eye. |
| + Carried bool |
| + Addresses []FlowAddress |
| +} |
| + |
| +// FlowAddress is one address hanging off a stage, with its health. |
| +type FlowAddress struct { |
| + Host string |
| + Health string // healthWord's plain word, as the Running tab shows it |
| + Version string // what this address reports it is running, "" unknown |
| + // Tone is how the health word should read: ok, idle, wait or bad. |
| + // Asleep is idle rather than bad on purpose — hibernating is the system |
| + // working, and colouring it as a problem trains owners to ignore the |
| + // colour that means an actual problem. |
| + Tone string |
| +} |
| + |
| +// addressTone maps healthWord's vocabulary onto the four tones. An |
| +// unrecognised word falls to idle: a health word nobody taught this function |
| +// is missing information, and missing information must never render as |
| +// either reassurance or alarm. |
| +func addressTone(health string) string { |
| + switch health { |
| + case "Running", "Serving": |
| + return "ok" |
| + case "Setting up": |
| + return "wait" |
| + case "Not responding", "Failed": |
| + return "bad" |
| + default: |
| + return "idle" |
| + } |
| +} |
| + |
| +// flowLine is the release path, in the order versions travel it, with the |
| +// label each box carries. "live" rather than "stable" is deliberate and |
| +// matches the Versions tab's strip: stable is the channel's name, live is |
| +// what it means. |
| +var flowLine = []struct{ name, channel string }{ |
| + {"edge", "edge"}, |
| + {"beta", "beta"}, |
| + {"live", release.RungStable}, |
| +} |
| + |
| +// assembleFlow folds the app's channels and its tenant-filtered instance rows |
| +// into the diagram. latest/latestAt are the newest shipped version and its |
| +// build time ("" when nothing has been shipped). |
| +func assembleFlow(view *AppView, rows []InstanceRow, latest, latestAt string) FlowView { |
| + f := FlowView{Shipped: latest} |
| + if t, err := time.Parse(time.RFC3339, latestAt); err == nil { |
| + f.ShippedAt = t |
| + } |
| + |
| + // Rows arrive in route order and stay in it: the Running tab lists these |
| + // same hosts in the same order, and two orderings of one list is exactly |
| + // the kind of small friction that makes a picture feel untrustworthy. |
| + byChannel := map[string][]FlowAddress{} |
| + for _, r := range rows { |
| + byChannel[r.Channel] = append(byChannel[r.Channel], FlowAddress{ |
| + Host: r.Host, |
| + Health: r.HealthWord, |
| + Version: r.Version, |
| + Tone: addressTone(r.HealthWord), |
| + }) |
| + } |
| + chans := make(map[string]ChannelView, len(view.Channels)) |
| + for _, c := range view.Channels { |
| + chans[c.Name] = c |
| + } |
| + // An unreadable production flag counts as ON, because that is what the |
| + // boxes do with it (they keep baking) — the diagram must not tell an |
| + // owner their delay is off while every box is still holding. |
| + held := view.Production.On || view.Production.Err != "" |
| + |
| + stage := func(name, channel string) FlowStage { |
| + c := chans[channel] |
| + s := FlowStage{ |
| + Name: name, Channel: channel, |
| + Version: c.Version, PromotedAt: c.PromotedAt, Err: c.Err, |
| + BakedUntil: c.BakedUntil, FastTrack: c.FastTrack, |
| + Addresses: byChannel[channel], |
| + } |
| + if held { |
| + if d := release.HoldFor(release.DefaultHolds, channel); d > 0 { |
| + s.Hold = holdText(d) |
| + } |
| + } |
| + // A channel with addresses but nothing ever published to it is the |
| + // silent failure the ladder was built to surface: hosts serving |
| + // nothing, waiting for a version that never came. |
| + if s.Err == "" && s.Version == "" && len(s.Addresses) > 0 { |
| + s.Err = "nothing published here yet — these addresses are waiting for a version" |
| + } |
| + return s |
| + } |
| + |
| + // The line, then the arrows between its boxes. A step counts as carried |
| + // only when the box before it actually holds something: two empty boxes |
| + // share a version string ("") without any version having travelled |
| + // between them, and drawing that as a solid arrow would be the diagram's |
| + // one chance to lie outright. |
| + for _, ls := range flowLine { |
| + f.Line = append(f.Line, stage(ls.name, ls.channel)) |
| + } |
| + prev := latest |
| + for i := range f.Line { |
| + f.Line[i].Carried = prev != "" && f.Line[i].Version == prev |
| + prev = f.Line[i].Version |
| + } |
| + |
| + // Canaries from their pointers first, then any canary channel an address |
| + // follows that has no pointer at all. |
| + drawn := map[string]bool{} |
| + for _, ls := range flowLine { |
| + drawn[ls.channel] = true |
| + } |
| + for _, c := range view.Channels { |
| + if !strings.HasPrefix(c.Name, "canary/") || drawn[c.Name] { |
| + continue |
| + } |
| + drawn[c.Name] = true |
| + f.Tests = append(f.Tests, stage(strings.TrimPrefix(c.Name, "canary/"), c.Name)) |
| + } |
| + for ch := range byChannel { |
| + if drawn[ch] { |
| + continue |
| + } |
| + drawn[ch] = true |
| + if strings.HasPrefix(ch, "canary/") { |
| + f.Tests = append(f.Tests, stage(strings.TrimPrefix(ch, "canary/"), ch)) |
| + continue |
| + } |
| + f.Extra = append(f.Extra, stage(ch, ch)) |
| + } |
| + // Map iteration ordered nothing above, and a diagram that reshuffles |
| + // between two refreshes of the same page is one nobody trusts. |
| + sortStages := func(s []FlowStage) { |
| + sort.Slice(s, func(a, b int) bool { return s[a].Channel < s[b].Channel }) |
| + } |
| + sortStages(f.Tests) |
| + sortStages(f.Extra) |
| + |
| + for _, band := range [][]FlowStage{f.Line, f.Tests, f.Extra} { |
| + for _, s := range band { |
| + if s.Version != "" || len(s.Addresses) > 0 { |
| + f.Show = true |
| + } |
| + } |
| + } |
| + return f |
| +} |
diff --git a/internal/console/flow_test.go b/internal/console/flow_test.go| new file mode 100644 |
| index 000000000..6ff027368 |
| --- /dev/null |
| +++ b/internal/console/flow_test.go |
| @@ -0,0 +1,225 @@ |
| +package console |
| + |
| +import "testing" |
| + |
| +// stageByChannel finds one stage across every band, so the tests can assert |
| +// about a channel without caring which band the assembler filed it under. |
| +func stageByChannel(f FlowView, channel string) *FlowStage { |
| + for _, band := range [][]FlowStage{f.Line, f.Tests, f.Extra} { |
| + for i := range band { |
| + if band[i].Channel == channel { |
| + return &band[i] |
| + } |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// The line is the app's release path, and it is drawn whole even when the |
| +// app has only reached the first rung: a pipeline with its later steps |
| +// missing reads as "there is no later step", which is the opposite of true. |
| +func TestFlowLineIsAlwaysTheWholePath(t *testing.T) { |
| + v := &AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}} |
| + f := assembleFlow(v, nil, "a1", "") |
| + |
| + if len(f.Line) != 3 { |
| + t.Fatalf("line has %d stages, want edge/beta/live: %+v", len(f.Line), f.Line) |
| + } |
| + for i, want := range []string{"edge", "beta", "stable"} { |
| + if f.Line[i].Channel != want { |
| + t.Errorf("line[%d] is %q, want %q", i, f.Line[i].Channel, want) |
| + } |
| + } |
| + if f.Line[2].Name != "live" { |
| + t.Errorf("the stable stage is labelled %q, want the word owners use: live", f.Line[2].Name) |
| + } |
| + if f.Line[1].Version != "" { |
| + t.Errorf("beta holds nothing but reports version %q", f.Line[1].Version) |
| + } |
| +} |
| + |
| +// The whole point of the picture is that an address hangs off the channel |
| +// feeding it. An address filed under the wrong stage would draw a lie. |
| +func TestFlowAddressesAttachToTheirChannel(t *testing.T) { |
| + v := &AppView{Channels: []ChannelView{ |
| + {Name: "stable", Version: "a1"}, |
| + {Name: "canary/rc", Version: "b2"}, |
| + }} |
| + rows := []InstanceRow{ |
| + {Host: "kass.dev", Channel: "stable", Version: "a1", HealthWord: "Running"}, |
| + {Host: "www.kass.dev", Channel: "stable", Version: "a1", HealthWord: "Running"}, |
| + {Host: "rc.kass.dev", Channel: "canary/rc", Version: "b2", HealthWord: "Asleep"}, |
| + } |
| + f := assembleFlow(v, rows, "b2", "") |
| + |
| + live := stageByChannel(f, "stable") |
| + if live == nil || len(live.Addresses) != 2 { |
| + t.Fatalf("live stage should carry both stable hosts: %+v", live) |
| + } |
| + if live.Addresses[0].Host != "kass.dev" || live.Addresses[1].Host != "www.kass.dev" { |
| + t.Errorf("live addresses out of order: %+v", live.Addresses) |
| + } |
| + rc := stageByChannel(f, "canary/rc") |
| + if rc == nil || len(rc.Addresses) != 1 || rc.Addresses[0].Host != "rc.kass.dev" { |
| + t.Fatalf("the canary host should hang off canary/rc: %+v", rc) |
| + } |
| + if rc.Name != "rc" { |
| + t.Errorf("canary stage is labelled %q, want the short name rc", rc.Name) |
| + } |
| + if len(f.Tests) != 1 { |
| + t.Errorf("canary/rc belongs in the test band, got %d test stages", len(f.Tests)) |
| + } |
| +} |
| + |
| +// A running address that the diagram does not draw is worse than no diagram: |
| +// the picture claims to be the whole story. Whatever channel a route follows, |
| +// its host must appear somewhere. |
| +func TestFlowDrawsEveryRunningAddress(t *testing.T) { |
| + v := &AppView{Channels: []ChannelView{{Name: "stable", Version: "a1"}}} |
| + rows := []InstanceRow{ |
| + {Host: "kass.dev", Channel: "stable", HealthWord: "Running"}, |
| + {Host: "rc.kass.dev", Channel: "canary/rc", HealthWord: "Running"}, |
| + {Host: "odd.kass.dev", Channel: "experiment", HealthWord: "Running"}, |
| + } |
| + f := assembleFlow(v, rows, "a1", "") |
| + |
| + for _, want := range []string{"kass.dev", "rc.kass.dev", "odd.kass.dev"} { |
| + found := false |
| + for _, band := range [][]FlowStage{f.Line, f.Tests, f.Extra} { |
| + for _, s := range band { |
| + for _, a := range s.Addresses { |
| + if a.Host == want { |
| + found = true |
| + } |
| + } |
| + } |
| + } |
| + if !found { |
| + t.Errorf("%s is running but appears nowhere in the diagram", want) |
| + } |
| + } |
| + if len(f.Extra) != 1 || f.Extra[0].Channel != "experiment" { |
| + t.Errorf("a route off the ladder needs its own band, got %+v", f.Extra) |
| + } |
| +} |
| + |
| +// The safety delay is the single most confusing thing on the old ladder, so |
| +// the diagram states it — but only when it is actually switched on, and only |
| +// on the channels that hold. |
| +func TestFlowShowsTheSafetyDelayOnlyWhenItApplies(t *testing.T) { |
| + chans := []ChannelView{{Name: "edge", Version: "a1"}, {Name: "beta", Version: "a1"}, {Name: "stable", Version: "a1"}} |
| + |
| + on := assembleFlow(&AppView{Channels: chans, Production: ProductionView{On: true}}, nil, "a1", "") |
| + if got := stageByChannel(on, "beta").Hold; got != "~24h" { |
| + t.Errorf("beta hold is %q, want ~24h", got) |
| + } |
| + if got := stageByChannel(on, "stable").Hold; got != "~72h" { |
| + t.Errorf("live hold is %q, want ~72h", got) |
| + } |
| + if got := stageByChannel(on, "edge").Hold; got != "" { |
| + t.Errorf("edge holds nothing back but says %q", got) |
| + } |
| + |
| + off := assembleFlow(&AppView{Channels: chans}, nil, "a1", "") |
| + for _, s := range off.Line { |
| + if s.Hold != "" { |
| + t.Errorf("safety delay is off, but %s advertises a %q wait", s.Channel, s.Hold) |
| + } |
| + } |
| + |
| + // An unreadable flag is treated as on, because that is what the boxes do. |
| + bad := assembleFlow(&AppView{Channels: chans, Production: ProductionView{Err: "unverifiable"}}, nil, "a1", "") |
| + if stageByChannel(bad, "stable").Hold == "" { |
| + t.Error("an unreadable production flag must still show the delay — boxes keep baking") |
| + } |
| +} |
| + |
| +// Health is the third of the three questions the console exists to answer, |
| +// and the diagram is where it gets answered per address. Asleep must read as |
| +// idle, not as a problem: hibernating is the platform working. |
| +func TestFlowTonesAddressHealth(t *testing.T) { |
| + rows := []InstanceRow{ |
| + {Host: "a.dev", Channel: "stable", HealthWord: "Running"}, |
| + {Host: "b.dev", Channel: "stable", HealthWord: "Asleep"}, |
| + {Host: "c.dev", Channel: "stable", HealthWord: "Not responding"}, |
| + {Host: "d.dev", Channel: "stable", HealthWord: "Failed"}, |
| + {Host: "e.dev", Channel: "stable", HealthWord: "Setting up"}, |
| + {Host: "f.dev", Channel: "stable", HealthWord: "Serving"}, |
| + {Host: "g.dev", Channel: "stable", HealthWord: "Something new"}, |
| + } |
| + f := assembleFlow(&AppView{Channels: []ChannelView{{Name: "stable", Version: "a1"}}}, rows, "a1", "") |
| + |
| + want := map[string]string{ |
| + "a.dev": "ok", "b.dev": "idle", "c.dev": "bad", "d.dev": "bad", |
| + "e.dev": "wait", "f.dev": "ok", |
| + // A word this function has never been taught is missing information: |
| + // neither reassurance nor alarm. |
| + "g.dev": "idle", |
| + } |
| + for _, a := range stageByChannel(f, "stable").Addresses { |
| + if a.Tone != want[a.Host] { |
| + t.Errorf("%s (%s): tone=%q, want %q", a.Host, a.Health, a.Tone, want[a.Host]) |
| + } |
| + } |
| +} |
| + |
| +// The solid-vs-dashed arrow is the diagram's one claim beyond the words on |
| +// it: that a version really did travel this step. It must be exactly true. |
| +func TestFlowCarriesTheArrowOnlyWhereTheVersionTravelled(t *testing.T) { |
| + // a1 shipped, promoted all the way to beta, not yet live. |
| + f := assembleFlow(&AppView{Channels: []ChannelView{ |
| + {Name: "edge", Version: "a1"}, |
| + {Name: "beta", Version: "a1"}, |
| + {Name: "stable", Version: "old"}, |
| + }}, nil, "a1", "") |
| + want := []bool{true, true, false} |
| + for i, s := range f.Line { |
| + if s.Carried != want[i] { |
| + t.Errorf("%s: carried=%v, want %v", s.Channel, s.Carried, want[i]) |
| + } |
| + } |
| + |
| + // Two empty boxes in a row share the empty string, and no version has |
| + // travelled between them — the arrow must stay dashed. |
| + empty := assembleFlow(&AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}, nil, "a1", "") |
| + if empty.Line[1].Carried || empty.Line[2].Carried { |
| + t.Errorf("empty boxes must not chain a solid arrow: %+v", empty.Line) |
| + } |
| + |
| + // Nothing shipped at all: the very first arrow has nothing to carry. |
| + none := assembleFlow(&AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}, nil, "", "") |
| + if none.Line[0].Carried { |
| + t.Error("no shipped version — the arrow into edge cannot be solid") |
| + } |
| +} |
| + |
| +// A host following a channel nothing was ever published to is the failure the |
| +// ladder exists to make visible; the diagram must not quietly draw an empty |
| +// box instead. |
| +func TestFlowSaysWhenAChannelHasNothingPublished(t *testing.T) { |
| + rows := []InstanceRow{{Host: "rc.kass.dev", Channel: "canary/rc", HealthWord: "Not responding"}} |
| + f := assembleFlow(&AppView{}, rows, "", "") |
| + |
| + rc := stageByChannel(f, "canary/rc") |
| + if rc == nil { |
| + t.Fatal("a host follows canary/rc — the stage must be drawn") |
| + } |
| + if rc.Err == "" { |
| + t.Error("canary/rc has no version and no pointer: the stage must say so") |
| + } |
| +} |
| + |
| +// Nothing shipped and nothing running is not a diagram — it is an empty |
| +// state, and the page has better words for that than three blank boxes. |
| +func TestFlowHidesItselfWhenThereIsNothingToDraw(t *testing.T) { |
| + if f := assembleFlow(&AppView{}, nil, "", ""); f.Show { |
| + t.Error("an app with no channels and no addresses should not draw a diagram") |
| + } |
| + if f := assembleFlow(&AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}, nil, "a1", ""); !f.Show { |
| + t.Error("a shipped version on edge is worth drawing") |
| + } |
| + rows := []InstanceRow{{Host: "kass.dev", Channel: "stable", HealthWord: "Running"}} |
| + if f := assembleFlow(&AppView{}, rows, "", ""); !f.Show { |
| + t.Error("a running address is worth drawing even with no readable pointer") |
| + } |
| +} |
diff --git a/internal/console/ladder.go b/internal/console/ladder.go| index 67eff601d..535ed1f57 100644 |
| --- a/internal/console/ladder.go |
| +++ b/internal/console/ladder.go |
| @@ -170,7 +170,7 @@ func assembleLadder(channels []ChannelView, routes []RouteView, now time.Time) [ |
| } |
| entries[rung] = append(entries[rung], LadderChannel{ |
| Channel: ch, Routes: rts, |
| - Err: "nothing has been published here yet — these machines are waiting for a version", |
| + Err: "nothing has been published here yet — these addresses are waiting for a version", |
| }) |
| } |
| out := make([]LadderRung, 0, 4) |
diff --git a/internal/console/overview_test.go b/internal/console/overview_test.go| index 4ada7994e..cfc5175a3 100644 |
| --- a/internal/console/overview_test.go |
| +++ b/internal/console/overview_test.go |
| @@ -347,8 +347,14 @@ func TestOverviewPageRendersTiles(t *testing.T) { |
| if !strings.Contains(body, "Needs attention") { |
| t.Fatalf("the headline verdict is missing:\n%s", body) |
| } |
| - if !strings.Contains(body, "Machines") { |
| - t.Fatalf("no Machines tile on the Overview:\n%s", body) |
| + if !strings.Contains(body, "<h2>Running</h2>") { |
| + t.Fatalf("no Running tile on the Overview:\n%s", body) |
| + } |
| + // "Machines" implied one computer per row when several rows routinely |
| + // share a box, and its count was byte-for-byte the Web addresses count |
| + // beside it. Both the word and the duplicate are meant to stay gone. |
| + if strings.Contains(body, "Machines") || strings.Contains(body, "machines") { |
| + t.Fatalf("the Overview still talks about machines:\n%s", body) |
| } |
| if strings.Contains(body, "stable pointer unreadable") { |
| t.Fatalf("stale tile copy still rendered:\n%s", body) |
| @@ -361,6 +367,61 @@ func TestOverviewPageRendersTiles(t *testing.T) { |
| } |
| } |
| +// flowSection returns just the diagram's markup. The console inlines its |
| +// whole stylesheet into every page, so `.flow-stage.is-carried` and friends |
| +// are present in the CSS on a page that draws no diagram at all — an |
| +// unscoped Contains would assert on the rules instead of the drawing. |
| +func flowSection(t *testing.T, page string) string { |
| + t.Helper() |
| + i := strings.Index(page, `<section class="flow"`) |
| + if i < 0 { |
| + t.Fatalf("no flow diagram on the page:\n%s", page) |
| + } |
| + j := strings.Index(page[i:], "</section>") |
| + if j < 0 { |
| + t.Fatalf("the flow section is never closed:\n%s", page[i:]) |
| + } |
| + return page[i : i+j] |
| +} |
| + |
| +// The flow diagram is the Overview's answer to "what is going on" — the |
| +// release path drawn as a path, with each address hanging off the channel |
| +// that feeds it. These assertions cover what a reader has to be able to see; |
| +// flow_test.go covers what the drawing is allowed to claim. |
| +func TestOverviewDrawsTheFlow(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + _, _, page := get(t, c, fx.u("/apps/kass")) |
| + |
| + // Scoped to the section, deliberately: the page carries its stylesheet |
| + // inline, so every class name this test looks for also appears in the |
| + // CSS above it, and a whole-body assertion would pass on the rules |
| + // rather than on the drawing. |
| + body := flowSection(t, page) |
| + // The whole path, including the steps this app has not reached: a path |
| + // drawn only as far as the app has travelled reads as a path with no |
| + // further to go. |
| + for _, label := range []string{"you shipped", "edge", "beta", "live"} { |
| + if !strings.Contains(body, `<p class="flow-label">`+label+"</p>") { |
| + t.Fatalf("the flow is missing its %q box:\n%s", label, body) |
| + } |
| + } |
| + // The addresses hang off the diagram, each with the same health word the |
| + // Running tab would give it — and as real links, since the point of an |
| + // address is that you can go to it. |
| + if !strings.Contains(body, `<a href="https://kass.carlosapp.dev">kass.carlosapp.dev</a>`) { |
| + t.Fatalf("the flow does not show the app's address as a link:\n%s", body) |
| + } |
| + if !strings.Contains(body, `class="flow-addr flow-tone-bad"`) { |
| + t.Fatalf("the fixture's silent host should be toned as needing attention:\n%s", body) |
| + } |
| + // The fixture ships v2 but only has v1 live, so nothing has travelled the |
| + // whole path: no step may claim it has. |
| + if strings.Contains(body, "is-carried") { |
| + t.Fatalf("v2 is not live, so no step carried it:\n%s", body) |
| + } |
| +} |
| + |
| // The Instances tab's one table, with the columns an owner reads across. |
| func TestOverviewInstancesPageRendersTable(t *testing.T) { |
| fx := newConsoleFixture(t) |
diff --git a/internal/console/server.go b/internal/console/server.go| index 4a9f9226a..d249e3c39 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -516,6 +516,10 @@ type page struct { |
| // from reads the page does anyway. |
| Overview OverviewStats |
| + // App Overview: the same reads drawn as the pipeline they describe |
| + // (flow.go). Zero-valued (Show false) renders nothing at all. |
| + Flow FlowView |
| + |
| // App Instances: the one table — routes joined with their readings. |
| InstanceRows []InstanceRow |
| @@ -1495,6 +1499,16 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) { |
| p.Versions = versions |
| metrics := s.appResourceMetrics(r.Context(), p.Detail, acct) |
| p.Overview = assembleOverview(p.Detail, acct.Sqid, metrics, versions) |
| + // The diagram is drawn from the SAME tenant-filtered rows the Running tab |
| + // renders, so the two can never disagree about which addresses exist or |
| + // how they are doing — see flow.go's package comment for why it is not |
| + // built from Detail.Routes. |
| + p.InstanceRows = assembleInstanceRows(p.Detail, acct, metrics) |
| + var latest, latestAt string |
| + if len(versions) > 0 { |
| + latest, latestAt = versions[0].Version, versions[0].BuiltAt |
| + } |
| + p.Flow = assembleFlow(p.Detail, p.InstanceRows, latest, latestAt) |
| if events, err := s.store.Events(acct.ID, app, overviewActivityCap); err != nil { |
| log.Printf("console: events %s: %v", app, err) |
| } else { |
| @@ -1953,7 +1967,7 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) { |
| // checkbox was ticked, the production flag — in a single bump. |
| s.bumpSerial(ctx) |
| if baking && release.HoldFor(release.DefaultHolds, channel) > 0 { |
| - msg += " It goes live on each machine after the safety delay — rollbacks skip it." |
| + msg += " It goes live everywhere after the safety delay — rollbacks skip it." |
| } |
| back("ok", msg) |
| } |
| @@ -2107,7 +2121,7 @@ func (s *Server) handleProduction(w http.ResponseWriter, r *http.Request) { |
| back("ok", "Safety delay enabled — new versions now wait on beta and stable before going live.") |
| } else { |
| s.audit(acct, app, addr, "production", "disabled the safety delay (production: off)") |
| - back("ok", "Safety delay turning off — takes effect on each machine after ~72h.") |
| + back("ok", "Safety delay turning off — takes effect after ~72h.") |
| } |
| } |
| @@ -2195,7 +2209,7 @@ func (s *Server) handleFastTrack(w http.ResponseWriter, r *http.Request) { |
| // A waiver re-signs the pointer to say "adopt this now" — the one write |
| // whose entire point is that boxes act on it immediately. |
| s.bumpSerial(ctx) |
| - back("ok", "Skipped the delay for "+ver+" on "+channel+" — machines pick it up within a couple of minutes.") |
| + back("ok", "Skipped the delay for "+ver+" on "+channel+" — it rolls out within a couple of minutes.") |
| } |
| // ---- canaries --------------------------------------------------------------- |
| @@ -2311,7 +2325,7 @@ func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) { |
| if enable != "" { |
| create = enable + " && " + create |
| } |
| - back("ok", "Created canary "+name+" — "+channel+" serves "+version+" the moment a machine follows it, no safety delay. Attach one: "+create) |
| + back("ok", "Created canary "+name+" — "+channel+" serves "+version+" the moment an address follows it, no safety delay. Attach one: "+create) |
| } |
| // instanceDomainFor answers "where do this app's instance hosts live?" — the |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index 71ad872ed..b8ff01e2c 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -534,7 +534,7 @@ func TestOverviewIsALadderNotAnEverythingPage(t *testing.T) { |
| t.Fatalf("overview still renders %q", gone) |
| } |
| } |
| - for _, want := range []string{`class="tiles stat-tiles"`, "Machines", "Web addresses", "Versions"} { |
| + for _, want := range []string{`class="tiles stat-tiles"`, "Running", "Web addresses", "Versions"} { |
| if !strings.Contains(body, want) { |
| t.Fatalf("overview missing %q", want) |
| } |
diff --git a/internal/console/templates/app_instances.html b/internal/console/templates/app_instances.html| index 62f390e41..2607731c1 100644 |
| --- a/internal/console/templates/app_instances.html |
| +++ b/internal/console/templates/app_instances.html |
| @@ -24,9 +24,9 @@ |
| {{end}} |
| </table> |
| </div> |
| -<p class="small">Version is what each machine reports it is running — a dash means |
| +<p class="small">Version is what each copy reports it is running — a dash means |
| it hasn't reported yet. Charts cover the last 48 hours; the memory line is drawn |
| -against that machine's own peak.</p> |
| +against that copy's own peak.</p> |
| {{else}} |
| <p>Nothing running yet. Publish a version, then give it a web address:</p> |
| {{if .AttachEnable}}<pre><code>{{.AttachEnable}} |
diff --git a/internal/console/templates/app_overview.html b/internal/console/templates/app_overview.html| index de5a45ea1..81689f40d 100644 |
| --- a/internal/console/templates/app_overview.html |
| +++ b/internal/console/templates/app_overview.html |
| @@ -26,16 +26,23 @@ |
| <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</a> tab.</p> |
| {{end}} |
| </section> |
| +{{end}} |
| +{{template "flow" .}} |
| +{{with .Overview}} |
| <section class="tiles stat-tiles" aria-label="Vital statistics"> |
| + {{/* "Running" answers health, not headcount: the count this tile used to |
| + show was byte-for-byte the Web addresses count beside it, under a name |
| + ("Machines") that also implied one computer per row when several rows |
| + routinely share a box. */}} |
| <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances"> |
| - <h2>Machines</h2> |
| - <p class="tile-value">{{.InstanceTotal}}</p> |
| - <p class="tile-detail">{{if eq .InstanceTotal 0}}none yet{{else if .MetricsUnavailable}}no data right now{{else if eq .InstanceExpected 0}}served by the platform{{else}}{{.InstanceReporting}} of {{.InstanceExpected}} processes responding{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{end}}</p> |
| + <h2>Running</h2> |
| + <p class="tile-value">{{if eq .InstanceTotal 0}}—{{else if eq .InstanceExpected 0}}Served{{else if .MetricsUnavailable}}?{{else}}{{.InstanceReporting}} of {{.InstanceExpected}}{{end}}</p> |
| + <p class="tile-detail">{{if eq .InstanceTotal 0}}nothing running yet{{else if eq .InstanceExpected 0}}static — the platform serves it, nothing to run{{else if .MetricsUnavailable}}no data right now{{else}}copies responding{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{end}}</p> |
| </a> |
| <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances"> |
| <h2>Resources</h2> |
| <p class="tile-value">{{pct .CPUSum}} · {{bytesOrDash .MemSum}}</p> |
| - <p class="tile-detail">{{if .CPUSum}}CPU · memory, from the machines responding{{else if .MetricsUnavailable}}no data right now{{else}}no recent data{{end}}</p> |
| + <p class="tile-detail">{{if .CPUSum}}CPU · memory, from the copies responding{{else if .MetricsUnavailable}}no data right now{{else}}no recent data{{end}}</p> |
| </a> |
| <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/settings"> |
| <h2>Web addresses</h2> |
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html| index 589f324fa..6c8074365 100644 |
| --- a/internal/console/templates/app_releases.html |
| +++ b/internal/console/templates/app_releases.html |
| @@ -110,7 +110,7 @@ carlos ship -app {{.Detail.App}} -kind static -version v1 <site-dir></code |
| {{end}} |
| </table> |
| </div> |
| -<p class="small">Go-live times are estimates — each machine keeps its own clock. Rollbacks skip the delay.</p> |
| +<p class="small">Go-live times are estimates — each copy keeps its own clock. Rollbacks skip the delay.</p> |
| {{else}}<p class="small">No channels yet — ship a release, then promote it below to create the first one.</p>{{end}} |
| <h2>Canaries</h2> |
| @@ -152,10 +152,10 @@ carlos ship -app {{.Detail.App}} -kind static -version v1 <site-dir></code |
| </select> |
| <button type="submit" class="btn-sm">Create canary</button> |
| </form> |
| -<p class="small">Creates <code>canary/<name></code> — a test version serving the chosen build immediately, no delay. It gets its own URL from a machine that follows it:</p> |
| +<p class="small">Creates <code>canary/<name></code> — a test version serving the chosen build immediately, no delay. It gets its own URL from an address that follows it:</p> |
| {{if .AttachEnable}}<pre><code>{{.AttachEnable}} |
| {{.AttachCmd}}</code></pre> |
| -<p class="small">This app has no machines yet, so the first line sets one up under this account's platform subdomain. It comes live within a couple of minutes.</p> |
| +<p class="small">This app has no addresses yet, so the first line sets one up under this account's platform subdomain. It comes live within a couple of minutes.</p> |
| {{else}}<pre><code>{{.AttachCmd}}</code></pre> |
| <p class="small">The address comes live within a couple of minutes.</p> |
| {{end}} |
diff --git a/internal/console/templates/flow.html b/internal/console/templates/flow.html| new file mode 100644 |
| index 000000000..0cdb1dac1 |
| --- /dev/null |
| +++ b/internal/console/templates/flow.html |
| @@ -0,0 +1,42 @@ |
| +{{define "flow"}}{{with .Flow}}{{if .Show}} |
| +<section class="flow" aria-label="How this app's versions reach the web"> |
| + <ol class="flow-line"> |
| + <li class="flow-stage flow-origin{{if .Shipped}} is-held{{end}}"> |
| + <p class="flow-label">you shipped</p> |
| + <p class="flow-version">{{if .Shipped}}<code>{{.Shipped}}</code>{{else}}nothing yet{{end}}</p> |
| + {{if not .ShippedAt.IsZero}}<p class="flow-note">{{ago .ShippedAt}}</p>{{end}} |
| + </li> |
| + {{range .Line}}{{template "flow_stage" .}}{{end}} |
| + </ol> |
| + {{if .Tests}} |
| + <p class="flow-band">Test versions — no delay, and they can point anywhere.</p> |
| + <ul class="flow-line flow-aside">{{range .Tests}}{{template "flow_stage" .}}{{end}}</ul> |
| + {{end}} |
| + {{if .Extra}} |
| + <p class="flow-band">Following a channel outside the release path.</p> |
| + <ul class="flow-line flow-aside">{{range .Extra}}{{template "flow_stage" .}}{{end}}</ul> |
| + {{end}} |
| + <p class="small flow-caption">A version moves one step at a time, left to right. Promoting is what moves it.</p> |
| +</section> |
| +{{end}}{{end}}{{end}} |
| + |
| +{{/* One box: a channel, what it holds, and the addresses it feeds. Used as an |
| + <li> in both the ordered release path and the unordered bands below it. */}} |
| +{{define "flow_stage"}} |
| +<li class="flow-stage{{if .Version}} is-held{{end}}{{if .Carried}} is-carried{{end}}"> |
| + <p class="flow-label">{{.Name}}</p> |
| + <p class="flow-version">{{if .Version}}<code>{{.Version}}</code>{{else}}nothing{{end}}</p> |
| + {{if .Hold}}<p class="flow-note">{{if .FastTrack}}delay skipped{{else}}waits {{.Hold}} here{{end}}</p>{{end}} |
| + {{if .Err}}<p class="flow-note error">{{.Err}}</p>{{end}} |
| + {{if .Addresses}} |
| + <ul class="flow-addrs"> |
| + {{range .Addresses}} |
| + <li class="flow-addr flow-tone-{{.Tone}}"> |
| + <a href="https://{{.Host}}">{{.Host}}</a> |
| + <span class="flow-health">{{if eq .Tone "bad"}}{{icon "triangle-alert"}}{{end}}{{.Health}}</span> |
| + </li> |
| + {{end}} |
| + </ul> |
| + {{end}} |
| +</li> |
| +{{end}} |
diff --git a/internal/console/templates/ladder.html b/internal/console/templates/ladder.html| index 4a57c86be..5adf19d0f 100644 |
| --- a/internal/console/templates/ladder.html |
| +++ b/internal/console/templates/ladder.html |
| @@ -1,7 +1,7 @@ |
| {{define "ladder"}} |
| <h2>The ladder</h2> |
| <p class="small">Every version climbs the same steps — canary (test) → edge → beta → stable (live). |
| -Promoting moves it one step up; the machines following that step pick it up.</p> |
| +Promoting moves it one step up; whatever follows that step picks it up.</p> |
| {{with .Detail.Production}} |
| <div class="bake"> |
| {{if .Err}} |
| @@ -16,7 +16,7 @@ Promoting moves it one step up; the machines following that step pick it up.</p> |
| {{end}} |
| </p> |
| {{else if .On}} |
| - <p class="small">Safety delay: <strong>on</strong> — new versions wait ~24h on beta and ~72h on live before machines pick them up. |
| + <p class="small">Safety delay: <strong>on</strong> — new versions wait ~24h on beta and ~72h on live before they go live. |
| {{if and $.CanPromote $.IsOwner}} |
| <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/production" class="inline"> |
| <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| @@ -88,7 +88,7 @@ Promoting moves it one step up; the machines following that step pick it up.</p> |
| {{end}} |
| </ul> |
| {{else}} |
| - <p class="small">No machine follows this channel.</p> |
| + <p class="small">Nothing follows this channel yet.</p> |
| {{end}} |
| </div> |
| {{end}} |
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html| index 820d2c96a..99cba569c 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -189,6 +189,73 @@ |
| .chip-up { background: var(--accent-soft); color: #2f7a4f; } |
| .chip-down { background: var(--accent-soft); color: var(--bad); } |
| .chips { display: flex; flex-wrap: wrap; margin: 0 0 1rem; } |
| + /* The app flow diagram (flow.go): the release path drawn as the pipeline it |
| + is, with the addresses each channel feeds hanging under it. |
| + |
| + The one claim the drawing makes beyond its own words is the ARROW: solid |
| + and accented where a version really did travel that step, dashed and grey |
| + where it did not. That is what lets "how far has my build got" be seen |
| + rather than worked out by comparing three version strings, so the two |
| + states have to stay visually unmistakable — and distinguishable by more |
| + than colour, hence solid vs dashed. */ |
| + .flow { margin: 0 0 1.75rem; } |
| + .flow-line { |
| + --flow-y: 0.52rem; |
| + display: flex; flex-wrap: wrap; gap: 1.1rem 2.6rem; |
| + list-style: none; margin: 0; padding: 0; |
| + } |
| + .flow-stage { position: relative; flex: 1 1 10rem; min-width: 9rem; } |
| + .flow-stage + .flow-stage::before, .flow-stage + .flow-stage::after { |
| + content: ""; position: absolute; pointer-events: none; |
| + } |
| + .flow-stage + .flow-stage::before { |
| + left: -2.05rem; top: var(--flow-y); width: 1.3rem; border-top: 1px dashed var(--rule); |
| + } |
| + .flow-stage + .flow-stage::after { |
| + left: -0.82rem; top: var(--flow-y); transform: translateY(-50%); |
| + border: 0.28rem solid transparent; border-right: 0; border-left-color: var(--rule); |
| + } |
| + .flow-stage.is-carried::before { border-top-style: solid; border-top-color: var(--accent); } |
| + .flow-stage.is-carried::after { border-left-color: var(--accent); } |
| + .flow-label { |
| + margin: 0; font-family: var(--mono); font-size: 0.74rem; text-transform: uppercase; |
| + letter-spacing: 0.05em; color: var(--muted); font-weight: 600; |
| + } |
| + .flow-version { margin: 0.3rem 0 0; font-size: 0.95rem; } |
| + /* An empty box states its emptiness in words, so it needs no fill of its |
| + own — the missing version chip is the signal. */ |
| + .flow-stage:not(.is-held) .flow-version { color: var(--muted); } |
| + .flow-note { margin: 0.25rem 0 0; font-size: 0.78rem; color: var(--muted); } |
| + .flow-addrs { |
| + list-style: none; margin: 0.6rem 0 0; padding: 0 0 0 0.7rem; |
| + border-left: 1px solid var(--rule); display: grid; gap: 0.35rem; |
| + } |
| + .flow-addr { font-size: 0.82rem; line-height: 1.35; } |
| + .flow-addr a { display: block; overflow-wrap: anywhere; } |
| + .flow-health { display: inline-flex; align-items: center; gap: 0.2rem; color: var(--muted); } |
| + .flow-health .icon { width: 0.9em; height: 0.9em; flex: none; } |
| + .flow-tone-ok .flow-health { color: var(--good); } |
| + .flow-tone-wait .flow-health { color: var(--warn, #a6790f); } |
| + .flow-tone-bad .flow-health { color: var(--bad); font-weight: 600; } |
| + .flow-band { margin: 1.4rem 0 0.7rem; font-size: 0.9rem; color: var(--muted); } |
| + .flow-caption { margin: 1.1rem 0 0; } |
| + /* Narrow screens: the same path, turned down the page. The connector turns |
| + with it — an arrow still pointing the way the version travels. */ |
| + @media (max-width: 46rem) { |
| + .flow-line { display: block; } |
| + .flow-stage + .flow-stage { margin-top: 1.6rem; } |
| + .flow-stage + .flow-stage::before { |
| + left: 0.55rem; top: -1.2rem; width: 0; height: 0.85rem; |
| + border-top: 0; border-left: 1px dashed var(--rule); |
| + } |
| + .flow-stage + .flow-stage::after { |
| + left: 0.55rem; top: -0.34rem; transform: translateX(-50%); |
| + border: 0.28rem solid transparent; border-bottom: 0; border-top-color: var(--rule); |
| + border-left-color: transparent; |
| + } |
| + .flow-stage.is-carried::before { border-left-color: var(--accent); } |
| + .flow-stage.is-carried::after { border-top-color: var(--accent); border-left-color: transparent; } |
| + } |
| /* The release ladder: four rungs drawn top-down in the order the spec |
| draws them (canary first, stable at the foot), with the promotion |
| between two of them rendered as the step it is. list-style is off |
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) |
| + } |
| +} |