fix: preserve guest network identities and refresh session profiles
This commit is contained in:
@@ -46,3 +46,10 @@ in `/etc/hydra/defaults.toml` affect only new profiles; explicit CLI options win
|
|||||||
counter guests and private headless viewers, including manual USB selection,
|
counter guests and private headless viewers, including manual USB selection,
|
||||||
reserved switching under inhibition, recovery and reconnect. See
|
reserved switching under inhibition, recovery and reconnect. See
|
||||||
[verification commands and physical checks still pending](docs/verification.md).
|
[verification commands and physical checks still pending](docs/verification.md).
|
||||||
|
|
||||||
|
An empty session opens local recovery. Enter `:shell`, create and start guests
|
||||||
|
with `hydra add` / `hydra start`, then leave the shell and enter `:refresh`.
|
||||||
|
Refresh rebuilds workspaces and reconnects viewers for the current profiles;
|
||||||
|
running VMs keep their processes and disks. Use it after adding or removing
|
||||||
|
profiles. New profiles save an individual MAC address; legacy profiles obtain
|
||||||
|
a stable address from their owner and disk path, including on the shared bridge.
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Check real NIC identities on a bridge isolated in user/net/mount namespaces."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from hydra.profiles import Profile
|
||||||
|
from hydra.runtime import Runtime
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--namespace-root', type=Path, help=argparse.SUPPRESS)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.namespace_root is None:
|
||||||
|
root = Path(tempfile.mkdtemp(prefix='hydra-network-', dir='/tmp'))
|
||||||
|
print('Evidence: ' + str(root), flush=True)
|
||||||
|
(root / 'parent-namespaces.json').write_text(json.dumps({name: str(Path('/proc/self/ns', name).readlink())
|
||||||
|
for name in ('user', 'net', 'mnt')}))
|
||||||
|
# All bridge and /etc changes below occur after this explicit namespace
|
||||||
|
# boundary. The inherited host bridge configuration is never modified.
|
||||||
|
subprocess.run(['unshare', '-Urnm', '--', str(Path(sys.executable).resolve()),
|
||||||
|
str(Path(__file__).resolve()), '--namespace-root', str(root)], check=True)
|
||||||
|
return
|
||||||
|
root = args.namespace_root
|
||||||
|
parent_namespaces = json.loads((root / 'parent-namespaces.json').read_text())
|
||||||
|
namespaces = {name: str(Path('/proc/self/ns', name).readlink()) for name in ('user', 'net', 'mnt')}
|
||||||
|
assert all(namespaces[name] != parent_namespaces[name] for name in namespaces), 'private namespaces required'
|
||||||
|
private_etc = root / 'etc'
|
||||||
|
(private_etc / 'qemu').mkdir(parents=True)
|
||||||
|
(private_etc / 'qemu/bridge.conf').write_text('allow hydrabr0\n')
|
||||||
|
subprocess.run(['mount', '--bind', str(private_etc), '/etc'], check=True)
|
||||||
|
subprocess.run(['ip', 'link', 'add', 'hydrabr0', 'type', 'bridge'], check=True)
|
||||||
|
subprocess.run(['ip', 'link', 'set', 'hydrabr0', 'up'], check=True)
|
||||||
|
manager = Runtime(root / 'config', root / 'state', root / 'run')
|
||||||
|
result = {'evidence': str(root), 'namespace': 'private user/net/mount',
|
||||||
|
'parent_namespaces': parent_namespaces, 'probe_namespaces': namespaces, 'names': {}}
|
||||||
|
try:
|
||||||
|
for name in ('alpha', 'beta'):
|
||||||
|
manager.add(Profile(name, memory=128, cpus=1, disk_size='8M',
|
||||||
|
graphics='software', network='bridge'))
|
||||||
|
manager.start(name)
|
||||||
|
|
||||||
|
def actual_mac(name):
|
||||||
|
with manager._connect(manager.paths(name)) as qmp:
|
||||||
|
network = qmp.command('human-monitor-command', {'command-line': 'info network'})
|
||||||
|
mac = manager.profile(name).mac
|
||||||
|
assert 'macaddr=' + mac in network, network
|
||||||
|
assert int(mac.split(':')[0], 16) & 3 == 2
|
||||||
|
return mac, network
|
||||||
|
|
||||||
|
for name in ('alpha', 'beta'):
|
||||||
|
mac, network = actual_mac(name)
|
||||||
|
result['names'][name] = {'mac': mac, 'network': network}
|
||||||
|
assert result['names']['alpha']['mac'] != result['names']['beta']['mac']
|
||||||
|
links = json.loads(subprocess.check_output(['ip', '-j', 'link', 'show', 'master', 'hydrabr0'], text=True))
|
||||||
|
assert len(links) == 2, links
|
||||||
|
result['bridge_ports'] = [link['ifname'] for link in links]
|
||||||
|
manager.stop('alpha', force=True)
|
||||||
|
manager.start('alpha')
|
||||||
|
assert actual_mac('alpha')[0] == result['names']['alpha']['mac']
|
||||||
|
result['restart_preserved_mac'] = True
|
||||||
|
result['dhcp_and_guest_network_drivers_pending'] = True
|
||||||
|
result['passed'] = True
|
||||||
|
(root / 'result.json').write_text(json.dumps(result, indent=2) + '\n')
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
finally:
|
||||||
|
for profile in manager.profiles():
|
||||||
|
if manager.status(profile.name)['status'] == 'running':
|
||||||
|
manager.stop(profile.name, force=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Exercise the product's empty-session/recovery/refresh loop privately."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from hydra.runtime import Runtime
|
||||||
|
from smoke import (wait_for, counter, focus_events, fresh_focus_transition,
|
||||||
|
measure_hidden_interval, classify_compositor_errors)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--labwc', default=os.environ.get('HYDRA_LABWC', 'labwc'))
|
||||||
|
parser.add_argument('--viewer', default=os.environ.get('HYDRA_VIEWER', 'remote-viewer'))
|
||||||
|
parser.add_argument('--keyboard', required=True, help='compiled dev/input-probe.c')
|
||||||
|
args = parser.parse_args()
|
||||||
|
for name in ('labwc', 'viewer', 'keyboard'):
|
||||||
|
executable = shutil.which(getattr(args, name))
|
||||||
|
if not executable:
|
||||||
|
parser.error('missing executable: ' + getattr(args, name))
|
||||||
|
setattr(args, name, executable)
|
||||||
|
if not os.access('/dev/kvm', os.R_OK | os.W_OK):
|
||||||
|
parser.error('KVM access required')
|
||||||
|
source = Path(__file__).resolve().parents[1]
|
||||||
|
root = Path(tempfile.mkdtemp(prefix='hydra-session-refresh-', dir='/tmp'))
|
||||||
|
print('Evidence: ' + str(root), flush=True)
|
||||||
|
xdg = root / 'xdg'; xdg.mkdir(mode=0o700)
|
||||||
|
manager = Runtime(root / 'config', root / 'state', root / 'run')
|
||||||
|
cli = [sys.executable, str(source / 'bin/hydra'), '--config', str(manager.config),
|
||||||
|
'--state', str(manager.state), '--runtime', str(manager.runtime)]
|
||||||
|
subprocess.run(['as', '--32', str(source / 'dev/guest-probe.S'), '-o', str(root / 'boot.o')], check=True)
|
||||||
|
subprocess.run(['objcopy', '-j', '.text', '-O', 'binary', str(root / 'boot.o'), str(root / 'boot.bin')], check=True)
|
||||||
|
(root / 'boot.raw').write_bytes((root / 'boot.bin').read_bytes() + bytes(8 * 1024 * 1024 - 512))
|
||||||
|
|
||||||
|
def wrapper(name, code):
|
||||||
|
path = root / name
|
||||||
|
path.write_text('#!' + sys.executable + '\n' + code)
|
||||||
|
path.chmod(0o700)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
common = 'import json, os, pathlib, subprocess, sys, time\nroot = pathlib.Path(' + repr(str(root)) + ')\n'
|
||||||
|
labwc = wrapper('labwc-private', common + '''
|
||||||
|
if '--version' in sys.argv:
|
||||||
|
os.execv(@LABWC@, [@LABWC@] + sys.argv[1:])
|
||||||
|
config = pathlib.Path(sys.argv[sys.argv.index('-C') + 1])
|
||||||
|
number = len(list(root.glob('snapshot-*.json')))
|
||||||
|
data = json.loads((config.parent / 'session.json').read_text())
|
||||||
|
data.update(pid=os.getpid(), config_directory=str(config))
|
||||||
|
(root / ('snapshot-%d.xml' % number)).write_text((config / 'rc.xml').read_text())
|
||||||
|
(root / ('snapshot-%d.json' % number)).write_text(json.dumps(data))
|
||||||
|
# Test-only wrapper replaces physical backends before the real compositor runs.
|
||||||
|
os.environ.update(WLR_BACKENDS='headless', WLR_RENDERER='pixman',
|
||||||
|
WLR_XWAYLAND='/bin/false', LABWC_UPDATE_ACTIVATION_ENV='0',
|
||||||
|
XDG_DATA_DIRS='/nonexistent')
|
||||||
|
os.execv(@LABWC@, [@LABWC@, '-d'] + sys.argv[1:])
|
||||||
|
'''.replace('@LABWC@', repr(args.labwc)))
|
||||||
|
viewer = wrapper('viewer-private', common + '''
|
||||||
|
if '--help' in sys.argv:
|
||||||
|
os.execv(VIEWER, [VIEWER] + sys.argv[1:])
|
||||||
|
name = sys.argv[sys.argv.index('--title') + 1].split(': ', 1)[1]
|
||||||
|
(root / (name + '-viewer.json')).write_text(json.dumps({
|
||||||
|
'argv': sys.argv[1:], 'config': os.environ['XDG_CONFIG_HOME'],
|
||||||
|
'gdk_backend': os.environ.get('GDK_BACKEND'), 'wayland_display': os.environ['WAYLAND_DISPLAY']}))
|
||||||
|
log = os.open(root / (name + '.log'), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||||
|
os.dup2(log, 1); os.dup2(log, 2)
|
||||||
|
os.environ['WAYLAND_DEBUG'] = '1'
|
||||||
|
os.execv(VIEWER, [VIEWER] + sys.argv[1:])
|
||||||
|
'''.replace('VIEWER', repr(args.viewer)))
|
||||||
|
terminal = wrapper('terminal-private', common + '''
|
||||||
|
manifest = pathlib.Path(sys.argv[-1])
|
||||||
|
socket = pathlib.Path(os.environ['XDG_RUNTIME_DIR']) / os.environ['WAYLAND_DISPLAY']
|
||||||
|
(root / 'recovery-ready').write_text(str(manifest))
|
||||||
|
while not (root / 'recovery-input').exists():
|
||||||
|
if not socket.exists():
|
||||||
|
sys.exit(0)
|
||||||
|
time.sleep(.05)
|
||||||
|
if not socket.exists():
|
||||||
|
sys.exit(0)
|
||||||
|
commands = (root / 'recovery-input').read_text()
|
||||||
|
# The actual product recovery helper executes :shell and :refresh.
|
||||||
|
with (root / 'recovery.log').open('a') as log:
|
||||||
|
sys.exit(subprocess.run(sys.argv[2:], input=commands, text=True, stdout=log, stderr=log).returncode)
|
||||||
|
''')
|
||||||
|
shell = wrapper('scratch-shell', common + 'sys.path.insert(0, ' + repr(str(source)) + ')\n' + '''
|
||||||
|
from hydra.runtime import Runtime
|
||||||
|
from smoke import counter, wait_for
|
||||||
|
cli = CLI
|
||||||
|
manager = Runtime(root / 'config', root / 'state', root / 'run')
|
||||||
|
for name in ('alpha', 'beta'):
|
||||||
|
subprocess.run(cli + ['add', name, '--memory', '128', '--cpus', '1', '--disk-size', '8M',
|
||||||
|
'--graphics', 'software', '--network', 'user'], check=True)
|
||||||
|
paths = manager.paths(name); manager.prepare(paths)
|
||||||
|
subprocess.run([os.environ['HYDRA_QEMU_IMG'], 'convert', '-f', 'raw', '-O', 'qcow2',
|
||||||
|
str(root / 'boot.raw'), str(paths.disk)], check=True)
|
||||||
|
subprocess.run(cli + ['start', name], check=True)
|
||||||
|
time.sleep(.3)
|
||||||
|
identities = {name: {'pid': manager.paths(name).pid.read_text().strip(),
|
||||||
|
'uuid': manager.paths(name).uuid, 'counter': counter(manager, name)}
|
||||||
|
for name in ('alpha', 'beta')}
|
||||||
|
(root / 'before.json').write_text(json.dumps(identities))
|
||||||
|
'''.replace('CLI', repr(cli)).replace('from smoke import', 'from dev.smoke import'))
|
||||||
|
env = dict(os.environ, XDG_RUNTIME_DIR=str(xdg), HYDRA_LABWC=labwc,
|
||||||
|
HYDRA_VIEWER=viewer, HYDRA_TERMINAL=terminal, HYDRA_BRIGHTNESS=shutil.which('true'),
|
||||||
|
SHELL=shell, GSETTINGS_BACKEND='memory')
|
||||||
|
for key in ('WAYLAND_DISPLAY', 'DISPLAY', 'LABWC_PID'):
|
||||||
|
env.pop(key, None)
|
||||||
|
log = (root / 'session.log').open('w')
|
||||||
|
launcher, injector = None, None
|
||||||
|
result = {'evidence': str(root), 'physical_input_pending': True,
|
||||||
|
'terminal': 'test-only command feeder; real recovery helper and scratch :shell',
|
||||||
|
'backend': 'test-only wrapper around actual product launch_session and actual labwc'}
|
||||||
|
try:
|
||||||
|
launcher = subprocess.Popen(cli + ['session'], env=env, stdout=log, stderr=log)
|
||||||
|
wait_for(lambda: (root / 'recovery-ready').exists(), 'empty session did not open recovery')
|
||||||
|
first = json.loads((root / 'snapshot-0.json').read_text())
|
||||||
|
assert first['names'] == [], 'session was not initially empty'
|
||||||
|
(root / 'recovery-input').write_text(':shell\n:refresh\n')
|
||||||
|
wait_for(lambda: (root / 'snapshot-1.json').exists(), 'product loop did not restart after :refresh', timeout=15)
|
||||||
|
second = json.loads((root / 'snapshot-1.json').read_text())
|
||||||
|
assert second['names'] == ['alpha', 'beta'], second
|
||||||
|
assert first['pid'] != second['pid'], 'compositor was not refreshed'
|
||||||
|
logs = {name: root / (name + '.log') for name in ('alpha', 'beta')}
|
||||||
|
for name, path in logs.items():
|
||||||
|
wait_for(lambda: path.exists() and 'set_title("HydraOS: ' + name + '")' in path.read_text(),
|
||||||
|
name + ' product viewer did not connect')
|
||||||
|
viewers = {name: json.loads((root / (name + '-viewer.json')).read_text()) for name in logs}
|
||||||
|
assert len({data['config'] for data in viewers.values()}) == 2, 'viewer settings were shared'
|
||||||
|
for data in viewers.values():
|
||||||
|
assert data['gdk_backend'] == 'wayland'
|
||||||
|
assert data['config'].startswith(str(manager.runtime) + '/viewer-')
|
||||||
|
assert '--spice-usbredir-auto-redirect-filter=-1,-1,-1,-1,0' in data['argv']
|
||||||
|
assert '--spice-usbredir-redirect-on-connect=-1,-1,-1,-1,0' in data['argv']
|
||||||
|
alog, blog = logs['alpha'], logs['beta']
|
||||||
|
display = next(p.name for p in xdg.glob('wayland-*') if not p.name.endswith('.lock'))
|
||||||
|
keyboard_env = dict(env, WAYLAND_DISPLAY=display)
|
||||||
|
injector = subprocess.Popen([args.keyboard], env=keyboard_env, stdin=subprocess.PIPE, text=True)
|
||||||
|
time.sleep(.2)
|
||||||
|
wait_for(lambda: focus_events(alog) and focus_events(alog)[-1] == 'enter', 'alpha did not focus')
|
||||||
|
|
||||||
|
def switch(code, source_log, target_log):
|
||||||
|
before_source, before_target = focus_events(source_log), focus_events(target_log)
|
||||||
|
injector.stdin.write(f'down 29\ndown 56\ndown 42\ndown {code}\nup {code}\nup 42\nup 56\nup 29\nwait 100\n')
|
||||||
|
injector.stdin.flush()
|
||||||
|
wait_for(lambda: fresh_focus_transition(before_source, before_target,
|
||||||
|
focus_events(source_log), focus_events(target_log)), 'fresh reserved focus transition failed')
|
||||||
|
|
||||||
|
switch(106, alog, blog)
|
||||||
|
intervals = {'alpha': measure_hidden_interval(lambda: counter(manager, 'alpha'), alog, blog)}
|
||||||
|
switch(105, blog, alog)
|
||||||
|
intervals['beta'] = measure_hidden_interval(lambda: counter(manager, 'beta'), blog, alog)
|
||||||
|
before = json.loads((root / 'before.json').read_text())
|
||||||
|
after = {name: {'pid': manager.paths(name).pid.read_text().strip(),
|
||||||
|
'uuid': manager.paths(name).uuid, 'counter': counter(manager, name)}
|
||||||
|
for name in ('alpha', 'beta')}
|
||||||
|
for name in before:
|
||||||
|
assert after[name]['pid'] == before[name]['pid'] and after[name]['uuid'] == before[name]['uuid']
|
||||||
|
assert after[name]['counter'] != before[name]['counter']
|
||||||
|
# Only the PID recorded by our own wrapper, verified against its private
|
||||||
|
# config path, may be signalled. Never inherit a host compositor PID.
|
||||||
|
command = Path('/proc') / str(second['pid']) / 'cmdline'
|
||||||
|
assert second['config_directory'].encode() in command.read_bytes()
|
||||||
|
os.kill(second['pid'], signal.SIGTERM)
|
||||||
|
assert launcher.wait(timeout=10) == 0, 'product session exit failed'
|
||||||
|
for name in before:
|
||||||
|
assert manager.status(name)['status'] == 'running'
|
||||||
|
assert manager.paths(name).pid.read_text().strip() == before[name]['pid']
|
||||||
|
result.update(initial_names=first['names'], refreshed_names=second['names'],
|
||||||
|
compositor_pids=[first['pid'], second['pid']], before=before, after=after,
|
||||||
|
viewers=viewers,
|
||||||
|
hidden_intervals=intervals, survived_session_exit=True,
|
||||||
|
expected_xwayland_diagnostics=classify_compositor_errors((root / 'session.log').read_text()),
|
||||||
|
passed=True)
|
||||||
|
(root / 'result.json').write_text(json.dumps(result, indent=2) + '\n')
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
finally:
|
||||||
|
if injector and injector.poll() is None:
|
||||||
|
injector.terminate(); injector.wait(timeout=5)
|
||||||
|
if launcher and launcher.poll() is None:
|
||||||
|
launcher.send_signal(signal.SIGINT); launcher.wait(timeout=12)
|
||||||
|
for profile in manager.profiles():
|
||||||
|
if manager.status(profile.name)['status'] == 'running':
|
||||||
|
manager.stop(profile.name, force=True)
|
||||||
|
log.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -12,6 +12,8 @@ python3 -m unittest discover -s tests -v
|
|||||||
nix-build dev/tools.nix -A viewer --no-out-link
|
nix-build dev/tools.nix -A viewer --no-out-link
|
||||||
nix-build dev/tools.nix -A qemu --no-out-link
|
nix-build dev/tools.nix -A qemu --no-out-link
|
||||||
nix-shell dev/tools.nix --run 'python3 dev/smoke.py'
|
nix-shell dev/tools.nix --run 'python3 dev/smoke.py'
|
||||||
|
nix-shell dev/tools.nix --run 'python3 dev/session-refresh-smoke.py --keyboard "$(dev/build-input-probe.sh)"'
|
||||||
|
nix-shell dev/tools.nix --run 'python3 dev/check-network.py'
|
||||||
```
|
```
|
||||||
|
|
||||||
The smoke test builds a private input helper with an ordinary evdev/pc105 US
|
The smoke test builds a private input helper with an ordinary evdev/pc105 US
|
||||||
@@ -53,6 +55,48 @@ suppress viewer errors or compiler/test diagnostics. The viewer build's upstream
|
|||||||
Meson boolean-check and compiler-warning-option guidance are build noise; the four-test
|
Meson boolean-check and compiler-warning-option guidance are build noise; the four-test
|
||||||
result, compiler errors and process exit status remain the build acceptance gate.
|
result, compiler errors and process exit status remain the build acceptance gate.
|
||||||
|
|
||||||
|
## Fresh-session recovery and profile changes
|
||||||
|
|
||||||
|
A session with no profiles opens recovery with first-use guidance. Enter `:shell`
|
||||||
|
to run `hydra add NAME --iso PATH` and `hydra start NAME`; leave the shell and enter
|
||||||
|
`:refresh`. After adding or removing profiles, refresh reloads the profile list,
|
||||||
|
rebuilds workspace rules, restarts only the session's own compositor, and attaches
|
||||||
|
new isolated viewers to running guests. QEMU processes and workloads survive.
|
||||||
|
The recovery prompt can then reconnect the new names; reserved Left/Right chords
|
||||||
|
switch between their workspaces. `:quit` closes the recovery prompt.
|
||||||
|
|
||||||
|
`dev/session-refresh-smoke.py` exercises the actual product launch loop, recovery
|
||||||
|
helper, `:shell` and `:refresh`, starting with an empty profile directory. A
|
||||||
|
test-only labwc wrapper selects private headless backends; a terminal command
|
||||||
|
feeder drives recovery and a scratch shell adds and starts two counter guests.
|
||||||
|
Actual labwc and native Wayland viewers then demonstrate fresh keyboard focus
|
||||||
|
transitions in both directions, hidden workload continuity, unchanged QEMU
|
||||||
|
PIDs/UUIDs through refresh, and guest survival after normal compositor exit.
|
||||||
|
This checks the recovery command flow, not physical terminal rendering/usability.
|
||||||
|
It leaves snapshots, recovery output, protocol logs and `result.json` in
|
||||||
|
`/tmp/hydra-session-refresh-*` and stops only its verified scratch QEMU processes.
|
||||||
|
|
||||||
|
## Bridge identities
|
||||||
|
|
||||||
|
New profiles persist a locally administered unicast `mac` in their TOML file.
|
||||||
|
It remains stable across stop/start, media changes and runtime socket-directory
|
||||||
|
changes. The generated address incorporates the VM disk path and user identity,
|
||||||
|
so separate profiles/users avoid QEMU's identical default MAC on a shared bridge.
|
||||||
|
Existing profiles without `mac` use the same deterministic calculation without
|
||||||
|
rewriting their file. Relocating a legacy disk or changing its owner changes
|
||||||
|
that fallback; explicitly persisting its current address retains the identity.
|
||||||
|
Explicit TOML addresses must also be locally administered unicast, and users
|
||||||
|
copying profiles must assign distinct addresses to concurrently used copies.
|
||||||
|
|
||||||
|
`dev/check-network.py` requires unprivileged user/network/mount namespaces. It
|
||||||
|
asserts all three namespace identities changed before creating a private
|
||||||
|
`hydrabr0` and mounting a private QEMU bridge-helper ACL over `/etc` within that
|
||||||
|
namespace. Two actual scratch QEMUs must report different configured NIC MACs,
|
||||||
|
both bridge TAP ports must exist, and restart must preserve the first guest's
|
||||||
|
address. Evidence remains under `/tmp/hydra-network-*`; host bridges, host `/etc`
|
||||||
|
and existing guests are unchanged. This does not prove guest network drivers,
|
||||||
|
DHCP leases or external connectivity; those need the installed-guest harness.
|
||||||
|
|
||||||
## Installed guest workflow
|
## Installed guest workflow
|
||||||
|
|
||||||
`--iso` is optional. Attach or detach media only while the VM is stopped:
|
`--iso` is optional. Attach or detach media only while the VM is stopped:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class Profile:
|
|||||||
width: int = 1280
|
width: int = 1280
|
||||||
height: int = 800
|
height: int = 800
|
||||||
refresh_millihz: int = 0
|
refresh_millihz: int = 0
|
||||||
|
mac: str | None = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
validate_name(self.name)
|
validate_name(self.name)
|
||||||
@@ -33,6 +34,12 @@ class Profile:
|
|||||||
if not isinstance(self.iso, (str, Path)) or not str(self.iso):
|
if not isinstance(self.iso, (str, Path)) or not str(self.iso):
|
||||||
raise ValueError('ISO must be a nonempty path or omitted')
|
raise ValueError('ISO must be a nonempty path or omitted')
|
||||||
object.__setattr__(self, 'iso', Path(self.iso).expanduser().resolve())
|
object.__setattr__(self, 'iso', Path(self.iso).expanduser().resolve())
|
||||||
|
if self.mac is not None:
|
||||||
|
if (not isinstance(self.mac, str) or
|
||||||
|
not re.fullmatch(r'[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}', self.mac) or
|
||||||
|
int(self.mac[:2], 16) & 3 != 2):
|
||||||
|
raise ValueError('mac must be a locally administered unicast Ethernet address')
|
||||||
|
object.__setattr__(self, 'mac', self.mac.lower())
|
||||||
for key, upper in (('memory', 262144), ('cpus', 256)):
|
for key, upper in (('memory', 262144), ('cpus', 256)):
|
||||||
value = getattr(self, key)
|
value = getattr(self, key)
|
||||||
if type(value) is not int or not 1 <= value <= upper:
|
if type(value) is not int or not 1 <= value <= upper:
|
||||||
|
|||||||
+12
-1
@@ -3,6 +3,7 @@
|
|||||||
import contextlib
|
import contextlib
|
||||||
import errno
|
import errno
|
||||||
import fcntl
|
import fcntl
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
@@ -54,6 +55,14 @@ def _intel_render_node() -> Path | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _stable_mac(paths: VMPaths) -> str:
|
||||||
|
# Disk/state identity survives restarts and runtime socket-directory changes.
|
||||||
|
# UID distinguishes owners even when a shared bridge sees the same names.
|
||||||
|
digest = bytearray(hashlib.sha256(f'{os.getuid()}:{paths.disk}'.encode()).digest()[:6])
|
||||||
|
digest[0] = (digest[0] & 0xfc) | 0x02
|
||||||
|
return ':'.join(f'{octet:02x}' for octet in digest)
|
||||||
|
|
||||||
|
|
||||||
def build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]:
|
def build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]:
|
||||||
accel = 'kvm' if os.access('/dev/kvm', os.R_OK | os.W_OK) else 'tcg'
|
accel = 'kvm' if os.access('/dev/kvm', os.R_OK | os.W_OK) else 'tcg'
|
||||||
argv = [os.environ.get('HYDRA_QEMU', 'qemu-system-x86_64'), '-name', profile.name,
|
argv = [os.environ.get('HYDRA_QEMU', 'qemu-system-x86_64'), '-name', profile.name,
|
||||||
@@ -78,7 +87,7 @@ def build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]:
|
|||||||
argv += ['-device', 'virtio-blk-pci,drive=disk']
|
argv += ['-device', 'virtio-blk-pci,drive=disk']
|
||||||
if profile.iso is not None:
|
if profile.iso is not None:
|
||||||
argv += ['-device', 'ide-cd,drive=iso']
|
argv += ['-device', 'ide-cd,drive=iso']
|
||||||
argv += ['-boot', 'order=dc' if profile.iso is not None else 'order=c', '-device', 'virtio-net-pci,netdev=net',
|
argv += ['-boot', 'order=dc' if profile.iso is not None else 'order=c', '-device', f'virtio-net-pci,netdev=net,mac={profile.mac or _stable_mac(paths)}',
|
||||||
'-netdev', 'user,id=net' if profile.network == 'user'
|
'-netdev', 'user,id=net' if profile.network == 'user'
|
||||||
else f'bridge,id=net,br={profile.bridge}',
|
else f'bridge,id=net,br={profile.bridge}',
|
||||||
'-device', 'qemu-xhci,id=usb', '-device', 'usb-tablet', '-device', 'usb-kbd']
|
'-device', 'qemu-xhci,id=usb', '-device', 'usb-tablet', '-device', 'usb-kbd']
|
||||||
@@ -136,6 +145,8 @@ class Runtime:
|
|||||||
def add(self, profile: Profile) -> None:
|
def add(self, profile: Profile) -> None:
|
||||||
self._validate_media(profile.iso)
|
self._validate_media(profile.iso)
|
||||||
_private_dir(self.config)
|
_private_dir(self.config)
|
||||||
|
if profile.mac is None:
|
||||||
|
profile = replace(profile, mac=_stable_mac(self.paths(profile.name)))
|
||||||
save_profile(self.config / f'{profile.name}.toml', profile)
|
save_profile(self.config / f'{profile.name}.toml', profile)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
+37
-6
@@ -49,6 +49,7 @@ def build_labwc_config(names: list[str], recovery_argv: list[str],
|
|||||||
core = ET.SubElement(root, 'core')
|
core = ET.SubElement(root, 'core')
|
||||||
ET.SubElement(core, 'primarySelection').text = 'no'
|
ET.SubElement(core, 'primarySelection').text = 'no'
|
||||||
desktops = ET.SubElement(root, 'desktops', number=str(max(1, len(names))))
|
desktops = ET.SubElement(root, 'desktops', number=str(max(1, len(names))))
|
||||||
|
if names:
|
||||||
labels = ET.SubElement(desktops, 'names')
|
labels = ET.SubElement(desktops, 'names')
|
||||||
for name in names:
|
for name in names:
|
||||||
ET.SubElement(labels, 'name').text = name
|
ET.SubElement(labels, 'name').text = name
|
||||||
@@ -173,9 +174,10 @@ def launch_session(runtime: Runtime, *, nested=False) -> int:
|
|||||||
match = re.search(r'(\d+)\.(\d+)\.(\d+)', version.stdout)
|
match = re.search(r'(\d+)\.(\d+)\.(\d+)', version.stdout)
|
||||||
if version.returncode or not match or tuple(map(int, match.groups())) < (0, 20, 0):
|
if version.returncode or not match or tuple(map(int, match.groups())) < (0, 20, 0):
|
||||||
raise SessionError('HydraOS requires labwc >= 0.20.0 for reserved host input bindings')
|
raise SessionError('HydraOS requires labwc >= 0.20.0 for reserved host input bindings')
|
||||||
|
_private_dir(runtime.runtime)
|
||||||
|
while True:
|
||||||
profiles = runtime.profiles()
|
profiles = runtime.profiles()
|
||||||
initial = next((p.name for p in profiles if runtime.status(p.name)['status'] == 'running'), None)
|
initial = next((p.name for p in profiles if runtime.status(p.name)['status'] == 'running'), None)
|
||||||
_private_dir(runtime.runtime)
|
|
||||||
with tempfile.TemporaryDirectory(prefix='session-', dir=runtime.runtime) as tmp:
|
with tempfile.TemporaryDirectory(prefix='session-', dir=runtime.runtime) as tmp:
|
||||||
directory = Path(tmp)
|
directory = Path(tmp)
|
||||||
config = directory / 'labwc'
|
config = directory / 'labwc'
|
||||||
@@ -188,10 +190,29 @@ def launch_session(runtime: Runtime, *, nested=False) -> int:
|
|||||||
_recovery_argv(manifest, terminal), brightness,
|
_recovery_argv(manifest, terminal), brightness,
|
||||||
initial=initial), encoding='utf-8')
|
initial=initial), encoding='utf-8')
|
||||||
(config / 'autostart').write_text('#!/bin/sh\n' + shlex.join(_module_argv('clients', manifest)) + ' &\n', encoding='utf-8')
|
(config / 'autostart').write_text('#!/bin/sh\n' + shlex.join(_module_argv('clients', manifest)) + ' &\n', encoding='utf-8')
|
||||||
# Empty environment files prevent loading unrelated /etc/xdg defaults.
|
|
||||||
(config / 'environment').touch()
|
(config / 'environment').touch()
|
||||||
env['PYTHONUNBUFFERED'] = '1'
|
env['PYTHONUNBUFFERED'] = '1'
|
||||||
return subprocess.run([labwc, '-C', str(config)], env=env).returncode
|
compositor = subprocess.Popen([labwc, '-C', str(config)], env=env)
|
||||||
|
refresh = False
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if (directory / 'refresh.request').exists():
|
||||||
|
refresh = True
|
||||||
|
break
|
||||||
|
if compositor.poll() is not None:
|
||||||
|
# A requesting recovery terminal can exit before polling.
|
||||||
|
refresh = (directory / 'refresh.request').exists()
|
||||||
|
break
|
||||||
|
time.sleep(.1)
|
||||||
|
finally:
|
||||||
|
if compositor.poll() is None:
|
||||||
|
compositor.terminate()
|
||||||
|
compositor.wait(timeout=10)
|
||||||
|
if not refresh:
|
||||||
|
return compositor.returncode
|
||||||
|
# Only our compositor was stopped. Its viewers disconnect; detached
|
||||||
|
# QEMU instances retain their identity and running workloads.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def session_helper(command: str, manifest: Path) -> int:
|
def session_helper(command: str, manifest: Path) -> int:
|
||||||
@@ -203,14 +224,22 @@ def session_helper(command: str, manifest: Path) -> int:
|
|||||||
raise SessionError('Invalid session manifest')
|
raise SessionError('Invalid session manifest')
|
||||||
for name in data['names']:
|
for name in data['names']:
|
||||||
validate_name(name)
|
validate_name(name)
|
||||||
if command not in ('clients', 'recover') and (
|
if command not in ('clients', 'recover', 'refresh') and (
|
||||||
not command.startswith('reconnect:') or command.split(':', 1)[1] not in data['names']):
|
not command.startswith('reconnect:') or command.split(':', 1)[1] not in data['names']):
|
||||||
raise SessionError('Invalid session helper operation')
|
raise SessionError('Invalid session helper operation')
|
||||||
runtime = Runtime(Path(data['config']), Path(data['state']), Path(data['runtime']))
|
runtime = Runtime(Path(data['config']), Path(data['state']), Path(data['runtime']))
|
||||||
|
if command == 'refresh':
|
||||||
|
fd = os.open(manifest.parent / 'refresh.request', os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||||
|
os.close(fd)
|
||||||
|
print('Refreshing workspaces and viewers; VMs keep running.')
|
||||||
|
return 0
|
||||||
if command == 'recover':
|
if command == 'recover':
|
||||||
print('HydraOS local recovery. VMs keep running independently.\n'
|
print('HydraOS local recovery. VMs keep running independently.\n'
|
||||||
'Ctrl+Alt+Shift+Left/Right switches workspaces; Ctrl+Alt+Shift+R releases input; Ctrl+Alt+Shift+U opens USB.\n'
|
'Ctrl+Alt+Shift+Left/Right switches workspaces; Ctrl+Alt+Shift+R releases input; Ctrl+Alt+Shift+U opens USB.\n'
|
||||||
'Enter a VM name to reconnect, :shell for a host shell, :quit to close this terminal.')
|
'Enter a VM name to reconnect, :shell for a host shell, :refresh to rebuild workspaces/viewers, or :quit.')
|
||||||
|
if not data['names']:
|
||||||
|
print('No VM profiles yet. Use :shell to run hydra add NAME --iso PATH and hydra start NAME.\n'
|
||||||
|
'After adding or removing profiles, leave the shell and enter :refresh; running VMs are preserved.')
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
print(json.dumps([runtime.status(name) for name in data['names']], indent=2))
|
print(json.dumps([runtime.status(name) for name in data['names']], indent=2))
|
||||||
@@ -219,12 +248,14 @@ def session_helper(command: str, manifest: Path) -> int:
|
|||||||
return 0
|
return 0
|
||||||
if choice == ':quit':
|
if choice == ':quit':
|
||||||
return 0
|
return 0
|
||||||
|
if choice == ':refresh':
|
||||||
|
return session_helper('refresh', manifest)
|
||||||
if choice == ':shell':
|
if choice == ':shell':
|
||||||
subprocess.run([os.environ.get('SHELL', '/bin/sh')])
|
subprocess.run([os.environ.get('SHELL', '/bin/sh')])
|
||||||
elif choice in data['names']:
|
elif choice in data['names']:
|
||||||
subprocess.Popen(_module_argv('reconnect:' + choice, manifest), start_new_session=True)
|
subprocess.Popen(_module_argv('reconnect:' + choice, manifest), start_new_session=True)
|
||||||
else:
|
else:
|
||||||
print('Enter a configured VM name, :shell, or :quit.')
|
print('Enter a configured VM name, :shell, :refresh, or :quit.')
|
||||||
if command.startswith('reconnect:'):
|
if command.startswith('reconnect:'):
|
||||||
name = command.split(':', 1)[1]
|
name = command.split(':', 1)[1]
|
||||||
# One viewer per VM inside this session; independent host view commands
|
# One viewer per VM inside this session; independent host view commands
|
||||||
|
|||||||
@@ -65,6 +65,15 @@ class ProfilesTests(unittest.TestCase):
|
|||||||
with self.subTest(field=field, value=value), self.assertRaises(ValueError):
|
with self.subTest(field=field, value=value), self.assertRaises(ValueError):
|
||||||
Profile('guest', self.iso, **{field: value})
|
Profile('guest', self.iso, **{field: value})
|
||||||
|
|
||||||
|
def test_explicit_mac_roundtrip_and_validation(self):
|
||||||
|
dest = self.root / 'guest.toml'
|
||||||
|
save_profile(dest, Profile('guest', mac='02:ab:cd:ef:01:23'))
|
||||||
|
self.assertEqual(load_profile(dest).mac, '02:ab:cd:ef:01:23')
|
||||||
|
for value in ('ff:00:00:00:00:01', '00:00:00:00:00:01',
|
||||||
|
'02:00:00:00:00:zz', '02:00:00:00:00', True):
|
||||||
|
with self.subTest(mac=value), self.assertRaises(ValueError):
|
||||||
|
Profile('guest', mac=value)
|
||||||
|
|
||||||
def test_refresh_vertical_descriptor_boundaries(self):
|
def test_refresh_vertical_descriptor_boundaries(self):
|
||||||
for value in (0, 50000, 255000):
|
for value in (0, 50000, 255000):
|
||||||
path = self.root / 'guest.toml'
|
path = self.root / 'guest.toml'
|
||||||
|
|||||||
+41
-1
@@ -88,6 +88,46 @@ class RuntimeTests(unittest.TestCase):
|
|||||||
except VMError:
|
except VMError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def test_mac_persisted_distinct_and_stable_across_restarts(self):
|
||||||
|
self.runtime.add(Profile('other', network='bridge'))
|
||||||
|
other = self.runtime.profile('other')
|
||||||
|
guest = self.runtime.profile('guest')
|
||||||
|
self.assertIsNotNone(guest.mac)
|
||||||
|
self.assertNotEqual(guest.mac, other.mac)
|
||||||
|
octet = int(guest.mac.split(':')[0], 16)
|
||||||
|
self.assertEqual(octet & 3, 2) # locally administered, unicast
|
||||||
|
def nic(profile, runtime):
|
||||||
|
return next(arg for arg in build_qemu_argv(profile, runtime.paths(profile.name))
|
||||||
|
if arg.startswith('virtio-net-pci,'))
|
||||||
|
expected = nic(guest, self.runtime)
|
||||||
|
self.runtime.start('guest')
|
||||||
|
self.runtime.stop('guest', force=True)
|
||||||
|
self.runtime.start('guest')
|
||||||
|
self.runtime.stop('guest', force=True)
|
||||||
|
self.assertEqual(nic(self.runtime.profile('guest'), self.runtime), expected)
|
||||||
|
changed_runtime = Runtime(self.runtime.config, self.runtime.state, self.root / 'different-run')
|
||||||
|
self.assertEqual(nic(changed_runtime.profile('guest'), changed_runtime), expected)
|
||||||
|
self.runtime.media('guest', None)
|
||||||
|
self.assertEqual(self.runtime.profile('guest').mac, guest.mac)
|
||||||
|
|
||||||
|
def test_legacy_profiles_gain_stable_owner_specific_argv_mac(self):
|
||||||
|
file = self.runtime.config / 'legacy.toml'
|
||||||
|
save_profile(file, Profile('legacy', network='bridge'))
|
||||||
|
original = file.read_bytes()
|
||||||
|
legacy = self.runtime.profile('legacy')
|
||||||
|
self.assertIsNone(legacy.mac)
|
||||||
|
paths = self.runtime.paths('legacy')
|
||||||
|
def mac():
|
||||||
|
return next(arg for arg in build_qemu_argv(legacy, paths) if arg.startswith('virtio-net-pci,'))
|
||||||
|
with patch('os.getuid', return_value=1000):
|
||||||
|
first = mac()
|
||||||
|
self.assertEqual(mac(), first)
|
||||||
|
self.assertNotEqual(first, next(arg for arg in build_qemu_argv(
|
||||||
|
legacy, self.runtime.paths('other')) if arg.startswith('virtio-net-pci,')))
|
||||||
|
with patch('os.getuid', return_value=1001):
|
||||||
|
self.assertNotEqual(mac(), first)
|
||||||
|
self.assertEqual(file.read_bytes(), original)
|
||||||
|
|
||||||
def test_disk_boot_omits_media_and_requests_display(self):
|
def test_disk_boot_omits_media_and_requests_display(self):
|
||||||
profile = Profile('guest', width=1920, height=1080, refresh_millihz=144000,
|
profile = Profile('guest', width=1920, height=1080, refresh_millihz=144000,
|
||||||
graphics='software')
|
graphics='software')
|
||||||
@@ -145,7 +185,7 @@ class RuntimeTests(unittest.TestCase):
|
|||||||
self.assertIn('disable-agent-file-xfer=on', spice)
|
self.assertIn('disable-agent-file-xfer=on', spice)
|
||||||
self.assertIn('run,,comma', spice)
|
self.assertIn('run,,comma', spice)
|
||||||
self.assertIn('virtio-blk-pci,drive=disk', argv)
|
self.assertIn('virtio-blk-pci,drive=disk', argv)
|
||||||
self.assertIn('virtio-net-pci,netdev=net', argv)
|
self.assertTrue(any(arg.startswith('virtio-net-pci,netdev=net,mac=') for arg in argv))
|
||||||
self.assertNotIn('-vnc', argv)
|
self.assertNotIn('-vnc', argv)
|
||||||
self.assertNotIn('-virtfs', argv)
|
self.assertNotIn('-virtfs', argv)
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ class SessionTests(unittest.TestCase):
|
|||||||
self.assertEqual(tree.findall('./mouse/context/mousebind/action'), [])
|
self.assertEqual(tree.findall('./mouse/context/mousebind/action'), [])
|
||||||
self.assertEqual(tree.findtext('./libinput/device/tap'), 'yes')
|
self.assertEqual(tree.findtext('./libinput/device/tap'), 'yes')
|
||||||
|
|
||||||
|
def test_empty_session_has_one_workspace_without_empty_names(self):
|
||||||
|
tree = ET.fromstring(session.build_labwc_config([], ['foot']))
|
||||||
|
self.assertEqual(tree.find('./desktops').get('number'), '1')
|
||||||
|
self.assertIsNone(tree.find('./desktops/names'))
|
||||||
|
|
||||||
def test_viewer_argv_is_private_safe_and_manual_usb_only(self):
|
def test_viewer_argv_is_private_safe_and_manual_usb_only(self):
|
||||||
paths = self.manager.paths('alpha')
|
paths = self.manager.paths('alpha')
|
||||||
argv = session.build_viewer_argv(paths, '/tool path/viewer')
|
argv = session.build_viewer_argv(paths, '/tool path/viewer')
|
||||||
@@ -173,6 +178,45 @@ else:
|
|||||||
self.assertEqual(main(['_session-helper', 'recover', str(manifest)]), 1)
|
self.assertEqual(main(['_session-helper', 'recover', str(manifest)]), 1)
|
||||||
self.assertIn('manifest', error.getvalue().lower())
|
self.assertIn('manifest', error.getvalue().lower())
|
||||||
|
|
||||||
|
def test_empty_launch_refresh_rebuilds_profile_snapshot(self):
|
||||||
|
manager = Runtime(self.root / 'empty-config', self.root / 'empty-state', self.root / 'empty-run')
|
||||||
|
capture = self.root / 'refresh-capture.json'
|
||||||
|
compositor = self.root / 'refresh-labwc'
|
||||||
|
compositor.write_text('#!' + os.sys.executable + '\n' + r'''import json, os, subprocess, sys
|
||||||
|
from pathlib import Path
|
||||||
|
if '--version' in sys.argv:
|
||||||
|
print('labwc 0.20.2')
|
||||||
|
sys.exit(0)
|
||||||
|
config = Path(sys.argv[sys.argv.index('-C') + 1])
|
||||||
|
manifest = config.parent / 'session.json'
|
||||||
|
data = json.loads(manifest.read_text())
|
||||||
|
capture = Path(os.environ['CAPTURE'])
|
||||||
|
seen = json.loads(capture.read_text()) if capture.exists() else []
|
||||||
|
seen.append({'names': data['names'], 'xml': (config / 'rc.xml').read_text()})
|
||||||
|
capture.write_text(json.dumps(seen))
|
||||||
|
if len(seen) == 1:
|
||||||
|
launcher = os.environ['HYDRA_SOURCE_LAUNCHER']
|
||||||
|
for name in ('first', 'second'):
|
||||||
|
subprocess.run([sys.executable, launcher, '--config', data['config'], '--state', data['state'],
|
||||||
|
'--runtime', data['runtime'], 'add', name], check=True)
|
||||||
|
subprocess.run([sys.executable, launcher, '_session-helper', 'recover', str(manifest)],
|
||||||
|
input=':refresh\n', text=True, check=True)
|
||||||
|
sys.exit(67)
|
||||||
|
sys.exit(0)
|
||||||
|
''')
|
||||||
|
compositor.chmod(0o700)
|
||||||
|
with patch.dict(os.environ, {'HYDRA_LABWC': str(compositor), 'HYDRA_TERMINAL': str(compositor),
|
||||||
|
'HYDRA_BRIGHTNESS': str(compositor), 'CAPTURE': str(capture),
|
||||||
|
'HYDRA_SOURCE_LAUNCHER': str(Path('bin/hydra').resolve())}), contextlib.redirect_stdout(io.StringIO()):
|
||||||
|
self.assertEqual(session.launch_session(manager), 0)
|
||||||
|
seen = json.loads(capture.read_text())
|
||||||
|
self.assertEqual([entry['names'] for entry in seen], [[], ['first', 'second']])
|
||||||
|
tree = ET.fromstring(seen[1]['xml'])
|
||||||
|
self.assertEqual(tree.find('desktops').get('number'), '2')
|
||||||
|
self.assertEqual([rule.get('title') for rule in tree.findall('./windowRules/windowRule')],
|
||||||
|
['HydraOS: first', 'HydraOS: second'])
|
||||||
|
self.assertFalse(manager.paths('first').disk.exists())
|
||||||
|
|
||||||
def test_no_running_vms_opens_recovery_without_start_or_stop(self):
|
def test_no_running_vms_opens_recovery_without_start_or_stop(self):
|
||||||
terminal = self.root / 'terminal'
|
terminal = self.root / 'terminal'
|
||||||
capture = self.root / 'recovery-argv.json'
|
capture = self.root / 'recovery-argv.json'
|
||||||
|
|||||||
Reference in New Issue
Block a user