diff --git a/README.md b/README.md index 8f412b5..f720f8d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ share for translations. One package, `session_ops`, deployed to one self-hosted | `crowdin-duplicates` | Crowdin string slots holding more than one translation, as they open and close | [crowdin-duplicates](docs/jobs/crowdin-duplicates.md) | | `crowdin-sync` | Weekday: Crowdin translations into iOS, Android and the localization module, and its submodule bumped in each client | [crowdin-sync](docs/jobs/crowdin-sync.md) | | `snode-list` | Weekday: the fallback service node list from the seed nodes into dynamic-assets, Desktop and 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) | +| `mau` | Monthly: active users per platform and in total, Android's from the Play Console export dropped in its inbox | [mau](docs/jobs/mau.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 days, 7 days and 24 hours before a token expires | [token-expiry](docs/jobs/token-expiry.md) | diff --git a/deploy/README.md b/deploy/README.md index a1c6cf3..8808cd2 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -7,6 +7,7 @@ does the work: accounts, venv, env files, units, timers, and migrating an older | Unit | What it is | | --- | --- | | `session-ops@.timer` → `.service` | One per job; a generated drop-in sets its account, env files and schedule. | +| `session-ops@.path` → `.service` | For a job with a `watch`: starts it when a matching file lands in its state directory. | | `session-ops-queue.timer` → `.service` | Starts the jobs in `jobs.toml`'s `[queue]`, which then run one at a time in its order. | | `zendesk-relay.service` | Always on, `127.0.0.1:8080`: Zendesk's `claude:` note webhooks. | | `session-ops-alert@.service` | Every unit's `OnFailure=` backstop; see [session-ops-silence](../docs/jobs/session-ops-silence.md). | @@ -85,6 +86,9 @@ To end it, stop the timers, then close the rehearsal pull requests: for link in /etc/systemd/system/timers.target.wants/session-ops@*.timer; do [ -L "$link" ] && systemctl disable --now "${link##*/}" done +for link in /etc/systemd/system/paths.target.wants/session-ops@*.path; do + [ -L "$link" ] && systemctl disable --now "${link##*/}" +done systemctl disable --now session-ops-queue.timer zendesk-relay.service for repo in session-android session-ios session-localization session-desktop-dynamic-assets \ session-desktop session-app session-website session-appium session-playwright; do diff --git a/deploy/env/alerts.env.example b/deploy/env/alerts.env.example index 3a0d509..d36b964 100644 --- a/deploy/env/alerts.env.example +++ b/deploy/env/alerts.env.example @@ -1,3 +1,3 @@ -# /etc/session-ops/alerts.env: the OnFailure backstop, the silence checker and -# release-stats. Empty disables the first two, and install.sh warns until it is set. +# /etc/session-ops/alerts.env: the OnFailure backstop and the silence checker. +# Empty disables both, and install.sh warns until it is set. ALERT_DISCORD_WEBHOOK_URL= diff --git a/deploy/env/mau.env.example b/deploy/env/mau.env.example new file mode 100644 index 0000000..8240ffa --- /dev/null +++ b/deploy/env/mau.env.example @@ -0,0 +1,5 @@ +# /etc/session-ops/mau.env: the monthly active users post. +# Where the figures, the reminders and the job's own failures are posted. +MAU_DISCORD_WEBHOOK_URL= +# The user@host the reminder's rsync command copies to; defaults to the host's own name. +MAU_INBOX_HOST= diff --git a/deploy/install.sh b/deploy/install.sh index 74d4fde..53d1000 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -68,7 +68,7 @@ move /etc/github-prs/env "$ETC/github-prs.env" 600 root move "$ETC/env" "$ETC/alerts.env" 600 root # systemd reads EnvironmentFile= as root before dropping privileges, so these need # no group: a job's account cannot read another job's secrets. -for name in zendesk github-prs crowdin publish alerts; do +for name in zendesk github-prs crowdin publish alerts mau; do [ -e "$ETC/$name.env" ] || install -m 600 /dev/null "$ETC/$name.env" done # What each file takes, commented; the env files stay empty until filled, since a job @@ -92,6 +92,12 @@ if [ -e "$NEW_HOUSE" ] && grep -qx "ZENDESK_HOUSE_ANSWERS=$OLD_HOUSE" "$ETC/zend echo "pointed ZENDESK_HOUSE_ANSWERS in $ETC/zendesk.env at $NEW_HOUSE" fi +# A watched job's inbox: root drops files in, and the job's account moves them out. +"$OPS" list --watched | while read -r job user dir; do + install -d -o "$user" -g "$user" -m 711 "$STATE/$job" + install -d -o "$user" -g "$user" -m 700 "$dir" +done + install -m 644 "$ROOT/deploy/session-ops.tmpfiles" /etc/tmpfiles.d/session-ops.conf systemd-tmpfiles --create session-ops.conf @@ -104,14 +110,15 @@ rm -f "$UNITS/zendesk-alert@.service" "$UNITS/github-prs-alert@.service" systemctl disable --now crowdin-relay.service 2>/dev/null || true rm -f "$UNITS/crowdin-relay.service" -install -m 644 "$ROOT"/deploy/*.service "$ROOT"/deploy/*.timer "$UNITS/" +install -m 644 "$ROOT"/deploy/*.service "$ROOT"/deploy/*.timer "$ROOT"/deploy/*.path "$UNITS/" # Only the generated files go, so a drop-in added by hand survives. rm -f "$UNITS"/session-ops@*.service.d/job.conf "$UNITS"/session-ops@*.timer.d/schedule.conf \ - "$UNITS"/session-ops-queue.timer.d/schedule.conf + "$UNITS"/session-ops@*.path.d/watch.conf "$UNITS"/session-ops-queue.timer.d/schedule.conf "$OPS" units --out "$UNITS" >/dev/null READY=$("$OPS" list --ready) QUEUED=$("$OPS" list --queued) +WATCHED=$("$OPS" list --watched | cut -d' ' -f1) listed() { printf '%s\n' $2 | grep -qxF "$1"; } # What the queue's timer starts: its ready jobs, rebuilt from scratch each install. WANTS="$UNITS/session-ops-queue.service.wants" @@ -142,6 +149,21 @@ for job in $READY; do echo "enabled session-ops@$job.timer" fi done +for link in "$UNITS"/paths.target.wants/session-ops@*.path; do + [ -L "$link" ] || continue + job=${link##*/session-ops@} + job=${job%.path} + if ! listed "$job" "$READY" || ! listed "$job" "$WATCHED"; then + systemctl disable --now "session-ops@$job.path" >/dev/null + echo "disabled session-ops@$job.path (no longer a ready job with a watch)" + fi +done +for job in $WATCHED; do + if listed "$job" "$READY"; then + systemctl enable --now "session-ops@$job.path" >/dev/null + echo "enabled session-ops@$job.path" + fi +done if [ -d "$WANTS" ]; then systemctl enable --now session-ops-queue.timer >/dev/null echo "enabled session-ops-queue.timer" diff --git a/deploy/session-ops@.path b/deploy/session-ops@.path new file mode 100644 index 0000000..06108dc --- /dev/null +++ b/deploy/session-ops@.path @@ -0,0 +1,12 @@ +# Starts a job when a file matching its watch lands. Its drop-in, written by +# `session-ops units`, sets PathExistsGlob= from jobs.toml's watch. +[Unit] +Description=Inbox watch for session-ops job %i +Documentation=https://github.com/session-foundation/session-shared-scripts + +[Path] +# Re-triggers for as long as a file matches, so the job must move each one out. +Unit=session-ops@%i.service + +[Install] +WantedBy=paths.target diff --git a/docs/jobs/mau.md b/docs/jobs/mau.md new file mode 100644 index 0000000..2fe3e05 --- /dev/null +++ b/docs/jobs/mau.md @@ -0,0 +1,66 @@ +# Monthly Active Users + +Posts last month's monthly active users once a month, per platform and in total. + +- Android: Play's MAU on the month's last day, with the change on the month before. + Google has no API for it, so the figures come from the Play Console export someone + drops in the job's inbox. +- Desktop, which has no active-user count: the latest stable release's downloads per + platform since its release, read from GitHub and Flathub when the post goes out. +- The total adds the two. + +| | | +| --- | --- | +| Runs | `session-ops@mau.timer` on the 10th at 11:00 Melbourne, and `session-ops@mau.path` whenever a `.csv` lands in the inbox | +| Secrets | `/etc/session-ops/mau.env`: `MAU_DISCORD_WEBHOOK_URL`, and `MAU_INBOX_HOST` for the reminder's `rsync` | +| Inbox | `/var/lib/session-ops/mau/inbox/` | +| Dry run | `session-ops run mau --dry-run` prints what it would post, and moves and writes nothing | +| Logs | `journalctl -u session-ops@mau -n 50 --no-pager` | + +## The export + +In Play Console, Statistics, a report saved once: + +- Metric: Monthly active users (MAU), Unique users, Per interval, Daily +- All countries / regions, no breakdown +- A date range ending today, such as Last 30 days, with the Console in English + +Export report → CSV, then: + +```sh +rsync "All countries _ regions.csv" root@:/var/lib/session-ops/mau/inbox/ +``` + +`rsync`, not `scp`: it writes to a hidden temporary name and renames it once complete, +and the job only reads `*.csv`. + +## What a run does + +1. Merges every export in `inbox/` into `history.json`, one figure per day, then moves + it to `done/`. An export may cover any range; where two give a day different + figures, the later one wins and the post lists the revision. +2. Moves a file it cannot read to `rejected/` and fails the run naming it, after the + rest of the run. +3. Posts last month once `history.json` holds its last day. Play's figures trail by + about eight days, so that is around the 9th. Until then, from the 10th, each run + posts a reminder instead. +4. Records the month as posted, so neither the timer nor a later export posts it again. + +`history.json` cannot be rebuilt by a re-run: an unreadable one stops the job. If it is +lost, drop an export covering the last 365 days. + +## Desktop downloads + +The latest Desktop release that is neither a draft nor a pre-release. Updates count +where the updater fetches an installer. + +| Platform | Counted | +| --- | --- | +| Linux | `.deb`, `.AppImage`, `.rpm`, `.freebsd`, and Flathub's installs of `network.loki.Session` since the release day | +| macOS | `.dmg`, `.zip` | +| Windows | `.exe` | + +`.blockmap`, `latest*.yml` and `signature.asc` are left out. Flathub builds from the +GitHub `.deb` once, so its installs are not in GitHub's counts; Homebrew's `session` +cask downloads the GitHub `.dmg`, so it already is. Flathub keeps 180 days of daily +installs, so the run fails on a release older than that rather than undercount. diff --git a/docs/jobs/release-stats.md b/docs/jobs/release-stats.md deleted file mode 100644 index 3c41d60..0000000 --- a/docs/jobs/release-stats.md +++ /dev/null @@ -1,27 +0,0 @@ -# Release Download Statistics - -Download counts of the last ten Desktop and Android releases, per installer, from the -public releases API, as two CSV files. - -| | | -| --- | --- | -| Runs | on demand only: `systemctl start session-ops@release-stats.service` | -| Secrets | none; `/etc/session-ops/alerts.env` for its failures | -| Dry run | `session-ops run release-stats --dry-run` prints the CSVs and writes nothing | -| Logs | `journalctl -u session-ops@release-stats -n 50 --no-pager`, which carries both CSVs | - -The files land in `/var/lib/session-ops/release-stats/runs//` and are pruned -after 14 days. From a checkout, `uv run python -m session_ops.platforms.release_stats ---out .` writes them to a timestamped folder in the current directory, `.//`. - -| Desktop column | Assets counted | -| --- | --- | -| `.deb`, `.appimage`, `.rpm`, `.exe` | by extension | -| `.dmg_arm64`, `.dmg_x64`, `.zip_arm64`, `.zip_x64` | by extension and architecture | - -| Android column | Assets counted | -| --- | --- | -| `.aab` | `play-release` bundles | -| `.apk_arm64`, `.apk_armv7a`, `.apk_x86_64` | `play-release` APKs for `arm64-v8a`, `armeabi-v7a`, `x86_64` | -| `.apk_x86` | `play-release` x86 APKs, not x86_64 | -| `.apk_universal_play`, `.apk_universal_huawei` | universal APKs per store | diff --git a/src/session_ops/jobs.toml b/src/session_ops/jobs.toml index fdaddc7..179c1ba 100644 --- a/src/session_ops/jobs.toml +++ b/src/session_ops/jobs.toml @@ -10,6 +10,8 @@ # channel_env where it posts, and where its own failures are reported; a job # without one reports to ALERT_DISCORD_WEBHOOK_URL # unit further [Service] lines, over the template's hardening +# watch a glob under {state}: a file matching it starts the job, which must +# move it out, or the path unit starts it again # # max_age_hours for a queued job, its queue's: the weekend's 72 h (73 h across # April's DST change), plus every job ahead of it running to its timeout @@ -127,10 +129,17 @@ timeout = "10min" unit = ["LoadCredential=github-app.pem:/etc/session-ops/github-app.pem"] [[job]] -name = "release-stats" -description = "Download counts of the latest Desktop and Android releases" -entry = "session_ops.platforms.release_stats:main" -args = ["--out", "{state}/runs"] +name = "mau" +description = "Monthly active users per platform, posted once a month" +entry = "session_ops.platforms.mau:main" +args = ["--state", "{state}"] user = "sessionops" -env_files = ["/etc/session-ops/alerts.env"] +env_files = ["/etc/session-ops/mau.env"] +env = ["MAU_DISCORD_WEBHOOK_URL"] +channel_env = "MAU_DISCORD_WEBHOOK_URL" +# An hour clear of the queue's posts at 10:00. +schedule = "*-*-10 11:00 Australia/Melbourne" +watch = "inbox/*.csv" +# The longest month, plus the hour a DST change adds and slack for the run. +max_age_hours = 750 timeout = "5min" diff --git a/src/session_ops/ops/registry.py b/src/session_ops/ops/registry.py index 9ed9186..7085af8 100644 --- a/src/session_ops/ops/registry.py +++ b/src/session_ops/ops/registry.py @@ -30,6 +30,7 @@ class Job: unit: tuple = field(default=()) queued: bool = False after: tuple = () + watch: str = None @property def scheduled(self): @@ -43,6 +44,10 @@ def timer(self): def state_dir(self): return os.path.join(STATE_ROOT, self.name) + @property + def watch_glob(self): + return os.path.join(self.state_dir, self.watch) if self.watch else None + def argv(self, dry_run=False, state_dir=None): """The job's arguments, with {state} standing for its state directory.""" state = state_dir or self.state_dir @@ -84,6 +89,8 @@ def load(path=REGISTRY): for k, v in row.items()})) jobs = _apply_queue(path, jobs, data.get("queue")) for job in jobs: + if job.watch and (os.path.isabs(job.watch) or ".." in job.watch.split("/")): + raise ValueError(f"{path}: {job.name}'s watch must stay inside its state directory") if job.scheduled and not isinstance(job.max_age_hours, (int, float)): raise ValueError(f"{path}: scheduled job {job.name} needs a numeric max_age_hours") return jobs diff --git a/src/session_ops/ops/runner.py b/src/session_ops/ops/runner.py index f4ab382..7184c02 100644 --- a/src/session_ops/ops/runner.py +++ b/src/session_ops/ops/runner.py @@ -235,6 +235,8 @@ def main(argv=None): help="Only the names of scheduled jobs with an empty env file.") readiness.add_argument("--queued", action="store_true", help="Only the names of the queued jobs, in the order they run.") + readiness.add_argument("--watched", action="store_true", + help="Name, account and watched directory of each job with a watch.") run_parser = sub.add_parser("run", help="Run a job as its timer does.") run_parser.add_argument("job") run_parser.add_argument("--dry-run", action="store_true", @@ -250,6 +252,11 @@ def main(argv=None): if args.queued: print("\n".join(queue.jobs)) return + if args.watched: + for job in registry.load(): + if job.watch: + print(job.name, job.user, os.path.dirname(job.watch_glob)) + return for job in registry.load(): if args.ready or args.not_ready: if job.scheduled and ready(job) == args.ready: diff --git a/src/session_ops/ops/units.py b/src/session_ops/ops/units.py index 0d1ec64..1bdbf45 100644 --- a/src/session_ops/ops/units.py +++ b/src/session_ops/ops/units.py @@ -25,6 +25,10 @@ def timer_dropin(schedule): return "\n".join([HEADER, "[Timer]", f"OnCalendar={schedule}"]) + "\n" +def path_dropin(pattern): + return "\n".join([HEADER, "[Path]", f"PathExistsGlob={pattern}"]) + "\n" + + def dropins(jobs, queue): """{relative path: content} for every job, and the queue's schedule. @@ -38,6 +42,8 @@ def dropins(jobs, queue): files[f"session-ops@{job.name}.service.d/job.conf"] = service_dropin(job) if job.schedule: files[f"session-ops@{job.name}.timer.d/schedule.conf"] = timer_dropin(job.schedule) + if job.watch: + files[f"session-ops@{job.name}.path.d/watch.conf"] = path_dropin(job.watch_glob) return files diff --git a/src/session_ops/platforms/mau.py b/src/session_ops/platforms/mau.py new file mode 100644 index 0000000..ba501c4 --- /dev/null +++ b/src/session_ops/platforms/mau.py @@ -0,0 +1,277 @@ +""" +Monthly active users, posted once a month: Android's from the Play Console exports +dropped into the job's inbox, and the latest Desktop release's downloads, which Desktop +has in place of active users. + + session-ops run mau [--dry-run] + rsync "All countries _ regions.csv" root@:/var/lib/session-ops/mau/inbox/ + +Every export's daily figures merge into history.json, so an export may cover any range +and overlap the previous one. A month is posted once its last day is in the history; +until then, from the REMIND_DAY on, each run posts a reminder instead. +""" +import argparse +import csv +import glob +import json +import os +import socket +import time +from datetime import date, datetime, timedelta +from zoneinfo import ZoneInfo + +from session_ops.ops.runner import step +from session_ops.shared import discord, http + +MAU_COLUMN = ("Monthly Active Users (MAU) (Unique users, Per interval, Daily): " + "All countries / regions") +EXPORT_DATE = "%b %d, %Y" +# Play's daily figures trail by about eight days, so a month's last day lands around the 9th. +REMIND_DAY = 10 +ZONE = ZoneInfo("Australia/Melbourne") +VERSION = 1 +HISTORY = "history.json" + +DESKTOP_RELEASES = "https://api.github.com/repos/session-foundation/session-desktop/releases" +FLATHUB = "https://flathub.org/api/v2/stats/network.loki.Session" +PLATFORM_EXTENSIONS = { + "linux": (".deb", ".AppImage", ".rpm", ".freebsd"), + "macos": (".dmg", ".zip"), + "windows": (".exe",), +} + + +class Rejected(ValueError): + pass + + +def parse_export(path): + """{ISO date: MAU} from a Play Console export of the saved MAU report.""" + try: + with open(path, encoding="utf-8-sig", newline="") as handle: + # strict: a quoted figure cut off at the end of the file is an error, not a smaller figure. + rows = list(csv.reader(handle, strict=True)) + except (UnicodeDecodeError, csv.Error) as exc: + raise Rejected(f"not a CSV export ({exc})") from None + if not rows or not rows[0] or rows[0][0] != "Date" or MAU_COLUMN not in rows[0]: + raise Rejected(f"no Date and “{MAU_COLUMN}” columns: export the saved MAU report " + "(unique users, per interval, daily, all countries) from a Console " + "set to English") + column = rows[0].index(MAU_COLUMN) + days = {} + for line, row in enumerate(rows[1:], start=2): + try: + day = datetime.strptime(row[0], EXPORT_DATE).date().isoformat() + figure = row[column].replace(",", "") + except (IndexError, ValueError): + raise Rejected(f"line {line} is not a date and a figure: {row[:column + 1]}") from None + if not figure: + continue + if not figure.isdigit(): + raise Rejected(f"line {line} has {row[column]!r} for a figure") + days[day] = int(figure) + if not days: + raise Rejected("no figures in it") + return days + + +def read_inbox(inbox): + """(path, days) for each export, oldest first, and (path, reason) for each rejected one. + + glob skips dotfiles, so rsync's temporary file is never read half-written. + """ + exports, rejected = [], [] + for path in sorted(glob.glob(os.path.join(inbox, "*.csv")), key=os.path.getmtime): + try: + exports.append((path, parse_export(path))) + except Rejected as exc: + rejected.append((path, str(exc))) + return exports, rejected + + +def merge(history, exports): + """Merge each export into `history`, the later export winning; returns the revisions, + (day, old, new), for the figures a later export changed.""" + revisions = [] + for _, days in exports: + for day, figure in sorted(days.items()): + old = history.get(day) + if old is not None and old != figure: + revisions.append((day, old, figure)) + history[day] = figure + return revisions + + +def file_away(path, folder): + os.makedirs(folder, exist_ok=True) + stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) + os.replace(path, os.path.join(folder, f"{stamp}-{os.path.basename(path)}")) + + +def load_history(path): + """The history at `path`, empty if there is none yet. Unlike a digest's dedup cache it + cannot be rebuilt from a re-run, so an unreadable one stops the run rather than reset.""" + if not os.path.exists(path): + return {"version": VERSION, "android": {}, "posted": []} + with open(path, encoding="utf-8") as handle: + data = json.load(handle) + if data.get("version") != VERSION: + raise RuntimeError(f"{path} is version {data.get('version')!r}, expected {VERSION}") + return data + + +def save_history(path, history): + temporary = f"{path}.tmp" + with open(temporary, "w", encoding="utf-8") as handle: + json.dump(history, handle, indent=2, sort_keys=True) + os.replace(temporary, path) + + +def previous_month_end(today): + return today.replace(day=1) - timedelta(days=1) + + +def figure(value): + return f"{value:,}" + + +def get_json(session, url): + resp = session.request("GET", url) + if resp.status_code != 200: + raise RuntimeError(f"HTTP {resp.status_code}: {resp.text[:200]}") + return resp.json() + + +def latest(releases): + return next(r for r in releases if not r["draft"] and not r["prerelease"]) + + +def release_day(release): + return release["published_at"].split("T")[0] + + +def flathub_installs_since(stats, day): + """Flathub installs from `day` on. Flathub builds from the GitHub .deb once, on its + own servers, so these are not already in the GitHub counts.""" + per_day = stats["installs_per_day"] + if day < min(per_day): + raise RuntimeError(f"Flathub keeps daily installs from {min(per_day)} only, " + f"after the release on {day}") + return sum(count for d, count in per_day.items() if d >= day) + + +def platform_totals(release, flathub_installs): + totals = {platform: sum(a["download_count"] for a in release["assets"] + if a["name"].endswith(extensions)) + for platform, extensions in PLATFORM_EXTENSIONS.items()} + totals["linux"] += flathub_installs + return totals + + +def desktop_downloads(session): + """(latest stable Desktop release, its downloads per platform).""" + release = latest(get_json(session, DESKTOP_RELEASES)) + flathub = flathub_installs_since(get_json(session, FLATHUB), release_day(release)) + return release, platform_totals(release, flathub) + + +def report_message(month_end, android, revisions, desktop): + month = month_end.strftime("%B %Y") + current = android[month_end.isoformat()] + release, downloads = desktop + desktop_total = downloads["linux"] + downloads["macos"] + downloads["windows"] + line = f"Android: **{figure(current)}**" + before = android.get(previous_month_end(month_end).isoformat()) + if before: + change = current - before + line += (f" ({'+' if change >= 0 else '−'}{figure(abs(change))}, " + f"{change / before:+.1%} on {previous_month_end(month_end):%B})") + lines = [ + f"📊 **Monthly active users, {month}**", + line, + f"Desktop: **{figure(desktop_total)}** (Linux {figure(downloads['linux'])} · " + f"macOS {figure(downloads['macos'])} · Windows {figure(downloads['windows'])})", + f"**Total: {figure(current + desktop_total)}**", + f"-# Android: Play Console MAU on {month_end:%-d %B}, users who opened Session in the " + "28 days before.", + f"-# Desktop: downloads of {release['tag_name']} since its release on " + f"{date.fromisoformat(release_day(release)):%-d %B}, updates included; " + "Desktop has no active-user count.", + ] + if revisions: + lines.append("-# The latest export revised " + ", ".join( + f"{date.fromisoformat(day):%-d %b} {figure(old)} → {figure(new)}" + for day, old, new in revisions)) + return "\n".join(lines) + + +def reminder_message(month_end, inbox): + return "\n".join([ + f"⏰ **Android MAU for {month_end:%B %Y} is missing.**", + "In Play Console, open Statistics → Saved reports → the MAU report, check it covers " + f"{month_end:%-d %B}, and Export report → CSV. Then copy it to the inbox, and the " + "figures post as soon as it lands:", + f'`rsync ".csv" {os.environ.get("MAU_INBOX_HOST") or socket.getfqdn()}:{inbox}/`', + ]) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.strip().split("\n")[0]) + parser.add_argument("--state", required=True, metavar="DIR", + help="Holds inbox/, done/, rejected/ and history.json.") + parser.add_argument("--dry-run", action="store_true", + help="Print what would be posted; move and write nothing.") + args = parser.parse_args(argv) + + inbox = os.path.join(args.state, "inbox") + history_path = os.path.join(args.state, HISTORY) + step("reading the inbox") + history = load_history(history_path) + exports, rejected = read_inbox(inbox) + revisions = merge(history["android"], exports) + for day, old, new in revisions: + print(f"{day}: {old} revised to {new}") + if not args.dry_run: + # Saved before the files move: a run stopped in between reads them again, harmlessly. + save_history(history_path, history) + for path, _ in exports: + file_away(path, os.path.join(args.state, "done")) + for path, _ in rejected: + file_away(path, os.path.join(args.state, "rejected")) + + today = datetime.now(ZONE).date() + month_end = previous_month_end(today) + month = month_end.strftime("%Y-%m") + message = None + if month in history["posted"]: + print(f"{month} already posted.") + elif month_end.isoformat() in history["android"]: + step("reading Desktop downloads") + session = http.Session() + session.headers.update({"Accept": "application/vnd.github.v3+json"}) + message = report_message(month_end, history["android"], revisions, + desktop_downloads(session)) + elif today.day >= REMIND_DAY: + message = reminder_message(month_end, inbox) + else: + print(f"Waiting for {month_end}; reminders start on the {REMIND_DAY}th.") + + if message: + step("posting to Discord") + if args.dry_run: + print(message) + else: + payload = {"content": message, "allowed_mentions": {"parse": []}} + if not discord.post_to_discord(http.Session(), os.environ["MAU_DISCORD_WEBHOOK_URL"], + [payload]): + raise RuntimeError("Discord did not accept the message") + if month_end.isoformat() in history["android"]: + history["posted"].append(month) + save_history(history_path, history) + if rejected: + raise SystemExit("rejected " + "; ".join( + f"{os.path.basename(path)}: {reason}" for path, reason in rejected)) + + +if __name__ == "__main__": + main() diff --git a/src/session_ops/platforms/release_stats.py b/src/session_ops/platforms/release_stats.py deleted file mode 100644 index 844de28..0000000 --- a/src/session_ops/platforms/release_stats.py +++ /dev/null @@ -1,109 +0,0 @@ -""" -Download counts of the last ten Desktop and Android releases, per installer, as two -CSV files. On demand only: nothing schedules it. - - session-ops run release-stats # CSVs kept under the job's runs/ - uv run python -m session_ops.platforms.release_stats --out . -""" -import argparse -import os -import time -from datetime import datetime, timezone - -from session_ops.ops.runner import step -from session_ops.shared import http - -API = "https://api.github.com/repos/session-foundation/{repo}/releases" -RELEASES = 10 - -DESKTOP_HEADER = ("version,snapshot_date,release_date,.deb,.appimage,.rpm,.dmg_arm64," - ".dmg_x64,.zip_arm64,.zip_x64,.exe") -ANDROID_HEADER = ("version,snapshot_date,release_date,.aab,.apk_arm64,.apk_armv7a," - ".apk_universal_huawei,.apk_universal_play,.apk_x86,.apk_x86_64") - - -def downloads(assets, keep): - return sum(a["download_count"] for a in assets if keep(a["name"])) - - -def desktop_row(release): - assets = release["assets"] - return [ - downloads(assets, lambda n: n.endswith(".deb")), - downloads(assets, lambda n: n.endswith(".AppImage")), - downloads(assets, lambda n: n.endswith(".rpm")), - downloads(assets, lambda n: n.endswith(".dmg") and "arm64" in n), - downloads(assets, lambda n: n.endswith(".dmg") and "x64" in n), - downloads(assets, lambda n: n.endswith(".zip") and "arm64" in n), - downloads(assets, lambda n: n.endswith(".zip") and "x64" in n), - downloads(assets, lambda n: n.endswith(".exe")), - ] - - -def android_row(release): - assets = release["assets"] - - def apk(arch): - return downloads(assets, lambda n: n.endswith(".apk") and arch in n - and "play-release" in n) - - return [ - downloads(assets, lambda n: n.endswith(".aab") and "play-release" in n), - apk("arm64-v8a"), - apk("armeabi-v7a"), - downloads(assets, lambda n: n.endswith(".apk") and "universal" in n - and "huawei-release" in n), - downloads(assets, lambda n: n.endswith(".apk") and "universal" in n - and "play-release" in n), - # x86 without x86_64, which contains it. - downloads(assets, lambda n: n.endswith(".apk") and "x86" in n - and "x86_64" not in n and "play-release" in n), - apk("x86_64"), - ] - - -def csv(releases, header, row, snapshot): - lines = [header] - for release in releases[:RELEASES]: - version = release["tag_name"][1:] if release["tag_name"].startswith("v") \ - else release["tag_name"] - lines.append(",".join(str(v) for v in [version, snapshot, - release["published_at"].split("T")[0], - *row(release)])) - return "\n".join(lines) - - -def fetch(session, repo): - resp = session.request("GET", API.format(repo=repo)) - if resp.status_code != 200: - raise RuntimeError(f"HTTP {resp.status_code}: {resp.text[:200]}") - return resp.json() - - -def main(argv=None): - parser = argparse.ArgumentParser(description=__doc__.strip().split("\n")[0]) - parser.add_argument("--out", default=".", metavar="DIR", - help="Where the CSVs go; a directory per run is made under it.") - parser.add_argument("--dry-run", action="store_true", help="Print the CSVs only.") - args = parser.parse_args(argv) - - session = http.Session() - session.headers.update({"Accept": "application/vnd.github.v3+json"}) - snapshot = datetime.now(timezone.utc).date().isoformat() - out = os.path.join(args.out, time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())) - for repo, header, row in (("session-desktop", DESKTOP_HEADER, desktop_row), - ("session-android", ANDROID_HEADER, android_row)): - step(repo) - content = csv(fetch(session, repo), header, row, snapshot) - print(f"# {repo}\n{content}\n") - if not args.dry_run: - os.makedirs(out, exist_ok=True) - with open(os.path.join(out, f"{repo}-release-stats.csv"), "w", - encoding="utf-8") as handle: - handle.write(content) - if not args.dry_run: - print(f"Written to {out}") - - -if __name__ == "__main__": - main() diff --git a/tests/goldens/units/dropins.txt b/tests/goldens/units/dropins.txt index 7f3cb1a..9f10073 100644 --- a/tests/goldens/units/dropins.txt +++ b/tests/goldens/units/dropins.txt @@ -44,18 +44,30 @@ Group=ghdigest EnvironmentFile=/etc/session-ops/github-prs.env TimeoutStartSec=15min -==> session-ops@release-stats.service.d/job.conf <== +==> session-ops@mau.path.d/watch.conf <== +# Generated by `session-ops units` from jobs.toml. Edit the registry, not this. + +[Path] +PathExistsGlob=/var/lib/session-ops/mau/inbox/*.csv + +==> session-ops@mau.service.d/job.conf <== # Generated by `session-ops units` from jobs.toml. Edit the registry, not this. [Unit] -Description=Download counts of the latest Desktop and Android releases +Description=Monthly active users per platform, posted once a month [Service] User=sessionops Group=sessionops -EnvironmentFile=/etc/session-ops/alerts.env +EnvironmentFile=/etc/session-ops/mau.env TimeoutStartSec=5min +==> session-ops@mau.timer.d/schedule.conf <== +# Generated by `session-ops units` from jobs.toml. Edit the registry, not this. + +[Timer] +OnCalendar=*-*-10 11:00 Australia/Melbourne + ==> session-ops@session-ops-silence.service.d/job.conf <== # Generated by `session-ops units` from jobs.toml. Edit the registry, not this. diff --git a/tests/ops/test_deploy.py b/tests/ops/test_deploy.py index ffe4a0f..c66b8f6 100644 --- a/tests/ops/test_deploy.py +++ b/tests/ops/test_deploy.py @@ -62,6 +62,16 @@ def test_a_scheduled_job_gets_a_timer_and_an_unscheduled_one_does_not(self): self.assertEqual(f"session-ops@{job.name}.timer.d/schedule.conf" in files, bool(job.schedule)) + def test_a_watched_job_gets_a_path_unit_and_moves_its_files_out_of_the_watch(self): + files = units.dropins(registry.load(), registry.load_queue()) + for job in registry.load(): + with self.subTest(job=job.name): + watch = files.get(f"session-ops@{job.name}.path.d/watch.conf") + self.assertEqual(watch is not None, bool(job.watch)) + if job.watch: + self.assertIn(f"\nPathExistsGlob={job.watch_glob}\n", watch) + self.assertIn("Unit=session-ops@%i.service\n", unit_text("session-ops@.path")) + def test_each_queued_job_runs_after_every_one_before_it(self): queue = registry.load_queue() files = units.dropins(registry.load(), queue) diff --git a/tests/ops/test_registry.py b/tests/ops/test_registry.py index 88842f3..f7e82b5 100644 --- a/tests/ops/test_registry.py +++ b/tests/ops/test_registry.py @@ -40,6 +40,16 @@ def test_state_is_substituted_and_dry_run_appended(self): self.assertEqual(job.argv(dry_run=True, state_dir="/x"), ["--state", "/x/s.json", "--dry-run"]) + def test_a_watch_is_a_glob_under_the_state_directory(self): + job = load(VALID + 'watch = "inbox/*.csv"\n')[0] + self.assertEqual(job.watch_glob, "/var/lib/session-ops/a/inbox/*.csv") + self.assertIsNone(load(VALID)[0].watch_glob) + + def test_a_watch_outside_the_state_directory_is_refused(self): + for watch in ("/tmp/*.csv", "../b/*.csv"): + with self.subTest(watch=watch), self.assertRaises(ValueError): + load(VALID + f'watch = "{watch}"\n') + def test_a_missing_field_is_refused(self): with self.assertRaises(ValueError): load(VALID.replace('user = "u"\n', "")) diff --git a/tests/test_mau.py b/tests/test_mau.py new file mode 100644 index 0000000..e3fc20d --- /dev/null +++ b/tests/test_mau.py @@ -0,0 +1,261 @@ +""" + uv run python -m unittest tests.test_mau +""" +import contextlib +import io +import json +import os +import tempfile +import unittest +from datetime import date, datetime +from unittest import mock + +from session_ops.platforms import mau +from session_ops.shared.testing import FakeResponse, FakeSession + +HEADER = f'Date,"{mau.MAU_COLUMN}",Notes\n' + + +def asset(name, count): + return {"name": name, "download_count": count} + + +def release(tag, *assets, published="2026-07-10", prerelease=False): + return {"tag_name": tag, "published_at": f"{published}T00:00:00Z", "draft": False, + "prerelease": prerelease, "assets": list(assets)} + + +LATEST = release( + "v1.18.1", + asset("session-desktop-linux-amd64-1.18.1.deb", 5), + asset("session-desktop-linux-x86_64-1.18.1.AppImage", 7), + asset("session-desktop-linux-x86_64-1.18.1.rpm", 1), + asset("session-desktop-linux-x64-1.18.1.freebsd", 2), + asset("session-desktop-mac-arm64-1.18.1.dmg", 30), + asset("session-desktop-mac-arm64-1.18.1.dmg.blockmap", 900), + asset("session-desktop-mac-x64-1.18.1.zip", 4), + asset("session-desktop-win-x64-1.18.1.exe", 100), + asset("session-desktop-win-x64-1.18.1.exe.blockmap", 900), + asset("latest.yml", 900), + asset("latest-linux.yml", 900), + asset("signature.asc", 900)) + +DESKTOP = [ + release("v1.19.0", asset("session-desktop-win-x64-1.19.0.exe", 9), + published="2026-10-01", prerelease=True), + LATEST, + release("v1.18.0", asset("session-desktop-win-x64-1.18.0.exe", 50), + published="2026-04-09"), +] + +FLATHUB = {"installs_per_day": {"2026-07-11": 10, "2026-07-09": 1000, "2026-07-10": 5}} + + +def export(*rows): + return HEADER + "".join(f'"{day}","{value}",{note}\n' for day, value, note in rows) + + +# LATEST's downloads with FLATHUB's 15 installs since its release: 30 + 34 + 100. +DOWNLOADS = (LATEST, {"linux": 30, "macos": 34, "windows": 100}) +SEPTEMBER = export(("Aug 31, 2026", "100,000", ""), + ("Sep 29, 2026", "104,500", "Rollout of release: 1.32.1 at 5%."), + ("Sep 30, 2026", "105,000", "")) + + +class ParseTest(unittest.TestCase): + def parse(self, text): + with tempfile.NamedTemporaryFile("w", suffix=".csv", delete=False) as handle: + handle.write(text) + self.addCleanup(os.remove, handle.name) + return mau.parse_export(handle.name) + + def test_reads_each_day_without_thousands_separators(self): + self.assertEqual(self.parse(SEPTEMBER), {"2026-08-31": 100000, "2026-09-29": 104500, + "2026-09-30": 105000}) + + def test_a_blank_figure_is_skipped(self): + self.assertEqual(self.parse(export(("Sep 30, 2026", "", ""), + ("Oct 1, 2026", "1,000", ""))), + {"2026-10-01": 1000}) + + def test_another_report_is_refused(self): + with self.assertRaisesRegex(mau.Rejected, "saved MAU report"): + self.parse('Date,"Daily active users (DAU): All countries / regions"\n' + '"Sep 30, 2026","1"\n') + + def test_a_console_in_another_language_is_refused(self): + with self.assertRaisesRegex(mau.Rejected, "line 2"): + self.parse(export(("30 sept. 2026", "608 002", ""))) + + def test_a_cut_off_last_row_is_refused(self): + with self.assertRaisesRegex(mau.Rejected, "line 4"): + self.parse(SEPTEMBER.rsplit("\n", 2)[0] + "\nSep 3") + + def test_a_figure_cut_off_mid_number_is_refused(self): + with self.assertRaisesRegex(mau.Rejected, "not a CSV export"): + self.parse(SEPTEMBER.rsplit("\n", 2)[0] + '\n"Sep 30, 2026","105,0') + + def test_a_non_figure_is_refused(self): + with self.assertRaisesRegex(mau.Rejected, "for a figure"): + self.parse(export(("Sep 30, 2026", "n/a", ""))) + + +class DesktopTest(unittest.TestCase): + def test_latest_skips_prereleases(self): + self.assertIs(mau.latest(DESKTOP), LATEST) + + def test_counts_installers_only_and_adds_flathub_to_linux(self): + self.assertEqual(mau.platform_totals(LATEST, 15), + {"linux": 30, "macos": 34, "windows": 100}) + + def test_flathub_counts_from_the_release_day_on(self): + self.assertEqual(mau.flathub_installs_since(FLATHUB, "2026-07-10"), 15) + + def test_flathub_refuses_a_release_older_than_its_window(self): + with self.assertRaisesRegex(RuntimeError, "from 2026-07-09 only"): + mau.flathub_installs_since(FLATHUB, "2026-07-01") + + def test_an_error_status_fails(self): + session = FakeSession([FakeResponse({"message": "nope"}, status_code=404)]) + with self.assertRaisesRegex(RuntimeError, "HTTP 404"): + mau.desktop_downloads(session) + + +class MergeTest(unittest.TestCase): + def test_a_later_export_wins_and_its_changes_are_returned(self): + history = {"2026-09-29": 104000} + revisions = mau.merge(history, [("a", {"2026-09-29": 104500, "2026-09-30": 1}), + ("b", {"2026-09-30": 1})]) + self.assertEqual(history, {"2026-09-29": 104500, "2026-09-30": 1}) + self.assertEqual(revisions, [("2026-09-29", 104000, 104500)]) + + +class MessageTest(unittest.TestCase): + def test_the_report_gives_the_month_end_and_the_change_on_the_month_before(self): + android = {"2026-08-31": 100000, "2026-09-30": 105000} + message = mau.report_message(date(2026, 9, 30), android, [], DOWNLOADS) + self.assertIn("**Monthly active users, September 2026**", message) + self.assertIn("Android: **105,000** (+5,000, +5.0% on August)", message) + + def test_desktop_downloads_are_listed_per_platform_and_counted_in_the_total(self): + message = mau.report_message(date(2026, 9, 30), {"2026-09-30": 105000}, [], DOWNLOADS) + self.assertIn("Desktop: **164** (Linux 30 · macOS 34 · Windows 100)", message) + self.assertIn("**Total: 105,164**", message) + self.assertIn("downloads of v1.18.1 since its release on 10 July", message) + + def test_a_drop_is_signed(self): + message = mau.report_message(date(2026, 9, 30), + {"2026-08-31": 107000, "2026-09-30": 105000}, [], + DOWNLOADS) + self.assertIn("(−2,000, -1.9% on August)", message) + + def test_revisions_are_listed(self): + message = mau.report_message(date(2026, 9, 30), {"2026-09-30": 105000}, + [("2026-09-28", 104200, 104300)], DOWNLOADS) + self.assertIn("revised 28 Sep 104,200 → 104,300", message) + + def test_the_reminder_names_the_day_and_where_to_copy_the_export(self): + with mock.patch.dict(os.environ, {"MAU_INBOX_HOST": "root@ops.example.org"}): + message = mau.reminder_message(date(2026, 9, 30), "/var/lib/session-ops/mau/inbox") + self.assertIn("Android MAU for September 2026 is missing", message) + self.assertIn("covers 30 September", message) + self.assertIn("root@ops.example.org:/var/lib/session-ops/mau/inbox/", message) + + +class RunTest(unittest.TestCase): + def setUp(self): + self.state = tempfile.mkdtemp() + self.addCleanup(lambda: __import__("shutil").rmtree(self.state)) + os.makedirs(os.path.join(self.state, "inbox")) + patcher = mock.patch.dict(os.environ, {"MAU_DISCORD_WEBHOOK_URL": "https://hook"}) + patcher.start() + self.addCleanup(patcher.stop) + + def drop(self, name, text): + with open(os.path.join(self.state, "inbox", name), "w", encoding="utf-8") as handle: + handle.write(text) + + def run_on(self, day, *args, responses=(FakeResponse(DESKTOP), FakeResponse(FLATHUB), + FakeResponse({}))): + session = FakeSession(list(responses)) + now = datetime(*day, 12, tzinfo=mau.ZONE) + with mock.patch.object(mau, "datetime", wraps=datetime) as clock, \ + mock.patch.object(mau.http, "Session", return_value=session), \ + contextlib.redirect_stdout(io.StringIO()) as out: + clock.now.return_value = now + mau.main(["--state", self.state, *args]) + return session, out.getvalue() + + def posted(self, session): + return [kwargs["json"]["content"] for method, _, kwargs in session.calls + if method == "POST"] + + def history(self): + with open(os.path.join(self.state, mau.HISTORY), encoding="utf-8") as handle: + return json.load(handle) + + def listing(self, folder): + return os.listdir(os.path.join(self.state, folder)) + + def test_an_export_with_the_month_end_posts_once_and_is_filed_away(self): + self.drop("All countries _ regions.csv", SEPTEMBER) + session, _ = self.run_on((2026, 10, 9)) + self.assertIn("Android: **105,000**", self.posted(session)[0]) + self.assertEqual(self.listing("inbox"), []) + (filed,) = self.listing("done") + self.assertTrue(filed.endswith("-All countries _ regions.csv")) + self.assertEqual(self.history()["posted"], ["2026-09"]) + + session, out = self.run_on((2026, 10, 10)) + self.assertEqual(session.calls, []) + self.assertIn("2026-09 already posted", out) + + def test_without_the_month_end_it_waits_then_reminds_from_the_tenth(self): + self.drop("early.csv", export(("Sep 29, 2026", "104,500", ""))) + session, out = self.run_on((2026, 10, 9)) + self.assertEqual(session.calls, []) + self.assertIn("Waiting for 2026-09-30", out) + + session, _ = self.run_on((2026, 10, 10), responses=[FakeResponse({})]) + self.assertIn("is missing", self.posted(session)[0]) + self.assertEqual(self.history()["posted"], []) + self.assertEqual(self.history()["android"], {"2026-09-29": 104500}) + + def test_a_rejected_file_is_set_aside_and_fails_the_run_after_the_rest(self): + self.drop("good.csv", SEPTEMBER) + self.drop("wrong.csv", "Date,Installs\n") + with self.assertRaisesRegex(SystemExit, "wrong.csv: no Date and"): + session, _ = self.run_on((2026, 10, 9)) + self.assertEqual(self.listing("inbox"), []) + self.assertEqual(len(self.listing("rejected")), 1) + self.assertEqual(self.history()["posted"], ["2026-09"]) + + def test_a_dotfile_is_left_for_rsync_to_finish(self): + self.drop(".export.csv.Ab12Cd", SEPTEMBER) + session, _ = self.run_on((2026, 10, 9)) + self.assertEqual(session.calls, []) + self.assertEqual(self.listing("inbox"), [".export.csv.Ab12Cd"]) + + def test_a_refused_post_is_not_recorded_and_fails_the_run(self): + self.drop("a.csv", SEPTEMBER) + with self.assertRaisesRegex(RuntimeError, "Discord did not accept"): + self.run_on((2026, 10, 9), responses=[FakeResponse(DESKTOP), FakeResponse(FLATHUB), + FakeResponse({}, status_code=400)]) + self.assertEqual(self.history()["posted"], []) + self.assertIn("2026-09-30", self.history()["android"]) + + def test_a_dry_run_moves_and_writes_nothing(self): + self.drop("a.csv", SEPTEMBER) + session, out = self.run_on((2026, 10, 9), "--dry-run") + self.assertEqual(self.posted(session), []) + self.assertIn("Android: **105,000**", out) + self.assertEqual(self.listing("inbox"), ["a.csv"]) + self.assertFalse(os.path.exists(os.path.join(self.state, mau.HISTORY))) + + def test_an_unreadable_history_stops_the_run_instead_of_starting_over(self): + with open(os.path.join(self.state, mau.HISTORY), "w", encoding="utf-8") as handle: + handle.write("{") + self.drop("a.csv", SEPTEMBER) + with self.assertRaises(ValueError): + self.run_on((2026, 10, 9)) + self.assertEqual(self.listing("inbox"), ["a.csv"])