Files
archy/scripts/import-uat-oci.py
T

199 lines
10 KiB
Python

#!/usr/bin/env python3
"""Stage or explicitly import the unsigned candidate image; never activate apps.
Import uses a live appointed C01/D09 runtime lease and its mount/storage view.
Missing runtime admission defers (exit 75); there is no default-store fallback.
"""
import argparse
import importlib.util
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import time
import tomllib
sys.dont_write_bytecode = True
import uat_oci
def stage(bundle, target):
receipt = uat_oci.bundle(bundle)
destination = target / 'opt/archipelago/unsigned-uat-oci'
if not target.is_absolute() or destination.parent.resolve() != destination.parent:
raise ValueError('staging destination must be an absolute non-symlink path')
destination.parent.mkdir(parents=True, exist_ok=True)
if destination.exists():
if uat_oci.sha(destination / 'BUNDLE.json') != uat_oci.sha(bundle / 'BUNDLE.json'):
raise ValueError('different candidate already staged; preserve it for review')
uat_oci.bundle(destination)
else:
shutil.copytree(bundle, destination)
uat_oci.bundle(destination)
# No chroot Podman, no service enable/start, no desired-state mutations.
return dict(status='staged-import-deferred', bundle_sha256=uat_oci.sha(destination / 'BUNDLE.json'),
oci=receipt['oci'], reason='explicit live appointed runtime import required')
def import_image(call, archive, proof):
"""Fixed image-only operation set. call supplies the verified storage context."""
call(['load', '--input', str(archive)])
image_id = proof['config_id'].removeprefix('sha256:')
tag = uat_oci.REPOSITORY + ':uat-' + proof['manifest_digest'].removeprefix('sha256:')
call(['tag', image_id, tag])
actual = json.loads(call(['image', 'inspect', proof['reference']]))
if len(actual) != 1:
raise ValueError('ambiguous imported image')
image = actual[0]
if (image['Id'].removeprefix('sha256:') != image_id
or image['Digest'] != proof['manifest_digest']
or (image['Os'], image['Architecture'], image['Config']['User']) != ('linux', 'amd64', '100:100')):
raise ValueError('imported image digest/config/platform/user mismatch')
return dict(reference=proof['reference'], manifest_digest=image['Digest'], config_id='sha256:' + image_id,
platform='linux/amd64', user=image['Config']['User'])
def storage_declaration(config, storage):
selected = storage['storage']
runroot = Path(selected['runroot'])
if (selected['graphroot'] != config['graphroot'] or not Path(selected['graphroot']).is_absolute()
or selected['driver'] not in ('vfs', 'overlay') or '..' in runroot.parts
or not runroot.is_absolute() or not runroot.is_relative_to(Path(config['runtime_dir']))):
raise ValueError('appointed storage declaration mismatch; refuse before invoking Podman')
return selected
def storage_check(info, config, storage, config_path):
store = info['store']
selected = storage_declaration(config, storage)
if (not info['host']['security']['rootless'] or store['graphRoot'] != config['graphroot']
or store['runRoot'] != selected['runroot']
or store['configFile'] != str(config_path) or store['graphDriverName'] != selected['driver']
):
raise ValueError('appointed rootless storage mismatch; no default-store fallback')
def appointed_import(bundle, bundle_hash, runtime_hash, storage_hash):
if os.getresuid() != (0, 0, 0):
raise ValueError('root coordinator required; Podman itself must run as appointed non-root account')
# These installed modules are the existing admission validators, not copies
# provided by an untrusted image or an alternate implementation of admission.
sys.path.insert(0, '/usr/libexec')
if not Path('/usr/libexec/c01_runtime_view.py').is_file():
raise FileNotFoundError('installed D09 runtime admission helpers unavailable; defer')
import c01_runtime
from c01_runtime_view import RuntimeLease
config, digest = c01_runtime.load_config()
if digest != runtime_hash or config['version'] != 2:
raise ValueError('appointed runtime configuration digest/version mismatch')
receipt = uat_oci.bundle(bundle, bundle_hash)
if not isinstance(receipt.get('source_commit'), str) or not re.fullmatch('[0-9a-f]{40}', receipt['source_commit']):
raise ValueError('fixture/unqualified bundle cannot be imported into an appointed runtime')
lease = RuntimeLease(config['manager_uid'], config['manager_gid'],
config['runtime_uid'], config['runtime_gid'], digest)
namespace = None
manager = None
try:
pid = lease.identity['pid']
namespace = os.open(f'/proc/{pid}/ns/mnt', os.O_RDONLY | os.O_CLOEXEC)
if os.fstat(namespace).st_ino != lease.identity['mount_namespace']:
raise ValueError('runtime mount namespace changed')
lease.check()
home = Path(config['home'])
conf = home / '.config/containers/storage.conf'
viewed_conf = Path(f'/proc/{pid}/root') / conf.relative_to('/')
if uat_oci.sha(viewed_conf) != storage_hash:
raise ValueError('appointed storage configuration digest mismatch')
storage = tomllib.loads(viewed_conf.read_text())
selected = storage_declaration(config, storage)
helper=Path(__file__).resolve().with_name('c01_oci_job.py')
c01_runtime.protected(helper)
helper_hash=uat_oci.sha(helper)
if receipt['files'].get('c01_oci_job.py')!=helper_hash:
raise ValueError('reviewed bundle/import job implementation mismatch')
spec=importlib.util.spec_from_file_location('appointed_oci_job',helper)
jobs=importlib.util.module_from_spec(spec);spec.loader.exec_module(jobs)
manager=jobs.UserManager(lease,config)
# Retain the verified read-only artifact descriptor. No second archive
# copy, chroot store, UID impersonation or out-of-hierarchy process.
fd=os.open(bundle/'lnd-tor.oci.tar',os.O_RDONLY|os.O_NOFOLLOW|os.O_CLOEXEC)
with os.fdopen(fd,'rb') as archive:
import hashlib
if hashlib.file_digest(archive, 'sha256').hexdigest() != receipt['oci']['archive_sha256']:
raise ValueError('OCI archive changed before import')
archive.seek(0)
context={'uid':config['runtime_uid'],'gid':config['runtime_gid'],'home':str(home),
'username':config['runtime_user'],'runtime_dir':config['runtime_dir'],
'mount_namespace':lease.identity['mount_namespace'],
'network_namespace':os.stat(f'/proc/{pid}/ns/net').st_ino,
'helper_sha256':helper_hash,'podman_sha256':uat_oci.sha(Path(f'/proc/{pid}/root/usr/bin/podman')),
'config_path':str(conf),'storage_sha256':storage_hash,'driver':selected['driver'],
'graphroot':selected['graphroot'],'runroot':selected['runroot'],
'image_id':receipt['oci']['config_id'].removeprefix('sha256:'),
'tag':uat_oci.REPOSITORY+':uat-'+receipt['oci']['manifest_digest'].removeprefix('sha256:'),
'reference':receipt['oci']['reference']}
context['archive_sha256']=receipt['oci']['archive_sha256']
def call(args):
lease.check()
if uat_oci.sha(viewed_conf) != storage_hash or c01_runtime.load_config()[1] != digest:
raise ValueError('runtime/storage config changed during import')
if args[0] == 'load':
archive.seek(0)
with tempfile.TemporaryFile() as log, tempfile.TemporaryFile() as errors, open('/dev/null','rb') as empty:
try:
manager.execute(helper,context,args,archive if args[0]=='load' else empty,log,errors)
lease.check()
if uat_oci.sha(viewed_conf)!=storage_hash or c01_runtime.load_config()[1]!=digest:
raise ValueError('runtime/storage changed during image command')
log.seek(0)
output=log.read(4*1024*1024+1)
if len(output)>4*1024*1024:raise ValueError('image command output exceeds bound')
return output.decode()
except Exception as error:
errors.seek(0)
raise RuntimeError('appointed image job failed: '+str(error)+': '+errors.read(16384).decode(errors='replace')) from error
info = json.loads(call(['info', '--format', 'json']))
storage_check(info, config, storage, conf)
imported = import_image(call, Path('/dev/stdin'), receipt['oci'])
storage_check(json.loads(call(['info', '--format', 'json'])), config, storage, conf)
uat_oci.bundle(bundle, bundle_hash)
return dict(status='imported-verified-no-activation', image=imported, runtime_sha256=digest,
storage_sha256=storage_hash, store=info['store'], runtime_uid=config['runtime_uid'],
bundle_sha256=bundle_hash, jobs=manager.records,
lifecycle='unchanged; stopped/uninstalled/held intent preserved')
finally:
try:
if manager is not None:manager.close()
finally:
if namespace is not None:os.close(namespace)
lease.close()
def main():
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest='action', required=True)
staging = commands.add_parser('stage')
staging.add_argument('--bundle', type=Path, required=True)
staging.add_argument('--target-root', type=Path, required=True)
importing = commands.add_parser('import')
importing.add_argument('--bundle', type=Path, required=True)
for name in ('bundle-sha256', 'runtime-sha256', 'storage-sha256'):
importing.add_argument('--' + name, required=True)
args = parser.parse_args()
try:
if args.action == 'stage':
result = stage(args.bundle, args.target_root)
else:
result = appointed_import(args.bundle, args.bundle_sha256, args.runtime_sha256, args.storage_sha256)
print(json.dumps(result, sort_keys=True))
except (OSError, ValueError, KeyError, ImportError, subprocess.SubprocessError, RuntimeError) as error:
parser.exit(75 if args.action == 'import' else 1, f'DEFER/FAIL: {error}\n')
if __name__ == '__main__':
main()