Files

427 lines
23 KiB
Python

#!/usr/bin/python3
"""Actual system-manager D09 qualification inside an explicitly named disposable container.
Uses inert Python executables, not deployment artifacts. Never run on a node.
No mock replaces systemctl, Unix peer credentials, or the production owner/worker.
"""
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import pwd
import secrets
import shutil
import socket
import subprocess
import sys
import time
assert os.geteuid() == 0
assert socket.gethostname() == 'd09-qualification'
assert Path('/run/.containerenv').exists()
assert os.environ.get('D09_DISPOSABLE_SYSTEMD') == '1'
PACKAGE = Path('/opt/d09-fixture')
# podman cp retains source-directory group-write bits. The provisioner rightly
# refuses those; install the disposable package as protected code first.
for directory, directories, files in os.walk(PACKAGE):
for path in [Path(directory), *(Path(directory) / name for name in files)]:
path.chmod(path.stat().st_mode & 0o755)
sys.path.insert(0, str(PACKAGE / 'scripts'))
import d09_wire as wire
import d09_store as store
spec = importlib.util.spec_from_file_location('provision', PACKAGE / 'scripts/provision-d09-lifecycle.py')
provision = importlib.util.module_from_spec(spec)
spec.loader.exec_module(provision)
owner = provision.owner
assert not owner.BINDING.exists() and not (owner.ROOT / 'ledger.json').exists(), 'retain previous authority evidence; use a fresh container'
def run(*args, check=True):
return subprocess.run(args, check=check, capture_output=True, text=True, timeout=45)
def wait_for(predicate, label, seconds=45):
end = time.monotonic() + seconds
while time.monotonic() < end:
value = predicate()
if value:
return value
time.sleep(0.1)
raise AssertionError('timed out: ' + label)
def write(path, data, mode=0o644):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data.encode() if isinstance(data, str) else data)
path.chmod(mode)
def config_digest():
return wire.digest(owner.effective_configuration().encode())
def sha(path):
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def ledger():
return json.loads((owner.ROOT / 'ledger.json').read_text())
def receipt_phase(phase):
value = ledger()
receipt = value.get('receipt')
if receipt and receipt['phase'] == 'CONFLICT':
raise AssertionError('owner entered conflict: ' + json.dumps(receipt))
return receipt if receipt and receipt['phase'] == phase else None
# Every changed pathname below belongs to this disposable container's overlay.
run('systemctl', 'stop', 'archipelago.service', check=False)
assert owner.service()['MainPID'] == '0'
for path in (owner.ROOT, owner.BINDING.parent, Path('/etc/systemd/system/archipelago.service.d'),
Path('/opt/archipelago/web-ui')):
if path.exists():
shutil.rmtree(path)
write('/etc/sudoers', 'root ALL=(ALL:ALL) ALL\n', 0o440)
if Path('/etc/sudoers.d').exists():
shutil.rmtree('/etc/sudoers.d')
Path('/etc/sudoers.d').mkdir(mode=0o755)
account = pwd.getpwnam('archipelago')
control = Path('/run/d09-fixture-client')
control.mkdir(mode=0o700, exist_ok=True)
os.chown(control, account.pw_uid, account.pw_gid)
home = Path('/home/archipelago')
home.mkdir(exist_ok=True)
os.chown(home, account.pw_uid, account.pw_gid)
cache = Path('/var/lib/archipelago/d09-requests')
cache.mkdir(parents=True, exist_ok=True, mode=0o700)
os.chown(cache, account.pw_uid, account.pw_gid)
requester = '''import json,os,pathlib,subprocess,time
p=pathlib.Path('/run/d09-fixture-client')
while True:
for request in p.glob('request-*.json'):
output=request.with_name(request.name.replace('request-','result-'))
if output.exists():continue
args=json.loads(request.read_text())
r=subprocess.run(['/usr/libexec/archipelago-d09ctl',*args],capture_output=True,text=True,timeout=15)
output.write_text(json.dumps({'exit':r.returncode,'stdout':r.stdout,'stderr':r.stderr}))
time.sleep(.1)
'''
write(PACKAGE / 'requester.py', requester)
python = Path(sys.executable).resolve().read_bytes()
write(owner.DESTINATIONS['backend'], python, 0o755)
web = owner.DESTINATIONS['frontend']
web.parent.mkdir(parents=True, exist_ok=True)
os.chown(web.parent, 0, 0)
web.parent.chmod(0o755)
write(web / 'index.html', 'old fixture frontend\n')
template = Path('archipelago-runtime/image-recipe/configs/nginx-archipelago.conf')
write(web / template, (PACKAGE / 'image-recipe/configs/nginx-archipelago.conf').read_bytes())
write('/etc/systemd/system/archipelago.service', '''[Unit]
Description=Disposable D09 requester
[Service]
Type=exec
User=archipelago
ExecStart=/usr/local/bin/archipelago /opt/d09-fixture/requester.py
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/run/d09-fixture-client /var/lib/archipelago/d09-requests
Restart=no
''')
preserved_paths = [home / 'Work/operator.txt',
home / '.local/state/archipelago/sessions/history.txt',
Path('/etc/systemd/system/archipelago.service.d/90-operator.conf')]
for path in preserved_paths[:2]:
write(path, 'retained operator bytes\n', 0o600)
for parent in [path, *path.parents]:
if parent == home.parent:
break
os.chown(parent, account.pw_uid, account.pw_gid)
write(preserved_paths[2], '[Service]\nEnvironment=D09_OPERATOR_FIXTURE=preserved\n')
preserved_before = [(str(path), path.stat().st_ino, path.stat().st_mode,
path.stat().st_uid, path.stat().st_gid, sha(path)) for path in preserved_paths]
run('systemctl', 'daemon-reload')
provision.install_package()
# Measure actual denied writes and sudo before admitting any transaction.
denials = []
for args in (['touch', '/usr/local/bin/d09-unauthorized'],
['touch', str(web / 'unauthorized')],
['python3', '-c', "import os;os.rename('/opt/archipelago/web-ui','/opt/archipelago/unauthorized-move')"],
['sudo', '-n', 'true']):
result = run('runuser', '-u', 'archipelago', '--', *args, check=False)
assert result.returncode != 0, args
denials.append({'operation': args[0], 'exit': result.returncode})
exclusion = wire.canonical({'disposable': True, 'observed_denials': denials})
store.atomic(owner.ROOT / 'incoming/exclusion.json', exclusion)
new_config = (PACKAGE / 'image-recipe/configs/60-terminal-workspace.conf').read_bytes()
for generation in ('new', 'restore'):
destination = owner.ROOT / 'incoming' / (generation + '-frontend')
shutil.copytree(web, destination)
if generation == 'new':
write(destination / 'index.html', 'new fixture frontend\n')
write(owner.ROOT / 'incoming' / (generation + '-backend'),
python + (b'\nD09-inert-fixture-generation-two\n' if generation == 'new' else b''), 0o755)
write(owner.ROOT / 'incoming/new-config', new_config)
# Old/restored config is absent, rather than an invented empty file.
restore_digest = config_digest()
write(owner.DESTINATIONS['config'], new_config)
run('systemctl', 'daemon-reload')
apply_digest = config_digest()
owner.DESTINATIONS['config'].unlink()
run('systemctl', 'daemon-reload')
assert config_digest() == restore_digest
binding = dict(schema_version=1, target_binding_digest=secrets.token_hex(32),
composition_digest=secrets.token_hex(32), admission_epoch_id=secrets.token_hex(16),
transaction_id=secrets.token_hex(16), grants={route: secrets.token_hex(32)
for route in ('UPDATER', 'AUTOMATIC', 'MANUAL', 'GUARD', 'OBSERVER')},
objects={name: {'old': owner.object_digest(destination),
'new': owner.object_digest(owner.ROOT / 'incoming' / ('new-' + name)),
'restore': owner.object_digest(owner.ROOT / 'incoming' / ('restore-' + name))}
for name, destination in owner.DESTINATIONS.items()},
artifacts={'backend': sha(owner.ROOT / 'incoming/new-backend'),
'frontend': owner.object_digest(owner.ROOT / 'incoming/new-frontend')},
restore_artifacts={'backend': sha(owner.DESTINATIONS['backend']),
'frontend': owner.object_digest(web)},
effective_config={'APPLY': apply_digest, 'RESTORE': restore_digest},
exclusion_evidence_digest=wire.digest(exclusion),
helper_digest=sha('/usr/libexec/archipelago-d09-prepare-workspace'),
prerequisite={'grant_digest': secrets.token_hex(32),
'backend_sha256': sha(owner.DESTINATIONS['backend']), 'effective_config_digest': restore_digest})
binding['release_identity'] = ['B', [['archipelago', 'B', binding['artifacts']['backend'], (owner.ROOT / 'incoming/new-backend').stat().st_size]]]
admission = owner.ROOT / 'incoming/admission.json'
store.atomic(admission, wire.canonical(binding))
provision.provision(admission)
run('systemctl', 'start', 'archipelago-d09-lifecycle.service')
wait_for(lambda: owner.PRIVATE.exists() and owner.SOCKET.exists(), 'owner sockets')
def prerequisite_ready():
result=provision.activate_prerequisite()
return result if result['prerequisite_started'] else None
started = wait_for(prerequisite_ready, 'one submitted prerequisite READY')
assert started['prerequisite_started']
first_invocation = started['invocation_id']
def history_fields():
v = ledger()
active = v.get('active_binding', binding)
index = {k: active[k] for k in ('target_binding_digest', 'composition_digest', 'transaction_id')}
return dict(index, predecessor_epoch_id=v['epoch'], head_sequence=v['sequence'],
preserved_head_digest=wire.digest(wire.canonical(v['receipt'])) if v['receipt'] else wire.digest((owner.ROOT / 'ledger.json').read_bytes()),
preserved_journal_digest=wire.digest(wire.canonical({'receipts': v['journal'], 'intents': v['intents']})),
preserved_consumed_history_digest=wire.digest(wire.canonical(owner.consumption_history(v))),
preserved_target_index_digest=wire.digest(wire.canonical(index)))
def external_record(fields):
# Disposable coordinator fixture, separate from the owner's rollback tree.
# This qualifies source binding, NOT operational external history transport.
directory = PACKAGE / 'external-history'
directory.mkdir(exist_ok=True, mode=0o700)
identity = secrets.token_hex(16)
history = dict(fields, sequence=1)
history_path = directory / (identity + '-history.json')
store.atomic(history_path, wire.canonical(history))
record = dict(fields, schema_version=1, external_history_sequence=1,
external_history_digest=sha(history_path), observed_boot_id=owner.boot_id())
path = directory / (identity + '-record.json')
store.atomic(path, wire.canonical(record))
return path, history_path
def restart_reactivate(action):
before = (owner.ROOT / 'ledger.json').read_bytes()
run('systemctl', 'restart', 'archipelago-d09-lifecycle.service')
wait_for(lambda: owner.service()['MainPID'] == '0' and owner.service()['ActiveState'] == 'inactive', 'restart drain')
def unknown():
result = run('/usr/libexec/archipelago-d09ctl', 'inspect', check=False)
return result.returncode == 0 and json.loads(result.stdout)['state'] == 'UNKNOWN'
wait_for(unknown, 'fresh owner holds unknown')
assert (owner.ROOT / 'ledger.json').read_bytes() == before
probe = owner.Owner.__new__(owner.Owner)
disposition = wait_for(lambda: probe.drained_disposition(ledger()), 'worker drain')
fields = dict(history_fields(), allowed_action=action, new_epoch_id=secrets.token_hex(16),
new_grant_digest=secrets.token_hex(32), execution_grant_digest=secrets.token_hex(32), disposition_digest=disposition)
record, history = external_record(fields)
provision.readmit(record, history, activation=True)
wait_for(lambda: owner.service()['ActiveState'] == 'active', 'external reactivation')
if action == 'REACTIVATE_RETAINED':
wait_for(lambda: receipt_phase('AWAITING_OBSERVATION'), 'fresh observation required')
else:
wait_for(lambda: ledger()['prerequisite']['state']=='STARTED', 'prerequisite observed READY')
invocation = owner.service()['InvocationID']
# Lost acknowledgement retry consumes neither another grant nor activation.
provision.readmit(record, history, activation=True)
assert owner.service()['InvocationID'] == invocation
return invocation
first_invocation = restart_reactivate('REACTIVATE_PREREQUISITE')
assert ledger()['prerequisite_history'][0]['grant_digest'] == binding['prerequisite']['grant_digest']
assert ledger()['sequence'] == 0
print('PASS sequence-zero owner loss: external reconciliation retains consumed grant A, starts once, retry nonactivating', flush=True)
def request(arguments):
identity = secrets.token_hex(8)
write(control / ('request-' + identity + '.json'), json.dumps(arguments), 0o644)
response = control / ('result-' + identity + '.json')
wait_for(response.exists, 'request ' + arguments[0])
value = json.loads(response.read_text())
assert value['exit'] == 0, value
return json.loads(value['stdout'])
initial_status = request(['inspect'])
assert initial_status['state'] == 'NO_D09_TRANSACTION', initial_status
selected = json.dumps(binding['release_identity'], separators=(',', ':'))
before_wrong = (owner.ROOT / 'ledger.json').read_bytes()
wrong = run('/usr/libexec/archipelago-d09ctl', 'prepare', '--selected-release', json.dumps(['C', binding['release_identity'][1]]), check=False)
assert wrong.returncode != 0 and (owner.ROOT / 'ledger.json').read_bytes() == before_wrong
request(['prepare', '--selected-release', selected, '--staging-generation', secrets.token_hex(16)])
applied = wait_for(lambda: receipt_phase('AWAITING_OBSERVATION'), 'actual apply')
assert owner.service()['InvocationID'] != first_invocation
assert sha(owner.DESTINATIONS['backend']) == binding['artifacts']['backend']
assert (web / 'index.html').read_text() == 'new fixture frontend\n'
assert owner.DESTINATIONS['config'].read_bytes() == new_config
assert request(['inspect'])['state'] == 'PENDING'
print('PASS real systemd apply: requester cgroup, external owner/worker, exact payload/config pair, pending until independent observation', flush=True)
def complete_fixture_observation():
r = receipt_phase('AWAITING_OBSERVATION')
assert r
pid = owner.service()['MainPID']
# OS PTY surrogate in the actual mount namespace. This is deliberately not
# native terminal integration/UAT; the evidence records that qualification.
run('nsenter', '-t', pid, '-m', '--', 'runuser', '-u', 'archipelago', '--',
'bash', '-c', 'cd /home/archipelago/Work && script -q -c "pwd" /dev/null')
for path in ('/home/archipelago/Work/d09-observer-probe', '/home/archipelago/.local/state/archipelago/sessions/d09-observer-probe'):
run('nsenter', '-t', pid, '-m', '--', 'runuser', '-u', 'archipelago', '--', 'touch', path)
for path in ('/usr/local/bin/d09-observer-denied', '/opt/archipelago/web-ui/d09-observer-denied'):
assert run('nsenter', '-t', pid, '-m', '--', 'runuser', '-u', 'archipelago', '--', 'touch', path, check=False).returncode != 0
after = [(str(path), path.stat().st_ino, path.stat().st_mode, path.stat().st_uid, path.stat().st_gid, sha(path)) for path in preserved_paths]
assert after == preserved_before
base = dict(transaction_id=r['transaction_id'], observed_invocation_id=r['observed_invocation_id'])
namespace = dict(base, qualification='inert executable / OS PTY surrogate; NOT native terminal',
default_terminal_created=True, default_terminal_workspace='/home/archipelago/Work',
namespace_writable=['/home/archipelago/Work', '/home/archipelago/.local/state/archipelago/sessions'])
preservation = dict(base, preserved={name: {'before': wire.digest(wire.canonical(before)), 'after': wire.digest(wire.canonical(now))}
for name, before, now in zip(('work', 'history', 'custom_fragments'), preserved_before, after)})
def publish(value):
raw = wire.canonical(value)
hashed = wire.digest(raw)
store.atomic(owner.ROOT / 'observations' / (hashed + '.json'), raw)
return hashed
observation = {k: r[k] for k in ('transaction_id', 'target_binding_digest', 'composition_digest',
'observed_invocation_id', 'effective_config_observation_digest', 'config_current_digest')}
observation.update(namespace_observation_digest=publish(namespace), preservation_observation_digest=publish(preservation))
public = json.loads(owner.BINDING.with_name('client.json').read_text())
request_value = dict(schema_version=1, operation='ACK_OBSERVATION', route='OBSERVER', reason='OBSERVATION_READY',
transaction_id=r['transaction_id'], request_id=secrets.token_hex(16), target_binding_digest=r['target_binding_digest'],
composition_digest=r['composition_digest'], grant_digest=public['grants']['OBSERVER'], expected_sequence=ledger()['sequence'],
requester_invocation_id=None, observer_receipt_digest=publish(observation), selected_release_digest=None, staging_generation=None)
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as conn:
conn.connect(str(owner.SOCKET))
conn.sendall(wire.canonical(request_value))
conn.shutdown(socket.SHUT_WR)
assert wire.parse(conn.recv(wire.MAX))['status'] == 'ACCEPTED'
wait_for(lambda: receipt_phase('COMPLETE'), 'fixture observer completion')
complete_fixture_observation()
restart_reactivate('REACTIVATE_RETAINED')
complete_fixture_observation()
print('PASS applied COMPLETE owner-loss reactivation: exact B retained, new invocation, fresh OS-surrogate observation', flush=True)
# Admit B -> C without resetting any ledger/journal or predecessor snapshots.
old_request = next(e['request'] for e in ledger()['requests'].values() if e['request']['operation'] == 'APPLY')
predecessor_receipts = list(ledger()['journal'])
incoming = owner.ROOT / 'incoming'
retained_incoming = PACKAGE / 'incoming-retained-B'
retained_incoming.mkdir(mode=0o700)
for entry in incoming.iterdir():
entry.rename(retained_incoming / entry.name)
store.atomic(incoming / 'exclusion.json', exclusion)
for generation in ('new', 'restore'):
for name, destination in owner.DESTINATIONS.items():
target = incoming / (generation + '-' + name)
if destination.is_dir():
shutil.copytree(destination, target)
elif destination.exists():
shutil.copy2(destination, target)
write(incoming / 'new-backend', python + b'\nD09-inert-fixture-generation-three\n', 0o755)
write(incoming / 'new-frontend/index.html', 'third fixture frontend\n')
successor_binding = dict(binding, transaction_id=secrets.token_hex(16), admission_epoch_id=secrets.token_hex(16),
composition_digest=secrets.token_hex(32), grants={route: secrets.token_hex(32) for route in binding['grants']},
objects={name: dict(old=owner.object_digest(dest), new=owner.object_digest(incoming / ('new-' + name)),
restore=owner.object_digest(incoming / ('restore-' + name))) for name, dest in owner.DESTINATIONS.items()},
artifacts=dict(backend=sha(incoming / 'new-backend'), frontend=owner.object_digest(incoming / 'new-frontend')),
restore_artifacts=binding['artifacts'], effective_config=dict(APPLY=apply_digest, RESTORE=apply_digest),
prerequisite=dict(grant_digest=secrets.token_hex(32), backend_sha256=sha(owner.DESTINATIONS['backend']), effective_config_digest=apply_digest))
successor_binding['release_identity'] = ['C', [['archipelago', 'C', successor_binding['artifacts']['backend'], (incoming / 'new-backend').stat().st_size]]]
successor_admission = incoming / 'admission.json'
store.atomic(successor_admission, wire.canonical(successor_binding))
protected = dict(successor_binding, requester_uid=account.pw_uid, requester_gid=account.pw_gid)
fields = dict(history_fields(), successor_binding_digest=wire.digest(wire.canonical(protected)), terminal_disposition='APPLY')
record, history = external_record(fields)
wait_for(lambda: owner.command('/usr/bin/systemctl', 'show', ledger()['execution']['unit'], '-p', 'ActiveState', '--value') == 'inactive', 'completed worker drain')
provision.successor(successor_admission, record, history)
assert ledger()['journal'] == predecessor_receipts
assert binding['transaction_id'] in ledger()['predecessors']
before_replay = (owner.ROOT / 'ledger.json').read_bytes()
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as conn:
conn.connect(str(owner.SOCKET))
conn.sendall(wire.canonical(old_request))
conn.shutdown(socket.SHUT_WR)
assert wire.parse(conn.recv(wire.MAX))['status'] == 'REFUSED'
assert before_replay == (owner.ROOT / 'ledger.json').read_bytes()
provision.successor(successor_admission, record, history)
assert before_replay == (owner.ROOT / 'ledger.json').read_bytes()
restart_reactivate('REACTIVATE_PREREQUISITE')
request(['prepare', '--selected-release', json.dumps(successor_binding['release_identity']), '--staging-generation', secrets.token_hex(16)])
wait_for(lambda: receipt_phase('AWAITING_OBSERVATION'), 'successor C apply')
assert sha(owner.DESTINATIONS['backend']) == successor_binding['artifacts']['backend']
complete_fixture_observation()
binding = successor_binding
print('PASS A -> B -> C successor admission: terminal predecessor, retained journal, exact B rollback provenance, old APPLY and admission replay nonactivating', flush=True)
request(['recover', '--reason', 'manual'])
restored = wait_for(lambda: receipt_phase('AWAITING_OBSERVATION')
if ledger().get('receipt', {}).get('config_action') == 'RESTORE' else None, 'actual recovery')
assert sha(owner.DESTINATIONS['backend']) == binding['restore_artifacts']['backend']
assert (web / 'index.html').read_text() == 'new fixture frontend\n'
assert owner.DESTINATIONS['config'].read_bytes() == new_config
assert restored['frontend_restoration'] == restored['backend_restoration'] == 'SUCCEEDED'
print('PASS C -> B recovery: exact predecessor pair/config restored', flush=True)
restart_reactivate('REACTIVATE_RETAINED')
complete_fixture_observation()
restart_reactivate('REACTIVATE_RETAINED')
complete_fixture_observation()
print('PASS restored AWAITING and restored COMPLETE reactivation: one-use authority, new invocations, fresh observations', flush=True)
preserved_after = [(str(path), path.stat().st_ino, path.stat().st_mode,
path.stat().st_uid, path.stat().st_gid, sha(path)) for path in preserved_paths]
assert preserved_after == preserved_before
print('PASS actual Work, history and custom-unit bytes/inodes/modes/owners preserved', flush=True)
# A fresh owner must not turn an on-disk journal into renewed execution rights.
# Exercise the real BindsTo hold and authenticated Unix inspect after restart.
ledger_before_restart = (owner.ROOT / 'ledger.json').read_bytes()
run('systemctl', 'restart', 'archipelago-d09-lifecycle.service')
wait_for(lambda: owner.service()['ActiveState'] == 'inactive', 'owner restart holds requester')
def restarted_inspection():
# The retained pathname can precede the new listener. Probe the actual
# authenticated, read-only exchange, not socket-file existence.
result = run('/usr/libexec/archipelago-d09ctl', 'inspect', check=False)
return json.loads(result.stdout) if result.returncode == 0 else None
unknown = wait_for(restarted_inspection, 'restarted owner authenticated inspect')
assert unknown['state'] == 'UNKNOWN', unknown
assert (owner.ROOT / 'ledger.json').read_bytes() == ledger_before_restart
assert sha(owner.DESTINATIONS['backend']) == binding['restore_artifacts']['backend']
assert (web / 'index.html').read_text() == 'new fixture frontend\n'
print('PASS actual owner restart: no disk-seeded authority, requester held, journal and recovered payloads retained', flush=True)