Files
stack/scripts/unify-fleet-goal.py

266 lines
11 KiB
Python

#!/usr/bin/env python3
"""#58 local fleet repair. Default preflight; explicit pinned apply or rollback.
Only two source directories and the Resume-hotfix launcher may change. Settings,
credentials, state, sessions and enforcement extensions are never written.
Linux renameat2 exchanges keep originals at the private backup paths, including
when interrupted. A receipt can recover a partially applied transaction.
"""
import argparse
from contextlib import contextmanager
import ctypes
import fcntl
import hashlib
import json
import os
from pathlib import Path
import stat
import uuid
SHARED = '.pi/extensions/goal'
ALIASES = ('fleet/extensions/goal', 'fleet/agents/velma/.pi/extensions/goal')
LAUNCHER = 'fleet/bin/launch-seat.sh'
DEPENDENCIES = ('.pi/extensions/mosaic-core', 'fleet/extensions/mosaic-core',
'fleet/agents/velma/.pi/extensions/mosaic-core',
'fleet/extensions/wrapper-guard/index.ts', 'tools/unslop-hook/extension.ts')
HOTFIX_SHA = 'dd9e5ece5f1a86cc286027668560198bb7a44d2ab891f7b1ab4f89d7379ca0e6'
ORIGINAL_SHA = '9352feed0acf9d449c26c0556ba00aba1c65ded43d376930a39ff6b5cee21986'
def require(condition, message):
if not condition:
raise RuntimeError(message)
def sha(data):
return hashlib.sha256(data).hexdigest()
def snapshot(path):
"""Ordinary source files only. Do not call this on goal-state directories."""
require(path.resolve() == path, f'non-canonical source path: {path}')
result = {}
paths = [path] + (sorted(path.rglob('*')) if path.is_dir() else [])
for p in paths:
s = p.lstat()
kind = 'dir' if stat.S_ISDIR(s.st_mode) else 'file' if stat.S_ISREG(s.st_mode) else None
require(kind is not None, f'symlink/special source file refused: {p}')
require(s.st_uid == os.getuid(), f'foreign-owned source: {p}')
result[str(p.relative_to(path))] = {'type': kind, 'mode': stat.S_IMODE(s.st_mode)}
if kind == 'file':
result[str(p.relative_to(path))]['sha256'] = sha(p.read_bytes())
return result
def inventory(root):
"""Pin configuration without copying its contents or reading auth/state."""
paths = []
for group in ('agents', 'roles'):
paths += list((root / 'fleet' / group).glob('*/.pi/agent/settings.json'))
paths += [root / 'fleet/templates/pi-settings.json.template']
result = {}
for p in sorted(paths):
data = p.read_bytes()
entry = {'sha256': sha(data), 'resolved': str(p.resolve()),
'link': os.readlink(p) if p.is_symlink() else None}
if p.name == 'settings.json':
settings = json.loads(data)
entry['extensions'] = settings.get('extensions', [])
require(not settings.get('packages'), f'unreviewed package source: {p}')
result[str(p.relative_to(root))] = entry
# Environment files may carry credentials. Pin filesystem identity/change
# metadata only; never read or copy their contents for this source repair.
for p in sorted((root / 'fleet/agents').glob('*/launch.env')):
s = p.stat()
result[str(p.relative_to(root))] = {
'resolved': str(p.resolve()), 'link': os.readlink(p) if p.is_symlink() else None,
'device': s.st_dev, 'inode': s.st_ino, 'size': s.st_size,
'mtime_ns': s.st_mtime_ns, 'ctime_ns': s.st_ctime_ns,
}
return result
def restored_launcher(data):
require(sha(data) == HOTFIX_SHA, 'launcher is not the reviewed Resume hotfix')
start = data.index(b'# Issue #57: Resume uses the operator-selected shared NG goal')
end = data.index(b'for f in "${PROMPT_FILES[@]}"', start)
result = data[:start] + data[end:]
require(sha(result) == ORIGINAL_SHA, 'launcher restoration differs from original')
return result
def prepare(root):
require(root.is_dir() and root.resolve() == root, 'root must be a canonical directory')
after = restored_launcher((root / LAUNCHER).read_bytes())
before = {p: snapshot(root / p) for p in (*ALIASES, LAUNCHER)}
launcher_after = {'.': {**before[LAUNCHER]['.'], 'sha256': sha(after)}}
return {'version': 1, 'root': str(root), 'shared': snapshot(root / SHARED),
'dependencies': {p: snapshot(root / p) for p in DEPENDENCIES},
'before': before, 'launcherAfter': launcher_after, 'inventory': inventory(root)}
def validate_plan(plan):
require(plan['version'] == 1, 'unsupported plan')
root = Path(plan['root'])
require(root.is_absolute() and root.resolve() == root, 'invalid root')
require(set(plan['before']) == set((*ALIASES, LAUNCHER)), 'invalid mutation scope')
require(plan['before'][LAUNCHER]['.']['sha256'] == HOTFIX_SHA, 'unreviewed launcher pin')
require(plan['launcherAfter']['.']['sha256'] == ORIGINAL_SHA, 'unreviewed restoration pin')
return root
def verify_common(plan):
root = validate_plan(plan)
require(snapshot(root / SHARED) == plan['shared'], 'shared source drift')
require(set(plan['dependencies']) == set(DEPENDENCIES), 'invalid dependency scope')
for p in DEPENDENCIES:
require(snapshot(root / p) == plan['dependencies'][p], f'dependency drift: {p}')
require(inventory(root) == plan['inventory'], 'configuration inventory drift')
return root
def verify_before(plan):
root = verify_common(plan)
for p in (*ALIASES, LAUNCHER):
require(snapshot(root / p) == plan['before'][p], f'before drift: {p}')
def is_alias(path, shared):
return path.is_symlink() and os.readlink(path) == str(shared) and path.resolve() == shared
def verify_after(plan):
root = verify_common(plan)
for p in ALIASES:
require(is_alias(root / p, root / SHARED), f'alias drift: {p}')
require(snapshot(root / LAUNCHER) == plan['launcherAfter'], 'launcher after drift')
def exchange(a, b):
fn = ctypes.CDLL(None, use_errno=True).renameat2
fn.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
fn.restype = ctypes.c_int
if fn(-100, os.fsencode(a), -100, os.fsencode(b), 2):
error = ctypes.get_errno()
raise OSError(error, os.strerror(error))
def write_once(path, data):
with path.open('xb') as f:
f.write(data)
f.flush()
os.fsync(f.fileno())
def stage_path(backup, relative):
return backup / ('launcher' if relative == LAUNCHER else 'legacy' if relative == ALIASES[0] else 'velma')
def recover(plan, backup):
"""Validate every pair before reverting any. Handles partial exchanges."""
root = verify_common(plan)
require(backup.parent == root / '.pi/goal-backups' and backup.resolve() == backup,
'backup outside approved directory')
swapped = []
for p in (*ALIASES, LAUNCHER):
target, stage = root / p, stage_path(backup, p)
if p in ALIASES:
target_after = is_alias(target, root / SHARED)
stage_after = is_alias(stage, root / SHARED)
else:
target_after = snapshot(target) == plan['launcherAfter']
stage_after = snapshot(stage) == plan['launcherAfter']
if target_after:
require(snapshot(stage) == plan['before'][p], f'rollback backup drift: {p}')
swapped.append(p)
else:
require(snapshot(target) == plan['before'][p] and stage_after, f'rollback pair drift: {p}')
for p in reversed(swapped):
exchange(root / p, stage_path(backup, p))
verify_before(plan)
write_once(backup / ('rollback-' + uuid.uuid4().hex + '.json'),
json.dumps({'status': 'rolled back', 'restored': swapped}).encode())
def apply(plan, raw):
verify_before(plan)
root = Path(plan['root'])
backup_root = root / '.pi/goal-backups'
backup_root.mkdir(mode=0o700, parents=True, exist_ok=True)
require(backup_root.resolve() == backup_root, 'backup directory alias refused')
backup = backup_root / ('goal58-' + uuid.uuid4().hex)
backup.mkdir(mode=0o700)
for p in ALIASES:
stage_path(backup, p).symlink_to(root / SHARED, target_is_directory=True)
stage = stage_path(backup, LAUNCHER)
write_once(stage, restored_launcher((root / LAUNCHER).read_bytes()))
os.chmod(stage, plan['before'][LAUNCHER]['.']['mode'])
for p in (*ALIASES, LAUNCHER):
require((root / p).lstat().st_dev == stage_path(backup, p).lstat().st_dev, 'cross-device exchange refused')
write_once(backup / 'planned.json', raw)
print(f'Recovery receipt: {backup}', flush=True)
try:
verify_before(plan)
for p in (*ALIASES, LAUNCHER):
require(snapshot(root / p) == plan['before'][p], f'concurrent drift: {p}')
exchange(root / p, stage_path(backup, p))
verify_after(plan)
for p in (*ALIASES, LAUNCHER):
require(snapshot(stage_path(backup, p)) == plan['before'][p], f'backup mismatch: {p}')
write_once(backup / 'deployed.json', json.dumps({'status': 'deployed', 'planSha256': sha(raw)}).encode())
except BaseException:
recover(plan, backup)
raise
return backup
@contextmanager
def deployment_lock(plan):
root = verify_common(plan)
directory = root / '.pi/goal-backups'
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
require(directory.resolve() == directory, 'backup directory alias refused')
fd = os.open(directory / 'goal58.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
yield
finally:
os.close(fd)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('mode', nargs='?', default='preflight', choices=['prepare', 'preflight', 'apply', 'verify', 'rollback'])
parser.add_argument('--root', type=Path)
parser.add_argument('--plan', type=Path, required=True)
parser.add_argument('--sha256')
parser.add_argument('--backup', type=Path)
args = parser.parse_args()
if args.mode == 'prepare':
require(args.root is not None, '--root required')
plan = prepare(args.root)
raw = (json.dumps(plan, indent=2, sort_keys=True) + '\n').encode()
write_once(args.plan, raw)
print('Prepared plan SHA256:', sha(raw))
return
raw = args.plan.read_bytes()
require(sha(raw) == args.sha256, 'reviewed plan SHA256 required')
plan = json.loads(raw)
if args.mode == 'preflight':
verify_before(plan)
elif args.mode == 'apply':
with deployment_lock(plan):
print('Deployed:', apply(plan, raw))
elif args.mode == 'verify':
verify_after(plan)
else:
require(args.backup is not None, '--backup required')
require((args.backup / 'planned.json').read_bytes() == raw, 'backup receipt mismatch')
with deployment_lock(plan):
recover(plan, args.backup)
print('PASS', args.mode)
if __name__ == '__main__':
main()