feat(delivery): install versioned ordinary service units
This commit is contained in:
@@ -137,3 +137,17 @@ arguments or unit text. The outbox unit uses the existing actual confirmed
|
||||
publisher module; the old scaffold check entry is not treated as a delivery loop.
|
||||
Python launches use a fixed module/argument list and only the generated service
|
||||
environment fields; inherited debugging/proxy/provider credentials are omitted.
|
||||
|
||||
`python3.13 -m scripts.release.service_install DIRECTORY --receipt-sha256 SHA`
|
||||
is the separate root operator step for those five ordinary units. It requires
|
||||
the selected installed configuration, the canonical configuration receipt from
|
||||
the generator, and matching service launch/installer bytes in that delivery.
|
||||
All app units must be stopped. It stores immutable batches under the protected
|
||||
`/etc/kf-onlinejudge/units/RELEASE.CONFIGURATION/` directory, then updates only
|
||||
known root-owned system unit pointers and performs a bounded daemon reload.
|
||||
Unknown regular files, foreign pointers, changed bytes and unrecorded old batches
|
||||
refuse replacement. Previously installed batches remain available; interruption
|
||||
may leave a mixed set of pointers, but each pre-start check rejects an unselected
|
||||
binding. Repeat installation can finish the same verified batch after stopping
|
||||
all app units. Loaded/inactive unit readback starts no service and makes no
|
||||
claim about effective runtime limits, readiness, Sandbox gates or VM acceptance.
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Root ordinary-unit installation; no service start, migration or readiness claim."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import stat
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from . import configuration, install, preflight, services
|
||||
from .policy import DeliveryError, canonical, digest, fields, read_json, sha256
|
||||
|
||||
UNITS = Path("/etc/kf-onlinejudge/units")
|
||||
SYSTEM_UNITS = Path("/etc/systemd/system")
|
||||
NAMES = {"kf-" + name + ".service" for name in services.PROFILES}
|
||||
RECEIPT_FIELDS = {
|
||||
"schema_version",
|
||||
"source_sha",
|
||||
"configuration_id",
|
||||
"configuration_receipt_sha256",
|
||||
"files",
|
||||
"installed",
|
||||
"services_started",
|
||||
"execution_verified",
|
||||
}
|
||||
|
||||
|
||||
def validate_receipt(value: dict) -> dict:
|
||||
value = fields(value, RECEIPT_FIELDS, "ordinary service receipt")
|
||||
if (
|
||||
type(value["schema_version"]) is not int
|
||||
or value["schema_version"] != 1
|
||||
or not isinstance(value["source_sha"], str)
|
||||
or not re.fullmatch(r"[0-9a-f]{40}", value["source_sha"])
|
||||
or not isinstance(value["configuration_id"], str)
|
||||
or not re.fullmatch(r"cfg-[0-9a-f]{24}", value["configuration_id"])
|
||||
or any(
|
||||
value[key] is not False
|
||||
for key in (
|
||||
"installed",
|
||||
"services_started",
|
||||
"execution_verified",
|
||||
)
|
||||
)
|
||||
):
|
||||
raise DeliveryError("ordinary service receipt binding differs")
|
||||
sha256(value["configuration_receipt_sha256"])
|
||||
fields(value["files"], NAMES, "ordinary unit inventory")
|
||||
for checksum in value["files"].values():
|
||||
sha256(checksum)
|
||||
return value
|
||||
|
||||
|
||||
def batch_directory(value: dict) -> Path:
|
||||
validate_receipt(value)
|
||||
return UNITS / ("sd-" + value["source_sha"][:12] + "." + value["configuration_id"])
|
||||
|
||||
|
||||
def previous_batch(target: str, name: str) -> Path:
|
||||
"""Only a known immutable ordinary-unit target can ever be replaced."""
|
||||
if name not in NAMES:
|
||||
raise DeliveryError("unexpected ordinary unit")
|
||||
path = Path(target)
|
||||
if (
|
||||
path.name != name
|
||||
or path.parent.parent != UNITS
|
||||
or not re.fullmatch(r"sd-[0-9a-f]{12}\.cfg-[0-9a-f]{24}", path.parent.name)
|
||||
or path.as_posix() != target
|
||||
):
|
||||
raise DeliveryError("unknown system unit target; operator review required")
|
||||
return path.parent
|
||||
|
||||
|
||||
def current_receipt() -> tuple[dict, dict]:
|
||||
selected = install.selection()
|
||||
path = configuration.installed_receipt_path(
|
||||
selected["release_id"], selected["configuration_id"]
|
||||
)
|
||||
installed = configuration.check_installed(path)
|
||||
hashes = {
|
||||
name: installed["configuration_files"][
|
||||
str(
|
||||
configuration.installed_path(
|
||||
selected["release_id"],
|
||||
selected["configuration_id"],
|
||||
name,
|
||||
)
|
||||
)
|
||||
]
|
||||
for name in configuration.CONFIG_FILES
|
||||
}
|
||||
original = {**installed, "installed": False, "configuration_files": hashes}
|
||||
configuration.validate_receipt(original, installed=False)
|
||||
return selected, original
|
||||
|
||||
|
||||
def check_group(directory: Path, receipt: dict) -> None:
|
||||
preflight.protected(directory, directory=True)
|
||||
if stat.S_IMODE(directory.stat().st_mode) != 0o700:
|
||||
raise DeliveryError("private immutable unit batch required")
|
||||
if {p.name for p in directory.iterdir()} != NAMES | {"receipt.json"}:
|
||||
raise DeliveryError("unexpected installed unit member")
|
||||
path = directory / "receipt.json"
|
||||
preflight.protected(path)
|
||||
configuration.private(path)
|
||||
if canonical(validate_receipt(read_json(path, 16384))) != canonical(receipt):
|
||||
raise DeliveryError("installed unit receipt differs")
|
||||
for name in sorted(NAMES):
|
||||
path = directory / name
|
||||
preflight.protected(path)
|
||||
if (
|
||||
path.stat().st_size > 32768
|
||||
or stat.S_IMODE(path.stat().st_mode) != 0o644
|
||||
or digest(path) != receipt["files"][name]
|
||||
):
|
||||
raise DeliveryError("installed ordinary unit bytes/mode differ")
|
||||
|
||||
|
||||
def known_link(path: Path) -> Path | None:
|
||||
try:
|
||||
info = path.lstat()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if not stat.S_ISLNK(info.st_mode) or info.st_uid != 0 or info.st_gid != 0:
|
||||
raise DeliveryError("unknown system unit; refused overwrite")
|
||||
directory = previous_batch(os.readlink(path), path.name)
|
||||
receipt_path = directory / "receipt.json"
|
||||
preflight.protected(receipt_path)
|
||||
configuration.private(receipt_path)
|
||||
receipt = validate_receipt(read_json(receipt_path, 16384))
|
||||
if batch_directory(receipt) != directory:
|
||||
raise DeliveryError("old unit target and receipt binding differ")
|
||||
check_group(directory, receipt)
|
||||
return directory
|
||||
|
||||
|
||||
def systemctl(*arguments: str) -> str:
|
||||
result = subprocess.run(
|
||||
["/usr/bin/systemctl", *arguments],
|
||||
timeout=15,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={"PATH": "/usr/bin:/bin", "LC_ALL": "C"},
|
||||
)
|
||||
if result.returncode or len(result.stdout) > 8192 or len(result.stderr) > 8192:
|
||||
raise DeliveryError("bounded unit manager operation refused")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def install_units(source: Path, expected: str) -> dict:
|
||||
if os.getuid() != 0 or os.geteuid() != 0:
|
||||
raise DeliveryError("ordinary unit installation requires root operator")
|
||||
preflight.protected(source, directory=True)
|
||||
path = source / "receipt.json"
|
||||
preflight.protected(path)
|
||||
configuration.private(path)
|
||||
if digest(path) != sha256(expected):
|
||||
raise DeliveryError("ordinary unit source receipt fingerprint differs")
|
||||
receipt = validate_receipt(read_json(path, 16384))
|
||||
selected, original = current_receipt()
|
||||
if (
|
||||
receipt["source_sha"] != original["source_sha"]
|
||||
or receipt["configuration_id"] != selected["configuration_id"]
|
||||
# Generated units bind the canonical *source* configuration receipt;
|
||||
# installing that configuration changes its installed flag/path keys.
|
||||
or receipt["configuration_receipt_sha256"]
|
||||
!= hashlib.sha256(canonical(original)).hexdigest()
|
||||
):
|
||||
raise DeliveryError("ordinary unit source is not the selected configuration")
|
||||
# A separately trusted operator cannot install a new launcher against an
|
||||
# older delivery that lacks that launcher or has different bytes.
|
||||
payload = install.RELEASES / selected["release_id"] / "payload"
|
||||
for name in ("services.py", "service_bootstrap.py", "service_install.py"):
|
||||
shipped = payload / "delivery/scripts/release" / name
|
||||
preflight.protected(shipped)
|
||||
if digest(shipped) != digest(Path(__file__).parent / name):
|
||||
raise DeliveryError("ordinary unit installer differs from selected delivery")
|
||||
if {p.name for p in source.iterdir()} != NAMES | {"receipt.json"}:
|
||||
raise DeliveryError("unexpected ordinary unit source member")
|
||||
contents = services.render(original)
|
||||
for name in sorted(NAMES):
|
||||
path = source / name
|
||||
preflight.protected(path)
|
||||
configuration.private(path)
|
||||
if path.stat().st_size > 32768 or digest(path) != receipt["files"][name]:
|
||||
raise DeliveryError("ordinary unit source fingerprint differs")
|
||||
if path.read_bytes() != contents[name]:
|
||||
raise DeliveryError("ordinary unit is not the fixed trusted composition")
|
||||
install.require_stopped()
|
||||
preflight.protected(SYSTEM_UNITS, directory=True)
|
||||
# Check every existing pointer before changing any. Regular files, foreign
|
||||
# links and incompletely recorded old batches require explicit review.
|
||||
for name in sorted(NAMES):
|
||||
known_link(SYSTEM_UNITS / name)
|
||||
install.directory(UNITS, 0o700)
|
||||
directory = batch_directory(receipt)
|
||||
install.directory(directory, 0o700)
|
||||
if not {p.name for p in directory.iterdir()} <= NAMES | {"receipt.json"}:
|
||||
raise DeliveryError("unexpected partial unit batch member")
|
||||
for path in directory.iterdir():
|
||||
preflight.protected(path)
|
||||
maximum = 16384 if path.name == "receipt.json" else 32768
|
||||
if path.stat().st_size > maximum:
|
||||
raise DeliveryError("partial unit batch member exceeds bound")
|
||||
for name in sorted(NAMES):
|
||||
install.exclusive_file(directory / name, contents[name], 0o644)
|
||||
install.exclusive_file(directory / "receipt.json", canonical(receipt), 0o600)
|
||||
check_group(directory, receipt)
|
||||
if install.selection() != selected:
|
||||
raise DeliveryError("selected configuration changed during unit preparation")
|
||||
install.require_stopped()
|
||||
for name in sorted(NAMES):
|
||||
path = SYSTEM_UNITS / name
|
||||
old = known_link(path)
|
||||
if old == directory:
|
||||
continue
|
||||
temporary = SYSTEM_UNITS / (".kf-unit-" + secrets.token_hex(16))
|
||||
created = None
|
||||
try:
|
||||
temporary.symlink_to(directory / name)
|
||||
info = temporary.lstat()
|
||||
created = (info.st_dev, info.st_ino)
|
||||
os.replace(temporary, path)
|
||||
install.sync_directory(SYSTEM_UNITS)
|
||||
finally:
|
||||
if created is not None and temporary.is_symlink():
|
||||
info = temporary.lstat()
|
||||
if (info.st_dev, info.st_ino) == created:
|
||||
temporary.unlink()
|
||||
systemctl("daemon-reload")
|
||||
loaded = {}
|
||||
for name in sorted(NAMES):
|
||||
values = dict(
|
||||
line.split("=", 1)
|
||||
for line in systemctl(
|
||||
"show",
|
||||
name,
|
||||
"--property=LoadState,ActiveState,MainPID,FragmentPath",
|
||||
).splitlines()
|
||||
if "=" in line
|
||||
)
|
||||
if (
|
||||
values.get("LoadState") != "loaded"
|
||||
or values.get("ActiveState") != "inactive"
|
||||
or values.get("MainPID") != "0"
|
||||
or values.get("FragmentPath") not in {str(SYSTEM_UNITS / name), str(directory / name)}
|
||||
or known_link(SYSTEM_UNITS / name) != directory
|
||||
):
|
||||
raise DeliveryError("ordinary unit manager load readback differs")
|
||||
loaded[name] = values
|
||||
if install.selection() != selected:
|
||||
raise DeliveryError("selected configuration changed during unit loading")
|
||||
return {
|
||||
"source_sha": receipt["source_sha"],
|
||||
"configuration_id": receipt["configuration_id"],
|
||||
"units_installed": True,
|
||||
"unit_manager_readback": loaded,
|
||||
"services_started": False,
|
||||
"execution_verified": False,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Root ordinary-unit installation")
|
||||
parser.add_argument("directory", type=Path)
|
||||
parser.add_argument("--receipt-sha256", required=True)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
print(json.dumps(install_units(args.directory, args.receipt_sha256), sort_keys=True))
|
||||
except (OSError, ValueError, subprocess.SubprocessError) as error:
|
||||
parser.exit(
|
||||
1,
|
||||
"Unit installation refused: "
|
||||
+ (str(error) if isinstance(error, DeliveryError) else type(error).__name__)
|
||||
+ "\n",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Ordinary service composition; rendering is not Linux service acceptance."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -148,6 +149,8 @@ def generate(
|
||||
from .policy import read_json
|
||||
|
||||
receipt = configuration.validate_receipt(read_json(receipt_file, 16384), installed=False)
|
||||
if hashlib.sha256(canonical(receipt)).hexdigest() != expected_receipt:
|
||||
raise DeliveryError("canonical generated configuration receipt required")
|
||||
if receipt["source_sha"] != data["source_sha"] or receipt["manifest_sha256"] != expected:
|
||||
raise DeliveryError("service configuration source differs from bundle")
|
||||
if {p.name for p in source.iterdir()} != configuration.CONFIG_FILES | {"receipt.json"}:
|
||||
|
||||
Reference in New Issue
Block a user