From 7547375cc23fba15e9f004ac4c15574147b09250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dvo=C5=99=C3=A1k?= <150935816+Dvorinka@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:12:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(notifications):=20producers=20=E2=80=94=20?= =?UTF-8?q?backups,=20agent=20health,=20scans,=20upgrades=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(filters): dynamic filter framework — typed schema, URL state, FilterBar Port of dflow's filter.utils pattern, adapted to Containr: - lib/dynamic-filter.ts: FilterConfig schema (search/select/multiselect/ boolean/date-range/status), FilterEngine with client-side apply, derived options with counts, active-filter tracking, removal. - lib/use-dynamic-filter.ts: filter state lives in ?f. search params — filtered views are shareable links. useFilterState exposes URL state before data loads (server-backed filters); useDynamicFilter adds the engine over fetched data. - shared/components/FilterBar.tsx: renders a schema as compact controls + active-filter chips, matching design tokens. - Applied: audit logs (server-backed — state in URL, params to API), builds (server-backed + client date-range), activity (severity to the server query, category/search on the fetched page). - 8 unit tests for the engine. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(filters): erasableSyntaxOnly compat + BuildStatus cast - Constructor parameter properties are banned under erasableSyntaxOnly — declare fields explicitly. - listBuilds status param needs the BuildStatus type, not raw string. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(notifications): producers round-out — backups, agent health, scans, upgrades Four new emitters behind insertUserNotification: - Backups: createBackupProcess notifies the database owner on completion (with size) and on failure (docker-down, snapshot error). - Agent health: StartAgentHealthSweep marks agents offline after 3 min without heartbeat and back online on recovery — admins notified on each transition. - Security scans: Scanner.OnScanComplete callback notifies the project owner with critical/high/total counts + score; scans with findings get a distinct title. - Upgrades: daily check against the latest GitHub release; admins notified once per new tag (deduped via app_settings). Version stamped into the image via Dockerfile ARG + ldflags on tag builds; "dev" disables the check. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 + app/backend/Dockerfile | 4 +- app/backend/internal/api/databases.go | 10 + .../internal/api/notification_producers.go | 209 ++++++++++++++++++ .../api/notification_producers_test.go | 26 +++ app/backend/internal/api/routes.go | 7 + app/backend/internal/api/security.go | 20 +- app/backend/internal/security/scanner.go | 12 +- 8 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 app/backend/internal/api/notification_producers.go create mode 100644 app/backend/internal/api/notification_producers_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f851f7c..c898985 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -248,6 +248,10 @@ jobs: push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta-backend.outputs.tags }} labels: ${{ steps.meta-backend.outputs.labels }} + # Stamp the release tag into the binary so the in-app upgrade + # check knows what it's running ("dev" disables it). + build-args: | + VERSION=${{ github.ref_type == 'tag' && github.ref_name || 'dev' }} cache-from: type=gha,scope=backend cache-to: type=gha,mode=max,scope=backend diff --git a/app/backend/Dockerfile b/app/backend/Dockerfile index c72946f..8b34b91 100644 --- a/app/backend/Dockerfile +++ b/app/backend/Dockerfile @@ -9,7 +9,9 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main cmd/server/main.go +ARG VERSION=dev +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo \ + -ldflags "-X containr/internal/api.Version=${VERSION}" -o main cmd/server/main.go # Build embedded Better Auth runtime dependencies FROM node:22-alpine AS auth-builder diff --git a/app/backend/internal/api/databases.go b/app/backend/internal/api/databases.go index 93c2472..3db63d5 100644 --- a/app/backend/internal/api/databases.go +++ b/app/backend/internal/api/databases.go @@ -2,6 +2,7 @@ package api import ( "archive/tar" + "containr/internal/database" "containr/internal/database/sqlcdb" "containr/internal/deployment" "containr/internal/docker" @@ -1868,14 +1869,22 @@ func (h *DatabaseHandler) createBackupProcess(databaseID, backupID, backupPath s ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) defer cancel() + notify := func(title, body string) { + if row, err := h.queries.GetDatabaseServiceByID(context.Background(), databaseID); err == nil && row.UserID != "" { + insertUserNotification(&database.DB{DB: h.db}, row.UserID, "backup", title, body, "database", databaseID) + } + } + if h.dockerClient == nil { _ = h.setDatabaseBackupStatus(backupID, "failed", "0 B", false) + notify("Database backup failed", "Backup could not start: docker unavailable.") return } sizeLabel, err := h.snapshotDatabaseVolume(ctx, databaseID, backupPath) if err != nil { _ = h.setDatabaseBackupStatus(backupID, "failed", "0 B", false) + notify("Database backup failed", fmt.Sprintf("Snapshot failed: %v", err)) return } if dbRow, err := h.queries.GetDatabaseServiceByID(ctx, databaseID); err == nil { @@ -1886,6 +1895,7 @@ func (h *DatabaseHandler) createBackupProcess(databaseID, backupID, backupPath s } } _ = h.setDatabaseBackupStatus(backupID, "completed", sizeLabel, true) + notify("Database backup completed", fmt.Sprintf("Backup archived (%s).", sizeLabel)) } func (h *DatabaseHandler) restoreBackupProcess(databaseID, backupID string, backupPath string) { diff --git a/app/backend/internal/api/notification_producers.go b/app/backend/internal/api/notification_producers.go new file mode 100644 index 0000000..d09a77e --- /dev/null +++ b/app/backend/internal/api/notification_producers.go @@ -0,0 +1,209 @@ +package api + +// Notification producers — the emitters behind `insertUserNotification`. +// Deployment success/failure lives in deployments.go; this file covers +// the remaining producers from the roadmap: admin fan-out helper, the +// agent offline/online sweep, and the upgrade-available check. + +import ( + "containr/internal/database" + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "time" + + "github.com/google/uuid" +) + +// Version is stamped at image build time via -ldflags "-X +// containr/internal/api.Version=…". "dev" disables the upgrade check. +var Version = "dev" + +// notifyAdmins inserts a notification for every admin user. Best-effort. +func notifyAdmins(db *database.DB, kind, title, body, resourceType, resourceID string) { + if db == nil { + return + } + rows, err := db.Query(`SELECT id FROM users WHERE is_admin`) + if err != nil { + log.Printf("containr: notify admins lookup failed: %v", err) + return + } + defer rows.Close() + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err == nil { + insertUserNotification(db, id.String(), kind, title, body, resourceType, resourceID) + } + } +} + +// StartAgentHealthSweep marks agents offline when their heartbeat goes +// stale (and back online when it resumes), notifying admins on each +// transition. Heartbeats keep the row fresh via UpdateAgentHeartbeat. +func StartAgentHealthSweep(ctx context.Context, db *database.DB) { + const staleAfter = 3 * time.Minute + go func() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + reconcileAgentHealth(db, staleAfter) + } + } + }() +} + +func reconcileAgentHealth(db *database.DB, staleAfter time.Duration) { + cutoff := time.Now().Add(-staleAfter) + type agentRow struct { + id string + name string + status string + stale bool + } + var agents []agentRow + rows, err := db.Query( + `SELECT id, COALESCE(name, hostname, id::text), COALESCE(status, ''), + COALESCE(last_heartbeat < $1, TRUE) + FROM node_agents`, cutoff) + if err != nil { + log.Printf("containr: agent health sweep: %v", err) + return + } + for rows.Next() { + var a agentRow + if err := rows.Scan(&a.id, &a.name, &a.status, &a.stale); err == nil { + agents = append(agents, a) + } + } + rows.Close() + + for _, a := range agents { + switch { + case a.stale && a.status != "offline": + if _, err := db.Exec( + `UPDATE node_agents SET status = 'offline', updated_at = NOW() WHERE id = $1`, a.id); err != nil { + continue + } + notifyAdmins(db, "agent", "Node agent offline", + fmt.Sprintf("Agent %s missed its heartbeat for over %s.", a.name, staleAfter), "agent", a.id) + case !a.stale && a.status == "offline": + if _, err := db.Exec( + `UPDATE node_agents SET status = 'online', updated_at = NOW() WHERE id = $1`, a.id); err != nil { + continue + } + notifyAdmins(db, "agent", "Node agent back online", + fmt.Sprintf("Agent %s resumed heartbeating.", a.name), "agent", a.id) + } + } +} + +// StartUpgradeCheck polls GitHub for the latest release once a day and +// notifies admins when it is newer than the running version. The last +// notified tag is persisted in app_settings so restarts don't re-fire. +func StartUpgradeCheck(ctx context.Context, db *database.DB, currentVersion string) { + if currentVersion == "" || currentVersion == "dev" { + return + } + go func() { + checkForUpgrade(db, currentVersion) + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + checkForUpgrade(db, currentVersion) + } + } + }() +} + +func checkForUpgrade(db *database.DB, currentVersion string) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + "https://api.github.com/repos/Dvorinka/Containr/releases/latest", nil) + if err != nil { + return + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "containr-upgrade-check") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return + } + var release struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil || release.TagName == "" { + return + } + if !semverNewer(release.TagName, currentVersion) { + return + } + + var lastNotified string + _ = db.QueryRow(`SELECT value FROM app_settings WHERE key = 'upgrade_notified_tag'`).Scan(&lastNotified) + if lastNotified == release.TagName { + return + } + notifyAdmins(db, "upgrade", "Containr update available", + fmt.Sprintf("Release %s is available (running %s). %s", release.TagName, currentVersion, release.HTMLURL), + "release", release.TagName) + _, _ = db.Exec( + `INSERT INTO app_settings (key, value, updated_at) VALUES ('upgrade_notified_tag', $1, NOW()) + ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`, release.TagName) +} + +// semverNewer reports whether candidate is a higher semver than current. +// Non-semver input (e.g. "dev", commit hashes) compares false. +func semverNewer(candidate, current string) bool { + parse := func(s string) ([]int, bool) { + s = strings.TrimPrefix(strings.TrimSpace(s), "v") + // Prerelease/build metadata sorts after the same base version + // upstream — for "is a newer release available" purposes the base + // comparison is what matters. + if j := strings.IndexAny(s, "-+"); j >= 0 { + s = s[:j] + } + parts := strings.Split(s, ".") + if len(parts) == 0 || len(parts) > 3 { + return nil, false + } + out := make([]int, 3) + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil { + return nil, false + } + out[i] = n + } + return out, true + } + c, ok1 := parse(candidate) + cur, ok2 := parse(current) + if !ok1 || !ok2 { + return false + } + for i := 0; i < 3; i++ { + if c[i] != cur[i] { + return c[i] > cur[i] + } + } + return false +} diff --git a/app/backend/internal/api/notification_producers_test.go b/app/backend/internal/api/notification_producers_test.go new file mode 100644 index 0000000..c7ba16e --- /dev/null +++ b/app/backend/internal/api/notification_producers_test.go @@ -0,0 +1,26 @@ +package api + +import "testing" + +func TestSemverNewer(t *testing.T) { + cases := []struct { + candidate, current string + want bool + }{ + {"v1.2.3", "v1.2.2", true}, + {"1.3.0", "v1.2.9", true}, + {"v2.0.0", "v1.9.9", true}, + {"v1.2.3", "v1.2.3", false}, + {"v1.2.0", "v1.2.3", false}, + {"v1.10.0", "v1.9.9", true}, // numeric, not lexicographic + {"dev", "v1.0.0", false}, + {"v1.0.0", "main", false}, + {"v1.2.3-rc.1", "v1.2.2", true}, // prerelease suffix tolerated + {"", "v1.0.0", false}, + } + for _, tc := range cases { + if got := semverNewer(tc.candidate, tc.current); got != tc.want { + t.Errorf("semverNewer(%q, %q) = %v, want %v", tc.candidate, tc.current, got, tc.want) + } + } +} diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index c4d3c96..f0b0236 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -109,6 +109,13 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg // Audit retention — prunes rows past AUDIT_RETENTION_DAYS (default 90). StartAuditRetention(context.Background(), db) + // Agent health — marks stale heartbeats offline, notifies admins on + // offline/recovery transitions. + StartAgentHealthSweep(context.Background(), db) + + // Daily upgrade check — notifies admins when a newer release exists. + StartUpgradeCheck(context.Background(), db, Version) + // Initialize database handler databaseHandler := NewDatabaseHandler(db.DB, dockerClient) diff --git a/app/backend/internal/api/security.go b/app/backend/internal/api/security.go index 05aa255..765a17a 100644 --- a/app/backend/internal/api/security.go +++ b/app/backend/internal/api/security.go @@ -29,9 +29,27 @@ type SecurityHandler struct { func NewSecurityHandler(db *database.DB, encryptionKey string) *SecurityHandler { encryptionManager, _ := security.NewEncryptionManager(encryptionKey) + scanner := security.NewScanner(db) + // Notify the project owner when a background scan completes — criticals + // earn a distinct title so they stand out in the notification list. + scanner.OnScanComplete = func(scan *security.SecurityScan) { + var ownerID string + if err := db.QueryRow(`SELECT owner_id::text FROM projects WHERE id = $1`, scan.ProjectID).Scan(&ownerID); err != nil || ownerID == "" { + return + } + title := "Security scan completed" + if scan.Summary.Critical > 0 || scan.Summary.High > 0 { + title = "Security scan found issues" + } + insertUserNotification(db, ownerID, "security", title, + fmt.Sprintf("%s scan finished: %d critical, %d high, %d total findings (score %d/100).", + scan.ScanType, scan.Summary.Critical, scan.Summary.High, scan.Summary.Total, scan.Summary.Score), + "project", scan.ProjectID) + } + return &SecurityHandler{ db: db, - scanner: security.NewScanner(db), + scanner: scanner, complianceManager: security.NewComplianceManager(db), encryptionManager: encryptionManager, dataRetentionManager: security.NewDataRetentionManager(encryptionManager), diff --git a/app/backend/internal/security/scanner.go b/app/backend/internal/security/scanner.go index 0848a94..e883b32 100644 --- a/app/backend/internal/security/scanner.go +++ b/app/backend/internal/security/scanner.go @@ -54,6 +54,9 @@ type ScanSummary struct { // Scanner handles security scanning operations type Scanner struct { db *database.DB + // OnScanComplete fires after a scan finishes (goroutine context) — + // wired by the api layer for notifications. Optional. + OnScanComplete func(scan *SecurityScan) } // NewScanner creates a new security scanner @@ -115,8 +118,11 @@ func (s *Scanner) performScan(scan *SecurityScan) { // Update scan with results completedAt := time.Now() + scan.CompletedAt = &completedAt + scan.Summary = summary + scan.Status = "completed" _, err := s.db.Exec(` - UPDATE security_scans + UPDATE security_scans SET status = $1, completed_at = $2, summary = $3 WHERE id = $4 `, "completed", completedAt, summaryToJSON(summary), scan.ID) @@ -137,6 +143,10 @@ func (s *Scanner) performScan(scan *SecurityScan) { log.Printf("Failed to store vulnerability %s: %v", vuln.ID, err) } } + + if s.OnScanComplete != nil { + s.OnScanComplete(scan) + } } // scanDependencies scans for known vulnerable dependencies