fix(signoz): scope explorer metadata exports
This commit is contained in:
@@ -7,6 +7,7 @@ import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import urllib.parse
|
||||
from contextlib import contextmanager
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
@@ -30,7 +31,18 @@ SOURCE_ASSETS: dict[str, Any] = {
|
||||
"status": "success",
|
||||
"data": {"rules": [{"id": "rule-1", "name": "Latency"}]},
|
||||
},
|
||||
"/api/v1/explorer/views": {"data": [{"id": "view-1", "name": "Errors"}]},
|
||||
"/api/v1/explorer/views?sourcePage=logs": {
|
||||
"data": [
|
||||
{
|
||||
"id": "view-1",
|
||||
"name": "Errors",
|
||||
"sourcePage": "logs",
|
||||
}
|
||||
]
|
||||
},
|
||||
"/api/v1/explorer/views?sourcePage=meter": {"data": None},
|
||||
"/api/v1/explorer/views?sourcePage=metrics": {"data": {}},
|
||||
"/api/v1/explorer/views?sourcePage=traces": {"data": []},
|
||||
"/api/v1/roles": {"data": [{"id": "role-1", "name": "Viewer"}]},
|
||||
"/api/v1/org/preferences": {"data": [{"name": "theme", "value": "dark"}]},
|
||||
"/api/v1/user/preferences": {"data": [{"name": "timezone", "value": "UTC"}]},
|
||||
@@ -63,12 +75,13 @@ class MetadataHandler(BaseHTTPRequestHandler):
|
||||
self._send_json(401, {"error": "unauthorized"})
|
||||
return
|
||||
path = self.path.split("?", 1)[0]
|
||||
if path not in self.assets:
|
||||
asset_key = self.path if self.path in self.assets else path
|
||||
if asset_key not in self.assets:
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
self.get_counts[path] = self.get_counts.get(path, 0) + 1
|
||||
value = json.loads(json.dumps(self.assets[path]))
|
||||
if self.drift_path == path and self.get_counts[path] >= 2:
|
||||
self.get_counts[asset_key] = self.get_counts.get(asset_key, 0) + 1
|
||||
value = json.loads(json.dumps(self.assets[asset_key]))
|
||||
if self.drift_path in {path, asset_key} and self.get_counts[asset_key] >= 2:
|
||||
value["data"].append({"id": "drift", "title": "changed"})
|
||||
self._send_json(200, value)
|
||||
|
||||
@@ -77,16 +90,7 @@ class MetadataHandler(BaseHTTPRequestHandler):
|
||||
self._send_json(401, {"error": "unauthorized"})
|
||||
return
|
||||
path = self.path.split("?", 1)[0]
|
||||
if path not in self.assets:
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
data = self.assets[path].get("data")
|
||||
collection = (
|
||||
data.get("rules")
|
||||
if path == "/api/v1/rules" and isinstance(data, dict)
|
||||
else data
|
||||
)
|
||||
if not isinstance(collection, list):
|
||||
if path != "/api/v1/explorer/views" and path not in self.assets:
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
@@ -94,6 +98,32 @@ class MetadataHandler(BaseHTTPRequestHandler):
|
||||
if not isinstance(value, dict):
|
||||
self._send_json(400, {"error": "object required"})
|
||||
return
|
||||
if path == "/api/v1/explorer/views":
|
||||
source_page = value.get("sourcePage")
|
||||
if source_page not in {"logs", "meter", "metrics", "traces"}:
|
||||
self._send_json(400, {"error": "sourcePage required"})
|
||||
return
|
||||
asset_key = (
|
||||
f"{path}?{urllib.parse.urlencode({'sourcePage': source_page})}"
|
||||
)
|
||||
if asset_key not in self.assets:
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
data = self.assets[asset_key].get("data")
|
||||
if data is None or data == {}:
|
||||
self.assets[asset_key]["data"] = []
|
||||
data = self.assets[asset_key]["data"]
|
||||
collection = data
|
||||
else:
|
||||
data = self.assets[path].get("data")
|
||||
collection = (
|
||||
data.get("rules")
|
||||
if path == "/api/v1/rules" and isinstance(data, dict)
|
||||
else data
|
||||
)
|
||||
if not isinstance(collection, list):
|
||||
self._send_json(404, {"error": "not found"})
|
||||
return
|
||||
type(self).post_count += 1
|
||||
restored = {"id": f"server-{type(self).post_count}", **value}
|
||||
collection.append(restored)
|
||||
@@ -137,6 +167,7 @@ def export_command(
|
||||
credential: Path | None,
|
||||
apply: bool,
|
||||
receipt_file: Path | None = None,
|
||||
policy: Path | None = None,
|
||||
) -> list[str]:
|
||||
command = [
|
||||
sys.executable,
|
||||
@@ -155,6 +186,8 @@ def export_command(
|
||||
]
|
||||
if credential is not None:
|
||||
command.extend(["--credential-file", str(credential)])
|
||||
if policy is not None:
|
||||
command.extend(["--policy", str(policy)])
|
||||
if apply:
|
||||
terminal_path = receipt_file or output_dir.with_name(
|
||||
f"{output_dir.name}.terminal.json"
|
||||
@@ -267,6 +300,53 @@ def test_policy_is_explicitly_non_raw_and_non_completion() -> None:
|
||||
alert_rules = next(item for item in policy["assets"] if item["id"] == "alert_rules")
|
||||
assert alert_rules["endpoint"] == "/api/v1/rules"
|
||||
assert alert_rules["response_pointer"] == "/data/rules"
|
||||
explorer_views = [
|
||||
item for item in policy["assets"] if item["id"].startswith("explorer_views_")
|
||||
]
|
||||
assert {item["query_parameters"]["sourcePage"] for item in explorer_views} == {
|
||||
"logs",
|
||||
"meter",
|
||||
"metrics",
|
||||
"traces",
|
||||
}
|
||||
assert all(item["response_pointer"] == "/data" for item in explorer_views)
|
||||
assert all(
|
||||
item["empty_collection_sentinels"] == ["null", "empty_object"]
|
||||
for item in explorer_views
|
||||
)
|
||||
|
||||
|
||||
def test_policy_rejects_unhashable_empty_collection_sentinel_as_contract_error(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
policy_value = json.loads(POLICY.read_text(encoding="utf-8"))
|
||||
explorer = next(
|
||||
item
|
||||
for item in policy_value["assets"]
|
||||
if item["id"] == "explorer_views_logs"
|
||||
)
|
||||
explorer["empty_collection_sentinels"] = [{}]
|
||||
malformed_policy = tmp_path / "malformed-policy.json"
|
||||
malformed_policy.write_text(json.dumps(policy_value), encoding="utf-8")
|
||||
result = subprocess.run(
|
||||
export_command(
|
||||
base_url="https://signoz.invalid",
|
||||
output_dir=tmp_path / "must-not-exist",
|
||||
credential=None,
|
||||
apply=False,
|
||||
policy=malformed_policy,
|
||||
),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 1
|
||||
assert "policy_empty_collection_sentinels_invalid_explorer_views_logs" in (
|
||||
result.stderr
|
||||
)
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
|
||||
def test_check_mode_writes_nothing(tmp_path: Path) -> None:
|
||||
@@ -322,6 +402,108 @@ def test_apply_and_independent_verifier_pass(tmp_path: Path) -> None:
|
||||
assert oct((bundle / "manifest.json").stat().st_mode & 0o777) == "0o600"
|
||||
|
||||
|
||||
def test_explorer_views_are_source_scoped_and_empty_sentinels_are_bounded(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
credential = credential_file(tmp_path)
|
||||
bundle = tmp_path / "bundle"
|
||||
with metadata_server(SOURCE_ASSETS) as (base_url, handler):
|
||||
result = subprocess.run(
|
||||
export_command(
|
||||
base_url=base_url,
|
||||
output_dir=bundle,
|
||||
credential=credential,
|
||||
apply=True,
|
||||
),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
expected_keys = {
|
||||
f"/api/v1/explorer/views?sourcePage={source_page}"
|
||||
for source_page in ("logs", "meter", "metrics", "traces")
|
||||
}
|
||||
assert expected_keys <= set(handler.get_counts)
|
||||
assert all(handler.get_counts[key] == 2 for key in expected_keys)
|
||||
assert "/api/v1/explorer/views" not in handler.get_counts
|
||||
manifest = json.loads((bundle / "manifest.json").read_text(encoding="utf-8"))
|
||||
view_counts = {
|
||||
item["id"]: item["item_count"]
|
||||
for item in manifest["assets"]
|
||||
if item["id"].startswith("explorer_views_")
|
||||
}
|
||||
assert view_counts == {
|
||||
"explorer_views_logs": 1,
|
||||
"explorer_views_meter": 0,
|
||||
"explorer_views_metrics": 0,
|
||||
"explorer_views_traces": 0,
|
||||
}
|
||||
view_queries = {
|
||||
item["id"]: item["query_parameters"]
|
||||
for item in manifest["assets"]
|
||||
if item["id"].startswith("explorer_views_")
|
||||
}
|
||||
assert view_queries == {
|
||||
"explorer_views_logs": {"sourcePage": "logs"},
|
||||
"explorer_views_meter": {"sourcePage": "meter"},
|
||||
"explorer_views_metrics": {"sourcePage": "metrics"},
|
||||
"explorer_views_traces": {"sourcePage": "traces"},
|
||||
}
|
||||
|
||||
|
||||
def test_explorer_views_nonempty_scalar_fails_closed(tmp_path: Path) -> None:
|
||||
assets = json.loads(json.dumps(SOURCE_ASSETS))
|
||||
assets["/api/v1/explorer/views?sourcePage=metrics"]["data"] = "unexpected"
|
||||
output = tmp_path / "bundle"
|
||||
credential = credential_file(tmp_path)
|
||||
with metadata_server(assets) as (base_url, _):
|
||||
result = subprocess.run(
|
||||
export_command(
|
||||
base_url=base_url,
|
||||
output_dir=output,
|
||||
credential=credential,
|
||||
apply=True,
|
||||
),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 1
|
||||
assert "asset_collection_shape_invalid_explorer_views_metrics" in result.stderr
|
||||
assert not output.exists()
|
||||
|
||||
|
||||
def test_explorer_view_item_must_match_scoped_source_page(tmp_path: Path) -> None:
|
||||
assets = json.loads(json.dumps(SOURCE_ASSETS))
|
||||
assets["/api/v1/explorer/views?sourcePage=logs"]["data"][0][
|
||||
"sourcePage"
|
||||
] = "traces"
|
||||
output = tmp_path / "bundle"
|
||||
credential = credential_file(tmp_path)
|
||||
with metadata_server(assets) as (base_url, _):
|
||||
result = subprocess.run(
|
||||
export_command(
|
||||
base_url=base_url,
|
||||
output_dir=output,
|
||||
credential=credential,
|
||||
apply=True,
|
||||
),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 1
|
||||
assert "asset_collection_scope_invalid_explorer_views_logs" in result.stderr
|
||||
assert not output.exists()
|
||||
|
||||
|
||||
def test_secret_key_fails_closed_without_bundle(tmp_path: Path) -> None:
|
||||
assets = json.loads(json.dumps(SOURCE_ASSETS))
|
||||
assets["/api/v1/global/config"]["data"]["clientSecret"] = "must-not-persist"
|
||||
@@ -429,7 +611,10 @@ def test_restore_check_and_semantic_apply_are_bounded(tmp_path: Path) -> None:
|
||||
target_assets = {
|
||||
"/api/v1/dashboards": {"data": []},
|
||||
"/api/v1/rules": {"status": "success", "data": {"rules": []}},
|
||||
"/api/v1/explorer/views": {"data": []},
|
||||
"/api/v1/explorer/views?sourcePage=logs": {"data": []},
|
||||
"/api/v1/explorer/views?sourcePage=meter": {"data": None},
|
||||
"/api/v1/explorer/views?sourcePage=metrics": {"data": {}},
|
||||
"/api/v1/explorer/views?sourcePage=traces": {"data": []},
|
||||
}
|
||||
with metadata_server(target_assets) as (target_base_url, handler):
|
||||
isolation = isolation_receipt(tmp_path, target_base_url)
|
||||
|
||||
Reference in New Issue
Block a user