Skip to content

Commit e398819

Browse files
fix(growth-report): address dsblank re-review (URL builder + sweep)
- admin_api_url: accept http+https on-prem bases (reject only malformed scheme/empty host) and preserve the base path prefix (e.g. /clientlib) instead of dropping it (#1, #2) - migrate_users: mirror the http+https check; print the actually-built request URL rather than a naively-concatenated one (#1, #2) - growth-report build(): surface a bad-URL ValueError with its own message instead of the generic "needs admin key" text (#3) - _iter_buckets: replace the per-bucket rescan with a created_at-sorted sweep pointer; behavior-preserving (#4) - _fetch_service_accounts: reuse the validated admin_api_url builder (#5a) - _short_api_error: drop the over-broad "csp" redaction marker (#5b) - churn_series: use `is not None` consistently for created/deleted (#5c) - leaderboard bar values: normalize via _lb_value/_num so fractional em_score renders as a clean int (#5d) Adds coverage for the URL layer (http, path prefix, malformed base/host), the sweep's mid-series deletion drop-out, and _num/_lb_value rounding. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a1b5a8b commit e398819

7 files changed

Lines changed: 201 additions & 47 deletions

File tree

cometx/cli/admin_growth_report.py

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
import datetime
2727
import os
2828
import re
29-
from urllib.parse import urlparse
3029

3130
from cometx.cli.admin_growth_render import build_html, write_html
3231
from cometx.cli.admin_growth_users import (
@@ -49,7 +48,7 @@
4948
workspace_churn_series,
5049
workspace_org_totals,
5150
)
52-
from cometx.utils import fetch_chargeback_report, format_time_key
51+
from cometx.utils import admin_api_url, fetch_chargeback_report, format_time_key
5352

5453
# `build_html` is re-exported here so the growth-report module is the single
5554
# import surface (tests + callers import it from here). Declaring it in
@@ -91,6 +90,19 @@ def _num(value):
9190
return value
9291

9392

93+
def _lb_value(value):
94+
"""Normalize a leaderboard bar value the same way workspace rows are
95+
rendered: round a fractional metric (e.g. `em_score`, which folds in
96+
`data_logged_mb`) to a clean integer via `_num(round(...))`. Passes
97+
`None` through unchanged so a metric a user lacks stays absent rather
98+
than becoming 0."""
99+
if value is None:
100+
return None
101+
if isinstance(value, float):
102+
return _num(round(value))
103+
return value
104+
105+
94106
def _window_growth(created_ms, window):
95107
"""Growth over the analysis window from a collection of creation timestamps
96108
(epoch ms): `new_in` = items created within `[window.start, window.end]`,
@@ -173,11 +185,12 @@ def _fetch_service_accounts(api) -> "set[str] | None":
173185
labeled regex heuristic instead of crashing or silently reporting a
174186
zero split under the admin_api label."""
175187
try:
176-
parsed = urlparse(api.config["comet.url_override"])
177-
base = "%s://%s" % (parsed.scheme, parsed.netloc)
178-
while base.endswith("/"):
179-
base = base[:-1]
180-
url = base + "/api/admin/service-accounts"
188+
# Reuse the shared validated URL builder so this endpoint honors the
189+
# same scheme/host/path-prefix handling as fetch_chargeback_report
190+
# (the two admin endpoints were previously built inconsistently).
191+
url = admin_api_url(
192+
api.config["comet.url_override"], "/api/admin/service-accounts"
193+
)
181194
response = api._client.get(
182195
url, headers={"Authorization": api.api_key}, params={}
183196
)
@@ -207,13 +220,17 @@ def _short_api_error(exc):
207220
# onward -- verbose SDK/HTTP errors dump headers, cookies, body, and CSP.
208221
lowered = text.lower()
209222
cut = len(text)
223+
# NB: markers are matched as substrings anywhere in the text, so each must
224+
# be specific enough not to fire on an incidental hostname/message word.
225+
# "content-security-policy" already covers the CSP header; a bare "csp"
226+
# (3 chars) would truncate errors that merely happen to contain those
227+
# letters, so it is intentionally not listed.
210228
for marker in (
211229
"headers:",
212230
"header:",
213231
"cookie",
214232
"body:",
215233
"content-security-policy",
216-
"csp",
217234
"set-cookie",
218235
):
219236
idx = lowered.find(marker)
@@ -344,6 +361,13 @@ def build(self, workspaces):
344361
print("Fetching chargeback report (admin API)...")
345362
try:
346363
chargeback = fetch_chargeback_report(self.api)
364+
except ValueError as exc:
365+
# A ValueError here is a configuration/URL problem (e.g. a
366+
# malformed --host or url_override), not an auth failure. Surface
367+
# it as-is rather than asserting the API key isn't admin.
368+
raise GrowthReportError(
369+
f"growth-report could not reach the chargeback endpoint: {exc}"
370+
) from exc
347371
except Exception as exc:
348372
raise GrowthReportError(
349373
"growth-report requires an admin API key: the chargeback "
@@ -976,7 +1000,10 @@ def emit_ws(slug, label, totals, hint):
9761000
self._leaderboard_chart(
9771001
f"chart-lb-user-{key}-top",
9781002
f"Top {n} users by {label}",
979-
[{"label": u.username, "value": value_fn(u)} for u in top],
1003+
[
1004+
{"label": u.username, "value": _lb_value(value_fn(u))}
1005+
for u in top
1006+
],
9801007
hint,
9811008
)
9821009
)
@@ -987,7 +1014,7 @@ def emit_ws(slug, label, totals, hint):
9871014
f"chart-lb-user-{key}-bottom",
9881015
f"Bottom {n} users by {label}",
9891016
[
990-
{"label": u.username, "value": value_fn(u)}
1017+
{"label": u.username, "value": _lb_value(value_fn(u))}
9911018
for u in bottom
9921019
],
9931020
hint,

cometx/cli/admin_growth_users.py

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -347,24 +347,36 @@ def _iter_buckets(users: "list[UserRecord]", units: str, now_ms: int):
347347
yet deleted) as of `bucket_end_ms`. `bucket_end_ms` is capped at
348348
`now_ms` so the current/last bucket reflects "as of now" rather than
349349
the theoretical end of an in-progress period."""
350-
created_times = [u.created_at for u in users if u.created_at is not None]
351-
if not created_times:
350+
# Sort the eligible (non-suspended, dated) users by `created_at` once so
351+
# each bucket can *admit* newly-created users via a sweep pointer instead
352+
# of rescanning the whole list. This turns the created-side filter from
353+
# O(buckets x users) into a single pass; the only per-bucket work left is
354+
# dropping users whose deletion has passed (cheap unless there are many
355+
# deletions). Buckets are yielded in increasing time order, and
356+
# `created_at` only ever admits (never removes) as time advances, so the
357+
# sweep is safe.
358+
dated = sorted(
359+
(u for u in users if not u.suspended and u.created_at is not None),
360+
key=lambda u: u.created_at,
361+
)
362+
if not dated:
352363
return
353-
earliest_ms = min(created_times)
364+
earliest_ms = dated[0].created_at
354365
if earliest_ms > now_ms:
355366
earliest_ms = now_ms
356367

368+
admitted: "list[UserRecord]" = []
369+
ptr = 0
370+
n = len(dated)
357371
for key in _bucket_keys(earliest_ms, now_ms, units):
358372
next_key = get_next_time_key(key, units)
359373
next_start_ms = _dt_to_ms(parse_time_key(next_key, units))
360374
bucket_end_ms = min(next_start_ms - 1, now_ms)
375+
while ptr < n and dated[ptr].created_at <= bucket_end_ms:
376+
admitted.append(dated[ptr])
377+
ptr += 1
361378
existing = [
362-
u
363-
for u in users
364-
if not u.suspended
365-
and u.created_at is not None
366-
and u.created_at <= bucket_end_ms
367-
and (u.deleted_at is None or u.deleted_at >= bucket_end_ms)
379+
u for u in admitted if u.deleted_at is None or u.deleted_at >= bucket_end_ms
368380
]
369381
yield key, bucket_end_ms, existing
370382

@@ -520,7 +532,7 @@ def churn_series(
520532
the chargeback snapshot are visible, so hard-deleted accounts are not
521533
counted -- `deleted` reflects soft-deletes only. Returns `None` when no
522534
user has a known `created_at`."""
523-
created = [u.created_at for u in users if u.created_at]
535+
created = [u.created_at for u in users if u.created_at is not None]
524536
if not created:
525537
return None
526538
earliest_ms = min(created)
@@ -530,10 +542,10 @@ def churn_series(
530542
added_counts: dict = {}
531543
deleted_counts: dict = {}
532544
for u in users:
533-
if u.created_at:
545+
if u.created_at is not None:
534546
k = format_time_key(_ms_to_dt(u.created_at), units)
535547
added_counts[k] = added_counts.get(k, 0) + 1
536-
if u.deleted_at:
548+
if u.deleted_at is not None:
537549
k = format_time_key(_ms_to_dt(u.deleted_at), units)
538550
deleted_counts[k] = deleted_counts.get(k, 0) + 1
539551

cometx/cli/migrate_users.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -137,14 +137,16 @@ class _RequestsClient:
137137
"""
138138

139139
def get(self, url, headers=None, params=None):
140-
# Defensive scheme check at the request boundary: callers derive
141-
# `url` from operator-supplied --url/--source-url, and we only ever
142-
# talk to a Comet server over https. Reject anything else rather than
143-
# handing an arbitrary value to requests.get.
140+
# Defensive sanity check at the request boundary: callers derive
141+
# `url` from operator-supplied --url/--source-url. Reject only
142+
# clearly-malformed values (no host, or a non-http(s) scheme) rather
143+
# than handing an arbitrary value to requests.get. On-prem Comet
144+
# servers are reachable over plain http (see MIGRATIONS.md), so http
145+
# is allowed; this is not an SSRF denylist.
144146
parsed = urllib.parse.urlparse(url)
145-
if parsed.scheme != "https" or not parsed.netloc:
147+
if parsed.scheme not in ("http", "https") or not parsed.netloc:
146148
raise ValueError(
147-
"Request URL must be an https:// URL with a host; got %r." % url
149+
"Request URL must be an http(s):// URL with a host; got %r." % url
148150
)
149151
resp = requests.get(url, headers=headers, params=params, timeout=30)
150152
resp.raise_for_status()
@@ -161,9 +163,12 @@ def __init__(self, url, key):
161163

162164

163165
def _fetch_chargeback_report(server_url, source_api_key):
164-
from cometx.utils import fetch_chargeback_report
166+
from cometx.utils import admin_api_url, fetch_chargeback_report
165167

166-
url = f"{server_url}/api/admin/chargeback/report"
168+
# Build the printed URL the same way fetch_chargeback_report builds the
169+
# one it requests (preserving any path prefix), so the debug line can't
170+
# drift from the URL actually hit.
171+
url = admin_api_url(server_url, "/api/admin/chargeback/report")
167172
print(f"Fetching chargeback report from {url}...")
168173
api = _ApiShim(server_url, source_api_key)
169174
return fetch_chargeback_report(api, host=server_url)

cometx/utils.py

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -387,29 +387,40 @@ def remove_extra_slashes(path):
387387
return ""
388388

389389

390+
def admin_api_url(base, path):
391+
"""Join an operator-supplied server base with an admin API `path`.
392+
393+
Validates that `base` is a well-formed http(s) URL with a host, then
394+
preserves its scheme, host, AND any path prefix (e.g. `/clientlib`) that
395+
on-prem deployments sit behind -- only clearly-malformed values (no
396+
scheme, non-http(s) scheme, or empty host) are rejected. This is a
397+
boundary sanity check, not an SSRF control: it intentionally does not
398+
denylist private/loopback hosts, since operators legitimately point these
399+
admin commands at internal addresses.
400+
"""
401+
parsed = urlparse(base)
402+
if parsed.scheme not in ("http", "https") or not parsed.netloc:
403+
raise ValueError(
404+
"Comet server URL must be an http(s):// URL with a host; got %r." % base
405+
)
406+
prefix = parsed.path.rstrip("/")
407+
return "%s://%s%s%s" % (parsed.scheme, parsed.netloc, prefix, path)
408+
409+
390410
def fetch_chargeback_report(api, host=None, report_month=None):
391411
"""Fetch the admin chargeback report JSON.
392412
393413
Single source for the `/api/admin/chargeback/report` call used by the
394414
chargeback-report action, migrate-users, and growth-report. `host`
395415
overrides the base URL derived from `api.config["comet.url_override"]`;
396-
`report_month` (YYYY-MM) adds `?reportMonth=`.
416+
`report_month` (YYYY-MM) adds `?reportMonth=`. The base's path prefix (if
417+
any) is preserved -- see `admin_api_url`.
397418
"""
398419
if host is not None:
399420
base = host
400421
else:
401422
base = api.config["comet.url_override"]
402-
# Require a well-formed https base before issuing the request. The base
403-
# comes from operator-supplied --host/--source-url or the configured
404-
# override; enforce a scheme so a malformed value can't be sent verbatim.
405-
parsed = urlparse(base)
406-
if parsed.scheme != "https" or not parsed.netloc:
407-
raise ValueError(
408-
"Chargeback server URL must be an https:// URL with a host; "
409-
"got %r." % base
410-
)
411-
base = "%s://%s" % (parsed.scheme, parsed.netloc)
412-
url = base + "/api/admin/chargeback/report"
423+
url = admin_api_url(base, "/api/admin/chargeback/report")
413424
# Pass reportMonth as a query param so it's URL-encoded rather than
414425
# interpolated raw into the URL.
415426
params = {"reportMonth": report_month} if report_month else {}

tests/unit/test_admin_growth_report.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1280,3 +1280,25 @@ def test_short_api_error_condenses_verbose_sdk_error():
12801280
assert _short_api_error("plain boom") == "plain boom"
12811281
long = "x" * 300
12821282
assert len(_short_api_error(long)) <= 160
1283+
1284+
1285+
def test_num_renders_integral_floats_as_int():
1286+
from cometx.cli.admin_growth_report import _num
1287+
1288+
assert _num(3.0) == 3
1289+
assert isinstance(_num(3.0), int)
1290+
assert _num(3.5) == 3.5
1291+
assert _num(7) == 7
1292+
1293+
1294+
def test_lb_value_rounds_fractional_metric_and_passes_none():
1295+
# #5d: leaderboard bar values must render like workspace rows -- an
1296+
# em_score of 12.7 (data_logged_mb folded in) shows as 13, not 12.7,
1297+
# and a metric a user lacks (None) stays absent rather than becoming 0.
1298+
from cometx.cli.admin_growth_report import _lb_value
1299+
1300+
assert _lb_value(12.7) == 13
1301+
assert isinstance(_lb_value(12.7), int)
1302+
assert _lb_value(4.0) == 4
1303+
assert _lb_value(None) is None
1304+
assert _lb_value(9) == 9

tests/unit/test_admin_growth_users.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,48 @@ def test_active_series_buckets_total_and_active():
117117
assert pts[-1]["values"]["total"] == 2
118118

119119

120+
def test_active_series_sweep_drops_user_after_deletion():
121+
# Guards the _iter_buckets sweep refactor: a user created early and
122+
# deleted mid-span must be counted in buckets before their deletion and
123+
# excluded from buckets after it (the anti-monotone deletion case the
124+
# created-sorted sweep pointer must still honor).
125+
from cometx.cli.admin_growth_users import active_series, parse_users
126+
127+
month = 31 * 86400 * 1000
128+
start = NOW - 4 * month
129+
cb = {
130+
"workspaces": [],
131+
"users": {
132+
"licensedUsers": [
133+
{
134+
"username": "steady",
135+
"email": "s@x",
136+
"createdAt": start,
137+
"lastUsedAt": NOW,
138+
"deletedAt": None,
139+
"suspended": False,
140+
},
141+
{
142+
"username": "leaver",
143+
"email": "l@x",
144+
"createdAt": start,
145+
"lastUsedAt": NOW - 3 * month,
146+
# deleted ~2 months before now
147+
"deletedAt": NOW - 2 * month,
148+
"suspended": False,
149+
},
150+
]
151+
},
152+
}
153+
pts = active_series(
154+
parse_users(cb), units="month", now_ms=NOW, active_window_days=30
155+
)
156+
totals = [p["values"]["total"] for p in pts]
157+
# both present in the first bucket; only 'steady' survives to the last
158+
assert totals[0] == 2
159+
assert totals[-1] == 1
160+
161+
120162
def test_capability_series_none_when_no_capability_fields():
121163
from cometx.cli.admin_growth_users import capability_series, parse_users
122164

0 commit comments

Comments
 (0)