carlos / platform Public

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>
Paul Campbell f74fb60d4f58cf926689920edc737ac3b7cd5432 parent 3428e5b
5 files changed, +645 −27
  • internal/console/server.go +201 −16
  • internal/console/server_test.go +273 −9
  • internal/console/templates/app.html +73 −0
  • internal/console/view.go +42 −1
  • internal/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/&lt;name&gt;</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/&lt;name&gt; &lt;host&gt;</code>.</p>
+{{end}}
+
<h2>Releases</h2>
{{if .Versions}}
<div class="tablewrap">
@@ -87,4 +147,17 @@ carlos ship -app {{.Detail.App}} -kind static -version v1 &lt;site-dir&gt;</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"})