coulomb-social/scripts/backup_install.py
tegwick 7298b7f616 Prepare reversible backup source installer primitives
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
2026-10-31 11:19:01 +01:00

301 lines
13 KiB
Python

"""Verified source-bundle installation primitives; no CLI, capture or timer start.
The supervised caller must hold backup/snapshot locks and verify disabled units
and the admitted route/profile. This module confines file writes and preserves
preimages; it does not grant that caller authority or activate a schedule.
"""
from __future__ import annotations
import hashlib
import io
import json
import os
import stat
import tarfile
import tempfile
from pathlib import Path
CONSUMER = (
"paired-backup.py", "snapshot-content-and-db.py", "mirror-paired-backup.py",
"check-paired-backup-health.py", "backup_capacity.py", "mirror-platform-runtime.json",
"backup_alerts.py", "notify-backup-alert.py", "observe-backup-alerts.py",
)
PLATFORM = (
"application_recovery_identities.py", "application_recovery_mirror.py",
"application_recovery_resources.py", "primary_application_archive.py",
)
UNITS = (
"coulomb-social-backup-freshness.service", "coulomb-social-paired-backup.service",
"coulomb-social-backup-freshness.timer", "coulomb-social-paired-backup.timer",
"coulomb-social-backup-alert@.service",
)
TARGETS = {**{"consumer/" + n: "/opt/coulomb-social-backup/" + n for n in CONSUMER},
**{"platform/" + n: "/opt/railiance-platform/scripts/" + n for n in PLATFORM},
**{"units/" + n: "/etc/systemd/system/" + n for n in UNITS},
"configuration/alerting.env": "/etc/coulomb-social-backup/alerting.env",
"evidence/backup-primary.lock": None,
"evidence/alertmanager-config.pending.json": None}
class Refused(ValueError):
pass
def sha(data):
return hashlib.sha256(data).hexdigest()
def read_bundle(data, archive_sha256, manifest_sha256):
if len(data) > 2 * 1024 * 1024 or sha(data) != archive_sha256:
raise Refused("archive_digest_or_size")
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
members = archive.getmembers()
names = [m.name for m in members]
if (len(set(names)) != len(names) or set(names) != set(TARGETS) | {"manifest.json"}
or any(not m.isfile() or not 0 < m.size <= 512 * 1024 for m in members)):
raise Refused("unexpected_archive_member")
contents = {m.name: archive.extractfile(m).read() for m in members}
if sha(contents["manifest.json"]) != manifest_sha256:
raise Refused("manifest_digest")
manifest = json.loads(contents.pop("manifest.json"))
if manifest.get("schema") != "coulomb.backup-operating-source-bundle.v1":
raise Refused("manifest_schema")
files = manifest["files"]
if len(files) != len(TARGETS) or {f["member"] for f in files} != set(TARGETS):
raise Refused("manifest_members")
for entry in files:
name = entry["member"]
if entry["target"] != TARGETS[name]:
raise Refused("target_outside_reviewed_paths")
if entry["target"] and entry["mode"] != ("0644" if name.startswith("units/") else "0600"):
raise Refused("unexpected_target_mode")
if entry["sha256"] != sha(contents[name]) or entry["bytes"] != len(contents[name]):
raise Refused("member_digest_or_size")
return manifest, contents
def path_for(root, target):
if target not in TARGETS.values() or target is None:
raise Refused("target_outside_reviewed_paths")
return root / target.lstrip("/")
def trusted_parents(root, path):
current = path.parent
while True:
if current.exists() or current.is_symlink():
info = current.lstat()
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid()
or info.st_mode & 0o022):
raise Refused("unsafe_target_parent")
if current == root:
break
if current == current.parent:
raise Refused("target_not_under_root")
current = current.parent
def fingerprint(path):
if not path.exists() and not path.is_symlink():
return {"exists": False}
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
with os.fdopen(fd, "rb") as stream:
info = os.fstat(stream.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_nlink != 1:
raise Refused("unsafe_target_file")
data = stream.read(512 * 1024 + 1)
after = os.fstat(stream.fileno())
if len(data) > 512 * 1024 or (info.st_size, info.st_mtime_ns) != (
after.st_size, after.st_mtime_ns):
raise Refused("target_size_or_read_race")
return {"exists": True, "sha256": sha(data), "mode": stat.S_IMODE(info.st_mode),
"uid": info.st_uid, "gid": info.st_gid, "inode": info.st_ino,
"device": info.st_dev, "mtime_ns": info.st_mtime_ns}
def observe(root=Path("/")):
result = {}
for target in TARGETS.values():
if target is not None:
path = path_for(root, target)
trusted_parents(root, path)
result[target] = fingerprint(path)
return result
def sync_directory(path):
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(fd)
finally:
os.close(fd)
def journal(directory, record):
fd, name = tempfile.mkstemp(prefix=".journal-", dir=directory)
with os.fdopen(fd, "w") as stream:
json.dump(record, stream, indent=2)
stream.flush()
os.fsync(stream.fileno())
os.replace(name, directory / "receipt.json")
sync_directory(directory)
def stage(path, data, mode, gid=None):
fd, name = tempfile.mkstemp(prefix=".coulomb-install-", dir=path.parent)
try:
with os.fdopen(fd, "wb") as stream:
stream.write(data)
if gid is not None:
os.fchown(stream.fileno(), os.geteuid(), gid)
os.fchmod(stream.fileno(), mode)
stream.flush()
os.fsync(stream.fileno())
except BaseException:
Path(name).unlink(missing_ok=True)
raise
return Path(name)
def restore(root, directory, record, reload_manager):
"""Explicit rollback: refuse interference before undoing any installed file."""
if len({c["target"] for c in record["changes"]}) != len(record["changes"]):
raise Refused("duplicate_rollback_target")
for item in record["changes"]:
path = path_for(root, item["target"])
trusted_parents(root, path)
preimage = item["preimage"]
if not preimage.endswith(".before") or not preimage.removesuffix(".before").isdecimal():
raise Refused("invalid_preimage_path")
for key in ("temporary", "rollback_temporary"):
if key in item:
temporary = Path(item[key])
if (temporary.parent != path.parent
or not temporary.name.startswith(".coulomb-install-")):
raise Refused("staging_outside_reviewed_parent")
current = fingerprint(path_for(root, item["target"]))
if current not in (item["before"], item["staged"], item.get("restored")):
raise Refused("rollback_target_changed_requires_review")
if item["before"]["exists"]:
if sha((directory / item["preimage"]).read_bytes()) != item["before"]["sha256"]:
raise Refused("rollback_preimage_differs")
# Persist expected restoration identities before any replacement/unlink, so
# an interrupted rollback can distinguish its own writes from interference.
for item in record["changes"]:
path = path_for(root, item["target"])
if fingerprint(path) != item["staged"] or item.get("restored") is not None:
continue
before = item["before"]
if before["exists"]:
temporary = stage(path, (directory / item["preimage"]).read_bytes(),
before["mode"], before["gid"])
item.update(rollback_temporary=str(temporary), restored=fingerprint(temporary))
else:
item["restored"] = {"exists": False}
record["status"] = "rollback_prepared"
journal(directory, record)
for item in reversed(record["changes"]):
path = path_for(root, item["target"])
if fingerprint(path) == item["staged"]:
before = item["before"]
if before["exists"]:
temporary = Path(item["rollback_temporary"])
if fingerprint(temporary) != item["restored"]:
raise Refused("rollback_staging_changed_requires_review")
os.replace(temporary, path)
else:
path.unlink()
sync_directory(path.parent)
temporary = Path(item["temporary"])
if temporary.exists():
if fingerprint(temporary) != item["staged"]:
raise Refused("rollback_staging_changed_requires_review")
temporary.unlink()
reload_manager()
for item in record["changes"]:
now, before = fingerprint(path_for(root, item["target"])), item["before"]
keys = ("exists", "sha256", "mode", "uid", "gid") if before["exists"] else ("exists",)
if any(now.get(k) != before.get(k) for k in keys):
raise Refused("rollback_readback_failed")
record["status"] = "prior_files_restored_verified"
journal(directory, record)
return record
def install(manifest, contents, expected_before, directory, *, root, reload_manager,
verify_inactive):
"""Caller supplies admitted bytes, exact native preimage and locked execution context.
Shared existing platform helpers must already match their pinned content.
Unchanged files keep their inode/mode; changed existing Python files retain
their owner's execute bit. All installs finish with inactive-unit verification.
"""
if observe(root) != expected_before:
raise Refused("installed_source_drift")
verify_inactive()
for entry in manifest["files"]:
if entry["member"].startswith("platform/"):
before = expected_before[entry["target"]]
if before["exists"] and before["sha256"] != entry["sha256"]:
raise Refused("shared_platform_source_drift")
directory.mkdir(mode=0o700, parents=True, exist_ok=False)
record = {"schema": "coulomb.backup-source-installation.v1", "status": "staging",
"changes": [], "timers_activated": False, "capture_executed": False}
journal(directory, record)
try:
for entry in manifest["files"]:
target = entry["target"]
if target is None:
continue
before = expected_before[target]
if before["exists"] and before["sha256"] == entry["sha256"]:
continue
path = path_for(root, target)
trusted_parents(root, path)
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
preimage = str(len(record["changes"])) + ".before"
if before["exists"]:
data = path.read_bytes()
if sha(data) != before["sha256"]:
raise Refused("preimage_changed")
fd = os.open(directory / preimage, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
with os.fdopen(fd, "wb") as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
mode = int(entry["mode"], 8)
if target.endswith(".py") and before["exists"]:
mode |= before["mode"] & 0o100
temporary = stage(path, contents[entry["member"]], mode,
before["gid"] if before["exists"] else None)
record["changes"].append({
"target": target, "before": before, "preimage": preimage,
"temporary": str(temporary), "staged": fingerprint(temporary)})
journal(directory, record)
if observe(root) != expected_before:
raise Refused("source_changed_during_staging")
record["status"] = "installing"
journal(directory, record)
for item in record["changes"]:
if fingerprint(path_for(root, item["target"])) != item["before"]:
raise Refused("source_changed_before_replace")
os.replace(item["temporary"], path_for(root, item["target"]))
sync_directory(path_for(root, item["target"]).parent)
reload_manager()
verify_inactive()
for item in record["changes"]:
if fingerprint(path_for(root, item["target"])) != item["staged"]:
raise Refused("installed_readback_differs")
expected_after = dict(expected_before)
expected_after.update({item["target"]: item["staged"] for item in record["changes"]})
if observe(root) != expected_after:
raise Refused("preserved_source_changed")
record["status"] = "sources_installed_units_inactive_verified"
record["verified_targets"] = len(expected_after)
journal(directory, record)
except Exception as error:
record.update(status="installation_requires_review", failure_type=type(error).__name__)
journal(directory, record)
raise
return record