From 9fad9346452c4ef9584c8d9ec3e5836d46ff590e Mon Sep 17 00:00:00 2001 From: Owen Price Skelly <21372141+OwenPriceSkelly@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:52:06 -0500 Subject: [PATCH] Add 'rootstock usage push': send aggregated rollups to the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collector (#159) aggregates the usage spool; this adds the last hop from issue #47 — pushing the aggregated monthly rollup rows to the rootstock-admin backend's new usage endpoint (Garden-AI/rootstock-admin#6). - `rootstock usage push [--dry-run]` pushes the same aggregation `report` shows, filed under the manifest's cluster name. The backend stores rollups per month and replaces only the months present in a push, so repeated pushes (e.g. from the smoke-test cron) are idempotent. - Only derived counts are sent; the salted user hashes stay in the spool (summarize_spool already strips them). - The usage endpoint URL is derived from api_url by swapping the standard rootstock-admin function name (-manifest -> -usage); a new optional usage_api_url config field / ROOTSTOCK_USAGE_API_URL env var overrides it for non-standard deployments. - The success line names the endpoint it pushed to: dev-vs-prod mixups previously looked identical to success (the delta manifest incident). Co-Authored-By: Claude Fable 5 --- README.md | 2 +- docs/cluster-setup.md | 2 +- rootstock/cli.py | 31 +++++++-- rootstock/client.py | 37 +++++++++-- rootstock/commands/__init__.py | 3 +- rootstock/commands/usage.py | 77 ++++++++++++++++++++++- rootstock/config.py | 23 +++++++ tests/cli/test_usage_cmd.py | 112 ++++++++++++++++++++++++++++++++- tests/test_config_save.py | 25 ++++++++ 9 files changed, 296 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index fb3b476..68539f9 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Rootstock ships an experimental LAMMPS `fix` that spawns a worker subprocess, gi ## Usage Statistics -On shared installs where the maintainer has provisioned it, each worker session drops one small anonymous JSON record into the install's `{cache_root}/usage/` directory: which environment/checkpoint/device ran, when, for how long, how many force calls it served, and which entry point started it. Nothing phones home — records stay on the cluster's shared filesystem until the install's maintainer aggregates them (`rootstock usage report`). No job ids, no simulation data, and no raw usernames are recorded: the only per-person field is a salted hash used to count distinct users, and checkpoints you register yourself with `rootstock add-local` appear as `(local)`, never by name. +On shared installs where the maintainer has provisioned it, each worker session drops one small anonymous JSON record into the install's `{cache_root}/usage/` directory: which environment/checkpoint/device ran, when, for how long, how many force calls it served, and which entry point started it. Nothing phones home — records stay on the cluster's shared filesystem until the install's maintainer aggregates them (`rootstock usage report`) and, if the cluster is registered with the dashboard, pushes the aggregated monthly counts (`rootstock usage push`). No job ids, no simulation data, and no raw usernames are recorded: the only per-person field is a salted hash used to count distinct users, and checkpoints you register yourself with `rootstock add-local` appear as `(local)`, never by name. Only derived counts ever leave the cluster — the hashes stay in the spool. If any of this rubs you the wrong way, opt out for all of your sessions with: diff --git a/docs/cluster-setup.md b/docs/cluster-setup.md index af17e36..dc7f87e 100644 --- a/docs/cluster-setup.md +++ b/docs/cluster-setup.md @@ -137,7 +137,7 @@ After `rootstock init` runs, verify ACLs landed correctly with `getfacl` on a fr ### The usage-record spool -`setup-perms` also creates `{cache_root}/usage/` with mode `1777` (sticky, `/tmp`-style). This is the spool for [anonymous per-session usage records](https://github.com/Garden-AI/rootstock#usage-statistics): any user's session must be able to drop a record file, and the sticky bit keeps users from deleting each other's. Records are created `O_EXCL` with unique names, so the directory behaves like `/tmp`, not like a writable code path — but some centers' security scans flag any world-writable directory in project space. If that's a problem on your cluster (or you just don't want usage collection), pass `--no-usage-spool`: a missing spool means collection is off for the install, and `check-perms` treats its absence as a deliberate choice, not an issue. Aggregate with `rootstock usage report`, and fold raw records into monthly rollups with `rootstock usage compact` (safe to run from the same cron as the nightly smoke-test). +`setup-perms` also creates `{cache_root}/usage/` with mode `1777` (sticky, `/tmp`-style). This is the spool for [anonymous per-session usage records](https://github.com/Garden-AI/rootstock#usage-statistics): any user's session must be able to drop a record file, and the sticky bit keeps users from deleting each other's. Records are created `O_EXCL` with unique names, so the directory behaves like `/tmp`, not like a writable code path — but some centers' security scans flag any world-writable directory in project space. If that's a problem on your cluster (or you just don't want usage collection), pass `--no-usage-spool`: a missing spool means collection is off for the install, and `check-perms` treats its absence as a deliberate choice, not an issue. Aggregate with `rootstock usage report`, fold raw records into monthly rollups with `rootstock usage compact`, and send the aggregated counts to the dashboard with `rootstock usage push` (uses the same API credentials as `manifest push`; all three are safe to run from the same cron as the nightly smoke-test). On clusters where your write access to the install is granted temporarily and later revoked (e.g. Delta), don't put the real spool inside the install — you'd eventually lose the ability to fix its permissions or prune it. Pass `--usage-dir ` pointing somewhere you permanently control (a directory under your home works): setup-perms creates the `1777` directory there and symlinks `{cache_root}/usage` to it, so sessions and the collector follow the link without any configuration. Make sure the target's ancestors grant world traverse (`chmod o+x ~` if the spool lives under your home). If the spool ever does become unwritable, user calculations are unaffected — sessions silently skip recording — and `check-perms` will tell you. diff --git a/rootstock/cli.py b/rootstock/cli.py index 8518453..c9667fe 100644 --- a/rootstock/cli.py +++ b/rootstock/cli.py @@ -48,9 +48,10 @@ rootstock usage report [--root ] [--json] rootstock usage compact [--root ] - Aggregate (read-only) or compact the anonymous usage-record spool at - {cache_root}/usage/. The spool is provisioned by setup-perms; without - it, usage collection is off. + rootstock usage push [--root ] [--dry-run] + Aggregate (read-only), compact, or push to the dashboard backend the + anonymous usage-record spool at {cache_root}/usage/. The spool is + provisioned by setup-perms; without it, usage collection is off. """ import argparse @@ -77,6 +78,7 @@ cmd_smoke_test, cmd_status, cmd_usage_compact, + cmd_usage_push, cmd_usage_report, ) from .commands.common import ROOTSTOCK_ROOT_ENV @@ -624,7 +626,8 @@ def main(): "Maintainer-side view of the anonymous usage records that " "calculator sessions spool to {cache_root}/usage/. 'report' " "aggregates read-only; 'compact' folds raw records into " - "per-month rollup files." + "per-month rollup files; 'push' sends the aggregated rollup " + "rows to the dashboard backend." ), ) usage_subparsers = usage_parser.add_subparsers( @@ -663,6 +666,26 @@ def main(): ) usage_compact_parser.set_defaults(func=cmd_usage_compact) + usage_push_parser = usage_subparsers.add_parser( + "push", + help="Push aggregated rollup rows to the dashboard backend", + ) + usage_push_parser.add_argument( + "--root", + default=os.environ.get(ROOTSTOCK_ROOT_ENV), + help=f"Root directory (default: ${ROOTSTOCK_ROOT_ENV})", + ) + usage_push_parser.add_argument( + "--cache-root", + help="Cache root override (default: the install's own declaration)", + ) + usage_push_parser.add_argument( + "--dry-run", + action="store_true", + help="Print the endpoint URL and payload instead of pushing", + ) + usage_push_parser.set_defaults(func=cmd_usage_push) + # parse_known_args instead of parse_args so `rootstock benchmark ...` can # forward arbitrary flags to the benchmark's own parser. Every other # command keeps strict parsing via the explicit error below. diff --git a/rootstock/client.py b/rootstock/client.py index c23c48b..5d49ecf 100644 --- a/rootstock/client.py +++ b/rootstock/client.py @@ -1,7 +1,7 @@ """ HTTP client for Rootstock backend API. -Handles pushing manifests to the central registry. +Handles pushing manifests and usage rollups to the central registry. """ from __future__ import annotations @@ -43,10 +43,39 @@ def push_manifest(self, manifest: Manifest) -> tuple[bool, str]: if not self.config.api_url: return False, "API URL not configured" - data = json.dumps(manifest.to_dict()).encode("utf-8") + return self._post(self.config.api_url, manifest.to_dict(), "Manifest pushed successfully") + + def push_usage(self, cluster: str, rows: list[dict]) -> tuple[bool, str]: + """ + Push aggregated usage rollup rows to the backend usage endpoint. + + Args: + cluster: Cluster name the rollups are filed under + rows: Rollup rows as aggregated by ``summarize_spool`` — counts + only, never the user hashes + + Returns: + (success, message) tuple + """ + if not self.config.api_key: + return False, "API key not configured" + if not self.config.api_secret: + return False, "API secret not configured" + url = self.config.resolve_usage_api_url() + if not url: + return False, "Usage API URL not configured (set usage_api_url)" + + return self._post( + url, + {"cluster": cluster, "rows": rows}, + f"Pushed {len(rows)} rollup row(s) to {url}", + ) + + def _post(self, url: str, payload: dict, success_message: str) -> tuple[bool, str]: + data = json.dumps(payload).encode("utf-8") request = Request( - self.config.api_url, + url, data=data, headers={ "Content-Type": "application/json", @@ -59,7 +88,7 @@ def push_manifest(self, manifest: Manifest) -> tuple[bool, str]: try: with urlopen(request, timeout=30) as response: if response.status in (200, 201, 204): - return True, "Manifest pushed successfully" + return True, success_message return False, f"Unexpected status: {response.status}" except HTTPError as e: return False, f"HTTP error {e.code}: {e.reason}" diff --git a/rootstock/commands/__init__.py b/rootstock/commands/__init__.py index 9329d56..bd4a6c5 100644 --- a/rootstock/commands/__init__.py +++ b/rootstock/commands/__init__.py @@ -13,7 +13,7 @@ from .setup_perms import cmd_setup_perms from .smoke_test import cmd_smoke_test from .status import cmd_list, cmd_status -from .usage import cmd_usage_compact, cmd_usage_report +from .usage import cmd_usage_compact, cmd_usage_push, cmd_usage_report __all__ = [ "cmd_add", @@ -34,5 +34,6 @@ "cmd_smoke_test", "cmd_status", "cmd_usage_compact", + "cmd_usage_push", "cmd_usage_report", ] diff --git a/rootstock/commands/usage.py b/rootstock/commands/usage.py index c0abba7..22d5aa5 100644 --- a/rootstock/commands/usage.py +++ b/rootstock/commands/usage.py @@ -1,9 +1,10 @@ -"""``rootstock usage`` — report on and compact the usage-record spool. +"""``rootstock usage`` — report on, compact, and push the usage-record spool. The spool ({cache_root}/usage/) fills with one small JSON record per calculator session (see rootstock/usage.py). ``report`` aggregates it read-only; ``compact`` folds raw records into per-month rollup files so the -spool doesn't accumulate thousands of tiny files. Both are login-node, +spool doesn't accumulate thousands of tiny files; ``push`` sends the +aggregated rollup rows to the dashboard backend. All are login-node, maintainer-side operations — the write side never needs them. """ @@ -12,6 +13,9 @@ import json import sys +from ..client import RootstockClient +from ..config import load_config +from ..manifest import load_manifest from ..usage import SpoolSummary, compact_spool, summarize_spool, usage_dir from .common import get_root_or_exit, resolve_cache_root @@ -85,6 +89,75 @@ def cmd_usage_report(args) -> int: return 0 +def cmd_usage_push(args) -> int: + """Push the aggregated rollup rows to the dashboard backend. + + Pushes the same aggregation ``report`` shows — rollup files plus any + not-yet-compacted raw records — filed under the manifest's cluster name. + The backend stores rollups per month and replaces only the months + present in the push, so pushing repeatedly (e.g. from the smoke-test + cron) is idempotent and never erases previously pushed history. Only + derived counts are sent; the salted user hashes stay in the spool. + """ + root = get_root_or_exit(args) + cache_root = resolve_cache_root(root, args.cache_root) + config = load_config() + + valid, error = config.validate() + if not valid: + print(f"Error: {error}", file=sys.stderr) + print( + "Configure API credentials in ~/.config/rootstock/config.toml", + file=sys.stderr, + ) + return 1 + url = config.resolve_usage_api_url() + if not url: + print( + "Error: no usage endpoint — api_url doesn't follow the standard " + "rootstock-admin naming, so set usage_api_url in " + "~/.config/rootstock/config.toml explicitly.", + file=sys.stderr, + ) + return 1 + + manifest = load_manifest(root) + if manifest is None or not manifest.cluster: + print( + f"No manifest at {root}/manifest.json — the push is filed under " + "the manifest's cluster name; run 'rootstock manifest init " + "--cluster ' first.", + file=sys.stderr, + ) + return 1 + + summary = summarize_spool(cache_root) + if summary is None: + print( + f"No usage spool at {usage_dir(cache_root)} — usage collection is " + "off for this install (rootstock setup-perms provisions it).", + file=sys.stderr, + ) + return 1 + if not summary.rows: + print("No usage recorded yet — nothing to push.") + return 0 + + if args.dry_run: + payload = {"cluster": manifest.cluster, "rows": summary.rows} + print(f"Would POST to {url}:") + print(json.dumps(payload, indent=2)) + return 0 + + client = RootstockClient(config) + success, message = client.push_usage(manifest.cluster, summary.rows) + if not success: + print(f"Error: {message}", file=sys.stderr) + return 1 + print(message) + return 0 + + def cmd_usage_compact(args) -> int: """Fold raw records into per-month rollup files.""" root = get_root_or_exit(args) diff --git a/rootstock/config.py b/rootstock/config.py index 0c48989..80e94f7 100644 --- a/rootstock/config.py +++ b/rootstock/config.py @@ -27,6 +27,7 @@ ROOTSTOCK_API_KEY_ENV = "ROOTSTOCK_API_KEY" ROOTSTOCK_API_SECRET_ENV = "ROOTSTOCK_API_SECRET" ROOTSTOCK_API_URL_ENV = "ROOTSTOCK_API_URL" +ROOTSTOCK_USAGE_API_URL_ENV = "ROOTSTOCK_USAGE_API_URL" ROOTSTOCK_ROOT_ENV = "ROOTSTOCK_ROOT" # Default config location @@ -42,6 +43,7 @@ class UserConfig: api_key: str | None = None api_secret: str | None = None api_url: str | None = None + usage_api_url: str | None = None name: str | None = None email: str | None = None is_maintainer: bool = False @@ -50,6 +52,22 @@ def is_push_enabled(self) -> bool: """Check if api_key, api_secret, and api_url are configured for pushing.""" return bool(self.api_key and self.api_secret and self.api_url) + def resolve_usage_api_url(self) -> str | None: + """The endpoint usage rollups are pushed to. + + Deployments of rootstock-admin expose the manifest and usage ingest + functions side by side (``...-manifest.modal.run`` / + ``...-usage.modal.run``), so when ``usage_api_url`` isn't configured + it is derived from ``api_url`` by swapping the function name. An + ``api_url`` that doesn't follow that naming can't be derived from — + set ``usage_api_url`` explicitly. + """ + if self.usage_api_url: + return self.usage_api_url + if self.api_url and "-manifest" in self.api_url: + return self.api_url.replace("-manifest", "-usage", 1) + return None + def validate(self) -> tuple[bool, str]: """ Validate configuration for push operations. @@ -91,6 +109,7 @@ def load_config(config_path: Path | None = None) -> UserConfig: config.api_key = data.get("api_key") config.api_secret = data.get("api_secret") config.api_url = data.get("api_url") + config.usage_api_url = data.get("usage_api_url") config.is_maintainer = data.get("is_maintainer", False) maintainer = data.get("maintainer", {}) config.name = maintainer.get("name") @@ -103,6 +122,8 @@ def load_config(config_path: Path | None = None) -> UserConfig: config.api_secret = os.environ.get(ROOTSTOCK_API_SECRET_ENV) if not config.api_url: config.api_url = os.environ.get(ROOTSTOCK_API_URL_ENV) + if not config.usage_api_url: + config.usage_api_url = os.environ.get(ROOTSTOCK_USAGE_API_URL_ENV) return config @@ -147,6 +168,8 @@ def save_config(config: UserConfig, config_path: Path | None = None) -> None: data["api_secret"] = config.api_secret if config.api_url: data["api_url"] = config.api_url + if config.usage_api_url: + data["usage_api_url"] = config.usage_api_url maintainer = {} if config.name: diff --git a/tests/cli/test_usage_cmd.py b/tests/cli/test_usage_cmd.py index 2d00ac2..2c7be0d 100644 --- a/tests/cli/test_usage_cmd.py +++ b/tests/cli/test_usage_cmd.py @@ -1,16 +1,18 @@ -"""``rootstock usage report`` / ``usage compact`` command adapters.""" +"""``rootstock usage report`` / ``usage compact`` / ``usage push`` command adapters.""" from __future__ import annotations import json from types import SimpleNamespace -from rootstock.commands.usage import cmd_usage_compact, cmd_usage_report +from rootstock.commands.usage import cmd_usage_compact, cmd_usage_push, cmd_usage_report +from rootstock.config import UserConfig +from rootstock.manifest import create_manifest, save_manifest from rootstock.usage import record_session, usage_dir def _args(tmp_path, **extra): - base = {"root": str(tmp_path), "cache_root": None, "json": False} + base = {"root": str(tmp_path), "cache_root": None, "json": False, "dry_run": False} base.update(extra) return SimpleNamespace(**base) @@ -66,3 +68,107 @@ def test_compact_then_report(tmp_path, capsys): assert cmd_usage_report(_args(tmp_path, json=True)) == 0 data = json.loads(capsys.readouterr().out) assert data["rows"][0]["sessions"] == 1 + + +# --------------------------------------------------------------------------- # +# push +# --------------------------------------------------------------------------- # + +_PUSH_CONFIG = UserConfig( + api_key="wk-key", + api_secret="ws-secret", + api_url="https://garden-ai-dev--rootstock-admin-manifest.modal.run", +) + + +def _seed_manifest(tmp_path, config=_PUSH_CONFIG, cluster="testcluster"): + save_manifest(create_manifest(tmp_path, cluster, config), tmp_path) + + +def _patch_config(monkeypatch, config=_PUSH_CONFIG): + monkeypatch.setattr("rootstock.commands.usage.load_config", lambda: config) + + +def test_push_without_credentials_fails(tmp_path, monkeypatch, capsys): + _patch_config(monkeypatch, UserConfig()) + assert cmd_usage_push(_args(tmp_path)) == 1 + assert "API key not configured" in capsys.readouterr().err + + +def test_push_without_derivable_url_fails(tmp_path, monkeypatch, capsys): + _patch_config( + monkeypatch, + UserConfig(api_key="k", api_secret="s", api_url="https://example.com/ingest"), + ) + assert cmd_usage_push(_args(tmp_path)) == 1 + assert "usage_api_url" in capsys.readouterr().err + + +def test_push_without_manifest_fails(tmp_path, monkeypatch, capsys): + _patch_config(monkeypatch) + _seed(tmp_path) + assert cmd_usage_push(_args(tmp_path)) == 1 + assert "manifest init" in capsys.readouterr().err + + +def test_push_without_spool_fails(tmp_path, monkeypatch, capsys): + _patch_config(monkeypatch) + _seed_manifest(tmp_path) + assert cmd_usage_push(_args(tmp_path)) == 1 + assert "usage collection is off" in capsys.readouterr().err + + +def test_push_empty_spool_is_a_noop(tmp_path, monkeypatch, capsys): + _patch_config(monkeypatch) + _seed_manifest(tmp_path) + usage_dir(tmp_path).mkdir() + assert cmd_usage_push(_args(tmp_path)) == 0 + assert "nothing to push" in capsys.readouterr().out + + +def test_push_dry_run_prints_url_and_payload(tmp_path, monkeypatch, capsys): + _patch_config(monkeypatch) + _seed_manifest(tmp_path) + _seed(tmp_path) + assert cmd_usage_push(_args(tmp_path, dry_run=True)) == 0 + out = capsys.readouterr().out + assert "https://garden-ai-dev--rootstock-admin-usage.modal.run" in out + payload = json.loads(out.split(":\n", 1)[1]) + assert payload["cluster"] == "testcluster" + assert payload["rows"][0]["checkpoint"] == "mace-mp-0-medium" + assert "users" not in payload["rows"][0] # counts only, never hashes + + +def test_push_posts_rollup_rows(tmp_path, monkeypatch, capsys): + _patch_config(monkeypatch) + _seed_manifest(tmp_path) + _seed(tmp_path) + + posted = {} + + class _Response: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def fake_urlopen(request, timeout=None): + posted["url"] = request.full_url + posted["headers"] = request.headers + posted["body"] = json.loads(request.data) + return _Response() + + monkeypatch.setattr("rootstock.client.urlopen", fake_urlopen) + + assert cmd_usage_push(_args(tmp_path)) == 0 + assert posted["url"] == "https://garden-ai-dev--rootstock-admin-usage.modal.run" + assert posted["headers"]["Modal-key"] == "wk-key" + assert posted["body"]["cluster"] == "testcluster" + assert posted["body"]["rows"][0]["sessions"] == 1 + assert "users" not in posted["body"]["rows"][0] + # The success line names the endpoint: pushing to the wrong deployment + # (dev vs prod) otherwise looks identical to success. + assert "rootstock-admin-usage" in capsys.readouterr().out diff --git a/tests/test_config_save.py b/tests/test_config_save.py index a553deb..898fbb2 100644 --- a/tests/test_config_save.py +++ b/tests/test_config_save.py @@ -66,3 +66,28 @@ def test_unset_fields_are_omitted(tmp_path: Path): assert "api_key" not in text assert "[maintainer]" not in text assert "is_maintainer = false" in text + + +def test_usage_api_url_round_trips(tmp_path: Path): + config = UserConfig(usage_api_url="https://example.com/usage") + assert _round_trip(tmp_path, config).usage_api_url == "https://example.com/usage" + + +def test_usage_url_derived_from_standard_api_url(): + config = UserConfig(api_url="https://garden-ai-prod--rootstock-admin-manifest.modal.run") + assert ( + config.resolve_usage_api_url() == "https://garden-ai-prod--rootstock-admin-usage.modal.run" + ) + + +def test_explicit_usage_url_wins_over_derivation(): + config = UserConfig( + api_url="https://garden-ai-prod--rootstock-admin-manifest.modal.run", + usage_api_url="https://example.com/usage", + ) + assert config.resolve_usage_api_url() == "https://example.com/usage" + + +def test_nonstandard_api_url_does_not_derive(): + assert UserConfig(api_url="https://example.com/ingest").resolve_usage_api_url() is None + assert UserConfig().resolve_usage_api_url() is None