Background
Follow-up to #2192 (foundation) and PR #2193 (pytest + Zuul infrastructure). Part of Tier 5 (#2199). This issue covers the five thin Celery task wrapper modules osism/tasks/ansible.py (57 LOC), osism/tasks/ceph.py (31 LOC), osism/tasks/kolla.py (31 LOC), osism/tasks/kubernetes.py (31 LOC) and osism/tasks/reconciler.py (90 LOC). The first four delegate to osism.tasks.run_ansible_in_environment with a hard-coded worker name; reconciler shells out to /run.sh under a Redlock. Each module also creates its own Celery app configured from osism.tasks.Config and (for ansible/reconciler) registers a periodic task via the on_after_configure signal.
Scope
Add a single combined tests/unit/tasks/test_task_wrappers.py. One file is cleaner than five here: ceph/kolla/kubernetes are byte-for-byte identical except for the worker string and can share one parametrized test class, and the two setup_periodic_tasks variants share a fixture. If the file grows uncomfortably during implementation, split reconciler out into tests/unit/tasks/test_reconciler.py.
There is no existing coverage: tests/unit/tasks/ only contains the conductor/ subpackage, and tests/unit/commands/test_reconciler.py merely patches osism.tasks.reconciler.run.delay (the command layer), not the task bodies. run_command is not used by any module in scope (only by netbox/openstack tasks, tracked separately) — delegation here is exclusively to run_ansible_in_environment and subprocess.Popen.
Test targets
App configuration & queue routing — ansible.py:8, ceph.py:8, kolla.py:8, kubernetes.py:8, reconciler.py:12
No patching needed; importing the modules and reading app.conf is safe (verified: the lazy osism.utils.__getattr__ keeps Redis untouched and the on_after_configure receiver does not fire on plain import/conf access). Parametrize over the five modules:
app.conf.task_default_queue == "default" (Config applied via config_from_object)
app.conf.task_routes maps the module pattern to the expected queue: osism.tasks.ansible.* → osism-ansible, osism.tasks.ceph.* → ceph-ansible, osism.tasks.kolla.* → kolla-ansible, osism.tasks.kubernetes.* → kubernetes, osism.tasks.reconciler.* → reconciler
- Tasks are registered under their explicit names in
app.tasks: osism.tasks.ansible.gather_facts, osism.tasks.ansible.run, osism.tasks.ansible.noop, osism.tasks.ceph.run, osism.tasks.kolla.run, osism.tasks.kubernetes.run, osism.tasks.reconciler.run, osism.tasks.reconciler.run_on_change
setup_periodic_tasks() (ansible) — ansible.py:13
Patch osism.tasks.ansible.utils.create_redlock and osism.tasks.ansible.settings.GATHER_FACTS_SCHEDULE (module attribute, read at call time); sender is a MagicMock. Call the receiver directly — it is a plain function.
- Schedule > 0,
lock.acquire(timeout=10) returns True → sender.add_periodic_task(schedule, <signature>, expires=10) called once; the signature is gather_facts.s()
- Schedule = 0 →
add_periodic_task not called; lock.acquire not called either (short-circuit and), but create_redlock is still called with key lock_osism_tasks_ansible_setup_periodic_tasks
- Schedule > 0,
acquire returns False → add_periodic_task not called
gather_facts() — ansible.py:24
Patch osism.tasks.ansible.run_ansible_in_environment.
- Default call → delegates with exactly
(request_id, "osism-ansible", "generic", "facts", [], True, False) — seven positional args, auto_release_time left at the delegate's default
publish=False forwarded as sixth argument
- Return value of
run_ansible_in_environment passed through unchanged
- No
check_task_lock_and_exit call (unlike run — assert the patched lock check stays uncalled)
run() (ansible) — ansible.py:31
Patch osism.tasks.ansible.run_ansible_in_environment and osism.tasks.ansible.utils.check_task_lock_and_exit.
- Happy path: arguments forwarded positionally as
(request_id, "osism-ansible", environment, playbook, arguments, publish, locking, auto_release_time)
- Defaults:
publish=True, locking=False, auto_release_time=3600
check_task_lock_and_exit is invoked before delegation; when it raises SystemExit (production behavior is exit(1)), run_ansible_in_environment is never called — use pytest.raises(SystemExit)
- Return value passthrough
noop() — ansible.py:56
- Returns
True (one-liner; include for the module's coverage target)
run() (ceph/kolla/kubernetes, parametrized) — ceph.py:18, kolla.py:18, kubernetes.py:18
Patch <module>.run_ansible_in_environment and <module>.utils.check_task_lock_and_exit per parametrized module.
- Worker string per module: ceph →
"ceph-ansible", kolla → "kolla-ansible", kubernetes → "osism-kubernetes" (note: deliberately differs from the queue name kubernetes)
locking is hard-coded False (seventh positional arg) — these tasks expose no locking parameter, unlike ansible.run
auto_release_time forwarded, default 3600; publish default True
check_task_lock_and_exit raising SystemExit short-circuits delegation
setup_periodic_tasks (ceph.py:13, kolla.py:13, kubernetes.py:13) is pass — call once with a MagicMock sender, assert the sender is untouched
setup_periodic_tasks() (reconciler) — reconciler.py:17
Same three cases as the ansible variant, with osism.tasks.reconciler.settings.INVENTORY_RECONCILER_SCHEDULE, lock key lock_osism_tasks_reconciler_setup_periodic_tasks, and run_on_change.s() as the scheduled signature.
run() (reconciler) — reconciler.py:28
Patch osism.tasks.reconciler.utils.check_task_lock_and_exit, osism.tasks.reconciler.utils.create_redlock, osism.tasks.reconciler.subprocess.Popen, osism.tasks.reconciler.utils.push_task_output, osism.tasks.reconciler.utils.finish_task_output.
create_redlock called with key="lock_osism_tasks_reconciler_run", auto_release_time=60
lock.acquire(timeout=20) returns False → Popen never called, task returns None
- Lock acquired,
publish=True: Popen called with "/run.sh", shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT and an env kwarg (copy of os.environ); each stdout line is forwarded via push_task_output(request_id, line); p.wait(timeout=60); finish_task_output(request_id, rc=<wait return value>)
publish=False: no push_task_output / finish_task_output calls, p.wait still called
lock.release() raising pottery.ReleaseUnlockedLock → warning logged, exception does not propagate (set the real exception class as side_effect on the mock lock's release)
check_task_lock_and_exit raising SystemExit → nothing else executes (no create_redlock, no Popen)
run_on_change() — reconciler.py:70
Same patch set minus the output helpers.
create_redlock called with key="lock_osism_tasks_reconciler_run_on_change", auto_release_time=60
- Not acquired → no
Popen
- Acquired →
Popen("/run.sh", shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) — no env kwarg this time; p.wait() without timeout; no output publishing at all
- No task-lock check here (unlike
run) — assert the patched check_task_lock_and_exit stays uncalled
ReleaseUnlockedLock on release handled with a warning
Mocking hints
- No broker needed. All tasks use
bind=True; two equivalent invocation styles (verified against the installed Celery):
task.__wrapped__(...) — already bound to the task instance, so call it without a self argument: ansible.run.__wrapped__("env", "playbook", []). Inside, self.request.id is None; assert delegation with request_id=None.
task.apply(args=[...], task_id="test-id") — runs eagerly in-process and sets self.request.id = "test-id", useful where the forwarded request id should be a real value (e.g. push_task_output assertions). SystemExit is a BaseException and propagates through apply(), so pytest.raises(SystemExit) works with either style.
utils is imported as a module object (from osism import utils), so mocker.patch("osism.tasks.ansible.utils.check_task_lock_and_exit") and mocker.patch("osism.utils.check_task_lock_and_exit") hit the same attribute — either works.
run_ansible_in_environment is imported into each wrapper's namespace (from osism.tasks import ...), so it must be patched at the wrapper, e.g. osism.tasks.ceph.run_ansible_in_environment, not at osism.tasks.
- For
reconciler.run with publish=True, the code wraps p.stdout in io.TextIOWrapper(..., encoding="utf-8") — a plain MagicMock will not iterate. Give the mocked Popen return value a real byte stream:
proc = mocker.MagicMock()
proc.stdout = io.BytesIO(b"line one\nline two\n")
proc.wait.return_value = 0
mocker.patch("osism.tasks.reconciler.subprocess.Popen", return_value=proc)
pottery is imported lazily inside the task bodies (from pottery import ReleaseUnlockedLock); import the real class in the test and use it as side_effect — do not patch pottery.
- Patch the schedule settings on the settings module (
mocker.patch.object(osism.settings, "GATHER_FACTS_SCHEDULE", 0) or via the wrapper's reference) — they are read at call time, not at import time. Defaults are env-derived (43200.0 / 600.0), so never assert the literal default.
Definition of Done
Dependencies
Background
Follow-up to #2192 (foundation) and PR #2193 (pytest + Zuul infrastructure). Part of Tier 5 (#2199). This issue covers the five thin Celery task wrapper modules
osism/tasks/ansible.py(57 LOC),osism/tasks/ceph.py(31 LOC),osism/tasks/kolla.py(31 LOC),osism/tasks/kubernetes.py(31 LOC) andosism/tasks/reconciler.py(90 LOC). The first four delegate toosism.tasks.run_ansible_in_environmentwith a hard-coded worker name;reconcilershells out to/run.shunder a Redlock. Each module also creates its ownCeleryapp configured fromosism.tasks.Configand (foransible/reconciler) registers a periodic task via theon_after_configuresignal.Scope
Add a single combined
tests/unit/tasks/test_task_wrappers.py. One file is cleaner than five here:ceph/kolla/kubernetesare byte-for-byte identical except for the worker string and can share one parametrized test class, and the twosetup_periodic_tasksvariants share a fixture. If the file grows uncomfortably during implementation, splitreconcilerout intotests/unit/tasks/test_reconciler.py.There is no existing coverage:
tests/unit/tasks/only contains theconductor/subpackage, andtests/unit/commands/test_reconciler.pymerely patchesosism.tasks.reconciler.run.delay(the command layer), not the task bodies.run_commandis not used by any module in scope (only bynetbox/openstacktasks, tracked separately) — delegation here is exclusively torun_ansible_in_environmentandsubprocess.Popen.Test targets
App configuration & queue routing —
ansible.py:8,ceph.py:8,kolla.py:8,kubernetes.py:8,reconciler.py:12No patching needed; importing the modules and reading
app.confis safe (verified: the lazyosism.utils.__getattr__keeps Redis untouched and theon_after_configurereceiver does not fire on plain import/conf access). Parametrize over the five modules:app.conf.task_default_queue == "default"(Config applied viaconfig_from_object)app.conf.task_routesmaps the module pattern to the expected queue:osism.tasks.ansible.*→osism-ansible,osism.tasks.ceph.*→ceph-ansible,osism.tasks.kolla.*→kolla-ansible,osism.tasks.kubernetes.*→kubernetes,osism.tasks.reconciler.*→reconcilerapp.tasks:osism.tasks.ansible.gather_facts,osism.tasks.ansible.run,osism.tasks.ansible.noop,osism.tasks.ceph.run,osism.tasks.kolla.run,osism.tasks.kubernetes.run,osism.tasks.reconciler.run,osism.tasks.reconciler.run_on_changesetup_periodic_tasks()(ansible) —ansible.py:13Patch
osism.tasks.ansible.utils.create_redlockandosism.tasks.ansible.settings.GATHER_FACTS_SCHEDULE(module attribute, read at call time);senderis aMagicMock. Call the receiver directly — it is a plain function.lock.acquire(timeout=10)returnsTrue→sender.add_periodic_task(schedule, <signature>, expires=10)called once; the signature isgather_facts.s()add_periodic_tasknot called;lock.acquirenot called either (short-circuitand), butcreate_redlockis still called with keylock_osism_tasks_ansible_setup_periodic_tasksacquirereturnsFalse→add_periodic_tasknot calledgather_facts()—ansible.py:24Patch
osism.tasks.ansible.run_ansible_in_environment.(request_id, "osism-ansible", "generic", "facts", [], True, False)— seven positional args,auto_release_timeleft at the delegate's defaultpublish=Falseforwarded as sixth argumentrun_ansible_in_environmentpassed through unchangedcheck_task_lock_and_exitcall (unlikerun— assert the patched lock check stays uncalled)run()(ansible) —ansible.py:31Patch
osism.tasks.ansible.run_ansible_in_environmentandosism.tasks.ansible.utils.check_task_lock_and_exit.(request_id, "osism-ansible", environment, playbook, arguments, publish, locking, auto_release_time)publish=True,locking=False,auto_release_time=3600check_task_lock_and_exitis invoked before delegation; when it raisesSystemExit(production behavior isexit(1)),run_ansible_in_environmentis never called — usepytest.raises(SystemExit)noop()—ansible.py:56True(one-liner; include for the module's coverage target)run()(ceph/kolla/kubernetes, parametrized) —ceph.py:18,kolla.py:18,kubernetes.py:18Patch
<module>.run_ansible_in_environmentand<module>.utils.check_task_lock_and_exitper parametrized module."ceph-ansible", kolla →"kolla-ansible", kubernetes →"osism-kubernetes"(note: deliberately differs from the queue namekubernetes)lockingis hard-codedFalse(seventh positional arg) — these tasks expose nolockingparameter, unlikeansible.runauto_release_timeforwarded, default3600;publishdefaultTruecheck_task_lock_and_exitraisingSystemExitshort-circuits delegationsetup_periodic_tasks(ceph.py:13,kolla.py:13,kubernetes.py:13) ispass— call once with aMagicMocksender, assert the sender is untouchedsetup_periodic_tasks()(reconciler) —reconciler.py:17Same three cases as the ansible variant, with
osism.tasks.reconciler.settings.INVENTORY_RECONCILER_SCHEDULE, lock keylock_osism_tasks_reconciler_setup_periodic_tasks, andrun_on_change.s()as the scheduled signature.run()(reconciler) —reconciler.py:28Patch
osism.tasks.reconciler.utils.check_task_lock_and_exit,osism.tasks.reconciler.utils.create_redlock,osism.tasks.reconciler.subprocess.Popen,osism.tasks.reconciler.utils.push_task_output,osism.tasks.reconciler.utils.finish_task_output.create_redlockcalled withkey="lock_osism_tasks_reconciler_run",auto_release_time=60lock.acquire(timeout=20)returnsFalse→Popennever called, task returnsNonepublish=True:Popencalled with"/run.sh",shell=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUTand anenvkwarg (copy ofos.environ); each stdout line is forwarded viapush_task_output(request_id, line);p.wait(timeout=60);finish_task_output(request_id, rc=<wait return value>)publish=False: nopush_task_output/finish_task_outputcalls,p.waitstill calledlock.release()raisingpottery.ReleaseUnlockedLock→ warning logged, exception does not propagate (set the real exception class asside_effecton the mock lock'srelease)check_task_lock_and_exitraisingSystemExit→ nothing else executes (nocreate_redlock, noPopen)run_on_change()—reconciler.py:70Same patch set minus the output helpers.
create_redlockcalled withkey="lock_osism_tasks_reconciler_run_on_change",auto_release_time=60PopenPopen("/run.sh", shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)— noenvkwarg this time;p.wait()without timeout; no output publishing at allrun) — assert the patchedcheck_task_lock_and_exitstays uncalledReleaseUnlockedLockon release handled with a warningMocking hints
bind=True; two equivalent invocation styles (verified against the installed Celery):task.__wrapped__(...)— already bound to the task instance, so call it without aselfargument:ansible.run.__wrapped__("env", "playbook", []). Inside,self.request.idisNone; assert delegation withrequest_id=None.task.apply(args=[...], task_id="test-id")— runs eagerly in-process and setsself.request.id = "test-id", useful where the forwarded request id should be a real value (e.g.push_task_outputassertions).SystemExitis aBaseExceptionand propagates throughapply(), sopytest.raises(SystemExit)works with either style.utilsis imported as a module object (from osism import utils), somocker.patch("osism.tasks.ansible.utils.check_task_lock_and_exit")andmocker.patch("osism.utils.check_task_lock_and_exit")hit the same attribute — either works.run_ansible_in_environmentis imported into each wrapper's namespace (from osism.tasks import ...), so it must be patched at the wrapper, e.g.osism.tasks.ceph.run_ansible_in_environment, not atosism.tasks.reconciler.runwithpublish=True, the code wrapsp.stdoutinio.TextIOWrapper(..., encoding="utf-8")— a plainMagicMockwill not iterate. Give the mockedPopenreturn value a real byte stream:potteryis imported lazily inside the task bodies (from pottery import ReleaseUnlockedLock); import the real class in the test and use it asside_effect— do not patchpottery.mocker.patch.object(osism.settings, "GATHER_FACTS_SCHEDULE", 0)or via the wrapper's reference) — they are read at call time, not at import time. Defaults are env-derived (43200.0/600.0), so never assert the literal default.Definition of Done
tests/unit/tasks/test_task_wrappers.pycreatedpytest --cov=osism.tasks.ansible --cov=osism.tasks.ceph --cov=osism.tasks.kolla --cov=osism.tasks.kubernetes --cov=osism.tasks.reconcilershows ≥ 95 %pipenv run pytest tests/unit/tasks/test_task_wrappers.pypasses locallyflake8,mypy,python-blackremain greenpython-osism-unit-testspassesDependencies