Skip to content

fix: persist terminal status on generic SBOM-generation failures to stop reprocessing loop - #855

Merged
matthyx merged 4 commits into
mainfrom
fix/sbom-failure-reprocessing-loop
Jul 20, 2026
Merged

fix: persist terminal status on generic SBOM-generation failures to stop reprocessing loop#855
matthyx merged 4 commits into
mainfrom
fix/sbom-failure-reprocessing-loop

Conversation

@matthyx

@matthyx matthyx commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Overview

SBOM-generation failures (sidecar scan error, invalid/unparseable image source, syft cataloging error, oversized image) are reported via reportFailure, but the reserved SBOM object was never marked with a terminal status. Since the reprocessing switch in processContainerWithMetadata only special-cased TooLarge and Learning, a permanently-failing image fell through to the default: "processing was interrupted, retrying" branch and was silently reprocessed on every subsequent container start — repeated identical error logs and repeated backend failure reports, forever.

What changed

  • Bounded retries for content-free images: a new Incomplete case in the reprocessing switch, version-gated like the existing Learning case, plus a failureRetries counter (handleGenericFailure) that only pins an image Incomplete after maxScanRetries (3) consecutive failures — a single transient error (a sidecar blip, a registry hiccup) is self-healing, not permanent. failureRetries is a bounded, TTL'd expirable.LRU (1000 entries, 30 min) rather than a plain map, so it doesn't leak entries for images seen once and never again.
  • Content preservation: marking a terminal status now goes through a new storage.SbomClient.PatchSBOMAnnotations — a JSON merge patch on metadata.annotations only, which structurally can never send spec. This matters because the reprocessing path fetches the SBOM via GetSBOMMeta, which the storage layer returns without its Spec (a metadata-only fetch) — persisting that object via a full ReplaceSBOM would silently destroy a previously-successful SBOM's real content (its CVE-scan data) the moment a later reprocess attempt failed. All SBOM-generation failure paths (generic scan/source/syft errors, repeated sidecar OOM crashes, and ErrImageTooLarge) now go through this patch-based marking, so retries are bounded uniformly whether or not the image previously had content — no separate case for "already had good data" is needed anymore.

Residual trade-off (documented, not fixed here)

failureRetries' TTL resets on every write, so the retry count only accumulates for failures spaced under 30 minutes apart. This fully closes the loop for the reported symptom (a tight failure/crash-loop cadence) but a permanent failure recurring more slowly than that (e.g. an infrequently-restarted long-lived pod) could in principle still reprocess indefinitely without ever hitting 3 consecutive failures. Flagged for follow-up if it turns out to matter in practice.

Additional Information

This is a pre-existing, independent defect discovered while working on #853/#854 (the digest-panic crash fix) — it already affected ordinary syft SBOM-generation failures, unrelated to digests. It's deliberately split into its own PR to keep each change minimal and independently reviewable, and does not depend on #854.

How to Test

go build ./...
go vet ./...
go test ./pkg/sbommanager/... ./pkg/storage/...

Test_processContainerWithMetadata_IncompleteReprocessing reproduces the bounded-retry behavior: maxScanRetries-1 failures don't touch storage, the maxScanRetries-th does, and a version bump grants a fresh retry budget. Test_processContainerWithMetadata_PreservesContentOn{ReprocessFailure,ScannerCrash,TooLarge} seed a content-bearing SBOM and drive each failure path past its retry threshold, asserting the SBOM's real content survives (only its annotations change) — each was verified to actually fail (reproducing the underlying data-loss bug) when its corresponding fix is reverted.

Related issues/PRs

Checklist before requesting a review

  • My code follows the style guidelines of this project
  • I have performed a self-review of my code
  • New and existing unit tests pass locally with my changes

Summary by CodeRabbit

  • Bug Fixes

    • Preserved existing SBOM content when reprocessing fails, including scanner crashes and oversized images.
    • Improved retry handling for repeated SBOM-generation failures.
    • Prevented unnecessary reprocessing when the tool version has not changed.
    • Reset retry behavior when a new tool version is detected.
    • SBOMs are now marked incomplete only after repeated failures without existing content.
  • Tests

    • Added coverage for retry limits, version changes, scanner crashes, oversized images, and content preservation.

Summary by CodeRabbit

  • Bug Fixes
    • Improved SBOM reprocessing reliability by retrying transient generation failures before marking results incomplete.
    • Preserved previously generated SBOM content when later scans fail, including scanner crashes and image-size errors.
    • Prevented repeated retries when an SBOM has already been marked incomplete for the current tool version.
    • Reset retry handling when the scanning tool version changes.
    • Freshly detected images that exceed size limits are now marked accordingly without unnecessary retries.
    • Updated status changes to avoid overwriting existing SBOM content.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

SBOM processing now tracks generic failure retries, gates reprocessing by tool version, preserves existing SBOM content across failures, and uses annotation-only storage patches for terminal status updates. Tests cover generic failures, scanner crashes, oversized images, and fresh reservations.

Changes

SBOM reprocessing

Layer / File(s) Summary
Annotation-only SBOM persistence
pkg/storage/storage_interface.go, pkg/storage/v1/storage.go, pkg/storage/storage_mock.go
The storage contract, Kubernetes backend, and mock support merge patches limited to SBOM annotations.
Retry state and reprocessing selection
pkg/sbommanager/v1/sbom_manager.go
SbomManager tracks bounded generic-failure retries and uses tool-version annotations to decide whether stored Learning or Incomplete SBOMs should be retried.
Failure handling and content preservation
pkg/sbommanager/v1/sbom_manager.go
Scanner, fallback, image-size, and generic failures preserve existing SBOM content during reprocessing; repeated failures mark SBOMs Incomplete, while fresh oversized images become TooLarge.
Reprocessing behavior tests
pkg/sbommanager/v1/sbom_manager_reprocessing_test.go
Fakes and tests cover retry thresholds, tool-version resets, scanner crashes, generic failures, content preservation, and oversized images.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ContainerProcessor
  participant Scanner
  participant FailureHandler
  participant SbomClient

  ContainerProcessor->>Scanner: Generate SBOM
  Scanner-->>ContainerProcessor: Success or failure
  ContainerProcessor->>FailureHandler: Handle failure with content state
  FailureHandler->>SbomClient: Patch status annotations at retry threshold
  FailureHandler-->>ContainerProcessor: Preserve existing Spec during reprocessing
Loading

Suggested reviewers: slashben

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: persisting terminal status on SBOM generation failures to stop repeated reprocessing.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/sbom-failure-reprocessing-loop

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…top reprocessing loop

SBOM-generation failures (sidecar scan error, invalid image source, syft
cataloging error) reported via reportFailure but never marked the reserved
SBOM object with a terminal status. Since the reprocessing switch only
special-cases TooLarge and Learning, a permanently-failing image fell
through to the "processing was interrupted, retrying" default case and
was silently reprocessed on every subsequent container start for that
image, producing repeated identical error logs and backend failure
reports forever.

Add markSBOMStatus, generalizing the existing TooLarge-marking pattern,
and use it to persist an Incomplete status on all three generic-failure
call sites. Add a matching Incomplete case to the reprocessing switch,
version-gated exactly like the existing Learning case, so a later
node-agent build still retries images that previously failed.

This is a pre-existing defect independent of #853/#854 (it already
affected ordinary syft SBOM-generation failures, unrelated to digests);
splitting it into its own change keeps each fix minimal and reviewable.

Docs-exempt: pure bug fix, no existing doc describes SBOM reprocessing or terminal-status behavior

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Matthias Bertschy <matthias.bertschy@gmail.com>
@matthyx
matthyx force-pushed the fix/sbom-failure-reprocessing-loop branch from 850ebad to 8b3170c Compare July 20, 2026 11:56
@github-actions

Copy link
Copy Markdown

Performance Benchmark Results

Node-Agent Resource Usage
Metric BEFORE AFTER Delta
Avg CPU (cores) 0.000 0.000 N/A
Peak CPU (cores) 0.000 0.000 N/A
Avg Memory (MiB) 0.000 0.000 N/A
Peak Memory (MiB) 0.000 0.000 N/A
Dedup Effectiveness

No data available.

@matthyx matthyx left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the change end-to-end against main (built, vetted and ran ./pkg/sbommanager/... on both branches). The diagnosis is right — the default: "processing was interrupted, retrying" fall-through really does reprocess permanently-failing images on every container start, and Incomplete is the semantically correct status (kubevuln's core/services/scan.go explicitly skips Incomplete/TooLarge, and scan_failure_reasons.go maps it to ReasonSBOMIncomplete). But treat this as changes requested: as written the fix introduces a worse regression than the bug it fixes.

Blockers

  1. Silent SBOM data loss + loss of CVE coverage. markSBOMStatus does a full ReplaceSBOM on a wipSbom that, in the reprocessing path, came from GetSBOMMeta and therefore has no Spec. A previously-good SBOM is overwritten with an empty one and pinned to incomplete, after which kubevuln skips CVE scanning for that image. Reproduced with a test that passes on main and fails here — details inline on markSBOMStatus.
  2. The pin is effectively permanent for transient failures. The only retry gate is a tool-version bump; unlike the TooLarge case there's no secondary escape hatch. A registry 503 or a sidecar restart costs that image its SBOM until the next node-agent release. Inline suggestion: reuse the existing scanRetries/maxScanRetries pattern so we only pin after N consecutive failures — that still bounds the loop, which is the actual goal.

Non-blocking

  1. Possible double failure-reporting to the backend (node-agent ReasonSBOMGenerationFailed + kubevuln ReasonSBOMIncomplete).
  2. Test fake diverges from the real GetSBOMMeta contract, which is why the regression above isn't caught; plus scanRetries is left nil in newTestManager.
  3. The new Incomplete case is a near-copy of the Learning case, and a // continue to create SBOM comment got orphaned between the two.

CI: the DCO check is ACTION_REQUIRED — commits need a Signed-off-by line before this can merge.

Happy to re-review quickly once (1) and (2) are addressed — the core insight is solid and I'd like to see it land.

Comment thread pkg/sbommanager/v1/sbom_manager.go Outdated
func (s *SbomManager) markSBOMStatus(wipSbom *v1beta1.SBOMSyft, sbomName, status string) {
delete(wipSbom.Annotations, NodeNameMetadataKey)
wipSbom.Annotations[helpersv1.StatusMetadataKey] = status
if _, err := s.storageClient.ReplaceSBOM(wipSbom); err != nil {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker — this wipes a previously-good SBOM and silently kills CVE scanning for that image.

ReplaceSBOM is a full k8s Update, and markSBOMStatus never clears/repopulates wipSbom.Spec. In the IsAlreadyExists reprocessing path, wipSbom came from GetSBOMMeta, which fetches with softwarecomposition.ResourceVersionMetadata (pkg/storage/v1/storage.go:93) and therefore returns the object without its Spec. SbomSyftStrategy.PrepareForUpdate is a no-op, so nothing merges the old spec back.

Failure scenario:

  1. Image scanned OK at node-agent v1.0.0 → SBOM stored with full Spec, status ready.
  2. Node-agent upgraded to v2.0.0. Container starts → AlreadyExistsGetSBOMMeta (empty Spec) → Learning case, version mismatch → continue to reprocess.
  3. The scan fails transiently (sidecar gRPC blip, registry hiccup).
  4. New code: markSBOMStatus(wipSbom, ..., Incomplete)Update with an empty Spec → the good SBOM's artifacts are destroyed and it is pinned to incomplete at v2.0.0.
  5. kubevuln then skips it forever: core/services/scan.goif sbom.Status == helpersv1.Incomplete || ... { skip } (ErrIncompleteSBOM). The image loses vulnerability coverage until the next node-agent version bump.

Verified empirically — this test passes on main and fails on this branch:

// metaOnlyClient mimics the real GetSBOMMeta, which strips Spec.
type metaOnlyClient struct{ *fakeSbomClient }

func (f *metaOnlyClient) GetSBOMMeta(name string) (*v1beta1.SBOMSyft, error) {
	f.mu.Lock(); defer f.mu.Unlock()
	if s, ok := f.sboms[name]; ok {
		c := s.DeepCopy()
		c.Spec = v1beta1.SBOMSyftSpec{} // <- what the apiserver actually returns
		return c, nil
	}
	return nil, k8serrors.NewNotFound(schema.GroupResource{Resource: "sbomsyfts"}, name)
}

func Test_goodSBOMWipedByTransientFailure(t *testing.T) {
	inner := newFakeSbomClient()
	mgr := ... // storageClient: &metaOnlyClient{inner}, version "v2.0.0", scanner always fails
	good := &v1beta1.SBOMSyft{}
	good.Name = sbomName
	good.Annotations = map[string]string{
		helpersv1.StatusMetadataKey:      helpersv1.Learning,
		helpersv1.ToolVersionMetadataKey: "v1.0.0",
	}
	good.Spec.Syft.Artifacts = make([]v1beta1.SyftPackage, 2)
	inner.sboms[sbomName] = good

	mgr.processContainerWithMetadata(notif, nil, imageStatus, imageTag, imageID)

	assert.Len(t, inner.sboms[sbomName].Spec.Syft.Artifacts, 2)
}
main:        status="ready"      toolVersion="v1.0.0"  artifacts=2   PASS
this branch: status="incomplete" toolVersion="v2.0.0"  artifacts=0   FAIL

Pre-PR this path just reportFailure + return, leaving the good SBOM intact — so this is a new regression, not a pre-existing one. (The existing TooLarge branch has the same shape, but it was reachable only via ErrImageTooLarge, which is deterministic per image; generalising it to every scan error is what makes it dangerous. Note handleScannerCrash deliberately does wipSbom.Spec = v1beta1.SBOMSyftSpec{} before its ReplaceSBOMmarkSBOMStatus is missing that awareness entirely, but zeroing the spec here would be equally wrong since it would still destroy the good data.)

Suggested fixes, roughly in order of preference:

  1. Only mark Incomplete when we actually own a fresh reservation — i.e. when the pre-scan status was Initializing. If we're reprocessing an SBOM that already had content, leave the stored object untouched on failure (report and return, as today).
  2. Or update annotations only, via a JSON/merge Patch on the typed client (GetStorageClient()), so the spec can never be clobbered by a metadata-only object.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — verified GetSBOMMeta's metadata-only fetch directly in pkg/storage/v1/storage.go:93. Fixed in db239df2: markSBOMStatus is no longer called unconditionally. I now track whether the SBOM being reprocessed had prior successful content (wipSbomHadContent, set only when the Learning case takes its version-mismatch retry branch — that's the only path where wipSbom can carry real server-side content it wasn't given) and skip the destructive ReplaceSBOM whenever it did, reporting the failure but otherwise leaving the stored object untouched, per your suggested fix #1.

While auditing every reachable persist path for the same defect, I also found (and fixed) two more sites with the identical unguarded pattern that a second and third review pass caught: handleScannerCrash (clears Spec + ReplaceSBOM after repeated OOM crashes) and the ErrImageTooLarge branch (whose totalSize is computed from the currently-mounted layer paths, not a fixed image property, so it's reachable during a content-bearing reprocess too, contrary to my earlier assumption that it was safely deterministic). All three now share the same guard. Added Test_processContainerWithMetadata_PreservesContentOnReprocessFailure/OnScannerCrash/OnTooLarge, each empirically confirmed to reproduce your exact failure scenario (artifacts wiped, status flipped, ReplaceSBOM called) when the corresponding guard is removed.

One edge case I'm flagging rather than fixing: on the success path, if a fresh reprocess actually runs and produces a real (non-empty) syftDoc that then exceeds MaxSBOMSize, the code still clears Spec and marks TooLarge unconditionally (pre-existing behavior, sbom_manager.go around the size-check after "prepare the SBOM"). That's a different flavor — a real new result being rejected for size, not a stale empty object clobbering a good one — but the net effect (an older, smaller, valid SBOM getting replaced by nothing) is similar. Happy to guard that too if you think it's in scope here, or file it separately.

// update the version of the tool
wipSbom.Annotations[helpersv1.ToolVersionMetadataKey] = s.version
// continue to create SBOM
case wipSbom.Annotations[helpersv1.StatusMetadataKey] == helpersv1.Incomplete:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker (with the comment on markSBOMStatus) — pinning is terminal for far too long, and there's no escape hatch.

The only retry gate here is the tool version. Compare the TooLarge case just above, which has a second escape hatch (ScannerMemoryLimitAnnotation change → retry). Incomplete has none, so an image that failed for a purely environmental reason — registry 503, sidecar restart, temporary MaxImageSize/config value, node pressure — is skipped on every subsequent container start until someone ships a new node-agent version. With the spec-wipe above, that also means no CVE data for that image for the whole window.

The PR description flags this trade-off as acceptable, but I don't think it is at n=1 with no backoff, given the blast radius is "no vulnerability scanning for this image". Options:

  • Reuse the existing scanRetries/maxScanRetries pattern (already in handleScannerCrash) and only pin to Incomplete after N consecutive failures. This is the smallest change that keeps the loop bounded and keeps transient failures self-healing.
  • Or record a retry-after / attempt-count annotation and gate the skip on it, mirroring how ScannerMemoryLimitAnnotation gates the TooLarge skip.

Either way the "stop unbounded reprocessing" goal is met without making one bad minute permanent.

Nits while here:

  • This case is a near-verbatim copy of the Learning case above (~20 lines). Worth collapsing to case status == helpersv1.Learning || status == helpersv1.Incomplete: with the log message varying, or extracting a small shouldRetryAtCurrentVersion helper.
  • The stray // continue to create SBOM comment now sits between the Learning case body and this case line, so it reads as if it belongs to the new case. Move it back inside the Learning case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in db239df2:

  • Bounded retry: added a failureRetries map[string]int counter (separate from scanRetries, which handleScannerCrash already owns for OOM crashes specifically) and a new handleGenericFailure helper. A content-safe (no prior content) generic failure now only gets pinned to Incomplete after maxScanRetries (3) consecutive failures — reused the existing constant/pattern as you suggested, rather than inventing a new one. Failures below that threshold report but don't touch storage, so a transient blip self-heals on the next container start. The counter resets on success and (implicitly) whenever a version bump gives an Incomplete image a fresh budget.
  • Style nits: extracted shouldRetryAtCurrentVersion(wipSbom, sbomName, notif, skipMsg, retryMsg) bool, now shared verbatim by both the Learning and Incomplete cases — eliminates the ~20-line duplication rather than just collapsing the case condition. The stray // continue to create SBOM comment placement is fixed as a side effect of the refactor (each case now owns its own trailing comment unambiguously).

Test coverage for the bounded-retry semantics is in Test_processContainerWithMetadata_IncompleteReprocessing (rewritten to assert maxScanRetries-1 failures don't touch storage, the maxScanRetries-th does, and a version bump grants a fresh budget rather than re-pinning immediately).

Comment thread pkg/sbommanager/v1/sbom_manager.go Outdated
helpers.String("pod", notif.Container.K8s.PodName),
helpers.String("container", notif.Container.K8s.ContainerName),
helpers.String("sbomName", sbomName))
s.markSBOMStatus(wipSbom, sbomName, helpersv1.Incomplete)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, but worth confirming: this now produces two backend failure reports for one failure. node-agent reports ReasonSBOMGenerationFailed here, and once the object is persisted as incomplete, kubevuln's scan_failure_reasons.go maps helpersv1.IncompleteReasonSBOMIncomplete and reports again when it picks the SBOM up. Previously the object stayed initializing and kubevuln never reported. Since reducing duplicate backend reports is one of the stated goals of this PR, it'd be good to check that this doesn't just move the duplication one layer down.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point, and I don't have visibility to fully resolve it from this repo alone — kubevuln's report-on-Incomplete behavior is outside this PR's scope. What I can say: with the bounded-retry fix above, node-agent's own reporting is no longer unbounded (previously it reported once per attempt, forever; now it's capped at maxScanRetries reports before the image is pinned and stops reprocessing), so the total report volume from node-agent's side is strictly lower than before regardless of what kubevuln does on top. Whether kubevuln's ReasonSBOMIncomplete report is itself redundant with node-agent's ReasonSBOMGenerationFailed is a call for that side to make — flagging it here in case it's useful context if you or someone follows up there.

return sbom, nil
}

func (f *fakeSbomClient) GetSBOMMeta(name string) (*v1beta1.SBOMSyft, error) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test gap — this fake can't catch the bug in markSBOMStatus.

fakeSbomClient.GetSBOMMeta returns s.DeepCopy() with the full Spec, but the real Storage.GetSBOMMeta fetches with softwarecomposition.ResourceVersionMetadata and returns the object with an empty Spec (pkg/storage/v1/storage.go:93). That divergence is exactly what hides the data-loss regression: the test only ever exercises the freshly-CreateSBOM'd object, never the metadata-only one from the reprocessing path.

Please make the fake mirror the real contract (c.Spec = v1beta1.SBOMSyftSpec{} before returning), and add a case that starts from a pre-existing ready SBOM with a populated Spec.Syft.Artifacts at an older tool version, fails the scan, and asserts the artifacts survive.

Also: newTestManager leaves scanRetries nil. It's unreachable in the current test (the fake scanner never returns ErrScannerCrashed), but handleScannerCrash does s.scanRetries[sbomName]++, which panics on a nil map — easy footgun for whoever extends this table next. Worth initialising it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in db239df2:

  • fakeSbomClient.GetSBOMMeta now always strips Spec before returning (meta.Spec = v1beta1.SBOMSyftSpec{}), unconditionally mirroring the real Storage.GetSBOMMeta contract for every test, not just a special-cased wrapper.
  • newTestManager now initializes both scanRetries and failureRetries (the new counter). Also added newTestManagerWithScannerErr / newTestManagerInProcess for the scanner-crash and too-large scenarios, all consistently initializing both maps.
  • Added the exact case you described: a pre-existing Learning-status SBOM with Spec.Syft.Artifacts populated at an older tool version, driven through a failing reprocess, asserting the artifacts survive untouched (plus the scanner-crash and too-large variants of the same scenario, once the audit found those paths had the identical gap).

I verified each of the three new tests is a real regression guard, not a vacuous one — for each, I temporarily removed just its corresponding guard, confirmed the test fails with the exact symptom (artifacts wiped to 0, status flipped, ReplaceSBOM invoked), then restored the fix and reconfirmed the full suite is green.

@matthyx matthyx added the release Create release label Jul 20, 2026
@github-actions

Copy link
Copy Markdown

Performance Benchmark Results

Node-Agent Resource Usage
Metric BEFORE AFTER Delta
Avg CPU (cores) 0.233 0.217 -6.8%
Peak CPU (cores) 0.248 0.228 -8.0%
Avg Memory (MiB) 325.345 269.583 -17.1%
Peak Memory (MiB) 328.445 279.074 -15.0%
Dedup Effectiveness

No data available.

Addresses review feedback: markSBOMStatus persisted a wipSbom fetched via
GetSBOMMeta, which the storage layer returns without its Spec (metadata-
only fetch). Reprocessing a previously-successful, content-bearing SBOM
(e.g. after a node-agent version bump) that then fails would silently
overwrite its real content with an empty Spec and pin it to a terminal
status -- permanently losing vulnerability-scan coverage for that image.

Track whether the SBOM being reprocessed had prior successful content
(wipSbomHadContent, set only in the Learning-case version-mismatch
branch) and skip the destructive persist whenever it did, across every
path that can reach it: the generic-failure branches (handleGenericFailure,
now with a bounded failureRetries counter so a single transient error
doesn't permanently pin an image either), the scanner-crash branch
(handleScannerCrash), and the ErrImageTooLarge branch, whose totalSize is
computed from the currently-mounted layer paths rather than being a fixed
property of the image and so is equally reachable while reprocessing.

Also extracts shouldRetryAtCurrentVersion, shared by the Learning and
Incomplete switch cases, removing the near-duplicate version-gating logic
and the comment that had drifted between them.

Docs-exempt: pure bug fix, no existing doc describes SBOM reprocessing or terminal-status behavior

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Matthias Bertschy <matthias.bertschy@gmail.com>
@matthyx

matthyx commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

@matthyx Thanks for the thorough review — the data-loss finding was real and important. Pushed db239df2 addressing everything:

Blocker 1 (data loss): Fixed as you suggested — never persist wipSbom on a failed reprocess when it came in with real prior content. Tracked via a new wipSbomHadContent flag, set only in the Learning case's version-mismatch branch (the one path where wipSbom can hold content it wasn't given, since GetSBOMMeta returns metadata only). While auditing every place that can persist wipSbom, I found the same unguarded pattern in two more places beyond what you flagged — handleScannerCrash (OOM-crash loop) and the ErrImageTooLarge branch (its size check depends on currently-mounted paths, not just the image itself, so it's not as deterministic as I'd assumed) — and fixed both the same way. All three are now covered by dedicated tests (Test_processContainerWithMetadata_PreservesContentOnReprocessFailure / OnScannerCrash / OnTooLarge), each verified to actually fail (reproducing your exact symptom) when its guard is removed.

Blocker 2 (no escape hatch): Added a failureRetries counter and handleGenericFailure, reusing the maxScanRetries pattern from handleScannerCrash as you suggested — a content-free SBOM only gets pinned Incomplete after 3 consecutive failures, so a single transient error self-heals instead of permanently losing coverage.

Non-blocking: replied inline on both — the double-reporting one is now bounded on node-agent's side but the kubevuln half is out of scope here; the test-fake fix and near-duplicate-case extraction are both done.

One thing I'm flagging rather than fixing (see inline reply on the markSBOMStatus thread): a successful reprocess whose real, freshly-generated result happens to exceed MaxSBOMSize still clears Spec unconditionally today — different mechanism than the bug you found (real new data being rejected, not stale metadata clobbering good data), but similar net effect. Let me know if you'd like that folded in here or tracked separately.

All tests + go build/go vet/gofmt green, diff still scoped to the same two files.

@matthyx matthyx left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at db239df. Both blockers are fixed — verified independently with my own tests rather than trusting the new ones:

Check Result
Content preserved through 15 consecutive failures on a ready SBOM at a bumped version artifacts=2, status=ready, replaceCalls=0
Bounded retry on a fresh SBOM: 20 container starts, always-failing scanner 3 scan attempts, then pinned incomplete and rescans stop
go build / go vet / go test ./pkg/sbommanager/... pass
DCO now success — both commits signed off

The wipSbomHadContent approach is sound, and I like that the guard was extended to all three destructive paths rather than just the one I flagged — handleScannerCrash and the ErrImageTooLarge branch were both genuinely reachable while reprocessing, and the reasoning about totalSize being derived from mounted layer paths is correct. I also checked that hadContent is set on the only case that can hold real content: Learning is the sole status node-agent writes with a populated Spec (TooLarge explicitly zeroes it, Initializing/Incomplete never had one), and a completed SBOM has no NodeName annotation so it can't reach the default: branch instead. shouldRetryAtCurrentVersion cleans up the duplication and the orphaned comment nicely, and the test double now mirrors the real metadata-only GetSBOMMeta contract, with scanRetries/failureRetries initialised.

Nothing blocking left. Two non-blocking observations inline:

  1. hadContent reintroduces unbounded reprocessing for previously-successful images that start failing permanently — 10 container starts still produce 10 scans, 10 error logs and 10 backend reports. Acceptable trade vs. data loss, but an annotation-only Patch would give you both properties and let hadContent go away. At minimum the PR description shouldn't claim the loop is fixed unconditionally.
  2. failureRetries accumulates stale counters (50 entries after 50 one-shot failures).

Also still open from last round: node-agent's ReasonSBOMGenerationFailed plus kubevuln's ReasonSBOMIncomplete on the persisted object means one failure can still surface as two backend reports — now only after 3 failures instead of 1, so much less noisy, but worth a sanity check on the backend side.

CI: component tests and the benchmark job are still running; everything that has finished is green.

LGTM once you've decided what to do with (1) — happy either way, it just shouldn't be silent.

Comment thread pkg/sbommanager/v1/sbom_manager.go Outdated
// failed), so it's safe to persist -- but only after maxScanRetries consecutive failures, so a
// single transient error doesn't permanently pin the image to Incomplete.
func (s *SbomManager) handleGenericFailure(wipSbom *v1beta1.SBOMSyft, sbomName string, hadContent bool) {
if hadContent {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both of my blockers are resolved here — I re-verified independently rather than relying on the new tests, and the content-preservation guard holds even when hammered well past the retry budget (15 consecutive failures on a ready SBOM at a bumped version: artifacts=2, status=ready, replaceCalls=0).

One residual worth naming explicitly, non-blocking: if hadContent { return } means a previously-successful SBOM that now fails permanently goes back to being reprocessed on every single container start — which is the exact spam this PR set out to kill, just scoped to a narrower set of images:

// content-bearing SBOM at v1.0.0, node-agent now v2.0.0, scanner always fails
for range 10 {
    mgr.processContainerWithMetadata(notif, nil, imageStatus, imageTag, imageID)
}
// scan attempts: 10  (stored tool version still "v1.0.0")

Ten container starts → ten full scans, ten SbomManager - sidecar scan failed error logs, ten reportFailure calls to the backend. shouldRetryAtCurrentVersion bumps ToolVersionMetadataKey in memory only, and since nothing is persisted on failure the stored object never advances past v1.0.0, so the next start makes the identical decision. Same shape in handleScannerCrash: with hadContent the OOM is still reported every maxScanRetries crashes, forever.

Trading unbounded logs for not destroying data is clearly the right call, so I'm fine with this shipping. But the cleaner endgame is the annotation-only update I mentioned last round: a JSON/merge Patch on the typed client (GetStorageClient()) touching only metadata.annotations can record the terminal status and the new tool version without ever sending a Spec, so the spec can't be clobbered by a metadata-only object. That gets you both properties at once and lets hadContent disappear entirely, along with the three separate guards it now threads through handleGenericFailure / handleScannerCrash / the ErrImageTooLarge branch.

If you'd rather keep this PR minimal (reasonable — it's already grown), the ask is just to soften the PR description: the reprocessing loop is now bounded for images that never had content, but still unbounded for images that had content and start failing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with the "cleaner endgame" — pushed d95955c7. Added storage.SbomClient.PatchSBOMAnnotations(name, annotations), a JSON merge patch on metadata.annotations only (implemented via the typed client's .Patch(..., types.MergePatchType, ...)), and switched all SBOM status marking to go through it instead of ReplaceSBOM. Since the patch payload only ever contains {"metadata":{"annotations":{...}}}, it structurally cannot carry spec — there's no path for it to clobber existing content regardless of what the caller does or doesn't know about the object. hadContent and every guard built on it (handleGenericFailure, handleScannerCrash, the ErrImageTooLarge branch) are gone entirely.

Practical effect: retries are now bounded uniformly for every image via the same failureRetries counter, whether or not it previously had content. I independently sanity-tested the real implementation (not just my test double) against a fake k8s clientset — created an SBOM with a node-name annotation, patched with node-name: nil + two new keys, and confirmed the null-valued key was actually deleted and the others set, both in the patch response and a subsequent GetSBOMMeta.

One caveat worth flagging (an independent review pass caught this): the new failureRetries counter uses expirable.LRU for the memory-leak fix below, and its TTL resets on every write — so two failures for the same image spaced more than 30 minutes apart don't accumulate toward the pin threshold. That fully closes the loop for a tight failure cadence (crash loops, the actual reported symptom) but a slow-cadence permanent failure (long-lived pod, infrequent CronJob) could still in principle reprocess indefinitely without ever hitting 3 consecutive failures. Documented on the field; happy to widen the TTL or decouple it from the counting logic if you'd rather close that gap too.

Comment thread pkg/sbommanager/v1/sbom_manager.go Outdated
scannerClient sbomscanner.SBOMScannerClient
scannerMemLimit int64
scanRetries map[string]int // safe without mutex: only accessed from pool workers (pool size 1)
failureRetries map[string]int // consecutive generic SBOM-generation failures per sbomName; safe without mutex: only accessed from pool workers (pool size 1)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this map only ever shrinks on success or on reaching maxScanRetries, so images that fail once or twice and are never seen again leave their counters behind forever. Easy to observe:

failureRetries entries after 50 distinct one-shot failures: 50

scanRetries has the same shape and predates this PR, so it's not a regression — but failureRetries is reachable from every scan error rather than just sidecar OOM crashes, so it accumulates a lot faster on a long-lived node-agent that sees many short-lived images. Worth capping it the way pendingScans already caps itself with maxPendingScans, or clearing entries when the container-collection drops the image.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d95955c7: failureRetries is now an expirable.LRU[string, int] (github.com/hashicorp/golang-lru/v2, already a repo dependency, used the same way in pkg/hostfimsensor/v1/dedup_cache.go and pkg/rulemanager/rulecooldown) capped at 1000 entries with a 30-minute TTL, instead of a plain unbounded map — so one-shot failures for images never seen again get reclaimed automatically instead of accumulating forever.

Side effect worth calling out (see my reply on the sibling thread): the TTL means the counter also resets between sparse failures, so it bounds retries for a tight failure cadence but not an arbitrarily slow one. Trade-off documented in a comment on the field.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
pkg/sbommanager/v1/sbom_manager.go (1)

647-659: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

markSBOMStatus relies on callers to keep wipSbom.Spec empty rather than enforcing it itself.

Every current caller of markSBOMStatus (line 476, and via handleGenericFailure at line 705) happens to invoke it before wipSbom.Spec is ever populated, so it's currently safe. But the sibling TooLarge-marking code explicitly zeroes Spec before persisting (lines 534, 590), while markSBOMStatus does not — it's an implicit invariant, not an enforced one. Given the exact failure mode this PR was created to fix was a Spec-wipe caused by a similar implicit assumption, hardening the shared helper removes the risk of a future call site (or reordering) silently reintroducing it.

🛡️ Defensive hardening for markSBOMStatus
 func (s *SbomManager) markSBOMStatus(wipSbom *v1beta1.SBOMSyft, sbomName, status string) {
 	delete(wipSbom.Annotations, NodeNameMetadataKey)
 	wipSbom.Annotations[helpersv1.StatusMetadataKey] = status
+	wipSbom.Spec = v1beta1.SBOMSyftSpec{}
 	if _, err := s.storageClient.ReplaceSBOM(wipSbom); err != nil {

Also applies to: 685-706

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/sbommanager/v1/sbom_manager.go` around lines 647 - 659, Update
markSBOMStatus and its callers’ shared status-persistence flow to explicitly
preserve the intended empty wipSbom.Spec state before ReplaceSBOM, rather than
relying on call order or caller behavior. Align this helper with the existing
TooLarge-marking paths that clear Spec, while keeping the annotation updates and
error logging unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@pkg/sbommanager/v1/sbom_manager.go`:
- Around line 647-659: Update markSBOMStatus and its callers’ shared
status-persistence flow to explicitly preserve the intended empty wipSbom.Spec
state before ReplaceSBOM, rather than relying on call order or caller behavior.
Align this helper with the existing TooLarge-marking paths that clear Spec,
while keeping the annotation updates and error logging unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e548f0a4-03e0-4122-be92-c0f64f7f9afe

📥 Commits

Reviewing files that changed from the base of the PR and between 35e5523 and db239df.

📒 Files selected for processing (2)
  • pkg/sbommanager/v1/sbom_manager.go
  • pkg/sbommanager/v1/sbom_manager_reprocessing_test.go

@github-actions

Copy link
Copy Markdown

Performance Benchmark Results

Node-Agent Resource Usage
Metric BEFORE AFTER Delta
Avg CPU (cores) 0.145 0.153 +5.4%
Peak CPU (cores) 0.154 0.162 +5.1%
Avg Memory (MiB) 321.931 266.418 -17.2%
Peak Memory (MiB) 331.000 275.496 -16.8%
Dedup Effectiveness

No data available.

@matthyx

matthyx commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Status re-check at db239df (no new commits since my last review): CI is now fully green — 30/30 checks, including all component tests, the benchmark job, DCO and CodeRabbit. go build / go vet / go test ./pkg/sbommanager/... still pass locally. No new code findings from me.

Two small things left, neither blocking:

1. The PR description is now stale. The "Known trade-off" paragraph describes the old behaviour and contradicts the shipped code:

a one-off transient failure (e.g. a brief sidecar gRPC blip) on an image is now also skipped on the next container start at the same tool version

That is no longer true — handleGenericFailure gives each image maxScanRetries consecutive failures before pinning, so a one-off blip is fully self-healing now.

A bounded retry-with-backoff before pinning to Incomplete (mirroring the existing scanRetries/maxScanRetries pattern for scanner crashes) was considered as a possible future refinement but is out of scope for this fix.

This is exactly what db239df implements, so it reads as an unfulfilled TODO when it's actually done.

The description also doesn't mention the content-preservation guard (wipSbomHadContent), which is now the more interesting half of the PR, nor the residual I flagged: a previously-successful SBOM that starts failing permanently is still reprocessed on every container start, because nothing is persisted for it. Worth a short rewrite of that section so the merge commit reflects what actually landed.

2. I'd take @coderabbitai's markSBOMStatus nitpick. Adding wipSbom.Spec = v1beta1.SBOMSyftSpec{} inside the helper is cheap and makes the invariant enforced rather than implicit — both sibling TooLarge paths already zero the Spec explicitly, and the whole point of this PR was a Spec-wipe caused by an implicit assumption about what the caller passes in. It doesn't change behaviour at any current call site.

Otherwise LGTM.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@matthyx Agreed on both points.

The PR description should be updated to reflect the bounded generic-failure retry behavior and the wipSbomHadContent preservation guard, while documenting the remaining retry-on-every-start behavior for previously successful SBOMs that permanently fail reprocessing.

I also agree that markSBOMStatus should explicitly reset wipSbom.Spec. Keeping that invariant inside the helper makes the terminal Incomplete transition self-contained and avoids relying on every caller to clear content correctly.

🐇 ✅


✏️ Learnings added
Learnt from: matthyx
URL: https://github.com/kubescape/node-agent/pull/855

Timestamp: 2026-07-20T13:20:34.901Z
Learning: In `pkg/sbommanager/v1/sbom_manager.go`, failed reprocessing of an SBOM that already has persisted content preserves that content and does not persist a terminal failure status. Consequently, if such reprocessing fails permanently, it is retried on subsequent container starts; this is intentional residual behavior of the current change and should not be mistaken for the content-free `Incomplete` retry path.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

…place

Addresses non-blocking review feedback: the previous fix's hadContent
guard meant a previously-successful SBOM that started failing
permanently (not just transiently) was reprocessed on every container
start forever, since nothing was ever persisted to stop the loop for
that class of image -- the retry-bounding only applied to images that
never had content.

Add storage.SbomClient.PatchSBOMAnnotations, a JSON merge patch on
metadata.annotations only, which never sends spec regardless of what
the caller does or doesn't know about the object's content. Route all
SBOM status marking (Incomplete, TooLarge) through it instead of a full
ReplaceSBOM, so it's always safe to persist a terminal status -- the
hadContent tracking, and every guard built on it across
handleGenericFailure, handleScannerCrash and the ErrImageTooLarge
branch, is removed entirely. Retries are now bounded uniformly for all
images via the same failureRetries counter, switched from a plain map
to a bounded+TTL'd expirable.LRU so short-lived images don't leak
entries either (the second non-blocking item).

markSBOMStatus now also records the current tool version alongside the
status, since the version check that gates future reprocessing depends
on it and the object is no longer implicitly carrying an in-memory
version bump the way the old ReplaceSBOM-based code did.

Docs-exempt: pure bug fix, no existing doc describes SBOM reprocessing, terminal-status, or storage patch behavior

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Matthias Bertschy <matthias.bertschy@gmail.com>
@matthyx

matthyx commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

@matthyx Pushed d95955c7 addressing both non-blocking items from your last review:

  1. Unbounded reprocessing for previously-successful images — implemented the "cleaner endgame" you suggested: storage.SbomClient gained PatchSBOMAnnotations, a JSON merge patch on metadata.annotations only, which structurally cannot carry spec. All SBOM status marking now goes through it instead of ReplaceSBOM, so hadContent and every guard built on it are gone — retries are bounded uniformly for all images now, regardless of prior content.
  2. failureRetries leaking entries — switched from a plain map to expirable.LRU (bounded at 1000 entries, 30-min TTL), matching the pattern already used elsewhere in this repo (dedup_cache.go, rulecooldown).

One new trade-off surfaced by an independent review pass and flagged inline: the LRU's TTL resets on every write, so the retry count only accumulates for failures spaced under 30 minutes apart — fully closes the loop for the reported symptom (crash loops) but not for an arbitrarily slow failure cadence. Happy to adjust if you'd like that closed too.

All tests green (including a from-scratch sanity check of the real PatchSBOMAnnotations implementation against a fake k8s clientset, confirming the null-value annotation deletion actually works end-to-end, not just against my own test double), go build/go vet/gofmt clean.

@matthyx

matthyx commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

@matthyx Re: #issuecomment-5022708354 — both addressed:

  1. PR description rewritten to reflect what actually shipped: bounded failureRetries retry (with the TTL caveat), the PatchSBOMAnnotations content-preservation mechanism, and the residual slow-cadence trade-off you and I already discussed. The stale "known trade-off"/"out of scope" paragraphs from the original description are gone.
  2. The markSBOMStatus/CodeRabbit nitpick is now moot — this comment was posted at db239df, right before I pushed d95955c7 (the PatchSBOMAnnotations rewrite, in response to your two non-blocking items from the review right after this comment). markSBOMStatus no longer takes wipSbom at all; it only ever builds an annotations-only patch payload, so there's no Spec for it to zero or forget to zero — the invariant CodeRabbit wanted enforced is now structural rather than a helper needing to remember it.

Let me know if the updated description reads right, or if you'd like the slow-cadence TTL gap closed too.

@matthyx matthyx left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at d95955c. The annotation-only patch is the right shape and I verified the mechanism end-to-end rather than assuming it works:

  • sbomsyfts.NewREST is a plain genericregistry.Store (no immutableStorage, no Patch override in the registry.REST wrapper), so MergePatchType is fully wired.
  • The registry's getCurrentState does a plain Get — not the ResourceVersionMetadata one — so the merge patch applies to the full stored object, and PrepareForUpdate is a no-op. Spec genuinely survives.
  • null values for annotation deletion are correct merge-patch semantics, and the mock mirrors that.
  • go build ./..., go vet, go test ./pkg/sbommanager/... ./pkg/storage/... all pass.

Removing hadContent entirely, uniform retry bounding for every image, the expirable.LRU (with its TTL caveat documented honestly), and the tests flipping from replaceCalls to patchCalls with an explicit "must never use a full ReplaceSBOM" assertion — all good. This is a genuinely better design than db239df.

One new problem, though, and I think it needs addressing before merge. TooLarge is not just another status: StorageImpl.GuaranteedUpdate (storage@v0.0.290, pkg/registry/file/storage.go:900) short-circuits and silently drops every subsequent write to any object annotated status: too-large. So the two markSBOMStatus(..., TooLarge, ...) call sites — the ErrImageTooLarge branch and handleScannerCrash — can now take a previously-successful, content-bearing SBOM and freeze it permanently: skipped by kubevuln (no CVE coverage), unrepairable by any future node-agent version, and still carrying the full spec that TooLarge exists to shed.

Against main this is still an improvement (there it froze and wiped). Against db239df it's a step back, because the hadContent guard kept those SBOMs ready and scannable. That's on my last review for pushing on the noise without flagging the freeze property — details and three fix options inline; I'd keep the patch for Incomplete and just not let TooLarge land on a content-bearing object.

CI on this head is still running; everything green on the previous head.

Comment thread pkg/sbommanager/v1/sbom_manager.go Outdated
helpers.Error(replaceErr),
helpers.String("sbomName", sbomName))
}
s.markSBOMStatus(sbomName, helpersv1.TooLarge, nil)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New issue introduced by this commit — marking a content-bearing SBOM TooLarge freezes it permanently in storage.

The patch mechanism itself is correct: sbomsyfts.NewREST is a plain genericregistry.Store, so a MergePatchType patch is applied server-side to the full current object (the registry's getCurrentState does a plain Get, not the ResourceVersionMetadata one), and SbomSyftStrategy.PrepareForUpdate is a no-op. So spec really is preserved. That part works.

But TooLarge is special in the storage layer in a way Incomplete is not. StorageImpl.GuaranteedUpdate short-circuits before running tryUpdate:

// storage@v0.0.290 pkg/registry/file/storage.go:900-906
annotations := origState.obj.(metav1.Object).GetAnnotations()
if annotations != nil && annotations[helpersv1.StatusMetadataKey] == helpersv1.TooLarge {
    logger.L().Debug("GuaranteedUpdate - already too large object, skipping update", ...)
    v.Set(reflect.ValueOf(origState.obj).Elem())
    return nil   // <- silently drops the write, returns nil error
}

Once an object carries status: too-large, no update or patch ever lands on it again — silently, with no error returned to the client.

Combine that with the guard removal here and you get a one-way door on two paths — the ErrImageTooLarge branch above, and handleScannerCrash after maxScanRetries OOMs — both now reachable while reprocessing a previously-successful ready SBOM:

  1. Image scanned fine at v1.0.0; SBOM has real artifacts.
  2. Version bump to v2.0.0Learning case reprocesses.
  3. ErrImageTooLarge fires (your own db239df message correctly noted totalSize comes from the currently-mounted layer paths, so this is reachable), or the sidecar OOMs three times.
  4. markSBOMStatus(sbomName, TooLarge, ...) patches status: too-large and leaves the full spec in place.

The resulting object is now:

  • skipped by kubevuln forevercore/services/scan.go skips on Incomplete || TooLarge, so the image silently loses CVE coverage;
  • unfixable by any future node-agent version — the TooLarge switch case only retries when ScannerMemoryLimitAnnotation changed, and even if it does retry and succeeds, the final ReplaceSBOM is silently swallowed by the short-circuit above. There is no node-agent code path that can ever repair it;
  • still carrying the full spec, which defeats the whole point of TooLarge (storage's own ObjectTooLargeError handler calls clearSpec(ret) with the comment "clear spec to not bloat the storage"), and both other TooLarge writers (handleScannerCrash pre-this-commit, and the sz > MaxSBOMSize path at line ~530) zero the Spec explicitly.

Your new Test_..._PreservesContentOnScannerCrash asserts exactly this state (status == TooLarge, artifacts == 2) as the desired outcome — that's the assertion I'd revisit.

Note this is not a regression against main (there, both paths did a spec-clearing ReplaceSBOM, so they froze the object and destroyed the content). It's a regression against db239df, where the hadContent guard left the SBOM ready and scannable, at the cost of the reprocessing noise I flagged. Trading noisy-but-recoverable for silent-and-permanent is the wrong direction, and I don't think I made that trade-off clear enough last round — sorry.

Suggested fix, smallest first:

  1. Keep the annotation-only patch for Incomplete, but don't let a content-bearing SBOM reach TooLarge at all on the reprocess path. Incomplete is retryable on a version bump and is not short-circuited by the storage layer, so it's the correct terminal status when we're not deliberately discarding content.
  2. Or keep a narrow hadContent guard only for the two TooLarge call sites, and let Incomplete flow freely through the patch — that preserves everything this commit achieves for the common case.
  3. Or, if you do want TooLarge semantics here, it must clear the spec, which means a full ReplaceSBOM — i.e. deliberately discarding the old SBOM. Defensible for a genuinely-too-large image, much less so for three sidecar OOMs.

I'd go with (1) or (2).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with your fix (1) — pushed a5c562be. Reintroduced wipSbomHadContent, but scoped narrowly to just the two TooLarge write sites this time (not threaded through every generic-failure path like the old hadContent was): the ErrImageTooLarge branch and handleScannerCrash's post-maxScanRetries marking. When a content-bearing SBOM would otherwise hit either, it now falls back to Incomplete (via the same annotation-only patch, retryable, no storage-layer short-circuit) instead of TooLarge. Content-free images still get marked TooLarge immediately on either path — unchanged.

Incomplete keeps flowing entirely through PatchSBOMAnnotations as of d95955c7, untouched by this change — this only affects the two TooLarge writers.

Added a new test, Test_processContainerWithMetadata_MarksFreshImageTooLargeImmediately, since all the prior TooLarge tests happened to be content-bearing scenarios and there was no coverage left for the unchanged "fresh reservation, no content to lose" case. Verified both modified tests (_PreservesContentOnScannerCrash, _PreservesContentOnTooLarge) reproduce your exact one-way-door symptom (status flips to too-large instead of staying incomplete) when the new guard is reverted.

One thing I deliberately left alone, flagging rather than fixing: handleScannerCrash keeps its own independent scanRetries counter rather than sharing failureRetries with the generic-failure path. An image that accumulates some generic failures and then hits 3 OOM crashes gets a fresh crash-specific budget on top, so total attempts before a terminal status can exceed maxScanRetries in a mixed-failure-mode scenario. That's a pre-existing inconsistency (both counters already existed independently before this PR), not something introduced by this fix — happy to unify them as a follow-up if you'd like, but didn't want to fold another counter-architecture change into round 5.

// bounds retries for a tight failure cadence (e.g. a crash loop) rather than every possible
// one -- a slow-cadence permanent failure (long-lived pod, infrequent CronJob) can still
// reprocess indefinitely without ever accumulating enough consecutive failures to pin.
failureRetries *expirable.LRU[string, int]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bounded + TTL'd LRU is a good call, and I appreciate that the TTL's downside is documented honestly rather than papered over — a slow-cadence permanent failure (long-lived pod, infrequent CronJob) never accumulating maxScanRetries consecutive failures within 30 minutes is a real hole, and saying so here is much better than silently pretending the loop is bounded in all cases.

Non-blocking thought for later: if that hole ever bites, persisting the attempt count as an annotation (alongside the status) would make the budget survive both the TTL and a node-agent restart, at the cost of one extra patch per failure. Not worth doing now — just noting where the ceiling is.

@github-actions

Copy link
Copy Markdown

Performance Benchmark Results

Node-Agent Resource Usage
Metric BEFORE AFTER Delta
Avg CPU (cores) 0.162 0.155 -4.7%
Peak CPU (cores) 0.171 0.161 -5.8%
Avg Memory (MiB) 325.670 268.470 -17.6%
Peak Memory (MiB) 327.359 271.719 -17.0%
Dedup Effectiveness

No data available.

…way door

Addresses review feedback on d95955c: TooLarge is special-cased in the
storage layer's GuaranteedUpdate, which silently drops every future
write (patch or replace) to an object once its status annotation is
TooLarge. Every other TooLarge writer in this codebase explicitly
clears Spec first because of this, but PatchSBOMAnnotations never
touches Spec at all -- so patching a content-bearing SBOM to TooLarge
left its real Spec permanently frozen in storage: unfixable by any
future reprocess, and defeating the point of TooLarge in the first
place (avoiding a bloated stored object).

Reintroduce wipSbomHadContent, scoped narrowly to the two TooLarge
write sites (the ErrImageTooLarge branch and handleScannerCrash's
post-maxScanRetries marking): a content-bearing SBOM now falls back to
the retryable Incomplete path instead, which has no such short-circuit.
Incomplete continues to flow entirely through the annotation-only
patch introduced in d95955c, unaffected.

Docs-exempt: pure bug fix, no existing doc describes SBOM status transitions or storage patch semantics

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Matthias Bertschy <matthias.bertschy@gmail.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pkg/sbommanager/v1/sbom_manager.go (1)

298-353: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

wipSbomHadContent isn't set when reprocessing an already-Incomplete SBOM that still has preserved content — reopens the TooLarge one-way door.

wipSbomHadContent is only set true in the Learning case (line 345). But an Incomplete-status SBOM can also carry real, preserved Spec.Syft.Artifacts — exactly what Test_processContainerWithMetadata_PreservesContentOnReprocessFailure demonstrates: a Learning SBOM that fails repeatedly ends up Incomplete with its 2 artifacts intact, because PatchSBOMAnnotations/handleGenericFailure never touch Spec.

If that SBOM is reprocessed again at a later tool version (hits the Incomplete case at line 347, not Learning), wipSbomHadContent stays false. A subsequent ErrImageTooLarge (line 484-490) or maxScanRetries scanner crashes (line 599-605) then takes the non-hadContent branch and calls markSBOMStatus(..., helpersv1.TooLarge, ...). That's an annotation-only patch — it does not clear Spec — so the real content survives in storage, but the storage layer's GuaranteedUpdate short-circuit makes TooLarge a one-way door: the object is now permanently frozen with its old content, unfixable by any future version bump. This is the exact bug class already fixed for the Learning case in this PR, just reachable through a narrower, currently-untested path.

Setting wipSbomHadContent = true in the Incomplete case too closes this gap; the only behavioral cost for a genuinely content-free Incomplete SBOM is a slightly more conservative (but safe) outcome.

🐛 Proposed fix
 		case wipSbom.Annotations[helpersv1.StatusMetadataKey] == helpersv1.Incomplete:
 			if !s.shouldRetryAtCurrentVersion(wipSbom, sbomName, notif,
 				"SBOM generation previously failed with this tool version, skipping",
 				"SBOM generation previously failed with a different tool version, retrying") {
 				return
 			}
+			// an Incomplete SBOM can already carry preserved content from an earlier
+			// successful generation (handleGenericFailure/handleScannerCrash never clear
+			// Spec), so treat it the same as Learning to avoid the TooLarge one-way door.
+			wipSbomHadContent = true
 			// continue to create SBOM
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/sbommanager/v1/sbom_manager.go` around lines 298 - 353, Set
wipSbomHadContent to true in the Incomplete branch of the existing-status
switch, after shouldRetryAtCurrentVersion permits reprocessing, matching the
Learning branch. This ensures reprocessed Incomplete SBOMs preserve content-safe
failure handling while leaving the retry and skip behavior unchanged.
🧹 Nitpick comments (1)
pkg/storage/v1/storage.go (1)

107-117: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Guard against a nil/empty annotations map wiping all annotations.

Per RFC 7396 (JSON Merge Patch), a null value for a field deletes that field from the target. json.Marshal of a nil Go map produces null, so PatchSBOMAnnotations(name, nil) would send {"metadata":{"annotations":null}}, deleting the object's entire annotations map (status, tool-version, image-tag, etc.) rather than being a no-op.

No current caller triggers this (markSBOMStatus always builds a non-nil literal map), so this isn't an active bug, but it's a footgun for future callers — and storage_mock.go's mock implementation silently behaves as a no-op for nil (ranging over a nil map), so tests built on the mock wouldn't catch a real-world regression here.

🛡️ Proposed guard
 func (sc *Storage) PatchSBOMAnnotations(name string, annotations map[string]any) (*v1beta1.SBOMSyft, error) {
+	if annotations == nil {
+		annotations = map[string]any{}
+	}
 	patch, err := json.Marshal(map[string]any{
 		"metadata": map[string]any{
 			"annotations": annotations,
 		},
 	})

Please confirm the RFC 7396 null-deletes-field semantics against your own understanding of the Kubernetes merge-patch behavior before applying, since this affects a sensitive annotation-preservation guarantee this PR relies on.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/storage/v1/storage.go` around lines 107 - 117, Update
PatchSBOMAnnotations to return without issuing a patch when annotations is nil
or empty, preserving all existing annotations and matching the mock’s no-op
behavior. Keep the current merge-patch flow unchanged for non-empty maps,
including the existing JSON marshaling and SBOMSyfts patch call.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@pkg/sbommanager/v1/sbom_manager.go`:
- Around line 298-353: Set wipSbomHadContent to true in the Incomplete branch of
the existing-status switch, after shouldRetryAtCurrentVersion permits
reprocessing, matching the Learning branch. This ensures reprocessed Incomplete
SBOMs preserve content-safe failure handling while leaving the retry and skip
behavior unchanged.

---

Nitpick comments:
In `@pkg/storage/v1/storage.go`:
- Around line 107-117: Update PatchSBOMAnnotations to return without issuing a
patch when annotations is nil or empty, preserving all existing annotations and
matching the mock’s no-op behavior. Keep the current merge-patch flow unchanged
for non-empty maps, including the existing JSON marshaling and SBOMSyfts patch
call.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7e4a7a44-2c8d-46f8-a502-982d28dbd6fc

📥 Commits

Reviewing files that changed from the base of the PR and between db239df and a5c562b.

📒 Files selected for processing (5)
  • pkg/sbommanager/v1/sbom_manager.go
  • pkg/sbommanager/v1/sbom_manager_reprocessing_test.go
  • pkg/storage/storage_interface.go
  • pkg/storage/storage_mock.go
  • pkg/storage/v1/storage.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • pkg/sbommanager/v1/sbom_manager_reprocessing_test.go

@matthyx matthyx left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at a5c562b. This is the fix I was asking for, and I verified the invariant independently across every failure mode rather than trusting the updated tests.

Hammered a content-bearing SBOM (ready at v1.0.0, 2 artifacts, node-agent at v2.0.0) with maxScanRetries * 4 container starts through each of the three paths that could previously reach TooLarge:

Failure mode Final status Artifacts patch / replace
generic sidecar error incomplete 2 1 / 0
scanner OOM crash loop incomplete 2 1 / 0
ErrImageTooLarge on reprocess incomplete 2 1 / 0

Never TooLarge, content intact, exactly one patch, zero full replaces, then it stops. And the other half still holds — a fresh content-free image hitting ErrImageTooLarge is pinned TooLarge immediately with no retry budget, Spec empty.

I also re-checked the invariant across all three TooLarge writers in the file, since that's what makes the one-way door safe: lines 489 and 602 are now both guarded by hadContent, and the sz > MaxSBOMSize path at 545 explicitly clears the Spec. So every object that can carry status: too-large has an empty Spec again — which is exactly the property the storage-layer short-circuit assumes. Scoping wipSbomHadContent to just the two TooLarge sites, and letting Incomplete keep flowing through the annotation-only patch, is the right split.

go build ./..., go vet, go test ./pkg/sbommanager/... ./pkg/storage/... all pass. CI is green apart from the benchmark job, still running.

LGTM. Two notes inline (a memory-limit-recovery asymmetry, and a pre-existing main issue worth a follow-up issue rather than more churn here), plus one thing to fix in the description before merge:

The rewritten description is much better, but it's now stale by one commit. Under Content preservation it still says:

All SBOM-generation failure paths (generic scan/source/syft errors, repeated sidecar OOM crashes, and ErrImageTooLarge) now go through this patch-based marking, so retries are bounded uniformly whether or not the image previously had content — no separate case for "already had good data" is needed anymore.

a5c562b reintroduced exactly that separate case, deliberately. Worth replacing that sentence with the one-way-door reasoning — it's now a load-bearing design constraint that the next person to touch markSBOMStatus needs to know, and the commit message explains it well.


Pre-existing, out of scope — recording it so it isn't lost, since it's the last remaining way a good SBOM's content can be destroyed. if a reprocess succeeds but the resulting SBOM exceeds MaxSBOMSize, the sz > MaxSBOMSize path (sbom_manager.go:545) clears the Spec and pins TooLarge via a full ReplaceSBOM. On the reprocess path that overwrites a previously-successful, correctly-sized SBOM with an empty one, and the storage-layer short-circuit then freezes it permanently — the same one-way door a5c562b closes for the failure paths, still open for the success-but-oversized path.

Arguably defensible (we did generate a real SBOM and it genuinely is too large), and unlike the failure paths there's no transient-error component here, so it's much less likely to fire spuriously. Worth a follow-up issue rather than more churn on this PR.

helpers.Error(replaceErr),
helpers.String("sbomName", sbomName))
if hadContent {
s.markSBOMStatus(sbomName, helpersv1.Incomplete, nil)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small behavioural consequence worth knowing about, not a change request: this branch drops ScannerMemoryLimitAnnotation, which the TooLarge branch below still records. So a content-bearing SBOM that OOM-crash-looped is pinned Incomplete, and the Incomplete switch case gates only on tool version — raising the scanner memory limit will no longer make node-agent retry that image; only a version bump will.

That's still strictly better than before: the old TooLarge marking recorded the memory-limit hint, but the storage-layer short-circuit meant that even when the hint did trigger a retry, a successful rescan could never be persisted. So a recoverable-in-principle path became an actually-recoverable one. Just flagging it since "bump the sidecar memory limit to recover OOM-killed images" is presumably the documented operator workflow, and it now silently doesn't apply to this subset.

If you want them symmetric, passing the same map[string]any{ScannerMemoryLimitAnnotation: ...} here plus a memory-limit escape hatch in the Incomplete case (mirroring the one in the TooLarge case) would do it — but that's scope creep on an already-long PR and fine as a follow-up.

@github-actions

Copy link
Copy Markdown

Performance Benchmark Results

Node-Agent Resource Usage
Metric BEFORE AFTER Delta
Avg CPU (cores) 0.155 0.153 -1.6%
Peak CPU (cores) 0.162 0.160 -1.3%
Avg Memory (MiB) 335.804 276.078 -17.8%
Peak Memory (MiB) 338.195 279.973 -17.2%
Dedup Effectiveness

No data available.

@matthyx matthyx moved this to WIP in KS PRs tracking Jul 20, 2026
@matthyx
matthyx merged commit daa4f2f into main Jul 20, 2026
30 checks passed
@matthyx
matthyx deleted the fix/sbom-failure-reprocessing-loop branch July 20, 2026 15:35
@matthyx matthyx moved this from WIP to To Archive in KS PRs tracking Jul 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release Create release

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

1 participant