Files
lix/tests/functional2/testlib/fixtures/nix.py
T
Raito Bezarius fbd6a014ec flake/inputs: 24.11-small -> 25.05-small
We upgrade to 25.05 release, which contains the curl commit
https://github.com/curl/curl/commit/5fbd78eb2dc4afbd8884e8eed27147fc3d4318f6
done in
https://github.com/NixOS/nixpkgs/pull/396200#issuecomment-2795944006.

This fixes HTTP transfers generating arbitrary errors and possibly
failing unusually.

Users who are already depending on 25.05-small or a recent unstable
already had the fix.

Special mention to the Linux kernel who gave me the opportunity to get
on a 24 hours bisection side quest to fix the local release engineering
test.

Special thanks to everyone who had to endure me ranting.

Change-Id: I866caf65d5ea103f1fa5eccd57df8031c9eacda0
Co-authored-by: eldritch horrors <pennae@lix.systems>
Co-authored-by: helle <helle@h3l.li>
Signed-off-by: Raito Bezarius <raito@lix.systems>
(cherry picked from commit 1e34c37477)
2025-06-19 17:09:59 +02:00

241 lines
8.1 KiB
Python

import dataclasses
import json
import logging
import os
import subprocess
from functools import partialmethod
from pathlib import Path
from typing import Any, AnyStr, Callable, Dict
import pytest
from functional2.testlib.terminal_code_eater import eat_terminal_codes
@dataclasses.dataclass
class CommandResult:
cmd: list[str]
rc: int
"""Return code"""
stderr: bytes
"""Outputted stderr"""
stdout: bytes
"""Outputted stdout"""
def ok(self) -> "CommandResult":
if self.rc != 0:
print("stdout: %s", self.stderr_s)
print("stderr: %s", self.stderr_s)
raise subprocess.CalledProcessError(
returncode=self.rc, cmd=self.cmd, stderr=self.stderr, output=self.stdout
)
return self
def expect(self, rc: int) -> "CommandResult":
if self.rc != rc:
print("stdout: %s", self.stderr_s)
print("stderr: %s", self.stderr_s)
raise subprocess.CalledProcessError(
returncode=self.rc, cmd=self.cmd, stderr=self.stderr, output=self.stdout
)
return self
@property
def stdout_s(self) -> str:
"""Command stdout as str"""
return self.stdout.decode("utf-8", errors="replace")
@property
def stderr_s(self) -> str:
"""Command stderr as str"""
return self.stderr.decode("utf-8", errors="replace")
@property
def stdout_plain(self) -> str:
"""Command stderr as str with terminal escape sequences eaten and whitespace stripped"""
return eat_terminal_codes(self.stdout).decode("utf-8", errors="replace").strip()
@property
def stderr_plain(self) -> str:
"""Command stderr as str with terminal escape sequences eaten and whitespace stripped"""
return eat_terminal_codes(self.stderr).decode("utf-8", errors="replace").strip()
def json(self) -> Any:
self.ok()
return json.loads(self.stdout)
@dataclasses.dataclass
class NixSettings:
"""Settings for invoking Nix"""
experimental_features: set[str] | None = None
store: str | None = None
"""
The store to operate on (may be a path or other thing, see nix help-stores).
Note that this can be set to the test's store directory if you want to use
/nix/store paths inside that test rather than NIX_STORE_DIR renaming
/nix/store to some unstable name (assuming that no builds are invoked).
"""
nix_store_dir: Path | None = None
"""
Alternative name to use for /nix/store: breaks all references and NAR imports if
set, but does allow builds in tests (since builds do not require chroots if
the store is relocated).
"""
def feature(self, *names: str) -> "NixSettings":
self.experimental_features = (self.experimental_features or set()) | set(names)
return self
def to_config(self) -> str:
config = ""
def serialise(value: Any) -> str:
if type(value) in {str, int}:
return str(value)
if type(value) in {list, set}:
return " ".join(str(e) for e in value)
msg = f"Value is unsupported in nix config: {value!r}"
raise ValueError(msg)
def field_may(name: str, value: Any, serializer: Callable[[Any], str] = serialise):
nonlocal config
if value is not None:
config += f"{name} = {serializer(value)}\n"
field_may("experimental-features", self.experimental_features)
field_may("store", self.store)
assert self.store or self.nix_store_dir, (
"Failing to set either nix_store_dir or store will cause accidental use of the system store."
)
return config
def to_env_overlay(self) -> dict[str, str]:
ret = {"NIX_CONFIG": self.to_config()}
if self.nix_store_dir:
ret["NIX_STORE_DIR"] = str(self.nix_store_dir)
return ret
@dataclasses.dataclass
class Command:
argv: list[str]
env: dict[str, str] = dataclasses.field(default_factory=dict)
stdin: bytes | None = None
cwd: Path | None = None
def with_env(self, **kwargs) -> "Command":
self.env.update(kwargs)
return self
def with_stdin(self, stdin: bytes) -> "Command":
self.stdin = stdin
return self
def run(self) -> CommandResult:
proc = subprocess.Popen(
self.argv,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=subprocess.PIPE if self.stdin else subprocess.DEVNULL,
cwd=self.cwd,
env=self.env,
)
(stdout, stderr) = proc.communicate(input=self.stdin)
rc = proc.returncode
return CommandResult(cmd=self.argv, rc=rc, stdout=stdout, stderr=stderr)
@dataclasses.dataclass
class NixCommand(Command):
settings: NixSettings = dataclasses.field(default_factory=NixSettings)
def apply_nix_config(self):
self.env.update(self.settings.to_env_overlay())
def run(self) -> CommandResult:
self.apply_nix_config()
return super().run()
@dataclasses.dataclass
class Nix:
test_root: Path
def hermetic_env(self) -> Dict[str, Path]:
# mirroring vars-and-functions.sh
home = self.test_root / "test-home"
home.mkdir(parents=True, exist_ok=True)
return {
"NIX_LOCALSTATE_DIR": self.test_root / "var",
"NIX_LOG_DIR": self.test_root / "var/log/nix",
"NIX_STATE_DIR": self.test_root / "var/nix",
"NIX_CONF_DIR": self.test_root / "etc",
"NIX_DAEMON_SOCKET_PATH": self.test_root / "daemon-socket",
"NIX_USER_CONF_FILES": "",
"HOME": home,
}
def make_env(self) -> Dict[AnyStr, AnyStr]:
# We conservatively assume that people might want to successfully get
# some env through to the subprocess, so we override whatever is in the
# global env.
d = os.environ.copy()
d.update(self.hermetic_env())
return d
def cmd(self, argv: list[str]) -> Command:
return Command(argv=argv, cwd=self.test_root, env=self.make_env())
def settings(self, allow_builds: bool = False) -> NixSettings:
"""
Parameters:
- allow_builds: relocate the Nix store so that builds work (however, makes store paths non-reproducible across test runs!)
"""
settings = NixSettings()
store_path = self.test_root / "store"
if allow_builds:
settings.nix_store_dir = store_path
else:
settings.store = str(store_path)
return settings
def nix_cmd(self, argv: list[str], flake: bool = False) -> NixCommand:
"""
Constructs a NixCommand with the appropriate settings.
"""
settings = self.settings()
if flake:
settings.feature("nix-command", "flakes")
return NixCommand(argv=argv, cwd=self.test_root, env=self.make_env(), settings=settings)
def nix(self, cmd: list[str], nix_exe: str = "nix", flake: bool = False) -> NixCommand:
return self.nix_cmd([nix_exe, *cmd], flake=flake)
# 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) -> CommandResult:
if settings is None:
settings = self.settings()
# clone due to reference-shenanigans
settings = dataclasses.replace(settings).feature("nix-command")
cmd = self.nix(["eval", "--json", "--expr", expr])
cmd.settings = settings
return cmd.run()
@pytest.fixture
def nix(tmp_path: Path) -> Nix:
return Nix(tmp_path)