@@ -0,0 +1,265 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user