From 8353beafa44ce97b7560ffb6fdb99070072f801e Mon Sep 17 00:00:00 2001 From: John Gruber Date: Mon, 24 Aug 2026 06:53:30 -0500 Subject: [PATCH 1/4] fix(#194): stamp last_synced_at on scan completion and add an explicit 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 --- backend/routes/k8s/clusters.py | 22 +++ backend/services/scanner/__init__.py | 10 ++ .../component/test_cluster_inventory_sync.py | 159 ++++++++++++++++++ .../integration/test_routes_k8s_clusters.py | 77 +++++++++ 4 files changed, 268 insertions(+) create mode 100644 backend/tests/component/test_cluster_inventory_sync.py diff --git a/backend/routes/k8s/clusters.py b/backend/routes/k8s/clusters.py index 3211514..8a1b5a6 100644 --- a/backend/routes/k8s/clusters.py +++ b/backend/routes/k8s/clusters.py @@ -113,6 +113,28 @@ def update_cluster(cluster_id: int, cluster_data: ClusterUpdateRequest, user: Us return result +@router.post("/k8s/clusters/{cluster_id}/resync", response_model=ClusterOperationResponse) +@handle_route_errors("resync cluster") +def resync_cluster(cluster_id: int, user: User = Depends(require_cluster_owner), db: Session = Depends(get_db)): + """Force a fresh inventory sync for a cluster (owner or admin only). + + Issue #194: an explicit, documented rescan trigger. Operators previously + relied on a no-op ``PUT`` to force a refresh; this endpoint makes that + intent first-class and reliable. It enqueues a background scan (the same + task registration/PUT use) and returns immediately — the scan stamps + ``last_synced_at`` on completion. 404s if the cluster does not exist. + """ + # Validate existence so a resync of an unknown cluster is a clean 404 + # rather than a silently-swallowed background no-op. + ClusterManagementService(db).get_cluster_details(cluster_id) + enqueue_cluster_scan(cluster_id) + return { + "success": True, + "message": "Inventory sync enqueued", + "cluster_id": cluster_id, + } + + @router.delete("/k8s/clusters/{cluster_id}", response_model=ClusterOperationResponse) @handle_route_errors("delete cluster") def delete_cluster(cluster_id: int, user: User = Depends(require_cluster_owner), db: Session = Depends(get_db)): diff --git a/backend/services/scanner/__init__.py b/backend/services/scanner/__init__.py index e65082d..04ccbe5 100644 --- a/backend/services/scanner/__init__.py +++ b/backend/services/scanner/__init__.py @@ -227,6 +227,16 @@ def scan(self, cluster_id: int) -> dict[str, Any]: end_time = datetime.now(UTC) duration_ms = int((end_time - start_time).total_seconds() * 1000) + # Issue #194: record when this cluster was last successfully scanned so + # "never scanned" (last_synced_at IS NULL) is distinguishable from + # "scanned and genuinely empty". Set only after all analysis has + # completed — a scan that raises earlier must NOT stamp a sync time. + # Every scan path (registration/PUT async task, the /scan endpoint, + # upgrade pre-checks) flows through here, so this is the single place + # that keeps last_synced_at honest. Flushed here; the caller commits. + cluster.last_synced_at = end_time + self.db.flush() + return { "cluster_id": cluster_id, "cluster_name": cluster.name, diff --git a/backend/tests/component/test_cluster_inventory_sync.py b/backend/tests/component/test_cluster_inventory_sync.py new file mode 100644 index 0000000..6782174 --- /dev/null +++ b/backend/tests/component/test_cluster_inventory_sync.py @@ -0,0 +1,159 @@ +""" +Issue #194: Cluster inventory sync — last_synced_at + pod inventory. + +Two defects are locked here: + +1. ClusterScanner.scan() never wrote ``last_synced_at``, so a registered + cluster stayed "never synced" forever (NULL) even after the scan ran and + after repeated no-op PUTs. These tests prove the scan now stamps + ``last_synced_at`` on completion, that the stamp is only written on success + (a scan that raises early must NOT stamp), and that it persists across a + commit (the async registration/PUT task path). + +2. A populated fetch surfaces pod inventory — the issue reported 0 Multus pods + on a cluster where Multus was running. With real analysis over a populated + fetch, the scan reports the running pods, not 0. +""" + +import contextlib +from datetime import datetime +from unittest.mock import MagicMock, patch + +_EMPTY_FETCH_DATA = { + "version_info": {}, "nodes": [], "namespaces": [], "crds": [], + "crd_names": set(), "crd_groups": set(), "cert_manager_pods": [], + "helm_releases": [], "kube_system_pods": [], "daemonsets": [], + "storage_classes": [], "gateways": [], "gatewayclasses": [], + "f5_tenant_pods": [], "f5_utils_pods": [], "dpf_operator_configs": [], + "dpudevices": [], "dpusets": [], "dpuclusters": [], "dpuservices": [], + "bfbs": [], "kamaji_pods": [], "kamaji_tcps": [], "cis_controllers": [], + "cis_virtualservers": [], "cis_transportservers": [], "cis_ingresslinks": [], + "cis_as3_configmaps": [], "cis_f5_ingresses": [], "openshift_routes": [], + "cneinstances": [], "vlans": [], +} + +# Analysis functions patched to no-ops when a test isolates one code path. +_ANALYZERS = [ + "services.scanner.analyze_cluster_info", + "services.scanner.analyze_cert_manager", + "services.scanner.analyze_multus", + "services.scanner.analyze_sriov", + "services.scanner.analyze_hugepages", + "services.scanner.analyze_storage", + "services.scanner.analyze_gateway_api", + "services.scanner.analyze_dpf", + "services.scanner.analyze_kamaji", + "services.scanner.analyze_cis", + "services.scanner.analyze_bnk_install", +] + + +def _run_scan(db, cluster, *, fetch_data=None, skip_analyzers=(), fetch_side_effect=None): + """Run ClusterScanner.scan() with I/O and (optionally) analyzers mocked. + + ``skip_analyzers`` names analyzers to leave REAL so a test can assert on + their output; the rest are patched to return ``{}``. ``fetch_side_effect`` + (e.g. an exception) simulates a scan that fails before completion. + """ + from services.scanner import ClusterScanner + + scanner = ClusterScanner(db) + platform_ctx = MagicMock() + platform_ctx.to_dict.return_value = {} + platform_ctx.detected_platform_profile = "roks" + + with contextlib.ExitStack() as stack: + stack.enter_context(patch.object(scanner.k8s_service, "get_cluster", return_value=cluster)) + stack.enter_context(patch.object(scanner.k8s_service, "load_kubeconfig", return_value=MagicMock())) + if fetch_side_effect is not None: + stack.enter_context(patch("services.scanner.fetch_scan_data", side_effect=fetch_side_effect)) + else: + stack.enter_context(patch( + "services.scanner.fetch_scan_data", + return_value=fetch_data if fetch_data is not None else dict(_EMPTY_FETCH_DATA), + )) + stack.enter_context(patch( + "services.scanner.PlatformContextService.apply_cluster_context", + return_value=platform_ctx, + )) + for name in _ANALYZERS: + if name in skip_analyzers: + continue + stack.enter_context(patch(name, return_value={})) + stack.enter_context(patch("services.scanner.build_recommendations", return_value=[])) + stack.enter_context(patch("services.scanner.build_proxy_recommendations", return_value=[])) + return scanner.scan(cluster.id) + + +class TestLastSyncedAtStamp: + def test_scan_stamps_last_synced_at(self, db, make_k8s_cluster): + """A completed scan sets last_synced_at (was permanently NULL — #194).""" + cluster = make_k8s_cluster() + assert cluster.last_synced_at is None # never scanned + + result = _run_scan(db, cluster) + + db.refresh(cluster) + assert isinstance(cluster.last_synced_at, datetime) + # Result metadata still reports the scan timing. + assert "scanned_at" in result["scan_metadata"] + + def test_last_synced_at_persists_across_commit(self, db, make_k8s_cluster): + """The stamp survives the commit the async registration/PUT task does.""" + cluster = make_k8s_cluster() + _run_scan(db, cluster) + db.commit() # mirrors scan_cluster_async's own commit + + db.expire_all() + reloaded = db.query(type(cluster)).filter_by(id=cluster.id).one() + assert reloaded.last_synced_at is not None + + def test_failed_scan_does_not_stamp_last_synced_at(self, db, make_k8s_cluster): + """A scan that raises before completion must NOT stamp last_synced_at. + + Mutation guard: moving the stamp above the analysis (or dropping the + 'only on success' property) would let a failed scan look synced. + """ + cluster = make_k8s_cluster() + assert cluster.last_synced_at is None + + import pytest + with pytest.raises(RuntimeError, match="cluster unreachable"): + _run_scan(db, cluster, fetch_side_effect=RuntimeError("cluster unreachable")) + + db.refresh(cluster) + assert cluster.last_synced_at is None # still never-synced + + +class TestPodInventoryPopulated: + def test_multus_pods_are_counted_not_zero(self, db, make_k8s_cluster): + """Issue #194 ground truth: 18 Multus pods running must not read as 0. + + Real analyze_multus over a populated fetch reports the running pods and + DETECTED status — the opposite of the reported bug. + """ + cluster = make_k8s_cluster() + + fetch = dict(_EMPTY_FETCH_DATA) + fetch["crd_names"] = {"network-attachment-definitions.k8s.cni.cncf.io"} + fetch["daemonsets"] = [ + {"name": "multus", "namespace": "openshift-multus", "desired": 6, "ready": 6}, + ] + fetch["kube_system_pods"] = [ + {"name": f"multus-{i}", "phase": "Running"} for i in range(6) + ] + + from services.scanner.constants import PrerequisiteStatus + + result = _run_scan( + db, cluster, fetch_data=fetch, + skip_analyzers=("services.scanner.analyze_multus",), + ) + + multus = result["prerequisites"]["multus"] + assert multus["running_pods"] == 6 + assert multus["status"] == PrerequisiteStatus.DETECTED + assert multus["nad_crd_installed"] is True + # And the scan is recorded, so "empty" vs "never scanned" is decidable. + db.refresh(cluster) + assert cluster.last_synced_at is not None diff --git a/backend/tests/integration/test_routes_k8s_clusters.py b/backend/tests/integration/test_routes_k8s_clusters.py index 0623b9f..b019013 100644 --- a/backend/tests/integration/test_routes_k8s_clusters.py +++ b/backend/tests/integration/test_routes_k8s_clusters.py @@ -45,6 +45,27 @@ def test_create_cluster(self, mock_svc_cls, client, admin_headers, sample_user, assert data["cloud_provider"] == "aws" mock_svc.create_cluster.assert_called_once() + @patch("routes.k8s.clusters.enqueue_cluster_scan") + @patch("routes.k8s.clusters.ClusterManagementService") + def test_registration_enqueues_initial_scan(self, mock_svc_cls, mock_enqueue, client, admin_headers, + sample_user, sample_project): + """Issue #194 defect 1: registering a cluster enqueues the first inventory sync. + + This is the path a roks/ibm register hits — the initial sync must be + enqueued so last_synced_at can be stamped when it completes. + """ + mock_svc = MagicMock() + mock_svc.create_cluster.return_value = {"id": 16, "name": "f5e2e1", "cloud_provider": "ibm"} + mock_svc_cls.return_value = mock_svc + + response = client.post( + f"/api/projects/{sample_project.id}/k8s/clusters", + json={"name": "f5e2e1", "kubeconfig": "YXBpVmVyc2lvbjogdjEK", "cloud_provider": "ibm"}, + headers=admin_headers, + ) + assert response.status_code == 200 + mock_enqueue.assert_called_once_with(16) + @patch("routes.k8s.clusters.ClusterManagementService") def test_create_cluster_operator_allowed(self, mock_svc_cls, client, operator_headers, all_test_users, sample_project): """Operator can create clusters.""" @@ -175,6 +196,62 @@ def test_viewer_cannot_update(self, client, viewer_headers, all_test_users, samp ) assert response.status_code == 403 + @patch("routes.k8s.clusters.enqueue_cluster_scan") + @patch("routes.k8s.clusters.ClusterManagementService") + def test_noop_put_enqueues_rescan(self, mock_svc_cls, mock_enqueue, client, admin_headers, + sample_user, sample_project, make_k8s_cluster): + """Issue #194 defect 2: a no-op PUT (empty body) still enqueues a rescan. + + Operators use a no-op PUT to force a refresh; the route must enqueue a + scan regardless of whether any field actually changed. + """ + cluster = make_k8s_cluster(project=sample_project, name="noop-put") + mock_svc = MagicMock() + mock_svc.update_cluster.return_value = {"id": cluster.id, "name": "noop-put"} + mock_svc_cls.return_value = mock_svc + + response = client.put( + f"/api/k8s/clusters/{cluster.id}", json={}, headers=admin_headers + ) + assert response.status_code == 200 + mock_enqueue.assert_called_once_with(cluster.id) + + +class TestClusterResync: + """POST /api/k8s/clusters/{id}/resync — explicit inventory-sync trigger (#194).""" + + @patch("routes.k8s.clusters.enqueue_cluster_scan") + @patch("routes.k8s.clusters.ClusterManagementService") + def test_resync_enqueues_scan(self, mock_svc_cls, mock_enqueue, client, admin_headers, + sample_user, sample_project, make_k8s_cluster): + """Admin can force a resync; the endpoint enqueues a background scan.""" + cluster = make_k8s_cluster(project=sample_project, name="resync-me") + mock_svc = MagicMock() + mock_svc.get_cluster_details.return_value = {"id": cluster.id} + mock_svc_cls.return_value = mock_svc + + response = client.post(f"/api/k8s/clusters/{cluster.id}/resync", headers=admin_headers) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["cluster_id"] == cluster.id + mock_enqueue.assert_called_once_with(cluster.id) + + @patch("routes.k8s.clusters.enqueue_cluster_scan") + def test_resync_unknown_cluster_404(self, mock_enqueue, client, admin_headers, sample_user): + """Resync of an unknown cluster is a clean 404 — no scan enqueued.""" + response = client.post("/api/k8s/clusters/99999/resync", headers=admin_headers) + assert response.status_code == 404 + mock_enqueue.assert_not_called() + + def test_viewer_cannot_resync(self, client, viewer_headers, all_test_users, + sample_project, make_k8s_cluster): + """Viewer cannot trigger a resync — returns 403.""" + cluster = make_k8s_cluster(project=sample_project) + response = client.post(f"/api/k8s/clusters/{cluster.id}/resync", headers=viewer_headers) + assert response.status_code == 403 + class TestClusterDelete: """DELETE /api/k8s/clusters/{id}.""" From 65db98e84ef5dcc1d71e7bc24f061b0cbd9971da Mon Sep 17 00:00:00 2001 From: John Gruber Date: Mon, 24 Aug 2026 07:05:42 -0500 Subject: [PATCH 2/4] chore(#194): regenerate OpenAPI spec + TS types for the new resync endpoint 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 --- backend/openapi.json | 43 +++++++++++++++++++ frontend-v2/src/types/api-generated.ts | 57 ++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/backend/openapi.json b/backend/openapi.json index 8ebd672..16c269c 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -1299,6 +1299,49 @@ } } }, + "/api/k8s/clusters/{cluster_id}/resync": { + "post": { + "tags": [ + "k8s-clusters" + ], + "summary": "Resync Cluster", + "description": "Force a fresh inventory sync for a cluster (owner or admin only).\n\nIssue #194: an explicit, documented rescan trigger. Operators previously\nrelied on a no-op ``PUT`` to force a refresh; this endpoint makes that\nintent first-class and reliable. It enqueues a background scan (the same\ntask registration/PUT use) and returns immediately \u2014 the scan stamps\n``last_synced_at`` on completion. 404s if the cluster does not exist.", + "operationId": "resync_cluster_api_k8s_clusters__cluster_id__resync_post", + "parameters": [ + { + "name": "cluster_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Cluster Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClusterOperationResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/k8s/clusters/{cluster_id}/refresh-kubeconfig": { "post": { "tags": [ diff --git a/frontend-v2/src/types/api-generated.ts b/frontend-v2/src/types/api-generated.ts index 44a03e8..f3b72aa 100644 --- a/frontend-v2/src/types/api-generated.ts +++ b/frontend-v2/src/types/api-generated.ts @@ -574,6 +574,32 @@ export interface paths { patch?: never; trace?: never; }; + "/api/k8s/clusters/{cluster_id}/resync": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Resync Cluster + * @description Force a fresh inventory sync for a cluster (owner or admin only). + * + * Issue #194: an explicit, documented rescan trigger. Operators previously + * relied on a no-op ``PUT`` to force a refresh; this endpoint makes that + * intent first-class and reliable. It enqueues a background scan (the same + * task registration/PUT use) and returns immediately — the scan stamps + * ``last_synced_at`` on completion. 404s if the cluster does not exist. + */ + post: operations["resync_cluster_api_k8s_clusters__cluster_id__resync_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/k8s/clusters/{cluster_id}/refresh-kubeconfig": { parameters: { query?: never; @@ -24070,6 +24096,37 @@ export interface operations { }; }; }; + resync_cluster_api_k8s_clusters__cluster_id__resync_post: { + parameters: { + query?: never; + header?: never; + path: { + cluster_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ClusterOperationResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; refresh_cluster_kubeconfig_api_k8s_clusters__cluster_id__refresh_kubeconfig_post: { parameters: { query?: never; From cb45656def4d9b112f54770de1fce0dd047f7e94 Mon Sep 17 00:00:00 2001 From: John Gruber Date: Mon, 24 Aug 2026 07:15:11 -0500 Subject: [PATCH 3/4] fix(#194): correct the misleading pod-inventory test + drop redundant 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 --- backend/routes/k8s/clusters.py | 7 +++---- .../component/test_cluster_inventory_sync.py | 21 ++++++++++++------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/backend/routes/k8s/clusters.py b/backend/routes/k8s/clusters.py index 8a1b5a6..8527159 100644 --- a/backend/routes/k8s/clusters.py +++ b/backend/routes/k8s/clusters.py @@ -122,11 +122,10 @@ def resync_cluster(cluster_id: int, user: User = Depends(require_cluster_owner), relied on a no-op ``PUT`` to force a refresh; this endpoint makes that intent first-class and reliable. It enqueues a background scan (the same task registration/PUT use) and returns immediately — the scan stamps - ``last_synced_at`` on completion. 404s if the cluster does not exist. + ``last_synced_at`` on completion. An unknown cluster 404s via the + ``require_cluster_owner`` dependency before this body runs, so there is no + silently-swallowed background no-op. """ - # Validate existence so a resync of an unknown cluster is a clean 404 - # rather than a silently-swallowed background no-op. - ClusterManagementService(db).get_cluster_details(cluster_id) enqueue_cluster_scan(cluster_id) return { "success": True, diff --git a/backend/tests/component/test_cluster_inventory_sync.py b/backend/tests/component/test_cluster_inventory_sync.py index 6782174..db2fe41 100644 --- a/backend/tests/component/test_cluster_inventory_sync.py +++ b/backend/tests/component/test_cluster_inventory_sync.py @@ -10,9 +10,13 @@ (a scan that raises early must NOT stamp), and that it persists across a commit (the async registration/PUT task path). -2. A populated fetch surfaces pod inventory — the issue reported 0 Multus pods - on a cluster where Multus was running. With real analysis over a populated - fetch, the scan reports the running pods, not 0. +2. Over a fetch that surfaces Multus pods in a namespace the scan actually reads + (kube-system), real analyze_multus counts them (not 0) and the scan records + ``last_synced_at`` — so "genuinely empty" is decidable from "never scanned". + NOTE: the reporter's own "0 Multus pods on OpenShift" was a SEPARATE, + pre-existing namespace-scoping gap — Multus runs in ``openshift-multus``, + which the pod fetch never queries (tracked in #202) — retracted by the + reporter; this PR does not fix or claim to fix that symptom. """ import contextlib @@ -127,17 +131,20 @@ def test_failed_scan_does_not_stamp_last_synced_at(self, db, make_k8s_cluster): class TestPodInventoryPopulated: def test_multus_pods_are_counted_not_zero(self, db, make_k8s_cluster): - """Issue #194 ground truth: 18 Multus pods running must not read as 0. + """analyze_multus counts Multus pods the fetch surfaced, and the scan stamps. - Real analyze_multus over a populated fetch reports the running pods and - DETECTED status — the opposite of the reported bug. + Locks the analysis + ``last_synced_at`` behaviour: over a fetch whose + Multus pods sit in kube-system (the namespace the scan actually reads), + the running count is reported, not 0. This is NOT a proof of the + reporter's OpenShift "0 pods" symptom, which is a separate + namespace-scoping gap (#202: Multus lives in openshift-multus, unfetched). """ cluster = make_k8s_cluster() fetch = dict(_EMPTY_FETCH_DATA) fetch["crd_names"] = {"network-attachment-definitions.k8s.cni.cncf.io"} fetch["daemonsets"] = [ - {"name": "multus", "namespace": "openshift-multus", "desired": 6, "ready": 6}, + {"name": "multus", "namespace": "kube-system", "desired": 6, "ready": 6}, ] fetch["kube_system_pods"] = [ {"name": f"multus-{i}", "phase": "Running"} for i in range(6) From 9728fc52df5e4d07bb577ccfc3aa3e32a190986c Mon Sep 17 00:00:00 2001 From: John Gruber Date: Mon, 24 Aug 2026 07:48:46 -0500 Subject: [PATCH 4/4] chore(#194): regenerate OpenAPI spec + TS types after the resync docstring 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 --- backend/openapi.json | 2 +- frontend-v2/src/types/api-generated.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/openapi.json b/backend/openapi.json index 16c269c..232b2dc 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -1305,7 +1305,7 @@ "k8s-clusters" ], "summary": "Resync Cluster", - "description": "Force a fresh inventory sync for a cluster (owner or admin only).\n\nIssue #194: an explicit, documented rescan trigger. Operators previously\nrelied on a no-op ``PUT`` to force a refresh; this endpoint makes that\nintent first-class and reliable. It enqueues a background scan (the same\ntask registration/PUT use) and returns immediately \u2014 the scan stamps\n``last_synced_at`` on completion. 404s if the cluster does not exist.", + "description": "Force a fresh inventory sync for a cluster (owner or admin only).\n\nIssue #194: an explicit, documented rescan trigger. Operators previously\nrelied on a no-op ``PUT`` to force a refresh; this endpoint makes that\nintent first-class and reliable. It enqueues a background scan (the same\ntask registration/PUT use) and returns immediately \u2014 the scan stamps\n``last_synced_at`` on completion. An unknown cluster 404s via the\n``require_cluster_owner`` dependency before this body runs, so there is no\nsilently-swallowed background no-op.", "operationId": "resync_cluster_api_k8s_clusters__cluster_id__resync_post", "parameters": [ { diff --git a/frontend-v2/src/types/api-generated.ts b/frontend-v2/src/types/api-generated.ts index f3b72aa..0a3b72b 100644 --- a/frontend-v2/src/types/api-generated.ts +++ b/frontend-v2/src/types/api-generated.ts @@ -591,7 +591,9 @@ export interface paths { * relied on a no-op ``PUT`` to force a refresh; this endpoint makes that * intent first-class and reliable. It enqueues a background scan (the same * task registration/PUT use) and returns immediately — the scan stamps - * ``last_synced_at`` on completion. 404s if the cluster does not exist. + * ``last_synced_at`` on completion. An unknown cluster 404s via the + * ``require_cluster_owner`` dependency before this body runs, so there is no + * silently-swallowed background no-op. */ post: operations["resync_cluster_api_k8s_clusters__cluster_id__resync_post"]; delete?: never;