Add safe ISO download and explicit run/open commands
This commit is contained in:
+61
-4
@@ -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
@@ -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
@@ -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)
|
||||
Reference in New Issue
Block a user