This commit enables the parametrization for "legacy-combined" protocol. All failing tests with that have been either fixed or disabled. Notably: - A couple of tests (e.g. involving builders) could be made to work with a non-local store, but this would require some refactoring to testlib in order to make certain configuration settings generic over local and remote operations. We've disabled those for now, in order to make progress - The store tests all run locally only because of their nature - The flakes tests also all run locally only because my energy for fixing them is limited (and it's a *lot* of test failures, with probably little overall benefit in terms of test coverage) Change-Id: I56fa249a64f7c17c952f688ec89e9687f2a13f12
296 lines
11 KiB
Python
296 lines
11 KiB
Python
import dataclasses
|
|
import logging
|
|
import platform
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
from testlib.environ import environ
|
|
|
|
import pytest
|
|
|
|
SLASHES_IN_STORE_PATH_UNTIL_PACKAGE = "/nix/store/hash-program_name/".count("/")
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class _ManagedPath:
|
|
"""
|
|
Wrapper class to handle building the `PATH` environment variable
|
|
"""
|
|
|
|
build_shell: dataclasses.InitVar[str | None]
|
|
build_env: dataclasses.InitVar[str | None] = None
|
|
"""statically linked shell to use within builds which provides coreutils functionality"""
|
|
_path: list[str] = dataclasses.field(default_factory=list)
|
|
|
|
def __post_init__(self, build_shell: str | None, build_env: str | None):
|
|
if build_shell:
|
|
self.prepend(build_shell)
|
|
if build_env:
|
|
for part in build_env.split(":"):
|
|
self.append(part)
|
|
|
|
def to_path(self) -> str:
|
|
"""
|
|
:return: string to be put into the `PATH` environment variable containing all added paths/programs
|
|
"""
|
|
return ":".join(self._path)
|
|
|
|
def prepend(self, exec_path: str | Path) -> "_ManagedPath":
|
|
"""
|
|
Adds the given file or folder at the FRONT of the path variable
|
|
:param exec_path: executable or folder containing executables to be added
|
|
:return: self, to allow for chaining
|
|
"""
|
|
self._path.insert(0, str(exec_path))
|
|
return self
|
|
|
|
def append(self, exec_path: str | Path) -> "_ManagedPath":
|
|
"""
|
|
Adds the given file or folder at the END of the path variable
|
|
:param exec_path: executable or folder containing executables to be added
|
|
:return: self, to allow for chaining
|
|
"""
|
|
self._path.append(str(exec_path))
|
|
return self
|
|
|
|
def insert_at(self, exec_path: str | Path, index: int) -> "_ManagedPath":
|
|
"""
|
|
Adds the given file or folder at the GIVEN INDEX of the path variable
|
|
:param exec_path: executable or folder containing executable to be added
|
|
:param index: where to insert the path
|
|
:return: self, to allow for chaining
|
|
"""
|
|
self._path.insert(index, str(exec_path))
|
|
return self
|
|
|
|
def add_program(self, program_name: str, all_associated: bool = True) -> "_ManagedPath":
|
|
"""
|
|
Adds the given program to the path by name.
|
|
:param program_name: executable/program to add
|
|
:param all_associated: if True, the folder containing the executable will be added instead. Otherwise, only the provided executable will be added
|
|
:raises ValueError: if the program could not be found
|
|
:return: self, to allow for chaining
|
|
"""
|
|
path = shutil.which(program_name)
|
|
if path is None:
|
|
msg = f"Couldn't find program {program_name!r}"
|
|
raise ValueError(msg)
|
|
# Convert to path object for better checking and operations
|
|
path = Path(path)
|
|
if all_associated and not path.is_dir():
|
|
path = path.parent
|
|
# handle as string within the data structure
|
|
path = str(path)
|
|
if path not in self._path:
|
|
self._path.append(path)
|
|
return self
|
|
|
|
def remove_path(self, exec_path: str | Path) -> "_ManagedPath":
|
|
"""
|
|
Removes the given file or folder from the path
|
|
:param exec_path: file or folder to remove
|
|
:raises ValueError: if the file or folder could not be found
|
|
:return: self, to allow for chaining
|
|
"""
|
|
self._path.remove(str(exec_path))
|
|
return self
|
|
|
|
def remove_program(self, program_name: str) -> "_ManagedPath":
|
|
"""
|
|
Removes the given executable/program from the path by name
|
|
:param program_name: executable/program to remove
|
|
:raises ValueError: if the program was not found or isn't present in path
|
|
:return: self, to allow for chaining
|
|
"""
|
|
path = shutil.which(program_name)
|
|
if path is None:
|
|
msg = f"Couldn't find program {program_name!r}"
|
|
raise ValueError(msg)
|
|
if path in self._path or (path := str(Path(path).parent)) in self._path:
|
|
self.remove_path(path)
|
|
else:
|
|
# Mirror behavior of `remove_path`
|
|
msg = f"path.remove({program_name}): {program_name} not in path"
|
|
raise ValueError(msg)
|
|
return self
|
|
|
|
def to_sandbox_paths(self) -> list[str]:
|
|
"""
|
|
:return: list of strings to put into the `sandbox_paths` nix setting
|
|
"""
|
|
ret = []
|
|
for p in self._path:
|
|
if p.startswith("/nix/store/"):
|
|
# adds the entire package to the sandbox,
|
|
# to ensure that dependencies and libraries from within the package are also present
|
|
ret.append("/".join(p.split("/")[:SLASHES_IN_STORE_PATH_UNTIL_PACKAGE]))
|
|
else:
|
|
ret.append(p)
|
|
return ret
|
|
|
|
def which(self, program_name: str) -> Path:
|
|
path = shutil.which(program_name, path=self.to_path())
|
|
if not path:
|
|
raise ValueError(f"{program_name} is not in configured path")
|
|
return Path(path)
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class _Dirs:
|
|
test_root: Path
|
|
home: Path
|
|
nix_log_dir: Path | None
|
|
nix_state_dir: Path | None
|
|
nix_conf_dir: Path | None
|
|
nix_bin_dir: Path | None
|
|
# this one *must not* be NIX_STORE_DIR, otherwise lix will pick it up
|
|
# and misconfigure itself. the config system is unbelievable bullshit
|
|
real_store_dir: Path | None
|
|
cache_dir: Path | None
|
|
xdg_cache_home: Path | None
|
|
tmpdir: Path | None
|
|
"""used for nar caching"""
|
|
nix_store_dir: Path | None = None
|
|
|
|
def get_env_keys(self) -> set[str]:
|
|
return {f.name.upper() for f in dataclasses.fields(self)}
|
|
|
|
def to_env_vars(self) -> dict[str, str]:
|
|
return {k.upper(): v for k, v in dataclasses.asdict(self).items() if v is not None}
|
|
|
|
|
|
class ManagedEnv:
|
|
def __init__(self, tmp_path: Path):
|
|
# Things fetched from the global env
|
|
build_shell = environ.get("BUILD_TEST_SHELL")
|
|
global_path = environ.get("PATH")
|
|
build_env = environ.get("BUILD_TEST_ENV")
|
|
# `NIX_BIN_DIR` either propagated from us or set by meson
|
|
# Set to the codebase internal output if started standalone
|
|
# This is where the current lix binaries are located.
|
|
# local import to avoid cyclic dependencies
|
|
from testlib.utils import lix_base_folder # noqa: PLC0415
|
|
|
|
lix_bin = Path(environ.get("NIX_BIN_DIR", lix_base_folder / "outputs/out/bin"))
|
|
|
|
self._env = {}
|
|
self.path = _ManagedPath(build_shell, build_env)
|
|
self._tmp_path = tmp_path
|
|
self.shell_dir = build_shell or "/bin"
|
|
self.build_env = build_env
|
|
|
|
self.dirs = _Dirs(
|
|
test_root=self._get_dir(""),
|
|
home=self._get_dir("test-home"),
|
|
nix_log_dir=self._get_dir("var/log/nix"),
|
|
nix_state_dir=self._get_dir("nix/var/nix"),
|
|
nix_conf_dir=self._get_dir("etc/nix"),
|
|
nix_bin_dir=lix_bin,
|
|
real_store_dir=self._get_dir("nix/store"),
|
|
nix_store_dir=self._get_dir("nix/store"),
|
|
cache_dir=self._get_dir("test-binary-cache"),
|
|
xdg_cache_home=self._get_dir("test-home/.cache"),
|
|
tmpdir=self._get_dir("tmp"),
|
|
)
|
|
self.path.prepend(self.dirs.nix_bin_dir)
|
|
self.path.prepend(self.shell_dir)
|
|
self.init_defaults(global_path)
|
|
|
|
self._registered_commands = []
|
|
|
|
def _get_dir(self, sub_path: str) -> Path:
|
|
p = self._tmp_path / sub_path
|
|
p.mkdir(parents=True, exist_ok=True)
|
|
return p
|
|
|
|
def init_defaults(self, global_path: str):
|
|
self._env = {
|
|
# Do not use the system-wide or local config for git, but *none* instead
|
|
"GIT_CONFIG_SYSTEM": "/dev/null",
|
|
# Shell to use, required by lix to run sub processes / commands
|
|
"SHELL": f"{self.shell_dir}/sh",
|
|
# when writing things to the terminal (esp with man pages) use cat, to print the full output to stdout
|
|
"PAGER": "cat",
|
|
"BUILD_TEST_SHELL": self.shell_dir,
|
|
# asan can't print to stdio because that breaks snapshot tests
|
|
"ASAN_OPTIONS": f"log_path={self._tmp_path}/asan.log:{environ.get('ASAN_OPTIONS', '')}",
|
|
}
|
|
if self.build_env:
|
|
self._env["BUILD_TEST_ENV"] = self.build_env
|
|
if platform.system() == "Darwin":
|
|
# Darwin / Apple behaves differently and requires _NIX_TEST_NO_SANDBOX to be set for whatever reason
|
|
self._env |= {"_NIX_TEST_NO_SANDBOX": "1"}
|
|
# Copy coreutils from the global path to maintain availability of commands that are not part of
|
|
# XCode Developer Tools and provided by busybox on Linux, which does not build on Darwin
|
|
for p in global_path.split(":"):
|
|
if Path(p).exists() and "coreutils" in p:
|
|
self.path.append(p)
|
|
break
|
|
|
|
def set_env(self, name: str, value: str):
|
|
if name in self.dirs.get_env_keys():
|
|
msg = f"Overriding paths should be done using the `env.dirs` attribute, use `env.dirs.{name.lower()}` instead."
|
|
raise ValueError(msg)
|
|
if name == "PATH":
|
|
msg = "Setting of path not supported. use `env.path` instead"
|
|
raise ValueError(msg)
|
|
if value is None:
|
|
msg = "setting to `None` is not allowed. did you mean to use `env.unset_env`?"
|
|
raise ValueError(msg)
|
|
self._env[name] = value
|
|
|
|
def __setitem__(self, key: str, value: str) -> None:
|
|
return self.set_env(key, value)
|
|
|
|
def get_env(self, name: str, default: str | Path | None = None) -> str | Path | None:
|
|
if name in self.dirs.get_env_keys():
|
|
return getattr(self.dirs, name.lower())
|
|
if name == "PATH":
|
|
msg = "getting of path not supported, use `env.path` instead"
|
|
raise ValueError(msg)
|
|
return self._env.get(name, default)
|
|
|
|
def __getitem__(self, item: str) -> str | Path | None:
|
|
itm = self.get_env(item)
|
|
if itm is None:
|
|
msg = f"{itm} is not set"
|
|
raise KeyError(msg)
|
|
return itm
|
|
|
|
def unset_env(self, name: str) -> str | None:
|
|
if name in self.dirs.get_env_keys():
|
|
msg = f"Overriding paths should be done using the `env.dirs` attribute, use `env.dirs.{name.lower()}` instead."
|
|
raise ValueError(msg)
|
|
return self._env.pop(name, None)
|
|
|
|
def __delitem__(self, key: str) -> str | None:
|
|
itm = self.unset_env(key)
|
|
if itm is None:
|
|
msg = f"{itm} is not set"
|
|
raise KeyError(msg)
|
|
return itm
|
|
|
|
def to_env(self) -> dict[str, str]:
|
|
ret = self.dirs.to_env_vars()
|
|
ret["PATH"] = self.path.to_path()
|
|
for k, v in self._env.copy().items():
|
|
if v is not None:
|
|
ret[k] = v
|
|
else:
|
|
logger.warning("environment variable '%s' is none", k)
|
|
return ret
|
|
|
|
|
|
@pytest.fixture
|
|
def env(tmp_path: Path) -> ManagedEnv:
|
|
env_ = ManagedEnv(tmp_path)
|
|
yield env_
|
|
for cmd in env_._registered_commands:
|
|
if not cmd._run:
|
|
e = ValueError("Command has not been run")
|
|
e.add_note(str(cmd))
|
|
raise e
|