From 4a33046aef32c363aa83b7df967aae6ab75ada2c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 22:08:18 -0400 Subject: [PATCH] fix(ansible): Run now runs the playbook and leaves the schedule's time alone (#5445) Run now set last_run_at to the click, and a schedule's next turn counts from last_run_at, so a manual run of the nightly prune at 14:00 moved every later run to 14:00. Operator ruling: "run now shouldn't change the schedule it should just run the playbook and leave the schedule as it was." Run now no longer touches last_run_at. It still records the run as last_run_id, so the list shows it and a firing during it is skipped. Co-Authored-By: Claude Opus 5.5 --- steward/ansible/routes.py | 8 ++- tests/integration/test_schedule_run_now.py | 66 ++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 tests/integration/test_schedule_run_now.py diff --git a/steward/ansible/routes.py b/steward/ansible/routes.py index 0eb2f60..f83728e 100644 --- a/steward/ansible/routes.py +++ b/steward/ansible/routes.py @@ -743,12 +743,16 @@ async def run_schedule_now(schedule_id: str): inventory_scope=inventory_scope, params=params, triggered_by=session["user_id"], origin="schedule", origin_id=schedule_id, ) + # Run now runs the playbook and leaves the schedule where it was: its + # time of day (last_run_at, the slot its next turn counts from) is not + # touched (operator, 2026-10-09). The run is still the schedule's latest, + # so the list shows it and a firing while it goes is skipped (#5158). async with current_app.db_sessionmaker() as db: async with db.begin(): fresh = await db.get(AnsibleSchedule, schedule_id) if fresh: - fresh.last_run_at = datetime.now(timezone.utc) - fresh.last_run_id = run.id if run else None + if run: + fresh.last_run_id = run.id fresh.last_error = err await _audit("ansible.schedule.run_now", "ansible_schedule", schedule_id, {"playbook": playbook_path, "run_id": run.id if run else None, "error": err}) diff --git a/tests/integration/test_schedule_run_now.py b/tests/integration/test_schedule_run_now.py new file mode 100644 index 0000000..76963fe --- /dev/null +++ b/tests/integration/test_schedule_run_now.py @@ -0,0 +1,66 @@ +"""Integration: Run now on a schedule runs its playbook and leaves the +schedule where it was (operator, 2026-10-09): its time of day stays, and the +run it started is the one the list shows.""" +from __future__ import annotations + +import asyncio +import os +import time +import uuid +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +import pytest + +pytestmark = pytest.mark.integration + +CSRF = {"X-CSRF-Token": "test-token"} + + +@pytest.fixture +def app(): + if not os.environ.get("STEWARD_DATABASE_URL"): + pytest.skip("needs Postgres") + from steward.app import create_app + return create_app(testing=False) + + +def test_run_now_leaves_the_schedule_s_time_alone(app, monkeypatch): + from steward.ansible import runner + from steward.models.ansible_schedule import AnsibleSchedule + from steward.models.users import User, UserRole + + started: list[dict] = [] + run_id = str(uuid.uuid4()) + + async def fake_trigger(_app, **kw): + started.append(kw) + return SimpleNamespace(id=run_id), None, None + + monkeypatch.setattr(runner, "trigger_run", fake_trigger) + + async def _go(): + tag = uuid.uuid4().hex[:8] + uid, sid = str(uuid.uuid4()), str(uuid.uuid4()) + slot = datetime.now(timezone.utc).replace(microsecond=0) - timedelta(hours=3) + async with app.db_sessionmaker() as s: + async with s.begin(): + s.add(User(id=uid, username=f"rn-{tag}", email=f"rn-{tag}@example.com", + password_hash="x", role=UserRole.operator, is_active=True)) + s.add(AnsibleSchedule(id=sid, name=f"nightly-{tag}", source_name="steward-builtin", + playbook_path="maintenance/docker_prune.yml", + interval_seconds=86400, enabled=True, last_run_at=slot)) + client = app.test_client() + async with client.session_transaction() as sess: + sess.update(user_id=uid, user_role="operator", username=f"rn-{tag}", + _csrf="test-token", login_at=time.time()) + + resp = await client.post(f"/ansible/schedules/{sid}/run-now", headers=CSRF) + assert resp.status_code == 302 and resp.headers["Location"].endswith(run_id) + assert [kw["origin_id"] for kw in started] == [sid] + async with app.db_sessionmaker() as s: + sched = await s.get(AnsibleSchedule, sid) + assert sched.last_run_at == slot # its next turn is still a day after the slot + assert sched.last_run_id == run_id and sched.last_error is None + + asyncio.run(_go())