Files
archy/scripts/tests/os_updater_activation_guest.py

350 lines
20 KiB
Python

#!/usr/bin/python3
"""QEMU-only bootstrap -> real D09 owner/worker -> actual OS transaction.
Manager ELF is a synthetic Python requester, NOT accepted deployed Archipelago.
OS metadata/package effects are real; release verifier is the VM-only exact-pin
adapter from the installed qualification fixture, never production signing.
"""
import importlib.util
import json
import os
from pathlib import Path
import pwd
import secrets
import shutil
import subprocess
import sys
import time
assert 'QEMU' in Path('/sys/class/dmi/id/sys_vendor').read_text()
assert subprocess.check_output(['findmnt', '-n', '-o', 'SOURCE', '/'], text=True).strip() == '/dev/vda'
PACKAGE = Path('/opt/ota-source')
NATIVE = (PACKAGE/'accepted-old-archipelago').exists()
sys.path.insert(0, str(PACKAGE / 'scripts'))
import os_updater_bootstrap as bootstrap
import os_updater_activate as activation
import os_update_executor as executor
import d09_wire as wire
def run(*args, check=True):
r = subprocess.run(args, capture_output=True, text=True, timeout=180)
print('COMMAND', args, r.returncode, r.stdout, r.stderr, flush=True)
if check:
r.check_returncode()
return r
def write(path, content, mode=0o600):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content.encode() if isinstance(content, str) else content)
path.chmod(mode)
def sha(path):
return bootstrap.digest(Path(path).read_bytes())
def wait(predicate, label, timeout=900):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
value = predicate()
if value:
return value
time.sleep(.2)
raise TimeoutError(label)
def native_health():
state = owner.service()
if state['MainPID']=='0':
return None
r = subprocess.run(['nsenter','--target',state['MainPID'],'--net','--',
'curl','--max-time','2','--fail','--silent','http://127.0.0.1:19432/health'],capture_output=True,text=True)
return r.stdout if r.returncode==0 else None
# Reuse the installed fixture's actual Debian planner, NOT its direct stage
# shortcut. Keep generated inputs outside protected plan storage until bootstrap.
planner = (PACKAGE / 'tests/regression/os-ota-installed-stage.py').read_text()
marker = " status = executor.stage(binding, 'APPLY', 'prepare')"
assert planner.count(marker) == 1
prefix = planner.split(marker)[0]
old = "source = executor.ROOT/'plans'/plan_sha"
assert prefix.count(old) == 1
prefix = prefix.replace(old, "source = Path('/root/bootstrap-generated-plan')")
context = {'__name__': 'bootstrap_planner', '__file__': str(PACKAGE / 'tests/regression/os-ota-installed-stage.py')}
exec(compile(prefix + '\nexcept BaseException:\n raise\n', context['__file__'], 'exec'), context)
print('BOOTSTRAP_PLANNER_COMPLETE', flush=True)
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
activation.untouched_authority()
run('systemctl', 'stop', 'archipelago.service', check=False)
try:
account = pwd.getpwnam('archipelago')
except KeyError:
# Minimal Debian rootfs has no Archipelago principal. Establish this fixture
# installation before taking preservation observations or invoking bootstrap.
run('useradd','--uid','1000','--user-group','--create-home','--shell','/bin/bash','archipelago')
account = pwd.getpwnam('archipelago')
# A separate pre-existing fixture admin represents the independently verified
# access channel. Creation is fixture setup, never done by production bootstrap.
run('useradd', '--no-create-home', '--shell', '/bin/bash', 'ota-fixture-admin')
admin = pwd.getpwnam('ota-fixture-admin')
if executor.ROOT == Path('/var/lib/archipelago-os-update'):
# Match the recorded deployed data-root ownership without weakening custody.
os.chown('/var/lib/archipelago',account.pw_uid,account.pw_gid)
control = Path('/run/d09-fixture-client')
control.mkdir(mode=0o700)
os.chown(control, account.pw_uid, account.pw_gid)
cache = Path('/var/lib/archipelago/d09-requests')
cache.mkdir(parents=True, mode=0o700, exist_ok=True)
os.chown(cache, account.pw_uid, account.pw_gid)
requester = '''import json,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
r=subprocess.run(['/usr/libexec/archipelago-d09ctl',*json.loads(request.read_text())],capture_output=True,text=True,timeout=60)
output.write_text(json.dumps({'exit':r.returncode,'stdout':r.stdout,'stderr':r.stderr}))
time.sleep(.1)
'''
write('/root/fixture-access.json', json.dumps({'fixture': True, 'independent_uid': admin.pw_uid,
'machine_id_sha256': sha('/etc/machine-id'), 'existing_access_checked': True}))
write(PACKAGE / 'requester.py', requester, 0o644)
python = Path(sys.executable).resolve().read_bytes()
old_backend = (PACKAGE/'accepted-old-archipelago').read_bytes() if NATIVE else python
new_backend = Path('/opt/os-ota-native').read_bytes() if NATIVE else python+b'\nfixture-v2\n'
if NATIVE:
assert wire.digest(old_backend)=='2cf004fcc7f207c65b3bbd1eac248f8c9556ec1cd0ec139011fb4b1e1ef96494'
proof = json.loads(Path('/opt/os-ota-native-proof.json').read_text())
assert wire.digest(new_backend)==proof['packaged_sha256']
write(owner.DESTINATIONS['backend'], old_backend, 0o755)
web = owner.DESTINATIONS['frontend']
write(web / 'index.html', 'old frontend\n', 0o644)
template = Path('archipelago-runtime/image-recipe/configs/nginx-archipelago.conf')
write(web / template, (PACKAGE / 'image-recipe/configs/nginx-archipelago.conf').read_bytes(), 0o644)
unit = '''[Unit]
Description=Disposable old/new manager requester
[Service]
Type=exec
User=archipelago
ExecStart=/usr/local/bin/archipelago /opt/ota-source/requester.py
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/run/d09-fixture-client /var/lib/archipelago/d09-requests
Restart=no
'''
if NATIVE:
manager_data = Path('/var/lib/os-bootstrap-native-manager')
manager_data.mkdir(mode=0o700)
os.chown(manager_data,account.pw_uid,account.pw_gid)
write(PACKAGE/'requester-launch.py', '''import subprocess
subprocess.Popen(['/usr/bin/python3','-B','/opt/ota-source/requester.py'],stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True)
''', 0o644)
unit = unit.replace('ExecStart=/usr/local/bin/archipelago /opt/ota-source/requester.py',
'ExecStart=/usr/local/bin/archipelago\nExecStartPost=/usr/bin/python3 -B /opt/ota-source/requester-launch.py\n'
'PrivateNetwork=yes\nEnvironment=ARCHIPELAGO_DATA_DIR=/var/lib/os-bootstrap-native-manager\n'
'Environment=ARCHIPELAGO_RUNTIME_DATA_DIR=/var/lib/os-bootstrap-native-manager\n'
'Environment=ARCHIPELAGO_BIND=127.0.0.1:19432\n'
'Environment=ARCHIPELAGO_NOSTR_DISCOVERY_ENABLED=false\nEnvironment=ARCHIPELAGO_NOSTR_RELAYS=\n'
'Environment=ARCHIPELAGO_SWARM_ENABLED=false\nReadWritePaths=/var/lib/os-bootstrap-native-manager')
write('/etc/systemd/system/archipelago.service', unit, 0o644)
native_identity = {}
if NATIVE:
# Run the real accepted installation before any bootstrap/D09 owner exists.
# Drain it externally only after health and nonempty identity are observed.
run('systemctl','daemon-reload')
run('systemctl','start','archipelago.service')
health = wait(native_health,'pre-bootstrap accepted native health',timeout=90)
state = owner.service()
assert sha('/proc/'+state['MainPID']+'/exe')==wire.digest(old_backend)
native_identity = {str(p):sha(p) for p in manager_data.joinpath('identity').iterdir() if p.is_file()}
assert native_identity
write('/root/accepted-old-running.json',wire.canonical(dict(backend_sha256=wire.digest(old_backend),
health=health,identity=native_identity,invocation_id=state['InvocationID'])))
print('PRE_BOOTSTRAP_ACCEPTED_NATIVE_RUNNING',health,flush=True)
run('systemctl','stop','archipelago.service')
write('/etc/systemd/system/archipelago.service.d/50-d09-lifecycle.conf',
(PACKAGE / 'image-recipe/configs/50-d09-lifecycle.conf').read_bytes(), 0o644)
run('systemctl', 'daemon-reload')
incoming = owner.ROOT / 'incoming'
incoming.mkdir(parents=True, mode=0o700)
exclusion = wire.canonical({'fixture': True, 'independent_root': True})
write(incoming / 'exclusion.json', exclusion)
for generation in ('new', 'restore'):
shutil.copytree(web, incoming / (generation + '-frontend'))
write(incoming / (generation + '-backend'), new_backend if generation=='new' else old_backend, 0o755)
new_config = (PACKAGE / 'image-recipe/configs/60-terminal-workspace.conf').read_bytes()
write(incoming / 'new-config', new_config)
restore_digest = wire.digest(owner.effective_configuration().encode())
write(owner.DESTINATIONS['config'], new_config, 0o644)
run('systemctl', 'daemon-reload')
apply_digest = wire.digest(owner.effective_configuration().encode())
owner.DESTINATIONS['config'].unlink()
run('systemctl', 'daemon-reload')
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={r: secrets.token_hex(32) for r in ('UPDATER','AUTOMATIC','MANUAL','GUARD','OBSERVER')},
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={'backend':sha(incoming/'new-backend'),'frontend':owner.object_digest(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(PACKAGE/'image-recipe/configs/prepare-terminal-workspace.sh'),
prerequisite={'grant_digest':secrets.token_hex(32),'backend_sha256':sha(owner.DESTINATIONS['backend']),
'effective_config_digest':restore_digest}, os_update=context['binding']['os_update'])
binding['release_identity'] = ['2.0.1-alpha', [['archipelago','2.0.1-alpha',binding['artifacts']['backend'],(incoming/'new-backend').stat().st_size],
['os-update-plan.json','2.0.1-alpha',binding['os_update']['plan_sha256'],(context['source']/'plan.json').stat().st_size]]]
manifest = dict(version='2.0.1-alpha', components=[dict(zip(('name','new_version','sha256','size_bytes'), row))
for row in binding['release_identity'][1]],
os_update=dict(version=1,plan_sha256=binding['os_update']['plan_sha256']),
qualification='ROOT-APPOINTED-EXACT-FIXTURE-NOT-PRODUCTION-SIGNATURE')
write(context['source']/'manifest.json', wire.canonical(manifest))
binding['os_update']['manifest_sha256'] = sha(context['source']/'manifest.json')
# A tiny native exact-pin adapter in the disposable guest, with QEMU refusal.
# No signing key, production verifier substitution, or production pass is implied.
native_verifier = r'''#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
int main(int argc,char **argv) {
char vendor[256]={0}, hash[256]={0};
FILE *f=fopen("/sys/class/dmi/id/sys_vendor","r");
if(!f || !fgets(vendor,sizeof vendor,f) || !strstr(vendor,"QEMU")) return 71;
fclose(f);
if(argc!=4 || strcmp(argv[1],"ceremony") || strcmp(argv[2],"verify")) return 72;
int p[2], status; if(pipe(p)) return 73;
pid_t pid=fork(); if(pid<0) return 74;
if(!pid) { close(p[0]);dup2(p[1],1);close(p[1]);
execl("/usr/bin/sha256sum","sha256sum","--",argv[3],(char*)0);_exit(75); }
close(p[1]); f=fdopen(p[0],"r"); if(!fgets(hash,sizeof hash,f)) return 76;
fclose(f); if(waitpid(pid,&status,0)!=pid || !WIFEXITED(status) || WEXITSTATUS(status)) return 77;
return strncmp(hash,"EXPECTED_SHA256",64) ? 78 : 0;
}
'''.replace('EXPECTED_SHA256', binding['os_update']['manifest_sha256'])
write('/root/fixture-native-verifier.c', native_verifier)
run('gcc','-O2','-o',str(executor.VERIFIER),'/root/fixture-native-verifier.c')
executor.VERIFIER.chmod(0o755)
binding['os_update']['verifier_sha256'] = sha(executor.VERIFIER)
# Explicit disposable authentication adapter appointment. Production initial
# custody must use the already appointed native release-root verifier; we cannot
# produce a production-signed candidate here. No production code is patched.
# Model independently established verifier custody and record the substitution.
custody = dict(version=1,verifier_sha256=sha(executor.VERIFIER),
prerequisite_backend_sha256=sha(executor.VERIFIER))
assert not (executor.ROOT/'verifier-custody.json').exists()
write(executor.ROOT/'verifier-custody.json', wire.canonical(custody))
write('/root/fixture-verifier-appointment.json', wire.canonical(dict(
fixture_only=True,production_signature_approval=False,custody=custody)))
write(incoming / 'admission.json', wire.canonical(binding))
write(context['EVIDENCE']/'binding.json', wire.canonical(binding))
files = {name: sha(context['source']/name) for name in ('plan.json','manifest.json','preservation.json')}
plan = dict(schema=1, machine_id_sha256=sha('/etc/machine-id'),
current={'version':'2.0.0-alpha+uat','source_sha256':sha(PACKAGE/'scripts/d09_worker.py'), 'backend_sha256':sha(owner.DESTINATIONS['backend'])},
candidate={'version':'2.0.1-alpha','source_sha256':wire.digest(wire.canonical(files)), 'backend_sha256':binding['artifacts']['backend']},
admin={'uid':admin.pw_uid,'service_uid':account.pw_uid,'access_receipt_sha256':sha('/root/fixture-access.json')},
observations={'root/fixture-access.json':sha('/root/fixture-access.json'),
'root/fixture-verifier-appointment.json':sha('/root/fixture-verifier-appointment.json')}, files=files)
if NATIVE:
plan['current']['source_sha256']=sha(PACKAGE/'accepted-source-receipt.json')
plan['observations'].update({'opt/ota-source/accepted-source-receipt.json':sha(PACKAGE/'accepted-source-receipt.json'),
'opt/os-ota-native-proof.json':sha('/opt/os-ota-native-proof.json'),
'root/accepted-old-running.json':sha('/root/accepted-old-running.json')})
write('/root/bootstrap-preparation.json', wire.canonical(plan))
bootstrap.prepare(Path('/'), Path('/root/bootstrap-preparation.json'), sha('/root/bootstrap-preparation.json'), context['source'])
approval = dict(version=1, prepared_sha256=sha('/root/bootstrap-preparation.json'), package_root=str(PACKAGE),
package_files=activation.package_inventory(PACKAGE), admission_sha256=sha(incoming/'admission.json'),
os_inputs=files, helper_sha256=sha(PACKAGE/'scripts/os_updater_activate.py'),
owner_dropin_sha256=bootstrap.digest(activation.owner_policy(executor.ROOT)))
write('/root/bootstrap-activation.json', wire.canonical(approval))
preserved = {str(p): sha(p) for p in [Path('/etc/passwd'),Path('/etc/group'),Path('/etc/shadow'),
*Path('/var/lib/archipelago/ota-synthetic-canaries').iterdir()]}
try:
result = activation.activate(Path('/root/bootstrap-activation.json'), sha('/root/bootstrap-activation.json'))
except BaseException as exc:
print('ACTIVATION_FAILURE', repr(exc), getattr(exc,'stdout',None), getattr(exc,'stderr',None), flush=True)
run('journalctl','-u','archipelago-d09-lifecycle.service','-u','archipelago.service','--no-pager',check=False)
raise
print('BOOTSTRAP_ACTIVATED', json.dumps(result), flush=True)
assert result['state'] == 'D09_PREREQUISITE_ACTIVATED'
if NATIVE:
print('ACCEPTED_OLD_NATIVE_HEALTH',wait(native_health,'accepted old native health',timeout=90),flush=True)
assert native_identity == {path:sha(path) for path in native_identity}
try:
activation.activate(Path('/root/bootstrap-activation.json'), sha('/root/bootstrap-activation.json'))
raise AssertionError('activation replay accepted')
except ValueError as exc:
assert 'existing activation intent' in str(exc)
staging_generation = secrets.token_hex(16)
write(control/'request-apply.json', json.dumps(['prepare', '--selected-release',
json.dumps(binding['release_identity']), '--staging-generation', staging_generation]), 0o644)
reply = wait(lambda: (control/'result-apply.json').read_text() if (control/'result-apply.json').exists() else None, 'client apply')
print('D09_APPLY_REPLY', reply, flush=True)
lost_reply = json.loads(reply)['exit'] != 0
if lost_reply:
assert 'TimeoutError: timed out' in json.loads(reply)['stderr'], reply
# A lost reply is not permission to replay an OS operation. Observe the
# independently protected exact reservation; never send another request.
def accepted_after_lost_reply():
value = json.loads((owner.ROOT/'ledger.json').read_text())
return any(item['request'].get('staging_generation')==staging_generation
for item in value['requests'].values())
wait(accepted_after_lost_reply,'protected reservation after lost reply',timeout=30)
print('D09_REPLY_LOST_RESERVATION_OBSERVED_NO_RETRY',flush=True)
status_path = executor.ROOT/'transactions'/binding['os_update']['authority_digest']/'status.json'
def completed():
if status_path.exists():
status = json.loads(status_path.read_text())
if status['phase'] == 'RECONCILIATION_REQUIRED':
raise AssertionError(json.dumps(status))
if status['phase'] == 'REBOOT_REQUIRED':
return status
ledger = json.loads((owner.ROOT/'ledger.json').read_text())
if (ledger.get('receipt') or {}).get('phase') == 'CONFLICT':
run('journalctl','--no-pager','-u','archipelago-d09-lifecycle.service',
'-u',ledger['execution']['unit'],check=False)
raise AssertionError(json.dumps(ledger))
status = wait(completed, 'actual OS apply')
def candidate_started():
value = json.loads((owner.ROOT/'ledger.json').read_text())
receipt = value.get('receipt') or {}
if receipt.get('phase') == 'CONFLICT':
raise AssertionError(json.dumps(value))
return receipt if receipt.get('phase') == 'AWAITING_OBSERVATION' else None
receipt = wait(candidate_started, 'candidate manager actual activation', timeout=90)
current = owner.service()
assert current['ActiveState']=='active' and current['InvocationID']==receipt['observed_invocation_id']
assert sha('/proc/'+current['MainPID']+'/exe')==binding['artifacts']['backend']
if NATIVE:
print('CANDIDATE_NATIVE_HEALTH',wait(native_health,'candidate native health',timeout=90),flush=True)
assert native_identity == {path:sha(path) for path in native_identity}
assert preserved == {path: sha(path) for path in preserved}
wallet = Path('/var/lib/archipelago/ota-synthetic-canaries/wallet.db')
wallet.write_bytes(wallet.read_bytes() + b'forward-only-after-OS-apply\n')
forward = sha(wallet)
try:
activation.activate(Path('/root/bootstrap-activation.json'), sha('/root/bootstrap-activation.json'))
raise AssertionError('post-apply bootstrap replay accepted')
except ValueError as exc:
assert 'existing activation intent' in str(exc)
assert sha(wallet) == forward
print('BOOTSTRAP_OS_ACTUAL_EFFECTS', json.dumps({'bootstrap':result,'os_phase':status['phase'],
'os_status_sha256':wire.digest(wire.canonical(status)),
'preserved':preserved,'forward_wallet_sha256':forward,'accepted_native_artifact':NATIVE,'production_signature_approval':False}), flush=True)
write('/var/log/os-ota-stage/bootstrap-result.json', wire.canonical({'passed':True,'bootstrap':result,
'os_status':status,'preserved':preserved,'forward_wallet_sha256':forward,'native_identity':native_identity,
'accepted_native_artifact':NATIVE,'production_signature_approval':False,'client_reply_lost':lost_reply,
'os_state_root':str(executor.ROOT),'application_data_uid':Path('/var/lib/archipelago').stat().st_uid}))
write('/var/log/os-ota-stage/result.json', wire.canonical({'passed':True,
'scope':'bootstrap -> actual D09 owner/worker -> installed Debian OS effects',
'accepted_native_artifact':NATIVE,'production_signature_approval':False,
'postboot_confirmed':False,'phase':status['phase'],'d09_phase':receipt['phase'],'client_reply_lost':lost_reply,
'os_state_root':str(executor.ROOT),'application_data_uid':Path('/var/lib/archipelago').stat().st_uid}))