From b9668f33bfd1448db88946f0efe29fd7a4e60cf2 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Mon, 5 Oct 2026 15:04:09 +1100 Subject: [PATCH 1/3] feat: alert before a token the jobs use expires A daily token-expiry job posts to the alerts channel 14, 7 and 1 days before a token expires, and once when it has. The GitHub token's expiry comes from GitHub's response header; the Claude Code token is dated by fingerprint from the day the job first sees it, with an optional issue date for one that predates the job; the Crowdin token's is recorded in /etc/session-ops/expiry.toml. --- README.md | 1 + deploy/README.md | 2 + deploy/env/expiry.toml.example | 14 ++ deploy/install.sh | 2 +- docs/jobs/token-expiry.md | 28 +++ pyproject.toml | 1 + src/session_ops/jobs.toml | 16 ++ src/session_ops/monitor/token_expiry.py | 300 ++++++++++++++++++++++++ tests/goldens/units/dropins.txt | 20 ++ tests/monitor/test_token_expiry.py | 207 ++++++++++++++++ 10 files changed, 590 insertions(+), 1 deletion(-) create mode 100644 deploy/env/expiry.toml.example create mode 100644 docs/jobs/token-expiry.md create mode 100644 src/session_ops/monitor/token_expiry.py create mode 100644 tests/monitor/test_token_expiry.py diff --git a/README.md b/README.md index 1d5224b..3715a72 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ share for translations. One package, `session_ops`, deployed to one self-hosted | `snode-list` | Weekday: the fallback service node list into session-ios | [snode-list](docs/jobs/snode-list.md) | | `release-stats` | On demand: download counts of the latest releases | [release-stats](docs/jobs/release-stats.md) | | `session-ops-silence` | Discord alerts for a job that failed, or stopped running | [session-ops-silence](docs/jobs/session-ops-silence.md) | +| `token-expiry` | Discord alerts 14, 7 and 1 days before a token expires | [token-expiry](docs/jobs/token-expiry.md) | Run by hand, not scheduled: [`sogs-ban`](docs/tools/sogs-ban.md) for community bans, and [`sogs-perms`](src/session_ops/sogs/perms.py) for a room's per-account permissions. diff --git a/deploy/README.md b/deploy/README.md index e0fe969..314bab1 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -56,6 +56,8 @@ files and state over. Remove `/opt/zendesk`, `/etc/zendesk` and `/var/lib/zendes Each `/etc/session-ops/.env` has a commented `.env.example` beside it, installed from [`env/`](env/), saying what goes in it. +`/etc/session-ops/expiry.toml` is not a secret: the expiry dates +[token-expiry](../docs/jobs/token-expiry.md) cannot read from an API, from `expiry.toml.example`. ## Checking diff --git a/deploy/env/expiry.toml.example b/deploy/env/expiry.toml.example new file mode 100644 index 0000000..ddf894c --- /dev/null +++ b/deploy/env/expiry.toml.example @@ -0,0 +1,14 @@ +# /etc/session-ops/expiry.toml: what token-expiry cannot learn by itself. Not a +# secret: mode 644. + +[expires] +# Crowdin → Account Settings → API → Personal Access Tokens, the Expires column. +# A date, or "never" when it does not expire or this host does not use it. +#CROWDIN_API_TOKEN = "never" + +# Only for a Claude Code token installed before token-expiry first ran: it otherwise +# dates a token from the day it first saw it. Ignored once that token is replaced. +# The fingerprint is printed by `session-ops run token-expiry --dry-run`. +#[issued.CLAUDE_CODE_OAUTH_TOKEN] +#fingerprint = "3f2a9c01b4de" +#date = 2025-11-20 diff --git a/deploy/install.sh b/deploy/install.sh index 6b78646..74d4fde 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -73,7 +73,7 @@ for name in zendesk github-prs crowdin publish alerts; do done # What each file takes, commented; the env files stay empty until filled, since a job # is enabled once its env files have content. -install -m 644 "$ROOT"/deploy/env/*.env.example "$ETC/" +install -m 644 "$ROOT"/deploy/env/*.example "$ETC/" # The publishing units load this as a credential, and a missing file would stop them # starting; left empty, their runs exit naming the key. [ -e "$ETC/github-app.pem" ] || install -m 600 /dev/null "$ETC/github-app.pem" diff --git a/docs/jobs/token-expiry.md b/docs/jobs/token-expiry.md new file mode 100644 index 0000000..14127e9 --- /dev/null +++ b/docs/jobs/token-expiry.md @@ -0,0 +1,28 @@ +# Token Expiry Alerts + +Posts to the alerts channel 14, 7 and 1 days before a token the jobs use expires, and +once when it has. Quiet otherwise. + +| | | +| --- | --- | +| Runs | `session-ops@token-expiry.timer`, daily at 09:00 Melbourne | +| Secrets | `/etc/session-ops/alerts.env`: `ALERT_DISCORD_WEBHOOK_URL`; `github-prs.env` and `zendesk.env` for the tokens it checks | +| Dates | `/etc/session-ops/expiry.toml`, from [`expiry.toml.example`](../../deploy/env/expiry.toml.example) | +| Dry run | `session-ops run token-expiry --dry-run` prints each token's expiry and the alert it would post | +| Logs | `journalctl -u session-ops@token-expiry -n 50 --no-pager` | + +| Token | Expiry from | +| --- | --- | +| `GITHUB_PRS_TOKEN` | GitHub's `github-authentication-token-expiration` header; nothing to update on rotation | +| `CROWDIN_API_TOKEN` | `expiry.toml`: the Expires column at https://crowdin.com/settings#api-key | +| `CLAUDE_CODE_OAUTH_TOKEN` | A year after the job first saw it, by fingerprint; nothing to update on rotation | + +`CROWDIN_API_TOKEN` is reported until `expiry.toml` has a date or `"never"` for it. + +The Claude Code token's first sighting lives in `/var/lib/session-ops/token-expiry/state.json`. +For a token installed before the job first ran, or if that file is lost, add its real +issue date under `[issued]` with the fingerprint the dry run prints; the entry is +ignored once the token changes. A date that is wrong is not caught: the Zendesk +digest's failure alert, which names a dead Claude login, is then the backstop. + +The Zendesk API token, the Discord webhooks and the GitHub App key do not expire. diff --git a/pyproject.toml b/pyproject.toml index 8480762..b8d908c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ sogs-perms = "session_ops.sogs.perms:cli" session-ops = "session_ops.ops.runner:main" session-ops-alert = "session_ops.monitor.alert:main" session-ops-silence = "session_ops.monitor.silence:main" +session-ops-token-expiry = "session_ops.monitor.token_expiry:main" [dependency-groups] dev = [ diff --git a/src/session_ops/jobs.toml b/src/session_ops/jobs.toml index b23b351..05dfdae 100644 --- a/src/session_ops/jobs.toml +++ b/src/session_ops/jobs.toml @@ -84,6 +84,22 @@ schedule = "hourly" max_age_hours = 3 timeout = "5min" +[[job]] +name = "token-expiry" +description = "Alert before a token the jobs use expires" +entry = "session_ops.monitor.token_expiry:main" +args = ["--state", "{state}/state.json"] +user = "sessionops" +# For the GitHub token it probes and the Claude Code token it fingerprints, which brings +# the rest of zendesk.env's secrets into this unit. expiry.toml holds none. +env_files = ["/etc/session-ops/alerts.env", "/etc/session-ops/github-prs.env", + "/etc/session-ops/zendesk.env"] +env = ["ALERT_DISCORD_WEBHOOK_URL"] +schedule = "*-*-* 09:00 Australia/Melbourne" +# A day, plus the hour a DST change adds and slack for the run. +max_age_hours = 27 +timeout = "5min" + [[job]] name = "crowdin-sync" description = "Crowdin translations into the platform repos" diff --git a/src/session_ops/monitor/token_expiry.py b/src/session_ops/monitor/token_expiry.py new file mode 100644 index 0000000..bd5e84f --- /dev/null +++ b/src/session_ops/monitor/token_expiry.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +""" +Alert before a token the jobs use expires. + +Each token's expiry comes from one of three places: + +- GITHUB_PRS_TOKEN: GitHub's response header, so a rotation needs nothing updated. +- CLAUDE_CODE_OAUTH_TOKEN: a year after this job first saw it, by a fingerprint kept + in --state, so a rotation needs nothing updated either. A token installed before the + job existed gets its real issue date from [issued] in the expiry file, which applies + only while that same token is in place. +- CROWDIN_API_TOKEN: exposes its expiry to no API, so it is recorded by hand, and + reported until it is. + +/etc/session-ops/expiry.toml: + + [expires] + # A date, or "never" when it does not expire or this host does not use it. + CROWDIN_API_TOKEN = "never" + + [issued.CLAUDE_CODE_OAUTH_TOKEN] + # As printed by --dry-run. + fingerprint = "3f2a9c01b4de" + date = 2025-11-20 + +Posts once as each token comes within 14, 7 and 1 days of expiring, and once more +when it has expired; a new expiry date starts it over. Success is quiet. + +Config: + ALERT_DISCORD_WEBHOOK_URL where the alert goes (not needed with --dry-run) + GITHUB_PRS_TOKEN probed for its expiry; skipped when unset + CLAUDE_CODE_OAUTH_TOKEN fingerprinted; skipped when unset + +Usage: + session-ops-token-expiry --state /var/lib/session-ops/token-expiry/state.json + session-ops-token-expiry --dry-run # print every token's expiry and the alert +""" +import argparse +import hashlib +import json +import os +import socket +import sys +import tomllib +from dataclasses import dataclass +from datetime import date, datetime, timedelta, timezone + +from session_ops.shared import discord, http +from session_ops.shared.env import get_env + +EXPIRY_FILE = "/etc/session-ops/expiry.toml" +THRESHOLDS = (1, 7, 14) +NEVER = "never" +GITHUB, RECORDED, FIRST_SEEN = "github", "recorded", "first seen" +GITHUB_RATE_LIMIT = "https://api.github.com/rate_limit" +GITHUB_EXPIRY_HEADER = "github-authentication-token-expiration" + + +@dataclass(frozen=True) +class Token: + env_file: str + renew: str + source: str + lifetime_days: int = None + + +TOKENS = { + "GITHUB_PRS_TOKEN": Token("/etc/session-ops/github-prs.env", + "GitHub → Settings → Developer settings → Personal access tokens", + GITHUB), + "CROWDIN_API_TOKEN": Token("/etc/session-ops/crowdin.env", + "https://crowdin.com/settings#api-key", RECORDED), + "CLAUDE_CODE_OAUTH_TOKEN": Token("/etc/session-ops/zendesk.env", + "`claude setup-token` on a machine with a browser, " + "then restart zendesk-relay", + FIRST_SEEN, lifetime_days=365), +} + + +@dataclass(frozen=True) +class Config: + expires: dict + issued: dict + + +def as_date(value): + return value.date() if isinstance(value, datetime) else value + + +def load_config(path): + """The expiry file's [expires] and [issued]; empty when it does not exist yet.""" + try: + with open(path, "rb") as handle: + data = tomllib.load(handle) + except FileNotFoundError: + return Config({}, {}) + except tomllib.TOMLDecodeError as exc: + sys.exit(f"{path}: {exc}") + expires = {} + for name, value in data.get("expires", {}).items(): + value = as_date(value) + if not (isinstance(value, date) or value == NEVER): + sys.exit(f"{path}: {name} must be a date (2027-03-01) or \"{NEVER}\", not {value!r}") + expires[name] = value + issued = {} + for name, entry in data.get("issued", {}).items(): + when = as_date(entry.get("date")) if isinstance(entry, dict) else None + if not (isinstance(when, date) and isinstance(entry.get("fingerprint"), str)): + sys.exit(f"{path}: [issued.{name}] needs a fingerprint and a date") + issued[name] = (entry["fingerprint"], when) + return Config(expires, issued) + + +def fingerprint(value): + """Enough of a hash to tell tokens apart, and nothing that helps recover one.""" + return hashlib.sha256(value.encode()).hexdigest()[:12] + + +def issued_on(name, value, issued, seen, today): + """When the token in `value` was issued: its [issued] date while that names this + token, else the day it was first seen. Records it in `seen`.""" + current = fingerprint(value) + recorded = issued.get(name) + entry = seen.get(name, {}) + if recorded and recorded[0] == current: + since = recorded[1] + elif entry.get("fingerprint") == current: + since = date.fromisoformat(entry["since"]) + else: + since = today + seen[name] = {"fingerprint": current, "since": since.isoformat()} + return since + + +def parse_github_expiry(value): + """The date in GitHub's expiry header, "2027-10-02 03:00:00 UTC".""" + try: + return date.fromisoformat(value[:10]) + except ValueError: + raise ValueError(f"unreadable {GITHUB_EXPIRY_HEADER}: {value!r}") from None + + +def probe_github(session, token): + """The token's expiry date, NEVER, or "rejected" when GitHub refuses it.""" + resp = session.request("GET", GITHUB_RATE_LIMIT, + headers={"Authorization": f"Bearer {token}"}) + if resp.status_code == 401: + return "rejected" + resp.raise_for_status() + header = resp.headers.get(GITHUB_EXPIRY_HEADER) + return parse_github_expiry(header) if header else NEVER + + +def level(expiry, today): + """What an alert about `expiry` would say, or None while it needs none.""" + if expiry in ("missing", "rejected"): + return expiry + if expiry == NEVER: + return None + days = (expiry - today).days + if days < 0: + return "expired" + return next((f"{t}d" for t in THRESHOLDS if days <= t), None) + + +def evaluate(expiries, state, today): + """The tokens due an alert, as (name, expiry, level). `state` drops every token + that needs no alert, so a renewed one starts over; recording an alert is left to + a delivered post.""" + due = [] + for name, expiry in expiries.items(): + current = level(expiry, today) + if current is None: + state.pop(name, None) + elif state.get(name) != record(expiry, current): + due.append((name, expiry, current)) + for name in [name for name in state if name not in expiries]: + del state[name] + return due + + +def record(expiry, current): + return {"level": current, "expires": expiry.isoformat() if isinstance(expiry, date) else None} + + +def describe(expiry, current, today, expiry_file): + if current == "missing": + return f"no expiry date in `{expiry_file}`" + if current == "rejected": + return "GitHub rejects it (401)" + days = (expiry - today).days + if days < 0: + return f"**expired** on {expiry.isoformat()}" + when = "today" if days == 0 else "tomorrow" if days == 1 else f"in {days} days" + return f"expires **{expiry.isoformat()}**, {when}" + + +def build_message(due, host, today, expiry_file): + lines = [f"🔑 **Token expiry** on `{host}`"] + for name, expiry, current in due: + token = TOKENS[name] + lines.append(f"• **{name}**: {describe(expiry, current, today, expiry_file)}.") + if current == "missing": + continue + then = f", then its date in `{expiry_file}`" if token.source == RECORDED else "" + lines.append(f" Renew: {token.renew}; update `{token.env_file}`{then}.") + return "\n".join(lines) + + +def utc_today(): + return datetime.now(timezone.utc).date() + + +def load_state(path): + """{"alerts": ..., "seen": ...}. Losing it re-dates a fingerprinted token from the + next run, which alerts late: the reason it is saved even when a post fails.""" + data = {} + if path and os.path.exists(path): + try: + with open(path, encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, ValueError): + data = {} + data = data if isinstance(data, dict) else {} + return {"alerts": data.get("alerts", {}), "seen": data.get("seen", {})} + + +def save_state(path, state): + temporary = f"{path}.tmp" + with open(temporary, "w", encoding="utf-8") as handle: + json.dump(state, handle, indent=2, sort_keys=True) + os.replace(temporary, path) + + +def collect(config, environ, seen, session, today): + """{name: expiry} for every token in use. A token set nowhere is left out, and so + is its fingerprint.""" + expiries = {} + for name, token in TOKENS.items(): + value = environ.get(name) + if token.source == RECORDED: + expiries[name] = config.expires.get(name, "missing") + elif not value: + seen.pop(name, None) + elif token.source == GITHUB: + expiries[name] = probe_github(session, value) + else: + since = issued_on(name, value, config.issued, seen, today) + expiries[name] = since + timedelta(days=token.lifetime_days) + return expiries + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Alert before a token expires.") + parser.add_argument("--expiry-file", default=EXPIRY_FILE) + parser.add_argument("--state", metavar="PATH", + help="Alert bookkeeping; without it every run re-alerts.") + parser.add_argument("--webhook", help="Discord webhook URL (else ALERT_DISCORD_WEBHOOK_URL).") + parser.add_argument("--dry-run", action="store_true", + help="Print the alert instead of posting it; write no state.") + args = parser.parse_args(argv) + + webhook = get_env("ALERT_DISCORD_WEBHOOK_URL", args.webhook, required=not args.dry_run) + now = utc_today() + state = load_state(args.state) + expiries = collect(load_config(args.expiry_file), os.environ, state["seen"], + http.Session(), now) + for name in TOKENS: + expiry = expiries.get(name, "not set") + line = f"{name}: {expiry.isoformat() if isinstance(expiry, date) else expiry}" + seen = state["seen"].get(name) + if seen: + line += f" (fingerprint {seen['fingerprint']}, issued {seen['since']})" + print(line) + + due = evaluate(expiries, state["alerts"], now) + persist = bool(args.state) and not args.dry_run + if not due: + print("Nothing due an alert.") + if persist: + save_state(args.state, state) + return + + message = build_message(due, socket.gethostname(), now, args.expiry_file) + if args.dry_run: + print(message) + return + if not discord.post_to_discord(http.Session(), webhook, [{"content": message}]): + if persist: + save_state(args.state, state) + sys.exit("Could not post the token expiry alert to Discord.") + for name, expiry, current in due: + state["alerts"][name] = record(expiry, current) + if persist: + save_state(args.state, state) + print(f"Alerted on {len(due)} token(s).") + + +if __name__ == "__main__": + main() diff --git a/tests/goldens/units/dropins.txt b/tests/goldens/units/dropins.txt index 27a9306..b9de733 100644 --- a/tests/goldens/units/dropins.txt +++ b/tests/goldens/units/dropins.txt @@ -89,6 +89,26 @@ EnvironmentFile=/etc/session-ops/publish.env TimeoutStartSec=10min LoadCredential=github-app.pem:/etc/session-ops/github-app.pem +==> session-ops@token-expiry.service.d/job.conf <== +# Generated by `session-ops units` from jobs.toml. Edit the registry, not this. + +[Unit] +Description=Alert before a token the jobs use expires + +[Service] +User=sessionops +Group=sessionops +EnvironmentFile=/etc/session-ops/alerts.env +EnvironmentFile=/etc/session-ops/github-prs.env +EnvironmentFile=/etc/session-ops/zendesk.env +TimeoutStartSec=5min + +==> session-ops@token-expiry.timer.d/schedule.conf <== +# Generated by `session-ops units` from jobs.toml. Edit the registry, not this. + +[Timer] +OnCalendar=*-*-* 09:00 Australia/Melbourne + ==> session-ops@zendesk-digest.service.d/job.conf <== # Generated by `session-ops units` from jobs.toml. Edit the registry, not this. diff --git a/tests/monitor/test_token_expiry.py b/tests/monitor/test_token_expiry.py new file mode 100644 index 0000000..6d07b68 --- /dev/null +++ b/tests/monitor/test_token_expiry.py @@ -0,0 +1,207 @@ +""" + uv run python -m unittest tests.monitor.test_token_expiry +""" +import os +import tempfile +import unittest +from datetime import date, timedelta +from unittest import mock + +from session_ops.monitor import token_expiry + +TODAY = date(2026, 10, 5) + + +def expiry_file(text): + handle = tempfile.NamedTemporaryFile("w", suffix=".toml", delete=False) + handle.write(text) + handle.close() + return handle.name + + +CLAUDE = "CLAUDE_CODE_OAUTH_TOKEN" +YEAR = timedelta(days=365) + + +class TestConfig(unittest.TestCase): + def test_reads_expires_and_issued(self): + config = token_expiry.load_config(expiry_file( + '[expires]\nCROWDIN_API_TOKEN = 2027-03-01\n' + '[issued.CLAUDE_CODE_OAUTH_TOKEN]\nfingerprint = "abc"\ndate = 2025-11-20\n')) + self.assertEqual(config.expires, {"CROWDIN_API_TOKEN": date(2027, 3, 1)}) + self.assertEqual(config.issued, {CLAUDE: ("abc", date(2025, 11, 20))}) + + def test_a_missing_file_records_nothing(self): + self.assertEqual(token_expiry.load_config("/nonexistent/expiry.toml"), + token_expiry.Config({}, {})) + + def test_refuses_an_expiry_that_is_neither_a_date_nor_never(self): + with self.assertRaises(SystemExit): + token_expiry.load_config(expiry_file('[expires]\nCROWDIN_API_TOKEN = "March"\n')) + + def test_refuses_an_issued_entry_without_a_fingerprint(self): + with self.assertRaises(SystemExit): + token_expiry.load_config(expiry_file('[issued.CLAUDE_CODE_OAUTH_TOKEN]\n' + 'date = 2025-11-20\n')) + + +class TestCollect(unittest.TestCase): + def collect(self, environ, seen=None, config=None, today=TODAY): + seen = {} if seen is None else seen + expiries = token_expiry.collect(config or token_expiry.Config({}, {}), environ, + seen, None, today) + return expiries, seen + + def test_crowdin_without_a_date_is_missing_and_unset_tokens_are_left_out(self): + expiries, _ = self.collect({}) + self.assertEqual(expiries, {"CROWDIN_API_TOKEN": "missing"}) + + def test_a_new_claude_token_expires_a_year_after_it_was_first_seen(self): + expiries, seen = self.collect({CLAUDE: "tok-a"}) + self.assertEqual(expiries[CLAUDE], TODAY + YEAR) + later, _ = self.collect({CLAUDE: "tok-a"}, seen, today=TODAY + timedelta(days=30)) + self.assertEqual(later[CLAUDE], TODAY + YEAR) + + def test_a_replaced_token_starts_a_new_year(self): + _, seen = self.collect({CLAUDE: "tok-a"}) + renewed = TODAY + timedelta(days=300) + expiries, _ = self.collect({CLAUDE: "tok-b"}, seen, today=renewed) + self.assertEqual(expiries[CLAUDE], renewed + YEAR) + + def test_the_issued_date_applies_only_to_the_token_it_names(self): + issued = date(2025, 11, 20) + config = token_expiry.Config({}, {CLAUDE: (token_expiry.fingerprint("tok-a"), issued)}) + expiries, seen = self.collect({CLAUDE: "tok-a"}, config=config) + self.assertEqual(expiries[CLAUDE], issued + YEAR) + expiries, _ = self.collect({CLAUDE: "tok-b"}, seen, config) + self.assertEqual(expiries[CLAUDE], TODAY + YEAR) + + def test_the_state_holds_a_fingerprint_not_the_token(self): + _, seen = self.collect({CLAUDE: "tok-a"}) + self.assertNotIn("tok-a", repr(seen)) + self.assertEqual(seen[CLAUDE]["fingerprint"], token_expiry.fingerprint("tok-a")) + + def test_an_unset_token_forgets_its_fingerprint(self): + _, seen = self.collect({CLAUDE: "tok-a"}) + self.collect({}, seen) + self.assertEqual(seen, {}) + + +class FakeResponse: + def __init__(self, status, headers=None): + self.status_code, self.headers = status, headers or {} + + def raise_for_status(self): + if self.status_code >= 400: + raise RuntimeError(self.status_code) + + +class TestProbe(unittest.TestCase): + def probe(self, response): + session = mock.Mock() + session.request.return_value = response + return token_expiry.probe_github(session, "tok") + + def test_reads_the_expiry_header(self): + response = FakeResponse(200, {token_expiry.GITHUB_EXPIRY_HEADER: + "2027-10-02 03:00:00 UTC"}) + self.assertEqual(self.probe(response), date(2027, 10, 2)) + + def test_no_header_is_a_token_that_never_expires(self): + self.assertEqual(self.probe(FakeResponse(200)), "never") + + def test_a_401_is_reported_not_raised(self): + self.assertEqual(self.probe(FakeResponse(401)), "rejected") + + def test_any_other_error_fails_the_run(self): + with self.assertRaises(RuntimeError): + self.probe(FakeResponse(503)) + + +class TestEvaluate(unittest.TestCase): + def due_in(self, days, state=None): + expiry = date.fromordinal(TODAY.toordinal() + days) + return token_expiry.evaluate({"CROWDIN_API_TOKEN": expiry}, + {} if state is None else state, TODAY) + + def test_quiet_until_fourteen_days_out(self): + self.assertEqual(self.due_in(15), []) + self.assertEqual(self.due_in(14)[0][2], "14d") + + def test_each_threshold_and_expiry_alerts(self): + self.assertEqual([self.due_in(d)[0][2] for d in (8, 7, 2, 1, 0, -3)], + ["14d", "7d", "7d", "1d", "1d", "expired"]) + + def test_an_alerted_threshold_is_not_repeated(self): + expiry = date(2026, 10, 15) + state = {"CROWDIN_API_TOKEN": token_expiry.record(expiry, "14d")} + self.assertEqual(token_expiry.evaluate({"CROWDIN_API_TOKEN": expiry}, state, TODAY), []) + + def test_a_renewed_token_clears_its_state(self): + state = {"CROWDIN_API_TOKEN": token_expiry.record(date(2026, 10, 6), "1d")} + self.assertEqual(self.due_in(365, state), []) + self.assertEqual(state, {}) + + def test_missing_and_rejected_alert_once(self): + state = {} + due = token_expiry.evaluate({"CLAUDE_CODE_OAUTH_TOKEN": "missing", + "GITHUB_PRS_TOKEN": "rejected"}, state, TODAY) + self.assertEqual([d[2] for d in due], ["missing", "rejected"]) + for name, expiry, current in due: + state[name] = token_expiry.record(expiry, current) + self.assertEqual(token_expiry.evaluate({"CLAUDE_CODE_OAUTH_TOKEN": "missing", + "GITHUB_PRS_TOKEN": "rejected"}, state, TODAY), + []) + + def test_never_is_quiet(self): + self.assertEqual(token_expiry.evaluate({"CROWDIN_API_TOKEN": "never"}, {}, TODAY), []) + + +class TestMessage(unittest.TestCase): + def test_names_the_token_its_date_and_how_to_renew(self): + message = token_expiry.build_message( + [("CROWDIN_API_TOKEN", date(2026, 10, 12), "7d"), + ("GITHUB_PRS_TOKEN", date(2026, 10, 6), "1d")], + "box", TODAY, "/etc/session-ops/expiry.toml") + self.assertIn("`box`", message) + self.assertIn("**CROWDIN_API_TOKEN**: expires **2026-10-12**, in 7 days.", message) + self.assertIn("crowdin.com/settings#api-key", message) + self.assertIn("then its date in `/etc/session-ops/expiry.toml`", message) + self.assertIn("**GITHUB_PRS_TOKEN**: expires **2026-10-06**, tomorrow.", message) + + def test_a_missing_date_names_the_file(self): + message = token_expiry.build_message([("CLAUDE_CODE_OAUTH_TOKEN", "missing", "missing")], + "box", TODAY, "/x/expiry.toml") + self.assertIn("no expiry date in `/x/expiry.toml`", message) + self.assertNotIn("Renew", message) + + +class TestMain(unittest.TestCase): + def test_a_delivered_alert_is_recorded_and_not_repeated(self): + state = os.path.join(tempfile.mkdtemp(), "state.json") + path = expiry_file('[expires]\nCROWDIN_API_TOKEN = 2026-10-10\n') + argv = ["--expiry-file", path, "--state", state, "--webhook", "https://example.invalid"] + with mock.patch.dict(os.environ, {}, clear=True), \ + mock.patch.object(token_expiry, "utc_today", return_value=TODAY), \ + mock.patch.object(token_expiry.discord, "post_to_discord", + return_value=1) as post: + token_expiry.main(argv) + token_expiry.main(argv) + self.assertEqual(post.call_count, 1) + + def test_a_failed_post_keeps_the_fingerprint_but_records_no_alert(self): + state = os.path.join(tempfile.mkdtemp(), "state.json") + path = expiry_file('[expires]\nCROWDIN_API_TOKEN = 2026-10-10\n') + with mock.patch.dict(os.environ, {CLAUDE: "tok-a"}, clear=True), \ + mock.patch.object(token_expiry, "utc_today", return_value=TODAY), \ + mock.patch.object(token_expiry.discord, "post_to_discord", return_value=0), \ + self.assertRaises(SystemExit): + token_expiry.main(["--expiry-file", path, "--state", state, + "--webhook", "https://example.invalid"]) + saved = token_expiry.load_state(state) + self.assertEqual(saved["alerts"], {}) + self.assertEqual(saved["seen"][CLAUDE]["since"], TODAY.isoformat()) + + +if __name__ == "__main__": + unittest.main() From 05b9c5a53002b628061329887591d292545a1649 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 6 Oct 2026 13:50:59 +1100 Subject: [PATCH 2/3] fix: time token expiry in UTC hours, not dates The 09:00 Melbourne run is the previous day in UTC, so comparing dates could call an expiry hours away "tomorrow", skip the alert on the expiry date, and report expiry two days late. Expiries are now UTC times (a date-only one counts from its start) and the alerts say "in less than 14 days / 7 days / 24h". --- README.md | 2 +- docs/jobs/token-expiry.md | 5 +- src/session_ops/monitor/token_expiry.py | 71 ++++++++------ tests/monitor/test_token_expiry.py | 120 +++++++++++++++--------- 4 files changed, 120 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 3715a72..5e56364 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ share for translations. One package, `session_ops`, deployed to one self-hosted | `snode-list` | Weekday: the fallback service node list into session-ios | [snode-list](docs/jobs/snode-list.md) | | `release-stats` | On demand: download counts of the latest releases | [release-stats](docs/jobs/release-stats.md) | | `session-ops-silence` | Discord alerts for a job that failed, or stopped running | [session-ops-silence](docs/jobs/session-ops-silence.md) | -| `token-expiry` | Discord alerts 14, 7 and 1 days before a token expires | [token-expiry](docs/jobs/token-expiry.md) | +| `token-expiry` | Discord alerts 14 days, 7 days and 24 hours before a token expires | [token-expiry](docs/jobs/token-expiry.md) | Run by hand, not scheduled: [`sogs-ban`](docs/tools/sogs-ban.md) for community bans, and [`sogs-perms`](src/session_ops/sogs/perms.py) for a room's per-account permissions. diff --git a/docs/jobs/token-expiry.md b/docs/jobs/token-expiry.md index 14127e9..df1443d 100644 --- a/docs/jobs/token-expiry.md +++ b/docs/jobs/token-expiry.md @@ -1,7 +1,8 @@ # Token Expiry Alerts -Posts to the alerts channel 14, 7 and 1 days before a token the jobs use expires, and -once when it has. Quiet otherwise. +Posts to the alerts channel 14 days, 7 days and 24 hours before a token the jobs use +expires, and once when it has. Quiet otherwise. Times are UTC; a date without one, such +as Crowdin's, counts from 00:00 UTC that day. | | | | --- | --- | diff --git a/src/session_ops/monitor/token_expiry.py b/src/session_ops/monitor/token_expiry.py index bd5e84f..8c8cbcd 100644 --- a/src/session_ops/monitor/token_expiry.py +++ b/src/session_ops/monitor/token_expiry.py @@ -23,8 +23,10 @@ fingerprint = "3f2a9c01b4de" date = 2025-11-20 -Posts once as each token comes within 14, 7 and 1 days of expiring, and once more -when it has expired; a new expiry date starts it over. Success is quiet. +Posts once as each token comes within 14 days, 7 days and 24 hours of expiring, and +once more when it has expired; a new expiry date starts it over. Times are UTC, and a +date without one counts from its start, so a run on any timezone's morning errs early. +Success is quiet. Config: ALERT_DISCORD_WEBHOOK_URL where the alert goes (not needed with --dry-run) @@ -83,6 +85,10 @@ class Config: issued: dict +def start_of(day): + return datetime(day.year, day.month, day.day, tzinfo=timezone.utc) + + def as_date(value): return value.date() if isinstance(value, datetime) else value @@ -133,15 +139,16 @@ def issued_on(name, value, issued, seen, today): def parse_github_expiry(value): - """The date in GitHub's expiry header, "2027-10-02 03:00:00 UTC".""" + """The time in GitHub's expiry header, "2027-10-02 03:00:00 UTC".""" try: - return date.fromisoformat(value[:10]) + return datetime.strptime(value.removesuffix(" UTC")[:19], "%Y-%m-%d %H:%M:%S") \ + .replace(tzinfo=timezone.utc) except ValueError: raise ValueError(f"unreadable {GITHUB_EXPIRY_HEADER}: {value!r}") from None def probe_github(session, token): - """The token's expiry date, NEVER, or "rejected" when GitHub refuses it.""" + """The token's expiry time, NEVER, or "rejected" when GitHub refuses it.""" resp = session.request("GET", GITHUB_RATE_LIMIT, headers={"Authorization": f"Bearer {token}"}) if resp.status_code == 401: @@ -151,25 +158,25 @@ def probe_github(session, token): return parse_github_expiry(header) if header else NEVER -def level(expiry, today): +def level(expiry, now): """What an alert about `expiry` would say, or None while it needs none.""" if expiry in ("missing", "rejected"): return expiry if expiry == NEVER: return None - days = (expiry - today).days - if days < 0: + left = expiry - now + if left <= timedelta(0): return "expired" - return next((f"{t}d" for t in THRESHOLDS if days <= t), None) + return next((f"{t}d" for t in THRESHOLDS if left <= timedelta(days=t)), None) -def evaluate(expiries, state, today): +def evaluate(expiries, state, now): """The tokens due an alert, as (name, expiry, level). `state` drops every token that needs no alert, so a renewed one starts over; recording an alert is left to a delivered post.""" due = [] for name, expiry in expiries.items(): - current = level(expiry, today) + current = level(expiry, now) if current is None: state.pop(name, None) elif state.get(name) != record(expiry, current): @@ -180,26 +187,31 @@ def evaluate(expiries, state, today): def record(expiry, current): - return {"level": current, "expires": expiry.isoformat() if isinstance(expiry, date) else None} + return {"level": current, "expires": shown(expiry) if isinstance(expiry, datetime) else None} + + +def shown(when): + return when.strftime("%Y-%m-%d %H:%M UTC") + + +WITHIN = {"1d": "in less than 24h", "7d": "in less than 7 days", "14d": "in less than 14 days"} -def describe(expiry, current, today, expiry_file): +def describe(expiry, current, expiry_file): if current == "missing": return f"no expiry date in `{expiry_file}`" if current == "rejected": return "GitHub rejects it (401)" - days = (expiry - today).days - if days < 0: - return f"**expired** on {expiry.isoformat()}" - when = "today" if days == 0 else "tomorrow" if days == 1 else f"in {days} days" - return f"expires **{expiry.isoformat()}**, {when}" + if current == "expired": + return f"**expired** at {shown(expiry)}" + return f"expires **{shown(expiry)}**, {WITHIN[current]}" -def build_message(due, host, today, expiry_file): +def build_message(due, host, expiry_file): lines = [f"🔑 **Token expiry** on `{host}`"] for name, expiry, current in due: token = TOKENS[name] - lines.append(f"• **{name}**: {describe(expiry, current, today, expiry_file)}.") + lines.append(f"• **{name}**: {describe(expiry, current, expiry_file)}.") if current == "missing": continue then = f", then its date in `{expiry_file}`" if token.source == RECORDED else "" @@ -207,8 +219,8 @@ def build_message(due, host, today, expiry_file): return "\n".join(lines) -def utc_today(): - return datetime.now(timezone.utc).date() +def utc_now(): + return datetime.now(timezone.utc) def load_state(path): @@ -232,21 +244,22 @@ def save_state(path, state): os.replace(temporary, path) -def collect(config, environ, seen, session, today): +def collect(config, environ, seen, session, now): """{name: expiry} for every token in use. A token set nowhere is left out, and so is its fingerprint.""" expiries = {} for name, token in TOKENS.items(): value = environ.get(name) if token.source == RECORDED: - expiries[name] = config.expires.get(name, "missing") + expiry = config.expires.get(name, "missing") + expiries[name] = start_of(expiry) if isinstance(expiry, date) else expiry elif not value: seen.pop(name, None) elif token.source == GITHUB: expiries[name] = probe_github(session, value) else: - since = issued_on(name, value, config.issued, seen, today) - expiries[name] = since + timedelta(days=token.lifetime_days) + since = issued_on(name, value, config.issued, seen, now.date()) + expiries[name] = start_of(since + timedelta(days=token.lifetime_days)) return expiries @@ -261,13 +274,13 @@ def main(argv=None): args = parser.parse_args(argv) webhook = get_env("ALERT_DISCORD_WEBHOOK_URL", args.webhook, required=not args.dry_run) - now = utc_today() + now = utc_now() state = load_state(args.state) expiries = collect(load_config(args.expiry_file), os.environ, state["seen"], http.Session(), now) for name in TOKENS: expiry = expiries.get(name, "not set") - line = f"{name}: {expiry.isoformat() if isinstance(expiry, date) else expiry}" + line = f"{name}: {shown(expiry) if isinstance(expiry, datetime) else expiry}" seen = state["seen"].get(name) if seen: line += f" (fingerprint {seen['fingerprint']}, issued {seen['since']})" @@ -281,7 +294,7 @@ def main(argv=None): save_state(args.state, state) return - message = build_message(due, socket.gethostname(), now, args.expiry_file) + message = build_message(due, socket.gethostname(), args.expiry_file) if args.dry_run: print(message) return diff --git a/tests/monitor/test_token_expiry.py b/tests/monitor/test_token_expiry.py index 6d07b68..55dd7d5 100644 --- a/tests/monitor/test_token_expiry.py +++ b/tests/monitor/test_token_expiry.py @@ -4,12 +4,20 @@ import os import tempfile import unittest -from datetime import date, timedelta +from datetime import date, datetime, timedelta, timezone from unittest import mock from session_ops.monitor import token_expiry -TODAY = date(2026, 10, 5) + + +def utc(*args): + return datetime(*args, tzinfo=timezone.utc) + + +# 09:00 Melbourne on 6 October, the job's schedule: still the 5th in UTC. +NOW = utc(2026, 10, 5, 22) +TODAY = NOW.date() def expiry_file(text): @@ -46,35 +54,39 @@ def test_refuses_an_issued_entry_without_a_fingerprint(self): class TestCollect(unittest.TestCase): - def collect(self, environ, seen=None, config=None, today=TODAY): + def collect(self, environ, seen=None, config=None, now=NOW): seen = {} if seen is None else seen expiries = token_expiry.collect(config or token_expiry.Config({}, {}), environ, - seen, None, today) + seen, None, now) return expiries, seen def test_crowdin_without_a_date_is_missing_and_unset_tokens_are_left_out(self): expiries, _ = self.collect({}) self.assertEqual(expiries, {"CROWDIN_API_TOKEN": "missing"}) + def test_a_recorded_date_counts_from_its_start_in_utc(self): + config = token_expiry.Config({"CROWDIN_API_TOKEN": date(2027, 3, 1)}, {}) + self.assertEqual(self.collect({}, config=config)[0]["CROWDIN_API_TOKEN"], + utc(2027, 3, 1)) + def test_a_new_claude_token_expires_a_year_after_it_was_first_seen(self): expiries, seen = self.collect({CLAUDE: "tok-a"}) - self.assertEqual(expiries[CLAUDE], TODAY + YEAR) - later, _ = self.collect({CLAUDE: "tok-a"}, seen, today=TODAY + timedelta(days=30)) - self.assertEqual(later[CLAUDE], TODAY + YEAR) + self.assertEqual(expiries[CLAUDE], utc(2027, 10, 5)) + later, _ = self.collect({CLAUDE: "tok-a"}, seen, now=NOW + timedelta(days=30)) + self.assertEqual(later[CLAUDE], utc(2027, 10, 5)) def test_a_replaced_token_starts_a_new_year(self): _, seen = self.collect({CLAUDE: "tok-a"}) - renewed = TODAY + timedelta(days=300) - expiries, _ = self.collect({CLAUDE: "tok-b"}, seen, today=renewed) - self.assertEqual(expiries[CLAUDE], renewed + YEAR) + expiries, _ = self.collect({CLAUDE: "tok-b"}, seen, now=utc(2027, 8, 1, 22)) + self.assertEqual(expiries[CLAUDE], utc(2028, 7, 31)) def test_the_issued_date_applies_only_to_the_token_it_names(self): - issued = date(2025, 11, 20) - config = token_expiry.Config({}, {CLAUDE: (token_expiry.fingerprint("tok-a"), issued)}) + config = token_expiry.Config({}, {CLAUDE: (token_expiry.fingerprint("tok-a"), + date(2025, 11, 20))}) expiries, seen = self.collect({CLAUDE: "tok-a"}, config=config) - self.assertEqual(expiries[CLAUDE], issued + YEAR) + self.assertEqual(expiries[CLAUDE], utc(2026, 11, 20)) expiries, _ = self.collect({CLAUDE: "tok-b"}, seen, config) - self.assertEqual(expiries[CLAUDE], TODAY + YEAR) + self.assertEqual(expiries[CLAUDE], utc(2027, 10, 5)) def test_the_state_holds_a_fingerprint_not_the_token(self): _, seen = self.collect({CLAUDE: "tok-a"}) @@ -102,10 +114,10 @@ def probe(self, response): session.request.return_value = response return token_expiry.probe_github(session, "tok") - def test_reads_the_expiry_header(self): + def test_reads_the_time_from_the_expiry_header(self): response = FakeResponse(200, {token_expiry.GITHUB_EXPIRY_HEADER: "2027-10-02 03:00:00 UTC"}) - self.assertEqual(self.probe(response), date(2027, 10, 2)) + self.assertEqual(self.probe(response), utc(2027, 10, 2, 3)) def test_no_header_is_a_token_that_never_expires(self): self.assertEqual(self.probe(FakeResponse(200)), "never") @@ -119,59 +131,75 @@ def test_any_other_error_fails_the_run(self): class TestEvaluate(unittest.TestCase): - def due_in(self, days, state=None): - expiry = date.fromordinal(TODAY.toordinal() + days) - return token_expiry.evaluate({"CROWDIN_API_TOKEN": expiry}, - {} if state is None else state, TODAY) - - def test_quiet_until_fourteen_days_out(self): - self.assertEqual(self.due_in(15), []) - self.assertEqual(self.due_in(14)[0][2], "14d") - - def test_each_threshold_and_expiry_alerts(self): - self.assertEqual([self.due_in(d)[0][2] for d in (8, 7, 2, 1, 0, -3)], - ["14d", "7d", "7d", "1d", "1d", "expired"]) - - def test_an_alerted_threshold_is_not_repeated(self): - expiry = date(2026, 10, 15) + def level_at(self, left, state=None): + due = token_expiry.evaluate({"CROWDIN_API_TOKEN": NOW + left}, + {} if state is None else state, NOW) + return due[0][2] if due else None + + def test_each_window_by_time_left(self): + hours = [24 * 14 + 1, 24 * 14, 24 * 7 + 1, 24 * 7, 25, 24, 1, 0, -1] + self.assertEqual([self.level_at(timedelta(hours=h)) for h in hours], + [None, "14d", "14d", "7d", "7d", "1d", "1d", "expired", "expired"]) + + def test_the_last_warning_comes_within_a_day_of_an_expiry_hours_away(self): + """09:00 Melbourne on 2 Oct is 23:00 UTC on 1 Oct; the token expires at 03:00 UTC on 2 Oct.""" + expiry, now = utc(2027, 10, 2, 3), utc(2027, 10, 1, 23) + due = token_expiry.evaluate({"GITHUB_PRS_TOKEN": expiry}, {}, now) + self.assertEqual(due, [("GITHUB_PRS_TOKEN", expiry, "1d")]) + + def test_a_date_only_expiry_warns_the_run_before_and_reports_expired_the_run_after(self): + expiry = utc(2027, 3, 1) + runs = [utc(2027, 2, 27, 22), utc(2027, 2, 28, 22), utc(2027, 3, 1, 22)] + self.assertEqual([token_expiry.level(expiry, run) for run in runs], + ["7d", "1d", "expired"]) + + def test_an_alerted_window_is_not_repeated(self): + expiry = NOW + timedelta(days=10) state = {"CROWDIN_API_TOKEN": token_expiry.record(expiry, "14d")} - self.assertEqual(token_expiry.evaluate({"CROWDIN_API_TOKEN": expiry}, state, TODAY), []) + self.assertEqual(token_expiry.evaluate({"CROWDIN_API_TOKEN": expiry}, state, NOW), []) def test_a_renewed_token_clears_its_state(self): - state = {"CROWDIN_API_TOKEN": token_expiry.record(date(2026, 10, 6), "1d")} - self.assertEqual(self.due_in(365, state), []) + state = {"CROWDIN_API_TOKEN": token_expiry.record(NOW + timedelta(hours=5), "1d")} + self.assertIsNone(self.level_at(timedelta(days=365), state)) self.assertEqual(state, {}) def test_missing_and_rejected_alert_once(self): state = {} due = token_expiry.evaluate({"CLAUDE_CODE_OAUTH_TOKEN": "missing", - "GITHUB_PRS_TOKEN": "rejected"}, state, TODAY) + "GITHUB_PRS_TOKEN": "rejected"}, state, NOW) self.assertEqual([d[2] for d in due], ["missing", "rejected"]) for name, expiry, current in due: state[name] = token_expiry.record(expiry, current) self.assertEqual(token_expiry.evaluate({"CLAUDE_CODE_OAUTH_TOKEN": "missing", - "GITHUB_PRS_TOKEN": "rejected"}, state, TODAY), + "GITHUB_PRS_TOKEN": "rejected"}, state, NOW), []) def test_never_is_quiet(self): - self.assertEqual(token_expiry.evaluate({"CROWDIN_API_TOKEN": "never"}, {}, TODAY), []) + self.assertEqual(token_expiry.evaluate({"CROWDIN_API_TOKEN": "never"}, {}, NOW), []) class TestMessage(unittest.TestCase): - def test_names_the_token_its_date_and_how_to_renew(self): + def test_names_the_token_its_time_window_and_how_to_renew(self): message = token_expiry.build_message( - [("CROWDIN_API_TOKEN", date(2026, 10, 12), "7d"), - ("GITHUB_PRS_TOKEN", date(2026, 10, 6), "1d")], - "box", TODAY, "/etc/session-ops/expiry.toml") + [("CROWDIN_API_TOKEN", utc(2026, 10, 12), "7d"), + ("GITHUB_PRS_TOKEN", utc(2026, 10, 6, 3), "1d")], + "box", "/etc/session-ops/expiry.toml") self.assertIn("`box`", message) - self.assertIn("**CROWDIN_API_TOKEN**: expires **2026-10-12**, in 7 days.", message) + self.assertIn("**CROWDIN_API_TOKEN**: expires **2026-10-12 00:00 UTC**, " + "in less than 7 days.", message) self.assertIn("crowdin.com/settings#api-key", message) self.assertIn("then its date in `/etc/session-ops/expiry.toml`", message) - self.assertIn("**GITHUB_PRS_TOKEN**: expires **2026-10-06**, tomorrow.", message) + self.assertIn("**GITHUB_PRS_TOKEN**: expires **2026-10-06 03:00 UTC**, " + "in less than 24h.", message) + + def test_an_expired_token_says_when(self): + message = token_expiry.build_message( + [("GITHUB_PRS_TOKEN", utc(2026, 10, 5, 3), "expired")], "box", "/x/expiry.toml") + self.assertIn("**expired** at 2026-10-05 03:00 UTC", message) def test_a_missing_date_names_the_file(self): message = token_expiry.build_message([("CLAUDE_CODE_OAUTH_TOKEN", "missing", "missing")], - "box", TODAY, "/x/expiry.toml") + "box", "/x/expiry.toml") self.assertIn("no expiry date in `/x/expiry.toml`", message) self.assertNotIn("Renew", message) @@ -182,7 +210,7 @@ def test_a_delivered_alert_is_recorded_and_not_repeated(self): path = expiry_file('[expires]\nCROWDIN_API_TOKEN = 2026-10-10\n') argv = ["--expiry-file", path, "--state", state, "--webhook", "https://example.invalid"] with mock.patch.dict(os.environ, {}, clear=True), \ - mock.patch.object(token_expiry, "utc_today", return_value=TODAY), \ + mock.patch.object(token_expiry, "utc_now", return_value=NOW), \ mock.patch.object(token_expiry.discord, "post_to_discord", return_value=1) as post: token_expiry.main(argv) @@ -193,7 +221,7 @@ def test_a_failed_post_keeps_the_fingerprint_but_records_no_alert(self): state = os.path.join(tempfile.mkdtemp(), "state.json") path = expiry_file('[expires]\nCROWDIN_API_TOKEN = 2026-10-10\n') with mock.patch.dict(os.environ, {CLAUDE: "tok-a"}, clear=True), \ - mock.patch.object(token_expiry, "utc_today", return_value=TODAY), \ + mock.patch.object(token_expiry, "utc_now", return_value=NOW), \ mock.patch.object(token_expiry.discord, "post_to_discord", return_value=0), \ self.assertRaises(SystemExit): token_expiry.main(["--expiry-file", path, "--state", state, From 7331fd351e21d077355935d00ef600edca36b8ed Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 6 Oct 2026 14:53:01 +1100 Subject: [PATCH 3/3] fix: report a token with no recorded expiry on every run, not once --- docs/jobs/token-expiry.md | 3 ++- src/session_ops/monitor/token_expiry.py | 10 +++++----- tests/monitor/test_token_expiry.py | 16 ++++++++++------ 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/docs/jobs/token-expiry.md b/docs/jobs/token-expiry.md index df1443d..a164f43 100644 --- a/docs/jobs/token-expiry.md +++ b/docs/jobs/token-expiry.md @@ -18,7 +18,8 @@ as Crowdin's, counts from 00:00 UTC that day. | `CROWDIN_API_TOKEN` | `expiry.toml`: the Expires column at https://crowdin.com/settings#api-key | | `CLAUDE_CODE_OAUTH_TOKEN` | A year after the job first saw it, by fingerprint; nothing to update on rotation | -`CROWDIN_API_TOKEN` is reported until `expiry.toml` has a date or `"never"` for it. +`CROWDIN_API_TOKEN` is reported on every daily run until `expiry.toml` has a date or +`"never"` for it. The Claude Code token's first sighting lives in `/var/lib/session-ops/token-expiry/state.json`. For a token installed before the job first ran, or if that file is lost, add its real diff --git a/src/session_ops/monitor/token_expiry.py b/src/session_ops/monitor/token_expiry.py index 8c8cbcd..86fba44 100644 --- a/src/session_ops/monitor/token_expiry.py +++ b/src/session_ops/monitor/token_expiry.py @@ -10,7 +10,7 @@ job existed gets its real issue date from [issued] in the expiry file, which applies only while that same token is in place. - CROWDIN_API_TOKEN: exposes its expiry to no API, so it is recorded by hand, and - reported until it is. + reported on every run until it is. /etc/session-ops/expiry.toml: @@ -171,15 +171,15 @@ def level(expiry, now): def evaluate(expiries, state, now): - """The tokens due an alert, as (name, expiry, level). `state` drops every token - that needs no alert, so a renewed one starts over; recording an alert is left to - a delivered post.""" + """The tokens due an alert, as (name, expiry, level): each window once, a missing + date on every run. `state` drops every token that needs no alert, so a renewed one + starts over; recording an alert is left to a delivered post.""" due = [] for name, expiry in expiries.items(): current = level(expiry, now) if current is None: state.pop(name, None) - elif state.get(name) != record(expiry, current): + elif current == "missing" or state.get(name) != record(expiry, current): due.append((name, expiry, current)) for name in [name for name in state if name not in expiries]: del state[name] diff --git a/tests/monitor/test_token_expiry.py b/tests/monitor/test_token_expiry.py index 55dd7d5..5147e2e 100644 --- a/tests/monitor/test_token_expiry.py +++ b/tests/monitor/test_token_expiry.py @@ -163,16 +163,20 @@ def test_a_renewed_token_clears_its_state(self): self.assertIsNone(self.level_at(timedelta(days=365), state)) self.assertEqual(state, {}) - def test_missing_and_rejected_alert_once(self): + def test_a_missing_date_alerts_on_every_run_and_rejected_once(self): state = {} - due = token_expiry.evaluate({"CLAUDE_CODE_OAUTH_TOKEN": "missing", - "GITHUB_PRS_TOKEN": "rejected"}, state, NOW) + expiries = {"CROWDIN_API_TOKEN": "missing", "GITHUB_PRS_TOKEN": "rejected"} + due = token_expiry.evaluate(expiries, state, NOW) self.assertEqual([d[2] for d in due], ["missing", "rejected"]) for name, expiry, current in due: state[name] = token_expiry.record(expiry, current) - self.assertEqual(token_expiry.evaluate({"CLAUDE_CODE_OAUTH_TOKEN": "missing", - "GITHUB_PRS_TOKEN": "rejected"}, state, NOW), - []) + self.assertEqual(token_expiry.evaluate(expiries, state, NOW + timedelta(days=1)), + [("CROWDIN_API_TOKEN", "missing", "missing")]) + + def test_a_date_recorded_later_stops_the_missing_alert(self): + state = {"CROWDIN_API_TOKEN": token_expiry.record("missing", "missing")} + self.assertEqual(token_expiry.evaluate({"CROWDIN_API_TOKEN": "never"}, state, NOW), []) + self.assertEqual(state, {}) def test_never_is_quiet(self): self.assertEqual(token_expiry.evaluate({"CROWDIN_API_TOKEN": "never"}, {}, NOW), [])