fix nix flake cache with chroot stores
The flake cache was not properly checking store paths when custom stores were used (e.g., when using --store flag or CLAN_TEST_STORE). This caused cache validation to fail even when the store paths existed. This fix: - Updates store path detection to properly identify any path with "/store/" in it and a proper nix store item format (hash-name) - Normalizes store paths to use the current store when checking if they exist (CLAN_TEST_STORE or nix config) - Uses CLAN_TEST_STORE environment variable for test stores, which matches how nix --store flag works Added comprehensive tests to verify the fix works with custom stores.
This commit is contained in:
@@ -9,6 +9,7 @@ from tempfile import NamedTemporaryFile
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from clan_lib.errors import ClanError
|
from clan_lib.errors import ClanError
|
||||||
|
from clan_lib.nix import nix_config
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -319,9 +320,7 @@ class FlakeCacheEntry:
|
|||||||
# strings need to be checked if they are store paths
|
# strings need to be checked if they are store paths
|
||||||
# if they are, we store them as a dict with the outPath key
|
# if they are, we store them as a dict with the outPath key
|
||||||
# this is to mirror nix behavior, where the outPath of an attrset is used if no further key is specified
|
# this is to mirror nix behavior, where the outPath of an attrset is used if no further key is specified
|
||||||
elif isinstance(value, str) and value.startswith(
|
elif isinstance(value, str) and self._is_store_path(value):
|
||||||
os.environ.get("NIX_STORE_DIR", "/nix/store")
|
|
||||||
):
|
|
||||||
assert selectors == []
|
assert selectors == []
|
||||||
self.value = {"outPath": FlakeCacheEntry(value)}
|
self.value = {"outPath": FlakeCacheEntry(value)}
|
||||||
|
|
||||||
@@ -335,14 +334,68 @@ class FlakeCacheEntry:
|
|||||||
msg = f"Cannot insert {value} into cache, already have {self.value}"
|
msg = f"Cannot insert {value} into cache, already have {self.value}"
|
||||||
raise TypeError(msg)
|
raise TypeError(msg)
|
||||||
|
|
||||||
|
def _is_store_path(self, value: str) -> bool:
|
||||||
|
"""Check if a string is a nix store path."""
|
||||||
|
# A store path is any path that has "store" as one of its parent directories
|
||||||
|
# and contains a hash-prefixed name after it
|
||||||
|
path_parts = Path(value).parts
|
||||||
|
|
||||||
|
try:
|
||||||
|
store_idx = path_parts.index("store")
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if there's at least one more component after "store"
|
||||||
|
if store_idx + 1 < len(path_parts):
|
||||||
|
# Check if the component after store looks like a nix store item
|
||||||
|
# (starts with a hash)
|
||||||
|
store_item = path_parts[store_idx + 1]
|
||||||
|
# Basic check: nix store items typically start with a hash
|
||||||
|
return len(store_item) > 32 and "-" in store_item
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _normalize_store_path(self, store_path: str) -> Path | None:
|
||||||
|
"""
|
||||||
|
Normalize a store path to use the current NIX_STORE_DIR.
|
||||||
|
Returns None if the path is not a valid store path.
|
||||||
|
"""
|
||||||
|
# Extract the store item (hash-name) from the path
|
||||||
|
path_parts = Path(store_path).parts
|
||||||
|
|
||||||
|
# Find the index of "store" in the path
|
||||||
|
try:
|
||||||
|
store_idx = path_parts.index("store")
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if store_idx + 1 < len(path_parts):
|
||||||
|
store_item = path_parts[store_idx + 1]
|
||||||
|
|
||||||
|
# Get the current store path
|
||||||
|
# Check if we're using a test store first
|
||||||
|
test_store = os.environ.get("CLAN_TEST_STORE")
|
||||||
|
if test_store:
|
||||||
|
# In test mode, the store is at CLAN_TEST_STORE/nix/store
|
||||||
|
current_store = str(Path(test_store) / "nix" / "store")
|
||||||
|
else:
|
||||||
|
# Otherwise use nix config
|
||||||
|
config = nix_config()
|
||||||
|
current_store = config.get("store", "/nix/store")
|
||||||
|
|
||||||
|
return Path(current_store) / store_item if current_store else None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def is_cached(self, selectors: list[Selector]) -> bool:
|
def is_cached(self, selectors: list[Selector]) -> bool:
|
||||||
selector: Selector
|
selector: Selector
|
||||||
|
|
||||||
# for store paths we have to check if they still exist, otherwise they have to be rebuild and are thus not cached
|
# for store paths we have to check if they still exist, otherwise they have to be rebuild and are thus not cached
|
||||||
if isinstance(self.value, str) and self.value.startswith(
|
if isinstance(self.value, str) and self._is_store_path(self.value):
|
||||||
os.environ.get("NIX_STORE_DIR", "/nix/store")
|
normalized_path = self._normalize_store_path(self.value)
|
||||||
):
|
if normalized_path:
|
||||||
return Path(self.value).exists()
|
return normalized_path.exists()
|
||||||
|
return False
|
||||||
|
|
||||||
# if self.value is not dict but we request more selectors, we assume we are cached and an error will be thrown in the select function
|
# if self.value is not dict but we request more selectors, we assume we are cached and an error will be thrown in the select function
|
||||||
if isinstance(self.value, str | float | int | None):
|
if isinstance(self.value, str | float | int | None):
|
||||||
|
|||||||
108
pkgs/clan-cli/clan_lib/tests/test_flake_cache_chroot.py
Normal file
108
pkgs/clan-cli/clan_lib/tests/test_flake_cache_chroot.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""Test flake cache with chroot stores."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from clan_lib.flake.flake import FlakeCache, FlakeCacheEntry
|
||||||
|
|
||||||
|
|
||||||
|
def test_flake_cache_with_chroot_store(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Test that flake cache works correctly with chroot stores."""
|
||||||
|
# Create a mock store path
|
||||||
|
normal_store = "/nix/store"
|
||||||
|
chroot_store = str(tmp_path / "nix" / "store")
|
||||||
|
|
||||||
|
# Create the chroot store directory
|
||||||
|
Path(chroot_store).mkdir(parents=True)
|
||||||
|
|
||||||
|
# Create a fake derivation in the chroot store with proper nix store format
|
||||||
|
fake_drv = "abcd1234abcd1234abcd1234abcd1234-test-package"
|
||||||
|
fake_store_path = f"{chroot_store}/{fake_drv}"
|
||||||
|
Path(fake_store_path).mkdir(parents=True)
|
||||||
|
|
||||||
|
# Test 1: Cache entry with normal store path
|
||||||
|
cache = FlakeCache()
|
||||||
|
|
||||||
|
# Insert a store path that doesn't exist in the normal store
|
||||||
|
non_existent_path = f"{normal_store}/{fake_drv}"
|
||||||
|
cache.insert({"test": {"package": non_existent_path}}, "")
|
||||||
|
|
||||||
|
# Without chroot store, this should be uncached (path doesn't exist)
|
||||||
|
assert not cache.is_cached("test.package")
|
||||||
|
|
||||||
|
# Test 2: Set CLAN_TEST_STORE to chroot store parent
|
||||||
|
# CLAN_TEST_STORE should point to the parent of nix/store
|
||||||
|
monkeypatch.setenv("CLAN_TEST_STORE", str(tmp_path))
|
||||||
|
|
||||||
|
# Create a new cache with the chroot store
|
||||||
|
cache2 = FlakeCache()
|
||||||
|
|
||||||
|
# Insert the same path but now it should use chroot store
|
||||||
|
cache2.insert({"test": {"package": fake_store_path}}, "")
|
||||||
|
|
||||||
|
# This should be cached because the path exists in chroot store
|
||||||
|
assert cache2.is_cached("test.package")
|
||||||
|
|
||||||
|
# Test 3: Cache persistence with chroot store
|
||||||
|
cache_file = tmp_path / "cache.json"
|
||||||
|
cache2.save_to_file(cache_file)
|
||||||
|
|
||||||
|
# Load cache in a new instance
|
||||||
|
cache3 = FlakeCache()
|
||||||
|
cache3.load_from_file(cache_file)
|
||||||
|
|
||||||
|
# Should still be cached with chroot store
|
||||||
|
assert cache3.is_cached("test.package")
|
||||||
|
|
||||||
|
# Test 4: Cache validation fails when chroot store changes
|
||||||
|
monkeypatch.setenv("CLAN_TEST_STORE", "/different")
|
||||||
|
|
||||||
|
# Same cache should now be invalid
|
||||||
|
assert not cache3.is_cached("test.package")
|
||||||
|
|
||||||
|
|
||||||
|
def test_flake_cache_entry_store_path_validation() -> None:
|
||||||
|
"""Test that FlakeCacheEntry correctly validates store paths."""
|
||||||
|
# Test with default store
|
||||||
|
entry = FlakeCacheEntry()
|
||||||
|
|
||||||
|
# Insert a non-existent store path with proper format
|
||||||
|
fake_path = "/nix/store/abcd1234abcd1234abcd1234abcd1234-fake-package"
|
||||||
|
entry.insert(fake_path, [])
|
||||||
|
|
||||||
|
# Should not be cached because path doesn't exist
|
||||||
|
assert not entry.is_cached([])
|
||||||
|
|
||||||
|
# Test with environment variable
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Create nix/store structure
|
||||||
|
store_dir = Path(tmpdir) / "nix" / "store"
|
||||||
|
store_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
# Create a fake store path with proper format
|
||||||
|
fake_drv = "test1234test1234test1234test1234-package"
|
||||||
|
fake_path_obj = store_dir / fake_drv
|
||||||
|
fake_path_obj.mkdir()
|
||||||
|
fake_path = str(fake_path_obj)
|
||||||
|
|
||||||
|
# Set CLAN_TEST_STORE to parent of store dir
|
||||||
|
old_env = os.environ.get("CLAN_TEST_STORE")
|
||||||
|
try:
|
||||||
|
os.environ["CLAN_TEST_STORE"] = str(tmpdir)
|
||||||
|
|
||||||
|
entry2 = FlakeCacheEntry()
|
||||||
|
entry2.insert(str(fake_path), [])
|
||||||
|
|
||||||
|
# Should be cached because path exists
|
||||||
|
assert entry2.is_cached([])
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if old_env is None:
|
||||||
|
os.environ.pop("CLAN_TEST_STORE", None)
|
||||||
|
else:
|
||||||
|
os.environ["CLAN_TEST_STORE"] = old_env
|
||||||
Reference in New Issue
Block a user