910 lines
47 KiB
Python
910 lines
47 KiB
Python
#!/usr/bin/env python3
|
|
"""Run the offline worker E2E against dedicated, already-built local images.
|
|
|
|
Run from Linux/WSL: python3 docker/verify.py [--keep]
|
|
Requires Docker with Compose v2 (directly or via sudo -n docker), Linux named
|
|
volumes, and Git. docker/test-results/latest.json must already be gitignored;
|
|
this script never changes ignore files. No image builds, pulls, host data
|
|
mounts, application edits, production Compose files, or broad cleanup.
|
|
|
|
Checks have a 3600-second aggregate budget; failure shutdown has a separate
|
|
720-second budget. Each health wait is at most 240 seconds, and each stop uses
|
|
600 seconds of grace. A forced/nonzero/OOM exit never counts as success.
|
|
Failures retain owned Docker artifacts after a guarded stop attempt. --keep
|
|
also retains them on success. Evidence contains only counts, hashes, image
|
|
IDs, fixed statuses, and durations; command logs are never printed or saved.
|
|
|
|
Contract limits: prepare is not repaired or rerun after recreation. A stopped
|
|
container loses its /run/truf tmpfs, so its shutdown receipt cannot be read
|
|
afterward. Receipt-write success is inferred ONLY from the healthy foreground
|
|
runtime's zero-exit contract; private PostgreSQL PID-file absence is separately
|
|
checked using the same runtime image and a read-only data-volume mount.
|
|
"""
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import secrets
|
|
import selectors
|
|
import shutil
|
|
import signal
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
|
|
PYTHON = ['/usr/local/bin/python3', '-I', '-S', '-B']
|
|
APP = '/opt/truf/app/container_runtime.py'
|
|
DRIVER = '/opt/truf/tests/container_e2e.py'
|
|
CONFIG = '/data/config/e2e.yaml'
|
|
ENTRYPOINT = ['/usr/bin/tini', '--', '/usr/local/bin/python3', '-u', '-I', '-S', '-B', APP]
|
|
HEALTH = ['CMD', *PYTHON, APP, 'health', '--config', CONFIG]
|
|
SERVICES = {'tools', 'provision', 'prepare', 'runtime', 'stopped'}
|
|
IMAGE_REFERENCES = {
|
|
'runtime': 'truf-worker-test:runtime',
|
|
'test': 'truf-worker-test:test',
|
|
}
|
|
TMPFS = {
|
|
'/run/truf': 'rw,nosuid,nodev,noexec,size=64m,mode=0700,uid=10001,gid=10001',
|
|
'/tmp': 'rw,nosuid,nodev,noexec,size=128m,mode=1777',
|
|
}
|
|
PROXY_ENV = {
|
|
name: '*' if name.lower() == 'no_proxy' else ''
|
|
for stem in ('http_proxy', 'https_proxy', 'ftp_proxy', 'all_proxy', 'no_proxy')
|
|
for name in (stem, stem.upper())
|
|
}
|
|
MOUNTS = {
|
|
'tools': {'/opt/truf/tests': ('tools', False)},
|
|
'provision': {'/data': ('data', False)},
|
|
'prepare': {'/data': ('data', False), '/opt/truf/tests': ('tools', True)},
|
|
'runtime': {'/data': ('data', False), '/opt/truf/tests': ('tools', True)},
|
|
'stopped': {'/data': ('data', True)},
|
|
}
|
|
LABEL = 'com.docker.compose.'
|
|
MAX_OUTPUT = 128 * 1024
|
|
MAX_DOCKER_RESOURCES = 1024
|
|
MAX_SNAPSHOT_BYTES = 4 * 1024 * 1024
|
|
HEX = re.compile(r'[a-f0-9]{64}')
|
|
|
|
CONTAINER_METADATA_FORMAT = (
|
|
'{"id":{{json .Id}},"name":{{json .Name}},"image":{{json .Image}},'
|
|
'"status":{{json .State.Status}},"running":{{json .State.Running}},'
|
|
'"paused":{{json .State.Paused}},"restarting":{{json .State.Restarting}},'
|
|
'"dead":{{json .State.Dead}},"mounts":{{json .Mounts}}}'
|
|
)
|
|
VOLUME_METADATA_FORMAT = (
|
|
'{"name":{{json .Name}},"driver":{{json .Driver}},"scope":{{json .Scope}},'
|
|
'"created":{{json .CreatedAt}},"mountpoint":{{json .Mountpoint}},'
|
|
'"labels":{{json .Labels}},"options":{{json .Options}}}'
|
|
)
|
|
|
|
INSPECT_FIELDS = {
|
|
'id': '.Id', 'name': '.Name', 'image': '.Image', 'status': '.State.Status',
|
|
'running': '.State.Running', 'pid': '.State.Pid',
|
|
'exit_code': '.State.ExitCode', 'oom_killed': '.State.OOMKilled',
|
|
'restarts': '.RestartCount', 'user': '.Config.User',
|
|
'entrypoint': '.Config.Entrypoint', 'command': '.Config.Cmd',
|
|
'stop_timeout': '.Config.StopTimeout',
|
|
'stop_signal': '.Config.StopSignal', 'mounts': '.Mounts',
|
|
'readonly': '.HostConfig.ReadonlyRootfs', 'network': '.HostConfig.NetworkMode',
|
|
'cap_drop': '.HostConfig.CapDrop', 'cap_add': '.HostConfig.CapAdd',
|
|
'security_opt': '.HostConfig.SecurityOpt', 'init': '.HostConfig.Init',
|
|
'privileged': '.HostConfig.Privileged', 'pid_mode': '.HostConfig.PidMode',
|
|
'ports': '.HostConfig.PortBindings', 'tmpfs': '.HostConfig.Tmpfs',
|
|
'cpus': '.HostConfig.NanoCpus', 'memory': '.HostConfig.Memory',
|
|
'pids_limit': '.HostConfig.PidsLimit', 'restart_policy': '.HostConfig.RestartPolicy',
|
|
**{key: '(index .Config.Labels "' + LABEL + suffix + '")' for key, suffix in (
|
|
('project', 'project'), ('service', 'service'), ('oneoff', 'oneoff'),
|
|
('config_files', 'project.config_files'), ('working_dir', 'project.working_dir'),
|
|
)},
|
|
}
|
|
# Do not inspect .State or .Config wholesale: health logs and environments can
|
|
# contain credentials. Only these selected fields enter the host process.
|
|
INSPECT_FORMAT = '{' + ','.join(
|
|
json.dumps(key) + ':{{json ' + value + '}}' for key, value in INSPECT_FIELDS.items()
|
|
) + (',"health_test":{{with index .Config "Healthcheck"}}{{json .Test}}{{else}}null{{end}}'
|
|
',"health":{{with index .State "Health"}}{{json .Status}}{{else}}null{{end}}}')
|
|
|
|
|
|
class Failure(Exception):
|
|
"""Only fixed check labels, never subprocess output or exception messages."""
|
|
|
|
|
|
def require(condition, label):
|
|
if not condition:
|
|
raise Failure(label)
|
|
|
|
|
|
class Verifier:
|
|
def __init__(self):
|
|
self.script = Path(__file__).absolute()
|
|
self.root = self.script.parent.parent.resolve(strict=True)
|
|
self.file = self.root / 'compose.e2e.yaml'
|
|
self.project = 'truf-worker-test-' + secrets.token_hex(16)
|
|
self.started = time.monotonic()
|
|
self.deadline = self.started + 3600
|
|
self.docker = []
|
|
self.compose = []
|
|
self.images = {}
|
|
self.owned_containers = {}
|
|
self.owned_volumes = {}
|
|
self.foreign_baseline = None
|
|
self.mutated = False
|
|
self.evidence_ready = False
|
|
self.file_hashes = {}
|
|
self.report = {
|
|
'status': {'result': 'running', 'cleanup': 'not_started'},
|
|
'counts': {'schema': 1},
|
|
'hashes': {'project_sha256': hashlib.sha256(self.project.encode('ascii')).hexdigest()},
|
|
'image_ids': self.images, 'durations': {},
|
|
}
|
|
allowed_env = (
|
|
'PATH', 'HOME', 'XDG_CONFIG_HOME', 'XDG_RUNTIME_DIR', 'SSH_AUTH_SOCK',
|
|
'DOCKER_HOST', 'DOCKER_CONTEXT', 'DOCKER_CONFIG', 'DOCKER_TLS_VERIFY',
|
|
'DOCKER_CERT_PATH',
|
|
)
|
|
self.env = {name: os.environ[name] for name in allowed_env if name in os.environ}
|
|
self.env['COMPOSE_DISABLE_ENV_FILE'] = '1'
|
|
|
|
def execute(self, label, args, *, timeout=30, capture=False, check=True):
|
|
started = time.monotonic()
|
|
end = min(self.deadline, started + timeout)
|
|
require(end > started, 'aggregate_timeout')
|
|
process = None
|
|
output = bytearray()
|
|
selector = selectors.DefaultSelector()
|
|
counts = self.report['counts'].setdefault(label, {})
|
|
counts['cli_calls'] = counts.get('cli_calls', 0) + 1
|
|
self.report['status'][label] = 'running'
|
|
try:
|
|
process = subprocess.Popen(
|
|
args, cwd=self.root, env=self.env, stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.PIPE if capture else subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL, start_new_session=True,
|
|
)
|
|
if capture:
|
|
selector.register(process.stdout, selectors.EVENT_READ)
|
|
while process.poll() is None or selector.get_map():
|
|
remaining = end - time.monotonic()
|
|
require(remaining > 0, label + '_timeout')
|
|
if selector.get_map():
|
|
for key, _ in selector.select(min(0.1, remaining)):
|
|
chunk = os.read(key.fd, 65536)
|
|
if not chunk:
|
|
selector.unregister(key.fileobj)
|
|
else:
|
|
output.extend(chunk)
|
|
require(len(output) <= MAX_OUTPUT, label + '_output_limit')
|
|
else:
|
|
time.sleep(min(0.05, remaining))
|
|
code = process.returncode
|
|
counts['exit_code' if code >= 0 else 'signal'] = abs(code)
|
|
self.report['status'][label] = 'passed' if code == 0 else 'failed'
|
|
if check and code != 0:
|
|
error = Failure(label + '_command_failed')
|
|
error.exit_code = code
|
|
raise error
|
|
return code, bytes(output)
|
|
except OSError:
|
|
self.report['status'][label] = 'failed'
|
|
raise Failure(label + '_unavailable') from None
|
|
except BaseException:
|
|
self.report['status'][label] = 'failed'
|
|
raise
|
|
finally:
|
|
selector.close()
|
|
if process is not None:
|
|
if process.poll() is None:
|
|
# Terminate only the local CLI process group, not containers.
|
|
# An interrupted Docker API operation can outlive its client;
|
|
# failure handling discovers and stops owned containers.
|
|
try:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
except (ProcessLookupError, PermissionError):
|
|
pass
|
|
try:
|
|
process.wait(timeout=2)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
if process.stdout is not None:
|
|
process.stdout.close()
|
|
durations = self.report['durations']
|
|
durations[label] = round(durations.get(label, 0) + time.monotonic() - started, 3)
|
|
|
|
def json_command(self, label, args, timeout=30):
|
|
_, output = self.execute(label, args, timeout=timeout, capture=True)
|
|
try:
|
|
return json.loads(output)
|
|
except (ValueError, UnicodeError):
|
|
raise Failure(label + '_invalid_json') from None
|
|
|
|
def words(self, label, args):
|
|
_, output = self.execute(label, [*self.docker, *args], capture=True)
|
|
try:
|
|
words = output.decode('ascii').split()
|
|
except UnicodeError:
|
|
raise Failure(label + '_invalid_inventory') from None
|
|
require(len(words) <= 16, label + '_inventory_limit')
|
|
return set(words)
|
|
|
|
def metadata_names(self, kind):
|
|
options = (['--all', '--no-trunc', '--format', '{{.ID}}']
|
|
if kind == 'container' else ['--format', '{{.Name}}'])
|
|
_, output = self.execute(
|
|
'foreign_' + kind + '_list', [*self.docker, kind, 'ls', *options], capture=True,
|
|
)
|
|
try:
|
|
values = {line for line in output.decode('ascii').splitlines() if line}
|
|
except UnicodeError:
|
|
raise Failure('foreign_' + kind + '_inventory_encoding') from None
|
|
require(len(values) <= MAX_DOCKER_RESOURCES, 'foreign_' + kind + '_inventory_bound')
|
|
return values
|
|
|
|
def container_metadata(self, identifier):
|
|
require(HEX.fullmatch(identifier), 'foreign_container_id_guard')
|
|
value = self.json_command('foreign_container_inspect', [
|
|
*self.docker, 'container', 'inspect', '--format', CONTAINER_METADATA_FORMAT,
|
|
identifier,
|
|
])
|
|
require(
|
|
value.get('id') == identifier and isinstance(value.get('name'), str)
|
|
and HEX.fullmatch(str(value.get('image') or '').removeprefix('sha256:'))
|
|
and value.get('status') in (
|
|
'created', 'running', 'paused', 'restarting', 'removing', 'exited', 'dead',
|
|
)
|
|
and all(type(value.get(key)) is bool for key in (
|
|
'running', 'paused', 'restarting', 'dead',
|
|
))
|
|
and isinstance(value.get('mounts'), list) and len(value['mounts']) <= 128,
|
|
'foreign_container_metadata_guard',
|
|
)
|
|
mounts = [{
|
|
key: mount.get(key) for key in (
|
|
'Type', 'Name', 'Source', 'Destination', 'Driver', 'Mode', 'RW', 'Propagation',
|
|
)
|
|
} for mount in value['mounts']]
|
|
return {
|
|
'id': value['id'], 'name': value['name'], 'image': value['image'],
|
|
'status': value['status'], 'running': value['running'], 'paused': value['paused'],
|
|
'restarting': value['restarting'], 'dead': value['dead'],
|
|
'mounts_sha256': hashlib.sha256(json.dumps(
|
|
mounts, ensure_ascii=True, sort_keys=True, separators=(',', ':'),
|
|
).encode('ascii')).hexdigest(),
|
|
}
|
|
|
|
def volume_metadata(self, name):
|
|
value = self.json_command('foreign_volume_inspect', [
|
|
*self.docker, 'volume', 'inspect', '--format', VOLUME_METADATA_FORMAT, name,
|
|
])
|
|
require(
|
|
value.get('name') == name and isinstance(value.get('driver'), str)
|
|
and isinstance(value.get('scope'), str) and isinstance(value.get('mountpoint'), str)
|
|
and (value.get('created') is None or isinstance(value['created'], str))
|
|
and (value.get('labels') is None or isinstance(value['labels'], dict))
|
|
and (value.get('options') is None or isinstance(value['options'], dict)),
|
|
'foreign_volume_metadata_guard',
|
|
)
|
|
return {
|
|
'name': name, 'driver': value['driver'], 'scope': value['scope'],
|
|
'created': value['created'],
|
|
'mountpoint_sha256': hashlib.sha256(value['mountpoint'].encode('utf-8')).hexdigest(),
|
|
'labels_sha256': hashlib.sha256(json.dumps(
|
|
value['labels'], ensure_ascii=True, sort_keys=True, separators=(',', ':'),
|
|
).encode('ascii')).hexdigest(),
|
|
'options_sha256': hashlib.sha256(json.dumps(
|
|
value['options'], ensure_ascii=True, sort_keys=True, separators=(',', ':'),
|
|
).encode('ascii')).hexdigest(),
|
|
}
|
|
|
|
def metadata_snapshot(self, *, exclude_owned=False):
|
|
containers = {}
|
|
for identifier in sorted(self.metadata_names('container')):
|
|
if exclude_owned and identifier in self.owned_containers:
|
|
self.inspect(identifier, 'foreign_owned_exclusion_guard')
|
|
continue
|
|
containers[identifier] = self.container_metadata(identifier)
|
|
volumes = {}
|
|
for name in sorted(self.metadata_names('volume')):
|
|
value = self.volume_metadata(name)
|
|
if exclude_owned and self.owned_volumes.get(name) == value:
|
|
continue
|
|
volumes[name] = value
|
|
snapshot = {'containers': containers, 'volumes': volumes}
|
|
require(len(json.dumps(snapshot, ensure_ascii=True, sort_keys=True)) <= MAX_SNAPSHOT_BYTES,
|
|
'foreign_metadata_snapshot_bound')
|
|
return snapshot
|
|
|
|
def snapshot_foreign(self):
|
|
require(self.foreign_baseline is None, 'foreign_snapshot_already_taken')
|
|
self.foreign_baseline = self.metadata_snapshot()
|
|
|
|
def assert_foreign_unchanged(self):
|
|
require(self.foreign_baseline is not None, 'foreign_snapshot_missing')
|
|
require(self.metadata_snapshot(exclude_owned=True) == self.foreign_baseline,
|
|
'foreign_docker_state_changed')
|
|
self.report['status']['foreign_docker_state'] = 'unchanged'
|
|
|
|
def guard_files(self):
|
|
require(re.fullmatch(r'truf-worker-test-[a-f0-9]{32}', self.project),
|
|
'worker_test_project_guard')
|
|
require(self.script == self.root / 'docker' / 'verify.py', 'verifier_path_guard')
|
|
require(self.file == self.root / 'compose.e2e.yaml', 'compose_path_guard')
|
|
for path in (self.script, self.file):
|
|
require(path.resolve(strict=True) == path and path.is_file() and not path.is_symlink(),
|
|
'canonical_file_required')
|
|
require(path.stat().st_size <= 256 * 1024, 'source_file_size_bound')
|
|
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
require(path not in self.file_hashes or self.file_hashes[path] == digest,
|
|
'source_changed_during_verification')
|
|
self.file_hashes[path] = digest
|
|
|
|
def compose_command(self, label, args, **kwargs):
|
|
self.guard_files()
|
|
return self.execute(label, [*self.compose, *args], **kwargs)
|
|
|
|
def inspect(self, container, label='inspect', timeout=30):
|
|
require(HEX.fullmatch(container), 'container_id_guard')
|
|
value = self.json_command(label, [
|
|
*self.docker, 'container', 'inspect', '--format', INSPECT_FORMAT, container,
|
|
], timeout=timeout)
|
|
require(value['id'] == container and value['project'] == self.project
|
|
and isinstance(value['name'], str)
|
|
and value['name'].startswith('/' + self.project + '-')
|
|
and value['service'] in SERVICES
|
|
and value['config_files'] == str(self.file)
|
|
and value['working_dir'] == str(self.root), 'container_ownership_guard')
|
|
service = value['service']
|
|
require(value['oneoff'] == ('False' if service == 'runtime' else 'True'),
|
|
'container_role_guard')
|
|
require(value['image'] == self.images['test' if service == 'tools' else 'runtime'],
|
|
'container_image_guard')
|
|
require(value['status'] in ('created', 'running', 'paused', 'restarting', 'removing', 'exited', 'dead'),
|
|
'container_state_guard')
|
|
require(all(type(value[key]) is int and value[key] >= 0 for key in ('exit_code', 'restarts', 'pid'))
|
|
and all(type(value[key]) is bool for key in ('oom_killed', 'running')),
|
|
'container_state_types_guard')
|
|
identity = {key: value[key] for key in (
|
|
'id', 'name', 'image', 'project', 'service', 'config_files', 'working_dir',
|
|
)}
|
|
require(self.owned_containers.setdefault(container, identity) == identity,
|
|
'container_identity_changed')
|
|
self.report['status']['container_' + service] = value['status']
|
|
self.report['counts']['container_' + service] = {
|
|
'exit_code': value['exit_code'], 'oom_killed': int(value['oom_killed']),
|
|
'restarts': value['restarts'], 'running': int(value['running']),
|
|
}
|
|
return value
|
|
|
|
def inventory(self, *, complete=False):
|
|
self.guard_files()
|
|
found = {}
|
|
for kind in ('container', 'volume', 'network'):
|
|
options = ['--all', '--quiet', '--no-trunc'] if kind == 'container' else ['--format', '{{.Name}}']
|
|
named = self.words('inventory_' + kind, [
|
|
kind, 'ls', *options, '--filter', 'name=' + self.project,
|
|
])
|
|
labeled = self.words('ownership_' + kind, [
|
|
kind, 'ls', *options, '--filter', 'label=' + LABEL + 'project=' + self.project,
|
|
])
|
|
require(named == labeled, 'resource_ownership_guard')
|
|
found[kind] = named
|
|
require(not found['network'], 'unexpected_project_network')
|
|
expected = {self.project + '_data', self.project + '_tools'}
|
|
require(found['volume'] <= expected, 'volume_name_guard')
|
|
if complete:
|
|
require(found['volume'] == expected, 'required_volumes_missing')
|
|
for volume in sorted(found['volume']):
|
|
value = self.json_command('inspect_volume', [
|
|
*self.docker, 'volume', 'inspect', '--format',
|
|
'{"name":{{json .Name}},"driver":{{json .Driver}},"options":{{json .Options}},'
|
|
'"scope":{{json .Scope}},"project":{{json (index .Labels "com.docker.compose.project")}},'
|
|
'"volume":{{json (index .Labels "com.docker.compose.volume")}}}', volume,
|
|
])
|
|
require(value['name'] == volume and value['project'] == self.project
|
|
and value['volume'] in ('data', 'tools')
|
|
and volume == self.project + '_' + value['volume']
|
|
and value['driver'] == 'local' and not value['options']
|
|
and value['scope'] == 'local', 'native_named_volume_guard')
|
|
metadata = self.volume_metadata(volume)
|
|
require(self.owned_volumes.setdefault(volume, metadata) == metadata,
|
|
'volume_identity_changed')
|
|
users = self.words('volume_users', [
|
|
'container', 'ls', '--all', '--quiet', '--no-trunc', '--filter', 'volume=' + volume,
|
|
])
|
|
require(users <= found['container'], 'foreign_volume_user_guard')
|
|
self.report['counts']['owned_resources'] = {
|
|
kind + 's': len(names) for kind, names in found.items()
|
|
}
|
|
return [self.inspect(container) for container in sorted(found['container'])]
|
|
|
|
def validate_compose(self, value):
|
|
require(value.get('name') == self.project, 'worker_test_project_guard')
|
|
require(set(value['services']) == SERVICES and set(value['volumes']) == {'data', 'tools'}
|
|
and not any(value.get(key) for key in ('networks', 'secrets', 'configs')),
|
|
'compose_project_contract')
|
|
for name, volume in value['volumes'].items():
|
|
require(volume.get('name') == self.project + '_' + name,
|
|
'production_volume_forbidden')
|
|
require(
|
|
volume.get('name') not in {'truf-docker_data', 'truf-docker_tools'}
|
|
and volume.get('driver') == 'local'
|
|
and set(volume) <= {'name', 'driver'}, 'compose_volume_contract')
|
|
allowed = {
|
|
'image', 'pull_policy', 'read_only', 'user', 'cap_drop', 'cap_add', 'security_opt',
|
|
'network_mode', 'volumes', 'tmpfs', 'cpus', 'mem_limit', 'pids_limit', 'shm_size',
|
|
'stop_signal', 'stop_grace_period', 'logging', 'restart', 'entrypoint', 'command',
|
|
'healthcheck', 'environment', 'init', 'ports',
|
|
}
|
|
for name, service in value['services'].items():
|
|
require(set(service) <= allowed, 'compose_service_options_guard')
|
|
require(service['image'] == self.images['test' if name == 'tools' else 'runtime'],
|
|
'worker_test_image_reference_guard')
|
|
require(not service.get('ports'), 'published_port_contract')
|
|
require(
|
|
service.get('network_mode') == 'none', 'internal_network_contract')
|
|
require(
|
|
service['image'] == self.images['test' if name == 'tools' else 'runtime']
|
|
and service.get('pull_policy') == 'never'
|
|
and service.get('read_only') is True
|
|
and service.get('environment') == PROXY_ENV
|
|
and service.get('init') is False
|
|
and service.get('user') == ('0:0' if name == 'provision' else '10001:10001')
|
|
and set(service.get('cap_drop', ())) == {'ALL'}
|
|
and set(service.get('cap_add', ())) == (
|
|
{'CHOWN', 'DAC_OVERRIDE', 'FOWNER'} if name == 'provision' else set())
|
|
and service.get('security_opt') == ['no-new-privileges:true']
|
|
and service.get('restart') == 'no'
|
|
and float(service['cpus']) == 2
|
|
and int(service['mem_limit']) == 6 * 1024 ** 3
|
|
and int(service['pids_limit']) == 512
|
|
and int(service['shm_size']) == 256 * 1024 ** 2
|
|
and service['stop_signal'] == 'SIGTERM'
|
|
and service['logging'] == {
|
|
'driver': 'json-file', 'options': {'max-size': '16m', 'max-file': '4'},
|
|
}
|
|
and set(service['tmpfs']) == {key + ':' + val for key, val in TMPFS.items()},
|
|
'compose_isolation_contract')
|
|
mounts = {}
|
|
for mount in service['volumes']:
|
|
require(mount.get('type') == 'volume', 'bind_mount_forbidden')
|
|
require(set(mount) <= {'type', 'source', 'target', 'read_only', 'volume'}
|
|
and set(mount.get('volume', {})) <= {'nocopy'}, 'compose_mount_guard')
|
|
require(mount['target'] not in mounts, 'duplicate_mount_guard')
|
|
mounts[mount['target']] = (mount['source'], mount.get('read_only', False))
|
|
if mount['source'] == 'tools':
|
|
require(mount.get('volume', {}).get('nocopy', False) is (name != 'tools'),
|
|
'tools_copy_up_contract')
|
|
require(mounts == MOUNTS[name], 'compose_mount_contract')
|
|
runtime = value['services']['runtime']
|
|
require(runtime.get('entrypoint') is None and runtime['command'] == ['run', '--config', CONFIG]
|
|
and runtime['healthcheck']['test'] == HEALTH, 'production_entrypoint_contract')
|
|
require(value['services']['provision'].get('entrypoint') is None
|
|
and value['services']['provision']['command'] == ['provision']
|
|
and value['services']['prepare']['entrypoint'] == [*PYTHON, DRIVER]
|
|
and value['services']['prepare']['command'] == ['prepare', '--config', CONFIG],
|
|
'prepare_command_contract')
|
|
|
|
def preflight(self, env_file):
|
|
self.guard_files()
|
|
for path, digest in self.file_hashes.items():
|
|
self.report['hashes']['compose_sha256' if path == self.file else 'verifier_sha256'] = digest
|
|
docker = shutil.which('docker')
|
|
require(docker, 'docker_cli_required')
|
|
candidates = [[docker]]
|
|
sudo = shutil.which('sudo')
|
|
if sudo:
|
|
candidates.append([sudo, '-n', docker])
|
|
for index, candidate in enumerate(candidates):
|
|
try:
|
|
code, output = self.execute('docker_probe_' + str(index), [
|
|
*candidate, 'info', '--format', '{{json .OSType}}',
|
|
], timeout=15, capture=True, check=False)
|
|
except Failure:
|
|
continue
|
|
if code == 0:
|
|
require(json.loads(output) == 'linux', 'linux_docker_daemon_required')
|
|
self.docker = candidate
|
|
break
|
|
require(self.docker, 'docker_or_passwordless_sudo_required')
|
|
self.execute('compose_available', [*self.docker, 'compose', 'version', '--short'])
|
|
self.snapshot_foreign()
|
|
for name in ('runtime', 'test'):
|
|
value = self.json_command('image_' + name, [
|
|
*self.docker, 'image', 'inspect', '--format',
|
|
'{"id":{{json .Id}},"os":{{json .Os}},"user":{{json .Config.User}},'
|
|
'"entrypoint":{{json .Config.Entrypoint}},"volumes":{{json (index .Config "Volumes")}}}',
|
|
IMAGE_REFERENCES[name],
|
|
])
|
|
require(re.fullmatch(r'sha256:[a-f0-9]{64}', value['id'])
|
|
and value['os'] == 'linux' and value['user'] == '10001:10001'
|
|
and not value['volumes'], 'prebuilt_image_contract')
|
|
if name == 'runtime':
|
|
require(value['entrypoint'] == ENTRYPOINT, 'runtime_image_entrypoint_contract')
|
|
self.images[name] = value['id']
|
|
require(self.images['runtime'] != self.images['test'], 'distinct_image_targets_required')
|
|
env_file.write('TRUF_WORKER_TEST_PROJECT=' + self.project + '\n'
|
|
'TRUF_WORKER_TEST_RUNTIME_IMAGE=' + self.images['runtime'] + '\n'
|
|
'TRUF_WORKER_TEST_TEST_IMAGE=' + self.images['test'] + '\n')
|
|
env_file.flush()
|
|
# An explicit env file works with sudo's environment reset, too. Never
|
|
# load the checkout's .env or accept COMPOSE_FILE/PROJECT_NAME overrides.
|
|
self.compose = [
|
|
*self.docker, 'compose', '--ansi', 'never', '--project-name', self.project,
|
|
'--project-directory', str(self.root), '--env-file', env_file.name,
|
|
'--file', str(self.file),
|
|
]
|
|
self.validate_compose(self.json_command('compose_contract', [
|
|
*self.compose, 'config', '--format', 'json',
|
|
]))
|
|
git = shutil.which('git')
|
|
require(git, 'git_required_for_evidence_ignore_guard')
|
|
code, _ = self.execute('evidence_ignore_guard', [
|
|
git, 'check-ignore', '--quiet', '--no-index', '--', 'docker/test-results/latest.json',
|
|
], check=False)
|
|
require(code == 0, 'evidence_path_must_be_gitignored')
|
|
require(not self.inventory(), 'fresh_project_required')
|
|
require(not self.words('fresh_volumes', [
|
|
'volume', 'ls', '--format', '{{.Name}}', '--filter', 'name=' + self.project,
|
|
]), 'fresh_volumes_required')
|
|
directory = self.root / 'docker' / 'test-results'
|
|
require(not directory.is_symlink(), 'evidence_directory_guard')
|
|
directory.mkdir(mode=0o700, exist_ok=True)
|
|
require(directory.resolve(strict=True) == directory and directory.is_dir(),
|
|
'evidence_directory_guard')
|
|
self.evidence_ready = True
|
|
|
|
def summary(self, label, args, timeout):
|
|
print(label + ': running', flush=True)
|
|
code, output = self.execute(label, args, timeout=timeout, capture=True, check=False)
|
|
try:
|
|
value = json.loads(output)
|
|
except (ValueError, UnicodeError):
|
|
raise Failure(label + '_invalid_summary') from None
|
|
require(isinstance(value, dict) and set(value) == {'counts', 'hashes'}
|
|
and all(isinstance(value[key], dict) and len(value[key]) <= 128 for key in value),
|
|
label + '_invalid_summary')
|
|
require(all(re.fullmatch(r'[a-z][a-z0-9_:]{0,120}', key)
|
|
and type(count) is int and 0 <= count <= 2 ** 63 - 1
|
|
for key, count in value['counts'].items())
|
|
and all(re.fullmatch(r'[a-z][a-z0-9_:]{0,120}_sha256', key)
|
|
and isinstance(digest, str) and HEX.fullmatch(digest)
|
|
for key, digest in value['hashes'].items()), label + '_invalid_summary')
|
|
self.report['counts'][label].update(value['counts'])
|
|
self.report['hashes'][label] = value['hashes']
|
|
require(code == 0 and value['counts'].get('ok') == 1, label + '_check_failed')
|
|
return value
|
|
|
|
def oneoff(self, service, label=None, timeout=180):
|
|
self.guard_files()
|
|
args = [*self.compose, 'run', '--rm', '--no-deps', '--pull', 'never', '-T', service]
|
|
if service == 'provision':
|
|
print('provision: running', flush=True)
|
|
self.execute('provision', args, timeout=timeout)
|
|
return None
|
|
return self.summary(label or service, args, timeout)
|
|
|
|
def start(self, label, previous=None):
|
|
if previous:
|
|
value = self.inspect(previous, label + '_previous_ownership')
|
|
require(value['status'] == 'exited' and not value['running'],
|
|
label + '_previous_not_stopped')
|
|
self.inspect(previous, label + '_previous_remove_guard')
|
|
self.execute(label + '_remove_previous', [
|
|
*self.docker, 'container', 'rm', previous,
|
|
])
|
|
self.compose_command(label, [
|
|
'up', '--detach', '--no-deps', '--no-build', '--pull', 'never',
|
|
'runtime',
|
|
], timeout=120)
|
|
containers = self.inventory(complete=True)
|
|
runtimes = [value for value in containers if value['service'] == 'runtime']
|
|
require(len(runtimes) == 1, 'single_runtime_required')
|
|
value = runtimes[0]
|
|
container = value['id']
|
|
require(container != previous, 'new_runtime_container_required')
|
|
require(value['entrypoint'] == ENTRYPOINT and value['command'] == ['run', '--config', CONFIG]
|
|
and value['health_test'] == HEALTH and value['user'] == '10001:10001'
|
|
and value['readonly'] is True and value['network'] == 'none'
|
|
and set(value['cap_drop'] or ()) == {'ALL'} and not value['cap_add']
|
|
and value['security_opt'] == ['no-new-privileges:true']
|
|
and not value['init'] and not value['privileged'] and not value['pid_mode']
|
|
and not value['ports'] and value['tmpfs'] == TMPFS
|
|
and value['cpus'] == 2_000_000_000 and value['memory'] == 6 * 1024 ** 3
|
|
and value['pids_limit'] == 512 and value['stop_timeout'] == 600
|
|
and value['stop_signal'] == 'SIGTERM'
|
|
and value['restart_policy'] == {'Name': 'no', 'MaximumRetryCount': 0},
|
|
'actual_runtime_isolation_contract')
|
|
mounts = {}
|
|
for mount in value['mounts']:
|
|
if mount['Type'] == 'tmpfs':
|
|
require(mount['Destination'] in TMPFS, 'unexpected_tmpfs')
|
|
continue
|
|
require(mount['Type'] == 'volume' and mount['Destination'] not in mounts,
|
|
'actual_named_mount_required')
|
|
mounts[mount['Destination']] = (mount['Name'], not mount['RW'])
|
|
require(mounts == {target: (self.project + '_' + name, ro)
|
|
for target, (name, ro) in MOUNTS['runtime'].items()},
|
|
'actual_runtime_mount_contract')
|
|
self.report['hashes'][label + '_container_sha256'] = hashlib.sha256(container.encode('ascii')).hexdigest()
|
|
health_started = time.monotonic()
|
|
health_end = min(self.deadline, health_started + 240)
|
|
while time.monotonic() < health_end:
|
|
value = self.inspect(container, label + '_health', timeout=min(10, health_end - time.monotonic()))
|
|
require(value['running'] and value['status'] == 'running'
|
|
and not value['oom_killed'] and value['restarts'] == 0,
|
|
label + '_runtime_exited_or_restarted')
|
|
if value['health'] == 'healthy':
|
|
self.report['durations'][label + '_ready'] = round(time.monotonic() - health_started, 3)
|
|
return container
|
|
time.sleep(min(2, max(0, health_end - time.monotonic())))
|
|
raise Failure(label + '_health_timeout')
|
|
|
|
def driver(self, container, mode, label, timeout=240):
|
|
self.inspect(container)
|
|
return self.summary(label, [
|
|
*self.docker, 'exec', '--user', '10001:10001', container, *PYTHON,
|
|
DRIVER, mode, '--config', CONFIG, '--timeout', '180',
|
|
], timeout)
|
|
|
|
def health(self, container, label):
|
|
self.inspect(container)
|
|
value = self.json_command(label, [
|
|
*self.docker, 'exec', '--user', '10001:10001', container, *PYTHON,
|
|
APP, 'health', '--config', CONFIG,
|
|
], timeout=30)
|
|
require(value == {
|
|
'healthy': True, 'activation_state': 'ACTIVE', 'postgres': 'READY',
|
|
'workers': ['gitlab', 'janitor', 'jsonl-projector', 'result-ingester'],
|
|
}, label + '_authenticated_health_failed')
|
|
self.report['counts'][label]['authenticated_health'] = 1
|
|
|
|
def stop(self, container, label):
|
|
containers = self.inventory(complete=True)
|
|
require(any(value['id'] == container and value['running'] for value in containers),
|
|
label + '_runtime_not_running')
|
|
print(label + ': stopping (600-second grace)', flush=True)
|
|
self.inspect(container, label + '_ownership_guard')
|
|
self.execute(label, [
|
|
*self.docker, 'container', 'stop', '--time', '600', container,
|
|
], timeout=660)
|
|
value = self.inspect(container, label + '_inspect')
|
|
self.report['counts'][label].update({
|
|
'container_exit_code': value['exit_code'], 'oom_killed': int(value['oom_killed']),
|
|
'restarts': value['restarts'],
|
|
})
|
|
require(value['status'] == 'exited' and not value['running'] and value['pid'] == 0
|
|
and value['exit_code'] == 0 and value['oom_killed'] is False
|
|
and value['restarts'] == 0, label + '_unclean_exit')
|
|
self.oneoff('stopped', label + '_data')
|
|
# Foreground supervisor.main returns zero only after receipt publication.
|
|
# Do not claim to have read that receipt from a destroyed tmpfs.
|
|
self.report['status'][label + '_receipt'] = 'exit_contract_only_tmpfs_removed'
|
|
|
|
def cleanup(self, keep):
|
|
containers = self.inventory(complete=True)
|
|
require(len(containers) == 1 and containers[0]['service'] == 'runtime'
|
|
and containers[0]['status'] == 'exited' and containers[0]['exit_code'] == 0
|
|
and not containers[0]['oom_killed'], 'cleanup_stopped_runtime_guard')
|
|
if keep:
|
|
self.assert_foreign_unchanged()
|
|
self.report['status']['cleanup'] = 'kept'
|
|
return
|
|
self.report['status']['cleanup'] = 'running'
|
|
container = containers[0]['id']
|
|
self.inspect(container, 'cleanup_container_remove_guard')
|
|
self.execute('remove_owned_container', [*self.docker, 'container', 'rm', container])
|
|
for volume in sorted(self.owned_volumes):
|
|
require(self.volume_metadata(volume) == self.owned_volumes[volume],
|
|
'cleanup_volume_identity_changed')
|
|
self.execute('remove_owned_volume', [*self.docker, 'volume', 'rm', volume])
|
|
require(not self.inventory(), 'cleanup_containers_remaining')
|
|
require(not self.words('cleanup_volumes', [
|
|
'volume', 'ls', '--format', '{{.Name}}', '--filter', 'name=' + self.project,
|
|
]), 'cleanup_volumes_remaining')
|
|
self.assert_foreign_unchanged()
|
|
self.report['status']['cleanup'] = 'removed'
|
|
|
|
def failure_stop(self):
|
|
self.deadline = time.monotonic() + 720
|
|
self.report['status']['cleanup'] = 'retained_after_failure'
|
|
try:
|
|
containers = self.inventory()
|
|
active = [value for value in containers
|
|
if value['running'] or value['status'] in ('restarting', 'paused')]
|
|
if active:
|
|
print('failure_stop: stopping owned containers (600-second grace)', flush=True)
|
|
for value in active:
|
|
container = value['id']
|
|
self.inspect(container, 'failure_stop_ownership_guard')
|
|
self.execute('failure_stop', [
|
|
*self.docker, 'container', 'stop', '--time', '600', container,
|
|
], timeout=660)
|
|
remaining = self.inventory()
|
|
self.report['counts']['failure_retained'] = {
|
|
'containers': len(remaining), 'running': sum(int(value['running']) for value in remaining),
|
|
}
|
|
self.report['status']['failure_stop'] = (
|
|
'incomplete' if any(value['running'] for value in remaining) else 'observed_stopped'
|
|
)
|
|
except (Exception, KeyboardInterrupt):
|
|
# Loss of the daemon, changed files, or unproven ownership must never
|
|
# lead to an unguarded down/prune/kill attempt or a false success.
|
|
self.report['status']['failure_stop'] = 'failed_or_ownership_unproven'
|
|
|
|
def write_evidence(self):
|
|
self.report['durations']['total'] = round(time.monotonic() - self.started, 3)
|
|
directory = self.root / 'docker' / 'test-results'
|
|
require(directory.resolve(strict=True) == directory, 'evidence_directory_guard')
|
|
descriptor = os.open(directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
temporary = '.' + self.project + '.json'
|
|
try:
|
|
try:
|
|
details = os.stat('latest.json', dir_fd=descriptor, follow_symlinks=False)
|
|
require(stat.S_ISREG(details.st_mode), 'evidence_file_guard')
|
|
except FileNotFoundError:
|
|
pass
|
|
output = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
0o600, dir_fd=descriptor)
|
|
with os.fdopen(output, 'w', encoding='ascii') as handle:
|
|
json.dump(self.report, handle, sort_keys=True, indent=2, allow_nan=False)
|
|
handle.write('\n')
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.replace(temporary, 'latest.json', src_dir_fd=descriptor, dst_dir_fd=descriptor)
|
|
os.fsync(descriptor)
|
|
finally:
|
|
try:
|
|
os.unlink(temporary, dir_fd=descriptor)
|
|
except FileNotFoundError:
|
|
pass
|
|
os.close(descriptor)
|
|
|
|
|
|
def main(argv=None):
|
|
class Parser(argparse.ArgumentParser):
|
|
def error(self, message):
|
|
raise Failure('invalid_arguments')
|
|
|
|
parser = Parser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, allow_abbrev=False)
|
|
parser.add_argument('--keep', action='store_true', help='retain stopped, owned Docker artifacts on success too')
|
|
args = parser.parse_args(argv)
|
|
require(sys.platform == 'linux', 'linux_host_required_use_wsl_python3')
|
|
verifier = Verifier()
|
|
print('E2E project: ' + verifier.project, flush=True)
|
|
|
|
def interrupted(_signum, _frame):
|
|
raise KeyboardInterrupt
|
|
|
|
previous = signal.signal(signal.SIGTERM, interrupted)
|
|
# This private file contains only the project nonce and image IDs.
|
|
with tempfile.NamedTemporaryFile(mode='w', encoding='ascii', prefix=verifier.project + '-', suffix='.env') as env_file:
|
|
try:
|
|
verifier.preflight(env_file)
|
|
verifier.write_evidence()
|
|
verifier.mutated = True
|
|
verifier.oneoff('tools')
|
|
verifier.oneoff('provision')
|
|
verifier.oneoff('prepare')
|
|
first = verifier.start('first_start')
|
|
verifier.driver(first, 'run-local-pipeline', 'local_pipeline', timeout=240)
|
|
verifier.driver(first, 'assert-pipeline', 'pipeline')
|
|
baseline = verifier.driver(first, 'keycheck-fixture', 'first_keycheck', timeout=510)
|
|
require(baseline['counts'].pop('http_requests', None) == 1, 'first_provider_request_count')
|
|
verifier.health(first, 'first_authenticated_health')
|
|
verifier.stop(first, 'first_stop')
|
|
second = verifier.start('second_start', previous=first)
|
|
persisted = verifier.driver(second, 'assert-persisted', 'persisted')
|
|
require(persisted == baseline, 'persisted_public_summary_changed')
|
|
checked = verifier.driver(second, 'keycheck-fixture', 'second_keycheck', timeout=510)
|
|
require(checked['counts'].pop('http_requests', None) == 0, 'repeated_provider_made_http_requests')
|
|
require(checked == baseline, 'repeated_provider_changed_public_summary')
|
|
verifier.health(second, 'second_authenticated_health')
|
|
verifier.driver(second, 'assert-remote-recovery', 'remote_recovery')
|
|
verifier.driver(second, 'prepare-remote-transport', 'remote_transport')
|
|
dockerhub_canary_prepare = verifier.driver(
|
|
second, 'prepare-dockerhub-canary',
|
|
'dockerhub_canary_prepare', timeout=510,
|
|
)
|
|
require(
|
|
dockerhub_canary_prepare['counts'].get('search_pages') == 1
|
|
and dockerhub_canary_prepare['counts'].get('cohort_targets') == 4
|
|
and dockerhub_canary_prepare['counts'].get('digest_resolutions') == 4
|
|
and dockerhub_canary_prepare['counts'].get('worker_provider_failures') == 2
|
|
and dockerhub_canary_prepare['counts'].get('accepted_uploads') == 3
|
|
and dockerhub_canary_prepare['counts'].get('expired_assignments') == 1
|
|
and dockerhub_canary_prepare['counts'].get('drain_cycles') == 2
|
|
and dockerhub_canary_prepare['counts'].get('pending_targets') == 1,
|
|
'dockerhub_canary_prepare_evidence',
|
|
)
|
|
remote_full_prepare = verifier.driver(
|
|
second, 'prepare-remote-full-race', 'remote_full_prepare', timeout=510,
|
|
)
|
|
require(remote_full_prepare['counts'].get('real_claims') == 2
|
|
and remote_full_prepare['counts'].get('native_scans') == 3
|
|
and remote_full_prepare['counts'].get('exact_expiries') == 1
|
|
and remote_full_prepare['counts'].get('pending_restart') == 1,
|
|
'remote_full_prepare_evidence')
|
|
verifier.stop(second, 'second_stop')
|
|
third = verifier.start('third_start', previous=second)
|
|
remote_full_finish = verifier.driver(
|
|
third, 'finish-remote-full-race', 'remote_full_finish', timeout=510,
|
|
)
|
|
require(remote_full_finish['counts'].get('concurrent_uploads') == 2
|
|
and remote_full_finish['counts'].get('authoritative_receipts') == 1
|
|
and remote_full_finish['counts'].get('authoritative_scans') == 1
|
|
and remote_full_finish['counts'].get('expired_losers') == 1
|
|
and remote_full_finish['counts'].get('completed_winners') == 1
|
|
and remote_full_finish['counts'].get('cached_keychecks') == 1
|
|
and remote_full_finish['counts'].get('provider_http_requests') == 0
|
|
and remote_full_finish['counts'].get('projection_streams') == 3,
|
|
'remote_full_finish_evidence')
|
|
verifier.driver(
|
|
third, 'assert-remote-transport-replay', 'remote_transport_replay',
|
|
)
|
|
dockerhub_canary_finish = verifier.driver(
|
|
third, 'finish-dockerhub-canary',
|
|
'dockerhub_canary_finish', timeout=510,
|
|
)
|
|
require(
|
|
dockerhub_canary_finish['counts'].get('runtime_restarts') == 1
|
|
and dockerhub_canary_finish['counts'].get('lost_claim_receipts') == 1
|
|
and dockerhub_canary_finish['counts'].get('receipt_replays') == 1
|
|
and dockerhub_canary_finish['counts'].get('conflicts_rejected') == 1
|
|
and dockerhub_canary_finish['counts'].get('exactly_once') == 1
|
|
and dockerhub_canary_finish['counts'].get('former_expiry_replays') == 1,
|
|
'dockerhub_canary_finish_evidence',
|
|
)
|
|
verifier.health(third, 'third_authenticated_health')
|
|
verifier.stop(third, 'third_stop')
|
|
verifier.report['status']['checks'] = 'passed'
|
|
# Persist the check results before deleting their Docker artifacts.
|
|
verifier.write_evidence()
|
|
verifier.cleanup(args.keep)
|
|
verifier.report['status']['result'] = 'passed'
|
|
except (Exception, KeyboardInterrupt) as exc:
|
|
verifier.report['status']['result'] = 'failed'
|
|
label = str(exc) if isinstance(exc, Failure) else (
|
|
'interrupted' if isinstance(exc, KeyboardInterrupt) else 'verifier_exception'
|
|
)
|
|
failure_class = type(exc).__name__
|
|
if not re.fullmatch(r'[a-z0-9_]{1,160}', label):
|
|
label = 'verifier_failure'
|
|
if not re.fullmatch(r'[A-Za-z][A-Za-z0-9_]{0,79}', failure_class):
|
|
failure_class = 'Exception'
|
|
exit_code = getattr(exc, 'exit_code', None)
|
|
if type(exit_code) is not int or not -(2 ** 31) <= exit_code < 2 ** 31:
|
|
exit_code = None
|
|
verifier.report['failure'] = {
|
|
'stage': label, 'class': failure_class,
|
|
'exit_code': exit_code,
|
|
}
|
|
print('E2E failed: ' + label, flush=True)
|
|
if verifier.mutated:
|
|
verifier.failure_stop()
|
|
finally:
|
|
signal.signal(signal.SIGTERM, previous)
|
|
if verifier.evidence_ready:
|
|
try:
|
|
verifier.write_evidence()
|
|
except (Exception, KeyboardInterrupt):
|
|
verifier.report['status']['result'] = 'failed'
|
|
print('E2E failed: evidence_write_failed', flush=True)
|
|
else:
|
|
print('Evidence: docker/test-results/latest.json', flush=True)
|
|
print('E2E ' + verifier.report['status']['result'] + '; project ' + verifier.project
|
|
+ '; artifacts ' + verifier.report['status']['cleanup'], flush=True)
|
|
return 0 if verifier.report['status']['result'] == 'passed' else 1
|
|
|
|
|
|
if __name__ == '__main__':
|
|
try:
|
|
raise SystemExit(main())
|
|
except (Exception, KeyboardInterrupt) as exc:
|
|
print('E2E failed: ' + (str(exc) if isinstance(exc, Failure) else 'verifier_exception'), flush=True)
|
|
raise SystemExit(1) from None
|