console: domains — the product surface for what routes always were
Claim writes apps/<app>/account.json (account = the claiming address; truth originates in the console and travels through the bucket), the app page backfills a missing manifest on the owner's first view and never clobbers an existing one, and the routes table becomes the Domains table: Domain/Type/DNS/TLS, em-dash unknowns, and a point-your-DNS-here guidance row only under custom domains that aren't pointing yet. PLATFORM_EDGE_IPS (the agent's own variable) feeds the guidance addresses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+306
−22
cmd/console/main.go+16 −0internal/console/server.go+72 −2internal/console/server_test.go+149 −1internal/console/templates/app.html+12 −11internal/console/view.go+52 −3internal/console/view_test.go+5 −5
diff --git a/cmd/console/main.go b/cmd/console/main.go| index 76583271b..0f4f8e077 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -126,6 +126,7 @@ func serve() { |
| Releases: openStore(), |
| ReleasePub: pub, |
| Key: key, |
| + EdgeIPs: edgeIPs(), |
| }, st) |
| if err != nil { |
| log.Fatal(err) |
| @@ -160,6 +161,21 @@ func openStore() release.Store { |
| return nil |
| } |
| +// edgeIPs parses PLATFORM_EDGE_IPS (comma-separated) — the SAME variable the |
| +// agent's domains reconcile loop reads, so the console's point-your-DNS-here |
| +// guidance and the agent's DNS health checks can never name different |
| +// addresses. Unset is legal: guidance falls back to naming "this |
| +// deployment's edge IP" in prose. |
| +func edgeIPs() []string { |
| + var out []string |
| + for _, ip := range strings.Split(os.Getenv("PLATFORM_EDGE_IPS"), ",") { |
| + if ip = strings.TrimSpace(ip); ip != "" { |
| + out = append(out, ip) |
| + } |
| + } |
| + return out |
| +} |
| + |
| func envOr(k, def string) string { |
| if v := os.Getenv(k); v != "" { |
| return v |
diff --git a/internal/console/server.go b/internal/console/server.go| index d63ff66d6..0a09465e6 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -10,6 +10,7 @@ import ( |
| "crypto/ed25519" |
| "crypto/subtle" |
| "embed" |
| + "encoding/json" |
| "errors" |
| "fmt" |
| "html/template" |
| @@ -95,6 +96,11 @@ type Config struct { |
| // AppScheme is the scheme apps' public hosts are reached on — "https" |
| // (the default) everywhere but tests. |
| AppScheme string |
| + // EdgeIPs is this deployment's edge address list (PLATFORM_EDGE_IPS, |
| + // read at console start) — surfaced in the Domains section's |
| + // point-your-DNS-here guidance. nil is legal: guidance then says |
| + // "this deployment's edge IP" instead of inventing an address. |
| + EdgeIPs []string |
| } |
| // Server is the console's http.Handler. |
| @@ -499,7 +505,7 @@ func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| if len(apps) > 0 { |
| - views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, apps) |
| + views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, apps, s.cfg.EdgeIPs) |
| if err != nil { |
| log.Printf("console: assemble: %v", err) |
| s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.") |
| @@ -541,7 +547,11 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, []string{app}) |
| + // Backfill the account manifest for apps claimed before the domains |
| + // feature existed (or CLI-granted ones): best-effort, on the OWNER's own |
| + // first view — ownedApp above already proved the viewer owns this app. |
| + s.ensureAccountManifest(r.Context(), app, addr) |
| + views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, []string{app}, s.cfg.EdgeIPs) |
| if err != nil || len(views) != 1 { |
| log.Printf("console: assemble %s: %v", app, err) |
| s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.") |
| @@ -575,6 +585,59 @@ func (s *Server) audit(app, addr, action, detail string) { |
| } |
| } |
| +// writeAccountManifest lands apps/<app>/account.json in the deployment |
| +// bucket: {"account": <addr>, "owner": <addr>} — the account name IS the |
| +// claiming address for now (one account per user), and the agent's domains |
| +// reconcile pass materializes it into the registry from here. Overwrite-safe |
| +// put (status.PublishFleet's read-etag-then-Put idiom): the store's Put is |
| +// CAS-only, and a backfill racing another of the owner's own tabs must |
| +// retry, not fail. |
| +func (s *Server) writeAccountManifest(ctx context.Context, app, addr string) error { |
| + key := "apps/" + app + "/account.json" |
| + body, err := json.Marshal(map[string]string{"account": addr, "owner": addr}) |
| + if err != nil { |
| + return err |
| + } |
| + put := func() error { |
| + etag := "" |
| + if _, cur, err := s.cfg.Releases.Get(ctx, key); err == nil { |
| + etag = cur |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + _, err := s.cfg.Releases.Put(ctx, key, body, etag) |
| + return err |
| + } |
| + if err := put(); errors.Is(err, release.ErrCASConflict) { |
| + return put() // one retry on a lost race; a second loss surfaces |
| + } else if err != nil { |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +// ensureAccountManifest is the view-time backfill: write the manifest only |
| +// when none exists. An existing manifest is account truth someone already |
| +// wrote (possibly by hand, possibly naming a different account) — a page |
| +// view must never clobber it. Best-effort: failures are logged, never fail |
| +// the page. |
| +func (s *Server) ensureAccountManifest(ctx context.Context, app, addr string) { |
| + key := "apps/" + app + "/account.json" |
| + _, _, err := s.cfg.Releases.Get(ctx, key) |
| + if err == nil { |
| + return // already there — nothing to backfill |
| + } |
| + if !errors.Is(err, os.ErrNotExist) { |
| + log.Printf("console: account manifest %s: %v", key, err) |
| + return |
| + } |
| + if err := s.writeAccountManifest(ctx, app, addr); err != nil { |
| + log.Printf("console: backfill account manifest %s: %v", key, err) |
| + return |
| + } |
| + log.Printf("console: backfilled account manifest for %s (owner %s)", app, addr) |
| +} |
| + |
| // 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 |
| @@ -634,6 +697,13 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) { |
| fail("Created the app, but granting you ownership failed — ask an operator.") |
| return |
| } |
| + // The account manifest: claim is where account truth originates, and it |
| + // travels through the bucket to the agent's reconcile pass. Best-effort — |
| + // the claim already succeeded, and the app-page backfill is the safety |
| + // net if this write loses to a network hiccup. |
| + if err := s.writeAccountManifest(ctx, name, addr); err != nil { |
| + log.Printf("console: account manifest for %s: %v", name, err) |
| + } |
| 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) |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index c0cecb579..7a7aa6a06 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -3,6 +3,7 @@ package console |
| import ( |
| "context" |
| "crypto/ed25519" |
| + "encoding/json" |
| "errors" |
| "io" |
| "net/http" |
| @@ -233,7 +234,7 @@ func TestConsoleE2E(t *testing.T) { |
| t.Fatalf("/apps/kass = %d", status) |
| } |
| for _, want := range []string{ |
| - "kass.carlosapp.dev", "docs.kass.carlosapp.dev", "instance", "static", |
| + "Domains", "kass.carlosapp.dev", "docs.kass.carlosapp.dev", "custom", |
| "ready", "v1", "stable", "canary/x", "v2", |
| "bakes until ~", "<td>live</td>", "approximation", |
| } { |
| @@ -829,6 +830,10 @@ 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") |
| } |
| + // The Domains section is a read — keyless (read-only) mode still renders it. |
| + if !strings.Contains(body, "Domains") || !strings.Contains(body, "kass.carlosapp.dev") { |
| + t.Fatal("keyless console is missing the Domains section") |
| + } |
| 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") |
| @@ -857,6 +862,149 @@ func TestPromotionsDisabledWithoutKey(t *testing.T) { |
| } |
| } |
| +// ---- domains ---------------------------------------------------------------- |
| + |
| +// TestClaimWritesAccountManifest: claiming an app writes the account |
| +// manifest into the deployment bucket — the console originates account |
| +// truth, and it travels through the bucket (never a console→registry write). |
| +func TestClaimWritesAccountManifest(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + if status, loc, _ := postForm(t, c, fx.ts.URL+"/apps/new", url.Values{"csrf": {csrf}, "name": {"zephyr"}}); status != http.StatusSeeOther || loc != "/apps/zephyr" { |
| + t.Fatalf("claim = %d -> %q", status, loc) |
| + } |
| + body, _, err := fx.rel.Get(context.Background(), "apps/zephyr/account.json") |
| + if err != nil { |
| + t.Fatalf("claim wrote no account manifest: %v", err) |
| + } |
| + var m struct{ Account, Owner string } |
| + if err := json.Unmarshal(body, &m); err != nil { |
| + t.Fatalf("manifest not JSON: %v (%q)", err, body) |
| + } |
| + if m.Account != "paul@keymail.dev" || m.Owner != "paul@keymail.dev" { |
| + t.Errorf("manifest = %+v, want the claiming address as account and owner", m) |
| + } |
| +} |
| + |
| +// TestAppPageBackfillsAccountManifest: an already-claimed app with no |
| +// manifest (claimed before this feature shipped, or CLI-granted) gets one |
| +// written on the owner's first page view — and an existing manifest is |
| +// never overwritten by a view. |
| +func TestAppPageBackfillsAccountManifest(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + |
| + if _, _, err := fx.rel.Get(context.Background(), "apps/kass/account.json"); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("fixture already has a manifest: %v", err) |
| + } |
| + if status, _, _ := get(t, c, fx.ts.URL+"/apps/kass"); status != 200 { |
| + t.Fatalf("/apps/kass = %d", status) |
| + } |
| + body, _, err := fx.rel.Get(context.Background(), "apps/kass/account.json") |
| + if err != nil { |
| + t.Fatalf("view did not backfill the manifest: %v", err) |
| + } |
| + if !strings.Contains(string(body), "paul@keymail.dev") { |
| + t.Errorf("backfilled manifest = %q", body) |
| + } |
| + |
| + // A pre-existing manifest is account truth someone already wrote — |
| + // a page view must not clobber it. |
| + custom := []byte(`{"account":"someteam","owner":"paul@keymail.dev"}`) |
| + if _, err := fx.rel.Put(context.Background(), "apps/zephyr/account.json", custom, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.Grant("paul@keymail.dev", "zephyr"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if status, _, _ := get(t, c, fx.ts.URL+"/apps/zephyr"); status != 200 { |
| + t.Fatalf("/apps/zephyr = %d", status) |
| + } |
| + body, _, err = fx.rel.Get(context.Background(), "apps/zephyr/account.json") |
| + if err != nil || string(body) != string(custom) { |
| + t.Errorf("view clobbered an existing manifest: %q %v", body, err) |
| + } |
| +} |
| + |
| +// TestDomainViewStates is the pure Domains-row builder over route fixtures: |
| +// type from the platform flag, unknown DNS as "" (rendered em-dash), |
| +// guidance ONLY for non-pointing custom domains, canary labeling preserved. |
| +func TestDomainViewStates(t *testing.T) { |
| + edge := []string{"203.0.113.7", "198.51.100.9"} |
| + renew := time.Date(2026, 10, 12, 3, 0, 0, 0, time.UTC).Unix() |
| + cases := []struct { |
| + name string |
| + rt registry.Route |
| + ips []string |
| + want DomainView |
| + }{ |
| + {"platform pointing ready with cert", |
| + registry.Route{Host: "kass.bab.oncarlos.com", Platform: true, Status: "ready", |
| + DNSStatus: "pointing", CertExpiresAt: renew}, |
| + edge, |
| + DomainView{Domain: "kass.bab.oncarlos.com", Type: "platform", DNS: "pointing", |
| + TLS: "ready · renews 2026-10-12"}}, |
| + {"custom elsewhere gets guidance", |
| + registry.Route{Host: "kass.training", Status: "ready", DNSStatus: "elsewhere"}, |
| + edge, |
| + DomainView{Domain: "kass.training", Type: "custom", DNS: "elsewhere", TLS: "ready", |
| + Guidance: "Point an A record for kass.training at 203.0.113.7, 198.51.100.9. Checks run every few minutes."}}, |
| + {"custom unknown: em-dash DNS, still guided", |
| + registry.Route{Host: "kass.training", Status: "issuing"}, |
| + edge, |
| + DomainView{Domain: "kass.training", Type: "custom", DNS: "", TLS: "issuing", |
| + Guidance: "Point an A record for kass.training at 203.0.113.7, 198.51.100.9. Checks run every few minutes."}}, |
| + {"custom pointing: no guidance", |
| + registry.Route{Host: "kass.training", Status: "ready", DNSStatus: "pointing"}, |
| + edge, |
| + DomainView{Domain: "kass.training", Type: "custom", DNS: "pointing", TLS: "ready"}}, |
| + {"platform elsewhere: never guided (we made this domain; guidance is for THEIR dns)", |
| + registry.Route{Host: "kass.bab.oncarlos.com", Platform: true, Status: "ready", DNSStatus: "elsewhere"}, |
| + edge, |
| + DomainView{Domain: "kass.bab.oncarlos.com", Type: "platform", DNS: "elsewhere", TLS: "ready"}}, |
| + {"canary row keeps its label", |
| + registry.Route{Host: "rc.kass.bab.oncarlos.com", Platform: true, Status: "ready", |
| + DNSStatus: "pointing", Channel: "canary/rc", Canary: true}, |
| + edge, |
| + DomainView{Domain: "rc.kass.bab.oncarlos.com", Type: "platform · canary/rc", DNS: "pointing", TLS: "ready"}}, |
| + {"no known edge IPs: guidance still helps, without inventing an address", |
| + registry.Route{Host: "kass.training", Status: "ready", DNSStatus: "elsewhere"}, |
| + nil, |
| + DomainView{Domain: "kass.training", Type: "custom", DNS: "elsewhere", TLS: "ready", |
| + Guidance: "Point an A record for kass.training at this deployment's edge IP. Checks run every few minutes."}}, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + if got := domainView(c.rt, c.ips); got != c.want { |
| + t.Errorf("domainView:\n got %+v\n want %+v", got, c.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestAppPageRendersDomains: the app page's Domains table renders from |
| +// registry state — platform + custom rows, em-dash unknowns, and the |
| +// guidance row beneath a non-pointing custom domain. |
| +func TestAppPageRendersDomains(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.addRoute(t, registry.Route{Host: "kass.bab.carlosapp.dev", Kind: "instance", |
| + Status: "ready", App: "kass", Platform: true, DNSStatus: "pointing", Account: "paul@keymail.dev"}) |
| + c := signInPaul(t, fx) |
| + _, _, body := get(t, c, fx.ts.URL+"/apps/kass") |
| + for _, want := range []string{ |
| + "Domains", "kass.bab.carlosapp.dev", "platform", "pointing", |
| + "kass.carlosapp.dev", "custom", "—", |
| + "Point an A record for kass.carlosapp.dev at this deployment's edge IP.", |
| + "Checks run every few minutes.", |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("/apps/kass missing %q in:\n%s", want, body) |
| + } |
| + } |
| +} |
| + |
| // seedFleetJSON writes a small status/fleet.json fixture through the real |
| // production path (one box snapshot, then BuildFleet + PublishFleet reading |
| // the fixture registry's routes) rather than hand-rolling JSON — so the test |
diff --git a/internal/console/templates/app.html b/internal/console/templates/app.html| index c821cedd2..deef1e511 100644 |
| --- a/internal/console/templates/app.html |
| +++ b/internal/console/templates/app.html |
| @@ -3,24 +3,25 @@ |
| {{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| {{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| -<h2>Routes</h2> |
| -{{if .Detail.Routes}} |
| +<h2>Domains</h2> |
| +{{if .Detail.Domains}} |
| <div class="tablewrap"> |
| <table> |
| - <tr><th>Host</th><th>Kind</th><th>Status</th><th>Version</th><th>Channel</th><th>Hibernate</th></tr> |
| - {{range .Detail.Routes}} |
| + <tr><th>Domain</th><th>Type</th><th>DNS</th><th>TLS</th></tr> |
| + {{range .Detail.Domains}} |
| <tr> |
| - <td><code>{{.Host}}</code></td> |
| - <td>{{.Kind}}</td> |
| - <td>{{.Status}}</td> |
| - <td>{{if .Version}}<code>{{.Version}}</code>{{else}}—{{end}}</td> |
| - <td>{{.Channel}}</td> |
| - <td>{{if .Hibernate}}yes{{else}}—{{end}}</td> |
| + <td><code>{{.Domain}}</code></td> |
| + <td>{{.Type}}</td> |
| + <td>{{if .DNS}}{{.DNS}}{{else}}<span title="unknown — not checked yet">—</span>{{end}}</td> |
| + <td>{{if .TLS}}{{.TLS}}{{else}}<span title="unknown">—</span>{{end}}</td> |
| </tr> |
| + {{if .Guidance}} |
| + <tr><td colspan="4" class="small">{{.Guidance}}</td></tr> |
| + {{end}} |
| {{end}} |
| </table> |
| </div> |
| -{{else}}<p class="small">No routes yet.</p>{{end}} |
| +{{else}}<p class="small">No domains yet — the first shipped route brings one.</p>{{end}} |
| {{if and .Instances .Instances.Reporting}} |
| <h2>Instances</h2> |
diff --git a/internal/console/view.go b/internal/console/view.go| index fa1ab7b73..2ca4e5bf8 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -24,10 +24,55 @@ import ( |
| type AppView struct { |
| App string |
| Routes []RouteView |
| + Domains []DomainView |
| Channels []ChannelView |
| Canaries []CanaryView |
| } |
| +// DomainView is one row of the app page's Domains table — the product |
| +// surface for what routes always were. Unknowns stay empty strings here and |
| +// render as the em-dash convention; Guidance is non-empty ONLY for a custom |
| +// domain that isn't pointing at this deployment yet. |
| +type DomainView struct { |
| + Domain, Type, DNS, TLS string |
| + Guidance string |
| +} |
| + |
| +// domainView folds one registry route into its Domains row. edgeIPs is the |
| +// console's PLATFORM_EDGE_IPS (nil = unknown; guidance then names "this |
| +// deployment's edge IP" rather than inventing an address). |
| +func domainView(rt registry.Route, edgeIPs []string) DomainView { |
| + typ := "custom" |
| + if rt.Platform { |
| + typ = "platform" |
| + } |
| + // Canary labeling is preserved: a canary follower's row says so, right |
| + // in its type cell, whichever type it is. |
| + if strings.HasPrefix(rt.Channel, "canary/") { |
| + typ += " · " + rt.Channel |
| + } |
| + // TLS: the route's status is the issuance signal (issuing/ready/failed); |
| + // a known cert expiry adds the renew-by date. |
| + tls := rt.Status |
| + if tls == "" { |
| + tls = "ready" // registry contract: empty status means ready |
| + } |
| + if rt.CertExpiresAt > 0 { |
| + tls += " · renews " + time.Unix(rt.CertExpiresAt, 0).UTC().Format("2006-01-02") |
| + } |
| + dv := DomainView{Domain: rt.Host, Type: typ, DNS: rt.DNSStatus, TLS: tls} |
| + // Guidance only for custom + non-pointing: platform domains are ours to |
| + // fix, and a pointing custom domain needs no pointing instructions. |
| + if !rt.Platform && rt.DNSStatus != "pointing" { |
| + target := "this deployment's edge IP" |
| + if len(edgeIPs) > 0 { |
| + target = strings.Join(edgeIPs, ", ") |
| + } |
| + dv.Guidance = "Point an A record for " + rt.Host + " at " + target + ". Checks run every few minutes." |
| + } |
| + return dv |
| +} |
| + |
| // 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. |
| @@ -96,9 +141,10 @@ func openRegistry(path string) (*registry.Registry, error) { |
| // Assemble builds the view for the given (owned) apps: routes from the |
| // registry at regPath, channels from the release store s verified against |
| -// pub. Every app in apps gets an AppView, even an empty one; per-channel |
| -// failures degrade to ChannelView.Err. |
| -func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, apps []string) ([]AppView, error) { |
| +// pub, Domains rows with guidance targeting edgeIPs (nil = unknown). Every |
| +// app in apps gets an AppView, even an empty one; per-channel failures |
| +// degrade to ChannelView.Err. |
| +func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, apps []string, edgeIPs []string) ([]AppView, error) { |
| ctx := context.Background() |
| reg, err := openRegistry(regPath) |
| if err != nil { |
| @@ -109,6 +155,7 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, apps []str |
| return nil, err |
| } |
| byApp := map[string][]RouteView{} |
| + domainsByApp := map[string][]DomainView{} |
| for _, rt := range routes { |
| ch := rt.Channel |
| if ch == "" { |
| @@ -118,6 +165,7 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, apps []str |
| Host: rt.Host, Kind: rt.Kind, Status: rt.Status, |
| Version: rt.Version, Channel: ch, Hibernate: rt.Hibernate, |
| }) |
| + domainsByApp[rt.App] = append(domainsByApp[rt.App], domainView(rt, edgeIPs)) |
| } |
| now := time.Now() |
| sorted := append([]string(nil), apps...) |
| @@ -133,6 +181,7 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, apps []str |
| out = append(out, AppView{ |
| App: app, |
| Routes: byApp[app], // List orders by host already |
| + Domains: domainsByApp[app], |
| Channels: channels, |
| Canaries: assembleCanaries(channels, byApp[app]), |
| }) |
diff --git a/internal/console/view_test.go b/internal/console/view_test.go| index 66d3f00b5..17ec58918 100644 |
| --- a/internal/console/view_test.go |
| +++ b/internal/console/view_test.go |
| @@ -108,7 +108,7 @@ func findChannel(t *testing.T, chans []ChannelView, name string) ChannelView { |
| func TestAssemble(t *testing.T) { |
| regPath, store, _, pub := buildFixtures(t) |
| - views, err := Assemble(regPath, store, pub, []string{"kass"}) |
| + views, err := Assemble(regPath, store, pub, []string{"kass"}, nil) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -164,7 +164,7 @@ func TestAssembleCanaries(t *testing.T) { |
| Status: "ready", App: "kass", Channel: "canary/x", Canary: true}); err != nil { |
| t.Fatal(err) |
| } |
| - views, err := Assemble(regPath, store, pub, []string{"kass"}) |
| + views, err := Assemble(regPath, store, pub, []string{"kass"}, nil) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -186,7 +186,7 @@ func TestAssembleCanaries(t *testing.T) { |
| func TestAssembleFiltersToGivenApps(t *testing.T) { |
| regPath, store, _, pub := buildFixtures(t) |
| - views, err := Assemble(regPath, store, pub, []string{"kass"}) |
| + views, err := Assemble(regPath, store, pub, []string{"kass"}, nil) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -204,7 +204,7 @@ func TestAssembleFiltersToGivenApps(t *testing.T) { |
| func TestAssembleEmptyAppStillListed(t *testing.T) { |
| regPath, store, _, pub := buildFixtures(t) |
| - views, err := Assemble(regPath, store, pub, []string{"ghost", "kass"}) |
| + views, err := Assemble(regPath, store, pub, []string{"ghost", "kass"}, nil) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -222,7 +222,7 @@ func TestAssembleDegradesBadChannel(t *testing.T) { |
| if _, err := store.Put(context.Background(), "apps/kass/channels/beta.json", []byte("not a signed pointer"), ""); err != nil { |
| t.Fatal(err) |
| } |
| - views, err := Assemble(regPath, store, pub, []string{"kass"}) |
| + views, err := Assemble(regPath, store, pub, []string{"kass"}, nil) |
| if err != nil { |
| t.Fatalf("Assemble must not fail on a bad channel: %v", err) |
| } |