console: OwnerAppMetrics matches operator-stamped units
OwnerAppMetrics gains a third allowlist key, unitByHost: an opt-in
per-host map from operator-stamped bespoke unit name (registry
Route.Unit, #110) to host, drawn from the caller's own route rows.
The security boundary comment's promise ("a unit the caller did not
ask for cannot be reached") stays true — a nil or empty map matches
nothing new, and appResourceMetrics builds unitByHost only from
routes whose Account matches the viewed account, the same filter the
hosts slice already applies.
appResourceMetrics (server.go) builds unitByHost from Task 1's
RouteView.Unit and passes it through. Task 5's box sweep already
emits readings for these routes keyed by the real unit name (e.g.
titogo@vicky.service); this closes the loop so the owner's app page
sees them.
TestOwnerAppMetricsMatchesStampedUnit pins both halves: WITH the
unitByHost entry the stamped-unit host reports (Box, CPU/mem);
WITHOUT it the same host stays Reporting() false, proving the key is
opt-in per host, not a wildcard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+82
−12
internal/console/metricsview.go+16 −5internal/console/metricsview_test.go+61 −6internal/console/server.go+5 −1
diff --git a/internal/console/metricsview.go b/internal/console/metricsview.go| index 2aae2b16f..0c2f5e488 100644 |
| --- a/internal/console/metricsview.go |
| +++ b/internal/console/metricsview.go |
| @@ -697,16 +697,19 @@ func (m InstanceMetrics) Reporting() bool { return m.Box != "" } |
| // OwnerAppMetrics filters the cached 48h sweep down to EXACTLY the given |
| // hosts' own per-instance readings — carlos-app@<host>.service for a |
| -// unit-backed tenant, carlos-exec@<host> for an exec-backed one. It returns |
| -// unit-level rows only — no host totals, no other units, no alert data. The |
| -// allowlist is the hosts slice the caller derived from the app's OWN routes; |
| -// nothing else can enter the view. |
| +// unit-backed tenant, carlos-exec@<host> for an exec-backed one, or the |
| +// operator-stamped bespoke unit named in unitByHost[host] (registry |
| +// Route.Unit, #110) when the caller supplies one. It returns unit-level |
| +// rows only — no host totals, no other units, no alert data. The allowlist |
| +// is the hosts slice the caller derived from the app's OWN routes, plus |
| +// unitByHost entries drawn from those same routes; nothing else can enter |
| +// the view. |
| // |
| // Every allowlisted host gets exactly one row, in host order, whether or not |
| // the sweep saw it: a host with no unit data is an UNKNOWN row (nil |
| // readings, Reporting false), never zeros and never silently dropped — an |
| // owner whose instance is asleep must still see it listed. |
| -func (s *Server) OwnerAppMetrics(ctx context.Context, hosts []string) ([]InstanceMetrics, error) { |
| +func (s *Server) OwnerAppMetrics(ctx context.Context, hosts []string, unitByHost map[string]string) ([]InstanceMetrics, error) { |
| // The allowlist, keyed by the unit names each host can legally match. |
| // Matching is exact-string on this map: a unit the caller did not ask |
| // for cannot be reached by any path below. |
| @@ -722,6 +725,14 @@ func (s *Server) OwnerAppMetrics(ctx context.Context, hosts []string) ([]Instanc |
| // rule below picks deterministically, no special case. |
| want[activator.UnitName(h)] = h |
| want[activator.ExecName(h)] = h |
| + // The third legal key: an operator-stamped bespoke unit (registry |
| + // Route.Unit, #110). Opt-in per host — the caller maps host → |
| + // stamped unit from the route rows it already holds; an empty map |
| + // costs nothing and matches nothing new. (A nil map indexes fine |
| + // in Go — no guard needed.) |
| + if u := unitByHost[h]; u != "" { |
| + want[u] = h |
| + } |
| } |
| if len(want) == 0 { |
| return nil, nil |
diff --git a/internal/console/metricsview_test.go b/internal/console/metricsview_test.go| index b2e260795..131b1cba7 100644 |
| --- a/internal/console/metricsview_test.go |
| +++ b/internal/console/metricsview_test.go |
| @@ -1015,14 +1015,14 @@ func TestSweepAllFailsOnUnreadableChunk(t *testing.T) { |
| // having no boxes at all) never an error path the app page has to handle. |
| func TestOwnerAppMetricsEmptyHosts(t *testing.T) { |
| srv, _ := newMetricsFixture(t) |
| - rows, err := srv.OwnerAppMetrics(context.Background(), nil) |
| + rows, err := srv.OwnerAppMetrics(context.Background(), nil, nil) |
| if err != nil { |
| t.Fatalf("empty hosts errored: %v", err) |
| } |
| if len(rows) != 0 { |
| t.Fatalf("empty hosts = %+v, want no rows", rows) |
| } |
| - rows, err = srv.OwnerAppMetrics(context.Background(), []string{}) |
| + rows, err = srv.OwnerAppMetrics(context.Background(), []string{}, nil) |
| if err != nil || len(rows) != 0 { |
| t.Fatalf("empty slice = %+v, %v; want no rows, no error", rows, err) |
| } |
| @@ -1053,7 +1053,7 @@ func TestOwnerAppMetricsFiltersToOwnUnits(t *testing.T) { |
| t.Fatal(err) |
| } |
| - rows, err := srv.OwnerAppMetrics(ctx, []string{"owned.example.com", "asleep.example.com"}) |
| + rows, err := srv.OwnerAppMetrics(ctx, []string{"owned.example.com", "asleep.example.com"}, nil) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -1140,7 +1140,7 @@ func TestOwnerAppMetricsSurvivesHealthyFlushCycle(t *testing.T) { |
| write("box-healthy-2", "flush2.example.com", 7*time.Minute) |
| write("box-dead", "dead.example.com", 8*time.Minute+30*time.Second) |
| - rows, err := srv.OwnerAppMetrics(ctx, []string{"flush1.example.com", "flush2.example.com", "dead.example.com"}) |
| + rows, err := srv.OwnerAppMetrics(ctx, []string{"flush1.example.com", "flush2.example.com", "dead.example.com"}, nil) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -1292,7 +1292,7 @@ func TestOwnerAppMetricsSeesExecBackedInstances(t *testing.T) { |
| t.Fatal(err) |
| } |
| - rows, err := srv.OwnerAppMetrics(ctx, []string{"keymail.example.com"}) |
| + rows, err := srv.OwnerAppMetrics(ctx, []string{"keymail.example.com"}, nil) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -1349,7 +1349,7 @@ func TestOwnerAppMetricsExecAndUnitKeysNeverDoubleReport(t *testing.T) { |
| t.Fatal(err) |
| } |
| - rows, err := srv.OwnerAppMetrics(ctx, []string{"flip.example.com"}) |
| + rows, err := srv.OwnerAppMetrics(ctx, []string{"flip.example.com"}, nil) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -1364,3 +1364,58 @@ func TestOwnerAppMetricsExecAndUnitKeysNeverDoubleReport(t *testing.T) { |
| t.Fatalf("CPU = %v, want the freshest box's 0.11", got.CPUFrac) |
| } |
| } |
| + |
| +// TestOwnerAppMetricsMatchesStampedUnit pins the third allowlist key: an |
| +// operator-stamped bespoke unit (registry Route.Unit, #110) reports under |
| +// its real systemd unit name, not carlos-app@<host>.service — and the |
| +// sweep can only match it when the caller opts a host into that name via |
| +// unitByHost. Without the entry the same host stays unreached, proving the |
| +// third key is opt-in per host, not a wildcard that reopens the boundary. |
| +func TestOwnerAppMetricsMatchesStampedUnit(t *testing.T) { |
| + srv, rel := newMetricsFixture(t) |
| + ctx := context.Background() |
| + at := time.Now().UTC().Add(-time.Minute) |
| + if err := metrics.WriteRaw(ctx, rel, metrics.RawChunk{ |
| + V: 1, Box: "b1", Hour: metrics.HourOf(at), |
| + Samples: []metrics.Sample{{ |
| + At: at, |
| + Units: map[string]metrics.UnitSample{ |
| + "titogo@vicky.service": {CPUFrac: p(0.33), MemBytes: pi64(3333)}, |
| + }, |
| + }}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + rows, err := srv.OwnerAppMetrics(ctx, []string{"vicky.go.tito.io"}, |
| + map[string]string{"vicky.go.tito.io": "titogo@vicky.service"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(rows) != 1 { |
| + t.Fatalf("rows = %+v, want exactly the one allowlisted host", rows) |
| + } |
| + got := rows[0] |
| + if got.Host != "vicky.go.tito.io" || !got.Reporting() { |
| + t.Fatalf("stamped-unit host not reporting: %+v", got) |
| + } |
| + if got.Box != "b1" { |
| + t.Fatalf("Box = %q, want b1", got.Box) |
| + } |
| + if got.CPUFrac == nil || *got.CPUFrac != 0.33 { |
| + t.Fatalf("CPU = %v, want 0.33", got.CPUFrac) |
| + } |
| + |
| + // Without the unitByHost entry, the same host must NOT match — the |
| + // third key is opt-in per host, not a wildcard. |
| + rows, err = srv.OwnerAppMetrics(ctx, []string{"vicky.go.tito.io"}, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(rows) != 1 { |
| + t.Fatalf("rows = %+v, want exactly the one allowlisted host", rows) |
| + } |
| + if rows[0].Reporting() { |
| + t.Fatalf("host reported without an opt-in unitByHost entry: %+v", rows[0]) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index df56e9399..07bb67c3c 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -1582,14 +1582,18 @@ func (s *Server) appPage(w http.ResponseWriter, r *http.Request, tab string) (pa |
| func (s *Server) appResourceMetrics(ctx context.Context, view *AppView, acct *Account) []InstanceMetrics { |
| hosts := make([]string, 0, len(view.Routes)) |
| hib := make(map[string]bool, len(view.Routes)) |
| + unitByHost := make(map[string]string, len(view.Routes)) |
| for _, rt := range view.Routes { |
| if rt.Account != acct.Sqid { |
| continue |
| } |
| hosts = append(hosts, rt.Host) |
| hib[rt.Host] = rt.Hibernate |
| + if rt.Unit != "" { |
| + unitByHost[rt.Host] = rt.Unit |
| + } |
| } |
| - am, err := s.OwnerAppMetrics(ctx, hosts) |
| + am, err := s.OwnerAppMetrics(ctx, hosts, unitByHost) |
| if err != nil { |
| log.Printf("console: app metrics %s: %v", view.App, err) |
| return nil |