fix(#194): enqueue cluster inventory sync on registration and make a resync reliably triggerable - #200
fix(#194): enqueue cluster inventory sync on registration and make a resync reliably triggerable#200jgruberf5 wants to merge 6 commits into
Conversation
…t resync trigger
Cluster inventory never appeared to sync: last_synced_at stayed null forever
and a no-op PUT looked like it did nothing.
Root cause (defect 1): ClusterScanner.scan() never wrote
KubernetesCluster.last_synced_at. Every registration path (POST create, the
roks/ibm and container/opentofu/ssh auto-registration tasks) already enqueues
scan_cluster_async, and the async task runs the scan and commits -- but the
scan itself only persisted capabilities, discovered namespaces and the running
release, never a sync timestamp. So "never scanned" and "scanned and genuinely
empty" were indistinguishable from the API, and every no-op PUT (which does
enqueue a scan) left last_synced_at null. The scan now stamps last_synced_at
at the end of scan(), after all analysis has completed, so a scan that raises
early does not falsely record a sync. Because all scan paths funnel through
this one method, the fix covers registration, PUT, the /scan endpoint and
upgrade pre-checks.
Defect 2 (reliable resync trigger): relying on a no-op PUT to force a refresh
was undocumented and easy to get wrong. Added POST
/api/k8s/clusters/{id}/resync (owner/admin), which validates the cluster
exists (clean 404) and enqueues the same background scan, returning
immediately. The PUT path already enqueues a scan unconditionally; a test now
locks that a no-op PUT still triggers a rescan.
Tests (mocked K8s client; mutation-checked):
- scan stamps last_synced_at on completion and it persists across the async
task's commit; a scan that fails before completion does NOT stamp it.
- a populated fetch surfaces pod inventory -- 6 running Multus pods read as 6
and DETECTED, not 0 (the reported symptom).
- registration (POST create) enqueues the initial sync.
- a no-op PUT enqueues a rescan; the resync endpoint enqueues a scan, 404s an
unknown cluster, and is denied to viewers.
Not changed (out of scope, noted for follow-up): honouring
k8s_sync_enabled / k8s_sync_interval_seconds for periodic resync, and an
automatic re-scan after a project's modules reach applied.
Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
…dpoint
The fix added POST /api/k8s/clusters/{cluster_id}/resync but didn't refresh the
committed backend/openapi.json (openapi-check) or frontend-v2 TS types
(typecheck-frontend). Regenerated both via generate-openapi.py + openapi-typescript
7.13.0 so both CI freshness gates pass.
Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
… 404 (self-review) Self-review (MAJOR): TestPodInventoryPopulated claimed to resolve the reporter's '0 Multus pods while 18 running' ground truth, but the real fetch reads only kube-system while OpenShift's Multus lives in openshift-multus (never queried) -- so stamping last_synced_at records a fresh time over a still-0 count. The test hand-built kube_system_pods while labelling the DaemonSet openshift-multus, proving only that analyze_multus counts a handed list. Reframed the test + docstrings to lock what the fix actually does (count + stamp over a fetched namespace) and to NOT claim the OpenShift symptom is fixed; filed the pre-existing namespace-scoping gap as #202. Self-review (MINOR): removed the redundant get_cluster_details() existence check in the resync route -- require_cluster_owner already 404s a missing cluster before the body runs (test_resync_unknown_cluster_404 still green via the dependency). Verified: 22 passed (inventory-sync + routes); ruff clean. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
Self-review (cold, adversarial) — the
|
…tring edit
The self-review fix reworded the resync route's docstring; FastAPI embeds the
docstring as the endpoint `description` in openapi.json (and it flows into the
generated TS types), so the committed spec went stale on that one field
("Schema definitions changed but names same"). Regenerated both with the exact
requirements.txt deps CI uses.
Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
Review discipline pass — verdict: BLOCK (two narrow conditions)Three independent cold audits (clean context, no prior review threads) plus an invariant sweep. Everything below verified by execution. Reviewed at To be clear up front: the one-line scanner change is the right fix at the right altitude. Putting the stamp inside Verified green
Must fix 1 — the stamp fires on a scan that fetched nothing, which defeats the reporter's stated requirementThe comment at Every fetcher swallows its exception and returns an empty default — This PR's own test demonstrates it. Failure scenario: cluster 16's bearer token expires. Every call 401s, all swallowed, analysis reports "not detected / 0 pods", the stamp is written, This matters more than a normal severity call, because of what the reporter asked for:
And the same comment notes registration deliberately precedes
Fix shape: derive a success signal from Must fix 2 — INV-4: silent merge collision with open PR #203PR #203 changes the - data["crds"], data["crd_names"], data["kube_system_pods"], data["daemonsets"]
+ data["crds"], data["crd_names"], data["multus_pods"], data["daemonsets"]This PR's new I applied #203's change and ran this PR's tests. Measured result: 3 of 4 fail with Not a one-key fix. #203 updated all five pre-existing fixtures; it simply could not see a file this PR had not created yet. This needs coordination on merge order, not a unilateral fix. Minor3. The docstring asserts "there is no silently-swallowed background no-op" ( 4. The upgrade health gate now holds an uncommitted row write across its whole window. 5. The comment overstates its own reach. 6. 7. 8. Scope against the issue — worth resolving before "Closes #194". In the follow-up above the reporter also retracted the framing this PR tests:
9. No dedup, cooldown, or queue guard on the new async trigger. Nits
Reported separatelyOne pre-existing issue outside this diff's scope was surfaced by the sweep — it concerns an unscoped, low-privilege-reachable path that this change amplifies from a read into a write. It affects released versions, so the details are being routed privately rather than posted here. Verdict: BLOCK, on must-fix 1 and 2 only. Must-fix 2 is coordination with #203. Must-fix 1 is a genuine design correction, but a small one, and the stamp's placement inside |
…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
…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
Round-2 minors also addressed @
|
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. |
Summary
A registered ROKS/OpenShift cluster showed no pod inventory on the Kubernetes page and
last_synced_atwas never set, even after 70+ minutes and repeated no-opPUTs meant to force a rescan. Capability detection worked; only the "when was this synced" signal was missing.Root cause
The sync ran but was never recorded. Every registration path already enqueues
scan_cluster_async(the POST create route, and the roks/ibm, container, opentofu and ssh auto-registration tasks all callenqueue_cluster_scan), and the async task runsClusterScanner.scan()and commits. Butscan()only persisted capabilities, discovered namespaces and the running release — it never wroteKubernetesCluster.last_synced_at. So "never scanned" and "scanned and genuinely empty" were indistinguishable from the API, and every no-op PUT (which does enqueue a scan) still leftlast_synced_atnull. Nothing anywhere in the codebase ever assignedKubernetesCluster.last_synced_at.Fix:
scan()now stampslast_synced_atat the very end, after all analysis has completed (so a scan that raises early does not falsely record a sync), and only when the scan genuinely reached the cluster's API server. Because all scan paths (registration/PUT async task, the/scanendpoint, the upgrade health gate) funnel through this one method, the fix covers them all.Reached-signal (only stamp on a scan that actually contacted the cluster)
Every fetcher in
scanner/fetch.pyswallows its exception and returns an empty default (frozenset()/[]/None), andload_kubeconfignever contacts the API server — so an expired-token or unreachable cluster produced a fully-shaped empty dict that was indistinguishable, key-by-key, from a reachable-but-empty cluster. Stamping there would write a fresh sync time over a panel with no data — strictly worse than the NULL that honestly says "we have never gotten data from this cluster."fetch_scan_datanow derives areachedboolean from the three preflight-class signals that all traverse the same reach-and-authenticate path — the/apisgroup discovery, the/versioncall, and the namespace list. A 401 / connection failure fails all three; a genuinely reachable cluster (even an empty one) always returns at least a version, registered API groups and the built-in namespaces.scan()stampslast_synced_atonly whenreachedis true. A genuinely-empty-but-reachable cluster still stamps; an unreachable / 401 one does not and stays NULL.The stamp records
start_time— the same instant surfaced asscan_metadata.scanned_at— so "when was this scanned" has a single answer across the DB stamp and the result payload.Rescan trigger
There is no new endpoint:
POST /api/k8s/clusters/{id}/scan?force=truealready exists, is UI-wired and exposed as an MCP tool, runs synchronously, returns the actual scan results, refreshes the scan cache, and — thanks to this PR'sscan()change — now stampslast_synced_atand commits it. An earlier revision of this PR added aPOST .../resyncendpoint; it was removed as a zero-caller duplicate of the strictly-better/scan?force=true(per review).Concurrency: no long-held row lock in the upgrade health gate
scan()now always writeslast_synced_at, so its flush leaves anUPDATE kubernetes_clusterspending. The upgrade health gate (_execute_health_gate) loopsscan()with 10s/15s sleeps between iterations, so that row lock would otherwise be held uncommitted across every sleep, blocking a concurrentscan_cluster_asynccommit for the same cluster. The 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 after every step / health snapshot" convention.What the tests lock (mocked K8s client, mutation-checked)
last_synced_at, and it persists across the async task's commit; a scan that fails before completion does not stamp it.reached=false) does not stamp — the timestamp-over-empty-panel failure this issue reported.analyze_multuscounts the Multus pods the fetch surfaced (6 → 6,DETECTED) over a namespace the scan actually reads; the fixture mirrors fix(#202): count Multus pods in the DaemonSet's namespace so OpenShift (openshift-multus) reports correctly #203's fetch-dict shape so the eventual merge stays clean.Files changed
backend/services/scanner/fetch.py— derive and return thereachedsignal.backend/services/scanner/__init__.py— stamplast_synced_atonly when reached (start_time); corrected the commit-ownership comment.backend/services/bnk_upgrade_execution_service.py— commit/rollback each health-gate scan iteration so no row lock is held across the sleeps.backend/schemas/k8s.py— tightenedClusterOperationResponsedocstring (delete-only).backend/tests/component/test_cluster_inventory_sync.py— reached/not-reached + pod-inventory tests.backend/tests/integration/test_routes_k8s_clusters.py— registration/PUT route tests.backend/openapi.json,frontend-v2/src/types/api-generated.ts— regenerated (resync endpoint removed).Out of scope (follow-up issues filed)
Honouring
k8s_sync_enabled/k8s_sync_interval_secondsfor periodic resync (#212) and an automatic re-scan after a project's modules reachapplied(#213) — the reporter's stood-behind suggestions 3 and 4. This PR closes the "never synced" defect only, so it does not auto-close the issue.Relates to #194
https://claude.ai/code/session_01UCsZXDxBsWV2s4kT47DwDW