165 lines
9.1 KiB
Python
165 lines
9.1 KiB
Python
#!/usr/bin/python3
|
|
"""Real PID1/netns fixture, only inside the dedicated disposable container."""
|
|
import hashlib
|
|
import ast
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import time
|
|
|
|
|
|
def run(*argv, **kwargs):
|
|
return subprocess.run(argv, check=True, capture_output=True, text=True, timeout=20, **kwargs).stdout.strip()
|
|
|
|
|
|
assert os.environ.get('RUNTIME_UPLINK_DISPOSABLE') == '1'
|
|
assert Path('/run/systemd/container').exists()
|
|
assert run('hostname') == 'runtime-uplink-fixture'
|
|
SOURCE = Path('/opt/uplink')
|
|
helper = SOURCE / 'runtime_uplink.py'
|
|
spec = importlib.util.spec_from_file_location('uplink', helper)
|
|
uplink = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(uplink)
|
|
|
|
|
|
def net(*args):
|
|
return run('ip', 'netns', 'exec', 'archipelago-runtime', 'ip', *args)
|
|
|
|
|
|
def state(unit, property):
|
|
return run('systemctl', 'show', unit, '-p', property, '--value')
|
|
|
|
|
|
def until(test):
|
|
deadline = time.monotonic() + 15
|
|
while not test():
|
|
assert time.monotonic() < deadline, 'fixture deadline'
|
|
time.sleep(.1)
|
|
|
|
|
|
run('ip', 'netns', 'add', 'archipelago-runtime')
|
|
# A misleading wg interface with an address and carrier is NOT a default.
|
|
net('link', 'add', 'wg0', 'type', 'dummy')
|
|
net('addr', 'add', '10.44.0.1/16', 'dev', 'wg0')
|
|
net('link', 'set', 'wg0', 'up')
|
|
net('link', 'set', 'lo', 'up')
|
|
runtime = Path('/run/uplink-runtime')
|
|
runtime.mkdir()
|
|
sentinel = runtime / 'wallet-sentinel'
|
|
sentinel.write_bytes(b'disposable retained data: never reset')
|
|
baseline = hashlib.sha256(sentinel.read_bytes()).hexdigest()
|
|
ns = Path('/run/netns/archipelago-runtime')
|
|
ns_before = (ns.stat().st_dev, ns.stat().st_ino)
|
|
|
|
# Render the actual C01 bootstrap template, changing only paths and the Podman
|
|
# command to a sentinel. The barrier, ordering, timeout and restart behavior are
|
|
# the production directives. No Podman/app operation occurs in this fixture.
|
|
template = (SOURCE / 'archipelago-runtime-podman.service').read_text()
|
|
template = template.replace('/usr/libexec/runtime_uplink.py', str(helper)).replace('%t', str(runtime))
|
|
template = template.replace('ExecStart=/usr/bin/podman info --format={{.Store.GraphRoot}}',
|
|
'ExecStart=/usr/bin/touch /run/bootstrap-ran')
|
|
template = template.replace('[Service]', '[Service]\nNetworkNamespacePath=/run/netns/archipelago-runtime')
|
|
Path('/etc/systemd/system/uplink-bootstrap.service').write_text(template)
|
|
# Project the exact user-manager preflight directives into a PID1-owned fixture
|
|
# unit. This tests the gate before manager exec, not a full C01 migration.
|
|
manager = (SOURCE / '81-c01-runtime-access.conf.in').read_text()
|
|
directives = '\n'.join(line for line in manager.splitlines() if line.startswith(('ExecCondition=', 'NetworkNamespacePath=', 'TimeoutStartSec=')))
|
|
directives = directives.replace('/usr/libexec/runtime_uplink.py', str(helper)).replace('/run/user/1000', str(runtime))
|
|
Path('/etc/systemd/system/uplink-manager.service').write_text(
|
|
'[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=/usr/bin/touch /run/manager-ran\n' + directives + '\n')
|
|
modules = next(ast.literal_eval(node.value) for node in ast.parse((SOURCE / 'provision-d09-lifecycle.py').read_text()).body
|
|
if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'MODULES' for t in node.targets))
|
|
assert 'runtime_uplink.py' in modules
|
|
assert '--c01' in (SOURCE / 'archipelago-runtime-worker.service').read_text()
|
|
assert 'ExecCondition=' in (SOURCE / '79-runtime-uplink.conf').read_text()
|
|
Path('/etc/systemd/system/uplink-stopped.service').write_text('[Service]\nExecStart=/usr/bin/touch /run/stopped-app-ran\n')
|
|
run('systemctl', 'daemon-reload')
|
|
run('systemctl', 'start', '--no-block', 'uplink-bootstrap.service')
|
|
run('systemctl', 'start', '--no-block', 'uplink-manager.service')
|
|
until(lambda: state('uplink-bootstrap.service', 'SubState') in ('condition', 'start-pre'))
|
|
time.sleep(1.5)
|
|
assert not Path('/run/bootstrap-ran').exists()
|
|
assert not Path('/run/manager-ran').exists()
|
|
# Actual kernel default, deliberately another nonstandard name. No WAN packets.
|
|
net('link', 'add', 'wg-real-default', 'type', 'dummy')
|
|
net('addr', 'add', '192.0.2.2/24', 'dev', 'wg-real-default')
|
|
net('link', 'set', 'wg-real-default', 'up')
|
|
net('route', 'add', 'default', 'via', '192.0.2.1', 'dev', 'wg-real-default')
|
|
until(lambda: state('uplink-bootstrap.service', 'ActiveState') == 'active')
|
|
assert Path('/run/bootstrap-ran').exists()
|
|
until(lambda: Path('/run/manager-ran').exists())
|
|
assert state('uplink-bootstrap.service', 'NRestarts') == '0'
|
|
# Exercise the actual unprivileged USER-unit namespace lookup permissions too.
|
|
run('ip', 'netns', 'exec', 'archipelago-runtime', 'runuser', '-u', 'nobody', '--',
|
|
'python3', '-I', '-S', str(helper), '--c01', '--runtime-dir', str(runtime), '--timeout=5')
|
|
|
|
# Retain a distinct real nsfs object with a misleading link but no default.
|
|
run('ip', 'netns', 'add', 'retained-rootless')
|
|
run('ip', '-n', 'retained-rootless', 'link', 'add', 'wg0', 'type', 'dummy')
|
|
run('ip', '-n', 'retained-rootless', 'addr', 'add', '10.44.0.1/16', 'dev', 'wg0')
|
|
run('ip', '-n', 'retained-rootless', 'link', 'set', 'wg0', 'up')
|
|
retained = runtime / 'containers/networks/rootless-netns/rootless-netns'
|
|
retained.parent.mkdir(parents=True)
|
|
retained.touch()
|
|
run('mount', '--bind', '/run/netns/retained-rootless', str(retained))
|
|
pin = os.open(retained, os.O_RDONLY)
|
|
retained_id = uplink.namespace_id(pin)
|
|
info = retained.parent / 'info.json'
|
|
info.write_text('{"IPAddresses":["192.0.2.2"]}')
|
|
before_info = info.read_bytes()
|
|
before_routes = run('ip', '-j', '-n', 'retained-rootless', 'route', 'show', 'table', 'all')
|
|
run('systemctl', 'stop', 'uplink-bootstrap.service')
|
|
Path('/run/bootstrap-ran').unlink()
|
|
run('systemctl', 'start', 'uplink-bootstrap.service')
|
|
assert state('uplink-bootstrap.service', 'ActiveState') == 'inactive'
|
|
assert 'retained rootless namespace requires owner reconciliation' in run('journalctl', '-u', 'uplink-bootstrap.service', '--no-pager')
|
|
assert not Path('/run/bootstrap-ran').exists()
|
|
assert uplink.namespace_id(pin) == retained_id == (retained.stat().st_dev, retained.stat().st_ino)
|
|
assert before_routes == run('ip', '-j', '-n', 'retained-rootless', 'route', 'show', 'table', 'all')
|
|
assert info.read_bytes() == before_info
|
|
assert hashlib.sha256(sentinel.read_bytes()).hexdigest() == baseline
|
|
assert (ns.stat().st_dev, ns.stat().st_ino) == ns_before
|
|
assert state('uplink-stopped.service', 'ActiveState') == 'inactive'
|
|
assert not Path('/run/stopped-app-ran').exists()
|
|
# Simulate the repaired network but stale Podman cache: still refuse, preserve.
|
|
info.write_text('{"IPAddresses":["10.44.0.1"]}')
|
|
before_info = info.read_bytes()
|
|
run('ip', '-n', 'retained-rootless', 'addr', 'add', '192.0.2.2/24', 'dev', 'wg0')
|
|
run('ip', '-n', 'retained-rootless', 'route', 'add', 'default', 'via', '192.0.2.1', 'dev', 'wg0')
|
|
run('systemctl', 'start', 'uplink-bootstrap.service')
|
|
assert state('uplink-bootstrap.service', 'ActiveState') == 'inactive'
|
|
assert 'address cache differs from uplink' in run('journalctl', '-u', 'uplink-bootstrap.service', '--no-pager')
|
|
assert info.read_bytes() == before_info and uplink.namespace_id(pin) == retained_id
|
|
# Only the fixture owner prepares consistent metadata; the helper never does.
|
|
info.write_text('{"IPAddresses":["192.0.2.2"]}')
|
|
run('systemctl', 'start', 'uplink-bootstrap.service')
|
|
assert state('uplink-bootstrap.service', 'ActiveState') == 'active'
|
|
assert uplink.namespace_id(pin) == retained_id
|
|
os.close(pin)
|
|
# Host namespace cannot impersonate C01 readiness even with an appointed path.
|
|
rejected = subprocess.run(['python3', '-I', '-S', str(helper), '--c01', '--runtime-dir', str(runtime), '--timeout=1'], capture_output=True, text=True)
|
|
assert rejected.returncode == 1 and 'outside the appointed' in rejected.stderr
|
|
# The actual legacy manager condition must skip an unavailable-uplink activation
|
|
# even with Restart=always. Outer fixture namespace deliberately has no default.
|
|
legacy = (SOURCE / '79-legacy-manager-uplink.conf').read_text()
|
|
legacy = legacy.replace('/opt/archipelago/scripts/runtime_uplink.py', str(helper))
|
|
legacy = legacy.replace('--runtime-dir=/run/user/1000', '--runtime-dir=/run/legacy-empty --timeout=1')
|
|
Path('/etc/systemd/system/uplink-legacy.service').write_text(
|
|
'[Service]\nExecStart=/usr/bin/touch /run/legacy-ran\nRestart=always\n' + legacy)
|
|
run('systemctl', 'daemon-reload')
|
|
run('systemctl', 'start', 'uplink-legacy.service')
|
|
time.sleep(2)
|
|
assert state('uplink-legacy.service', 'ActiveState') == 'inactive'
|
|
assert not Path('/run/legacy-ran').exists()
|
|
assert state('uplink-legacy.service', 'NRestarts') == '0'
|
|
print(json.dumps({'boot_order_no_default_blocked': True, 'real_default_releases_bootstrap': True,
|
|
'user_manager_preflight_ordered': True, 'provisioning_module_present': True,
|
|
'unprivileged_namespace_gate': True,
|
|
'misleading_interface_names_ignored': True, 'retained_broken_refused_preserved': True,
|
|
'healthy_retained_namespace_preserved': True, 'stale_cache_refused_preserved': True,
|
|
'stopped_app_preserved': True,
|
|
'host_cannot_satisfy_c01': True, 'legacy_deadline_no_restart_loop': True,
|
|
'daemon_restarts': 0}, sort_keys=True))
|