"""Capability probes. Results describe this machine and this directory, nothing else."""
import ctypes
import errno
import json
import os
import shutil
import subprocess
import sys
import tempfile
from eb_staged_rebuild.swap import AT_FDCWD, RENAME_EXCHANGE
VERSION_PROBE_TIMEOUT_SECONDS = 30
CAPABILITY_PROBE_TIMEOUT_SECONDS = 60
USER_NAMESPACE_FLAGS = (
"--user",
"--map-root-user",
"--mount",
"--fork",
"--kill-child",
)
# Keys read from easybuild/tools/bwrap.py at these tags. A version that is not
# in this table is reported as unknown; the JSON written by a build is what a
# later step trusts, and a missing stage path is not guessed.
BWRAP_STAGE_KEYS_READ_FROM_SOURCE = {
(5, 3, 0): False,
(5, 3, 1): False,
(5, 4, 0): True,
# easybuild-framework-v5.4.1, easybuild/tools/bwrap.py lines 49-51.
(5, 4, 1): True,
(5, 4, 2): True,
}
LEGACY_BWRAP_LAYOUT = {(5, 3, 0), (5, 3, 1)}
def _run(cmd, timeout, env=None):
try:
return subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
env=env,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
return exc
[docs]
def probe_bwrap():
path = shutil.which("bwrap")
if not path:
return {
"installed": False,
"usable": False,
"path": None,
"version": None,
"reason": "bwrap is not installed",
}
version = _run([path, "--version"], VERSION_PROBE_TIMEOUT_SECONDS)
version_text = ""
if isinstance(version, subprocess.CompletedProcess) and version.returncode == 0:
version_text = (version.stdout or version.stderr or "").strip()
probe = _run(
[path, "--dev-bind", "/", "/", "true"],
CAPABILITY_PROBE_TIMEOUT_SECONDS,
)
if isinstance(probe, subprocess.CompletedProcess) and probe.returncode == 0:
return {
"installed": True,
"usable": True,
"path": path,
"version": version_text or None,
"reason": None,
}
if isinstance(probe, subprocess.CompletedProcess):
err = (probe.stderr or probe.stdout or "").strip()
reason = f"bwrap exited {probe.returncode}"
if err:
reason = f"{reason}: {err}"
else:
reason = f"bwrap probe failed: {probe}"
return {
"installed": True,
"usable": False,
"path": path,
"version": version_text or None,
"reason": reason,
}
[docs]
def probe_user_namespaces():
path = "/proc/sys/user/max_user_namespaces"
clone_path = "/proc/sys/kernel/unprivileged_userns_clone"
out = {"max_user_namespaces": None, "unprivileged_userns_clone": None, "readable": False}
try:
with open(path, encoding="utf-8") as handle:
out["max_user_namespaces"] = int(handle.read().strip())
out["readable"] = True
except (OSError, ValueError) as exc:
out["error"] = f"{path}: {exc}"
try:
with open(clone_path, encoding="utf-8") as handle:
out["unprivileged_userns_clone"] = int(handle.read().strip())
except (OSError, ValueError):
out["unprivileged_userns_clone"] = None
return out
[docs]
def probe_userns_sandbox():
"""Test a user and mount namespace with a verified read-only bind mount."""
path = shutil.which("unshare")
result = {
"installed": bool(path),
"usable": False,
"path": path,
"reason": None,
}
if not path:
result["reason"] = "unshare is not installed"
return result
command = [
path,
*USER_NAMESPACE_FLAGS,
sys.executable,
"-m",
"eb_staged_rebuild.userns_sandbox",
"--probe",
]
probe = _run(command, CAPABILITY_PROBE_TIMEOUT_SECONDS, env=os.environ.copy())
if isinstance(probe, subprocess.CompletedProcess) and probe.returncode == 0:
result["usable"] = True
return result
if isinstance(probe, subprocess.CompletedProcess):
detail = (probe.stderr or probe.stdout or "").strip()
result["reason"] = f"user-namespace probe exited {probe.returncode}"
if detail:
result["reason"] += f": {detail}"
else:
result["reason"] = f"user-namespace probe failed: {probe}"
return result
[docs]
def filesystem(path):
"""Longest mount in ``/proc/mounts`` covering ``path``. This host only."""
target = os.path.realpath(path if os.path.isdir(path) else os.path.dirname(path) or ".")
best = None
try:
with open("/proc/mounts", encoding="utf-8") as handle:
lines = handle.read().splitlines()
except OSError as exc:
return {"error": str(exc), "path": target}
for line in lines:
parts = line.split()
if len(parts) < 3:
continue
device, mount, fstype = parts[0], parts[1], parts[2]
mount = mount.replace("\\040", " ")
if target == mount or target.startswith(mount.rstrip("/") + "/"):
if best is None or len(mount) > len(best["mount"]):
best = {"device": device, "mount": mount, "fstype": fstype, "path": target}
return best or {"path": target, "fstype": None}
[docs]
def probe_rename_exchange(directory):
"""Try ``renameat2(RENAME_EXCHANGE)`` on two temporary directories.
The result applies to the filesystem that holds ``directory``. It is not a
measurement of any other filesystem.
"""
directory = os.fspath(directory)
os.makedirs(directory, exist_ok=True)
info = filesystem(directory)
info.update({"ok": False, "errno": None, "error": None, "flag": RENAME_EXCHANGE})
parent = tempfile.mkdtemp(prefix=".rename-exchange-", dir=directory)
left = os.path.join(parent, "left")
right = os.path.join(parent, "right")
try:
os.mkdir(left)
os.mkdir(right)
with open(os.path.join(left, "mark"), "w", encoding="utf-8") as handle:
handle.write("L")
with open(os.path.join(right, "mark"), "w", encoding="utf-8") as handle:
handle.write("R")
libc = ctypes.CDLL(None, use_errno=True)
if not hasattr(libc, "renameat2"):
info["errno"] = errno.ENOSYS
info["error"] = "renameat2 is not in libc"
return info
func = libc.renameat2
func.argtypes = [
ctypes.c_int,
ctypes.c_char_p,
ctypes.c_int,
ctypes.c_char_p,
ctypes.c_uint,
]
func.restype = ctypes.c_int
rc = func(AT_FDCWD, left.encode(), AT_FDCWD, right.encode(), RENAME_EXCHANGE)
if rc == 0:
with open(os.path.join(left, "mark"), encoding="utf-8") as handle:
swapped = handle.read() == "R"
info["ok"] = bool(swapped)
if not swapped:
info["error"] = "renameat2 returned 0 but the directories did not exchange"
return info
err = ctypes.get_errno()
info["errno"] = err
info["error"] = os.strerror(err) if err else f"renameat2 returned {rc}"
return info
except OSError as exc:
info["errno"] = exc.errno
info["error"] = str(exc)
return info
finally:
shutil.rmtree(parent, ignore_errors=True)
[docs]
def parse_framework_version(text):
import re
match = re.search(r"framework:\s*(\d+)\.(\d+)\.(\d+)", text or "")
if not match:
match = re.search(r"This is EasyBuild\s+(\d+)\.(\d+)\.(\d+)", text or "")
if not match:
return None
return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
def _import_bwrap_keys(eb_path):
python = os.path.join(os.path.dirname(os.path.realpath(eb_path)), "python3")
if not os.path.isfile(python):
python = os.path.join(os.path.dirname(os.path.realpath(eb_path)), "python")
if not os.path.isfile(python):
return None
code = (
"import json\n"
"import easybuild.tools.bwrap as b\n"
"print(json.dumps(sorted(b._bwrap_info)))\n"
)
proc = _run([python, "-c", code], CAPABILITY_PROBE_TIMEOUT_SECONDS)
if not isinstance(proc, subprocess.CompletedProcess) or proc.returncode != 0:
return None
try:
keys = json.loads(proc.stdout)
except json.JSONDecodeError:
return None
if isinstance(keys, list) and all(isinstance(item, str) for item in keys):
return keys
return None
[docs]
def probe_easybuild(eb="eb"):
path = shutil.which(eb) if os.sep not in eb else (eb if os.path.isfile(eb) else None)
result = {
"eb": eb,
"path": path,
"found": bool(path),
"version_text": None,
"framework_version": None,
"bwrap_info_keys": None,
"keys_source": None,
"stage_path_keys": None,
"legacy_layout_versions": ["5.3.0", "5.3.1"],
"reason": None,
}
if not path:
result["reason"] = f"{eb} was not found on PATH"
return result
proc = _run([path, "--version"], CAPABILITY_PROBE_TIMEOUT_SECONDS)
if not isinstance(proc, subprocess.CompletedProcess):
result["reason"] = str(proc)
return result
text = "\n".join(part for part in (proc.stdout, proc.stderr) if part)
result["version_text"] = text.strip() or None
if proc.returncode != 0:
result["reason"] = f"eb --version exited {proc.returncode}"
return result
version = parse_framework_version(text)
result["framework_version"] = list(version) if version else None
imported = _import_bwrap_keys(path)
if imported is not None:
result["bwrap_info_keys"] = imported
result["keys_source"] = "imported"
result["stage_path_keys"] = "bwrap_installpath_software" in imported
return result
if version in BWRAP_STAGE_KEYS_READ_FROM_SOURCE:
present = BWRAP_STAGE_KEYS_READ_FROM_SOURCE[version]
result["keys_source"] = "source-table"
result["stage_path_keys"] = present
result["bwrap_info_keys"] = None
return result
result["keys_source"] = "unknown"
result["stage_path_keys"] = None
result["reason"] = "this EasyBuild version was not read and its bwrap keys could not be imported"
return result
[docs]
def probe_all(directory, eb="eb"):
directory = os.fspath(directory)
return {
"bwrap": probe_bwrap(),
"user_namespaces": probe_user_namespaces(),
"userns_sandbox": probe_userns_sandbox(),
"rename_exchange": probe_rename_exchange(directory),
"easybuild": probe_easybuild(eb),
"filesystem": filesystem(directory),
"flock": {
"method": "flock",
"scope": "one kernel, the host that runs this process",
"cross_node": "unmeasured",
},
}
[docs]
def stage_paths_from_bwrap_info(info, framework_version, bwrap_installpath):
"""Return ``(software, modules, how)`` or raise Refused.
``bwrap_installpath_software`` is used when EasyBuild wrote it. EasyBuild
5.3.0 and 5.3.1 do not write it; their ``prepare_bwrap`` joins
``<bwrap_installpath>/software`` and ``<bwrap_installpath>/modules``. That
layout is used only for those two versions. Any other version without the
key is refused, and the stage is left unpublished.
"""
from eb_staged_rebuild.errors import Refused
if not isinstance(info, dict):
raise Refused("bwrap_info.json is not an object")
command = info.get("bwrap_cmd") or []
if "--overlay" in command:
raise Refused(
"bwrap used overlayfs for this install; the stage directory is an upper "
"layer, not a complete generation, so it was not published"
)
software = info.get("bwrap_installpath_software") or ""
modules = info.get("bwrap_installpath_modules") or ""
if software and modules:
return software, modules, "bwrap_info"
version = tuple(framework_version) if framework_version else None
if version in LEGACY_BWRAP_LAYOUT:
root = info.get("bwrap_installpath") or bwrap_installpath
if not root:
raise Refused("EasyBuild 5.3.x bwrap_info.json has no bwrap_installpath")
return (
os.path.join(root, "software"),
os.path.join(root, "modules"),
"easybuild-5.3-layout",
)
present = sorted(info)
raise Refused(
"bwrap_info.json has no bwrap_installpath_software "
f"(keys: {', '.join(present) or 'none'}). "
"EasyBuild 5.3.0 and 5.3.1 omit it; this version was not identified as either, "
"so the stage was kept and not published"
)