238 lines
11 KiB
Python
238 lines
11 KiB
Python
#!/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())
|