Skip to content

Add unit tests for task lifecycle commands#2464

Open
berendt wants to merge 7 commits into
mainfrom
unit-tests-task-lifecycle-commands
Open

Add unit tests for task lifecycle commands#2464
berendt wants to merge 7 commits into
mainfrom
unit-tests-task-lifecycle-commands

Conversation

@berendt

@berendt berendt commented Jul 13, 2026

Copy link
Copy Markdown
Member

Implements #2362 (part of the Tier 8 meta issue #2199).

Adds unit tests for the nine small task-lifecycle CLI command modules under osism/commands/, following the established pattern from tests/unit/commands/test_reconciler.py / test_wait.py (Cmd(MagicMock(), MagicMock()) + get_parser("test").parse_args([...])).

New test files

  • test_task.py — parser contract and Celery revoke with terminate=True; pins the latent bug that the one-element nargs=1 list reaches control.revoke
  • test_worker.py — concurrency defaults (CPU count capped at 4, OSISM_CELERY_CONCURRENCY read at parser-build time), worker-type → tasks-module/queue mapping including the osism-ansible special case and suffix stripping, -c propagation, and the unvalidated unknown-type command pinned as current behavior
  • test_lock.pyLock/Unlock/LockStatus flows: already-locked warnings (with/without reason), --user/--reason pass-through, OPERATOR_USER default (also baked into the --user help at parser-build time), unknown-key fallbacks, and non-zero returns when the lock backend fails
  • test_service.py — exact subprocess command lines for api, listener, beat (seven schedulers, one per task module), flower, and reconciler (env/CPU concurrency logic); watchdog observer setup on /opt/configuration/inventory, cleanup via finally, on_any_event rebinding, and the inventory event triggering reconciler.run.delay(); unknown service type as silent no-op
  • test_container.py — parser host/REMAINDER split, one-shot SSH docker execution (key, known-hosts option, operator user), known-hosts init failure warning, interactive prompt loop, and Exit/exit/EXIT loop termination
  • test_configuration.py — REMAINDER forwarding, delay(..., auto_release_time=60), rc propagation via handle_task; pins the latent builtin-format argument
  • test_set.py / test_noset.py — state playbook scheduling parametrized over the Maintenance/Bootstrap command pairs (status=True vs. status=False)

Extended

  • test_reconciler.py — deprecation warning and concurrency of the deprecated worker runner; Sync wait/no-wait paths, --task-timeout forwarding, and the OSISM_TASK_TIMEOUT parser default (the existing timeout test is kept unchanged)

All Celery task calls, subprocesses, and the Redis task lock are mocked at the command module (lazy imports patched at the source module); the tests also assert that check_task_lock_and_exit runs before any task is scheduled or process is started.

Closes #2362

🤖 Generated with Claude Code

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've left some high level feedback:

  • Several tests explicitly codify latent bugs as current behavior (e.g. parsed_args.task list passed to control.revoke, empty tasks module for unknown worker type, builtin format to handle_task); consider marking these with a clear TODO/xfail or a dedicated test naming convention so they’re easy to find and update when the underlying bugs are fixed.
  • The test_lock_user_help_bakes_in_operator_user_at_parser_build_time test relies on parser._actions, which is an internal argparse detail; it would be more robust to locate the --user action via a public API or helper rather than depending on private attributes.
  • There is repeated setup logic across command tests for env-based concurrency defaults and check_task_lock_and_exit ordering; consider extracting shared helpers/fixtures (e.g. for building parsers and asserting lock-check-before-schedule semantics) to reduce duplication and keep behavior changes easier to maintain.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Several tests explicitly codify latent bugs as current behavior (e.g. `parsed_args.task` list passed to `control.revoke`, empty tasks module for unknown worker type, builtin `format` to `handle_task`); consider marking these with a clear TODO/xfail or a dedicated test naming convention so they’re easy to find and update when the underlying bugs are fixed.
- The `test_lock_user_help_bakes_in_operator_user_at_parser_build_time` test relies on `parser._actions`, which is an internal argparse detail; it would be more robust to locate the `--user` action via a public API or helper rather than depending on private attributes.
- There is repeated setup logic across command tests for env-based concurrency defaults and `check_task_lock_and_exit` ordering; consider extracting shared helpers/fixtures (e.g. for building parsers and asserting lock-check-before-schedule semantics) to reduce duplication and keep behavior changes easier to maintain.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@berendt
berendt force-pushed the unit-tests-task-lifecycle-commands branch from cc543f2 to 6f0b977 Compare July 15, 2026 07:27
@berendt berendt moved this from New to Ready for review in Human Board Jul 15, 2026
@berendt
berendt requested a review from ideaship July 15, 2026 08:07
Comment thread tests/unit/commands/test_service.py Outdated


@pytest.mark.latent_bug
def test_unknown_service_type_is_a_silent_noop():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pins an unknown service type passing without error. The test is green only because service.py silently does nothing for an unrecognized type, and latent_bug does not exclude it from the default suite — so whoever adds the error path turns this test red and must rewrite it. Assert the intended behavior (unknown type should error) and fix service.py in a dedicated commit.

Comment thread tests/unit/commands/test_worker.py Outdated


@pytest.mark.latent_bug
def test_take_action_unknown_type_yields_empty_tasks_module():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pins a broken command as intended output. An unvalidated short type makes queue[:-8] empty, producing celery -A osism.tasks. worker …. Reject unknown worker types (or map them explicitly) in a dedicated commit, and assert the error rather than the malformed command.



@pytest.mark.latent_bug
def test_parser_rejects_leading_option_arguments():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pins a parser that rejects the Ansible options its arguments remainder exists to forward. nargs=REMAINDER only starts capturing at the first positional, so osism configuration sync -e foo=1 is rejected — and since every forwardable option starts with - and there is no natural leading positional here, no option can be passed in practice. Switch to the parse_known_args approach already used in openstack, in a dedicated commit, and assert the option is forwarded.

assert result == rc


@pytest.mark.latent_bug

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pins a real bug — the wrong value is passed. Only this caller passes the builtin format instead of a format string (manage.py, netbox.py"script"; apply.py, validate.pyparsed_args.format); it is inert today only because wait=True short-circuits before it is read, but --no-wait (as reconciler.Sync already has) would make the else branch print nothing. Fix production to pass "log" (or wire --format) in a dedicated commit and assert that; do not pin the builtin as intended.

Comment thread tests/unit/commands/test_task.py Outdated


@pytest.mark.latent_bug
def test_revoke_passes_task_list_instead_of_id():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only nargs=1 positional passed on without [0] unpackingservice.py:25, worker.py:34, and set/noset/container all unpack. control.revoke accepts both a string and a list, so the current call is not broken; this is a sibling-consistency gap, not a bug. If uniformity is the goal, unpack here too — app.control.revoke(task[0], terminate=True) — in a dedicated commit, and drop the latent_bug marker; keep the behavioral assertion.

from ._helpers import assert_not_called_before_lock_check, parse_args


def _run_worker(monkeypatch, *, env=None, cpu_count=16):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The commit message's lock-ordering claim is false because the ordering is missing on this path. reconciler.Run.take_action (osism/commands/reconciler.py:14-27) starts a worker subprocess with no check_task_lock_and_exit() — only Sync calls it (:49) — so the commit body's "before any task is scheduled or process is started" does not hold, and this _run_worker helper neither patches nor asserts the lock. It is a deprecated path (its replacement service.Run guards the lock), so severity is low. Pick one: (a) add the missing check in a dedicated commit — then the commit message is accurate as written and this test should assert the lock; or (b) leave the deprecated path and correct the commit message to name reconciler.Run as the exception rather than claiming universal coverage.

Comment thread tests/unit/commands/test_container.py Outdated
mock_prompt.assert_not_called()
mock_call.assert_called_once()
ssh_command = mock_call.call_args[0][0]
assert mock_call.call_args[1] == {"shell": True}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These assertions would block a safe rewrite. call_args[1] == {"shell": True} plus the exact command string would fail a future hardening to an argv vector (subprocess.call([...]), no shell=True) for no behavioral reason. Assert only the observable contract — ssh targets testuser@node1 and runs docker ps -a. Separately, the shell=True interpolation in osism/commands/container.py:47,55 (already # FIXME'd) is a pre-existing, codebase-wide hardening candidate for its own PR; it is operator-supplied input, so not a security blocker here.

@github-project-automation github-project-automation Bot moved this from Ready for review to In review in Human Board Jul 15, 2026
berendt added 7 commits July 16, 2026 14:19
An unrecognized type in osism service fell through all branches and
exited silently with success. Log an error and return a non-zero exit
code instead.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christian Berendt <berendt@osism.tech>
An unrecognized worker type fell through to the suffix-stripping branch
for the *-ansible workers, so a type shorter than eight characters
produced the malformed command "celery -A osism.tasks. worker ...".
Match the *-ansible types explicitly and log an error and return a
non-zero exit code for anything else.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christian Berendt <berendt@osism.tech>
The arguments remainder used argparse.REMAINDER, which only starts
capturing at the first positional token. Everything forwarded to
Ansible here is option-like (-e, --limit, ...) and the command has no
natural leading positional, so no option could be passed in practice:
osism configuration sync -e foo=1 was rejected by the parser.

Parse with parse_known_args() instead (the same approach as the
openstack passthrough) so the parser consumes only its own options and
forwards everything else verbatim.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christian Berendt <berendt@osism.tech>
Sync.take_action passed the Python builtin format where a format string
("log"/"script") is expected, matching neither output branch in
handle_task. This is inert as long as the task is always awaited
(wait=True short-circuits before the format is read), but would
silently print nothing if a --no-wait path were added. Pass "log"
explicitly, like the other handle_task callers do.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christian Berendt <berendt@osism.tech>
Revoke passed the one-element list produced by its nargs=1 positional
straight to control.revoke. Celery accepts both a string and a list, so
this was not broken, but every sibling command unpacks its nargs=1
positional before use; do the same here for consistency.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christian Berendt <berendt@osism.tech>
reconciler.Run started its worker subprocess without consulting the
task lock, while its replacement (osism service reconciler) and all
other task-scheduling and service commands call
check_task_lock_and_exit() first. Add the missing check so the lock is
honored on this path as well.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christian Berendt <berendt@osism.tech>
Cover the task lifecycle CLI command modules under osism/commands/ with
unit tests following the established pattern (command instantiated with
mocked app/options, args built via get_parser().parse_args()):

- task: parser contract and Celery revoke by task id with
  terminate=True
- worker: concurrency defaults (CPU count capped at 4,
  OSISM_CELERY_CONCURRENCY read at parser-build time), worker type to
  tasks module/queue mapping including the osism-ansible special case,
  and the unknown-type error
- reconciler (extended): deprecation warning, task lock check, and
  concurrency of the worker runner; sync wait/no-wait paths, task
  timeout forwarding, and the OSISM_TASK_TIMEOUT parser default
- lock: lock/unlock/status flows including already-locked warnings,
  OPERATOR_USER default, unknown-key fallbacks, and non-zero returns on
  lock backend failures
- service: subprocess command lines for api, listener, beat (one
  scheduler per task module), flower, and reconciler; watchdog observer
  setup/cleanup and inventory event handling; unknown type error
- container: one-shot and interactive SSH docker execution, known-hosts
  fallback warning, and prompt exit keywords; the SSH tests assert only
  the observable contract (target and docker command) so a future
  argv-vector hardening does not break them
- configuration: forwarding of option-like Ansible arguments, task
  scheduling with auto_release_time, and rc propagation via handle_task
- set/noset: state playbook scheduling parametrized over the
  maintenance/bootstrap command pairs

All Celery task calls, subprocesses, and the Redis task lock are mocked;
the tests also assert that check_task_lock_and_exit runs before any task
is scheduled or process is started.

Closes #2362

Assisted-by: Claude:claude-fable-5
Signed-off-by: Christian Berendt <berendt@osism.tech>
@berendt
berendt force-pushed the unit-tests-task-lifecycle-commands branch from 6f0b977 to 2859e48 Compare July 16, 2026 12:21
@berendt
berendt requested a review from ideaship July 16, 2026 12:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In review

Development

Successfully merging this pull request may close these issues.

Unit tests for osism/commands/ — task lifecycle (task, worker, reconciler, lock, service, container, configuration, set, noset)

3 participants