Add the server rollover, its systemd units, and an installer

Never modifies an existing note. It only creates today's file when that
file is absent, so the blast radius is one new file and re-running is a
no-op.

Two bugs that only a real run on the server could surface. tempfile
creates 0600 and os.replace preserves it, so notes would have landed
private in a tree where everything is 0644 and both sftpgo and syncthing
read; the previous note's mode is now carried over rather than hardcoded.
And --dry-run fell into a degenerate branch once today's file existed,
emitting a bare heading exactly when a preview is most wanted, so it now
always previews from the newest note that is not today.

The unit is sandboxed, and that was verified against the NFS mount rather
than assumed: writes to the notes directory succeed, and writes to its
parent are refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
This commit is contained in:
Connor Johnstone
2026-09-09 18:33:54 -04:00
co-authored by Claude Opus 5
parent 185b45dbfc
commit 213e285c37
5 changed files with 143 additions and 0 deletions
+1
View File
@@ -3,3 +3,4 @@
local.properties
.gradle/
build/
__pycache__/
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# Install the rollover onto the server. Idempotent; safe to re-run after edits.
set -eu
LIB=/home/connor/.local/lib/todo
UNITS=/etc/systemd/system
HERE=$(cd "$(dirname "$0")" && pwd)
mkdir -p "$LIB"
install -m 0755 "$HERE/prune.py" "$HERE/rollover.py" "$LIB/"
sudo install -m 0644 "$HERE/todo-rollover.service" "$HERE/todo-rollover.timer" "$UNITS/"
sudo systemctl daemon-reload
echo "installed. enable with: sudo systemctl enable --now todo-rollover.timer"
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Create today's note from the most recent one, pruning finished work.
Runs on the server, once, at 00:01. A single writer is the whole point: two
clients each creating today's file independently is what produced the Syncthing
conflicts this system is replacing.
Never modifies an existing file. If today's note is already there it exits
without touching anything, so re-running is always safe.
"""
import argparse
import os
import re
import stat
import sys
import tempfile
from datetime import datetime
from zoneinfo import ZoneInfo
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from prune import prune
NOTE = re.compile(r"^\d{4}-\d{2}-\d{2}\.md$")
DATE_HEADING = re.compile(r"^#\s+\d{4}-\d{2}-\d{2}\s*$")
def latest_note(d, exclude=None):
"""Newest note by filename. Deliberately not by mtime: this tree is on NFS,
where mtimes come from the server's clock rather than the writer's."""
notes = sorted(f for f in os.listdir(d) if NOTE.match(f) and f != exclude)
return notes[-1] if notes else None
def retitle(text, today):
"""Replace the date heading on line 1, or prepend one if it is missing."""
lines = text.split("\n")
if lines and DATE_HEADING.match(lines[0]):
lines[0] = f"# {today}"
return "\n".join(lines)
return f"# {today}\n\n" + text
def write_atomic(path, text, mode):
d = os.path.dirname(path)
fd, tmp = tempfile.mkstemp(dir=d, prefix=".rollover-", suffix=".tmp")
try:
with os.fdopen(fd, "w") as fh:
fh.write(text)
# mkstemp makes the file private; the notes are 0644 and syncthing and
# sftpgo both read this tree, so carry the previous note's mode over.
os.chmod(tmp, mode)
os.replace(tmp, path)
except BaseException:
os.path.exists(tmp) and os.unlink(tmp)
raise
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--dir", default=os.path.expanduser("~/docs/notes/todo"))
ap.add_argument("--tz", default="America/Louisville")
ap.add_argument("--date", help="override today, for testing")
ap.add_argument("--dry-run", action="store_true", help="print, do not write")
a = ap.parse_args()
today = a.date or datetime.now(ZoneInfo(a.tz)).strftime("%Y-%m-%d")
target = os.path.join(a.dir, f"{today}.md")
if os.path.exists(target) and not a.dry_run:
print(f"{today}.md already exists, nothing to do")
return 0
prev = latest_note(a.dir, exclude=f"{today}.md")
if prev is None:
sys.exit(f"no notes in {a.dir} to roll forward from, refusing to guess")
src = os.path.join(a.dir, prev)
mode = stat.S_IMODE(os.stat(src).st_mode)
body = retitle(prune(open(src).read()), today)
if a.dry_run:
sys.stdout.write(body)
print(f"\n--- would write {target} from {prev} ---", file=sys.stderr)
return 0
write_atomic(target, body, mode)
print(f"created {today}.md from {prev}")
return 0
if __name__ == "__main__":
sys.exit(main())
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=Roll today's todo note forward from the most recent one
Documentation=https://github.com/connorjohnstone/todo
After=network-online.target remote-fs.target
Wants=remote-fs.target
[Service]
Type=oneshot
User=connor
Group=connor
ExecStart=/home/connor/.local/lib/todo/rollover.py
# The notes live on an NFS mount, so a stalled server must not wedge the timer.
TimeoutStartSec=120
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/home/connor/docs/notes/todo
NoNewPrivileges=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
+13
View File
@@ -0,0 +1,13 @@
[Unit]
Description=Roll the todo note forward at 00:01
[Timer]
OnCalendar=*-*-* 00:01:00
Timezone=America/Louisville
# Catch up after downtime, so a server that was off at midnight still rolls over.
Persistent=true
AccuracySec=1min
Unit=todo-rollover.service
[Install]
WantedBy=timers.target