Compare commits
41
Commits
feat/image
..
0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c26442f6f5 | ||
|
|
6855c62ba1 | ||
|
|
ebeebc7fb1 | ||
|
|
ce04318b78 | ||
|
|
1ae05a6823 | ||
|
|
6baccefde2 | ||
|
|
579f3bb071 | ||
|
|
6058a0660e | ||
|
|
3c15890823 | ||
|
|
a77006a05f | ||
|
|
ec40a5a305 | ||
|
|
f0101f8cb6 | ||
|
|
b03ac9c2a6 | ||
|
|
798a5779ef | ||
|
|
261ee28f77 | ||
|
|
d625733207 | ||
|
|
70b7edae8a | ||
|
|
352f1f9acb | ||
|
|
00aa0229cc | ||
|
|
87003e92c6 | ||
|
|
8512d1d10b | ||
|
|
653388f0d7 | ||
|
|
ae0d9cf08d | ||
|
|
98022dd355 | ||
|
|
c43c0977f6 | ||
|
|
bc4fe60947 | ||
|
|
71fb8eacbd | ||
|
|
4eebb54f08 | ||
|
|
be30e9dd49 | ||
|
|
1bc7965cca | ||
|
|
73bcf616ee | ||
|
|
61a5628f9e | ||
|
|
dcf1fbae40 | ||
|
|
4e65a0bdc3 | ||
|
|
8c8ece99f1 | ||
|
|
d2988bcecc | ||
|
|
c6099103b2 | ||
|
|
b547d6c535 | ||
|
|
e6dc59b567 | ||
|
|
1784f3a8ce | ||
|
|
9abedf29f0 |
@@ -1,4 +1,4 @@
|
||||
# HydraOS
|
||||
# Hydra OS
|
||||
|
||||
Gentoo-based host for concurrent virtual desktops. Its default session shows one
|
||||
VM fullscreen and switches between guests with keyboard shortcuts. Other host
|
||||
@@ -6,12 +6,15 @@ desktops may use the same independent VM manager and viewers.
|
||||
|
||||
## Status
|
||||
|
||||
The first installable UEFI preview passed a complete disposable QEMU/KVM run:
|
||||
installation, boot without the ISO, password login, persistent data, networking,
|
||||
nested KVM and VM survival across user logout. See the
|
||||
[build and acceptance record](docs/releases/2026-10-13/acceptance.json).
|
||||
The first physical target is ASUS TUF Dash F15 FX517ZC; laptop hardware checks
|
||||
remain pending. Existing NixOS and VM disks were not installation or test targets.
|
||||
Hydra OS 0.2.0 adds selected-partition installation, signed runtime updates,
|
||||
offline Btrfs base recovery and read-only laptop diagnostics. The complete source
|
||||
suite passed **431 tests**. The final ISO passed **13 whole-disk** and **23
|
||||
preserved-partition/recovery** UEFI/KVM checks, with no skips. See the
|
||||
[release and acceptance record](docs/releases/2026-10-13-laptop-ready/README.md).
|
||||
The [previous terminal UX preview](docs/releases/2026-10-13-terminal-ux/README.md)
|
||||
passed 13 disposable UEFI/KVM checks against its earlier image. The first physical
|
||||
target is ASUS TUF Dash F15 FX517ZC; hardware checks remain pending. No physical
|
||||
partition has been selected, and existing NixOS and VM disks were not targets.
|
||||
|
||||
1. Independent QEMU/KVM lifecycle with private QMP control.
|
||||
2. Minimal labwc session and fullscreen SPICE viewers.
|
||||
@@ -25,15 +28,45 @@ a refresh-rate setting does not prove delivered FPS.
|
||||
- [Session design](docs/superpowers/specs/2026-10-13-graphics-session-design.md)
|
||||
- [Implementation plan](docs/superpowers/plans/2026-10-13-graphics-session.md)
|
||||
- [Hardware target](docs/hardware/asus-fx517zc.md)
|
||||
- [Signed runtime updates](docs/updates.md)
|
||||
- [Offline Gentoo base recovery](docs/system-recovery.md)
|
||||
|
||||
Build outputs, VM disks, runtime state and credentials stay outside Git.
|
||||
|
||||
## First use
|
||||
|
||||
Follow the [installation guide](docs/install.md) to install from the live ISO.
|
||||
The initial empty graphical session opens a recovery terminal. Enter `:shell`
|
||||
for an ordinary host shell; `sudo nmtui` configures host network connections.
|
||||
Guest operating systems are supplied by you:
|
||||
The session opens a terminal VM menu. Choose a machine by number or name;
|
||||
Enter opens the remembered selection, or the only machine. Opening a stopped
|
||||
machine starts it; opening a running one reconnects its screen. Merely opening
|
||||
the menu does not start every VM. `:shell` opens an ordinary host shell and
|
||||
`:network` opens NetworkManager's connection menu. The existing VM disks and
|
||||
command-line controls remain available.
|
||||
|
||||
From a Hydra shell, a direct installer URL can become a running VM in one
|
||||
command. Replace the example URL with an actual ISO download link:
|
||||
|
||||
```sh
|
||||
hydra run 'https://example.org/distribution.iso' --name work --memory 4096 --cpus 2
|
||||
hydra open work
|
||||
```
|
||||
|
||||
`run` also accepts a local ISO path. It refuses to replace an existing VM name;
|
||||
use `open` for subsequent starts. Add `--no-view` for a headless launch. A
|
||||
download-page URL returning HTML is rejected. Downloads show progress, can be
|
||||
cancelled with Ctrl+C, and become reusable cache entries only after validation.
|
||||
Supply the publisher's SHA-256 when available:
|
||||
|
||||
```sh
|
||||
hydra download 'https://example.org/distribution.iso' --sha256 PUBLISHER_SHA256
|
||||
```
|
||||
|
||||
The cache lives under `$XDG_CACHE_HOME/hydra/iso` (normally
|
||||
`~/.cache/hydra/iso`). Without a publisher checksum, ISO format validation
|
||||
does not establish publisher authenticity. New launches through this workflow
|
||||
check available RAM with a 768 MiB host reserve; this cannot guarantee against
|
||||
later memory pressure. In a nested test, leave RAM available on the outer host
|
||||
too. The original lower-level commands remain useful for manual control:
|
||||
|
||||
```sh
|
||||
hydra add work --iso /absolute/path/ubuntu.iso --memory 8192 --cpus 4
|
||||
@@ -43,7 +76,7 @@ hydra start experiments
|
||||
exit
|
||||
```
|
||||
|
||||
Back at `hydra recovery>`, enter `:refresh` to show the newly created guests.
|
||||
Back in the terminal menu, select the new guest to open its screen.
|
||||
Guests remain running while you switch, reconnect viewers or leave the host
|
||||
graphical session. Shut a guest down from inside its OS, or request an orderly
|
||||
shutdown with `hydra shutdown NAME`. Check `hydra status NAME` before detaching
|
||||
@@ -52,14 +85,14 @@ its installer with `hydra media NAME --detach` and starting it again.
|
||||
| Shortcut | Action |
|
||||
| --- | --- |
|
||||
| Ctrl+Alt+Shift+Left / Right | Switch guest workspaces |
|
||||
| Ctrl+Alt+Shift+Escape | Open local recovery |
|
||||
| Ctrl+Alt+Shift+Escape | Open the terminal VM menu |
|
||||
| Ctrl+Alt+Shift+R | Release viewer input capture |
|
||||
| Ctrl+Alt+Shift+U | Choose a USB device to redirect |
|
||||
| Brightness keys | Adjust host backlight |
|
||||
|
||||
New guests share `hydrabr0` (`10.203.0.0/24`) and have distinct persistent MAC
|
||||
addresses. Services can communicate between guests without per-port forwarding;
|
||||
access from your LAN requires a router route through the HydraOS host. Each
|
||||
access from your LAN requires a router route through the Hydra OS host. Each
|
||||
guest controls its own services and firewall. Host Internet access is separate
|
||||
from this local guest network.
|
||||
|
||||
@@ -69,11 +102,16 @@ Other host desktops can use `hydra start NAME` and `hydra view NAME --windowed`
|
||||
without entering the minimal labwc session. Ordinary Gentoo administration and
|
||||
Portage remain available through the recovery shell.
|
||||
|
||||
Fastfetch displays the **Hydra OS** system summary and its custom terminal
|
||||
logo. It is included in the new image; the first published ISO predates this
|
||||
interface update. See [terminal usage](docs/terminal-usage.md) for networking,
|
||||
installation media and common recovery steps.
|
||||
|
||||
## Build the ISO
|
||||
|
||||
Follow the [isolated Gentoo build instructions](image/README.md). Outputs are
|
||||
`build/artifacts/hydra-os.iso` and `hydra-os.iso.sha256`; binary images stay out
|
||||
of Git. [Recorded source inputs and package versions](docs/releases/2026-10-13/rootfs-manifest.json)
|
||||
of Git. [Recorded source inputs and package versions](docs/releases/2026-10-13-laptop-ready/rootfs-manifest.json)
|
||||
identify the tested image. The builder uses Gentoo systemd for login/device
|
||||
management, labwc for the minimal graphical session, and QEMU/KVM for guests.
|
||||
Portage and the tracked QEMU/viewer patches remain available for ordinary updates.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Hydra OS mark
|
||||
|
||||
`hydra-mark.png` is the original transparent image generated with the built-in
|
||||
imagegen tool. The complete prompt and conversion settings are in
|
||||
[`generation.json`](generation.json). `preview.png` shows the resulting ASCII
|
||||
glyphs; the shipped asset is `image/rootfs/usr/share/hydra/fastfetch.txt`.
|
||||
|
||||
The 44-column mark uses ordinary ASCII characters, cyan Fastfetch color `$1`,
|
||||
and a white `$2` caption. Pillow is needed only to regenerate the text, not to
|
||||
run Fastfetch or build an image with the checked-in asset.
|
||||
|
||||
From the repository root, with Python and Pillow available:
|
||||
|
||||
```sh
|
||||
python3 dev/render-logo.py
|
||||
fastfetch --config image/rootfs/etc/fastfetch/config.jsonc \
|
||||
--logo image/rootfs/usr/share/hydra/fastfetch.txt --pipe false
|
||||
```
|
||||
|
||||
On a Nix development host, the converter can also run in a temporary environment:
|
||||
|
||||
```sh
|
||||
nix-shell -p 'python3.withPackages (p: [p.pillow])' \
|
||||
--run 'python3 dev/render-logo.py'
|
||||
```
|
||||
|
||||
The converter uses opacity to preserve the single-color silhouette, removes
|
||||
faint alpha noise, and accounts for terminal character proportions. It preserves
|
||||
the PNG source. Future ISO builds and runtime update bundles include the new text;
|
||||
previously built ISOs and installed guests retain their existing logo.
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"tool": "built-in image_gen",
|
||||
"generated_on": "2026-10-13",
|
||||
"prompt": "Use case: logo-brand.\nAsset type: Hydra OS emblem, to be converted to a small 40–48 column ASCII logo for the Fastfetch Linux terminal system summary.\nPrimary request: Create one original, striking and extremely readable three-headed hydra emblem.\nSubject: exactly three reptilian dragon heads sharing one continuous broad base, with three clearly separated curved necks. A slightly taller center head faces forward, two side heads face outward. Distinct angular snouts, simple horns and large negative-space eyes. Broad separated necks and open gaps must make all three heads unmistakable at tiny sizes.\nStyle/medium: flat graphic silhouette; elegant, minimal, crisp, bold shapes; restrained technical OS identity.\nComposition: one centered balanced compact emblem in a square image, fills most of the canvas with a small even margin; no enclosing badge or border.\nColor: a single bright cyan fill, no shading. Actual transparent background including the gaps and eye cutouts.\nConstraints: clarity when downsampled to approximately 44 columns by 22 text rows. Thick shapes and large negative spaces, very few details. No text, letters, words, watermarks, gradients, glow, shadows, 3D, textures, scales, hairline strokes, or mockup. Only the hydra symbol.",
|
||||
"source": "hydra-mark.png",
|
||||
"conversion": {
|
||||
"script": "../../dev/render-logo.py",
|
||||
"library": "Pillow",
|
||||
"verified_version": "12.3.0",
|
||||
"columns": 44,
|
||||
"character_aspect": 0.5,
|
||||
"output": "../../image/rootfs/usr/share/hydra/fastfetch.txt"
|
||||
},
|
||||
"preview": "preview.png"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 680 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run HydraOS from this checkout without installing a host package."""
|
||||
"""Run Hydra OS from this checkout without installing a host package."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Source checkout entry point; installed copies use the installed Python package."""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
checkout = Path(__file__).resolve().parent.parent
|
||||
if (checkout / 'hydra/system_recovery.py').is_file():
|
||||
sys.path.insert(0, str(checkout))
|
||||
from hydra.system_recovery import main
|
||||
|
||||
raise SystemExit(main())
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/python3 -I
|
||||
"""Independent trusted updater launcher; does not import the mutable runtime."""
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
|
||||
path = Path(__file__).resolve()
|
||||
if path.parent == Path('/usr/bin'):
|
||||
engine = Path('/usr/lib/hydra-update/engine.py')
|
||||
else:
|
||||
# Source checkout only. Installed launchers never fall back to mutable code.
|
||||
engine = path.parent.parent / 'hydra/update_engine.py'
|
||||
raise SystemExit(runpy.run_path(str(engine))['main']())
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/python3 -I
|
||||
"""Recover without importing any module from the mutable Hydra runtime."""
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
|
||||
path = Path(__file__).resolve()
|
||||
engine = Path('/usr/lib/hydra-update/engine.py') if path.parent == Path('/usr/bin') else path.parent.parent / 'hydra/update_engine.py'
|
||||
raise SystemExit(runpy.run_path(str(engine))['main'](recovery=True))
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a self-contained, backed-up runtime update for an installed Hydra guest.
|
||||
|
||||
This updates explicitly listed program/branding files, never guest disks,
|
||||
profiles, credentials, networking or the kernel. It is not a package manager.
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
|
||||
class UpdateError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
EXACT = {'usr/lib/hydra/bin/hydra', 'etc/motd',
|
||||
'etc/fastfetch/config.jsonc', 'usr/share/hydra/fastfetch.txt'}
|
||||
LIMIT = 16 * 1024 * 1024
|
||||
|
||||
|
||||
def allowed(name):
|
||||
return (name in EXACT or
|
||||
re.fullmatch(r'usr/lib/hydra/hydra/[a-z_]+\.py', name) is not None)
|
||||
|
||||
|
||||
def digest(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def build_bundle(source, output):
|
||||
source, output = Path(source), Path(output)
|
||||
files = {}
|
||||
for module in sorted((source / 'hydra').glob('*.py')):
|
||||
files['usr/lib/hydra/hydra/' + module.name] = (module.read_bytes(), 0o644)
|
||||
launcher = source / 'bin/hydra'
|
||||
if not launcher.is_file() or 'usr/lib/hydra/hydra/cli.py' not in files:
|
||||
raise UpdateError('Source is missing the Hydra runtime')
|
||||
files['usr/lib/hydra/bin/hydra'] = (launcher.read_bytes(), 0o755)
|
||||
for name in EXACT - {'usr/lib/hydra/bin/hydra'}:
|
||||
path = source / 'image/rootfs' / name
|
||||
if path.is_file():
|
||||
files[name] = (path.read_bytes(), 0o644)
|
||||
commit = subprocess.run(['git', '-C', str(source), 'rev-parse', 'HEAD'],
|
||||
text=True, capture_output=True, timeout=10)
|
||||
manifest = {'format': 1, 'product': 'Hydra OS',
|
||||
'source_commit': commit.stdout.strip() if commit.returncode == 0 else None,
|
||||
'files': {name: {'sha256': digest(data), 'mode': mode}
|
||||
for name, (data, mode) in sorted(files.items())}}
|
||||
# Exclusive creation preserves previous artifacts, even on repeated builds.
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with output.open('xb') as stream:
|
||||
stream.write(b'#!/usr/bin/env python3\n')
|
||||
with zipfile.ZipFile(stream, 'w', zipfile.ZIP_DEFLATED) as bundle:
|
||||
bundle.writestr('__main__.py', Path(__file__).read_bytes())
|
||||
bundle.writestr('manifest.json', json.dumps(manifest, indent=2))
|
||||
for name, (data, _) in sorted(files.items()):
|
||||
if not allowed(name):
|
||||
raise UpdateError('Unrecognized payload path: ' + name)
|
||||
bundle.writestr('payload/' + name, data)
|
||||
except BaseException:
|
||||
# Do not remove a pre-existing output when exclusive creation failed.
|
||||
if 'stream' in locals():
|
||||
output.unlink(missing_ok=True)
|
||||
raise
|
||||
output.chmod(0o700)
|
||||
return manifest
|
||||
|
||||
|
||||
def checked_path(root, name):
|
||||
relative = PurePosixPath(name)
|
||||
if relative.is_absolute() or '..' in relative.parts:
|
||||
raise UpdateError('Unsafe update path')
|
||||
current = root
|
||||
for part in relative.parts:
|
||||
current = current / part
|
||||
if current.is_symlink():
|
||||
# /etc/os-release is the one intentional replacement of a known
|
||||
# vendor link. os.replace replaces the link, never its target.
|
||||
if (name == 'etc/os-release' and current == root / name and
|
||||
str(current.readlink()) in {'../usr/lib/os-release', '/usr/lib/os-release'}):
|
||||
checked_path(root, 'usr/lib/os-release')
|
||||
return current
|
||||
raise UpdateError('Symlink update destination refused: ' + str(current))
|
||||
if current.exists() and not current.is_file():
|
||||
raise UpdateError('Update destination is not a regular file: ' + str(current))
|
||||
return current
|
||||
|
||||
|
||||
def atomic_write(path, data, mode):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix='.hydra-update-', dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as stream:
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.fchmod(stream.fileno(), mode)
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
Path(temporary).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def apply_bundle(bundle, root=Path('/'), *, execute=False):
|
||||
root = Path(root).absolute()
|
||||
if root.is_symlink() or root.resolve() != root:
|
||||
raise UpdateError('Symlink target root refused')
|
||||
marker = checked_path(root, 'usr/share/hydra/build-manifest.json')
|
||||
if not marker.is_file() or not (root / 'usr/lib/hydra/hydra').is_dir():
|
||||
raise UpdateError('Target is not an existing Hydra installation')
|
||||
payload = {}
|
||||
with zipfile.ZipFile(bundle) as archive:
|
||||
names = archive.namelist()
|
||||
if len(names) != len(set(names)) or sum(i.file_size for i in archive.infolist()) > LIMIT:
|
||||
raise UpdateError('Invalid or oversized update bundle')
|
||||
manifest = json.loads(archive.read('manifest.json'))
|
||||
if manifest.get('format') != 1 or manifest.get('product') != 'Hydra OS' or not isinstance(manifest.get('files'), dict):
|
||||
raise UpdateError('Invalid Hydra update manifest')
|
||||
for name, entry in manifest['files'].items():
|
||||
if not allowed(name) or entry.get('mode') not in {0o644, 0o755}:
|
||||
raise UpdateError('Unrecognized update file: ' + name)
|
||||
data = archive.read('payload/' + name)
|
||||
if digest(data) != entry.get('sha256'):
|
||||
raise UpdateError('Payload checksum mismatch: ' + name)
|
||||
checked_path(root, name)
|
||||
payload[name] = (data, entry['mode'])
|
||||
if 'usr/lib/hydra/hydra/cli.py' not in payload:
|
||||
raise UpdateError('Update has no Hydra CLI')
|
||||
release = checked_path(root, 'etc/os-release')
|
||||
vendor = checked_path(root, 'usr/lib/os-release')
|
||||
current_release = vendor if release.is_symlink() else release
|
||||
original = current_release.read_text() if current_release.is_file() else ''
|
||||
retained = [line for line in original.splitlines()
|
||||
if not re.match(r'^(NAME|PRETTY_NAME|ID|ID_LIKE)=', line)]
|
||||
retained += ['NAME="Hydra OS"', 'PRETTY_NAME="Hydra OS"',
|
||||
'ID=hydra-os', 'ID_LIKE=gentoo']
|
||||
payload['etc/os-release'] = (('\n'.join(retained) + '\n').encode(), 0o644)
|
||||
receipt_name = 'usr/share/hydra/runtime-update.json'
|
||||
checked_path(root, receipt_name)
|
||||
payload[receipt_name] = ((json.dumps(manifest, indent=2) + '\n').encode(), 0o644)
|
||||
result = {'applied': False, 'source_commit': manifest.get('source_commit'),
|
||||
'files': sorted(payload), 'backup': None}
|
||||
if not execute:
|
||||
return result
|
||||
if root == Path('/') and os.geteuid() != 0:
|
||||
raise UpdateError('Apply inside the Hydra guest with sudo')
|
||||
backup_base = root / 'var/lib/hydra/updates'
|
||||
# Validate ancestors without creating anything before all payload checks.
|
||||
checked_path(root, 'var/lib/hydra/updates/.probe')
|
||||
backup_base.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
stamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ-')
|
||||
backup = Path(tempfile.mkdtemp(prefix=stamp, dir=backup_base))
|
||||
records = {}
|
||||
for name in payload:
|
||||
dest = checked_path(root, name)
|
||||
if dest.exists() or dest.is_symlink():
|
||||
saved = backup / name
|
||||
saved.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(dest, saved, follow_symlinks=False)
|
||||
records[name] = {'existed': True, 'mode': dest.lstat().st_mode & 0o777}
|
||||
if dest.is_symlink():
|
||||
records[name]['symlink'] = str(dest.readlink())
|
||||
else:
|
||||
records[name]['sha256'] = digest(dest.read_bytes())
|
||||
else:
|
||||
records[name] = {'existed': False}
|
||||
(backup / 'backup.json').write_text(json.dumps(records, indent=2) + '\n')
|
||||
changed = []
|
||||
try:
|
||||
for name, (data, mode) in payload.items():
|
||||
atomic_write(checked_path(root, name), data, mode)
|
||||
changed.append(name)
|
||||
except BaseException as apply_error:
|
||||
rollback_errors = {}
|
||||
for name in reversed(changed):
|
||||
try:
|
||||
dest = checked_path(root, name)
|
||||
original = records[name]
|
||||
if 'symlink' in original:
|
||||
dest.unlink()
|
||||
dest.symlink_to(original['symlink'])
|
||||
elif original['existed']:
|
||||
atomic_write(dest, (backup / name).read_bytes(), original['mode'])
|
||||
else:
|
||||
dest.unlink(missing_ok=True)
|
||||
except BaseException as restore_error:
|
||||
# A full/unavailable filesystem may prevent one restoration
|
||||
# while unlinking independently created files still succeeds.
|
||||
rollback_errors[name] = f'{type(restore_error).__name__}: {restore_error}'
|
||||
if rollback_errors:
|
||||
report = ('Hydra OS update: rollback incomplete; the runtime may contain mixed versions.\n'
|
||||
f'Retained backup: {backup}\n'
|
||||
'Manual recovery records: ' + str(backup / 'backup.json') + '\n' +
|
||||
'\n'.join(f' {name}: {error}' for name, error in rollback_errors.items()))
|
||||
apply_error.add_note(report)
|
||||
print(report, file=sys.stderr)
|
||||
raise
|
||||
result.update(applied=True, backup=str(backup))
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--build', type=Path, metavar='SOURCE')
|
||||
parser.add_argument('--output', type=Path)
|
||||
parser.add_argument('--root', type=Path, default=Path('/'), help='Installed Hydra root; staging roots are supported for verification')
|
||||
parser.add_argument('--apply', action='store_true', help='Apply after verification and retain original files in a backup')
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.build:
|
||||
if not args.output or args.apply:
|
||||
parser.error('--build requires --output and cannot be combined with --apply')
|
||||
result = build_bundle(args.build, args.output)
|
||||
else:
|
||||
result = apply_bundle(Path(sys.argv[0]), args.root, execute=args.apply)
|
||||
print(json.dumps(result, indent=2))
|
||||
if args.apply:
|
||||
print('Runtime updated. Log out of the Hydra graphical session and log in again to reload session code; running VMs are unchanged.')
|
||||
if not (args.root / 'usr/bin/fastfetch').exists():
|
||||
print('Fastfetch is not installed yet. Inside Hydra: sudo emerge --getbinpkg --noreplace app-misc/fastfetch')
|
||||
return 0
|
||||
except (UpdateError, OSError, ValueError, KeyError, zipfile.BadZipFile) as exc:
|
||||
print('Hydra OS update: ' + str(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
+275
-4
@@ -308,6 +308,265 @@ def lifecycle_guest_script():
|
||||
'\n'.join(inspect.getsource(helper) for helper in helpers) + LIFECYCLE_BODY)
|
||||
|
||||
|
||||
def terminal_ux_iso():
|
||||
"""An empty ISO9660 filesystem, intentionally without an El Torito boot image."""
|
||||
def both(value, width):
|
||||
return value.to_bytes(width, 'little') + value.to_bytes(width, 'big')
|
||||
|
||||
def directory_record(identifier):
|
||||
record = bytearray(34)
|
||||
record[0] = 34
|
||||
record[2:10] = both(20, 4)
|
||||
record[10:18] = both(2048, 4)
|
||||
record[18:25] = bytes((126, 10, 13, 0, 0, 0, 0))
|
||||
record[25] = 2
|
||||
record[28:32] = both(1, 2)
|
||||
record[32] = 1
|
||||
record[33] = identifier
|
||||
return record
|
||||
|
||||
data = bytearray(21 * 2048)
|
||||
primary = bytearray(2048)
|
||||
primary[:7] = b'\x01CD001\x01'
|
||||
primary[8:40] = b'HYDRA OS'.ljust(32, b' ')
|
||||
primary[40:72] = b'HYDRA_UX_PROBE'.ljust(32, b' ')
|
||||
primary[80:88] = both(21, 4)
|
||||
primary[120:124] = both(1, 2)
|
||||
primary[124:128] = both(1, 2)
|
||||
primary[128:132] = both(2048, 2)
|
||||
primary[132:140] = both(10, 4)
|
||||
primary[140:144] = (18).to_bytes(4, 'little')
|
||||
primary[148:152] = (19).to_bytes(4, 'big')
|
||||
primary[156:190] = directory_record(0)
|
||||
primary[190:813] = b' ' * 623
|
||||
primary[813:830] = b'2026101300000000\x00'
|
||||
primary[830:847] = b'2026101300000000\x00'
|
||||
primary[847:881] = b'0000000000000000\x00' * 2
|
||||
primary[881] = 1
|
||||
data[16 * 2048:17 * 2048] = primary
|
||||
data[17 * 2048:17 * 2048 + 7] = b'\xffCD001\x01'
|
||||
for sector, order in ((18, 'little'), (19, 'big')):
|
||||
data[sector * 2048:sector * 2048 + 10] = (
|
||||
b'\x01\x00' + (20).to_bytes(4, order) + (1).to_bytes(2, order) + b'\x00\x00')
|
||||
data[20 * 2048:20 * 2048 + 68] = directory_record(0) + directory_record(1)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def verify_terminal_ux_branding(output, errors, logo):
|
||||
ansi = r'\x1b\[[0-?]*[ -/]*[@-~]'
|
||||
rendered = re.sub(ansi, '', output)
|
||||
if errors.strip() or re.search(r'\b(error|failed|unknown module)\b', rendered, re.I):
|
||||
raise SmokeError('Installed default Fastfetch emitted errors: ' + (errors or rendered)[-1500:])
|
||||
if not re.search(r'\bOS\s*:\s*Hydra OS\b', rendered):
|
||||
raise SmokeError('Installed default Fastfetch did not render the Hydra OS pretty name')
|
||||
expected = [line.rstrip() for line in re.sub(r'\$[1-9]|' + ansi, '', logo).splitlines()
|
||||
if line.strip()]
|
||||
caption = 'H Y D R A O S'
|
||||
if (not any(line.strip() == caption for line in expected)
|
||||
or not any(line.strip() != caption for line in expected)):
|
||||
raise SmokeError('Packaged Fastfetch logo must contain art and the Hydra OS caption')
|
||||
width = max(map(len, expected))
|
||||
lines = iter(rendered.splitlines())
|
||||
for row in expected:
|
||||
if not any(line[:width].rstrip() == row
|
||||
and (len(line) <= width or line[width].isspace()) for line in lines):
|
||||
raise SmokeError('Installed default Fastfetch did not render the complete packaged Hydra logo')
|
||||
return rendered
|
||||
|
||||
|
||||
def terminal_ux_snapshot(context):
|
||||
"""Verify CLI-owned live process/QMP identity, excluding OS boot or audio claims."""
|
||||
try:
|
||||
if context['uid'] != os.getuid():
|
||||
raise SmokeError('Terminal UX process is not owned by the installed normal user')
|
||||
runtime = Path(context['runtime'])
|
||||
run = runtime / context['name']
|
||||
for directory in (runtime, run):
|
||||
info = directory.lstat()
|
||||
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != context['uid']
|
||||
or stat.S_IMODE(info.st_mode) != 0o700):
|
||||
raise SmokeError('Terminal UX runtime is not private and owned')
|
||||
if json.loads((run / 'identity.json').read_text()) != {
|
||||
'name': context['name'], 'uuid': context['uuid']}:
|
||||
raise SmokeError('Terminal UX metadata belongs to a different VM')
|
||||
pid = int((run / 'qemu.pid').read_text())
|
||||
process = Path('/proc') / str(pid)
|
||||
fields = (process / 'stat').read_text().rsplit(')', 1)[1].split()
|
||||
if process.stat().st_uid != context['uid'] or fields[0] == 'Z':
|
||||
raise SmokeError('Terminal UX VM process exited or changed owner')
|
||||
argv = (process / 'cmdline').read_bytes().split(b'\0')
|
||||
for option, expected in ((b'-name', context['name']), (b'-uuid', context['uuid'])):
|
||||
if argv[argv.index(option) + 1] != expected.encode():
|
||||
raise SmokeError('Terminal UX PID belongs to a different VM')
|
||||
lifecycle_socket(run / 'qmp.sock', connect_socket=False)
|
||||
lifecycle_socket(run / 'spice.sock')
|
||||
with QMPClient(run / 'qmp.sock', timeout=2) as client:
|
||||
if (client.command('query-name') != {'name': context['name']}
|
||||
or client.command('query-uuid') != {'UUID': context['uuid']}):
|
||||
raise SmokeError('Terminal UX QMP belongs to a different VM')
|
||||
if client.command('query-status').get('status') != 'running':
|
||||
raise SmokeError('Terminal UX VM is not running')
|
||||
if not client.command('query-kvm').get('enabled'):
|
||||
raise SmokeError('Terminal UX VM did not initialise nested KVM')
|
||||
return {'name': context['name'], 'uuid': context['uuid'], 'pid': pid,
|
||||
'process_start': fields[19], 'kvm': True}
|
||||
except (OSError, ValueError, KeyError, IndexError, QMPError) as exc:
|
||||
raise SmokeError('Terminal UX cannot establish owned live identity: ' + str(exc)) from exc
|
||||
|
||||
|
||||
TERMINAL_UX_BODY = r'''
|
||||
if os.getuid() == 0 or subprocess.check_output(['id', '-un'], text=True).strip() != 'hydratest':
|
||||
raise SmokeError('Terminal UX probe must run as the installed normal hydratest user')
|
||||
if not os.access('/dev/kvm', os.R_OK | os.W_OK):
|
||||
raise SmokeError('Terminal UX needs installed-user nested KVM access')
|
||||
if not Path('/usr/bin/hydra').is_file():
|
||||
raise SmokeError('Installed product launcher /usr/bin/hydra is missing')
|
||||
fetch = subprocess.run(['fastfetch', '--pipe', 'false', '--show-errors'],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
if fetch.returncode:
|
||||
raise SmokeError('Installed default Fastfetch failed: ' + fetch.stderr[-1500:])
|
||||
fastfetch_output = verify_terminal_ux_branding(
|
||||
fetch.stdout, fetch.stderr, Path('/usr/share/hydra/fastfetch.txt').read_text())
|
||||
config_hash = hashlib.sha256(Path('/etc/fastfetch/config.jsonc').read_bytes()).hexdigest()
|
||||
logo_hash = hashlib.sha256(Path('/usr/share/hydra/fastfetch.txt').read_bytes()).hexdigest()
|
||||
base = Path(tempfile.mkdtemp(prefix='hux-', dir='/tmp'))
|
||||
name = 'ux-' + secrets.token_hex(4)
|
||||
context = {'name': name, 'uid': os.getuid(), 'config': str(base / 'config'),
|
||||
'state': str(base / 'state'), 'runtime': str(base / 'run')}
|
||||
context['uuid'] = str(uuid.uuid5(uuid.NAMESPACE_URL, str(base / 'state' / name / 'disk.qcow2')))
|
||||
environment = dict(os.environ, XDG_CACHE_HOME=str(base / 'cache'))
|
||||
environment.pop('HYDRA_SESSION_MANIFEST', None)
|
||||
command = ['/usr/bin/hydra', '--config', context['config'], '--state', context['state'],
|
||||
'--runtime', context['runtime']]
|
||||
|
||||
def cli(*arguments, input=None):
|
||||
result = subprocess.run(command + list(arguments), input=input, env=environment,
|
||||
capture_output=True, text=True, timeout=60)
|
||||
if result.returncode:
|
||||
raise SmokeError('Installed hydra ' + arguments[0] + ' failed: ' +
|
||||
(result.stdout + result.stderr)[-2000:])
|
||||
return result.stdout
|
||||
|
||||
def cli_json(*arguments):
|
||||
return json.loads(cli(*arguments))
|
||||
|
||||
fixture = base / 'fixture.iso'
|
||||
fixture.write_bytes(terminal_ux_iso())
|
||||
iso_hash = hashlib.sha256(fixture.read_bytes()).hexdigest()
|
||||
requests = []
|
||||
class ISOHandler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
requests.append(self.path)
|
||||
if self.path != '/fixture.iso':
|
||||
self.send_error(404)
|
||||
return
|
||||
payload = fixture.read_bytes()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/x-iso9660-image')
|
||||
self.send_header('Content-Length', str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), ISOHandler)
|
||||
worker = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
worker.start()
|
||||
url = 'http://127.0.0.1:' + str(server.server_port) + '/fixture.iso'
|
||||
result = None
|
||||
try:
|
||||
if cli_json('list'):
|
||||
raise SmokeError('Terminal UX private configuration was not initially empty')
|
||||
empty_menu = cli('menu', input=':quit\n')
|
||||
if ('Hydra OS' not in empty_menu or 'No VM profiles' not in empty_menu
|
||||
or list(base.glob('run/*/qemu.pid')) or cli_json('list')):
|
||||
raise SmokeError('Installed empty menu was unreadable or started a VM')
|
||||
run_output = cli('run', url, '--name', name, '--memory', '512', '--cpus', '1',
|
||||
'--disk-size', '64M', '--sha256', iso_hash, '--no-view')
|
||||
if requests != ['/fixture.iso']:
|
||||
raise SmokeError('Installed hydra run did not download exactly its loopback ISO')
|
||||
before = terminal_ux_snapshot(context)
|
||||
profiles = cli_json('list')
|
||||
if len(profiles) != 1 or profiles[0]['name'] != name:
|
||||
raise SmokeError('Installed hydra run did not create exactly the requested private profile')
|
||||
cached_iso = Path(profiles[0]['iso'])
|
||||
cache = base / 'cache' / 'hydra' / 'iso'
|
||||
info = cached_iso.lstat()
|
||||
if (cached_iso.parent != cache or not stat.S_ISREG(info.st_mode)
|
||||
or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600
|
||||
or hashlib.sha256(cached_iso.read_bytes()).hexdigest() != iso_hash
|
||||
or list(cache.glob('*.part'))):
|
||||
raise SmokeError('Installed hydra run cache is unsafe, incomplete or has a different checksum')
|
||||
profile = tomllib.loads((base / 'config' / (name + '.toml')).read_text())
|
||||
if (profile['memory'], profile['cpus'], profile['disk_size']) != (512, 1, '64M'):
|
||||
raise SmokeError('Installed hydra run lost its explicit allocations')
|
||||
downloaded = cli('download', url, '--sha256', iso_hash)
|
||||
if (Path(downloaded.splitlines()[-1]) != cached_iso or requests != ['/fixture.iso']
|
||||
or hashlib.sha256(cached_iso.read_bytes()).hexdigest() != iso_hash):
|
||||
raise SmokeError('Installed hydra download did not reuse the complete cached ISO')
|
||||
cli('open', name, '--no-view')
|
||||
after_open = terminal_ux_snapshot(context)
|
||||
if before != after_open:
|
||||
raise SmokeError('Installed hydra open restarted or replaced its running VM')
|
||||
menu = cli('menu', input=':quit\n')
|
||||
after_menu = terminal_ux_snapshot(context)
|
||||
if ('Hydra OS' not in menu or name not in menu or 'running' not in menu
|
||||
or '512 MiB' not in menu or '"status":' in menu or before != after_menu):
|
||||
raise SmokeError('Installed running menu was unreadable or changed VM lifetime')
|
||||
result = {'status': 'passed', 'uid': os.getuid(), 'normal_user': 'hydratest',
|
||||
'launcher': '/usr/bin/hydra', 'fixture_bytes': fixture.stat().st_size,
|
||||
'iso_sha256': iso_hash, 'cache_reused': True, 'http_requests': requests,
|
||||
'before': before, 'after_open': after_open, 'after_menu': after_menu,
|
||||
'memory_mib': profile['memory'], 'cpus': profile['cpus'],
|
||||
'disk_size': profile['disk_size'], 'network': profile['network'],
|
||||
'empty_menu': empty_menu, 'running_menu': menu, 'run_output': run_output,
|
||||
'download_output': downloaded, 'fastfetch_command': ['fastfetch', '--pipe', 'false', '--show-errors'],
|
||||
'fastfetch_default_config_sha256': config_hash, 'fastfetch_logo_sha256': logo_hash,
|
||||
'fastfetch_output': fastfetch_output,
|
||||
'scope': 'Packaged installed CLI, cache, branding and live nested QMP/KVM; nonboot ISO fixture, no nested OS boot or performance claim'}
|
||||
finally:
|
||||
try:
|
||||
status = cli_json('status', name).get('status')
|
||||
if status == 'running':
|
||||
# Never signal a saved PID: verify the exact private live identity
|
||||
# and let the product CLI perform its own identity-checked QMP quit.
|
||||
terminal_ux_snapshot(context)
|
||||
if cli_json('stop', name, '--force').get('status') != 'stopped':
|
||||
raise SmokeError('Owned terminal UX fixture VM did not stop')
|
||||
elif status != 'stopped':
|
||||
raise SmokeError('Refusing terminal UX cleanup of an unverified VM identity at ' + str(base))
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
worker.join(timeout=5)
|
||||
if result is not None:
|
||||
result['cleanup'] = 'Only the verified private fixture VM stopped through hydra stop --force'
|
||||
print(json.dumps(result))
|
||||
shutil.rmtree(base)
|
||||
'''
|
||||
|
||||
|
||||
def terminal_ux_guest_script():
|
||||
helpers = (lifecycle_socket, terminal_ux_iso, verify_terminal_ux_branding, terminal_ux_snapshot)
|
||||
return ('import hashlib, http.server, json, os, re, secrets, shutil, socket, stat, subprocess, sys, tempfile, threading, tomllib, uuid\n'
|
||||
'from pathlib import Path\nsys.path.insert(0, "/usr/lib/hydra")\n'
|
||||
'from hydra.qmp import QMPClient, QMPError\n'
|
||||
'class SmokeError(RuntimeError): pass\n' +
|
||||
'\n'.join(inspect.getsource(helper) for helper in helpers) + TERMINAL_UX_BODY)
|
||||
|
||||
|
||||
def installed_terminal_ux_probe(console):
|
||||
preparation = ("python3 - <<'HYDRA_TERMINAL_UX_SOURCE'\n" + terminal_ux_guest_script() +
|
||||
'\nHYDRA_TERMINAL_UX_SOURCE')
|
||||
output = console.command(preparation, 180)
|
||||
try:
|
||||
result = json.loads(output)
|
||||
except ValueError as exc:
|
||||
raise SmokeError('Installed terminal UX did not return framed JSON evidence') from exc
|
||||
if result.get('status') != 'passed':
|
||||
raise SmokeError('Installed terminal UX did not verify its packaged CLI')
|
||||
return output
|
||||
|
||||
|
||||
def installed_admin(console, password):
|
||||
console.buffer = b''
|
||||
prompt_encoded = base64.b64encode(b'HYDRA_SUDO_PASSWORD: ').decode()
|
||||
@@ -376,7 +635,7 @@ def qemu_path(path):
|
||||
return str(path)
|
||||
|
||||
|
||||
def qemu_argv(qemu, scratch, disk, code, variables, iso, memory):
|
||||
def qemu_argv(qemu, scratch, disk, code, variables, iso, memory, *, keep_cdrom_drive=False):
|
||||
scratch = Path(scratch)
|
||||
owned_file(disk, scratch)
|
||||
owned_file(variables, scratch)
|
||||
@@ -391,8 +650,18 @@ def qemu_argv(qemu, scratch, disk, code, variables, iso, memory):
|
||||
if iso is not None:
|
||||
if not Path(iso).is_file():
|
||||
raise SmokeError('Trusted ISO must exist as a regular file')
|
||||
argv += ['-drive', f'file={qemu_path(iso)},media=cdrom,format=raw,readonly=on',
|
||||
'-boot', 'order=d,strict=on']
|
||||
if keep_cdrom_drive:
|
||||
# Keep SATA0 stable so a saved DVD boot option has the same device path
|
||||
# when media returns. OVMF may still prune the empty drive's boot entry.
|
||||
# No file= means there is no optical medium.
|
||||
optical = 'if=ide,index=0,id=acceptance-cdrom,media=cdrom,readonly=on'
|
||||
if iso is not None:
|
||||
optical += ',format=raw,file=' + qemu_path(iso)
|
||||
argv += ['-drive', optical]
|
||||
elif iso is not None:
|
||||
argv += ['-drive', f'file={qemu_path(iso)},media=cdrom,format=raw,readonly=on']
|
||||
if iso is not None:
|
||||
argv += ['-boot', 'order=d,strict=on']
|
||||
else:
|
||||
argv += ['-boot', 'order=c,strict=on']
|
||||
return argv
|
||||
@@ -557,7 +826,8 @@ class VM:
|
||||
(scratch / name).unlink(missing_ok=True)
|
||||
self.stderr = open(scratch / f'{phase}-qemu.log', 'ab', buffering=0)
|
||||
self.process = subprocess.Popen(qemu_argv(args.qemu, scratch, disk,
|
||||
args.ovmf_code, scratch / 'OVMF_VARS.fd', iso, args.memory),
|
||||
args.ovmf_code, scratch / 'OVMF_VARS.fd', iso, args.memory,
|
||||
keep_cdrom_drive=getattr(args, 'keep_cdrom_drive', False)),
|
||||
stdin=subprocess.DEVNULL, stdout=self.stderr, stderr=self.stderr)
|
||||
self.console = None
|
||||
self.qmp = None
|
||||
@@ -875,6 +1145,7 @@ def main(argv=None):
|
||||
if output.strip() != b'hydratest':
|
||||
raise SmokeError('Installed account identity mismatch')
|
||||
checkpoint('installed_password_login', output)
|
||||
checkpoint('installed_terminal_ux', installed_terminal_ux_probe(console))
|
||||
console.buffer = b''
|
||||
prompt_encoded = base64.b64encode(b'HYDRA_SUDO_PASSWORD: ').decode()
|
||||
console.send(f'sudo -p "$(printf %s {prompt_encoded} | base64 -d)" -i\n')
|
||||
|
||||
Executable
+632
@@ -0,0 +1,632 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real laptop acceptance in a NEW disposable UEFI/KVM VM; retains scratch evidence.
|
||||
|
||||
Only ISO/firmware inputs are accepted. Never attach host devices or existing VM
|
||||
images. A private HTTP server exposes exact TEST artifact bytes, never a directory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import hashlib
|
||||
import http.server
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import shlex
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
_spec = importlib.util.spec_from_file_location('hydra_iso_smoke', _ROOT / 'dev/iso-smoke.py')
|
||||
iso_smoke = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(iso_smoke)
|
||||
SmokeError = iso_smoke.SmokeError
|
||||
|
||||
|
||||
def argument_parser():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
for option in ('iso', 'ovmf-code', 'ovmf-vars'):
|
||||
parser.add_argument('--' + option, required=True, type=Path)
|
||||
parser.add_argument('--qemu', default='qemu-system-x86_64')
|
||||
parser.add_argument('--qemu-img', default='qemu-img')
|
||||
parser.add_argument('--scratch-parent', type=Path, default=Path(tempfile.gettempdir()))
|
||||
parser.add_argument('--memory', type=int, default=4096)
|
||||
parser.add_argument('--boot-timeout', type=int, default=360)
|
||||
parser.add_argument('--install-timeout', type=int, default=1800)
|
||||
return parser
|
||||
|
||||
|
||||
def verify_preserved(before, after, *, firmware_reboot=False):
|
||||
fields = ('gpt', 'foreign_sha256', 'foreign_efi')
|
||||
if not firmware_reboot:
|
||||
fields += ('boot_order',)
|
||||
for name in fields:
|
||||
if before[name] != after[name]:
|
||||
raise SmokeError('Preserved installation changed ' + name)
|
||||
|
||||
|
||||
def partition_installer_command(token):
|
||||
if not re.fullmatch('[a-zA-Z0-9]+', token):
|
||||
raise SmokeError('Invalid installer token')
|
||||
return ('sudo -n hydra-install --root-partition /dev/vda3 --esp-partition /dev/vda1 '
|
||||
'--root-fs btrfs --user hydratest --execute --confirm-format /dev/vda3 '
|
||||
'--register-boot-entry; rc=$?; '
|
||||
f"printf '\\036{token}:INSTALL_EXIT:%s\\037' \"$rc\"")
|
||||
|
||||
|
||||
def artifact_server(files):
|
||||
payload = dict(files)
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
data = payload.get(self.path)
|
||||
if data is None:
|
||||
self.send_error(404)
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/octet-stream')
|
||||
self.send_header('Content-Length', str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
return http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
||||
|
||||
|
||||
def test_artifacts(scratch, runtime, commit):
|
||||
"""Generate disposable TEST trust; never include private bytes in the share."""
|
||||
private = scratch / 'TEST-private.pem'
|
||||
public = scratch / 'public.pem'
|
||||
subprocess.run(['openssl', 'genpkey', '-algorithm', 'ED25519', '-out', str(private)],
|
||||
check=True, capture_output=True, timeout=15)
|
||||
private.chmod(0o600)
|
||||
shared = {}
|
||||
try:
|
||||
subprocess.run(['openssl', 'pkey', '-in', str(private), '-pubout', '-out', str(public)],
|
||||
check=True, capture_output=True, timeout=15)
|
||||
shared['/public.pem'] = public.read_bytes()
|
||||
for sequence, name in ((1, 'one'), (2, 'two')):
|
||||
path = 'usr/lib/hydra/hydra/cli.py'
|
||||
content = runtime + ('\n# Disposable laptop acceptance TEST sequence ' + str(sequence) + '\n').encode()
|
||||
stream = io.BytesIO()
|
||||
with zipfile.ZipFile(stream, 'w', zipfile.ZIP_DEFLATED) as archive:
|
||||
info = zipfile.ZipInfo(path, (1980, 1, 1, 0, 0, 0))
|
||||
info.create_system = 3
|
||||
info.external_attr = (stat.S_IFREG | 0o644) << 16
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
archive.writestr(info, content)
|
||||
payload = stream.getvalue()
|
||||
digest = lambda data: hashlib.sha256(data).hexdigest()
|
||||
manifest = {'format': 1, 'product': 'Hydra OS', 'scope': 'runtime', 'channel': 'stable',
|
||||
'architecture': 'x86_64', 'sequence': sequence, 'version': '0.0.0-test-' + str(sequence),
|
||||
'source_commit': commit, 'archive': {'name': 'runtime.zip', 'size': len(payload),
|
||||
'sha256': digest(payload)}, 'files': {path: {'size': len(content), 'sha256': digest(content), 'mode': 0o644}}}
|
||||
raw = (json.dumps(manifest, sort_keys=True, separators=(',', ':')) + '\n').encode()
|
||||
input_path, signature = scratch / 'TEST-manifest.json', scratch / 'TEST-manifest.sig'
|
||||
input_path.write_bytes(raw)
|
||||
subprocess.run(['openssl', 'pkeyutl', '-sign', '-rawin', '-inkey', str(private),
|
||||
'-in', str(input_path), '-out', str(signature)], check=True, capture_output=True, timeout=15)
|
||||
for file, data in (('manifest.json', raw), ('manifest.sig', signature.read_bytes()), ('runtime.zip', payload)):
|
||||
shared['/' + name + '/' + file] = data
|
||||
input_path.unlink(); signature.unlink()
|
||||
finally:
|
||||
private.unlink(missing_ok=True)
|
||||
return shared
|
||||
|
||||
|
||||
GUEST_COMMON = r'''
|
||||
import contextlib, hashlib, json, os, re, runpy, shutil, signal, subprocess, sys, time, urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
def run(*args, timeout=120):
|
||||
p = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
|
||||
if p.returncode:
|
||||
raise RuntimeError('command failed: ' + repr(args) + '\n' + p.stdout + p.stderr)
|
||||
return p.stdout
|
||||
|
||||
def sha(path):
|
||||
h = hashlib.sha256()
|
||||
with open(path, 'rb') as source:
|
||||
for block in iter(lambda: source.read(1024 * 1024), b''): h.update(block)
|
||||
return h.hexdigest()
|
||||
|
||||
def tree(path):
|
||||
return {str(p.relative_to(path)): sha(p) for p in sorted(path.rglob('*')) if p.is_file()}
|
||||
|
||||
EFI_GUID = '8be4df61-93ca-11d2-aa0d-00e098032b8c'
|
||||
DVD_LABEL = 'UEFI QEMU DVD-ROM QM00001'
|
||||
|
||||
def efi_description(data):
|
||||
# The file starts with efivarfs attributes, EFI_LOAD_OPTION attributes and
|
||||
# FilePathListLength, followed by a bounded UTF-16 description.
|
||||
for end in range(10, min(len(data) - 1, 522), 2):
|
||||
if data[end:end + 2] == b'\0\0':
|
||||
try: return data[10:end].decode('utf-16-le').strip(), end + 2
|
||||
except UnicodeDecodeError: break
|
||||
return None, None
|
||||
|
||||
def validate_dvd_variable(data):
|
||||
label, offset = efi_description(data)
|
||||
path = bytes.fromhex('02010c00d041030a0000000001010600021f03120a000000ffff00007fff0400')
|
||||
automatic = bytes.fromhex('4eac0881119f594d850ee21a522c59b2')
|
||||
if (len(data) > 512 or data[:10] != bytes.fromhex('07000000010000002000')
|
||||
or label != DVD_LABEL or data[offset:offset + 32] != path
|
||||
or data[offset + 32:] not in (b'', automatic)):
|
||||
raise RuntimeError('Invalid saved SATA0 DVD EFI load option')
|
||||
|
||||
def dvd_variables(variables):
|
||||
matches = []
|
||||
for path in variables.glob('Boot????-' + EFI_GUID):
|
||||
if not re.fullmatch('Boot[0-9A-Fa-f]{4}-' + EFI_GUID, path.name): continue
|
||||
data = path.read_bytes()
|
||||
if efi_description(data)[0] == DVD_LABEL:
|
||||
validate_dvd_variable(data)
|
||||
matches.append((path.name[4:8], data))
|
||||
if len(matches) > 1: raise RuntimeError('Ambiguous DVD EFI load options')
|
||||
return matches
|
||||
|
||||
def capture_dvd_variable(variables):
|
||||
matches = dvd_variables(variables)
|
||||
if len(matches) != 1: raise RuntimeError('Expected one active SATA0 DVD EFI load option')
|
||||
entry, data = matches[0]
|
||||
current = (variables / ('BootCurrent-' + EFI_GUID)).read_bytes()
|
||||
if len(current) != 6 or int.from_bytes(current[4:], 'little') != int(entry, 16):
|
||||
raise RuntimeError('Initial live system did not boot from the captured DVD entry')
|
||||
return {'entry': entry, 'raw_hex': data.hex(), 'sha256': hashlib.sha256(data).hexdigest()}
|
||||
|
||||
def restore_dvd_variable(variables, data):
|
||||
validate_dvd_variable(data)
|
||||
order = variables / ('BootOrder-' + EFI_GUID)
|
||||
before = order.read_bytes()
|
||||
matches = dvd_variables(variables)
|
||||
created = not matches
|
||||
if matches:
|
||||
entry, existing = matches[0]
|
||||
if existing != data: raise RuntimeError('Existing DVD option differs from captured bytes')
|
||||
else:
|
||||
occupied = {int(p.name[4:8], 16) for p in variables.glob('Boot????-' + EFI_GUID)
|
||||
if re.fullmatch('Boot[0-9A-Fa-f]{4}-' + EFI_GUID, p.name)}
|
||||
free = next((number for number in range(65536) if number not in occupied), None)
|
||||
if free is None: raise RuntimeError('No unused UEFI boot variable')
|
||||
entry = f'{free:04X}'
|
||||
path = variables / ('Boot' + entry + '-' + EFI_GUID)
|
||||
# Never overwrite an existing entry, including a concurrently created one.
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, 'wb', buffering=0) as target:
|
||||
if target.write(data) != len(data): raise RuntimeError('Short EFI variable write')
|
||||
if path.read_bytes() != data: raise RuntimeError('Saved DVD variable readback mismatch')
|
||||
if order.read_bytes() != before: raise RuntimeError('DVD variable creation changed BootOrder')
|
||||
return {'entry': entry, 'created': created, 'sha256': hashlib.sha256(data).hexdigest(),
|
||||
'boot_order_sha256': hashlib.sha256(before).hexdigest()}
|
||||
|
||||
@contextlib.contextmanager
|
||||
def mounted(device, filesystem, options=''):
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory(prefix='hydra-acceptance-') as directory:
|
||||
args = ['mount', '-t', filesystem] + (['-o', options] if options else []) + [device, directory]
|
||||
run(*args)
|
||||
try: yield Path(directory)
|
||||
finally: run('umount', directory)
|
||||
|
||||
def installed_esp(marker):
|
||||
mounts = json.loads(run('findmnt', '--json', '--mountpoint', '/boot/efi', '--output',
|
||||
'SOURCE,TARGET,FSTYPE,UUID,MAJ:MIN'))['filesystems']
|
||||
devices = json.loads(run('lsblk', '--json', '--nodeps', '--output',
|
||||
'NAME,MAJ:MIN,FSTYPE,UUID', '/dev/vda1'))['blockdevices']
|
||||
if len(mounts) != 1 or len(devices) != 1:
|
||||
raise RuntimeError('Expected one mounted installed ESP and one test ESP device')
|
||||
mount, device = mounts[0], devices[0]
|
||||
if (mount.get('source') != '/dev/vda1' or mount.get('target') != '/boot/efi'
|
||||
or mount.get('fstype') != 'vfat' or device.get('name') != 'vda1'
|
||||
or device.get('fstype') != 'vfat' or not device.get('maj:min')
|
||||
or mount.get('maj:min') != device['maj:min'] or not device.get('uuid')
|
||||
or mount.get('uuid') != device['uuid'] or marker.get('esp_uuid') != device['uuid']):
|
||||
raise RuntimeError('Installed ESP mount/device identity does not match the storage marker')
|
||||
return Path('/boot/efi')
|
||||
|
||||
@contextlib.contextmanager
|
||||
def readonly_esp():
|
||||
if Path('/etc/hydra/live-mode/active').exists():
|
||||
with mounted('/dev/vda1', 'vfat', 'ro') as esp: yield esp
|
||||
else:
|
||||
# The installed ESP is already mounted rw by fstab. Verify it, then only
|
||||
# read hashes; a second ro mount would conflict with its superblock state.
|
||||
yield installed_esp(json.loads(Path('/etc/hydra/storage.json').read_text()))
|
||||
|
||||
def preserved():
|
||||
table = json.loads(run('sfdisk', '--json', '/dev/vda'))['partitiontable']
|
||||
# No filesystem UUIDs: selected root UUID must change on formatting.
|
||||
with readonly_esp() as esp:
|
||||
foreign = {k: v for k, v in tree(esp / 'EFI').items() if not k.startswith('HydraOS/')}
|
||||
order = re.search(r'^BootOrder:\s*(.*)$', run('efibootmgr'), re.M)
|
||||
assert order, 'UEFI BootOrder was not observable'
|
||||
return {'gpt': table, 'foreign_sha256': sha('/dev/vda2'), 'foreign_efi': foreign,
|
||||
'boot_order': order.group(1) if order else None}
|
||||
|
||||
def base_state(root, esp):
|
||||
pkg = root / 'var/db/pkg/app-misc'
|
||||
records = {p.name: tree(p) for p in sorted(pkg.glob('hydra-acceptance-*')) if p.is_dir()}
|
||||
return {'boot': {k: v for k, v in tree(root / 'boot').items() if not k.startswith('efi/')}, 'portage_fixture': records,
|
||||
'world': sha(root / 'var/lib/portage/world'), 'owned_efi': tree(esp / 'EFI/HydraOS'),
|
||||
'fixture': (root / 'usr/share/hydra/base-fixture.txt').read_text()}
|
||||
|
||||
def fixture(version):
|
||||
repo = Path('/var/lib/hydra-acceptance-repo')
|
||||
(repo / 'profiles').mkdir(parents=True, exist_ok=True)
|
||||
(repo / 'metadata').mkdir(exist_ok=True)
|
||||
(repo / 'profiles/repo_name').write_text('hydra-acceptance\n')
|
||||
(repo / 'metadata/layout.conf').write_text('masters =\nauto-sync = false\nthin-manifests = true\n')
|
||||
conf = Path('/etc/portage/repos.conf')
|
||||
conf.mkdir(exist_ok=True)
|
||||
(conf / 'hydra-acceptance.conf').write_text('[hydra-acceptance]\nlocation = ' + str(repo) + '\nauto-sync = no\n')
|
||||
package = repo / 'app-misc/hydra-acceptance'
|
||||
package.mkdir(parents=True, exist_ok=True)
|
||||
ebuild = package / ('hydra-acceptance-' + version + '.ebuild')
|
||||
ebuild.write_text('EAPI=8\nDESCRIPTION="Offline recovery acceptance fixture"\nHOMEPAGE="https://example.invalid"\nLICENSE="MIT"\nSLOT="0"\nKEYWORDS="amd64"\nS="${WORKDIR}"\nsrc_install() {\n insinto /usr/share/hydra\n printf "%s\\n" "${PV}" > base-fixture.txt\n doins base-fixture.txt\n}\n')
|
||||
run('unshare', '--net', '--', 'ebuild', str(ebuild), 'manifest')
|
||||
output = run('unshare', '--net', '--', 'emerge', '--ignore-default-opts', '--nodeps',
|
||||
'--autounmask=n', '--usepkg=n', '--buildpkg=n', '--getbinpkg=n',
|
||||
'=app-misc/hydra-acceptance-' + version, timeout=600)
|
||||
assert Path('/usr/share/hydra/base-fixture.txt').read_text().strip() == version
|
||||
assert Path('/var/db/pkg/app-misc/hydra-acceptance-' + version).is_dir()
|
||||
return output
|
||||
|
||||
def stop_child(source):
|
||||
ready = Path('/run/hydra-acceptance-child-ready')
|
||||
ready.unlink(missing_ok=True)
|
||||
process = subprocess.Popen([sys.executable, '-I', '-c', source], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
try:
|
||||
deadline = time.monotonic() + 120
|
||||
while not ready.exists() and process.poll() is None and time.monotonic() < deadline: time.sleep(.05)
|
||||
if not ready.exists():
|
||||
raise RuntimeError('child never reached durable mutation: ' + repr(process.communicate(timeout=5)))
|
||||
process.kill()
|
||||
process.communicate(timeout=10)
|
||||
assert process.returncode == -signal.SIGKILL
|
||||
finally:
|
||||
if process.poll() is None: process.kill(); process.communicate(timeout=10)
|
||||
ready.unlink(missing_ok=True)
|
||||
'''
|
||||
|
||||
GUEST_BODIES = {
|
||||
'capture-live-boot': "result = capture_dvd_variable(Path('/sys/firmware/efi/efivars'))\n",
|
||||
'restore-live-boot': "result = restore_dvd_variable(Path('/sys/firmware/efi/efivars'), bytes.fromhex(context['dvd_variable']))\n",
|
||||
'seed': r'''
|
||||
# Guest-only target is the new empty disk attached by this harness.
|
||||
assert os.geteuid() == 0 and Path('/etc/hydra/live-mode/active').exists()
|
||||
assert not Path('/sys/class/block/vda/partition').exists()
|
||||
assert json.loads(run('lsblk', '-J', '-o', 'NAME,TYPE,MOUNTPOINT', '/dev/vda'))['blockdevices'][0].get('children') is None
|
||||
run('parted', '-s', '/dev/vda', 'mklabel', 'gpt', 'mkpart', 'ESP', 'fat32', '1MiB', '513MiB',
|
||||
'set', '1', 'esp', 'on', 'mkpart', 'foreign', '513MiB', '577MiB', 'mkpart', 'HydraRoot', '577MiB', '100%')
|
||||
run('udevadm', 'settle')
|
||||
run('mkfs.vfat', '-F', '32', '/dev/vda1')
|
||||
with open('/dev/vda2', 'r+b', buffering=0) as foreign:
|
||||
foreign.write((context['sentinel'] * 1024).encode()); os.fsync(foreign.fileno())
|
||||
with mounted('/dev/vda1', 'vfat') as esp:
|
||||
for name in ('EFI/ForeignOS/loader.efi', 'EFI/BOOT/BOOTX64.EFI'):
|
||||
path = esp / name; path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(context['sentinel'] + ':' + name)
|
||||
run('sync')
|
||||
result = preserved()
|
||||
''',
|
||||
'preserved': 'result = preserved()\n',
|
||||
'baseline': r'''
|
||||
assert not Path('/etc/hydra/live-mode/active').exists()
|
||||
output = fixture('1.0')
|
||||
home = Path('/home/hydratest')
|
||||
(home / 'acceptance-home').write_text('before-' + context['sentinel'])
|
||||
vm = home / '.local/share/hydra/acceptance-guest'
|
||||
vm.mkdir(parents=True, exist_ok=True)
|
||||
run('qemu-img', 'create', '-f', 'qcow2', str(vm / 'disk.qcow2'), '64M')
|
||||
(vm / 'guest-data-sentinel').write_text('VM-DATA-' + context['sentinel'])
|
||||
(vm / 'profile.json').write_text('{"fixture":"preserve"}\n')
|
||||
run('chown', '-R', 'hydratest:hydratest', str(vm))
|
||||
run('chown', 'hydratest:hydratest', str(home / 'acceptance-home'))
|
||||
result = {'fixture_emerge': output, 'vm_data': tree(vm), 'doctor': json.loads(run('hydra', 'doctor', '--json'))}
|
||||
''',
|
||||
'upgrade': r'''
|
||||
output = fixture('2.0')
|
||||
Path('/home/hydratest/acceptance-home').write_text('current-' + context['sentinel'])
|
||||
with Path('/var/lib/portage/world').open('a') as world: world.write('# acceptance post-checkpoint world\n')
|
||||
result = {'fixture_emerge': output, 'home': Path('/home/hydratest/acceptance-home').read_text()}
|
||||
''',
|
||||
'base-state': r'''
|
||||
with mounted('/dev/vda3', 'btrfs', 'ro,subvol=@root') as root:
|
||||
with mounted('/dev/vda1', 'vfat', 'ro') as esp:
|
||||
result = base_state(root, esp)
|
||||
''',
|
||||
'damage': r'''
|
||||
with mounted('/dev/vda3', 'btrfs', 'subvol=@root') as root:
|
||||
kernels = list((root / 'boot').glob('vmlinuz*'))
|
||||
assert kernels, 'No actual installed kernel to damage'
|
||||
for kernel in kernels: kernel.write_bytes(b'DELIBERATE ACCEPTANCE KERNEL DAMAGE\n')
|
||||
with mounted('/dev/vda1', 'vfat') as esp:
|
||||
loader = esp / 'EFI/HydraOS/grubx64.efi'
|
||||
assert loader.is_file(); loader.write_bytes(b'DELIBERATE ACCEPTANCE EFI DAMAGE\n')
|
||||
run('sync')
|
||||
result = {'damaged_kernels': [p.name for p in kernels], 'damaged_owned_loader': 'EFI/HydraOS/grubx64.efi'}
|
||||
''',
|
||||
'restored': r'''
|
||||
marker = json.loads(Path('/etc/hydra/storage.json').read_text())
|
||||
assert marker['root_subvolume'] == '@root'
|
||||
assert Path('/home/hydratest/acceptance-home').read_text() == 'current-' + context['sentinel']
|
||||
assert not Path('/var/db/pkg/app-misc/hydra-acceptance-2.0').exists()
|
||||
assert Path('/usr/share/hydra/base-fixture.txt').read_text().strip() == '1.0'
|
||||
with readonly_esp() as esp: result = base_state(Path('/'), esp)
|
||||
result['home'] = Path('/home/hydratest/acceptance-home').read_text()
|
||||
result['vm_data'] = tree(Path('/home/hydratest/.local/share/hydra/acceptance-guest'))
|
||||
result['root_mount'] = run('findmnt', '-n', '-o', 'SOURCE,OPTIONS', '/')
|
||||
''',
|
||||
'runtime': r'''
|
||||
base = Path('/var/lib/hydra-acceptance-runtime')
|
||||
base.mkdir(mode=0o700)
|
||||
url = 'http://10.0.2.2:' + str(context['port'])
|
||||
def download(path):
|
||||
with urllib.request.urlopen(url + path, timeout=15) as response: return response.read(16 * 1024 * 1024)
|
||||
for name in ('one', 'two'):
|
||||
directory = base / name; directory.mkdir()
|
||||
for file in ('manifest.json', 'manifest.sig', 'runtime.zip'): (directory / file).write_bytes(download('/' + name + '/' + file))
|
||||
target = Path('/usr/lib/hydra/hydra/cli.py')
|
||||
original = target.read_bytes()
|
||||
key = Path('/etc/hydra/update.pub'); production_key = key.read_bytes()
|
||||
vm = Path('/home/hydratest/.local/share/hydra/acceptance-guest'); original_vm = tree(vm)
|
||||
key.write_bytes(download('/public.pem')); key.chmod(0o644)
|
||||
try:
|
||||
checked = json.loads(run('hydra', 'update', 'check', '--artifacts', str(base / 'one')))
|
||||
assert checked['available']['sequence'] == 1
|
||||
applied = json.loads(run('hydra', 'update', 'apply', '--artifacts', str(base / 'one')))
|
||||
assert target.read_bytes() != original
|
||||
rollback = json.loads(run('/usr/bin/hydra-update', 'rollback', applied['transaction']))
|
||||
assert target.read_bytes() == original
|
||||
tamper = base / 'tampered'; shutil.copytree(base / 'two', tamper)
|
||||
with (tamper / 'runtime.zip').open('ab') as file: file.write(b'TAMPER')
|
||||
prior_status = run('/usr/bin/hydra-update', 'status')
|
||||
refused = subprocess.run(['/usr/bin/hydra-update', 'apply', '--artifacts', str(tamper)], capture_output=True, text=True)
|
||||
assert refused.returncode != 0 and target.read_bytes() == original
|
||||
assert run('/usr/bin/hydra-update', 'status') == prior_status
|
||||
source = """import os, runpy, signal
|
||||
from pathlib import Path
|
||||
m = runpy.run_path('/usr/lib/hydra-update/engine.py')
|
||||
write = m['atomic_write']
|
||||
def pause(path, *args, **kwargs):
|
||||
write(path, *args, **kwargs)
|
||||
if path == Path('/usr/lib/hydra/hydra/cli.py'):
|
||||
Path('/run/hydra-acceptance-child-ready').touch()
|
||||
os.kill(os.getpid(), signal.SIGSTOP)
|
||||
m['Updater'].apply.__globals__['atomic_write'] = pause
|
||||
m['Updater']().apply(Path(ARTIFACTS))
|
||||
""".replace('ARTIFACTS', repr(str(base / 'two')))
|
||||
stop_child(source)
|
||||
assert json.loads(run('/usr/bin/hydra-update', 'status'))['pending']
|
||||
target.write_bytes(b'DELIBERATELY INVALID PYTHON !!!\n')
|
||||
recovered = json.loads(run('/usr/bin/hydra-update-recover'))
|
||||
assert target.read_bytes() == original
|
||||
status = json.loads(run('/usr/bin/hydra-update', 'status'))
|
||||
assert not status['pending'] and status['highest_sequence'] == 2
|
||||
assert tree(vm) == original_vm
|
||||
run('hydra', 'doctor', '--json')
|
||||
result = {'signed_check': checked, 'apply': applied, 'rollback': rollback,
|
||||
'tamper_refusal': refused.stderr, 'fresh_process_recovery': recovered,
|
||||
'status': status, 'unchanged_vm_data': original_vm, 'trust': 'disposable TEST key only'}
|
||||
finally:
|
||||
key.write_bytes(production_key); key.chmod(0o644)
|
||||
''',
|
||||
'restore-interrupt': r'''
|
||||
# Instrument a real live CLI process, after its durable journal and real rename.
|
||||
# No fake filesystems or production fault hooks are involved.
|
||||
source = """import os, signal, sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, '/usr/lib/hydra')
|
||||
from hydra import system_recovery as recovery
|
||||
save = recovery.RecoverySession.save_journal
|
||||
def pause(self, value, phase):
|
||||
save(self, value, phase)
|
||||
if phase == 'root-retained':
|
||||
Path('/run/hydra-acceptance-child-ready').touch()
|
||||
os.kill(os.getpid(), signal.SIGSTOP)
|
||||
recovery.RecoverySession.save_journal = pause
|
||||
raise SystemExit(recovery.main(ARGUMENTS))
|
||||
""".replace('ARGUMENTS', repr(['restore', '--root-uuid', context['root_uuid'], '--checkpoint', context['checkpoint'], '--execute', '--confirm-root-uuid', context['root_uuid']]))
|
||||
stop_child(source)
|
||||
with mounted('/dev/vda3', 'btrfs', 'ro,subvolid=5') as top:
|
||||
journal = json.loads((top / '@snapshots/restore.json').read_text())
|
||||
assert journal['phase'] == 'root-retained'
|
||||
assert not (top / '@root').exists() and (top / journal['failed_root']).exists()
|
||||
result = {'sigkill_after_real_rename': True, 'durable_journal': journal}
|
||||
''',
|
||||
}
|
||||
|
||||
|
||||
def guest_script(mode, context):
|
||||
return GUEST_COMMON + '\ncontext = ' + repr(context) + '\n' + GUEST_BODIES[mode] + '\nprint(json.dumps(result, sort_keys=True))\n'
|
||||
|
||||
|
||||
def probe(console, mode, context, timeout=180):
|
||||
source = guest_script(mode, context)
|
||||
return json.loads(console.command("sudo -n python3 - <<'HYDRA_LAPTOP_PROBE'\n" + source + '\nHYDRA_LAPTOP_PROBE', timeout))
|
||||
|
||||
|
||||
def select_boot_entry(console, label, expected_path, create=None):
|
||||
# Harness-only one-shot selection: never rewrite BootOrder or firmware VARS.
|
||||
output = console.command('sudo -n efibootmgr', 30).decode()
|
||||
before = re.findall(r'^BootOrder:\s*([^\r\n]+)', output, re.M)
|
||||
if len(before) != 1:
|
||||
raise SmokeError('Expected one observable UEFI BootOrder: ' + output)
|
||||
pattern = r'^Boot([0-9A-Fa-f]{4})(\*?)[ \t]+' + re.escape(label) + r' *\t([^\r\n]+)'
|
||||
entries = re.findall(pattern, output, re.M)
|
||||
if not entries and create:
|
||||
# OVMF may prune create-only entries that are outside BootOrder after
|
||||
# consuming BootNext. Recreate only our known loader; never reorder it.
|
||||
console.command(create, 30)
|
||||
output = console.command('sudo -n efibootmgr', 30).decode()
|
||||
if re.findall(r'^BootOrder:\s*([^\r\n]+)', output, re.M) != before:
|
||||
raise SmokeError('Create-only registration changed UEFI BootOrder')
|
||||
entries = re.findall(pattern, output, re.M)
|
||||
if len(entries) != 1 or entries[0][1] != '*':
|
||||
raise SmokeError('Expected one active ' + label + ' entry: ' + output)
|
||||
entry, _, path = entries[0]
|
||||
if not re.fullmatch(expected_path, path, re.I):
|
||||
raise SmokeError('Unexpected ' + label + ' boot device or loader: ' + path)
|
||||
console.command('sudo -n efibootmgr --bootnext ' + entry, 30)
|
||||
after = console.command('sudo -n efibootmgr', 30).decode()
|
||||
if re.findall(r'^BootOrder:\s*([^\r\n]+)', after, re.M) != before:
|
||||
raise SmokeError('BootNext selection changed UEFI BootOrder')
|
||||
if re.findall(r'^BootNext:\s*([0-9A-Fa-f]{4})\s*$', after, re.M) != [entry]:
|
||||
raise SmokeError('UEFI did not retain the requested BootNext')
|
||||
return entry
|
||||
|
||||
|
||||
def select_installed(console, *, recreate=False):
|
||||
# Deliberate action AFTER proving the installer preserved the original order.
|
||||
esp = console.command('sudo -n blkid -s PARTUUID -o value /dev/vda1', 30).decode().strip()
|
||||
if not re.fullmatch(r'[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}', esp):
|
||||
raise SmokeError('Expected GPT identity of the disposable ESP')
|
||||
path = re.escape('HD(1,GPT,' + esp + ',0x800,0x100000)/\\EFI\\HydraOS\\grubx64.efi')
|
||||
create = ("sudo -n efibootmgr --create-only --disk /dev/vda --part 1 "
|
||||
"--label HydraOS --loader '\\EFI\\HydraOS\\grubx64.efi'")
|
||||
return select_boot_entry(console, 'HydraOS', path, create if recreate else None)
|
||||
|
||||
|
||||
def select_live(console):
|
||||
path = re.escape('PciRoot(0x0)/Pci(0x1f,0x2)/Sata(0,65535,0)') + r'(?:\{auto_created_boot_option\})?'
|
||||
return select_boot_entry(console, 'UEFI QEMU DVD-ROM QM00001', path)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argument_parser(); args = parser.parse_args(argv)
|
||||
for path in (args.iso, args.ovmf_code, args.ovmf_vars):
|
||||
if not path.is_file(): parser.error('Missing regular input: ' + str(path))
|
||||
if args.memory < 2048 or min(args.boot_timeout, args.install_timeout) <= 0:
|
||||
parser.error('Memory >=2048 MiB and positive timeouts required')
|
||||
args.keep_cdrom_drive = True
|
||||
args.iso, args.ovmf_code, args.ovmf_vars = (p.resolve() for p in (args.iso, args.ovmf_code, args.ovmf_vars))
|
||||
scratch = Path(tempfile.mkdtemp(prefix='hydra-laptop-smoke-', dir=args.scratch_parent))
|
||||
if len(str(scratch / 'serial.sock').encode()) >= 104: parser.error('Use shorter scratch parent such as /tmp')
|
||||
print('Acceptance artifacts: ' + str(scratch), flush=True)
|
||||
inputs = {str(p): iso_smoke.digest(p) for p in (args.iso, args.ovmf_code, args.ovmf_vars)}
|
||||
report = {'schema': 1, 'status': 'running', 'inputs_sha256': inputs, 'checks': {},
|
||||
'physical_hardware': 'pending', 'kernel_scope': 'exact kernel restoration; no two-kernel upgrade claim',
|
||||
'trust_scope': 'disposable TEST key temporarily pinned only in this disposable installed guest',
|
||||
'firmware_scope': 'BootOrder checked around each installer/checkpoint/restore/selector operation; firmware may prune empty-media entries between boots'}
|
||||
context = {'sentinel': secrets.token_hex(32)}
|
||||
password = secrets.token_urlsafe(24); vm = None; server = None
|
||||
def record(name, evidence):
|
||||
report['checks'][name] = evidence
|
||||
(scratch / 'report.json').write_text(json.dumps(report, indent=2) + '\n')
|
||||
print('Passed: ' + name, flush=True)
|
||||
def boot(live, phase):
|
||||
nonlocal vm
|
||||
vm = iso_smoke.VM(args, scratch, disk, args.iso if live else None, password, phase)
|
||||
console = vm.console
|
||||
if live:
|
||||
console.expect(rb'hydra-live[^\r\n]*[#$] ', args.boot_timeout)
|
||||
# Keep asynchronous mount diagnostics out of framed JSON on ttyS0;
|
||||
# they remain available in the guest's dmesg/journal buffers.
|
||||
console.command('sudo -n dmesg --console-level 3', 30)
|
||||
else:
|
||||
console.expect(rb'login: ', args.boot_timeout); console.send('hydratest\n')
|
||||
console.expect(rb'Password: ', 30); console.send(password + '\n')
|
||||
console.expect(rb'hydratest[^\r\n]*[#$] ', 60)
|
||||
iso_smoke.installed_admin(console, password)
|
||||
return console
|
||||
def shutdown(console):
|
||||
nonlocal vm
|
||||
console.send('sudo -n poweroff\n'); vm.wait_poweroff(90); vm.close(); vm = None
|
||||
try:
|
||||
disk = scratch / 'fresh-laptop.qcow2'
|
||||
iso_smoke.reserve_disk(disk)
|
||||
subprocess.run([args.qemu_img, 'create', '-f', 'qcow2', str(disk), '40G'], check=True, timeout=30, stdout=subprocess.DEVNULL)
|
||||
# Both code and variables copied; original firmware never attached writable.
|
||||
for source, name in ((args.ovmf_code, 'OVMF_CODE.fd'), (args.ovmf_vars, 'OVMF_VARS.fd')):
|
||||
shutil.copyfile(source, scratch / name); (scratch / name).chmod(0o600)
|
||||
args.ovmf_code = scratch / 'OVMF_CODE.fd'
|
||||
commit = subprocess.run(['git', '-C', str(_ROOT), 'rev-parse', 'HEAD'], check=True, capture_output=True, text=True).stdout.strip()
|
||||
files = test_artifacts(scratch, (_ROOT / 'hydra/cli.py').read_bytes(), commit)
|
||||
server = artifact_server(files); context['port'] = server.server_port
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
console = boot(True, 'live-install')
|
||||
before = probe(console, 'seed', context); record('seeded_foreign_invariants', before)
|
||||
live_boot = probe(console, 'capture-live-boot', context)
|
||||
context['dvd_variable'] = live_boot['raw_hex']; record('captured_live_boot_option', live_boot)
|
||||
token = secrets.token_hex(12); console.buffer = b''
|
||||
console.send(partition_installer_command(token) + '\n')
|
||||
console.expect_installer(rb'Installed account password: ', 120, token); console.send(password + '\n')
|
||||
console.expect_installer(rb'Repeat password: ', 30, token); console.send(password + '\n')
|
||||
console.expect_installer(rb'Installation completed\. Diagnostic log: [^\r\n]+', args.install_timeout, token)
|
||||
console.finish_installer(token)
|
||||
after = probe(console, 'preserved', context); verify_preserved(before, after)
|
||||
record('preserved_partition_install', after)
|
||||
record('harness_bootnext_selection', select_installed(console)); shutdown(console)
|
||||
console = boot(False, 'installed-baseline')
|
||||
# The existing UX probe runs as the normal account.
|
||||
console.send('exit\n'); console.expect(rb'hydratest[^\r\n]*[#$] ', 30)
|
||||
record('packaged_ascii_branding', json.loads(iso_smoke.installed_terminal_ux_probe(console)))
|
||||
iso_smoke.installed_admin(console, password)
|
||||
baseline = probe(console, 'baseline', context, 720); record('real_portage_baseline_doctor_vmdata', baseline)
|
||||
record('signed_runtime_update_recovery', probe(console, 'runtime', context, 300))
|
||||
record('harness_checkpoint_dvd_option', probe(console, 'restore-live-boot', context))
|
||||
record('harness_checkpoint_bootnext', select_live(console)); shutdown(console)
|
||||
console = boot(True, 'live-checkpoint')
|
||||
checkpoint_preserved = probe(console, 'preserved', context)
|
||||
verify_preserved(before, checkpoint_preserved, firmware_reboot=True)
|
||||
base_before = probe(console, 'base-state', context); record('base_before_checkpoint', base_before)
|
||||
context['root_uuid'] = console.command('sudo -n blkid -s UUID -o value /dev/vda3').decode().strip()
|
||||
esp_partuuid = console.command('sudo -n blkid -s PARTUUID -o value /dev/vda1').decode().strip()
|
||||
command = ('sudo -n hydra-system checkpoint --root-uuid ' + shlex.quote(context['root_uuid']) +
|
||||
' --esp-partuuid ' + shlex.quote(esp_partuuid) + ' --name laptop-before-upgrade')
|
||||
record('checkpoint_plan', json.loads(console.command(command, 180)))
|
||||
cp = json.loads(console.command(command + ' --execute --confirm-root-uuid ' + shlex.quote(context['root_uuid']), 300))
|
||||
context['checkpoint'] = cp['id']; record('real_offline_checkpoint', cp)
|
||||
record('checkpoint_list', json.loads(console.command('sudo -n hydra-system list --root-uuid ' + shlex.quote(context['root_uuid']), 180)))
|
||||
checkpoint_after = probe(console, 'preserved', context)
|
||||
verify_preserved(checkpoint_preserved, checkpoint_after)
|
||||
record('checkpoint_preserved_firmware_and_data', checkpoint_after)
|
||||
select_installed(console, recreate=True); shutdown(console)
|
||||
console = boot(False, 'installed-upgrade'); record('real_portage_upgrade_current_home', probe(console, 'upgrade', context, 720))
|
||||
record('harness_restore_dvd_option', probe(console, 'restore-live-boot', context))
|
||||
record('harness_restore_bootnext', select_live(console)); shutdown(console)
|
||||
console = boot(True, 'live-restore')
|
||||
restore_preserved = probe(console, 'preserved', context)
|
||||
verify_preserved(before, restore_preserved, firmware_reboot=True)
|
||||
record('deliberate_kernel_efi_damage', probe(console, 'damage', context))
|
||||
record('restore_sigkill_real_btrfs', probe(console, 'restore-interrupt', context, 300))
|
||||
restore = ('sudo -n hydra-system restore --root-uuid ' + shlex.quote(context['root_uuid']) +
|
||||
' --checkpoint ' + shlex.quote(context['checkpoint']) +
|
||||
' --execute --confirm-root-uuid ' + shlex.quote(context['root_uuid']))
|
||||
record('fresh_process_restore_resume', json.loads(console.command(restore, 300)))
|
||||
base_after = probe(console, 'base-state', context)
|
||||
if base_after != base_before: raise SmokeError('Offline restore did not restore exact base/Portage/kernel/ownedEFI hashes')
|
||||
preserved_after = probe(console, 'preserved', context)
|
||||
verify_preserved(restore_preserved, preserved_after)
|
||||
verify_preserved(before, preserved_after, firmware_reboot=True)
|
||||
record('restored_base_foreign_invariants', {'base': base_after, 'preserved': preserved_after})
|
||||
select_installed(console, recreate=True); shutdown(console)
|
||||
console = boot(False, 'installed-restored'); restored = probe(console, 'restored', context)
|
||||
if any(restored[k] != v for k, v in base_before.items()): raise SmokeError('Disk-only boot did not retain restored base')
|
||||
if restored['vm_data'] != baseline['vm_data']: raise SmokeError('VM disk/profile bytes changed')
|
||||
record('disk_only_restored_boot_current_home_vmdata', restored)
|
||||
final_preserved = probe(console, 'preserved', context)
|
||||
verify_preserved(before, final_preserved, firmware_reboot=True)
|
||||
record('final_disk_boot_foreign_invariants', final_preserved); shutdown(console)
|
||||
if any(iso_smoke.digest(Path(path)) != digest for path, digest in inputs.items()): raise SmokeError('Input ISO/firmware changed')
|
||||
report['status'] = 'passed'; return 0
|
||||
except (SmokeError, OSError, ValueError, subprocess.SubprocessError, iso_smoke.QMPError) as exc:
|
||||
report['status'] = 'failed'; report['error'] = str(exc).replace(password, '[REDACTED]')
|
||||
print('Acceptance failed: ' + report['error'], flush=True); return 1
|
||||
finally:
|
||||
if vm: vm.close()
|
||||
if server: server.shutdown(); server.server_close()
|
||||
report['completed_utc'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||
(scratch / 'report.json').write_text(json.dumps(report, indent=2) + '\n')
|
||||
print('Report: ' + str(scratch / 'report.json'), flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build reproducible, passive Ed25519-signed runtime release artifacts."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
ENGINE = runpy.run_path(str(Path(__file__).resolve().parents[1] / 'hydra/update_engine.py'))
|
||||
|
||||
|
||||
def build(source, output, private_key, version, sequence):
|
||||
source, output, private_key = Path(source).resolve(), Path(output).absolute(), Path(private_key).absolute()
|
||||
if private_key.is_symlink() or not private_key.is_file():
|
||||
raise ValueError('Private key must be an existing regular file outside Git')
|
||||
info = private_key.stat()
|
||||
if info.st_uid != os.geteuid() or info.st_mode & 0o077:
|
||||
raise ValueError('Private key must be user-owned and private (0600)')
|
||||
git = subprocess.run(['git', '-C', str(private_key.parent), 'rev-parse', '--show-toplevel'], capture_output=True, text=True)
|
||||
if private_key.is_relative_to(source) or git.returncode == 0:
|
||||
raise ValueError('Private key must remain outside Git and the source/build context')
|
||||
commit = subprocess.run(['git', '-C', str(source), 'rev-parse', 'HEAD'], check=True, capture_output=True, text=True).stdout.strip()
|
||||
files = {}
|
||||
for path in sorted((source / 'hydra').glob('*.py')):
|
||||
name = 'usr/lib/hydra/hydra/' + path.name
|
||||
if ENGINE['allowed'](name):
|
||||
if path.is_symlink():
|
||||
raise ValueError('Source payload symlink refused')
|
||||
files[name] = (ENGINE['read_bytes'](path, ENGINE['FILE_LIMIT']), 0o644)
|
||||
files['usr/lib/hydra/bin/hydra'] = (ENGINE['read_bytes'](source / 'bin/hydra', ENGINE['FILE_LIMIT']), 0o755)
|
||||
for name in sorted(ENGINE['EXACT'] - {'usr/lib/hydra/bin/hydra'}):
|
||||
path = source / 'image/rootfs' / name
|
||||
if path.exists():
|
||||
files[name] = (ENGINE['read_bytes'](path, ENGINE['FILE_LIMIT']), 0o644)
|
||||
# Construct privately, verify our complete artifacts, then publish a new directory.
|
||||
with tempfile.TemporaryDirectory(prefix='hydra-release-build-') as temporary:
|
||||
temporary = Path(temporary)
|
||||
with zipfile.ZipFile(temporary / 'runtime.zip', 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
|
||||
for name, (data, mode) in sorted(files.items()):
|
||||
info = zipfile.ZipInfo(name, (1980, 1, 1, 0, 0, 0))
|
||||
info.create_system = 3
|
||||
info.external_attr = (stat.S_IFREG | mode) << 16
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
archive.writestr(info, data, compresslevel=9)
|
||||
data = ENGINE['read_bytes'](temporary / 'runtime.zip', ENGINE['LIMIT'])
|
||||
manifest = {'format': 1, 'product': 'Hydra OS', 'scope': 'runtime', 'channel': 'stable',
|
||||
'architecture': 'x86_64', 'version': version, 'sequence': sequence, 'source_commit': commit,
|
||||
'archive': {'name': 'runtime.zip', 'size': len(data), 'sha256': ENGINE['digest'](data)},
|
||||
'files': {name: {'size': len(content), 'mode': mode, 'sha256': ENGINE['digest'](content)}
|
||||
for name, (content, mode) in sorted(files.items())}}
|
||||
raw = (json.dumps(manifest, sort_keys=True, separators=(',', ':')) + '\n').encode()
|
||||
(temporary / 'manifest.json').write_bytes(raw)
|
||||
subprocess.run(['openssl', 'pkeyutl', '-sign', '-rawin', '-inkey', str(private_key), '-in', str(temporary / 'manifest.json'),
|
||||
'-out', str(temporary / 'manifest.sig')], check=True, capture_output=True, timeout=15)
|
||||
subprocess.run(['openssl', 'pkey', '-in', str(private_key), '-pubout', '-out', str(temporary / 'public.pem')],
|
||||
check=True, capture_output=True, timeout=15)
|
||||
ENGINE['validate_manifest'](raw, (temporary / 'manifest.sig').read_bytes(), temporary / 'public.pem')
|
||||
ENGINE['validate_archive'](data, manifest)
|
||||
output.mkdir(mode=0o755) # Existing version directories are immutable.
|
||||
try:
|
||||
for name in ('manifest.json', 'manifest.sig', 'runtime.zip'):
|
||||
ENGINE['atomic_write'](output / name, (temporary / name).read_bytes(), 0o644)
|
||||
ENGINE['fsync_dir'](output.parent)
|
||||
except BaseException:
|
||||
shutil.rmtree(output)
|
||||
raise
|
||||
return manifest
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--source', type=Path, default=Path(__file__).resolve().parents[1])
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
parser.add_argument('--private-key', type=Path, required=True)
|
||||
parser.add_argument('--version', required=True)
|
||||
parser.add_argument('--sequence', type=int, required=True)
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
print(json.dumps(build(args.source, args.output, args.private_key, args.version, args.sequence), indent=2))
|
||||
return 0
|
||||
except (ValueError, OSError, subprocess.SubprocessError) as exc:
|
||||
print('hydra-release: ' + str(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert the generated Hydra silhouette to Fastfetch ASCII using Pillow.
|
||||
|
||||
Run from the repository root with Python + Pillow (development only):
|
||||
python3 dev/render-logo.py
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RAMP = ' .:-=+*#%@' # '$' is reserved for Fastfetch color placeholders.
|
||||
CAPTION = 'H Y D R A O S'
|
||||
|
||||
|
||||
def render(source, columns):
|
||||
# This is a single-color mark: opacity, not luminance, defines its shape.
|
||||
# Threshold near-transparent generation noise before reducing the image.
|
||||
with Image.open(source) as image:
|
||||
mask = image.convert('RGBA').getchannel('A').point(
|
||||
lambda value: 255 if value > 160 else 0)
|
||||
bounds = mask.getbbox()
|
||||
if bounds is None:
|
||||
raise ValueError('The source image has no visible silhouette')
|
||||
mask = mask.crop(bounds)
|
||||
# A terminal character is approximately twice as tall as it is wide.
|
||||
rows = max(1, round(mask.height / mask.width * columns * 0.5))
|
||||
pixels = mask.resize((columns, rows), Image.Resampling.LANCZOS)
|
||||
lines = []
|
||||
for y in range(rows):
|
||||
line = ''
|
||||
for x in range(columns):
|
||||
coverage = pixels.getpixel((x, y)) / 255
|
||||
level = min(9, max(0, round((coverage - 0.09) / 0.82 * 9)))
|
||||
line += RAMP[level]
|
||||
lines.append(line.rstrip())
|
||||
return '$1' + '\n'.join(lines) + '\n\n$2' + CAPTION.center(columns).rstrip() + '\n'
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--source', type=Path,
|
||||
default=ROOT / 'assets/branding/hydra-mark.png')
|
||||
parser.add_argument('--output', type=Path,
|
||||
default=ROOT / 'image/rootfs/usr/share/hydra/fastfetch.txt')
|
||||
parser.add_argument('--columns', type=int, default=44)
|
||||
args = parser.parse_args()
|
||||
if not len(CAPTION) <= args.columns <= 100:
|
||||
parser.error('columns must be between 13 and 100')
|
||||
args.output.write_text(render(args.source, args.columns), encoding='ascii')
|
||||
print(args.output)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -130,7 +130,7 @@ identities = {name: {'pid': manager.paths(name).pid.read_text().strip(),
|
||||
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(),
|
||||
wait_for(lambda: path.exists() and 'set_title("Hydra OS: ' + 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'
|
||||
@@ -141,10 +141,28 @@ identities = {name: {'pid': manager.paths(name).pid.read_text().strip(),
|
||||
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'))
|
||||
# Exercise a real menu selection of the second, already running VM.
|
||||
# Its request refreshes only the private compositor/viewers.
|
||||
before_beta_focus = len(focus_events(blog))
|
||||
manifest = Path(second['config_directory']).parent / 'session.json'
|
||||
with (root / 'selection.log').open('w') as selection_log:
|
||||
selection = subprocess.run(cli + ['_session-helper', 'recover', str(manifest)],
|
||||
input='beta\n', text=True,
|
||||
env=dict(env, WAYLAND_DISPLAY=display),
|
||||
stdout=selection_log, stderr=selection_log, timeout=20)
|
||||
assert selection.returncode == 0, 'menu selection failed'
|
||||
wait_for(lambda: (root / 'snapshot-2.json').exists(), 'selected menu did not refresh compositor', timeout=15)
|
||||
third = json.loads((root / 'snapshot-2.json').read_text())
|
||||
from xml.etree import ElementTree as ET
|
||||
assert ET.fromstring((root / 'snapshot-2.xml').read_text()).findtext('./desktops/initial') == '2'
|
||||
wait_for(lambda: any(p.name.startswith('wayland-') and not p.name.endswith('.lock')
|
||||
for p in xdg.iterdir()), 'refreshed private display is not ready')
|
||||
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')
|
||||
wait_for(lambda: len(focus_events(blog)) > before_beta_focus and focus_events(blog)[-1] == 'enter',
|
||||
'second VM did not gain fresh focus after menu selection')
|
||||
|
||||
def switch(code, source_log, target_log):
|
||||
before_source, before_target = focus_events(source_log), focus_events(target_log)
|
||||
@@ -153,10 +171,10 @@ identities = {name: {'pid': manager.paths(name).pid.read_text().strip(),
|
||||
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)
|
||||
intervals = {'beta': measure_hidden_interval(lambda: counter(manager, 'beta'), blog, alog)}
|
||||
switch(106, alog, blog)
|
||||
intervals['alpha'] = measure_hidden_interval(lambda: counter(manager, 'alpha'), alog, blog)
|
||||
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)}
|
||||
@@ -166,15 +184,16 @@ identities = {name: {'pid': manager.paths(name).pid.read_text().strip(),
|
||||
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)
|
||||
command = Path('/proc') / str(third['pid']) / 'cmdline'
|
||||
assert third['config_directory'].encode() in command.read_bytes()
|
||||
os.kill(third['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,
|
||||
compositor_pids=[first['pid'], second['pid'], third['pid']],
|
||||
selected_name='beta', selected_workspace=2, before=before, after=after,
|
||||
viewers=viewers,
|
||||
hidden_intervals=intervals, survived_session_exit=True,
|
||||
expected_xwayland_diagnostics=classify_compositor_errors((root / 'session.log').read_text()),
|
||||
|
||||
+3
-3
@@ -178,7 +178,7 @@ def main():
|
||||
env=viewer_env, stdout=vl, stderr=vl)
|
||||
processes.append(proc)
|
||||
log = root / f'{name}-{number}.log'
|
||||
wait_for(lambda: f'set_title("HydraOS: {name}")' in log.read_text(), f'{name} viewer did not connect')
|
||||
wait_for(lambda: f'set_title("Hydra OS: {name}")' in log.read_text(), f'{name} viewer did not connect')
|
||||
return proc, log
|
||||
|
||||
for name in names:
|
||||
@@ -220,8 +220,8 @@ def main():
|
||||
post_usb_switches = [switch(106, alog, blog), switch(105, blog, alog)]
|
||||
assert alpha.poll() is None and beta.poll() is None
|
||||
assert identities == {name: manager.paths(name).pid.read_text().strip() for name in names}, 'switch restarted a guest'
|
||||
assert alog.read_text().count('set_title("HydraOS: alpha")') == 1, 'switch reconnected alpha'
|
||||
assert blog.read_text().count('set_title("HydraOS: beta")') == 1, 'switch reconnected beta'
|
||||
assert alog.read_text().count('set_title("Hydra OS: alpha")') == 1, 'switch reconnected alpha'
|
||||
assert blog.read_text().count('set_title("Hydra OS: beta")') == 1, 'switch reconnected beta'
|
||||
with manager._connect(manager.paths('alpha')) as qmp:
|
||||
usb_after = qmp.command('human-monitor-command', {'command-line': 'info usb'})
|
||||
assert usb_after == usb_before, 'opening selector attached an unselected USB device'
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# ASUS TUF Dash F15 FX517ZC
|
||||
|
||||
Read-only inventory 2026-10-13; no serial numbers or machine identifiers.
|
||||
Initial read-only inventory on 2026-10-13; no serials or machine identifiers.
|
||||
|
||||
| Component | Hardware | Driver / requirement |
|
||||
| --- | --- | --- |
|
||||
| CPU | i7-12650H, 10 cores / 16 threads | KVM Intel, VT-x |
|
||||
| RAM | 32 GB nominal | Four build jobs initially; avoid disrupting existing VMs |
|
||||
| RAM | 32 GB nominal | Preserve memory headroom for existing VMs |
|
||||
| Display | Intel Alder Lake-P UHD 8086:46a3 | i915, internal eDP, intel_backlight |
|
||||
| Discrete GPU | RTX 3050 Mobile 10de:25a2 | Initial shell uses Intel; test NVIDIA separately |
|
||||
| Wi-Fi | Intel CNVi 8086:51f0 | iwlwifi/iwlmvm, linux-firmware |
|
||||
@@ -16,7 +16,53 @@ Read-only inventory 2026-10-13; no serial numbers or machine identifiers.
|
||||
| Thunderbolt | Intel 8086:463e | thunderbolt |
|
||||
| Laptop controls | ASUS | asus_wmi, asus_nb_wmi |
|
||||
|
||||
UEFI boot, Secure Boot currently disabled. Include vmd, nvme, xhci_pci,
|
||||
thunderbolt, usb_storage, sd_mod in boot coverage. Target physical refresh144Hz;
|
||||
actual guest frame delivery must be measured. About290GiB build storage free at
|
||||
inventory. Initial RAM headroom about10GiB with existing sessions running.
|
||||
Include vmd, nvme, xhci_pci, thunderbolt, usb_storage and sd_mod in boot coverage.
|
||||
Target physical refresh is 144 Hz; actual guest frame delivery requires measurement.
|
||||
|
||||
## Read-only doctor and live functional checks
|
||||
|
||||
Run `hydra doctor` from the Hydra live session, or `hydra doctor --json` to save
|
||||
structured observations. The command reads procfs/sysfs and checks service status;
|
||||
it does not connect networks, load modules, change brightness, play sound, mount
|
||||
storage, suspend, or start services. Root is not required. Missing tools, absent
|
||||
hardware, access failures and service timeouts are reported without aborting.
|
||||
|
||||
Reports omit storage serials, PCI addresses, interface names, MAC/IP addresses,
|
||||
SSID, usernames, hostname and machine IDs. CPU facts include architecture, logical
|
||||
processor count and virtualization flags. Device facts include counts, capacities,
|
||||
allowlisted driver names and connector resolution modes. Unknown driver names are
|
||||
reported as `other`. Presence and status do not establish physical functionality.
|
||||
|
||||
Reads are limited to 64 KiB per file, directory scans to 64 entries, connector
|
||||
modes to 32 lines, and seven service queries to one second each. Counts may be
|
||||
partial on systems exceeding these limits. Service output is discarded; exit
|
||||
status alone determines active/inactive/unavailable/unknown. User audio service
|
||||
queries refer to the user running the command. A root invocation can therefore
|
||||
observe a different user audio manager than the live desktop user.
|
||||
|
||||
The following source-checkout smoke was observed on the running NixOS host on
|
||||
2026-10-13. It involved no changes to host settings or hardware. It is separate
|
||||
from Hydra ISO validation and does not certify the new image's laptop drivers.
|
||||
|
||||
| Check | Doctor detects | Source-checkout host observation | Live functional check still needed |
|
||||
| --- | --- | --- | --- |
|
||||
| CPU / RAM | Architecture, logical CPUs, VM flags, memory totals | x86_64, 16 logical CPUs, `vmx`, 31,715 MiB RAM | Boot the Hydra image and compare |
|
||||
| Firmware | UEFI presence, Secure Boot variable | UEFI present, Secure Boot disabled | Confirm live boot from USB |
|
||||
| KVM | Device access and loaded KVM modules | `/dev/kvm` accessible, `kvm` and `kvm_intel` present | Launch a disposable VM from Hydra |
|
||||
| Storage | Whole-device count/capacity; no serials or identities | Visibility observed; no target selected | Identify any future installation target separately |
|
||||
| Graphics | PCI driver bindings and connector resolution modes | `i915`, `nvidia`; connected 1920×1080 mode | Select 144 Hz and verify actual refresh rate |
|
||||
| Input / brightness | Event devices, keyboard capabilities, backlight presence | 23 event devices, three keyboard-capable devices, one backlight | Test keyboard, touchpad and both brightness keys |
|
||||
| Wi-Fi / Ethernet | Device counts, wireless presence, driver bindings | One wireless device; `iwlwifi` and `e1000e` | Connect Wi-Fi and test connectivity; test Ethernet if used |
|
||||
| Audio | Card presence and user audio service status | Three cards; PipeWire, WirePlumber and PipeWire Pulse active | Test speakers, headphones and microphone recording |
|
||||
| Desktop / guest networking | Hydra and desktop service status | NetworkManager active; Hydra services absent on NixOS | Observe required services in the Hydra live session |
|
||||
| Suspend / USB | No functional probe | Not exercised | Suspend/resume, then retest graphics/network/audio; attach USB devices |
|
||||
|
||||
Run the manual checks before deciding the image is ready for daily laptop use.
|
||||
Read-only diagnostics and disposable VM acceptance cannot replace these checks.
|
||||
Keep any report you share within its default structured output: unrelated logs,
|
||||
network commands and screenshots can contain private identifiers.
|
||||
|
||||
Installation remains an explicit operator action; this document selects no disk
|
||||
or partition. Signed runtime rollback restores Hydra runtime files only. Offline
|
||||
Btrfs root/owned-EFI recovery has separate requirements and acceptance evidence
|
||||
in [system-recovery.md](../system-recovery.md).
|
||||
|
||||
+76
-10
@@ -1,12 +1,72 @@
|
||||
# Install HydraOS
|
||||
# Install Hydra OS
|
||||
|
||||
HydraOS targets x86_64 UEFI systems. Disable Secure Boot for this first image.
|
||||
Boot the ISO and open the recovery terminal. The live session uses a locked
|
||||
Hydra OS targets x86_64 UEFI systems. Disable Secure Boot for this first image.
|
||||
Boot the ISO and enter `:shell` in the terminal VM menu. The live session uses a locked
|
||||
`hydra-live` account with local sudo access; changes in the live filesystem are
|
||||
volatile. The image provides no default SSH login or universal installed password.
|
||||
|
||||
`hydra-install` lists disks without selecting one. Inspect the devices, then ask
|
||||
for a plan using the whole disk you intend to erase:
|
||||
for a plan using either the explicitly chosen existing partitions or a whole disk.
|
||||
The commands below use placeholders; the installer never chooses devices automatically.
|
||||
|
||||
## Preserve existing partitions
|
||||
|
||||
Choose a root partition whose contents you intend to replace and an existing FAT
|
||||
GPT EFI System Partition (ESP). Inspect their identities and sizes with `lsblk`.
|
||||
Both selected partitions must be unmounted; unrelated mounted siblings are allowed.
|
||||
|
||||
```sh
|
||||
sudo hydra-install --root-partition /dev/CHOSEN_ROOT \
|
||||
--esp-partition /dev/CHOSEN_ESP --user YOUR_USER --root-fs btrfs
|
||||
```
|
||||
|
||||
This dry-run describes the operation. To execute the same choice:
|
||||
|
||||
```sh
|
||||
sudo hydra-install --root-partition /dev/CHOSEN_ROOT \
|
||||
--esp-partition /dev/CHOSEN_ESP --user YOUR_USER --root-fs btrfs \
|
||||
--execute --confirm-format /dev/CHOSEN_ROOT
|
||||
```
|
||||
|
||||
`--confirm-format` must name the canonical resolved root path exactly, even if an
|
||||
input path is an alias. This mode formats **only the selected root partition**.
|
||||
It never recreates GPT, changes partition boundaries, or formats the ESP. Before
|
||||
formatting, it checks uncompressed payload capacity, mounts the ESP read-only,
|
||||
refuses any existing `EFI/HydraOS` collision, and requires at least 64 MiB free.
|
||||
It fingerprints foreign ESP files and verifies their hashes after installation.
|
||||
|
||||
GRUB writes `EFI/HydraOS/grubx64.efi` using `--no-nvram` without `--removable`.
|
||||
Existing EFI loaders, `EFI/BOOT/BOOTX64.EFI`, and firmware BootOrder are preserved.
|
||||
By default, select that Hydra EFI file manually in your firmware boot interface.
|
||||
`--register-boot-entry` opts into `efibootmgr --create-only`: it creates a new
|
||||
HydraOS entry without adding it to BootOrder. Firmware support for selecting EFI
|
||||
files and managing entries varies.
|
||||
|
||||
Only plain, nonoverlapping GPT partitions on physical disks are supported. Root
|
||||
and ESP may reside on different disks. Device mapper, encrypted/LVM/RAID targets,
|
||||
nested partition devices, missing identities/bounds, active holders, and ambiguous
|
||||
backing information are rejected. Mounted Btrfs filesystems must report every
|
||||
backing member; an incomplete or missing-device report is rejected. ZFS backing
|
||||
anatomy is unsupported and causes a conservative refusal. The checks are
|
||||
repeated just before formatting; do not change mounts or storage topology during
|
||||
installation. They cannot replace your inspection and backups.
|
||||
|
||||
`--root-fs ext4` is the default. Btrfs creates `@root`, `@home`, and `@snapshots`.
|
||||
The kernel, `/boot`, and Portage database stay inside `@root`; `/home` (including
|
||||
guest disks) uses `@home`, and `/.snapshots` uses `@snapshots`. fstab and boot
|
||||
arguments use filesystem UUIDs and named subvolumes, without a fixed root
|
||||
subvolume ID. The layout alone does not implement system rollback; full Gentoo
|
||||
and kernel recovery requires separate bootable verification.
|
||||
|
||||
An installed, root-owned mode-0600 `/etc/hydra/storage.json` records format 1, a
|
||||
random install UUID, root/ESP filesystem UUIDs and GPT PARTUUIDs, filesystem type,
|
||||
and owned EFI path `EFI/HydraOS`. Btrfs additionally records
|
||||
`root_subvolume: "@root"`. Treat this identity record as installation metadata,
|
||||
not an update payload.
|
||||
|
||||
## Erase a whole disk
|
||||
|
||||
Use this mode only when every partition on the explicitly chosen disk may be erased:
|
||||
|
||||
```sh
|
||||
hydra-install
|
||||
@@ -26,7 +86,7 @@ sudo hydra-install --disk /dev/CHOSEN_DISK --user YOUR_USER \
|
||||
--execute --confirm-erase /dev/CHOSEN_DISK
|
||||
```
|
||||
|
||||
Execution erases the disk, creates a 1 GiB FAT32 EFI partition and an ext4 root,
|
||||
Execution erases the disk, creates a 1 GiB FAT32 EFI partition and an ext4 root (or Btrfs with `--root-fs btrfs`),
|
||||
extracts the prepared payload, and prompts twice on the local terminal for your
|
||||
installed account password. Passwords have no command-line or configuration
|
||||
option. The installed root account stays locked. Defaults are Europe/Moscow and
|
||||
@@ -34,8 +94,8 @@ English UTF-8, with Russian UTF-8 also available; `--timezone` accepts an IANA
|
||||
zone present in the payload.
|
||||
|
||||
The installer removes the live account, local password-free sudo rule, and
|
||||
live autologin overrides, then builds a normal disk initramfs and fallback UEFI
|
||||
loader. Wait for `Installation completed`, power off, remove the ISO, and boot
|
||||
live autologin overrides, then builds a normal disk initramfs, its owned HydraOS UEFI loader, and a fallback
|
||||
UEFI loader on the newly created ESP. Both installation modes keep os-prober disabled. Wait for `Installation completed`, power off, remove the ISO, and boot
|
||||
the installed disk. Log in with the account and password you supplied. On
|
||||
failure, read the printed live diagnostic path; when a target root was mounted,
|
||||
a nonsecret snapshot is also saved at `/var/log/hydra-install.log` on that disk.
|
||||
@@ -70,10 +130,16 @@ shuts down, and launches a second QEMU with the ISO absent. It verifies password
|
||||
login, disk root, removed live overrides, services, bridge/NAT configuration,
|
||||
DNS, recovery tools, and a routed new TCP connection from a synthetic LAN to a
|
||||
synthetic VM endpoint. Network namespaces and veth interfaces exist only inside
|
||||
the disposable HydraOS guest. When guest KVM is available, it launches a further
|
||||
the disposable Hydra OS guest. When guest KVM is available, it launches a further
|
||||
fresh-disk QEMU as the installed account to exercise KVM, the bridge helper,
|
||||
and private QMP; this checks VM launch, not a nested operating system boot.
|
||||
It also logs the installed account out and back in, checking that the same VM,
|
||||
The installed normal account also exercises the packaged terminal menu,
|
||||
downloads a complete nonboot ISO fixture from a private loopback HTTP server,
|
||||
starts a scratch VM with `hydra run`, checks cache reuse and stable process/QMP
|
||||
identity through `hydra open`, and verifies the default Fastfetch logo/name.
|
||||
This fixture checks the command flow, not a nested guest OS boot.
|
||||
|
||||
The harness also logs the installed account out and back in, checking that the same VM,
|
||||
private sockets and user audio services survive a measured interval without an
|
||||
open login session. This establishes process/socket continuity, not audio playback.
|
||||
|
||||
@@ -88,7 +154,7 @@ acceptance.
|
||||
## Checks requiring the laptop
|
||||
|
||||
Disposable QEMU results cannot establish ASUS FX517ZC hardware support. Before
|
||||
using HydraOS daily, boot the live ISO and observe Wi-Fi, touchpad, audio,
|
||||
using Hydra OS daily, boot the live ISO and observe Wi-Fi, touchpad, audio,
|
||||
brightness, suspend/resume, USB device selection, input capture, and actual
|
||||
display refresh. Physical installation and these checks remain pending until
|
||||
explicitly performed and recorded.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Disposable laptop acceptance
|
||||
|
||||
`dev/laptop-smoke.py` exercises the packaged installer, doctor, ASCII branding,
|
||||
signed runtime updater and offline base recovery in one new UEFI/KVM VM. It accepts
|
||||
only trusted ISO and firmware inputs. It creates a private scratch directory and a
|
||||
new 40 GiB qcow2, copies both firmware files, and retains the disk, serial logs and
|
||||
`report.json` after success or failure. It never accepts an existing disk or host
|
||||
block device. Run it where QEMU/KVM, Python and OpenSSL are already available; on
|
||||
this development machine that is the normal-user Ubuntu development workspace.
|
||||
|
||||
```sh
|
||||
python3 dev/laptop-smoke.py \
|
||||
--iso /absolute/path/to/new-hydra.iso \
|
||||
--ovmf-code /absolute/path/to/OVMF_CODE.fd \
|
||||
--ovmf-vars /absolute/path/to/OVMF_VARS.fd \
|
||||
--scratch-parent /tmp
|
||||
```
|
||||
|
||||
The separate `dev/iso-smoke.py` whole-disk regression remains required. This harness
|
||||
adds the following real guest operations:
|
||||
|
||||
1. Seed fresh GPT with an ESP, foreign data partition and selected root. Record
|
||||
partition GUIDs/boundaries, the complete foreign partition hash, foreign EFI and
|
||||
fallback bytes, and firmware BootOrder. Install Btrfs into `/dev/vda3` while
|
||||
retaining `/dev/vda1`, explicitly register a HydraOS entry, then verify those
|
||||
invariants before the harness deliberately sets BootNext to choose that entry.
|
||||
Subsequent live boots also use verified one-shot BootNext selection. The same
|
||||
SATA DVD drive stays attached throughout, empty during installed boots; no ISO
|
||||
is available to those boots. OVMF can prune the empty DVD's automatic boot entry
|
||||
despite that stable topology. The first live boot captures its verified EFI
|
||||
load-option bytes; before returning to live, the harness restores those exact
|
||||
bytes into an unused Boot#### variable only, verifies readback and unchanged
|
||||
BootOrder, then selects BootNext. Existing variables are never overwritten. If
|
||||
OVMF prunes HydraOS's create-only entry after consuming BootNext, the harness
|
||||
similarly recreates only that known ESP/loader entry before later selections.
|
||||
Initial installed selection still requires the installer's entry to exist.
|
||||
Every deliberate registration and BootNext write is checked against unchanged
|
||||
BootOrder. Live test consoles
|
||||
reduce kernel console verbosity so asynchronous mount messages cannot corrupt
|
||||
framed JSON; the messages remain in the guest kernel log.
|
||||
2. Boot without ISO, verify the packaged Fastfetch art and doctor, install version
|
||||
1.0 of a locally created EAPI 8 Portage package, and create a real qcow2 plus
|
||||
profile/data sentinels under HOME. The fixture has no sources or dependencies;
|
||||
`emerge --nodeps` runs in a private guest network namespace with no interfaces,
|
||||
so it cannot fetch a package.
|
||||
3. Temporarily pin a disposable TEST public key in that guest and verify signed
|
||||
passive local artifacts through the packaged CLI. Apply and roll back a runtime
|
||||
change, refuse a tampered archive without mutation, SIGKILL a child after an
|
||||
actual runtime replacement, damage the CLI, then invoke the independently
|
||||
installed `/usr/bin/hydra-update-recover` in a fresh process. Verify exact original
|
||||
runtime bytes, the monotonic replay floor and unchanged VM data. Restore the
|
||||
guest's original public key. The production signing key is never read.
|
||||
4. Boot live with installed filesystems offline. Record kernel/boot, fixture Portage
|
||||
database, world and owned EFI hashes, then create and list a real Btrfs checkpoint
|
||||
through `hydra-system`. Boot the installed system, upgrade the fixture to 2.0
|
||||
through Portage in a private network namespace and modify HOME and world.
|
||||
5. Boot live again, deliberately damage actual installed kernels and the owned EFI
|
||||
loader, and SIGKILL a real restore after its durable `root-retained` transition.
|
||||
Resume the same checkpoint through a fresh `hydra-system` process. Compare exact
|
||||
base, package database, world and EFI hashes and all preserved foreign invariants.
|
||||
Boot without ISO and verify version 1.0, current HOME and unchanged VM disk/profile
|
||||
data. The previous failed root and checkpoint remain on the disposable disk.
|
||||
Installed checks read the existing `/boot/efi` mount after verifying its device,
|
||||
filesystem and UUID against the storage marker; they never remount it read-only.
|
||||
|
||||
BootOrder preservation is checked immediately around each installer, checkpoint,
|
||||
restore and harness selection operation. It is not compared across firmware boots:
|
||||
OVMF itself removes empty-media entries from BootOrder. GPT, foreign partition
|
||||
bytes and foreign/fallback EFI bytes remain strict invariants across the entire
|
||||
sequence, including all reboots. Both scopes are explicit in `report.json`.
|
||||
|
||||
The loopback HTTP fixture serves only explicitly named in-memory TEST artifacts
|
||||
and their public key. It exposes no host directory, build tree, private key or
|
||||
credentials. The disposable private key is deleted before the VM boots. This HTTP
|
||||
transport tests offline artifact delivery, not production HTTPS discovery or its
|
||||
redirect policy. Neither runtime nor base recovery receives production signing
|
||||
material or existing VM disks.
|
||||
|
||||
Only an actual `report.json` with `status: "passed"`, input SHA-256 identities and
|
||||
all guest phases establishes acceptance. Source tests verify harness construction,
|
||||
real signatures, narrow sharing, byte comparisons and guest script compilation;
|
||||
they do not prove UEFI, Btrfs, Portage or installed boot. This sequence restores an
|
||||
exact kernel after damage and upgrades a real minimal fixture package; it does not
|
||||
claim an upgrade between two kernel versions. Physical ASUS Wi-Fi, audio, suspend,
|
||||
brightness and refresh-rate checks still require live laptop verification.
|
||||
|
||||
Run the focused source checks without booting a VM:
|
||||
|
||||
```sh
|
||||
python3 -m unittest tests.test_laptop_smoke
|
||||
python3 -m py_compile dev/laptop-smoke.py tests/test_laptop_smoke.py
|
||||
```
|
||||
@@ -0,0 +1,78 @@
|
||||
# Legacy development bundle recovery
|
||||
|
||||
This procedure applies only to the early local development `.pyz` bundles.
|
||||
They are not the signed public update mechanism. Do not download and execute an
|
||||
untrusted bundle. New installations use [signed runtime updates](updates.md).
|
||||
The following instructions are retained for recovering an already applied old
|
||||
bundle and its existing backup.
|
||||
|
||||
|
||||
The first ISO predates this interface. A runtime bundle updates the Hydra
|
||||
program and branding files while retaining the original image manifest and
|
||||
backing up replaced files under `/var/lib/hydra/updates`. It leaves VM profiles,
|
||||
disks, the kernel and network configuration in place. To build one from a
|
||||
trusted checkout:
|
||||
|
||||
```sh
|
||||
python3 dev/guest-update.py --build . --output /tmp/hydra-os-update.pyz
|
||||
```
|
||||
|
||||
Copy the resulting file into Hydra, then inspect and apply it **inside Hydra**:
|
||||
|
||||
```sh
|
||||
python3 hydra-os-update.pyz
|
||||
sudo python3 hydra-os-update.pyz --apply
|
||||
```
|
||||
|
||||
The first command only prints the file inventory. After applying, open the
|
||||
current Hydra recovery terminal with Ctrl+Alt+Shift+Escape, enter `:shell`, then
|
||||
run this command **inside that installed Hydra graphical session**:
|
||||
|
||||
```sh
|
||||
labwc --exit
|
||||
```
|
||||
|
||||
At the login screen, log in again as the same Hydra user. This restarts the
|
||||
long-running session process with the updated code. Use this from the recovery
|
||||
shell, not an SSH login or the outer host terminal. Reopening a terminal alone
|
||||
cannot reload that process. Running VMs remain independent of the graphical
|
||||
session.
|
||||
|
||||
To reverse a successful update, or finish recovery after a reported partial
|
||||
rollback, use the exact retained backup path printed by the updater. Correct
|
||||
the reported filesystem problem first. Inspect its records inside Hydra:
|
||||
|
||||
```sh
|
||||
backup='/var/lib/hydra/updates/REPLACE_WITH_THE_REPORTED_BACKUP'
|
||||
sudo python3 -m json.tool "$backup/backup.json"
|
||||
```
|
||||
|
||||
Process every listed path relative to `/`. For an entry with `"existed": true`,
|
||||
restore that path from the same relative location in the backup. For example,
|
||||
after confirming the CLI entry originally existed:
|
||||
|
||||
```sh
|
||||
relative='usr/lib/hydra/hydra/cli.py'
|
||||
sudo cp --no-dereference --remove-destination --preserve=mode,timestamps -- "$backup/$relative" "/$relative"
|
||||
```
|
||||
|
||||
Use the same operation for other originally existing files. When `"symlink"`
|
||||
is recorded, restore the symlink itself with this command; do not copy its
|
||||
referent. In particular, restoring `etc/os-release` must recreate its recorded
|
||||
vendor symlink while leaving `/usr/lib/os-release` intact. Check the restored
|
||||
link with `readlink /etc/os-release` and compare it to `backup.json`.
|
||||
|
||||
For each entry with `"existed": false`, remove only that exact installed file
|
||||
using `sudo rm -f -- /THE_RECORDED_RELATIVE_PATH`; no backup copy exists for it.
|
||||
This includes newly added modules, logos or the runtime-update receipt when
|
||||
listed as originally absent. Copying the backup directory over `/` alone will
|
||||
leave these additions behind. Confirm restored regular-file modes and SHA-256
|
||||
values against their records, retain the backup, then exit the Hydra graphical
|
||||
session and log in again as described above.
|
||||
|
||||
This bundle contains the Fastfetch configuration and logo, but no executable
|
||||
packages. If the old installation lacks Fastfetch, install it inside Hydra:
|
||||
|
||||
```sh
|
||||
sudo emerge --getbinpkg --noreplace app-misc/fastfetch
|
||||
```
|
||||
@@ -0,0 +1,120 @@
|
||||
# Hydra OS 0.2.0 — installation, updates and recovery
|
||||
|
||||
**The source suite passed 431 tests. The final ISO passed 13 whole-disk checks
|
||||
and 23 preserved-partition/recovery checks. No acceptance check was skipped.**
|
||||
|
||||
Hydra OS is a Gentoo host for concurrent virtual desktops, with a minimal labwc
|
||||
session and terminal controls. This release adds installation into selected
|
||||
partitions, signed runtime updates, offline Btrfs base recovery and read-only
|
||||
laptop diagnostics.
|
||||
|
||||
## Verification
|
||||
|
||||
| Item | Final result |
|
||||
| --- | --- |
|
||||
| Image and runtime source | `ebeebc7fb1aed07a5f50bc62374365d614953dad` |
|
||||
| ISO | `hydra-os.iso`, 2,380,406,784 bytes |
|
||||
| ISO SHA-256 | `451e058228d7cbea87b5301ae6502b6be609cf7f52cc7c9e6fb477822501389b` |
|
||||
| Signed runtime | Version `0.2.0`, sequence `1` |
|
||||
| Complete source suite | `431` passed in `26.048` seconds |
|
||||
| Whole-disk UEFI/KVM acceptance | 13 passed; 0 skipped |
|
||||
| Preserved-partition/recovery UEFI/KVM acceptance | 23 passed; 0 skipped |
|
||||
|
||||
Independent final integration review found no actionable issues and verified all
|
||||
19 signed runtime payload files against source bytes/modes and the pinned key.
|
||||
After publication, [public update verification](public-update-verification.json)
|
||||
discovered the signed release through the normal HTTPS path, checked it against
|
||||
the image's pinned key, and verified installation of all 19 files, CLI startup,
|
||||
exact rollback and replay rejection in a separate staging root.
|
||||
|
||||
The final [artifact record](artifacts.json), [whole-disk report](whole-disk-acceptance.json)
|
||||
and [laptop report](laptop-acceptance.json) identify the exact ISO, firmware and
|
||||
harnesses. The [image audit](image-audit.json) checks packaged files, including the
|
||||
independent updater and pinned key, against source hashes and required ownership
|
||||
and modes. The [rootfs manifest](rootfs-manifest.json) records source inputs and
|
||||
package versions; [Fastfetch](fastfetch.txt) captures the final installed guest.
|
||||
|
||||
Final acceptance uses fresh private virtual disks and the final ISO. Earlier
|
||||
diagnostic runs against an older ISO do not establish this release's acceptance.
|
||||
Both installed boot sequences remove ISO media. The laptop sequence checks GPT
|
||||
boundaries, foreign partition bytes and foreign/fallback EFI bytes across all
|
||||
boots. It checks BootOrder immediately around installation, checkpoint, restore
|
||||
and each deliberate boot-selection operation. OVMF may prune an empty DVD entry
|
||||
between boots; the report does not claim identical BootOrder across reboots.
|
||||
|
||||
## Installation and recovery
|
||||
|
||||
Existing-partition installation formats only the selected root partition and
|
||||
retains the existing ESP. It supports ext4 or Btrfs; Btrfs uses `@root`, `@home` and
|
||||
`@snapshots`. From the live ISO's `:shell`, inspect the devices and request a plan:
|
||||
|
||||
```sh
|
||||
sudo hydra-install --root-partition /dev/CHOSEN_ROOT \
|
||||
--esp-partition /dev/CHOSEN_ESP --user YOUR_USER --root-fs btrfs
|
||||
```
|
||||
|
||||
These are placeholders; no physical target partition has been selected. Execution
|
||||
requires `--execute --confirm-format /dev/CHOSEN_ROOT`. The ESP needs room for
|
||||
`EFI/HydraOS`; select its `grubx64.efi` loader in firmware, or explicitly request
|
||||
`--register-boot-entry` to create an entry without changing BootOrder. See the
|
||||
[installation guide](../../install.md).
|
||||
|
||||
Boot the trusted live ISO with the installed filesystems unmounted to create or
|
||||
restore a Btrfs checkpoint:
|
||||
|
||||
```sh
|
||||
sudo hydra-system checkpoint --root-uuid ROOT_UUID --esp-partuuid ESP_PARTUUID \
|
||||
--name before-update --execute --confirm-root-uuid ROOT_UUID
|
||||
sudo hydra-system list --root-uuid ROOT_UUID
|
||||
sudo hydra-system restore --root-uuid ROOT_UUID --checkpoint CHECKPOINT_ID \
|
||||
--execute --confirm-root-uuid ROOT_UUID
|
||||
```
|
||||
|
||||
Omit execution and confirmation options to inspect a plan first. Recovery restores
|
||||
the root, kernel, Portage state and owned EFI loader together. Current HOME and VM
|
||||
data stored there remain unchanged. An interrupted restore resumes with the same
|
||||
checkpoint ID. Acceptance upgrades a local Portage fixture, damages the actual
|
||||
kernel/owned loader, sends SIGKILL after a durable restore transition, resumes in
|
||||
a fresh process, then verifies exact restored base hashes and boots without ISO.
|
||||
This proves exact kernel restoration, not an upgrade between two kernel versions.
|
||||
|
||||
Checkpoints share the original disk and are not external backups. Restoring the
|
||||
root also restores runtime update receipts and their replay floor. See
|
||||
[base recovery](../../system-recovery.md).
|
||||
|
||||
## Updates and diagnostics
|
||||
|
||||
```sh
|
||||
hydra update check
|
||||
sudo hydra update apply
|
||||
hydra update status
|
||||
sudo hydra update rollback TRANSACTION
|
||||
sudo hydra-update-recover
|
||||
hydra doctor
|
||||
```
|
||||
|
||||
The [signed runtime manifest](../../../releases/stable/manifest.json) describes a
|
||||
passive payload verified against the image's independently pinned Ed25519 key.
|
||||
The public repository serves the release without a separate update server.
|
||||
The standalone recovery command works independently of a damaged normal CLI.
|
||||
Acceptance uses a disposable TEST key in its private guest, refuses a tampered
|
||||
archive, and verifies apply, rollback and fresh-process recovery after SIGKILL.
|
||||
The original pinned key is restored afterward.
|
||||
|
||||
The payload updates Hydra runtime and branding. Gentoo packages/kernel, networking,
|
||||
guest data, the updater engine and its trust key are outside its scope. Log out
|
||||
and in after applying or rolling back to load changed session code. Older images
|
||||
without the trusted updater need a verified new image or a separately verified
|
||||
bootstrap. See [update scope and trust](../../updates.md).
|
||||
|
||||
`hydra doctor` reports observations without changing settings. Physical ASUS
|
||||
FX517ZC checks remain pending: live boot, Wi-Fi/Ethernet, audio playback, touchpad
|
||||
and input capture, brightness, USB, suspend/resume and delivered display refresh.
|
||||
The first image requires Secure Boot disabled. No physical disk, existing NixOS
|
||||
installation or existing user VM disk was an installation target. VM acceptance
|
||||
does not establish physical graphics performance or audio playback.
|
||||
|
||||
See [acceptance instructions](../../laptop-acceptance.md) to repeat both harnesses
|
||||
with the recorded source, ISO and firmware inputs. ISO files, test disks, private
|
||||
build records and raw logs remain outside Git; this directory contains the
|
||||
public verification record.
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"product": "Hydra OS",
|
||||
"version": "0.2.0",
|
||||
"tag": "0.2.0",
|
||||
"source_commit": "ebeebc7fb1aed07a5f50bc62374365d614953dad",
|
||||
"iso": {
|
||||
"name": "hydra-os.iso",
|
||||
"size": 2380406784,
|
||||
"sha256": "451e058228d7cbea87b5301ae6502b6be609cf7f52cc7c9e6fb477822501389b"
|
||||
},
|
||||
"runtime": {
|
||||
"version": "0.2.0",
|
||||
"sequence": 1,
|
||||
"payload_files": 19,
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "manifest.json",
|
||||
"size": 2831,
|
||||
"sha256": "622eadd77d5eb18ba80d81f60f6d7ba4546ce8a4ba276152cf0b2b38d1c4ba03"
|
||||
},
|
||||
{
|
||||
"name": "manifest.sig",
|
||||
"size": 64,
|
||||
"sha256": "4102a9f1d0f1ac00e848c00a9a37644647789a3e20b70aa41f1fd4a7e7a6974a"
|
||||
},
|
||||
{
|
||||
"name": "runtime.zip",
|
||||
"size": 71577,
|
||||
"sha256": "9284edd5de37f621955189f3d317ebcfa76f60a4b6e1f481361b6aad1967f4f8"
|
||||
}
|
||||
],
|
||||
"public_key_der_sha256": "fb08fd4892c5e4de0dbda82ef43f911a423a24d0f82bdf70f7b6fcaeb1d9ad51"
|
||||
},
|
||||
"kernel": "6.18.54-gentoo-dist-bin",
|
||||
"packages": 581,
|
||||
"verification": {
|
||||
"source_suite": {
|
||||
"passed": 431,
|
||||
"failed": 0,
|
||||
"seconds": 26.048
|
||||
},
|
||||
"whole_disk": {
|
||||
"passed": 13,
|
||||
"skipped": 0
|
||||
},
|
||||
"laptop": {
|
||||
"passed": 23,
|
||||
"skipped": 0
|
||||
},
|
||||
"physical_hardware": "pending"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"name": "whole-disk-acceptance.json",
|
||||
"size": 6797,
|
||||
"sha256": "dee6f6736a204126ff9594a258f14fe79b0edb7b24129afa5519ba5549fa8187"
|
||||
},
|
||||
{
|
||||
"name": "laptop-acceptance.json",
|
||||
"size": 181161,
|
||||
"sha256": "15e4ccbef7696740debdf53b5c942cdc897d89467fb5583a365c143b3fd4e4a7"
|
||||
},
|
||||
{
|
||||
"name": "image-audit.json",
|
||||
"size": 5146,
|
||||
"sha256": "697cd71dab2b4a8e299f9fcbfe133c418efb712e92b8bcd459455e18c2249024"
|
||||
},
|
||||
{
|
||||
"name": "fastfetch.txt",
|
||||
"size": 1132,
|
||||
"sha256": "3e7f54fbd70a46dd8c655f5ff10e2afbbeac70608efdea12b3822a3a79567a9b"
|
||||
},
|
||||
{
|
||||
"name": "rootfs-manifest.json",
|
||||
"size": 142178,
|
||||
"sha256": "126c66d84b7c2160a517cbd0c3649630982a4d734e113ecdd6ee48e0859f1630"
|
||||
},
|
||||
{
|
||||
"name": "public-update-verification.json",
|
||||
"size": 665,
|
||||
"sha256": "7d5df66c6f13b1638abf0de9e280338755520b2eb020f54d33a4a19d6731746b"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
= = hydratest@hydra-os
|
||||
=@ @= OS: Hydra OS x86_64
|
||||
:@+ :: +@: Kernel: Linux 6.18.54-gentoo-dist-bin
|
||||
:-*@%*@@*%@#-: Uptime: 7 seconds
|
||||
. +: -@@@@@@@@@@- :+ . CPU: 12th Gen Intel(R) Core(TM) i7-12650H (2) @ 2.69 GHz
|
||||
=*:+#= =@*+@@@@+*@= =#+-*= Memory: 425.80 MiB / 3.80 GiB (11%)
|
||||
#@#@@##= .%@=*@@*=@%. =##@@#@# Disk (/): 7.11 GiB / 30.34 GiB (23%) - ext4
|
||||
#@@@@@@@@#=. .@@@@@@. .=#@@@@@@@@# Local IP (enp0s2): 10.0.2.15/24
|
||||
.%%=:%@@@@@@@*: +%@@%+ :*@@@@@@@%:=%%.
|
||||
.#@##@@@%*@@@@#: =@@= :#@@@@*%@@@##@#.
|
||||
@%@%=-%@# .*@@@@. .%==%. .@@@@*. #@%-=%@%@
|
||||
=%-:-%+ #@@@# .@@@@. #@@@# +%-:=%+
|
||||
-%- @@@@% =@@@@= %@@@@ -%-
|
||||
.%@@@@. @@@@@@ .@@@@%.
|
||||
-@@@@@: *%@@@@%* :@@@@@-
|
||||
.@@@@@* .-%@@@@%-. *@@@@@.
|
||||
*@@@@@%:+@@@@@@@@+:%@@@@@*
|
||||
=#%@@@@@@@@@@@@@@@@@@@@%#=
|
||||
.*@@@@@@@@@@@@@@@@@@*.
|
||||
.:-=+*#@@#*+=-:.
|
||||
|
||||
H Y D R A O S
|
||||
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"files": {
|
||||
"usr/lib/hydra/hydra/__init__.py": {
|
||||
"sha256": "2775bedc6bc41a09d8a492e99a38ce147730d979867316a672f0049f055c76a1",
|
||||
"mode": 420,
|
||||
"uid": 0,
|
||||
"gid": 0
|
||||
},
|
||||
"usr/lib/hydra/hydra/build.py": {
|
||||
"sha256": "7d130a0ce0eed8913d8402c28824b1dff139d33c6482d9616a01d30f96cd65ac",
|
||||
"mode": 420,
|
||||
"uid": 0,
|
||||
"gid": 0
|
||||
},
|
||||
"usr/lib/hydra/hydra/cli.py": {
|
||||
"sha256": "a9399544462279af97542de068907f6f2adb634874648338bdb6094606c03c78",
|
||||
"mode": 420,
|
||||
"uid": 0,
|
||||
"gid": 0
|
||||
},
|
||||
"usr/lib/hydra/hydra/doctor.py": {
|
||||
"sha256": "9d759958c48b56a5f58acaab301c8c6f49c3a6c2ea7d434ee7d17bc420ba51c2",
|
||||
"mode": 420,
|
||||
"uid": 0,
|
||||
"gid": 0
|
||||
},
|
||||
"usr/lib/hydra/hydra/image.py": {
|
||||
"sha256": "b4b77ad87ceba929ed39bee2b02852682718788dacbbbb550ee1a50c216e8070",
|
||||
"mode": 420,
|
||||
"uid": 0,
|
||||
"gid": 0
|
||||
},
|
||||
"usr/lib/hydra/hydra/install_storage.py": {
|
||||
"sha256": "3c59d5f295d6bdb86e6decbae668db67cc2f2d0b8c6abe49ef07f35d8aef7aee",
|
||||
"mode": 420,
|
||||
"uid": 0,
|
||||
"gid": 0
|
||||
},
|
||||
"usr/lib/hydra/hydra/installer.py": {
|
||||
"sha256": "3d038216c37e248db5ad8a76863663d701d5ef497cb5be52658c9382c92f0da5",
|
||||
"mode": 420,
|
||||
"uid": 0,
|
||||
"gid": 0
|
||||
},
|
||||
"usr/lib/hydra/hydra/launch.py": {
|
||||
"sha256": "e9e40af8cfb2aff39306735e6ad70c91d7f93cb01d1451daecbbebda8362c4b0",
|
||||
"mode": 420,
|
||||
"uid": 0,
|
||||
"gid": 0
|
||||
},
|
||||
"usr/lib/hydra/hydra/media.py": {
|
||||
"sha256": "4c85798a81e767605e8e0a1cd0eb21062f625c49dd71df511b2b67db375d95af",
|
||||
"mode": 420,
|
||||
"uid": 0,
|
||||
"gid": 0
|
||||
},
|
||||
"usr/lib/hydra/hydra/menu.py": {
|
||||
"sha256": "ba99dcc9ed33543d54ad61d13b6c5f02c2d4c6d60f6c2cf9c5ceeb0634a88f5c",
|
||||
"mode": 420,
|
||||
"uid": 0,
|
||||
"gid": 0
|
||||
},
|
||||
"usr/lib/hydra/hydra/profiles.py": {
|
||||
"sha256": "7e3074086172bfb1ebde89996a2ca9be4512250e667e1375b9086d7148f947c3",
|
||||
"mode": 420,
|
||||
"uid": 0,
|
||||
"gid": 0
|
||||
},
|
||||
"usr/lib/hydra/hydra/qmp.py": {
|
||||
"sha256": "e86da77103b991cf7f0104e701555ebbc91e2b526d6b7ec6db76209f264af024",
|
||||
"mode": 420,
|
||||
"uid": 0,
|
||||
"gid": 0
|
||||
},
|
||||
"usr/lib/hydra/hydra/runtime.py": {
|
||||
"sha256": "d72353ebd675135801a3686a69ee8a193b87d5787f691917a46c99de0555d5d3",
|
||||
"mode": 420,
|
||||
"uid": 0,
|
||||
"gid": 0
|
||||
},
|
||||
"usr/lib/hydra/hydra/session.py": {
|
||||
"sha256": "729d6cd263f9828850018b335917a15f03d2a2bda0e38e73f842d3cfc196db1d",
|
||||
"mode": 420,
|
||||
"uid": 0,
|
||||
"gid": 0
|
||||
},
|
||||
"usr/lib/hydra/hydra/system_recovery.py": {
|
||||
"sha256": "957d9b8ab422b446f1f7c0f922c1629a0728e0043bd0a3ade36495f4200d5c05",
|
||||
"mode": 420,
|
||||
"uid": 0,
|
||||
"gid": 0
|
||||
},
|
||||
"usr/lib/hydra/hydra/update_engine.py": {
|
||||
"sha256": "30e4175d9ab894d9ee0cfb825ff43bb376e82857241076554335a9c64e5e9258",
|
||||
"mode": 420,
|
||||
"uid": 0,
|
||||
"gid": 0
|
||||
},
|
||||
"usr/lib/hydra-update/engine.py": {
|
||||
"sha256": "30e4175d9ab894d9ee0cfb825ff43bb376e82857241076554335a9c64e5e9258",
|
||||
"mode": 420,
|
||||
"uid": 0,
|
||||
"gid": 0
|
||||
},
|
||||
"usr/bin/hydra-update": {
|
||||
"sha256": "faaf0c932e6d8009095c7d7c49c3b09eff9563c8537ae0944fc6fbd7daae7ec8",
|
||||
"mode": 493,
|
||||
"uid": 0,
|
||||
"gid": 0
|
||||
},
|
||||
"usr/bin/hydra-update-recover": {
|
||||
"sha256": "b5a66ce468f022c170276de4add3970b22c3bff8caf126dc23e85d655d482acd",
|
||||
"mode": 493,
|
||||
"uid": 0,
|
||||
"gid": 0
|
||||
},
|
||||
"etc/hydra/update.pub": {
|
||||
"sha256": "42cc72480eefa9ee1e5250451c16750710af4b45804e5c821b359780a7f2bed3",
|
||||
"mode": 420,
|
||||
"uid": 0,
|
||||
"gid": 0
|
||||
},
|
||||
"usr/share/hydra/fastfetch.txt": {
|
||||
"sha256": "a0c67cfbc66a3097fb3ac9b8ec16f75a8b59d8a8994ec43db144426fc089fa67",
|
||||
"mode": 420,
|
||||
"uid": 0,
|
||||
"gid": 0
|
||||
},
|
||||
"etc/fastfetch/config.jsonc": {
|
||||
"sha256": "2af12ce007705f039870867c89b0fc6f90bcf6c6b29c04b63ab75f345419b1a1",
|
||||
"mode": 420,
|
||||
"uid": 0,
|
||||
"gid": 0
|
||||
}
|
||||
},
|
||||
"builder_trust_and_state_absent": true,
|
||||
"managed_root_tmpfiles": {
|
||||
"home.conf": {
|
||||
"vendor_sha256": "fcc7434662a40ed081eec2c608dd1a3ac8c909440359a7890c08175fb685ff68",
|
||||
"override_sha256": "81cfa05bb475bf605d9fbdb2245705761b8f997727dac478b25b6babece34ee7",
|
||||
"ordinary_directory": "/srv"
|
||||
},
|
||||
"systemd-nspawn.conf": {
|
||||
"vendor_sha256": "29464830fe7050c98670a1560d76df2672ea1792e5d4ba00d13e16a15801808c",
|
||||
"override_sha256": "85b74fa729d312f25d34cd2b8b300e53305ab15dcce8417c2eb05c224c46410d",
|
||||
"ordinary_directory": "/var/lib/machines"
|
||||
},
|
||||
"portables.conf": {
|
||||
"vendor_sha256": "00c398c1f82a0908f1cb45ce482dafcd99c2287a9432acdb385cc9d9a27cdd43",
|
||||
"override_sha256": "910bdf0ceba6def64116bfcfbfa851d5934b4a62e0dbbaa74db19e21c00a7a69",
|
||||
"ordinary_directory": "/var/lib/portables"
|
||||
}
|
||||
},
|
||||
"sanitization": {
|
||||
"files_scanned": 581,
|
||||
"filtered_files": 41,
|
||||
"variable_names": [
|
||||
"HTTPS_PROXY",
|
||||
"HTTP_PROXY",
|
||||
"NO_PROXY"
|
||||
]
|
||||
},
|
||||
"source_commit": "ebeebc7fb1aed07a5f50bc62374365d614953dad",
|
||||
"iso_sha256": "451e058228d7cbea87b5301ae6502b6be609cf7f52cc7c9e6fb477822501389b"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"source_commit": "ebeebc7fb1aed07a5f50bc62374365d614953dad",
|
||||
"version": "0.2.0",
|
||||
"sequence": 1,
|
||||
"archive_url": "https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/runtime.zip",
|
||||
"archive_sha256": "9284edd5de37f621955189f3d317ebcfa76f60a4b6e1f481361b6aad1967f4f8",
|
||||
"public_artifacts_match_local_signed_candidate": true,
|
||||
"signature_verified_against_image_pinned_key": true,
|
||||
"staging_apply_all_files_and_modes": 19,
|
||||
"staging_cli_help": "passed",
|
||||
"staging_rollback_exact_originals": true,
|
||||
"staging_replay_refused": true,
|
||||
"actual_system_mutated": false,
|
||||
"recorded_utc": "2026-10-13T20:51:13Z"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,189 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"status": "passed",
|
||||
"iso_sha256": "451e058228d7cbea87b5301ae6502b6be609cf7f52cc7c9e6fb477822501389b",
|
||||
"physical_hardware": "pending",
|
||||
"nested_scope": "Normal-user KVM VM launch, bridge helper and QMP; no nested guest OS boot",
|
||||
"ovmf_code_sha256": "f3ff7e73448ed2845ee15356f394882f5618eb5dab92c9a30ec6ee0e1468553a",
|
||||
"ovmf_vars_input_sha256": "6ed987af3a3c155be71665f510eae3e007eda9b8b94afd59d45e91c4a11565cc",
|
||||
"completed_utc": "2026-10-13T20:39:59Z",
|
||||
"iso": "hydra-os.iso",
|
||||
"source_commit": "ebeebc7fb1aed07a5f50bc62374365d614953dad",
|
||||
"raw_report_sha256": "2ded3055b6999a3336ead6558bc8e7c134588c9353c5cd202c1116b881f43db1",
|
||||
"harness_sha256": "a56b59b0259282e5b2beaa0a2ed7cb357f45d6c9bba29640aa4831296791ddff",
|
||||
"memory_mib": 4096,
|
||||
"checks": {
|
||||
"live_qmp_kvm": {
|
||||
"status": "passed",
|
||||
"output_sha256": "73741bf0ae47c0d63e9e39bde651b5d15449d680658328b222ba2f837c84a1e3",
|
||||
"evidence": {
|
||||
"enabled": true,
|
||||
"present": true
|
||||
}
|
||||
},
|
||||
"live_boot": {
|
||||
"status": "passed",
|
||||
"output_sha256": "2595ba19b77bbbedac87fa974e237edff158512d7ea0ecfe42d32e431bfa242d",
|
||||
"result": "/usr/bin/hydra\n/usr/bin/hydra-install\n/usr/bin/qemu-system-x86_64\n/usr/bin/qemu-img\n/usr/bin/remote-viewer\n/usr/bin/foot\n/usr/bin/labwc\nactive\nactive\nactive"
|
||||
},
|
||||
"real_installer": {
|
||||
"status": "passed",
|
||||
"output_sha256": "56876246904dce94ed1ac584883403c529267de15dc9918dcc96d4089b66a6d4",
|
||||
"result": "Installer completed via guest controlling TTY"
|
||||
},
|
||||
"live_poweroff": {
|
||||
"status": "passed",
|
||||
"output_sha256": "bfd9b9dfb2690be661de5e9d37e2272dc9c5a10fbd6e5d8ba10842e63329bab7",
|
||||
"result": "QEMU exited before ISO-free reboot"
|
||||
},
|
||||
"installed_qmp_kvm": {
|
||||
"status": "passed",
|
||||
"output_sha256": "73741bf0ae47c0d63e9e39bde651b5d15449d680658328b222ba2f837c84a1e3",
|
||||
"evidence": {
|
||||
"enabled": true,
|
||||
"present": true
|
||||
}
|
||||
},
|
||||
"installed_password_login": {
|
||||
"status": "passed",
|
||||
"output_sha256": "2c32717f3fc0bb7f06629b8260f933c4f35d83ae585775835c746b279c4750b1",
|
||||
"result": "hydratest"
|
||||
},
|
||||
"installed_terminal_ux": {
|
||||
"status": "passed",
|
||||
"output_sha256": "119506083ea62d990dcf6af8994add93cd09b34feded939fa867b3398c879123",
|
||||
"evidence": {
|
||||
"status": "passed",
|
||||
"uid": 1000,
|
||||
"normal_user": "hydratest",
|
||||
"launcher": "/usr/bin/hydra",
|
||||
"fixture_bytes": 43008,
|
||||
"iso_sha256": "aa66329c263f560f514c00a71551490dd6593ee7632404e072584e2803e0a1c7",
|
||||
"cache_reused": true,
|
||||
"http_requests": [
|
||||
"/fixture.iso"
|
||||
],
|
||||
"before": {
|
||||
"name": "ux-d7b31c1e",
|
||||
"uuid": "b58b8996-8743-54ff-a362-42c847a37bd3",
|
||||
"pid": 707,
|
||||
"process_start": "749",
|
||||
"kvm": true
|
||||
},
|
||||
"after_open": {
|
||||
"name": "ux-d7b31c1e",
|
||||
"uuid": "b58b8996-8743-54ff-a362-42c847a37bd3",
|
||||
"pid": 707,
|
||||
"process_start": "749",
|
||||
"kvm": true
|
||||
},
|
||||
"after_menu": {
|
||||
"name": "ux-d7b31c1e",
|
||||
"uuid": "b58b8996-8743-54ff-a362-42c847a37bd3",
|
||||
"pid": 707,
|
||||
"process_start": "749",
|
||||
"kvm": true
|
||||
},
|
||||
"memory_mib": 512,
|
||||
"cpus": 1,
|
||||
"disk_size": "64M",
|
||||
"network": "bridge",
|
||||
"fastfetch_command": [
|
||||
"fastfetch",
|
||||
"--pipe",
|
||||
"false",
|
||||
"--show-errors"
|
||||
],
|
||||
"fastfetch_default_config_sha256": "2af12ce007705f039870867c89b0fc6f90bcf6c6b29c04b63ab75f345419b1a1",
|
||||
"fastfetch_logo_sha256": "a0c67cfbc66a3097fb3ac9b8ec16f75a8b59d8a8994ec43db144426fc089fa67",
|
||||
"scope": "Packaged installed CLI, cache, branding and live nested QMP/KVM; nonboot ISO fixture, no nested OS boot or performance claim",
|
||||
"cleanup": "Only the verified private fixture VM stopped through hydra stop --force"
|
||||
}
|
||||
},
|
||||
"disk_persistence": {
|
||||
"status": "passed",
|
||||
"output_sha256": "365e772a615be1727b4898bd10c984dc672790aaa617da6444d6281d5b2796be",
|
||||
"result": "Sentinel content verified from command output frame"
|
||||
},
|
||||
"installed_contract": {
|
||||
"status": "passed",
|
||||
"output_sha256": "2b1b0972acbc56b5b6577ebbed2237dc6c285a35b4199a83f1cf9f8f6d6ccf1b",
|
||||
"result": "All assertions in the recorded unchanged harness passed."
|
||||
},
|
||||
"lan_to_vm_tcp": {
|
||||
"status": "passed",
|
||||
"output_sha256": "39e841582e2fae2337515f751e47ce7aea62a04b8d0c81dcf54a0b31d1aee4cd",
|
||||
"result": "PASS: routed new LAN-to-VM TCP connection on arbitrary port 43117"
|
||||
},
|
||||
"nested_kvm": {
|
||||
"status": "passed",
|
||||
"output_sha256": "6ed0138e29e02b7a80d02229febc1eaeb0a91771fb620c9772471f45d0da5317",
|
||||
"result": "All assertions in the recorded unchanged harness passed."
|
||||
},
|
||||
"installed_user_logout": {
|
||||
"status": "passed",
|
||||
"output_sha256": "1f7d9f7fb23265d5cb9b7fbd9b01d9a457527b4017e3aff0b1a4651d5a1ba9cb",
|
||||
"evidence": {
|
||||
"status": "passed",
|
||||
"real_logout": true,
|
||||
"no_open_logins": true,
|
||||
"survival_seconds": 15,
|
||||
"same_vm": {
|
||||
"name": "logout-9587e00e",
|
||||
"uuid": "ed93cd51-4e26-5e1a-ae88-2b34ed2eb665",
|
||||
"pid": 982,
|
||||
"process_start": "1013",
|
||||
"directories": {
|
||||
"/run/user/1000/hydra-logout-9587e00e": [
|
||||
46,
|
||||
52,
|
||||
16832
|
||||
],
|
||||
"/run/user/1000/hydra-logout-9587e00e/logout-9587e00e": [
|
||||
46,
|
||||
53,
|
||||
16832
|
||||
]
|
||||
},
|
||||
"sockets": {
|
||||
"qmp": [
|
||||
46,
|
||||
57,
|
||||
1000,
|
||||
49640
|
||||
],
|
||||
"spice": [
|
||||
46,
|
||||
56,
|
||||
1000,
|
||||
49640
|
||||
],
|
||||
"pipewire": [
|
||||
46,
|
||||
28,
|
||||
1000,
|
||||
49590
|
||||
],
|
||||
"pulse": [
|
||||
46,
|
||||
27,
|
||||
1000,
|
||||
49590
|
||||
]
|
||||
},
|
||||
"audio_services": {
|
||||
"pipewire.service": "InvocationID=8d86c20efc8f48bf803994159b1b5335\nExecMainPID=954\n",
|
||||
"pipewire-pulse.service": "InvocationID=5d452f74172b472c8fad2b998316d7bd\nExecMainPID=956\n",
|
||||
"wireplumber.service": "InvocationID=9b92a86d9d3c47e08069ea7842191da5\nExecMainPID=955\n"
|
||||
}
|
||||
},
|
||||
"audio_scope": "Socket and service continuity only; signal/playback untested"
|
||||
}
|
||||
},
|
||||
"installed_poweroff": {
|
||||
"status": "passed",
|
||||
"output_sha256": "047a24b28a9faa96e7864cc9e6a0621210985e702324326b250028aad6fb15a9",
|
||||
"result": "Normal installed guest shutdown"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# Hydra OS terminal UX preview
|
||||
|
||||
Source revision: `1bc7965ccaddb76abc78aafa82bb59358cf4dc0e`.
|
||||
|
||||
## Changes
|
||||
|
||||
- Terminal VM menu accepts a number, name, or the remembered selection. Opening a stopped guest starts it explicitly; opening a live guest reconnects its screen. The selected VM receives the initial workspace after refresh, while other QEMU processes continue independently.
|
||||
- `hydra run URL --name NAME` downloads an ISO, creates a new guest and opens it. `hydra open NAME` reopens it; `hydra download URL` only downloads. Guided creation honors the same `--defaults` as direct CLI creation.
|
||||
- Downloads show progress, support cancellation and optional publisher SHA-256, validate ISO9660 declared extents, and preserve existing files/profiles. Pure UDF images are explicitly unsupported by this initial downloader; ISO9660/UDF hybrids are accepted through ISO9660 validation. A format check does not establish publisher authenticity or arbitrary guest OS bootability.
|
||||
- Visible system, boot, installer and viewer labels use **Hydra OS**. Fastfetch includes an original three-headed text logo and a concise host summary.
|
||||
- A separate self-contained developer update bundle supports dry-run, exact checksums, backups and best-effort exception rollback. It does not replace guest disks, profiles, kernel or networking.
|
||||
|
||||
## Verification
|
||||
|
||||
- Complete source suite: **293 tests passed**, 44.788 seconds.
|
||||
- Private two-guest focus probe: selecting Beta opens workspace 2; fresh keyboard focus, unchanged PID/UUID and continuing hidden counters survive refresh and compositor exit.
|
||||
- Private general graphics/input smoke: reserved switching while keybindings are inhibited, manual USB selector without automatic attachment, view reconnection and media lifecycle passed. Requested display mode is 1920×1080 at 144000 mHz; physical refresh/input behavior is not inferred from that request.
|
||||
- Independent task and final branch reviews completed; ISO extent validation, partial rollback reporting, shortcut help and custom menu defaults findings were resolved. No open review findings remain.
|
||||
- The final full update zipapp was exercised against a disposable installed-root fixture: dry-run, apply, all installed hashes, retained original metadata and relocated CLI passed. The existing user installation has not been updated.
|
||||
|
||||
Rebuilt ISO boot/install acceptance: **all 13 checks passed**. The [complete report](acceptance.json) identifies the exact ISO and firmware. It includes real installation, ISO-free boot, password login, persistent data, the packaged terminal UX and Fastfetch, routed LAN-to-VM TCP, nested KVM, VM/socket survival through actual logout and normal shutdown. No check was skipped. The [Fastfetch capture](fastfetch.txt) comes from the installed test guest's default configuration.
|
||||
|
||||
The previous preview record at `../2026-10-13/` describes only the earlier ISO and is unchanged.
|
||||
|
||||
## Delivery and decisions
|
||||
|
||||
ISO: `hydra-os.iso`, 2,345,932,800 bytes. SHA-256:
|
||||
|
||||
```text
|
||||
1ed2cc79482ac5b14794ed6d4c4caf46ecc54af6457c5d38bc662cd3c104fbad
|
||||
```
|
||||
|
||||
Runtime update: `hydra-os-update-1bc7965.pyz`, 56,425 bytes. SHA-256:
|
||||
|
||||
```text
|
||||
3ed9288451894277797aa34f105f1969af0a892b1ec14dc27b1fadeed2a7e7f3
|
||||
```
|
||||
|
||||
See [terminal usage](../../terminal-usage.md) for applying it inside an installed Hydra guest, logging out and in again, installing Fastfetch on older images, and manually reversing an update. The bundle includes Fastfetch configuration/artwork, not its executable package.
|
||||
|
||||
The derivative OS identity is `ID=hydra-os`, `ID_LIKE=gentoo`; vendor `/usr/lib/os-release` remains intact. Consumers matching only `ID=gentoo` need to consider `ID_LIKE`. Technical hostname, bridge, storage paths and EFI bootloader identifier remain compatible.
|
||||
|
||||
The downloader deliberately rejects pure UDF because it cannot verify its declared extent with the implemented validator. Such media needs a supported alternative or a separate manual download/lower-level workflow. Publisher hashes remain advisable for whole-image integrity.
|
||||
|
||||
Physical ASUS graphics, touchpad, brightness keys, Wi-Fi, suspend, audio playback and actual USB-device redirection remain hardware checks. The updater is a trusted-source developer tool with backups, not a signed package manager or a guarantee of multi-file atomicity across power loss. No command channel into the existing manual test VM was available; staged delivery evidence does not mean that VM was updated.
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"status": "passed",
|
||||
"scratch": "/home/workspaces/ubuntu-dev/home/.cache/hydra-tests/hydra-iso-smoke-rjtq89i8",
|
||||
"iso": "/home/workspaces/ubuntu-dev/home/Projects/hydra-os/build/artifacts/hydra-os.iso",
|
||||
"iso_sha256": "1ed2cc79482ac5b14794ed6d4c4caf46ecc54af6457c5d38bc662cd3c104fbad",
|
||||
"checks": {
|
||||
"live_qmp_kvm": {
|
||||
"status": "passed",
|
||||
"output": "{\"enabled\": true, \"present\": true}"
|
||||
},
|
||||
"live_boot": {
|
||||
"status": "passed",
|
||||
"output": "/usr/bin/hydra\n/usr/bin/hydra-install\n/usr/bin/qemu-system-x86_64\n/usr/bin/qemu-img\n/usr/bin/remote-viewer\n/usr/bin/foot\n/usr/bin/labwc\nactive\nactive\nactive\n"
|
||||
},
|
||||
"real_installer": {
|
||||
"status": "passed",
|
||||
"output": "Installer completed via guest controlling TTY"
|
||||
},
|
||||
"live_poweroff": {
|
||||
"status": "passed",
|
||||
"output": "QEMU exited before ISO-free reboot"
|
||||
},
|
||||
"installed_qmp_kvm": {
|
||||
"status": "passed",
|
||||
"output": "{\"enabled\": true, \"present\": true}"
|
||||
},
|
||||
"installed_password_login": {
|
||||
"status": "passed",
|
||||
"output": "hydratest\n"
|
||||
},
|
||||
"installed_terminal_ux": {
|
||||
"status": "passed",
|
||||
"output": "{\"status\": \"passed\", \"uid\": 1000, \"normal_user\": \"hydratest\", \"launcher\": \"/usr/bin/hydra\", \"fixture_bytes\": 43008, \"iso_sha256\": \"aa66329c263f560f514c00a71551490dd6593ee7632404e072584e2803e0a1c7\", \"cache_reused\": true, \"http_requests\": [\"/fixture.iso\"], \"before\": {\"name\": \"ux-407c8d94\", \"uuid\": \"708c7761-5d45-540b-9e50-ecbccab235dd\", \"pid\": 664, \"process_start\": \"1365\", \"kvm\": true}, \"after_open\": {\"name\": \"ux-407c8d94\", \"uuid\": \"708c7761-5d45-540b-9e50-ecbccab235dd\", \"pid\": 664, \"process_start\": \"1365\", \"kvm\": true}, \"after_menu\": {\"name\": \"ux-407c8d94\", \"uuid\": \"708c7761-5d45-540b-9e50-ecbccab235dd\", \"pid\": 664, \"process_start\": \"1365\", \"kvm\": true}, \"memory_mib\": 512, \"cpus\": 1, \"disk_size\": \"64M\", \"network\": \"bridge\", \"empty_menu\": \"\\nHydra OS \\u2014 virtual machines\\n\\nNo VM profiles. Use :run to create one from an ISO path or direct URL.\\n\\nNumber/name opens one VM; Enter opens the last (*) or only VM.\\n:run / :new :download :network :shell :help :refresh :quit\\n:shutdown NAME requests guest shutdown; :detach NAME removes ISO while stopped.\\nhydra> \", \"running_menu\": \"\\nHydra OS \\u2014 virtual machines\\n\\n # Name State RAM\\n 1 ux-407c8d94 running 512 MiB\\n\\nNumber/name opens one VM; Enter opens the last (*) or only VM.\\n:run / :new :download :network :shell :help :refresh :quit\\n:shutdown NAME requests guest shutdown; :detach NAME removes ISO while stopped.\\nhydra> \", \"run_output\": \"\", \"download_output\": \"/tmp/hux-cov4pymn/cache/hydra/iso/4341afe870c3591f6581e2c0c00554e8876cfe2f669a6208636261a2f6b069ee-fixture.iso\\n\", \"fastfetch_command\": [\"fastfetch\", \"--pipe\", \"false\", \"--show-errors\"], \"fastfetch_default_config_sha256\": \"2af12ce007705f039870867c89b0fc6f90bcf6c6b29c04b63ab75f345419b1a1\", \"fastfetch_logo_sha256\": \"ddd6d11f785aac99591fbcc5959e9f553287514459832131c70e48525ad76868\", \"fastfetch_output\": \" /\\\\ /\\\\ /\\\\ hydratest@hydra-os\\n .-(o > .-(o > .-(o > OS: Hydra OS x86_64\\n/ / / / / / Kernel: Linux 6.18.54-gentoo-dist-bin\\n\\\\ \\\\______\\\\ \\\\______\\\\ \\\\ Uptime: 12 seconds\\n \\\\ \\\\ / CPU: 12th Gen Intel(R) Core(TM) i7-12650H (2) @ 2.69 GHz\\n '---. .---' Memory: 426.05 MiB / 3.80 GiB (11%)\\n \\\\ /\\\\/\\\\ / Disk (/): 6.98 GiB / 30.34 GiB (23%) - ext4\\n ___\\\\/ \\\\/___ Local IP (enp0s2): 10.0.2.15/24\\n /______________\\\\\\n H Y D R A O S\\n\\n\", \"scope\": \"Packaged installed CLI, cache, branding and live nested QMP/KVM; nonboot ISO fixture, no nested OS boot or performance claim\", \"cleanup\": \"Only the verified private fixture VM stopped through hydra stop --force\"}\n"
|
||||
},
|
||||
"disk_persistence": {
|
||||
"status": "passed",
|
||||
"output": "Sentinel content verified from command output frame"
|
||||
},
|
||||
"installed_contract": {
|
||||
"status": "passed",
|
||||
"output": "active\nactive\nactive\nactive\n3: hydrabr0 inet 10.203.0.1/24 brd 10.203.0.255 scope global noprefixroute hydrabr0\\ valid_lft forever preferred_lft forever\ndefault via 10.0.2.2 dev enp0s2 proto dhcp src 10.0.2.15 metric 100 \ntable ip hydra_nat {\n\tchain postrouting {\n\t\ttype nat hook postrouting priority srcnat; policy accept;\n\t\tip saddr 10.203.0.0/24 oifname != \"hydrabr0\" masquerade\n\t}\n}\nconnected\n198.18.0.41 STREAM gentoo.org\n198.18.0.41 DGRAM \n198.18.0.41 RAW \n/usr/bin/hydra\n/usr/bin/hydra-install\n/usr/bin/qemu-system-x86_64\n/usr/bin/qemu-img\n/usr/bin/remote-viewer\n/usr/bin/foot\n/usr/bin/labwc\n\u001b[1;34musage: \u001b[0m\u001b[1;35mhydra\u001b[0m [\u001b[32m-h\u001b[0m] [\u001b[36m--config \u001b[33mCONFIG\u001b[0m] [\u001b[36m--state \u001b[33mSTATE\u001b[0m] [\u001b[36m--runtime \u001b[33mRUNTIME\u001b[0m]\n [\u001b[36m--defaults \u001b[33mDEFAULTS\u001b[0m]\n \u001b[32m{menu,download,run,open,add,media,list,status,start,shutdown,stop,view,session,_session-helper} ...\u001b[0m\n\nHydra OS independent VM manager\n\n\u001b[1;34mpositional arguments:\u001b[0m\n \u001b[1;32m{menu,download,run,open,add,media,list,status,start,shutdown,stop,view,session,_session-helper}\u001b[0m\n \u001b[1;32mmenu\u001b[0m Open the terminal VM menu\n \u001b[1;32mdownload\u001b[0m Download and validate a direct ISO URL\n \u001b[1;32mrun\u001b[0m Create, start and display a VM from a local ISO or\n HTTP(S) URL\n \u001b[1;32mopen\u001b[0m Start a stopped VM or reconnect a running VM\n \u001b[1;32madd\u001b[0m Create a VM profile without starting it\n \u001b[1;32mmedia\u001b[0m Attach or detach installation media while stopped\n \u001b[1;32mlist\u001b[0m List configured VMs\n \u001b[1;32mstatus\u001b[0m Query VM state through private QMP\n \u001b[1;32mview\u001b[0m Connect a viewer without changing VM power\n \u001b[1;32msession\u001b[0m Start the minimal Hydra OS labwc session\n \u001b[1;32m_session-helper\u001b[0m ==SUPPRESS==\n\n\u001b[1;34moptions:\u001b[0m\n \u001b[1;32m-h\u001b[0m, \u001b[1;36m--help\u001b[0m show this help message and exit\n \u001b[1;36m--config\u001b[0m \u001b[1;33mCONFIG\u001b[0m\n \u001b[1;36m--state\u001b[0m \u001b[1;33mSTATE\u001b[0m\n \u001b[1;36m--runtime\u001b[0m \u001b[1;33mRUNTIME\u001b[0m\n \u001b[1;36m--defaults\u001b[0m \u001b[1;33mDEFAULTS\u001b[0m Distribution preferences for newly created profiles\nQEMU emulator version 10.2.3\nCopyright (c) 2003-2025 Fabrice Bellard and the QEMU Project developers\nqemu-img version 10.2.3\nCopyright (c) 2003-2025 Fabrice Bellard and the QEMU Project developers\n/usr/share/edk2/OvmfX64/OVMF_CODE.fd\n"
|
||||
},
|
||||
"lan_to_vm_tcp": {
|
||||
"status": "passed",
|
||||
"output": "PASS: routed new LAN-to-VM TCP connection on arbitrary port 43117\n"
|
||||
},
|
||||
"nested_kvm": {
|
||||
"status": "passed",
|
||||
"output": "Formatting '/tmp/hydra-nested-pmtnvwzh/fresh.qcow2', fmt=qcow2 cluster_size=65536 extended_l2=off compression_type=zlib size=67108864 lazy_refcounts=off refcount_bits=16\nPASS: normal-user nested KVM VM and bridge helper; fresh qcow2; private QMP\n"
|
||||
},
|
||||
"installed_user_logout": {
|
||||
"status": "passed",
|
||||
"output": "{\"status\": \"passed\", \"real_logout\": true, \"no_open_logins\": true, \"survival_seconds\": 15, \"same_vm\": {\"name\": \"logout-bc617dc9\", \"uuid\": \"0c031012-0f16-55bb-98c5-ed7352000c2c\", \"pid\": 943, \"process_start\": \"2192\", \"directories\": {\"/run/user/1000/hydra-logout-bc617dc9\": [46, 52, 16832], \"/run/user/1000/hydra-logout-bc617dc9/logout-bc617dc9\": [46, 53, 16832]}, \"sockets\": {\"qmp\": [46, 57, 1000, 49640], \"spice\": [46, 56, 1000, 49640], \"pipewire\": [46, 28, 1000, 49590], \"pulse\": [46, 27, 1000, 49590]}, \"audio_services\": {\"pipewire.service\": \"InvocationID=aac07ae95f15474894fc94690edfa62c\\nExecMainPID=916\\n\", \"pipewire-pulse.service\": \"InvocationID=55735e4672f2412398cfd82086b21030\\nExecMainPID=918\\n\", \"wireplumber.service\": \"InvocationID=1dc399b7b35c42c7bb43f44923805472\\nExecMainPID=917\\n\"}}, \"audio_scope\": \"Socket and service continuity only; signal/playback untested\"}\n"
|
||||
},
|
||||
"installed_poweroff": {
|
||||
"status": "passed",
|
||||
"output": "Normal installed guest shutdown"
|
||||
}
|
||||
},
|
||||
"physical_hardware": "pending",
|
||||
"nested_scope": "Normal-user KVM VM launch, bridge helper and QMP; no nested guest OS boot",
|
||||
"ovmf_code_sha256": "f3ff7e73448ed2845ee15356f394882f5618eb5dab92c9a30ec6ee0e1468553a",
|
||||
"ovmf_vars_input_sha256": "6ed987af3a3c155be71665f510eae3e007eda9b8b94afd59d45e91c4a11565cc",
|
||||
"completed_utc": "2026-10-13T15:08:23Z"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"source_commit": "1bc7965ccaddb76abc78aafa82bb59358cf4dc0e",
|
||||
"iso": {
|
||||
"file": "hydra-os.iso",
|
||||
"size": 2345932800,
|
||||
"sha256": "1ed2cc79482ac5b14794ed6d4c4caf46ecc54af6457c5d38bc662cd3c104fbad"
|
||||
},
|
||||
"runtime_update": {
|
||||
"status": "passed",
|
||||
"bundle": "hydra-os-update-1bc7965.pyz",
|
||||
"source_commit": "1bc7965ccaddb76abc78aafa82bb59358cf4dc0e",
|
||||
"sha256": "3ed9288451894277797aa34f105f1969af0a892b1ec14dc27b1fadeed2a7e7f3",
|
||||
"payload_files": 16,
|
||||
"checks": [
|
||||
"dry_run_no_changes",
|
||||
"zipapp_apply",
|
||||
"all_payload_hashes",
|
||||
"original_image_manifest",
|
||||
"vendor_release_preserved",
|
||||
"branded_release",
|
||||
"relocated_cli"
|
||||
],
|
||||
"existing_user_guest_modified": false
|
||||
},
|
||||
"kernel": "6.18.54-gentoo-dist-bin",
|
||||
"packages": 561,
|
||||
"tests": {
|
||||
"unit": 293,
|
||||
"iso_acceptance": 13,
|
||||
"iso_skipped": 0
|
||||
},
|
||||
"physical_hardware": "pending"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/\ /\ /\ hydratest@hydra-os
|
||||
.-(o > .-(o > .-(o > OS: Hydra OS x86_64
|
||||
/ / / / / / Kernel: Linux 6.18.54-gentoo-dist-bin
|
||||
\ \______\ \______\ \ Uptime: 12 seconds
|
||||
\ \ / CPU: 12th Gen Intel(R) Core(TM) i7-12650H (2) @ 2.69 GHz
|
||||
'---. .---' Memory: 426.05 MiB / 3.80 GiB (11%)
|
||||
\ /\/\ / Disk (/): 6.98 GiB / 30.34 GiB (23%) - ext4
|
||||
___\/ \/___ Local IP (enp0s2): 10.0.2.15/24
|
||||
/______________\
|
||||
H Y D R A O S
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"bundle": "hydra-os-update-1bc7965.pyz",
|
||||
"source_commit": "1bc7965ccaddb76abc78aafa82bb59358cf4dc0e",
|
||||
"sha256": "3ed9288451894277797aa34f105f1969af0a892b1ec14dc27b1fadeed2a7e7f3",
|
||||
"payload_files": 16,
|
||||
"checks": [
|
||||
"dry_run_no_changes",
|
||||
"zipapp_apply",
|
||||
"all_payload_hashes",
|
||||
"original_image_manifest",
|
||||
"vendor_release_preserved",
|
||||
"branded_release",
|
||||
"relocated_cli"
|
||||
],
|
||||
"existing_user_guest_modified": false
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# Offline Base Recovery Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Recover Gentoo root, package database, kernel and owned EFI files together
|
||||
while preserving HOME and other systems, using the trusted live ISO.
|
||||
**Architecture:** Offline Btrfs read-only checkpoints and a restartable restore
|
||||
journal outside @root; matching EFI/HydraOS backups; explicit target identities.
|
||||
**Tech Stack:** Python stdlib, Btrfs tools, existing live image and QEMU acceptance.
|
||||
**Spec:** `docs/superpowers/specs/2026-10-13-base-recovery-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No real host devices or existing VM disks may be modified during development.
|
||||
- Only genuine live mode, selected unmounted single-device Btrfs/ESP, explicit UUID.
|
||||
- @home, foreign EFI/BOOT, partition table and firmware state remain unchanged.
|
||||
- Runtime update and full-base checkpoint/recovery remain distinct commands/scopes.
|
||||
- Recovery requires no installed executables/network and retains previous roots.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. Duplicate/cloned UUIDs or changed PARTUUIDs fail before modifying either target.
|
||||
2. A nested root subvolume or read-write snapshot invalidates the checkpoint contract.
|
||||
3. Interrupted root rename/EFI copy resumes from the durable journal and actual UUIDs.
|
||||
4. Corrupt/path-traversing EFI backups never overwrite foreign files.
|
||||
5. The real VM proves coordinated kernel/package recovery and post-checkpoint HOME.
|
||||
|
||||
## Task 1: Offline command and journal
|
||||
|
||||
**Files:** `hydra/system_recovery.py`, `bin/hydra-system`,
|
||||
`tests/test_system_recovery.py`, `docs/system-recovery.md`.
|
||||
**Consumes:** installer storage.json schema and @root/@home/@snapshots layout.
|
||||
**Produces:** checkpoint/list/restore CLI; root-owned completed metadata and durable
|
||||
restore transaction; module has no import-time mutations and works from live ISO.
|
||||
|
||||
- [x] Write failing topology/ownership/path/format checks and checkpoint publication tests.
|
||||
- [x] Implement root/ESP resolution and private mount cleanup, read-only planning.
|
||||
- [x] Implement offline checkpoint snapshot and EFI backup with exact metadata/hashes.
|
||||
- [x] Implement restartable restore preserving failed roots and all non-owned data.
|
||||
- [x] Test process interruption/restart at journal transitions and malformed backups.
|
||||
- [x] Scoped tests, self-review, commit, independent task review.
|
||||
|
||||
## Task 2: Bootable acceptance and integration
|
||||
|
||||
**Files:** new `dev/system-recovery-smoke.py` or extension to preserve-partition
|
||||
acceptance, associated harness tests, image wiring and release evidence.
|
||||
|
||||
- [x] Consume completed installer mode; seed and record fresh qcow2 only.
|
||||
- [x] Install, checkpoint offline, upgrade real fixture package and change HOME.
|
||||
- [x] Damage selected kernel and owned EFI, recover from live ISO and boot disk-only.
|
||||
- [x] Verify exact old base/kernel/Portage and unchanged current HOME/foreign data.
|
||||
- [x] Exercise interrupted restore, record hashes/states and honest limitations.
|
||||
- [x] Integrate with laptop ISO acceptance and final review before delivery.
|
||||
|
||||
## Verification status
|
||||
|
||||
The command, tests, image wiring and independent source reviews are complete.
|
||||
The unchanged final laptop harness passed all 23 checks against the final ISO,
|
||||
including real SIGKILL/resume, restored disk-only boot, exact base/kernel/Portage/EFI
|
||||
hashes, current HOME and unchanged guest/foreign data. Physical laptop checks
|
||||
remain separate; exact kernel restoration does not establish a two-kernel upgrade.
|
||||
See the [release record](../../releases/2026-10-13-laptop-ready/README.md).
|
||||
@@ -0,0 +1,118 @@
|
||||
# Hydra OS Laptop Preparation Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Deliver a tested ISO supporting preserved-partition installation, signed
|
||||
runtime updates/recovery, and live laptop diagnostics without modifying the host.
|
||||
|
||||
**Architecture:** Extend the validated installer with an explicit partition mode;
|
||||
use a separately installed trusted updater for signed passive release payloads;
|
||||
retain independent guest VM state. Validate each subsystem then exercise them in
|
||||
new disposable UEFI/KVM VMs before integrating and delivering release artifacts.
|
||||
|
||||
**Tech Stack:** Python stdlib, Gentoo, OpenSSL Ed25519, Btrfs tools, GRUB/efibootmgr,
|
||||
QEMU/KVM, existing dedicated rootless Podman builder.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-13-laptop-ready-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No physical block, firmware, host-service, original-container or existing-VM writes.
|
||||
- Install tests exclusively use fresh private qcow2 disks; no target selected for laptop.
|
||||
- Preserve partition boundaries and foreign EFI/fallback files across reboots;
|
||||
preserve firmware BootOrder around every installer/recovery/selection operation.
|
||||
Firmware may independently prune empty-media entries between boots.
|
||||
- Runtime payloads never contain HOME, VM data/config, credentials or networking.
|
||||
- Check release signature using a separately installed key before trusting contents.
|
||||
- Keep physical functional claims separate from read-only diagnostics and VM results.
|
||||
- Existing whole-disk and runtime behavior must retain their regression coverage.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. Partition aliases, topology changes and mounted siblings: reject actual targets
|
||||
in use and identity changes, preserve eligible unrelated siblings (Task 1).
|
||||
2. Existing EFI loaders, lack of ESP space and partial installation: fail before
|
||||
destructive root formatting when possible and preserve foreign bytes (Task 1).
|
||||
3. A signed but malformed/replayed archive or unsafe redirect: no mutation (Task 2).
|
||||
4. Power loss during apply/rollback and a broken CLI: standalone fresh-process
|
||||
recovery restores exact originals and retains monotonic replay state (Task 2).
|
||||
5. Missing hardware/tools, privacy and a release that passed only mocks: bounded
|
||||
honest diagnostics and real disposable-disk acceptance evidence (Tasks 3, 4, 5).
|
||||
|
||||
## Task 1: Preserve-partition installer
|
||||
|
||||
**Files:** `hydra/installer.py`, optional focused `hydra/install_storage.py`,
|
||||
`tests/test_installer.py`, `image/packages`, `image/dracut.conf`, `docs/install.md`.
|
||||
**Interfaces:** extend Request compatibly with root_partition, esp_partition,
|
||||
confirm_format, root_fs and register_boot_entry. Existing `install` stays the entry.
|
||||
|
||||
- [x] Add failing tests for mode parsing, identity/bounds/protection, mounted siblings,
|
||||
Btrfs member enumeration, ESP collision/space, only-root formatting and EFI policy.
|
||||
- [x] Implement shared validated target plan and distinct storage/boot branches.
|
||||
- [x] Add ext4 and Btrfs layouts plus explicit dependencies; test failure cleanup.
|
||||
- [x] Run installer suite, self-review, commit only task-owned files.
|
||||
- [x] Independent task review and scoped fixes.
|
||||
|
||||
## Task 2: Signed update and recovery
|
||||
|
||||
**Files:** new `hydra/update*.py` as needed, stable `bin/hydra-update*`, `hydra/cli.py`,
|
||||
`dev/release.py`, `tests/test_update*.py`, `tests/test_cli.py`, image install wiring,
|
||||
`docs/updates.md`. Coordinate shared image files with Task 1; do not race edits.
|
||||
**Interfaces:** the CLI actions from the spec; stable recover entrypoint runnable
|
||||
without importing hydra runtime; passive artifact manifest with version/sequence.
|
||||
|
||||
- [x] Add failing tests for real crypto, transport bounds, manifest/payload validation,
|
||||
no-write failures, replay rules, locking and destination/backup path ownership.
|
||||
- [x] Implement passive package builder, trusted verification and transactional apply.
|
||||
- [x] Implement explicit rollback and independent crash recovery with durable journal.
|
||||
- [x] Prove SIGKILL recovery in a fresh process, unchanged guest data and blocked
|
||||
corrupt backups; verify status/check work unprivileged.
|
||||
- [x] Document release creation, local key ownership, bootstrap and runtime scope.
|
||||
- [x] Run scoped suites, commit, independent review and scoped fixes.
|
||||
|
||||
## Task 3: Read-only laptop doctor
|
||||
|
||||
**Files:** `hydra/doctor.py`, `tests/test_doctor.py`, `docs/hardware/asus-fx517zc.md`.
|
||||
**Interfaces:** `doctor_report()` returns serializable bounded observations;
|
||||
`doctor_main(argv)` serves text/JSON. CLI wiring coordinated after Task 2.
|
||||
|
||||
- [x] Test fake proc/sys/tools with absent devices, timeouts and redaction.
|
||||
- [x] Implement independent read-only probes and manual functional checks.
|
||||
- [x] Run unit suite and safe host smoke, commit, independent review.
|
||||
|
||||
## Task 4: Integration and disposable acceptance harnesses
|
||||
|
||||
**Files:** `dev/iso-smoke.py`, `tests/test_iso_smoke.py`, new `dev/update-smoke.py`
|
||||
or helper as needed, CLI wiring and docs; do not edit these while other tasks do.
|
||||
|
||||
- [x] Preserve whole-disk mode; add existing-partitions acceptance using seeded fresh
|
||||
disk only, no existing-disk argument. Capture partition/EFI/BootOrder identities.
|
||||
- [x] Add signed update/tamper/apply/rollback/crash-recover installed-guest checks.
|
||||
- [x] Confirm brand asset and doctor availability, compile standalone guest scripts.
|
||||
- [x] Run full source suite once integrations settle; independent review.
|
||||
|
||||
## Task 5: Build, run acceptance and deliver
|
||||
|
||||
**Files:** source release records under `docs/releases`; build artifacts remain ignored.
|
||||
|
||||
- [x] Prepare isolated build checkout and preserve old ISO/firmware hashes.
|
||||
- [x] Build needed packages/rootfs/ISO, retaining explicit signature trust material.
|
||||
- [x] Run whole-disk and preserve-partitions real UEFI acceptance plus updater recovery.
|
||||
- [x] Run focused fixes only for observed failures, rerun affected acceptance.
|
||||
- [x] Verify exact release files, checksums, source revisions and absence of secrets.
|
||||
- [x] Complete final independent branch review.
|
||||
- [x] Integrate and publish authorized source/artifacts using available authenticated
|
||||
transport; report any unavailable publishing action.
|
||||
- [x] Report physical checks still requiring live laptop boot, not unsupported success.
|
||||
|
||||
## Verification status
|
||||
|
||||
Implementation and scoped reviews are complete through source
|
||||
`ebeebc7fb1aed07a5f50bc62374365d614953dad`. The complete source suite passed
|
||||
431 tests in 26.048 seconds. Independent final review is complete. The final ISO
|
||||
passed the unchanged whole-disk harness (13 checks) and laptop/recovery harness
|
||||
(23 checks), with no skips. Exact artifact identities and public evidence passed
|
||||
review. Source and signed artifacts are published in the public repository.
|
||||
Actual public discovery, signature verification, staging apply/rollback and replay
|
||||
rejection passed. Physical checks remain operator work after live laptop boot.
|
||||
See the [release record](../../releases/2026-10-13-laptop-ready/README.md).
|
||||
@@ -0,0 +1,103 @@
|
||||
# Hydra OS terminal experience implementation plan
|
||||
|
||||
> **For agentic workers:** Use superpowers:subagent-driven-development. Tasks
|
||||
> are implemented in the shared feature worktree with disjoint owned files,
|
||||
> then reviewed and integrated before publication.
|
||||
|
||||
**Goal:** Download and open VMs through a concise terminal workflow and ship
|
||||
consistent Hydra OS branding with Fastfetch.
|
||||
|
||||
**Architecture:** Retain Runtime as the VM lifecycle authority. Add a safe ISO
|
||||
download module and explicit launch orchestration. Replace recovery's JSON
|
||||
prompt with a terminal menu and use the existing session-refresh boundary to
|
||||
focus a selected workspace without changing QEMU lifetimes.
|
||||
|
||||
**Tech stack:** Python standard library, existing labwc/Foot/SPICE, Gentoo,
|
||||
Fastfetch. No GUI toolkit or host NixOS changes.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-13-terminal-ux-design.md`
|
||||
|
||||
## Global constraints
|
||||
|
||||
- Visible product name is **Hydra OS**; preserve technical identifiers and data.
|
||||
- No NixOS configuration changes, user-disk replacement or guest input injection.
|
||||
- Opening a selected stopped VM is explicit; menu display never starts all VMs.
|
||||
- Existing CLI output and runtime ownership guarantees remain compatible.
|
||||
- Downloads publish only verified complete ISO files; SHA-256 is optional.
|
||||
- New launch preflight reserves 768 MiB of available RAM for the Hydra host.
|
||||
|
||||
## Review focus
|
||||
|
||||
- Concurrent downloads or interruption must not publish partial ISO files (T1).
|
||||
- Existing names/disks and unknown VM identities must not be replaced (T1).
|
||||
- Selecting a second VM must focus it and preserve other QEMU processes (T2).
|
||||
- Invalid profiles, missing ISO, absent network tools must leave a usable menu (T2).
|
||||
- Branding must not invalidate existing IDs or rewrite historical evidence (T3).
|
||||
|
||||
## Task 1: Download and explicit launch commands
|
||||
|
||||
Owned: `hydra/media.py`, `hydra/launch.py`, `hydra/cli.py`,
|
||||
`tests/test_media.py`, `tests/test_launch.py`, relevant new CLI tests.
|
||||
|
||||
Interfaces:
|
||||
- `media.download_iso(url, *, sha256=None, cache=None, progress=None) -> Path`.
|
||||
`progress(received_bytes, total_bytes_or_none)` is optional.
|
||||
- `launch.open_vm(runtime, name, *, windowed=False, no_view=False,
|
||||
manifest=None) -> int`. For a session manifest call
|
||||
`session.request_refresh(manifest, selected=name)` after successful launch;
|
||||
outside a session use existing `session.view`. No GUI check for `no_view`.
|
||||
- CLI: `open NAME`, `run SOURCE --name NAME`, `download URL`; plain `hydra`
|
||||
and `hydra menu` call `menu.run_menu(runtime, manifest=...)` lazily.
|
||||
- Manifest defaults from `HYDRA_SESSION_MANIFEST` when valid; launch errors
|
||||
remain actionable and never silently restart an unknown/foreign VM.
|
||||
|
||||
- [x] Write and observe failing tests using a local HTTP fixture (ISO, HTML,
|
||||
truncation, checksum, cancellation, cache reuse, collision).
|
||||
- [x] Implement downloader and explicit open/run commands, preserve existing CLI.
|
||||
- [x] Test no accidental overwrite/start, headless operation, RAM preflight and
|
||||
view-only reconnection. Run scoped tests and commit only owned files.
|
||||
|
||||
## Task 2: Terminal menu and focused session handoff
|
||||
|
||||
Owned: `hydra/menu.py`, `hydra/session.py`, `tests/test_menu.py`,
|
||||
`tests/test_session.py`, relevant `dev/session-refresh-smoke.py` extensions.
|
||||
|
||||
Interfaces:
|
||||
- `menu.run_menu(runtime, *, manifest=None) -> int`: clear readable menu,
|
||||
numbers/names/Enter, persistent last selection, old colon commands, controlled
|
||||
guided `run`/`download`, network and shell actions. Use Task 1's launch API.
|
||||
- `session.request_refresh(manifest: Path, *, selected=None) -> int`: backwards
|
||||
compatible empty refresh requests, optional validated selected VM name.
|
||||
- Session env carries `HYDRA_SESSION_MANIFEST`; next compositor starts on the
|
||||
requested configured VM. Recovery delegates to menu; automatic clients remain
|
||||
view-only. Errors stay in menu, and operations use fresh runtime profiles.
|
||||
|
||||
- [x] Write failing menu/refresh tests for stopped/running selection, unknown
|
||||
identity, names such as s/q, empty/invalid profiles and second-workspace focus.
|
||||
- [x] Implement terminal flow, visible command previews and recovery fallback.
|
||||
- [x] Run scoped tests plus an isolated session probe where feasible; commit
|
||||
owned files. Existing reserved chords and VM independence remain intact.
|
||||
|
||||
## Task 3: Hydra OS branding and Fastfetch
|
||||
|
||||
Owned: `image/packages`, `image/rootfs` (Fastfetch config/logo/motd only),
|
||||
`hydra/image.py`, `image/grub.cfg`, `session` metadata, new branding tests.
|
||||
Coordinate session/CLI visible names with their owners; do not edit their files.
|
||||
|
||||
- [x] Confirm Fastfetch's Gentoo atom, supported logo/config format and search path.
|
||||
- [x] Add original terminal logo and restrained summary configuration; retain
|
||||
technical IDs while updating visible OS/boot/session labels.
|
||||
- [x] Verify config with actual Fastfetch in a temporary/container environment,
|
||||
preview logo, and test resulting image metadata. Commit owned files.
|
||||
|
||||
## Task 4: Integration and delivery
|
||||
|
||||
Owner: controller. README/install docs, update bundle, isolated validation.
|
||||
|
||||
- [x] Review each task diff with concrete test reports; resolve findings.
|
||||
- [x] Integrate task interfaces; document terminal usage and branded name.
|
||||
- [x] Run complete suite and isolated smoke covering run/open/menu with new code.
|
||||
- [x] Prepare a reversible guest update with exact file inventory and checksums;
|
||||
preserve old ISO and test disk. Assess cached image rebuild and test new output.
|
||||
- [x] Review whole branch, commit/publish authorized repository changes and report
|
||||
exactly which artifacts and guest states have actually been updated.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Offline recovery of the Gentoo base
|
||||
|
||||
This separately verified part of laptop preparation supplies real OS rollback,
|
||||
not just runtime-file rollback. It is deliberately offline: create checkpoints
|
||||
from the trusted live ISO while the installed root and ESP are unmounted, perform
|
||||
ordinary Portage updates in the installed system, and use the ISO to recover if
|
||||
needed. No online application-consistency or automatic failed-boot claim is made.
|
||||
|
||||
The explicit Btrfs layout is @root, @home, @snapshots; /boot, kernel modules,
|
||||
Portage package database and world remain in @root. There must be no nested
|
||||
subvolumes within @root. VM disks and user data in @home survive OS recovery.
|
||||
GRUB/fstab/initramfs select UUID plus subvol=@root, never fixed subvolume IDs.
|
||||
|
||||
`/etc/hydra/storage.json` is the installation ownership marker with exact fields:
|
||||
format=1, install_uuid, root_fs_uuid, root_partuuid, esp_uuid, esp_partuuid,
|
||||
owned_efi_path='EFI/HydraOS', filesystem='btrfs', root_subvolume='@root'. The ESP
|
||||
must be the recorded FAT partition; never operate on foreign EFI paths or NVRAM.
|
||||
|
||||
CLI `hydra-system checkpoint --root-uuid UUID --esp-partuuid PARTUUID --name NAME`,
|
||||
`hydra-system list --root-uuid UUID`, and
|
||||
`hydra-system restore --root-uuid UUID --checkpoint ID`. Changes require --execute
|
||||
and --confirm-root-uuid UUID. Default plans/list are read-only. Require a genuine
|
||||
Hydra live marker, root execution for changes, exactly one matching root and ESP,
|
||||
ordinary unmounted partitions without active swap/holders, single-device Btrfs,
|
||||
matching partition/filesystem/install UUIDs and supported owned layout.
|
||||
|
||||
Checkpoint mounts selected filesystems privately, snapshots @root read-only into
|
||||
@snapshots/ID/root and saves only EFI/HydraOS with a hash/metadata manifest.
|
||||
Preserve exact installed root and EFI state while offline. Durably publish the
|
||||
completed checkpoint descriptor last. Incomplete checkpoints cannot be restored.
|
||||
Snapshots remain on the same disk and are not external backups.
|
||||
|
||||
Restore validates all metadata, recorded source/snapshot UUIDs, readonly snapshot,
|
||||
layout marker and saved EFI bytes before mutation. Prepare a writable snapshot,
|
||||
record a durable transaction outside @root, retain the previous root under a unique
|
||||
failed-root name, rename the new root to @root, then restore only owned EFI files.
|
||||
Keep checkpoints and failed roots. Never delete @home or alter GPT, fallback EFI,
|
||||
foreign partitions/EFI directories or BootOrder. Directory fsync and filesystem
|
||||
sync delimit journal transitions. Lock operations and reject unexplained state.
|
||||
|
||||
Root/ESP replacement is not atomic across Btrfs and FAT. A power interruption can
|
||||
leave the install temporarily unbootable; rerunning restore from the live ISO must
|
||||
verify identities and resume the recorded transaction without guessing or deleting
|
||||
either root. Recovery must not invoke installed programs, fetch files, or require
|
||||
network access. Symlinks, malformed paths, unexpected files and tampered backup
|
||||
metadata fail closed before writes.
|
||||
|
||||
Actual acceptance uses a fresh UEFI VM, preserved foreign EFI/data sentinels,
|
||||
Btrfs install, live checkpoint, a real local Portage package upgrade, changed HOME,
|
||||
and damaged installed kernel/own EFI. After recovery boot without ISO and verify
|
||||
old package database/world/kernel artifacts together, updated HOME retained, and
|
||||
foreign bytes/GPT/BootOrder unchanged. Exercise restart after journal transition
|
||||
failures. Do not claim a two-kernel-version upgrade unless separately exercised.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Hydra OS laptop preparation
|
||||
|
||||
The user authorized autonomous implementation of the agreed scope: preserve all
|
||||
existing laptop partitions; install Hydra into an explicitly selected partition;
|
||||
obtain updates from the now-public `Emil_Shanaty/Hydra-OS` Gitea repository; verify
|
||||
signatures and recover after failed updates; prepare hardware diagnostics; rebuild
|
||||
and test an ISO including the generated Fastfetch branding. Technical choices are
|
||||
delegated. Actual laptop partitioning/installation and physical hardware checks
|
||||
remain operator steps; no physical target is selected or authorized.
|
||||
|
||||
## Invariants
|
||||
|
||||
- Never modify the physical host's block devices, firmware, services, NixOS
|
||||
configuration, networking, existing containers, or existing VM disks.
|
||||
- All installation/recovery execution tests use freshly created private qcow2s.
|
||||
- Keep whole-disk installation compatible, but add a distinct existing-partitions
|
||||
mode. No automatic partition selection, shrinking, or repartitioning.
|
||||
- Preserve foreign EFI files, the removable fallback loader, and BootOrder.
|
||||
- VM disks, profiles, HOME, credentials and network configuration are not runtime
|
||||
update payloads. Release trust is installed independently of untrusted discovery.
|
||||
- Prefer the standard library and already installed OpenSSL; no hosted dependency.
|
||||
- Hardware detection is evidence about presence, not proof of working devices.
|
||||
- A runtime rollback must never be described as rollback of Gentoo or its kernel.
|
||||
|
||||
## Existing-partitions installation
|
||||
|
||||
Keep `--disk` and `--confirm-erase` for fresh whole-disk installations. Add
|
||||
`--root-partition`, `--esp-partition`, `--confirm-format`, `--root-fs ext4|btrfs`
|
||||
and opt-in `--register-boot-entry`. A request chooses exactly one mode. Existing
|
||||
mode formats only the selected root after full identity revalidation, requires an
|
||||
unmounted existing FAT ESP, and never runs disk-label/partition/ESP-format commands.
|
||||
|
||||
Require stable canonical paths, block identities, parent/partition identities and
|
||||
bounds; reject mounted/active/protected targets, overlaps, aliases, ambiguous
|
||||
metadata and devices with holders. Unrelated mounted sibling partitions must not
|
||||
cause false rejection. Btrfs mounts must resolve all backing members conservatively.
|
||||
Require enough capacity for the installed payload and boot files before erasure.
|
||||
|
||||
Write only EFI/HydraOS, refusing an existing collision. Use GRUB --no-nvram without
|
||||
--removable, disable os-prober, and leave foreign EFI entries and fallback intact.
|
||||
Explicit registration may create a new entry without modifying BootOrder. Default
|
||||
output explains manual firmware selection. ESP preflight is read-only and happens
|
||||
before root formatting; record and verify unchanged foreign file hashes.
|
||||
|
||||
Ext4 remains compatible. Optional Btrfs uses @root, @home, @snapshots. The kernel,
|
||||
/boot and Portage database remain within @root; guest disks under HOME remain in
|
||||
@home. This layout permits a separately verified system recovery path; it alone
|
||||
is not a rollback implementation.
|
||||
|
||||
## Signed runtime updates
|
||||
|
||||
Use a bounded passive ZIP payload, exact-byte Ed25519-signed JSON manifest and
|
||||
detached signature. Manifest records product, scope, format, channel, architecture,
|
||||
monotonic sequence, release version, source commit, archive name/size/hash and the
|
||||
allowlisted installed paths/modes/hashes. OpenSSL verifies using an installed public
|
||||
key. Private release material stays outside Git, the build context and Nix store.
|
||||
|
||||
Gitea discovery is unauthenticated HTTPS on the fixed project origin; it is not a
|
||||
trust authority. Reject cross-origin/downgrade redirects, malformed URLs, duplicate
|
||||
JSON/archive entries, traversal/symlinks/special files, excess sizes, tampering and
|
||||
unsupported formats. Verify the manifest before trusting the archive. Empty releases
|
||||
are a normal result. No fallback to executing unsigned downloaded Python archives.
|
||||
|
||||
Publication must also work using the already available Git SSH access alone.
|
||||
When native releases have no applicable candidate, support the fixed public
|
||||
`main/releases/stable` raw path for the same three signed passive artifacts.
|
||||
The branch is untrusted transport: the pinned signature, hashes and monotonic
|
||||
sequence remain mandatory. Missing artifacts mean no update; invalid signed
|
||||
material must produce an error. No API token or extra update server is required.
|
||||
|
||||
CLI: `hydra update check`, `status`, `apply` and `rollback TRANSACTION`, plus a
|
||||
standalone `hydra-update-recover` available when the runtime itself is damaged.
|
||||
The trusted engine and recovery bootstrap are outside the runtime update allowlist.
|
||||
Prefer a protected installed engine imported without arbitrary sys.path entries.
|
||||
For local tests/support permit an explicit trusted local artifact directory and a
|
||||
staging-root API; production target checks must still require a Hydra installation.
|
||||
|
||||
Serialize mutations with a lock. Before the first destination change, validate all
|
||||
paths and save original bytes, modes, owners, symlinks and hashes into a durable
|
||||
private backup; persist a journal and fsync files/directories. Recover prepared or
|
||||
interrupted transactions from independently validated records. Latest-only explicit
|
||||
rollback refuses unexpected modifications. Preserve highest accepted sequence when
|
||||
rolling back current state; block new changes while recovery is pending. Failure
|
||||
must retain the journal and all backups. Include real fresh-process crash tests.
|
||||
|
||||
## Diagnostics and delivery
|
||||
|
||||
`hydra doctor` collects a bounded read-only report useful on the live ASUS laptop:
|
||||
UEFI/Secure Boot, KVM, CPU/RAM, storage visibility, kernel/driver bindings, graphics,
|
||||
input devices, network/audio tools and required services. Redact serials, MACs,
|
||||
SSID, IP addresses, usernames and machine IDs by default. Print actionable checks
|
||||
without implying physical Wi-Fi/audio/brightness/suspend/144Hz delivery were tested.
|
||||
|
||||
Rebuild using the isolated existing builder store. Preserve the previous ISO and
|
||||
firmware. Run whole-disk acceptance plus a distinct preserve-partitions acceptance:
|
||||
seed foreign EFI/fallback/sibling sentinel files, install into only the target,
|
||||
compare GPT/BootOrder/sentinels, and boot without ISO. Exercise signed update,
|
||||
tamper refusal, rollback and recovery with guest data unchanged. Store reports
|
||||
against exact source/ISO hashes. Publish only intended code/artifacts; private
|
||||
builder exports, signing keys, VM data and test secrets are never release assets.
|
||||
|
||||
Whole-base Portage recovery is a separate design/verification unit. Until its own
|
||||
bootable test passes, documentation must retain that limitation explicitly.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Hydra OS terminal experience
|
||||
|
||||
The user requested a general UX overhaul inside Hydra, clarified that the
|
||||
interface should stay in the terminal for control, and specifically requested
|
||||
one command to download and launch an ISO from a URL. They also requested
|
||||
Fastfetch with a custom logo and consistent visible branding as **Hydra OS**.
|
||||
Earlier autonomous-development authorization continues to apply. This work
|
||||
must not modify the current NixOS configuration or existing user VM disks.
|
||||
|
||||
## Experience
|
||||
|
||||
- The existing recovery terminal becomes a readable VM menu, with names,
|
||||
running/stopped/error states and memory allocations instead of raw JSON.
|
||||
A number or name opens one VM; Enter chooses the remembered selection (or
|
||||
the only VM). Opening explicitly starts a stopped VM and reconnects a
|
||||
running one. Merely displaying or refreshing the menu never starts VMs.
|
||||
- Keep `:shell`, `:refresh`, `:quit` and VM names compatible. Offer discoverable
|
||||
actions for new VM setup, ISO downloading, networking (`sudo nmtui`) and
|
||||
help. Existing CLI commands remain usable and JSON-compatible.
|
||||
- `hydra open NAME` starts if needed and displays an existing VM. Selecting a
|
||||
VM must actually focus its workspace, including a VM other than the first.
|
||||
Existing independent QEMU lifecycle and identity/locking protections remain.
|
||||
- `hydra run SOURCE --name NAME` accepts a local ISO or HTTP(S) URL and performs
|
||||
download (when necessary), profile creation, startup and display. Options
|
||||
expose RAM, CPUs, disk size, SHA-256 and `--no-view` for headless use. An
|
||||
existing name is never replaced: report how to use `hydra open NAME` instead.
|
||||
- `hydra download URL [--sha256 HEX]` stores a reusable ISO locally, reports
|
||||
progress and final path, and supports Ctrl+C cancellation. Explain direct
|
||||
image URLs versus HTML download pages. Never treat HTML as an ISO.
|
||||
- Check available RAM with a 768 MiB host reserve before an explicit new
|
||||
launch through the new UX. Refuse an obviously insufficient budget clearly;
|
||||
this is a preflight estimate, not a guarantee against future memory pressure.
|
||||
- Clear actionable errors, no startup of all VMs, no forced VM shutdowns.
|
||||
|
||||
## Downloads and storage
|
||||
|
||||
Use Python standard-library HTTP(S), no network service or new daemon. Cache
|
||||
under the user's XDG cache directory in `hydra/iso`, keyed by URL hash and a
|
||||
safe basename. Reject URL credentials and non-HTTP(S) remote schemes. Download
|
||||
to a private temporary file; check HTTP status, content length when supplied,
|
||||
ISO9660/UDF volume signature and optional SHA-256 before atomic publication.
|
||||
Never overwrite an existing unrelated file. Cancelled/failed downloads cannot
|
||||
be mistaken for complete images. Check disk space; reuse validated cached
|
||||
images. Render safe progress and errors without leaking URL query credentials.
|
||||
|
||||
## Branding
|
||||
|
||||
Visible product name is **Hydra OS**. Preserve technical identifiers such as
|
||||
`hydra`, `hydra-os`, service names, existing profile formats, disk directories
|
||||
and EFI installation paths. Historical release manifests/evidence keep their
|
||||
original contents. Add an original compact multi-headed hydra ASCII logo for
|
||||
Fastfetch, a restrained color scheme and an intentionally short useful system
|
||||
summary. Include Fastfetch in the Gentoo image, not on NixOS.
|
||||
|
||||
## Delivery and validation
|
||||
|
||||
Work in `feat/ux` under `.worktrees/ux`. Keep the original ISO and manual test
|
||||
disk. Use meaningful regression tests for download failures/cache integrity,
|
||||
explicit launch behavior and menu/session focus. Run the existing full suite
|
||||
once integrated, and an isolated real graphical/VM smoke test where available.
|
||||
Prepare a reviewable update bundle for the already installed guest and update
|
||||
the image sources; rebuild/test a new ISO when feasible without a cold toolchain
|
||||
build. Never claim the running user guest or an old ISO already contains new
|
||||
code. No keystroke injection into the user's desktop or guest sessions.
|
||||
@@ -0,0 +1,135 @@
|
||||
# Offline Gentoo base recovery
|
||||
|
||||
`hydra-system` creates and restores a matched Btrfs root snapshot and the installed
|
||||
`EFI/HydraOS` tree. Boot the trusted Hydra live ISO and run it as root with the
|
||||
installed root and ESP unmounted. This command does not operate from the installed
|
||||
OS. An installed `/etc/hydra/live-mode/active` file alone is insufficient: the
|
||||
command also checks the live boot arguments, overlay root and squashfs mount.
|
||||
|
||||
The supported installation has `@root`, `@home` and `@snapshots`. `/boot`, kernel
|
||||
modules, the Portage package database and world file belong to `@root`. Nested
|
||||
subvolumes beneath `@root` are refused. Boot and fstab must select the filesystem
|
||||
UUID plus `subvol=@root`, never a numeric root subvolume ID. `@home` and VM disks
|
||||
stored there survive restoration; files stored elsewhere in `@root` roll back.
|
||||
|
||||
The image keeps `/srv`, `/var/lib/machines` and `/var/lib/portables` as ordinary
|
||||
directories so that systemd does not create nested subvolumes on first boot.
|
||||
Assembly copies the installed vendor `home.conf`, `systemd-nspawn.conf` and
|
||||
`portables.conf` into `/etc/tmpfiles.d`, changing only those three creation rules.
|
||||
Other vendor rules and the separate `/home` policy are preserved. Image rebuilds
|
||||
regenerate these overrides and reject unexpected vendor rules. After a Portage
|
||||
systemd update, review the overrides against the new `/usr/lib/tmpfiles.d` files;
|
||||
existing `/etc` overrides continue to take precedence. Recovery still refuses
|
||||
unknown nested subvolumes rather than silently omitting their contents.
|
||||
|
||||
This includes `/var/lib/hydra-update`: a full root restoration also restores the
|
||||
checkpoint's runtime update receipts and replay floor. The signed updater's
|
||||
monotonic sequence guarantee applies to runtime apply/recovery/rollback, not to
|
||||
replacing the entire root with an older checkpoint. Check the available signed
|
||||
release again after base recovery.
|
||||
|
||||
Record the Btrfs filesystem UUID and ESP PARTUUID at installation. From the live
|
||||
ISO, inspect the plan before executing it:
|
||||
|
||||
```sh
|
||||
hydra-system checkpoint --root-uuid ROOT_UUID --esp-partuuid ESP_PARTUUID --name before-update
|
||||
hydra-system checkpoint --root-uuid ROOT_UUID --esp-partuuid ESP_PARTUUID --name before-update \
|
||||
--execute --confirm-root-uuid ROOT_UUID
|
||||
hydra-system list --root-uuid ROOT_UUID
|
||||
```
|
||||
|
||||
Commands emit JSON; the completed checkpoint has an `id` field. Boot the installed
|
||||
system to perform ordinary Portage updates. To recover, boot the live ISO again:
|
||||
|
||||
```sh
|
||||
hydra-system restore --root-uuid ROOT_UUID --checkpoint CHECKPOINT_ID
|
||||
hydra-system restore --root-uuid ROOT_UUID --checkpoint CHECKPOINT_ID \
|
||||
--execute --confirm-root-uuid ROOT_UUID
|
||||
```
|
||||
|
||||
Plans and list mount targets read-only; Btrfs inspection disables log replay with
|
||||
`rescue=nologreplay`, as supported by the packaged kernel.
|
||||
Execution requires matching confirmation and verifies UUID/PARTUUID uniqueness,
|
||||
ordinary partitions, inactive swap, no holders, and a single-device Btrfs layout.
|
||||
A private mount namespace and a root-owned lock isolate the command's mounts.
|
||||
No installed executable, network access or installed recovery program is needed.
|
||||
|
||||
Recovery restores only the recorded `EFI/HydraOS` directory. It does not change
|
||||
partition tables, other EFI directories, `EFI/BOOT`, firmware entries or BootOrder.
|
||||
Boot the restored system through its HydraOS firmware entry, or select
|
||||
`EFI/HydraOS/grubx64.efi` in firmware. A whole-disk installation can also have a
|
||||
removable fallback under `EFI/BOOT`; that fallback is deliberately not restored.
|
||||
If an updated fallback can no longer load the restored root, select the owned
|
||||
HydraOS loader explicitly. Automatic failed-boot recovery is not provided.
|
||||
|
||||
## Durable records and interrupted restores
|
||||
|
||||
The installer marker `/etc/hydra/storage.json` has exactly these fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"format": 1,
|
||||
"install_uuid": "installation UUID",
|
||||
"filesystem": "btrfs",
|
||||
"root_fs_uuid": "Btrfs UUID",
|
||||
"root_partuuid": "root GPT partition UUID",
|
||||
"esp_uuid": "FAT UUID (XXXX-XXXX)",
|
||||
"esp_partuuid": "ESP GPT partition UUID",
|
||||
"owned_efi_path": "EFI/HydraOS",
|
||||
"root_subvolume": "@root"
|
||||
}
|
||||
```
|
||||
|
||||
At the Btrfs top level, completed checkpoints are stored as:
|
||||
|
||||
```text
|
||||
@snapshots/<checkpoint-UUID>/root read-only Btrfs snapshot
|
||||
@snapshots/<checkpoint-UUID>/efi/ owned EFI backup
|
||||
@snapshots/<checkpoint-UUID>/checkpoint.json completed descriptor, published last
|
||||
```
|
||||
|
||||
The descriptor fields are `format`, `id`, `name`, `created`, `storage`,
|
||||
`source_root_uuid`, `snapshot_uuid` and `efi_manifest`. `format` is integer 1;
|
||||
`created` is a UTC ISO timestamp; `storage` is the exact marker above. Each manifest
|
||||
entry records a relative `path`, `kind` (`dir` or `file`), numeric `mode` and
|
||||
`mtime_ns`; files additionally record `size` and SHA-256 `sha256`. Backup paths,
|
||||
bytes and metadata are rechecked before every restore. Links, hardlinks, special
|
||||
files, ambiguous FAT names and extra checkpoint files are refused. Metadata files
|
||||
must be regular, singly linked, owned by root and not group/world writable.
|
||||
|
||||
`@snapshots/restore.json` is the durable active transaction. It has exactly
|
||||
`format`, `id` (transaction UUID), `checkpoint`, `checkpoint_digest`, `storage`,
|
||||
`old_uuid`, `new_uuid`, `prepared_root`, `failed_root` and `phase`. The digest is
|
||||
SHA-256 of the descriptor's canonical JSON (sorted keys, compact separators).
|
||||
Phases are `preparing`, `prepared`, `root-retained`, `root-installed` and
|
||||
`efi-restored`. `new_uuid` starts null; it is recorded once the writable snapshot
|
||||
exists. Snapshot parent UUID and marker checks establish the source if power was
|
||||
lost before that UUID was journaled. Directory fsync and filesystem sync delimit
|
||||
transitions.
|
||||
|
||||
A writable copy is prepared as `@restore-<transaction-UUID>`. The previous root is
|
||||
retained as `@failed-<transaction-UUID>` before the prepared root becomes `@root`.
|
||||
Resume verifies the actual subvolume UUIDs at all three paths and accepts only the
|
||||
three valid rename arrangements. The recorded phase alone never decides which
|
||||
root to keep. After EFI restoration and verification, the transaction is archived
|
||||
as `@snapshots/restored-<transaction-UUID>.json`. No failed roots or checkpoints are
|
||||
automatically deleted.
|
||||
|
||||
Btrfs root replacement and FAT file copying are not one atomic operation. Power
|
||||
loss can leave the system temporarily unbootable. Rerun the **same checkpoint ID**
|
||||
from the live ISO with the same explicit confirmation; recovery validates its
|
||||
records and resumes. Do not manually rename or delete recovery roots or journal
|
||||
files. Unexplained UUID/path combinations are refused for offline inspection.
|
||||
An incomplete checkpoint without its completed descriptor is never restorable.
|
||||
|
||||
These snapshots remain on the original disk. They are not external backups, and
|
||||
there is no promise of online application consistency: checkpoint only while the
|
||||
installation is offline. The unit suite uses a persistent fake Btrfs backend and
|
||||
fresh processes to exercise journal and rename interruptions. Real Btrfs, FAT,
|
||||
UEFI, Portage and disk-only boot acceptance must be verified separately in a fresh
|
||||
VM before release; unit tests do not establish those claims.
|
||||
|
||||
Btrfs's [subvolume documentation](https://btrfs.readthedocs.io/en/latest/btrfs-subvolume.html)
|
||||
explains snapshot boundaries and read-only snapshots; the command checks the
|
||||
[read-only property](https://btrfs.readthedocs.io/en/latest/btrfs-property.html)
|
||||
explicitly instead of inferring it from mount options.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Using Hydra OS from the terminal
|
||||
|
||||
Hydra OS keeps the host small. Virtual machines have independent disks and
|
||||
processes; closing a screen or the host menu does not power them off.
|
||||
|
||||
## Open a machine
|
||||
|
||||
The initial session shows the VM menu. Enter a number or a VM name. Enter alone
|
||||
uses the last selection when it still exists, or the only configured VM. A
|
||||
stopped VM starts; an already running one reconnects. Ctrl+Alt+Shift+Escape
|
||||
opens the menu while working inside a VM, and Ctrl+Alt+Shift+Left/Right switch
|
||||
between VM workspaces.
|
||||
|
||||
An ordinary terminal or SSH session can use:
|
||||
|
||||
```sh
|
||||
hydra open work
|
||||
hydra start work
|
||||
hydra status work
|
||||
hydra shutdown work
|
||||
```
|
||||
|
||||
`open` requires a graphical display unless `--no-view` is supplied. `start`
|
||||
and `shutdown` are the existing explicit lifecycle commands. Shutdown requests
|
||||
an orderly guest poweroff; it does not promise instantaneous completion.
|
||||
|
||||
## Download and run an installer
|
||||
|
||||
Use a direct `.iso` download URL supplied by the distribution:
|
||||
|
||||
```sh
|
||||
hydra run 'https://example.org/distribution.iso' --name work --memory 4096 --cpus 2
|
||||
```
|
||||
|
||||
Replace the example URL. The command downloads a validated image, creates a
|
||||
new profile, starts that VM and opens its screen. Existing names are refused
|
||||
before downloads or disk changes; use `hydra open work` next time. The same
|
||||
command accepts a local ISO path. Use `--no-view` to run without a viewer.
|
||||
|
||||
To download without creating a machine:
|
||||
|
||||
```sh
|
||||
hydra download 'https://example.org/distribution.iso'
|
||||
```
|
||||
|
||||
Add `--sha256` followed by the publisher's 64 hexadecimal digits to check the
|
||||
download. Supported images are ISO9660 and ISO9660/UDF hybrids; pure UDF
|
||||
images are currently refused because their completeness cannot be validated.
|
||||
Format validation checks the declared ISO9660 volume fits the file; it does not
|
||||
verify the publisher or optional trailing hybrid data. Validated files are cached under
|
||||
`~/.cache/hydra/iso` (or `$XDG_CACHE_HOME/hydra/iso`). Ctrl+C cancels an active
|
||||
download without publishing its partial file. HTTP is available for a local
|
||||
test transfer; prefer the publisher's HTTPS endpoint for Internet downloads.
|
||||
|
||||
The guest OS installer still performs installation inside its virtual disk.
|
||||
After it finishes, shut the guest down, detach the installer and reopen it:
|
||||
|
||||
```sh
|
||||
hydra shutdown work
|
||||
hydra status work
|
||||
# Wait until status is stopped:
|
||||
hydra media work --detach
|
||||
hydra open work
|
||||
```
|
||||
|
||||
This avoids accidentally booting the live ISO again. No guest disk is recreated.
|
||||
|
||||
## Network and host controls
|
||||
|
||||
The menu offers host shell and network actions. `sudo nmtui` controls the
|
||||
physical host's Wi-Fi/Ethernet connections. Guests see virtual Ethernet and
|
||||
receive Internet access through Hydra; they do not each select the Wi-Fi SSID.
|
||||
When testing Hydra inside another VM, physical Wi-Fi remains controlled by the
|
||||
outer host (currently NixOS).
|
||||
|
||||
The guest bridge uses `10.203.0.1/24`. A guest can SSH to `10.203.0.1` after
|
||||
SSH service and authentication have been configured on Hydra. An SSH login is
|
||||
not enabled automatically. Networking between Hydra and its guests does not
|
||||
require Internet access. VPN clients can run in individual guests; a shared
|
||||
host VPN requires routing guest traffic through that VPN explicitly.
|
||||
|
||||
## If something fails
|
||||
|
||||
- Download returns HTML: copy the actual ISO link, not its web download page.
|
||||
- A checksum mismatch: do not boot that download; verify the source/checksum.
|
||||
- A missing ISO: attach a valid image, or detach installation media after the
|
||||
guest was installed and stopped.
|
||||
- Insufficient RAM: stop an unused VM or choose a smaller guest allocation.
|
||||
The launch check reserves 768 MiB on Hydra. A nested Hydra cannot see memory
|
||||
pressure on the outer NixOS host; leave headroom there too.
|
||||
- A viewer closes: reopen the VM. Closing a viewer does not erase its disk or
|
||||
stop its running workloads.
|
||||
- An unknown VM state: inspect the reported error/QEMU log. Hydra will not
|
||||
blindly start a duplicate or signal a process whose identity it cannot verify.
|
||||
|
||||
Run `fastfetch` for the host summary and Hydra OS logo. Advanced administration
|
||||
remains ordinary Gentoo administration from the host shell.
|
||||
|
||||
## Updates and recovery
|
||||
|
||||
New images include signed runtime updates from the public repository:
|
||||
|
||||
```sh
|
||||
hydra update check
|
||||
sudo hydra update apply
|
||||
hydra update status
|
||||
```
|
||||
|
||||
Record the transaction printed by apply. To reverse that runtime update, use
|
||||
`sudo hydra update rollback TRANSACTION`. If the update was interrupted or the
|
||||
normal CLI is damaged, run `sudo hydra-update-recover`. See
|
||||
[update trust, backups and limits](updates.md).
|
||||
|
||||
After applying or rolling back, open the host shell from the terminal menu and
|
||||
run `labwc --exit` inside the Hydra graphical session. Log in again to load the
|
||||
new session code; running VMs keep their independent processes and disks.
|
||||
|
||||
Runtime updates leave the Gentoo base, kernel and guest data in place. For
|
||||
Portage/kernel recovery, Btrfs installations support separate
|
||||
[offline root and EFI checkpoints](system-recovery.md) made from the live ISO.
|
||||
|
||||
Early images lack the trusted updater and pinned key; boot/install the verified
|
||||
new ISO or follow the independently verified bootstrap guidance in the update
|
||||
documentation. [Legacy local bundle recovery](legacy-runtime-update.md) remains
|
||||
available for old test installations that already used those bundles.
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
# Signed Hydra runtime updates
|
||||
|
||||
The new ISO includes an independent updater and an independently pinned Ed25519
|
||||
public key. Discovery uses the public Gitea repository at
|
||||
`https://git.shanaty.ru/Emil_Shanaty/Hydra-OS`; discovery is not a trust source.
|
||||
If Gitea has no published runtime release candidate, discovery checks the fixed
|
||||
public repository path `releases/stable/` on branch `main`. If that path has no
|
||||
manifest (HTTP 404), it reports no update. Both locations use the same independently
|
||||
pinned signature key; Git branch contents and repository ownership do not establish
|
||||
release trust.
|
||||
|
||||
```sh
|
||||
hydra update check
|
||||
hydra update status
|
||||
sudo hydra update apply
|
||||
sudo hydra update rollback TRANSACTION
|
||||
sudo hydra-update-recover
|
||||
```
|
||||
|
||||
`check` and `status` run without sudo. Apply prints the transaction identifier.
|
||||
After applying or rolling back, log out of the graphical session and log in again
|
||||
to load the changed runtime. Existing guest disks and profiles remain unchanged.
|
||||
If the normal `hydra` command is broken, use the independent `hydra-update` command
|
||||
or `hydra-update-recover` directly. A prepared/interrupted transaction blocks the
|
||||
next apply until recovery succeeds. Recovery may be repeated after interruption.
|
||||
|
||||
Only the latest current transaction can be explicitly rolled back. Rollback first
|
||||
checks that its installed files still match their recorded content, ownership and
|
||||
permissions, and refuses local edits. Recovery restores the originals of an
|
||||
interrupted transaction without requiring mixed runtime files to match the new
|
||||
release. Corrupt or missing backups stop restoration before any destination changes;
|
||||
keep the complete transaction directory for inspection. No automatic cleanup removes
|
||||
failed transaction evidence.
|
||||
|
||||
Runtime updates contain only Python runtime modules, `usr/lib/hydra/bin/hydra`,
|
||||
`etc/motd`, the Fastfetch configuration and its text logo. They cannot contain
|
||||
`update*.py`, the independent updater, release keys, `/home`, guest profiles/disks,
|
||||
credentials, network configuration, services, Portage, the kernel or boot files.
|
||||
Runtime rollback **does not roll back Gentoo or its kernel**. Whole-system recovery
|
||||
is a separate facility with separate acceptance requirements.
|
||||
|
||||
## Trust bootstrap and installation
|
||||
|
||||
These files are installed by the trusted image build, outside the runtime payload:
|
||||
|
||||
| Source | Installed path | Owner/mode |
|
||||
| --- | --- | --- |
|
||||
| `hydra/update_engine.py` | `/usr/lib/hydra-update/engine.py` | root:root 0644 |
|
||||
| `bin/hydra-update` | `/usr/bin/hydra-update` | root:root 0755 |
|
||||
| `bin/hydra-update-recover` | `/usr/bin/hydra-update-recover` | root:root 0755 |
|
||||
| Independently supplied public key | `/etc/hydra/update.pub` | root:root 0644 |
|
||||
|
||||
The launchers use isolated Python (`/usr/bin/python3 -I`) and never import the
|
||||
mutable `hydra` package. Install Python and OpenSSL in the image. Protect all parent
|
||||
directories with root ownership and no group/other write permissions. The engine
|
||||
and its key cannot update themselves through this protocol; their maintenance is
|
||||
an independently verified image/bootstrap operation.
|
||||
|
||||
An old ISO without these files cannot safely bootstrap by executing a downloaded
|
||||
zipapp. Reinstall from the newly verified ISO, or independently verify and install
|
||||
the exact engine, launchers and public key from a trusted source as administrator.
|
||||
Do not fetch the trust key from the same release discovery response being verified.
|
||||
Verify its fingerprint through the independently supplied release information.
|
||||
|
||||
Production mutation requires UID 0, x86_64, the Hydra build marker and Hydra's
|
||||
`ID=hydra-os` OS release. Production always uses `/etc/hydra/update.pub`; a key
|
||||
override is refused. A `--root DIRECTORY` support/testing argument targets an
|
||||
explicit isolated root containing the build marker and existing payload parent
|
||||
directories. Only these staging roots allow `--public-key FILE`. No environment
|
||||
variable silently redirects an installed update to another root or key.
|
||||
|
||||
## Release creation
|
||||
|
||||
Keep the Ed25519 private key outside Git, the source/build context and the Nix
|
||||
store, in a user-owned private file (0600). Never upload it as a release asset.
|
||||
Provision this key through the release operator's separate key-management process.
|
||||
The builder does not generate keys, publish assets or overwrite an existing output.
|
||||
|
||||
```sh
|
||||
python3 dev/release.py \
|
||||
--source /path/to/verified-checkout \
|
||||
--output /path/to/new-release-directory \
|
||||
--private-key /private/outside-git/release.pem \
|
||||
--version 1.0.0 --sequence 1
|
||||
```
|
||||
|
||||
The three release assets are `manifest.json`, `manifest.sig`, and `runtime.zip`.
|
||||
Publish them under the immutable Gitea tag `1.0.0`, exactly matching the signed
|
||||
version. Alternatively, when the native Gitea release list has no runtime candidates,
|
||||
publish those same three passive artifacts together in `releases/stable/` on the
|
||||
repository's `main` branch. The raw HTTPS prefix is fixed:
|
||||
`https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/`.
|
||||
The branch serves as a discovery location; the verified manifest supplies its
|
||||
version and sequence. Keep historical signed artifacts under immutable versions
|
||||
outside this moving stable pointer. Native Gitea runtime releases take precedence;
|
||||
a present native candidate, including one already installed, disables this fallback.
|
||||
|
||||
Each new release needs a strictly increasing integer sequence; never
|
||||
reuse a sequence or mutate assets at an existing version. Rollback retains the
|
||||
highest accepted sequence independently of the current installed version.
|
||||
The builder is deterministic for identical source, version, sequence, key and
|
||||
compression implementation. It records Git HEAD; build from a reviewed clean
|
||||
checkout so that commit identifies the bytes actually being released.
|
||||
|
||||
The signature is Ed25519 over the exact bytes of `manifest.json`, using OpenSSL
|
||||
`pkeyutl -sign -rawin -inkey PRIVATE -in manifest.json -out manifest.sig`.
|
||||
Verification uses `pkeyutl -verify -pubin -rawin` with the pinned key. No downloaded
|
||||
Python is executed. `runtime.zip` is passive file data with reproducible timestamps.
|
||||
|
||||
Manifest format 1 has exact top-level fields `format`, `product` (`Hydra OS`),
|
||||
`scope` (`runtime`), `channel` (`stable`), `architecture` (`x86_64`), `sequence`,
|
||||
`version`, `source_commit`, `archive` and `files`. The archive entry contains
|
||||
`name`, `size` and `sha256`; each allowed file has `size`, `sha256` and `mode`
|
||||
(0644 or 0755). Duplicate keys, unknown fields, unsafe paths, links/special archive
|
||||
members, mismatched hashes/sizes and unsupported formats are rejected.
|
||||
The maximum manifest is 256 KiB, archive/expanded payload 16 MiB, individual file
|
||||
4 MiB and file count 256. Downloads have a 15-second socket timeout and 60-second
|
||||
per-artifact deadline; redirects remain on the fixed HTTPS origin. Discovery
|
||||
checks up to the latest 20 published release records, skipping draft/prerelease
|
||||
records. Publish runtime releases regularly enough that the current stable release
|
||||
remains in that window. Only an otherwise successful discovery with no runtime
|
||||
candidates activates the repository fallback. API errors, malformed native assets,
|
||||
tag mismatches and bad signatures remain visible errors and never trigger fallback.
|
||||
For the repository path, only a missing `manifest.json` (404) means no release;
|
||||
a missing signature, server error or invalid signature is an error. The ZIP is
|
||||
fetched only for apply after manifest verification, and its signed hash/size and
|
||||
all existing download bounds/redirect restrictions still apply. A branch update
|
||||
between requests may cause a safe verification failure; retry after publication
|
||||
finishes.
|
||||
|
||||
For offline/local verification and application:
|
||||
|
||||
```sh
|
||||
hydra update check --artifacts /trusted/local/release
|
||||
sudo hydra update apply --artifacts /trusted/local/release
|
||||
```
|
||||
|
||||
These use the same pinned key and validation as online releases. For developer
|
||||
staging, add `--root /isolated/hydra-root --public-key /test/public.pem`.
|
||||
The Python API is `Updater(root, public_key).apply(artifacts)`, `.status()`,
|
||||
`.rollback(transaction)` and `.recover()`; staging roots may run as their owner.
|
||||
|
||||
## Durability and limits
|
||||
|
||||
A nonblocking `flock` serializes mutations. The private state directory is
|
||||
`/var/lib/hydra-update` (0700). Each transaction saves original regular-file bytes,
|
||||
hashes, sizes, permissions and ownership; symlink targets/ownership and previously
|
||||
absent files are recorded explicitly. Unsupported destination types and unsafe
|
||||
parent directories are refused. Payload parent directories must already exist.
|
||||
|
||||
All destination paths and payload bytes are checked before creating update state.
|
||||
All backups and a prepared journal are durable before the first runtime replacement.
|
||||
Writes use adjacent temporary files, file fsync, atomic replacement and directory
|
||||
fsync. Recovery is independent of the runtime package. The public read-only status
|
||||
summary is `/usr/share/hydra/update-status.json`; private journals are authoritative,
|
||||
and an abrupt loss of power may leave the summary briefly older than those journals.
|
||||
Run recovery as administrator when an update was interrupted; it refreshes the
|
||||
public summary from authoritative state even when no transaction remains pending.
|
||||
|
||||
This is a per-file recoverable transaction, not a simultaneous filesystem snapshot.
|
||||
A running process can observe a mixture during apply. Do not launch new Hydra
|
||||
runtime operations until apply/recovery finishes. Root administrators and processes
|
||||
that can already modify protected root-owned directories are outside the updater's
|
||||
concurrent-write threat model. Filesystem/device failures can require manual repair;
|
||||
recovery retains all evidence and never promises recovery from lost backup storage.
|
||||
+20
-14
@@ -1,13 +1,17 @@
|
||||
# Graphics and recovery verification
|
||||
|
||||
The [2026-10-13 preview record](releases/2026-10-13/acceptance.json) identifies
|
||||
the exact ISO, firmware and harness used for the passing twelve-check acceptance
|
||||
run. The unmodified ISO installed onto a fresh 32 GiB qcow2 disk and booted from
|
||||
The [terminal UX preview record](releases/2026-10-13-terminal-ux/acceptance.json)
|
||||
identifies the exact ISO and firmware used for the passing thirteen-check
|
||||
acceptance run. The unmodified ISO installed onto a fresh 32 GiB qcow2 disk and booted from
|
||||
that disk with no ISO attached. Password login, persistent data, removed live
|
||||
overrides, installed services, DNS lookup, routed LAN-to-VM TCP, normal-user
|
||||
nested KVM/bridge access and normal shutdown passed. An actual logout followed
|
||||
by 15 seconds without an open login and a new login preserved the same VM,
|
||||
QMP/SPICE sockets and user audio services. Audio playback was not measured.
|
||||
The installed normal account also exercised download/cache/run/open/menu using
|
||||
the packaged commands and verified the default Fastfetch rendering. Its ISO
|
||||
fixture is intentionally nonbootable; nested guest OS boot is not inferred.
|
||||
The [first preview record](releases/2026-10-13/acceptance.json) remains unchanged.
|
||||
|
||||
Live graphical recovery was also observed through a private QEMU screenshot;
|
||||
the earlier two-guest compositor and profile-refresh probes cover fullscreen
|
||||
@@ -72,22 +76,24 @@ 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.
|
||||
A session with no profiles opens the terminal VM menu with first-use guidance.
|
||||
Use `:run` for guided creation, or `:shell` for the existing CLI. Selecting a
|
||||
machine opens it on its own workspace. `:refresh` reloads the profile list after
|
||||
manual edits, rebuilds workspace rules, restarts only the session's own compositor,
|
||||
and attaches viewers to verified live guests. QEMU processes and workloads
|
||||
survive. Reserved Left/Right chords switch between their workspaces; `:quit`
|
||||
closes the menu.
|
||||
|
||||
`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.
|
||||
The menu explicitly selects the second VM, and the rebuilt compositor must
|
||||
start on its workspace with that viewer focused. 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
|
||||
terminal 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.
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
"""HydraOS independent VM management."""
|
||||
"""Hydra OS independent VM management."""
|
||||
|
||||
+25
-3
@@ -426,14 +426,36 @@ fi'''
|
||||
if self.complete('rootfs'):
|
||||
return
|
||||
source_inputs = image.source_identity(self.source_dir)
|
||||
self.record('rootfs', complete=False)
|
||||
previous = self.manifest().get('rootfs', {})
|
||||
inputs = self.input_id()
|
||||
inventory_checksum = image.digest(self.work / 'artifacts/packages.json')
|
||||
if inventory_checksum != self.manifest().get('packages', {}).get('inventory_sha256'):
|
||||
raise BuildError('Package inventory changed before rootfs export')
|
||||
original = self.work / 'artifacts/builder-rootfs.tar'
|
||||
if original.is_symlink() or not original.parent.resolve().is_relative_to(self.work):
|
||||
raise BuildError('Raw export output must remain inside the dedicated work directory')
|
||||
if original.exists() and (not original.is_file() or original.stat().st_nlink != 1):
|
||||
raise BuildError('Raw export output must be a singly linked regular file')
|
||||
snapshot = None
|
||||
previous_artifacts = previous.get('artifacts', {})
|
||||
if (previous.get('complete') is True and previous.get('inputs') == inputs
|
||||
and isinstance(previous_artifacts, dict)
|
||||
and previous_artifacts.get('packages.json') == inventory_checksum
|
||||
and isinstance(previous.get('snapshot_sha256'), str)
|
||||
and re.fullmatch('[a-f0-9]{64}', previous['snapshot_sha256'])
|
||||
and original.is_file()):
|
||||
try:
|
||||
if image.digest(original) == previous['snapshot_sha256']:
|
||||
snapshot = previous['snapshot_sha256']
|
||||
except OSError:
|
||||
pass # Unreadable cache is never trusted; attempt an ordinary export.
|
||||
# Preserve the prior completed provenance until the cache decision is made.
|
||||
self.record('rootfs', complete=False)
|
||||
original.touch(mode=0o600, exist_ok=True)
|
||||
original.chmod(0o600) # The unsanitized intermediate can contain builder-private trust.
|
||||
self.pod('export', '--output', str(original), self.name)
|
||||
snapshot = image.digest(original)
|
||||
if snapshot is None:
|
||||
self.pod('export', '--output', str(original), self.name)
|
||||
snapshot = image.digest(original)
|
||||
assembler = self.assembly(snapshot)
|
||||
commit = self.run(['git', '-C', str(self.source_dir), 'rev-parse', 'HEAD'], capture=True).stdout.strip()
|
||||
script = 'root=/hydra-output/rootfs\n' + rootfs_retry_guard_script() + r"""mkdir -p "$root"
|
||||
|
||||
+76
-5
@@ -13,13 +13,34 @@ 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')
|
||||
doctor = commands.add_parser('doctor', help='Read-only laptop hardware observations')
|
||||
doctor.add_argument('--json', action='store_true', help='Print structured observations')
|
||||
update = commands.add_parser('update', help='Signed runtime updates and rollback')
|
||||
update.add_argument('update_args', nargs=argparse.REMAINDER)
|
||||
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 +69,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 +77,57 @@ 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)
|
||||
runtime = Runtime(args.config, args.state, args.runtime)
|
||||
cli = parser()
|
||||
args = cli.parse_args(argv)
|
||||
try:
|
||||
if args.command == 'doctor':
|
||||
from .doctor import doctor_main
|
||||
return doctor_main(['--json'] if args.json else [])
|
||||
if args.command == 'update':
|
||||
checkout = Path(__file__).resolve().parents[1]
|
||||
if checkout == Path('/usr/lib/hydra'):
|
||||
command = ['/usr/bin/hydra-update']
|
||||
else:
|
||||
command = [sys.executable, '-I', str(checkout / 'bin/hydra-update')]
|
||||
return subprocess.run(command + args.update_args, check=False).returncode
|
||||
runtime = Runtime(args.config, args.state, args.runtime)
|
||||
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), defaults=args.defaults)
|
||||
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 +155,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
|
||||
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
"""Bounded, read-only hardware observations; presence is not a functional test."""
|
||||
|
||||
import argparse
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
READ_LIMIT = 65536
|
||||
DEVICE_LIMIT = 64
|
||||
TOOLS = ('systemctl', 'nmcli', 'ip', 'iw', 'rfkill', 'wpctl', 'pactl',
|
||||
'aplay', 'brightnessctl', 'wlr-randr', 'qemu-system-x86_64', 'btrfs',
|
||||
'openssl', 'efibootmgr', 'lsblk', 'lspci')
|
||||
DRIVERS = frozenset(('i915', 'xe', 'nouveau', 'nvidia', 'amdgpu', 'radeon',
|
||||
'virtio_gpu', 'bochs', 'simpledrm', 'iwlwifi', 'ath9k',
|
||||
'ath10k_pci', 'ath11k_pci', 'mt7921e', 'mt7922', 'rtw89_pci',
|
||||
'r8169', 'e1000e', 'igc', 'virtio_net', 'snd_hda_intel'))
|
||||
MODULES = ('kvm', 'kvm_intel', 'kvm_amd', 'i915', 'xe', 'nouveau', 'nvidia',
|
||||
'amdgpu', 'iwlwifi', 'mt7921e', 'snd_hda_intel', 'snd_sof_pci_intel_tgl')
|
||||
MANUAL_CHECKS = [
|
||||
'Wi-Fi connection: connect to your access point and test connectivity.',
|
||||
'Brightness keys: check both keys and the visible panel brightness.',
|
||||
'144 Hz: select the panel mode and verify the actual refresh rate.',
|
||||
'Audio: play sound through speakers/headphones and record microphone input.',
|
||||
'Suspend/resume: test wake-up, graphics, network and audio after suspend.',
|
||||
'USB: attach your devices and verify input/storage without erasing data.',
|
||||
]
|
||||
|
||||
|
||||
def _read(path, limit=READ_LIMIT):
|
||||
try:
|
||||
with path.open('rb') as stream:
|
||||
return stream.read(limit).decode('ascii', errors='replace')
|
||||
except OSError:
|
||||
return ''
|
||||
|
||||
|
||||
def _entries(path):
|
||||
try:
|
||||
# Do not materialize an unbounded directory or emit its identifying names.
|
||||
with os.scandir(path) as entries:
|
||||
return [Path(entry.path) for entry in itertools.islice(entries, DEVICE_LIMIT)]
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def _number(path, *, base=10):
|
||||
text = _read(path, 128).strip()
|
||||
try:
|
||||
value = int(text, base)
|
||||
return value if 0 <= value <= 2**64 - 1 else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _driver(path):
|
||||
try:
|
||||
name = (path / 'driver').resolve(strict=True).name
|
||||
return name if name in DRIVERS else 'other'
|
||||
except OSError:
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def _services(find_tool, run):
|
||||
systemctl = find_tool('systemctl')
|
||||
result = {}
|
||||
for unit, user in (('NetworkManager', False), ('greetd', False),
|
||||
('hydra-network', False), ('hydra-dnsmasq', False),
|
||||
('pipewire', True), ('wireplumber', True), ('pipewire-pulse', True)):
|
||||
key = unit + (':user' if user else ':system')
|
||||
if not systemctl:
|
||||
result[key] = 'unavailable'
|
||||
continue
|
||||
command = [systemctl] + (['--user'] if user else []) + ['is-active', '--quiet', unit + '.service']
|
||||
try:
|
||||
status = run(command, timeout=1, stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False).returncode
|
||||
result[key] = {0: 'active', 3: 'inactive', 4: 'unavailable'}.get(status, 'unknown')
|
||||
except subprocess.TimeoutExpired:
|
||||
result[key] = 'timeout'
|
||||
except OSError:
|
||||
result[key] = 'unavailable'
|
||||
return result
|
||||
|
||||
|
||||
def doctor_report(*, proc_root=Path('/proc'), sys_root=Path('/sys'), dev_root=Path('/dev'),
|
||||
find_tool=shutil.which, run=subprocess.run):
|
||||
"""Return JSON-safe facts without identifiers or hardware-changing commands.
|
||||
|
||||
Each file read is bounded to 64 KiB, each device class to 64 entries and
|
||||
the seven read-only service queries to one second each. Missing access
|
||||
produces unknown/absent facts, never a claim of functional success.
|
||||
"""
|
||||
proc_root, sys_root, dev_root = Path(proc_root), Path(sys_root), Path(dev_root)
|
||||
cpu = _read(proc_root / 'cpuinfo')
|
||||
flags = set(re.findall(r'\b(?:vmx|svm)\b', cpu))
|
||||
memory = {}
|
||||
meminfo = _read(proc_root / 'meminfo')
|
||||
for field, key in (('MemTotal', 'total_mib'), ('MemAvailable', 'available_mib')):
|
||||
match = re.search(r'^' + field + r':\s+([0-9]{1,16})\s+kB$', meminfo, re.M)
|
||||
memory[key] = int(match[1]) // 1024 if match else None
|
||||
efi = sys_root / 'firmware/efi'
|
||||
secure_boot = None
|
||||
variable = efi / 'efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c'
|
||||
try:
|
||||
with variable.open('rb') as stream:
|
||||
value = stream.read(6)
|
||||
if len(value) == 5 and value[4] in (0, 1):
|
||||
secure_boot = bool(value[4])
|
||||
except OSError:
|
||||
pass
|
||||
storage = []
|
||||
for path in _entries(sys_root / 'class/block'):
|
||||
if (path / 'partition').exists() or re.match(r'^(loop|ram|zram)', path.name):
|
||||
continue
|
||||
sectors = _number(path / 'size')
|
||||
storage.append({'size_bytes': sectors * 512 if sectors is not None else None,
|
||||
'removable': {0: False, 1: True}.get(_number(path / 'removable'))})
|
||||
graphics_drivers = set()
|
||||
for path in _entries(sys_root / 'bus/pci/devices'):
|
||||
pci_class = _number(path / 'class', base=16)
|
||||
if pci_class is not None and pci_class >> 16 == 3:
|
||||
graphics_drivers.add(_driver(path))
|
||||
connectors = []
|
||||
for path in _entries(sys_root / 'class/drm'):
|
||||
if not (path / 'status').exists():
|
||||
continue
|
||||
status = _read(path / 'status', 64).strip()
|
||||
modes = [line for line in _read(path / 'modes').splitlines()[:32]
|
||||
if re.fullmatch(r'[0-9]{1,5}x[0-9]{1,5}', line)]
|
||||
connectors.append({'status': status if status in ('connected', 'disconnected', 'unknown') else 'unknown',
|
||||
'modes': modes})
|
||||
network = _entries(sys_root / 'class/net')
|
||||
inputs = [p for p in _entries(sys_root / 'class/input') if re.fullmatch(r'event[0-9]+', p.name)]
|
||||
keyboards = 0
|
||||
for path in inputs:
|
||||
words = _read(path / 'device/capabilities/key', 256).split()
|
||||
if words and all(re.fullmatch(r'[0-9a-fA-F]{1,16}', word) for word in words):
|
||||
# x86_64 sysfs prints most-significant unsigned-long words first,
|
||||
# without zero padding. KEY_A (30) and KEY_Z (44) are in the low word.
|
||||
bits = int(words[-1], 16)
|
||||
keyboards += bool(bits & (1 << 30) and bits & (1 << 44))
|
||||
return {
|
||||
'format': 1,
|
||||
'scope': 'Read-only observations; physical functionality requires manual tests.',
|
||||
'cpu': {'architecture': platform.machine(), 'logical_processors': len(re.findall(r'^processor\s*:', cpu, re.M)) or None,
|
||||
'virtualization_flags': sorted(flags)},
|
||||
'memory': memory,
|
||||
'firmware': {'uefi': efi.is_dir(), 'secure_boot': secure_boot},
|
||||
'kvm': {'device_present': (dev_root / 'kvm').exists(),
|
||||
'accessible': os.access(dev_root / 'kvm', os.R_OK | os.W_OK),
|
||||
'modules': [name for name in ('kvm', 'kvm_intel', 'kvm_amd') if (sys_root / 'module' / name).is_dir()]},
|
||||
'storage': {'devices': storage},
|
||||
'graphics': {'drivers': sorted(graphics_drivers), 'connectors': connectors,
|
||||
'refresh_rate_tested': False},
|
||||
'input': {'event_devices': len(inputs), 'keyboards': keyboards,
|
||||
'backlight_devices': len(_entries(sys_root / 'class/backlight'))},
|
||||
'network': {'devices': len(network), 'wireless_devices': sum((p / 'wireless').is_dir() for p in network),
|
||||
'drivers': sorted({_driver(p / 'device') for p in network if (p / 'device').exists()})},
|
||||
'audio': {'cards': sum(bool(re.fullmatch(r'card[0-9]+', p.name)) for p in _entries(sys_root / 'class/sound'))},
|
||||
'modules': [name for name in MODULES if (sys_root / 'module' / name).is_dir()],
|
||||
'tools': {name: bool(find_tool(name)) for name in TOOLS},
|
||||
'services': _services(find_tool, run),
|
||||
'manual_checks': list(MANUAL_CHECKS),
|
||||
}
|
||||
|
||||
|
||||
def doctor_main(argv=None):
|
||||
parser = argparse.ArgumentParser(prog='hydra doctor', description=__doc__)
|
||||
parser.add_argument('--json', action='store_true', help='Print structured observations')
|
||||
options = parser.parse_args(argv)
|
||||
report = doctor_report()
|
||||
if options.json:
|
||||
print(json.dumps(report, indent=2, sort_keys=True))
|
||||
else:
|
||||
print('Hydra laptop doctor — read-only observations')
|
||||
print(report['scope'])
|
||||
for key, value in report.items():
|
||||
if key not in ('format', 'scope', 'manual_checks'):
|
||||
print(f'{key}: {json.dumps(value, sort_keys=True)}')
|
||||
print('\nManual checks:')
|
||||
for check in report['manual_checks']:
|
||||
print('- ' + check)
|
||||
return 0
|
||||
+77
-5
@@ -251,11 +251,64 @@ def enable_user_audio(root):
|
||||
raise ImageError('Cannot enable user audio sockets: ' + result.stderr.strip())
|
||||
|
||||
|
||||
def brand_os_release(root):
|
||||
"""Override derivative identity while retaining packaged Gentoo release fields."""
|
||||
release = root_path(root, 'etc/os-release')
|
||||
if not release.is_file():
|
||||
release = root_path(root, 'usr/lib/os-release')
|
||||
original = release.read_text() if release.is_file() else ''
|
||||
retained = [line for line in original.splitlines()
|
||||
if not re.match(r'^(NAME|PRETTY_NAME|ID|ID_LIKE)=', line)]
|
||||
retained += ['NAME="Hydra OS"', 'PRETTY_NAME="Hydra OS"',
|
||||
'ID=hydra-os', 'ID_LIKE=gentoo']
|
||||
# Gentoo links /etc/os-release to its packaged file; keep that file intact.
|
||||
remove(root, 'etc/os-release')
|
||||
write(root, 'etc/os-release', '\n'.join(retained) + '\n')
|
||||
|
||||
|
||||
def configure_managed_root_tmpfiles(root):
|
||||
"""Keep automatic systemd directories inside the recoverable root snapshot.
|
||||
|
||||
Same-name overrides retain the installed vendor policy, changing only the
|
||||
three subvolume-creation rules. Do not mask nspawn cleanup/socket rules or
|
||||
the separately mounted /home. Unexpected vendor changes need a review.
|
||||
"""
|
||||
overrides = []
|
||||
for filename, target in (('home.conf', '/srv'),
|
||||
('portables.conf', '/var/lib/portables'),
|
||||
('systemd-nspawn.conf', '/var/lib/machines')):
|
||||
vendor = root_path(root, 'usr/lib/tmpfiles.d/' + filename)
|
||||
destination = 'etc/tmpfiles.d/' + filename
|
||||
override = root_path(root, destination, follow_leaf=False)
|
||||
if vendor.is_symlink() or not vendor.is_file():
|
||||
raise ImageError(f'Required ordinary vendor tmpfiles policy is missing: {filename}')
|
||||
if override.is_symlink() or override.exists():
|
||||
raise ImageError(f'Refusing to replace an existing tmpfiles override: {filename}')
|
||||
lines = vendor.read_text().splitlines(keepends=True)
|
||||
matches = []
|
||||
for index, line in enumerate(lines):
|
||||
fields = line.split()
|
||||
if fields and not fields[0].startswith('#') and len(fields) > 1 and fields[1] == target:
|
||||
matches.append((index, fields[0]))
|
||||
if len(matches) != 1 or matches[0][1] not in ('q', 'Q'):
|
||||
raise ImageError(f'Expected one vendor tmpfiles q/Q rule for {target}: {filename}')
|
||||
index = matches[0][0]
|
||||
line = lines[index]
|
||||
start = len(line) - len(line.lstrip(' \t'))
|
||||
lines[index] = line[:start] + 'd' + line[start + 1:]
|
||||
overrides.append((destination, ''.join(lines)))
|
||||
# Validate the complete policy before publishing any same-name overrides.
|
||||
for destination, content in overrides:
|
||||
write(root, destination, content)
|
||||
|
||||
|
||||
def configure_rootfs(root, source):
|
||||
root, source = Path(root), Path(source)
|
||||
# Refuse incomplete integrations before changing the exported copy.
|
||||
for name in ('bin/hydra', 'bin/hydra-install', 'hydra/session.py', 'hydra/installer.py'):
|
||||
if not (source / name).is_file():
|
||||
for name in ('bin/hydra', 'bin/hydra-install', 'hydra/session.py', 'hydra/installer.py',
|
||||
'bin/hydra-update', 'bin/hydra-update-recover', 'hydra/update_engine.py',
|
||||
'bin/hydra-system', 'hydra/system_recovery.py', 'image/rootfs/etc/hydra/update.pub'):
|
||||
if (source / name).is_symlink() or not (source / name).is_file():
|
||||
raise ImageError(f'Required committed runtime/installer asset is missing: {name}')
|
||||
version = kernel_version(root)
|
||||
kernel = kernel_image(root)
|
||||
@@ -264,6 +317,8 @@ def configure_rootfs(root, source):
|
||||
alias.symlink_to(kernel.name)
|
||||
sanitize_rootfs(root)
|
||||
copy_overlay(root, source / 'image/rootfs')
|
||||
configure_managed_root_tmpfiles(root)
|
||||
brand_os_release(root)
|
||||
write(root, 'etc/hydra/live-mode/dracut.conf', (source / 'image/dracut.conf').read_text())
|
||||
root_path(root, 'etc/hydra/live-mode/dracut.conf.d').mkdir()
|
||||
package = root_path(root, 'usr/lib/hydra/hydra')
|
||||
@@ -271,11 +326,28 @@ def configure_rootfs(root, source):
|
||||
for module in sorted((source / 'hydra').glob('*.py')):
|
||||
shutil.copyfile(module, root_path(root, 'usr/lib/hydra/hydra/' + module.name))
|
||||
# Session helpers derive this checkout-compatible launcher from __file__.
|
||||
for name in ('hydra', 'hydra-install'):
|
||||
for name in ('hydra', 'hydra-install', 'hydra-system'):
|
||||
write(root, 'usr/lib/hydra/bin/' + name, (source / 'bin' / name).read_text(), 0o755)
|
||||
for name, module in (('hydra', 'cli'), ('hydra-install', 'installer')):
|
||||
# The updater bootstrap and engine are independently installed trusted files.
|
||||
# They must retain their isolated runpy launchers, outside runtime imports.
|
||||
for destination, asset, mode in (
|
||||
('usr/lib/hydra-update/engine.py', 'hydra/update_engine.py', 0o644),
|
||||
('usr/bin/hydra-update', 'bin/hydra-update', 0o755),
|
||||
('usr/bin/hydra-update-recover', 'bin/hydra-update-recover', 0o755),
|
||||
('etc/hydra/update.pub', 'image/rootfs/etc/hydra/update.pub', 0o644),
|
||||
):
|
||||
remove(root, destination)
|
||||
path = write(root, destination, (source / asset).read_text(), mode)
|
||||
if os.geteuid() == 0: # Assembly runs as root; source fixtures remain unprivileged.
|
||||
os.chown(path, 0, 0)
|
||||
engine_directory = root_path(root, 'usr/lib/hydra-update')
|
||||
engine_directory.chmod(0o755)
|
||||
if os.geteuid() == 0:
|
||||
os.chown(engine_directory, 0, 0)
|
||||
for name, module in (('hydra', 'cli'), ('hydra-install', 'installer'),
|
||||
('hydra-system', 'system_recovery')):
|
||||
write(root, 'usr/bin/' + name,
|
||||
'#!/usr/bin/python3\nimport sys\nsys.path.insert(0, "/usr/lib/hydra")\n'
|
||||
'#!/usr/bin/python3 -I\nimport sys\nsys.path.insert(0, "/usr/lib/hydra")\n'
|
||||
f'from hydra.{module} import main\nraise SystemExit(main())\n', 0o755)
|
||||
write(root, 'usr/bin/hydra-session', '#!/bin/sh\nexec /usr/bin/hydra session "$@"\n', 0o755)
|
||||
write(root, 'usr/bin/hydra-network', '#!/usr/bin/python3\nimport sys\nsys.path.insert(0, "/usr/lib/hydra")\n'
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Read-only validation and explicit storage plans for the installer.
|
||||
|
||||
Only plain GPT partitions on physical disks are supported. This module never
|
||||
selects a target or runs a partition/format command while constructing a plan.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
|
||||
ESP_TYPE = 'c12a7328-f81f-11d2-ba4b-00a0c93ec93b'
|
||||
UUID = re.compile(r'[A-Fa-f0-9]+(?:-[A-Fa-f0-9]+)+\Z')
|
||||
PARTUUID = re.compile(r'[A-Fa-f0-9]{8}(?:-[A-Fa-f0-9]{4}){3}-[A-Fa-f0-9]{12}\Z')
|
||||
ESP_RESERVE = 64 * 1024**2
|
||||
MIN_ROOT = 4 * 1024**3
|
||||
|
||||
|
||||
def fail(message):
|
||||
from .installer import InstallError
|
||||
raise InstallError(message)
|
||||
|
||||
|
||||
def ancestors(topo, path):
|
||||
seen, pending = set(), list(topo.parents[path])
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
if current not in seen:
|
||||
seen.add(current)
|
||||
pending.extend(topo.parents[current] - seen)
|
||||
return seen
|
||||
|
||||
|
||||
def fingerprint(node):
|
||||
return tuple(node.get(key) for key in
|
||||
('path', 'type', 'size', 'maj:min', 'start', 'partuuid', 'parttype', 'fstype', 'uuid'))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiskPlan:
|
||||
target: str
|
||||
size: int
|
||||
identity: tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PartitionPlan:
|
||||
root: str
|
||||
esp: str
|
||||
root_parent: str
|
||||
esp_parent: str
|
||||
esp_number: int
|
||||
root_size: int
|
||||
root_partuuid: str
|
||||
esp_partuuid: str
|
||||
esp_uuid: str
|
||||
identity: tuple
|
||||
|
||||
|
||||
def partition_plan(request, probe, topo):
|
||||
from .installer import DEVICE
|
||||
paths = []
|
||||
for chosen in (request.root_partition, request.esp_partition):
|
||||
if not isinstance(chosen, str) or not DEVICE.fullmatch(chosen) or '..' in PurePosixPath(chosen).parts:
|
||||
fail('Choose explicit /dev root and ESP partitions')
|
||||
canonical = probe.resolve(chosen)
|
||||
if not DEVICE.fullmatch(canonical):
|
||||
fail('Invalid canonical partition path')
|
||||
paths.append(canonical)
|
||||
root, esp = paths
|
||||
if root == esp:
|
||||
fail('Root and ESP must be distinct partitions')
|
||||
if request.execute and request.confirm_format != root:
|
||||
fail(f'Execution requires --confirm-format {root} exactly')
|
||||
parents, numbers, identities = [], [], []
|
||||
for path in paths:
|
||||
node = topo.nodes.get(path)
|
||||
if not node or node['type'] != 'part' or len(topo.parents[path]) != 1:
|
||||
fail('Target must be a plain partition with exactly one physical backing disk')
|
||||
parent = next(iter(topo.parents[path]))
|
||||
if topo.nodes[parent]['type'] != 'disk' or topo.parents[parent] or not probe.physical_disk(parent):
|
||||
fail('Nested or virtual partition backing is unsupported')
|
||||
if topo.edges[path] - {parent}:
|
||||
fail('Selected partition has active holders or nested devices')
|
||||
if any(source == path or source in ancestors(topo, path) or path in ancestors(topo, source)
|
||||
for source in topo.protected):
|
||||
fail(f'Selected partition backs mounted/root/live storage: {path}')
|
||||
if not PARTUUID.fullmatch(node.get('partuuid') or ''):
|
||||
fail('Missing or ambiguous GPT partition identity')
|
||||
# Check every sibling extent; never infer bounds from partition names.
|
||||
siblings = [p for p in topo.nodes if parent in topo.parents[p]]
|
||||
extents = []
|
||||
for sibling in siblings:
|
||||
child = topo.nodes[sibling]
|
||||
start = child.get('start')
|
||||
if child['type'] != 'part' or topo.parents[sibling] != {parent} or isinstance(start, bool) or not isinstance(start, int) or start <= 0:
|
||||
fail('Ambiguous partition bounds or nested topology')
|
||||
end = start * 512 + child['size']
|
||||
if child['size'] <= 0 or end > topo.nodes[parent]['size']:
|
||||
fail('Partition extends beyond its backing disk')
|
||||
extents.append((start*512, end, sibling))
|
||||
extents.sort()
|
||||
if any(left[1] > right[0] for left, right in zip(extents, extents[1:])):
|
||||
fail('Overlapping partition bounds')
|
||||
# The partition number is provided by the kernel, not derived from an alias.
|
||||
number = probe.partition_number(path, node)
|
||||
if number <= 0:
|
||||
fail('Missing kernel partition number')
|
||||
probe.verify_identity(path, node)
|
||||
probe.verify_identity(parent, topo.nodes[parent])
|
||||
parents.append(parent); numbers.append(number)
|
||||
identities.append((fingerprint(node), fingerprint(topo.nodes[parent]),
|
||||
tuple(sorted((sibling, fingerprint(topo.nodes[sibling])) for sibling in siblings))))
|
||||
if topo.nodes[root]['size'] < MIN_ROOT:
|
||||
fail('Root partition is smaller than the minimum 4 GiB')
|
||||
esp_node = topo.nodes[esp]
|
||||
if esp_node.get('fstype') != 'vfat' or (esp_node.get('parttype') or '').lower() != ESP_TYPE:
|
||||
fail('ESP must be an existing FAT GPT EFI System Partition')
|
||||
if not UUID.fullmatch(esp_node.get('uuid') or ''):
|
||||
fail('ESP filesystem UUID missing')
|
||||
# fstab resolves the ESP by filesystem UUID. Copies on any surviving
|
||||
# device are ambiguous even when their GPT partition identities differ.
|
||||
# Root's old filesystem is replaced before fstab is written.
|
||||
copies = [path for path, node in topo.nodes.items()
|
||||
if path not in {root, esp}
|
||||
and (node.get('uuid') or '').lower() == esp_node['uuid'].lower()]
|
||||
if copies:
|
||||
fail('ESP filesystem UUID is ambiguous on surviving devices: ' + ', '.join(sorted(copies)))
|
||||
# Equal GPT IDs anywhere would make later PARTUUID recovery ambiguous.
|
||||
ids = [n.get('partuuid', '').lower() for n in topo.nodes.values() if n.get('partuuid')]
|
||||
if len(ids) != len(set(ids)):
|
||||
fail('Duplicate GPT partition identities')
|
||||
return PartitionPlan(root, esp, *parents, numbers[1], topo.nodes[root]['size'],
|
||||
topo.nodes[root]['partuuid'], esp_node['partuuid'], esp_node['uuid'], tuple(identities))
|
||||
|
||||
|
||||
def format_root(root, filesystem):
|
||||
return (['mkfs.btrfs', '-f', '-L', 'HYDRA_ROOT', root] if filesystem == 'btrfs' else
|
||||
['mkfs.ext4', '-F', '-L', 'HYDRA_ROOT', root])
|
||||
|
||||
|
||||
def payload_capacity(listing):
|
||||
"""Sum uncompressed inode sizes from unsquashfs numeric long listing."""
|
||||
allocated, inodes = 0, 0
|
||||
for line in listing.splitlines():
|
||||
match = re.match(r'^[-dlcbps][rwxstST-]{9}\s+\d+/\d+\s+(\d+)\s+\d{4}-\d\d-\d\d\s+\d\d:\d\d\s+', line)
|
||||
if match:
|
||||
size = int(match[1])
|
||||
allocated += ((size + 4095) // 4096) * 4096 + 4096
|
||||
inodes += 1
|
||||
elif re.match(r'^[cb][rwxstST-]{9}\s+\d+/\d+\s+\d+,\s*\d+\s+\d{4}-\d\d-\d\d\s+\d\d:\d\d\s+', line):
|
||||
allocated += 4096
|
||||
inodes += 1
|
||||
elif line and not line.startswith(('Parallel unsquashfs:', 'Number of processors', 'squashfs-root')):
|
||||
fail('Cannot determine uncompressed payload capacity')
|
||||
if not inodes:
|
||||
fail('Cannot determine uncompressed payload capacity')
|
||||
# Metadata, allocated blocks, generated initramfs, and initial user state.
|
||||
return max(MIN_ROOT, allocated * 12 // 10 + 1024**3)
|
||||
|
||||
|
||||
def foreign_efi_hashes(directory, *, ignore_owned=False):
|
||||
"""Fingerprint all foreign regular files; reject ambiguous/special paths."""
|
||||
root = Path(directory)
|
||||
result = {}
|
||||
for path in sorted(root.rglob('*')):
|
||||
relative = path.relative_to(root).as_posix()
|
||||
if ignore_owned and (relative.casefold() == 'efi/hydraos' or relative.casefold().startswith('efi/hydraos/')):
|
||||
continue
|
||||
if path.is_symlink() or not (path.is_file() or path.is_dir()):
|
||||
fail('ESP contains unsupported special files')
|
||||
if path.is_file():
|
||||
with path.open('rb') as source:
|
||||
digest = hashlib.file_digest(source, 'sha256').hexdigest()
|
||||
result[relative] = digest
|
||||
return result
|
||||
|
||||
|
||||
def inspect_esp(directory):
|
||||
root = Path(directory)
|
||||
for entry in root.iterdir():
|
||||
if entry.name.casefold() == 'efi':
|
||||
if not entry.is_dir() or entry.is_symlink():
|
||||
fail('ESP EFI directory is not an ordinary directory')
|
||||
if any(p.name.casefold() == 'hydraos' for p in entry.iterdir()):
|
||||
fail('ESP already contains EFI/HydraOS; refusing loader collision')
|
||||
info = os.statvfs(root)
|
||||
if info.f_bavail * info.f_frsize < ESP_RESERVE:
|
||||
fail('ESP needs at least 64 MiB free for the Hydra EFI loader')
|
||||
return foreign_efi_hashes(root)
|
||||
+231
-40
@@ -1,4 +1,4 @@
|
||||
"""Explicit whole-disk installer. Importing this module never mutates storage.
|
||||
"""Explicit whole-disk and existing-partition installer. Importing this module never mutates storage.
|
||||
|
||||
Live payload contract: /etc/hydra/live-mode/active and remove-on-install.json
|
||||
must be regular files. The manifest is a JSON array of absolute paths matching
|
||||
@@ -23,8 +23,12 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import warnings
|
||||
import uuid
|
||||
|
||||
from typing import TextIO
|
||||
|
||||
from . import install_storage as storage
|
||||
|
||||
DEFAULT_PAYLOAD = '/run/initramfs/live/LiveOS/squashfs.img'
|
||||
LIVE_REMOVABLE = (
|
||||
'/etc/systemd/system/getty@*.service.d/90-hydra-live.conf',
|
||||
@@ -46,13 +50,18 @@ class InstallError(RuntimeError):
|
||||
|
||||
@dataclass
|
||||
class Request:
|
||||
disk: str
|
||||
disk: str | None
|
||||
user: str
|
||||
payload: str = DEFAULT_PAYLOAD
|
||||
execute: bool = False
|
||||
confirm_erase: str | None = None
|
||||
timezone: str = 'Europe/Moscow'
|
||||
kernel: str | None = None
|
||||
root_partition: str | None = None
|
||||
esp_partition: str | None = None
|
||||
confirm_format: str | None = None
|
||||
root_fs: str = 'ext4'
|
||||
register_boot_entry: bool = False
|
||||
|
||||
|
||||
class Runner:
|
||||
@@ -109,7 +118,13 @@ class Topology:
|
||||
raise InstallError('Zero-size device is not an unrelated empty optical drive')
|
||||
if not isinstance(identity, str) or not re.fullmatch(r'\d+:\d+', identity):
|
||||
raise InstallError('Invalid lsblk device identity')
|
||||
if path in result.nodes and any(result.nodes[path][key] != node[key] for key in ('type', 'size', 'maj:min', 'mountpoints')):
|
||||
for key in ('partuuid', 'parttype', 'fstype', 'uuid'):
|
||||
if node.get(key) is not None and not isinstance(node[key], str):
|
||||
raise InstallError('Malformed lsblk storage metadata')
|
||||
if node.get('start') is not None and (isinstance(node['start'], bool) or not isinstance(node['start'], int)):
|
||||
raise InstallError('Malformed lsblk partition start')
|
||||
if path in result.nodes and any(result.nodes[path].get(key) != node.get(key) for key in
|
||||
('type', 'size', 'maj:min', 'mountpoints', 'start', 'partuuid', 'parttype', 'fstype', 'uuid')):
|
||||
raise InstallError('Inconsistent duplicate lsblk node')
|
||||
result.nodes[path] = node
|
||||
result.edges.setdefault(path, set())
|
||||
@@ -224,7 +239,7 @@ class SystemProbe:
|
||||
|
||||
def snapshot(self, payload):
|
||||
raw = self.runner.run(['lsblk', '--json', '--paths', '--bytes', '--output',
|
||||
'PATH,TYPE,PKNAME,MOUNTPOINTS,SIZE,MAJ:MIN'])
|
||||
'PATH,TYPE,PKNAME,MOUNTPOINTS,SIZE,MAJ:MIN,START,PARTUUID,PARTTYPE,FSTYPE,UUID'])
|
||||
# lsblk PATH can use /dev/mapper aliases while findmnt uses /dev/dm-N.
|
||||
try:
|
||||
decoded = json.loads(raw)
|
||||
@@ -275,11 +290,15 @@ class SystemProbe:
|
||||
for path in pending:
|
||||
identity = self._loop_backing_identity(path, topo.nodes[path]['maj:min'])
|
||||
sources = [p for p, n in topo.nodes.items() if n['maj:min'] == identity]
|
||||
if len(sources) != 1:
|
||||
raise InstallError(f'Unknown physical backing for mounted loop: {path} ({identity})')
|
||||
source = sources[0]
|
||||
topo.link(path, source)
|
||||
topo.protect(source)
|
||||
if not sources:
|
||||
btrfs_mounts = [m for m in mounts if m['fstype'] == 'btrfs' and m['maj:min'] == identity]
|
||||
member_sets = [set(self._btrfs_members(m, topo)) for m in btrfs_mounts]
|
||||
if not member_sets or any(members != member_sets[0] for members in member_sets[1:]):
|
||||
raise InstallError(f'Unknown physical backing for mounted loop: {path} ({identity})')
|
||||
sources = sorted(member_sets[0])
|
||||
for source in sources:
|
||||
topo.link(path, source)
|
||||
topo.protect(source)
|
||||
inspected.add(path)
|
||||
return topo
|
||||
|
||||
@@ -331,7 +350,9 @@ class SystemProbe:
|
||||
raise InstallError('Malformed findmnt topology') from exc
|
||||
|
||||
def _block_source(self, mount, topo):
|
||||
if mount['fstype'] in {'zfs', 'btrfs'}:
|
||||
if mount['fstype'] == 'btrfs':
|
||||
return self._btrfs_source(mount, topo)
|
||||
if mount['fstype'] == 'zfs':
|
||||
raise InstallError('Cannot safely resolve all backing disks of mounted ' + mount['fstype'])
|
||||
source = mount['source'].split('[', 1)[0]
|
||||
identity = mount['maj:min']
|
||||
@@ -352,6 +373,49 @@ class SystemProbe:
|
||||
raise InstallError(f'Unknown mounted block source: {source}')
|
||||
return None
|
||||
|
||||
def _btrfs_source(self, mount, topo):
|
||||
return self._btrfs_members(mount, topo)[0]
|
||||
|
||||
def _btrfs_members(self, mount, topo):
|
||||
if not Path(mount['target']).is_absolute() or any(ord(c) < 32 for c in mount['target']):
|
||||
raise InstallError('Invalid mounted btrfs path')
|
||||
raw = self.runner.run(['btrfs', 'filesystem', 'show', '--raw', mount['target']])
|
||||
counts = re.findall(r'Total devices (\d+) FS bytes used \d+', raw)
|
||||
rows = re.findall(r'^\s*devid\s+(\d+)\s+size\s+\d+\s+used\s+\d+\s+path\s+(/dev/[^\s]+)\s*$', raw, re.MULTILINE)
|
||||
if len(counts) != 1 or int(counts[0]) <= 0 or len(rows) != int(counts[0]) or len({row[0] for row in rows}) != len(rows):
|
||||
raise InstallError('Cannot safely resolve all backing members of mounted btrfs')
|
||||
paths = [self.resolve(row[1]) for row in rows]
|
||||
if len(set(paths)) != len(paths) or any(path not in topo.nodes for path in paths):
|
||||
raise InstallError('Unknown or duplicate mounted btrfs backing member')
|
||||
source = mount['source'].split('[', 1)[0]
|
||||
if source.startswith('/dev/') and self.resolve(source) in topo.nodes and self.resolve(source) not in paths:
|
||||
raise InstallError('Mounted btrfs source is absent from backing members')
|
||||
for path in paths:
|
||||
topo.protect(path)
|
||||
return paths
|
||||
|
||||
def verify_identity(self, path, node):
|
||||
try:
|
||||
info = os.stat(path)
|
||||
identity = f'{os.major(info.st_rdev)}:{os.minor(info.st_rdev)}'
|
||||
if not stat.S_ISBLK(info.st_mode) or identity != node['maj:min']:
|
||||
raise InstallError('Block device identity changed: ' + path)
|
||||
sysfs = Path('/sys/dev/block') / identity
|
||||
if int((sysfs / 'size').read_text()) * 512 != node['size']:
|
||||
raise InstallError('Block device capacity changed: ' + path)
|
||||
if list((sysfs / 'holders').iterdir()):
|
||||
raise InstallError('Selected device has active holders: ' + path)
|
||||
if node['type'] == 'part' and int((sysfs / 'start').read_text()) != node['start']:
|
||||
raise InstallError('Partition bounds changed: ' + path)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise InstallError('Cannot verify kernel block identity: ' + path) from exc
|
||||
|
||||
def partition_number(self, path, node):
|
||||
try:
|
||||
return int((Path('/sys/dev/block') / node['maj:min'] / 'partition').read_text())
|
||||
except (OSError, ValueError) as exc:
|
||||
raise InstallError('Cannot read kernel partition number: ' + path) from exc
|
||||
|
||||
def _source_for_path(self, path, topo):
|
||||
raw = self.runner.run(['findmnt', '--json', '--target', path, '--output',
|
||||
'SOURCE,TARGET,FSTYPE,MAJ:MIN,OPTIONS'])
|
||||
@@ -361,9 +425,17 @@ class SystemProbe:
|
||||
return self._block_source(mounts[0], topo)
|
||||
|
||||
|
||||
def validate_request(request, probe):
|
||||
if not isinstance(request.disk, str) or not DEVICE.fullmatch(request.disk) or '..' in PurePosixPath(request.disk).parts:
|
||||
def validate_request(request, probe, *, return_plan=False):
|
||||
existing = request.root_partition is not None or request.esp_partition is not None
|
||||
if request.root_fs not in {'ext4', 'btrfs'}:
|
||||
raise InstallError('Root filesystem must be ext4 or btrfs')
|
||||
if existing:
|
||||
if request.disk is not None or request.confirm_erase is not None:
|
||||
raise InstallError('Choose exactly one installation mode; partitions cannot use --disk/--confirm-erase')
|
||||
elif not isinstance(request.disk, str) or not DEVICE.fullmatch(request.disk) or '..' in PurePosixPath(request.disk).parts:
|
||||
raise InstallError('Choose an explicit /dev whole-disk device')
|
||||
elif request.confirm_format is not None:
|
||||
raise InstallError('--confirm-format is only valid for existing partitions')
|
||||
if not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', request.user) or request.user in {'root', 'hydra-live', 'greetd'}:
|
||||
raise InstallError('Choose a valid nonreserved lowercase Unix username')
|
||||
if not re.fullmatch(r'[A-Za-z_+-]+(?:/[A-Za-z0-9_+-]+)+', request.timezone):
|
||||
@@ -372,6 +444,9 @@ def validate_request(request, probe):
|
||||
raise InstallError('Payload must be an absolute path without control characters')
|
||||
if request.kernel is not None and not KERNEL.fullmatch(request.kernel):
|
||||
raise InstallError('Invalid kernel version')
|
||||
if existing:
|
||||
plan = storage.partition_plan(request, probe, probe.snapshot(request.payload))
|
||||
return plan if return_plan else plan.root
|
||||
target = probe.resolve(request.disk)
|
||||
if not DEVICE.fullmatch(target) or not probe.physical_disk(target):
|
||||
raise InstallError(f'Target is not a physical whole block disk: {target}')
|
||||
@@ -385,6 +460,10 @@ def validate_request(request, probe):
|
||||
forbidden = topo.component(target) & topo.protected
|
||||
if forbidden:
|
||||
raise InstallError('Target backs mounted/root/live storage: ' + ', '.join(sorted(forbidden)))
|
||||
probe.verify_identity(target, topo.nodes[target])
|
||||
if return_plan:
|
||||
return storage.DiskPlan(target, topo.nodes[target]['size'],
|
||||
tuple(sorted((p, storage.fingerprint(topo.nodes[p])) for p in topo.component(target))))
|
||||
return target
|
||||
|
||||
|
||||
@@ -393,7 +472,7 @@ def partitions(target):
|
||||
return target + suffix + '1', target + suffix + '2'
|
||||
|
||||
|
||||
def partition_commands(target):
|
||||
def partition_commands(target, root_fs='ext4'):
|
||||
esp, root = partitions(target)
|
||||
return [
|
||||
['parted', '--script', target, 'mklabel', 'gpt'],
|
||||
@@ -402,20 +481,38 @@ def partition_commands(target):
|
||||
['parted', '--script', target, 'unit', 'MiB', 'mkpart', 'root', 'ext4', '1025MiB', '100%'],
|
||||
['partprobe', target], ['udevadm', 'settle'],
|
||||
['mkfs.vfat', '-F', '32', '-n', 'HYDRA_EFI', esp],
|
||||
['mkfs.ext4', '-F', '-L', 'HYDRA_ROOT', root],
|
||||
storage.format_root(root, root_fs),
|
||||
]
|
||||
|
||||
|
||||
def grub_command(root):
|
||||
def grub_command(root, preserve=False):
|
||||
return ['chroot', str(root), 'grub-install', '--target=x86_64-efi',
|
||||
'--efi-directory=/boot/efi', '--bootloader-id=HydraOS', '--removable', '--no-nvram']
|
||||
'--efi-directory=/boot/efi', '--bootloader-id=HydraOS',
|
||||
*([] if preserve else ['--removable']), '--no-nvram']
|
||||
|
||||
|
||||
def render_plan(request, target, root):
|
||||
esp, part = partitions(target)
|
||||
commands = partition_commands(target) + [
|
||||
['mount', part, str(root)], ['unsquashfs', '-f', '-d', str(root), request.payload],
|
||||
['mount', esp, str(Path(root) / 'boot/efi')],
|
||||
existing = request.root_partition is not None
|
||||
esp, part = (request.esp_partition, target) if existing else partitions(target)
|
||||
commands = [['unsquashfs', '-s', request.payload], ['unsquashfs', '-lln', request.payload]]
|
||||
if existing:
|
||||
temporary = str(Path(root).parent / 'esp-preflight-<random>')
|
||||
commands.extend([['mount', '-t', 'vfat', '-o', 'ro,nosuid,nodev,noexec', esp, temporary],
|
||||
['umount', temporary]])
|
||||
commands.extend([storage.format_root(part, request.root_fs)] if existing else partition_commands(target, request.root_fs))
|
||||
if request.root_fs == 'btrfs':
|
||||
top = str(Path(root).parent / 'btrfs-top')
|
||||
commands.append(['mount', '-t', 'btrfs', '-o', 'subvolid=5', part, top])
|
||||
commands.extend(['btrfs', 'subvolume', 'create', str(Path(top) / name)]
|
||||
for name in ('@root', '@home', '@snapshots'))
|
||||
commands.extend([['umount', top], ['mount', '-t', 'btrfs', '-o', 'subvol=@root', part, str(root)],
|
||||
['mount', '-t', 'btrfs', '-o', 'subvol=@home', part, str(Path(root) / 'home')],
|
||||
['mount', '-t', 'btrfs', '-o', 'subvol=@snapshots', part, str(Path(root) / '.snapshots')]])
|
||||
else:
|
||||
commands.append(['mount', '-t', 'ext4', part, str(root)])
|
||||
commands += [
|
||||
['unsquashfs', '-f', '-d', str(root), request.payload],
|
||||
['mount', '-t', 'vfat', esp, str(Path(root) / 'boot/efi')],
|
||||
['blkid', '-s', 'UUID', '-o', 'value', part],
|
||||
['blkid', '-s', 'UUID', '-o', 'value', esp],
|
||||
['mount', '--rbind', '/dev', str(Path(root) / 'dev')],
|
||||
@@ -430,12 +527,18 @@ def render_plan(request, target, root):
|
||||
['chroot', str(root), 'systemd-machine-id-setup'],
|
||||
['chroot', str(root), 'dracut', '--force', '--no-hostonly', '--omit', 'dmsquash-live',
|
||||
'/boot/initramfs-<kernel>.img', '<kernel>'],
|
||||
grub_command(root), ['chroot', str(root), 'grub-mkconfig', '-o', '/boot/grub/grub.cfg'],
|
||||
grub_command(root, preserve=True),
|
||||
*([] if existing else [grub_command(root)]),
|
||||
['chroot', str(root), 'grub-mkconfig', '-o', '/boot/grub/grub.cfg'],
|
||||
]
|
||||
lines = [f'HydraOS {"EXECUTION" if request.execute else "dry-run"}: erase all data on {target}.',
|
||||
'GPT: exactly 1 GiB FAT32 ESP, remaining space ext4 root.',
|
||||
lines = [f'Hydra OS {"EXECUTION" if request.execute else "dry-run"}: erase all data on {target}.',
|
||||
f'GPT: exactly 1 GiB FAT32 ESP, remaining space {request.root_fs} root.',
|
||||
f'Prepared payload: {request.payload}; account: {request.user}; timezone: {request.timezone}.',
|
||||
'Commands (UUIDs/kernel discovered after unpacking; no commands run in dry-run):']
|
||||
if existing:
|
||||
lines[:2] = [f'Hydra OS {"EXECUTION" if request.execute else "dry-run"}: format ONLY {target} as {request.root_fs}.',
|
||||
f'Preserve GPT, existing ESP {esp}, foreign EFI loaders, fallback loader and BootOrder.',
|
||||
'First inspect the ESP read-only for collision/free space and hash foreign files.']
|
||||
lines.extend(' ' + shlex.join(c) for c in commands)
|
||||
lines.extend(['Write UUID fstab, en_US.UTF-8 and ru_RU.UTF-8, timezone, installed dracut/GRUB defaults.',
|
||||
'Remove allowlisted live-mode files, hydra-live account and /home/hydra-live; reset machine-id.',
|
||||
@@ -443,6 +546,11 @@ def render_plan(request, target, root):
|
||||
'chpasswd receives the TTY-entered password on private stdin; root remains locked.',
|
||||
'Unmount tracked sys/proc/dev/ESP/root in reverse order on success or failure.',
|
||||
f'To execute, add --execute --confirm-erase {target} (as root; no automatic sudo).'])
|
||||
if existing:
|
||||
lines[-1] = f'To execute, add --execute --confirm-format {target} (as root; no automatic sudo).'
|
||||
lines.append('Default: select EFI/HydraOS/grubx64.efi in firmware manually; no firmware changes.')
|
||||
if request.root_fs == 'btrfs':
|
||||
lines.append('Btrfs: @root contains /boot and Portage; @home and @snapshots are separate subvolumes.')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
@@ -572,7 +680,10 @@ def install(request, runner, probe, root, password, log: TextIO):
|
||||
raise InstallError('Installation requires --execute')
|
||||
if not isinstance(password, str) or not password or any(c in password for c in '\r\n\x00'):
|
||||
raise InstallError('Password must be nonempty and contain no newline or NUL')
|
||||
target = validate_request(request, probe)
|
||||
validated = validate_request(request, probe, return_plan=True)
|
||||
existing = isinstance(validated, storage.PartitionPlan)
|
||||
target = validated.root if existing else validated.target
|
||||
esp, part = (validated.esp, validated.root) if existing else partitions(target)
|
||||
root = Path(root)
|
||||
if not root.is_absolute() or root.is_symlink() or not root.is_dir() or any(root.iterdir()):
|
||||
raise InstallError('Target mount directory must be an empty absolute directory')
|
||||
@@ -594,13 +705,46 @@ def install(request, runner, probe, root, password, log: TextIO):
|
||||
|
||||
try:
|
||||
run(['unsquashfs', '-s', request.payload])
|
||||
# Recheck after preflight, directly before the first destructive command.
|
||||
if validate_request(request, probe) != target:
|
||||
raise InstallError('Target device changed during validation')
|
||||
for argv in partition_commands(target):
|
||||
required = storage.payload_capacity(run(['unsquashfs', '-lln', request.payload]))
|
||||
capacity = validated.root_size if existing else validated.size - 1025 * 1024**2
|
||||
if capacity < required:
|
||||
raise InstallError('Root has insufficient capacity for the uncompressed payload and install reserve')
|
||||
foreign = None
|
||||
if existing:
|
||||
# An independent temporary mount keeps the target root empty.
|
||||
temporary = tempfile.mkdtemp(prefix='esp-preflight-', dir=root.parent)
|
||||
mount(['mount', '-t', 'vfat', '-o', 'ro,nosuid,nodev,noexec', esp, temporary], temporary)
|
||||
try:
|
||||
foreign = storage.inspect_esp(temporary)
|
||||
finally:
|
||||
# Never recurse through a directory that could still be mounted.
|
||||
run(['umount', temporary])
|
||||
mounted.pop()
|
||||
try:
|
||||
Path(temporary).rmdir()
|
||||
except OSError:
|
||||
pass # Retain unexpected contents; never delete ESP files.
|
||||
# Recheck the canonical paths, kernel identities, GPT IDs and bounds
|
||||
# after read-only ESP inspection, immediately before formatting.
|
||||
if validate_request(request, probe, return_plan=True) != validated:
|
||||
raise InstallError('Target device identity or partition bounds changed during validation')
|
||||
for argv in ([storage.format_root(part, request.root_fs)] if existing else partition_commands(target, request.root_fs)):
|
||||
run(argv)
|
||||
esp, part = partitions(target)
|
||||
mount(['mount', part, str(root)], root)
|
||||
if request.root_fs == 'btrfs':
|
||||
top = root.parent / 'btrfs-top'
|
||||
top.mkdir()
|
||||
mount(['mount', '-t', 'btrfs', '-o', 'subvolid=5', part, str(top)], top)
|
||||
for subvolume in ('@root', '@home', '@snapshots'):
|
||||
run(['btrfs', 'subvolume', 'create', str(top / subvolume)])
|
||||
run(['umount', str(top)])
|
||||
mounted.pop()
|
||||
top.rmdir()
|
||||
mount(['mount', '-t', 'btrfs', '-o', 'subvol=@root', part, str(root)], root)
|
||||
for directory, subvolume in (('home', '@home'), ('.snapshots', '@snapshots')):
|
||||
(root / directory).mkdir()
|
||||
mount(['mount', '-t', 'btrfs', '-o', 'subvol=' + subvolume, part, str(root / directory)], root / directory)
|
||||
else:
|
||||
mount(['mount', '-t', 'ext4', part, str(root)], root)
|
||||
run(['unsquashfs', '-f', '-d', str(root), request.payload])
|
||||
kernel = select_kernel(root, request.kernel)
|
||||
remove_live_files(root)
|
||||
@@ -609,12 +753,35 @@ def install(request, runner, probe, root, password, log: TextIO):
|
||||
raise InstallError('Timezone does not exist in payload: ' + request.timezone)
|
||||
for folder in ('boot/efi', 'dev', 'proc', 'sys'):
|
||||
target_path(root, folder).mkdir(parents=True, exist_ok=True)
|
||||
mount(['mount', esp, str(root / 'boot/efi')], root / 'boot/efi')
|
||||
mount(['mount', '-t', 'vfat', esp, str(root / 'boot/efi')], root / 'boot/efi')
|
||||
root_uuid = run(['blkid', '-s', 'UUID', '-o', 'value', part]).strip()
|
||||
esp_uuid = run(['blkid', '-s', 'UUID', '-o', 'value', esp]).strip()
|
||||
if any(not re.fullmatch(r'[A-Fa-f0-9-]+', value) for value in (root_uuid, esp_uuid)):
|
||||
raise InstallError('Missing or invalid partition UUID')
|
||||
write_config(root, 'etc/fstab', f'UUID={root_uuid} / ext4 defaults 0 1\nUUID={esp_uuid} /boot/efi vfat umask=0077 0 2\n')
|
||||
if request.root_fs == 'btrfs':
|
||||
root_lines = (f'UUID={root_uuid} / btrfs defaults,subvol=@root 0 0\n'
|
||||
f'UUID={root_uuid} /home btrfs defaults,subvol=@home 0 0\n'
|
||||
f'UUID={root_uuid} /.snapshots btrfs defaults,subvol=@snapshots 0 0\n')
|
||||
else:
|
||||
root_lines = f'UUID={root_uuid} / ext4 defaults 0 1\n'
|
||||
write_config(root, 'etc/fstab', root_lines + f'UUID={esp_uuid} /boot/efi vfat umask=0077 0 2\n')
|
||||
root_partuuid = run(['blkid', '-s', 'PARTUUID', '-o', 'value', part]).strip()
|
||||
esp_partuuid = run(['blkid', '-s', 'PARTUUID', '-o', 'value', esp]).strip()
|
||||
if not all(storage.PARTUUID.fullmatch(value) for value in (root_partuuid, esp_partuuid)):
|
||||
raise InstallError('Missing or invalid GPT partition UUID for installed storage marker')
|
||||
if existing:
|
||||
before_root, before_esp = validated.root_partuuid, validated.esp_partuuid
|
||||
if (root_partuuid.lower(), esp_partuuid.lower()) != (before_root.lower(), before_esp.lower()):
|
||||
raise InstallError('GPT partition identity changed after formatting')
|
||||
if esp_uuid.lower() != validated.esp_uuid.lower():
|
||||
raise InstallError('ESP filesystem identity changed after preflight')
|
||||
marker = dict(format=1, install_uuid=str(uuid.uuid4()), filesystem=request.root_fs,
|
||||
root_fs_uuid=root_uuid, root_partuuid=root_partuuid,
|
||||
esp_uuid=esp_uuid, esp_partuuid=esp_partuuid,
|
||||
owned_efi_path='EFI/HydraOS')
|
||||
if request.root_fs == 'btrfs':
|
||||
marker['root_subvolume'] = '@root'
|
||||
write_config(root, 'etc/hydra/storage.json', json.dumps(marker, sort_keys=True) + '\n', 0o600)
|
||||
write_config(root, 'etc/locale.gen', 'en_US.UTF-8 UTF-8\nru_RU.UTF-8 UTF-8\n')
|
||||
write_config(root, 'etc/locale.conf', 'LANG=en_US.UTF-8\n')
|
||||
write_config(root, 'etc/timezone', request.timezone + '\n')
|
||||
@@ -629,9 +796,13 @@ def install(request, runner, probe, root, password, log: TextIO):
|
||||
write_config(root, 'etc/machine-id', '')
|
||||
dbus_machine_id = target_path(root, 'var/lib/dbus') / 'machine-id'
|
||||
dbus_machine_id.unlink(missing_ok=True)
|
||||
boot_args = f'root=UUID={root_uuid}' + (' rootflags=subvol=@root' if request.root_fs == 'btrfs' else '')
|
||||
write_config(root, 'etc/dracut.conf.d/90-hydra-installed.conf',
|
||||
'hostonly="no"\nhostonly_cmdline="no"\nomit_dracutmodules+=" dmsquash-live "\n# No root=live: or rd.live arguments on the installed disk.\n')
|
||||
write_config(root, 'etc/default/grub', 'GRUB_DISTRIBUTOR="HydraOS"\nGRUB_CMDLINE_LINUX=""\nGRUB_TIMEOUT=3\n')
|
||||
'hostonly="no"\nhostonly_cmdline="no"\nomit_dracutmodules+=" dmsquash-live "\n'
|
||||
+ ('filesystems+=" btrfs "\n' if request.root_fs == 'btrfs' else '')
|
||||
+ f'kernel_cmdline="{boot_args}"\n')
|
||||
write_config(root, 'etc/default/grub', 'GRUB_DISTRIBUTOR="Hydra OS"\n'
|
||||
+ f'GRUB_CMDLINE_LINUX="{boot_args}"\nGRUB_TIMEOUT=3\nGRUB_DISABLE_OS_PROBER=true\n')
|
||||
write_config(root, 'etc/sudoers.d/90-hydra-admin', '%wheel ALL=(ALL:ALL) ALL\n', 0o440)
|
||||
mount(['mount', '--rbind', '/dev', str(root / 'dev')], root / 'dev', True)
|
||||
run(['mount', '--make-rslave', str(root / 'dev')])
|
||||
@@ -650,8 +821,16 @@ def install(request, runner, probe, root, password, log: TextIO):
|
||||
chroot('systemd-machine-id-setup')
|
||||
chroot('dracut', '--force', '--no-hostonly', '--omit', 'dmsquash-live',
|
||||
'/boot/initramfs-' + kernel + '.img', kernel)
|
||||
run(grub_command(root))
|
||||
run(grub_command(root, preserve=True))
|
||||
if not existing:
|
||||
run(grub_command(root))
|
||||
chroot('grub-mkconfig', '-o', '/boot/grub/grub.cfg')
|
||||
if existing and storage.foreign_efi_hashes(root / 'boot/efi', ignore_owned=True) != foreign:
|
||||
raise InstallError('Foreign ESP files changed; refusing successful installation')
|
||||
if request.register_boot_entry:
|
||||
parent, number = (validated.esp_parent, validated.esp_number) if existing else (target, 1)
|
||||
run(['efibootmgr', '--create-only', '--disk', parent, '--part', str(number),
|
||||
'--label', 'HydraOS', '--loader', r'\EFI\HydraOS\grubx64.efi'])
|
||||
run(['sync'])
|
||||
except (InstallError, OSError, KeyboardInterrupt) as exc:
|
||||
error = exc
|
||||
@@ -674,6 +853,8 @@ def install(request, runner, probe, root, password, log: TextIO):
|
||||
error = InstallError('Mount cleanup failed: ' + '; '.join(cleanup_errors))
|
||||
if error:
|
||||
raise InstallError(str(error)) from error
|
||||
if existing and not request.register_boot_entry:
|
||||
print('Select EFI/HydraOS/grubx64.efi manually in firmware; BootOrder was not changed.', file=log, flush=True)
|
||||
print('Installation finished; remove the live ISO before booting the installed disk.', file=log, flush=True)
|
||||
|
||||
|
||||
@@ -686,8 +867,13 @@ def preflight(request, runner):
|
||||
raise InstallError('Execution requires a TTY for password entry')
|
||||
if not Path('/sys/firmware/efi').is_dir():
|
||||
raise InstallError('Boot the live medium in UEFI mode before installation')
|
||||
for command in ('lsblk', 'findmnt', 'parted', 'partprobe', 'udevadm', 'mkfs.vfat',
|
||||
'mkfs.ext4', 'mount', 'umount', 'unsquashfs', 'blkid', 'chroot', 'sync'):
|
||||
commands = ['lsblk', 'findmnt', 'mount', 'umount', 'unsquashfs', 'blkid', 'chroot', 'sync']
|
||||
if request.root_partition is None:
|
||||
commands.extend(['parted', 'partprobe', 'udevadm', 'mkfs.vfat'])
|
||||
commands.extend(['btrfs', 'mkfs.btrfs'] if request.root_fs == 'btrfs' else ['mkfs.ext4'])
|
||||
if request.register_boot_entry:
|
||||
commands.append('efibootmgr')
|
||||
for command in commands:
|
||||
if shutil.which(command) is None:
|
||||
raise InstallError('Required live installer tool missing: ' + command)
|
||||
runner.run(['unsquashfs', '-s', request.payload])
|
||||
@@ -711,8 +897,13 @@ def prompt_password():
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description='HydraOS explicit whole-disk installer; dry-run by default')
|
||||
parser = argparse.ArgumentParser(description='Hydra OS explicit disk or existing-partition installer; dry-run by default')
|
||||
parser.add_argument('--disk', help='explicit physical whole-disk target; never automatically selected')
|
||||
parser.add_argument('--root-partition', help='existing root partition; formats only this partition')
|
||||
parser.add_argument('--esp-partition', help='existing FAT GPT ESP; never formatted in partition mode')
|
||||
parser.add_argument('--confirm-format', help='must exactly match the resolved root partition with --execute')
|
||||
parser.add_argument('--root-fs', choices=('ext4', 'btrfs'), default='ext4')
|
||||
parser.add_argument('--register-boot-entry', action='store_true', help='opt in to create-only EFI entry, preserving BootOrder')
|
||||
parser.add_argument('--user', help='installed administrator username')
|
||||
parser.add_argument('--payload', default=DEFAULT_PAYLOAD, help='prepared live squashfs root payload')
|
||||
parser.add_argument('--execute', action='store_true')
|
||||
@@ -722,8 +913,8 @@ def main(argv=None):
|
||||
args = parser.parse_args(argv)
|
||||
runner = Runner()
|
||||
try:
|
||||
if not args.disk:
|
||||
if args.execute or args.confirm_erase:
|
||||
if not args.disk and not args.root_partition and not args.esp_partition:
|
||||
if args.execute or args.confirm_erase or args.confirm_format or args.register_boot_entry:
|
||||
raise InstallError('--execute requires an explicit --disk and --user')
|
||||
topo = Topology.from_json(runner.run(['lsblk', '--json', '--paths', '--bytes', '--output',
|
||||
'PATH,TYPE,PKNAME,MOUNTPOINTS,SIZE,MAJ:MIN']))
|
||||
|
||||
+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)
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
"""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 _both_endian(field: bytes) -> int:
|
||||
half = len(field) // 2
|
||||
little = int.from_bytes(field[:half], 'little')
|
||||
if little != int.from_bytes(field[half:], 'big'):
|
||||
raise MediaError('Invalid ISO9660 descriptor: inconsistent byte-order fields')
|
||||
return little
|
||||
|
||||
|
||||
def _volume_extent(stream) -> None:
|
||||
"""Check ECMA-119 6.7.1 and PVD fields 8.4.8/8.4.12 against the file."""
|
||||
size = os.fstat(stream.fileno()).st_size
|
||||
extent = None
|
||||
udf = False
|
||||
terminated = False
|
||||
stream.seek(16 * 2048)
|
||||
for index in range(16, 257):
|
||||
sector = stream.read(2048)
|
||||
if len(sector) != 2048:
|
||||
break
|
||||
if sector[1:6] in (b'NSR02', b'NSR03') and sector[6] == 1:
|
||||
udf = True
|
||||
if sector[1:6] != b'CD001':
|
||||
continue
|
||||
if sector[0] == 1:
|
||||
if sector[6] != 1:
|
||||
raise MediaError('Unsupported ISO9660 primary volume descriptor version')
|
||||
blocks = _both_endian(sector[80:88])
|
||||
block_size = _both_endian(sector[128:132])
|
||||
# Raw ISO files use 2048-byte sectors. ECMA-119 6.2.2 permits
|
||||
# power-of-two logical blocks from 512 bytes up to sector size.
|
||||
if not blocks or block_size not in (512, 1024, 2048):
|
||||
raise MediaError('Invalid ISO9660 volume extent or logical block size')
|
||||
declared = blocks * block_size
|
||||
if declared > size:
|
||||
raise MediaError(f'ISO is truncated: declared volume extent is {declared} bytes, '
|
||||
f'but the image contains {size}; retry the direct ISO URL')
|
||||
if extent is not None and extent != declared:
|
||||
raise MediaError('Invalid ISO9660 descriptors: inconsistent volume extents')
|
||||
extent = declared
|
||||
elif sector[0] == 255 and sector[6] == 1:
|
||||
terminated = True
|
||||
if extent is not None and extent < (index + 1) * 2048:
|
||||
raise MediaError('Invalid ISO9660 volume extent: descriptor set lies outside the volume')
|
||||
break
|
||||
if extent is None:
|
||||
if udf:
|
||||
raise MediaError('Pure UDF image completeness is unsupported; use an ISO9660 or ISO9660/UDF hybrid ISO')
|
||||
raise MediaError('Not a supported ISO9660 image; use a direct ISO download URL rather than an HTML download page')
|
||||
if not terminated:
|
||||
raise MediaError('ISO9660 descriptor set is incomplete or unsupported; retry a complete ISO image')
|
||||
|
||||
|
||||
def validate_iso(path: Path, *, sha256: str | None = None) -> Path:
|
||||
"""Validate ISO9660 extent (including hybrids) 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}')
|
||||
with path.open('rb') as stream:
|
||||
_volume_extent(stream)
|
||||
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)
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
"""Readable terminal control; displaying this menu never starts guests."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from .profiles import load_profile, validate_name
|
||||
from .runtime import Runtime, RuntimeError, _private_dir
|
||||
from .session import request_refresh
|
||||
|
||||
|
||||
def open_vm(runtime, name, **kwargs):
|
||||
from .launch import open_vm as launch
|
||||
return launch(runtime, name, **kwargs)
|
||||
|
||||
|
||||
def run_source(runtime, source, **kwargs):
|
||||
from .launch import run_source as launch
|
||||
return launch(runtime, source, **kwargs)
|
||||
|
||||
|
||||
def download_iso(url, **kwargs):
|
||||
from .media import download_iso as download
|
||||
return download(url, **kwargs)
|
||||
|
||||
|
||||
def _safe(value) -> str:
|
||||
# Profile errors and terminal input can contain control sequences.
|
||||
return ''.join(char if char.isprintable() else '?' for char in str(value))
|
||||
|
||||
|
||||
def _last_selected(runtime: Runtime) -> str | None:
|
||||
try:
|
||||
data = json.loads((runtime.state / 'menu.json').read_text(encoding='utf-8'))
|
||||
return validate_name(data['last_selected'])
|
||||
except (OSError, ValueError, TypeError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
def _remember(runtime: Runtime, name: str) -> None:
|
||||
_private_dir(runtime.state)
|
||||
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=runtime.state,
|
||||
prefix='.menu-', delete=False) as stream:
|
||||
temporary = Path(stream.name)
|
||||
try:
|
||||
json.dump({'last_selected': validate_name(name)}, stream)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, runtime.state / 'menu.json')
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _profiles(runtime: Runtime):
|
||||
profiles = []
|
||||
for path in sorted(runtime.config.glob('*.toml')):
|
||||
try:
|
||||
profiles.append(load_profile(path))
|
||||
except (OSError, ValueError) as exc:
|
||||
print(f'Profile error — {_safe(path.name)}: {_safe(exc)}; repair with :shell.')
|
||||
return profiles
|
||||
|
||||
|
||||
def _render(runtime: Runtime, profiles, last: str | None):
|
||||
print('\nHydra OS — virtual machines\n')
|
||||
if not profiles:
|
||||
print('No VM profiles. Use :run to create one from an ISO path or direct URL.')
|
||||
else:
|
||||
print(f' # {"Name":<32} {"State":<16} RAM')
|
||||
for number, profile in enumerate(profiles, 1):
|
||||
try:
|
||||
status = runtime.status(profile.name)['status']
|
||||
except (RuntimeError, OSError, ValueError) as exc:
|
||||
status = 'error'
|
||||
print(f' {_safe(profile.name)}: {_safe(exc)}')
|
||||
marker = '*' if profile.name == last else ' '
|
||||
print(f'{marker}{number:>2} {profile.name:<32} {_safe(status):<16} {profile.memory} MiB')
|
||||
print('\nNumber/name opens one VM; Enter opens the last (*) or only VM.')
|
||||
print(':run / :new :download :network :shell :help :refresh :quit')
|
||||
print(':shutdown NAME requests guest shutdown; :detach NAME removes ISO while stopped.')
|
||||
|
||||
|
||||
def _preview(argv: list[str]):
|
||||
print('Command: ' + _safe(shlex.join(argv)))
|
||||
|
||||
|
||||
def _source_prompt(label: str, supplied: str | None = None) -> str:
|
||||
value = supplied if supplied is not None else input(label).strip()
|
||||
if not value:
|
||||
raise ValueError('Action cancelled; no changes made.')
|
||||
return value
|
||||
|
||||
|
||||
def _guided_run(runtime: Runtime, manifest: Path | None, source: str | None, *, defaults: Path):
|
||||
source = _source_prompt('ISO path or direct HTTP(S) URL (blank cancels)> ', source)
|
||||
name = validate_name(_source_prompt('New VM name (blank cancels)> '))
|
||||
# Reject duplicates before asking for options or downloading an image.
|
||||
if (runtime.config / (name + '.toml')).exists():
|
||||
raise ValueError(f'{name}: profile exists; enter its name or use hydra open {name}.')
|
||||
memory = int(input('RAM in MiB [4096]> ').strip() or '4096')
|
||||
cpus = int(input('CPUs [2]> ').strip() or '2')
|
||||
disk_size = input('Disk size [40G]> ').strip() or '40G'
|
||||
sha256 = input('SHA-256 (optional)> ').strip() or None
|
||||
prefix = ['hydra']
|
||||
if defaults != Path('/etc/hydra/defaults.toml'):
|
||||
prefix += ['--defaults', str(defaults)]
|
||||
_preview(prefix + ['run', _redact_url(source), '--name', name, '--memory', str(memory),
|
||||
'--cpus', str(cpus), '--disk-size', disk_size] + (['--sha256', sha256] if sha256 else []))
|
||||
code = run_source(runtime, source, name=name, memory=memory, cpus=cpus,
|
||||
disk_size=disk_size, sha256=sha256, manifest=manifest,
|
||||
defaults=defaults, progress=_progress())
|
||||
return code, name
|
||||
|
||||
|
||||
def _progress():
|
||||
previous = -1.0
|
||||
def report(received: int, total: int | None):
|
||||
nonlocal previous
|
||||
now = time.monotonic()
|
||||
if previous >= 0 and now - previous < 1 and received != total:
|
||||
return
|
||||
previous = now
|
||||
amount = f'{received / 1048576:.1f}'
|
||||
if total is not None:
|
||||
amount += f' / {total / 1048576:.1f}'
|
||||
print(f'Download: {amount} MiB', flush=True)
|
||||
return report
|
||||
|
||||
|
||||
def _redact_url(source: str) -> str:
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
try:
|
||||
parsed = urlsplit(source)
|
||||
if parsed.scheme.lower() in ('http', 'https'):
|
||||
# Query strings and credentials must never appear in previews.
|
||||
return urlunsplit((parsed.scheme, parsed.hostname or '', parsed.path,
|
||||
'redacted' if parsed.query else '', ''))
|
||||
except ValueError:
|
||||
return '<invalid URL>'
|
||||
return source
|
||||
|
||||
|
||||
def run_menu(runtime: Runtime, *, manifest: Path | None = None,
|
||||
defaults: Path = Path('/etc/hydra/defaults.toml')) -> int:
|
||||
last = _last_selected(runtime)
|
||||
while True:
|
||||
profiles = _profiles(runtime)
|
||||
names = [profile.name for profile in profiles]
|
||||
_render(runtime, profiles, last)
|
||||
try:
|
||||
choice = input('hydra> ').strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
return 0
|
||||
try:
|
||||
if not choice:
|
||||
choice = last if last in names else (names[0] if len(names) == 1 else '')
|
||||
if choice in names or choice.isdecimal():
|
||||
name = choice
|
||||
if choice.isdecimal():
|
||||
index = int(choice) - 1
|
||||
if not 0 <= index < len(names):
|
||||
raise ValueError('Choose a listed VM number or name.')
|
||||
name = names[index]
|
||||
_preview(['hydra', 'open', name])
|
||||
code = open_vm(runtime, name, manifest=manifest)
|
||||
if code == 0:
|
||||
try:
|
||||
_remember(runtime, name)
|
||||
except (OSError, RuntimeError) as exc:
|
||||
print(f'Could not remember selection: {_safe(exc)}')
|
||||
last = name
|
||||
if manifest is not None:
|
||||
return 0
|
||||
else:
|
||||
print(f'{name}: open returned {code}; retry or use :shell for diagnostics.')
|
||||
continue
|
||||
command = shlex.split(choice)
|
||||
action = command[0] if command else ''
|
||||
arguments = command[1:]
|
||||
if action in (':quit', ':help', ':shell', ':network', ':refresh') and arguments:
|
||||
raise ValueError(f'{action} takes no arguments.')
|
||||
if action == ':quit':
|
||||
return 0
|
||||
if action == ':shell':
|
||||
_preview([os.environ.get('SHELL', '/bin/sh')])
|
||||
subprocess.run([os.environ.get('SHELL', '/bin/sh')])
|
||||
elif action == ':network':
|
||||
nmtui = shutil.which('nmtui')
|
||||
sudo = shutil.which('sudo')
|
||||
if not nmtui:
|
||||
raise ValueError('Network setup needs nmtui (NetworkManager); use :shell to inspect networking.')
|
||||
argv = [nmtui] if os.geteuid() == 0 else [sudo, nmtui] if sudo else None
|
||||
if argv is None:
|
||||
raise ValueError('Network setup needs sudo; use :shell to inspect networking.')
|
||||
_preview(argv)
|
||||
result = subprocess.run(argv)
|
||||
if result.returncode:
|
||||
print(f'Network setup exited with {result.returncode}; menu remains available.')
|
||||
elif action == ':refresh':
|
||||
if manifest is not None:
|
||||
return request_refresh(manifest)
|
||||
print('Refreshing the VM list.')
|
||||
elif action in (':run', ':new'):
|
||||
if len(arguments) > 1:
|
||||
raise ValueError('Use :run followed by one quoted ISO path or URL, or follow the prompts.')
|
||||
code, name = _guided_run(runtime, manifest, arguments[0] if arguments else None,
|
||||
defaults=defaults)
|
||||
if code == 0:
|
||||
_remember(runtime, name)
|
||||
last = name
|
||||
if manifest is not None:
|
||||
return 0
|
||||
else:
|
||||
print(f'{name}: launch returned {code}; enter its name to retry.')
|
||||
elif action == ':download':
|
||||
if len(arguments) > 1:
|
||||
raise ValueError('Use :download followed by one quoted direct URL, or follow the prompts.')
|
||||
url = _source_prompt('Direct ISO HTTP(S) URL (blank cancels)> ', arguments[0] if arguments else None)
|
||||
sha256 = input('SHA-256 (optional)> ').strip() or None
|
||||
_preview(['hydra', 'download', _redact_url(url)] + (['--sha256', sha256] if sha256 else []))
|
||||
print('Downloading ISO; Ctrl+C cancels. Use a direct image URL, not a download page.')
|
||||
print('ISO saved: ' + _safe(download_iso(url, sha256=sha256, progress=_progress())))
|
||||
elif action in (':shutdown', ':detach'):
|
||||
if len(arguments) != 1:
|
||||
raise ValueError(f'Use {action} NAME.')
|
||||
name = validate_name(arguments[0])
|
||||
runtime.profile(name)
|
||||
_preview(['hydra', 'shutdown', name] if action == ':shutdown' else ['hydra', 'media', name, '--detach'])
|
||||
if action == ':shutdown':
|
||||
runtime.shutdown(name)
|
||||
print(f'{name}: guest shutdown requested; the guest controls completion.')
|
||||
else:
|
||||
runtime.media(name, None)
|
||||
print(f'{name}: installation ISO detached.')
|
||||
elif action == ':help':
|
||||
print('Open explicitly starts a stopped VM or reconnects a running VM. Unknown identities need inspection.')
|
||||
print('Ctrl+Alt+Shift+Left/Right switches VMs; Ctrl+Alt+Shift+Escape opens this menu.')
|
||||
print('Ctrl+Alt+Shift+R releases input; Ctrl+Alt+Shift+U opens USB.')
|
||||
print('Use :run for a new VM from a local ISO or direct URL. Blank source/name cancels.')
|
||||
print('Use :shell for hydra status NAME, editing profiles, and advanced CLI options.')
|
||||
else:
|
||||
print('Choose a VM number/name, or a colon command. Use :help for details.')
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print('\nAction cancelled; menu remains available.')
|
||||
except (RuntimeError, OSError, ValueError, subprocess.SubprocessError) as exc:
|
||||
print('Error: ' + _safe(exc))
|
||||
@@ -23,6 +23,12 @@ class QMPClient:
|
||||
self.socket.settimeout(timeout)
|
||||
self.socket.connect(str(path))
|
||||
greeting = self._receive(deadline)
|
||||
# Real QEMU can queue a SPICE event across monitor reconnects.
|
||||
# Consume only protocol events, retaining the original deadline.
|
||||
while 'event' in greeting:
|
||||
if not self._async_event(greeting):
|
||||
raise QMPError('Invalid QMP event before greeting')
|
||||
greeting = self._receive(deadline)
|
||||
if not isinstance(greeting.get('QMP'), dict):
|
||||
raise QMPError('Invalid QMP greeting')
|
||||
self._command('qmp_capabilities', None, deadline)
|
||||
@@ -32,6 +38,22 @@ class QMPClient:
|
||||
raise
|
||||
raise QMPError(f'QMP connection failed: {exc}') from exc
|
||||
|
||||
@staticmethod
|
||||
def _async_event(message: dict) -> bool:
|
||||
if not {'event', 'timestamp'} <= message.keys() <= {'event', 'timestamp', 'data'}:
|
||||
return False
|
||||
if not isinstance(message['event'], str) or not message['event']:
|
||||
return False
|
||||
if 'data' in message and not isinstance(message['data'], dict):
|
||||
return False
|
||||
stamp = message['timestamp']
|
||||
if (not isinstance(stamp, dict) or set(stamp) != {'seconds', 'microseconds'}
|
||||
or any(type(value) is not int for value in stamp.values())):
|
||||
return False
|
||||
seconds, micros = stamp['seconds'], stamp['microseconds']
|
||||
# QMP specifies (-1, -1) when the server cannot retrieve host time.
|
||||
return (seconds == -1 and micros == -1) or (seconds >= 0 and 0 <= micros < 1000000)
|
||||
|
||||
def _receive(self, deadline: float) -> dict:
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
|
||||
+97
-38
@@ -15,8 +15,8 @@ from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .profiles import validate_name
|
||||
from .runtime import Runtime, VMPaths, _private_dir
|
||||
from .profiles import load_profile, validate_name
|
||||
from .runtime import Runtime, RuntimeError, VMPaths, _private_dir
|
||||
|
||||
|
||||
class SessionError(ValueError):
|
||||
@@ -24,7 +24,7 @@ class SessionError(ValueError):
|
||||
|
||||
|
||||
def viewer_title(name: str) -> str:
|
||||
return 'HydraOS: ' + validate_name(name)
|
||||
return 'Hydra OS: ' + validate_name(name)
|
||||
|
||||
|
||||
def build_viewer_argv(paths: VMPaths, viewer: str, *, windowed=False) -> list[str]:
|
||||
@@ -101,7 +101,7 @@ def _viewer() -> str:
|
||||
viewer = _tool('HYDRA_VIEWER', 'remote-viewer')
|
||||
result = subprocess.run([viewer, '--help'], capture_output=True, text=True, timeout=10)
|
||||
if result.returncode or '--hide-toolbar' not in result.stdout:
|
||||
raise SessionError('Viewer lacks HydraOS toolbar/USB adaptation; use dev/tools.nix')
|
||||
raise SessionError('Viewer lacks Hydra OS toolbar/USB adaptation; use dev/tools.nix')
|
||||
return viewer
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ def _viewer_environment(directory: Path) -> dict[str, str]:
|
||||
settings.mkdir(parents=True, mode=0o700)
|
||||
(settings / 'settings').write_text('[virt-viewer]\nshare-clipboard=false\n', encoding='utf-8')
|
||||
env = dict(os.environ, XDG_CONFIG_HOME=str(config), GSETTINGS_BACKEND='memory')
|
||||
# A dedicated backend prevents accidental XWayland input behavior in HydraOS.
|
||||
# A dedicated backend prevents accidental XWayland input behavior in Hydra OS.
|
||||
if env.get('WAYLAND_DISPLAY'):
|
||||
env['GDK_BACKEND'] = 'wayland'
|
||||
return env
|
||||
@@ -162,7 +162,7 @@ def _module_argv(command: str, manifest: Path) -> list[str]:
|
||||
|
||||
|
||||
def _recovery_argv(manifest: Path, terminal: str) -> list[str]:
|
||||
return [terminal, '--title=HydraOS-recovery'] + _module_argv('recover', manifest)
|
||||
return [terminal, '--title=Hydra OS recovery'] + _module_argv('recover', manifest)
|
||||
|
||||
|
||||
def launch_session(runtime: Runtime, *, nested=False) -> int:
|
||||
@@ -173,11 +173,25 @@ def launch_session(runtime: Runtime, *, nested=False) -> int:
|
||||
version = subprocess.run([labwc, '--version'], capture_output=True, text=True, timeout=10)
|
||||
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')
|
||||
raise SessionError('Hydra OS requires labwc >= 0.20.0 for reserved host input bindings')
|
||||
_private_dir(runtime.runtime)
|
||||
selected = None
|
||||
while True:
|
||||
profiles = runtime.profiles()
|
||||
initial = next((p.name for p in profiles if runtime.status(p.name)['status'] == 'running'), None)
|
||||
profiles = []
|
||||
for path in sorted(runtime.config.glob('*.toml')):
|
||||
try:
|
||||
profiles.append(load_profile(path))
|
||||
except (RuntimeError, OSError, ValueError) as exc:
|
||||
print(f'hydra: {path.name}: {exc}', file=sys.stderr)
|
||||
initial = selected if selected in [p.name for p in profiles] else None
|
||||
if initial is None:
|
||||
for profile in profiles:
|
||||
try:
|
||||
if runtime.status(profile.name)['status'] not in ('stopped', 'unknown'):
|
||||
initial = profile.name
|
||||
break
|
||||
except (RuntimeError, OSError, ValueError) as exc:
|
||||
print(f'hydra: {profile.name}: {exc}', file=sys.stderr)
|
||||
with tempfile.TemporaryDirectory(prefix='session-', dir=runtime.runtime) as tmp:
|
||||
directory = Path(tmp)
|
||||
config = directory / 'labwc'
|
||||
@@ -192,6 +206,7 @@ def launch_session(runtime: Runtime, *, nested=False) -> int:
|
||||
(config / 'autostart').write_text('#!/bin/sh\n' + shlex.join(_module_argv('clients', manifest)) + ' &\n', encoding='utf-8')
|
||||
(config / 'environment').touch()
|
||||
env['PYTHONUNBUFFERED'] = '1'
|
||||
env['HYDRA_SESSION_MANIFEST'] = str(manifest)
|
||||
compositor = subprocess.Popen([labwc, '-C', str(config)], env=env)
|
||||
refresh = False
|
||||
try:
|
||||
@@ -210,12 +225,13 @@ def launch_session(runtime: Runtime, *, nested=False) -> int:
|
||||
compositor.wait(timeout=10)
|
||||
if not refresh:
|
||||
return compositor.returncode
|
||||
selected = _refresh_selection(directory / 'refresh.request')
|
||||
# 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 _manifest_data(manifest: Path) -> dict:
|
||||
data = json.loads(manifest.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()
|
||||
@@ -224,38 +240,79 @@ def session_helper(command: str, manifest: Path) -> int:
|
||||
raise SessionError('Invalid session manifest')
|
||||
for name in data['names']:
|
||||
validate_name(name)
|
||||
return data
|
||||
|
||||
|
||||
def request_refresh(manifest: Path, *, selected: str | None = None) -> int:
|
||||
"""Publish a complete request without controlling any guest process."""
|
||||
manifest = Path(manifest)
|
||||
data = _manifest_data(manifest)
|
||||
if selected is not None:
|
||||
validate_name(selected)
|
||||
# Profiles can be created after the compositor's original snapshot.
|
||||
Runtime(Path(data['config']), Path(data['state']), Path(data['runtime'])).profile(selected)
|
||||
payload = '' if selected is None else json.dumps({'selected': selected})
|
||||
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=manifest.parent,
|
||||
prefix='.refresh-', delete=False) as stream:
|
||||
temporary = Path(stream.name)
|
||||
try:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, manifest.parent / 'refresh.request')
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
print('Refreshing workspaces and viewers; VMs keep running.')
|
||||
return 0
|
||||
|
||||
|
||||
def _refresh_selection(request: Path) -> str | None:
|
||||
try:
|
||||
payload = request.read_text(encoding='utf-8')
|
||||
if not payload:
|
||||
return None
|
||||
data = json.loads(payload)
|
||||
if not isinstance(data, dict) or set(data) != {'selected'}:
|
||||
raise SessionError('Invalid refresh request')
|
||||
return validate_name(data['selected'])
|
||||
except (RuntimeError, OSError, ValueError) as exc:
|
||||
print(f'hydra: refresh selection ignored: {exc}', file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def _emergency_recovery(manifest: Path) -> int:
|
||||
print('Hydra OS emergency recovery. :shell, :refresh or :quit; VMs keep running.')
|
||||
while True:
|
||||
try:
|
||||
choice = input('hydra recovery> ').strip()
|
||||
if choice == ':quit':
|
||||
return 0
|
||||
if choice == ':shell':
|
||||
subprocess.run([os.environ.get('SHELL', '/bin/sh')])
|
||||
elif choice == ':refresh':
|
||||
return request_refresh(manifest)
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return 0
|
||||
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
||||
print(f'hydra: {exc}')
|
||||
|
||||
|
||||
def session_helper(command: str, manifest: Path) -> int:
|
||||
data = _manifest_data(manifest)
|
||||
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
|
||||
return request_refresh(manifest)
|
||||
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, :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))
|
||||
choice = input('hydra recovery> ').strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
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, :refresh, or :quit.')
|
||||
try:
|
||||
from .menu import run_menu
|
||||
return run_menu(runtime, manifest=manifest)
|
||||
except Exception as exc:
|
||||
# Recovery is the last local escape hatch, including menu defects.
|
||||
print(f'hydra: menu unavailable: {exc}')
|
||||
return _emergency_recovery(manifest)
|
||||
if command.startswith('reconnect:'):
|
||||
name = command.split(':', 1)[1]
|
||||
# One viewer per VM inside this session; independent host view commands
|
||||
@@ -275,9 +332,11 @@ def session_helper(command: str, manifest: Path) -> int:
|
||||
recovery = subprocess.Popen(_recovery_argv(manifest, data['terminal']))
|
||||
for name in data['names']:
|
||||
try:
|
||||
if runtime.status(name)['status'] == 'running':
|
||||
# Verified paused/prelaunch guests also have a live SPICE server;
|
||||
# reconnecting must not imply a guest start or resume.
|
||||
if runtime.status(name)['status'] not in ('stopped', 'unknown'):
|
||||
processes.append(subprocess.Popen(_module_argv('reconnect:' + name, manifest)))
|
||||
except (OSError, ValueError) as exc:
|
||||
except (RuntimeError, OSError, ValueError) as exc:
|
||||
print(f'hydra: {name}: {exc}', file=sys.stderr)
|
||||
if not processes:
|
||||
open_recovery()
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
"""Offline, live-ISO-only Btrfs base checkpoints. Never runs installed executables."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import ctypes
|
||||
from datetime import datetime, timezone
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
|
||||
class RecoveryError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
MARKER_KEYS = {'format', 'install_uuid', 'filesystem', 'root_fs_uuid', 'root_partuuid',
|
||||
'esp_uuid', 'esp_partuuid', 'owned_efi_path', 'root_subvolume'}
|
||||
ID_RE = re.compile(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\Z')
|
||||
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise RecoveryError(message)
|
||||
|
||||
|
||||
def valid_uuid(value):
|
||||
return isinstance(value, str) and bool(ID_RE.fullmatch(value.lower()))
|
||||
|
||||
|
||||
def validate_marker(value):
|
||||
require(isinstance(value, dict) and set(value) == MARKER_KEYS, 'Invalid storage marker fields')
|
||||
require(type(value['format']) is int and value['format'] == 1, 'Unsupported storage marker format')
|
||||
require(all(valid_uuid(value[k]) for k in ('install_uuid', 'root_fs_uuid', 'root_partuuid', 'esp_partuuid')),
|
||||
'Invalid storage UUID')
|
||||
require(isinstance(value['esp_uuid'], str) and re.fullmatch(r'[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}', value['esp_uuid']),
|
||||
'Invalid FAT filesystem UUID')
|
||||
require(value['filesystem'] == 'btrfs' and value['root_subvolume'] == '@root'
|
||||
and value['owned_efi_path'] == 'EFI/HydraOS', 'Unsupported storage layout')
|
||||
return value
|
||||
|
||||
|
||||
def safe_path(base, relative, *, missing=False):
|
||||
"""Refuse links/special files at every component; caller owns private mount."""
|
||||
base = Path(base)
|
||||
require(not base.is_symlink() and base.is_dir(), 'Unsafe base directory')
|
||||
parts = Path(relative).parts
|
||||
require(parts and not Path(relative).is_absolute() and all(p not in ('.', '..') for p in parts), 'Unsafe path')
|
||||
path = base
|
||||
for part in parts:
|
||||
path = path / part
|
||||
try:
|
||||
mode = path.lstat().st_mode
|
||||
except FileNotFoundError:
|
||||
require(missing, 'Missing path: ' + str(path))
|
||||
continue
|
||||
require(stat.S_ISDIR(mode) or stat.S_ISREG(mode), 'Link or special file refused: ' + str(path))
|
||||
return path
|
||||
|
||||
|
||||
def read_json(path):
|
||||
path = Path(path)
|
||||
info = path.lstat()
|
||||
require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1 and info.st_uid == os.geteuid()
|
||||
and not info.st_mode & 0o022, 'Metadata must be a trusted regular file: ' + str(path))
|
||||
require(info.st_size <= 16 * 1024 * 1024, 'Metadata too large')
|
||||
def unique(pairs):
|
||||
out = {}
|
||||
for key, value in pairs:
|
||||
require(key not in out, 'Duplicate JSON field')
|
||||
out[key] = value
|
||||
return out
|
||||
try:
|
||||
return json.loads(path.read_text(), object_pairs_hook=unique)
|
||||
except (ValueError, UnicodeError) as exc:
|
||||
raise RecoveryError('Malformed metadata: ' + str(path)) from exc
|
||||
|
||||
|
||||
def fsync_dir(path):
|
||||
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def durable_json(path, value):
|
||||
path = Path(path)
|
||||
fd, name = tempfile.mkstemp(prefix='.metadata-', dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as stream:
|
||||
json.dump(value, stream, sort_keys=True, indent=2)
|
||||
stream.write('\n'); stream.flush(); os.fsync(stream.fileno())
|
||||
os.replace(name, path)
|
||||
fsync_dir(path.parent)
|
||||
finally:
|
||||
if os.path.exists(name): os.unlink(name)
|
||||
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
||||
|
||||
|
||||
def file_hash(path):
|
||||
with Path(path).open('rb') as stream:
|
||||
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
||||
|
||||
|
||||
def tree_manifest(root):
|
||||
"""FAT-compatible names, regular files/directories, exact backup metadata."""
|
||||
root = Path(root)
|
||||
require(root.is_dir() and not root.is_symlink(), 'Owned EFI tree must be a directory')
|
||||
entries = []
|
||||
def walk(directory):
|
||||
folded = set()
|
||||
for path in sorted(directory.iterdir()):
|
||||
name = path.name
|
||||
require(re.fullmatch(r'[A-Za-z0-9_+., -]+', name) and name not in ('.', '..')
|
||||
and not name.endswith((' ', '.')), 'Unsafe EFI name')
|
||||
require(name.casefold() not in folded, 'Ambiguous FAT filename')
|
||||
folded.add(name.casefold())
|
||||
info = path.lstat()
|
||||
require(stat.S_ISDIR(info.st_mode) or stat.S_ISREG(info.st_mode), 'Unsafe EFI file type')
|
||||
require(not stat.S_ISREG(info.st_mode) or info.st_nlink == 1, 'EFI hardlink refused')
|
||||
entry = dict(path=path.relative_to(root).as_posix(), kind='dir' if path.is_dir() else 'file',
|
||||
mode=stat.S_IMODE(info.st_mode), mtime_ns=info.st_mtime_ns)
|
||||
if path.is_file():
|
||||
entry.update(size=info.st_size, sha256=file_hash(path))
|
||||
entries.append(entry)
|
||||
if path.is_dir(): walk(path)
|
||||
walk(root)
|
||||
return entries
|
||||
|
||||
|
||||
def sync_tree(root):
|
||||
for path in Path(root).rglob('*'):
|
||||
if path.is_file():
|
||||
with path.open('rb') as stream: os.fsync(stream.fileno())
|
||||
for directory, _, _ in os.walk(root, topdown=False): fsync_dir(directory)
|
||||
|
||||
|
||||
class Btrfs:
|
||||
def run(self, argv):
|
||||
try:
|
||||
return subprocess.run(argv, check=True, text=True, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, env=dict(os.environ, LC_ALL='C')).stdout
|
||||
except subprocess.CalledProcessError as exc:
|
||||
detail = (exc.stderr or '').strip()[:2000]
|
||||
raise RecoveryError(f'{argv[0]} failed ({exc.returncode}): {detail}') from exc
|
||||
def info(self, path):
|
||||
output = self.run(['btrfs', 'subvolume', 'show', str(path)])
|
||||
fields = dict(line.strip().split(':', 1) for line in output.splitlines() if ':' in line)
|
||||
value = fields.get('UUID', '').strip()
|
||||
parent = fields.get('Parent UUID', '').strip()
|
||||
require(valid_uuid(value), 'Not a Btrfs subvolume: ' + str(path))
|
||||
ro = self.run(['btrfs', 'property', 'get', '-ts', str(path), 'ro']).strip()
|
||||
require(ro in ('ro=true', 'ro=false'), 'Cannot establish snapshot readonly state')
|
||||
return dict(uuid=value, parent_uuid=None if parent == '-' else parent, readonly=ro == 'ro=true')
|
||||
def nested(self, path):
|
||||
return self.run(['btrfs', 'subvolume', 'list', '-o', str(path)]).splitlines()
|
||||
def snapshot(self, source, dest, readonly):
|
||||
self.run(['btrfs', 'subvolume', 'snapshot', *(['-r'] if readonly else []), str(source), str(dest)])
|
||||
return self.info(dest)
|
||||
def sync(self, path):
|
||||
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
try:
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
if libc.syncfs(fd) != 0: raise OSError(ctypes.get_errno(), 'syncfs failed')
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
class RecoverySession:
|
||||
"""Mounted engine; CLI supplies verified identities and private offline mounts."""
|
||||
def __init__(self, top, esp, backend, identities):
|
||||
self.top, self.esp, self.backend = Path(top), Path(esp), backend
|
||||
self.identities = dict(identities)
|
||||
self.snapshots = safe_path(self.top, '@snapshots')
|
||||
self.journal = self.snapshots / 'restore.json'
|
||||
for name in ('@snapshots', '@home'):
|
||||
path = safe_path(self.top, name)
|
||||
require(not self.backend.info(path)['readonly'], 'Layout subvolume is readonly')
|
||||
|
||||
def marker(self, root):
|
||||
marker = validate_marker(read_json(safe_path(root, 'etc/hydra/storage.json')))
|
||||
for key, expected in self.identities.items():
|
||||
require(marker.get(key) == expected, 'Storage identity mismatch: ' + key)
|
||||
require(not self.backend.nested(root), 'Nested subvolumes under root are unsupported')
|
||||
return marker
|
||||
|
||||
def current_root(self):
|
||||
root = safe_path(self.top, '@root')
|
||||
require(not self.backend.info(root)['readonly'], 'Installed root is readonly')
|
||||
return root, self.marker(root)
|
||||
|
||||
def efi_root(self, *, missing=False):
|
||||
# EFI itself must exist. Only the owned directory may be recreated.
|
||||
safe_path(self.esp, 'EFI')
|
||||
root = safe_path(self.esp, 'EFI/HydraOS', missing=missing)
|
||||
for parent, expected in ((self.esp, 'EFI'), (self.esp / 'EFI', 'HydraOS')):
|
||||
require(not any(p.name.casefold() == expected.casefold() and p.name != expected for p in parent.iterdir()),
|
||||
'Case alias of owned EFI directory refused')
|
||||
names = [p.name.casefold() for p in parent.iterdir()]
|
||||
require(len(names) == len(set(names)), 'Ambiguous EFI directory names')
|
||||
return root
|
||||
|
||||
def checkpoint(self, name, *, dry_run=False):
|
||||
safe_path(self.snapshots, 'restore.json', missing=True)
|
||||
require(not self.journal.exists(), 'Resume pending restore before checkpointing')
|
||||
require(isinstance(name, str) and 1 <= len(name) <= 100 and all(ord(c) >= 32 for c in name), 'Invalid checkpoint name')
|
||||
root, storage = self.current_root()
|
||||
efi = self.efi_root()
|
||||
manifest = tree_manifest(efi)
|
||||
source = self.backend.info(root)
|
||||
if dry_run:
|
||||
return dict(operation='checkpoint', execute=False, name=name, storage=storage)
|
||||
ident = str(uuid.uuid4())
|
||||
directory = self.snapshots / ident
|
||||
directory.mkdir(mode=0o700)
|
||||
fsync_dir(self.snapshots)
|
||||
snapshot = self.backend.snapshot(root, directory / 'root', True)
|
||||
require(snapshot['readonly'] and snapshot['parent_uuid'] == source['uuid'], 'Invalid new snapshot')
|
||||
shutil.copytree(efi, directory / 'efi', copy_function=shutil.copy2)
|
||||
require(tree_manifest(directory / 'efi') == manifest, 'EFI changed during checkpoint')
|
||||
sync_tree(directory / 'efi')
|
||||
self.backend.sync(self.top)
|
||||
descriptor = dict(format=1, id=ident, name=name, created=datetime.now(timezone.utc).isoformat(),
|
||||
storage=storage, source_root_uuid=source['uuid'], snapshot_uuid=snapshot['uuid'],
|
||||
efi_manifest=manifest)
|
||||
durable_json(directory / 'checkpoint.json', descriptor)
|
||||
self.backend.sync(self.top)
|
||||
return descriptor
|
||||
|
||||
def descriptor(self, ident):
|
||||
require(valid_uuid(ident), 'Invalid checkpoint ID')
|
||||
directory = safe_path(self.snapshots, ident)
|
||||
require({p.name for p in directory.iterdir()} == {'root', 'efi', 'checkpoint.json'}, 'Unexpected checkpoint files')
|
||||
value = read_json(safe_path(directory, 'checkpoint.json'))
|
||||
require(isinstance(value, dict) and set(value) == {'format', 'id', 'name', 'created', 'storage',
|
||||
'source_root_uuid', 'snapshot_uuid', 'efi_manifest'}, 'Invalid checkpoint fields')
|
||||
require(type(value['format']) is int and value['format'] == 1 and value['id'] == ident,
|
||||
'Unsupported checkpoint descriptor')
|
||||
require(isinstance(value['name'], str) and isinstance(value['created'], str)
|
||||
and valid_uuid(value['source_root_uuid']) and valid_uuid(value['snapshot_uuid']), 'Invalid checkpoint metadata')
|
||||
validate_marker(value['storage'])
|
||||
snapshot = safe_path(directory, 'root')
|
||||
info = self.backend.info(snapshot)
|
||||
require(info == dict(uuid=value['snapshot_uuid'], parent_uuid=value['source_root_uuid'], readonly=True),
|
||||
'Checkpoint snapshot identity or readonly state changed')
|
||||
require(self.marker(snapshot) == value['storage'], 'Checkpoint storage identity changed')
|
||||
require(tree_manifest(safe_path(directory, 'efi')) == value['efi_manifest'], 'EFI backup metadata/hash mismatch')
|
||||
return value
|
||||
|
||||
def list(self):
|
||||
result = []
|
||||
for directory in sorted(self.snapshots.iterdir()):
|
||||
if valid_uuid(directory.name) and not directory.is_symlink() and (directory / 'checkpoint.json').exists():
|
||||
result.append(self.descriptor(directory.name))
|
||||
return result
|
||||
|
||||
def save_journal(self, value, phase):
|
||||
value['phase'] = phase
|
||||
durable_json(self.journal, value)
|
||||
self.backend.sync(self.top)
|
||||
|
||||
def restore(self, ident, *, dry_run=False):
|
||||
safe_path(self.snapshots, 'restore.json', missing=True)
|
||||
descriptor = self.descriptor(ident) # ALL backup validation precedes any write.
|
||||
source = self.snapshots / ident / 'root'
|
||||
owned = self.efi_root(missing=True)
|
||||
if owned.exists(): tree_manifest(owned)
|
||||
if self.journal.exists():
|
||||
tx = read_json(self.journal)
|
||||
require(isinstance(tx, dict) and set(tx) == {'format', 'id', 'checkpoint', 'checkpoint_digest',
|
||||
'storage', 'old_uuid', 'new_uuid', 'prepared_root', 'failed_root', 'phase'}, 'Invalid restore journal')
|
||||
require(type(tx['format']) is int and tx['format'] == 1 and valid_uuid(tx['id'])
|
||||
and tx['checkpoint'] == ident and tx['checkpoint_digest'] == digest(descriptor)
|
||||
and tx['storage'] == descriptor['storage'] and valid_uuid(tx['old_uuid'])
|
||||
and (tx['new_uuid'] is None or valid_uuid(tx['new_uuid']))
|
||||
and tx['phase'] in ('preparing', 'prepared', 'root-retained', 'root-installed', 'efi-restored')
|
||||
and tx['prepared_root'] == '@restore-' + tx['id'] and tx['failed_root'] == '@failed-' + tx['id'],
|
||||
'Restore journal does not match requested checkpoint')
|
||||
else:
|
||||
root, marker = self.current_root()
|
||||
require(marker == descriptor['storage'], 'Current root belongs to a different installation')
|
||||
require(not any(p.name.startswith('@restore-') for p in self.top.iterdir()), 'Unexplained prepared root')
|
||||
txid = str(uuid.uuid4())
|
||||
tx = dict(format=1, id=txid, checkpoint=ident, checkpoint_digest=digest(descriptor),
|
||||
storage=marker, old_uuid=self.backend.info(root)['uuid'], new_uuid=None,
|
||||
prepared_root='@restore-' + txid, failed_root='@failed-' + txid, phase='preparing')
|
||||
prepared = safe_path(self.top, tx['prepared_root'], missing=True)
|
||||
failed = safe_path(self.top, tx['failed_root'], missing=True)
|
||||
root = safe_path(self.top, '@root', missing=True)
|
||||
def identity(path):
|
||||
if not path.exists(): return None
|
||||
info = self.backend.info(path)
|
||||
require(not info['readonly'] and self.marker(path) == tx['storage'], 'Unexpected root identity or layout')
|
||||
return info
|
||||
actual_root, actual_prepared, actual_failed = identity(root), identity(prepared), identity(failed)
|
||||
# Enumerate the only three legitimate rename arrangements. No phase-based guessing.
|
||||
if tx['new_uuid'] is None:
|
||||
require(tx['phase'] == 'preparing' and actual_root and actual_root['uuid'] == tx['old_uuid']
|
||||
and actual_failed is None, 'Unexplained initial restore state')
|
||||
if actual_prepared is None:
|
||||
if dry_run:
|
||||
return dict(operation='restore', execute=False, checkpoint=ident, storage=descriptor['storage'])
|
||||
if not self.journal.exists(): self.save_journal(tx, 'preparing')
|
||||
actual_prepared = self.backend.snapshot(source, prepared, False)
|
||||
fsync_dir(self.top); self.backend.sync(self.top)
|
||||
require(actual_prepared['parent_uuid'] == descriptor['snapshot_uuid'] and not actual_prepared['readonly'],
|
||||
'Prepared snapshot source mismatch')
|
||||
tx['new_uuid'] = actual_prepared['uuid']
|
||||
if not dry_run: self.save_journal(tx, 'prepared')
|
||||
new_uuid, old_uuid = tx['new_uuid'], tx['old_uuid']
|
||||
state = (actual_root['uuid'] if actual_root else None, actual_prepared['uuid'] if actual_prepared else None,
|
||||
actual_failed['uuid'] if actual_failed else None)
|
||||
require(state in ((old_uuid, new_uuid, None), (None, new_uuid, old_uuid), (new_uuid, None, old_uuid)),
|
||||
'Unexplained root rename state; preserve all roots and inspect offline')
|
||||
active_new = actual_prepared if actual_prepared else actual_root
|
||||
require(active_new['parent_uuid'] == descriptor['snapshot_uuid'], 'Prepared root parent UUID mismatch')
|
||||
if dry_run:
|
||||
return dict(operation='restore', execute=False, checkpoint=ident, storage=descriptor['storage'])
|
||||
if state == (old_uuid, new_uuid, None):
|
||||
os.rename(root, failed); fsync_dir(self.top); self.backend.sync(self.top)
|
||||
self.save_journal(tx, 'root-retained')
|
||||
state = (None, new_uuid, old_uuid)
|
||||
if state == (None, new_uuid, old_uuid):
|
||||
os.rename(prepared, root); fsync_dir(self.top); self.backend.sync(self.top)
|
||||
self.save_journal(tx, 'root-installed')
|
||||
self.restore_efi(self.snapshots / ident / 'efi', owned)
|
||||
self.backend.sync(self.esp)
|
||||
self.save_journal(tx, 'efi-restored')
|
||||
# Archive rather than discard the identity record and retained root path.
|
||||
os.replace(self.journal, self.snapshots / ('restored-' + tx['id'] + '.json'))
|
||||
fsync_dir(self.snapshots); self.backend.sync(self.top)
|
||||
return dict(checkpoint=ident, failed_root=tx['failed_root'], restored_root_uuid=new_uuid)
|
||||
|
||||
def restore_efi(self, source, dest):
|
||||
# Backup and existing destination have already passed complete path validation.
|
||||
# Repeating this copy is safe after interruption at any individual FAT write.
|
||||
if dest.exists():
|
||||
for child in list(dest.iterdir()):
|
||||
if child.is_dir(): shutil.rmtree(child)
|
||||
else: child.unlink()
|
||||
else:
|
||||
dest.mkdir()
|
||||
shutil.copytree(source, dest, dirs_exist_ok=True, copy_function=shutil.copy2)
|
||||
sync_tree(dest); fsync_dir(dest.parent)
|
||||
expected = [(e['path'], e.get('sha256')) for e in tree_manifest(source)]
|
||||
actual = [(e['path'], e.get('sha256')) for e in tree_manifest(dest)]
|
||||
require(actual == expected, 'Restored EFI verification failed; rerun the same restore')
|
||||
|
||||
|
||||
def flatten(nodes):
|
||||
result = {}
|
||||
def visit(node):
|
||||
require(isinstance(node, dict) and isinstance(node.get('path'), str), 'Malformed device topology')
|
||||
path = node['path']
|
||||
require(re.fullmatch(r'/dev/[A-Za-z0-9_./-]+', path) and '..' not in Path(path).parts, 'Invalid device path')
|
||||
require(path not in result or result[path] == node, 'Conflicting device topology')
|
||||
result[path] = node
|
||||
require(isinstance(node.get('children', []), list), 'Malformed device children')
|
||||
for child in node.get('children', []): visit(child)
|
||||
for node in nodes: visit(node)
|
||||
return result
|
||||
|
||||
|
||||
def resolve_partition(nodes, key, value, filesystem):
|
||||
devices = flatten(nodes)
|
||||
matches = [n for n in devices.values() if str(n.get(key, '')).lower() == value.lower()]
|
||||
require(len(matches) == 1, 'Missing or ambiguous ' + key + ': ' + value)
|
||||
selected = matches[0]
|
||||
require(selected.get('type') == 'part' and selected.get('fstype') == filesystem
|
||||
and not selected.get('children'), 'Target must be an ordinary partition of type ' + filesystem)
|
||||
require(isinstance(selected.get('mountpoints'), list) and not any(selected['mountpoints']), 'Target is mounted or active swap')
|
||||
for field in ('uuid', 'partuuid'):
|
||||
identity = selected.get(field)
|
||||
require(isinstance(identity, str) and identity and sum(str(n.get(field, '')).lower() == identity.lower()
|
||||
for n in devices.values()) == 1, 'Missing or duplicate target ' + field)
|
||||
parent = selected.get('pkname', '')
|
||||
if not parent.startswith('/dev/'): parent = '/dev/' + parent
|
||||
require(parent in devices and devices[parent].get('type') == 'disk', 'Target parent must be an ordinary disk')
|
||||
require(not any(devices[parent].get('mountpoints', [])), 'Parent disk is mounted')
|
||||
require(re.fullmatch(r'\d+:\d+', selected.get('maj:min', '')), 'Missing target block identity')
|
||||
return selected
|
||||
|
||||
|
||||
def validate_live(base=Path('/'), cmdline=None, mountinfo=None):
|
||||
base = Path(base)
|
||||
path = safe_path(base, 'etc/hydra/live-mode/active')
|
||||
info = path.lstat()
|
||||
require(stat.S_ISREG(info.st_mode) and info.st_uid == os.geteuid() and info.st_nlink == 1
|
||||
and not info.st_mode & 0o022, 'Untrusted live marker')
|
||||
for directory in (path.parent, path.parent.parent, path.parent.parent.parent):
|
||||
info = directory.stat()
|
||||
require(info.st_uid == os.geteuid() and not info.st_mode & 0o022, 'Untrusted live marker directory')
|
||||
if cmdline is None: cmdline = Path('/proc/cmdline').read_text()
|
||||
if mountinfo is None: mountinfo = Path('/proc/self/mountinfo').read_text()
|
||||
words = cmdline.split()
|
||||
require('rd.live.image' in words and any(w.startswith('root=live:') for w in words), 'Recovery requires a Hydra live boot')
|
||||
mounts = [line.split() for line in mountinfo.splitlines()]
|
||||
require(any(len(parts) > 8 and parts[4] == '/' and '-' in parts
|
||||
and parts[parts.index('-') + 1] == 'overlay' for parts in mounts)
|
||||
and any(' - squashfs ' in line for line in mountinfo.splitlines()), 'Live root must use overlay and squashfs')
|
||||
|
||||
|
||||
class DeviceProbe:
|
||||
def __init__(self, backend, *, proc=Path('/proc'), sysroot=Path('/sys')):
|
||||
self.backend, self.proc, self.sysroot = backend, Path(proc), Path(sysroot)
|
||||
def nodes(self):
|
||||
data = json.loads(self.backend.run(['lsblk', '--json', '--paths', '--output',
|
||||
'PATH,TYPE,PKNAME,MAJ:MIN,FSTYPE,UUID,PARTUUID,MOUNTPOINTS']))
|
||||
require(isinstance(data, dict) and isinstance(data.get('blockdevices'), list), 'Malformed lsblk result')
|
||||
nodes = data['blockdevices']
|
||||
# Probe signatures directly; do not trust stale udev UUID caches.
|
||||
for node in flatten(nodes).values():
|
||||
result = subprocess.run(['blkid', '-p', '-o', 'export', node['path']], text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
require(result.returncode in (0, 2), 'Ambiguous or unreadable device signatures')
|
||||
fields = dict(line.split('=', 1) for line in result.stdout.splitlines() if '=' in line)
|
||||
node.update(uuid=fields.get('UUID'), partuuid=fields.get('PART_ENTRY_UUID'), fstype=fields.get('TYPE'))
|
||||
return nodes
|
||||
def check(self, node):
|
||||
path = Path(node['path'])
|
||||
info = path.stat()
|
||||
require(stat.S_ISBLK(info.st_mode) and f'{os.major(info.st_rdev)}:{os.minor(info.st_rdev)}' == node['maj:min'],
|
||||
'Device identity changed')
|
||||
sysnode = self.sysroot / 'dev/block' / node['maj:min']
|
||||
require((sysnode / 'partition').is_file() and not any((sysnode / 'holders').iterdir()), 'Target has holders or is not a partition')
|
||||
parent = sysnode.resolve().parent
|
||||
require(not any((parent / 'holders').iterdir()), 'Parent disk has active holders')
|
||||
for line in (self.proc / 'self/mountinfo').read_text().splitlines():
|
||||
fields = line.split(); separator = fields.index('-')
|
||||
source = fields[separator + 2]
|
||||
require(fields[2] != node['maj:min'], 'Target is mounted')
|
||||
if source.startswith('/dev/'):
|
||||
try: mounted = Path(source).stat()
|
||||
except FileNotFoundError: raise RecoveryError('Cannot resolve mounted block source')
|
||||
require(mounted.st_rdev != info.st_rdev, 'Target backs a mounted filesystem')
|
||||
for line in (self.proc / 'swaps').read_text().splitlines()[1:]:
|
||||
source = Path(line.split()[0])
|
||||
swap = source.stat()
|
||||
require((swap.st_rdev if stat.S_ISBLK(swap.st_mode) else swap.st_dev) != info.st_rdev,
|
||||
'Target has active swap')
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def operation_lock():
|
||||
fd = os.open('/run/hydra-system.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
require(stat.S_ISREG(info.st_mode) and info.st_uid == 0 and info.st_nlink == 1 and not info.st_mode & 0o077,
|
||||
'Untrusted operation lock')
|
||||
try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError: raise RecoveryError('Another hydra-system operation is running')
|
||||
yield
|
||||
finally: os.close(fd)
|
||||
|
||||
|
||||
def private_namespace(backend):
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
if libc.unshare(0x00020000) != 0: raise OSError(ctypes.get_errno(), 'Cannot create private mount namespace')
|
||||
backend.run(['mount', '--make-rprivate', '/'])
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def mounted(backend, device, filesystem, writable=False):
|
||||
directory = Path(tempfile.mkdtemp(prefix='hydra-system-', dir='/run'))
|
||||
did_mount = False
|
||||
try:
|
||||
options = ['rw' if writable else 'ro', 'nosuid', 'nodev', 'noexec']
|
||||
if filesystem == 'btrfs':
|
||||
options.append('subvolid=5')
|
||||
if not writable: options.append('rescue=nologreplay')
|
||||
backend.run(['mount', '-t', filesystem, '-o', ','.join(options), device, str(directory)])
|
||||
did_mount = True
|
||||
if filesystem == 'btrfs':
|
||||
show = backend.run(['btrfs', 'filesystem', 'show', '--raw', str(directory)])
|
||||
require(re.search(r'Total devices\s+1\b', show) and len(re.findall(r'\bdevid\s+\d+', show)) == 1,
|
||||
'Recovery requires a single-device Btrfs filesystem')
|
||||
yield directory
|
||||
finally:
|
||||
if did_mount:
|
||||
# A failed unmount deliberately leaves the directory intact; never recurse into a mount.
|
||||
backend.run(['umount', str(directory)])
|
||||
directory.rmdir()
|
||||
|
||||
|
||||
def mounted_storage(top):
|
||||
if (top / '@root').exists():
|
||||
return validate_marker(read_json(safe_path(top, '@root/etc/hydra/storage.json')))
|
||||
journal = read_json(safe_path(top, '@snapshots/restore.json'))
|
||||
require(isinstance(journal, dict) and isinstance(journal.get('storage'), dict), 'Missing root without valid recovery journal')
|
||||
return validate_marker(journal['storage'])
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest='command', required=True)
|
||||
for name in ('checkpoint', 'list', 'restore'):
|
||||
command = commands.add_parser(name)
|
||||
command.add_argument('--root-uuid', required=True)
|
||||
if name != 'list':
|
||||
command.add_argument('--execute', action='store_true')
|
||||
command.add_argument('--confirm-root-uuid')
|
||||
if name == 'checkpoint':
|
||||
command.add_argument('--esp-partuuid', required=True)
|
||||
command.add_argument('--name', required=True)
|
||||
if name == 'restore': command.add_argument('--checkpoint', required=True)
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
require(valid_uuid(args.root_uuid), 'Invalid root filesystem UUID')
|
||||
execute = getattr(args, 'execute', False)
|
||||
if execute:
|
||||
require(args.confirm_root_uuid == args.root_uuid, 'Execution requires --confirm-root-uuid matching --root-uuid')
|
||||
require(os.geteuid() == 0, 'Run from the Hydra live ISO as root; no automatic sudo')
|
||||
validate_live()
|
||||
backend = Btrfs(); probe = DeviceProbe(backend)
|
||||
with operation_lock():
|
||||
nodes = probe.nodes()
|
||||
root = resolve_partition(nodes, 'uuid', args.root_uuid, 'btrfs'); probe.check(root)
|
||||
private_namespace(backend)
|
||||
# First inspect read-only, without log replay, before opening either filesystem writable.
|
||||
with mounted(backend, root['path'], 'btrfs') as top:
|
||||
marker = mounted_storage(top)
|
||||
require(marker['root_fs_uuid'] == args.root_uuid and marker['root_partuuid'] == root['partuuid'], 'Root ownership mismatch')
|
||||
if args.command == 'checkpoint': require(args.esp_partuuid == marker['esp_partuuid'], 'ESP differs from installation record')
|
||||
esp = resolve_partition(nodes, 'partuuid', marker['esp_partuuid'], 'vfat'); probe.check(esp)
|
||||
require(esp['uuid'] == marker['esp_uuid'], 'ESP filesystem identity mismatch')
|
||||
with mounted(backend, esp['path'], 'vfat') as efi:
|
||||
session = RecoverySession(top, efi, backend, marker)
|
||||
if args.command == 'list': result = session.list()
|
||||
elif args.command == 'restore':
|
||||
result = session.restore(args.checkpoint, dry_run=True)
|
||||
else:
|
||||
result = session.checkpoint(args.name, dry_run=True)
|
||||
if execute:
|
||||
# Re-resolve and check after releasing inspection mounts, immediately before writes.
|
||||
nodes = probe.nodes()
|
||||
current_root = resolve_partition(nodes, 'uuid', args.root_uuid, 'btrfs')
|
||||
current_esp = resolve_partition(nodes, 'partuuid', marker['esp_partuuid'], 'vfat')
|
||||
for before, after in ((root, current_root), (esp, current_esp)):
|
||||
require(all(before.get(k) == after.get(k) for k in ('path', 'uuid', 'partuuid', 'maj:min', 'fstype')), 'Target changed during planning')
|
||||
probe.check(after)
|
||||
with mounted(backend, root['path'], 'btrfs', True) as top:
|
||||
with mounted(backend, esp['path'], 'vfat', True) as efi:
|
||||
session = RecoverySession(top, efi, backend, marker)
|
||||
result = session.checkpoint(args.name) if args.command == 'checkpoint' else session.restore(args.checkpoint)
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
return 0
|
||||
except (RecoveryError, OSError, ValueError, subprocess.CalledProcessError) as exc:
|
||||
print('hydra-system: ' + str(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,630 @@
|
||||
"""Trusted, self-contained runtime updater. Installed outside the runtime payload.
|
||||
|
||||
Only Python's standard library and OpenSSL are used. Never import hydra here.
|
||||
"""
|
||||
import argparse
|
||||
from contextlib import contextmanager
|
||||
import fcntl
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
import zipfile
|
||||
|
||||
LIMIT = 16 * 1024 * 1024
|
||||
MANIFEST_LIMIT = 256 * 1024
|
||||
FILE_LIMIT = 4 * 1024 * 1024
|
||||
MAX_FILES = 256
|
||||
ORIGIN = 'https://git.shanaty.ru'
|
||||
RELEASES = ORIGIN + '/api/v1/repos/Emil_Shanaty/Hydra-OS/releases?limit=20'
|
||||
REPOSITORY_RELEASE = ORIGIN + '/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/'
|
||||
EXACT = {'usr/lib/hydra/bin/hydra', 'etc/motd', 'etc/fastfetch/config.jsonc',
|
||||
'usr/share/hydra/fastfetch.txt'}
|
||||
|
||||
|
||||
class UpdateError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def digest(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def allowed(name):
|
||||
return isinstance(name, str) and (name in EXACT or
|
||||
re.fullmatch(r'usr/lib/hydra/hydra/(?!update)[a-z_]+\.py', name) is not None)
|
||||
|
||||
|
||||
def integer(value, minimum=0, maximum=LIMIT):
|
||||
return type(value) is int and minimum <= value <= maximum
|
||||
|
||||
|
||||
def sha(value):
|
||||
return isinstance(value, str) and re.fullmatch('[0-9a-f]{64}', value) is not None
|
||||
|
||||
|
||||
def parse_json(data):
|
||||
def pairs(items):
|
||||
result = {}
|
||||
for key, value in items:
|
||||
if key in result:
|
||||
raise UpdateError('Duplicate JSON key')
|
||||
result[key] = value
|
||||
return result
|
||||
try:
|
||||
return json.loads(data, object_pairs_hook=pairs,
|
||||
parse_constant=lambda _: (_ for _ in ()).throw(UpdateError('Invalid JSON number')))
|
||||
except (UnicodeError, json.JSONDecodeError, RecursionError) as exc:
|
||||
raise UpdateError('Invalid JSON') from exc
|
||||
|
||||
|
||||
def read_bytes(path, limit):
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
with os.fdopen(fd, 'rb') as stream:
|
||||
info = os.fstat(stream.fileno())
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_size > limit:
|
||||
raise UpdateError('Oversized or nonregular file')
|
||||
data = stream.read(limit + 1)
|
||||
if len(data) > limit:
|
||||
raise UpdateError('Oversized file')
|
||||
return data
|
||||
|
||||
|
||||
def validate_manifest(raw, signature, public_key):
|
||||
if len(raw) > MANIFEST_LIMIT or len(signature) != 64:
|
||||
raise UpdateError('Invalid signature or manifest size')
|
||||
with tempfile.TemporaryDirectory(prefix='hydra-verify-') as directory:
|
||||
directory = Path(directory)
|
||||
(directory / 'manifest').write_bytes(raw)
|
||||
(directory / 'signature').write_bytes(signature)
|
||||
# Copy the key so a changing external file cannot race OpenSSL's read.
|
||||
(directory / 'key').write_bytes(read_bytes(public_key, 4096))
|
||||
result = subprocess.run(['openssl', 'pkeyutl', '-verify', '-pubin', '-inkey', str(directory / 'key'),
|
||||
'-rawin', '-in', str(directory / 'manifest'), '-sigfile', str(directory / 'signature')],
|
||||
capture_output=True, timeout=15, env={'PATH': '/usr/bin:/bin'})
|
||||
if result.returncode:
|
||||
raise UpdateError('Ed25519 signature verification failed')
|
||||
manifest = parse_json(raw)
|
||||
expected = {'format', 'product', 'scope', 'channel', 'architecture', 'sequence', 'version', 'source_commit', 'archive', 'files'}
|
||||
if not isinstance(manifest, dict) or set(manifest) != expected:
|
||||
raise UpdateError('Invalid manifest fields')
|
||||
for key, value in {'format': 1, 'product': 'Hydra OS', 'scope': 'runtime', 'channel': 'stable', 'architecture': 'x86_64'}.items():
|
||||
if type(manifest[key]) is not type(value) or manifest[key] != value:
|
||||
raise UpdateError('Unsupported manifest ' + key)
|
||||
if not integer(manifest['sequence'], 1, 2**63 - 1):
|
||||
raise UpdateError('Invalid release sequence')
|
||||
if not isinstance(manifest['version'], str) or not re.fullmatch(r'[0-9][A-Za-z0-9._-]{0,63}', manifest['version']):
|
||||
raise UpdateError('Invalid release version')
|
||||
if not isinstance(manifest['source_commit'], str) or not re.fullmatch('[0-9a-f]{40}', manifest['source_commit']):
|
||||
raise UpdateError('Invalid source commit')
|
||||
archive = manifest['archive']
|
||||
if (not isinstance(archive, dict) or set(archive) != {'name', 'size', 'sha256'} or
|
||||
archive['name'] != 'runtime.zip' or not integer(archive['size'], 1) or not sha(archive['sha256'])):
|
||||
raise UpdateError('Invalid archive metadata')
|
||||
files = manifest['files']
|
||||
if not isinstance(files, dict) or not 1 <= len(files) <= MAX_FILES:
|
||||
raise UpdateError('Invalid file count')
|
||||
for name, entry in files.items():
|
||||
if (not allowed(name) or not isinstance(entry, dict) or set(entry) != {'size', 'sha256', 'mode'} or
|
||||
not integer(entry['size'], 0, FILE_LIMIT) or not sha(entry['sha256']) or
|
||||
type(entry['mode']) is not int or entry['mode'] not in (0o644, 0o755)):
|
||||
raise UpdateError('Invalid or forbidden payload path/metadata')
|
||||
if sum(entry['size'] for entry in files.values()) > LIMIT:
|
||||
raise UpdateError('Oversized payload')
|
||||
if 'usr/lib/hydra/hydra/cli.py' not in files:
|
||||
raise UpdateError('Missing runtime CLI')
|
||||
return manifest
|
||||
|
||||
|
||||
def validate_archive(data, manifest):
|
||||
metadata = manifest['archive']
|
||||
if len(data) != metadata['size'] or digest(data) != metadata['sha256']:
|
||||
raise UpdateError('Archive checksum/size mismatch')
|
||||
payload = {}
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
infos = archive.infolist()
|
||||
names = [entry.filename for entry in infos]
|
||||
if len(names) != len(set(names)) or set(names) != set(manifest['files']):
|
||||
raise UpdateError('Duplicate or unlisted archive entry')
|
||||
for info in infos:
|
||||
entry = manifest['files'][info.filename]
|
||||
mode = info.external_attr >> 16
|
||||
if (info.is_dir() or stat.S_IFMT(mode) != stat.S_IFREG or info.flag_bits & 1 or
|
||||
info.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED) or
|
||||
info.file_size != entry['size']):
|
||||
raise UpdateError('Invalid archive file type/size')
|
||||
with archive.open(info) as stream:
|
||||
content = stream.read(entry['size'] + 1)
|
||||
if len(content) != entry['size'] or digest(content) != entry['sha256']:
|
||||
raise UpdateError('Payload checksum/size mismatch')
|
||||
payload[info.filename] = content
|
||||
except (zipfile.BadZipFile, RuntimeError, NotImplementedError) as exc:
|
||||
raise UpdateError('Invalid archive') from exc
|
||||
return payload
|
||||
|
||||
|
||||
def fsync_dir(path):
|
||||
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def atomic_write(path, data, mode=0o600, uid=None, gid=None):
|
||||
fd, temporary = tempfile.mkstemp(prefix='.hydra-update-', dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as stream:
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
if uid is not None:
|
||||
os.fchown(stream.fileno(), uid, gid)
|
||||
os.fchmod(stream.fileno(), mode)
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
fsync_dir(path.parent)
|
||||
finally:
|
||||
Path(temporary).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def write_json(path, value, mode=0o600):
|
||||
atomic_write(path, (json.dumps(value, sort_keys=True) + '\n').encode(), mode)
|
||||
|
||||
|
||||
class Updater:
|
||||
def __init__(self, root=Path('/'), public_key=None):
|
||||
self.root = Path(root).absolute()
|
||||
if self.root.resolve() != self.root or not self.root.is_dir():
|
||||
raise UpdateError('Unsafe target root')
|
||||
self.owner = 0 if self.root == Path('/') else os.geteuid()
|
||||
if self.root == Path('/') and public_key is not None:
|
||||
raise UpdateError('Production trust key cannot be overridden')
|
||||
self.public_key = Path(public_key) if public_key else self.root / 'etc/hydra/update.pub'
|
||||
self.base = self.root / 'var/lib/hydra-update'
|
||||
self.public_status = self.root / 'usr/share/hydra/update-status.json'
|
||||
|
||||
def path(self, name, *, leaf_link=True):
|
||||
parts = name.split('/')
|
||||
if any(part in ('', '.', '..') for part in parts):
|
||||
raise UpdateError('Unsafe path')
|
||||
current = self.root
|
||||
for index, part in enumerate(parts):
|
||||
current = current / part
|
||||
leaf = index == len(parts) - 1
|
||||
try:
|
||||
info = current.lstat()
|
||||
except FileNotFoundError:
|
||||
if leaf:
|
||||
return current
|
||||
raise UpdateError('Missing destination parent: ' + name)
|
||||
if info.st_uid != self.owner or (not stat.S_ISLNK(info.st_mode) and info.st_mode & 0o022):
|
||||
raise UpdateError('Unsafe path ownership/permissions: ' + name)
|
||||
if not leaf and not stat.S_ISDIR(info.st_mode):
|
||||
raise UpdateError('Unsafe symlink or non-directory parent: ' + name)
|
||||
if leaf and not (stat.S_ISREG(info.st_mode) or leaf_link and stat.S_ISLNK(info.st_mode)):
|
||||
raise UpdateError('Unsafe destination type: ' + name)
|
||||
return current
|
||||
|
||||
def target(self):
|
||||
self.path('usr/share/hydra/build-manifest.json', leaf_link=False)
|
||||
if not (self.root / 'usr/share/hydra/build-manifest.json').is_file():
|
||||
raise UpdateError('Target is not a Hydra installation')
|
||||
if self.root == Path('/'):
|
||||
if os.geteuid() != 0:
|
||||
raise UpdateError('Use sudo inside Hydra for update mutations')
|
||||
if os.uname().machine != 'x86_64':
|
||||
raise UpdateError('Unsupported architecture')
|
||||
release = read_bytes(self.path('etc/os-release', leaf_link=False), 65536)
|
||||
if not re.search(rb'^ID=(?:"hydra-os"|hydra-os)$', release, re.M):
|
||||
raise UpdateError('Target is not Hydra OS')
|
||||
self.path('etc/hydra/update.pub', leaf_link=False)
|
||||
|
||||
def state(self):
|
||||
if not self.base.exists():
|
||||
return {'highest_sequence': 0, 'current': None}
|
||||
path = self.path('var/lib/hydra-update/state.json', leaf_link=False)
|
||||
if not path.exists():
|
||||
return {'highest_sequence': 0, 'current': None}
|
||||
result = parse_json(read_bytes(path, MANIFEST_LIMIT))
|
||||
if not isinstance(result, dict) or set(result) != {'highest_sequence', 'current'} or not integer(result['highest_sequence'], 0, 2**63 - 1):
|
||||
raise UpdateError('Corrupt update state')
|
||||
self.validate_current(result['current'])
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def validate_current(value):
|
||||
if value is not None and (not isinstance(value, dict) or set(value) != {'transaction', 'sequence', 'version'} or
|
||||
not re.fullmatch('[0-9a-f]{32}', str(value.get('transaction'))) or
|
||||
not integer(value.get('sequence'), 1, 2**63 - 1) or not isinstance(value.get('version'), str)):
|
||||
raise UpdateError('Corrupt current update state')
|
||||
|
||||
def pending(self):
|
||||
directory = self.base / 'transactions'
|
||||
if not directory.exists():
|
||||
return []
|
||||
self.path('var/lib/hydra-update/transactions/.probe')
|
||||
result = []
|
||||
for item in sorted(directory.iterdir()):
|
||||
if not re.fullmatch('[0-9a-f]{32}', item.name):
|
||||
raise UpdateError('Unexpected transaction directory')
|
||||
path = self.path('var/lib/hydra-update/transactions/' + item.name + '/journal.json', leaf_link=False)
|
||||
if not path.exists():
|
||||
# Backup preparation interrupted before any destination mutation.
|
||||
continue
|
||||
journal = parse_json(read_bytes(path, MANIFEST_LIMIT))
|
||||
if not isinstance(journal, dict) or journal.get('phase') not in ('prepared', 'committed', 'restoring', 'recovered', 'rolled_back'):
|
||||
raise UpdateError('Corrupt journal')
|
||||
if journal['phase'] in ('prepared', 'restoring'):
|
||||
result.append(item.name)
|
||||
return result
|
||||
|
||||
def status(self):
|
||||
try:
|
||||
value = self.state()
|
||||
return dict(value, pending=self.pending())
|
||||
except PermissionError:
|
||||
value = parse_json(read_bytes(self.public_status, MANIFEST_LIMIT))
|
||||
return value
|
||||
|
||||
def publish(self):
|
||||
self.path('usr/share/hydra/update-status.json', leaf_link=False)
|
||||
write_json(self.public_status, dict(self.state(), pending=self.pending()), 0o644)
|
||||
|
||||
@contextmanager
|
||||
def lock(self):
|
||||
self.target()
|
||||
self.path('var/lib/.probe')
|
||||
if not self.base.exists():
|
||||
self.base.mkdir(mode=0o700)
|
||||
fsync_dir(self.base.parent)
|
||||
self.path('var/lib/hydra-update/.probe')
|
||||
if stat.S_IMODE(self.base.stat().st_mode) != 0o700:
|
||||
raise UpdateError('Update state must be private (0700)')
|
||||
path = self.path('var/lib/hydra-update/lock', leaf_link=False)
|
||||
fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
try:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError as exc:
|
||||
raise UpdateError('Another update operation holds the lock') from exc
|
||||
yield
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
def local(self, directory):
|
||||
directory = Path(directory)
|
||||
raw = read_bytes(directory / 'manifest.json', MANIFEST_LIMIT)
|
||||
signature = read_bytes(directory / 'manifest.sig', 64)
|
||||
manifest = validate_manifest(raw, signature, self.public_key)
|
||||
payload = validate_archive(read_bytes(directory / 'runtime.zip', LIMIT), manifest)
|
||||
return manifest, payload
|
||||
|
||||
def apply(self, directory):
|
||||
manifest, payload = self.local(directory)
|
||||
self.target()
|
||||
# Preflight every destination before state creation or backups.
|
||||
for name in payload:
|
||||
self.path(name)
|
||||
with self.lock():
|
||||
if self.pending():
|
||||
raise UpdateError('Recovery pending; run hydra-update-recover')
|
||||
state = self.state()
|
||||
if manifest['sequence'] <= state['highest_sequence']:
|
||||
raise UpdateError('Release sequence rejected (replay)')
|
||||
transaction = uuid.uuid4().hex
|
||||
directory = self.base / 'transactions'
|
||||
if not directory.exists():
|
||||
directory.mkdir(mode=0o700)
|
||||
fsync_dir(self.base)
|
||||
saved = directory / transaction
|
||||
saved.mkdir(mode=0o700)
|
||||
fsync_dir(directory)
|
||||
(saved / 'backup').mkdir(mode=0o700)
|
||||
fsync_dir(saved)
|
||||
records = {}
|
||||
for index, name in enumerate(sorted(payload)):
|
||||
dest = self.path(name)
|
||||
if not dest.exists() and not dest.is_symlink():
|
||||
records[name] = {'type': 'absent'}
|
||||
continue
|
||||
info = dest.lstat()
|
||||
record = {'mode': stat.S_IMODE(info.st_mode), 'uid': info.st_uid, 'gid': info.st_gid}
|
||||
if stat.S_ISLNK(info.st_mode):
|
||||
record.update(type='symlink', target=os.readlink(dest))
|
||||
else:
|
||||
content = read_bytes(dest, FILE_LIMIT)
|
||||
record.update(type='file', sha256=digest(content), size=len(content), backup=str(index))
|
||||
atomic_write(saved / 'backup' / str(index), content)
|
||||
records[name] = record
|
||||
journal = {'format': 1, 'transaction': transaction, 'phase': 'prepared',
|
||||
'previous': state['current'], 'release': {'transaction': transaction, 'sequence': manifest['sequence'], 'version': manifest['version']},
|
||||
'files': manifest['files'], 'originals': records}
|
||||
write_json(saved / 'journal.json', journal)
|
||||
# Reserve the sequence before mutation; recovery never lowers this floor.
|
||||
state['highest_sequence'] = manifest['sequence']
|
||||
write_json(self.base / 'state.json', state)
|
||||
self.publish()
|
||||
for name, content in sorted(payload.items()):
|
||||
atomic_write(self.path(name), content, manifest['files'][name]['mode'], self.owner, 0 if self.owner == 0 else os.getegid())
|
||||
state['current'] = journal['release']
|
||||
write_json(self.base / 'state.json', state)
|
||||
journal['phase'] = 'committed'
|
||||
write_json(saved / 'journal.json', journal)
|
||||
self.publish()
|
||||
return dict(journal['release'], applied=True)
|
||||
|
||||
def load_journal(self, transaction):
|
||||
if not re.fullmatch('[0-9a-f]{32}', transaction):
|
||||
raise UpdateError('Invalid transaction identifier')
|
||||
prefix = 'var/lib/hydra-update/transactions/' + transaction
|
||||
path = self.path(prefix + '/journal.json', leaf_link=False)
|
||||
journal = parse_json(read_bytes(path, MANIFEST_LIMIT))
|
||||
if (not isinstance(journal, dict) or set(journal) != {'format', 'transaction', 'phase', 'previous', 'release', 'files', 'originals'} or
|
||||
type(journal['format']) is not int or journal['format'] != 1 or journal['transaction'] != transaction or
|
||||
journal['phase'] not in ('prepared', 'committed', 'restoring', 'recovered', 'rolled_back')):
|
||||
raise UpdateError('Corrupt journal')
|
||||
self.validate_current(journal['previous'])
|
||||
self.validate_current(journal['release'])
|
||||
if journal['release'] is None or journal['release']['transaction'] != transaction:
|
||||
raise UpdateError('Corrupt journal release')
|
||||
files, records = journal['files'], journal['originals']
|
||||
if not isinstance(files, dict) or not isinstance(records, dict) or set(files) != set(records) or not 1 <= len(files) <= MAX_FILES:
|
||||
raise UpdateError('Corrupt journal records')
|
||||
contents = {}
|
||||
for name, record in records.items():
|
||||
if not allowed(name) or not isinstance(record, dict):
|
||||
raise UpdateError('Unsafe backup path')
|
||||
self.path(name)
|
||||
entry = files[name]
|
||||
if (not isinstance(entry, dict) or set(entry) != {'mode', 'size', 'sha256'} or
|
||||
not integer(entry['mode']) or entry['mode'] not in (0o644, 0o755) or
|
||||
not integer(entry['size'], 0, FILE_LIMIT) or not sha(entry['sha256'])):
|
||||
raise UpdateError('Corrupt journal file')
|
||||
kind = record.get('type')
|
||||
if kind == 'absent' and set(record) == {'type'}:
|
||||
continue
|
||||
if kind not in ('file', 'symlink') or not integer(record.get('mode'), 0, 0o7777) or not integer(record.get('uid'), 0, 2**32-1) or not integer(record.get('gid'), 0, 2**32-1):
|
||||
raise UpdateError('Corrupt backup metadata')
|
||||
if kind == 'symlink':
|
||||
if set(record) != {'type', 'mode', 'uid', 'gid', 'target'} or not isinstance(record['target'], str) or '\0' in record['target'] or not 1 <= len(record['target']) <= 4096:
|
||||
raise UpdateError('Corrupt backup symlink')
|
||||
else:
|
||||
if (set(record) != {'type', 'mode', 'uid', 'gid', 'sha256', 'size', 'backup'} or
|
||||
not isinstance(record['backup'], str) or not re.fullmatch('[0-9]{1,3}', record['backup']) or
|
||||
not integer(record['size'], 0, FILE_LIMIT) or not sha(record['sha256'])):
|
||||
raise UpdateError('Corrupt backup metadata')
|
||||
content = read_bytes(self.path(prefix + '/backup/' + record['backup'], leaf_link=False), FILE_LIMIT)
|
||||
if digest(content) != record['sha256'] or len(content) != record['size']:
|
||||
raise UpdateError('Corrupt backup bytes')
|
||||
contents[name] = content
|
||||
return path, journal, contents
|
||||
|
||||
def restore(self, transaction, explicit):
|
||||
path, journal, contents = self.load_journal(transaction)
|
||||
state = self.state()
|
||||
if explicit:
|
||||
if journal['phase'] != 'committed' or state['current'] != journal['release']:
|
||||
raise UpdateError('Only the latest current transaction can be rolled back')
|
||||
for name, entry in journal['files'].items():
|
||||
dest = self.path(name)
|
||||
if not dest.exists() or dest.is_symlink():
|
||||
raise UpdateError('Runtime was locally modified')
|
||||
info = dest.stat()
|
||||
if (stat.S_IMODE(info.st_mode) != entry['mode'] or info.st_uid != self.owner or
|
||||
info.st_gid != (0 if self.owner == 0 else os.getegid()) or digest(read_bytes(dest, FILE_LIMIT)) != entry['sha256']):
|
||||
raise UpdateError('Runtime was locally modified')
|
||||
journal['phase'] = 'restoring'
|
||||
write_json(path, journal)
|
||||
self.publish()
|
||||
elif journal['phase'] not in ('prepared', 'restoring'):
|
||||
raise UpdateError('Transaction does not require recovery')
|
||||
for name, record in sorted(journal['originals'].items()):
|
||||
dest = self.path(name)
|
||||
if record['type'] == 'absent':
|
||||
dest.unlink(missing_ok=True)
|
||||
fsync_dir(dest.parent)
|
||||
elif record['type'] == 'file':
|
||||
atomic_write(dest, contents[name], record['mode'], record['uid'], record['gid'])
|
||||
else:
|
||||
temporary = dest.parent / ('.hydra-update-' + uuid.uuid4().hex)
|
||||
try:
|
||||
temporary.symlink_to(record['target'])
|
||||
os.chown(temporary, record['uid'], record['gid'], follow_symlinks=False)
|
||||
fsync_dir(dest.parent)
|
||||
os.replace(temporary, dest)
|
||||
fsync_dir(dest.parent)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
state['current'] = journal['previous']
|
||||
state['highest_sequence'] = max(state['highest_sequence'], journal['release']['sequence'])
|
||||
write_json(self.base / 'state.json', state)
|
||||
journal['phase'] = 'rolled_back' if explicit or journal['phase'] == 'restoring' else 'recovered'
|
||||
write_json(path, journal)
|
||||
self.publish()
|
||||
return {'transaction': transaction, 'status': journal['phase']}
|
||||
|
||||
def rollback(self, transaction):
|
||||
with self.lock():
|
||||
if self.pending():
|
||||
raise UpdateError('Recovery pending')
|
||||
return self.restore(transaction, True)
|
||||
|
||||
def recover(self):
|
||||
with self.lock():
|
||||
pending = self.pending()
|
||||
if len(pending) > 1:
|
||||
raise UpdateError('Multiple pending journals require manual inspection')
|
||||
results = [self.restore(transaction, False) for transaction in pending]
|
||||
# A crash can leave a terminal journal durable but its public summary stale.
|
||||
# Reconcile even when there is no transaction left to restore.
|
||||
self.publish()
|
||||
return results
|
||||
|
||||
|
||||
def checked_url(url):
|
||||
if not isinstance(url, str) or len(url) > 2048 or any(ord(c) < 33 for c in url) or '\\' in url:
|
||||
raise UpdateError('Malformed release URL')
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
decoded = urllib.parse.unquote(parsed.path)
|
||||
if (parsed.scheme != 'https' or parsed.netloc != 'git.shanaty.ru' or parsed.fragment or
|
||||
not parsed.path.startswith('/') or decoded.startswith('//') or
|
||||
any(part in ('.', '..') for part in decoded.split('/')) or '\\' in decoded):
|
||||
raise UpdateError('Release URL must remain on the fixed HTTPS origin')
|
||||
return url
|
||||
|
||||
|
||||
class SafeRedirect(urllib.request.HTTPRedirectHandler):
|
||||
max_redirections = 3
|
||||
max_repeats = 1
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
checked_url(newurl)
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
def fetch(url, limit):
|
||||
import time
|
||||
url = checked_url(url)
|
||||
# Do not inherit arbitrary proxy configuration in a privileged updater.
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), SafeRedirect())
|
||||
request = urllib.request.Request(url, headers={'Accept': 'application/octet-stream', 'User-Agent': 'Hydra-Update/1'})
|
||||
deadline = time.monotonic() + 60
|
||||
with opener.open(request, timeout=15) as response:
|
||||
if hasattr(response, 'geturl'):
|
||||
checked_url(response.geturl())
|
||||
chunks, total = [], 0
|
||||
reader = getattr(response, 'read1', response.read)
|
||||
while True:
|
||||
if time.monotonic() > deadline:
|
||||
raise UpdateError('Release download deadline exceeded')
|
||||
chunk = reader(min(65536, limit - total + 1))
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
total += len(chunk)
|
||||
if total > limit:
|
||||
raise UpdateError('Release download exceeds size limit')
|
||||
return b''.join(chunks)
|
||||
|
||||
|
||||
def discover_repository(public_key):
|
||||
# The mutable branch locates passive bytes; only the pinned signature grants trust.
|
||||
try:
|
||||
raw = fetch(REPOSITORY_RELEASE + 'manifest.json', MANIFEST_LIMIT)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code != 404:
|
||||
raise
|
||||
exc.close()
|
||||
return None
|
||||
# A present manifest with missing/broken companions is an error, never "no release".
|
||||
signature = fetch(REPOSITORY_RELEASE + 'manifest.sig', 64)
|
||||
manifest = validate_manifest(raw, signature, public_key)
|
||||
return manifest, raw, signature, REPOSITORY_RELEASE + 'runtime.zip'
|
||||
|
||||
|
||||
def discover(public_key):
|
||||
releases = parse_json(fetch(RELEASES, 1024 * 1024))
|
||||
if not isinstance(releases, list) or len(releases) > 20:
|
||||
raise UpdateError('Invalid release discovery response')
|
||||
candidates = []
|
||||
for release in releases:
|
||||
if not isinstance(release, dict):
|
||||
raise UpdateError('Invalid release record')
|
||||
if release.get('draft') or release.get('prerelease'):
|
||||
continue
|
||||
assets = release.get('assets', [])
|
||||
if not isinstance(assets, list) or len(assets) > 100:
|
||||
raise UpdateError('Invalid release assets')
|
||||
found = {}
|
||||
for asset in assets:
|
||||
if not isinstance(asset, dict):
|
||||
raise UpdateError('Invalid release asset')
|
||||
name = asset.get('name')
|
||||
if name in ('manifest.json', 'manifest.sig', 'runtime.zip'):
|
||||
if name in found:
|
||||
raise UpdateError('Duplicate release asset')
|
||||
found[name] = checked_url(asset.get('browser_download_url'))
|
||||
if not found:
|
||||
continue
|
||||
if set(found) != {'manifest.json', 'manifest.sig', 'runtime.zip'}:
|
||||
raise UpdateError('Incomplete runtime release')
|
||||
raw = fetch(found['manifest.json'], MANIFEST_LIMIT)
|
||||
signature = fetch(found['manifest.sig'], 64)
|
||||
manifest = validate_manifest(raw, signature, public_key)
|
||||
if release.get('tag_name') != manifest['version']:
|
||||
raise UpdateError('Release tag does not match signed immutable version')
|
||||
candidates.append((manifest, raw, signature, found['runtime.zip']))
|
||||
if not candidates:
|
||||
return discover_repository(public_key)
|
||||
sequences = [item[0]['sequence'] for item in candidates]
|
||||
if len(sequences) != len(set(sequences)):
|
||||
raise UpdateError('Duplicate release sequence')
|
||||
return max(candidates, key=lambda item: item[0]['sequence'])
|
||||
|
||||
|
||||
def main(argv=None, *, recovery=False):
|
||||
parser = argparse.ArgumentParser(description='Verify, apply and recover signed Hydra runtime updates')
|
||||
if not recovery:
|
||||
parser.add_argument('action', choices=('check', 'status', 'apply', 'rollback'))
|
||||
parser.add_argument('transaction', nargs='?')
|
||||
parser.add_argument('--root', type=Path, default=Path('/'), help='Explicit isolated staging root for support/testing')
|
||||
parser.add_argument('--public-key', type=Path, help='Staging-only verification key; installed root always uses its pinned key')
|
||||
parser.add_argument('--artifacts', type=Path, help='Trusted local directory containing manifest.json, manifest.sig and runtime.zip')
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
if args.root.absolute() == Path('/') and args.public_key is not None:
|
||||
raise UpdateError('Production trust key cannot be overridden')
|
||||
updater = Updater(args.root, args.public_key)
|
||||
action = 'recover' if recovery else args.action
|
||||
if not recovery and (action == 'rollback') != (args.transaction is not None):
|
||||
raise UpdateError('Only rollback requires a transaction identifier')
|
||||
if action == 'status':
|
||||
result = updater.status()
|
||||
elif action == 'recover':
|
||||
result = updater.recover()
|
||||
elif action == 'rollback':
|
||||
result = updater.rollback(args.transaction)
|
||||
elif action == 'check':
|
||||
if args.artifacts:
|
||||
manifest, _ = updater.local(args.artifacts)
|
||||
else:
|
||||
found = discover(updater.public_key)
|
||||
manifest = found[0] if found else None
|
||||
state = updater.status()
|
||||
result = dict(state, available=manifest if manifest and manifest['sequence'] > state['highest_sequence'] else None)
|
||||
elif args.artifacts:
|
||||
result = updater.apply(args.artifacts)
|
||||
else:
|
||||
found = discover(updater.public_key)
|
||||
if found is None:
|
||||
result = {'applied': False, 'reason': 'No signed runtime releases available'}
|
||||
else:
|
||||
manifest, raw, signature, url = found
|
||||
data = fetch(url, manifest['archive']['size'])
|
||||
validate_archive(data, manifest)
|
||||
with tempfile.TemporaryDirectory(prefix='hydra-release-') as directory:
|
||||
directory = Path(directory)
|
||||
(directory / 'manifest.json').write_bytes(raw)
|
||||
(directory / 'manifest.sig').write_bytes(signature)
|
||||
(directory / 'runtime.zip').write_bytes(data)
|
||||
result = updater.apply(directory)
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0
|
||||
except (OSError, ValueError, subprocess.SubprocessError, urllib.error.URLError) as exc:
|
||||
print('hydra-update: ' + str(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
HydraOS builds start from the pinned amd64 systemd stage3 and pinned Gentoo
|
||||
Hydra OS builds start from the pinned amd64 systemd stage3 and pinned Gentoo
|
||||
repository in `sources.json`. Run the builder as the normal user with Python 3,
|
||||
Podman and GnuPG available; temporary tool environments are sufficient. It
|
||||
refuses host root and work/cache paths outside the primary project checkout.
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
hostonly="no"
|
||||
hostonly_cmdline="no"
|
||||
add_dracutmodules+=" dmsquash-live "
|
||||
filesystems+=" squashfs overlay iso9660 vfat ext4 "
|
||||
add_drivers+=" vmd nvme xhci_pci thunderbolt usb_storage uas sd_mod virtio_pci virtio_blk virtio_scsi virtio_net virtio_gpu i915 loop squashfs overlay sr_mod ahci "
|
||||
filesystems+=" squashfs overlay iso9660 vfat ext4 btrfs "
|
||||
add_drivers+=" vmd nvme xhci_pci thunderbolt usb_storage uas sd_mod virtio_pci virtio_blk virtio_scsi virtio_net virtio_gpu i915 loop squashfs overlay sr_mod ahci btrfs "
|
||||
compress="zstd"
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ insmod serial
|
||||
serial --unit=0 --speed=115200
|
||||
terminal_input console serial
|
||||
terminal_output console serial
|
||||
menuentry "HydraOS live (volatile host root)" {
|
||||
menuentry "Hydra OS live (volatile host root)" {
|
||||
search --no-floppy --label HYDRA_OS --set=root
|
||||
linux /boot/vmlinuz root=live:CDLABEL=HYDRA_OS rd.live.image rd.live.overlay.overlayfs=1 ro console=tty0 console=ttyS0,115200n8
|
||||
initrd /boot/initramfs-live.img
|
||||
|
||||
@@ -12,6 +12,7 @@ net-firewall/nftables
|
||||
media-video/pipewire
|
||||
media-video/wireplumber
|
||||
app-admin/sudo
|
||||
app-misc/fastfetch
|
||||
sys-kernel/gentoo-kernel-bin
|
||||
sys-kernel/linux-firmware
|
||||
sys-firmware/sof-firmware
|
||||
@@ -21,6 +22,9 @@ sys-boot/grub
|
||||
sys-fs/squashfs-tools
|
||||
sys-fs/dosfstools
|
||||
sys-fs/e2fsprogs
|
||||
sys-fs/btrfs-progs
|
||||
sys-boot/efibootmgr
|
||||
dev-libs/openssl
|
||||
sys-block/parted
|
||||
sys-apps/util-linux
|
||||
sys-fs/mtools
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
GRUB_DISTRIBUTOR="HydraOS"
|
||||
GRUB_DISTRIBUTOR="Hydra OS"
|
||||
GRUB_TIMEOUT=5
|
||||
GRUB_CMDLINE_LINUX_DEFAULT="console=tty0 console=ttyS0,115200n8"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://github.com/fastfetch-cli/fastfetch/raw/dev/doc/json_schema.json",
|
||||
"logo": {
|
||||
"type": "file",
|
||||
"source": "/usr/share/hydra/fastfetch.txt",
|
||||
"color": { "1": "cyan", "2": "white" },
|
||||
"padding": { "right": 3 }
|
||||
},
|
||||
"display": {
|
||||
"color": { "keys": "cyan", "title": "white" },
|
||||
"separator": ": ",
|
||||
"percent": { "type": 1 }
|
||||
},
|
||||
"modules": [
|
||||
"title",
|
||||
{ "type": "os", "format": "{pretty-name} {arch}" },
|
||||
"kernel",
|
||||
"uptime",
|
||||
"cpu",
|
||||
"memory",
|
||||
{ "type": "disk", "folders": "/" },
|
||||
"localip"
|
||||
]
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
HydraOS live medium: changes to the host root are not persistent.
|
||||
Hydra OS live medium: changes to the host root are not persistent.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAv7478NwuD7binWXyVcaWCIo9o+ahNThjNjMRot+k5w0=
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -1,5 +1,6 @@
|
||||
HydraOS: fullscreen VMs with an ordinary Gentoo host underneath.
|
||||
Hydra OS: fullscreen VMs with an ordinary Gentoo host underneath.
|
||||
Live host changes are volatile. Use sudo hydra-install for the guarded installer.
|
||||
Use hydra --help, nmcli, or nmtui to configure VMs and network connections.
|
||||
Use hydra for the VM menu; hydra --help lists commands. fastfetch shows this host.
|
||||
Use sudo nmtui to configure network connections.
|
||||
Ctrl+Alt+F2 opens local recovery; return to the graphical session on Ctrl+Alt+F1.
|
||||
Guest bridge: 10.203.0.0/24. Routed LAN access requires a route on your LAN router.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=Restrict QEMU bridge helper to HydraOS VM users
|
||||
Description=Restrict QEMU bridge helper to Hydra OS VM users
|
||||
Before=greetd.service
|
||||
[Service]
|
||||
Type=oneshot
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=DHCP and DNS for HydraOS guests
|
||||
Description=DHCP and DNS for Hydra OS guests
|
||||
Requires=hydra-network.service
|
||||
PartOf=hydra-network.service
|
||||
After=hydra-network.service NetworkManager.service
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=HydraOS guest bridge and Internet NAT
|
||||
Description=Hydra OS guest bridge and Internet NAT
|
||||
Requires=NetworkManager.service
|
||||
PartOf=NetworkManager.service
|
||||
After=NetworkManager.service
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
$1 = =
|
||||
=@ @=
|
||||
:@+ :: +@:
|
||||
:-*@%*@@*%@#-:
|
||||
. +: -@@@@@@@@@@- :+ .
|
||||
=*:+#= =@*+@@@@+*@= =#+-*=
|
||||
#@#@@##= .%@=*@@*=@%. =##@@#@#
|
||||
#@@@@@@@@#=. .@@@@@@. .=#@@@@@@@@#
|
||||
.%%=:%@@@@@@@*: +%@@%+ :*@@@@@@@%:=%%.
|
||||
.#@##@@@%*@@@@#: =@@= :#@@@@*%@@@##@#.
|
||||
@%@%=-%@# .*@@@@. .%==%. .@@@@*. #@%-=%@%@
|
||||
=%-:-%+ #@@@# .@@@@. #@@@# +%-:=%+
|
||||
-%- @@@@% =@@@@= %@@@@ -%-
|
||||
.%@@@@. @@@@@@ .@@@@%.
|
||||
-@@@@@: *%@@@@%* :@@@@@-
|
||||
.@@@@@* .-%@@@@%-. *@@@@@.
|
||||
*@@@@@%:+@@@@@@@@+:%@@@@@*
|
||||
=#%@@@@@@@@@@@@@@@@@@@@%#=
|
||||
.*@@@@@@@@@@@@@@@@@@*.
|
||||
.:-=+*#@@#*+=-:.
|
||||
|
||||
$2 H Y D R A O S
|
||||
@@ -0,0 +1 @@
|
||||
{"architecture":"x86_64","archive":{"name":"runtime.zip","sha256":"9284edd5de37f621955189f3d317ebcfa76f60a4b6e1f481361b6aad1967f4f8","size":71577},"channel":"stable","files":{"etc/fastfetch/config.jsonc":{"mode":420,"sha256":"2af12ce007705f039870867c89b0fc6f90bcf6c6b29c04b63ab75f345419b1a1","size":569},"etc/motd":{"mode":420,"sha256":"67f9a310050964a9bfefa728e22eaa127c651fc325b6af97b3268261c45b79e2","size":446},"usr/lib/hydra/bin/hydra":{"mode":493,"sha256":"04e83985f4667f727518c36f952c17aad2e82957a3ca86ebb643ce665109a0cb","size":250},"usr/lib/hydra/hydra/__init__.py":{"mode":420,"sha256":"2775bedc6bc41a09d8a492e99a38ce147730d979867316a672f0049f055c76a1","size":42},"usr/lib/hydra/hydra/build.py":{"mode":420,"sha256":"7d130a0ce0eed8913d8402c28824b1dff139d33c6482d9616a01d30f96cd65ac","size":31591},"usr/lib/hydra/hydra/cli.py":{"mode":420,"sha256":"a9399544462279af97542de068907f6f2adb634874648338bdb6094606c03c78","size":8444},"usr/lib/hydra/hydra/doctor.py":{"mode":420,"sha256":"9d759958c48b56a5f58acaab301c8c6f49c3a6c2ea7d434ee7d17bc420ba51c2","size":8699},"usr/lib/hydra/hydra/image.py":{"mode":420,"sha256":"b4b77ad87ceba929ed39bee2b02852682718788dacbbbb550ee1a50c216e8070","size":22481},"usr/lib/hydra/hydra/install_storage.py":{"mode":420,"sha256":"3c59d5f295d6bdb86e6decbae668db67cc2f2d0b8c6abe49ef07f35d8aef7aee","size":8481},"usr/lib/hydra/hydra/installer.py":{"mode":420,"sha256":"3d038216c37e248db5ad8a76863663d701d5ef497cb5be52658c9382c92f0da5","size":50513},"usr/lib/hydra/hydra/launch.py":{"mode":420,"sha256":"e9e40af8cfb2aff39306735e6ad70c91d7f93cb01d1451daecbbebda8362c4b0","size":5845},"usr/lib/hydra/hydra/media.py":{"mode":420,"sha256":"4c85798a81e767605e8e0a1cd0eb21062f625c49dd71df511b2b67db375d95af","size":9445},"usr/lib/hydra/hydra/menu.py":{"mode":420,"sha256":"ba99dcc9ed33543d54ad61d13b6c5f02c2d4c6d60f6c2cf9c5ceeb0634a88f5c","size":11472},"usr/lib/hydra/hydra/profiles.py":{"mode":420,"sha256":"7e3074086172bfb1ebde89996a2ca9be4512250e667e1375b9086d7148f947c3","size":4364},"usr/lib/hydra/hydra/qmp.py":{"mode":420,"sha256":"e86da77103b991cf7f0104e701555ebbc91e2b526d6b7ec6db76209f264af024","size":4585},"usr/lib/hydra/hydra/runtime.py":{"mode":420,"sha256":"d72353ebd675135801a3686a69ee8a193b87d5787f691917a46c99de0555d5d3","size":15605},"usr/lib/hydra/hydra/session.py":{"mode":420,"sha256":"729d6cd263f9828850018b335917a15f03d2a2bda0e38e73f842d3cfc196db1d","size":16098},"usr/lib/hydra/hydra/system_recovery.py":{"mode":420,"sha256":"957d9b8ab422b446f1f7c0f922c1629a0728e0043bd0a3ade36495f4200d5c05","size":30194},"usr/share/hydra/fastfetch.txt":{"mode":420,"sha256":"a0c67cfbc66a3097fb3ac9b8ec16f75a8b59d8a8994ec43db144426fc089fa67","size":769}},"format":1,"product":"Hydra OS","scope":"runtime","sequence":1,"source_commit":"ebeebc7fb1aed07a5f50bc62374365d614953dad","version":"0.2.0"}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,63 @@
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from hydra import image
|
||||
|
||||
|
||||
class BrandingTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name) / 'rootfs'
|
||||
self.root.mkdir()
|
||||
version = '6.18.54-gentoo-dist-bin'
|
||||
(self.root / 'lib/modules' / version).mkdir(parents=True)
|
||||
(self.root / 'boot').mkdir()
|
||||
(self.root / 'boot' / ('vmlinuz-' + version)).write_text('kernel')
|
||||
tmpfiles = self.root / 'usr/lib/tmpfiles.d'
|
||||
tmpfiles.mkdir(parents=True)
|
||||
for name, content in (
|
||||
('home.conf', 'Q /home 0755 - - -\nq /srv 0755 - - -\n'),
|
||||
('portables.conf', 'Q /var/lib/portables 0700\n'),
|
||||
('systemd-nspawn.conf', 'Q /var/lib/machines 0700 - - -\n'),
|
||||
):
|
||||
(tmpfiles / name).write_text(content)
|
||||
|
||||
def release(self):
|
||||
return dict(line.split('=', 1)[0:1] + shlex.split(line.split('=', 1)[1])
|
||||
for line in (self.root / 'etc/os-release').read_text().splitlines()
|
||||
if line and not line.startswith('#'))
|
||||
|
||||
def test_export_brands_derivative_without_rewriting_packaged_gentoo_metadata(self):
|
||||
vendor = self.root / 'usr/lib/os-release'
|
||||
vendor.parent.mkdir(parents=True, exist_ok=True)
|
||||
original = "NAME='Gentoo'\nID=gentoo\nPRETTY_NAME='Gentoo Linux'\nVERSION_ID=2.18\nHOME_URL='https://www.gentoo.org/'\n"
|
||||
vendor.write_text(original)
|
||||
(self.root / 'etc').mkdir()
|
||||
(self.root / 'etc/os-release').symlink_to('../usr/lib/os-release')
|
||||
|
||||
image.configure_rootfs(self.root, Path(__file__).resolve().parents[1])
|
||||
|
||||
self.assertEqual(self.release(), {
|
||||
'NAME': 'Hydra OS', 'PRETTY_NAME': 'Hydra OS', 'ID': 'hydra-os',
|
||||
'ID_LIKE': 'gentoo', 'VERSION_ID': '2.18',
|
||||
'HOME_URL': 'https://www.gentoo.org/',
|
||||
})
|
||||
self.assertFalse((self.root / 'etc/os-release').is_symlink())
|
||||
self.assertEqual(vendor.read_text(), original)
|
||||
self.assertEqual((self.root / 'etc/hostname').read_text(), 'hydra-os\n')
|
||||
|
||||
def test_export_can_brand_minimal_payload_without_os_release(self):
|
||||
image.configure_rootfs(self.root, Path(__file__).resolve().parents[1])
|
||||
|
||||
self.assertTrue((self.root / 'etc/os-release').is_file())
|
||||
self.assertEqual(self.release(), {
|
||||
'NAME': 'Hydra OS', 'PRETTY_NAME': 'Hydra OS',
|
||||
'ID': 'hydra-os', 'ID_LIKE': 'gentoo',
|
||||
})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -27,6 +27,148 @@ class BuilderTests(unittest.TestCase):
|
||||
def builder(self):
|
||||
return self.module.Builder(self.project, self.project / 'build', self.project / 'cache', podman='podman')
|
||||
|
||||
def rootfs_fixture(self):
|
||||
from hydra import image
|
||||
import subprocess
|
||||
(self.project / 'image').mkdir()
|
||||
(self.project / 'image/packages').write_text('app-emulation/qemu\n')
|
||||
(self.project / 'hydra').mkdir()
|
||||
(self.project / 'hydra/runtime.py').write_text('# runtime revision two\n')
|
||||
builder = self.builder()
|
||||
artifacts = builder.work / 'artifacts'
|
||||
artifacts.mkdir(parents=True)
|
||||
inventory = artifacts / 'packages.json'
|
||||
inventory.write_text('[{"package":"app-emulation/qemu-9.2.4","use":["spice"],"build_time":"123"}]')
|
||||
raw = artifacts / 'builder-rootfs.tar'
|
||||
raw.write_bytes(b'verified raw builder snapshot')
|
||||
original_complete = builder.complete
|
||||
builder.complete = lambda step: True if step == 'prepare' else original_complete(step)
|
||||
builder.record('packages', inventory_sha256=image.digest(inventory))
|
||||
builder.record('rootfs', source_inputs='prior-runtime-identity', snapshot_sha256=image.digest(raw),
|
||||
artifacts={'packages.json': image.digest(inventory)})
|
||||
commands = []
|
||||
def run(argv, capture=False, check=True):
|
||||
if argv[0] == 'git':
|
||||
return subprocess.CompletedProcess(argv, 0, 'source-commit\n', '')
|
||||
args = argv[7:]
|
||||
commands.append(args)
|
||||
output = ''
|
||||
if args[:2] == ['container', 'exists']:
|
||||
return subprocess.CompletedProcess(argv, 0, '', '')
|
||||
if args[:2] == ['container', 'inspect']:
|
||||
output = json.dumps([{'Image': 'trusted-assembler-image', 'Mounts': [
|
||||
{'Destination': '/hydra-source', 'Source': str(builder.source_dir), 'RW': False},
|
||||
{'Destination': '/hydra-output', 'Source': str(builder.work), 'RW': True}]}])
|
||||
elif args[:2] == ['image', 'inspect']:
|
||||
output = json.dumps([{'RootFS': {'Layers': ['sha256:' + image.digest(raw)]}}])
|
||||
elif args[0] == 'export':
|
||||
raw.write_bytes(b'fresh raw builder snapshot')
|
||||
elif args[0] == 'exec':
|
||||
for name in ('vmlinuz', 'initramfs-live.img', 'squashfs.img'):
|
||||
(artifacts / name).write_bytes(b'reassembled ' + name.encode())
|
||||
(artifacts / 'rootfs-manifest.json').write_text(json.dumps({
|
||||
'kernel': '6.18-test', 'packages': json.loads(inventory.read_text()),
|
||||
'source_files': image.source_files(builder.source_dir)}))
|
||||
return subprocess.CompletedProcess(argv, 0, output, '')
|
||||
builder.run = run # Fake only the external Podman/git boundary.
|
||||
return builder, raw, inventory, commands
|
||||
|
||||
def test_runtime_change_reuses_verified_raw_export_and_reassembles(self):
|
||||
from hydra import image
|
||||
builder, raw, inventory, commands = self.rootfs_fixture()
|
||||
original_bytes = raw.read_bytes()
|
||||
builder.rootfs()
|
||||
self.assertFalse(any(args[0] == 'export' for args in commands))
|
||||
self.assertEqual(raw.read_bytes(), original_bytes)
|
||||
self.assertEqual(raw.stat().st_mode & 0o777, 0o600)
|
||||
self.assertTrue(any(args[0] == 'exec' for args in commands), 'raw reuse must still run assembly')
|
||||
self.assertEqual((inventory.parent / 'squashfs.img').read_bytes(), b'reassembled squashfs.img')
|
||||
self.assertEqual(builder.manifest()['rootfs']['snapshot_sha256'], image.digest(raw))
|
||||
self.assertTrue(builder.complete('rootfs'))
|
||||
|
||||
def test_corrupt_raw_export_falls_back_to_fresh_export(self):
|
||||
builder, raw, inventory, commands = self.rootfs_fixture()
|
||||
raw.write_bytes(b'corrupted raw cache')
|
||||
builder.rootfs()
|
||||
self.assertTrue(any(args[0] == 'export' for args in commands))
|
||||
self.assertEqual(raw.read_bytes(), b'fresh raw builder snapshot')
|
||||
|
||||
def test_changed_image_inputs_require_fresh_raw_export(self):
|
||||
from hydra import image
|
||||
builder, raw, inventory, commands = self.rootfs_fixture()
|
||||
(self.project / 'image/packages').write_text('app-emulation/qemu\nsys-fs/btrfs-progs\n')
|
||||
builder.record('packages', inventory_sha256=image.digest(inventory))
|
||||
builder.rootfs()
|
||||
self.assertTrue(any(args[0] == 'export' for args in commands))
|
||||
|
||||
def test_changed_validated_package_inventory_requires_fresh_export(self):
|
||||
from hydra import image
|
||||
builder, raw, inventory, commands = self.rootfs_fixture()
|
||||
inventory.write_text('[{"package":"app-emulation/qemu-10.0.0","use":["spice"],"build_time":"124"}]')
|
||||
builder.record('packages', inventory_sha256=image.digest(inventory))
|
||||
builder.rootfs()
|
||||
self.assertTrue(any(args[0] == 'export' for args in commands))
|
||||
|
||||
def test_incomplete_raw_export_checkpoint_requires_fresh_export(self):
|
||||
builder, raw, inventory, commands = self.rootfs_fixture()
|
||||
builder.record('rootfs', complete=False)
|
||||
builder.rootfs()
|
||||
self.assertTrue(any(args[0] == 'export' for args in commands))
|
||||
|
||||
def test_missing_raw_export_falls_back_to_fresh_export(self):
|
||||
builder, raw, inventory, commands = self.rootfs_fixture()
|
||||
raw.unlink()
|
||||
builder.rootfs()
|
||||
self.assertTrue(any(args[0] == 'export' for args in commands))
|
||||
self.assertEqual(raw.read_bytes(), b'fresh raw builder snapshot')
|
||||
|
||||
def test_invalid_package_inventory_blocks_even_matching_raw_cache(self):
|
||||
builder, raw, inventory, commands = self.rootfs_fixture()
|
||||
original_bytes = raw.read_bytes()
|
||||
inventory.write_text('[]')
|
||||
with self.assertRaisesRegex(self.module.BuildError, 'completed packages'):
|
||||
builder.rootfs()
|
||||
self.assertEqual(raw.read_bytes(), original_bytes)
|
||||
self.assertEqual(commands, [])
|
||||
|
||||
def test_raw_export_parent_escape_is_rejected_before_podman_writes(self):
|
||||
builder, raw, inventory, commands = self.rootfs_fixture()
|
||||
outside = self.project / 'outside-artifacts'
|
||||
inventory.parent.rename(outside)
|
||||
(builder.work / 'artifacts').symlink_to(outside, target_is_directory=True)
|
||||
with self.assertRaisesRegex(self.module.BuildError, 'dedicated work'):
|
||||
builder.rootfs()
|
||||
self.assertEqual((outside / raw.name).read_bytes(), b'verified raw builder snapshot')
|
||||
self.assertEqual(commands, [])
|
||||
|
||||
def test_hardlinked_raw_export_is_rejected_before_podman_writes(self):
|
||||
builder, raw, inventory, commands = self.rootfs_fixture()
|
||||
linked = self.project / 'outside-private'
|
||||
os.link(raw, linked)
|
||||
with self.assertRaisesRegex(self.module.BuildError, 'singly linked'):
|
||||
builder.rootfs()
|
||||
self.assertEqual(linked.read_bytes(), b'verified raw builder snapshot')
|
||||
self.assertEqual(commands, [])
|
||||
|
||||
def test_raw_export_symlink_is_rejected_before_podman_writes(self):
|
||||
builder, raw, inventory, commands = self.rootfs_fixture()
|
||||
outside = self.project / 'outside-private'
|
||||
outside.write_bytes(b'private sentinel')
|
||||
raw.unlink()
|
||||
raw.symlink_to(outside)
|
||||
with self.assertRaisesRegex(self.module.BuildError, 'dedicated work'):
|
||||
builder.rootfs()
|
||||
self.assertEqual(outside.read_bytes(), b'private sentinel')
|
||||
self.assertEqual(commands, [])
|
||||
|
||||
def test_raw_export_directory_is_rejected_before_podman_writes(self):
|
||||
builder, raw, inventory, commands = self.rootfs_fixture()
|
||||
raw.unlink()
|
||||
raw.mkdir()
|
||||
with self.assertRaisesRegex(self.module.BuildError, 'regular file'):
|
||||
builder.rootfs()
|
||||
self.assertEqual(commands, [])
|
||||
|
||||
def test_bad_cached_hash_is_rejected(self):
|
||||
artifact = self.project / 'archive'
|
||||
artifact.write_bytes(b'tampered')
|
||||
|
||||
@@ -21,6 +21,29 @@ class CLITests(unittest.TestCase):
|
||||
def run_cli(self, *args):
|
||||
return subprocess.run(self.base + list(args), capture_output=True, text=True, timeout=5)
|
||||
|
||||
def test_doctor_json_and_text_do_not_create_runtime_state(self):
|
||||
from unittest.mock import patch
|
||||
from hydra import cli
|
||||
import contextlib
|
||||
import io
|
||||
with patch('hydra.doctor.doctor_report', return_value={'format': 1, 'scope': 'observations',
|
||||
'manual_checks': ['Wi-Fi connection', 'suspend']}):
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
try:
|
||||
status = cli.main(self.base[2:] + ['doctor', '--json'])
|
||||
except SystemExit as error:
|
||||
status = error.code
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(json.loads(output.getvalue())['format'], 1)
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
self.assertEqual(cli.main(self.base[2:] + ['doctor']), 0)
|
||||
self.assertIn('Manual checks', output.getvalue())
|
||||
self.assertFalse((self.root / 'state').exists())
|
||||
self.assertFalse((self.root / 'config').exists())
|
||||
self.assertFalse((self.root / 'run').exists())
|
||||
|
||||
def test_defaults_affect_new_profiles_only_and_explicit_options_win(self):
|
||||
self.defaults.write_text('network="bridge"\nbridge="hydrabr0"\nwidth=1920\nheight=1080\nrefresh_millihz=144000\n')
|
||||
added = self.run_cli('add', 'first', '--width', '1600', '--network', 'user', '--refresh-millihz', '0')
|
||||
@@ -64,3 +87,104 @@ class CLITests(unittest.TestCase):
|
||||
self.assertEqual(result.returncode, 1, result.stderr)
|
||||
self.assertNotIn('Traceback', result.stderr)
|
||||
self.assertFalse((self.root / 'config/guest.toml').exists())
|
||||
|
||||
def test_plain_noninteractive_invocation_prints_help_without_creating_state(self):
|
||||
result = self.run_cli()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn('Hydra OS', result.stdout)
|
||||
self.assertIn('run', result.stdout)
|
||||
self.assertFalse((self.root / 'state').exists())
|
||||
|
||||
def test_run_and_open_are_headless_and_existing_name_is_never_replaced(self):
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
from test_media import iso_bytes
|
||||
from test_runtime import FAKE_IMG, FAKE_QEMU
|
||||
from hydra.runtime import Runtime
|
||||
iso = self.root / 'installer.iso'
|
||||
iso.write_bytes(iso_bytes())
|
||||
qemu = self.root / 'qemu'
|
||||
qemu.write_text(FAKE_QEMU)
|
||||
qemu.chmod(0o700)
|
||||
img = self.root / 'img'
|
||||
img.write_text(FAKE_IMG)
|
||||
img.chmod(0o700)
|
||||
runtime = Runtime(self.root / 'config', self.root / 'state', self.root / 'run')
|
||||
def cleanup():
|
||||
if runtime.status('guest')['status'] == 'running':
|
||||
runtime.stop('guest', force=True)
|
||||
self.addCleanup(cleanup)
|
||||
with patch.dict(os.environ, {'HYDRA_QEMU': str(qemu), 'HYDRA_QEMU_IMG': str(img),
|
||||
'DISPLAY': '', 'WAYLAND_DISPLAY': '', 'HYDRA_SESSION_MANIFEST': '/not/a/manifest'}):
|
||||
started = self.run_cli('run', str(iso), '--name', 'guest', '--memory', '256', '--cpus', '1', '--disk-size', '8M', '--no-view')
|
||||
self.assertEqual(started.returncode, 0, started.stderr)
|
||||
original = (self.root / 'config/guest.toml').read_bytes()
|
||||
pid = runtime.paths('guest').pid.read_bytes()
|
||||
duplicate = self.run_cli('run', str(iso), '--name', 'guest', '--no-view')
|
||||
self.assertEqual(duplicate.returncode, 1, duplicate.stderr)
|
||||
self.assertIn('hydra open guest', duplicate.stderr)
|
||||
iso.unlink()
|
||||
opened = self.run_cli('open', 'guest', '--no-view')
|
||||
self.assertEqual(opened.returncode, 0, opened.stderr)
|
||||
self.assertEqual((self.root / 'config/guest.toml').read_bytes(), original)
|
||||
self.assertEqual(runtime.paths('guest').pid.read_bytes(), pid)
|
||||
|
||||
def test_download_rejects_credential_urls_without_echoing_secrets(self):
|
||||
result = self.run_cli('download', 'https://user:supersecret@example.org/installer.iso?token=private')
|
||||
self.assertEqual(result.returncode, 1, result.stderr)
|
||||
self.assertNotIn('supersecret', result.stderr)
|
||||
self.assertNotIn('private', result.stderr)
|
||||
self.assertNotIn('Traceback', result.stderr)
|
||||
|
||||
def guided_defaults_profile(self, command):
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
from hydra.cli import main
|
||||
from hydra.runtime import Runtime
|
||||
from test_media import iso_bytes
|
||||
from test_runtime import FAKE_IMG, FAKE_QEMU
|
||||
|
||||
self.defaults.write_text('network="bridge"\nbridge="testbr0"\nwidth=1600\nheight=900\nrefresh_millihz=75000\n')
|
||||
iso = self.root / 'guided.iso'
|
||||
iso.write_bytes(iso_bytes())
|
||||
qemu = self.root / 'fake-qemu'
|
||||
qemu.write_text(FAKE_QEMU); qemu.chmod(0o700)
|
||||
image = self.root / 'fake-img'
|
||||
image.write_text(FAKE_IMG); image.chmod(0o700)
|
||||
viewer = self.root / 'fake-viewer'
|
||||
viewer.write_text('#!/bin/sh\necho --hide-toolbar\n'); viewer.chmod(0o700)
|
||||
runtime = Runtime(self.root / 'config', self.root / 'state', self.root / 'run')
|
||||
def cleanup():
|
||||
if runtime.status('guided')['status'] == 'running':
|
||||
runtime.stop('guided', force=True)
|
||||
self.addCleanup(cleanup)
|
||||
inputs = [':run ' + str(iso), 'guided', '512', '1', '8M', '', ':quit']
|
||||
with patch.dict(os.environ, {'HYDRA_QEMU': str(qemu), 'HYDRA_QEMU_IMG': str(image),
|
||||
'HYDRA_VIEWER': str(viewer), 'WAYLAND_DISPLAY': 'unused',
|
||||
'HYDRA_SESSION_MANIFEST': ''}), \
|
||||
patch('sys.stdin.isatty', return_value=True), patch('builtins.input', side_effect=inputs), \
|
||||
patch('hydra.launch.available_memory_mib', return_value=8192), \
|
||||
patch('hydra.session.view', return_value=0), contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(main(self.base[2:] + command), 0)
|
||||
self.assertEqual(runtime.status('guided')['status'], 'running')
|
||||
return runtime.profile('guided')
|
||||
|
||||
def test_menu_guided_run_honors_cli_custom_network_and_display_defaults(self):
|
||||
profile = self.guided_defaults_profile(['menu'])
|
||||
self.assertEqual((profile.network, profile.bridge, profile.width, profile.height, profile.refresh_millihz),
|
||||
('bridge', 'testbr0', 1600, 900, 75000))
|
||||
|
||||
def test_plain_interactive_guided_run_honors_cli_custom_network_and_display_defaults(self):
|
||||
profile = self.guided_defaults_profile([])
|
||||
self.assertEqual((profile.network, profile.bridge, profile.width, profile.height, profile.refresh_millihz),
|
||||
('bridge', 'testbr0', 1600, 900, 75000))
|
||||
|
||||
def test_update_status_dispatch_is_independent_of_vm_state(self):
|
||||
result = self.run_cli('update', 'status', '--root', str(self.root))
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(json.loads(result.stdout), {'highest_sequence': 0, 'current': None, 'pending': []})
|
||||
self.assertFalse((self.root / 'config').exists())
|
||||
self.assertFalse((self.root / 'state').exists())
|
||||
self.assertFalse((self.root / 'run').exists())
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from hydra import doctor
|
||||
except ImportError:
|
||||
doctor = None
|
||||
|
||||
|
||||
class DoctorTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.assertIsNotNone(doctor, 'read-only doctor module is missing')
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
for name in ('proc', 'sys', 'dev'):
|
||||
(self.root / name).mkdir()
|
||||
|
||||
def file(self, name, value=''):
|
||||
path = self.root / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(value)
|
||||
return path
|
||||
|
||||
def report(self, **kwargs):
|
||||
return doctor.doctor_report(proc_root=self.root / 'proc', sys_root=self.root / 'sys',
|
||||
dev_root=self.root / 'dev', find_tool=lambda _: None, **kwargs)
|
||||
|
||||
def test_absent_hardware_is_reported_without_false_functional_claims(self):
|
||||
result = self.report()
|
||||
self.assertEqual(result['firmware']['uefi'], False)
|
||||
self.assertIsNone(result['firmware']['secure_boot'])
|
||||
self.assertEqual(result['storage']['devices'], [])
|
||||
self.assertFalse(result['kvm']['device_present'])
|
||||
self.assertTrue(all(not value for value in result['tools'].values()))
|
||||
self.assertIn('Wi-Fi connection', ' '.join(result['manual_checks']))
|
||||
self.assertIn('144 Hz', ' '.join(result['manual_checks']))
|
||||
json.dumps(result)
|
||||
|
||||
def test_fake_hardware_reports_presence_and_driver_without_identifiers(self):
|
||||
self.file('proc/cpuinfo', 'processor : 0\nflags : vmx sse\nSerial : PRIVATE_SERIAL\n')
|
||||
self.file('proc/meminfo', 'MemTotal: 8388608 kB\nMemAvailable: 4194304 kB\n')
|
||||
secure = self.file('sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c')
|
||||
secure.write_bytes(b'\x07\0\0\0\x01')
|
||||
self.file('dev/kvm')
|
||||
self.file('sys/module/kvm_intel/refcnt', '0')
|
||||
self.file('sys/class/block/nvme0n1/size', '2097152')
|
||||
self.file('sys/class/block/nvme0n1/device/serial', 'PRIVATE_SERIAL')
|
||||
self.file('sys/class/block/nvme0n1p1/partition', '1')
|
||||
self.file('sys/class/drm/card0-eDP-1/status', 'connected')
|
||||
self.file('sys/class/drm/card0-eDP-1/modes', '1920x1080\nPRIVATE_USER\n')
|
||||
self.file('sys/bus/pci/devices/0000:00:02.0/class', '0x030000')
|
||||
driver = self.root / 'sys/bus/pci/drivers/i915'
|
||||
driver.mkdir(parents=True)
|
||||
(self.root / 'sys/bus/pci/devices/0000:00:02.0/driver').symlink_to(driver)
|
||||
self.file('sys/class/net/wlan0/wireless/present')
|
||||
self.file('sys/class/net/wlan0/address', 'aa:bb:cc:dd:ee:ff')
|
||||
self.file('sys/class/net/wlan0/ssid', 'PRIVATE_SSID')
|
||||
self.file('sys/class/sound/card0/id', 'PRIVATE_USER')
|
||||
self.file('sys/class/input/event0/device/name', 'PRIVATE_USER')
|
||||
self.file('sys/class/input/event0/device/capabilities/key', hex((1 << 30) | (1 << 44))[2:])
|
||||
result = self.report()
|
||||
self.assertEqual(result['cpu']['virtualization_flags'], ['vmx'])
|
||||
self.assertEqual(result['memory']['total_mib'], 8192)
|
||||
self.assertEqual(result['firmware']['secure_boot'], True)
|
||||
self.assertEqual(result['storage']['devices'][0]['size_bytes'], 1073741824)
|
||||
self.assertEqual(result['graphics']['drivers'], ['i915'])
|
||||
self.assertEqual(result['network']['wireless_devices'], 1)
|
||||
self.assertEqual(result['audio']['cards'], 1)
|
||||
self.assertEqual(result['input']['keyboards'], 1)
|
||||
encoded = json.dumps(result)
|
||||
for secret in ('PRIVATE_SERIAL', 'PRIVATE_USER', 'PRIVATE_SSID', 'aa:bb:cc:dd:ee:ff'):
|
||||
self.assertNotIn(secret, encoded)
|
||||
|
||||
def test_keyboard_detection_preserves_64_bit_capability_word_boundaries(self):
|
||||
capability = self.file('sys/class/input/event0/device/capabilities/key')
|
||||
for bitmap, expected in (('10004000000 0\n', 0),
|
||||
('1 100040000000\n', 1),
|
||||
('100040000000\n', 1),
|
||||
('10004000000 invalid\n', 0)):
|
||||
with self.subTest(bitmap=bitmap):
|
||||
capability.write_text(bitmap)
|
||||
self.assertEqual(self.report()['input']['keyboards'], expected)
|
||||
|
||||
def test_probes_timeout_independently_and_discard_untrusted_output(self):
|
||||
calls = []
|
||||
def run(command, **kwargs):
|
||||
calls.append((command, kwargs))
|
||||
if len(calls) == 1:
|
||||
raise subprocess.TimeoutExpired(command, kwargs['timeout'], output='PRIVATE_SSID')
|
||||
return subprocess.CompletedProcess(command, 5, 'PRIVATE_USER 192.0.2.1 aa:bb:cc:dd:ee:ff', '')
|
||||
result = doctor.doctor_report(proc_root=self.root / 'proc', sys_root=self.root / 'sys',
|
||||
dev_root=self.root / 'dev', find_tool=lambda name: '/usr/bin/' + name,
|
||||
run=run)
|
||||
self.assertIn('timeout', result['services'].values())
|
||||
self.assertIn('unknown', result['services'].values())
|
||||
self.assertNotIn('PRIVATE', json.dumps(result))
|
||||
self.assertLessEqual(len(calls), 8)
|
||||
for argv, options in calls:
|
||||
self.assertEqual(argv[0], '/usr/bin/systemctl')
|
||||
self.assertIn('is-active', argv)
|
||||
self.assertLessEqual(options['timeout'], 1)
|
||||
self.assertFalse(options.get('shell', False))
|
||||
|
||||
def test_reads_are_bounded_and_invalid_numbers_are_unknown(self):
|
||||
self.file('proc/meminfo', 'MemTotal: invalid kB\n' + 'secret' * 100000)
|
||||
self.file('sys/class/block/sda/size', '-1')
|
||||
self.file('sys/class/drm/card0-HDMI-A-1/status', 'private text')
|
||||
result = self.report()
|
||||
self.assertIsNone(result['memory']['total_mib'])
|
||||
self.assertIsNone(result['storage']['devices'][0]['size_bytes'])
|
||||
self.assertEqual(result['graphics']['connectors'][0]['status'], 'unknown')
|
||||
self.assertLess(len(json.dumps(result)), 10000)
|
||||
|
||||
def test_text_and_json_outputs_share_report_and_manual_limits(self):
|
||||
with patch.object(doctor, 'doctor_report', return_value=self.report()):
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
self.assertEqual(doctor.doctor_main(['--json']), 0)
|
||||
self.assertEqual(json.loads(output.getvalue())['firmware']['uefi'], False)
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
self.assertEqual(doctor.doctor_main([]), 0)
|
||||
self.assertIn('Manual checks', output.getvalue())
|
||||
self.assertIn('suspend', output.getvalue())
|
||||
@@ -0,0 +1,184 @@
|
||||
"""An installed-guest update must be bounded, inspectable and reversible."""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import zipfile
|
||||
|
||||
|
||||
class GuestUpdateTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.script = Path(__file__).resolve().parents[1] / 'dev/guest-update.py'
|
||||
|
||||
def setUp(self):
|
||||
self.assertTrue(self.script.is_file(), 'guest update helper must exist')
|
||||
spec = importlib.util.spec_from_file_location('guest_update', self.script)
|
||||
self.mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(self.mod)
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.base = Path(self.tmp.name)
|
||||
self.source = self.base / 'source'
|
||||
for name, value in {'hydra/__init__.py': '', 'hydra/cli.py': 'NEW = True\n',
|
||||
'bin/hydra': '#!/usr/bin/python3\n',
|
||||
'image/rootfs/etc/motd': 'Hydra OS\n',
|
||||
'image/rootfs/usr/share/hydra/fastfetch.txt': 'hydra logo\n'}.items():
|
||||
dest = self.source / name
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(value)
|
||||
self.root = self.base / 'guest'
|
||||
marker = self.root / 'usr/share/hydra/build-manifest.json'
|
||||
marker.parent.mkdir(parents=True)
|
||||
marker.write_text('{"original": true}\n')
|
||||
self.old = self.root / 'usr/lib/hydra/hydra/cli.py'
|
||||
self.old.parent.mkdir(parents=True)
|
||||
self.old.write_text('OLD = True\n')
|
||||
self.bundle = self.base / 'update.pyz'
|
||||
|
||||
def build(self):
|
||||
return self.mod.build_bundle(self.source, self.bundle)
|
||||
|
||||
def test_dry_run_preserves_installed_files_and_user_disk(self):
|
||||
self.build()
|
||||
disk = self.root / 'home/user/.local/state/hydra/work/disk.qcow2'
|
||||
disk.parent.mkdir(parents=True)
|
||||
disk.write_bytes(b'user disk')
|
||||
result = self.mod.apply_bundle(self.bundle, self.root)
|
||||
self.assertFalse(result['applied'])
|
||||
self.assertEqual(self.old.read_text(), 'OLD = True\n')
|
||||
self.assertEqual(disk.read_bytes(), b'user disk')
|
||||
self.assertFalse((self.root / 'var/lib/hydra/updates').exists())
|
||||
|
||||
def test_apply_saves_backup_and_preserves_original_image_evidence(self):
|
||||
self.build()
|
||||
result = self.mod.apply_bundle(self.bundle, self.root, execute=True)
|
||||
self.assertTrue(result['applied'])
|
||||
self.assertEqual(self.old.read_text(), 'NEW = True\n')
|
||||
backup = Path(result['backup'])
|
||||
self.assertEqual((backup / 'usr/lib/hydra/hydra/cli.py').read_text(), 'OLD = True\n')
|
||||
self.assertEqual(json.loads((self.root / 'usr/share/hydra/build-manifest.json').read_text()), {'original': True})
|
||||
self.assertTrue((self.root / 'usr/share/hydra/runtime-update.json').is_file())
|
||||
self.assertEqual((self.root / 'usr/share/hydra/fastfetch.txt').read_text(), 'hydra logo\n')
|
||||
|
||||
def test_branding_preserves_vendor_release_and_backs_up_etc_symlink(self):
|
||||
vendor = self.root / 'usr/lib/os-release'
|
||||
vendor.parent.mkdir(parents=True, exist_ok=True)
|
||||
vendor.write_text('NAME="Gentoo"\nID=gentoo\nVERSION_ID=2.18\n')
|
||||
release = self.root / 'etc/os-release'
|
||||
release.parent.mkdir(parents=True)
|
||||
release.symlink_to('../usr/lib/os-release')
|
||||
self.build()
|
||||
result = self.mod.apply_bundle(self.bundle, self.root, execute=True)
|
||||
self.assertFalse(release.is_symlink())
|
||||
self.assertIn('PRETTY_NAME="Hydra OS"', release.read_text())
|
||||
self.assertIn('ID_LIKE=gentoo', release.read_text())
|
||||
self.assertIn('VERSION_ID=2.18', release.read_text())
|
||||
self.assertEqual(vendor.read_text(), 'NAME="Gentoo"\nID=gentoo\nVERSION_ID=2.18\n')
|
||||
saved = Path(result['backup']) / 'etc/os-release'
|
||||
self.assertTrue(saved.is_symlink())
|
||||
self.assertEqual(str(saved.readlink()), '../usr/lib/os-release')
|
||||
|
||||
def test_non_hydra_root_is_refused_before_changes(self):
|
||||
self.build()
|
||||
(self.root / 'usr/share/hydra/build-manifest.json').unlink()
|
||||
with self.assertRaisesRegex(self.mod.UpdateError, 'Hydra'):
|
||||
self.mod.apply_bundle(self.bundle, self.root, execute=True)
|
||||
self.assertEqual(self.old.read_text(), 'OLD = True\n')
|
||||
|
||||
def test_modified_payload_is_refused_before_changes(self):
|
||||
self.build()
|
||||
other = self.base / 'tampered.pyz'
|
||||
with zipfile.ZipFile(self.bundle) as src, zipfile.ZipFile(other, 'w') as dst:
|
||||
for name in src.namelist():
|
||||
data = b'CORRUPTED\n' if name == 'payload/usr/lib/hydra/hydra/cli.py' else src.read(name)
|
||||
dst.writestr(name, data)
|
||||
with self.assertRaisesRegex(self.mod.UpdateError, 'checksum'):
|
||||
self.mod.apply_bundle(other, self.root, execute=True)
|
||||
self.assertEqual(self.old.read_text(), 'OLD = True\n')
|
||||
|
||||
def test_symlink_destination_cannot_redirect_privileged_update(self):
|
||||
self.build()
|
||||
outside = self.base / 'outside'
|
||||
outside.write_text('KEEP\n')
|
||||
self.old.unlink()
|
||||
self.old.symlink_to(outside)
|
||||
with self.assertRaisesRegex(self.mod.UpdateError, '[Ss]ymlink'):
|
||||
self.mod.apply_bundle(self.bundle, self.root, execute=True)
|
||||
self.assertEqual(outside.read_text(), 'KEEP\n')
|
||||
|
||||
def test_bundle_never_overwrites_an_existing_output(self):
|
||||
self.bundle.write_bytes(b'keep this artifact')
|
||||
with self.assertRaises((FileExistsError, self.mod.UpdateError)):
|
||||
self.build()
|
||||
self.assertEqual(self.bundle.read_bytes(), b'keep this artifact')
|
||||
|
||||
def test_self_contained_bundle_applies_without_source_checkout(self):
|
||||
self.build()
|
||||
result = subprocess.run([sys.executable, str(self.bundle), '--root', str(self.root), '--apply'],
|
||||
capture_output=True, text=True, timeout=10, cwd=self.base)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(self.old.read_text(), 'NEW = True\n')
|
||||
self.assertIn('Runtime updated', result.stdout)
|
||||
|
||||
def test_write_failure_rolls_back_files_already_replaced(self):
|
||||
self.build()
|
||||
actual_write = self.mod.atomic_write
|
||||
calls = 0
|
||||
|
||||
def fail_once(*args):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 3:
|
||||
raise OSError('injected full disk')
|
||||
return actual_write(*args)
|
||||
|
||||
with patch.object(self.mod, 'atomic_write', side_effect=fail_once):
|
||||
with self.assertRaisesRegex(OSError, 'full disk'):
|
||||
self.mod.apply_bundle(self.bundle, self.root, execute=True)
|
||||
self.assertEqual(self.old.read_text(), 'OLD = True\n')
|
||||
self.assertFalse((self.root / 'etc/motd').exists())
|
||||
self.assertFalse((self.root / 'usr/lib/hydra/bin/hydra').exists())
|
||||
|
||||
def test_persistent_restore_failures_attempt_all_cleanup_and_preserve_apply_error(self):
|
||||
import contextlib
|
||||
import io
|
||||
launcher = self.root / 'usr/lib/hydra/bin/hydra'
|
||||
launcher.parent.mkdir(parents=True)
|
||||
launcher.write_text('OLD LAUNCHER\n')
|
||||
self.build()
|
||||
actual_write = self.mod.atomic_write
|
||||
original_error = OSError('original apply failure: disk remains full')
|
||||
calls = 0
|
||||
|
||||
def persistent_failure(*args):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 5:
|
||||
raise original_error
|
||||
if calls > 5:
|
||||
raise OSError('persistent restore failure: disk remains full')
|
||||
return actual_write(*args)
|
||||
|
||||
errors = io.StringIO()
|
||||
with contextlib.redirect_stderr(errors), patch.object(self.mod, 'atomic_write', side_effect=persistent_failure):
|
||||
with self.assertRaises(OSError) as raised:
|
||||
self.mod.apply_bundle(self.bundle, self.root, execute=True)
|
||||
self.assertIs(raised.exception, original_error)
|
||||
self.assertEqual(self.old.read_text(), 'NEW = True\n')
|
||||
self.assertFalse((self.root / 'etc/motd').exists())
|
||||
self.assertFalse((self.root / 'usr/lib/hydra/hydra/__init__.py').exists())
|
||||
backups = list((self.root / 'var/lib/hydra/updates').iterdir())
|
||||
self.assertEqual(len(backups), 1)
|
||||
backup = backups[0]
|
||||
self.assertEqual((backup / 'usr/lib/hydra/hydra/cli.py').read_text(), 'OLD = True\n')
|
||||
self.assertEqual((backup / 'usr/lib/hydra/bin/hydra').read_text(), 'OLD LAUNCHER\n')
|
||||
self.assertIn(str(backup), errors.getvalue())
|
||||
self.assertIn('rollback incomplete', errors.getvalue().lower())
|
||||
self.assertIn('usr/lib/hydra/hydra/cli.py', errors.getvalue())
|
||||
self.assertIn('usr/lib/hydra/bin/hydra', errors.getvalue())
|
||||
self.assertIn('persistent restore failure', errors.getvalue())
|
||||
+134
-2
@@ -23,6 +23,17 @@ class ImageTests(unittest.TestCase):
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name) / 'rootfs'
|
||||
self.root.mkdir()
|
||||
self.vendor_tmpfiles = {
|
||||
'home.conf': '# vendor home policy\nQ /home 0755 - - -\nq /srv 0755 - - -\n',
|
||||
'portables.conf': '# vendor portables policy\nQ /var/lib/portables 0700\n',
|
||||
'systemd-nspawn.conf': (
|
||||
'# vendor nspawn policy\nQ /var/lib/machines 0700 - - -\n'
|
||||
'R! /var/lib/machines/.#*\nR! /.#machine.*\n'
|
||||
'L? /run/systemd/io.systemd.NamespaceResource - - - - /run/host/io.systemd.NamespaceResource\n'
|
||||
'L? /run/systemd/io.systemd.MountFileSystem - - - - /run/host/io.systemd.MountFileSystem\n'),
|
||||
}
|
||||
for name, content in self.vendor_tmpfiles.items():
|
||||
self.file('usr/lib/tmpfiles.d/' + name, content)
|
||||
|
||||
def file(self, relative, content='preserved'):
|
||||
path = self.root / relative
|
||||
@@ -35,6 +46,127 @@ class ImageTests(unittest.TestCase):
|
||||
self.file('boot/vmlinuz-' + version, 'kernel')
|
||||
return version
|
||||
|
||||
def test_managed_root_tmpfiles_creates_ordinary_directories_without_losing_vendor_rules(self):
|
||||
self.kernel()
|
||||
source = Path(__file__).resolve().parents[1]
|
||||
retained = [self.file(path + '/retained-data', 'user data')
|
||||
for path in ('srv', 'var/lib/portables', 'var/lib/machines')]
|
||||
image.configure_rootfs(self.root, source)
|
||||
expected = {
|
||||
'home.conf': '# vendor home policy\nQ /home 0755 - - -\nd /srv 0755 - - -\n',
|
||||
'portables.conf': '# vendor portables policy\nd /var/lib/portables 0700\n',
|
||||
'systemd-nspawn.conf': (
|
||||
'# vendor nspawn policy\nd /var/lib/machines 0700 - - -\n'
|
||||
'R! /var/lib/machines/.#*\nR! /.#machine.*\n'
|
||||
'L? /run/systemd/io.systemd.NamespaceResource - - - - /run/host/io.systemd.NamespaceResource\n'
|
||||
'L? /run/systemd/io.systemd.MountFileSystem - - - - /run/host/io.systemd.MountFileSystem\n'),
|
||||
}
|
||||
for name, content in expected.items():
|
||||
with self.subTest(name=name):
|
||||
override = self.root / 'etc/tmpfiles.d' / name
|
||||
self.assertTrue(override.is_file(), f'missing managed-root policy: {name}')
|
||||
self.assertEqual(override.read_text(), content)
|
||||
self.assertEqual(override.stat().st_mode & 0o777, 0o644)
|
||||
self.assertEqual((self.root / 'usr/lib/tmpfiles.d' / name).read_text(),
|
||||
self.vendor_tmpfiles[name])
|
||||
self.assertEqual([path.read_text() for path in retained], ['user data'] * 3)
|
||||
|
||||
def test_managed_root_tmpfiles_refuses_missing_duplicate_or_changed_vendor_rule(self):
|
||||
import shutil
|
||||
self.kernel()
|
||||
source = Path(__file__).resolve().parents[1]
|
||||
for index, content in enumerate((None, '# no managed path\n', 'd /var/lib/machines 0700\n',
|
||||
'Q /var/lib/machines 0700\nq /var/lib/machines 0700\n',
|
||||
'Q /var/lib/machines 0700\nd /var/lib/machines 0700\n')):
|
||||
with self.subTest(content=content):
|
||||
root = Path(self.tmp.name) / ('drift-' + str(index))
|
||||
shutil.copytree(self.root, root)
|
||||
vendor = root / 'usr/lib/tmpfiles.d/systemd-nspawn.conf'
|
||||
if content is None:
|
||||
vendor.unlink()
|
||||
else:
|
||||
vendor.write_text(content)
|
||||
with self.assertRaisesRegex(image.ImageError, 'tmpfiles'):
|
||||
image.configure_rootfs(root, source)
|
||||
self.assertFalse((root / 'etc/tmpfiles.d/home.conf').exists())
|
||||
|
||||
def test_managed_root_tmpfiles_refuses_existing_override(self):
|
||||
self.kernel()
|
||||
original = self.file('etc/tmpfiles.d/home.conf', 'd /srv 0710 local local -\n')
|
||||
with self.assertRaisesRegex(image.ImageError, 'tmpfiles'):
|
||||
image.configure_rootfs(self.root, Path(__file__).resolve().parents[1])
|
||||
self.assertEqual(original.read_text(), 'd /srv 0710 local local -\n')
|
||||
|
||||
def test_managed_root_tmpfiles_refuses_vendor_symlink(self):
|
||||
self.kernel()
|
||||
original = self.file('usr/lib/tmpfiles.d/other.conf', 'Q /var/lib/machines 0700\n')
|
||||
vendor = self.root / 'usr/lib/tmpfiles.d/systemd-nspawn.conf'
|
||||
vendor.unlink()
|
||||
vendor.symlink_to('other.conf')
|
||||
with self.assertRaisesRegex(image.ImageError, 'tmpfiles'):
|
||||
image.configure_rootfs(self.root, Path(__file__).resolve().parents[1])
|
||||
self.assertEqual(original.read_text(), 'Q /var/lib/machines 0700\n')
|
||||
|
||||
def test_installs_independent_updater_and_base_recovery_assets(self):
|
||||
self.kernel()
|
||||
source = Path(__file__).resolve().parents[1]
|
||||
image.configure_rootfs(self.root, source)
|
||||
for relative, asset, mode in (
|
||||
('usr/lib/hydra-update/engine.py', 'hydra/update_engine.py', 0o644),
|
||||
('usr/bin/hydra-update', 'bin/hydra-update', 0o755),
|
||||
('usr/bin/hydra-update-recover', 'bin/hydra-update-recover', 0o755),
|
||||
('usr/lib/hydra/bin/hydra-system', 'bin/hydra-system', 0o755),
|
||||
('etc/hydra/update.pub', 'image/rootfs/etc/hydra/update.pub', 0o644),
|
||||
):
|
||||
with self.subTest(relative=relative):
|
||||
path = self.root / relative
|
||||
self.assertTrue(path.is_file(), relative)
|
||||
self.assertEqual(path.read_bytes(), (source / asset).read_bytes())
|
||||
self.assertEqual(path.stat().st_mode & 0o777, mode)
|
||||
wrapper = self.root / 'usr/bin/hydra-system'
|
||||
self.assertTrue(wrapper.is_file(), 'base recovery launcher is missing')
|
||||
self.assertEqual(wrapper.stat().st_mode & 0o777, 0o755)
|
||||
self.assertEqual(wrapper.read_text(),
|
||||
'#!/usr/bin/python3 -I\nimport sys\nsys.path.insert(0, "/usr/lib/hydra")\n'
|
||||
'from hydra.system_recovery import main\nraise SystemExit(main())\n')
|
||||
self.assertEqual((self.root / 'usr/lib/hydra/hydra/doctor.py').read_bytes(),
|
||||
(source / 'hydra/doctor.py').read_bytes())
|
||||
|
||||
def test_root_assembly_sets_trusted_asset_ownership(self):
|
||||
from unittest.mock import patch
|
||||
self.kernel()
|
||||
with patch('hydra.image.os.geteuid', return_value=0), patch('hydra.image.os.chown') as chown:
|
||||
image.configure_rootfs(self.root, Path(__file__).resolve().parents[1])
|
||||
for relative in ('usr/lib/hydra-update/engine.py', 'usr/lib/hydra-update',
|
||||
'usr/bin/hydra-update', 'usr/bin/hydra-update-recover', 'etc/hydra/update.pub'):
|
||||
chown.assert_any_call(self.root / relative, 0, 0)
|
||||
|
||||
def test_missing_trusted_asset_refuses_configuration_before_mutation(self):
|
||||
import shutil
|
||||
source = Path(__file__).resolve().parents[1]
|
||||
fixture = Path(self.tmp.name) / 'source'
|
||||
fixture.mkdir()
|
||||
for directory in ('bin', 'hydra', 'image'):
|
||||
shutil.copytree(source / directory, fixture / directory,
|
||||
ignore=shutil.ignore_patterns('__pycache__'))
|
||||
for asset in ('bin/hydra-update', 'bin/hydra-update-recover', 'hydra/update_engine.py',
|
||||
'bin/hydra-system', 'hydra/system_recovery.py', 'image/rootfs/etc/hydra/update.pub'):
|
||||
with self.subTest(asset=asset):
|
||||
self.root = Path(self.tmp.name) / ('root-' + asset.replace('/', '-'))
|
||||
self.root.mkdir()
|
||||
self.kernel('6.18.54-gentoo-dist-bin')
|
||||
original = self.file('etc/machine-id', 'preserved-builder-id')
|
||||
missing = fixture / asset
|
||||
saved = missing.read_bytes()
|
||||
missing.unlink()
|
||||
try:
|
||||
with self.assertRaisesRegex(image.ImageError, 'missing'):
|
||||
image.configure_rootfs(self.root, fixture)
|
||||
self.assertEqual(original.read_text(), 'preserved-builder-id')
|
||||
self.assertFalse((self.root / 'usr').exists())
|
||||
finally:
|
||||
missing.write_bytes(saved)
|
||||
|
||||
def test_kernel_from_payload_not_running_host(self):
|
||||
self.kernel()
|
||||
self.assertEqual(image.kernel_version(self.root), '6.12.58-gentoo-dist')
|
||||
@@ -256,8 +388,8 @@ sys.exit(result.returncode)
|
||||
result = subprocess.run([sys.executable, '-I', '-c', script, str(self.root / 'usr/lib/hydra'), str(manifest)],
|
||||
cwd=self.tmp.name, text=True, capture_output=True, timeout=20)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn('HydraOS local recovery', result.stdout)
|
||||
self.assertIn('add', result.stdout)
|
||||
self.assertIn('Hydra OS', result.stdout)
|
||||
self.assertIn('run', result.stdout)
|
||||
|
||||
def test_installed_updates_exclude_unadapted_qemu_and_viewer_binaries(self):
|
||||
self.kernel()
|
||||
|
||||
+369
-1
@@ -43,6 +43,12 @@ class FakeProbe:
|
||||
def physical_disk(self, path):
|
||||
return path == '/dev/vda'
|
||||
|
||||
def verify_identity(self, path, node):
|
||||
return None
|
||||
|
||||
def partition_number(self, path, node):
|
||||
return {'/dev/vda1': 1, '/dev/vda2': 2, '/dev/vda3': 3}.get(path, 0)
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
def __init__(self, root, fail=None):
|
||||
@@ -68,11 +74,357 @@ class FakeRunner:
|
||||
(self.root / 'etc/systemd/system/getty@tty1.service.d/90-hydra-live.conf').touch()
|
||||
(self.root / 'etc/sudoers.d/90-hydra-live').touch()
|
||||
(self.root / 'boot/vmlinuz-6.12.1').touch()
|
||||
if argv[0] == 'unsquashfs' and '-lln' in argv:
|
||||
return 'drwxr-xr-x 0/0 0 2026-10-13 12:00 squashfs-root\n-rw-r--r-- 0/0 2048 2026-10-13 12:00 squashfs-root/file\n'
|
||||
if argv[0] == 'blkid':
|
||||
return '11111111-2222-3333-4444-555555555555\n'
|
||||
if 'PARTUUID' in argv:
|
||||
number = '1' if argv[-1].endswith('1') else '2'
|
||||
return 'aaaaaaaa-bbbb-cccc-dddd-' + number.zfill(12) + '\n'
|
||||
return 'ABCD-1234\n' if argv[-1].endswith('1') else '11111111-2222-3333-4444-555555555555\n'
|
||||
return ''
|
||||
|
||||
|
||||
def partition_topology(*, sibling_mounted=False):
|
||||
esp = disk('/dev/vda1', kind='part')
|
||||
root = disk('/dev/vda2', kind='part')
|
||||
sibling = disk('/dev/vda3', kind='part', mounts=['/other'] if sibling_mounted else [])
|
||||
for node, start, size, number in ((esp, 2048, 1024**3, 1),
|
||||
(root, 2099200, 8*1024**3, 2),
|
||||
(sibling, 18876416, 4*1024**3, 3)):
|
||||
node.update(start=start, size=size, partuuid=f'aaaaaaaa-bbbb-cccc-dddd-{number:012d}',
|
||||
parttype='c12a7328-f81f-11d2-ba4b-00a0c93ec93b' if number == 1 else
|
||||
'0fc63daf-8483-4772-8e79-3d69d8477de4',
|
||||
fstype='vfat' if number == 1 else 'ext4', uuid='ABCD-1234' if number == 1 else
|
||||
f'11111111-2222-3333-4444-{number:012d}')
|
||||
return topology(disk(children=[esp, root, sibling]))
|
||||
|
||||
|
||||
def partition_request(**values):
|
||||
return i.Request(None, 'emil', root_partition='/dev/vda2', esp_partition='/dev/vda1',
|
||||
**values)
|
||||
|
||||
|
||||
class PartitionGuardTests(unittest.TestCase):
|
||||
def test_explicit_partitions_allow_unrelated_mounted_sibling(self):
|
||||
self.assertEqual(i.validate_request(partition_request(), FakeProbe(partition_topology(sibling_mounted=True))), '/dev/vda2')
|
||||
|
||||
def test_duplicate_esp_uuid_on_surviving_devices_is_refused(self):
|
||||
for location in ('sibling', 'other-disk'):
|
||||
for copied_uuid in ('ABCD-1234', 'abcd-1234'):
|
||||
topo = partition_topology()
|
||||
if location == 'sibling':
|
||||
copied = topo.nodes['/dev/vda3']
|
||||
else:
|
||||
copied = disk('/dev/vdb1', kind='part', pkname='/dev/vdb')
|
||||
copied.update(start=2048, size=1024**3,
|
||||
partuuid='bbbbbbbb-cccc-dddd-eeee-000000000001')
|
||||
topo = topology(topo.nodes['/dev/vda'], disk('/dev/vdb', children=[copied]))
|
||||
copied = topo.nodes['/dev/vdb1']
|
||||
copied.update(fstype='vfat', uuid=copied_uuid,
|
||||
parttype='c12a7328-f81f-11d2-ba4b-00a0c93ec93b')
|
||||
with self.subTest(location=location, uuid=copied_uuid), self.assertRaisesRegex(i.InstallError, 'ESP filesystem UUID'):
|
||||
i.validate_request(partition_request(), FakeProbe(topo))
|
||||
|
||||
def test_modes_and_confirmations_are_unambiguous(self):
|
||||
for changes in ({'disk': '/dev/vda'}, {'esp_partition': None}, {'root_fs': 'xfs'},
|
||||
{'confirm_erase': '/dev/vda'}, {'confirm_format': '/dev/vda3', 'execute': True}):
|
||||
request = partition_request()
|
||||
for key, value in changes.items():
|
||||
setattr(request, key, value)
|
||||
with self.subTest(changes=changes), self.assertRaises(i.InstallError):
|
||||
i.validate_request(request, FakeProbe(partition_topology()))
|
||||
self.assertEqual(i.validate_request(partition_request(execute=True, confirm_format='/dev/vda2'),
|
||||
FakeProbe(partition_topology())), '/dev/vda2')
|
||||
|
||||
def test_selected_mount_holder_alias_overlap_or_bad_metadata_refused(self):
|
||||
for mutation in ('mount', 'holder', 'same', 'bounds', 'overlap', 'missing', 'not-fat', 'not-esp', 'nested'):
|
||||
topo = partition_topology()
|
||||
req = partition_request()
|
||||
if mutation == 'mount': topo.protect('/dev/vda2')
|
||||
if mutation == 'holder':
|
||||
topo.nodes['/dev/holder'] = disk('/dev/holder', kind='crypt')
|
||||
topo.edges['/dev/holder'] = set(); topo.parents['/dev/holder'] = set()
|
||||
topo.link('/dev/holder', '/dev/vda2')
|
||||
if mutation == 'same': req.esp_partition = req.root_partition
|
||||
if mutation == 'bounds': topo.nodes['/dev/vda2']['size'] = 40*1024**3
|
||||
if mutation == 'overlap': topo.nodes['/dev/vda2']['start'] = 2048
|
||||
if mutation == 'missing': del topo.nodes['/dev/vda2']['partuuid']
|
||||
if mutation == 'not-fat': topo.nodes['/dev/vda1']['fstype'] = 'ext4'
|
||||
if mutation == 'not-esp': topo.nodes['/dev/vda1']['parttype'] = '0fc63daf-8483-4772-8e79-3d69d8477de4'
|
||||
if mutation == 'nested': topo.parents['/dev/vda2'].add('/dev/vda3')
|
||||
with self.subTest(mutation=mutation), self.assertRaises(i.InstallError):
|
||||
i.validate_request(req, FakeProbe(topo))
|
||||
|
||||
def test_malformed_optional_storage_metadata_is_rejected(self):
|
||||
for field, value in (('partuuid', 123), ('uuid', []), ('parttype', {}), ('start', True)):
|
||||
nodes = partition_topology().nodes
|
||||
nodes['/dev/vda1'][field] = value
|
||||
with self.subTest(field=field), self.assertRaises(i.InstallError):
|
||||
topo = topology(nodes['/dev/vda'])
|
||||
i.validate_request(partition_request(), FakeProbe(topo))
|
||||
|
||||
def test_btrfs_loop_backing_members_preserve_eligible_root(self):
|
||||
nodes = list(partition_topology().nodes.values())[:1]
|
||||
loop = disk('/dev/loop0', kind='loop', mounts=['/scratch']); loop['maj:min']='7:0'
|
||||
media = disk('/dev/sr0', kind='rom', mounts=['/live']); media['maj:min']='11:0'
|
||||
nodes.extend([loop,media])
|
||||
btrfs={'source':'/dev/vda3[/home]', 'target':'/other', 'fstype':'btrfs','maj:min':'0:37','options':'rw'}
|
||||
scratch={'source':'/dev/loop0','target':'/scratch','fstype':'ext4','maj:min':'7:0','options':'rw'}
|
||||
live={'source':'/dev/sr0','target':'/live','fstype':'iso9660','maj:min':'11:0','options':'ro'}
|
||||
class ReadOnlyRunner:
|
||||
def run(self, argv):
|
||||
if argv[0]=='lsblk': return json.dumps({'blockdevices':nodes})
|
||||
if argv[0]=='btrfs':
|
||||
return 'Label: none uuid: abc\n Total devices 1 FS bytes used 4096\n devid 1 size 999 used 1 path /dev/vda3\n'
|
||||
return json.dumps({'filesystems':[live] if '--target' in argv else [btrfs,scratch,live]})
|
||||
probe=i.SystemProbe(ReadOnlyRunner())
|
||||
probe.physical_disk=lambda path: path=='/dev/vda'
|
||||
probe.verify_identity=lambda path,node: None
|
||||
probe.partition_number=lambda path,node: 1 if path.endswith('1') else 2
|
||||
with patch.object(Path,'is_file',return_value=True), patch.object(i.SystemProbe,'_loop_backing_identity',return_value='0:37'):
|
||||
topo=probe.snapshot(i.DEFAULT_PAYLOAD)
|
||||
probe.snapshot=lambda payload:topo
|
||||
self.assertIn('/dev/vda3',topo.parents['/dev/loop0'])
|
||||
self.assertEqual(i.validate_request(partition_request(),probe),'/dev/vda2')
|
||||
|
||||
def test_dry_run_mounts_use_requested_root_and_validated_esp_types(self):
|
||||
for request in (i.Request('/dev/vda', 'emil'), partition_request()):
|
||||
with self.subTest(mode='disk' if request.disk else 'partitions'):
|
||||
target='/dev/vda' if request.disk else '/dev/vda2'
|
||||
plan=i.render_plan(request,target,'/mnt/target')
|
||||
self.assertIn('mount -t ext4 /dev/vda2 /mnt/target',plan)
|
||||
self.assertIn('mount -t vfat /dev/vda1 /mnt/target/boot/efi',plan)
|
||||
btrfs_plan=i.render_plan(partition_request(root_fs='btrfs'),'/dev/vda2','/mnt/target')
|
||||
self.assertIn('mount -t btrfs -o subvol=@root /dev/vda2 /mnt/target',btrfs_plan)
|
||||
self.assertIn('mount -t vfat /dev/vda1 /mnt/target/boot/efi',btrfs_plan)
|
||||
|
||||
def test_partition_plan_formats_only_root_and_preserves_efi(self):
|
||||
req = partition_request()
|
||||
plan = i.render_plan(req, '/dev/vda2', '/mnt/target')
|
||||
self.assertIn('mkfs.ext4', plan)
|
||||
self.assertIn('--no-nvram', plan)
|
||||
self.assertNotIn('--removable', plan)
|
||||
self.assertNotIn('parted', plan)
|
||||
self.assertNotIn('mkfs.vfat', plan)
|
||||
self.assertIn('--confirm-format /dev/vda2', plan)
|
||||
|
||||
def test_btrfs_dry_run_includes_real_mount_layout(self):
|
||||
plan=i.render_plan(partition_request(root_fs='btrfs'),'/dev/vda2','/mnt/target')
|
||||
self.assertIn('subvol=@root',plan)
|
||||
self.assertIn('subvol=@home',plan)
|
||||
self.assertIn('subvol=@snapshots',plan)
|
||||
self.assertLess(plan.index('ro,nosuid,nodev,noexec'),plan.index('mkfs.btrfs'))
|
||||
|
||||
def test_cli_accepts_partition_mode_without_disk(self):
|
||||
output=io.StringIO()
|
||||
with patch.object(i,'SystemProbe',return_value=FakeProbe(partition_topology())), contextlib.redirect_stdout(output):
|
||||
self.assertEqual(i.main(['--root-partition','/dev/vda2','--esp-partition','/dev/vda1','--user','emil']),0)
|
||||
self.assertIn('format ONLY /dev/vda2',output.getvalue())
|
||||
|
||||
def test_btrfs_mount_protects_all_members_without_protecting_siblings(self):
|
||||
topo = partition_topology(sibling_mounted=True)
|
||||
class ReadOnlyRunner:
|
||||
def run(self, argv):
|
||||
return ("Label: none uuid: 11111111-2222-3333-4444-555555555555\n"
|
||||
"\tTotal devices 1 FS bytes used 4096\n"
|
||||
"\tdevid 1 size 4294967296 used 4096 path /dev/vda3\n")
|
||||
probe = i.SystemProbe(ReadOnlyRunner())
|
||||
probe._block_source({'source':'/dev/vda3[/home]', 'target':'/other', 'fstype':'btrfs',
|
||||
'maj:min':'0:37', 'options':'rw'}, topo)
|
||||
self.assertIn('/dev/vda3', topo.protected)
|
||||
self.assertEqual(i.validate_request(partition_request(), FakeProbe(topo)), '/dev/vda2')
|
||||
|
||||
def test_multi_member_btrfs_mount_protects_selected_target(self):
|
||||
class ReadOnlyRunner:
|
||||
def run(self, argv):
|
||||
return ('Label: none uuid: abc\n Total devices 2 FS bytes used 4096\n'
|
||||
' devid 1 size 999 used 1 path /dev/vda3\n'
|
||||
' devid 2 size 999 used 1 path /dev/vda2\n')
|
||||
topo = partition_topology()
|
||||
i.SystemProbe(ReadOnlyRunner())._block_source(
|
||||
{'source':'/dev/vda3', 'target':'/other', 'fstype':'btrfs', 'maj:min':'0:37'}, topo)
|
||||
with self.assertRaises(i.InstallError):
|
||||
i.validate_request(partition_request(), FakeProbe(topo))
|
||||
|
||||
def test_conflicting_duplicate_partition_metadata_is_rejected(self):
|
||||
node = disk('/dev/vda1', kind='part', pkname='/dev/vda')
|
||||
other = dict(node, start=9999)
|
||||
with self.assertRaises(i.InstallError):
|
||||
topology(disk(children=[dict(node, start=2048)]), other)
|
||||
|
||||
def test_btrfs_source_not_in_reported_members_is_rejected(self):
|
||||
class ReadOnlyRunner:
|
||||
def run(self, argv):
|
||||
return 'Label: none uuid: abc\n Total devices 1 FS bytes used 4096\n devid 1 size 999 used 1 path /dev/vda3\n'
|
||||
with self.assertRaises(i.InstallError):
|
||||
i.SystemProbe(ReadOnlyRunner())._block_source(
|
||||
{'source':'/dev/vda2', 'target':'/other', 'fstype':'btrfs', 'maj:min':'0:37'}, partition_topology())
|
||||
|
||||
def test_incomplete_btrfs_members_fail_closed(self):
|
||||
class ReadOnlyRunner:
|
||||
def run(self, argv):
|
||||
return 'Label: none uuid: abc\n Total devices 2 FS bytes used 4096\n devid 1 size 999 used 1 path /dev/vda3\n'
|
||||
with self.assertRaises(i.InstallError):
|
||||
i.SystemProbe(ReadOnlyRunner())._block_source(
|
||||
{'source':'/dev/vda3', 'target':'/other', 'fstype':'btrfs', 'maj:min':'0:37'}, partition_topology())
|
||||
|
||||
|
||||
class PartitionPipelineTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory(); self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)/'target'; self.root.mkdir()
|
||||
self.probe = FakeProbe(partition_topology(sibling_mounted=True))
|
||||
self.request = partition_request(execute=True, confirm_format='/dev/vda2')
|
||||
|
||||
def install(self, runner):
|
||||
i.install(self.request, runner, self.probe, self.root, 'test-password', io.StringIO())
|
||||
|
||||
def test_duplicate_fat_uuid_with_distinct_partuuid_stops_before_root_format(self):
|
||||
copied = self.probe.topo.nodes['/dev/vda3']
|
||||
copied.update(fstype='vfat', uuid='ABCD-1234',
|
||||
parttype='c12a7328-f81f-11d2-ba4b-00a0c93ec93b')
|
||||
self.assertNotEqual(copied['partuuid'], self.probe.topo.nodes['/dev/vda1']['partuuid'])
|
||||
runner = FakeRunner(self.root)
|
||||
with self.assertRaisesRegex(i.InstallError, 'ESP filesystem UUID'):
|
||||
self.install(runner)
|
||||
self.assertEqual(runner.calls, [])
|
||||
self.assertFalse((self.root/'etc/fstab').exists())
|
||||
|
||||
def test_partition_execution_mounts_known_filesystem_types_explicitly(self):
|
||||
runner=FakeRunner(self.root); self.install(runner)
|
||||
self.assertIn(['mount','-t','ext4','/dev/vda2',str(self.root)],runner.calls)
|
||||
self.assertIn(['mount','-t','vfat','/dev/vda1',str(self.root/'boot/efi')],runner.calls)
|
||||
|
||||
def test_existing_mode_formats_root_only_and_records_owned_layout(self):
|
||||
runner = FakeRunner(self.root)
|
||||
self.install(runner)
|
||||
self.assertFalse(any(c[0] in ('parted', 'partprobe', 'mkfs.vfat') for c in runner.calls))
|
||||
self.assertEqual([c[-1] for c in runner.calls if c[0].startswith('mkfs.')], ['/dev/vda2'])
|
||||
self.assertFalse(any('--removable' in c for c in runner.calls))
|
||||
self.assertFalse(any(c[0] == 'efibootmgr' for c in runner.calls))
|
||||
ro = next(n for n,c in enumerate(runner.calls) if c[0]=='mount' and 'ro,nosuid,nodev,noexec' in c)
|
||||
format_at = next(n for n,c in enumerate(runner.calls) if c[0]=='mkfs.ext4')
|
||||
self.assertLess(ro, format_at)
|
||||
marker = json.loads((self.root/'etc/hydra/storage.json').read_text())
|
||||
self.assertEqual(marker['format'], 1); self.assertEqual(marker['filesystem'], 'ext4')
|
||||
self.assertEqual(marker['owned_efi_path'], 'EFI/HydraOS')
|
||||
self.assertEqual(stat.S_IMODE((self.root/'etc/hydra/storage.json').stat().st_mode), 0o600)
|
||||
self.assertNotIn('root_subvolume', marker)
|
||||
self.assertIn('GRUB_DISABLE_OS_PROBER=true', (self.root/'etc/default/grub').read_text())
|
||||
|
||||
def test_esp_collision_or_insufficient_space_prevents_formatting(self):
|
||||
for problem in ('collision', 'space'):
|
||||
runner = FakeRunner(self.root)
|
||||
real_run = runner.run
|
||||
def run(argv, **kw):
|
||||
result = real_run(argv, **kw)
|
||||
if argv[0]=='mount' and 'ro,nosuid,nodev,noexec' in argv and problem=='collision':
|
||||
(Path(argv[-1])/'EFI/HydraOS').mkdir(parents=True)
|
||||
return result
|
||||
runner.run = run
|
||||
context = patch('os.statvfs', return_value=SimpleNamespace(f_bavail=1, f_frsize=4096)) if problem=='space' else contextlib.nullcontext()
|
||||
with self.subTest(problem=problem), context, self.assertRaises(i.InstallError):
|
||||
self.install(runner)
|
||||
self.assertFalse(any(c[0].startswith('mkfs.') for c in runner.calls))
|
||||
|
||||
def test_esp_preflight_failure_never_recursively_removes_foreign_files(self):
|
||||
runner=FakeRunner(self.root); original_run=runner.run; sentinels=[]
|
||||
def run(argv, **kw):
|
||||
result=original_run(argv, **kw)
|
||||
if argv[0]=='mount' and 'ro,nosuid,nodev,noexec' in argv:
|
||||
sentinel=Path(argv[-1])/'EFI/HydraOS/foreign'
|
||||
sentinel.parent.mkdir(parents=True); sentinel.write_bytes(b'keep')
|
||||
sentinels.append(sentinel)
|
||||
return result
|
||||
runner.run=run
|
||||
with self.assertRaises(i.InstallError): self.install(runner)
|
||||
self.assertEqual(sentinels[0].read_bytes(), b'keep')
|
||||
self.assertFalse(any(c[0].startswith('mkfs.') for c in runner.calls))
|
||||
|
||||
def test_identity_change_after_esp_preflight_prevents_formatting(self):
|
||||
runner = FakeRunner(self.root)
|
||||
original_snapshot = self.probe.snapshot
|
||||
def snapshot(payload):
|
||||
topo = original_snapshot(payload)
|
||||
if self.probe.calls >= 2: topo.nodes['/dev/vda2']['maj:min'] = '252:9999'
|
||||
return topo
|
||||
self.probe.snapshot = snapshot
|
||||
with self.assertRaises(i.InstallError): self.install(runner)
|
||||
self.assertFalse(any(c[0].startswith('mkfs.') for c in runner.calls))
|
||||
|
||||
def test_btrfs_root_uses_named_subvolumes_and_boot_arguments(self):
|
||||
self.request.root_fs = 'btrfs'
|
||||
runner = FakeRunner(self.root); self.install(runner)
|
||||
self.assertIn(['mkfs.btrfs', '-f', '-L', 'HYDRA_ROOT', '/dev/vda2'], runner.calls)
|
||||
subvolumes = [Path(c[-1]).name for c in runner.calls if c[:3]==['btrfs','subvolume','create']]
|
||||
self.assertEqual(subvolumes, ['@root','@home','@snapshots'])
|
||||
fstab=(self.root/'etc/fstab').read_text()
|
||||
for name in ('@root','@home','@snapshots'): self.assertIn('subvol='+name, fstab)
|
||||
self.assertNotIn('subvolid', fstab)
|
||||
self.assertIn('rootflags=subvol=@root', (self.root/'etc/default/grub').read_text())
|
||||
self.assertIn('rootflags=subvol=@root', (self.root/'etc/dracut.conf.d/90-hydra-installed.conf').read_text())
|
||||
self.assertEqual(json.loads((self.root/'etc/hydra/storage.json').read_text())['root_subvolume'],'@root')
|
||||
self.assertEqual(runner.calls[-1], ['umount', str(self.root)])
|
||||
|
||||
def test_foreign_efi_files_are_compared_after_loader_install(self):
|
||||
runner=FakeRunner(self.root)
|
||||
original_run=runner.run
|
||||
def run(argv, **kw):
|
||||
result=original_run(argv, **kw)
|
||||
if argv[0]=='mount' and argv[-2]=='/dev/vda1':
|
||||
foreign=Path(argv[-1])/'EFI/BOOT/BOOTX64.EFI'
|
||||
foreign.parent.mkdir(parents=True, exist_ok=True); foreign.write_bytes(b'foreign-loader')
|
||||
if 'grub-install' in argv:
|
||||
(self.root/'boot/efi/EFI/BOOT/BOOTX64.EFI').write_bytes(b'changed-loader')
|
||||
return result
|
||||
runner.run=run
|
||||
with self.assertRaisesRegex(i.InstallError, 'Foreign ESP files changed'):
|
||||
self.install(runner)
|
||||
|
||||
def test_payload_capacity_failure_occurs_before_root_format(self):
|
||||
runner=FakeRunner(self.root); original_run=runner.run
|
||||
def run(argv, **kw):
|
||||
if '-lln' in argv:
|
||||
return '-rw-r--r-- 0/0 10737418240 2026-10-13 12:00 squashfs-root/large\n'
|
||||
return original_run(argv, **kw)
|
||||
runner.run=run
|
||||
with self.assertRaisesRegex(i.InstallError, 'capacity'):
|
||||
self.install(runner)
|
||||
self.assertFalse(any(c[0].startswith('mkfs.') for c in runner.calls))
|
||||
|
||||
def test_btrfs_extraction_failure_cleans_home_snapshots_and_root(self):
|
||||
self.request.root_fs='btrfs'; runner=FakeRunner(self.root,'unsquashfs-extract')
|
||||
with self.assertRaises(i.InstallError): self.install(runner)
|
||||
self.assertEqual(runner.calls[-3:], [['umount',str(self.root/'.snapshots')],
|
||||
['umount',str(self.root/'home')], ['umount',str(self.root)]])
|
||||
|
||||
def test_registration_creates_only_new_entry(self):
|
||||
self.request.register_boot_entry=True
|
||||
runner=FakeRunner(self.root); self.install(runner)
|
||||
commands=[c for c in runner.calls if c[0]=='efibootmgr']
|
||||
self.assertEqual(len(commands),1)
|
||||
self.assertIn('--create-only', commands[0]); self.assertNotIn('--bootorder',commands[0])
|
||||
self.assertIn('/dev/vda',commands[0]); self.assertIn('1',commands[0])
|
||||
|
||||
|
||||
|
||||
class PayloadCapacityTests(unittest.TestCase):
|
||||
def test_capacity_reserves_blocks_for_many_small_inodes(self):
|
||||
listing='-rw-r--r-- 0/0 1 2026-10-13 12:00 squashfs-root/tiny\n' * 500000
|
||||
self.assertGreater(i.storage.payload_capacity(listing), 4*1024**3)
|
||||
|
||||
def test_payload_special_inodes_do_not_need_device_capacity(self):
|
||||
listing=('drwxr-xr-x 0/0 39 2026-10-13 12:00 squashfs-root\n'
|
||||
'crw-rw-rw- 0/0 1, 3 2026-10-13 12:00 squashfs-root/dev/null\n'
|
||||
'brw-rw---- 0/0 8, 0 2026-10-13 12:00 squashfs-root/dev/disk\n')
|
||||
self.assertEqual(i.storage.payload_capacity(listing),4*1024**3)
|
||||
|
||||
def test_unrecognized_payload_listing_fails_closed(self):
|
||||
for listing in ('', '-rw-r--r-- root/root unknown file', 'device entry cannot be measured'):
|
||||
with self.subTest(listing=listing), self.assertRaises(i.InstallError):
|
||||
i.storage.payload_capacity(listing)
|
||||
|
||||
|
||||
class GuardTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.assertIsNotNone(i, 'installer module must implement the guarded installer')
|
||||
@@ -177,10 +529,24 @@ class PipelineTests(unittest.TestCase):
|
||||
self.assertIn('hostonly_cmdline="no"', (self.root / 'etc/dracut.conf.d/90-hydra-installed.conf').read_text())
|
||||
self.assertIn('omit_dracutmodules+=" dmsquash-live "', (self.root / 'etc/dracut.conf.d/90-hydra-installed.conf').read_text())
|
||||
self.assertNotIn('root=live:', (self.root / 'etc/default/grub').read_text())
|
||||
self.assertIn('GRUB_DISTRIBUTOR="Hydra OS"', (self.root / 'etc/default/grub').read_text())
|
||||
dracut = next(c for c in runner.calls if 'dracut' in c)
|
||||
self.assertIn('6.12.1', dracut)
|
||||
self.assertEqual(runner.calls[-1], ['umount', str(self.root)])
|
||||
|
||||
def test_whole_disk_execution_mounts_created_filesystem_types_explicitly(self):
|
||||
runner=FakeRunner(self.root); self.install(runner)
|
||||
self.assertIn(['mount','-t','ext4','/dev/vda2',str(self.root)],runner.calls)
|
||||
self.assertIn(['mount','-t','vfat','/dev/vda1',str(self.root/'boot/efi')],runner.calls)
|
||||
|
||||
def test_whole_disk_installs_owned_loader_and_fallback(self):
|
||||
runner=FakeRunner(self.root); self.install(runner)
|
||||
boot_commands=[c for c in runner.calls if 'grub-install' in c]
|
||||
self.assertEqual(len(boot_commands),2)
|
||||
self.assertNotIn('--removable',boot_commands[0])
|
||||
self.assertIn('--removable',boot_commands[1])
|
||||
self.assertEqual(json.loads((self.root/'etc/hydra/storage.json').read_text())['owned_efi_path'], 'EFI/HydraOS')
|
||||
|
||||
def test_partition_format_and_extract_failures_abort_and_cleanup_only_mounted(self):
|
||||
for failing in ('parted', 'mkfs.vfat', 'mkfs.ext4', 'unsquashfs-extract'):
|
||||
with self.subTest(failing=failing):
|
||||
@@ -451,6 +817,7 @@ class RelocatedLiveLoopTests(unittest.TestCase):
|
||||
self.assertEqual(syscall.call_args.args, (91, 0x4C05, bytes(232)))
|
||||
self.assertFalse(any('/LiveOS/squashfs.img' in args for args in calls))
|
||||
probe.snapshot = lambda payload: topo
|
||||
probe.verify_identity = lambda path, node: None
|
||||
self.assertEqual(i.validate_request(i.Request('/dev/vda', 'hydratest'), probe), '/dev/vda')
|
||||
probe.physical_disk = lambda path: path == '/dev/sr0'
|
||||
with self.assertRaises(i.InstallError):
|
||||
@@ -521,6 +888,7 @@ class NestedLoopAncestryTests(unittest.TestCase):
|
||||
side_effect=lambda path, expected: identities[path]):
|
||||
topo = probe.snapshot(i.DEFAULT_PAYLOAD)
|
||||
probe.snapshot = lambda payload: topo
|
||||
probe.verify_identity = lambda path, node: None
|
||||
return probe, topo
|
||||
|
||||
def test_reverse_order_nested_loop_refuses_physical_backing(self):
|
||||
|
||||
@@ -63,6 +63,30 @@ class SmokeTests(unittest.TestCase):
|
||||
self.assertNotIn('media=cdrom', ' '.join(argv))
|
||||
self.assertNotIn('-cdrom', argv)
|
||||
|
||||
def test_persistent_optical_drive_keeps_topology_with_no_installed_medium(self):
|
||||
disk = self.root / 'disk.qcow2'
|
||||
self.mod.reserve_disk(disk)
|
||||
iso = self.root / 'trusted.iso'
|
||||
iso.write_bytes(b'ISO')
|
||||
optical = []
|
||||
for medium in (iso, None):
|
||||
argv = self.mod.qemu_argv('qemu', self.root, disk, self.code,
|
||||
self.variables, medium, 2048, keep_cdrom_drive=True)
|
||||
drives = [argv[i + 1] for i, value in enumerate(argv[:-1]) if value == '-drive']
|
||||
cdrom = [dict(field.split('=', 1) for field in drive.split(','))
|
||||
for drive in drives if 'media=cdrom' in drive]
|
||||
self.assertEqual(len(cdrom), 1)
|
||||
self.assertEqual(cdrom[0]['if'], 'ide')
|
||||
self.assertEqual(cdrom[0]['index'], '0')
|
||||
self.assertEqual(cdrom[0]['id'], 'acceptance-cdrom')
|
||||
self.assertEqual(cdrom[0]['readonly'], 'on')
|
||||
optical.append(cdrom[0])
|
||||
self.assertEqual(optical[0].pop('file'), str(iso))
|
||||
self.assertEqual(optical[0].pop('format'), 'raw')
|
||||
self.assertNotIn('file', optical[1], 'Installed boot must have no ISO medium')
|
||||
self.assertNotIn('format', optical[1], 'An empty drive must not request a raw block backend')
|
||||
self.assertEqual(optical[0], optical[1])
|
||||
|
||||
def test_hardlinked_disk_is_refused(self):
|
||||
disk = self.root / 'disk.qcow2'
|
||||
self.mod.reserve_disk(disk)
|
||||
@@ -632,3 +656,73 @@ else: sys.exit(1)
|
||||
def test_guest_tools_accept_gentoo_remote_viewer_without_virt_viewer(self):
|
||||
self.assertEqual(self.probe_fixture('clean'), 0)
|
||||
self.assertEqual(self.probe_fixture('clean', live=True), 0)
|
||||
|
||||
|
||||
def test_terminal_ux_fixture_has_complete_iso_volume_and_nonboot_directory(self):
|
||||
self.assertTrue(hasattr(self.mod, 'terminal_ux_iso'), 'Installed UX ISO fixture is missing')
|
||||
from hydra.media import validate_iso, MediaError
|
||||
iso = self.root / 'ux.iso'
|
||||
data = self.mod.terminal_ux_iso()
|
||||
iso.write_bytes(data)
|
||||
self.assertEqual(validate_iso(iso), iso)
|
||||
self.assertEqual(len(data), 21 * 2048)
|
||||
self.assertEqual(data[16 * 2048:16 * 2048 + 7], b'\x01CD001\x01')
|
||||
self.assertEqual(data[17 * 2048:17 * 2048 + 7], b'\xffCD001\x01')
|
||||
self.assertEqual(data[20 * 2048 + 25], 2, 'Root directory must be a directory record')
|
||||
self.assertNotIn(b'EL TORITO SPECIFICATION', data, 'Fixture must not imply an OS boot')
|
||||
iso.write_bytes(data[:-2048])
|
||||
with self.assertRaises(MediaError):
|
||||
validate_iso(iso)
|
||||
|
||||
def test_terminal_ux_branding_verifies_packaged_art_with_colors_and_side_data(self):
|
||||
logo = '$1 @@@ @@@ @@@\n %%%%%\n\n$2 H Y D R A O S\n'
|
||||
rendered = (' @@@ @@@ @@@ hydratest@hydra-os\n'
|
||||
' %%%%% OS: Hydra OS x86_64\n'
|
||||
' Kernel: Linux\n'
|
||||
' H Y D R A O S Uptime: 1 minute\n')
|
||||
actual = self.mod.verify_terminal_ux_branding(
|
||||
'\x1b[36m' + rendered + '\x1b[0m', '', logo)
|
||||
self.assertEqual(actual, rendered)
|
||||
|
||||
def test_terminal_ux_branding_rejects_incomplete_or_wrong_art_and_render_errors(self):
|
||||
logo = '$1 @@@\n %%%\n %%%\n$2 H Y D R A O S\n'
|
||||
rendered = ' @@@\n %%%\n %%%\n H Y D R A O S\nOS: Hydra OS x86_64\n'
|
||||
for output, error in ((rendered.replace('OS: Hydra OS', 'OS: Gentoo'), ''),
|
||||
(rendered.replace(' @@@\n', ''), ''),
|
||||
(rendered.replace(' @@@', ' @@@@'), ''),
|
||||
(rendered.replace(' @@@', ' @@@ xx'), ''),
|
||||
(rendered.replace(' %%%\n', '', 1), ''),
|
||||
(rendered.replace(' @@@\n %%%', ' %%%\n @@@'), ''),
|
||||
(rendered.replace('H Y D R A O S', 'OTHER OS'), ''),
|
||||
('H Y D R A O S\nOS: Hydra OS x86_64\n', ''),
|
||||
(rendered, 'Unknown JSON key: invalid'),
|
||||
(rendered + 'Memory: Error loading module\n', '')):
|
||||
with self.subTest(output=output, error=error):
|
||||
with self.assertRaises(self.mod.SmokeError):
|
||||
self.mod.verify_terminal_ux_branding(output, error, logo)
|
||||
|
||||
def test_terminal_ux_branding_requires_nonblank_packaged_art_and_caption(self):
|
||||
rendered = ' @@@\n H Y D R A O S\nOS: Hydra OS x86_64\n'
|
||||
for logo in ('', ' \n', '$2 H Y D R A O S\n', '$1 @@@\n'):
|
||||
with self.subTest(logo=logo):
|
||||
with self.assertRaises(self.mod.SmokeError):
|
||||
self.mod.verify_terminal_ux_branding(rendered, '', logo)
|
||||
|
||||
def test_terminal_ux_snapshot_checks_live_qmp_identity_and_same_process(self):
|
||||
self.assertTrue(hasattr(self.mod, 'terminal_ux_snapshot'), 'Installed UX identity verification is missing')
|
||||
context, process, run = self.lifecycle_fixture()
|
||||
before = self.mod.terminal_ux_snapshot(context)
|
||||
self.assertEqual(before['pid'], process.pid)
|
||||
self.assertEqual(before['uuid'], context['uuid'])
|
||||
self.assertEqual(self.mod.terminal_ux_snapshot(context), before)
|
||||
context['qmp_name'] = 'foreign-process'
|
||||
with self.assertRaises(self.mod.SmokeError):
|
||||
self.mod.terminal_ux_snapshot(context)
|
||||
context.pop('qmp_name')
|
||||
process.terminate(); process.wait(timeout=3)
|
||||
with self.assertRaises(self.mod.SmokeError):
|
||||
self.mod.terminal_ux_snapshot(context)
|
||||
|
||||
def test_installed_terminal_ux_script_compiles_as_standalone_guest_probe(self):
|
||||
self.assertTrue(hasattr(self.mod, 'terminal_ux_guest_script'), 'Installed terminal UX probe is missing')
|
||||
compile(self.mod.terminal_ux_guest_script(), '<guest-terminal-ux>', 'exec')
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
class BootConsole:
|
||||
"""Captured efibootmgr format with observable guest NVRAM mutations."""
|
||||
def __init__(self, entries=None, changed_order=False, drop_next=False, changed_create_order=False):
|
||||
self.order = '0000,0001,0002,0003'
|
||||
self.next = None
|
||||
self.changed_order, self.drop_next = changed_order, drop_next
|
||||
self.changed_create_order = changed_create_order
|
||||
self.entries = entries if entries is not None else (
|
||||
'Boot0000* UiApp\tFvVol(example)/FvFile(example)\n'
|
||||
'Boot0001* UEFI QEMU DVD-ROM QM00001 \tPciRoot(0x0)/Pci(0x1f,0x2)/Sata(0,65535,0){auto_created_boot_option}\n'
|
||||
'Boot0002* UEFI Misc Device\tPciRoot(0x0)/Pci(0x3,0x0)\n'
|
||||
'Boot0003* EFI Internal Shell\tFvVol(example)/FvFile(example)\n'
|
||||
'Boot0004* HydraOS\tHD(1,GPT,5d24ab30-9c80-4f5d-8594-f9f08fdf31fb,0x800,0x100000)/\\EFI\\HydraOS\\grubx64.efi\n')
|
||||
|
||||
def command(self, command, timeout):
|
||||
if command == 'sudo -n blkid -s PARTUUID -o value /dev/vda1':
|
||||
return b'5d24ab30-9c80-4f5d-8594-f9f08fdf31fb\n'
|
||||
if command == "sudo -n efibootmgr --create-only --disk /dev/vda --part 1 --label HydraOS --loader '\\EFI\\HydraOS\\grubx64.efi'":
|
||||
self.entries += ('Boot0004* HydraOS\tHD(1,GPT,5d24ab30-9c80-4f5d-8594-f9f08fdf31fb,0x800,0x100000)/\\EFI\\HydraOS\\grubx64.efi\n')
|
||||
if self.changed_create_order:
|
||||
self.order = '0004,0000,0001,0002,0003'
|
||||
elif command.startswith('sudo -n efibootmgr --bootnext '):
|
||||
if not self.drop_next:
|
||||
self.next = command.split()[-1]
|
||||
if self.changed_order:
|
||||
self.order = '0001,0000,0002,0003'
|
||||
elif command != 'sudo -n efibootmgr':
|
||||
raise AssertionError('Unexpected firmware mutation: ' + command)
|
||||
return ('BootCurrent: 0004\nTimeout: 1 seconds\nBootOrder: ' + self.order + '\n' +
|
||||
('BootNext: ' + self.next + '\n' if self.next else '') + self.entries).encode()
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / 'dev/laptop-smoke.py'
|
||||
EFI_GUID = '8be4df61-93ca-11d2-aa0d-00e098032b8c'
|
||||
# Captured efivarfs bytes from the actual OVMF SATA0 DVD boot option.
|
||||
DVD_VARIABLE = bytes.fromhex(
|
||||
'0700000001000000200055004500460049002000510045004d005500200044005600'
|
||||
'44002d0052004f004d00200051004d00300030003000300031002000000002010c00'
|
||||
'd041030a0000000001010600021f03120a000000ffff00007fff04004eac0881119f'
|
||||
'594d850ee21a522c59b2')
|
||||
|
||||
|
||||
class LaptopSmokeTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
spec = importlib.util.spec_from_file_location('laptop_smoke', SCRIPT)
|
||||
cls.mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(cls.mod)
|
||||
|
||||
def test_cli_cannot_accept_existing_disks_or_private_signing_keys(self):
|
||||
parser = self.mod.argument_parser()
|
||||
flags = {flag for action in parser._actions for flag in action.option_strings}
|
||||
self.assertFalse({'--disk', '--target', '--private-key', '--existing-disk'} & flags)
|
||||
self.assertTrue({'--iso', '--ovmf-code', '--ovmf-vars'} <= flags)
|
||||
|
||||
def test_invariant_check_rejects_partition_foreign_data_efi_and_bootorder_changes(self):
|
||||
original = {'gpt': {'partitions': [{'start': 2048, 'size': 1024}]},
|
||||
'foreign_sha256': 'data', 'foreign_efi': {'EFI/BOOT/BOOTX64.EFI': 'fallback'},
|
||||
'boot_order': '0001,0002'}
|
||||
self.mod.verify_preserved(original, json.loads(json.dumps(original)))
|
||||
for key in original:
|
||||
changed = dict(original, **{key: 'changed'})
|
||||
with self.subTest(key=key), self.assertRaises(self.mod.SmokeError):
|
||||
self.mod.verify_preserved(original, changed)
|
||||
|
||||
def test_cross_firmware_comparison_excludes_only_bootorder(self):
|
||||
original = {'gpt': 'partition table', 'foreign_sha256': 'data',
|
||||
'foreign_efi': {'fallback': 'bytes'}, 'boot_order': '0000,0001,0002'}
|
||||
after = dict(original, boot_order='0000,0002')
|
||||
self.mod.verify_preserved(original, after, firmware_reboot=True)
|
||||
for name in ('gpt', 'foreign_sha256', 'foreign_efi'):
|
||||
with self.subTest(name=name), self.assertRaises(self.mod.SmokeError):
|
||||
self.mod.verify_preserved(original, dict(after, **{name: 'changed'}), firmware_reboot=True)
|
||||
|
||||
def dvd_namespace(self):
|
||||
namespace = {}
|
||||
exec(self.mod.GUEST_COMMON, namespace)
|
||||
return namespace
|
||||
|
||||
def test_capture_requires_actual_current_unique_validated_dvd_option(self):
|
||||
namespace = self.dvd_namespace()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
variables = Path(directory)
|
||||
(variables / ('BootCurrent-' + EFI_GUID)).write_bytes(bytes.fromhex('060000000100'))
|
||||
(variables / ('Boot0001-' + EFI_GUID)).write_bytes(DVD_VARIABLE)
|
||||
saved = namespace['capture_dvd_variable'](variables)
|
||||
self.assertEqual(saved['raw_hex'], DVD_VARIABLE.hex())
|
||||
self.assertEqual(saved['entry'], '0001')
|
||||
(variables / ('BootCurrent-' + EFI_GUID)).write_bytes(bytes.fromhex('060000000200'))
|
||||
with self.assertRaises(RuntimeError):
|
||||
namespace['capture_dvd_variable'](variables)
|
||||
(variables / ('BootCurrent-' + EFI_GUID)).write_bytes(bytes.fromhex('060000000100'))
|
||||
(variables / ('Boot0002-' + EFI_GUID)).write_bytes(DVD_VARIABLE)
|
||||
with self.assertRaises(RuntimeError):
|
||||
namespace['capture_dvd_variable'](variables)
|
||||
|
||||
def test_restore_saved_dvd_uses_free_variable_without_overwriting_or_reordering(self):
|
||||
namespace = self.dvd_namespace()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
variables = Path(directory)
|
||||
original = {'Boot0000-' + EFI_GUID: b'unrelated boot entry',
|
||||
'Boot0001-' + EFI_GUID: b'HydraOS reused original DVD number',
|
||||
'BootOrder-' + EFI_GUID: bytes.fromhex('0700000000000300'),
|
||||
'unrelated-vendor-variable': b'foreign variable'}
|
||||
for name, data in original.items():
|
||||
(variables / name).write_bytes(data)
|
||||
restored = namespace['restore_dvd_variable'](variables, DVD_VARIABLE)
|
||||
self.assertEqual(restored['entry'], '0002')
|
||||
self.assertTrue(restored['created'])
|
||||
self.assertEqual((variables / ('Boot0002-' + EFI_GUID)).read_bytes(), DVD_VARIABLE)
|
||||
for name, data in original.items():
|
||||
self.assertEqual((variables / name).read_bytes(), data)
|
||||
restored_again = namespace['restore_dvd_variable'](variables, DVD_VARIABLE)
|
||||
self.assertFalse(restored_again['created'])
|
||||
self.assertEqual(restored_again['entry'], '0002')
|
||||
|
||||
def test_restore_rejects_wrong_attributes_label_device_path_or_extra_payload(self):
|
||||
namespace = self.dvd_namespace()
|
||||
changes = [bytes.fromhex('06000000') + DVD_VARIABLE[4:],
|
||||
DVD_VARIABLE[:4] + bytes.fromhex('00000000') + DVD_VARIABLE[8:],
|
||||
DVD_VARIABLE.replace('QM00001'.encode('utf-16-le'), 'QM00003'.encode('utf-16-le')),
|
||||
DVD_VARIABLE.replace(bytes.fromhex('03120a000000ffff0000'), bytes.fromhex('03120a000200ffff0000')),
|
||||
DVD_VARIABLE + b'foreign optional loader data']
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
variables = Path(directory)
|
||||
(variables / ('BootOrder-' + EFI_GUID)).write_bytes(bytes.fromhex('070000000000'))
|
||||
for data in changes:
|
||||
with self.subTest(data=data.hex()), self.assertRaises(RuntimeError):
|
||||
namespace['restore_dvd_variable'](variables, data)
|
||||
self.assertEqual(len(list(variables.iterdir())), 1)
|
||||
|
||||
def test_restore_refuses_ambiguous_or_altered_existing_dvd_option(self):
|
||||
namespace = self.dvd_namespace()
|
||||
for altered in (False, True):
|
||||
with self.subTest(altered=altered), tempfile.TemporaryDirectory() as directory:
|
||||
variables = Path(directory)
|
||||
(variables / ('BootOrder-' + EFI_GUID)).write_bytes(bytes.fromhex('070000000000'))
|
||||
(variables / ('Boot0001-' + EFI_GUID)).write_bytes(DVD_VARIABLE)
|
||||
(variables / ('Boot0002-' + EFI_GUID)).write_bytes(DVD_VARIABLE if not altered else
|
||||
DVD_VARIABLE.replace(bytes.fromhex('03120a000000ffff0000'), bytes.fromhex('03120a000200ffff0000')))
|
||||
with self.assertRaises(RuntimeError):
|
||||
namespace['restore_dvd_variable'](variables, DVD_VARIABLE)
|
||||
self.assertEqual(len(list(variables.iterdir())), 3)
|
||||
|
||||
def test_restore_never_overwrites_a_concurrently_created_boot_variable(self):
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
namespace = self.dvd_namespace()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
variables = Path(directory)
|
||||
order = variables / ('BootOrder-' + EFI_GUID)
|
||||
order.write_bytes(bytes.fromhex('070000000100'))
|
||||
original_order = order.read_bytes()
|
||||
destination = variables / ('Boot0000-' + EFI_GUID)
|
||||
real_open = os.open
|
||||
def race(path, flags, mode):
|
||||
self.assertEqual(path, destination)
|
||||
destination.write_bytes(b'concurrent foreign boot entry')
|
||||
return real_open(path, flags, mode)
|
||||
with patch.object(namespace['os'], 'open', side_effect=race), self.assertRaises(FileExistsError):
|
||||
namespace['restore_dvd_variable'](variables, DVD_VARIABLE)
|
||||
self.assertEqual(destination.read_bytes(), b'concurrent foreign boot entry')
|
||||
self.assertEqual(order.read_bytes(), original_order)
|
||||
|
||||
def test_http_serves_only_explicit_bytes_with_no_filesystem_or_traversal(self):
|
||||
server = self.mod.artifact_server({'/public.pem': b'TEST public', '/one/manifest.json': b'{}'})
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
self.addCleanup(server.server_close)
|
||||
self.addCleanup(server.shutdown)
|
||||
url = 'http://127.0.0.1:' + str(server.server_port)
|
||||
self.assertEqual(urllib.request.urlopen(url + '/public.pem').read(), b'TEST public')
|
||||
for path in ('/', '/one/', '/public.pem?secret=1', '/../private.pem', '/%2e%2e/private.pem'):
|
||||
with self.subTest(path=path), self.assertRaises(urllib.error.HTTPError):
|
||||
urllib.request.urlopen(url + path)
|
||||
|
||||
def test_test_artifacts_have_real_signatures_and_no_private_key_in_share(self):
|
||||
from hydra.update_engine import validate_manifest, validate_archive
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
files = self.mod.test_artifacts(Path(directory), b'# runtime fixture\n', 'a' * 40)
|
||||
public = Path(directory) / 'public.pem'
|
||||
for name in ('one', 'two'):
|
||||
manifest = validate_manifest(files['/' + name + '/manifest.json'],
|
||||
files['/' + name + '/manifest.sig'], public)
|
||||
validate_archive(files['/' + name + '/runtime.zip'], manifest)
|
||||
self.assertEqual(set(files), {'/public.pem'} | {
|
||||
'/' + name + '/' + file for name in ('one', 'two')
|
||||
for file in ('manifest.json', 'manifest.sig', 'runtime.zip')})
|
||||
self.assertFalse((Path(directory) / 'TEST-private.pem').exists())
|
||||
|
||||
def test_every_guest_probe_compiles(self):
|
||||
for mode in self.mod.GUEST_BODIES:
|
||||
with self.subTest(mode=mode):
|
||||
compile(self.mod.guest_script(mode, {'sentinel': 'fixture', 'port': 1234,
|
||||
'checkpoint': '00000000-0000-0000-0000-000000000001',
|
||||
'dvd_variable': DVD_VARIABLE.hex(),
|
||||
'root_uuid': '00000000-0000-0000-0000-000000000002'}), mode, 'exec')
|
||||
|
||||
def test_installer_only_formats_selected_root_and_registration_is_opt_in(self):
|
||||
command = self.mod.partition_installer_command('safe123')
|
||||
self.assertIn('--root-partition /dev/vda3', command)
|
||||
self.assertIn('--esp-partition /dev/vda1', command)
|
||||
self.assertIn('--confirm-format /dev/vda3', command)
|
||||
self.assertIn('--root-fs btrfs', command)
|
||||
self.assertIn('--register-boot-entry', command)
|
||||
self.assertNotIn('--confirm-erase', command)
|
||||
self.assertNotIn('--disk', command)
|
||||
with self.assertRaises(self.mod.SmokeError):
|
||||
self.mod.partition_installer_command('bad;token')
|
||||
|
||||
def test_live_boot_is_selected_once_without_changing_bootorder(self):
|
||||
console = BootConsole()
|
||||
self.mod.select_live(console)
|
||||
self.assertEqual(console.next, '0001')
|
||||
self.assertEqual(console.order, '0000,0001,0002,0003')
|
||||
|
||||
def test_live_boot_refuses_missing_ambiguous_or_inactive_dvd_entry(self):
|
||||
for entries in ('Boot0001* Unrelated DVD\tPciRoot(0x0)\n',
|
||||
'Boot0001* UEFI QEMU DVD-ROM QM00001 Other\tPciRoot(0x0)\n',
|
||||
'Boot0001 UEFI QEMU DVD-ROM QM00001 \tPciRoot(0x0)\n',
|
||||
'Boot0001* UEFI QEMU DVD-ROM QM00001 \tPciRoot(0x0)\n'
|
||||
'Boot0002* UEFI QEMU DVD-ROM QM00001 \tPciRoot(0x0)\n'):
|
||||
console = BootConsole(entries=entries)
|
||||
with self.subTest(entries=entries), self.assertRaises(self.mod.SmokeError):
|
||||
self.mod.select_live(console)
|
||||
self.assertIsNone(console.next)
|
||||
|
||||
def test_boot_selection_verifies_effective_bootnext_and_unchanged_order(self):
|
||||
for selector in ('select_live', 'select_installed'):
|
||||
for fault in ('changed_order', 'drop_next'):
|
||||
with self.subTest(selector=selector, fault=fault), self.assertRaises(self.mod.SmokeError):
|
||||
getattr(self.mod, selector)(BootConsole(**{fault: True}))
|
||||
|
||||
def test_installed_boot_recreates_pruned_owned_entry_without_changing_order(self):
|
||||
console = BootConsole()
|
||||
console.entries = console.entries[:console.entries.index('Boot0004')]
|
||||
with self.assertRaises(self.mod.SmokeError):
|
||||
self.mod.select_installed(console)
|
||||
self.assertIsNone(console.next, 'First selection must prove installer registration')
|
||||
self.mod.select_installed(console, recreate=True)
|
||||
self.assertEqual(console.next, '0004')
|
||||
self.assertEqual(console.order, '0000,0001,0002,0003')
|
||||
self.assertIn('\\EFI\\HydraOS\\grubx64.efi', console.entries)
|
||||
|
||||
def test_recreated_entry_cannot_change_bootorder(self):
|
||||
console = BootConsole(changed_create_order=True)
|
||||
console.entries = console.entries[:console.entries.index('Boot0004')]
|
||||
with self.assertRaises(self.mod.SmokeError):
|
||||
self.mod.select_installed(console, recreate=True)
|
||||
self.assertIsNone(console.next)
|
||||
|
||||
def test_boot_selection_rejects_correct_label_with_foreign_device_or_loader(self):
|
||||
for selector, before, after in (
|
||||
('select_live', 'Sata(0,65535,0)', 'Sata(2,65535,0)'),
|
||||
('select_installed', '5d24ab30-9c80-4f5d-8594-f9f08fdf31fb', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'),
|
||||
('select_installed', '\\EFI\\HydraOS\\grubx64.efi', '\\EFI\\ForeignOS\\grubx64.efi')):
|
||||
console = BootConsole()
|
||||
console.entries = console.entries.replace(before, after)
|
||||
with self.subTest(selector=selector, wrong=after), self.assertRaises(self.mod.SmokeError):
|
||||
getattr(self.mod, selector)(console)
|
||||
self.assertIsNone(console.next)
|
||||
|
||||
def test_base_state_compares_kernel_bytes_without_including_mounted_esp(self):
|
||||
# Offline /boot/efi is an empty mountpoint; on disk-only boot it contains
|
||||
# the mounted ESP. That mount must not produce a false base mismatch.
|
||||
namespace = {}
|
||||
exec(self.mod.GUEST_COMMON, namespace)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory) / 'root'
|
||||
esp = Path(directory) / 'esp'
|
||||
for path, data in [('boot/vmlinuz-fixture', 'kernel'),
|
||||
('boot/efi/EFI/BOOT/BOOTX64.EFI', 'mounted foreign fallback'),
|
||||
('var/db/pkg/app-misc/hydra-acceptance-1.0/CONTENTS', 'package'),
|
||||
('var/lib/portage/world', 'app-misc/hydra-acceptance'),
|
||||
('usr/share/hydra/base-fixture.txt', '1.0')]:
|
||||
target = root / path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(data)
|
||||
target = esp / 'EFI/HydraOS/grubx64.efi'
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text('owned loader')
|
||||
result = namespace['base_state'](root, esp)
|
||||
self.assertEqual(set(result['boot']), {'vmlinuz-fixture'})
|
||||
(root / 'boot/vmlinuz-fixture').write_text('damaged')
|
||||
self.assertNotEqual(namespace['base_state'](root, esp)['boot'], result['boot'])
|
||||
|
||||
def test_real_portage_install_is_executed_in_guest_network_namespace(self):
|
||||
import ast
|
||||
function = next(node for node in ast.parse(self.mod.GUEST_COMMON).body
|
||||
if isinstance(node, ast.FunctionDef) and node.name == 'fixture')
|
||||
commands = [node for node in ast.walk(function) if isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name) and node.func.id == 'run']
|
||||
emerge = next(node for node in commands if any(isinstance(arg, ast.Constant)
|
||||
and arg.value == 'emerge' for arg in node.args))
|
||||
constants = [arg.value for arg in emerge.args if isinstance(arg, ast.Constant)]
|
||||
self.assertEqual(constants[:3], ['unshare', '--net', '--'])
|
||||
self.assertNotIn('--offline', constants, 'emerge has no --offline option')
|
||||
self.assertIn('--ignore-default-opts', constants)
|
||||
self.assertIn('--nodeps', constants)
|
||||
|
||||
def test_guest_mount_explicitly_selects_known_filesystem(self):
|
||||
namespace = {}
|
||||
exec(self.mod.GUEST_COMMON, namespace)
|
||||
calls = []
|
||||
namespace['run'] = lambda *args: calls.append(args)
|
||||
for device, filesystem, options in (('/dev/vda1', 'vfat', 'ro'),
|
||||
('/dev/vda2', 'ext4', 'ro'),
|
||||
('/dev/vda3', 'btrfs', 'ro,subvol=@root')):
|
||||
with self.subTest(device=device):
|
||||
with namespace['mounted'](device, filesystem, options) as directory:
|
||||
self.assertEqual(calls[-1], ('mount', '-t', filesystem, '-o', options, device, str(directory)))
|
||||
self.assertEqual(calls[-1], ('umount', str(directory)))
|
||||
|
||||
def test_installed_esp_is_read_from_verified_existing_mount_without_remount(self):
|
||||
namespace = self.dvd_namespace()
|
||||
calls = []
|
||||
def query(*args):
|
||||
calls.append(args)
|
||||
if args[0] == 'findmnt':
|
||||
return json.dumps({'filesystems': [{'source': '/dev/vda1', 'target': '/boot/efi',
|
||||
'fstype': 'vfat', 'uuid': '1234-ABCD', 'maj:min': '253:1'}]})
|
||||
if args[0] == 'lsblk':
|
||||
return json.dumps({'blockdevices': [{'name': 'vda1', 'fstype': 'vfat',
|
||||
'uuid': '1234-ABCD', 'maj:min': '253:1'}]})
|
||||
raise AssertionError('ESP verification attempted a mutation: ' + repr(args))
|
||||
namespace['run'] = query
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
marker = Path(directory) / 'marker.json'
|
||||
marker.write_text(json.dumps({'esp_uuid': '1234-ABCD'}))
|
||||
def guest_path(path):
|
||||
if path == '/etc/hydra/storage.json': return marker
|
||||
if path == '/etc/hydra/live-mode/active': return Path(directory) / 'absent-live-mode'
|
||||
return Path(path)
|
||||
namespace['Path'] = guest_path
|
||||
with namespace['readonly_esp']() as esp:
|
||||
self.assertEqual(esp, Path('/boot/efi'))
|
||||
self.assertEqual(calls, [
|
||||
('findmnt', '--json', '--mountpoint', '/boot/efi', '--output', 'SOURCE,TARGET,FSTYPE,UUID,MAJ:MIN'),
|
||||
('lsblk', '--json', '--nodeps', '--output', 'NAME,MAJ:MIN,FSTYPE,UUID', '/dev/vda1')])
|
||||
|
||||
def test_installed_esp_rejects_wrong_device_uuid_mountpoint_or_filesystem(self):
|
||||
namespace = self.dvd_namespace()
|
||||
mount = {'source': '/dev/vda1', 'target': '/boot/efi', 'fstype': 'vfat',
|
||||
'uuid': '1234-ABCD', 'maj:min': '253:1'}
|
||||
device = {'name': 'vda1', 'fstype': 'vfat', 'uuid': '1234-ABCD', 'maj:min': '253:1'}
|
||||
for key, wrong in [('source', '/dev/vdb1'), ('target', '/boot'), ('fstype', 'ext4'),
|
||||
('uuid', 'FFFF-ABCD'), ('maj:min', '253:2')]:
|
||||
altered = dict(mount, **{key: wrong})
|
||||
namespace['run'] = lambda *args: json.dumps(
|
||||
{'filesystems': [altered]} if args[0] == 'findmnt' else {'blockdevices': [device]})
|
||||
with self.subTest(key=key), self.assertRaises(RuntimeError):
|
||||
namespace['installed_esp']({'esp_uuid': '1234-ABCD'})
|
||||
|
||||
def test_every_guest_mount_call_names_the_partition_filesystem(self):
|
||||
import ast
|
||||
expected = {'/dev/vda1': 'vfat', '/dev/vda2': 'ext4', '/dev/vda3': 'btrfs'}
|
||||
sources = [self.mod.GUEST_COMMON, *self.mod.GUEST_BODIES.values()]
|
||||
for source in sources:
|
||||
for node in ast.walk(ast.parse(source)):
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == 'mounted':
|
||||
with self.subTest(call=ast.unparse(node)):
|
||||
self.assertGreaterEqual(len(node.args), 2)
|
||||
self.assertEqual(ast.literal_eval(node.args[1]), expected[ast.literal_eval(node.args[0])])
|
||||
@@ -0,0 +1,176 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from hydra import launch
|
||||
from hydra.profiles import Profile
|
||||
from hydra.runtime import Runtime, RuntimeError as VMError
|
||||
from test_media import iso_bytes
|
||||
from test_runtime import FAKE_IMG, FAKE_QEMU
|
||||
|
||||
|
||||
class LaunchTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix='hl-')
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
self.runtime = Runtime(self.root / 'config', self.root / 'state', self.root / 'run', boot_timeout=1)
|
||||
self.iso = self.root / 'installer.iso'
|
||||
self.iso.write_bytes(iso_bytes())
|
||||
self.qemu = self.root / 'qemu'
|
||||
self.qemu.write_text(FAKE_QEMU)
|
||||
self.qemu.chmod(0o700)
|
||||
self.img = self.root / 'img'
|
||||
self.img.write_text(FAKE_IMG)
|
||||
self.img.chmod(0o700)
|
||||
self.viewer = self.root / 'viewer'
|
||||
self.viewer.write_text('#!/bin/sh\necho --hide-toolbar\n')
|
||||
self.viewer.chmod(0o700)
|
||||
env = patch.dict(os.environ, {'HYDRA_QEMU': str(self.qemu), 'HYDRA_QEMU_IMG': str(self.img), 'HYDRA_VIEWER': str(self.viewer)})
|
||||
env.start()
|
||||
self.addCleanup(env.stop)
|
||||
memory = patch('hydra.launch.available_memory_mib', return_value=16384)
|
||||
memory.start()
|
||||
self.addCleanup(memory.stop)
|
||||
self.addCleanup(self.cleanup_guests)
|
||||
|
||||
def cleanup_guests(self):
|
||||
for name in ('guest', 'other'):
|
||||
if self.runtime.status(name)['status'] == 'running':
|
||||
self.runtime.stop(name, force=True)
|
||||
|
||||
def test_run_creates_and_starts_once_then_open_reconnects_without_memory_or_iso(self):
|
||||
self.assertEqual(launch.run_source(self.runtime, str(self.iso), name='guest', memory=256, cpus=1, disk_size='8M', no_view=True), 0)
|
||||
profile = self.runtime.profile('guest')
|
||||
self.assertEqual((profile.memory, profile.cpus, profile.disk_size), (256, 1, '8M'))
|
||||
pid = self.runtime.paths('guest').pid.read_bytes()
|
||||
disk = self.runtime.paths('guest').disk.read_bytes()
|
||||
self.iso.unlink()
|
||||
with patch('hydra.launch.available_memory_mib', return_value=0):
|
||||
self.assertEqual(launch.open_vm(self.runtime, 'guest', no_view=True), 0)
|
||||
self.assertEqual(self.runtime.paths('guest').pid.read_bytes(), pid)
|
||||
self.assertEqual(self.runtime.paths('guest').disk.read_bytes(), disk)
|
||||
|
||||
def test_existing_profile_or_orphan_disk_refuses_run_without_changes(self):
|
||||
self.runtime.add(Profile('guest', memory=256))
|
||||
original = (self.runtime.config / 'guest.toml').read_bytes()
|
||||
with self.assertRaisesRegex(VMError, 'open guest'):
|
||||
launch.run_source(self.runtime, str(self.iso), name='guest', no_view=True)
|
||||
self.assertEqual((self.runtime.config / 'guest.toml').read_bytes(), original)
|
||||
paths = self.runtime.paths('other')
|
||||
paths.disk.parent.mkdir(parents=True)
|
||||
paths.disk.write_text('valuable old disk')
|
||||
with self.assertRaisesRegex(VMError, 'disk|state'):
|
||||
launch.run_source(self.runtime, str(self.iso), name='other', no_view=True)
|
||||
self.assertEqual(paths.disk.read_text(), 'valuable old disk')
|
||||
self.assertFalse((self.runtime.config / 'other.toml').exists())
|
||||
|
||||
def test_memory_reserve_refuses_before_profile_or_disk_then_allows_boundary(self):
|
||||
with patch('hydra.launch.available_memory_mib', return_value=1023):
|
||||
with self.assertRaisesRegex(VMError, '768'):
|
||||
launch.run_source(self.runtime, str(self.iso), name='guest', memory=256, no_view=True)
|
||||
self.assertFalse(self.runtime.config.exists())
|
||||
with patch('hydra.launch.available_memory_mib', return_value=1024):
|
||||
self.assertEqual(launch.run_source(self.runtime, str(self.iso), name='guest', memory=256, no_view=True), 0)
|
||||
|
||||
def test_open_stopped_preserves_disk_and_checks_memory(self):
|
||||
self.runtime.add(Profile('guest', memory=256))
|
||||
paths = self.runtime.paths('guest')
|
||||
paths.disk.parent.mkdir(parents=True)
|
||||
paths.disk.write_text('installed OS')
|
||||
with patch('hydra.launch.available_memory_mib', return_value=1000):
|
||||
with self.assertRaises(VMError):
|
||||
launch.open_vm(self.runtime, 'guest', no_view=True)
|
||||
self.assertEqual(self.runtime.status('guest')['status'], 'stopped')
|
||||
self.assertEqual(launch.open_vm(self.runtime, 'guest', no_view=True), 0)
|
||||
self.assertEqual(paths.disk.read_text(), 'installed OS')
|
||||
|
||||
def test_missing_display_or_viewer_refuses_before_launch_side_effects(self):
|
||||
with patch.dict(os.environ, {'DISPLAY': '', 'WAYLAND_DISPLAY': ''}):
|
||||
with self.assertRaisesRegex(ValueError, 'display'):
|
||||
launch.run_source(self.runtime, str(self.iso), name='guest', no_view=False)
|
||||
self.assertFalse(self.runtime.config.exists())
|
||||
self.runtime.add(Profile('guest', memory=256))
|
||||
with patch.dict(os.environ, {'DISPLAY': ':1', 'HYDRA_VIEWER': '/does/not/exist'}):
|
||||
with self.assertRaisesRegex(ValueError, 'Missing'):
|
||||
launch.open_vm(self.runtime, 'guest')
|
||||
self.assertFalse(self.runtime.paths('guest').disk.exists())
|
||||
self.assertEqual(self.runtime.status('guest')['status'], 'stopped')
|
||||
|
||||
def test_unknown_identity_never_restarts_or_controls_vm(self):
|
||||
launch.run_source(self.runtime, str(self.iso), name='guest', memory=256, no_view=True)
|
||||
paths = self.runtime.paths('guest')
|
||||
original = paths.metadata.read_text()
|
||||
pid = paths.pid.read_bytes()
|
||||
paths.metadata.write_text(json.dumps({'name': 'foreign', 'uuid': 'foreign'}))
|
||||
try:
|
||||
with self.assertRaisesRegex(VMError, 'unknown|identity'):
|
||||
launch.open_vm(self.runtime, 'guest', no_view=True)
|
||||
self.assertEqual(paths.pid.read_bytes(), pid)
|
||||
finally:
|
||||
paths.metadata.write_text(original)
|
||||
|
||||
def test_invalid_local_iso_never_creates_profile(self):
|
||||
self.iso.write_text('<html>not an ISO</html>')
|
||||
with self.assertRaises(ValueError):
|
||||
launch.run_source(self.runtime, str(self.iso), name='guest', no_view=True)
|
||||
self.assertFalse(self.runtime.config.exists())
|
||||
|
||||
def test_session_open_writes_selected_refresh_and_preserves_other_guest(self):
|
||||
self.runtime.add(Profile('guest', memory=256))
|
||||
self.runtime.add(Profile('other', memory=256))
|
||||
launch.open_vm(self.runtime, 'other', no_view=True)
|
||||
other_pid = self.runtime.paths('other').pid.read_bytes()
|
||||
directory = self.root / 'session'
|
||||
directory.mkdir()
|
||||
manifest = directory / 'session.json'
|
||||
manifest.write_text(json.dumps({'config': str(self.runtime.config), 'state': str(self.runtime.state),
|
||||
'runtime': str(self.runtime.runtime), 'terminal': '/bin/sh',
|
||||
'directory': str(directory), 'names': ['guest', 'other']}))
|
||||
with patch.dict(os.environ, {'DISPLAY': ':1', 'HYDRA_SESSION_MANIFEST': str(manifest)}):
|
||||
self.assertEqual(launch.open_vm(self.runtime, 'guest'), 0)
|
||||
self.assertEqual(json.loads((directory / 'refresh.request').read_text()), {'selected': 'guest'})
|
||||
self.assertEqual(self.runtime.status('guest')['status'], 'running')
|
||||
self.assertEqual(self.runtime.paths('other').pid.read_bytes(), other_pid)
|
||||
|
||||
def test_invalid_explicit_manifest_refuses_before_new_profile_and_environment_falls_back(self):
|
||||
manifest = self.root / 'bad.json'
|
||||
manifest.write_text('{}')
|
||||
with patch.dict(os.environ, {'DISPLAY': ':1'}):
|
||||
with self.assertRaisesRegex(ValueError, 'manifest'):
|
||||
launch.run_source(self.runtime, str(self.iso), name='guest', manifest=manifest)
|
||||
self.assertFalse(self.runtime.config.exists())
|
||||
with patch.dict(os.environ, {'HYDRA_SESSION_MANIFEST': str(manifest)}):
|
||||
self.assertIsNone(launch.session_manifest(self.runtime))
|
||||
|
||||
def test_remote_run_downloads_then_launches_with_distribution_defaults(self):
|
||||
import http.server
|
||||
import threading
|
||||
owner = self
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
body = iso_bytes()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
||||
self.addCleanup(server.server_close)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
self.addCleanup(server.shutdown)
|
||||
defaults = self.root / 'defaults.toml'
|
||||
defaults.write_text('network="bridge"\nbridge="testbr0"\nwidth=1600\n')
|
||||
with patch.dict(os.environ, {'XDG_CACHE_HOME': str(self.root / 'cache')}):
|
||||
result = launch.run_source(self.runtime, f'http://127.0.0.1:{server.server_port}/os.iso', name='guest',
|
||||
memory=256, no_view=True, defaults=defaults)
|
||||
self.assertEqual(result, 0)
|
||||
profile = self.runtime.profile('guest')
|
||||
self.assertEqual((profile.network, profile.bridge, profile.width), ('bridge', 'testbr0', 1600))
|
||||
self.assertEqual(profile.iso.read_bytes(), iso_bytes())
|
||||
self.assertTrue(profile.iso.is_relative_to(self.root / 'cache/hydra/iso'))
|
||||
@@ -0,0 +1,210 @@
|
||||
import hashlib
|
||||
import http.server
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from hydra import media
|
||||
|
||||
|
||||
def iso_bytes(signature=b'CD001', *, declared_blocks=40, block_size=2048, hybrid=False):
|
||||
data = bytearray(40 * 2048)
|
||||
start = 16 * 2048
|
||||
data[start:start + 7] = b'\x01' + signature + b'\x01'
|
||||
data[start + 80:start + 88] = declared_blocks.to_bytes(4, 'little') + declared_blocks.to_bytes(4, 'big')
|
||||
data[start + 120:start + 124] = b'\x01\x00\x00\x01'
|
||||
data[start + 124:start + 128] = b'\x01\x00\x00\x01'
|
||||
data[start + 128:start + 132] = block_size.to_bytes(2, 'little') + block_size.to_bytes(2, 'big')
|
||||
data[start + 881] = 1
|
||||
data[17 * 2048:17 * 2048 + 7] = b'\xffCD001\x01'
|
||||
if hybrid:
|
||||
data[20 * 2048:20 * 2048 + 7] = b'\x00NSR03\x01'
|
||||
return bytes(data)
|
||||
|
||||
|
||||
class MediaTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.cache = Path(self.tmp.name) / 'cache'
|
||||
self.requests = []
|
||||
owner = self
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
owner.requests.append(self.path)
|
||||
body = b'<html>download here</html>' if self.path.startswith('/html') else iso_bytes(
|
||||
declared_blocks=4000 if self.path.startswith('/extent') else 40)
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/html' if self.path.startswith('/html') else 'application/octet-stream')
|
||||
if not self.path.startswith('/extent-no-length'):
|
||||
self.send_header('Content-Length', str(len(body) + (100 if self.path.startswith('/truncated') else 0)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
self.server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
||||
self.addCleanup(self.server.server_close)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
self.addCleanup(self.server.shutdown)
|
||||
self.url = f'http://127.0.0.1:{self.server.server_port}'
|
||||
|
||||
def test_verified_download_is_reused_and_progress_reports_complete_bytes(self):
|
||||
updates = []
|
||||
url = self.url + '/installer.iso?token=secret'
|
||||
path = media.download_iso(url, cache=self.cache, progress=lambda got, total: updates.append((got, total)))
|
||||
self.assertEqual(path.read_bytes(), iso_bytes())
|
||||
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
|
||||
self.assertNotIn('secret', path.name)
|
||||
self.assertEqual(updates[-1], (81920, 81920))
|
||||
self.assertEqual(media.download_iso(url, cache=self.cache), path)
|
||||
self.assertEqual(len(self.requests), 1)
|
||||
|
||||
def test_html_truncation_and_checksum_do_not_publish_images(self):
|
||||
for endpoint, checksum in (('/html.iso', None), ('/truncated.iso', None), ('/wrong.iso', '0' * 64)):
|
||||
with self.subTest(endpoint=endpoint):
|
||||
with self.assertRaises(ValueError):
|
||||
media.download_iso(self.url + endpoint, cache=self.cache, sha256=checksum)
|
||||
self.assertFalse(list(self.cache.glob('*.iso')))
|
||||
self.assertFalse(list(self.cache.glob('*.part')))
|
||||
|
||||
def test_cancellation_removes_partial_and_retry_completes(self):
|
||||
def cancel(got, total):
|
||||
if got:
|
||||
raise KeyboardInterrupt
|
||||
with self.assertRaises(KeyboardInterrupt):
|
||||
media.download_iso(self.url + '/cancel.iso', cache=self.cache, progress=cancel)
|
||||
self.assertFalse(list(self.cache.glob('*.iso')))
|
||||
self.assertFalse(list(self.cache.glob('*.part')))
|
||||
self.assertEqual(media.download_iso(self.url + '/cancel.iso', cache=self.cache).read_bytes(), iso_bytes())
|
||||
|
||||
def test_same_basename_different_urls_and_concurrent_downloads_are_safe(self):
|
||||
results = []
|
||||
errors = []
|
||||
def fetch():
|
||||
try:
|
||||
results.append(media.download_iso(self.url + '/one/os.iso', cache=self.cache))
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
threads = [threading.Thread(target=fetch) for _ in range(2)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join(5)
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(len(set(results)), 1)
|
||||
self.assertEqual(self.requests, ['/one/os.iso'])
|
||||
second = media.download_iso(self.url + '/two/os.iso', cache=self.cache)
|
||||
self.assertNotEqual(second, results[0])
|
||||
|
||||
def test_corrupt_cache_and_symlink_never_overwrite_unrelated_files(self):
|
||||
url = self.url + '/installer.iso'
|
||||
path = media.download_iso(url, cache=self.cache)
|
||||
path.write_text('valuable unrelated data')
|
||||
with self.assertRaises(ValueError):
|
||||
media.download_iso(url, cache=self.cache)
|
||||
self.assertEqual(path.read_text(), 'valuable unrelated data')
|
||||
path.unlink()
|
||||
target = Path(self.tmp.name) / 'outside'
|
||||
target.write_bytes(iso_bytes())
|
||||
path.symlink_to(target)
|
||||
with self.assertRaises(ValueError):
|
||||
media.download_iso(url, cache=self.cache)
|
||||
self.assertTrue(path.is_symlink())
|
||||
self.assertEqual(target.read_bytes(), iso_bytes())
|
||||
|
||||
def test_unsupported_urls_checksums_and_insufficient_space_fail_before_download(self):
|
||||
for url in ('ftp://example.org/os.iso', 'http://user:secret@example.org/os.iso'):
|
||||
with self.assertRaises(ValueError):
|
||||
media.download_iso(url, cache=self.cache)
|
||||
with self.assertRaises(ValueError):
|
||||
media.download_iso(self.url + '/os.iso', cache=self.cache, sha256='bad')
|
||||
with patch('hydra.media.shutil.disk_usage', return_value=(10, 9, 1)):
|
||||
with self.assertRaisesRegex(ValueError, 'space'):
|
||||
media.download_iso(self.url + '/os.iso', cache=self.cache)
|
||||
self.assertFalse(list(self.cache.glob('*.iso')))
|
||||
|
||||
def test_local_validation_accepts_iso_udf_hybrid_and_rejects_html_and_bad_checksum(self):
|
||||
path = Path(self.tmp.name) / 'os.iso'
|
||||
path.write_bytes(iso_bytes(hybrid=True))
|
||||
media.validate_iso(path, sha256=hashlib.sha256(path.read_bytes()).hexdigest())
|
||||
with self.assertRaises(ValueError):
|
||||
media.validate_iso(path, sha256='0' * 64)
|
||||
path.write_text('<html>not an image</html>')
|
||||
with self.assertRaisesRegex(ValueError, 'direct|ISO'):
|
||||
media.validate_iso(path)
|
||||
|
||||
def test_cache_reuse_reports_completed_progress_without_another_request(self):
|
||||
url = self.url + '/cached.iso'
|
||||
path = media.download_iso(url, cache=self.cache)
|
||||
updates = []
|
||||
self.assertEqual(media.download_iso(url, cache=self.cache, progress=lambda got, total: updates.append((got, total))), path)
|
||||
self.assertEqual(updates, [(81920, 81920)])
|
||||
self.assertEqual(self.requests, ['/cached.iso'])
|
||||
|
||||
def test_declared_extent_truncation_with_matching_content_length_never_publishes(self):
|
||||
with self.assertRaisesRegex(media.MediaError, 'truncated|extent'):
|
||||
media.download_iso(self.url + '/extent.iso', cache=self.cache)
|
||||
self.assertFalse(list(self.cache.glob('*.iso')))
|
||||
self.assertFalse(list(self.cache.glob('*.part')))
|
||||
|
||||
def test_declared_extent_truncation_without_content_length_never_publishes(self):
|
||||
with self.assertRaisesRegex(media.MediaError, 'truncated|extent'):
|
||||
media.download_iso(self.url + '/extent-no-length.iso', cache=self.cache)
|
||||
self.assertFalse(list(self.cache.glob('*.iso')))
|
||||
self.assertFalse(list(self.cache.glob('*.part')))
|
||||
|
||||
def test_zero_invalid_or_inconsistent_block_fields_are_rejected(self):
|
||||
path = Path(self.tmp.name) / 'invalid.iso'
|
||||
for body in (iso_bytes(declared_blocks=0), iso_bytes(block_size=0), iso_bytes(block_size=4096)):
|
||||
with self.subTest(body=body[16 * 2048 + 80:16 * 2048 + 132]):
|
||||
path.write_bytes(body)
|
||||
with self.assertRaises(media.MediaError):
|
||||
media.validate_iso(path)
|
||||
body = bytearray(iso_bytes())
|
||||
body[16 * 2048 + 84:16 * 2048 + 88] = (4000).to_bytes(4, 'big')
|
||||
path.write_bytes(body)
|
||||
with self.assertRaises(media.MediaError):
|
||||
media.validate_iso(path)
|
||||
body = bytearray(iso_bytes())
|
||||
body[16 * 2048 + 130:16 * 2048 + 132] = (1024).to_bytes(2, 'big')
|
||||
path.write_bytes(body)
|
||||
with self.assertRaises(media.MediaError):
|
||||
media.validate_iso(path)
|
||||
|
||||
def test_boot_signature_or_unterminated_primary_is_not_a_complete_image(self):
|
||||
path = Path(self.tmp.name) / 'descriptor.iso'
|
||||
boot_only = bytearray(iso_bytes())
|
||||
boot_only[16 * 2048] = 0
|
||||
unterminated = bytearray(iso_bytes())
|
||||
unterminated[17 * 2048:18 * 2048] = bytes(2048)
|
||||
for body in (boot_only, unterminated, iso_bytes()[:17 * 2048]):
|
||||
path.write_bytes(body)
|
||||
with self.assertRaises(media.MediaError):
|
||||
media.validate_iso(path)
|
||||
|
||||
def test_pure_udf_is_rejected_with_supported_image_guidance(self):
|
||||
path = Path(self.tmp.name) / 'udf.iso'
|
||||
for signature in (b'NSR02', b'NSR03'):
|
||||
path.write_bytes(iso_bytes(signature))
|
||||
with self.assertRaisesRegex(media.MediaError, 'UDF.*ISO9660|ISO9660.*UDF'):
|
||||
media.validate_iso(path)
|
||||
|
||||
def test_iso_udf_hybrid_with_trailing_data_and_smaller_blocks_remains_valid(self):
|
||||
path = Path(self.tmp.name) / 'hybrid.iso'
|
||||
for body in (iso_bytes(hybrid=True) + b'trailing hybrid partition data',
|
||||
iso_bytes(declared_blocks=80, block_size=1024),
|
||||
iso_bytes(declared_blocks=160, block_size=512)):
|
||||
path.write_bytes(body)
|
||||
self.assertEqual(media.validate_iso(path), path)
|
||||
|
||||
def test_cached_iso_truncated_with_descriptor_intact_is_not_reused(self):
|
||||
url = self.url + '/cached-truncated.iso'
|
||||
path = media.download_iso(url, cache=self.cache)
|
||||
path.write_bytes(path.read_bytes()[:20 * 2048])
|
||||
with self.assertRaisesRegex(media.MediaError, 'Cached ISO failed validation'):
|
||||
media.download_iso(url, cache=self.cache)
|
||||
self.assertEqual(path.stat().st_size, 20 * 2048)
|
||||
self.assertEqual(self.requests, ['/cached-truncated.iso'])
|
||||
@@ -0,0 +1,168 @@
|
||||
import contextlib
|
||||
import importlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from hydra.profiles import Profile
|
||||
from hydra.runtime import Runtime, RuntimeError
|
||||
|
||||
|
||||
class MenuTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix='hm-')
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
self.manager = Runtime(self.root / 'config', self.root / 'state', self.root / 'run')
|
||||
|
||||
def menu(self):
|
||||
self.assertIsNotNone(importlib.util.find_spec('hydra.menu'), 'Terminal menu is missing')
|
||||
return importlib.import_module('hydra.menu')
|
||||
|
||||
def run_choices(self, choices, **kwargs):
|
||||
output = io.StringIO()
|
||||
with patch('builtins.input', side_effect=choices), contextlib.redirect_stdout(output):
|
||||
code = self.menu().run_menu(self.manager, **kwargs)
|
||||
self.assertEqual(code, 0)
|
||||
return output.getvalue()
|
||||
|
||||
def test_display_is_readable_and_never_starts_a_vm(self):
|
||||
self.manager.add(Profile('alpha', memory=512))
|
||||
with patch.object(Runtime, 'start', side_effect=AssertionError('display started a VM')):
|
||||
output = self.run_choices([':quit'])
|
||||
for label in ('Hydra OS', 'alpha', 'stopped', '512 MiB', ':run', ':network', ':shell'):
|
||||
self.assertIn(label, output)
|
||||
self.assertNotIn('"status":', output)
|
||||
self.assertFalse(self.manager.paths('alpha').disk.exists())
|
||||
|
||||
def test_number_selection_persists_and_enter_reuses_it(self):
|
||||
self.manager.add(Profile('alpha'))
|
||||
self.manager.add(Profile('beta'))
|
||||
module = self.menu()
|
||||
selected = []
|
||||
def open_vm(runtime, name, **kwargs):
|
||||
selected.append(name)
|
||||
return 0
|
||||
with patch.object(module, 'open_vm', side_effect=open_vm):
|
||||
self.run_choices(['2', ':quit'])
|
||||
self.run_choices(['', ':quit'])
|
||||
self.assertEqual(selected, ['beta', 'beta'])
|
||||
self.assertEqual(json.loads((self.manager.state / 'menu.json').read_text()), {'last_selected': 'beta'})
|
||||
self.assertEqual((self.manager.state / 'menu.json').stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_names_s_and_q_remain_vm_selections(self):
|
||||
for name in ('s', 'q'):
|
||||
self.manager.add(Profile(name))
|
||||
selected = []
|
||||
with patch.object(self.menu(), 'open_vm', side_effect=lambda runtime, name, **kw: selected.append(name) or 0):
|
||||
self.run_choices(['s', 'q', ':quit'])
|
||||
self.assertEqual(selected, ['s', 'q'])
|
||||
|
||||
def test_invalid_profile_and_unknown_identity_leave_other_vms_accessible(self):
|
||||
self.manager.add(Profile('alpha'))
|
||||
self.manager.add(Profile('beta'))
|
||||
(self.manager.config / 'bad.toml').write_text('memory = "invalid"')
|
||||
self.manager.paths('alpha').spice.parent.mkdir(parents=True)
|
||||
self.manager.paths('alpha').spice.touch()
|
||||
module = self.menu()
|
||||
with patch.object(module, 'open_vm', side_effect=RuntimeError('alpha: unknown identity; inspect status')):
|
||||
output = self.run_choices(['alpha', ':help', ':quit'])
|
||||
self.assertIn('bad.toml', output)
|
||||
self.assertIn('unknown', output)
|
||||
self.assertIn('beta', output)
|
||||
self.assertFalse((self.manager.state / 'menu.json').exists())
|
||||
|
||||
def test_empty_or_removed_selection_does_not_launch_and_shell_can_repair_profiles(self):
|
||||
module = self.menu()
|
||||
def repair(argv):
|
||||
self.manager.add(Profile('fresh'))
|
||||
return type('Result', (), {'returncode': 0})()
|
||||
with patch.object(module.subprocess, 'run', side_effect=repair), patch.object(module, 'open_vm', return_value=0) as launch:
|
||||
output = self.run_choices(['', ':shell', '', ':quit'])
|
||||
self.assertIn('No VM profiles', output)
|
||||
self.assertEqual([call.args[1] for call in launch.call_args_list], ['fresh'])
|
||||
|
||||
def test_missing_network_tool_and_cancelled_action_return_to_menu(self):
|
||||
module = self.menu()
|
||||
with patch.object(module.shutil, 'which', return_value=None):
|
||||
output = self.run_choices([':network', ':run', '', ':quit'])
|
||||
self.assertIn('nmtui', output)
|
||||
self.assertIn('cancel', output.lower())
|
||||
|
||||
def test_refresh_and_successful_session_selection_return_to_compositor(self):
|
||||
self.manager.add(Profile('beta'))
|
||||
manifest = self.root / 'manifest.json'
|
||||
module = self.menu()
|
||||
with patch.object(module, 'open_vm', return_value=0) as launch:
|
||||
self.run_choices([''], manifest=manifest)
|
||||
self.assertEqual(launch.call_args.args[1], 'beta')
|
||||
self.assertEqual(launch.call_args.kwargs['manifest'], manifest)
|
||||
with patch.object(module, 'request_refresh', return_value=0) as refresh:
|
||||
self.run_choices([':refresh'], manifest=manifest)
|
||||
self.assertEqual(refresh.call_args.args, (manifest,))
|
||||
|
||||
def test_orderly_shutdown_and_detach_are_explicit_and_do_not_force_stop(self):
|
||||
iso = self.root / 'install.iso'; iso.touch()
|
||||
self.manager.add(Profile('alpha', iso))
|
||||
module = self.menu()
|
||||
with patch.object(Runtime, 'stop', side_effect=AssertionError('must not force stop')), patch.object(Runtime, 'shutdown', return_value={'status': 'shutdown-requested'}) as shutdown:
|
||||
self.run_choices([':shutdown alpha', ':detach alpha', ':quit'])
|
||||
self.assertEqual(shutdown.call_args.args, ('alpha',))
|
||||
self.assertIsNone(self.manager.profile('alpha').iso)
|
||||
|
||||
|
||||
def test_download_renders_safe_preview_and_transfer_progress(self):
|
||||
module = self.menu()
|
||||
iso = self.root / 'saved.iso'
|
||||
def transfer(url, *, sha256=None, progress=None):
|
||||
self.assertEqual(url, 'https://example.test/live.iso?token=private-token')
|
||||
self.assertIsNotNone(progress, 'Download menu needs progress feedback')
|
||||
progress(1048576, 2097152)
|
||||
iso.write_bytes(b'complete fixture')
|
||||
progress(2097152, 2097152)
|
||||
return iso
|
||||
with patch.object(module, 'download_iso', side_effect=transfer):
|
||||
output = self.run_choices([':download https://example.test/live.iso?token=private-token', '', ':quit'])
|
||||
self.assertEqual(iso.read_bytes(), b'complete fixture')
|
||||
self.assertIn('2.0 / 2.0 MiB', output)
|
||||
self.assertIn(str(iso), output)
|
||||
self.assertNotIn('private-token', output)
|
||||
|
||||
def test_guided_run_applies_allocations_and_returns_after_selected_handoff(self):
|
||||
module = self.menu()
|
||||
def launch(runtime, source, **kwargs):
|
||||
self.assertEqual(source, '/tmp/example.iso')
|
||||
self.assertEqual(kwargs['disk_size'], '8G')
|
||||
self.assertEqual(kwargs.get('defaults'), Path('/etc/hydra/defaults.toml'))
|
||||
runtime.add(Profile(kwargs['name'], memory=kwargs['memory'], cpus=kwargs['cpus']))
|
||||
return 0
|
||||
with patch.object(module, 'run_source', side_effect=launch):
|
||||
self.run_choices([':run /tmp/example.iso', 'new-vm', '512', '1', '8G', ''], manifest=self.root / 'manifest.json')
|
||||
self.assertEqual(self.manager.profile('new-vm').memory, 512)
|
||||
self.assertEqual(self.manager.profile('new-vm').cpus, 1)
|
||||
self.assertEqual(json.loads((self.manager.state / 'menu.json').read_text()), {'last_selected': 'new-vm'})
|
||||
|
||||
def test_duplicate_guided_run_does_not_download_or_replace_profile(self):
|
||||
self.manager.add(Profile('alpha', memory=512))
|
||||
before = (self.manager.config / 'alpha.toml').read_bytes()
|
||||
with patch.object(self.menu(), 'run_source', side_effect=AssertionError('existing name must not launch')):
|
||||
output = self.run_choices([':run https://example.test/live.iso', 'alpha', ':quit'])
|
||||
self.assertIn('hydra open alpha', output)
|
||||
self.assertEqual((self.manager.config / 'alpha.toml').read_bytes(), before)
|
||||
|
||||
def test_corrupt_saved_selection_and_removed_vm_do_not_block_menu(self):
|
||||
self.manager.state.mkdir()
|
||||
(self.manager.state / 'menu.json').write_text('null')
|
||||
self.run_choices(['', ':quit'])
|
||||
(self.manager.state / 'menu.json').write_text('{"last_selected": "removed"}')
|
||||
with patch.object(self.menu(), 'open_vm', side_effect=AssertionError('removed VM must not open')):
|
||||
self.run_choices(['', ':quit'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -136,6 +136,83 @@ class QMPTests(unittest.TestCase):
|
||||
client.command('query-status')
|
||||
self.assertLess(time.monotonic() - started, 0.25)
|
||||
|
||||
def test_spice_events_before_greeting_still_negotiate_and_query_identity(self):
|
||||
commands = []
|
||||
def handler(conn):
|
||||
send(conn, {'event': 'SPICE_CONNECTED', 'data': {'server': {'family': 'unix'}},
|
||||
'timestamp': {'seconds': 1791919209, 'microseconds': 650048}})
|
||||
send(conn, {'event': 'SPICE_DISCONNECTED',
|
||||
'timestamp': {'seconds': -1, 'microseconds': -1}})
|
||||
greeting(conn)
|
||||
with conn.makefile('rb') as stream:
|
||||
for name in ('qmp_capabilities', 'query-name'):
|
||||
request = json.loads(stream.readline())
|
||||
commands.append(request['execute'])
|
||||
send(conn, {'return': {} if name == 'qmp_capabilities' else {'name': 'owned'},
|
||||
'id': request['id']})
|
||||
self.server(handler)
|
||||
with QMPClient(self.path, timeout=0.5) as client:
|
||||
self.assertEqual(client.command('query-name'), {'name': 'owned'})
|
||||
self.assertEqual(commands, ['qmp_capabilities', 'query-name'])
|
||||
|
||||
def test_before_greeting_rejects_replies_and_malformed_events(self):
|
||||
messages = [
|
||||
{}, {'return': {}}, {'error': {}},
|
||||
{'event': 'SPICE_CONNECTED'},
|
||||
{'event': '', 'timestamp': {'seconds': 1, 'microseconds': 0}},
|
||||
{'event': 1, 'timestamp': {'seconds': 1, 'microseconds': 0}},
|
||||
{'event': 'STOP', 'data': [], 'timestamp': {'seconds': 1, 'microseconds': 0}},
|
||||
{'event': 'STOP', 'timestamp': {'seconds': True, 'microseconds': 0}},
|
||||
{'event': 'STOP', 'timestamp': {'seconds': 1, 'microseconds': 1000000}},
|
||||
{'event': 'STOP', 'timestamp': {'seconds': 1, 'microseconds': -1}},
|
||||
{'event': 'STOP', 'timestamp': {'seconds': 1}},
|
||||
{'event': 'STOP', 'timestamp': {'seconds': 1, 'microseconds': 0}, 'return': {}},
|
||||
]
|
||||
for message in messages:
|
||||
with self.subTest(message=message):
|
||||
self.path.unlink(missing_ok=True)
|
||||
server = self.server(lambda conn, message=message: (send(conn, message), greeting(conn)))
|
||||
with self.assertRaisesRegex(QMPError, 'Invalid QMP'):
|
||||
QMPClient(self.path, timeout=0.2)
|
||||
server.close()
|
||||
|
||||
def test_before_greeting_event_flood_cannot_extend_handshake_deadline(self):
|
||||
def handler(conn):
|
||||
while True:
|
||||
send(conn, {'event': 'SPICE_CONNECTED', 'timestamp': {'seconds': 1, 'microseconds': 0}})
|
||||
time.sleep(0.005)
|
||||
self.server(handler)
|
||||
started = time.monotonic()
|
||||
with self.assertRaisesRegex(QMPError, 'deadline|receive'):
|
||||
QMPClient(self.path, timeout=0.08)
|
||||
elapsed = time.monotonic() - started
|
||||
self.assertGreaterEqual(elapsed, 0.06)
|
||||
self.assertLess(elapsed, 0.25)
|
||||
|
||||
def test_greeting_and_capabilities_share_original_handshake_deadline(self):
|
||||
def handler(conn):
|
||||
send(conn, {'event': 'SPICE_CONNECTED', 'timestamp': {'seconds': 1, 'microseconds': 0}})
|
||||
time.sleep(0.06)
|
||||
greeting(conn)
|
||||
request = json.loads(conn.makefile('rb').readline())
|
||||
time.sleep(0.06)
|
||||
send(conn, {'return': {}, 'id': request['id']})
|
||||
self.server(handler)
|
||||
started = time.monotonic()
|
||||
with self.assertRaisesRegex(QMPError, 'deadline|receive'):
|
||||
QMPClient(self.path, timeout=0.1)
|
||||
self.assertLess(time.monotonic() - started, 0.17)
|
||||
|
||||
def test_event_then_greeting_does_not_bypass_capabilities_failure(self):
|
||||
def handler(conn):
|
||||
send(conn, {'event': 'SPICE_CONNECTED', 'timestamp': {'seconds': 1, 'microseconds': 0}})
|
||||
greeting(conn)
|
||||
request = json.loads(conn.makefile('rb').readline())
|
||||
send(conn, {'error': {'class': 'GenericError', 'desc': 'refused'}, 'id': request['id']})
|
||||
self.server(handler)
|
||||
with self.assertRaisesRegex(QMPError, 'QMP qmp_capabilities'):
|
||||
QMPClient(self.path, timeout=0.2)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
+136
-18
@@ -4,6 +4,7 @@ import io
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -13,7 +14,7 @@ from unittest.mock import patch
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from hydra.profiles import Profile
|
||||
from hydra.runtime import Runtime
|
||||
from hydra.runtime import Runtime, RuntimeError as VMError
|
||||
from hydra import session
|
||||
from hydra.cli import main, parser
|
||||
|
||||
@@ -34,7 +35,7 @@ class SessionTests(unittest.TestCase):
|
||||
self.assertEqual([n.text for n in tree.findall('./desktops/names/name')], ['alpha', 'beta'])
|
||||
self.assertEqual(tree.findtext('./desktops/popupTime'), '0')
|
||||
rules = tree.findall('./windowRules/windowRule')
|
||||
self.assertEqual([r.get('title') for r in rules], ['HydraOS: alpha', 'HydraOS: beta'])
|
||||
self.assertEqual([r.get('title') for r in rules], ['Hydra OS: alpha', 'Hydra OS: beta'])
|
||||
for index, rule in enumerate(rules, 1):
|
||||
self.assertEqual(rule.get('serverDecoration'), 'no')
|
||||
self.assertEqual(rule.find("./action[@name='SendToDesktop']").get('to'), str(index))
|
||||
@@ -214,7 +215,7 @@ sys.exit(0)
|
||||
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'])
|
||||
['Hydra OS: first', 'Hydra OS: second'])
|
||||
self.assertFalse(manager.paths('first').disk.exists())
|
||||
|
||||
def test_no_running_vms_opens_recovery_without_start_or_stop(self):
|
||||
@@ -226,7 +227,7 @@ sys.exit(0)
|
||||
manifest.write_text(json.dumps({'config': str(self.manager.config), 'state': str(self.manager.state), 'runtime': str(self.manager.runtime), 'names': ['alpha', 'beta'], 'terminal': str(terminal), 'directory': str(self.root)}))
|
||||
with patch.dict(os.environ, {'CAPTURE': str(capture)}), patch.object(Runtime, 'start', side_effect=AssertionError('must not start')), patch.object(Runtime, 'stop', side_effect=AssertionError('must not stop')):
|
||||
self.assertEqual(session.session_helper('clients', manifest), 0)
|
||||
self.assertEqual(json.loads(capture.read_text())[0], '--title=HydraOS-recovery')
|
||||
self.assertEqual(json.loads(capture.read_text())[0], '--title=Hydra OS recovery')
|
||||
self.assertEqual(self.manager.status('alpha')['status'], 'stopped')
|
||||
self.assertFalse(self.manager.paths('alpha').disk.exists())
|
||||
|
||||
@@ -265,27 +266,22 @@ else:
|
||||
return manifest, {'WAYLAND_DISPLAY': 'unused', 'HYDRA_VIEWER': str(viewer), 'RECOVERY_CAPTURE': str(self.root), 'SHELL': str(shell)}
|
||||
|
||||
def _check_recovery_vm_name(self, name):
|
||||
import importlib.util
|
||||
self.assertIsNotNone(importlib.util.find_spec('hydra.menu'), 'Terminal menu is missing')
|
||||
from hydra import menu
|
||||
manifest, env = self._recovery_fixture()
|
||||
processes = []
|
||||
original_popen = subprocess.Popen
|
||||
def launch(*args, **kwargs):
|
||||
process = original_popen(*args, **kwargs)
|
||||
processes.append(process)
|
||||
return process
|
||||
with patch.dict(os.environ, env), patch('builtins.input', side_effect=[name, ':quit', EOFError()]), patch.object(subprocess, 'Popen', launch), contextlib.redirect_stdout(io.StringIO()):
|
||||
with patch.dict(os.environ, env), patch('builtins.input', side_effect=[name, ':quit']), \
|
||||
patch.object(menu, 'open_vm', return_value=0) as launch, contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(session.session_helper('recover', manifest), 0)
|
||||
for process in processes:
|
||||
self.assertEqual(process.wait(timeout=5), 0)
|
||||
connected = self.root / (name + '.connected')
|
||||
self.assertTrue(connected.exists(), f'VM {name} was not reconnected')
|
||||
self.assertEqual(connected.read_text(), name)
|
||||
self.assertEqual(launch.call_args.args[1], name)
|
||||
self.assertEqual(launch.call_args.kwargs['manifest'], manifest)
|
||||
self.assertFalse((self.root / 'shell.opened').exists())
|
||||
self.assertFalse(self.manager.paths(name).disk.exists())
|
||||
|
||||
def test_recovery_reconnects_vm_named_s(self):
|
||||
def test_recovery_selects_vm_named_s(self):
|
||||
self._check_recovery_vm_name('s')
|
||||
|
||||
def test_recovery_reconnects_vm_named_q(self):
|
||||
def test_recovery_selects_vm_named_q(self):
|
||||
self._check_recovery_vm_name('q')
|
||||
|
||||
def test_recovery_colon_commands_open_shell_and_close_terminal(self):
|
||||
@@ -297,5 +293,127 @@ else:
|
||||
self.assertFalse((self.root / 'q.connected').exists())
|
||||
|
||||
|
||||
def test_refresh_payload_selects_fresh_profile_and_legacy_empty_request_is_accepted(self):
|
||||
manifest = self.root / 'session.json'
|
||||
manifest.write_text(json.dumps({'config': str(self.manager.config), 'state': str(self.manager.state),
|
||||
'runtime': str(self.manager.runtime), 'names': ['alpha'], 'terminal': '/bin/sh'}))
|
||||
self.assertTrue(hasattr(session, 'request_refresh'), 'Selected refresh API is missing')
|
||||
self.assertEqual(session.request_refresh(manifest, selected='beta'), 0)
|
||||
request = self.root / 'refresh.request'
|
||||
self.assertEqual(json.loads(request.read_text()), {'selected': 'beta'})
|
||||
self.assertEqual(request.stat().st_mode & 0o777, 0o600)
|
||||
request.unlink()
|
||||
self.assertEqual(session.request_refresh(manifest), 0)
|
||||
self.assertEqual(request.read_bytes(), b'')
|
||||
|
||||
def test_refresh_rejects_invalid_or_removed_selection_without_publishing(self):
|
||||
manifest = self.root / 'session.json'
|
||||
manifest.write_text(json.dumps({'config': str(self.manager.config), 'state': str(self.manager.state),
|
||||
'runtime': str(self.manager.runtime), 'names': ['alpha'], 'terminal': '/bin/sh'}))
|
||||
self.assertTrue(hasattr(session, 'request_refresh'), 'Selected refresh API is missing')
|
||||
for name in ('missing', '../alpha', 'bad*'):
|
||||
with self.assertRaises((ValueError, OSError)):
|
||||
session.request_refresh(manifest, selected=name)
|
||||
self.assertFalse((self.root / 'refresh.request').exists())
|
||||
|
||||
def test_selected_second_workspace_survives_compositor_refresh_without_starting_guests(self):
|
||||
capture = self.root / 'selected-capture.json'
|
||||
compositor = self.root / 'selected-labwc'
|
||||
compositor.write_text('#!' + os.sys.executable + '\n' + r'''import json, os, 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'
|
||||
capture = Path(os.environ['CAPTURE'])
|
||||
seen = json.loads(capture.read_text()) if capture.exists() else []
|
||||
seen.append({'xml': (config / 'rc.xml').read_text(), 'manifest_env': os.environ.get('HYDRA_SESSION_MANIFEST'),
|
||||
'manifest_path': str(manifest)})
|
||||
capture.write_text(json.dumps(seen))
|
||||
if len(seen) == 1:
|
||||
(manifest.parent / 'refresh.request').write_text('{"selected": "beta"}')
|
||||
''')
|
||||
compositor.chmod(0o700)
|
||||
with patch.dict(os.environ, {'HYDRA_LABWC': str(compositor), 'HYDRA_TERMINAL': str(compositor),
|
||||
'HYDRA_BRIGHTNESS': str(compositor), 'CAPTURE': str(capture)}), \
|
||||
patch.object(Runtime, 'start', side_effect=AssertionError('session must never start guests')):
|
||||
self.assertEqual(session.launch_session(self.manager), 0)
|
||||
seen = json.loads(capture.read_text())
|
||||
self.assertEqual(len(seen), 2)
|
||||
self.assertEqual(ET.fromstring(seen[1]['xml']).findtext('./desktops/initial'), '2')
|
||||
self.assertTrue(all(s['manifest_env'] == s['manifest_path'] for s in seen))
|
||||
self.assertFalse(self.manager.paths('alpha').disk.exists())
|
||||
self.assertFalse(self.manager.paths('beta').disk.exists())
|
||||
|
||||
def test_recovery_failure_leaves_emergency_shell_accessible(self):
|
||||
manifest, env = self._recovery_fixture()
|
||||
import importlib.util
|
||||
self.assertIsNotNone(importlib.util.find_spec('hydra.menu'), 'Terminal menu is missing')
|
||||
from hydra import menu
|
||||
with patch.dict(os.environ, env), patch.object(menu, 'run_menu', side_effect=TypeError('broken menu')), \
|
||||
patch('builtins.input', side_effect=[':shell', ':quit']), contextlib.redirect_stdout(io.StringIO()) as output:
|
||||
try:
|
||||
code = session.session_helper('recover', manifest)
|
||||
except TypeError as exc:
|
||||
self.fail(f'Menu failure escaped emergency recovery: {exc}')
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn('emergency', output.getvalue().lower())
|
||||
self.assertTrue((self.root / 'shell.opened').exists())
|
||||
|
||||
|
||||
def test_runtime_status_error_opens_recovery_without_starting_guests(self):
|
||||
manifest = self.root / 'session.json'
|
||||
manifest.write_text(json.dumps({'config': str(self.manager.config), 'state': str(self.manager.state),
|
||||
'runtime': str(self.manager.runtime), 'names': ['alpha'], 'terminal': shutil.which('true')}))
|
||||
with patch.object(Runtime, 'status', side_effect=VMError('private runtime permission problem')), \
|
||||
patch.object(Runtime, 'start', side_effect=AssertionError('must not start')), \
|
||||
contextlib.redirect_stderr(io.StringIO()) as error:
|
||||
try:
|
||||
code = session.session_helper('clients', manifest)
|
||||
except VMError as exc:
|
||||
self.fail(f'Status failure escaped session recovery: {exc}')
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn('private runtime permission problem', error.getvalue())
|
||||
|
||||
def test_invalid_profile_is_excluded_from_compositor_but_valid_profiles_remain(self):
|
||||
(self.manager.config / 'bad.toml').write_text('memory = "broken"')
|
||||
capture = self.root / 'invalid-capture.json'
|
||||
compositor = self.root / 'invalid-labwc'
|
||||
compositor.write_text('#!' + os.sys.executable + '\n' + r'''import json, os, sys
|
||||
from pathlib import Path
|
||||
if '--version' in sys.argv:
|
||||
print('labwc 0.20.2')
|
||||
else:
|
||||
config = Path(sys.argv[sys.argv.index('-C') + 1])
|
||||
Path(os.environ['CAPTURE']).write_text((config.parent / 'session.json').read_text())
|
||||
''')
|
||||
compositor.chmod(0o700)
|
||||
with patch.dict(os.environ, {'HYDRA_LABWC': str(compositor), 'HYDRA_TERMINAL': str(compositor),
|
||||
'HYDRA_BRIGHTNESS': str(compositor), 'CAPTURE': str(capture)}), \
|
||||
contextlib.redirect_stderr(io.StringIO()) as error:
|
||||
self.assertEqual(session.launch_session(self.manager), 0)
|
||||
self.assertEqual(json.loads(capture.read_text())['names'], ['alpha', 'beta'])
|
||||
self.assertIn('bad.toml', error.getvalue())
|
||||
|
||||
|
||||
def test_paused_guests_reconnect_viewers_without_start_or_resume(self):
|
||||
manifest, env = self._recovery_fixture()
|
||||
terminal = self.root / 'exit-terminal'
|
||||
terminal.write_text('#!' + os.sys.executable + '\n')
|
||||
terminal.chmod(0o700)
|
||||
data = json.loads(manifest.read_text())
|
||||
data['terminal'] = str(terminal)
|
||||
manifest.write_text(json.dumps(data))
|
||||
with patch.dict(os.environ, env), patch.object(Runtime, 'status', return_value={'status': 'paused'}), \
|
||||
patch.object(Runtime, 'start', side_effect=AssertionError('must not start/resume')), \
|
||||
patch.object(Runtime, 'stop', side_effect=AssertionError('must not stop')), \
|
||||
contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(session.session_helper('clients', manifest), 0)
|
||||
for name in ('s', 'q'):
|
||||
self.assertTrue((self.root / (name + '.connected')).exists(), f'Paused {name} was not reconnected')
|
||||
self.assertFalse(self.manager.paths(name).disk.exists())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Offline recovery tests; fake Btrfs only, never operate on block devices."""
|
||||
import copy
|
||||
import io
|
||||
import stat
|
||||
from types import SimpleNamespace
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from hydra import system_recovery as r
|
||||
except ImportError:
|
||||
r = None
|
||||
|
||||
ROOT_UUID = '11111111-2222-3333-4444-555555555555'
|
||||
ROOT_PART = '21111111-2222-3333-4444-555555555555'
|
||||
ESP_PART = '31111111-2222-3333-4444-555555555555'
|
||||
MARKER = dict(format=1, install_uuid='41111111-2222-3333-4444-555555555555',
|
||||
filesystem='btrfs', root_fs_uuid=ROOT_UUID, root_partuuid=ROOT_PART,
|
||||
esp_uuid='1234-ABCD', esp_partuuid=ESP_PART,
|
||||
owned_efi_path='EFI/HydraOS', root_subvolume='@root')
|
||||
|
||||
class FakeBtrfs:
|
||||
"""Persist identity inside fake subvolumes so a fresh process can recover."""
|
||||
def info(self, path):
|
||||
return json.loads((Path(path) / '.fake-subvolume.json').read_text())
|
||||
def create(self, path, parent=None, readonly=False):
|
||||
Path(path).mkdir(parents=True, exist_ok=True)
|
||||
info = dict(uuid=str(uuid.uuid4()), parent_uuid=parent, readonly=readonly)
|
||||
(Path(path) / '.fake-subvolume.json').write_text(json.dumps(info))
|
||||
return info
|
||||
def snapshot(self, source, dest, readonly):
|
||||
shutil.copytree(source, dest)
|
||||
return self.create(dest, self.info(source)['uuid'], readonly)
|
||||
def nested(self, path):
|
||||
return [str(p) for p in Path(path).glob('**/.fake-subvolume.json') if p.parent != Path(path)]
|
||||
def sync(self, path):
|
||||
pass
|
||||
|
||||
class RecoveryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.assertIsNotNone(r, 'offline recovery module must exist')
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.base = Path(self.temp.name)
|
||||
self.top, self.esp = self.base / 'top', self.base / 'esp'
|
||||
self.top.mkdir(); self.esp.mkdir()
|
||||
self.b = FakeBtrfs()
|
||||
for name in ('@root', '@home', '@snapshots'):
|
||||
self.b.create(self.top / name)
|
||||
marker = self.top / '@root/etc/hydra/storage.json'
|
||||
marker.parent.mkdir(parents=True)
|
||||
marker.write_text(json.dumps(MARKER))
|
||||
(self.top / '@root/kernel').write_text('old kernel')
|
||||
(self.top / '@home/user-data').write_text('user data')
|
||||
(self.esp / 'EFI/HydraOS').mkdir(parents=True)
|
||||
(self.esp / 'EFI/HydraOS/grubx64.efi').write_bytes(b'old EFI')
|
||||
(self.esp / 'EFI/OtherOS').mkdir()
|
||||
(self.esp / 'EFI/OtherOS/sentinel').write_bytes(b'foreign')
|
||||
self.session = r.RecoverySession(self.top, self.esp, self.b, MARKER)
|
||||
def checkpoint(self):
|
||||
return self.session.checkpoint('before-update')
|
||||
def test_exact_marker(self):
|
||||
self.assertEqual(r.validate_marker(MARKER), MARKER)
|
||||
for change in ({'format': True}, {'filesystem': 'ext4'}, {'root_subvolume': '@other'},
|
||||
{'owned_efi_path': '../EFI'}, {'unexpected': 'field'}, {'root_partuuid': 'bad'}):
|
||||
with self.subTest(change=change), self.assertRaises(r.RecoveryError):
|
||||
r.validate_marker(dict(MARKER, **change))
|
||||
def test_checkpoint_completed_and_readonly(self):
|
||||
result = self.checkpoint()
|
||||
self.assertTrue(self.b.info(self.top / '@snapshots' / result['id'] / 'root')['readonly'])
|
||||
self.assertEqual(self.session.list(), [result])
|
||||
self.assertEqual(result['storage'], MARKER)
|
||||
def test_snapshot_failure_never_publishes_descriptor(self):
|
||||
with patch.object(self.b, 'snapshot', side_effect=OSError('interrupted')):
|
||||
with self.assertRaises(OSError): self.checkpoint()
|
||||
self.assertEqual(self.session.list(), [])
|
||||
def test_nested_subvolume_refused(self):
|
||||
self.b.create(self.top / '@root/nested')
|
||||
with self.assertRaises(r.RecoveryError): self.checkpoint()
|
||||
def test_restore_preserves_home_foreign_and_failed_root(self):
|
||||
cp = self.checkpoint()
|
||||
old_uuid = self.b.info(self.top / '@root')['uuid']
|
||||
(self.top / '@root/kernel').write_text('broken kernel')
|
||||
(self.top / '@home/user-data').write_text('new home')
|
||||
(self.esp / 'EFI/HydraOS/grubx64.efi').write_bytes(b'broken')
|
||||
(self.esp / 'EFI/HydraOS/new-file').write_text('remove')
|
||||
result = self.session.restore(cp['id'])
|
||||
self.assertEqual((self.top / '@root/kernel').read_text(), 'old kernel')
|
||||
self.assertEqual((self.top / '@home/user-data').read_text(), 'new home')
|
||||
self.assertEqual((self.esp / 'EFI/OtherOS/sentinel').read_bytes(), b'foreign')
|
||||
self.assertEqual((self.esp / 'EFI/HydraOS/grubx64.efi').read_bytes(), b'old EFI')
|
||||
self.assertFalse((self.esp / 'EFI/HydraOS/new-file').exists())
|
||||
self.assertEqual(self.b.info(self.top / result['failed_root'])['uuid'], old_uuid)
|
||||
def test_tampered_backup_rejected_before_root_mutation(self):
|
||||
cp = self.checkpoint()
|
||||
original = self.b.info(self.top / '@root')
|
||||
(self.top / '@snapshots' / cp['id'] / 'efi/grubx64.efi').write_bytes(b'bad')
|
||||
with self.assertRaises(r.RecoveryError): self.session.restore(cp['id'])
|
||||
self.assertEqual(self.b.info(self.top / '@root'), original)
|
||||
self.assertFalse((self.top / '@snapshots/restore.json').exists())
|
||||
def test_symlink_efi_and_metadata_refused(self):
|
||||
(self.esp / 'EFI/HydraOS/link').symlink_to('/etc/passwd')
|
||||
with self.assertRaises(r.RecoveryError): self.checkpoint()
|
||||
def test_rw_snapshot_refused(self):
|
||||
cp = self.checkpoint()
|
||||
path = self.top / '@snapshots' / cp['id'] / 'root/.fake-subvolume.json'
|
||||
data = json.loads(path.read_text()); data['readonly'] = False; path.write_text(json.dumps(data))
|
||||
with self.assertRaises(r.RecoveryError): self.session.restore(cp['id'])
|
||||
def test_wrong_installed_identity_refused(self):
|
||||
marker = self.top / '@root/etc/hydra/storage.json'
|
||||
marker.write_text(json.dumps(dict(MARKER, esp_partuuid=ROOT_PART)))
|
||||
with self.assertRaises(r.RecoveryError): self.checkpoint()
|
||||
def test_fresh_process_resume_each_transition(self):
|
||||
cp = self.checkpoint()
|
||||
for phase in ('prepared', 'root-retained', 'root-installed', 'efi-restored'):
|
||||
with self.subTest(phase=phase):
|
||||
(self.top / '@root/kernel').write_text('broken again')
|
||||
script = '''import json, os, sys
|
||||
from pathlib import Path
|
||||
from hydra import system_recovery as r
|
||||
sys.path.insert(0, 'tests')
|
||||
from test_system_recovery import FakeBtrfs, MARKER
|
||||
s = r.RecoverySession(Path(sys.argv[1]), Path(sys.argv[2]), FakeBtrfs(), MARKER)
|
||||
save = s.save_journal
|
||||
def crash(value, phase):
|
||||
save(value, phase)
|
||||
if phase == sys.argv[4]: os._exit(77)
|
||||
s.save_journal = crash
|
||||
s.restore(sys.argv[3])
|
||||
'''
|
||||
result = subprocess.run([sys.executable, '-c', script, str(self.top), str(self.esp), cp['id'], phase])
|
||||
self.assertEqual(result.returncode, 77)
|
||||
subprocess.run([sys.executable, '-c', script, str(self.top), str(self.esp), cp['id'], 'none'], check=True)
|
||||
self.assertEqual((self.top / '@root/kernel').read_text(), 'old kernel')
|
||||
|
||||
|
||||
class GuardTests(unittest.TestCase):
|
||||
def devices(self):
|
||||
return [dict(path='/dev/vda', type='disk', **{'maj:min': '8:0'}, mountpoints=[], children=[
|
||||
dict(path='/dev/vda1', type='part', fstype='vfat', uuid='1234-ABCD', partuuid=ESP_PART,
|
||||
pkname='/dev/vda', **{'maj:min': '8:1'}, mountpoints=[], children=[]),
|
||||
dict(path='/dev/vda2', type='part', fstype='btrfs', uuid=ROOT_UUID, partuuid=ROOT_PART,
|
||||
pkname='/dev/vda', **{'maj:min': '8:2'}, mountpoints=[], children=[])])]
|
||||
def test_unique_ordinary_unmounted_partition(self):
|
||||
nodes = self.devices()
|
||||
self.assertEqual(r.resolve_partition(nodes, 'uuid', ROOT_UUID, 'btrfs')['path'], '/dev/vda2')
|
||||
for change in ('duplicate', 'mounted', 'holder', 'not-partition', 'same-partuuid'):
|
||||
bad = copy.deepcopy(nodes)
|
||||
root = bad[0]['children'][1]
|
||||
if change == 'duplicate':
|
||||
extra = dict(root, path='/dev/vdb2', **{'maj:min': '8:18'})
|
||||
bad.append(extra)
|
||||
elif change == 'mounted': root['mountpoints'] = ['/mnt/target']
|
||||
elif change == 'holder': root['children'] = [dict(path='/dev/dm-0', type='crypt')]
|
||||
elif change == 'not-partition': root['type'] = 'disk'
|
||||
else: bad[0]['children'][0]['partuuid'] = ROOT_PART
|
||||
with self.subTest(change=change), self.assertRaises(r.RecoveryError):
|
||||
r.resolve_partition(bad, 'uuid', ROOT_UUID, 'btrfs')
|
||||
def test_live_marker_must_be_regular_trusted_and_live_boot(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
base = Path(temp); marker = base / 'etc/hydra/live-mode/active'
|
||||
marker.parent.mkdir(parents=True); marker.write_text('')
|
||||
cmdline = 'root=live:CDLABEL=HYDRA_OS rd.live.image rd.live.overlay.overlayfs=1'
|
||||
mountinfo = '1 0 0:1 / / rw - overlay overlay rw\n2 1 7:0 / /run/rootfsbase ro - squashfs /dev/loop0 ro\n'
|
||||
r.validate_live(base, cmdline, mountinfo)
|
||||
for command, mounts in (('', mountinfo), (cmdline, '')):
|
||||
with self.assertRaises(r.RecoveryError): r.validate_live(base, command, mounts)
|
||||
marker.unlink(); marker.symlink_to('/etc/passwd')
|
||||
with self.assertRaises(r.RecoveryError): r.validate_live(base, cmdline, mountinfo)
|
||||
def test_untrusted_journal_link_refused_before_write(self):
|
||||
case = RecoveryTests('test_exact_marker'); case.setUp()
|
||||
try:
|
||||
cp = case.checkpoint()
|
||||
(case.top / '@snapshots/restore.json').symlink_to(case.base / 'absent')
|
||||
with self.assertRaises(r.RecoveryError): case.session.restore(cp['id'])
|
||||
self.assertFalse((case.base / 'absent').exists())
|
||||
finally: case.doCleanups()
|
||||
|
||||
class HardeningTests(unittest.TestCase):
|
||||
setUp = RecoveryTests.setUp
|
||||
checkpoint = RecoveryTests.checkpoint
|
||||
def test_checkpoint_plan_validates_pending_transaction_without_writes(self):
|
||||
before = sorted(str(p.relative_to(self.base)) for p in self.base.rglob('*'))
|
||||
self.session.checkpoint('planned', dry_run=True)
|
||||
self.assertEqual(before, sorted(str(p.relative_to(self.base)) for p in self.base.rglob('*')))
|
||||
(self.top / '@snapshots/restore.json').write_text('{}')
|
||||
with self.assertRaises(r.RecoveryError): self.session.checkpoint('planned', dry_run=True)
|
||||
def test_dry_restore_verifies_destination_and_writes_nothing(self):
|
||||
cp = self.checkpoint()
|
||||
before = sorted(str(p.relative_to(self.base)) for p in self.base.rglob('*'))
|
||||
self.session.restore(cp['id'], dry_run=True)
|
||||
self.assertEqual(before, sorted(str(p.relative_to(self.base)) for p in self.base.rglob('*')))
|
||||
(self.esp / 'EFI/HydraOS/escape').symlink_to(self.base / 'foreign')
|
||||
with self.assertRaises(r.RecoveryError): self.session.restore(cp['id'], dry_run=True)
|
||||
def test_case_alias_of_owned_directory_refused(self):
|
||||
cp = self.checkpoint()
|
||||
(self.esp / 'EFI/HydraOS').rename(self.esp / 'EFI/hydraos')
|
||||
with self.assertRaises(r.RecoveryError): self.session.restore(cp['id'])
|
||||
self.assertFalse((self.top / '@snapshots/restore.json').exists())
|
||||
def test_unexpected_checkpoint_file_refused(self):
|
||||
cp = self.checkpoint()
|
||||
(self.top / '@snapshots' / cp['id'] / 'unexpected').write_text('bad')
|
||||
with self.assertRaises(r.RecoveryError): self.session.restore(cp['id'])
|
||||
def test_fresh_process_death_between_mutation_and_journal(self):
|
||||
cp = self.checkpoint()
|
||||
for point in ('preparing', 'snapshot', 'first-rename', 'second-rename', 'efi-copy'):
|
||||
script = '''import os, sys
|
||||
from pathlib import Path
|
||||
from hydra import system_recovery as r
|
||||
sys.path.insert(0, 'tests')
|
||||
from test_system_recovery import FakeBtrfs, MARKER
|
||||
b = FakeBtrfs()
|
||||
s = r.RecoverySession(Path(sys.argv[1]), Path(sys.argv[2]), b, MARKER)
|
||||
point = sys.argv[4]
|
||||
save = s.save_journal
|
||||
def crash(value, phase):
|
||||
save(value, phase)
|
||||
if phase == point: os._exit(77)
|
||||
s.save_journal = crash
|
||||
snapshot = b.snapshot
|
||||
def interrupted_snapshot(*args):
|
||||
result = snapshot(*args)
|
||||
if point == 'snapshot': os._exit(77)
|
||||
return result
|
||||
b.snapshot = interrupted_snapshot
|
||||
rename = r.os.rename
|
||||
def interrupted_rename(source, dest):
|
||||
rename(source, dest)
|
||||
if (point == 'first-rename' and Path(source).name == '@root') or (point == 'second-rename' and Path(dest).name == '@root'): os._exit(77)
|
||||
r.os.rename = interrupted_rename
|
||||
copytree = r.shutil.copytree
|
||||
def interrupted_copytree(*args, **kwargs):
|
||||
if point == 'efi-copy' and Path(args[0]).name == 'efi': os._exit(77)
|
||||
return copytree(*args, **kwargs)
|
||||
if point == 'efi-copy': r.shutil.copytree = interrupted_copytree
|
||||
s.restore(sys.argv[3])
|
||||
'''
|
||||
with self.subTest(point=point):
|
||||
result = subprocess.run([sys.executable, '-c', script, str(self.top), str(self.esp), cp['id'], point])
|
||||
self.assertEqual(result.returncode, 77)
|
||||
# A different checkpoint must never take over this transaction.
|
||||
with self.assertRaises(r.RecoveryError): self.session.restore(str(uuid.uuid4()))
|
||||
subprocess.run([sys.executable, '-c', script, str(self.top), str(self.esp), cp['id'], 'none'], check=True)
|
||||
self.assertEqual((self.top / '@root/kernel').read_text(), 'old kernel')
|
||||
|
||||
class NativeBoundaryTests(unittest.TestCase):
|
||||
def test_failed_native_command_includes_bounded_stderr(self):
|
||||
backend = r.Btrfs()
|
||||
error = subprocess.CalledProcessError(32, ['mount'], stderr='specific mount failure\n' + 'x' * 5000)
|
||||
with patch.object(r.subprocess, 'run', side_effect=error):
|
||||
with self.assertRaisesRegex(r.RecoveryError, 'specific mount failure') as caught:
|
||||
backend.run(['mount', '-t', 'btrfs', '/dev/example', '/run/example'])
|
||||
self.assertLess(len(str(caught.exception)), 2300)
|
||||
|
||||
def test_btrfs_show_parser_and_readonly_property(self):
|
||||
backend = r.Btrfs()
|
||||
sample = '''@snapshots/example/root
|
||||
Name: root
|
||||
UUID: 51111111-2222-3333-4444-555555555555
|
||||
Parent UUID: 61111111-2222-3333-4444-555555555555
|
||||
Received UUID: -
|
||||
Creation time: 2026-10-13 10:15:03 +0000
|
||||
Subvolume ID: 259
|
||||
Flags: readonly
|
||||
Snapshot(s):
|
||||
'''
|
||||
with patch.object(backend, 'run', side_effect=[sample, 'ro=true\n']):
|
||||
self.assertEqual(backend.info('/mnt/snapshot'), dict(uuid='51111111-2222-3333-4444-555555555555',
|
||||
parent_uuid='61111111-2222-3333-4444-555555555555', readonly=True))
|
||||
def test_failed_mount_never_unmounts_unowned_path(self):
|
||||
backend = r.Btrfs()
|
||||
calls = []
|
||||
def fail(argv):
|
||||
calls.append(argv)
|
||||
raise r.RecoveryError('mount failed')
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
own = Path(temp) / 'own'; own.mkdir()
|
||||
with patch.object(r.tempfile, 'mkdtemp', return_value=str(own)), patch.object(backend, 'run', side_effect=fail):
|
||||
with self.assertRaises(r.RecoveryError):
|
||||
with r.mounted(backend, '/dev/fake', 'btrfs'): pass
|
||||
self.assertEqual([argv[0] for argv in calls], ['mount'])
|
||||
self.assertFalse(own.exists())
|
||||
def test_multidevice_mount_is_rejected_and_owned_mount_cleaned(self):
|
||||
backend = r.Btrfs()
|
||||
calls = []
|
||||
def run(argv):
|
||||
calls.append(argv)
|
||||
return 'Total devices 2\n devid 1 size 100\n devid 2 size 100' if argv[0] == 'btrfs' else ''
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
own = Path(temp) / 'own'; own.mkdir()
|
||||
with patch.object(r.tempfile, 'mkdtemp', return_value=str(own)), patch.object(backend, 'run', side_effect=run):
|
||||
with self.assertRaises(r.RecoveryError):
|
||||
with r.mounted(backend, '/dev/fake', 'btrfs'): pass
|
||||
self.assertEqual([argv[0] for argv in calls], ['mount', 'btrfs', 'umount'])
|
||||
self.assertIn('ro,nosuid,nodev,noexec,subvolid=5,rescue=nologreplay', calls[0])
|
||||
def test_cli_requires_matching_confirmation_before_probe(self):
|
||||
with patch.object(r, 'DeviceProbe') as probe, patch.object(sys, 'stderr', new_callable=io.StringIO):
|
||||
self.assertEqual(r.main(['restore', '--root-uuid', ROOT_UUID, '--checkpoint', ROOT_PART, '--execute']), 1)
|
||||
probe.assert_not_called()
|
||||
|
||||
|
||||
class DeviceStateTests(unittest.TestCase):
|
||||
def test_fake_proc_sys_mount_swap_and_holders_fail_closed(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
base = Path(temp)
|
||||
proc, sysroot = base / 'proc', base / 'sys'
|
||||
proc.mkdir(); (proc / 'self').mkdir()
|
||||
(proc / 'self/mountinfo').write_text('')
|
||||
(proc / 'swaps').write_text('Filename Type Size Used Priority\n')
|
||||
physical = sysroot / 'devices/disk/part'
|
||||
physical.mkdir(parents=True)
|
||||
(physical / 'partition').write_text('2')
|
||||
(physical / 'holders').mkdir(); (physical.parent / 'holders').mkdir()
|
||||
(sysroot / 'dev/block').mkdir(parents=True)
|
||||
(sysroot / 'dev/block/8:2').symlink_to(physical)
|
||||
node = GuardTests().devices()[0]['children'][1]
|
||||
original_stat = Path.stat
|
||||
def device_stat(path, *args, **kwargs):
|
||||
if str(path) in ('/dev/vda2', '/dev/alias'):
|
||||
return SimpleNamespace(st_mode=stat.S_IFBLK, st_rdev=os.makedev(8, 2))
|
||||
return original_stat(path, *args, **kwargs)
|
||||
with patch.object(Path, 'stat', device_stat):
|
||||
probe = r.DeviceProbe(r.Btrfs(), proc=proc, sysroot=sysroot)
|
||||
probe.check(node)
|
||||
(proc / 'self/mountinfo').write_text('1 0 0:99 / /mnt rw - btrfs /dev/alias rw\n')
|
||||
with self.assertRaises(r.RecoveryError): probe.check(node)
|
||||
(proc / 'self/mountinfo').write_text('')
|
||||
(proc / 'swaps').write_text('Filename Type Size Used Priority\n/dev/vda2 partition 100 0 -2\n')
|
||||
with self.assertRaises(r.RecoveryError): probe.check(node)
|
||||
(proc / 'swaps').write_text('Filename Type Size Used Priority\n')
|
||||
(physical / 'holders/dm-0').touch()
|
||||
with self.assertRaises(r.RecoveryError): probe.check(node)
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,575 @@
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
import warnings
|
||||
import zipfile
|
||||
|
||||
ENGINE = Path(__file__).resolve().parents[1] / 'hydra/update_engine.py'
|
||||
|
||||
def engine():
|
||||
spec = importlib.util.spec_from_file_location('update_engine', ENGINE)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
class UpdateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.base = Path(self.tmp.name)
|
||||
self.root = self.base / 'root'
|
||||
for name in ('usr/lib/hydra/hydra', 'usr/share/hydra', 'etc', 'var/lib', 'home/live/VMs'):
|
||||
(self.root / name).mkdir(parents=True, exist_ok=True)
|
||||
(self.root / 'usr/share/hydra/build-manifest.json').write_text('{}')
|
||||
self.target = self.root / 'usr/lib/hydra/hydra/cli.py'
|
||||
self.target.write_bytes(b'old runtime\n')
|
||||
self.target.chmod(0o640)
|
||||
self.vm = self.root / 'home/live/VMs/disk.qcow2'
|
||||
self.vm.write_bytes(b'guest disk unchanged')
|
||||
self.key = self.base / 'private.pem'
|
||||
self.pub = self.base / 'public.pem'
|
||||
subprocess.run(['openssl', 'genpkey', '-algorithm', 'ED25519', '-out', str(self.key)], check=True, capture_output=True)
|
||||
subprocess.run(['openssl', 'pkey', '-in', str(self.key), '-pubout', '-out', str(self.pub)], check=True, capture_output=True)
|
||||
self.artifacts = self.base / 'artifacts'
|
||||
self.artifacts.mkdir()
|
||||
|
||||
def package(self, *, files=None, sequence=1, entries=None, raw=None):
|
||||
files = files or {'usr/lib/hydra/hydra/cli.py': b'new runtime\n', 'etc/motd': b'Welcome\n'}
|
||||
with zipfile.ZipFile(self.artifacts / 'runtime.zip', 'w') as archive:
|
||||
for name, data in (entries or list(files.items())):
|
||||
info = zipfile.ZipInfo(name)
|
||||
info.create_system = 3
|
||||
info.external_attr = (stat.S_IFREG | 0o644) << 16
|
||||
archive.writestr(info, data)
|
||||
data = (self.artifacts / 'runtime.zip').read_bytes()
|
||||
manifest = {'format': 1, 'product': 'Hydra OS', 'scope': 'runtime', 'channel': 'stable',
|
||||
'architecture': 'x86_64', 'sequence': sequence, 'version': '1.2.3',
|
||||
'source_commit': 'a' * 40,
|
||||
'archive': {'name': 'runtime.zip', 'size': len(data), 'sha256': hashlib.sha256(data).hexdigest()},
|
||||
'files': {name: {'size': len(content), 'sha256': hashlib.sha256(content).hexdigest(), 'mode': 0o644}
|
||||
for name, content in files.items()}}
|
||||
self.sign(raw if raw is not None else json.dumps(manifest).encode())
|
||||
return manifest
|
||||
|
||||
def sign(self, raw):
|
||||
(self.artifacts / 'manifest.json').write_bytes(raw)
|
||||
subprocess.run(['openssl', 'pkeyutl', '-sign', '-rawin', '-inkey', str(self.key), '-in',
|
||||
str(self.artifacts / 'manifest.json'), '-out', str(self.artifacts / 'manifest.sig')], check=True, capture_output=True)
|
||||
|
||||
def updater(self):
|
||||
return engine().Updater(self.root, self.pub)
|
||||
|
||||
def test_signed_apply_and_rollback_restore_bytes_mode_absence_and_keep_replay_floor(self):
|
||||
self.package()
|
||||
updater = self.updater()
|
||||
result = updater.apply(self.artifacts)
|
||||
self.assertEqual(self.target.read_bytes(), b'new runtime\n')
|
||||
self.assertEqual(self.vm.read_bytes(), b'guest disk unchanged')
|
||||
updater.rollback(result['transaction'])
|
||||
self.assertEqual(self.target.read_bytes(), b'old runtime\n')
|
||||
self.assertEqual(stat.S_IMODE(self.target.stat().st_mode), 0o640)
|
||||
self.assertFalse((self.root / 'etc/motd').exists())
|
||||
self.assertEqual(updater.status()['highest_sequence'], 1)
|
||||
with self.assertRaisesRegex(ValueError, 'sequence|replay'):
|
||||
updater.apply(self.artifacts)
|
||||
|
||||
def test_tampered_signature_payload_duplicate_json_and_forbidden_paths_fail_before_state(self):
|
||||
for kind in ('signature', 'payload', 'duplicate_json', 'forbidden', 'duplicate_zip', 'boolean_sequence'):
|
||||
with self.subTest(kind=kind):
|
||||
self.package()
|
||||
if kind == 'signature':
|
||||
(self.artifacts / 'manifest.json').write_bytes((self.artifacts / 'manifest.json').read_bytes() + b' ')
|
||||
elif kind == 'payload':
|
||||
with (self.artifacts / 'runtime.zip').open('ab') as stream:
|
||||
stream.write(b'changed')
|
||||
elif kind == 'duplicate_json':
|
||||
raw = (self.artifacts / 'manifest.json').read_bytes().replace(b'"format": 1', b'"format": 1, "format": 1')
|
||||
self.sign(raw)
|
||||
elif kind == 'forbidden':
|
||||
self.package(files={'home/live/VMs/disk.qcow2': b'evil'})
|
||||
elif kind == 'boolean_sequence':
|
||||
self.package(sequence=True)
|
||||
else:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('ignore')
|
||||
self.package(entries=[('usr/lib/hydra/hydra/cli.py', b'new runtime\n')] * 2)
|
||||
with self.assertRaises(ValueError):
|
||||
self.updater().apply(self.artifacts)
|
||||
self.assertEqual(self.target.read_bytes(), b'old runtime\n')
|
||||
self.assertFalse((self.root / 'var/lib/hydra-update').exists())
|
||||
|
||||
def test_symlink_leaf_restored_but_unsafe_parent_refused(self):
|
||||
self.package()
|
||||
self.target.unlink()
|
||||
self.target.symlink_to('/outside/missing')
|
||||
updater = self.updater()
|
||||
result = updater.apply(self.artifacts)
|
||||
updater.rollback(result['transaction'])
|
||||
self.assertEqual(os.readlink(self.target), '/outside/missing')
|
||||
self.target.unlink()
|
||||
self.target.parent.rmdir()
|
||||
self.target.parent.symlink_to(self.base)
|
||||
self.package(sequence=2)
|
||||
with self.assertRaisesRegex(ValueError, 'symlink|parent'):
|
||||
updater.apply(self.artifacts)
|
||||
|
||||
def test_rollback_refuses_modified_runtime_and_corrupt_backup(self):
|
||||
self.package()
|
||||
updater = self.updater()
|
||||
result = updater.apply(self.artifacts)
|
||||
self.target.write_bytes(b'local edit')
|
||||
with self.assertRaisesRegex(ValueError, 'modified'):
|
||||
updater.rollback(result['transaction'])
|
||||
self.target.write_bytes(b'new runtime\n')
|
||||
backups = list((self.root / 'var/lib/hydra-update/transactions' / result['transaction'] / 'backup').iterdir())
|
||||
backups[0].write_bytes(b'corrupt')
|
||||
with self.assertRaisesRegex(ValueError, 'backup'):
|
||||
updater.rollback(result['transaction'])
|
||||
self.assertEqual(self.target.read_bytes(), b'new runtime\n')
|
||||
|
||||
def test_rejects_signed_special_archive_types_sizes_traversal_and_engine_updates(self):
|
||||
for kind in ('symlink', 'fifo', 'oversize', 'traversal', 'engine'):
|
||||
with self.subTest(kind=kind):
|
||||
manifest = self.package()
|
||||
if kind in ('traversal', 'engine'):
|
||||
name = '../etc/passwd' if kind == 'traversal' else 'usr/lib/hydra/hydra/update_engine.py'
|
||||
self.package(files={name: b'bad'})
|
||||
elif kind == 'oversize':
|
||||
manifest['files']['usr/lib/hydra/hydra/cli.py']['size'] = 5 * 1024 * 1024
|
||||
self.sign(json.dumps(manifest).encode())
|
||||
else:
|
||||
with zipfile.ZipFile(self.artifacts / 'runtime.zip', 'w') as archive:
|
||||
for name, content in {'usr/lib/hydra/hydra/cli.py': b'new runtime\n', 'etc/motd': b'Welcome\n'}.items():
|
||||
info = zipfile.ZipInfo(name)
|
||||
info.create_system = 3
|
||||
info.external_attr = ((stat.S_IFLNK if kind == 'symlink' else stat.S_IFIFO) | 0o644) << 16
|
||||
archive.writestr(info, content)
|
||||
data = (self.artifacts / 'runtime.zip').read_bytes()
|
||||
manifest['archive'].update(size=len(data), sha256=hashlib.sha256(data).hexdigest())
|
||||
self.sign(json.dumps(manifest).encode())
|
||||
with self.assertRaises(ValueError):
|
||||
self.updater().apply(self.artifacts)
|
||||
self.assertFalse((self.root / 'var/lib/hydra-update').exists())
|
||||
|
||||
def test_fresh_process_recovers_sigkill_during_apply_and_rollback_with_runtime_broken(self):
|
||||
self.package()
|
||||
for operation in ('apply', 'rollback'):
|
||||
with self.subTest(operation=operation):
|
||||
if operation == 'rollback':
|
||||
self.package(sequence=2)
|
||||
transaction = self.updater().apply(self.artifacts)['transaction']
|
||||
call = 'u.rollback(' + repr(transaction) + ')'
|
||||
else:
|
||||
call = 'u.apply(Path(' + repr(str(self.artifacts)) + '))'
|
||||
ready = self.base / ('ready-' + operation)
|
||||
script = '''import os, runpy, signal
|
||||
from pathlib import Path
|
||||
m = runpy.run_path(ENGINE)
|
||||
u = m['Updater'](Path(ROOT), Path(KEY))
|
||||
original = m['atomic_write']
|
||||
def pause(path, *args, **kwargs):
|
||||
original(path, *args, **kwargs)
|
||||
if path == Path(TARGET):
|
||||
Path(READY).touch()
|
||||
os.kill(os.getpid(), signal.SIGSTOP)
|
||||
u.apply.__func__.__globals__['atomic_write'] = pause
|
||||
CALL
|
||||
'''
|
||||
for key, value in [('ENGINE', str(ENGINE)), ('ROOT', str(self.root)), ('KEY', str(self.pub)), ('TARGET', str(self.target)), ('READY', str(ready))]:
|
||||
script = script.replace(key, repr(value))
|
||||
script = script.replace('CALL', call)
|
||||
proc = subprocess.Popen([sys.executable, '-I', '-c', script], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
try:
|
||||
deadline = time.monotonic() + 10
|
||||
while not ready.exists() and proc.poll() is None and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
self.assertTrue(ready.exists(), 'child failed to reach actual mutation')
|
||||
proc.kill()
|
||||
proc.communicate(timeout=5)
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
proc.communicate()
|
||||
self.assertEqual(len(self.updater().status()['pending']), 1)
|
||||
with self.assertRaisesRegex(ValueError, 'pending'):
|
||||
self.updater().apply(self.artifacts)
|
||||
# No hydra imports: corrupt the normal runtime before standalone recovery.
|
||||
self.target.write_bytes(b'this is invalid Python')
|
||||
result = subprocess.run([sys.executable, '-I', str(ENGINE.parent.parent / 'bin/hydra-update-recover'),
|
||||
'--root', str(self.root), '--public-key', str(self.pub)], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(self.target.read_bytes(), b'old runtime\n')
|
||||
self.assertEqual(self.updater().status()['pending'], [])
|
||||
self.assertEqual(self.vm.read_bytes(), b'guest disk unchanged')
|
||||
self.assertEqual(self.updater().status()['highest_sequence'], 1 if operation == 'apply' else 2)
|
||||
|
||||
def test_lock_in_separate_process_prevents_mutation(self):
|
||||
self.package()
|
||||
updater = self.updater()
|
||||
with updater.lock():
|
||||
proc = subprocess.run([sys.executable, '-I', str(ENGINE), 'apply', '--root', str(self.root),
|
||||
'--public-key', str(self.pub), '--artifacts', str(self.artifacts)], capture_output=True, text=True)
|
||||
self.assertNotEqual(proc.returncode, 0)
|
||||
self.assertIn('lock', proc.stderr)
|
||||
self.assertEqual(self.target.read_bytes(), b'old runtime\n')
|
||||
|
||||
def test_only_current_transaction_can_rollback(self):
|
||||
self.package()
|
||||
first = self.updater().apply(self.artifacts)['transaction']
|
||||
self.package(sequence=2)
|
||||
self.updater().apply(self.artifacts)
|
||||
with self.assertRaisesRegex(ValueError, 'latest'):
|
||||
self.updater().rollback(first)
|
||||
|
||||
def test_transport_rejects_origin_changes_credentials_and_oversized_reads(self):
|
||||
module = engine()
|
||||
for url in ('http://git.shanaty.ru/file', 'https://evil.example/file',
|
||||
'https://git.shanaty.ru@evil.example/file', 'https://git.shanaty.ru:443/file',
|
||||
'https://git.shanaty.ru/../file', 'https://git.shanaty.ru/%2e%2e/file'):
|
||||
with self.subTest(url=url), self.assertRaises(ValueError):
|
||||
module.checked_url(url)
|
||||
redirect = module.SafeRedirect()
|
||||
request = __import__('urllib.request', fromlist=['Request']).Request('https://git.shanaty.ru/file')
|
||||
with self.assertRaises(ValueError):
|
||||
redirect.redirect_request(request, None, 302, '', {}, 'https://other.example/file')
|
||||
import io
|
||||
from unittest.mock import patch
|
||||
with patch('urllib.request.OpenerDirector.open', return_value=io.BytesIO(b'x' * 100)):
|
||||
with self.assertRaisesRegex(ValueError, 'size|large'):
|
||||
module.fetch('https://git.shanaty.ru/file', 10)
|
||||
|
||||
def test_empty_releases_and_verified_local_check_do_not_create_state(self):
|
||||
module = engine()
|
||||
from unittest.mock import patch
|
||||
from urllib.error import HTTPError
|
||||
def empty_releases(url, limit):
|
||||
if url == module.RELEASES:
|
||||
return b'[]'
|
||||
if url == 'https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/manifest.json':
|
||||
raise HTTPError(url, 404, 'Not Found', {}, None)
|
||||
raise AssertionError('Unexpected discovery URL: ' + url)
|
||||
with patch.object(module, 'fetch', side_effect=empty_releases):
|
||||
self.assertIsNone(module.discover(self.pub))
|
||||
self.package()
|
||||
result = subprocess.run([sys.executable, '-I', str(ENGINE), 'check', '--root', str(self.root),
|
||||
'--public-key', str(self.pub), '--artifacts', str(self.artifacts)], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(json.loads(result.stdout)['available']['sequence'], 1)
|
||||
self.assertFalse((self.root / 'var/lib/hydra-update').exists())
|
||||
|
||||
def test_release_builder_is_reproducible_and_signs_passive_payload_only(self):
|
||||
repo = ENGINE.parent.parent
|
||||
output_a = self.base / 'release-a'
|
||||
output_b = self.base / 'release-b'
|
||||
self.key.chmod(0o600)
|
||||
command = [sys.executable, str(repo / 'dev/release.py'), '--source', str(repo),
|
||||
'--private-key', str(self.key), '--version', '1.0.0', '--sequence', '10']
|
||||
for output in (output_a, output_b):
|
||||
result = subprocess.run(command + ['--output', str(output)], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
for name in ('manifest.json', 'manifest.sig', 'runtime.zip'):
|
||||
self.assertEqual((output_a / name).read_bytes(), (output_b / name).read_bytes())
|
||||
module = engine()
|
||||
manifest, payload = self.updater().local(output_a)
|
||||
self.assertEqual(manifest['sequence'], 10)
|
||||
self.assertIn('usr/lib/hydra/hydra/cli.py', payload)
|
||||
self.assertFalse(any('/update' in name for name in payload))
|
||||
self.assertNotIn('__main__.py', zipfile.ZipFile(output_a / 'runtime.zip').namelist())
|
||||
result = subprocess.run(command + ['--output', str(output_a)], capture_output=True, text=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.key.chmod(0o644)
|
||||
result = subprocess.run(command + ['--output', str(self.base / 'unsafe-key')], capture_output=True, text=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse((self.base / 'unsafe-key').exists())
|
||||
|
||||
def test_production_api_cannot_override_pinned_key(self):
|
||||
with self.assertRaisesRegex(ValueError, 'key|trust'):
|
||||
engine().Updater(Path('/'), self.pub)
|
||||
|
||||
def test_corrupt_backup_during_pending_recovery_keeps_journal_and_runtime(self):
|
||||
self.package()
|
||||
updater = self.updater()
|
||||
from unittest.mock import patch
|
||||
globals_ = updater.apply.__func__.__globals__
|
||||
original = globals_['atomic_write']
|
||||
def fail(path, *args, **kwargs):
|
||||
if path == self.target:
|
||||
raise OSError('simulated full filesystem')
|
||||
return original(path, *args, **kwargs)
|
||||
with patch.dict(globals_, atomic_write=fail):
|
||||
with self.assertRaises(OSError):
|
||||
updater.apply(self.artifacts)
|
||||
transaction = updater.status()['pending'][0]
|
||||
journal = self.root / 'var/lib/hydra-update/transactions' / transaction / 'journal.json'
|
||||
saved = journal.read_bytes()
|
||||
backup = next((journal.parent / 'backup').iterdir())
|
||||
backup.write_bytes(b'broken backup')
|
||||
with self.assertRaisesRegex(ValueError, 'backup'):
|
||||
updater.recover()
|
||||
self.assertEqual(journal.read_bytes(), saved)
|
||||
self.assertEqual(self.target.read_bytes(), b'old runtime\n')
|
||||
self.assertEqual(updater.status()['pending'], [transaction])
|
||||
|
||||
def test_unsafe_state_directory_and_backup_symlinks_are_rejected(self):
|
||||
self.package()
|
||||
state = self.root / 'var/lib/hydra-update'
|
||||
outside = self.base / 'outside'
|
||||
outside.mkdir()
|
||||
state.symlink_to(outside)
|
||||
with self.assertRaises(ValueError):
|
||||
self.updater().apply(self.artifacts)
|
||||
self.assertEqual(list(outside.iterdir()), [])
|
||||
state.unlink()
|
||||
result = self.updater().apply(self.artifacts)
|
||||
backup = next((state / 'transactions' / result['transaction'] / 'backup').iterdir())
|
||||
backup.unlink()
|
||||
backup.symlink_to(self.vm)
|
||||
with self.assertRaises(ValueError):
|
||||
self.updater().rollback(result['transaction'])
|
||||
self.assertEqual(self.target.read_bytes(), b'new runtime\n')
|
||||
|
||||
def test_discovery_verifies_manifest_before_archive_and_binds_release_tag(self):
|
||||
module = engine()
|
||||
self.package()
|
||||
from unittest.mock import patch
|
||||
urls = {name: 'https://git.shanaty.ru/attachments/' + name for name in ('manifest.json', 'manifest.sig', 'runtime.zip')}
|
||||
release = {'tag_name': '1.2.3', 'draft': False, 'prerelease': False,
|
||||
'assets': [{'name': name, 'browser_download_url': url} for name, url in urls.items()]}
|
||||
requested = []
|
||||
def fetch(url, limit):
|
||||
requested.append(url)
|
||||
if url == module.RELEASES:
|
||||
return json.dumps([release]).encode()
|
||||
return (self.artifacts / url.rsplit('/', 1)[1]).read_bytes()
|
||||
with patch.object(module, 'fetch', side_effect=fetch):
|
||||
found = module.discover(self.pub)
|
||||
self.assertEqual(found[0]['version'], '1.2.3')
|
||||
self.assertFalse(any('/raw/branch/' in url for url in requested))
|
||||
self.assertNotIn(urls['runtime.zip'], requested)
|
||||
release['tag_name'] = 'latest'
|
||||
with self.assertRaisesRegex(ValueError, 'tag'):
|
||||
module.discover(self.pub)
|
||||
release['tag_name'] = '1.2.3'
|
||||
(self.artifacts / 'manifest.json').write_bytes(b'bad manifest')
|
||||
with self.assertRaisesRegex(ValueError, 'signature'):
|
||||
module.discover(self.pub)
|
||||
self.assertNotIn(urls['runtime.zip'], requested)
|
||||
|
||||
def test_missing_or_unwritable_parent_and_special_destination_rejected_before_state(self):
|
||||
self.package()
|
||||
self.target.unlink()
|
||||
os.mkfifo(self.target)
|
||||
with self.assertRaises(ValueError):
|
||||
self.updater().apply(self.artifacts)
|
||||
self.assertFalse((self.root / 'var/lib/hydra-update').exists())
|
||||
self.target.unlink()
|
||||
self.target.parent.chmod(0o777)
|
||||
with self.assertRaises(ValueError):
|
||||
self.updater().apply(self.artifacts)
|
||||
self.assertFalse((self.root / 'var/lib/hydra-update').exists())
|
||||
|
||||
def test_signature_wrong_key_and_truncated_archive_fail_without_changes(self):
|
||||
self.package()
|
||||
other = self.base / 'other.pem'
|
||||
otherpub = self.base / 'other.pub'
|
||||
subprocess.run(['openssl', 'genpkey', '-algorithm', 'ED25519', '-out', str(other)], check=True, capture_output=True)
|
||||
subprocess.run(['openssl', 'pkey', '-in', str(other), '-pubout', '-out', str(otherpub)], check=True, capture_output=True)
|
||||
with self.assertRaisesRegex(ValueError, 'signature'):
|
||||
engine().Updater(self.root, otherpub).apply(self.artifacts)
|
||||
data = (self.artifacts / 'runtime.zip').read_bytes()
|
||||
(self.artifacts / 'runtime.zip').write_bytes(data[:20])
|
||||
with self.assertRaises(ValueError):
|
||||
self.updater().apply(self.artifacts)
|
||||
self.assertFalse((self.root / 'var/lib/hydra-update').exists())
|
||||
|
||||
def test_status_remains_readable_when_private_state_cannot_be_read(self):
|
||||
self.package()
|
||||
updater = self.updater()
|
||||
applied = updater.apply(self.artifacts)
|
||||
state = self.root / 'var/lib/hydra-update'
|
||||
state.chmod(0)
|
||||
try:
|
||||
result = updater.status()
|
||||
self.assertEqual(result['current']['transaction'], applied['transaction'])
|
||||
self.assertEqual(result['highest_sequence'], 1)
|
||||
self.assertEqual(result['pending'], [])
|
||||
finally:
|
||||
state.chmod(0o700)
|
||||
|
||||
def test_recovery_rejects_corrupt_journal_paths_before_touching_runtime(self):
|
||||
self.package()
|
||||
updater = self.updater()
|
||||
transaction = updater.apply(self.artifacts)['transaction']
|
||||
path = self.root / 'var/lib/hydra-update/transactions' / transaction / 'journal.json'
|
||||
journal = json.loads(path.read_text())
|
||||
journal['phase'] = 'prepared'
|
||||
journal['originals']['home/live/VMs/disk.qcow2'] = {'type': 'absent'}
|
||||
journal['files']['home/live/VMs/disk.qcow2'] = {'mode': 0o644, 'size': 1, 'sha256': 'a' * 64}
|
||||
path.write_text(json.dumps(journal))
|
||||
with self.assertRaisesRegex(ValueError, 'backup path'):
|
||||
updater.recover()
|
||||
self.assertEqual(self.target.read_bytes(), b'new runtime\n')
|
||||
self.assertEqual(self.vm.read_bytes(), b'guest disk unchanged')
|
||||
|
||||
def test_prepared_journal_without_state_is_recoverable_and_reserves_sequence(self):
|
||||
self.package()
|
||||
updater = self.updater()
|
||||
from unittest.mock import patch
|
||||
globals_ = updater.apply.__func__.__globals__
|
||||
original = globals_['write_json']
|
||||
def crash(path, *args, **kwargs):
|
||||
if path == self.root / 'var/lib/hydra-update/state.json':
|
||||
raise OSError('power lost after durable journal before ledger')
|
||||
return original(path, *args, **kwargs)
|
||||
with patch.dict(globals_, write_json=crash):
|
||||
with self.assertRaises(OSError):
|
||||
updater.apply(self.artifacts)
|
||||
self.assertEqual(len(updater.status()['pending']), 1)
|
||||
updater.recover()
|
||||
self.assertEqual(updater.status()['highest_sequence'], 1)
|
||||
self.assertEqual(self.target.read_bytes(), b'old runtime\n')
|
||||
|
||||
def test_fresh_recovery_reconciles_public_status_after_terminal_journal_before_publish(self):
|
||||
self.package()
|
||||
updater = self.updater()
|
||||
from unittest.mock import patch
|
||||
original_publish = updater.publish
|
||||
def interrupted_publish():
|
||||
if not updater.pending():
|
||||
raise OSError('power lost after terminal journal before public status')
|
||||
return original_publish()
|
||||
for operation in ('apply', 'rollback'):
|
||||
with self.subTest(operation=operation):
|
||||
with patch.object(updater, 'publish', side_effect=interrupted_publish):
|
||||
with self.assertRaisesRegex(OSError, 'terminal journal'):
|
||||
if operation == 'apply':
|
||||
updater.apply(self.artifacts)
|
||||
else:
|
||||
updater.rollback(updater.state()['current']['transaction'])
|
||||
authoritative = updater.status()
|
||||
self.assertEqual(authoritative['pending'], [])
|
||||
stale = json.loads(updater.public_status.read_text())
|
||||
self.assertNotEqual(stale, authoritative)
|
||||
result = subprocess.run([sys.executable, '-I', str(ENGINE.parent.parent / 'bin/hydra-update-recover'),
|
||||
'--root', str(self.root), '--public-key', str(self.pub)], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(json.loads(result.stdout), [])
|
||||
self.assertEqual(json.loads(updater.public_status.read_text()), authoritative)
|
||||
self.assertEqual(self.target.read_bytes(), b'new runtime\n' if operation == 'apply' else b'old runtime\n')
|
||||
updater.base.chmod(0)
|
||||
try:
|
||||
self.assertEqual(updater.status(), authoritative)
|
||||
finally:
|
||||
updater.base.chmod(0o700)
|
||||
|
||||
|
||||
def test_signed_repository_fallback_applies_and_preserves_replay_protection(self):
|
||||
self.package()
|
||||
module = engine()
|
||||
from unittest.mock import patch
|
||||
from contextlib import redirect_stdout, redirect_stderr
|
||||
import io
|
||||
base = 'https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/'
|
||||
requested = []
|
||||
def fetch(url, limit):
|
||||
requested.append(url)
|
||||
if url == module.RELEASES:
|
||||
return b'[]'
|
||||
if url not in {base + name for name in ('manifest.json', 'manifest.sig', 'runtime.zip')}:
|
||||
raise AssertionError('Unexpected fallback URL: ' + url)
|
||||
return (self.artifacts / url.rsplit('/', 1)[1]).read_bytes()
|
||||
with patch.object(module, 'fetch', side_effect=fetch):
|
||||
found = module.discover(self.pub)
|
||||
self.assertIsNotNone(found)
|
||||
self.assertEqual(found[0]['version'], '1.2.3')
|
||||
self.assertEqual(found[3], base + 'runtime.zip')
|
||||
self.assertNotIn(base + 'runtime.zip', requested)
|
||||
with redirect_stdout(io.StringIO()) as output:
|
||||
code = module.main(['apply', '--root', str(self.root), '--public-key', str(self.pub)])
|
||||
self.assertEqual(code, 0)
|
||||
self.assertTrue(json.loads(output.getvalue())['applied'])
|
||||
self.assertEqual(self.target.read_bytes(), b'new runtime\n')
|
||||
with redirect_stderr(io.StringIO()) as error:
|
||||
code = module.main(['apply', '--root', str(self.root), '--public-key', str(self.pub)])
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn('replay', error.getvalue())
|
||||
self.assertEqual(self.vm.read_bytes(), b'guest disk unchanged')
|
||||
|
||||
def test_repository_fallback_refuses_tamper_missing_signature_and_server_errors(self):
|
||||
self.package()
|
||||
module = engine()
|
||||
from unittest.mock import patch
|
||||
from urllib.error import HTTPError
|
||||
base = 'https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/'
|
||||
for failure in ('signature', 'missing_signature', 'server_error'):
|
||||
with self.subTest(failure=failure):
|
||||
requested = []
|
||||
def fetch(url, limit):
|
||||
requested.append(url)
|
||||
if url == module.RELEASES:
|
||||
return b'[]'
|
||||
if url == base + 'manifest.json':
|
||||
if failure == 'server_error':
|
||||
raise HTTPError(url, 503, 'Unavailable', {}, None)
|
||||
return (self.artifacts / 'manifest.json').read_bytes()
|
||||
if url == base + 'manifest.sig':
|
||||
if failure == 'missing_signature':
|
||||
raise HTTPError(url, 404, 'Not Found', {}, None)
|
||||
return b'x' * 64
|
||||
raise AssertionError('Unsigned archive must not be fetched')
|
||||
with patch.object(module, 'fetch', side_effect=fetch):
|
||||
with self.assertRaises((ValueError, HTTPError)):
|
||||
module.discover(self.pub)
|
||||
self.assertNotIn(base + 'runtime.zip', requested)
|
||||
self.assertFalse((self.root / 'var/lib/hydra-update').exists())
|
||||
|
||||
def test_native_release_errors_do_not_fall_back_to_repository_assets(self):
|
||||
self.package()
|
||||
module = engine()
|
||||
from unittest.mock import patch
|
||||
from urllib.error import HTTPError
|
||||
assets = [{'name': name, 'browser_download_url': 'https://git.shanaty.ru/attachments/' + name}
|
||||
for name in ('manifest.json', 'manifest.sig', 'runtime.zip')]
|
||||
for failure in ('tag', 'incomplete', 'signature', 'api_unavailable'):
|
||||
with self.subTest(failure=failure):
|
||||
def fetch(url, limit):
|
||||
if '/raw/branch/' in url:
|
||||
raise AssertionError('API failures must not activate fallback')
|
||||
if url == module.RELEASES:
|
||||
if failure == 'api_unavailable':
|
||||
raise HTTPError(url, 503, 'Unavailable', {}, None)
|
||||
return json.dumps([{'tag_name': 'wrong' if failure == 'tag' else '1.2.3',
|
||||
'assets': assets[:1] if failure == 'incomplete' else assets}]).encode()
|
||||
if failure == 'signature' and url.endswith('manifest.sig'):
|
||||
return b'x' * 64
|
||||
return (self.artifacts / url.rsplit('/', 1)[1]).read_bytes()
|
||||
with patch.object(module, 'fetch', side_effect=fetch):
|
||||
with self.assertRaises((ValueError, HTTPError)):
|
||||
module.discover(self.pub)
|
||||
|
||||
def test_repository_fallback_downloads_use_existing_size_and_redirect_checks(self):
|
||||
module = engine()
|
||||
from unittest.mock import patch
|
||||
import io
|
||||
import urllib.request
|
||||
base = 'https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/'
|
||||
def oversized(request, timeout):
|
||||
if request.full_url == module.RELEASES:
|
||||
return io.BytesIO(b'[]')
|
||||
return io.BytesIO(b'x' * (module.MANIFEST_LIMIT + 1))
|
||||
with patch('urllib.request.OpenerDirector.open', side_effect=oversized):
|
||||
with self.assertRaisesRegex(ValueError, 'size'):
|
||||
module.discover(self.pub)
|
||||
request = urllib.request.Request(base + 'manifest.json')
|
||||
for destination in ('http://git.shanaty.ru/manifest.json', 'https://other.example/manifest.json'):
|
||||
with self.subTest(destination=destination), self.assertRaises(ValueError):
|
||||
module.SafeRedirect().redirect_request(request, None, 302, '', {}, destination)
|
||||
Reference in New Issue
Block a user