mirror of
https://github.com/ansible/awx.git
synced 2026-10-29 22:03:20 -02:30
Put receptor work-unit operations behind an interface with a fake
Streaming results, reading status, canceling and releasing a work unit now go through ReceptorWork instead of raw receptorctl commands. The process phase, error handling, release, same-controller adoption and the work-unit reaper take a ReceptorWork. Behavior is unchanged. awx.main.tests.fake_receptor provides FakeReceptorWork, which serves the ansible-runner worker line protocol from canned events and can break a stream partway, fail status calls or lose a unit. New functional tests drive the real reattach, dedup and finalize code against it, including one that records today's behavior when a results stream breaks: a unit that succeeded is finalized failed. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -810,9 +810,9 @@ class BaseTask(object):
|
|||||||
if not res:
|
if not res:
|
||||||
# res is None when quota exceeded or other early-return condition.
|
# res is None when quota exceeded or other early-return condition.
|
||||||
# Must release work unit here before returning, or it will leak.
|
# Must release work unit here before returning, or it will leak.
|
||||||
if receptor_job and getattr(receptor_job, 'receptor_ctl', None):
|
if receptor_job and getattr(receptor_job, 'work', None):
|
||||||
try:
|
try:
|
||||||
receptor_job._receptor_release_work(receptor_job.receptor_ctl, 'error')
|
receptor_job._receptor_release_work(receptor_job.work, 'error')
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f'Failed to release work unit {getattr(receptor_job, "unit_id", "unknown")} on early return')
|
logger.exception(f'Failed to release work unit {getattr(receptor_job, "unit_id", "unknown")} on early return')
|
||||||
return
|
return
|
||||||
@@ -857,9 +857,9 @@ class BaseTask(object):
|
|||||||
self.instance = _finalize_job_run(self.model, pk, self.runner_callback, status)
|
self.instance = _finalize_job_run(self.model, pk, self.runner_callback, status)
|
||||||
finally:
|
finally:
|
||||||
# Guarantee work unit release even if finalization throws
|
# Guarantee work unit release even if finalization throws
|
||||||
if receptor_job and getattr(receptor_job, 'receptor_ctl', None):
|
if receptor_job and getattr(receptor_job, 'work', None):
|
||||||
try:
|
try:
|
||||||
receptor_job._receptor_release_work(receptor_job.receptor_ctl, status)
|
receptor_job._receptor_release_work(receptor_job.work, status)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f'Failed to release work unit {getattr(receptor_job, "unit_id", "unknown")}')
|
logger.exception(f'Failed to release work unit {getattr(receptor_job, "unit_id", "unknown")}')
|
||||||
|
|
||||||
|
|||||||
+48
-15
@@ -181,6 +181,38 @@ def get_receptor_ctl(config_data=None):
|
|||||||
return ReceptorControl(receptor_sockfile)
|
return ReceptorControl(receptor_sockfile)
|
||||||
|
|
||||||
|
|
||||||
|
class ReceptorWork:
|
||||||
|
"""The receptor work-unit operations AWX uses after a job is submitted.
|
||||||
|
|
||||||
|
Streaming results, reading status, canceling and releasing go through this class
|
||||||
|
instead of raw receptorctl commands, so a test can stand in a fake work unit
|
||||||
|
(see awx.main.tests.fake_receptor) and drive the real streaming, adoption and
|
||||||
|
finalize code without a receptor mesh.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, receptor_ctl):
|
||||||
|
self.receptor_ctl = receptor_ctl
|
||||||
|
|
||||||
|
def list(self):
|
||||||
|
return self.receptor_ctl.simple_command('work list')
|
||||||
|
|
||||||
|
def status(self, unit_id):
|
||||||
|
return self.receptor_ctl.simple_command(f'work status {unit_id}')
|
||||||
|
|
||||||
|
def results(self, unit_id, startpos=None):
|
||||||
|
"""Return (socket, file) streaming the unit's ansible-runner output."""
|
||||||
|
kwargs = dict(return_socket=True, return_sockfile=True)
|
||||||
|
if startpos is not None:
|
||||||
|
kwargs['startpos'] = startpos
|
||||||
|
return self.receptor_ctl.get_work_results(unit_id, **kwargs)
|
||||||
|
|
||||||
|
def cancel(self, unit_id):
|
||||||
|
return self.receptor_ctl.simple_command(f'work cancel {unit_id}')
|
||||||
|
|
||||||
|
def release(self, unit_id):
|
||||||
|
return self.receptor_ctl.simple_command(f'work release {unit_id}')
|
||||||
|
|
||||||
|
|
||||||
def adopt_remote_work(receptor_ctl, node, unit_id, config_data=None):
|
def adopt_remote_work(receptor_ctl, node, unit_id, config_data=None):
|
||||||
"""Adopt a running work unit from a remote node, passing TLS/signwork when configured.
|
"""Adopt a running work unit from a remote node, passing TLS/signwork when configured.
|
||||||
|
|
||||||
@@ -446,7 +478,7 @@ class AWXReceptorJob:
|
|||||||
self.receptor_ctl = get_receptor_ctl(self.config_data)
|
self.receptor_ctl = get_receptor_ctl(self.config_data)
|
||||||
return self._run_internal(self.receptor_ctl)
|
return self._run_internal(self.receptor_ctl)
|
||||||
|
|
||||||
def _receptor_release_work(self, receptor_ctl: ReceptorControl, status: str) -> None:
|
def _receptor_release_work(self, work: ReceptorWork, status: str) -> None:
|
||||||
if self.unit_id is None:
|
if self.unit_id is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -457,14 +489,15 @@ class AWXReceptorJob:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
receptor_ctl.simple_command(f"work release {self.unit_id}")
|
work.release(self.unit_id)
|
||||||
logger.debug(f"Released work unit {self.unit_id}.")
|
logger.debug(f"Released work unit {self.unit_id}.")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f"Error releasing work unit {self.unit_id}.")
|
logger.exception(f"Error releasing work unit {self.unit_id}.")
|
||||||
|
|
||||||
def _run_internal(self, receptor_ctl):
|
def _run_internal(self, receptor_ctl):
|
||||||
|
self.work = ReceptorWork(receptor_ctl)
|
||||||
self._transmit_phase(receptor_ctl)
|
self._transmit_phase(receptor_ctl)
|
||||||
return self._process_phase(receptor_ctl)
|
return self._process_phase(self.work)
|
||||||
|
|
||||||
def _transmit_phase(self, receptor_ctl):
|
def _transmit_phase(self, receptor_ctl):
|
||||||
"""Submit work to receptor and wait for the transmit thread to finish.
|
"""Submit work to receptor and wait for the transmit thread to finish.
|
||||||
@@ -526,7 +559,7 @@ class AWXReceptorJob:
|
|||||||
if self.work_type != 'local' and os.path.exists(artifact_dir):
|
if self.work_type != 'local' and os.path.exists(artifact_dir):
|
||||||
shutil.rmtree(artifact_dir)
|
shutil.rmtree(artifact_dir)
|
||||||
|
|
||||||
def _process_phase(self, receptor_ctl):
|
def _process_phase(self, work: ReceptorWork):
|
||||||
"""Stream events from the receptor work unit via the ansible-runner process streamer.
|
"""Stream events from the receptor work unit via the ansible-runner process streamer.
|
||||||
|
|
||||||
Extracted from _run_internal() so it can be called standalone for job adoption
|
Extracted from _run_internal() so it can be called standalone for job adoption
|
||||||
@@ -536,7 +569,7 @@ class AWXReceptorJob:
|
|||||||
resultsock = None
|
resultsock = None
|
||||||
resultfile = None
|
resultfile = None
|
||||||
try:
|
try:
|
||||||
resultsock, resultfile = receptor_ctl.get_work_results(self.unit_id, return_socket=True, return_sockfile=True)
|
resultsock, resultfile = work.results(self.unit_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f'Failed to get work results for unit {self.unit_id}')
|
logger.exception(f'Failed to get work results for unit {self.unit_id}')
|
||||||
raise
|
raise
|
||||||
@@ -557,7 +590,7 @@ class AWXReceptorJob:
|
|||||||
raise SignalExit()
|
raise SignalExit()
|
||||||
res = processor_future.result()
|
res = processor_future.result()
|
||||||
except SignalExit:
|
except SignalExit:
|
||||||
receptor_ctl.simple_command(f"work cancel {self.unit_id}")
|
work.cancel(self.unit_id)
|
||||||
if resultsock:
|
if resultsock:
|
||||||
try:
|
try:
|
||||||
resultsock.shutdown(socket.SHUT_RDWR)
|
resultsock.shutdown(socket.SHUT_RDWR)
|
||||||
@@ -571,11 +604,11 @@ class AWXReceptorJob:
|
|||||||
signal_state.raise_exception = False
|
signal_state.raise_exception = False
|
||||||
|
|
||||||
if res.status == 'error':
|
if res.status == 'error':
|
||||||
return self._handle_work_error(receptor_ctl, res)
|
return self._handle_work_error(work, res)
|
||||||
|
|
||||||
return res
|
return res
|
||||||
|
|
||||||
def _handle_work_error(self, receptor_ctl, res):
|
def _handle_work_error(self, work: ReceptorWork, res):
|
||||||
"""Handle the error path from _process_phase when the work unit status is 'error'."""
|
"""Handle the error path from _process_phase when the work unit status is 'error'."""
|
||||||
# If ansible-runner ran, but an error occured at runtime, the traceback information
|
# If ansible-runner ran, but an error occured at runtime, the traceback information
|
||||||
# is saved via the status_handler passed in to the processor.
|
# is saved via the status_handler passed in to the processor.
|
||||||
@@ -583,7 +616,7 @@ class AWXReceptorJob:
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
try:
|
try:
|
||||||
unit_status = receptor_ctl.simple_command(f'work status {self.unit_id}')
|
unit_status = work.status(self.unit_id)
|
||||||
detail = unit_status.get('Detail') or ''
|
detail = unit_status.get('Detail') or ''
|
||||||
state_name = unit_status.get('StateName', None)
|
state_name = unit_status.get('StateName', None)
|
||||||
stdout_size = unit_status.get('StdoutSize', 0)
|
stdout_size = unit_status.get('StdoutSize', 0)
|
||||||
@@ -607,7 +640,7 @@ class AWXReceptorJob:
|
|||||||
# contain useful information about why the job failed. In case stdout is
|
# contain useful information about why the job failed. In case stdout is
|
||||||
# massive, only ask for last 1000 bytes
|
# massive, only ask for last 1000 bytes
|
||||||
startpos = max(stdout_size - 1000, 0)
|
startpos = max(stdout_size - 1000, 0)
|
||||||
_resultsock, resultfile = receptor_ctl.get_work_results(self.unit_id, startpos=startpos, return_socket=True, return_sockfile=True)
|
_resultsock, resultfile = work.results(self.unit_id, startpos=startpos)
|
||||||
lines = resultfile.readlines()
|
lines = resultfile.readlines()
|
||||||
receptor_output = b"".join(lines).decode()
|
receptor_output = b"".join(lines).decode()
|
||||||
_resultsock.shutdown(socket.SHUT_RDWR)
|
_resultsock.shutdown(socket.SHUT_RDWR)
|
||||||
@@ -965,7 +998,7 @@ def _compute_adoption_dedup(job):
|
|||||||
return safe_threshold, collision_zone
|
return safe_threshold, collision_zone
|
||||||
|
|
||||||
|
|
||||||
def reattach_to_work_unit(job, receptor_ctl):
|
def reattach_to_work_unit(job, work: ReceptorWork):
|
||||||
"""Reconnect to a receptor work unit and stream events in real-time until it completes.
|
"""Reconnect to a receptor work unit and stream events in real-time until it completes.
|
||||||
|
|
||||||
Reconstructs the minimal process-phase context from the DB job record, then calls
|
Reconstructs the minimal process-phase context from the DB job record, then calls
|
||||||
@@ -980,7 +1013,7 @@ def reattach_to_work_unit(job, receptor_ctl):
|
|||||||
|
|
||||||
# Check state for logging — no longer a gate. We stream regardless.
|
# Check state for logging — no longer a gate. We stream regardless.
|
||||||
try:
|
try:
|
||||||
unit_status = receptor_ctl.simple_command(f'work status {unit_id}')
|
unit_status = work.status(unit_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning(f'Cannot get receptor status for work unit {unit_id} (job {job.id}), deferring adoption')
|
logger.warning(f'Cannot get receptor status for work unit {unit_id} (job {job.id}), deferring adoption')
|
||||||
return False
|
return False
|
||||||
@@ -1011,7 +1044,7 @@ def reattach_to_work_unit(job, receptor_ctl):
|
|||||||
process_phase_failed = False
|
process_phase_failed = False
|
||||||
res = None
|
res = None
|
||||||
try:
|
try:
|
||||||
res = receptor_job._process_phase(receptor_ctl) # blocks until unit completes
|
res = receptor_job._process_phase(work) # blocks until unit completes
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f'Adoption process phase failed for job {job.id} (unit={unit_id})')
|
logger.exception(f'Adoption process phase failed for job {job.id} (unit={unit_id})')
|
||||||
process_phase_failed = True
|
process_phase_failed = True
|
||||||
@@ -1031,7 +1064,7 @@ def reattach_to_work_unit(job, receptor_ctl):
|
|||||||
else:
|
else:
|
||||||
# _process_phase raised — fall back to work status for exit code
|
# _process_phase raised — fall back to work status for exit code
|
||||||
try:
|
try:
|
||||||
unit_status = receptor_ctl.simple_command(f'work status {unit_id}')
|
unit_status = work.status(unit_id)
|
||||||
state_name = unit_status.get('StateName', '')
|
state_name = unit_status.get('StateName', '')
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug(f'Could not get final status for {unit_id} after process phase failure')
|
logger.debug(f'Could not get final status for {unit_id} after process phase failure')
|
||||||
@@ -1040,7 +1073,7 @@ def reattach_to_work_unit(job, receptor_ctl):
|
|||||||
exit_code = _get_adoption_exit_code(unit_status, state_name)
|
exit_code = _get_adoption_exit_code(unit_status, state_name)
|
||||||
_finalize_adopted_job(job, callback, exit_code, process_phase_failed)
|
_finalize_adopted_job(job, callback, exit_code, process_phase_failed)
|
||||||
finally:
|
finally:
|
||||||
receptor_job._receptor_release_work(receptor_ctl, getattr(res, 'status', 'error'))
|
receptor_job._receptor_release_work(work, getattr(res, 'status', 'error'))
|
||||||
shutil.rmtree(private_data_dir, ignore_errors=True)
|
shutil.rmtree(private_data_dir, ignore_errors=True)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ from awx.main.tasks.adoption_decisions import (
|
|||||||
from awx.main.tasks.helpers import is_run_threshold_reached
|
from awx.main.tasks.helpers import is_run_threshold_reached
|
||||||
from awx.main.tasks.host_indirect import save_indirect_host_entries
|
from awx.main.tasks.host_indirect import save_indirect_host_entries
|
||||||
from awx.main.tasks.receptor import (
|
from awx.main.tasks.receptor import (
|
||||||
|
ReceptorWork,
|
||||||
administrative_workunit_reaper,
|
administrative_workunit_reaper,
|
||||||
get_receptor_ctl,
|
get_receptor_ctl,
|
||||||
reattach_to_work_unit,
|
reattach_to_work_unit,
|
||||||
@@ -1025,7 +1026,7 @@ def adopt_job_async(job_id):
|
|||||||
|
|
||||||
receptor_ctl = get_receptor_ctl()
|
receptor_ctl = get_receptor_ctl()
|
||||||
try:
|
try:
|
||||||
reattach_to_work_unit(job, receptor_ctl)
|
reattach_to_work_unit(job, ReceptorWork(receptor_ctl))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f'adopt_job_async: adoption failed for job {job.id} (unit={job.work_unit_id})')
|
logger.exception(f'adopt_job_async: adoption failed for job {job.id} (unit={job.work_unit_id})')
|
||||||
finally:
|
finally:
|
||||||
@@ -1063,8 +1064,9 @@ def awx_receptor_workunit_reaper():
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.info('Receptorctl sockfile not found for workunit reaper, doing nothing')
|
logger.info('Receptorctl sockfile not found for workunit reaper, doing nothing')
|
||||||
return
|
return
|
||||||
|
work = ReceptorWork(receptor_ctl)
|
||||||
try:
|
try:
|
||||||
receptor_work_list = receptor_ctl.simple_command("work list")
|
receptor_work_list = work.list()
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
logger.info(f'Error getting work list for workunit reaper, error: {str(exc)}')
|
logger.info(f'Error getting work list for workunit reaper, error: {str(exc)}')
|
||||||
return
|
return
|
||||||
@@ -1075,8 +1077,8 @@ def awx_receptor_workunit_reaper():
|
|||||||
jobs_with_unreleased_receptor_units = jobs_with_unreleased_receptor_units.exclude(status__in=ERROR_STATES)
|
jobs_with_unreleased_receptor_units = jobs_with_unreleased_receptor_units.exclude(status__in=ERROR_STATES)
|
||||||
for job in jobs_with_unreleased_receptor_units:
|
for job in jobs_with_unreleased_receptor_units:
|
||||||
logger.debug(f"{job.log_format} is not active, reaping receptor work unit {job.work_unit_id}")
|
logger.debug(f"{job.log_format} is not active, reaping receptor work unit {job.work_unit_id}")
|
||||||
receptor_ctl.simple_command(f"work cancel {job.work_unit_id}")
|
work.cancel(job.work_unit_id)
|
||||||
receptor_ctl.simple_command(f"work release {job.work_unit_id}")
|
work.release(job.work_unit_id)
|
||||||
|
|
||||||
administrative_workunit_reaper(receptor_work_list)
|
administrative_workunit_reaper(receptor_work_list)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""In-memory stand-ins for receptor work units, for testing streaming and adoption without a mesh.
|
||||||
|
|
||||||
|
FakeReceptorWork implements the ReceptorWork interface. Each unit holds the events its
|
||||||
|
playbook produced and the state receptor reports. results() returns the same line
|
||||||
|
protocol the ansible-runner worker writes, so AWX's real process-phase code parses it.
|
||||||
|
|
||||||
|
Failures are injected per unit:
|
||||||
|
- break_after=N: the stream ends after N events with no status or eof, as when the
|
||||||
|
connection to the execution node drops.
|
||||||
|
- status_error: status() raises, as when the node is unreachable.
|
||||||
|
- released units disappear, and releasing or reading an unknown unit raises like receptor.
|
||||||
|
|
||||||
|
RecordingDispatcher replaces CallbackQueueDispatcher so tests can see the events that
|
||||||
|
would have gone to the callback receiver.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from awx.main.tasks.receptor import ReceptorWork
|
||||||
|
|
||||||
|
TERMINAL_STATE = {'successful': 'Succeeded', 'failed': 'Failed', 'canceled': 'Failed', 'error': 'Failed'}
|
||||||
|
|
||||||
|
|
||||||
|
def make_events(count, failed_tasks=()):
|
||||||
|
"""Return `count` runner events shaped like a playbook run: start, tasks, then stats."""
|
||||||
|
events = []
|
||||||
|
for counter in range(1, count + 1):
|
||||||
|
if counter == 1:
|
||||||
|
event = 'playbook_on_start'
|
||||||
|
elif counter == count:
|
||||||
|
event = 'playbook_on_stats'
|
||||||
|
elif counter in failed_tasks:
|
||||||
|
event = 'runner_on_failed'
|
||||||
|
else:
|
||||||
|
event = 'runner_on_ok'
|
||||||
|
events.append(
|
||||||
|
{
|
||||||
|
'uuid': str(uuid.uuid4()),
|
||||||
|
'counter': counter,
|
||||||
|
'event': event,
|
||||||
|
'stdout': f'line {counter}',
|
||||||
|
'start_line': counter - 1,
|
||||||
|
'end_line': counter,
|
||||||
|
'event_data': {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWorkUnit:
|
||||||
|
def __init__(self, events=(), status='successful', state=None, exit_code=None, detail='', break_after=None, status_error=None):
|
||||||
|
self.events = list(events)
|
||||||
|
self.status = status
|
||||||
|
self.state = state or TERMINAL_STATE.get(status, 'Succeeded')
|
||||||
|
self.exit_code = exit_code
|
||||||
|
self.detail = detail
|
||||||
|
self.break_after = break_after
|
||||||
|
self.status_error = status_error
|
||||||
|
|
||||||
|
def stream(self):
|
||||||
|
lines = []
|
||||||
|
events = self.events if self.break_after is None else self.events[: self.break_after]
|
||||||
|
for event in events:
|
||||||
|
lines.append(json.dumps(event))
|
||||||
|
if self.break_after is None:
|
||||||
|
lines.append(json.dumps({'status': self.status, 'runner_ident': 'fake'}))
|
||||||
|
lines.append(json.dumps({'eof': True}))
|
||||||
|
return ('\n'.join(lines) + '\n').encode() if lines else b''
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSocket:
|
||||||
|
def __init__(self):
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def shutdown(self, how):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
class FakeReceptorWork(ReceptorWork):
|
||||||
|
def __init__(self, units=None):
|
||||||
|
super().__init__(receptor_ctl=None)
|
||||||
|
self.units = dict(units or {})
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def _unit(self, unit_id):
|
||||||
|
if unit_id not in self.units:
|
||||||
|
raise RuntimeError(f'ERROR: unknown work unit {unit_id}')
|
||||||
|
return self.units[unit_id]
|
||||||
|
|
||||||
|
def list(self):
|
||||||
|
self.calls.append(('list',))
|
||||||
|
return {unit_id: {'StateName': unit.state, 'ExtraData': {}} for unit_id, unit in self.units.items()}
|
||||||
|
|
||||||
|
def status(self, unit_id):
|
||||||
|
self.calls.append(('status', unit_id))
|
||||||
|
unit = self._unit(unit_id)
|
||||||
|
if unit.status_error:
|
||||||
|
raise unit.status_error
|
||||||
|
status = {'StateName': unit.state, 'Detail': unit.detail, 'StdoutSize': len(unit.stream())}
|
||||||
|
if unit.exit_code is not None:
|
||||||
|
status['ExitCode'] = unit.exit_code
|
||||||
|
return status
|
||||||
|
|
||||||
|
def results(self, unit_id, startpos=None):
|
||||||
|
self.calls.append(('results', unit_id, startpos))
|
||||||
|
data = self._unit(unit_id).stream()
|
||||||
|
return FakeSocket(), io.BytesIO(data[startpos or 0 :])
|
||||||
|
|
||||||
|
def cancel(self, unit_id):
|
||||||
|
self.calls.append(('cancel', unit_id))
|
||||||
|
self._unit(unit_id).state = 'Failed'
|
||||||
|
|
||||||
|
def release(self, unit_id):
|
||||||
|
self.calls.append(('release', unit_id))
|
||||||
|
self._unit(unit_id)
|
||||||
|
del self.units[unit_id]
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingDispatcher:
|
||||||
|
"""Collects callback-receiver messages instead of sending them to Redis."""
|
||||||
|
|
||||||
|
dispatched = []
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def dispatch(self, obj):
|
||||||
|
RecordingDispatcher.dispatched.append(obj)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def reset(cls):
|
||||||
|
cls.dispatched = []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def counters(cls):
|
||||||
|
return [e['counter'] for e in cls.dispatched if 'counter' in e]
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""Adoption run end to end against a fake receptor work unit.
|
||||||
|
|
||||||
|
These tests drive the real reattach, streaming, dedup and finalize code. Only receptor
|
||||||
|
(FakeReceptorWork) and the callback-receiver queue (RecordingDispatcher) are replaced.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from awx.main.models import Job, JobEvent
|
||||||
|
from awx.main.tasks.receptor import reattach_to_work_unit
|
||||||
|
from awx.main.tasks.system import awx_receptor_workunit_reaper
|
||||||
|
from awx.main.tests.fake_receptor import FakeReceptorWork, FakeWorkUnit, RecordingDispatcher, make_events
|
||||||
|
|
||||||
|
UNIT = 'unit-1'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def adoption_env(settings, tmp_path):
|
||||||
|
settings.AWX_ISOLATION_BASE_PATH = str(tmp_path)
|
||||||
|
RecordingDispatcher.reset()
|
||||||
|
# connections.close_all() would end the test's transaction; events go to the recorder instead of Redis.
|
||||||
|
with (
|
||||||
|
mock.patch('awx.main.tasks.receptor.connections'),
|
||||||
|
mock.patch('awx.main.tasks.callback.CallbackQueueDispatcher', RecordingDispatcher),
|
||||||
|
mock.patch('awx.main.tasks.jobs.ScheduleTaskManager'),
|
||||||
|
mock.patch('awx.main.tasks.jobs.ScheduleWorkflowManager'),
|
||||||
|
mock.patch('awx.main.models.unified_jobs.UnifiedJob.websocket_emit_status'),
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def running_job(me_inst):
|
||||||
|
return Job.objects.create(controller_node=me_inst.hostname, execution_node='receptor-1', status='running', work_unit_id=UNIT)
|
||||||
|
|
||||||
|
|
||||||
|
def save_events(job, counters):
|
||||||
|
for counter in counters:
|
||||||
|
JobEvent.objects.create(job=job, counter=counter, event='runner_on_ok', job_created=job.created)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_finished_unit_is_replayed_and_finalized(running_job):
|
||||||
|
work = FakeReceptorWork({UNIT: FakeWorkUnit(make_events(30))})
|
||||||
|
|
||||||
|
assert reattach_to_work_unit(running_job, work) is True
|
||||||
|
|
||||||
|
running_job.refresh_from_db()
|
||||||
|
assert running_job.status == 'successful'
|
||||||
|
assert RecordingDispatcher.counters() == list(range(1, 31))
|
||||||
|
assert UNIT not in work.units, 'work unit released after finalize'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_events_already_saved_are_not_dispatched_again(running_job):
|
||||||
|
save_events(running_job, range(1, 21))
|
||||||
|
work = FakeReceptorWork({UNIT: FakeWorkUnit(make_events(30))})
|
||||||
|
|
||||||
|
reattach_to_work_unit(running_job, work)
|
||||||
|
|
||||||
|
assert RecordingDispatcher.counters() == list(range(21, 31))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_events_saved_out_of_order_are_skipped(running_job):
|
||||||
|
# Parallel callback workers can commit counter 13 before 11 and 12.
|
||||||
|
save_events(running_job, [*range(1, 11), 13])
|
||||||
|
work = FakeReceptorWork({UNIT: FakeWorkUnit(make_events(20))})
|
||||||
|
|
||||||
|
reattach_to_work_unit(running_job, work)
|
||||||
|
|
||||||
|
assert RecordingDispatcher.counters() == [11, 12, *range(14, 21)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_failed_playbook_finalizes_failed(running_job):
|
||||||
|
work = FakeReceptorWork({UNIT: FakeWorkUnit(make_events(10, failed_tasks=[5]), status='failed', exit_code=2)})
|
||||||
|
|
||||||
|
reattach_to_work_unit(running_job, work)
|
||||||
|
|
||||||
|
running_job.refresh_from_db()
|
||||||
|
assert running_job.status == 'failed'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_unit_still_running_is_deferred(running_job):
|
||||||
|
work = FakeReceptorWork({UNIT: FakeWorkUnit(make_events(10), state='Running')})
|
||||||
|
|
||||||
|
assert reattach_to_work_unit(running_job, work) is False
|
||||||
|
|
||||||
|
running_job.refresh_from_db()
|
||||||
|
assert running_job.status == 'running'
|
||||||
|
assert RecordingDispatcher.dispatched == []
|
||||||
|
assert UNIT in work.units
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_unit_gone_is_deferred(running_job):
|
||||||
|
# The unit was released, or the node lost it, before adoption.
|
||||||
|
work = FakeReceptorWork()
|
||||||
|
|
||||||
|
assert reattach_to_work_unit(running_job, work) is False
|
||||||
|
|
||||||
|
running_job.refresh_from_db()
|
||||||
|
assert running_job.status == 'running'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_stream_broken_mid_replay_fails_a_successful_unit(running_job):
|
||||||
|
# The unit succeeded, but the results stream ends after 12 of 30 events.
|
||||||
|
# Today the job is finalized failed, with only the events read before the break.
|
||||||
|
work = FakeReceptorWork({UNIT: FakeWorkUnit(make_events(30), break_after=12)})
|
||||||
|
|
||||||
|
reattach_to_work_unit(running_job, work)
|
||||||
|
|
||||||
|
running_job.refresh_from_db()
|
||||||
|
assert running_job.status == 'failed'
|
||||||
|
assert RecordingDispatcher.counters() == list(range(1, 13))
|
||||||
|
assert 'Unexpected empty line' in running_job.job_explanation
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_workunit_reaper_releases_units_of_finished_jobs(me_inst, settings):
|
||||||
|
settings.RECEPTOR_RELEASE_WORK = True
|
||||||
|
finished = Job.objects.create(controller_node=me_inst.hostname, status='successful', work_unit_id='done')
|
||||||
|
running = Job.objects.create(controller_node=me_inst.hostname, status='running', work_unit_id='live')
|
||||||
|
work = FakeReceptorWork({'done': FakeWorkUnit(), 'live': FakeWorkUnit(state='Running')})
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch('awx.main.tasks.system.get_receptor_ctl'),
|
||||||
|
mock.patch('awx.main.tasks.system.ReceptorWork', return_value=work),
|
||||||
|
mock.patch('awx.main.tasks.system.administrative_workunit_reaper'),
|
||||||
|
):
|
||||||
|
awx_receptor_workunit_reaper()
|
||||||
|
|
||||||
|
assert ('cancel', finished.work_unit_id) in work.calls
|
||||||
|
assert finished.work_unit_id not in work.units
|
||||||
|
assert running.work_unit_id in work.units
|
||||||
@@ -11,6 +11,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from awx.main.tasks.adoption_decisions import JobAction
|
from awx.main.tasks.adoption_decisions import JobAction
|
||||||
|
from awx.main.tasks.receptor import ReceptorWork
|
||||||
from awx.main.tasks.system import (
|
from awx.main.tasks.system import (
|
||||||
CleanupImagesAndFiles,
|
CleanupImagesAndFiles,
|
||||||
execution_node_health_check,
|
execution_node_health_check,
|
||||||
@@ -804,7 +805,7 @@ def test_adoption_finalizes_successful_job(me_inst):
|
|||||||
mock_instance._process_phase.return_value = MagicMock(status='successful', rc=0)
|
mock_instance._process_phase.return_value = MagicMock(status='successful', rc=0)
|
||||||
mock_job_cls.return_value = mock_instance
|
mock_job_cls.return_value = mock_instance
|
||||||
|
|
||||||
reattach_to_work_unit(job, ctl)
|
reattach_to_work_unit(job, ReceptorWork(ctl))
|
||||||
|
|
||||||
job.refresh_from_db()
|
job.refresh_from_db()
|
||||||
assert job.status == 'successful'
|
assert job.status == 'successful'
|
||||||
@@ -827,7 +828,7 @@ def test_adoption_finalizes_failed_job(me_inst):
|
|||||||
mock_instance._process_phase.return_value = MagicMock(status='failed', rc=1)
|
mock_instance._process_phase.return_value = MagicMock(status='failed', rc=1)
|
||||||
mock_job_cls.return_value = mock_instance
|
mock_job_cls.return_value = mock_instance
|
||||||
|
|
||||||
reattach_to_work_unit(job, ctl)
|
reattach_to_work_unit(job, ReceptorWork(ctl))
|
||||||
|
|
||||||
job.refresh_from_db()
|
job.refresh_from_db()
|
||||||
assert job.status == 'failed'
|
assert job.status == 'failed'
|
||||||
@@ -1107,7 +1108,7 @@ def test_reattach_receptor_command_fails(me_inst):
|
|||||||
ctl = MagicMock()
|
ctl = MagicMock()
|
||||||
ctl.simple_command.side_effect = Exception('connection refused')
|
ctl.simple_command.side_effect = Exception('connection refused')
|
||||||
|
|
||||||
result = reattach_to_work_unit(job, ctl)
|
result = reattach_to_work_unit(job, ReceptorWork(ctl))
|
||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
job.refresh_from_db()
|
job.refresh_from_db()
|
||||||
@@ -1128,7 +1129,7 @@ def test_reattach_exit_code_from_detail(me_inst):
|
|||||||
patch('awx.main.tasks.callback.RunnerCallback'),
|
patch('awx.main.tasks.callback.RunnerCallback'),
|
||||||
):
|
):
|
||||||
mock_job_cls.return_value = MagicMock()
|
mock_job_cls.return_value = MagicMock()
|
||||||
reattach_to_work_unit(job, ctl)
|
reattach_to_work_unit(job, ReceptorWork(ctl))
|
||||||
|
|
||||||
job.refresh_from_db()
|
job.refresh_from_db()
|
||||||
assert job.status == 'failed'
|
assert job.status == 'failed'
|
||||||
@@ -1150,7 +1151,7 @@ def test_reattach_exit_code_fallback_succeeded(me_inst):
|
|||||||
mock_instance = MagicMock()
|
mock_instance = MagicMock()
|
||||||
mock_instance._process_phase.return_value = MagicMock(status='successful', rc=0)
|
mock_instance._process_phase.return_value = MagicMock(status='successful', rc=0)
|
||||||
mock_job_cls.return_value = mock_instance
|
mock_job_cls.return_value = mock_instance
|
||||||
reattach_to_work_unit(job, ctl)
|
reattach_to_work_unit(job, ReceptorWork(ctl))
|
||||||
|
|
||||||
job.refresh_from_db()
|
job.refresh_from_db()
|
||||||
assert job.status == 'successful'
|
assert job.status == 'successful'
|
||||||
@@ -1170,7 +1171,7 @@ def test_reattach_exit_code_fallback_failed(me_inst):
|
|||||||
patch('awx.main.tasks.callback.RunnerCallback'),
|
patch('awx.main.tasks.callback.RunnerCallback'),
|
||||||
):
|
):
|
||||||
mock_job_cls.return_value = MagicMock()
|
mock_job_cls.return_value = MagicMock()
|
||||||
reattach_to_work_unit(job, ctl)
|
reattach_to_work_unit(job, ReceptorWork(ctl))
|
||||||
|
|
||||||
job.refresh_from_db()
|
job.refresh_from_db()
|
||||||
assert job.status == 'failed'
|
assert job.status == 'failed'
|
||||||
@@ -1192,7 +1193,7 @@ def test_reattach_process_phase_raises(me_inst):
|
|||||||
mock_instance = MagicMock()
|
mock_instance = MagicMock()
|
||||||
mock_instance._process_phase.side_effect = RuntimeError('boom')
|
mock_instance._process_phase.side_effect = RuntimeError('boom')
|
||||||
mock_job_cls.return_value = mock_instance
|
mock_job_cls.return_value = mock_instance
|
||||||
result = reattach_to_work_unit(job, ctl)
|
result = reattach_to_work_unit(job, ReceptorWork(ctl))
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
job.refresh_from_db()
|
job.refresh_from_db()
|
||||||
@@ -1218,7 +1219,7 @@ def test_reattach_job_already_finalized(me_inst):
|
|||||||
mock_instance = MagicMock()
|
mock_instance = MagicMock()
|
||||||
mock_instance._process_phase.side_effect = _finalize_in_db
|
mock_instance._process_phase.side_effect = _finalize_in_db
|
||||||
mock_job_cls.return_value = mock_instance
|
mock_job_cls.return_value = mock_instance
|
||||||
reattach_to_work_unit(job, ctl)
|
reattach_to_work_unit(job, ReceptorWork(ctl))
|
||||||
|
|
||||||
job.refresh_from_db()
|
job.refresh_from_db()
|
||||||
assert job.status == 'successful'
|
assert job.status == 'successful'
|
||||||
@@ -1236,7 +1237,10 @@ def test_adopt_job_async_calls_reattach(me_inst, settings):
|
|||||||
adopt_job_async(job.id)
|
adopt_job_async(job.id)
|
||||||
|
|
||||||
mock_ctl_factory.assert_called_once()
|
mock_ctl_factory.assert_called_once()
|
||||||
mock_reattach.assert_called_once_with(job, mock_ctl_factory.return_value)
|
mock_reattach.assert_called_once()
|
||||||
|
called_job, work = mock_reattach.call_args.args
|
||||||
|
assert called_job == job
|
||||||
|
assert work.receptor_ctl is mock_ctl_factory.return_value
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|||||||
@@ -682,41 +682,41 @@ def test_work_unit_released_on_quota_exceeded():
|
|||||||
Scenario: Job hits quota limit → _handle_work_error returns None →
|
Scenario: Job hits quota limit → _handle_work_error returns None →
|
||||||
BaseTask.run() early-returns. Work unit MUST be released.
|
BaseTask.run() early-returns. Work unit MUST be released.
|
||||||
"""
|
"""
|
||||||
receptor_ctl = mock.MagicMock()
|
work = mock.MagicMock()
|
||||||
receptor_job = mock.MagicMock()
|
receptor_job = mock.MagicMock()
|
||||||
receptor_job.unit_id = "test-unit-123"
|
receptor_job.unit_id = "test-unit-123"
|
||||||
receptor_job.receptor_ctl = receptor_ctl
|
receptor_job.work = work
|
||||||
|
|
||||||
# When res is None (quota exceeded case)
|
# When res is None (quota exceeded case)
|
||||||
res = None
|
res = None
|
||||||
|
|
||||||
# This is the fixed code path from jobs.py lines 810-818
|
# This is the fixed code path from jobs.py lines 810-818
|
||||||
if not res:
|
if not res:
|
||||||
if receptor_job and getattr(receptor_job, 'receptor_ctl', None):
|
if receptor_job and getattr(receptor_job, 'work', None):
|
||||||
try:
|
try:
|
||||||
receptor_job._receptor_release_work(receptor_job.receptor_ctl, 'error')
|
receptor_job._receptor_release_work(receptor_job.work, 'error')
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Verify release was called
|
# Verify release was called
|
||||||
receptor_job._receptor_release_work.assert_called_once_with(receptor_ctl, 'error')
|
receptor_job._receptor_release_work.assert_called_once_with(work, 'error')
|
||||||
|
|
||||||
|
|
||||||
def test_work_unit_not_released_if_no_receptor_ctl():
|
def test_work_unit_not_released_if_no_receptor_work():
|
||||||
"""
|
"""
|
||||||
Edge case: receptor_ctl not set (transmit phase failed before unit submission).
|
Edge case: work not set (transmit phase failed before unit submission).
|
||||||
|
|
||||||
Verify graceful handling when receptor_ctl is None.
|
Verify graceful handling when work is None.
|
||||||
"""
|
"""
|
||||||
receptor_job = mock.MagicMock(spec=['unit_id', '_receptor_release_work'])
|
receptor_job = mock.MagicMock(spec=['unit_id', '_receptor_release_work'])
|
||||||
receptor_job.receptor_ctl = None # Not set
|
receptor_job.work = None # Not set
|
||||||
|
|
||||||
res = None
|
res = None
|
||||||
|
|
||||||
# Code should not crash
|
# Code should not crash
|
||||||
if not res:
|
if not res:
|
||||||
if receptor_job and getattr(receptor_job, 'receptor_ctl', None):
|
if receptor_job and getattr(receptor_job, 'work', None):
|
||||||
receptor_job._receptor_release_work(receptor_job.receptor_ctl, 'error')
|
receptor_job._receptor_release_work(receptor_job.work, 'error')
|
||||||
# else: No release needed, unit was never submitted
|
# else: No release needed, unit was never submitted
|
||||||
|
|
||||||
# Verify _receptor_release_work was NOT called
|
# Verify _receptor_release_work was NOT called
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from awx.main.tasks.callback import RunnerCallback
|
|||||||
from awx.main.tasks.jobs import _finalize_job_run
|
from awx.main.tasks.jobs import _finalize_job_run
|
||||||
from awx.main.tasks.receptor import (
|
from awx.main.tasks.receptor import (
|
||||||
AWXReceptorJob,
|
AWXReceptorJob,
|
||||||
|
ReceptorWork,
|
||||||
_AdoptionTask,
|
_AdoptionTask,
|
||||||
_compute_adoption_dedup,
|
_compute_adoption_dedup,
|
||||||
_finalize_adopted_job,
|
_finalize_adopted_job,
|
||||||
@@ -127,7 +128,7 @@ def test_process_phase_success(mock_signal, mock_connections):
|
|||||||
rj.processor = Mock(return_value=expected_res)
|
rj.processor = Mock(return_value=expected_res)
|
||||||
ctl = _make_receptor_ctl()
|
ctl = _make_receptor_ctl()
|
||||||
|
|
||||||
res = rj._process_phase(ctl)
|
res = rj._process_phase(ReceptorWork(ctl))
|
||||||
|
|
||||||
assert res.status == 'successful'
|
assert res.status == 'successful'
|
||||||
mock_connections.close_all.assert_called_once()
|
mock_connections.close_all.assert_called_once()
|
||||||
@@ -145,7 +146,7 @@ def test_process_phase_signal_exit(mock_signal, mock_connections):
|
|||||||
rj.processor = Mock()
|
rj.processor = Mock()
|
||||||
ctl = _make_receptor_ctl()
|
ctl = _make_receptor_ctl()
|
||||||
|
|
||||||
res = rj._process_phase(ctl)
|
res = rj._process_phase(ReceptorWork(ctl))
|
||||||
|
|
||||||
assert res.status == 'canceled'
|
assert res.status == 'canceled'
|
||||||
ctl.simple_command.assert_any_call('work cancel unit-1')
|
ctl.simple_command.assert_any_call('work cancel unit-1')
|
||||||
@@ -161,7 +162,7 @@ def test_handle_work_error_result_traceback_present():
|
|||||||
ctl = _make_receptor_ctl()
|
ctl = _make_receptor_ctl()
|
||||||
err_res = _Result(status='error', rc=1)
|
err_res = _Result(status='error', rc=1)
|
||||||
|
|
||||||
result = rj._handle_work_error(ctl, err_res)
|
result = rj._handle_work_error(ReceptorWork(ctl), err_res)
|
||||||
|
|
||||||
assert result is err_res
|
assert result is err_res
|
||||||
ctl.simple_command.assert_not_called()
|
ctl.simple_command.assert_not_called()
|
||||||
@@ -179,7 +180,7 @@ def test_handle_work_error_status_command_raises():
|
|||||||
err_res = _Result(status='error', rc=1)
|
err_res = _Result(status='error', rc=1)
|
||||||
|
|
||||||
# should not raise; falls through and returns res
|
# should not raise; falls through and returns res
|
||||||
result = rj._handle_work_error(ctl, err_res)
|
result = rj._handle_work_error(ReceptorWork(ctl), err_res)
|
||||||
assert result is err_res
|
assert result is err_res
|
||||||
|
|
||||||
|
|
||||||
@@ -193,7 +194,7 @@ def test_handle_work_error_exceeded_quota():
|
|||||||
ctl = _make_receptor_ctl(detail='exceeded quota for namespace')
|
ctl = _make_receptor_ctl(detail='exceeded quota for namespace')
|
||||||
err_res = _Result(status='error', rc=1)
|
err_res = _Result(status='error', rc=1)
|
||||||
|
|
||||||
result = rj._handle_work_error(ctl, err_res)
|
result = rj._handle_work_error(ReceptorWork(ctl), err_res)
|
||||||
|
|
||||||
assert result is None
|
assert result is None
|
||||||
rj.task.update_model.assert_called_once_with(1, status='pending')
|
rj.task.update_model.assert_called_once_with(1, status='pending')
|
||||||
@@ -209,7 +210,7 @@ def test_handle_work_error_reads_receptor_output_on_failed():
|
|||||||
ctl = _make_receptor_ctl(state='Failed', stdout_size=2000)
|
ctl = _make_receptor_ctl(state='Failed', stdout_size=2000)
|
||||||
err_res = _Result(status='error', rc=1)
|
err_res = _Result(status='error', rc=1)
|
||||||
|
|
||||||
rj._handle_work_error(ctl, err_res)
|
rj._handle_work_error(ReceptorWork(ctl), err_res)
|
||||||
|
|
||||||
rj.task.runner_callback.delay_update.assert_called_once()
|
rj.task.runner_callback.delay_update.assert_called_once()
|
||||||
call_kwargs = rj.task.runner_callback.delay_update.call_args[1]
|
call_kwargs = rj.task.runner_callback.delay_update.call_args[1]
|
||||||
@@ -226,7 +227,7 @@ def test_handle_work_error_detail_only():
|
|||||||
ctl = _make_receptor_ctl(state='Succeeded', detail='some error detail')
|
ctl = _make_receptor_ctl(state='Succeeded', detail='some error detail')
|
||||||
err_res = _Result(status='error', rc=1)
|
err_res = _Result(status='error', rc=1)
|
||||||
|
|
||||||
rj._handle_work_error(ctl, err_res)
|
rj._handle_work_error(ReceptorWork(ctl), err_res)
|
||||||
|
|
||||||
rj.task.runner_callback.delay_update.assert_called_once()
|
rj.task.runner_callback.delay_update.assert_called_once()
|
||||||
call_kwargs = rj.task.runner_callback.delay_update.call_args[1]
|
call_kwargs = rj.task.runner_callback.delay_update.call_args[1]
|
||||||
@@ -243,7 +244,7 @@ def test_handle_work_error_no_detail_no_output():
|
|||||||
ctl = _make_receptor_ctl(state='Succeeded', detail='')
|
ctl = _make_receptor_ctl(state='Succeeded', detail='')
|
||||||
err_res = _Result(status='error', rc=1)
|
err_res = _Result(status='error', rc=1)
|
||||||
|
|
||||||
result = rj._handle_work_error(ctl, err_res)
|
result = rj._handle_work_error(ReceptorWork(ctl), err_res)
|
||||||
|
|
||||||
assert result is err_res
|
assert result is err_res
|
||||||
rj.task.runner_callback.delay_update.assert_not_called()
|
rj.task.runner_callback.delay_update.assert_not_called()
|
||||||
@@ -261,7 +262,7 @@ def test_handle_work_error_get_results_raises():
|
|||||||
err_res = _Result(status='error', rc=1)
|
err_res = _Result(status='error', rc=1)
|
||||||
|
|
||||||
with pytest.raises(RuntimeError):
|
with pytest.raises(RuntimeError):
|
||||||
rj._handle_work_error(ctl, err_res)
|
rj._handle_work_error(ReceptorWork(ctl), err_res)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -296,7 +297,7 @@ def test_reattach_sets_job_created_on_callback(mock_rmtree, mock_pdd, mock_relea
|
|||||||
created_callbacks.append(self)
|
created_callbacks.append(self)
|
||||||
|
|
||||||
with patch.object(RealCallback, '__init__', capturing_cb_init):
|
with patch.object(RealCallback, '__init__', capturing_cb_init):
|
||||||
reattach_to_work_unit(job, ctl)
|
reattach_to_work_unit(job, ReceptorWork(ctl))
|
||||||
|
|
||||||
assert len(created_callbacks) == 1
|
assert len(created_callbacks) == 1
|
||||||
assert created_callbacks[0].job_created == str(job.created)
|
assert created_callbacks[0].job_created == str(job.created)
|
||||||
@@ -325,7 +326,7 @@ def test_reattach_releases_work_unit_on_success(mock_rmtree, mock_pdd, mock_rele
|
|||||||
ctl.simple_command.return_value = {'StateName': ''}
|
ctl.simple_command.return_value = {'StateName': ''}
|
||||||
mock_process.return_value = Mock(status='successful', rc=0)
|
mock_process.return_value = Mock(status='successful', rc=0)
|
||||||
|
|
||||||
reattach_to_work_unit(job, ctl)
|
reattach_to_work_unit(job, ReceptorWork(ctl))
|
||||||
|
|
||||||
mock_release.assert_called_once()
|
mock_release.assert_called_once()
|
||||||
|
|
||||||
@@ -350,7 +351,7 @@ def test_reattach_releases_work_unit_on_failure(mock_rmtree, mock_pdd, mock_rele
|
|||||||
{'StateName': 'Succeeded', 'ExitCode': 0, 'Detail': ''}, # fallback re-check
|
{'StateName': 'Succeeded', 'ExitCode': 0, 'Detail': ''}, # fallback re-check
|
||||||
]
|
]
|
||||||
|
|
||||||
result = reattach_to_work_unit(job, ctl)
|
result = reattach_to_work_unit(job, ReceptorWork(ctl))
|
||||||
|
|
||||||
assert result is True # finalized via exit_code path
|
assert result is True # finalized via exit_code path
|
||||||
mock_release.assert_called_once()
|
mock_release.assert_called_once()
|
||||||
@@ -391,7 +392,7 @@ def test_reattach_sets_parent_workflow_job_id_when_workflow_child(mock_rmtree, m
|
|||||||
created_callbacks.append(self)
|
created_callbacks.append(self)
|
||||||
|
|
||||||
with patch.object(RealCallback, '__init__', capturing_cb_init):
|
with patch.object(RealCallback, '__init__', capturing_cb_init):
|
||||||
reattach_to_work_unit(job, ctl)
|
reattach_to_work_unit(job, ReceptorWork(ctl))
|
||||||
|
|
||||||
assert len(created_callbacks) == 1
|
assert len(created_callbacks) == 1
|
||||||
assert created_callbacks[0].parent_workflow_job_id == 999
|
assert created_callbacks[0].parent_workflow_job_id == 999
|
||||||
@@ -415,7 +416,7 @@ def test_reattach_workflow_job_lookup_exception_swallowed(mock_rmtree, mock_pdd,
|
|||||||
ctl = Mock()
|
ctl = Mock()
|
||||||
ctl.simple_command.return_value = {'StateName': 'Succeeded', 'ExitCode': 0, 'Detail': ''}
|
ctl.simple_command.return_value = {'StateName': 'Succeeded', 'ExitCode': 0, 'Detail': ''}
|
||||||
|
|
||||||
result = reattach_to_work_unit(job, ctl)
|
result = reattach_to_work_unit(job, ReceptorWork(ctl))
|
||||||
|
|
||||||
assert result is True # exception swallowed, adoption completes
|
assert result is True # exception swallowed, adoption completes
|
||||||
|
|
||||||
@@ -552,7 +553,7 @@ def test_reattach_non_workflow_job_no_parent_id(mock_rmtree, mock_pdd, mock_rele
|
|||||||
created_callbacks.append(self)
|
created_callbacks.append(self)
|
||||||
|
|
||||||
with patch.object(RealCallback, '__init__', capturing_cb_init):
|
with patch.object(RealCallback, '__init__', capturing_cb_init):
|
||||||
reattach_to_work_unit(job, ctl)
|
reattach_to_work_unit(job, ReceptorWork(ctl))
|
||||||
|
|
||||||
assert created_callbacks[0].parent_workflow_job_id is None
|
assert created_callbacks[0].parent_workflow_job_id is None
|
||||||
|
|
||||||
@@ -585,7 +586,7 @@ def test_reattach_running_defers_without_streaming_variant(mock_rmtree, mock_pdd
|
|||||||
ctl = Mock()
|
ctl = Mock()
|
||||||
ctl.simple_command.return_value = {'StateName': 'Running'}
|
ctl.simple_command.return_value = {'StateName': 'Running'}
|
||||||
|
|
||||||
result = reattach_to_work_unit(job, ctl)
|
result = reattach_to_work_unit(job, ReceptorWork(ctl))
|
||||||
|
|
||||||
mock_process.assert_not_called()
|
mock_process.assert_not_called()
|
||||||
assert result is False
|
assert result is False
|
||||||
@@ -619,7 +620,7 @@ def test_reattach_process_phase_failure_falls_back_to_work_status(mock_rmtree, m
|
|||||||
]
|
]
|
||||||
mock_process.side_effect = RuntimeError('process phase failed')
|
mock_process.side_effect = RuntimeError('process phase failed')
|
||||||
|
|
||||||
result = reattach_to_work_unit(job, ctl) # must not raise
|
result = reattach_to_work_unit(job, ReceptorWork(ctl)) # must not raise
|
||||||
|
|
||||||
# Returns True (function completed without re-raising)
|
# Returns True (function completed without re-raising)
|
||||||
assert result is True
|
assert result is True
|
||||||
@@ -645,7 +646,7 @@ def test_reattach_pending_defers_without_streaming():
|
|||||||
ctl.simple_command.return_value = {'StateName': 'Pending'}
|
ctl.simple_command.return_value = {'StateName': 'Pending'}
|
||||||
|
|
||||||
with patch('awx.main.tasks.receptor.AWXReceptorJob._process_phase') as mock_process:
|
with patch('awx.main.tasks.receptor.AWXReceptorJob._process_phase') as mock_process:
|
||||||
result = reattach_to_work_unit(job, ctl)
|
result = reattach_to_work_unit(job, ReceptorWork(ctl))
|
||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
mock_process.assert_not_called()
|
mock_process.assert_not_called()
|
||||||
@@ -673,7 +674,7 @@ def test_reattach_succeeded_goes_through_process_phase(mock_rmtree, mock_pdd, mo
|
|||||||
ctl.simple_command.return_value = {'StateName': 'Succeeded', 'ExitCode': 0, 'Detail': ''}
|
ctl.simple_command.return_value = {'StateName': 'Succeeded', 'ExitCode': 0, 'Detail': ''}
|
||||||
|
|
||||||
with patch('awx.main.tasks.receptor.AWXReceptorJob._process_phase', return_value=Mock(status='successful', rc=0)) as mock_process:
|
with patch('awx.main.tasks.receptor.AWXReceptorJob._process_phase', return_value=Mock(status='successful', rc=0)) as mock_process:
|
||||||
reattach_to_work_unit(job, ctl)
|
reattach_to_work_unit(job, ReceptorWork(ctl))
|
||||||
|
|
||||||
mock_process.assert_called_once()
|
mock_process.assert_called_once()
|
||||||
mock_finalize_job.assert_called_once()
|
mock_finalize_job.assert_called_once()
|
||||||
@@ -699,7 +700,7 @@ def test_reattach_failed_goes_through_process_phase(mock_rmtree, mock_pdd, mock_
|
|||||||
ctl.simple_command.return_value = {'StateName': 'Failed', 'ExitCode': 1, 'Detail': 'exit status 1'}
|
ctl.simple_command.return_value = {'StateName': 'Failed', 'ExitCode': 1, 'Detail': 'exit status 1'}
|
||||||
|
|
||||||
with patch('awx.main.tasks.receptor.AWXReceptorJob._process_phase', return_value=Mock(status='failed', rc=1)) as mock_process:
|
with patch('awx.main.tasks.receptor.AWXReceptorJob._process_phase', return_value=Mock(status='failed', rc=1)) as mock_process:
|
||||||
reattach_to_work_unit(job, ctl)
|
reattach_to_work_unit(job, ReceptorWork(ctl))
|
||||||
|
|
||||||
mock_process.assert_called_once()
|
mock_process.assert_called_once()
|
||||||
mock_finalize_job.assert_called_once()
|
mock_finalize_job.assert_called_once()
|
||||||
@@ -724,7 +725,7 @@ def test_reattach_canceled_goes_through_process_phase(mock_rmtree, mock_pdd, moc
|
|||||||
ctl.simple_command.return_value = {'StateName': 'Canceled', 'Detail': ''}
|
ctl.simple_command.return_value = {'StateName': 'Canceled', 'Detail': ''}
|
||||||
|
|
||||||
with patch('awx.main.tasks.receptor.AWXReceptorJob._process_phase', return_value=Mock(status='error', rc=1)) as mock_process:
|
with patch('awx.main.tasks.receptor.AWXReceptorJob._process_phase', return_value=Mock(status='error', rc=1)) as mock_process:
|
||||||
reattach_to_work_unit(job, ctl)
|
reattach_to_work_unit(job, ReceptorWork(ctl))
|
||||||
|
|
||||||
mock_process.assert_called_once()
|
mock_process.assert_called_once()
|
||||||
mock_finalize_job.assert_called_once()
|
mock_finalize_job.assert_called_once()
|
||||||
@@ -752,7 +753,7 @@ def test_reattach_running_defers_without_streaming(mock_rmtree, mock_pdd, mock_r
|
|||||||
ctl.simple_command.return_value = {'StateName': 'Running'}
|
ctl.simple_command.return_value = {'StateName': 'Running'}
|
||||||
|
|
||||||
with patch('awx.main.tasks.receptor.AWXReceptorJob._process_phase') as mock_process:
|
with patch('awx.main.tasks.receptor.AWXReceptorJob._process_phase') as mock_process:
|
||||||
result = reattach_to_work_unit(job, ctl)
|
result = reattach_to_work_unit(job, ReceptorWork(ctl))
|
||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
mock_process.assert_not_called()
|
mock_process.assert_not_called()
|
||||||
@@ -969,7 +970,7 @@ def test_reattach_returns_false_when_status_command_raises():
|
|||||||
ctl = Mock()
|
ctl = Mock()
|
||||||
ctl.simple_command.side_effect = Exception('socket closed')
|
ctl.simple_command.side_effect = Exception('socket closed')
|
||||||
|
|
||||||
result = reattach_to_work_unit(job, ctl)
|
result = reattach_to_work_unit(job, ReceptorWork(ctl))
|
||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
@@ -1320,7 +1321,7 @@ def test_handle_work_error_no_output_no_detail(mock_logger):
|
|||||||
receptor_job.task.runner_callback.extra_update_fields = {}
|
receptor_job.task.runner_callback.extra_update_fields = {}
|
||||||
receptor_job.task.runner_callback.event_ct = 0 # No events
|
receptor_job.task.runner_callback.event_ct = 0 # No events
|
||||||
|
|
||||||
result = receptor_job._handle_work_error(receptor_ctl, res)
|
result = receptor_job._handle_work_error(ReceptorWork(receptor_ctl), res)
|
||||||
|
|
||||||
assert result == res
|
assert result == res
|
||||||
mock_logger.warning.assert_called()
|
mock_logger.warning.assert_called()
|
||||||
@@ -1345,7 +1346,7 @@ def test_handle_work_error_state_name_empty_string(mock_logger):
|
|||||||
receptor_job.task.runner_callback.extra_update_fields = {}
|
receptor_job.task.runner_callback.extra_update_fields = {}
|
||||||
receptor_job.task.runner_callback.event_ct = 5
|
receptor_job.task.runner_callback.event_ct = 5
|
||||||
|
|
||||||
result = receptor_job._handle_work_error(receptor_ctl, res)
|
result = receptor_job._handle_work_error(ReceptorWork(receptor_ctl), res)
|
||||||
|
|
||||||
assert result == res
|
assert result == res
|
||||||
# Verify exception was logged
|
# Verify exception was logged
|
||||||
@@ -1377,7 +1378,7 @@ def test_handle_work_error_delay_update_with_receptor_output():
|
|||||||
receptor_job.task.runner_callback.extra_update_fields = {}
|
receptor_job.task.runner_callback.extra_update_fields = {}
|
||||||
receptor_job.task.runner_callback.event_ct = 0 # No events → fetch output
|
receptor_job.task.runner_callback.event_ct = 0 # No events → fetch output
|
||||||
|
|
||||||
result = receptor_job._handle_work_error(receptor_ctl, res)
|
result = receptor_job._handle_work_error(ReceptorWork(receptor_ctl), res)
|
||||||
|
|
||||||
assert result == res
|
assert result == res
|
||||||
# Verify delay_update was called with receptor output
|
# Verify delay_update was called with receptor output
|
||||||
@@ -1406,7 +1407,7 @@ def test_handle_work_error_delay_update_with_detail_fallback():
|
|||||||
receptor_job.task.runner_callback.extra_update_fields = {}
|
receptor_job.task.runner_callback.extra_update_fields = {}
|
||||||
receptor_job.task.runner_callback.event_ct = 5
|
receptor_job.task.runner_callback.event_ct = 5
|
||||||
|
|
||||||
result = receptor_job._handle_work_error(receptor_ctl, res)
|
result = receptor_job._handle_work_error(ReceptorWork(receptor_ctl), res)
|
||||||
|
|
||||||
assert result == res
|
assert result == res
|
||||||
# Verify delay_update was called with detail
|
# Verify delay_update was called with detail
|
||||||
|
|||||||
Reference in New Issue
Block a user