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.
157 lines
5.6 KiB
Python
Executable File
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())
|