Files
KF-OnlineJudge/scripts/release/preflight.py
T

277 lines
11 KiB
Python

"""Read-only installation eligibility; never an execution isolation permit."""
import grp
import os
import platform
import pwd
import re
import shutil
import socket
import stat
import subprocess
from pathlib import Path
from .policy import DeliveryError, digest
GIB = 1024**3
ACCOUNTS = {"kf-oj": 1200, "kf-coordinator": 1201, "kf-worker": 1202}
TASK_UID = 100000
TASK_GID = 100000
def protected(path: Path, *, directory: bool = False, ancestors: bool = True) -> None:
"""Only the deliberate `current` link is handled by the activation module."""
paths = [path, *path.parents] if ancestors else [path]
for item in paths:
info = item.lstat()
if info.st_uid != 0 or info.st_mode & 0o022 or stat.S_ISLNK(info.st_mode):
raise DeliveryError("root-owned non-writable ordinary path required")
if item != path or directory:
if not stat.S_ISDIR(info.st_mode):
raise DeliveryError("ordinary protected directory required")
elif not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
raise DeliveryError("ordinary protected single-link file required")
def hardware() -> dict:
"""Conservative measurement binding; no performance claim is inferred."""
cpu = Path("/proc/cpuinfo").read_text()[: 1024 * 1024]
keys = {"vendor_id", "cpu family", "model", "model name", "stepping", "microcode", "flags"}
processor = {}
for line in cpu.split("\n\n", 1)[0].splitlines():
if ":" in line:
key, value = line.split(":", 1)
if key.strip() in keys:
processor[key.strip()] = value.strip()
group = current_cgroup()
limits = {
name: (group / name).read_text().strip() if (group / name).exists() else "max"
for name in ("cpu.max", "memory.max")
}
cpuset = group / "cpuset.cpus.effective"
limits["cpuset.cpus.effective"] = cpuset.read_text().strip() if cpuset.exists() else ""
return {
"kernel": platform.release(),
"architecture": platform.machine(),
"online_cpus": os.cpu_count(),
"processor": processor,
"parent_limits": limits,
}
def current_cgroup() -> Path:
value = Path("/proc/self/cgroup").read_text().strip()
if not value.startswith("0::/") or "\n" in value:
raise DeliveryError("unified cgroup v2 required")
return Path("/sys/fs/cgroup") / value.removeprefix("0::/")
def identity_conflicts() -> list[str]:
errors = []
for name, identity in ACCOUNTS.items():
for lookup, kind in ((pwd.getpwuid, "uid"), (grp.getgrgid, "gid")):
try:
row = lookup(identity)
except KeyError:
continue
if row[0] != name:
errors.append("reserved " + kind + " conflicts")
try:
account = pwd.getpwnam(name)
if (account.pw_uid, account.pw_gid, account.pw_dir, account.pw_shell) != (
identity,
identity,
"/nonexistent",
"/usr/bin/nologin",
):
errors.append("existing service identity differs")
except KeyError:
pass
try:
group = grp.getgrnam(name)
if group.gr_gid != identity or group.gr_mem:
errors.append("existing service group differs")
except KeyError:
pass
if any(name in group.gr_mem for group in grp.getgrall() if group.gr_gid != identity):
errors.append("service identity has supplementary authority")
for rows, field, reserved in (
(pwd.getpwall(), "pw_uid", TASK_UID),
(grp.getgrall(), "gr_gid", TASK_GID),
):
if any(getattr(row, field) == reserved for row in rows):
errors.append("task identity already assigned")
for name, reserved in (("subuid", TASK_UID), ("subgid", TASK_GID)):
source = Path("/etc") / name
if not source.exists():
continue
raw = source.read_text()
if len(raw) > 1024 * 1024:
errors.append("subordinate identity file exceeds bound")
continue
for line in raw.splitlines():
if not line or line.startswith("#"):
continue
fields = line.split(":")
if len(fields) != 3 or not fields[1].isdigit() or not fields[2].isdigit():
errors.append("subordinate identity allocation invalid")
elif int(fields[1]) <= reserved < int(fields[1]) + int(fields[2]):
errors.append("task identity overlaps subordinate allocation")
return errors
def observe(payload: Path, *, bundle_data: dict, require_root: bool = True) -> dict:
errors = []
observations = {}
if platform.system() != "Linux" or platform.machine() != "x86_64":
errors.append("unsupported architecture")
version = re.match(r"(\d+)\.(\d+)", platform.release())
if not version or tuple(map(int, version.groups())) < (6, 6):
errors.append("kernel below supported policy")
if require_root and os.geteuid() != 0:
errors.append("installer requires root")
os_release = Path("/etc/os-release").read_text()
if not re.search(r'^ID=(?:"?)(arch|cachyos)(?:"?)$', os_release, re.MULTILINE):
errors.append("unsupported distribution profile")
if Path("/proc/1/comm").read_text().strip() != "systemd":
errors.append("systemd PID1 required")
try:
observations["hardware"] = hardware()
flags = set(observations["hardware"]["processor"].get("flags", "").split())
required = {
"avx",
"avx2",
"bmi1",
"bmi2",
"f16c",
"fma",
"movbe",
"xsave",
"ssse3",
"sse4_1",
"sse4_2",
"cx16",
"popcnt",
}
if not required <= flags or not flags & {"abm", "lzcnt"}:
errors.append("x86_64-v3 features missing")
if (os.cpu_count() or 0) < 2:
errors.append("two online CPUs required")
controllers = set(Path("/sys/fs/cgroup/cgroup.controllers").read_text().split())
if not {"cpu", "memory", "pids"} <= controllers:
errors.append("required cgroup controllers missing")
group = current_cgroup()
while True:
if group == Path("/sys/fs/cgroup"):
break
memory_max = (group / "memory.max").read_text().strip()
if memory_max != "max" and int(memory_max) < 7 * GIB // 2:
errors.append("effective memory limit below node capacity")
quota, period = (group / "cpu.max").read_text().split()
if quota != "max" and int(quota) < 2 * int(period):
errors.append("effective CPU quota below two CPUs")
group = group.parent
except (OSError, ValueError):
errors.append("cgroup/CPU observations unavailable")
memory = {
key: int(value.split()[0]) * 1024
for key, value in (
line.split(":", 1) for line in Path("/proc/meminfo").read_text().splitlines()
)
}
observations["memory"] = {key: memory[key] for key in ("MemTotal", "MemAvailable")}
# A 4-GiB physical guest exposes slightly less MemTotal after kernel reserve.
if memory["MemTotal"] < 15 * GIB // 4 or memory["MemAvailable"] < 5 * GIB // 2:
errors.append("physical/available memory below installation capacity")
disk = shutil.disk_usage("/var/lib")
protected_images = sum(
entry["bytes"] for entry in bundle_data["files"] if entry["path"].startswith("images/")
)
legacy_image = sum(
entry["bytes"] for entry in bundle_data["files"] if entry["path"].startswith("images/gnu/")
)
# Count a full new application + protected image copies + legacy image and
# four GiB of mutable data/operation reserve. Filesystem metadata makes a
# 12-GiB device expose less than 12 GiB free even when it is empty.
required = bundle_data["payload_bytes"] + protected_images + legacy_image + 4 * GIB
observations["disk"] = {
"total_bytes": disk.total,
"free_bytes": disk.free,
"required_free_bytes": required,
}
if disk.total < 12 * GIB or disk.free < required:
errors.append("installation/data capacity or inventory-derived free reserve insufficient")
for name in ("libc.so.6", "ld-linux-x86-64.so.2"):
system = Path("/usr/lib") / name
try:
protected(system)
if digest(system) != digest(payload / "lib" / name):
errors.append("system GNU ABI bytes differ from verified release")
except (DeliveryError, OSError):
errors.append("system GNU ABI unavailable/unprotected")
for executable in (
"/usr/bin/systemctl",
"/usr/bin/useradd",
"/usr/bin/groupadd",
"/usr/bin/nologin",
"/usr/bin/postgres",
"/usr/bin/psql",
):
try:
protected(Path(executable))
except (DeliveryError, OSError):
errors.append("offline system dependency absent/unprotected")
for path in (
"/usr/lib/rabbitmq/sbin/rabbitmq-server",
"/usr/lib/rabbitmq/plugins/rabbit-4.3.6/ebin/rabbit.app",
"/usr/lib/erlang/erts-15.2.7/bin/beam.smp",
):
try:
protected(Path(path))
except (DeliveryError, OSError):
errors.append("offline broker/OTP dependency absent/unprotected")
broker_version = Path("/usr/lib/rabbitmq/plugins/rabbit-4.3.6/ebin/rabbit.app")
if broker_version.is_file():
with broker_version.open("rb") as stream:
header = stream.read(65537)
if len(header) > 65536 or not re.search(rb'\{vsn,\s*"4\.3\.6"\}', header):
errors.append("offline broker version differs")
for executable, expected in (
("/usr/bin/systemctl", "systemd 262"),
("/usr/bin/postgres", "(PostgreSQL) 18.6"),
):
try:
result = subprocess.run(
[executable, "--version"],
capture_output=True,
text=True,
timeout=5,
env={"PATH": "/usr/bin", "LC_ALL": "C"},
)
if result.returncode or expected not in result.stdout[:4096]:
errors.append("offline system dependency version differs")
except (OSError, subprocess.SubprocessError):
errors.append("offline system dependency unavailable")
errors += identity_conflicts()
for directory in ("/opt", "/etc", "/var/lib"):
try:
protected(Path(directory), directory=True)
except (OSError, DeliveryError):
errors.append("installation ancestor unprotected")
# No socket is opened for a running service and no peer is contacted.
for port in (8000, 8100):
with socket.socket() as check:
try:
check.bind(("127.0.0.1", port))
except OSError:
errors.append("application control port occupied")
if Path("/var/lib/kf-judge/factory/control.sock").exists():
errors.append("factory socket exists; drain and stop before installation")
return {
"eligible": not errors,
"errors": sorted(set(errors)),
"observations": observations,
"installed": False,
"execution_verified": False,
}