From d5a9581d72c2daa0702faf176becaaacbd8486f6 Mon Sep 17 00:00:00 2001 From: FMSMITH91 <12152698+FMSMITH91@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:25:25 -0500 Subject: [PATCH] fix: the Autostart switch now follows the crontab instead of a stale column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from the panel: Scheduled Tasks shows `*/5 * * * * /home/codserver/codserver monitor [autostart]`, last run OK 2m ago — while the Details page shows Auto-start OFF. Both are reading the same concept; only one of them is right. What "Autostart" MEANS is "is that monitor line scheduled". It was stored as a column, and three paths changed the line without touching the column: install_game_cron() schedules monitor at install and never recorded it import adopts an existing setup, same gap Scheduled Tasks deleting the line by hand — and the card's own help text promises "deleting monitor turns Autostart off", which was simply not true So the crontab is the source of truth now. _sync_toggles_from_cron() reads the `role` that list_cron_jobs already returns for each line and reconciles both switches — Autostart and Daily restart, which had the identical problem — on every read, add and delete of the cron list. Install records the autostart it just scheduled, fixing the root cause for new servers. An existing server heals the next time its Files & Config page loads, since that page lists the cron. Three checks drive the real endpoint with a stubbed crontab: Off + monitor present corrects to On, the line disappearing turns it Off, and deleting it turns the switch off the way the card says it will. Mutation-verified. Co-Authored-By: Claude Opus 5 --- app.py | 39 +++++++++++++++++++++++++++++++++++++-- tests/smoke_test.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index a1dcb06..f4e0635 100644 --- a/app.py +++ b/app.py @@ -4628,7 +4628,14 @@ def _run(): if cmds: gs.set_commands(cmds); db.session.commit() try: - install_game_cron(remote, short_name, gs.lgsm_name, {c["cmd"] for c in cmds}) + supported = {c["cmd"] for c in cmds} + install_game_cron(remote, short_name, gs.lgsm_name, supported) + # install_game_cron schedules `monitor` when the game has it, and + # that line IS the Autostart switch — record it, or the Details + # page shows Off while monitor is scheduled and running. + if "monitor" in supported and not gs.autostart: + gs.autostart = True + db.session.commit() except Exception: _log.debug("_run: ignored non-fatal error", exc_info=True) except Exception: @@ -8284,6 +8291,27 @@ def api_server_delete_path(server_id): # editable here too — nothing is locked in the generic editor any more (see # ssh_manager._cron_managed_patterns, which now returns []). This comment previously claimed # they were read-only, which read as a security guarantee that the code does not make. + def _sync_toggles_from_cron(gs, jobs): + """Make the crontab the source of truth for the Autostart / Daily-restart switches. + + Both switches are stored as columns, but what they really mean is "is this line in the + crontab". Three paths wrote the line without touching the column — install_game_cron() at + install time, an import that adopted an existing setup, and deleting the line by hand in + Scheduled Tasks (whose own help text promised that deleting `monitor` turns Autostart off). + So the Details page could show Off while `*/5 * * * * ... monitor` was scheduled and + running. Reconciles on every read or write of the cron list; returns True if it changed + anything.""" + roles = {j.get("role") for j in (jobs or [])} + changed = False + for field, role in (("autostart", "autostart"), ("daily_restart", "daily-restart")): + live = role in roles + if bool(getattr(gs, field)) != live: + setattr(gs, field, live) + changed = True + if changed: + db.session.commit() + return changed + @app.route("/api/server//cron", methods=["GET", "POST"]) @login_required @server_access_required @@ -8299,13 +8327,17 @@ def api_server_cron(server_id): upgrade_managed_cron_tracking(gs.remote, gs.short_name, gs.lgsm_name) except Exception: app.logger.debug("cron tracking upgrade skipped", exc_info=True) - return jsonify({"jobs": list_cron_jobs(gs.remote, gs.short_name, gs.lgsm_name)}) + jobs = list_cron_jobs(gs.remote, gs.short_name, gs.lgsm_name) + _sync_toggles_from_cron(gs, jobs) + return jsonify({"jobs": jobs}) except Exception: return jsonify({"error": _log_and_generic("list_cron_jobs failed")}), 500 data = _json_body() try: ok, msg = add_cron_job(gs.remote, gs.short_name, data.get("schedule"), data.get("command"), gs.lgsm_name) + if ok: + _sync_toggles_from_cron(gs, list_cron_jobs(gs.remote, gs.short_name, gs.lgsm_name)) log_action(current_user, "cron_add", target=gs.name, success=ok, detail=(data.get("schedule") or "")[:120]) return jsonify({"success": ok, "message": msg or ("Added" if ok else "Failed")}) @@ -8339,6 +8371,9 @@ def api_server_cron_delete(server_id): data = _json_body() try: ok, msg = delete_cron_job(gs.remote, gs.short_name, data.get("raw") or "", gs.lgsm_name) + if ok: + # The card's own help text promises that deleting `monitor` turns Autostart off. + _sync_toggles_from_cron(gs, list_cron_jobs(gs.remote, gs.short_name, gs.lgsm_name)) log_action(current_user, "cron_delete", target=gs.name, success=ok) return jsonify({"success": ok, "message": msg or ("Deleted" if ok else "Failed")}) except Exception: diff --git a/tests/smoke_test.py b/tests/smoke_test.py index 270052f..e6b3eae 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -1817,6 +1817,50 @@ def _act_btns(html): 'data-action="clearConsole"' in _det_html and 'data-action="clearConsole"' not in _fil_html) + # ── The Autostart switch must follow the crontab, not a stale column ────────────────────────── + # What "Autostart" MEANS is "is `*/5 * * * * ./server monitor` scheduled". It was stored as a + # column that three paths never updated — install, import, and deleting the line by hand — so + # the Details page could read Off while monitor was scheduled and running every 5 minutes. + _sv_lcj = _am.list_cron_jobs + try: + _mon = {"raw": "*/5 * * * * /home/csgoserver/csgoserver monitor", "schedule": "*/5 * * * *", + "command": "/home/csgoserver/csgoserver monitor", "managed": False, + "role": "autostart", "last_run": None, "ok": None, "error": ""} + with app.app_context(): + db.session.get(GameServer, gs_id).autostart = False # the stale column + db.session.commit() + _am.list_cron_jobs = lambda *a, **k: [_mon] + c.get("/api/server/%d/cron" % gs_id) + with app.app_context(): + _now = db.session.get(GameServer, gs_id).autostart + check("autostart: reading the cron corrects a switch that said Off while monitor is scheduled", + _now is True, "still %r" % _now) + + _am.list_cron_jobs = lambda *a, **k: [] # monitor line gone + c.get("/api/server/%d/cron" % gs_id) + with app.app_context(): + _now = db.session.get(GameServer, gs_id).autostart + check("autostart: and turns it back Off once the line is no longer there", _now is False, + "still %r" % _now) + + # The card's help text promises this; now it is true. + _sv_del = _am.delete_cron_job + try: + with app.app_context(): + db.session.get(GameServer, gs_id).autostart = True + db.session.commit() + _am.delete_cron_job = lambda *a, **k: (True, "Deleted") + _am.list_cron_jobs = lambda *a, **k: [] + c.post("/api/server/%d/cron/delete" % gs_id, json={"raw": _mon["raw"]}) + with app.app_context(): + _now = db.session.get(GameServer, gs_id).autostart + check("autostart: deleting the monitor line turns the switch off, as the card promises", + _now is False, "still %r" % _now) + finally: + _am.delete_cron_job = _sv_del + finally: + _am.list_cron_jobs = _sv_lcj + # ── Bearer API tokens: the other way into every route ───────────────────────────────────────── # A token authenticates AS its owner and inherits exactly that user's RBAC, and app.py exempts # Bearer requests from CSRF — so this is a full authentication path that had no test at all.