From d10ff069ad90bbc14eac425f33e49b4dc9b0e34a Mon Sep 17 00:00:00 2001 From: Maxime Lamothe-Brassard Date: Fri, 12 Jun 2026 06:46:23 -0700 Subject: [PATCH] cli: add org quota-usage command for getOrgQuotaUsage API Adds support for the GET /quota_usage/{oid} endpoint (getOrgQuotaUsage), exposing the enforced sensor quota usage the platform uses to gate sensors coming online. Unlike the online sensor count, this weights EPP/response-mode sensors, so it can read higher and is the correct value to size the sensor quota against. - SDK: Organization.get_quota_usage() returns {usage, quota, breakdown} - CLI: `limacharlie org quota-usage` with --ai-help explain text - Docs + unit tests (SDK, CLI, command-map lint, subcommand regression) Co-Authored-By: Claude Opus 4.8 (1M context) --- doc/cli/platform-admin.md | 1 + limacharlie/commands/org.py | 26 +++++++++++++++++++ limacharlie/sdk/organization.py | 14 ++++++++++ tests/unit/test_cli_commands.py | 19 ++++++++++++++ .../unit/test_cli_lazy_loading_regression.py | 2 +- tests/unit/test_sdk_organization.py | 11 ++++++++ 6 files changed, 72 insertions(+), 1 deletion(-) diff --git a/doc/cli/platform-admin.md b/doc/cli/platform-admin.md index 63e747f9..440a91ac 100644 --- a/doc/cli/platform-admin.md +++ b/doc/cli/platform-admin.md @@ -9,6 +9,7 @@ Commands for organization management, users, groups, API keys, ingestion keys, b ```bash limacharlie org info # Name, sensor count, version, quotas limacharlie org stats # Usage statistics +limacharlie org quota-usage # Enforced sensor quota usage + breakdown limacharlie org urls # Service URLs (for firewall rules) limacharlie org errors # Platform errors limacharlie org dismiss-error --component diff --git a/limacharlie/commands/org.py b/limacharlie/commands/org.py index 98614c8d..d7d3414c 100644 --- a/limacharlie/commands/org.py +++ b/limacharlie/commands/org.py @@ -390,6 +390,32 @@ def quota(ctx: click.Context, quota: int) -> None: _output(ctx, data) +# --------------------------------------------------------------------------- +# quota-usage +# --------------------------------------------------------------------------- + +_EXPLAIN_QUOTA_USAGE = """\ +Display the enforced sensor quota usage for the organization. This is the +weighted virtual-sensor count the platform actually uses to decide whether a +sensor may come online, so it is the value to size the sensor quota against. + +The reported usage can read higher than the online sensor count, which +weights EPP/response-mode sensors at 0. The response includes the configured +quota and a per-category breakdown (raw count and weighted contribution), +making it useful for capacity planning and understanding headroom before +licensing additional sensors. +""" +register_explain("org.quota-usage", _EXPLAIN_QUOTA_USAGE) + + +@group.command("quota-usage") +@pass_context +def quota_usage(ctx: click.Context) -> None: + org = _get_org(ctx) + data = org.get_quota_usage() + _output(ctx, data) + + # --------------------------------------------------------------------------- # schema # --------------------------------------------------------------------------- diff --git a/limacharlie/sdk/organization.py b/limacharlie/sdk/organization.py index bda7ff92..bba3878b 100644 --- a/limacharlie/sdk/organization.py +++ b/limacharlie/sdk/organization.py @@ -186,6 +186,20 @@ def set_quota(self, quota: int) -> dict[str, Any]: """ return self._client.request("POST", f"orgs/{self.oid}/quota", params={"quota": quota}) + def get_quota_usage(self) -> dict[str, Any]: + """Get the enforced sensor quota usage for the organization. + + This is the weighted virtual-sensor count the platform actually uses + to decide whether a sensor may come online, so it is the value to size + the sensor quota against. It can read higher than the online sensor + count, which weights EPP/response-mode sensors at 0. + + Returns: + dict: ``{"usage": int, "quota": int, "breakdown": {category: + {"n": int, "quota": float}}}``. + """ + return self._client.request("GET", f"quota_usage/{self.oid}") + def rename(self, new_name: str) -> dict[str, Any]: """Rename the organization. diff --git a/tests/unit/test_cli_commands.py b/tests/unit/test_cli_commands.py index 934bbb29..c852c3f7 100644 --- a/tests/unit/test_cli_commands.py +++ b/tests/unit/test_cli_commands.py @@ -313,6 +313,25 @@ def test_org_info(self, mock_org_cls, mock_client_cls): assert parsed["name"] == "TestOrg" mock_org.get_info.assert_called_once() + @patch("limacharlie.commands.org.Client") + @patch("limacharlie.commands.org.Organization") + def test_org_quota_usage(self, mock_org_cls, mock_client_cls): + mock_org = MagicMock() + mock_org.get_quota_usage.return_value = { + "usage": 42, + "quota": 100, + "breakdown": {"edr": {"n": 40, "quota": 40.0}}, + } + mock_org_cls.return_value = mock_org + + runner = CliRunner() + result = runner.invoke(cli, ["--output", "json", "org", "quota-usage"]) + assert result.exit_code == 0 + parsed = json.loads(result.output) + assert parsed["usage"] == 42 + assert parsed["quota"] == 100 + mock_org.get_quota_usage.assert_called_once() + @patch("limacharlie.commands.org.Client") @patch("limacharlie.commands.org.Organization") def test_org_urls(self, mock_org_cls, mock_client_cls): diff --git a/tests/unit/test_cli_lazy_loading_regression.py b/tests/unit/test_cli_lazy_loading_regression.py index 1141e405..25797d5a 100644 --- a/tests/unit/test_cli_lazy_loading_regression.py +++ b/tests/unit/test_cli_lazy_loading_regression.py @@ -183,7 +183,7 @@ "org": frozenset({ "check-name", "config-get", "config-set", "create", "delete", "dismiss-error", "errors", "info", "list", "mitre", "quota", - "rename", "runtime-metadata", "schema", "stats", "urls", + "quota-usage", "rename", "runtime-metadata", "schema", "stats", "urls", }), "output": frozenset({"create", "delete", "list"}), "payload": frozenset({"delete", "download", "list", "upload"}), diff --git a/tests/unit/test_sdk_organization.py b/tests/unit/test_sdk_organization.py index b279df9e..0086bdad 100644 --- a/tests/unit/test_sdk_organization.py +++ b/tests/unit/test_sdk_organization.py @@ -47,6 +47,17 @@ def test_get_stats(self, org, mock_client): org.get_stats() mock_client.request.assert_called_once_with("GET", "usage/test-oid-123") + def test_get_quota_usage(self, org, mock_client): + mock_client.request.return_value = { + "usage": 42, + "quota": 100, + "breakdown": {"edr": {"n": 40, "quota": 40.0}}, + } + result = org.get_quota_usage() + mock_client.request.assert_called_once_with("GET", "quota_usage/test-oid-123") + assert result["usage"] == 42 + assert result["quota"] == 100 + def test_get_errors(self, org, mock_client): mock_client.request.return_value = {"errors": []} org.get_errors()