Add safe ISO download and explicit run/open commands

This commit is contained in:
Codex
2026-10-13 15:14:10 +03:00
parent 1784f3a8ce
commit e6dc59b567
6 changed files with 706 additions and 4 deletions
+61 -4
View File
@@ -13,13 +13,30 @@ from .session import launch_session, session_helper, view
def parser() -> argparse.ArgumentParser:
cli = argparse.ArgumentParser(prog='hydra', description='Independent HydraOS VM manager')
cli = argparse.ArgumentParser(prog='hydra', description='Hydra OS independent VM manager')
cli.add_argument('--config', type=Path, default=Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'hydra')
cli.add_argument('--state', type=Path, default=Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local/state')) / 'hydra')
cli.add_argument('--runtime', type=Path, default=Path(os.environ.get('XDG_RUNTIME_DIR', f'/tmp/hydra-{os.getuid()}')) / 'hydra')
cli.add_argument('--defaults', type=Path, default=Path('/etc/hydra/defaults.toml'),
help='Distribution preferences for newly created profiles')
commands = cli.add_subparsers(dest='command', required=True)
commands = cli.add_subparsers(dest='command')
commands.add_parser('menu', help='Open the terminal VM menu')
download = commands.add_parser('download', help='Download and validate a direct ISO URL')
download.add_argument('url')
download.add_argument('--sha256')
run = commands.add_parser('run', help='Create, start and display a VM from a local ISO or HTTP(S) URL')
run.add_argument('source')
run.add_argument('--name', required=True)
run.add_argument('--memory', '--ram', type=int, default=4096, help='VM RAM in MiB')
run.add_argument('--cpus', type=int, default=2)
run.add_argument('--disk-size', default='40G')
run.add_argument('--sha256')
run.add_argument('--no-view', action='store_true')
run.add_argument('--windowed', action='store_true')
opener = commands.add_parser('open', help='Start a stopped VM or reconnect a running VM')
opener.add_argument('name')
opener.add_argument('--no-view', action='store_true')
opener.add_argument('--windowed', action='store_true')
add = commands.add_parser('add', help='Create a VM profile without starting it')
add.add_argument('name')
add.add_argument('--iso', type=Path)
@@ -48,7 +65,7 @@ def parser() -> argparse.ArgumentParser:
viewer = commands.add_parser('view', help='Connect a viewer without changing VM power')
viewer.add_argument('name')
viewer.add_argument('--windowed', action='store_true')
session = commands.add_parser('session', help='Start the minimal HydraOS labwc session')
session = commands.add_parser('session', help='Start the minimal Hydra OS labwc session')
session.add_argument('--nested', action='store_true')
helper = commands.add_parser('_session-helper', help=argparse.SUPPRESS)
helper.add_argument('operation')
@@ -56,10 +73,47 @@ def parser() -> argparse.ArgumentParser:
return cli
def _download_progress():
last = None
interactive = sys.stderr.isatty()
def report(received, total):
nonlocal last
step = received * 100 // total if total else received // (64 * 1024 * 1024)
if last == step:
return
last = step
if not interactive and received and (total is None or received != total):
return
suffix = f' / {total // (1024 * 1024)} MiB ({step}%)' if total else ' MiB'
print(f'Download: {received // (1024 * 1024)}{suffix}', file=sys.stderr)
return report
def main(argv: list[str] | None = None) -> int:
args = parser().parse_args(argv)
cli = parser()
args = cli.parse_args(argv)
runtime = Runtime(args.config, args.state, args.runtime)
try:
if args.command is None and not sys.stdin.isatty():
cli.print_help()
return 0
if args.command in (None, 'menu'):
from .launch import session_manifest
from .menu import run_menu
return run_menu(runtime, manifest=session_manifest(runtime))
if args.command == 'download':
from .media import download_iso
print(download_iso(args.url, sha256=args.sha256, progress=_download_progress()))
return 0
if args.command == 'run':
from .launch import run_source
return run_source(runtime, args.source, name=args.name, memory=args.memory,
cpus=args.cpus, disk_size=args.disk_size, sha256=args.sha256,
no_view=args.no_view, windowed=args.windowed, defaults=args.defaults,
progress=_download_progress())
if args.command == 'open':
from .launch import open_vm
return open_vm(runtime, args.name, no_view=args.no_view, windowed=args.windowed)
if args.command == 'view':
return view(runtime, args.name, windowed=args.windowed)
elif args.command == 'session':
@@ -87,6 +141,9 @@ def main(argv: list[str] | None = None) -> int:
result = getattr(runtime, args.command)(args.name)
print(json.dumps(result, indent=2))
return 0
except KeyboardInterrupt:
print('hydra: Cancelled', file=sys.stderr)
return 130
except (RuntimeError, OSError, ValueError, subprocess.TimeoutExpired) as exc:
print(f'hydra: {exc}', file=sys.stderr)
return 1
+123
View File
@@ -0,0 +1,123 @@
"""Explicit start-if-stopped and new-ISO launch, preserving runtime authority."""
import json
import os
from pathlib import Path
from . import media, session
from .profiles import Profile, load_defaults, validate_name
from .runtime import Runtime, RuntimeError
HOST_RESERVE_MIB = 768
def available_memory_mib() -> int:
"""Linux MemAvailable accounts for reclaimable host caches."""
try:
for line in Path('/proc/meminfo').read_text().splitlines():
if line.startswith('MemAvailable:'):
return int(line.split()[1]) // 1024
except (OSError, ValueError, IndexError) as exc:
raise RuntimeError('Cannot estimate available RAM from /proc/meminfo') from exc
raise RuntimeError('Cannot estimate available RAM: MemAvailable is missing')
def _memory_preflight(memory: int) -> None:
available = available_memory_mib()
if memory + HOST_RESERVE_MIB > available:
raise RuntimeError(f'Not enough available RAM: VM needs {memory} MiB plus a {HOST_RESERVE_MIB} MiB host reserve; '
f'{available} MiB available. Choose less --memory or stop another VM')
def session_manifest(runtime: Runtime, manifest: Path | None = None) -> Path | None:
"""Use an inherited session only when it belongs to these runtime roots."""
explicit = manifest is not None
inherited = os.environ.get('HYDRA_SESSION_MANIFEST')
if not explicit and not inherited:
return None
path = Path(manifest if explicit else inherited).expanduser().absolute()
try:
data = json.loads(path.read_text(encoding='utf-8'))
if (not isinstance(data, dict) or
any(not isinstance(data.get(key), str) or not Path(data[key]).is_absolute()
or Path(data[key]) != getattr(runtime, key) for key in ('config', 'state', 'runtime')) or
not isinstance(data.get('names'), list) or
any(not isinstance(name, str) for name in data['names']) or
not isinstance(data.get('terminal'), str) or not Path(data['terminal']).is_absolute() or
not isinstance(data.get('directory'), str) or Path(data['directory']) != path.parent):
raise ValueError('Invalid session manifest or different runtime roots')
for name in data['names']:
validate_name(name)
return path
except (OSError, ValueError) as exc:
if explicit:
raise session.SessionError('Invalid session manifest; start a fresh Hydra OS session') from exc
return None
def _view_preflight(runtime: Runtime, *, no_view: bool, manifest: Path | None) -> Path | None:
if no_view:
return None
selected = session_manifest(runtime, manifest)
session._display()
session._viewer()
return selected
def _show(runtime: Runtime, name: str, *, no_view: bool, windowed: bool,
manifest: Path | None) -> int:
if no_view:
return 0
if manifest is not None:
return session.request_refresh(manifest, selected=name)
return session.view(runtime, name, windowed=windowed)
def open_vm(runtime: Runtime, name: str, *, windowed=False, no_view=False,
manifest: Path | None = None) -> int:
profile = runtime.profile(name)
current = runtime.status(name)
status = current['status']
if status == 'unknown':
raise RuntimeError(f'Cannot open {name}: unknown VM identity; {current.get("error", "inspect its QMP identity before retrying")}')
manifest = _view_preflight(runtime, no_view=no_view, manifest=manifest)
if status == 'stopped':
_memory_preflight(profile.memory)
runtime.start(name)
# Other verified QEMU states (for example paused) are viewable without a
# new process. Runtime.start owns the lock and final identity recheck.
return _show(runtime, name, no_view=no_view, windowed=windowed, manifest=manifest)
def run_source(runtime: Runtime, source: str | Path, *, name: str, memory=4096,
cpus=2, disk_size='40G', sha256: str | None = None, windowed=False,
no_view=False, manifest: Path | None = None, defaults: Path | None = None,
progress=None) -> int:
validate_name(name)
profile_path = runtime.config / f'{name}.toml'
if profile_path.exists() or profile_path.is_symlink():
raise RuntimeError(f'VM {name} already exists; use hydra open {name}')
paths = runtime.paths(name)
if paths.disk.parent.exists() or paths.disk.parent.is_symlink():
raise RuntimeError(f'Existing VM state/disk for {name}; choose a new name and preserve the old disk')
if runtime.status(name)['status'] != 'stopped':
raise RuntimeError(f'Cannot create {name}: existing or unknown VM identity')
preferences = load_defaults(defaults) if defaults is not None else {}
# Validate requested allocations before downloading or creating anything.
Profile(name, memory=memory, cpus=cpus, disk_size=disk_size, **preferences)
manifest = _view_preflight(runtime, no_view=no_view, manifest=manifest)
_memory_preflight(memory)
source = str(source)
if '://' in source:
iso = media.download_iso(source, sha256=sha256, progress=progress)
else:
iso = media.validate_iso(Path(source).expanduser().resolve(), sha256=sha256)
# Recheck after a potentially lengthy transfer. Profile publication itself
# is exclusive ('x') and Runtime never replaces an existing disk.
if paths.disk.parent.exists() or paths.disk.parent.is_symlink():
raise RuntimeError(f'Existing VM state/disk for {name}; choose a new name')
_memory_preflight(memory)
runtime.add(Profile(name, iso, memory, cpus, disk_size, **preferences))
runtime.start(name)
return _show(runtime, name, no_view=no_view, windowed=windowed, manifest=manifest)
+164
View File
@@ -0,0 +1,164 @@
"""Private, validated ISO downloads with atomic, non-replacing publication."""
import fcntl
import hashlib
import http.client
import os
import re
import shutil
import stat
import tempfile
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from .runtime import _private_dir
class MediaError(ValueError):
"""An actionable image error without remote query strings or credentials."""
def _checksum(sha256: str | None) -> str | None:
if sha256 is not None and (not isinstance(sha256, str) or not re.fullmatch(r'[a-fA-F0-9]{64}', sha256)):
raise MediaError('SHA-256 must contain exactly 64 hexadecimal characters')
return sha256.lower() if sha256 else None
def _url(url: str) -> urllib.parse.SplitResult:
try:
parts = urllib.parse.urlsplit(url)
port = parts.port
except ValueError as exc:
raise MediaError('Invalid HTTP(S) image URL') from exc
if parts.scheme not in ('http', 'https') or not parts.hostname:
raise MediaError('Use a direct HTTP(S) ISO URL, or a local ISO path with hydra run')
if parts.username is not None or parts.password is not None:
raise MediaError('URL credentials are not supported; use a direct image URL without credentials')
if any(ord(char) < 32 or ord(char) == 127 for char in url):
raise MediaError('Image URL contains control characters')
return parts
class _SafeRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
_url(newurl)
return super().redirect_request(req, fp, code, msg, headers, newurl)
def validate_iso(path: Path, *, sha256: str | None = None) -> Path:
"""Recognize ISO9660 or UDF volume descriptors and optional whole-file hash."""
wanted = _checksum(sha256)
path = Path(path)
if not path.is_file():
raise MediaError(f'ISO does not exist or is not a file: {path}')
found = False
with path.open('rb') as stream:
stream.seek(16 * 2048)
for _ in range(241):
sector = stream.read(2048)
if len(sector) != 2048:
break
if ((sector[1:6] == b'CD001' and sector[0] in (0, 1, 2, 3, 255) and sector[6] == 1)
or (sector[1:6] in (b'NSR02', b'NSR03') and sector[6] == 1)):
found = True
break
if not found:
raise MediaError('Not an ISO9660/UDF image; use a direct ISO download URL rather than an HTML download page')
if wanted:
stream.seek(0)
digest = hashlib.file_digest(stream, 'sha256').hexdigest()
if digest != wanted:
raise MediaError('ISO SHA-256 mismatch; image was not accepted')
return path
def _cached(path: Path, sha256: str | None) -> Path | None:
try:
info = path.lstat()
except FileNotFoundError:
return None
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid():
raise MediaError('Refusing an unsafe ISO cache entry; choose another cache directory')
try:
return validate_iso(path, sha256=sha256)
except MediaError as exc:
raise MediaError('Cached ISO failed validation; move it aside or choose another cache directory') from exc
def download_iso(url: str, *, sha256: str | None = None, cache: Path | None = None,
progress=None) -> Path:
"""Download once under a URL lock; publish only complete verified images."""
parts = _url(url)
wanted = _checksum(sha256)
cache = Path(cache) if cache is not None else Path(os.environ.get('XDG_CACHE_HOME', Path.home() / '.cache')) / 'hydra/iso'
cache = cache.expanduser().absolute()
_private_dir(cache)
basename = re.sub(r'[^A-Za-z0-9._-]', '_', urllib.parse.unquote(Path(parts.path).name))[:96].strip('.') or 'image.iso'
if not basename.lower().endswith('.iso'):
basename += '.iso'
key = hashlib.sha256(url.encode()).hexdigest()
destination = cache / f'{key}-{basename}'
lock = os.open(cache / (key + '.lock'), os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
try:
fcntl.flock(lock, fcntl.LOCK_EX)
cached = _cached(destination, wanted)
if cached:
if progress:
size = cached.stat().st_size
progress(size, size)
return cached
temporary = None
try:
opener = urllib.request.build_opener(_SafeRedirect())
request = urllib.request.Request(url, headers={'User-Agent': 'Hydra-OS/1', 'Accept-Encoding': 'identity'})
with opener.open(request, timeout=30) as response:
if response.status != 200:
raise MediaError(f'Image server returned HTTP {response.status}; use a direct ISO URL')
if response.headers.get_content_type() in ('text/html', 'application/xhtml+xml'):
raise MediaError('Server returned an HTML download page; use a direct ISO URL')
raw_length = response.headers.get('Content-Length')
try:
total = int(raw_length) if raw_length is not None else None
if total is not None and total <= 0:
raise ValueError
except ValueError as exc:
raise MediaError('Image server supplied an invalid content length') from exc
if total is not None and total > shutil.disk_usage(cache)[2]:
raise MediaError('Not enough free disk space for the ISO download')
fd, name = tempfile.mkstemp(prefix='.' + key + '-', suffix='.part', dir=cache)
temporary = Path(name)
received = 0
with os.fdopen(fd, 'wb') as output:
if progress:
progress(0, total)
while True:
chunk = response.read(1024 * 1024)
if not chunk:
break
if len(chunk) > shutil.disk_usage(cache)[2]:
raise MediaError('Not enough free disk space for the ISO download')
received += len(chunk)
if total is not None and received > total:
raise MediaError('Image download exceeds its advertised content length')
output.write(chunk)
if progress:
progress(received, total)
output.flush()
os.fsync(output.fileno())
if total is not None and received != total:
raise MediaError('Image download was truncated; retry the direct ISO URL')
validate_iso(temporary, sha256=wanted)
# Linking cannot replace a concurrent or unrelated destination.
os.link(temporary, destination)
return destination
except urllib.error.HTTPError as exc:
raise MediaError(f'Image server returned HTTP {exc.code}; use a direct ISO URL') from None
except (urllib.error.URLError, http.client.HTTPException, TimeoutError) as exc:
raise MediaError('ISO download failed or timed out; check networking and retry the direct URL') from None
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
finally:
os.close(lock)