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 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 17:47:57 +02:00
co-authored by Claude Opus 5.5
parent cb5184a55d
commit ec2f444ef3
6 changed files with 63 additions and 28 deletions
+23 -13
View File
@@ -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.
+11
View File
@@ -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
+11
View File
@@ -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
+2 -2
View File
@@ -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
+8 -6
View File
@@ -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:
+8 -7
View File
@@ -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