diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 017af4ee3..84655d858 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -1170,6 +1170,13 @@ Name normalization for that contract must treat Docker's leading `/` prefix as presentation noise rather than identity, so routine recreate flows keep metadata continuity when one report spells the same container as `/app` and a later report spells it as `app`. +Docker-managed app-container web-interface metadata must use the same +host-plus-normalized-container-name identity (`app-container::name:`) +as the stable synchronization key. Monitoring must migrate current runtime-key +Docker metadata and legacy app-container guest metadata to that key when a +Docker report is ingested, then prefer that stable guest key when projecting +unified app-container custom URLs. Runtime container IDs remain action and +metric identities, not the persistent URL metadata identity. The same applies to proxmox topology coordinates exposed through typed views: node, cluster, and instance accessors must return canonical trimmed values so monitoring consumers do not fork topology grouping or labeling on `" pve-a "` diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index d480f6bd3..8717f9f26 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -1215,6 +1215,13 @@ owns workload-route synchronization, deep-link normalization, and route-scoped filter contracts. Future workload hot-path changes must extend through those owners within the Workloads surface, and new overview-route work must not accrete back into `frontend-modern/src/components/Workloads/WorkloadsSurface.tsx`. +Docker-managed app-container saved web-interface URLs are a metadata identity +exception inside that owner boundary: Workloads must derive their metadata key +from Docker host plus normalized container name, read stable-first with legacy +runtime-key fallback, and keep intentional empty stable records so stale +runtime-key URLs do not reappear after a user clears the link. This must not +change the canonical workload ID used for selection, routing, charts, actions, +or hover correlation. That same route-owned filter contract now also includes canonical workload `platform` scoping for API-backed runtimes. Workloads URL-sync, filter-option assembly, and workload drill-down routes must preserve @@ -1756,7 +1763,10 @@ but they must not add a second discovery request, route-level Suspense dependency, row-time endpoint probe, or auto-save mutation. Suggested URLs remain copy/open/adopt affordances only; persisting them as a workload link stays a user action through the existing Web Interface URL -field. +field. For Docker-managed app-containers, that field must receive the stable +host-plus-container-name metadata ID rather than the runtime container ID, while +the row and drawer still use the canonical workload ID for all non-metadata +state. The compact provenance marker for those values is presentation-only. It may identify already-loaded Discovery fields, but must not introduce additional discovery fetches, provider lookups, endpoint probes, or diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx index 2565313c3..5cfd9f88d 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx @@ -5,7 +5,7 @@ import type { WorkloadGuest } from '@/types/workloads'; import type { Memory, Disk, GuestNetworkInterface } from '@/types/api'; import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery'; import { aiChatStore } from '@/stores/aiChat'; -import { getCanonicalWorkloadId } from '@/utils/workloads'; +import { getCanonicalWorkloadId, getWorkloadMetadataId } from '@/utils/workloads'; import { getDiscoveryProvenanceTitle } from '@/utils/discoveryPresentation'; import guestDrawerSource from './GuestDrawer.tsx?raw'; @@ -994,6 +994,25 @@ describe('GuestDrawer', () => { ); }); + it('passes stable app-container metadataId for Docker-managed workload URLs', () => { + const guest = makeGuest({ + id: 'app-container-synthetic-hash', + name: 'grafana', + type: 'app-container', + workloadType: 'app-container', + platformType: 'docker', + dockerHostId: 'docker-main', + containerId: 'container-runtime-id', + }); + + render(() => ); + + expect(screen.getByTestId('url-id').textContent).toBe(getWorkloadMetadataId(guest)); + expect(screen.getByTestId('url-id').textContent).toBe( + 'app-container:docker-main:name:grafana', + ); + }); + it('builds canonical id from instance:node:vmid when id is empty', () => { render(() => ( = (props) => { setHistoryRange, showInGuestAgentInstallCue, switchTab, + webInterfaceMetadataId, webInterfaceTargetLabel, workloadActionAgentTitle, } = useGuestDrawerState(props); @@ -152,6 +153,7 @@ export const GuestDrawer: Component = (props) => { discoveryIdentifiedSummary={discoveryIdentifiedSummary()} hasWorkloadActionAgent={hasWorkloadActionAgent()} showInGuestAgentInstallCue={showInGuestAgentInstallCue()} + webInterfaceMetadataId={webInterfaceMetadataId()} webInterfaceTargetLabel={webInterfaceTargetLabel()} workloadActionAgentTitle={workloadActionAgentTitle()} /> diff --git a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx index 9dd2d0673..e21946bdd 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx @@ -49,6 +49,7 @@ interface GuestDrawerOverviewProps { } | null; diskThresholds?: MetricDisplayThresholds | null; discoveryIdentifiedSummary?: DiscoveryIdentifiedSummary | null; + webInterfaceMetadataId: string; webInterfaceTargetLabel: string; workloadActionAgentTitle: string; } @@ -465,10 +466,10 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
props.onCustomUrlChange?.(props.guestId, url)} + onCustomUrlChange={(url) => props.onCustomUrlChange?.(props.webInterfaceMetadataId, url)} suggestedUrl={props.discoveryIdentifiedSummary?.suggestedUrl} suggestedUrlReasonText={props.discoveryIdentifiedSummary?.suggestedUrlReasonText} suggestedUrlReasonTitle={props.discoveryIdentifiedSummary?.suggestedUrlReasonTitle} @@ -479,7 +480,12 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
- +
}> {(guest) => { const guestId = createMemo(() => getCanonicalWorkloadId(guest())); + const metadataIdCandidates = createMemo(() => + getWorkloadMetadataIdCandidates(guest()), + ); const detailControlsId = createMemo(() => buildSummaryDisclosureControlsId(guestId()), ); - const metadata = () => - props.guestMetadata()[guestId()] || - props.guestMetadata()[`${guest().instance}:${guest().node}:${guest().vmid}`]; + const metadata = () => { + const byId = props.guestMetadata(); + for (const metadataId of metadataIdCandidates()) { + if (metadataId && byId[metadataId]) { + return byId[metadataId]; + } + } + return byId[`${guest().instance}:${guest().node}:${guest().vmid}`]; + }; const parentNode = () => node() ?? props.guestParentNodeMap()[guestId()]; const parentNodeOnline = () => { const pn = parentNode(); @@ -567,6 +576,7 @@ export function WorkloadPanel(props: WorkloadPanelProps) { props.setSelectedGuestId(null)} + metadataId={metadataIdCandidates()[0] || guestId()} customUrl={metadata()?.customUrl} nestedWorkloadContext={nestedWorkloadContext()} onCustomUrlChange={props.handleCustomUrlUpdate} diff --git a/frontend-modern/src/components/Workloads/__tests__/useWorkloadGuestMetadataState.test.ts b/frontend-modern/src/components/Workloads/__tests__/useWorkloadGuestMetadataState.test.ts new file mode 100644 index 000000000..a5f62f04c --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/useWorkloadGuestMetadataState.test.ts @@ -0,0 +1,53 @@ +import { createRoot } from 'solid-js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useWorkloadGuestMetadataState } from '../useWorkloadGuestMetadataState'; + +vi.mock('@/api/guestMetadata', () => ({ + GuestMetadataAPI: { + getAllMetadata: vi.fn().mockResolvedValue({}), + }, +})); + +vi.mock('@/utils/apiClient', () => ({ + getOrgID: () => 'default', +})); + +vi.mock('@/stores/events', () => ({ + eventBus: { + on: vi.fn(() => () => undefined), + }, +})); + +describe('useWorkloadGuestMetadataState', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('keeps an empty stable app-container metadata record when clearing a URL', () => { + createRoot((dispose) => { + const state = useWorkloadGuestMetadataState(); + + state.handleCustomUrlUpdate('app-container:docker-main:name:grafana', ''); + + expect(state.guestMetadata()['app-container:docker-main:name:grafana']).toEqual({ + id: 'app-container:docker-main:name:grafana', + customUrl: '', + }); + + dispose(); + }); + }); + + it('does not create empty records for non-stable metadata ids', () => { + createRoot((dispose) => { + const state = useWorkloadGuestMetadataState(); + + state.handleCustomUrlUpdate('app-container:docker-main:runtime-id', ''); + + expect(state.guestMetadata()['app-container:docker-main:runtime-id']).toBeUndefined(); + + dispose(); + }); + }); +}); diff --git a/frontend-modern/src/components/Workloads/guestDrawerModel.ts b/frontend-modern/src/components/Workloads/guestDrawerModel.ts index 3d9521c63..d32c68c76 100644 --- a/frontend-modern/src/components/Workloads/guestDrawerModel.ts +++ b/frontend-modern/src/components/Workloads/guestDrawerModel.ts @@ -16,6 +16,7 @@ type Guest = WorkloadGuest; export interface GuestDrawerProps { guest: Guest; onClose: () => void; + metadataId?: string; customUrl?: string; onCustomUrlChange?: (guestId: string, url: string) => void; parentNodeOnline?: boolean; diff --git a/frontend-modern/src/components/Workloads/useGuestDrawerState.ts b/frontend-modern/src/components/Workloads/useGuestDrawerState.ts index 3490afebb..c0486a39d 100644 --- a/frontend-modern/src/components/Workloads/useGuestDrawerState.ts +++ b/frontend-modern/src/components/Workloads/useGuestDrawerState.ts @@ -21,6 +21,7 @@ import { getDiscoveryResourceTypeForWorkload, hasDiscoverySupportForWorkload, getWebInterfaceTargetLabelForWorkload, + getWorkloadMetadataId, } from '@/utils/workloads'; import { @@ -112,6 +113,9 @@ export function useGuestDrawerState(props: GuestDrawerProps) { const webInterfaceTargetLabel = createMemo(() => getWebInterfaceTargetLabelForWorkload(props.guest), ); + const webInterfaceMetadataId = createMemo( + () => props.metadataId?.trim() || getWorkloadMetadataId(props.guest), + ); // Load the existing discovery record (if any) so the Overview can surface // the identified service alongside CPU/agent/OS metadata. This is a passive @@ -225,6 +229,7 @@ export function useGuestDrawerState(props: GuestDrawerProps) { copyAgentContext, switchTab, setHistoryRange, + webInterfaceMetadataId, webInterfaceTargetLabel, }; } diff --git a/frontend-modern/src/components/Workloads/useWorkloadGuestMetadataState.ts b/frontend-modern/src/components/Workloads/useWorkloadGuestMetadataState.ts index 6e1ad879f..3c6b472db 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadGuestMetadataState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadGuestMetadataState.ts @@ -32,6 +32,9 @@ const instrumentationEnabled = import.meta.env.DEV && typeof performance !== 'un const guestMetadataStorageKeyForOrg = (orgScope: string): string => `${STORAGE_KEYS.GUEST_METADATA}.${encodeURIComponent(orgScope)}`; +const isStableAppContainerMetadataId = (guestId: string): boolean => + guestId.trim().startsWith('app-container:') && guestId.includes(':name:'); + const readGuestMetadataCache = (storageKey: string): GuestMetadataRecord => { if (cachedGuestMetadata && cachedGuestMetadataStorageKey === storageKey) { return cachedGuestMetadata; @@ -231,7 +234,9 @@ export function useWorkloadGuestMetadataState() { const trimmedUrl = url.trim(); const nextUrl = trimmedUrl === '' ? undefined : trimmedUrl; const currentUrl = guestMetadata()[guestId]?.customUrl; - if (currentUrl === nextUrl) { + const shouldKeepStableClearRecord = + nextUrl === undefined && isStableAppContainerMetadataId(guestId); + if (currentUrl === nextUrl && !shouldKeepStableClearRecord) { return; } @@ -239,6 +244,16 @@ export function useWorkloadGuestMetadataState() { const previousEntry = prev[guestId]; if (nextUrl === undefined) { + if (isStableAppContainerMetadataId(guestId)) { + return { + ...prev, + [guestId]: { + ...(previousEntry || { id: guestId }), + customUrl: '', + }, + }; + } + if (!previousEntry || typeof previousEntry.customUrl === 'undefined') { return prev; } diff --git a/frontend-modern/src/utils/__tests__/workloads.test.ts b/frontend-modern/src/utils/__tests__/workloads.test.ts index c7b176297..04579cb32 100644 --- a/frontend-modern/src/utils/__tests__/workloads.test.ts +++ b/frontend-modern/src/utils/__tests__/workloads.test.ts @@ -9,6 +9,8 @@ import { getWorkloadMetricsKind, getCanonicalWorkloadId, getCanonicalWorkloadIdForResource, + getWorkloadMetadataId, + getWorkloadMetadataIdCandidates, isDockerManagedAppContainer, getWebInterfaceTargetLabelForWorkload, } from '@/utils/workloads'; @@ -232,6 +234,45 @@ describe('buildCanonicalNodeScopedWorkloadId', () => { }); }); +describe('getWorkloadMetadataId', () => { + it('uses a stable host-and-name key for Docker-managed app-container URLs', () => { + const guest = { + id: 'app-container:docker-main:container-123', + name: '/grafana', + type: 'docker', + workloadType: 'app-container' as const, + dockerHostId: 'docker-main', + instance: '', + node: '', + vmid: 0, + }; + + expect(getWorkloadMetadataId(guest)).toBe('app-container:docker-main:name:grafana'); + expect(getWorkloadMetadataIdCandidates(guest)).toEqual([ + 'app-container:docker-main:name:grafana', + 'app-container:docker-main:container-123', + ]); + }); + + it('falls back to the canonical workload id when a Docker host id is unavailable', () => { + const guest = { + id: 'app-container:truenas-main:nextcloud', + name: 'nextcloud', + type: 'app-container', + workloadType: 'app-container' as const, + platformType: 'truenas', + instance: '', + node: '', + vmid: 0, + }; + + expect(getWorkloadMetadataId(guest)).toBe('app-container:truenas-main:nextcloud'); + expect(getWorkloadMetadataIdCandidates(guest)).toEqual([ + 'app-container:truenas-main:nextcloud', + ]); + }); +}); + describe('getCanonicalWorkloadIdForResource', () => { it('uses proxmox guest identity for vm and system-container resources', () => { expect( diff --git a/frontend-modern/src/utils/workloads.ts b/frontend-modern/src/utils/workloads.ts index 3e764be98..8bb3e700b 100644 --- a/frontend-modern/src/utils/workloads.ts +++ b/frontend-modern/src/utils/workloads.ts @@ -148,6 +148,42 @@ export const getCanonicalWorkloadId = ( return guest.id; }; +export const buildAppContainerMetadataId = ({ + dockerHostId, + name, +}: Pick): string | null => { + const hostId = (dockerHostId || '').trim(); + const containerName = (name || '').trim().replace(/^\/+/, ''); + if (!hostId || !containerName) return null; + return `app-container:${hostId}:name:${containerName}`; +}; + +export const getWorkloadMetadataIdCandidates = ( + guest: Pick< + WorkloadGuest, + 'id' | 'workloadType' | 'type' | 'instance' | 'node' | 'vmid' | 'dockerHostId' | 'name' + >, +): string[] => { + const canonicalId = getCanonicalWorkloadId(guest); + const candidates: string[] = []; + + if (isDockerManagedAppContainer(guest)) { + const appContainerId = buildAppContainerMetadataId(guest); + if (appContainerId) candidates.push(appContainerId); + } + + if (canonicalId) candidates.push(canonicalId); + + return [...new Set(candidates)]; +}; + +export const getWorkloadMetadataId = ( + guest: Pick< + WorkloadGuest, + 'id' | 'workloadType' | 'type' | 'instance' | 'node' | 'vmid' | 'dockerHostId' | 'name' + >, +): string => getWorkloadMetadataIdCandidates(guest)[0] || getCanonicalWorkloadId(guest); + type NodeScopedWorkloadIdentity = { instance?: string | null; node?: string | null; diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index dbe199f87..4a6a89a6a 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -1652,7 +1652,7 @@ func TestDockerReportPreservesMetadataAcrossObservedContainerRecreation(t *testi requiredMigrationSnippets := []string{ "func (m *Monitor) migrateDockerContainerMetadataForRecreatedContainers(", "normalizeDockerContainerMetadataIdentity(container.Name)", - `strings.TrimSpace(strings.TrimPrefix(name, "/"))`, + `strings.TrimLeft(strings.TrimSpace(name), "/")`, "m.CopyDockerContainerMetadata(hostID, previousContainer.ID, container.ID)", } for _, snippet := range requiredMigrationSnippets { diff --git a/internal/monitoring/docker_metadata_migration.go b/internal/monitoring/docker_metadata_migration.go index 8c0f81509..4a8207257 100644 --- a/internal/monitoring/docker_metadata_migration.go +++ b/internal/monitoring/docker_metadata_migration.go @@ -7,6 +7,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/rs/zerolog/log" ) @@ -30,8 +31,8 @@ func (m *Monitor) CopyDockerContainerMetadata(hostID, oldContainerID, newContain return nil } - oldKey := fmt.Sprintf("%s:container:%s", hostID, oldContainerID) - newKey := fmt.Sprintf("%s:container:%s", hostID, newContainerID) + oldKey := dockerContainerRuntimeMetadataKey(hostID, oldContainerID) + newKey := dockerContainerRuntimeMetadataKey(hostID, newContainerID) oldMeta := m.dockerMetadataStore.Get(oldKey) if oldMeta == nil { @@ -73,6 +74,181 @@ func (m *Monitor) CopyDockerContainerMetadata(hostID, oldContainerID, newContain return m.dockerMetadataStore.Set(newKey, &merged) } +func dockerContainerRuntimeMetadataKey(hostID, containerID string) string { + hostID = strings.TrimSpace(hostID) + containerID = strings.TrimSpace(containerID) + if hostID == "" || containerID == "" { + return "" + } + return fmt.Sprintf("%s:container:%s", hostID, containerID) +} + +func dockerContainerNameMetadataKey(hostID, containerName string) string { + hostID = strings.TrimSpace(hostID) + containerName = normalizeDockerContainerMetadataIdentity(containerName) + if hostID == "" || containerName == "" { + return "" + } + return fmt.Sprintf("%s:container-name:%s", hostID, containerName) +} + +func dockerAppContainerMetadataKey(hostID, containerName string) string { + hostID = strings.TrimSpace(hostID) + containerName = normalizeDockerContainerMetadataIdentity(containerName) + if hostID == "" || containerName == "" { + return "" + } + return fmt.Sprintf("app-container:%s:name:%s", hostID, containerName) +} + +func dockerAppContainerLegacyResourceID(hostID, containerID string) string { + hostID = strings.TrimSpace(hostID) + containerID = strings.TrimSpace(containerID) + if hostID == "" || containerID == "" { + return "" + } + return unifiedresources.SourceSpecificID( + unifiedresources.ResourceTypeAppContainer, + unifiedresources.SourceDocker, + fmt.Sprintf("%s/container/%s", hostID, containerID), + ) +} + +func dockerAppContainerLegacyMetadataKey(hostID, containerID string) string { + hostID = strings.TrimSpace(hostID) + containerID = strings.TrimSpace(containerID) + if hostID == "" || containerID == "" { + return "" + } + return fmt.Sprintf("app-container:%s:%s", hostID, containerID) +} + +func dockerAppContainerGuestMetadataLegacyKeys(hostID, containerID string) []string { + candidates := []string{ + dockerAppContainerLegacyResourceID(hostID, containerID), + dockerAppContainerLegacyMetadataKey(hostID, containerID), + } + out := make([]string, 0, len(candidates)) + seen := make(map[string]struct{}, len(candidates)) + for _, candidate := range candidates { + candidate = strings.TrimSpace(candidate) + if candidate == "" { + continue + } + if _, ok := seen[candidate]; ok { + continue + } + seen[candidate] = struct{}{} + out = append(out, candidate) + } + return out +} + +func copyDockerMetadataAliasIfTargetMissing(store *config.DockerMetadataStore, sourceKey, targetKey string) error { + if store == nil || strings.TrimSpace(sourceKey) == "" || strings.TrimSpace(targetKey) == "" { + return nil + } + if store.Get(targetKey) != nil { + return nil + } + source := store.Get(sourceKey) + if source == nil { + return nil + } + clone := &config.DockerMetadata{ + CustomURL: source.CustomURL, + Description: source.Description, + } + if len(source.Tags) > 0 { + clone.Tags = append([]string(nil), source.Tags...) + } + if len(source.Notes) > 0 { + clone.Notes = append([]string(nil), source.Notes...) + } + return store.Set(targetKey, clone) +} + +func copyGuestMetadataAliasIfTargetMissing( + store *config.GuestMetadataStore, + sourceKey, + targetKey, + containerName string, +) error { + if store == nil || strings.TrimSpace(sourceKey) == "" || strings.TrimSpace(targetKey) == "" { + return nil + } + if store.Get(targetKey) != nil { + return nil + } + source := store.Get(sourceKey) + if source == nil { + return nil + } + clone := &config.GuestMetadata{ + CustomURL: source.CustomURL, + Description: source.Description, + LastKnownName: normalizeDockerContainerMetadataIdentity(containerName), + LastKnownType: "app-container", + } + if len(source.Tags) > 0 { + clone.Tags = append([]string(nil), source.Tags...) + } + if len(source.Notes) > 0 { + clone.Notes = append([]string(nil), source.Notes...) + } + return store.Set(targetKey, clone) +} + +func (m *Monitor) migrateCurrentDockerContainerMetadataToStableIdentities( + hostID string, + containers []models.DockerContainer, +) { + if m == nil || len(containers) == 0 { + return + } + + hostID = strings.TrimSpace(hostID) + if hostID == "" { + return + } + + for _, container := range containers { + containerID := strings.TrimSpace(container.ID) + containerName := normalizeDockerContainerMetadataIdentity(container.Name) + if containerID == "" || containerName == "" { + continue + } + + if stableKey := dockerContainerNameMetadataKey(hostID, containerName); stableKey != "" { + sourceKey := dockerContainerRuntimeMetadataKey(hostID, containerID) + if err := copyDockerMetadataAliasIfTargetMissing(m.dockerMetadataStore, sourceKey, stableKey); err != nil { + log.Warn(). + Err(err). + Str("dockerHostID", hostID). + Str("containerName", container.Name). + Str("containerID", container.ID). + Msg("Failed to migrate docker metadata to stable container name key") + } + } + + if stableKey := dockerAppContainerMetadataKey(hostID, containerName); stableKey != "" { + for _, sourceKey := range dockerAppContainerGuestMetadataLegacyKeys(hostID, containerID) { + if err := copyGuestMetadataAliasIfTargetMissing(m.guestMetadataStore, sourceKey, stableKey, containerName); err != nil { + log.Warn(). + Err(err). + Str("dockerHostID", hostID). + Str("containerName", container.Name). + Str("containerID", container.ID). + Msg("Failed to migrate guest metadata to stable app-container name key") + } + if m.guestMetadataStore != nil && m.guestMetadataStore.Get(stableKey) != nil { + break + } + } + } + } +} + func (m *Monitor) migrateDockerContainerMetadataForRecreatedContainers( hostID string, previousContainers []models.DockerContainer, @@ -133,5 +309,5 @@ func (m *Monitor) migrateDockerContainerMetadataForRecreatedContainers( } func normalizeDockerContainerMetadataIdentity(name string) string { - return strings.TrimSpace(strings.TrimPrefix(name, "/")) + return strings.TrimLeft(strings.TrimSpace(name), "/") } diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index a229942d8..e05487a1a 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -5176,7 +5176,7 @@ func (m *Monitor) getResourcesForBroadcast() []models.ResourceFrontend { } func (m *Monitor) applyDockerMetadataToUnifiedResources(resources []unifiedresources.Resource) []unifiedresources.Resource { - if len(resources) == 0 || m == nil || m.dockerMetadataStore == nil { + if len(resources) == 0 || m == nil { return resources } @@ -5189,16 +5189,58 @@ func (m *Monitor) applyDockerMetadataToUnifiedResources(resources []unifiedresou } hostID := strings.TrimSpace(resource.Docker.HostSourceID) containerID := strings.TrimSpace(resource.Docker.ContainerID) - if hostID == "" || containerID == "" { + if hostID == "" { continue } - if meta := m.dockerMetadataStore.Get(fmt.Sprintf("%s:container:%s", hostID, containerID)); meta != nil { - resource.CustomURL = strings.TrimSpace(meta.CustomURL) + if customURL, ok := m.dockerAppContainerCustomURL(*resource, hostID, containerID); ok { + resource.CustomURL = customURL } } return out } +func (m *Monitor) dockerAppContainerCustomURL( + resource unifiedresources.Resource, + hostID, + containerID string, +) (string, bool) { + if m == nil { + return "", false + } + + if m.guestMetadataStore != nil { + if stableKey := dockerAppContainerMetadataKey(hostID, resource.Name); stableKey != "" { + if meta := m.guestMetadataStore.Get(stableKey); meta != nil { + return strings.TrimSpace(meta.CustomURL), true + } + } + for _, key := range append( + []string{strings.TrimSpace(resource.ID)}, + dockerAppContainerGuestMetadataLegacyKeys(hostID, containerID)..., + ) { + if key == "" { + continue + } + if meta := m.guestMetadataStore.Get(key); meta != nil { + return strings.TrimSpace(meta.CustomURL), true + } + } + } + + if m.dockerMetadataStore != nil { + if stableKey := dockerContainerNameMetadataKey(hostID, resource.Name); stableKey != "" { + if meta := m.dockerMetadataStore.Get(stableKey); meta != nil { + return strings.TrimSpace(meta.CustomURL), true + } + } + if meta := m.dockerMetadataStore.Get(dockerContainerRuntimeMetadataKey(hostID, containerID)); meta != nil { + return strings.TrimSpace(meta.CustomURL), true + } + } + + return "", false +} + // convertResourcesForBroadcast converts unified resources into the frontend payload shape. func convertResourcesForBroadcast( allResources []unifiedresources.Resource, diff --git a/internal/monitoring/monitor_agents.go b/internal/monitoring/monitor_agents.go index 6ad5b5f2b..9c6a3787c 100644 --- a/internal/monitoring/monitor_agents.go +++ b/internal/monitoring/monitor_agents.go @@ -1446,6 +1446,7 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con if hasPrevious { m.migrateDockerContainerMetadataForRecreatedContainers(identifier, previous.Containers(), host.Containers) } + m.migrateCurrentDockerContainerMetadataToStableIdentities(identifier, host.Containers) if tokenRecord != nil { host.TokenID = tokenRecord.ID diff --git a/internal/monitoring/monitor_docker_test.go b/internal/monitoring/monitor_docker_test.go index fbde2e27f..38362773a 100644 --- a/internal/monitoring/monitor_docker_test.go +++ b/internal/monitoring/monitor_docker_test.go @@ -23,6 +23,7 @@ func newTestMonitor(t *testing.T) *Monitor { rateTracker: NewRateTracker(), metricsHistory: NewMetricsHistory(1000, 24*time.Hour), dockerTokenBindings: make(map[string]string), + guestMetadataStore: config.NewGuestMetadataStore(t.TempDir(), nil), dockerMetadataStore: config.NewDockerMetadataStore(t.TempDir(), nil), } t.Cleanup(func() { m.alertManager.Stop() }) @@ -428,6 +429,85 @@ func TestApplyDockerReportMigratesMetadataWhenContainerRuntimeIDChanges(t *testi } } +func TestApplyDockerReportMigratesGuestMetadataToStableContainerName(t *testing.T) { + monitor := newTestMonitor(t) + + baseTimestamp := time.Now().UTC() + report := agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ + ID: "agent-stable-guest", + Version: "1.0.0", + IntervalSeconds: 30, + }, + Host: agentsdocker.HostInfo{ + Hostname: "docker-host-stable-guest", + MachineID: "machine-stable-guest", + }, + Containers: []agentsdocker.Container{ + {ID: "container-old", Name: "/app"}, + }, + Timestamp: baseTimestamp, + } + + host, err := monitor.ApplyDockerReport(report, nil) + if err != nil { + t.Fatalf("first ApplyDockerReport failed: %v", err) + } + + legacyKey := dockerAppContainerLegacyResourceID(host.ID, "container-old") + if err := monitor.guestMetadataStore.Set(legacyKey, &config.GuestMetadata{ + CustomURL: "https://app.internal", + }); err != nil { + t.Fatalf("seed guest metadata: %v", err) + } + + report.Timestamp = baseTimestamp.Add(30 * time.Second) + if _, err := monitor.ApplyDockerReport(report, nil); err != nil { + t.Fatalf("metadata seeding ApplyDockerReport failed: %v", err) + } + + stableKey := dockerAppContainerMetadataKey(host.ID, "app") + stableMeta := monitor.guestMetadataStore.Get(stableKey) + if stableMeta == nil { + t.Fatalf("expected stable guest metadata key %q", stableKey) + } + if stableMeta.CustomURL != "https://app.internal" { + t.Fatalf("stable guest metadata URL = %q, want https://app.internal", stableMeta.CustomURL) + } + + report.Timestamp = baseTimestamp.Add(60 * time.Second) + report.Containers = nil + if _, err := monitor.ApplyDockerReport(report, nil); err != nil { + t.Fatalf("empty-container ApplyDockerReport failed: %v", err) + } + + report.Timestamp = baseTimestamp.Add(90 * time.Second) + report.Containers = []agentsdocker.Container{ + {ID: "container-new", Name: "app"}, + } + host, err = monitor.ApplyDockerReport(report, nil) + if err != nil { + t.Fatalf("recreated-container ApplyDockerReport failed: %v", err) + } + + resources := []unifiedresources.Resource{ + { + ID: dockerAppContainerLegacyResourceID(host.ID, "container-new"), + Type: unifiedresources.ResourceTypeAppContainer, + Name: "app", + Docker: &unifiedresources.DockerData{ + HostSourceID: host.ID, + ContainerID: "container-new", + }, + }, + } + + got := monitor.applyDockerMetadataToUnifiedResources(resources) + if got[0].CustomURL != "https://app.internal" { + t.Fatalf("CustomURL after recreate gap = %q, want https://app.internal", got[0].CustomURL) + } +} + func TestApplyDockerReportSkipsMetadataMigrationForAmbiguousContainerNames(t *testing.T) { monitor := newTestMonitor(t) @@ -530,6 +610,63 @@ func TestApplyDockerMetadataToUnifiedResourcesKeepsResourceCustomURL(t *testing. } } +func TestApplyDockerMetadataToUnifiedResourcesUsesStableDockerMetadata(t *testing.T) { + monitor := newTestMonitor(t) + if err := monitor.dockerMetadataStore.Set("docker-host-1:container-name:app", &config.DockerMetadata{ + CustomURL: "https://stable-docker.internal", + }); err != nil { + t.Fatalf("seed stable docker metadata: %v", err) + } + + resources := []unifiedresources.Resource{ + { + ID: "resource:app-container:app", + Type: unifiedresources.ResourceTypeAppContainer, + Name: "app", + Docker: &unifiedresources.DockerData{ + HostSourceID: "docker-host-1", + ContainerID: "container-new", + }, + }, + } + + got := monitor.applyDockerMetadataToUnifiedResources(resources) + if got[0].CustomURL != "https://stable-docker.internal" { + t.Fatalf("CustomURL = %q, want stable Docker metadata URL", got[0].CustomURL) + } +} + +func TestApplyDockerMetadataToUnifiedResourcesStableGuestMetadataBlocksLegacyFallback(t *testing.T) { + monitor := newTestMonitor(t) + if err := monitor.guestMetadataStore.Set(dockerAppContainerMetadataKey("docker-host-1", "app"), &config.GuestMetadata{ + CustomURL: "", + }); err != nil { + t.Fatalf("seed stable guest metadata: %v", err) + } + if err := monitor.dockerMetadataStore.Set("docker-host-1:container:container-new", &config.DockerMetadata{ + CustomURL: "https://legacy.internal", + }); err != nil { + t.Fatalf("seed legacy docker metadata: %v", err) + } + + resources := []unifiedresources.Resource{ + { + ID: "resource:app-container:app", + Type: unifiedresources.ResourceTypeAppContainer, + Name: "app", + Docker: &unifiedresources.DockerData{ + HostSourceID: "docker-host-1", + ContainerID: "container-new", + }, + }, + } + + got := monitor.applyDockerMetadataToUnifiedResources(resources) + if got[0].CustomURL != "" { + t.Fatalf("CustomURL = %q, want stable empty guest metadata to block legacy fallback", got[0].CustomURL) + } +} + func TestApplyDockerReportComputesContainerNetworkAndDiskRates(t *testing.T) { monitor := newTestMonitor(t) baseTime := time.Now().UTC() diff --git a/internal/monitoring/monitor_host_agents_test.go b/internal/monitoring/monitor_host_agents_test.go index d8e3a1a0f..696d28a42 100644 --- a/internal/monitoring/monitor_host_agents_test.go +++ b/internal/monitoring/monitor_host_agents_test.go @@ -253,6 +253,52 @@ func TestApplyDockerReportNormalizesContainerCPUCapacityAcceptedIngestProof(t *t } } +func TestApplyDockerReportMigratesAppContainerURLToStableNameAcceptedIngestProof(t *testing.T) { + monitor := newTestMonitor(t) + report := agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ + ID: "docker-url-agent", + Version: "1.0.0", + IntervalSeconds: 30, + }, + Host: agentsdocker.HostInfo{ + Hostname: "docker-url-host", + MachineID: "docker-url-machine", + }, + Containers: []agentsdocker.Container{{ + ID: "container-runtime-old", + Name: "/homepage", + }}, + Timestamp: time.Now().UTC(), + } + + host, err := monitor.ApplyDockerReport(report, nil) + if err != nil { + t.Fatalf("initial ApplyDockerReport: %v", err) + } + + legacyKey := dockerAppContainerLegacyResourceID(host.ID, "container-runtime-old") + if err := monitor.guestMetadataStore.Set(legacyKey, &config.GuestMetadata{ + CustomURL: "https://homepage.internal", + }); err != nil { + t.Fatalf("seed legacy guest metadata: %v", err) + } + + report.Timestamp = report.Timestamp.Add(30 * time.Second) + if _, err := monitor.ApplyDockerReport(report, nil); err != nil { + t.Fatalf("metadata migration ApplyDockerReport: %v", err) + } + + stableKey := dockerAppContainerMetadataKey(host.ID, "homepage") + stableMeta := monitor.guestMetadataStore.Get(stableKey) + if stableMeta == nil { + t.Fatalf("expected stable metadata at %q", stableKey) + } + if stableMeta.CustomURL != "https://homepage.internal" { + t.Fatalf("stable CustomURL = %q, want legacy URL", stableMeta.CustomURL) + } +} + func TestMonitor_HostAgentConfigUpdatePreservesReportedCommandStateInHostState(t *testing.T) { monitor := &Monitor{ state: models.NewState(),