138 changed files with 244 additions and 28307 deletions
+4 -129
View File
@@ -1,4 +1,4 @@
# Hydra OS # HydraOS
Gentoo-based host for concurrent virtual desktops. Its default session shows one Gentoo-based host for concurrent virtual desktops. Its default session shows one
VM fullscreen and switches between guests with keyboard shortcuts. Other host VM fullscreen and switches between guests with keyboard shortcuts. Other host
@@ -6,15 +6,9 @@ desktops may use the same independent VM manager and viewers.
## Status ## Status
Hydra OS 0.2.0 adds selected-partition installation, signed runtime updates, Under active development, not yet an installation-ready distribution. The target
offline Btrfs base recovery and read-only laptop diagnostics. The complete source is an installable UEFI ISO, first validated on ASUS TUF Dash F15 FX517ZC. Existing
suite passed **431 tests**. The final ISO passed **13 whole-disk** and **23 NixOS and VM disks are not installation or test targets.
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. 1. Independent QEMU/KVM lifecycle with private QMP control.
2. Minimal labwc session and fullscreen SPICE viewers. 2. Minimal labwc session and fullscreen SPICE viewers.
@@ -28,124 +22,5 @@ a refresh-rate setting does not prove delivered FPS.
- [Session design](docs/superpowers/specs/2026-10-13-graphics-session-design.md) - [Session design](docs/superpowers/specs/2026-10-13-graphics-session-design.md)
- [Implementation plan](docs/superpowers/plans/2026-10-13-graphics-session.md) - [Implementation plan](docs/superpowers/plans/2026-10-13-graphics-session.md)
- [Hardware target](docs/hardware/asus-fx517zc.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. 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 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
hydra start work
hydra add experiments --iso /absolute/path/gentoo.iso
hydra start experiments
exit
```
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
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 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 Hydra OS host. Each
guest controls its own services and firewall. Host Internet access is separate
from this local guest network.
Profiles live in `~/.config/hydra`, disks in `~/.local/state/hydra`; system defaults
for new profiles are in `/etc/hydra/defaults.toml`. Use `hydra --help` for overrides.
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-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.
Resume a build from the same source checkout and work directory used to create
its builder. Moving from a worktree to another checkout requires a fresh
`--work` directory under the project, or using the original worktree; provenance
checks deliberately reject an existing container mounted to a different source.
## Run the prototype
```sh
python3 -m unittest discover -s tests -v
nix-shell dev/tools.nix
bin/hydra add guest --iso /absolute/path/installer.iso
bin/hydra start guest
bin/hydra view guest
# From a dedicated graphical login; --nested uses the current graphical display.
bin/hydra session --nested
```
After installation, shut the guest down and run `bin/hydra media guest --detach`
to boot its existing disk without installation media. `add` also supports disk-only
profiles, `--width`, `--height`, and `--refresh-millihz`. Distribution preferences
in `/etc/hydra/defaults.toml` affect only new profiles; explicit CLI options win.
`nix-shell dev/tools.nix --run 'python3 dev/smoke.py'` checks two disposable KVM
counter guests and private headless viewers, including manual USB selection,
reserved switching under inhibition, recovery and reconnect. See
[verification commands and physical checks still pending](docs/verification.md).
An empty session opens local recovery. Enter `:shell`, create and start guests
with `hydra add` / `hydra start`, then leave the shell and enter `:refresh`.
Refresh rebuilds workspaces and reconnects viewers for the current profiles;
running VMs keep their processes and disks. Use it after adding or removing
profiles. New profiles save an individual MAC address; legacy profiles obtain
a stable address from their owner and disk path, including on the shared bridge.
-30
View File
@@ -1,30 +0,0 @@
# 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.
-15
View File
@@ -1,15 +0,0 @@
{
"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.

Before

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Run Hydra OS from this checkout without installing a host package.""" """Run HydraOS from this checkout without installing a host package."""
import sys import sys
from pathlib import Path from pathlib import Path
-11
View File
@@ -1,11 +0,0 @@
#!/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())
-12
View File
@@ -1,12 +0,0 @@
#!/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']())
-8
View File
@@ -1,8 +0,0 @@
#!/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))
-8
View File
@@ -1,8 +0,0 @@
#!/bin/sh
set -eu
source_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
build_dir=$(mktemp -d /tmp/hydra-input-build-XXXXXX)
wayland-scanner client-header "$source_dir/virtual-keyboard-unstable-v1.xml" "$build_dir/virtual-keyboard-client-protocol.h"
wayland-scanner private-code "$source_dir/virtual-keyboard-unstable-v1.xml" "$build_dir/virtual-keyboard-protocol.c"
cc -Wall -Wextra -Werror "$source_dir/input-probe.c" "$build_dir/virtual-keyboard-protocol.c" -I"$build_dir" $(pkg-config --cflags --libs wayland-client xkbcommon) -o "$build_dir/input-probe"
printf '%s\n' "$build_dir/input-probe"
-100
View File
@@ -1,100 +0,0 @@
#!/usr/bin/env python3
"""Compile actual QEMU EDID code and decode the guest-visible response.
Catches loss of the configured refresh rate, a frontend overriding it, and
monitor ranges excluding the generated preferred mode. No VM is touched.
"""
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import tarfile
source_root = Path(sys.argv[1])
# Accept the pinned QEMU source archive directly. Read only the four files
# needed here, avoiding unrelated upstream absolute symlinks in rom sources.
if source_root.is_file():
extracted = tempfile.TemporaryDirectory(prefix='hydra-edid-source-')
with tarfile.open(source_root) as archive:
members = {name: next(member for member in archive.getmembers()
if member.name.endswith('/' + name)) for name in (
'hw/display/edid-generate.c', 'include/hw/display/edid.h',
'include/hw/virtio/virtio-gpu.h', 'hw/display/virtio-gpu-base.c')}
source_root = Path(extracted.name)
for name, member in members.items():
destination = source_root / name
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(archive.extractfile(member).read())
subprocess.run(['patch', '--batch', '-p1', '-i',
str(Path(__file__).resolve().parents[1] / 'patches/qemu-virtio-refresh-rate.patch')],
cwd=source_root, check=True)
edid = (source_root / 'hw/display/edid-generate.c').read_text()
edid = '\n'.join(line for line in edid.splitlines() if not line.startswith('#include'))
header = (source_root / 'include/hw/display/edid.h').read_text()
header = header[header.index('typedef struct qemu_edid_info'):header.index('void qemu_edid_region_io')]
gpu = (source_root / 'hw/display/virtio-gpu-base.c').read_text()
start = gpu.index('void\nvirtio_gpu_base_generate_edid')
gpu = gpu[start:gpu.index('static void virtio_gpu_invalidate_display', start)]
stubs = r'''
#include <stdint.h>
#include <stddef.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#define ARRAY_SIZE(x) (sizeof(x)/sizeof((x)[0]))
#define MAX(a,b) ((a) > (b) ? (a) : (b))
#define MIN(a,b) ((a) < (b) ? (a) : (b))
#define DIV_ROUND_UP(n,d) (((n) + (d) - 1) / (d))
#define cpu_to_le32(x) (x)
static void stw_le_p(void *p,uint16_t v) { uint8_t *b=p; b[0]=v; b[1]=v>>8; }
static void stw_be_p(void *p,uint16_t v) { uint8_t *b=p; b[1]=v; b[0]=v>>8; }
static void stl_le_p(void *p,uint32_t v) { uint8_t *b=p; for(int i=0;i<4;i++) b[i]=v>>(8*i); }
typedef struct VirtIOGPUOutput { const char *name; } VirtIOGPUOutput;
typedef struct VirtIOGPUOutputList { struct VirtIOGPUOutputList *next; VirtIOGPUOutput *value; } VirtIOGPUOutputList;
struct virtio_gpu_requested_state { uint16_t width_mm,height_mm; uint32_t width,height,refresh_rate; int x,y; };
typedef struct VirtIOGPUBase { struct { uint32_t refresh_rate; VirtIOGPUOutputList *outputs; } conf; struct virtio_gpu_requested_state req_state[1]; } VirtIOGPUBase;
struct virtio_gpu_resp_edid { uint32_t size; uint8_t edid[1024]; };
'''
main = r'''
int main(int argc,char **argv) {
VirtIOGPUBase gpu = {0}; struct virtio_gpu_resp_edid out = {0};
gpu.conf.refresh_rate=atoi(argv[1]); gpu.req_state[0].refresh_rate=atoi(argv[2]);
gpu.req_state[0].width=atoi(argv[3]); gpu.req_state[0].height=atoi(argv[4]);
virtio_gpu_base_generate_edid(&gpu,0,&out);
fwrite(out.edid,1,qemu_edid_size(out.edid),stdout); return 0;
}
'''
compiler = os.environ.get('CC', 'cc')
with tempfile.TemporaryDirectory(prefix='qemu-edid-probe-') as temporary:
directory = Path(temporary)
(directory / 'probe.c').write_text(stubs + header + edid + gpu + main)
subprocess.run([compiler, '-O2', str(directory / 'probe.c'), '-o', str(directory / 'probe')], check=True)
for configured, frontend, width, height, expected in [
(0, 0, 1920, 1080, 75),
(0, 60000, 1920, 1080, 60),
(144000, 0, 1920, 1080, 144),
(144000, 60000, 1920, 1080, 144),
(144000, 0, 1600, 900, 144),
(0, 144000, 1920, 1080, 144),
(50000, 0, 1920, 1080, 50),
(255000, 0, 1280, 800, 255),
]:
data = subprocess.check_output([str(directory / 'probe'), *map(str, (configured, frontend, width, height))])
assert all(sum(data[offset:offset + 128]) % 256 == 0 for offset in range(0, len(data), 128)), 'invalid EDID checksum'
timing = data[54:72]
clock = int.from_bytes(timing[:2], 'little') * 10000
horizontal = timing[2] + ((timing[4] >> 4) << 8)
hblank = timing[3] + ((timing[4] & 15) << 8)
vertical = timing[5] + ((timing[7] >> 4) << 8)
vblank = timing[6] + ((timing[7] & 15) << 8)
actual = clock / (horizontal + hblank) / (vertical + vblank)
assert (horizontal, vertical) == (width, height)
assert abs(actual - expected) < 0.01, f'configured={configured}, frontend={frontend}: wanted {expected} Hz, got {actual}'
ranges = next(data[offset:offset + 18] for offset in (54, 72, 90, 108) if data[offset:offset + 5] == b'\0\0\0\xfd\0')
assert ranges[5] <= actual <= ranges[6], f'vertical range {ranges[5:7]} excludes {actual}'
horizontal_khz = clock / (horizontal + hblank) / 1000
assert ranges[7] <= horizontal_khz <= ranges[8], f'horizontal range {ranges[7:9]} excludes {horizontal_khz}'
if configured == 0 and expected <= 75:
assert ranges[5:9] == bytes((50, 125, 30, 160)), 'legacy monitor range changed'
print(f'PASS configured={configured}, frontend={frontend}: {width}x{height}@{actual:.6f}, ranges={list(ranges[5:9])}')
-78
View File
@@ -1,78 +0,0 @@
#!/usr/bin/env python3
"""Check real NIC identities on a bridge isolated in user/net/mount namespaces."""
import argparse
import json
from pathlib import Path
import subprocess
import sys
import tempfile
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from hydra.profiles import Profile
from hydra.runtime import Runtime
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--namespace-root', type=Path, help=argparse.SUPPRESS)
args = parser.parse_args()
if args.namespace_root is None:
root = Path(tempfile.mkdtemp(prefix='hydra-network-', dir='/tmp'))
print('Evidence: ' + str(root), flush=True)
(root / 'parent-namespaces.json').write_text(json.dumps({name: str(Path('/proc/self/ns', name).readlink())
for name in ('user', 'net', 'mnt')}))
# All bridge and /etc changes below occur after this explicit namespace
# boundary. The inherited host bridge configuration is never modified.
subprocess.run(['unshare', '-Urnm', '--', str(Path(sys.executable).resolve()),
str(Path(__file__).resolve()), '--namespace-root', str(root)], check=True)
return
root = args.namespace_root
parent_namespaces = json.loads((root / 'parent-namespaces.json').read_text())
namespaces = {name: str(Path('/proc/self/ns', name).readlink()) for name in ('user', 'net', 'mnt')}
assert all(namespaces[name] != parent_namespaces[name] for name in namespaces), 'private namespaces required'
private_etc = root / 'etc'
(private_etc / 'qemu').mkdir(parents=True)
(private_etc / 'qemu/bridge.conf').write_text('allow hydrabr0\n')
subprocess.run(['mount', '--bind', str(private_etc), '/etc'], check=True)
subprocess.run(['ip', 'link', 'add', 'hydrabr0', 'type', 'bridge'], check=True)
subprocess.run(['ip', 'link', 'set', 'hydrabr0', 'up'], check=True)
manager = Runtime(root / 'config', root / 'state', root / 'run')
result = {'evidence': str(root), 'namespace': 'private user/net/mount',
'parent_namespaces': parent_namespaces, 'probe_namespaces': namespaces, 'names': {}}
try:
for name in ('alpha', 'beta'):
manager.add(Profile(name, memory=128, cpus=1, disk_size='8M',
graphics='software', network='bridge'))
manager.start(name)
def actual_mac(name):
with manager._connect(manager.paths(name)) as qmp:
network = qmp.command('human-monitor-command', {'command-line': 'info network'})
mac = manager.profile(name).mac
assert 'macaddr=' + mac in network, network
assert int(mac.split(':')[0], 16) & 3 == 2
return mac, network
for name in ('alpha', 'beta'):
mac, network = actual_mac(name)
result['names'][name] = {'mac': mac, 'network': network}
assert result['names']['alpha']['mac'] != result['names']['beta']['mac']
links = json.loads(subprocess.check_output(['ip', '-j', 'link', 'show', 'master', 'hydrabr0'], text=True))
assert len(links) == 2, links
result['bridge_ports'] = [link['ifname'] for link in links]
manager.stop('alpha', force=True)
manager.start('alpha')
assert actual_mac('alpha')[0] == result['names']['alpha']['mac']
result['restart_preserved_mac'] = True
result['dhcp_and_guest_network_drivers_pending'] = True
result['passed'] = True
(root / 'result.json').write_text(json.dumps(result, indent=2) + '\n')
print(json.dumps(result, indent=2))
finally:
for profile in manager.profiles():
if manager.status(profile.name)['status'] == 'running':
manager.stop(profile.name, force=True)
if __name__ == '__main__':
main()
-25
View File
@@ -1,25 +0,0 @@
.code16
.global _start
_start:
cli
xor %ax,%ax
mov %ax,%ds
mov %ax,%ss
mov $0x7c00,%sp
movl $0x48594452,0x508
movl $0,0x500
movw $0,0x504
loop:
incl 0x500
in $0x64,%al
test $1,%al
jz loop
in $0x60,%al
mov 0x504,%bx
mov %al,0x600(%bx)
inc %bx
and $255,%bx
mov %bx,0x504
jmp loop
.org 510
.word 0xaa55
-237
View File
@@ -1,237 +0,0 @@
#!/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())
-63
View File
@@ -1,63 +0,0 @@
/* Private-display integration helper; stdin uses Linux evdev key codes.
* Compile via dev/build-input-probe.sh inside the temporary tools shell. */
#define _GNU_SOURCE
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
#include <wayland-client.h>
#include <xkbcommon/xkbcommon.h>
#include "virtual-keyboard-client-protocol.h"
static struct wl_seat *seat;
static struct zwp_virtual_keyboard_manager_v1 *manager;
static void global(void *data, struct wl_registry *registry, uint32_t id,
const char *interface, uint32_t version) {
(void)data; (void)version;
if (!strcmp(interface,"wl_seat"))
seat = wl_registry_bind(registry,id,&wl_seat_interface,1);
else if (!strcmp(interface,"zwp_virtual_keyboard_manager_v1"))
manager = wl_registry_bind(registry,id,&zwp_virtual_keyboard_manager_v1_interface,1);
}
static void removed(void *data,struct wl_registry *registry,uint32_t id) {
(void)data; (void)registry; (void)id;
}
int main(void) {
/* The caller supplies the separately created scratch Wayland runtime. */
assert(getenv("WAYLAND_DISPLAY") && getenv("XDG_RUNTIME_DIR"));
struct wl_display *display = wl_display_connect(NULL); assert(display);
struct wl_registry *registry = wl_display_get_registry(display);
struct wl_registry_listener listener={global,removed};
wl_registry_add_listener(registry,&listener,NULL); wl_display_roundtrip(display);
assert(seat && manager);
struct zwp_virtual_keyboard_v1 *keyboard = zwp_virtual_keyboard_manager_v1_create_virtual_keyboard(manager,seat);
struct xkb_context *context=xkb_context_new(XKB_CONTEXT_NO_FLAGS);
struct xkb_rule_names names={.rules="evdev",.model="pc105",.layout="us"};
struct xkb_keymap *map=xkb_keymap_new_from_names(context,&names,XKB_KEYMAP_COMPILE_NO_FLAGS); assert(map);
struct xkb_state *state=xkb_state_new(map); assert(state);
char *text=xkb_keymap_get_as_string(map,XKB_KEYMAP_FORMAT_TEXT_V1);
size_t size=strlen(text)+1;
int fd=memfd_create("hydra-private-keymap",0); assert(fd>=0);
assert(write(fd,text,size)==(ssize_t)size);
zwp_virtual_keyboard_v1_keymap(keyboard,WL_KEYBOARD_KEYMAP_FORMAT_XKB_V1,fd,size);
wl_display_roundtrip(display); close(fd); free(text);
char command[16]; unsigned key; unsigned tick=0;
while (scanf("%15s %u",command,&key)==2) {
if (!strcmp(command,"wait")) { usleep(key*1000); continue; }
unsigned down=!strcmp(command,"down");
assert(down || !strcmp(command,"up"));
xkb_state_update_key(state,key+8,down?XKB_KEY_DOWN:XKB_KEY_UP);
zwp_virtual_keyboard_v1_key(keyboard,++tick,key,down);
zwp_virtual_keyboard_v1_modifiers(keyboard,
xkb_state_serialize_mods(state,XKB_STATE_MODS_DEPRESSED),
xkb_state_serialize_mods(state,XKB_STATE_MODS_LATCHED),
xkb_state_serialize_mods(state,XKB_STATE_MODS_LOCKED),
xkb_state_serialize_layout(state,XKB_STATE_LAYOUT_EFFECTIVE));
assert(wl_display_roundtrip(display)>=0);
}
zwp_virtual_keyboard_v1_destroy(keyboard); wl_display_roundtrip(display);
xkb_state_unref(state); xkb_keymap_unref(map); xkb_context_unref(context);
wl_display_disconnect(display);
return 0;
}
+4 -275
View File
@@ -308,265 +308,6 @@ def lifecycle_guest_script():
'\n'.join(inspect.getsource(helper) for helper in helpers) + LIFECYCLE_BODY) '\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): def installed_admin(console, password):
console.buffer = b'' console.buffer = b''
prompt_encoded = base64.b64encode(b'HYDRA_SUDO_PASSWORD: ').decode() prompt_encoded = base64.b64encode(b'HYDRA_SUDO_PASSWORD: ').decode()
@@ -635,7 +376,7 @@ def qemu_path(path):
return str(path) return str(path)
def qemu_argv(qemu, scratch, disk, code, variables, iso, memory, *, keep_cdrom_drive=False): def qemu_argv(qemu, scratch, disk, code, variables, iso, memory):
scratch = Path(scratch) scratch = Path(scratch)
owned_file(disk, scratch) owned_file(disk, scratch)
owned_file(variables, scratch) owned_file(variables, scratch)
@@ -650,18 +391,8 @@ def qemu_argv(qemu, scratch, disk, code, variables, iso, memory, *, keep_cdrom_d
if iso is not None: if iso is not None:
if not Path(iso).is_file(): if not Path(iso).is_file():
raise SmokeError('Trusted ISO must exist as a regular file') raise SmokeError('Trusted ISO must exist as a regular file')
if keep_cdrom_drive: argv += ['-drive', f'file={qemu_path(iso)},media=cdrom,format=raw,readonly=on',
# Keep SATA0 stable so a saved DVD boot option has the same device path '-boot', 'order=d,strict=on']
# 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: else:
argv += ['-boot', 'order=c,strict=on'] argv += ['-boot', 'order=c,strict=on']
return argv return argv
@@ -826,8 +557,7 @@ class VM:
(scratch / name).unlink(missing_ok=True) (scratch / name).unlink(missing_ok=True)
self.stderr = open(scratch / f'{phase}-qemu.log', 'ab', buffering=0) self.stderr = open(scratch / f'{phase}-qemu.log', 'ab', buffering=0)
self.process = subprocess.Popen(qemu_argv(args.qemu, scratch, disk, 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) stdin=subprocess.DEVNULL, stdout=self.stderr, stderr=self.stderr)
self.console = None self.console = None
self.qmp = None self.qmp = None
@@ -1145,7 +875,6 @@ def main(argv=None):
if output.strip() != b'hydratest': if output.strip() != b'hydratest':
raise SmokeError('Installed account identity mismatch') raise SmokeError('Installed account identity mismatch')
checkpoint('installed_password_login', output) checkpoint('installed_password_login', output)
checkpoint('installed_terminal_ux', installed_terminal_ux_probe(console))
console.buffer = b'' console.buffer = b''
prompt_encoded = base64.b64encode(b'HYDRA_SUDO_PASSWORD: ').decode() prompt_encoded = base64.b64encode(b'HYDRA_SUDO_PASSWORD: ').decode()
console.send(f'sudo -p "$(printf %s {prompt_encoded} | base64 -d)" -i\n') console.send(f'sudo -p "$(printf %s {prompt_encoded} | base64 -d)" -i\n')
-632
View File
@@ -1,632 +0,0 @@
#!/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())
-93
View File
@@ -1,93 +0,0 @@
#!/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())
-58
View File
@@ -1,58 +0,0 @@
#!/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()
-215
View File
@@ -1,215 +0,0 @@
#!/usr/bin/env python3
"""Exercise the product's empty-session/recovery/refresh loop privately."""
import argparse
import json
import os
from pathlib import Path
import shutil
import signal
import subprocess
import sys
import tempfile
import time
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from hydra.runtime import Runtime
from smoke import (wait_for, counter, focus_events, fresh_focus_transition,
measure_hidden_interval, classify_compositor_errors)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--labwc', default=os.environ.get('HYDRA_LABWC', 'labwc'))
parser.add_argument('--viewer', default=os.environ.get('HYDRA_VIEWER', 'remote-viewer'))
parser.add_argument('--keyboard', required=True, help='compiled dev/input-probe.c')
args = parser.parse_args()
for name in ('labwc', 'viewer', 'keyboard'):
executable = shutil.which(getattr(args, name))
if not executable:
parser.error('missing executable: ' + getattr(args, name))
setattr(args, name, executable)
if not os.access('/dev/kvm', os.R_OK | os.W_OK):
parser.error('KVM access required')
source = Path(__file__).resolve().parents[1]
root = Path(tempfile.mkdtemp(prefix='hydra-session-refresh-', dir='/tmp'))
print('Evidence: ' + str(root), flush=True)
xdg = root / 'xdg'; xdg.mkdir(mode=0o700)
manager = Runtime(root / 'config', root / 'state', root / 'run')
cli = [sys.executable, str(source / 'bin/hydra'), '--config', str(manager.config),
'--state', str(manager.state), '--runtime', str(manager.runtime)]
subprocess.run(['as', '--32', str(source / 'dev/guest-probe.S'), '-o', str(root / 'boot.o')], check=True)
subprocess.run(['objcopy', '-j', '.text', '-O', 'binary', str(root / 'boot.o'), str(root / 'boot.bin')], check=True)
(root / 'boot.raw').write_bytes((root / 'boot.bin').read_bytes() + bytes(8 * 1024 * 1024 - 512))
def wrapper(name, code):
path = root / name
path.write_text('#!' + sys.executable + '\n' + code)
path.chmod(0o700)
return str(path)
common = 'import json, os, pathlib, subprocess, sys, time\nroot = pathlib.Path(' + repr(str(root)) + ')\n'
labwc = wrapper('labwc-private', common + '''
if '--version' in sys.argv:
os.execv(@LABWC@, [@LABWC@] + sys.argv[1:])
config = pathlib.Path(sys.argv[sys.argv.index('-C') + 1])
number = len(list(root.glob('snapshot-*.json')))
data = json.loads((config.parent / 'session.json').read_text())
data.update(pid=os.getpid(), config_directory=str(config))
(root / ('snapshot-%d.xml' % number)).write_text((config / 'rc.xml').read_text())
(root / ('snapshot-%d.json' % number)).write_text(json.dumps(data))
# Test-only wrapper replaces physical backends before the real compositor runs.
os.environ.update(WLR_BACKENDS='headless', WLR_RENDERER='pixman',
WLR_XWAYLAND='/bin/false', LABWC_UPDATE_ACTIVATION_ENV='0',
XDG_DATA_DIRS='/nonexistent')
os.execv(@LABWC@, [@LABWC@, '-d'] + sys.argv[1:])
'''.replace('@LABWC@', repr(args.labwc)))
viewer = wrapper('viewer-private', common + '''
if '--help' in sys.argv:
os.execv(VIEWER, [VIEWER] + sys.argv[1:])
name = sys.argv[sys.argv.index('--title') + 1].split(': ', 1)[1]
(root / (name + '-viewer.json')).write_text(json.dumps({
'argv': sys.argv[1:], 'config': os.environ['XDG_CONFIG_HOME'],
'gdk_backend': os.environ.get('GDK_BACKEND'), 'wayland_display': os.environ['WAYLAND_DISPLAY']}))
log = os.open(root / (name + '.log'), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
os.dup2(log, 1); os.dup2(log, 2)
os.environ['WAYLAND_DEBUG'] = '1'
os.execv(VIEWER, [VIEWER] + sys.argv[1:])
'''.replace('VIEWER', repr(args.viewer)))
terminal = wrapper('terminal-private', common + '''
manifest = pathlib.Path(sys.argv[-1])
socket = pathlib.Path(os.environ['XDG_RUNTIME_DIR']) / os.environ['WAYLAND_DISPLAY']
(root / 'recovery-ready').write_text(str(manifest))
while not (root / 'recovery-input').exists():
if not socket.exists():
sys.exit(0)
time.sleep(.05)
if not socket.exists():
sys.exit(0)
commands = (root / 'recovery-input').read_text()
# The actual product recovery helper executes :shell and :refresh.
with (root / 'recovery.log').open('a') as log:
sys.exit(subprocess.run(sys.argv[2:], input=commands, text=True, stdout=log, stderr=log).returncode)
''')
shell = wrapper('scratch-shell', common + 'sys.path.insert(0, ' + repr(str(source)) + ')\n' + '''
from hydra.runtime import Runtime
from smoke import counter, wait_for
cli = CLI
manager = Runtime(root / 'config', root / 'state', root / 'run')
for name in ('alpha', 'beta'):
subprocess.run(cli + ['add', name, '--memory', '128', '--cpus', '1', '--disk-size', '8M',
'--graphics', 'software', '--network', 'user'], check=True)
paths = manager.paths(name); manager.prepare(paths)
subprocess.run([os.environ['HYDRA_QEMU_IMG'], 'convert', '-f', 'raw', '-O', 'qcow2',
str(root / 'boot.raw'), str(paths.disk)], check=True)
subprocess.run(cli + ['start', name], check=True)
time.sleep(.3)
identities = {name: {'pid': manager.paths(name).pid.read_text().strip(),
'uuid': manager.paths(name).uuid, 'counter': counter(manager, name)}
for name in ('alpha', 'beta')}
(root / 'before.json').write_text(json.dumps(identities))
'''.replace('CLI', repr(cli)).replace('from smoke import', 'from dev.smoke import'))
env = dict(os.environ, XDG_RUNTIME_DIR=str(xdg), HYDRA_LABWC=labwc,
HYDRA_VIEWER=viewer, HYDRA_TERMINAL=terminal, HYDRA_BRIGHTNESS=shutil.which('true'),
SHELL=shell, GSETTINGS_BACKEND='memory')
for key in ('WAYLAND_DISPLAY', 'DISPLAY', 'LABWC_PID'):
env.pop(key, None)
log = (root / 'session.log').open('w')
launcher, injector = None, None
result = {'evidence': str(root), 'physical_input_pending': True,
'terminal': 'test-only command feeder; real recovery helper and scratch :shell',
'backend': 'test-only wrapper around actual product launch_session and actual labwc'}
try:
launcher = subprocess.Popen(cli + ['session'], env=env, stdout=log, stderr=log)
wait_for(lambda: (root / 'recovery-ready').exists(), 'empty session did not open recovery')
first = json.loads((root / 'snapshot-0.json').read_text())
assert first['names'] == [], 'session was not initially empty'
(root / 'recovery-input').write_text(':shell\n:refresh\n')
wait_for(lambda: (root / 'snapshot-1.json').exists(), 'product loop did not restart after :refresh', timeout=15)
second = json.loads((root / 'snapshot-1.json').read_text())
assert second['names'] == ['alpha', 'beta'], second
assert first['pid'] != second['pid'], 'compositor was not refreshed'
logs = {name: root / (name + '.log') for name in ('alpha', 'beta')}
for name, path in logs.items():
wait_for(lambda: path.exists() and 'set_title("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'
for data in viewers.values():
assert data['gdk_backend'] == 'wayland'
assert data['config'].startswith(str(manager.runtime) + '/viewer-')
assert '--spice-usbredir-auto-redirect-filter=-1,-1,-1,-1,0' in data['argv']
assert '--spice-usbredir-redirect-on-connect=-1,-1,-1,-1,0' in data['argv']
alog, blog = logs['alpha'], logs['beta']
display = next(p.name for p in xdg.glob('wayland-*') if not p.name.endswith('.lock'))
# 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: 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)
injector.stdin.write(f'down 29\ndown 56\ndown 42\ndown {code}\nup {code}\nup 42\nup 56\nup 29\nwait 100\n')
injector.stdin.flush()
wait_for(lambda: fresh_focus_transition(before_source, before_target,
focus_events(source_log), focus_events(target_log)), 'fresh reserved focus transition failed')
switch(105, 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)}
for name in ('alpha', 'beta')}
for name in before:
assert after[name]['pid'] == before[name]['pid'] and after[name]['uuid'] == before[name]['uuid']
assert after[name]['counter'] != before[name]['counter']
# Only the PID recorded by our own wrapper, verified against its private
# config path, may be signalled. Never inherit a host compositor PID.
command = Path('/proc') / str(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'], 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()),
passed=True)
(root / 'result.json').write_text(json.dumps(result, indent=2) + '\n')
print(json.dumps(result, indent=2))
finally:
if injector and injector.poll() is None:
injector.terminate(); injector.wait(timeout=5)
if launcher and launcher.poll() is None:
launcher.send_signal(signal.SIGINT); launcher.wait(timeout=12)
for profile in manager.profiles():
if manager.status(profile.name)['status'] == 'running':
manager.stop(profile.name, force=True)
log.close()
if __name__ == '__main__':
main()
-273
View File
@@ -1,273 +0,0 @@
#!/usr/bin/env python3
"""Two disposable KVM guests on a private headless display; never host input."""
import argparse
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import time
from xml.etree import ElementTree as ET
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from hydra.profiles import Profile
from hydra.runtime import Runtime
from hydra.session import build_labwc_config, build_viewer_argv, _viewer_environment
def wait_for(predicate, description, timeout=8):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return
time.sleep(.05)
raise AssertionError(description)
def counter(manager, name):
with manager._connect(manager.paths(name)) as qmp:
reply = qmp.command('human-monitor-command', {'command-line': 'xp /3wx 0x500'})
values = [int(word, 16) for word in re.findall(r'0x([0-9a-fA-F]+)', reply)]
assert len(values) == 3 and values[2] == 0x48594452, 'scratch guest did not boot'
return values[0]
def focus_events(log):
"""Only keyboard focus, excluding pointer-enter and old window titles."""
return re.findall(r'wl_keyboard#\d+\.(enter|leave)\(', log.read_text())
def fresh_focus_transition(source_before, target_before, source_after, target_after):
return (len(source_after) > len(source_before) and source_after[-1] == 'leave'
and len(target_after) > len(target_before) and target_after[-1] == 'enter')
def measure_hidden_interval(read_counter, hidden_log, visible_log, duration=.5):
hidden_focus, visible_focus = focus_events(hidden_log), focus_events(visible_log)
assert hidden_focus and hidden_focus[-1] == 'leave', 'sampled guest is not hidden'
assert visible_focus and visible_focus[-1] == 'enter', 'other guest is not focused'
def unchanged():
assert (focus_events(hidden_log) == hidden_focus and
focus_events(visible_log) == visible_focus), 'focus changed during hidden interval'
start = time.monotonic()
before = read_counter()
unchanged()
while time.monotonic() - start < duration:
time.sleep(min(.05, duration))
unchanged()
after = read_counter()
unchanged()
assert after != before, 'hidden guest stopped working during continuously hidden interval'
return {'before': before, 'after': after,
'elapsed_seconds': time.monotonic() - start, 'focus_unchanged': True}
def classify_compositor_errors(log):
expected = []
for line in log.splitlines():
if '[ERROR]' not in line:
continue
if re.search(r'\[xwayland/sockets\.c:\d+\] Failed to bind socket '
r'@?/tmp/\.X11-unix/X\d+: Address already in use$', line):
expected.append(line)
else:
raise AssertionError('Unexpected compositor error: ' + line)
return expected
def main():
cli = argparse.ArgumentParser(description=__doc__)
cli.add_argument('--viewer', default=os.environ.get('HYDRA_VIEWER', 'remote-viewer'))
cli.add_argument('--labwc', default=os.environ.get('HYDRA_LABWC', 'labwc'))
cli.add_argument('--keyboard', help='compiled dev/input-probe.c; built automatically when omitted')
args = cli.parse_args()
for name in ('viewer', 'labwc'):
value = shutil.which(getattr(args, name))
if not value:
cli.error(f'missing {name}: {getattr(args, name)}')
setattr(args, name, value)
source = Path(__file__).resolve().parent
if not args.keyboard:
args.keyboard = subprocess.check_output([str(source / 'build-input-probe.sh')], text=True).strip()
if not os.access('/dev/kvm', os.R_OK | os.W_OK):
cli.error('KVM access required for this integration check')
root = Path(tempfile.mkdtemp(prefix='hydra-smoke-', dir='/tmp'))
print(f'Evidence: {root}', flush=True)
xdg = root / 'xdg'
xdg.mkdir(mode=0o700)
config = root / 'labwc'
config.mkdir()
recovery = root / 'recovery'
unreserved = root / 'unreserved'
names = ['alpha', 'beta']
rc = ET.fromstring(build_labwc_config(names, [sys.executable, '-c',
'from pathlib import Path; Path(' + repr(str(recovery)) + ').touch()'], initial='alpha'))
# Test-only controls prove native labwc inhibition is active; production
# config has no ToggleKeybinds or ordinary host shortcut.
keyboard = rc.find('keyboard')
binding = ET.SubElement(keyboard, 'keybind', key='C-A-S-i')
ET.SubElement(binding, 'action', name='ToggleKeybinds')
binding = ET.SubElement(keyboard, 'keybind', key='F12')
ET.SubElement(binding, 'action', name='Execute', command=shutil.which('touch') + ' ' + str(unreserved))
ET.ElementTree(rc).write(config / 'rc.xml')
(config / 'autostart').write_text('#!/bin/sh\n')
(config / 'environment').touch()
env = dict(os.environ, XDG_RUNTIME_DIR=str(xdg), WLR_BACKENDS='headless',
WLR_RENDERER='pixman', WLR_XWAYLAND='/bin/false', XDG_DATA_DIRS='/nonexistent',
GSETTINGS_BACKEND='memory', LABWC_UPDATE_ACTIVATION_ENV='0')
for key in ('DISPLAY', 'WAYLAND_DISPLAY'):
env.pop(key, None)
manager = Runtime(root / 'config', root / 'state', root / 'run')
boot_object, boot = root / 'boot.o', root / 'boot.bin'
subprocess.run(['as', '--32', str(source / 'guest-probe.S'), '-o', str(boot_object)], check=True)
subprocess.run(['objcopy', '-j', '.text', '-O', 'binary', str(boot_object), str(boot)], check=True)
raw = root / 'boot.raw'
raw.write_bytes(boot.read_bytes() + bytes(8 * 1024 * 1024 - 512))
media = root / 'installation.iso'
media.touch()
for name in names:
manager.add(Profile(name, media if name == 'alpha' else None, memory=128, cpus=1, disk_size='8M', graphics='software',
width=1920, height=1080, refresh_millihz=144000))
paths = manager.paths(name)
manager.prepare(paths)
subprocess.run([os.environ.get('HYDRA_QEMU_IMG', 'qemu-img'), 'convert', '-f', 'raw',
'-O', 'qcow2', str(raw), str(paths.disk)], check=True)
processes, streams = [], []
result = {'evidence': str(root), 'physical_checks_pending': True}
try:
cl = (root / 'labwc.log').open('w'); streams.append(cl)
compositor = subprocess.Popen([args.labwc, '-d', '-C', str(config)], env=env, stdout=cl, stderr=cl)
processes.append(compositor)
wait_for(lambda: any(not p.name.endswith('.lock') for p in xdg.glob('wayland-*')), 'private display did not start')
env['WAYLAND_DISPLAY'] = next(p.name for p in xdg.glob('wayland-*') if not p.name.endswith('.lock'))
injector = subprocess.Popen([args.keyboard], env=env, stdin=subprocess.PIPE, text=True)
processes.append(injector)
def keys(commands):
injector.stdin.write(commands + '\nwait 100\n'); injector.stdin.flush()
time.sleep(.15)
assert injector.poll() is None, 'private input helper exited'
def chord(code):
keys(f'down 29\ndown 56\ndown 42\ndown {code}\nup {code}\nup 42\nup 56\nup 29')
def switch(key, source_log, target_log):
source_before, target_before = focus_events(source_log), focus_events(target_log)
chord(key)
wait_for(lambda: fresh_focus_transition(source_before, target_before,
focus_events(source_log), focus_events(target_log)),
'reserved switch did not produce fresh keyboard leave/enter')
return {'from_events': len(focus_events(source_log)),
'to_events': len(focus_events(target_log)), 'keycode': key}
def attach(name, number):
vl = (root / f'{name}-{number}.log').open('w'); streams.append(vl)
previous = dict(os.environ)
try:
os.environ.update(env)
viewer_env = _viewer_environment(root / f'{name}-{number}-settings')
finally:
os.environ.clear(); os.environ.update(previous)
viewer_env['WAYLAND_DEBUG'] = '1'
proc = subprocess.Popen(build_viewer_argv(manager.paths(name), args.viewer),
env=viewer_env, stdout=vl, stderr=vl)
processes.append(proc)
log = root / f'{name}-{number}.log'
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:
manager.start(name)
media.unlink() # Open QEMU media disappears; status/control/view still work.
time.sleep(1)
before = {name: counter(manager, name) for name in names}
with manager._connect(manager.paths('alpha')) as qmp:
children = qmp.command('qom-list', {'path': '/machine/peripheral-anon'})
video = next(child['name'] for child in children if child['type'] == 'child<virtio-vga>')
mode = {prop: qmp.command('qom-get', {'path': '/machine/peripheral-anon/' + video, 'property': prop})
for prop in ('xres', 'yres', 'refresh_rate')}
assert mode == {'xres': 1920, 'yres': 1080, 'refresh_rate': 144000}, mode
usb_before = qmp.command('human-monitor-command', {'command-line': 'info usb'})
identities = {name: manager.paths(name).pid.read_text().strip() for name in names}
alpha, alog = attach('alpha', 1)
beta, blog = attach('beta', 1)
wait_for(lambda: '.enter(' in alog.read_text(), 'alpha did not receive focus')
# F12 must work before inhibition; then become suppressed in alpha.
keys('down 88\nup 88')
wait_for(unreserved.exists, 'ordinary test binding failed before inhibition')
unreserved.unlink()
chord(23) # I: test-only ToggleKeybinds.
keys('down 88\nup 88')
assert not unreserved.exists(), 'keybinding inhibition did not suppress ordinary F12'
switch(106, alog, blog) # Right: beta focused, alpha continuously hidden.
hidden_intervals = {'alpha': measure_hidden_interval(lambda: counter(manager, 'alpha'), alog, blog)}
switch(105, blog, alog) # Left: alpha focused, beta continuously hidden.
hidden_intervals['beta'] = measure_hidden_interval(lambda: counter(manager, 'beta'), blog, alog)
chord(1) # Escape: production recovery binding still works.
wait_for(recovery.exists, 'reserved recovery failed with inhibition')
dialog = 'set_title("Select USB devices for redirection")'
assert dialog not in alog.read_text(), 'USB selector opened automatically'
# Hold modifiers through opening, then release inside the modal dialog.
keys('down 29\ndown 56\ndown 42\ndown 22')
wait_for(lambda: dialog in alog.read_text(), 'manual USB selector did not open', timeout=3)
keys('up 22\nup 42\nup 56\nup 29')
keys('down 1\nup 1') # Close selector.
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("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'
alpha.terminate(); alpha.wait(timeout=5)
assert manager.status('alpha')['status'] == 'running'
alpha, attached = attach('alpha', 2)
after = {name: counter(manager, name) for name in names}
assert all(after[name] != before[name] for name in names), 'background guest stopped working'
# Quit only beta; alpha's counter/lifecycle remains independent.
manager.stop('beta', force=True)
assert manager.status('alpha')['status'] == 'running'
assert counter(manager, 'alpha') != after['alpha']
compositor.terminate(); compositor.wait(timeout=5)
assert manager.status('alpha')['status'] == 'running'
manager.stop('alpha', force=True)
manager.media('alpha', None)
assert manager.profile('alpha').iso is None
manager.start('alpha')
time.sleep(1)
disk_boot_counter = counter(manager, 'alpha')
assert disk_boot_counter > 0
expected_compositor_noise = classify_compositor_errors((root / 'labwc.log').read_text())
result.update(hidden_guest_intervals=hidden_intervals, post_usb_switches=post_usb_switches,
expected_compositor_noise=expected_compositor_noise,
manual_usb_dialog=True, standard_us_keymap=True,
native_labwc_inhibition=True, reserved_switch_and_recovery=True,
qemu_survives_viewer_and_compositor=True, independent_lifecycle=True,
guest_counters_before=before, guest_counters_after=after,
guest_pids=identities, explicit_reattach=True, requested_qemu_mode=mode,
usb_devices_unchanged_without_selection=True,
missing_iso_does_not_block_views_or_control=True,
restart_without_original_media=True, disk_boot_counter=disk_boot_counter,
viewer_version=subprocess.check_output([args.viewer, '--version'], text=True).strip(),
guest_input_and_physical_modifiers='not proven by this boot counter workload')
(root / 'result.json').write_text(json.dumps(result, indent=2) + '\n')
print(json.dumps(result, indent=2))
finally:
for proc in reversed(processes):
if proc.poll() is None:
proc.terminate(); proc.wait(timeout=5)
for stream in streams:
stream.close()
for name in names:
if manager.status(name)['status'] != 'stopped':
manager.stop(name, force=True)
if __name__ == '__main__':
main()
+2 -7
View File
@@ -8,9 +8,6 @@ let
url = "https://github.com/NixOS/nixpkgs/archive/7fd4004d0511991fff3a0524856a9be09e28c5f0.tar.gz"; url = "https://github.com/NixOS/nixpkgs/archive/7fd4004d0511991fff3a0524856a9be09e28c5f0.tar.gz";
sha256 = "sha256-i5qk1kLiit98rRbh1AXmWBNT9QXC/y4l5Wk/u5EdpDQ="; sha256 = "sha256-i5qk1kLiit98rRbh1AXmWBNT9QXC/y4l5Wk/u5EdpDQ=";
}) {}; }) {};
qemu = (pkgs.qemu.override { hostCpuOnly = true; }).overrideAttrs (old: {
patches = (old.patches or []) ++ [ ../patches/qemu-virtio-refresh-rate.patch ];
});
viewer = pkgs.virt-viewer.overrideAttrs (old: { viewer = pkgs.virt-viewer.overrideAttrs (old: {
src = pkgs.fetchurl { src = pkgs.fetchurl {
url = "https://releases.pagure.org/virt-viewer/virt-viewer-11.0.tar.xz"; url = "https://releases.pagure.org/virt-viewer/virt-viewer-11.0.tar.xz";
@@ -20,9 +17,7 @@ let
doCheck = true; doCheck = true;
}); });
in pkgs.mkShell { in pkgs.mkShell {
nativeBuildInputs = [ pkgs.stdenv.cc pkgs.pkg-config pkgs.wayland-scanner ]; packages = [ pkgs.python3 pkgs.qemu viewer graphics.labwc pkgs.foot
buildInputs = [ pkgs.wayland.dev pkgs.libxkbcommon.dev ];
packages = [ pkgs.python3 qemu viewer graphics.labwc pkgs.foot
pkgs.brightnessctl pkgs.wayland-utils pkgs.wlr-randr ]; pkgs.brightnessctl pkgs.wayland-utils pkgs.wlr-randr ];
passthru = { inherit viewer qemu; labwc = graphics.labwc; }; passthru = { inherit viewer; labwc = graphics.labwc; };
} }
-113
View File
@@ -1,113 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="virtual_keyboard_unstable_v1">
<copyright>
Copyright © 2008-2011 Kristian Høgsberg
Copyright © 2010-2013 Intel Corporation
Copyright © 2012-2013 Collabora, Ltd.
Copyright © 2018 Purism SPC
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice (including the next
paragraph) shall be included in all copies or substantial portions of the
Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
</copyright>
<interface name="zwp_virtual_keyboard_v1" version="1">
<description summary="virtual keyboard">
The virtual keyboard provides an application with requests which emulate
the behaviour of a physical keyboard.
This interface can be used by clients on its own to provide raw input
events, or it can accompany the input method protocol.
</description>
<request name="keymap">
<description summary="keyboard mapping">
Provide a file descriptor to the compositor which can be
memory-mapped to provide a keyboard mapping description.
Format carries a value from the keymap_format enumeration.
</description>
<arg name="format" type="uint" summary="keymap format"/>
<arg name="fd" type="fd" summary="keymap file descriptor"/>
<arg name="size" type="uint" summary="keymap size, in bytes"/>
</request>
<enum name="error">
<entry name="no_keymap" value="0" summary="No keymap was set"/>
</enum>
<request name="key">
<description summary="key event">
A key was pressed or released.
The time argument is a timestamp with millisecond granularity, with an
undefined base. All requests regarding a single object must share the
same clock.
Keymap must be set before issuing this request.
State carries a value from the key_state enumeration.
</description>
<arg name="time" type="uint" summary="timestamp with millisecond granularity"/>
<arg name="key" type="uint" summary="key that produced the event"/>
<arg name="state" type="uint" summary="physical state of the key"/>
</request>
<request name="modifiers">
<description summary="modifier and group state">
Notifies the compositor that the modifier and/or group state has
changed, and it should update state.
The client should use wl_keyboard.modifiers event to synchronize its
internal state with seat state.
Keymap must be set before issuing this request.
</description>
<arg name="mods_depressed" type="uint" summary="depressed modifiers"/>
<arg name="mods_latched" type="uint" summary="latched modifiers"/>
<arg name="mods_locked" type="uint" summary="locked modifiers"/>
<arg name="group" type="uint" summary="keyboard layout"/>
</request>
<request name="destroy" type="destructor" since="1">
<description summary="destroy the virtual keyboard keyboard object"/>
</request>
</interface>
<interface name="zwp_virtual_keyboard_manager_v1" version="1">
<description summary="virtual keyboard manager">
A virtual keyboard manager allows an application to provide keyboard
input events as if they came from a physical keyboard.
</description>
<enum name="error">
<entry name="unauthorized" value="0" summary="client not authorized to use the interface"/>
</enum>
<request name="create_virtual_keyboard">
<description summary="Create a new virtual keyboard">
Creates a new virtual keyboard associated to a seat.
If the compositor enables a keyboard to perform arbitrary actions, it
should present an error when an untrusted client requests a new
keyboard.
</description>
<arg name="seat" type="object" interface="wl_seat"/>
<arg name="id" type="new_id" interface="zwp_virtual_keyboard_v1"/>
</request>
</interface>
</protocol>
+6 -52
View File
@@ -1,11 +1,11 @@
# ASUS TUF Dash F15 FX517ZC # ASUS TUF Dash F15 FX517ZC
Initial read-only inventory on 2026-10-13; no serials or machine identifiers. Read-only inventory 2026-10-13; no serial numbers or machine identifiers.
| Component | Hardware | Driver / requirement | | Component | Hardware | Driver / requirement |
| --- | --- | --- | | --- | --- | --- |
| CPU | i7-12650H, 10 cores / 16 threads | KVM Intel, VT-x | | CPU | i7-12650H, 10 cores / 16 threads | KVM Intel, VT-x |
| RAM | 32 GB nominal | Preserve memory headroom for existing VMs | | RAM | 32 GB nominal | Four build jobs initially; avoid disrupting existing VMs |
| Display | Intel Alder Lake-P UHD 8086:46a3 | i915, internal eDP, intel_backlight | | 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 | | Discrete GPU | RTX 3050 Mobile 10de:25a2 | Initial shell uses Intel; test NVIDIA separately |
| Wi-Fi | Intel CNVi 8086:51f0 | iwlwifi/iwlmvm, linux-firmware | | Wi-Fi | Intel CNVi 8086:51f0 | iwlwifi/iwlmvm, linux-firmware |
@@ -16,53 +16,7 @@ Initial read-only inventory on 2026-10-13; no serials or machine identifiers.
| Thunderbolt | Intel 8086:463e | thunderbolt | | Thunderbolt | Intel 8086:463e | thunderbolt |
| Laptop controls | ASUS | asus_wmi, asus_nb_wmi | | Laptop controls | ASUS | asus_wmi, asus_nb_wmi |
Include vmd, nvme, xhci_pci, thunderbolt, usb_storage and sd_mod in boot coverage. UEFI boot, Secure Boot currently disabled. Include vmd, nvme, xhci_pci,
Target physical refresh is 144 Hz; actual guest frame delivery requires measurement. thunderbolt, usb_storage, sd_mod in boot coverage. Target physical refresh144Hz;
actual guest frame delivery must be measured. About290GiB build storage free at
## Read-only doctor and live functional checks inventory. Initial RAM headroom about10GiB with existing sessions running.
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).
+14 -89
View File
@@ -1,76 +1,16 @@
# Install Hydra OS # Install HydraOS
Hydra OS targets x86_64 UEFI systems. Disable Secure Boot for this first image. HydraOS 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 Boot the ISO and open the recovery terminal. The live session uses a locked
`hydra-live` account with local sudo access; changes in the live filesystem are `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. volatile. The image provides no default SSH login or universal installed password.
`hydra-install` lists disks without selecting one. Inspect the devices, then ask `hydra-install` lists disks without selecting one. Inspect the devices, then ask
for a plan using either the explicitly chosen existing partitions or a whole disk. for a plan using the whole disk you intend to erase:
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 ```sh
hydra-install hydra-install
sudo hydra-install --disk /dev/CHOSEN_DISK --user YOUR_USER hydra-install --disk /dev/CHOSEN_DISK --user YOUR_USER
``` ```
Replace the placeholders with an actual whole disk and your desired account Replace the placeholders with an actual whole disk and your desired account
@@ -86,7 +26,7 @@ sudo hydra-install --disk /dev/CHOSEN_DISK --user YOUR_USER \
--execute --confirm-erase /dev/CHOSEN_DISK --execute --confirm-erase /dev/CHOSEN_DISK
``` ```
Execution erases the disk, creates a 1 GiB FAT32 EFI partition and an ext4 root (or Btrfs with `--root-fs btrfs`), Execution erases the disk, creates a 1 GiB FAT32 EFI partition and an ext4 root,
extracts the prepared payload, and prompts twice on the local terminal for your extracts the prepared payload, and prompts twice on the local terminal for your
installed account password. Passwords have no command-line or configuration installed account password. Passwords have no command-line or configuration
option. The installed root account stays locked. Defaults are Europe/Moscow and option. The installed root account stays locked. Defaults are Europe/Moscow and
@@ -94,35 +34,29 @@ English UTF-8, with Russian UTF-8 also available; `--timezone` accepts an IANA
zone present in the payload. zone present in the payload.
The installer removes the live account, local password-free sudo rule, and The installer removes the live account, local password-free sudo rule, and
live autologin overrides, then builds a normal disk initramfs, its owned HydraOS UEFI loader, and a fallback live autologin overrides, then builds a normal disk initramfs and fallback UEFI
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 loader. Wait for `Installation completed`, power off, remove the ISO, and boot
the installed disk. Log in with the account and password you supplied. On 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, 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. a nonsecret snapshot is also saved at `/var/log/hydra-install.log` on that disk.
## Disposable VM acceptance ## Disposable VM acceptance
Run the repository harness with QEMU/KVM, `qemu-img`, Python 3, and a generic Run the repository harness with QEMU/KVM, `qemu-img`, Python 3, and an OVMF
x86_64 QEMU OVMF CODE/VARS pair. A Xen-specific OVMF build is unsuitable: the CODE/VARS pair. Use trusted image and firmware files. For example:
first validation attempt with that variant produced no guest boot output.
Use trusted image and matching firmware files. For example:
```sh ```sh
install -d -m 700 "$HOME/.cache/hydra-tests"
python3 dev/iso-smoke.py --iso /absolute/path/to/hydra-os.iso \ python3 dev/iso-smoke.py --iso /absolute/path/to/hydra-os.iso \
--ovmf-code /absolute/path/to/OVMF_CODE.fd \ --ovmf-code /absolute/path/to/OVMF_CODE.fd \
--ovmf-vars /absolute/path/to/OVMF_VARS.fd \ --ovmf-vars /absolute/path/to/OVMF_VARS.fd
--scratch-parent "$HOME/.cache/hydra-tests"
``` ```
The harness creates a private directory under `--scratch-parent` (`/tmp` by default), a new 32 GiB sparse The harness creates a private directory under `/tmp`, a new 32 GiB sparse
qcow2 disk, and a private copy of OVMF variables. There is no existing disk qcow2 disk, and a private copy of OVMF variables. There is no existing disk
argument. QEMU attaches the ISO read-only; serial and QMP use private Unix argument. QEMU attaches the ISO read-only; serial and QMP use private Unix
sockets. Tool overrides `--qemu` and `--qemu-img` support temporary development sockets. Tool overrides `--qemu` and `--qemu-img` support temporary development
environments. `--scratch-parent`, `--memory`, `--boot-timeout`, and environments. `--scratch-parent`, `--memory`, `--boot-timeout`, and
`--install-timeout` control retained artifacts and bounded resource use. `--install-timeout` control retained artifacts and bounded resource use.
Prefer a disk-backed scratch directory: an installed disk stored on a tmpfs
such as `/tmp` consumes host RAM. The example keeps test data in the user's cache.
The test boots the live image, runs the real installer through the guest's The test boots the live image, runs the real installer through the guest's
controlling terminal using a generated password, writes a persistence sentinel, controlling terminal using a generated password, writes a persistence sentinel,
@@ -130,18 +64,9 @@ shuts down, and launches a second QEMU with the ISO absent. It verifies password
login, disk root, removed live overrides, services, bridge/NAT configuration, 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 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 synthetic VM endpoint. Network namespaces and veth interfaces exist only inside
the disposable Hydra OS guest. When guest KVM is available, it launches a further the disposable HydraOS guest. When guest KVM is available, it launches a further
fresh-disk QEMU as the installed account to exercise KVM, the bridge helper, 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. and private QMP; this checks VM launch, not a nested operating system boot.
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.
`report.json` records each observed check, ISO/firmware SHA256, failures, and `report.json` records each observed check, ISO/firmware SHA256, failures, and
skipped nested virtualization. Serial/QEMU logs and the qcow2 remain in the skipped nested virtualization. Serial/QEMU logs and the qcow2 remain in the
@@ -154,7 +79,7 @@ acceptance.
## Checks requiring the laptop ## Checks requiring the laptop
Disposable QEMU results cannot establish ASUS FX517ZC hardware support. Before Disposable QEMU results cannot establish ASUS FX517ZC hardware support. Before
using Hydra OS daily, boot the live ISO and observe Wi-Fi, touchpad, audio, using HydraOS daily, boot the live ISO and observe Wi-Fi, touchpad, audio,
brightness, suspend/resume, USB device selection, input capture, and actual brightness, suspend/resume, USB device selection, input capture, and actual
display refresh. Physical installation and these checks remain pending until display refresh. Physical installation and these checks remain pending until
explicitly performed and recorded. explicitly performed and recorded.
-92
View File
@@ -1,92 +0,0 @@
# 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
```
-78
View File
@@ -1,78 +0,0 @@
# 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
```
@@ -1,120 +0,0 @@
# 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.
@@ -1,85 +0,0 @@
{
"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"
}
]
}
@@ -1,22 +0,0 @@
= = 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
@@ -1,165 +0,0 @@
{
"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
@@ -1,16 +0,0 @@
{
"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
@@ -1,189 +0,0 @@
{
"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"
}
}
}
@@ -1,45 +0,0 @@
# 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.
@@ -1,66 +0,0 @@
{
"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"
}
@@ -1,33 +0,0 @@
{
"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"
}
@@ -1,11 +0,0 @@
/\ /\ /\ 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
@@ -1,17 +0,0 @@
{
"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
}
-57
View File
@@ -1,57 +0,0 @@
{
"format": 1,
"status": "passed",
"completed_utc": "2026-10-13T02:49:50Z",
"artifact": {
"file": "hydra-os.iso",
"bytes": 2345109504,
"sha256": "bdff621037ce9220767b7869499823c9b840b76c0b38268bc7f56a7b54390ce0",
"source_commit": "903e7cf35339e50783a1bb3790b06e3204efab4d",
"source_inputs_sha256": "cfe9ddf4549bdedcd91888adc8c4b00639e370b6296c361898d3898daf76d4de",
"kernel": "6.18.54-gentoo-dist-bin",
"package_count": 559,
"rootfs_manifest_sha256": "c53ce549e98e0b5a56091d7960a2737534882e3f64a2497a12e3b2a27e522c48"
},
"validation": {
"harness_commit": "4a636924df4e630788dcbbe8d0002261fa9a7df9",
"harness_sha256": "64863d2f856cd3cee1e0c28af5aac2db2706b6403bf5deb32f64f57c33df1a53",
"ovmf_code_sha256": "f3ff7e73448ed2845ee15356f394882f5618eb5dab92c9a30ec6ee0e1468553a",
"ovmf_vars_input_sha256": "6ed987af3a3c155be71665f510eae3e007eda9b8b94afd59d45e91c4a11565cc",
"tools": "Ubuntu QEMU8.2.2, Python3.12.3",
"disk": "new 32 GiB sparse qcow2, only this disposable VM",
"iso_unmodified": true,
"manual_guest_changes": false,
"full_private_report_sha256": "accfc85840681da69eeacabf82c9007dceb66952dac907e0e545b1061a9dee10",
"checks": {
"live_qmp_kvm": "passed",
"live_boot": "passed",
"real_installer": "passed",
"live_poweroff": "passed",
"installed_qmp_kvm": "passed",
"installed_password_login": "passed",
"disk_persistence": "passed",
"installed_contract": "passed",
"lan_to_vm_tcp": "passed",
"nested_kvm": "passed",
"installed_user_logout": "passed",
"installed_poweroff": "passed"
}
},
"scope": {
"nested_virtualization": "Normal-user KVM VM launch, bridge helper and QMP; no nested guest OS boot",
"audio": "Socket/service continuity only; signal/playback untested",
"logout": "Actual getty logout, no open login for measured 15-second survival interval, relogin; unchanged VM process/start/UUID, runtime directories, private sockets and user audio service identities",
"graphics": "Private graphical live recovery observed; actual two-guest labwc/viewer and profile-refresh probes recorded separately; no physical scanout assertion"
},
"pending_hardware": [
"ASUS live boot",
"Wi-Fi and Ethernet on laptop",
"touchpad and physical input capture",
"brightness",
"physical audio playback",
"suspend/resume",
"USB device attachment and guest drivers",
"physical display refresh and delivered FPS"
],
"publication": "Source, patches and these records are in Git. ISO, VM disks, raw logs and private runtime identities remain local."
}
@@ -1 +0,0 @@
bdff621037ce9220767b7869499823c9b840b76c0b38268bc7f56a7b54390ce0 hydra-os.iso
File diff suppressed because it is too large Load Diff
@@ -1,62 +0,0 @@
# 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).
@@ -34,40 +34,40 @@
**Files:** `image/sources.json`, `image/packages`, `image/portage/`, `scripts/hydra-build`, `hydra/build.py`, `tests/test_build.py`. **Files:** `image/sources.json`, `image/packages`, `image/portage/`, `scripts/hydra-build`, `hydra/build.py`, `tests/test_build.py`.
**Interfaces:** `hydra-build fetch|prepare|packages|rootfs|iso|status`, `--work PATH`, `--cache PATH`, optional `--podman PATH`. Each step resumable and validates prerequisite manifests. Outputs WORK/rootfs payload or dedicated builder container, WORK/artifacts/, build manifest. No host root required. **Interfaces:** `hydra-build fetch|prepare|packages|rootfs|iso|status`, `--work PATH`, `--cache PATH`, optional `--podman PATH`. Each step resumable and validates prerequisite manifests. Outputs WORK/rootfs payload or dedicated builder container, WORK/artifacts/, build manifest. No host root required.
- [x] Test pinned download hash mismatch, required signaturefingerprint, safe workpath/refusal of root, resume state vs missingactualartifact, scoped Podman argv. - [ ] Test pinned download hash mismatch, required signaturefingerprint, safe workpath/refusal of root, resume state vs missingactualartifact, scoped Podman argv.
- [x] Observe RED; implement verifiedfetch and isolatedimport/container naming. Use stage3 20261011T170049Z SHA256 50634e8cb87384b94a6dbf78cd2707766c45a5a98f3823e7287d114ee998970b and Portage4696089e782c20b5f0c04952f6698d6a8cc1acb4 archiveSHA256 d24b2056f7ae26506193374b0d9f25c82d4bf547e0ee17279940169418bd3bca. - [ ] Observe RED; implement verifiedfetch and isolatedimport/container naming. Use stage3 20261011T170049Z SHA256 50634e8cb87384b94a6dbf78cd2707766c45a5a98f3823e7287d114ee998970b and Portage4696089e782c20b5f0c04952f6698d6a8cc1acb4 archiveSHA256 d24b2056f7ae26506193374b0d9f25c82d4bf547e0ee17279940169418bd3bca.
- [x] Configure officialbinhost signaturechecking, selective ~amd64 for labwc/wlroots, QEMU x86_64 only, required SPICE/GL/USB flags. Preserve licenses and installed packageDB. - [ ] Configure officialbinhost signaturechecking, selective ~amd64 for labwc/wlroots, QEMU x86_64 only, required SPICE/GL/USB flags. Preserve licenses and installed packageDB.
- [x] Run actual dependency resolution and package install; record exact versions/USE and any selective sourcebuild decisions. No claim of bit-identical build. - [ ] Run actual dependency resolution and package install; record exact versions/USE and any selective sourcebuild decisions. No claim of bit-identical build.
- [x] Run tests and commit builder inputs/scripts. - [ ] Run tests and commit builder inputs/scripts.
### Task 2: Host rootfs and bootable image ### Task 2: Host rootfs and bootable image
**Files:** `image/rootfs/`, `image/grub.cfg`, `image/dracut.conf`, `image/configure-rootfs.sh`, `tests/test_image.py`. **Files:** `image/rootfs/`, `image/grub.cfg`, `image/dracut.conf`, `image/configure-rootfs.sh`, `tests/test_image.py`.
**Interfaces:** Consumes builder and tested CLI/session from graphics plan. Produces labelled HYDRA_OS ISO with boot/vmlinuz, boot/initramfs-live.img, LiveOS/squashfs.img and package/buildmanifest. **Interfaces:** Consumes builder and tested CLI/session from graphics plan. Produces labelled HYDRA_OS ISO with boot/vmlinuz, boot/initramfs-live.img, LiveOS/squashfs.img and package/buildmanifest.
- [x] Test service/config generation, live-only overrides separation, exact kernelversion used for dracut, image label/cmdline agreement, networksubnet conflict handling. - [ ] Test service/config generation, live-only overrides separation, exact kernelversion used for dracut, image label/cmdline agreement, networksubnet conflict handling.
- [x] Implement rootfs overlay: greetd/minimalsession, recovery, localliveadministration, installedauth baseline, audio, NetworkManager, hydrabr0/DHCP/NAT and narrowlyscoped bridgehelper. Include laptopfirmware/bootdrivers and OVMF. - [ ] Implement rootfs overlay: greetd/minimalsession, recovery, localliveadministration, installedauth baseline, audio, NetworkManager, hydrabr0/DHCP/NAT and narrowlyscoped bridgehelper. Include laptopfirmware/bootdrivers and OVMF.
- [x] Build genericliveinitramfs with dmsquash-live and OverlayFS; squashfspayload retains kernel/packageDB. Assemble UEFIISO with GRUB; save SHA256 and manifest. - [ ] Build genericliveinitramfs with dmsquash-live and OverlayFS; squashfspayload retains kernel/packageDB. Assemble UEFIISO with GRUB; save SHA256 and manifest.
- [x] Run image checks and disposable OVMF/KVM liveboot; verify rootfilesystem ready and expected tools. Commit rootfs/imageconfig. - [ ] Run image checks and disposable OVMF/KVM liveboot; verify rootfilesystem ready and expected tools. Commit rootfs/imageconfig.
### Task 3: Explicit guarded installer ### Task 3: Explicit guarded installer
**Files:** `bin/hydra-install`, `hydra/installer.py`, `tests/test_installer.py`. **Files:** `bin/hydra-install`, `hydra/installer.py`, `tests/test_installer.py`.
**Interfaces:** `hydra-install --disk DEVICE --user USER [--execute --confirm-erase DEVICE]`; without execute prints exactplan. Environment/default rootfs path points to live squashfs, never running root. Password via getpass/TTY, never CLI/log. Separate nonsecret dryrun from execution. **Interfaces:** `hydra-install --disk DEVICE --user USER [--execute --confirm-erase DEVICE]`; without execute prints exactplan. Environment/default rootfs path points to live squashfs, never running root. Password via getpass/TTY, never CLI/log. Separate nonsecret dryrun from execution.
- [x] Test missing/mismatched confirmation, disk mounted directly or through children/ancestors, root/live source backingdisk, invaliddevice, symlink resolution, invalidusername, failedcommands abort subsequentsteps. - [ ] Test missing/mismatched confirmation, disk mounted directly or through children/ancestors, root/live source backingdisk, invaliddevice, symlink resolution, invalidusername, failedcommands abort subsequentsteps.
- [x] Observe RED; implement sysfs/lsblk topology validation, GPT1GiBESP/ext4, payload extraction, fstabUUIDs, user/password setup and liveoverridesremoval. Explicit target, no automaticdiscoveryselection. - [ ] Observe RED; implement sysfs/lsblk topology validation, GPT1GiBESP/ext4, payload extraction, fstabUUIDs, user/password setup and liveoverridesremoval. Explicit target, no automaticdiscoveryselection.
- [x] Install regularinitramfs and GRUBfallback --removable --no-nvram. Checked subprocessargv; handle failure diagnostics and mounts cleanup without deletingtargetdata. - [ ] Install regularinitramfs and GRUBfallback --removable --no-nvram. Checked subprocessargv; handle failure diagnostics and mounts cleanup without deletingtargetdata.
- [x] Test fakecommandfailure paths then complete actualinstallation inside fresh VM using disposable qcow2. Rootprivilege exists only in testguest. - [ ] Test fakecommandfailure paths then complete actualinstallation inside fresh VM using disposable qcow2. Rootprivilege exists only in testguest.
- [x] Commit installer and negative tests. - [ ] Commit installer and negative tests.
### Task 4: Installed-system validation and publication ### Task 4: Installed-system validation and publication
**Files:** `dev/iso-smoke.py`, `docs/install.md`, `docs/verification.md`, `README.md`. **Files:** `dev/iso-smoke.py`, `docs/install.md`, `docs/verification.md`, `README.md`.
**Interfaces:** Consumes ISO and installer. Produces evidence from boot/install/reboot and releaseartifact checksum, plus pendinghardwarechecklist. **Interfaces:** Consumes ISO and installer. Produces evidence from boot/install/reboot and releaseartifact checksum, plus pendinghardwarechecklist.
- [x] Boot ISO under OVMF/KVM with fresh disk; install only there; write persistence sentinel. - [ ] Boot ISO under OVMF/KVM with fresh disk; install only there; write persistence sentinel.
- [x] Remove ISO and boot installeddisk; verify sentinel, services, QEMUtools, network and shell/recovery. Check no liveoverlay/autologinadminoverride remains. - [ ] Remove ISO and boot installeddisk; verify sentinel, services, QEMUtools, network and shell/recovery. Check no liveoverlay/autologinadminoverride remains.
- [x] Test nested guest VM if KVM nesting available; distinguish virtualgraphics from real laptopresults. - [ ] Test nested guest VM if KVM nesting available; distinguish virtualgraphics from real laptopresults.
- [x] Run full test suite and independent review. Correct material defects; document remaining physicalchecks without claiming them passed. - [ ] Run full test suite and independent review. Correct material defects; document remaining physicalchecks without claiming them passed.
- [x] Save ISO locally with checksum, push source/patches/manifests to suppliedrepo. BinaryISO only uploaded when suitable repository release/storage path exists; never commit ISO to Git. - [ ] Save ISO locally with checksum, push source/patches/manifests to suppliedrepo. BinaryISO only uploaded when suitable repository release/storage path exists; never commit ISO to Git.
@@ -50,12 +50,12 @@ unittest; `dev/` holds pinned tools and disposable integration probes.
- `load_profile(path: Path) -> Profile`, `build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]`, `QMPClient.command(name: str, arguments: dict | None = None) -> dict`. - `load_profile(path: Path) -> Profile`, `build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]`, `QMPClient.command(name: str, arguments: dict | None = None) -> dict`.
- `VMPaths` provides disk, log, lock and private QMP/SPICE sockets. Session consumes stable name and socket paths only. - `VMPaths` provides disk, log, lock and private QMP/SPICE sockets. Session consumes stable name and socket paths only.
- [x] Write validation/QMP fake-server tests: invalid names/resources, missing ISO, paths containing spaces, greeting/capabilities, asynchronous events, IDs, timeouts. - [ ] Write validation/QMP fake-server tests: invalid names/resources, missing ISO, paths containing spaces, greeting/capabilities, asynchronous events, IDs, timeouts.
- [x] Run `python3 -m unittest discover -s tests -v`; observe failure before implementation. - [ ] Run `python3 -m unittest discover -s tests -v`; observe failure before implementation.
- [x] Implement validation and bounded QMP; subprocess argv arrays, named environment tool overrides for tests. - [ ] Implement validation and bounded QMP; subprocess argv arrays, named environment tool overrides for tests.
- [x] Add lifecycle tests: simultaneous start, existing-disk preservation, stale socket, failed boot, shutdown vs force, QMP VM identity. - [ ] Add lifecycle tests: simultaneous start, existing-disk preservation, stale socket, failed boot, shutdown vs force, QMP VM identity.
- [x] Implement locked safe disk creation, detached QEMU, virtio disk/network, SPICE no clipboard/file transfer, optional Intel GL. No display dependency in manager and no PID-only stop. - [ ] Implement locked safe disk creation, detached QEMU, virtio disk/network, SPICE no clipboard/file transfer, optional Intel GL. No display dependency in manager and no PID-only stop.
- [x] Run complete suite, help, scratch QEMU lifecycle probe. Commit `feat: add independent VM manager`. - [ ] Run complete suite, help, scratch QEMU lifecycle probe. Commit `feat: add independent VM manager`.
### Task 2: Minimal fullscreen session ### Task 2: Minimal fullscreen session
@@ -63,12 +63,12 @@ unittest; `dev/` holds pinned tools and disposable integration probes.
**Interfaces:** Consumes Task1 Profile/VMPaths. Produces `hydra view NAME [--windowed]`, `hydra session [--nested]`, labwc config only in selected runtime. Viewer title is deterministic from validated name; each VM maps to one workspace. Recovery opens host terminal; session never shuts down a VM. **Interfaces:** Consumes Task1 Profile/VMPaths. Produces `hydra view NAME [--windowed]`, `hydra session [--nested]`, labwc config only in selected runtime. Viewer title is deterministic from validated name; each VM maps to one workspace. Recovery opens host terminal; session never shuts down a VM.
- [x] Probe actual labwc version/overrideInhibition and remote-viewer kiosk USB behavior on disposable state; record versions. - [ ] Probe actual labwc version/overrideInhibition and remote-viewer kiosk USB behavior on disposable state; record versions.
- [x] Resolve manual USB dialog with a small upstream viewer adaptation if necessary; never auto-redirect USB. - [ ] Resolve manual USB dialog with a small upstream viewer adaptation if necessary; never auto-redirect USB.
- [x] Write tests for valid escaped XML, exact window/workspace/fullscreen rules, no default keyboard/mouse bindings, overrideInhibition on host keys, missing viewer/display errors. - [ ] Write tests for valid escaped XML, exact window/workspace/fullscreen rules, no default keyboard/mouse bindings, overrideInhibition on host keys, missing viewer/display errors.
- [x] Run session tests and observe missing implementation failure. - [ ] Run session tests and observe missing implementation failure.
- [x] Implement session/viewer commands and pinned temporary tool environment; no host installation. - [ ] Implement session/viewer commands and pinned temporary tool environment; no host installation.
- [x] Run full tests and labwc config parsing. Commit `feat: add fullscreen HydraOS session`. - [ ] Run full tests and labwc config parsing. Commit `feat: add fullscreen HydraOS session`.
### Task 3: Integration and recovery ### Task 3: Integration and recovery
@@ -76,12 +76,12 @@ unittest; `dev/` holds pinned tools and disposable integration probes.
**Interfaces:** Consumes Tasks1/2, produces disposable-VM integration evidence. **Interfaces:** Consumes Tasks1/2, produces disposable-VM integration evidence.
- [x] Run two disposable KVM instances/viewers, never existing lab disks; verify private QMP and independent lifecycle. - [ ] Run two disposable KVM instances/viewers, never existing lab disks; verify private QMP and independent lifecycle.
- [x] Verify viewer/session exit preserves QEMU and reattach works; switching neither restarts nor reconnects guests. - [ ] Verify viewer/session exit preserves QEMU and reattach works; switching neither restarts nor reconnects guests.
- [x] Check input only in disposable session, without injecting into current host or user's guests; add regression tests for discovered defects. - [ ] Check input only in disposable session, without injecting into current host or user's guests; add regression tests for discovered defects.
- [x] Record held-key, physical brightness and 144Hz checks needing dedicated TTY. Nested tests cannot prove them. - [ ] Record held-key, physical brightness and 144Hz checks needing dedicated TTY. Nested tests cannot prove them.
- [x] Run full suite and independent review; fix material findings with reproducing tests. - [ ] Run full suite and independent review; fix material findings with reproducing tests.
- [x] Commit evidence and push reviewed prototype branch to supplied repository. - [ ] Commit evidence and push reviewed prototype branch to supplied repository.
## Next plan ## Next plan
@@ -1,118 +0,0 @@
# 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).
@@ -1,103 +0,0 @@
# 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.
@@ -1,53 +0,0 @@
# 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.
@@ -1,103 +0,0 @@
# 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.
@@ -1,64 +0,0 @@
# 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.
-135
View File
@@ -1,135 +0,0 @@
# 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.
-125
View File
@@ -1,125 +0,0 @@
# 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
View File
@@ -1,165 +0,0 @@
# 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.
-199
View File
@@ -1,199 +0,0 @@
# Graphics and recovery verification
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
switching and hidden workload continuity. These virtual checks leave the physical
ASUS checks listed below pending. No current host configuration or existing user
VM disk was changed for this validation.
Run from the checkout. Tools are temporary Nix development dependencies; no
persistent host installation is required. The two-guest smoke test needs access
to `/dev/kvm` and creates its own headless Wayland display, VM disks and sockets
under a fresh `/tmp/hydra-smoke-*` directory. It never uses the inherited display,
active TTY, existing guests or existing disks. Evidence remains in that directory;
the script stops only its own verified QEMU instances on exit.
```sh
python3 -m unittest discover -s tests -v
nix-build dev/tools.nix -A viewer --no-out-link
nix-build dev/tools.nix -A qemu --no-out-link
nix-shell dev/tools.nix --run 'python3 dev/smoke.py'
nix-shell dev/tools.nix --run 'python3 dev/session-refresh-smoke.py --keyboard "$(dev/build-input-probe.sh)"'
nix-shell dev/tools.nix --run 'python3 dev/check-network.py'
```
The smoke test builds a private input helper with an ordinary evdev/pc105 US
keyboard map. It boots two tiny counter workloads from separate scratch qcow2
disks, connects two fullscreen viewers, and checks reserved switching/recovery,
manual USB selection, explicit reconnect and independent QMP lifecycles.
After each confirmed workspace switch, the hidden guest is sampled across a
half-second interval with no focus changes, switches or reconnects; the check is
then repeated with the other guest hidden. `result.json` records both intervals.
Guest memory counters must advance and QEMU PIDs remain unchanged through
switching. Viewer protocol logs must show no reconnection during switching.
A test-only `ToggleKeybinds` action and F12 marker establish native labwc
keybinding inhibition before checking the production reserved bindings. Those
extra controls are absent from the production session configuration. See the
[labwc configuration manual](https://labwc.github.io/labwc-config.5.html) for
`overrideInhibition` and the [actions manual](https://labwc.github.io/labwc-actions.5.html)
for native `ToggleKeybinds` behavior.
USB acceptance checks the actual selector window title, holds the modifiers
through opening, releases them inside the modal dialog, then asserts fresh
keyboard leave/enter transitions for both Right and Left switches.
QEMU's USB device list must stay unchanged without a selection. Both viewer
USB filters deny automatic hotplug and connection redirection. This check does
not attach a physical USB device or prove guest driver behavior.
The viewer adaptation handles only the configured manual USB accelerator before
forwarding an event to the guest. Upstream keyboard grabs remove the GTK
application accelerator group; without this adaptation, the configured USB
shortcut reaches the viewer but does not open its selector.
The pinned labwc 0.20.2 runtime has no documented runtime XWayland-disable
option. This native-Wayland probe sets `WLR_XWAYLAND=/bin/false` so no X server
is launched, and explicitly disables activation-environment updates. It can
still log an occupied X socket while wlroots reserves a display number. Only
`[xwayland/sockets.c] Failed to bind socket ... Address already in use` is
classified as expected noise in `result.json`; any other compositor `[ERROR]`
fails the probe. Full logs remain available. This classification does not
suppress viewer errors or compiler/test diagnostics. The viewer build's upstream
Meson boolean-check and compiler-warning-option guidance are build noise; the four-test
result, compiler errors and process exit status remain the build acceptance gate.
## Fresh-session recovery and profile changes
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.
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.
## Bridge identities
New profiles persist a locally administered unicast `mac` in their TOML file.
It remains stable across stop/start, media changes and runtime socket-directory
changes. The generated address incorporates the VM disk path and user identity,
so separate profiles/users avoid QEMU's identical default MAC on a shared bridge.
Existing profiles without `mac` use the same deterministic calculation without
rewriting their file. Relocating a legacy disk or changing its owner changes
that fallback; explicitly persisting its current address retains the identity.
Explicit TOML addresses must also be locally administered unicast, and users
copying profiles must assign distinct addresses to concurrently used copies.
`dev/check-network.py` requires unprivileged user/network/mount namespaces. It
asserts all three namespace identities changed before creating a private
`hydrabr0` and mounting a private QEMU bridge-helper ACL over `/etc` within that
namespace. Two actual scratch QEMUs must report different configured NIC MACs,
both bridge TAP ports must exist, and restart must preserve the first guest's
address. Evidence remains under `/tmp/hydra-network-*`; host bridges, host `/etc`
and existing guests are unchanged. This does not prove guest network drivers,
DHCP leases or external connectivity; those need the installed-guest harness.
## Installed guest workflow
`--iso` is optional. Attach or detach media only while the VM is stopped:
```sh
bin/hydra add guest --iso /absolute/path/installer.iso
bin/hydra start guest
bin/hydra view guest
# Shut down from inside the guest, then check it is stopped.
bin/hydra status guest
bin/hydra media guest --detach
bin/hydra start guest
```
For an existing stopped guest, `bin/hydra media guest --iso PATH` attaches
installation/rescue media. A missing configured ISO prevents a new start with
an actionable error; it does not prevent listing profiles, status, stopping a
running guest, reconnecting a viewer or detaching the missing media. Media
updates share the VM lifecycle lock and preserve its qcow2 disk. Abrupt stopping
still requires `bin/hydra stop guest --force` and verified QMP identity.
## Display preferences and EDID
New developer profiles use user networking, 1280×800 and `refresh_millihz=0`.
Zero omits the patched QEMU refresh property and remains compatible with stock
QEMU. Hydra's installed defaults can use this flat `/etc/hydra/defaults.toml`:
```toml
network = "bridge"
bridge = "hydrabr0"
width = 1920
height = 1080
refresh_millihz = 144000
```
Only `add` reads distribution defaults. Explicit `--network`, `--bridge`,
`--width`, `--height` and `--refresh-millihz` options win; existing profiles do
not change. `--defaults PATH` selects another preferences file for development.
Missing defaults use developer preferences; unknown or invalid fields fail
before creating a profile. Dimensions are integers from 1 to 16384, refresh is
zero for automatic refresh or an integer from 50000 to 255000 mHz, matching
the vertical range descriptor limits. This bound does not establish every
resolution/refresh combination; pixel-clock and horizontal timing limits are
mode-dependent and the verified target remains 1920×1080/144000 mHz. Nonzero
refresh requires the tracked patched QEMU provided by `dev/tools.nix` and distribution packaging.
The patch keeps the requested refresh through frontend resizing and extends
EDID monitor ranges to include the generated preferred mode. To verify the
actual patched QEMU generator with the pinned source archive:
```sh
qemu_source=$(nix-build dev/tools.nix -A qemu.src --no-out-link)
nix-shell dev/tools.nix --run "python3 dev/check-edid.py '$qemu_source'"
```
The probe applies the tracked patch to four relevant source files in a temporary
directory, compiles the actual EDID and virtio GPU generator functions with a
small dependency harness, and decodes the result. It checks 60/75 Hz fallback,
144 Hz configuration, a conflicting 60 Hz frontend request, resized dimensions,
checksums and monitor ranges. It also checks the supported vertical-refresh
boundaries: 1920×1080/50000 mHz and 1280×800/255000 mHz. Range endpoints
use floor/ceiling of the encoded timing after pixel-clock rounding, so the
49.998895 Hz lower-boundary timing is included by a 49 Hz minimum. Normal
automatic 60/75 Hz cases retain the legacy 50–125 Hz range. At 1920×1080/144000 mHz the preferred timing is
143.997231 Hz after EDID clock rounding. This is an advertised guest mode,
not measured frame delivery or proof of a 144 Hz physical output.
## Still requiring a dedicated physical session
- Physical reserved chords, brightness/backlight permissions and touchpad input.
- Physical held keys/modifiers across guest switching, grabs and modal dialogs;
guest key-up delivery, pointer grab release and absence of stuck modifiers.
- Physical USB selection/redirection and guest drivers; no automatic redirection.
- Toolbar behavior at physical screen edges and actual recovery shell usability.
- ASUS output selection at 1920×1080/144 Hz, guest-reported display mode, delivered
frame rate, hidden guest graphics continuity, tearing and switching latency.
Headless checks establish CPU workload continuity, protocol focus transitions
and the exercised compositor actions. They cannot prove physical input or scanout.
+1 -1
View File
@@ -1 +1 @@
"""Hydra OS independent VM management.""" """HydraOS independent VM management."""
+10 -199
View File
@@ -9,7 +9,6 @@ import os
from pathlib import Path from pathlib import Path
import re import re
import shutil import shutil
import shlex
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@@ -58,21 +57,6 @@ sys.exit(code)
''' '''
def rootfs_retry_guard_script():
"""Refuse deletion of a still-mounted export, including nested mountpoints."""
return r"""mounts=$(findmnt --raw --noheadings --output TARGET)
while IFS= read -r mountpoint; do
case "$mountpoint" in
"$root"|"$root/"*) echo 'Refusing to remove a mounted rootfs export' >&2; exit 1 ;;
esac
done <<< "$mounts"
if [ -e "$root" ] || [ -L "$root" ]; then
[ ! -L "$root" ] && [ -f "$root/.hydra-export-owned" ] || { echo 'Unowned rootfs output' >&2; exit 1; }
rm -rf -- "$root"
fi
"""
def verify_hash(path, expected): def verify_hash(path, expected):
digest = hashlib.sha256() digest = hashlib.sha256()
with Path(path).open('rb') as stream: with Path(path).open('rb') as stream:
@@ -206,8 +190,7 @@ if path.exists():
def input_id(self): def input_id(self):
digest = hashlib.sha256() digest = hashlib.sha256()
for directory in ('image', 'patches'): for path in sorted((self.source_dir / 'image').rglob('*')):
for path in sorted((self.source_dir / directory).rglob('*')):
if path.is_file(): if path.is_file():
digest.update(str(path.relative_to(self.source_dir)).encode()) digest.update(str(path.relative_to(self.source_dir)).encode())
digest.update(path.read_bytes()) digest.update(path.read_bytes())
@@ -226,17 +209,8 @@ if path.exists():
entry = self.manifest().get(step, {}) entry = self.manifest().get(step, {})
if not entry.get('complete') or entry.get('inputs') != self.input_id(): if not entry.get('complete') or entry.get('inputs') != self.input_id():
return False return False
if step in ('rootfs', 'iso'):
from hydra import image
if entry.get('source_inputs') != image.source_identity(self.source_dir):
return False
if not image.artifacts_valid(self.work / 'artifacts', entry):
return False
return self.complete('packages' if step == 'rootfs' else 'rootfs')
if step == 'packages': if step == 'packages':
try: if not (self.work / 'artifacts/packages.json').is_file():
self.package_inventory()
except (BuildError, OSError):
return False return False
return self.complete('prepare') return self.complete('prepare')
if step == 'prepare': if step == 'prepare':
@@ -252,7 +226,8 @@ if path.exists():
if step == 'fetch': if step == 'fetch':
inputs = json.loads((self.source_dir / 'image/sources.json').read_text()) inputs = json.loads((self.source_dir / 'image/sources.json').read_text())
try: try:
for item in [inputs['stage3'], inputs['portage'], *inputs.get('local_packages', [])]: for name in ('stage3', 'portage'):
item = inputs[name]
path = self.cache / item.get('filename', Path(urllib.parse.urlparse(item['url']).path).name) path = self.cache / item.get('filename', Path(urllib.parse.urlparse(item['url']).path).name)
verify_hash(path, item['sha256']) verify_hash(path, item['sha256'])
except (OSError, BuildError): except (OSError, BuildError):
@@ -260,16 +235,6 @@ if path.exists():
return True return True
return False return False
def package_inventory(self):
from hydra import image
checksum = self.manifest().get('packages', {}).get('inventory_sha256')
if not isinstance(checksum, str) or not re.fullmatch('[a-f0-9]{64}', checksum):
raise BuildError('Package inventory has no recorded checksum')
try:
return image.read_package_inventory(self.work / 'artifacts/packages.json', checksum)
except (image.ImageError, OSError) as error:
raise BuildError(f'Package inventory validation failed: {error}') from error
def download(self, url, filename=None): def download(self, url, filename=None):
parsed = urllib.parse.urlparse(url) parsed = urllib.parse.urlparse(url)
if parsed.scheme != 'https': if parsed.scheme != 'https':
@@ -290,8 +255,6 @@ if path.exists():
verify_hash(archive, stage['sha256']) verify_hash(archive, stage['sha256'])
snapshot = self.download(inputs['portage']['url'], inputs['portage']['filename']) snapshot = self.download(inputs['portage']['url'], inputs['portage']['filename'])
verify_hash(snapshot, inputs['portage']['sha256']) verify_hash(snapshot, inputs['portage']['sha256'])
for item in inputs.get('local_packages', []):
verify_hash(self.download(item['url'], item['filename']), item['sha256'])
signature = self.download(stage['signature_url']) signature = self.download(stage['signature_url'])
keyring = self.download(inputs['keyring_url']) keyring = self.download(inputs['keyring_url'])
verify_hash(keyring, inputs['keyring_sha256']) verify_hash(keyring, inputs['keyring_sha256'])
@@ -332,12 +295,6 @@ if [ ! -f /var/db/repos/gentoo/.hydra-snapshot ]; then
printf '%s\\n' '{inputs['portage']['commit']}' > /var/db/repos/gentoo/.hydra-snapshot printf '%s\\n' '{inputs['portage']['commit']}' > /var/db/repos/gentoo/.hydra-snapshot
fi fi
cp -a /hydra-source/image/portage/. /etc/portage/ cp -a /hydra-source/image/portage/. /etc/portage/
mkdir -p /var/db/repos/hydra /var/cache/distfiles
cp -a /hydra-source/image/overlay/. /var/db/repos/hydra/
cp /hydra-cache/brightnessctl-0.5.1.tar.gz /var/cache/distfiles/brightnessctl-0.5.1.tar.gz
mkdir -p /etc/portage/patches/app-emulation/qemu /etc/portage/patches/app-emulation/virt-viewer
cp /hydra-source/patches/qemu-virtio-refresh-rate.patch /etc/portage/patches/app-emulation/qemu/10-hydra-refresh.patch
cp /hydra-source/patches/virt-viewer-hydra-session.patch /etc/portage/patches/app-emulation/virt-viewer/10-hydra-session.patch
if [ -d /etc/portage/gnupg ] && [ ! -s /etc/portage/gnupg/mykeyid ]; then if [ -d /etc/portage/gnupg ] && [ ! -s /etc/portage/gnupg/mykeyid ]; then
mv /etc/portage/gnupg /etc/portage/gnupg.hydra-untrusted mv /etc/portage/gnupg /etc/portage/gnupg.hydra-untrusted
fi fi
@@ -354,167 +311,19 @@ chmod a+r /etc/portage/gnupg/pubring.kbx /etc/portage/gnupg/trustdb.gpg
atoms = [line.strip() for line in (self.source_dir / 'image/packages').read_text().splitlines() atoms = [line.strip() for line in (self.source_dir / 'image/packages').read_text().splitlines()
if line.strip() and not line.startswith('#')] if line.strip() and not line.startswith('#')]
import shlex import shlex
command = ['emerge', '--verbose', '--update', '--newuse', '--deep', '--backtrack=100', command = ['emerge', '--verbose', '--update', '--newuse', '--deep', '--backtrack=100']
'--usepkg-exclude=app-emulation/qemu app-emulation/virt-viewer']
if pretend: if pretend:
self.exec(shlex.join([*command, '--pretend', *atoms])) self.exec(shlex.join([*command, '--pretend', *atoms]))
return return
inputs = self.input_id() inputs = self.input_id()
self.record('packages', complete=False, job_log='/var/lib/hydra-build/packages.log') self.record('packages', complete=False, job_log='/var/lib/hydra-build/packages.log')
patches = hashlib.sha256() script = package_job_script([*command, *atoms], inputs=inputs)
for name in ('qemu-virtio-refresh-rate.patch', 'virt-viewer-hydra-session.patch'):
patches.update((self.source_dir / 'patches' / name).read_bytes())
patch_id = patches.hexdigest()
forced = ['emerge', '--verbose', '--oneshot', '--usepkg=n', '--getbinpkg=n',
'app-emulation/qemu', 'app-emulation/virt-viewer']
package_commands = shlex.join([*command, *atoms]) + '\n' + f'''if [ "$(cat /var/lib/hydra-build/patched-source.sha256 2>/dev/null || true)" != {shlex.quote(patch_id)} ]; then
{shlex.join(forced)}
printf '%s\\n' {shlex.quote(patch_id)} > /var/lib/hydra-build/patched-source.sha256
fi'''
script = package_job_script(['/bin/bash', '-euc', package_commands], inputs=inputs)
self.exec(shlex.join(['python3', '-c', script])) self.exec(shlex.join(['python3', '-c', script]))
if self.input_id() != inputs: if self.input_id() != inputs:
raise BuildError('Image inputs changed during package installation; completion was not recorded') raise BuildError('Image inputs changed during package installation; completion was not recorded')
inventory = self.exec('cat /var/lib/hydra-build/packages.json', capture=True).stdout inventory = self.exec('cat /var/lib/hydra-build/packages.json', capture=True).stdout
from hydra import image (self.work / 'artifacts/packages.json').write_text(inventory)
try: self.record('packages', inventory='artifacts/packages.json')
image.validate_package_inventory(json.loads(inventory))
except (ValueError, image.ImageError) as error:
raise BuildError(f'Package inventory is malformed: {error}') from error
target = self.work / 'artifacts/packages.json'
temporary = target.with_suffix('.tmp')
temporary.write_text(inventory)
temporary.replace(target)
self.record('packages', inventory='artifacts/packages.json', patched_source=patch_id,
inventory_sha256=image.digest(target))
def assembly(self, snapshot_digest=None):
"""A separate rootless container processes an exported copy, never the builder."""
if snapshot_digest is None:
state = self.manifest().get('rootfs', {})
snapshot_digest = state.get('snapshot_sha256')
if not isinstance(snapshot_digest, str) or not re.fullmatch('[a-f0-9]{64}', snapshot_digest):
raise BuildError('Verified exported snapshot is required')
name = self.name + '-image-' + snapshot_digest[:12]
image = 'localhost/hydra-image:' + snapshot_digest
exists = self.pod('container', 'exists', name, capture=True, check=False).returncode == 0
if not exists:
self.pod('import', str(self.work / 'artifacts/builder-rootfs.tar'), image)
self.pod('run', '--detach', '--name', name, '--hostname', 'hydra-image-assembler',
'--network', 'none', '--cap-add', 'SYS_ADMIN', '--security-opt', 'seccomp=unconfined',
'--volume', f'{self.source_dir}:/hydra-source:ro',
'--volume', f'{self.work}:/hydra-output:rw', image, '/bin/sleep', 'infinity')
container = self.inspect('container', name)
for destination, source, writable in (('/hydra-source', self.source_dir, False),
('/hydra-output', self.work, True)):
mounts = [mount for mount in container.get('Mounts', []) if mount.get('Destination') == destination]
if len(mounts) != 1 or mounts[0].get('Source') != str(source) or mounts[0].get('RW') is not writable:
raise BuildError('Assembly mount provenance differs; refusing to write')
provenance = self.inspect('image', container['Image'])
if provenance.get('RootFS', {}).get('Layers') != ['sha256:' + snapshot_digest]:
raise BuildError('Assembly image does not match the exported builder snapshot')
if exists:
self.pod('start', name)
return name
def rootfs(self):
from hydra import image
if self.active_package_job():
raise BuildError('A contained package job is running; wait before export')
if not self.complete('packages'):
raise BuildError('Verified completed packages are required before rootfs export')
if self.complete('rootfs'):
return
source_inputs = image.source_identity(self.source_dir)
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.
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"
touch "$root/.hydra-export-owned"
tar --extract --preserve-permissions --numeric-owner --file /hydra-output/artifacts/builder-rootfs.tar --directory "$root"
/hydra-source/image/configure-rootfs.sh "$root"
cp "$(python3 /hydra-source/hydra/image.py kernel-image "$root")" /hydra-output/artifacts/vmlinuz
cp "$root/boot/initramfs-live.img" /hydra-output/artifacts/initramfs-live.img
cp "$root/usr/share/hydra/build-manifest.json" /hydra-output/artifacts/rootfs-manifest.json
# Keep ownership for safe retries outside the shipped payload.
mksquashfs "$root" /hydra-output/artifacts/squashfs.img -noappend -comp zstd -processors 4 -e boot/initramfs-live.img .hydra-export-owned
chmod 0644 /hydra-output/artifacts/vmlinuz /hydra-output/artifacts/initramfs-live.img /hydra-output/artifacts/squashfs.img /hydra-output/artifacts/rootfs-manifest.json
"""
self.pod('exec', '--env', f'HYDRA_SOURCE_COMMIT={commit}', assembler, '/bin/bash', '-euc', script)
if image.source_identity(self.source_dir) != source_inputs:
raise BuildError('Source inputs changed during rootfs assembly; completion not recorded')
manifest = json.loads((self.work / 'artifacts/rootfs-manifest.json').read_text())
if manifest.get('packages') != self.package_inventory():
raise BuildError('Payload package inventory differs from the validated package checkpoint')
if manifest.get('source_files') != image.source_files(self.source_dir):
raise BuildError('Payload source manifest is incomplete or stale')
artifacts = {name: image.digest(self.work / 'artifacts' / name) for name in
('vmlinuz', 'initramfs-live.img', 'squashfs.img', 'rootfs-manifest.json', 'packages.json')}
self.record('rootfs', source_inputs=source_inputs, snapshot_sha256=snapshot,
assembler=assembler, kernel=manifest['kernel'], artifacts=artifacts)
def iso(self):
from hydra import image
if not self.complete('rootfs'):
raise BuildError('Verified rootfs artifacts are required before ISO assembly')
if self.complete('iso'):
return
source_inputs = image.source_identity(self.source_dir)
assembler = self.assembly()
self.record('iso', complete=False)
script = r"""tree=/hydra-output/iso-tree
[ ! -L "$tree" ] || { echo 'ISO output cannot be a symlink' >&2; exit 1; }
mkdir -p "$tree/boot/grub" "$tree/LiveOS"
cp /hydra-output/artifacts/vmlinuz "$tree/boot/vmlinuz"
cp /hydra-output/artifacts/initramfs-live.img "$tree/boot/initramfs-live.img"
cp /hydra-output/artifacts/squashfs.img "$tree/LiveOS/squashfs.img"
cp /hydra-source/image/grub.cfg "$tree/boot/grub/grub.cfg"
cp /hydra-output/artifacts/rootfs-manifest.json "$tree/build-manifest.json"
cp /hydra-output/artifacts/packages.json "$tree/packages.json"
grub-mkrescue --directory=/usr/lib/grub/x86_64-efi --output=/hydra-output/artifacts/hydra-os.iso "$tree" -- -volid HYDRA_OS
xorriso -indev /hydra-output/artifacts/hydra-os.iso -report_el_torito plain -pvd_info > /hydra-output/artifacts/iso-inspection.txt 2>&1
chmod 0644 /hydra-output/artifacts/hydra-os.iso /hydra-output/artifacts/iso-inspection.txt
"""
self.pod('exec', assembler, '/bin/bash', '-euc', script)
inspection = (self.work / 'artifacts/iso-inspection.txt').read_text()
if 'HYDRA_OS' not in inspection or not re.search(r'El Torito boot img.*UEFI', inspection):
raise BuildError('ISO does not report the expected volume label and UEFI boot image')
if image.source_identity(self.source_dir) != source_inputs:
raise BuildError('Source inputs changed during ISO assembly; completion not recorded')
checksum = image.digest(self.work / 'artifacts/hydra-os.iso')
(self.work / 'artifacts/hydra-os.iso.sha256').write_text(checksum + ' hydra-os.iso\n')
artifacts = {name: image.digest(self.work / 'artifacts' / name) for name in
('hydra-os.iso', 'hydra-os.iso.sha256', 'iso-inspection.txt', 'packages.json')}
self.record('iso', source_inputs=source_inputs, label=image.LABEL, artifacts=artifacts)
def status(self): def status(self):
data = self.manifest() data = self.manifest()
@@ -571,6 +380,8 @@ def main(argv=None):
builder.status() builder.status()
return 0 return 0
with builder.locked(): with builder.locked():
if options.step in ('rootfs', 'iso'):
raise BuildError(f'{options.step} assembly is not configured yet')
if options.step == 'packages': if options.step == 'packages':
builder.packages(options.pretend) builder.packages(options.pretend)
else: else:
+11 -98
View File
@@ -7,57 +7,26 @@ import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from .profiles import Profile, load_defaults from .profiles import Profile
from .runtime import Runtime, RuntimeError from .runtime import Runtime, RuntimeError
from .session import launch_session, session_helper, view from .session import launch_session, session_helper, view
def parser() -> argparse.ArgumentParser: def parser() -> argparse.ArgumentParser:
cli = argparse.ArgumentParser(prog='hydra', description='Hydra OS independent VM manager') cli = argparse.ArgumentParser(prog='hydra', description='Independent HydraOS VM manager')
cli.add_argument('--config', type=Path, default=Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'hydra') 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('--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('--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'), commands = cli.add_subparsers(dest='command', required=True)
help='Distribution preferences for newly created profiles')
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 = commands.add_parser('add', help='Create a VM profile without starting it')
add.add_argument('name') add.add_argument('name')
add.add_argument('--iso', type=Path) add.add_argument('--iso', type=Path, required=True)
add.add_argument('--memory', type=int, default=4096) add.add_argument('--memory', type=int, default=4096)
add.add_argument('--cpus', type=int, default=2) add.add_argument('--cpus', type=int, default=2)
add.add_argument('--disk-size', default='40G') add.add_argument('--disk-size', default='40G')
add.add_argument('--graphics', choices=('auto', 'software'), default='auto') add.add_argument('--graphics', choices=('auto', 'software'), default='auto')
add.add_argument('--network', choices=('user', 'bridge')) add.add_argument('--network', choices=('user', 'bridge'), default='user')
add.add_argument('--bridge') add.add_argument('--bridge', default='hydrabr0')
add.add_argument('--width', type=int)
add.add_argument('--height', type=int)
add.add_argument('--refresh-millihz', type=int)
media = commands.add_parser('media', help='Attach or detach installation media while stopped')
media.add_argument('name')
selection = media.add_mutually_exclusive_group(required=True)
selection.add_argument('--iso', type=Path)
selection.add_argument('--detach', action='store_true')
commands.add_parser('list', help='List configured VMs') commands.add_parser('list', help='List configured VMs')
status = commands.add_parser('status', help='Query VM state through private QMP') status = commands.add_parser('status', help='Query VM state through private QMP')
status.add_argument('name', nargs='?') status.add_argument('name', nargs='?')
@@ -69,7 +38,7 @@ def parser() -> argparse.ArgumentParser:
viewer = commands.add_parser('view', help='Connect a viewer without changing VM power') viewer = commands.add_parser('view', help='Connect a viewer without changing VM power')
viewer.add_argument('name') viewer.add_argument('name')
viewer.add_argument('--windowed', action='store_true') viewer.add_argument('--windowed', action='store_true')
session = commands.add_parser('session', help='Start the minimal Hydra OS labwc session') session = commands.add_parser('session', help='Start the minimal HydraOS labwc session')
session.add_argument('--nested', action='store_true') session.add_argument('--nested', action='store_true')
helper = commands.add_parser('_session-helper', help=argparse.SUPPRESS) helper = commands.add_parser('_session-helper', help=argparse.SUPPRESS)
helper.add_argument('operation') helper.add_argument('operation')
@@ -77,57 +46,10 @@ def parser() -> argparse.ArgumentParser:
return cli 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: def main(argv: list[str] | None = None) -> int:
cli = parser() args = parser().parse_args(argv)
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) runtime = Runtime(args.config, args.state, args.runtime)
if args.command is None and not sys.stdin.isatty(): try:
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': if args.command == 'view':
return view(runtime, args.name, windowed=args.windowed) return view(runtime, args.name, windowed=args.windowed)
elif args.command == 'session': elif args.command == 'session':
@@ -135,18 +57,12 @@ def main(argv: list[str] | None = None) -> int:
elif args.command == '_session-helper': elif args.command == '_session-helper':
return session_helper(args.operation, args.manifest) return session_helper(args.operation, args.manifest)
elif args.command == 'add': elif args.command == 'add':
preferences = load_defaults(args.defaults)
preferences.update({key: getattr(args, key) for key in
('network', 'bridge', 'width', 'height', 'refresh_millihz')
if getattr(args, key) is not None})
profile = Profile(args.name, args.iso, args.memory, args.cpus, args.disk_size, profile = Profile(args.name, args.iso, args.memory, args.cpus, args.disk_size,
args.graphics, **preferences) args.graphics, args.network, args.bridge)
runtime.add(profile) runtime.add(profile)
result = {'name': profile.name, 'status': 'added'} result = {'name': profile.name, 'status': 'added'}
elif args.command == 'media':
result = runtime.media(args.name, None if args.detach else args.iso)
elif args.command == 'list': elif args.command == 'list':
result = [{'name': profile.name, 'iso': str(profile.iso) if profile.iso else None} for profile in runtime.profiles()] result = [{'name': profile.name, 'iso': str(profile.iso)} for profile in runtime.profiles()]
elif args.command == 'status': elif args.command == 'status':
result = runtime.status(args.name) if args.name else [runtime.status(p.name) for p in runtime.profiles()] result = runtime.status(args.name) if args.name else [runtime.status(p.name) for p in runtime.profiles()]
elif args.command == 'stop': elif args.command == 'stop':
@@ -155,9 +71,6 @@ def main(argv: list[str] | None = None) -> int:
result = getattr(runtime, args.command)(args.name) result = getattr(runtime, args.command)(args.name)
print(json.dumps(result, indent=2)) print(json.dumps(result, indent=2))
return 0 return 0
except KeyboardInterrupt:
print('hydra: Cancelled', file=sys.stderr)
return 130
except (RuntimeError, OSError, ValueError, subprocess.TimeoutExpired) as exc: except (RuntimeError, OSError, ValueError, subprocess.TimeoutExpired) as exc:
print(f'hydra: {exc}', file=sys.stderr) print(f'hydra: {exc}', file=sys.stderr)
return 1 return 1
-188
View File
@@ -1,188 +0,0 @@
"""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
-476
View File
@@ -1,476 +0,0 @@
"""Prepare only exported Gentoo payloads and configure the dedicated guest network."""
import argparse
import bz2
import io
import runpy
import hashlib
import ipaddress
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
LABEL = 'HYDRA_OS'
BRIDGE = 'hydrabr0'
SUBNET = ipaddress.ip_network('10.203.0.0/24')
CONNECTION_UUID = 'c84844bd-2955-4a61-9463-5e540140f9a2'
class ImageError(RuntimeError):
pass
def root_path(root, relative, follow_leaf=True):
root = Path(root).resolve()
if root == Path('/') or not root.is_dir():
raise ImageError('Expected an isolated exported rootfs directory')
relative = Path(relative)
if relative.is_absolute() or '..' in relative.parts:
raise ImageError('Payload path must be relative and remain inside the export')
path = root / relative
checked = path if follow_leaf else path.parent
if not checked.resolve().is_relative_to(root):
raise ImageError(f'Payload symlink escapes the export: {relative}')
return path
def remove(root, relative):
path = root_path(root, relative, follow_leaf=False)
if path.is_symlink() or path.is_file():
path.unlink()
elif path.is_dir():
shutil.rmtree(path)
def write(root, relative, content, mode=0o644):
path = root_path(root, relative)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
path.chmod(mode)
return path
def sanitize_saved_environments(root):
"""Use Portage's parser to scrub exported declarations without evaluating them."""
database = root_path(root, 'var/db/pkg')
paths = sorted(database.glob('*/*/environment.bz2'))
result = {'files_scanned': len(paths), 'filtered_files': 0, 'variable_names': []}
if not paths:
return result
scripts = sorted(root_path(root, 'usr/lib/portage').glob('*/filter-bash-environment.py'))
if not scripts:
raise ImageError('Installed Portage environment filter is required for export')
script = root_path(root, scripts[0].relative_to(root))
api = runpy.run_path(str(script))
pattern = re.compile(rb'(?i:(?:HTTP|HTTPS|FTP|ALL|NO)_PROXY|.*(?:PASSWORD|PASSWD|SECRET|TOKEN|API_KEY|ACCESS_KEY|CREDENTIAL|AUTHORIZATION).*)')
declarations = re.compile(rb'^(?:declare|export|readonly)\s+(?:-[^ ]+\s+)*([A-Za-z_][A-Za-z0-9_]*)=', re.M)
names = set()
for path in paths:
path = root_path(root, path.relative_to(root))
try:
original = bz2.decompress(path.read_bytes())
except (OSError, ValueError) as error:
raise ImageError('Cannot read an exported package environment') from error
filtered = io.BytesIO()
api['filter_bash_environment'](pattern, io.BytesIO(original), filtered)
clean = filtered.getvalue()
if clean != original:
names.update(name.decode('ascii') for name in declarations.findall(original)
if pattern.fullmatch(name))
path.write_bytes(bz2.compress(clean))
result['filtered_files'] += 1
result['variable_names'] = sorted(names)
return result
def sanitize_rootfs(root):
"""Erase builder identity/secrets in the exported copy, retaining Gentoo updates."""
for relative in ('etc/portage/gnupg', 'etc/portage/gnupg.hydra-untrusted',
'var/lib/hydra-build', 'root', 'home', 'var/cache', 'var/tmp',
'tmp', 'run', 'var/log', 'var/lib/systemd/random-seed',
'var/lib/NetworkManager', 'etc/NetworkManager/system-connections',
'hydra-source', 'hydra-cache', 'hydra-output'):
remove(root, relative)
for name in root_path(root, 'etc/ssh').glob('ssh_host_*'):
remove(root, str(name.relative_to(root)))
for relative, mode in [('root', 0o700), ('home', 0o755), ('tmp', 0o1777),
('var/tmp', 0o1777), ('run', 0o755), ('var/log', 0o755),
('var/cache', 0o755), ('dev', 0o755), ('proc', 0o755), ('sys', 0o755)]:
path = root_path(root, relative)
path.mkdir(parents=True, exist_ok=True)
path.chmod(mode)
remove(root, 'etc/machine-id')
write(root, 'etc/machine-id', '')
remove(root, 'var/lib/dbus/machine-id')
path = root_path(root, 'var/lib/dbus/machine-id', follow_leaf=False)
path.parent.mkdir(parents=True, exist_ok=True)
path.symlink_to('/etc/machine-id')
write(root, 'etc/hostname', 'hydra-os\n')
write(root, 'etc/hosts', '127.0.0.1 localhost\n::1 localhost\n127.0.1.1 hydra-os\n')
remove(root, 'etc/resolv.conf')
root_path(root, 'etc/resolv.conf', follow_leaf=False).symlink_to('/run/NetworkManager/resolv.conf')
audit = sanitize_saved_environments(root)
write(root, 'usr/share/hydra/environment-sanitization.json', json.dumps(audit, indent=2) + '\n')
def copy_overlay(root, overlay):
for source in sorted(Path(overlay).rglob('*')):
destination = root_path(root, source.relative_to(overlay))
if source.is_symlink():
raise ImageError('Source overlay must contain ordinary directories/files')
if source.is_dir():
destination.mkdir(parents=True, exist_ok=True)
elif source.is_file():
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, destination)
destination.chmod(source.stat().st_mode & 0o777)
def _kernel_version(root):
modules = root_path(root, 'lib/modules')
versions = sorted(path.name for path in modules.iterdir() if path.is_dir()) if modules.is_dir() else []
if len(versions) != 1 or not re.fullmatch(r'[A-Za-z0-9_.+-]+', versions[0]):
raise ImageError('Exactly one installed payload kernel is required')
return versions[0]
def kernel_image(root):
version = _kernel_version(root)
candidates = [root_path(root, 'boot/' + prefix + version)
for prefix in ('kernel-', 'vmlinuz-')]
images = [path for path in candidates if path.is_file()]
if len(images) == 2 and images[0].samefile(images[1]):
return images[1] # Stable installer/GRUB vmlinuz alias for Gentoo kernel-bin.
if len(images) != 1:
raise ImageError('Installed modules require exactly one matching boot kernel image')
return images[0]
def kernel_version(root):
kernel_image(root)
return _kernel_version(root)
def live_kernel_args():
return [f'root=live:CDLABEL={LABEL}', 'rd.live.image', 'rd.live.overlay.overlayfs=1',
'ro', 'console=tty0', 'console=ttyS0,115200n8']
def source_files(source):
source = Path(source)
result = {}
for directory in ('image', 'hydra', 'bin', 'session', 'patches'):
for path in sorted((source / directory).rglob('*')):
if path.is_file() and '__pycache__' not in path.parts and path.suffix != '.pyc':
result[str(path.relative_to(source))] = digest(path)
return result
def source_identity(source):
return hashlib.sha256(json.dumps(source_files(source), sort_keys=True).encode()).hexdigest()
def payload_manifest(root, source):
packages = []
database = root_path(root, 'var/db/pkg')
for use in sorted(database.glob('*/*/USE')):
package = use.parent
built = package / 'BUILD_TIME'
packages.append({'package': str(package.relative_to(database)), 'use': use.read_text().split(),
'build_time': built.read_text().strip() if built.is_file() else None})
commit = os.environ.get('HYDRA_SOURCE_COMMIT')
if not commit:
git = subprocess.run(['git', '-C', str(source), 'rev-parse', 'HEAD'], text=True, capture_output=True)
commit = git.stdout.strip() if git.returncode == 0 else None
return {'format': 1, 'label': LABEL, 'kernel': kernel_version(root),
'live_kernel_args': live_kernel_args(), 'source_commit': commit,
'source_files': source_files(source), 'sources': json.loads((Path(source) / 'image/sources.json').read_text()),
'packages': packages}
def artifacts_valid(directory, state):
artifacts = state.get('artifacts')
if not isinstance(artifacts, dict) or not artifacts:
return False
for name, expected in artifacts.items():
if not isinstance(name, str) or Path(name).name != name or not isinstance(expected, str):
return False
path = Path(directory) / name
try:
if path.is_symlink() or not path.is_file() or digest(path) != expected:
return False
except OSError:
return False
return True
def validate_package_inventory(data):
if not isinstance(data, list) or not data:
raise ImageError('Package inventory must be a nonempty array')
seen = set()
for record in data:
if not isinstance(record, dict) or set(record) != {'package', 'use', 'build_time'}:
raise ImageError('Malformed package inventory entry')
package = record['package']
if (not isinstance(package, str) or
not re.fullmatch(r'[A-Za-z0-9+_.-]+/[A-Za-z0-9+_.-]+-[0-9][A-Za-z0-9+_.-]*', package) or
package in seen):
raise ImageError('Invalid or duplicate package inventory identity')
seen.add(package)
flags = record['use']
if (not isinstance(flags, list) or any(not isinstance(flag, str) or
not re.fullmatch(r'[A-Za-z0-9+_.@-]+', flag) for flag in flags) or len(set(flags)) != len(flags)):
raise ImageError('Invalid package inventory USE flags')
built = record['build_time']
if built is not None and (not isinstance(built, str) or not re.fullmatch(r'[0-9]+', built)):
raise ImageError('Invalid package inventory build time')
return data
def read_package_inventory(path, expected_digest=None):
path = Path(path)
if path.is_symlink() or not path.is_file():
raise ImageError('Package inventory must be an ordinary file')
if expected_digest is not None and digest(path) != expected_digest:
raise ImageError('Package inventory checksum differs from checkpoint')
try:
return validate_package_inventory(json.loads(path.read_text()))
except (ValueError, UnicodeError) as error:
raise ImageError('Package inventory is not valid JSON') from error
def enable_user_audio(root):
root_path(root, 'etc/systemd/user')
result = subprocess.run(['systemctl', '--root=' + str(root), '--global', 'enable',
'pipewire.socket', 'pipewire-pulse.socket', 'wireplumber.service'],
text=True, capture_output=True)
if result.returncode:
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',
'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)
alias = root_path(root, 'boot/vmlinuz-' + version)
if not alias.exists():
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')
package.mkdir(parents=True, exist_ok=True)
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', 'hydra-system'):
write(root, 'usr/lib/hydra/bin/' + name, (source / 'bin' / name).read_text(), 0o755)
# 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 -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'
'from hydra.image import network_main\nraise SystemExit(network_main())\n', 0o755)
write(root, 'usr/share/hydra/build-manifest.json', json.dumps(payload_manifest(root, source), indent=2) + '\n')
make = root_path(root, 'etc/portage/make.conf')
if make.is_file():
settings = make.read_text()
settings = re.sub(r'(?<=[\s"\'])-(?:usersandbox|sandbox|network-sandbox|ipc-sandbox|pid-sandbox)(?=[\s"\']|$)', '', settings)
make.write_text(settings)
for path in root_path(root, 'etc/sudoers.d').iterdir():
path.chmod(0o440)
return version
def check_network_conflicts(routes, addresses):
try:
if not isinstance(routes, list) or not isinstance(addresses, list):
raise ValueError('Expected JSON arrays')
for route in routes:
dst = route.get('dst')
if dst in ('default', None):
continue
network = ipaddress.ip_network(dst, strict=False)
if network.version != 4:
raise ValueError('Unexpected non-IPv4 route')
if network.overlaps(SUBNET) and not (route.get('dev') == BRIDGE and network.subnet_of(SUBNET)):
raise ImageError(f'Guest subnet {SUBNET} conflicts with route {dst} on {route.get("dev")}')
for interface in addresses:
for addr in interface['addr_info']:
if addr.get('family') != 'inet':
continue
network = ipaddress.ip_network(f'{addr["local"]}/{addr["prefixlen"]}', strict=False)
owned = interface['ifname'] == BRIDGE and addr['local'] == '10.203.0.1' and addr['prefixlen'] == 24
if network.overlaps(SUBNET) and not owned:
raise ImageError(f'Guest subnet {SUBNET} conflicts with address on {interface["ifname"]}')
except (ValueError, KeyError, TypeError, AttributeError) as error:
raise ImageError('Cannot safely interpret IPv4 routes/addresses') from error
def configure_network(run=subprocess.run):
def command(argv, **kwargs):
result = run(argv, text=True, capture_output=True, **kwargs)
if result.returncode:
raise ImageError(f'Network command failed: {argv[0]}: {result.stderr.strip()}')
return result.stdout
routes = json.loads(command(['ip', '-json', '-4', 'route', 'show', 'table', 'all']))
addresses = json.loads(command(['ip', '-json', '-4', 'address', 'show']))
check_network_conflicts(routes, addresses)
probe = run(['nmcli', '--get-values', 'connection.uuid,connection.type,connection.interface-name,ipv4.method,ipv4.addresses',
'connection', 'show', 'uuid', CONNECTION_UUID], text=True, capture_output=True)
exists = probe.returncode == 0
if exists and probe.stdout.strip().splitlines() != [CONNECTION_UUID, 'bridge', BRIDGE, 'manual', '10.203.0.1/24']:
raise ImageError('Hydra bridge connection exists with unexpected configuration')
if not exists and probe.returncode != 10:
raise ImageError('Cannot query NetworkManager connections')
links = json.loads(command(['ip', '-json', '-details', 'link', 'show']))
for link in links:
if link.get('ifname') == BRIDGE and (not exists or link.get('linkinfo', {}).get('info_kind') != 'bridge'):
raise ImageError('Refusing to replace an existing interface named hydrabr0')
if not exists:
command(['nmcli', 'connection', 'add', 'type', 'bridge', 'con-name', 'hydra-guest',
'ifname', BRIDGE, 'connection.uuid', CONNECTION_UUID,
'connection.autoconnect', 'no', 'bridge.stp', 'no',
'ipv4.method', 'manual', 'ipv4.addresses', '10.203.0.1/24',
'ipv4.never-default', 'yes', 'ipv6.method', 'disabled'])
# Activation is always gated by the fresh route/address check, including restarts.
command(['nmcli', 'connection', 'up', 'uuid', CONNECTION_UUID])
command(['sysctl', '-w', 'net.ipv4.ip_forward=1'])
table = run(['nft', 'list', 'table', 'ip', 'hydra_nat'], text=True, capture_output=True)
rules = ('delete table ip hydra_nat\n' if table.returncode == 0 else '') + '''table ip hydra_nat {
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
ip saddr 10.203.0.0/24 oifname != "hydrabr0" masquerade
}
}
'''
command(['nft', '-f', '-'], input=rules)
def network_main():
try:
configure_network()
except (ImageError, OSError, ValueError) as error:
print(f'hydra-network: {error}', file=sys.stderr)
return 1
return 0
def digest(path):
value = hashlib.sha256()
with Path(path).open('rb') as stream:
for block in iter(lambda: stream.read(1024 * 1024), b''):
value.update(block)
return value.hexdigest()
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('step', choices=('configure', 'kernel', 'kernel-image', 'network', 'audio'))
parser.add_argument('root', nargs='?', type=Path)
parser.add_argument('--source', type=Path)
options = parser.parse_args(argv)
try:
if options.step == 'network':
return network_main()
if options.root is None:
raise ImageError('An isolated rootfs directory is required')
if options.step == 'kernel':
print(kernel_version(options.root))
elif options.step == 'kernel-image':
print(kernel_image(options.root))
elif options.step == 'audio':
enable_user_audio(options.root)
else:
if options.source is None:
raise ImageError('Source checkout is required')
configure_rootfs(options.root, options.source)
except (ImageError, OSError, ValueError) as error:
print(f'hydra-image: {error}', file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
raise SystemExit(main())
-190
View File
@@ -1,190 +0,0 @@
"""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)
+49 -284
View File
@@ -1,4 +1,4 @@
"""Explicit whole-disk and existing-partition installer. Importing this module never mutates storage. """Explicit whole-disk installer. Importing this module never mutates storage.
Live payload contract: /etc/hydra/live-mode/active and remove-on-install.json 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 must be regular files. The manifest is a JSON array of absolute paths matching
@@ -10,7 +10,6 @@ from __future__ import annotations
import argparse import argparse
from dataclasses import dataclass from dataclasses import dataclass
import getpass import getpass
import fcntl
import json import json
import os import os
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
@@ -18,17 +17,12 @@ import re
import shlex import shlex
import shutil import shutil
import stat import stat
import struct
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import warnings import warnings
import uuid
from typing import TextIO from typing import TextIO
from . import install_storage as storage
DEFAULT_PAYLOAD = '/run/initramfs/live/LiveOS/squashfs.img' DEFAULT_PAYLOAD = '/run/initramfs/live/LiveOS/squashfs.img'
LIVE_REMOVABLE = ( LIVE_REMOVABLE = (
'/etc/systemd/system/getty@*.service.d/90-hydra-live.conf', '/etc/systemd/system/getty@*.service.d/90-hydra-live.conf',
@@ -50,18 +44,13 @@ class InstallError(RuntimeError):
@dataclass @dataclass
class Request: class Request:
disk: str | None disk: str
user: str user: str
payload: str = DEFAULT_PAYLOAD payload: str = DEFAULT_PAYLOAD
execute: bool = False execute: bool = False
confirm_erase: str | None = None confirm_erase: str | None = None
timezone: str = 'Europe/Moscow' timezone: str = 'Europe/Moscow'
kernel: str | None = None 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: class Runner:
@@ -83,7 +72,6 @@ class Topology:
def __init__(self): def __init__(self):
self.nodes = {} self.nodes = {}
self.edges = {} self.edges = {}
self.parents = {}
self.protected = set() self.protected = set()
@classmethod @classmethod
@@ -118,17 +106,10 @@ class Topology:
raise InstallError('Zero-size device is not an unrelated empty optical drive') 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): if not isinstance(identity, str) or not re.fullmatch(r'\d+:\d+', identity):
raise InstallError('Invalid lsblk device identity') raise InstallError('Invalid lsblk device identity')
for key in ('partuuid', 'parttype', 'fstype', 'uuid'): if path in result.nodes and any(result.nodes[path][key] != node[key] for key in ('type', 'size', 'maj:min', 'mountpoints')):
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') raise InstallError('Inconsistent duplicate lsblk node')
result.nodes[path] = node result.nodes[path] = node
result.edges.setdefault(path, set()) result.edges.setdefault(path, set())
result.parents.setdefault(path, set())
if any(m for m in mounts): if any(m for m in mounts):
result.protected.add(path) result.protected.add(path)
pkname = node.get('pkname') pkname = node.get('pkname')
@@ -185,17 +166,6 @@ class Topology:
def link(self, child, parent): def link(self, child, parent):
if child == parent or child not in self.nodes or parent not in self.nodes: if child == parent or child not in self.nodes or parent not in self.nodes:
raise InstallError('Unresolved or cyclic block-device parent') raise InstallError('Unresolved or cyclic block-device parent')
# Backing links discovered after lsblk must obey the same directed
# ancestry rule. An undirected shared disk is not itself a cycle.
seen, pending = set(), [parent]
while pending:
current = pending.pop()
if current == child:
raise InstallError('Cyclic block-device backing ancestry')
if current not in seen:
seen.add(current)
pending.extend(self.parents[current] - seen)
self.parents[child].add(parent)
self.edges[child].add(parent) self.edges[child].add(parent)
self.edges[parent].add(child) self.edges[parent].add(child)
@@ -239,7 +209,7 @@ class SystemProbe:
def snapshot(self, payload): def snapshot(self, payload):
raw = self.runner.run(['lsblk', '--json', '--paths', '--bytes', '--output', raw = self.runner.run(['lsblk', '--json', '--paths', '--bytes', '--output',
'PATH,TYPE,PKNAME,MOUNTPOINTS,SIZE,MAJ:MIN,START,PARTUUID,PARTTYPE,FSTYPE,UUID']) 'PATH,TYPE,PKNAME,MOUNTPOINTS,SIZE,MAJ:MIN'])
# lsblk PATH can use /dev/mapper aliases while findmnt uses /dev/dm-N. # lsblk PATH can use /dev/mapper aliases while findmnt uses /dev/dm-N.
try: try:
decoded = json.loads(raw) decoded = json.loads(raw)
@@ -268,6 +238,21 @@ class SystemProbe:
source = self._block_source(mount, topo) source = self._block_source(mount, topo)
if source: if source:
topo.protect(source) topo.protect(source)
# Mounted loops may hide the physical filesystem that stores their file.
for path, node in topo.nodes.items():
if node['type'] == 'loop' and path in topo.protected:
backing_file = Path('/sys/dev/block') / node['maj:min'] / 'loop/backing_file'
try:
backing = backing_file.read_text().strip()
except OSError as exc:
raise InstallError(f'Cannot inspect mounted loop backing file: {path}') from exc
backing = re.sub(r'\\([0-7]{3})', lambda m: chr(int(m[1], 8)), backing)
if not backing.startswith('/'):
backing = '/' + backing
source = self._source_for_path(backing, topo)
if not source:
raise InstallError(f'Unknown physical backing for mounted loop: {path}')
topo.link(path, source)
if not Path(payload).is_file(): if not Path(payload).is_file():
raise InstallError(f'Prepared squashfs payload is not a regular file: {payload}') raise InstallError(f'Prepared squashfs payload is not a regular file: {payload}')
source = self._source_for_path(self.resolve(payload), topo) source = self._source_for_path(self.resolve(payload), topo)
@@ -275,56 +260,8 @@ class SystemProbe:
# tmpfs/overlay payload location cannot establish live-medium ancestry. # tmpfs/overlay payload location cannot establish live-medium ancestry.
raise InstallError('Cannot identify the payload backing block device; use --payload on the mounted live medium') raise InstallError('Cannot identify the payload backing block device; use --payload on the mounted live medium')
topo.protect(source) topo.protect(source)
# Loops can hide further loops, including ancestors of a mounted
# partition or device mapper. Resolve protected components to a fixed
# point, independent of lsblk ordering and payload discovery order.
inspected = set()
while True:
protected_devices = set()
for path in topo.protected:
protected_devices.update(topo.component(path))
pending = [path for path in topo.nodes if path in protected_devices
and topo.nodes[path]['type'] == 'loop' and path not in inspected]
if not pending:
break
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 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 return topo
@staticmethod
def _loop_backing_identity(path, expected_identity):
# LOOP_GET_STATUS64 reports the backing inode's filesystem device,
# independent of names which dracut switch_root may have relocated.
# lo_rdevice is nonzero when the backing inode is itself a block device.
try:
descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC)
try:
info = os.fstat(descriptor)
identity = f'{os.major(info.st_rdev)}:{os.minor(info.st_rdev)}'
if not stat.S_ISBLK(info.st_mode) or identity != expected_identity:
raise InstallError(f'Mounted loop device identity changed: {path}')
status = fcntl.ioctl(descriptor, 0x4C05, bytes(232))
if not isinstance(status, bytes) or len(status) != 232:
raise InstallError(f'Malformed loop status: {path}')
filesystem_device, _, block_device = struct.unpack_from('=QQQ', status)
finally:
os.close(descriptor)
except (OSError, struct.error) as exc:
raise InstallError(f'Cannot inspect mounted loop backing identity: {path}') from exc
device = block_device or filesystem_device
return f'{os.major(device)}:{os.minor(device)}'
@staticmethod @staticmethod
def _mounts(raw): def _mounts(raw):
try: try:
@@ -350,9 +287,7 @@ class SystemProbe:
raise InstallError('Malformed findmnt topology') from exc raise InstallError('Malformed findmnt topology') from exc
def _block_source(self, mount, topo): def _block_source(self, mount, topo):
if mount['fstype'] == 'btrfs': if mount['fstype'] in {'zfs', 'btrfs'}:
return self._btrfs_source(mount, topo)
if mount['fstype'] == 'zfs':
raise InstallError('Cannot safely resolve all backing disks of mounted ' + mount['fstype']) raise InstallError('Cannot safely resolve all backing disks of mounted ' + mount['fstype'])
source = mount['source'].split('[', 1)[0] source = mount['source'].split('[', 1)[0]
identity = mount['maj:min'] identity = mount['maj:min']
@@ -373,49 +308,6 @@ class SystemProbe:
raise InstallError(f'Unknown mounted block source: {source}') raise InstallError(f'Unknown mounted block source: {source}')
return None 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): def _source_for_path(self, path, topo):
raw = self.runner.run(['findmnt', '--json', '--target', path, '--output', raw = self.runner.run(['findmnt', '--json', '--target', path, '--output',
'SOURCE,TARGET,FSTYPE,MAJ:MIN,OPTIONS']) 'SOURCE,TARGET,FSTYPE,MAJ:MIN,OPTIONS'])
@@ -425,17 +317,9 @@ class SystemProbe:
return self._block_source(mounts[0], topo) return self._block_source(mounts[0], topo)
def validate_request(request, probe, *, return_plan=False): def validate_request(request, probe):
existing = request.root_partition is not None or request.esp_partition is not None if not isinstance(request.disk, str) or not DEVICE.fullmatch(request.disk) or '..' in PurePosixPath(request.disk).parts:
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') 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'}: 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') raise InstallError('Choose a valid nonreserved lowercase Unix username')
if not re.fullmatch(r'[A-Za-z_+-]+(?:/[A-Za-z0-9_+-]+)+', request.timezone): if not re.fullmatch(r'[A-Za-z_+-]+(?:/[A-Za-z0-9_+-]+)+', request.timezone):
@@ -444,9 +328,6 @@ def validate_request(request, probe, *, return_plan=False):
raise InstallError('Payload must be an absolute path without control characters') raise InstallError('Payload must be an absolute path without control characters')
if request.kernel is not None and not KERNEL.fullmatch(request.kernel): if request.kernel is not None and not KERNEL.fullmatch(request.kernel):
raise InstallError('Invalid kernel version') 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) target = probe.resolve(request.disk)
if not DEVICE.fullmatch(target) or not probe.physical_disk(target): if not DEVICE.fullmatch(target) or not probe.physical_disk(target):
raise InstallError(f'Target is not a physical whole block disk: {target}') raise InstallError(f'Target is not a physical whole block disk: {target}')
@@ -460,10 +341,6 @@ def validate_request(request, probe, *, return_plan=False):
forbidden = topo.component(target) & topo.protected forbidden = topo.component(target) & topo.protected
if forbidden: if forbidden:
raise InstallError('Target backs mounted/root/live storage: ' + ', '.join(sorted(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 return target
@@ -472,7 +349,7 @@ def partitions(target):
return target + suffix + '1', target + suffix + '2' return target + suffix + '1', target + suffix + '2'
def partition_commands(target, root_fs='ext4'): def partition_commands(target):
esp, root = partitions(target) esp, root = partitions(target)
return [ return [
['parted', '--script', target, 'mklabel', 'gpt'], ['parted', '--script', target, 'mklabel', 'gpt'],
@@ -481,38 +358,20 @@ def partition_commands(target, root_fs='ext4'):
['parted', '--script', target, 'unit', 'MiB', 'mkpart', 'root', 'ext4', '1025MiB', '100%'], ['parted', '--script', target, 'unit', 'MiB', 'mkpart', 'root', 'ext4', '1025MiB', '100%'],
['partprobe', target], ['udevadm', 'settle'], ['partprobe', target], ['udevadm', 'settle'],
['mkfs.vfat', '-F', '32', '-n', 'HYDRA_EFI', esp], ['mkfs.vfat', '-F', '32', '-n', 'HYDRA_EFI', esp],
storage.format_root(root, root_fs), ['mkfs.ext4', '-F', '-L', 'HYDRA_ROOT', root],
] ]
def grub_command(root, preserve=False): def grub_command(root):
return ['chroot', str(root), 'grub-install', '--target=x86_64-efi', return ['chroot', str(root), 'grub-install', '--target=x86_64-efi',
'--efi-directory=/boot/efi', '--bootloader-id=HydraOS', '--efi-directory=/boot/efi', '--bootloader-id=HydraOS', '--removable', '--no-nvram']
*([] if preserve else ['--removable']), '--no-nvram']
def render_plan(request, target, root): def render_plan(request, target, root):
existing = request.root_partition is not None esp, part = partitions(target)
esp, part = (request.esp_partition, target) if existing else partitions(target) commands = partition_commands(target) + [
commands = [['unsquashfs', '-s', request.payload], ['unsquashfs', '-lln', request.payload]] ['mount', part, str(root)], ['unsquashfs', '-f', '-d', str(root), request.payload],
if existing: ['mount', esp, str(Path(root) / 'boot/efi')],
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', part],
['blkid', '-s', 'UUID', '-o', 'value', esp], ['blkid', '-s', 'UUID', '-o', 'value', esp],
['mount', '--rbind', '/dev', str(Path(root) / 'dev')], ['mount', '--rbind', '/dev', str(Path(root) / 'dev')],
@@ -527,18 +386,12 @@ def render_plan(request, target, root):
['chroot', str(root), 'systemd-machine-id-setup'], ['chroot', str(root), 'systemd-machine-id-setup'],
['chroot', str(root), 'dracut', '--force', '--no-hostonly', '--omit', 'dmsquash-live', ['chroot', str(root), 'dracut', '--force', '--no-hostonly', '--omit', 'dmsquash-live',
'/boot/initramfs-<kernel>.img', '<kernel>'], '/boot/initramfs-<kernel>.img', '<kernel>'],
grub_command(root, preserve=True), grub_command(root), ['chroot', str(root), 'grub-mkconfig', '-o', '/boot/grub/grub.cfg'],
*([] if existing else [grub_command(root)]),
['chroot', str(root), 'grub-mkconfig', '-o', '/boot/grub/grub.cfg'],
] ]
lines = [f'Hydra OS {"EXECUTION" if request.execute else "dry-run"}: erase all data on {target}.', lines = [f'HydraOS {"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.', 'GPT: exactly 1 GiB FAT32 ESP, remaining space ext4 root.',
f'Prepared payload: {request.payload}; account: {request.user}; timezone: {request.timezone}.', f'Prepared payload: {request.payload}; account: {request.user}; timezone: {request.timezone}.',
'Commands (UUIDs/kernel discovered after unpacking; no commands run in dry-run):'] '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(' ' + 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.', 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.', 'Remove allowlisted live-mode files, hydra-live account and /home/hydra-live; reset machine-id.',
@@ -546,11 +399,6 @@ def render_plan(request, target, root):
'chpasswd receives the TTY-entered password on private stdin; root remains locked.', '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.', '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).']) 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) return '\n'.join(lines)
@@ -680,10 +528,7 @@ def install(request, runner, probe, root, password, log: TextIO):
raise InstallError('Installation requires --execute') raise InstallError('Installation requires --execute')
if not isinstance(password, str) or not password or any(c in password for c in '\r\n\x00'): 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') raise InstallError('Password must be nonempty and contain no newline or NUL')
validated = validate_request(request, probe, return_plan=True) target = validate_request(request, probe)
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) root = Path(root)
if not root.is_absolute() or root.is_symlink() or not root.is_dir() or any(root.iterdir()): 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') raise InstallError('Target mount directory must be an empty absolute directory')
@@ -705,46 +550,13 @@ def install(request, runner, probe, root, password, log: TextIO):
try: try:
run(['unsquashfs', '-s', request.payload]) run(['unsquashfs', '-s', request.payload])
required = storage.payload_capacity(run(['unsquashfs', '-lln', request.payload])) # Recheck after preflight, directly before the first destructive command.
capacity = validated.root_size if existing else validated.size - 1025 * 1024**2 if validate_request(request, probe) != target:
if capacity < required: raise InstallError('Target device changed during validation')
raise InstallError('Root has insufficient capacity for the uncompressed payload and install reserve') for argv in partition_commands(target):
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) run(argv)
if request.root_fs == 'btrfs': esp, part = partitions(target)
top = root.parent / 'btrfs-top' mount(['mount', part, str(root)], root)
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]) run(['unsquashfs', '-f', '-d', str(root), request.payload])
kernel = select_kernel(root, request.kernel) kernel = select_kernel(root, request.kernel)
remove_live_files(root) remove_live_files(root)
@@ -753,35 +565,12 @@ def install(request, runner, probe, root, password, log: TextIO):
raise InstallError('Timezone does not exist in payload: ' + request.timezone) raise InstallError('Timezone does not exist in payload: ' + request.timezone)
for folder in ('boot/efi', 'dev', 'proc', 'sys'): for folder in ('boot/efi', 'dev', 'proc', 'sys'):
target_path(root, folder).mkdir(parents=True, exist_ok=True) target_path(root, folder).mkdir(parents=True, exist_ok=True)
mount(['mount', '-t', 'vfat', esp, str(root / 'boot/efi')], root / 'boot/efi') mount(['mount', esp, str(root / 'boot/efi')], root / 'boot/efi')
root_uuid = run(['blkid', '-s', 'UUID', '-o', 'value', part]).strip() root_uuid = run(['blkid', '-s', 'UUID', '-o', 'value', part]).strip()
esp_uuid = run(['blkid', '-s', 'UUID', '-o', 'value', esp]).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)): 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') raise InstallError('Missing or invalid partition UUID')
if request.root_fs == 'btrfs': write_config(root, 'etc/fstab', f'UUID={root_uuid} / ext4 defaults 0 1\nUUID={esp_uuid} /boot/efi vfat umask=0077 0 2\n')
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.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/locale.conf', 'LANG=en_US.UTF-8\n')
write_config(root, 'etc/timezone', request.timezone + '\n') write_config(root, 'etc/timezone', request.timezone + '\n')
@@ -796,13 +585,9 @@ def install(request, runner, probe, root, password, log: TextIO):
write_config(root, 'etc/machine-id', '') write_config(root, 'etc/machine-id', '')
dbus_machine_id = target_path(root, 'var/lib/dbus') / 'machine-id' dbus_machine_id = target_path(root, 'var/lib/dbus') / 'machine-id'
dbus_machine_id.unlink(missing_ok=True) 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', write_config(root, 'etc/dracut.conf.d/90-hydra-installed.conf',
'hostonly="no"\nhostonly_cmdline="no"\nomit_dracutmodules+=" dmsquash-live "\n' 'hostonly="no"\nhostonly_cmdline="no"\nomit_dracutmodules+=" dmsquash-live "\n# No root=live: or rd.live arguments on the installed disk.\n')
+ ('filesystems+=" btrfs "\n' if request.root_fs == 'btrfs' else '') write_config(root, 'etc/default/grub', 'GRUB_DISTRIBUTOR="HydraOS"\nGRUB_CMDLINE_LINUX=""\nGRUB_TIMEOUT=3\n')
+ 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) 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) mount(['mount', '--rbind', '/dev', str(root / 'dev')], root / 'dev', True)
run(['mount', '--make-rslave', str(root / 'dev')]) run(['mount', '--make-rslave', str(root / 'dev')])
@@ -821,16 +606,8 @@ def install(request, runner, probe, root, password, log: TextIO):
chroot('systemd-machine-id-setup') chroot('systemd-machine-id-setup')
chroot('dracut', '--force', '--no-hostonly', '--omit', 'dmsquash-live', chroot('dracut', '--force', '--no-hostonly', '--omit', 'dmsquash-live',
'/boot/initramfs-' + kernel + '.img', kernel) '/boot/initramfs-' + kernel + '.img', kernel)
run(grub_command(root, preserve=True))
if not existing:
run(grub_command(root)) run(grub_command(root))
chroot('grub-mkconfig', '-o', '/boot/grub/grub.cfg') 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']) run(['sync'])
except (InstallError, OSError, KeyboardInterrupt) as exc: except (InstallError, OSError, KeyboardInterrupt) as exc:
error = exc error = exc
@@ -853,8 +630,6 @@ def install(request, runner, probe, root, password, log: TextIO):
error = InstallError('Mount cleanup failed: ' + '; '.join(cleanup_errors)) error = InstallError('Mount cleanup failed: ' + '; '.join(cleanup_errors))
if error: if error:
raise InstallError(str(error)) from 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) print('Installation finished; remove the live ISO before booting the installed disk.', file=log, flush=True)
@@ -867,13 +642,8 @@ def preflight(request, runner):
raise InstallError('Execution requires a TTY for password entry') raise InstallError('Execution requires a TTY for password entry')
if not Path('/sys/firmware/efi').is_dir(): if not Path('/sys/firmware/efi').is_dir():
raise InstallError('Boot the live medium in UEFI mode before installation') raise InstallError('Boot the live medium in UEFI mode before installation')
commands = ['lsblk', 'findmnt', 'mount', 'umount', 'unsquashfs', 'blkid', 'chroot', 'sync'] for command in ('lsblk', 'findmnt', 'parted', 'partprobe', 'udevadm', 'mkfs.vfat',
if request.root_partition is None: 'mkfs.ext4', 'mount', 'umount', 'unsquashfs', 'blkid', 'chroot', 'sync'):
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: if shutil.which(command) is None:
raise InstallError('Required live installer tool missing: ' + command) raise InstallError('Required live installer tool missing: ' + command)
runner.run(['unsquashfs', '-s', request.payload]) runner.run(['unsquashfs', '-s', request.payload])
@@ -897,13 +667,8 @@ def prompt_password():
def main(argv=None): def main(argv=None):
parser = argparse.ArgumentParser(description='Hydra OS explicit disk or existing-partition installer; dry-run by default') parser = argparse.ArgumentParser(description='HydraOS explicit whole-disk installer; dry-run by default')
parser.add_argument('--disk', help='explicit physical whole-disk target; never automatically selected') 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('--user', help='installed administrator username')
parser.add_argument('--payload', default=DEFAULT_PAYLOAD, help='prepared live squashfs root payload') parser.add_argument('--payload', default=DEFAULT_PAYLOAD, help='prepared live squashfs root payload')
parser.add_argument('--execute', action='store_true') parser.add_argument('--execute', action='store_true')
@@ -913,8 +678,8 @@ def main(argv=None):
args = parser.parse_args(argv) args = parser.parse_args(argv)
runner = Runner() runner = Runner()
try: try:
if not args.disk and not args.root_partition and not args.esp_partition: if not args.disk:
if args.execute or args.confirm_erase or args.confirm_format or args.register_boot_entry: if args.execute or args.confirm_erase:
raise InstallError('--execute requires an explicit --disk and --user') raise InstallError('--execute requires an explicit --disk and --user')
topo = Topology.from_json(runner.run(['lsblk', '--json', '--paths', '--bytes', '--output', topo = Topology.from_json(runner.run(['lsblk', '--json', '--paths', '--bytes', '--output',
'PATH,TYPE,PKNAME,MOUNTPOINTS,SIZE,MAJ:MIN'])) 'PATH,TYPE,PKNAME,MOUNTPOINTS,SIZE,MAJ:MIN']))
-123
View File
@@ -1,123 +0,0 @@
"""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
View File
@@ -1,205 +0,0 @@
"""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
View File
@@ -1,252 +0,0 @@
"""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))
+5 -37
View File
@@ -16,40 +16,24 @@ def validate_name(name: str) -> str:
@dataclass(frozen=True) @dataclass(frozen=True)
class Profile: class Profile:
name: str name: str
iso: Path | None = None iso: Path
memory: int = 4096 memory: int = 4096
cpus: int = 2 cpus: int = 2
disk_size: str = '40G' disk_size: str = '40G'
graphics: str = 'auto' graphics: str = 'auto'
network: str = 'user' network: str = 'user'
bridge: str = 'hydrabr0' bridge: str = 'hydrabr0'
width: int = 1280
height: int = 800
refresh_millihz: int = 0
mac: str | None = None
def __post_init__(self): def __post_init__(self):
validate_name(self.name) validate_name(self.name)
if self.iso is not None: iso = Path(self.iso).expanduser().resolve()
if not isinstance(self.iso, (str, Path)) or not str(self.iso): if not iso.is_file():
raise ValueError('ISO must be a nonempty path or omitted') raise ValueError(f'ISO does not exist or is not a file: {iso}')
object.__setattr__(self, 'iso', Path(self.iso).expanduser().resolve()) object.__setattr__(self, 'iso', iso)
if self.mac is not None:
if (not isinstance(self.mac, str) or
not re.fullmatch(r'[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}', self.mac) or
int(self.mac[:2], 16) & 3 != 2):
raise ValueError('mac must be a locally administered unicast Ethernet address')
object.__setattr__(self, 'mac', self.mac.lower())
for key, upper in (('memory', 262144), ('cpus', 256)): for key, upper in (('memory', 262144), ('cpus', 256)):
value = getattr(self, key) value = getattr(self, key)
if type(value) is not int or not 1 <= value <= upper: if type(value) is not int or not 1 <= value <= upper:
raise ValueError(f'{key} must be an integer between 1 and {upper}') raise ValueError(f'{key} must be an integer between 1 and {upper}')
for key in ('width', 'height'):
if type(getattr(self, key)) is not int or not 1 <= getattr(self, key) <= 16384:
raise ValueError(f'{key} must be an integer between 1 and 16384')
if type(self.refresh_millihz) is not int or not (self.refresh_millihz == 0 or
50000 <= self.refresh_millihz <= 255000):
raise ValueError('refresh_millihz must be 0 (automatic) or an integer between 50000 and 255000')
if not isinstance(self.disk_size, str) or not re.fullmatch(r'[1-9][0-9]{0,5}[MGT]', self.disk_size): if not isinstance(self.disk_size, str) or not re.fullmatch(r'[1-9][0-9]{0,5}[MGT]', self.disk_size):
raise ValueError('disk_size must be a positive size with M, G or T suffix') raise ValueError('disk_size must be a positive size with M, G or T suffix')
if self.graphics not in ('auto', 'software'): if self.graphics not in ('auto', 'software'):
@@ -83,22 +67,6 @@ def save_profile(path: Path, profile: Profile) -> None:
lines = [] lines = []
for field in Profile.__dataclass_fields__: for field in Profile.__dataclass_fields__:
value = getattr(profile, field) value = getattr(profile, field)
if value is None:
continue
lines.append(f'{field} = {value if type(value) is int else json.dumps(str(value), ensure_ascii=False)}\n') lines.append(f'{field} = {value if type(value) is int else json.dumps(str(value), ensure_ascii=False)}\n')
with path.open('x', encoding='utf-8') as stream: with path.open('x', encoding='utf-8') as stream:
stream.write(''.join(lines)) stream.write(''.join(lines))
def load_defaults(path: Path) -> dict:
"""Distribution preferences apply only when creating a new profile."""
try:
with Path(path).open('rb') as stream:
data = tomllib.load(stream)
except FileNotFoundError:
return {}
unknown = data.keys() - {'network', 'bridge', 'width', 'height', 'refresh_millihz'}
if unknown:
raise ValueError(f'Unknown defaults fields: {", ".join(sorted(unknown))}')
Profile('defaults-check', **data)
return data
-22
View File
@@ -23,12 +23,6 @@ class QMPClient:
self.socket.settimeout(timeout) self.socket.settimeout(timeout)
self.socket.connect(str(path)) self.socket.connect(str(path))
greeting = self._receive(deadline) 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): if not isinstance(greeting.get('QMP'), dict):
raise QMPError('Invalid QMP greeting') raise QMPError('Invalid QMP greeting')
self._command('qmp_capabilities', None, deadline) self._command('qmp_capabilities', None, deadline)
@@ -38,22 +32,6 @@ class QMPClient:
raise raise
raise QMPError(f'QMP connection failed: {exc}') from exc 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: def _receive(self, deadline: float) -> dict:
while True: while True:
remaining = deadline - time.monotonic() remaining = deadline - time.monotonic()
+8 -53
View File
@@ -3,7 +3,6 @@
import contextlib import contextlib
import errno import errno
import fcntl import fcntl
import hashlib
import json import json
import os import os
import socket import socket
@@ -12,7 +11,7 @@ import subprocess
import tempfile import tempfile
import time import time
import uuid import uuid
from dataclasses import dataclass, replace from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from .profiles import Profile, load_profile, save_profile, validate_name from .profiles import Profile, load_profile, save_profile, validate_name
@@ -55,14 +54,6 @@ def _intel_render_node() -> Path | None:
return None return None
def _stable_mac(paths: VMPaths) -> str:
# Disk/state identity survives restarts and runtime socket-directory changes.
# UID distinguishes owners even when a shared bridge sees the same names.
digest = bytearray(hashlib.sha256(f'{os.getuid()}:{paths.disk}'.encode()).digest()[:6])
digest[0] = (digest[0] & 0xfc) | 0x02
return ':'.join(f'{octet:02x}' for octet in digest)
def build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]: def build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]:
accel = 'kvm' if os.access('/dev/kvm', os.R_OK | os.W_OK) else 'tcg' accel = 'kvm' if os.access('/dev/kvm', os.R_OK | os.W_OK) else 'tcg'
argv = [os.environ.get('HYDRA_QEMU', 'qemu-system-x86_64'), '-name', profile.name, argv = [os.environ.get('HYDRA_QEMU', 'qemu-system-x86_64'), '-name', profile.name,
@@ -73,21 +64,15 @@ def build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]:
'-mon', 'chardev=qmp,mode=control'] '-mon', 'chardev=qmp,mode=control']
if accel == 'kvm': if accel == 'kvm':
argv += ['-cpu', 'host'] argv += ['-cpu', 'host']
blockdevs = [ for options in (
{'driver': 'file', 'filename': str(paths.disk), 'node-name': 'disk-file'}, {'driver': 'file', 'filename': str(paths.disk), 'node-name': 'disk-file'},
{'driver': 'qcow2', 'file': 'disk-file', 'node-name': 'disk'}, {'driver': 'qcow2', 'file': 'disk-file', 'node-name': 'disk'},
]
if profile.iso is not None:
blockdevs += [
{'driver': 'file', 'filename': str(profile.iso), 'node-name': 'iso-file', 'read-only': True}, {'driver': 'file', 'filename': str(profile.iso), 'node-name': 'iso-file', 'read-only': True},
{'driver': 'raw', 'file': 'iso-file', 'node-name': 'iso', 'read-only': True}, {'driver': 'raw', 'file': 'iso-file', 'node-name': 'iso', 'read-only': True},
] ):
for options in blockdevs:
argv += ['-blockdev', json.dumps(options)] argv += ['-blockdev', json.dumps(options)]
argv += ['-device', 'virtio-blk-pci,drive=disk'] argv += ['-device', 'virtio-blk-pci,drive=disk', '-device', 'ide-cd,drive=iso',
if profile.iso is not None: '-boot', 'order=dc', '-device', 'virtio-net-pci,netdev=net',
argv += ['-device', 'ide-cd,drive=iso']
argv += ['-boot', 'order=dc' if profile.iso is not None else 'order=c', '-device', f'virtio-net-pci,netdev=net,mac={profile.mac or _stable_mac(paths)}',
'-netdev', 'user,id=net' if profile.network == 'user' '-netdev', 'user,id=net' if profile.network == 'user'
else f'bridge,id=net,br={profile.bridge}', else f'bridge,id=net,br={profile.bridge}',
'-device', 'qemu-xhci,id=usb', '-device', 'usb-tablet', '-device', 'usb-kbd'] '-device', 'qemu-xhci,id=usb', '-device', 'usb-tablet', '-device', 'usb-kbd']
@@ -98,15 +83,12 @@ def build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]:
'-device', f'usb-redir,chardev=usbredir{channel}'] '-device', f'usb-redir,chardev=usbredir{channel}']
spice = (f'unix=on,addr={_qemu_value(paths.spice)},disable-ticketing=on,' spice = (f'unix=on,addr={_qemu_value(paths.spice)},disable-ticketing=on,'
'disable-copy-paste=on,disable-agent-file-xfer=on') 'disable-copy-paste=on,disable-agent-file-xfer=on')
mode = f',xres={profile.width},yres={profile.height}'
if profile.refresh_millihz:
mode += f',refresh_rate={profile.refresh_millihz}'
render = _intel_render_node() if profile.graphics == 'auto' else None render = _intel_render_node() if profile.graphics == 'auto' else None
if render: if render:
spice += f',gl=on,rendernode={_qemu_value(render)}' spice += f',gl=on,rendernode={_qemu_value(render)}'
argv += ['-display', f'egl-headless,rendernode={_qemu_value(render)}', '-device', 'virtio-vga-gl' + mode] argv += ['-display', f'egl-headless,rendernode={_qemu_value(render)}', '-device', 'virtio-vga-gl']
else: else:
argv += ['-display', 'none', '-device', 'virtio-vga' + mode] argv += ['-display', 'none', '-device', 'virtio-vga']
return argv + ['-spice', spice] return argv + ['-spice', spice]
@@ -143,35 +125,9 @@ class Runtime:
_private_dir(directory) _private_dir(directory)
def add(self, profile: Profile) -> None: def add(self, profile: Profile) -> None:
self._validate_media(profile.iso)
_private_dir(self.config) _private_dir(self.config)
if profile.mac is None:
profile = replace(profile, mac=_stable_mac(self.paths(profile.name)))
save_profile(self.config / f'{profile.name}.toml', profile) save_profile(self.config / f'{profile.name}.toml', profile)
@staticmethod
def _validate_media(iso: Path | None) -> None:
if iso is not None and not iso.is_file():
raise RuntimeError(f'ISO does not exist or is not a file: {iso}; attach valid media or detach it while stopped')
def media(self, name: str, iso: Path | None) -> dict:
paths = self.paths(name)
with self._locked(paths):
current = self.status(name)
if current['status'] != 'stopped':
raise RuntimeError(f'Cannot change media for {name}: {current["status"]}')
profile = replace(self.profile(name), iso=iso)
self._validate_media(profile.iso)
destination = self.config / f'{name}.toml'
if destination.is_symlink():
raise RuntimeError('Refusing to replace a symlink profile')
with tempfile.TemporaryDirectory(prefix=f'.{name}-', dir=self.config) as tmp:
temp = Path(tmp) / f'{name}.toml'
save_profile(temp, profile)
temp.chmod(0o600)
os.replace(temp, destination)
return {'name': name, 'iso': str(profile.iso) if profile.iso else None, 'status': 'media-updated'}
def profiles(self) -> list[Profile]: def profiles(self) -> list[Profile]:
if not self.config.exists(): if not self.config.exists():
return [] return []
@@ -278,13 +234,12 @@ class Runtime:
temp.unlink(missing_ok=True) temp.unlink(missing_ok=True)
def start(self, name: str) -> dict: def start(self, name: str) -> dict:
profile = self.profile(name)
paths = self.paths(name) paths = self.paths(name)
with self._locked(paths): with self._locked(paths):
profile = self.profile(name)
current = self.status(name) current = self.status(name)
if current['status'] != 'stopped': if current['status'] != 'stopped':
raise RuntimeError(f'Cannot start {name}: {current["status"]}') raise RuntimeError(f'Cannot start {name}: {current["status"]}')
self._validate_media(profile.iso)
for path in (paths.qmp, paths.spice): for path in (paths.qmp, paths.spice):
if self._listening(path): if self._listening(path):
raise RuntimeError(f'Runtime socket is already in use: {path}') raise RuntimeError(f'Runtime socket is already in use: {path}')
+29 -119
View File
@@ -15,8 +15,8 @@ from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
from .profiles import load_profile, validate_name from .profiles import validate_name
from .runtime import Runtime, RuntimeError, VMPaths, _private_dir from .runtime import Runtime, VMPaths, _private_dir
class SessionError(ValueError): class SessionError(ValueError):
@@ -24,7 +24,7 @@ class SessionError(ValueError):
def viewer_title(name: str) -> str: def viewer_title(name: str) -> str:
return 'Hydra OS: ' + validate_name(name) return 'HydraOS: ' + validate_name(name)
def build_viewer_argv(paths: VMPaths, viewer: str, *, windowed=False) -> list[str]: def build_viewer_argv(paths: VMPaths, viewer: str, *, windowed=False) -> list[str]:
@@ -49,7 +49,6 @@ def build_labwc_config(names: list[str], recovery_argv: list[str],
core = ET.SubElement(root, 'core') core = ET.SubElement(root, 'core')
ET.SubElement(core, 'primarySelection').text = 'no' ET.SubElement(core, 'primarySelection').text = 'no'
desktops = ET.SubElement(root, 'desktops', number=str(max(1, len(names)))) desktops = ET.SubElement(root, 'desktops', number=str(max(1, len(names))))
if names:
labels = ET.SubElement(desktops, 'names') labels = ET.SubElement(desktops, 'names')
for name in names: for name in names:
ET.SubElement(labels, 'name').text = name ET.SubElement(labels, 'name').text = name
@@ -101,7 +100,7 @@ def _viewer() -> str:
viewer = _tool('HYDRA_VIEWER', 'remote-viewer') viewer = _tool('HYDRA_VIEWER', 'remote-viewer')
result = subprocess.run([viewer, '--help'], capture_output=True, text=True, timeout=10) result = subprocess.run([viewer, '--help'], capture_output=True, text=True, timeout=10)
if result.returncode or '--hide-toolbar' not in result.stdout: if result.returncode or '--hide-toolbar' not in result.stdout:
raise SessionError('Viewer lacks Hydra OS toolbar/USB adaptation; use dev/tools.nix') raise SessionError('Viewer lacks HydraOS toolbar/USB adaptation; use dev/tools.nix')
return viewer return viewer
@@ -120,7 +119,7 @@ def _viewer_environment(directory: Path) -> dict[str, str]:
settings.mkdir(parents=True, mode=0o700) settings.mkdir(parents=True, mode=0o700)
(settings / 'settings').write_text('[virt-viewer]\nshare-clipboard=false\n', encoding='utf-8') (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') env = dict(os.environ, XDG_CONFIG_HOME=str(config), GSETTINGS_BACKEND='memory')
# A dedicated backend prevents accidental XWayland input behavior in Hydra OS. # A dedicated backend prevents accidental XWayland input behavior in HydraOS.
if env.get('WAYLAND_DISPLAY'): if env.get('WAYLAND_DISPLAY'):
env['GDK_BACKEND'] = 'wayland' env['GDK_BACKEND'] = 'wayland'
return env return env
@@ -162,7 +161,7 @@ def _module_argv(command: str, manifest: Path) -> list[str]:
def _recovery_argv(manifest: Path, terminal: str) -> list[str]: def _recovery_argv(manifest: Path, terminal: str) -> list[str]:
return [terminal, '--title=Hydra OS recovery'] + _module_argv('recover', manifest) return [terminal, '--title=HydraOS-recovery'] + _module_argv('recover', manifest)
def launch_session(runtime: Runtime, *, nested=False) -> int: def launch_session(runtime: Runtime, *, nested=False) -> int:
@@ -173,25 +172,10 @@ def launch_session(runtime: Runtime, *, nested=False) -> int:
version = subprocess.run([labwc, '--version'], capture_output=True, text=True, timeout=10) version = subprocess.run([labwc, '--version'], capture_output=True, text=True, timeout=10)
match = re.search(r'(\d+)\.(\d+)\.(\d+)', version.stdout) match = re.search(r'(\d+)\.(\d+)\.(\d+)', version.stdout)
if version.returncode or not match or tuple(map(int, match.groups())) < (0, 20, 0): if version.returncode or not match or tuple(map(int, match.groups())) < (0, 20, 0):
raise SessionError('Hydra OS requires labwc >= 0.20.0 for reserved host input bindings') raise SessionError('HydraOS requires labwc >= 0.20.0 for reserved host input bindings')
profiles = runtime.profiles()
initial = next((p.name for p in profiles if runtime.status(p.name)['status'] == 'running'), None)
_private_dir(runtime.runtime) _private_dir(runtime.runtime)
selected = None
while True:
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: with tempfile.TemporaryDirectory(prefix='session-', dir=runtime.runtime) as tmp:
directory = Path(tmp) directory = Path(tmp)
config = directory / 'labwc' config = directory / 'labwc'
@@ -204,34 +188,13 @@ def launch_session(runtime: Runtime, *, nested=False) -> int:
_recovery_argv(manifest, terminal), brightness, _recovery_argv(manifest, terminal), brightness,
initial=initial), encoding='utf-8') initial=initial), encoding='utf-8')
(config / 'autostart').write_text('#!/bin/sh\n' + shlex.join(_module_argv('clients', manifest)) + ' &\n', encoding='utf-8') (config / 'autostart').write_text('#!/bin/sh\n' + shlex.join(_module_argv('clients', manifest)) + ' &\n', encoding='utf-8')
# Empty environment files prevent loading unrelated /etc/xdg defaults.
(config / 'environment').touch() (config / 'environment').touch()
env['PYTHONUNBUFFERED'] = '1' env['PYTHONUNBUFFERED'] = '1'
env['HYDRA_SESSION_MANIFEST'] = str(manifest) return subprocess.run([labwc, '-C', str(config)], env=env).returncode
compositor = subprocess.Popen([labwc, '-C', str(config)], env=env)
refresh = False
try:
while True:
if (directory / 'refresh.request').exists():
refresh = True
break
if compositor.poll() is not None:
# A requesting recovery terminal can exit before polling.
refresh = (directory / 'refresh.request').exists()
break
time.sleep(.1)
finally:
if compositor.poll() is None:
compositor.terminate()
compositor.wait(timeout=10)
if not refresh:
return compositor.returncode
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')) data = json.loads(manifest.read_text(encoding='utf-8'))
if (not isinstance(data, dict) or if (not isinstance(data, dict) or
any(not isinstance(data.get(key), str) or not Path(data[key]).is_absolute() any(not isinstance(data.get(key), str) or not Path(data[key]).is_absolute()
@@ -240,79 +203,28 @@ def _manifest_data(manifest: Path) -> dict:
raise SessionError('Invalid session manifest') raise SessionError('Invalid session manifest')
for name in data['names']: for name in data['names']:
validate_name(name) validate_name(name)
return data if command not in ('clients', 'recover') and (
not command.startswith('reconnect:') or command.split(':', 1)[1] not in data['names']):
raise SessionError('Invalid session helper operation')
def request_refresh(manifest: Path, *, selected: str | None = None) -> int: runtime = Runtime(Path(data['config']), Path(data['state']), Path(data['runtime']))
"""Publish a complete request without controlling any guest process.""" if command == 'recover':
manifest = Path(manifest) print('HydraOS local recovery. VMs keep running independently.\n'
data = _manifest_data(manifest) 'Ctrl+Alt+Shift+Left/Right switches workspaces; Ctrl+Alt+Shift+R releases input; Ctrl+Alt+Shift+U opens USB.\n'
if selected is not None: 'Enter a VM name to reconnect, :shell for a host shell, :quit to close this terminal.')
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: while True:
try: try:
print(json.dumps([runtime.status(name) for name in data['names']], indent=2))
choice = input('hydra recovery> ').strip() choice = input('hydra recovery> ').strip()
except (EOFError, KeyboardInterrupt):
return 0
if choice == ':quit': if choice == ':quit':
return 0 return 0
if choice == ':shell': if choice == ':shell':
subprocess.run([os.environ.get('SHELL', '/bin/sh')]) subprocess.run([os.environ.get('SHELL', '/bin/sh')])
elif choice == ':refresh': elif choice in data['names']:
return request_refresh(manifest) subprocess.Popen(_module_argv('reconnect:' + choice, manifest), start_new_session=True)
except (EOFError, KeyboardInterrupt): else:
return 0 print('Enter a configured VM name, :shell, or :quit.')
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':
return request_refresh(manifest)
if command == 'recover':
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:'): if command.startswith('reconnect:'):
name = command.split(':', 1)[1] name = command.split(':', 1)[1]
# One viewer per VM inside this session; independent host view commands # One viewer per VM inside this session; independent host view commands
@@ -332,11 +244,9 @@ def session_helper(command: str, manifest: Path) -> int:
recovery = subprocess.Popen(_recovery_argv(manifest, data['terminal'])) recovery = subprocess.Popen(_recovery_argv(manifest, data['terminal']))
for name in data['names']: for name in data['names']:
try: try:
# Verified paused/prelaunch guests also have a live SPICE server; if runtime.status(name)['status'] == 'running':
# 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))) processes.append(subprocess.Popen(_module_argv('reconnect:' + name, manifest)))
except (RuntimeError, OSError, ValueError) as exc: except (OSError, ValueError) as exc:
print(f'hydra: {name}: {exc}', file=sys.stderr) print(f'hydra: {name}: {exc}', file=sys.stderr)
if not processes: if not processes:
open_recovery() open_recovery()
-566
View File
@@ -1,566 +0,0 @@
"""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())
-630
View File
@@ -1,630 +0,0 @@
"""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())
+3 -12
View File
@@ -1,4 +1,4 @@
Hydra OS builds start from the pinned amd64 systemd stage3 and pinned Gentoo HydraOS 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, repository in `sources.json`. Run the builder as the normal user with Python 3,
Podman and GnuPG available; temporary tool environments are sufficient. It Podman and GnuPG available; temporary tool environments are sufficient. It
refuses host root and work/cache paths outside the primary project checkout. refuses host root and work/cache paths outside the primary project checkout.
@@ -8,8 +8,6 @@ scripts/hydra-build fetch
scripts/hydra-build prepare scripts/hydra-build prepare
scripts/hydra-build packages --pretend scripts/hydra-build packages --pretend
scripts/hydra-build packages scripts/hydra-build packages
scripts/hydra-build rootfs
scripts/hydra-build iso
scripts/hydra-build status scripts/hydra-build status
``` ```
@@ -36,12 +34,5 @@ for installed-system updates. Rolling binhost contents can change; resulting
versions/USE are recorded without claiming bit-identical output. The pinned versions/USE are recorded without claiming bit-identical output. The pinned
service-keyring hash may need deliberate review when Gentoo rotates keys. service-keyring hash may need deliberate review when Gentoo rotates keys.
`rootfs` exports the dedicated builder into a private intermediate and assembles `rootfs` and `iso` are reserved assembly commands and currently return an
an isolated sanitized payload, generic live initramfs and squashfs. `iso` checks explicit error until the live-image assembly implementation is integrated.
those artifacts and emits the UEFI `HYDRA_OS` ISO, SHA256 and build manifest in
`build/artifacts/`. The working builder is retained for package retries. The
installed Gentoo repository uses the standard rsync backend with signed
metamanifest verification; the builder continues to use its pinned snapshot.
QEMU and remote-viewer retain local patches and are excluded from upstream
binary packages on installed updates. Disposable boot/install validation is
required before treating an emitted ISO as release ready.
-50
View File
@@ -1,50 +0,0 @@
#!/bin/bash
set -euo pipefail
if [[ $# != 1 || "$1" != /hydra-output/rootfs ]]; then
echo "configure-rootfs: expected the isolated /hydra-output/rootfs export" >&2
exit 1
fi
root=$1
python3 /hydra-source/hydra/image.py configure "$root" --source /hydra-source
# This is a separate rootfs inside the assembly container, never the host root.
mounted=()
cleanup_mounts() {
status=$?
trap - EXIT
cleanup_failed=0
for ((index=${#mounted[@]}-1; index>=0; index--)); do
umount "${mounted[index]}" || cleanup_failed=1
done
if ((cleanup_failed)); then
echo 'configure-rootfs: temporary mount cleanup failed; refusing success' >&2
status=1
fi
exit "$status"
}
trap cleanup_mounts EXIT
mount --bind /dev "$root/dev"
mounted+=("$root/dev")
mount -t proc proc "$root/proc"
mounted+=("$root/proc")
mount --bind /sys "$root/sys"
mounted+=("$root/sys")
for group in wheel kvm video render audio; do
chroot "$root" getent group "$group" >/dev/null || chroot "$root" groupadd -r "$group"
done
if ! chroot "$root" getent passwd hydra-live >/dev/null; then
chroot "$root" useradd --uid 1000 --create-home --home-dir /home/hydra-live --shell /bin/bash --groups wheel,kvm,video,render,audio hydra-live
fi
chroot "$root" usermod --lock hydra-live
chroot "$root" usermod --lock root
chroot "$root" locale-gen
ln -snf /usr/share/zoneinfo/Europe/Moscow "$root/etc/localtime"
# Enable the ordinary per-user sound services without starting a user manager.
python3 /hydra-source/hydra/image.py audio "$root"
# Set rootfs permissions now; capabilities are regenerated in the boot namespace.
chroot "$root" chown root:kvm /usr/libexec/qemu-bridge-helper
chroot "$root" chmod 0750 /usr/libexec/qemu-bridge-helper
chroot "$root" visudo --check
systemctl --root="$root" enable NetworkManager.service greetd.service hydra-network.service hydra-dnsmasq.service hydra-bridge-permissions.service getty@tty2.service serial-getty@ttyS0.service
systemctl --root="$root" set-default graphical.target
kernel=$(python3 /hydra-source/hydra/image.py kernel "$root")
chroot "$root" dracut --force --no-hostonly --no-hostonly-cmdline --conf /etc/hydra/live-mode/dracut.conf --confdir /etc/hydra/live-mode/dracut.conf.d --add dmsquash-live /boot/initramfs-live.img "$kernel"
-6
View File
@@ -1,6 +0,0 @@
hostonly="no"
hostonly_cmdline="no"
add_dracutmodules+=" dmsquash-live "
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"
-12
View File
@@ -1,12 +0,0 @@
set default=0
set timeout=5
insmod all_video
insmod serial
serial --unit=0 --speed=115200
terminal_input console serial
terminal_output console serial
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
}
-3
View File
@@ -1,3 +0,0 @@
masters = gentoo
thin-manifests = true
sign-manifests = false
-1
View File
@@ -1 +0,0 @@
hydra
@@ -1 +0,0 @@
DIST brightnessctl-0.5.1.tar.gz 8284 BLAKE2B 7d2226aae10fddc606c5ce71ed4a04eda583acf245ea1532d8730c73dc02ea7aeed47a0e25ca7f9d40e7b0170bb08d87617e9f4aa082495e78894982870ca7cc SHA512 41ac86357b3b5a3d54a043140a6bdb1fb68d5080a16e86692059ad58f4b9134a8532432a3f664c70c665a4ad73ea8a94cc9b88fa84abc28f0356b8b397aaebc7
@@ -1,18 +0,0 @@
# HydraOS local package for the pinned Gentoo snapshot.
EAPI=8
inherit toolchain-funcs udev
DESCRIPTION="Read and control backlight brightness without privileged launchers"
HOMEPAGE="https://github.com/Hummer12007/brightnessctl"
SRC_URI="https://github.com/Hummer12007/brightnessctl/archive/refs/tags/${PV}.tar.gz -> ${P}.tar.gz"
LICENSE="MIT"
SLOT="0"
KEYWORDS="amd64"
RDEPEND="virtual/udev"
src_compile() {
emake CC="$(tc-getCC)" VERSION="${PV}"
}
src_install() {
# Upstream's udev mode uses 0755, never its alternate setuid mode.
emake DESTDIR="${D}" PREFIX="${EPREFIX}/usr" UDEVDIR="${EPREFIX}$(get_udevdir)/rules.d" INSTALL_UDEV_RULES=1 install
dodoc README.md LICENSE
}
-5
View File
@@ -12,7 +12,6 @@ net-firewall/nftables
media-video/pipewire media-video/pipewire
media-video/wireplumber media-video/wireplumber
app-admin/sudo app-admin/sudo
app-misc/fastfetch
sys-kernel/gentoo-kernel-bin sys-kernel/gentoo-kernel-bin
sys-kernel/linux-firmware sys-kernel/linux-firmware
sys-firmware/sof-firmware sys-firmware/sof-firmware
@@ -22,9 +21,6 @@ sys-boot/grub
sys-fs/squashfs-tools sys-fs/squashfs-tools
sys-fs/dosfstools sys-fs/dosfstools
sys-fs/e2fsprogs sys-fs/e2fsprogs
sys-fs/btrfs-progs
sys-boot/efibootmgr
dev-libs/openssl
sys-block/parted sys-block/parted
sys-apps/util-linux sys-apps/util-linux
sys-fs/mtools sys-fs/mtools
@@ -32,4 +28,3 @@ dev-libs/libisoburn
dev-lang/python dev-lang/python
sec-keys/openpgp-keys-gentoo-release sec-keys/openpgp-keys-gentoo-release
sec-keys/openpgp-keys-gentoo-auth sec-keys/openpgp-keys-gentoo-auth
sys-apps/brightnessctl
+2 -2
View File
@@ -4,10 +4,10 @@ CXXFLAGS="${COMMON_FLAGS}"
FCFLAGS="${COMMON_FLAGS}" FCFLAGS="${COMMON_FLAGS}"
FFLAGS="${COMMON_FLAGS}" FFLAGS="${COMMON_FLAGS}"
MAKEOPTS="-j4" MAKEOPTS="-j4"
EMERGE_DEFAULT_OPTS="--jobs=1 --getbinpkg --binpkg-respect-use=y --autounmask=n --usepkg-exclude='app-emulation/qemu app-emulation/virt-viewer'" EMERGE_DEFAULT_OPTS="--jobs=1 --getbinpkg --binpkg-respect-use=y --autounmask=n"
FEATURES="getbinpkg binpkg-request-signature -usersandbox -sandbox -network-sandbox -ipc-sandbox -pid-sandbox" FEATURES="getbinpkg binpkg-request-signature -usersandbox -sandbox -network-sandbox -ipc-sandbox -pid-sandbox"
ACCEPT_LICENSE="@FREE @BINARY-REDISTRIBUTABLE linux-fw-redistributable no-source-code Intel-SDP" ACCEPT_LICENSE="@FREE @BINARY-REDISTRIBUTABLE linux-fw-redistributable no-source-code Intel-SDP"
VIDEO_CARDS="intel virgl" VIDEO_CARDS="intel"
USE="X dbus wayland opengl alsa" USE="X dbus wayland opengl alsa"
QEMU_SOFTMMU_TARGETS="x86_64" QEMU_SOFTMMU_TARGETS="x86_64"
QEMU_USER_TARGETS="" QEMU_USER_TARGETS=""
-2
View File
@@ -6,5 +6,3 @@ net-misc/networkmanager wifi
sys-boot/grub mount sys-boot/grub mount
media-libs/freetype harfbuzz media-libs/freetype harfbuzz
sys-kernel/installkernel dracut sys-kernel/installkernel dracut
sys-fs/squashfs-tools zstd
-4
View File
@@ -1,4 +0,0 @@
[hydra]
location = /var/db/repos/hydra
masters = gentoo
auto-sync = no
-3
View File
@@ -1,3 +0,0 @@
GRUB_DISTRIBUTOR="Hydra OS"
GRUB_TIMEOUT=5
GRUB_CMDLINE_LINUX_DEFAULT="console=tty0 console=ttyS0,115200n8"
@@ -1,3 +0,0 @@
hostonly="no"
hostonly_cmdline="no"
omit_dracutmodules+=" dmsquash-live "
-24
View File
@@ -1,24 +0,0 @@
{
"$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"
]
}
-5
View File
@@ -1,5 +0,0 @@
[terminal]
vt = 1
[default_session]
command = "/usr/bin/agreety --cmd /usr/bin/hydra-session"
user = "greetd"
-8
View File
@@ -1,8 +0,0 @@
[terminal]
vt = 1
[default_session]
command = "/usr/bin/agreety --cmd /usr/bin/hydra-session"
user = "greetd"
[initial_session]
command = "/usr/bin/hydra-session"
user = "hydra-live"
-5
View File
@@ -1,5 +0,0 @@
network = "bridge"
bridge = "hydrabr0"
width = 1920
height = 1080
refresh_millihz = 144000
-11
View File
@@ -1,11 +0,0 @@
interface=hydrabr0
bind-dynamic
except-interface=lo
listen-address=10.203.0.1
dhcp-range=10.203.0.20,10.203.0.254,255.255.255.0,12h
dhcp-option=option:router,10.203.0.1
dhcp-option=option:dns-server,10.203.0.1
dhcp-leasefile=/var/lib/hydra/dnsmasq.leases
resolv-file=/run/NetworkManager/resolv.conf
domain-needed
bogus-priv
-1
View File
@@ -1 +0,0 @@
Hydra OS live medium: changes to the host root are not persistent.
@@ -1,7 +0,0 @@
[
"/etc/greetd/live.toml",
"/etc/sudoers.d/90-hydra-live",
"/etc/systemd/system/greetd.service.d/90-hydra-live.conf",
"/etc/systemd/system/getty@tty2.service.d/90-hydra-live.conf",
"/etc/systemd/system/serial-getty@ttyS0.service.d/90-hydra-live.conf"
]
-3
View File
@@ -1,3 +0,0 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAv7478NwuD7binWXyVcaWCIo9o+ahNThjNjMRot+k5w0=
-----END PUBLIC KEY-----
-1
View File
@@ -1 +0,0 @@
LANG=en_US.UTF-8
-2
View File
@@ -1,2 +0,0 @@
en_US.UTF-8 UTF-8
ru_RU.UTF-8 UTF-8
-6
View File
@@ -1,6 +0,0 @@
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 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.
-6
View File
@@ -1,6 +0,0 @@
# Preserve the scoped bridge-helper permission after ordinary QEMU updates.
post_pkg_postinst() {
if [[ ${CATEGORY}/${PN} == app-emulation/qemu ]]; then
"${EROOT}usr/libexec/hydra-bridge-permissions" || die "Hydra bridge permissions failed"
fi
}
@@ -1,11 +0,0 @@
[gentoo]
location = /var/db/repos/gentoo
sync-type = rsync
sync-uri = rsync://rsync.gentoo.org/gentoo-portage
auto-sync = yes
sync-rsync-verify-metamanifest = yes
sync-rsync-verify-max-age = 3
sync-openpgp-key-package = sec-keys/openpgp-keys-gentoo-release
sync-openpgp-key-path = /usr/share/openpgp-keys/gentoo-release.asc
sync-openpgp-keyserver = hkps://keys.gentoo.org
sync-webrsync-verify-signature = yes
-1
View File
@@ -1 +0,0 @@
allow hydrabr0
@@ -1 +0,0 @@
%wheel ALL=(ALL:ALL) ALL

Some files were not shown because too many files have changed in this diff Show More