Pulse/internal/updatedetection/manager.go
rcourtman 3fdf753a5b Enhance devcontainer and CI workflows
- Add persistent volume mounts for Go/npm caches (faster rebuilds)
- Add shell config with helpful aliases and custom prompt
- Add comprehensive devcontainer documentation
- Add pre-commit hooks for Go formatting and linting
- Use go-version-file in CI workflows instead of hardcoded versions
- Simplify docker compose commands with --wait flag
- Add gitignore entries for devcontainer auth files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 22:29:15 +00:00

270 lines
7.6 KiB
Go

// Package updatedetection provides unified update detection across all Pulse-managed
// infrastructure types.
//
// The Manager coordinates update detection for Docker containers, receiving update status
// from Docker agents and checking registries on demand. It maintains an in-memory store
// of available updates and provides APIs for querying update status.
package updatedetection
import (
"context"
"sync"
"time"
"github.com/rs/zerolog"
)
// Manager coordinates update detection across all infrastructure types.
type Manager struct {
store *Store
registry *RegistryChecker
logger zerolog.Logger
mu sync.RWMutex
// Configuration
enabled bool
checkInterval time.Duration
alertDelayHours int
enableDockerUpdates bool
}
// ManagerConfig holds configuration for the update detection manager.
type ManagerConfig struct {
Enabled bool // Master switch for update detection
CheckInterval time.Duration // How often to check for updates (default 6h)
AlertDelayHours int // Hours before alerting on a new update (default 24)
EnableDockerUpdates bool // Enable Docker image update detection
}
// DefaultManagerConfig returns sensible default configuration.
func DefaultManagerConfig() ManagerConfig {
return ManagerConfig{
Enabled: true,
CheckInterval: 6 * time.Hour,
AlertDelayHours: 24,
EnableDockerUpdates: true,
}
}
// NewManager creates a new update detection manager.
func NewManager(cfg ManagerConfig, logger zerolog.Logger) *Manager {
return &Manager{
store: NewStore(),
registry: NewRegistryChecker(logger),
logger: logger.With().Str("component", "updatedetection").Logger(),
enabled: cfg.Enabled,
checkInterval: cfg.CheckInterval,
alertDelayHours: cfg.AlertDelayHours,
enableDockerUpdates: cfg.EnableDockerUpdates,
}
}
// ProcessDockerContainerUpdate processes an update status report from a Docker agent.
// This is called when the agent reports container data with update status.
func (m *Manager) ProcessDockerContainerUpdate(
hostID string,
containerID string,
containerName string,
image string,
currentDigest string,
updateStatus *ContainerUpdateStatus,
) {
if !m.enabled || !m.enableDockerUpdates {
return
}
if updateStatus == nil {
// No update status provided by agent - we can still track the container
// but won't know about updates yet
return
}
if !updateStatus.UpdateAvailable {
// No update available - remove any existing update entry
m.store.DeleteUpdatesForResource(containerID)
return
}
// Create or update the update entry
updateID := "docker:" + hostID + ":" + containerID
update := &UpdateInfo{
ID: updateID,
ResourceID: containerID,
ResourceType: "docker",
ResourceName: containerName,
HostID: hostID,
Type: UpdateTypeDockerImage,
CurrentDigest: updateStatus.CurrentDigest,
LatestDigest: updateStatus.LatestDigest,
LastChecked: updateStatus.LastChecked,
CurrentVersion: image,
}
if updateStatus.Error != "" {
update.Error = updateStatus.Error
}
m.store.UpsertUpdate(update)
m.logger.Debug().
Str("container", containerName).
Str("image", image).
Str("hostID", hostID).
Bool("hasUpdate", updateStatus.UpdateAvailable).
Msg("Processed container update status")
}
// CheckImageUpdate checks a specific image for updates using the registry API.
// This can be called on demand from the server side.
func (m *Manager) CheckImageUpdate(ctx context.Context, image, currentDigest string) (*ImageUpdateInfo, error) {
if !m.enabled {
return nil, nil
}
return m.registry.CheckImageUpdate(ctx, image, currentDigest)
}
// GetUpdates returns all tracked updates, optionally filtered.
func (m *Manager) GetUpdates(filters UpdateFilters) []*UpdateInfo {
all := m.store.GetAllUpdates()
if filters.IsEmpty() {
return all
}
result := make([]*UpdateInfo, 0)
for _, update := range all {
if filters.Matches(update) {
result = append(result, update)
}
}
return result
}
// GetUpdatesForHost returns all updates for a specific host.
func (m *Manager) GetUpdatesForHost(hostID string) []*UpdateInfo {
return m.store.GetUpdatesForHost(hostID)
}
// GetUpdatesForResource returns the update for a specific resource.
func (m *Manager) GetUpdatesForResource(resourceID string) *UpdateInfo {
return m.store.GetUpdatesForResource(resourceID)
}
// GetSummary returns aggregated update statistics by host.
func (m *Manager) GetSummary() map[string]*UpdateSummary {
return m.store.GetSummary()
}
// GetTotalCount returns the total number of tracked updates.
func (m *Manager) GetTotalCount() int {
return m.store.Count()
}
// DeleteUpdatesForHost removes all updates for a host (called when host is removed).
func (m *Manager) DeleteUpdatesForHost(hostID string) {
m.store.DeleteUpdatesForHost(hostID)
}
// AddRegistryConfig adds registry authentication configuration.
func (m *Manager) AddRegistryConfig(cfg RegistryConfig) {
m.registry.AddRegistryConfig(cfg)
}
// CleanupStale removes update entries that haven't been refreshed recently.
// This is called periodically to clean up stale entries from removed containers.
func (m *Manager) CleanupStale(maxAge time.Duration) int {
all := m.store.GetAllUpdates()
cutoff := time.Now().Add(-maxAge)
removed := 0
for _, update := range all {
if update.LastChecked.Before(cutoff) {
m.store.DeleteUpdate(update.ID)
removed++
}
}
if removed > 0 {
m.logger.Info().Int("removed", removed).Msg("Cleaned up stale update entries")
}
return removed
}
// UpdateFilters allows filtering update queries.
type UpdateFilters struct {
HostID string // Filter by host
ResourceType string // Filter by resource type (docker, lxc, vm, etc)
UpdateType UpdateType // Filter by update type
Severity UpdateSeverity
HasError *bool // Filter by error status
}
// IsEmpty returns true if no filters are set.
func (f *UpdateFilters) IsEmpty() bool {
return f.HostID == "" && f.ResourceType == "" && f.UpdateType == "" && f.Severity == "" && f.HasError == nil
}
// Matches returns true if the update matches all set filters.
func (f *UpdateFilters) Matches(update *UpdateInfo) bool {
if f.HostID != "" && update.HostID != f.HostID {
return false
}
if f.ResourceType != "" && update.ResourceType != f.ResourceType {
return false
}
if f.UpdateType != "" && update.Type != f.UpdateType {
return false
}
if f.Severity != "" && update.Severity != f.Severity {
return false
}
if f.HasError != nil {
hasError := update.Error != ""
if hasError != *f.HasError {
return false
}
}
return true
}
// Enabled returns whether update detection is enabled.
func (m *Manager) Enabled() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.enabled
}
// SetEnabled enables or disables update detection.
func (m *Manager) SetEnabled(enabled bool) {
m.mu.Lock()
defer m.mu.Unlock()
m.enabled = enabled
}
// AlertDelayHours returns the configured delay before alerting on updates.
func (m *Manager) AlertDelayHours() int {
m.mu.RLock()
defer m.mu.RUnlock()
return m.alertDelayHours
}
// GetUpdatesReadyForAlert returns updates that have been pending for longer than the alert delay.
func (m *Manager) GetUpdatesReadyForAlert() []*UpdateInfo {
m.mu.RLock()
delay := time.Duration(m.alertDelayHours) * time.Hour
m.mu.RUnlock()
all := m.store.GetAllUpdates()
cutoff := time.Now().Add(-delay)
ready := make([]*UpdateInfo, 0)
for _, update := range all {
if update.FirstDetected.Before(cutoff) && update.Error == "" {
ready = append(ready, update)
}
}
return ready
}