feat(delivery): add guarded offline installation and node configuration
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import secrets
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -12,6 +13,7 @@ sys.path.insert(0, str(ROOT))
|
||||
policy = importlib.import_module("scripts.release.policy")
|
||||
manifest = importlib.import_module("scripts.release.manifest")
|
||||
builder = importlib.import_module("scripts.release.build")
|
||||
configuration = importlib.import_module("scripts.release.configuration")
|
||||
architecture = importlib.import_module("scripts.check_architecture")
|
||||
|
||||
|
||||
@@ -111,3 +113,127 @@ def test_signed_guest_library_alias_uses_guest_root(tmp_path):
|
||||
builder.curated(root, records, "services/usr/lib/library.so", target)
|
||||
assert target.read_bytes() == library.read_bytes()
|
||||
assert not target.is_symlink()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def delivery_configuration(tmp_path, monkeypatch):
|
||||
"""Synthetic operator files only; no services, DB or user program are run."""
|
||||
import hashlib
|
||||
|
||||
images = {}
|
||||
for name, toolchain, kind, version in (
|
||||
("gnu", "gnu-cxx-v1", "linux_gnu_cxx_x86_64_v1", "16.2.1+r23+gd564253eb6c8"),
|
||||
("python313", "cpython313-v1", "linux_cpython313_x86_64_v1", "3.13.16"),
|
||||
("java21", "openjdk21-v1", "linux_openjdk21_x86_64_v1", "21.0.12.1+1-LTS"),
|
||||
):
|
||||
images[name] = {
|
||||
"toolchain_id": toolchain,
|
||||
"image_kind": kind,
|
||||
"image_revision": 1,
|
||||
"runtime_version": version,
|
||||
"image_digest": hashlib.sha256(name.encode()).hexdigest(),
|
||||
}
|
||||
data = {
|
||||
"images": images,
|
||||
"release_id": "sd-123456789abc",
|
||||
"source_sha": "123456789abc" + "d" * 28,
|
||||
}
|
||||
node = {
|
||||
"schema_version": 1,
|
||||
"origin": "https://oj.test",
|
||||
"worker_id": "a28adc33-8fbd-4a33-8b21-7a46431e506c",
|
||||
"measurement_nonce": secrets.token_hex(32),
|
||||
}
|
||||
urls = {}
|
||||
for name, role in configuration.PROFILES.items():
|
||||
password = secrets.token_urlsafe(32)
|
||||
authority = "" if name == "import" else "127.0.0.1:5432"
|
||||
urls[name] = f"postgresql://{role}:{password}@{authority}/kf_onlinejudge"
|
||||
if name == "import":
|
||||
urls[name] += "?host=/run/postgresql"
|
||||
endpoints = {
|
||||
"schema_version": 1,
|
||||
"database": urls,
|
||||
"amqp": {
|
||||
name: f"amqp://kf_{name}:{secrets.token_urlsafe(32)}@127.0.0.1:5672/kf"
|
||||
for name in ("worker", "outbox")
|
||||
},
|
||||
}
|
||||
configuration.validate_profile(node)
|
||||
configuration.validate_connections(endpoints)
|
||||
observed = {
|
||||
"kernel": "6.18-fixture",
|
||||
"architecture": "x86_64",
|
||||
"online_cpus": 2,
|
||||
"processor": {"model name": "fixture"},
|
||||
}
|
||||
monkeypatch.setattr(configuration, "measured_hardware", lambda: dict(observed))
|
||||
raw = configuration.compose(ROOT, data, node, endpoints, attest=True)
|
||||
paths = {}
|
||||
for name, content in raw.items():
|
||||
path = tmp_path / name
|
||||
path.write_bytes(content)
|
||||
path.chmod(0o600)
|
||||
paths[name] = path
|
||||
return paths, raw, data, node, endpoints, observed
|
||||
|
||||
|
||||
def test_generated_configuration_preserves_wire_policies_and_backend_settings(
|
||||
delivery_configuration, monkeypatch
|
||||
):
|
||||
from kf_oj.core.settings import Settings
|
||||
|
||||
paths, raw, data, _, _, _ = delivery_configuration
|
||||
configuration.validate_contents(paths, ROOT, data, attest=True)
|
||||
env = configuration.environment_read(raw["api.env"])
|
||||
for key, value in env.items():
|
||||
monkeypatch.setenv(key, value)
|
||||
settings = Settings(_env_file=None)
|
||||
assert settings.database_url.get_secret_value().startswith("postgresql+asyncpg:")
|
||||
admission = json.loads(raw["admission.json"])
|
||||
coordinator, worker, root = [
|
||||
json.loads(raw[name]) for name in ("coordinator.json", "worker.json", "runtime.json")
|
||||
]
|
||||
assert settings.judge_admission.model_dump(mode="json") == admission
|
||||
assert coordinator["admission"] == worker["admission"] == admission
|
||||
assert "database_url" not in worker and "database_url" not in root
|
||||
assert root["identities"]["count"] == 1
|
||||
assert root["image_root"] not in [image["image_root"] for image in root["runtime_images"]]
|
||||
assert admission["legacy"]["toolchain"] == {
|
||||
"toolchain_id": "gnu-cxx-v1",
|
||||
"image_kind": "linux_gnu_cxx_x86_64_v1",
|
||||
"image_revision": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_epoch_rebinds_when_kernel_changes(delivery_configuration):
|
||||
paths, raw, data, node, endpoints, observed = delivery_configuration
|
||||
before = json.loads(raw["measurement.json"])["epoch"]
|
||||
observed["kernel"] = "6.19-fixture"
|
||||
with pytest.raises(policy.DeliveryError, match="measurement policy mismatch"):
|
||||
configuration.validate_contents(paths, ROOT, data, attest=True)
|
||||
after = configuration.compose(ROOT, data, node, endpoints, attest=True)
|
||||
assert json.loads(after["measurement.json"])["epoch"] != before
|
||||
|
||||
|
||||
def test_unattested_configuration_has_no_comparable_epoch(delivery_configuration):
|
||||
_, _, data, node, endpoints, _ = delivery_configuration
|
||||
raw = configuration.compose(ROOT, data, node, endpoints, attest=False)
|
||||
assert json.loads(raw["coordinator.json"])["workers"][0]["measurement_epoch"] is None
|
||||
|
||||
|
||||
def test_configuration_receipt_is_bound_before_path_use():
|
||||
receipt = {
|
||||
"schema_version": 1,
|
||||
"source_sha": "123456789abc" + "d" * 28,
|
||||
"release_id": "sd-123456789abc",
|
||||
"manifest_sha256": "f" * 64,
|
||||
"configuration_files": {name: "b" * 64 for name in configuration.CONFIG_FILES},
|
||||
"operator_attested_measurement": False,
|
||||
"installed": False,
|
||||
"execution_verified": False,
|
||||
}
|
||||
configuration.validate_receipt(receipt, installed=False)
|
||||
receipt["installed"] = 0
|
||||
with pytest.raises(policy.DeliveryError, match="configuration binding"):
|
||||
configuration.validate_receipt(receipt, installed=False)
|
||||
|
||||
@@ -44,3 +44,41 @@ data-loss target; restoring from already prepared release/backup materials has a
|
||||
verification. These are acceptance targets, not results inferred from this file.
|
||||
Installation/service/restore evidence is required before this pre-release can be
|
||||
treated as an accepted delivery; the contract itself does not deploy production.
|
||||
|
||||
The node input for `generate-configuration` has exactly `schema_version: 1`,
|
||||
`origin` (one HTTPS origin), `worker_id` (canonical nonzero UUID), and
|
||||
`measurement_nonce` (null or a fresh operator-generated SHA-256-sized nonce).
|
||||
It is a 0600 single-link file. Endpoint input is a separate 0600 JSON object
|
||||
with exactly `schema_version: 1`, `database` and `amqp`: database keys are
|
||||
`api`, `coordinator`, `import`, `migration`, `identity`; broker keys are `outbox`
|
||||
and `worker`. Login names and numeric loopback/socket locations are validated by
|
||||
the tool. Every endpoint requires its own operator-supplied private credential;
|
||||
there is no working example password or shared default. Import uses the reachable
|
||||
`/run/postgresql` Unix socket; all other control connections use numeric loopback.
|
||||
|
||||
Generated admission uses the actual verified catalogs for C17/C++20, CPython
|
||||
3.13.16 and OpenJDK 21.0.12.1+1-LTS and the unchanged public per-language budgets.
|
||||
The protected GNU legacy image is a separate directory: it retains the original
|
||||
v1 header and identical verified GNU executable/library bytes, while the three
|
||||
v2 catalog identities remain untouched. Shared Worker/task UID/GID pool and
|
||||
execution/prefetch/import slots remain one. No canonical judging report or
|
||||
historical runtime identity is converted by the installer.
|
||||
|
||||
Comparability defaults to unavailable (`measurement_epoch=null`). A trusted
|
||||
operator may explicitly use `--attest-measurement` only after actual node/service
|
||||
limits and the intended execution environment have been verified. The attestation
|
||||
binds a fresh nonce, worker, source, exact images, budgets, CPU/microcode, kernel
|
||||
and node scheduling snapshot. `check-configuration` refuses an old binding when
|
||||
those values change; it does not manufacture replacement metrics for old reports.
|
||||
Before any service resume after such a change, drain execution, generate/install
|
||||
a new complete private group with a new nonce and independently revalidate the
|
||||
environment. Raw configuration checks are not performance or isolation evidence.
|
||||
|
||||
The installation entry points are available with `python3.13 -m scripts.release
|
||||
--help`. Use them in order: verify/preflight, install-files, generate-configuration,
|
||||
install-configuration, explicit DB identity provision, controlled Backend Alembic
|
||||
and account CLI operations, then activate and the separately verified bounded
|
||||
service lifecycle. These operations never contact a package index and do not
|
||||
install onto a business host on their own. OS dependencies (systemd 262, matching
|
||||
glibc ABI bytes, PostgreSQL 18.6 and the fixed broker runtime) must already be
|
||||
present from separately verified offline materials.
|
||||
|
||||
@@ -28,3 +28,44 @@ contains the source and manifest fingerprints, file count and byte total, with
|
||||
`installed=false` and `execution_verified=false`. Installation and service
|
||||
acceptance remain separate operations. See `deploy/release/README.md` for the
|
||||
versioned installation contract and authority split.
|
||||
|
||||
`preflight` reads installation eligibility only. `install-files` requires root,
|
||||
a completely root-controlled verified input tree and the fixed target profile;
|
||||
it installs protected versions/images and bounded OS identities, without table
|
||||
migrations, application accounts, services or the active-version switch.
|
||||
Repeated identical files converge; unknown modes/owners, changed bytes or an
|
||||
incomplete staging directory require operator inspection and are never removed.
|
||||
|
||||
`generate-configuration` composes exact public contracts with separately supplied
|
||||
0600 node and endpoint inputs. It emits a new private directory and a hash-bound
|
||||
receipt, never prints tokens, never changes a supplied password and never silently
|
||||
overwrites an earlier identity. API, publisher, import, migration and identity
|
||||
operations have distinct required database authority; Worker holds only its own
|
||||
MQ/control identity, Root receives no business credentials. Backend URLs use the
|
||||
existing asyncpg driver. No uvx/pip or online runtime installation is used.
|
||||
|
||||
`install-configuration` validates the entire group before writing any member;
|
||||
`check-configuration` checks installed bytes, same-source admission, identity and
|
||||
hardware/kernel/node scheduler bindings. `activate` requires that check and
|
||||
stopped control ports, no factory socket and no retained task/identity/capture
|
||||
resources. It atomically selects an installed directory and starts no process;
|
||||
draining, migrations and actual health remain separate operations. It does not
|
||||
erase retained journals or release resources lacking cleanup proof.
|
||||
|
||||
`provision-database-identities` is an explicit local database operator step.
|
||||
It uses the locked asyncpg dependency, bounded connections/statements and a
|
||||
120-second overall deadline. It installs only fixed independent logins/NOLOGIN
|
||||
groups and the application database, verifies every supplied credential and
|
||||
refuses unexpected existing authority. There is no application table DDL or
|
||||
automatic account bootstrap: the migration login owns the database without
|
||||
global superuser/create-role/create-db/bypass-RLS rights; Backend Alembic remains
|
||||
the only schema/grant migration entry. Runtime logins cannot grant/set group
|
||||
authority. See [PostgreSQL role membership](https://www.postgresql.org/docs/18/role-membership.html)
|
||||
and [function ownership](https://www.postgresql.org/docs/18/sql-alterfunction.html).
|
||||
|
||||
Always bootstrap validation from a separately trusted tool copy and an out-of-band
|
||||
manifest SHA. Do not execute a checker supplied inside an unverified bundle to
|
||||
establish trust in that same bundle. A verified delivery includes `bin/kf-delivery`
|
||||
and its source for later offline operations. Command success/receipt generation
|
||||
does not claim Linux execution, working service roles or installation acceptance;
|
||||
these require the recorded independent VM evidence.
|
||||
|
||||
@@ -5,7 +5,7 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from . import build, manifest, policy
|
||||
from . import build, configuration, database, install, manifest, policy, preflight
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -21,6 +21,28 @@ def main() -> None:
|
||||
check.add_argument("--inputs", type=Path, required=True)
|
||||
check.add_argument("--output", type=Path, required=True)
|
||||
check.add_argument("--work", type=Path, required=True)
|
||||
for name in ("preflight", "install-files"):
|
||||
check = sub.add_parser(name)
|
||||
check.add_argument("bundle", type=Path)
|
||||
check.add_argument("--manifest-sha256", required=True)
|
||||
check = sub.add_parser("generate-configuration")
|
||||
check.add_argument("bundle", type=Path)
|
||||
check.add_argument("--manifest-sha256", required=True)
|
||||
check.add_argument("--profile", type=Path, required=True)
|
||||
check.add_argument("--connections", type=Path, required=True)
|
||||
check.add_argument("--output", type=Path, required=True)
|
||||
check.add_argument("--attest-measurement", action="store_true")
|
||||
check = sub.add_parser("install-configuration")
|
||||
check.add_argument("directory", type=Path)
|
||||
check.add_argument("--receipt-sha256", required=True)
|
||||
check = sub.add_parser("check-configuration")
|
||||
check.add_argument("receipt", type=Path)
|
||||
check = sub.add_parser("provision-database-identities")
|
||||
check.add_argument("--operator", type=Path, required=True)
|
||||
check.add_argument("--connections", type=Path, required=True)
|
||||
check = sub.add_parser("activate")
|
||||
check.add_argument("release_id")
|
||||
check.add_argument("--manifest-sha256", required=True)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.command == "contract":
|
||||
@@ -36,10 +58,41 @@ def main() -> None:
|
||||
"installed": False,
|
||||
"execution_verified": False,
|
||||
}
|
||||
else:
|
||||
elif args.command == "build":
|
||||
result = build.build(args.repo, args.inputs, args.output, args.work)
|
||||
elif args.command == "preflight":
|
||||
manifest.verify(args.bundle, args.manifest_sha256)
|
||||
result = preflight.observe(args.bundle / "payload")
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
if not result["eligible"]:
|
||||
parser.exit(1)
|
||||
return
|
||||
elif args.command == "install-files":
|
||||
result = install.files(args.bundle, args.manifest_sha256)
|
||||
elif args.command == "generate-configuration":
|
||||
result = configuration.generate(
|
||||
args.bundle,
|
||||
args.manifest_sha256,
|
||||
args.profile,
|
||||
args.connections,
|
||||
args.output,
|
||||
attest=args.attest_measurement,
|
||||
)
|
||||
elif args.command == "install-configuration":
|
||||
result = configuration.install_configuration(args.directory, args.receipt_sha256)
|
||||
elif args.command == "check-configuration":
|
||||
receipt = configuration.check_installed(args.receipt)
|
||||
result = {
|
||||
"configuration_valid": True,
|
||||
"source_sha": receipt["source_sha"],
|
||||
"execution_verified": False,
|
||||
}
|
||||
elif args.command == "provision-database-identities":
|
||||
result = database.provision(args.operator, args.connections)
|
||||
else:
|
||||
result = install.activate(args.release_id, args.manifest_sha256)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
except (policy.DeliveryError, OSError, subprocess.SubprocessError) as error:
|
||||
except (ValueError, OSError, subprocess.SubprocessError) as error:
|
||||
# OSError filenames can include private operator paths; do not echo them.
|
||||
parser.exit(
|
||||
1,
|
||||
|
||||
@@ -13,6 +13,7 @@ from . import manifest
|
||||
from .policy import DeliveryError, contract, digest, fields, read_json, relative_name, sha256
|
||||
|
||||
ENTRYPOINTS = {
|
||||
"kf-delivery": "scripts.release",
|
||||
"kf-api": (
|
||||
"uvicorn kf_oj.api.app:create_app --factory --host 127.0.0.1 --port 8000 "
|
||||
"--no-proxy-headers --limit-concurrency 32 --backlog 64"
|
||||
@@ -322,6 +323,13 @@ def build(repo: Path, inputs: Path, output: Path, work: Path) -> dict:
|
||||
dependency_records(payload / "site")
|
||||
tree(repo / "frontend/dist", payload / "frontend")
|
||||
tracked(repo, "deploy", payload)
|
||||
for raw in subprocess.check_output(
|
||||
["git", "ls-files", "-z", "scripts/release"], cwd=repo
|
||||
).split(b"\0"):
|
||||
if raw:
|
||||
name = raw.decode()
|
||||
relative_name("delivery/" + name)
|
||||
copy(repo / name, payload / "delivery" / name)
|
||||
for name in ("kf-sandbox", "kf-worker", "kf-coordinator"):
|
||||
copy(repo / "judge/target/release" / name, payload / "bin" / name)
|
||||
for name in sorted(records):
|
||||
@@ -374,7 +382,7 @@ def build(repo: Path, inputs: Path, output: Path, work: Path) -> dict:
|
||||
"#!/bin/sh\nset -eu\n"
|
||||
'APP_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P)\n'
|
||||
'export PYTHONHOME="$APP_ROOT/python"\n'
|
||||
'export PYTHONPATH="$APP_ROOT/backend/src:$APP_ROOT/site"\n'
|
||||
'export PYTHONPATH="$APP_ROOT/backend/src:$APP_ROOT/site:$APP_ROOT/delivery"\n'
|
||||
"export PYTHONDONTWRITEBYTECODE=1\n"
|
||||
'export LD_LIBRARY_PATH="$APP_ROOT/lib:$APP_ROOT/python/lib"\n'
|
||||
'cd "$APP_ROOT"\n'
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
"""Compose public contracts and private identities without application state writes."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import stat
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from . import manifest, preflight
|
||||
from .policy import DeliveryError, canonical, digest, fields, read_json, sha256
|
||||
|
||||
PROFILES = {
|
||||
"api": "kf_platform",
|
||||
"coordinator": "kf_coordinator",
|
||||
"import": "kf_problem_import",
|
||||
"identity": "kf_identity_operator",
|
||||
"migration": "kf_migrator",
|
||||
}
|
||||
IMPORT_CGROUP = "/sys/fs/cgroup/system.slice/kf-problem-import.service"
|
||||
CONFIG_FILES = {
|
||||
"api.env",
|
||||
"outbox.env",
|
||||
"import.env",
|
||||
"identity.env",
|
||||
"migration.env",
|
||||
"coordinator.json",
|
||||
"worker.json",
|
||||
"runtime.json",
|
||||
"admission.json",
|
||||
"measurement.json",
|
||||
}
|
||||
|
||||
|
||||
def private(path: Path) -> None:
|
||||
info = path.lstat()
|
||||
if (
|
||||
not stat.S_ISREG(info.st_mode)
|
||||
or info.st_nlink != 1
|
||||
or stat.S_IMODE(info.st_mode) != 0o600
|
||||
or info.st_uid != os.geteuid()
|
||||
):
|
||||
raise DeliveryError("private single-link operator input required")
|
||||
|
||||
|
||||
def profile(path: Path) -> dict:
|
||||
private(path)
|
||||
return validate_profile(read_json(path, 16384))
|
||||
|
||||
|
||||
def validate_profile(value: dict) -> dict:
|
||||
value = fields(
|
||||
value,
|
||||
{"schema_version", "origin", "worker_id", "measurement_nonce"},
|
||||
"node profile",
|
||||
)
|
||||
if type(value["schema_version"]) is not int or value["schema_version"] != 1:
|
||||
raise DeliveryError("unsupported node profile")
|
||||
origin = value["origin"]
|
||||
if not isinstance(origin, str) or len(origin) > 253:
|
||||
raise DeliveryError("invalid browser origin")
|
||||
parts = urlsplit(origin)
|
||||
if (
|
||||
parts.scheme != "https"
|
||||
or not parts.hostname
|
||||
or parts.path
|
||||
or parts.query
|
||||
or parts.fragment
|
||||
or parts.username
|
||||
or parts.password
|
||||
or not re.fullmatch(r"[a-z0-9][a-z0-9.-]*[a-z0-9]", parts.hostname)
|
||||
or parts.port not in (None, 443)
|
||||
):
|
||||
raise DeliveryError("explicit HTTPS origin required")
|
||||
try:
|
||||
worker = uuid.UUID(value["worker_id"])
|
||||
except (ValueError, TypeError, AttributeError) as error:
|
||||
raise DeliveryError("invalid worker identity") from error
|
||||
if str(worker) != value["worker_id"] or worker.int == 0:
|
||||
raise DeliveryError("canonical nonzero worker UUID required")
|
||||
if value["measurement_nonce"] is not None:
|
||||
sha256(value["measurement_nonce"])
|
||||
return value
|
||||
|
||||
|
||||
def connections(path: Path) -> dict:
|
||||
private(path)
|
||||
return validate_connections(read_json(path, 16384))
|
||||
|
||||
|
||||
def validate_connections(value: dict) -> dict:
|
||||
value = fields(value, {"schema_version", "database", "amqp"}, "service connections")
|
||||
if type(value["schema_version"]) is not int or value["schema_version"] != 1:
|
||||
raise DeliveryError("unsupported service connections")
|
||||
database = fields(value["database"], set(PROFILES), "database connections")
|
||||
passwords = set()
|
||||
for name, raw in database.items():
|
||||
if not isinstance(raw, str) or len(raw) > 1024:
|
||||
raise DeliveryError("invalid database connection")
|
||||
parts = urlsplit(raw)
|
||||
unix = (
|
||||
name == "import"
|
||||
and parts.hostname is None
|
||||
and parse_qs(parts.query, strict_parsing=True) == {"host": ["/run/postgresql"]}
|
||||
)
|
||||
network = (
|
||||
name != "import"
|
||||
and parts.hostname == "127.0.0.1"
|
||||
and parts.port == 5432
|
||||
and not parts.query
|
||||
)
|
||||
if (
|
||||
parts.scheme != "postgresql"
|
||||
or parts.username != PROFILES[name]
|
||||
or not re.fullmatch(r"[A-Za-z0-9_-]{32,128}", parts.password or "")
|
||||
or parts.path != "/kf_onlinejudge"
|
||||
or parts.fragment
|
||||
or not (unix or network)
|
||||
):
|
||||
raise DeliveryError("database authority or loopback/socket policy mismatch")
|
||||
if parts.password in passwords:
|
||||
raise DeliveryError("service credentials must be independent")
|
||||
passwords.add(parts.password)
|
||||
broker = fields(value["amqp"], {"outbox", "worker"}, "broker connections")
|
||||
for name, raw in broker.items():
|
||||
if not isinstance(raw, str) or len(raw) > 1024:
|
||||
raise DeliveryError("invalid broker connection")
|
||||
parts = urlsplit(raw)
|
||||
if (
|
||||
parts.scheme != "amqp"
|
||||
or parts.hostname != "127.0.0.1"
|
||||
or parts.port != 5672
|
||||
or parts.username != "kf_" + name
|
||||
or not re.fullmatch(r"[A-Za-z0-9_-]{32,128}", parts.password or "")
|
||||
or parts.path != "/kf"
|
||||
or parts.query
|
||||
or parts.fragment
|
||||
):
|
||||
raise DeliveryError("broker authority or loopback policy mismatch")
|
||||
if parts.password in passwords:
|
||||
raise DeliveryError("service credentials must be independent")
|
||||
passwords.add(parts.password)
|
||||
return value
|
||||
|
||||
|
||||
def environmental(values: dict) -> bytes:
|
||||
"""JSON strings are valid systemd EnvironmentFile double-quoted values here.
|
||||
|
||||
Input URLs/passwords cannot contain dollar/backtick/newline expansions;
|
||||
only trusted JSON admission data and known fixed paths appear otherwise.
|
||||
"""
|
||||
return "".join(
|
||||
key + "=" + json.dumps(value, separators=(",", ":")) + "\n"
|
||||
for key, value in sorted(values.items())
|
||||
).encode()
|
||||
|
||||
|
||||
def measured_hardware() -> dict:
|
||||
value = preflight.hardware()
|
||||
# Operator command cgroup does not define execution scheduling. The fixed
|
||||
# execution/supervisor budgets below are bound separately and read back by
|
||||
# Sandbox; no transient installer PID/unit name is part of the epoch.
|
||||
value.pop("parent_limits")
|
||||
# Bind stable node-wide scheduling and ancestor constraints too. Per-service
|
||||
# ceilings are in the policy; Sandbox separately verifies their actual
|
||||
# values on every execution. This is not a transient operator unit snapshot.
|
||||
value["node_scheduler"] = {}
|
||||
for label, group in (
|
||||
("root", Path("/sys/fs/cgroup")),
|
||||
("system", Path("/sys/fs/cgroup/system.slice")),
|
||||
):
|
||||
value["node_scheduler"][label] = {
|
||||
name: (group / name).read_text().strip() if (group / name).exists() else None
|
||||
for name in ("cpu.max", "memory.max", "cpuset.cpus.effective")
|
||||
}
|
||||
return value
|
||||
|
||||
|
||||
def admission_runtime(payload: Path, data: dict) -> tuple[dict, dict]:
|
||||
from kf_oj.contracts.judging_v2 import AdmissionRegistry
|
||||
|
||||
admission = read_json(payload / "deploy/judge.admission.example.json", 16384)
|
||||
root = read_json(payload / "deploy/sandbox.managed.runtime.example.json", 16384)
|
||||
for cap in admission["multi_runtime"]["capabilities"]:
|
||||
image = {"c17": "gnu", "cpp20": "gnu", "python313": "python313", "java21": "java21"}[
|
||||
cap["language"]
|
||||
]
|
||||
cap["toolchain"] = data["images"][image]
|
||||
admission = AdmissionRegistry.model_validate(admission).model_dump(mode="json")
|
||||
state = "/var/lib/kf-judge/images/" + data["release_id"]
|
||||
root["image_root"] = state + "/legacy-gnu"
|
||||
root["delegated_cgroup_root"] = "/sys/fs/cgroup/kf-judge-tasks"
|
||||
root["identities"] = {
|
||||
"worker_uid": preflight.ACCOUNTS["kf-worker"],
|
||||
"worker_gid": preflight.ACCOUNTS["kf-worker"],
|
||||
"first_task_uid": preflight.TASK_UID,
|
||||
"first_task_gid": preflight.TASK_GID,
|
||||
"count": 1,
|
||||
}
|
||||
for image, entry in zip(("gnu", "python313", "java21"), root["runtime_images"], strict=True):
|
||||
entry["image_root"] = state + "/" + image
|
||||
entry["runtime_identity"] = data["images"][image]
|
||||
if root["max_concurrent_runs"] != 1:
|
||||
raise DeliveryError("single-slot configuration required")
|
||||
return admission, root
|
||||
|
||||
|
||||
def compose(
|
||||
payload: Path,
|
||||
data: dict,
|
||||
node: dict,
|
||||
endpoints: dict,
|
||||
*,
|
||||
attest: bool,
|
||||
service_tokens: tuple[str, str] | None = None,
|
||||
) -> dict:
|
||||
"""Caller has verified the full delivery inventory before this composition."""
|
||||
admission, root = admission_runtime(payload, data)
|
||||
measurement = {
|
||||
"schema_version": 1,
|
||||
"hardware": measured_hardware(),
|
||||
"source_sha": data["source_sha"],
|
||||
"images": data["images"],
|
||||
"admission": admission,
|
||||
"sandbox_budget": root["aggregate"],
|
||||
"measurement_nonce": node["measurement_nonce"],
|
||||
"worker_id": node["worker_id"],
|
||||
}
|
||||
epoch = None
|
||||
if attest:
|
||||
if node["measurement_nonce"] is None:
|
||||
raise DeliveryError("explicit fresh measurement nonce required for attestation")
|
||||
epoch = hashlib.sha256(canonical(measurement)).hexdigest()
|
||||
worker_token, platform_token = service_tokens or (secrets.token_hex(32), secrets.token_hex(32))
|
||||
coordinator = {
|
||||
"listen": "127.0.0.1:8100",
|
||||
"database_url": endpoints["database"]["coordinator"],
|
||||
"workers": [{"id": node["worker_id"], "token": worker_token, "measurement_epoch": epoch}],
|
||||
"platform_token": platform_token,
|
||||
"admission": admission,
|
||||
"lease_seconds": 120,
|
||||
"recovery_seconds": 5,
|
||||
}
|
||||
worker = {
|
||||
"worker_id": node["worker_id"],
|
||||
"service_token": worker_token,
|
||||
"coordinator_url": "http://127.0.0.1:8100",
|
||||
"platform_url": "http://127.0.0.1:8000",
|
||||
"amqp_url": endpoints["amqp"]["worker"],
|
||||
"socket": "/var/lib/kf-judge/factory/control.sock",
|
||||
"staging": "/var/lib/kf-onlinejudge/worker",
|
||||
"admission": admission,
|
||||
"control_timeout_ms": 5000,
|
||||
}
|
||||
common = {
|
||||
"KF_OJ_ENVIRONMENT": "production",
|
||||
"KF_OJ_LOG_LEVEL": "INFO",
|
||||
"KF_OJ_BROWSER_ORIGINS": canonical([node["origin"]]).decode().strip(),
|
||||
"KF_OJ_ARTIFACT_ROOT": "/var/lib/kf-onlinejudge/artifacts",
|
||||
"KF_OJ_IMPORT_STAGING_ROOT": "/var/lib/kf-onlinejudge/imports",
|
||||
"KF_OJ_ARTIFACT_MAX_BYTES": "536870912",
|
||||
"KF_OJ_ARTIFACT_MAX_FILES": "4096",
|
||||
}
|
||||
|
||||
def backend_url(name):
|
||||
return endpoints["database"][name].replace("postgresql:", "postgresql+asyncpg:", 1)
|
||||
|
||||
api = {
|
||||
**common,
|
||||
"KF_OJ_DATABASE_URL": backend_url("api"),
|
||||
"KF_OJ_JUDGE_ADMISSION": canonical(admission).decode().strip(),
|
||||
"KF_OJ_COORDINATOR_URL": "http://127.0.0.1:8100",
|
||||
"KF_OJ_COORDINATOR_TOKEN": platform_token,
|
||||
}
|
||||
files = {
|
||||
"api.env": environmental(api),
|
||||
"outbox.env": environmental(
|
||||
{
|
||||
**common,
|
||||
"KF_OJ_DATABASE_URL": backend_url("api"),
|
||||
"KF_OJ_AMQP_URL": endpoints["amqp"]["outbox"],
|
||||
}
|
||||
),
|
||||
"import.env": environmental(
|
||||
{
|
||||
**common,
|
||||
"KF_OJ_DATABASE_URL": backend_url("import"),
|
||||
"KF_OJ_IMPORT_WORKER_CGROUP": IMPORT_CGROUP,
|
||||
}
|
||||
),
|
||||
"identity.env": environmental(
|
||||
{
|
||||
**common,
|
||||
"KF_OJ_DATABASE_URL": backend_url("api"),
|
||||
"KF_OJ_IDENTITY_OPERATOR_DATABASE_URL": backend_url("identity"),
|
||||
}
|
||||
),
|
||||
"migration.env": environmental({**common, "KF_OJ_DATABASE_URL": backend_url("migration")}),
|
||||
"admission.json": canonical(admission),
|
||||
"runtime.json": canonical(root),
|
||||
"coordinator.json": canonical(coordinator),
|
||||
"worker.json": canonical(worker),
|
||||
"measurement.json": canonical(
|
||||
{"binding": measurement, "epoch": epoch, "operator_attested": attest}
|
||||
),
|
||||
}
|
||||
return files
|
||||
|
||||
|
||||
def validate_receipt(value: dict, *, installed: bool) -> dict:
|
||||
value = fields(
|
||||
value,
|
||||
{
|
||||
"schema_version",
|
||||
"source_sha",
|
||||
"release_id",
|
||||
"manifest_sha256",
|
||||
"configuration_files",
|
||||
"operator_attested_measurement",
|
||||
"installed",
|
||||
"execution_verified",
|
||||
},
|
||||
"configuration 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 value["release_id"] != "sd-" + value["source_sha"][:12]
|
||||
or type(value["operator_attested_measurement"]) is not bool
|
||||
or value["installed"] is not installed
|
||||
or value["execution_verified"] is not False
|
||||
):
|
||||
raise DeliveryError("invalid configuration binding")
|
||||
sha256(value["manifest_sha256"])
|
||||
names = CONFIG_FILES
|
||||
if installed:
|
||||
names = {installed_path(value["release_id"], name).as_posix() for name in CONFIG_FILES}
|
||||
fields(value["configuration_files"], names, "configuration inventory")
|
||||
for checksum in value["configuration_files"].values():
|
||||
sha256(checksum)
|
||||
return value
|
||||
|
||||
|
||||
def installed_path(release_id: str, name: str) -> Path:
|
||||
base = "/etc/kf-judge" if name == "runtime.json" else "/etc/kf-onlinejudge"
|
||||
return Path(base) / (release_id + "." + name)
|
||||
|
||||
|
||||
def environment_read(contents: bytes) -> dict:
|
||||
if len(contents) > 32768:
|
||||
raise DeliveryError("environment file exceeds limit")
|
||||
values = {}
|
||||
for line in contents.decode().splitlines():
|
||||
name, sep, raw = line.partition("=")
|
||||
if not sep or not re.fullmatch(r"KF_OJ_[A-Z_]+", name) or name in values:
|
||||
raise DeliveryError("invalid service environment")
|
||||
value = json.loads(raw)
|
||||
if not isinstance(value, str) or "\n" in value or "\r" in value or "\x00" in value:
|
||||
raise DeliveryError("invalid service environment value")
|
||||
values[name] = value
|
||||
return values
|
||||
|
||||
|
||||
def validate_contents(paths: dict[str, Path], payload: Path, data: dict, *, attest: bool) -> None:
|
||||
"""Validate public policy, endpoint roles and epoch binding, without health claims."""
|
||||
if set(paths) != CONFIG_FILES:
|
||||
raise DeliveryError("incomplete service configuration")
|
||||
raw = {}
|
||||
for name, path in paths.items():
|
||||
private(path)
|
||||
if path.stat().st_size > 32768:
|
||||
raise DeliveryError("service configuration exceeds limit")
|
||||
raw[name] = path.read_bytes()
|
||||
envs = {name: environment_read(raw[name]) for name in CONFIG_FILES if name.endswith(".env")}
|
||||
measurement = fields(
|
||||
read_json(paths["measurement.json"], 32768),
|
||||
{"binding", "epoch", "operator_attested"},
|
||||
"measurement record",
|
||||
)
|
||||
binding = fields(
|
||||
measurement["binding"],
|
||||
{
|
||||
"schema_version",
|
||||
"hardware",
|
||||
"source_sha",
|
||||
"images",
|
||||
"admission",
|
||||
"sandbox_budget",
|
||||
"measurement_nonce",
|
||||
"worker_id",
|
||||
},
|
||||
"measurement binding",
|
||||
)
|
||||
origins = json.loads(envs["api.env"].get("KF_OJ_BROWSER_ORIGINS", "null"))
|
||||
if not isinstance(origins, list) or len(origins) != 1:
|
||||
raise DeliveryError("one explicit browser origin required")
|
||||
node = validate_profile(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"origin": origins[0],
|
||||
"worker_id": binding["worker_id"],
|
||||
"measurement_nonce": binding["measurement_nonce"],
|
||||
}
|
||||
)
|
||||
coordinator = fields(
|
||||
read_json(paths["coordinator.json"], 32768),
|
||||
{
|
||||
"listen",
|
||||
"database_url",
|
||||
"workers",
|
||||
"platform_token",
|
||||
"admission",
|
||||
"lease_seconds",
|
||||
"recovery_seconds",
|
||||
},
|
||||
"coordinator configuration",
|
||||
)
|
||||
worker = fields(
|
||||
read_json(paths["worker.json"], 32768),
|
||||
{
|
||||
"worker_id",
|
||||
"service_token",
|
||||
"coordinator_url",
|
||||
"platform_url",
|
||||
"amqp_url",
|
||||
"socket",
|
||||
"staging",
|
||||
"admission",
|
||||
"control_timeout_ms",
|
||||
},
|
||||
"worker configuration",
|
||||
)
|
||||
|
||||
def database_url(name: str, key="KF_OJ_DATABASE_URL"):
|
||||
raw = envs[name].get(key)
|
||||
if not isinstance(raw, str) or not raw.startswith("postgresql+asyncpg:"):
|
||||
raise DeliveryError("backend database driver mismatch")
|
||||
return raw.replace("postgresql+asyncpg:", "postgresql:", 1)
|
||||
|
||||
endpoints = validate_connections(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"database": {
|
||||
"api": database_url("api.env"),
|
||||
"coordinator": coordinator.get("database_url"),
|
||||
"import": database_url("import.env"),
|
||||
"migration": database_url("migration.env"),
|
||||
"identity": database_url("identity.env", "KF_OJ_IDENTITY_OPERATOR_DATABASE_URL"),
|
||||
},
|
||||
"amqp": {
|
||||
"outbox": envs["outbox.env"].get("KF_OJ_AMQP_URL"),
|
||||
"worker": worker.get("amqp_url"),
|
||||
},
|
||||
}
|
||||
)
|
||||
worker_token, platform_token = (
|
||||
sha256(worker.get("service_token")),
|
||||
sha256(coordinator.get("platform_token")),
|
||||
)
|
||||
if worker_token == platform_token:
|
||||
raise DeliveryError("service identities overlap")
|
||||
expected = compose(
|
||||
payload, data, node, endpoints, attest=attest, service_tokens=(worker_token, platform_token)
|
||||
)
|
||||
for name in CONFIG_FILES:
|
||||
# JSON comparisons via canonical bytes distinguish true/1 and null/[];
|
||||
# environmental files must have precisely the generated keys/encoding.
|
||||
if name.endswith(".json"):
|
||||
match = canonical(read_json(paths[name], 32768)) == expected[name]
|
||||
else:
|
||||
match = raw[name] == expected[name]
|
||||
if not match:
|
||||
raise DeliveryError("configuration/identity/measurement policy mismatch")
|
||||
|
||||
|
||||
def check_installed(receipt_path: Path) -> dict:
|
||||
from .install import RELEASES
|
||||
|
||||
preflight.protected(receipt_path)
|
||||
private(receipt_path)
|
||||
receipt = validate_receipt(read_json(receipt_path, 16384), installed=True)
|
||||
release = RELEASES / receipt["release_id"]
|
||||
preflight.protected(release, directory=True)
|
||||
data = manifest.verify(release, receipt["manifest_sha256"])
|
||||
if receipt["source_sha"] != data["source_sha"]:
|
||||
raise DeliveryError("configuration source differs from release")
|
||||
paths = {}
|
||||
for name in CONFIG_FILES:
|
||||
path = installed_path(receipt["release_id"], name)
|
||||
preflight.protected(path)
|
||||
if digest(path) != receipt["configuration_files"][path.as_posix()]:
|
||||
raise DeliveryError("installed configuration fingerprint differs")
|
||||
paths[name] = path
|
||||
validate_contents(
|
||||
paths, release / "payload", data, attest=receipt["operator_attested_measurement"]
|
||||
)
|
||||
return receipt
|
||||
|
||||
|
||||
def generate(
|
||||
bundle: Path,
|
||||
expected: str,
|
||||
node_path: Path,
|
||||
connections_path: Path,
|
||||
output: Path,
|
||||
*,
|
||||
attest: bool = False,
|
||||
) -> dict:
|
||||
data = manifest.verify(bundle, expected)
|
||||
node, endpoints = profile(node_path), connections(connections_path)
|
||||
if output.exists() or output.is_symlink():
|
||||
raise DeliveryError("configuration output must be new; secrets are never overwritten")
|
||||
files = compose(bundle / "payload", data, node, endpoints, attest=attest)
|
||||
output.mkdir(mode=0o700)
|
||||
hashes = {}
|
||||
for name, contents in files.items():
|
||||
with (output / name).open("xb") as stream:
|
||||
os.chmod(output / name, 0o600)
|
||||
stream.write(contents)
|
||||
hashes[name] = digest(output / name)
|
||||
receipt = {
|
||||
"schema_version": 1,
|
||||
"source_sha": data["source_sha"],
|
||||
"release_id": data["release_id"],
|
||||
"manifest_sha256": expected,
|
||||
"configuration_files": hashes,
|
||||
"operator_attested_measurement": attest,
|
||||
"installed": False,
|
||||
"execution_verified": False,
|
||||
}
|
||||
(output / "receipt.json").write_bytes(canonical(receipt))
|
||||
(output / "receipt.json").chmod(0o600)
|
||||
return {
|
||||
"configuration_generated": True,
|
||||
"source_sha": data["source_sha"],
|
||||
"receipt_sha256": digest(output / "receipt.json"),
|
||||
"operator_attested_measurement": attest,
|
||||
"installed": False,
|
||||
"execution_verified": False,
|
||||
}
|
||||
|
||||
|
||||
def install_configuration(source: Path, expected_receipt: str) -> dict:
|
||||
from .install import RELEASES, exclusive_file
|
||||
|
||||
if os.geteuid() != 0:
|
||||
raise DeliveryError("configuration installation requires root")
|
||||
preflight.protected(source, directory=True)
|
||||
receipt_path = source / "receipt.json"
|
||||
preflight.protected(receipt_path)
|
||||
if digest(receipt_path) != sha256(expected_receipt):
|
||||
raise DeliveryError("configuration receipt fingerprint differs")
|
||||
receipt = validate_receipt(read_json(receipt_path, 16384), installed=False)
|
||||
data = manifest.verify(RELEASES / receipt["release_id"], receipt["manifest_sha256"])
|
||||
if (
|
||||
receipt["source_sha"] != data["source_sha"]
|
||||
or type(receipt["schema_version"]) is not int
|
||||
or receipt["schema_version"] != 1
|
||||
):
|
||||
raise DeliveryError("configuration source mismatch")
|
||||
if {p.name for p in source.iterdir()} != CONFIG_FILES | {"receipt.json"}:
|
||||
raise DeliveryError("unexpected configuration member")
|
||||
# Validate the entire group before publishing any configuration member.
|
||||
for name, checksum in receipt["configuration_files"].items():
|
||||
preflight.protected(source / name)
|
||||
if digest(source / name) != checksum:
|
||||
raise DeliveryError("configuration bytes differ")
|
||||
validate_contents(
|
||||
{name: source / name for name in CONFIG_FILES},
|
||||
RELEASES / data["release_id"] / "payload",
|
||||
data,
|
||||
attest=receipt["operator_attested_measurement"],
|
||||
)
|
||||
installed = {}
|
||||
for name, checksum in receipt["configuration_files"].items():
|
||||
member = source / name
|
||||
preflight.protected(member)
|
||||
private(member)
|
||||
if digest(member) != sha256(checksum):
|
||||
raise DeliveryError("configuration bytes differ")
|
||||
target = installed_path(data["release_id"], name)
|
||||
exclusive_file(target, member.read_bytes(), 0o600)
|
||||
installed[str(target)] = checksum
|
||||
receipt = {**receipt, "configuration_files": installed, "installed": True}
|
||||
target = Path("/etc/kf-onlinejudge") / (data["release_id"] + ".configuration.json")
|
||||
exclusive_file(target, canonical(receipt), 0o600)
|
||||
return {
|
||||
"configuration_installed": True,
|
||||
"source_sha": data["source_sha"],
|
||||
"receipt_sha256": digest(target),
|
||||
"execution_verified": False,
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Explicit DB identities only; all application schema changes belong to Alembic."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from .configuration import PROFILES, connections, private
|
||||
from .policy import DeliveryError, fields, read_json
|
||||
|
||||
GROUPS = (
|
||||
"kf_oj_platform",
|
||||
"kf_oj_coordinator",
|
||||
"kf_oj_problem_import",
|
||||
"kf_oj_identity_operator",
|
||||
"kf_oj_identity_authority_owner",
|
||||
)
|
||||
MEMBERSHIP = {
|
||||
"kf_platform": "kf_oj_platform",
|
||||
"kf_coordinator": "kf_oj_coordinator",
|
||||
"kf_problem_import": "kf_oj_problem_import",
|
||||
"kf_identity_operator": "kf_oj_identity_operator",
|
||||
"kf_migrator": "kf_oj_identity_authority_owner",
|
||||
}
|
||||
DATABASE = "kf_onlinejudge"
|
||||
SETTINGS = {
|
||||
"statement_timeout": "5000",
|
||||
"lock_timeout": "1000",
|
||||
"idle_in_transaction_session_timeout": "5000",
|
||||
"application_name": "kf-delivery-identities",
|
||||
}
|
||||
|
||||
|
||||
def operator_url(path: Path) -> str:
|
||||
private(path)
|
||||
value = fields(read_json(path, 4096), {"schema_version", "url"}, "DB operator connection")
|
||||
if type(value["schema_version"]) is not int or value["schema_version"] != 1:
|
||||
raise DeliveryError("unsupported DB operator input")
|
||||
raw = value["url"]
|
||||
if not isinstance(raw, str) or len(raw) > 1024:
|
||||
raise DeliveryError("invalid DB operator connection")
|
||||
parts = urlsplit(raw)
|
||||
if (
|
||||
parts.scheme != "postgresql"
|
||||
or parts.hostname != "127.0.0.1"
|
||||
or parts.port != 5432
|
||||
or parts.username != "postgres"
|
||||
or parts.path != "/postgres"
|
||||
or parts.query
|
||||
or parts.fragment
|
||||
or not re.fullmatch(r"[A-Za-z0-9_-]{32,128}", parts.password or "")
|
||||
):
|
||||
raise DeliveryError("explicit local DB operator identity required")
|
||||
return raw
|
||||
|
||||
|
||||
async def _provision(operator: Path, endpoint_path: Path) -> dict:
|
||||
import asyncpg
|
||||
|
||||
endpoints = connections(endpoint_path)
|
||||
db = await asyncpg.connect(
|
||||
operator_url(operator),
|
||||
timeout=3,
|
||||
command_timeout=5,
|
||||
server_settings={**SETTINGS, "log_min_error_statement": "panic"},
|
||||
)
|
||||
try:
|
||||
if not await db.fetchval("SELECT rolsuper FROM pg_roles WHERE rolname=current_user"):
|
||||
raise DeliveryError("explicit local DB identity operator required")
|
||||
for name, expected in (
|
||||
("log_statement", "none"),
|
||||
("log_min_duration_statement", "-1"),
|
||||
("log_min_duration_sample", "-1"),
|
||||
("log_transaction_sample_rate", "0"),
|
||||
):
|
||||
if await db.fetchval("SHOW " + name) != expected:
|
||||
raise DeliveryError("DB statement logging incompatible with secret provisioning")
|
||||
async with db.transaction():
|
||||
for name in (*GROUPS, *PROFILES.values()):
|
||||
login = name in PROFILES.values()
|
||||
row = await db.fetchrow(
|
||||
"SELECT rolcanlogin,rolsuper,rolcreatedb,rolcreaterole,"
|
||||
"rolreplication,rolbypassrls,"
|
||||
"rolconnlimit FROM pg_roles WHERE rolname=$1",
|
||||
name,
|
||||
)
|
||||
wanted = (login, False, False, False, False, False, 8 if login else -1)
|
||||
if row is None:
|
||||
# Names are fixed public constants. The password alphabet
|
||||
# is validated before any utility SQL is constructed.
|
||||
statement = (
|
||||
"CREATE ROLE "
|
||||
+ name
|
||||
+ (" LOGIN" if login else " NOLOGIN")
|
||||
+ " NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION "
|
||||
"NOBYPASSRLS INHERIT"
|
||||
)
|
||||
if login:
|
||||
statement += " CONNECTION LIMIT 8"
|
||||
key = next(key for key, role in PROFILES.items() if role == name)
|
||||
password = urlsplit(endpoints["database"][key]).password
|
||||
statement += " PASSWORD '" + password + "'"
|
||||
await db.execute(statement)
|
||||
elif tuple(row) != wanted:
|
||||
raise DeliveryError("existing DB authority differs; refused overwrite")
|
||||
memberships = [
|
||||
tuple(row)
|
||||
for row in await db.fetch(
|
||||
"SELECT r.rolname,m.admin_option,m.inherit_option,m.set_option "
|
||||
"FROM pg_auth_members m JOIN pg_roles r ON r.oid=m.roleid "
|
||||
"JOIN pg_roles u ON u.oid=m.member WHERE u.rolname=$1",
|
||||
name,
|
||||
)
|
||||
]
|
||||
expected = (
|
||||
[]
|
||||
if not login
|
||||
else [(MEMBERSHIP[name], False, name != "kf_migrator", name == "kf_migrator")]
|
||||
)
|
||||
if memberships and memberships != expected:
|
||||
raise DeliveryError("existing DB role membership differs")
|
||||
for login, group in MEMBERSHIP.items():
|
||||
inherit = "FALSE" if login == "kf_migrator" else "TRUE"
|
||||
set_role = "TRUE" if login == "kf_migrator" else "FALSE"
|
||||
await db.execute(
|
||||
f"GRANT {group} TO {login} WITH ADMIN FALSE, INHERIT {inherit}, SET {set_role}"
|
||||
)
|
||||
existing = await db.fetchval(
|
||||
"SELECT r.rolname FROM pg_database d JOIN pg_roles r ON r.oid=d.datdba "
|
||||
"WHERE d.datname=$1",
|
||||
DATABASE,
|
||||
)
|
||||
if existing is None:
|
||||
await db.execute(
|
||||
"CREATE DATABASE kf_onlinejudge OWNER kf_migrator "
|
||||
"TEMPLATE template0 ENCODING 'UTF8'"
|
||||
)
|
||||
elif existing != "kf_migrator":
|
||||
raise DeliveryError("existing application database owner differs")
|
||||
finally:
|
||||
await db.close(timeout=3)
|
||||
verified = []
|
||||
for name, raw in endpoints["database"].items():
|
||||
session = await asyncpg.connect(raw, timeout=3, command_timeout=5, server_settings=SETTINGS)
|
||||
try:
|
||||
row = await session.fetchrow("SELECT current_user,current_database()")
|
||||
if tuple(row) != (PROFILES[name], DATABASE):
|
||||
raise DeliveryError("DB identity readback differs")
|
||||
verified.append(name)
|
||||
finally:
|
||||
await session.close(timeout=3)
|
||||
return {
|
||||
"database_identities_provisioned": sorted(verified),
|
||||
"application_schema_migrated": False,
|
||||
"application_accounts_created": False,
|
||||
"secrets_reset": False,
|
||||
}
|
||||
|
||||
|
||||
def provision(operator: Path, endpoint_path: Path) -> dict:
|
||||
"""No table DDL, no secret reset and no automatic application initialization.
|
||||
|
||||
Pre-created NOLOGIN groups allow the unchanged Backend Alembic migrations
|
||||
to run under a separate non-superuser database owner. Only this migration
|
||||
login can SET the authority-function owner; ordinary logins inherit their
|
||||
own one group and cannot grant it. Unexpected existing authority is refused.
|
||||
"""
|
||||
if os.geteuid() != 0:
|
||||
raise DeliveryError("DB provisioning requires the root operator")
|
||||
import asyncpg
|
||||
|
||||
try:
|
||||
return asyncio.run(asyncio.wait_for(_provision(operator, endpoint_path), timeout=120))
|
||||
except (asyncpg.PostgresError, asyncpg.InterfaceError, OSError, TimeoutError) as error:
|
||||
# A server error may include utility SQL; never format it or its query.
|
||||
raise DeliveryError("DB identity provisioning/readback refused") from error
|
||||
@@ -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,
|
||||
}
|
||||
@@ -24,7 +24,7 @@ from .policy import (
|
||||
)
|
||||
|
||||
|
||||
def inventory(root: Path) -> list[dict]:
|
||||
def inventory(root: Path, *, path_prefix: str = "") -> list[dict]:
|
||||
"""No links/devices or inferred inclusion; bounds apply before hashing."""
|
||||
if not root.is_dir() or root.is_symlink():
|
||||
raise DeliveryError("payload root must be an ordinary directory")
|
||||
@@ -50,7 +50,8 @@ def inventory(root: Path) -> list[dict]:
|
||||
total += size
|
||||
if total > MAX_BUNDLE_BYTES or len(entries) >= MAX_FILES:
|
||||
raise DeliveryError("payload aggregate limit")
|
||||
relative = relative_name(path.relative_to(root).as_posix())
|
||||
relative = path.relative_to(root).as_posix()
|
||||
relative_name(path_prefix + relative)
|
||||
entries.append(
|
||||
{
|
||||
"path": relative,
|
||||
@@ -62,13 +63,13 @@ def inventory(root: Path) -> list[dict]:
|
||||
return sorted(entries, key=lambda x: x["path"])
|
||||
|
||||
|
||||
def directories(root: Path) -> list[str]:
|
||||
def directories(root: Path, *, path_prefix: str = "") -> list[str]:
|
||||
result = []
|
||||
for directory, names, _ in os.walk(root, followlinks=False):
|
||||
for name in names:
|
||||
path = Path(directory) / name
|
||||
value = path.relative_to(root).as_posix()
|
||||
relative_name(value + "/.__directory__")
|
||||
relative_name(path_prefix + value + "/.__directory__")
|
||||
if path.is_symlink() or stat.S_IMODE(path.stat().st_mode) != 0o555:
|
||||
raise DeliveryError("payload directories must be read-only ordinary directories")
|
||||
result.append(value)
|
||||
|
||||
@@ -13,7 +13,17 @@ MAX_BUNDLE_BYTES = 4 * 1024**3
|
||||
FORMAT = "kf.single-node.directory.v1"
|
||||
PLATFORM = "linux_x86_64_v3_glibc244_systemd"
|
||||
TOOLCHAINS = {"python": "3.13.16", "rust": "1.99.0", "node": "26.10.0", "pnpm": "11.28.4"}
|
||||
ROOTS = ("backend/", "python/", "site/", "lib/", "bin/", "frontend/", "images/", "deploy/")
|
||||
ROOTS = (
|
||||
"backend/",
|
||||
"python/",
|
||||
"site/",
|
||||
"lib/",
|
||||
"bin/",
|
||||
"frontend/",
|
||||
"images/",
|
||||
"deploy/",
|
||||
"delivery/",
|
||||
)
|
||||
FORBIDDEN = {".git", ".localplan", "docs", "__pycache__", ".env", ".ssh"}
|
||||
|
||||
|
||||
@@ -73,11 +83,27 @@ def read_json(path: Path, maximum: int = MAX_METADATA_BYTES) -> dict:
|
||||
if len(data) > maximum:
|
||||
raise DeliveryError("metadata size limit")
|
||||
try:
|
||||
return json.loads(data, object_pairs_hook=unique_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as error:
|
||||
value = json.loads(data, object_pairs_hook=unique_object, parse_constant=invalid_constant)
|
||||
pending = [(value, 0)]
|
||||
count = 0
|
||||
while pending:
|
||||
item, depth = pending.pop()
|
||||
count += 1
|
||||
if depth > 32 or count > 400000:
|
||||
raise DeliveryError("metadata structure limit")
|
||||
if isinstance(item, dict):
|
||||
pending.extend((member, depth + 1) for member in item.values())
|
||||
elif isinstance(item, list):
|
||||
pending.extend((member, depth + 1) for member in item)
|
||||
return value
|
||||
except (UnicodeError, json.JSONDecodeError, RecursionError) as error:
|
||||
raise DeliveryError("invalid JSON metadata") from error
|
||||
|
||||
|
||||
def invalid_constant(_value: str):
|
||||
raise DeliveryError("nonfinite JSON value refused")
|
||||
|
||||
|
||||
def canonical(value: Any) -> bytes:
|
||||
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
"""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, *, 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")
|
||||
observations["disk_free_bytes"] = shutil.disk_usage("/var/lib").free
|
||||
if observations["disk_free_bytes"] < 12 * GIB:
|
||||
errors.append("installation/data disk reserve below 12 GiB")
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user