Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions psi/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
54 changes: 54 additions & 0 deletions tests/test_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
_RETRY_DELAYS,
_generate_drop_in,
_is_retryable,
_prune_stale_cache_entries,
_register_secrets,
_setup_infisical_workload,
)
Expand Down Expand Up @@ -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()
Loading