Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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/<int:server_id>/cron", methods=["GET", "POST"])
@login_required
@server_access_required
Expand All @@ -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")})
Expand Down Expand Up @@ -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:
Expand Down
44 changes: 44 additions & 0 deletions tests/smoke_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down