From ec2f444ef3ec80c46ca3c459980db2085ab1e38f Mon Sep 17 00:00:00 2001 From: cato447 Date: Tue, 20 Oct 2026 17:47:57 +0200 Subject: [PATCH] Commit challs.txt once a day at 04:00, separate from the HedgeDoc refresh The 30-minute timer now only updates the HedgeDoc note; a new ctf-challs.timer runs --challs daily at 04:00. Locks are per target so the two runs can overlap without one skipping. Co-Authored-By: Claude Opus 5.5 --- README.md | 36 +++++++++++++++++++++++------------- ctf-challs.service | 11 +++++++++++ ctf-challs.timer | 11 +++++++++++ ctf-status.service | 4 ++-- ctf_status.py | 14 ++++++++------ install-timer.sh | 15 ++++++++------- 6 files changed, 63 insertions(+), 28 deletions(-) create mode 100644 ctf-challs.service create mode 100644 ctf-challs.timer diff --git a/README.md b/README.md index fc223cb..db487c2 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,14 @@ deployment project. If the branch is protected, the token's user must be allowed to push to it. Without `CHALLS_BRANCH`, the default branch is looked up, which also needs **Project: Read** there. -## Every 30 minutes, on a server +## On a schedule, on a server + +Two timers, independent of each other: + +| timer | when | runs | +|---|---|---| +| `ctf-status.timer` | every 30 minutes | `--update` (HedgeDoc note) | +| `ctf-challs.timer` | daily at 04:00 (server local time) | `--challs` (challs.txt commit) | Copy the repo to the server, create `ctf_status.env` or `.env` there (both are gitignored, so they do not travel with the code), then: @@ -84,27 +91,30 @@ do not travel with the code), then: ``` It writes systemd **user** units into `~/.config/systemd/user/`, substituting -whatever directory the repo actually sits in, enables the timer, turns on lingering -so it keeps firing when you are not logged in, and does one run to prove it works. +whatever directory the repo actually sits in, enables both timers, turns on lingering +so they keep firing when you are not logged in, and does one run of each to prove they work. Re-run it any time; it just overwrites the units. ```sh -systemctl --user list-timers ctf-status.timer # when it next fires -journalctl --user -u ctf-status.service -n 50 # what it did -systemctl --user start ctf-status.service # run it now -systemctl --user disable --now ctf-status.timer # stop it +systemctl --user list-timers 'ctf-*' # when they next fire +journalctl --user -u ctf-status.service -n 50 # what the HedgeDoc run did +journalctl --user -u ctf-challs.service -n 50 # what the challs.txt run did +systemctl --user start ctf-challs.service # commit challs.txt now +systemctl --user disable --now ctf-challs.timer # stop the daily commit ``` -The timer fires at `:00` and `:30` with up to a minute of jitter, and -`Persistent=true` catches up a run missed while the machine was down. Plain cron -works too, if you would rather: +The HedgeDoc timer fires at `:00` and `:30` with up to a minute of jitter; both +timers have `Persistent=true`, so a run missed while the machine was down is caught up. +Plain cron works too, if you would rather: ``` -*/30 * * * * /path/to/planner/ctf_status.py --update --challs --quiet +*/30 * * * * /path/to/planner/ctf_status.py --update --quiet +0 4 * * * /path/to/planner/ctf_status.py --challs --quiet ``` -`--quiet` keeps a successful run silent, so you only hear about it when the note -changed or something broke. Overlapping runs are prevented by a lock file, and the +`--quiet` keeps a successful run silent, so you only hear about it when something +changed or broke. Overlapping runs of the same target are prevented by a lock file +per target (the 04:00 challs run and the HedgeDoc run can overlap), and the HedgeDoc write is retried (`--retries`, default 3) before the run gives up with a one-line error and a non-zero exit. diff --git a/ctf-challs.service b/ctf-challs.service new file mode 100644 index 0000000..d68b36e --- /dev/null +++ b/ctf-challs.service @@ -0,0 +1,11 @@ +[Unit] +Description=Commit the testable challenges to challs.txt in the deployment repo +Documentation=file://__DIR__/README.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=__DIR__ +ExecStart=__DIR__/ctf_status.py --challs --quiet +TimeoutStartSec=5min diff --git a/ctf-challs.timer b/ctf-challs.timer new file mode 100644 index 0000000..e437f76 --- /dev/null +++ b/ctf-challs.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Refresh challs.txt in the deployment repo once a day at 04:00 + +[Timer] +OnCalendar=*-*-* 04:00:00 +AccuracySec=1min +# catch up on the last run if the machine was down when it was due +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/ctf-status.service b/ctf-status.service index 5940b26..ba1c07d 100644 --- a/ctf-status.service +++ b/ctf-status.service @@ -1,5 +1,5 @@ [Unit] -Description=Push the CTF challenge status to HedgeDoc and challs.txt +Description=Push the CTF challenge status to HedgeDoc Documentation=file://__DIR__/README.md After=network-online.target Wants=network-online.target @@ -7,6 +7,6 @@ Wants=network-online.target [Service] Type=oneshot WorkingDirectory=__DIR__ -ExecStart=__DIR__/ctf_status.py --update --challs --quiet +ExecStart=__DIR__/ctf_status.py --update --quiet # GitLab walk + HedgeDoc write; never let one run squat on the next timer slot TimeoutStartSec=5min diff --git a/ctf_status.py b/ctf_status.py index 4f4a13d..a0ca53b 100755 --- a/ctf_status.py +++ b/ctf_status.py @@ -690,13 +690,14 @@ def push_hedgedoc(base: str, md: str) -> str: return loc if loc.startswith("http") else base.rstrip("/") + loc -def single_run_lock(): - """Keep a slow or hung cron run from colliding with the next one.""" - fh = open(Path(tempfile.gettempdir()) / "ctf_status.lock", "w") +def single_run_lock(target: str): + """Keep a slow or hung cron run from colliding with the next one. One lock per + target, so the daily challs.txt run and the HedgeDoc refresh can overlap.""" + fh = open(Path(tempfile.gettempdir()) / f"ctf_status-{target}.lock", "w") try: fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: - sys.exit("another ctf_status.py run is still going; skipping this one") + sys.exit(f"another ctf_status.py {target} run is still going; skipping this one") return fh # held until the process exits @@ -742,8 +743,9 @@ def main() -> None: sys.exit(str(e)) if args.challs and not cfg.get("CHALLS_REPO_URL"): sys.exit("--challs needs CHALLS_REPO_URL in the config") - if note_url or args.challs: - lock = single_run_lock() # noqa: F841 (held for the lifetime of the process) + # held for the lifetime of the process + locks = [single_run_lock(t) for t, on in (("hedgedoc", note_url), ("challs", args.challs)) + if on] # noqa: F841 gl = GitLab(cfg["GITLAB_GROUP_URL"], cfg["GITLAB_TOKEN"]) try: diff --git a/install-timer.sh b/install-timer.sh index c310bde..0906205 100755 --- a/install-timer.sh +++ b/install-timer.sh @@ -1,5 +1,6 @@ #!/usr/bin/env sh -# Install the 30-minute HedgeDoc refresh as systemd *user* units. +# Install the 30-minute HedgeDoc refresh and the daily 04:00 challs.txt commit +# as systemd *user* units. # Run it as the user that owns ctf_status.env / .env, on the machine that should do the # polling. Re-running it is safe; it just overwrites the units. set -eu @@ -16,20 +17,20 @@ done [ $# -gt 0 ] || { echo "no ctf_status.env or .env in $dir — copy ctf_status.env.example to one first" >&2 exit 1; } -# the timer runs --update --challs, which need these two +# the timers run --update and --challs, which need these two for key in HEDGEDOC_NOTE CHALLS_REPO_URL; do grep -qi "^$key=..*" "$@" || { echo "set $key=... in $* first" >&2; exit 1; } done chmod 600 "$@" mkdir -p "$units" -for u in ctf-status.service ctf-status.timer; do +for u in ctf-status.service ctf-status.timer ctf-challs.service ctf-challs.timer; do sed "s|__DIR__|$dir|g" "$dir/$u" > "$units/$u" echo "installed $units/$u" done systemctl --user daemon-reload -systemctl --user enable --now ctf-status.timer +systemctl --user enable --now ctf-status.timer ctf-challs.timer # without lingering, user units are torn down as soon as you log out if ! loginctl show-user "$(id -un)" -p Linger --value 2>/dev/null | grep -qx yes; then @@ -38,6 +39,6 @@ if ! loginctl show-user "$(id -un)" -p Linger --value 2>/dev/null | grep -qx yes fi echo -echo "one run now, to prove it works:" -systemctl --user start ctf-status.service && echo " ok" -systemctl --user list-timers ctf-status.timer --no-pager +echo "one run of each now, to prove they work:" +systemctl --user start ctf-status.service ctf-challs.service && echo " ok" +systemctl --user list-timers ctf-status.timer ctf-challs.timer --no-pager