You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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
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):
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
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:
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:
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:
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):
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
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
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 —
UpdateFindingrows for running containers and observed images,VulnerabilityFindingrows includingCriticalseverity — 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
RuntimeContainerScanOrchestratordeactivates and re-creates runtime update findings on every scan (DeactivateSupersededRuntimeFindingsAsyncfollowed byCreateRuntimeFindingAsync), 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
SaveChangesAsyncre-sends one digest.Scope
1. Data layer — new entity and enums
src/DockerUpdateGuard.Data/Entities/NotificationRecord.cs(one type per file,GuidPK defaulting toGuid.NewGuid(),DateTimeOffset ...AtUtctimestamps, matching the existing entities):IdGuidKindNotificationKindDedupKeystring(max 512)ChannelNamestring(max 64)"Webhook"— one record per event and channel, which makes the ledger multi-channel readyStatusNotificationRecordStatusUpdateFindingIdGuid?UpdateFindings,OnDelete(SetNull)VulnerabilityFindingIdGuid?VulnerabilityFindings,OnDelete(SetNull)Subjectstring(max 512)prod / web-1: nginx:1.27.1 → 1.29.0Detailsstring?(text)AttemptCountint0NextAttemptAtUtcDateTimeOffset?LastErrorstring?(max 1024)DispatchBatchIdGuid?CreatedAtUtcDateTimeOffsetSentAtUtcDateTimeOffset?The nullable
SetNullforeign keys are deliberate:ScanCleanupBackgroundServicedeletes old findings, and the ledger must survive that without losing its dedup history — which is also whySubject/Detailsare denormalized rather than joined at read time.New enum files (each individually XML-documented,
NotSet = 0first, per the C# instructions):src/DockerUpdateGuard.Data/Entities/NotificationKind.cs—NotSet = 0,ContainerUpdateAvailable = 1,ObservedImageUpdateAvailable = 2,CriticalVulnerability = 3src/DockerUpdateGuard.Data/Entities/NotificationRecordStatus.cs—NotSet = 0,Pending = 1,Sent = 2,Retrying = 3,DeadLettered = 4,Suppressed = 5(Suppressed= captured as baseline, intentionally never sent)src/DockerUpdateGuard.Data/Configurations/NotificationRecordConfiguration.csmodelled onVulnerabilityFindingConfiguration.cs:ToTable("NotificationRecords"),HasKey,HasMaxLengthper the table above,DetailsasHasColumnType("text")(ChannelName, DedupKey)— the anti-join key and the guard against duplicate records(Status, NextAttemptAtUtc)for the delivery queryCreatedAtUtcfor the later history page (Notifications 6/6: notification history page (optional) #225)HasOne<UpdateFinding>().WithMany().HasForeignKey(entity => entity.UpdateFindingId).OnDelete(DeleteBehavior.SetNull), same forVulnerabilityFindingDbSet<NotificationRecord> NotificationRecordsonsrc/DockerUpdateGuard.Data/DockerUpdateGuardDbContext.cs(configuration is picked up automatically byApplyConfigurationsFromAssembly).2. Migration
Update8Following 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, updateDockerUpdateGuardDbContextModelSnapshot.cs.3. Configuration
New files under
src/DockerUpdateGuard/Configuration/:NotificationOptions.csEnabledboolfalseDispatchIntervalMinutesint5NotifyContainerUpdatesbooltrueNotifyObservedImageUpdatesbooltrueNotifyVulnerabilitiesbooltrueMinimumVulnerabilitySeverityVulnerabilitySeverityCriticalHighNotifyPreExistingFindingsboolfalseMaxItemsPerNotificationint50MaxDeliveryAttemptsint5RetryBaseDelayMinutesint5WebhookWebhookNotificationOptionsnew()WebhookNotificationOptions.cs—Enabled(false),Url(string?),AuthorizationHeaderName("Authorization"),AuthorizationHeaderValue(string?),PayloadFormat(WebhookPayloadFormat.Generic),RequestTimeoutSeconds(30),AllowInsecureHttp(false).WebhookPayloadFormat.cs—NotSet = 0,Generic,Ntfy,Gotify.Wire into
src/DockerUpdateGuard/Configuration/DockerUpdateGuardOptions.cs:Extend
DockerUpdateGuardOptionsValidator.cswith aprivate static void ValidateNotificationOptions(NotificationOptions options, List<string> failures)called fromValidate, in the established style (full configuration paths in messages, reuse ofValidateAbsoluteHttpUri):Enabledis true, at least one channel must be enabled — mirrors the existing "provider must be configured" rule for vulnerabilitiesDispatchIntervalMinutes,MaxItemsPerNotification,MaxDeliveryAttempts,RetryBaseDelayMinutes(always evaluated, not only when enabled — consistent with the other sections)Webhook.Enabledis true:Urlrequired and an absolute http(s) URI; plaintext HTTP rejected unlessAllowInsecureHttpis true (same reasoning and wording style as the Portainer plaintext guard, since an auth token may be attached);RequestTimeoutSecondsrange 1–300README.md: two new subsections### DockerUpdateGuard:Notificationsand### DockerUpdateGuard:Notifications:Webhookin the configuration reference (between theScanningandDockerInstances[]tables), same table format, plus the new section in the JSON example.4. Notification subsystem skeleton
New folder
src/DockerUpdateGuard/Notifications/(peer ofVulnerabilities/), one type per file:NotificationMessageData:Title,Summary,GeneratedAtUtc,UpdateItems/VulnerabilityItems(IReadOnlyList<NotificationItemData>),TruncatedItemCountNotificationItemData:Kind,Subject,Details,Severity?,AdvisoryId?,CurrentVersion?,RecommendedVersion?NotificationDeliveryData:DeliveredAtUtc,HttpStatusCode?ExternalOperationResult<T>status (Succeeded,NotConfigured,Failed, …), matching the repository-wide convention inInfrastructure/ExternalOperationResult{T}.csNotificationChannelResolvermirrorsVulnerabilities/VulnerabilityProviderResolver.cs(singleton,IOptionsMonitor<DockerUpdateGuardOptions>, concrete channels constructor-injected) but returns all enabled channels:With
Notifications.Enabled == falseit returns an empty list. In this issue there is no concrete channel yet, so it always returns empty — #221 adds the webhook.NotificationDispatchBackgroundServicederives fromImages/ScheduledBackgroundService(template:Images/VulnerabilityRefreshBackgroundService.cs):GetInterval()readsNotifications.DispatchIntervalMinutes,ShouldExecuteImmediately()returnstrue(cheap no-op when disabled and drains due retries after a restart),ExecuteCoreAsynccreates an async scope and callsINotificationDispatchService.DispatchAsync.NotificationDispatchService(scoped, takesDockerUpdateGuardDbContext,INotificationChannelResolver,IOptionsMonitor<DockerUpdateGuardOptions>,ILogger<T>) in this issue implements only:NotificationRecordrows, capture all currently matching findings asSuppressedand send nothing (unlessNotifyPreExistingFindingsis 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.cswith 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 summarysrc/DockerUpdateGuard.Telemetry/TelemetryMetricNames.cs:dockerupdateguard.notifications.sent,dockerupdateguard.notifications.failed,dockerupdateguard.notifications.pending; tag namenotification.channelinTelemetryTagNames. Instrument wiring inApplicationTelemetryfollows in Notifications 5/6: delivery hardening — retry, dead-letter, retention, metrics #224.6. Dependency injection
In
src/DockerUpdateGuard/ServiceCollectionExtensions.cs(AddDockerUpdateGuardHost):plus the options binding with
IValidateOptionsand.ValidateOnStart()in the same shape as the existing sections.Testing
src/Tests/DockerUpdateGuard.Data.Tests/Update8MigrationTests.csmirroringUpdate7MigrationTests:CreateTableOperationforNotificationRecords, unique index on(ChannelName, DedupKey),SetNullFK behavior, column types and max lengthssrc/Tests/DockerUpdateGuard.Data.Tests/MappingTests.cssrc/Tests/DockerUpdateGuard.Tests/NotificationDispatchServiceTests.cs(skeleton scenarios): disabled → no-op; no channels → no-op; baseline captured asSuppressedwithout sending;NotifyPreExistingFindings = trueleaves recordsPending; second run after a captured baseline does not re-capturesrc/Tests/DockerUpdateGuard.Tests/NotificationChannelResolverTests.cs: master switch off → empty listsrc/Tests/DockerUpdateGuard.Tests/NotificationDispatchBackgroundServiceTests.csfollowingScanCleanupBackgroundServiceTestsand theTestScanCleanupBackgroundServicehelper patternDockerUpdateGuardOptionsValidatorTestsandServiceCollectionExtensionsTests(new registrations)Use the existing helpers:
Data/SqliteTestDatabase.cs,Data/TestOptionsMonitor{TOptions}.cs,Data/TestLogger{TCategoryName}.cs.Acceptance criteria
Notificationssection is absent from the configurationNotifications:Enabled = truewithout any enabled channel fails startup validation with a message naming the configuration pathAllowInsecureHttpUpdate8applies and rolls back cleanly;Update8MigrationTestscover table, unique index, andSetNullFK behavior(ChannelName, DedupKey)prevents duplicate ledger rowsSuppressedbaseline and sends nothingreihitsu-format ./clean, build without new analyzer findings, all tests greenOut of scope