215 lines
12 KiB
Python
215 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Freeze normal-start source, package a non-libtest build, and record drift.
|
|
|
|
Only repository source and explicitly selected distro ELF dependencies are copied.
|
|
No runtime homes, container stores, credentials or host mounts are inputs.
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import gzip
|
|
import io
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import stat
|
|
import struct
|
|
import shutil
|
|
import subprocess
|
|
import tarfile
|
|
|
|
|
|
def sha(path):
|
|
return hashlib.file_digest(path.open('rb'), 'sha256').hexdigest()
|
|
|
|
|
|
def record(path, value):
|
|
path.write_text(json.dumps(value, indent=2, sort_keys=True) + '\n')
|
|
|
|
|
|
def inventory(root):
|
|
names = subprocess.check_output([
|
|
'git', '-C', str(root), 'ls-files', '-z', '--cached', '--others',
|
|
'--exclude-standard', '--', 'core', 'scripts', 'image-recipe/configs',
|
|
'tests', 'apps'], text=True).split('\0')
|
|
return sorted(set(n for n in names if n and (root / n).is_file()))
|
|
|
|
|
|
def freeze(root, out):
|
|
out.mkdir(mode=0o700)
|
|
destination = out / 'source'
|
|
destination.mkdir()
|
|
manifest = {}
|
|
for name in inventory(root):
|
|
source = root / name
|
|
if source.is_symlink():
|
|
raise RuntimeError('source alias requires review: ' + name)
|
|
data = source.read_bytes()
|
|
target = destination / name
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes(data)
|
|
target.chmod(0o555 if source.stat().st_mode & 0o111 else 0o444)
|
|
manifest[name] = hashlib.sha256(data).hexdigest()
|
|
record(out / 'source-sha256.json', manifest)
|
|
changed = [n for n in manifest if not (root / n).is_file() or sha(root / n) != manifest[n]]
|
|
changed += sorted(set(inventory(root)) - set(manifest))
|
|
record(out / 'freeze-check.json', {'changed_during_copy': changed})
|
|
if changed:
|
|
raise RuntimeError('source changed during freeze; retain attempt and retry')
|
|
record(out / 'source-identity.json', {
|
|
'repository': str(root),
|
|
'head': subprocess.check_output(['git', '-C', str(root), 'rev-parse', 'HEAD'], text=True).strip(),
|
|
'branch': subprocess.check_output(['git', '-C', str(root), 'branch', '--show-current'], text=True).strip(),
|
|
'manifest_sha256': sha(out / 'source-sha256.json')})
|
|
(out / 'containers.conf').write_text('[containers]\nkeyring=false\n')
|
|
record(out / 'accommodations.json', {
|
|
'outer_keyring': 'disabled for exhausted rootless kernel key quota; not keyring isolation qualification',
|
|
'outer_proc': 'unmask=ALL for nested rootless proc mounting',
|
|
'nested_runtime': 'historical fixture uses vfs, network none, no ping sysctl, seccomp unconfined',
|
|
'tmux': 'copied public distro ELF and loader; exact package manifest retained'})
|
|
with tarfile.open(out / 'source.tar', 'x') as archive:
|
|
for name in manifest:
|
|
archive.add(destination / name, arcname=name, recursive=False)
|
|
with (out/'source.tar').open('rb') as source,(out/'source.tar.gz').open('xb') as destination:
|
|
with gzip.GzipFile(filename='',fileobj=destination,mode='wb',mtime=0) as compressed:
|
|
shutil.copyfileobj(source,compressed)
|
|
|
|
|
|
def artifact(binary, out):
|
|
# The runner builds --bin archipelago, never cargo test / a guarded entry.
|
|
target = out / 'normal-elf'
|
|
subprocess.run(['strip', '--strip-unneeded', '-o', str(target), str(binary)], check=True)
|
|
def allocated(path):
|
|
data=path.read_bytes()
|
|
assert data[:6]==b'\x7fELF\x02\x01', 'reviewed ELF64 little-endian artifact required'
|
|
header=struct.unpack_from('<16sHHIQQQIHHHHHH',data)
|
|
section_offset,section_size,count,names_index=header[6],header[11],header[12],header[13]
|
|
sections=[struct.unpack_from('<IIQQQQIIQQ',data,section_offset+i*section_size) for i in range(count)]
|
|
strings=sections[names_index];strings=data[strings[4]:strings[4]+strings[5]]
|
|
result={}
|
|
for name,kind,flags,address,offset,size,*_ in sections:
|
|
if flags&2:
|
|
label=strings[name:].split(b'\0',1)[0].decode()
|
|
result[label]=[kind,flags,address,size,hashlib.sha256(data[offset:offset+size]).hexdigest() if kind!=8 else None]
|
|
return {'machine':header[2],'entry':header[4],'sections':result}
|
|
before,after=allocated(binary),allocated(target)
|
|
assert before==after, 'strip changed allocated native sections/entry'
|
|
record(out/'strip-derivation.json',{'operation':'strip --strip-unneeded','allocated_sections_unchanged':True,
|
|
'before_bytes':binary.stat().st_size,'after_bytes':target.stat().st_size,'loadable_identity':after})
|
|
dependencies = {Path(w) for w in subprocess.check_output(['ldd', str(binary)], text=True).split()
|
|
if w.startswith('/')}
|
|
files = {'usr/local/bin/archipelago': target}
|
|
for path in dependencies:
|
|
source = path.resolve(strict=True)
|
|
for p in (source, *source.parents):
|
|
meta = p.stat()
|
|
if meta.st_uid != 0 or meta.st_mode & 0o022:
|
|
raise RuntimeError('unprotected distro dependency: ' + str(p))
|
|
files[str(path).lstrip('/')] = source
|
|
manifest = {}
|
|
with tarfile.open(out / 'normal-native.tar', 'x') as archive:
|
|
for name, source in sorted(files.items()):
|
|
data = source.read_bytes()
|
|
manifest[name] = {'source': str(source), 'sha256': hashlib.sha256(data).hexdigest()}
|
|
info = tarfile.TarInfo(name)
|
|
info.size, info.mode = len(data), 0o755
|
|
archive.addfile(info, io.BytesIO(data))
|
|
record(out / 'native-artifact-sha256.json', manifest)
|
|
with (out/'normal-native.tar').open('rb') as source,(out/'normal-native.tar.gz').open('xb') as destination:
|
|
with gzip.GzipFile(filename='',fileobj=destination,mode='wb',mtime=0) as compressed:
|
|
shutil.copyfileobj(source,compressed)
|
|
with gzip.open(out/'normal-native.tar.gz','rb') as restored:
|
|
require_hash=hashlib.file_digest(restored,'sha256').hexdigest()
|
|
assert require_hash==sha(out/'normal-native.tar'), 'compressed native artifact derivation'
|
|
record(out/'native-compression.json',{'uncompressed_bytes':(out/'normal-native.tar').stat().st_size,
|
|
'compressed_bytes':(out/'normal-native.tar.gz').stat().st_size,'uncompressed_sha256':require_hash,
|
|
'compressed_sha256':sha(out/'normal-native.tar.gz')})
|
|
record(out / 'native-derivation.json', {'compiled': str(binary), 'compiled_sha256': sha(binary),
|
|
'packaged_sha256': sha(target), 'source_manifest_sha256': sha(out / 'source-sha256.json'),
|
|
'kind': 'cargo build --locked --offline --bin archipelago (not libtest)'})
|
|
|
|
|
|
def verify(root, out):
|
|
manifest = json.loads((out / 'source-sha256.json').read_text())
|
|
frozen_changes = [n for n, digest in manifest.items() if sha(out / 'source' / n) != digest]
|
|
live_changes = [n for n, digest in manifest.items() if not (root / n).is_file() or sha(root / n) != digest]
|
|
record(out / 'source-after.json', {'frozen_changes': frozen_changes, 'live_changes': live_changes,
|
|
'live_additions': sorted(set(inventory(root)) - set(manifest))})
|
|
if frozen_changes:
|
|
raise RuntimeError('frozen inputs changed')
|
|
|
|
|
|
def replay(previous, out):
|
|
"""Retry immutable prior build after an infrastructure failure, not live code."""
|
|
manifest = json.loads((previous / 'source-sha256.json').read_text())
|
|
for name, digest in manifest.items():
|
|
if sha(previous / 'source' / name) != digest:
|
|
raise RuntimeError('prior frozen source drift: ' + name)
|
|
derivation = json.loads((previous / 'native-derivation.json').read_text())
|
|
assert sha(previous / 'source-sha256.json') == derivation['source_manifest_sha256']
|
|
assert sha(previous / 'normal-elf') == derivation['packaged_sha256']
|
|
out.mkdir(mode=0o700)
|
|
shutil.copytree(previous / 'source', out / 'source')
|
|
for name in ('source.tar', 'source-sha256.json', 'source-identity.json', 'normal-native.tar',
|
|
'normal-elf', 'native-artifact-sha256.json', 'native-derivation.json',
|
|
'tmux-runtime.tar', 'tmux-runtime-sha256.json'):
|
|
shutil.copyfile(previous / name, out / name)
|
|
(out / 'containers.conf').write_text('[containers]\nkeyring=false\n')
|
|
record(out / 'replay.json', {'previous_attempt': str(previous),
|
|
'source': 'same frozen source, not current worktree',
|
|
'keyring': 'creation disabled due to rootless quota; not keyring isolation qualification',
|
|
'package_driver_sha256': sha(Path(__file__))})
|
|
|
|
|
|
def reuse_native(previous, out):
|
|
"""Reuse only when all non-fixture/embedded build inputs are byte identical.
|
|
|
|
Embedded owner/coordinator modules must match the native build exactly.
|
|
Never relabel the binary's original source manifest as the new fixture one.
|
|
"""
|
|
old = json.loads((previous / 'source-sha256.json').read_text())
|
|
new = json.loads((out / 'source-sha256.json').read_text())
|
|
allowed = {'tests/regression/c01-compose.py', 'tests/regression/c01-normal-systemd.py',
|
|
'tests/regression/c01-normal-acceptance.py',
|
|
'tests/regression/c01-history-migration.py',
|
|
'tests/regression/c01-running-services.py',
|
|
'tests/regression/c01-appointed-oci.py',
|
|
# Verified bundle tools execute externally; no Rust include_str input.
|
|
'scripts/c01_oci_job.py','scripts/import-uat-oci.py','scripts/build-iso-uat.py',
|
|
'scripts/tests/test_c01_oci_job.py','scripts/tests/test_uat_oci.py',
|
|
# External cold-migration coordinator, not embedded by c01_supervisor.rs.
|
|
'scripts/c01_migration.py', 'scripts/tests/test_c01_migration_approval.py',
|
|
'tests/regression/c01-role-views.py',
|
|
'tests/regression/c01-prerequisite-systemd.py', 'scripts/c01_access.py',
|
|
'scripts/c01_access_kernel.py',
|
|
'scripts/tests/test_c01_retirement_pins.py',
|
|
'scripts/tests/prepare_c01_nts_dependencies.py',
|
|
'tests/regression/c01-power-systemd.py', 'scripts/test-c01-normal-systemd-isolated.sh',
|
|
# External USER unit, read from the frozen deployment package; not
|
|
# embedded in Rust/CPython. Its distinct source manifest remains pinned.
|
|
'image-recipe/configs/archipelago-runtime-podman.service',
|
|
'image-recipe/configs/archipelago-c01-network.service',
|
|
'image-recipe/configs/70-c01-supervisor.conf.in',
|
|
'scripts/tests/package_c01_normal_fixture.py'}
|
|
changed = sorted(name for name in old.keys() | new.keys() if old.get(name) != new.get(name))
|
|
assert set(changed) <= allowed, 'native build inputs changed: ' + str(set(changed) - allowed)
|
|
original = json.loads((previous / 'native-derivation.json').read_text())
|
|
assert sha(previous / 'normal-elf') == original['packaged_sha256']
|
|
for name in ('normal-native.tar','normal-elf','native-artifact-sha256.json',
|
|
'tmux-runtime.tar','tmux-runtime-sha256.json'):
|
|
shutil.copyfile(previous / name, out / name)
|
|
for name in ('normal-native.tar.gz','native-compression.json','strip-derivation.json'):
|
|
if (previous/name).exists():shutil.copyfile(previous/name,out/name)
|
|
record(out / 'native-derivation.json', {**original,
|
|
'fixture_source_manifest_sha256': sha(out / 'source-sha256.json'),
|
|
'immutable_native_from': str(previous), 'nonembedded_changes': changed})
|
|
|
|
|
|
if __name__ == '__main__':
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('mode', choices=['freeze', 'artifact', 'verify', 'replay', 'reuse-native'])
|
|
parser.add_argument('input', type=Path)
|
|
parser.add_argument('receipt', type=Path)
|
|
args = parser.parse_args()
|
|
{'freeze': freeze, 'artifact': artifact, 'verify': verify, 'replay': replay,
|
|
'reuse-native': reuse_native}[args.mode](args.input.resolve(), args.receipt.resolve())
|