mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Extract chart and resource query services
This commit is contained in:
@@ -1232,7 +1232,7 @@
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/api/resources_tenant_security_test.go",
|
||||
"path": "internal/api/resourceapi/resources_tenant_security_test.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
@@ -5459,7 +5459,7 @@
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/api/resources.go",
|
||||
"path": "internal/api/resourceapi/resources.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
@@ -5899,7 +5899,7 @@
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/api/resources.go",
|
||||
"path": "internal/api/resourceapi/resources.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
@@ -6643,12 +6643,12 @@
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/api/resources.go",
|
||||
"path": "internal/api/resourceapi/resources.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/api/resources_test.go",
|
||||
"path": "internal/api/resourceapi/resources_test.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
@@ -7424,7 +7424,7 @@
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/api/resources.go",
|
||||
"path": "internal/api/resourceapi/resources.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1560,7 +1560,7 @@ the intentionally sparse public response.
|
||||
They must not bake vendor model IDs or provider-model fallback rules into
|
||||
install or activation flows just because those routes share the backend API
|
||||
tree.
|
||||
Lifecycle-adjacent resource reads that traverse `internal/api/resources.go`
|
||||
Lifecycle-adjacent resource reads that traverse `internal/api/resourceapi/resources.go`
|
||||
must also preserve the canonical unified-resource `name -> type -> id`
|
||||
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
|
||||
@@ -1584,7 +1584,7 @@ the intentionally sparse public response.
|
||||
snapshot must carry the same canonical resource types and display names as
|
||||
`/api/resources` instead of briefly showing legacy host aliases before the
|
||||
first websocket-backed refresh lands.
|
||||
Chart-adjacent lifecycle reads in shared `internal/api/router.go` must obey
|
||||
Chart-adjacent lifecycle reads in shared `internal/api/chartapi/service.go` must obey
|
||||
that same mock-aware unified snapshot boundary: demo `/api/charts` and
|
||||
`/api/charts/infrastructure` payloads may not bypass
|
||||
`GetUnifiedReadStateOrSnapshot()` and silently drop VMware-backed host rows
|
||||
@@ -2039,7 +2039,7 @@ the intentionally sparse public response.
|
||||
user identity passed through that helper, but they must not treat a missing
|
||||
configured role header as administrator proof.
|
||||
13. Preserve shipped security-doc guidance in shared lifecycle setup helpers so `internal/api/configapi/config_setup_handlers.go` and adjacent install/setup runtime paths point operators at the running build's local security documentation route rather than GitHub `main` links.
|
||||
14. Keep shared `internal/api/router.go` workload-chart downsampling presentation-only: when that router caps mixed-cadence workload history into equal-time buckets for operator-facing cards, lifecycle-adjacent setup and fleet surfaces must not reuse the shaped chart samples as heartbeat, enrollment, or last-seen authority.
|
||||
14. Keep shared `internal/api/chartapi/service.go` workload-chart downsampling presentation-only: when that service caps mixed-cadence workload history into equal-time buckets for operator-facing cards, lifecycle-adjacent setup and fleet surfaces must not reuse the shaped chart samples as heartbeat, enrollment, or last-seen authority.
|
||||
That same presentation-only boundary must preserve canonical millisecond timestamps when it serializes chart points, so lifecycle-adjacent first-host and fleet surfaces do not misread rounded chart samples as duplicate or restarted heartbeat evidence.
|
||||
The same rule now applies to storage summary interaction. Shared sticky-card or row-hover focus behavior on infrastructure, workloads, and storage may reuse the canonical chart transport, but lifecycle-adjacent install, enrollment, and fleet surfaces must not treat highlighted summary series or sticky-shell state as agent freshness or setup progress.
|
||||
The same rule now applies to infrastructure-summary metric filters. Shared
|
||||
@@ -2049,7 +2049,7 @@ the intentionally sparse public response.
|
||||
omitted disk or network series as missing lifecycle telemetry, missing
|
||||
agent capabilities, or reduced fleet freshness truth.
|
||||
The same rule now applies to retired compact dashboard summary payloads.
|
||||
Shared `internal/api/resources.go` routes must not restore
|
||||
Shared `internal/api/resourceapi/resources.go` routes must not restore
|
||||
`/api/resources/dashboard-summary` as a compatibility read; lifecycle
|
||||
surfaces must continue to use install inventory, enrollment proof, and
|
||||
fleet freshness truth from their owning contracts.
|
||||
@@ -3701,7 +3701,7 @@ credential reaches only the explicit backend-owned route inventory, so
|
||||
lifecycle-adjacent setup and install flows cannot accidentally widen the
|
||||
paired-device credential just by touching neighboring `internal/api/` routes.
|
||||
The same shared API runtime now also exposes dedicated unified-resource
|
||||
timeline reads through `internal/api/resources.go` plus the bundled facet
|
||||
timeline reads through `internal/api/resourceapi/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
|
||||
@@ -3710,7 +3710,7 @@ agent lifecycle routing still stays on canonical fleet-continuity ownership
|
||||
instead of re-deriving resource history locally.
|
||||
Those dedicated resource timeline and facet reads are also relationship-aware
|
||||
at the API boundary: lifecycle-adjacent fleet views may consume the direct plus
|
||||
`relatedResources` history returned by `internal/api/resources.go`, but they
|
||||
`relatedResources` history returned by `internal/api/resourceapi/resources.go`, but they
|
||||
must not rebuild cross-resource timeline joins inside lifecycle-owned routes or
|
||||
change the direct-only store default used by other callers.
|
||||
The bundled facet read may also expose the selected resource's canonical
|
||||
@@ -3719,7 +3719,7 @@ surfaces must treat those fields as API/unified-resource facts rather than
|
||||
agent-lifecycle-owned install, approval, or topology state.
|
||||
Agent-host, Kubernetes, and runtime parentage exposed through `ParentID` must
|
||||
therefore enter shared drawers as facet relationships from
|
||||
`internal/api/resources.go`; lifecycle surfaces must not rederive those edges
|
||||
`internal/api/resourceapi/resources.go`; lifecycle surfaces must not rederive those edges
|
||||
from agent install state, cluster names, or local fleet table grouping.
|
||||
That same shared `internal/api/` boundary now also exposes a dedicated VM
|
||||
inventory export route for reporting. Fleet and install surfaces may coexist
|
||||
|
||||
@@ -35,6 +35,15 @@ binding policy are shared through `internal/api/agenttokens/` and
|
||||
`internal/api/agentbinding/`, so config enrollment and root Router admission
|
||||
consume one security contract without importing each other.
|
||||
|
||||
Unified resource registry construction, tenant stores, list/detail/facet
|
||||
queries, discovery projections, and storage/resource response contracts live
|
||||
with their tests in `internal/api/resourceapi/`. Chart aggregation, monitor
|
||||
history queries, response contracts, downsampling, per-tenant caches, and all
|
||||
six chart handlers live with their tests in `internal/api/chartapi/`. Root
|
||||
`ResourceHandlers` and Router methods remain source-compatible composition
|
||||
facades for action/operator-state mutations, extensions, and route wiring; they
|
||||
must not regain resource-query or chart computation behavior.
|
||||
|
||||
Browser WebSocket shutdown distinguishes ordinary client lifecycle from
|
||||
transport failure. Normal closure, navigation/going-away, and abnormal closure
|
||||
codes used by disconnected clients are informational; policy violations and
|
||||
@@ -139,7 +148,10 @@ continues to mint and quote the enrollment token.
|
||||
1a. `internal/api/platform_connection_shared.go`
|
||||
1b. `internal/api/metadata_handlers_shared.go`
|
||||
1c. `internal/config/persistence_metadata_accessors.go`
|
||||
2. `internal/api/resources.go`
|
||||
2. `internal/api/resourceapi/resources.go`
|
||||
2a. `internal/api/resources_compat.go`
|
||||
2b. `internal/api/chartapi/service.go`
|
||||
2c. `internal/api/chartapi/types.go`
|
||||
3. `internal/api/discovery_handlers.go`
|
||||
4. `internal/api/alerting/alerts.go`
|
||||
4a. `internal/api/attention_handlers.go`
|
||||
@@ -836,7 +848,7 @@ token-gated PowerShell payload until its installer owns the same saved-state
|
||||
update contract.
|
||||
|
||||
Summary-chart response caching is a shared API boundary:
|
||||
`internal/api/router.go` may serve a short cached JSON payload for repeated
|
||||
`internal/api/chartapi/service.go` may serve a short cached JSON payload for repeated
|
||||
infrastructure-summary and workloads-summary requests with the same
|
||||
organization, range, metric set, and workload scope, but that cache is
|
||||
transport-only. It may amortize polling and remount cost, but it must not
|
||||
@@ -1897,7 +1909,7 @@ payload shape change when the portal presents compact client rows.
|
||||
true server failures remain explicit.
|
||||
77. `internal/api/relay_mobile_capability.go` shared with `relay-runtime`: the backend-owned Pulse Mobile relay capability inventory is both a relay runtime boundary and a canonical API payload contract surface.
|
||||
78. `internal/api/relay_mobile_capability_generated.go` shared with `relay-runtime`: the generated Pulse Mobile route inventory is both a relay runtime allowlist and the backend projection of the canonical mobile API contract.
|
||||
78. `internal/api/resources.go` shared with `unified-resources`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary.
|
||||
78. `internal/api/resourceapi/resources.go` shared with `unified-resources`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary.
|
||||
79. `internal/api/security.go` shared with `security-privacy`: the security handlers are both a security/privacy control surface and a canonical API payload contract boundary.
|
||||
That same shared security/API boundary owns CSRF replacement-token
|
||||
concurrency. When parallel browser mutations arrive with stale or missing
|
||||
@@ -2129,7 +2141,7 @@ a new API state machine, queue contract, or verification-accounting field.
|
||||
than CSS-derived state.
|
||||
3. Add dedicated contract tests for new stable payloads
|
||||
Unified resource type-filter and organization-share resource type additions
|
||||
must route through `internal/api/resources.go`, `internal/api/org_handlers.go`,
|
||||
must route through `internal/api/resourceapi/resources.go`, `internal/api/org_handlers.go`,
|
||||
frontend resource typing, and `internal/api/contract_test.go` together.
|
||||
Native provider projections such as TrueNAS `network-share` may be accepted
|
||||
by `/api/resources` filters and cross-organization share normalization only
|
||||
@@ -2227,7 +2239,7 @@ a new API state machine, queue contract, or verification-accounting field.
|
||||
metadata; that token may bind once to the first command agent ID and
|
||||
hostname that registers, and a later different agent or hostname must be
|
||||
rejected. Generic unbound `agent:exec` tokens remain fail-closed.
|
||||
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
|
||||
4. Route unified resource sensitivity, routing, and `aiSafeSummary` payload changes through `internal/api/resourceapi/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
|
||||
@@ -2521,7 +2533,7 @@ a new API state machine, queue contract, or verification-accounting field.
|
||||
`GET /api/audit/actions` and `GET /api/audit/actions/{id}/events` as
|
||||
verification adapters, but they must remain read-only views of the canonical
|
||||
action audit and lifecycle trail rather than a second audit store.
|
||||
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.
|
||||
6. Route dedicated unified-resource timeline and facet-bundle reads through `frontend-modern/src/api/resources.ts`, `internal/api/resourceapi/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.
|
||||
`/api/resources/{id}/timeline` and `/api/resources/{id}/facets` must keep
|
||||
resource timelines relationship-aware by opting into the canonical
|
||||
`ResourceChangeFilters.IncludeRelated` store path, so a resource timeline
|
||||
@@ -2539,7 +2551,7 @@ a new API state machine, queue contract, or verification-accounting field.
|
||||
the monitoring-read scope. Platform pages may use it for API-authored
|
||||
provider activity such as vSphere tasks and events, but they must not create
|
||||
page-local activity stores or a second query vocabulary.
|
||||
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
|
||||
7. Route unified-resource list ordering through `internal/api/resourceapi/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
|
||||
That same shared API contract also owns the external resource `type`, canonical display name, and cluster identity published through `/api/resources` and `/api/state`; the websocket/state hydrate path must not emit legacy aliases or raw store labels once the unified resource contract has normalized them.
|
||||
Lightweight integration polling uses the same canonical read-state boundary:
|
||||
`GET /api/state/summary` is an authenticated `monitoring:read` route that
|
||||
@@ -3044,9 +3056,9 @@ a new API state machine, queue contract, or verification-accounting field.
|
||||
22. Keep hosted billing-state quickstart grants retired from new shared API flows: `internal/api/hosted_entitlement_refresh.go`, hosted signup, and trial-state construction must not auto-grant or refresh quickstart inventory for new workspaces, while low-level billing-state readers may still preserve historical fields that already exist on disk.
|
||||
23. Keep hosted AI settings bootstrap on the shared API contract as a retired path: `internal/api/ai_hosted_runtime.go`, `internal/api/ai_handlers.go`, `internal/api/ai_handler.go`, and `internal/api/contract_test.go` must treat a missing `ai.enc` in hosted mode as an unconfigured BYOK/local-provider state, not as a machine-owned `quickstart:pulse-hosted` bootstrap condition. Hosted tenant reads may inherit billing state for commercial authorization, but they must not create quickstart-backed AI config or call the quickstart bootstrap upstream route.
|
||||
24. Keep post-boot AI enablement contract-backed on the shared AI/mobile approval surface: `internal/api/ai_handler.go`, `internal/api/ai_handlers.go`, `internal/api/router.go`, `internal/api/router_routes_ai_relay.go`, and `internal/api/contract_test.go` must turn the governed approvals-list API into the canonical empty-list payload as soon as settings-driven AI enablement succeeds, rather than leaving that surface on `503 Approval store not initialized` until some separate startup-only side effect happens. The same post-boot lifecycle owns the investigation surface: a successful in-process chat start or restart must reapply the same live dependency wiring as cold startup, including the Enterprise investigation orchestrator and Patrol circuit breaker, so enabling Intelligence after boot cannot leave finding investigations on `503 Investigation orchestrator not initialized` until the server restarts.
|
||||
25. Keep infrastructure summary chart transport contract-backed on the shared API surface: `internal/api/router.go`, `internal/api/contract_test.go`, and frontend infrastructure summary consumers must normalize long-range mixed-cadence history into equal-time summary buckets before shipping the infrastructure charts API payload, so 7-day and 30-day summary cards do not expose compressed right-edge tails just because recent samples arrive at a finer storage resolution.
|
||||
26. Keep long-range workload chart transport time-proportional on the shared API surface: `internal/api/router.go`, `internal/api/contract_test.go`, and workload chart consumers must cap mixed-cadence workload history by equal-time buckets rather than raw point index for the per-workload and aggregate workload chart APIs, so 7-day and 30-day workload cards do not bunch recent samples at the right edge just because recent telemetry is stored more densely.
|
||||
27. Keep chart timestamp precision canonical on that same shared API surface: when `internal/api/router.go` serializes monitoring history into infrastructure or workload chart payloads, it must preserve canonical millisecond timestamps from the shared monitoring timeline instead of rounding through whole-second conversion, so seeded mock history and live appends collapse onto one operator-visible timeline instead of appearing as duplicated tail samples.
|
||||
25. Keep infrastructure summary chart transport contract-backed on the shared API surface: `internal/api/chartapi/service.go`, `internal/api/contract_test.go`, and frontend infrastructure summary consumers must normalize long-range mixed-cadence history into equal-time summary buckets before shipping the infrastructure charts API payload, so 7-day and 30-day summary cards do not expose compressed right-edge tails just because recent samples arrive at a finer storage resolution.
|
||||
26. Keep long-range workload chart transport time-proportional on the shared API surface: `internal/api/chartapi/service.go`, `internal/api/contract_test.go`, and workload chart consumers must cap mixed-cadence workload history by equal-time buckets rather than raw point index for the per-workload and aggregate workload chart APIs, so 7-day and 30-day workload cards do not bunch recent samples at the right edge just because recent telemetry is stored more densely.
|
||||
27. Keep chart timestamp precision canonical on that same shared API surface: when `internal/api/chartapi/service.go` serializes monitoring history into infrastructure or workload chart payloads, it must preserve canonical millisecond timestamps from the shared monitoring timeline instead of rounding through whole-second conversion, so seeded mock history and live appends collapse onto one operator-visible timeline instead of appearing as duplicated tail samples.
|
||||
28. Keep Patrol remediation payload naming backward-compatible without leaking
|
||||
legacy automation-first wording into product copy. `frontend-modern/src/api/patrol.ts`,
|
||||
`internal/api/ai_handlers.go`, and `internal/api/router_routes_ai_relay.go`
|
||||
@@ -3056,7 +3068,7 @@ a new API state machine, queue contract, or verification-accounting field.
|
||||
contract must describe the operator-visible capability as remediation or
|
||||
safe remediation workflows.
|
||||
29. Keep storage chart identity canonical on that same shared API surface: the shared storage charts endpoint must key pool and physical-disk series by the resolved unified-resource `MetricsTarget.ResourceID`, not by canonical resource IDs or page-local aliases, so storage rows, focused summary cards, sticky summary shells, and detail charts all address the same history series in live and mock mode.
|
||||
30. Keep synthetic summary-chart fallback identity canonical on that same shared API surface: when `internal/api/router.go` has to synthesize mock summary history for infrastructure, workloads, or storage cards, it must derive the fallback from canonical `resourceType`, `resourceID`, and `metricType` ownership instead of raw min/max seed-prefix helpers, so range changes and runtime mock updates stay on one governed timeline.
|
||||
30. Keep synthetic summary-chart fallback identity canonical on that same shared API surface: when `internal/api/chartapi/service.go` has to synthesize mock summary history for infrastructure, workloads, or storage cards, it must derive the fallback from canonical `resourceType`, `resourceID`, and `metricType` ownership instead of raw min/max seed-prefix helpers, so range changes and runtime mock updates stay on one governed timeline.
|
||||
The same compact chart boundary also owns aggregate-only storage summary
|
||||
transport. `/api/charts/storage-summary` may batch only the canonical
|
||||
`used` and `avail` storage series required for the aggregate capacity
|
||||
@@ -3066,7 +3078,7 @@ a new API state machine, queue contract, or verification-accounting field.
|
||||
When mock mode is active, that same endpoint must come from the
|
||||
monitor-owned aggregate summary cache rather than rehydrating each pool
|
||||
chart on request.
|
||||
31. Keep workload-chart response identity canonical on that same shared API surface: `internal/api/router.go`, `internal/api/contract_test.go`, and workload summary consumers must emit provider-backed VM and system-container series under the same canonical workload IDs that workloads page rows use, while resolving history through the unified `MetricsTarget.ResourceID`, so hover and focus selection do not fall off for provider-backed rows.
|
||||
31. Keep workload-chart response identity canonical on that same shared API surface: `internal/api/chartapi/service.go`, `internal/api/contract_test.go`, and workload summary consumers must emit provider-backed VM and system-container series under the same canonical workload IDs that workloads page rows use, while resolving history through the unified `MetricsTarget.ResourceID`, so hover and focus selection do not fall off for provider-backed rows.
|
||||
Kubernetes pod workload rows follow that same contract through their
|
||||
metrics target. `/api/resources` may expose pod history only through the
|
||||
unified `MetricsTarget.ResourceID`, but that target must be the canonical
|
||||
@@ -3151,7 +3163,7 @@ a new API state machine, queue contract, or verification-accounting field.
|
||||
`internal/monitoring/monitor_metrics.go` instead of fetching the full guest
|
||||
metric set and trimming after the API payload is already assembled.
|
||||
39. Keep the retired compact dashboard overview route absent from that same
|
||||
shared API surface. `internal/api/resources.go`,
|
||||
shared API surface. `internal/api/resourceapi/resources.go`,
|
||||
`internal/api/router_routes_monitoring.go`, and
|
||||
`frontend-modern/src/api/resources.ts` must not restore
|
||||
`/api/resources/dashboard-summary`, `useDashboardOverview`, or frontend
|
||||
@@ -6320,7 +6332,7 @@ Action-plan stale-plan protection on those audit records now uses the canonical
|
||||
response contract stays deterministic without extra version baggage.
|
||||
The same API contract now also owns the dedicated frontend resource facet
|
||||
client in `frontend-modern/src/api/resources.ts`, which fetches the governed
|
||||
capability, relationship, and timeline surfaces from `internal/api/resources.go`
|
||||
capability, relationship, and timeline surfaces from `internal/api/resourceapi/resources.go`
|
||||
instead of teaching the drawer or list views to reconstruct them inline.
|
||||
Those facet reads now explicitly include the selected resource's canonical
|
||||
`capabilities` and `relationships`, so action affordances and relationship-map
|
||||
@@ -6385,7 +6397,7 @@ kinds, source types, and source adapters through the shared unified-resource
|
||||
change-filter parser, so API validation stays owned by the change model rather
|
||||
than being re-parsed separately in the HTTP handler.
|
||||
The tenant-scoped unified resource API now also stays on canonical
|
||||
unified-resource seeds end to end: `internal/api/resources.go`,
|
||||
unified-resource seeds end to end: `internal/api/resourceapi/resources.go`,
|
||||
`internal/api/router_helpers.go`, and `internal/api/state_provider.go` no
|
||||
longer treat raw tenant `StateSnapshot` data as a live registry-seeding owner
|
||||
once `UnifiedResourceSnapshotForTenant` is available.
|
||||
|
||||
@@ -919,7 +919,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "internal/api/resources.go",
|
||||
"path": "internal/api/resourceapi/resources.go",
|
||||
"rationale": "the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary",
|
||||
"subsystems": [
|
||||
"api-contracts",
|
||||
@@ -5510,7 +5510,7 @@
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"internal/api/resources_test.go",
|
||||
"internal/api/resourceapi/resources_test.go",
|
||||
"internal/monitoring/monitor_polling_test.go",
|
||||
"internal/truenas/client_api_shapes_test.go",
|
||||
"internal/truenas/client_test.go",
|
||||
@@ -5563,7 +5563,7 @@
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"internal/api/resources_test.go",
|
||||
"internal/api/resourceapi/resources_test.go",
|
||||
"internal/storagehealth/risk_test.go",
|
||||
"internal/storagehealth/topology_test.go",
|
||||
"internal/storagehealth/zfs_pool_health_contract_test.go"
|
||||
@@ -7447,7 +7447,7 @@
|
||||
"frontend-modern/src/utils/sourceTypePresentation.ts",
|
||||
"frontend-modern/src/utils/workloads.ts",
|
||||
"frontend-modern/src/utils/workloadTypePresentation.ts",
|
||||
"internal/api/resources.go",
|
||||
"internal/api/resourceapi/resources.go",
|
||||
"internal/platformsupport/manifest_generated.go"
|
||||
],
|
||||
"verification": {
|
||||
@@ -7622,7 +7622,7 @@
|
||||
"frontend-modern/src/types/resource.ts",
|
||||
"frontend-modern/src/utils/resourceIdentity.ts",
|
||||
"frontend-modern/src/utils/workloads.ts",
|
||||
"internal/api/resources.go"
|
||||
"internal/api/resourceapi/resources.go"
|
||||
],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
|
||||
@@ -41,7 +41,7 @@ disk behind a client-side page ceiling. Search includes vendor, WWN, transport,
|
||||
instance, controller, and member target. Explicit `0%` life remaining is a
|
||||
known critical value for SSD/NVMe media; absent or negative wearout remains the
|
||||
neutral unknown state.
|
||||
The shared `internal/api/resources.go` registry builder may repair
|
||||
The shared `internal/api/resourceapi/resources.go` registry builder may repair
|
||||
availability-check identity by replaying authoritative supplemental records.
|
||||
That availability composition remains owned by API contracts and unified
|
||||
resources; it does not make availability rows recovery points, storage health,
|
||||
@@ -1017,7 +1017,7 @@ recovery scope, or a storage/recovery-owned secret source.
|
||||
reinterpret that gap as a new counted system or invent a storage-local
|
||||
grace rule when the shared API and monitoring boundary already carry recent
|
||||
host continuity.
|
||||
Any adjacent list surfaces that reuse `internal/api/resources.go` must also
|
||||
Any adjacent list surfaces that reuse `internal/api/resourceapi/resources.go` must also
|
||||
preserve the canonical unified-resource `name -> type -> id` order so
|
||||
duplicate-name storage and recovery resources do not reshuffle between cold
|
||||
hydrate, paginated reads, and later live runtime updates.
|
||||
@@ -1068,7 +1068,7 @@ recovery scope, or a storage/recovery-owned secret source.
|
||||
boundary for storage/recovery consumers: org-scope or enabled-state changes
|
||||
must invalidate stale in-flight REST refreshes before their errors or
|
||||
request-guard cleanup can leak into the active resource snapshot.
|
||||
Shared chart transports in `internal/api/router.go` must follow the same
|
||||
Shared chart transports in `internal/api/chartapi/service.go` must follow the same
|
||||
rule in mock mode: `/api/storage-charts` and adjacent infrastructure chart
|
||||
payloads must read through `GetUnifiedReadStateOrSnapshot()` so storage and
|
||||
recovery consumers stay aligned with the canonical mock unified snapshot
|
||||
@@ -1573,13 +1573,13 @@ recovery scope, or a storage/recovery-owned secret source.
|
||||
`certificate_*` incident on those availability facets remain monitoring and
|
||||
alerts context. They do not prove storage encryption, backup integrity,
|
||||
repository authenticity, protection coverage, or restore readiness.
|
||||
33. Keep infrastructure summary chart bucketing and short response caching presentation-only on the adjacent shared API boundary. When `internal/api/router.go` normalizes mixed-cadence infrastructure history into equal-time summary buckets or serves a cached summary payload for repeated operator-facing summary-card requests, storage and recovery may consume the resulting visual context only; they must not reinterpret those normalized chart samples, cached timestamps, or cache hits as recovery freshness windows, backup cadence, or restore evidence.
|
||||
33. Keep infrastructure summary chart bucketing and short response caching presentation-only on the adjacent shared API boundary. When `internal/api/chartapi/service.go` normalizes mixed-cadence infrastructure history into equal-time summary buckets or serves a cached summary payload for repeated operator-facing summary-card requests, storage and recovery may consume the resulting visual context only; they must not reinterpret those normalized chart samples, cached timestamps, or cache hits as recovery freshness windows, backup cadence, or restore evidence.
|
||||
The same router may wire the adjacent server-owned agent command
|
||||
authorization verifier, but storage/recovery must not treat an approval id,
|
||||
signed command grant, or command result as restore authorization or
|
||||
recovery evidence; that authority remains action-governance and
|
||||
agent-lifecycle owned.
|
||||
34. Keep workload chart downsampling and short response caching presentation-only on that same adjacent shared API boundary. When `internal/api/router.go` caps mixed-cadence workload history into equal-time buckets or serves a cached workload-summary payload for repeated operator-facing workload-card requests, storage and recovery may consume the resulting visual context only; they must not reinterpret those shaped chart samples, cached timestamps, or cache hits as recovery freshness windows, backup cadence, or restore evidence.
|
||||
34. Keep workload chart downsampling and short response caching presentation-only on that same adjacent shared API boundary. When `internal/api/chartapi/service.go` caps mixed-cadence workload history into equal-time buckets or serves a cached workload-summary payload for repeated operator-facing workload-card requests, storage and recovery may consume the resulting visual context only; they must not reinterpret those shaped chart samples, cached timestamps, or cache hits as recovery freshness windows, backup cadence, or restore evidence.
|
||||
The same adjacent chart boundary now covers compact storage capacity
|
||||
transport. `internal/api/router.go` may batch only the canonical `used`
|
||||
and `avail` storage series for `/api/charts/storage-summary`, but storage
|
||||
|
||||
@@ -818,7 +818,7 @@ container inventory table.
|
||||
runtime workload belongs to both Docker and an owning infrastructure
|
||||
platform.
|
||||
18. `frontend-modern/src/utils/workloads.ts` shared with `performance-and-scalability`: the stable workload metadata identity helper is both a unified-resource persistence boundary and a workloads hot-path lookup boundary.
|
||||
19. `internal/api/resources.go` shared with `api-contracts`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary.
|
||||
19. `internal/api/resourceapi/resources.go` shared with `api-contracts`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary.
|
||||
`/api/resources` type filters must accept URL-encoded comma-separated lists
|
||||
from browser query builders exactly like literal comma separators, so Docker
|
||||
/ Podman runtime pages do not lose `docker-host` inventory while requesting
|
||||
@@ -1137,7 +1137,7 @@ AI-only summary payloads, or page-local heuristics.
|
||||
`internal/unifiedresources/pbs_rollups.go`,
|
||||
`internal/unifiedresources/monitored_systems.go`,
|
||||
`internal/unifiedresources/monitored_system_projection.go`, and
|
||||
the shared list-order helpers consumed by `internal/api/resources.go`;
|
||||
the shared list-order helpers consumed by `internal/api/resourceapi/resources.go`;
|
||||
canonical unified-resource lists must preserve one deterministic
|
||||
`name -> type -> id` order across registry reads, REST pagination, and
|
||||
websocket-backed refreshes so equal-name resources do not silently reshuffle
|
||||
@@ -3319,7 +3319,7 @@ large historical tables during startup. Timeline reads must resolve legacy
|
||||
`timestamp` and nullable `observed_at` values through read-time fallback
|
||||
expressions, while writes preserve the legacy timestamp on target databases
|
||||
that still require it.
|
||||
`internal/api/resources.go` now exposes that same history through dedicated
|
||||
`internal/api/resourceapi/resources.go` now exposes that same history through dedicated
|
||||
`/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.
|
||||
@@ -3888,7 +3888,7 @@ The unified resource core is strong and canonical, but monitoring and some
|
||||
frontend/API consumers are still being tightened around it.
|
||||
|
||||
Tenant-scoped API resource seeding now also stays on unified-resource ownership
|
||||
end to end: `internal/api/resources.go` consumes
|
||||
end to end: `internal/api/resourceapi/resources.go` consumes
|
||||
`UnifiedResourceSnapshotForTenant` as the canonical tenant registry seed, and
|
||||
no longer falls back to raw tenant `StateSnapshot` seeding on the live request
|
||||
path when that unified seed is empty.
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/actionplanner"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/resourceapi"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
@@ -816,12 +817,12 @@ func projectActionAudits(records []unified.ActionAuditRecord, registry *unified.
|
||||
|
||||
func projectActionAudit(record unified.ActionAuditRecord, registry *unified.ResourceRegistry) actionAuditProjection {
|
||||
projection := actionAuditProjection{ActionAuditRecord: record}
|
||||
resource, ok := presentationResourceByID(registry, record.Request.ResourceID)
|
||||
resource, ok := resourceapi.PresentationResourceByID(registry, record.Request.ResourceID)
|
||||
if ok && resource != nil && strings.TrimSpace(resource.Name) != "" {
|
||||
projection.Resource = &actionResourcePresentation{
|
||||
ID: unified.CanonicalResourceID(resource.ID),
|
||||
Name: strings.TrimSpace(resource.Name),
|
||||
Type: resourceContractType(*resource),
|
||||
Type: resourceapi.ContractType(*resource),
|
||||
}
|
||||
for _, capability := range resource.Capabilities {
|
||||
if capability.Name == record.Request.CapabilityName {
|
||||
@@ -834,10 +835,10 @@ func projectActionAudit(record unified.ActionAuditRecord, registry *unified.Reso
|
||||
projection.BlastRadius = make([]actionResourcePresentation, 0, len(record.Plan.PredictedBlastRadius))
|
||||
for _, affectedID := range record.Plan.PredictedBlastRadius {
|
||||
entry := actionResourcePresentation{ID: affectedID}
|
||||
if affected, found := presentationResourceByID(registry, affectedID); found && affected != nil {
|
||||
if affected, found := resourceapi.PresentationResourceByID(registry, affectedID); found && affected != nil {
|
||||
entry.ID = unified.CanonicalResourceID(affected.ID)
|
||||
entry.Name = strings.TrimSpace(affected.Name)
|
||||
entry.Type = resourceContractType(*affected)
|
||||
entry.Type = resourceapi.ContractType(*affected)
|
||||
}
|
||||
projection.BlastRadius = append(projection.BlastRadius, entry)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentcontext"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/resourceapi"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/telemetry"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
@@ -492,7 +493,7 @@ func (h *AgentContextHandler) HandleResourceContext(w http.ResponseWriter, r *ht
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
resource, resourceID, ok := presentationResourceByReference(registry, resourceID)
|
||||
resource, resourceID, ok := resourceapi.PresentationResourceByReference(registry, resourceID)
|
||||
if !ok {
|
||||
writeJSONError(w, http.StatusNotFound, agentcapabilities.AgentErrCodeResourceNotFound,
|
||||
"No resource is registered with this canonical id.")
|
||||
@@ -507,8 +508,8 @@ func (h *AgentContextHandler) HandleResourceContext(w http.ResponseWriter, r *ht
|
||||
|
||||
generatedAt := time.Now().UTC()
|
||||
resourceCopy := *resource
|
||||
attachDiscoveryTarget(&resourceCopy)
|
||||
h.resources.attachDiscoveryReadiness(&resourceCopy, generatedAt)
|
||||
resourceapi.AttachDiscoveryTarget(&resourceCopy)
|
||||
h.resources.AttachDiscoveryReadiness(&resourceCopy, generatedAt)
|
||||
|
||||
bundle := AgentResourceContext{
|
||||
CanonicalID: resourceID,
|
||||
@@ -617,7 +618,7 @@ func (h *AgentContextHandler) HandleResourceCapabilities(w http.ResponseWriter,
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
resource, resourceID, ok := presentationResourceByReference(registry, resourceID)
|
||||
resource, resourceID, ok := resourceapi.PresentationResourceByReference(registry, resourceID)
|
||||
if !ok {
|
||||
writeJSONError(w, http.StatusNotFound, agentcapabilities.AgentErrCodeResourceNotFound,
|
||||
"No resource is registered with this canonical id.")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package chartapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,330 @@
|
||||
package chartapi
|
||||
|
||||
// ChartResponse represents chart data
|
||||
type ChartResponse struct {
|
||||
ChartData map[string]VMChartData `json:"data"`
|
||||
NodeData map[string]NodeChartData `json:"nodeData"`
|
||||
StorageData map[string]StorageChartData `json:"storageData"`
|
||||
DockerData map[string]VMChartData `json:"dockerData"` // Docker container metrics (keyed by container ID)
|
||||
DockerHostData map[string]VMChartData `json:"dockerHostData"` // Docker host metrics (keyed by host ID)
|
||||
AgentData map[string]VMChartData `json:"agentData"` // Unified agent metrics (keyed by agent ID)
|
||||
GuestTypes map[string]string `json:"guestTypes"` // Maps guest ID to type ("vm", "system-container", "k8s")
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Stats ChartStats `json:"stats"`
|
||||
}
|
||||
|
||||
func EmptyChartResponse() ChartResponse {
|
||||
return ChartResponse{}.NormalizeCollections()
|
||||
}
|
||||
|
||||
func (r ChartResponse) NormalizeCollections() ChartResponse {
|
||||
if r.ChartData == nil {
|
||||
r.ChartData = map[string]VMChartData{}
|
||||
}
|
||||
if r.NodeData == nil {
|
||||
r.NodeData = map[string]NodeChartData{}
|
||||
}
|
||||
if r.StorageData == nil {
|
||||
r.StorageData = map[string]StorageChartData{}
|
||||
}
|
||||
if r.DockerData == nil {
|
||||
r.DockerData = map[string]VMChartData{}
|
||||
}
|
||||
if r.DockerHostData == nil {
|
||||
r.DockerHostData = map[string]VMChartData{}
|
||||
}
|
||||
if r.AgentData == nil {
|
||||
r.AgentData = map[string]VMChartData{}
|
||||
}
|
||||
if r.GuestTypes == nil {
|
||||
r.GuestTypes = map[string]string{}
|
||||
}
|
||||
r.Stats = r.Stats.NormalizeCollections()
|
||||
return r
|
||||
}
|
||||
|
||||
// InfrastructureChartsResponse is a lightweight variant of ChartResponse used by
|
||||
// infra-only clients (Infrastructure summary sparklines, prewarm caches).
|
||||
// It avoids the heavy guest/storage chart payload and associated compute.
|
||||
type InfrastructureChartsResponse struct {
|
||||
NodeData map[string]NodeChartData `json:"nodeData"`
|
||||
DockerHostData map[string]VMChartData `json:"dockerHostData"` // Docker host metrics (keyed by host ID)
|
||||
AgentData map[string]VMChartData `json:"agentData"` // Unified agent metrics (keyed by agent ID)
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Stats ChartStats `json:"stats"`
|
||||
}
|
||||
|
||||
func EmptyInfrastructureChartsResponse() InfrastructureChartsResponse {
|
||||
return InfrastructureChartsResponse{}.NormalizeCollections()
|
||||
}
|
||||
|
||||
func (r InfrastructureChartsResponse) NormalizeCollections() InfrastructureChartsResponse {
|
||||
if r.NodeData == nil {
|
||||
r.NodeData = map[string]NodeChartData{}
|
||||
}
|
||||
if r.DockerHostData == nil {
|
||||
r.DockerHostData = map[string]VMChartData{}
|
||||
}
|
||||
if r.AgentData == nil {
|
||||
r.AgentData = map[string]VMChartData{}
|
||||
}
|
||||
r.Stats = r.Stats.NormalizeCollections()
|
||||
return r
|
||||
}
|
||||
|
||||
// WorkloadChartsResponse is a lightweight chart payload used by Workloads
|
||||
// summary sparklines. It intentionally excludes infrastructure/storage series.
|
||||
type WorkloadChartsResponse struct {
|
||||
ChartData map[string]VMChartData `json:"data"` // Workload metrics keyed by workload ID
|
||||
DockerData map[string]VMChartData `json:"dockerData"` // Docker container metrics keyed by container ID
|
||||
GuestTypes map[string]string `json:"guestTypes"` // Maps guest ID to type ("vm", "system-container", "k8s")
|
||||
Timestamp int64 `json:"timestamp"` // Unix timestamp in milliseconds
|
||||
Stats ChartStats `json:"stats"` // Includes pointCounts + source hints
|
||||
}
|
||||
|
||||
func EmptyWorkloadChartsResponse() WorkloadChartsResponse {
|
||||
return WorkloadChartsResponse{}.NormalizeCollections()
|
||||
}
|
||||
|
||||
func (r WorkloadChartsResponse) NormalizeCollections() WorkloadChartsResponse {
|
||||
if r.ChartData == nil {
|
||||
r.ChartData = map[string]VMChartData{}
|
||||
}
|
||||
if r.DockerData == nil {
|
||||
r.DockerData = map[string]VMChartData{}
|
||||
}
|
||||
if r.GuestTypes == nil {
|
||||
r.GuestTypes = map[string]string{}
|
||||
}
|
||||
r.Stats = r.Stats.NormalizeCollections()
|
||||
return r
|
||||
}
|
||||
|
||||
// WorkloadsSummaryMetricData captures aggregate workload trend lines for a
|
||||
// single metric (median and p95 across workloads).
|
||||
type WorkloadsSummaryMetricData struct {
|
||||
P50 []MetricPoint `json:"p50"`
|
||||
P95 []MetricPoint `json:"p95"`
|
||||
}
|
||||
|
||||
func (m WorkloadsSummaryMetricData) NormalizeCollections() WorkloadsSummaryMetricData {
|
||||
if m.P50 == nil {
|
||||
m.P50 = []MetricPoint{}
|
||||
}
|
||||
if m.P95 == nil {
|
||||
m.P95 = []MetricPoint{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// WorkloadsGuestCounts captures workload counts used by the workloads summary
|
||||
// cards for quick context.
|
||||
type WorkloadsGuestCounts struct {
|
||||
Total int `json:"total"`
|
||||
Running int `json:"running"`
|
||||
Stopped int `json:"stopped"`
|
||||
}
|
||||
|
||||
// WorkloadsSummaryContributor identifies a high-impact workload for a metric.
|
||||
type WorkloadsSummaryContributor struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
|
||||
// WorkloadsSummaryContributors groups top contributors by metric.
|
||||
type WorkloadsSummaryContributors struct {
|
||||
CPU []WorkloadsSummaryContributor `json:"cpu"`
|
||||
Memory []WorkloadsSummaryContributor `json:"memory"`
|
||||
Disk []WorkloadsSummaryContributor `json:"disk"`
|
||||
Network []WorkloadsSummaryContributor `json:"network"`
|
||||
}
|
||||
|
||||
func (c WorkloadsSummaryContributors) NormalizeCollections() WorkloadsSummaryContributors {
|
||||
if c.CPU == nil {
|
||||
c.CPU = []WorkloadsSummaryContributor{}
|
||||
}
|
||||
if c.Memory == nil {
|
||||
c.Memory = []WorkloadsSummaryContributor{}
|
||||
}
|
||||
if c.Disk == nil {
|
||||
c.Disk = []WorkloadsSummaryContributor{}
|
||||
}
|
||||
if c.Network == nil {
|
||||
c.Network = []WorkloadsSummaryContributor{}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// WorkloadsSummaryBlastRadius describes how concentrated each metric pressure is.
|
||||
type WorkloadsSummaryBlastRadius struct {
|
||||
Scope string `json:"scope"` // idle, concentrated, mixed, distributed
|
||||
Top3Share float64 `json:"top3Share"`
|
||||
ActiveWorkloads int `json:"activeWorkloads"`
|
||||
}
|
||||
|
||||
// WorkloadsSummaryBlastRadiusGroup groups blast-radius insights by metric.
|
||||
type WorkloadsSummaryBlastRadiusGroup struct {
|
||||
CPU WorkloadsSummaryBlastRadius `json:"cpu"`
|
||||
Memory WorkloadsSummaryBlastRadius `json:"memory"`
|
||||
Disk WorkloadsSummaryBlastRadius `json:"disk"`
|
||||
Network WorkloadsSummaryBlastRadius `json:"network"`
|
||||
}
|
||||
|
||||
// WorkloadsSummaryChartsResponse is a compact response for workloads top-card
|
||||
// sparklines. It avoids returning per-workload time series.
|
||||
type WorkloadsSummaryChartsResponse struct {
|
||||
CPU WorkloadsSummaryMetricData `json:"cpu"`
|
||||
Memory WorkloadsSummaryMetricData `json:"memory"`
|
||||
Disk WorkloadsSummaryMetricData `json:"disk"`
|
||||
Network WorkloadsSummaryMetricData `json:"network"`
|
||||
GuestCounts WorkloadsGuestCounts `json:"guestCounts"`
|
||||
TopContributors WorkloadsSummaryContributors `json:"topContributors"`
|
||||
BlastRadius WorkloadsSummaryBlastRadiusGroup `json:"blastRadius"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Stats ChartStats `json:"stats"`
|
||||
}
|
||||
|
||||
func EmptyWorkloadsSummaryChartsResponse() WorkloadsSummaryChartsResponse {
|
||||
return WorkloadsSummaryChartsResponse{}.NormalizeCollections()
|
||||
}
|
||||
|
||||
func (r WorkloadsSummaryChartsResponse) NormalizeCollections() WorkloadsSummaryChartsResponse {
|
||||
r.CPU = r.CPU.NormalizeCollections()
|
||||
r.Memory = r.Memory.NormalizeCollections()
|
||||
r.Disk = r.Disk.NormalizeCollections()
|
||||
r.Network = r.Network.NormalizeCollections()
|
||||
r.TopContributors = r.TopContributors.NormalizeCollections()
|
||||
r.Stats = r.Stats.NormalizeCollections()
|
||||
return r
|
||||
}
|
||||
|
||||
// StorageSummaryTrendResponse is a compact response for the dashboard storage
|
||||
// card. It intentionally avoids returning per-pool and per-disk series.
|
||||
type StorageSummaryTrendResponse struct {
|
||||
Capacity []MetricPoint `json:"capacity"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Stats ChartStats `json:"stats"`
|
||||
}
|
||||
|
||||
func EmptyStorageSummaryTrendResponse() StorageSummaryTrendResponse {
|
||||
return StorageSummaryTrendResponse{}.NormalizeCollections()
|
||||
}
|
||||
|
||||
func (r StorageSummaryTrendResponse) NormalizeCollections() StorageSummaryTrendResponse {
|
||||
if r.Capacity == nil {
|
||||
r.Capacity = []MetricPoint{}
|
||||
}
|
||||
r.Stats = r.Stats.NormalizeCollections()
|
||||
return r
|
||||
}
|
||||
|
||||
// ChartStats represents chart statistics
|
||||
type ChartStats struct {
|
||||
OldestDataTimestamp int64 `json:"oldestDataTimestamp"`
|
||||
Range string `json:"range,omitempty"`
|
||||
RangeSeconds int64 `json:"rangeSeconds,omitempty"`
|
||||
MetricsStoreEnabled bool `json:"metricsStoreEnabled"`
|
||||
PrimarySourceHint string `json:"primarySourceHint,omitempty"`
|
||||
InMemoryThresholdSecs int64 `json:"inMemoryThresholdSecs,omitempty"`
|
||||
PointCounts ChartPointCounts `json:"pointCounts,omitempty"`
|
||||
}
|
||||
|
||||
func (s ChartStats) NormalizeCollections() ChartStats {
|
||||
return s
|
||||
}
|
||||
|
||||
// ChartPointCounts summarizes how many points were returned in /api/charts.
|
||||
type ChartPointCounts struct {
|
||||
Total int `json:"total,omitempty"`
|
||||
Guests int `json:"guests,omitempty"`
|
||||
Nodes int `json:"nodes,omitempty"`
|
||||
Storage int `json:"storage,omitempty"`
|
||||
DockerContainers int `json:"dockerContainers,omitempty"`
|
||||
DockerHosts int `json:"dockerHosts,omitempty"`
|
||||
Agents int `json:"agents,omitempty"`
|
||||
}
|
||||
|
||||
// VMChartData represents chart data for a VM
|
||||
type VMChartData map[string][]MetricPoint
|
||||
|
||||
// NodeChartData represents chart data for a node
|
||||
type NodeChartData map[string][]MetricPoint
|
||||
|
||||
// StorageChartData represents chart data for storage
|
||||
type StorageChartData map[string][]MetricPoint
|
||||
|
||||
// StorageChartsResponse represents storage charts API response.
|
||||
// It includes both pool-level capacity metrics and physical disk metrics.
|
||||
type StorageChartsResponse struct {
|
||||
Pools map[string]StoragePoolChartData `json:"pools"`
|
||||
Disks map[string]StorageDiskChartData `json:"disks"`
|
||||
Stats ChartStats `json:"stats"`
|
||||
}
|
||||
|
||||
func EmptyStorageChartsResponse() StorageChartsResponse {
|
||||
return StorageChartsResponse{}.NormalizeCollections()
|
||||
}
|
||||
|
||||
func (r StorageChartsResponse) NormalizeCollections() StorageChartsResponse {
|
||||
if r.Pools == nil {
|
||||
r.Pools = map[string]StoragePoolChartData{}
|
||||
}
|
||||
if r.Disks == nil {
|
||||
r.Disks = map[string]StorageDiskChartData{}
|
||||
}
|
||||
for key, pool := range r.Pools {
|
||||
r.Pools[key] = pool.NormalizeCollections()
|
||||
}
|
||||
for key, disk := range r.Disks {
|
||||
r.Disks[key] = disk.NormalizeCollections()
|
||||
}
|
||||
r.Stats = r.Stats.NormalizeCollections()
|
||||
return r
|
||||
}
|
||||
|
||||
// StoragePoolChartData holds per-pool capacity time-series.
|
||||
type StoragePoolChartData struct {
|
||||
Name string `json:"name"`
|
||||
Usage []MetricPoint `json:"usage"`
|
||||
Used []MetricPoint `json:"used"`
|
||||
Avail []MetricPoint `json:"avail"`
|
||||
}
|
||||
|
||||
func (d StoragePoolChartData) NormalizeCollections() StoragePoolChartData {
|
||||
if d.Usage == nil {
|
||||
d.Usage = []MetricPoint{}
|
||||
}
|
||||
if d.Used == nil {
|
||||
d.Used = []MetricPoint{}
|
||||
}
|
||||
if d.Avail == nil {
|
||||
d.Avail = []MetricPoint{}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// StorageDiskChartData holds per-disk temperature time-series.
|
||||
type StorageDiskChartData struct {
|
||||
Name string `json:"name"`
|
||||
Node string `json:"node"`
|
||||
Temperature []MetricPoint `json:"temperature"`
|
||||
}
|
||||
|
||||
func (d StorageDiskChartData) NormalizeCollections() StorageDiskChartData {
|
||||
if d.Temperature == nil {
|
||||
d.Temperature = []MetricPoint{}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// MetricPoint represents a single metric data point
|
||||
type MetricPoint struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
|
||||
// AgentVersionResponse represents Docker / Podman module version information.
|
||||
type AgentVersionResponse struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/chartapi"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
||||
)
|
||||
|
||||
type routerChartMonitorResolver struct {
|
||||
router *Router
|
||||
}
|
||||
|
||||
func (r routerChartMonitorResolver) MonitorForContext(ctx context.Context) *monitoring.Monitor {
|
||||
if r.router == nil {
|
||||
return nil
|
||||
}
|
||||
return r.router.getTenantMonitor(ctx)
|
||||
}
|
||||
|
||||
func (r *Router) ensureChartService() *chartapi.Service {
|
||||
if r.chartService == nil {
|
||||
r.chartService = chartapi.NewService(routerChartMonitorResolver{router: r})
|
||||
}
|
||||
return r.chartService
|
||||
}
|
||||
|
||||
func (r *Router) handleCharts(w http.ResponseWriter, req *http.Request) {
|
||||
r.ensureChartService().HandleCharts(w, req)
|
||||
}
|
||||
|
||||
func (r *Router) handleWorkloadCharts(w http.ResponseWriter, req *http.Request) {
|
||||
r.ensureChartService().HandleWorkloadCharts(w, req)
|
||||
}
|
||||
|
||||
func (r *Router) handleInfrastructureCharts(w http.ResponseWriter, req *http.Request) {
|
||||
r.ensureChartService().HandleInfrastructureCharts(w, req)
|
||||
}
|
||||
|
||||
func (r *Router) handleWorkloadsSummaryCharts(w http.ResponseWriter, req *http.Request) {
|
||||
r.ensureChartService().HandleWorkloadsSummaryCharts(w, req)
|
||||
}
|
||||
|
||||
func (r *Router) handleStorageCharts(w http.ResponseWriter, req *http.Request) {
|
||||
r.ensureChartService().HandleStorageCharts(w, req)
|
||||
}
|
||||
|
||||
func (r *Router) handleStorageSummaryCharts(w http.ResponseWriter, req *http.Request) {
|
||||
r.ensureChartService().HandleStorageSummaryCharts(w, req)
|
||||
}
|
||||
@@ -37,7 +37,9 @@ import (
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/unified"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/chartapi"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/configapi"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/resourceapi"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/license/entitlements"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
||||
@@ -480,7 +482,7 @@ func TestContract_UnifiedSeedSourcesUsesSourceOwnedAvailabilityEndpoint(t *testi
|
||||
TargetID: "ops-api",
|
||||
}},
|
||||
}
|
||||
sources := unifiedSeedSources([]unifiedresources.Resource{hostProjection})
|
||||
sources := resourceapi.UnifiedSeedSources([]unifiedresources.Resource{hostProjection})
|
||||
if _, ok := sources[unifiedresources.SourceAvailability]; ok {
|
||||
t.Fatalf("seed sources = %v, host projection must not claim provider ownership", sources)
|
||||
}
|
||||
@@ -492,7 +494,7 @@ func TestContract_UnifiedSeedSourcesUsesSourceOwnedAvailabilityEndpoint(t *testi
|
||||
TargetID: "ops-api",
|
||||
}},
|
||||
}
|
||||
sources = unifiedSeedSources([]unifiedresources.Resource{hostProjection, check})
|
||||
sources = resourceapi.UnifiedSeedSources([]unifiedresources.Resource{hostProjection, check})
|
||||
if _, ok := sources[unifiedresources.SourceAvailability]; !ok {
|
||||
t.Fatalf("seed sources = %v, want availability from source-owned endpoint", sources)
|
||||
}
|
||||
@@ -2501,7 +2503,7 @@ func TestContract_AISettingsProviderRegistryMetadata(t *testing.T) {
|
||||
func TestContract_ChartMetricPointsPreserveMillisecondPrecision(t *testing.T) {
|
||||
pointTime := time.Date(2026, time.March, 31, 12, 0, 0, 987_000_000, time.UTC)
|
||||
|
||||
converted := monitorPointsToAPI([]monitoring.MetricPoint{{
|
||||
converted := chartapi.MonitorPointsToAPI([]monitoring.MetricPoint{{
|
||||
Timestamp: pointTime,
|
||||
Value: 42,
|
||||
}})
|
||||
@@ -2915,8 +2917,8 @@ func TestContract_InfrastructureChartsNormalizeLongRangeMixedCadence(t *testing.
|
||||
if len(cpuSeries) == 0 {
|
||||
t.Fatal("expected normalized cpu series")
|
||||
}
|
||||
if len(cpuSeries) > infrastructureSummaryMaxSeriesPoints {
|
||||
t.Fatalf("expected cpu series <= %d points, got %d", infrastructureSummaryMaxSeriesPoints, len(cpuSeries))
|
||||
if len(cpuSeries) > chartapi.InfrastructureSummaryMaxSeriesPoints {
|
||||
t.Fatalf("expected cpu series <= %d points, got %d", chartapi.InfrastructureSummaryMaxSeriesPoints, len(cpuSeries))
|
||||
}
|
||||
if cpuSeries[len(cpuSeries)-1].Timestamp != now.UnixMilli() {
|
||||
t.Fatalf("expected latest cpu timestamp %d, got %d", now.UnixMilli(), cpuSeries[len(cpuSeries)-1].Timestamp)
|
||||
@@ -3235,7 +3237,7 @@ func TestContract_WorkloadChartsUseCanonicalWorkloadIDsForProviderBackedVMs(t *t
|
||||
if readState == nil || len(readState.VMs()) != 1 || readState.VMs()[0] == nil {
|
||||
t.Fatalf("expected one provider-backed VM in read state, got %+v", readState)
|
||||
}
|
||||
resourceID, _, ok := vmChartRequest(readState.VMs()[0])
|
||||
resourceID, _, ok := chartapi.VMChartRequest(readState.VMs()[0])
|
||||
if !ok {
|
||||
t.Fatal("expected canonical vm chart request")
|
||||
}
|
||||
@@ -3373,11 +3375,11 @@ func TestContract_WorkloadsSummaryChartsNormalizeLongRangeMixedCadence(t *testin
|
||||
if len(decoded.CPU.P50) == 0 {
|
||||
t.Fatal("expected normalized workload summary p50 series")
|
||||
}
|
||||
if len(decoded.CPU.P50) > workloadsSummaryMaxSeriesPoints {
|
||||
t.Fatalf("expected workload summary p50 <= %d points, got %d", workloadsSummaryMaxSeriesPoints, len(decoded.CPU.P50))
|
||||
if len(decoded.CPU.P50) > chartapi.WorkloadsSummaryMaxSeriesPoints {
|
||||
t.Fatalf("expected workload summary p50 <= %d points, got %d", chartapi.WorkloadsSummaryMaxSeriesPoints, len(decoded.CPU.P50))
|
||||
}
|
||||
if len(decoded.CPU.P95) > workloadsSummaryMaxSeriesPoints {
|
||||
t.Fatalf("expected workload summary p95 <= %d points, got %d", workloadsSummaryMaxSeriesPoints, len(decoded.CPU.P95))
|
||||
if len(decoded.CPU.P95) > chartapi.WorkloadsSummaryMaxSeriesPoints {
|
||||
t.Fatalf("expected workload summary p95 <= %d points, got %d", chartapi.WorkloadsSummaryMaxSeriesPoints, len(decoded.CPU.P95))
|
||||
}
|
||||
if decoded.CPU.P50[len(decoded.CPU.P50)-1].Timestamp != now.UnixMilli() {
|
||||
t.Fatalf("expected latest workload summary timestamp %d, got %d", now.UnixMilli(), decoded.CPU.P50[len(decoded.CPU.P50)-1].Timestamp)
|
||||
@@ -3396,9 +3398,9 @@ func TestContract_WorkloadsSummaryChartsNormalizeLongRangeMixedCadence(t *testin
|
||||
}
|
||||
|
||||
func TestContract_WorkloadChartMetricBudgetGuardrailsRemainCanonical(t *testing.T) {
|
||||
data, err := os.ReadFile("router.go")
|
||||
data, err := os.ReadFile("chartapi/service.go")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read router.go: %v", err)
|
||||
t.Fatalf("failed to read chartapi/service.go: %v", err)
|
||||
}
|
||||
source := string(data)
|
||||
|
||||
@@ -3428,7 +3430,7 @@ func TestContract_WorkloadChartMetricBudgetGuardrailsRemainCanonical(t *testing.
|
||||
}
|
||||
for _, snippet := range requiredSnippets {
|
||||
if !strings.Contains(source, snippet) {
|
||||
t.Fatalf("router.go must contain %q", snippet)
|
||||
t.Fatalf("chartapi/service.go must contain %q", snippet)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3436,7 +3438,7 @@ func TestContract_WorkloadChartMetricBudgetGuardrailsRemainCanonical(t *testing.
|
||||
func TestContract_GenerateStyledMockSeries_UsesTimestampBasedCurve(t *testing.T) {
|
||||
now := time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC).UnixMilli()
|
||||
|
||||
coarse := generateStyledMockSeries(
|
||||
coarse := chartapi.GenerateStyledMockSeries(
|
||||
now,
|
||||
time.Hour,
|
||||
7,
|
||||
@@ -3445,7 +3447,7 @@ func TestContract_GenerateStyledMockSeries_UsesTimestampBasedCurve(t *testing.T)
|
||||
"orion-2-f54579833f9c",
|
||||
"memory",
|
||||
)
|
||||
fine := generateStyledMockSeries(
|
||||
fine := chartapi.GenerateStyledMockSeries(
|
||||
now,
|
||||
time.Hour,
|
||||
13,
|
||||
@@ -13331,11 +13333,11 @@ func TestContractResourceFiltersAcceptNativeDockerAndKubernetesInventory(t *test
|
||||
"k8s-event",
|
||||
}, ",")
|
||||
|
||||
if unsupported := unsupportedResourceTypeFilterTokens(raw); len(unsupported) != 0 {
|
||||
if unsupported := resourceapi.UnsupportedResourceTypeFilterTokens(raw); len(unsupported) != 0 {
|
||||
t.Fatalf("native platform inventory filters should be supported, got unsupported=%v", unsupported)
|
||||
}
|
||||
|
||||
got := parseResourceTypes(raw)
|
||||
got := resourceapi.ParseResourceTypes(raw)
|
||||
for _, resourceType := range []unifiedresources.ResourceType{
|
||||
unifiedresources.ResourceTypeDockerImage,
|
||||
unifiedresources.ResourceTypeDockerVolume,
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/chartapi"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/resourceapi"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/deploy"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
@@ -15,7 +17,7 @@ import (
|
||||
// - filterPVEBackups (pve_backups.go)
|
||||
// - filterReplicationJobs (replication.go)
|
||||
// - cephDiscoveryTarget (resources.go)
|
||||
// - capMetricPointSeriesByIndex (router.go)
|
||||
// - chartapi.CapMetricPointSeriesByIndex (router.go)
|
||||
// - deriveInstallJobStatus (deploy_handlers.go)
|
||||
//
|
||||
// Conventions mirror internal/api/deploy_handlers_test.go (package api,
|
||||
@@ -257,7 +259,7 @@ func TestBranchcov0722PM_CephDiscoveryTarget(t *testing.T) {
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := cephDiscoveryTarget(tc.resource)
|
||||
got := resourceapi.CephDiscoveryTarget(tc.resource)
|
||||
if tc.wantNil {
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil target, got %+v", got)
|
||||
@@ -283,7 +285,7 @@ func TestBranchcov0722PM_CephDiscoveryTarget(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- capMetricPointSeriesByIndex --------------------------------------------
|
||||
// --- chartapi.CapMetricPointSeriesByIndex --------------------------------------------
|
||||
|
||||
func TestBranchcov0722PM_CapMetricPointSeriesByIndex(t *testing.T) {
|
||||
// 10 distinct points: timestamp == index, value == index.
|
||||
@@ -352,7 +354,7 @@ func TestBranchcov0722PM_CapMetricPointSeriesByIndex(t *testing.T) {
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := capMetricPointSeriesByIndex(tc.points, tc.maxPoints)
|
||||
got := chartapi.CapMetricPointSeriesByIndex(tc.points, tc.maxPoints)
|
||||
if len(got) != tc.wantLen {
|
||||
t.Fatalf("len: got %d, want %d", len(got), tc.wantLen)
|
||||
}
|
||||
|
||||
@@ -18,131 +18,6 @@ import (
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
|
||||
)
|
||||
|
||||
// TestLoad_500Node_ConcurrentResources validates that the /api/resources
|
||||
// endpoint sustains acceptable latency under concurrent load with 500 nodes
|
||||
// and ~2500 VMs in state, simulating a large self-hosted deployment.
|
||||
//
|
||||
// The test launches 50 concurrent goroutines each making serial requests over
|
||||
// a 2-second window. It measures p50/p95/p99 and total throughput.
|
||||
func TestLoad_500Node_ConcurrentResources(t *testing.T) {
|
||||
skipUnderRace(t)
|
||||
suppressTestLogs(t)
|
||||
|
||||
state := buildLargeDeploymentState(t, 500)
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
handlers := NewResourceHandlers(cfg)
|
||||
handlers.SetStateProvider(&loadTestStateProvider{state: state})
|
||||
|
||||
// Warm the cache with an initial request.
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/resources", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handlers.HandleListResources(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("warmup failed: status %d, body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var warmupResp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &warmupResp); err != nil {
|
||||
t.Fatalf("warmup unmarshal: %v", err)
|
||||
}
|
||||
meta, _ := warmupResp["meta"].(map[string]interface{})
|
||||
total, _ := meta["total"].(float64)
|
||||
// 500 nodes + 2500 VMs = 3000 resources exactly.
|
||||
if int(total) != 3000 {
|
||||
t.Fatalf("warmup: expected 3000 total resources, got %v", total)
|
||||
}
|
||||
t.Logf("state populated: %.0f total resources", total)
|
||||
|
||||
const (
|
||||
concurrency = 50
|
||||
duration = 2 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
latencies = make([][]time.Duration, concurrency)
|
||||
errors int64
|
||||
totalCount int64
|
||||
)
|
||||
|
||||
// Use a barrier to start all goroutines simultaneously, ensuring wall-clock
|
||||
// timing starts after goroutine launch overhead.
|
||||
var ready sync.WaitGroup
|
||||
ready.Add(concurrency)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for g := 0; g < concurrency; g++ {
|
||||
wg.Add(1)
|
||||
g := g
|
||||
latencies[g] = make([]time.Duration, 0, 200)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ready.Done()
|
||||
ready.Wait() // All goroutines wait here until everyone is launched
|
||||
deadline := time.Now().Add(duration)
|
||||
for time.Now().Before(deadline) {
|
||||
reqStart := time.Now()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/resources?limit=50", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handlers.HandleListResources(rec, req)
|
||||
elapsed := time.Since(reqStart)
|
||||
if rec.Code != http.StatusOK {
|
||||
atomic.AddInt64(&errors, 1)
|
||||
continue
|
||||
}
|
||||
latencies[g] = append(latencies[g], elapsed)
|
||||
atomic.AddInt64(&totalCount, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
ready.Wait()
|
||||
start := time.Now()
|
||||
wg.Wait()
|
||||
wallTime := time.Since(start)
|
||||
|
||||
if errors > 0 {
|
||||
t.Errorf("got %d error responses", errors)
|
||||
}
|
||||
|
||||
all := mergeLatencies(latencies)
|
||||
if len(all) == 0 {
|
||||
t.Fatal("no successful requests recorded")
|
||||
}
|
||||
|
||||
p50 := percentile(all, 0.50)
|
||||
p95 := percentile(all, 0.95)
|
||||
p99 := percentile(all, 0.99)
|
||||
rps := float64(totalCount) / wallTime.Seconds()
|
||||
|
||||
t.Logf("resources 500-node load: %d requests in %v (%.1f rps)", totalCount, wallTime, rps)
|
||||
t.Logf(" p50=%v p95=%v p99=%v", p50, p95, p99)
|
||||
|
||||
// At 500-node scale (3000 resources) with 50 concurrent goroutines, the
|
||||
// resource handler processes snapshot comparison, filtering, sorting,
|
||||
// pagination, and JSON serialization under mutex contention. The p95 budget
|
||||
// accounts for this in-process overhead — it catches gross regressions
|
||||
// (e.g., O(n²) algorithms, lock contention bugs) without being flaky. The
|
||||
// hosted-runner budget reflects the April 9, 2026 RC dry run (~3.23s p95).
|
||||
target := effectiveLoadP95Budget("resources", 3*time.Second)
|
||||
if p95 > target {
|
||||
failOrSkipLoadOverrun(t, "p95 latency %v exceeds %v budget for 500-node concurrent resources load", p95, target)
|
||||
}
|
||||
// Use completed request count rather than wall-clock RPS so tail-overrun
|
||||
// doesn't get double-counted by both the latency budget and a throughput
|
||||
// floor. The hosted-runner floor was recalibrated on 2026-05-11 down from
|
||||
// 65 to 40 after the post-rc.4 operator-state, agent-substrate context,
|
||||
// and action-audit plumbing added enough per-request CPU on the 2-core
|
||||
// GitHub runner to push consistent throughput to 50-55 requests in the
|
||||
// 2s test window (p95 latency stays inside the 4s envelope). Local
|
||||
// hardware (more cores) still sees 300+ requests; the local floor of 100
|
||||
// is unchanged. The test still catches gross regressions (e.g. drops to
|
||||
// single-digit rps from algorithmic blowup) while accommodating the
|
||||
// observed CI ceiling.
|
||||
minCount := effectiveLoadMinCount(100, 40)
|
||||
if totalCount < minCount {
|
||||
failOrSkipLoadOverrun(t, "completed only %d requests, expected at least %d for 500-node concurrent resources load", totalCount, minCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad_500Node_ConcurrentMetricsHistory validates that /api/metrics-store/history
|
||||
// sustains acceptable latency when 50 concurrent goroutines query chart data for
|
||||
// resources across a 500-node deployment.
|
||||
|
||||
@@ -205,9 +205,7 @@ func TestPatrolRefreshRebindsCurrentPolicyAndTrustedOrigin(t *testing.T) {
|
||||
snapshot: models.StateSnapshot{LastUpdate: changed.UpdatedAt},
|
||||
resources: []unified.Resource{changed},
|
||||
})
|
||||
h.cacheMu.Lock()
|
||||
h.registryCache = make(map[string]registryCacheEntry)
|
||||
h.cacheMu.Unlock()
|
||||
h.invalidateCache("default")
|
||||
policyVersion = "tenant-v2"
|
||||
h.SetActionRefreshPlanner(NewActionRefreshPlanner(h, policy))
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/chartapi"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
||||
@@ -22,7 +23,7 @@ import (
|
||||
// - restoreAgentExecMetadata (agent_exec_token_binding.go)
|
||||
// - buildAlertConnectionSnapshotsWithRuntimeSources (connections_alerts.go)
|
||||
// - mockProtectionPostures (recovery_handlers.go)
|
||||
// - buildMockWorkloadMetricHistorySeries (router.go)
|
||||
// - chartapi.BuildMockWorkloadMetricHistorySeries (router.go)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// restoreAgentExecMetadata
|
||||
@@ -394,7 +395,7 @@ func TestBranchcov0724pmMockProtectionPostures(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildMockWorkloadMetricHistorySeries
|
||||
// chartapi.BuildMockWorkloadMetricHistorySeries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBranchcov0724pmBuildMockWorkloadMetricHistorySeries(t *testing.T) {
|
||||
@@ -402,7 +403,7 @@ func TestBranchcov0724pmBuildMockWorkloadMetricHistorySeries(t *testing.T) {
|
||||
|
||||
t.Run("unsupported_metric_type_returns_nil", func(t *testing.T) {
|
||||
for _, metricType := range []string{"smart_temp", "bogus", ""} {
|
||||
got := buildMockWorkloadMetricHistorySeries(now, time.Hour, 100, "vm", "res-1", metricType, 50.0)
|
||||
got := chartapi.BuildMockWorkloadMetricHistorySeries(now, time.Hour, 100, "vm", "res-1", metricType, 50.0)
|
||||
if got != nil {
|
||||
t.Fatalf("metricType %q: expected nil, got %d points", metricType, len(got))
|
||||
}
|
||||
@@ -413,11 +414,11 @@ func TestBranchcov0724pmBuildMockWorkloadMetricHistorySeries(t *testing.T) {
|
||||
supported := []string{"cpu", "memory", "disk", "diskread", "diskwrite", "netin", "netout"}
|
||||
duration := 2 * time.Hour
|
||||
maxPoints := 0
|
||||
expectedLen := targetMockSeriesPoints(duration, maxPoints)
|
||||
expectedLen := chartapi.TargetMockSeriesPoints(duration, maxPoints)
|
||||
|
||||
for _, metricType := range supported {
|
||||
t.Run(metricType, func(t *testing.T) {
|
||||
got := buildMockWorkloadMetricHistorySeries(now, duration, maxPoints, "vm", "res-1", metricType, 50.0)
|
||||
got := chartapi.BuildMockWorkloadMetricHistorySeries(now, duration, maxPoints, "vm", "res-1", metricType, 50.0)
|
||||
if len(got) != expectedLen {
|
||||
t.Fatalf("expected %d points, got %d", expectedLen, len(got))
|
||||
}
|
||||
@@ -444,23 +445,23 @@ func TestBranchcov0724pmBuildMockWorkloadMetricHistorySeries(t *testing.T) {
|
||||
t.Run("max_points_caps_series_length", func(t *testing.T) {
|
||||
duration := 24 * time.Hour
|
||||
maxPoints := 50
|
||||
got := buildMockWorkloadMetricHistorySeries(now, duration, maxPoints, "vm", "res-1", "cpu", 50.0)
|
||||
got := chartapi.BuildMockWorkloadMetricHistorySeries(now, duration, maxPoints, "vm", "res-1", "cpu", 50.0)
|
||||
if len(got) != maxPoints {
|
||||
t.Fatalf("expected %d points (capped by maxPoints), got %d", maxPoints, len(got))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deterministic_for_same_inputs", func(t *testing.T) {
|
||||
first := buildMockWorkloadMetricHistorySeries(now, 2*time.Hour, 0, "vm", "res-1", "cpu", 50.0)
|
||||
second := buildMockWorkloadMetricHistorySeries(now, 2*time.Hour, 0, "vm", "res-1", "cpu", 50.0)
|
||||
first := chartapi.BuildMockWorkloadMetricHistorySeries(now, 2*time.Hour, 0, "vm", "res-1", "cpu", 50.0)
|
||||
second := chartapi.BuildMockWorkloadMetricHistorySeries(now, 2*time.Hour, 0, "vm", "res-1", "cpu", 50.0)
|
||||
if !reflect.DeepEqual(first, second) {
|
||||
t.Fatalf("series not deterministic for identical inputs")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different_resource_ids_produce_different_series", func(t *testing.T) {
|
||||
a := buildMockWorkloadMetricHistorySeries(now, 2*time.Hour, 0, "vm", "res-1", "cpu", 50.0)
|
||||
b := buildMockWorkloadMetricHistorySeries(now, 2*time.Hour, 0, "vm", "res-2", "cpu", 50.0)
|
||||
a := chartapi.BuildMockWorkloadMetricHistorySeries(now, 2*time.Hour, 0, "vm", "res-1", "cpu", 50.0)
|
||||
b := chartapi.BuildMockWorkloadMetricHistorySeries(now, 2*time.Hour, 0, "vm", "res-2", "cpu", 50.0)
|
||||
if reflect.DeepEqual(a, b) {
|
||||
t.Fatalf("expected different series for different resource IDs")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
// These fixtures remain in the API integration-test package because action,
|
||||
// agent-context, and router tests compose the public ResourceHandlers facade.
|
||||
type resourceUnifiedSeedProvider struct {
|
||||
snapshot models.StateSnapshot
|
||||
resources []unified.Resource
|
||||
}
|
||||
|
||||
type resourceStateProvider struct {
|
||||
snapshot models.StateSnapshot
|
||||
}
|
||||
|
||||
func (p resourceStateProvider) ReadSnapshot() models.StateSnapshot { return p.snapshot }
|
||||
|
||||
type tenantResourceStateProvider struct {
|
||||
snapshots map[string]models.StateSnapshot
|
||||
}
|
||||
|
||||
func (p tenantResourceStateProvider) GetStateForTenant(orgID string) models.StateSnapshot {
|
||||
return p.snapshots[orgID]
|
||||
}
|
||||
|
||||
func (p tenantResourceStateProvider) UnifiedReadStateForTenant(orgID string) unified.ReadState {
|
||||
return SnapshotReadState(p.GetStateForTenant(orgID))
|
||||
}
|
||||
|
||||
func (p tenantResourceStateProvider) UnifiedResourceSnapshotForTenant(orgID string) ([]unified.Resource, time.Time) {
|
||||
snapshot := p.GetStateForTenant(orgID)
|
||||
if snapshot.LastUpdate.IsZero() {
|
||||
return nil, time.Time{}
|
||||
}
|
||||
return []unified.Resource{{
|
||||
ID: "agent-tenant-seeded", Type: unified.ResourceTypeAgent, Name: "tenant-seeded",
|
||||
Status: unified.StatusOnline, LastSeen: snapshot.LastUpdate, UpdatedAt: snapshot.LastUpdate,
|
||||
Sources: []unified.DataSource{unified.SourceAgent},
|
||||
Identity: unified.ResourceIdentity{Hostnames: []string{"tenant-seeded"}},
|
||||
}}, snapshot.LastUpdate
|
||||
}
|
||||
|
||||
func (p resourceUnifiedSeedProvider) ReadSnapshot() models.StateSnapshot { return p.snapshot }
|
||||
|
||||
func (p resourceUnifiedSeedProvider) UnifiedResourceSnapshot() ([]unified.Resource, time.Time) {
|
||||
return append([]unified.Resource(nil), p.resources...), p.snapshot.LastUpdate
|
||||
}
|
||||
|
||||
type mutableResourceUnifiedSeedProvider struct {
|
||||
snapshot models.StateSnapshot
|
||||
resources []unified.Resource
|
||||
freshness time.Time
|
||||
}
|
||||
|
||||
func (p *mutableResourceUnifiedSeedProvider) ReadSnapshot() models.StateSnapshot { return p.snapshot }
|
||||
|
||||
func (p *mutableResourceUnifiedSeedProvider) UnifiedResourceSnapshot() ([]unified.Resource, time.Time) {
|
||||
return append([]unified.Resource(nil), p.resources...), p.freshness
|
||||
}
|
||||
+5
-4
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package resourceapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/apicontext"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
@@ -41,7 +42,7 @@ func (r k8sNamespacesResponse) normalizeCollections() k8sNamespacesResponse {
|
||||
|
||||
// HandleK8sNamespaces handles GET /api/resources/k8s/namespaces?cluster=<clusterName>
|
||||
// and returns namespace-level counts for Pods and Deployments.
|
||||
func (h *ResourceHandlers) HandleK8sNamespaces(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *QueryService) HandleK8sNamespaces(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
@@ -53,10 +54,10 @@ func (h *ResourceHandlers) HandleK8sNamespaces(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
orgID := GetOrgID(r.Context())
|
||||
orgID := apicontext.OrgID(r.Context())
|
||||
resources, _, err := h.sharedRawResources(orgID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package resourceapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
)
|
||||
|
||||
// TestLoad_500Node_ConcurrentResources validates the resource query service
|
||||
// with 500 nodes and 2,500 VMs under 50-way concurrent request load.
|
||||
func TestLoad_500Node_ConcurrentResources(t *testing.T) {
|
||||
if raceEnabled {
|
||||
t.Skip("skipping latency test under -race")
|
||||
}
|
||||
|
||||
state := buildResourceLoadState(t, 500)
|
||||
handlers := NewQueryService(&config.Config{DataPath: t.TempDir()})
|
||||
handlers.SetStateProvider(&resourceLoadStateProvider{state: state})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/resources", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handlers.HandleListResources(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("warmup failed: status %d, body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var warmupResp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &warmupResp); err != nil {
|
||||
t.Fatalf("warmup unmarshal: %v", err)
|
||||
}
|
||||
meta, _ := warmupResp["meta"].(map[string]interface{})
|
||||
total, _ := meta["total"].(float64)
|
||||
if int(total) != 3000 {
|
||||
t.Fatalf("warmup: expected 3000 total resources, got %v", total)
|
||||
}
|
||||
|
||||
const concurrency = 50
|
||||
const duration = 2 * time.Second
|
||||
latencies := make([][]time.Duration, concurrency)
|
||||
var errors, totalCount int64
|
||||
var ready, workers sync.WaitGroup
|
||||
ready.Add(concurrency)
|
||||
|
||||
for index := 0; index < concurrency; index++ {
|
||||
workers.Add(1)
|
||||
index := index
|
||||
latencies[index] = make([]time.Duration, 0, 200)
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
ready.Done()
|
||||
ready.Wait()
|
||||
deadline := time.Now().Add(duration)
|
||||
for time.Now().Before(deadline) {
|
||||
started := time.Now()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/resources?limit=50", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handlers.HandleListResources(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
atomic.AddInt64(&errors, 1)
|
||||
continue
|
||||
}
|
||||
latencies[index] = append(latencies[index], time.Since(started))
|
||||
atomic.AddInt64(&totalCount, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
ready.Wait()
|
||||
started := time.Now()
|
||||
workers.Wait()
|
||||
wallTime := time.Since(started)
|
||||
if errors > 0 {
|
||||
t.Errorf("got %d error responses", errors)
|
||||
}
|
||||
|
||||
all := mergeResourceLoadLatencies(latencies)
|
||||
if len(all) == 0 {
|
||||
t.Fatal("no successful requests recorded")
|
||||
}
|
||||
p50 := resourceLoadPercentile(all, 0.50)
|
||||
p95 := resourceLoadPercentile(all, 0.95)
|
||||
p99 := resourceLoadPercentile(all, 0.99)
|
||||
t.Logf("resources 500-node load: %d requests in %v (%.1f rps)", totalCount, wallTime, float64(totalCount)/wallTime.Seconds())
|
||||
t.Logf("p50=%v p95=%v p99=%v", p50, p95, p99)
|
||||
|
||||
target := 3 * time.Second
|
||||
if os.Getenv("GITHUB_ACTIONS") == "true" {
|
||||
target = 4 * time.Second
|
||||
}
|
||||
if p95 > target {
|
||||
resourceLoadOverrun(t, "p95 latency %v exceeds %v budget for 500-node concurrent resources load", p95, target)
|
||||
}
|
||||
minimum := int64(100)
|
||||
if os.Getenv("GITHUB_ACTIONS") == "true" {
|
||||
minimum = 40
|
||||
}
|
||||
if totalCount < minimum {
|
||||
resourceLoadOverrun(t, "completed only %d requests, expected at least %d for 500-node concurrent resources load", totalCount, minimum)
|
||||
}
|
||||
}
|
||||
|
||||
func buildResourceLoadState(t *testing.T, numNodes int) *models.State {
|
||||
t.Helper()
|
||||
state := models.NewState()
|
||||
const numInstances = 10
|
||||
basePerInstance, remainder := numNodes/numInstances, numNodes%numInstances
|
||||
nodesSoFar := 0
|
||||
for instance := 0; instance < numInstances; instance++ {
|
||||
instanceName := fmt.Sprintf("pve%d", instance)
|
||||
count := basePerInstance
|
||||
if instance < remainder {
|
||||
count++
|
||||
}
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
nodes := make([]models.Node, count)
|
||||
for node := range nodes {
|
||||
globalIndex := nodesSoFar + node
|
||||
nodes[node] = models.Node{
|
||||
ID: fmt.Sprintf("%s:node%d", instanceName, node), Name: fmt.Sprintf("node-%d", globalIndex), Instance: instanceName,
|
||||
Status: "online", CPU: float64(globalIndex%80+10) / 100,
|
||||
Memory: models.Memory{Usage: float64(globalIndex%60 + 20), Total: 64 << 30, Used: 32 << 30},
|
||||
Disk: models.Disk{Usage: float64(globalIndex%40 + 30), Total: 500 << 30, Used: 250 << 30},
|
||||
}
|
||||
}
|
||||
state.UpdateNodesForInstance(instanceName, nodes)
|
||||
vms := make([]models.VM, count*5)
|
||||
for vm := range vms {
|
||||
nodeIndex := vm / 5
|
||||
globalIndex := (nodesSoFar+nodeIndex)*5 + vm%5
|
||||
vms[vm] = models.VM{
|
||||
ID: fmt.Sprintf("%s:node%d:%d", instanceName, nodeIndex, 1000+globalIndex), VMID: 1000 + globalIndex,
|
||||
Name: fmt.Sprintf("vm-%d", globalIndex), Node: fmt.Sprintf("node%d", nodeIndex), Instance: instanceName,
|
||||
Status: "running", Type: "qemu", CPU: float64(globalIndex%80+10) / 100,
|
||||
Memory: models.Memory{Usage: float64(globalIndex%60 + 20), Total: 4 << 30, Used: 2 << 30},
|
||||
Disk: models.Disk{Usage: float64(globalIndex%40 + 30), Total: 50 << 30, Used: 25 << 30},
|
||||
}
|
||||
}
|
||||
state.UpdateVMsForInstance(instanceName, vms)
|
||||
nodesSoFar += count
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func resourceLoadOverrun(t *testing.T, format string, args ...interface{}) {
|
||||
t.Helper()
|
||||
if os.Getenv("GITHUB_ACTIONS") == "true" {
|
||||
t.Errorf(format, args...)
|
||||
return
|
||||
}
|
||||
t.Skipf("%s (host CPU contention can cause this locally; CI enforces the budget)", fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func mergeResourceLoadLatencies(groups [][]time.Duration) []time.Duration {
|
||||
var merged []time.Duration
|
||||
for _, group := range groups {
|
||||
merged = append(merged, group...)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func resourceLoadPercentile(durations []time.Duration, percentile float64) time.Duration {
|
||||
sorted := append([]time.Duration(nil), durations...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
|
||||
return sorted[int(float64(len(sorted)-1)*percentile)]
|
||||
}
|
||||
|
||||
type resourceLoadStateProvider struct{ state *models.State }
|
||||
|
||||
func (p *resourceLoadStateProvider) ReadSnapshot() models.StateSnapshot { return p.state.GetSnapshot() }
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !race
|
||||
|
||||
package resourceapi
|
||||
|
||||
const raceEnabled = false
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build race
|
||||
|
||||
package resourceapi
|
||||
|
||||
const raceEnabled = true
|
||||
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package resourceapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/apicontext"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/storagehealth"
|
||||
@@ -22,33 +23,35 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// ResourceHandlers provides HTTP handlers for the unified resource API.
|
||||
type ResourceHandlers struct {
|
||||
cfg *config.Config
|
||||
storeMu sync.Mutex
|
||||
stores map[string]unified.ResourceStore
|
||||
cacheMu sync.Mutex
|
||||
registryCache map[string]registryCacheEntry
|
||||
supplementalMu sync.RWMutex
|
||||
supplementalRecords map[unified.DataSource]SupplementalRecordsProvider
|
||||
stateProvider SnapshotProvider
|
||||
tenantStateProvider TenantStateProvider
|
||||
actionExecutor ActionExecutor
|
||||
actionCompleted func(unified.ActionAuditRecord)
|
||||
actionTransition func(orgID string, record unified.ActionAuditRecord)
|
||||
policyAdmission *actionlifecycle.PolicyAdmissionCoordinator
|
||||
actionEmergencyStop func(orgID string) (bool, error)
|
||||
actionDecisionAuthorizer actionlifecycle.DecisionAuthorizer
|
||||
actionExecutionAuthorizer actionlifecycle.ExecutionAuthorizer
|
||||
actionRefreshPlanner actionlifecycle.RefreshPlanner
|
||||
discoveryReadiness ResourceDiscoveryReadinessProvider
|
||||
operatorStateChanged func(orgID, resourceID string)
|
||||
// QueryService provides HTTP handlers for the unified resource API.
|
||||
type QueryService struct {
|
||||
cfg *config.Config
|
||||
storeMu sync.Mutex
|
||||
stores map[string]unified.ResourceStore
|
||||
cacheMu sync.Mutex
|
||||
registryCache map[string]registryCacheEntry
|
||||
supplementalMu sync.RWMutex
|
||||
supplementalRecords map[unified.DataSource]SupplementalRecordsProvider
|
||||
stateProvider SnapshotProvider
|
||||
tenantStateProvider TenantStateProvider
|
||||
actionAvailability actionlifecycle.AvailabilityChecker
|
||||
discoveryReadiness ResourceDiscoveryReadinessProvider
|
||||
}
|
||||
|
||||
// SetOperatorStateChanged installs the runtime reconciliation hook invoked
|
||||
// after a canonical operator-state mutation commits successfully.
|
||||
func (h *ResourceHandlers) SetOperatorStateChanged(callback func(orgID, resourceID string)) {
|
||||
h.operatorStateChanged = callback
|
||||
// SnapshotProvider supplies the legacy monitoring snapshot used while callers
|
||||
// migrate to the canonical unified-resource snapshot contract.
|
||||
type SnapshotProvider = models.SnapshotProvider
|
||||
|
||||
// TenantStateProvider is the tenant-scoped resource read contract.
|
||||
type TenantStateProvider interface {
|
||||
UnifiedReadStateForTenant(orgID string) unified.ReadState
|
||||
UnifiedResourceSnapshotForTenant(orgID string) ([]unified.Resource, time.Time)
|
||||
}
|
||||
|
||||
// UnifiedResourceSnapshotProvider supplies a canonical unified-resource seed
|
||||
// and freshness marker for the default tenant.
|
||||
type UnifiedResourceSnapshotProvider interface {
|
||||
UnifiedResourceSnapshot() ([]unified.Resource, time.Time)
|
||||
}
|
||||
|
||||
// ResourceDiscoveryReadinessProvider projects service-discovery state onto a
|
||||
@@ -92,72 +95,41 @@ type TenantSupplementalSnapshotSourceOwner interface {
|
||||
SnapshotOwnedSourcesForOrg(orgID string) []unified.DataSource
|
||||
}
|
||||
|
||||
// NewResourceHandlers creates a new ResourceHandlers.
|
||||
func NewResourceHandlers(cfg *config.Config) *ResourceHandlers {
|
||||
return &ResourceHandlers{
|
||||
// NewQueryService creates a unified resource query and storage service.
|
||||
func NewQueryService(cfg *config.Config) *QueryService {
|
||||
return &QueryService{
|
||||
cfg: cfg,
|
||||
stores: make(map[string]unified.ResourceStore),
|
||||
registryCache: make(map[string]registryCacheEntry),
|
||||
supplementalRecords: make(map[unified.DataSource]SupplementalRecordsProvider),
|
||||
policyAdmission: &actionlifecycle.PolicyAdmissionCoordinator{},
|
||||
}
|
||||
}
|
||||
|
||||
// SetStateProvider sets the state provider for on-demand population.
|
||||
func (h *ResourceHandlers) SetStateProvider(provider SnapshotProvider) {
|
||||
func (h *QueryService) SetStateProvider(provider SnapshotProvider) {
|
||||
h.stateProvider = provider
|
||||
}
|
||||
|
||||
// SetTenantStateProvider sets the tenant-aware provider.
|
||||
func (h *ResourceHandlers) SetTenantStateProvider(provider TenantStateProvider) {
|
||||
func (h *QueryService) SetTenantStateProvider(provider TenantStateProvider) {
|
||||
h.tenantStateProvider = provider
|
||||
}
|
||||
|
||||
// SetActionExecutor configures the API-owned action execution driver.
|
||||
func (h *ResourceHandlers) SetActionExecutor(executor ActionExecutor) {
|
||||
h.actionExecutor = executor
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) SetActionEmergencyStopChecker(checker func(orgID string) (bool, error)) {
|
||||
h.actionEmergencyStop = checker
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) SetActionAuthorizers(decision actionlifecycle.DecisionAuthorizer, execution actionlifecycle.ExecutionAuthorizer) {
|
||||
h.actionDecisionAuthorizer = decision
|
||||
h.actionExecutionAuthorizer = execution
|
||||
}
|
||||
|
||||
// SetActionRefreshPlanner installs the trusted reconstruction hook used when
|
||||
// a broker-originated immutable plan must be replaced.
|
||||
func (h *ResourceHandlers) SetActionRefreshPlanner(planner actionlifecycle.RefreshPlanner) {
|
||||
h.actionRefreshPlanner = planner
|
||||
}
|
||||
|
||||
// SetActionCompletedPublisher configures the terminal action notification hook
|
||||
// used by the agent SSE bridge. It is intentionally outside the execution
|
||||
// driver so refused-before-dispatch failures and future executor
|
||||
// implementations all publish from the API-owned lifecycle boundary.
|
||||
func (h *ResourceHandlers) SetActionCompletedPublisher(publisher func(unified.ActionAuditRecord)) {
|
||||
h.actionCompleted = publisher
|
||||
}
|
||||
|
||||
// SetActionTransitionPublisher configures the persisted-state transition
|
||||
// hook on the shared action lifecycle: plan creation, approval decisions,
|
||||
// and terminal execution outcomes, published only after the corresponding
|
||||
// store write succeeds. The org ID keys per-tenant reconciliation (e.g.
|
||||
// mapping a Patrol-origin action's decision back onto its finding).
|
||||
func (h *ResourceHandlers) SetActionTransitionPublisher(publisher func(orgID string, record unified.ActionAuditRecord)) {
|
||||
h.actionTransition = publisher
|
||||
// SetActionAvailabilityChecker configures the read-only projection of action
|
||||
// readiness onto resource responses. Mutation lifecycle ownership remains in
|
||||
// the API composition layer.
|
||||
func (h *QueryService) SetActionAvailabilityChecker(checker actionlifecycle.AvailabilityChecker) {
|
||||
h.actionAvailability = checker
|
||||
}
|
||||
|
||||
// SetDiscoveryReadinessProvider wires the canonical discovery-readiness
|
||||
// projection onto resource responses and Assistant context packs.
|
||||
func (h *ResourceHandlers) SetDiscoveryReadinessProvider(provider ResourceDiscoveryReadinessProvider) {
|
||||
func (h *QueryService) SetDiscoveryReadinessProvider(provider ResourceDiscoveryReadinessProvider) {
|
||||
h.discoveryReadiness = provider
|
||||
}
|
||||
|
||||
// SetSupplementalRecordsProvider configures additional records for a source.
|
||||
func (h *ResourceHandlers) SetSupplementalRecordsProvider(source unified.DataSource, provider SupplementalRecordsProvider) {
|
||||
func (h *QueryService) SetSupplementalRecordsProvider(source unified.DataSource, provider SupplementalRecordsProvider) {
|
||||
h.supplementalMu.Lock()
|
||||
if h.supplementalRecords == nil {
|
||||
h.supplementalRecords = make(map[unified.DataSource]SupplementalRecordsProvider)
|
||||
@@ -176,16 +148,16 @@ func (h *ResourceHandlers) SetSupplementalRecordsProvider(source unified.DataSou
|
||||
}
|
||||
|
||||
// HandleListResources handles GET /api/resources.
|
||||
func (h *ResourceHandlers) HandleListResources(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *QueryService) HandleListResources(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := GetOrgID(r.Context())
|
||||
orgID := apicontext.OrgID(r.Context())
|
||||
sharedResources, registry, err := h.sharedPresentationResources(orgID)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -227,16 +199,16 @@ func (h *ResourceHandlers) HandleListResources(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
// HandleStorageSummary handles GET /api/resources/storage-summary.
|
||||
func (h *ResourceHandlers) HandleStorageSummary(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *QueryService) HandleStorageSummary(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := GetOrgID(r.Context())
|
||||
orgID := apicontext.OrgID(r.Context())
|
||||
resources, _, err := h.sharedRawResources(orgID)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -257,16 +229,16 @@ func (h *ResourceHandlers) HandleStorageSummary(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
|
||||
// HandleStorageIncidents handles GET /api/resources/storage-incidents.
|
||||
func (h *ResourceHandlers) HandleStorageIncidents(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *QueryService) HandleStorageIncidents(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := GetOrgID(r.Context())
|
||||
orgID := apicontext.OrgID(r.Context())
|
||||
resources, _, err := h.sharedRawResources(orgID)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -313,16 +285,16 @@ func pruneResourceForListResponse(resource *unified.Resource) {
|
||||
}
|
||||
|
||||
// HandleGetResource handles GET /api/resources/{id}.
|
||||
func (h *ResourceHandlers) HandleGetResource(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *QueryService) HandleGetResource(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := GetOrgID(r.Context())
|
||||
orgID := apicontext.OrgID(r.Context())
|
||||
registry, err := h.buildRegistry(orgID)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -354,9 +326,9 @@ func (h *ResourceHandlers) HandleGetResource(w http.ResponseWriter, r *http.Requ
|
||||
json.NewEncoder(w).Encode(resourceCopy)
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) applyActionAvailability(ctx context.Context, resources []unified.Resource) {
|
||||
checker, ok := h.actionExecutor.(ActionAvailabilityChecker)
|
||||
if !ok || checker == nil {
|
||||
func (h *QueryService) applyActionAvailability(ctx context.Context, resources []unified.Resource) {
|
||||
checker := h.actionAvailability
|
||||
if checker == nil {
|
||||
return
|
||||
}
|
||||
for i := range resources {
|
||||
@@ -404,6 +376,12 @@ func presentationResourceByID(registry *unified.ResourceRegistry, resourceID str
|
||||
return resource, ok
|
||||
}
|
||||
|
||||
// PresentationResourceByID resolves a resource through the canonical
|
||||
// presentation identity map used by list and detail handlers.
|
||||
func PresentationResourceByID(registry *unified.ResourceRegistry, resourceID string) (*unified.Resource, bool) {
|
||||
return presentationResourceByID(registry, resourceID)
|
||||
}
|
||||
|
||||
func presentationResourceByReference(registry *unified.ResourceRegistry, ref string) (*unified.Resource, string, bool) {
|
||||
ref = unified.CanonicalResourceID(ref)
|
||||
if ref == "" || registry == nil {
|
||||
@@ -424,6 +402,12 @@ func presentationResourceByReference(registry *unified.ResourceRegistry, ref str
|
||||
return rawResource, resolvedID, true
|
||||
}
|
||||
|
||||
// PresentationResourceByReference resolves canonical IDs and accepted aliases
|
||||
// through the same presentation identity map as the resource API.
|
||||
func PresentationResourceByReference(registry *unified.ResourceRegistry, ref string) (*unified.Resource, string, bool) {
|
||||
return presentationResourceByReference(registry, ref)
|
||||
}
|
||||
|
||||
type resourceFacetCountsResponse = unified.ResourceFacetCounts
|
||||
|
||||
type resourceFacetBundleResponse struct {
|
||||
@@ -472,7 +456,7 @@ func parseResourceTimelineQuery(r *http.Request, defaultLimit int, includeRelate
|
||||
}
|
||||
|
||||
// HandleResourceRoutes dispatches nested resource routes.
|
||||
func (h *ResourceHandlers) HandleResourceRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *QueryService) HandleResourceRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.TrimSuffix(r.URL.Path, "/") == "/api/resources/timeline" {
|
||||
h.HandleListResourceTimeline(w, r)
|
||||
return
|
||||
@@ -509,21 +493,21 @@ func (h *ResourceHandlers) HandleResourceRoutes(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
|
||||
// HandleGetResourceFacets handles GET /api/resources/{id}/facets.
|
||||
func (h *ResourceHandlers) HandleGetResourceFacets(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *QueryService) HandleGetResourceFacets(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := GetOrgID(r.Context())
|
||||
orgID := apicontext.OrgID(r.Context())
|
||||
registry, err := h.buildRegistry(orgID)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
store, err := h.getStore(orgID)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -553,27 +537,27 @@ func (h *ResourceHandlers) HandleGetResourceFacets(w http.ResponseWriter, r *htt
|
||||
|
||||
recentChanges, err := store.GetRecentChangesFiltered(resourceID, since, limit, filters)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
changeCount, err := store.CountRecentChangesFiltered(resourceID, since, filters)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
changeKindCounts, err := store.CountRecentChangesByKindFiltered(resourceID, since, filters)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sourceTypeCounts, err := store.CountRecentChangesBySourceTypeFiltered(resourceID, since, filters)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sourceAdapterCounts, err := store.CountRecentChangesBySourceAdapterFiltered(resourceID, since, filters)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -593,16 +577,16 @@ func (h *ResourceHandlers) HandleGetResourceFacets(w http.ResponseWriter, r *htt
|
||||
}
|
||||
|
||||
// HandleGetChildren handles GET /api/resources/{id}/children.
|
||||
func (h *ResourceHandlers) HandleGetChildren(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *QueryService) HandleGetChildren(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := GetOrgID(r.Context())
|
||||
orgID := apicontext.OrgID(r.Context())
|
||||
registry, err := h.buildRegistry(orgID)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -628,16 +612,16 @@ func (h *ResourceHandlers) HandleGetChildren(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
// HandleGetMetrics handles GET /api/resources/{id}/metrics.
|
||||
func (h *ResourceHandlers) HandleGetMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *QueryService) HandleGetMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := GetOrgID(r.Context())
|
||||
orgID := apicontext.OrgID(r.Context())
|
||||
registry, err := h.buildRegistry(orgID)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -661,16 +645,16 @@ func (h *ResourceHandlers) HandleGetMetrics(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
// HandleListResourceTimeline handles GET /api/resources/timeline.
|
||||
func (h *ResourceHandlers) HandleListResourceTimeline(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *QueryService) HandleListResourceTimeline(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := GetOrgID(r.Context())
|
||||
orgID := apicontext.OrgID(r.Context())
|
||||
store, err := h.getStore(orgID)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -686,12 +670,12 @@ func (h *ResourceHandlers) HandleListResourceTimeline(w http.ResponseWriter, r *
|
||||
|
||||
changes, err := store.GetRecentChangesFiltered(resourceID, since, limit, filters)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
changeCount, err := store.CountRecentChangesFiltered(resourceID, since, filters)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -704,16 +688,16 @@ func (h *ResourceHandlers) HandleListResourceTimeline(w http.ResponseWriter, r *
|
||||
}
|
||||
|
||||
// HandleGetResourceTimeline handles GET /api/resources/{id}/timeline.
|
||||
func (h *ResourceHandlers) HandleGetResourceTimeline(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *QueryService) HandleGetResourceTimeline(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := GetOrgID(r.Context())
|
||||
orgID := apicontext.OrgID(r.Context())
|
||||
store, err := h.getStore(orgID)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -737,12 +721,12 @@ func (h *ResourceHandlers) HandleGetResourceTimeline(w http.ResponseWriter, r *h
|
||||
|
||||
changes, err := store.GetRecentChangesFiltered(resourceID, since, limit, filters)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
changeCount, err := store.CountRecentChangesFiltered(resourceID, since, filters)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -755,16 +739,16 @@ func (h *ResourceHandlers) HandleGetResourceTimeline(w http.ResponseWriter, r *h
|
||||
}
|
||||
|
||||
// HandleStats handles GET /api/resources/stats.
|
||||
func (h *ResourceHandlers) HandleStats(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *QueryService) HandleStats(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := GetOrgID(r.Context())
|
||||
orgID := apicontext.OrgID(r.Context())
|
||||
allResources, _, err := h.sharedPresentationResources(orgID)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -777,16 +761,16 @@ func (h *ResourceHandlers) HandleStats(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// HandleLink handles POST /api/resources/{id}/link.
|
||||
func (h *ResourceHandlers) HandleLink(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *QueryService) HandleLink(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := GetOrgID(r.Context())
|
||||
orgID := apicontext.OrgID(r.Context())
|
||||
store, err := h.getStore(orgID)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -823,7 +807,7 @@ func (h *ResourceHandlers) HandleLink(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := store.AddLink(link); err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.invalidateCache(orgID)
|
||||
@@ -836,16 +820,16 @@ func (h *ResourceHandlers) HandleLink(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// HandleUnlink handles POST /api/resources/{id}/unlink.
|
||||
func (h *ResourceHandlers) HandleUnlink(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *QueryService) HandleUnlink(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := GetOrgID(r.Context())
|
||||
orgID := apicontext.OrgID(r.Context())
|
||||
store, err := h.getStore(orgID)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -881,7 +865,7 @@ func (h *ResourceHandlers) HandleUnlink(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
if err := store.AddExclusion(exclusion); err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.invalidateCache(orgID)
|
||||
@@ -894,16 +878,16 @@ func (h *ResourceHandlers) HandleUnlink(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// HandleReportMerge handles POST /api/resources/{id}/report-merge.
|
||||
func (h *ResourceHandlers) HandleReportMerge(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *QueryService) HandleReportMerge(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := GetOrgID(r.Context())
|
||||
orgID := apicontext.OrgID(r.Context())
|
||||
store, err := h.getStore(orgID)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -927,7 +911,7 @@ func (h *ResourceHandlers) HandleReportMerge(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
registry, err := h.buildRegistry(orgID)
|
||||
if err != nil {
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -988,7 +972,7 @@ func (h *ResourceHandlers) HandleReportMerge(w http.ResponseWriter, r *http.Requ
|
||||
Str("resourceID", path).
|
||||
Str("candidateID", target.CandidateID).
|
||||
Msg("Failed to add resource merge exclusion")
|
||||
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
|
||||
http.Error(w, sanitizeError(err, "Internal server error"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
exclusionsAdded += 1
|
||||
@@ -1017,7 +1001,7 @@ func (h *ResourceHandlers) HandleReportMerge(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
// buildRegistry constructs a registry for the current tenant.
|
||||
func (h *ResourceHandlers) buildRegistry(orgID string) (*unified.ResourceRegistry, error) {
|
||||
func (h *QueryService) buildRegistry(orgID string) (*unified.ResourceRegistry, error) {
|
||||
store, err := h.getStore(orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1084,12 +1068,22 @@ func (h *ResourceHandlers) buildRegistry(orgID string) (*unified.ResourceRegistr
|
||||
}
|
||||
|
||||
h.cacheMu.Lock()
|
||||
if h.registryCache == nil {
|
||||
h.registryCache = make(map[string]registryCacheEntry)
|
||||
}
|
||||
h.registryCache[key] = registryCacheEntry{registry: registry, lastUpdate: seed.lastUpdate}
|
||||
h.cacheMu.Unlock()
|
||||
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
// BuildRegistry returns the canonical tenant resource registry. It is exposed
|
||||
// for API composition services that coordinate resource mutations and action
|
||||
// lifecycle decisions against the same durable store.
|
||||
func (h *QueryService) BuildRegistry(orgID string) (*unified.ResourceRegistry, error) {
|
||||
return h.buildRegistry(orgID)
|
||||
}
|
||||
|
||||
func sortedSupplementalSources(
|
||||
providers map[unified.DataSource]SupplementalRecordsProvider,
|
||||
) []unified.DataSource {
|
||||
@@ -1109,7 +1103,7 @@ func sortedSupplementalSources(
|
||||
return sources
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) registrySeed(orgID string) (registrySeed, error) {
|
||||
func (h *QueryService) registrySeed(orgID string) (registrySeed, error) {
|
||||
seed := registrySeed{}
|
||||
|
||||
if orgID != "" && orgID != "default" {
|
||||
@@ -1241,6 +1235,11 @@ func unifiedSeedSources(resources []unified.Resource) map[unified.DataSource]str
|
||||
return sources
|
||||
}
|
||||
|
||||
// UnifiedSeedSources reports source-owned records present in a canonical seed.
|
||||
func UnifiedSeedSources(resources []unified.Resource) map[unified.DataSource]struct{} {
|
||||
return unifiedSeedSources(resources)
|
||||
}
|
||||
|
||||
func unifiedSeedIncludesSource(seedSources map[unified.DataSource]struct{}, source unified.DataSource) bool {
|
||||
if len(seedSources) == 0 {
|
||||
return false
|
||||
@@ -1275,9 +1274,18 @@ func normalizeDataSourceAlias(source unified.DataSource) unified.DataSource {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) getStore(orgID string) (unified.ResourceStore, error) {
|
||||
// NormalizeDataSourceAlias canonicalizes legacy source identifiers accepted at
|
||||
// the router and query boundaries.
|
||||
func NormalizeDataSourceAlias(source unified.DataSource) unified.DataSource {
|
||||
return normalizeDataSourceAlias(source)
|
||||
}
|
||||
|
||||
func (h *QueryService) getStore(orgID string) (unified.ResourceStore, error) {
|
||||
h.storeMu.Lock()
|
||||
defer h.storeMu.Unlock()
|
||||
if h.stores == nil {
|
||||
h.stores = make(map[string]unified.ResourceStore)
|
||||
}
|
||||
key := cacheKey(orgID)
|
||||
if store, ok := h.stores[key]; ok {
|
||||
return store, nil
|
||||
@@ -1294,13 +1302,25 @@ func (h *ResourceHandlers) getStore(orgID string) (unified.ResourceStore, error)
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// InvalidateCache clears a tenant's resource registry and presentation cache
|
||||
// after an external mutation coordinated by another API domain.
|
||||
func (h *QueryService) InvalidateCache(orgID string) {
|
||||
h.invalidateCache(orgID)
|
||||
}
|
||||
|
||||
// Store returns the tenant-scoped durable resource store shared by resource
|
||||
// queries and API mutation services.
|
||||
func (h *QueryService) Store(orgID string) (unified.ResourceStore, error) {
|
||||
return h.getStore(orgID)
|
||||
}
|
||||
|
||||
// CloseTenantStore closes and evicts one org's cached resource store.
|
||||
//
|
||||
// getStore opens a SQLite handle per org and caches it for the process
|
||||
// lifetime. Without this, offboarding a tenant left its handle open: the
|
||||
// -wal and -shm files stay on disk, the file descriptors are never returned,
|
||||
// and the tenant directory cannot be fully removed.
|
||||
func (h *ResourceHandlers) CloseTenantStore(orgID string) error {
|
||||
func (h *QueryService) CloseTenantStore(orgID string) error {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -1323,7 +1343,7 @@ func (h *ResourceHandlers) CloseTenantStore(orgID string) error {
|
||||
|
||||
// CloseStores closes and evicts every cached resource store. Used on shutdown
|
||||
// so SQLite handles are released rather than left to process exit.
|
||||
func (h *ResourceHandlers) CloseStores() error {
|
||||
func (h *QueryService) CloseStores() error {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -1349,7 +1369,7 @@ func (h *ResourceHandlers) CloseStores() error {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) invalidateCache(orgID string) {
|
||||
func (h *QueryService) invalidateCache(orgID string) {
|
||||
key := cacheKey(orgID)
|
||||
h.cacheMu.Lock()
|
||||
delete(h.registryCache, key)
|
||||
@@ -1533,7 +1553,7 @@ func flatCopyResources(shared []unified.Resource) []unified.Resource {
|
||||
// sharedPresentationResources returns the org's cached presentation-shape
|
||||
// resource list for the current registry generation, building it at most
|
||||
// once per generation instead of deep-cloning the registry on every request.
|
||||
func (h *ResourceHandlers) sharedPresentationResources(orgID string) ([]unified.Resource, *unified.ResourceRegistry, error) {
|
||||
func (h *QueryService) sharedPresentationResources(orgID string) ([]unified.Resource, *unified.ResourceRegistry, error) {
|
||||
registry, err := h.buildRegistry(orgID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -1561,7 +1581,7 @@ func (h *ResourceHandlers) sharedPresentationResources(orgID string) ([]unified.
|
||||
|
||||
// sharedRawResources is sharedPresentationResources for the raw (uncoalesced)
|
||||
// registry list.
|
||||
func (h *ResourceHandlers) sharedRawResources(orgID string) ([]unified.Resource, *unified.ResourceRegistry, error) {
|
||||
func (h *QueryService) sharedRawResources(orgID string) ([]unified.Resource, *unified.ResourceRegistry, error) {
|
||||
registry, err := h.buildRegistry(orgID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -2114,6 +2134,11 @@ func parseResourceTypes(raw string) map[unified.ResourceType]struct{} {
|
||||
return result
|
||||
}
|
||||
|
||||
// ParseResourceTypes parses the public resource type filter grammar.
|
||||
func ParseResourceTypes(raw string) map[unified.ResourceType]struct{} {
|
||||
return parseResourceTypes(raw)
|
||||
}
|
||||
|
||||
func unsupportedResourceTypeFilterTokens(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
@@ -2127,6 +2152,11 @@ func unsupportedResourceTypeFilterTokens(raw string) []string {
|
||||
return unsupported
|
||||
}
|
||||
|
||||
// UnsupportedResourceTypeFilterTokens returns rejected public filter tokens.
|
||||
func UnsupportedResourceTypeFilterTokens(raw string) []string {
|
||||
return unsupportedResourceTypeFilterTokens(raw)
|
||||
}
|
||||
|
||||
func isSupportedResourceTypeFilterToken(token string) bool {
|
||||
return len(resourceTypeFilterAdapter(token)) > 0
|
||||
}
|
||||
@@ -2308,7 +2338,7 @@ func attachDiscoveryTarget(resource *unified.Resource) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) attachDiscoveryReadinesses(resources []unified.Resource, now time.Time) {
|
||||
func (h *QueryService) attachDiscoveryReadinesses(resources []unified.Resource, now time.Time) {
|
||||
if h == nil || h.discoveryReadiness == nil {
|
||||
return
|
||||
}
|
||||
@@ -2317,7 +2347,7 @@ func (h *ResourceHandlers) attachDiscoveryReadinesses(resources []unified.Resour
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) attachDiscoveryReadiness(resource *unified.Resource, now time.Time) {
|
||||
func (h *QueryService) attachDiscoveryReadiness(resource *unified.Resource, now time.Time) {
|
||||
if h == nil || h.discoveryReadiness == nil || resource == nil {
|
||||
return
|
||||
}
|
||||
@@ -2328,6 +2358,12 @@ func (h *ResourceHandlers) attachDiscoveryReadiness(resource *unified.Resource,
|
||||
resource.DiscoveryReadiness = &readiness
|
||||
}
|
||||
|
||||
// AttachDiscoveryReadiness projects current discovery state onto a resource
|
||||
// used by another API domain.
|
||||
func (h *QueryService) AttachDiscoveryReadiness(resource *unified.Resource, now time.Time) {
|
||||
h.attachDiscoveryReadiness(resource, now)
|
||||
}
|
||||
|
||||
func attachMetricsTargets(resources []unified.Resource, registry *unified.ResourceRegistry) {
|
||||
for i := range resources {
|
||||
attachMetricsTarget(&resources[i], registry)
|
||||
@@ -2347,6 +2383,16 @@ func resourceContractType(r unified.Resource) unified.ResourceType {
|
||||
return unified.ContractResourceType(r)
|
||||
}
|
||||
|
||||
// ContractType maps an internal resource shape onto the public API contract.
|
||||
func ContractType(resource unified.Resource) unified.ResourceType {
|
||||
return resourceContractType(resource)
|
||||
}
|
||||
|
||||
// AttachDiscoveryTarget projects the canonical discovery target metadata.
|
||||
func AttachDiscoveryTarget(resource *unified.Resource) {
|
||||
attachDiscoveryTarget(resource)
|
||||
}
|
||||
|
||||
// applyResourceContractTypes rewrites Type fields on the response slice so the
|
||||
// REST API exposes the canonical resource-type contract.
|
||||
func applyResourceContractTypes(resources []unified.Resource) {
|
||||
@@ -2440,6 +2486,11 @@ func cephDiscoveryTarget(resource unified.Resource) *unified.DiscoveryTarget {
|
||||
}
|
||||
}
|
||||
|
||||
// CephDiscoveryTarget constructs the canonical discovery target for a Ceph resource.
|
||||
func CephDiscoveryTarget(resource unified.Resource) *unified.DiscoveryTarget {
|
||||
return cephDiscoveryTarget(resource)
|
||||
}
|
||||
|
||||
func hostDiscoveryTarget(resource unified.Resource) *unified.DiscoveryTarget {
|
||||
linkedAgentID := ""
|
||||
agentBacked := hasSource(resource.Sources, unified.SourceAgent) || resource.Agent != nil
|
||||
@@ -2739,3 +2790,10 @@ func parseIntDefault(raw string, def int) int {
|
||||
func getUserID(r *http.Request) string {
|
||||
return auth.GetUser(r.Context())
|
||||
}
|
||||
|
||||
func sanitizeError(err error, genericMessage string) string {
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg(genericMessage)
|
||||
}
|
||||
return genericMessage
|
||||
}
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package resourceapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -371,7 +371,7 @@ func TestResourceListUsesCanonicalContractTypes(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
// Test 1: legacy ?type=node is accepted at the request boundary but resolves
|
||||
@@ -578,7 +578,7 @@ func TestResourceGetUsesCanonicalContractType(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
// First get the resource ID from a list query.
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package resourceapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
func TestResourceListPreservesCanonicalPoolHealthEvidence(t *testing.T) {
|
||||
observedAt := time.Date(2026, 7, 24, 10, 0, 0, 0, time.UTC)
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceUnifiedSeedProvider{
|
||||
snapshot: models.StateSnapshot{LastUpdate: observedAt},
|
||||
resources: []unified.Resource{{
|
||||
+4
-3
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package resourceapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/apicontext"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
@@ -35,7 +36,7 @@ func TestPruneResourceForListResponseDoesNotMutateSharedPMG(t *testing.T) {
|
||||
|
||||
func TestSharedResourceListsCachedPerGenerationAndImmuneToRequestDecoration(t *testing.T) {
|
||||
now := time.Date(2026, 8, 5, 12, 0, 0, 0, time.UTC)
|
||||
h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
|
||||
h := NewQueryService(&config.Config{DataPath: t.TempDir()})
|
||||
h.SetStateProvider(resourceUnifiedSeedProvider{
|
||||
snapshot: models.StateSnapshot{LastUpdate: now},
|
||||
resources: []unifiedresources.Resource{
|
||||
@@ -81,7 +82,7 @@ func TestSharedResourceListsCachedPerGenerationAndImmuneToRequestDecoration(t *t
|
||||
// A full list request prunes PMG detail in its response; the shared cache
|
||||
// must keep the untouched data for later consumers.
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/resources", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, orgID))
|
||||
req = req.WithContext(context.WithValue(req.Context(), apicontext.OrgIDContextKey, orgID))
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleListResources(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
+3
-29
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package resourceapi
|
||||
|
||||
import (
|
||||
"os"
|
||||
@@ -27,7 +27,7 @@ func sqliteSidecarsPresent(t *testing.T, dataDir, orgID string) bool {
|
||||
// descriptors, and its -wal/-shm files outlive the tenant.
|
||||
func TestResourceHandlers_CloseTenantStoreReleasesTheHandle(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
handlers := NewResourceHandlers(&config.Config{DataPath: dataDir})
|
||||
handlers := NewQueryService(&config.Config{DataPath: dataDir})
|
||||
t.Cleanup(func() { _ = handlers.CloseStores() })
|
||||
|
||||
if _, err := handlers.getStore("client-x"); err != nil {
|
||||
@@ -54,7 +54,7 @@ func TestResourceHandlers_CloseTenantStoreReleasesTheHandle(t *testing.T) {
|
||||
|
||||
func TestResourceHandlers_CloseStoresReleasesEveryTenant(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
handlers := NewResourceHandlers(&config.Config{DataPath: dataDir})
|
||||
handlers := NewQueryService(&config.Config{DataPath: dataDir})
|
||||
|
||||
for _, orgID := range []string{"client-a", "client-b"} {
|
||||
if _, err := handlers.getStore(orgID); err != nil {
|
||||
@@ -77,29 +77,3 @@ func TestResourceHandlers_CloseStoresReleasesEveryTenant(t *testing.T) {
|
||||
t.Fatalf("%d store(s) still cached after CloseStores", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
// Closing must be safe to repeat and safe on a nil handler: the shutdown path
|
||||
// runs before every dependency is guaranteed to be wired.
|
||||
func TestResourceHandlers_CloseIsIdempotentAndNilSafe(t *testing.T) {
|
||||
var nilHandlers *ResourceHandlers
|
||||
if err := nilHandlers.CloseStores(); err != nil {
|
||||
t.Fatalf("nil handler CloseStores: %v", err)
|
||||
}
|
||||
if err := nilHandlers.CloseTenantStore("client-a"); err != nil {
|
||||
t.Fatalf("nil handler CloseTenantStore: %v", err)
|
||||
}
|
||||
|
||||
handlers := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
||||
if _, err := handlers.getStore("client-a"); err != nil {
|
||||
t.Fatalf("getStore: %v", err)
|
||||
}
|
||||
if err := handlers.CloseStores(); err != nil {
|
||||
t.Fatalf("first CloseStores: %v", err)
|
||||
}
|
||||
if err := handlers.CloseStores(); err != nil {
|
||||
t.Fatalf("second CloseStores must be a no-op: %v", err)
|
||||
}
|
||||
if err := handlers.CloseTenantStore("client-a"); err != nil {
|
||||
t.Fatalf("CloseTenantStore on an evicted org must be a no-op: %v", err)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package resourceapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func TestResourceHandlers_GetStore_DotAndUnderscoreOrgIDsRemainIsolated(t *testing.T) {
|
||||
h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
||||
h := NewQueryService(&config.Config{DataPath: t.TempDir()})
|
||||
|
||||
dotStore, err := h.getStore("org.a")
|
||||
if err != nil {
|
||||
+13
-10
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package resourceapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/apicontext"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
@@ -19,7 +20,9 @@ type tenantResourceStateProvider struct {
|
||||
}
|
||||
|
||||
func (p tenantResourceStateProvider) UnifiedReadStateForTenant(orgID string) unified.ReadState {
|
||||
return SnapshotReadState(p.GetStateForTenant(orgID))
|
||||
registry := unified.NewRegistry(nil)
|
||||
registry.IngestSnapshot(p.GetStateForTenant(orgID))
|
||||
return unified.NewMonitorAdapter(registry)
|
||||
}
|
||||
|
||||
func (p tenantResourceStateProvider) GetStateForTenant(orgID string) models.StateSnapshot {
|
||||
@@ -53,13 +56,13 @@ func (p tenantResourceStateProvider) UnifiedResourceSnapshotForTenant(orgID stri
|
||||
|
||||
func TestResourceHandlers_NonDefaultOrgRequiresTenantStateProvider(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
||||
h := NewQueryService(&config.Config{DataPath: t.TempDir()})
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{
|
||||
Hosts: []models.Host{{ID: "host-default", Hostname: "default", Status: "online", LastSeen: now}},
|
||||
}})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/resources", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "acme"))
|
||||
req = req.WithContext(context.WithValue(req.Context(), apicontext.OrgIDContextKey, "acme"))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandleListResources(rec, req)
|
||||
@@ -74,7 +77,7 @@ func TestResourceHandlers_NonDefaultOrgRequiresTenantStateProvider(t *testing.T)
|
||||
|
||||
func TestResourceHandlers_NonDefaultOrgUsesTenantStateProvider(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
||||
h := NewQueryService(&config.Config{DataPath: t.TempDir()})
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{
|
||||
Hosts: []models.Host{{ID: "host-default", Hostname: "default", Status: "online", LastSeen: now}},
|
||||
}})
|
||||
@@ -86,7 +89,7 @@ func TestResourceHandlers_NonDefaultOrgUsesTenantStateProvider(t *testing.T) {
|
||||
}})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "acme"))
|
||||
req = req.WithContext(context.WithValue(req.Context(), apicontext.OrgIDContextKey, "acme"))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandleListResources(rec, req)
|
||||
@@ -109,13 +112,13 @@ func TestResourceHandlers_NonDefaultOrgUsesTenantStateProvider(t *testing.T) {
|
||||
|
||||
func TestResourceHandlers_NonDefaultOrgUsesTenantUnifiedSeedProvider(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
||||
h := NewQueryService(&config.Config{DataPath: t.TempDir()})
|
||||
h.SetTenantStateProvider(tenantResourceStateProvider{snapshots: map[string]models.StateSnapshot{
|
||||
"acme": {LastUpdate: now},
|
||||
}})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "acme"))
|
||||
req = req.WithContext(context.WithValue(req.Context(), apicontext.OrgIDContextKey, "acme"))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandleListResources(rec, req)
|
||||
@@ -138,7 +141,7 @@ func TestResourceHandlers_NonDefaultOrgUsesTenantUnifiedSeedProvider(t *testing.
|
||||
|
||||
func TestResourceHandlers_NonDefaultOrgDoesNotFallbackToRawSnapshotSeeding(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
||||
h := NewQueryService(&config.Config{DataPath: t.TempDir()})
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{
|
||||
Hosts: []models.Host{{ID: "host-default", Hostname: "default", Status: "online", LastSeen: now}},
|
||||
}})
|
||||
@@ -150,7 +153,7 @@ func TestResourceHandlers_NonDefaultOrgDoesNotFallbackToRawSnapshotSeeding(t *te
|
||||
}})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "acme"))
|
||||
req = req.WithContext(context.WithValue(req.Context(), apicontext.OrgIDContextKey, "acme"))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
h.HandleListResources(rec, req)
|
||||
@@ -1,4 +1,4 @@
|
||||
package api
|
||||
package resourceapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/storagehealth"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/truenas"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
authpkg "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
||||
)
|
||||
|
||||
type resourceStateProvider struct {
|
||||
@@ -64,6 +63,47 @@ type mockSupplementalRecordsProvider struct {
|
||||
ownedSources []unified.DataSource
|
||||
}
|
||||
|
||||
type ownedSupplementalProvider struct {
|
||||
source unified.DataSource
|
||||
}
|
||||
|
||||
func (p ownedSupplementalProvider) GetCurrentRecords() []unified.IngestRecord { return nil }
|
||||
|
||||
func (p ownedSupplementalProvider) SnapshotOwnedSources() []unified.DataSource {
|
||||
return []unified.DataSource{p.source}
|
||||
}
|
||||
|
||||
type staticResourceDiscoveryReadinessProvider struct {
|
||||
byResource map[string]unified.ResourceDiscoveryReadiness
|
||||
}
|
||||
|
||||
func (p staticResourceDiscoveryReadinessProvider) DiscoveryReadinessForResource(resource unified.Resource, now time.Time) unified.ResourceDiscoveryReadiness {
|
||||
if readiness, ok := p.byResource[unified.CanonicalResourceID(resource.ID)]; ok {
|
||||
if readiness.GeneratedAt.IsZero() {
|
||||
readiness.GeneratedAt = now
|
||||
}
|
||||
if resource.DiscoveryTarget != nil {
|
||||
if readiness.ResourceType == "" {
|
||||
readiness.ResourceType = resource.DiscoveryTarget.ResourceType
|
||||
}
|
||||
if readiness.TargetID == "" {
|
||||
readiness.TargetID = resource.DiscoveryTarget.AgentID
|
||||
}
|
||||
if readiness.ResourceID == "" {
|
||||
readiness.ResourceID = resource.DiscoveryTarget.ResourceID
|
||||
}
|
||||
}
|
||||
return readiness
|
||||
}
|
||||
readiness := unified.ResourceDiscoveryReadiness{State: unified.ResourceDiscoveryReadinessMissing, Source: "service-discovery", GeneratedAt: now}
|
||||
if resource.DiscoveryTarget != nil {
|
||||
readiness.ResourceType = resource.DiscoveryTarget.ResourceType
|
||||
readiness.TargetID = resource.DiscoveryTarget.AgentID
|
||||
readiness.ResourceID = resource.DiscoveryTarget.ResourceID
|
||||
}
|
||||
return readiness
|
||||
}
|
||||
|
||||
func TestResourceListRepairsPreFixAvailabilitySnapshotFromConfiguredTargets(t *testing.T) {
|
||||
now := time.Date(2026, 7, 23, 22, 0, 0, 0, time.UTC)
|
||||
hostID := "agent-core2026"
|
||||
@@ -126,7 +166,7 @@ func TestResourceListRepairsPreFixAvailabilitySnapshotFromConfiguredTargets(t *t
|
||||
}
|
||||
}
|
||||
|
||||
h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
||||
h := NewQueryService(&config.Config{DataPath: t.TempDir()})
|
||||
h.SetStateProvider(&mutableResourceUnifiedSeedProvider{
|
||||
resources: seed,
|
||||
freshness: now,
|
||||
@@ -200,7 +240,7 @@ func (m mockSupplementalRecordsProvider) SupplementalRecords(*monitoring.Monitor
|
||||
|
||||
func TestResourceListRejectsLegacyHostTypeFilter(t *testing.T) {
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{}})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -244,7 +284,7 @@ func TestResourceListMergesLinkedHost(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -293,7 +333,7 @@ func TestResourceListMergesLinkedHost(t *testing.T) {
|
||||
func TestResourceListUsesUnifiedSeedProvider(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceUnifiedSeedProvider{
|
||||
snapshot: models.StateSnapshot{LastUpdate: now},
|
||||
resources: []unified.Resource{
|
||||
@@ -381,7 +421,7 @@ func TestResourceListAttachesDiscoveryReadiness(t *testing.T) {
|
||||
now := time.Date(2026, 6, 4, 11, 0, 0, 0, time.UTC)
|
||||
observedAt := now.Add(-5 * time.Minute)
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceUnifiedSeedProvider{
|
||||
snapshot: models.StateSnapshot{LastUpdate: now},
|
||||
resources: []unified.Resource{
|
||||
@@ -441,7 +481,7 @@ func TestResourceListAttachesDiscoveryReadiness(t *testing.T) {
|
||||
func TestResourceListUsesDeterministicNameTieBreakers(t *testing.T) {
|
||||
now := time.Date(2026, 4, 11, 0, 0, 0, 0, time.UTC)
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceUnifiedSeedProvider{
|
||||
snapshot: models.StateSnapshot{LastUpdate: now},
|
||||
resources: []unified.Resource{
|
||||
@@ -503,7 +543,7 @@ func TestResourceListInvalidatesUnifiedSeedCacheOnFreshnessChange(t *testing.T)
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(provider)
|
||||
|
||||
firstRec := httptest.NewRecorder()
|
||||
@@ -582,7 +622,7 @@ func TestResourceListMergesOneSidedLinkedHostWhenHostnameCorroborates(t *testing
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -645,7 +685,7 @@ func TestResourceListDoesNotMergeOneSidedLinkedHostWithoutHostnameCorroboration(
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -713,7 +753,7 @@ func TestResourceListCollapsesClusterAndStandaloneNodeViewsByEndpoint(t *testing
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -744,7 +784,7 @@ func TestResourceListCollapsesClusterAndStandaloneNodeViewsByEndpoint(t *testing
|
||||
func TestResourceListDerivesProxmoxWorkloadParentFromUnifiedSeed(t *testing.T) {
|
||||
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceUnifiedSeedProvider{
|
||||
snapshot: models.StateSnapshot{LastUpdate: now},
|
||||
resources: []unified.Resource{
|
||||
@@ -821,107 +861,6 @@ func TestResourceListDerivesProxmoxWorkloadParentFromUnifiedSeed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateEndpointDerivesProxmoxWorkloadParentFromSupplementalRecords(t *testing.T) {
|
||||
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
|
||||
hashedPassword, err := authpkg.HashPassword("password")
|
||||
if err != nil {
|
||||
t.Fatalf("hash password: %v", err)
|
||||
}
|
||||
dataPath := t.TempDir()
|
||||
InitSessionStore(dataPath)
|
||||
InitCSRFStore(dataPath)
|
||||
cfg := &config.Config{
|
||||
DataPath: dataPath,
|
||||
AuthUser: "admin",
|
||||
AuthPass: hashedPassword,
|
||||
}
|
||||
monitor, err := monitoring.New(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("new monitor: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { monitor.Stop() })
|
||||
|
||||
monitor.SetResourceStore(unified.NewMonitorAdapter(nil))
|
||||
monitor.SetSupplementalRecordsProvider(unified.SourceProxmox, mockSupplementalRecordsProvider{
|
||||
records: []unified.IngestRecord{
|
||||
{
|
||||
SourceID: "homelab-delly",
|
||||
Resource: unified.Resource{
|
||||
Type: unified.ResourceTypeAgent,
|
||||
Name: "delly",
|
||||
Status: unified.StatusOnline,
|
||||
LastSeen: now,
|
||||
Proxmox: &unified.ProxmoxData{
|
||||
SourceID: "homelab-delly",
|
||||
NodeName: "delly",
|
||||
ClusterName: "homelab",
|
||||
Instance: "delly",
|
||||
},
|
||||
},
|
||||
Identity: unified.ResourceIdentity{
|
||||
MachineID: "machine-delly",
|
||||
Hostnames: []string{"delly"},
|
||||
},
|
||||
},
|
||||
{
|
||||
SourceID: "delly:delly:104",
|
||||
Resource: unified.Resource{
|
||||
Type: unified.ResourceTypeSystemContainer,
|
||||
Name: "cloudflared",
|
||||
Status: unified.StatusOnline,
|
||||
LastSeen: now,
|
||||
Proxmox: &unified.ProxmoxData{
|
||||
SourceID: "delly:delly:104",
|
||||
NodeName: "delly",
|
||||
ClusterName: "homelab",
|
||||
Instance: "delly",
|
||||
VMID: 104,
|
||||
},
|
||||
},
|
||||
Identity: unified.ResourceIdentity{Hostnames: []string{"cloudflared"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
router := &Router{config: cfg, monitor: monitor}
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/state", nil)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
router.handleState(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("/api/state status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var state models.StateFrontend
|
||||
if err := json.NewDecoder(rec.Body).Decode(&state); err != nil {
|
||||
t.Fatalf("decode /api/state: %v", err)
|
||||
}
|
||||
|
||||
dellyCount := 0
|
||||
dellyID := ""
|
||||
cloudflaredParentID := ""
|
||||
for _, resource := range state.Resources {
|
||||
switch {
|
||||
case resource.Type == string(unified.ResourceTypeAgent) && resource.Name == "delly":
|
||||
dellyCount++
|
||||
dellyID = resource.ID
|
||||
case resource.Type == string(unified.ResourceTypeSystemContainer) && resource.Name == "cloudflared":
|
||||
cloudflaredParentID = resource.ParentID
|
||||
}
|
||||
}
|
||||
|
||||
if dellyCount != 1 {
|
||||
t.Fatalf("expected exactly one delly resource in /api/state, got %d: %#v", dellyCount, state.Resources)
|
||||
}
|
||||
if dellyID == "" {
|
||||
t.Fatalf("expected delly resource id in /api/state: %#v", state.Resources)
|
||||
}
|
||||
if cloudflaredParentID != dellyID {
|
||||
t.Fatalf("expected cloudflared parent %q, got %q: %#v", dellyID, cloudflaredParentID, state.Resources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceListCollapsesAsymmetricLinkedClusterNodeViews(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
snapshot := models.StateSnapshot{
|
||||
@@ -966,7 +905,7 @@ func TestResourceListCollapsesAsymmetricLinkedClusterNodeViews(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -1040,7 +979,7 @@ func TestResourceListCollapsesHostLinkedClusterNodeViews(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -1110,7 +1049,7 @@ func TestResourceListCollapsesHostLinkedNodeViewsAcrossEndpointForms(t *testing.
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -1173,7 +1112,7 @@ func TestResourceListIncludesHostSMARTPhysicalDisks(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -1224,7 +1163,7 @@ func TestResourceListUsesCanonicalMetricIDForProxmoxPhysicalDisks(t *testing.T)
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -1261,7 +1200,7 @@ func TestResourceGetResource(t *testing.T) {
|
||||
snapshot := models.StateSnapshot{Hosts: []models.Host{host}}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
listRec := httptest.NewRecorder()
|
||||
@@ -1344,7 +1283,7 @@ func TestResourceGetFacetsAndTimeline(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceUnifiedSeedProvider{
|
||||
snapshot: models.StateSnapshot{LastUpdate: now},
|
||||
resources: []unified.Resource{resource, node},
|
||||
@@ -1739,7 +1678,7 @@ func TestResourceLinkMergesResources(t *testing.T) {
|
||||
snapshot := models.StateSnapshot{Hosts: []models.Host{host}, DockerHosts: []models.DockerHost{dockerHost}}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
listRec := httptest.NewRecorder()
|
||||
@@ -1863,7 +1802,7 @@ func TestResourceLinkFromAgentKeepsHypervisorGuestAuthorityInAPIState(t *testing
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: tc.snapshot})
|
||||
|
||||
list := func() ResourcesResponse {
|
||||
@@ -1965,7 +1904,7 @@ func TestResourceReportMergeCreatesExclusions(t *testing.T) {
|
||||
snapshot := models.StateSnapshot{Hosts: []models.Host{host}, DockerHosts: []models.DockerHost{dockerHost}}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
listRec := httptest.NewRecorder()
|
||||
@@ -2044,7 +1983,7 @@ func TestResourceListIncludesKubernetesPods(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -2122,7 +2061,7 @@ func TestResourceListProjectsKubernetesClusterAgentVersionOntoNode(t *testing.T)
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -2185,7 +2124,7 @@ func TestResourceListFiltersCanonicalKubernetesNamespace(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -2411,7 +2350,7 @@ func TestK8sNamespacesEndpointAggregatesPodsAndDeployments(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -2626,7 +2565,7 @@ func TestResourceListRejectsLegacyKubernetesTypeAlias(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -2680,7 +2619,7 @@ func TestResourceListReturnsCanonicalKubernetesMetricsTargets(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -2784,7 +2723,7 @@ func TestResourceListIncludesDockerSwarmServicesAndFiltersByCluster(t *testing.T
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
// Unfiltered-by-cluster: expect the service to show up exactly once.
|
||||
@@ -2879,7 +2818,7 @@ func TestResourceListIncludesPBSAndPMG(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -2982,7 +2921,7 @@ func TestResourceListIncludesStorageMetadata(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -3086,7 +3025,7 @@ func TestResourceListIncludesStorageConsumerImpact(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -3206,7 +3145,7 @@ func TestResourceListIncludesPBSStorageConsumerImpact(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -3283,7 +3222,7 @@ func TestResourceListIncludesPBSPrimaryIncidentRollup(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -3419,7 +3358,7 @@ func TestPBSResourceListIncludesProtectedWorkloadRollup(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -3500,7 +3439,7 @@ func TestResourceListIncludesHostUnraidStorage(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -3556,7 +3495,7 @@ func TestResourceListReturnsCanonicalStorageMetricsTargets(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -3583,7 +3522,7 @@ func TestResourceListReturnsCanonicalStorageMetricsTargets(t *testing.T) {
|
||||
t.Run("truenas pool", func(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{LastUpdate: now}})
|
||||
h.SetSupplementalRecordsProvider(unified.SourceTrueNAS, mockSupplementalRecordsProvider{
|
||||
records: []unified.IngestRecord{
|
||||
@@ -3636,7 +3575,7 @@ func TestResourceListReturnsCanonicalStorageMetricsTargets(t *testing.T) {
|
||||
func TestResourceStorageSummaryRollsUpIncidents(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceUnifiedSeedProvider{
|
||||
snapshot: models.StateSnapshot{LastUpdate: now},
|
||||
resources: []unified.Resource{
|
||||
@@ -3772,7 +3711,7 @@ func TestResourceListIncludesTrueNASPhysicalDiskTemperature(t *testing.T) {
|
||||
|
||||
now := time.Now().UTC()
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{LastUpdate: now}})
|
||||
h.SetSupplementalRecordsProvider(unified.SourceTrueNAS, mockSupplementalRecordsProvider{
|
||||
records: truenas.NewProvider(truenas.DefaultFixtures()).Records(),
|
||||
@@ -3851,7 +3790,7 @@ func TestResourceListIncludesTrueNASAppsAsAppContainers(t *testing.T) {
|
||||
|
||||
fixtures := truenas.DefaultFixtures()
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{LastUpdate: time.Now().UTC()}})
|
||||
h.SetSupplementalRecordsProvider(unified.SourceTrueNAS, mockSupplementalRecordsProvider{
|
||||
records: truenas.NewProvider(fixtures).Records(),
|
||||
@@ -3917,7 +3856,7 @@ func TestResourceListIncludesTrueNASVMsAsCanonicalWorkloads(t *testing.T) {
|
||||
|
||||
fixtures := truenas.DefaultFixtures()
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{LastUpdate: time.Now().UTC()}})
|
||||
h.SetSupplementalRecordsProvider(unified.SourceTrueNAS, mockSupplementalRecordsProvider{
|
||||
records: truenas.NewProvider(fixtures).Records(),
|
||||
@@ -3976,7 +3915,7 @@ func TestResourceListIncludesTrueNASSystemAsCanonicalHost(t *testing.T) {
|
||||
})
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{LastUpdate: time.Now().UTC()}})
|
||||
h.SetSupplementalRecordsProvider(unified.SourceTrueNAS, mockSupplementalRecordsProvider{
|
||||
records: truenas.NewProvider(truenas.DefaultFixtures()).Records(),
|
||||
@@ -4026,7 +3965,7 @@ func TestResourceListIncludesTrueNASSystemAsCanonicalHost(t *testing.T) {
|
||||
func TestResourceStorageIncidentsGroupsCanonicalSections(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceUnifiedSeedProvider{
|
||||
snapshot: models.StateSnapshot{LastUpdate: now},
|
||||
resources: []unified.Resource{
|
||||
@@ -4209,7 +4148,7 @@ func TestResourceListIncludesTrueNASFromSupplementalProvider(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{LastUpdate: now}})
|
||||
h.SetSupplementalRecordsProvider(unified.SourceTrueNAS, mockSupplementalRecordsProvider{
|
||||
records: []unified.IngestRecord{
|
||||
@@ -4259,7 +4198,7 @@ func TestResourceListUnifiedSeedSkipsSupplementalReingest(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceUnifiedSeedProvider{
|
||||
snapshot: models.StateSnapshot{LastUpdate: now},
|
||||
resources: []unified.Resource{
|
||||
@@ -4321,7 +4260,7 @@ func TestResourceListUnifiedSeedIngestsOwnedSupplementalWhenSourceMissing(t *tes
|
||||
now := time.Now().UTC()
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceUnifiedSeedProvider{
|
||||
snapshot: models.StateSnapshot{LastUpdate: now},
|
||||
resources: []unified.Resource{
|
||||
@@ -4398,7 +4337,7 @@ func TestResourceListSupplementalOwnerSuppressesSnapshotSource(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
h.SetSupplementalRecordsProvider(unified.SourceAgent, mockSupplementalRecordsProvider{
|
||||
ownedSources: []unified.DataSource{unified.SourceAgent},
|
||||
@@ -4455,7 +4394,7 @@ func TestResourceListWithoutSupplementalProvider(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := &config.Config{DataPath: t.TempDir()}
|
||||
h := NewResourceHandlers(cfg)
|
||||
h := NewQueryService(cfg)
|
||||
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
|
||||
|
||||
truenasRec := httptest.NewRecorder()
|
||||
@@ -4493,7 +4432,7 @@ func TestResourceListWithoutSupplementalProvider(t *testing.T) {
|
||||
|
||||
func TestSupplementalSnapshotOwnedSources_TrueNASProviders(t *testing.T) {
|
||||
sources := supplementalSnapshotOwnedSources(map[unified.DataSource]SupplementalRecordsProvider{
|
||||
unified.SourceTrueNAS: mockSupplementalRecordsAdapter{source: unified.SourceTrueNAS},
|
||||
unified.SourceTrueNAS: ownedSupplementalProvider{source: unified.SourceTrueNAS},
|
||||
}, "default")
|
||||
|
||||
if len(sources) != 1 || sources[0] != unified.SourceTrueNAS {
|
||||
@@ -0,0 +1,148 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/resourceapi"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
||||
)
|
||||
|
||||
// ResourceHandlers preserves the established router and extension surface
|
||||
// while composing the resource-query service with API-owned action mutations.
|
||||
// QueryService owns resource registry construction, durable resource storage,
|
||||
// filtering, projections, and read handlers. The fields below remain here
|
||||
// because action lifecycle and operator-state mutations are separate domains.
|
||||
type ResourceHandlers struct {
|
||||
resourceapi.QueryService
|
||||
|
||||
cfg *config.Config
|
||||
tenantStateProvider TenantStateProvider
|
||||
actionExecutor ActionExecutor
|
||||
actionCompleted func(unified.ActionAuditRecord)
|
||||
actionTransition func(orgID string, record unified.ActionAuditRecord)
|
||||
policyAdmission *actionlifecycle.PolicyAdmissionCoordinator
|
||||
actionEmergencyStop func(orgID string) (bool, error)
|
||||
actionDecisionAuthorizer actionlifecycle.DecisionAuthorizer
|
||||
actionExecutionAuthorizer actionlifecycle.ExecutionAuthorizer
|
||||
actionRefreshPlanner actionlifecycle.RefreshPlanner
|
||||
operatorStateChanged func(orgID, resourceID string)
|
||||
}
|
||||
|
||||
type ResourceDiscoveryReadinessProvider = resourceapi.ResourceDiscoveryReadinessProvider
|
||||
type SupplementalRecordsProvider = resourceapi.SupplementalRecordsProvider
|
||||
type TenantSupplementalRecordsProvider = resourceapi.TenantSupplementalRecordsProvider
|
||||
type SupplementalSnapshotSourceOwner = resourceapi.SupplementalSnapshotSourceOwner
|
||||
type TenantSupplementalSnapshotSourceOwner = resourceapi.TenantSupplementalSnapshotSourceOwner
|
||||
type ResourcesResponse = resourceapi.ResourcesResponse
|
||||
type ResourcesMeta = resourceapi.ResourcesMeta
|
||||
type StorageSummaryResponse = resourceapi.StorageSummaryResponse
|
||||
type StorageSummaryIncident = resourceapi.StorageSummaryIncident
|
||||
type StorageIncidentsResponse = resourceapi.StorageIncidentsResponse
|
||||
type StorageIncidentSection = resourceapi.StorageIncidentSection
|
||||
|
||||
func EmptyResourcesResponse() ResourcesResponse { return resourceapi.EmptyResourcesResponse() }
|
||||
|
||||
func EmptyStorageSummaryResponse() StorageSummaryResponse {
|
||||
return resourceapi.EmptyStorageSummaryResponse()
|
||||
}
|
||||
|
||||
func EmptyStorageIncidentsResponse() StorageIncidentsResponse {
|
||||
return resourceapi.EmptyStorageIncidentsResponse()
|
||||
}
|
||||
|
||||
// NewResourceHandlers builds the compatibility composition used by Router.
|
||||
func NewResourceHandlers(cfg *config.Config) *ResourceHandlers {
|
||||
return &ResourceHandlers{
|
||||
QueryService: *resourceapi.NewQueryService(cfg),
|
||||
cfg: cfg,
|
||||
policyAdmission: &actionlifecycle.PolicyAdmissionCoordinator{},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) ensureQueryService() *resourceapi.QueryService {
|
||||
return &h.QueryService
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) SetStateProvider(provider SnapshotProvider) {
|
||||
h.ensureQueryService().SetStateProvider(provider)
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) SetTenantStateProvider(provider TenantStateProvider) {
|
||||
h.tenantStateProvider = provider
|
||||
h.ensureQueryService().SetTenantStateProvider(provider)
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) SetActionExecutor(executor ActionExecutor) {
|
||||
h.actionExecutor = executor
|
||||
checker, _ := executor.(actionlifecycle.AvailabilityChecker)
|
||||
h.ensureQueryService().SetActionAvailabilityChecker(checker)
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) SetActionEmergencyStopChecker(checker func(orgID string) (bool, error)) {
|
||||
h.actionEmergencyStop = checker
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) SetActionAuthorizers(decision actionlifecycle.DecisionAuthorizer, execution actionlifecycle.ExecutionAuthorizer) {
|
||||
h.actionDecisionAuthorizer = decision
|
||||
h.actionExecutionAuthorizer = execution
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) SetActionRefreshPlanner(planner actionlifecycle.RefreshPlanner) {
|
||||
h.actionRefreshPlanner = planner
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) SetActionCompletedPublisher(publisher func(unified.ActionAuditRecord)) {
|
||||
h.actionCompleted = publisher
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) SetActionTransitionPublisher(publisher func(orgID string, record unified.ActionAuditRecord)) {
|
||||
h.actionTransition = publisher
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) SetOperatorStateChanged(callback func(orgID, resourceID string)) {
|
||||
h.operatorStateChanged = callback
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) buildRegistry(orgID string) (*unified.ResourceRegistry, error) {
|
||||
return h.ensureQueryService().BuildRegistry(orgID)
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) getStore(orgID string) (unified.ResourceStore, error) {
|
||||
return h.ensureQueryService().Store(orgID)
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) invalidateCache(orgID string) {
|
||||
h.ensureQueryService().InvalidateCache(orgID)
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) CloseTenantStore(orgID string) error {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
return h.QueryService.CloseTenantStore(orgID)
|
||||
}
|
||||
|
||||
func (h *ResourceHandlers) CloseStores() error {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
return h.QueryService.CloseStores()
|
||||
}
|
||||
|
||||
// firstNonEmptyTrimmed is retained for non-resource connection projections.
|
||||
func firstNonEmptyTrimmed(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getUserID(r *http.Request) string {
|
||||
return auth.GetUser(r.Context())
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
authpkg "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
||||
)
|
||||
|
||||
type stateSupplementalRecordsProvider struct {
|
||||
records []unified.IngestRecord
|
||||
}
|
||||
|
||||
func (p stateSupplementalRecordsProvider) GetCurrentRecords() []unified.IngestRecord {
|
||||
return append([]unified.IngestRecord(nil), p.records...)
|
||||
}
|
||||
|
||||
func (p stateSupplementalRecordsProvider) SupplementalRecords(*monitoring.Monitor, string) []unified.IngestRecord {
|
||||
return p.GetCurrentRecords()
|
||||
}
|
||||
|
||||
func TestStateEndpointDerivesProxmoxWorkloadParentFromSupplementalRecords(t *testing.T) {
|
||||
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
|
||||
hashedPassword, err := authpkg.HashPassword("password")
|
||||
if err != nil {
|
||||
t.Fatalf("hash password: %v", err)
|
||||
}
|
||||
dataPath := t.TempDir()
|
||||
InitSessionStore(dataPath)
|
||||
InitCSRFStore(dataPath)
|
||||
cfg := &config.Config{DataPath: dataPath, AuthUser: "admin", AuthPass: hashedPassword}
|
||||
monitor, err := monitoring.New(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("new monitor: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { monitor.Stop() })
|
||||
|
||||
monitor.SetResourceStore(unified.NewMonitorAdapter(nil))
|
||||
monitor.SetSupplementalRecordsProvider(unified.SourceProxmox, stateSupplementalRecordsProvider{records: []unified.IngestRecord{
|
||||
{
|
||||
SourceID: "homelab-delly",
|
||||
Resource: unified.Resource{
|
||||
Type: unified.ResourceTypeAgent, Name: "delly", Status: unified.StatusOnline, LastSeen: now,
|
||||
Proxmox: &unified.ProxmoxData{SourceID: "homelab-delly", NodeName: "delly", ClusterName: "homelab", Instance: "delly"},
|
||||
},
|
||||
Identity: unified.ResourceIdentity{MachineID: "machine-delly", Hostnames: []string{"delly"}},
|
||||
},
|
||||
{
|
||||
SourceID: "delly:delly:104",
|
||||
Resource: unified.Resource{
|
||||
Type: unified.ResourceTypeSystemContainer, Name: "cloudflared", Status: unified.StatusOnline, LastSeen: now,
|
||||
Proxmox: &unified.ProxmoxData{SourceID: "delly:delly:104", NodeName: "delly", ClusterName: "homelab", Instance: "delly", VMID: 104},
|
||||
},
|
||||
Identity: unified.ResourceIdentity{Hostnames: []string{"cloudflared"}},
|
||||
},
|
||||
}})
|
||||
|
||||
router := &Router{config: cfg, monitor: monitor}
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/state", nil)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
router.handleState(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("/api/state status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var state models.StateFrontend
|
||||
if err := json.NewDecoder(rec.Body).Decode(&state); err != nil {
|
||||
t.Fatalf("decode /api/state: %v", err)
|
||||
}
|
||||
dellyCount, dellyID, cloudflaredParentID := 0, "", ""
|
||||
for _, resource := range state.Resources {
|
||||
switch {
|
||||
case resource.Type == string(unified.ResourceTypeAgent) && resource.Name == "delly":
|
||||
dellyCount++
|
||||
dellyID = resource.ID
|
||||
case resource.Type == string(unified.ResourceTypeSystemContainer) && resource.Name == "cloudflared":
|
||||
cloudflaredParentID = resource.ParentID
|
||||
}
|
||||
}
|
||||
if dellyCount != 1 || dellyID == "" || cloudflaredParentID != dellyID {
|
||||
t.Fatalf("unexpected /api/state resource projection: dellyCount=%d dellyID=%q cloudflaredParentID=%q resources=%#v", dellyCount, dellyID, cloudflaredParentID, state.Resources)
|
||||
}
|
||||
}
|
||||
|
||||
// Closing must be safe to repeat and safe on a nil compatibility facade: the
|
||||
// shutdown path runs before every dependency is guaranteed to be wired.
|
||||
func TestResourceHandlers_CloseIsIdempotentAndNilSafe(t *testing.T) {
|
||||
var nilHandlers *ResourceHandlers
|
||||
if err := nilHandlers.CloseStores(); err != nil {
|
||||
t.Fatalf("nil handler CloseStores: %v", err)
|
||||
}
|
||||
if err := nilHandlers.CloseTenantStore("client-a"); err != nil {
|
||||
t.Fatalf("nil handler CloseTenantStore: %v", err)
|
||||
}
|
||||
|
||||
handlers := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
||||
if _, err := handlers.getStore("client-a"); err != nil {
|
||||
t.Fatalf("getStore: %v", err)
|
||||
}
|
||||
if err := handlers.CloseStores(); err != nil {
|
||||
t.Fatalf("first CloseStores: %v", err)
|
||||
}
|
||||
if err := handlers.CloseStores(); err != nil {
|
||||
t.Fatalf("second CloseStores must be a no-op: %v", err)
|
||||
}
|
||||
if err := handlers.CloseTenantStore("client-a"); err != nil {
|
||||
t.Fatalf("CloseTenantStore on an evicted org must be a no-op: %v", err)
|
||||
}
|
||||
}
|
||||
+14
-3171
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+28
-314
@@ -1,5 +1,7 @@
|
||||
package api
|
||||
|
||||
import "github.com/rcourtman/pulse-go-rewrite/internal/api/chartapi"
|
||||
|
||||
// Common response types for API endpoints
|
||||
|
||||
// HealthResponse represents the health check response
|
||||
@@ -42,329 +44,41 @@ type VersionResponse struct {
|
||||
ContainerID string `json:"containerId,omitempty"`
|
||||
}
|
||||
|
||||
// ChartResponse represents chart data
|
||||
type ChartResponse struct {
|
||||
ChartData map[string]VMChartData `json:"data"`
|
||||
NodeData map[string]NodeChartData `json:"nodeData"`
|
||||
StorageData map[string]StorageChartData `json:"storageData"`
|
||||
DockerData map[string]VMChartData `json:"dockerData"` // Docker container metrics (keyed by container ID)
|
||||
DockerHostData map[string]VMChartData `json:"dockerHostData"` // Docker host metrics (keyed by host ID)
|
||||
AgentData map[string]VMChartData `json:"agentData"` // Unified agent metrics (keyed by agent ID)
|
||||
GuestTypes map[string]string `json:"guestTypes"` // Maps guest ID to type ("vm", "system-container", "k8s")
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Stats ChartStats `json:"stats"`
|
||||
}
|
||||
|
||||
func EmptyChartResponse() ChartResponse {
|
||||
return ChartResponse{}.NormalizeCollections()
|
||||
}
|
||||
|
||||
func (r ChartResponse) NormalizeCollections() ChartResponse {
|
||||
if r.ChartData == nil {
|
||||
r.ChartData = map[string]VMChartData{}
|
||||
}
|
||||
if r.NodeData == nil {
|
||||
r.NodeData = map[string]NodeChartData{}
|
||||
}
|
||||
if r.StorageData == nil {
|
||||
r.StorageData = map[string]StorageChartData{}
|
||||
}
|
||||
if r.DockerData == nil {
|
||||
r.DockerData = map[string]VMChartData{}
|
||||
}
|
||||
if r.DockerHostData == nil {
|
||||
r.DockerHostData = map[string]VMChartData{}
|
||||
}
|
||||
if r.AgentData == nil {
|
||||
r.AgentData = map[string]VMChartData{}
|
||||
}
|
||||
if r.GuestTypes == nil {
|
||||
r.GuestTypes = map[string]string{}
|
||||
}
|
||||
r.Stats = r.Stats.NormalizeCollections()
|
||||
return r
|
||||
}
|
||||
|
||||
// InfrastructureChartsResponse is a lightweight variant of ChartResponse used by
|
||||
// infra-only clients (Infrastructure summary sparklines, prewarm caches).
|
||||
// It avoids the heavy guest/storage chart payload and associated compute.
|
||||
type InfrastructureChartsResponse struct {
|
||||
NodeData map[string]NodeChartData `json:"nodeData"`
|
||||
DockerHostData map[string]VMChartData `json:"dockerHostData"` // Docker host metrics (keyed by host ID)
|
||||
AgentData map[string]VMChartData `json:"agentData"` // Unified agent metrics (keyed by agent ID)
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Stats ChartStats `json:"stats"`
|
||||
}
|
||||
type ChartResponse = chartapi.ChartResponse
|
||||
type InfrastructureChartsResponse = chartapi.InfrastructureChartsResponse
|
||||
type WorkloadChartsResponse = chartapi.WorkloadChartsResponse
|
||||
type WorkloadsSummaryMetricData = chartapi.WorkloadsSummaryMetricData
|
||||
type WorkloadsGuestCounts = chartapi.WorkloadsGuestCounts
|
||||
type WorkloadsSummaryContributor = chartapi.WorkloadsSummaryContributor
|
||||
type WorkloadsSummaryContributors = chartapi.WorkloadsSummaryContributors
|
||||
type WorkloadsSummaryBlastRadius = chartapi.WorkloadsSummaryBlastRadius
|
||||
type WorkloadsSummaryBlastRadiusGroup = chartapi.WorkloadsSummaryBlastRadiusGroup
|
||||
type WorkloadsSummaryChartsResponse = chartapi.WorkloadsSummaryChartsResponse
|
||||
type StorageSummaryTrendResponse = chartapi.StorageSummaryTrendResponse
|
||||
type ChartStats = chartapi.ChartStats
|
||||
type ChartPointCounts = chartapi.ChartPointCounts
|
||||
type VMChartData = chartapi.VMChartData
|
||||
type NodeChartData = chartapi.NodeChartData
|
||||
type StorageChartData = chartapi.StorageChartData
|
||||
type StorageChartsResponse = chartapi.StorageChartsResponse
|
||||
type StoragePoolChartData = chartapi.StoragePoolChartData
|
||||
type StorageDiskChartData = chartapi.StorageDiskChartData
|
||||
type MetricPoint = chartapi.MetricPoint
|
||||
|
||||
func EmptyChartResponse() ChartResponse { return chartapi.EmptyChartResponse() }
|
||||
func EmptyInfrastructureChartsResponse() InfrastructureChartsResponse {
|
||||
return InfrastructureChartsResponse{}.NormalizeCollections()
|
||||
return chartapi.EmptyInfrastructureChartsResponse()
|
||||
}
|
||||
|
||||
func (r InfrastructureChartsResponse) NormalizeCollections() InfrastructureChartsResponse {
|
||||
if r.NodeData == nil {
|
||||
r.NodeData = map[string]NodeChartData{}
|
||||
}
|
||||
if r.DockerHostData == nil {
|
||||
r.DockerHostData = map[string]VMChartData{}
|
||||
}
|
||||
if r.AgentData == nil {
|
||||
r.AgentData = map[string]VMChartData{}
|
||||
}
|
||||
r.Stats = r.Stats.NormalizeCollections()
|
||||
return r
|
||||
}
|
||||
|
||||
// WorkloadChartsResponse is a lightweight chart payload used by Workloads
|
||||
// summary sparklines. It intentionally excludes infrastructure/storage series.
|
||||
type WorkloadChartsResponse struct {
|
||||
ChartData map[string]VMChartData `json:"data"` // Workload metrics keyed by workload ID
|
||||
DockerData map[string]VMChartData `json:"dockerData"` // Docker container metrics keyed by container ID
|
||||
GuestTypes map[string]string `json:"guestTypes"` // Maps guest ID to type ("vm", "system-container", "k8s")
|
||||
Timestamp int64 `json:"timestamp"` // Unix timestamp in milliseconds
|
||||
Stats ChartStats `json:"stats"` // Includes pointCounts + source hints
|
||||
}
|
||||
|
||||
func EmptyWorkloadChartsResponse() WorkloadChartsResponse {
|
||||
return WorkloadChartsResponse{}.NormalizeCollections()
|
||||
return chartapi.EmptyWorkloadChartsResponse()
|
||||
}
|
||||
|
||||
func (r WorkloadChartsResponse) NormalizeCollections() WorkloadChartsResponse {
|
||||
if r.ChartData == nil {
|
||||
r.ChartData = map[string]VMChartData{}
|
||||
}
|
||||
if r.DockerData == nil {
|
||||
r.DockerData = map[string]VMChartData{}
|
||||
}
|
||||
if r.GuestTypes == nil {
|
||||
r.GuestTypes = map[string]string{}
|
||||
}
|
||||
r.Stats = r.Stats.NormalizeCollections()
|
||||
return r
|
||||
}
|
||||
|
||||
// WorkloadsSummaryMetricData captures aggregate workload trend lines for a
|
||||
// single metric (median and p95 across workloads).
|
||||
type WorkloadsSummaryMetricData struct {
|
||||
P50 []MetricPoint `json:"p50"`
|
||||
P95 []MetricPoint `json:"p95"`
|
||||
}
|
||||
|
||||
func (m WorkloadsSummaryMetricData) NormalizeCollections() WorkloadsSummaryMetricData {
|
||||
if m.P50 == nil {
|
||||
m.P50 = []MetricPoint{}
|
||||
}
|
||||
if m.P95 == nil {
|
||||
m.P95 = []MetricPoint{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// WorkloadsGuestCounts captures workload counts used by the workloads summary
|
||||
// cards for quick context.
|
||||
type WorkloadsGuestCounts struct {
|
||||
Total int `json:"total"`
|
||||
Running int `json:"running"`
|
||||
Stopped int `json:"stopped"`
|
||||
}
|
||||
|
||||
// WorkloadsSummaryContributor identifies a high-impact workload for a metric.
|
||||
type WorkloadsSummaryContributor struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
|
||||
// WorkloadsSummaryContributors groups top contributors by metric.
|
||||
type WorkloadsSummaryContributors struct {
|
||||
CPU []WorkloadsSummaryContributor `json:"cpu"`
|
||||
Memory []WorkloadsSummaryContributor `json:"memory"`
|
||||
Disk []WorkloadsSummaryContributor `json:"disk"`
|
||||
Network []WorkloadsSummaryContributor `json:"network"`
|
||||
}
|
||||
|
||||
func (c WorkloadsSummaryContributors) NormalizeCollections() WorkloadsSummaryContributors {
|
||||
if c.CPU == nil {
|
||||
c.CPU = []WorkloadsSummaryContributor{}
|
||||
}
|
||||
if c.Memory == nil {
|
||||
c.Memory = []WorkloadsSummaryContributor{}
|
||||
}
|
||||
if c.Disk == nil {
|
||||
c.Disk = []WorkloadsSummaryContributor{}
|
||||
}
|
||||
if c.Network == nil {
|
||||
c.Network = []WorkloadsSummaryContributor{}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// WorkloadsSummaryBlastRadius describes how concentrated each metric pressure is.
|
||||
type WorkloadsSummaryBlastRadius struct {
|
||||
Scope string `json:"scope"` // idle, concentrated, mixed, distributed
|
||||
Top3Share float64 `json:"top3Share"`
|
||||
ActiveWorkloads int `json:"activeWorkloads"`
|
||||
}
|
||||
|
||||
// WorkloadsSummaryBlastRadiusGroup groups blast-radius insights by metric.
|
||||
type WorkloadsSummaryBlastRadiusGroup struct {
|
||||
CPU WorkloadsSummaryBlastRadius `json:"cpu"`
|
||||
Memory WorkloadsSummaryBlastRadius `json:"memory"`
|
||||
Disk WorkloadsSummaryBlastRadius `json:"disk"`
|
||||
Network WorkloadsSummaryBlastRadius `json:"network"`
|
||||
}
|
||||
|
||||
// WorkloadsSummaryChartsResponse is a compact response for workloads top-card
|
||||
// sparklines. It avoids returning per-workload time series.
|
||||
type WorkloadsSummaryChartsResponse struct {
|
||||
CPU WorkloadsSummaryMetricData `json:"cpu"`
|
||||
Memory WorkloadsSummaryMetricData `json:"memory"`
|
||||
Disk WorkloadsSummaryMetricData `json:"disk"`
|
||||
Network WorkloadsSummaryMetricData `json:"network"`
|
||||
GuestCounts WorkloadsGuestCounts `json:"guestCounts"`
|
||||
TopContributors WorkloadsSummaryContributors `json:"topContributors"`
|
||||
BlastRadius WorkloadsSummaryBlastRadiusGroup `json:"blastRadius"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Stats ChartStats `json:"stats"`
|
||||
}
|
||||
|
||||
func EmptyWorkloadsSummaryChartsResponse() WorkloadsSummaryChartsResponse {
|
||||
return WorkloadsSummaryChartsResponse{}.NormalizeCollections()
|
||||
return chartapi.EmptyWorkloadsSummaryChartsResponse()
|
||||
}
|
||||
|
||||
func (r WorkloadsSummaryChartsResponse) NormalizeCollections() WorkloadsSummaryChartsResponse {
|
||||
r.CPU = r.CPU.NormalizeCollections()
|
||||
r.Memory = r.Memory.NormalizeCollections()
|
||||
r.Disk = r.Disk.NormalizeCollections()
|
||||
r.Network = r.Network.NormalizeCollections()
|
||||
r.TopContributors = r.TopContributors.NormalizeCollections()
|
||||
r.Stats = r.Stats.NormalizeCollections()
|
||||
return r
|
||||
}
|
||||
|
||||
// StorageSummaryTrendResponse is a compact response for the dashboard storage
|
||||
// card. It intentionally avoids returning per-pool and per-disk series.
|
||||
type StorageSummaryTrendResponse struct {
|
||||
Capacity []MetricPoint `json:"capacity"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Stats ChartStats `json:"stats"`
|
||||
}
|
||||
|
||||
func EmptyStorageSummaryTrendResponse() StorageSummaryTrendResponse {
|
||||
return StorageSummaryTrendResponse{}.NormalizeCollections()
|
||||
}
|
||||
|
||||
func (r StorageSummaryTrendResponse) NormalizeCollections() StorageSummaryTrendResponse {
|
||||
if r.Capacity == nil {
|
||||
r.Capacity = []MetricPoint{}
|
||||
}
|
||||
r.Stats = r.Stats.NormalizeCollections()
|
||||
return r
|
||||
}
|
||||
|
||||
// ChartStats represents chart statistics
|
||||
type ChartStats struct {
|
||||
OldestDataTimestamp int64 `json:"oldestDataTimestamp"`
|
||||
Range string `json:"range,omitempty"`
|
||||
RangeSeconds int64 `json:"rangeSeconds,omitempty"`
|
||||
MetricsStoreEnabled bool `json:"metricsStoreEnabled"`
|
||||
PrimarySourceHint string `json:"primarySourceHint,omitempty"`
|
||||
InMemoryThresholdSecs int64 `json:"inMemoryThresholdSecs,omitempty"`
|
||||
PointCounts ChartPointCounts `json:"pointCounts,omitempty"`
|
||||
}
|
||||
|
||||
func (s ChartStats) NormalizeCollections() ChartStats {
|
||||
return s
|
||||
}
|
||||
|
||||
// ChartPointCounts summarizes how many points were returned in /api/charts.
|
||||
type ChartPointCounts struct {
|
||||
Total int `json:"total,omitempty"`
|
||||
Guests int `json:"guests,omitempty"`
|
||||
Nodes int `json:"nodes,omitempty"`
|
||||
Storage int `json:"storage,omitempty"`
|
||||
DockerContainers int `json:"dockerContainers,omitempty"`
|
||||
DockerHosts int `json:"dockerHosts,omitempty"`
|
||||
Agents int `json:"agents,omitempty"`
|
||||
}
|
||||
|
||||
// VMChartData represents chart data for a VM
|
||||
type VMChartData map[string][]MetricPoint
|
||||
|
||||
// NodeChartData represents chart data for a node
|
||||
type NodeChartData map[string][]MetricPoint
|
||||
|
||||
// StorageChartData represents chart data for storage
|
||||
type StorageChartData map[string][]MetricPoint
|
||||
|
||||
// StorageChartsResponse represents storage charts API response.
|
||||
// It includes both pool-level capacity metrics and physical disk metrics.
|
||||
type StorageChartsResponse struct {
|
||||
Pools map[string]StoragePoolChartData `json:"pools"`
|
||||
Disks map[string]StorageDiskChartData `json:"disks"`
|
||||
Stats ChartStats `json:"stats"`
|
||||
}
|
||||
|
||||
func EmptyStorageChartsResponse() StorageChartsResponse {
|
||||
return StorageChartsResponse{}.NormalizeCollections()
|
||||
}
|
||||
|
||||
func (r StorageChartsResponse) NormalizeCollections() StorageChartsResponse {
|
||||
if r.Pools == nil {
|
||||
r.Pools = map[string]StoragePoolChartData{}
|
||||
}
|
||||
if r.Disks == nil {
|
||||
r.Disks = map[string]StorageDiskChartData{}
|
||||
}
|
||||
for key, pool := range r.Pools {
|
||||
r.Pools[key] = pool.NormalizeCollections()
|
||||
}
|
||||
for key, disk := range r.Disks {
|
||||
r.Disks[key] = disk.NormalizeCollections()
|
||||
}
|
||||
r.Stats = r.Stats.NormalizeCollections()
|
||||
return r
|
||||
}
|
||||
|
||||
// StoragePoolChartData holds per-pool capacity time-series.
|
||||
type StoragePoolChartData struct {
|
||||
Name string `json:"name"`
|
||||
Usage []MetricPoint `json:"usage"`
|
||||
Used []MetricPoint `json:"used"`
|
||||
Avail []MetricPoint `json:"avail"`
|
||||
}
|
||||
|
||||
func (d StoragePoolChartData) NormalizeCollections() StoragePoolChartData {
|
||||
if d.Usage == nil {
|
||||
d.Usage = []MetricPoint{}
|
||||
}
|
||||
if d.Used == nil {
|
||||
d.Used = []MetricPoint{}
|
||||
}
|
||||
if d.Avail == nil {
|
||||
d.Avail = []MetricPoint{}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// StorageDiskChartData holds per-disk temperature time-series.
|
||||
type StorageDiskChartData struct {
|
||||
Name string `json:"name"`
|
||||
Node string `json:"node"`
|
||||
Temperature []MetricPoint `json:"temperature"`
|
||||
}
|
||||
|
||||
func (d StorageDiskChartData) NormalizeCollections() StorageDiskChartData {
|
||||
if d.Temperature == nil {
|
||||
d.Temperature = []MetricPoint{}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// MetricPoint represents a single metric data point
|
||||
type MetricPoint struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Value float64 `json:"value"`
|
||||
return chartapi.EmptyStorageSummaryTrendResponse()
|
||||
}
|
||||
func EmptyStorageChartsResponse() StorageChartsResponse { return chartapi.EmptyStorageChartsResponse() }
|
||||
|
||||
// AgentVersionResponse represents Docker / Podman module version information.
|
||||
type AgentVersionResponse struct {
|
||||
|
||||
@@ -650,7 +650,7 @@ func TestCloneResourceCopiesHostThermalState(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProxmoxWorkloadActionTargetsStayBackendAuthored(t *testing.T) {
|
||||
apiSource, err := os.ReadFile(filepath.Join("..", "api", "resources.go"))
|
||||
apiSource, err := os.ReadFile(filepath.Join("..", "api", "resourceapi", "resources.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("read api resources source: %v", err)
|
||||
}
|
||||
@@ -695,9 +695,9 @@ func TestDockerSwarmEvidenceGuardStaysInAdapter(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAPIResourcesKeepsOwnedSupplementalGapFillAndVMwareAlias(t *testing.T) {
|
||||
data, err := os.ReadFile(filepath.Join("..", "api", "resources.go"))
|
||||
data, err := os.ReadFile(filepath.Join("..", "api", "resourceapi", "resources.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read ../api/resources.go: %v", err)
|
||||
t.Fatalf("failed to read ../api/resourceapi/resources.go: %v", err)
|
||||
}
|
||||
source := string(data)
|
||||
|
||||
@@ -709,23 +709,23 @@ func TestAPIResourcesKeepsOwnedSupplementalGapFillAndVMwareAlias(t *testing.T) {
|
||||
}
|
||||
for _, snippet := range requiredSnippets {
|
||||
if !strings.Contains(source, snippet) {
|
||||
t.Fatalf("../api/resources.go must contain %q", snippet)
|
||||
t.Fatalf("../api/resourceapi/resources.go must contain %q", snippet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceAPIUsesCanonicalTenantUnifiedSeed(t *testing.T) {
|
||||
data, err := os.ReadFile(filepath.Join("..", "api", "resources.go"))
|
||||
data, err := os.ReadFile(filepath.Join("..", "api", "resourceapi", "resources.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read resources.go: %v", err)
|
||||
}
|
||||
source := string(data)
|
||||
|
||||
if strings.Contains(source, "GetStateForTenant(") {
|
||||
t.Fatalf("internal/api/resources.go must not fall back to tenant StateSnapshot seeding")
|
||||
t.Fatalf("internal/api/resourceapi/resources.go must not fall back to tenant StateSnapshot seeding")
|
||||
}
|
||||
if !strings.Contains(source, "UnifiedResourceSnapshotForTenant(orgID)") {
|
||||
t.Fatalf("internal/api/resources.go must use tenant unified resource snapshots as the canonical seed")
|
||||
t.Fatalf("internal/api/resourceapi/resources.go must use tenant unified resource snapshots as the canonical seed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -887,7 +887,7 @@ func TestCephPoolsProjectThroughCanonicalStoragePath(t *testing.T) {
|
||||
|
||||
func TestResourceAPIExposesDedicatedFacetReads(t *testing.T) {
|
||||
requiredSnippets := map[string][]string{
|
||||
filepath.Join("..", "api", "resources.go"): {
|
||||
filepath.Join("..", "api", "resourceapi", "resources.go"): {
|
||||
"HandleGetResourceFacets",
|
||||
"HandleGetResourceTimeline",
|
||||
"HandleListResourceTimeline",
|
||||
@@ -1027,7 +1027,7 @@ func TestActionExecutionContractStaysAPIOwned(t *testing.T) {
|
||||
"agentcapabilities.AgentErrCodeActionPlanDrift",
|
||||
"agentcapabilities.AgentErrCodeActionExecutorUnavailable",
|
||||
},
|
||||
filepath.Join("..", "api", "resources.go"): {
|
||||
filepath.Join("..", "api", "resources_compat.go"): {
|
||||
"actionExecutor ActionExecutor",
|
||||
"actionCompleted func(unified.ActionAuditRecord)",
|
||||
"actionTransition func(orgID string, record unified.ActionAuditRecord)",
|
||||
@@ -1035,7 +1035,10 @@ func TestActionExecutionContractStaysAPIOwned(t *testing.T) {
|
||||
"func (h *ResourceHandlers) SetActionCompletedPublisher(",
|
||||
"func (h *ResourceHandlers) SetActionTransitionPublisher(",
|
||||
"policyAdmission *actionlifecycle.PolicyAdmissionCoordinator",
|
||||
"func (h *ResourceHandlers) applyActionAvailability(ctx context.Context, resources []unified.Resource)",
|
||||
},
|
||||
filepath.Join("..", "api", "resourceapi", "resources.go"): {
|
||||
"actionAvailability actionlifecycle.AvailabilityChecker",
|
||||
"func (h *QueryService) applyActionAvailability(ctx context.Context, resources []unified.Resource)",
|
||||
"resources[i].ActionReadiness = readinesses",
|
||||
},
|
||||
filepath.Join("..", "api", "agent_events.go"): {
|
||||
@@ -1093,7 +1096,7 @@ func TestResourceChangeFilterParsingIsOwnedByUnifiedResources(t *testing.T) {
|
||||
"func parseResourceChangeSourceTypes(values []string) ([]ChangeSourceType, error)",
|
||||
"func parseResourceChangeSourceAdapters(values []string) ([]ChangeSourceAdapter, error)",
|
||||
},
|
||||
filepath.Join("..", "api", "resources.go"): {
|
||||
filepath.Join("..", "api", "resourceapi", "resources.go"): {
|
||||
"unified.ParseResourceChangeFilters(r.URL.Query()[\"kind\"], r.URL.Query()[\"sourceType\"], r.URL.Query()[\"sourceAdapter\"])",
|
||||
},
|
||||
}
|
||||
@@ -1252,7 +1255,7 @@ func TestCanonicalMetadataRefreshHelperUsedByConsumers(t *testing.T) {
|
||||
filepath.Join(".", "clone.go"): {
|
||||
"RefreshCanonicalMetadata(&out)",
|
||||
},
|
||||
filepath.Join("..", "api", "resources.go"): {
|
||||
filepath.Join("..", "api", "resourceapi", "resources.go"): {
|
||||
"unified.RefreshCanonicalMetadata(&resourceCopy)",
|
||||
"unified.RefreshCanonicalMetadataSlice(paged)",
|
||||
"unified.RefreshCanonicalMetadataSlice(children)",
|
||||
@@ -1380,7 +1383,7 @@ func TestPolicyPostureSummaryIsOwnedByUnifiedResources(t *testing.T) {
|
||||
filepath.Join("..", "ai", "resource_context.go"): {
|
||||
"unifiedresources.SummarizePolicyPosture(allResources)",
|
||||
},
|
||||
filepath.Join("..", "api", "resources.go"): {
|
||||
filepath.Join("..", "api", "resourceapi", "resources.go"): {
|
||||
"resourcePolicyPostureAggregation(allResources)",
|
||||
"unified.ResourcePolicyPostureContract(unified.SummarizePolicyPosture(canonicalResources))",
|
||||
},
|
||||
@@ -2304,7 +2307,7 @@ func TestV6ReleaseFacingAPITestsCoverLegacyHostRejection(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
path: filepath.Join(repoRoot, "internal", "api", "resources_test.go"),
|
||||
path: filepath.Join(repoRoot, "internal", "api", "resourceapi", "resources_test.go"),
|
||||
requiredSnippets: []string{
|
||||
`/api/resources?type=host`,
|
||||
`unsupported type filter token(s): host`,
|
||||
@@ -2544,14 +2547,14 @@ func TestV6DirectHostAliasValidatorCoverage(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
path: filepath.Join(repoRoot, "internal", "api", "resources_test.go"),
|
||||
path: filepath.Join(repoRoot, "internal", "api", "resourceapi", "resources_test.go"),
|
||||
requiredSnippets: []string{
|
||||
`/api/resources?type=host`,
|
||||
`unsupported type filter token(s): host`,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: filepath.Join(repoRoot, "internal", "api", "resources_frontend_types_test.go"),
|
||||
path: filepath.Join(repoRoot, "internal", "api", "resourceapi", "resources_frontend_types_test.go"),
|
||||
requiredSnippets: []string{
|
||||
`unsupported host ignored by parser`,
|
||||
`TestUnsupportedResourceTypeFilterTokensRejectsLegacyAliases`,
|
||||
@@ -2608,7 +2611,7 @@ func TestV6DirectHostAliasValidatorCoverage(t *testing.T) {
|
||||
|
||||
func TestResourceAPIHotPathUsesSharedPresentationSnapshot(t *testing.T) {
|
||||
repoRoot := filepath.Join("..", "..")
|
||||
path := filepath.Join(repoRoot, "internal", "api", "resources.go")
|
||||
path := filepath.Join(repoRoot, "internal", "api", "resourceapi", "resources.go")
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -2637,7 +2640,7 @@ func TestResourceAPIHotPathUsesSharedPresentationSnapshot(t *testing.T) {
|
||||
|
||||
func TestCanonicalResourceOrderingContractsStayShared(t *testing.T) {
|
||||
repoRoot := filepath.Join("..", "..")
|
||||
resourcesPath := filepath.Join(repoRoot, "internal", "api", "resources.go")
|
||||
resourcesPath := filepath.Join(repoRoot, "internal", "api", "resourceapi", "resources.go")
|
||||
registryPath := filepath.Join(repoRoot, "internal", "unifiedresources", "registry.go")
|
||||
|
||||
resourcesSource, err := os.ReadFile(resourcesPath)
|
||||
@@ -2663,7 +2666,7 @@ func TestCanonicalResourceOrderingContractsStayShared(t *testing.T) {
|
||||
func TestBroadcastStateUsesSharedCanonicalResourceContract(t *testing.T) {
|
||||
repoRoot := filepath.Join("..", "..")
|
||||
typesPath := filepath.Join(repoRoot, "internal", "unifiedresources", "types.go")
|
||||
resourcesPath := filepath.Join(repoRoot, "internal", "api", "resources.go")
|
||||
resourcesPath := filepath.Join(repoRoot, "internal", "api", "resourceapi", "resources.go")
|
||||
monitorPath := filepath.Join(repoRoot, "internal", "monitoring", "monitor.go")
|
||||
|
||||
typesSource, err := os.ReadFile(typesPath)
|
||||
@@ -2826,16 +2829,16 @@ func TestGuestRRDPointCarriesOnlyRecordedGuestColumns(t *testing.T) {
|
||||
// unremovable. This is a source-shape guard because the leak is invisible at
|
||||
// runtime until a tenant is deleted or a data directory is torn down.
|
||||
func TestCachedResourceStoresHaveATenantReleasePath(t *testing.T) {
|
||||
resources, err := os.ReadFile("../api/resources.go")
|
||||
resources, err := os.ReadFile("../api/resourceapi/resources.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read resources.go: %v", err)
|
||||
}
|
||||
for _, fragment := range []string{
|
||||
"func (h *ResourceHandlers) CloseTenantStore(orgID string) error",
|
||||
"func (h *ResourceHandlers) CloseStores() error",
|
||||
"func (h *QueryService) CloseTenantStore(orgID string) error",
|
||||
"func (h *QueryService) CloseStores() error",
|
||||
} {
|
||||
if !strings.Contains(string(resources), fragment) {
|
||||
t.Errorf("internal/api/resources.go must expose %q so cached per-tenant stores can be released", fragment)
|
||||
t.Errorf("internal/api/resourceapi/resources.go must expose %q so cached per-tenant stores can be released", fragment)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user