Document terminal workflows and provide a backed-up guest runtime update

This commit is contained in:
Codex
2026-10-13 15:22:32 +03:00
parent c6099103b2
commit d2988bcecc
6 changed files with 540 additions and 12 deletions
+224
View File
@@ -0,0 +1,224 @@
#!/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:
for name in reversed(changed):
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()
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())