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

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