Files
truf-server/tests/test_worker_assignment_runner.py
T
2026-09-30 20:30:56 +03:00

1775 lines
79 KiB
Python

import os
import copy
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from contextlib import nullcontext
from datetime import datetime, timedelta, timezone
from unittest import mock
APP_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'app'))
if APP_DIR not in sys.path:
sys.path.insert(0, APP_DIR)
import worker_assignment_runner as runner
import remote_worker_client
import scan_execution
from remote_worker_client import WorkerClientError, WorkerSlot
from result_bundle import (
BundleReservation, ResultBundleReader, ResultBundleWriter, bundle_ready_path,
)
from runtime_security import atomic_write_private_json, ensure_private_directory
from scanner import StagedResult
from test_worker_api import assignment, build_compatibility
def live_assignment():
value = assignment()
issued = datetime.now(timezone.utc).replace(microsecond=0)
deadline = issued + timedelta(days=1)
issued_text = issued.isoformat(timespec='seconds')
deadline_text = deadline.isoformat(timespec='seconds')
value['reservation']['remote_issued_at'] = issued_text
value['reservation']['remote_expires_at'] = deadline_text
value['deadlines'].update({
'assignment_issued_at': issued_text,
'assignment_deadline_at': deadline_text,
'assignment_ttl_seconds': 86400,
})
return value
def package_runtime():
return {
'build_compatibility': build_compatibility(),
'code_manifest': {},
'code_manifest_sha256': '9' * 64,
'trufflehog_path': 'trufflehog',
'git_path': 'git',
'detector_policy_path': 'detectors.yaml',
'capabilities': (
('github', 'github', 'exact_git_v1'),
('gitlab', 'gitlab', 'exact_git_v1'),
),
}
class WorkerAssignmentRunnerProtocolTests(unittest.TestCase):
def setUp(self):
self._scanner_paths = (
runner.scanner.scan_config.trufflehog_path,
runner.scanner.scan_config.trufflehog_config,
runner.scanner.scan_config.work_dir,
)
def tearDown(self):
(
runner.scanner.scan_config.trufflehog_path,
runner.scanner.scan_config.trufflehog_config,
runner.scanner.scan_config.work_dir,
) = self._scanner_paths
def _root(
self, temporary, generation='a' * 32, release_gate=True,
deadline_seconds=300,
):
work = ensure_private_directory(os.path.join(temporary, 'work'), reject_reparse=True)
pending = live_assignment()
started = datetime.now(timezone.utc)
value = runner.build_runner_input(
pending,
generation=generation,
slot_id=0,
scan_started_at=started.isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
scan_deadline_at=(started + timedelta(seconds=deadline_seconds)).isoformat(
timespec='milliseconds',
).replace('+00:00', 'Z'),
)
name = runner.runner_root_name(0, 17, generation)
paths, digest = runner.create_runner_root(work, name, value)
if release_gate:
identity = runner.serialize_process_identity(runner.current_process_identity())
runner.publish_start_gate(
paths['start'], generation=generation, input_sha256=digest,
host=identity, payload=identity,
)
return work, pending, paths, digest
def test_runner_does_not_begin_permit_or_scan_work_before_identity_gate(self):
with tempfile.TemporaryDirectory() as temporary:
_work, _pending, paths, digest = self._root(
temporary, release_gate=False,
)
entered = threading.Event()
result = []
def execute(*_args, **_kwargs):
entered.set()
raise AssertionError('fixture stops after proving gate release')
def run():
result.append(runner.run_assignment(paths['root'], package_runtime()))
with mock.patch.object(
runner.scanner, 'client_scan_launch_authority', return_value=nullcontext(),
), mock.patch.object(
runner.scanner, 'initialize_scanner_runtime',
), mock.patch.object(
runner, 'execute_protocol2_remote_claim', side_effect=execute,
):
thread = threading.Thread(target=run)
thread.start()
self.assertFalse(entered.wait(0.2))
identity = runner.serialize_process_identity(
runner.current_process_identity(),
)
runner.publish_start_gate(
paths['start'], generation='a' * 32,
input_sha256=digest, host=identity, payload=identity,
)
self.assertTrue(entered.wait(2))
thread.join(5)
self.assertEqual(result, [1])
def test_input_is_closed_canonical_and_contains_no_worker_api_token(self):
with tempfile.TemporaryDirectory() as temporary:
_work, _assignment, paths, digest = self._root(temporary)
value, loaded_digest = runner.load_runner_input(paths['input'])
self.assertEqual(loaded_digest, digest)
self.assertEqual(set(value), {
'schema', 'generation', 'slot_id', 'created_at',
'scan_started_at', 'scan_deadline_at', 'operation',
'watchdog_deadline_at', 'timeout_phase', 'assignment',
})
payload = open(paths['input'], 'r', encoding='utf-8').read()
self.assertNotIn('worker-api-secret', payload)
value['unexpected'] = True
with self.assertRaises(runner.RunnerProtocolError):
runner.validate_runner_input(value)
def test_subsecond_budget_keeps_validated_policy_and_never_passes_fractional_timeout(self):
with tempfile.TemporaryDirectory() as temporary:
_work, pending, paths, _digest = self._root(
temporary, deadline_seconds=0.5,
)
validated_kwargs = scan_execution.validate_scan_kwargs(
pending['reservation']['platform'], pending['scan_kwargs'],
)
self.assertEqual(validated_kwargs['timeout_sec'], 60.0)
with mock.patch.object(
runner.scanner, 'client_scan_launch_authority', return_value=nullcontext(),
), mock.patch.object(
runner.scanner, 'initialize_scanner_runtime',
), mock.patch.object(
runner, 'execute_protocol2_remote_claim',
side_effect=AssertionError('fractional timeout reached execution validator'),
) as execute:
self.assertEqual(runner.run_assignment(paths['root'], package_runtime()), 1)
execute.assert_not_called()
terminal, _digest = runner.load_generation_terminal(
paths['terminal'], generation='a' * 32,
input_sha256=runner.load_runner_input(paths['input'])[1],
)
self.assertEqual(terminal['decision'], 'timed_out')
self.assertEqual(terminal['reason'], 'scan_stage_deadline')
def test_real_runner_boundaries_have_deterministic_phase_failures(self):
phases = (
'preparing', 'waiting_permit', 'scanning', 'filtering',
'cleaning', 'bundling',
)
for failed_phase in phases:
with self.subTest(phase=failed_phase), tempfile.TemporaryDirectory() as temporary:
_work, _pending, paths, digest = self._root(temporary)
def inject(phase):
if phase == failed_phase:
raise RuntimeError('injected phase failure')
def execute(_validated, _bundle_root, *_args, phase_callback=None, **_kwargs):
for phase in phases[1:]:
phase_callback(phase)
raise AssertionError('phase injection did not stop the runner')
with mock.patch.object(
runner.scanner, 'client_scan_launch_authority', return_value=nullcontext(),
), mock.patch.object(
runner.scanner, 'initialize_scanner_runtime',
), mock.patch.object(
runner, 'execute_protocol2_remote_claim', side_effect=execute,
):
self.assertEqual(
runner.run_assignment(
paths['root'], package_runtime(), fault=inject,
),
1,
)
events = runner.read_runner_events(
paths['events'], generation='a' * 32,
input_sha256=digest, operation='execute',
)
self.assertEqual(events[-1]['phase'], failed_phase)
terminal, _ = runner.load_generation_terminal(
paths['terminal'], generation='a' * 32,
input_sha256=digest,
)
self.assertEqual(terminal['outcome']['status'], 'failed')
self.assertEqual(terminal['outcome']['final_phase'], failed_phase)
def test_source_failure_is_reported_in_the_honest_encompassing_scan_phase(self):
with tempfile.TemporaryDirectory() as temporary:
_work, _pending, paths, digest = self._root(temporary)
def execute(_validated, _bundle_root, *_args, phase_callback=None, **_kwargs):
phase_callback('waiting_permit')
phase_callback('scanning', {'operation': 'provider_and_scanner'})
raise RuntimeError('injected source failure')
with mock.patch.object(
runner.scanner, 'client_scan_launch_authority', return_value=nullcontext(),
), mock.patch.object(
runner.scanner, 'initialize_scanner_runtime',
), mock.patch.object(
runner, 'execute_protocol2_remote_claim', side_effect=execute,
):
self.assertEqual(runner.run_assignment(paths['root'], package_runtime()), 1)
events = runner.read_runner_events(
paths['events'], generation='a' * 32,
input_sha256=digest, operation='execute',
)
self.assertEqual(events[-1]['phase'], 'scanning')
self.assertNotIn('downloading', [item['phase'] for item in events])
self.assertNotIn('cloning', [item['phase'] for item in events])
def test_each_real_phase_stall_is_closed_by_the_terminal_barrier(self):
phases = (
'preparing', 'waiting_permit', 'scanning', 'filtering',
'cleaning', 'bundling',
)
for stalled_phase in phases:
with self.subTest(phase=stalled_phase), tempfile.TemporaryDirectory() as temporary:
_work, _pending, paths, digest = self._root(temporary)
entered = threading.Event()
release = threading.Event()
result = []
def stall(phase):
if phase == stalled_phase:
entered.set()
self.assertTrue(release.wait(5))
def execute(_validated, _bundle_root, *_args, phase_callback=None, **_kwargs):
for phase in phases[1:]:
phase_callback(phase)
raise AssertionError('phase stall did not close the runner')
def run():
result.append(runner.run_assignment(
paths['root'], package_runtime(), fault=stall,
))
with mock.patch.object(
runner.scanner, 'client_scan_launch_authority', return_value=nullcontext(),
), mock.patch.object(
runner.scanner, 'initialize_scanner_runtime',
), mock.patch.object(
runner, 'execute_protocol2_remote_claim', side_effect=execute,
):
thread = threading.Thread(target=run)
thread.start()
self.assertTrue(entered.wait(5))
runner.publish_generation_terminal(
paths['terminal'], generation='a' * 32,
input_sha256=digest, decision='timed_out',
reason='scan_stage_deadline',
)
release.set()
thread.join(5)
self.assertEqual(result, [1])
def test_real_bundle_fsync_and_publish_stalls_lose_terminal_race(self):
for stage in ('before_fsync', 'before_rename'):
with self.subTest(stage=stage), tempfile.TemporaryDirectory() as temporary:
_work, _pending, paths, digest = self._root(temporary)
entered = threading.Event()
release = threading.Event()
result = []
def stall(current, _writer):
if current == stage:
entered.set()
self.assertTrue(release.wait(5))
def execute(validated, bundle_root, *_args, phase_callback=None, bundle_fault=None, **_kwargs):
return self._stage_fixture(
validated, bundle_root, phase_callback, bundle_fault,
)
def run():
result.append(runner.run_assignment(
paths['root'], package_runtime(), bundle_fault=stall,
))
with mock.patch.object(
runner.scanner, 'client_scan_launch_authority', return_value=nullcontext(),
), mock.patch.object(
runner.scanner, 'initialize_scanner_runtime',
), mock.patch.object(
runner, 'execute_protocol2_remote_claim', side_effect=execute,
):
thread = threading.Thread(target=run)
thread.start()
self.assertTrue(entered.wait(5))
runner.publish_generation_terminal(
paths['terminal'], generation='a' * 32,
input_sha256=digest, decision='timed_out',
reason='scan_stage_deadline',
)
release.set()
thread.join(5)
self.assertEqual(result, [1])
terminal, _ = runner.load_generation_terminal(
paths['terminal'], generation='a' * 32,
input_sha256=digest,
)
self.assertEqual(terminal['decision'], 'timed_out')
def test_real_permit_block_loses_to_terminal_timeout_barrier(self):
with tempfile.TemporaryDirectory() as temporary:
_work, _pending, paths, digest = self._root(temporary)
waiting = threading.Event()
release = threading.Event()
result = []
class BlockedPermit:
def __enter__(self):
waiting.set()
self_test.assertTrue(release.wait(5))
def __exit__(self, *_args):
return False
self_test = self
def run():
result.append(runner.run_assignment(paths['root'], package_runtime()))
with mock.patch.object(
runner.scanner, 'client_scan_launch_authority', return_value=nullcontext(),
), mock.patch.object(
runner.scanner, 'initialize_scanner_runtime',
), mock.patch.object(
scan_execution, 'scan_slot_scope', return_value=BlockedPermit(),
):
thread = threading.Thread(target=run)
thread.start()
self.assertTrue(waiting.wait(5))
events = runner.read_runner_events(
paths['events'], generation='a' * 32,
input_sha256=digest, operation='execute',
)
self.assertEqual(
[item['phase'] for item in events],
['preparing', 'waiting_permit'],
)
runner.publish_generation_terminal(
paths['terminal'], generation='a' * 32,
input_sha256=digest, decision='timed_out',
reason='scan_stage_deadline',
)
release.set()
thread.join(5)
self.assertEqual(result, [1])
def test_successful_outcome_adopts_only_validated_identity_matching_bundle(self):
with tempfile.TemporaryDirectory() as temporary:
work, pending, paths, digest = self._root(temporary)
def execute(validated, bundle_root, *_args, phase_callback=None, **_kwargs):
for phase in (
'waiting_permit', 'scanning',
'filtering', 'cleaning', 'bundling',
):
phase_callback(phase)
reservation = validated['reservation']
with ResultBundleWriter.open(bundle_root, reservation) as writer:
commit = writer.finish({'queue_status': 'done'})
return StagedResult(
target=reservation.target,
scan_event_id=commit.scan_event_id,
bundle_id=commit.bundle_id,
reservation_id=commit.reservation_id,
scan_event_hash=commit.scan_event_hash,
actual_bytes=commit.actual_bytes,
relative_path=commit.relative_path,
frame_count=commit.frame_count,
finding_count=commit.finding_count,
error_count=commit.error_count,
candidate_count=commit.candidate_count,
queue_status='done',
source_failure=False,
source_failure_category='',
source_failure_auth_related=False,
first_error='',
)
with mock.patch.object(
runner.scanner, 'client_scan_launch_authority', return_value=nullcontext(),
), mock.patch.object(
runner.scanner, 'initialize_scanner_runtime',
), mock.patch.object(
runner, 'execute_protocol2_remote_claim', side_effect=execute,
):
self.assertEqual(runner.run_assignment(paths['root'], package_runtime()), 0)
terminal, outcome_digest = runner.load_generation_terminal(
paths['terminal'], generation='a' * 32, input_sha256=digest,
)
outcome = terminal['outcome']
self.assertEqual(outcome['status'], 'succeeded')
self.assertEqual(len(outcome_digest), 64)
destination = ensure_private_directory(
os.path.join(temporary, 'bundles'), reject_reparse=True,
)
for name in ('tmp', 'ready', 'quarantine'):
ensure_private_directory(os.path.join(destination, name), reject_reparse=True)
adopted = runner.adopt_runner_bundle(
work, os.path.basename(paths['root']), outcome, pending, destination,
)
self.assertEqual(adopted.header, BundleReservation.from_mapping(
pending['reservation'],
).header())
self.assertTrue(os.path.isfile(bundle_ready_path(destination, 'd' * 32)))
stale = dict(outcome)
stale['generation'] = 'b' * 32
with self.assertRaises(runner.RunnerProtocolError):
runner.adopt_runner_bundle(
work, os.path.basename(paths['root']), stale, pending, destination,
)
@staticmethod
def _stage_fixture(validated, bundle_root, phase_callback, bundle_fault=None):
for phase in (
'waiting_permit', 'scanning', 'filtering', 'cleaning', 'bundling',
):
phase_callback(phase)
reservation = validated['reservation']
with ResultBundleWriter.open(
bundle_root, reservation, fault=bundle_fault,
) as writer:
commit = writer.finish({'queue_status': 'done'})
return StagedResult(
target=reservation.target,
scan_event_id=commit.scan_event_id,
bundle_id=commit.bundle_id,
reservation_id=commit.reservation_id,
scan_event_hash=commit.scan_event_hash,
actual_bytes=commit.actual_bytes,
relative_path=commit.relative_path,
frame_count=commit.frame_count,
finding_count=commit.finding_count,
error_count=commit.error_count,
candidate_count=commit.candidate_count,
queue_status='done', source_failure=False,
source_failure_category='', source_failure_auth_related=False,
first_error='',
)
def test_timeout_terminal_decision_wins_race_against_late_success(self):
with tempfile.TemporaryDirectory() as temporary:
_work, _pending, paths, digest = self._root(temporary)
staged = threading.Event()
release = threading.Event()
result = []
def execute(validated, bundle_root, *_args, phase_callback=None, **_kwargs):
value = self._stage_fixture(validated, bundle_root, phase_callback)
staged.set()
self.assertTrue(release.wait(5))
return value
def run():
result.append(runner.run_assignment(paths['root'], package_runtime()))
with mock.patch.object(
runner.scanner, 'client_scan_launch_authority', return_value=nullcontext(),
), mock.patch.object(
runner.scanner, 'initialize_scanner_runtime',
), mock.patch.object(
runner, 'execute_protocol2_remote_claim', side_effect=execute,
):
thread = threading.Thread(target=run)
thread.start()
self.assertTrue(staged.wait(5))
terminal, won = runner.publish_generation_terminal(
paths['terminal'], generation='a' * 32,
input_sha256=digest, decision='timed_out',
reason='scan_stage_deadline',
)
self.assertTrue(won)
self.assertEqual(terminal['decision'], 'timed_out')
release.set()
thread.join(5)
self.assertEqual(result, [1])
terminal, _ = runner.load_generation_terminal(
paths['terminal'], generation='a' * 32, input_sha256=digest,
)
self.assertEqual(terminal['decision'], 'timed_out')
self.assertTrue(os.path.isfile(bundle_ready_path(paths['bundle_root'], 'd' * 32)))
def test_completed_terminal_decision_cannot_be_replaced_by_timeout(self):
with tempfile.TemporaryDirectory() as temporary:
_work, _pending, paths, digest = self._root(temporary)
def execute(validated, bundle_root, *_args, phase_callback=None, **_kwargs):
return self._stage_fixture(validated, bundle_root, phase_callback)
with mock.patch.object(
runner.scanner, 'client_scan_launch_authority', return_value=nullcontext(),
), mock.patch.object(
runner.scanner, 'initialize_scanner_runtime',
), mock.patch.object(
runner, 'execute_protocol2_remote_claim', side_effect=execute,
):
self.assertEqual(runner.run_assignment(paths['root'], package_runtime()), 0)
terminal, won = runner.publish_generation_terminal(
paths['terminal'], generation='a' * 32,
input_sha256=digest, decision='timed_out',
reason='scan_stage_deadline',
)
self.assertFalse(won)
self.assertEqual(terminal['decision'], 'completed')
def test_terminal_semantics_reject_phase_sequence_and_duration_tampering(self):
with tempfile.TemporaryDirectory() as temporary:
_work, _pending, paths, digest = self._root(temporary)
def execute(validated, bundle_root, *_args, phase_callback=None, **_kwargs):
return self._stage_fixture(validated, bundle_root, phase_callback)
with mock.patch.object(
runner.scanner, 'client_scan_launch_authority', return_value=nullcontext(),
), mock.patch.object(
runner.scanner, 'initialize_scanner_runtime',
), mock.patch.object(
runner, 'execute_protocol2_remote_claim', side_effect=execute,
):
self.assertEqual(runner.run_assignment(paths['root'], package_runtime()), 0)
terminal, _ = runner.load_generation_terminal(
paths['terminal'], generation='a' * 32, input_sha256=digest,
)
runner_input, _ = runner.load_runner_input(paths['input'])
events = runner.read_runner_events(
paths['events'], generation='a' * 32, input_sha256=digest,
operation='execute',
)
wrong_phase = copy.deepcopy(terminal)
wrong_phase['outcome']['final_phase'] = 'cleaning'
with self.assertRaisesRegex(runner.RunnerProtocolError, 'journal'):
runner.validate_terminal_against_journal(
wrong_phase, events, runner_input,
)
wrong_duration = copy.deepcopy(terminal)
wrong_duration['outcome']['phase_durations']['idle'] = 1.0
with self.assertRaisesRegex(runner.RunnerProtocolError, 'journal'):
runner.validate_terminal_against_journal(
wrong_duration, events, runner_input,
)
def test_operation_requires_its_exact_first_phase(self):
with tempfile.TemporaryDirectory() as temporary:
_work, _pending, paths, digest = self._root(temporary)
journal = runner.RunnerEventJournal(
paths['events'], paths['terminal'], 'a' * 32, digest,
)
journal.emit('bundling')
with self.assertRaisesRegex(runner.RunnerProtocolError, 'wrong operation phase'):
runner.read_runner_events(
paths['events'], generation='a' * 32,
input_sha256=digest, operation='execute',
)
def test_event_journal_clamps_backward_wall_clock(self):
with tempfile.TemporaryDirectory() as temporary:
_work, _pending, paths, digest = self._root(temporary)
journal = runner.RunnerEventJournal(
paths['events'], paths['terminal'], 'a' * 32, digest,
)
with mock.patch.object(runner, 'utc_now', side_effect=(
'2026-09-26T08:59:23.344Z',
'2026-09-26T08:59:23.300Z',
'2026-09-26T08:59:23.250Z',
)):
first = journal.emit('preparing')
second = journal.emit('waiting_permit')
completed_at = journal.current_timestamp()
self.assertEqual(second['timestamp'], first['timestamp'])
self.assertEqual(second['phase_started_at'], first['timestamp'])
self.assertEqual(completed_at, first['timestamp'])
events = runner.read_runner_events(
paths['events'], generation='a' * 32,
input_sha256=digest, operation='execute',
)
self.assertEqual(len(events), 2)
self.assertEqual(
runner.phase_durations_from_events(events, completed_at),
{'preparing': 0.0, 'waiting_permit': 0.0},
)
def test_malformed_and_incomplete_event_tail_is_never_interpreted_as_progress(self):
with tempfile.TemporaryDirectory() as temporary:
_work, _pending, paths, digest = self._root(temporary)
journal = runner.RunnerEventJournal(
paths['events'], paths['terminal'], 'a' * 32, digest,
)
journal.emit('waiting_permit')
with open(paths['events'], 'ab') as handle:
handle.write(b'{"partial":')
events = runner.read_runner_events(
paths['events'], generation='a' * 32, input_sha256=digest,
)
self.assertEqual([item['phase'] for item in events], ['waiting_permit'])
with open(paths['events'], 'ab') as handle:
handle.write(b'false}\n')
with self.assertRaises(runner.RunnerProtocolError):
runner.read_runner_events(
paths['events'], generation='a' * 32, input_sha256=digest,
)
def test_fence_blocks_late_bundle_publication_fault_boundary(self):
with tempfile.TemporaryDirectory() as temporary:
_work, _pending, paths, digest = self._root(temporary)
runner.publish_generation_terminal(
paths['terminal'], generation='a' * 32,
input_sha256=digest, decision='timed_out',
reason='controller_timeout',
)
journal = runner.RunnerEventJournal(
paths['events'], paths['terminal'], 'a' * 32, digest,
)
with self.assertRaises(runner.RunnerFencedError):
journal.emit('waiting_permit')
def test_janitor_retains_active_slot_root_then_removes_abandoned_root(self):
with tempfile.TemporaryDirectory() as temporary:
work, _pending, paths, _digest = self._root(temporary)
name = os.path.basename(paths['root'])
with mock.patch('janitor.exact_process_identity_state', return_value='dead'):
retained = runner.cleanup_abandoned_runner_roots(
work, minimum_age_sec=0, active_root_names=(name,),
)
self.assertEqual(retained['removed'], 0)
self.assertTrue(os.path.isdir(paths['root']))
removed = runner.cleanup_abandoned_runner_roots(
work, minimum_age_sec=0,
)
self.assertEqual(removed['removed'], 1)
self.assertFalse(os.path.exists(paths['root']))
def test_abandoned_tree_transfer_is_durable_validated_and_janitor_visible(self):
with tempfile.TemporaryDirectory() as temporary:
work, _pending, paths, _digest = self._root(temporary)
reference = runner.transfer_runner_to_janitor(
work, os.path.basename(paths['root']), 'a' * 32,
)
destination = os.path.join(work, *reference.split('/'))
self.assertFalse(os.path.exists(paths['root']))
self.assertTrue(os.path.isdir(destination))
marker = runner.read_private_json(
os.path.join(destination, '.scanner-owner.json'),
)
intent = runner.read_private_json(
os.path.join(destination, '.janitor-transfer.json'),
)
self.assertEqual(marker['relative_path'], reference)
self.assertEqual(intent['status'], 'transferred')
with mock.patch('janitor.exact_process_identity_state', return_value='dead'):
report = runner.cleanup_abandoned_runner_roots(
work, minimum_age_sec=0,
)
self.assertEqual(report['removed'], 1)
self.assertFalse(os.path.exists(destination))
def test_bundle_fsync_fault_publishes_failed_outcome_not_ready_bundle(self):
with tempfile.TemporaryDirectory() as temporary:
_work, _pending, paths, digest = self._root(temporary)
def execute(validated, bundle_root, *_args, phase_callback=None, bundle_fault=None, **_kwargs):
return self._stage_fixture(
validated, bundle_root, phase_callback, bundle_fault,
)
with mock.patch.object(
runner.scanner, 'client_scan_launch_authority', return_value=nullcontext(),
), mock.patch.object(
runner.scanner, 'initialize_scanner_runtime',
), mock.patch.object(
runner, 'execute_protocol2_remote_claim', side_effect=execute,
):
code = runner.run_assignment(
paths['root'], package_runtime(),
bundle_fault=lambda stage, _writer: (
(_ for _ in ()).throw(OSError('injected fsync failure'))
if stage == 'before_fsync' else None
),
)
self.assertEqual(code, 1)
terminal, _ = runner.load_generation_terminal(
paths['terminal'], generation='a' * 32, input_sha256=digest,
)
outcome = terminal['outcome']
self.assertEqual(outcome['status'], 'failed')
self.assertEqual(outcome['final_phase'], 'bundling')
self.assertIsNone(outcome['bundle'])
class NonExitingProcess:
def __init__(self):
executable = os.path.abspath(sys.executable)
self.host_identity = {
'pid': 101, 'creation_time': 'windows-filetime:1',
'creation_time_unix': 1.0, 'executable': executable, 'in_job': True,
}
self.payload_identity = {
'pid': 102, 'creation_time': 'windows-filetime:2',
'creation_time_unix': 1.0, 'executable': executable, 'in_job': True,
}
self.job_membership_verified = True
self.kills = 0
def poll(self):
return None
def kill(self):
self.kills += 1
def wait(self, timeout=None):
raise subprocess.TimeoutExpired('runner', timeout)
class CrashedProcess(NonExitingProcess):
def __init__(self, ordinal):
super().__init__()
self.host_identity['pid'] += ordinal * 10
self.payload_identity['pid'] += ordinal * 10
def poll(self):
return 7
def wait(self, timeout=None):
return 7
class KillableProcess(NonExitingProcess):
def __init__(self):
super().__init__()
self.alive = True
self.killed_at = None
def poll(self):
return None if self.alive else 1
def kill(self):
super().kill()
self.alive = False
self.killed_at = time.monotonic()
def wait(self, timeout=None):
self.alive = False
return 1
class WorkerSlotRunnerTests(unittest.TestCase):
def setUp(self):
self._scanner_paths = (
runner.scanner.scan_config.trufflehog_path,
runner.scanner.scan_config.trufflehog_config,
runner.scanner.scan_config.work_dir,
)
def tearDown(self):
(
runner.scanner.scan_config.trufflehog_path,
runner.scanner.scan_config.trufflehog_config,
runner.scanner.scan_config.work_dir,
) = self._scanner_paths
def _slot(self, temporary, api=object(), process_factory=None):
state = ensure_private_directory(os.path.join(temporary, 'state'), reject_reparse=True)
bundles = ensure_private_directory(os.path.join(temporary, 'bundles'), reject_reparse=True)
work = ensure_private_directory(os.path.join(temporary, 'work'), reject_reparse=True)
for name in ('tmp', 'ready', 'quarantine'):
ensure_private_directory(os.path.join(bundles, name), reject_reparse=True)
return WorkerSlot(
0, api, build_compatibility(), state, bundles, work_root=work,
process_factory=process_factory or remote_worker_client.OwnedProcess,
package_runtime={
'code_manifest': {}, 'code_manifest_sha256': '9' * 64,
'trufflehog_path': 'trufflehog', 'git_path': 'git',
'detector_policy_path': 'detectors.yaml',
'capabilities': (
('github', 'github', 'exact_git_v1'),
('gitlab', 'gitlab', 'exact_git_v1'),
),
'bootstrap_path': os.path.join(APP_DIR, 'remote_worker_bootstrap.py'),
},
)
def test_assignment_runner_launch_hides_windows_console(self):
with tempfile.TemporaryDirectory() as temporary:
process = NonExitingProcess()
slot = self._slot(temporary)
for platform, expected in (('nt', 0x08000000), ('posix', 0)):
captured = {}
def factory(*_args, **kwargs):
captured.update(kwargs)
return process
slot._process_factory = factory
with self.subTest(platform=platform), mock.patch.object(
remote_worker_client.os, 'name', platform,
), mock.patch.object(
remote_worker_client.subprocess,
'CREATE_NO_WINDOW', 0x08000000, create=True,
):
launched = slot._launch_process_bounded(
{}, {'root': slot.work_root}, 1.0,
)
self.assertIs(launched, process)
self.assertEqual(captured['creationflags'], expected)
def test_permit_starvation_nonexit_keeps_containment_pending(self):
process = NonExitingProcess()
with tempfile.TemporaryDirectory() as temporary:
slot = self._slot(temporary, process_factory=lambda *_args, **_kwargs: process)
pending = live_assignment()
pending['deadlines']['target_scan_timeout_seconds'] = 1
state = {'phase': 'assigned', 'assignment': pending}
slot._save(state)
state = slot._load()
with mock.patch.object(
remote_worker_client, 'validate_protocol2_remote_assignment', return_value={},
):
slot._new_runner(state)
started = datetime.now(timezone.utc)
with mock.patch.object(
remote_worker_client, 'RUNNER_STOP_TIMEOUT_SECONDS', 0.01,
), mock.patch.object(
slot, '_timeout_bundle', side_effect=lambda value: value,
) as timeout_bundle:
with self.assertRaises(remote_worker_client.RunnerContainmentPending):
slot._launch_runner(state)
timeout_bundle.assert_not_called()
self.assertGreaterEqual(process.kills, 2)
self.assertEqual(state['runner']['status'], 'stopping')
self.assertLess((datetime.now(timezone.utc) - started).total_seconds(), 2.0)
self.assertTrue(os.path.isfile(slot._runner_paths(state['runner'])['terminal']))
def test_cleanup_stall_records_cleaning_as_the_timed_out_phase(self):
process = NonExitingProcess()
captured = []
with tempfile.TemporaryDirectory() as temporary:
slot = self._slot(temporary)
pending = live_assignment()
pending['deadlines']['target_scan_timeout_seconds'] = 1
state = {'phase': 'assigned', 'assignment': pending}
slot._save(state)
state = slot._load()
with mock.patch.object(
remote_worker_client, 'validate_protocol2_remote_assignment', return_value={},
):
slot._new_runner(state)
paths = slot._runner_paths(state['runner'])
def launch(*_args, **_kwargs):
journal = runner.RunnerEventJournal(
paths['events'], paths['terminal'],
state['runner']['generation'], state['runner']['input_sha256'],
)
for phase in (
'preparing', 'waiting_permit', 'scanning',
'filtering', 'cleaning',
):
journal.emit(phase)
return process
slot._process_factory = launch
slot._event_phase = remote_worker_client.WorkerPhase.ASSIGNED
def timed_out(value):
captured.append(value['runner']['last_event']['phase'])
return value
with mock.patch.object(
remote_worker_client, 'RUNNER_STOP_TIMEOUT_SECONDS', 0.01,
), mock.patch.object(
slot, '_timeout_bundle', side_effect=timed_out,
):
with self.assertRaises(remote_worker_client.RunnerContainmentPending):
slot._launch_runner(state)
self.assertEqual(state['runner']['last_event']['phase'], 'cleaning')
self.assertEqual(captured, [])
def test_slot_state_migrates_shipped_legacy_shape_and_rejects_unknown_fields(self):
with tempfile.TemporaryDirectory() as temporary:
slot = self._slot(temporary)
slot._save({'phase': 'assigned', 'assignment': live_assignment()})
migrated = slot._load()
self.assertEqual(migrated['schema'], remote_worker_client.SLOT_STATE_SCHEMA)
self.assertIsNone(migrated['runner'])
malformed = {**migrated, 'server_command': 'forbidden'}
atomic_write_private_json(slot.state_path, malformed)
with self.assertRaisesRegex(WorkerClientError, 'state shape'):
slot._load()
def test_outcomeless_runner_crash_retries_three_generations_without_resetting_deadline(self):
launched = []
def factory(*_args, **_kwargs):
process = CrashedProcess(len(launched) + 1)
launched.append(process)
return process
with tempfile.TemporaryDirectory() as temporary:
slot = self._slot(temporary, process_factory=factory)
state = {'phase': 'assigned', 'assignment': live_assignment()}
slot._save(state)
state = slot._load()
with self.assertRaisesRegex(
runner.RunnerProtocolError, 'bounded generation retries',
):
slot._execute(state)
self.assertEqual(len(launched), 3)
self.assertIsNone(state['runner'])
self.assertEqual(len(state['retained_work']), 3)
deadlines = []
for reference in state['retained_work']:
value, _ = runner.load_runner_input(os.path.join(
slot.work_root, *reference.split('/'), runner.RUNNER_INPUT_NAME,
))
deadlines.append(value['scan_deadline_at'])
self.assertEqual(len(set(deadlines)), 1)
def test_timed_out_execute_generation_starts_one_timeout_bundle_without_crash_retries(self):
with tempfile.TemporaryDirectory() as temporary:
slot = self._slot(temporary)
state = {'phase': 'assigned', 'assignment': live_assignment()}
slot._save(state)
state = slot._load()
started = datetime.now(timezone.utc)
scan_deadline = (started + timedelta(seconds=0.5)).isoformat(
timespec='milliseconds',
).replace('+00:00', 'Z')
with mock.patch.object(
remote_worker_client, 'validate_protocol2_remote_assignment',
return_value={},
):
execute_runner = dict(slot._new_runner(
state,
scan_started_at=started.isoformat(
timespec='milliseconds',
).replace('+00:00', 'Z'),
scan_deadline_at=scan_deadline,
watchdog_deadline_at=scan_deadline,
operation='execute', attempt=1,
))
paths = slot._runner_paths(execute_runner)
journal = runner.RunnerEventJournal(
paths['events'], paths['terminal'],
execute_runner['generation'], execute_runner['input_sha256'],
)
for phase in ('preparing', 'waiting_permit', 'scanning'):
journal.emit(phase)
runner.publish_generation_terminal(
paths['terminal'], generation=execute_runner['generation'],
input_sha256=execute_runner['input_sha256'],
decision='timed_out', reason='scan_stage_deadline',
)
launched = []
def launch(value, **_kwargs):
launched.append(dict(value['runner']))
return value
with mock.patch.object(
remote_worker_client, 'validate_protocol2_remote_assignment',
return_value={},
), mock.patch.object(
slot, '_launch_runner', side_effect=launch,
):
completed = slot._execute(state)
self.assertIs(completed, state)
self.assertEqual(len(launched), 1)
self.assertEqual(launched[0]['operation'], 'timeout_bundle')
self.assertEqual(launched[0]['attempt'], 1)
self.assertEqual(launched[0]['scan_deadline_at'], scan_deadline)
self.assertEqual(launched[0]['timeout_phase'], 'scanning')
self.assertEqual(len(state['retained_work']), 1)
def test_restart_malformed_outcome_is_fenced_and_never_adopted(self):
with tempfile.TemporaryDirectory() as temporary:
slot = self._slot(temporary)
state = {'phase': 'assigned', 'assignment': live_assignment()}
slot._save(state)
state = slot._load()
with mock.patch.object(
remote_worker_client, 'validate_protocol2_remote_assignment', return_value={},
):
slot._new_runner(state)
paths = slot._runner_paths(state['runner'])
atomic_write_private_json(paths['terminal'], {'schema': 1, 'stale': True})
state['runner']['attempt'] = 3
slot._save(state)
with mock.patch.object(
remote_worker_client, 'validate_protocol2_remote_assignment', return_value={},
), self.assertRaises(runner.RunnerProtocolError):
slot._execute(state)
self.assertTrue(os.path.isfile(os.path.join(
slot.work_root, 'abandoned', os.path.basename(paths['root']),
runner.RUNNER_TERMINAL_NAME,
)))
self.assertFalse(os.path.exists(bundle_ready_path(slot.bundle_root, 'd' * 32)))
def test_controller_rejects_completion_for_generation_already_marked_stopping(self):
with tempfile.TemporaryDirectory() as temporary:
slot = self._slot(temporary)
state = {'phase': 'assigned', 'assignment': live_assignment()}
slot._save(state)
state = slot._load()
with mock.patch.object(
remote_worker_client, 'validate_protocol2_remote_assignment', return_value={},
):
slot._new_runner(state)
paths = slot._runner_paths(state['runner'])
identity = runner.serialize_process_identity(runner.current_process_identity())
runner.publish_start_gate(
paths['start'], generation=state['runner']['generation'],
input_sha256=state['runner']['input_sha256'],
host=identity, payload=identity,
)
def execute(validated, bundle_root, *_args, phase_callback=None, **_kwargs):
return WorkerAssignmentRunnerProtocolTests._stage_fixture(
validated, bundle_root, phase_callback,
)
with mock.patch.object(
runner.scanner, 'client_scan_launch_authority', return_value=nullcontext(),
), mock.patch.object(
runner.scanner, 'initialize_scanner_runtime',
), mock.patch.object(
runner, 'execute_protocol2_remote_claim', side_effect=execute,
):
self.assertEqual(runner.run_assignment(paths['root'], package_runtime()), 0)
state['runner']['status'] = 'stopping'
slot._save(state)
with self.assertRaisesRegex(
runner.RunnerProtocolError, 'closed runner generation',
):
slot._load_runner_terminal(state)
def test_late_ready_bundle_from_timed_out_generation_is_never_adopted(self):
with tempfile.TemporaryDirectory() as temporary:
slot = self._slot(temporary)
pending = live_assignment()
state = {'phase': 'assigned', 'assignment': pending}
slot._save(state)
state = slot._load()
with mock.patch.object(
remote_worker_client, 'validate_protocol2_remote_assignment', return_value={},
):
slot._new_runner(state)
reservation = BundleReservation.from_mapping(pending['reservation'])
with ResultBundleWriter.open(slot.bundle_root, reservation) as writer:
writer.finish({'queue_status': 'done'})
paths = slot._runner_paths(state['runner'])
runner.publish_generation_terminal(
paths['terminal'], generation=state['runner']['generation'],
input_sha256=state['runner']['input_sha256'],
decision='timed_out', reason='scan_stage_deadline',
)
with self.assertRaisesRegex(
runner.RunnerProtocolError, 'lacks a valid completed generation',
):
slot._adopt_ready_bundle(state)
self.assertNotEqual(state['phase'], 'bundle_ready')
def test_restart_rerun_preserves_immutable_scan_clock_and_fences_generation(self):
with tempfile.TemporaryDirectory() as temporary:
slot = self._slot(temporary)
state = {'phase': 'assigned', 'assignment': live_assignment()}
slot._save(state)
state = slot._load()
with mock.patch.object(
remote_worker_client, 'validate_protocol2_remote_assignment', return_value={},
):
old_runner = dict(slot._new_runner(state))
old_paths = slot._runner_paths(old_runner)
with mock.patch.object(
slot, '_launch_runner', side_effect=lambda value: value,
):
recovered = slot._execute(state)
self.assertNotEqual(
recovered['runner']['generation'], old_runner['generation'],
)
self.assertEqual(
recovered['runner']['scan_started_at'], old_runner['scan_started_at'],
)
self.assertEqual(
recovered['runner']['scan_deadline_at'], old_runner['scan_deadline_at'],
)
self.assertTrue(os.path.isfile(os.path.join(
slot.work_root, 'abandoned', os.path.basename(old_paths['root']),
runner.RUNNER_TERMINAL_NAME,
)))
def test_controller_recovery_terminates_real_live_owned_containment_before_rerun(self):
with tempfile.TemporaryDirectory() as temporary:
slot = self._slot(temporary)
state = {'phase': 'assigned', 'assignment': live_assignment()}
slot._save(state)
state = slot._load()
with mock.patch.object(
remote_worker_client, 'validate_protocol2_remote_assignment', return_value={},
):
slot._new_runner(state)
process = remote_worker_client.OwnedProcess(
[sys.executable, '-I', '-S', '-B', '-c', 'import time; time.sleep(60)'],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
self.addCleanup(
lambda: process.kill() if process.poll() is None else None,
)
state['runner']['identity'] = {
'host': slot._identity_value(process.host_identity),
'payload': slot._identity_value(process.payload_identity),
'job_membership_verified': True,
}
state['runner']['status'] = 'running'
slot._save(state)
runner.bind_runner_owner(
slot.work_root, state['runner']['root_name'],
state['runner']['identity']['payload'],
)
with mock.patch.object(
remote_worker_client, 'validate_protocol2_remote_assignment', return_value={},
), mock.patch.object(
slot, '_launch_runner', side_effect=lambda value: value,
):
recovered = slot._execute(state)
self.assertIsNotNone(process.poll())
self.assertNotEqual(
recovered['runner']['generation'],
state['retained_work'][0].rsplit('-', 1)[-1],
)
def test_recovery_replays_valid_tail_with_original_runner_timestamps(self):
with tempfile.TemporaryDirectory() as temporary:
events = []
slot = self._slot(temporary)
slot.event_callback = events.append
state = {'phase': 'assigned', 'assignment': live_assignment()}
slot._save(state)
state = slot._load()
with mock.patch.object(
remote_worker_client, 'validate_protocol2_remote_assignment', return_value={},
):
old = dict(slot._new_runner(state))
paths = slot._runner_paths(old)
journal = runner.RunnerEventJournal(
paths['events'], paths['terminal'],
old['generation'], old['input_sha256'],
)
first = journal.emit('preparing')
second = journal.emit('waiting_permit')
with mock.patch.object(
slot, '_launch_runner', side_effect=lambda value: value,
):
slot._execute(state)
replayed = [
item for item in events
if (item.get('progress') or {}).get('runner_sequence')
]
self.assertEqual(
[item['timestamp'] for item in replayed],
[first['timestamp'], second['timestamp']],
)
self.assertEqual(
[item['phase_started_at'] for item in replayed],
[first['phase_started_at'], second['phase_started_at']],
)
def test_malformed_complete_event_is_fenced_before_generation_retry(self):
with tempfile.TemporaryDirectory() as temporary:
slot = self._slot(temporary)
state = {'phase': 'assigned', 'assignment': live_assignment()}
slot._save(state)
state = slot._load()
with mock.patch.object(
remote_worker_client, 'validate_protocol2_remote_assignment', return_value={},
):
old = dict(slot._new_runner(state))
paths = slot._runner_paths(old)
journal = runner.RunnerEventJournal(
paths['events'], paths['terminal'],
old['generation'], old['input_sha256'],
)
journal.emit('preparing')
with open(paths['events'], 'ab') as handle:
handle.write(b'{"bad":true}\n')
with mock.patch.object(
slot, '_launch_runner', side_effect=lambda value: value,
):
recovered = slot._execute(state)
self.assertNotEqual(recovered['runner']['generation'], old['generation'])
terminal, _ = runner.load_generation_terminal(
os.path.join(
slot.work_root, 'abandoned', old['root_name'],
runner.RUNNER_TERMINAL_NAME,
),
generation=old['generation'], input_sha256=old['input_sha256'],
)
self.assertEqual(terminal['decision'], 'fenced')
def test_deadline_produces_normal_phase_specific_timeout_bundle(self):
with tempfile.TemporaryDirectory() as temporary:
slot = self._slot(temporary)
state = {'phase': 'assigned', 'assignment': live_assignment()}
slot._save(state)
state = slot._load()
with mock.patch.object(
remote_worker_client, 'validate_protocol2_remote_assignment', return_value={},
):
slot._new_runner(state)
state['runner']['last_event'] = {
'schema': runner.RUNNER_PROTOCOL_SCHEMA,
'generation': state['runner']['generation'],
'input_sha256': state['runner']['input_sha256'],
'sequence': 1,
'timestamp': state['runner']['scan_started_at'],
'phase': 'waiting_permit',
'phase_started_at': state['runner']['scan_started_at'],
'progress': {},
}
slot._event_phase = remote_worker_client.WorkerPhase.WAITING_PERMIT
def publish_timeout(value, **_kwargs):
paths = slot._runner_paths(value['runner'])
identity = runner.serialize_process_identity(
runner.current_process_identity(),
)
runner.publish_start_gate(
paths['start'], generation=value['runner']['generation'],
input_sha256=value['runner']['input_sha256'],
host=identity, payload=identity,
)
self.assertEqual(
runner.run_assignment(paths['root'], package_runtime()), 0,
)
terminal = slot._load_runner_terminal(value)
return slot._adopt_runner_outcome(value, terminal['outcome'])
with mock.patch.object(
slot, '_launch_runner', side_effect=publish_timeout,
):
completed = slot._timeout_bundle(state)
self.assertEqual(completed['phase'], 'bundle_ready')
self.assertEqual(len(completed['retained_work']), 2)
self.assertTrue(all(
os.path.isdir(os.path.join(slot.work_root, *item.split('/')))
for item in completed['retained_work']
))
reader = ResultBundleReader(
bundle_ready_path(slot.bundle_root, 'd' * 32),
max_event_bytes=1024 * 1024,
)
metadata = reader.metadata()
self.assertEqual(metadata['error_class'], 'timeout')
self.assertEqual(metadata['scan_meta']['timed_out_phase'], 'waiting_permit')
self.assertTrue(metadata['scan_meta']['full_stage_timeout'])
self.assertEqual(reader.validate().diagnostic_count, 1)
self.assertEqual(
list(reader.iter_diagnostics())[0]['code'], 'scan.stage_timeout',
)
self.assertIn('deadline exceeded', list(reader.iter_errors())[0].lower())
def test_timeout_bundle_publish_stall_has_its_own_bounded_watchdog(self):
process = NonExitingProcess()
with tempfile.TemporaryDirectory() as temporary:
slot = self._slot(temporary, process_factory=lambda *_args, **_kwargs: process)
state = {'phase': 'assigned', 'assignment': live_assignment()}
slot._save(state)
state = slot._load()
started = datetime.now(timezone.utc)
with mock.patch.object(
remote_worker_client, 'validate_protocol2_remote_assignment', return_value={},
):
slot._new_runner(
state,
scan_started_at=started.isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
scan_deadline_at=(started + timedelta(seconds=1)).isoformat(
timespec='milliseconds',
).replace('+00:00', 'Z'),
watchdog_deadline_at=(started + timedelta(seconds=0.2)).isoformat(
timespec='milliseconds',
).replace('+00:00', 'Z'),
operation='timeout_bundle', timeout_phase='cleaning',
)
with mock.patch.object(
remote_worker_client, 'RUNNER_STOP_TIMEOUT_SECONDS', 0.01,
), self.assertRaisesRegex(
remote_worker_client.RunnerContainmentPending, 'teardown remains pending',
):
slot._launch_runner(state, allow_timeout_fallback=False)
self.assertGreaterEqual(process.kills, 2)
self.assertEqual(state['runner']['status'], 'stopping')
def test_blocked_owned_process_launcher_cannot_cross_absolute_deadline(self):
release = threading.Event()
process = KillableProcess()
def blocked_factory(*_args, **_kwargs):
release.wait(5)
return process
with tempfile.TemporaryDirectory() as temporary:
slot = self._slot(temporary, process_factory=blocked_factory)
state = {'phase': 'assigned', 'assignment': live_assignment()}
slot._save(state)
state = slot._load()
started = datetime.now(timezone.utc)
with mock.patch.object(
remote_worker_client, 'validate_protocol2_remote_assignment', return_value={},
):
slot._new_runner(
state,
scan_started_at=started.isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
scan_deadline_at=(started + timedelta(seconds=1)).isoformat(
timespec='milliseconds',
).replace('+00:00', 'Z'),
watchdog_deadline_at=(started + timedelta(seconds=0.2)).isoformat(
timespec='milliseconds',
).replace('+00:00', 'Z'),
operation='timeout_bundle', timeout_phase='preparing',
)
before = time.monotonic()
with self.assertRaises(runner.RunnerProtocolError):
slot._launch_runner(state, allow_timeout_fallback=False)
self.assertLess(time.monotonic() - before, 1.0)
release.set()
deadline = time.monotonic() + 2
while process.kills == 0 and time.monotonic() < deadline:
time.sleep(0.01)
self.assertGreaterEqual(process.kills, 1)
self.assertTrue(slot._runner_launch_cleanup.wait(2))
def test_blocked_prelaunch_filesystem_becomes_normal_preparing_timeout_bundle(self):
entered = threading.Event()
release = threading.Event()
finished = threading.Event()
original_create = remote_worker_client.create_runner_root
calls = [0]
def blocked_create(*args, **kwargs):
calls[0] += 1
if calls[0] > 1:
return original_create(*args, **kwargs)
entered.set()
release.wait(5)
try:
return original_create(*args, **kwargs)
finally:
finished.set()
with tempfile.TemporaryDirectory() as temporary:
slot = self._slot(temporary)
pending = live_assignment()
pending['deadlines']['target_scan_timeout_seconds'] = 1
state = {'phase': 'assigned', 'assignment': pending}
slot._save(state)
state = slot._load()
def publish_timeout(value, **_kwargs):
paths = slot._runner_paths(value['runner'])
identity = runner.serialize_process_identity(
runner.current_process_identity(),
)
runner.publish_start_gate(
paths['start'], generation=value['runner']['generation'],
input_sha256=value['runner']['input_sha256'],
host=identity, payload=identity,
)
self.assertEqual(
runner.run_assignment(paths['root'], package_runtime()), 0,
)
terminal = slot._load_runner_terminal(value)
return slot._adopt_runner_outcome(value, terminal['outcome'])
before = time.monotonic()
with mock.patch.object(
remote_worker_client, 'validate_protocol2_remote_assignment', return_value={},
), mock.patch.object(
remote_worker_client, 'create_runner_root', side_effect=blocked_create,
), mock.patch.object(
runner, 'validate_protocol2_remote_assignment',
return_value={'reservation': BundleReservation.from_mapping(
pending['reservation'],
)},
), mock.patch.object(
slot, '_launch_runner', side_effect=publish_timeout,
):
completed = slot._execute(state)
self.assertTrue(entered.is_set())
self.assertLess(time.monotonic() - before, 2.0)
self.assertEqual(completed['phase'], 'bundle_ready')
metadata = ResultBundleReader(
bundle_ready_path(slot.bundle_root, 'd' * 32),
max_event_bytes=1024 * 1024,
).metadata()
self.assertEqual(metadata['error_class'], 'timeout')
self.assertEqual(metadata['scan_meta']['timed_out_phase'], 'preparing')
release.set()
self.assertTrue(finished.wait(2))
def test_watchdog_kills_while_state_persistence_is_blocked(self):
process = KillableProcess()
with tempfile.TemporaryDirectory() as temporary:
slot = self._slot(
temporary, process_factory=lambda *_args, **_kwargs: process,
)
state = {'phase': 'assigned', 'assignment': live_assignment()}
slot._save(state)
state = slot._load()
started = datetime.now(timezone.utc)
with mock.patch.object(
remote_worker_client, 'validate_protocol2_remote_assignment', return_value={},
):
slot._new_runner(
state,
scan_started_at=started.isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
scan_deadline_at=(started + timedelta(seconds=2)).isoformat(
timespec='milliseconds',
).replace('+00:00', 'Z'),
watchdog_deadline_at=(started + timedelta(seconds=1)).isoformat(
timespec='milliseconds',
).replace('+00:00', 'Z'),
operation='timeout_bundle', timeout_phase='preparing',
)
original_save = slot._save
def slow_save(value):
if (value.get('runner') or {}).get('status') == 'running':
time.sleep(1.4)
return original_save(value)
before = time.monotonic()
with mock.patch.object(slot, '_save', side_effect=slow_save), \
self.assertRaises(remote_worker_client.RunnerContainmentPending):
slot._launch_runner(state, allow_timeout_fallback=False)
self.assertIsNotNone(process.killed_at)
self.assertLess(process.killed_at - before, 1.3)
persisted = slot._load()
self.assertEqual(persisted['phase'], 'assigned')
self.assertEqual(
persisted['runner']['generation'], state['runner']['generation'],
)
self.assertNotEqual(persisted['phase'], 'bundle_ready')
self.assertIn(0, remote_worker_client.persisted_slot_ids(slot.state_dir))
def test_blocked_startup_gate_write_enters_preparing_timeout_result_path(self):
process = KillableProcess()
with tempfile.TemporaryDirectory() as temporary:
slot = self._slot(
temporary, process_factory=lambda *_args, **_kwargs: process,
)
state = {'phase': 'assigned', 'assignment': live_assignment()}
slot._save(state)
state = slot._load()
started = datetime.now(timezone.utc)
with mock.patch.object(
remote_worker_client, 'validate_protocol2_remote_assignment', return_value={},
):
slot._new_runner(
state,
scan_started_at=started.isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
scan_deadline_at=(started + timedelta(seconds=2)).isoformat(
timespec='milliseconds',
).replace('+00:00', 'Z'),
watchdog_deadline_at=(started + timedelta(seconds=1)).isoformat(
timespec='milliseconds',
).replace('+00:00', 'Z'),
operation='execute',
)
original_gate = remote_worker_client.publish_start_gate
def slow_gate(*args, **kwargs):
time.sleep(1.4)
return original_gate(*args, **kwargs)
captured = []
def timeout_result(value):
captured.append(
(value['runner'].get('last_event') or {}).get('phase')
or 'preparing'
)
return value
before = time.monotonic()
with mock.patch.object(
remote_worker_client, 'publish_start_gate', side_effect=slow_gate,
), mock.patch.object(
slot, '_timeout_bundle', side_effect=timeout_result,
):
slot._launch_runner(state)
terminal, _ = runner.load_generation_terminal(
slot._runner_paths(state['runner'])['terminal'],
generation=state['runner']['generation'],
input_sha256=state['runner']['input_sha256'],
)
self.assertEqual(terminal['decision'], 'timed_out')
self.assertEqual(captured, ['preparing'])
self.assertLess(process.killed_at - before, 1.3)
def test_watchdog_kills_while_event_drain_is_blocked(self):
process = KillableProcess()
with tempfile.TemporaryDirectory() as temporary:
slot = self._slot(
temporary, process_factory=lambda *_args, **_kwargs: process,
)
state = {'phase': 'assigned', 'assignment': live_assignment()}
slot._save(state)
state = slot._load()
started = datetime.now(timezone.utc)
with mock.patch.object(
remote_worker_client, 'validate_protocol2_remote_assignment', return_value={},
):
slot._new_runner(
state,
scan_started_at=started.isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
scan_deadline_at=(started + timedelta(seconds=2)).isoformat(
timespec='milliseconds',
).replace('+00:00', 'Z'),
watchdog_deadline_at=(started + timedelta(seconds=1)).isoformat(
timespec='milliseconds',
).replace('+00:00', 'Z'),
operation='timeout_bundle', timeout_phase='cleaning',
)
before = time.monotonic()
with mock.patch.object(
slot, '_drain_runner_events', side_effect=lambda *_args, **_kwargs: time.sleep(1.4),
), self.assertRaises(runner.RunnerProtocolError):
slot._launch_runner(state, allow_timeout_fallback=False)
self.assertIsNotNone(process.killed_at)
self.assertLess(process.killed_at - before, 1.3)
def test_upload_retry_preserves_bundle_and_uses_actual_receipt_wait_phase(self):
class API:
def __init__(self):
self.calls = 0
def status(self, reservation_id):
return {
'reservation_id': reservation_id, 'state': 'scanning',
'resolution': None, 'expires_at': '2999-01-01T00:00:00Z',
}
def upload(self, reservation_id, path):
self.calls += 1
if self.calls == 1:
raise WorkerClientError('injected upload retry')
digest = runner.hashlib.sha256(open(path, 'rb').read()).hexdigest()
return {
'reservation_id': reservation_id, 'receipt_id': '1' * 64,
'bundle_id': 'd' * 32, 'scan_event_id': 'e' * 32,
'resolution': 'bundle_accepted', 'payload_sha256': digest,
}
with tempfile.TemporaryDirectory() as temporary:
api = API()
events = []
slot = self._slot(temporary, api=api)
slot.event_callback = events.append
pending = live_assignment()
reservation = BundleReservation.from_mapping(pending['reservation'])
with ResultBundleWriter.open(slot.bundle_root, reservation) as writer:
commit = writer.finish({'queue_status': 'done'})
slot._save({
'schema': remote_worker_client.SLOT_STATE_SCHEMA,
'phase': 'bundle_ready', 'assignment': pending,
'runner': None, 'retained_work': [], 'bundle': commit.as_dict(),
})
with self.assertRaises(WorkerClientError):
slot.step()
self.assertTrue(os.path.isfile(bundle_ready_path(slot.bundle_root, 'd' * 32)))
self.assertTrue(slot.step())
self.assertEqual(api.calls, 2)
phases = [item['phase'] for item in events]
self.assertIn('backoff', phases)
self.assertEqual(phases[-2:], ['awaiting_receipt', 'idle'])
def test_upload_request_send_and_response_wait_have_distinct_blocked_phases(self):
send_entered = threading.Event()
release_send = threading.Event()
response_entered = threading.Event()
release_response = threading.Event()
with tempfile.TemporaryDirectory() as temporary:
events = []
api = object.__new__(remote_worker_client.WorkerHTTPClient)
api.token = 'x' * 32
api.status = lambda reservation_id: {
'reservation_id': reservation_id, 'state': 'scanning',
'resolution': None, 'expires_at': '2999-01-01T00:00:00Z',
}
slot = self._slot(temporary, api=api)
slot.event_callback = events.append
pending = live_assignment()
reservation = BundleReservation.from_mapping(pending['reservation'])
with ResultBundleWriter.open(slot.bundle_root, reservation) as writer:
commit = writer.finish({'queue_status': 'done'})
path = bundle_ready_path(slot.bundle_root, reservation.bundle_id)
payload_sha256 = runner.hashlib.sha256(open(path, 'rb').read()).hexdigest()
receipt = {
'reservation_id': reservation.reservation_id,
'receipt_id': '1' * 64,
'bundle_id': reservation.bundle_id,
'scan_event_id': reservation.scan_event_id,
'resolution': 'bundle_accepted',
'payload_sha256': payload_sha256,
}
class Response:
status = 200
@staticmethod
def read(_maximum):
return runner.json.dumps(receipt).encode('utf-8')
@staticmethod
def getheader(_name):
return None
class Connection:
def putrequest(self, *_args):
pass
def putheader(self, *_args):
pass
def endheaders(self):
pass
def send(self, _chunk):
send_entered.set()
self_test.assertTrue(release_send.wait(5))
def getresponse(self):
response_entered.set()
self_test.assertTrue(release_response.wait(5))
return Response()
def close(self):
pass
self_test = self
api._connection = Connection
slot._save({
'schema': remote_worker_client.SLOT_STATE_SCHEMA,
'phase': 'bundle_ready', 'assignment': pending,
'runner': None, 'retained_work': [], 'bundle': commit.as_dict(),
})
result = []
thread = threading.Thread(target=lambda: result.append(slot.step()))
thread.start()
self.assertTrue(send_entered.wait(5))
self.assertEqual(events[-1]['phase'], 'uploading')
self.assertFalse(response_entered.is_set())
release_send.set()
self.assertTrue(response_entered.wait(5))
self.assertEqual(events[-1]['phase'], 'awaiting_receipt')
release_response.set()
thread.join(5)
self.assertEqual(result, [True])
def test_upload_send_failure_enters_backoff_before_transport_retry(self):
with tempfile.TemporaryDirectory() as temporary:
events = []
api = object.__new__(remote_worker_client.WorkerHTTPClient)
api.token = 'x' * 32
api.status = lambda reservation_id: {
'reservation_id': reservation_id, 'state': 'scanning',
'resolution': None, 'expires_at': '2999-01-01T00:00:00Z',
}
slot = self._slot(temporary, api=api)
slot.event_callback = events.append
pending = live_assignment()
reservation = BundleReservation.from_mapping(pending['reservation'])
with ResultBundleWriter.open(slot.bundle_root, reservation) as writer:
commit = writer.finish({'queue_status': 'done'})
path = bundle_ready_path(slot.bundle_root, reservation.bundle_id)
payload_sha256 = runner.hashlib.sha256(open(path, 'rb').read()).hexdigest()
attempts = [0]
class Response:
status = 200
@staticmethod
def read(_maximum):
return runner.json.dumps({
'reservation_id': reservation.reservation_id,
'receipt_id': '1' * 64,
'bundle_id': reservation.bundle_id,
'scan_event_id': reservation.scan_event_id,
'resolution': 'bundle_accepted',
'payload_sha256': payload_sha256,
}).encode('utf-8')
@staticmethod
def getheader(_name):
return None
class Connection:
def putrequest(self, *_args):
pass
def putheader(self, *_args):
pass
def endheaders(self):
pass
def send(self, _chunk):
if attempts[0] == 0:
attempts[0] += 1
raise OSError('injected send failure')
def getresponse(self):
return Response()
def close(self):
pass
api._connection = Connection
slot._save({
'schema': remote_worker_client.SLOT_STATE_SCHEMA,
'phase': 'bundle_ready', 'assignment': pending,
'runner': None, 'retained_work': [], 'bundle': commit.as_dict(),
})
with self.assertRaises(OSError):
slot.step()
self.assertEqual(events[-1]['phase'], 'backoff')
self.assertTrue(slot.step())
self.assertIn('awaiting_receipt', [item['phase'] for item in events])
if __name__ == '__main__':
unittest.main()