Resume active profiles safely after forward APT upgrades

This commit is contained in:
Gerardo Betancourt committed 2026-10-28 12:08:15 -04:00
1 parent b1ff87dd81
commit b0c3e71555
4 files changed
+809 -3

No files matched your search

@@ -0,0 +1,440 @@
"""Resume only previously running installed profiles after an APT upgrade.
The pre-install snapshot never stops the process carrying APT. The configure
hook schedules an isolated system service; it waits for dpkg to release its
lock before restarting the captured profiles as their original Linux account.
No credentials, boot policy or unrelated services are changed.
"""
from __future__ import annotations
import argparse
from contextlib import contextmanager
import fcntl
import hashlib
import json
import os
from pathlib import Path
import pwd
import re
import stat
import subprocess
import sys
import tempfile
import time
ROOT = Path('/var/lib/framebridge-expanded/package-upgrade')
MODULE = '/usr/lib/framebridge-expanded/expanded/framebridge_expanded/package_upgrade.py'
PREFIX = '/usr/lib/framebridge-expanded'
PYTHONS = {'/usr/bin/python3', '/usr/bin/python3.12', '/usr/bin/python3.13', '/usr/bin/python3.14'}
ENV = {'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8'}
UNIT = re.compile(r'framebridge-(?:autostart-[0-9a-f]{16}|profile-[0-9a-f]{20}|qa-[A-Za-z0-9-]{1,100})\.service\Z')
MAXIMUM = 2 * 1024 * 1024
def _need(value, message):
if not value:
raise RuntimeError(message)
def _read(path, uid, maximum=MAXIMUM):
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
try:
info = os.fstat(fd)
_need(stat.S_ISREG(info.st_mode) and info.st_uid == uid
and info.st_nlink == 1 and not info.st_mode & 0o077
and info.st_size <= maximum, 'UPGRADE_FILE_UNTRUSTED')
data = os.read(fd, maximum + 1)
_need(len(data) <= maximum, 'UPGRADE_FILE_TOO_LARGE')
return data
finally:
os.close(fd)
def _private(path, uid):
info = path.lstat()
_need(stat.S_ISDIR(info.st_mode) and info.st_uid == uid
and stat.S_IMODE(info.st_mode) == 0o700, 'UPGRADE_DIRECTORY_UNTRUSTED')
def _root():
_need(os.getuid() == 0 and os.geteuid() == 0, 'UPGRADE_ROOT_REQUIRED')
for parent in [Path('/var/lib'), Path('/var/lib/framebridge-expanded')]:
if not parent.exists():
parent.mkdir(mode=0o700)
info = parent.lstat()
_need(stat.S_ISDIR(info.st_mode) and info.st_uid == 0
and not info.st_mode & 0o022, 'UPGRADE_ROOT_PARENT_UNTRUSTED')
ROOT.mkdir(mode=0o700, exist_ok=True)
_private(ROOT, 0)
def _write(path, value):
fd, name = tempfile.mkstemp(prefix='.upgrade-', dir=path.parent)
try:
with os.fdopen(fd, 'wb') as stream:
os.fchmod(stream.fileno(), 0o600)
stream.write((json.dumps(value, indent=2, sort_keys=True) + '\n').encode())
stream.flush()
os.fsync(stream.fileno())
os.replace(name, path)
finally:
if os.path.exists(name):
os.unlink(name)
def _birth(proc):
fields = (proc / 'stat').read_text().rsplit(') ', 1)[1].split()
return fields[19] if fields[0] not in {'Z', 'X'} else None
def identify(proc):
args = [part.decode() for part in (proc / 'cmdline').read_bytes().rstrip(b'\0').split(b'\0')]
if (len(args) != 6 or args[0] not in PYTHONS
or args[1:5] != ['-m', 'framebridge_expanded.runtime', '_manager', '--state']):
return None
env = dict(x.split(b'=', 1) for x in (proc / 'environ').read_bytes().split(b'\0') if b'=' in x)
if (env.get(b'FRAMEBRIDGE_EXPANDED_PREFIX') != PREFIX.encode()
or env.get(b'FRAMEBRIDGE_EXPANDED_STATE') != args[5].encode()
or env.get(b'PYTHONPATH', b'').split(b':')[0] != (PREFIX + '/expanded').encode()):
return None
uid = proc.stat().st_uid
_need(uid != 0, 'UPGRADE_ROOT_PROFILE_REFUSED')
state = Path(args[5])
_need(state.is_absolute() and str(state) == os.path.normpath(str(state))
and not any(ord(c) < 32 for c in str(state)), 'UPGRADE_PROFILE_PATH_INVALID')
for parent in [state, *state.parents]:
_need(not parent.is_symlink(), 'UPGRADE_PROFILE_SYMLINK_REFUSED')
_private(state, uid)
birth = _birth(proc)
_need(birth is not None, 'UPGRADE_MANAGER_NOT_ALIVE')
record = json.loads(_read(state / 'manager.json', uid))
_need(record.get('pid') == int(proc.name) and record.get('birth') == birth
and record.get('uid') == uid, 'UPGRADE_MANAGER_RECORD_MISMATCH')
account = pwd.getpwuid(uid)
config = json.loads(_read(state / 'config.json', uid))
_need(config.get('role') in {'node', 'central', 'standalone'}, 'UPGRADE_CONFIG_INVALID')
group = (proc / 'cgroup').read_text().splitlines()
units = [part for line in group for part in line.split('/') if UNIT.fullmatch(part)]
_need(len(set(units)) <= 1, 'UPGRADE_MANAGER_UNIT_AMBIGUOUS')
return {'uid': uid, 'user': account.pw_name, 'home': account.pw_dir,
'state': str(state), 'pid': int(proc.name), 'birth': birth,
'config_sha256': hashlib.sha256(_read(state / 'config.json', uid)).hexdigest(),
'node_id': config.get('node_id'), 'unit': next(iter(set(units)), None),
'phase': 'CAPTURED'}
def snapshot(proc_root=Path('/proc')):
_root()
rows = []
for proc in proc_root.iterdir():
if not proc.name.isdigit():
continue
try:
row = identify(proc)
except (FileNotFoundError, ProcessLookupError, PermissionError, UnicodeError, IndexError):
continue
if row:
rows.append(row)
keys = [(r['uid'], r['state']) for r in rows]
_need(len(keys) == len(set(keys)), 'UPGRADE_DUPLICATE_PROFILE_MANAGERS')
value = {'schema': 1, 'phase': 'CAPTURED', 'profiles': rows,
'transaction': os.urandom(8).hex(),
'captured_utc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
'boot_policy_changed': False, 'credentials_changed': False}
pending = ROOT / 'pending.json'
if pending.exists() or pending.is_symlink():
previous = json.loads(_read(pending, 0))
_need(previous.get('phase') not in {'SCHEDULED', 'RUNNING'}, 'UPGRADE_PREVIOUS_WORKER_PENDING')
_write(ROOT / 'previous.json', previous)
_write(pending, value)
return value
def schedule():
_root()
pending = ROOT / 'pending.json'
if not pending.exists():
return {'phase': 'NO_UPGRADE_SNAPSHOT'}
value = json.loads(_read(pending, 0))
if value.get('phase') in {'COMPLETE', 'SCHEDULED', 'RUNNING'}:
return value
_need(value.get('phase') == 'CAPTURED', 'UPGRADE_REVIEW_PREVIOUS_FAILURE')
if not value['profiles']:
value['phase'] = 'COMPLETE'
_write(pending, value)
return value
transaction = value.get('transaction', '')
_need(re.fullmatch(r'[a-f0-9]{16}', transaction), 'UPGRADE_TRANSACTION_INVALID')
value['phase'] = 'SCHEDULED'
_write(pending, value)
try:
result = subprocess.run([
'/usr/bin/systemd-run', '--quiet', '--collect',
'--unit=framebridge-package-upgrade-' + transaction, '--on-active=2s',
'--timer-property=RemainAfterElapse=no',
'--property=Type=oneshot', '--property=TimeoutStartSec=600',
'/usr/bin/python3', '-I', '-S', MODULE, 'resume'],
env=ENV, capture_output=True, timeout=15)
_need(result.returncode == 0, 'UPGRADE_WORKER_SCHEDULE_FAILED')
except BaseException:
value['phase'] = 'CAPTURED'
_write(pending, value)
raise
return value
@contextmanager
def _wait_dpkg(timeout=180):
"""Hold APT/dpkg locks throughout bounded runtime replacement."""
deadline = time.monotonic() + timeout
descriptors = []
held = []
try:
for name in ('lock-frontend', 'lock'):
descriptors.append(os.open('/var/lib/dpkg/' + name, os.O_RDWR | os.O_NOFOLLOW))
while True:
try:
for fd in descriptors:
fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
held.append(fd)
break
except BlockingIOError:
for fd in held:
fcntl.lockf(fd, fcntl.LOCK_UN)
held = []
_need(time.monotonic() < deadline, 'UPGRADE_DPKG_LOCK_TIMEOUT')
time.sleep(0.25)
yield
finally:
for fd in held:
fcntl.lockf(fd, fcntl.LOCK_UN)
for fd in descriptors:
os.close(fd)
def _owner(row, operation):
account = pwd.getpwuid(row['uid'])
_need(row['uid'] != 0 and account.pw_name == row['user']
and account.pw_dir == row['home'], 'UPGRADE_ACCOUNT_CHANGED')
args = ['/usr/sbin/runuser', '-u', account.pw_name, '--', '/usr/bin/env', '-i',
'HOME=' + account.pw_dir, 'USER=' + account.pw_name, 'LOGNAME=' + account.pw_name,
'PATH=/usr/bin:/bin', 'LANG=C.UTF-8', 'LC_ALL=C.UTF-8',
'XDG_RUNTIME_DIR=/run/user/' + str(row['uid']),
'DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/' + str(row['uid']) + '/bus',
'/usr/bin/python3', '-I', '-S', MODULE, operation,
'--state', row['state'], '--config-sha256', row['config_sha256'],
'--manager-pid', str(row['pid']), '--manager-birth', row['birth']]
background = row.get('background')
if background:
args += ['--connection-pid', str(background['pid']),
'--connection-birth', str(background['start_ticks'])]
result = subprocess.run(args, env=ENV, capture_output=True, text=True, timeout=120)
_need(result.returncode == 0, 'UPGRADE_OWNER_OPERATION_FAILED:' + operation)
value = json.loads(result.stdout)
_need(value.get('ok') is True, 'UPGRADE_OWNER_NOT_READY:' + operation)
return value
def _start_manual(row):
unit = 'framebridge-profile-' + hashlib.sha256(row['state'].encode()).hexdigest()[:20] + '.service'
base = _unit(row)[:-2] # runuser/env for the owner's actual user bus
result = subprocess.run([*base, '/usr/bin/systemd-run', '--user', '--quiet', '--collect',
'--unit=' + unit, '--property=Type=exec', '--property=ExitType=cgroup',
'--property=KillMode=control-group',
'--setenv=FRAMEBRIDGE_EXPANDED_PREFIX=' + PREFIX,
'--setenv=FRAMEBRIDGE_EXPANDED_STATE=' + row['state'],
'--setenv=HOME=' + row['home'], '--setenv=PATH=/usr/bin:/bin', '/usr/bin/fb', '--state-dir', row['state'], '--json', 'start'],
env=ENV, capture_output=True, timeout=20)
_need(result.returncode == 0, 'UPGRADE_NATIVE_USER_UNIT_START_FAILED')
return unit
def _unit(row):
account = pwd.getpwuid(row['uid'])
_need(row['uid'] != 0 and account.pw_name == row['user']
and account.pw_dir == row['home'], 'UPGRADE_ACCOUNT_CHANGED')
return ['/usr/sbin/runuser', '-u', account.pw_name, '--', '/usr/bin/env', '-i',
'HOME=' + account.pw_dir, 'PATH=/usr/bin:/bin',
'XDG_RUNTIME_DIR=/run/user/' + str(row['uid']),
'DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/' + str(row['uid']) + '/bus',
'/usr/bin/systemctl', '--user']
def _resume():
_root()
pending = ROOT / 'pending.json'
value = json.loads(_read(pending, 0))
_need(value.get('schema') == 1 and value.get('phase') == 'SCHEDULED', 'UPGRADE_INTENT_INVALID')
value['phase'] = 'RUNNING'
_write(pending, value)
failures = []
for row in value['profiles']:
try:
current = identify(Path('/proc') / str(row['pid']))
_need(current and all(current[k] == row[k] for k in
('uid', 'user', 'home', 'state', 'pid', 'birth', 'config_sha256', 'unit')),
'UPGRADE_MANAGER_IDENTITY_CHANGED')
# Migration runs as the profile owner and only recognizes the old
# FrameBridge desktop entry; no third-party launcher is overwritten.
try:
row['desktop'] = _owner(row, 'user-desktop')
except Exception as desktop_error:
row['desktop'] = {'ok': False, 'warning': str(desktop_error)[:160]}
if row['unit']:
current = identify(Path('/proc') / str(row['pid']))
_need(current and all(current[k] == row[k] for k in
('uid', 'state', 'pid', 'birth', 'config_sha256', 'unit')),
'UPGRADE_MANAGER_IDENTITY_CHANGED')
_need(UNIT.fullmatch(row['unit']), 'UPGRADE_UNIT_INVALID')
result = subprocess.run([*_unit(row), 'restart', row['unit']],
env=ENV, capture_output=True, timeout=100)
_need(result.returncode == 0, 'UPGRADE_UNIT_RESTART_FAILED')
row['runtime'] = _owner(row, 'user-ready')
else:
stopped = _owner(row, 'user-stop')
row['background'] = stopped.get('background')
row['unit'] = _start_manual(row)
row['runtime'] = _owner(row, 'user-ready')
row['phase'] = 'READY'
except Exception as error:
row['phase'] = 'FAILED'
row['error'] = str(error)[:160]
failures.append(row['state'])
_write(pending, value)
value['phase'] = 'FAILED' if failures else 'COMPLETE'
value['completed_utc'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
_write(pending, value)
_need(not failures, 'UPGRADE_PROFILE_RESTART_FAILED: inspect /var/lib/framebridge-expanded/package-upgrade/pending.json')
return value
@contextmanager
def _resume_lock():
fd = os.open(ROOT / '.lock', os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
try:
info = os.fstat(fd)
_need(stat.S_ISREG(info.st_mode) and info.st_uid == 0
and info.st_nlink == 1 and stat.S_IMODE(info.st_mode) == 0o600,
'UPGRADE_LOCK_UNTRUSTED')
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
yield
finally:
os.close(fd)
def resume():
_root()
with _resume_lock(), _wait_dpkg():
return _resume()
def _background(state):
from framebridge_expanded import connection_background as background
directory = background._directory(state, False)
record = background._read(directory / 'status.json') if directory else {}
if not record:
return None
valid = background._valid_identity(record, state)
pid = record.get('pid')
if not valid:
_need(type(pid) is not int or not Path('/proc', str(pid)).exists(),
'UPGRADE_BACKGROUND_IDENTITY_UNVERIFIED')
return None
return {'pid': pid, 'start_ticks': record['start_ticks'],
'verified': background.status(state)['verified'],
'preserved_in_memory': True, 'process_reloaded': False}
def user_operation(operation, state, config_sha256, *, manager_pid=None, manager_birth=None,
connection_pid=None, connection_birth=None):
_need(os.getuid() != 0 and os.getuid() == os.geteuid(), 'UPGRADE_OWNER_REQUIRED')
state = Path(state)
_private(state, os.getuid())
data = _read(state / 'config.json', os.getuid())
_need(hashlib.sha256(data).hexdigest() == config_sha256, 'UPGRADE_CONFIG_CHANGED')
sys.path.insert(0, PREFIX + '/expanded')
if operation == 'user-desktop':
from framebridge_expanded.console_desktop import migrate_legacy
return migrate_legacy(state)
from framebridge_expanded import runtime
if operation == 'user-stop':
from framebridge_expanded import autostart
def restart(value):
# runtime.stop writes a manual-stop marker. Retain its former bytes
# even when stopping fails; upgrading must not change boot intent.
marker = state / 'autostart/manual-stop.json'
before = _read(marker, os.getuid()) if os.path.lexists(marker) else None
manager = json.loads(_read(state / 'manager.json', os.getuid()))
_need(manager.get('pid') == manager_pid and manager.get('birth') == manager_birth
and _birth(Path('/proc') / str(manager_pid)) == manager_birth,
'UPGRADE_MANAGER_IDENTITY_CHANGED')
background = _background(state)
try:
current = runtime.stop(state, preserve_connection=True)
deadline = time.monotonic() + 20
while current.get('running') or any(x.get('alive') for x in current.get('processes', [])):
_need(time.monotonic() < deadline, 'UPGRADE_OLD_RUNTIME_STOP_TIMEOUT')
time.sleep(0.1)
current = runtime.status(state)
finally:
if before is None:
marker.unlink(missing_ok=True)
else:
fd, temporary = tempfile.mkstemp(prefix='.upgrade-stop-', dir=marker.parent)
with os.fdopen(fd, 'wb') as stream:
os.fchmod(stream.fileno(), 0o600)
stream.write(before)
os.replace(temporary, marker)
return {'ok': True, 'stopped': True, 'background': background,
'boot_policy_changed': False}
return autostart._configuration_action(state, restart)
else:
deadline = time.monotonic() + 60
while True:
result = runtime.status(state)
manager = json.loads(_read(state / 'manager.json', os.getuid()))
if (result.get('running') and result.get('ready')
and (manager.get('pid'), manager.get('birth')) != (manager_pid, manager_birth)):
break
_need(time.monotonic() < deadline, 'UPGRADE_NEW_RUNTIME_NOT_READY')
time.sleep(0.25)
_need(result.get('running') is True and result.get('ready') is True, 'UPGRADE_NEW_RUNTIME_NOT_READY')
background = _background(state)
if connection_pid:
_need(background and background['pid'] == connection_pid
and background['start_ticks'] == connection_birth, 'UPGRADE_BACKGROUND_IDENTITY_CHANGED')
return {'ok': True, 'ready': True, 'running': True, 'background': background,
'manager': json.loads(_read(state / 'manager.json', os.getuid())),
'boot_policy_changed': False}
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('operation', choices=['snapshot', 'schedule', 'resume',
'user-stop', 'user-ready', 'user-desktop'])
parser.add_argument('--state')
parser.add_argument('--config-sha256')
parser.add_argument('--manager-pid', type=int)
parser.add_argument('--manager-birth')
parser.add_argument('--connection-pid', type=int)
parser.add_argument('--connection-birth', type=int)
args = parser.parse_args(argv)
try:
if args.operation.startswith('user-'):
value = user_operation(args.operation, args.state, args.config_sha256,
manager_pid=args.manager_pid, manager_birth=args.manager_birth,
connection_pid=args.connection_pid, connection_birth=args.connection_birth)
else:
value = globals()[args.operation]()
print(json.dumps(value))
return 0
except Exception as error:
print('FrameBridge package upgrade: ' + str(error), file=sys.stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())
@@ -466,11 +466,13 @@ def start(state):
result.update(ok=False, error=result["error"] or "Startup failed. Check this profile's logs.") result.update(ok=False, error=result["error"] or "Startup failed. Check this profile's logs.")
return result return result
def stop(state): def stop(state, *, preserve_connection=False):
state = state_path(state) state = state_path(state)
load_config(state) load_config(state)
from .connection_background import disconnect from .connection_background import disconnect, status as connection_status
connection = disconnect(state) # Upgrade-only internal scope: retain an identity-verified background
# supervisor while local tools restart. Ordinary CLI stop is unchanged.
connection = connection_status(state) if preserve_connection else disconnect(state)
if not connection.get("ok"): if not connection.get("ok"):
raise RuntimeError("The background ChatGPT connection could not be stopped safely; local services were left unchanged.") raise RuntimeError("The background ChatGPT connection could not be stopped safely; local services were left unchanged.")
from . import tty from . import tty
+25
View File
@@ -173,6 +173,28 @@ def maintainer_prerm(version):
) )
def maintainer_preinst(version=None):
"""Snapshot active profiles without stopping the process carrying APT."""
version = version or VERSION
if not re.fullmatch(r"[0-9][0-9A-Za-z.+:~\\-]*", version):
raise ValueError("Invalid package upgrade version")
module = (BASE / "expanded/framebridge_expanded/package_upgrade.py").read_text()
module = module.rsplit("if __name__ == '__main__':", 1)[0]
return ("#!/bin/sh\nset -eu\ncase \"$1\" in\n upgrade)\n"
'/usr/bin/dpkg --validate-version "$2"\n'
'if /usr/bin/dpkg --compare-versions "$2" lt ' + shlex.quote(version) + "; then\n"
"/usr/bin/python3 -I -S - <<'FRAMEBRIDGE_UPGRADE_SNAPSHOT'\n"
+ module + "\nraise SystemExit(main(['snapshot']))\n"
+ "FRAMEBRIDGE_UPGRADE_SNAPSHOT\nfi\n ;;\nesac\n")
def maintainer_postinst():
return ("#!/bin/sh\nset -eu\ncase \"$1\" in\n configure)\n"
"/usr/bin/python3 -I -S /usr/lib/framebridge-expanded/expanded/framebridge_expanded/package_upgrade.py schedule\n"
" ;;\nesac\n")
def package_relationships(profile): def package_relationships(profile):
"""Keep the opt-in virtual window manager out of automatic APT installs.""" """Keep the opt-in virtual window manager out of automatic APT installs."""
return { return {
@@ -304,6 +326,9 @@ def main(argv=None):
" Installing the package alone does not activate a connection.\n") " Installing the package alone does not activate a connection.\n")
(control / "prerm").write_text(maintainer_prerm(PRIVILEGED_HELPER_FLOOR)) (control / "prerm").write_text(maintainer_prerm(PRIVILEGED_HELPER_FLOOR))
(control / "prerm").chmod(0o755) (control / "prerm").chmod(0o755)
for hook, contents in (("preinst", maintainer_preinst()), ("postinst", maintainer_postinst())):
(control / hook).write_text(contents)
(control / hook).chmod(0o755)
rows = [] rows = []
for path in sorted(STAGE.rglob("*")): for path in sorted(STAGE.rglob("*")):
if path.is_file() and not path.is_symlink() and control not in path.parents: if path.is_file() and not path.is_symlink() and control not in path.parents:
+339
View File
@@ -0,0 +1,339 @@
import hashlib
from contextlib import nullcontext
import json
import os
from pathlib import Path
from types import SimpleNamespace
import tempfile
import unittest
from unittest import mock
from framebridge_expanded import package_upgrade as upgrade
class PackageUpgradeTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.original_read = upgrade._read
self.state = self.root / 'profile'
self.state.mkdir(mode=0o700)
self.config = {'role': 'node', 'node_id': 'test-node'}
self.write(self.state / 'config.json', self.config)
self.proc = self.root / '1234'
self.proc.mkdir()
self.identity = {'pid': 1234, 'birth': '99', 'uid': os.getuid()}
self.write(self.state / 'manager.json', self.identity)
self.args = ['/usr/bin/python3', '-m', 'framebridge_expanded.runtime',
'_manager', '--state', str(self.state)]
(self.proc / 'cmdline').write_bytes(b'\0'.join(x.encode() for x in self.args) + b'\0')
self.environ()
(self.proc / 'stat').write_text('1234 (python3) S ' + ' '.join(['0'] * 18 + ['99']))
(self.proc / 'cgroup').write_text('0::/user.slice/user-1000.slice/user@1000.service/app.slice/test.scope\n')
def write(self, path, data):
path.write_text(json.dumps(data))
path.chmod(0o600)
def environ(self, prefix=None):
prefix = upgrade.PREFIX if prefix is None else prefix
(self.proc / 'environ').write_bytes(b'\0'.join(x.encode() for x in [
'FRAMEBRIDGE_EXPANDED_PREFIX=' + prefix,
'FRAMEBRIDGE_EXPANDED_STATE=' + str(self.state),
'PYTHONPATH=' + prefix + '/expanded:' + prefix + '/portable']) + b'\0')
def identify(self):
if os.getuid() == 0:
self.skipTest('Identity fixtures run as the ordinary build owner, not root')
return upgrade.identify(self.proc)
def test_identifies_only_installed_manager_and_exact_owned_record(self):
row = self.identify()
self.assertEqual(row['pid'], 1234)
self.assertEqual(row['state'], str(self.state))
self.assertEqual(row['birth'], '99')
self.assertIsNone(row['unit'])
self.assertNotIn('config', row)
def test_development_clone_is_not_an_installed_service(self):
self.environ('/home/example/source')
self.assertIsNone(self.identify())
def test_arbitrary_python_command_is_ignored(self):
(self.proc / 'cmdline').write_bytes(b'/usr/bin/python3\0-c\0print(1)\0')
self.assertIsNone(upgrade.identify(self.proc))
def test_pid_reuse_is_rejected(self):
self.write(self.state / 'manager.json', dict(self.identity, birth='98'))
if os.getuid() == 0:
self.skipTest('Ordinary-owner fixture required')
with self.assertRaisesRegex(RuntimeError, 'RECORD_MISMATCH'):
upgrade.identify(self.proc)
def test_recognizes_qa_unit_without_selecting_unrelated_services(self):
(self.proc / 'cgroup').write_text('0::/user.slice/user@1000.service/app.slice/framebridge-qa-mint223-private2-c3418304.service\n')
self.assertEqual(self.identify()['unit'], 'framebridge-qa-mint223-private2-c3418304.service')
def test_unrelated_unit_is_not_restarted(self):
(self.proc / 'cgroup').write_text('0::/user.slice/user@1000.service/app.slice/owner-important.service\n')
self.assertIsNone(self.identify()['unit'])
def test_world_readable_metadata_is_refused(self):
path = self.state / 'config.json'
path.chmod(0o644)
with self.assertRaisesRegex(RuntimeError, 'FILE_UNTRUSTED'):
upgrade._read(path, os.getuid())
def test_metadata_symlink_is_refused(self):
path = self.state / 'alias.json'
path.symlink_to(self.state / 'config.json')
with self.assertRaises(OSError):
upgrade._read(path, os.getuid())
def test_shared_profile_directory_is_refused(self):
self.state.chmod(0o755)
with self.assertRaisesRegex(RuntimeError, 'DIRECTORY_UNTRUSTED'):
upgrade._private(self.state, os.getuid())
def root_read(self, path, uid, maximum=upgrade.MAXIMUM):
# Root-owned production intent is represented by the test owner's file.
return self.original_read(path, os.getuid(), maximum)
def pending(self, profiles=None):
value = {'schema': 1, 'transaction': '1' * 16, 'phase': 'CAPTURED', 'profiles': profiles or []}
self.write(self.root / 'pending.json', value)
return value
def test_fresh_install_does_not_schedule_or_start_profiles(self):
with mock.patch.object(upgrade, 'ROOT', self.root), \
mock.patch.object(upgrade, '_root'), \
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
mock.patch.object(upgrade.subprocess, 'run') as run:
self.assertEqual(upgrade.schedule()['phase'], 'NO_UPGRADE_SNAPSHOT')
run.assert_not_called()
def test_empty_upgrade_stays_stopped(self):
self.pending()
with mock.patch.object(upgrade, 'ROOT', self.root), \
mock.patch.object(upgrade, '_root'), \
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
mock.patch.object(upgrade.subprocess, 'run') as run:
self.assertEqual(upgrade.schedule()['phase'], 'COMPLETE')
run.assert_not_called()
def test_restart_is_detached_and_delayed_past_package_transaction(self):
self.pending([{'state': str(self.state)}])
with mock.patch.object(upgrade, 'ROOT', self.root), \
mock.patch.object(upgrade, '_root'), \
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
mock.patch.object(upgrade.subprocess, 'run', return_value=SimpleNamespace(returncode=0)) as run:
self.assertEqual(upgrade.schedule()['phase'], 'SCHEDULED')
args = run.call_args.args[0]
self.assertEqual(args[0], '/usr/bin/systemd-run')
self.assertIn('--on-active=2s', args)
self.assertEqual(args[-2:], [upgrade.MODULE, 'resume'])
def test_scheduling_failure_keeps_captured_intent(self):
self.pending([{'state': str(self.state)}])
with mock.patch.object(upgrade, 'ROOT', self.root), \
mock.patch.object(upgrade, '_root'), \
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
mock.patch.object(upgrade.subprocess, 'run', return_value=SimpleNamespace(returncode=1)):
with self.assertRaisesRegex(RuntimeError, 'SCHEDULE_FAILED'):
upgrade.schedule()
self.assertEqual(json.loads((self.root / 'pending.json').read_text())['phase'], 'CAPTURED')
def test_repeated_configure_does_not_schedule_second_restart(self):
self.write(self.root / 'pending.json', {'phase': 'SCHEDULED'})
with mock.patch.object(upgrade, 'ROOT', self.root), \
mock.patch.object(upgrade, '_root'), \
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
mock.patch.object(upgrade.subprocess, 'run') as run:
self.assertEqual(upgrade.schedule()['phase'], 'SCHEDULED')
run.assert_not_called()
def test_changed_manager_is_not_stopped_and_failure_is_recorded(self):
row = {'uid': 1000, 'user': 'owner', 'home': '/home/owner',
'state': str(self.state), 'pid': 1234, 'birth': '99',
'config_sha256': 'a' * 64, 'unit': None, 'phase': 'CAPTURED'}
self.write(self.root / 'pending.json', {'schema': 1, 'phase': 'SCHEDULED', 'profiles': [row]})
with mock.patch.object(upgrade, 'ROOT', self.root), \
mock.patch.object(upgrade, '_root'), \
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
mock.patch.object(upgrade, '_wait_dpkg', return_value=nullcontext()), \
mock.patch.object(upgrade, '_resume_lock', return_value=nullcontext()), \
mock.patch.object(upgrade, 'identify', return_value=dict(row, birth='100')), \
mock.patch.object(upgrade, '_owner') as owner:
with self.assertRaisesRegex(RuntimeError, 'PROFILE_RESTART_FAILED'):
upgrade.resume()
owner.assert_not_called()
value = json.loads((self.root / 'pending.json').read_text())
self.assertEqual(value['phase'], 'FAILED')
self.assertIn('IDENTITY_CHANGED', value['profiles'][0]['error'])
def test_manual_profile_restart_never_enables_service(self):
row = {'uid': 1000, 'user': 'owner', 'home': '/home/owner',
'state': str(self.state), 'pid': 1234, 'birth': '99',
'config_sha256': 'a' * 64, 'unit': None, 'phase': 'CAPTURED'}
self.write(self.root / 'pending.json', {'schema': 1, 'phase': 'SCHEDULED', 'profiles': [row]})
with mock.patch.object(upgrade, 'ROOT', self.root), \
mock.patch.object(upgrade, '_root'), \
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
mock.patch.object(upgrade, '_wait_dpkg', return_value=nullcontext()), \
mock.patch.object(upgrade, '_resume_lock', return_value=nullcontext()), \
mock.patch.object(upgrade, 'identify', return_value=row), \
mock.patch.object(upgrade, '_owner', return_value={'ok': True, 'ready': True}) as owner, \
mock.patch.object(upgrade, '_start_manual', return_value='framebridge-profile-' + 'a' * 20 + '.service'), \
mock.patch.object(upgrade.subprocess, 'run') as run:
self.assertEqual(upgrade.resume()['phase'], 'COMPLETE')
self.assertEqual([c.args[1] for c in owner.call_args_list], ['user-desktop', 'user-stop', 'user-ready'])
run.assert_not_called()
def test_snapshot_does_not_stop_the_process_running_apt(self):
row = {'uid': os.getuid(), 'state': str(self.state), 'pid': 1234}
with mock.patch.object(upgrade, 'ROOT', self.root), \
mock.patch.object(upgrade, '_root'), \
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
mock.patch.object(upgrade, 'identify', return_value=row), \
mock.patch.object(upgrade.subprocess, 'run') as run:
value = upgrade.snapshot(self.root)
self.assertEqual(value['phase'], 'CAPTURED')
run.assert_not_called()
def test_optional_desktop_migration_failure_does_not_stop_runtime_recovery(self):
row = {'uid': 1000, 'user': 'owner', 'home': '/home/owner',
'state': str(self.state), 'pid': 1234, 'birth': '99',
'config_sha256': 'a' * 64, 'unit': None, 'phase': 'CAPTURED'}
self.write(self.root / 'pending.json', {'schema': 1, 'phase': 'SCHEDULED', 'profiles': [row]})
def owner(profile, operation):
if operation == 'user-desktop':
raise RuntimeError('Legacy console is closed')
return {'ok': True, 'ready': True}
with mock.patch.object(upgrade, 'ROOT', self.root), \
mock.patch.object(upgrade, '_root'), \
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
mock.patch.object(upgrade, '_wait_dpkg', return_value=nullcontext()), \
mock.patch.object(upgrade, '_resume_lock', return_value=nullcontext()), \
mock.patch.object(upgrade, 'identify', return_value=row), \
mock.patch.object(upgrade, '_owner', side_effect=owner), \
mock.patch.object(upgrade, '_start_manual', return_value='framebridge-profile-' + 'a' * 20 + '.service'):
value = upgrade.resume()
self.assertEqual(value['phase'], 'COMPLETE')
self.assertEqual(value['profiles'][0]['phase'], 'READY')
self.assertFalse(value['profiles'][0]['desktop']['ok'])
def check_stop_failure_retains_marker(self, before):
if os.getuid() == 0:
self.skipTest('Ordinary-owner fixture required')
from framebridge_expanded import runtime, autostart
directory = self.state / 'autostart'
directory.mkdir(mode=0o700)
marker = directory / 'manual-stop.json'
if before is not None:
marker.write_bytes(before)
marker.chmod(0o600)
def fail(state, **kwargs):
marker.write_text('{"temporary":true}')
marker.chmod(0o600)
raise RuntimeError('simulated bounded stop failure')
with mock.patch.object(runtime, 'stop', side_effect=fail), \
mock.patch.object(upgrade, '_birth', return_value='99'), \
mock.patch.object(upgrade, '_background', return_value=None), \
mock.patch.object(runtime, 'start') as start, \
mock.patch.object(autostart, '_configuration_action', side_effect=lambda state, action: action(state)):
with self.assertRaisesRegex(RuntimeError, 'bounded stop failure'):
upgrade.user_operation('user-stop', str(self.state),
hashlib.sha256((self.state / 'config.json').read_bytes()).hexdigest(),
manager_pid=1234, manager_birth='99')
start.assert_not_called()
if before is None:
self.assertFalse(marker.exists())
else:
self.assertEqual(marker.read_bytes(), before)
def test_stop_failure_retains_absent_manual_stop_policy(self):
self.check_stop_failure_retains_marker(None)
def test_stop_failure_retains_existing_manual_stop_bytes(self):
self.check_stop_failure_retains_marker(b'{"manual_stop": true}\n')
def test_user_unit_restart_rejects_account_reassignment(self):
row = {'uid': 1000, 'user': 'owner', 'home': '/home/owner'}
account = SimpleNamespace(pw_name='different', pw_dir='/home/different')
with mock.patch.object(upgrade.pwd, 'getpwuid', return_value=account):
with self.assertRaisesRegex(RuntimeError, 'ACCOUNT_CHANGED'):
upgrade._unit(row)
def test_manual_start_uses_an_independent_user_service_cgroup(self):
row = {'uid': os.getuid(), 'user': 'owner', 'home': '/home/owner', 'state': str(self.state)}
account = SimpleNamespace(pw_name='owner', pw_dir='/home/owner')
with mock.patch.object(upgrade.pwd, 'getpwuid', return_value=account), \
mock.patch.object(upgrade.subprocess, 'run', return_value=SimpleNamespace(returncode=0)) as run:
if os.getuid() == 0:
self.skipTest('Ordinary-owner fixture required')
unit = upgrade._start_manual(row)
args = run.call_args.args[0]
self.assertIn('/usr/bin/systemd-run', args)
self.assertIn('--property=ExitType=cgroup', args)
self.assertIn('--property=KillMode=control-group', args)
self.assertIn('--unit=' + unit, args)
self.assertEqual(args[-5:], ['/usr/bin/fb', '--state-dir', str(self.state), '--json', 'start'])
self.assertNotIn('enable', args)
def test_background_identity_failure_is_refused_without_signaling(self):
from framebridge_expanded import connection_background as background
with mock.patch.object(background, '_directory', return_value=self.state), \
mock.patch.object(background, '_read', return_value={'pid': os.getpid()}), \
mock.patch.object(background, '_valid_identity', return_value=False), \
mock.patch.object(background, 'disconnect') as disconnect:
with self.assertRaisesRegex(RuntimeError, 'BACKGROUND_IDENTITY_UNVERIFIED'):
upgrade._background(self.state)
disconnect.assert_not_called()
def test_verified_background_connection_is_retained_without_reading_credentials(self):
from framebridge_expanded import connection_background as background
record = {'pid': 1234, 'start_ticks': 88}
with mock.patch.object(background, '_directory', return_value=self.state), \
mock.patch.object(background, '_read', return_value=record), \
mock.patch.object(background, '_valid_identity', return_value=True), \
mock.patch.object(background, 'status', return_value={'verified': True}):
value = upgrade._background(self.state)
self.assertTrue(value['preserved_in_memory'])
self.assertFalse(value['process_reloaded'])
self.assertEqual(value['start_ticks'], 88)
def check_runtime_stop_connection(self, preserve):
from framebridge_expanded import runtime, connection_background, tty, autostart
with mock.patch.object(runtime, 'load_config', return_value=self.config), \
mock.patch.object(runtime, '_read', return_value={}), \
mock.patch.object(runtime, '_terminate'), \
mock.patch.object(runtime, '_atomic'), \
mock.patch.object(runtime, 'status', return_value={'running': False}), \
mock.patch.object(tty, 'stop', return_value={'ok': True, 'cleanup_verified': True}), \
mock.patch.object(autostart, 'mark_manual_stop'), \
mock.patch.object(connection_background, 'disconnect', return_value={'ok': True}) as disconnect, \
mock.patch.object(connection_background, 'status', return_value={'ok': True, 'alive': True}) as status:
if preserve:
runtime.stop(self.state, preserve_connection=True)
disconnect.assert_not_called()
status.assert_called_once_with(self.state)
else:
runtime.stop(self.state)
disconnect.assert_called_once_with(self.state)
status.assert_not_called()
def test_normal_runtime_stop_still_disconnects(self):
self.check_runtime_stop_connection(False)
def test_upgrade_runtime_stop_retains_existing_connection(self):
self.check_runtime_stop_connection(True)
if __name__ == '__main__':
unittest.main()