Files
Hydra-OS/hydra/update_engine.py
T

631 lines
30 KiB
Python

"""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())