feat: preserve immutable configuration batches for controlled rotation
This commit is contained in:
@@ -51,6 +51,12 @@ 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.
|
||||
Every private group also has a content-bound `configuration_id`. Installing a
|
||||
second group for the same source preserves the first group byte-for-byte.
|
||||
`activate RELEASE --manifest-sha256 SHA --configuration-id BATCH` selects an
|
||||
explicit validated batch only after ordinary application units and independent
|
||||
task resources are stopped. `check-active` checks the protected batch/code
|
||||
binding and current measurement snapshot, with no liveness/readiness claim.
|
||||
|
||||
`provision-database-identities` is an explicit local database operator step.
|
||||
It uses the locked asyncpg dependency, bounded connections/statements and a
|
||||
|
||||
@@ -48,6 +48,8 @@ def main() -> None:
|
||||
check = sub.add_parser("activate")
|
||||
check.add_argument("release_id")
|
||||
check.add_argument("--manifest-sha256", required=True)
|
||||
check.add_argument("--configuration-id", required=True)
|
||||
sub.add_parser("check-active")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.command == "contract":
|
||||
@@ -96,8 +98,14 @@ def main() -> None:
|
||||
}
|
||||
elif args.command == "provision-database-identities":
|
||||
result = database.provision(args.operator, args.connections)
|
||||
elif args.command == "check-active":
|
||||
result = {
|
||||
**install.selection(),
|
||||
"configuration_valid": True,
|
||||
"execution_verified": False,
|
||||
}
|
||||
else:
|
||||
result = install.activate(args.release_id, args.manifest_sha256)
|
||||
result = install.activate(args.release_id, args.manifest_sha256, args.configuration_id)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
except (ValueError, OSError, subprocess.SubprocessError) as error:
|
||||
# OSError filenames can include private operator paths; do not echo them.
|
||||
|
||||
@@ -317,6 +317,7 @@ def validate_receipt(value: dict, *, installed: bool) -> dict:
|
||||
"schema_version",
|
||||
"source_sha",
|
||||
"release_id",
|
||||
"configuration_id",
|
||||
"manifest_sha256",
|
||||
"configuration_files",
|
||||
"operator_attested_measurement",
|
||||
@@ -331,6 +332,8 @@ def validate_receipt(value: dict, *, installed: bool) -> dict:
|
||||
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 not isinstance(value["configuration_id"], str)
|
||||
or not re.fullmatch(r"cfg-[0-9a-f]{24}", value["configuration_id"])
|
||||
or type(value["operator_attested_measurement"]) is not bool
|
||||
or value["installed"] is not installed
|
||||
or value["execution_verified"] is not False
|
||||
@@ -339,16 +342,39 @@ def validate_receipt(value: dict, *, installed: bool) -> dict:
|
||||
sha256(value["manifest_sha256"])
|
||||
names = CONFIG_FILES
|
||||
if installed:
|
||||
names = {installed_path(value["release_id"], name).as_posix() for name in CONFIG_FILES}
|
||||
names = {
|
||||
installed_path(value["release_id"], value["configuration_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)
|
||||
hashes = {
|
||||
name: value["configuration_files"][
|
||||
installed_path(value["release_id"], value["configuration_id"], name).as_posix()
|
||||
if installed
|
||||
else name
|
||||
]
|
||||
for name in CONFIG_FILES
|
||||
}
|
||||
if value["configuration_id"] != configuration_id(
|
||||
value["source_sha"], value["manifest_sha256"], hashes
|
||||
):
|
||||
raise DeliveryError("configuration batch identity mismatch")
|
||||
return value
|
||||
|
||||
|
||||
def installed_path(release_id: str, name: str) -> Path:
|
||||
def configuration_id(source_sha: str, manifest_sha: str, hashes: dict) -> str:
|
||||
return "cfg-" + hashlib.sha256(canonical([source_sha, manifest_sha, hashes])).hexdigest()[:24]
|
||||
|
||||
|
||||
def installed_receipt_path(release_id: str, batch: str) -> Path:
|
||||
return Path("/etc/kf-onlinejudge") / (release_id + "." + batch + ".configuration.json")
|
||||
|
||||
|
||||
def installed_path(release_id: str, batch: str, name: str) -> Path:
|
||||
base = "/etc/kf-judge" if name == "runtime.json" else "/etc/kf-onlinejudge"
|
||||
return Path(base) / (release_id + "." + name)
|
||||
return Path(base) / (release_id + "." + batch + "." + name)
|
||||
|
||||
|
||||
def environment_read(contents: bytes) -> dict:
|
||||
@@ -491,7 +517,7 @@ def check_installed(receipt_path: Path) -> dict:
|
||||
raise DeliveryError("configuration source differs from release")
|
||||
paths = {}
|
||||
for name in CONFIG_FILES:
|
||||
path = installed_path(receipt["release_id"], name)
|
||||
path = installed_path(receipt["release_id"], receipt["configuration_id"], name)
|
||||
preflight.protected(path)
|
||||
if digest(path) != receipt["configuration_files"][path.as_posix()]:
|
||||
raise DeliveryError("installed configuration fingerprint differs")
|
||||
@@ -527,6 +553,7 @@ def generate(
|
||||
"schema_version": 1,
|
||||
"source_sha": data["source_sha"],
|
||||
"release_id": data["release_id"],
|
||||
"configuration_id": configuration_id(data["source_sha"], expected, hashes),
|
||||
"manifest_sha256": expected,
|
||||
"configuration_files": hashes,
|
||||
"operator_attested_measurement": attest,
|
||||
@@ -538,6 +565,7 @@ def generate(
|
||||
return {
|
||||
"configuration_generated": True,
|
||||
"source_sha": data["source_sha"],
|
||||
"configuration_id": receipt["configuration_id"],
|
||||
"receipt_sha256": digest(output / "receipt.json"),
|
||||
"operator_attested_measurement": attest,
|
||||
"installed": False,
|
||||
@@ -583,15 +611,16 @@ def install_configuration(source: Path, expected_receipt: str) -> dict:
|
||||
private(member)
|
||||
if digest(member) != sha256(checksum):
|
||||
raise DeliveryError("configuration bytes differ")
|
||||
target = installed_path(data["release_id"], name)
|
||||
target = installed_path(data["release_id"], receipt["configuration_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")
|
||||
target = installed_receipt_path(data["release_id"], receipt["configuration_id"])
|
||||
exclusive_file(target, canonical(receipt), 0o600)
|
||||
return {
|
||||
"configuration_installed": True,
|
||||
"source_sha": data["source_sha"],
|
||||
"configuration_id": receipt["configuration_id"],
|
||||
"receipt_sha256": digest(target),
|
||||
"execution_verified": False,
|
||||
}
|
||||
|
||||
+110
-13
@@ -9,11 +9,21 @@ import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from . import manifest, preflight
|
||||
from .policy import DeliveryError, canonical, digest, read_json, sha256
|
||||
from .policy import DeliveryError, canonical, digest, fields, read_json, sha256
|
||||
|
||||
RELEASES = Path("/opt/kf-onlinejudge/releases")
|
||||
CURRENT = Path("/opt/kf-onlinejudge/current")
|
||||
IMAGES = Path("/var/lib/kf-judge/images")
|
||||
ACTIVE = Path("/etc/kf-onlinejudge/active.json")
|
||||
APPLICATION_UNITS = (
|
||||
"kf-api.service",
|
||||
"kf-coordinator.service",
|
||||
"kf-outbox.service",
|
||||
"kf-problem-import.service",
|
||||
"kf-worker.service",
|
||||
"kf-sandbox-factory.service",
|
||||
"kf-proxy.service",
|
||||
)
|
||||
LEGACY_HEADER = {"image_kind": "linux_gnu_cxx_x86_64_v1", "architecture": "x86_64", "revision": 1}
|
||||
|
||||
|
||||
@@ -281,26 +291,102 @@ def files(bundle: Path, expected: str) -> dict:
|
||||
return receipt
|
||||
|
||||
|
||||
def activate(release_id: str, expected: str) -> dict:
|
||||
def selection(*, check_configuration: bool = True) -> dict:
|
||||
"""One bounded protected binding; no process or health claim."""
|
||||
from .configuration import check_installed, installed_receipt_path, private, validate_receipt
|
||||
|
||||
preflight.protected(ACTIVE)
|
||||
private(ACTIVE)
|
||||
value = fields(
|
||||
read_json(ACTIVE, 4096),
|
||||
{"schema_version", "release_id", "manifest_sha256", "configuration_id", "receipt_sha256"},
|
||||
"active binding",
|
||||
)
|
||||
if (
|
||||
type(value["schema_version"]) is not int
|
||||
or value["schema_version"] != 1
|
||||
or not isinstance(value["release_id"], str)
|
||||
or not re.fullmatch(r"sd-[0-9a-f]{12}", value["release_id"])
|
||||
or not isinstance(value["configuration_id"], str)
|
||||
or not re.fullmatch(r"cfg-[0-9a-f]{24}", value["configuration_id"])
|
||||
):
|
||||
raise DeliveryError("invalid active release/configuration binding")
|
||||
sha256(value["manifest_sha256"])
|
||||
sha256(value["receipt_sha256"])
|
||||
receipt = installed_receipt_path(value["release_id"], value["configuration_id"])
|
||||
preflight.protected(receipt)
|
||||
private(receipt)
|
||||
if digest(receipt) != value["receipt_sha256"]:
|
||||
raise DeliveryError("active configuration receipt differs")
|
||||
binding = validate_receipt(read_json(receipt, 16384), installed=True)
|
||||
if any(
|
||||
binding[key] != value[key] for key in ("release_id", "configuration_id", "manifest_sha256")
|
||||
):
|
||||
raise DeliveryError("active configuration identity differs")
|
||||
target = RELEASES / value["release_id"] / "payload"
|
||||
info = CURRENT.lstat()
|
||||
if not stat.S_ISLNK(info.st_mode) or info.st_uid != 0 or os.readlink(CURRENT) != str(target):
|
||||
raise DeliveryError("active code pointer differs from configuration binding")
|
||||
preflight.protected(target, directory=True)
|
||||
if check_configuration:
|
||||
check_installed(receipt)
|
||||
return value
|
||||
|
||||
|
||||
def require_stopped() -> None:
|
||||
"""Do not select different private identities while any app unit is alive."""
|
||||
for unit in APPLICATION_UNITS:
|
||||
result = subprocess.run(
|
||||
["/usr/bin/systemctl", "show", unit, "--property=LoadState,ActiveState,MainPID"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=False,
|
||||
env={"PATH": "/usr/bin", "LC_ALL": "C"},
|
||||
)
|
||||
if len(result.stdout) > 4096:
|
||||
raise DeliveryError("application stop observation exceeds limit")
|
||||
values = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line)
|
||||
if (
|
||||
result.returncode
|
||||
or values.get("LoadState") not in {"loaded", "not-found"}
|
||||
or values.get("ActiveState") != "inactive"
|
||||
or values.get("MainPID") != "0"
|
||||
):
|
||||
raise DeliveryError("all application units must be stopped before selection")
|
||||
delegated = Path("/sys/fs/cgroup/kfonlinejudge.slice/kf-judge-tasks")
|
||||
if delegated.exists():
|
||||
if delegated.is_symlink() or (delegated / "cgroup.procs").read_text().strip():
|
||||
raise DeliveryError("delegated execution processes remain")
|
||||
if any(path.is_dir() for path in delegated.iterdir()):
|
||||
raise DeliveryError("delegated task groups remain; cleanup proof required")
|
||||
|
||||
|
||||
def activate(release_id: str, expected: str, batch: 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):
|
||||
if (
|
||||
os.geteuid() != 0
|
||||
or not re.fullmatch(r"sd-[0-9a-f]{12}", release_id)
|
||||
or not re.fullmatch(r"cfg-[0-9a-f]{24}", batch)
|
||||
):
|
||||
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
|
||||
from .configuration import check_installed, installed_receipt_path
|
||||
|
||||
config_receipt = installed_receipt_path(release_id, batch)
|
||||
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")
|
||||
require_stopped()
|
||||
for port in (8000, 8100):
|
||||
import socket
|
||||
|
||||
@@ -317,20 +403,31 @@ def activate(release_id: str, expected: str) -> dict:
|
||||
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)
|
||||
if ACTIVE.exists() or ACTIVE.is_symlink() or CURRENT.exists() or CURRENT.is_symlink():
|
||||
# Retiring a byte-bound old batch is allowed after a kernel change.
|
||||
# It is never accepted for execution under that changed environment.
|
||||
selection(check_configuration=False)
|
||||
active = {
|
||||
"schema_version": 1,
|
||||
"release_id": release_id,
|
||||
"manifest_sha256": expected,
|
||||
"configuration_id": batch,
|
||||
"receipt_sha256": digest(config_receipt),
|
||||
}
|
||||
incoming = ACTIVE.parent / (".active-" + secrets.token_hex(16))
|
||||
exclusive_file(incoming, canonical(active), 0o600)
|
||||
temporary = CURRENT.parent / (".current-" + secrets.token_hex(16))
|
||||
temporary.symlink_to(release / "payload")
|
||||
os.replace(temporary, CURRENT)
|
||||
sync_directory(CURRENT.parent)
|
||||
os.replace(incoming, ACTIVE)
|
||||
sync_directory(ACTIVE.parent)
|
||||
# A power loss between the two atomic writes leaves a detectable binding
|
||||
# mismatch. No service is started and check-active refuses that state.
|
||||
selection()
|
||||
return {
|
||||
"selected_source_sha": data["source_sha"],
|
||||
"configuration_id": batch,
|
||||
"activated": True,
|
||||
"services_started": False,
|
||||
"execution_verified": False,
|
||||
|
||||
Reference in New Issue
Block a user