diff --git a/psi/setup.py b/psi/setup.py index b13b8be..c7f9ec3 100644 --- a/psi/setup.py +++ b/psi/setup.py @@ -60,10 +60,12 @@ def run_setup( else: logger.warning("Unknown provider '{}', skipping", workload.provider) - if cache is not None and cache_updates: - logger.info("Writing {} entries to secret cache", len(cache_updates)) - for key, value in cache_updates.items(): - cache.set(key, value) + if cache is not None: + if cache_updates: + logger.info("Writing {} entries to secret cache", len(cache_updates)) + for key, value in cache_updates.items(): + cache.set(key, value) + _prune_stale_cache_entries(cache) cache.save() finally: if cache is not None: @@ -74,6 +76,27 @@ def run_setup( logger.info("Setup complete.") +def _prune_stale_cache_entries(cache: Cache) -> None: + """Drop cache entries whose keys are not currently in Podman's secret store. + + Each time ``_register_secrets`` deletes and re-creates a Podman secret, + Podman assigns a new hex ID. The old ID's cache entry becomes orphaned — + valid ciphertext for a secret that no longer exists. Without pruning, the + cache grows unboundedly across setup runs. + """ + try: + active_ids = {s.get("ID", "") for s in _list_podman_shell_secrets()} + except httpx.HTTPError as e: + logger.warning("Cannot query Podman secrets for cache pruning: {}", e) + return + + stale = [k for k in cache.entry_ids() if k not in active_ids] + if stale: + logger.info("Pruning {} stale cache entries", len(stale)) + for key in stale: + cache.invalidate(key) + + def _open_setup_cache(settings: PsiSettings) -> Cache | None: """Open the cache for write during setup, or return None on any failure.""" if not settings.cache.enabled or settings.cache.backend is None: diff --git a/tests/test_setup.py b/tests/test_setup.py index 4ebdc14..f8e3884 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -16,6 +16,7 @@ _RETRY_DELAYS, _generate_drop_in, _is_retryable, + _prune_stale_cache_entries, _register_secrets, _setup_infisical_workload, ) @@ -371,3 +372,56 @@ def mock_request(method, url, **kwargs): id_map = _register_secrets(settings, "myapp", {"DB_URL": "{}"}) assert id_map == {"DB_URL": "abc123hex"} + + +class TestPruneStaleCacheEntries: + def test_drops_entries_not_in_active_podman_ids(self) -> None: + """Orphaned cache entries from prior setup runs are pruned.""" + from unittest.mock import MagicMock + + cache = MagicMock() + cache.entry_ids.return_value = ["active1", "stale-old", "active2", "stale-older"] + + podman_secrets = [ + {"ID": "active1", "Spec": {"Name": "x", "Driver": {"Name": "shell"}}}, + {"ID": "active2", "Spec": {"Name": "y", "Driver": {"Name": "shell"}}}, + ] + + with patch("psi.setup._list_podman_shell_secrets", return_value=podman_secrets): + _prune_stale_cache_entries(cache) + + invalidated = [call.args[0] for call in cache.invalidate.call_args_list] + assert sorted(invalidated) == ["stale-old", "stale-older"] + + def test_keeps_cache_intact_if_podman_api_unreachable(self) -> None: + """A Podman API failure should not drop any entries.""" + from unittest.mock import MagicMock + + cache = MagicMock() + cache.entry_ids.return_value = ["keep1", "keep2"] + + with patch( + "psi.setup._list_podman_shell_secrets", + side_effect=httpx.ConnectError("refused"), + ): + _prune_stale_cache_entries(cache) + + cache.invalidate.assert_not_called() + + def test_no_op_when_cache_is_already_clean(self) -> None: + """No invalidate calls when every entry is already in Podman.""" + from unittest.mock import MagicMock + + cache = MagicMock() + cache.entry_ids.return_value = ["abc", "def"] + + with patch( + "psi.setup._list_podman_shell_secrets", + return_value=[ + {"ID": "abc", "Spec": {"Name": "x", "Driver": {"Name": "shell"}}}, + {"ID": "def", "Spec": {"Name": "y", "Driver": {"Name": "shell"}}}, + ], + ): + _prune_stale_cache_entries(cache) + + cache.invalidate.assert_not_called()