338 lines
13 KiB
Python
338 lines
13 KiB
Python
"""Root operator file installation, independent of migrations and service start."""
|
|
|
|
import os
|
|
import re
|
|
import secrets
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from . import manifest, preflight
|
|
from .policy import DeliveryError, canonical, digest, read_json, sha256
|
|
|
|
RELEASES = Path("/opt/kf-onlinejudge/releases")
|
|
CURRENT = Path("/opt/kf-onlinejudge/current")
|
|
IMAGES = Path("/var/lib/kf-judge/images")
|
|
LEGACY_HEADER = {"image_kind": "linux_gnu_cxx_x86_64_v1", "architecture": "x86_64", "revision": 1}
|
|
|
|
|
|
def directory(path: Path, mode: int) -> None:
|
|
if not path.exists() and not path.is_symlink():
|
|
preflight.protected(path.parent, directory=True)
|
|
path.mkdir(mode=mode)
|
|
preflight.protected(path, directory=True)
|
|
if stat.S_IMODE(path.stat().st_mode) != mode:
|
|
raise DeliveryError("existing directory mode differs; operator review required")
|
|
|
|
|
|
def exclusive_file(path: Path, data: bytes, mode: int) -> None:
|
|
preflight.protected(path.parent, directory=True)
|
|
if path.exists() or path.is_symlink():
|
|
preflight.protected(path)
|
|
if stat.S_IMODE(path.stat().st_mode) != mode or path.read_bytes() != data:
|
|
raise DeliveryError("existing installed file differs; refused overwrite")
|
|
return
|
|
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, mode)
|
|
try:
|
|
with os.fdopen(fd, "wb", closefd=False) as output:
|
|
output.write(data)
|
|
output.flush()
|
|
os.fsync(fd)
|
|
finally:
|
|
os.close(fd)
|
|
sync_directory(path.parent)
|
|
|
|
|
|
def sync_directory(path: Path) -> None:
|
|
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
try:
|
|
os.fsync(fd)
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def copy_verified(source: Path, target: Path, expected: str, mode: int) -> None:
|
|
preflight.protected(source)
|
|
if digest(source) != sha256(expected):
|
|
raise DeliveryError("source changed after bundle verification")
|
|
if target.exists() or target.is_symlink():
|
|
preflight.protected(target)
|
|
if digest(target) != expected or stat.S_IMODE(target.stat().st_mode) != mode:
|
|
raise DeliveryError("existing release differs; refused overwrite")
|
|
return
|
|
preflight.protected(target.parent, directory=True)
|
|
incoming = os.open(source, os.O_RDONLY | os.O_NOFOLLOW)
|
|
outgoing = os.open(target, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o600)
|
|
try:
|
|
with (
|
|
os.fdopen(incoming, "rb", closefd=False) as src,
|
|
os.fdopen(outgoing, "wb", closefd=False) as dst,
|
|
):
|
|
shutil.copyfileobj(src, dst, 1024 * 1024)
|
|
dst.flush()
|
|
os.fsync(outgoing)
|
|
if digest(target) != expected:
|
|
raise DeliveryError("installed bytes differ; incomplete release retained")
|
|
os.fchmod(outgoing, mode)
|
|
finally:
|
|
os.close(incoming)
|
|
os.close(outgoing)
|
|
|
|
|
|
def ensure_accounts() -> None:
|
|
if preflight.identity_conflicts():
|
|
raise DeliveryError("OS identity conflicts")
|
|
import grp
|
|
import pwd
|
|
|
|
for name, identity in preflight.ACCOUNTS.items():
|
|
try:
|
|
grp.getgrnam(name)
|
|
except KeyError:
|
|
subprocess.run(
|
|
["/usr/bin/groupadd", "--gid", str(identity), name],
|
|
check=True,
|
|
timeout=10,
|
|
capture_output=True,
|
|
)
|
|
try:
|
|
pwd.getpwnam(name)
|
|
except KeyError:
|
|
subprocess.run(
|
|
[
|
|
"/usr/bin/useradd",
|
|
"--uid",
|
|
str(identity),
|
|
"--gid",
|
|
str(identity),
|
|
"--no-create-home",
|
|
"--no-user-group",
|
|
"--key",
|
|
"SUB_UID_COUNT=0",
|
|
"--key",
|
|
"SUB_GID_COUNT=0",
|
|
"--home-dir",
|
|
"/nonexistent",
|
|
"--shell",
|
|
"/usr/bin/nologin",
|
|
name,
|
|
],
|
|
check=True,
|
|
timeout=10,
|
|
capture_output=True,
|
|
)
|
|
if preflight.identity_conflicts():
|
|
raise DeliveryError("OS identity readback differs")
|
|
|
|
|
|
def data_directory(path: Path, owner: int) -> None:
|
|
preflight.protected(path.parent, directory=True)
|
|
if not path.exists() and not path.is_symlink():
|
|
path.mkdir(mode=0o700)
|
|
os.chown(path, owner, owner)
|
|
info = path.lstat()
|
|
if (
|
|
not stat.S_ISDIR(info.st_mode)
|
|
or info.st_uid != owner
|
|
or info.st_gid != owner
|
|
or stat.S_IMODE(info.st_mode) != 0o700
|
|
):
|
|
raise DeliveryError("existing private data directory identity/mode differs")
|
|
|
|
|
|
def frozen_copy(source: Path, target: Path, *, path_prefix: str = "") -> None:
|
|
"""For already verified release images, never accept external archive members."""
|
|
records = manifest.inventory(source, path_prefix=path_prefix)
|
|
directories = manifest.directories(source, path_prefix=path_prefix)
|
|
if target.exists() or target.is_symlink():
|
|
preflight.protected(target, directory=True)
|
|
if (
|
|
manifest.inventory(target, path_prefix=path_prefix) != records
|
|
or manifest.directories(target, path_prefix=path_prefix) != directories
|
|
):
|
|
raise DeliveryError("existing protected image differs")
|
|
for record in records:
|
|
preflight.protected(target / record["path"])
|
|
return
|
|
target.mkdir(mode=0o700)
|
|
for name in sorted(directories, key=lambda name: (name.count("/"), name)):
|
|
(target / name).mkdir(mode=0o700)
|
|
for record in records:
|
|
copy_verified(
|
|
source / record["path"],
|
|
target / record["path"],
|
|
record["sha256"],
|
|
int(record["mode"], 8),
|
|
)
|
|
for name in sorted(directories, key=lambda name: name.count("/"), reverse=True):
|
|
(target / name).chmod(0o555)
|
|
sync_directory(target / name)
|
|
target.chmod(0o555)
|
|
sync_directory(target)
|
|
if (
|
|
manifest.inventory(target, path_prefix=path_prefix) != records
|
|
or manifest.directories(target, path_prefix=path_prefix) != directories
|
|
):
|
|
raise DeliveryError("installed image readback differs")
|
|
|
|
|
|
def files(bundle: Path, expected: str) -> dict:
|
|
if os.geteuid() != 0:
|
|
raise DeliveryError("installer requires root")
|
|
preflight.protected(bundle, directory=True)
|
|
data = manifest.verify(bundle, expected)
|
|
observation = preflight.observe(bundle / "payload", bundle_data=data)
|
|
if not observation["eligible"]:
|
|
raise DeliveryError("installation preflight refused: " + "; ".join(observation["errors"]))
|
|
# Per-entry ownership matters even when the inventory is identical.
|
|
for record in data["files"]:
|
|
preflight.protected(bundle / "payload" / record["path"])
|
|
for name in data["directories"]:
|
|
preflight.protected(bundle / "payload" / name, directory=True)
|
|
ensure_accounts()
|
|
for name, mode in (
|
|
("/opt/kf-onlinejudge", 0o755),
|
|
(str(RELEASES), 0o755),
|
|
("/etc/kf-onlinejudge", 0o700),
|
|
("/etc/kf-judge", 0o700),
|
|
("/var/lib/kf-onlinejudge", 0o755),
|
|
("/var/lib/kf-judge", 0o755),
|
|
("/var/lib/kf-judge/tasks", 0o755),
|
|
(str(IMAGES), 0o755),
|
|
):
|
|
directory(Path(name), mode)
|
|
for name in ("artifacts", "imports"):
|
|
data_directory(Path("/var/lib/kf-onlinejudge") / name, preflight.ACCOUNTS["kf-oj"])
|
|
data_directory(Path("/var/lib/kf-onlinejudge/worker"), preflight.ACCOUNTS["kf-worker"])
|
|
node_unit = bundle / "payload/deploy/release/kfonlinejudge.slice"
|
|
exclusive_file(Path("/etc/systemd/system/kfonlinejudge.slice"), node_unit.read_bytes(), 0o644)
|
|
release = RELEASES / data["release_id"]
|
|
if release.exists():
|
|
preflight.protected(release, directory=True)
|
|
manifest.verify(release, expected)
|
|
for record in data["files"]:
|
|
preflight.protected(release / "payload" / record["path"])
|
|
else:
|
|
stage = RELEASES / ("." + data["release_id"] + ".installing")
|
|
if stage.exists() or stage.is_symlink():
|
|
raise DeliveryError("incomplete release requires explicit operator inspection")
|
|
stage.mkdir(mode=0o700)
|
|
# `frozen_copy` accepts names through the same bounded allowlist.
|
|
frozen_copy(bundle / "payload", stage / "payload")
|
|
copy_verified(bundle / "manifest.json", stage / "manifest.json", expected, 0o444)
|
|
manifest.verify(stage, expected)
|
|
stage.chmod(0o555)
|
|
os.rename(stage, release)
|
|
sync_directory(RELEASES)
|
|
image_base = IMAGES / data["release_id"]
|
|
directory(image_base, 0o755)
|
|
for name in data["images"]:
|
|
frozen_copy(
|
|
release / "payload/images" / name, image_base / name, path_prefix="images/catalog/"
|
|
)
|
|
legacy = image_base / "legacy-gnu"
|
|
if legacy.exists():
|
|
preflight.protected(legacy, directory=True)
|
|
if read_json(legacy / "manifest.json") != LEGACY_HEADER:
|
|
raise DeliveryError("legacy compatibility image header differs")
|
|
wanted = {
|
|
r["path"]: r
|
|
for r in manifest.inventory(image_base / "gnu", path_prefix="images/catalog/")
|
|
if r["path"] != "manifest.json"
|
|
}
|
|
actual = {
|
|
r["path"]: r
|
|
for r in manifest.inventory(legacy, path_prefix="images/catalog/")
|
|
if r["path"] != "manifest.json"
|
|
}
|
|
if wanted != actual or (
|
|
manifest.directories(legacy, path_prefix="images/catalog/")
|
|
!= manifest.directories(image_base / "gnu", path_prefix="images/catalog/")
|
|
):
|
|
raise DeliveryError("legacy compatibility image bytes differ")
|
|
else:
|
|
# This is an explicit v1 compatibility image, not a catalog rewrite.
|
|
# All executable/library bytes are identical to the verified GNU image.
|
|
frozen_copy(image_base / "gnu", legacy, path_prefix="images/catalog/")
|
|
legacy.chmod(0o700)
|
|
old = legacy / "manifest.json"
|
|
old.chmod(0o600)
|
|
old.write_bytes(canonical(LEGACY_HEADER))
|
|
old.chmod(0o444)
|
|
legacy.chmod(0o555)
|
|
receipt = {
|
|
"schema_version": 1,
|
|
"release_id": data["release_id"],
|
|
"source_sha": data["source_sha"],
|
|
"manifest_sha256": expected,
|
|
"images": data["images"],
|
|
"legacy_header_sha256": digest(legacy / "manifest.json"),
|
|
"files_installed": True,
|
|
"activated": False,
|
|
"migrated": False,
|
|
"execution_verified": False,
|
|
}
|
|
exclusive_file(
|
|
Path("/etc/kf-onlinejudge") / (data["release_id"] + ".files.json"),
|
|
canonical(receipt),
|
|
0o600,
|
|
)
|
|
return receipt
|
|
|
|
|
|
def activate(release_id: str, expected: str) -> dict:
|
|
"""Only files + separately installed exact configuration may be selected.
|
|
|
|
This command starts no service, migrates no table, and removes no old version.
|
|
Runtime admission/isolation/health remains a separate bounded operation.
|
|
"""
|
|
if os.geteuid() != 0 or not re.fullmatch(r"sd-[0-9a-f]{12}", release_id):
|
|
raise DeliveryError("invalid activation identity or privilege")
|
|
release = RELEASES / release_id
|
|
preflight.protected(release, directory=True)
|
|
data = manifest.verify(release, expected)
|
|
config_receipt = Path("/etc/kf-onlinejudge") / (release_id + ".configuration.json")
|
|
from .configuration import check_installed
|
|
|
|
binding = check_installed(config_receipt)
|
|
if (
|
|
binding.get("manifest_sha256") != expected
|
|
or binding.get("source_sha") != data["source_sha"]
|
|
):
|
|
raise DeliveryError("configuration not bound to selected release")
|
|
for port in (8000, 8100):
|
|
import socket
|
|
|
|
with socket.socket() as check:
|
|
try:
|
|
check.bind(("127.0.0.1", port))
|
|
except OSError as error:
|
|
raise DeliveryError("stop and drain services before activation") from error
|
|
if Path("/var/lib/kf-judge/factory/control.sock").exists():
|
|
raise DeliveryError("factory socket present; explicit stop/cleanup required")
|
|
for name in ("tasks", "identities", "captures"):
|
|
path = Path("/var/lib/kf-judge") / name
|
|
if path.exists():
|
|
preflight.protected(path, directory=True)
|
|
if next(path.iterdir(), None) is not None:
|
|
raise DeliveryError("execution leases or task resources remain; activation refused")
|
|
if CURRENT.exists() or CURRENT.is_symlink():
|
|
info = CURRENT.lstat()
|
|
if not stat.S_ISLNK(info.st_mode) or info.st_uid != 0:
|
|
raise DeliveryError("unknown current entry refused")
|
|
old = Path(os.readlink(CURRENT))
|
|
if old.parent.parent != RELEASES or old.name != "payload":
|
|
raise DeliveryError("current link outside release layout")
|
|
preflight.protected(old, directory=True)
|
|
temporary = CURRENT.parent / (".current-" + secrets.token_hex(16))
|
|
temporary.symlink_to(release / "payload")
|
|
os.replace(temporary, CURRENT)
|
|
sync_directory(CURRENT.parent)
|
|
return {
|
|
"selected_source_sha": data["source_sha"],
|
|
"activated": True,
|
|
"services_started": False,
|
|
"execution_verified": False,
|
|
}
|