Files
inference-bot 43f8f8c3fe operator-token-runbook: add the credential rotation policy and reminder
Records the 12-month cadence and the trigger rules agreed in co-op/support#35,
a last-rotated table (first entry: the Open Collective token, rotated
2026-10-27; other rows blank because nothing is recorded), and the account map
behind the credentials. Adds scripts/rotation_due.py, which reads the table and
prints only when a credential is within 30 days of its due date.
2026-11-04 14:36:57 +00:00

157 lines
5.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""Rotation-due watchdog for the co-op credential rotation policy.
Reads the `last rotated` table from the operator token runbook and prints a
reminder ONLY when a credential is due -- i.e. within 30 days of its due date
(last rotation + 12 months), or already past it. Prints NOTHING when nothing is
due, so it can drive a watchdog cron where empty output means "no message".
Deterministic: the output depends only on the table and the current date. It
prints no timestamps of its own, so when nothing is due it produces no output at
all and stays silent across runs.
Table source (first that applies):
1. --file PATH
2. $ROTATION_RUNBOOK
3. the runbook fetched from Gitea's main branch (authoritative, always current)
4. the local clone ~/repos/ops-docs/operator-token-runbook.md
Exit codes: 0 ok (including "nothing due", no output), 2 could not read the
runbook or found no dated rows -- a loud failure so a watcher is not silently
blind.
"""
import argparse
import calendar
import datetime as dt
import os
import re
import sys
import urllib.request
RUNBOOK_URL = "https://git.inference.coop/co-op/ops-docs/raw/branch/main/operator-token-runbook.md"
LOCAL_FALLBACK = os.path.expanduser("~/repos/ops-docs/operator-token-runbook.md")
DEFAULT_WITHIN_DAYS = 30
SECTION_RE = re.compile(r"^#{1,6}\s+.*last\s+rotated.*$", re.IGNORECASE)
HEADING_RE = re.compile(r"^#{1,6}\s+")
SEP_RE = re.compile(r"[\s|:\-]+")
DATE_RE = re.compile(r"\b(\d{4})-(\d{2})-(\d{2})\b")
def fetch_text(args):
if args.file:
try:
with open(args.file, encoding="utf-8") as fh:
return fh.read()
except OSError as exc:
print(f"rotation_due: could not read --file {args.file} ({exc})", file=sys.stderr)
raise SystemExit(2)
env = os.environ.get("ROTATION_RUNBOOK")
if env and os.path.exists(env):
try:
with open(env, encoding="utf-8") as fh:
return fh.read()
except OSError as exc:
print(f"rotation_due: could not read $ROTATION_RUNBOOK {env} ({exc})", file=sys.stderr)
raise SystemExit(2)
try:
req = urllib.request.Request(RUNBOOK_URL, headers={"User-Agent": "rotation_due/1.0"})
with urllib.request.urlopen(req, timeout=20) as resp:
return resp.read().decode("utf-8")
except Exception as exc: # noqa: BLE001 - fall back, then fail loudly
if os.path.exists(LOCAL_FALLBACK):
with open(LOCAL_FALLBACK, encoding="utf-8") as fh:
return fh.read()
print(
f"rotation_due: could not read the runbook from {RUNBOOK_URL} ({exc}) "
f"and no local copy at {LOCAL_FALLBACK}",
file=sys.stderr,
)
raise SystemExit(2)
def extract_section(text):
"""Return the lines of the first 'last rotated' section."""
out, inside = [], False
for line in text.splitlines():
if SECTION_RE.match(line):
inside = True
continue
if inside and HEADING_RE.match(line):
break
if inside:
out.append(line)
return out
def parse_rows(section_lines):
"""Yield (name, last_rotated_date) for table rows that carry a date."""
for line in section_lines:
if not line.lstrip().startswith("|"):
continue
stripped = line.strip().strip("|")
if not stripped or SEP_RE.fullmatch(line.strip()):
continue
cells = [c.strip() for c in stripped.split("|")]
if not cells:
continue
match = DATE_RE.search(line)
if not match:
continue
year, month, day = (int(g) for g in match.groups())
try:
last = dt.date(year, month, day)
except ValueError:
continue
yield cells[0], last
def add_months(date, months):
month_index = date.month - 1 + months
year = date.year + month_index // 12
month = month_index % 12 + 1
day = min(date.day, calendar.monthrange(year, month)[1])
return dt.date(year, month, day)
def main():
ap = argparse.ArgumentParser(description="Remind when a credential rotation is due.")
ap.add_argument("--file", help="read the runbook from this path instead of fetching it")
ap.add_argument("--within-days", type=int, default=DEFAULT_WITHIN_DAYS,
help="days before the due date to start reminding (default 30)")
ap.add_argument("--as-of", help="evaluate as of this date (YYYY-MM-DD); default today UTC")
args = ap.parse_args()
section = extract_section(fetch_text(args))
if not section:
print("rotation_due: no 'last rotated' section found in the runbook", file=sys.stderr)
return 2
parsed = list(parse_rows(section))
if not parsed:
print("rotation_due: the 'last rotated' section has no rows carrying a date", file=sys.stderr)
return 2
as_of = dt.date.fromisoformat(args.as_of) if args.as_of else dt.datetime.now(dt.timezone.utc).date()
due = []
for name, last in parsed:
due_date = add_months(last, 12)
days = (due_date - as_of).days
if days <= args.within_days:
due.append((days, name, due_date))
if not due:
return 0 # nothing due -> no output at all
due.sort()
for days, name, due_date in due:
if days < 0:
print(f"credential rotation OVERDUE: {name} -- was due {due_date.isoformat()} ({-days} days ago)")
else:
print(f"credential rotation due soon: {name} -- due {due_date.isoformat()} (in {days} days)")
return 0
if __name__ == "__main__":
sys.exit(main())