Skip to content

Notifications 1/6: foundation — data model, options, dispatch skeleton #220

Description

@LarsLaskowski

First stage of the optional notification feature set. It creates the persistence, configuration, and pipeline skeleton that stages #221#225 build on. No notification is ever sent by this issue — it ends with a dispatch service that can capture a baseline and log.

Full design: notification concept

Context

DockerUpdateGuard already detects everything worth notifying about — UpdateFinding rows for running containers and observed images, VulnerabilityFinding rows including Critical severity — but surfaces them only in the Blazor UI. There is no notification infrastructure of any kind in the repository.

The chosen architecture is an outbox/diff pattern: a scheduled background service periodically diffs the already persisted findings against a notification ledger and dispatches the delta. No existing orchestrator or enrichment service is modified. The deciding reason is that RuntimeContainerScanOrchestrator deactivates and re-creates runtime update findings on every scan (DeactivateSupersededRuntimeFindingsAsync followed by CreateRuntimeFindingAsync), so "one notification per finding row" would fire on every scan cycle. Deduplication must therefore be content based and persisted — and once that ledger exists, the outbox is nearly free while direct hooks would additionally put webhook latency and retries inside the scan transaction.

Resulting delivery semantics: at-least-once. A crash between a successful send and SaveChangesAsync re-sends one digest.

Scope

1. Data layer — new entity and enums

src/DockerUpdateGuard.Data/Entities/NotificationRecord.cs (one type per file, Guid PK defaulting to Guid.NewGuid(), DateTimeOffset ...AtUtc timestamps, matching the existing entities):

Property Type Notes
Id Guid Primary key
Kind NotificationKind Event category
DedupKey string (max 512) Content-based identity; part of the unique index
ChannelName string (max 64) e.g. "Webhook" — one record per event and channel, which makes the ledger multi-channel ready
Status NotificationRecordStatus Lifecycle state
UpdateFindingId Guid? FK → UpdateFindings, OnDelete(SetNull)
VulnerabilityFindingId Guid? FK → VulnerabilityFindings, OnDelete(SetNull)
Subject string (max 512) Denormalized display text, e.g. prod / web-1: nginx:1.27.1 → 1.29.0
Details string? (text) Optional denormalized detail line
AttemptCount int Default 0
NextAttemptAtUtc DateTimeOffset? Backoff scheduling (used from #224)
LastError string? (max 1024) Truncated channel error message
DispatchBatchId Guid? Groups records delivered in the same digest
CreatedAtUtc DateTimeOffset Required
SentAtUtc DateTimeOffset?

The nullable SetNull foreign keys are deliberate: ScanCleanupBackgroundService deletes old findings, and the ledger must survive that without losing its dedup history — which is also why Subject/Details are denormalized rather than joined at read time.

New enum files (each individually XML-documented, NotSet = 0 first, per the C# instructions):

  • src/DockerUpdateGuard.Data/Entities/NotificationKind.csNotSet = 0, ContainerUpdateAvailable = 1, ObservedImageUpdateAvailable = 2, CriticalVulnerability = 3
  • src/DockerUpdateGuard.Data/Entities/NotificationRecordStatus.csNotSet = 0, Pending = 1, Sent = 2, Retrying = 3, DeadLettered = 4, Suppressed = 5 (Suppressed = captured as baseline, intentionally never sent)

src/DockerUpdateGuard.Data/Configurations/NotificationRecordConfiguration.cs modelled on VulnerabilityFindingConfiguration.cs:

  • ToTable("NotificationRecords"), HasKey, HasMaxLength per the table above, Details as HasColumnType("text")
  • unique index on (ChannelName, DedupKey) — the anti-join key and the guard against duplicate records
  • index on (Status, NextAttemptAtUtc) for the delivery query
  • optional index on CreatedAtUtc for the later history page (Notifications 6/6: notification history page (optional) #225)
  • HasOne<UpdateFinding>().WithMany().HasForeignKey(entity => entity.UpdateFindingId).OnDelete(DeleteBehavior.SetNull), same for VulnerabilityFinding

DbSet<NotificationRecord> NotificationRecords on src/DockerUpdateGuard.Data/DockerUpdateGuardDbContext.cs (configuration is picked up automatically by ApplyConfigurationsFromAssembly).

2. Migration Update8

Following the documented convention: generate, rename the files to Update8.cs / Update8.Designer.cs (timestamp prefix removed from the file name), keep the generated [Migration("yyyyMMddHHmmss_Update8")] attribute unchanged, update DockerUpdateGuardDbContextModelSnapshot.cs.

3. Configuration

New files under src/DockerUpdateGuard/Configuration/:

NotificationOptions.cs

Key Type Default Description
Enabled bool false Master switch; everything below is inert without it
DispatchIntervalMinutes int 5 Dispatch cadence, valid range 1–1440
NotifyContainerUpdates bool true Trigger A, container scope (#222)
NotifyObservedImageUpdates bool true Trigger A, observed-image scope (#222)
NotifyVulnerabilities bool true Trigger B (#223)
MinimumVulnerabilitySeverity VulnerabilitySeverity Critical Threshold, allows widening to High
NotifyPreExistingFindings bool false Send the historical backlog instead of capturing a silent baseline
MaxItemsPerNotification int 50 Digest item cap, 1–500
MaxDeliveryAttempts int 5 Attempts before dead-lettering, 1–20 (#224)
RetryBaseDelayMinutes int 5 Base for exponential backoff, 1–1440 (#224)
Webhook WebhookNotificationOptions new() Channel subsection (#221)

WebhookNotificationOptions.csEnabled (false), Url (string?), AuthorizationHeaderName ("Authorization"), AuthorizationHeaderValue (string?), PayloadFormat (WebhookPayloadFormat.Generic), RequestTimeoutSeconds (30), AllowInsecureHttp (false).

WebhookPayloadFormat.csNotSet = 0, Generic, Ntfy, Gotify.

Wire into src/DockerUpdateGuard/Configuration/DockerUpdateGuardOptions.cs:

/// <summary>
/// Notification configuration
/// </summary>
public NotificationOptions Notifications { get; set; } = new();

Extend DockerUpdateGuardOptionsValidator.cs with a private static void ValidateNotificationOptions(NotificationOptions options, List<string> failures) called from Validate, in the established style (full configuration paths in messages, reuse of ValidateAbsoluteHttpUri):

  • when Enabled is true, at least one channel must be enabled — mirrors the existing "provider must be configured" rule for vulnerabilities
  • range checks for DispatchIntervalMinutes, MaxItemsPerNotification, MaxDeliveryAttempts, RetryBaseDelayMinutes (always evaluated, not only when enabled — consistent with the other sections)
  • when Webhook.Enabled is true: Url required and an absolute http(s) URI; plaintext HTTP rejected unless AllowInsecureHttp is true (same reasoning and wording style as the Portainer plaintext guard, since an auth token may be attached); RequestTimeoutSeconds range 1–300

README.md: two new subsections ### DockerUpdateGuard:Notifications and ### DockerUpdateGuard:Notifications:Webhook in the configuration reference (between the Scanning and DockerInstances[] tables), same table format, plus the new section in the JSON example.

4. Notification subsystem skeleton

New folder src/DockerUpdateGuard/Notifications/ (peer of Vulnerabilities/), one type per file:

Notifications/
    Interfaces/INotificationChannel.cs
    Interfaces/INotificationChannelResolver.cs
    Interfaces/INotificationDispatchService.cs
    NotificationChannelResolver.cs
    NotificationDispatchService.cs
    NotificationDispatchBackgroundService.cs
    NotificationLoggingExtensions.cs
    Data/NotificationMessageData.cs
    Data/NotificationItemData.cs
    Data/NotificationDeliveryData.cs
public interface INotificationChannel
{
    /// <summary>
    /// Stable channel name persisted on NotificationRecord.ChannelName
    /// </summary>
    string Name { get; }

    /// <summary>
    /// Indicates whether the channel is enabled and configured
    /// </summary>
    bool IsEnabled { get; }

    /// <summary>
    /// Send a notification digest
    /// </summary>
    /// <param name="message">Notification digest</param>
    /// <param name="cancellationToken">Cancellation token</param>
    /// <returns>Delivery result</returns>
    Task<ExternalOperationResult<NotificationDeliveryData>> SendAsync(NotificationMessageData message,
                                                                     CancellationToken cancellationToken);
}
  • NotificationMessageData: Title, Summary, GeneratedAtUtc, UpdateItems / VulnerabilityItems (IReadOnlyList<NotificationItemData>), TruncatedItemCount
  • NotificationItemData: Kind, Subject, Details, Severity?, AdvisoryId?, CurrentVersion?, RecommendedVersion?
  • NotificationDeliveryData: DeliveredAtUtc, HttpStatusCode?
  • Channels never throw across the interface — every outcome is an ExternalOperationResult<T> status (Succeeded, NotConfigured, Failed, …), matching the repository-wide convention in Infrastructure/ExternalOperationResult{T}.cs

NotificationChannelResolver mirrors Vulnerabilities/VulnerabilityProviderResolver.cs (singleton, IOptionsMonitor<DockerUpdateGuardOptions>, concrete channels constructor-injected) but returns all enabled channels:

IReadOnlyList<INotificationChannel> ResolveEnabledChannels();

With Notifications.Enabled == false it returns an empty list. In this issue there is no concrete channel yet, so it always returns empty — #221 adds the webhook.

NotificationDispatchBackgroundService derives from Images/ScheduledBackgroundService (template: Images/VulnerabilityRefreshBackgroundService.cs): GetInterval() reads Notifications.DispatchIntervalMinutes, ShouldExecuteImmediately() returns true (cheap no-op when disabled and drains due retries after a restart), ExecuteCoreAsync creates an async scope and calls INotificationDispatchService.DispatchAsync.

NotificationDispatchService (scoped, takes DockerUpdateGuardDbContext, INotificationChannelResolver, IOptionsMonitor<DockerUpdateGuardOptions>, ILogger<T>) in this issue implements only:

  1. resolve enabled channels; none → return
  2. per channel: baseline check — if the channel has zero NotificationRecord rows, capture all currently matching findings as Suppressed and send nothing (unless NotifyPreExistingFindings is true). Detection queries themselves land in Notifications 3/6: update-availability notifications (trigger A) #222/Notifications 4/6: critical-CVE notifications for running containers (trigger B) #223; here the hook and the record-writing path exist and are exercised by tests with seeded findings.

Baseline capture is what prevents a notification storm: enabling the feature on an installation with hundreds of historical findings sends nothing, and only subsequent changes notify. Because the check is per channel, a channel added later gets the same treatment automatically. Fresh installations start with an empty baseline, so their first real scan results do notify.

5. Observability

  • Notifications/NotificationLoggingExtensions.cs with source-generated [LoggerMessage] methods in the free EventId block 3600–3699 (existing blocks: 1000–1010, 2000–2098, 3100–3106, 3200–3210, 3300–3310, 3400–3425, 3500–3501): 3600 dispatch skipped (disabled), 3601 baseline captured, 3602 events detected, 3603 digest sent, 3604 digest send failed, 3605 record dead-lettered, 3606 channel not configured, 3607 run summary
  • Metric name constants in src/DockerUpdateGuard.Telemetry/TelemetryMetricNames.cs: dockerupdateguard.notifications.sent, dockerupdateguard.notifications.failed, dockerupdateguard.notifications.pending; tag name notification.channel in TelemetryTagNames. Instrument wiring in ApplicationTelemetry follows in Notifications 5/6: delivery hardening — retry, dead-letter, retention, metrics #224.

6. Dependency injection

In src/DockerUpdateGuard/ServiceCollectionExtensions.cs (AddDockerUpdateGuardHost):

services.AddSingleton<INotificationChannelResolver, NotificationChannelResolver>();
services.AddScoped<INotificationDispatchService, NotificationDispatchService>();
services.AddHostedService<NotificationDispatchBackgroundService>();

plus the options binding with IValidateOptions and .ValidateOnStart() in the same shape as the existing sections.

Testing

  • src/Tests/DockerUpdateGuard.Data.Tests/Update8MigrationTests.cs mirroring Update7MigrationTests: CreateTableOperation for NotificationRecords, unique index on (ChannelName, DedupKey), SetNull FK behavior, column types and max lengths
  • extend src/Tests/DockerUpdateGuard.Data.Tests/MappingTests.cs
  • src/Tests/DockerUpdateGuard.Tests/NotificationDispatchServiceTests.cs (skeleton scenarios): disabled → no-op; no channels → no-op; baseline captured as Suppressed without sending; NotifyPreExistingFindings = true leaves records Pending; second run after a captured baseline does not re-capture
  • src/Tests/DockerUpdateGuard.Tests/NotificationChannelResolverTests.cs: master switch off → empty list
  • src/Tests/DockerUpdateGuard.Tests/NotificationDispatchBackgroundServiceTests.cs following ScanCleanupBackgroundServiceTests and the TestScanCleanupBackgroundService helper pattern
  • extend DockerUpdateGuardOptionsValidatorTests and ServiceCollectionExtensionsTests (new registrations)

Use the existing helpers: Data/SqliteTestDatabase.cs, Data/TestOptionsMonitor{TOptions}.cs, Data/TestLogger{TCategoryName}.cs.

Acceptance criteria

  • The application boots unchanged when the Notifications section is absent from the configuration
  • Notifications:Enabled = true without any enabled channel fails startup validation with a message naming the configuration path
  • Webhook validation rejects a missing/relative URL and plaintext HTTP without AllowInsecureHttp
  • Migration Update8 applies and rolls back cleanly; Update8MigrationTests cover table, unique index, and SetNull FK behavior
  • The unique index on (ChannelName, DedupKey) prevents duplicate ledger rows
  • With notifications disabled the dispatch service is a no-op and logs the skip at debug level
  • With notifications enabled and findings present, the first run captures a Suppressed baseline and sends nothing
  • README documents both new configuration sections and the JSON example includes them
  • 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