console: canaries from the browser + instance tables + recent activity
Slice 3. Canary creation: POST /apps/{app}/canary promotes a shipped
version to a brand-new canary/<name> channel (slug-validated, refuses
re-creating an existing canary); the app page lists canaries with the
hosts following each channel — the canary's URL — or a carlos-add hint
when no host follows yet. Retirement: POST /apps/{app}/canary/retire
removes the pointer, refusing while any route still follows the channel;
it needs a Delete primitive release.Store does not carry yet, so it
type-asserts a channelDeleter and degrades to a polite refusal on stores
without one (the missing release.Retire is reported, not worked around).
Both follow slice 2 exactly: owner-gated, CSRF double-submit,
POST-redirect-flash, keyless = 503 read-only.
The app page also renders the /.carlos/instances self-report (count +
slug/status/region/detail table) when the app serves the contract, and a
Recent-activity section from the new audit table (promote, rollback,
canary create/retire, app creation — console actions only).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+645
−27
internal/console/server.go+201 −16internal/console/server_test.go+273 −9internal/console/templates/app.html+73 −0internal/console/view.go+42 −1internal/console/view_test.go+56 −1
diff --git a/internal/console/server.go b/internal/console/server.go| index 0ecc52387..1bf0eefbf 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -10,11 +10,14 @@ import ( |
| "crypto/ed25519" |
| "crypto/subtle" |
| "embed" |
| + "errors" |
| "fmt" |
| "html/template" |
| "log" |
| "net/http" |
| "net/url" |
| + "os" |
| + "regexp" |
| "sort" |
| "strings" |
| "time" |
| @@ -47,15 +50,22 @@ type Config struct { |
| // 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 |
| + // AppClient fetches app-internal instance reports (INSTANCES.md); nil |
| + // uses a default client with the contract's short timeout. |
| + AppClient *http.Client |
| + // AppScheme is the scheme apps' public hosts are reached on — "https" |
| + // (the default) everywhere but tests. |
| + AppScheme string |
| } |
| // Server is the console's http.Handler. |
| type Server struct { |
| - cfg Config |
| - store *Store |
| - km *Keymail |
| - mux *http.ServeMux |
| - secure bool // Secure cookies iff the origin is https |
| + cfg Config |
| + store *Store |
| + km *Keymail |
| + mux *http.ServeMux |
| + instances *instanceFetcher |
| + secure bool // Secure cookies iff the origin is https |
| } |
| // NewServer wires a server over an opened Store, loading (or minting) the |
| @@ -69,11 +79,12 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| } |
| km.SetSigningKey(key) |
| s := &Server{ |
| - cfg: cfg, |
| - store: st, |
| - km: km, |
| - mux: http.NewServeMux(), |
| - secure: strings.HasPrefix(cfg.Origin, "https://"), |
| + cfg: cfg, |
| + store: st, |
| + km: km, |
| + mux: http.NewServeMux(), |
| + instances: newInstanceFetcher(cfg.AppClient, cfg.AppScheme), |
| + secure: strings.HasPrefix(cfg.Origin, "https://"), |
| } |
| s.mux.HandleFunc("GET /{$}", s.handleHome) |
| s.mux.HandleFunc("POST /auth/continue", s.handleContinue) |
| @@ -86,6 +97,8 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| 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("POST /apps/{app}/canary", s.handleNewCanary) |
| + s.mux.HandleFunc("POST /apps/{app}/canary/retire", s.handleRetireCanary) |
| s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { |
| fmt.Fprintln(w, "ok") |
| }) |
| @@ -112,10 +125,12 @@ type page struct { |
| 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 |
| + Apps []appRow // /apps |
| + Detail *AppView // /apps/{app} |
| + Versions []VersionView // /apps/{app}: shipped releases, newest first |
| + Instances *InstanceReport // /apps/{app}: the app's self-report, nil when unpolled |
| + Events []Event // /apps/{app}: recent console actions, newest first |
| + CanPromote bool // a signing key is configured on this host |
| } |
| // appRow is one line of the "your apps" table. |
| @@ -463,9 +478,26 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) { |
| p.FlashErr = "Could not list this app's releases." |
| } |
| p.Versions = versions |
| + p.Instances = s.instances.Report(r.Context(), views[0].Routes) |
| + if events, err := s.store.Events(app, eventsCap); err != nil { |
| + log.Printf("console: events %s: %v", app, err) |
| + } else { |
| + p.Events = events |
| + } |
| s.render(w, http.StatusOK, "app", p) |
| } |
| +// eventsCap bounds the app page's recent-activity list. |
| +const eventsCap = 10 |
| + |
| +// audit best-effort-records an owner action: a failed audit line is logged, |
| +// never surfaced, and never fails the action it records. |
| +func (s *Server) audit(app, addr, action, detail string) { |
| + if err := s.store.RecordEvent(app, addr, action, detail, time.Now()); err != nil { |
| + log.Printf("console: audit %s %s: %v", action, app, err) |
| + } |
| +} |
| + |
| // 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 |
| @@ -525,13 +557,14 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) { |
| fail("Created the app, but granting you ownership failed — ask an operator.") |
| return |
| } |
| + s.audit(name, addr, "create", "created app "+name) |
| 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) |
| + addr, raw, app, ok := s.ownedApp(w, r) |
| if !ok { |
| return |
| } |
| @@ -572,6 +605,7 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) { |
| back("err", "Promote failed: "+err.Error()) |
| return |
| } |
| + s.audit(app, addr, "promote", "promoted "+version+" to "+channel) |
| msg := "Promoted " + version + " to " + channel + "." |
| if release.HoldFor(release.DefaultHolds, channel) > 0 { |
| msg += " Adopts after the bake window on each host — rollback never bakes." |
| @@ -581,7 +615,7 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) { |
| // 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) |
| + addr, raw, app, ok := s.ownedApp(w, r) |
| if !ok { |
| return |
| } |
| @@ -620,9 +654,160 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) { |
| back("err", "Rollback failed: "+err.Error()) |
| return |
| } |
| + s.audit(app, addr, "rollback", "rolled back "+channel+" to "+target) |
| back("ok", "Rolled back "+channel+" to "+target+".") |
| } |
| +// ---- canaries --------------------------------------------------------------- |
| + |
| +// canaryNameRe pins canary names to the same boring slugs as app names — the |
| +// name becomes a channel path segment and an on-box state file name. |
| +var canaryNameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`) |
| + |
| +// channelDeleter is the retirement primitive the console needs from the |
| +// release store: remove one channel-pointer key. release.Store does not |
| +// carry it yet (see the slice-3 report) — stores that grow it get canary |
| +// retirement enabled, everything else degrades to a polite refusal. |
| +type channelDeleter interface { |
| + Delete(ctx context.Context, key string) error |
| +} |
| + |
| +// channelHosts lists the registry hosts whose routes follow app's channel. |
| +// Read-only, best-effort: a registry hiccup reads as "no hosts". |
| +func (s *Server) channelHosts(app, channel string) []string { |
| + reg, err := openRegistry(s.cfg.Registry) |
| + if err != nil { |
| + return nil |
| + } |
| + routes, err := reg.List() |
| + if err != nil { |
| + return nil |
| + } |
| + var out []string |
| + for _, rt := range routes { |
| + if rt.App == app && rt.Channel == channel { |
| + out = append(out, rt.Host) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// handleNewCanary creates a canary: a brand-new canary/<name> channel |
| +// pointed at a chosen shipped version. Canary channels bypass bake by |
| +// design, so the canary serves the moment a host follows its channel. |
| +func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) { |
| + addr, 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) |
| + } |
| + name := strings.ToLower(strings.TrimSpace(r.FormValue("name"))) |
| + if !canaryNameRe.MatchString(name) { |
| + back("err", "Invalid canary name — lowercase letters, digits, and dashes, starting with a letter.") |
| + return |
| + } |
| + channel := "canary/" + name |
| + version := strings.TrimSpace(r.FormValue("version")) |
| + if !release.ValidVersion(version) { |
| + back("err", "Invalid version.") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| + defer cancel() |
| + if _, err := release.GetManifest(ctx, s.cfg.Releases, app, version); err != nil { |
| + back("err", "Version "+version+" is not a shipped release of "+app+".") |
| + return |
| + } |
| + // New means new: an existing canary is promoted to (releases table) or |
| + // retired, never quietly re-created. |
| + if _, _, err := s.cfg.Releases.Get(ctx, release.ChannelKey(app, channel)); err == nil { |
| + back("err", "Canary "+name+" already exists — promote to it from the releases table, or retire it first.") |
| + return |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + back("err", "Could not check the release store. Please try again.") |
| + return |
| + } |
| + if err := release.Promote(ctx, s.cfg.Releases, s.cfg.Key, app, channel, version); err != nil { |
| + log.Printf("console: canary %s %s -> %s: %v", app, version, channel, err) |
| + back("err", "Creating the canary failed: "+err.Error()) |
| + return |
| + } |
| + s.audit(app, addr, "canary-create", "created canary "+name+" at "+version) |
| + if hosts := s.channelHosts(app, channel); len(hosts) > 0 { |
| + back("ok", "Created canary "+name+" — live at https://"+hosts[0]+" serving "+version+" (canaries never bake).") |
| + return |
| + } |
| + back("ok", "Created canary "+name+" — "+channel+" serves "+version+" the moment a host follows it, no bake. Attach one: carlos add -app "+app+" -channel "+channel+" <host>.") |
| +} |
| + |
| +// handleRetireCanary removes a canary's channel pointer. Shipped releases |
| +// are untouched — retirement un-points, it never deletes bits. |
| +func (s *Server) handleRetireCanary(w http.ResponseWriter, r *http.Request) { |
| + addr, 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) |
| + } |
| + name := strings.ToLower(strings.TrimSpace(r.FormValue("name"))) |
| + if !canaryNameRe.MatchString(name) { |
| + back("err", "Invalid canary name.") |
| + return |
| + } |
| + channel := "canary/" + name |
| + // A canary with followers is load-bearing: retiring its pointer would |
| + // strand every host on it. |
| + if hosts := s.channelHosts(app, channel); len(hosts) > 0 { |
| + back("err", "Canary "+name+" still has a host ("+hosts[0]+") following it — remove or repoint that route first.") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| + defer cancel() |
| + key := release.ChannelKey(app, channel) |
| + if _, _, err := s.cfg.Releases.Get(ctx, key); errors.Is(err, os.ErrNotExist) { |
| + back("err", "No canary named "+name+" here.") |
| + return |
| + } else if err != nil { |
| + back("err", "Could not read the release store. Please try again.") |
| + return |
| + } |
| + del, canDelete := s.cfg.Releases.(channelDeleter) |
| + if !canDelete { |
| + back("err", "This deployment's release store can't retire channels yet — ask an operator to remove "+key+".") |
| + return |
| + } |
| + if err := del.Delete(ctx, key); err != nil { |
| + log.Printf("console: retire %s %s: %v", app, channel, err) |
| + back("err", "Retiring the canary failed: "+err.Error()) |
| + return |
| + } |
| + s.audit(app, addr, "canary-retire", "retired canary "+name) |
| + back("ok", "Retired canary "+name+" — its channel pointer is gone; shipped releases are untouched.") |
| +} |
| + |
| // 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 a878c5c78..e101f790d 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -3,16 +3,20 @@ package console |
| import ( |
| "context" |
| "crypto/ed25519" |
| + "errors" |
| "io" |
| "net/http" |
| "net/http/cookiejar" |
| "net/http/httptest" |
| "net/url" |
| + "os" |
| "path/filepath" |
| "regexp" |
| "strings" |
| "testing" |
| + "time" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -20,12 +24,14 @@ import ( |
| // 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 |
| - rel release.Store |
| - priv ed25519.PrivateKey |
| - pub ed25519.PublicKey |
| + ts *httptest.Server |
| + srv *Server |
| + st *Store |
| + fk *fakeKeymail |
| + rel release.Store |
| + regPath string |
| + priv ed25519.PrivateKey |
| + pub ed25519.PublicKey |
| } |
| func newConsoleFixture(t *testing.T) *consoleFixture { |
| @@ -35,8 +41,18 @@ func newConsoleFixture(t *testing.T) *consoleFixture { |
| // 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 { |
| + return newConsoleFixtureWith(t, withKey, nil) |
| +} |
| + |
| +// newConsoleFixtureWith additionally lets a test wrap the release store the |
| +// server sees (e.g. hide the Delete primitive). |
| +func newConsoleFixtureWith(t *testing.T, withKey bool, wrap func(release.Store) release.Store) *consoleFixture { |
| t.Helper() |
| regPath, relStore, priv, pub := buildFixtures(t) |
| + serverStore := relStore |
| + if wrap != nil { |
| + serverStore = wrap(relStore) |
| + } |
| st, err := Open(filepath.Join(t.TempDir(), "console.db")) |
| if err != nil { |
| t.Fatal(err) |
| @@ -54,8 +70,9 @@ func newConsoleFixtureKeyed(t *testing.T, withKey bool) *consoleFixture { |
| Origin: ts.URL, |
| Keymail: fk.srv.URL, |
| Registry: regPath, |
| - Releases: relStore, |
| + Releases: serverStore, |
| ReleasePub: pub, |
| + AppScheme: "http", // instance polls reach httptest apps, not TLS |
| } |
| if withKey { |
| cfg.Key = priv |
| @@ -64,10 +81,12 @@ func newConsoleFixtureKeyed(t *testing.T, withKey bool) *consoleFixture { |
| if err != nil { |
| t.Fatal(err) |
| } |
| + // Fixture route hosts don't resolve; keep the not-reporting verdict quick. |
| + srv.instances.timeout = 100 * time.Millisecond |
| if err := st.Grant("paul@keymail.dev", "kass"); err != nil { |
| t.Fatal(err) |
| } |
| - return &consoleFixture{ts: ts, st: st, fk: fk, rel: relStore, priv: priv, pub: pub} |
| + return &consoleFixture{ts: ts, srv: srv, st: st, fk: fk, rel: relStore, regPath: regPath, priv: priv, pub: pub} |
| } |
| // channelVersion reads a channel pointer straight from the bucket. |
| @@ -538,6 +557,239 @@ func TestRollbackFlow(t *testing.T) { |
| } |
| } |
| +// addRoute upserts a registry route into the fixture's registry. |
| +func (fx *consoleFixture) addRoute(t *testing.T, rt registry.Route) { |
| + t.Helper() |
| + reg, err := registry.Open(fx.regPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestCanaryFlow(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + // The app page: create form, and the fixture canary listed hostless. |
| + _, _, body := get(t, c, fx.ts.URL+"/apps/kass") |
| + for _, want := range []string{ |
| + `action="/apps/kass/canary"`, `action="/apps/kass/canary/retire"`, |
| + "canary/x", "no host yet", "Create canary", |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("/apps/kass missing %q in:\n%s", want, body) |
| + } |
| + } |
| + |
| + // Create a canary: a fresh canary/rc channel pointed at v1, no bake. |
| + status, loc, _ := postForm(t, c, fx.ts.URL+"/apps/kass/canary", |
| + url.Values{"csrf": {csrf}, "name": {"rc"}, "version": {"v1"}}) |
| + if status != http.StatusSeeOther || loc != "/apps/kass" { |
| + t.Fatalf("canary = %d -> %q", status, loc) |
| + } |
| + if p := fx.channelVersion(t, "kass", "canary/rc"); p.Version != "v1" { |
| + t.Fatalf("canary/rc pointer = %+v, want v1", p) |
| + } |
| + _, _, body = get(t, c, fx.ts.URL+"/apps/kass") |
| + for _, want := range []string{"Created canary rc", "no bake", "carlos add -app kass -channel canary/rc", "canary/rc"} { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("post-create page missing %q in:\n%s", want, body) |
| + } |
| + } |
| + |
| + // New means new: the existing canary refuses re-creation. |
| + postForm(t, c, fx.ts.URL+"/apps/kass/canary", |
| + url.Values{"csrf": {csrf}, "name": {"x"}, "version": {"v1"}}) |
| + if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "Canary x already exists") { |
| + t.Fatal("no already-exists flash") |
| + } |
| + if p := fx.channelVersion(t, "kass", "canary/x"); p.Version != "v2" { |
| + t.Fatalf("canary/x moved to %s on a refused create", p.Version) |
| + } |
| + |
| + // Bad names, unshipped versions, CSRF. |
| + postForm(t, c, fx.ts.URL+"/apps/kass/canary", |
| + url.Values{"csrf": {csrf}, "name": {"Bad Name"}, "version": {"v1"}}) |
| + if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "Invalid canary name") { |
| + t.Fatal("no invalid-name flash") |
| + } |
| + postForm(t, c, fx.ts.URL+"/apps/kass/canary", |
| + url.Values{"csrf": {csrf}, "name": {"ok-name"}, "version": {"v9"}}) |
| + if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "not a shipped release") { |
| + t.Fatal("no unshipped-version flash") |
| + } |
| + if status, _, _ := postForm(t, c, fx.ts.URL+"/apps/kass/canary", |
| + url.Values{"csrf": {"wrong"}, "name": {"rc2"}, "version": {"v1"}}); status != http.StatusForbidden { |
| + t.Fatalf("bad csrf canary = %d, want 403", status) |
| + } |
| + |
| + // Non-owners get the same 404 as everyone else. |
| + fx.setUser("bob@keymail.dev") |
| + cb := newBrowser(t) |
| + signUp(t, fx, cb, "code-bob-c") |
| + if status, _, _ := postForm(t, cb, fx.ts.URL+"/apps/kass/canary", |
| + url.Values{"csrf": {sessionToken(t, fx, cb)}, "name": {"evil"}, "version": {"v1"}}); status != http.StatusNotFound { |
| + t.Fatalf("non-owner canary = %d, want 404", status) |
| + } |
| +} |
| + |
| +func TestCanaryURLFromRoute(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + // A host already follows canary/rc (routes can pre-exist their channel); |
| + // creating the canary makes it live there, and the console says so. |
| + fx.addRoute(t, registry.Route{Host: "rc.kass.carlosapp.dev", Kind: "instance", |
| + Status: "ready", App: "kass", Channel: "canary/rc", Canary: true}) |
| + postForm(t, c, fx.ts.URL+"/apps/kass/canary", |
| + url.Values{"csrf": {csrf}, "name": {"rc"}, "version": {"v1"}}) |
| + _, _, body := get(t, c, fx.ts.URL+"/apps/kass") |
| + if !strings.Contains(body, "live at https://rc.kass.carlosapp.dev") { |
| + t.Fatalf("create flash has no canary URL:\n%s", body) |
| + } |
| + if !strings.Contains(body, `<a href="https://rc.kass.carlosapp.dev">rc.kass.carlosapp.dev</a>`) { |
| + t.Fatalf("canary table has no host link:\n%s", body) |
| + } |
| +} |
| + |
| +func TestRetireCanary(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + // A canary with a follower refuses to go: retiring the pointer would |
| + // strand the host. |
| + fx.addRoute(t, registry.Route{Host: "x.kass.carlosapp.dev", Kind: "instance", |
| + Status: "ready", App: "kass", Channel: "canary/x", Canary: true}) |
| + postForm(t, c, fx.ts.URL+"/apps/kass/canary/retire", url.Values{"csrf": {csrf}, "name": {"x"}}) |
| + if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "still has a host") { |
| + t.Fatal("no still-followed flash") |
| + } |
| + if p := fx.channelVersion(t, "kass", "canary/x"); p.Version != "v2" { |
| + t.Fatal("followed canary was retired anyway") |
| + } |
| + |
| + // Unfollow, retire: the pointer is gone, the releases are not. |
| + reg, err := registry.Open(fx.regPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Delete("x.kass.carlosapp.dev"); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, loc, _ := postForm(t, c, fx.ts.URL+"/apps/kass/canary/retire", url.Values{"csrf": {csrf}, "name": {"x"}}) |
| + if status != http.StatusSeeOther || loc != "/apps/kass" { |
| + t.Fatalf("retire = %d -> %q", status, loc) |
| + } |
| + if _, _, err := fx.rel.Get(context.Background(), release.ChannelKey("kass", "canary/x")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("canary/x pointer still present: %v", err) |
| + } |
| + if _, err := release.GetManifest(context.Background(), fx.rel, "kass", "v2"); err != nil { |
| + t.Fatalf("retire touched a shipped release: %v", err) |
| + } |
| + _, _, body := get(t, c, fx.ts.URL+"/apps/kass") |
| + if !strings.Contains(body, "Retired canary x") { |
| + t.Fatal("no retired flash") |
| + } |
| + if strings.Contains(body, "canary/x") { |
| + t.Fatal("retired canary still listed") |
| + } |
| + |
| + // Retiring what isn't there is a polite no. |
| + postForm(t, c, fx.ts.URL+"/apps/kass/canary/retire", url.Values{"csrf": {csrf}, "name": {"ghost"}}) |
| + if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "No canary named ghost") { |
| + t.Fatal("no no-such-canary flash") |
| + } |
| + if status, _, _ := postForm(t, c, fx.ts.URL+"/apps/kass/canary/retire", |
| + url.Values{"csrf": {"wrong"}, "name": {"x"}}); status != http.StatusForbidden { |
| + t.Fatalf("bad csrf retire = %d, want 403", status) |
| + } |
| +} |
| + |
| +func TestRetireWithoutDeletePrimitive(t *testing.T) { |
| + // A release store without Delete (today's release.Store exactly): the UI |
| + // stays, retirement degrades to a polite refusal, nothing changes. |
| + fx := newConsoleFixtureWith(t, true, func(s release.Store) release.Store { |
| + return struct{ release.Store }{s} |
| + }) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + postForm(t, c, fx.ts.URL+"/apps/kass/canary/retire", url.Values{"csrf": {csrf}, "name": {"x"}}) |
| + if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "retire channels yet") { |
| + t.Fatal("no unsupported-store flash") |
| + } |
| + if p := fx.channelVersion(t, "kass", "canary/x"); p.Version != "v2" { |
| + t.Fatal("unsupported store still lost the pointer") |
| + } |
| +} |
| + |
| +func TestAppPageInstances(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + |
| + // Before any reporting route exists: no Instances section — the page is |
| + // exactly the route-level view. |
| + if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); strings.Contains(body, "<h2>Instances</h2>") { |
| + t.Fatal("Instances section rendered with nothing reporting") |
| + } |
| + |
| + // Kass starts reporting its three training instances. |
| + app := newInstanceApp(t, kassReport) |
| + fx.addRoute(t, registry.Route{Host: app.host(t), Kind: "instance", |
| + Status: "ready", App: "kass"}) |
| + _, _, body := get(t, c, fx.ts.URL+"/apps/kass") |
| + for _, want := range []string{ |
| + "<h2>Instances</h2>", "reports 3 internal instances", |
| + "riders-2026", "spring-camp", "trial-run", |
| + "eu-west-1", "archived", "read-only since June", |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("/apps/kass missing %q in:\n%s", want, body) |
| + } |
| + } |
| +} |
| + |
| +func TestRecentActivity(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + // Fresh app page: no activity yet. |
| + if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); strings.Contains(body, "Recent activity") { |
| + t.Fatal("activity section rendered before any action") |
| + } |
| + |
| + postForm(t, c, fx.ts.URL+"/apps/kass/promote", |
| + url.Values{"csrf": {csrf}, "version": {"v2"}, "channel": {"stable"}}) |
| + postForm(t, c, fx.ts.URL+"/apps/kass/rollback", |
| + url.Values{"csrf": {csrf}, "channel": {"stable"}}) |
| + postForm(t, c, fx.ts.URL+"/apps/kass/canary", |
| + url.Values{"csrf": {csrf}, "name": {"rc"}, "version": {"v1"}}) |
| + postForm(t, c, fx.ts.URL+"/apps/kass/canary/retire", |
| + url.Values{"csrf": {csrf}, "name": {"rc"}}) |
| + |
| + _, _, body := get(t, c, fx.ts.URL+"/apps/kass") |
| + for _, want := range []string{ |
| + "Recent activity", "paul@keymail.dev", |
| + "promoted v2 to stable", "rolled back stable to v1", |
| + "created canary rc at v1", "retired canary rc", |
| + "CLI promotions don", |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("activity missing %q in:\n%s", want, body) |
| + } |
| + } |
| + // Newest first: the retire outranks the promote. |
| + if strings.Index(body, "retired canary rc") > strings.Index(body, "promoted v2 to stable") { |
| + t.Fatal("activity is not newest-first") |
| + } |
| +} |
| + |
| func TestPromotionsDisabledWithoutKey(t *testing.T) { |
| fx := newConsoleFixtureKeyed(t, false) |
| c := signInPaul(t, fx) |
| @@ -547,7 +799,8 @@ func TestPromotionsDisabledWithoutKey(t *testing.T) { |
| 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"`) { |
| + if strings.Contains(body, `action="/apps/kass/promote"`) || strings.Contains(body, `action="/apps/kass/rollback"`) || |
| + strings.Contains(body, `action="/apps/kass/canary"`) || strings.Contains(body, `action="/apps/kass/canary/retire"`) { |
| t.Fatal("keyless console rendered mutation forms") |
| } |
| if status, _, _ := postForm(t, c, fx.ts.URL+"/apps/kass/promote", |
| @@ -558,7 +811,18 @@ func TestPromotionsDisabledWithoutKey(t *testing.T) { |
| url.Values{"csrf": {csrf}, "channel": {"stable"}}); status != http.StatusServiceUnavailable { |
| t.Fatalf("keyless rollback = %d, want 503", status) |
| } |
| + if status, _, _ := postForm(t, c, fx.ts.URL+"/apps/kass/canary", |
| + url.Values{"csrf": {csrf}, "name": {"rc"}, "version": {"v1"}}); status != http.StatusServiceUnavailable { |
| + t.Fatalf("keyless canary create = %d, want 503", status) |
| + } |
| + if status, _, _ := postForm(t, c, fx.ts.URL+"/apps/kass/canary/retire", |
| + url.Values{"csrf": {csrf}, "name": {"x"}}); status != http.StatusServiceUnavailable { |
| + t.Fatalf("keyless canary retire = %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) |
| } |
| + if p := fx.channelVersion(t, "kass", "canary/x"); p.Version != "v2" { |
| + t.Fatalf("keyless console touched canary/x (%s)", p.Version) |
| + } |
| } |
diff --git a/internal/console/templates/app.html b/internal/console/templates/app.html| index f344b7a49..c821cedd2 100644 |
| --- a/internal/console/templates/app.html |
| +++ b/internal/console/templates/app.html |
| @@ -22,6 +22,26 @@ |
| </div> |
| {{else}}<p class="small">No routes yet.</p>{{end}} |
| +{{if and .Instances .Instances.Reporting}} |
| +<h2>Instances</h2> |
| +<p class="small">This app reports {{len .Instances.Instances}} internal instance{{if ne (len .Instances.Instances) 1}}s{{end}} (via <code>/.carlos/instances</code> on <code>{{.Instances.Host}}</code>).</p> |
| +{{if .Instances.Instances}} |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>Slug</th><th>Status</th><th>Region</th><th>Detail</th></tr> |
| + {{range .Instances.Instances}} |
| + <tr> |
| + <td><code>{{.Slug}}</code></td> |
| + <td>{{if .Status}}{{.Status}}{{else}}—{{end}}</td> |
| + <td>{{if .Region}}{{.Region}}{{else}}—{{end}}</td> |
| + <td>{{if .Detail}}{{.Detail}}{{else}}—{{end}}</td> |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +{{end}} |
| +{{end}} |
| + |
| <h2>Channels</h2> |
| {{if .Detail.Channels}} |
| <div class="tablewrap"> |
| @@ -51,6 +71,46 @@ |
| <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>Canaries</h2> |
| +{{if .Detail.Canaries}} |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>Name</th><th>Channel</th><th>Version</th><th>Created</th><th>URL</th><th></th></tr> |
| + {{range .Detail.Canaries}} |
| + <tr> |
| + <td>{{.Name}}</td> |
| + <td><code>{{.Channel}}</code></td> |
| + {{if .Err}} |
| + <td colspan="3" class="error">{{.Err}}</td> |
| + {{else}} |
| + <td><code>{{.Version}}</code></td> |
| + <td>{{fmtTime .PromotedAt}}</td> |
| + <td>{{if .Hosts}}{{range .Hosts}}<a href="https://{{.}}">{{.}}</a> {{end}}{{else}}no host yet{{end}}</td> |
| + {{end}} |
| + <td>{{if $.CanPromote}} |
| + <form method="post" action="/apps/{{$.Detail.App}}/canary/retire"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="name" value="{{.Name}}"> |
| + <button type="submit" class="linkish">Retire</button> |
| + </form> |
| + {{end}}</td> |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +{{else}}<p class="small">No canaries — a canary is a fresh channel serving one chosen release immediately, with no bake window.</p>{{end}} |
| +{{if and .CanPromote .Versions}} |
| +<form class="signin" method="post" action="/apps/{{.Detail.App}}/canary"> |
| + <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| + <input type="text" name="name" placeholder="rc-2"> |
| + <select name="version"> |
| + {{range .Versions}}{{if not .Err}}<option value="{{.Version}}">{{.Version}}</option>{{end}}{{end}} |
| + </select> |
| + <button type="submit">Create canary</button> |
| +</form> |
| +<p class="small">Creates channel <code>canary/<name></code> pointing at the chosen release — serving immediately, no bake. It gets its URL from a host that follows the channel: <code>carlos add -app {{.Detail.App}} -channel canary/<name> <host></code>.</p> |
| +{{end}} |
| + |
| <h2>Releases</h2> |
| {{if .Versions}} |
| <div class="tablewrap"> |
| @@ -87,4 +147,17 @@ carlos ship -app {{.Detail.App}} -kind static -version v1 <site-dir></code |
| <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}} |
| + |
| +{{if .Events}} |
| +<h2>Recent activity</h2> |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>When</th><th>Who</th><th>What</th></tr> |
| + {{range .Events}} |
| + <tr><td>{{fmtTime .CreatedAt}}</td><td>{{.Address}}</td><td>{{.Detail}}</td></tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +<p class="small">Actions taken through this console only — CLI promotions don't appear here.</p> |
| +{{end}} |
| {{template "bottom" .}}{{end}} |
diff --git a/internal/console/view.go b/internal/console/view.go| index 11666639c..e06c89609 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -21,6 +21,20 @@ type AppView struct { |
| App string |
| Routes []RouteView |
| Channels []ChannelView |
| + Canaries []CanaryView |
| +} |
| + |
| +// CanaryView is one canary on the app page: a canary/<name> channel plus |
| +// the hosts (routes) currently following it. A canary with no hosts is a |
| +// pointer waiting for a route — real, serving nothing yet. |
| +type CanaryView struct { |
| + Name string // the short name: "rc" for channel canary/rc |
| + Channel string // the full channel name |
| + Version string |
| + PromotedAt time.Time |
| + Hosts []string |
| + // Err mirrors ChannelView.Err: an unreadable pointer is one bad row. |
| + Err string |
| } |
| // RouteView is one row of the routes table. |
| @@ -111,15 +125,42 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, apps []str |
| continue |
| } |
| seen[app] = true |
| + channels := assembleChannels(ctx, s, pub, app, now) |
| out = append(out, AppView{ |
| App: app, |
| Routes: byApp[app], // List orders by host already |
| - Channels: assembleChannels(ctx, s, pub, app, now), |
| + Channels: channels, |
| + Canaries: assembleCanaries(channels, byApp[app]), |
| }) |
| } |
| return out, nil |
| } |
| +// assembleCanaries derives the canary list: every canary/* channel, with the |
| +// hosts whose routes follow it. |
| +func assembleCanaries(channels []ChannelView, routes []RouteView) []CanaryView { |
| + var out []CanaryView |
| + for _, ch := range channels { |
| + if !strings.HasPrefix(ch.Name, "canary/") { |
| + continue |
| + } |
| + cv := CanaryView{ |
| + Name: strings.TrimPrefix(ch.Name, "canary/"), |
| + Channel: ch.Name, |
| + Version: ch.Version, |
| + PromotedAt: ch.PromotedAt, |
| + Err: ch.Err, |
| + } |
| + for _, rt := range routes { |
| + if rt.Channel == ch.Name { |
| + cv.Hosts = append(cv.Hosts, rt.Host) |
| + } |
| + } |
| + out = append(out, cv) |
| + } |
| + return out |
| +} |
| + |
| // assembleChannels lists apps/<app>/channels/ and resolves each pointer. |
| func assembleChannels(ctx context.Context, s release.Store, pub ed25519.PublicKey, app string, now time.Time) []ChannelView { |
| prefix := "apps/" + app + "/channels/" |
diff --git a/internal/console/view_test.go b/internal/console/view_test.go| index ae421743b..66d3f00b5 100644 |
| --- a/internal/console/view_test.go |
| +++ b/internal/console/view_test.go |
| @@ -3,7 +3,10 @@ package console |
| import ( |
| "context" |
| "crypto/ed25519" |
| + "fmt" |
| + "os" |
| "path/filepath" |
| + "strings" |
| "testing" |
| "time" |
| @@ -11,6 +14,26 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| +// deletableStore adds the retirement primitive the console needs (Delete) on |
| +// top of a DirStore — the shape release.Store is expected to grow. Console |
| +// tests exercise canary retirement through it today. |
| +type deletableStore struct { |
| + release.Store |
| + root string |
| +} |
| + |
| +func (d *deletableStore) Delete(_ context.Context, key string) error { |
| + if key == "" || strings.HasPrefix(key, "/") { |
| + return fmt.Errorf("deletablestore: invalid key %q", key) |
| + } |
| + for _, seg := range strings.Split(key, "/") { |
| + if seg == "" || seg == "." || seg == ".." { |
| + return fmt.Errorf("deletablestore: invalid key %q", key) |
| + } |
| + } |
| + return os.Remove(filepath.Join(d.root, filepath.FromSlash(key))) |
| +} |
| + |
| // 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. |
| @@ -35,7 +58,8 @@ func buildFixtures(t *testing.T) (regPath string, store release.Store, priv ed25 |
| } |
| } |
| - store = release.NewDirStore(filepath.Join(dir, "bucket")) |
| + bucketDir := filepath.Join(dir, "bucket") |
| + store = &deletableStore{Store: release.NewDirStore(bucketDir), root: bucketDir} |
| seed, pubB64, err := release.GenerateKey() |
| if err != nil { |
| t.Fatal(err) |
| @@ -129,6 +153,37 @@ func TestAssemble(t *testing.T) { |
| } |
| } |
| +func TestAssembleCanaries(t *testing.T) { |
| + regPath, store, _, pub := buildFixtures(t) |
| + // Attach a route that follows the fixture's canary channel. |
| + reg, err := registry.Open(regPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{Host: "x.kass.carlosapp.dev", Kind: "instance", |
| + Status: "ready", App: "kass", Channel: "canary/x", Canary: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + views, err := Assemble(regPath, store, pub, []string{"kass"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + canaries := views[0].Canaries |
| + if len(canaries) != 1 { |
| + t.Fatalf("canaries = %+v, want exactly canary/x", canaries) |
| + } |
| + c := canaries[0] |
| + if c.Name != "x" || c.Channel != "canary/x" || c.Version != "v2" || c.Err != "" { |
| + t.Fatalf("canary = %+v", c) |
| + } |
| + if len(c.Hosts) != 1 || c.Hosts[0] != "x.kass.carlosapp.dev" { |
| + t.Fatalf("canary hosts = %v, want the subscribed route", c.Hosts) |
| + } |
| + if c.PromotedAt.IsZero() { |
| + t.Fatal("canary has no PromotedAt") |
| + } |
| +} |
| + |
| func TestAssembleFiltersToGivenApps(t *testing.T) { |
| regPath, store, _, pub := buildFixtures(t) |
| views, err := Assemble(regPath, store, pub, []string{"kass"}) |