Files
eldritch horrors 93ca179023 f2: fix nix fixture store setup
log dir and state dir are not taken from the environment if root is set.

Change-Id: I3752dcf7ac81b291e61beafbb3fed9601e3cfcd1
2026-07-17 13:33:19 +00:00

514 lines
19 KiB
Python

import contextlib
import copy
import dataclasses
import sys
from functools import partialmethod
from pathlib import Path
from typing import Any, Literal, get_args
from collections.abc import Callable, Generator
import shutil
import subprocess
import logging
import pytest
from testlib.fixtures.command import CommandResult, Command
from testlib.fixtures.env import ManagedEnv
from testlib.utils import is_value_of_type
from textwrap import dedent
type _NixSettingValue = str | int | list[str] | bool | None
type _NixValue = str | int | float | list[_NixValue] | dict[str, _NixValue] | bool | None
def _serialise_config(value: _NixSettingValue) -> str:
if is_value_of_type(value, list[str]):
return " ".join(_serialise_config(e) for e in value)
if is_value_of_type(value, bool):
return "true" if value else "false"
if is_value_of_type(value, str | int):
return str(value)
msg = f"Value is unsupported in nix config: {value!r}, must be {_NixSettingValue.__value__}"
raise ValueError(msg)
def serialise_nix(value: _NixValue) -> str:
"""
Serialises the given python object into a nix represenatation.
NOTE: this interface does not allow accessing variables unless using interpolation, as all strings will be surrounded by quotes.
"""
def escape(v: str) -> str:
if "\\r" in v:
raise ValueError("\\r is not supported for conversion")
escaped = v.replace("\\", "\\\\").replace("$", "\\$").replace('"', '\\"')
return f'"{escaped}"'
if is_value_of_type(value, list[_NixValue.__value__]):
return f"[{' '.join([serialise_nix(v) for v in value])}]"
if is_value_of_type(value, dict[str, _NixValue.__value__]):
return dedent(f"""
{{
{"\n ".join([f"{escape(k)} = {serialise_nix(v)};" for k, v in value.items()])}
}}
""")
if is_value_of_type(value, bool):
return "true" if value else "false"
if is_value_of_type(value, int | float):
return str(value)
if is_value_of_type(value, str):
return escape(value)
if is_value_of_type(value, None):
return "null"
msg = f"Value is unsupported in nix code: {value!r}"
raise ValueError(msg)
class NixSettings:
"""Settings for invoking Nix"""
def __init__(self):
self._settings: dict[str, _NixSettingValue] = {
# Running the test suite creates a lot of stores in the test root (somewhere under TMPDIR).
# Obviously, they are not critical for system operation, so there is no need to reserve space.
# The cleanup will only happen a couple of runs later, wasting space in the meantime.
# Effectively disable this space reserve to reduce the waste considerably (by about 98%).
"gc-reserved-space": 0,
"show-trace": True,
"sandbox": True,
# explicitly disable substitution by default, otherwise we may attempt to contact
# substituters and slow down many tests with pointless connection retry timeouts.
"substituters": [],
"extra-sandbox-paths": [],
"extra-experimental-features": [],
"extra-deprecated-features": [],
}
self.disabled = False
"""
This is *not* a nix specific setting, but disables the application of the configured settings.
Use this, if you need to test config file discovery or similar.
"""
def __getattr__(self, attr: str) -> _NixSettingValue:
if attr.startswith("__") or attr == "disabled":
return super().__getattr__(attr)
return self._settings[attr.replace("_", "-")]
def __setattr__(self, attr: str, value: _NixSettingValue):
if attr in ("_settings", "disabled"):
super().__setattr__(attr, value)
else:
self._settings[attr.replace("_", "-")] = value
def __getitem__(self, attr: str) -> _NixSettingValue:
return self._settings[attr]
def __setitem__(self, attr: str, value: str):
self._settings[attr] = value
def pop(self, attr: str, default: str | None = None):
self._settings.pop(attr, default)
def add_xp_feature(self, *names: str):
self["extra-experimental-features"] += names
def add_dp_feature(self, *names: str):
self["extra-deprecated-features"] += names
def update(self, args: dict[str, _NixSettingValue] | None = None, **kwargs):
"""
Overrides the settings with the given dict or kwargs.
"""
self._settings.update(kwargs | (args or {}))
def clone(self) -> "NixSettings":
"""
shortcut to clone the settings to a new object
"""
return self.with_settings()
def with_settings(
self, args: dict[str, _NixSettingValue] | None = None, **kwargs
) -> "NixSettings":
"""
Copies the current settings into a new object, overriding the provided ones.
:returns: A new Settings object with overridden settings
"""
new_settings = NixSettings()
new_settings._settings = copy.deepcopy(self._settings)
new_settings.disabled = self.disabled
new_settings.update(args, **kwargs)
return new_settings
def to_config(self, env: ManagedEnv) -> str:
config = ""
self["extra-sandbox-paths"] += env.path.to_sandbox_paths()
def field_may(name: str, value: Any, serializer: Callable[[Any], str] = _serialise_config):
nonlocal config
if value is not None:
config += f"{name} = {serializer(value)}\n"
for name, value in self._settings.items():
field_may(name, value)
return config
def to_env_overlay(self, env: ManagedEnv) -> None:
if self.disabled:
return
cfg = self.to_config(env)
(env.dirs.nix_conf_dir / "nix.conf").write_text(cfg)
env.set_env("NIX_CONFIG", cfg)
@dataclasses.dataclass
class Nix:
env: ManagedEnv
logger: logging.Logger
_settings: NixSettings | None = dataclasses.field(init=False, default=None)
uses_daemon: bool = False
@property
def _nix_executable(self) -> Path:
if nix_bin_dir := self.env.dirs.nix_bin_dir:
return Path(nix_bin_dir) / "nix"
if from_path := shutil.which("nix"):
return Path(from_path)
raise ValueError(
"Couldn't find a Nix command to execute! Set NIX_BIN_DIR or fix your environment"
)
@property
def settings(self) -> NixSettings:
"""
:return: the settings for the nix instance
"""
if self._settings is None:
self._settings = NixSettings()
self._settings.store = f"local?root={self.env.dirs.test_root}&log={self.env.dirs.nix_log_dir}&state={self.env.dirs.nix_state_dir}"
if sys.platform == "linux":
# sandbox build dir cannot be withing store dir. choose a short non-overlapping path.
self._settings.sandbox_build_dir = (
"/build-f2" if self.env.dirs.test_root.parts[1] != "build-f2" else "/build.f2"
)
return self._settings
def nix_cmd(self, argv: list[str], flake: bool = False, cwd: Path | None = None) -> Command:
"""
Constructs a NixCommand with the appropriate settings.
"""
# Create a copy of settings to not have a writing side effect
settings = self.settings.clone()
if flake:
settings.add_xp_feature("nix-command", "flakes")
settings.to_env_overlay(self.env)
return Command(argv=argv, exe=self._nix_executable, _env=self.env, cwd=cwd)
def nix(
self, cmd: list[str], nix_exe: str = "nix", flake: bool = False, cwd: Path | None = None
) -> Command:
return self.nix_cmd([nix_exe, *cmd], flake=flake, cwd=cwd)
# Mark each of these as correct as they are not ClassVars, but we also don't want to turn off RUF045
nix_build = partialmethod(nix, nix_exe="nix-build") # noqa: RUF045
nix_shell = partialmethod(nix, nix_exe="nix-shell") # noqa: RUF045
nix_store = partialmethod(nix, nix_exe="nix-store") # noqa: RUF045
nix_env = partialmethod(nix, nix_exe="nix-env") # noqa: RUF045
nix_instantiate = partialmethod(nix, nix_exe="nix-instantiate") # noqa: RUF045
nix_channel = partialmethod(nix, nix_exe="nix-channel") # noqa: RUF045
nix_prefetch_url = partialmethod(nix, nix_exe="nix-prefetch-url") # noqa: RUF045
def eval(
self, expr: str, settings: NixSettings | None = None, flags: list[str] | None = None
) -> CommandResult:
"""
calls `nix eval --json --expr {expr}` using the given expression
:param expr: what to evaluate
:param settings: if none, the global settings will be used, otherwise the given one
:param flags: if none, empty list, otherwise pass flags to the CLI invocation
:return: result of the evaluation
"""
if flags is None:
flags = []
orig = self.settings.clone()
self._settings = settings or self.settings
self.settings.add_xp_feature("nix-command")
cmd = self.nix(["eval", "--json", *flags, "--expr", expr])
# restore previous settings
self._settings = orig
return cmd.run()
def eval_builtin(self, name: str, *args: _NixValue) -> CommandResult:
"""
This is a high-level wrapper, to easily evaluate and obtain the result of a builtin.
In the background, it calls `nix eval --json --expr builtins.{name} {args}`
It is recommended to call `.json()` on the return value.
:param name: name of the builtin
:param args: list of arguments to provide to the builtin
:return: result of the evaluation
"""
args = [serialise_nix(arg) for arg in args]
return self.eval(f"builtins.{name} {' '.join(args)}")
@property
def store_dir(self) -> Path:
"""
The actual NIX_STORE_DIR this Nix command uses.
"""
assert self.env.dirs.real_store_dir is not None, "bug in ManagedEnv"
return self.env.dirs.real_store_dir
def physical_store_path_for(self, path: str | Path) -> Path:
"""
Takes a /nix/store/… path and rewrites it to be relative to this Nix's NIX_STORE_DIR.
Nix accepts and returns store paths as `/nix/store` even when that's not where `NIX_STORE_DIR`
physically is on the filesystem. Since we move the conceptual root for Nix to `test_root`,
these "virtual" paths differ from the physical ones. So this function will convert `/nix/store`
"virtual" paths to their real, physical location on the system.
Basically, if you're passing it to `nix build` or `nix-store` or whatever, you want the
`/nix/store` version. If you're passing it to a Python API (like pathlib.Path.exists()) or a
command that operates on arbitrary files instead of store paths, you want the output of this
function.
:param path: a string or Path to convert
:return: a Path object holding the rewritten, physical system path to the store entry
"""
return (
Path(str(path).replace("/nix/store", self.store_dir.as_posix()))
if str(path).startswith("/nix/store")
else Path(path)
)
def hash_path(self, store_path: str | Path, *args: str) -> str:
"""
Shortcut to use `nix hash path {store_path}`, converting "virtual" store paths returned
from Nix to their physical system paths including the test root.
:param store_path: store path of the derivation or entry to hash
"""
actual_path = self.physical_store_path_for(store_path).as_posix()
res = self.nix(["hash", "path", actual_path, *args], flake=True).run().ok()
return res.stdout_plain
def clear_store(self):
"""
Clears the test-owned store (and state) and resets them to an empty state
"""
nix_store_dir = self.env.dirs.real_store_dir
state_dir = self.env.dirs.nix_state_dir
# Make store writable
Command(["chmod", "-R", "+w", nix_store_dir], self.env).run().ok()
shutil.rmtree(nix_store_dir)
shutil.rmtree(state_dir)
# Re-create the directories
nix_store_dir.mkdir()
state_dir.mkdir()
_fully_sandboxed = (
sys.platform == "linux"
and Path("/proc/self/ns/user").is_symlink()
and subprocess.run(["unshare", "--user", "--mount", "--pid", "true"]).returncode == 0
)
def pytest_runtest_setup(item: pytest.Item):
for mark in item.iter_markers(name="full_sandbox"):
if not _fully_sandboxed:
pytest.skip(f"{sys.platform} does not support full sandboxing")
type NixDaemon = Callable[..., contextlib.AbstractAsyncContextManager[Nix]]
# NOTE: the order of items here is important. the daemon fixture requires
# the first item in this list to be the last socket opened by the daemon.
type NixDaemonProtocol = Literal["legacy-combined", "legacy", "lix-xp-1"]
daemon_protocols: list[NixDaemonProtocol] = get_args(NixDaemonProtocol.__value__)
_daemon_protocol_xp_features: dict[NixDaemon, list[str]] = {"lix-xp-1": ["rpc-sockets"]}
@contextlib.contextmanager
def _daemon_wrapper(
default_protocol: NixDaemonProtocol,
nix: Nix,
args: list[str] | None = None,
settings: dict[str, _NixSettingValue] | None = None,
protocol: NixDaemonProtocol | None = None,
**kwargs,
) -> contextlib.AbstractAsyncContextManager[Nix]:
protocol = protocol or default_protocol
daemon = copy.deepcopy(nix)
daemon.logger = nix.logger.getChild("daemon")
daemon.settings["allowed-users"] = ["*"]
daemon.settings["trusted-users"] = []
daemon.settings.store = f"local?root={nix.env.dirs.test_root}&log={nix.env.dirs.nix_log_dir}&state={nix.env.dirs.nix_state_dir}"
daemon.settings.update(settings)
if requires_features := _daemon_protocol_xp_features.get(protocol):
daemon.settings.add_xp_feature(*requires_features)
sockets_dir = Path(daemon.env.dirs.nix_state_dir) / "daemon-socket"
sockets = [sockets_dir / "socket", sockets_dir / "lix-xp-1/socket"]
for p in sockets:
p.unlink(missing_ok=True)
proc = daemon.nix(args or [], nix_exe="nix-daemon", **kwargs).start()
def log_daemon_result(result: CommandResult | None, level: int):
if result:
daemon.logger.log(level, "daemon exited with code %i", result.rc)
daemon.logger.log(level, "stdout: %s", result.stdout_s)
daemon.logger.log(level, "stderr: %s", result.stderr_s)
else:
daemon.logger.error("daemon exited unexpectedly")
# wait for daemon to come up. this may take a while under load.
# we wait only for the first socket in the list, expecting that
# it'll be the last one opened by the daemon. this is to ensure
# that we always return correctly regardless of rpc xp settings
while not sockets[0].exists():
if status := proc.wait(0.01):
log_daemon_result(status, logging.ERROR)
raise RuntimeError("daemon exited during startup")
inner = copy.deepcopy(nix)
inner.uses_daemon = True
socket_path = sockets_dir / "socket" if protocol == "legacy-combined" else sockets_dir
inner.settings.store = f"unix://{socket_path}?protocol={protocol}"
try:
timeout, level = 1, logging.ERROR
yield inner
# 5 seconds should be enough to wait for a *graceful* exit.
timeout, level = 5, logging.DEBUG
finally:
result = proc.terminate(timeout)
if not result:
result = proc.kill()
log_daemon_result(result, level)
@pytest.fixture
def nix_settings(request: pytest.FixtureRequest) -> dict[str, _NixSettingValue]:
return getattr(request, "param", {})
def _nix_plain_impl(
tmp_path: Path, env: ManagedEnv, logger: logging.Logger, settings: dict[str, _NixSettingValue]
) -> Generator[Nix, Any, None]:
nix = Nix(env, logger)
nix.settings.update(settings)
yield nix
# when things are done using the nix store, the permissions for the store are read only
# after the test was executed, we set the permissions to rwx (write being the important part)
# for pytest to be able to delete the files during cleanup
cmd = Command(argv=["chmod", "-R", "+w", str(tmp_path.absolute())], _env=env)
cmd.run().ok()
@pytest.fixture
def nix(
tmp_path: Path,
env: ManagedEnv,
logger: logging.Logger,
request: pytest.FixtureRequest,
nix_settings: dict[str, _NixSettingValue],
) -> Generator[Nix, Any, None]:
"""
Provides a rich way of calling `nix`.
For pre-applied commands use `nix.nix_instantiate`, `nix.nix_build` etc.
After configuring the command, use `.run()` to run it
"""
for nix in _nix_plain_impl(tmp_path, env, logger, nix_settings):
if getattr(request, "param", None) is None:
yield nix
else:
with _daemon_wrapper(request.param, nix, settings=nix_settings) as inner:
yield inner
@pytest.fixture(params=daemon_protocols)
def daemon(request: pytest.FixtureRequest, nix_settings: dict[str, _NixSettingValue]) -> NixDaemon:
"""
paramterize every daemon tests to run using all supported nix protocols
"""
def wrapper(
nix: Nix,
args: list[str] | None = None,
settings: dict[str, _NixSettingValue] | None = None,
protocol: NixDaemonProtocol | None = None,
**kwargs,
) -> contextlib.AbstractAsyncContextManager[Nix]:
return _daemon_wrapper(
request.param, nix, args, nix_settings | (settings or {}), protocol, **kwargs
)
return wrapper
def pytest_generate_tests(metafunc: pytest.Metafunc):
"""
Generate parametrized tests from all tests that use the Nix fixture to test
nix with all available daemon protocols, *unless* those tests are marked with `no_daemon`
"""
if "nix" not in metafunc.fixturenames or "daemon" in metafunc.fixturenames:
return
nix_settings = {}
for settings in metafunc.definition.iter_markers("nix_settings"):
nix_settings.update({k.replace("_", "-"): v for k, v in settings.kwargs.items()})
if nix_settings:
metafunc.parametrize(
"nix_settings", [nix_settings], indirect=True, ids=[pytest.HIDDEN_PARAM]
)
if not list(metafunc.definition.iter_markers("no_daemon")):
protocols = [None, *daemon_protocols]
ids = protocols
metafunc.parametrize("nix", protocols, indirect=True, ids=ids)
@pytest.fixture
def fake_nixpkgs(nix: Nix):
nixpkgs = nix.env.dirs.test_root / "nixpkgs"
nixpkgs.mkdir()
(nixpkgs / "default.nix").write_text('{...}: {lib.version = "f2-fake-nixpkgs";}')
nix.env["NIX_PATH"] = f"nixpkgs={nixpkgs.absolute()}"
@pytest.fixture
def enable_diverted_store(nix: Nix):
"""
clear NIX_STORE_DIR, resetting it to the default (ie /nix/store).
this makes builds impossible on platforms that cannot bind-mount,
(e.g. macos) but it is important for eval result reproducibility.
while builds may not work, substitution should still be possible.
"""
nix.env.dirs.nix_store_dir = None
nix.settings.sandbox_build_dir = None
def with_diverted_store(func: Callable[[Any], None]) -> Callable[[Any], None]:
return pytest.mark.usefixtures("enable_diverted_store")(func)