Record Docker host, service and sub-resource alert history (#2620)

Follows #2608, which made `MonitorAdapter.RecordChange` resolve Proxmox
and agent alert references to canonical resources. Three kinds of alert
reference still journaled under strings no resource answers to:

- Docker host alerts (`docker:<host ID>`)
- Swarm service alerts (`docker:<host ID>/service/<ID>`)
- sub-resource alerts such as ZFS pool and device health (`<storage
ID>/zfs-pool:<pool>[/device:<dev>]`) and host filesystem, SMART, RAID
and sensor alerts (`agent:<host ID>/disk:…` etc.)

Resource facets, timelines, resource intelligence and Assistant context
read history by canonical ID, so these alerts never appeared there.

## What changes

- **Docker host refs** resolve through the registry's Docker source ID.
- **Swarm service refs** resolve through the host's Swarm cluster to the
service with that exact ID, from any manager that reports it. A service
with no ID is referenced by its name in the same slot, so while a
cluster has such a service, every service ref there is a conflict and
binds nothing.
- **Never bind:** container names, shortened container IDs and hostless
`docker-service:<name>` refs never bind, and are no longer retried
against later inventory.
- **Sub-resource refs** bind to the owner whose durable ID they carry:
  - ZFS pool and device alerts go to the storage.
  - The Unraid array alert goes to its array storage.
- Filesystem, SMART, disk temperature, RAID and custom-sensor alerts go
to the host.
- The owner must resolve to exactly one resource of the expected type.
An ambiguous or incompatible owner is a conflict.
- Mount and kernel device labels can name a different disk after a
reboot, so these alerts never bind a physical disk.
- **Retries:** a reference that hits a conflict at write time now enters
the backfill retry set, the same as one found at startup.

## Known residuals (producer-side, follow-up proposed)

- A container with no ID alerts under its host's reference
(`DockerResourceID(host, "")`), so its events join that host's history
(never another container's).
- An ID-less service whose name equals another service's ID shares that
service's reference. Rows it journaled can join the other service's
history once a later generation or event binds the reference. Swarm
always reports service IDs, so this needs a malformed report.

Both are documented in the contract. Separating them needs a producer
change in `internal/alerts`.

## Evidence

Private `PULSE_MOCK_MODE` stack, fresh data dir. The binary at #2608's
tip ran first, then this change on the same dir. Alert rows were
classified by source reference (from `alert_identifier`) and checked
against each owner's `/api/resources/{id}/timeline`:

| ref shape | rows readable via owner timeline, before | after |
|---|---|---|
| Docker host | 0 / 1 | 3 / 3 |
| Swarm service | 0 / 5 | 19 / 19 |
| ZFS pool | 0 / 9 | 26 / 26 |
| ZFS device | 0 / 9 | 27 / 27 |
| agent filesystem | 0 / 1 | 3 / 3 |
| Docker container (mock short IDs) | 0 / 11 | 0 / 30, no container
alias written |

Tests:
-
`TestHistoryIdentityMonitorAdapterResolvesDockerHostAndServiceReferences`
covers multi-manager services, a Docker host merged into its agent
machine, legacy-row backfill, recovery after removal, ID-less service
conflicts, and names and short IDs staying unbound.
- `TestHistoryIdentityMonitorAdapterResolvesSubResourceReferences`
covers every owner shape, wrong-type and ambiguous owners, retained
bindings, write-time conflict retry, and physical disks staying unbound.
- `TestOwnerAlertTimelinesUseCanonicalHistoryIdentity` drives the real
alert manager for Docker host offline, Swarm service gap, ZFS
pool/device and host filesystem alerts. It fails on #2608's tip.

UI reach: the Docker, storage and platform host-row drawers don't
request per-resource history. This fix reaches the
facets/timeline/intelligence APIs and Assistant resource context, not a
drawer.
This commit is contained in:
rcourtman
2026-10-06 22:57:41 +01:00
committed by GitHub
6 changed files with 546 additions and 40 deletions
@@ -1060,6 +1060,20 @@ replay stays idempotent. The real alert-manager callback path is covered by
`TestProxmoxAlertTimelineUsesCanonicalHistoryIdentity` in
`internal/monitoring/monitor_alert_handling_test.go`.
Docker host and Swarm service alert lifecycle events, and sub-resource alert
events (ZFS pools and devices, host filesystems, disks, RAID arrays, sensors
and the Unraid array), pass through the same writer, which binds them to the
Docker host, Swarm service, storage or host named in the unified-resources
history identity clause. Container names and shortened container IDs still
bind nothing. `CheckDockerHost` gives a container without an ID its host's
reference, so that container's events join the host's history. A producer
that changes one of these reference shapes must change
`dockerHostHistoryReference` or `historySubResourceOwner` with it.
`TestOwnerAlertTimelinesUseCanonicalHistoryIdentity` in
`internal/monitoring/monitor_alert_handling_test.go` pins the Docker host,
Swarm service, ZFS pool and device, and host filesystem shapes against the
real alert manager.
TrueNAS native alert projection preserves the trimmed, uppercase provider level in ResourceIncident.NativeSeverity. INFO and NOTICE retain the same canonical monitor risk; consumers must not lose their distinct actionability when projecting provider evidence. Native CRITICAL, ALERT, and EMERGENCY all project to canonical critical severity; EMERGENCY must not be discarded as unknown or make a still-active condition appear recovered. WARNING remains warning, and INFO and NOTICE remain informational at this projection boundary.
Verification: `TestIncidentProjectionPreservesNativeSeverity` in `internal/truenas/provider_pool_health_contract_test.go` covers all seven native levels and case/whitespace normalization. `TestTrueNASNativeSeverityDispatch` in `internal/alerts/truenas_native_dispatch_test.go` verifies downstream INFO suppression, NOTICE preservation, notification severity, duplicate-poll retention, and confirmed recovery callback identity. The TrueNAS lifecycle tests in `internal/alerts/unified_incidents_test.go` require repeated EMERGENCY evidence to interrupt recovery confirmation. These are fixture-based projection and manager checks, not appliance ingestion or external notification-provider receipt proof.
@@ -5764,10 +5764,12 @@ and storage IDs), a node-scoped Proxmox guest reference after migration, or
`ResolveReferenceID` used by alert policy, so a resource named like a system
reference cannot capture its events. A reference two resources answer to binds
to neither: its event keeps its own reference and creates or changes no
binding, though an earlier binding still covers that reference on read. A
binding, though an earlier binding still covers that reference on read. Like
any unbound reference it is retried, and once a later generation resolves it
to one resource it binds there, replacing an earlier binding. A
reference no resource
answers to follows its retained binding after inventory removal, or keeps its
own history. Docker references never take this path. Alert journal references
own history. Docker container references never take this path. Alert journal references
without a binding (read once per process, plus any written before inventory
named their resource) are retried on each published registry generation for
`legacyHistoryBindWindow`. Bound rows keep their recorded resource ID, so a
@@ -5779,6 +5781,46 @@ Assistant and Patrol resource contexts read it from the store. Proof:
`TestHistoryIdentityMonitorAdapterResolvesProxmoxAlertReferences` and
`TestHistoryIdentityBindsLegacyAlertRowsFromRegistryGenerations` in
`internal/unifiedresources/history_identity_test.go`.
Docker host alerts (`docker:<host ID>`) and Swarm service alerts
(`docker:<host ID>/service/<service ID>`) resolve only through the registry's
Docker source identities: the host ID is the Docker host's source ID, and a
service reference names the service with that exact ID in the host's Swarm
cluster, whichever manager reported it. A hostname in the host position is
only ever looked up as a Docker host source ID. A service without an ID is
referenced by its normalized name in the same place, so while its cluster has
such a service every service reference there is a conflict. Alert references
do not record whether they came from an ID, so a row journaled by an ID-less
service can still join another service's history if a later generation or
event binds the same reference; Swarm always reports service IDs, so that needs a malformed
report naming a service exactly like another service's ID. Hostless
`docker-service:<name>` references, container names and shortened container
IDs never bind and are never retried against later inventory. A container
without an ID alerts under its host's reference (`alerts.DockerResourceID`), so
its events join that host's history, never another container's.
Sub-resource alert references name no resource of their own and bind to the
owner whose durable ID they carry (`historySubResourceOwner`): ZFS pool and
device alerts (`<storage ID>/zfs-pool:<pool>[/device:<device>]`) to the
storage, the Unraid array alert (`agent:<host ID>/storage:unraid-array`) to
the array storage with that agent source ID, and host filesystem, SMART disk,
disk temperature, RAID and custom-sensor alerts (`agent:<host ID>/disk:`,
`/disk_temp:`, `/raid:`, `/custom:`) to the host. The owner reference must
resolve to exactly one resource of the expected type; an ambiguous or
incompatible owner is a conflict, so the event keeps its own reference,
follows no retained binding and is retried. Pool, mount and kernel device labels are names,
and a device name can denote a different disk after a reboot, so these alerts
never bind a physical disk. Any other sub-resource shape stays unbound until
its producer and this list change together. The Docker, storage and platform
host-row drawers do not request per-resource history, so these bindings reach
the facets, timeline and intelligence APIs and Assistant resource context, not
a drawer. Patrol's scoped change feed matches the stored resource ID without
expanding bindings, so it includes events recorded under the owner's
canonical ID but not rows journaled before their binding.
Proof: `TestHistoryIdentityMonitorAdapterResolvesDockerHostAndServiceReferences`
and `TestHistoryIdentityMonitorAdapterResolvesSubResourceReferences` in
`internal/unifiedresources/history_identity_test.go`, and
`TestOwnerAlertTimelinesUseCanonicalHistoryIdentity` in
`internal/monitoring/monitor_alert_handling_test.go` against the real alert
producers.
That same shared timeline vocabulary now includes the `activity` change kind
for provider-read breadcrumbs such as VMware tasks and events, plus the
`vmware_adapter` source-adapter token for canonical provenance drill-down.
@@ -150,6 +150,67 @@ func TestProxmoxAlertTimelineUsesCanonicalHistoryIdentity(t *testing.T) {
require.Equal(t, canonical["pve1"], changes[0].ResourceID)
}
// Docker host, Swarm service and sub-resource alerts (a ZFS pool and device,
// a host filesystem) carry references the registry has no resource for. Their
// lifecycle must reach the owner's canonical history, so the producers' ref
// shapes are pinned here against the real alert manager.
func TestOwnerAlertTimelinesUseCanonicalHistoryIdentity(t *testing.T) {
store, err := unifiedresources.NewSQLiteResourceStore(t.TempDir(), "default")
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, store.Close()) })
manager := alerts.NewManagerWithDataDir(t.TempDir())
t.Cleanup(manager.Stop)
config := manager.GetConfig()
config.Enabled = true
config.ActivationState = alerts.ActivationPending
config.TimeThresholds = map[string]int{"all": 0}
config.MetricTimeThresholds = map[string]map[string]int{"all": {"disk": 0}}
config.SuppressionWindow = 0
manager.UpdateConfig(config)
now := time.Now()
dockerHost := models.DockerHost{ID: "dh-7f3a", Hostname: "tower", DisplayName: "tower", Status: "online", LastSeen: now,
Swarm: &models.DockerSwarmInfo{NodeID: "node-a", NodeRole: "manager", LocalState: "active", ControlAvailable: true, ClusterID: "swarm-1"},
Services: []models.DockerService{{ID: "x7k2m9q4w1e8r5t3y6u0i2o4p", Name: "web", DesiredTasks: 2, RunningTasks: 0}}}
storage := models.Storage{ID: "lab-pve1-local-zfs", Name: "local-zfs", Node: "pve1", Instance: "lab", Type: "zfspool", Status: "available", Total: 100, Used: 10, Usage: 10, Enabled: true, Active: true, LastSeen: now,
ZFSPool: &models.ZFSPool{Name: "rpool", State: "DEGRADED", Status: "Degraded", Devices: []models.ZFSDevice{{Name: "sda2", Type: "disk", State: "FAULTED"}}}}
agentHost := models.Host{ID: "host-nas", Hostname: "nas", DisplayName: "nas", MachineID: "fedcba9876543210", Status: "online", LastSeen: now,
Disks: []models.Disk{{Mountpoint: "/var", Device: "/dev/sdb1", Type: "ext4", Total: 100, Used: 99, Free: 1, Usage: 99}}}
registry := unifiedresources.NewRegistry(store)
registry.IngestSnapshot(models.StateSnapshot{DockerHosts: []models.DockerHost{dockerHost}, Storage: []models.Storage{storage}, Hosts: []models.Host{agentHost}})
monitor := &Monitor{alertManager: manager, resourceStore: unifiedresources.NewMonitorAdapter(registry)}
manager.SubscribeLifecycleCallback(monitor.handleAlertLifecycleEvent)
manager.CheckDockerHost(dockerHost)
manager.CheckStorage(storage)
manager.CheckHost(agentHost)
for range 3 {
manager.HandleDockerHostOffline(dockerHost)
}
owner := map[string]string{}
for _, resource := range registry.List() {
owner[string(resource.Type)+"/"+resource.Name] = resource.ID
}
filters := unifiedresources.ResourceChangeFilters{Kinds: []unifiedresources.ChangeKind{unifiedresources.ChangeAlertFired}}
want := map[string]string{
"docker-host-offline": owner["agent/tower"],
"docker-service-health": owner["docker-service/web"],
"zfs-pool-state": owner["storage/local-zfs"],
"zfs-device": owner["storage/local-zfs"],
"disk": owner["agent/nas"],
}
for alertType, canonicalID := range want {
require.NotEmpty(t, canonicalID, alertType)
changes, err := store.GetRecentChangesFiltered(canonicalID, time.Time{}, 10, filters)
require.NoError(t, err)
found := false
for _, change := range changes {
require.Equal(t, canonicalID, change.ResourceID)
found = found || change.Metadata["alert_type"] == alertType
}
require.True(t, found, "%s alert missing from %s history: %+v", alertType, canonicalID, changes)
}
}
func TestMonitor_HandleAlertFired_Extra(t *testing.T) {
// 1. Alert is nil
m1 := &Monitor{}
+169 -27
View File
@@ -32,35 +32,180 @@ func legacyDockerHistoryIdentity(ref string) (sourceID, canonicalID string, ok b
return sourceID, SourceSpecificID(ResourceTypeAppContainer, SourceDocker, sourceID), true
}
// isDockerHistoryReference marks Docker alert source references, which only a
// complete container ID binds (legacyDockerHistoryIdentity). A container name
// or shortened ID must never join history through general reference matching.
// isDockerHistoryReference marks Docker alert source references, including the
// hostless "docker-service:<name>" form. They never join history through
// general reference matching: only a Docker host, a Swarm service ID
// (dockerHostHistoryReference) or a complete container ID
// (legacyDockerHistoryIdentity) binds.
func isDockerHistoryReference(ref string) bool {
return strings.HasPrefix(strings.TrimSpace(ref), "docker:")
ref = strings.TrimSpace(ref)
return strings.HasPrefix(ref, "docker:") || strings.HasPrefix(ref, "docker-service:")
}
// resolveHistoryReference resolves a non-Docker alert reference to the
// resource whose history it joins. Only durable identities qualify: the
// canonical ID, a retired era, a source ID (Proxmox node, guest and storage
// IDs), a node-scoped Proxmox guest reference, or an agent alert reference
// ("agent:<host ID>"). Names and hostnames never bind history, so a resource
// named like a system reference cannot capture its events. claimed reports
// that inventory answers to the reference, possibly ambiguously; an ambiguous
// reference resolves to nothing and must not follow a retained binding.
// isDockerNameHistoryReference marks Docker alert references that are not host
// or service references: container references, which only a complete
// container ID binds, and hostless service names. A name or shortened ID must
// never join history, not even through a retained binding.
func isDockerNameHistoryReference(ref string) bool {
_, _, ok := dockerHostHistoryReference(ref)
return isDockerHistoryReference(ref) && !ok
}
// dockerHostHistoryReference parses the Docker host alert reference
// ("docker:<host ID>", alerts.DockerResourceID without a container) and the
// Swarm service alert reference ("docker:<host ID>/service/<service ID>",
// alerts.DockerServiceResourceID). serviceID is empty for a host reference.
// "docker:unknown" is the shared fallback for alerts without a host.
func dockerHostHistoryReference(ref string) (hostID, serviceID string, ok bool) {
rest, found := strings.CutPrefix(strings.TrimSpace(ref), "docker:")
if !found {
return "", "", false
}
hostID, serviceID, isService := strings.Cut(rest, "/service/")
if hostID == "" || hostID == "unknown" || strings.TrimSpace(hostID) != hostID || strings.Contains(hostID, "/") {
return "", "", false
}
if isService && (serviceID == "" || strings.TrimSpace(serviceID) != serviceID || strings.Contains(serviceID, "/")) {
return "", "", false
}
return hostID, serviceID, true
}
// dockerHistoryOwnerLocked resolves a Docker host or Swarm service alert
// reference through the registry's own Docker source identities. The host ID
// is the Docker host's source ID. A service reference names the service with
// that ID in the host's Swarm cluster, which every manager of the cluster
// reports under its own host ID. A service without an ID is referenced by its
// normalized name in the same place, so while the cluster has such a service
// every service reference there is a conflict.
func (rr *ResourceRegistry) dockerHistoryOwnerLocked(ref string) (resourceID string, conflict bool) {
hostID, serviceID, ok := dockerHostHistoryReference(ref)
if !ok {
return "", false
}
hostResourceID := rr.bySource[SourceDocker][hostID]
host := rr.resources[hostResourceID]
if host == nil || CanonicalResourceType(host.Type) != ResourceTypeAgent {
return "", false
}
if serviceID == "" {
return hostResourceID, false
}
if host.Docker == nil {
return "", false
}
cluster := dockerSwarmClusterKeyFromMeta(host.Docker.Swarm)
if cluster == "" {
return "", false
}
for _, other := range rr.resources {
if CanonicalResourceType(other.Type) == ResourceTypeDockerService && other.Docker != nil &&
strings.TrimSpace(other.Docker.ServiceID) == "" && dockerSwarmClusterKeyFromMeta(other.Docker.Swarm) == cluster {
return "", true
}
}
serviceResourceID := rr.bySource[SourceDocker][normalizeSourceID(cluster+":service:"+serviceID)]
service := rr.resources[serviceResourceID]
if service == nil || CanonicalResourceType(service.Type) != ResourceTypeDockerService ||
service.Docker == nil || strings.TrimSpace(service.Docker.ServiceID) != serviceID {
return "", false
}
return serviceResourceID, false
}
// historySubResourceOwner names the owner of a sub-resource alert reference,
// one that names no resource of its own, and the type that owner must have.
// Only these producer shapes qualify, each carrying its owner's durable
// identity:
// - "<storage ID>/zfs-pool:<pool>[/device:<device>]" (ZFS pool state,
// errors and device health) belongs to the storage.
// - "agent:<host ID>/storage:<array>" (Unraid array) is the agent source ID
// "<host ID>/storage:<array>" of the host's array storage.
// - "agent:<host ID>/disk:<mount or device>", ".../disk_temp:<device>",
// ".../raid:<device>" and ".../custom:<sensor>" belong to the host.
//
// Pool, mount and kernel device labels are names: a device name can belong to
// a different disk after a reboot, so these never bind a physical disk.
func historySubResourceOwner(ref string) (ownerRef string, ownerType ResourceType, ok bool) {
if rest, found := strings.CutPrefix(ref, "agent:"); found {
hostID, child, _ := strings.Cut(rest, "/")
kind, label, _ := strings.Cut(child, ":")
if hostID == "" || label == "" {
return "", "", false
}
switch kind {
case "storage":
return hostID + "/" + child, ResourceTypeStorage, true
case "disk", "disk_temp", "raid", "custom":
return "agent:" + hostID, ResourceTypeAgent, true
}
return "", "", false
}
if i := strings.LastIndex(ref, "/zfs-pool:"); i > 0 {
pool, device, hasDevice := strings.Cut(ref[i+len("/zfs-pool:"):], "/device:")
if pool == "" || strings.Contains(pool, "/") || (hasDevice && (device == "" || strings.Contains(device, "/"))) {
return "", "", false
}
return ref[:i], ResourceTypeStorage, true
}
return "", "", false
}
// resolveHistoryReference resolves an alert reference to the resource whose
// history it joins. Only durable identities qualify: the canonical ID, a
// retired era, a source ID (Proxmox node, guest and storage IDs), a
// node-scoped Proxmox guest reference, an agent alert reference
// ("agent:<host ID>"), a Docker host or Swarm service ID, or the owner of a
// sub-resource reference (historySubResourceOwner). Names and hostnames never
// bind history, so a resource named like a system reference cannot capture
// its events. claimed reports that inventory answers to the reference,
// possibly ambiguously; an ambiguous or conflicting reference resolves to
// nothing and must not follow a retained binding.
func (rr *ResourceRegistry) resolveHistoryReference(ref string) (resourceID string, claimed bool) {
ref = CanonicalResourceID(ref)
if rr == nil || ref == "" || isDockerHistoryReference(ref) {
if rr == nil || ref == "" {
return "", false
}
rr.mu.RLock()
defer rr.mu.RUnlock()
if isDockerHistoryReference(ref) {
resourceID, conflict := rr.dockerHistoryOwnerLocked(ref)
return resourceID, resourceID != "" || conflict
}
matches := rr.durableHistoryMatchesLocked(ref)
if ownerRef, ownerType, ok := historySubResourceOwner(ref); ok && len(matches) == 0 {
// The owner must resolve uniquely and have the expected type. An owner
// reference that names an ambiguous or incompatible resource is a
// conflict: the event keeps its own reference.
owners := rr.durableHistoryMatchesLocked(ownerRef)
for id := range owners {
if len(owners) != 1 || CanonicalResourceType(rr.resources[id].Type) != ownerType {
return "", true
}
matches[id] = struct{}{}
}
}
if len(matches) != 1 {
return "", len(matches) > 1
}
for id := range matches {
resourceID = id
}
return resourceID, true
}
// durableHistoryMatchesLocked lists the resources that answer to a reference
// by durable identity.
func (rr *ResourceRegistry) durableHistoryMatchesLocked(ref string) map[string]struct{} {
matches := make(map[string]struct{}, 1)
if rr.resources[ref] != nil {
return ref, true
matches[ref] = struct{}{}
return matches
}
if id := rr.supersededResourceIDLocked(ref); rr.resources[id] != nil {
return id, true
matches[id] = struct{}{}
return matches
}
matches := make(map[string]struct{}, 1)
for _, mapping := range rr.bySource {
if id := mapping[ref]; rr.resources[id] != nil {
matches[id] = struct{}{}
@@ -78,13 +223,7 @@ func (rr *ResourceRegistry) resolveHistoryReference(ref string) (resourceID stri
}
}
}
if len(matches) != 1 {
return "", len(matches) > 1
}
for id := range matches {
resourceID = id
}
return resourceID, true
return matches
}
// resourceHistoryIdentityWriter binds a source reference to the canonical
@@ -187,11 +326,12 @@ func (b *legacyHistoryBackfill) add(ref string, now time.Time) {
}
// unboundHistoryReferences lists alert journal references without a binding.
// Docker references are excluded: the store migration binds full container
// IDs, and names or shortened IDs must never bind.
// Docker container references and hostless service names are excluded: the
// store migration binds full container IDs, and names or shortened IDs must
// never bind.
func (s *SQLiteResourceStore) unboundHistoryReferences() ([]string, error) {
rows, err := s.db.Query(`SELECT DISTINCT canonical_id FROM resource_changes
WHERE kind GLOB 'alert_*' AND canonical_id NOT GLOB 'docker:*'
WHERE kind GLOB 'alert_*'
AND canonical_id NOT IN (SELECT source_id FROM resource_history_aliases)
ORDER BY canonical_id`)
if err != nil {
@@ -204,7 +344,9 @@ func (s *SQLiteResourceStore) unboundHistoryReferences() ([]string, error) {
if err := rows.Scan(&ref); err != nil {
return nil, err
}
refs = append(refs, ref)
if !isDockerNameHistoryReference(ref) {
refs = append(refs, ref)
}
}
return refs, rows.Err()
}
@@ -333,3 +333,246 @@ func TestHistoryIdentityBindsLegacyAlertRowsFromRegistryGenerations(t *testing.T
require.NoError(t, err)
require.Equal(t, []string{"lab:pve1:999"}, refs)
}
func requireHistoryReferenceBindings(t *testing.T, store *SQLiteResourceStore, bound map[string]string, unbound []string) {
t.Helper()
byResource := make(map[string][]string)
for ref, canonicalID := range bound {
byResource[canonicalID] = append(byResource[canonicalID], "fired-"+ref)
id, found, err := store.ResolveHistorySourceIdentity(ref)
require.NoError(t, err)
require.True(t, found, ref)
require.Equal(t, canonicalID, id, ref)
}
for canonicalID, want := range byResource {
got, err := store.GetRecentChangesFiltered(canonicalID, time.Time{}, 50, ResourceChangeFilters{Kinds: []ChangeKind{ChangeAlertFired}})
require.NoError(t, err)
ids := make([]string, 0, len(got))
for _, change := range got {
ids = append(ids, change.ID)
// A row journaled before its binding keeps its recorded reference.
require.Contains(t, []string{canonicalID, strings.TrimPrefix(change.ID, "fired-")}, change.ResourceID, change.ID)
}
require.ElementsMatch(t, want, ids, canonicalID)
}
for _, ref := range unbound {
_, found, err := store.ResolveHistorySourceIdentity(ref)
require.NoError(t, err)
require.False(t, found, ref)
got, err := store.GetRecentChanges(ref, time.Time{}, 10)
require.NoError(t, err)
require.Len(t, got, 1, ref)
require.Equal(t, ref, got[0].ResourceID, ref)
}
}
// Docker host alerts reference the host's source ID ("docker:<host ID>") and
// Swarm service alerts the service ID ("docker:<host ID>/service/<ID>") from
// every manager that reports the service. Both join the canonical host and
// service history, also for a host merged into its agent's machine. Hostnames,
// service names, container names and shortened container IDs never bind, not
// even to the history of what they name, and a cluster with a service that
// lacks an ID, whose alerts carry its normalized name, binds no service.
func TestHistoryIdentityMonitorAdapterResolvesDockerHostAndServiceReferences(t *testing.T) {
store, err := NewSQLiteResourceStore(t.TempDir(), "default")
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, store.Close()) })
now := time.Now().UTC().Truncate(time.Second)
container := strings.Repeat("f", 64)
serviceID := "x7k2m9q4w1e8r5t3y6u0i2o4p"
otherServiceID := "q1w2e3r4t5y6u7i8o9p0a1s2d"
manager := func(id, hostname, cluster string, services ...models.DockerService) models.DockerHost {
return models.DockerHost{ID: id, Hostname: hostname, Status: "online", LastSeen: now, Services: services,
Swarm: &models.DockerSwarmInfo{NodeID: id + "-node", NodeRole: "manager", LocalState: "active", ControlAvailable: true, ClusterID: cluster}}
}
web := models.DockerService{ID: serviceID, Name: "web"}
// hostA runs on a machine whose agent also reports, so the two merge.
hostA := manager("dh-7f3a", "tower", "swarm-1", web)
hostA.AgentID, hostA.MachineID = "host-tower", "0123456789abcdef"
hostA.Containers = []models.DockerContainer{{ID: container, Name: "worker", State: "running"}}
hostB := manager("dh-91c0", "rack", "swarm-1", web)
// In swarm-2 a service without an ID is named like another service's ID.
hostC := manager("dh-c4d2", "edge", "swarm-2", models.DockerService{ID: otherServiceID, Name: "api"}, models.DockerService{Name: strings.ToUpper(otherServiceID)})
// Rows journaled before this process started are bound by a later generation.
legacy := []string{"docker:dh-91c0", "docker:dh-91c0/service/" + serviceID, "docker:dh-7f3a/worker"}
for i, ref := range legacy {
require.NoError(t, store.RecordChange(ResourceChange{ID: "fired-" + ref, ResourceID: ref, Kind: ChangeAlertFired, SourceType: SourceHeuristic, ObservedAt: now.Add(time.Duration(i) * time.Second)}))
}
adapter := NewMonitorAdapter(NewRegistry(store))
adapter.PopulateFromSnapshot(models.StateSnapshot{
Hosts: []models.Host{{ID: "host-tower", Hostname: "tower", MachineID: "0123456789abcdef", Status: "online", LastSeen: now}},
DockerHosts: []models.DockerHost{hostA, hostB, hostC},
})
registry := adapter.currentRegistry()
hostAID := registry.sourceResourceID(SourceDocker, "dh-7f3a")
hostBID := registry.sourceResourceID(SourceDocker, "dh-91c0")
serviceResourceID := historyIdentityResourceID(t, registry, ResourceTypeDockerService, "web")
containerID := registry.sourceResourceID(SourceDocker, "dh-7f3a/container/"+container)
require.Equal(t, registry.sourceResourceID(SourceAgent, "host-tower"), hostAID, "the Docker host merges into its agent's machine")
require.NotEqual(t, hostAID, hostBID)
require.NotEmpty(t, containerID)
bound := map[string]string{
"docker:dh-91c0": hostBID,
"docker:dh-91c0/service/" + serviceID: serviceResourceID,
"docker:dh-7f3a": hostAID,
"docker:dh-7f3a/service/" + serviceID: serviceResourceID,
}
unbound := []string{
"docker:tower", // hostname
"docker:dh-7f3a/service/web", // service name
"docker:dh-c4d2/service/" + otherServiceID, // its cluster has a service without an ID
"docker-service:web", // hostless service name
"docker:dh-7f3a/worker", // container name
"docker:dh-7f3a/" + container[:12], // shortened container ID
"docker:unknown",
}
for i, ref := range append([]string{"docker:dh-7f3a", "docker:dh-7f3a/service/" + serviceID}, unbound...) {
if ref == "docker:dh-7f3a/worker" {
continue // journaled above
}
require.NoError(t, adapter.RecordChange(ResourceChange{ID: "fired-" + ref, ResourceID: ref, Kind: ChangeAlertFired, SourceType: SourceHeuristic, ObservedAt: now.Add(time.Minute + time.Duration(i)*time.Second)}))
}
requireHistoryReferenceBindings(t, store, bound, unbound)
// Names and shortened IDs are never retried against later inventory.
require.Contains(t, adapter.legacyHistory.pending, "docker:tower")
for _, ref := range []string{"docker:dh-7f3a/worker", "docker:dh-7f3a/" + container[:12], "docker-service:web"} {
require.NotContains(t, adapter.legacyHistory.pending, ref)
}
got, err := store.GetRecentChangesFiltered(containerID, time.Time{}, 10, ResourceChangeFilters{Kinds: []ChangeKind{ChangeAlertFired}})
require.NoError(t, err)
require.Empty(t, got, "a container name or short ID never joins the container's history")
// While its cluster has a service without an ID, a service reference is a
// conflict and must not follow a binding recorded earlier.
collision := "docker:dh-c4d2/service/" + otherServiceID
apiID := historyIdentityResourceID(t, registry, ResourceTypeDockerService, "api")
require.NoError(t, store.RecordChangeWithSourceIdentity(ResourceChange{ID: "earlier-api", ResourceID: apiID, Kind: ChangeAlertFired, SourceType: SourceHeuristic, ObservedAt: now}, collision))
require.NoError(t, adapter.RecordChange(ResourceChange{ID: "conflict-api", ResourceID: collision, Kind: ChangeAlertFired, SourceType: SourceHeuristic, ObservedAt: now.Add(2 * time.Minute)}))
// The same holds once the service with that ID has left inventory.
hostC.Services = hostC.Services[1:]
adapter.PopulateFromSnapshot(models.StateSnapshot{
Hosts: []models.Host{{ID: "host-tower", Hostname: "tower", MachineID: "0123456789abcdef", Status: "online", LastSeen: now}},
DockerHosts: []models.DockerHost{hostA, hostB, hostC},
})
require.NoError(t, adapter.RecordChange(ResourceChange{ID: "conflict-api-removed", ResourceID: collision, Kind: ChangeAlertFired, SourceType: SourceHeuristic, ObservedAt: now.Add(3 * time.Minute)}))
for _, id := range []string{"conflict-api", "conflict-api-removed"} {
var recordedAs string
require.NoError(t, store.db.QueryRow(`SELECT canonical_id FROM resource_changes WHERE id = ?`, id).Scan(&recordedAs))
require.Equal(t, collision, recordedAs, id)
}
// After the service leaves inventory its alerts follow the retained binding;
// a container name still binds nothing.
removed := NewMonitorAdapter(NewRegistry(store))
for _, ref := range []string{"docker:dh-91c0/service/" + serviceID, "docker:dh-7f3a/worker"} {
require.NoError(t, removed.RecordChange(ResourceChange{ID: "resolved-" + ref, ResourceID: ref, Kind: ChangeAlertResolved, SourceType: SourceHeuristic, ObservedAt: now.Add(time.Hour)}))
}
got, err = store.GetRecentChangesFiltered(serviceResourceID, time.Time{}, 10, ResourceChangeFilters{Kinds: []ChangeKind{ChangeAlertFired, ChangeAlertResolved}})
require.NoError(t, err)
require.Len(t, got, 3)
require.Equal(t, "resolved-docker:dh-91c0/service/"+serviceID, got[0].ID)
require.Equal(t, serviceResourceID, got[0].ResourceID)
got, err = store.GetRecentChanges("docker:dh-7f3a/worker", time.Time{}, 10)
require.NoError(t, err)
require.Len(t, got, 2)
require.Equal(t, "docker:dh-7f3a/worker", got[0].ResourceID)
}
// Sub-resource alerts (ZFS pools and devices, host filesystems, disks, RAID
// arrays, sensors, the Unraid array) name no resource of their own. They join
// the history of the owner their reference carries a durable ID for: the
// storage, the host, or the Unraid array storage. Device labels never bind a
// physical disk, and an owner of the wrong type or a name binds nothing.
func TestHistoryIdentityMonitorAdapterResolvesSubResourceReferences(t *testing.T) {
store, err := NewSQLiteResourceStore(t.TempDir(), "default")
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, store.Close()) })
now := time.Now().UTC().Truncate(time.Second)
snapshot := proxmoxHistoryIdentitySnapshot(now)
snapshot.Storage = []models.Storage{{ID: "lab-pve1-local-zfs", Name: "local-zfs", Node: "pve1", Instance: "lab", Type: "zfspool", Status: "available", Total: 100, Used: 10, LastSeen: now}}
snapshot.Hosts[0].Sensors.SMART = []models.HostDiskSMART{{Device: "sda", Serial: "S3Z1NB0K", Model: "Samsung SSD", Health: "PASSED", Temperature: 34}}
snapshot.Hosts = append(snapshot.Hosts, models.Host{ID: "host-nas", Hostname: "nas", MachineID: "fedcba9876543210", Status: "online", LastSeen: now,
Unraid: &models.HostUnraidStorage{ArrayStarted: true, ArrayState: "STARTED", Disks: []models.HostUnraidDisk{{Name: "disk1", Device: "sdb", Role: "data", Status: "DISK_OK", Serial: "WD-1"}}}})
adapter := NewMonitorAdapter(NewRegistry(store))
adapter.PopulateFromSnapshot(snapshot)
registry := adapter.currentRegistry()
nodeID := historyIdentityResourceID(t, registry, ResourceTypeAgent, "pve1")
storageID := registry.sourceResourceID(SourceProxmox, "lab-pve1-local-zfs")
unraidID := registry.sourceResourceID(SourceAgent, "host-nas/storage:unraid-array")
diskID := registry.sourceResourceID(SourceAgent, HostSMARTDiskSourceID(snapshot.Hosts[0], snapshot.Hosts[0].Sensors.SMART[0]))
require.NotEmpty(t, storageID)
require.NotEmpty(t, unraidID)
require.NotEmpty(t, diskID)
bound := map[string]string{
"lab-pve1-local-zfs/zfs-pool:local-zfs": storageID,
"lab-pve1-local-zfs/zfs-pool:local-zfs/device:sda2": storageID,
"agent:host-pve1/disk:var": nodeID,
"agent:host-pve1/disk:sda": nodeID,
"agent:host-pve1/disk_temp:sda": nodeID,
"agent:host-pve1/raid:md0": nodeID,
"agent:host-pve1/custom:fan1": nodeID,
"agent:host-nas/storage:unraid-array": unraidID,
}
unbound := []string{
"lab-pve1/zfs-pool:local-zfs", // the owner part names a node, not a storage
"local-zfs/zfs-pool:local-zfs", // a storage name, not its ID
"agent:pve1/disk:var", // a hostname, not the host ID
"agent:host-pve1/service:sshd", // not a sub-resource shape any producer emits
"agent:host-pve1/disk:",
}
refs := append([]string{}, unbound...)
for ref := range bound {
refs = append(refs, ref)
}
for i, ref := range refs {
require.NoError(t, adapter.RecordChange(ResourceChange{ID: "fired-" + ref, ResourceID: ref, Kind: ChangeAlertFired, SourceType: SourceHeuristic, ObservedAt: now.Add(time.Duration(i) * time.Second)}))
}
requireHistoryReferenceBindings(t, store, bound, unbound)
got, err := store.GetRecentChangesFiltered(diskID, time.Time{}, 10, ResourceChangeFilters{Kinds: []ChangeKind{ChangeAlertFired}})
require.NoError(t, err)
require.Empty(t, got, "a kernel device label never binds a physical disk")
// Recovery after the storage left inventory follows the retained binding.
pool := "lab-pve1-local-zfs/zfs-pool:local-zfs"
removed := NewMonitorAdapter(NewRegistry(store))
require.NoError(t, removed.RecordChange(ResourceChange{ID: "resolved-" + pool, ResourceID: pool, Kind: ChangeAlertResolved, SourceType: SourceHeuristic, ObservedAt: now.Add(time.Hour)}))
got, err = store.GetRecentChangesFiltered(storageID, time.Time{}, 10, ResourceChangeFilters{Kinds: []ChangeKind{ChangeAlertResolved}})
require.NoError(t, err)
require.Len(t, got, 1)
require.Equal(t, storageID, got[0].ResourceID)
// An owner reference that two resources answer to, or that names a resource
// of another type, binds nothing and must not follow the retained binding.
vmID := historyIdentityResourceID(t, registry, ResourceTypeVM, "web")
for _, conflict := range []struct {
name string
source DataSource
}{{"ambiguous", SourcePBS}, {"wrong-type", SourceProxmox}} {
registry.mu.Lock()
delete(registry.bySource[SourcePBS], "lab-pve1-local-zfs")
if registry.bySource[conflict.source] == nil {
registry.bySource[conflict.source] = make(map[string]string)
}
registry.bySource[conflict.source]["lab-pve1-local-zfs"] = vmID
registry.mu.Unlock()
id := conflict.name + "-" + pool
require.NoError(t, adapter.RecordChange(ResourceChange{ID: id, ResourceID: pool, Kind: ChangeAlertFired, SourceType: SourceHeuristic, ObservedAt: now.Add(2 * time.Hour)}))
var recordedAs string
require.NoError(t, store.db.QueryRow(`SELECT canonical_id FROM resource_changes WHERE id = ?`, id).Scan(&recordedAs))
require.Equal(t, pool, recordedAs, conflict.name)
bound, _, err := store.ResolveHistorySourceIdentity(pool)
require.NoError(t, err)
require.Equal(t, storageID, bound, conflict.name)
}
// A conflicting reference without a binding is retried, like one found at
// startup, and binds once a generation names its owner unambiguously.
device := "lab-pve1-local-zfs/zfs-pool:local-zfs/device:sdc2"
require.NoError(t, adapter.RecordChange(ResourceChange{ID: "fired-" + device, ResourceID: device, Kind: ChangeAlertFired, SourceType: SourceHeuristic, ObservedAt: now.Add(3 * time.Hour)}))
require.Contains(t, adapter.legacyHistory.pending, device)
adapter.PopulateFromSnapshot(snapshot)
deviceBinding, found, err := store.ResolveHistorySourceIdentity(device)
require.NoError(t, err)
require.True(t, found)
require.Equal(t, storageID, deviceBinding)
}
+15 -11
View File
@@ -198,7 +198,8 @@ func (a *MonitorAdapter) currentRegistry() *ResourceRegistry {
// RecordChange forwards canonical resource-history events into the underlying
// resource store when monitoring has a durable store attached. Alert lifecycle
// events carry source-native references (Proxmox node and guest IDs, agent
// IDs); they resolve here so history reads by canonical ID include them.
// IDs, Docker host and Swarm service IDs, sub-resource references); they
// resolve here so history reads by canonical ID include them.
func (a *MonitorAdapter) RecordChange(change ResourceChange) error {
registry := a.currentRegistry()
if registry == nil || registry.store == nil {
@@ -229,16 +230,19 @@ func (a *MonitorAdapter) RecordChange(change ResourceChange) error {
}
} else if resolvedID, claimed := registry.resolveHistoryReference(sourceRef); resolvedID != "" {
change.ResourceID = resolvedID
} else if hasHistory && !claimed && !isDockerHistoryReference(sourceRef) {
// No resource answers to it: keep the binding an earlier event recorded.
id, found, err := history.ResolveHistorySourceIdentity(CanonicalResourceID(sourceRef))
if err != nil {
return err
}
if found {
change.ResourceID = id
} else {
unbound = true
} else if hasHistory && !isDockerNameHistoryReference(sourceRef) {
// A later generation may name the resource or settle a conflict.
unbound = true
if !claimed {
// No resource answers to it: keep the binding an earlier event recorded.
id, found, err := history.ResolveHistorySourceIdentity(CanonicalResourceID(sourceRef))
if err != nil {
return err
}
if found {
change.ResourceID = id
unbound = false
}
}
}
if hasHistory && CanonicalResourceID(sourceRef) != CanonicalResourceID(change.ResourceID) {