fix(#202): count Multus pods in the DaemonSet's namespace so OpenShift (openshift-multus) reports correctly - #203
Conversation
…t reports correctly The scanner fetched pods only from a hardcoded namespace set (kube-system, cert-manager, kamaji-system, + BNK/F5 discovery) and analyze_multus derived running_pods from the kube-system list alone. On ROKS/OpenShift, Multus runs in openshift-multus, which was never queried, so running_pods read 0 even when Multus was healthy (the DaemonSet is still found via list_daemon_set_for_all_namespaces, so status showed DETECTED with 0 pods — exactly the reported "0 Multus pods while N running"). Fix (DaemonSet-namespace-driven): fetch the Multus pods from whatever namespace the discovered DaemonSet actually lives in. Vanilla k8s (kube-system) reuses the already-fetched pod list — no extra API call; OpenShift (openshift-multus) and any future layout fetch that namespace. No new hardcode. - fetch.py: add _multus_daemonset_namespace + _fetch_multus_pods; expose a new namespace-scoped "multus_pods" in the fetch dict. - __init__.py: feed analyze_multus the scoped multus_pods. - prereqs.py: rename the analyze_multus param to multus_pods; count running pods from it. Tests exercise the REAL fetch path: fetch_scan_data runs with the k8s API mocked so list_daemon_set_for_all_namespaces reports Multus in openshift-multus and list_namespaced_pod returns Multus pods ONLY there. The scan then queries openshift-multus and counts 3 running pods (was 0 before). A vanilla-k8s test (Multus in kube-system) still counts. Reverting the fix reds the OpenShift tests (mutation-verified). Stub fetch dicts gain the new multus_pods key. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
Self-review (cold, adversarial) — no blocker, no major; correct for the real OpenShift scenarioAn independent cold auditor reviewed this PR, executing against a mocked k8s API (326 tests green across the scanner/fetch/prereqs/recommendations/inventory suites). Held under attack (verified):
Two MINORs (cosmetic, non-blocking):
Addressing both below for robustness. |
…d-count scope (self-review) Self-review MINOR 2: _multus_daemonset_namespace (fetch) and analyze_multus (prereqs) picked the first DaemonSet merely CONTAINING "multus", so a sibling like "multus-additional-cni-plugins" could be chosen ahead of the primary if it sorted first. Both now prefer the DaemonSet named EXACTLY "multus" (falling back to the first containing it), so the fetched namespace and the reported DaemonSet are consistent regardless of list order. Locked by two new tests (sibling-first ordering). Self-review MINOR 1: running_pods intentionally counts the broad Multus- networking pod set (any Running pod whose name carries "multus"). Precise per-DaemonSet attribution would need pod ownerReferences we do not fetch; a name-prefix heuristic mis-handles real-world names (a "multus" DaemonSet whose pods are "kube-multus-ds-*", as the vanilla test shows), so guessing would regress that case. Kept the tolerant match and documented the scope — it is a display metric and still far better than the 0-count bug. Verified: scanner/prereqs/multus + recommendations/proxy-inventory/discovery/ scan-task suites = 129 passed; ruff clean. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
Review discipline — round 1 @
|
Review —
|
| Input | _multus_daemonset_namespace |
analyze_multus |
|---|---|---|
{"name": None, ...} |
tolerates | AttributeError: 'NoneType' object has no attribute 'lower' |
{"name": "multus"}, no namespace |
returns None |
KeyError: 'namespace' |
Neither is reachable today (_fetch_daemonsets always populates both), so this is a drift hazard, not a live crash — but the diff introduced the divergence while holding the safe idiom in the other file. One of the two hardenings is wrong: either None is reachable and analyze_multus crashes the scan, or it isn't and the or "" is dead. Extract one shared pick_primary_multus_daemonset(daemonsets); no test asserts the two pickers agree.
m-3 · The comment describing running_pods contradicts what the code now computes. prereqs.py:198-205 claims the metric is "the broad Multus-networking pod set… on a cluster with a separate multus-additional-cni-plugins DaemonSet this includes those pods too." That was true pre-fix; it is now false whenever the sibling lives elsewhere, because only the primary DaemonSet's namespace is fetched. Verified end-to-end with multus@openshift-multus (3 pods) + sibling@sib-ns (6 pods) → running_pods = 3, sibling excluded. The metric is now a topology-dependent hybrid: neither the broad set nor the DaemonSet's ready count.
The flip side matters more in practice: real OpenShift co-locates both DaemonSets in openshift-multus, so on a 3-node cluster this now reports running_pods: 6 beside a DaemonSet reading 3/3. The issue's symptom "0 Multus pods while N running" becomes "6 while 3 running". Still wrong, in the other direction.
Also, the stated reason for accepting that — "precise per-DaemonSet attribution needs pod ownerReferences we do not fetch" — understates what is available: _fetch_pods_in_ns already captures pod labels (fetch.py:266). The DaemonSet's spec.selector.matchLabels is the canonical mechanism and is a one-line addition to an already-fetched object (_fetch_daemonsets captures metadata.labels but not spec.selector) — no extra API call. The design choice may still be right; the justification for it isn't accurate.
m-4 · An RBAC denial reproduces the exact #202 symptom with zero diagnostic. _fetch_pods_in_ns swallows every exception and returns [] with no logging — unlike sibling _fetch_daemonsets, which logs at fetch.py:252. On a ROKS cluster whose scan credential can list DaemonSets cluster-wide but not pods in openshift-multus, the panel shows the identical wrong running_pods: 0 this PR fixes, and nothing says why. Verified: no exception raised, running_pods = 0, status = detected. One logger.warning(f"Failed to fetch pods in {namespace}: {e}") makes the fix's own failure mode observable.
m-5 · The new fetch is serial, on the critical path. fetch.py:948 sits outside the with pool: block (closes at :936), so it runs after the entire parallel burst, adding up to _request_timeout=10 serially to every OpenShift scan. It correctly adds no call where none is needed (vanilla queries ['cert-manager','kube-system']; OpenShift adds openshift-multus). Resolving daemonsets_f early and submitting the dependent fetch inside the pool keeps it off the critical path — or it disappears entirely under M-1's sweep.
m-6 · Coverage gaps. Untested: empty DaemonSet list; DaemonSet with no namespace key; {"name": None}; and sibling-before-primary end-to-end (_run_fetch hardcodes a single DS named multus, so fetch→analyze agreement under a cross-namespace sibling list is never exercised — only the two unit-level pickers). test_non_running_openshift_multus_pods_not_counted fails pre-fix only via KeyError: 'multus_pods', so it proves the dict contract, not the phase filter it names.
Mock fidelity is acceptable — _v1_pod/_v1_daemonset set every field the parsers read, so no auto-attribute leaks into an assertion. But the docstring's "exercise the REAL fetch path" is generous: with only CoreV1Api/AppsV1Api patched, every other key degrades to empty through swallowed exceptions and version_info comes back as raw MagicMock objects. The multus assertions are sound; the framing overstates.
Nits
scanner/__init__.py:107still threadsdata["kube_system_pods"]intoanalyze_sriov, whose third parameter now appears only in its signature — I grepped the whole function body, one occurrence. Multus was its last real consumer. Drop the dead parameter, or the plumbing.analyze_multusis in__all__and its 3rd parameter was renamedkube_system_pods→multus_pods. All in-repo calls are positional so nothing breaks; keyword callers outside the repo would.
Review Assessment
- Verdict: REVISE
- Audit SHA:
1e575d09db32e443020e42811b98d7a9ee16d72a - Cold Audit Performed: Yes — independent agent, no session context, full diff, verified against the repo
- Invariants Verified: INV-4 (cross-writer namespace/file collision — clean vs open fix(#191): validate credential-template provider so an unknown value can't silently inject nothing #199/fix(#194): enqueue cluster inventory sync on registration and make a resync reliably triggerable #200/fix(#195): stream opentofu task logs incrementally and expose log fields in the task list #201/Surface L4Route service-settings faithfully (groundwork for #8; on-screen fix needs cluster data) #172); INV-6 analog (frontend
?? 0fails closed tomissing, acceptable for a display metric); INV-1/2/3/5/7/8/9 N/A (no DB queries, FK writes, schema enums, delete paths, migrations, locks, or shell scripts in this diff) - Git & Harness Cleanliness: Clean for PR content; the local checkout carries unrelated untracked harness drift (
.gitignore.maf.new,bin/hooks/)
Findings & Action Items
- Major (Blockers):
-
fetch.py:833/:895(+prereqs.pycert-manager & kamaji gates): hardcoded-namespace pod fetch is a 3-instance class; only the multus consumer is fixed. cert-manager additionally downgrades status and emits a wrong recommendation. Class fix: onelist_pod_for_all_namespacessweep per the existingbnk_pod_discovery.py:272pattern — or file the follow-ups explicitly.
-
- Minor (Non-blocking):
-
fetch.py:295/prereqs.py:190: exact-multusmatch never fires onkube-multus-ds; selection falls back to list order. -
fetch.py:292vsprereqs.py:188: duplicated picker, divergent null-handling — extract one shared helper. -
prereqs.py:198-205: comment contradicts the implementation; count reads ~2× the DaemonSet on co-located OpenShift; the ownerReferences rationale ignores already-fetched pod labels. -
fetch.py:281: add alogger.warningso an RBAC denial is distinguishable from "Multus absent". -
fetch.py:948: serial fetch outside the pool; submit it inside. -
tests/unit/test_scanner_multus_namespace.py: add empty-DS-list, missing-namespace, and end-to-end sibling-first cases.
-
- Nits:
scanner/__init__.py:107:analyze_sriov'skube_system_podsparameter is dead.analyze_multuspublic parameter renamed; positional-only in-repo, safe.
…uster + forward-compat #203 bonnyr-f5 round-2 BLOCK, two conditions. Must-fix 1 — success signal. Every fetcher swallows its exception and returns an empty default, and load_kubeconfig never contacts the API server, so an expired-token / unreachable cluster produced a fully-shaped EMPTY fetch dict and scan() stamped last_synced_at anyway — a fresh time over an empty panel, the exact failure #194 reported (strictly worse than NULL). fetch_scan_data now derives a `reached` boolean from the version/namespace/API-group preflight (all three share the reach-and-authenticate path; a 401/unreachable fails all three, a genuinely reachable cluster returns at least a version, API groups and built-in namespaces). scan() stamps ONLY when data["reached"] is true. Chose the lighter "stamp only when reached" over adding sync_status/sync_error: the reviewer accepts it, NULL-vs-timestamp already resolves the reporter's never-vs-empty distinction, and a model migration would maximize merge collision with the sibling #202/#203 PRs touching this same area. Tests: test_scan_stamps_last_synced_at now passes a genuinely-empty-but-REACHED fetch (was an empty not-reached dict that encoded the bug); added test_unreachable_scan_does_not_stamp_last_synced_at for the expired-token case (all-empty, reached=false → no stamp). Mutation-checked. Must-fix 2 — silent auto-merge collision with #203. Added "multus_pods" to _EMPTY_FETCH_DATA (mirrors #203's fetch-dict shape) and seeded multus_pods alongside kube_system_pods in the pod-count test so running_pods == 6 survives #203's filter. Verified by applying #203's one-line analyze_multus change locally (data["multus_pods"]) — 5/5 green, no KeyError — then reverted. Verify: 28 passed (inventory-sync + routes-k8s-clusters + cluster-scan-task); 78 passed across running-release / proxy-inventory / scanner-recommendations; ruff clean. No migration, no API surface change. Claude-Session: https://claude.ai/code/session_01UCsZXDxBsWV2s4kT47DwDW
…class follow-ups Address bonnyr-f5's round-1 review on PR #203. m-1 (ranked selection): the pickers preferred exact "multus" then fell back to list order, so on Forge/vanilla clusters — whose installer names the DaemonSet "kube-multus-ds" (modules/bare_metal/install_multus.py) — the exact match never fired and selection was order-dependent again. Rank deterministically instead: exact "multus" > "kube-multus-ds" > sorted-name tiebreak. m-2 (one rule, one implementation): extract a single pick_primary_multus_daemonset(daemonsets) in prereqs.py, used by BOTH fetch._multus_daemonset_namespace and prereqs.analyze_multus. Previously the two had divergent null-handling ((ds.get("name") or "") vs .get("name","").lower(), which raises on name=None; and primary_ds["namespace"] which raises KeyError if absent). Single-sourced on the safe idiom; analyze_multus reads daemonset fields via .get(). Divergence would silently zero running_pods (#202 re-armed), so a test now pins that the two callers agree on a multi-DS multi-namespace fixture. m-3 (comment vs code): the running_pods comment claimed the metric was the broad Multus-networking pod set including multus-additional-cni-plugins. Since the fix only fetches the primary DaemonSet's own namespace, a sibling elsewhere is excluded (multus@openshift-multus 3 + sibling@sib-ns 6 -> 3). Rewrote the comment to describe what the code actually computes. M-1 (class completeness): the hardcoded-namespace pod fetch is 1-of-3; kept this PR scoped and filed the siblings instead of collapsing the picker signature: - #210 cert-manager (severity:medium — HIGHER than #202: it downgrades status to PARTIAL + emits a wrong recommendation; reachable on ROKS ibm-cert-manager / openshift-cert-manager) - #211 kamaji-system (severity:low) A future fix collapses all three into services/bnk_pod_discovery.py's _sweep_all_namespaces / list_pod_for_all_namespaces pattern. Tests: extended test_scanner_multus_namespace.py — ranked pick (kube-multus-ds on Forge/vanilla, order independence, sorted tiebreak, name=None tolerance), fetch+analyze agreement, missing-namespace tolerance, and sibling-in-other-ns exclusion end-to-end. Mutation-verified: exact-match-then-first reds 4 tests; a divergent fetch picker reds 2. ruff + mypy clean on changed modules. Claude-Session: https://claude.ai/code/session_01UCsZXDxBsWV2s4kT47DwDW
Round-1 review addressed @
|
Round-1 response @
|
…e row-lock, align stamp/comment bonnyr-f5 round-2 minors + nits (findings 3-9). Findings 3, 6, 7, 9 — remove the /resync endpoint entirely. It was a zero-caller duplicate of the strictly-better existing POST /scan?force=true (synchronous, UI-wired, an MCP tool, and — thanks to the scan() stamp change — now stamps last_synced_at AND commits it). Removing it moots the swallow-success (3), scan-cache staleness (6), no-throttle (9) and the 403-test nit at once. Deleted resync_cluster (routes/k8s/clusters.py) and its three tests; regenerated openapi.json + api-generated.ts (resync path gone). Finding 4 (real concurrency bug) — scan() now always writes last_synced_at, so its flush leaves an uncommitted UPDATE on kubernetes_clusters. The upgrade health gate loops scan() with 10s/15s sleeps and no commit, so that row lock was held uncommitted across every sleep, blocking a concurrent scan_cluster_async commit for the same cluster. _execute_health_gate now commits each iteration's scan writes (and rolls back a failed iteration) before sleeping, releasing the lock — consistent with the service's ENG-006 commit-per-step convention. Finding 5 — corrected the overstated commit-ownership comment in scanner/__init__.py: the stamp is flushed, not committed, and several callers (get_adaptive_module_plan / _from_scan) never commit, so for those the stamp is rolled back with their read-only session (a missed stamp, never wrong data). Finding 8 — PR body Closes #194 -> Relates to #194; filed follow-ups #212 (honor k8s_sync_enabled/k8s_sync_interval_seconds — no scheduler reader exists) and #213 (auto-rescan after modules reach applied) for the reporter's stood-behind suggestions 3 and 4, which this PR does not implement. Nits — tightened ClusterOperationResponse docstring (now delete-only); aligned last_synced_at to start_time so it matches scan_metadata.scanned_at (one answer to "when was this scanned"; duration stays in scan_metadata.duration_ms). Verify: 211 passed across inventory-sync / routes-k8s-clusters / cluster-scan-task / running-release / proxy-inventory / scanner-recommendations / bnk-upgrade-service; ruff clean; openapi --check OK. Re-confirmed the #203 merge (applied its analyze_multus one-liner locally: 5/5 green, no KeyError; reverted). Claude-Session: https://claude.ai/code/session_01UCsZXDxBsWV2s4kT47DwDW
CI status noteThe code review above stands. The only red CI on this PR is the two repo-wide P4 security gates:
Both are environmental and repo-wide, not caused by this PR's code: the advisory/vuln DBs updated after staging last audited clean on 2026-08-24, so every open PR (and staging itself, if re-run) is red on them. Both are fixed in #215 (a documented All P1/P2/P3 gates are green. Awaiting further review. |
Review —
|
| Finding | Status | Evidence |
|---|---|---|
| M-1 hardcoded-namespace class (1-of-3 fixed) | ✅ resolved via follow-ups | fetch.py still queries the three literal namespaces, but the multus consumer is fixed and #210/#211 track cert-manager + kamaji |
m-1 exact-multus match never fires on kube-multus-ds |
✅ fixed | new shared pick_primary_multus_daemonset (prereqs.py:173-205) ranks multus(0) → kube-multus-ds(1) → sorted tiebreak; covered by order-reversal tests |
| m-2 two divergent pickers, divergent null-handling | ✅ fixed | both fetch.py and analyze_multus now delegate to the one shared helper; agreement pinned by test_fetch_and_analyze_agree_* |
m-4 _fetch_pods_in_ns swallows exceptions with no logging |
fetch.py:281-282 except Exception: return [] unchanged — an RBAC denial still silently reproduces the #202 symptom |
|
dead param analyze_sriov(…, kube_system_pods) |
prereqs.py:222 param unused in the body; still passed at __init__.py:107 |
The new cross-module import (fetch.py → prereqs.py) is cycle-free, and the ["name"]→.get("name") changes are strict null-tolerance improvements. No new regressions.
m-4 and the dead param are non-blocking and outside the M-1 gate — worth a carry-forward (a follow-up commit or ticket), but they don't hold the PR.
Review Assessment
- Verdict: PASS
- Audit SHA:
2677c04d1db2d37b4796bd43e5a23ee0d6dd9a3a - Cold Audit Performed: Yes — closure check in an independent context; cert-manager in a non-default namespace downgrades status to PARTIAL (hardcoded pod-fetch namespace, #202 class) #210/kamaji pod count uses hardcoded kamaji-system namespace (#202 class, low likelihood) #211 state verified against the live issue tracker
- Invariants Verified: INV-10 — fetch and analyze now select the same DaemonSet via one shared picker, so the namespace queried for pods always matches the reported DaemonSet; divergence can no longer silently zero
running_pods - Git & Harness Cleanliness: Clean (PR content)
Findings & Action Items
- Major (Blockers): none
- Minor (Non-blocking, carry-forward):
-
fetch.py:281: swallow-and-return-[]with no logging; add alogger.warningso an RBAC denial is distinguishable from absence -
prereqs.py:222/__init__.py:107: drop the unusedkube_system_podsparam fromanalyze_sriov
-
🤖 Generated with Claude Code
…22.0 stable Trivy's DB now flags CVE-2026-56854 (golang.org/x/crypto/ssh — authentication bypass via unenforced source-address restriction, CRITICAL) in the bundled helm binary (helm 3.20.0 embeds x/crypto v0.46.0). It is the sole CRITICAL blocking the P4 "Docker Build + Scan" gate, which had gone red on every open PR whose scan re-ran after the DB update (staging itself last scanned clean on 2026-08-24 and would now be red too). Fixed in x/crypto 0.55.0, but the only helm release carrying it is v3.22.0-rc.1 (prerelease); latest stable v3.21.4 still ships v0.54.0. We do not pin a prerelease helm in production, so this follows the file's established pattern for un-fixable-yet third-party Go-binary CVEs: a documented, justified, dated suppression. Not exploitable in our context: the flaw is in the SSH *server* auth path (ssh.ServerConfig source-address enforcement); helm/kubectl/tofu/infracost never run an SSH server and no container exposes one (Python SSH uses paramiko) — same rationale as the existing CVE-2024-45337 entry. The real fix (bump to helm 3.22.0 stable + drop this entry) is tracked in #214. Claude-Session: https://claude.ai/code/session_01UCsZXDxBsWV2s4kT47DwDW
pip-audit flags gitpython 3.1.58 for PYSEC-2026-3785/3786/3787/3788, all fixed in 3.1.59 (a patch release). This is the sole finding failing the "P4 · Security Audit" gate (make security-audit) — the npm prod HIGH+ and dev CRITICAL gates both pass. Like the Trivy suppression in this PR, it's a repo-wide gate that went red on every open PR after the advisory DB updated (staging last audited clean on 2026-08-24), not caused by any PR's code. Claude-Session: https://claude.ai/code/session_01UCsZXDxBsWV2s4kT47DwDW
npm audit flags js-yaml 4.3.1 for GHSA-2883-xcg3-v3hh (maxTotalMergeKeys does not limit CPU use for empty merge sources — HIGH), the sole HIGH in the prod-deps gate (`npm audit --omit=dev --audit-level=high`). Patched in 4.3.2, which is inside the existing ^4.3.1 caret — a clean, non-breaking lockfile bump (package-lock diff is js-yaml-only). The remaining DOMPurify (transitive via monaco-editor) and react-router advisories are moderate and do not gate. Completes the P4 Security Audit fix alongside the gitpython bump: pip-audit and both npm gates (prod HIGH+, dev CRITICAL) now pass. Same environmental class as the rest of this PR — the advisory DB moved after staging last audited clean. Claude-Session: https://claude.ai/code/session_01UCsZXDxBsWV2s4kT47DwDW
Summary
ClusterScannerreported 0 running Multus pods on ROKS/OpenShift even when Multus was healthy. This fixes the namespace-scoping so the count reflects reality on any cluster layout.Root cause
The pod fetch (
backend/services/scanner/fetch.py) enumerated pods only from a hardcoded namespace set (cert-manager,kube-system,kamaji-system, + BNK/F5 discovery), andanalyze_multus(prereqs.py) derivedrunning_podsfrom thekube-systemlist alone. On OpenShift/ROKS, Multus runs inopenshift-multus, which was never queried — sorunning_podsread 0. The DaemonSet is still discovered cluster-wide vialist_daemon_set_for_all_namespaces, so status showedDETECTEDwith 0 pods: exactly the reported "0 Multus pods while N running".Fix (DaemonSet-namespace-driven — preferred option)
Fetch the Multus pods from whatever namespace the discovered DaemonSet actually lives in, rather than a hardcoded
kube-system:kube-system) reuses the already-fetched pod list — no extra API call.openshift-multus) and any future layout fetch that namespace.Changes:
fetch.py:_multus_daemonset_namespace+_fetch_multus_pods; new namespace-scopedmultus_podsin the fetch dict.__init__.py: feedanalyze_multusthe scopedmultus_pods.prereqs.py: rename theanalyze_multusparam tomultus_pods; count running pods from it.How the tests exercise the REAL fetch (not a handed list)
tests/unit/test_scanner_multus_namespace.pyruns the actualfetch_scan_datawith the k8s API mocked, not a pre-built pod list:list_daemon_set_for_all_namespacesreports the Multus DaemonSet inopenshift-multus.list_namespaced_pod(namespace=...)returns Multus pods only foropenshift-multus(empty forkube-system) — the exact shape of a real OpenShift cluster.The test asserts the fetch actually queried
openshift-multus(viacall_args_list) and thatrunning_pods == 3. A vanilla-k8s test (Multus inkube-system) still counts (no regression), plus a non-Running-phase guard.Reproduce + verify evidence
_fetch_multus_pods(...)back tomultus_pods = kube_system_podsreds the OpenShift tests —running_pods == 0andopenshift-multusnever appears in the queried namespaces (assert 'openshift-multus' in {'cert-manager', 'kube-system'}fails). The vanilla-k8s test stays green.test_scanner_prereqs,test_scanner_recommendations,test_proxy_inventory,test_running_release_discovery,test_bnk_pod_discovery) = 172 passed.ruff checkclean on all changed files.Not verifiable without a live cluster
The exact Multus DaemonSet/pod naming and the
openshift-multusnamespace on a real ROKS/OpenShift cluster are assumed from the issue report; the logic keys off"multus"in the DaemonSet name and its reported namespace, so it adapts to whatever a real cluster presents.Closes #202