Remove dead capability and relationship endpoints

This commit is contained in:
rcourtman
2026-03-19 11:30:01 +00:00
parent 42e958e550
commit 4eb78aec1e
11 changed files with 21 additions and 212 deletions
@@ -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.
@@ -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
@@ -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
@@ -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
@@ -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,
-24
View File
@@ -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 <T>(url: string): Promise<T> =>
});
export class ResourceAPI {
static async getCapabilities(resourceId: string): Promise<ResourceCapabilitiesResponse> {
return fetchFacet<ResourceCapabilitiesResponse>(buildFacetPath(resourceId, 'capabilities'));
}
static async getRelationships(resourceId: string): Promise<ResourceRelationshipsResponse> {
return fetchFacet<ResourceRelationshipsResponse>(buildFacetPath(resourceId, 'relationships'));
}
static async getTimeline(
resourceId: string,
options?: ResourceTimelineQueryOptions,
-92
View File
@@ -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 {
-43
View File
@@ -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)
-2
View File
@@ -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/",
-2
View File
@@ -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
@@ -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 {