feat(delivery): add guarded offline installation and node configuration
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
"""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")
|
||||
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"])
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user