mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
parent
92951532e3
commit
7edfa1bb86
18 changed files with 625 additions and 18 deletions
|
|
@ -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 "`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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!}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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), "/")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue