Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/cli/platform-admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>
Expand Down
26 changes: 26 additions & 0 deletions limacharlie/commands/org.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
14 changes: 14 additions & 0 deletions limacharlie/sdk/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
19 changes: 19 additions & 0 deletions tests/unit/test_cli_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/test_cli_lazy_loading_regression.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}),
Expand Down
11 changes: 11 additions & 0 deletions tests/unit/test_sdk_organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down