Fix Docker container URL metadata identity

Refs #1490
This commit is contained in:
rcourtman 2026-07-06 15:48:26 +01:00
parent 92951532e3
commit 7edfa1bb86
18 changed files with 625 additions and 18 deletions

View file

@ -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:<host>:name:<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 "`

View file

@ -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

View file

@ -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(() => <GuestDrawer guest={guest} onClose={vi.fn()} />);
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(() => (
<GuestDrawer

View file

@ -53,6 +53,7 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
setHistoryRange,
showInGuestAgentInstallCue,
switchTab,
webInterfaceMetadataId,
webInterfaceTargetLabel,
workloadActionAgentTitle,
} = useGuestDrawerState(props);
@ -152,6 +153,7 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
discoveryIdentifiedSummary={discoveryIdentifiedSummary()}
hasWorkloadActionAgent={hasWorkloadActionAgent()}
showInGuestAgentInstallCue={showInGuestAgentInstallCue()}
webInterfaceMetadataId={webInterfaceMetadataId()}
webInterfaceTargetLabel={webInterfaceTargetLabel()}
workloadActionAgentTitle={workloadActionAgentTitle()}
/>

View file

@ -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) {
<div class="mt-3">
<WebInterfaceUrlField
metadataKind="guest"
metadataId={props.guestId}
metadataId={props.webInterfaceMetadataId}
targetLabel={props.webInterfaceTargetLabel}
customUrl={props.customUrl}
onCustomUrlChange={(url) => 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) {
<AvailabilityProbeStatusCard availability={props.guest.availability!} />
</div>
</Show>
<Show when={props.discoveryIdentifiedSummary?.suggestedAvailabilityProbe && !props.guest.availability}>
<Show
when={
props.discoveryIdentifiedSummary?.suggestedAvailabilityProbe &&
!props.guest.availability
}
>
<div class="mt-3 max-w-sm">
<AvailabilityProbeSuggestionCard
suggestion={props.discoveryIdentifiedSummary!.suggestedAvailabilityProbe!}

View file

@ -21,7 +21,7 @@ import { getAlertStyles } from '@/utils/alerts';
import { formatSpeed, formatUptime } from '@/utils/format';
import { isNodeOnline } from '@/utils/status';
import { formatTemperature, getCpuTemperature, getTemperatureTextClass } from '@/utils/temperature';
import { getCanonicalWorkloadId } from '@/utils/workloads';
import { getCanonicalWorkloadId, getWorkloadMetadataIdCandidates } from '@/utils/workloads';
import {
resolveSummaryGroupMemberInteractionState,
type SummarySeriesGroupScope,
@ -509,12 +509,21 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
<Index each={groupGuests()} fallback={<></>}>
{(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) {
<GuestDrawer
guest={guest()}
onClose={() => props.setSelectedGuestId(null)}
metadataId={metadataIdCandidates()[0] || guestId()}
customUrl={metadata()?.customUrl}
nestedWorkloadContext={nestedWorkloadContext()}
onCustomUrlChange={props.handleCustomUrlUpdate}

View file

@ -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();
});
});
});

View file

@ -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;

View file

@ -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,
};
}

View file

@ -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;
}

View file

@ -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(

View file

@ -148,6 +148,42 @@ export const getCanonicalWorkloadId = (
return guest.id;
};
export const buildAppContainerMetadataId = ({
dockerHostId,
name,
}: Pick<WorkloadGuest, 'dockerHostId' | 'name'>): 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;

View file

@ -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 {

View file

@ -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), "/")
}

View file

@ -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,

View file

@ -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

View file

@ -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()

View file

@ -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(),