mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
Expose resource policy posture aggregation
This commit is contained in:
parent
bfcdfa7699
commit
361b921b91
15 changed files with 370 additions and 22 deletions
|
|
@ -0,0 +1,24 @@
|
|||
# Policy-Aware Data Governance Resource Aggregations - 2026-04-25
|
||||
|
||||
Status: partial slice complete; the policy-aware data governance lane is not complete.
|
||||
|
||||
## Scope
|
||||
|
||||
This slice promotes the existing unified resource policy posture out of AI-only usage and into the canonical resource API contract.
|
||||
|
||||
## Implemented
|
||||
|
||||
- `internal/unifiedresources` now owns a camelCase resource API policy posture contract derived from the canonical `PolicyPostureSummary`.
|
||||
- `/api/resources` and `/api/resources/stats` expose `policyPosture` alongside existing resource aggregations.
|
||||
- Empty resources responses normalize policy posture maps to `{}` instead of `null`.
|
||||
- `frontend-modern/src/hooks/useUnifiedResources.ts` exposes `policyPosture()` from the canonical resources API so future UI surfaces do not need to depend on AI summary payloads for estate policy posture.
|
||||
|
||||
## Proof
|
||||
|
||||
- `go test ./internal/unifiedresources ./internal/api -run 'TestSummarizePolicyPosture|TestResourcePolicyPostureContractUsesCamelCaseNonNullCollections|TestContract_ResourceListPolicyMetadata|TestContract_TenantResourcesDoNotFallbackToRawSnapshotSeeding|TestContract_ResourceListCarriesTimelineAndCapabilityContracts|TestResourceAndStorageResponsesUseCanonicalEmptyCollections' -count=1`
|
||||
- `npm --prefix frontend-modern test -- src/hooks/__tests__/useUnifiedResources.test.ts`
|
||||
- `npm --prefix frontend-modern run type-check`
|
||||
|
||||
## Remaining Gap
|
||||
|
||||
The lane still needs a full governed product model for policy-aware data governance across Pulse and Pulse Enterprise, including customer-facing policy controls, cloud routing boundaries, enterprise audit semantics, and explicit GA proof once those surfaces exist.
|
||||
|
|
@ -177,6 +177,10 @@ an add-only capacity posture.
|
|||
order instead of inheriting map order or page-local re-sorts, so install
|
||||
and runtime hydration do not present one resource ordering at first load and
|
||||
a different ordering after the first live refresh.
|
||||
Those lifecycle-adjacent reads may observe resource API
|
||||
`policyPosture` aggregation as read-only data-governance context, but they
|
||||
must not reinterpret sensitivity, routing, or redaction counts as install
|
||||
capacity, registration eligibility, or agent assignment state.
|
||||
When lifecycle surfaces also hydrate from `/api/state`, that first-session
|
||||
snapshot must carry the same canonical resource types and display names as
|
||||
`/api/resources` instead of briefly showing legacy host aliases before the
|
||||
|
|
|
|||
|
|
@ -206,6 +206,11 @@ the canonical monitored-system blocked payload.
|
|||
Websocket-backed API consumers such as `frontend-modern/src/components/Settings/useAPITokenManagerState.ts` and `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx` may read runtime context only through `frontend-modern/src/contexts/appRuntime.ts`; they must not import `frontend-modern/src/App.tsx`, because payload ownership remains in the API contract rather than the root shell.
|
||||
3. Add dedicated contract tests for new stable payloads
|
||||
4. Route unified resource sensitivity, routing, and `aiSafeSummary` payload changes through `internal/api/resources.go`, `internal/api/contract_test.go`, and the canonical frontend resource consumer proofs together; resource governance metadata must not ship as an API-only or frontend-only heuristic
|
||||
That same resource payload contract owns `aggregations.policyPosture` on
|
||||
`/api/resources` and `/api/resources/stats`. The aggregation must be derived
|
||||
from canonical unified-resource policy metadata, normalized as camelCase
|
||||
resource API JSON, and exercised with backend contract tests plus the
|
||||
canonical `useUnifiedResources` frontend hook proof whenever it changes.
|
||||
5. Route unified-resource action, lifecycle, and export audit reads through `internal/api/activity_audit_handlers.go`, `internal/api/router_routes_licensing.go`, and `internal/api/contract_test.go` together so the control-plane execution trail stays on a governed API contract instead of a store-only shape
|
||||
6. Route dedicated unified-resource timeline and facet-bundle reads through `frontend-modern/src/api/resources.ts`, `internal/api/resources.go`, and `internal/api/contract_test.go` together so the backend facet contract and the frontend client stay aligned on one timeline-first surface, while capability and relationship detail stays backend-owned for AI correlation and change detection
|
||||
7. Route unified-resource list ordering through `internal/api/resources.go`, `internal/api/contract_test.go`, and the owned unified-resource registry helpers together; list payloads must stay deterministic for equal-name resources by carrying one canonical `name -> type -> id` tie-break across cold seed, REST pagination, and websocket-backed refreshes instead of inheriting map order or page-local re-sorts
|
||||
|
|
|
|||
|
|
@ -172,6 +172,10 @@ querying, and the operator-facing storage health presentation layer.
|
|||
`ResourceType` normalization for route/query filters, so storage subtypes
|
||||
such as `physical_disk` stay on the same cache-backed snapshot instead of
|
||||
relying on storage-local filter aliases.
|
||||
Storage and recovery consumers that need estate data-governance posture
|
||||
must read the hook's resource API-backed `policyPosture()` accessor rather
|
||||
than deriving sensitivity, routing, or redaction counts from storage-local
|
||||
tables, AI summary payloads, or route filters.
|
||||
Optional selector shells that only surface storage/recovery counts when they
|
||||
are visible must now pass an explicit enabled gate into that shared hook and
|
||||
any adjacent recovery-rollup query, so hidden workload-route selectors do
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ cross-source deduplication.
|
|||
79. `frontend-modern/src/types/resource.ts`
|
||||
80. `frontend-modern/src/utils/sourcePlatforms.ts`
|
||||
81. `internal/unifiedresources/kubernetes_metric_ids.go`
|
||||
82. `internal/unifiedresources/policy_posture.go`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
|
|
@ -134,6 +135,11 @@ The canonical AI-safe summary builder now owns the sensitivity-specific suffix
|
|||
phrases for `sensitive` and `restricted` resources, so the backend policy
|
||||
contract controls those strings instead of duplicating them inside the summary
|
||||
assembly branch.
|
||||
Canonical policy posture aggregation is owned here as well. Resource API
|
||||
payloads may expose a camelCase transport projection, but the counts must be
|
||||
derived from `internal/unifiedresources/policy_posture.go` after canonical
|
||||
policy metadata has been refreshed, not recomputed from frontend labels,
|
||||
AI-only summary payloads, or page-local heuristics.
|
||||
4. Add metrics-target normalization or synthetic metrics support through `internal/unifiedresources/metrics_targets.go` and `internal/unifiedresources/metrics.go`
|
||||
5. Add platform registry, resolution, host-dedup, or monitored-system
|
||||
projection behavior through `internal/unifiedresources/registry.go`,
|
||||
|
|
|
|||
|
|
@ -553,7 +553,9 @@ describe('useUnifiedResources', () => {
|
|||
readRate: 1_250_000,
|
||||
writeRate: 640_000,
|
||||
});
|
||||
expect(result!.resources().find((resource) => resource.id === 'pbs-1')?.platformData?.pbs).toEqual(
|
||||
expect(
|
||||
result!.resources().find((resource) => resource.id === 'pbs-1')?.platformData?.pbs,
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
datastoreCount: 2,
|
||||
backupJobCount: 4,
|
||||
|
|
@ -586,7 +588,9 @@ describe('useUnifiedResources', () => {
|
|||
readRate: 1_250_000,
|
||||
writeRate: 640_000,
|
||||
});
|
||||
expect(result!.resources().find((resource) => resource.id === 'pbs-1')?.platformData?.pbs).toEqual(
|
||||
expect(
|
||||
result!.resources().find((resource) => resource.id === 'pbs-1')?.platformData?.pbs,
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
datastoreCount: 2,
|
||||
backupJobCount: 4,
|
||||
|
|
@ -757,6 +761,59 @@ describe('useUnifiedResources', () => {
|
|||
dispose();
|
||||
});
|
||||
|
||||
it('passes resource policy posture aggregations through unchanged', async () => {
|
||||
apiFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [v2Resource],
|
||||
aggregations: {
|
||||
policyPosture: {
|
||||
totalResources: 3,
|
||||
sensitivityCounts: {
|
||||
restricted: 1,
|
||||
sensitive: 2,
|
||||
},
|
||||
routingCounts: {
|
||||
'local-only': 1,
|
||||
'local-first': 2,
|
||||
},
|
||||
redactionCounts: {
|
||||
hostname: 1,
|
||||
'ip-address': 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
let dispose = () => {};
|
||||
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | undefined;
|
||||
createRoot((d) => {
|
||||
dispose = d;
|
||||
result = useUnifiedResources();
|
||||
});
|
||||
|
||||
await waitForValue(() => result!.policyPosture()?.totalResources, 3);
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(result!.policyPosture()).toEqual({
|
||||
totalResources: 3,
|
||||
sensitivityCounts: {
|
||||
restricted: 1,
|
||||
sensitive: 2,
|
||||
},
|
||||
routingCounts: {
|
||||
'local-only': 1,
|
||||
'local-first': 2,
|
||||
},
|
||||
redactionCounts: {
|
||||
hostname: 1,
|
||||
'ip-address': 1,
|
||||
},
|
||||
});
|
||||
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('normalizes legacy k8s discovery targets to pod for frontend consumers', async () => {
|
||||
apiFetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
|
|
@ -1553,7 +1610,7 @@ describe('useUnifiedResources', () => {
|
|||
expect(useUnifiedResourcesSource).toContain('supportsCanonicalWsHydration');
|
||||
expect(useUnifiedResourcesSource).toContain("initialHydration === 'prefer-ws'");
|
||||
expect(useUnifiedResourcesSource).toContain('wsStore.state.resources');
|
||||
expect(useUnifiedResourcesSource).not.toContain('const DEFAULT_ORG_SCOPE = \'default\'');
|
||||
expect(useUnifiedResourcesSource).not.toContain("const DEFAULT_ORG_SCOPE = 'default'");
|
||||
expect(useUnifiedResourcesSource).not.toContain('const normalizeOrgScope =');
|
||||
expect(useUnifiedResourcesSource).toContain('asTrimmedString');
|
||||
expect(useUnifiedResourcesSource).not.toContain(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type {
|
|||
ResourceDiscoveryTarget,
|
||||
ResourceMetricsTarget,
|
||||
ResourcePBSMeta,
|
||||
ResourcePolicyPostureSummary,
|
||||
ResourceStatus,
|
||||
ResourceStorageMeta,
|
||||
ResourceType,
|
||||
|
|
@ -442,10 +443,26 @@ type APIListResponse = {
|
|||
meta?: {
|
||||
totalPages?: number;
|
||||
};
|
||||
aggregations?: {
|
||||
policyPosture?: APIResourcePolicyPostureSummary;
|
||||
};
|
||||
};
|
||||
|
||||
type APIResourcePolicyPostureSummary = {
|
||||
totalResources?: number;
|
||||
sensitivityCounts?: Partial<Record<string, number>>;
|
||||
routingCounts?: Partial<Record<string, number>>;
|
||||
redactionCounts?: Partial<Record<string, number>>;
|
||||
};
|
||||
|
||||
type UnifiedResourcesSnapshot = {
|
||||
resources: Resource[];
|
||||
policyPosture: ResourcePolicyPostureSummary | null;
|
||||
};
|
||||
|
||||
type UnifiedResourcesCacheEntry = {
|
||||
resources: Resource[];
|
||||
policyPosture: ResourcePolicyPostureSummary | null;
|
||||
hasSnapshot: boolean;
|
||||
cachedAt: number;
|
||||
lastFetchAt: number;
|
||||
|
|
@ -704,12 +721,57 @@ const buildUnifiedResourcesUrl = (query: string, page: number): string => {
|
|||
return `${UNIFIED_RESOURCES_BASE_URL}?${params.toString()}`;
|
||||
};
|
||||
|
||||
const resolveResourcesPayload = (payload: unknown): { data: APIResource[]; totalPages: number } => {
|
||||
const normalizeNonNegativeCount = (value: unknown): number | undefined => {
|
||||
const count = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(count)) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.max(0, Math.trunc(count));
|
||||
};
|
||||
|
||||
const normalizeCountMap = <T extends string>(value: unknown): Partial<Record<T, number>> => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const counts: Partial<Record<T, number>> = {};
|
||||
for (const [key, rawCount] of Object.entries(value as Record<string, unknown>)) {
|
||||
const normalizedKey = asTrimmedString(key);
|
||||
const normalizedCount = normalizeNonNegativeCount(rawCount);
|
||||
if (normalizedKey && normalizedCount !== undefined) {
|
||||
counts[normalizedKey as T] = normalizedCount;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
};
|
||||
|
||||
const normalizeResourcePolicyPosture = (
|
||||
value?: APIResourcePolicyPostureSummary | null,
|
||||
): ResourcePolicyPostureSummary | null => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
totalResources: normalizeNonNegativeCount(value.totalResources) ?? 0,
|
||||
sensitivityCounts: normalizeCountMap(value.sensitivityCounts),
|
||||
routingCounts: normalizeCountMap(value.routingCounts),
|
||||
redactionCounts: normalizeCountMap(value.redactionCounts),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveResourcesPayload = (
|
||||
payload: unknown,
|
||||
): {
|
||||
data: APIResource[];
|
||||
totalPages: number;
|
||||
policyPosture: ResourcePolicyPostureSummary | null;
|
||||
} => {
|
||||
if (Array.isArray(payload)) {
|
||||
return { data: payload as APIResource[], totalPages: 1 };
|
||||
return { data: payload as APIResource[], totalPages: 1, policyPosture: null };
|
||||
}
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return { data: [], totalPages: 1 };
|
||||
return { data: [], totalPages: 1, policyPosture: null };
|
||||
}
|
||||
const record = payload as APIListResponse;
|
||||
const data = Array.isArray(record.data)
|
||||
|
|
@ -720,7 +782,11 @@ const resolveResourcesPayload = (payload: unknown): { data: APIResource[]; total
|
|||
const totalPages = Number.isFinite(record.meta?.totalPages)
|
||||
? Math.max(1, Number(record.meta?.totalPages))
|
||||
: 1;
|
||||
return { data, totalPages };
|
||||
return {
|
||||
data,
|
||||
totalPages,
|
||||
policyPosture: normalizeResourcePolicyPosture(record.aggregations?.policyPosture),
|
||||
};
|
||||
};
|
||||
|
||||
const dedupeResources = (resources: APIResource[]): APIResource[] => {
|
||||
|
|
@ -741,6 +807,7 @@ const getUnifiedResourcesCacheEntry = (cacheKey: string): UnifiedResourcesCacheE
|
|||
}
|
||||
const created: UnifiedResourcesCacheEntry = {
|
||||
resources: [],
|
||||
policyPosture: null,
|
||||
hasSnapshot: false,
|
||||
cachedAt: 0,
|
||||
lastFetchAt: 0,
|
||||
|
|
@ -757,8 +824,10 @@ const setUnifiedResourcesCache = (
|
|||
entry: UnifiedResourcesCacheEntry,
|
||||
resources: Resource[],
|
||||
at = Date.now(),
|
||||
policyPosture: ResourcePolicyPostureSummary | null = entry.policyPosture,
|
||||
) => {
|
||||
entry.resources = resources;
|
||||
entry.policyPosture = policyPosture;
|
||||
entry.hasSnapshot = true;
|
||||
entry.cachedAt = at;
|
||||
};
|
||||
|
|
@ -829,6 +898,7 @@ const seedUnifiedResourcesCacheFromAllResources = (
|
|||
entry.resources = allResourcesEntry.resources.filter((resource) =>
|
||||
typeFilter.has(resolveType(resource.type)),
|
||||
);
|
||||
entry.policyPosture = allResourcesEntry.policyPosture;
|
||||
entry.hasSnapshot = true;
|
||||
entry.cachedAt = allResourcesEntry.cachedAt;
|
||||
entry.lastFetchAt = allResourcesEntry.lastFetchAt;
|
||||
|
|
@ -836,9 +906,10 @@ const seedUnifiedResourcesCacheFromAllResources = (
|
|||
return entry;
|
||||
};
|
||||
|
||||
async function fetchUnifiedResources(query: string): Promise<Resource[]> {
|
||||
async function fetchUnifiedResources(query: string): Promise<UnifiedResourcesSnapshot> {
|
||||
const normalizedQuery = normalizeUnifiedResourcesQuery(query);
|
||||
const allRawResources: APIResource[] = [];
|
||||
let policyPosture: ResourcePolicyPostureSummary | null = null;
|
||||
let totalPages = 1;
|
||||
|
||||
for (let page = 1; page <= totalPages && page <= UNIFIED_RESOURCES_MAX_PAGES; page += 1) {
|
||||
|
|
@ -856,10 +927,14 @@ async function fetchUnifiedResources(query: string): Promise<Resource[]> {
|
|||
const payload = (await response.json()) as APIListResponse | APIResource[];
|
||||
const resolved = resolveResourcesPayload(payload);
|
||||
allRawResources.push(...resolved.data);
|
||||
policyPosture = policyPosture ?? resolved.policyPosture;
|
||||
totalPages = Math.max(totalPages, resolved.totalPages);
|
||||
}
|
||||
|
||||
return dedupeResources(allRawResources).map((resource) => toResource(resource));
|
||||
return {
|
||||
resources: dedupeResources(allRawResources).map((resource) => toResource(resource)),
|
||||
policyPosture,
|
||||
};
|
||||
}
|
||||
|
||||
const fetchUnifiedResourcesShared = async (
|
||||
|
|
@ -878,9 +953,9 @@ const fetchUnifiedResourcesShared = async (
|
|||
const request = (async () => {
|
||||
const fetched = await fetchUnifiedResources(query);
|
||||
const now = Date.now();
|
||||
setUnifiedResourcesCache(entry, fetched, now);
|
||||
setUnifiedResourcesCache(entry, fetched.resources, now, fetched.policyPosture);
|
||||
entry.lastFetchAt = now;
|
||||
return fetched;
|
||||
return fetched.resources;
|
||||
})();
|
||||
|
||||
entry.sharedFetch = request;
|
||||
|
|
@ -938,9 +1013,13 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
|
|||
orgScope(),
|
||||
);
|
||||
const initialResources = cacheEntry.resources;
|
||||
const initialPolicyPosture = cacheEntry.policyPosture;
|
||||
const hasCachedResources = cacheEntry.hasSnapshot;
|
||||
|
||||
const [resources, setResources] = createStore<Resource[]>(initialResources);
|
||||
const [policyPosture, setPolicyPosture] = createSignal<ResourcePolicyPostureSummary | null>(
|
||||
initialPolicyPosture,
|
||||
);
|
||||
const [loading, setLoading] = createSignal(!hasCachedResources);
|
||||
const [error, setError] = createSignal<unknown>(undefined);
|
||||
const wsStore = getGlobalWebSocketStore();
|
||||
|
|
@ -961,6 +1040,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
|
|||
return;
|
||||
}
|
||||
setResources(reconcile(next, { key: 'id' }));
|
||||
setPolicyPosture(targetEntry.policyPosture);
|
||||
};
|
||||
|
||||
const runRefetch = async (options?: {
|
||||
|
|
@ -1149,7 +1229,11 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
|
|||
wsResources,
|
||||
allResourcesEntry.resources,
|
||||
);
|
||||
const projectedResources = filterCanonicalUnifiedResources(mergedWsResources, query, typeFilter);
|
||||
const projectedResources = filterCanonicalUnifiedResources(
|
||||
mergedWsResources,
|
||||
query,
|
||||
typeFilter,
|
||||
);
|
||||
const now = Date.now();
|
||||
clearInitialHydrationTimeout();
|
||||
setUnifiedResourcesCache(allResourcesEntry, mergedWsResources, now);
|
||||
|
|
@ -1167,6 +1251,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
|
|||
cacheEntry.lastFetchAt = now;
|
||||
batch(() => {
|
||||
setResources(reconcile(mergedProjectedResources, { key: 'id' }));
|
||||
setPolicyPosture(cacheEntry.policyPosture);
|
||||
setError(undefined);
|
||||
setLoading(false);
|
||||
});
|
||||
|
|
@ -1233,9 +1318,11 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
|
|||
clearInitialHydrationTimeout();
|
||||
|
||||
const scopedResources = cacheEntry.resources;
|
||||
const scopedPolicyPosture = cacheEntry.policyPosture;
|
||||
batch(() => {
|
||||
setError(undefined);
|
||||
setResources(reconcile(scopedResources, { key: 'id' }));
|
||||
setPolicyPosture(scopedPolicyPosture);
|
||||
setLoading(enabled() && !cacheEntry.hasSnapshot);
|
||||
});
|
||||
|
||||
|
|
@ -1256,6 +1343,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
|
|||
|
||||
return {
|
||||
resources: () => resources,
|
||||
policyPosture,
|
||||
refetch,
|
||||
mutate,
|
||||
loading,
|
||||
|
|
|
|||
|
|
@ -138,6 +138,13 @@ export interface ResourcePolicy {
|
|||
routing: ResourceRoutingPolicy;
|
||||
}
|
||||
|
||||
export interface ResourcePolicyPostureSummary {
|
||||
totalResources: number;
|
||||
sensitivityCounts: Partial<Record<ResourceSensitivity, number>>;
|
||||
routingCounts: Partial<Record<ResourceRoutingScope, number>>;
|
||||
redactionCounts: Partial<Record<ResourceRedactionHint, number>>;
|
||||
}
|
||||
|
||||
export const requiresGovernedResourceDisplay = (policy?: ResourcePolicy | null): boolean => {
|
||||
if (!policy) return false;
|
||||
return policy.routing.scope === 'local-only' || (policy.routing.redact?.length ?? 0) > 0;
|
||||
|
|
|
|||
|
|
@ -10354,7 +10354,7 @@ func TestContract_TenantResourcesDoNotFallbackToRawSnapshotSeeding(t *testing.T)
|
|||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
const want = `{"data":[],"meta":{"page":1,"limit":50,"total":0,"totalPages":0},"aggregations":{"total":0,"byType":{},"byStatus":{},"bySource":{}}}`
|
||||
const want = `{"data":[],"meta":{"page":1,"limit":50,"total":0,"totalPages":0},"aggregations":{"total":0,"byType":{},"byStatus":{},"bySource":{},"policyPosture":{"totalResources":0,"sensitivityCounts":{},"routingCounts":{},"redactionCounts":{}}}}`
|
||||
if got := strings.TrimSpace(rec.Body.String()); got != want {
|
||||
t.Fatalf("tenant resource fallback contract = %s, want %s", got, want)
|
||||
}
|
||||
|
|
@ -10427,6 +10427,21 @@ func TestContract_ResourceListPolicyMetadata(t *testing.T) {
|
|||
if got := resource.AISafeSummary; !strings.Contains(got, "virtual machine resource;") || !strings.Contains(got, "local-only context") {
|
||||
t.Fatalf("aiSafeSummary = %q", got)
|
||||
}
|
||||
if resp.Aggregations.PolicyPosture == nil {
|
||||
t.Fatal("expected policy posture aggregation in resource list contract")
|
||||
}
|
||||
if got := resp.Aggregations.PolicyPosture.TotalResources; got != 1 {
|
||||
t.Fatalf("policyPosture.totalResources = %d, want 1", got)
|
||||
}
|
||||
if got := resp.Aggregations.PolicyPosture.SensitivityCounts[unifiedresources.ResourceSensitivityRestricted]; got != 1 {
|
||||
t.Fatalf("policyPosture.sensitivityCounts[restricted] = %d, want 1", got)
|
||||
}
|
||||
if got := resp.Aggregations.PolicyPosture.RoutingCounts[unifiedresources.ResourceRoutingScopeLocalOnly]; got != 1 {
|
||||
t.Fatalf("policyPosture.routingCounts[local-only] = %d, want 1", got)
|
||||
}
|
||||
if got := resp.Aggregations.PolicyPosture.RedactionCounts[unifiedresources.ResourceRedactionHostname]; got != 1 {
|
||||
t.Fatalf("policyPosture.redactionCounts[hostname] = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContract_ResourceListUsesDeterministicNameTieBreakers(t *testing.T) {
|
||||
|
|
@ -10662,10 +10677,11 @@ func TestContract_ResourceListCarriesTimelineAndCapabilityContracts(t *testing.T
|
|||
TotalPages: 1,
|
||||
},
|
||||
Aggregations: unifiedresources.ResourceStats{
|
||||
Total: 1,
|
||||
ByType: map[unifiedresources.ResourceType]int{unifiedresources.ResourceTypeVM: 1},
|
||||
ByStatus: map[unifiedresources.ResourceStatus]int{unifiedresources.StatusOnline: 1},
|
||||
BySource: map[unifiedresources.DataSource]int{unifiedresources.SourceProxmox: 1},
|
||||
Total: 1,
|
||||
ByType: map[unifiedresources.ResourceType]int{unifiedresources.ResourceTypeVM: 1},
|
||||
ByStatus: map[unifiedresources.ResourceStatus]int{unifiedresources.StatusOnline: 1},
|
||||
BySource: map[unifiedresources.DataSource]int{unifiedresources.SourceProxmox: 1},
|
||||
PolicyPosture: unifiedresources.EmptyResourcePolicyPostureSummary(),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -10738,7 +10754,7 @@ func TestContract_ResourceListCarriesTimelineAndCapabilityContracts(t *testing.T
|
|||
}
|
||||
],
|
||||
"meta":{"page":1,"limit":50,"total":1,"totalPages":1},
|
||||
"aggregations":{"total":1,"byType":{"vm":1},"byStatus":{"online":1},"bySource":{"proxmox":1}}
|
||||
"aggregations":{"total":1,"byType":{"vm":1},"byStatus":{"online":1},"bySource":{"proxmox":1},"policyPosture":{"totalResources":0,"sensitivityCounts":{},"routingCounts":{},"redactionCounts":{}}}
|
||||
}`
|
||||
|
||||
assertJSONSnapshot(t, got, want)
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ func (h *ResourceHandlers) HandleListResources(w http.ResponseWriter, r *http.Re
|
|||
// match the canonical REST resource contract.
|
||||
stats := registry.Stats()
|
||||
stats.ByType = computeResourceContractByType(allResources)
|
||||
stats.PolicyPosture = resourcePolicyPostureAggregation(allResources)
|
||||
|
||||
applyResourceContractTypes(paged)
|
||||
|
||||
|
|
@ -586,6 +587,7 @@ func (h *ResourceHandlers) HandleStats(w http.ResponseWriter, r *http.Request) {
|
|||
allResources := registry.List()
|
||||
stats := registry.Stats()
|
||||
stats.ByType = computeResourceContractByType(allResources)
|
||||
stats.PolicyPosture = resourcePolicyPostureAggregation(allResources)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(stats)
|
||||
|
|
@ -1101,6 +1103,11 @@ func (r ResourcesResponse) NormalizeCollections() ResourcesResponse {
|
|||
if r.Data == nil {
|
||||
r.Data = []unified.Resource{}
|
||||
}
|
||||
if r.Aggregations.PolicyPosture == nil {
|
||||
r.Aggregations.PolicyPosture = unified.EmptyResourcePolicyPostureSummary()
|
||||
} else {
|
||||
r.Aggregations.PolicyPosture = r.Aggregations.PolicyPosture.NormalizeCollections()
|
||||
}
|
||||
if r.Aggregations.ByType == nil {
|
||||
r.Aggregations.ByType = map[unified.ResourceType]int{}
|
||||
}
|
||||
|
|
@ -2279,6 +2286,11 @@ func computeResourceContractByType(resources []unified.Resource) map[unified.Res
|
|||
return m
|
||||
}
|
||||
|
||||
func resourcePolicyPostureAggregation(resources []unified.Resource) *unified.ResourcePolicyPostureSummary {
|
||||
canonicalResources := unified.RefreshCanonicalMetadataSlice(resources)
|
||||
return unified.ResourcePolicyPostureContract(unified.SummarizePolicyPosture(canonicalResources))
|
||||
}
|
||||
|
||||
func buildDiscoveryTarget(resource unified.Resource) *unified.DiscoveryTarget {
|
||||
switch unified.CanonicalResourceType(resource.Type) {
|
||||
case unified.ResourceTypeAgent:
|
||||
|
|
|
|||
|
|
@ -1719,6 +1719,9 @@ func TestResourceAndStorageResponsesUseCanonicalEmptyCollections(t *testing.T) {
|
|||
{name: "resources_by_type", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "byType"}},
|
||||
{name: "resources_by_status", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "byStatus"}},
|
||||
{name: "resources_by_source", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "bySource"}},
|
||||
{name: "resources_policy_sensitivity", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "policyPosture", "sensitivityCounts"}},
|
||||
{name: "resources_policy_routing", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "policyPosture", "routingCounts"}},
|
||||
{name: "resources_policy_redaction", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "policyPosture", "redactionCounts"}},
|
||||
{name: "storage_summary_by_platform", raw: EmptyStorageSummaryResponse(), keys: []string{"byPlatform"}},
|
||||
{name: "storage_summary_by_resource_type", raw: EmptyStorageSummaryResponse(), keys: []string{"byResourceType"}},
|
||||
{name: "storage_summary_by_incident_category", raw: EmptyStorageSummaryResponse(), keys: []string{"byIncidentCategory"}},
|
||||
|
|
|
|||
|
|
@ -606,6 +606,8 @@ func TestPolicyPostureSummaryIsOwnedByUnifiedResources(t *testing.T) {
|
|||
filepath.Join(".", "policy_posture.go"): {
|
||||
"type PolicyPostureSummary struct {",
|
||||
"func SummarizePolicyPosture(resources []Resource) *PolicyPostureSummary",
|
||||
"type ResourcePolicyPostureSummary struct {",
|
||||
"func ResourcePolicyPostureContract(summary *PolicyPostureSummary) *ResourcePolicyPostureSummary",
|
||||
},
|
||||
filepath.Join("..", "ai", "intelligence.go"): {
|
||||
"unifiedresources.PolicyPostureSummary",
|
||||
|
|
@ -614,6 +616,10 @@ func TestPolicyPostureSummaryIsOwnedByUnifiedResources(t *testing.T) {
|
|||
filepath.Join("..", "ai", "resource_context.go"): {
|
||||
"unifiedresources.SummarizePolicyPosture(allResources)",
|
||||
},
|
||||
filepath.Join("..", "api", "resources.go"): {
|
||||
"resourcePolicyPostureAggregation(allResources)",
|
||||
"unified.ResourcePolicyPostureContract(unified.SummarizePolicyPosture(canonicalResources))",
|
||||
},
|
||||
}
|
||||
|
||||
for name, snippets := range requiredSnippets {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,15 @@ type PolicyPostureSummary struct {
|
|||
RedactionCounts map[ResourceRedactionHint]int `json:"redaction_counts,omitempty"`
|
||||
}
|
||||
|
||||
// ResourcePolicyPostureSummary is the camelCase REST contract for policy posture
|
||||
// exposed through resource aggregations.
|
||||
type ResourcePolicyPostureSummary struct {
|
||||
TotalResources int `json:"totalResources"`
|
||||
SensitivityCounts map[ResourceSensitivity]int `json:"sensitivityCounts"`
|
||||
RoutingCounts map[ResourceRoutingScope]int `json:"routingCounts"`
|
||||
RedactionCounts map[ResourceRedactionHint]int `json:"redactionCounts"`
|
||||
}
|
||||
|
||||
// SummarizePolicyPosture aggregates canonical policy posture across the given
|
||||
// unified resources.
|
||||
func SummarizePolicyPosture(resources []Resource) *PolicyPostureSummary {
|
||||
|
|
@ -46,3 +55,73 @@ func SummarizePolicyPosture(resources []Resource) *PolicyPostureSummary {
|
|||
|
||||
return summary
|
||||
}
|
||||
|
||||
// ResourcePolicyPostureContract converts the canonical policy posture summary
|
||||
// into the resource API's camelCase, non-null collection contract.
|
||||
func ResourcePolicyPostureContract(summary *PolicyPostureSummary) *ResourcePolicyPostureSummary {
|
||||
contract := &ResourcePolicyPostureSummary{}
|
||||
if summary != nil {
|
||||
contract.TotalResources = summary.TotalResources
|
||||
contract.SensitivityCounts = cloneResourceSensitivityCounts(summary.SensitivityCounts)
|
||||
contract.RoutingCounts = cloneResourceRoutingCounts(summary.RoutingCounts)
|
||||
contract.RedactionCounts = cloneResourceRedactionCounts(summary.RedactionCounts)
|
||||
}
|
||||
return contract.NormalizeCollections()
|
||||
}
|
||||
|
||||
// EmptyResourcePolicyPostureSummary returns the canonical empty resource API
|
||||
// policy posture contract.
|
||||
func EmptyResourcePolicyPostureSummary() *ResourcePolicyPostureSummary {
|
||||
return (&ResourcePolicyPostureSummary{}).NormalizeCollections()
|
||||
}
|
||||
|
||||
// NormalizeCollections keeps resource API policy posture maps as JSON objects
|
||||
// instead of nulls.
|
||||
func (summary *ResourcePolicyPostureSummary) NormalizeCollections() *ResourcePolicyPostureSummary {
|
||||
if summary == nil {
|
||||
return EmptyResourcePolicyPostureSummary()
|
||||
}
|
||||
if summary.SensitivityCounts == nil {
|
||||
summary.SensitivityCounts = map[ResourceSensitivity]int{}
|
||||
}
|
||||
if summary.RoutingCounts == nil {
|
||||
summary.RoutingCounts = map[ResourceRoutingScope]int{}
|
||||
}
|
||||
if summary.RedactionCounts == nil {
|
||||
summary.RedactionCounts = map[ResourceRedactionHint]int{}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func cloneResourceSensitivityCounts(in map[ResourceSensitivity]int) map[ResourceSensitivity]int {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[ResourceSensitivity]int, len(in))
|
||||
for key, value := range in {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneResourceRoutingCounts(in map[ResourceRoutingScope]int) map[ResourceRoutingScope]int {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[ResourceRoutingScope]int, len(in))
|
||||
for key, value := range in {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneResourceRedactionCounts(in map[ResourceRedactionHint]int) map[ResourceRedactionHint]int {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[ResourceRedactionHint]int, len(in))
|
||||
for key, value := range in {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,3 +78,39 @@ func TestSummarizePolicyPosture(t *testing.T) {
|
|||
t.Fatal("expected hostname redaction count")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePolicyPostureContractUsesCamelCaseNonNullCollections(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
summary := &PolicyPostureSummary{
|
||||
TotalResources: 2,
|
||||
SensitivityCounts: map[ResourceSensitivity]int{
|
||||
ResourceSensitivityRestricted: 1,
|
||||
},
|
||||
RoutingCounts: map[ResourceRoutingScope]int{
|
||||
ResourceRoutingScopeLocalOnly: 1,
|
||||
},
|
||||
}
|
||||
|
||||
contract := ResourcePolicyPostureContract(summary)
|
||||
if contract == nil {
|
||||
t.Fatal("expected resource policy posture contract")
|
||||
}
|
||||
if contract.TotalResources != 2 {
|
||||
t.Fatalf("total resources = %d, want 2", contract.TotalResources)
|
||||
}
|
||||
if got := contract.SensitivityCounts[ResourceSensitivityRestricted]; got != 1 {
|
||||
t.Fatalf("restricted sensitivity count = %d, want 1", got)
|
||||
}
|
||||
if got := contract.RoutingCounts[ResourceRoutingScopeLocalOnly]; got != 1 {
|
||||
t.Fatalf("local only routing count = %d, want 1", got)
|
||||
}
|
||||
if contract.RedactionCounts == nil {
|
||||
t.Fatal("expected empty redaction counts map, got nil")
|
||||
}
|
||||
|
||||
summary.SensitivityCounts[ResourceSensitivityRestricted] = 5
|
||||
if got := contract.SensitivityCounts[ResourceSensitivityRestricted]; got != 1 {
|
||||
t.Fatalf("contract mutated with source summary: got %d want 1", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1125,8 +1125,9 @@ type DockerSwarmInfo struct {
|
|||
|
||||
// ResourceStats contains aggregated stats for a set of resources.
|
||||
type ResourceStats struct {
|
||||
Total int `json:"total"`
|
||||
ByType map[ResourceType]int `json:"byType"`
|
||||
ByStatus map[ResourceStatus]int `json:"byStatus"`
|
||||
BySource map[DataSource]int `json:"bySource"`
|
||||
Total int `json:"total"`
|
||||
ByType map[ResourceType]int `json:"byType"`
|
||||
ByStatus map[ResourceStatus]int `json:"byStatus"`
|
||||
BySource map[DataSource]int `json:"bySource"`
|
||||
PolicyPosture *ResourcePolicyPostureSummary `json:"policyPosture,omitempty"`
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue