fix: preserve guest network identities and refresh session profiles
This commit is contained in:
@@ -26,6 +26,7 @@ class Profile:
|
||||
width: int = 1280
|
||||
height: int = 800
|
||||
refresh_millihz: int = 0
|
||||
mac: str | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
validate_name(self.name)
|
||||
@@ -33,6 +34,12 @@ class Profile:
|
||||
if not isinstance(self.iso, (str, Path)) or not str(self.iso):
|
||||
raise ValueError('ISO must be a nonempty path or omitted')
|
||||
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)):
|
||||
value = getattr(self, key)
|
||||
if type(value) is not int or not 1 <= value <= upper:
|
||||
|
||||
+12
-1
@@ -3,6 +3,7 @@
|
||||
import contextlib
|
||||
import errno
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
@@ -54,6 +55,14 @@ def _intel_render_node() -> Path | 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]:
|
||||
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,
|
||||
@@ -78,7 +87,7 @@ def build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]:
|
||||
argv += ['-device', 'virtio-blk-pci,drive=disk']
|
||||
if profile.iso is not None:
|
||||
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'
|
||||
else f'bridge,id=net,br={profile.bridge}',
|
||||
'-device', 'qemu-xhci,id=usb', '-device', 'usb-tablet', '-device', 'usb-kbd']
|
||||
@@ -136,6 +145,8 @@ class Runtime:
|
||||
def add(self, profile: Profile) -> None:
|
||||
self._validate_media(profile.iso)
|
||||
_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)
|
||||
|
||||
@staticmethod
|
||||
|
||||
+55
-24
@@ -49,9 +49,10 @@ def build_labwc_config(names: list[str], recovery_argv: list[str],
|
||||
core = ET.SubElement(root, 'core')
|
||||
ET.SubElement(core, 'primarySelection').text = 'no'
|
||||
desktops = ET.SubElement(root, 'desktops', number=str(max(1, len(names))))
|
||||
labels = ET.SubElement(desktops, 'names')
|
||||
for name in names:
|
||||
ET.SubElement(labels, 'name').text = name
|
||||
if names:
|
||||
labels = ET.SubElement(desktops, 'names')
|
||||
for name in names:
|
||||
ET.SubElement(labels, 'name').text = name
|
||||
if initial is not None:
|
||||
ET.SubElement(desktops, 'initial').text = str(names.index(initial) + 1)
|
||||
ET.SubElement(desktops, 'popupTime').text = '0'
|
||||
@@ -173,25 +174,45 @@ def launch_session(runtime: Runtime, *, nested=False) -> int:
|
||||
match = re.search(r'(\d+)\.(\d+)\.(\d+)', version.stdout)
|
||||
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')
|
||||
profiles = runtime.profiles()
|
||||
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:
|
||||
directory = Path(tmp)
|
||||
config = directory / 'labwc'
|
||||
config.mkdir(mode=0o700)
|
||||
manifest = directory / 'session.json'
|
||||
manifest.write_text(json.dumps({'config': str(runtime.config), 'state': str(runtime.state),
|
||||
'runtime': str(runtime.runtime), 'names': [p.name for p in profiles],
|
||||
'terminal': terminal, 'directory': str(directory)}), encoding='utf-8')
|
||||
(config / 'rc.xml').write_text(build_labwc_config([p.name for p in profiles],
|
||||
_recovery_argv(manifest, terminal), brightness,
|
||||
initial=initial), 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()
|
||||
env['PYTHONUNBUFFERED'] = '1'
|
||||
return subprocess.run([labwc, '-C', str(config)], env=env).returncode
|
||||
while True:
|
||||
profiles = runtime.profiles()
|
||||
initial = next((p.name for p in profiles if runtime.status(p.name)['status'] == 'running'), None)
|
||||
with tempfile.TemporaryDirectory(prefix='session-', dir=runtime.runtime) as tmp:
|
||||
directory = Path(tmp)
|
||||
config = directory / 'labwc'
|
||||
config.mkdir(mode=0o700)
|
||||
manifest = directory / 'session.json'
|
||||
manifest.write_text(json.dumps({'config': str(runtime.config), 'state': str(runtime.state),
|
||||
'runtime': str(runtime.runtime), 'names': [p.name for p in profiles],
|
||||
'terminal': terminal, 'directory': str(directory)}), encoding='utf-8')
|
||||
(config / 'rc.xml').write_text(build_labwc_config([p.name for p in profiles],
|
||||
_recovery_argv(manifest, terminal), brightness,
|
||||
initial=initial), encoding='utf-8')
|
||||
(config / 'autostart').write_text('#!/bin/sh\n' + shlex.join(_module_argv('clients', manifest)) + ' &\n', encoding='utf-8')
|
||||
(config / 'environment').touch()
|
||||
env['PYTHONUNBUFFERED'] = '1'
|
||||
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:
|
||||
@@ -203,14 +224,22 @@ def session_helper(command: str, manifest: Path) -> int:
|
||||
raise SessionError('Invalid session manifest')
|
||||
for name in data['names']:
|
||||
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']):
|
||||
raise SessionError('Invalid session helper operation')
|
||||
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':
|
||||
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'
|
||||
'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:
|
||||
try:
|
||||
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
|
||||
if choice == ':quit':
|
||||
return 0
|
||||
if choice == ':refresh':
|
||||
return session_helper('refresh', manifest)
|
||||
if choice == ':shell':
|
||||
subprocess.run([os.environ.get('SHELL', '/bin/sh')])
|
||||
elif choice in data['names']:
|
||||
subprocess.Popen(_module_argv('reconnect:' + choice, manifest), start_new_session=True)
|
||||
else:
|
||||
print('Enter a configured VM name, :shell, or :quit.')
|
||||
print('Enter a configured VM name, :shell, :refresh, or :quit.')
|
||||
if command.startswith('reconnect:'):
|
||||
name = command.split(':', 1)[1]
|
||||
# One viewer per VM inside this session; independent host view commands
|
||||
|
||||
Reference in New Issue
Block a user