diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 07af1e6a6..93f820152 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -96,13 +96,13 @@ Those unified audit list endpoints also clamp oversized `limit` requests to the governed maximum, so audit history stays bounded even when callers ask for arbitrarily large pages. The same shared API runtime now also exposes dedicated unified-resource -capability, relationship, and timeline reads through -`internal/api/resources.go`, but those query surfaces remain owned by the API -and unified-resource contracts rather than by lifecycle continuity. -Those same dedicated reads also accept governed timeline filters for change -kind, source type, and source adapter, and the underlying store owns the -filtered counts so agent lifecycle routing still stays on canonical -fleet-continuity ownership instead of re-deriving resource history locally. +timeline reads through `internal/api/resources.go` plus the bundled facet +history read used by the drawer, but those query surfaces remain owned by the +API and unified-resource contracts rather than by lifecycle continuity. +Those timeline reads also accept governed filters for change kind, source +type, and source adapter, and the underlying store owns the filtered counts so +agent lifecycle routing still stays on canonical fleet-continuity ownership +instead of re-deriving resource history locally. The same API serializer now also refreshes canonical identity and policy metadata through the shared unified-resource helper before it returns resource payloads, so lifecycle-adjacent links keep the same canonical @@ -1064,7 +1064,7 @@ policy-aware resource metadata. Agent lifecycle and fleet-control surfaces may consume canonical `policy` and `aiSafeSummary` fields from unified resource payloads when they need resource context, but they must not fork their own sensitivity-classification or local-vs-cloud routing heuristics on the same -runtime boundary. The same shared resource boundary now also owns the canonical -facet-bundle read path for capabilities, relationships, and timeline history, -so fleet lifecycle surfaces that open resource drawers must continue to consume -the backend bundle instead of reassembling a local multi-call summary. +runtime boundary. The same shared resource boundary now also owns the bundled +facet history read path for timeline data, so fleet lifecycle surfaces that +open resource drawers must continue to consume the backend bundle instead of +reassembling a local multi-call summary. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 4e3580492..6f70cc3da 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -134,10 +134,10 @@ through the owned backend response: resource objects can expose canonical in addition to policy and identity metadata, so the backend payload contract stays aligned with the timeline and control-plane model instead of flattening those fields away. -The same resource contract now also exposes dedicated facet endpoints for -`/api/resources/{id}/capabilities`, `/api/resources/{id}/relationships`, and -`/api/resources/{id}/timeline`, so operators can read the graph and change -history without depending on a monolithic resource payload. +The same resource contract now also exposes a dedicated +`/api/resources/{id}/timeline` history endpoint and bundled facet reads under +`/api/resources/{id}/facets`, so operators can inspect change history without +depending on a monolithic resource payload. The `/api/resources` serializer now also refreshes canonical identity and policy metadata through the shared unified-resource helper before it writes the payload, so backend and frontend contract tests stay aligned on one diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 75b30e252..0a4ba8e17 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -150,8 +150,8 @@ Those unified audit list endpoints also clamp oversized `limit` requests to the governed maximum, so adjacent recovery and storage workflows do not turn bounded history reads into unbounded collection scans. The same shared API runtime now also exposes dedicated -`/api/resources/{id}/capabilities`, `/api/resources/{id}/relationships`, and -`/api/resources/{id}/timeline` reads, but storage and recovery must continue +`/api/resources/{id}/timeline` reads plus the bundled +`/api/resources/{id}/facets` surface, but storage and recovery must continue to treat those as adjacent governed API ownership rather than storage/recovery timeline ownership. Those resource timeline reads now also accept governed kind and source-type diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 2e6df9f70..caf4504a9 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -200,10 +200,9 @@ the pre-v6 `timestamp` column by backfilling canonical `observed_at` values, adding the newer `occurred_at` field, and preserving the legacy timestamp on write when the target database still requires it. `internal/api/resources.go` now exposes that same history through dedicated -`/api/resources/{id}/timeline` reads, while `/api/resources/{id}/capabilities` -and `/api/resources/{id}/relationships` expose the current graph facets as -separate queryable surfaces instead of forcing consumers to parse the full -resource payload. +`/api/resources/{id}/timeline` reads, while the bundled `/api/resources/{id}/facets` +surface keeps the facet summary and recent-change history available without +forcing consumers to parse the full resource payload. Those filtered timeline reads are backed by dedicated `resource_changes` indexes on `canonical_id`, `kind`, `source_type`, and `observed_at`, so the canonical history path stays fast as the filtered timeline grows instead of diff --git a/frontend-modern/src/api/__tests__/resources.test.ts b/frontend-modern/src/api/__tests__/resources.test.ts index d8b78b17e..f46a67fdc 100644 --- a/frontend-modern/src/api/__tests__/resources.test.ts +++ b/frontend-modern/src/api/__tests__/resources.test.ts @@ -11,30 +11,9 @@ describe('ResourceAPI', () => { vi.clearAllMocks(); }); - it('fetches capabilities with the canonical facet endpoint', async () => { + it('fetches the resource history bundle from the facet endpoint', async () => { vi.mocked(apiFetchJSON).mockResolvedValueOnce({ resourceId: 'vm:42', - capabilities: [], - count: 0, - } as any); - - const result = await ResourceAPI.getCapabilities(' vm:42 '); - - expect(apiFetchJSON).toHaveBeenCalledWith('/api/resources/vm%3A42/capabilities', { - cache: 'no-store', - }); - expect(result).toEqual({ - resourceId: 'vm:42', - capabilities: [], - count: 0, - }); - }); - - it('fetches the resource history bundle from the dedicated facet endpoints', async () => { - vi.mocked(apiFetchJSON).mockResolvedValueOnce({ - resourceId: 'vm:42', - capabilities: [{ name: 'restart' }], - relationships: [{ sourceId: 'node:1', targetId: 'vm:42' }], recentChanges: [{ id: 'change-1' }], counts: { capabilities: 1, @@ -71,8 +50,6 @@ describe('ResourceAPI', () => { ); expect(result).toEqual({ resourceId: 'vm:42', - capabilities: [{ name: 'restart' }], - relationships: [{ sourceId: 'node:1', targetId: 'vm:42' }], recentChanges: [{ id: 'change-1' }], counts: { capabilities: 1, diff --git a/frontend-modern/src/api/resources.ts b/frontend-modern/src/api/resources.ts index 2c848e2cb..b654516ba 100644 --- a/frontend-modern/src/api/resources.ts +++ b/frontend-modern/src/api/resources.ts @@ -1,26 +1,12 @@ import { apiFetchJSON } from '@/utils/apiClient'; import type { - ResourceCapability, ResourceChange, ResourceChangeKind, ResourceChangeSourceAdapter, ResourceChangeSourceType, ResourceFacetCounts, - ResourceRelationship, } from '@/types/resource'; -export interface ResourceCapabilitiesResponse { - resourceId: string; - capabilities: ResourceCapability[]; - count: number; -} - -export interface ResourceRelationshipsResponse { - resourceId: string; - relationships: ResourceRelationship[]; - count: number; -} - export interface ResourceTimelineQueryOptions { since?: string | number | Date; limit?: number; @@ -36,8 +22,6 @@ export interface ResourceTimelineResponse { } export interface ResourceFacetBundle { - capabilities: ResourceCapability[]; - relationships: ResourceRelationship[]; recentChanges: ResourceChange[]; counts: ResourceFacetCounts; } @@ -77,14 +61,6 @@ const fetchFacet = async (url: string): Promise => }); export class ResourceAPI { - static async getCapabilities(resourceId: string): Promise { - return fetchFacet(buildFacetPath(resourceId, 'capabilities')); - } - - static async getRelationships(resourceId: string): Promise { - return fetchFacet(buildFacetPath(resourceId, 'relationships')); - } - static async getTimeline( resourceId: string, options?: ResourceTimelineQueryOptions, diff --git a/internal/api/resources.go b/internal/api/resources.go index 37d7c1f58..2831e758a 100644 --- a/internal/api/resources.go +++ b/internal/api/resources.go @@ -291,14 +291,6 @@ func (h *ResourceHandlers) HandleResourceRoutes(w http.ResponseWriter, r *http.R h.HandleGetResourceFacets(w, r) return } - if strings.HasSuffix(r.URL.Path, "/capabilities") { - h.HandleGetResourceCapabilities(w, r) - return - } - if strings.HasSuffix(r.URL.Path, "/relationships") { - h.HandleGetResourceRelationships(w, r) - return - } if strings.HasSuffix(r.URL.Path, "/timeline") { h.HandleGetResourceTimeline(w, r) return @@ -502,90 +494,6 @@ func (h *ResourceHandlers) HandleGetMetrics(w http.ResponseWriter, r *http.Reque json.NewEncoder(w).Encode(resource.Metrics) } -// HandleGetResourceCapabilities handles GET /api/resources/{id}/capabilities. -func (h *ResourceHandlers) HandleGetResourceCapabilities(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - orgID := GetOrgID(r.Context()) - registry, err := h.buildRegistry(orgID) - if err != nil { - http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) - return - } - - resourceID := strings.TrimPrefix(r.URL.Path, "/api/resources/") - resourceID = strings.TrimSuffix(resourceID, "/capabilities") - resourceID = strings.TrimSuffix(resourceID, "/") - resourceID = unified.CanonicalResourceID(resourceID) - if resourceID == "" { - http.Error(w, "Resource ID required", http.StatusBadRequest) - return - } - - resource, ok := registry.Get(resourceID) - if !ok { - http.Error(w, "Resource not found", http.StatusNotFound) - return - } - - capabilities := resource.Capabilities - if capabilities == nil { - capabilities = []unified.ResourceCapability{} - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "resourceId": resourceID, - "capabilities": capabilities, - "count": resource.FacetCounts.Capabilities, - }) -} - -// HandleGetResourceRelationships handles GET /api/resources/{id}/relationships. -func (h *ResourceHandlers) HandleGetResourceRelationships(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - orgID := GetOrgID(r.Context()) - registry, err := h.buildRegistry(orgID) - if err != nil { - http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) - return - } - - resourceID := strings.TrimPrefix(r.URL.Path, "/api/resources/") - resourceID = strings.TrimSuffix(resourceID, "/relationships") - resourceID = strings.TrimSuffix(resourceID, "/") - resourceID = unified.CanonicalResourceID(resourceID) - if resourceID == "" { - http.Error(w, "Resource ID required", http.StatusBadRequest) - return - } - - resource, ok := registry.Get(resourceID) - if !ok { - http.Error(w, "Resource not found", http.StatusNotFound) - return - } - - relationships := resource.Relationships - if relationships == nil { - relationships = []unified.ResourceRelationship{} - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "resourceId": resourceID, - "relationships": relationships, - "count": resource.FacetCounts.Relationships, - }) -} - // HandleGetResourceTimeline handles GET /api/resources/{id}/timeline. func (h *ResourceHandlers) HandleGetResourceTimeline(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/internal/api/resources_test.go b/internal/api/resources_test.go index 0c94cd5e5..2838f1a0a 100644 --- a/internal/api/resources_test.go +++ b/internal/api/resources_test.go @@ -1020,49 +1020,6 @@ func TestResourceGetFacetsAndTimeline(t *testing.T) { } }) - t.Run("capabilities", func(t *testing.T) { - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:42/capabilities", nil) - h.HandleResourceRoutes(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) - } - var payload struct { - ResourceID string `json:"resourceId"` - Capabilities []unified.ResourceCapability `json:"capabilities"` - Count int `json:"count"` - } - if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { - t.Fatalf("decode capabilities: %v", err) - } - if payload.ResourceID != "vm:42" || payload.Count != 1 || len(payload.Capabilities) != 1 { - t.Fatalf("unexpected capabilities payload: %#v", payload) - } - }) - - t.Run("relationships", func(t *testing.T) { - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:42/relationships", nil) - h.HandleResourceRoutes(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) - } - var payload struct { - ResourceID string `json:"resourceId"` - Relationships []unified.ResourceRelationship `json:"relationships"` - Count int `json:"count"` - } - if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { - t.Fatalf("decode relationships: %v", err) - } - if payload.ResourceID != "vm:42" || payload.Count != 1 || len(payload.Relationships) != 1 { - t.Fatalf("unexpected relationships payload: %#v", payload) - } - if got := payload.Relationships[0].Metadata["cluster"]; got != "pve-prod" { - t.Fatalf("unexpected relationship metadata: %#v", payload.Relationships[0].Metadata) - } - }) - t.Run("timeline", func(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:42/timeline?limit=10", nil) diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go index 8b56832a6..d81a066f0 100644 --- a/internal/api/route_inventory_test.go +++ b/internal/api/route_inventory_test.go @@ -354,8 +354,6 @@ var allRouteAllowlist = []string{ "/api/resources/stats", "/api/resources/", "/api/resources/{id}/facets", - "/api/resources/{id}/capabilities", - "/api/resources/{id}/relationships", "/api/resources/{id}/timeline", "/api/guests/metadata", "/api/guests/metadata/", diff --git a/internal/api/router_routes_monitoring.go b/internal/api/router_routes_monitoring.go index ceab2bb45..7e3fb3954 100644 --- a/internal/api/router_routes_monitoring.go +++ b/internal/api/router_routes_monitoring.go @@ -34,8 +34,6 @@ func (r *Router) registerMonitoringResourceRoutes( r.mux.HandleFunc("/api/resources/stats", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleStats))) r.mux.HandleFunc("/api/resources/k8s/namespaces", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleK8sNamespaces))) r.mux.HandleFunc("/api/resources/{id}/facets", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleGetResourceFacets))) - r.mux.HandleFunc("/api/resources/{id}/capabilities", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleGetResourceCapabilities))) - r.mux.HandleFunc("/api/resources/{id}/relationships", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleGetResourceRelationships))) r.mux.HandleFunc("/api/resources/{id}/timeline", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleGetResourceTimeline))) r.mux.HandleFunc("/api/resources/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleResourceRoutes))) // Guest metadata routes diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index 609e84800..8ea6e1235 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -242,8 +242,6 @@ func TestResourceAPIExposesDedicatedFacetReads(t *testing.T) { requiredSnippets := []string{ "HandleGetResourceFacets", - "HandleGetResourceCapabilities", - "HandleGetResourceRelationships", "HandleGetResourceTimeline", "unified.ParseResourceChangeFilters(r.URL.Query()[\"kind\"], r.URL.Query()[\"sourceType\"], r.URL.Query()[\"sourceAdapter\"])", "GetRecentChangesFiltered(resourceID, since, limit, filters)", @@ -252,8 +250,6 @@ func TestResourceAPIExposesDedicatedFacetReads(t *testing.T) { "CountRecentChangesBySourceTypeFiltered(resourceID, since, filters)", "sourceAdapter", "strings.HasSuffix(r.URL.Path, \"/facets\")", - "strings.HasSuffix(r.URL.Path, \"/capabilities\")", - "strings.HasSuffix(r.URL.Path, \"/relationships\")", "strings.HasSuffix(r.URL.Path, \"/timeline\")", } for _, snippet := range requiredSnippets {