Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea43a67a97 | ||
|
|
e9906a793b | ||
|
|
adfce75973 | ||
|
|
7353c4ec92 | ||
|
|
623ecdef85 | ||
|
|
6f7344fca5 | ||
|
|
0e86fffd54 | ||
|
|
e6cf7b7252 | ||
|
|
09ed8f314f | ||
|
|
a6367e55ae | ||
|
|
d7d01a4520 |
@@ -1,4 +1,4 @@
|
||||
# Hydra OS
|
||||
# HydraOS
|
||||
|
||||
Gentoo-based host for concurrent virtual desktops. Its default session shows one
|
||||
VM fullscreen and switches between guests with keyboard shortcuts. Other host
|
||||
@@ -6,21 +6,9 @@ desktops may use the same independent VM manager and viewers.
|
||||
|
||||
## Status
|
||||
|
||||
Hydra OS 0.2.0 adds selected-partition installation, signed runtime updates,
|
||||
offline Btrfs base recovery and read-only laptop diagnostics. The complete source
|
||||
suite passed **431 tests**. The final ISO passed **13 whole-disk** and **23
|
||||
preserved-partition/recovery** UEFI/KVM checks, with no skips. See the
|
||||
[release and acceptance record](docs/releases/2026-10-13-laptop-ready/README.md).
|
||||
**USB correction (2026-10-14):** those installation tests used virtual CD-ROM
|
||||
media. Raw USB boot exposed a duplicate HFS+ live-volume label in that ISO.
|
||||
Use `hydra-os-usb-fixed.iso` for USB installation; its unchanged system payload
|
||||
now passes both USB and CD-ROM live boot, graphical-session and shutdown checks.
|
||||
The updated source suite passes **439 tests**. See the
|
||||
[USB correction record](docs/releases/2026-10-14-usb-fix/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.
|
||||
Under active development, not yet an installation-ready distribution. The target
|
||||
is an installable UEFI ISO, first validated on ASUS TUF Dash F15 FX517ZC. Existing
|
||||
NixOS and VM disks are not installation or test targets.
|
||||
|
||||
1. Independent QEMU/KVM lifecycle with private QMP control.
|
||||
2. Minimal labwc session and fullscreen SPICE viewers.
|
||||
@@ -34,99 +22,9 @@ a refresh-rate setting does not prove delivered FPS.
|
||||
- [Session design](docs/superpowers/specs/2026-10-13-graphics-session-design.md)
|
||||
- [Implementation plan](docs/superpowers/plans/2026-10-13-graphics-session.md)
|
||||
- [Hardware target](docs/hardware/asus-fx517zc.md)
|
||||
- [Signed runtime updates](docs/updates.md)
|
||||
- [Offline Gentoo base recovery](docs/system-recovery.md)
|
||||
|
||||
Build outputs, VM disks, runtime state and credentials stay outside Git.
|
||||
|
||||
## First use
|
||||
|
||||
Follow the [installation guide](docs/install.md) to install from the live ISO.
|
||||
The 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
|
||||
|
||||
@@ -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.
|
||||
@@ -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,5 +1,5 @@
|
||||
#!/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
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -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/installer.py').is_file():
|
||||
sys.path.insert(0, str(checkout))
|
||||
from hydra.installer import main
|
||||
|
||||
raise SystemExit(main())
|
||||
@@ -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())
|
||||
@@ -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']())
|
||||
@@ -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))
|
||||
@@ -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())
|
||||
-1223
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -130,7 +130,7 @@ identities = {name: {'pid': manager.paths(name).pid.read_text().strip(),
|
||||
assert first['pid'] != second['pid'], 'compositor was not refreshed'
|
||||
logs = {name: root / (name + '.log') for name in ('alpha', 'beta')}
|
||||
for name, path in logs.items():
|
||||
wait_for(lambda: path.exists() and 'set_title("Hydra OS: ' + name + '")' in path.read_text(),
|
||||
wait_for(lambda: path.exists() and 'set_title("HydraOS: ' + name + '")' in path.read_text(),
|
||||
name + ' product viewer did not connect')
|
||||
viewers = {name: json.loads((root / (name + '-viewer.json')).read_text()) for name in logs}
|
||||
assert len({data['config'] for data in viewers.values()}) == 2, 'viewer settings were shared'
|
||||
@@ -141,28 +141,10 @@ identities = {name: {'pid': manager.paths(name).pid.read_text().strip(),
|
||||
assert '--spice-usbredir-redirect-on-connect=-1,-1,-1,-1,0' in data['argv']
|
||||
alog, blog = logs['alpha'], logs['beta']
|
||||
display = next(p.name for p in xdg.glob('wayland-*') if not p.name.endswith('.lock'))
|
||||
# Exercise a real menu selection of the second, already running VM.
|
||||
# Its request refreshes only the private compositor/viewers.
|
||||
before_beta_focus = len(focus_events(blog))
|
||||
manifest = Path(second['config_directory']).parent / 'session.json'
|
||||
with (root / 'selection.log').open('w') as selection_log:
|
||||
selection = subprocess.run(cli + ['_session-helper', 'recover', str(manifest)],
|
||||
input='beta\n', text=True,
|
||||
env=dict(env, WAYLAND_DISPLAY=display),
|
||||
stdout=selection_log, stderr=selection_log, timeout=20)
|
||||
assert selection.returncode == 0, 'menu selection failed'
|
||||
wait_for(lambda: (root / 'snapshot-2.json').exists(), 'selected menu did not refresh compositor', timeout=15)
|
||||
third = json.loads((root / 'snapshot-2.json').read_text())
|
||||
from xml.etree import ElementTree as ET
|
||||
assert ET.fromstring((root / 'snapshot-2.xml').read_text()).findtext('./desktops/initial') == '2'
|
||||
wait_for(lambda: any(p.name.startswith('wayland-') and not p.name.endswith('.lock')
|
||||
for p in xdg.iterdir()), 'refreshed private display is not ready')
|
||||
display = next(p.name for p in xdg.glob('wayland-*') if not p.name.endswith('.lock'))
|
||||
keyboard_env = dict(env, WAYLAND_DISPLAY=display)
|
||||
injector = subprocess.Popen([args.keyboard], env=keyboard_env, stdin=subprocess.PIPE, text=True)
|
||||
time.sleep(.2)
|
||||
wait_for(lambda: len(focus_events(blog)) > before_beta_focus and focus_events(blog)[-1] == 'enter',
|
||||
'second VM did not gain fresh focus after menu selection')
|
||||
wait_for(lambda: focus_events(alog) and focus_events(alog)[-1] == 'enter', 'alpha did not focus')
|
||||
|
||||
def switch(code, source_log, target_log):
|
||||
before_source, before_target = focus_events(source_log), focus_events(target_log)
|
||||
@@ -171,10 +153,10 @@ identities = {name: {'pid': manager.paths(name).pid.read_text().strip(),
|
||||
wait_for(lambda: fresh_focus_transition(before_source, before_target,
|
||||
focus_events(source_log), focus_events(target_log)), 'fresh reserved focus transition failed')
|
||||
|
||||
switch(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)
|
||||
intervals = {'alpha': measure_hidden_interval(lambda: counter(manager, 'alpha'), alog, blog)}
|
||||
switch(105, blog, alog)
|
||||
intervals['beta'] = measure_hidden_interval(lambda: counter(manager, 'beta'), blog, alog)
|
||||
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)}
|
||||
@@ -184,16 +166,15 @@ identities = {name: {'pid': manager.paths(name).pid.read_text().strip(),
|
||||
assert after[name]['counter'] != before[name]['counter']
|
||||
# Only the PID recorded by our own wrapper, verified against its private
|
||||
# config path, may be signalled. Never inherit a host compositor PID.
|
||||
command = Path('/proc') / str(third['pid']) / 'cmdline'
|
||||
assert third['config_directory'].encode() in command.read_bytes()
|
||||
os.kill(third['pid'], signal.SIGTERM)
|
||||
command = Path('/proc') / str(second['pid']) / 'cmdline'
|
||||
assert second['config_directory'].encode() in command.read_bytes()
|
||||
os.kill(second['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,
|
||||
compositor_pids=[first['pid'], second['pid']], before=before, after=after,
|
||||
viewers=viewers,
|
||||
hidden_intervals=intervals, survived_session_exit=True,
|
||||
expected_xwayland_diagnostics=classify_compositor_errors((root / 'session.log').read_text()),
|
||||
|
||||
+3
-3
@@ -178,7 +178,7 @@ def main():
|
||||
env=viewer_env, stdout=vl, stderr=vl)
|
||||
processes.append(proc)
|
||||
log = root / f'{name}-{number}.log'
|
||||
wait_for(lambda: f'set_title("Hydra OS: {name}")' in log.read_text(), f'{name} viewer did not connect')
|
||||
wait_for(lambda: f'set_title("HydraOS: {name}")' in log.read_text(), f'{name} viewer did not connect')
|
||||
return proc, log
|
||||
|
||||
for name in names:
|
||||
@@ -220,8 +220,8 @@ def main():
|
||||
post_usb_switches = [switch(106, alog, blog), switch(105, blog, alog)]
|
||||
assert alpha.poll() is None and beta.poll() is None
|
||||
assert identities == {name: manager.paths(name).pid.read_text().strip() for name in names}, 'switch restarted a guest'
|
||||
assert alog.read_text().count('set_title("Hydra OS: alpha")') == 1, 'switch reconnected alpha'
|
||||
assert blog.read_text().count('set_title("Hydra OS: beta")') == 1, 'switch reconnected beta'
|
||||
assert alog.read_text().count('set_title("HydraOS: alpha")') == 1, 'switch reconnected alpha'
|
||||
assert blog.read_text().count('set_title("HydraOS: beta")') == 1, 'switch reconnected beta'
|
||||
with manager._connect(manager.paths('alpha')) as qmp:
|
||||
usb_after = qmp.command('human-monitor-command', {'command-line': 'info usb'})
|
||||
assert usb_after == usb_before, 'opening selector attached an unselected USB device'
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# ASUS TUF Dash F15 FX517ZC
|
||||
|
||||
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 |
|
||||
| --- | --- | --- |
|
||||
| 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 |
|
||||
| Discrete GPU | RTX 3050 Mobile 10de:25a2 | Initial shell uses Intel; test NVIDIA separately |
|
||||
| Wi-Fi | Intel CNVi 8086:51f0 | iwlwifi/iwlmvm, linux-firmware |
|
||||
@@ -16,53 +16,7 @@ Initial read-only inventory on 2026-10-13; no serials or machine identifiers.
|
||||
| Thunderbolt | Intel 8086:463e | thunderbolt |
|
||||
| Laptop controls | ASUS | asus_wmi, asus_nb_wmi |
|
||||
|
||||
Include vmd, nvme, xhci_pci, thunderbolt, usb_storage and sd_mod in boot coverage.
|
||||
Target physical refresh is 144 Hz; actual guest frame delivery requires measurement.
|
||||
|
||||
## Read-only doctor and live functional checks
|
||||
|
||||
Run `hydra doctor` from the Hydra live session, or `hydra doctor --json` to save
|
||||
structured observations. The command reads procfs/sysfs and checks service status;
|
||||
it does not connect networks, load modules, change brightness, play sound, mount
|
||||
storage, suspend, or start services. Root is not required. Missing tools, absent
|
||||
hardware, access failures and service timeouts are reported without aborting.
|
||||
|
||||
Reports omit storage serials, PCI addresses, interface names, MAC/IP addresses,
|
||||
SSID, usernames, hostname and machine IDs. CPU facts include architecture, logical
|
||||
processor count and virtualization flags. Device facts include counts, capacities,
|
||||
allowlisted driver names and connector resolution modes. Unknown driver names are
|
||||
reported as `other`. Presence and status do not establish physical functionality.
|
||||
|
||||
Reads are limited to 64 KiB per file, directory scans to 64 entries, connector
|
||||
modes to 32 lines, and seven service queries to one second each. Counts may be
|
||||
partial on systems exceeding these limits. Service output is discarded; exit
|
||||
status alone determines active/inactive/unavailable/unknown. User audio service
|
||||
queries refer to the user running the command. A root invocation can therefore
|
||||
observe a different user audio manager than the live desktop user.
|
||||
|
||||
The following source-checkout smoke was observed on the running NixOS host on
|
||||
2026-10-13. It involved no changes to host settings or hardware. It is separate
|
||||
from Hydra ISO validation and does not certify the new image's laptop drivers.
|
||||
|
||||
| Check | Doctor detects | Source-checkout host observation | Live functional check still needed |
|
||||
| --- | --- | --- | --- |
|
||||
| CPU / RAM | Architecture, logical CPUs, VM flags, memory totals | x86_64, 16 logical CPUs, `vmx`, 31,715 MiB RAM | Boot the Hydra image and compare |
|
||||
| Firmware | UEFI presence, Secure Boot variable | UEFI present, Secure Boot disabled | Confirm live boot from USB |
|
||||
| KVM | Device access and loaded KVM modules | `/dev/kvm` accessible, `kvm` and `kvm_intel` present | Launch a disposable VM from Hydra |
|
||||
| Storage | Whole-device count/capacity; no serials or identities | Visibility observed; no target selected | Identify any future installation target separately |
|
||||
| Graphics | PCI driver bindings and connector resolution modes | `i915`, `nvidia`; connected 1920×1080 mode | Select 144 Hz and verify actual refresh rate |
|
||||
| Input / brightness | Event devices, keyboard capabilities, backlight presence | 23 event devices, three keyboard-capable devices, one backlight | Test keyboard, touchpad and both brightness keys |
|
||||
| Wi-Fi / Ethernet | Device counts, wireless presence, driver bindings | One wireless device; `iwlwifi` and `e1000e` | Connect Wi-Fi and test connectivity; test Ethernet if used |
|
||||
| Audio | Card presence and user audio service status | Three cards; PipeWire, WirePlumber and PipeWire Pulse active | Test speakers, headphones and microphone recording |
|
||||
| Desktop / guest networking | Hydra and desktop service status | NetworkManager active; Hydra services absent on NixOS | Observe required services in the Hydra live session |
|
||||
| Suspend / USB | No functional probe | Not exercised | Suspend/resume, then retest graphics/network/audio; attach USB devices |
|
||||
|
||||
Run the manual checks before deciding the image is ready for daily laptop use.
|
||||
Read-only diagnostics and disposable VM acceptance cannot replace these checks.
|
||||
Keep any report you share within its default structured output: unrelated logs,
|
||||
network commands and screenshots can contain private identifiers.
|
||||
|
||||
Installation remains an explicit operator action; this document selects no disk
|
||||
or partition. Signed runtime rollback restores Hydra runtime files only. Offline
|
||||
Btrfs root/owned-EFI recovery has separate requirements and acceptance evidence
|
||||
in [system-recovery.md](../system-recovery.md).
|
||||
UEFI boot, Secure Boot currently disabled. Include vmd, nvme, xhci_pci,
|
||||
thunderbolt, usb_storage, sd_mod in boot coverage. Target physical refresh144Hz;
|
||||
actual guest frame delivery must be measured. About290GiB build storage free at
|
||||
inventory. Initial RAM headroom about10GiB with existing sessions running.
|
||||
|
||||
-165
@@ -1,165 +0,0 @@
|
||||
# Install Hydra OS
|
||||
|
||||
Hydra OS targets x86_64 UEFI systems. Disable Secure Boot for this first image.
|
||||
For a USB flash drive, use the [2026-10-14 corrected ISO](releases/2026-10-14-usb-fix/README.md),
|
||||
`hydra-os-usb-fixed.iso`. The original 0.2.0 ISO passed optical-media tests but has
|
||||
a raw USB live-boot bug. Write the corrected image to the entire flash drive
|
||||
using a host-native image writer; this erases that flash drive. Mounting it before
|
||||
reboot is unnecessary. Physical firmware compatibility still requires testing.
|
||||
Boot the ISO and enter `:shell` in the terminal VM menu. The live session uses a locked
|
||||
`hydra-live` account with local sudo access; changes in the live filesystem are
|
||||
volatile. The image provides no default SSH login or universal installed password.
|
||||
|
||||
`hydra-install` lists disks without selecting one. Inspect the devices, then ask
|
||||
for a plan using either the explicitly chosen existing partitions or a whole disk.
|
||||
The commands below use placeholders; the installer never chooses devices automatically.
|
||||
|
||||
## Preserve existing partitions
|
||||
|
||||
Choose a root partition whose contents you intend to replace and an existing FAT
|
||||
GPT EFI System Partition (ESP). Inspect their identities and sizes with `lsblk`.
|
||||
Both selected partitions must be unmounted; unrelated mounted siblings are allowed.
|
||||
|
||||
```sh
|
||||
sudo hydra-install --root-partition /dev/CHOSEN_ROOT \
|
||||
--esp-partition /dev/CHOSEN_ESP --user YOUR_USER --root-fs btrfs
|
||||
```
|
||||
|
||||
This dry-run describes the operation. To execute the same choice:
|
||||
|
||||
```sh
|
||||
sudo hydra-install --root-partition /dev/CHOSEN_ROOT \
|
||||
--esp-partition /dev/CHOSEN_ESP --user YOUR_USER --root-fs btrfs \
|
||||
--execute --confirm-format /dev/CHOSEN_ROOT
|
||||
```
|
||||
|
||||
`--confirm-format` must name the canonical resolved root path exactly, even if an
|
||||
input path is an alias. This mode formats **only the selected root partition**.
|
||||
It never recreates GPT, changes partition boundaries, or formats the ESP. Before
|
||||
formatting, it checks uncompressed payload capacity, mounts the ESP read-only,
|
||||
refuses any existing `EFI/HydraOS` collision, and requires at least 64 MiB free.
|
||||
It fingerprints foreign ESP files and verifies their hashes after installation.
|
||||
|
||||
GRUB writes `EFI/HydraOS/grubx64.efi` using `--no-nvram` without `--removable`.
|
||||
Existing EFI loaders, `EFI/BOOT/BOOTX64.EFI`, and firmware BootOrder are preserved.
|
||||
By default, select that Hydra EFI file manually in your firmware boot interface.
|
||||
`--register-boot-entry` opts into `efibootmgr --create-only`: it creates a new
|
||||
HydraOS entry without adding it to BootOrder. Firmware support for selecting EFI
|
||||
files and managing entries varies.
|
||||
|
||||
Only plain, nonoverlapping GPT partitions on physical disks are supported. Root
|
||||
and ESP may reside on different disks. Device mapper, encrypted/LVM/RAID targets,
|
||||
nested partition devices, missing identities/bounds, active holders, and ambiguous
|
||||
backing information are rejected. Mounted Btrfs filesystems must report every
|
||||
backing member; an incomplete or missing-device report is rejected. ZFS backing
|
||||
anatomy is unsupported and causes a conservative refusal. The checks are
|
||||
repeated just before formatting; do not change mounts or storage topology during
|
||||
installation. They cannot replace your inspection and backups.
|
||||
|
||||
`--root-fs ext4` is the default. Btrfs creates `@root`, `@home`, and `@snapshots`.
|
||||
The kernel, `/boot`, and Portage database stay inside `@root`; `/home` (including
|
||||
guest disks) uses `@home`, and `/.snapshots` uses `@snapshots`. fstab and boot
|
||||
arguments use filesystem UUIDs and named subvolumes, without a fixed root
|
||||
subvolume ID. The layout alone does not implement system rollback; full Gentoo
|
||||
and kernel recovery requires separate bootable verification.
|
||||
|
||||
An installed, root-owned mode-0600 `/etc/hydra/storage.json` records format 1, a
|
||||
random install UUID, root/ESP filesystem UUIDs and GPT PARTUUIDs, filesystem type,
|
||||
and owned EFI path `EFI/HydraOS`. Btrfs additionally records
|
||||
`root_subvolume: "@root"`. Treat this identity record as installation metadata,
|
||||
not an update payload.
|
||||
|
||||
## Erase a whole disk
|
||||
|
||||
Use this mode only when every partition on the explicitly chosen disk may be erased:
|
||||
|
||||
```sh
|
||||
hydra-install
|
||||
sudo hydra-install --disk /dev/CHOSEN_DISK --user YOUR_USER
|
||||
```
|
||||
|
||||
Replace the placeholders with an actual whole disk and your desired account
|
||||
name. The installer rejects mounted devices, the running root, the live medium,
|
||||
and their backing disks. These checks help prevent mistakes; verify the chosen
|
||||
disk and preserve backups before executing the plan. Physical disk installation
|
||||
requires the operator's explicit choice.
|
||||
|
||||
To execute, enter the identical resolved whole-disk path in the confirmation:
|
||||
|
||||
```sh
|
||||
sudo hydra-install --disk /dev/CHOSEN_DISK --user YOUR_USER \
|
||||
--execute --confirm-erase /dev/CHOSEN_DISK
|
||||
```
|
||||
|
||||
Execution erases the disk, creates a 1 GiB FAT32 EFI partition and an ext4 root (or Btrfs with `--root-fs btrfs`),
|
||||
extracts the prepared payload, and prompts twice on the local terminal for your
|
||||
installed account password. Passwords have no command-line or configuration
|
||||
option. The installed root account stays locked. Defaults are Europe/Moscow and
|
||||
English UTF-8, with Russian UTF-8 also available; `--timezone` accepts an IANA
|
||||
zone present in the payload.
|
||||
|
||||
The installer removes the live account, local password-free sudo rule, and
|
||||
live autologin overrides, then builds a normal disk initramfs, its owned HydraOS UEFI loader, and a fallback
|
||||
UEFI loader on the newly created ESP. Both installation modes keep os-prober disabled. Wait for `Installation completed`, power off, remove the ISO, and boot
|
||||
the installed disk. Log in with the account and password you supplied. On
|
||||
failure, read the printed live diagnostic path; when a target root was mounted,
|
||||
a nonsecret snapshot is also saved at `/var/log/hydra-install.log` on that disk.
|
||||
|
||||
## Disposable VM acceptance
|
||||
|
||||
Run the repository harness with QEMU/KVM, `qemu-img`, Python 3, and a generic
|
||||
x86_64 QEMU OVMF CODE/VARS pair. A Xen-specific OVMF build is unsuitable: the
|
||||
first validation attempt with that variant produced no guest boot output.
|
||||
Use trusted image and matching firmware files. For example:
|
||||
|
||||
```sh
|
||||
install -d -m 700 "$HOME/.cache/hydra-tests"
|
||||
python3 dev/iso-smoke.py --iso /absolute/path/to/hydra-os.iso \
|
||||
--ovmf-code /absolute/path/to/OVMF_CODE.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
|
||||
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
|
||||
sockets. Tool overrides `--qemu` and `--qemu-img` support temporary development
|
||||
environments. `--scratch-parent`, `--memory`, `--boot-timeout`, and
|
||||
`--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
|
||||
controlling terminal using a generated password, writes a persistence sentinel,
|
||||
shuts down, and launches a second QEMU with the ISO absent. It verifies password
|
||||
login, disk root, removed live overrides, services, bridge/NAT configuration,
|
||||
DNS, recovery tools, and a routed new TCP connection from a synthetic LAN to a
|
||||
synthetic VM endpoint. Network namespaces and veth interfaces exist only inside
|
||||
the disposable Hydra OS guest. When guest KVM is available, it launches a further
|
||||
fresh-disk QEMU as the installed account to exercise KVM, the bridge helper,
|
||||
and private QMP; this checks VM launch, not a nested operating system boot.
|
||||
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
|
||||
skipped nested virtualization. Serial/QEMU logs and the qcow2 remain in the
|
||||
printed scratch directory for review; passwords are redacted even across serial
|
||||
read boundaries. Cleanup controls only the QEMU process created by this run.
|
||||
A passing unit suite establishes harness behavior, not ISO acceptance. An
|
||||
actual successful report from the built ISO is required for boot/install/reboot
|
||||
acceptance.
|
||||
|
||||
## Checks requiring the laptop
|
||||
|
||||
Disposable QEMU results cannot establish ASUS FX517ZC hardware support. Before
|
||||
using Hydra OS daily, boot the live ISO and observe Wi-Fi, touchpad, audio,
|
||||
brightness, suspend/resume, USB device selection, input capture, and actual
|
||||
display refresh. Physical installation and these checks remain pending until
|
||||
explicitly performed and recorded.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,49 +0,0 @@
|
||||
# Hydra OS USB media correction — 2026-10-14
|
||||
|
||||
The original 0.2.0 ISO (`451e058228d7cbea87b5301ae6502b6be609cf7f52cc7c9e6fb477822501389b`)
|
||||
boots as a virtual CD-ROM but fails when attached as raw USB storage. GRUB's HFS+
|
||||
hybrid uses the same `HYDRA_OS` label as ISO9660; dracut then selects the HFS+
|
||||
partition and fails to mount the live payload. This was reproduced under OVMF
|
||||
with a read-only USB image and no physical disk access.
|
||||
|
||||
The corrected image adds native xorriso `-hfsplus off` to `grub-mkrescue`. It keeps
|
||||
GPT, the EFI partition and El Torito boot information, and removes HFS+/APM.
|
||||
The builder now rejects HFS+/APM in its inspection report.
|
||||
|
||||
- Local file: `build/artifacts/hydra-os-usb-fixed.iso`
|
||||
- Size: 2,380,120,064 bytes
|
||||
- SHA256: `3a126e1a1004ba24ead66971013ba4a8468508f6fac4dcaf04aa9770cb8df7e1`
|
||||
- Frozen payload source: `ebeebc7fb1aed07a5f50bc62374365d614953dad`
|
||||
- The ISO is local; this Git directory contains evidence, not an ISO download.
|
||||
|
||||
Kernel, live initramfs, squashfs and embedded build manifest match the original
|
||||
ISO byte-for-byte. [Payload hashes](payload-comparison.json) were computed from
|
||||
the actual original and corrected ISO extents. This is a media-layout repair,
|
||||
not a new system payload or signed runtime update. The original artifact and
|
||||
release evidence remain unchanged.
|
||||
|
||||
## Verification
|
||||
|
||||
`python3 -m unittest discover -s tests -q` passed **439 tests** inside Ubuntu with
|
||||
Python 3.12 and OpenSSL available. Initial host runs failed in pre-existing
|
||||
OpenSSL-dependent updater/signature tests because OpenSSL was absent or lost
|
||||
from the test PATH; these were not reported as successful runs.
|
||||
|
||||
The same corrected ISO passed the real `dev/iso-smoke.py --live-only` checks in
|
||||
both [USB](usb-live-acceptance.json) and [CD-ROM](cdrom-live-acceptance.json) modes:
|
||||
KVM, UEFI live user and overlay root, readable squashfs, sudo, required tools,
|
||||
NetworkManager and Hydra network services, active greetd/labwc/foot, and normal
|
||||
shutdown. Each run used fresh OVMF variables and a disposable test disk. Neither
|
||||
invoked the installer or accessed host disks. The earlier full installation and
|
||||
recovery checks apply to the byte-identical system payload, not a newly repeated
|
||||
full installation test of this repacked ISO.
|
||||
|
||||
The remaining FAT12 EFI partition is unchanged. OVMF successfully starts it via
|
||||
USB; this does not certify detection by every physical firmware. ASUS boot-menu
|
||||
visibility, USB port behavior and actual laptop live boot still need physical
|
||||
confirmation. Secure Boot must remain disabled.
|
||||
|
||||
Reflash the corrected ISO using a host-native writer. For the existing NixOS
|
||||
setup, GNOME Disks can be run temporarily; an Etcher process inside rootless
|
||||
Distrobox could not elevate or write the raw USB device. Do not change SSD
|
||||
partitions to repair an installation USB.
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"status": "passed",
|
||||
"scratch": "/tmp/hydra-iso-smoke-l7g8944d",
|
||||
"iso": "/home/workspaces/ubuntu-dev/home/Projects/hydra-os/build/artifacts/usb-fix-nohfs/hydra-os.iso",
|
||||
"iso_sha256": "3a126e1a1004ba24ead66971013ba4a8468508f6fac4dcaf04aa9770cb8df7e1",
|
||||
"live_media": "cdrom",
|
||||
"scope": "live-only",
|
||||
"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"
|
||||
},
|
||||
"live_graphical_session": {
|
||||
"status": "passed",
|
||||
"output": "active\n832\n854\n"
|
||||
},
|
||||
"live_poweroff": {
|
||||
"status": "passed",
|
||||
"output": "Live-only check completed without invoking the installer"
|
||||
}
|
||||
},
|
||||
"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-14T04:35:22Z"
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"vmlinuz": {
|
||||
"size": 22666224,
|
||||
"original": "bb77e0f760f1ce97d2f6e23acad1fb2bb3dccab6c1381bec0868327e110ec3a5",
|
||||
"candidate": "bb77e0f760f1ce97d2f6e23acad1fb2bb3dccab6c1381bec0868327e110ec3a5",
|
||||
"matches": true
|
||||
},
|
||||
"initramfs-live.img": {
|
||||
"size": 83130912,
|
||||
"original": "694778902d95bfc94da1d7566d371379fb4a60de95e49babb6500cf907509d8e",
|
||||
"candidate": "694778902d95bfc94da1d7566d371379fb4a60de95e49babb6500cf907509d8e",
|
||||
"matches": true
|
||||
},
|
||||
"squashfs.img": {
|
||||
"size": 2254630912,
|
||||
"original": "ca550eb444e05505f815f4820b28527c3f8e8aee425005b73b7f02e4e74f7fc8",
|
||||
"candidate": "ca550eb444e05505f815f4820b28527c3f8e8aee425005b73b7f02e4e74f7fc8",
|
||||
"matches": true
|
||||
},
|
||||
"build-manifest.json": {
|
||||
"size": 142178,
|
||||
"original": "126c66d84b7c2160a517cbd0c3649630982a4d734e113ecdd6ee48e0859f1630",
|
||||
"candidate": "126c66d84b7c2160a517cbd0c3649630982a4d734e113ecdd6ee48e0859f1630",
|
||||
"matches": true
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"status": "passed",
|
||||
"scratch": "/tmp/hydra-iso-smoke-arp78mfm",
|
||||
"iso": "/home/workspaces/ubuntu-dev/home/Projects/hydra-os/build/artifacts/usb-fix-nohfs/hydra-os.iso",
|
||||
"iso_sha256": "3a126e1a1004ba24ead66971013ba4a8468508f6fac4dcaf04aa9770cb8df7e1",
|
||||
"live_media": "usb",
|
||||
"scope": "live-only",
|
||||
"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"
|
||||
},
|
||||
"live_graphical_session": {
|
||||
"status": "passed",
|
||||
"output": "active\n855\n869\n"
|
||||
},
|
||||
"live_poweroff": {
|
||||
"status": "passed",
|
||||
"output": "Live-only check completed without invoking the installer"
|
||||
}
|
||||
},
|
||||
"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-14T04:35:22Z"
|
||||
}
|
||||
@@ -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`.
|
||||
**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.
|
||||
- [x] 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.
|
||||
- [x] 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.
|
||||
- [ ] Test pinned download hash mismatch, required signaturefingerprint, safe workpath/refusal of root, resume state vs missingactualartifact, scoped Podman argv.
|
||||
- [ ] Observe RED; implement verifiedfetch and isolatedimport/container naming. Use stage3 20261011T170049Z SHA256 50634e8cb87384b94a6dbf78cd2707766c45a5a98f3823e7287d114ee998970b and Portage4696089e782c20b5f0c04952f6698d6a8cc1acb4 archiveSHA256 d24b2056f7ae26506193374b0d9f25c82d4bf547e0ee17279940169418bd3bca.
|
||||
- [ ] Configure officialbinhost signaturechecking, selective ~amd64 for labwc/wlroots, QEMU x86_64 only, required SPICE/GL/USB flags. Preserve licenses and installed packageDB.
|
||||
- [ ] Run actual dependency resolution and package install; record exact versions/USE and any selective sourcebuild decisions. No claim of bit-identical build.
|
||||
- [ ] Run tests and commit builder inputs/scripts.
|
||||
|
||||
### Task 2: Host rootfs and bootable image
|
||||
|
||||
**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.
|
||||
|
||||
- [x] 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.
|
||||
- [x] 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.
|
||||
- [ ] Test service/config generation, live-only overrides separation, exact kernelversion used for dracut, image label/cmdline agreement, networksubnet conflict handling.
|
||||
- [ ] Implement rootfs overlay: greetd/minimalsession, recovery, localliveadministration, installedauth baseline, audio, NetworkManager, hydrabr0/DHCP/NAT and narrowlyscoped bridgehelper. Include laptopfirmware/bootdrivers and OVMF.
|
||||
- [ ] Build genericliveinitramfs with dmsquash-live and OverlayFS; squashfspayload retains kernel/packageDB. Assemble UEFIISO with GRUB; save SHA256 and manifest.
|
||||
- [ ] Run image checks and disposable OVMF/KVM liveboot; verify rootfilesystem ready and expected tools. Commit rootfs/imageconfig.
|
||||
|
||||
### Task 3: Explicit guarded installer
|
||||
|
||||
**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.
|
||||
|
||||
- [x] 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.
|
||||
- [x] 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.
|
||||
- [x] Commit installer and negative tests.
|
||||
- [ ] Test missing/mismatched confirmation, disk mounted directly or through children/ancestors, root/live source backingdisk, invaliddevice, symlink resolution, invalidusername, failedcommands abort subsequentsteps.
|
||||
- [ ] Observe RED; implement sysfs/lsblk topology validation, GPT1GiBESP/ext4, payload extraction, fstabUUIDs, user/password setup and liveoverridesremoval. Explicit target, no automaticdiscoveryselection.
|
||||
- [ ] Install regularinitramfs and GRUBfallback --removable --no-nvram. Checked subprocessargv; handle failure diagnostics and mounts cleanup without deletingtargetdata.
|
||||
- [ ] Test fakecommandfailure paths then complete actualinstallation inside fresh VM using disposable qcow2. Rootprivilege exists only in testguest.
|
||||
- [ ] Commit installer and negative tests.
|
||||
|
||||
### Task 4: Installed-system validation and publication
|
||||
|
||||
**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.
|
||||
|
||||
- [x] 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.
|
||||
- [x] 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.
|
||||
- [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.
|
||||
- [ ] Boot ISO under OVMF/KVM with fresh disk; install only there; write persistence sentinel.
|
||||
- [ ] Remove ISO and boot installeddisk; verify sentinel, services, QEMUtools, network and shell/recovery. Check no liveoverlay/autologinadminoverride remains.
|
||||
- [ ] Test nested guest VM if KVM nesting available; distinguish virtualgraphics from real laptopresults.
|
||||
- [ ] Run full test suite and independent review. Correct material defects; document remaining physicalchecks without claiming them passed.
|
||||
- [ ] 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`.
|
||||
- `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.
|
||||
- [x] 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.
|
||||
- [x] 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.
|
||||
- [x] Run complete suite, help, scratch QEMU lifecycle probe. Commit `feat: add independent VM manager`.
|
||||
- [ ] Write validation/QMP fake-server tests: invalid names/resources, missing ISO, paths containing spaces, greeting/capabilities, asynchronous events, IDs, timeouts.
|
||||
- [ ] Run `python3 -m unittest discover -s tests -v`; observe failure before implementation.
|
||||
- [ ] Implement validation and bounded QMP; subprocess argv arrays, named environment tool overrides for tests.
|
||||
- [ ] Add lifecycle tests: simultaneous start, existing-disk preservation, stale socket, failed boot, shutdown vs force, QMP VM identity.
|
||||
- [ ] 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.
|
||||
- [ ] Run complete suite, help, scratch QEMU lifecycle probe. Commit `feat: add independent VM manager`.
|
||||
|
||||
### Task 2: Minimal fullscreen session
|
||||
|
||||
@@ -63,25 +63,29 @@ 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.
|
||||
|
||||
- [x] 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.
|
||||
- [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.
|
||||
- [x] Run session tests and observe missing implementation failure.
|
||||
- [x] 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`.
|
||||
- [ ] Probe actual labwc version/overrideInhibition and remote-viewer kiosk USB behavior on disposable state; record versions.
|
||||
- [ ] Resolve manual USB dialog with a small upstream viewer adaptation if necessary; never auto-redirect USB.
|
||||
- [ ] Write tests for valid escaped XML, exact window/workspace/fullscreen rules, no default keyboard/mouse bindings, overrideInhibition on host keys, missing viewer/display errors.
|
||||
- [ ] Run session tests and observe missing implementation failure.
|
||||
- [ ] Implement session/viewer commands and pinned temporary tool environment; no host installation.
|
||||
- [ ] Run full tests and labwc config parsing. Commit `feat: add fullscreen HydraOS session`.
|
||||
|
||||
### Task 3: Integration and recovery
|
||||
|
||||
**Files:** Create `dev/smoke.py`, `docs/verification.md`; update README with verified commands.
|
||||
**Files:** Create `dev/smoke.py`, `docs/verification.md`; update README with verified commands. Amend runtime/profile/CLI/session tests and dependency patches for integration defects and the installed-guest workflow below.
|
||||
|
||||
**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.
|
||||
- [x] 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.
|
||||
- [x] 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.
|
||||
- [x] Commit evidence and push reviewed prototype branch to supplied repository.
|
||||
- [ ] Run two disposable KVM instances/viewers, never existing lab disks; verify private QMP and independent lifecycle.
|
||||
- [ ] Verify viewer/session exit preserves QEMU and reattach works; switching neither restarts nor reconnects guests.
|
||||
- [ ] Check input only in disposable session, without injecting into current host or user's guests; add regression tests for discovered defects.
|
||||
- [ ] Resolve Task 2's observed USB shortcut dispatch failure; demonstrate manual selector opening without automatic USB redirection on the private display. Check reserved switching/recovery actions with a standard keymap and keyboard inhibition.
|
||||
- [ ] Complete installed-guest boot workflow: optional installation media, explicit attach/detach while stopped, disk boot without the original ISO, and status/stop usable even if previously configured ISO disappears. Preserve exclusive disk creation and verified QMP control.
|
||||
- [ ] Add validated display dimensions/optional refresh and distribution defaults under `/etc/hydra/defaults.toml`. Defaults affect newly created profiles only; explicit CLI values win. Developer default remains user networking and stock-QEMU-compatible refresh; installed Hydra uses hydrabr0 and ASUS 1920x1080/144000 mHz preference.
|
||||
- [ ] Port the existing tested `qemu-virtio-refresh-rate.patch` from the old lab as a tracked dependency patch; validate the generated EDID and requested mode. Distinguish advertised refresh from achieved frame rate and physical display verification.
|
||||
- [ ] Record held-key, physical brightness and 144Hz checks needing dedicated TTY. Nested tests cannot prove them.
|
||||
- [ ] Run full suite and independent review; fix material findings with reproducing tests.
|
||||
- [ ] Commit evidence and push reviewed prototype branch to supplied repository.
|
||||
|
||||
## 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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
+11
-32
@@ -1,24 +1,5 @@
|
||||
# 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
|
||||
@@ -76,24 +57,22 @@ 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.
|
||||
A session with no profiles opens recovery with first-use guidance. Enter `:shell`
|
||||
to run `hydra add NAME --iso PATH` and `hydra start NAME`; leave the shell and enter
|
||||
`:refresh`. After adding or removing profiles, refresh reloads the profile list,
|
||||
rebuilds workspace rules, restarts only the session's own compositor, and attaches
|
||||
new isolated viewers to running guests. QEMU processes and workloads survive.
|
||||
The recovery prompt can then reconnect the new names; reserved Left/Right chords
|
||||
switch between their workspaces. `:quit` closes the recovery prompt.
|
||||
|
||||
`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.
|
||||
Actual labwc and native Wayland viewers then demonstrate fresh keyboard focus
|
||||
transitions in both directions, hidden workload continuity, unchanged QEMU
|
||||
PIDs/UUIDs through refresh, and guest survival after normal compositor exit.
|
||||
This checks the recovery command flow, not physical terminal rendering/usability.
|
||||
It leaves snapshots, recovery output, protocol logs and `result.json` in
|
||||
`/tmp/hydra-session-refresh-*` and stops only its verified scratch QEMU processes.
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
"""Hydra OS independent VM management."""
|
||||
"""HydraOS independent VM management."""
|
||||
|
||||
-585
@@ -1,585 +0,0 @@
|
||||
"""Verified, resumable Gentoo builds in a dedicated rootless Podman store."""
|
||||
import argparse
|
||||
import contextlib
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import lzma
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
class BuildError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def package_job_script(command, state='/var/lib/hydra-build', package_db='/var/db/pkg', inputs=''):
|
||||
"""The contained job owns its log/result even if the outer caller disconnects."""
|
||||
return f'''import fcntl, json, os, pathlib, subprocess, sys, time
|
||||
state = pathlib.Path({str(state)!r})
|
||||
database = pathlib.Path({str(package_db)!r})
|
||||
state.mkdir(parents=True, exist_ok=True)
|
||||
lock = (state / 'packages.lock').open('a')
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
sys.exit(75)
|
||||
def write(name, data):
|
||||
temporary = state / (name + '.tmp')
|
||||
temporary.write_text(json.dumps(data, indent=2) + '\\n')
|
||||
temporary.replace(state / name)
|
||||
status = {{'running': True, 'pid': os.getpid(), 'inputs': {inputs!r}, 'started': int(time.time())}}
|
||||
write('packages.status.json', status)
|
||||
with (state / 'packages.log').open('a') as log:
|
||||
try:
|
||||
result = subprocess.run({command!r}, stdout=log, stderr=subprocess.STDOUT)
|
||||
code = result.returncode
|
||||
except OSError as error:
|
||||
log.write(str(error) + '\\n')
|
||||
code = 127
|
||||
if code == 0:
|
||||
inventory = []
|
||||
for use in sorted(database.glob('*/*/USE')):
|
||||
package = use.parent
|
||||
inventory.append({{'package': str(package.relative_to(database)), 'use': use.read_text().split(),
|
||||
'build_time': (package / 'BUILD_TIME').read_text().strip() if (package / 'BUILD_TIME').exists() else None}})
|
||||
write('packages.json', inventory)
|
||||
status.update(running=False, exit_code=code, finished=int(time.time()))
|
||||
write('packages.status.json', status)
|
||||
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):
|
||||
digest = hashlib.sha256()
|
||||
with Path(path).open('rb') as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
|
||||
digest.update(chunk)
|
||||
if digest.hexdigest() != expected:
|
||||
raise BuildError(f'SHA256 mismatch: {path}')
|
||||
|
||||
|
||||
def require_signature(status, fingerprint):
|
||||
valid = [line.split() for line in status.splitlines() if line.startswith('[GNUPG:] VALIDSIG ')]
|
||||
if not valid or not any(fields[-1].upper() == fingerprint.upper() for fields in valid):
|
||||
raise BuildError('Required primary signature fingerprint is missing')
|
||||
if any(token in status for token in ('BADSIG', 'ERRSIG', 'REVKEYSIG', 'EXPKEYSIG')):
|
||||
raise BuildError('Invalid release signature')
|
||||
|
||||
|
||||
class Builder:
|
||||
def __init__(self, project, work, cache, podman=None, gpg=None, source_dir=None):
|
||||
if os.geteuid() == 0:
|
||||
raise BuildError('Run as the normal user, never host root')
|
||||
self.project = Path(project).resolve()
|
||||
self.source_dir = Path(source_dir or project).resolve()
|
||||
self.work, self.cache = Path(work).resolve(), Path(cache).resolve()
|
||||
for path in (self.work, self.cache):
|
||||
if path == self.project or not path.is_relative_to(self.project):
|
||||
raise BuildError('Work and cache paths must be inside the project, below its root')
|
||||
if self.work == self.cache or self.work.is_relative_to(self.cache) or self.cache.is_relative_to(self.work):
|
||||
raise BuildError('Work and cache must be separate directories')
|
||||
self.podman = podman or shutil.which('podman') or 'podman'
|
||||
self.gpg = gpg or shutil.which('gpg') or 'gpg'
|
||||
runtime = Path(os.environ.get('XDG_RUNTIME_DIR', f'/run/user/{os.getuid()}'))
|
||||
identifier = hashlib.sha256(str(self.work).encode()).hexdigest()[:12]
|
||||
self.runroot = runtime / ('hydra-build-podman' if self.work == self.project / 'build' else f'hydra-build-{identifier}')
|
||||
self.name = 'hydra-builder-' + identifier
|
||||
self.image = 'localhost/hydra-stage3:20261011'
|
||||
self._stage3_layers = {}
|
||||
|
||||
def podman_argv(self, *args):
|
||||
return [self.podman, '--root', str(self.work / 'podman'), '--runroot', str(self.runroot), '--storage-driver', 'overlay', *args]
|
||||
|
||||
def container_run_args(self):
|
||||
return ['run', '--detach', '--name', self.name, '--hostname', 'hydra-builder',
|
||||
'--network', 'host', '--cap-add', 'SYS_ADMIN', '--security-opt', 'seccomp=unconfined',
|
||||
'--volume', f'{self.source_dir}:/hydra-source:ro', '--volume', f'{self.cache}:/hydra-cache:ro',
|
||||
self.image, '/bin/sleep', 'infinity']
|
||||
|
||||
def run(self, argv, capture=False, check=True):
|
||||
print('+ ' + ' '.join(str(arg) for arg in argv), file=sys.stderr, flush=True)
|
||||
result = subprocess.run(argv, text=True, stdout=subprocess.PIPE if capture else None,
|
||||
stderr=subprocess.PIPE if capture else None)
|
||||
if check and result.returncode:
|
||||
raise BuildError(f'Command failed ({result.returncode}): {argv[0]}\n{result.stderr or ""}')
|
||||
return result
|
||||
|
||||
def pod(self, *args, **kwargs):
|
||||
return self.run(self.podman_argv(*args), **kwargs)
|
||||
|
||||
def exec(self, command, capture=False):
|
||||
return self.pod('exec', self.name, '/bin/bash', '-euc', command, capture=capture)
|
||||
|
||||
def inspect(self, kind, name):
|
||||
result = self.pod(kind, 'inspect', name, capture=True, check=False)
|
||||
if result.returncode:
|
||||
raise BuildError(f'{kind} provenance is unavailable; use a fresh work directory')
|
||||
entries = json.loads(result.stdout)
|
||||
if not isinstance(entries, list) or len(entries) != 1 or not isinstance(entries[0], dict):
|
||||
raise BuildError(f'Unexpected {kind} provenance')
|
||||
return entries[0]
|
||||
|
||||
def check_mounts(self, container):
|
||||
for destination, path, label in (('/hydra-source', self.source_dir, 'source'),
|
||||
('/hydra-cache', self.cache, 'cache')):
|
||||
mounts = [item for item in container.get('Mounts', []) if item.get('Destination') == destination]
|
||||
if len(mounts) != 1 or mounts[0].get('Source') != str(path) or mounts[0].get('RW') is not False:
|
||||
raise BuildError(f'Existing builder {label} mount differs; use its original checkout/cache or a fresh work directory')
|
||||
|
||||
def stage3_layer(self, inputs):
|
||||
stage = inputs['stage3']
|
||||
archive = self.cache / Path(urllib.parse.urlparse(stage['url']).path).name
|
||||
verify_hash(archive, stage['sha256'])
|
||||
if stage['sha256'] not in self._stage3_layers:
|
||||
digest = hashlib.sha256()
|
||||
with lzma.open(archive, 'rb') as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
|
||||
digest.update(chunk)
|
||||
self._stage3_layers[stage['sha256']] = 'sha256:' + digest.hexdigest()
|
||||
return self._stage3_layers[stage['sha256']]
|
||||
|
||||
def check_base(self, image, inputs):
|
||||
expected = self.stage3_layer(inputs)
|
||||
if image.get('RootFS', {}).get('Layers') != [expected]:
|
||||
raise BuildError('Existing builder stage3 differs from pinned archive; use a fresh work directory')
|
||||
return expected
|
||||
|
||||
def read_container_file(self, path):
|
||||
# Podman cp also reads a stopped container, so provenance precedes start.
|
||||
with tempfile.TemporaryDirectory(prefix='hydra-read-') as directory:
|
||||
target = Path(directory) / 'data'
|
||||
result = self.pod('cp', f'{self.name}:{path}', str(target), capture=True, check=False)
|
||||
return target.read_text() if result.returncode == 0 else None
|
||||
|
||||
def check_snapshot(self, inputs, allow_pending=False):
|
||||
marker = self.read_container_file('/var/db/repos/gentoo/.hydra-snapshot')
|
||||
if marker is None and allow_pending:
|
||||
marker = self.read_container_file('/var/db/repos/gentoo/.hydra-snapshot-input')
|
||||
if marker is None or marker.strip() != inputs['portage']['commit']:
|
||||
raise BuildError('Existing builder snapshot differs or is unverified; use a fresh work directory')
|
||||
|
||||
def active_package_job(self):
|
||||
if not (self.work / 'podman').is_dir():
|
||||
return False
|
||||
result = self.pod('top', self.name, 'hpid', 'args', capture=True, check=False)
|
||||
if result.returncode == 0 and any(re.search(r'(?:^|\s)(?:\S*/)?emerge(?:\s|$)', line)
|
||||
for line in result.stdout.splitlines()[1:]):
|
||||
return True
|
||||
probe = self.pod('exec', self.name, 'python3', '-c', '''import fcntl, pathlib, sys
|
||||
path = pathlib.Path('/var/lib/hydra-build/packages.lock')
|
||||
if path.exists():
|
||||
with path.open('r') as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_SH | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
sys.exit(75)
|
||||
''', capture=True, check=False)
|
||||
return probe.returncode == 75
|
||||
|
||||
def manifest(self):
|
||||
path = self.work / 'manifest.json'
|
||||
return json.loads(path.read_text()) if path.exists() else {}
|
||||
|
||||
def input_id(self):
|
||||
digest = hashlib.sha256()
|
||||
for directory in ('image', 'patches'):
|
||||
for path in sorted((self.source_dir / directory).rglob('*')):
|
||||
if path.is_file():
|
||||
digest.update(str(path.relative_to(self.source_dir)).encode())
|
||||
digest.update(path.read_bytes())
|
||||
return digest.hexdigest()
|
||||
|
||||
def record(self, step, **details):
|
||||
data = self.manifest()
|
||||
data[step] = {'complete': True, 'inputs': self.input_id(), **details}
|
||||
data['builder'] = self.name
|
||||
path = self.work / 'manifest.json'
|
||||
temp = path.with_suffix('.tmp')
|
||||
temp.write_text(json.dumps(data, indent=2) + '\n')
|
||||
temp.replace(path)
|
||||
|
||||
def complete(self, step):
|
||||
entry = self.manifest().get(step, {})
|
||||
if not entry.get('complete') or entry.get('inputs') != self.input_id():
|
||||
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':
|
||||
try:
|
||||
self.package_inventory()
|
||||
except (BuildError, OSError):
|
||||
return False
|
||||
return self.complete('prepare')
|
||||
if step == 'prepare':
|
||||
try:
|
||||
container = self.inspect('container', self.name)
|
||||
self.check_mounts(container)
|
||||
inputs = json.loads((self.source_dir / 'image/sources.json').read_text())
|
||||
self.check_base(self.inspect('image', container['Image']), inputs)
|
||||
self.check_snapshot(inputs)
|
||||
return True
|
||||
except (BuildError, OSError, ValueError, KeyError, lzma.LZMAError):
|
||||
return False
|
||||
if step == 'fetch':
|
||||
inputs = json.loads((self.source_dir / 'image/sources.json').read_text())
|
||||
try:
|
||||
for item in [inputs['stage3'], inputs['portage'], *inputs.get('local_packages', [])]:
|
||||
path = self.cache / item.get('filename', Path(urllib.parse.urlparse(item['url']).path).name)
|
||||
verify_hash(path, item['sha256'])
|
||||
except (OSError, BuildError):
|
||||
return False
|
||||
return True
|
||||
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):
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.scheme != 'https':
|
||||
raise BuildError('Build input URLs must use HTTPS')
|
||||
target = self.cache / (filename or Path(parsed.path).name)
|
||||
if not target.exists():
|
||||
temp = target.with_suffix(target.suffix + '.partial')
|
||||
print(f'Downloading {url}', flush=True)
|
||||
with urllib.request.urlopen(url, timeout=120) as response, temp.open('wb') as stream:
|
||||
shutil.copyfileobj(response, stream)
|
||||
temp.replace(target)
|
||||
return target
|
||||
|
||||
def fetch(self):
|
||||
inputs = json.loads((self.source_dir / 'image/sources.json').read_text())
|
||||
stage = inputs['stage3']
|
||||
archive = self.download(stage['url'])
|
||||
verify_hash(archive, stage['sha256'])
|
||||
snapshot = self.download(inputs['portage']['url'], inputs['portage']['filename'])
|
||||
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'])
|
||||
keyring = self.download(inputs['keyring_url'])
|
||||
verify_hash(keyring, inputs['keyring_sha256'])
|
||||
with tempfile.TemporaryDirectory(prefix='hydra-gpg-', dir=self.work) as home:
|
||||
result = self.run([self.gpg, '--batch', '--homedir', home, '--no-default-keyring', '--keyring', str(keyring),
|
||||
'--status-fd', '1', '--verify', str(signature), str(archive)], capture=True)
|
||||
require_signature(result.stdout, stage['fingerprint'])
|
||||
(self.work / 'artifacts/stage3-signature.txt').write_text(result.stdout + result.stderr)
|
||||
self.record('fetch', sources=inputs)
|
||||
return inputs
|
||||
|
||||
def prepare(self):
|
||||
existing = self.pod('container', 'exists', self.name, capture=True, check=False).returncode == 0
|
||||
container = self.inspect('container', self.name) if existing else None
|
||||
if container is not None:
|
||||
self.check_mounts(container) # Reject another worktree before changing any checkpoint.
|
||||
if self.active_package_job():
|
||||
raise BuildError('A contained package job is running; wait for it to finish before preparation')
|
||||
inputs = self.fetch() # Reverify cached bytes even when resuming.
|
||||
if container is not None:
|
||||
layer = self.check_base(self.inspect('image', container['Image']), inputs)
|
||||
self.check_snapshot(inputs, allow_pending=True)
|
||||
elif self.pod('image', 'exists', self.image, capture=True, check=False).returncode:
|
||||
self.pod('import', str(self.cache / Path(urllib.parse.urlparse(inputs['stage3']['url']).path).name), self.image)
|
||||
layer = self.check_base(self.inspect('image', self.image), inputs)
|
||||
else:
|
||||
layer = self.check_base(self.inspect('image', self.image), inputs)
|
||||
if container is None:
|
||||
self.pod(*self.container_run_args())
|
||||
else:
|
||||
self.pod('start', self.name)
|
||||
snapshot = inputs['portage']['filename']
|
||||
# Snapshot extraction happens only inside the container; host paths are read-only.
|
||||
self.exec(f'''mkdir -p /var/db/repos/gentoo
|
||||
if [ ! -f /var/db/repos/gentoo/.hydra-snapshot ]; then
|
||||
printf '%s\\n' '{inputs['portage']['commit']}' > /var/db/repos/gentoo/.hydra-snapshot-input
|
||||
tar --extract --gzip --file /hydra-cache/{snapshot} --strip-components=1 --directory /var/db/repos/gentoo
|
||||
printf '%s\\n' '{inputs['portage']['commit']}' > /var/db/repos/gentoo/.hydra-snapshot
|
||||
fi
|
||||
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
|
||||
mv /etc/portage/gnupg /etc/portage/gnupg.hydra-untrusted
|
||||
fi
|
||||
getuto
|
||||
gpg --batch --homedir /etc/portage/gnupg --import /hydra-cache/service-keys.gpg
|
||||
chmod a+r /etc/portage/gnupg/pubring.kbx /etc/portage/gnupg/trustdb.gpg
|
||||
''')
|
||||
self.record('prepare', image=container['Image'] if container else self.image,
|
||||
stage3_sha256=inputs['stage3']['sha256'], stage3_layer=layer,
|
||||
source_dir=str(self.source_dir), cache_dir=str(self.cache), snapshot=inputs['portage']['commit'])
|
||||
|
||||
def packages(self, pretend=False):
|
||||
self.prepare()
|
||||
atoms = [line.strip() for line in (self.source_dir / 'image/packages').read_text().splitlines()
|
||||
if line.strip() and not line.startswith('#')]
|
||||
import shlex
|
||||
command = ['emerge', '--verbose', '--update', '--newuse', '--deep', '--backtrack=100',
|
||||
'--usepkg-exclude=app-emulation/qemu app-emulation/virt-viewer']
|
||||
if pretend:
|
||||
self.exec(shlex.join([*command, '--pretend', *atoms]))
|
||||
return
|
||||
inputs = self.input_id()
|
||||
self.record('packages', complete=False, job_log='/var/lib/hydra-build/packages.log')
|
||||
patches = hashlib.sha256()
|
||||
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]))
|
||||
if self.input_id() != inputs:
|
||||
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
|
||||
from hydra import image
|
||||
try:
|
||||
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's default HFS+ hybrid duplicates HYDRA_OS on a second filesystem.
|
||||
# On USB, dracut can select that label instead of the ISO9660 live payload.
|
||||
grub-mkrescue --directory=/usr/lib/grub/x86_64-efi --output=/hydra-output/artifacts/hydra-os.iso "$tree" -- -volid HYDRA_OS -hfsplus off
|
||||
xorriso -indev /hydra-output/artifacts/hydra-os.iso -report_el_torito plain -report_system_area 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 re.search(r'\b(?:APM|HFSPLUS)\b', inspection):
|
||||
raise BuildError('ISO contains an HFS+/APM hybrid that can shadow the live volume on USB')
|
||||
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):
|
||||
data = self.manifest()
|
||||
for step in ('fetch', 'prepare', 'packages', 'rootfs', 'iso'):
|
||||
if step in data:
|
||||
data[step]['recorded_complete'] = data[step].get('complete', False)
|
||||
data[step]['complete'] = self.complete(step)
|
||||
data['mutation_lock_held'] = self.build_running()
|
||||
data['package_job_running'] = self.active_package_job()
|
||||
data['running'] = data['mutation_lock_held'] or data['package_job_running']
|
||||
print(json.dumps(data, indent=2))
|
||||
|
||||
def build_running(self):
|
||||
try:
|
||||
with (self.work / '.hydra-build.lock').open('r') as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_SH | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return False
|
||||
|
||||
@contextlib.contextmanager
|
||||
def locked(self):
|
||||
self.work.mkdir(parents=True, exist_ok=True)
|
||||
self.cache.mkdir(parents=True, exist_ok=True)
|
||||
(self.work / 'artifacts').mkdir(exist_ok=True)
|
||||
with (self.work / '.hydra-build.lock').open('a') as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError as exc:
|
||||
raise BuildError('Another Hydra build is running in this work directory') from exc
|
||||
yield
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
source = Path(__file__).resolve().parents[1]
|
||||
# Linked worktrees share project-owned caches/build state in the primary checkout.
|
||||
common = subprocess.run(['git', '-C', str(source), 'rev-parse', '--path-format=absolute', '--git-common-dir'],
|
||||
text=True, capture_output=True)
|
||||
project = Path(common.stdout.strip()).parent if common.returncode == 0 else source
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('step', choices=('fetch', 'prepare', 'packages', 'rootfs', 'iso', 'status'))
|
||||
parser.add_argument('--work', type=Path, default=project / 'build')
|
||||
parser.add_argument('--cache', type=Path, default=project / 'cache')
|
||||
parser.add_argument('--podman')
|
||||
parser.add_argument('--gpg')
|
||||
parser.add_argument('--pretend', action='store_true', help='resolve packages without installing')
|
||||
options = parser.parse_args(argv)
|
||||
try:
|
||||
builder = Builder(project, options.work, options.cache, options.podman, options.gpg, source)
|
||||
if options.step == 'status':
|
||||
builder.status()
|
||||
return 0
|
||||
with builder.locked():
|
||||
if options.step == 'packages':
|
||||
builder.packages(options.pretend)
|
||||
else:
|
||||
getattr(builder, options.step)()
|
||||
return 0
|
||||
except (BuildError, OSError, ValueError, lzma.LZMAError) as exc:
|
||||
print(f'hydra-build: {exc}', file=sys.stderr)
|
||||
return 1
|
||||
+5
-76
@@ -13,34 +13,13 @@ from .session import launch_session, session_helper, view
|
||||
|
||||
|
||||
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('--state', type=Path, default=Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local/state')) / 'hydra')
|
||||
cli.add_argument('--runtime', type=Path, default=Path(os.environ.get('XDG_RUNTIME_DIR', f'/tmp/hydra-{os.getuid()}')) / 'hydra')
|
||||
cli.add_argument('--defaults', type=Path, default=Path('/etc/hydra/defaults.toml'),
|
||||
help='Distribution preferences for newly created profiles')
|
||||
commands = cli.add_subparsers(dest='command')
|
||||
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')
|
||||
commands = cli.add_subparsers(dest='command', required=True)
|
||||
add = commands.add_parser('add', help='Create a VM profile without starting it')
|
||||
add.add_argument('name')
|
||||
add.add_argument('--iso', type=Path)
|
||||
@@ -69,7 +48,7 @@ def parser() -> argparse.ArgumentParser:
|
||||
viewer = commands.add_parser('view', help='Connect a viewer without changing VM power')
|
||||
viewer.add_argument('name')
|
||||
viewer.add_argument('--windowed', action='store_true')
|
||||
session = commands.add_parser('session', help='Start the minimal Hydra OS labwc session')
|
||||
session = commands.add_parser('session', help='Start the minimal HydraOS labwc session')
|
||||
session.add_argument('--nested', action='store_true')
|
||||
helper = commands.add_parser('_session-helper', help=argparse.SUPPRESS)
|
||||
helper.add_argument('operation')
|
||||
@@ -77,57 +56,10 @@ def parser() -> argparse.ArgumentParser:
|
||||
return cli
|
||||
|
||||
|
||||
def _download_progress():
|
||||
last = None
|
||||
interactive = sys.stderr.isatty()
|
||||
def report(received, total):
|
||||
nonlocal last
|
||||
step = received * 100 // total if total else received // (64 * 1024 * 1024)
|
||||
if last == step:
|
||||
return
|
||||
last = step
|
||||
if not interactive and received and (total is None or received != total):
|
||||
return
|
||||
suffix = f' / {total // (1024 * 1024)} MiB ({step}%)' if total else ' MiB'
|
||||
print(f'Download: {received // (1024 * 1024)}{suffix}', file=sys.stderr)
|
||||
return report
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
cli = parser()
|
||||
args = cli.parse_args(argv)
|
||||
args = parser().parse_args(argv)
|
||||
runtime = Runtime(args.config, args.state, args.runtime)
|
||||
try:
|
||||
if args.command == 'doctor':
|
||||
from .doctor import doctor_main
|
||||
return doctor_main(['--json'] if args.json else [])
|
||||
if args.command == 'update':
|
||||
checkout = Path(__file__).resolve().parents[1]
|
||||
if checkout == Path('/usr/lib/hydra'):
|
||||
command = ['/usr/bin/hydra-update']
|
||||
else:
|
||||
command = [sys.executable, '-I', str(checkout / 'bin/hydra-update')]
|
||||
return subprocess.run(command + args.update_args, check=False).returncode
|
||||
runtime = Runtime(args.config, args.state, args.runtime)
|
||||
if args.command is None and not sys.stdin.isatty():
|
||||
cli.print_help()
|
||||
return 0
|
||||
if args.command in (None, 'menu'):
|
||||
from .launch import session_manifest
|
||||
from .menu import run_menu
|
||||
return run_menu(runtime, manifest=session_manifest(runtime), defaults=args.defaults)
|
||||
if args.command == 'download':
|
||||
from .media import download_iso
|
||||
print(download_iso(args.url, sha256=args.sha256, progress=_download_progress()))
|
||||
return 0
|
||||
if args.command == 'run':
|
||||
from .launch import run_source
|
||||
return run_source(runtime, args.source, name=args.name, memory=args.memory,
|
||||
cpus=args.cpus, disk_size=args.disk_size, sha256=args.sha256,
|
||||
no_view=args.no_view, windowed=args.windowed, defaults=args.defaults,
|
||||
progress=_download_progress())
|
||||
if args.command == 'open':
|
||||
from .launch import open_vm
|
||||
return open_vm(runtime, args.name, no_view=args.no_view, windowed=args.windowed)
|
||||
if args.command == 'view':
|
||||
return view(runtime, args.name, windowed=args.windowed)
|
||||
elif args.command == 'session':
|
||||
@@ -155,9 +87,6 @@ def main(argv: list[str] | None = None) -> int:
|
||||
result = getattr(runtime, args.command)(args.name)
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0
|
||||
except KeyboardInterrupt:
|
||||
print('hydra: Cancelled', file=sys.stderr)
|
||||
return 130
|
||||
except (RuntimeError, OSError, ValueError, subprocess.TimeoutExpired) as exc:
|
||||
print(f'hydra: {exc}', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
-188
@@ -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
@@ -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())
|
||||
@@ -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)
|
||||
@@ -1,956 +0,0 @@
|
||||
"""Explicit whole-disk and existing-partition installer. Importing this module never mutates storage.
|
||||
|
||||
Live payload contract: /etc/hydra/live-mode/active and remove-on-install.json
|
||||
must be regular files. The manifest is a JSON array of absolute paths matching
|
||||
LIVE_REMOVABLE. Store all other live-only configuration in live-mode itself;
|
||||
ordinary installed configuration must already be present in the payload.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
import getpass
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import stat
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import warnings
|
||||
import uuid
|
||||
|
||||
from typing import TextIO
|
||||
|
||||
from . import install_storage as storage
|
||||
|
||||
DEFAULT_PAYLOAD = '/run/initramfs/live/LiveOS/squashfs.img'
|
||||
LIVE_REMOVABLE = (
|
||||
'/etc/systemd/system/getty@*.service.d/90-hydra-live.conf',
|
||||
'/etc/systemd/system/serial-getty@*.service.d/90-hydra-live.conf',
|
||||
'/etc/systemd/system/greetd.service.d/90-hydra-live.conf',
|
||||
'/etc/sudoers.d/90-hydra-live',
|
||||
'/etc/greetd/live.toml',
|
||||
'/etc/NetworkManager/system-connections/90-hydra-live.nmconnection',
|
||||
)
|
||||
DEVICE = re.compile(r'/dev/[A-Za-z0-9_.+:/-]+\Z')
|
||||
KERNEL = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.+-]*\Z')
|
||||
KNOWN_TYPES = {'disk', 'part', 'loop', 'rom', 'crypt', 'lvm', 'mpath', 'dm',
|
||||
'raid0', 'raid1', 'raid4', 'raid5', 'raid6', 'raid10', 'md', 'zram'}
|
||||
|
||||
|
||||
class InstallError(RuntimeError):
|
||||
"""A failed safety check or operation; never suppress this in execution."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Request:
|
||||
disk: str | None
|
||||
user: str
|
||||
payload: str = DEFAULT_PAYLOAD
|
||||
execute: bool = False
|
||||
confirm_erase: str | None = None
|
||||
timezone: str = 'Europe/Moscow'
|
||||
kernel: str | None = None
|
||||
root_partition: str | None = None
|
||||
esp_partition: str | None = None
|
||||
confirm_format: str | None = None
|
||||
root_fs: str = 'ext4'
|
||||
register_boot_entry: bool = False
|
||||
|
||||
|
||||
class Runner:
|
||||
"""Checked argv-only commands. Secret-bearing commands redact all output."""
|
||||
def run(self, argv, *, input=None, sensitive=False):
|
||||
try:
|
||||
result = subprocess.run(list(argv), input=input, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
shell=False, check=False)
|
||||
except OSError as exc:
|
||||
raise InstallError(f'Cannot run {argv[0]}: {exc.strerror}') from exc
|
||||
if result.returncode:
|
||||
detail = '' if sensitive else ': ' + result.stderr.strip()[:2000]
|
||||
raise InstallError(f'Command failed ({result.returncode}): {shlex.join(argv)}{detail}')
|
||||
return '' if sensitive else result.stdout
|
||||
|
||||
|
||||
class Topology:
|
||||
def __init__(self):
|
||||
self.nodes = {}
|
||||
self.edges = {}
|
||||
self.parents = {}
|
||||
self.protected = set()
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, raw):
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise InstallError('Malformed lsblk JSON') from exc
|
||||
if not isinstance(data, dict) or not isinstance(data.get('blockdevices'), list) or not data['blockdevices']:
|
||||
raise InstallError('lsblk must return a nonempty blockdevices array')
|
||||
result = cls()
|
||||
parents = []
|
||||
|
||||
def visit(node, ancestor=None, trail=()):
|
||||
if not isinstance(node, dict):
|
||||
raise InstallError('Malformed lsblk node')
|
||||
path, kind = node.get('path'), node.get('type')
|
||||
if not isinstance(path, str) or not DEVICE.fullmatch(path) or '..' in PurePosixPath(path).parts:
|
||||
raise InstallError('Invalid lsblk device path')
|
||||
if path in trail or kind not in KNOWN_TYPES:
|
||||
raise InstallError('Cyclic or unknown lsblk topology')
|
||||
mounts = node.get('mountpoints')
|
||||
size, identity = node.get('size'), node.get('maj:min')
|
||||
if not isinstance(mounts, list) or any(m is not None and not isinstance(m, str) for m in mounts):
|
||||
raise InstallError('Malformed lsblk mountpoints')
|
||||
if isinstance(size, bool) or not isinstance(size, int) or size < 0:
|
||||
raise InstallError('Invalid lsblk size')
|
||||
empty_optical = (kind == 'rom' and not any(mounts)
|
||||
and not node.get('children') and node.get('pkname') is None
|
||||
and ancestor is None)
|
||||
if size == 0 and not empty_optical:
|
||||
raise InstallError('Zero-size device is not an unrelated empty optical drive')
|
||||
if not isinstance(identity, str) or not re.fullmatch(r'\d+:\d+', identity):
|
||||
raise InstallError('Invalid lsblk device identity')
|
||||
for key in ('partuuid', 'parttype', 'fstype', 'uuid'):
|
||||
if node.get(key) is not None and not isinstance(node[key], str):
|
||||
raise InstallError('Malformed lsblk storage metadata')
|
||||
if node.get('start') is not None and (isinstance(node['start'], bool) or not isinstance(node['start'], int)):
|
||||
raise InstallError('Malformed lsblk partition start')
|
||||
if path in result.nodes and any(result.nodes[path].get(key) != node.get(key) for key in
|
||||
('type', 'size', 'maj:min', 'mountpoints', 'start', 'partuuid', 'parttype', 'fstype', 'uuid')):
|
||||
raise InstallError('Inconsistent duplicate lsblk node')
|
||||
result.nodes[path] = node
|
||||
result.edges.setdefault(path, set())
|
||||
result.parents.setdefault(path, set())
|
||||
if any(m for m in mounts):
|
||||
result.protected.add(path)
|
||||
pkname = node.get('pkname')
|
||||
if pkname is not None:
|
||||
if not isinstance(pkname, str) or not pkname:
|
||||
raise InstallError('Invalid lsblk parent')
|
||||
parent = pkname if pkname.startswith('/dev/') else '/dev/' + pkname
|
||||
if not DEVICE.fullmatch(parent):
|
||||
raise InstallError('Invalid lsblk parent path')
|
||||
parents.append((path, parent))
|
||||
if ancestor:
|
||||
parents.append((path, ancestor))
|
||||
children = node.get('children', [])
|
||||
if not isinstance(children, list):
|
||||
raise InstallError('Malformed lsblk children')
|
||||
for child in children:
|
||||
visit(child, path, trail + (path,))
|
||||
|
||||
for node in data['blockdevices']:
|
||||
visit(node)
|
||||
identities = {}
|
||||
for path, node in result.nodes.items():
|
||||
identity = node['maj:min']
|
||||
if identity in identities and identities[identity] != path:
|
||||
raise InstallError('Conflicting block-device identities')
|
||||
identities[identity] = path
|
||||
directed = {path: set() for path in result.nodes}
|
||||
for child, parent in parents:
|
||||
result.link(child, parent)
|
||||
directed[child].add(parent)
|
||||
visiting, visited = set(), set()
|
||||
def check_cycle(path):
|
||||
if path in visiting:
|
||||
raise InstallError('Cyclic block-device parent references')
|
||||
if path in visited:
|
||||
return
|
||||
visiting.add(path)
|
||||
for parent in directed[path]:
|
||||
check_cycle(parent)
|
||||
visiting.remove(path)
|
||||
visited.add(path)
|
||||
for path in directed:
|
||||
check_cycle(path)
|
||||
for path, node in result.nodes.items():
|
||||
if node['size'] == 0 and result.edges[path]:
|
||||
raise InstallError('Empty optical drive has block-device dependencies')
|
||||
# An undirected child edge does not establish backing ancestry.
|
||||
# Each layer must identify its own parent, even when another
|
||||
# apparently unrelated disk is the requested installation target.
|
||||
if node['type'] not in {'disk', 'rom', 'loop', 'zram'} and not directed[path]:
|
||||
raise InstallError(f"Device has no known backing parent: {path} ({node['type']})")
|
||||
return result
|
||||
|
||||
def link(self, child, parent):
|
||||
if child == parent or child not in self.nodes or parent not in self.nodes:
|
||||
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[parent].add(child)
|
||||
|
||||
def protect(self, path):
|
||||
if path not in self.nodes:
|
||||
raise InstallError(f'Cannot resolve mounted/live source: {path}')
|
||||
if self.nodes[path]['size'] == 0:
|
||||
raise InstallError(f'Empty optical drive unexpectedly backs mounted/live storage: {path}')
|
||||
self.protected.add(path)
|
||||
|
||||
def component(self, path):
|
||||
seen, pending = set(), [path]
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
if current not in seen:
|
||||
seen.add(current)
|
||||
pending.extend(self.edges[current] - seen)
|
||||
return seen
|
||||
|
||||
|
||||
class SystemProbe:
|
||||
"""Read-only Linux topology probes, including loop backing files."""
|
||||
def __init__(self, runner=None):
|
||||
self.runner = runner or Runner()
|
||||
|
||||
def resolve(self, path):
|
||||
return os.path.realpath(path)
|
||||
|
||||
def physical_disk(self, path):
|
||||
try:
|
||||
info = os.stat(path)
|
||||
if not stat.S_ISBLK(info.st_mode):
|
||||
return False
|
||||
device = Path(f'/sys/dev/block/{os.major(info.st_rdev)}:{os.minor(info.st_rdev)}')
|
||||
if not device.exists() or (device / 'partition').exists():
|
||||
return False
|
||||
# Reject loop, device mapper, md, ram and other virtual targets.
|
||||
return '/virtual/' not in str(device.resolve()) and (device / 'device').exists()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def snapshot(self, payload):
|
||||
raw = self.runner.run(['lsblk', '--json', '--paths', '--bytes', '--output',
|
||||
'PATH,TYPE,PKNAME,MOUNTPOINTS,SIZE,MAJ:MIN,START,PARTUUID,PARTTYPE,FSTYPE,UUID'])
|
||||
# lsblk PATH can use /dev/mapper aliases while findmnt uses /dev/dm-N.
|
||||
try:
|
||||
decoded = json.loads(raw)
|
||||
def canonicalize(node):
|
||||
if not isinstance(node, dict):
|
||||
raise ValueError()
|
||||
if not isinstance(node.get('path'), str) or not DEVICE.fullmatch(node['path']) or '..' in PurePosixPath(node['path']).parts:
|
||||
raise ValueError()
|
||||
node['path'] = self.resolve(node['path'])
|
||||
if isinstance(node.get('pkname'), str):
|
||||
parent = node['pkname']
|
||||
node['pkname'] = self.resolve(parent if parent.startswith('/dev/') else '/dev/' + parent)
|
||||
children = node.get('children', [])
|
||||
if not isinstance(children, list):
|
||||
raise ValueError()
|
||||
for child in children:
|
||||
canonicalize(child)
|
||||
for node in decoded['blockdevices']:
|
||||
canonicalize(node)
|
||||
topo = Topology.from_json(json.dumps(decoded))
|
||||
except (ValueError, TypeError, KeyError) as exc:
|
||||
raise InstallError('Malformed lsblk JSON') from exc
|
||||
mounts = self._mounts(self.runner.run(['findmnt', '--json', '--output',
|
||||
'SOURCE,TARGET,FSTYPE,MAJ:MIN,OPTIONS']))
|
||||
for mount in mounts:
|
||||
source = self._block_source(mount, topo)
|
||||
if source:
|
||||
topo.protect(source)
|
||||
if not Path(payload).is_file():
|
||||
raise InstallError(f'Prepared squashfs payload is not a regular file: {payload}')
|
||||
source = self._source_for_path(self.resolve(payload), topo)
|
||||
if not source:
|
||||
# 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')
|
||||
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
|
||||
|
||||
@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
|
||||
def _mounts(raw):
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
roots = data['filesystems']
|
||||
if not isinstance(roots, list) or not roots:
|
||||
raise ValueError()
|
||||
result = []
|
||||
|
||||
def visit(item):
|
||||
if not isinstance(item, dict) or any(not isinstance(item.get(k), str) for k in ('source', 'target', 'fstype', 'maj:min')):
|
||||
raise ValueError()
|
||||
result.append(item)
|
||||
children = item.get('children', [])
|
||||
if not isinstance(children, list):
|
||||
raise ValueError()
|
||||
for child in children:
|
||||
visit(child)
|
||||
for root in roots:
|
||||
visit(root)
|
||||
return result
|
||||
except (ValueError, TypeError, KeyError) as exc:
|
||||
raise InstallError('Malformed findmnt topology') from exc
|
||||
|
||||
def _block_source(self, mount, topo):
|
||||
if mount['fstype'] == 'btrfs':
|
||||
return self._btrfs_source(mount, topo)
|
||||
if mount['fstype'] == 'zfs':
|
||||
raise InstallError('Cannot safely resolve all backing disks of mounted ' + mount['fstype'])
|
||||
source = mount['source'].split('[', 1)[0]
|
||||
identity = mount['maj:min']
|
||||
matches = [p for p, n in topo.nodes.items() if n['maj:min'] == identity]
|
||||
if source.startswith('/dev/'):
|
||||
path = self.resolve(source)
|
||||
if path in topo.nodes:
|
||||
if topo.nodes[path]['maj:min'] != identity:
|
||||
raise InstallError('Inconsistent mounted block identity')
|
||||
topo.protect(path)
|
||||
return path
|
||||
if not matches:
|
||||
raise InstallError(f'Cannot resolve mounted/live source: {source}')
|
||||
if matches:
|
||||
topo.protect(matches[0])
|
||||
return matches[0]
|
||||
if identity.split(':', 1)[0] != '0' and mount['fstype'] not in {'zfs', 'nfs', 'nfs4', 'cifs', '9p', 'virtiofs'}:
|
||||
raise InstallError(f'Unknown mounted block source: {source}')
|
||||
return None
|
||||
|
||||
def _btrfs_source(self, mount, topo):
|
||||
return self._btrfs_members(mount, topo)[0]
|
||||
|
||||
def _btrfs_members(self, mount, topo):
|
||||
if not Path(mount['target']).is_absolute() or any(ord(c) < 32 for c in mount['target']):
|
||||
raise InstallError('Invalid mounted btrfs path')
|
||||
raw = self.runner.run(['btrfs', 'filesystem', 'show', '--raw', mount['target']])
|
||||
counts = re.findall(r'Total devices (\d+) FS bytes used \d+', raw)
|
||||
rows = re.findall(r'^\s*devid\s+(\d+)\s+size\s+\d+\s+used\s+\d+\s+path\s+(/dev/[^\s]+)\s*$', raw, re.MULTILINE)
|
||||
if len(counts) != 1 or int(counts[0]) <= 0 or len(rows) != int(counts[0]) or len({row[0] for row in rows}) != len(rows):
|
||||
raise InstallError('Cannot safely resolve all backing members of mounted btrfs')
|
||||
paths = [self.resolve(row[1]) for row in rows]
|
||||
if len(set(paths)) != len(paths) or any(path not in topo.nodes for path in paths):
|
||||
raise InstallError('Unknown or duplicate mounted btrfs backing member')
|
||||
source = mount['source'].split('[', 1)[0]
|
||||
if source.startswith('/dev/') and self.resolve(source) in topo.nodes and self.resolve(source) not in paths:
|
||||
raise InstallError('Mounted btrfs source is absent from backing members')
|
||||
for path in paths:
|
||||
topo.protect(path)
|
||||
return paths
|
||||
|
||||
def verify_identity(self, path, node):
|
||||
try:
|
||||
info = os.stat(path)
|
||||
identity = f'{os.major(info.st_rdev)}:{os.minor(info.st_rdev)}'
|
||||
if not stat.S_ISBLK(info.st_mode) or identity != node['maj:min']:
|
||||
raise InstallError('Block device identity changed: ' + path)
|
||||
sysfs = Path('/sys/dev/block') / identity
|
||||
if int((sysfs / 'size').read_text()) * 512 != node['size']:
|
||||
raise InstallError('Block device capacity changed: ' + path)
|
||||
if list((sysfs / 'holders').iterdir()):
|
||||
raise InstallError('Selected device has active holders: ' + path)
|
||||
if node['type'] == 'part' and int((sysfs / 'start').read_text()) != node['start']:
|
||||
raise InstallError('Partition bounds changed: ' + path)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise InstallError('Cannot verify kernel block identity: ' + path) from exc
|
||||
|
||||
def partition_number(self, path, node):
|
||||
try:
|
||||
return int((Path('/sys/dev/block') / node['maj:min'] / 'partition').read_text())
|
||||
except (OSError, ValueError) as exc:
|
||||
raise InstallError('Cannot read kernel partition number: ' + path) from exc
|
||||
|
||||
def _source_for_path(self, path, topo):
|
||||
raw = self.runner.run(['findmnt', '--json', '--target', path, '--output',
|
||||
'SOURCE,TARGET,FSTYPE,MAJ:MIN,OPTIONS'])
|
||||
mounts = self._mounts(raw)
|
||||
if len(mounts) != 1:
|
||||
raise InstallError('Ambiguous backing filesystem')
|
||||
return self._block_source(mounts[0], topo)
|
||||
|
||||
|
||||
def validate_request(request, probe, *, return_plan=False):
|
||||
existing = request.root_partition is not None or request.esp_partition is not None
|
||||
if request.root_fs not in {'ext4', 'btrfs'}:
|
||||
raise InstallError('Root filesystem must be ext4 or btrfs')
|
||||
if existing:
|
||||
if request.disk is not None or request.confirm_erase is not None:
|
||||
raise InstallError('Choose exactly one installation mode; partitions cannot use --disk/--confirm-erase')
|
||||
elif not isinstance(request.disk, str) or not DEVICE.fullmatch(request.disk) or '..' in PurePosixPath(request.disk).parts:
|
||||
raise InstallError('Choose an explicit /dev whole-disk device')
|
||||
elif request.confirm_format is not None:
|
||||
raise InstallError('--confirm-format is only valid for existing partitions')
|
||||
if not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', request.user) or request.user in {'root', 'hydra-live', 'greetd'}:
|
||||
raise InstallError('Choose a valid nonreserved lowercase Unix username')
|
||||
if not re.fullmatch(r'[A-Za-z_+-]+(?:/[A-Za-z0-9_+-]+)+', request.timezone):
|
||||
raise InstallError('Invalid timezone; use an IANA name such as Europe/Moscow')
|
||||
if not Path(request.payload).is_absolute() or any(ord(c) < 32 for c in request.payload):
|
||||
raise InstallError('Payload must be an absolute path without control characters')
|
||||
if request.kernel is not None and not KERNEL.fullmatch(request.kernel):
|
||||
raise InstallError('Invalid kernel version')
|
||||
if existing:
|
||||
plan = storage.partition_plan(request, probe, probe.snapshot(request.payload))
|
||||
return plan if return_plan else plan.root
|
||||
target = probe.resolve(request.disk)
|
||||
if not DEVICE.fullmatch(target) or not probe.physical_disk(target):
|
||||
raise InstallError(f'Target is not a physical whole block disk: {target}')
|
||||
if request.execute and request.confirm_erase != target:
|
||||
raise InstallError(f'Execution requires --confirm-erase {target} exactly')
|
||||
topo = probe.snapshot(request.payload)
|
||||
if target not in topo.nodes or topo.nodes[target]['type'] != 'disk':
|
||||
raise InstallError('Target is not a known whole disk')
|
||||
if topo.nodes[target]['size'] < 4 * 1024 ** 3:
|
||||
raise InstallError('Target disk is smaller than the minimum 4 GiB')
|
||||
forbidden = topo.component(target) & topo.protected
|
||||
if forbidden:
|
||||
raise InstallError('Target backs mounted/root/live storage: ' + ', '.join(sorted(forbidden)))
|
||||
probe.verify_identity(target, topo.nodes[target])
|
||||
if return_plan:
|
||||
return storage.DiskPlan(target, topo.nodes[target]['size'],
|
||||
tuple(sorted((p, storage.fingerprint(topo.nodes[p])) for p in topo.component(target))))
|
||||
return target
|
||||
|
||||
|
||||
def partitions(target):
|
||||
suffix = 'p' if target[-1].isdigit() else ''
|
||||
return target + suffix + '1', target + suffix + '2'
|
||||
|
||||
|
||||
def partition_commands(target, root_fs='ext4'):
|
||||
esp, root = partitions(target)
|
||||
return [
|
||||
['parted', '--script', target, 'mklabel', 'gpt'],
|
||||
['parted', '--script', target, 'unit', 'MiB', 'mkpart', 'ESP', 'fat32', '1MiB', '1025MiB'],
|
||||
['parted', '--script', target, 'set', '1', 'esp', 'on'],
|
||||
['parted', '--script', target, 'unit', 'MiB', 'mkpart', 'root', 'ext4', '1025MiB', '100%'],
|
||||
['partprobe', target], ['udevadm', 'settle'],
|
||||
['mkfs.vfat', '-F', '32', '-n', 'HYDRA_EFI', esp],
|
||||
storage.format_root(root, root_fs),
|
||||
]
|
||||
|
||||
|
||||
def grub_command(root, preserve=False):
|
||||
return ['chroot', str(root), 'grub-install', '--target=x86_64-efi',
|
||||
'--efi-directory=/boot/efi', '--bootloader-id=HydraOS',
|
||||
*([] if preserve else ['--removable']), '--no-nvram']
|
||||
|
||||
|
||||
def render_plan(request, target, root):
|
||||
existing = request.root_partition is not None
|
||||
esp, part = (request.esp_partition, target) if existing else partitions(target)
|
||||
commands = [['unsquashfs', '-s', request.payload], ['unsquashfs', '-lln', request.payload]]
|
||||
if existing:
|
||||
temporary = str(Path(root).parent / 'esp-preflight-<random>')
|
||||
commands.extend([['mount', '-t', 'vfat', '-o', 'ro,nosuid,nodev,noexec', esp, temporary],
|
||||
['umount', temporary]])
|
||||
commands.extend([storage.format_root(part, request.root_fs)] if existing else partition_commands(target, request.root_fs))
|
||||
if request.root_fs == 'btrfs':
|
||||
top = str(Path(root).parent / 'btrfs-top')
|
||||
commands.append(['mount', '-t', 'btrfs', '-o', 'subvolid=5', part, top])
|
||||
commands.extend(['btrfs', 'subvolume', 'create', str(Path(top) / name)]
|
||||
for name in ('@root', '@home', '@snapshots'))
|
||||
commands.extend([['umount', top], ['mount', '-t', 'btrfs', '-o', 'subvol=@root', part, str(root)],
|
||||
['mount', '-t', 'btrfs', '-o', 'subvol=@home', part, str(Path(root) / 'home')],
|
||||
['mount', '-t', 'btrfs', '-o', 'subvol=@snapshots', part, str(Path(root) / '.snapshots')]])
|
||||
else:
|
||||
commands.append(['mount', '-t', 'ext4', part, str(root)])
|
||||
commands += [
|
||||
['unsquashfs', '-f', '-d', str(root), request.payload],
|
||||
['mount', '-t', 'vfat', esp, str(Path(root) / 'boot/efi')],
|
||||
['blkid', '-s', 'UUID', '-o', 'value', part],
|
||||
['blkid', '-s', 'UUID', '-o', 'value', esp],
|
||||
['mount', '--rbind', '/dev', str(Path(root) / 'dev')],
|
||||
['mount', '--make-rslave', str(Path(root) / 'dev')],
|
||||
['mount', '-t', 'proc', 'proc', str(Path(root) / 'proc')],
|
||||
['mount', '--rbind', '/sys', str(Path(root) / 'sys')],
|
||||
['mount', '--make-rslave', str(Path(root) / 'sys')],
|
||||
['chroot', str(root), 'locale-gen'],
|
||||
['chroot', str(root), 'useradd', '--create-home', '--groups', 'wheel,kvm,video,render,audio', '--shell', '/bin/bash', request.user],
|
||||
['chroot', str(root), 'chpasswd'],
|
||||
['chroot', str(root), 'passwd', '--lock', 'root'],
|
||||
['chroot', str(root), 'systemd-machine-id-setup'],
|
||||
['chroot', str(root), 'dracut', '--force', '--no-hostonly', '--omit', 'dmsquash-live',
|
||||
'/boot/initramfs-<kernel>.img', '<kernel>'],
|
||||
grub_command(root, preserve=True),
|
||||
*([] 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}.',
|
||||
f'GPT: exactly 1 GiB FAT32 ESP, remaining space {request.root_fs} root.',
|
||||
f'Prepared payload: {request.payload}; account: {request.user}; timezone: {request.timezone}.',
|
||||
'Commands (UUIDs/kernel discovered after unpacking; no commands run in dry-run):']
|
||||
if existing:
|
||||
lines[:2] = [f'Hydra OS {"EXECUTION" if request.execute else "dry-run"}: format ONLY {target} as {request.root_fs}.',
|
||||
f'Preserve GPT, existing ESP {esp}, foreign EFI loaders, fallback loader and BootOrder.',
|
||||
'First inspect the ESP read-only for collision/free space and hash foreign files.']
|
||||
lines.extend(' ' + shlex.join(c) for c in commands)
|
||||
lines.extend(['Write UUID fstab, en_US.UTF-8 and ru_RU.UTF-8, timezone, installed dracut/GRUB defaults.',
|
||||
'Remove allowlisted live-mode files, hydra-live account and /home/hydra-live; reset machine-id.',
|
||||
'Ensure wheel,kvm,video,render,audio groups exist before creating the installed account.',
|
||||
'chpasswd receives the TTY-entered password on private stdin; root remains locked.',
|
||||
'Unmount tracked sys/proc/dev/ESP/root in reverse order on success or failure.',
|
||||
f'To execute, add --execute --confirm-erase {target} (as root; no automatic sudo).'])
|
||||
if existing:
|
||||
lines[-1] = f'To execute, add --execute --confirm-format {target} (as root; no automatic sudo).'
|
||||
lines.append('Default: select EFI/HydraOS/grubx64.efi in firmware manually; no firmware changes.')
|
||||
if request.root_fs == 'btrfs':
|
||||
lines.append('Btrfs: @root contains /boot and Portage; @home and @snapshots are separate subvolumes.')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def target_path(root, relative, *, symlinks=False):
|
||||
"""Resolve a path inside a target, interpreting absolute symlinks as chroot paths."""
|
||||
root = Path(root).resolve()
|
||||
parts = list(PurePosixPath(relative).parts)
|
||||
if parts and parts[0] == '/':
|
||||
parts.pop(0)
|
||||
resolved, hops = [], 0
|
||||
while parts:
|
||||
part = parts.pop(0)
|
||||
if part in ('', '.'):
|
||||
continue
|
||||
if part == '..':
|
||||
if not resolved:
|
||||
raise InstallError('Target path escapes root')
|
||||
resolved.pop()
|
||||
continue
|
||||
candidate = root.joinpath(*resolved, part)
|
||||
if candidate.is_symlink():
|
||||
if not symlinks:
|
||||
raise InstallError(f'Refusing symlink traversal in target: {relative}')
|
||||
hops += 1
|
||||
if hops > 40:
|
||||
raise InstallError('Target symlink cycle')
|
||||
link = PurePosixPath(os.readlink(candidate))
|
||||
extra = list(link.parts)
|
||||
if link.is_absolute():
|
||||
resolved = []
|
||||
extra.pop(0)
|
||||
parts = extra + parts
|
||||
else:
|
||||
resolved.append(part)
|
||||
return root.joinpath(*resolved)
|
||||
|
||||
|
||||
def remove_live_files(root):
|
||||
directory = target_path(root, 'etc/hydra/live-mode')
|
||||
if not directory.is_dir():
|
||||
raise InstallError('Payload missing /etc/hydra/live-mode contract')
|
||||
manifest = target_path(root, 'etc/hydra/live-mode/remove-on-install.json')
|
||||
marker = target_path(root, 'etc/hydra/live-mode/active')
|
||||
if not manifest.is_file() or not marker.is_file():
|
||||
raise InstallError('Payload live-mode marker/manifest missing')
|
||||
try:
|
||||
paths = json.loads(manifest.read_text())
|
||||
except (OSError, ValueError) as exc:
|
||||
raise InstallError('Invalid live-mode removal manifest') from exc
|
||||
if not isinstance(paths, list):
|
||||
raise InstallError('Live removal manifest must be a JSON array')
|
||||
removals = []
|
||||
for path in paths:
|
||||
if not isinstance(path, str) or not path.startswith('/') or '..' in PurePosixPath(path).parts or str(PurePosixPath(path)) != path:
|
||||
raise InstallError('Invalid path in live removal manifest')
|
||||
if not any(re.fullmatch(re.escape(pattern).replace(r'\*', r'[A-Za-z0-9_.-]+'), path) for pattern in LIVE_REMOVABLE):
|
||||
raise InstallError(f'Live manifest path is not allowlisted: {path}')
|
||||
leaf = PurePosixPath(path)
|
||||
parent = target_path(root, str(leaf.parent))
|
||||
candidate = parent / leaf.name
|
||||
if candidate.exists() and not candidate.is_file() and not candidate.is_symlink():
|
||||
raise InstallError('Live manifest may only remove files/symlinks')
|
||||
removals.append(candidate)
|
||||
for path in removals:
|
||||
path.unlink(missing_ok=True)
|
||||
# shutil.rmtree never follows directory symlinks inside this directory.
|
||||
shutil.rmtree(directory)
|
||||
|
||||
|
||||
def remove_live_account(root, run):
|
||||
"""Remove the payload's fixed local live identity, before binding live /proc."""
|
||||
passwd = target_path(root, 'etc/passwd')
|
||||
try:
|
||||
rows = [row.split(':') for row in passwd.read_text().splitlines() if row.split(':', 1)[0] == 'hydra-live']
|
||||
except OSError as exc:
|
||||
raise InstallError('Payload missing readable /etc/passwd') from exc
|
||||
if len(rows) > 1:
|
||||
raise InstallError('Duplicate live account in payload')
|
||||
if rows:
|
||||
row = rows[0]
|
||||
if len(row) != 7 or not row[2].isdigit() or int(row[2]) < 1000 or row[5] != '/home/hydra-live':
|
||||
raise InstallError('Live account must have a nonroot UID and exactly /home/hydra-live')
|
||||
home = target_path(root, 'home/hydra-live')
|
||||
if home.exists() and not home.is_dir():
|
||||
raise InstallError('Live home must be an ordinary directory')
|
||||
if rows:
|
||||
# /proc is not mounted yet, so the live session's identical UID cannot
|
||||
# make userdel mistake a running live process for a target process.
|
||||
run(['chroot', str(root), 'userdel', 'hydra-live'])
|
||||
if home.exists():
|
||||
shutil.rmtree(home)
|
||||
for spool in ('var/mail/hydra-live', 'var/spool/mail/hydra-live'):
|
||||
parent = target_path(root, str(PurePosixPath(spool).parent), symlinks=True)
|
||||
leaf = parent / 'hydra-live'
|
||||
if leaf.is_file() or leaf.is_symlink():
|
||||
leaf.unlink()
|
||||
|
||||
|
||||
def select_kernel(root, requested):
|
||||
modules = target_path(root, '/lib/modules', symlinks=True)
|
||||
if not modules.is_dir():
|
||||
raise InstallError('Payload has no installed kernel modules')
|
||||
versions = sorted(p.name for p in modules.iterdir() if not p.is_symlink() and p.is_dir() and KERNEL.fullmatch(p.name))
|
||||
if requested:
|
||||
if requested not in versions:
|
||||
raise InstallError(f'Requested kernel not installed: {requested}')
|
||||
version = requested
|
||||
elif len(versions) == 1:
|
||||
version = versions[0]
|
||||
else:
|
||||
raise InstallError('Select --kernel explicitly when payload has zero or multiple kernels: ' + ', '.join(versions))
|
||||
if not target_path(root, '/boot/vmlinuz-' + version, symlinks=True).is_file():
|
||||
raise InstallError(f'Payload missing /boot/vmlinuz-{version}')
|
||||
return version
|
||||
|
||||
|
||||
def write_config(root, relative, value, mode=0o644):
|
||||
path = target_path(root, relative)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(value)
|
||||
path.chmod(mode)
|
||||
|
||||
|
||||
def install(request, runner, probe, root, password, log: TextIO):
|
||||
"""Execute into an empty mount directory; inject a runner for disk-free tests."""
|
||||
if not request.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'):
|
||||
raise InstallError('Password must be nonempty and contain no newline or NUL')
|
||||
validated = validate_request(request, probe, return_plan=True)
|
||||
existing = isinstance(validated, storage.PartitionPlan)
|
||||
target = validated.root if existing else validated.target
|
||||
esp, part = (validated.esp, validated.root) if existing else partitions(target)
|
||||
root = Path(root)
|
||||
if not root.is_absolute() or root.is_symlink() or not root.is_dir() or any(root.iterdir()):
|
||||
raise InstallError('Target mount directory must be an empty absolute directory')
|
||||
mounted = []
|
||||
error = None
|
||||
journal = []
|
||||
|
||||
def emit(message):
|
||||
journal.append(message)
|
||||
print(message, file=log, flush=True)
|
||||
|
||||
def run(argv, **kw):
|
||||
emit('+ ' + shlex.join(argv))
|
||||
return runner.run(argv, **kw)
|
||||
|
||||
def mount(argv, path, recursive=False):
|
||||
run(argv)
|
||||
mounted.append((str(path), recursive))
|
||||
|
||||
try:
|
||||
run(['unsquashfs', '-s', request.payload])
|
||||
required = storage.payload_capacity(run(['unsquashfs', '-lln', request.payload]))
|
||||
capacity = validated.root_size if existing else validated.size - 1025 * 1024**2
|
||||
if capacity < required:
|
||||
raise InstallError('Root has insufficient capacity for the uncompressed payload and install reserve')
|
||||
foreign = None
|
||||
if existing:
|
||||
# An independent temporary mount keeps the target root empty.
|
||||
temporary = tempfile.mkdtemp(prefix='esp-preflight-', dir=root.parent)
|
||||
mount(['mount', '-t', 'vfat', '-o', 'ro,nosuid,nodev,noexec', esp, temporary], temporary)
|
||||
try:
|
||||
foreign = storage.inspect_esp(temporary)
|
||||
finally:
|
||||
# Never recurse through a directory that could still be mounted.
|
||||
run(['umount', temporary])
|
||||
mounted.pop()
|
||||
try:
|
||||
Path(temporary).rmdir()
|
||||
except OSError:
|
||||
pass # Retain unexpected contents; never delete ESP files.
|
||||
# Recheck the canonical paths, kernel identities, GPT IDs and bounds
|
||||
# after read-only ESP inspection, immediately before formatting.
|
||||
if validate_request(request, probe, return_plan=True) != validated:
|
||||
raise InstallError('Target device identity or partition bounds changed during validation')
|
||||
for argv in ([storage.format_root(part, request.root_fs)] if existing else partition_commands(target, request.root_fs)):
|
||||
run(argv)
|
||||
if request.root_fs == 'btrfs':
|
||||
top = root.parent / 'btrfs-top'
|
||||
top.mkdir()
|
||||
mount(['mount', '-t', 'btrfs', '-o', 'subvolid=5', part, str(top)], top)
|
||||
for subvolume in ('@root', '@home', '@snapshots'):
|
||||
run(['btrfs', 'subvolume', 'create', str(top / subvolume)])
|
||||
run(['umount', str(top)])
|
||||
mounted.pop()
|
||||
top.rmdir()
|
||||
mount(['mount', '-t', 'btrfs', '-o', 'subvol=@root', part, str(root)], root)
|
||||
for directory, subvolume in (('home', '@home'), ('.snapshots', '@snapshots')):
|
||||
(root / directory).mkdir()
|
||||
mount(['mount', '-t', 'btrfs', '-o', 'subvol=' + subvolume, part, str(root / directory)], root / directory)
|
||||
else:
|
||||
mount(['mount', '-t', 'ext4', part, str(root)], root)
|
||||
run(['unsquashfs', '-f', '-d', str(root), request.payload])
|
||||
kernel = select_kernel(root, request.kernel)
|
||||
remove_live_files(root)
|
||||
remove_live_account(root, run)
|
||||
if not target_path(root, '/usr/share/zoneinfo/' + request.timezone, symlinks=True).is_file():
|
||||
raise InstallError('Timezone does not exist in payload: ' + request.timezone)
|
||||
for folder in ('boot/efi', 'dev', 'proc', 'sys'):
|
||||
target_path(root, folder).mkdir(parents=True, exist_ok=True)
|
||||
mount(['mount', '-t', 'vfat', esp, str(root / 'boot/efi')], root / 'boot/efi')
|
||||
root_uuid = run(['blkid', '-s', 'UUID', '-o', 'value', part]).strip()
|
||||
esp_uuid = run(['blkid', '-s', 'UUID', '-o', 'value', esp]).strip()
|
||||
if any(not re.fullmatch(r'[A-Fa-f0-9-]+', value) for value in (root_uuid, esp_uuid)):
|
||||
raise InstallError('Missing or invalid partition UUID')
|
||||
if request.root_fs == 'btrfs':
|
||||
root_lines = (f'UUID={root_uuid} / btrfs defaults,subvol=@root 0 0\n'
|
||||
f'UUID={root_uuid} /home btrfs defaults,subvol=@home 0 0\n'
|
||||
f'UUID={root_uuid} /.snapshots btrfs defaults,subvol=@snapshots 0 0\n')
|
||||
else:
|
||||
root_lines = f'UUID={root_uuid} / ext4 defaults 0 1\n'
|
||||
write_config(root, 'etc/fstab', root_lines + f'UUID={esp_uuid} /boot/efi vfat umask=0077 0 2\n')
|
||||
root_partuuid = run(['blkid', '-s', 'PARTUUID', '-o', 'value', part]).strip()
|
||||
esp_partuuid = run(['blkid', '-s', 'PARTUUID', '-o', 'value', esp]).strip()
|
||||
if not all(storage.PARTUUID.fullmatch(value) for value in (root_partuuid, esp_partuuid)):
|
||||
raise InstallError('Missing or invalid GPT partition UUID for installed storage marker')
|
||||
if existing:
|
||||
before_root, before_esp = validated.root_partuuid, validated.esp_partuuid
|
||||
if (root_partuuid.lower(), esp_partuuid.lower()) != (before_root.lower(), before_esp.lower()):
|
||||
raise InstallError('GPT partition identity changed after formatting')
|
||||
if esp_uuid.lower() != validated.esp_uuid.lower():
|
||||
raise InstallError('ESP filesystem identity changed after preflight')
|
||||
marker = dict(format=1, install_uuid=str(uuid.uuid4()), filesystem=request.root_fs,
|
||||
root_fs_uuid=root_uuid, root_partuuid=root_partuuid,
|
||||
esp_uuid=esp_uuid, esp_partuuid=esp_partuuid,
|
||||
owned_efi_path='EFI/HydraOS')
|
||||
if request.root_fs == 'btrfs':
|
||||
marker['root_subvolume'] = '@root'
|
||||
write_config(root, 'etc/hydra/storage.json', json.dumps(marker, sort_keys=True) + '\n', 0o600)
|
||||
write_config(root, 'etc/locale.gen', 'en_US.UTF-8 UTF-8\nru_RU.UTF-8 UTF-8\n')
|
||||
write_config(root, 'etc/locale.conf', 'LANG=en_US.UTF-8\n')
|
||||
write_config(root, 'etc/timezone', request.timezone + '\n')
|
||||
localtime = target_path(root, 'etc/localtime', symlinks=True)
|
||||
# Replace even an absolute /etc/localtime symlink without touching the host.
|
||||
localtime_link = target_path(root, 'etc') / 'localtime'
|
||||
if localtime_link.is_symlink():
|
||||
localtime_link.unlink()
|
||||
elif localtime.exists():
|
||||
localtime.unlink()
|
||||
(root / 'etc/localtime').symlink_to('/usr/share/zoneinfo/' + request.timezone)
|
||||
write_config(root, 'etc/machine-id', '')
|
||||
dbus_machine_id = target_path(root, 'var/lib/dbus') / 'machine-id'
|
||||
dbus_machine_id.unlink(missing_ok=True)
|
||||
boot_args = f'root=UUID={root_uuid}' + (' rootflags=subvol=@root' if request.root_fs == 'btrfs' else '')
|
||||
write_config(root, 'etc/dracut.conf.d/90-hydra-installed.conf',
|
||||
'hostonly="no"\nhostonly_cmdline="no"\nomit_dracutmodules+=" dmsquash-live "\n'
|
||||
+ ('filesystems+=" btrfs "\n' if request.root_fs == 'btrfs' else '')
|
||||
+ f'kernel_cmdline="{boot_args}"\n')
|
||||
write_config(root, 'etc/default/grub', 'GRUB_DISTRIBUTOR="Hydra OS"\n'
|
||||
+ f'GRUB_CMDLINE_LINUX="{boot_args}"\nGRUB_TIMEOUT=3\nGRUB_DISABLE_OS_PROBER=true\n')
|
||||
write_config(root, 'etc/sudoers.d/90-hydra-admin', '%wheel ALL=(ALL:ALL) ALL\n', 0o440)
|
||||
mount(['mount', '--rbind', '/dev', str(root / 'dev')], root / 'dev', True)
|
||||
run(['mount', '--make-rslave', str(root / 'dev')])
|
||||
mount(['mount', '-t', 'proc', 'proc', str(root / 'proc')], root / 'proc')
|
||||
mount(['mount', '--rbind', '/sys', str(root / 'sys')], root / 'sys', True)
|
||||
run(['mount', '--make-rslave', str(root / 'sys')])
|
||||
def chroot(*args, **kw):
|
||||
return run(['chroot', str(root), *args], **kw)
|
||||
chroot('locale-gen')
|
||||
for group in ('wheel', 'kvm', 'video', 'render', 'audio'):
|
||||
chroot('groupadd', '--force', '--system', group)
|
||||
chroot('useradd', '--create-home', '--groups', 'wheel,kvm,video,render,audio', '--shell', '/bin/bash', request.user)
|
||||
chroot('chpasswd', input=f'{request.user}:{password}\n', sensitive=True)
|
||||
password = None
|
||||
chroot('passwd', '--lock', 'root')
|
||||
chroot('systemd-machine-id-setup')
|
||||
chroot('dracut', '--force', '--no-hostonly', '--omit', 'dmsquash-live',
|
||||
'/boot/initramfs-' + kernel + '.img', kernel)
|
||||
run(grub_command(root, preserve=True))
|
||||
if not existing:
|
||||
run(grub_command(root))
|
||||
chroot('grub-mkconfig', '-o', '/boot/grub/grub.cfg')
|
||||
if existing and storage.foreign_efi_hashes(root / 'boot/efi', ignore_owned=True) != foreign:
|
||||
raise InstallError('Foreign ESP files changed; refusing successful installation')
|
||||
if request.register_boot_entry:
|
||||
parent, number = (validated.esp_parent, validated.esp_number) if existing else (target, 1)
|
||||
run(['efibootmgr', '--create-only', '--disk', parent, '--part', str(number),
|
||||
'--label', 'HydraOS', '--loader', r'\EFI\HydraOS\grubx64.efi'])
|
||||
run(['sync'])
|
||||
except (InstallError, OSError, KeyboardInterrupt) as exc:
|
||||
error = exc
|
||||
emit(f'Installation stopped: {exc}. Target data retained for diagnosis.')
|
||||
finally:
|
||||
password = None
|
||||
if any(path == str(root) for path, _ in mounted):
|
||||
try:
|
||||
write_config(root, 'var/log/hydra-install.log', '\n'.join(journal) + '\n', 0o600)
|
||||
except (InstallError, OSError) as exc:
|
||||
emit(f'Unable to retain diagnostic in target: {exc}')
|
||||
cleanup_errors = []
|
||||
for path, recursive in reversed(mounted):
|
||||
try:
|
||||
run(['umount', *(['--recursive'] if recursive else []), path])
|
||||
except (InstallError, OSError) as exc:
|
||||
cleanup_errors.append(str(exc))
|
||||
emit(f'cleanup failed: {exc}')
|
||||
if cleanup_errors and error is None:
|
||||
error = InstallError('Mount cleanup failed: ' + '; '.join(cleanup_errors))
|
||||
if error:
|
||||
raise InstallError(str(error)) from error
|
||||
if existing and not request.register_boot_entry:
|
||||
print('Select EFI/HydraOS/grubx64.efi manually in firmware; BootOrder was not changed.', file=log, flush=True)
|
||||
print('Installation finished; remove the live ISO before booting the installed disk.', file=log, flush=True)
|
||||
|
||||
|
||||
def preflight(request, runner):
|
||||
if any(Path(marker).exists() for marker in ('/.dockerenv', '/run/.containerenv', '/run/systemd/container')):
|
||||
raise InstallError('Refusing disk installation in a container; boot the live ISO in a disposable VM')
|
||||
if os.geteuid() != 0:
|
||||
raise InstallError('Execution requires root inside the live environment; no automatic sudo')
|
||||
if not sys.stdin.isatty():
|
||||
raise InstallError('Execution requires a TTY for password entry')
|
||||
if not Path('/sys/firmware/efi').is_dir():
|
||||
raise InstallError('Boot the live medium in UEFI mode before installation')
|
||||
commands = ['lsblk', 'findmnt', 'mount', 'umount', 'unsquashfs', 'blkid', 'chroot', 'sync']
|
||||
if request.root_partition is None:
|
||||
commands.extend(['parted', 'partprobe', 'udevadm', 'mkfs.vfat'])
|
||||
commands.extend(['btrfs', 'mkfs.btrfs'] if request.root_fs == 'btrfs' else ['mkfs.ext4'])
|
||||
if request.register_boot_entry:
|
||||
commands.append('efibootmgr')
|
||||
for command in commands:
|
||||
if shutil.which(command) is None:
|
||||
raise InstallError('Required live installer tool missing: ' + command)
|
||||
runner.run(['unsquashfs', '-s', request.payload])
|
||||
|
||||
|
||||
def prompt_password():
|
||||
try:
|
||||
# Only the prompt output stream is needed here; getpass opens its own
|
||||
# raw controlling-TTY input. Buffered r+ requires seekability.
|
||||
with open('/dev/tty', 'w') as tty:
|
||||
if not tty.isatty():
|
||||
raise InstallError('Password entry requires a controlling TTY')
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('error', getpass.GetPassWarning)
|
||||
password = getpass.getpass('Installed account password: ', stream=tty)
|
||||
if password != getpass.getpass('Repeat password: ', stream=tty):
|
||||
raise InstallError('Passwords differ')
|
||||
return password
|
||||
except (OSError, getpass.GetPassWarning) as exc:
|
||||
raise InstallError('Cannot securely read password from the controlling TTY') from exc
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description='Hydra OS explicit disk or existing-partition installer; dry-run by default')
|
||||
parser.add_argument('--disk', help='explicit physical whole-disk target; never automatically selected')
|
||||
parser.add_argument('--root-partition', help='existing root partition; formats only this partition')
|
||||
parser.add_argument('--esp-partition', help='existing FAT GPT ESP; never formatted in partition mode')
|
||||
parser.add_argument('--confirm-format', help='must exactly match the resolved root partition with --execute')
|
||||
parser.add_argument('--root-fs', choices=('ext4', 'btrfs'), default='ext4')
|
||||
parser.add_argument('--register-boot-entry', action='store_true', help='opt in to create-only EFI entry, preserving BootOrder')
|
||||
parser.add_argument('--user', help='installed administrator username')
|
||||
parser.add_argument('--payload', default=DEFAULT_PAYLOAD, help='prepared live squashfs root payload')
|
||||
parser.add_argument('--execute', action='store_true')
|
||||
parser.add_argument('--confirm-erase', help='must exactly match the resolved /dev target with --execute')
|
||||
parser.add_argument('--timezone', default='Europe/Moscow')
|
||||
parser.add_argument('--kernel', help='required if payload contains multiple kernel module versions')
|
||||
args = parser.parse_args(argv)
|
||||
runner = Runner()
|
||||
try:
|
||||
if not args.disk and not args.root_partition and not args.esp_partition:
|
||||
if args.execute or args.confirm_erase or args.confirm_format or args.register_boot_entry:
|
||||
raise InstallError('--execute requires an explicit --disk and --user')
|
||||
topo = Topology.from_json(runner.run(['lsblk', '--json', '--paths', '--bytes', '--output',
|
||||
'PATH,TYPE,PKNAME,MOUNTPOINTS,SIZE,MAJ:MIN']))
|
||||
print('Disks (no target selected; mounted disks are ineligible):')
|
||||
for path, node in sorted(topo.nodes.items()):
|
||||
if node['type'] == 'disk':
|
||||
state = 'in use' if topo.component(path) & topo.protected else 'unmounted; requires full guard check'
|
||||
print(f' {path} {node["size"] / 1024**3:.1f} GiB {state}')
|
||||
print('Choose --disk DEVICE --user USER to inspect the dry-run plan.')
|
||||
return 0
|
||||
if not args.user:
|
||||
raise InstallError('--user is required with --disk')
|
||||
request = Request(**vars(args))
|
||||
probe = SystemProbe(runner)
|
||||
target = validate_request(request, probe)
|
||||
print(render_plan(request, target, '/mnt/hydra-target'))
|
||||
if not request.execute:
|
||||
return 0
|
||||
preflight(request, runner)
|
||||
password = prompt_password()
|
||||
state = Path(tempfile.mkdtemp(prefix='hydra-install-', dir='/run'))
|
||||
state.chmod(0o700)
|
||||
root = state / 'target'
|
||||
root.mkdir()
|
||||
logfile = state / 'installer.log'
|
||||
print(f'Diagnostic log: {logfile}')
|
||||
with logfile.open('w') as log:
|
||||
log.write(render_plan(request, target, str(root)) + '\n')
|
||||
install(request, runner, probe, root, password, log)
|
||||
password = None
|
||||
print(f'Installation completed. Diagnostic log: {logfile}')
|
||||
return 0
|
||||
except (InstallError, OSError, KeyboardInterrupt) as exc:
|
||||
print(f'hydra-install: {exc}', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
-123
@@ -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
@@ -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
@@ -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))
|
||||
@@ -23,12 +23,6 @@ class QMPClient:
|
||||
self.socket.settimeout(timeout)
|
||||
self.socket.connect(str(path))
|
||||
greeting = self._receive(deadline)
|
||||
# Real QEMU can queue a SPICE event across monitor reconnects.
|
||||
# Consume only protocol events, retaining the original deadline.
|
||||
while 'event' in greeting:
|
||||
if not self._async_event(greeting):
|
||||
raise QMPError('Invalid QMP event before greeting')
|
||||
greeting = self._receive(deadline)
|
||||
if not isinstance(greeting.get('QMP'), dict):
|
||||
raise QMPError('Invalid QMP greeting')
|
||||
self._command('qmp_capabilities', None, deadline)
|
||||
@@ -38,22 +32,6 @@ class QMPClient:
|
||||
raise
|
||||
raise QMPError(f'QMP connection failed: {exc}') from exc
|
||||
|
||||
@staticmethod
|
||||
def _async_event(message: dict) -> bool:
|
||||
if not {'event', 'timestamp'} <= message.keys() <= {'event', 'timestamp', 'data'}:
|
||||
return False
|
||||
if not isinstance(message['event'], str) or not message['event']:
|
||||
return False
|
||||
if 'data' in message and not isinstance(message['data'], dict):
|
||||
return False
|
||||
stamp = message['timestamp']
|
||||
if (not isinstance(stamp, dict) or set(stamp) != {'seconds', 'microseconds'}
|
||||
or any(type(value) is not int for value in stamp.values())):
|
||||
return False
|
||||
seconds, micros = stamp['seconds'], stamp['microseconds']
|
||||
# QMP specifies (-1, -1) when the server cannot retrieve host time.
|
||||
return (seconds == -1 and micros == -1) or (seconds >= 0 and 0 <= micros < 1000000)
|
||||
|
||||
def _receive(self, deadline: float) -> dict:
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
|
||||
+38
-97
@@ -15,8 +15,8 @@ from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from .profiles import load_profile, validate_name
|
||||
from .runtime import Runtime, RuntimeError, VMPaths, _private_dir
|
||||
from .profiles import validate_name
|
||||
from .runtime import Runtime, VMPaths, _private_dir
|
||||
|
||||
|
||||
class SessionError(ValueError):
|
||||
@@ -24,7 +24,7 @@ class SessionError(ValueError):
|
||||
|
||||
|
||||
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]:
|
||||
@@ -101,7 +101,7 @@ def _viewer() -> str:
|
||||
viewer = _tool('HYDRA_VIEWER', 'remote-viewer')
|
||||
result = subprocess.run([viewer, '--help'], capture_output=True, text=True, timeout=10)
|
||||
if result.returncode or '--hide-toolbar' not in result.stdout:
|
||||
raise SessionError('Viewer lacks Hydra OS toolbar/USB adaptation; use dev/tools.nix')
|
||||
raise SessionError('Viewer lacks HydraOS toolbar/USB adaptation; use dev/tools.nix')
|
||||
return viewer
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ def _viewer_environment(directory: Path) -> dict[str, str]:
|
||||
settings.mkdir(parents=True, mode=0o700)
|
||||
(settings / 'settings').write_text('[virt-viewer]\nshare-clipboard=false\n', encoding='utf-8')
|
||||
env = dict(os.environ, XDG_CONFIG_HOME=str(config), GSETTINGS_BACKEND='memory')
|
||||
# A dedicated backend prevents accidental XWayland input behavior in Hydra OS.
|
||||
# A dedicated backend prevents accidental XWayland input behavior in HydraOS.
|
||||
if env.get('WAYLAND_DISPLAY'):
|
||||
env['GDK_BACKEND'] = 'wayland'
|
||||
return env
|
||||
@@ -162,7 +162,7 @@ def _module_argv(command: str, manifest: Path) -> list[str]:
|
||||
|
||||
|
||||
def _recovery_argv(manifest: Path, terminal: str) -> list[str]:
|
||||
return [terminal, '--title=Hydra OS recovery'] + _module_argv('recover', manifest)
|
||||
return [terminal, '--title=HydraOS-recovery'] + _module_argv('recover', manifest)
|
||||
|
||||
|
||||
def launch_session(runtime: Runtime, *, nested=False) -> int:
|
||||
@@ -173,25 +173,11 @@ def launch_session(runtime: Runtime, *, nested=False) -> int:
|
||||
version = subprocess.run([labwc, '--version'], capture_output=True, text=True, timeout=10)
|
||||
match = re.search(r'(\d+)\.(\d+)\.(\d+)', version.stdout)
|
||||
if version.returncode or not match or tuple(map(int, match.groups())) < (0, 20, 0):
|
||||
raise SessionError('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')
|
||||
_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)
|
||||
profiles = runtime.profiles()
|
||||
initial = next((p.name for p in profiles if runtime.status(p.name)['status'] == 'running'), None)
|
||||
with tempfile.TemporaryDirectory(prefix='session-', dir=runtime.runtime) as tmp:
|
||||
directory = Path(tmp)
|
||||
config = directory / 'labwc'
|
||||
@@ -206,7 +192,6 @@ def launch_session(runtime: Runtime, *, nested=False) -> int:
|
||||
(config / 'autostart').write_text('#!/bin/sh\n' + shlex.join(_module_argv('clients', manifest)) + ' &\n', encoding='utf-8')
|
||||
(config / 'environment').touch()
|
||||
env['PYTHONUNBUFFERED'] = '1'
|
||||
env['HYDRA_SESSION_MANIFEST'] = str(manifest)
|
||||
compositor = subprocess.Popen([labwc, '-C', str(config)], env=env)
|
||||
refresh = False
|
||||
try:
|
||||
@@ -225,13 +210,12 @@ def launch_session(runtime: Runtime, *, nested=False) -> int:
|
||||
compositor.wait(timeout=10)
|
||||
if not refresh:
|
||||
return compositor.returncode
|
||||
selected = _refresh_selection(directory / 'refresh.request')
|
||||
# Only our compositor was stopped. Its viewers disconnect; detached
|
||||
# QEMU instances retain their identity and running workloads.
|
||||
|
||||
|
||||
|
||||
def _manifest_data(manifest: Path) -> dict:
|
||||
def session_helper(command: str, manifest: Path) -> int:
|
||||
data = json.loads(manifest.read_text(encoding='utf-8'))
|
||||
if (not isinstance(data, dict) or
|
||||
any(not isinstance(data.get(key), str) or not Path(data[key]).is_absolute()
|
||||
@@ -240,79 +224,38 @@ def _manifest_data(manifest: Path) -> dict:
|
||||
raise SessionError('Invalid session manifest')
|
||||
for name in data['names']:
|
||||
validate_name(name)
|
||||
return data
|
||||
|
||||
|
||||
def request_refresh(manifest: Path, *, selected: str | None = None) -> int:
|
||||
"""Publish a complete request without controlling any guest process."""
|
||||
manifest = Path(manifest)
|
||||
data = _manifest_data(manifest)
|
||||
if selected is not None:
|
||||
validate_name(selected)
|
||||
# Profiles can be created after the compositor's original snapshot.
|
||||
Runtime(Path(data['config']), Path(data['state']), Path(data['runtime'])).profile(selected)
|
||||
payload = '' if selected is None else json.dumps({'selected': selected})
|
||||
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=manifest.parent,
|
||||
prefix='.refresh-', delete=False) as stream:
|
||||
temporary = Path(stream.name)
|
||||
try:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, manifest.parent / 'refresh.request')
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
print('Refreshing workspaces and viewers; VMs keep running.')
|
||||
return 0
|
||||
|
||||
|
||||
def _refresh_selection(request: Path) -> str | None:
|
||||
try:
|
||||
payload = request.read_text(encoding='utf-8')
|
||||
if not payload:
|
||||
return None
|
||||
data = json.loads(payload)
|
||||
if not isinstance(data, dict) or set(data) != {'selected'}:
|
||||
raise SessionError('Invalid refresh request')
|
||||
return validate_name(data['selected'])
|
||||
except (RuntimeError, OSError, ValueError) as exc:
|
||||
print(f'hydra: refresh selection ignored: {exc}', file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def _emergency_recovery(manifest: Path) -> int:
|
||||
print('Hydra OS emergency recovery. :shell, :refresh or :quit; VMs keep running.')
|
||||
while True:
|
||||
try:
|
||||
choice = input('hydra recovery> ').strip()
|
||||
if choice == ':quit':
|
||||
return 0
|
||||
if choice == ':shell':
|
||||
subprocess.run([os.environ.get('SHELL', '/bin/sh')])
|
||||
elif choice == ':refresh':
|
||||
return request_refresh(manifest)
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return 0
|
||||
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
||||
print(f'hydra: {exc}')
|
||||
|
||||
|
||||
def session_helper(command: str, manifest: Path) -> int:
|
||||
data = _manifest_data(manifest)
|
||||
if command not in ('clients', 'recover', 'refresh') and (
|
||||
not command.startswith('reconnect:') or command.split(':', 1)[1] not in data['names']):
|
||||
raise SessionError('Invalid session helper operation')
|
||||
runtime = Runtime(Path(data['config']), Path(data['state']), Path(data['runtime']))
|
||||
if command == 'refresh':
|
||||
return request_refresh(manifest)
|
||||
fd = os.open(manifest.parent / 'refresh.request', os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
os.close(fd)
|
||||
print('Refreshing workspaces and viewers; VMs keep running.')
|
||||
return 0
|
||||
if command == 'recover':
|
||||
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)
|
||||
print('HydraOS local recovery. VMs keep running independently.\n'
|
||||
'Ctrl+Alt+Shift+Left/Right switches workspaces; Ctrl+Alt+Shift+R releases input; Ctrl+Alt+Shift+U opens USB.\n'
|
||||
'Enter a VM name to reconnect, :shell for a host shell, :refresh to rebuild workspaces/viewers, or :quit.')
|
||||
if not data['names']:
|
||||
print('No VM profiles yet. Use :shell to run hydra add NAME --iso PATH and hydra start NAME.\n'
|
||||
'After adding or removing profiles, leave the shell and enter :refresh; running VMs are preserved.')
|
||||
while True:
|
||||
try:
|
||||
print(json.dumps([runtime.status(name) for name in data['names']], indent=2))
|
||||
choice = input('hydra recovery> ').strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return 0
|
||||
if choice == ':quit':
|
||||
return 0
|
||||
if choice == ':refresh':
|
||||
return session_helper('refresh', manifest)
|
||||
if choice == ':shell':
|
||||
subprocess.run([os.environ.get('SHELL', '/bin/sh')])
|
||||
elif choice in data['names']:
|
||||
subprocess.Popen(_module_argv('reconnect:' + choice, manifest), start_new_session=True)
|
||||
else:
|
||||
print('Enter a configured VM name, :shell, :refresh, or :quit.')
|
||||
if command.startswith('reconnect:'):
|
||||
name = command.split(':', 1)[1]
|
||||
# One viewer per VM inside this session; independent host view commands
|
||||
@@ -332,11 +275,9 @@ def session_helper(command: str, manifest: Path) -> int:
|
||||
recovery = subprocess.Popen(_recovery_argv(manifest, data['terminal']))
|
||||
for name in data['names']:
|
||||
try:
|
||||
# Verified paused/prelaunch guests also have a live SPICE server;
|
||||
# reconnecting must not imply a guest start or resume.
|
||||
if runtime.status(name)['status'] not in ('stopped', 'unknown'):
|
||||
if runtime.status(name)['status'] == 'running':
|
||||
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)
|
||||
if not processes:
|
||||
open_recovery()
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -1,54 +0,0 @@
|
||||
Hydra OS builds start from the pinned amd64 systemd stage3 and pinned Gentoo
|
||||
repository in `sources.json`. Run the builder as the normal user with Python 3,
|
||||
Podman and GnuPG available; temporary tool environments are sufficient. It
|
||||
refuses host root and work/cache paths outside the primary project checkout.
|
||||
|
||||
```
|
||||
scripts/hydra-build fetch
|
||||
scripts/hydra-build prepare
|
||||
scripts/hydra-build packages --pretend
|
||||
scripts/hydra-build packages
|
||||
scripts/hydra-build rootfs
|
||||
scripts/hydra-build iso
|
||||
scripts/hydra-build status
|
||||
```
|
||||
|
||||
`--work PATH`, `--cache PATH`, `--podman PATH` and `--gpg PATH` override defaults.
|
||||
Linked worktrees use the primary checkout's `build/` and `cache/`. The build
|
||||
lock prevents simultaneous writes in the same work directory. Interrupted
|
||||
package installation resumes through Portage; successful steps write
|
||||
`build/manifest.json`, signature evidence and the installed package/version/USE
|
||||
inventory in `build/artifacts/`. Downloaded inputs are reverified on every
|
||||
prepare; missing or changed files cannot be trusted from a checkpoint alone.
|
||||
|
||||
The dedicated rootless Podman store is `build/podman`, separate from default
|
||||
Podman and Distrobox storage. The build container mounts inputs and repository
|
||||
read-only and retains its writable layer across failures. Host networking makes
|
||||
the existing local VPN/proxy available. `SYS_ADMIN` operates within the rootless
|
||||
user namespace; package sandbox features incompatible with it are disabled.
|
||||
No host disks or `/dev/kvm` are passed into the builder. Hydra never prunes
|
||||
containers/images or changes host services.
|
||||
|
||||
Official Gentoo binpkgs require signatures. USE mismatches and selective testing
|
||||
keywords can cause source builds; concurrency is four compiler processes and
|
||||
one emerge job. Keep licenses and Portage's `/var/db/pkg`, world and repository
|
||||
for installed-system updates. Rolling binhost contents can change; resulting
|
||||
versions/USE are recorded without claiming bit-identical output. The pinned
|
||||
service-keyring hash may need deliberate review when Gentoo rotates keys.
|
||||
|
||||
`rootfs` exports the dedicated builder into a private intermediate and assembles
|
||||
an isolated sanitized payload, generic live initramfs and squashfs. `iso` checks
|
||||
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.
|
||||
|
||||
The x86_64 ISO disables GRUB's optional HFS+/APM hybrid. Otherwise that second
|
||||
filesystem duplicates the `HYDRA_OS` label and can make dracut mount the wrong
|
||||
live medium after raw USB writing. ISO inspection includes the system area and
|
||||
rejects HFS+/APM. Test both optical and USB attachment with `dev/iso-smoke.py
|
||||
--live-only --live-media cdrom` and `--live-only --live-media usb` (plus the normal
|
||||
ISO and OVMF arguments); optical boot alone is insufficient.
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
masters = gentoo
|
||||
thin-manifests = true
|
||||
sign-manifests = false
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
# Host, boot medium, and recovery tools. Keep Portage's installed database/world.
|
||||
app-emulation/qemu
|
||||
~sys-firmware/edk2-bin-202408
|
||||
>=gui-wm/labwc-0.20.0
|
||||
app-emulation/virt-viewer
|
||||
gui-apps/foot
|
||||
media-fonts/dejavu
|
||||
gui-libs/greetd
|
||||
net-misc/networkmanager
|
||||
net-dns/dnsmasq
|
||||
net-firewall/nftables
|
||||
media-video/pipewire
|
||||
media-video/wireplumber
|
||||
app-admin/sudo
|
||||
app-misc/fastfetch
|
||||
sys-kernel/gentoo-kernel-bin
|
||||
sys-kernel/linux-firmware
|
||||
sys-firmware/sof-firmware
|
||||
sys-firmware/intel-microcode
|
||||
sys-kernel/dracut
|
||||
sys-boot/grub
|
||||
sys-fs/squashfs-tools
|
||||
sys-fs/dosfstools
|
||||
sys-fs/e2fsprogs
|
||||
sys-fs/btrfs-progs
|
||||
sys-boot/efibootmgr
|
||||
dev-libs/openssl
|
||||
sys-block/parted
|
||||
sys-apps/util-linux
|
||||
sys-fs/mtools
|
||||
dev-libs/libisoburn
|
||||
dev-lang/python
|
||||
sec-keys/openpgp-keys-gentoo-release
|
||||
sec-keys/openpgp-keys-gentoo-auth
|
||||
sys-apps/brightnessctl
|
||||
@@ -1,4 +0,0 @@
|
||||
[gentoo]
|
||||
sync-uri = https://distfiles.gentoo.org/releases/amd64/binpackages/23.0/x86-64/
|
||||
priority = 10
|
||||
verify-signature = yes
|
||||
@@ -1,16 +0,0 @@
|
||||
COMMON_FLAGS="-O2 -pipe -march=x86-64 -mtune=generic"
|
||||
CFLAGS="${COMMON_FLAGS}"
|
||||
CXXFLAGS="${COMMON_FLAGS}"
|
||||
FCFLAGS="${COMMON_FLAGS}"
|
||||
FFLAGS="${COMMON_FLAGS}"
|
||||
MAKEOPTS="-j4"
|
||||
EMERGE_DEFAULT_OPTS="--jobs=1 --getbinpkg --binpkg-respect-use=y --autounmask=n --usepkg-exclude='app-emulation/qemu app-emulation/virt-viewer'"
|
||||
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"
|
||||
VIDEO_CARDS="intel virgl"
|
||||
USE="X dbus wayland opengl alsa"
|
||||
QEMU_SOFTMMU_TARGETS="x86_64"
|
||||
QEMU_USER_TARGETS=""
|
||||
GRUB_PLATFORMS="efi-64"
|
||||
BINPKG_GPG_VERIFY_GPG_HOME="/etc/portage/gnupg"
|
||||
PORTAGE_NICENESS="10"
|
||||
@@ -1,2 +0,0 @@
|
||||
>=gui-wm/labwc-0.20.0 ~amd64
|
||||
gui-libs/wlroots ~amd64
|
||||
@@ -1,10 +0,0 @@
|
||||
app-emulation/qemu spice usb usbredir virgl opengl wayland pipewire -gtk -sdl
|
||||
app-emulation/virt-viewer spice -libvirt
|
||||
net-misc/spice-gtk usbredir
|
||||
media-video/pipewire sound-server bluetooth dbus pipewire-alsa
|
||||
net-misc/networkmanager wifi
|
||||
sys-boot/grub mount
|
||||
media-libs/freetype harfbuzz
|
||||
sys-kernel/installkernel dracut
|
||||
|
||||
sys-fs/squashfs-tools zstd
|
||||
@@ -1,5 +0,0 @@
|
||||
[gentoo]
|
||||
location = /var/db/repos/gentoo
|
||||
sync-type = git
|
||||
sync-uri = https://github.com/gentoo/gentoo.git
|
||||
auto-sync = no
|
||||
@@ -1,4 +0,0 @@
|
||||
[hydra]
|
||||
location = /var/db/repos/hydra
|
||||
masters = gentoo
|
||||
auto-sync = no
|
||||
@@ -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 "
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
[terminal]
|
||||
vt = 1
|
||||
[default_session]
|
||||
command = "/usr/bin/agreety --cmd /usr/bin/hydra-session"
|
||||
user = "greetd"
|
||||
@@ -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"
|
||||
@@ -1,5 +0,0 @@
|
||||
network = "bridge"
|
||||
bridge = "hydrabr0"
|
||||
width = 1920
|
||||
height = 1080
|
||||
refresh_millihz = 144000
|
||||
@@ -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 +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"
|
||||
]
|
||||
@@ -1,3 +0,0 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEAv7478NwuD7binWXyVcaWCIo9o+ahNThjNjMRot+k5w0=
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -1 +0,0 @@
|
||||
LANG=en_US.UTF-8
|
||||
@@ -1,2 +0,0 @@
|
||||
en_US.UTF-8 UTF-8
|
||||
ru_RU.UTF-8 UTF-8
|
||||
@@ -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.
|
||||
@@ -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 +0,0 @@
|
||||
allow hydrabr0
|
||||
@@ -1 +0,0 @@
|
||||
%wheel ALL=(ALL:ALL) ALL
|
||||
@@ -1 +0,0 @@
|
||||
hydra-live ALL=(ALL:ALL) NOPASSWD: ALL
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user