Add lightweight integration tests for Celery/Redis basics in CI - #2369
Conversation
1b3d448 to
cb9d08b
Compare
There was a problem hiding this comment.
Hey - I've found 2 security issues, and 1 other issue
Security issues:
- Detected subprocess function 'Popen' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'. (link)
- Detected subprocess function 'Popen' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'. (link)
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="osism/commands/service.py" line_range="63" />
<code_context>
elif service == "flower":
p = subprocess.Popen(
- "celery --broker=redis://redis flower",
+ f"celery --broker={Config.broker_url} flower",
shell=True,
)
</code_context>
<issue_to_address>
**issue (bug_risk):** Handle special characters in broker URL for the flower invocation.
Because this uses `shell=True`, any `&`, `?`, or other shell metacharacters in `Config.broker_url` can be split or reinterpreted by the shell. Please use a list-based `Popen` without `shell=True`, e.g. `Popen(["celery", "--broker", Config.broker_url, "flower"])`, or otherwise ensure the URL is safely quoted/escaped.
</issue_to_address>
### Comment 2
<location path="osism/commands/service.py" line_range="51-54" />
<code_context>
subprocess.Popen(
f"celery -A {t} --broker={Config.broker_url} beat -s /tmp/celerybeat-schedule-{t}.db",
shell=True,
)
</code_context>
<issue_to_address>
**security (python.lang.security.audit.dangerous-subprocess-use-audit):** Detected subprocess function 'Popen' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'.
*Source: opengrep*
</issue_to_address>
### Comment 3
<location path="osism/commands/service.py" line_range="62-65" />
<code_context>
p = subprocess.Popen(
f"celery --broker={Config.broker_url} flower",
shell=True,
)
</code_context>
<issue_to_address>
**security (python.lang.security.audit.dangerous-subprocess-use-audit):** Detected subprocess function 'Popen' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'.
*Source: opengrep*
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
The Celery Config hardcoded broker_url and result_backend to redis://redis, with the same literals duplicated in the beat and flower service commands. Derive the URLs from REDIS_HOST / REDIS_PORT / REDIS_DB -- the same settings osism.utils._init_redis already honors -- and allow an override via CELERY_BROKER_URL / CELERY_RESULT_BACKEND. The default of REDIS_HOST is redis, so this stays backwards compatible while letting the integration tests point Celery at a local Redis with REDIS_HOST=localhost. Also fix task_track_started, which was the tuple (True,) instead of the intended bool; it only worked because a non-empty tuple is truthy. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Christian Berendt <berendt@osism.tech>
Add tests/integration/ covering the task-processing core against a live Redis and a Celery worker started from the same virtualenv: - Celery round-trip: ansible.noop.delay() returns True, exercising broker, osism-ansible queue routing, worker and result backend. - Worker visibility: app.control.inspect().ping() sees the worker, the mechanism behind `osism get status workers`. - Redis streams: push/finish/fetch_task_output round-trip, the path `osism apply` uses to stream task logs. - Locking: create_redlock acquire/release and set/is/remove_task_lock. The worker runs only the ansible Celery app, which has no import-time dependency on NetBox/OpenStack/ansible-core. GATHER_FACTS_SCHEDULE=0 keeps the periodic gather_facts task from registering. The tests carry an `integration` marker (registered for the --strict-markers run) and are skipped when Redis is not reachable, so the default `pytest tests/unit` run is unaffected. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Christian Berendt <berendt@osism.tech>
Add the python-osism-integration-tests job to the check and periodic-daily pipelines. It reuses playbooks/pre.yml (which already sets up Docker via ensure-docker), starts a redis:7-alpine container, installs the package with pipenv like the unit-test job, and runs `pytest tests/integration` with REDIS_HOST=localhost. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Christian Berendt <berendt@osism.tech>
cb9d08b to
81ec7d2
Compare
| set -o pipefail | ||
| set -x | ||
|
|
||
| {{ python_venv_dir }}/bin/pipenv run pytest tests/integration |
There was a problem hiding this comment.
This job can pass green with zero tests executed. tests/integration/conftest.py skips all integration-marked tests when Redis is unreachable, and pytest exits 0 on all-skipped (reproduced locally as 5 skipped, exit 0). With no readiness gate after docker run -d, any Redis-startup failure here surfaces as a passing job instead of a red one, defeating the gate. Please add (a) a Redis-readiness wait before pytest, and (b) a required-Redis mode for CI, e.g. an OSISM_REQUIRE_REDIS=1 env lever that makes conftest.py fail the session instead of skipping when Redis is unreachable. Set it only in this job so local skip behavior is preserved.
| enable_ironic = os.environ.get("ENABLE_IRONIC", "True") | ||
| broker_url = "redis://redis" | ||
| result_backend = "redis://redis" | ||
| broker_url = os.environ.get( |
There was a problem hiding this comment.
This is a production-path change with no unit test. It replaced two hardcoded constants with real precedence logic (CELERY_BROKER_URL override, and result_backend defaulting to broker_url), but there is no reference to either env var anywhere under tests/; the integration suite only exercises the REDIS_HOST-derived fallback. Please add a unit test asserting: (1) CELERY_BROKER_URL wins over the derived URL, (2) CELERY_RESULT_BACKEND overrides independently, (3) absent CELERY_RESULT_BACKEND, result_backend == broker_url. Note these are class attributes resolved at import time from import-frozen settings.REDIS_*, so the test must importlib.reload(settings) then importlib.reload(osism.tasks) after setting env; monkeypatch.setenv alone will assert the stale cached value and pass for the wrong reason.
| task_create_missing_queues = True | ||
| task_default_queue = "default" | ||
| task_track_started = (True,) | ||
| task_track_started = True |
There was a problem hiding this comment.
This (True,) to True fix is correct (the 1-tuple only worked by being truthy), but it is an unrelated bug fix folded into a commit about broker-URL derivation; it should have been its own commit so it is traceable independently. Since the commit's suite advertises end-to-end "task core" coverage, also consider asserting the corrected value: either a direct Config.task_track_started is True check, or a STARTED-state transition in the Celery round-trip test.
| lock.release() | ||
|
|
||
|
|
||
| def test_task_lock_set_check_remove(): |
There was a problem hiding this comment.
This test reads/writes/deletes the fixed global key osism:task_lock on db 0 (see osism/utils/__init__.py:500-554); it cannot use a per-run UUID like the redlock/stream tests do. Against a disposable CI Redis that is fine, but the README example points REDIS_HOST=localhost at a local box, where this would clobber a real operator lock. Please either run the integration suite against a dedicated REDIS_DB, or add an explicit note in the README that the suite mutates live state on db 0 and must target a disposable Redis.
| raise RuntimeError( | ||
| f"Celery worker exited early with code {proc.returncode}" | ||
| ) | ||
| if celery_app.control.inspect().ping(): |
There was a problem hiding this comment.
Readiness breaks on any worker pinging the broker, not ci-worker specifically. On an isolated CI Redis this is fine, but on a shared local broker it can pass before this worker is up and hide a startup failure. Consider gating on the expected node, e.g. any(n.startswith("ci-worker@") for n in (celery_app.control.inspect().ping() or {})), mirroring what test_worker_is_visible already does.
- conftest.py: add an OSISM_REQUIRE_REDIS lever so an unreachable Redis fails the session instead of skipping every test; otherwise an all-skipped run exits 0 and a broken Redis would pass the CI gate green. Gate worker readiness on the ci-worker@ node specifically rather than any worker answering on the broker. - playbooks/test-integration.yml: wait for Redis to answer PING before running pytest, and set OSISM_REQUIRE_REDIS=1 only in this job so the local skip behaviour is preserved. - tests/unit/tasks/test_config.py: cover the broker-URL precedence logic (CELERY_BROKER_URL override, independent CELERY_RESULT_BACKEND, and result_backend defaulting to broker_url) and assert task_track_started is True. settings and osism.tasks are reloaded after setting the environment because Config resolves these attributes at import time. - README.md: warn that the suite mutates live state on the configured Redis (the global osism:task_lock key on the selected REDIS_DB) and must target a disposable Redis. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Christian Berendt <berendt@osism.tech>
Closes #2368.
Adds a lightweight integration-test job that covers the Celery/Redis
task-processing core (broker, queue routing, worker, result backend, Redis
streams for task output, distributed locks) end-to-end in CI, plus the
prerequisite that makes the broker URL configurable.
Change set (in commit order)
1. Derive Celery broker URL from settings and environment
osism/tasks/__init__.py,osism/commands/service.pyConfig.broker_url/Config.result_backendwere hardcoded toredis://redis, with the same literals duplicated in thebeatandflowerservice commands. They now derive fromREDIS_HOST/REDIS_PORT/REDIS_DB(the same settingsosism.utils._init_redisalready honors), overridable via
CELERY_BROKER_URL/CELERY_RESULT_BACKEND. The default ofREDIS_HOSTisredis, so thisis backwards compatible.
service.pynow referencesConfig.broker_url(single source of truth)for
beat/flower, removing the duplicated literals.task_track_started, which was the tuple(True,)instead of theintended bool (it only worked because a non-empty tuple is truthy).
This is what lets the tests point Celery at a local Redis via
REDIS_HOST=localhost.2. Add Celery/Redis integration tests
tests/integration/,setup.cfg,README.mdtest_celery.py— round-trip:ansible.noop.delay()→result.get(timeout=60) is True(broker +osism-ansiblerouting +worker + result backend); worker visibility: a fresh
Celeryclient configured from
Configsees the worker viainspect().ping()(the mechanism behindosism get status workers).test_redis_streams.py—push_task_output/finish_task_output/fetch_task_outputround-trip (the pathosism applyuses to streamlogs).
test_locking.py—create_redlockacquire/release andset_task_lock/is_task_locked/remove_task_lock.conftest.pystarts the worker as a session-scoped fixture from thesame venv, running only the
ansibleCelery app (no import-timedependency on NetBox/OpenStack/ansible-core) with
GATHER_FACTS_SCHEDULE=0so the periodicgather_factstask is notregistered. It polls
inspect().ping()until the worker is ready andterminates it on teardown.
integrationmarker (registered insetup.cfgfor the--strict-markersrun) and are skipped when Redis is not reachable,so a local
pytestis unaffected.3. Add Zuul integration-test job
.zuul.yaml,playbooks/test-integration.ymlpython-osism-integration-testsjob added to thecheckandperiodic-dailypipelines. It reusesplaybooks/pre.yml(which alreadysets up Docker via
ensure-docker), starts aredis:7-alpinecontainer, installs the package with pipenv exactly like the unit-test
job, and runs
pytest tests/integrationwithREDIS_HOST=localhost.Notes / deviations
tests/unitviatestpaths = tests/unitinsetup.cfg, so a barepytestnevercollects
tests/integration— the issue's first concern is alreadysatisfied. The
integrationmarker + Redis-reachability skip are stilladded (the marker is required by
--strict-markers, and the skip keepspytest tests/integrationgreen locally without Redis).CHANGELOG.mdentry: this repo maintains the changelog in batched,post-release commits that map merged PR numbers to release versions, so
per-PR entries are out of convention here.
coverage) are intentionally out of scope.
Local verification
black --check— clean (6 files)flake8— cleanpytest tests/integration— not run locally (no Redis); exercised bythe new CI job.
🤖 Generated with Claude Code