perf(db): replace materialized views with maintained tables (cluster + sbom/vuln) #12

Merged
jonas merged 43 commits from chore/remove-material-views into main 2026-06-24 13:13:46 +02:00
Owner

Replaces the materialized-view families that were the dominant DB CPU cost
(wholesale REFRESH re-computing the whole fleet on every ingest trigger,
around the clock — pinning prod at ~95% node CPU) with regular tables that
are maintained cheaply.

cluster_summary / cluster_image_inventory

Incrementally maintained: each trigger recomputes only the clusters /
records changed since a max(last_change_at) watermark and UPSERTs them.

sbom/vuln family → tables fed by _src views

  • Extraction tables (sbom_component_view, view_unified_repositories_vulnerabilities,
    view_unified_image_vulnerabilities) — extract-on-arrival: re-extract
    only the entities whose source rows landed since a per-table watermark,
    debounced from the existing SBOM/scan ingest hooks. Fresh as data lands,
    cost proportional to what changed.
  • Aggregation tables (vuln_canonical_assets/summary, asset_risk) —
    daily REBUILD_DERIVED_TABLES job (03:00 Oslo) + manual "Rebuild now"
    via /admin/views/refresh. They fold in KEV/EPSS feeds and cluster/
    exposure state that change without a scan, so per-entity extraction
    doesn't fit them.
  • Per-ingest wholesale REFRESH paths are neutralized; *Populated gates
    read materialized_view_refreshes; cold-start populates once at boot.

Migration cutover: type-guarded DROP + CREATE TABLE AS ... WITH NO DATA
(columns inferred from the body); matviewsExist() now treats a table as
"present" so converted views aren't re-asserted as matviews on later boots.

Verify before merge (SQL is untested; Go vets)

  • Migration cutover on a populated copy: asset_risk + canonical
    tables land at their final definitions; no "relation does not exist"
    loop on a second boot.
  • EXPLAIN an extract-on-arrival re-INSERT for the two unified tables:
    the entity predicate must push into the source scans (one scan
    re-extracts one repo/image, not the fleet). sbom_component_view is
    already safe.
  • Read parity vs a pre-change snapshot + the CPU drop; a scan freshens
    the per-finding surfaces while the rolled-up dashboard/triage update
    on rebuild.

Note: freshness trade-off — the rolled-up vuln dashboard + triage scores
reflect the last rebuild (daily/manual), not the latest scan.

🤖 Generated with Claude Code

Replaces the materialized-view families that were the dominant DB CPU cost (wholesale `REFRESH` re-computing the whole fleet on every ingest trigger, around the clock — pinning prod at ~95% node CPU) with regular tables that are maintained cheaply. ## cluster_summary / cluster_image_inventory Incrementally maintained: each trigger recomputes only the clusters / records changed since a `max(last_change_at)` watermark and UPSERTs them. ## sbom/vuln family → tables fed by `_src` views - **Extraction tables** (`sbom_component_view`, `view_unified_repositories_vulnerabilities`, `view_unified_image_vulnerabilities`) — **extract-on-arrival**: re-extract only the entities whose source rows landed since a per-table watermark, debounced from the existing SBOM/scan ingest hooks. Fresh as data lands, cost proportional to what changed. - **Aggregation tables** (`vuln_canonical_assets/summary`, `asset_risk`) — **daily `REBUILD_DERIVED_TABLES` job (03:00 Oslo) + manual "Rebuild now"** via `/admin/views/refresh`. They fold in KEV/EPSS feeds and cluster/ exposure state that change without a scan, so per-entity extraction doesn't fit them. - Per-ingest wholesale `REFRESH` paths are neutralized; `*Populated` gates read `materialized_view_refreshes`; cold-start populates once at boot. Migration cutover: type-guarded DROP + `CREATE TABLE AS ... WITH NO DATA` (columns inferred from the body); `matviewsExist()` now treats a table as "present" so converted views aren't re-asserted as matviews on later boots. ## Verify before merge (SQL is untested; Go vets) - [x] Migration cutover on a **populated** copy: `asset_risk` + canonical tables land at their final definitions; no "relation does not exist" loop on a second boot. - [x] `EXPLAIN` an extract-on-arrival re-INSERT for the two unified tables: the entity predicate must push into the source scans (one scan re-extracts one repo/image, not the fleet). `sbom_component_view` is already safe. - [x] Read parity vs a pre-change snapshot + the CPU drop; a scan freshens the per-finding surfaces while the rolled-up dashboard/triage update on rebuild. Note: freshness trade-off — the rolled-up vuln dashboard + triage scores reflect the last rebuild (daily/manual), not the latest scan. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(imagescan): upstream registry creds + honest pull-failure status
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m5s
Build and Deploy / build-sbom-scanner (push) Successful in 1m9s
Build and Deploy / build-image-scanner (push) Successful in 1m16s
Build and Deploy / build-app (push) Successful in 3m8s
Build and Deploy / deploy-helm (push) Successful in 17s
70bfd6c5be
Two related changes so private upstream images get scanned and broken
scans stop reporting green.

Scan classification fix:
- Pre-flight `crane manifest` check before the scanner steps; an
  unpullable image now fails the job (transient pulls retry, auth/missing
  digest fail) instead of riding the lone cosign "signed:false" artifact
  into "succeeded with partial failures".
- Backstop guard: a run where every image-dependent scanner failed is a
  failure even if an artifact was produced.

Upstream registry credentials:
- New registrycreds package: encrypted-at-rest token store (reuses the
  provider secrets key) with longest-prefix repo matching.
- Admin CRUD API under /api/admin/upstream-repos (audited, token
  write-only) + "Upstream Repos" admin settings page.
- Lease handler resolves the matching credential and ships it to the
  scanner over the HMAC lease channel; the scanner writes a per-job
  Docker config.json and exports DOCKER_CONFIG so crane/cosign/syft/grype
  authenticate the pull.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(web): group repos + registries under Providers tab
All checks were successful
Build and Deploy / build-sbom-scanner (push) Successful in 1m4s
Build and Deploy / build-image-scanner (push) Successful in 1m8s
Build and Deploy / build-repo-runner (push) Successful in 1m12s
Build and Deploy / build-app (push) Successful in 3m16s
Build and Deploy / deploy-helm (push) Successful in 18s
508cf0014e
Remove the top-level "Upstream Repos" tab and nest both source types
under Providers via a sub-tab switcher (Repositories / Registries). The
registries page moves to /admin/settings/providers/registries; the API
route is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(web): move add-provider/registry forms into non-dismissable dialogs
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m7s
Build and Deploy / build-sbom-scanner (push) Successful in 1m7s
Build and Deploy / build-image-scanner (push) Successful in 1m9s
Build and Deploy / build-app (push) Successful in 3m7s
Build and Deploy / deploy-helm (push) Successful in 17s
73722fcd3a
Replace the inline slide-down add forms (providers + registries) with
modal dialogs that ignore backdrop clicks and Escape, so a stray click
no longer discards a half-typed provider URL or registry token. Closing
is explicit via the X or Cancel.

Adds a `dismissable` prop to Dialog (default true; existing dialogs
unchanged) and applies it to the token-entry rotate dialogs too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(web): unify add dialogs, nest provider sub-tabs, polish footers
All checks were successful
Build and Deploy / build-sbom-scanner (push) Successful in 1m4s
Build and Deploy / build-image-scanner (push) Successful in 1m8s
Build and Deploy / build-repo-runner (push) Successful in 1m12s
Build and Deploy / build-app (push) Successful in 3m18s
Build and Deploy / deploy-helm (push) Successful in 17s
f04587be6b
- AddProviderForm shares the registry dialog's 2-col field grid, header,
  width, and footer so both add windows read as one theme.
- Repositories/Registries switcher moves inside the content panel (top,
  centered TabSelector) instead of floating between sections.
- Dialog footers: Cancel|Save order, right-aligned; provider rotate keeps
  Make Public on the left with the group pushed right.
- Provider Type dropdown no longer forces a taller height than the inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
REFRESH MATERIALIZED VIEW CONCURRENTLY rebuilds legitimately run for
minutes. When a statement_timeout cap took effect on the app role, the
dedicated refresh connection was never exempted (the explicit follow-up
20260618_session_timeouts.sql flagged), so every vuln/asset_risk rebuild
was killed mid-flight with SQLSTATE 57014. The refresher returns the
error before recording its debounce timestamp, so the family is never
"recently refreshed" and re-runs on the next trigger — and since scan
completion / cluster-ingest cascades keep setting the gate's pending bit,
the gate hot-looped a full timeout of failed work per cycle.

Result: the unified/canonical vuln MVs froze and the image vulnerability
list returned empty even though image_vuln_findings (queried live by the
image detail endpoint) held the data.

- refreshView: SET statement_timeout = 0 on the refresh connection
  (RESET on return for pool hygiene), alongside the existing jit/work_mem
  tuning. Fixes the freeze for all MV families that route through it.
- vulnmetrics + assetrisk trigger gates: add capped exponential backoff
  (2s..5m) on a failing refresh so a persistent error can't hot-loop when
  upstream keeps the pending bit set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The image detail endpoint counted vuln_severity from raw findings in the
latest image_scan_runs row, while /api/images/{id}/vulnerabilities counts
canonical advisories from vuln_canonical_assets. The two disagreed:
scanner variants (CVE/GHSA/BIT for one advisory) were collapsed in the
list but not the chip, NEGLIGIBLE folded into Low vs Unknown, and the
grain was latest-scan vs the MV's cross-scan snapshot.

Add vulnmetrics.ImageSeverityCounts, which counts per-severity canonical
advisories for one image from the same source LoadListPage reads, with
the identical readiness tiering (canonical MV, unified-view bootstrap
fallback, or empty when MVs aren't populated). The detail handler now
uses it, so the chip totals always match the list — and both move
together on an MV refresh instead of drifting apart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(uiapi): repo image/workload lists + app summary count canonical vulns
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m7s
Build and Deploy / build-image-scanner (push) Successful in 53s
Build and Deploy / build-sbom-scanner (push) Successful in 1m11s
Build and Deploy / build-app (push) Successful in 3m30s
Build and Deploy / deploy-helm (push) Successful in 18s
20aeb23e6a
Three more read paths counted vulnerabilities from live tables instead of
the canonical MV, so their numbers disagreed with the vuln list and the
image detail chip:

- RepoImagesHandler (/api/repos/{repo_id}/images)
- RepoWorkloadsHandler (/api/repos/{repo_id}/workloads)
- AppSummaryHandler "Recent SBOMs" card (/api/app/summary)

The image-side counts were COUNT(*) over image_vuln_findings with no
scan_run_id filter — summing every historical scan run of a digest and
never collapsing scanner variants, so a rescanned image was inflated
several-fold. The app-summary repo side summed raw sbom_scan_results
severity columns, double-counting an advisory seen by multiple scanners.

All now count vuln_canonical_assets (one row per asset+canonical
advisory), matching the list / detail / dashboard. Add
vulnmetrics.CanonicalAssetsReady so these larger raw queries fall back to
0 during the pre-first-refresh window instead of failing on SQLSTATE
55000 against the unpopulated MV, and a shared imageVulnCountExpr helper
for the two image-list subqueries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(vulns): per-finding grain for image vuln surfaces to match grype
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m6s
Build and Deploy / build-image-scanner (push) Successful in 50s
Build and Deploy / build-sbom-scanner (push) Successful in 1m10s
Build and Deploy / build-app (push) Successful in 3m27s
Build and Deploy / deploy-helm (push) Successful in 17s
646e24c1a6
Per-image vulnerability views now count at finding grain — one row per
(vuln_id, package, installed_version) — instead of canonical one-row-per-CVE.
The canonical dedup collapsed a CVE affecting two packages (e.g. libcrypto3
+ libssl3) into a single row, so SPAM read lower than `grype` run against
the same image and eroded trust. It also caused a real search bug: the
canonical row kept only one representative pkg_name, so searching the
dropped package returned nothing — a false negative on a vuln that does
affect that component.

Changes (per-asset surfaces → per-finding; fleet dashboard stays canonical):
- vulnmetrics.LoadImageVulnerabilities: new per-finding list for
  /api/images/{id}/vulnerabilities. Collapses scanner duplicates, keeps the
  scanner-reported vuln_id, enriches KEV/EPSS/year off the canonical id so
  GHSA-only findings still get their CVE's signals. Searches the real
  per-finding package, fixing the component-search false negative.
- vulnmetrics.ImageSeverityCounts: count findings (vuln × package), same
  grain as the list, so the detail chip equals the list and grype.
- RepoImagesHandler / RepoWorkloadsHandler vuln_count + AppSummary
  "Recent SBOMs" card: count findings from the unified views, matching the
  detail pages.
- Add vulnmetrics.UnifiedViewsReady; drop now-unused CanonicalAssetsReady.

The fleet dashboard (/api/vuln/list admin + summary) keeps canonical
unique-CVE grain. Fixing its package search needs a searchable package
column on the canonical MV — separate follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per-finding vuln grain means a CVE can appear on multiple packages, so
keying the vuln {#each} on vuln_id alone threw each_key_duplicate and
froze the page on its loading spinner. Key rows on vuln_id+pkg+version;
route expand state through the same key while caching advisory detail
per-CVE.

Also: drop the duplicate /api/auth/me fetch (layout did a raw fetch then
loadSession) and the duplicate vulnerabilities fetch (effect depended on
`image`, refiring when the profile resolved), and remove the eager
per-row advisory prefetch that pulled large payloads on open — rows
lazy-load detail on expand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor(web): bind providers sub-tab selector to local state
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m7s
Build and Deploy / build-sbom-scanner (push) Successful in 1m18s
Build and Deploy / build-image-scanner (push) Successful in 1m21s
Build and Deploy / build-app (push) Successful in 3m13s
Build and Deploy / deploy-helm (push) Successful in 17s
833d6f09bb
Match the working TabSelector pattern used elsewhere: drive the active
tab from local $state synced to the route via $effect, with named route
constants, instead of a derived activeValue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(web): skeleton placeholder for loading advisory details
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m7s
Build and Deploy / build-sbom-scanner (push) Successful in 1m10s
Build and Deploy / build-image-scanner (push) Successful in 1m10s
Build and Deploy / build-app (push) Successful in 3m11s
Build and Deploy / deploy-helm (push) Successful in 18s
f35ed7b77c
Replace the single spinner line in the expanded vuln row with an
animated skeleton mirroring the real two-column panel (title,
description, tag pills, CTA + signal cards) so the layout doesn't jump
when the advisory resolves. Keeps the loading text as an sr-only label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The repo drill-down list (/api/vuln/list?repo_id=) went through the
canonical one-row-per-CVE path while the repo detail chip counts the
unified repo view per finding — the same chip-vs-list split just fixed for
images. Serve the repo drill-down at finding grain too so the two agree
and every affected package is searchable.

Refactor the image per-finding loader into a shared loadAssetVulnerabilities
driven by an assetVulnSpec (view + columns); LoadImageVulnerabilities and
the new LoadRepoVulnerabilities are thin wrappers. VulnListHandler routes
repo-scoped requests to it; the fleet (unscoped) list stays canonical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(web): higher-contrast, taller advisory loading skeleton
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m7s
Build and Deploy / build-image-scanner (push) Successful in 56s
Build and Deploy / build-sbom-scanner (push) Successful in 1m11s
Build and Deploy / build-app (push) Successful in 3m30s
Build and Deploy / deploy-helm (push) Successful in 18s
665536db88
Use --border-color instead of --hover-bg-subtle for skeleton fills so
they read against the row background, and grow the description block to
five taller lines so it better matches a real advisory body.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(vulns): make fleet vuln search find any affected package
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m8s
Build and Deploy / build-image-scanner (push) Successful in 51s
Build and Deploy / build-sbom-scanner (push) Successful in 1m14s
Build and Deploy / build-app (push) Successful in 3m33s
Build and Deploy / deploy-helm (push) Successful in 17s
41ffa84753
The canonical MVs keep one representative pkg_name per (asset, canonical),
so the fleet vuln list searched only that package — a CVE affecting two
packages (libcrypto3 + libssl3) dropped one, and searching the dropped
package returned nothing: a false negative on a vuln that does affect that
component.

Recreate vuln_canonical_assets / vuln_canonical_summary with a pkg_names
jsonb holding all distinct affected packages, and search it in
loadListPageScoped / loadListPageFromSummary via jsonb_array_elements_text.

Migration recreates both MVs WITH NO DATA (instant, doesn't block boot) and
resets their refresh watermark so the next refresh actually rebuilds them;
LoadListPage falls back to the per-finding bootstrap union while the
canonical MVs are unpopulated, so the dashboard keeps working through the
repopulate window. scopedAssetsCTE's SELECT * carries pkg_names through
automatically (both UNION branches are the same table, so arity is intact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(vulns): expandable vuln tables on image + cluster pages
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m9s
Build and Deploy / build-image-scanner (push) Successful in 1m3s
Build and Deploy / build-sbom-scanner (push) Successful in 1m16s
Build and Deploy / build-app (push) Successful in 3m38s
Build and Deploy / deploy-helm (push) Successful in 18s
b86bc04b12
Replace the two divergent vuln surfaces with dedicated expandable tables
(no shared generic — the views differ too much). Both drop the Signals
column in favour of first-class KEV and EPSS columns.

- ImageVulnTable: extracts the image page's per-package table; expand
  still lazy-loads advisory detail with the skeleton placeholder.
- ClusterVulnTable: new CVE-grouped table for the cluster page; each row
  expands to the advisory text plus the affected *running* images in the
  cluster, lazily fetched per CVE.
- Cluster Vulnerabilities tab pill no longer shows a count.

Backend: add ClusterVulnImagesHandler
(GET /api/cluster/{id}/vulnerabilities/{vuln_id}/images) returning the
running images affected by one canonical CVE — digest, registry/repo,
worst severity, fix availability/version, package count — ACL-gated and
namespace-filtered like the sibling endpoints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
matviewsExist() — the CASCADE-drop recovery check that reapplies a
CREATE MATERIALIZED VIEW migration when its output goes missing — now
counts a name present as a regular TABLE (relkind 'r'), not only as a
materialized view (relkind 'm').

This is the enabling step for converting the sbom/vuln materialized-view
family to incrementally-maintained tables: once a view becomes a table it
must not be re-asserted as a matview on the next boot. Behaviour is
unchanged until such a conversion lands — a genuinely CASCADE-dropped
matview is neither 'm' nor 'r', so it still fails the check and is
reapplied exactly as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(db): extract-on-arrival for the sbom/vuln extraction tables
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m10s
Build and Deploy / build-sbom-scanner (push) Successful in 1m13s
Build and Deploy / build-image-scanner (push) Successful in 1m9s
Build and Deploy / build-app (push) Successful in 3m54s
Build and Deploy / deploy-helm (push) Successful in 18s
526f88f2b7
Completes the freshness model after converting the MV family to tables.
The CPU problem was wholesale re-extraction (REFRESH re-parsed the whole
fleet's JSON on every trigger); the fix is to extract only what arrived.

ExtractDerivedOnArrival re-extracts the three extraction tables
(sbom_component_view, view_unified_repositories_vulnerabilities,
view_unified_image_vulnerabilities) for just the entities whose source
rows landed since a per-table watermark (stored in
materialized_view_refreshes "<table>:wm"). It's a watermark-scoped
DELETE + re-INSERT from each table's _src view, shares the rebuild
advisory lock, and is called (debounced) from the existing sbomviews /
vulnmetrics TriggerRefresh ingest hooks — so these tables update as SBOMs
and scans arrive, at a cost proportional to what changed.

The aggregation tables (vuln_canonical_assets/summary, asset_risk) stay
rebuild-only (daily backstop + manual "Rebuild now"): they fold in feeds
(KEV/EPSS) and cluster/exposure state that change without a scan, so
per-entity extraction doesn't fit them. RebuildDerivedTables seeds the
extraction watermarks so the first post-rebuild trigger doesn't
re-extract the whole fleet.

Also converts the MV family to tables fed by _src views, adds
RebuildDerivedTables (daily REBUILD_DERIVED_TABLES job + manual trigger),
neutralizes the per-ingest REFRESH paths, and points the *Populated gates
and cold-start at the new tables.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(db): disable extract-on-arrival (timed out at fleet scale)
Some checks failed
Build and Deploy / build-sbom-scanner (push) Has been cancelled
Build and Deploy / build-image-scanner (push) Has been cancelled
Build and Deploy / deploy-helm (push) Has been cancelled
Build and Deploy / build-repo-runner (push) Has been cancelled
Build and Deploy / build-app (push) Has been cancelled
db1c3fc288
Filtering the `<table>_src` views by changed entity does not push the
predicate into the source scans, so ExtractDerivedOnArrival ran the full
(minutes-long) extraction on every ingest trigger and hit statement_timeout
(SQLSTATE 57014) at ~50GB — e.g. sbom_component_view_src parses all SBOMs
regardless of how few sbom_ids match. The transaction rolls back, so no
data was harmed (verified: sbom_component_view holds its full row set from
the daily/cold-start rebuild), but the loop spammed timeout errors and
added no freshness.

Unwire the two TriggerRefresh hooks; the derived tables are kept fresh by
the daily REBUILD_DERIVED_TABLES job + the manual "Rebuild now" button,
which run with statement_timeout = 0 and complete. ExtractDerivedOnArrival
is left in place but parked — re-enable only after a source-scoped rewrite
(filter sboms/scans before the JSON parse), verified with EXPLAIN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(db): re-enable extract-on-arrival, guarded + timeout-exempt
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m11s
Build and Deploy / build-sbom-scanner (push) Successful in 1m12s
Build and Deploy / build-image-scanner (push) Successful in 1m9s
Build and Deploy / build-app (push) Successful in 3m52s
Build and Deploy / deploy-helm (push) Successful in 18s
d0938454bb
Reverts the previous "disable" (db1c3fc), which was based on a misread:
the timeout was NOT inherent to every extract — it was only the cold/epoch
run with no watermark seeded, which re-extracts the whole fleet. Prod
confirmed the conversion's win (node CPU 95%→30%, DB CPU 1501m→28m) and
that once the first full pass seeds the per-table ":wm" watermark, steady-
state extracts are scoped to newly-arrived rows and cheap.

So keep the continuous freshness hooks and make the one-time full pass
safe instead:
- ExtractDerivedOnArrival no-ops until DerivedTablesPopulated() — i.e.
  until RebuildDerivedTables (statement_timeout = 0) has done the first
  full populate AND seeded the watermarks — so a trigger never launches an
  epoch-wide extract.
- The extract connection sets statement_timeout = 0 + work_mem itself, so
  a large CI burst can't kill it mid-write and the global statement_timeout
  cap can stay in place for normal queries (no need for a server-wide bump).
- Re-wire the sbomviews / vulnmetrics ingest hooks.

Net: the rebuild is the only full pass (daily + manual + cold-start); the
hooks keep the extraction tables up to date, scoped to changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(web): "Rebuild now" button for the derived sbom/vuln tables
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m12s
Build and Deploy / build-sbom-scanner (push) Successful in 1m19s
Build and Deploy / build-image-scanner (push) Successful in 1m14s
Build and Deploy / build-app (push) Successful in 4m6s
Build and Deploy / deploy-helm (push) Successful in 18s
97589760bc
Adds a manual rebuild trigger to the Materialised views panel on
Admin → Settings → Jobs. POSTs to the existing /api/admin/views/refresh,
which enqueues REBUILD_DERIVED_TABLES (worker-run, advisory-locked); 409
(already queued/running) is treated as success. Mirrors the KEV/EPSS feed
Refresh buttons.

Note: the converted tables are regular tables now, so they don't appear in
the pg_matviews-backed status list above the button — the help text points
operators at materialized_view_refreshes for their timestamps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(db): clear stale refresh rows on cluster table conversion
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m13s
Build and Deploy / build-sbom-scanner (push) Successful in 1m12s
Build and Deploy / build-image-scanner (push) Successful in 1m13s
Build and Deploy / build-app (push) Successful in 4m0s
Build and Deploy / deploy-helm (push) Successful in 18s
26cd137a76
Converting cluster_summary / cluster_image_inventory from materialized
views to tables left the old materialized_view_refreshes rows behind. The
new ClusterSummaryViewPopulated / ClusterImageInventoryPopulated checks
treat "a refresh row exists" as populated, so EnsureFirstPopulate saw the
stale rows, skipped the cold-start full populate, and the tables filled
only incrementally from the stale watermark — i.e. just the clusters that
changed since the old MV's last refresh. Symptom: /clusters shows a subset
that trickles in as clusters send changes.

Delete the leftover rows in each conversion migration (mirrors what
20260624 does for the canonical tables) so the first refresh does a full
-infinity populate of every cluster.

On an already-converted prod, the one-time manual equivalent is:
  DELETE FROM materialized_view_refreshes
   WHERE name IN ('cluster_summary','cluster_image_inventory');
the next ingest then full-populates within seconds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(scam): /clusters "last seen" uses cluster_sessions.last_push_at
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m11s
Build and Deploy / build-sbom-scanner (push) Successful in 1m17s
Build and Deploy / build-image-scanner (push) Successful in 1m16s
Build and Deploy / build-app (push) Successful in 4m10s
Build and Deploy / deploy-helm (push) Successful in 18s
f29db6a714
cs.last_seen is MAX(received_at), and ingest gates received_at to advance
only on an actual data/presence change (the no-op-heartbeat dedup), so it
tracks "last change" and goes stale for a healthy-but-unchanging cluster.
last_push_at is bumped on every push (heartbeats included) — the real
liveness signal, already used for the liveness filter.

Always LEFT JOIN cluster_sessions (the liveness filter stays conditional in
WHERE), and surface COALESCE(sess.last_push_at, cs.last_seen) as last_seen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(jobs): image-scan stale timeout + cap requeue at max_attempts
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m12s
Build and Deploy / build-sbom-scanner (push) Successful in 1m16s
Build and Deploy / build-image-scanner (push) Successful in 55s
Build and Deploy / build-app (push) Successful in 3m44s
Build and Deploy / deploy-helm (push) Successful in 18s
317e2ea139
Two bugs that made large image scans loop and never die:

1. IMAGE_SCAN used the 45s "sync" stale timeout (usesExtendedStaleTimeout
   covered only CREATE_RUN), so RequeueStaleJobs — which runs every worker
   tick — flipped a scan from RUNNING back to RETRY 45s in, while the
   scanner pod was still scanning (per-image deadline is 15m). The job then
   showed empty in /api/runs?status=RUNNING and got re-leased repeatedly.
   IMAGE_SCAN is externally executed by the dedicated scanner pod (like
   CREATE_RUN by a K8s job), so it now uses the long StaleTimeout.

2. RequeueStaleJobs always re-queued as RETRY regardless of attempts, so a
   job whose executor kept outliving its lock looped forever (observed: 186
   attempts at max_attempts 3). It now marks a job FAILED once attempts are
   exhausted instead of resurrecting it.

Note: the underlying blocker for the giant images (vllm) is the 16Gi
work emptyDir being too small for the unpacked rootfs — that's a chart
change (imageScanner.workSizeLimit + ephemeral-storage), handled separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(scanner): bump betterleaks v1.1.1 → v1.5.0
Some checks failed
Build and Deploy / build-repo-runner (push) Successful in 1m12s
Build and Deploy / build-image-scanner (push) Has been cancelled
Build and Deploy / deploy-helm (push) Has been cancelled
Build and Deploy / build-app (push) Has been cancelled
Build and Deploy / build-sbom-scanner (push) Has been cancelled
a43d2b9949
Updates the pinned betterleaks image (image-scanner + repo-runner
Dockerfiles) to the latest release. v1.5.0 adds ~50 validated detection
rules and remains gitleaks-flag compatible (so --max-target-megabytes is
available for the OOM follow-up). Pinned to the multi-arch index digest.

Note: v1.5.0 enables archive handling by default — it now descends into
archives, which can increase memory/disk on images containing large
archives. Pair with a per-file size cap (--max-target-megabytes) for the
giant ML images.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(scanner): bump grype v0.111.0 → v0.114.0
Some checks failed
Build and Deploy / build-repo-runner (push) Successful in 1m12s
Build and Deploy / build-sbom-scanner (push) Successful in 1m15s
Build and Deploy / deploy-helm (push) Has been cancelled
Build and Deploy / build-image-scanner (push) Has been cancelled
Build and Deploy / build-app (push) Has been cancelled
a0d8724acc
Latest grype release (adds zarf-package scanning + Go Vuln DB OSV record
handling). Pinned to the multi-arch index digest, which is content-
addressed and identical across docker.io / ghcr / the ncr.sky.nhn.no
pull-through mirror the Dockerfile uses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(scanner): bump syft/crane/cosign + pin sbom-scanner grype
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m15s
Build and Deploy / build-sbom-scanner (push) Successful in 1m8s
Build and Deploy / build-image-scanner (push) Successful in 1m41s
Build and Deploy / build-app (push) Successful in 3m54s
Build and Deploy / deploy-helm (push) Successful in 19s
a788a4d59c
Updates the pinned scanner toolchain across the three runner Dockerfiles
to current releases (multi-arch index digests):

  syft   v1.42.3 -> v1.45.1   (image-scanner + repo-runner)
  crane  v0.21.4 -> v0.21.7   (image-scanner)
  cosign v2.6.3  -> v3.1.1     (image-scanner) — MAJOR bump, see note

Also pins the sbom-scanner's grype, which was floating on :latest, to
v0.114.0@digest — matching the image-scanner and restoring reproducible
builds.

NOTE: cosign v2 -> v3 is a major release with breaking changes; verify the
"signature" step (cosign.json) still parses as expected after deploy. trivy
(0.69.3) is intentionally left as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(scanner): cap secret-scan file size to bound memory (OOM fix)
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m11s
Build and Deploy / build-sbom-scanner (push) Successful in 1m9s
Build and Deploy / build-image-scanner (push) Successful in 1m18s
Build and Deploy / build-app (push) Successful in 3m27s
Build and Deploy / deploy-helm (push) Successful in 18s
d75448d2d7
betterleaks scans every file in the rootfs, so multi-GB binaries (ML model
weights in images like vllm) get read into memory and OOM-kill the pod.
Pass --max-target-megabytes to skip files above a size cap — secrets live
in small text/config files, never in giant binaries, so nothing real is
lost and peak memory is bounded regardless of image size.

Applied to both betterleaks call sites (image-scanner pipeline +
repo-runner). Tunable via SPAM_SECRETS_MAX_TARGET_MB (0 disables); default
50. betterleaks v1.5.0 keeps gitleaks flag compatibility, so the flag is
supported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(deps): upgrade go modules + pin go 1.26.4 for runner
Some checks failed
Build and Deploy / build-repo-runner (push) Successful in 1m12s
Build and Deploy / build-image-scanner (push) Successful in 59s
Build and Deploy / build-sbom-scanner (push) Successful in 1m17s
Build and Deploy / deploy-helm (push) Has been cancelled
Build and Deploy / build-app (push) Has been cancelled
7faee98829
Run go get -u ./... + go mod tidy on api and runner modules. Bump
runner go directive to 1.26.4 and runner Dockerfile base images to
golang:1.26.4-alpine to match api.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore(deps): bump runtime base from EOL alpine 3.20 to 3.24
Some checks failed
Build and Deploy / build-sbom-scanner (push) Has been cancelled
Build and Deploy / build-image-scanner (push) Has been cancelled
Build and Deploy / deploy-helm (push) Has been cancelled
Build and Deploy / build-repo-runner (push) Has been cancelled
Build and Deploy / build-app (push) Has been cancelled
bfd4efafd4
alpine 3.20 reached EOL, so grype flags its packages as having
incomplete/outdated vuln data. Bump the four runtime/cert base images
to alpine 3.24, matching the golang:1.26.4-alpine builder's alpine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(scanner): rootfs extract handles read-only dirs (--delay-directory-restore)
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m18s
Build and Deploy / build-sbom-scanner (push) Successful in 1m21s
Build and Deploy / build-image-scanner (push) Successful in 1m44s
Build and Deploy / build-app (push) Successful in 4m1s
Build and Deploy / deploy-helm (push) Successful in 18s
fa7390c65c
crane export | tar -x failed with "Cannot open: Permission denied" on the
contents of read-only image directories (e.g. a 0555 usr/sbin holding the
libcap/glibc tools: capsh, setcap, chroot, ldconfig, …). As uid 1000, tar
set the directory's mode before extracting its files, leaving it
unwritable. Add --delay-directory-restore so directory modes are applied
last, and --no-same-owner since non-root can't chown the root-owned
entries. GNU tar (already installed in the scanner image) supports both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(scanner): make grype-result upload cap configurable (was hard 50MB)
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m12s
Build and Deploy / build-sbom-scanner (push) Successful in 1m15s
Build and Deploy / build-image-scanner (push) Successful in 1m14s
Build and Deploy / build-app (push) Successful in 4m7s
Build and Deploy / deploy-helm (push) Successful in 18s
be09d9dda6
EOL distros (e.g. debian 11) with thousands of unfixed CVEs can produce a
grype result larger than the 50 MB guard, which 413s on upload (the
sbom-scanner's image/repo result handlers). Make the cap tunable via
SPAM_MAX_SCAN_RESULT_MB (default unchanged at 50 MB).

Default left at 50 on purpose: the body is stored verbatim as
sbom_scan_results.raw_json (jsonb) and introspected by the vuln
views/search, so a bigger cap = bigger rows = DB bloat. Raise it explicitly
(e.g. 256) for fleets with large EOL images, and only as far as needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
perf(db): source-scope sbom_component_view extract (no more full re-sort)
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m12s
Build and Deploy / build-sbom-scanner (push) Successful in 1m13s
Build and Deploy / build-image-scanner (push) Successful in 1m14s
Build and Deploy / build-app (push) Successful in 4m6s
Build and Deploy / deploy-helm (push) Successful in 18s
f131bcdf0c
Extract-on-arrival did `SELECT * FROM sbom_component_view_src WHERE
sbom_id IN (...)`, but the filter can't push through the view's DISTINCT ON
— so every ingest re-sorted all ~30M components (observed: ~6 min, 14 GB
temp spill), i.e. a "scoped" extract cost a full rebuild.

Add sbom_component_view_scoped(p_since): the view body with one predicate,
`WHERE s.created_at > p_since` in the sbom_json CTE, so only SBOMs newer
than the watermark are parsed and sorted. The incremental refresher calls
it with the watermark; sbom_component_view_src is redefined as a thin
wrapper passing '-infinity' (created_at is NOT NULL, so = all rows), so the
full-rebuild path is byte-for-byte unchanged and the body lives in exactly
one place. Extraction restructured to per-table delete/insert SQL.

The two unified vuln tables still filter the finished view (the expensive
path) — flagged TODO(source-scope); their scoped functions come next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
perf(db): source-scope view_unified_image_vulnerabilities + index
Some checks failed
Build and Deploy / build-sbom-scanner (push) Successful in 1m11s
Build and Deploy / build-repo-runner (push) Successful in 1m13s
Build and Deploy / build-image-scanner (push) Successful in 1m13s
Build and Deploy / deploy-helm (push) Has been cancelled
Build and Deploy / build-app (push) Has been cancelled
2e64b62d74
Top pg_stat_statements cost after the conversion: the image-vuln extract
filtered the finished _src view (`… WHERE image_id IN (…)`), which can't
push through the DISTINCT ON, so each ingest re-sorted all image findings
(176 min / 703 calls / 15 s avg).

Add view_unified_image_vulnerabilities_scoped(p_since): the body with
`AND f.created_at > p_since` on image_vuln_findings before the DISTINCT ON
(findings are delete+reinserted per scan, so a changed image's set carries
a fresh created_at). _src becomes a thin '-infinity' wrapper, so the full
rebuild is unchanged and the body lives once.

Also add idx_image_vuln_findings_created_at — fixes both the scoped filter
and the watermark MAX(created_at) (was a 16-min full-table scan).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
perf(db): source-scope view_unified_repositories_vulnerabilities
All checks were successful
Build and Deploy / build-sbom-scanner (push) Successful in 1m6s
Build and Deploy / build-repo-runner (push) Successful in 1m11s
Build and Deploy / build-image-scanner (push) Successful in 54s
Build and Deploy / build-app (push) Successful in 3m16s
Build and Deploy / deploy-helm (push) Successful in 18s
856c2f1419
Completes the source-scoped rewrite. view_unified_repositories_
vulnerabilities_scoped(p_since) computes a changed_repos CTE from the
watermark (repos with a scan newer than p_since, or whose purls had a
component_vulnerabilities row checked since) and filters BOTH branches at
their sources — sbom_scan_results before the matches JSON parse, and the
sbom_component_view join — instead of filtering the finished view (which
couldn't push through the UNION/DISTINCT ON). _src becomes a thin
'-infinity' wrapper so the full rebuild is unchanged.

All three extraction tables now scope at the source; none re-sort the whole
view per ingest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(admin): reset buttons for slow-query + storage stats
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m23s
Build and Deploy / build-sbom-scanner (push) Successful in 1m14s
Build and Deploy / build-image-scanner (push) Successful in 1m29s
Build and Deploy / build-app (push) Successful in 3m24s
Build and Deploy / deploy-helm (push) Successful in 17s
52a6210350
Adds two admin-gated reset actions on the Database settings page:

- Slow queries: POST /api/admin/db/slow-queries/reset → pg_stat_statements_reset()
  so you can clear cumulative noise and measure a fresh window (e.g. before
  reproducing a slow path). Guarded on the extension being installed.
- Database storage: POST /api/admin/db/storage/reset → pg_stat_reset(),
  clearing the cumulative scan/write/dead-tuple counters behind the bloat
  signals. Table/index SIZES are physical and unaffected (the button copy
  says so).

Both surface the underlying error if the app role lacks privilege to call
the reset functions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(review): stale-job routing, derived-table freshness, vuln-count bugs + cleanups
All checks were successful
Build and Deploy / build-sbom-scanner (push) Successful in 1m14s
Build and Deploy / build-repo-runner (push) Successful in 1m14s
Build and Deploy / build-image-scanner (push) Successful in 1m14s
Build and Deploy / build-app (push) Successful in 3m19s
Build and Deploy / deploy-helm (push) Successful in 17s
0c25107585
Address verified code-review findings on the derived-tables branch:

- jobs: route all extended-timeout job types (CREATE_RUN + IMAGE_SCAN)
  through the async stale branch in RequeueStaleJobs, not just CREATE_RUN.
  Image scans were being reclaimed at the 45s sync timeout mid-flight,
  inflating attempts and hard-failing healthy scans.
- db: add RebuildDerivedAggregations (canonical/asset_risk/dependency only)
  and run it on the REFRESH_MV cadence. These aggregations read from the
  incrementally-fresh extraction tables, so this bounds triage/fleet-vuln/
  dependency dashboard staleness without re-adding per-ingest rebuild load.
- uiapi: repo image/workload vuln_count now falls back to image_vuln_findings
  when the derived tables aren't populated (was showing 0 on fresh deploy),
  via a single repo-scoped pre-aggregation CTE instead of a per-row subquery.
- uiapi: scope the app-summary recent-SBOMs vuln CTEs to the displayed
  assets instead of aggregating the whole vuln tables fleet-wide.
- vulnmetrics: fold the per-asset vuln-list total into the page query with
  COUNT(*) OVER (), removing the duplicate findings-CTE scan.
- registrycreds: canonicalise Docker Hub registry aliases (incl. empty) so a
  credential configured under one alias matches images recorded under another.
  Credentials stay host-scoped via the Docker config.json auth key.
- web: extract the duplicated advisory-link mapping into $lib/vulnAdvisory;
  ClusterVulnTable/ImageVulnTable/VulnAdvisoryDialog now share it (fixes the
  CVE host divergence between the copies).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(clusters): return fleet-wide summary from /api/clusters/summary
All checks were successful
Build and Deploy / build-sbom-scanner (push) Successful in 1m9s
Build and Deploy / build-image-scanner (push) Successful in 1m10s
Build and Deploy / build-repo-runner (push) Successful in 1m13s
Build and Deploy / build-app (push) Successful in 3m23s
Build and Deploy / deploy-helm (push) Successful in 18s
f55d72bd02
The endpoint returned only per-cluster rows, so any consumer that wanted a
fleet total summed the rows — which over-counts images: the per-cluster
images value is DISTINCT registry/image@digest within one cluster, and a
shared base/sidecar image runs in many clusters, so the sum fans each shared
image out by its cluster count (the 28k-vs-2k inflation).

Response is now {"clusters": [...], "summary": {...}}. The summary rolls up
the returned clusters:
- images: COUNT(DISTINCT digest) over cluster_image_inventory, scoped to the
  same cluster-id set the rows came from (so ACL + liveness + search are
  already applied). Keyed on bare digest so the same image pulled via a
  mirror or under two names counts once — the true unique-image count.
- containers / namespaces / ingress_count: sums of the per-cluster rows,
  which is correct since each is keyed on an entity unique to one cluster.

Deny and cold-start paths emit the same shape with an empty cluster list and
a zero summary, so consumers never special-case a bare array. SearchPalette
(the only in-repo consumer) reads data.clusters with an array fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(clusters): consume fleet summary on the clusters page
All checks were successful
Build and Deploy / build-sbom-scanner (push) Successful in 1m8s
Build and Deploy / build-image-scanner (push) Successful in 1m11s
Build and Deploy / build-repo-runner (push) Successful in 1m12s
Build and Deploy / build-app (push) Successful in 3m26s
Build and Deploy / deploy-helm (push) Successful in 19s
9875208a6a
The /clusters page was the real consumer of /api/clusters/summary (via
loadUnfiltered/loadMain), not just the search palette. The previous commit
changed the response from a bare array to { clusters, summary }, which broke
the page: both loaders parsed it as `Array.isArray(body) ? body : []` and so
got an empty cluster list.

- Add parseClusterSummary() to read { clusters, summary } (tolerating a bare
  array from an older/cached backend), and use it in both loaders.
- Drive the page-level "Images" card from summary.images (COUNT(DISTINCT
  digest), de-duplicated across clusters) instead of summing clustersAll.images
  across rows, which fanned each shared image out by its cluster count — the
  28k-vs-2k inflation. The card's "Unique by digest" subtitle is now accurate.
- Containers fall back to a row reduce if the summary is absent; that sum is
  already correct since containers are unique per cluster.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(secrets): never store run_secrets.findings as a JSON scalar
All checks were successful
Build and Deploy / build-sbom-scanner (push) Successful in 1m10s
Build and Deploy / build-repo-runner (push) Successful in 1m12s
Build and Deploy / build-image-scanner (push) Successful in 1m15s
Build and Deploy / build-app (push) Successful in 3m26s
Build and Deploy / deploy-helm (push) Successful in 18s
bce6229f23
gitleaks emits a bare `null` (not `[]`) when a repo has no findings.
json.Unmarshal("null", &slice) succeeds with a nil slice and no error, so
the raw bytes were stored as findings = 'null'::jsonb. COALESCE(findings,
'[]') only catches SQL NULL, so that JSON null reached jsonb_array_elements
and threw "cannot extract elements from a scalar" — which 500s the secrets
dashboard endpoints AND fails asset_risk_src, rolling back the entire
derived-tables rebuild (asset_risk is in the rebuild order).

- runner: normalise the parsed findings slice (nil -> []) and re-marshal
  before persisting, so the column is always an array. Stops new bad rows
  at the source.
- secrets dashboard: guard all four jsonb_array_elements sites with
  CASE WHEN jsonb_typeof(findings) = 'array' ... ELSE '[]', which also
  survives scalar/object values, and log the real error on the three
  foreground 500 paths (they were swallowing it).

Existing malformed rows still need a one-time cleanup:
  UPDATE run_secrets SET findings = '[]'::jsonb, finding_count = 0
  WHERE findings IS NOT NULL AND jsonb_typeof(findings) <> 'array';

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Merge remote-tracking branch 'origin/main' into chore/remove-material-views
Some checks failed
Build and Deploy / deploy-helm (push) Has been cancelled
Build and Deploy / build-repo-runner (push) Has been cancelled
Build and Deploy / build-app (push) Has been cancelled
Build and Deploy / build-image-scanner (push) Has been cancelled
Build and Deploy / build-sbom-scanner (push) Has been cancelled
ea5ece8d78
fix(vuln): scanned_sboms reflects current snapshot, not all-time coverage
All checks were successful
Build and Deploy / build-repo-runner (push) Successful in 1m10s
Build and Deploy / build-sbom-scanner (push) Successful in 1m11s
Build and Deploy / build-image-scanner (push) Successful in 1m11s
Build and Deploy / build-app (push) Successful in 3m23s
Build and Deploy / deploy-helm (push) Successful in 18s
5057c9afb0
/api/vuln/summary's scanned_sboms counted every SBOM ever scanned (repo OSV
results UNION any image-bound SBOM with a finished scan) — cumulative, with
no recency/branch/liveness scoping. That inflates the headline "scanned
SBOMs" number relative to what's actually deployed today.

Redefine it as the org's current snapshot in both computeSummary (unrestricted)
and computeSummaryScoped (narrow-grant), so the two stay consistent:
- repos: the latest scan per repo (DISTINCT ON (repo_id) ORDER BY scanned_at
  DESC over sbom_scan_results), matching how the repo vuln view picks each
  repo's representative scan. Superseded commits drop out.
- images: SBOMs for digests currently running in a LIVE cluster — joined via
  cluster_image_inventory and gated by db.LiveClusterExistsSQL, reusing the
  SPAM_CLUSTER_LIVE_WINDOW window. Digests no longer running anywhere drop out.
last_scanned_at stays an all-time MAX.

vulnmetrics can't import scam (import cycle scam -> ... -> jobs -> vulnmetrics),
so the live-window SQL helper lives in the leaf db package
(ClusterLiveWindowInterval / LiveClusterExistsSQL), reading the same
SPAM_CLUSTER_LIVE_WINDOW env + 24h default as scam.

Scope: only the live /api/vuln/summary value changes. /api/vuln/trend is
severity-only and untouched; historic vuln_dashboard_snapshots rows are not
recomputed (new dailies use the new definition going forward).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Merge pull request 'fix(vuln): scanned_sboms reflects current snapshot, not all-time coverage' (#14) from fix/vuln-summary-snapshot-sboms into chore/remove-material-views
All checks were successful
Build and Deploy / build-sbom-scanner (push) Successful in 1m12s
Build and Deploy / build-repo-runner (push) Successful in 1m16s
Build and Deploy / build-image-scanner (push) Successful in 1m19s
Build and Deploy / build-app (push) Successful in 3m43s
Build and Deploy / deploy-helm (push) Successful in 18s
fb99124364
jonas merged commit 8532b89fc8 into main 2026-06-24 13:13:46 +02:00
jonas deleted branch chore/remove-material-views 2026-06-24 13:13:46 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Motstandskraft/spam!12
No description provided.