diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index af396cfb16..f5eb5192ba 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -154,6 +154,10 @@ an add-only capacity posture. order instead of inheriting map order or page-local re-sorts, so install and runtime hydration do not present one resource ordering at first load and a different ordering after the first live refresh. + When lifecycle surfaces also hydrate from `/api/state`, that first-session + snapshot must carry the same canonical resource types and display names as + `/api/resources` instead of briefly showing legacy host aliases before the + first websocket-backed refresh lands. Persisted legacy hosted quickstart model IDs are therefore not lifecycle truth either: when shared settings helpers load or save historical quickstart values, they must normalize them back to diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 1758cec4f7..8b7b452c0d 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -155,6 +155,7 @@ when the disabled candidate no longer counts toward monitored-system capacity. 5. Route unified-resource action, lifecycle, and export audit reads through `internal/api/activity_audit_handlers.go`, `internal/api/router_routes_licensing.go`, and `internal/api/contract_test.go` together so the control-plane execution trail stays on a governed API contract instead of a store-only shape 6. Route dedicated unified-resource timeline and facet-bundle reads through `frontend-modern/src/api/resources.ts`, `internal/api/resources.go`, and `internal/api/contract_test.go` together so the backend facet contract and the frontend client stay aligned on one timeline-first surface, while capability and relationship detail stays backend-owned for AI correlation and change detection 7. Route unified-resource list ordering through `internal/api/resources.go`, `internal/api/contract_test.go`, and the owned unified-resource registry helpers together; list payloads must stay deterministic for equal-name resources by carrying one canonical `name -> type -> id` tie-break across cold seed, REST pagination, and websocket-backed refreshes instead of inheriting map order or page-local re-sorts + 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. 8. Route canonical AI intelligence summary and resource-intelligence reads through `frontend-modern/src/api/ai.ts`, `frontend-modern/src/stores/aiIntelligence.ts`, `frontend-modern/src/stores/aiIntelligenceSummaryModel.ts`, `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts`, `frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx`, the Patrol-owned section files under `frontend-modern/src/features/patrol/`, `frontend-modern/src/pages/AIIntelligence.tsx`, `internal/api/ai_handlers.go`, and `internal/api/contract_test.go` together so the summary card, store normalization owner, runtime hook, feature shell, section owners, route shell, and backend payload stay aligned on one governed surface, including the canonical recent-changes slice while keeping the learning counters backend-only coverage, so the summary page keeps Patrol health and findings primary and renders timeline, correlation, and policy-posture data as secondary investigation context rather than as a separate headline product metric and the Patrol findings empty-state behavior, so `0 active findings` only renders as a healthy frontend conclusion when the same governed AI summary contract still reports healthy overall health; degraded or not-fully-verified health predictions must flow through to the Patrol findings surface instead of being replaced by page-local "looks healthy" copy diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 38c05b122b..e1fa6c6c65 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -158,6 +158,13 @@ or restart discovery until the canonical mock runtime has accepted the requested mode change; rejected release-demo fixture enables must fail before any monitoring reset so the live preview does not blank itself on an unauthorized toggle. +That same monitoring owner also owns canonical unified-resource publication on +`/api/state` and the websocket `state.resources` hydrate path. Monitoring must +publish those resources from the same canonical unified snapshot that +`/api/resources` seeds in mock and live mode, rather than projecting a second +raw store-only inventory for broadcast. Otherwise cold hydrate and later +registry-backed refreshes can swap the operator-visible infrastructure set +under one running session. That same mock-runtime boundary also owns freshness while demos are running. The mock update loop must keep provider-backed TrueNAS and VMware records plus legacy PBS and PMG summaries on current `LastSeen` and health state each tick, diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 77b7d6aa38..8c12cbdd2e 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -83,6 +83,10 @@ querying, and the operator-facing storage health presentation layer. 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. + If those same surfaces cold-hydrate from websocket `state.resources`, the + state payload must publish the same canonical resource types and display + labels as `/api/resources` so storage and recovery do not momentarily switch + between legacy and canonical infrastructure identities within one session. That same adjacent API boundary now also owns SSO outbound discovery and metadata fetch trust: storage- and recovery-adjacent surfaces may share `internal/api/sso_outbound.go`, `internal/api/saml_service.go`, and `internal/api/oidc_service.go`, but they must not fork separate metadata/discovery HTTP clients, redirect policies, or credential-file read rules when they depend on shared backend auth helpers. 5. Route canonical storage/recovery resource selection through `frontend-modern/src/hooks/useUnifiedResources.ts` and the owning `unified-resources` contract That shared hook now also projects resource `clusterId` through the shared cluster-name helper, so storage and recovery links keep the same cluster-context label as other unified-resource consumers instead of rebuilding a local fallback chain. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index dfbaf91b75..dab3e4c29c 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -148,6 +148,11 @@ assembly branch. `name -> type -> id` order across registry reads, REST pagination, and websocket-backed refreshes so equal-name resources do not silently reshuffle between cold hydrate and later runtime updates + That same unified-resource owner also defines the canonical transport + projection for operator-facing resources: `/api/resources` and websocket + `state.resources` must share `ContractResourceType`, canonical display + names, and canonical cluster labels instead of publishing separate REST and + broadcast aliases for the same machine. `internal/unifiedresources/top_level_systems.go` Explicit linked-host correlation is canonical here: when Kubernetes node ingest has a resolved backing host agent, the registry must merge that node diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 6f79edfff1..d2f84203db 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -44,6 +44,12 @@ import ( "github.com/rcourtman/pulse-go-rewrite/pkg/reporting" ) +type resourceContractSnapshot struct { + ID string + Name string + Type string +} + type contractCapturingStreamingProvider struct { lastRequest providers.ChatRequest } @@ -8974,6 +8980,120 @@ func TestContract_ResourceListUsesDeterministicNameTieBreakers(t *testing.T) { } } +func TestContract_StateAndResourceListShareCanonicalMockResourceContract(t *testing.T) { + t.Setenv("PULSE_MOCK_MODE", "true") + mock.SetEnabled(true) + t.Cleanup(func() { mock.SetEnabled(false) }) + + dataPath := t.TempDir() + hashedPassword, err := authpkg.HashPassword("password") + if err != nil { + t.Fatalf("hash password: %v", err) + } + + cfg := &config.Config{ + DataPath: dataPath, + MultiTenantEnabled: true, + AuthUser: "admin", + AuthPass: hashedPassword, + } + + InitSessionStore(dataPath) + InitCSRFStore(dataPath) + + monitor, err := monitoring.New(cfg) + if err != nil { + t.Fatalf("new monitor: %v", err) + } + t.Cleanup(func() { monitor.Stop() }) + + router := &Router{ + config: cfg, + monitor: monitor, + persistence: config.NewConfigPersistence(dataPath), + } + handlers := NewResourceHandlers(cfg) + handlers.SetStateProvider(monitor) + + stateReq := httptest.NewRequest(http.MethodGet, "/api/state", nil) + stateReq.SetBasicAuth("admin", "password") + stateRec := httptest.NewRecorder() + router.handleState(stateRec, stateReq) + if stateRec.Code != http.StatusOK { + t.Fatalf("/api/state status = %d, body=%s", stateRec.Code, stateRec.Body.String()) + } + + var state models.StateFrontend + if err := json.NewDecoder(stateRec.Body).Decode(&state); err != nil { + t.Fatalf("decode /api/state: %v", err) + } + + listReq := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent,docker-host,pbs,pmg,k8s-cluster,k8s-node&page=1&limit=100", nil) + listRec := httptest.NewRecorder() + handlers.HandleListResources(listRec, listReq) + if listRec.Code != http.StatusOK { + t.Fatalf("/api/resources status = %d, body=%s", listRec.Code, listRec.Body.String()) + } + + var listResp ResourcesResponse + if err := json.NewDecoder(listRec.Body).Decode(&listResp); err != nil { + t.Fatalf("decode /api/resources: %v", err) + } + + allowedTypes := map[string]bool{ + "agent": true, + "docker-host": true, + "k8s-cluster": true, + "k8s-node": true, + "pbs": true, + "pmg": true, + } + + stateContracts := make(map[string]resourceContractSnapshot) + for _, resource := range state.Resources { + if !allowedTypes[resource.Type] { + continue + } + if resource.Type == "node" { + t.Fatalf("/api/state published legacy resource type %#v", resource) + } + if resource.Name == "pve1" || resource.Name == "edge-apps-01" { + t.Fatalf("/api/state published legacy display name %#v", resource) + } + stateContracts[resource.ID] = resourceContractSnapshot{ + ID: resource.ID, + Name: resource.Name, + Type: resource.Type, + } + } + + listContracts := make(map[string]resourceContractSnapshot) + for _, resource := range listResp.Data { + if !allowedTypes[string(resource.Type)] { + continue + } + listContracts[resource.ID] = resourceContractSnapshot{ + ID: resource.ID, + Name: resource.Name, + Type: string(resource.Type), + } + } + + if !reflect.DeepEqual(stateContracts, listContracts) { + t.Fatalf("/api/state and /api/resources resource contracts diverged: state=%#v resources=%#v", stateContracts, listContracts) + } + foundCanonicalProxmoxName := false + for _, resource := range stateContracts { + if resource.Name == "West Production A" && resource.Type == "agent" { + foundCanonicalProxmoxName = true + break + } + } + if !foundCanonicalProxmoxName { + t.Fatalf("expected canonical proxmox resource contract in /api/state, got %#v", stateContracts) + } +} + func TestContract_ResourceListCarriesTimelineAndCapabilityContracts(t *testing.T) { now := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC) occurredAt := now.Add(-2 * time.Minute) diff --git a/internal/api/resources.go b/internal/api/resources.go index 14863a13e6..f14333c56b 100644 --- a/internal/api/resources.go +++ b/internal/api/resources.go @@ -2258,24 +2258,7 @@ func attachMetricsTarget(resource *unified.Resource, registry *unified.ResourceR // resourceContractType maps internal unified resource shapes onto the canonical // REST resource-type contract without exposing legacy aliases. func resourceContractType(r unified.Resource) unified.ResourceType { - canonicalType := unified.CanonicalResourceType(r.Type) - switch canonicalType { - case unified.ResourceTypeAgent: - if r.Proxmox != nil || r.Agent != nil { - return "agent" - } - if r.Docker != nil { - return "docker-host" - } - return "agent" - case unified.ResourceTypeSystemContainer: - return "system-container" - case unified.ResourceTypeAppContainer: - return "app-container" - default: - // Other resource types already match their canonical API names. - return canonicalType - } + return unified.ContractResourceType(r) } // applyResourceContractTypes rewrites Type fields on the response slice so the diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 7e29ab0a81..695e9d3461 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -3712,11 +3712,11 @@ func (m *Monitor) buildBroadcastFrontendStateFromSnapshot(snapshot models.StateS frontendState.ActiveAlerts = liveAlerts } } - unifiedResources := m.getUnifiedResourcesForBroadcast() - frontendState.Resources = convertResourcesForBroadcast(unifiedResources) - frontendState.ConnectedInfrastructure = buildConnectedInfrastructure(unifiedResources, snapshot) - if freshness := m.currentUnifiedResourceFreshness(); !freshness.IsZero() { - frontendState.LastUpdate = freshness.UnixMilli() + unifiedView := m.currentUnifiedStateView() + frontendState.Resources = convertResourcesForBroadcast(unifiedView.resources) + frontendState.ConnectedInfrastructure = buildConnectedInfrastructure(unifiedView.resources, snapshot) + if !unifiedView.freshness.IsZero() { + frontendState.LastUpdate = unifiedView.freshness.UnixMilli() } return frontendState } @@ -4854,6 +4854,7 @@ func convertResourcesForBroadcast(allResources []unifiedresources.Resource) []mo } func monitorResourceToConvertInput(resource unifiedresources.Resource) models.ResourceConvertInput { + unifiedresources.RefreshCanonicalMetadata(&resource) resourceType := monitorFrontendResourceType(resource) name, displayName := monitorFrontendNames(resource, resourceType) platformID := monitorPlatformID(resource, resourceType) @@ -4890,73 +4891,29 @@ func monitorResourceToConvertInput(resource unifiedresources.Resource) models.Re } func monitorFrontendResourceType(resource unifiedresources.Resource) string { - switch resource.Type { - case unifiedresources.ResourceTypeVM: - return "vm" - case unifiedresources.ResourceTypeSystemContainer: - return "system-container" - case unifiedresources.ResourceTypeAppContainer: - return "app-container" - case unifiedresources.ResourceTypeK8sCluster: - return "k8s-cluster" - case unifiedresources.ResourceTypeK8sNode: - return "k8s-node" - case unifiedresources.ResourceTypePod: - return "pod" - case unifiedresources.ResourceTypeK8sDeployment: - return "k8s-deployment" - case unifiedresources.ResourceTypePBS: - return "pbs" - case unifiedresources.ResourceTypePMG: - return "pmg" - case unifiedresources.ResourceTypeStorage: - return "storage" - case unifiedresources.ResourceTypeCeph: - return "pool" - case unifiedresources.ResourceTypeAgent: - if resource.Proxmox != nil { - return "node" - } - if resource.Docker != nil { - return "docker-host" - } - return "agent" - default: - return string(resource.Type) - } + return string(unifiedresources.ContractResourceType(resource)) } func monitorFrontendNames(resource unifiedresources.Resource, resourceType string) (string, string) { - name := strings.TrimSpace(resource.Name) - displayName := "" - - switch resourceType { - case "node": - if resource.Proxmox != nil && resource.Proxmox.NodeName != "" && !strings.EqualFold(resource.Proxmox.NodeName, name) { - displayName = name - name = resource.Proxmox.NodeName - } - case "agent": - if resource.Agent != nil && resource.Agent.Hostname != "" && !strings.EqualFold(resource.Agent.Hostname, name) { - displayName = name - name = resource.Agent.Hostname - } - case "docker-host": - if resource.Docker != nil && resource.Docker.Hostname != "" && !strings.EqualFold(resource.Docker.Hostname, name) { - displayName = name - name = resource.Docker.Hostname - } - } - + name := strings.TrimSpace(unifiedresources.ResourceDisplayName(resource)) if name == "" { name = resource.ID } - return name, strings.TrimSpace(displayName) + return name, name } func monitorPlatformType(resource unifiedresources.Resource, resourceType string) string { + if resource.Proxmox != nil { + return "proxmox-pve" + } + if resource.VMware != nil { + return "vmware-vsphere" + } + if resource.TrueNAS != nil { + return "truenas" + } switch resourceType { - case "node", "vm", "system-container", "storage", "pool": + case "vm", "system-container", "storage", "pool": return "proxmox-pve" case "docker-host", "app-container": return "docker" @@ -5086,6 +5043,10 @@ func monitorIsWorkloadType(resourceType string) bool { } func monitorClusterID(resource unifiedresources.Resource) string { + if clusterID := strings.TrimSpace(unifiedresources.ResourceClusterName(resource)); clusterID != "" { + return clusterID + } + if resource.Docker != nil && resource.Docker.Swarm != nil { if name := strings.TrimSpace(resource.Docker.Swarm.ClusterName); name != "" { return name @@ -5095,17 +5056,7 @@ func monitorClusterID(resource unifiedresources.Resource) string { } } - clusterID := strings.TrimSpace(resource.Identity.ClusterName) - if clusterID == "" && resource.Proxmox != nil { - clusterID = strings.TrimSpace(resource.Proxmox.ClusterName) - } - if clusterID == "" && resource.Kubernetes != nil { - clusterID = strings.TrimSpace(resource.Kubernetes.ClusterID) - } - if clusterID == "" && resource.Kubernetes != nil { - clusterID = strings.TrimSpace(resource.Kubernetes.ClusterName) - } - return clusterID + return "" } func monitorMetricInput(metric *unifiedresources.MetricValue) *models.ResourceMetricInput { @@ -5284,7 +5235,20 @@ func monitorPlatformData(resource unifiedresources.Resource, resourceType string case "system-container", "oci-container": payload = buildProxmoxVMPayload(resource) case "agent": - if resource.Agent != nil { + if resource.Proxmox != nil { + payload = map[string]interface{}{ + "instance": resource.Proxmox.Instance, + "host": "", + "guestURL": "", + "pveVersion": resource.Proxmox.PVEVersion, + "kernelVersion": resource.Proxmox.KernelVersion, + "cpuInfo": resource.Proxmox.CPUInfo, + "loadAverage": []float64{}, + "isClusterMember": resource.Proxmox.ClusterName != "", + "clusterName": resource.Proxmox.ClusterName, + "connectionHealth": monitorSourceStatus(resource.SourceStatus, unifiedresources.SourceProxmox), + } + } else if resource.Agent != nil { payload = map[string]interface{}{ "platform": resource.Agent.Platform, "osName": resource.Agent.OSName, diff --git a/internal/monitoring/monitor_unified_state_test.go b/internal/monitoring/monitor_unified_state_test.go index 35a53f99d3..6d257569d4 100644 --- a/internal/monitoring/monitor_unified_state_test.go +++ b/internal/monitoring/monitor_unified_state_test.go @@ -81,6 +81,15 @@ func hasUnifiedResourceName(resources []unifiedresources.Resource, name string) return false } +func hasFrontendResourceName(resources []models.ResourceFrontend, name string) bool { + for _, resource := range resources { + if resource.Name == name { + return true + } + } + return false +} + func TestMonitorGetUnifiedReadStateOrSnapshotUsesStoreResourcesWithoutSnapshotFallback(t *testing.T) { now := time.Date(2026, 3, 6, 12, 0, 0, 0, time.UTC) store := &resourceOnlyStore{ @@ -231,6 +240,7 @@ func TestMonitorGetUnifiedReadStateOrSnapshotUsesCanonicalMockUnifiedResources(t } m := &Monitor{ + state: models.NewState(), resourceStore: store, alertManager: alerts.NewManager(), } @@ -257,6 +267,85 @@ func TestMonitorGetUnifiedReadStateOrSnapshotUsesCanonicalMockUnifiedResources(t } } +func TestMonitorBuildBroadcastFrontendStateUsesCanonicalMockUnifiedResources(t *testing.T) { + mock.SetEnabled(true) + t.Cleanup(func() { mock.SetEnabled(false) }) + + graph := mock.CurrentFixtureGraph() + legacyName := "" + if len(graph.State.VMs) > 0 { + legacyName = graph.State.VMs[0].Name + } else if len(graph.State.Containers) > 0 { + legacyName = graph.State.Containers[0].Name + } + if legacyName == "" { + t.Fatal("expected canonical mock graph to include at least one legacy resource") + } + + store := &resourceOnlyStore{ + resources: []unifiedresources.Resource{ + { + ID: "store-only-resource", + Type: unifiedresources.ResourceTypeAgent, + Name: "store-only-resource", + Status: unifiedresources.StatusOnline, + LastSeen: time.Date(2026, 3, 6, 14, 0, 0, 0, time.UTC), + Sources: []unifiedresources.DataSource{unifiedresources.SourceAgent}, + Identity: unifiedresources.ResourceIdentity{Hostnames: []string{"store-only-resource"}}, + }, + }, + } + + m := &Monitor{ + state: models.NewState(), + resourceStore: store, + alertManager: alerts.NewManager(), + } + defer m.alertManager.Stop() + + expectedResources, freshness := m.UnifiedResourceSnapshot() + frontend := m.BuildBroadcastFrontendState() + + if hasFrontendResourceName(frontend.Resources, "store-only-resource") { + t.Fatal("expected mock-mode broadcast state to ignore live resource store data") + } + if !hasFrontendResourceName(frontend.Resources, "truenas-main") { + t.Fatalf("expected mock-mode broadcast state to include TrueNAS mock resources, got %#v", frontend.Resources) + } + if hasFrontendResourceName(frontend.Resources, "pve1") { + t.Fatalf("expected mock-mode broadcast state to stop publishing legacy proxmox node labels, got %#v", frontend.Resources) + } + if hasFrontendResourceName(frontend.Resources, "edge-apps-01") { + t.Fatalf("expected mock-mode broadcast state to stop publishing legacy docker host labels, got %#v", frontend.Resources) + } + if !hasFrontendResourceName(frontend.Resources, "esxi-01.lab.local") { + t.Fatalf("expected mock-mode broadcast state to include VMware mock resources, got %#v", frontend.Resources) + } + if !hasFrontendResourceName(frontend.Resources, "West Production A") { + t.Fatalf("expected mock-mode broadcast state to include canonical proxmox host label, got %#v", frontend.Resources) + } + if !hasFrontendResourceName(frontend.Resources, "Edge Apps 01") { + t.Fatalf("expected mock-mode broadcast state to include canonical docker host label, got %#v", frontend.Resources) + } + if !hasFrontendResourceName(frontend.Resources, legacyName) { + t.Fatalf("expected mock-mode broadcast state to include legacy mock resource %q, got %#v", legacyName, frontend.Resources) + } + if len(frontend.Resources) != len(expectedResources) { + t.Fatalf("expected mock-mode broadcast state to mirror canonical unified resource count %d, got %d", len(expectedResources), len(frontend.Resources)) + } + if freshness.IsZero() { + t.Fatal("expected canonical mock unified snapshot freshness") + } + if frontend.LastUpdate != freshness.UnixMilli() { + t.Fatalf("expected broadcast lastUpdate %d from canonical mock freshness, got %d", freshness.UnixMilli(), frontend.LastUpdate) + } + for _, resource := range frontend.Resources { + if resource.Type == "node" { + t.Fatalf("expected canonical broadcast resource types only, got legacy node resource %#v", resource) + } + } +} + func TestMonitorMonitoredSystemUsageWaitsForSupplementalReadinessAndStoreFreshness(t *testing.T) { monitor := &Monitor{} monitor.SetResourceStore(unifiedresources.NewMonitorAdapter(nil)) diff --git a/internal/unifiedresources/canonical_ids_types_test.go b/internal/unifiedresources/canonical_ids_types_test.go index 0811ddb917..a5305fe22a 100644 --- a/internal/unifiedresources/canonical_ids_types_test.go +++ b/internal/unifiedresources/canonical_ids_types_test.go @@ -16,6 +16,62 @@ func TestCanonicalResourceTypeDoesNotAliasHost(t *testing.T) { } } +func TestContractResourceType(t *testing.T) { + tests := []struct { + name string + resource Resource + want ResourceType + }{ + { + name: "proxmox agent uses canonical agent contract type", + resource: Resource{ + Type: ResourceTypeAgent, + Proxmox: &ProxmoxData{}, + }, + want: ResourceTypeAgent, + }, + { + name: "docker host uses docker-host contract type", + resource: Resource{ + Type: ResourceTypeAgent, + Docker: &DockerData{}, + }, + want: ResourceType("docker-host"), + }, + { + name: "vmware host stays agent", + resource: Resource{ + Type: ResourceTypeAgent, + VMware: &VMwareData{}, + }, + want: ResourceTypeAgent, + }, + { + name: "truenas host stays agent", + resource: Resource{ + Type: ResourceTypeAgent, + TrueNAS: &TrueNASData{}, + }, + want: ResourceTypeAgent, + }, + { + name: "workload passthrough remains canonical", + resource: Resource{ + Type: ResourceTypeVM, + }, + want: ResourceTypeVM, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ContractResourceType(tt.resource); got != tt.want { + t.Fatalf("ContractResourceType(%+v) = %q, want %q", tt.resource, got, tt.want) + } + }) + } +} + func TestCanonicalResourceIDDoesNotAliasLegacyHostPrefixes(t *testing.T) { cases := []struct { name string diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index 152b01dfd1..240b82c614 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -1767,3 +1767,42 @@ func TestCanonicalResourceOrderingContractsStayShared(t *testing.T) { t.Fatalf("%s: cached unified resource views must share the canonical deterministic name ordering helper", filepath.ToSlash(registryPath)) } } + +func TestBroadcastStateUsesSharedCanonicalResourceContract(t *testing.T) { + repoRoot := filepath.Join("..", "..") + typesPath := filepath.Join(repoRoot, "internal", "unifiedresources", "types.go") + resourcesPath := filepath.Join(repoRoot, "internal", "api", "resources.go") + monitorPath := filepath.Join(repoRoot, "internal", "monitoring", "monitor.go") + + typesSource, err := os.ReadFile(typesPath) + if err != nil { + t.Fatalf("failed to read %s: %v", typesPath, err) + } + resourcesSource, err := os.ReadFile(resourcesPath) + if err != nil { + t.Fatalf("failed to read %s: %v", resourcesPath, err) + } + monitorSource, err := os.ReadFile(monitorPath) + if err != nil { + t.Fatalf("failed to read %s: %v", monitorPath, err) + } + + if !strings.Contains(string(typesSource), "func ContractResourceType(resource Resource) ResourceType {") { + t.Fatalf("%s: unified resource contract type helper must remain canonical", filepath.ToSlash(typesPath)) + } + if !strings.Contains(string(resourcesSource), "return unified.ContractResourceType(r)") { + t.Fatalf("%s: /api/resources must derive external resource types from unified.ContractResourceType", filepath.ToSlash(resourcesPath)) + } + + requiredMonitorSnippets := []string{ + "unifiedView := m.currentUnifiedStateView()", + "return string(unifiedresources.ContractResourceType(resource))", + "unifiedresources.ResourceDisplayName(resource)", + "unifiedresources.ResourceClusterName(resource)", + } + for _, snippet := range requiredMonitorSnippets { + if !strings.Contains(string(monitorSource), snippet) { + t.Fatalf("%s: websocket/state broadcast must contain %q", filepath.ToSlash(monitorPath), snippet) + } + } +} diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index 39da913235..51b982a22c 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -131,6 +131,27 @@ func CanonicalResourceType(rt ResourceType) ResourceType { return normalized } +// ContractResourceType maps an internal unified resource onto the canonical +// external transport type used by REST and websocket/state payloads. +func ContractResourceType(resource Resource) ResourceType { + switch CanonicalResourceType(resource.Type) { + case ResourceTypeAgent: + if resource.Proxmox != nil || resource.Agent != nil || resource.TrueNAS != nil || resource.VMware != nil { + return ResourceTypeAgent + } + if resource.Docker != nil { + return ResourceType("docker-host") + } + return ResourceTypeAgent + case ResourceTypeSystemContainer: + return ResourceTypeSystemContainer + case ResourceTypeAppContainer: + return ResourceTypeAppContainer + default: + return CanonicalResourceType(resource.Type) + } +} + // ResourceStatus represents the high-level status of a resource. type ResourceStatus string