Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a636924df | ||
|
|
17ade497a4 | ||
|
|
32e9334feb | ||
|
|
c490991054 | ||
|
|
972b82c24b | ||
|
|
f46fef764b | ||
|
|
e93820bd52 |
@@ -6,15 +6,9 @@ desktops may use the same independent VM manager and viewers.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Hydra OS 0.2.0 adds selected-partition installation, signed runtime updates,
|
Under active development, not yet an installation-ready distribution. The target
|
||||||
offline Btrfs base recovery and read-only laptop diagnostics. The complete source
|
is an installable UEFI ISO, first validated on ASUS TUF Dash F15 FX517ZC. Existing
|
||||||
suite passed **431 tests**. The final ISO passed **13 whole-disk** and **23
|
NixOS and VM disks are not installation or test targets.
|
||||||
preserved-partition/recovery** UEFI/KVM checks, with no skips. See the
|
|
||||||
[release and acceptance record](docs/releases/2026-10-13-laptop-ready/README.md).
|
|
||||||
The [previous terminal UX preview](docs/releases/2026-10-13-terminal-ux/README.md)
|
|
||||||
passed 13 disposable UEFI/KVM checks against its earlier image. The first physical
|
|
||||||
target is ASUS TUF Dash F15 FX517ZC; hardware checks remain pending. No physical
|
|
||||||
partition has been selected, and existing NixOS and VM disks were not targets.
|
|
||||||
|
|
||||||
1. Independent QEMU/KVM lifecycle with private QMP control.
|
1. Independent QEMU/KVM lifecycle with private QMP control.
|
||||||
2. Minimal labwc session and fullscreen SPICE viewers.
|
2. Minimal labwc session and fullscreen SPICE viewers.
|
||||||
@@ -28,124 +22,5 @@ a refresh-rate setting does not prove delivered FPS.
|
|||||||
- [Session design](docs/superpowers/specs/2026-10-13-graphics-session-design.md)
|
- [Session design](docs/superpowers/specs/2026-10-13-graphics-session-design.md)
|
||||||
- [Implementation plan](docs/superpowers/plans/2026-10-13-graphics-session.md)
|
- [Implementation plan](docs/superpowers/plans/2026-10-13-graphics-session.md)
|
||||||
- [Hardware target](docs/hardware/asus-fx517zc.md)
|
- [Hardware target](docs/hardware/asus-fx517zc.md)
|
||||||
- [Signed runtime updates](docs/updates.md)
|
|
||||||
- [Offline Gentoo base recovery](docs/system-recovery.md)
|
|
||||||
|
|
||||||
Build outputs, VM disks, runtime state and credentials stay outside Git.
|
Build outputs, VM disks, runtime state and credentials stay outside Git.
|
||||||
|
|
||||||
## First use
|
|
||||||
|
|
||||||
Follow the [installation guide](docs/install.md) to install from the live ISO.
|
|
||||||
The session opens a terminal VM menu. Choose a machine by number or name;
|
|
||||||
Enter opens the remembered selection, or the only machine. Opening a stopped
|
|
||||||
machine starts it; opening a running one reconnects its screen. Merely opening
|
|
||||||
the menu does not start every VM. `:shell` opens an ordinary host shell and
|
|
||||||
`:network` opens NetworkManager's connection menu. The existing VM disks and
|
|
||||||
command-line controls remain available.
|
|
||||||
|
|
||||||
From a Hydra shell, a direct installer URL can become a running VM in one
|
|
||||||
command. Replace the example URL with an actual ISO download link:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
hydra run 'https://example.org/distribution.iso' --name work --memory 4096 --cpus 2
|
|
||||||
hydra open work
|
|
||||||
```
|
|
||||||
|
|
||||||
`run` also accepts a local ISO path. It refuses to replace an existing VM name;
|
|
||||||
use `open` for subsequent starts. Add `--no-view` for a headless launch. A
|
|
||||||
download-page URL returning HTML is rejected. Downloads show progress, can be
|
|
||||||
cancelled with Ctrl+C, and become reusable cache entries only after validation.
|
|
||||||
Supply the publisher's SHA-256 when available:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
hydra download 'https://example.org/distribution.iso' --sha256 PUBLISHER_SHA256
|
|
||||||
```
|
|
||||||
|
|
||||||
The cache lives under `$XDG_CACHE_HOME/hydra/iso` (normally
|
|
||||||
`~/.cache/hydra/iso`). Without a publisher checksum, ISO format validation
|
|
||||||
does not establish publisher authenticity. New launches through this workflow
|
|
||||||
check available RAM with a 768 MiB host reserve; this cannot guarantee against
|
|
||||||
later memory pressure. In a nested test, leave RAM available on the outer host
|
|
||||||
too. The original lower-level commands remain useful for manual control:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
hydra add work --iso /absolute/path/ubuntu.iso --memory 8192 --cpus 4
|
|
||||||
hydra start work
|
|
||||||
hydra add experiments --iso /absolute/path/gentoo.iso
|
|
||||||
hydra start experiments
|
|
||||||
exit
|
|
||||||
```
|
|
||||||
|
|
||||||
Back in the terminal menu, select the new guest to open its screen.
|
|
||||||
Guests remain running while you switch, reconnect viewers or leave the host
|
|
||||||
graphical session. Shut a guest down from inside its OS, or request an orderly
|
|
||||||
shutdown with `hydra shutdown NAME`. Check `hydra status NAME` before detaching
|
|
||||||
its installer with `hydra media NAME --detach` and starting it again.
|
|
||||||
|
|
||||||
| Shortcut | Action |
|
|
||||||
| --- | --- |
|
|
||||||
| Ctrl+Alt+Shift+Left / Right | Switch guest workspaces |
|
|
||||||
| Ctrl+Alt+Shift+Escape | Open the terminal VM menu |
|
|
||||||
| Ctrl+Alt+Shift+R | Release viewer input capture |
|
|
||||||
| Ctrl+Alt+Shift+U | Choose a USB device to redirect |
|
|
||||||
| Brightness keys | Adjust host backlight |
|
|
||||||
|
|
||||||
New guests share `hydrabr0` (`10.203.0.0/24`) and have distinct persistent MAC
|
|
||||||
addresses. Services can communicate between guests without per-port forwarding;
|
|
||||||
access from your LAN requires a router route through the Hydra OS host. Each
|
|
||||||
guest controls its own services and firewall. Host Internet access is separate
|
|
||||||
from this local guest network.
|
|
||||||
|
|
||||||
Profiles live in `~/.config/hydra`, disks in `~/.local/state/hydra`; system defaults
|
|
||||||
for new profiles are in `/etc/hydra/defaults.toml`. Use `hydra --help` for overrides.
|
|
||||||
Other host desktops can use `hydra start NAME` and `hydra view NAME --windowed`
|
|
||||||
without entering the minimal labwc session. Ordinary Gentoo administration and
|
|
||||||
Portage remain available through the recovery shell.
|
|
||||||
|
|
||||||
Fastfetch displays the **Hydra OS** system summary and its custom terminal
|
|
||||||
logo. It is included in the new image; the first published ISO predates this
|
|
||||||
interface update. See [terminal usage](docs/terminal-usage.md) for networking,
|
|
||||||
installation media and common recovery steps.
|
|
||||||
|
|
||||||
## Build the ISO
|
|
||||||
|
|
||||||
Follow the [isolated Gentoo build instructions](image/README.md). Outputs are
|
|
||||||
`build/artifacts/hydra-os.iso` and `hydra-os.iso.sha256`; binary images stay out
|
|
||||||
of Git. [Recorded source inputs and package versions](docs/releases/2026-10-13-laptop-ready/rootfs-manifest.json)
|
|
||||||
identify the tested image. The builder uses Gentoo systemd for login/device
|
|
||||||
management, labwc for the minimal graphical session, and QEMU/KVM for guests.
|
|
||||||
Portage and the tracked QEMU/viewer patches remain available for ordinary updates.
|
|
||||||
|
|
||||||
Resume a build from the same source checkout and work directory used to create
|
|
||||||
its builder. Moving from a worktree to another checkout requires a fresh
|
|
||||||
`--work` directory under the project, or using the original worktree; provenance
|
|
||||||
checks deliberately reject an existing container mounted to a different source.
|
|
||||||
|
|
||||||
## Run the prototype
|
|
||||||
|
|
||||||
```sh
|
|
||||||
python3 -m unittest discover -s tests -v
|
|
||||||
nix-shell dev/tools.nix
|
|
||||||
bin/hydra add guest --iso /absolute/path/installer.iso
|
|
||||||
bin/hydra start guest
|
|
||||||
bin/hydra view guest
|
|
||||||
# From a dedicated graphical login; --nested uses the current graphical display.
|
|
||||||
bin/hydra session --nested
|
|
||||||
```
|
|
||||||
|
|
||||||
After installation, shut the guest down and run `bin/hydra media guest --detach`
|
|
||||||
to boot its existing disk without installation media. `add` also supports disk-only
|
|
||||||
profiles, `--width`, `--height`, and `--refresh-millihz`. Distribution preferences
|
|
||||||
in `/etc/hydra/defaults.toml` affect only new profiles; explicit CLI options win.
|
|
||||||
|
|
||||||
`nix-shell dev/tools.nix --run 'python3 dev/smoke.py'` checks two disposable KVM
|
|
||||||
counter guests and private headless viewers, including manual USB selection,
|
|
||||||
reserved switching under inhibition, recovery and reconnect. See
|
|
||||||
[verification commands and physical checks still pending](docs/verification.md).
|
|
||||||
|
|
||||||
An empty session opens local recovery. Enter `:shell`, create and start guests
|
|
||||||
with `hydra add` / `hydra start`, then leave the shell and enter `:refresh`.
|
|
||||||
Refresh rebuilds workspaces and reconnects viewers for the current profiles;
|
|
||||||
running VMs keep their processes and disks. Use it after adding or removing
|
|
||||||
profiles. New profiles save an individual MAC address; legacy profiles obtain
|
|
||||||
a stable address from their owner and disk path, including on the shared bridge.
|
|
||||||
|
|||||||
@@ -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,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,8 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
source_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
||||||
build_dir=$(mktemp -d /tmp/hydra-input-build-XXXXXX)
|
|
||||||
wayland-scanner client-header "$source_dir/virtual-keyboard-unstable-v1.xml" "$build_dir/virtual-keyboard-client-protocol.h"
|
|
||||||
wayland-scanner private-code "$source_dir/virtual-keyboard-unstable-v1.xml" "$build_dir/virtual-keyboard-protocol.c"
|
|
||||||
cc -Wall -Wextra -Werror "$source_dir/input-probe.c" "$build_dir/virtual-keyboard-protocol.c" -I"$build_dir" $(pkg-config --cflags --libs wayland-client xkbcommon) -o "$build_dir/input-probe"
|
|
||||||
printf '%s\n' "$build_dir/input-probe"
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Compile actual QEMU EDID code and decode the guest-visible response.
|
|
||||||
|
|
||||||
Catches loss of the configured refresh rate, a frontend overriding it, and
|
|
||||||
monitor ranges excluding the generated preferred mode. No VM is touched.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import tarfile
|
|
||||||
|
|
||||||
source_root = Path(sys.argv[1])
|
|
||||||
# Accept the pinned QEMU source archive directly. Read only the four files
|
|
||||||
# needed here, avoiding unrelated upstream absolute symlinks in rom sources.
|
|
||||||
if source_root.is_file():
|
|
||||||
extracted = tempfile.TemporaryDirectory(prefix='hydra-edid-source-')
|
|
||||||
with tarfile.open(source_root) as archive:
|
|
||||||
members = {name: next(member for member in archive.getmembers()
|
|
||||||
if member.name.endswith('/' + name)) for name in (
|
|
||||||
'hw/display/edid-generate.c', 'include/hw/display/edid.h',
|
|
||||||
'include/hw/virtio/virtio-gpu.h', 'hw/display/virtio-gpu-base.c')}
|
|
||||||
source_root = Path(extracted.name)
|
|
||||||
for name, member in members.items():
|
|
||||||
destination = source_root / name
|
|
||||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
destination.write_bytes(archive.extractfile(member).read())
|
|
||||||
subprocess.run(['patch', '--batch', '-p1', '-i',
|
|
||||||
str(Path(__file__).resolve().parents[1] / 'patches/qemu-virtio-refresh-rate.patch')],
|
|
||||||
cwd=source_root, check=True)
|
|
||||||
edid = (source_root / 'hw/display/edid-generate.c').read_text()
|
|
||||||
edid = '\n'.join(line for line in edid.splitlines() if not line.startswith('#include'))
|
|
||||||
header = (source_root / 'include/hw/display/edid.h').read_text()
|
|
||||||
header = header[header.index('typedef struct qemu_edid_info'):header.index('void qemu_edid_region_io')]
|
|
||||||
gpu = (source_root / 'hw/display/virtio-gpu-base.c').read_text()
|
|
||||||
start = gpu.index('void\nvirtio_gpu_base_generate_edid')
|
|
||||||
gpu = gpu[start:gpu.index('static void virtio_gpu_invalidate_display', start)]
|
|
||||||
stubs = r'''
|
|
||||||
#include <stdint.h>
|
|
||||||
#include <stddef.h>
|
|
||||||
#include <string.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <stdio.h>
|
|
||||||
#define ARRAY_SIZE(x) (sizeof(x)/sizeof((x)[0]))
|
|
||||||
#define MAX(a,b) ((a) > (b) ? (a) : (b))
|
|
||||||
#define MIN(a,b) ((a) < (b) ? (a) : (b))
|
|
||||||
#define DIV_ROUND_UP(n,d) (((n) + (d) - 1) / (d))
|
|
||||||
#define cpu_to_le32(x) (x)
|
|
||||||
static void stw_le_p(void *p,uint16_t v) { uint8_t *b=p; b[0]=v; b[1]=v>>8; }
|
|
||||||
static void stw_be_p(void *p,uint16_t v) { uint8_t *b=p; b[1]=v; b[0]=v>>8; }
|
|
||||||
static void stl_le_p(void *p,uint32_t v) { uint8_t *b=p; for(int i=0;i<4;i++) b[i]=v>>(8*i); }
|
|
||||||
typedef struct VirtIOGPUOutput { const char *name; } VirtIOGPUOutput;
|
|
||||||
typedef struct VirtIOGPUOutputList { struct VirtIOGPUOutputList *next; VirtIOGPUOutput *value; } VirtIOGPUOutputList;
|
|
||||||
struct virtio_gpu_requested_state { uint16_t width_mm,height_mm; uint32_t width,height,refresh_rate; int x,y; };
|
|
||||||
typedef struct VirtIOGPUBase { struct { uint32_t refresh_rate; VirtIOGPUOutputList *outputs; } conf; struct virtio_gpu_requested_state req_state[1]; } VirtIOGPUBase;
|
|
||||||
struct virtio_gpu_resp_edid { uint32_t size; uint8_t edid[1024]; };
|
|
||||||
'''
|
|
||||||
main = r'''
|
|
||||||
int main(int argc,char **argv) {
|
|
||||||
VirtIOGPUBase gpu = {0}; struct virtio_gpu_resp_edid out = {0};
|
|
||||||
gpu.conf.refresh_rate=atoi(argv[1]); gpu.req_state[0].refresh_rate=atoi(argv[2]);
|
|
||||||
gpu.req_state[0].width=atoi(argv[3]); gpu.req_state[0].height=atoi(argv[4]);
|
|
||||||
virtio_gpu_base_generate_edid(&gpu,0,&out);
|
|
||||||
fwrite(out.edid,1,qemu_edid_size(out.edid),stdout); return 0;
|
|
||||||
}
|
|
||||||
'''
|
|
||||||
compiler = os.environ.get('CC', 'cc')
|
|
||||||
with tempfile.TemporaryDirectory(prefix='qemu-edid-probe-') as temporary:
|
|
||||||
directory = Path(temporary)
|
|
||||||
(directory / 'probe.c').write_text(stubs + header + edid + gpu + main)
|
|
||||||
subprocess.run([compiler, '-O2', str(directory / 'probe.c'), '-o', str(directory / 'probe')], check=True)
|
|
||||||
for configured, frontend, width, height, expected in [
|
|
||||||
(0, 0, 1920, 1080, 75),
|
|
||||||
(0, 60000, 1920, 1080, 60),
|
|
||||||
(144000, 0, 1920, 1080, 144),
|
|
||||||
(144000, 60000, 1920, 1080, 144),
|
|
||||||
(144000, 0, 1600, 900, 144),
|
|
||||||
(0, 144000, 1920, 1080, 144),
|
|
||||||
(50000, 0, 1920, 1080, 50),
|
|
||||||
(255000, 0, 1280, 800, 255),
|
|
||||||
]:
|
|
||||||
data = subprocess.check_output([str(directory / 'probe'), *map(str, (configured, frontend, width, height))])
|
|
||||||
assert all(sum(data[offset:offset + 128]) % 256 == 0 for offset in range(0, len(data), 128)), 'invalid EDID checksum'
|
|
||||||
timing = data[54:72]
|
|
||||||
clock = int.from_bytes(timing[:2], 'little') * 10000
|
|
||||||
horizontal = timing[2] + ((timing[4] >> 4) << 8)
|
|
||||||
hblank = timing[3] + ((timing[4] & 15) << 8)
|
|
||||||
vertical = timing[5] + ((timing[7] >> 4) << 8)
|
|
||||||
vblank = timing[6] + ((timing[7] & 15) << 8)
|
|
||||||
actual = clock / (horizontal + hblank) / (vertical + vblank)
|
|
||||||
assert (horizontal, vertical) == (width, height)
|
|
||||||
assert abs(actual - expected) < 0.01, f'configured={configured}, frontend={frontend}: wanted {expected} Hz, got {actual}'
|
|
||||||
ranges = next(data[offset:offset + 18] for offset in (54, 72, 90, 108) if data[offset:offset + 5] == b'\0\0\0\xfd\0')
|
|
||||||
assert ranges[5] <= actual <= ranges[6], f'vertical range {ranges[5:7]} excludes {actual}'
|
|
||||||
horizontal_khz = clock / (horizontal + hblank) / 1000
|
|
||||||
assert ranges[7] <= horizontal_khz <= ranges[8], f'horizontal range {ranges[7:9]} excludes {horizontal_khz}'
|
|
||||||
if configured == 0 and expected <= 75:
|
|
||||||
assert ranges[5:9] == bytes((50, 125, 30, 160)), 'legacy monitor range changed'
|
|
||||||
print(f'PASS configured={configured}, frontend={frontend}: {width}x{height}@{actual:.6f}, ranges={list(ranges[5:9])}')
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Check real NIC identities on a bridge isolated in user/net/mount namespaces."""
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
||||||
from hydra.profiles import Profile
|
|
||||||
from hydra.runtime import Runtime
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument('--namespace-root', type=Path, help=argparse.SUPPRESS)
|
|
||||||
args = parser.parse_args()
|
|
||||||
if args.namespace_root is None:
|
|
||||||
root = Path(tempfile.mkdtemp(prefix='hydra-network-', dir='/tmp'))
|
|
||||||
print('Evidence: ' + str(root), flush=True)
|
|
||||||
(root / 'parent-namespaces.json').write_text(json.dumps({name: str(Path('/proc/self/ns', name).readlink())
|
|
||||||
for name in ('user', 'net', 'mnt')}))
|
|
||||||
# All bridge and /etc changes below occur after this explicit namespace
|
|
||||||
# boundary. The inherited host bridge configuration is never modified.
|
|
||||||
subprocess.run(['unshare', '-Urnm', '--', str(Path(sys.executable).resolve()),
|
|
||||||
str(Path(__file__).resolve()), '--namespace-root', str(root)], check=True)
|
|
||||||
return
|
|
||||||
root = args.namespace_root
|
|
||||||
parent_namespaces = json.loads((root / 'parent-namespaces.json').read_text())
|
|
||||||
namespaces = {name: str(Path('/proc/self/ns', name).readlink()) for name in ('user', 'net', 'mnt')}
|
|
||||||
assert all(namespaces[name] != parent_namespaces[name] for name in namespaces), 'private namespaces required'
|
|
||||||
private_etc = root / 'etc'
|
|
||||||
(private_etc / 'qemu').mkdir(parents=True)
|
|
||||||
(private_etc / 'qemu/bridge.conf').write_text('allow hydrabr0\n')
|
|
||||||
subprocess.run(['mount', '--bind', str(private_etc), '/etc'], check=True)
|
|
||||||
subprocess.run(['ip', 'link', 'add', 'hydrabr0', 'type', 'bridge'], check=True)
|
|
||||||
subprocess.run(['ip', 'link', 'set', 'hydrabr0', 'up'], check=True)
|
|
||||||
manager = Runtime(root / 'config', root / 'state', root / 'run')
|
|
||||||
result = {'evidence': str(root), 'namespace': 'private user/net/mount',
|
|
||||||
'parent_namespaces': parent_namespaces, 'probe_namespaces': namespaces, 'names': {}}
|
|
||||||
try:
|
|
||||||
for name in ('alpha', 'beta'):
|
|
||||||
manager.add(Profile(name, memory=128, cpus=1, disk_size='8M',
|
|
||||||
graphics='software', network='bridge'))
|
|
||||||
manager.start(name)
|
|
||||||
|
|
||||||
def actual_mac(name):
|
|
||||||
with manager._connect(manager.paths(name)) as qmp:
|
|
||||||
network = qmp.command('human-monitor-command', {'command-line': 'info network'})
|
|
||||||
mac = manager.profile(name).mac
|
|
||||||
assert 'macaddr=' + mac in network, network
|
|
||||||
assert int(mac.split(':')[0], 16) & 3 == 2
|
|
||||||
return mac, network
|
|
||||||
|
|
||||||
for name in ('alpha', 'beta'):
|
|
||||||
mac, network = actual_mac(name)
|
|
||||||
result['names'][name] = {'mac': mac, 'network': network}
|
|
||||||
assert result['names']['alpha']['mac'] != result['names']['beta']['mac']
|
|
||||||
links = json.loads(subprocess.check_output(['ip', '-j', 'link', 'show', 'master', 'hydrabr0'], text=True))
|
|
||||||
assert len(links) == 2, links
|
|
||||||
result['bridge_ports'] = [link['ifname'] for link in links]
|
|
||||||
manager.stop('alpha', force=True)
|
|
||||||
manager.start('alpha')
|
|
||||||
assert actual_mac('alpha')[0] == result['names']['alpha']['mac']
|
|
||||||
result['restart_preserved_mac'] = True
|
|
||||||
result['dhcp_and_guest_network_drivers_pending'] = True
|
|
||||||
result['passed'] = True
|
|
||||||
(root / 'result.json').write_text(json.dumps(result, indent=2) + '\n')
|
|
||||||
print(json.dumps(result, indent=2))
|
|
||||||
finally:
|
|
||||||
for profile in manager.profiles():
|
|
||||||
if manager.status(profile.name)['status'] == 'running':
|
|
||||||
manager.stop(profile.name, force=True)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
.code16
|
|
||||||
.global _start
|
|
||||||
_start:
|
|
||||||
cli
|
|
||||||
xor %ax,%ax
|
|
||||||
mov %ax,%ds
|
|
||||||
mov %ax,%ss
|
|
||||||
mov $0x7c00,%sp
|
|
||||||
movl $0x48594452,0x508
|
|
||||||
movl $0,0x500
|
|
||||||
movw $0,0x504
|
|
||||||
loop:
|
|
||||||
incl 0x500
|
|
||||||
in $0x64,%al
|
|
||||||
test $1,%al
|
|
||||||
jz loop
|
|
||||||
in $0x60,%al
|
|
||||||
mov 0x504,%bx
|
|
||||||
mov %al,0x600(%bx)
|
|
||||||
inc %bx
|
|
||||||
and $255,%bx
|
|
||||||
mov %bx,0x504
|
|
||||||
jmp loop
|
|
||||||
.org 510
|
|
||||||
.word 0xaa55
|
|
||||||
@@ -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())
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
/* Private-display integration helper; stdin uses Linux evdev key codes.
|
|
||||||
* Compile via dev/build-input-probe.sh inside the temporary tools shell. */
|
|
||||||
#define _GNU_SOURCE
|
|
||||||
#include <assert.h>
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <string.h>
|
|
||||||
#include <sys/mman.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
#include <wayland-client.h>
|
|
||||||
#include <xkbcommon/xkbcommon.h>
|
|
||||||
#include "virtual-keyboard-client-protocol.h"
|
|
||||||
static struct wl_seat *seat;
|
|
||||||
static struct zwp_virtual_keyboard_manager_v1 *manager;
|
|
||||||
static void global(void *data, struct wl_registry *registry, uint32_t id,
|
|
||||||
const char *interface, uint32_t version) {
|
|
||||||
(void)data; (void)version;
|
|
||||||
if (!strcmp(interface,"wl_seat"))
|
|
||||||
seat = wl_registry_bind(registry,id,&wl_seat_interface,1);
|
|
||||||
else if (!strcmp(interface,"zwp_virtual_keyboard_manager_v1"))
|
|
||||||
manager = wl_registry_bind(registry,id,&zwp_virtual_keyboard_manager_v1_interface,1);
|
|
||||||
}
|
|
||||||
static void removed(void *data,struct wl_registry *registry,uint32_t id) {
|
|
||||||
(void)data; (void)registry; (void)id;
|
|
||||||
}
|
|
||||||
int main(void) {
|
|
||||||
/* The caller supplies the separately created scratch Wayland runtime. */
|
|
||||||
assert(getenv("WAYLAND_DISPLAY") && getenv("XDG_RUNTIME_DIR"));
|
|
||||||
struct wl_display *display = wl_display_connect(NULL); assert(display);
|
|
||||||
struct wl_registry *registry = wl_display_get_registry(display);
|
|
||||||
struct wl_registry_listener listener={global,removed};
|
|
||||||
wl_registry_add_listener(registry,&listener,NULL); wl_display_roundtrip(display);
|
|
||||||
assert(seat && manager);
|
|
||||||
struct zwp_virtual_keyboard_v1 *keyboard = zwp_virtual_keyboard_manager_v1_create_virtual_keyboard(manager,seat);
|
|
||||||
struct xkb_context *context=xkb_context_new(XKB_CONTEXT_NO_FLAGS);
|
|
||||||
struct xkb_rule_names names={.rules="evdev",.model="pc105",.layout="us"};
|
|
||||||
struct xkb_keymap *map=xkb_keymap_new_from_names(context,&names,XKB_KEYMAP_COMPILE_NO_FLAGS); assert(map);
|
|
||||||
struct xkb_state *state=xkb_state_new(map); assert(state);
|
|
||||||
char *text=xkb_keymap_get_as_string(map,XKB_KEYMAP_FORMAT_TEXT_V1);
|
|
||||||
size_t size=strlen(text)+1;
|
|
||||||
int fd=memfd_create("hydra-private-keymap",0); assert(fd>=0);
|
|
||||||
assert(write(fd,text,size)==(ssize_t)size);
|
|
||||||
zwp_virtual_keyboard_v1_keymap(keyboard,WL_KEYBOARD_KEYMAP_FORMAT_XKB_V1,fd,size);
|
|
||||||
wl_display_roundtrip(display); close(fd); free(text);
|
|
||||||
char command[16]; unsigned key; unsigned tick=0;
|
|
||||||
while (scanf("%15s %u",command,&key)==2) {
|
|
||||||
if (!strcmp(command,"wait")) { usleep(key*1000); continue; }
|
|
||||||
unsigned down=!strcmp(command,"down");
|
|
||||||
assert(down || !strcmp(command,"up"));
|
|
||||||
xkb_state_update_key(state,key+8,down?XKB_KEY_DOWN:XKB_KEY_UP);
|
|
||||||
zwp_virtual_keyboard_v1_key(keyboard,++tick,key,down);
|
|
||||||
zwp_virtual_keyboard_v1_modifiers(keyboard,
|
|
||||||
xkb_state_serialize_mods(state,XKB_STATE_MODS_DEPRESSED),
|
|
||||||
xkb_state_serialize_mods(state,XKB_STATE_MODS_LATCHED),
|
|
||||||
xkb_state_serialize_mods(state,XKB_STATE_MODS_LOCKED),
|
|
||||||
xkb_state_serialize_layout(state,XKB_STATE_LAYOUT_EFFECTIVE));
|
|
||||||
assert(wl_display_roundtrip(display)>=0);
|
|
||||||
}
|
|
||||||
zwp_virtual_keyboard_v1_destroy(keyboard); wl_display_roundtrip(display);
|
|
||||||
xkb_state_unref(state); xkb_keymap_unref(map); xkb_context_unref(context);
|
|
||||||
wl_display_disconnect(display);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
+4
-275
@@ -308,265 +308,6 @@ def lifecycle_guest_script():
|
|||||||
'\n'.join(inspect.getsource(helper) for helper in helpers) + LIFECYCLE_BODY)
|
'\n'.join(inspect.getsource(helper) for helper in helpers) + LIFECYCLE_BODY)
|
||||||
|
|
||||||
|
|
||||||
def terminal_ux_iso():
|
|
||||||
"""An empty ISO9660 filesystem, intentionally without an El Torito boot image."""
|
|
||||||
def both(value, width):
|
|
||||||
return value.to_bytes(width, 'little') + value.to_bytes(width, 'big')
|
|
||||||
|
|
||||||
def directory_record(identifier):
|
|
||||||
record = bytearray(34)
|
|
||||||
record[0] = 34
|
|
||||||
record[2:10] = both(20, 4)
|
|
||||||
record[10:18] = both(2048, 4)
|
|
||||||
record[18:25] = bytes((126, 10, 13, 0, 0, 0, 0))
|
|
||||||
record[25] = 2
|
|
||||||
record[28:32] = both(1, 2)
|
|
||||||
record[32] = 1
|
|
||||||
record[33] = identifier
|
|
||||||
return record
|
|
||||||
|
|
||||||
data = bytearray(21 * 2048)
|
|
||||||
primary = bytearray(2048)
|
|
||||||
primary[:7] = b'\x01CD001\x01'
|
|
||||||
primary[8:40] = b'HYDRA OS'.ljust(32, b' ')
|
|
||||||
primary[40:72] = b'HYDRA_UX_PROBE'.ljust(32, b' ')
|
|
||||||
primary[80:88] = both(21, 4)
|
|
||||||
primary[120:124] = both(1, 2)
|
|
||||||
primary[124:128] = both(1, 2)
|
|
||||||
primary[128:132] = both(2048, 2)
|
|
||||||
primary[132:140] = both(10, 4)
|
|
||||||
primary[140:144] = (18).to_bytes(4, 'little')
|
|
||||||
primary[148:152] = (19).to_bytes(4, 'big')
|
|
||||||
primary[156:190] = directory_record(0)
|
|
||||||
primary[190:813] = b' ' * 623
|
|
||||||
primary[813:830] = b'2026101300000000\x00'
|
|
||||||
primary[830:847] = b'2026101300000000\x00'
|
|
||||||
primary[847:881] = b'0000000000000000\x00' * 2
|
|
||||||
primary[881] = 1
|
|
||||||
data[16 * 2048:17 * 2048] = primary
|
|
||||||
data[17 * 2048:17 * 2048 + 7] = b'\xffCD001\x01'
|
|
||||||
for sector, order in ((18, 'little'), (19, 'big')):
|
|
||||||
data[sector * 2048:sector * 2048 + 10] = (
|
|
||||||
b'\x01\x00' + (20).to_bytes(4, order) + (1).to_bytes(2, order) + b'\x00\x00')
|
|
||||||
data[20 * 2048:20 * 2048 + 68] = directory_record(0) + directory_record(1)
|
|
||||||
return bytes(data)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_terminal_ux_branding(output, errors, logo):
|
|
||||||
ansi = r'\x1b\[[0-?]*[ -/]*[@-~]'
|
|
||||||
rendered = re.sub(ansi, '', output)
|
|
||||||
if errors.strip() or re.search(r'\b(error|failed|unknown module)\b', rendered, re.I):
|
|
||||||
raise SmokeError('Installed default Fastfetch emitted errors: ' + (errors or rendered)[-1500:])
|
|
||||||
if not re.search(r'\bOS\s*:\s*Hydra OS\b', rendered):
|
|
||||||
raise SmokeError('Installed default Fastfetch did not render the Hydra OS pretty name')
|
|
||||||
expected = [line.rstrip() for line in re.sub(r'\$[1-9]|' + ansi, '', logo).splitlines()
|
|
||||||
if line.strip()]
|
|
||||||
caption = 'H Y D R A O S'
|
|
||||||
if (not any(line.strip() == caption for line in expected)
|
|
||||||
or not any(line.strip() != caption for line in expected)):
|
|
||||||
raise SmokeError('Packaged Fastfetch logo must contain art and the Hydra OS caption')
|
|
||||||
width = max(map(len, expected))
|
|
||||||
lines = iter(rendered.splitlines())
|
|
||||||
for row in expected:
|
|
||||||
if not any(line[:width].rstrip() == row
|
|
||||||
and (len(line) <= width or line[width].isspace()) for line in lines):
|
|
||||||
raise SmokeError('Installed default Fastfetch did not render the complete packaged Hydra logo')
|
|
||||||
return rendered
|
|
||||||
|
|
||||||
|
|
||||||
def terminal_ux_snapshot(context):
|
|
||||||
"""Verify CLI-owned live process/QMP identity, excluding OS boot or audio claims."""
|
|
||||||
try:
|
|
||||||
if context['uid'] != os.getuid():
|
|
||||||
raise SmokeError('Terminal UX process is not owned by the installed normal user')
|
|
||||||
runtime = Path(context['runtime'])
|
|
||||||
run = runtime / context['name']
|
|
||||||
for directory in (runtime, run):
|
|
||||||
info = directory.lstat()
|
|
||||||
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != context['uid']
|
|
||||||
or stat.S_IMODE(info.st_mode) != 0o700):
|
|
||||||
raise SmokeError('Terminal UX runtime is not private and owned')
|
|
||||||
if json.loads((run / 'identity.json').read_text()) != {
|
|
||||||
'name': context['name'], 'uuid': context['uuid']}:
|
|
||||||
raise SmokeError('Terminal UX metadata belongs to a different VM')
|
|
||||||
pid = int((run / 'qemu.pid').read_text())
|
|
||||||
process = Path('/proc') / str(pid)
|
|
||||||
fields = (process / 'stat').read_text().rsplit(')', 1)[1].split()
|
|
||||||
if process.stat().st_uid != context['uid'] or fields[0] == 'Z':
|
|
||||||
raise SmokeError('Terminal UX VM process exited or changed owner')
|
|
||||||
argv = (process / 'cmdline').read_bytes().split(b'\0')
|
|
||||||
for option, expected in ((b'-name', context['name']), (b'-uuid', context['uuid'])):
|
|
||||||
if argv[argv.index(option) + 1] != expected.encode():
|
|
||||||
raise SmokeError('Terminal UX PID belongs to a different VM')
|
|
||||||
lifecycle_socket(run / 'qmp.sock', connect_socket=False)
|
|
||||||
lifecycle_socket(run / 'spice.sock')
|
|
||||||
with QMPClient(run / 'qmp.sock', timeout=2) as client:
|
|
||||||
if (client.command('query-name') != {'name': context['name']}
|
|
||||||
or client.command('query-uuid') != {'UUID': context['uuid']}):
|
|
||||||
raise SmokeError('Terminal UX QMP belongs to a different VM')
|
|
||||||
if client.command('query-status').get('status') != 'running':
|
|
||||||
raise SmokeError('Terminal UX VM is not running')
|
|
||||||
if not client.command('query-kvm').get('enabled'):
|
|
||||||
raise SmokeError('Terminal UX VM did not initialise nested KVM')
|
|
||||||
return {'name': context['name'], 'uuid': context['uuid'], 'pid': pid,
|
|
||||||
'process_start': fields[19], 'kvm': True}
|
|
||||||
except (OSError, ValueError, KeyError, IndexError, QMPError) as exc:
|
|
||||||
raise SmokeError('Terminal UX cannot establish owned live identity: ' + str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
TERMINAL_UX_BODY = r'''
|
|
||||||
if os.getuid() == 0 or subprocess.check_output(['id', '-un'], text=True).strip() != 'hydratest':
|
|
||||||
raise SmokeError('Terminal UX probe must run as the installed normal hydratest user')
|
|
||||||
if not os.access('/dev/kvm', os.R_OK | os.W_OK):
|
|
||||||
raise SmokeError('Terminal UX needs installed-user nested KVM access')
|
|
||||||
if not Path('/usr/bin/hydra').is_file():
|
|
||||||
raise SmokeError('Installed product launcher /usr/bin/hydra is missing')
|
|
||||||
fetch = subprocess.run(['fastfetch', '--pipe', 'false', '--show-errors'],
|
|
||||||
capture_output=True, text=True, timeout=30)
|
|
||||||
if fetch.returncode:
|
|
||||||
raise SmokeError('Installed default Fastfetch failed: ' + fetch.stderr[-1500:])
|
|
||||||
fastfetch_output = verify_terminal_ux_branding(
|
|
||||||
fetch.stdout, fetch.stderr, Path('/usr/share/hydra/fastfetch.txt').read_text())
|
|
||||||
config_hash = hashlib.sha256(Path('/etc/fastfetch/config.jsonc').read_bytes()).hexdigest()
|
|
||||||
logo_hash = hashlib.sha256(Path('/usr/share/hydra/fastfetch.txt').read_bytes()).hexdigest()
|
|
||||||
base = Path(tempfile.mkdtemp(prefix='hux-', dir='/tmp'))
|
|
||||||
name = 'ux-' + secrets.token_hex(4)
|
|
||||||
context = {'name': name, 'uid': os.getuid(), 'config': str(base / 'config'),
|
|
||||||
'state': str(base / 'state'), 'runtime': str(base / 'run')}
|
|
||||||
context['uuid'] = str(uuid.uuid5(uuid.NAMESPACE_URL, str(base / 'state' / name / 'disk.qcow2')))
|
|
||||||
environment = dict(os.environ, XDG_CACHE_HOME=str(base / 'cache'))
|
|
||||||
environment.pop('HYDRA_SESSION_MANIFEST', None)
|
|
||||||
command = ['/usr/bin/hydra', '--config', context['config'], '--state', context['state'],
|
|
||||||
'--runtime', context['runtime']]
|
|
||||||
|
|
||||||
def cli(*arguments, input=None):
|
|
||||||
result = subprocess.run(command + list(arguments), input=input, env=environment,
|
|
||||||
capture_output=True, text=True, timeout=60)
|
|
||||||
if result.returncode:
|
|
||||||
raise SmokeError('Installed hydra ' + arguments[0] + ' failed: ' +
|
|
||||||
(result.stdout + result.stderr)[-2000:])
|
|
||||||
return result.stdout
|
|
||||||
|
|
||||||
def cli_json(*arguments):
|
|
||||||
return json.loads(cli(*arguments))
|
|
||||||
|
|
||||||
fixture = base / 'fixture.iso'
|
|
||||||
fixture.write_bytes(terminal_ux_iso())
|
|
||||||
iso_hash = hashlib.sha256(fixture.read_bytes()).hexdigest()
|
|
||||||
requests = []
|
|
||||||
class ISOHandler(http.server.BaseHTTPRequestHandler):
|
|
||||||
def do_GET(self):
|
|
||||||
requests.append(self.path)
|
|
||||||
if self.path != '/fixture.iso':
|
|
||||||
self.send_error(404)
|
|
||||||
return
|
|
||||||
payload = fixture.read_bytes()
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header('Content-Type', 'application/x-iso9660-image')
|
|
||||||
self.send_header('Content-Length', str(len(payload)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(payload)
|
|
||||||
def log_message(self, *args):
|
|
||||||
pass
|
|
||||||
server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), ISOHandler)
|
|
||||||
worker = threading.Thread(target=server.serve_forever, daemon=True)
|
|
||||||
worker.start()
|
|
||||||
url = 'http://127.0.0.1:' + str(server.server_port) + '/fixture.iso'
|
|
||||||
result = None
|
|
||||||
try:
|
|
||||||
if cli_json('list'):
|
|
||||||
raise SmokeError('Terminal UX private configuration was not initially empty')
|
|
||||||
empty_menu = cli('menu', input=':quit\n')
|
|
||||||
if ('Hydra OS' not in empty_menu or 'No VM profiles' not in empty_menu
|
|
||||||
or list(base.glob('run/*/qemu.pid')) or cli_json('list')):
|
|
||||||
raise SmokeError('Installed empty menu was unreadable or started a VM')
|
|
||||||
run_output = cli('run', url, '--name', name, '--memory', '512', '--cpus', '1',
|
|
||||||
'--disk-size', '64M', '--sha256', iso_hash, '--no-view')
|
|
||||||
if requests != ['/fixture.iso']:
|
|
||||||
raise SmokeError('Installed hydra run did not download exactly its loopback ISO')
|
|
||||||
before = terminal_ux_snapshot(context)
|
|
||||||
profiles = cli_json('list')
|
|
||||||
if len(profiles) != 1 or profiles[0]['name'] != name:
|
|
||||||
raise SmokeError('Installed hydra run did not create exactly the requested private profile')
|
|
||||||
cached_iso = Path(profiles[0]['iso'])
|
|
||||||
cache = base / 'cache' / 'hydra' / 'iso'
|
|
||||||
info = cached_iso.lstat()
|
|
||||||
if (cached_iso.parent != cache or not stat.S_ISREG(info.st_mode)
|
|
||||||
or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600
|
|
||||||
or hashlib.sha256(cached_iso.read_bytes()).hexdigest() != iso_hash
|
|
||||||
or list(cache.glob('*.part'))):
|
|
||||||
raise SmokeError('Installed hydra run cache is unsafe, incomplete or has a different checksum')
|
|
||||||
profile = tomllib.loads((base / 'config' / (name + '.toml')).read_text())
|
|
||||||
if (profile['memory'], profile['cpus'], profile['disk_size']) != (512, 1, '64M'):
|
|
||||||
raise SmokeError('Installed hydra run lost its explicit allocations')
|
|
||||||
downloaded = cli('download', url, '--sha256', iso_hash)
|
|
||||||
if (Path(downloaded.splitlines()[-1]) != cached_iso or requests != ['/fixture.iso']
|
|
||||||
or hashlib.sha256(cached_iso.read_bytes()).hexdigest() != iso_hash):
|
|
||||||
raise SmokeError('Installed hydra download did not reuse the complete cached ISO')
|
|
||||||
cli('open', name, '--no-view')
|
|
||||||
after_open = terminal_ux_snapshot(context)
|
|
||||||
if before != after_open:
|
|
||||||
raise SmokeError('Installed hydra open restarted or replaced its running VM')
|
|
||||||
menu = cli('menu', input=':quit\n')
|
|
||||||
after_menu = terminal_ux_snapshot(context)
|
|
||||||
if ('Hydra OS' not in menu or name not in menu or 'running' not in menu
|
|
||||||
or '512 MiB' not in menu or '"status":' in menu or before != after_menu):
|
|
||||||
raise SmokeError('Installed running menu was unreadable or changed VM lifetime')
|
|
||||||
result = {'status': 'passed', 'uid': os.getuid(), 'normal_user': 'hydratest',
|
|
||||||
'launcher': '/usr/bin/hydra', 'fixture_bytes': fixture.stat().st_size,
|
|
||||||
'iso_sha256': iso_hash, 'cache_reused': True, 'http_requests': requests,
|
|
||||||
'before': before, 'after_open': after_open, 'after_menu': after_menu,
|
|
||||||
'memory_mib': profile['memory'], 'cpus': profile['cpus'],
|
|
||||||
'disk_size': profile['disk_size'], 'network': profile['network'],
|
|
||||||
'empty_menu': empty_menu, 'running_menu': menu, 'run_output': run_output,
|
|
||||||
'download_output': downloaded, 'fastfetch_command': ['fastfetch', '--pipe', 'false', '--show-errors'],
|
|
||||||
'fastfetch_default_config_sha256': config_hash, 'fastfetch_logo_sha256': logo_hash,
|
|
||||||
'fastfetch_output': fastfetch_output,
|
|
||||||
'scope': 'Packaged installed CLI, cache, branding and live nested QMP/KVM; nonboot ISO fixture, no nested OS boot or performance claim'}
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
status = cli_json('status', name).get('status')
|
|
||||||
if status == 'running':
|
|
||||||
# Never signal a saved PID: verify the exact private live identity
|
|
||||||
# and let the product CLI perform its own identity-checked QMP quit.
|
|
||||||
terminal_ux_snapshot(context)
|
|
||||||
if cli_json('stop', name, '--force').get('status') != 'stopped':
|
|
||||||
raise SmokeError('Owned terminal UX fixture VM did not stop')
|
|
||||||
elif status != 'stopped':
|
|
||||||
raise SmokeError('Refusing terminal UX cleanup of an unverified VM identity at ' + str(base))
|
|
||||||
finally:
|
|
||||||
server.shutdown()
|
|
||||||
server.server_close()
|
|
||||||
worker.join(timeout=5)
|
|
||||||
if result is not None:
|
|
||||||
result['cleanup'] = 'Only the verified private fixture VM stopped through hydra stop --force'
|
|
||||||
print(json.dumps(result))
|
|
||||||
shutil.rmtree(base)
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
def terminal_ux_guest_script():
|
|
||||||
helpers = (lifecycle_socket, terminal_ux_iso, verify_terminal_ux_branding, terminal_ux_snapshot)
|
|
||||||
return ('import hashlib, http.server, json, os, re, secrets, shutil, socket, stat, subprocess, sys, tempfile, threading, tomllib, uuid\n'
|
|
||||||
'from pathlib import Path\nsys.path.insert(0, "/usr/lib/hydra")\n'
|
|
||||||
'from hydra.qmp import QMPClient, QMPError\n'
|
|
||||||
'class SmokeError(RuntimeError): pass\n' +
|
|
||||||
'\n'.join(inspect.getsource(helper) for helper in helpers) + TERMINAL_UX_BODY)
|
|
||||||
|
|
||||||
|
|
||||||
def installed_terminal_ux_probe(console):
|
|
||||||
preparation = ("python3 - <<'HYDRA_TERMINAL_UX_SOURCE'\n" + terminal_ux_guest_script() +
|
|
||||||
'\nHYDRA_TERMINAL_UX_SOURCE')
|
|
||||||
output = console.command(preparation, 180)
|
|
||||||
try:
|
|
||||||
result = json.loads(output)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise SmokeError('Installed terminal UX did not return framed JSON evidence') from exc
|
|
||||||
if result.get('status') != 'passed':
|
|
||||||
raise SmokeError('Installed terminal UX did not verify its packaged CLI')
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
def installed_admin(console, password):
|
def installed_admin(console, password):
|
||||||
console.buffer = b''
|
console.buffer = b''
|
||||||
prompt_encoded = base64.b64encode(b'HYDRA_SUDO_PASSWORD: ').decode()
|
prompt_encoded = base64.b64encode(b'HYDRA_SUDO_PASSWORD: ').decode()
|
||||||
@@ -635,7 +376,7 @@ def qemu_path(path):
|
|||||||
return str(path)
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
def qemu_argv(qemu, scratch, disk, code, variables, iso, memory, *, keep_cdrom_drive=False):
|
def qemu_argv(qemu, scratch, disk, code, variables, iso, memory):
|
||||||
scratch = Path(scratch)
|
scratch = Path(scratch)
|
||||||
owned_file(disk, scratch)
|
owned_file(disk, scratch)
|
||||||
owned_file(variables, scratch)
|
owned_file(variables, scratch)
|
||||||
@@ -650,18 +391,8 @@ def qemu_argv(qemu, scratch, disk, code, variables, iso, memory, *, keep_cdrom_d
|
|||||||
if iso is not None:
|
if iso is not None:
|
||||||
if not Path(iso).is_file():
|
if not Path(iso).is_file():
|
||||||
raise SmokeError('Trusted ISO must exist as a regular file')
|
raise SmokeError('Trusted ISO must exist as a regular file')
|
||||||
if keep_cdrom_drive:
|
argv += ['-drive', f'file={qemu_path(iso)},media=cdrom,format=raw,readonly=on',
|
||||||
# Keep SATA0 stable so a saved DVD boot option has the same device path
|
'-boot', 'order=d,strict=on']
|
||||||
# when media returns. OVMF may still prune the empty drive's boot entry.
|
|
||||||
# No file= means there is no optical medium.
|
|
||||||
optical = 'if=ide,index=0,id=acceptance-cdrom,media=cdrom,readonly=on'
|
|
||||||
if iso is not None:
|
|
||||||
optical += ',format=raw,file=' + qemu_path(iso)
|
|
||||||
argv += ['-drive', optical]
|
|
||||||
elif iso is not None:
|
|
||||||
argv += ['-drive', f'file={qemu_path(iso)},media=cdrom,format=raw,readonly=on']
|
|
||||||
if iso is not None:
|
|
||||||
argv += ['-boot', 'order=d,strict=on']
|
|
||||||
else:
|
else:
|
||||||
argv += ['-boot', 'order=c,strict=on']
|
argv += ['-boot', 'order=c,strict=on']
|
||||||
return argv
|
return argv
|
||||||
@@ -826,8 +557,7 @@ class VM:
|
|||||||
(scratch / name).unlink(missing_ok=True)
|
(scratch / name).unlink(missing_ok=True)
|
||||||
self.stderr = open(scratch / f'{phase}-qemu.log', 'ab', buffering=0)
|
self.stderr = open(scratch / f'{phase}-qemu.log', 'ab', buffering=0)
|
||||||
self.process = subprocess.Popen(qemu_argv(args.qemu, scratch, disk,
|
self.process = subprocess.Popen(qemu_argv(args.qemu, scratch, disk,
|
||||||
args.ovmf_code, scratch / 'OVMF_VARS.fd', iso, args.memory,
|
args.ovmf_code, scratch / 'OVMF_VARS.fd', iso, args.memory),
|
||||||
keep_cdrom_drive=getattr(args, 'keep_cdrom_drive', False)),
|
|
||||||
stdin=subprocess.DEVNULL, stdout=self.stderr, stderr=self.stderr)
|
stdin=subprocess.DEVNULL, stdout=self.stderr, stderr=self.stderr)
|
||||||
self.console = None
|
self.console = None
|
||||||
self.qmp = None
|
self.qmp = None
|
||||||
@@ -1145,7 +875,6 @@ def main(argv=None):
|
|||||||
if output.strip() != b'hydratest':
|
if output.strip() != b'hydratest':
|
||||||
raise SmokeError('Installed account identity mismatch')
|
raise SmokeError('Installed account identity mismatch')
|
||||||
checkpoint('installed_password_login', output)
|
checkpoint('installed_password_login', output)
|
||||||
checkpoint('installed_terminal_ux', installed_terminal_ux_probe(console))
|
|
||||||
console.buffer = b''
|
console.buffer = b''
|
||||||
prompt_encoded = base64.b64encode(b'HYDRA_SUDO_PASSWORD: ').decode()
|
prompt_encoded = base64.b64encode(b'HYDRA_SUDO_PASSWORD: ').decode()
|
||||||
console.send(f'sudo -p "$(printf %s {prompt_encoded} | base64 -d)" -i\n')
|
console.send(f'sudo -p "$(printf %s {prompt_encoded} | base64 -d)" -i\n')
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Exercise the product's empty-session/recovery/refresh loop privately."""
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import shutil
|
|
||||||
import signal
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
||||||
from hydra.runtime import Runtime
|
|
||||||
from smoke import (wait_for, counter, focus_events, fresh_focus_transition,
|
|
||||||
measure_hidden_interval, classify_compositor_errors)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument('--labwc', default=os.environ.get('HYDRA_LABWC', 'labwc'))
|
|
||||||
parser.add_argument('--viewer', default=os.environ.get('HYDRA_VIEWER', 'remote-viewer'))
|
|
||||||
parser.add_argument('--keyboard', required=True, help='compiled dev/input-probe.c')
|
|
||||||
args = parser.parse_args()
|
|
||||||
for name in ('labwc', 'viewer', 'keyboard'):
|
|
||||||
executable = shutil.which(getattr(args, name))
|
|
||||||
if not executable:
|
|
||||||
parser.error('missing executable: ' + getattr(args, name))
|
|
||||||
setattr(args, name, executable)
|
|
||||||
if not os.access('/dev/kvm', os.R_OK | os.W_OK):
|
|
||||||
parser.error('KVM access required')
|
|
||||||
source = Path(__file__).resolve().parents[1]
|
|
||||||
root = Path(tempfile.mkdtemp(prefix='hydra-session-refresh-', dir='/tmp'))
|
|
||||||
print('Evidence: ' + str(root), flush=True)
|
|
||||||
xdg = root / 'xdg'; xdg.mkdir(mode=0o700)
|
|
||||||
manager = Runtime(root / 'config', root / 'state', root / 'run')
|
|
||||||
cli = [sys.executable, str(source / 'bin/hydra'), '--config', str(manager.config),
|
|
||||||
'--state', str(manager.state), '--runtime', str(manager.runtime)]
|
|
||||||
subprocess.run(['as', '--32', str(source / 'dev/guest-probe.S'), '-o', str(root / 'boot.o')], check=True)
|
|
||||||
subprocess.run(['objcopy', '-j', '.text', '-O', 'binary', str(root / 'boot.o'), str(root / 'boot.bin')], check=True)
|
|
||||||
(root / 'boot.raw').write_bytes((root / 'boot.bin').read_bytes() + bytes(8 * 1024 * 1024 - 512))
|
|
||||||
|
|
||||||
def wrapper(name, code):
|
|
||||||
path = root / name
|
|
||||||
path.write_text('#!' + sys.executable + '\n' + code)
|
|
||||||
path.chmod(0o700)
|
|
||||||
return str(path)
|
|
||||||
|
|
||||||
common = 'import json, os, pathlib, subprocess, sys, time\nroot = pathlib.Path(' + repr(str(root)) + ')\n'
|
|
||||||
labwc = wrapper('labwc-private', common + '''
|
|
||||||
if '--version' in sys.argv:
|
|
||||||
os.execv(@LABWC@, [@LABWC@] + sys.argv[1:])
|
|
||||||
config = pathlib.Path(sys.argv[sys.argv.index('-C') + 1])
|
|
||||||
number = len(list(root.glob('snapshot-*.json')))
|
|
||||||
data = json.loads((config.parent / 'session.json').read_text())
|
|
||||||
data.update(pid=os.getpid(), config_directory=str(config))
|
|
||||||
(root / ('snapshot-%d.xml' % number)).write_text((config / 'rc.xml').read_text())
|
|
||||||
(root / ('snapshot-%d.json' % number)).write_text(json.dumps(data))
|
|
||||||
# Test-only wrapper replaces physical backends before the real compositor runs.
|
|
||||||
os.environ.update(WLR_BACKENDS='headless', WLR_RENDERER='pixman',
|
|
||||||
WLR_XWAYLAND='/bin/false', LABWC_UPDATE_ACTIVATION_ENV='0',
|
|
||||||
XDG_DATA_DIRS='/nonexistent')
|
|
||||||
os.execv(@LABWC@, [@LABWC@, '-d'] + sys.argv[1:])
|
|
||||||
'''.replace('@LABWC@', repr(args.labwc)))
|
|
||||||
viewer = wrapper('viewer-private', common + '''
|
|
||||||
if '--help' in sys.argv:
|
|
||||||
os.execv(VIEWER, [VIEWER] + sys.argv[1:])
|
|
||||||
name = sys.argv[sys.argv.index('--title') + 1].split(': ', 1)[1]
|
|
||||||
(root / (name + '-viewer.json')).write_text(json.dumps({
|
|
||||||
'argv': sys.argv[1:], 'config': os.environ['XDG_CONFIG_HOME'],
|
|
||||||
'gdk_backend': os.environ.get('GDK_BACKEND'), 'wayland_display': os.environ['WAYLAND_DISPLAY']}))
|
|
||||||
log = os.open(root / (name + '.log'), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
||||||
os.dup2(log, 1); os.dup2(log, 2)
|
|
||||||
os.environ['WAYLAND_DEBUG'] = '1'
|
|
||||||
os.execv(VIEWER, [VIEWER] + sys.argv[1:])
|
|
||||||
'''.replace('VIEWER', repr(args.viewer)))
|
|
||||||
terminal = wrapper('terminal-private', common + '''
|
|
||||||
manifest = pathlib.Path(sys.argv[-1])
|
|
||||||
socket = pathlib.Path(os.environ['XDG_RUNTIME_DIR']) / os.environ['WAYLAND_DISPLAY']
|
|
||||||
(root / 'recovery-ready').write_text(str(manifest))
|
|
||||||
while not (root / 'recovery-input').exists():
|
|
||||||
if not socket.exists():
|
|
||||||
sys.exit(0)
|
|
||||||
time.sleep(.05)
|
|
||||||
if not socket.exists():
|
|
||||||
sys.exit(0)
|
|
||||||
commands = (root / 'recovery-input').read_text()
|
|
||||||
# The actual product recovery helper executes :shell and :refresh.
|
|
||||||
with (root / 'recovery.log').open('a') as log:
|
|
||||||
sys.exit(subprocess.run(sys.argv[2:], input=commands, text=True, stdout=log, stderr=log).returncode)
|
|
||||||
''')
|
|
||||||
shell = wrapper('scratch-shell', common + 'sys.path.insert(0, ' + repr(str(source)) + ')\n' + '''
|
|
||||||
from hydra.runtime import Runtime
|
|
||||||
from smoke import counter, wait_for
|
|
||||||
cli = CLI
|
|
||||||
manager = Runtime(root / 'config', root / 'state', root / 'run')
|
|
||||||
for name in ('alpha', 'beta'):
|
|
||||||
subprocess.run(cli + ['add', name, '--memory', '128', '--cpus', '1', '--disk-size', '8M',
|
|
||||||
'--graphics', 'software', '--network', 'user'], check=True)
|
|
||||||
paths = manager.paths(name); manager.prepare(paths)
|
|
||||||
subprocess.run([os.environ['HYDRA_QEMU_IMG'], 'convert', '-f', 'raw', '-O', 'qcow2',
|
|
||||||
str(root / 'boot.raw'), str(paths.disk)], check=True)
|
|
||||||
subprocess.run(cli + ['start', name], check=True)
|
|
||||||
time.sleep(.3)
|
|
||||||
identities = {name: {'pid': manager.paths(name).pid.read_text().strip(),
|
|
||||||
'uuid': manager.paths(name).uuid, 'counter': counter(manager, name)}
|
|
||||||
for name in ('alpha', 'beta')}
|
|
||||||
(root / 'before.json').write_text(json.dumps(identities))
|
|
||||||
'''.replace('CLI', repr(cli)).replace('from smoke import', 'from dev.smoke import'))
|
|
||||||
env = dict(os.environ, XDG_RUNTIME_DIR=str(xdg), HYDRA_LABWC=labwc,
|
|
||||||
HYDRA_VIEWER=viewer, HYDRA_TERMINAL=terminal, HYDRA_BRIGHTNESS=shutil.which('true'),
|
|
||||||
SHELL=shell, GSETTINGS_BACKEND='memory')
|
|
||||||
for key in ('WAYLAND_DISPLAY', 'DISPLAY', 'LABWC_PID'):
|
|
||||||
env.pop(key, None)
|
|
||||||
log = (root / 'session.log').open('w')
|
|
||||||
launcher, injector = None, None
|
|
||||||
result = {'evidence': str(root), 'physical_input_pending': True,
|
|
||||||
'terminal': 'test-only command feeder; real recovery helper and scratch :shell',
|
|
||||||
'backend': 'test-only wrapper around actual product launch_session and actual labwc'}
|
|
||||||
try:
|
|
||||||
launcher = subprocess.Popen(cli + ['session'], env=env, stdout=log, stderr=log)
|
|
||||||
wait_for(lambda: (root / 'recovery-ready').exists(), 'empty session did not open recovery')
|
|
||||||
first = json.loads((root / 'snapshot-0.json').read_text())
|
|
||||||
assert first['names'] == [], 'session was not initially empty'
|
|
||||||
(root / 'recovery-input').write_text(':shell\n:refresh\n')
|
|
||||||
wait_for(lambda: (root / 'snapshot-1.json').exists(), 'product loop did not restart after :refresh', timeout=15)
|
|
||||||
second = json.loads((root / 'snapshot-1.json').read_text())
|
|
||||||
assert second['names'] == ['alpha', 'beta'], second
|
|
||||||
assert first['pid'] != second['pid'], 'compositor was not refreshed'
|
|
||||||
logs = {name: root / (name + '.log') for name in ('alpha', 'beta')}
|
|
||||||
for name, path in logs.items():
|
|
||||||
wait_for(lambda: path.exists() and 'set_title("Hydra OS: ' + name + '")' in path.read_text(),
|
|
||||||
name + ' product viewer did not connect')
|
|
||||||
viewers = {name: json.loads((root / (name + '-viewer.json')).read_text()) for name in logs}
|
|
||||||
assert len({data['config'] for data in viewers.values()}) == 2, 'viewer settings were shared'
|
|
||||||
for data in viewers.values():
|
|
||||||
assert data['gdk_backend'] == 'wayland'
|
|
||||||
assert data['config'].startswith(str(manager.runtime) + '/viewer-')
|
|
||||||
assert '--spice-usbredir-auto-redirect-filter=-1,-1,-1,-1,0' in data['argv']
|
|
||||||
assert '--spice-usbredir-redirect-on-connect=-1,-1,-1,-1,0' in data['argv']
|
|
||||||
alog, blog = logs['alpha'], logs['beta']
|
|
||||||
display = next(p.name for p in xdg.glob('wayland-*') if not p.name.endswith('.lock'))
|
|
||||||
# Exercise a real menu selection of the second, already running VM.
|
|
||||||
# Its request refreshes only the private compositor/viewers.
|
|
||||||
before_beta_focus = len(focus_events(blog))
|
|
||||||
manifest = Path(second['config_directory']).parent / 'session.json'
|
|
||||||
with (root / 'selection.log').open('w') as selection_log:
|
|
||||||
selection = subprocess.run(cli + ['_session-helper', 'recover', str(manifest)],
|
|
||||||
input='beta\n', text=True,
|
|
||||||
env=dict(env, WAYLAND_DISPLAY=display),
|
|
||||||
stdout=selection_log, stderr=selection_log, timeout=20)
|
|
||||||
assert selection.returncode == 0, 'menu selection failed'
|
|
||||||
wait_for(lambda: (root / 'snapshot-2.json').exists(), 'selected menu did not refresh compositor', timeout=15)
|
|
||||||
third = json.loads((root / 'snapshot-2.json').read_text())
|
|
||||||
from xml.etree import ElementTree as ET
|
|
||||||
assert ET.fromstring((root / 'snapshot-2.xml').read_text()).findtext('./desktops/initial') == '2'
|
|
||||||
wait_for(lambda: any(p.name.startswith('wayland-') and not p.name.endswith('.lock')
|
|
||||||
for p in xdg.iterdir()), 'refreshed private display is not ready')
|
|
||||||
display = next(p.name for p in xdg.glob('wayland-*') if not p.name.endswith('.lock'))
|
|
||||||
keyboard_env = dict(env, WAYLAND_DISPLAY=display)
|
|
||||||
injector = subprocess.Popen([args.keyboard], env=keyboard_env, stdin=subprocess.PIPE, text=True)
|
|
||||||
time.sleep(.2)
|
|
||||||
wait_for(lambda: len(focus_events(blog)) > before_beta_focus and focus_events(blog)[-1] == 'enter',
|
|
||||||
'second VM did not gain fresh focus after menu selection')
|
|
||||||
|
|
||||||
def switch(code, source_log, target_log):
|
|
||||||
before_source, before_target = focus_events(source_log), focus_events(target_log)
|
|
||||||
injector.stdin.write(f'down 29\ndown 56\ndown 42\ndown {code}\nup {code}\nup 42\nup 56\nup 29\nwait 100\n')
|
|
||||||
injector.stdin.flush()
|
|
||||||
wait_for(lambda: fresh_focus_transition(before_source, before_target,
|
|
||||||
focus_events(source_log), focus_events(target_log)), 'fresh reserved focus transition failed')
|
|
||||||
|
|
||||||
switch(105, blog, alog)
|
|
||||||
intervals = {'beta': measure_hidden_interval(lambda: counter(manager, 'beta'), blog, alog)}
|
|
||||||
switch(106, alog, blog)
|
|
||||||
intervals['alpha'] = measure_hidden_interval(lambda: counter(manager, 'alpha'), alog, blog)
|
|
||||||
before = json.loads((root / 'before.json').read_text())
|
|
||||||
after = {name: {'pid': manager.paths(name).pid.read_text().strip(),
|
|
||||||
'uuid': manager.paths(name).uuid, 'counter': counter(manager, name)}
|
|
||||||
for name in ('alpha', 'beta')}
|
|
||||||
for name in before:
|
|
||||||
assert after[name]['pid'] == before[name]['pid'] and after[name]['uuid'] == before[name]['uuid']
|
|
||||||
assert after[name]['counter'] != before[name]['counter']
|
|
||||||
# Only the PID recorded by our own wrapper, verified against its private
|
|
||||||
# config path, may be signalled. Never inherit a host compositor PID.
|
|
||||||
command = Path('/proc') / str(third['pid']) / 'cmdline'
|
|
||||||
assert third['config_directory'].encode() in command.read_bytes()
|
|
||||||
os.kill(third['pid'], signal.SIGTERM)
|
|
||||||
assert launcher.wait(timeout=10) == 0, 'product session exit failed'
|
|
||||||
for name in before:
|
|
||||||
assert manager.status(name)['status'] == 'running'
|
|
||||||
assert manager.paths(name).pid.read_text().strip() == before[name]['pid']
|
|
||||||
result.update(initial_names=first['names'], refreshed_names=second['names'],
|
|
||||||
compositor_pids=[first['pid'], second['pid'], third['pid']],
|
|
||||||
selected_name='beta', selected_workspace=2, before=before, after=after,
|
|
||||||
viewers=viewers,
|
|
||||||
hidden_intervals=intervals, survived_session_exit=True,
|
|
||||||
expected_xwayland_diagnostics=classify_compositor_errors((root / 'session.log').read_text()),
|
|
||||||
passed=True)
|
|
||||||
(root / 'result.json').write_text(json.dumps(result, indent=2) + '\n')
|
|
||||||
print(json.dumps(result, indent=2))
|
|
||||||
finally:
|
|
||||||
if injector and injector.poll() is None:
|
|
||||||
injector.terminate(); injector.wait(timeout=5)
|
|
||||||
if launcher and launcher.poll() is None:
|
|
||||||
launcher.send_signal(signal.SIGINT); launcher.wait(timeout=12)
|
|
||||||
for profile in manager.profiles():
|
|
||||||
if manager.status(profile.name)['status'] == 'running':
|
|
||||||
manager.stop(profile.name, force=True)
|
|
||||||
log.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
-273
@@ -1,273 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Two disposable KVM guests on a private headless display; never host input."""
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
from xml.etree import ElementTree as ET
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
||||||
from hydra.profiles import Profile
|
|
||||||
from hydra.runtime import Runtime
|
|
||||||
from hydra.session import build_labwc_config, build_viewer_argv, _viewer_environment
|
|
||||||
|
|
||||||
|
|
||||||
def wait_for(predicate, description, timeout=8):
|
|
||||||
deadline = time.monotonic() + timeout
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
if predicate():
|
|
||||||
return
|
|
||||||
time.sleep(.05)
|
|
||||||
raise AssertionError(description)
|
|
||||||
|
|
||||||
|
|
||||||
def counter(manager, name):
|
|
||||||
with manager._connect(manager.paths(name)) as qmp:
|
|
||||||
reply = qmp.command('human-monitor-command', {'command-line': 'xp /3wx 0x500'})
|
|
||||||
values = [int(word, 16) for word in re.findall(r'0x([0-9a-fA-F]+)', reply)]
|
|
||||||
assert len(values) == 3 and values[2] == 0x48594452, 'scratch guest did not boot'
|
|
||||||
return values[0]
|
|
||||||
|
|
||||||
|
|
||||||
def focus_events(log):
|
|
||||||
"""Only keyboard focus, excluding pointer-enter and old window titles."""
|
|
||||||
return re.findall(r'wl_keyboard#\d+\.(enter|leave)\(', log.read_text())
|
|
||||||
|
|
||||||
|
|
||||||
def fresh_focus_transition(source_before, target_before, source_after, target_after):
|
|
||||||
return (len(source_after) > len(source_before) and source_after[-1] == 'leave'
|
|
||||||
and len(target_after) > len(target_before) and target_after[-1] == 'enter')
|
|
||||||
|
|
||||||
|
|
||||||
def measure_hidden_interval(read_counter, hidden_log, visible_log, duration=.5):
|
|
||||||
hidden_focus, visible_focus = focus_events(hidden_log), focus_events(visible_log)
|
|
||||||
assert hidden_focus and hidden_focus[-1] == 'leave', 'sampled guest is not hidden'
|
|
||||||
assert visible_focus and visible_focus[-1] == 'enter', 'other guest is not focused'
|
|
||||||
|
|
||||||
def unchanged():
|
|
||||||
assert (focus_events(hidden_log) == hidden_focus and
|
|
||||||
focus_events(visible_log) == visible_focus), 'focus changed during hidden interval'
|
|
||||||
|
|
||||||
start = time.monotonic()
|
|
||||||
before = read_counter()
|
|
||||||
unchanged()
|
|
||||||
while time.monotonic() - start < duration:
|
|
||||||
time.sleep(min(.05, duration))
|
|
||||||
unchanged()
|
|
||||||
after = read_counter()
|
|
||||||
unchanged()
|
|
||||||
assert after != before, 'hidden guest stopped working during continuously hidden interval'
|
|
||||||
return {'before': before, 'after': after,
|
|
||||||
'elapsed_seconds': time.monotonic() - start, 'focus_unchanged': True}
|
|
||||||
|
|
||||||
|
|
||||||
def classify_compositor_errors(log):
|
|
||||||
expected = []
|
|
||||||
for line in log.splitlines():
|
|
||||||
if '[ERROR]' not in line:
|
|
||||||
continue
|
|
||||||
if re.search(r'\[xwayland/sockets\.c:\d+\] Failed to bind socket '
|
|
||||||
r'@?/tmp/\.X11-unix/X\d+: Address already in use$', line):
|
|
||||||
expected.append(line)
|
|
||||||
else:
|
|
||||||
raise AssertionError('Unexpected compositor error: ' + line)
|
|
||||||
return expected
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
cli = argparse.ArgumentParser(description=__doc__)
|
|
||||||
cli.add_argument('--viewer', default=os.environ.get('HYDRA_VIEWER', 'remote-viewer'))
|
|
||||||
cli.add_argument('--labwc', default=os.environ.get('HYDRA_LABWC', 'labwc'))
|
|
||||||
cli.add_argument('--keyboard', help='compiled dev/input-probe.c; built automatically when omitted')
|
|
||||||
args = cli.parse_args()
|
|
||||||
for name in ('viewer', 'labwc'):
|
|
||||||
value = shutil.which(getattr(args, name))
|
|
||||||
if not value:
|
|
||||||
cli.error(f'missing {name}: {getattr(args, name)}')
|
|
||||||
setattr(args, name, value)
|
|
||||||
source = Path(__file__).resolve().parent
|
|
||||||
if not args.keyboard:
|
|
||||||
args.keyboard = subprocess.check_output([str(source / 'build-input-probe.sh')], text=True).strip()
|
|
||||||
if not os.access('/dev/kvm', os.R_OK | os.W_OK):
|
|
||||||
cli.error('KVM access required for this integration check')
|
|
||||||
root = Path(tempfile.mkdtemp(prefix='hydra-smoke-', dir='/tmp'))
|
|
||||||
print(f'Evidence: {root}', flush=True)
|
|
||||||
xdg = root / 'xdg'
|
|
||||||
xdg.mkdir(mode=0o700)
|
|
||||||
config = root / 'labwc'
|
|
||||||
config.mkdir()
|
|
||||||
recovery = root / 'recovery'
|
|
||||||
unreserved = root / 'unreserved'
|
|
||||||
names = ['alpha', 'beta']
|
|
||||||
rc = ET.fromstring(build_labwc_config(names, [sys.executable, '-c',
|
|
||||||
'from pathlib import Path; Path(' + repr(str(recovery)) + ').touch()'], initial='alpha'))
|
|
||||||
# Test-only controls prove native labwc inhibition is active; production
|
|
||||||
# config has no ToggleKeybinds or ordinary host shortcut.
|
|
||||||
keyboard = rc.find('keyboard')
|
|
||||||
binding = ET.SubElement(keyboard, 'keybind', key='C-A-S-i')
|
|
||||||
ET.SubElement(binding, 'action', name='ToggleKeybinds')
|
|
||||||
binding = ET.SubElement(keyboard, 'keybind', key='F12')
|
|
||||||
ET.SubElement(binding, 'action', name='Execute', command=shutil.which('touch') + ' ' + str(unreserved))
|
|
||||||
ET.ElementTree(rc).write(config / 'rc.xml')
|
|
||||||
(config / 'autostart').write_text('#!/bin/sh\n')
|
|
||||||
(config / 'environment').touch()
|
|
||||||
env = dict(os.environ, XDG_RUNTIME_DIR=str(xdg), WLR_BACKENDS='headless',
|
|
||||||
WLR_RENDERER='pixman', WLR_XWAYLAND='/bin/false', XDG_DATA_DIRS='/nonexistent',
|
|
||||||
GSETTINGS_BACKEND='memory', LABWC_UPDATE_ACTIVATION_ENV='0')
|
|
||||||
for key in ('DISPLAY', 'WAYLAND_DISPLAY'):
|
|
||||||
env.pop(key, None)
|
|
||||||
manager = Runtime(root / 'config', root / 'state', root / 'run')
|
|
||||||
boot_object, boot = root / 'boot.o', root / 'boot.bin'
|
|
||||||
subprocess.run(['as', '--32', str(source / 'guest-probe.S'), '-o', str(boot_object)], check=True)
|
|
||||||
subprocess.run(['objcopy', '-j', '.text', '-O', 'binary', str(boot_object), str(boot)], check=True)
|
|
||||||
raw = root / 'boot.raw'
|
|
||||||
raw.write_bytes(boot.read_bytes() + bytes(8 * 1024 * 1024 - 512))
|
|
||||||
media = root / 'installation.iso'
|
|
||||||
media.touch()
|
|
||||||
for name in names:
|
|
||||||
manager.add(Profile(name, media if name == 'alpha' else None, memory=128, cpus=1, disk_size='8M', graphics='software',
|
|
||||||
width=1920, height=1080, refresh_millihz=144000))
|
|
||||||
paths = manager.paths(name)
|
|
||||||
manager.prepare(paths)
|
|
||||||
subprocess.run([os.environ.get('HYDRA_QEMU_IMG', 'qemu-img'), 'convert', '-f', 'raw',
|
|
||||||
'-O', 'qcow2', str(raw), str(paths.disk)], check=True)
|
|
||||||
processes, streams = [], []
|
|
||||||
result = {'evidence': str(root), 'physical_checks_pending': True}
|
|
||||||
try:
|
|
||||||
cl = (root / 'labwc.log').open('w'); streams.append(cl)
|
|
||||||
compositor = subprocess.Popen([args.labwc, '-d', '-C', str(config)], env=env, stdout=cl, stderr=cl)
|
|
||||||
processes.append(compositor)
|
|
||||||
wait_for(lambda: any(not p.name.endswith('.lock') for p in xdg.glob('wayland-*')), 'private display did not start')
|
|
||||||
env['WAYLAND_DISPLAY'] = next(p.name for p in xdg.glob('wayland-*') if not p.name.endswith('.lock'))
|
|
||||||
injector = subprocess.Popen([args.keyboard], env=env, stdin=subprocess.PIPE, text=True)
|
|
||||||
processes.append(injector)
|
|
||||||
|
|
||||||
def keys(commands):
|
|
||||||
injector.stdin.write(commands + '\nwait 100\n'); injector.stdin.flush()
|
|
||||||
time.sleep(.15)
|
|
||||||
assert injector.poll() is None, 'private input helper exited'
|
|
||||||
|
|
||||||
def chord(code):
|
|
||||||
keys(f'down 29\ndown 56\ndown 42\ndown {code}\nup {code}\nup 42\nup 56\nup 29')
|
|
||||||
|
|
||||||
def switch(key, source_log, target_log):
|
|
||||||
source_before, target_before = focus_events(source_log), focus_events(target_log)
|
|
||||||
chord(key)
|
|
||||||
wait_for(lambda: fresh_focus_transition(source_before, target_before,
|
|
||||||
focus_events(source_log), focus_events(target_log)),
|
|
||||||
'reserved switch did not produce fresh keyboard leave/enter')
|
|
||||||
return {'from_events': len(focus_events(source_log)),
|
|
||||||
'to_events': len(focus_events(target_log)), 'keycode': key}
|
|
||||||
|
|
||||||
def attach(name, number):
|
|
||||||
vl = (root / f'{name}-{number}.log').open('w'); streams.append(vl)
|
|
||||||
previous = dict(os.environ)
|
|
||||||
try:
|
|
||||||
os.environ.update(env)
|
|
||||||
viewer_env = _viewer_environment(root / f'{name}-{number}-settings')
|
|
||||||
finally:
|
|
||||||
os.environ.clear(); os.environ.update(previous)
|
|
||||||
viewer_env['WAYLAND_DEBUG'] = '1'
|
|
||||||
proc = subprocess.Popen(build_viewer_argv(manager.paths(name), args.viewer),
|
|
||||||
env=viewer_env, stdout=vl, stderr=vl)
|
|
||||||
processes.append(proc)
|
|
||||||
log = root / f'{name}-{number}.log'
|
|
||||||
wait_for(lambda: f'set_title("Hydra OS: {name}")' in log.read_text(), f'{name} viewer did not connect')
|
|
||||||
return proc, log
|
|
||||||
|
|
||||||
for name in names:
|
|
||||||
manager.start(name)
|
|
||||||
media.unlink() # Open QEMU media disappears; status/control/view still work.
|
|
||||||
time.sleep(1)
|
|
||||||
before = {name: counter(manager, name) for name in names}
|
|
||||||
with manager._connect(manager.paths('alpha')) as qmp:
|
|
||||||
children = qmp.command('qom-list', {'path': '/machine/peripheral-anon'})
|
|
||||||
video = next(child['name'] for child in children if child['type'] == 'child<virtio-vga>')
|
|
||||||
mode = {prop: qmp.command('qom-get', {'path': '/machine/peripheral-anon/' + video, 'property': prop})
|
|
||||||
for prop in ('xres', 'yres', 'refresh_rate')}
|
|
||||||
assert mode == {'xres': 1920, 'yres': 1080, 'refresh_rate': 144000}, mode
|
|
||||||
usb_before = qmp.command('human-monitor-command', {'command-line': 'info usb'})
|
|
||||||
identities = {name: manager.paths(name).pid.read_text().strip() for name in names}
|
|
||||||
alpha, alog = attach('alpha', 1)
|
|
||||||
beta, blog = attach('beta', 1)
|
|
||||||
wait_for(lambda: '.enter(' in alog.read_text(), 'alpha did not receive focus')
|
|
||||||
# F12 must work before inhibition; then become suppressed in alpha.
|
|
||||||
keys('down 88\nup 88')
|
|
||||||
wait_for(unreserved.exists, 'ordinary test binding failed before inhibition')
|
|
||||||
unreserved.unlink()
|
|
||||||
chord(23) # I: test-only ToggleKeybinds.
|
|
||||||
keys('down 88\nup 88')
|
|
||||||
assert not unreserved.exists(), 'keybinding inhibition did not suppress ordinary F12'
|
|
||||||
switch(106, alog, blog) # Right: beta focused, alpha continuously hidden.
|
|
||||||
hidden_intervals = {'alpha': measure_hidden_interval(lambda: counter(manager, 'alpha'), alog, blog)}
|
|
||||||
switch(105, blog, alog) # Left: alpha focused, beta continuously hidden.
|
|
||||||
hidden_intervals['beta'] = measure_hidden_interval(lambda: counter(manager, 'beta'), blog, alog)
|
|
||||||
chord(1) # Escape: production recovery binding still works.
|
|
||||||
wait_for(recovery.exists, 'reserved recovery failed with inhibition')
|
|
||||||
dialog = 'set_title("Select USB devices for redirection")'
|
|
||||||
assert dialog not in alog.read_text(), 'USB selector opened automatically'
|
|
||||||
# Hold modifiers through opening, then release inside the modal dialog.
|
|
||||||
keys('down 29\ndown 56\ndown 42\ndown 22')
|
|
||||||
wait_for(lambda: dialog in alog.read_text(), 'manual USB selector did not open', timeout=3)
|
|
||||||
keys('up 22\nup 42\nup 56\nup 29')
|
|
||||||
keys('down 1\nup 1') # Close selector.
|
|
||||||
post_usb_switches = [switch(106, alog, blog), switch(105, blog, alog)]
|
|
||||||
assert alpha.poll() is None and beta.poll() is None
|
|
||||||
assert identities == {name: manager.paths(name).pid.read_text().strip() for name in names}, 'switch restarted a guest'
|
|
||||||
assert alog.read_text().count('set_title("Hydra OS: alpha")') == 1, 'switch reconnected alpha'
|
|
||||||
assert blog.read_text().count('set_title("Hydra OS: beta")') == 1, 'switch reconnected beta'
|
|
||||||
with manager._connect(manager.paths('alpha')) as qmp:
|
|
||||||
usb_after = qmp.command('human-monitor-command', {'command-line': 'info usb'})
|
|
||||||
assert usb_after == usb_before, 'opening selector attached an unselected USB device'
|
|
||||||
alpha.terminate(); alpha.wait(timeout=5)
|
|
||||||
assert manager.status('alpha')['status'] == 'running'
|
|
||||||
alpha, attached = attach('alpha', 2)
|
|
||||||
after = {name: counter(manager, name) for name in names}
|
|
||||||
assert all(after[name] != before[name] for name in names), 'background guest stopped working'
|
|
||||||
# Quit only beta; alpha's counter/lifecycle remains independent.
|
|
||||||
manager.stop('beta', force=True)
|
|
||||||
assert manager.status('alpha')['status'] == 'running'
|
|
||||||
assert counter(manager, 'alpha') != after['alpha']
|
|
||||||
compositor.terminate(); compositor.wait(timeout=5)
|
|
||||||
assert manager.status('alpha')['status'] == 'running'
|
|
||||||
manager.stop('alpha', force=True)
|
|
||||||
manager.media('alpha', None)
|
|
||||||
assert manager.profile('alpha').iso is None
|
|
||||||
manager.start('alpha')
|
|
||||||
time.sleep(1)
|
|
||||||
disk_boot_counter = counter(manager, 'alpha')
|
|
||||||
assert disk_boot_counter > 0
|
|
||||||
expected_compositor_noise = classify_compositor_errors((root / 'labwc.log').read_text())
|
|
||||||
result.update(hidden_guest_intervals=hidden_intervals, post_usb_switches=post_usb_switches,
|
|
||||||
expected_compositor_noise=expected_compositor_noise,
|
|
||||||
manual_usb_dialog=True, standard_us_keymap=True,
|
|
||||||
native_labwc_inhibition=True, reserved_switch_and_recovery=True,
|
|
||||||
qemu_survives_viewer_and_compositor=True, independent_lifecycle=True,
|
|
||||||
guest_counters_before=before, guest_counters_after=after,
|
|
||||||
guest_pids=identities, explicit_reattach=True, requested_qemu_mode=mode,
|
|
||||||
usb_devices_unchanged_without_selection=True,
|
|
||||||
missing_iso_does_not_block_views_or_control=True,
|
|
||||||
restart_without_original_media=True, disk_boot_counter=disk_boot_counter,
|
|
||||||
viewer_version=subprocess.check_output([args.viewer, '--version'], text=True).strip(),
|
|
||||||
guest_input_and_physical_modifiers='not proven by this boot counter workload')
|
|
||||||
(root / 'result.json').write_text(json.dumps(result, indent=2) + '\n')
|
|
||||||
print(json.dumps(result, indent=2))
|
|
||||||
finally:
|
|
||||||
for proc in reversed(processes):
|
|
||||||
if proc.poll() is None:
|
|
||||||
proc.terminate(); proc.wait(timeout=5)
|
|
||||||
for stream in streams:
|
|
||||||
stream.close()
|
|
||||||
for name in names:
|
|
||||||
if manager.status(name)['status'] != 'stopped':
|
|
||||||
manager.stop(name, force=True)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
+2
-7
@@ -8,9 +8,6 @@ let
|
|||||||
url = "https://github.com/NixOS/nixpkgs/archive/7fd4004d0511991fff3a0524856a9be09e28c5f0.tar.gz";
|
url = "https://github.com/NixOS/nixpkgs/archive/7fd4004d0511991fff3a0524856a9be09e28c5f0.tar.gz";
|
||||||
sha256 = "sha256-i5qk1kLiit98rRbh1AXmWBNT9QXC/y4l5Wk/u5EdpDQ=";
|
sha256 = "sha256-i5qk1kLiit98rRbh1AXmWBNT9QXC/y4l5Wk/u5EdpDQ=";
|
||||||
}) {};
|
}) {};
|
||||||
qemu = (pkgs.qemu.override { hostCpuOnly = true; }).overrideAttrs (old: {
|
|
||||||
patches = (old.patches or []) ++ [ ../patches/qemu-virtio-refresh-rate.patch ];
|
|
||||||
});
|
|
||||||
viewer = pkgs.virt-viewer.overrideAttrs (old: {
|
viewer = pkgs.virt-viewer.overrideAttrs (old: {
|
||||||
src = pkgs.fetchurl {
|
src = pkgs.fetchurl {
|
||||||
url = "https://releases.pagure.org/virt-viewer/virt-viewer-11.0.tar.xz";
|
url = "https://releases.pagure.org/virt-viewer/virt-viewer-11.0.tar.xz";
|
||||||
@@ -20,9 +17,7 @@ let
|
|||||||
doCheck = true;
|
doCheck = true;
|
||||||
});
|
});
|
||||||
in pkgs.mkShell {
|
in pkgs.mkShell {
|
||||||
nativeBuildInputs = [ pkgs.stdenv.cc pkgs.pkg-config pkgs.wayland-scanner ];
|
packages = [ pkgs.python3 pkgs.qemu viewer graphics.labwc pkgs.foot
|
||||||
buildInputs = [ pkgs.wayland.dev pkgs.libxkbcommon.dev ];
|
|
||||||
packages = [ pkgs.python3 qemu viewer graphics.labwc pkgs.foot
|
|
||||||
pkgs.brightnessctl pkgs.wayland-utils pkgs.wlr-randr ];
|
pkgs.brightnessctl pkgs.wayland-utils pkgs.wlr-randr ];
|
||||||
passthru = { inherit viewer qemu; labwc = graphics.labwc; };
|
passthru = { inherit viewer; labwc = graphics.labwc; };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<protocol name="virtual_keyboard_unstable_v1">
|
|
||||||
<copyright>
|
|
||||||
Copyright © 2008-2011 Kristian Høgsberg
|
|
||||||
Copyright © 2010-2013 Intel Corporation
|
|
||||||
Copyright © 2012-2013 Collabora, Ltd.
|
|
||||||
Copyright © 2018 Purism SPC
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a
|
|
||||||
copy of this software and associated documentation files (the "Software"),
|
|
||||||
to deal in the Software without restriction, including without limitation
|
|
||||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
||||||
and/or sell copies of the Software, and to permit persons to whom the
|
|
||||||
Software is furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice (including the next
|
|
||||||
paragraph) shall be included in all copies or substantial portions of the
|
|
||||||
Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
|
||||||
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
||||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
||||||
DEALINGS IN THE SOFTWARE.
|
|
||||||
</copyright>
|
|
||||||
|
|
||||||
<interface name="zwp_virtual_keyboard_v1" version="1">
|
|
||||||
<description summary="virtual keyboard">
|
|
||||||
The virtual keyboard provides an application with requests which emulate
|
|
||||||
the behaviour of a physical keyboard.
|
|
||||||
|
|
||||||
This interface can be used by clients on its own to provide raw input
|
|
||||||
events, or it can accompany the input method protocol.
|
|
||||||
</description>
|
|
||||||
|
|
||||||
<request name="keymap">
|
|
||||||
<description summary="keyboard mapping">
|
|
||||||
Provide a file descriptor to the compositor which can be
|
|
||||||
memory-mapped to provide a keyboard mapping description.
|
|
||||||
|
|
||||||
Format carries a value from the keymap_format enumeration.
|
|
||||||
</description>
|
|
||||||
<arg name="format" type="uint" summary="keymap format"/>
|
|
||||||
<arg name="fd" type="fd" summary="keymap file descriptor"/>
|
|
||||||
<arg name="size" type="uint" summary="keymap size, in bytes"/>
|
|
||||||
</request>
|
|
||||||
|
|
||||||
<enum name="error">
|
|
||||||
<entry name="no_keymap" value="0" summary="No keymap was set"/>
|
|
||||||
</enum>
|
|
||||||
|
|
||||||
<request name="key">
|
|
||||||
<description summary="key event">
|
|
||||||
A key was pressed or released.
|
|
||||||
The time argument is a timestamp with millisecond granularity, with an
|
|
||||||
undefined base. All requests regarding a single object must share the
|
|
||||||
same clock.
|
|
||||||
|
|
||||||
Keymap must be set before issuing this request.
|
|
||||||
|
|
||||||
State carries a value from the key_state enumeration.
|
|
||||||
</description>
|
|
||||||
<arg name="time" type="uint" summary="timestamp with millisecond granularity"/>
|
|
||||||
<arg name="key" type="uint" summary="key that produced the event"/>
|
|
||||||
<arg name="state" type="uint" summary="physical state of the key"/>
|
|
||||||
</request>
|
|
||||||
|
|
||||||
<request name="modifiers">
|
|
||||||
<description summary="modifier and group state">
|
|
||||||
Notifies the compositor that the modifier and/or group state has
|
|
||||||
changed, and it should update state.
|
|
||||||
|
|
||||||
The client should use wl_keyboard.modifiers event to synchronize its
|
|
||||||
internal state with seat state.
|
|
||||||
|
|
||||||
Keymap must be set before issuing this request.
|
|
||||||
</description>
|
|
||||||
<arg name="mods_depressed" type="uint" summary="depressed modifiers"/>
|
|
||||||
<arg name="mods_latched" type="uint" summary="latched modifiers"/>
|
|
||||||
<arg name="mods_locked" type="uint" summary="locked modifiers"/>
|
|
||||||
<arg name="group" type="uint" summary="keyboard layout"/>
|
|
||||||
</request>
|
|
||||||
|
|
||||||
<request name="destroy" type="destructor" since="1">
|
|
||||||
<description summary="destroy the virtual keyboard keyboard object"/>
|
|
||||||
</request>
|
|
||||||
</interface>
|
|
||||||
|
|
||||||
<interface name="zwp_virtual_keyboard_manager_v1" version="1">
|
|
||||||
<description summary="virtual keyboard manager">
|
|
||||||
A virtual keyboard manager allows an application to provide keyboard
|
|
||||||
input events as if they came from a physical keyboard.
|
|
||||||
</description>
|
|
||||||
|
|
||||||
<enum name="error">
|
|
||||||
<entry name="unauthorized" value="0" summary="client not authorized to use the interface"/>
|
|
||||||
</enum>
|
|
||||||
|
|
||||||
<request name="create_virtual_keyboard">
|
|
||||||
<description summary="Create a new virtual keyboard">
|
|
||||||
Creates a new virtual keyboard associated to a seat.
|
|
||||||
|
|
||||||
If the compositor enables a keyboard to perform arbitrary actions, it
|
|
||||||
should present an error when an untrusted client requests a new
|
|
||||||
keyboard.
|
|
||||||
</description>
|
|
||||||
<arg name="seat" type="object" interface="wl_seat"/>
|
|
||||||
<arg name="id" type="new_id" interface="zwp_virtual_keyboard_v1"/>
|
|
||||||
</request>
|
|
||||||
</interface>
|
|
||||||
</protocol>
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
# ASUS TUF Dash F15 FX517ZC
|
# ASUS TUF Dash F15 FX517ZC
|
||||||
|
|
||||||
Initial read-only inventory on 2026-10-13; no serials or machine identifiers.
|
Read-only inventory 2026-10-13; no serial numbers or machine identifiers.
|
||||||
|
|
||||||
| Component | Hardware | Driver / requirement |
|
| Component | Hardware | Driver / requirement |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| CPU | i7-12650H, 10 cores / 16 threads | KVM Intel, VT-x |
|
| CPU | i7-12650H, 10 cores / 16 threads | KVM Intel, VT-x |
|
||||||
| RAM | 32 GB nominal | Preserve memory headroom for existing VMs |
|
| RAM | 32 GB nominal | Four build jobs initially; avoid disrupting existing VMs |
|
||||||
| Display | Intel Alder Lake-P UHD 8086:46a3 | i915, internal eDP, intel_backlight |
|
| Display | Intel Alder Lake-P UHD 8086:46a3 | i915, internal eDP, intel_backlight |
|
||||||
| Discrete GPU | RTX 3050 Mobile 10de:25a2 | Initial shell uses Intel; test NVIDIA separately |
|
| Discrete GPU | RTX 3050 Mobile 10de:25a2 | Initial shell uses Intel; test NVIDIA separately |
|
||||||
| Wi-Fi | Intel CNVi 8086:51f0 | iwlwifi/iwlmvm, linux-firmware |
|
| Wi-Fi | Intel CNVi 8086:51f0 | iwlwifi/iwlmvm, linux-firmware |
|
||||||
@@ -16,53 +16,7 @@ Initial read-only inventory on 2026-10-13; no serials or machine identifiers.
|
|||||||
| Thunderbolt | Intel 8086:463e | thunderbolt |
|
| Thunderbolt | Intel 8086:463e | thunderbolt |
|
||||||
| Laptop controls | ASUS | asus_wmi, asus_nb_wmi |
|
| Laptop controls | ASUS | asus_wmi, asus_nb_wmi |
|
||||||
|
|
||||||
Include vmd, nvme, xhci_pci, thunderbolt, usb_storage and sd_mod in boot coverage.
|
UEFI boot, Secure Boot currently disabled. Include vmd, nvme, xhci_pci,
|
||||||
Target physical refresh is 144 Hz; actual guest frame delivery requires measurement.
|
thunderbolt, usb_storage, sd_mod in boot coverage. Target physical refresh144Hz;
|
||||||
|
actual guest frame delivery must be measured. About290GiB build storage free at
|
||||||
## Read-only doctor and live functional checks
|
inventory. Initial RAM headroom about10GiB with existing sessions running.
|
||||||
|
|
||||||
Run `hydra doctor` from the Hydra live session, or `hydra doctor --json` to save
|
|
||||||
structured observations. The command reads procfs/sysfs and checks service status;
|
|
||||||
it does not connect networks, load modules, change brightness, play sound, mount
|
|
||||||
storage, suspend, or start services. Root is not required. Missing tools, absent
|
|
||||||
hardware, access failures and service timeouts are reported without aborting.
|
|
||||||
|
|
||||||
Reports omit storage serials, PCI addresses, interface names, MAC/IP addresses,
|
|
||||||
SSID, usernames, hostname and machine IDs. CPU facts include architecture, logical
|
|
||||||
processor count and virtualization flags. Device facts include counts, capacities,
|
|
||||||
allowlisted driver names and connector resolution modes. Unknown driver names are
|
|
||||||
reported as `other`. Presence and status do not establish physical functionality.
|
|
||||||
|
|
||||||
Reads are limited to 64 KiB per file, directory scans to 64 entries, connector
|
|
||||||
modes to 32 lines, and seven service queries to one second each. Counts may be
|
|
||||||
partial on systems exceeding these limits. Service output is discarded; exit
|
|
||||||
status alone determines active/inactive/unavailable/unknown. User audio service
|
|
||||||
queries refer to the user running the command. A root invocation can therefore
|
|
||||||
observe a different user audio manager than the live desktop user.
|
|
||||||
|
|
||||||
The following source-checkout smoke was observed on the running NixOS host on
|
|
||||||
2026-10-13. It involved no changes to host settings or hardware. It is separate
|
|
||||||
from Hydra ISO validation and does not certify the new image's laptop drivers.
|
|
||||||
|
|
||||||
| Check | Doctor detects | Source-checkout host observation | Live functional check still needed |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| CPU / RAM | Architecture, logical CPUs, VM flags, memory totals | x86_64, 16 logical CPUs, `vmx`, 31,715 MiB RAM | Boot the Hydra image and compare |
|
|
||||||
| Firmware | UEFI presence, Secure Boot variable | UEFI present, Secure Boot disabled | Confirm live boot from USB |
|
|
||||||
| KVM | Device access and loaded KVM modules | `/dev/kvm` accessible, `kvm` and `kvm_intel` present | Launch a disposable VM from Hydra |
|
|
||||||
| Storage | Whole-device count/capacity; no serials or identities | Visibility observed; no target selected | Identify any future installation target separately |
|
|
||||||
| Graphics | PCI driver bindings and connector resolution modes | `i915`, `nvidia`; connected 1920×1080 mode | Select 144 Hz and verify actual refresh rate |
|
|
||||||
| Input / brightness | Event devices, keyboard capabilities, backlight presence | 23 event devices, three keyboard-capable devices, one backlight | Test keyboard, touchpad and both brightness keys |
|
|
||||||
| Wi-Fi / Ethernet | Device counts, wireless presence, driver bindings | One wireless device; `iwlwifi` and `e1000e` | Connect Wi-Fi and test connectivity; test Ethernet if used |
|
|
||||||
| Audio | Card presence and user audio service status | Three cards; PipeWire, WirePlumber and PipeWire Pulse active | Test speakers, headphones and microphone recording |
|
|
||||||
| Desktop / guest networking | Hydra and desktop service status | NetworkManager active; Hydra services absent on NixOS | Observe required services in the Hydra live session |
|
|
||||||
| Suspend / USB | No functional probe | Not exercised | Suspend/resume, then retest graphics/network/audio; attach USB devices |
|
|
||||||
|
|
||||||
Run the manual checks before deciding the image is ready for daily laptop use.
|
|
||||||
Read-only diagnostics and disposable VM acceptance cannot replace these checks.
|
|
||||||
Keep any report you share within its default structured output: unrelated logs,
|
|
||||||
network commands and screenshots can contain private identifiers.
|
|
||||||
|
|
||||||
Installation remains an explicit operator action; this document selects no disk
|
|
||||||
or partition. Signed runtime rollback restores Hydra runtime files only. Offline
|
|
||||||
Btrfs root/owned-EFI recovery has separate requirements and acceptance evidence
|
|
||||||
in [system-recovery.md](../system-recovery.md).
|
|
||||||
|
|||||||
+10
-85
@@ -1,76 +1,16 @@
|
|||||||
# Install HydraOS
|
# Install HydraOS
|
||||||
|
|
||||||
HydraOS targets x86_64 UEFI systems. Disable Secure Boot for this first image.
|
HydraOS targets x86_64 UEFI systems. Disable Secure Boot for this first image.
|
||||||
Boot the ISO and enter `:shell` in the terminal VM menu. The live session uses a locked
|
Boot the ISO and open the recovery terminal. The live session uses a locked
|
||||||
`hydra-live` account with local sudo access; changes in the live filesystem are
|
`hydra-live` account with local sudo access; changes in the live filesystem are
|
||||||
volatile. The image provides no default SSH login or universal installed password.
|
volatile. The image provides no default SSH login or universal installed password.
|
||||||
|
|
||||||
`hydra-install` lists disks without selecting one. Inspect the devices, then ask
|
`hydra-install` lists disks without selecting one. Inspect the devices, then ask
|
||||||
for a plan using either the explicitly chosen existing partitions or a whole disk.
|
for a plan using the whole disk you intend to erase:
|
||||||
The commands below use placeholders; the installer never chooses devices automatically.
|
|
||||||
|
|
||||||
## Preserve existing partitions
|
|
||||||
|
|
||||||
Choose a root partition whose contents you intend to replace and an existing FAT
|
|
||||||
GPT EFI System Partition (ESP). Inspect their identities and sizes with `lsblk`.
|
|
||||||
Both selected partitions must be unmounted; unrelated mounted siblings are allowed.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo hydra-install --root-partition /dev/CHOSEN_ROOT \
|
|
||||||
--esp-partition /dev/CHOSEN_ESP --user YOUR_USER --root-fs btrfs
|
|
||||||
```
|
|
||||||
|
|
||||||
This dry-run describes the operation. To execute the same choice:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo hydra-install --root-partition /dev/CHOSEN_ROOT \
|
|
||||||
--esp-partition /dev/CHOSEN_ESP --user YOUR_USER --root-fs btrfs \
|
|
||||||
--execute --confirm-format /dev/CHOSEN_ROOT
|
|
||||||
```
|
|
||||||
|
|
||||||
`--confirm-format` must name the canonical resolved root path exactly, even if an
|
|
||||||
input path is an alias. This mode formats **only the selected root partition**.
|
|
||||||
It never recreates GPT, changes partition boundaries, or formats the ESP. Before
|
|
||||||
formatting, it checks uncompressed payload capacity, mounts the ESP read-only,
|
|
||||||
refuses any existing `EFI/HydraOS` collision, and requires at least 64 MiB free.
|
|
||||||
It fingerprints foreign ESP files and verifies their hashes after installation.
|
|
||||||
|
|
||||||
GRUB writes `EFI/HydraOS/grubx64.efi` using `--no-nvram` without `--removable`.
|
|
||||||
Existing EFI loaders, `EFI/BOOT/BOOTX64.EFI`, and firmware BootOrder are preserved.
|
|
||||||
By default, select that Hydra EFI file manually in your firmware boot interface.
|
|
||||||
`--register-boot-entry` opts into `efibootmgr --create-only`: it creates a new
|
|
||||||
HydraOS entry without adding it to BootOrder. Firmware support for selecting EFI
|
|
||||||
files and managing entries varies.
|
|
||||||
|
|
||||||
Only plain, nonoverlapping GPT partitions on physical disks are supported. Root
|
|
||||||
and ESP may reside on different disks. Device mapper, encrypted/LVM/RAID targets,
|
|
||||||
nested partition devices, missing identities/bounds, active holders, and ambiguous
|
|
||||||
backing information are rejected. Mounted Btrfs filesystems must report every
|
|
||||||
backing member; an incomplete or missing-device report is rejected. ZFS backing
|
|
||||||
anatomy is unsupported and causes a conservative refusal. The checks are
|
|
||||||
repeated just before formatting; do not change mounts or storage topology during
|
|
||||||
installation. They cannot replace your inspection and backups.
|
|
||||||
|
|
||||||
`--root-fs ext4` is the default. Btrfs creates `@root`, `@home`, and `@snapshots`.
|
|
||||||
The kernel, `/boot`, and Portage database stay inside `@root`; `/home` (including
|
|
||||||
guest disks) uses `@home`, and `/.snapshots` uses `@snapshots`. fstab and boot
|
|
||||||
arguments use filesystem UUIDs and named subvolumes, without a fixed root
|
|
||||||
subvolume ID. The layout alone does not implement system rollback; full Gentoo
|
|
||||||
and kernel recovery requires separate bootable verification.
|
|
||||||
|
|
||||||
An installed, root-owned mode-0600 `/etc/hydra/storage.json` records format 1, a
|
|
||||||
random install UUID, root/ESP filesystem UUIDs and GPT PARTUUIDs, filesystem type,
|
|
||||||
and owned EFI path `EFI/HydraOS`. Btrfs additionally records
|
|
||||||
`root_subvolume: "@root"`. Treat this identity record as installation metadata,
|
|
||||||
not an update payload.
|
|
||||||
|
|
||||||
## Erase a whole disk
|
|
||||||
|
|
||||||
Use this mode only when every partition on the explicitly chosen disk may be erased:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
hydra-install
|
hydra-install
|
||||||
sudo hydra-install --disk /dev/CHOSEN_DISK --user YOUR_USER
|
hydra-install --disk /dev/CHOSEN_DISK --user YOUR_USER
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace the placeholders with an actual whole disk and your desired account
|
Replace the placeholders with an actual whole disk and your desired account
|
||||||
@@ -86,7 +26,7 @@ sudo hydra-install --disk /dev/CHOSEN_DISK --user YOUR_USER \
|
|||||||
--execute --confirm-erase /dev/CHOSEN_DISK
|
--execute --confirm-erase /dev/CHOSEN_DISK
|
||||||
```
|
```
|
||||||
|
|
||||||
Execution erases the disk, creates a 1 GiB FAT32 EFI partition and an ext4 root (or Btrfs with `--root-fs btrfs`),
|
Execution erases the disk, creates a 1 GiB FAT32 EFI partition and an ext4 root,
|
||||||
extracts the prepared payload, and prompts twice on the local terminal for your
|
extracts the prepared payload, and prompts twice on the local terminal for your
|
||||||
installed account password. Passwords have no command-line or configuration
|
installed account password. Passwords have no command-line or configuration
|
||||||
option. The installed root account stays locked. Defaults are Europe/Moscow and
|
option. The installed root account stays locked. Defaults are Europe/Moscow and
|
||||||
@@ -94,35 +34,29 @@ English UTF-8, with Russian UTF-8 also available; `--timezone` accepts an IANA
|
|||||||
zone present in the payload.
|
zone present in the payload.
|
||||||
|
|
||||||
The installer removes the live account, local password-free sudo rule, and
|
The installer removes the live account, local password-free sudo rule, and
|
||||||
live autologin overrides, then builds a normal disk initramfs, its owned HydraOS UEFI loader, and a fallback
|
live autologin overrides, then builds a normal disk initramfs and fallback UEFI
|
||||||
UEFI loader on the newly created ESP. Both installation modes keep os-prober disabled. Wait for `Installation completed`, power off, remove the ISO, and boot
|
loader. Wait for `Installation completed`, power off, remove the ISO, and boot
|
||||||
the installed disk. Log in with the account and password you supplied. On
|
the installed disk. Log in with the account and password you supplied. On
|
||||||
failure, read the printed live diagnostic path; when a target root was mounted,
|
failure, read the printed live diagnostic path; when a target root was mounted,
|
||||||
a nonsecret snapshot is also saved at `/var/log/hydra-install.log` on that disk.
|
a nonsecret snapshot is also saved at `/var/log/hydra-install.log` on that disk.
|
||||||
|
|
||||||
## Disposable VM acceptance
|
## Disposable VM acceptance
|
||||||
|
|
||||||
Run the repository harness with QEMU/KVM, `qemu-img`, Python 3, and a generic
|
Run the repository harness with QEMU/KVM, `qemu-img`, Python 3, and an OVMF
|
||||||
x86_64 QEMU OVMF CODE/VARS pair. A Xen-specific OVMF build is unsuitable: the
|
CODE/VARS pair. Use trusted image and firmware files. For example:
|
||||||
first validation attempt with that variant produced no guest boot output.
|
|
||||||
Use trusted image and matching firmware files. For example:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
install -d -m 700 "$HOME/.cache/hydra-tests"
|
|
||||||
python3 dev/iso-smoke.py --iso /absolute/path/to/hydra-os.iso \
|
python3 dev/iso-smoke.py --iso /absolute/path/to/hydra-os.iso \
|
||||||
--ovmf-code /absolute/path/to/OVMF_CODE.fd \
|
--ovmf-code /absolute/path/to/OVMF_CODE.fd \
|
||||||
--ovmf-vars /absolute/path/to/OVMF_VARS.fd \
|
--ovmf-vars /absolute/path/to/OVMF_VARS.fd
|
||||||
--scratch-parent "$HOME/.cache/hydra-tests"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The harness creates a private directory under `--scratch-parent` (`/tmp` by default), a new 32 GiB sparse
|
The harness creates a private directory under `/tmp`, a new 32 GiB sparse
|
||||||
qcow2 disk, and a private copy of OVMF variables. There is no existing disk
|
qcow2 disk, and a private copy of OVMF variables. There is no existing disk
|
||||||
argument. QEMU attaches the ISO read-only; serial and QMP use private Unix
|
argument. QEMU attaches the ISO read-only; serial and QMP use private Unix
|
||||||
sockets. Tool overrides `--qemu` and `--qemu-img` support temporary development
|
sockets. Tool overrides `--qemu` and `--qemu-img` support temporary development
|
||||||
environments. `--scratch-parent`, `--memory`, `--boot-timeout`, and
|
environments. `--scratch-parent`, `--memory`, `--boot-timeout`, and
|
||||||
`--install-timeout` control retained artifacts and bounded resource use.
|
`--install-timeout` control retained artifacts and bounded resource use.
|
||||||
Prefer a disk-backed scratch directory: an installed disk stored on a tmpfs
|
|
||||||
such as `/tmp` consumes host RAM. The example keeps test data in the user's cache.
|
|
||||||
|
|
||||||
The test boots the live image, runs the real installer through the guest's
|
The test boots the live image, runs the real installer through the guest's
|
||||||
controlling terminal using a generated password, writes a persistence sentinel,
|
controlling terminal using a generated password, writes a persistence sentinel,
|
||||||
@@ -133,15 +67,6 @@ synthetic VM endpoint. Network namespaces and veth interfaces exist only inside
|
|||||||
the disposable HydraOS guest. When guest KVM is available, it launches a further
|
the disposable HydraOS guest. When guest KVM is available, it launches a further
|
||||||
fresh-disk QEMU as the installed account to exercise KVM, the bridge helper,
|
fresh-disk QEMU as the installed account to exercise KVM, the bridge helper,
|
||||||
and private QMP; this checks VM launch, not a nested operating system boot.
|
and private QMP; this checks VM launch, not a nested operating system boot.
|
||||||
The installed normal account also exercises the packaged terminal menu,
|
|
||||||
downloads a complete nonboot ISO fixture from a private loopback HTTP server,
|
|
||||||
starts a scratch VM with `hydra run`, checks cache reuse and stable process/QMP
|
|
||||||
identity through `hydra open`, and verifies the default Fastfetch logo/name.
|
|
||||||
This fixture checks the command flow, not a nested guest OS boot.
|
|
||||||
|
|
||||||
The harness also logs the installed account out and back in, checking that the same VM,
|
|
||||||
private sockets and user audio services survive a measured interval without an
|
|
||||||
open login session. This establishes process/socket continuity, not audio playback.
|
|
||||||
|
|
||||||
`report.json` records each observed check, ISO/firmware SHA256, failures, and
|
`report.json` records each observed check, ISO/firmware SHA256, failures, and
|
||||||
skipped nested virtualization. Serial/QEMU logs and the qcow2 remain in the
|
skipped nested virtualization. Serial/QEMU logs and the qcow2 remain in the
|
||||||
|
|||||||
@@ -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,62 +0,0 @@
|
|||||||
# Offline Base Recovery Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Recover Gentoo root, package database, kernel and owned EFI files together
|
|
||||||
while preserving HOME and other systems, using the trusted live ISO.
|
|
||||||
**Architecture:** Offline Btrfs read-only checkpoints and a restartable restore
|
|
||||||
journal outside @root; matching EFI/HydraOS backups; explicit target identities.
|
|
||||||
**Tech Stack:** Python stdlib, Btrfs tools, existing live image and QEMU acceptance.
|
|
||||||
**Spec:** `docs/superpowers/specs/2026-10-13-base-recovery-design.md`
|
|
||||||
|
|
||||||
## Global Constraints
|
|
||||||
|
|
||||||
- No real host devices or existing VM disks may be modified during development.
|
|
||||||
- Only genuine live mode, selected unmounted single-device Btrfs/ESP, explicit UUID.
|
|
||||||
- @home, foreign EFI/BOOT, partition table and firmware state remain unchanged.
|
|
||||||
- Runtime update and full-base checkpoint/recovery remain distinct commands/scopes.
|
|
||||||
- Recovery requires no installed executables/network and retains previous roots.
|
|
||||||
|
|
||||||
## Review Focus
|
|
||||||
|
|
||||||
1. Duplicate/cloned UUIDs or changed PARTUUIDs fail before modifying either target.
|
|
||||||
2. A nested root subvolume or read-write snapshot invalidates the checkpoint contract.
|
|
||||||
3. Interrupted root rename/EFI copy resumes from the durable journal and actual UUIDs.
|
|
||||||
4. Corrupt/path-traversing EFI backups never overwrite foreign files.
|
|
||||||
5. The real VM proves coordinated kernel/package recovery and post-checkpoint HOME.
|
|
||||||
|
|
||||||
## Task 1: Offline command and journal
|
|
||||||
|
|
||||||
**Files:** `hydra/system_recovery.py`, `bin/hydra-system`,
|
|
||||||
`tests/test_system_recovery.py`, `docs/system-recovery.md`.
|
|
||||||
**Consumes:** installer storage.json schema and @root/@home/@snapshots layout.
|
|
||||||
**Produces:** checkpoint/list/restore CLI; root-owned completed metadata and durable
|
|
||||||
restore transaction; module has no import-time mutations and works from live ISO.
|
|
||||||
|
|
||||||
- [x] Write failing topology/ownership/path/format checks and checkpoint publication tests.
|
|
||||||
- [x] Implement root/ESP resolution and private mount cleanup, read-only planning.
|
|
||||||
- [x] Implement offline checkpoint snapshot and EFI backup with exact metadata/hashes.
|
|
||||||
- [x] Implement restartable restore preserving failed roots and all non-owned data.
|
|
||||||
- [x] Test process interruption/restart at journal transitions and malformed backups.
|
|
||||||
- [x] Scoped tests, self-review, commit, independent task review.
|
|
||||||
|
|
||||||
## Task 2: Bootable acceptance and integration
|
|
||||||
|
|
||||||
**Files:** new `dev/system-recovery-smoke.py` or extension to preserve-partition
|
|
||||||
acceptance, associated harness tests, image wiring and release evidence.
|
|
||||||
|
|
||||||
- [x] Consume completed installer mode; seed and record fresh qcow2 only.
|
|
||||||
- [x] Install, checkpoint offline, upgrade real fixture package and change HOME.
|
|
||||||
- [x] Damage selected kernel and owned EFI, recover from live ISO and boot disk-only.
|
|
||||||
- [x] Verify exact old base/kernel/Portage and unchanged current HOME/foreign data.
|
|
||||||
- [x] Exercise interrupted restore, record hashes/states and honest limitations.
|
|
||||||
- [x] Integrate with laptop ISO acceptance and final review before delivery.
|
|
||||||
|
|
||||||
## Verification status
|
|
||||||
|
|
||||||
The command, tests, image wiring and independent source reviews are complete.
|
|
||||||
The unchanged final laptop harness passed all 23 checks against the final ISO,
|
|
||||||
including real SIGKILL/resume, restored disk-only boot, exact base/kernel/Portage/EFI
|
|
||||||
hashes, current HOME and unchanged guest/foreign data. Physical laptop checks
|
|
||||||
remain separate; exact kernel restoration does not establish a two-kernel upgrade.
|
|
||||||
See the [release record](../../releases/2026-10-13-laptop-ready/README.md).
|
|
||||||
@@ -34,40 +34,40 @@
|
|||||||
**Files:** `image/sources.json`, `image/packages`, `image/portage/`, `scripts/hydra-build`, `hydra/build.py`, `tests/test_build.py`.
|
**Files:** `image/sources.json`, `image/packages`, `image/portage/`, `scripts/hydra-build`, `hydra/build.py`, `tests/test_build.py`.
|
||||||
**Interfaces:** `hydra-build fetch|prepare|packages|rootfs|iso|status`, `--work PATH`, `--cache PATH`, optional `--podman PATH`. Each step resumable and validates prerequisite manifests. Outputs WORK/rootfs payload or dedicated builder container, WORK/artifacts/, build manifest. No host root required.
|
**Interfaces:** `hydra-build fetch|prepare|packages|rootfs|iso|status`, `--work PATH`, `--cache PATH`, optional `--podman PATH`. Each step resumable and validates prerequisite manifests. Outputs WORK/rootfs payload or dedicated builder container, WORK/artifacts/, build manifest. No host root required.
|
||||||
|
|
||||||
- [x] Test pinned download hash mismatch, required signaturefingerprint, safe workpath/refusal of root, resume state vs missingactualartifact, scoped Podman argv.
|
- [ ] Test pinned download hash mismatch, required signaturefingerprint, safe workpath/refusal of root, resume state vs missingactualartifact, scoped Podman argv.
|
||||||
- [x] Observe RED; implement verifiedfetch and isolatedimport/container naming. Use stage3 20261011T170049Z SHA256 50634e8cb87384b94a6dbf78cd2707766c45a5a98f3823e7287d114ee998970b and Portage4696089e782c20b5f0c04952f6698d6a8cc1acb4 archiveSHA256 d24b2056f7ae26506193374b0d9f25c82d4bf547e0ee17279940169418bd3bca.
|
- [ ] Observe RED; implement verifiedfetch and isolatedimport/container naming. Use stage3 20261011T170049Z SHA256 50634e8cb87384b94a6dbf78cd2707766c45a5a98f3823e7287d114ee998970b and Portage4696089e782c20b5f0c04952f6698d6a8cc1acb4 archiveSHA256 d24b2056f7ae26506193374b0d9f25c82d4bf547e0ee17279940169418bd3bca.
|
||||||
- [x] Configure officialbinhost signaturechecking, selective ~amd64 for labwc/wlroots, QEMU x86_64 only, required SPICE/GL/USB flags. Preserve licenses and installed packageDB.
|
- [ ] Configure officialbinhost signaturechecking, selective ~amd64 for labwc/wlroots, QEMU x86_64 only, required SPICE/GL/USB flags. Preserve licenses and installed packageDB.
|
||||||
- [x] Run actual dependency resolution and package install; record exact versions/USE and any selective sourcebuild decisions. No claim of bit-identical build.
|
- [ ] Run actual dependency resolution and package install; record exact versions/USE and any selective sourcebuild decisions. No claim of bit-identical build.
|
||||||
- [x] Run tests and commit builder inputs/scripts.
|
- [ ] Run tests and commit builder inputs/scripts.
|
||||||
|
|
||||||
### Task 2: Host rootfs and bootable image
|
### Task 2: Host rootfs and bootable image
|
||||||
|
|
||||||
**Files:** `image/rootfs/`, `image/grub.cfg`, `image/dracut.conf`, `image/configure-rootfs.sh`, `tests/test_image.py`.
|
**Files:** `image/rootfs/`, `image/grub.cfg`, `image/dracut.conf`, `image/configure-rootfs.sh`, `tests/test_image.py`.
|
||||||
**Interfaces:** Consumes builder and tested CLI/session from graphics plan. Produces labelled HYDRA_OS ISO with boot/vmlinuz, boot/initramfs-live.img, LiveOS/squashfs.img and package/buildmanifest.
|
**Interfaces:** Consumes builder and tested CLI/session from graphics plan. Produces labelled HYDRA_OS ISO with boot/vmlinuz, boot/initramfs-live.img, LiveOS/squashfs.img and package/buildmanifest.
|
||||||
|
|
||||||
- [x] Test service/config generation, live-only overrides separation, exact kernelversion used for dracut, image label/cmdline agreement, networksubnet conflict handling.
|
- [ ] Test service/config generation, live-only overrides separation, exact kernelversion used for dracut, image label/cmdline agreement, networksubnet conflict handling.
|
||||||
- [x] Implement rootfs overlay: greetd/minimalsession, recovery, localliveadministration, installedauth baseline, audio, NetworkManager, hydrabr0/DHCP/NAT and narrowlyscoped bridgehelper. Include laptopfirmware/bootdrivers and OVMF.
|
- [ ] Implement rootfs overlay: greetd/minimalsession, recovery, localliveadministration, installedauth baseline, audio, NetworkManager, hydrabr0/DHCP/NAT and narrowlyscoped bridgehelper. Include laptopfirmware/bootdrivers and OVMF.
|
||||||
- [x] Build genericliveinitramfs with dmsquash-live and OverlayFS; squashfspayload retains kernel/packageDB. Assemble UEFIISO with GRUB; save SHA256 and manifest.
|
- [ ] Build genericliveinitramfs with dmsquash-live and OverlayFS; squashfspayload retains kernel/packageDB. Assemble UEFIISO with GRUB; save SHA256 and manifest.
|
||||||
- [x] Run image checks and disposable OVMF/KVM liveboot; verify rootfilesystem ready and expected tools. Commit rootfs/imageconfig.
|
- [ ] Run image checks and disposable OVMF/KVM liveboot; verify rootfilesystem ready and expected tools. Commit rootfs/imageconfig.
|
||||||
|
|
||||||
### Task 3: Explicit guarded installer
|
### Task 3: Explicit guarded installer
|
||||||
|
|
||||||
**Files:** `bin/hydra-install`, `hydra/installer.py`, `tests/test_installer.py`.
|
**Files:** `bin/hydra-install`, `hydra/installer.py`, `tests/test_installer.py`.
|
||||||
**Interfaces:** `hydra-install --disk DEVICE --user USER [--execute --confirm-erase DEVICE]`; without execute prints exactplan. Environment/default rootfs path points to live squashfs, never running root. Password via getpass/TTY, never CLI/log. Separate nonsecret dryrun from execution.
|
**Interfaces:** `hydra-install --disk DEVICE --user USER [--execute --confirm-erase DEVICE]`; without execute prints exactplan. Environment/default rootfs path points to live squashfs, never running root. Password via getpass/TTY, never CLI/log. Separate nonsecret dryrun from execution.
|
||||||
|
|
||||||
- [x] Test missing/mismatched confirmation, disk mounted directly or through children/ancestors, root/live source backingdisk, invaliddevice, symlink resolution, invalidusername, failedcommands abort subsequentsteps.
|
- [ ] Test missing/mismatched confirmation, disk mounted directly or through children/ancestors, root/live source backingdisk, invaliddevice, symlink resolution, invalidusername, failedcommands abort subsequentsteps.
|
||||||
- [x] Observe RED; implement sysfs/lsblk topology validation, GPT1GiBESP/ext4, payload extraction, fstabUUIDs, user/password setup and liveoverridesremoval. Explicit target, no automaticdiscoveryselection.
|
- [ ] Observe RED; implement sysfs/lsblk topology validation, GPT1GiBESP/ext4, payload extraction, fstabUUIDs, user/password setup and liveoverridesremoval. Explicit target, no automaticdiscoveryselection.
|
||||||
- [x] Install regularinitramfs and GRUBfallback --removable --no-nvram. Checked subprocessargv; handle failure diagnostics and mounts cleanup without deletingtargetdata.
|
- [ ] Install regularinitramfs and GRUBfallback --removable --no-nvram. Checked subprocessargv; handle failure diagnostics and mounts cleanup without deletingtargetdata.
|
||||||
- [x] Test fakecommandfailure paths then complete actualinstallation inside fresh VM using disposable qcow2. Rootprivilege exists only in testguest.
|
- [ ] Test fakecommandfailure paths then complete actualinstallation inside fresh VM using disposable qcow2. Rootprivilege exists only in testguest.
|
||||||
- [x] Commit installer and negative tests.
|
- [ ] Commit installer and negative tests.
|
||||||
|
|
||||||
### Task 4: Installed-system validation and publication
|
### Task 4: Installed-system validation and publication
|
||||||
|
|
||||||
**Files:** `dev/iso-smoke.py`, `docs/install.md`, `docs/verification.md`, `README.md`.
|
**Files:** `dev/iso-smoke.py`, `docs/install.md`, `docs/verification.md`, `README.md`.
|
||||||
**Interfaces:** Consumes ISO and installer. Produces evidence from boot/install/reboot and releaseartifact checksum, plus pendinghardwarechecklist.
|
**Interfaces:** Consumes ISO and installer. Produces evidence from boot/install/reboot and releaseartifact checksum, plus pendinghardwarechecklist.
|
||||||
|
|
||||||
- [x] Boot ISO under OVMF/KVM with fresh disk; install only there; write persistence sentinel.
|
- [ ] Boot ISO under OVMF/KVM with fresh disk; install only there; write persistence sentinel.
|
||||||
- [x] Remove ISO and boot installeddisk; verify sentinel, services, QEMUtools, network and shell/recovery. Check no liveoverlay/autologinadminoverride remains.
|
- [ ] Remove ISO and boot installeddisk; verify sentinel, services, QEMUtools, network and shell/recovery. Check no liveoverlay/autologinadminoverride remains.
|
||||||
- [x] Test nested guest VM if KVM nesting available; distinguish virtualgraphics from real laptopresults.
|
- [ ] Test nested guest VM if KVM nesting available; distinguish virtualgraphics from real laptopresults.
|
||||||
- [x] Run full test suite and independent review. Correct material defects; document remaining physicalchecks without claiming them passed.
|
- [ ] Run full test suite and independent review. Correct material defects; document remaining physicalchecks without claiming them passed.
|
||||||
- [x] Save ISO locally with checksum, push source/patches/manifests to suppliedrepo. BinaryISO only uploaded when suitable repository release/storage path exists; never commit ISO to Git.
|
- [ ] Save ISO locally with checksum, push source/patches/manifests to suppliedrepo. BinaryISO only uploaded when suitable repository release/storage path exists; never commit ISO to Git.
|
||||||
|
|||||||
@@ -50,12 +50,12 @@ unittest; `dev/` holds pinned tools and disposable integration probes.
|
|||||||
- `load_profile(path: Path) -> Profile`, `build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]`, `QMPClient.command(name: str, arguments: dict | None = None) -> dict`.
|
- `load_profile(path: Path) -> Profile`, `build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]`, `QMPClient.command(name: str, arguments: dict | None = None) -> dict`.
|
||||||
- `VMPaths` provides disk, log, lock and private QMP/SPICE sockets. Session consumes stable name and socket paths only.
|
- `VMPaths` provides disk, log, lock and private QMP/SPICE sockets. Session consumes stable name and socket paths only.
|
||||||
|
|
||||||
- [x] Write validation/QMP fake-server tests: invalid names/resources, missing ISO, paths containing spaces, greeting/capabilities, asynchronous events, IDs, timeouts.
|
- [ ] Write validation/QMP fake-server tests: invalid names/resources, missing ISO, paths containing spaces, greeting/capabilities, asynchronous events, IDs, timeouts.
|
||||||
- [x] Run `python3 -m unittest discover -s tests -v`; observe failure before implementation.
|
- [ ] Run `python3 -m unittest discover -s tests -v`; observe failure before implementation.
|
||||||
- [x] Implement validation and bounded QMP; subprocess argv arrays, named environment tool overrides for tests.
|
- [ ] Implement validation and bounded QMP; subprocess argv arrays, named environment tool overrides for tests.
|
||||||
- [x] Add lifecycle tests: simultaneous start, existing-disk preservation, stale socket, failed boot, shutdown vs force, QMP VM identity.
|
- [ ] Add lifecycle tests: simultaneous start, existing-disk preservation, stale socket, failed boot, shutdown vs force, QMP VM identity.
|
||||||
- [x] Implement locked safe disk creation, detached QEMU, virtio disk/network, SPICE no clipboard/file transfer, optional Intel GL. No display dependency in manager and no PID-only stop.
|
- [ ] Implement locked safe disk creation, detached QEMU, virtio disk/network, SPICE no clipboard/file transfer, optional Intel GL. No display dependency in manager and no PID-only stop.
|
||||||
- [x] Run complete suite, help, scratch QEMU lifecycle probe. Commit `feat: add independent VM manager`.
|
- [ ] Run complete suite, help, scratch QEMU lifecycle probe. Commit `feat: add independent VM manager`.
|
||||||
|
|
||||||
### Task 2: Minimal fullscreen session
|
### Task 2: Minimal fullscreen session
|
||||||
|
|
||||||
@@ -63,12 +63,12 @@ unittest; `dev/` holds pinned tools and disposable integration probes.
|
|||||||
|
|
||||||
**Interfaces:** Consumes Task1 Profile/VMPaths. Produces `hydra view NAME [--windowed]`, `hydra session [--nested]`, labwc config only in selected runtime. Viewer title is deterministic from validated name; each VM maps to one workspace. Recovery opens host terminal; session never shuts down a VM.
|
**Interfaces:** Consumes Task1 Profile/VMPaths. Produces `hydra view NAME [--windowed]`, `hydra session [--nested]`, labwc config only in selected runtime. Viewer title is deterministic from validated name; each VM maps to one workspace. Recovery opens host terminal; session never shuts down a VM.
|
||||||
|
|
||||||
- [x] Probe actual labwc version/overrideInhibition and remote-viewer kiosk USB behavior on disposable state; record versions.
|
- [ ] Probe actual labwc version/overrideInhibition and remote-viewer kiosk USB behavior on disposable state; record versions.
|
||||||
- [x] Resolve manual USB dialog with a small upstream viewer adaptation if necessary; never auto-redirect USB.
|
- [ ] Resolve manual USB dialog with a small upstream viewer adaptation if necessary; never auto-redirect USB.
|
||||||
- [x] Write tests for valid escaped XML, exact window/workspace/fullscreen rules, no default keyboard/mouse bindings, overrideInhibition on host keys, missing viewer/display errors.
|
- [ ] Write tests for valid escaped XML, exact window/workspace/fullscreen rules, no default keyboard/mouse bindings, overrideInhibition on host keys, missing viewer/display errors.
|
||||||
- [x] Run session tests and observe missing implementation failure.
|
- [ ] Run session tests and observe missing implementation failure.
|
||||||
- [x] Implement session/viewer commands and pinned temporary tool environment; no host installation.
|
- [ ] Implement session/viewer commands and pinned temporary tool environment; no host installation.
|
||||||
- [x] Run full tests and labwc config parsing. Commit `feat: add fullscreen HydraOS session`.
|
- [ ] Run full tests and labwc config parsing. Commit `feat: add fullscreen HydraOS session`.
|
||||||
|
|
||||||
### Task 3: Integration and recovery
|
### Task 3: Integration and recovery
|
||||||
|
|
||||||
@@ -76,12 +76,12 @@ unittest; `dev/` holds pinned tools and disposable integration probes.
|
|||||||
|
|
||||||
**Interfaces:** Consumes Tasks1/2, produces disposable-VM integration evidence.
|
**Interfaces:** Consumes Tasks1/2, produces disposable-VM integration evidence.
|
||||||
|
|
||||||
- [x] Run two disposable KVM instances/viewers, never existing lab disks; verify private QMP and independent lifecycle.
|
- [ ] Run two disposable KVM instances/viewers, never existing lab disks; verify private QMP and independent lifecycle.
|
||||||
- [x] Verify viewer/session exit preserves QEMU and reattach works; switching neither restarts nor reconnects guests.
|
- [ ] Verify viewer/session exit preserves QEMU and reattach works; switching neither restarts nor reconnects guests.
|
||||||
- [x] Check input only in disposable session, without injecting into current host or user's guests; add regression tests for discovered defects.
|
- [ ] Check input only in disposable session, without injecting into current host or user's guests; add regression tests for discovered defects.
|
||||||
- [x] Record held-key, physical brightness and 144Hz checks needing dedicated TTY. Nested tests cannot prove them.
|
- [ ] Record held-key, physical brightness and 144Hz checks needing dedicated TTY. Nested tests cannot prove them.
|
||||||
- [x] Run full suite and independent review; fix material findings with reproducing tests.
|
- [ ] Run full suite and independent review; fix material findings with reproducing tests.
|
||||||
- [x] Commit evidence and push reviewed prototype branch to supplied repository.
|
- [ ] Commit evidence and push reviewed prototype branch to supplied repository.
|
||||||
|
|
||||||
## Next plan
|
## Next plan
|
||||||
|
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
# Hydra OS Laptop Preparation Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Deliver a tested ISO supporting preserved-partition installation, signed
|
|
||||||
runtime updates/recovery, and live laptop diagnostics without modifying the host.
|
|
||||||
|
|
||||||
**Architecture:** Extend the validated installer with an explicit partition mode;
|
|
||||||
use a separately installed trusted updater for signed passive release payloads;
|
|
||||||
retain independent guest VM state. Validate each subsystem then exercise them in
|
|
||||||
new disposable UEFI/KVM VMs before integrating and delivering release artifacts.
|
|
||||||
|
|
||||||
**Tech Stack:** Python stdlib, Gentoo, OpenSSL Ed25519, Btrfs tools, GRUB/efibootmgr,
|
|
||||||
QEMU/KVM, existing dedicated rootless Podman builder.
|
|
||||||
|
|
||||||
**Spec:** `docs/superpowers/specs/2026-10-13-laptop-ready-design.md`
|
|
||||||
|
|
||||||
## Global Constraints
|
|
||||||
|
|
||||||
- No physical block, firmware, host-service, original-container or existing-VM writes.
|
|
||||||
- Install tests exclusively use fresh private qcow2 disks; no target selected for laptop.
|
|
||||||
- Preserve partition boundaries and foreign EFI/fallback files across reboots;
|
|
||||||
preserve firmware BootOrder around every installer/recovery/selection operation.
|
|
||||||
Firmware may independently prune empty-media entries between boots.
|
|
||||||
- Runtime payloads never contain HOME, VM data/config, credentials or networking.
|
|
||||||
- Check release signature using a separately installed key before trusting contents.
|
|
||||||
- Keep physical functional claims separate from read-only diagnostics and VM results.
|
|
||||||
- Existing whole-disk and runtime behavior must retain their regression coverage.
|
|
||||||
|
|
||||||
## Review Focus
|
|
||||||
|
|
||||||
1. Partition aliases, topology changes and mounted siblings: reject actual targets
|
|
||||||
in use and identity changes, preserve eligible unrelated siblings (Task 1).
|
|
||||||
2. Existing EFI loaders, lack of ESP space and partial installation: fail before
|
|
||||||
destructive root formatting when possible and preserve foreign bytes (Task 1).
|
|
||||||
3. A signed but malformed/replayed archive or unsafe redirect: no mutation (Task 2).
|
|
||||||
4. Power loss during apply/rollback and a broken CLI: standalone fresh-process
|
|
||||||
recovery restores exact originals and retains monotonic replay state (Task 2).
|
|
||||||
5. Missing hardware/tools, privacy and a release that passed only mocks: bounded
|
|
||||||
honest diagnostics and real disposable-disk acceptance evidence (Tasks 3, 4, 5).
|
|
||||||
|
|
||||||
## Task 1: Preserve-partition installer
|
|
||||||
|
|
||||||
**Files:** `hydra/installer.py`, optional focused `hydra/install_storage.py`,
|
|
||||||
`tests/test_installer.py`, `image/packages`, `image/dracut.conf`, `docs/install.md`.
|
|
||||||
**Interfaces:** extend Request compatibly with root_partition, esp_partition,
|
|
||||||
confirm_format, root_fs and register_boot_entry. Existing `install` stays the entry.
|
|
||||||
|
|
||||||
- [x] Add failing tests for mode parsing, identity/bounds/protection, mounted siblings,
|
|
||||||
Btrfs member enumeration, ESP collision/space, only-root formatting and EFI policy.
|
|
||||||
- [x] Implement shared validated target plan and distinct storage/boot branches.
|
|
||||||
- [x] Add ext4 and Btrfs layouts plus explicit dependencies; test failure cleanup.
|
|
||||||
- [x] Run installer suite, self-review, commit only task-owned files.
|
|
||||||
- [x] Independent task review and scoped fixes.
|
|
||||||
|
|
||||||
## Task 2: Signed update and recovery
|
|
||||||
|
|
||||||
**Files:** new `hydra/update*.py` as needed, stable `bin/hydra-update*`, `hydra/cli.py`,
|
|
||||||
`dev/release.py`, `tests/test_update*.py`, `tests/test_cli.py`, image install wiring,
|
|
||||||
`docs/updates.md`. Coordinate shared image files with Task 1; do not race edits.
|
|
||||||
**Interfaces:** the CLI actions from the spec; stable recover entrypoint runnable
|
|
||||||
without importing hydra runtime; passive artifact manifest with version/sequence.
|
|
||||||
|
|
||||||
- [x] Add failing tests for real crypto, transport bounds, manifest/payload validation,
|
|
||||||
no-write failures, replay rules, locking and destination/backup path ownership.
|
|
||||||
- [x] Implement passive package builder, trusted verification and transactional apply.
|
|
||||||
- [x] Implement explicit rollback and independent crash recovery with durable journal.
|
|
||||||
- [x] Prove SIGKILL recovery in a fresh process, unchanged guest data and blocked
|
|
||||||
corrupt backups; verify status/check work unprivileged.
|
|
||||||
- [x] Document release creation, local key ownership, bootstrap and runtime scope.
|
|
||||||
- [x] Run scoped suites, commit, independent review and scoped fixes.
|
|
||||||
|
|
||||||
## Task 3: Read-only laptop doctor
|
|
||||||
|
|
||||||
**Files:** `hydra/doctor.py`, `tests/test_doctor.py`, `docs/hardware/asus-fx517zc.md`.
|
|
||||||
**Interfaces:** `doctor_report()` returns serializable bounded observations;
|
|
||||||
`doctor_main(argv)` serves text/JSON. CLI wiring coordinated after Task 2.
|
|
||||||
|
|
||||||
- [x] Test fake proc/sys/tools with absent devices, timeouts and redaction.
|
|
||||||
- [x] Implement independent read-only probes and manual functional checks.
|
|
||||||
- [x] Run unit suite and safe host smoke, commit, independent review.
|
|
||||||
|
|
||||||
## Task 4: Integration and disposable acceptance harnesses
|
|
||||||
|
|
||||||
**Files:** `dev/iso-smoke.py`, `tests/test_iso_smoke.py`, new `dev/update-smoke.py`
|
|
||||||
or helper as needed, CLI wiring and docs; do not edit these while other tasks do.
|
|
||||||
|
|
||||||
- [x] Preserve whole-disk mode; add existing-partitions acceptance using seeded fresh
|
|
||||||
disk only, no existing-disk argument. Capture partition/EFI/BootOrder identities.
|
|
||||||
- [x] Add signed update/tamper/apply/rollback/crash-recover installed-guest checks.
|
|
||||||
- [x] Confirm brand asset and doctor availability, compile standalone guest scripts.
|
|
||||||
- [x] Run full source suite once integrations settle; independent review.
|
|
||||||
|
|
||||||
## Task 5: Build, run acceptance and deliver
|
|
||||||
|
|
||||||
**Files:** source release records under `docs/releases`; build artifacts remain ignored.
|
|
||||||
|
|
||||||
- [x] Prepare isolated build checkout and preserve old ISO/firmware hashes.
|
|
||||||
- [x] Build needed packages/rootfs/ISO, retaining explicit signature trust material.
|
|
||||||
- [x] Run whole-disk and preserve-partitions real UEFI acceptance plus updater recovery.
|
|
||||||
- [x] Run focused fixes only for observed failures, rerun affected acceptance.
|
|
||||||
- [x] Verify exact release files, checksums, source revisions and absence of secrets.
|
|
||||||
- [x] Complete final independent branch review.
|
|
||||||
- [x] Integrate and publish authorized source/artifacts using available authenticated
|
|
||||||
transport; report any unavailable publishing action.
|
|
||||||
- [x] Report physical checks still requiring live laptop boot, not unsupported success.
|
|
||||||
|
|
||||||
## Verification status
|
|
||||||
|
|
||||||
Implementation and scoped reviews are complete through source
|
|
||||||
`ebeebc7fb1aed07a5f50bc62374365d614953dad`. The complete source suite passed
|
|
||||||
431 tests in 26.048 seconds. Independent final review is complete. The final ISO
|
|
||||||
passed the unchanged whole-disk harness (13 checks) and laptop/recovery harness
|
|
||||||
(23 checks), with no skips. Exact artifact identities and public evidence passed
|
|
||||||
review. Source and signed artifacts are published in the public repository.
|
|
||||||
Actual public discovery, signature verification, staging apply/rollback and replay
|
|
||||||
rejection passed. Physical checks remain operator work after live laptop boot.
|
|
||||||
See the [release record](../../releases/2026-10-13-laptop-ready/README.md).
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
# Hydra OS terminal experience implementation plan
|
|
||||||
|
|
||||||
> **For agentic workers:** Use superpowers:subagent-driven-development. Tasks
|
|
||||||
> are implemented in the shared feature worktree with disjoint owned files,
|
|
||||||
> then reviewed and integrated before publication.
|
|
||||||
|
|
||||||
**Goal:** Download and open VMs through a concise terminal workflow and ship
|
|
||||||
consistent Hydra OS branding with Fastfetch.
|
|
||||||
|
|
||||||
**Architecture:** Retain Runtime as the VM lifecycle authority. Add a safe ISO
|
|
||||||
download module and explicit launch orchestration. Replace recovery's JSON
|
|
||||||
prompt with a terminal menu and use the existing session-refresh boundary to
|
|
||||||
focus a selected workspace without changing QEMU lifetimes.
|
|
||||||
|
|
||||||
**Tech stack:** Python standard library, existing labwc/Foot/SPICE, Gentoo,
|
|
||||||
Fastfetch. No GUI toolkit or host NixOS changes.
|
|
||||||
|
|
||||||
**Spec:** `docs/superpowers/specs/2026-10-13-terminal-ux-design.md`
|
|
||||||
|
|
||||||
## Global constraints
|
|
||||||
|
|
||||||
- Visible product name is **Hydra OS**; preserve technical identifiers and data.
|
|
||||||
- No NixOS configuration changes, user-disk replacement or guest input injection.
|
|
||||||
- Opening a selected stopped VM is explicit; menu display never starts all VMs.
|
|
||||||
- Existing CLI output and runtime ownership guarantees remain compatible.
|
|
||||||
- Downloads publish only verified complete ISO files; SHA-256 is optional.
|
|
||||||
- New launch preflight reserves 768 MiB of available RAM for the Hydra host.
|
|
||||||
|
|
||||||
## Review focus
|
|
||||||
|
|
||||||
- Concurrent downloads or interruption must not publish partial ISO files (T1).
|
|
||||||
- Existing names/disks and unknown VM identities must not be replaced (T1).
|
|
||||||
- Selecting a second VM must focus it and preserve other QEMU processes (T2).
|
|
||||||
- Invalid profiles, missing ISO, absent network tools must leave a usable menu (T2).
|
|
||||||
- Branding must not invalidate existing IDs or rewrite historical evidence (T3).
|
|
||||||
|
|
||||||
## Task 1: Download and explicit launch commands
|
|
||||||
|
|
||||||
Owned: `hydra/media.py`, `hydra/launch.py`, `hydra/cli.py`,
|
|
||||||
`tests/test_media.py`, `tests/test_launch.py`, relevant new CLI tests.
|
|
||||||
|
|
||||||
Interfaces:
|
|
||||||
- `media.download_iso(url, *, sha256=None, cache=None, progress=None) -> Path`.
|
|
||||||
`progress(received_bytes, total_bytes_or_none)` is optional.
|
|
||||||
- `launch.open_vm(runtime, name, *, windowed=False, no_view=False,
|
|
||||||
manifest=None) -> int`. For a session manifest call
|
|
||||||
`session.request_refresh(manifest, selected=name)` after successful launch;
|
|
||||||
outside a session use existing `session.view`. No GUI check for `no_view`.
|
|
||||||
- CLI: `open NAME`, `run SOURCE --name NAME`, `download URL`; plain `hydra`
|
|
||||||
and `hydra menu` call `menu.run_menu(runtime, manifest=...)` lazily.
|
|
||||||
- Manifest defaults from `HYDRA_SESSION_MANIFEST` when valid; launch errors
|
|
||||||
remain actionable and never silently restart an unknown/foreign VM.
|
|
||||||
|
|
||||||
- [x] Write and observe failing tests using a local HTTP fixture (ISO, HTML,
|
|
||||||
truncation, checksum, cancellation, cache reuse, collision).
|
|
||||||
- [x] Implement downloader and explicit open/run commands, preserve existing CLI.
|
|
||||||
- [x] Test no accidental overwrite/start, headless operation, RAM preflight and
|
|
||||||
view-only reconnection. Run scoped tests and commit only owned files.
|
|
||||||
|
|
||||||
## Task 2: Terminal menu and focused session handoff
|
|
||||||
|
|
||||||
Owned: `hydra/menu.py`, `hydra/session.py`, `tests/test_menu.py`,
|
|
||||||
`tests/test_session.py`, relevant `dev/session-refresh-smoke.py` extensions.
|
|
||||||
|
|
||||||
Interfaces:
|
|
||||||
- `menu.run_menu(runtime, *, manifest=None) -> int`: clear readable menu,
|
|
||||||
numbers/names/Enter, persistent last selection, old colon commands, controlled
|
|
||||||
guided `run`/`download`, network and shell actions. Use Task 1's launch API.
|
|
||||||
- `session.request_refresh(manifest: Path, *, selected=None) -> int`: backwards
|
|
||||||
compatible empty refresh requests, optional validated selected VM name.
|
|
||||||
- Session env carries `HYDRA_SESSION_MANIFEST`; next compositor starts on the
|
|
||||||
requested configured VM. Recovery delegates to menu; automatic clients remain
|
|
||||||
view-only. Errors stay in menu, and operations use fresh runtime profiles.
|
|
||||||
|
|
||||||
- [x] Write failing menu/refresh tests for stopped/running selection, unknown
|
|
||||||
identity, names such as s/q, empty/invalid profiles and second-workspace focus.
|
|
||||||
- [x] Implement terminal flow, visible command previews and recovery fallback.
|
|
||||||
- [x] Run scoped tests plus an isolated session probe where feasible; commit
|
|
||||||
owned files. Existing reserved chords and VM independence remain intact.
|
|
||||||
|
|
||||||
## Task 3: Hydra OS branding and Fastfetch
|
|
||||||
|
|
||||||
Owned: `image/packages`, `image/rootfs` (Fastfetch config/logo/motd only),
|
|
||||||
`hydra/image.py`, `image/grub.cfg`, `session` metadata, new branding tests.
|
|
||||||
Coordinate session/CLI visible names with their owners; do not edit their files.
|
|
||||||
|
|
||||||
- [x] Confirm Fastfetch's Gentoo atom, supported logo/config format and search path.
|
|
||||||
- [x] Add original terminal logo and restrained summary configuration; retain
|
|
||||||
technical IDs while updating visible OS/boot/session labels.
|
|
||||||
- [x] Verify config with actual Fastfetch in a temporary/container environment,
|
|
||||||
preview logo, and test resulting image metadata. Commit owned files.
|
|
||||||
|
|
||||||
## Task 4: Integration and delivery
|
|
||||||
|
|
||||||
Owner: controller. README/install docs, update bundle, isolated validation.
|
|
||||||
|
|
||||||
- [x] Review each task diff with concrete test reports; resolve findings.
|
|
||||||
- [x] Integrate task interfaces; document terminal usage and branded name.
|
|
||||||
- [x] Run complete suite and isolated smoke covering run/open/menu with new code.
|
|
||||||
- [x] Prepare a reversible guest update with exact file inventory and checksums;
|
|
||||||
preserve old ISO and test disk. Assess cached image rebuild and test new output.
|
|
||||||
- [x] Review whole branch, commit/publish authorized repository changes and report
|
|
||||||
exactly which artifacts and guest states have actually been updated.
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# Offline recovery of the Gentoo base
|
|
||||||
|
|
||||||
This separately verified part of laptop preparation supplies real OS rollback,
|
|
||||||
not just runtime-file rollback. It is deliberately offline: create checkpoints
|
|
||||||
from the trusted live ISO while the installed root and ESP are unmounted, perform
|
|
||||||
ordinary Portage updates in the installed system, and use the ISO to recover if
|
|
||||||
needed. No online application-consistency or automatic failed-boot claim is made.
|
|
||||||
|
|
||||||
The explicit Btrfs layout is @root, @home, @snapshots; /boot, kernel modules,
|
|
||||||
Portage package database and world remain in @root. There must be no nested
|
|
||||||
subvolumes within @root. VM disks and user data in @home survive OS recovery.
|
|
||||||
GRUB/fstab/initramfs select UUID plus subvol=@root, never fixed subvolume IDs.
|
|
||||||
|
|
||||||
`/etc/hydra/storage.json` is the installation ownership marker with exact fields:
|
|
||||||
format=1, install_uuid, root_fs_uuid, root_partuuid, esp_uuid, esp_partuuid,
|
|
||||||
owned_efi_path='EFI/HydraOS', filesystem='btrfs', root_subvolume='@root'. The ESP
|
|
||||||
must be the recorded FAT partition; never operate on foreign EFI paths or NVRAM.
|
|
||||||
|
|
||||||
CLI `hydra-system checkpoint --root-uuid UUID --esp-partuuid PARTUUID --name NAME`,
|
|
||||||
`hydra-system list --root-uuid UUID`, and
|
|
||||||
`hydra-system restore --root-uuid UUID --checkpoint ID`. Changes require --execute
|
|
||||||
and --confirm-root-uuid UUID. Default plans/list are read-only. Require a genuine
|
|
||||||
Hydra live marker, root execution for changes, exactly one matching root and ESP,
|
|
||||||
ordinary unmounted partitions without active swap/holders, single-device Btrfs,
|
|
||||||
matching partition/filesystem/install UUIDs and supported owned layout.
|
|
||||||
|
|
||||||
Checkpoint mounts selected filesystems privately, snapshots @root read-only into
|
|
||||||
@snapshots/ID/root and saves only EFI/HydraOS with a hash/metadata manifest.
|
|
||||||
Preserve exact installed root and EFI state while offline. Durably publish the
|
|
||||||
completed checkpoint descriptor last. Incomplete checkpoints cannot be restored.
|
|
||||||
Snapshots remain on the same disk and are not external backups.
|
|
||||||
|
|
||||||
Restore validates all metadata, recorded source/snapshot UUIDs, readonly snapshot,
|
|
||||||
layout marker and saved EFI bytes before mutation. Prepare a writable snapshot,
|
|
||||||
record a durable transaction outside @root, retain the previous root under a unique
|
|
||||||
failed-root name, rename the new root to @root, then restore only owned EFI files.
|
|
||||||
Keep checkpoints and failed roots. Never delete @home or alter GPT, fallback EFI,
|
|
||||||
foreign partitions/EFI directories or BootOrder. Directory fsync and filesystem
|
|
||||||
sync delimit journal transitions. Lock operations and reject unexplained state.
|
|
||||||
|
|
||||||
Root/ESP replacement is not atomic across Btrfs and FAT. A power interruption can
|
|
||||||
leave the install temporarily unbootable; rerunning restore from the live ISO must
|
|
||||||
verify identities and resume the recorded transaction without guessing or deleting
|
|
||||||
either root. Recovery must not invoke installed programs, fetch files, or require
|
|
||||||
network access. Symlinks, malformed paths, unexpected files and tampered backup
|
|
||||||
metadata fail closed before writes.
|
|
||||||
|
|
||||||
Actual acceptance uses a fresh UEFI VM, preserved foreign EFI/data sentinels,
|
|
||||||
Btrfs install, live checkpoint, a real local Portage package upgrade, changed HOME,
|
|
||||||
and damaged installed kernel/own EFI. After recovery boot without ISO and verify
|
|
||||||
old package database/world/kernel artifacts together, updated HOME retained, and
|
|
||||||
foreign bytes/GPT/BootOrder unchanged. Exercise restart after journal transition
|
|
||||||
failures. Do not claim a two-kernel-version upgrade unless separately exercised.
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
# Hydra OS laptop preparation
|
|
||||||
|
|
||||||
The user authorized autonomous implementation of the agreed scope: preserve all
|
|
||||||
existing laptop partitions; install Hydra into an explicitly selected partition;
|
|
||||||
obtain updates from the now-public `Emil_Shanaty/Hydra-OS` Gitea repository; verify
|
|
||||||
signatures and recover after failed updates; prepare hardware diagnostics; rebuild
|
|
||||||
and test an ISO including the generated Fastfetch branding. Technical choices are
|
|
||||||
delegated. Actual laptop partitioning/installation and physical hardware checks
|
|
||||||
remain operator steps; no physical target is selected or authorized.
|
|
||||||
|
|
||||||
## Invariants
|
|
||||||
|
|
||||||
- Never modify the physical host's block devices, firmware, services, NixOS
|
|
||||||
configuration, networking, existing containers, or existing VM disks.
|
|
||||||
- All installation/recovery execution tests use freshly created private qcow2s.
|
|
||||||
- Keep whole-disk installation compatible, but add a distinct existing-partitions
|
|
||||||
mode. No automatic partition selection, shrinking, or repartitioning.
|
|
||||||
- Preserve foreign EFI files, the removable fallback loader, and BootOrder.
|
|
||||||
- VM disks, profiles, HOME, credentials and network configuration are not runtime
|
|
||||||
update payloads. Release trust is installed independently of untrusted discovery.
|
|
||||||
- Prefer the standard library and already installed OpenSSL; no hosted dependency.
|
|
||||||
- Hardware detection is evidence about presence, not proof of working devices.
|
|
||||||
- A runtime rollback must never be described as rollback of Gentoo or its kernel.
|
|
||||||
|
|
||||||
## Existing-partitions installation
|
|
||||||
|
|
||||||
Keep `--disk` and `--confirm-erase` for fresh whole-disk installations. Add
|
|
||||||
`--root-partition`, `--esp-partition`, `--confirm-format`, `--root-fs ext4|btrfs`
|
|
||||||
and opt-in `--register-boot-entry`. A request chooses exactly one mode. Existing
|
|
||||||
mode formats only the selected root after full identity revalidation, requires an
|
|
||||||
unmounted existing FAT ESP, and never runs disk-label/partition/ESP-format commands.
|
|
||||||
|
|
||||||
Require stable canonical paths, block identities, parent/partition identities and
|
|
||||||
bounds; reject mounted/active/protected targets, overlaps, aliases, ambiguous
|
|
||||||
metadata and devices with holders. Unrelated mounted sibling partitions must not
|
|
||||||
cause false rejection. Btrfs mounts must resolve all backing members conservatively.
|
|
||||||
Require enough capacity for the installed payload and boot files before erasure.
|
|
||||||
|
|
||||||
Write only EFI/HydraOS, refusing an existing collision. Use GRUB --no-nvram without
|
|
||||||
--removable, disable os-prober, and leave foreign EFI entries and fallback intact.
|
|
||||||
Explicit registration may create a new entry without modifying BootOrder. Default
|
|
||||||
output explains manual firmware selection. ESP preflight is read-only and happens
|
|
||||||
before root formatting; record and verify unchanged foreign file hashes.
|
|
||||||
|
|
||||||
Ext4 remains compatible. Optional Btrfs uses @root, @home, @snapshots. The kernel,
|
|
||||||
/boot and Portage database remain within @root; guest disks under HOME remain in
|
|
||||||
@home. This layout permits a separately verified system recovery path; it alone
|
|
||||||
is not a rollback implementation.
|
|
||||||
|
|
||||||
## Signed runtime updates
|
|
||||||
|
|
||||||
Use a bounded passive ZIP payload, exact-byte Ed25519-signed JSON manifest and
|
|
||||||
detached signature. Manifest records product, scope, format, channel, architecture,
|
|
||||||
monotonic sequence, release version, source commit, archive name/size/hash and the
|
|
||||||
allowlisted installed paths/modes/hashes. OpenSSL verifies using an installed public
|
|
||||||
key. Private release material stays outside Git, the build context and Nix store.
|
|
||||||
|
|
||||||
Gitea discovery is unauthenticated HTTPS on the fixed project origin; it is not a
|
|
||||||
trust authority. Reject cross-origin/downgrade redirects, malformed URLs, duplicate
|
|
||||||
JSON/archive entries, traversal/symlinks/special files, excess sizes, tampering and
|
|
||||||
unsupported formats. Verify the manifest before trusting the archive. Empty releases
|
|
||||||
are a normal result. No fallback to executing unsigned downloaded Python archives.
|
|
||||||
|
|
||||||
Publication must also work using the already available Git SSH access alone.
|
|
||||||
When native releases have no applicable candidate, support the fixed public
|
|
||||||
`main/releases/stable` raw path for the same three signed passive artifacts.
|
|
||||||
The branch is untrusted transport: the pinned signature, hashes and monotonic
|
|
||||||
sequence remain mandatory. Missing artifacts mean no update; invalid signed
|
|
||||||
material must produce an error. No API token or extra update server is required.
|
|
||||||
|
|
||||||
CLI: `hydra update check`, `status`, `apply` and `rollback TRANSACTION`, plus a
|
|
||||||
standalone `hydra-update-recover` available when the runtime itself is damaged.
|
|
||||||
The trusted engine and recovery bootstrap are outside the runtime update allowlist.
|
|
||||||
Prefer a protected installed engine imported without arbitrary sys.path entries.
|
|
||||||
For local tests/support permit an explicit trusted local artifact directory and a
|
|
||||||
staging-root API; production target checks must still require a Hydra installation.
|
|
||||||
|
|
||||||
Serialize mutations with a lock. Before the first destination change, validate all
|
|
||||||
paths and save original bytes, modes, owners, symlinks and hashes into a durable
|
|
||||||
private backup; persist a journal and fsync files/directories. Recover prepared or
|
|
||||||
interrupted transactions from independently validated records. Latest-only explicit
|
|
||||||
rollback refuses unexpected modifications. Preserve highest accepted sequence when
|
|
||||||
rolling back current state; block new changes while recovery is pending. Failure
|
|
||||||
must retain the journal and all backups. Include real fresh-process crash tests.
|
|
||||||
|
|
||||||
## Diagnostics and delivery
|
|
||||||
|
|
||||||
`hydra doctor` collects a bounded read-only report useful on the live ASUS laptop:
|
|
||||||
UEFI/Secure Boot, KVM, CPU/RAM, storage visibility, kernel/driver bindings, graphics,
|
|
||||||
input devices, network/audio tools and required services. Redact serials, MACs,
|
|
||||||
SSID, IP addresses, usernames and machine IDs by default. Print actionable checks
|
|
||||||
without implying physical Wi-Fi/audio/brightness/suspend/144Hz delivery were tested.
|
|
||||||
|
|
||||||
Rebuild using the isolated existing builder store. Preserve the previous ISO and
|
|
||||||
firmware. Run whole-disk acceptance plus a distinct preserve-partitions acceptance:
|
|
||||||
seed foreign EFI/fallback/sibling sentinel files, install into only the target,
|
|
||||||
compare GPT/BootOrder/sentinels, and boot without ISO. Exercise signed update,
|
|
||||||
tamper refusal, rollback and recovery with guest data unchanged. Store reports
|
|
||||||
against exact source/ISO hashes. Publish only intended code/artifacts; private
|
|
||||||
builder exports, signing keys, VM data and test secrets are never release assets.
|
|
||||||
|
|
||||||
Whole-base Portage recovery is a separate design/verification unit. Until its own
|
|
||||||
bootable test passes, documentation must retain that limitation explicitly.
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# Hydra OS terminal experience
|
|
||||||
|
|
||||||
The user requested a general UX overhaul inside Hydra, clarified that the
|
|
||||||
interface should stay in the terminal for control, and specifically requested
|
|
||||||
one command to download and launch an ISO from a URL. They also requested
|
|
||||||
Fastfetch with a custom logo and consistent visible branding as **Hydra OS**.
|
|
||||||
Earlier autonomous-development authorization continues to apply. This work
|
|
||||||
must not modify the current NixOS configuration or existing user VM disks.
|
|
||||||
|
|
||||||
## Experience
|
|
||||||
|
|
||||||
- The existing recovery terminal becomes a readable VM menu, with names,
|
|
||||||
running/stopped/error states and memory allocations instead of raw JSON.
|
|
||||||
A number or name opens one VM; Enter chooses the remembered selection (or
|
|
||||||
the only VM). Opening explicitly starts a stopped VM and reconnects a
|
|
||||||
running one. Merely displaying or refreshing the menu never starts VMs.
|
|
||||||
- Keep `:shell`, `:refresh`, `:quit` and VM names compatible. Offer discoverable
|
|
||||||
actions for new VM setup, ISO downloading, networking (`sudo nmtui`) and
|
|
||||||
help. Existing CLI commands remain usable and JSON-compatible.
|
|
||||||
- `hydra open NAME` starts if needed and displays an existing VM. Selecting a
|
|
||||||
VM must actually focus its workspace, including a VM other than the first.
|
|
||||||
Existing independent QEMU lifecycle and identity/locking protections remain.
|
|
||||||
- `hydra run SOURCE --name NAME` accepts a local ISO or HTTP(S) URL and performs
|
|
||||||
download (when necessary), profile creation, startup and display. Options
|
|
||||||
expose RAM, CPUs, disk size, SHA-256 and `--no-view` for headless use. An
|
|
||||||
existing name is never replaced: report how to use `hydra open NAME` instead.
|
|
||||||
- `hydra download URL [--sha256 HEX]` stores a reusable ISO locally, reports
|
|
||||||
progress and final path, and supports Ctrl+C cancellation. Explain direct
|
|
||||||
image URLs versus HTML download pages. Never treat HTML as an ISO.
|
|
||||||
- Check available RAM with a 768 MiB host reserve before an explicit new
|
|
||||||
launch through the new UX. Refuse an obviously insufficient budget clearly;
|
|
||||||
this is a preflight estimate, not a guarantee against future memory pressure.
|
|
||||||
- Clear actionable errors, no startup of all VMs, no forced VM shutdowns.
|
|
||||||
|
|
||||||
## Downloads and storage
|
|
||||||
|
|
||||||
Use Python standard-library HTTP(S), no network service or new daemon. Cache
|
|
||||||
under the user's XDG cache directory in `hydra/iso`, keyed by URL hash and a
|
|
||||||
safe basename. Reject URL credentials and non-HTTP(S) remote schemes. Download
|
|
||||||
to a private temporary file; check HTTP status, content length when supplied,
|
|
||||||
ISO9660/UDF volume signature and optional SHA-256 before atomic publication.
|
|
||||||
Never overwrite an existing unrelated file. Cancelled/failed downloads cannot
|
|
||||||
be mistaken for complete images. Check disk space; reuse validated cached
|
|
||||||
images. Render safe progress and errors without leaking URL query credentials.
|
|
||||||
|
|
||||||
## Branding
|
|
||||||
|
|
||||||
Visible product name is **Hydra OS**. Preserve technical identifiers such as
|
|
||||||
`hydra`, `hydra-os`, service names, existing profile formats, disk directories
|
|
||||||
and EFI installation paths. Historical release manifests/evidence keep their
|
|
||||||
original contents. Add an original compact multi-headed hydra ASCII logo for
|
|
||||||
Fastfetch, a restrained color scheme and an intentionally short useful system
|
|
||||||
summary. Include Fastfetch in the Gentoo image, not on NixOS.
|
|
||||||
|
|
||||||
## Delivery and validation
|
|
||||||
|
|
||||||
Work in `feat/ux` under `.worktrees/ux`. Keep the original ISO and manual test
|
|
||||||
disk. Use meaningful regression tests for download failures/cache integrity,
|
|
||||||
explicit launch behavior and menu/session focus. Run the existing full suite
|
|
||||||
once integrated, and an isolated real graphical/VM smoke test where available.
|
|
||||||
Prepare a reviewable update bundle for the already installed guest and update
|
|
||||||
the image sources; rebuild/test a new ISO when feasible without a cold toolchain
|
|
||||||
build. Never claim the running user guest or an old ISO already contains new
|
|
||||||
code. No keystroke injection into the user's desktop or guest sessions.
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
# Graphics and recovery verification
|
|
||||||
|
|
||||||
The [terminal UX preview record](releases/2026-10-13-terminal-ux/acceptance.json)
|
|
||||||
identifies the exact ISO and firmware used for the passing thirteen-check
|
|
||||||
acceptance run. The unmodified ISO installed onto a fresh 32 GiB qcow2 disk and booted from
|
|
||||||
that disk with no ISO attached. Password login, persistent data, removed live
|
|
||||||
overrides, installed services, DNS lookup, routed LAN-to-VM TCP, normal-user
|
|
||||||
nested KVM/bridge access and normal shutdown passed. An actual logout followed
|
|
||||||
by 15 seconds without an open login and a new login preserved the same VM,
|
|
||||||
QMP/SPICE sockets and user audio services. Audio playback was not measured.
|
|
||||||
The installed normal account also exercised download/cache/run/open/menu using
|
|
||||||
the packaged commands and verified the default Fastfetch rendering. Its ISO
|
|
||||||
fixture is intentionally nonbootable; nested guest OS boot is not inferred.
|
|
||||||
The [first preview record](releases/2026-10-13/acceptance.json) remains unchanged.
|
|
||||||
|
|
||||||
Live graphical recovery was also observed through a private QEMU screenshot;
|
|
||||||
the earlier two-guest compositor and profile-refresh probes cover fullscreen
|
|
||||||
switching and hidden workload continuity. These virtual checks leave the physical
|
|
||||||
ASUS checks listed below pending. No current host configuration or existing user
|
|
||||||
VM disk was changed for this validation.
|
|
||||||
|
|
||||||
Run from the checkout. Tools are temporary Nix development dependencies; no
|
|
||||||
persistent host installation is required. The two-guest smoke test needs access
|
|
||||||
to `/dev/kvm` and creates its own headless Wayland display, VM disks and sockets
|
|
||||||
under a fresh `/tmp/hydra-smoke-*` directory. It never uses the inherited display,
|
|
||||||
active TTY, existing guests or existing disks. Evidence remains in that directory;
|
|
||||||
the script stops only its own verified QEMU instances on exit.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
python3 -m unittest discover -s tests -v
|
|
||||||
nix-build dev/tools.nix -A viewer --no-out-link
|
|
||||||
nix-build dev/tools.nix -A qemu --no-out-link
|
|
||||||
nix-shell dev/tools.nix --run 'python3 dev/smoke.py'
|
|
||||||
nix-shell dev/tools.nix --run 'python3 dev/session-refresh-smoke.py --keyboard "$(dev/build-input-probe.sh)"'
|
|
||||||
nix-shell dev/tools.nix --run 'python3 dev/check-network.py'
|
|
||||||
```
|
|
||||||
|
|
||||||
The smoke test builds a private input helper with an ordinary evdev/pc105 US
|
|
||||||
keyboard map. It boots two tiny counter workloads from separate scratch qcow2
|
|
||||||
disks, connects two fullscreen viewers, and checks reserved switching/recovery,
|
|
||||||
manual USB selection, explicit reconnect and independent QMP lifecycles.
|
|
||||||
After each confirmed workspace switch, the hidden guest is sampled across a
|
|
||||||
half-second interval with no focus changes, switches or reconnects; the check is
|
|
||||||
then repeated with the other guest hidden. `result.json` records both intervals.
|
|
||||||
Guest memory counters must advance and QEMU PIDs remain unchanged through
|
|
||||||
switching. Viewer protocol logs must show no reconnection during switching.
|
|
||||||
A test-only `ToggleKeybinds` action and F12 marker establish native labwc
|
|
||||||
keybinding inhibition before checking the production reserved bindings. Those
|
|
||||||
extra controls are absent from the production session configuration. See the
|
|
||||||
[labwc configuration manual](https://labwc.github.io/labwc-config.5.html) for
|
|
||||||
`overrideInhibition` and the [actions manual](https://labwc.github.io/labwc-actions.5.html)
|
|
||||||
for native `ToggleKeybinds` behavior.
|
|
||||||
|
|
||||||
USB acceptance checks the actual selector window title, holds the modifiers
|
|
||||||
through opening, releases them inside the modal dialog, then asserts fresh
|
|
||||||
keyboard leave/enter transitions for both Right and Left switches.
|
|
||||||
QEMU's USB device list must stay unchanged without a selection. Both viewer
|
|
||||||
USB filters deny automatic hotplug and connection redirection. This check does
|
|
||||||
not attach a physical USB device or prove guest driver behavior.
|
|
||||||
|
|
||||||
The viewer adaptation handles only the configured manual USB accelerator before
|
|
||||||
forwarding an event to the guest. Upstream keyboard grabs remove the GTK
|
|
||||||
application accelerator group; without this adaptation, the configured USB
|
|
||||||
shortcut reaches the viewer but does not open its selector.
|
|
||||||
|
|
||||||
The pinned labwc 0.20.2 runtime has no documented runtime XWayland-disable
|
|
||||||
option. This native-Wayland probe sets `WLR_XWAYLAND=/bin/false` so no X server
|
|
||||||
is launched, and explicitly disables activation-environment updates. It can
|
|
||||||
still log an occupied X socket while wlroots reserves a display number. Only
|
|
||||||
`[xwayland/sockets.c] Failed to bind socket ... Address already in use` is
|
|
||||||
classified as expected noise in `result.json`; any other compositor `[ERROR]`
|
|
||||||
fails the probe. Full logs remain available. This classification does not
|
|
||||||
suppress viewer errors or compiler/test diagnostics. The viewer build's upstream
|
|
||||||
Meson boolean-check and compiler-warning-option guidance are build noise; the four-test
|
|
||||||
result, compiler errors and process exit status remain the build acceptance gate.
|
|
||||||
|
|
||||||
## Fresh-session recovery and profile changes
|
|
||||||
|
|
||||||
A session with no profiles opens the terminal VM menu with first-use guidance.
|
|
||||||
Use `:run` for guided creation, or `:shell` for the existing CLI. Selecting a
|
|
||||||
machine opens it on its own workspace. `:refresh` reloads the profile list after
|
|
||||||
manual edits, rebuilds workspace rules, restarts only the session's own compositor,
|
|
||||||
and attaches viewers to verified live guests. QEMU processes and workloads
|
|
||||||
survive. Reserved Left/Right chords switch between their workspaces; `:quit`
|
|
||||||
closes the menu.
|
|
||||||
|
|
||||||
`dev/session-refresh-smoke.py` exercises the actual product launch loop, recovery
|
|
||||||
helper, `:shell` and `:refresh`, starting with an empty profile directory. A
|
|
||||||
test-only labwc wrapper selects private headless backends; a terminal command
|
|
||||||
feeder drives recovery and a scratch shell adds and starts two counter guests.
|
|
||||||
The menu explicitly selects the second VM, and the rebuilt compositor must
|
|
||||||
start on its workspace with that viewer focused. Actual labwc and native
|
|
||||||
Wayland viewers then demonstrate fresh keyboard focus transitions in both
|
|
||||||
directions, hidden workload continuity, unchanged QEMU PIDs/UUIDs through
|
|
||||||
refresh, and guest survival after normal compositor exit. This checks the
|
|
||||||
terminal command flow, not physical terminal rendering/usability.
|
|
||||||
It leaves snapshots, recovery output, protocol logs and `result.json` in
|
|
||||||
`/tmp/hydra-session-refresh-*` and stops only its verified scratch QEMU processes.
|
|
||||||
|
|
||||||
## Bridge identities
|
|
||||||
|
|
||||||
New profiles persist a locally administered unicast `mac` in their TOML file.
|
|
||||||
It remains stable across stop/start, media changes and runtime socket-directory
|
|
||||||
changes. The generated address incorporates the VM disk path and user identity,
|
|
||||||
so separate profiles/users avoid QEMU's identical default MAC on a shared bridge.
|
|
||||||
Existing profiles without `mac` use the same deterministic calculation without
|
|
||||||
rewriting their file. Relocating a legacy disk or changing its owner changes
|
|
||||||
that fallback; explicitly persisting its current address retains the identity.
|
|
||||||
Explicit TOML addresses must also be locally administered unicast, and users
|
|
||||||
copying profiles must assign distinct addresses to concurrently used copies.
|
|
||||||
|
|
||||||
`dev/check-network.py` requires unprivileged user/network/mount namespaces. It
|
|
||||||
asserts all three namespace identities changed before creating a private
|
|
||||||
`hydrabr0` and mounting a private QEMU bridge-helper ACL over `/etc` within that
|
|
||||||
namespace. Two actual scratch QEMUs must report different configured NIC MACs,
|
|
||||||
both bridge TAP ports must exist, and restart must preserve the first guest's
|
|
||||||
address. Evidence remains under `/tmp/hydra-network-*`; host bridges, host `/etc`
|
|
||||||
and existing guests are unchanged. This does not prove guest network drivers,
|
|
||||||
DHCP leases or external connectivity; those need the installed-guest harness.
|
|
||||||
|
|
||||||
## Installed guest workflow
|
|
||||||
|
|
||||||
`--iso` is optional. Attach or detach media only while the VM is stopped:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
bin/hydra add guest --iso /absolute/path/installer.iso
|
|
||||||
bin/hydra start guest
|
|
||||||
bin/hydra view guest
|
|
||||||
# Shut down from inside the guest, then check it is stopped.
|
|
||||||
bin/hydra status guest
|
|
||||||
bin/hydra media guest --detach
|
|
||||||
bin/hydra start guest
|
|
||||||
```
|
|
||||||
|
|
||||||
For an existing stopped guest, `bin/hydra media guest --iso PATH` attaches
|
|
||||||
installation/rescue media. A missing configured ISO prevents a new start with
|
|
||||||
an actionable error; it does not prevent listing profiles, status, stopping a
|
|
||||||
running guest, reconnecting a viewer or detaching the missing media. Media
|
|
||||||
updates share the VM lifecycle lock and preserve its qcow2 disk. Abrupt stopping
|
|
||||||
still requires `bin/hydra stop guest --force` and verified QMP identity.
|
|
||||||
|
|
||||||
## Display preferences and EDID
|
|
||||||
|
|
||||||
New developer profiles use user networking, 1280×800 and `refresh_millihz=0`.
|
|
||||||
Zero omits the patched QEMU refresh property and remains compatible with stock
|
|
||||||
QEMU. Hydra's installed defaults can use this flat `/etc/hydra/defaults.toml`:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
network = "bridge"
|
|
||||||
bridge = "hydrabr0"
|
|
||||||
width = 1920
|
|
||||||
height = 1080
|
|
||||||
refresh_millihz = 144000
|
|
||||||
```
|
|
||||||
|
|
||||||
Only `add` reads distribution defaults. Explicit `--network`, `--bridge`,
|
|
||||||
`--width`, `--height` and `--refresh-millihz` options win; existing profiles do
|
|
||||||
not change. `--defaults PATH` selects another preferences file for development.
|
|
||||||
Missing defaults use developer preferences; unknown or invalid fields fail
|
|
||||||
before creating a profile. Dimensions are integers from 1 to 16384, refresh is
|
|
||||||
zero for automatic refresh or an integer from 50000 to 255000 mHz, matching
|
|
||||||
the vertical range descriptor limits. This bound does not establish every
|
|
||||||
resolution/refresh combination; pixel-clock and horizontal timing limits are
|
|
||||||
mode-dependent and the verified target remains 1920×1080/144000 mHz. Nonzero
|
|
||||||
refresh requires the tracked patched QEMU provided by `dev/tools.nix` and distribution packaging.
|
|
||||||
|
|
||||||
The patch keeps the requested refresh through frontend resizing and extends
|
|
||||||
EDID monitor ranges to include the generated preferred mode. To verify the
|
|
||||||
actual patched QEMU generator with the pinned source archive:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
qemu_source=$(nix-build dev/tools.nix -A qemu.src --no-out-link)
|
|
||||||
nix-shell dev/tools.nix --run "python3 dev/check-edid.py '$qemu_source'"
|
|
||||||
```
|
|
||||||
|
|
||||||
The probe applies the tracked patch to four relevant source files in a temporary
|
|
||||||
directory, compiles the actual EDID and virtio GPU generator functions with a
|
|
||||||
small dependency harness, and decodes the result. It checks 60/75 Hz fallback,
|
|
||||||
144 Hz configuration, a conflicting 60 Hz frontend request, resized dimensions,
|
|
||||||
checksums and monitor ranges. It also checks the supported vertical-refresh
|
|
||||||
boundaries: 1920×1080/50000 mHz and 1280×800/255000 mHz. Range endpoints
|
|
||||||
use floor/ceiling of the encoded timing after pixel-clock rounding, so the
|
|
||||||
49.998895 Hz lower-boundary timing is included by a 49 Hz minimum. Normal
|
|
||||||
automatic 60/75 Hz cases retain the legacy 50–125 Hz range. At 1920×1080/144000 mHz the preferred timing is
|
|
||||||
143.997231 Hz after EDID clock rounding. This is an advertised guest mode,
|
|
||||||
not measured frame delivery or proof of a 144 Hz physical output.
|
|
||||||
|
|
||||||
## Still requiring a dedicated physical session
|
|
||||||
|
|
||||||
- Physical reserved chords, brightness/backlight permissions and touchpad input.
|
|
||||||
- Physical held keys/modifiers across guest switching, grabs and modal dialogs;
|
|
||||||
guest key-up delivery, pointer grab release and absence of stuck modifiers.
|
|
||||||
- Physical USB selection/redirection and guest drivers; no automatic redirection.
|
|
||||||
- Toolbar behavior at physical screen edges and actual recovery shell usability.
|
|
||||||
- ASUS output selection at 1920×1080/144 Hz, guest-reported display mode, delivered
|
|
||||||
frame rate, hidden guest graphics continuity, tearing and switching latency.
|
|
||||||
|
|
||||||
Headless checks establish CPU workload continuity, protocol focus transitions
|
|
||||||
and the exercised compositor actions. They cannot prove physical input or scanout.
|
|
||||||
+10
-199
@@ -9,7 +9,6 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import shlex
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -58,21 +57,6 @@ sys.exit(code)
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
def rootfs_retry_guard_script():
|
|
||||||
"""Refuse deletion of a still-mounted export, including nested mountpoints."""
|
|
||||||
return r"""mounts=$(findmnt --raw --noheadings --output TARGET)
|
|
||||||
while IFS= read -r mountpoint; do
|
|
||||||
case "$mountpoint" in
|
|
||||||
"$root"|"$root/"*) echo 'Refusing to remove a mounted rootfs export' >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
done <<< "$mounts"
|
|
||||||
if [ -e "$root" ] || [ -L "$root" ]; then
|
|
||||||
[ ! -L "$root" ] && [ -f "$root/.hydra-export-owned" ] || { echo 'Unowned rootfs output' >&2; exit 1; }
|
|
||||||
rm -rf -- "$root"
|
|
||||||
fi
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def verify_hash(path, expected):
|
def verify_hash(path, expected):
|
||||||
digest = hashlib.sha256()
|
digest = hashlib.sha256()
|
||||||
with Path(path).open('rb') as stream:
|
with Path(path).open('rb') as stream:
|
||||||
@@ -206,8 +190,7 @@ if path.exists():
|
|||||||
|
|
||||||
def input_id(self):
|
def input_id(self):
|
||||||
digest = hashlib.sha256()
|
digest = hashlib.sha256()
|
||||||
for directory in ('image', 'patches'):
|
for path in sorted((self.source_dir / 'image').rglob('*')):
|
||||||
for path in sorted((self.source_dir / directory).rglob('*')):
|
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
digest.update(str(path.relative_to(self.source_dir)).encode())
|
digest.update(str(path.relative_to(self.source_dir)).encode())
|
||||||
digest.update(path.read_bytes())
|
digest.update(path.read_bytes())
|
||||||
@@ -226,17 +209,8 @@ if path.exists():
|
|||||||
entry = self.manifest().get(step, {})
|
entry = self.manifest().get(step, {})
|
||||||
if not entry.get('complete') or entry.get('inputs') != self.input_id():
|
if not entry.get('complete') or entry.get('inputs') != self.input_id():
|
||||||
return False
|
return False
|
||||||
if step in ('rootfs', 'iso'):
|
|
||||||
from hydra import image
|
|
||||||
if entry.get('source_inputs') != image.source_identity(self.source_dir):
|
|
||||||
return False
|
|
||||||
if not image.artifacts_valid(self.work / 'artifacts', entry):
|
|
||||||
return False
|
|
||||||
return self.complete('packages' if step == 'rootfs' else 'rootfs')
|
|
||||||
if step == 'packages':
|
if step == 'packages':
|
||||||
try:
|
if not (self.work / 'artifacts/packages.json').is_file():
|
||||||
self.package_inventory()
|
|
||||||
except (BuildError, OSError):
|
|
||||||
return False
|
return False
|
||||||
return self.complete('prepare')
|
return self.complete('prepare')
|
||||||
if step == 'prepare':
|
if step == 'prepare':
|
||||||
@@ -252,7 +226,8 @@ if path.exists():
|
|||||||
if step == 'fetch':
|
if step == 'fetch':
|
||||||
inputs = json.loads((self.source_dir / 'image/sources.json').read_text())
|
inputs = json.loads((self.source_dir / 'image/sources.json').read_text())
|
||||||
try:
|
try:
|
||||||
for item in [inputs['stage3'], inputs['portage'], *inputs.get('local_packages', [])]:
|
for name in ('stage3', 'portage'):
|
||||||
|
item = inputs[name]
|
||||||
path = self.cache / item.get('filename', Path(urllib.parse.urlparse(item['url']).path).name)
|
path = self.cache / item.get('filename', Path(urllib.parse.urlparse(item['url']).path).name)
|
||||||
verify_hash(path, item['sha256'])
|
verify_hash(path, item['sha256'])
|
||||||
except (OSError, BuildError):
|
except (OSError, BuildError):
|
||||||
@@ -260,16 +235,6 @@ if path.exists():
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def package_inventory(self):
|
|
||||||
from hydra import image
|
|
||||||
checksum = self.manifest().get('packages', {}).get('inventory_sha256')
|
|
||||||
if not isinstance(checksum, str) or not re.fullmatch('[a-f0-9]{64}', checksum):
|
|
||||||
raise BuildError('Package inventory has no recorded checksum')
|
|
||||||
try:
|
|
||||||
return image.read_package_inventory(self.work / 'artifacts/packages.json', checksum)
|
|
||||||
except (image.ImageError, OSError) as error:
|
|
||||||
raise BuildError(f'Package inventory validation failed: {error}') from error
|
|
||||||
|
|
||||||
def download(self, url, filename=None):
|
def download(self, url, filename=None):
|
||||||
parsed = urllib.parse.urlparse(url)
|
parsed = urllib.parse.urlparse(url)
|
||||||
if parsed.scheme != 'https':
|
if parsed.scheme != 'https':
|
||||||
@@ -290,8 +255,6 @@ if path.exists():
|
|||||||
verify_hash(archive, stage['sha256'])
|
verify_hash(archive, stage['sha256'])
|
||||||
snapshot = self.download(inputs['portage']['url'], inputs['portage']['filename'])
|
snapshot = self.download(inputs['portage']['url'], inputs['portage']['filename'])
|
||||||
verify_hash(snapshot, inputs['portage']['sha256'])
|
verify_hash(snapshot, inputs['portage']['sha256'])
|
||||||
for item in inputs.get('local_packages', []):
|
|
||||||
verify_hash(self.download(item['url'], item['filename']), item['sha256'])
|
|
||||||
signature = self.download(stage['signature_url'])
|
signature = self.download(stage['signature_url'])
|
||||||
keyring = self.download(inputs['keyring_url'])
|
keyring = self.download(inputs['keyring_url'])
|
||||||
verify_hash(keyring, inputs['keyring_sha256'])
|
verify_hash(keyring, inputs['keyring_sha256'])
|
||||||
@@ -332,12 +295,6 @@ if [ ! -f /var/db/repos/gentoo/.hydra-snapshot ]; then
|
|||||||
printf '%s\\n' '{inputs['portage']['commit']}' > /var/db/repos/gentoo/.hydra-snapshot
|
printf '%s\\n' '{inputs['portage']['commit']}' > /var/db/repos/gentoo/.hydra-snapshot
|
||||||
fi
|
fi
|
||||||
cp -a /hydra-source/image/portage/. /etc/portage/
|
cp -a /hydra-source/image/portage/. /etc/portage/
|
||||||
mkdir -p /var/db/repos/hydra /var/cache/distfiles
|
|
||||||
cp -a /hydra-source/image/overlay/. /var/db/repos/hydra/
|
|
||||||
cp /hydra-cache/brightnessctl-0.5.1.tar.gz /var/cache/distfiles/brightnessctl-0.5.1.tar.gz
|
|
||||||
mkdir -p /etc/portage/patches/app-emulation/qemu /etc/portage/patches/app-emulation/virt-viewer
|
|
||||||
cp /hydra-source/patches/qemu-virtio-refresh-rate.patch /etc/portage/patches/app-emulation/qemu/10-hydra-refresh.patch
|
|
||||||
cp /hydra-source/patches/virt-viewer-hydra-session.patch /etc/portage/patches/app-emulation/virt-viewer/10-hydra-session.patch
|
|
||||||
if [ -d /etc/portage/gnupg ] && [ ! -s /etc/portage/gnupg/mykeyid ]; then
|
if [ -d /etc/portage/gnupg ] && [ ! -s /etc/portage/gnupg/mykeyid ]; then
|
||||||
mv /etc/portage/gnupg /etc/portage/gnupg.hydra-untrusted
|
mv /etc/portage/gnupg /etc/portage/gnupg.hydra-untrusted
|
||||||
fi
|
fi
|
||||||
@@ -354,167 +311,19 @@ chmod a+r /etc/portage/gnupg/pubring.kbx /etc/portage/gnupg/trustdb.gpg
|
|||||||
atoms = [line.strip() for line in (self.source_dir / 'image/packages').read_text().splitlines()
|
atoms = [line.strip() for line in (self.source_dir / 'image/packages').read_text().splitlines()
|
||||||
if line.strip() and not line.startswith('#')]
|
if line.strip() and not line.startswith('#')]
|
||||||
import shlex
|
import shlex
|
||||||
command = ['emerge', '--verbose', '--update', '--newuse', '--deep', '--backtrack=100',
|
command = ['emerge', '--verbose', '--update', '--newuse', '--deep', '--backtrack=100']
|
||||||
'--usepkg-exclude=app-emulation/qemu app-emulation/virt-viewer']
|
|
||||||
if pretend:
|
if pretend:
|
||||||
self.exec(shlex.join([*command, '--pretend', *atoms]))
|
self.exec(shlex.join([*command, '--pretend', *atoms]))
|
||||||
return
|
return
|
||||||
inputs = self.input_id()
|
inputs = self.input_id()
|
||||||
self.record('packages', complete=False, job_log='/var/lib/hydra-build/packages.log')
|
self.record('packages', complete=False, job_log='/var/lib/hydra-build/packages.log')
|
||||||
patches = hashlib.sha256()
|
script = package_job_script([*command, *atoms], inputs=inputs)
|
||||||
for name in ('qemu-virtio-refresh-rate.patch', 'virt-viewer-hydra-session.patch'):
|
|
||||||
patches.update((self.source_dir / 'patches' / name).read_bytes())
|
|
||||||
patch_id = patches.hexdigest()
|
|
||||||
forced = ['emerge', '--verbose', '--oneshot', '--usepkg=n', '--getbinpkg=n',
|
|
||||||
'app-emulation/qemu', 'app-emulation/virt-viewer']
|
|
||||||
package_commands = shlex.join([*command, *atoms]) + '\n' + f'''if [ "$(cat /var/lib/hydra-build/patched-source.sha256 2>/dev/null || true)" != {shlex.quote(patch_id)} ]; then
|
|
||||||
{shlex.join(forced)}
|
|
||||||
printf '%s\\n' {shlex.quote(patch_id)} > /var/lib/hydra-build/patched-source.sha256
|
|
||||||
fi'''
|
|
||||||
script = package_job_script(['/bin/bash', '-euc', package_commands], inputs=inputs)
|
|
||||||
self.exec(shlex.join(['python3', '-c', script]))
|
self.exec(shlex.join(['python3', '-c', script]))
|
||||||
if self.input_id() != inputs:
|
if self.input_id() != inputs:
|
||||||
raise BuildError('Image inputs changed during package installation; completion was not recorded')
|
raise BuildError('Image inputs changed during package installation; completion was not recorded')
|
||||||
inventory = self.exec('cat /var/lib/hydra-build/packages.json', capture=True).stdout
|
inventory = self.exec('cat /var/lib/hydra-build/packages.json', capture=True).stdout
|
||||||
from hydra import image
|
(self.work / 'artifacts/packages.json').write_text(inventory)
|
||||||
try:
|
self.record('packages', inventory='artifacts/packages.json')
|
||||||
image.validate_package_inventory(json.loads(inventory))
|
|
||||||
except (ValueError, image.ImageError) as error:
|
|
||||||
raise BuildError(f'Package inventory is malformed: {error}') from error
|
|
||||||
target = self.work / 'artifacts/packages.json'
|
|
||||||
temporary = target.with_suffix('.tmp')
|
|
||||||
temporary.write_text(inventory)
|
|
||||||
temporary.replace(target)
|
|
||||||
self.record('packages', inventory='artifacts/packages.json', patched_source=patch_id,
|
|
||||||
inventory_sha256=image.digest(target))
|
|
||||||
|
|
||||||
def assembly(self, snapshot_digest=None):
|
|
||||||
"""A separate rootless container processes an exported copy, never the builder."""
|
|
||||||
if snapshot_digest is None:
|
|
||||||
state = self.manifest().get('rootfs', {})
|
|
||||||
snapshot_digest = state.get('snapshot_sha256')
|
|
||||||
if not isinstance(snapshot_digest, str) or not re.fullmatch('[a-f0-9]{64}', snapshot_digest):
|
|
||||||
raise BuildError('Verified exported snapshot is required')
|
|
||||||
name = self.name + '-image-' + snapshot_digest[:12]
|
|
||||||
image = 'localhost/hydra-image:' + snapshot_digest
|
|
||||||
exists = self.pod('container', 'exists', name, capture=True, check=False).returncode == 0
|
|
||||||
if not exists:
|
|
||||||
self.pod('import', str(self.work / 'artifacts/builder-rootfs.tar'), image)
|
|
||||||
self.pod('run', '--detach', '--name', name, '--hostname', 'hydra-image-assembler',
|
|
||||||
'--network', 'none', '--cap-add', 'SYS_ADMIN', '--security-opt', 'seccomp=unconfined',
|
|
||||||
'--volume', f'{self.source_dir}:/hydra-source:ro',
|
|
||||||
'--volume', f'{self.work}:/hydra-output:rw', image, '/bin/sleep', 'infinity')
|
|
||||||
container = self.inspect('container', name)
|
|
||||||
for destination, source, writable in (('/hydra-source', self.source_dir, False),
|
|
||||||
('/hydra-output', self.work, True)):
|
|
||||||
mounts = [mount for mount in container.get('Mounts', []) if mount.get('Destination') == destination]
|
|
||||||
if len(mounts) != 1 or mounts[0].get('Source') != str(source) or mounts[0].get('RW') is not writable:
|
|
||||||
raise BuildError('Assembly mount provenance differs; refusing to write')
|
|
||||||
provenance = self.inspect('image', container['Image'])
|
|
||||||
if provenance.get('RootFS', {}).get('Layers') != ['sha256:' + snapshot_digest]:
|
|
||||||
raise BuildError('Assembly image does not match the exported builder snapshot')
|
|
||||||
if exists:
|
|
||||||
self.pod('start', name)
|
|
||||||
return name
|
|
||||||
|
|
||||||
def rootfs(self):
|
|
||||||
from hydra import image
|
|
||||||
if self.active_package_job():
|
|
||||||
raise BuildError('A contained package job is running; wait before export')
|
|
||||||
if not self.complete('packages'):
|
|
||||||
raise BuildError('Verified completed packages are required before rootfs export')
|
|
||||||
if self.complete('rootfs'):
|
|
||||||
return
|
|
||||||
source_inputs = image.source_identity(self.source_dir)
|
|
||||||
previous = self.manifest().get('rootfs', {})
|
|
||||||
inputs = self.input_id()
|
|
||||||
inventory_checksum = image.digest(self.work / 'artifacts/packages.json')
|
|
||||||
if inventory_checksum != self.manifest().get('packages', {}).get('inventory_sha256'):
|
|
||||||
raise BuildError('Package inventory changed before rootfs export')
|
|
||||||
original = self.work / 'artifacts/builder-rootfs.tar'
|
|
||||||
if original.is_symlink() or not original.parent.resolve().is_relative_to(self.work):
|
|
||||||
raise BuildError('Raw export output must remain inside the dedicated work directory')
|
|
||||||
if original.exists() and (not original.is_file() or original.stat().st_nlink != 1):
|
|
||||||
raise BuildError('Raw export output must be a singly linked regular file')
|
|
||||||
snapshot = None
|
|
||||||
previous_artifacts = previous.get('artifacts', {})
|
|
||||||
if (previous.get('complete') is True and previous.get('inputs') == inputs
|
|
||||||
and isinstance(previous_artifacts, dict)
|
|
||||||
and previous_artifacts.get('packages.json') == inventory_checksum
|
|
||||||
and isinstance(previous.get('snapshot_sha256'), str)
|
|
||||||
and re.fullmatch('[a-f0-9]{64}', previous['snapshot_sha256'])
|
|
||||||
and original.is_file()):
|
|
||||||
try:
|
|
||||||
if image.digest(original) == previous['snapshot_sha256']:
|
|
||||||
snapshot = previous['snapshot_sha256']
|
|
||||||
except OSError:
|
|
||||||
pass # Unreadable cache is never trusted; attempt an ordinary export.
|
|
||||||
# Preserve the prior completed provenance until the cache decision is made.
|
|
||||||
self.record('rootfs', complete=False)
|
|
||||||
original.touch(mode=0o600, exist_ok=True)
|
|
||||||
original.chmod(0o600) # The unsanitized intermediate can contain builder-private trust.
|
|
||||||
if snapshot is None:
|
|
||||||
self.pod('export', '--output', str(original), self.name)
|
|
||||||
snapshot = image.digest(original)
|
|
||||||
assembler = self.assembly(snapshot)
|
|
||||||
commit = self.run(['git', '-C', str(self.source_dir), 'rev-parse', 'HEAD'], capture=True).stdout.strip()
|
|
||||||
script = 'root=/hydra-output/rootfs\n' + rootfs_retry_guard_script() + r"""mkdir -p "$root"
|
|
||||||
touch "$root/.hydra-export-owned"
|
|
||||||
tar --extract --preserve-permissions --numeric-owner --file /hydra-output/artifacts/builder-rootfs.tar --directory "$root"
|
|
||||||
/hydra-source/image/configure-rootfs.sh "$root"
|
|
||||||
cp "$(python3 /hydra-source/hydra/image.py kernel-image "$root")" /hydra-output/artifacts/vmlinuz
|
|
||||||
cp "$root/boot/initramfs-live.img" /hydra-output/artifacts/initramfs-live.img
|
|
||||||
cp "$root/usr/share/hydra/build-manifest.json" /hydra-output/artifacts/rootfs-manifest.json
|
|
||||||
# Keep ownership for safe retries outside the shipped payload.
|
|
||||||
mksquashfs "$root" /hydra-output/artifacts/squashfs.img -noappend -comp zstd -processors 4 -e boot/initramfs-live.img .hydra-export-owned
|
|
||||||
chmod 0644 /hydra-output/artifacts/vmlinuz /hydra-output/artifacts/initramfs-live.img /hydra-output/artifacts/squashfs.img /hydra-output/artifacts/rootfs-manifest.json
|
|
||||||
"""
|
|
||||||
self.pod('exec', '--env', f'HYDRA_SOURCE_COMMIT={commit}', assembler, '/bin/bash', '-euc', script)
|
|
||||||
if image.source_identity(self.source_dir) != source_inputs:
|
|
||||||
raise BuildError('Source inputs changed during rootfs assembly; completion not recorded')
|
|
||||||
manifest = json.loads((self.work / 'artifacts/rootfs-manifest.json').read_text())
|
|
||||||
if manifest.get('packages') != self.package_inventory():
|
|
||||||
raise BuildError('Payload package inventory differs from the validated package checkpoint')
|
|
||||||
if manifest.get('source_files') != image.source_files(self.source_dir):
|
|
||||||
raise BuildError('Payload source manifest is incomplete or stale')
|
|
||||||
artifacts = {name: image.digest(self.work / 'artifacts' / name) for name in
|
|
||||||
('vmlinuz', 'initramfs-live.img', 'squashfs.img', 'rootfs-manifest.json', 'packages.json')}
|
|
||||||
self.record('rootfs', source_inputs=source_inputs, snapshot_sha256=snapshot,
|
|
||||||
assembler=assembler, kernel=manifest['kernel'], artifacts=artifacts)
|
|
||||||
|
|
||||||
def iso(self):
|
|
||||||
from hydra import image
|
|
||||||
if not self.complete('rootfs'):
|
|
||||||
raise BuildError('Verified rootfs artifacts are required before ISO assembly')
|
|
||||||
if self.complete('iso'):
|
|
||||||
return
|
|
||||||
source_inputs = image.source_identity(self.source_dir)
|
|
||||||
assembler = self.assembly()
|
|
||||||
self.record('iso', complete=False)
|
|
||||||
script = r"""tree=/hydra-output/iso-tree
|
|
||||||
[ ! -L "$tree" ] || { echo 'ISO output cannot be a symlink' >&2; exit 1; }
|
|
||||||
mkdir -p "$tree/boot/grub" "$tree/LiveOS"
|
|
||||||
cp /hydra-output/artifacts/vmlinuz "$tree/boot/vmlinuz"
|
|
||||||
cp /hydra-output/artifacts/initramfs-live.img "$tree/boot/initramfs-live.img"
|
|
||||||
cp /hydra-output/artifacts/squashfs.img "$tree/LiveOS/squashfs.img"
|
|
||||||
cp /hydra-source/image/grub.cfg "$tree/boot/grub/grub.cfg"
|
|
||||||
cp /hydra-output/artifacts/rootfs-manifest.json "$tree/build-manifest.json"
|
|
||||||
cp /hydra-output/artifacts/packages.json "$tree/packages.json"
|
|
||||||
grub-mkrescue --directory=/usr/lib/grub/x86_64-efi --output=/hydra-output/artifacts/hydra-os.iso "$tree" -- -volid HYDRA_OS
|
|
||||||
xorriso -indev /hydra-output/artifacts/hydra-os.iso -report_el_torito plain -pvd_info > /hydra-output/artifacts/iso-inspection.txt 2>&1
|
|
||||||
chmod 0644 /hydra-output/artifacts/hydra-os.iso /hydra-output/artifacts/iso-inspection.txt
|
|
||||||
"""
|
|
||||||
self.pod('exec', assembler, '/bin/bash', '-euc', script)
|
|
||||||
inspection = (self.work / 'artifacts/iso-inspection.txt').read_text()
|
|
||||||
if 'HYDRA_OS' not in inspection or not re.search(r'El Torito boot img.*UEFI', inspection):
|
|
||||||
raise BuildError('ISO does not report the expected volume label and UEFI boot image')
|
|
||||||
if image.source_identity(self.source_dir) != source_inputs:
|
|
||||||
raise BuildError('Source inputs changed during ISO assembly; completion not recorded')
|
|
||||||
checksum = image.digest(self.work / 'artifacts/hydra-os.iso')
|
|
||||||
(self.work / 'artifacts/hydra-os.iso.sha256').write_text(checksum + ' hydra-os.iso\n')
|
|
||||||
artifacts = {name: image.digest(self.work / 'artifacts' / name) for name in
|
|
||||||
('hydra-os.iso', 'hydra-os.iso.sha256', 'iso-inspection.txt', 'packages.json')}
|
|
||||||
self.record('iso', source_inputs=source_inputs, label=image.LABEL, artifacts=artifacts)
|
|
||||||
|
|
||||||
def status(self):
|
def status(self):
|
||||||
data = self.manifest()
|
data = self.manifest()
|
||||||
@@ -571,6 +380,8 @@ def main(argv=None):
|
|||||||
builder.status()
|
builder.status()
|
||||||
return 0
|
return 0
|
||||||
with builder.locked():
|
with builder.locked():
|
||||||
|
if options.step in ('rootfs', 'iso'):
|
||||||
|
raise BuildError(f'{options.step} assembly is not configured yet')
|
||||||
if options.step == 'packages':
|
if options.step == 'packages':
|
||||||
builder.packages(options.pretend)
|
builder.packages(options.pretend)
|
||||||
else:
|
else:
|
||||||
|
|||||||
+10
-97
@@ -7,57 +7,26 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .profiles import Profile, load_defaults
|
from .profiles import Profile
|
||||||
from .runtime import Runtime, RuntimeError
|
from .runtime import Runtime, RuntimeError
|
||||||
from .session import launch_session, session_helper, view
|
from .session import launch_session, session_helper, view
|
||||||
|
|
||||||
|
|
||||||
def parser() -> argparse.ArgumentParser:
|
def parser() -> argparse.ArgumentParser:
|
||||||
cli = argparse.ArgumentParser(prog='hydra', description='Hydra OS independent VM manager')
|
cli = argparse.ArgumentParser(prog='hydra', description='Independent HydraOS VM manager')
|
||||||
cli.add_argument('--config', type=Path, default=Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'hydra')
|
cli.add_argument('--config', type=Path, default=Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'hydra')
|
||||||
cli.add_argument('--state', type=Path, default=Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local/state')) / 'hydra')
|
cli.add_argument('--state', type=Path, default=Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local/state')) / 'hydra')
|
||||||
cli.add_argument('--runtime', type=Path, default=Path(os.environ.get('XDG_RUNTIME_DIR', f'/tmp/hydra-{os.getuid()}')) / 'hydra')
|
cli.add_argument('--runtime', type=Path, default=Path(os.environ.get('XDG_RUNTIME_DIR', f'/tmp/hydra-{os.getuid()}')) / 'hydra')
|
||||||
cli.add_argument('--defaults', type=Path, default=Path('/etc/hydra/defaults.toml'),
|
commands = cli.add_subparsers(dest='command', required=True)
|
||||||
help='Distribution preferences for newly created profiles')
|
|
||||||
commands = cli.add_subparsers(dest='command')
|
|
||||||
commands.add_parser('menu', help='Open the terminal VM menu')
|
|
||||||
doctor = commands.add_parser('doctor', help='Read-only laptop hardware observations')
|
|
||||||
doctor.add_argument('--json', action='store_true', help='Print structured observations')
|
|
||||||
update = commands.add_parser('update', help='Signed runtime updates and rollback')
|
|
||||||
update.add_argument('update_args', nargs=argparse.REMAINDER)
|
|
||||||
download = commands.add_parser('download', help='Download and validate a direct ISO URL')
|
|
||||||
download.add_argument('url')
|
|
||||||
download.add_argument('--sha256')
|
|
||||||
run = commands.add_parser('run', help='Create, start and display a VM from a local ISO or HTTP(S) URL')
|
|
||||||
run.add_argument('source')
|
|
||||||
run.add_argument('--name', required=True)
|
|
||||||
run.add_argument('--memory', '--ram', type=int, default=4096, help='VM RAM in MiB')
|
|
||||||
run.add_argument('--cpus', type=int, default=2)
|
|
||||||
run.add_argument('--disk-size', default='40G')
|
|
||||||
run.add_argument('--sha256')
|
|
||||||
run.add_argument('--no-view', action='store_true')
|
|
||||||
run.add_argument('--windowed', action='store_true')
|
|
||||||
opener = commands.add_parser('open', help='Start a stopped VM or reconnect a running VM')
|
|
||||||
opener.add_argument('name')
|
|
||||||
opener.add_argument('--no-view', action='store_true')
|
|
||||||
opener.add_argument('--windowed', action='store_true')
|
|
||||||
add = commands.add_parser('add', help='Create a VM profile without starting it')
|
add = commands.add_parser('add', help='Create a VM profile without starting it')
|
||||||
add.add_argument('name')
|
add.add_argument('name')
|
||||||
add.add_argument('--iso', type=Path)
|
add.add_argument('--iso', type=Path, required=True)
|
||||||
add.add_argument('--memory', type=int, default=4096)
|
add.add_argument('--memory', type=int, default=4096)
|
||||||
add.add_argument('--cpus', type=int, default=2)
|
add.add_argument('--cpus', type=int, default=2)
|
||||||
add.add_argument('--disk-size', default='40G')
|
add.add_argument('--disk-size', default='40G')
|
||||||
add.add_argument('--graphics', choices=('auto', 'software'), default='auto')
|
add.add_argument('--graphics', choices=('auto', 'software'), default='auto')
|
||||||
add.add_argument('--network', choices=('user', 'bridge'))
|
add.add_argument('--network', choices=('user', 'bridge'), default='user')
|
||||||
add.add_argument('--bridge')
|
add.add_argument('--bridge', default='hydrabr0')
|
||||||
add.add_argument('--width', type=int)
|
|
||||||
add.add_argument('--height', type=int)
|
|
||||||
add.add_argument('--refresh-millihz', type=int)
|
|
||||||
media = commands.add_parser('media', help='Attach or detach installation media while stopped')
|
|
||||||
media.add_argument('name')
|
|
||||||
selection = media.add_mutually_exclusive_group(required=True)
|
|
||||||
selection.add_argument('--iso', type=Path)
|
|
||||||
selection.add_argument('--detach', action='store_true')
|
|
||||||
commands.add_parser('list', help='List configured VMs')
|
commands.add_parser('list', help='List configured VMs')
|
||||||
status = commands.add_parser('status', help='Query VM state through private QMP')
|
status = commands.add_parser('status', help='Query VM state through private QMP')
|
||||||
status.add_argument('name', nargs='?')
|
status.add_argument('name', nargs='?')
|
||||||
@@ -77,57 +46,10 @@ def parser() -> argparse.ArgumentParser:
|
|||||||
return cli
|
return cli
|
||||||
|
|
||||||
|
|
||||||
def _download_progress():
|
|
||||||
last = None
|
|
||||||
interactive = sys.stderr.isatty()
|
|
||||||
def report(received, total):
|
|
||||||
nonlocal last
|
|
||||||
step = received * 100 // total if total else received // (64 * 1024 * 1024)
|
|
||||||
if last == step:
|
|
||||||
return
|
|
||||||
last = step
|
|
||||||
if not interactive and received and (total is None or received != total):
|
|
||||||
return
|
|
||||||
suffix = f' / {total // (1024 * 1024)} MiB ({step}%)' if total else ' MiB'
|
|
||||||
print(f'Download: {received // (1024 * 1024)}{suffix}', file=sys.stderr)
|
|
||||||
return report
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
cli = parser()
|
args = parser().parse_args(argv)
|
||||||
args = cli.parse_args(argv)
|
|
||||||
try:
|
|
||||||
if args.command == 'doctor':
|
|
||||||
from .doctor import doctor_main
|
|
||||||
return doctor_main(['--json'] if args.json else [])
|
|
||||||
if args.command == 'update':
|
|
||||||
checkout = Path(__file__).resolve().parents[1]
|
|
||||||
if checkout == Path('/usr/lib/hydra'):
|
|
||||||
command = ['/usr/bin/hydra-update']
|
|
||||||
else:
|
|
||||||
command = [sys.executable, '-I', str(checkout / 'bin/hydra-update')]
|
|
||||||
return subprocess.run(command + args.update_args, check=False).returncode
|
|
||||||
runtime = Runtime(args.config, args.state, args.runtime)
|
runtime = Runtime(args.config, args.state, args.runtime)
|
||||||
if args.command is None and not sys.stdin.isatty():
|
try:
|
||||||
cli.print_help()
|
|
||||||
return 0
|
|
||||||
if args.command in (None, 'menu'):
|
|
||||||
from .launch import session_manifest
|
|
||||||
from .menu import run_menu
|
|
||||||
return run_menu(runtime, manifest=session_manifest(runtime), defaults=args.defaults)
|
|
||||||
if args.command == 'download':
|
|
||||||
from .media import download_iso
|
|
||||||
print(download_iso(args.url, sha256=args.sha256, progress=_download_progress()))
|
|
||||||
return 0
|
|
||||||
if args.command == 'run':
|
|
||||||
from .launch import run_source
|
|
||||||
return run_source(runtime, args.source, name=args.name, memory=args.memory,
|
|
||||||
cpus=args.cpus, disk_size=args.disk_size, sha256=args.sha256,
|
|
||||||
no_view=args.no_view, windowed=args.windowed, defaults=args.defaults,
|
|
||||||
progress=_download_progress())
|
|
||||||
if args.command == 'open':
|
|
||||||
from .launch import open_vm
|
|
||||||
return open_vm(runtime, args.name, no_view=args.no_view, windowed=args.windowed)
|
|
||||||
if args.command == 'view':
|
if args.command == 'view':
|
||||||
return view(runtime, args.name, windowed=args.windowed)
|
return view(runtime, args.name, windowed=args.windowed)
|
||||||
elif args.command == 'session':
|
elif args.command == 'session':
|
||||||
@@ -135,18 +57,12 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
elif args.command == '_session-helper':
|
elif args.command == '_session-helper':
|
||||||
return session_helper(args.operation, args.manifest)
|
return session_helper(args.operation, args.manifest)
|
||||||
elif args.command == 'add':
|
elif args.command == 'add':
|
||||||
preferences = load_defaults(args.defaults)
|
|
||||||
preferences.update({key: getattr(args, key) for key in
|
|
||||||
('network', 'bridge', 'width', 'height', 'refresh_millihz')
|
|
||||||
if getattr(args, key) is not None})
|
|
||||||
profile = Profile(args.name, args.iso, args.memory, args.cpus, args.disk_size,
|
profile = Profile(args.name, args.iso, args.memory, args.cpus, args.disk_size,
|
||||||
args.graphics, **preferences)
|
args.graphics, args.network, args.bridge)
|
||||||
runtime.add(profile)
|
runtime.add(profile)
|
||||||
result = {'name': profile.name, 'status': 'added'}
|
result = {'name': profile.name, 'status': 'added'}
|
||||||
elif args.command == 'media':
|
|
||||||
result = runtime.media(args.name, None if args.detach else args.iso)
|
|
||||||
elif args.command == 'list':
|
elif args.command == 'list':
|
||||||
result = [{'name': profile.name, 'iso': str(profile.iso) if profile.iso else None} for profile in runtime.profiles()]
|
result = [{'name': profile.name, 'iso': str(profile.iso)} for profile in runtime.profiles()]
|
||||||
elif args.command == 'status':
|
elif args.command == 'status':
|
||||||
result = runtime.status(args.name) if args.name else [runtime.status(p.name) for p in runtime.profiles()]
|
result = runtime.status(args.name) if args.name else [runtime.status(p.name) for p in runtime.profiles()]
|
||||||
elif args.command == 'stop':
|
elif args.command == 'stop':
|
||||||
@@ -155,9 +71,6 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
result = getattr(runtime, args.command)(args.name)
|
result = getattr(runtime, args.command)(args.name)
|
||||||
print(json.dumps(result, indent=2))
|
print(json.dumps(result, indent=2))
|
||||||
return 0
|
return 0
|
||||||
except KeyboardInterrupt:
|
|
||||||
print('hydra: Cancelled', file=sys.stderr)
|
|
||||||
return 130
|
|
||||||
except (RuntimeError, OSError, ValueError, subprocess.TimeoutExpired) as exc:
|
except (RuntimeError, OSError, ValueError, subprocess.TimeoutExpired) as exc:
|
||||||
print(f'hydra: {exc}', file=sys.stderr)
|
print(f'hydra: {exc}', file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
-188
@@ -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)
|
|
||||||
+48
-283
@@ -1,4 +1,4 @@
|
|||||||
"""Explicit whole-disk and existing-partition installer. Importing this module never mutates storage.
|
"""Explicit whole-disk installer. Importing this module never mutates storage.
|
||||||
|
|
||||||
Live payload contract: /etc/hydra/live-mode/active and remove-on-install.json
|
Live payload contract: /etc/hydra/live-mode/active and remove-on-install.json
|
||||||
must be regular files. The manifest is a JSON array of absolute paths matching
|
must be regular files. The manifest is a JSON array of absolute paths matching
|
||||||
@@ -10,7 +10,6 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import getpass
|
import getpass
|
||||||
import fcntl
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
@@ -18,17 +17,12 @@ import re
|
|||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import stat
|
import stat
|
||||||
import struct
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import warnings
|
import warnings
|
||||||
import uuid
|
|
||||||
|
|
||||||
from typing import TextIO
|
from typing import TextIO
|
||||||
|
|
||||||
from . import install_storage as storage
|
|
||||||
|
|
||||||
DEFAULT_PAYLOAD = '/run/initramfs/live/LiveOS/squashfs.img'
|
DEFAULT_PAYLOAD = '/run/initramfs/live/LiveOS/squashfs.img'
|
||||||
LIVE_REMOVABLE = (
|
LIVE_REMOVABLE = (
|
||||||
'/etc/systemd/system/getty@*.service.d/90-hydra-live.conf',
|
'/etc/systemd/system/getty@*.service.d/90-hydra-live.conf',
|
||||||
@@ -50,18 +44,13 @@ class InstallError(RuntimeError):
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Request:
|
class Request:
|
||||||
disk: str | None
|
disk: str
|
||||||
user: str
|
user: str
|
||||||
payload: str = DEFAULT_PAYLOAD
|
payload: str = DEFAULT_PAYLOAD
|
||||||
execute: bool = False
|
execute: bool = False
|
||||||
confirm_erase: str | None = None
|
confirm_erase: str | None = None
|
||||||
timezone: str = 'Europe/Moscow'
|
timezone: str = 'Europe/Moscow'
|
||||||
kernel: str | None = None
|
kernel: str | None = None
|
||||||
root_partition: str | None = None
|
|
||||||
esp_partition: str | None = None
|
|
||||||
confirm_format: str | None = None
|
|
||||||
root_fs: str = 'ext4'
|
|
||||||
register_boot_entry: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class Runner:
|
class Runner:
|
||||||
@@ -83,7 +72,6 @@ class Topology:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.nodes = {}
|
self.nodes = {}
|
||||||
self.edges = {}
|
self.edges = {}
|
||||||
self.parents = {}
|
|
||||||
self.protected = set()
|
self.protected = set()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -118,17 +106,10 @@ class Topology:
|
|||||||
raise InstallError('Zero-size device is not an unrelated empty optical drive')
|
raise InstallError('Zero-size device is not an unrelated empty optical drive')
|
||||||
if not isinstance(identity, str) or not re.fullmatch(r'\d+:\d+', identity):
|
if not isinstance(identity, str) or not re.fullmatch(r'\d+:\d+', identity):
|
||||||
raise InstallError('Invalid lsblk device identity')
|
raise InstallError('Invalid lsblk device identity')
|
||||||
for key in ('partuuid', 'parttype', 'fstype', 'uuid'):
|
if path in result.nodes and any(result.nodes[path][key] != node[key] for key in ('type', 'size', 'maj:min', 'mountpoints')):
|
||||||
if node.get(key) is not None and not isinstance(node[key], str):
|
|
||||||
raise InstallError('Malformed lsblk storage metadata')
|
|
||||||
if node.get('start') is not None and (isinstance(node['start'], bool) or not isinstance(node['start'], int)):
|
|
||||||
raise InstallError('Malformed lsblk partition start')
|
|
||||||
if path in result.nodes and any(result.nodes[path].get(key) != node.get(key) for key in
|
|
||||||
('type', 'size', 'maj:min', 'mountpoints', 'start', 'partuuid', 'parttype', 'fstype', 'uuid')):
|
|
||||||
raise InstallError('Inconsistent duplicate lsblk node')
|
raise InstallError('Inconsistent duplicate lsblk node')
|
||||||
result.nodes[path] = node
|
result.nodes[path] = node
|
||||||
result.edges.setdefault(path, set())
|
result.edges.setdefault(path, set())
|
||||||
result.parents.setdefault(path, set())
|
|
||||||
if any(m for m in mounts):
|
if any(m for m in mounts):
|
||||||
result.protected.add(path)
|
result.protected.add(path)
|
||||||
pkname = node.get('pkname')
|
pkname = node.get('pkname')
|
||||||
@@ -185,17 +166,6 @@ class Topology:
|
|||||||
def link(self, child, parent):
|
def link(self, child, parent):
|
||||||
if child == parent or child not in self.nodes or parent not in self.nodes:
|
if child == parent or child not in self.nodes or parent not in self.nodes:
|
||||||
raise InstallError('Unresolved or cyclic block-device parent')
|
raise InstallError('Unresolved or cyclic block-device parent')
|
||||||
# Backing links discovered after lsblk must obey the same directed
|
|
||||||
# ancestry rule. An undirected shared disk is not itself a cycle.
|
|
||||||
seen, pending = set(), [parent]
|
|
||||||
while pending:
|
|
||||||
current = pending.pop()
|
|
||||||
if current == child:
|
|
||||||
raise InstallError('Cyclic block-device backing ancestry')
|
|
||||||
if current not in seen:
|
|
||||||
seen.add(current)
|
|
||||||
pending.extend(self.parents[current] - seen)
|
|
||||||
self.parents[child].add(parent)
|
|
||||||
self.edges[child].add(parent)
|
self.edges[child].add(parent)
|
||||||
self.edges[parent].add(child)
|
self.edges[parent].add(child)
|
||||||
|
|
||||||
@@ -239,7 +209,7 @@ class SystemProbe:
|
|||||||
|
|
||||||
def snapshot(self, payload):
|
def snapshot(self, payload):
|
||||||
raw = self.runner.run(['lsblk', '--json', '--paths', '--bytes', '--output',
|
raw = self.runner.run(['lsblk', '--json', '--paths', '--bytes', '--output',
|
||||||
'PATH,TYPE,PKNAME,MOUNTPOINTS,SIZE,MAJ:MIN,START,PARTUUID,PARTTYPE,FSTYPE,UUID'])
|
'PATH,TYPE,PKNAME,MOUNTPOINTS,SIZE,MAJ:MIN'])
|
||||||
# lsblk PATH can use /dev/mapper aliases while findmnt uses /dev/dm-N.
|
# lsblk PATH can use /dev/mapper aliases while findmnt uses /dev/dm-N.
|
||||||
try:
|
try:
|
||||||
decoded = json.loads(raw)
|
decoded = json.loads(raw)
|
||||||
@@ -268,6 +238,21 @@ class SystemProbe:
|
|||||||
source = self._block_source(mount, topo)
|
source = self._block_source(mount, topo)
|
||||||
if source:
|
if source:
|
||||||
topo.protect(source)
|
topo.protect(source)
|
||||||
|
# Mounted loops may hide the physical filesystem that stores their file.
|
||||||
|
for path, node in topo.nodes.items():
|
||||||
|
if node['type'] == 'loop' and path in topo.protected:
|
||||||
|
backing_file = Path('/sys/dev/block') / node['maj:min'] / 'loop/backing_file'
|
||||||
|
try:
|
||||||
|
backing = backing_file.read_text().strip()
|
||||||
|
except OSError as exc:
|
||||||
|
raise InstallError(f'Cannot inspect mounted loop backing file: {path}') from exc
|
||||||
|
backing = re.sub(r'\\([0-7]{3})', lambda m: chr(int(m[1], 8)), backing)
|
||||||
|
if not backing.startswith('/'):
|
||||||
|
backing = '/' + backing
|
||||||
|
source = self._source_for_path(backing, topo)
|
||||||
|
if not source:
|
||||||
|
raise InstallError(f'Unknown physical backing for mounted loop: {path}')
|
||||||
|
topo.link(path, source)
|
||||||
if not Path(payload).is_file():
|
if not Path(payload).is_file():
|
||||||
raise InstallError(f'Prepared squashfs payload is not a regular file: {payload}')
|
raise InstallError(f'Prepared squashfs payload is not a regular file: {payload}')
|
||||||
source = self._source_for_path(self.resolve(payload), topo)
|
source = self._source_for_path(self.resolve(payload), topo)
|
||||||
@@ -275,56 +260,8 @@ class SystemProbe:
|
|||||||
# tmpfs/overlay payload location cannot establish live-medium ancestry.
|
# tmpfs/overlay payload location cannot establish live-medium ancestry.
|
||||||
raise InstallError('Cannot identify the payload backing block device; use --payload on the mounted live medium')
|
raise InstallError('Cannot identify the payload backing block device; use --payload on the mounted live medium')
|
||||||
topo.protect(source)
|
topo.protect(source)
|
||||||
# Loops can hide further loops, including ancestors of a mounted
|
|
||||||
# partition or device mapper. Resolve protected components to a fixed
|
|
||||||
# point, independent of lsblk ordering and payload discovery order.
|
|
||||||
inspected = set()
|
|
||||||
while True:
|
|
||||||
protected_devices = set()
|
|
||||||
for path in topo.protected:
|
|
||||||
protected_devices.update(topo.component(path))
|
|
||||||
pending = [path for path in topo.nodes if path in protected_devices
|
|
||||||
and topo.nodes[path]['type'] == 'loop' and path not in inspected]
|
|
||||||
if not pending:
|
|
||||||
break
|
|
||||||
for path in pending:
|
|
||||||
identity = self._loop_backing_identity(path, topo.nodes[path]['maj:min'])
|
|
||||||
sources = [p for p, n in topo.nodes.items() if n['maj:min'] == identity]
|
|
||||||
if not sources:
|
|
||||||
btrfs_mounts = [m for m in mounts if m['fstype'] == 'btrfs' and m['maj:min'] == identity]
|
|
||||||
member_sets = [set(self._btrfs_members(m, topo)) for m in btrfs_mounts]
|
|
||||||
if not member_sets or any(members != member_sets[0] for members in member_sets[1:]):
|
|
||||||
raise InstallError(f'Unknown physical backing for mounted loop: {path} ({identity})')
|
|
||||||
sources = sorted(member_sets[0])
|
|
||||||
for source in sources:
|
|
||||||
topo.link(path, source)
|
|
||||||
topo.protect(source)
|
|
||||||
inspected.add(path)
|
|
||||||
return topo
|
return topo
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _loop_backing_identity(path, expected_identity):
|
|
||||||
# LOOP_GET_STATUS64 reports the backing inode's filesystem device,
|
|
||||||
# independent of names which dracut switch_root may have relocated.
|
|
||||||
# lo_rdevice is nonzero when the backing inode is itself a block device.
|
|
||||||
try:
|
|
||||||
descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC)
|
|
||||||
try:
|
|
||||||
info = os.fstat(descriptor)
|
|
||||||
identity = f'{os.major(info.st_rdev)}:{os.minor(info.st_rdev)}'
|
|
||||||
if not stat.S_ISBLK(info.st_mode) or identity != expected_identity:
|
|
||||||
raise InstallError(f'Mounted loop device identity changed: {path}')
|
|
||||||
status = fcntl.ioctl(descriptor, 0x4C05, bytes(232))
|
|
||||||
if not isinstance(status, bytes) or len(status) != 232:
|
|
||||||
raise InstallError(f'Malformed loop status: {path}')
|
|
||||||
filesystem_device, _, block_device = struct.unpack_from('=QQQ', status)
|
|
||||||
finally:
|
|
||||||
os.close(descriptor)
|
|
||||||
except (OSError, struct.error) as exc:
|
|
||||||
raise InstallError(f'Cannot inspect mounted loop backing identity: {path}') from exc
|
|
||||||
device = block_device or filesystem_device
|
|
||||||
return f'{os.major(device)}:{os.minor(device)}'
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _mounts(raw):
|
def _mounts(raw):
|
||||||
try:
|
try:
|
||||||
@@ -350,9 +287,7 @@ class SystemProbe:
|
|||||||
raise InstallError('Malformed findmnt topology') from exc
|
raise InstallError('Malformed findmnt topology') from exc
|
||||||
|
|
||||||
def _block_source(self, mount, topo):
|
def _block_source(self, mount, topo):
|
||||||
if mount['fstype'] == 'btrfs':
|
if mount['fstype'] in {'zfs', 'btrfs'}:
|
||||||
return self._btrfs_source(mount, topo)
|
|
||||||
if mount['fstype'] == 'zfs':
|
|
||||||
raise InstallError('Cannot safely resolve all backing disks of mounted ' + mount['fstype'])
|
raise InstallError('Cannot safely resolve all backing disks of mounted ' + mount['fstype'])
|
||||||
source = mount['source'].split('[', 1)[0]
|
source = mount['source'].split('[', 1)[0]
|
||||||
identity = mount['maj:min']
|
identity = mount['maj:min']
|
||||||
@@ -373,49 +308,6 @@ class SystemProbe:
|
|||||||
raise InstallError(f'Unknown mounted block source: {source}')
|
raise InstallError(f'Unknown mounted block source: {source}')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _btrfs_source(self, mount, topo):
|
|
||||||
return self._btrfs_members(mount, topo)[0]
|
|
||||||
|
|
||||||
def _btrfs_members(self, mount, topo):
|
|
||||||
if not Path(mount['target']).is_absolute() or any(ord(c) < 32 for c in mount['target']):
|
|
||||||
raise InstallError('Invalid mounted btrfs path')
|
|
||||||
raw = self.runner.run(['btrfs', 'filesystem', 'show', '--raw', mount['target']])
|
|
||||||
counts = re.findall(r'Total devices (\d+) FS bytes used \d+', raw)
|
|
||||||
rows = re.findall(r'^\s*devid\s+(\d+)\s+size\s+\d+\s+used\s+\d+\s+path\s+(/dev/[^\s]+)\s*$', raw, re.MULTILINE)
|
|
||||||
if len(counts) != 1 or int(counts[0]) <= 0 or len(rows) != int(counts[0]) or len({row[0] for row in rows}) != len(rows):
|
|
||||||
raise InstallError('Cannot safely resolve all backing members of mounted btrfs')
|
|
||||||
paths = [self.resolve(row[1]) for row in rows]
|
|
||||||
if len(set(paths)) != len(paths) or any(path not in topo.nodes for path in paths):
|
|
||||||
raise InstallError('Unknown or duplicate mounted btrfs backing member')
|
|
||||||
source = mount['source'].split('[', 1)[0]
|
|
||||||
if source.startswith('/dev/') and self.resolve(source) in topo.nodes and self.resolve(source) not in paths:
|
|
||||||
raise InstallError('Mounted btrfs source is absent from backing members')
|
|
||||||
for path in paths:
|
|
||||||
topo.protect(path)
|
|
||||||
return paths
|
|
||||||
|
|
||||||
def verify_identity(self, path, node):
|
|
||||||
try:
|
|
||||||
info = os.stat(path)
|
|
||||||
identity = f'{os.major(info.st_rdev)}:{os.minor(info.st_rdev)}'
|
|
||||||
if not stat.S_ISBLK(info.st_mode) or identity != node['maj:min']:
|
|
||||||
raise InstallError('Block device identity changed: ' + path)
|
|
||||||
sysfs = Path('/sys/dev/block') / identity
|
|
||||||
if int((sysfs / 'size').read_text()) * 512 != node['size']:
|
|
||||||
raise InstallError('Block device capacity changed: ' + path)
|
|
||||||
if list((sysfs / 'holders').iterdir()):
|
|
||||||
raise InstallError('Selected device has active holders: ' + path)
|
|
||||||
if node['type'] == 'part' and int((sysfs / 'start').read_text()) != node['start']:
|
|
||||||
raise InstallError('Partition bounds changed: ' + path)
|
|
||||||
except (OSError, ValueError) as exc:
|
|
||||||
raise InstallError('Cannot verify kernel block identity: ' + path) from exc
|
|
||||||
|
|
||||||
def partition_number(self, path, node):
|
|
||||||
try:
|
|
||||||
return int((Path('/sys/dev/block') / node['maj:min'] / 'partition').read_text())
|
|
||||||
except (OSError, ValueError) as exc:
|
|
||||||
raise InstallError('Cannot read kernel partition number: ' + path) from exc
|
|
||||||
|
|
||||||
def _source_for_path(self, path, topo):
|
def _source_for_path(self, path, topo):
|
||||||
raw = self.runner.run(['findmnt', '--json', '--target', path, '--output',
|
raw = self.runner.run(['findmnt', '--json', '--target', path, '--output',
|
||||||
'SOURCE,TARGET,FSTYPE,MAJ:MIN,OPTIONS'])
|
'SOURCE,TARGET,FSTYPE,MAJ:MIN,OPTIONS'])
|
||||||
@@ -425,17 +317,9 @@ class SystemProbe:
|
|||||||
return self._block_source(mounts[0], topo)
|
return self._block_source(mounts[0], topo)
|
||||||
|
|
||||||
|
|
||||||
def validate_request(request, probe, *, return_plan=False):
|
def validate_request(request, probe):
|
||||||
existing = request.root_partition is not None or request.esp_partition is not None
|
if not isinstance(request.disk, str) or not DEVICE.fullmatch(request.disk) or '..' in PurePosixPath(request.disk).parts:
|
||||||
if request.root_fs not in {'ext4', 'btrfs'}:
|
|
||||||
raise InstallError('Root filesystem must be ext4 or btrfs')
|
|
||||||
if existing:
|
|
||||||
if request.disk is not None or request.confirm_erase is not None:
|
|
||||||
raise InstallError('Choose exactly one installation mode; partitions cannot use --disk/--confirm-erase')
|
|
||||||
elif not isinstance(request.disk, str) or not DEVICE.fullmatch(request.disk) or '..' in PurePosixPath(request.disk).parts:
|
|
||||||
raise InstallError('Choose an explicit /dev whole-disk device')
|
raise InstallError('Choose an explicit /dev whole-disk device')
|
||||||
elif request.confirm_format is not None:
|
|
||||||
raise InstallError('--confirm-format is only valid for existing partitions')
|
|
||||||
if not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', request.user) or request.user in {'root', 'hydra-live', 'greetd'}:
|
if not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', request.user) or request.user in {'root', 'hydra-live', 'greetd'}:
|
||||||
raise InstallError('Choose a valid nonreserved lowercase Unix username')
|
raise InstallError('Choose a valid nonreserved lowercase Unix username')
|
||||||
if not re.fullmatch(r'[A-Za-z_+-]+(?:/[A-Za-z0-9_+-]+)+', request.timezone):
|
if not re.fullmatch(r'[A-Za-z_+-]+(?:/[A-Za-z0-9_+-]+)+', request.timezone):
|
||||||
@@ -444,9 +328,6 @@ def validate_request(request, probe, *, return_plan=False):
|
|||||||
raise InstallError('Payload must be an absolute path without control characters')
|
raise InstallError('Payload must be an absolute path without control characters')
|
||||||
if request.kernel is not None and not KERNEL.fullmatch(request.kernel):
|
if request.kernel is not None and not KERNEL.fullmatch(request.kernel):
|
||||||
raise InstallError('Invalid kernel version')
|
raise InstallError('Invalid kernel version')
|
||||||
if existing:
|
|
||||||
plan = storage.partition_plan(request, probe, probe.snapshot(request.payload))
|
|
||||||
return plan if return_plan else plan.root
|
|
||||||
target = probe.resolve(request.disk)
|
target = probe.resolve(request.disk)
|
||||||
if not DEVICE.fullmatch(target) or not probe.physical_disk(target):
|
if not DEVICE.fullmatch(target) or not probe.physical_disk(target):
|
||||||
raise InstallError(f'Target is not a physical whole block disk: {target}')
|
raise InstallError(f'Target is not a physical whole block disk: {target}')
|
||||||
@@ -460,10 +341,6 @@ def validate_request(request, probe, *, return_plan=False):
|
|||||||
forbidden = topo.component(target) & topo.protected
|
forbidden = topo.component(target) & topo.protected
|
||||||
if forbidden:
|
if forbidden:
|
||||||
raise InstallError('Target backs mounted/root/live storage: ' + ', '.join(sorted(forbidden)))
|
raise InstallError('Target backs mounted/root/live storage: ' + ', '.join(sorted(forbidden)))
|
||||||
probe.verify_identity(target, topo.nodes[target])
|
|
||||||
if return_plan:
|
|
||||||
return storage.DiskPlan(target, topo.nodes[target]['size'],
|
|
||||||
tuple(sorted((p, storage.fingerprint(topo.nodes[p])) for p in topo.component(target))))
|
|
||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
@@ -472,7 +349,7 @@ def partitions(target):
|
|||||||
return target + suffix + '1', target + suffix + '2'
|
return target + suffix + '1', target + suffix + '2'
|
||||||
|
|
||||||
|
|
||||||
def partition_commands(target, root_fs='ext4'):
|
def partition_commands(target):
|
||||||
esp, root = partitions(target)
|
esp, root = partitions(target)
|
||||||
return [
|
return [
|
||||||
['parted', '--script', target, 'mklabel', 'gpt'],
|
['parted', '--script', target, 'mklabel', 'gpt'],
|
||||||
@@ -481,38 +358,20 @@ def partition_commands(target, root_fs='ext4'):
|
|||||||
['parted', '--script', target, 'unit', 'MiB', 'mkpart', 'root', 'ext4', '1025MiB', '100%'],
|
['parted', '--script', target, 'unit', 'MiB', 'mkpart', 'root', 'ext4', '1025MiB', '100%'],
|
||||||
['partprobe', target], ['udevadm', 'settle'],
|
['partprobe', target], ['udevadm', 'settle'],
|
||||||
['mkfs.vfat', '-F', '32', '-n', 'HYDRA_EFI', esp],
|
['mkfs.vfat', '-F', '32', '-n', 'HYDRA_EFI', esp],
|
||||||
storage.format_root(root, root_fs),
|
['mkfs.ext4', '-F', '-L', 'HYDRA_ROOT', root],
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def grub_command(root, preserve=False):
|
def grub_command(root):
|
||||||
return ['chroot', str(root), 'grub-install', '--target=x86_64-efi',
|
return ['chroot', str(root), 'grub-install', '--target=x86_64-efi',
|
||||||
'--efi-directory=/boot/efi', '--bootloader-id=HydraOS',
|
'--efi-directory=/boot/efi', '--bootloader-id=HydraOS', '--removable', '--no-nvram']
|
||||||
*([] if preserve else ['--removable']), '--no-nvram']
|
|
||||||
|
|
||||||
|
|
||||||
def render_plan(request, target, root):
|
def render_plan(request, target, root):
|
||||||
existing = request.root_partition is not None
|
esp, part = partitions(target)
|
||||||
esp, part = (request.esp_partition, target) if existing else partitions(target)
|
commands = partition_commands(target) + [
|
||||||
commands = [['unsquashfs', '-s', request.payload], ['unsquashfs', '-lln', request.payload]]
|
['mount', part, str(root)], ['unsquashfs', '-f', '-d', str(root), request.payload],
|
||||||
if existing:
|
['mount', esp, str(Path(root) / 'boot/efi')],
|
||||||
temporary = str(Path(root).parent / 'esp-preflight-<random>')
|
|
||||||
commands.extend([['mount', '-t', 'vfat', '-o', 'ro,nosuid,nodev,noexec', esp, temporary],
|
|
||||||
['umount', temporary]])
|
|
||||||
commands.extend([storage.format_root(part, request.root_fs)] if existing else partition_commands(target, request.root_fs))
|
|
||||||
if request.root_fs == 'btrfs':
|
|
||||||
top = str(Path(root).parent / 'btrfs-top')
|
|
||||||
commands.append(['mount', '-t', 'btrfs', '-o', 'subvolid=5', part, top])
|
|
||||||
commands.extend(['btrfs', 'subvolume', 'create', str(Path(top) / name)]
|
|
||||||
for name in ('@root', '@home', '@snapshots'))
|
|
||||||
commands.extend([['umount', top], ['mount', '-t', 'btrfs', '-o', 'subvol=@root', part, str(root)],
|
|
||||||
['mount', '-t', 'btrfs', '-o', 'subvol=@home', part, str(Path(root) / 'home')],
|
|
||||||
['mount', '-t', 'btrfs', '-o', 'subvol=@snapshots', part, str(Path(root) / '.snapshots')]])
|
|
||||||
else:
|
|
||||||
commands.append(['mount', '-t', 'ext4', part, str(root)])
|
|
||||||
commands += [
|
|
||||||
['unsquashfs', '-f', '-d', str(root), request.payload],
|
|
||||||
['mount', '-t', 'vfat', esp, str(Path(root) / 'boot/efi')],
|
|
||||||
['blkid', '-s', 'UUID', '-o', 'value', part],
|
['blkid', '-s', 'UUID', '-o', 'value', part],
|
||||||
['blkid', '-s', 'UUID', '-o', 'value', esp],
|
['blkid', '-s', 'UUID', '-o', 'value', esp],
|
||||||
['mount', '--rbind', '/dev', str(Path(root) / 'dev')],
|
['mount', '--rbind', '/dev', str(Path(root) / 'dev')],
|
||||||
@@ -527,18 +386,12 @@ def render_plan(request, target, root):
|
|||||||
['chroot', str(root), 'systemd-machine-id-setup'],
|
['chroot', str(root), 'systemd-machine-id-setup'],
|
||||||
['chroot', str(root), 'dracut', '--force', '--no-hostonly', '--omit', 'dmsquash-live',
|
['chroot', str(root), 'dracut', '--force', '--no-hostonly', '--omit', 'dmsquash-live',
|
||||||
'/boot/initramfs-<kernel>.img', '<kernel>'],
|
'/boot/initramfs-<kernel>.img', '<kernel>'],
|
||||||
grub_command(root, preserve=True),
|
grub_command(root), ['chroot', str(root), 'grub-mkconfig', '-o', '/boot/grub/grub.cfg'],
|
||||||
*([] if existing else [grub_command(root)]),
|
|
||||||
['chroot', str(root), 'grub-mkconfig', '-o', '/boot/grub/grub.cfg'],
|
|
||||||
]
|
]
|
||||||
lines = [f'HydraOS {"EXECUTION" if request.execute else "dry-run"}: erase all data on {target}.',
|
lines = [f'HydraOS {"EXECUTION" if request.execute else "dry-run"}: erase all data on {target}.',
|
||||||
f'GPT: exactly 1 GiB FAT32 ESP, remaining space {request.root_fs} root.',
|
'GPT: exactly 1 GiB FAT32 ESP, remaining space ext4 root.',
|
||||||
f'Prepared payload: {request.payload}; account: {request.user}; timezone: {request.timezone}.',
|
f'Prepared payload: {request.payload}; account: {request.user}; timezone: {request.timezone}.',
|
||||||
'Commands (UUIDs/kernel discovered after unpacking; no commands run in dry-run):']
|
'Commands (UUIDs/kernel discovered after unpacking; no commands run in dry-run):']
|
||||||
if existing:
|
|
||||||
lines[:2] = [f'Hydra OS {"EXECUTION" if request.execute else "dry-run"}: format ONLY {target} as {request.root_fs}.',
|
|
||||||
f'Preserve GPT, existing ESP {esp}, foreign EFI loaders, fallback loader and BootOrder.',
|
|
||||||
'First inspect the ESP read-only for collision/free space and hash foreign files.']
|
|
||||||
lines.extend(' ' + shlex.join(c) for c in commands)
|
lines.extend(' ' + shlex.join(c) for c in commands)
|
||||||
lines.extend(['Write UUID fstab, en_US.UTF-8 and ru_RU.UTF-8, timezone, installed dracut/GRUB defaults.',
|
lines.extend(['Write UUID fstab, en_US.UTF-8 and ru_RU.UTF-8, timezone, installed dracut/GRUB defaults.',
|
||||||
'Remove allowlisted live-mode files, hydra-live account and /home/hydra-live; reset machine-id.',
|
'Remove allowlisted live-mode files, hydra-live account and /home/hydra-live; reset machine-id.',
|
||||||
@@ -546,11 +399,6 @@ def render_plan(request, target, root):
|
|||||||
'chpasswd receives the TTY-entered password on private stdin; root remains locked.',
|
'chpasswd receives the TTY-entered password on private stdin; root remains locked.',
|
||||||
'Unmount tracked sys/proc/dev/ESP/root in reverse order on success or failure.',
|
'Unmount tracked sys/proc/dev/ESP/root in reverse order on success or failure.',
|
||||||
f'To execute, add --execute --confirm-erase {target} (as root; no automatic sudo).'])
|
f'To execute, add --execute --confirm-erase {target} (as root; no automatic sudo).'])
|
||||||
if existing:
|
|
||||||
lines[-1] = f'To execute, add --execute --confirm-format {target} (as root; no automatic sudo).'
|
|
||||||
lines.append('Default: select EFI/HydraOS/grubx64.efi in firmware manually; no firmware changes.')
|
|
||||||
if request.root_fs == 'btrfs':
|
|
||||||
lines.append('Btrfs: @root contains /boot and Portage; @home and @snapshots are separate subvolumes.')
|
|
||||||
return '\n'.join(lines)
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -680,10 +528,7 @@ def install(request, runner, probe, root, password, log: TextIO):
|
|||||||
raise InstallError('Installation requires --execute')
|
raise InstallError('Installation requires --execute')
|
||||||
if not isinstance(password, str) or not password or any(c in password for c in '\r\n\x00'):
|
if not isinstance(password, str) or not password or any(c in password for c in '\r\n\x00'):
|
||||||
raise InstallError('Password must be nonempty and contain no newline or NUL')
|
raise InstallError('Password must be nonempty and contain no newline or NUL')
|
||||||
validated = validate_request(request, probe, return_plan=True)
|
target = validate_request(request, probe)
|
||||||
existing = isinstance(validated, storage.PartitionPlan)
|
|
||||||
target = validated.root if existing else validated.target
|
|
||||||
esp, part = (validated.esp, validated.root) if existing else partitions(target)
|
|
||||||
root = Path(root)
|
root = Path(root)
|
||||||
if not root.is_absolute() or root.is_symlink() or not root.is_dir() or any(root.iterdir()):
|
if not root.is_absolute() or root.is_symlink() or not root.is_dir() or any(root.iterdir()):
|
||||||
raise InstallError('Target mount directory must be an empty absolute directory')
|
raise InstallError('Target mount directory must be an empty absolute directory')
|
||||||
@@ -705,46 +550,13 @@ def install(request, runner, probe, root, password, log: TextIO):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
run(['unsquashfs', '-s', request.payload])
|
run(['unsquashfs', '-s', request.payload])
|
||||||
required = storage.payload_capacity(run(['unsquashfs', '-lln', request.payload]))
|
# Recheck after preflight, directly before the first destructive command.
|
||||||
capacity = validated.root_size if existing else validated.size - 1025 * 1024**2
|
if validate_request(request, probe) != target:
|
||||||
if capacity < required:
|
raise InstallError('Target device changed during validation')
|
||||||
raise InstallError('Root has insufficient capacity for the uncompressed payload and install reserve')
|
for argv in partition_commands(target):
|
||||||
foreign = None
|
|
||||||
if existing:
|
|
||||||
# An independent temporary mount keeps the target root empty.
|
|
||||||
temporary = tempfile.mkdtemp(prefix='esp-preflight-', dir=root.parent)
|
|
||||||
mount(['mount', '-t', 'vfat', '-o', 'ro,nosuid,nodev,noexec', esp, temporary], temporary)
|
|
||||||
try:
|
|
||||||
foreign = storage.inspect_esp(temporary)
|
|
||||||
finally:
|
|
||||||
# Never recurse through a directory that could still be mounted.
|
|
||||||
run(['umount', temporary])
|
|
||||||
mounted.pop()
|
|
||||||
try:
|
|
||||||
Path(temporary).rmdir()
|
|
||||||
except OSError:
|
|
||||||
pass # Retain unexpected contents; never delete ESP files.
|
|
||||||
# Recheck the canonical paths, kernel identities, GPT IDs and bounds
|
|
||||||
# after read-only ESP inspection, immediately before formatting.
|
|
||||||
if validate_request(request, probe, return_plan=True) != validated:
|
|
||||||
raise InstallError('Target device identity or partition bounds changed during validation')
|
|
||||||
for argv in ([storage.format_root(part, request.root_fs)] if existing else partition_commands(target, request.root_fs)):
|
|
||||||
run(argv)
|
run(argv)
|
||||||
if request.root_fs == 'btrfs':
|
esp, part = partitions(target)
|
||||||
top = root.parent / 'btrfs-top'
|
mount(['mount', part, str(root)], root)
|
||||||
top.mkdir()
|
|
||||||
mount(['mount', '-t', 'btrfs', '-o', 'subvolid=5', part, str(top)], top)
|
|
||||||
for subvolume in ('@root', '@home', '@snapshots'):
|
|
||||||
run(['btrfs', 'subvolume', 'create', str(top / subvolume)])
|
|
||||||
run(['umount', str(top)])
|
|
||||||
mounted.pop()
|
|
||||||
top.rmdir()
|
|
||||||
mount(['mount', '-t', 'btrfs', '-o', 'subvol=@root', part, str(root)], root)
|
|
||||||
for directory, subvolume in (('home', '@home'), ('.snapshots', '@snapshots')):
|
|
||||||
(root / directory).mkdir()
|
|
||||||
mount(['mount', '-t', 'btrfs', '-o', 'subvol=' + subvolume, part, str(root / directory)], root / directory)
|
|
||||||
else:
|
|
||||||
mount(['mount', '-t', 'ext4', part, str(root)], root)
|
|
||||||
run(['unsquashfs', '-f', '-d', str(root), request.payload])
|
run(['unsquashfs', '-f', '-d', str(root), request.payload])
|
||||||
kernel = select_kernel(root, request.kernel)
|
kernel = select_kernel(root, request.kernel)
|
||||||
remove_live_files(root)
|
remove_live_files(root)
|
||||||
@@ -753,35 +565,12 @@ def install(request, runner, probe, root, password, log: TextIO):
|
|||||||
raise InstallError('Timezone does not exist in payload: ' + request.timezone)
|
raise InstallError('Timezone does not exist in payload: ' + request.timezone)
|
||||||
for folder in ('boot/efi', 'dev', 'proc', 'sys'):
|
for folder in ('boot/efi', 'dev', 'proc', 'sys'):
|
||||||
target_path(root, folder).mkdir(parents=True, exist_ok=True)
|
target_path(root, folder).mkdir(parents=True, exist_ok=True)
|
||||||
mount(['mount', '-t', 'vfat', esp, str(root / 'boot/efi')], root / 'boot/efi')
|
mount(['mount', esp, str(root / 'boot/efi')], root / 'boot/efi')
|
||||||
root_uuid = run(['blkid', '-s', 'UUID', '-o', 'value', part]).strip()
|
root_uuid = run(['blkid', '-s', 'UUID', '-o', 'value', part]).strip()
|
||||||
esp_uuid = run(['blkid', '-s', 'UUID', '-o', 'value', esp]).strip()
|
esp_uuid = run(['blkid', '-s', 'UUID', '-o', 'value', esp]).strip()
|
||||||
if any(not re.fullmatch(r'[A-Fa-f0-9-]+', value) for value in (root_uuid, esp_uuid)):
|
if any(not re.fullmatch(r'[A-Fa-f0-9-]+', value) for value in (root_uuid, esp_uuid)):
|
||||||
raise InstallError('Missing or invalid partition UUID')
|
raise InstallError('Missing or invalid partition UUID')
|
||||||
if request.root_fs == 'btrfs':
|
write_config(root, 'etc/fstab', f'UUID={root_uuid} / ext4 defaults 0 1\nUUID={esp_uuid} /boot/efi vfat umask=0077 0 2\n')
|
||||||
root_lines = (f'UUID={root_uuid} / btrfs defaults,subvol=@root 0 0\n'
|
|
||||||
f'UUID={root_uuid} /home btrfs defaults,subvol=@home 0 0\n'
|
|
||||||
f'UUID={root_uuid} /.snapshots btrfs defaults,subvol=@snapshots 0 0\n')
|
|
||||||
else:
|
|
||||||
root_lines = f'UUID={root_uuid} / ext4 defaults 0 1\n'
|
|
||||||
write_config(root, 'etc/fstab', root_lines + f'UUID={esp_uuid} /boot/efi vfat umask=0077 0 2\n')
|
|
||||||
root_partuuid = run(['blkid', '-s', 'PARTUUID', '-o', 'value', part]).strip()
|
|
||||||
esp_partuuid = run(['blkid', '-s', 'PARTUUID', '-o', 'value', esp]).strip()
|
|
||||||
if not all(storage.PARTUUID.fullmatch(value) for value in (root_partuuid, esp_partuuid)):
|
|
||||||
raise InstallError('Missing or invalid GPT partition UUID for installed storage marker')
|
|
||||||
if existing:
|
|
||||||
before_root, before_esp = validated.root_partuuid, validated.esp_partuuid
|
|
||||||
if (root_partuuid.lower(), esp_partuuid.lower()) != (before_root.lower(), before_esp.lower()):
|
|
||||||
raise InstallError('GPT partition identity changed after formatting')
|
|
||||||
if esp_uuid.lower() != validated.esp_uuid.lower():
|
|
||||||
raise InstallError('ESP filesystem identity changed after preflight')
|
|
||||||
marker = dict(format=1, install_uuid=str(uuid.uuid4()), filesystem=request.root_fs,
|
|
||||||
root_fs_uuid=root_uuid, root_partuuid=root_partuuid,
|
|
||||||
esp_uuid=esp_uuid, esp_partuuid=esp_partuuid,
|
|
||||||
owned_efi_path='EFI/HydraOS')
|
|
||||||
if request.root_fs == 'btrfs':
|
|
||||||
marker['root_subvolume'] = '@root'
|
|
||||||
write_config(root, 'etc/hydra/storage.json', json.dumps(marker, sort_keys=True) + '\n', 0o600)
|
|
||||||
write_config(root, 'etc/locale.gen', 'en_US.UTF-8 UTF-8\nru_RU.UTF-8 UTF-8\n')
|
write_config(root, 'etc/locale.gen', 'en_US.UTF-8 UTF-8\nru_RU.UTF-8 UTF-8\n')
|
||||||
write_config(root, 'etc/locale.conf', 'LANG=en_US.UTF-8\n')
|
write_config(root, 'etc/locale.conf', 'LANG=en_US.UTF-8\n')
|
||||||
write_config(root, 'etc/timezone', request.timezone + '\n')
|
write_config(root, 'etc/timezone', request.timezone + '\n')
|
||||||
@@ -796,13 +585,9 @@ def install(request, runner, probe, root, password, log: TextIO):
|
|||||||
write_config(root, 'etc/machine-id', '')
|
write_config(root, 'etc/machine-id', '')
|
||||||
dbus_machine_id = target_path(root, 'var/lib/dbus') / 'machine-id'
|
dbus_machine_id = target_path(root, 'var/lib/dbus') / 'machine-id'
|
||||||
dbus_machine_id.unlink(missing_ok=True)
|
dbus_machine_id.unlink(missing_ok=True)
|
||||||
boot_args = f'root=UUID={root_uuid}' + (' rootflags=subvol=@root' if request.root_fs == 'btrfs' else '')
|
|
||||||
write_config(root, 'etc/dracut.conf.d/90-hydra-installed.conf',
|
write_config(root, 'etc/dracut.conf.d/90-hydra-installed.conf',
|
||||||
'hostonly="no"\nhostonly_cmdline="no"\nomit_dracutmodules+=" dmsquash-live "\n'
|
'hostonly="no"\nhostonly_cmdline="no"\nomit_dracutmodules+=" dmsquash-live "\n# No root=live: or rd.live arguments on the installed disk.\n')
|
||||||
+ ('filesystems+=" btrfs "\n' if request.root_fs == 'btrfs' else '')
|
write_config(root, 'etc/default/grub', 'GRUB_DISTRIBUTOR="HydraOS"\nGRUB_CMDLINE_LINUX=""\nGRUB_TIMEOUT=3\n')
|
||||||
+ f'kernel_cmdline="{boot_args}"\n')
|
|
||||||
write_config(root, 'etc/default/grub', 'GRUB_DISTRIBUTOR="Hydra OS"\n'
|
|
||||||
+ f'GRUB_CMDLINE_LINUX="{boot_args}"\nGRUB_TIMEOUT=3\nGRUB_DISABLE_OS_PROBER=true\n')
|
|
||||||
write_config(root, 'etc/sudoers.d/90-hydra-admin', '%wheel ALL=(ALL:ALL) ALL\n', 0o440)
|
write_config(root, 'etc/sudoers.d/90-hydra-admin', '%wheel ALL=(ALL:ALL) ALL\n', 0o440)
|
||||||
mount(['mount', '--rbind', '/dev', str(root / 'dev')], root / 'dev', True)
|
mount(['mount', '--rbind', '/dev', str(root / 'dev')], root / 'dev', True)
|
||||||
run(['mount', '--make-rslave', str(root / 'dev')])
|
run(['mount', '--make-rslave', str(root / 'dev')])
|
||||||
@@ -821,16 +606,8 @@ def install(request, runner, probe, root, password, log: TextIO):
|
|||||||
chroot('systemd-machine-id-setup')
|
chroot('systemd-machine-id-setup')
|
||||||
chroot('dracut', '--force', '--no-hostonly', '--omit', 'dmsquash-live',
|
chroot('dracut', '--force', '--no-hostonly', '--omit', 'dmsquash-live',
|
||||||
'/boot/initramfs-' + kernel + '.img', kernel)
|
'/boot/initramfs-' + kernel + '.img', kernel)
|
||||||
run(grub_command(root, preserve=True))
|
|
||||||
if not existing:
|
|
||||||
run(grub_command(root))
|
run(grub_command(root))
|
||||||
chroot('grub-mkconfig', '-o', '/boot/grub/grub.cfg')
|
chroot('grub-mkconfig', '-o', '/boot/grub/grub.cfg')
|
||||||
if existing and storage.foreign_efi_hashes(root / 'boot/efi', ignore_owned=True) != foreign:
|
|
||||||
raise InstallError('Foreign ESP files changed; refusing successful installation')
|
|
||||||
if request.register_boot_entry:
|
|
||||||
parent, number = (validated.esp_parent, validated.esp_number) if existing else (target, 1)
|
|
||||||
run(['efibootmgr', '--create-only', '--disk', parent, '--part', str(number),
|
|
||||||
'--label', 'HydraOS', '--loader', r'\EFI\HydraOS\grubx64.efi'])
|
|
||||||
run(['sync'])
|
run(['sync'])
|
||||||
except (InstallError, OSError, KeyboardInterrupt) as exc:
|
except (InstallError, OSError, KeyboardInterrupt) as exc:
|
||||||
error = exc
|
error = exc
|
||||||
@@ -853,8 +630,6 @@ def install(request, runner, probe, root, password, log: TextIO):
|
|||||||
error = InstallError('Mount cleanup failed: ' + '; '.join(cleanup_errors))
|
error = InstallError('Mount cleanup failed: ' + '; '.join(cleanup_errors))
|
||||||
if error:
|
if error:
|
||||||
raise InstallError(str(error)) from error
|
raise InstallError(str(error)) from error
|
||||||
if existing and not request.register_boot_entry:
|
|
||||||
print('Select EFI/HydraOS/grubx64.efi manually in firmware; BootOrder was not changed.', file=log, flush=True)
|
|
||||||
print('Installation finished; remove the live ISO before booting the installed disk.', file=log, flush=True)
|
print('Installation finished; remove the live ISO before booting the installed disk.', file=log, flush=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -867,13 +642,8 @@ def preflight(request, runner):
|
|||||||
raise InstallError('Execution requires a TTY for password entry')
|
raise InstallError('Execution requires a TTY for password entry')
|
||||||
if not Path('/sys/firmware/efi').is_dir():
|
if not Path('/sys/firmware/efi').is_dir():
|
||||||
raise InstallError('Boot the live medium in UEFI mode before installation')
|
raise InstallError('Boot the live medium in UEFI mode before installation')
|
||||||
commands = ['lsblk', 'findmnt', 'mount', 'umount', 'unsquashfs', 'blkid', 'chroot', 'sync']
|
for command in ('lsblk', 'findmnt', 'parted', 'partprobe', 'udevadm', 'mkfs.vfat',
|
||||||
if request.root_partition is None:
|
'mkfs.ext4', 'mount', 'umount', 'unsquashfs', 'blkid', 'chroot', 'sync'):
|
||||||
commands.extend(['parted', 'partprobe', 'udevadm', 'mkfs.vfat'])
|
|
||||||
commands.extend(['btrfs', 'mkfs.btrfs'] if request.root_fs == 'btrfs' else ['mkfs.ext4'])
|
|
||||||
if request.register_boot_entry:
|
|
||||||
commands.append('efibootmgr')
|
|
||||||
for command in commands:
|
|
||||||
if shutil.which(command) is None:
|
if shutil.which(command) is None:
|
||||||
raise InstallError('Required live installer tool missing: ' + command)
|
raise InstallError('Required live installer tool missing: ' + command)
|
||||||
runner.run(['unsquashfs', '-s', request.payload])
|
runner.run(['unsquashfs', '-s', request.payload])
|
||||||
@@ -897,13 +667,8 @@ def prompt_password():
|
|||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
parser = argparse.ArgumentParser(description='Hydra OS explicit disk or existing-partition installer; dry-run by default')
|
parser = argparse.ArgumentParser(description='HydraOS explicit whole-disk installer; dry-run by default')
|
||||||
parser.add_argument('--disk', help='explicit physical whole-disk target; never automatically selected')
|
parser.add_argument('--disk', help='explicit physical whole-disk target; never automatically selected')
|
||||||
parser.add_argument('--root-partition', help='existing root partition; formats only this partition')
|
|
||||||
parser.add_argument('--esp-partition', help='existing FAT GPT ESP; never formatted in partition mode')
|
|
||||||
parser.add_argument('--confirm-format', help='must exactly match the resolved root partition with --execute')
|
|
||||||
parser.add_argument('--root-fs', choices=('ext4', 'btrfs'), default='ext4')
|
|
||||||
parser.add_argument('--register-boot-entry', action='store_true', help='opt in to create-only EFI entry, preserving BootOrder')
|
|
||||||
parser.add_argument('--user', help='installed administrator username')
|
parser.add_argument('--user', help='installed administrator username')
|
||||||
parser.add_argument('--payload', default=DEFAULT_PAYLOAD, help='prepared live squashfs root payload')
|
parser.add_argument('--payload', default=DEFAULT_PAYLOAD, help='prepared live squashfs root payload')
|
||||||
parser.add_argument('--execute', action='store_true')
|
parser.add_argument('--execute', action='store_true')
|
||||||
@@ -913,8 +678,8 @@ def main(argv=None):
|
|||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
runner = Runner()
|
runner = Runner()
|
||||||
try:
|
try:
|
||||||
if not args.disk and not args.root_partition and not args.esp_partition:
|
if not args.disk:
|
||||||
if args.execute or args.confirm_erase or args.confirm_format or args.register_boot_entry:
|
if args.execute or args.confirm_erase:
|
||||||
raise InstallError('--execute requires an explicit --disk and --user')
|
raise InstallError('--execute requires an explicit --disk and --user')
|
||||||
topo = Topology.from_json(runner.run(['lsblk', '--json', '--paths', '--bytes', '--output',
|
topo = Topology.from_json(runner.run(['lsblk', '--json', '--paths', '--bytes', '--output',
|
||||||
'PATH,TYPE,PKNAME,MOUNTPOINTS,SIZE,MAJ:MIN']))
|
'PATH,TYPE,PKNAME,MOUNTPOINTS,SIZE,MAJ:MIN']))
|
||||||
|
|||||||
-123
@@ -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))
|
|
||||||
+5
-37
@@ -16,40 +16,24 @@ def validate_name(name: str) -> str:
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Profile:
|
class Profile:
|
||||||
name: str
|
name: str
|
||||||
iso: Path | None = None
|
iso: Path
|
||||||
memory: int = 4096
|
memory: int = 4096
|
||||||
cpus: int = 2
|
cpus: int = 2
|
||||||
disk_size: str = '40G'
|
disk_size: str = '40G'
|
||||||
graphics: str = 'auto'
|
graphics: str = 'auto'
|
||||||
network: str = 'user'
|
network: str = 'user'
|
||||||
bridge: str = 'hydrabr0'
|
bridge: str = 'hydrabr0'
|
||||||
width: int = 1280
|
|
||||||
height: int = 800
|
|
||||||
refresh_millihz: int = 0
|
|
||||||
mac: str | None = None
|
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
validate_name(self.name)
|
validate_name(self.name)
|
||||||
if self.iso is not None:
|
iso = Path(self.iso).expanduser().resolve()
|
||||||
if not isinstance(self.iso, (str, Path)) or not str(self.iso):
|
if not iso.is_file():
|
||||||
raise ValueError('ISO must be a nonempty path or omitted')
|
raise ValueError(f'ISO does not exist or is not a file: {iso}')
|
||||||
object.__setattr__(self, 'iso', Path(self.iso).expanduser().resolve())
|
object.__setattr__(self, 'iso', iso)
|
||||||
if self.mac is not None:
|
|
||||||
if (not isinstance(self.mac, str) or
|
|
||||||
not re.fullmatch(r'[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}', self.mac) or
|
|
||||||
int(self.mac[:2], 16) & 3 != 2):
|
|
||||||
raise ValueError('mac must be a locally administered unicast Ethernet address')
|
|
||||||
object.__setattr__(self, 'mac', self.mac.lower())
|
|
||||||
for key, upper in (('memory', 262144), ('cpus', 256)):
|
for key, upper in (('memory', 262144), ('cpus', 256)):
|
||||||
value = getattr(self, key)
|
value = getattr(self, key)
|
||||||
if type(value) is not int or not 1 <= value <= upper:
|
if type(value) is not int or not 1 <= value <= upper:
|
||||||
raise ValueError(f'{key} must be an integer between 1 and {upper}')
|
raise ValueError(f'{key} must be an integer between 1 and {upper}')
|
||||||
for key in ('width', 'height'):
|
|
||||||
if type(getattr(self, key)) is not int or not 1 <= getattr(self, key) <= 16384:
|
|
||||||
raise ValueError(f'{key} must be an integer between 1 and 16384')
|
|
||||||
if type(self.refresh_millihz) is not int or not (self.refresh_millihz == 0 or
|
|
||||||
50000 <= self.refresh_millihz <= 255000):
|
|
||||||
raise ValueError('refresh_millihz must be 0 (automatic) or an integer between 50000 and 255000')
|
|
||||||
if not isinstance(self.disk_size, str) or not re.fullmatch(r'[1-9][0-9]{0,5}[MGT]', self.disk_size):
|
if not isinstance(self.disk_size, str) or not re.fullmatch(r'[1-9][0-9]{0,5}[MGT]', self.disk_size):
|
||||||
raise ValueError('disk_size must be a positive size with M, G or T suffix')
|
raise ValueError('disk_size must be a positive size with M, G or T suffix')
|
||||||
if self.graphics not in ('auto', 'software'):
|
if self.graphics not in ('auto', 'software'):
|
||||||
@@ -83,22 +67,6 @@ def save_profile(path: Path, profile: Profile) -> None:
|
|||||||
lines = []
|
lines = []
|
||||||
for field in Profile.__dataclass_fields__:
|
for field in Profile.__dataclass_fields__:
|
||||||
value = getattr(profile, field)
|
value = getattr(profile, field)
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
lines.append(f'{field} = {value if type(value) is int else json.dumps(str(value), ensure_ascii=False)}\n')
|
lines.append(f'{field} = {value if type(value) is int else json.dumps(str(value), ensure_ascii=False)}\n')
|
||||||
with path.open('x', encoding='utf-8') as stream:
|
with path.open('x', encoding='utf-8') as stream:
|
||||||
stream.write(''.join(lines))
|
stream.write(''.join(lines))
|
||||||
|
|
||||||
|
|
||||||
def load_defaults(path: Path) -> dict:
|
|
||||||
"""Distribution preferences apply only when creating a new profile."""
|
|
||||||
try:
|
|
||||||
with Path(path).open('rb') as stream:
|
|
||||||
data = tomllib.load(stream)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return {}
|
|
||||||
unknown = data.keys() - {'network', 'bridge', 'width', 'height', 'refresh_millihz'}
|
|
||||||
if unknown:
|
|
||||||
raise ValueError(f'Unknown defaults fields: {", ".join(sorted(unknown))}')
|
|
||||||
Profile('defaults-check', **data)
|
|
||||||
return data
|
|
||||||
|
|||||||
@@ -23,12 +23,6 @@ class QMPClient:
|
|||||||
self.socket.settimeout(timeout)
|
self.socket.settimeout(timeout)
|
||||||
self.socket.connect(str(path))
|
self.socket.connect(str(path))
|
||||||
greeting = self._receive(deadline)
|
greeting = self._receive(deadline)
|
||||||
# Real QEMU can queue a SPICE event across monitor reconnects.
|
|
||||||
# Consume only protocol events, retaining the original deadline.
|
|
||||||
while 'event' in greeting:
|
|
||||||
if not self._async_event(greeting):
|
|
||||||
raise QMPError('Invalid QMP event before greeting')
|
|
||||||
greeting = self._receive(deadline)
|
|
||||||
if not isinstance(greeting.get('QMP'), dict):
|
if not isinstance(greeting.get('QMP'), dict):
|
||||||
raise QMPError('Invalid QMP greeting')
|
raise QMPError('Invalid QMP greeting')
|
||||||
self._command('qmp_capabilities', None, deadline)
|
self._command('qmp_capabilities', None, deadline)
|
||||||
@@ -38,22 +32,6 @@ class QMPClient:
|
|||||||
raise
|
raise
|
||||||
raise QMPError(f'QMP connection failed: {exc}') from exc
|
raise QMPError(f'QMP connection failed: {exc}') from exc
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _async_event(message: dict) -> bool:
|
|
||||||
if not {'event', 'timestamp'} <= message.keys() <= {'event', 'timestamp', 'data'}:
|
|
||||||
return False
|
|
||||||
if not isinstance(message['event'], str) or not message['event']:
|
|
||||||
return False
|
|
||||||
if 'data' in message and not isinstance(message['data'], dict):
|
|
||||||
return False
|
|
||||||
stamp = message['timestamp']
|
|
||||||
if (not isinstance(stamp, dict) or set(stamp) != {'seconds', 'microseconds'}
|
|
||||||
or any(type(value) is not int for value in stamp.values())):
|
|
||||||
return False
|
|
||||||
seconds, micros = stamp['seconds'], stamp['microseconds']
|
|
||||||
# QMP specifies (-1, -1) when the server cannot retrieve host time.
|
|
||||||
return (seconds == -1 and micros == -1) or (seconds >= 0 and 0 <= micros < 1000000)
|
|
||||||
|
|
||||||
def _receive(self, deadline: float) -> dict:
|
def _receive(self, deadline: float) -> dict:
|
||||||
while True:
|
while True:
|
||||||
remaining = deadline - time.monotonic()
|
remaining = deadline - time.monotonic()
|
||||||
|
|||||||
+8
-53
@@ -3,7 +3,6 @@
|
|||||||
import contextlib
|
import contextlib
|
||||||
import errno
|
import errno
|
||||||
import fcntl
|
import fcntl
|
||||||
import hashlib
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
@@ -12,7 +11,7 @@ import subprocess
|
|||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .profiles import Profile, load_profile, save_profile, validate_name
|
from .profiles import Profile, load_profile, save_profile, validate_name
|
||||||
@@ -55,14 +54,6 @@ def _intel_render_node() -> Path | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _stable_mac(paths: VMPaths) -> str:
|
|
||||||
# Disk/state identity survives restarts and runtime socket-directory changes.
|
|
||||||
# UID distinguishes owners even when a shared bridge sees the same names.
|
|
||||||
digest = bytearray(hashlib.sha256(f'{os.getuid()}:{paths.disk}'.encode()).digest()[:6])
|
|
||||||
digest[0] = (digest[0] & 0xfc) | 0x02
|
|
||||||
return ':'.join(f'{octet:02x}' for octet in digest)
|
|
||||||
|
|
||||||
|
|
||||||
def build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]:
|
def build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]:
|
||||||
accel = 'kvm' if os.access('/dev/kvm', os.R_OK | os.W_OK) else 'tcg'
|
accel = 'kvm' if os.access('/dev/kvm', os.R_OK | os.W_OK) else 'tcg'
|
||||||
argv = [os.environ.get('HYDRA_QEMU', 'qemu-system-x86_64'), '-name', profile.name,
|
argv = [os.environ.get('HYDRA_QEMU', 'qemu-system-x86_64'), '-name', profile.name,
|
||||||
@@ -73,21 +64,15 @@ def build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]:
|
|||||||
'-mon', 'chardev=qmp,mode=control']
|
'-mon', 'chardev=qmp,mode=control']
|
||||||
if accel == 'kvm':
|
if accel == 'kvm':
|
||||||
argv += ['-cpu', 'host']
|
argv += ['-cpu', 'host']
|
||||||
blockdevs = [
|
for options in (
|
||||||
{'driver': 'file', 'filename': str(paths.disk), 'node-name': 'disk-file'},
|
{'driver': 'file', 'filename': str(paths.disk), 'node-name': 'disk-file'},
|
||||||
{'driver': 'qcow2', 'file': 'disk-file', 'node-name': 'disk'},
|
{'driver': 'qcow2', 'file': 'disk-file', 'node-name': 'disk'},
|
||||||
]
|
|
||||||
if profile.iso is not None:
|
|
||||||
blockdevs += [
|
|
||||||
{'driver': 'file', 'filename': str(profile.iso), 'node-name': 'iso-file', 'read-only': True},
|
{'driver': 'file', 'filename': str(profile.iso), 'node-name': 'iso-file', 'read-only': True},
|
||||||
{'driver': 'raw', 'file': 'iso-file', 'node-name': 'iso', 'read-only': True},
|
{'driver': 'raw', 'file': 'iso-file', 'node-name': 'iso', 'read-only': True},
|
||||||
]
|
):
|
||||||
for options in blockdevs:
|
|
||||||
argv += ['-blockdev', json.dumps(options)]
|
argv += ['-blockdev', json.dumps(options)]
|
||||||
argv += ['-device', 'virtio-blk-pci,drive=disk']
|
argv += ['-device', 'virtio-blk-pci,drive=disk', '-device', 'ide-cd,drive=iso',
|
||||||
if profile.iso is not None:
|
'-boot', 'order=dc', '-device', 'virtio-net-pci,netdev=net',
|
||||||
argv += ['-device', 'ide-cd,drive=iso']
|
|
||||||
argv += ['-boot', 'order=dc' if profile.iso is not None else 'order=c', '-device', f'virtio-net-pci,netdev=net,mac={profile.mac or _stable_mac(paths)}',
|
|
||||||
'-netdev', 'user,id=net' if profile.network == 'user'
|
'-netdev', 'user,id=net' if profile.network == 'user'
|
||||||
else f'bridge,id=net,br={profile.bridge}',
|
else f'bridge,id=net,br={profile.bridge}',
|
||||||
'-device', 'qemu-xhci,id=usb', '-device', 'usb-tablet', '-device', 'usb-kbd']
|
'-device', 'qemu-xhci,id=usb', '-device', 'usb-tablet', '-device', 'usb-kbd']
|
||||||
@@ -98,15 +83,12 @@ def build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]:
|
|||||||
'-device', f'usb-redir,chardev=usbredir{channel}']
|
'-device', f'usb-redir,chardev=usbredir{channel}']
|
||||||
spice = (f'unix=on,addr={_qemu_value(paths.spice)},disable-ticketing=on,'
|
spice = (f'unix=on,addr={_qemu_value(paths.spice)},disable-ticketing=on,'
|
||||||
'disable-copy-paste=on,disable-agent-file-xfer=on')
|
'disable-copy-paste=on,disable-agent-file-xfer=on')
|
||||||
mode = f',xres={profile.width},yres={profile.height}'
|
|
||||||
if profile.refresh_millihz:
|
|
||||||
mode += f',refresh_rate={profile.refresh_millihz}'
|
|
||||||
render = _intel_render_node() if profile.graphics == 'auto' else None
|
render = _intel_render_node() if profile.graphics == 'auto' else None
|
||||||
if render:
|
if render:
|
||||||
spice += f',gl=on,rendernode={_qemu_value(render)}'
|
spice += f',gl=on,rendernode={_qemu_value(render)}'
|
||||||
argv += ['-display', f'egl-headless,rendernode={_qemu_value(render)}', '-device', 'virtio-vga-gl' + mode]
|
argv += ['-display', f'egl-headless,rendernode={_qemu_value(render)}', '-device', 'virtio-vga-gl']
|
||||||
else:
|
else:
|
||||||
argv += ['-display', 'none', '-device', 'virtio-vga' + mode]
|
argv += ['-display', 'none', '-device', 'virtio-vga']
|
||||||
return argv + ['-spice', spice]
|
return argv + ['-spice', spice]
|
||||||
|
|
||||||
|
|
||||||
@@ -143,35 +125,9 @@ class Runtime:
|
|||||||
_private_dir(directory)
|
_private_dir(directory)
|
||||||
|
|
||||||
def add(self, profile: Profile) -> None:
|
def add(self, profile: Profile) -> None:
|
||||||
self._validate_media(profile.iso)
|
|
||||||
_private_dir(self.config)
|
_private_dir(self.config)
|
||||||
if profile.mac is None:
|
|
||||||
profile = replace(profile, mac=_stable_mac(self.paths(profile.name)))
|
|
||||||
save_profile(self.config / f'{profile.name}.toml', profile)
|
save_profile(self.config / f'{profile.name}.toml', profile)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate_media(iso: Path | None) -> None:
|
|
||||||
if iso is not None and not iso.is_file():
|
|
||||||
raise RuntimeError(f'ISO does not exist or is not a file: {iso}; attach valid media or detach it while stopped')
|
|
||||||
|
|
||||||
def media(self, name: str, iso: Path | None) -> dict:
|
|
||||||
paths = self.paths(name)
|
|
||||||
with self._locked(paths):
|
|
||||||
current = self.status(name)
|
|
||||||
if current['status'] != 'stopped':
|
|
||||||
raise RuntimeError(f'Cannot change media for {name}: {current["status"]}')
|
|
||||||
profile = replace(self.profile(name), iso=iso)
|
|
||||||
self._validate_media(profile.iso)
|
|
||||||
destination = self.config / f'{name}.toml'
|
|
||||||
if destination.is_symlink():
|
|
||||||
raise RuntimeError('Refusing to replace a symlink profile')
|
|
||||||
with tempfile.TemporaryDirectory(prefix=f'.{name}-', dir=self.config) as tmp:
|
|
||||||
temp = Path(tmp) / f'{name}.toml'
|
|
||||||
save_profile(temp, profile)
|
|
||||||
temp.chmod(0o600)
|
|
||||||
os.replace(temp, destination)
|
|
||||||
return {'name': name, 'iso': str(profile.iso) if profile.iso else None, 'status': 'media-updated'}
|
|
||||||
|
|
||||||
def profiles(self) -> list[Profile]:
|
def profiles(self) -> list[Profile]:
|
||||||
if not self.config.exists():
|
if not self.config.exists():
|
||||||
return []
|
return []
|
||||||
@@ -278,13 +234,12 @@ class Runtime:
|
|||||||
temp.unlink(missing_ok=True)
|
temp.unlink(missing_ok=True)
|
||||||
|
|
||||||
def start(self, name: str) -> dict:
|
def start(self, name: str) -> dict:
|
||||||
|
profile = self.profile(name)
|
||||||
paths = self.paths(name)
|
paths = self.paths(name)
|
||||||
with self._locked(paths):
|
with self._locked(paths):
|
||||||
profile = self.profile(name)
|
|
||||||
current = self.status(name)
|
current = self.status(name)
|
||||||
if current['status'] != 'stopped':
|
if current['status'] != 'stopped':
|
||||||
raise RuntimeError(f'Cannot start {name}: {current["status"]}')
|
raise RuntimeError(f'Cannot start {name}: {current["status"]}')
|
||||||
self._validate_media(profile.iso)
|
|
||||||
for path in (paths.qmp, paths.spice):
|
for path in (paths.qmp, paths.spice):
|
||||||
if self._listening(path):
|
if self._listening(path):
|
||||||
raise RuntimeError(f'Runtime socket is already in use: {path}')
|
raise RuntimeError(f'Runtime socket is already in use: {path}')
|
||||||
|
|||||||
+25
-115
@@ -15,8 +15,8 @@ from pathlib import Path
|
|||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
from .profiles import load_profile, validate_name
|
from .profiles import validate_name
|
||||||
from .runtime import Runtime, RuntimeError, VMPaths, _private_dir
|
from .runtime import Runtime, VMPaths, _private_dir
|
||||||
|
|
||||||
|
|
||||||
class SessionError(ValueError):
|
class SessionError(ValueError):
|
||||||
@@ -49,7 +49,6 @@ def build_labwc_config(names: list[str], recovery_argv: list[str],
|
|||||||
core = ET.SubElement(root, 'core')
|
core = ET.SubElement(root, 'core')
|
||||||
ET.SubElement(core, 'primarySelection').text = 'no'
|
ET.SubElement(core, 'primarySelection').text = 'no'
|
||||||
desktops = ET.SubElement(root, 'desktops', number=str(max(1, len(names))))
|
desktops = ET.SubElement(root, 'desktops', number=str(max(1, len(names))))
|
||||||
if names:
|
|
||||||
labels = ET.SubElement(desktops, 'names')
|
labels = ET.SubElement(desktops, 'names')
|
||||||
for name in names:
|
for name in names:
|
||||||
ET.SubElement(labels, 'name').text = name
|
ET.SubElement(labels, 'name').text = name
|
||||||
@@ -162,7 +161,7 @@ def _module_argv(command: str, manifest: Path) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _recovery_argv(manifest: Path, terminal: str) -> list[str]:
|
def _recovery_argv(manifest: Path, terminal: str) -> list[str]:
|
||||||
return [terminal, '--title=Hydra OS recovery'] + _module_argv('recover', manifest)
|
return [terminal, '--title=HydraOS-recovery'] + _module_argv('recover', manifest)
|
||||||
|
|
||||||
|
|
||||||
def launch_session(runtime: Runtime, *, nested=False) -> int:
|
def launch_session(runtime: Runtime, *, nested=False) -> int:
|
||||||
@@ -174,24 +173,9 @@ def launch_session(runtime: Runtime, *, nested=False) -> int:
|
|||||||
match = re.search(r'(\d+)\.(\d+)\.(\d+)', version.stdout)
|
match = re.search(r'(\d+)\.(\d+)\.(\d+)', version.stdout)
|
||||||
if version.returncode or not match or tuple(map(int, match.groups())) < (0, 20, 0):
|
if version.returncode or not match or tuple(map(int, match.groups())) < (0, 20, 0):
|
||||||
raise SessionError('HydraOS requires labwc >= 0.20.0 for reserved host input bindings')
|
raise SessionError('HydraOS requires labwc >= 0.20.0 for reserved host input bindings')
|
||||||
|
profiles = runtime.profiles()
|
||||||
|
initial = next((p.name for p in profiles if runtime.status(p.name)['status'] == 'running'), None)
|
||||||
_private_dir(runtime.runtime)
|
_private_dir(runtime.runtime)
|
||||||
selected = None
|
|
||||||
while True:
|
|
||||||
profiles = []
|
|
||||||
for path in sorted(runtime.config.glob('*.toml')):
|
|
||||||
try:
|
|
||||||
profiles.append(load_profile(path))
|
|
||||||
except (RuntimeError, OSError, ValueError) as exc:
|
|
||||||
print(f'hydra: {path.name}: {exc}', file=sys.stderr)
|
|
||||||
initial = selected if selected in [p.name for p in profiles] else None
|
|
||||||
if initial is None:
|
|
||||||
for profile in profiles:
|
|
||||||
try:
|
|
||||||
if runtime.status(profile.name)['status'] not in ('stopped', 'unknown'):
|
|
||||||
initial = profile.name
|
|
||||||
break
|
|
||||||
except (RuntimeError, OSError, ValueError) as exc:
|
|
||||||
print(f'hydra: {profile.name}: {exc}', file=sys.stderr)
|
|
||||||
with tempfile.TemporaryDirectory(prefix='session-', dir=runtime.runtime) as tmp:
|
with tempfile.TemporaryDirectory(prefix='session-', dir=runtime.runtime) as tmp:
|
||||||
directory = Path(tmp)
|
directory = Path(tmp)
|
||||||
config = directory / 'labwc'
|
config = directory / 'labwc'
|
||||||
@@ -204,34 +188,13 @@ def launch_session(runtime: Runtime, *, nested=False) -> int:
|
|||||||
_recovery_argv(manifest, terminal), brightness,
|
_recovery_argv(manifest, terminal), brightness,
|
||||||
initial=initial), encoding='utf-8')
|
initial=initial), encoding='utf-8')
|
||||||
(config / 'autostart').write_text('#!/bin/sh\n' + shlex.join(_module_argv('clients', manifest)) + ' &\n', encoding='utf-8')
|
(config / 'autostart').write_text('#!/bin/sh\n' + shlex.join(_module_argv('clients', manifest)) + ' &\n', encoding='utf-8')
|
||||||
|
# Empty environment files prevent loading unrelated /etc/xdg defaults.
|
||||||
(config / 'environment').touch()
|
(config / 'environment').touch()
|
||||||
env['PYTHONUNBUFFERED'] = '1'
|
env['PYTHONUNBUFFERED'] = '1'
|
||||||
env['HYDRA_SESSION_MANIFEST'] = str(manifest)
|
return subprocess.run([labwc, '-C', str(config)], env=env).returncode
|
||||||
compositor = subprocess.Popen([labwc, '-C', str(config)], env=env)
|
|
||||||
refresh = False
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
if (directory / 'refresh.request').exists():
|
|
||||||
refresh = True
|
|
||||||
break
|
|
||||||
if compositor.poll() is not None:
|
|
||||||
# A requesting recovery terminal can exit before polling.
|
|
||||||
refresh = (directory / 'refresh.request').exists()
|
|
||||||
break
|
|
||||||
time.sleep(.1)
|
|
||||||
finally:
|
|
||||||
if compositor.poll() is None:
|
|
||||||
compositor.terminate()
|
|
||||||
compositor.wait(timeout=10)
|
|
||||||
if not refresh:
|
|
||||||
return compositor.returncode
|
|
||||||
selected = _refresh_selection(directory / 'refresh.request')
|
|
||||||
# Only our compositor was stopped. Its viewers disconnect; detached
|
|
||||||
# QEMU instances retain their identity and running workloads.
|
|
||||||
|
|
||||||
|
|
||||||
|
def session_helper(command: str, manifest: Path) -> int:
|
||||||
def _manifest_data(manifest: Path) -> dict:
|
|
||||||
data = json.loads(manifest.read_text(encoding='utf-8'))
|
data = json.loads(manifest.read_text(encoding='utf-8'))
|
||||||
if (not isinstance(data, dict) or
|
if (not isinstance(data, dict) or
|
||||||
any(not isinstance(data.get(key), str) or not Path(data[key]).is_absolute()
|
any(not isinstance(data.get(key), str) or not Path(data[key]).is_absolute()
|
||||||
@@ -240,79 +203,28 @@ def _manifest_data(manifest: Path) -> dict:
|
|||||||
raise SessionError('Invalid session manifest')
|
raise SessionError('Invalid session manifest')
|
||||||
for name in data['names']:
|
for name in data['names']:
|
||||||
validate_name(name)
|
validate_name(name)
|
||||||
return data
|
if command not in ('clients', 'recover') and (
|
||||||
|
not command.startswith('reconnect:') or command.split(':', 1)[1] not in data['names']):
|
||||||
|
raise SessionError('Invalid session helper operation')
|
||||||
def request_refresh(manifest: Path, *, selected: str | None = None) -> int:
|
runtime = Runtime(Path(data['config']), Path(data['state']), Path(data['runtime']))
|
||||||
"""Publish a complete request without controlling any guest process."""
|
if command == 'recover':
|
||||||
manifest = Path(manifest)
|
print('HydraOS local recovery. VMs keep running independently.\n'
|
||||||
data = _manifest_data(manifest)
|
'Ctrl+Alt+Shift+Left/Right switches workspaces; Ctrl+Alt+Shift+R releases input; Ctrl+Alt+Shift+U opens USB.\n'
|
||||||
if selected is not None:
|
'Enter a VM name to reconnect, :shell for a host shell, :quit to close this terminal.')
|
||||||
validate_name(selected)
|
|
||||||
# Profiles can be created after the compositor's original snapshot.
|
|
||||||
Runtime(Path(data['config']), Path(data['state']), Path(data['runtime'])).profile(selected)
|
|
||||||
payload = '' if selected is None else json.dumps({'selected': selected})
|
|
||||||
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=manifest.parent,
|
|
||||||
prefix='.refresh-', delete=False) as stream:
|
|
||||||
temporary = Path(stream.name)
|
|
||||||
try:
|
|
||||||
stream.write(payload)
|
|
||||||
stream.flush()
|
|
||||||
os.fsync(stream.fileno())
|
|
||||||
os.replace(temporary, manifest.parent / 'refresh.request')
|
|
||||||
finally:
|
|
||||||
temporary.unlink(missing_ok=True)
|
|
||||||
print('Refreshing workspaces and viewers; VMs keep running.')
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def _refresh_selection(request: Path) -> str | None:
|
|
||||||
try:
|
|
||||||
payload = request.read_text(encoding='utf-8')
|
|
||||||
if not payload:
|
|
||||||
return None
|
|
||||||
data = json.loads(payload)
|
|
||||||
if not isinstance(data, dict) or set(data) != {'selected'}:
|
|
||||||
raise SessionError('Invalid refresh request')
|
|
||||||
return validate_name(data['selected'])
|
|
||||||
except (RuntimeError, OSError, ValueError) as exc:
|
|
||||||
print(f'hydra: refresh selection ignored: {exc}', file=sys.stderr)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _emergency_recovery(manifest: Path) -> int:
|
|
||||||
print('Hydra OS emergency recovery. :shell, :refresh or :quit; VMs keep running.')
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
print(json.dumps([runtime.status(name) for name in data['names']], indent=2))
|
||||||
choice = input('hydra recovery> ').strip()
|
choice = input('hydra recovery> ').strip()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
return 0
|
||||||
if choice == ':quit':
|
if choice == ':quit':
|
||||||
return 0
|
return 0
|
||||||
if choice == ':shell':
|
if choice == ':shell':
|
||||||
subprocess.run([os.environ.get('SHELL', '/bin/sh')])
|
subprocess.run([os.environ.get('SHELL', '/bin/sh')])
|
||||||
elif choice == ':refresh':
|
elif choice in data['names']:
|
||||||
return request_refresh(manifest)
|
subprocess.Popen(_module_argv('reconnect:' + choice, manifest), start_new_session=True)
|
||||||
except (EOFError, KeyboardInterrupt):
|
else:
|
||||||
return 0
|
print('Enter a configured VM name, :shell, or :quit.')
|
||||||
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
|
||||||
print(f'hydra: {exc}')
|
|
||||||
|
|
||||||
|
|
||||||
def session_helper(command: str, manifest: Path) -> int:
|
|
||||||
data = _manifest_data(manifest)
|
|
||||||
if command not in ('clients', 'recover', 'refresh') and (
|
|
||||||
not command.startswith('reconnect:') or command.split(':', 1)[1] not in data['names']):
|
|
||||||
raise SessionError('Invalid session helper operation')
|
|
||||||
runtime = Runtime(Path(data['config']), Path(data['state']), Path(data['runtime']))
|
|
||||||
if command == 'refresh':
|
|
||||||
return request_refresh(manifest)
|
|
||||||
if command == 'recover':
|
|
||||||
try:
|
|
||||||
from .menu import run_menu
|
|
||||||
return run_menu(runtime, manifest=manifest)
|
|
||||||
except Exception as exc:
|
|
||||||
# Recovery is the last local escape hatch, including menu defects.
|
|
||||||
print(f'hydra: menu unavailable: {exc}')
|
|
||||||
return _emergency_recovery(manifest)
|
|
||||||
if command.startswith('reconnect:'):
|
if command.startswith('reconnect:'):
|
||||||
name = command.split(':', 1)[1]
|
name = command.split(':', 1)[1]
|
||||||
# One viewer per VM inside this session; independent host view commands
|
# One viewer per VM inside this session; independent host view commands
|
||||||
@@ -332,11 +244,9 @@ def session_helper(command: str, manifest: Path) -> int:
|
|||||||
recovery = subprocess.Popen(_recovery_argv(manifest, data['terminal']))
|
recovery = subprocess.Popen(_recovery_argv(manifest, data['terminal']))
|
||||||
for name in data['names']:
|
for name in data['names']:
|
||||||
try:
|
try:
|
||||||
# Verified paused/prelaunch guests also have a live SPICE server;
|
if runtime.status(name)['status'] == 'running':
|
||||||
# reconnecting must not imply a guest start or resume.
|
|
||||||
if runtime.status(name)['status'] not in ('stopped', 'unknown'):
|
|
||||||
processes.append(subprocess.Popen(_module_argv('reconnect:' + name, manifest)))
|
processes.append(subprocess.Popen(_module_argv('reconnect:' + name, manifest)))
|
||||||
except (RuntimeError, OSError, ValueError) as exc:
|
except (OSError, ValueError) as exc:
|
||||||
print(f'hydra: {name}: {exc}', file=sys.stderr)
|
print(f'hydra: {name}: {exc}', file=sys.stderr)
|
||||||
if not processes:
|
if not processes:
|
||||||
open_recovery()
|
open_recovery()
|
||||||
|
|||||||
@@ -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())
|
|
||||||
+2
-11
@@ -8,8 +8,6 @@ scripts/hydra-build fetch
|
|||||||
scripts/hydra-build prepare
|
scripts/hydra-build prepare
|
||||||
scripts/hydra-build packages --pretend
|
scripts/hydra-build packages --pretend
|
||||||
scripts/hydra-build packages
|
scripts/hydra-build packages
|
||||||
scripts/hydra-build rootfs
|
|
||||||
scripts/hydra-build iso
|
|
||||||
scripts/hydra-build status
|
scripts/hydra-build status
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -36,12 +34,5 @@ for installed-system updates. Rolling binhost contents can change; resulting
|
|||||||
versions/USE are recorded without claiming bit-identical output. The pinned
|
versions/USE are recorded without claiming bit-identical output. The pinned
|
||||||
service-keyring hash may need deliberate review when Gentoo rotates keys.
|
service-keyring hash may need deliberate review when Gentoo rotates keys.
|
||||||
|
|
||||||
`rootfs` exports the dedicated builder into a private intermediate and assembles
|
`rootfs` and `iso` are reserved assembly commands and currently return an
|
||||||
an isolated sanitized payload, generic live initramfs and squashfs. `iso` checks
|
explicit error until the live-image assembly implementation is integrated.
|
||||||
those artifacts and emits the UEFI `HYDRA_OS` ISO, SHA256 and build manifest in
|
|
||||||
`build/artifacts/`. The working builder is retained for package retries. The
|
|
||||||
installed Gentoo repository uses the standard rsync backend with signed
|
|
||||||
metamanifest verification; the builder continues to use its pinned snapshot.
|
|
||||||
QEMU and remote-viewer retain local patches and are excluded from upstream
|
|
||||||
binary packages on installed updates. Disposable boot/install validation is
|
|
||||||
required before treating an emitted ISO as release ready.
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ net-firewall/nftables
|
|||||||
media-video/pipewire
|
media-video/pipewire
|
||||||
media-video/wireplumber
|
media-video/wireplumber
|
||||||
app-admin/sudo
|
app-admin/sudo
|
||||||
app-misc/fastfetch
|
|
||||||
sys-kernel/gentoo-kernel-bin
|
sys-kernel/gentoo-kernel-bin
|
||||||
sys-kernel/linux-firmware
|
sys-kernel/linux-firmware
|
||||||
sys-firmware/sof-firmware
|
sys-firmware/sof-firmware
|
||||||
@@ -22,9 +21,6 @@ sys-boot/grub
|
|||||||
sys-fs/squashfs-tools
|
sys-fs/squashfs-tools
|
||||||
sys-fs/dosfstools
|
sys-fs/dosfstools
|
||||||
sys-fs/e2fsprogs
|
sys-fs/e2fsprogs
|
||||||
sys-fs/btrfs-progs
|
|
||||||
sys-boot/efibootmgr
|
|
||||||
dev-libs/openssl
|
|
||||||
sys-block/parted
|
sys-block/parted
|
||||||
sys-apps/util-linux
|
sys-apps/util-linux
|
||||||
sys-fs/mtools
|
sys-fs/mtools
|
||||||
@@ -32,4 +28,3 @@ dev-libs/libisoburn
|
|||||||
dev-lang/python
|
dev-lang/python
|
||||||
sec-keys/openpgp-keys-gentoo-release
|
sec-keys/openpgp-keys-gentoo-release
|
||||||
sec-keys/openpgp-keys-gentoo-auth
|
sec-keys/openpgp-keys-gentoo-auth
|
||||||
sys-apps/brightnessctl
|
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ CXXFLAGS="${COMMON_FLAGS}"
|
|||||||
FCFLAGS="${COMMON_FLAGS}"
|
FCFLAGS="${COMMON_FLAGS}"
|
||||||
FFLAGS="${COMMON_FLAGS}"
|
FFLAGS="${COMMON_FLAGS}"
|
||||||
MAKEOPTS="-j4"
|
MAKEOPTS="-j4"
|
||||||
EMERGE_DEFAULT_OPTS="--jobs=1 --getbinpkg --binpkg-respect-use=y --autounmask=n --usepkg-exclude='app-emulation/qemu app-emulation/virt-viewer'"
|
EMERGE_DEFAULT_OPTS="--jobs=1 --getbinpkg --binpkg-respect-use=y --autounmask=n"
|
||||||
FEATURES="getbinpkg binpkg-request-signature -usersandbox -sandbox -network-sandbox -ipc-sandbox -pid-sandbox"
|
FEATURES="getbinpkg binpkg-request-signature -usersandbox -sandbox -network-sandbox -ipc-sandbox -pid-sandbox"
|
||||||
ACCEPT_LICENSE="@FREE @BINARY-REDISTRIBUTABLE linux-fw-redistributable no-source-code Intel-SDP"
|
ACCEPT_LICENSE="@FREE @BINARY-REDISTRIBUTABLE linux-fw-redistributable no-source-code Intel-SDP"
|
||||||
VIDEO_CARDS="intel virgl"
|
VIDEO_CARDS="intel"
|
||||||
USE="X dbus wayland opengl alsa"
|
USE="X dbus wayland opengl alsa"
|
||||||
QEMU_SOFTMMU_TARGETS="x86_64"
|
QEMU_SOFTMMU_TARGETS="x86_64"
|
||||||
QEMU_USER_TARGETS=""
|
QEMU_USER_TARGETS=""
|
||||||
|
|||||||
@@ -6,5 +6,3 @@ net-misc/networkmanager wifi
|
|||||||
sys-boot/grub mount
|
sys-boot/grub mount
|
||||||
media-libs/freetype harfbuzz
|
media-libs/freetype harfbuzz
|
||||||
sys-kernel/installkernel dracut
|
sys-kernel/installkernel dracut
|
||||||
|
|
||||||
sys-fs/squashfs-tools zstd
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
net.ipv4.ip_forward = 1
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user