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.