Skip to content

Commit 8c1b42a

Browse files
committed
refactor nix_csi: split monolithic service.py into focused modules
Extract functionality from service.py (620 lines) into dedicated modules: - constants.py: configuration and path constants - store.py: Nix store path regex and extraction utilities - hardlinks.py: filesystem operations for hardlink trees - builders.py: Kubernetes builder pod discovery - cache.py: cache connectivity and copy operations - nix.py: Nix build/eval operations - volume.py: volume preparation and mounting service.py now focuses on CSI orchestration (341 lines). Removed unused kubernetes.py (get_builder_ips was never called). Absorbed copytocache.py into cache.py.
1 parent 54bad12 commit 8c1b42a

10 files changed

Lines changed: 688 additions & 535 deletions

File tree

python/nix_csi/builders.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import kr8s
2+
import logging
3+
4+
from .constants import BUILDERS_ENABLED, BUILDERS_SERVICE, NAMESPACE
5+
6+
logger = logging.getLogger("nix-csi")
7+
8+
9+
async def get_builder_uris() -> list[str]:
10+
"""Query k8s API for builder pods, return list of SSH URIs for --builders flag."""
11+
if not BUILDERS_ENABLED:
12+
return []
13+
14+
try:
15+
# Use kr8s to query pods with label selector
16+
# kr8s.asyncio.get() returns an async generator, iterate with async for
17+
uris = []
18+
async for pod in kr8s.asyncio.get(
19+
"pods", namespace=NAMESPACE, label_selector="app.kubernetes.io/name=builder"
20+
):
21+
if pod.status.phase == "Running":
22+
pod_name = pod.metadata.name
23+
uri = f"ssh://nix@{pod_name}.{BUILDERS_SERVICE}.{NAMESPACE}.svc.cluster.local"
24+
uris.append(uri)
25+
26+
logger.debug(f"Discovered {len(uris)} builder pods: {uris}")
27+
return uris
28+
except Exception as e:
29+
logger.warning(f"Failed to discover builder pods: {e}")
30+
return []
31+
32+
33+
def build_builder_args(uris: list[str]) -> list[str]:
34+
"""Build nix command arguments for using builder pods."""
35+
if not uris:
36+
return []
37+
38+
args = ["--max-jobs", "0"]
39+
for uri in uris:
40+
args.extend(["--builders", uri])
41+
args.append("--builders-use-substitutes")
42+
return args

python/nix_csi/cache.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import asyncio
2+
import logging
3+
from asyncio import Semaphore, sleep
4+
from collections import defaultdict
5+
from pathlib import Path
6+
7+
from grpclib import GRPCError
8+
9+
from .constants import CACHE_ENABLED
10+
from .subprocessing import run_captured, try_console
11+
12+
logger = logging.getLogger("nix-csi")
13+
14+
# Locks that prevent the same derivation to be uploaded in parallel
15+
copy_lock: defaultdict[Path, Semaphore] = defaultdict(Semaphore)
16+
17+
18+
async def check_cache_connectivity() -> bool:
19+
"""Check if the cache is reachable via SSH."""
20+
if not CACHE_ENABLED:
21+
return False
22+
23+
try:
24+
logger.debug("Trying to connect to cache")
25+
await asyncio.wait_for(
26+
try_console("ssh", "nix@nix-cache", "--", "true"),
27+
timeout=2.0,
28+
)
29+
logger.debug("Cache connectivity check succeeded")
30+
return True
31+
except (GRPCError, OSError, asyncio.TimeoutError):
32+
logger.warning("Cache connectivity check failed")
33+
return False
34+
35+
36+
def get_substituter_args() -> list[str]:
37+
"""Get nix command arguments for using the cache as a substituter."""
38+
return [
39+
"--extra-substituters",
40+
"ssh-ng://nix@nix-cache?trusted=1&priority=20",
41+
]
42+
43+
44+
async def copy_to_cache(package_path: Path) -> None:
45+
"""
46+
Copy a package and its closure to the cache.
47+
48+
TODO: Rewrite this entire copy process to support user-supplied copy scripts.
49+
This will allow end-users to copy to arbitrary destinations (S3, GCS, custom caches, etc.)
50+
rather than hard-coding ssh-ng://nix@nix-cache.
51+
52+
TODO: Building should be moved to separate builder pods rather than happening
53+
within the CSI daemonset. The daemonset should only handle mounting pre-built paths.
54+
This will improve separation of concerns and allow dedicated builder infrastructure.
55+
"""
56+
# Only run one copy per path per time
57+
async with copy_lock[package_path]:
58+
paths = [str(package_path)]
59+
# Try to get derivation paths recursively. This may fail if we only have
60+
# store paths without .drv files (e.g., fetched from substituters), which is normal.
61+
path_info_drv = await run_captured(
62+
"nix",
63+
"path-info",
64+
"--recursive",
65+
"--derivation",
66+
package_path,
67+
)
68+
if path_info_drv.returncode == 0:
69+
paths += path_info_drv.stdout.splitlines()
70+
else:
71+
logger.debug(
72+
f"No derivation paths found for {package_path} (normal if fetched from substituters)"
73+
)
74+
75+
# Filter out .drv files and deduplicate (path-info runs return overlapping results)
76+
paths = {p for p in paths if not p.endswith(".drv")}
77+
78+
if len(paths) > 0:
79+
for attempt in range(6):
80+
if attempt > 0:
81+
exp_backoff = min(5 * (2 ** (attempt - 1)), 60)
82+
logger.warning(
83+
f"Retry {attempt}/6 copying to cache after {exp_backoff}s: {package_path}"
84+
)
85+
await sleep(exp_backoff)
86+
87+
nix_copy = await run_captured(
88+
"nix", "copy", "--to", "ssh-ng://nix@nix-cache", *paths
89+
)
90+
if nix_copy.returncode == 0:
91+
logger.debug(f"Successfully copied to cache: {package_path}")
92+
break
93+
else:
94+
logger.error(f"Failed to copy to cache after 6 attempts: {package_path}")

python/nix_csi/constants.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import os
2+
from asyncio import Semaphore
3+
from importlib import metadata
4+
from pathlib import Path
5+
6+
CSI_PLUGIN_NAME = "nix.csi.store"
7+
CSI_VENDOR_VERSION = metadata.version("nix-csi")
8+
9+
# Exit code from mount command when target is already mounted
10+
MOUNT_ALREADY_MOUNTED = 32
11+
12+
# Paths we base everything on.
13+
# Remember that these are CSI pod paths not node paths.
14+
NIX_ROOT = Path("/")
15+
CSI_ROOT = NIX_ROOT / "nix/var/nix-csi"
16+
CSI_VOLUMES = CSI_ROOT / "volumes"
17+
CSI_GCROOTS = NIX_ROOT / "nix/var/nix/gcroots/nix-csi"
18+
19+
# Configurable via kubenix option: rsyncConcurrency (default: 1)
20+
# Set via RSYNC_CONCURRENCY environment variable
21+
RSYNC_CONCURRENCY = Semaphore(max(int(os.environ.get("RSYNC_CONCURRENCY", "1")), 1))
22+
23+
# Configurable via kubenix option: nodeBuildTimeout (default: 300)
24+
# Set via NIX_BUILD_TIMEOUT environment variable
25+
NIX_BUILD_TIMEOUT = float(os.environ.get("NIX_BUILD_TIMEOUT", "300"))
26+
27+
# Builder configuration
28+
# Set via environment variables from kubenix when builders are enabled
29+
BUILDERS_ENABLED = os.environ.get("BUILDERS_ENABLED", "false").lower() == "true"
30+
31+
NAMESPACE = os.environ.get("KUBE_NAMESPACE", "nix-csi")
32+
BUILDERS_SERVICE = "nix-csi-builders"
33+
34+
# Simple string check is fine - value controlled by easykubenix (always "true" or "false")
35+
CACHE_ENABLED = os.environ.get("CACHE_ENABLED", "false") == "true"

python/nix_csi/copytocache.py

Lines changed: 0 additions & 61 deletions
This file was deleted.

python/nix_csi/hardlinks.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import os
2+
from pathlib import Path
3+
4+
5+
def hardlink_tree(src: Path, dst: Path) -> None:
6+
"""Hardlink a single directory tree, preserving symlinks."""
7+
dst.mkdir(parents=True, exist_ok=True)
8+
9+
for entry in os.scandir(src):
10+
dst_path = dst / entry.name
11+
12+
if entry.is_symlink():
13+
os.symlink(os.readlink(entry.path), dst_path)
14+
elif entry.is_dir(follow_symlinks=False):
15+
hardlink_tree(Path(entry.path), dst_path)
16+
elif entry.is_file(follow_symlinks=False):
17+
dst_path.hardlink_to(entry.path)
18+
19+
20+
def hardlink_closure(store_paths: list[Path], dst: Path) -> None:
21+
"""
22+
Hardlink multiple store paths into dst.
23+
24+
store_paths: [/nix/store/abc-foo, /nix/store/def-bar, ...]
25+
dst: volume_root/nix/store
26+
result: dst/abc-foo/..., dst/def-bar/...
27+
"""
28+
dst.mkdir(parents=True, exist_ok=True)
29+
30+
for store_path in store_paths:
31+
target = dst / store_path.name
32+
if target.exists():
33+
continue # already copied (deduplication across volumes)
34+
hardlink_tree(store_path, target)
35+
36+
37+
def deref_hardlink_tree(src: Path, dst: Path) -> None:
38+
"""
39+
Recursively copy src to dst, dereferencing symlinks and
40+
hardlinking files for space efficiency.
41+
42+
All symlink targets must exist on the same filesystem as dst.
43+
"""
44+
src = Path(src)
45+
dst = Path(dst)
46+
dst.mkdir(parents=True, exist_ok=True)
47+
48+
for entry in os.scandir(src):
49+
dst_path = dst / entry.name
50+
resolved = Path(entry.path).resolve()
51+
52+
if not resolved.exists():
53+
raise FileNotFoundError(f"Broken symlink: {entry.path} -> {resolved}")
54+
55+
if resolved.is_dir():
56+
deref_hardlink_tree(resolved, dst_path)
57+
elif resolved.is_file():
58+
dst_path.hardlink_to(resolved)

python/nix_csi/kubernetes.py

Lines changed: 0 additions & 38 deletions
This file was deleted.

0 commit comments

Comments
 (0)