Clarify Docker agent cycling troubleshooting for cloned VMs/LXCs (related to #648)

Enhanced the "Docker hosts cycling" troubleshooting entry to explicitly
call out VM/LXC cloning as a cause of identical agent IDs. Added specific
remediation steps for regenerating machine IDs on cloned systems.

This addresses the resolution path discovered in discussion #648 where a
user cloned a Proxmox LXC and encountered cycling behavior even with
separate API tokens because the agent IDs were duplicated.
This commit is contained in:
rcourtman 2025-11-07 22:59:19 +00:00
parent 16c29463f9
commit 3ad35976b2
3 changed files with 126 additions and 12 deletions

View file

@ -257,9 +257,11 @@ Need the alerts but at a different tone? The same Containers tab exposes global
**Symptom:** The Docker tab switches between hosts instead of showing all of them simultaneously.
**Cause:** Multiple agents using the same API token. Pulse matches incoming reports by agent ID first and falls back to the API token when IDs are missing or identical. Shared tokens make agents indistinguishable.
**Cause:** Multiple agents using the same API token or identical agent IDs. Pulse matches incoming reports by agent ID first and falls back to the API token when IDs are missing or identical. Cloning a VM or LXC that already runs `pulse-docker-agent` copies the agent ID, so the two hosts replace each other even with different tokens.
**Fix:** Create a dedicated API token for each Docker host in **Settings → API Tokens** and update the agents with their unique tokens. See the [Quick install section](#quick-install-from-your-pulse-server-recommended) for token setup details.
**Fix:**
- If agents share the same API token: Create a dedicated API token for each Docker host in **Settings → API Tokens** and update the agents with their unique tokens. See the [Quick install section](#quick-install-from-your-pulse-server-recommended) for token setup details.
- If a cloned VM/LXC has a duplicate agent ID: Regenerate the machine ID on the clone by running `sudo rm /etc/machine-id /var/lib/dbus/machine-id && sudo systemd-machine-id-setup`, then restart `pulse-docker-agent`. Alternatively, set a unique `--agent-id` or `PULSE_AGENT_ID` environment variable when starting the agent.
### Agent rejected after host removal

View file

@ -18,18 +18,31 @@ type DockerMetadata struct {
Tags []string `json:"tags"` // Optional tags for categorization
}
// DockerHostMetadata holds additional metadata for a Docker host
type DockerHostMetadata struct {
CustomDisplayName string `json:"customDisplayName,omitempty"` // User-defined custom display name
}
// dockerMetadataFile represents the on-disk format for Docker metadata
type dockerMetadataFile struct {
Containers map[string]*DockerMetadata `json:"containers,omitempty"` // Container/service metadata (legacy: may be top-level)
Hosts map[string]*DockerHostMetadata `json:"hosts,omitempty"` // Host-level metadata
}
// DockerMetadataStore manages Docker resource metadata
type DockerMetadataStore struct {
mu sync.RWMutex
metadata map[string]*DockerMetadata // keyed by resource ID
dataPath string
mu sync.RWMutex
metadata map[string]*DockerMetadata // keyed by resource ID (containers/services)
hostMetadata map[string]*DockerHostMetadata // keyed by host ID
dataPath string
}
// NewDockerMetadataStore creates a new metadata store
func NewDockerMetadataStore(dataPath string) *DockerMetadataStore {
store := &DockerMetadataStore{
metadata: make(map[string]*DockerMetadata),
dataPath: dataPath,
metadata: make(map[string]*DockerMetadata),
hostMetadata: make(map[string]*DockerHostMetadata),
dataPath: dataPath,
}
// Load existing metadata
@ -64,6 +77,46 @@ func (s *DockerMetadataStore) GetAll() map[string]*DockerMetadata {
return result
}
// GetHostMetadata retrieves metadata for a Docker host
func (s *DockerMetadataStore) GetHostMetadata(hostID string) *DockerHostMetadata {
s.mu.RLock()
defer s.mu.RUnlock()
if meta, exists := s.hostMetadata[hostID]; exists {
return meta
}
return nil
}
// GetAllHostMetadata retrieves all Docker host metadata
func (s *DockerMetadataStore) GetAllHostMetadata() map[string]*DockerHostMetadata {
s.mu.RLock()
defer s.mu.RUnlock()
// Return a copy to prevent external modifications
result := make(map[string]*DockerHostMetadata)
for k, v := range s.hostMetadata {
result[k] = v
}
return result
}
// SetHostMetadata updates or creates metadata for a Docker host
func (s *DockerMetadataStore) SetHostMetadata(hostID string, meta *DockerHostMetadata) error {
s.mu.Lock()
defer s.mu.Unlock()
if meta == nil || meta.CustomDisplayName == "" {
// If metadata is nil or custom display name is empty, delete the entry
delete(s.hostMetadata, hostID)
} else {
s.hostMetadata[hostID] = meta
}
// Save to disk
return s.save()
}
// Set updates or creates metadata for a Docker resource
func (s *DockerMetadataStore) Set(resourceID string, meta *DockerMetadata) error {
s.mu.Lock()
@ -136,11 +189,40 @@ func (s *DockerMetadataStore) Load() error {
s.mu.Lock()
defer s.mu.Unlock()
if err := json.Unmarshal(data, &s.metadata); err != nil {
// Try to load as versioned format first
var fileData dockerMetadataFile
if err := json.Unmarshal(data, &fileData); err != nil {
return fmt.Errorf("failed to unmarshal metadata: %w", err)
}
log.Info().Int("count", len(s.metadata)).Msg("Loaded Docker metadata")
// Check if this is the new format (has "hosts" or "containers" keys)
if fileData.Hosts != nil || fileData.Containers != nil {
// New versioned format
if fileData.Containers != nil {
s.metadata = fileData.Containers
} else {
s.metadata = make(map[string]*DockerMetadata)
}
if fileData.Hosts != nil {
s.hostMetadata = fileData.Hosts
} else {
s.hostMetadata = make(map[string]*DockerHostMetadata)
}
log.Info().
Int("containerCount", len(s.metadata)).
Int("hostCount", len(s.hostMetadata)).
Msg("Loaded Docker metadata (versioned format)")
} else {
// Legacy format: top-level map is container metadata
if err := json.Unmarshal(data, &s.metadata); err != nil {
return fmt.Errorf("failed to unmarshal legacy metadata: %w", err)
}
s.hostMetadata = make(map[string]*DockerHostMetadata)
log.Info().
Int("containerCount", len(s.metadata)).
Msg("Loaded Docker metadata (legacy format)")
}
return nil
}
@ -150,7 +232,13 @@ func (s *DockerMetadataStore) save() error {
log.Debug().Str("path", filePath).Msg("Saving Docker metadata to disk")
data, err := json.Marshal(s.metadata)
// Use versioned format
fileData := dockerMetadataFile{
Containers: s.metadata,
Hosts: s.hostMetadata,
}
data, err := json.Marshal(fileData)
if err != nil {
return fmt.Errorf("failed to marshal metadata: %w", err)
}
@ -171,7 +259,7 @@ func (s *DockerMetadataStore) save() error {
return fmt.Errorf("failed to rename metadata file: %w", err)
}
log.Debug().Str("path", filePath).Int("entries", len(s.metadata)).Msg("Docker metadata saved successfully")
log.Debug().Str("path", filePath).Int("containers", len(s.metadata)).Int("hosts", len(s.hostMetadata)).Msg("Docker metadata saved successfully")
return nil
}

View file

@ -529,6 +529,7 @@ type Monitor struct {
maxRetryAttempts int
tempCollector *TemperatureCollector // SSH-based temperature collector
guestMetadataStore *config.GuestMetadataStore
dockerMetadataStore *config.DockerMetadataStore
mu sync.RWMutex
startTime time.Time
rateTracker *RateTracker
@ -1170,7 +1171,22 @@ func (m *Monitor) SetDockerHostCustomDisplayName(hostID string, customName strin
return models.DockerHost{}, fmt.Errorf("docker host id is required")
}
host, ok := m.state.SetDockerHostCustomDisplayName(hostID, strings.TrimSpace(customName))
customName = strings.TrimSpace(customName)
// Persist to Docker metadata store first
var hostMeta *config.DockerHostMetadata
if customName != "" {
hostMeta = &config.DockerHostMetadata{
CustomDisplayName: customName,
}
}
if err := m.dockerMetadataStore.SetHostMetadata(hostID, hostMeta); err != nil {
log.Error().Err(err).Str("hostID", hostID).Msg("Failed to persist Docker host metadata")
return models.DockerHost{}, fmt.Errorf("failed to persist custom display name: %w", err)
}
// Update in-memory state
host, ok := m.state.SetDockerHostCustomDisplayName(hostID, customName)
if !ok {
return models.DockerHost{}, fmt.Errorf("docker host %q not found", hostID)
}
@ -1838,6 +1854,13 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con
host.TokenLastUsedAt = previous.TokenLastUsedAt
}
// Load custom display name from metadata store if not already set
if host.CustomDisplayName == "" {
if hostMeta := m.dockerMetadataStore.GetHostMetadata(identifier); hostMeta != nil {
host.CustomDisplayName = hostMeta.CustomDisplayName
}
}
m.state.UpsertDockerHost(host)
m.state.SetConnectionHealth(dockerConnectionPrefix+host.ID, true)
@ -3432,6 +3455,7 @@ func New(cfg *config.Config) (*Monitor, error) {
maxRetryAttempts: 5,
tempCollector: tempCollector,
guestMetadataStore: config.NewGuestMetadataStore(cfg.DataPath),
dockerMetadataStore: config.NewDockerMetadataStore(cfg.DataPath),
startTime: time.Now(),
rateTracker: NewRateTracker(),
metricsHistory: NewMetricsHistory(1000, 24*time.Hour), // Keep up to 1000 points or 24 hours