feat(telemetry): add portable local telemetry plane
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
NORMALIZER_PATH = (
|
||||
REPOSITORY_ROOT
|
||||
/ "deploy"
|
||||
/ "telemetry-plane"
|
||||
/ "normalizer"
|
||||
/ "normalizer.py"
|
||||
)
|
||||
COMPOSE_PATH = REPOSITORY_ROOT / "deploy" / "telemetry-plane" / "compose.yaml"
|
||||
NORMALIZER_SOURCE = NORMALIZER_PATH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _normalizer() -> ModuleType:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"missioncore_telemetry_normalizer",
|
||||
NORMALIZER_PATH,
|
||||
)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_normalizer_accepts_native_telegraf_host_metric() -> None:
|
||||
normalizer = _normalizer()
|
||||
payload = json.dumps(
|
||||
{
|
||||
"name": "cpu",
|
||||
"tags": {
|
||||
"node_id": "DESKTOP-OPJ8J04",
|
||||
"host": "DESKTOP-OPJ8J04",
|
||||
"contour_id": "worker-006",
|
||||
"agent_id": "worker-006",
|
||||
},
|
||||
"fields": {"usage_active": 12.5},
|
||||
"timestamp": 1_785_179_600,
|
||||
}
|
||||
).encode()
|
||||
|
||||
row = normalizer._normalize(
|
||||
"mission-core/v1/contours/worker-006/agents/worker-006/host",
|
||||
payload,
|
||||
)
|
||||
|
||||
assert row[0] == datetime.fromtimestamp(1_785_179_600, tz=UTC)
|
||||
assert row[1:5] == ("worker-006", "worker-006", "DESKTOP-OPJ8J04", "host")
|
||||
assert row[5] == "cpu"
|
||||
assert row[6] == "{}"
|
||||
assert row[7] == "telegraf.metric-json/v1"
|
||||
|
||||
|
||||
def test_normalizer_keeps_same_timestamp_host_measurements_distinct() -> None:
|
||||
normalizer = _normalizer()
|
||||
rows = [
|
||||
normalizer._normalize(
|
||||
"mission-core/v1/contours/worker-006/agents/worker-006/host",
|
||||
json.dumps(
|
||||
{
|
||||
"name": name,
|
||||
"tags": {
|
||||
"node_id": "DESKTOP-OPJ8J04",
|
||||
"contour_id": "worker-006",
|
||||
"agent_id": "worker-006",
|
||||
},
|
||||
"fields": fields,
|
||||
"timestamp": 1_785_179_600,
|
||||
}
|
||||
).encode(),
|
||||
)
|
||||
for name, fields in (
|
||||
("cpu", {"usage_active": 12.5}),
|
||||
("mem", {"used_percent": 43.2}),
|
||||
)
|
||||
]
|
||||
|
||||
assert rows[0][0] == rows[1][0]
|
||||
assert rows[0][5] == "cpu"
|
||||
assert rows[1][5] == "mem"
|
||||
assert rows[0][:5] == rows[1][:5]
|
||||
|
||||
|
||||
def test_normalizer_keeps_same_measurement_series_distinct() -> None:
|
||||
normalizer = _normalizer()
|
||||
rows = [
|
||||
normalizer._normalize(
|
||||
"mission-core/v1/contours/worker-006/agents/worker-006/host",
|
||||
json.dumps(
|
||||
{
|
||||
"name": "net",
|
||||
"tags": {
|
||||
"node_id": "DESKTOP-OPJ8J04",
|
||||
"contour_id": "worker-006",
|
||||
"agent_id": "worker-006",
|
||||
"interface": interface,
|
||||
},
|
||||
"fields": {"bytes_recv": received},
|
||||
"timestamp": 1_785_179_600,
|
||||
}
|
||||
).encode(),
|
||||
)
|
||||
for interface, received in (("Ethernet", 10), ("Wi-Fi", 20))
|
||||
]
|
||||
|
||||
assert rows[0][0:6] == rows[1][0:6]
|
||||
assert rows[0][6] == '{"interface":"Ethernet"}'
|
||||
assert rows[1][6] == '{"interface":"Wi-Fi"}'
|
||||
|
||||
|
||||
def test_telegraf_upsert_merges_split_fields_in_one_series() -> None:
|
||||
assert "contour_telemetry_samples.payload -> 'fields'" in NORMALIZER_SOURCE
|
||||
assert "EXCLUDED.payload -> 'fields'" in NORMALIZER_SOURCE
|
||||
|
||||
|
||||
def test_normalizer_rejects_unschematized_pipeline_payload() -> None:
|
||||
normalizer = _normalizer()
|
||||
with pytest.raises(ValueError, match="schema"):
|
||||
normalizer._normalize(
|
||||
"mission-core/v1/contours/worker-006/agents/worker-006/pipeline",
|
||||
json.dumps(
|
||||
{
|
||||
"node_id": "DESKTOP-OPJ8J04",
|
||||
"observed_at_utc": "2026-07-27T12:00:00Z",
|
||||
}
|
||||
).encode(),
|
||||
)
|
||||
|
||||
|
||||
def test_telemetry_plane_uses_the_ndc_docker_namespace() -> None:
|
||||
document = yaml.safe_load(COMPOSE_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
assert document["name"] == "ndc-mission-core-telemetry"
|
||||
services = document["services"]
|
||||
assert {
|
||||
service["container_name"]
|
||||
for service in services.values()
|
||||
} == {
|
||||
"ndc-mission-core-mqtt-broker",
|
||||
"ndc-mission-core-telemetry-normalizer",
|
||||
"ndc-mission-core-telemetry-timescaledb",
|
||||
}
|
||||
for service in services.values():
|
||||
assert service["container_name"].startswith("ndc-")
|
||||
assert service["labels"]["com.nodedc.product"] == "mission-core"
|
||||
assert service["labels"]["com.nodedc.stack"] == "ndc-mission-core-telemetry"
|
||||
|
||||
assert document["networks"]["default"]["name"] == "ndc-mission-core-telemetry"
|
||||
assert {
|
||||
volume["name"]
|
||||
for volume in document["volumes"].values()
|
||||
} == {
|
||||
"ndc-mission-core-mqtt-data",
|
||||
"ndc-mission-core-telemetry-timescale-data",
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _prepare_module() -> ModuleType:
|
||||
path = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "deploy"
|
||||
/ "telemetry-plane"
|
||||
/ "prepare.py"
|
||||
)
|
||||
specification = importlib.util.spec_from_file_location(
|
||||
"mission_core_telemetry_prepare",
|
||||
path,
|
||||
)
|
||||
assert specification is not None
|
||||
assert specification.loader is not None
|
||||
module = importlib.util.module_from_spec(specification)
|
||||
specification.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
prepare = _prepare_module()
|
||||
|
||||
|
||||
def test_initialize_environment_generates_private_unique_secrets(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
env_path = tmp_path / ".env"
|
||||
monkeypatch.setattr(prepare, "ENV_PATH", env_path)
|
||||
|
||||
prepare._initialize_environment("192.0.2.15")
|
||||
|
||||
values = dict(
|
||||
line.split("=", 1)
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines()
|
||||
)
|
||||
assert values["MISSIONCORE_MQTT_BIND_ADDRESS"] == "192.0.2.15"
|
||||
secrets = {
|
||||
values["MISSIONCORE_DB_PASSWORD"],
|
||||
values["MISSIONCORE_MQTT_INGEST_PASSWORD"],
|
||||
values["MISSIONCORE_MQTT_WORKER_006_PASSWORD"],
|
||||
}
|
||||
assert len(secrets) == 3
|
||||
assert all(len(secret) >= 40 for secret in secrets)
|
||||
assert stat.S_IMODE(env_path.stat().st_mode) == 0o600
|
||||
|
||||
|
||||
def test_initialize_environment_refuses_to_replace_credentials(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_text("existing=true\n", encoding="utf-8")
|
||||
monkeypatch.setattr(prepare, "ENV_PATH", env_path)
|
||||
|
||||
with pytest.raises(RuntimeError, match="refusing to overwrite"):
|
||||
prepare._initialize_environment("127.0.0.1")
|
||||
|
||||
assert env_path.read_text(encoding="utf-8") == "existing=true\n"
|
||||
Reference in New Issue
Block a user