diff --git a/internal/console/public/app.js b/internal/console/public/app.js index 91864650..b11f55fe 100644 --- a/internal/console/public/app.js +++ b/internal/console/public/app.js @@ -244,6 +244,7 @@ async function loadData() { setTableMessage('table-enrollments', 4, 'Restricted to administrators.'); } renderNodesTable(data.enrolled_nodes || []); + renderServicesTable(data.node_catalog || {}, buildLabelsByPeer(data.enrolled_nodes || [])); renderRoutersTable(data.active_routers || []); renderRouterTopography(data.active_routers || []); renderBootstrapTokensTable(data.bootstrap_tokens || []); @@ -332,16 +333,49 @@ function renderUsersTable(users) { `).join(''); } +// Renders an operator-declared labels map (e.g. {component: "stvv", role: +// "producer"}, from sam-node.yaml's labels: key) as a compact key=value +// list - the closest thing to a node mnemonic that exists today, since SAM +// has no dedicated name/alias field. Returns '' if there are none. +function formatLabels(labels) { + const entries = Object.entries(labels || {}); + if (entries.length === 0) { + return ''; + } + return entries.map(([k, v]) => `${k}=${v}`).join(', '); +} + +// A peer ID cell: the raw ID (still the real, authoritative identifier) +// with its operator-declared labels shown underneath when present. +function peerCell(peerID, labels) { + const labelText = formatLabels(labels); + const sub = labelText + ? `
${escapeHTML(labelText)}
` + : ''; + return `${escapeHTML(peerID)}${sub}`; +} + +// Builds a peer ID -> labels lookup from the enrolled_nodes list, so other +// tables (e.g. Services) can show the same labels next to a bare peer ID +// without a second fetch. +function buildLabelsByPeer(nodes) { + const byPeer = {}; + for (const node of nodes || []) { + byPeer[node.PeerID] = node.Labels || {}; + } + return byPeer; +} + function renderNodesTable(nodes) { const tbody = document.getElementById('table-nodes'); if (nodes.length === 0) { tbody.innerHTML = `No enrolled nodes found`; return; } - + tbody.innerHTML = nodes.map(node => ` - ${escapeHTML(node.PeerID)} + ${peerCell(node.PeerID, node.Labels)} ${escapeHTML(node.Role)} ${escapeHTML(node.OwnerID)} @@ -353,6 +387,41 @@ function renderNodesTable(nodes) { `).join(''); } +// Service type is a protobuf enum (SERVICE_TYPE_MCP = 1, SERVICE_TYPE_INFERENCE = 2, +// SERVICE_TYPE_A2A = 3); plain encoding/json on the Go side emits the bare +// int, not the enum name, and omits it entirely (omitempty) if it's ever 0. +const SERVICE_TYPE_NAMES = { 1: 'mcp', 2: 'inference', 3: 'a2a' }; + +function renderServicesTable(nodeCatalog, labelsByPeer) { + const tbody = document.getElementById('table-services'); + const peerIDs = Object.keys(nodeCatalog || {}); + const rows = []; + for (const peerID of peerIDs) { + const entry = nodeCatalog[peerID] || {}; + const services = entry.services || []; + for (const svc of services) { + if (svc) { + rows.push({ peerID, reportedAt: entry.reported_at, svc }); + } + } + } + + if (rows.length === 0) { + tbody.innerHTML = `No nodes have reported any services yet`; + return; + } + + tbody.innerHTML = rows.map(({ peerID, reportedAt, svc }) => ` + + ${escapeHTML(svc.name || '')} + ${escapeHTML(SERVICE_TYPE_NAMES[svc.type] || 'unknown')} + ${escapeHTML(svc.description || '')} + ${peerCell(peerID, (labelsByPeer || {})[peerID])} + ${reportedAt ? escapeHTML(new Date(reportedAt).toLocaleString()) : '-'} + + `).join(''); +} + function getStatusBadge(status) { if (status === 0 || status === 'ENROLLMENT_STATUS_PENDING') { return `Pending`; diff --git a/internal/console/public/index.html b/internal/console/public/index.html index 5a6f6bfd..ec55b739 100644 --- a/internal/console/public/index.html +++ b/internal/console/public/index.html @@ -71,6 +71,10 @@

SAM Console

Nodes + + + Services + Enrollments @@ -199,6 +203,32 @@

Enrolled Nodes

+ +
+
+

Mesh Services

+
+
+
+ + + + + + + + + + + + + +
ServiceTypeDescriptionNode IDReported At
Loading...
+
+
+
+
diff --git a/internal/console/public/style.css b/internal/console/public/style.css index 782e4264..e3b4105d 100644 --- a/internal/console/public/style.css +++ b/internal/console/public/style.css @@ -646,6 +646,13 @@ body { border-bottom: none; } +/* An operator-declared label line under a bare peer ID (see peerCell in + app.js) - a class, not an inline style, so CSP style-src can stay strict. */ +.cell-subtext { + color: var(--text-secondary); + font-size: 0.85em; +} + .text-center { text-align: center; } diff --git a/internal/controlplane/catalog.go b/internal/controlplane/catalog.go new file mode 100644 index 00000000..dcf75c07 --- /dev/null +++ b/internal/controlplane/catalog.go @@ -0,0 +1,141 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controlplane + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "strings" + "time" + + "github.com/google/sam/api" + "github.com/google/sam/internal/identity" +) + +// nodeCatalogEntry is what HandleNodeCatalog caches per reporting peer. +type nodeCatalogEntry struct { + Services []*api.ServiceInfo `json:"services"` + ReportedAt time.Time `json:"reported_at"` +} + +// nodeCatalogRequest is HandleNodeCatalog's request body. The reporting +// peer's identity comes from its verified Biscuit, never from this body - +// a node can only ever report on itself. +type nodeCatalogRequest struct { + Services []*api.ServiceInfo `json:"services"` +} + +// catalogSnapshot returns a stable copy of the current node service catalog +// cache, safe to range over or marshal without holding catalogMu. +func (s *Server) catalogSnapshot() map[string]nodeCatalogEntry { + s.catalogMu.RLock() + defer s.catalogMu.RUnlock() + snap := make(map[string]nodeCatalogEntry, len(s.catalog)) + for k, v := range s.catalog { + snap[k] = v + } + return snap +} + +// HandleNodeCatalog HTTP POST /nodes/catalog - a node self-reports the +// services it currently has registered locally (the same data +// list_local_services already answers on the node itself), so the control +// plane can show mesh-wide service topology without needing to be a DHT +// participant or open a P2P connection to every enrolled node itself. +// +// This is a live-status cache, not authoritative state: a node that goes +// offline without ever reporting an empty catalog just leaves its last +// report in place until ReportedAt visibly goes stale. Good enough for an +// admin-facing "what's running where" view; not a substitute for the real +// per-request Biscuit authorization every actual service call still goes +// through independently. +func (s *Server) HandleNodeCatalog(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + http.Error(w, "Missing node Biscuit token in Authorization header", http.StatusUnauthorized) + return + } + biscuitBytes, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHeader, "Bearer ")) + if err != nil { + http.Error(w, "Malformed base64 token", http.StatusBadRequest) + return + } + + ctx := r.Context() + validKeys, err := s.store.GetAllValidKeys(ctx) + if err != nil { + logger.Errorf("Failed to retrieve valid signing keys: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + var trustedKeys []ed25519.PublicKey + for _, k := range validKeys { + trustedKeys = append(trustedKeys, k.Public) + } + + peerID, err := identity.VerifyAndExtractPeerID(trustedKeys, biscuitBytes, s.config.BiscuitTimeout) + if err != nil { + logger.Warnw("Invalid biscuit presented to /nodes/catalog", "error", err) + http.Error(w, "Invalid biscuit: "+err.Error(), http.StatusUnauthorized) + return + } + + nodeRecord, err := s.store.GetNode(ctx, peerID.String()) + if err != nil || nodeRecord == nil || nodeRecord.CheckAdmission(time.Now()) != nil { + http.Error(w, "Node not enrolled or not admitted", http.StatusUnauthorized) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes) + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read body", http.StatusBadRequest) + return + } + defer func() { _ = r.Body.Close() }() + + var req nodeCatalogRequest + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "Invalid JSON body", http.StatusBadRequest) + return + } + + // A malformed report (e.g. {"services": [null]}) unmarshals into a nil + // element rather than failing - filter those out so a bad report from one + // node can't crash rendering for every node's entry in the console. + var validServices []*api.ServiceInfo + for _, svc := range req.Services { + if svc != nil { + validServices = append(validServices, svc) + } + } + + s.catalogMu.Lock() + s.catalog[peerID.String()] = nodeCatalogEntry{ + Services: validServices, + ReportedAt: time.Now(), + } + s.catalogMu.Unlock() + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/controlplane/catalog_test.go b/internal/controlplane/catalog_test.go new file mode 100644 index 00000000..469492ea --- /dev/null +++ b/internal/controlplane/catalog_test.go @@ -0,0 +1,161 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controlplane + +import ( + "bytes" + "context" + "encoding/base64" + "net/http" + "testing" + "time" + + "github.com/google/sam/api" + "github.com/google/sam/internal/identity" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/peer" +) + +// postCatalog POSTs a raw catalog report body to /nodes/catalog under the +// given biscuit and returns the response status. +func postCatalog(t *testing.T, cpURL string, biscuit []byte, body []byte) int { + t.Helper() + + req, err := http.NewRequest(http.MethodPost, cpURL+"/nodes/catalog", bytes.NewReader(body)) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + if biscuit != nil { + req.Header.Set("Authorization", "Bearer "+base64.StdEncoding.EncodeToString(biscuit)) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + defer func() { _ = resp.Body.Close() }() + return resp.StatusCode +} + +func TestHandleNodeCatalog(t *testing.T) { + t.Parallel() + + srv, store, cpURL := setupTestServer(t, "") + defer func() { + _ = srv.Close() + _ = store.Close() + }() + + ctx := context.Background() + _, biscuitBytes := enrollRefreshTestNode(t, ctx, store) + + body := []byte(`{"services":[{"type":1,"name":"stvv-compliance-docs","description":"doc lookup"},null,{"type":1,"name":"everything","description":"reference server"}]}`) + if got := postCatalog(t, cpURL, biscuitBytes, body); got != http.StatusNoContent { + t.Fatalf("HandleNodeCatalog: got status %d, want %d", got, http.StatusNoContent) + } + + snap := srv.catalogSnapshot() + if len(snap) != 1 { + t.Fatalf("expected exactly one reporting peer in the catalog, got %d", len(snap)) + } + for peerID, entry := range snap { + if len(entry.Services) != 2 { + t.Fatalf("expected the null service element to be filtered out, got %d services: %+v", len(entry.Services), entry.Services) + } + for i, svc := range entry.Services { + if svc == nil { + t.Fatalf("service at index %d is nil, filtering failed", i) + } + } + if entry.ReportedAt.IsZero() { + t.Errorf("ReportedAt was not set for peer %s", peerID) + } + } +} + +func TestHandleNodeCatalog_MissingAuth(t *testing.T) { + t.Parallel() + + srv, store, cpURL := setupTestServer(t, "") + defer func() { + _ = srv.Close() + _ = store.Close() + }() + + body := []byte(`{"services":[]}`) + if got := postCatalog(t, cpURL, nil, body); got != http.StatusUnauthorized { + t.Fatalf("missing Authorization header: got status %d, want %d", got, http.StatusUnauthorized) + } +} + +func TestHandleNodeCatalog_UnenrolledPeer(t *testing.T) { + t.Parallel() + + srv, store, cpURL := setupTestServer(t, "") + defer func() { + _ = srv.Close() + _ = store.Close() + }() + + ctx := context.Background() + cpPriv, _, err := store.GetCurrentKey(ctx) + if err != nil { + t.Fatalf("GetCurrentKey: %v", err) + } + + // A biscuit minted for a peer that was never enrolled (or whose + // enrollment record has since been removed) must be rejected: a node can + // only ever report a catalog for itself, and self-reporting requires an + // admitted enrollment record to attribute the report to. + priv, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) + if err != nil { + t.Fatalf("GenerateKeyPair: %v", err) + } + strangerPeer, err := peer.IDFromPrivateKey(priv) + if err != nil { + t.Fatalf("IDFromPrivateKey: %v", err) + } + strangerBiscuit, err := identity.MintBootstrapBiscuitToken( + cpPriv, strangerPeer, api.RoleNode, time.Now().Add(api.BiscuitTokenTTL), nil, nil, + ) + if err != nil { + t.Fatalf("MintBootstrapBiscuitToken: %v", err) + } + + body := []byte(`{"services":[]}`) + if got := postCatalog(t, cpURL, strangerBiscuit, body); got != http.StatusUnauthorized { + t.Fatalf("unenrolled peer: got status %d, want %d", got, http.StatusUnauthorized) + } +} + +func TestHandleNodeCatalog_MethodNotAllowed(t *testing.T) { + t.Parallel() + + srv, store, cpURL := setupTestServer(t, "") + defer func() { + _ = srv.Close() + _ = store.Close() + }() + + resp, err := http.Get(cpURL + "/nodes/catalog") + if err != nil { + t.Fatalf("Get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("GET /nodes/catalog: got status %d, want %d", resp.StatusCode, http.StatusMethodNotAllowed) + } +} diff --git a/internal/controlplane/server.go b/internal/controlplane/server.go index 5f398ba9..c771df5f 100644 --- a/internal/controlplane/server.go +++ b/internal/controlplane/server.go @@ -77,6 +77,13 @@ type Server struct { providersMu sync.RWMutex providers map[string]*oidc.Provider + // catalogMu/catalog cache each node's self-reported local service list + // (see HandleNodeCatalog), keyed by peer ID. In-memory only: this is a + // live-status view, not authoritative state, so it's fine to lose on + // restart - every node re-reports on its own next periodic push. + catalogMu sync.RWMutex + catalog map[string]nodeCatalogEntry + ctx context.Context cancel context.CancelFunc wg sync.WaitGroup @@ -98,6 +105,7 @@ func NewServer(config Options, store storage.Store) (*Server, error) { mesh: NewNopMeshAdapter(), limiter: rate.NewLimiter(rate.Limit(EnrollRateLimit), EnrollBurst), providers: make(map[string]*oidc.Provider), + catalog: make(map[string]nodeCatalogEntry), ctx: ctx, cancel: cancel, }, nil @@ -204,6 +212,7 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/enroll", s.HandleEnroll) mux.HandleFunc("/enroll/status", s.HandleEnrollStatus) mux.HandleFunc("/refresh", s.HandleRefresh) + mux.HandleFunc("/nodes/catalog", s.HandleNodeCatalog) mux.HandleFunc("/admin/bootstrap-tokens", s.HandleAdminBootstrapTokens) mux.HandleFunc("/admin/enrollments", s.HandleAdminEnrollments) mux.HandleFunc("/admin/enrollments/", s.HandleAdminEnrollmentAction) diff --git a/internal/controlplane/ui.go b/internal/controlplane/ui.go index cb688fcb..32b9dd49 100644 --- a/internal/controlplane/ui.go +++ b/internal/controlplane/ui.go @@ -86,6 +86,7 @@ func (s *Server) HandleAdminStatus(w http.ResponseWriter, r *http.Request) { "enrollment_requests": reqs, "bootstrap_tokens": tokens, "policy_json": policyJSON, + "node_catalog": s.catalogSnapshot(), } w.Header().Set("Content-Type", "application/json") diff --git a/internal/node/controlplane.go b/internal/node/controlplane.go index de40ed4a..9e8ce145 100644 --- a/internal/node/controlplane.go +++ b/internal/node/controlplane.go @@ -19,6 +19,7 @@ import ( "context" "crypto/ed25519" "encoding/base64" + "encoding/json" "fmt" "io" "net/http" @@ -251,3 +252,48 @@ func FetchMeshPolicy(ctx context.Context, controlPlaneURL string, biscuitToken [ return &policyResp, nil } + +// nodeCatalogRequest mirrors internal/controlplane/catalog.go's request +// body - kept as a plain JSON struct here rather than a shared package +// import, matching how this file already treats /policies as a boundary +// between the two components. +type nodeCatalogRequest struct { + Services []*api.ServiceInfo `json:"services"` +} + +// ReportNodeCatalog self-reports this node's locally registered services to +// the control plane's /nodes/catalog endpoint, so an admin can see mesh-wide +// service topology (see catalog.go's HandleNodeCatalog for why this exists +// instead of the control plane discovering it via DHT/P2P itself). +func ReportNodeCatalog(ctx context.Context, controlPlaneURL string, biscuitToken []byte, services []*api.ServiceInfo) error { + if !strings.HasPrefix(controlPlaneURL, "http://") && !strings.HasPrefix(controlPlaneURL, "https://") { + controlPlaneURL = "https://" + controlPlaneURL + } + controlPlaneURL = strings.TrimSuffix(controlPlaneURL, "/") + + payload, err := json.Marshal(nodeCatalogRequest{Services: services}) + if err != nil { + return fmt.Errorf("failed to encode catalog report: %w", err) + } + + urlStr := controlPlaneURL + "/nodes/catalog" + req, err := http.NewRequestWithContext(ctx, "POST", urlStr, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("failed to create HTTP request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+base64.StdEncoding.EncodeToString(biscuitToken)) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusNoContent { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("control plane returned status %s: %s", resp.Status, string(body)) + } + return nil +} diff --git a/internal/node/controlplane_test.go b/internal/node/controlplane_test.go index e47508e3..d4adc201 100644 --- a/internal/node/controlplane_test.go +++ b/internal/node/controlplane_test.go @@ -17,6 +17,8 @@ package node import ( "context" "crypto/ed25519" + "encoding/base64" + "encoding/json" "net/http" "net/http/httptest" "reflect" @@ -220,3 +222,54 @@ func TestSyncMeshConfig(t *testing.T) { t.Errorf("Expected saved addrs %v, got %v", expectedInfo.RouterAddresses, savedAddrsStr) } } + +func TestReportNodeCatalog(t *testing.T) { + services := []*api.ServiceInfo{ + {Type: api.ServiceType_SERVICE_TYPE_MCP, Name: "stvv-compliance-docs", Description: "doc lookup"}, + } + + var gotAuth string + var gotReq nodeCatalogRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("Expected POST, got %s", r.Method) + } + if r.URL.Path != "/nodes/catalog" { + t.Errorf("Expected path /nodes/catalog, got %s", r.URL.Path) + } + gotAuth = r.Header.Get("Authorization") + if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil { + t.Errorf("failed to decode request body: %v", err) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + biscuitToken := []byte("fake-biscuit-bytes") + if err := ReportNodeCatalog(context.Background(), server.URL, biscuitToken, services); err != nil { + t.Fatalf("ReportNodeCatalog failed: %v", err) + } + + wantAuth := "Bearer " + base64.StdEncoding.EncodeToString(biscuitToken) + if gotAuth != wantAuth { + t.Errorf("Expected Authorization header %q, got %q", wantAuth, gotAuth) + } + if len(gotReq.Services) != 1 || gotReq.Services[0].Name != "stvv-compliance-docs" { + t.Errorf("Expected relayed services %v, got %v", services, gotReq.Services) + } +} + +func TestReportNodeCatalog_HTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "node not enrolled or not admitted", http.StatusUnauthorized) + })) + defer server.Close() + + err := ReportNodeCatalog(context.Background(), server.URL, []byte("fake-biscuit-bytes"), nil) + if err == nil { + t.Fatal("Expected error, got nil") + } + if !strings.Contains(err.Error(), "control plane returned status 401") { + t.Errorf("Expected error to mention status 401, got %v", err) + } +} diff --git a/internal/node/node.go b/internal/node/node.go index cf934824..82343746 100644 --- a/internal/node/node.go +++ b/internal/node/node.go @@ -631,6 +631,10 @@ func (n *SamNode) Start(ctx context.Context) error { // Periodically sync mesh policy n.startPolicySyncLoop(ctx, n.config.PolicySyncInterval) + // Periodically self-report local services to the control plane, so an + // admin can see mesh-wide service topology. + n.startCatalogReportLoop(ctx, n.config.CatalogReportInterval) + return nil } @@ -2208,6 +2212,60 @@ func (n *SamNode) syncMeshPolicy(ctx context.Context) error { return nil } +// reportNodeCatalog self-reports this node's local service list to the +// control plane (see internal/controlplane/catalog.go's HandleNodeCatalog), +// so an admin console can show mesh-wide service topology. +func (n *SamNode) reportNodeCatalog(ctx context.Context) error { + controlPlaneURL, err := n.Store.LoadControlPlaneURL() + if err != nil || controlPlaneURL == "" { + return fmt.Errorf("control plane URL not found in store") + } + + token := n.GetIdentity() + if len(token) == 0 { + return fmt.Errorf("node has no identity token to report its catalog") + } + + services := n.ListLocalServices(api.ServiceType_SERVICE_TYPE_UNSPECIFIED) + if err := ReportNodeCatalog(ctx, controlPlaneURL, token, services); err != nil { + return fmt.Errorf("failed to report node catalog: %w", err) + } + return nil +} + +func (n *SamNode) startCatalogReportLoop(ctx context.Context, interval time.Duration) { + if interval <= 0 { + interval = 1 * time.Minute + } + + go func() { + // Run an initial report after a short delay, once services have had + // a chance to register at startup. + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Second): + if err := n.reportNodeCatalog(ctx); err != nil { + logger.Warnf("Initial node catalog report failed: %v", err) + } + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := n.reportNodeCatalog(ctx); err != nil { + logger.Warnf("Periodic node catalog report failed: %v", err) + } + } + } + }() +} + func (n *SamNode) startPolicySyncLoop(ctx context.Context, interval time.Duration) { if interval <= 0 { interval = 1 * time.Hour diff --git a/internal/node/options.go b/internal/node/options.go index 730ecd1e..7c4c7cc1 100644 --- a/internal/node/options.go +++ b/internal/node/options.go @@ -86,6 +86,12 @@ type Options struct { // is tight enough that even simple interpreted-language MCP servers can // miss it on first spawn. BackendProbeTimeout time.Duration + // CatalogReportInterval specifies how often the node self-reports its + // locally registered services to the control plane (POST + // /nodes/catalog), so an admin can see mesh-wide service topology + // without the control plane needing DHT/P2P access to every node + // itself. Zero uses the default. + CatalogReportInterval time.Duration } // Default applies default values to Options if they are not specified. @@ -136,6 +142,9 @@ func (o *Options) Default() { if o.PolicySyncInterval == 0 { o.PolicySyncInterval = 1 * time.Hour } + if o.CatalogReportInterval == 0 { + o.CatalogReportInterval = 1 * time.Minute + } if o.PolicySyncJitter <= 0 { o.PolicySyncJitter = 10 * time.Second } diff --git a/tests/ui/console.spec.js b/tests/ui/console.spec.js index ea2950fe..18b14dea 100644 --- a/tests/ui/console.spec.js +++ b/tests/ui/console.spec.js @@ -312,3 +312,28 @@ test('the served markup carries no inline style attribute', async ({ request }) const html = await (await request.get('/index.html')).text(); expect(html).not.toMatch(/<[^>]+\sstyle=/); }); + +// The Services view has no admin flow to seed it from the browser alone (a +// real node has to self-report), so this only pins what is reachable without +// one: the nav item routes there and the empty state renders without a JS +// error. TestHandleNodeCatalog in internal/controlplane covers the reporting +// endpoint itself, and TestReportNodeCatalog in internal/node covers the +// node-side push. +test('the Services view is reachable and renders its empty state', async ({ page }) => { + await login(page); + + await page.click('.nav-item[data-target="services"]'); + await expect(page).toHaveURL(/#services$/); + await expect(page.locator('#view-services')).toBeVisible(); + await expect(page.locator('#table-services')).toContainText('No nodes have reported any services yet'); +}); + +// A reload must survive on this view too, same as the other deep-linkable +// views already covered above for #routers. +test('the Services view is deep-linkable via the URL hash', async ({ page }) => { + await login(page); + + await page.goto('/#services'); + await expect(page.locator('#view-services')).toBeVisible(); + await expect(page.locator('.nav-item[data-target="services"]')).toHaveAttribute('aria-current', 'page'); +});