Files
2026-09-30 20:30:56 +03:00

1244 lines
50 KiB
Python

import argparse
import json
import os
import socket
import ssl
import subprocess
import sys
import tempfile
import threading
import time
import yaml
from datetime import datetime, timezone
from types import SimpleNamespace
from urllib.parse import urlsplit
sys.dont_write_bytecode = True
if not sys.dont_write_bytecode:
raise RuntimeError('worker CLI could not disable bytecode writes')
from remote_worker_client import WorkerHTTPClient, default_worker_paths
from runtime_security import (
atomic_write_private_json,
canonical_path,
durable_unlink,
ensure_private_directory,
private_file_ready,
read_private_json,
)
from worker_contracts import WORKER_EVENT_SCHEMA
from worker_local_state import (
DEFAULT_LOG_BYTES,
DEFAULT_LOG_FILES,
DEFAULT_RETENTION_BYTES,
DEFAULT_RETENTION_DAYS,
RETENTION_SCHEMA,
WorkerLocalState,
WorkerLocalStateError,
)
from worker_package import verify_worker_package, worker_package_manifest_sha256
from worker_supervisor import (
WorkerAlreadyRunning,
WorkerSupervisor,
WorkerSupervisorError,
CONTROL_SCHEMA,
PROJECTION_SCHEMA,
capture_spawned_process_identity,
classify_instance,
detached_command,
load_shutdown_receipt,
send_control_request,
spawn_detached,
terminate_spawned_process,
wait_for_startup,
)
from worker_assignment_runner import run_assignment
CLI_SCHEMA = 1
CONFIG_SCHEMA = 2
LEGACY_CONFIG_SCHEMA = 1
LAUNCH_SCHEMA = 1
MAX_INSTALL_CONFIG_BYTES = 64 * 1024
EXIT_SUCCESS = 0
EXIT_NOT_RUNNING = 3
EXIT_STALE_OR_UNVERIFIABLE = 4
EXIT_STARTUP_FAILED = 6
EXIT_STOP_INCOMPLETE = 7
EXIT_INVALID_INVOCATION = 64
class InvocationError(ValueError):
pass
class WorkerArgumentParser(argparse.ArgumentParser):
def error(self, message):
raise InvocationError(message)
def _canonical(value):
return json.dumps(
value, ensure_ascii=True, sort_keys=True, separators=(',', ':'),
allow_nan=False,
)
def _print_json(value):
print(_canonical(value), flush=True)
def _config_path(paths):
return os.path.join(paths['state_dir'], 'control', 'worker.config.json')
def _validate_config(value):
legacy_fields = {
'schema', 'server', 'token', 'parallelism', 'installed_at',
}
current_fields = {
'schema', 'server', 'token', 'parallelism', 'installed_at',
'retention_days', 'retention_bytes', 'log_bytes', 'log_files',
}
if not isinstance(value, dict):
raise InvocationError('installed worker configuration is invalid')
if value.get('schema') == LEGACY_CONFIG_SCHEMA:
if set(value) == legacy_fields:
value = {
**value,
'schema': CONFIG_SCHEMA,
'retention_days': DEFAULT_RETENTION_DAYS,
'retention_bytes': DEFAULT_RETENTION_BYTES,
'log_bytes': DEFAULT_LOG_BYTES,
'log_files': DEFAULT_LOG_FILES,
}
elif set(value) == current_fields:
value = {**value, 'schema': CONFIG_SCHEMA}
else:
raise InvocationError('installed worker configuration is invalid')
elif value.get('schema') == CONFIG_SCHEMA and set(value) == current_fields:
value = dict(value)
else:
raise InvocationError('installed worker configuration is invalid')
if type(value.get('parallelism')) is not int or not 1 <= value['parallelism'] <= 128:
raise InvocationError('installed worker parallelism is invalid')
WorkerHTTPClient(value.get('server'), value.get('token'))
if not isinstance(value.get('installed_at'), str):
raise InvocationError('installed worker timestamp is invalid')
if type(value.get('retention_days')) is not int or not 1 <= value['retention_days'] <= 3650:
raise InvocationError('installed worker retention days are invalid')
if type(value.get('retention_bytes')) is not int or not 16 * 1024 * 1024 <= value['retention_bytes'] <= 1024 ** 4:
raise InvocationError('installed worker retention bytes are invalid')
if type(value.get('log_bytes')) is not int or not 64 * 1024 <= value['log_bytes'] <= 1024 ** 3:
raise InvocationError('installed worker log bytes are invalid')
if type(value.get('log_files')) is not int or not 1 <= value['log_files'] <= 20:
raise InvocationError('installed worker log file count is invalid')
return dict(value)
def _load_config(paths):
try:
return _validate_config(read_private_json(_config_path(paths)))
except OSError as exc:
raise InvocationError('worker is not installed; run truf-worker install') from exc
def _load_install_config(path):
if path == '-':
payload = sys.stdin.buffer.read(MAX_INSTALL_CONFIG_BYTES + 1)
else:
path = os.path.abspath(os.fspath(path))
if not private_file_ready(path):
raise InvocationError('worker install configuration must be a private regular file')
with open(path, 'rb') as handle:
payload = handle.read(MAX_INSTALL_CONFIG_BYTES + 1)
if len(payload) > MAX_INSTALL_CONFIG_BYTES:
raise InvocationError('worker install configuration exceeds its byte bound')
try:
class UniqueKeyLoader(yaml.SafeLoader):
def compose_node(self, parent, index):
if self.check_event(yaml.AliasEvent):
event = self.get_event()
raise yaml.composer.ComposerError(
None, None, 'YAML aliases are not supported', event.start_mark,
)
return super().compose_node(parent, index)
def construct_mapping(loader, node, deep=False):
loader.flatten_mapping(node)
value = {}
for key_node, value_node in node.value:
key = loader.construct_object(key_node, deep=deep)
if key in value:
raise yaml.constructor.ConstructorError(
'while constructing a mapping', node.start_mark,
f'duplicate key: {key}', key_node.start_mark,
)
value[key] = loader.construct_object(value_node, deep=deep)
return value
UniqueKeyLoader.add_constructor(
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, construct_mapping,
)
value = yaml.load(payload.decode('utf-8', errors='strict'), Loader=UniqueKeyLoader)
except (UnicodeDecodeError, yaml.YAMLError, TypeError, ValueError) as exc:
raise InvocationError('worker install configuration is invalid YAML') from exc
if not isinstance(value, dict) or set(value) != {'server', 'token', 'parallelism'}:
raise InvocationError(
'worker install configuration must contain only server, token, and parallelism'
)
return value
def _configuration(args, paths, *, require_explicit=False):
server = getattr(args, 'server', None)
token = getattr(args, 'token', None)
parallelism = getattr(args, 'parallelism', None)
config_path = getattr(args, 'config', None)
if config_path and any(value is not None for value in (server, token, parallelism)):
raise InvocationError('--config cannot be combined with --server, --token, or --parallelism')
if config_path:
document = _load_install_config(config_path)
server = document['server']
token = document['token']
parallelism = document['parallelism']
if bool(server) != bool(token):
raise InvocationError('--server and --token must be supplied together')
if server:
value = {
'schema': CONFIG_SCHEMA,
'server': server,
'token': token,
'parallelism': 1 if parallelism is None else parallelism,
'installed_at': datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z'),
'retention_days': int(getattr(args, 'retention_days', DEFAULT_RETENTION_DAYS)),
'retention_bytes': int(getattr(args, 'retention_bytes', DEFAULT_RETENTION_BYTES)),
'log_bytes': int(getattr(args, 'log_bytes', DEFAULT_LOG_BYTES)),
'log_files': int(getattr(args, 'log_files', DEFAULT_LOG_FILES)),
}
return _validate_config(value)
if require_explicit:
raise InvocationError('--server and --token are required')
value = _load_config(paths)
if parallelism is not None:
value['parallelism'] = parallelism
return _validate_config(value)
def _client_args(config, paths):
return SimpleNamespace(
package_manifest=paths['package_manifest'],
state_dir=paths['state_dir'],
bundle_dir=paths['bundle_dir'],
work_dir=paths['work_dir'],
server=config['server'],
token=config['token'],
parallelism=config['parallelism'],
poll_seconds=5.0,
error_delay_seconds=15.0,
http_timeout=120,
retention_days=config['retention_days'],
retention_bytes=config['retention_bytes'],
log_bytes=config['log_bytes'],
log_files=config['log_files'],
)
def _add_configuration(parser, *, required=False, allow_config=False):
parser.add_argument('--server', required=required)
parser.add_argument('--token', required=required)
parser.add_argument('--parallelism', type=int)
if allow_config:
parser.add_argument('--config')
def build_parser():
parser = WorkerArgumentParser(prog='truf-worker', description='TRUF remote worker operator CLI')
commands = parser.add_subparsers(dest='command', required=True)
install = commands.add_parser('install')
_add_configuration(install, allow_config=True)
install.add_argument('--retention-days', type=int, default=DEFAULT_RETENTION_DAYS)
install.add_argument('--retention-bytes', type=int, default=DEFAULT_RETENTION_BYTES)
install.add_argument('--log-bytes', type=int, default=DEFAULT_LOG_BYTES)
install.add_argument('--log-files', type=int, default=DEFAULT_LOG_FILES)
run = commands.add_parser('run')
_add_configuration(run)
start = commands.add_parser('start')
_add_configuration(start)
start.add_argument('--startup-timeout', type=float, default=30.0)
stop = commands.add_parser('stop')
stop.add_argument('--timeout', type=float, default=30.0)
stop.add_argument('--json', action='store_true')
status = commands.add_parser('status')
status.add_argument('--json', action='store_true')
attach = commands.add_parser('attach')
attach_output = attach.add_mutually_exclusive_group()
attach_output.add_argument('--json', action='store_true')
attach_output.add_argument('--ndjson', action='store_true')
attach.add_argument('--follow-seconds', type=float)
watch = commands.add_parser('watch')
watch.add_argument('--follow-seconds', type=float)
watch.set_defaults(json=False, ndjson=False)
logs = commands.add_parser('logs')
logs.add_argument('--tail', type=int, default=100)
logs.add_argument('--follow', action='store_true')
logs_output = logs.add_mutually_exclusive_group()
logs_output.add_argument('--json', action='store_true')
logs_output.add_argument('--ndjson', action='store_true')
logs.add_argument('--follow-seconds', type=float, default=60.0)
history = commands.add_parser('history')
history.add_argument('--limit', type=int, default=100)
history.add_argument('--reservation', type=int)
history_output = history.add_mutually_exclusive_group()
history_output.add_argument('--json', action='store_true')
history_output.add_argument('--ndjson', action='store_true')
doctor = commands.add_parser('doctor')
doctor.add_argument('--json', action='store_true')
child = commands.add_parser('_supervise', help=argparse.SUPPRESS)
child.add_argument('--launch-file', required=True)
child.add_argument('--startup-file', required=True)
child.add_argument('--launch-nonce', required=True)
runner = commands.add_parser('_assignment_runner', help=argparse.SUPPRESS)
runner.add_argument('--root', required=True)
return parser
def parse_args(argv=None):
values = list(sys.argv[1:] if argv is None else argv)
if values and values[0].startswith('-'):
values.insert(0, 'run')
args = build_parser().parse_args(values)
if getattr(args, 'parallelism', None) is not None and not 1 <= args.parallelism <= 128:
raise InvocationError('--parallelism must be between 1 and 128')
if getattr(args, 'startup_timeout', 1) is not None and not 0.1 <= getattr(args, 'startup_timeout', 1) <= 300:
raise InvocationError('--startup-timeout must be between 0.1 and 300 seconds')
if getattr(args, 'timeout', 1) is not None and not 0.1 <= getattr(args, 'timeout', 1) <= 3600:
raise InvocationError('--timeout must be between 0.1 and 3600 seconds')
if getattr(args, 'tail', 1) is not None and not 1 <= getattr(args, 'tail', 1) <= 10000:
raise InvocationError('--tail must be between 1 and 10000')
if getattr(args, 'limit', 1) is not None and not 1 <= getattr(args, 'limit', 1) <= 1000:
raise InvocationError('--limit must be between 1 and 1000')
follow = getattr(args, 'follow_seconds', None)
if follow is not None and not 0 <= follow <= 3600:
raise InvocationError('--follow-seconds must be between 0 and 3600')
if getattr(args, 'reservation', None) is not None and args.reservation <= 0:
raise InvocationError('--reservation must be positive')
return args
def _seconds(value, now=None):
if not value:
return None
try:
parsed = datetime.fromisoformat(str(value).replace('Z', '+00:00'))
except ValueError:
return None
return round(((now or datetime.now(timezone.utc)) - parsed).total_seconds(), 3)
def _local_layout_ready(state_dir):
return all(os.path.isdir(os.path.join(state_dir, name)) for name in (
'control', 'events', 'history', 'diagnostics', 'logs',
))
def _local_settings(paths):
try:
config = _load_config(paths)
except (InvocationError, ValueError):
config = {}
return {
'retention_days': config.get('retention_days', DEFAULT_RETENTION_DAYS),
'retention_bytes': config.get('retention_bytes', DEFAULT_RETENTION_BYTES),
'log_bytes': config.get('log_bytes', DEFAULT_LOG_BYTES),
'log_files': config.get('log_files', DEFAULT_LOG_FILES),
}
def _open_local_reader(paths):
return WorkerLocalState(
paths['state_dir'], read_only=True, **_local_settings(paths),
)
def _directory_usage(path):
byte_count = 0
file_count = 0
if os.path.isdir(path):
for current, directories, files in os.walk(path, followlinks=False):
directories[:] = [
name for name in directories
if not os.path.islink(os.path.join(current, name))
]
for name in files:
candidate = os.path.join(current, name)
try:
if os.path.isfile(candidate) and not os.path.islink(candidate):
byte_count += os.path.getsize(candidate)
file_count += 1
except OSError:
continue
return {'bytes': byte_count, 'files': file_count}
def _root_file_usage(path):
byte_count = 0
file_count = 0
if os.path.isdir(path):
with os.scandir(path) as entries:
for entry in entries:
try:
if entry.is_file(follow_symlinks=False):
byte_count += entry.stat(follow_symlinks=False).st_size
file_count += 1
except OSError:
continue
return {'bytes': byte_count, 'files': file_count}
def _fallback_retention(paths):
settings = _local_settings(paths)
def non_evictable(item):
return {
**item,
'evictable_bytes': 0,
'evictable_files': 0,
'non_evictable_bytes': item['bytes'],
'non_evictable_files': item['files'],
}
categories = {
'control': non_evictable(_directory_usage(
os.path.join(paths['state_dir'], 'control'),
)),
'state': non_evictable(_root_file_usage(paths['state_dir'])),
'events': non_evictable({'bytes': 0, 'files': 0}),
'history': non_evictable({'bytes': 0, 'files': 0}),
'diagnostics': non_evictable({'bytes': 0, 'files': 0}),
'logs': non_evictable({'bytes': 0, 'files': 0}),
'bundles': non_evictable(_directory_usage(paths['bundle_dir'])),
'work': non_evictable(_directory_usage(paths['work_dir'])),
}
total_bytes = sum(item['bytes'] for item in categories.values())
return {
'schema': RETENTION_SCHEMA,
'maximum_age_days': settings['retention_days'],
'maximum_bytes': settings['retention_bytes'],
'total_bytes': total_bytes,
'total_files': sum(item['files'] for item in categories.values()),
'evictable_bytes': 0,
'evictable_files': 0,
'non_evictable_bytes': total_bytes,
'non_evictable_files': sum(item['files'] for item in categories.values()),
'over_limit': total_bytes > settings['retention_bytes'],
'categories': categories,
}
def _slot_document(slot, now=None):
now = now or datetime.now(timezone.utc)
progress = dict(slot.get('progress') or {})
assignment_remaining = _seconds(slot.get('assignment_deadline_at'), now)
scan_remaining = _seconds(slot.get('scan_deadline_at'), now)
measured = {
key: value
for key, value in progress.items()
if type(value) in (int, float) and key not in {
'attempt', 'retry_after_seconds',
}
}
counters = progress.get('counters')
if isinstance(counters, dict):
measured.update({
key: value for key, value in counters.items()
if type(value) in (int, float)
})
return {
'slot_id': int(slot['slot_id']),
'sequence': int(slot.get('sequence') or 0),
'phase': str(slot['phase']),
'phase_age_seconds': _seconds(slot.get('phase_started_at'), now),
'reservation_id': slot.get('reservation_id'),
'source': slot.get('source'),
'attempt': progress.get('attempt'),
'scan_deadline_at': slot.get('scan_deadline_at'),
'scan_deadline_remaining_seconds': (
-scan_remaining if scan_remaining is not None else None
),
'assignment_deadline_at': slot.get('assignment_deadline_at'),
'assignment_remaining_seconds': (
-assignment_remaining if assignment_remaining is not None else None
),
'last_progress_at': slot.get('timestamp'),
'last_progress_age_seconds': _seconds(slot.get('timestamp'), now),
'progress': progress,
'measured_progress': measured,
'child_state': progress.get('child_state'),
'idle_reason': progress.get('reason') if slot.get('phase') in {'idle', 'backoff'} else None,
'next_claim_at': progress.get('next_claim_at'),
}
def status_document(paths, classification=None):
classification = classification or classify_instance(paths['state_dir'])
state = classification['state']
instance = classification.get('instance')
package = instance.get('package') if instance else None
runtime = instance.get('runtime') if instance else None
protocol = instance.get('protocol') if instance else None
if instance is None:
try:
verified = verify_worker_package(paths['package_manifest'])
manifest = verified['manifest']
package = {
'schema': manifest['schema'],
'manifest_sha256': worker_package_manifest_sha256(manifest),
'code_manifest_sha256': verified['code_manifest_sha256'],
'platform_tag': manifest['platform_tag'],
}
runtime = {
'python': '.'.join(str(item) for item in sys.version_info[:3]),
'platform': sys.platform,
'executable': canonical_path(sys.executable),
'mode': 'inactive',
}
protocol = {
'worker_protocol': manifest['protocol_version'],
'bundle_format': manifest['bundle_format_version'],
'event': WORKER_EVENT_SCHEMA,
'control': CONTROL_SCHEMA,
'projection': PROJECTION_SCHEMA,
}
except (OSError, ValueError):
pass
worker = None
slots = []
retention = None
if state in {'running', 'draining'}:
try:
snapshot = send_control_request(classification['record'], 'snapshot', {})
worker = snapshot['worker']
slots = [_slot_document(item) for item in snapshot['projection']['slots']]
retention = snapshot['retention']
except (OSError, ValueError, WorkerSupervisorError, WorkerLocalStateError) as exc:
state = 'unverifiable'
classification = dict(classification)
classification['state'] = state
classification['detail'] = f'live worker snapshot failed: {type(exc).__name__}'
if state not in {'running', 'draining'} and os.path.isdir(paths['state_dir']):
local = None
if _local_layout_ready(paths['state_dir']):
try:
local = _open_local_reader(paths)
except (OSError, ValueError, WorkerLocalStateError):
local = None
projection = (
local.snapshot()
if local is not None
else {'aggregate': {'slot_count': 0, 'phases': {}}, 'slots': []}
)
slots = [_slot_document(item) for item in projection['slots']]
retention = (
local.retention_usage({
'bundles': paths['bundle_dir'], 'work': paths['work_dir'],
}) if local is not None else _fallback_retention(paths)
)
try:
configured_parallelism = _load_config(paths)['parallelism']
except (InvocationError, ValueError):
configured_parallelism = None
worker = {
'state': state,
'parallelism': configured_parallelism,
'slot_cap': configured_parallelism,
'configured_slots': configured_parallelism,
'recovery_slots': None,
'started_at': None,
'drain_deadline_at': None,
'aggregate': projection['aggregate'],
}
return {
'schema': CLI_SCHEMA,
'command': 'status',
'state': state,
'detail': classification.get('detail'),
'instance': instance,
'package': package,
'runtime': runtime,
'protocol': protocol,
'worker': worker,
'slots': slots,
'retention': retention,
}
def _human_status(document):
print(f"Worker: {document['state']} ({document.get('detail') or 'no detail'})")
instance = document.get('instance')
if instance:
print(f"Instance: {instance['instance_id']} PID: {instance['pid']} Mode: {instance['runtime']['mode']}")
print(
f"Package: {instance['package']['manifest_sha256']} "
f"Protocol: {instance['protocol']['worker_protocol']}"
)
worker = document.get('worker')
if worker:
print(
f"Slots: {worker['aggregate']['slot_count']} Cap: {worker['slot_cap']} "
f"State: {worker['state']}"
)
for slot in document.get('slots') or []:
reason = slot['idle_reason'] or ''
visibility = slot['progress'].get('execution_visibility') or ''
counters = _canonical(slot['measured_progress']) if slot['measured_progress'] else '{}'
print(
f"slot {slot['slot_id']}: {slot['phase']} age={slot['phase_age_seconds']}s "
f"source={slot['source'] or '-'} reservation={slot['reservation_id'] or '-'} "
f"attempt={slot['attempt'] or '-'} "
f"scan_deadline={slot['scan_deadline_at'] or '-'} "
f"scan_remaining={slot['scan_deadline_remaining_seconds']}s "
f"assignment_deadline={slot['assignment_deadline_at'] or '-'} "
f"assignment_remaining={slot['assignment_remaining_seconds']}s "
f"progress_age={slot['last_progress_age_seconds']}s "
f"next_claim_at={slot['next_claim_at'] or '-'} "
f"child_state={slot['child_state'] or '-'} counters={counters} "
f"{reason} {visibility}"
)
retention = document.get('retention')
if retention:
print(f"Local retention: {retention['total_bytes']} bytes in {retention['total_files']} files")
def command_install(args, paths):
config = _configuration(args, paths, require_explicit=True)
try:
verify_worker_package(paths['package_manifest'])
except Exception as exc:
print(f'Worker installation failed: {type(exc).__name__}', file=sys.stderr)
return EXIT_STARTUP_FAILED
ensure_private_directory(paths['state_dir'], reject_reparse=True)
ensure_private_directory(os.path.join(paths['state_dir'], 'control'), reject_reparse=True)
ensure_private_directory(paths['bundle_dir'], reject_reparse=True)
ensure_private_directory(paths['work_dir'], reject_reparse=True)
atomic_write_private_json(_config_path(paths), config)
print(f"Installed worker configuration at {_config_path(paths)}")
return EXIT_SUCCESS
def command_run(args, paths):
config = _configuration(args, paths)
try:
supervisor = WorkerSupervisor(_client_args(config, paths), foreground=True)
return supervisor.run()
except WorkerAlreadyRunning as exc:
print(str(exc), file=sys.stderr)
return EXIT_STARTUP_FAILED
except Exception as exc:
print(f'Worker startup failed: {type(exc).__name__}', file=sys.stderr)
return EXIT_STARTUP_FAILED
def _launch_paths(paths, nonce):
control = ensure_private_directory(os.path.join(paths['state_dir'], 'control'), reject_reparse=True)
return (
os.path.join(control, f'worker.launch.{nonce}.json'),
os.path.join(control, f'worker.startup.{nonce}.json'),
)
def command_start(args, paths):
current = classify_instance(paths['state_dir'], remove_stale=True)
if current['state'] == 'running':
document = status_document(paths, current)
_human_status(document)
return EXIT_SUCCESS
if current['state'] in {'unverifiable', 'starting', 'draining'} or (
current['state'] == 'stale' and not current.get('removed')
):
_human_status(status_document(paths, current))
return EXIT_STALE_OR_UNVERIFIABLE
config = _configuration(args, paths)
nonce = os.urandom(16).hex()
launch_file, startup_file = _launch_paths(paths, nonce)
launch = {
'schema': LAUNCH_SCHEMA,
'nonce': nonce,
'server': config['server'],
'token': config['token'],
'parallelism': config['parallelism'],
'retention_days': config['retention_days'],
'retention_bytes': config['retention_bytes'],
'log_bytes': config['log_bytes'],
'log_files': config['log_files'],
'package_manifest': paths['package_manifest'],
'state_dir': paths['state_dir'],
'bundle_dir': paths['bundle_dir'],
'work_dir': paths['work_dir'],
}
atomic_write_private_json(launch_file, launch)
bootstrap = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'remote_worker_bootstrap.py')
process = None
spawned_identity = None
started = False
try:
process = spawn_detached(detached_command(bootstrap, launch_file, startup_file, nonce))
spawned_identity = capture_spawned_process_identity(process)
result = wait_for_startup(startup_file, nonce, process, timeout=args.startup_timeout)
if result['outcome'] != 'ready':
print(f"Worker startup failed: {result['outcome']}", file=sys.stderr)
return EXIT_STARTUP_FAILED
verified = classify_instance(paths['state_dir'])
if (
verified['state'] != 'running'
or verified['instance']['instance_id'] != result['instance_id']
or result['pid'] != spawned_identity['pid']
or verified['instance']['pid'] != spawned_identity['pid']
or verified['instance']['process_creation_time'] != spawned_identity['creation_time']
or canonical_path(verified['instance']['executable']) != canonical_path(
spawned_identity['executable']
)
):
print('Worker startup identity could not be verified', file=sys.stderr)
return EXIT_STARTUP_FAILED
started = True
_human_status(status_document(paths, verified))
return EXIT_SUCCESS
except (OSError, ValueError, WorkerSupervisorError) as exc:
print(f'Worker startup failed: {exc}', file=sys.stderr)
return EXIT_STARTUP_FAILED
finally:
try:
if process is not None and not started:
if spawned_identity is not None:
terminate_spawned_process(process, spawned_identity)
elif process.poll() is None:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
classify_instance(paths['state_dir'], remove_stale=True)
finally:
for path in (launch_file, startup_file):
try:
if os.path.exists(path):
durable_unlink(path)
except OSError:
pass
def command_stop(args, paths):
classification = classify_instance(paths['state_dir'])
if classification['state'] == 'stopped':
value = {'schema': CLI_SCHEMA, 'command': 'stop', 'state': 'stopped', 'receipt': None}
_print_json(value) if args.json else print('Worker is not running')
return EXIT_NOT_RUNNING
if classification['state'] not in {'running', 'draining'}:
value = {
'schema': CLI_SCHEMA, 'command': 'stop',
'state': classification['state'], 'receipt': None,
}
_print_json(value) if args.json else print(f"Worker is {classification['state']}")
return EXIT_STALE_OR_UNVERIFIABLE
record = classification['record']
try:
result = send_control_request(
record, 'stop', {'timeout_seconds': args.timeout}, timeout=min(args.timeout, 10),
)
except (OSError, TimeoutError, WorkerSupervisorError):
final = status_document(paths, classify_instance(paths['state_dir']))
value = {
'schema': CLI_SCHEMA, 'command': 'stop',
'state': 'control_disconnected', 'receipt': None,
'final_status': final,
}
if args.json:
_print_json(value)
else:
print('Worker control disconnected; final state:')
_human_status(final)
return (
EXIT_NOT_RUNNING if final['state'] == 'stopped'
else EXIT_STALE_OR_UNVERIFIABLE
)
deadline = time.monotonic() + args.timeout + 5.0
receipt = None
while time.monotonic() < deadline:
try:
receipt = load_shutdown_receipt(paths['state_dir'], record['instance_id'])
break
except (OSError, WorkerSupervisorError):
time.sleep(0.1)
clean = bool(
receipt is not None
and receipt.get('drained') is True
and receipt.get('exit_code') == 0
)
state = 'stopped' if clean else (
'non_drained' if receipt is not None else 'stop_timeout'
)
value = {
'schema': CLI_SCHEMA, 'command': 'stop', 'state': state,
'accepted': result['accepted'], 'receipt': receipt,
}
_print_json(value) if args.json else print(
'Worker stopped cleanly' if clean else (
'Worker exited without a clean drain receipt'
if receipt is not None else 'Worker stop timed out without a shutdown receipt'
)
)
return EXIT_SUCCESS if clean else EXIT_STOP_INCOMPLETE
def command_status(args, paths):
document = status_document(paths)
_print_json(document) if args.json else _human_status(document)
if document['state'] in {'running', 'starting', 'draining'}:
return EXIT_SUCCESS
if document['state'] == 'stopped':
return EXIT_NOT_RUNNING
return EXIT_STALE_OR_UNVERIFIABLE
def _follow_events(record, sequence, seconds, emit):
deadline = None if seconds is None else time.monotonic() + seconds
while deadline is None or time.monotonic() < deadline:
response = send_control_request(
record, 'events', {'after_sequence': sequence, 'limit': 256},
)
for event in response['events']:
sequence = max(sequence, int(event['sequence']))
emit(event)
time.sleep(0.2)
return sequence
def command_attach(args, paths):
command_name = getattr(args, 'command', 'attach')
classification = classify_instance(paths['state_dir'])
if classification['state'] not in {'running', 'draining'}:
document = status_document(paths, classification)
if args.json:
_print_json({'schema': CLI_SCHEMA, 'command': command_name, 'status': document})
elif args.ndjson:
_print_json({'schema': CLI_SCHEMA, 'type': 'snapshot', 'status': document})
else:
_human_status(document)
return (
EXIT_NOT_RUNNING if classification['state'] == 'stopped'
else EXIT_STALE_OR_UNVERIFIABLE
)
record = classification['record']
snapshot = status_document(paths, classification)
if args.json:
_print_json({
'schema': CLI_SCHEMA,
'command': command_name,
'status': snapshot,
})
return EXIT_SUCCESS
if args.ndjson:
_print_json({'schema': CLI_SCHEMA, 'type': 'snapshot', 'status': snapshot})
else:
_human_status(snapshot)
action = 'Watching' if command_name == 'watch' else 'Attached'
print(f'{action}; q, EOF, or Ctrl-C detaches without stopping the worker.')
stop = threading.Event()
if not args.ndjson:
def read_input():
try:
while not stop.is_set():
value = sys.stdin.read(1)
if value == '' or value.lower() == 'q':
stop.set()
return
except (EOFError, OSError):
stop.set()
threading.Thread(target=read_input, daemon=True).start()
sequence = max((slot.get('sequence', 0) for slot in snapshot.get('slots', [])), default=0)
deadline = None if args.follow_seconds is None else time.monotonic() + args.follow_seconds
next_refresh = time.monotonic() + 1.0
disconnected = False
try:
while not stop.is_set() and (deadline is None or time.monotonic() < deadline):
response = send_control_request(
record, 'events', {'after_sequence': sequence, 'limit': 256},
)
for event in response['events']:
sequence = max(sequence, int(event['sequence']))
if args.ndjson:
_print_json(event)
now = time.monotonic()
if not args.ndjson and (response['events'] or now >= next_refresh):
refreshed = status_document(paths, classification)
print('---')
_human_status(refreshed)
next_refresh = now + 1.0
stop.wait(0.2)
except (OSError, TimeoutError, WorkerSupervisorError):
disconnected = True
final = status_document(paths, classify_instance(paths['state_dir']))
if args.ndjson:
_print_json({'schema': CLI_SCHEMA, 'type': 'snapshot', 'status': final})
else:
print('Worker control disconnected; final state:')
_human_status(final)
except (KeyboardInterrupt, EOFError):
pass
finally:
stop.set()
if disconnected:
return (
EXIT_NOT_RUNNING if final['state'] == 'stopped'
else EXIT_STALE_OR_UNVERIFIABLE
)
return EXIT_SUCCESS
def command_logs(args, paths):
if not os.path.isdir(paths['state_dir']):
value = {'schema': CLI_SCHEMA, 'command': 'logs', 'lines': []}
if args.follow and (args.json or args.ndjson):
print('Worker logs follow unavailable: worker state is not initialized', file=sys.stderr)
elif args.json:
_print_json(value)
elif not args.ndjson:
print('No worker logs')
return EXIT_NOT_RUNNING
layout_ready = _local_layout_ready(paths['state_dir'])
local = _open_local_reader(paths) if layout_ready else None
lines = local.log_tail(args.tail) if local is not None else []
machine_follow = args.follow and (args.json or args.ndjson)
if machine_follow:
sequence = max(0, (local.snapshot()['sequence'] if local else 0) - args.tail)
events = local.events_after(sequence, args.tail) if local else []
for event in events:
_print_json(event)
if events:
sequence = events[-1]['sequence']
elif args.ndjson:
for index, line in enumerate(lines, 1):
_print_json({'schema': CLI_SCHEMA, 'type': 'log', 'index': index, 'line': line})
elif args.json and not args.follow:
_print_json({'schema': CLI_SCHEMA, 'command': 'logs', 'lines': lines})
else:
for line in lines:
print(line)
if not args.follow:
return EXIT_SUCCESS
classification = classify_instance(paths['state_dir'])
if classification['state'] not in {'running', 'draining'}:
if machine_follow:
print(
f"Worker logs follow ended: {classification['state']}",
file=sys.stderr,
)
return EXIT_NOT_RUNNING
if local is None:
local = _open_local_reader(paths)
if not machine_follow:
sequence = local.snapshot()['sequence']
try:
_follow_events(
classification['record'], sequence, args.follow_seconds,
_print_json if machine_follow else lambda event: print(
f"#{event['sequence']} slot {event['slot_id']} {event['phase']}"
),
)
except (OSError, TimeoutError, WorkerSupervisorError):
final = status_document(paths, classify_instance(paths['state_dir']))
if machine_follow:
print(
f"Worker logs follow control disconnected: {final['state']}",
file=sys.stderr,
)
else:
print('Worker control disconnected; final state:')
_human_status(final)
return (
EXIT_NOT_RUNNING if final['state'] == 'stopped'
else EXIT_STALE_OR_UNVERIFIABLE
)
except KeyboardInterrupt:
pass
return EXIT_SUCCESS
def command_history(args, paths):
if not _local_layout_ready(paths['state_dir']):
values = []
else:
values = _open_local_reader(paths).history(args.limit, args.reservation)
if args.ndjson:
for value in values:
_print_json(value)
elif args.json:
_print_json({'schema': CLI_SCHEMA, 'command': 'history', 'assignments': values})
elif not values:
print('No terminal assignment history')
else:
for value in values:
print(
f"reservation {value['reservation_id']}: {value['outcome']} "
f"source={value.get('source') or '-'} slot={value['slot_id']} "
f"completed={value['completed_at']} duration={value.get('duration_seconds')}s"
)
timeline = ' -> '.join(
f"{event['phase']}#{event['sequence']}"
for event in value.get('timeline') or []
)
if timeline:
print(f' timeline {timeline}')
for diagnostic in value.get('diagnostics') or []:
print(
f" diagnostic {diagnostic.get('diagnostic_uid')}: "
f"{diagnostic.get('record')} available={diagnostic.get('available')}"
)
return EXIT_SUCCESS
def _doctor_check(name, applicable, status, summary):
return {
'name': name,
'applicable': bool(applicable),
'status': str(status),
'summary': str(summary),
}
def command_doctor(args, paths):
checks = []
package = None
installed = os.path.isfile(paths['package_manifest'])
checks.append(_doctor_check(
'installation', True, 'ok' if installed else 'error',
'worker package manifest is installed' if installed else 'worker package manifest is absent',
))
try:
package = verify_worker_package(paths['package_manifest'])
checks.append(_doctor_check('package_identity', True, 'ok', 'package identity verified'))
except Exception as exc:
checks.append(_doctor_check('package_identity', True, 'error', f'package verification failed: {type(exc).__name__}'))
config = None
try:
config = _load_config(paths)
checks.append(_doctor_check('configuration', True, 'ok', 'installed configuration is valid'))
checks.append(_doctor_check('credentials', True, 'ok', 'worker credential shape is valid'))
except (InvocationError, ValueError) as exc:
checks.append(_doctor_check('configuration', True, 'error', str(exc)))
checks.append(_doctor_check('credentials', False, 'not_applicable', 'configuration is unavailable'))
path_values = (paths['state_dir'], paths['bundle_dir'], paths['work_dir'])
missing_paths = [path for path in path_values if not os.path.isdir(path)]
if missing_paths:
checks.append(_doctor_check(
'paths', False, 'not_applicable',
'runtime paths are not initialized; run install first',
))
else:
probe_error = None
for path in path_values:
descriptor = None
temporary = None
try:
descriptor, temporary = tempfile.mkstemp(prefix='.doctor-', dir=path)
except OSError as exc:
probe_error = exc
break
finally:
if descriptor is not None:
os.close(descriptor)
if temporary is not None:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
except OSError as exc:
probe_error = probe_error or exc
if probe_error is None:
checks.append(_doctor_check('paths', True, 'ok', 'initialized state and data paths are writable'))
else:
checks.append(_doctor_check(
'paths', True, 'error',
f'path validation failed: {type(probe_error).__name__}',
))
for name, key in (('git', 'git_path'), ('trufflehog', 'trufflehog_path')):
if package is None:
checks.append(_doctor_check(name, False, 'not_applicable', 'verified package is unavailable'))
continue
try:
completed = subprocess.run(
[package[key], '--version'], stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=10, check=False,
)
status = 'ok' if completed.returncode == 0 else 'error'
checks.append(_doctor_check(name, True, status, f'{name} executable returned {completed.returncode}'))
except (OSError, subprocess.TimeoutExpired) as exc:
checks.append(_doctor_check(name, True, 'error', f'{name} validation failed: {type(exc).__name__}'))
if config is None:
checks.append(_doctor_check('server_reachability', False, 'not_applicable', 'configuration is unavailable'))
else:
parsed = urlsplit(config['server'])
try:
context = ssl.create_default_context()
with socket.create_connection((parsed.hostname, parsed.port or 443), timeout=3) as raw:
with context.wrap_socket(raw, server_hostname=parsed.hostname):
pass
checks.append(_doctor_check('server_reachability', True, 'ok', 'server TLS endpoint is reachable'))
except OSError as exc:
checks.append(_doctor_check('server_reachability', True, 'error', f'server is unreachable: {type(exc).__name__}'))
classification = classify_instance(paths['state_dir'])
status = (
'ok'
if classification['state'] in {'running', 'starting', 'draining', 'stopped'}
else 'error'
)
checks.append(_doctor_check('singleton_process', True, status, classification['detail']))
if _local_layout_ready(paths['state_dir']):
try:
local = _open_local_reader(paths)
retention = local.retention_usage({
'bundles': paths['bundle_dir'], 'work': paths['work_dir'],
})
except (OSError, ValueError, WorkerLocalStateError):
retention = _fallback_retention(paths)
checks.append(_doctor_check(
'local_state', True, 'error', 'local state is unreadable',
))
else:
retention = _fallback_retention(paths)
checks.append(_doctor_check('retention', True, 'ok', f"{retention['total_bytes']} bytes retained"))
overall = 'ok' if all(item['status'] in {'ok', 'not_applicable'} for item in checks) else 'error'
document = {
'schema': CLI_SCHEMA,
'command': 'doctor',
'overall': overall,
'checks': checks,
'retention': retention,
}
if args.json:
_print_json(document)
else:
print(f'Doctor: {overall}')
for check in checks:
print(f"[{check['status']}] {check['name']}: {check['summary']}")
return EXIT_SUCCESS if overall == 'ok' else EXIT_STARTUP_FAILED
def _load_launch(path, nonce):
value = read_private_json(path)
if not isinstance(value, dict) or set(value) != {
'schema', 'nonce', 'server', 'token', 'parallelism', 'package_manifest',
'state_dir', 'bundle_dir', 'work_dir', 'retention_days',
'retention_bytes', 'log_bytes', 'log_files',
} or value.get('schema') != LAUNCH_SCHEMA or value.get('nonce') != nonce:
raise InvocationError('detached launch record is invalid')
_validate_config({
'schema': CONFIG_SCHEMA,
'server': value['server'], 'token': value['token'],
'parallelism': value['parallelism'], 'installed_at': '',
'retention_days': value['retention_days'],
'retention_bytes': value['retention_bytes'],
'log_bytes': value['log_bytes'],
'log_files': value['log_files'],
})
return value
def command_supervise(args):
try:
value = _load_launch(args.launch_file, args.launch_nonce)
durable_unlink(args.launch_file)
client = SimpleNamespace(
package_manifest=value['package_manifest'],
state_dir=value['state_dir'], bundle_dir=value['bundle_dir'],
work_dir=value['work_dir'], server=value['server'], token=value['token'],
parallelism=value['parallelism'], poll_seconds=5.0,
error_delay_seconds=15.0, http_timeout=120,
retention_days=value['retention_days'],
retention_bytes=value['retention_bytes'],
log_bytes=value['log_bytes'], log_files=value['log_files'],
)
return WorkerSupervisor(client, foreground=False).run(
startup_file=args.startup_file, launch_nonce=args.launch_nonce,
)
except WorkerAlreadyRunning:
return EXIT_STARTUP_FAILED
except BaseException as exc:
from worker_supervisor import write_startup_result
try:
write_startup_result(
args.startup_file, args.launch_nonce, 'failed', error=type(exc).__name__,
)
except OSError:
pass
return EXIT_STARTUP_FAILED
def command_assignment_runner(args, paths):
work_root = canonical_path(ensure_private_directory(
os.path.abspath(paths['work_dir']), reject_reparse=True,
))
root = canonical_path(os.path.abspath(args.root))
if os.path.dirname(root) != work_root or not os.path.basename(root).startswith(
'worker-assignment-'
):
raise InvocationError('assignment runner root is outside worker work storage')
package = verify_worker_package(paths['package_manifest'])
manifest = package.pop('manifest')
build_compatibility = package.pop('build_compatibility')
package.pop('runtime_trees')
runtime = {
**package,
'build_compatibility': build_compatibility,
'capabilities': tuple(
(item['source'], item['platform'], item['planning_kind'])
for item in manifest['capabilities']
),
}
return run_assignment(root, runtime)
def main(argv=None):
try:
args = parse_args(argv)
if args.command == '_supervise':
return command_supervise(args)
paths = default_worker_paths(module_path=__file__)
if args.command == '_assignment_runner':
return command_assignment_runner(args, paths)
commands = {
'install': command_install,
'run': command_run,
'start': command_start,
'stop': command_stop,
'status': command_status,
'attach': command_attach,
'watch': command_attach,
'logs': command_logs,
'history': command_history,
'doctor': command_doctor,
}
return commands[args.command](args, paths)
except (InvocationError, ValueError) as exc:
print(f'truf-worker: {exc}', file=sys.stderr)
return EXIT_INVALID_INVOCATION
except (WorkerSupervisorError, WorkerLocalStateError) as exc:
print(f'truf-worker: {exc}', file=sys.stderr)
return EXIT_STALE_OR_UNVERIFIABLE
except KeyboardInterrupt:
return 130
if __name__ == '__main__':
raise SystemExit(main())