Skip to content

Notifications 3/6: update-availability notifications (trigger A) #222

Description

@LarsLaskowski

Third stage of the notification feature set: the first real trigger. Notifies when an update is available for a running container or an observed image. Depends on #220 and #221.

Full design: notification concept

Context

UpdateFinding rows are the event source. The subtlety that shapes this issue: RuntimeContainerScanOrchestrator deactivates and re-creates runtime update findings on every scan cycle (DeactivateSupersededRuntimeFindingsAsyncCreateRuntimeFindingAsync), so finding IDs churn constantly while the underlying fact ("web-1 can go to 1.29.0") stays the same. Deduplication therefore keys on content, not on the finding row, and the notification ledger from #220 persists that key.

Scope

Detection queries

Implemented in NotificationDispatchService, executed per enabled channel, each guarded by its own toggle.

Container updates (NotifyContainerUpdates, Kind = ContainerUpdateAvailable) — active UpdateFindings where

  • IsActive
  • ContainerSnapshotId != null
  • RecommendedImageVersionId != null
  • Type ∈ { RuntimeImageUpdate, DerivedBaseRuntimeUpdate }

TagRecommendation findings are deliberately excluded: they represent "needs review" without a concrete upgrade target and would produce noise that the user cannot act on directly.

Observed image updates (NotifyObservedImageUpdates, Kind = ObservedImageUpdateAvailable) — active UpdateFindings where

  • IsActive
  • ObservedImageId != null
  • RecommendedImageVersionId != null
  • the referenced ObservedImage.IsEnabled is true (a disabled monitored image must not notify)
  • Type ∈ { BaseImageUpdate, RuntimeImageUpdate }

Both queries project the data needed for the ledger row and the digest — including the container snapshot's instance/container identity, the current image reference, and the recommended tag — in a single AsNoTracking projection. Per the C# instructions, navigations are materialized into explicit locals rather than assumed to be populated; LINQ method syntax only.

Dedup keys

update:container:{DockerInstanceId}:{ContainerId}:{RecommendedImageVersionId}
update:observed:{ObservedImageId}:{RecommendedImageVersionId}

The container key uses the stable (DockerInstanceId, ContainerId) pair from ContainerSnapshot rather than the snapshot ID, because snapshots are per-scan rows. Including RecommendedImageVersionId is what makes a newer recommendation a new event.

Detection performs an anti-join against NotificationRecords on (ChannelName, DedupKey) and inserts a Pending record for each unmatched event, with Subject/Details denormalized at insert time (e.g. prod / web-1: nginx:1.27.1 → 1.29.0). Detection is committed with SaveChangesAsync before delivery is attempted, so a crash during delivery cannot lose events — it can at most re-send them.

Resulting behavior

Situation Outcome
New update finding appears One digest entry on the next dispatch run
Same finding re-created by the next scan (new row, same recommendation) Nothing — the dedup key already has a Sent record
Recommendation moves on (1.29.0 → 1.29.1) New key → new record → notified again
Finding deactivated without successor (user updated the container) Nothing sent; "resolved" notifications are explicitly out of scope for v1
Deactivate-and-supersede within one scan Invisible to the dispatcher, which only sees the committed end state
Observed image disabled Its findings never notify

The existing Sent record is intentionally not re-pointed at the newer finding ID: Subject is already denormalized, so nothing is gained by the extra write.

Digest builder

One aggregated message per channel per dispatch run, not one message per finding — a scan that discovers twelve updates produces one notification, not twelve.

  • Title summarizes counts, e.g. DockerUpdateGuard: 3 updates available
  • Summary is a plain-text rendering used by the ntfy/Gotify formats
  • Items are capped at MaxItemsPerNotification; the remainder is reported through TruncatedItemCount and an "and N more" line in the summary
  • Ordering is deterministic (kind, then subject) so digests are stable and testable

On a successful send, every included record is set to Sent with SentAtUtc and a shared DispatchBatchId, then saved. Failure handling beyond a simple retry-next-run is added in #224.

Testing

Extend src/Tests/DockerUpdateGuard.Tests/NotificationDispatchServiceTests.cs (SQLite-backed context as in RuntimeContainerScanOrchestratorTests, fake INotificationChannel recording sends, TestOptionsMonitor, TestLogger):

  • new container update finding → exactly one item in exactly one digest
  • row churn: delete and re-create the finding with the same recommendation between two dispatch runs → no second notification (the regression this design exists for)
  • changed RecommendedImageVersionId → second notification
  • TagRecommendation finding → never notified
  • finding without RecommendedImageVersionId → never notified
  • observed image finding notified; the same finding with IsEnabled = false → not notified
  • NotifyContainerUpdates = false suppresses only the container scope, NotifyObservedImageUpdates = false only the observed scope
  • more than MaxItemsPerNotification events → digest capped, TruncatedItemCount correct, all records still marked Sent
  • send failure → records stay unsent and are retried on the next run (full backoff semantics land in Notifications 5/6: delivery hardening — retry, dead-letter, retention, metrics #224)
  • two channels enabled → each gets its own records and its own digest

Acceptance criteria

  • A new update finding produces exactly one digest entry on the next dispatch run
  • Repeated scans that re-create the same finding do not re-notify (covered by an explicit row-churn test)
  • A changed recommended version produces a new notification
  • TagRecommendation findings and findings without a recommendation never notify
  • Disabled observed images never notify; each scope toggle suppresses exactly its own scope
  • The digest aggregates all pending events into one message, caps at MaxItemsPerNotification, and reports the truncated count
  • Detection is committed before delivery is attempted
  • reihitsu-format ./ clean, build without new analyzer findings, all tests green

Out of scope

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions