feat: console slice 2 — claim apps, promote, rollback from the browser
Claim-once app names (CLI-granted apps stay claim-proof); per-version promote to existing or new channels with bake-window notes; per-channel rollback; keyless deployments degrade to read-only. CSRF throughout; owner-gating identical to 404.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
10 files changed,
+734
−42
cmd/console/main.go+16 −2internal/console/server.go+228 −14internal/console/server_test.go+240 −8internal/console/store.go+34 −0internal/console/store_test.go+29 −0internal/console/templates/app.html+48 −2internal/console/templates/apps.html+10 −2internal/console/templates/layout.html+12 −5internal/console/view.go+60 −1internal/console/view_test.go+57 −8
diff --git a/cmd/console/main.go b/cmd/console/main.go| index c009273ba..9bade7513 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -14,12 +14,14 @@ |
| // /data/console/console.db), CONSOLE_ORIGIN (required, e.g. |
| // https://platform.carlosframework.com), CONSOLE_KEYMAIL (default |
| // https://keymail.dev), CARLOS_REGISTRY (default /data/registry.db), |
| -// CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR, CARLOS_RELEASE_PUBKEY. |
| -// grant/revoke/owners need only CONSOLE_DB. |
| +// CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR, CARLOS_RELEASE_PUBKEY, |
| +// and optionally CARLOS_RELEASE_KEY (enables promote/rollback from the |
| +// console; unset = read-only). grant/revoke/owners need only CONSOLE_DB. |
| package main |
| import ( |
| "context" |
| + "crypto/ed25519" |
| "fmt" |
| "log" |
| "net/http" |
| @@ -85,6 +87,17 @@ func serve() { |
| if err != nil { |
| log.Fatal(err) |
| } |
| + // The signing key is optional: without it the console runs read-only and |
| + // the promote/rollback surface is disabled. Never logged, never rendered. |
| + var key ed25519.PrivateKey |
| + if os.Getenv("CARLOS_RELEASE_KEY") != "" { |
| + key, err = release.ParseSigningKey(os.Getenv("CARLOS_RELEASE_KEY"), "CARLOS_RELEASE_KEY") |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + } else { |
| + log.Print("console: CARLOS_RELEASE_KEY unset — promotions disabled, running read-only") |
| + } |
| st := openConsoleDB() |
| srv, err := console.NewServer(console.Config{ |
| Origin: origin, |
| @@ -92,6 +105,7 @@ func serve() { |
| Registry: envOr("CARLOS_REGISTRY", "/data/registry.db"), |
| Releases: openStore(), |
| ReleasePub: pub, |
| + Key: key, |
| }, st) |
| if err != nil { |
| log.Fatal(err) |
diff --git a/internal/console/server.go b/internal/console/server.go| index 10f103873..0ecc52387 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -43,6 +43,10 @@ type Config struct { |
| Registry string // path to the platform registry DB (read-only here) |
| Releases release.Store // the deployment bucket |
| ReleasePub ed25519.PublicKey // pinned channel-pointer verification key |
| + // Key signs channel pointers for promote/rollback (CARLOS_RELEASE_KEY). |
| + // nil is a legal deployment: the console runs read-only, the promotion |
| + // UI renders disabled, and the mutation handlers refuse with 503. |
| + Key ed25519.PrivateKey |
| } |
| // Server is the console's http.Handler. |
| @@ -78,7 +82,10 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("GET /auth/confirm", s.handleConfirm) |
| s.mux.HandleFunc("POST /auth/signout", s.handleSignout) |
| s.mux.HandleFunc("GET /apps", s.handleApps) |
| + s.mux.HandleFunc("POST /apps/new", s.handleNewApp) |
| s.mux.HandleFunc("GET /apps/{app}", s.handleApp) |
| + s.mux.HandleFunc("POST /apps/{app}/promote", s.handlePromote) |
| + s.mux.HandleFunc("POST /apps/{app}/rollback", s.handleRollback) |
| s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { |
| fmt.Fprintln(w, "ok") |
| }) |
| @@ -102,8 +109,13 @@ type page struct { |
| Msg string // generic message page body |
| - Apps []appRow // /apps |
| - Detail *AppView // /apps/{app} |
| + Flash string // one-shot success notice (cookie-carried across a redirect) |
| + FlashErr string // one-shot error notice |
| + |
| + Apps []appRow // /apps |
| + Detail *AppView // /apps/{app} |
| + Versions []VersionView // /apps/{app}: shipped releases, newest first |
| + CanPromote bool // a signing key is configured on this host |
| } |
| // appRow is one line of the "your apps" table. |
| @@ -167,6 +179,35 @@ func (s *Server) clearCookie(w http.ResponseWriter, name string) { |
| s.setCookie(w, name, "", -1) |
| } |
| +// ---- flash notices ---------------------------------------------------------- |
| + |
| +const flashCookie = "carlos_console_flash" |
| + |
| +// setFlash parks a one-shot notice ("ok" or "err") in a cookie for the page |
| +// after the redirect — no query-string flashes an outsider could spoof into |
| +// a link. |
| +func (s *Server) setFlash(w http.ResponseWriter, kind, msg string) { |
| + s.setCookie(w, flashCookie, url.QueryEscape(kind+"|"+msg), 300) |
| +} |
| + |
| +// takeFlash reads and clears the notice. |
| +func (s *Server) takeFlash(w http.ResponseWriter, r *http.Request) (flash, flashErr string) { |
| + c, err := r.Cookie(flashCookie) |
| + if err != nil || c.Value == "" { |
| + return "", "" |
| + } |
| + s.clearCookie(w, flashCookie) |
| + v, err := url.QueryUnescape(c.Value) |
| + if err != nil { |
| + return "", "" |
| + } |
| + kind, msg, _ := strings.Cut(v, "|") |
| + if kind == "err" { |
| + return "", msg |
| + } |
| + return msg, "" |
| +} |
| + |
| // ---- auth handlers ---------------------------------------------------------- |
| func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { |
| @@ -359,6 +400,7 @@ func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) { |
| p.Welcome = true |
| s.clearCookie(w, welcomeCookie) |
| } |
| + p.Flash, p.FlashErr = s.takeFlash(w, r) |
| apps, err := s.store.AppsFor(addr) |
| if err != nil { |
| s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.") |
| @@ -378,25 +420,33 @@ func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) { |
| s.render(w, http.StatusOK, "apps", p) |
| } |
| -func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) { |
| - addr, raw, ok := s.session(r) |
| - if !ok { |
| +// ownedApp gates the per-app pages and mutations: session required, and the |
| +// path's app must be owned by the signed-in address — not owned and |
| +// nonexistent look identical (404) on purpose. |
| +func (s *Server) ownedApp(w http.ResponseWriter, r *http.Request) (addr, raw, app string, ok bool) { |
| + addr, raw, sok := s.session(r) |
| + if !sok { |
| http.Redirect(w, r, "/", http.StatusFound) |
| - return |
| + return "", "", "", false |
| } |
| - app := r.PathValue("app") |
| + app = r.PathValue("app") |
| apps, err := s.store.AppsFor(addr) |
| if err != nil { |
| s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.") |
| - return |
| + return "", "", "", false |
| } |
| - owned := false |
| for _, a := range apps { |
| - owned = owned || a == app |
| + if a == app { |
| + return addr, raw, app, true |
| + } |
| } |
| - if !owned { |
| - // Not owned and nonexistent look identical on purpose. |
| - s.renderMessage(w, r, http.StatusNotFound, "Not found", "No such app here.") |
| + s.renderMessage(w, r, http.StatusNotFound, "Not found", "No such app here.") |
| + return "", "", "", false |
| +} |
| + |
| +func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, app, ok := s.ownedApp(w, r) |
| + if !ok { |
| return |
| } |
| views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, []string{app}) |
| @@ -405,10 +455,174 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) { |
| s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.") |
| return |
| } |
| - p := page{Title: app, Address: addr, CSRF: raw, Detail: &views[0]} |
| + p := page{Title: app, Address: addr, CSRF: raw, Detail: &views[0], CanPromote: s.cfg.Key != nil} |
| + p.Flash, p.FlashErr = s.takeFlash(w, r) |
| + versions, err := Versions(s.cfg.Releases, app) |
| + if err != nil { |
| + log.Printf("console: versions %s: %v", app, err) |
| + p.FlashErr = "Could not list this app's releases." |
| + } |
| + p.Versions = versions |
| s.render(w, http.StatusOK, "app", p) |
| } |
| +// handleNewApp claims an app name — once, ever. A name is refused when the |
| +// apps table has it, when ANY owner exists for it (CLI-granted apps stay |
| +// claim-proof), or when the release store already holds anything under |
| +// apps/<name>/. |
| +func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, ok := s.session(r) |
| + if !ok { |
| + http.Redirect(w, r, "/", http.StatusFound) |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + fail := func(msg string) { |
| + s.setFlash(w, "err", msg) |
| + http.Redirect(w, r, "/apps", http.StatusSeeOther) |
| + } |
| + name := strings.ToLower(strings.TrimSpace(r.FormValue("name"))) |
| + if !release.ValidApp(name) { |
| + fail("Invalid app name — lowercase letters, digits, and dashes, starting with a letter.") |
| + return |
| + } |
| + if has, err := s.store.HasApp(name); err != nil { |
| + fail("Could not check that name. Please try again.") |
| + return |
| + } else if has { |
| + fail("That name is taken.") |
| + return |
| + } |
| + if has, err := s.store.HasOwners(name); err != nil { |
| + fail("Could not check that name. Please try again.") |
| + return |
| + } else if has { |
| + fail("That name is taken.") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| + defer cancel() |
| + if keys, err := s.cfg.Releases.List(ctx, "apps/"+name+"/"); err != nil { |
| + fail("Could not check the release store. Please try again.") |
| + return |
| + } else if len(keys) > 0 { |
| + fail("That name is taken.") |
| + return |
| + } |
| + created, err := s.store.CreateApp(name, addr, time.Now()) |
| + if err != nil { |
| + fail("Could not create the app. Please try again.") |
| + return |
| + } |
| + if !created { // lost a claim race after the checks |
| + fail("That name is taken.") |
| + return |
| + } |
| + if err := s.store.Grant(addr, name); err != nil { |
| + fail("Created the app, but granting you ownership failed — ask an operator.") |
| + return |
| + } |
| + s.setFlash(w, "ok", "Created app "+name+" — it's yours.") |
| + http.Redirect(w, r, "/apps/"+url.PathEscape(name), http.StatusSeeOther) |
| +} |
| + |
| +// handlePromote points a channel at a shipped version, from the app page. |
| +func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) { |
| + _, raw, app, ok := s.ownedApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + if s.cfg.Key == nil { |
| + s.renderMessage(w, r, http.StatusServiceUnavailable, "Promotions disabled", |
| + "No signing key on this host — the console is running read-only.") |
| + return |
| + } |
| + back := func(kind, msg string) { |
| + s.setFlash(w, kind, msg) |
| + http.Redirect(w, r, "/apps/"+url.PathEscape(app), http.StatusSeeOther) |
| + } |
| + channel := strings.TrimSpace(r.FormValue("new_channel")) |
| + if channel == "" { |
| + channel = strings.TrimSpace(r.FormValue("channel")) |
| + } |
| + if !release.ValidChannel(channel) { |
| + back("err", "Invalid channel name.") |
| + return |
| + } |
| + version := strings.TrimSpace(r.FormValue("version")) |
| + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| + defer cancel() |
| + if !release.ValidVersion(version) { |
| + back("err", "Invalid version.") |
| + return |
| + } |
| + if _, err := release.GetManifest(ctx, s.cfg.Releases, app, version); err != nil { |
| + back("err", "Version "+version+" is not a shipped release of "+app+".") |
| + return |
| + } |
| + if err := release.Promote(ctx, s.cfg.Releases, s.cfg.Key, app, channel, version); err != nil { |
| + log.Printf("console: promote %s %s -> %s: %v", app, version, channel, err) |
| + back("err", "Promote failed: "+err.Error()) |
| + return |
| + } |
| + msg := "Promoted " + version + " to " + channel + "." |
| + if release.HoldFor(release.DefaultHolds, channel) > 0 { |
| + msg += " Adopts after the bake window on each host — rollback never bakes." |
| + } |
| + back("ok", msg) |
| +} |
| + |
| +// handleRollback swaps a channel back to its previous version. |
| +func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) { |
| + _, raw, app, ok := s.ownedApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + if s.cfg.Key == nil { |
| + s.renderMessage(w, r, http.StatusServiceUnavailable, "Promotions disabled", |
| + "No signing key on this host — the console is running read-only.") |
| + return |
| + } |
| + back := func(kind, msg string) { |
| + s.setFlash(w, kind, msg) |
| + http.Redirect(w, r, "/apps/"+url.PathEscape(app), http.StatusSeeOther) |
| + } |
| + channel := strings.TrimSpace(r.FormValue("channel")) |
| + if !release.ValidChannel(channel) { |
| + back("err", "Invalid channel name.") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| + defer cancel() |
| + cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, app, channel) |
| + if err != nil { |
| + back("err", "Could not read channel "+channel+".") |
| + return |
| + } |
| + if len(cur.History) == 0 { |
| + back("err", "Channel "+channel+" has no previous version to roll back to.") |
| + return |
| + } |
| + target := cur.History[0] |
| + if err := release.Rollback(ctx, s.cfg.Releases, s.cfg.Key, app, channel); err != nil { |
| + log.Printf("console: rollback %s %s: %v", app, channel, err) |
| + back("err", "Rollback failed: "+err.Error()) |
| + return |
| + } |
| + back("ok", "Rolled back "+channel+" to "+target+".") |
| +} |
| + |
| // summarize folds one AppView into its "your apps" table row. |
| func summarize(v AppView) appRow { |
| row := appRow{App: v.App, Hosts: len(v.Routes)} |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index 05dd9e659..a878c5c78 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -1,6 +1,8 @@ |
| package console |
| import ( |
| + "context" |
| + "crypto/ed25519" |
| "io" |
| "net/http" |
| "net/http/cookiejar" |
| @@ -10,20 +12,31 @@ import ( |
| "regexp" |
| "strings" |
| "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| // consoleFixture is a whole console: fixture platform (registry + bucket |
| // from view_test.go), console store, fake keymail, and the server under an |
| // httptest listener whose URL is its own OAuth origin. |
| type consoleFixture struct { |
| - ts *httptest.Server |
| - st *Store |
| - fk *fakeKeymail |
| + ts *httptest.Server |
| + st *Store |
| + fk *fakeKeymail |
| + rel release.Store |
| + priv ed25519.PrivateKey |
| + pub ed25519.PublicKey |
| } |
| func newConsoleFixture(t *testing.T) *consoleFixture { |
| + return newConsoleFixtureKeyed(t, true) |
| +} |
| + |
| +// newConsoleFixtureKeyed builds the fixture with or without the channel |
| +// signing key (withKey=false is the read-only deployment shape). |
| +func newConsoleFixtureKeyed(t *testing.T, withKey bool) *consoleFixture { |
| t.Helper() |
| - regPath, relStore, pub := buildFixtures(t) |
| + regPath, relStore, priv, pub := buildFixtures(t) |
| st, err := Open(filepath.Join(t.TempDir(), "console.db")) |
| if err != nil { |
| t.Fatal(err) |
| @@ -37,20 +50,34 @@ func newConsoleFixture(t *testing.T) *consoleFixture { |
| srv.ServeHTTP(w, r) |
| })) |
| t.Cleanup(ts.Close) |
| - srv, err = NewServer(Config{ |
| + cfg := Config{ |
| Origin: ts.URL, |
| Keymail: fk.srv.URL, |
| Registry: regPath, |
| Releases: relStore, |
| ReleasePub: pub, |
| - }, st) |
| + } |
| + if withKey { |
| + cfg.Key = priv |
| + } |
| + srv, err = NewServer(cfg, st) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if err := st.Grant("paul@keymail.dev", "kass"); err != nil { |
| t.Fatal(err) |
| } |
| - return &consoleFixture{ts: ts, st: st, fk: fk} |
| + return &consoleFixture{ts: ts, st: st, fk: fk, rel: relStore, priv: priv, pub: pub} |
| +} |
| + |
| +// channelVersion reads a channel pointer straight from the bucket. |
| +func (fx *consoleFixture) channelVersion(t *testing.T, app, channel string) *release.ChannelPointer { |
| + t.Helper() |
| + p, err := release.Channel(context.Background(), fx.rel, fx.pub, app, channel) |
| + if err != nil { |
| + t.Fatalf("channel %s/%s: %v", app, channel, err) |
| + } |
| + return p |
| } |
| func (fx *consoleFixture) setUser(address string) { |
| @@ -320,7 +347,7 @@ func TestEmptyStateWelcomesNewUser(t *testing.T) { |
| } |
| for _, want := range []string{ |
| "Account created", "Welcome to the CARLOS platform", |
| - "bob@keymail.dev", "coming soon", |
| + "bob@keymail.dev", "Create app", `action="/apps/new"`, |
| } { |
| if !strings.Contains(body, want) { |
| t.Fatalf("empty state missing %q in:\n%s", want, body) |
| @@ -330,3 +357,208 @@ func TestEmptyStateWelcomesNewUser(t *testing.T) { |
| t.Fatal("empty state leaked someone else's apps") |
| } |
| } |
| + |
| +// signInPaul signs a fresh browser in as the fixture's kass owner. |
| +func signInPaul(t *testing.T, fx *consoleFixture) *http.Client { |
| + t.Helper() |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-"+t.Name()) |
| + return c |
| +} |
| + |
| +func TestCreateApp(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + // Happy path: claim, own, land on the ship-your-first-release panel. |
| + status, loc, _ := postForm(t, c, fx.ts.URL+"/apps/new", url.Values{"csrf": {csrf}, "name": {"zephyr"}}) |
| + if status != http.StatusSeeOther || loc != "/apps/zephyr" { |
| + t.Fatalf("claim = %d -> %q, want 303 -> /apps/zephyr", status, loc) |
| + } |
| + status, _, body := get(t, c, fx.ts.URL+"/apps/zephyr") |
| + if status != 200 { |
| + t.Fatalf("/apps/zephyr = %d", status) |
| + } |
| + for _, want := range []string{ |
| + "Created app zephyr", "Nothing shipped yet", |
| + "carlos ship -app zephyr -version $(git rev-parse --short HEAD) -target linux-arm64", |
| + "carlos ship -app zephyr -kind static -version v1", |
| + "promoted to channels from this page", |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("/apps/zephyr missing %q in:\n%s", want, body) |
| + } |
| + } |
| + apps, err := fx.st.AppsFor("paul@keymail.dev") |
| + if err != nil || !strings.Contains(strings.Join(apps, " "), "zephyr") { |
| + t.Fatalf("AppsFor = %v %v, want zephyr owned", apps, err) |
| + } |
| + |
| + // A second user cannot claim the same name, a CLI-granted app, or an |
| + // app with releases already in the bucket — all the same "taken". |
| + if err := release.Ship(context.Background(), fx.rel, release.Manifest{ |
| + Schema: 1, App: "orphan", Version: "v1", Kind: "binary", |
| + Artifacts: map[string]release.Artifact{"linux-amd64": {File: "orphan"}}, |
| + }, map[string][]byte{"linux-amd64": []byte("bin")}); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.setUser("bob@keymail.dev") |
| + cb := newBrowser(t) |
| + signUp(t, fx, cb, "code-bob") |
| + bobCSRF := sessionToken(t, fx, cb) |
| + for _, name := range []string{"zephyr", "kass", "orphan"} { |
| + if status, loc, _ := postForm(t, cb, fx.ts.URL+"/apps/new", url.Values{"csrf": {bobCSRF}, "name": {name}}); status != http.StatusSeeOther || loc != "/apps" { |
| + t.Fatalf("claim %s = %d -> %q, want 303 -> /apps", name, status, loc) |
| + } |
| + if _, _, body := get(t, cb, fx.ts.URL+"/apps"); !strings.Contains(body, "That name is taken.") { |
| + t.Fatalf("claim %s: no taken flash", name) |
| + } |
| + if apps, _ := fx.st.AppsFor("bob@keymail.dev"); len(apps) != 0 { |
| + t.Fatalf("bob owns %v after refused claim of %s", apps, name) |
| + } |
| + } |
| + |
| + // Invalid names refused; CSRF enforced. |
| + if status, _, _ := postForm(t, cb, fx.ts.URL+"/apps/new", url.Values{"csrf": {bobCSRF}, "name": {"Bad Name!"}}); status != http.StatusSeeOther { |
| + t.Fatalf("invalid name = %d, want redirect with flash", status) |
| + } |
| + if _, _, body := get(t, cb, fx.ts.URL+"/apps"); !strings.Contains(body, "Invalid app name") { |
| + t.Fatal("no invalid-name flash") |
| + } |
| + if status, _, _ := postForm(t, cb, fx.ts.URL+"/apps/new", url.Values{"csrf": {"wrong"}, "name": {"fine-name"}}); status != http.StatusForbidden { |
| + t.Fatalf("bad csrf claim = %d, want 403", status) |
| + } |
| +} |
| + |
| +func TestPromoteFlow(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + // The app page offers promote forms per shipped version. |
| + _, _, body := get(t, c, fx.ts.URL+"/apps/kass") |
| + if !strings.Contains(body, `action="/apps/kass/promote"`) { |
| + t.Fatal("no promote form on the app page") |
| + } |
| + if strings.Contains(body, "Promotions disabled") { |
| + t.Fatal("keyed fixture rendered the disabled note") |
| + } |
| + |
| + // Promote v2 to stable (select field). |
| + status, loc, _ := postForm(t, c, fx.ts.URL+"/apps/kass/promote", |
| + url.Values{"csrf": {csrf}, "version": {"v2"}, "channel": {"stable"}}) |
| + if status != http.StatusSeeOther || loc != "/apps/kass" { |
| + t.Fatalf("promote = %d -> %q", status, loc) |
| + } |
| + if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v2" || len(p.History) == 0 || p.History[0] != "v1" { |
| + t.Fatalf("stable pointer = %+v, want v2 with v1 in history", p) |
| + } |
| + _, _, body = get(t, c, fx.ts.URL+"/apps/kass") |
| + if !strings.Contains(body, "Promoted v2 to stable.") || |
| + !strings.Contains(body, "bake window") || !strings.Contains(body, "rollback never bakes") { |
| + t.Fatalf("promote flash missing bake note:\n%s", body) |
| + } |
| + |
| + // A brand-new channel via the free-text field; beta also bakes. |
| + postForm(t, c, fx.ts.URL+"/apps/kass/promote", |
| + url.Values{"csrf": {csrf}, "version": {"v1"}, "channel": {"stable"}, "new_channel": {"beta"}}) |
| + if p := fx.channelVersion(t, "kass", "beta"); p.Version != "v1" { |
| + t.Fatalf("beta pointer = %+v, want v1", p) |
| + } |
| + if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "Promoted v1 to beta.") { |
| + t.Fatal("no beta promote flash") |
| + } |
| + |
| + // Unshipped version: refused, pointer unmoved. |
| + postForm(t, c, fx.ts.URL+"/apps/kass/promote", |
| + url.Values{"csrf": {csrf}, "version": {"v9"}, "channel": {"stable"}}) |
| + if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "not a shipped release") { |
| + t.Fatal("no bad-version flash") |
| + } |
| + if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v2" { |
| + t.Fatalf("stable moved to %s on a refused promote", p.Version) |
| + } |
| + |
| + // Invalid channel name refused. |
| + postForm(t, c, fx.ts.URL+"/apps/kass/promote", |
| + url.Values{"csrf": {csrf}, "version": {"v1"}, "new_channel": {"bad//name"}}) |
| + if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "Invalid channel name.") { |
| + t.Fatal("no invalid-channel flash") |
| + } |
| + |
| + // CSRF enforced; non-owners see the same 404 as everyone else. |
| + if status, _, _ := postForm(t, c, fx.ts.URL+"/apps/kass/promote", |
| + url.Values{"csrf": {"wrong"}, "version": {"v1"}, "channel": {"stable"}}); status != http.StatusForbidden { |
| + t.Fatalf("bad csrf promote = %d, want 403", status) |
| + } |
| + fx.setUser("bob@keymail.dev") |
| + cb := newBrowser(t) |
| + signUp(t, fx, cb, "code-bob-p") |
| + if status, _, _ := postForm(t, cb, fx.ts.URL+"/apps/kass/promote", |
| + url.Values{"csrf": {sessionToken(t, fx, cb)}, "version": {"v2"}, "channel": {"stable"}}); status != http.StatusNotFound { |
| + t.Fatalf("non-owner promote = %d, want 404", status) |
| + } |
| +} |
| + |
| +func TestRollbackFlow(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + // Fixture stable is v1 with no history: no rollback button for it (nor |
| + // for the single-promote canary), and a forced POST politely refuses. |
| + _, _, body := get(t, c, fx.ts.URL+"/apps/kass") |
| + if strings.Contains(body, `name="channel" value="stable"`) || strings.Contains(body, `name="channel" value="canary/x"`) { |
| + t.Fatal("rollback button rendered for a channel with no history") |
| + } |
| + postForm(t, c, fx.ts.URL+"/apps/kass/rollback", url.Values{"csrf": {csrf}, "channel": {"canary/x"}}) |
| + if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "no previous version") { |
| + t.Fatal("no empty-history rollback flash") |
| + } |
| + |
| + // Promote v2 over v1, then roll back: pointer returns to v1 and v2 |
| + // becomes the (re-)rollback target. |
| + postForm(t, c, fx.ts.URL+"/apps/kass/promote", |
| + url.Values{"csrf": {csrf}, "version": {"v2"}, "channel": {"stable"}}) |
| + if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, `name="channel" value="stable"`) { |
| + t.Fatal("no rollback button after stable gained history") |
| + } |
| + status, loc, _ := postForm(t, c, fx.ts.URL+"/apps/kass/rollback", |
| + url.Values{"csrf": {csrf}, "channel": {"stable"}}) |
| + if status != http.StatusSeeOther || loc != "/apps/kass" { |
| + t.Fatalf("rollback = %d -> %q", status, loc) |
| + } |
| + p := fx.channelVersion(t, "kass", "stable") |
| + if p.Version != "v1" || len(p.History) == 0 || p.History[0] != "v2" { |
| + t.Fatalf("after rollback stable = %+v, want v1 with v2 first in history", p) |
| + } |
| + if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "Rolled back stable to v1.") { |
| + t.Fatal("no rollback flash") |
| + } |
| +} |
| + |
| +func TestPromotionsDisabledWithoutKey(t *testing.T) { |
| + fx := newConsoleFixtureKeyed(t, false) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + _, _, body := get(t, c, fx.ts.URL+"/apps/kass") |
| + if !strings.Contains(body, "Promotions disabled: no signing key on this host.") { |
| + t.Fatal("keyless console is missing the disabled note") |
| + } |
| + if strings.Contains(body, `action="/apps/kass/promote"`) || strings.Contains(body, `action="/apps/kass/rollback"`) { |
| + t.Fatal("keyless console rendered mutation forms") |
| + } |
| + if status, _, _ := postForm(t, c, fx.ts.URL+"/apps/kass/promote", |
| + url.Values{"csrf": {csrf}, "version": {"v2"}, "channel": {"stable"}}); status != http.StatusServiceUnavailable { |
| + t.Fatalf("keyless promote = %d, want 503", status) |
| + } |
| + if status, _, _ := postForm(t, c, fx.ts.URL+"/apps/kass/rollback", |
| + url.Values{"csrf": {csrf}, "channel": {"stable"}}); status != http.StatusServiceUnavailable { |
| + t.Fatalf("keyless rollback = %d, want 503", status) |
| + } |
| + if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v1" { |
| + t.Fatalf("keyless console moved a pointer to %s", p.Version) |
| + } |
| +} |
diff --git a/internal/console/store.go b/internal/console/store.go| index cd7211f89..6cbfcbe2f 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -112,6 +112,11 @@ func (s *Store) migrate() error { |
| name TEXT PRIMARY KEY, |
| pkcs8 TEXT NOT NULL, |
| created_at INTEGER NOT NULL |
| +);`, |
| + `CREATE TABLE IF NOT EXISTS apps ( |
| + app TEXT PRIMARY KEY, |
| + created_by TEXT NOT NULL, |
| + created_at INTEGER NOT NULL |
| );`, |
| } { |
| if _, err := s.db.Exec(stmt); err != nil { |
| @@ -179,6 +184,35 @@ func (s *Store) Owners() (map[string][]string, error) { |
| return out, rows.Err() |
| } |
| +// ---- apps (claim-once names) ------------------------------------------------ |
| + |
| +// CreateApp claims an app name, once. created is false when the name was |
| +// already claimed — the caller treats that exactly like every other "taken". |
| +func (s *Store) CreateApp(app, createdBy string, now time.Time) (created bool, err error) { |
| + res, err := s.db.Exec(`INSERT INTO apps (app, created_by, created_at) VALUES (?, ?, ?) |
| +ON CONFLICT(app) DO NOTHING`, app, createdBy, now.Unix()) |
| + if err != nil { |
| + return false, err |
| + } |
| + n, err := res.RowsAffected() |
| + return n > 0, err |
| +} |
| + |
| +// HasApp reports whether an app name is claimed in the apps table. |
| +func (s *Store) HasApp(app string) (bool, error) { |
| + var n int |
| + err := s.db.QueryRow(`SELECT COUNT(*) FROM apps WHERE app = ?`, app).Scan(&n) |
| + return n > 0, err |
| +} |
| + |
| +// HasOwners reports whether ANY owner exists for app — CLI-granted apps |
| +// predate the apps table and must stay claim-proof. |
| +func (s *Store) HasOwners(app string) (bool, error) { |
| + var n int |
| + err := s.db.QueryRow(`SELECT COUNT(*) FROM owners WHERE app = ?`, app).Scan(&n) |
| + return n > 0, err |
| +} |
| + |
| // ---- users ------------------------------------------------------------------ |
| // EnsureUser records address as a console user if it isn't one yet. created |
diff --git a/internal/console/store_test.go b/internal/console/store_test.go| index f91d4726e..6ec1a8e0d 100644 |
| --- a/internal/console/store_test.go |
| +++ b/internal/console/store_test.go |
| @@ -226,3 +226,32 @@ func TestReopenMigrationIdempotent(t *testing.T) { |
| t.Fatalf("after reopen AppsFor = %v %v", apps, err) |
| } |
| } |
| + |
| +func TestCreateAppClaimOnce(t *testing.T) { |
| + s := openTestStore(t) |
| + now := time.Now() |
| + created, err := s.CreateApp("zephyr", "paul@keymail.dev", now) |
| + if err != nil || !created { |
| + t.Fatalf("first CreateApp = %v %v, want true nil", created, err) |
| + } |
| + created, err = s.CreateApp("zephyr", "bob@keymail.dev", now) |
| + if err != nil || created { |
| + t.Fatalf("second CreateApp = %v %v, want false nil", created, err) |
| + } |
| + if has, _ := s.HasApp("zephyr"); !has { |
| + t.Fatal("HasApp(zephyr) = false after claim") |
| + } |
| + if has, _ := s.HasApp("ghost"); has { |
| + t.Fatal("HasApp(ghost) = true") |
| + } |
| + // HasOwners sees CLI-style grants independently of the apps table. |
| + if has, _ := s.HasOwners("kass"); has { |
| + t.Fatal("HasOwners before any grant") |
| + } |
| + if err := s.Grant("paul@keymail.dev", "kass"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if has, _ := s.HasOwners("kass"); !has { |
| + t.Fatal("HasOwners(kass) = false after grant") |
| + } |
| +} |
diff --git a/internal/console/templates/app.html b/internal/console/templates/app.html| index 4c848dae3..f344b7a49 100644 |
| --- a/internal/console/templates/app.html |
| +++ b/internal/console/templates/app.html |
| @@ -1,5 +1,7 @@ |
| {{define "app"}}{{template "top" .}} |
| <h1>{{.Detail.App}}</h1> |
| +{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| +{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| <h2>Routes</h2> |
| {{if .Detail.Routes}} |
| @@ -24,7 +26,7 @@ |
| {{if .Detail.Channels}} |
| <div class="tablewrap"> |
| <table> |
| - <tr><th>Channel</th><th>Version</th><th>Promoted</th><th>Bake</th></tr> |
| + <tr><th>Channel</th><th>Version</th><th>Promoted</th><th>Bake</th><th></th></tr> |
| {{range .Detail.Channels}} |
| <tr> |
| <td>{{.Name}}</td> |
| @@ -35,10 +37,54 @@ |
| <td>{{fmtTime .PromotedAt}}</td> |
| <td>{{if .BakedUntil.IsZero}}live{{else}}bakes until ~{{fmtTime .BakedUntil}}{{end}}</td> |
| {{end}} |
| + <td>{{if and $.CanPromote .HasHistory (not .Err)}} |
| + <form method="post" action="/apps/{{$.Detail.App}}/rollback"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="channel" value="{{.Name}}"> |
| + <button type="submit" class="linkish">Roll back</button> |
| + </form> |
| + {{end}}</td> |
| </tr> |
| {{end}} |
| </table> |
| </div> |
| -<p class="small">Bake times are an approximation from each channel's default hold and the promotion time; the authoritative clock is each box's own first-seen record.</p> |
| +<p class="small">Bake times are an approximation from each channel's default hold and the promotion time; the authoritative clock is each box's own first-seen record. Rollback never bakes.</p> |
| {{else}}<p class="small">No channels yet.</p>{{end}} |
| + |
| +<h2>Releases</h2> |
| +{{if .Versions}} |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>Version</th><th>Kind</th><th>Built</th>{{if .CanPromote}}<th>Promote</th>{{end}}</tr> |
| + {{range .Versions}} |
| + <tr> |
| + <td><code>{{.Version}}</code></td> |
| + {{if .Err}} |
| + <td colspan="2" class="error">{{.Err}}</td> |
| + {{else}} |
| + <td>{{.Kind}}</td> |
| + <td>{{if .BuiltAt}}{{.BuiltAt}}{{else}}—{{end}}</td> |
| + {{end}} |
| + {{if $.CanPromote}}<td> |
| + <form method="post" action="/apps/{{$.Detail.App}}/promote"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="version" value="{{.Version}}"> |
| + <select name="channel"> |
| + {{range $.Detail.Channels}}<option value="{{.Name}}">{{.Name}}</option>{{end}} |
| + </select> |
| + <input type="text" name="new_channel" placeholder="or a new channel" size="14"> |
| + <button type="submit" class="linkish">Promote</button> |
| + </form> |
| + </td>{{end}} |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +{{else}} |
| +<p>Nothing shipped yet. Ship your first release from your repo:</p> |
| +<pre><code>carlos ship -app {{.Detail.App}} -version $(git rev-parse --short HEAD) -target linux-arm64 <binary> |
| +carlos ship -app {{.Detail.App}} -kind static -version v1 <site-dir></code></pre> |
| +<p class="small">Releases appear here once shipped, and get promoted to channels from this page.</p> |
| +{{end}} |
| +{{if not .CanPromote}}<p class="small">Promotions disabled: no signing key on this host.</p>{{end}} |
| {{template "bottom" .}}{{end}} |
diff --git a/internal/console/templates/apps.html b/internal/console/templates/apps.html| index a8cb6ad39..9c6cf08e2 100644 |
| --- a/internal/console/templates/apps.html |
| +++ b/internal/console/templates/apps.html |
| @@ -1,5 +1,7 @@ |
| {{define "apps"}}{{template "top" .}} |
| {{if .Welcome}}<p class="banner">Account created — welcome! You signed up as <strong>{{.Address}}</strong>.</p>{{end}} |
| +{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| +{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| {{if .Apps}} |
| <h1>Your apps</h1> |
| <div class="tablewrap"> |
| @@ -19,7 +21,13 @@ |
| </div> |
| {{else}} |
| <h1>Welcome to the CARLOS platform</h1> |
| -<p>Your account <strong>{{.Address}}</strong> is ready. No apps live here yet — creating apps from the console is coming soon.</p> |
| -<p class="small">When an app is granted to this address, it appears right here with its live routes and release channels.</p> |
| +<p>Your account <strong>{{.Address}}</strong> is ready. No apps live here yet — claim a name below and it's yours.</p> |
| {{end}} |
| +<h2>Create an app</h2> |
| +<form class="signin" method="post" action="/apps/new"> |
| + <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| + <input type="text" name="name" placeholder="my-app"> |
| + <button type="submit">Create app</button> |
| +</form> |
| +<p class="small">Names are lowercase letters, digits, and dashes, starting with a letter — claimed once, yours forever.</p> |
| {{template "bottom" .}}{{end}} |
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html| index b6968d4b3..1990cb678 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -29,6 +29,17 @@ |
| font-size: 0.88em; background: var(--accent-soft); |
| padding: 0.1em 0.35em; border-radius: 0.25rem; |
| } |
| + pre { |
| + background: var(--accent-soft); border-radius: 0.5rem; |
| + padding: 0.9rem 1.1rem; margin: 0 0 1rem; max-width: 40rem; |
| + overflow-x: auto; font-size: 0.85rem; line-height: 1.6; |
| + } |
| + pre code { background: none; padding: 0; } |
| + select, input[type=text] { |
| + font: inherit; font-size: 0.9rem; color: var(--ink); background: var(--bg); |
| + border: 1px solid var(--rule); border-radius: 0.5rem; padding: 0.3rem 0.5rem; |
| + } |
| + td form { display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; margin: 0; } |
| header { |
| max-width: 56rem; margin: 0 auto; padding: 1.4rem 0; |
| display: flex; align-items: baseline; gap: 1rem; |
| @@ -60,11 +71,7 @@ |
| } |
| th, td { padding: 0.5rem 1rem 0.5rem 0; border-bottom: 1px solid var(--rule); } |
| form.signin { display: flex; gap: 0.6rem; flex-wrap: wrap; margin: 0 0 1rem; } |
| - input[type=text] { |
| - font: inherit; color: var(--ink); background: var(--bg); |
| - border: 1px solid var(--rule); border-radius: 0.5rem; |
| - padding: 0.55rem 0.8rem; min-width: 16rem; |
| - } |
| + form.signin input[type=text] { padding: 0.55rem 0.8rem; min-width: 16rem; } |
| button, .btn { |
| font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; |
| font-size: 0.95rem; font-weight: 600; background: var(--accent); |
diff --git a/internal/console/view.go b/internal/console/view.go| index 21a9eb318..11666639c 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -39,11 +39,21 @@ type ChannelView struct { |
| // clock is each box's own first-seen record (release/bake.go), which the |
| // console cannot see, and per-deployment hold overrides are not read here. |
| BakedUntil time.Time |
| + // HasHistory is whether the pointer records previous versions — i.e. |
| + // whether a rollback has somewhere to go. |
| + HasHistory bool |
| // Err degrades a missing or invalid channel pointer to one row's error |
| // text instead of a failed page. |
| Err string |
| } |
| +// VersionView is one shipped release on the app page. |
| +type VersionView struct { |
| + Version, Kind, BuiltAt string |
| + // Err degrades an unreadable manifest to one row's error text. |
| + Err string |
| +} |
| + |
| // registry.Registry has no Close, so opening per page would leak a handle |
| // each request; one cached handle per path instead. Freshness is not |
| // sacrificed: Registry.List reads over a fresh connection every call. |
| @@ -133,7 +143,10 @@ func assembleChannels(ctx context.Context, s release.Store, pub ed25519.PublicKe |
| out = append(out, ChannelView{Name: name, Err: err.Error()}) |
| continue |
| } |
| - cv := ChannelView{Name: name, Version: p.Version, PromotedAt: p.PromotedAt} |
| + cv := ChannelView{ |
| + Name: name, Version: p.Version, PromotedAt: p.PromotedAt, |
| + HasHistory: len(p.History) > 0, |
| + } |
| if hold := release.HoldFor(release.DefaultHolds, name); hold > 0 { |
| if until := p.PromotedAt.Add(hold); now.Before(until) { |
| cv.BakedUntil = until |
| @@ -143,3 +156,49 @@ func assembleChannels(ctx context.Context, s release.Store, pub ed25519.PublicKe |
| } |
| return out |
| } |
| + |
| +// versionsCap bounds the app page's release list. |
| +const versionsCap = 20 |
| + |
| +// Versions lists an app's shipped releases — the distinct version dirs under |
| +// 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. |
| +func Versions(s release.Store, app string) ([]VersionView, error) { |
| + ctx := context.Background() |
| + prefix := "apps/" + app + "/releases/" |
| + keys, err := s.List(ctx, prefix) |
| + 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 |
| + m, err := release.GetManifest(ctx, s, app, v) |
| + if err != nil { |
| + out = append(out, VersionView{Version: v, Err: err.Error()}) |
| + continue |
| + } |
| + out = append(out, VersionView{Version: v, Kind: m.Kind, BuiltAt: m.BuiltAt}) |
| + } |
| + parse := func(s string) time.Time { |
| + t, _ := time.Parse(time.RFC3339, s) |
| + return t |
| + } |
| + sort.SliceStable(out, func(i, j int) bool { |
| + ti, tj := parse(out[i].BuiltAt), parse(out[j].BuiltAt) |
| + if !ti.Equal(tj) { |
| + return ti.After(tj) |
| + } |
| + return out[i].Version > out[j].Version |
| + }) |
| + if len(out) > versionsCap { |
| + out = out[:versionsCap] |
| + } |
| + return out, nil |
| +} |
diff --git a/internal/console/view_test.go b/internal/console/view_test.go| index 4b1e3d451..ae421743b 100644 |
| --- a/internal/console/view_test.go |
| +++ b/internal/console/view_test.go |
| @@ -14,7 +14,7 @@ import ( |
| // buildFixtures makes a little platform: a registry with routes for apps |
| // kass (ready instance on stable) and otherapp, and a deployment bucket with |
| // kass v1 promoted to stable and v2 to canary/x. Shared with server_test.go. |
| -func buildFixtures(t *testing.T) (regPath string, store release.Store, pub ed25519.PublicKey) { |
| +func buildFixtures(t *testing.T) (regPath string, store release.Store, priv ed25519.PrivateKey, pub ed25519.PublicKey) { |
| t.Helper() |
| dir := t.TempDir() |
| regPath = filepath.Join(dir, "registry.db") |
| @@ -40,7 +40,7 @@ func buildFixtures(t *testing.T) (regPath string, store release.Store, pub ed255 |
| if err != nil { |
| t.Fatal(err) |
| } |
| - priv, err := release.ParseSigningKey(seed, "test") |
| + priv, err = release.ParseSigningKey(seed, "test") |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -49,9 +49,13 @@ func buildFixtures(t *testing.T) (regPath string, store release.Store, pub ed255 |
| t.Fatal(err) |
| } |
| ctx := context.Background() |
| + builtAt := map[string]string{ |
| + "v1": "2026-07-01T10:00:00Z", |
| + "v2": "2026-07-02T10:00:00Z", |
| + } |
| for _, v := range []string{"v1", "v2"} { |
| m := release.Manifest{ |
| - Schema: 1, App: "kass", Version: v, Kind: "binary", |
| + Schema: 1, App: "kass", Version: v, Kind: "binary", BuiltAt: builtAt[v], |
| Artifacts: map[string]release.Artifact{"linux-amd64": {File: "kass"}}, |
| } |
| if err := release.Ship(ctx, store, m, map[string][]byte{"linux-amd64": []byte("binary " + v)}); err != nil { |
| @@ -64,7 +68,7 @@ func buildFixtures(t *testing.T) (regPath string, store release.Store, pub ed255 |
| if err := release.Promote(ctx, store, priv, "kass", "canary/x", "v2"); err != nil { |
| t.Fatal(err) |
| } |
| - return regPath, store, pub |
| + return regPath, store, priv, pub |
| } |
| func findChannel(t *testing.T, chans []ChannelView, name string) ChannelView { |
| @@ -79,7 +83,7 @@ func findChannel(t *testing.T, chans []ChannelView, name string) ChannelView { |
| } |
| func TestAssemble(t *testing.T) { |
| - regPath, store, pub := buildFixtures(t) |
| + regPath, store, _, pub := buildFixtures(t) |
| views, err := Assemble(regPath, store, pub, []string{"kass"}) |
| if err != nil { |
| t.Fatal(err) |
| @@ -126,7 +130,7 @@ func TestAssemble(t *testing.T) { |
| } |
| func TestAssembleFiltersToGivenApps(t *testing.T) { |
| - regPath, store, pub := buildFixtures(t) |
| + regPath, store, _, pub := buildFixtures(t) |
| views, err := Assemble(regPath, store, pub, []string{"kass"}) |
| if err != nil { |
| t.Fatal(err) |
| @@ -144,7 +148,7 @@ func TestAssembleFiltersToGivenApps(t *testing.T) { |
| } |
| func TestAssembleEmptyAppStillListed(t *testing.T) { |
| - regPath, store, pub := buildFixtures(t) |
| + regPath, store, _, pub := buildFixtures(t) |
| views, err := Assemble(regPath, store, pub, []string{"ghost", "kass"}) |
| if err != nil { |
| t.Fatal(err) |
| @@ -158,7 +162,7 @@ func TestAssembleEmptyAppStillListed(t *testing.T) { |
| } |
| func TestAssembleDegradesBadChannel(t *testing.T) { |
| - regPath, store, pub := buildFixtures(t) |
| + regPath, store, _, pub := buildFixtures(t) |
| // A garbage pointer must become one row's error, never a failed page. |
| if _, err := store.Put(context.Background(), "apps/kass/channels/beta.json", []byte("not a signed pointer"), ""); err != nil { |
| t.Fatal(err) |
| @@ -175,3 +179,48 @@ func TestAssembleDegradesBadChannel(t *testing.T) { |
| t.Fatalf("healthy channel degraded too: %+v", stable) |
| } |
| } |
| + |
| +func TestVersionsNewestFirst(t *testing.T) { |
| + _, store, _, _ := buildFixtures(t) |
| + vs, err := Versions(store, "kass") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(vs) != 2 { |
| + t.Fatalf("versions = %+v, want 2", vs) |
| + } |
| + if vs[0].Version != "v2" || vs[1].Version != "v1" { |
| + t.Fatalf("order = %s, %s — want v2 (newer built_at) first", vs[0].Version, vs[1].Version) |
| + } |
| + if vs[0].Kind != "binary" || vs[0].BuiltAt != "2026-07-02T10:00:00Z" || vs[0].Err != "" { |
| + t.Fatalf("v2 = %+v", vs[0]) |
| + } |
| +} |
| + |
| +func TestVersionsDegradesBadManifest(t *testing.T) { |
| + _, store, _, _ := buildFixtures(t) |
| + if _, err := store.Put(context.Background(), "apps/kass/releases/v3/manifest.json", []byte("junk"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + vs, err := Versions(store, "kass") |
| + if err != nil { |
| + t.Fatalf("Versions must not fail on one bad manifest: %v", err) |
| + } |
| + var v3 *VersionView |
| + for i := range vs { |
| + if vs[i].Version == "v3" { |
| + v3 = &vs[i] |
| + } |
| + } |
| + if v3 == nil || v3.Err == "" { |
| + t.Fatalf("versions = %+v, want a degraded v3 row", vs) |
| + } |
| +} |
| + |
| +func TestVersionsEmptyApp(t *testing.T) { |
| + _, store, _, _ := buildFixtures(t) |
| + vs, err := Versions(store, "ghost") |
| + if err != nil || len(vs) != 0 { |
| + t.Fatalf("Versions(ghost) = %+v %v, want empty nil", vs, err) |
| + } |
| +} |