fix: Kubernetes serialises an empty item list as null, not []
ci / lint (push) Successful in 30s
ci / types (push) Successful in 36s
ci / unit (push) Successful in 25s
ci / security (push) Successful in 39s
ci / dockerfile (push) Successful in 6s
ci / chart (push) Successful in 8s
ci / integration (push) Successful in 39s
ci / image (api) (push) Successful in 1m43s
ci / image (reconciler) (push) Successful in 2m7s
ci / image (worker) (push) Successful in 2m0s
ci / bump (push) Successful in 21s
ci / lint (push) Successful in 30s
ci / types (push) Successful in 36s
ci / unit (push) Successful in 25s
ci / security (push) Successful in 39s
ci / dockerfile (push) Successful in 6s
ci / chart (push) Successful in 8s
ci / integration (push) Successful in 39s
ci / image (api) (push) Successful in 1m43s
ci / image (reconciler) (push) Successful in 2m7s
ci / image (worker) (push) Successful in 2m0s
ci / bump (push) Successful in 21s
The drift check failed on every tick with:
TypeError: 'NoneType' object is not iterable
`resp.json().get("items", [])` cannot defend against this. The key IS present,
with the value null, so the default never fires. An empty PartialObjectMetadataList
comes back as `"items": null`, which is exactly what happens once the release
selector matches nothing — the normal state of a cluster with no tenant
releases provisioned yet.
`or []` handles both shapes.
The tests could not have caught this: all of them passed a real empty list,
which is the one shape that works. The new test sends `"items": null` as the
API server actually sends it, and is control-tested — restoring the old
expression fails it with the same TypeError seen in production.
Worth recording why this survived review. The API read was verified against a
live cluster before shipping, but that cluster had 25 matching release secrets,
so the empty case never ran. A measurement on real data proved the fast path
worked and said nothing about the path taken when there is no data.
This commit is contained in:
@@ -228,3 +228,42 @@ async def test_no_service_account_falls_back_to_helm(monkeypatch: pytest.MonkeyP
|
||||
await HelmProvisioner(kubeconfig=Path("/dev/null")).list_releases()
|
||||
|
||||
assert seen and seen[0][:2] == ["helm", "list"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_read_handles_kubernetes_serialising_empty_as_null(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""`"items": null`, not `[]`, is what an empty list looks like on the wire.
|
||||
|
||||
The key is present, so `.get("items", [])` returns None and the default never fires.
|
||||
This shipped and failed in production on the first tick that matched no releases:
|
||||
TypeError: 'NoneType' object is not iterable. The earlier tests all passed a real
|
||||
empty list, which is the one shape that cannot catch it.
|
||||
"""
|
||||
cap = _fake_api(monkeypatch, tmp_path, [])
|
||||
cap["force_null_items"] = True
|
||||
|
||||
class _NullResponse:
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> dict[str, object]:
|
||||
return {"apiVersion": "meta.k8s.io/v1", "kind": "PartialObjectMetadataList", "items": None}
|
||||
|
||||
class _NullClient:
|
||||
def __init__(self, **kw: object) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> _NullClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> None:
|
||||
return None
|
||||
|
||||
async def get(self, url: str, **kw: object) -> _NullResponse:
|
||||
return _NullResponse()
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _NullClient)
|
||||
|
||||
assert await HelmProvisioner().list_releases() == []
|
||||
|
||||
Reference in New Issue
Block a user