feat(telemetry): add portable local telemetry plane

This commit is contained in:
DCCONSTRUCTIONS
2026-07-28 00:54:11 +03:00
parent 9fc002d86f
commit b8a008b50e
16 changed files with 1486 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
MISSIONCORE_MQTT_BIND_ADDRESS=127.0.0.1
MISSIONCORE_MQTT_PORT=1883
MISSIONCORE_TELEMETRY_QUERY_PORT=18030
MISSIONCORE_DB_NAME=missioncore_telemetry
MISSIONCORE_DB_USER=missioncore_ingest
MISSIONCORE_DB_PASSWORD=replace-with-a-random-local-secret
MISSIONCORE_MQTT_INGEST_USER=missioncore-ingest
MISSIONCORE_MQTT_INGEST_PASSWORD=replace-with-a-random-local-secret
MISSIONCORE_MQTT_WORKER_006_USER=worker-006
MISSIONCORE_MQTT_WORKER_006_PASSWORD=replace-with-a-different-random-local-secret
+2
View File
@@ -0,0 +1,2 @@
.env
runtime/
+81
View File
@@ -0,0 +1,81 @@
# NDC Mission Core local telemetry plane
Portable local-only telemetry infrastructure for compute contours.
One Docker Compose project, `ndc-mission-core-telemetry`, owns three isolated
containers:
- `ndc-mission-core-mqtt-broker` — Eclipse Mosquitto `2.1.2-alpine`;
- `ndc-mission-core-telemetry-timescaledb` — TimescaleDB HA OSS
`pg16.14-ts2.28.2-all-oss`;
- `ndc-mission-core-telemetry-normalizer` — Mission Core telemetry normalizer.
The Telegraf configuration templates cover Windows and Linux, but the agent runs as a
host service rather than inside this Compose project. This preserves access to native
Windows performance counters, Docker Desktop and NVIDIA telemetry.
The normalizer exposes a read-only normalized telemetry adapter on
`127.0.0.1:18030`. Mission Core reads this adapter; the browser never receives MQTT or
Timescale credentials, and Timescale is not published on a host port.
The stack is one deployment contour, not one multi-process container. Keeping broker,
normalizer and database in separate containers preserves independent health checks,
least-privilege boundaries and rollback while Compose provides one operator lifecycle:
```bash
docker compose ps
docker compose up -d
docker compose down
```
All NODE.DC-owned Docker objects use the lowercase `ndc-` namespace. Docker names are
case-sensitive identifiers, so the product prefix is normalized to lowercase while the
product name remains NODE.DC in operator-facing copy.
This directory does not contain credentials. Copy `.env.example` to `.env`, generate
unique passwords and build the ACL/password files before starting the stack.
```bash
uv run python prepare.py --initialize --mqtt-bind-address <MISSION_CORE_HOST_LAN_IP>
docker compose up -d --build
```
Expected Docker object names:
```text
project: ndc-mission-core-telemetry
network: ndc-mission-core-telemetry
containers:
ndc-mission-core-mqtt-broker
ndc-mission-core-telemetry-normalizer
ndc-mission-core-telemetry-timescaledb
volumes:
ndc-mission-core-mqtt-data
ndc-mission-core-telemetry-timescale-data
```
`prepare.py` passes passwords to `mosquitto_passwd` through stdin. Secrets are not placed
in process arguments or committed files. `--initialize` generates `.env` with mode
`0600` and refuses to replace existing credentials. The generated `.env` and `runtime/`
directory are ignored by Git.
The product architecture and topic contract are defined in
`docs/adr/0031-local-compute-contour-telemetry-plane.md`.
## Worker agent
Worker 006 uses the official Windows Telegraf distribution as the host service
`NDC Mission Core Telemetry Agent`. Install or update it with:
```powershell
.\telegraf\Install-NdcMissionCoreTelegraf.ps1
.\telegraf\Update-NdcMissionCoreTelegraf.ps1
```
The update path validates the candidate configuration, backs up the active
configuration and rolls back if the service does not return to `Running`. MQTT
credentials are scoped to the service environment and must not be passed on a command
line or stored in the repository.
The stack and agent are intentionally not started by repository tests. Provisioning a
machine is a separate, explicit operation.
+112
View File
@@ -0,0 +1,112 @@
name: ndc-mission-core-telemetry
x-ndc-labels: &ndc-labels
com.nodedc.product: mission-core
com.nodedc.stack: ndc-mission-core-telemetry
com.nodedc.managed-by: docker-compose
services:
broker:
image: eclipse-mosquitto:2.1.2-alpine
container_name: ndc-mission-core-mqtt-broker
restart: unless-stopped
labels:
<<: *ndc-labels
com.nodedc.role: mqtt-broker
ports:
- "${MISSIONCORE_MQTT_BIND_ADDRESS:-127.0.0.1}:${MISSIONCORE_MQTT_PORT:-1883}:1883"
environment:
MISSIONCORE_MQTT_HEALTH_USER: "${MISSIONCORE_MQTT_INGEST_USER}"
MISSIONCORE_MQTT_HEALTH_PASSWORD: "${MISSIONCORE_MQTT_INGEST_PASSWORD}"
volumes:
- ./mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
- ./runtime/mosquitto/passwords:/mosquitto/config/passwords:ro
- ./runtime/mosquitto/acl:/mosquitto/config/acl:ro
- broker-data:/mosquitto/data
healthcheck:
test:
- CMD-SHELL
- >-
mosquitto_sub -h 127.0.0.1 -t '$$SYS/broker/uptime' -C 1 -W 3
-u "$$MISSIONCORE_MQTT_HEALTH_USER"
-P "$$MISSIONCORE_MQTT_HEALTH_PASSWORD"
interval: 10s
timeout: 4s
retries: 6
timescale:
image: timescale/timescaledb-ha:pg16.14-ts2.28.2-all-oss
container_name: ndc-mission-core-telemetry-timescaledb
restart: unless-stopped
labels:
<<: *ndc-labels
com.nodedc.role: telemetry-database
environment:
POSTGRES_DB: "${MISSIONCORE_DB_NAME}"
POSTGRES_USER: "${MISSIONCORE_DB_USER}"
POSTGRES_PASSWORD: "${MISSIONCORE_DB_PASSWORD}"
volumes:
- timescale-data:/home/postgres/pgdata/data
- ./timescale/001_telemetry.sql:/docker-entrypoint-initdb.d/001_telemetry.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${MISSIONCORE_DB_USER} -d ${MISSIONCORE_DB_NAME}"]
interval: 10s
timeout: 5s
retries: 10
normalizer:
image: nodedc/mission-core-telemetry-normalizer:local
container_name: ndc-mission-core-telemetry-normalizer
build:
context: ../..
dockerfile: deploy/telemetry-plane/normalizer/Dockerfile
restart: unless-stopped
labels:
<<: *ndc-labels
com.nodedc.role: telemetry-normalizer
depends_on:
broker:
condition: service_healthy
timescale:
condition: service_healthy
ports:
- "127.0.0.1:${MISSIONCORE_TELEMETRY_QUERY_PORT:-18030}:18030"
environment:
MISSIONCORE_MQTT_HOST: broker
MISSIONCORE_MQTT_PORT: "1883"
MISSIONCORE_MQTT_USERNAME: "${MISSIONCORE_MQTT_INGEST_USER}"
MISSIONCORE_MQTT_PASSWORD: "${MISSIONCORE_MQTT_INGEST_PASSWORD}"
MISSIONCORE_DATABASE_DSN: >-
postgresql://${MISSIONCORE_DB_USER}:${MISSIONCORE_DB_PASSWORD}@timescale:5432/${MISSIONCORE_DB_NAME}
MISSIONCORE_QUERY_HOST: "0.0.0.0"
MISSIONCORE_QUERY_PORT: "18030"
healthcheck:
test:
- CMD
- python
- -c
- >-
import urllib.request;
urllib.request.urlopen('http://127.0.0.1:18030/health', timeout=3).read()
interval: 10s
timeout: 4s
retries: 6
volumes:
broker-data:
name: ndc-mission-core-mqtt-data
labels:
<<: *ndc-labels
com.nodedc.role: mqtt-storage
timescale-data:
name: ndc-mission-core-telemetry-timescale-data
labels:
<<: *ndc-labels
com.nodedc.role: telemetry-storage
networks:
default:
name: ndc-mission-core-telemetry
labels:
<<: *ndc-labels
com.nodedc.role: telemetry-network
@@ -0,0 +1,11 @@
persistence true
persistence_location /mosquitto/data/
listener 1883 0.0.0.0
allow_anonymous false
password_file /mosquitto/config/passwords
acl_file /mosquitto/config/acl
log_dest stdout
connection_messages true
log_timestamp true
@@ -0,0 +1,8 @@
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir "paho-mqtt>=2.1,<3" "psycopg[binary]>=3.2,<4"
COPY deploy/telemetry-plane/normalizer/normalizer.py /app/normalizer.py
USER 65532:65532
ENTRYPOINT ["python", "/app/normalizer.py"]
@@ -0,0 +1,378 @@
from __future__ import annotations
import json
import os
import re
import threading
import time
from datetime import UTC, datetime
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Final
from urllib.parse import parse_qs, urlparse
TOPIC: Final = "mission-core/v1/contours/+/agents/+/+"
TOPIC_RE: Final = re.compile(
r"^mission-core/v1/contours/(?P<contour>[-a-z0-9]+)/"
r"agents/(?P<agent>[-a-z0-9]+)/(?P<kind>host|runtime|pipeline|heartbeat)$"
)
SOURCE_SCHEMAS: Final = {
"host": "missioncore.agent-host-telemetry/v1",
"runtime": "missioncore.agent-runtime-telemetry/v1",
"pipeline": "missioncore.agent-pipeline-telemetry/v1",
"heartbeat": "missioncore.agent-heartbeat/v1",
}
TELEGRAF_SCHEMA: Final = "telegraf.metric-json/v1"
QUERY_SCHEMA: Final = "missioncore.telemetry-query/v1"
SAFE_IDENTIFIER: Final = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$")
def _required(name: str) -> str:
value = os.environ.get(name, "").strip()
if not value:
raise RuntimeError(f"{name} is required")
return value
def _timestamp(value: object) -> datetime:
if isinstance(value, int | float) and not isinstance(value, bool):
return datetime.fromtimestamp(float(value), tz=UTC)
if not isinstance(value, str):
raise ValueError("sample timestamp is required")
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
raise ValueError("observed_at_utc must include a timezone")
return parsed.astimezone(UTC)
def _optional_text(document: dict[str, Any], name: str) -> str | None:
value = document.get(name)
return value if isinstance(value, str) and value else None
def _optional_integer(document: dict[str, Any], name: str) -> int | None:
value = document.get(name)
return value if isinstance(value, int) and not isinstance(value, bool) else None
def _tag_text(document: dict[str, Any], name: str) -> str | None:
tags = document.get("tags")
if not isinstance(tags, dict):
return None
value = tags.get(name)
return value if isinstance(value, str) and value else None
def _series_key(document: dict[str, Any]) -> str:
tags = document.get("tags")
if not isinstance(tags, dict):
value = document.get("series_key")
return value if isinstance(value, str) else ""
stable_tags = {
str(name): str(value)
for name, value in tags.items()
if name not in {"agent_id", "contour_id", "host", "node_id"}
}
return json.dumps(
stable_tags,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
def _normalize(topic: str, payload: bytes) -> tuple[object, ...]:
match = TOPIC_RE.fullmatch(topic)
if match is None:
raise ValueError("topic is outside the telemetry contract")
document = json.loads(payload.decode("utf-8"))
if not isinstance(document, dict):
raise ValueError("payload must be an object")
kind = match.group("kind")
source_schema = document.get("schema_version")
if source_schema == SOURCE_SCHEMAS[kind]:
observed_value = document.get("observed_at_utc")
measurement = kind
elif (
kind == "host"
and isinstance(document.get("name"), str)
and isinstance(document.get("fields"), dict)
):
source_schema = TELEGRAF_SCHEMA
observed_value = document.get("timestamp")
measurement = document["name"].strip()
if not measurement:
raise ValueError("Telegraf measurement name is required")
else:
raise ValueError("payload schema does not match topic kind")
node_id = document.get("node_id") or _tag_text(document, "node_id")
if not node_id:
node_id = _tag_text(document, "host")
if not isinstance(node_id, str) or not node_id.strip():
raise ValueError("node_id is required")
observed_at = _timestamp(observed_value)
return (
observed_at,
match.group("contour"),
match.group("agent"),
node_id.strip(),
kind,
measurement,
_series_key(document),
source_schema,
topic,
_optional_text(document, "lab_id") or _tag_text(document, "lab_id"),
_optional_text(document, "run_id") or _tag_text(document, "run_id"),
_optional_text(document, "request_id") or _tag_text(document, "request_id"),
_optional_integer(document, "frame_index"),
json.dumps(document, separators=(",", ":"), ensure_ascii=False),
)
class TelemetryQueryServer(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, server_address: tuple[str, int], dsn: str) -> None:
super().__init__(server_address, TelemetryQueryHandler)
self.dsn = dsn
class TelemetryQueryHandler(BaseHTTPRequestHandler):
server: TelemetryQueryServer
def do_GET(self) -> None: # noqa: N802 - stdlib HTTP handler contract
parsed = urlparse(self.path)
if parsed.path == "/health":
self._health()
return
match = re.fullmatch(
r"/v1/contours/(?P<contour>[-a-z0-9]+)/agents/"
r"(?P<agent>[-a-z0-9]+)/latest",
parsed.path,
)
if match is None:
self._json(HTTPStatus.NOT_FOUND, {"detail": "not found"})
return
query = parse_qs(parsed.query)
try:
max_age_seconds = int(query.get("max_age_seconds", ["120"])[0])
except ValueError:
self._json(HTTPStatus.BAD_REQUEST, {"detail": "invalid max_age_seconds"})
return
if not 1 <= max_age_seconds <= 86_400:
self._json(HTTPStatus.BAD_REQUEST, {"detail": "invalid max_age_seconds"})
return
self._latest(
match.group("contour"),
match.group("agent"),
max_age_seconds=max_age_seconds,
)
def log_message(self, format: str, *args: object) -> None:
print(f"telemetry query: {format % args}", flush=True)
def _health(self) -> None:
import psycopg # type: ignore[import-not-found]
try:
with (
psycopg.connect(self.server.dsn) as connection,
connection.cursor() as cursor,
):
cursor.execute("SELECT 1")
cursor.fetchone()
except psycopg.Error:
self._json(
HTTPStatus.SERVICE_UNAVAILABLE,
{"ok": False, "schema_version": QUERY_SCHEMA},
)
return
self._json(HTTPStatus.OK, {"ok": True, "schema_version": QUERY_SCHEMA})
def _latest(
self,
contour_id: str,
agent_id: str,
*,
max_age_seconds: int,
) -> None:
import psycopg # type: ignore[import-not-found]
if (
SAFE_IDENTIFIER.fullmatch(contour_id) is None
or SAFE_IDENTIFIER.fullmatch(agent_id) is None
):
self._json(HTTPStatus.BAD_REQUEST, {"detail": "invalid identifier"})
return
try:
with (
psycopg.connect(self.server.dsn) as connection,
connection.cursor() as cursor,
):
cursor.execute(
"""
SELECT DISTINCT ON (kind, measurement, series_key)
observed_at, ingested_at, node_id, kind, measurement,
series_key, source_schema, lab_id, run_id, request_id,
frame_index, payload
FROM contour_telemetry_samples
WHERE contour_id = %s
AND agent_id = %s
AND observed_at >= NOW() - (%s * INTERVAL '1 second')
ORDER BY kind, measurement, series_key, observed_at DESC
""",
(contour_id, agent_id, max_age_seconds),
)
rows = cursor.fetchall()
except psycopg.Error:
self._json(
HTTPStatus.SERVICE_UNAVAILABLE,
{"detail": "telemetry database unavailable"},
)
return
samples = [
{
"observed_at_utc": row[0].astimezone(UTC).isoformat().replace("+00:00", "Z"),
"ingested_at_utc": row[1].astimezone(UTC).isoformat().replace("+00:00", "Z"),
"node_id": row[2],
"kind": row[3],
"measurement": row[4],
"series_key": row[5],
"source_schema": row[6],
"lab_id": row[7],
"run_id": row[8],
"request_id": row[9],
"frame_index": row[10],
"payload": row[11],
}
for row in rows
]
self._json(
HTTPStatus.OK,
{
"schema_version": QUERY_SCHEMA,
"contour_id": contour_id,
"agent_id": agent_id,
"samples": samples,
},
)
def _json(self, status: HTTPStatus, document: dict[str, object]) -> None:
body = json.dumps(
document,
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
self.send_response(status.value)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _start_query_server(dsn: str) -> ThreadingHTTPServer:
host = os.environ.get("MISSIONCORE_QUERY_HOST", "0.0.0.0")
port = int(os.environ.get("MISSIONCORE_QUERY_PORT", "18030"))
server = TelemetryQueryServer((host, port), dsn)
thread = threading.Thread(
target=server.serve_forever,
name="telemetry-query",
daemon=True,
)
thread.start()
return server
def main() -> None:
import paho.mqtt.client as mqtt
import psycopg # type: ignore[import-not-found]
from paho.mqtt import MQTTException
from paho.mqtt.enums import CallbackAPIVersion
dsn = _required("MISSIONCORE_DATABASE_DSN")
host = _required("MISSIONCORE_MQTT_HOST")
port = int(os.environ.get("MISSIONCORE_MQTT_PORT", "1883"))
username = _required("MISSIONCORE_MQTT_USERNAME")
password = _required("MISSIONCORE_MQTT_PASSWORD")
_start_query_server(dsn)
connection = psycopg.connect(dsn, autocommit=True)
client = mqtt.Client(
callback_api_version=CallbackAPIVersion.VERSION2,
client_id="ndc-mission-core-telemetry-normalizer",
clean_session=False,
)
client.username_pw_set(username, password)
def on_connect(
connected_client: Any,
_userdata: object,
_flags: Any,
reason_code: Any,
_properties: Any,
) -> None:
if reason_code.is_failure:
raise RuntimeError(f"MQTT connection rejected: {reason_code}")
connected_client.subscribe(TOPIC, qos=1)
def on_message(_client: Any, _userdata: object, message: Any) -> None:
try:
row = _normalize(message.topic, message.payload)
with connection.cursor() as cursor:
cursor.execute(
"""
INSERT INTO contour_telemetry_samples (
observed_at, contour_id, agent_id, node_id, kind, measurement,
series_key, source_schema, source_topic, lab_id, run_id,
request_id, frame_index, payload
)
VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s::jsonb
)
ON CONFLICT (
observed_at, contour_id, agent_id, kind, measurement, series_key
)
DO UPDATE SET
ingested_at = NOW(),
node_id = EXCLUDED.node_id,
series_key = EXCLUDED.series_key,
source_schema = EXCLUDED.source_schema,
source_topic = EXCLUDED.source_topic,
lab_id = EXCLUDED.lab_id,
run_id = EXCLUDED.run_id,
request_id = EXCLUDED.request_id,
frame_index = EXCLUDED.frame_index,
payload = CASE
WHEN contour_telemetry_samples.source_schema = %s
AND EXCLUDED.source_schema = %s
THEN EXCLUDED.payload || jsonb_build_object(
'fields',
COALESCE(
contour_telemetry_samples.payload -> 'fields',
'{}'::jsonb
) || COALESCE(
EXCLUDED.payload -> 'fields',
'{}'::jsonb
)
)
ELSE EXCLUDED.payload
END
""",
(*row, TELEGRAF_SCHEMA, TELEGRAF_SCHEMA),
)
except (ValueError, UnicodeDecodeError, json.JSONDecodeError, psycopg.Error) as exc:
print(f"telemetry sample rejected: {exc}", flush=True)
client.on_connect = on_connect
client.on_message = on_message
while True:
try:
client.connect(host, port, keepalive=30)
client.loop_forever(retry_first_connection=True)
except (OSError, MQTTException, psycopg.Error) as exc:
print(f"telemetry normalizer reconnecting: {exc}", flush=True)
time.sleep(3)
if __name__ == "__main__":
main()
+130
View File
@@ -0,0 +1,130 @@
from __future__ import annotations
import argparse
import ipaddress
import os
import secrets
import subprocess
from pathlib import Path
from typing import Final
ROOT: Final = Path(__file__).resolve().parent
ENV_PATH: Final = ROOT / ".env"
RUNTIME: Final = ROOT / "runtime" / "mosquitto"
IMAGE: Final = "eclipse-mosquitto:2.1.2-alpine"
PLACEHOLDER: Final = "replace-with-"
def _initialize_environment(bind_address: str, *, overwrite: bool = False) -> None:
if ENV_PATH.exists() and not overwrite:
raise RuntimeError(".env already exists; refusing to overwrite local credentials")
normalized_bind_address = str(ipaddress.ip_address(bind_address))
values = {
"MISSIONCORE_MQTT_BIND_ADDRESS": normalized_bind_address,
"MISSIONCORE_MQTT_PORT": "1883",
"MISSIONCORE_TELEMETRY_QUERY_PORT": "18030",
"MISSIONCORE_DB_NAME": "missioncore_telemetry",
"MISSIONCORE_DB_USER": "missioncore_ingest",
"MISSIONCORE_DB_PASSWORD": secrets.token_urlsafe(36),
"MISSIONCORE_MQTT_INGEST_USER": "missioncore-ingest",
"MISSIONCORE_MQTT_INGEST_PASSWORD": secrets.token_urlsafe(36),
"MISSIONCORE_MQTT_WORKER_006_USER": "worker-006",
"MISSIONCORE_MQTT_WORKER_006_PASSWORD": secrets.token_urlsafe(36),
}
ENV_PATH.write_text(
"".join(f"{name}={value}\n" for name, value in values.items()),
encoding="utf-8",
)
os.chmod(ENV_PATH, 0o600)
def _environment() -> dict[str, str]:
if not ENV_PATH.is_file():
raise RuntimeError("copy .env.example to .env and set unique secrets first")
values: dict[str, str] = {}
for raw_line in ENV_PATH.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
name, value = line.split("=", 1)
values[name.strip()] = value.strip()
return values
def _required(values: dict[str, str], name: str) -> str:
value = values.get(name, "")
if not value or value.startswith(PLACEHOLDER):
raise RuntimeError(f"{name} must contain a non-placeholder value")
return value
def _password_entry(path: Path, username: str, password: str, *, create: bool) -> None:
command = [
"docker",
"run",
"--rm",
"-i",
"-v",
f"{RUNTIME}:/out",
IMAGE,
"mosquitto_passwd",
]
if create:
command.append("-c")
command.extend([f"/out/{path.name}", username])
subprocess.run(
command,
check=True,
input=f"{password}\n{password}\n",
text=True,
)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--initialize",
action="store_true",
help="create a private .env with generated local credentials",
)
parser.add_argument(
"--mqtt-bind-address",
default="127.0.0.1",
help="host IP exposed to telemetry agents",
)
arguments = parser.parse_args()
if arguments.initialize:
_initialize_environment(arguments.mqtt_bind_address)
values = _environment()
ingest_user = _required(values, "MISSIONCORE_MQTT_INGEST_USER")
ingest_password = _required(values, "MISSIONCORE_MQTT_INGEST_PASSWORD")
worker_user = _required(values, "MISSIONCORE_MQTT_WORKER_006_USER")
worker_password = _required(values, "MISSIONCORE_MQTT_WORKER_006_PASSWORD")
_required(values, "MISSIONCORE_DB_PASSWORD")
RUNTIME.mkdir(parents=True, exist_ok=True)
password_path = RUNTIME / "passwords"
_password_entry(password_path, ingest_user, ingest_password, create=True)
_password_entry(password_path, worker_user, worker_password, create=False)
acl_path = RUNTIME / "acl"
acl_path.write_text(
"\n".join(
[
f"user {ingest_user}",
"topic read mission-core/v1/contours/+/agents/+/+",
"topic read $SYS/broker/uptime",
"",
"# Agent username must equal its stable agent id.",
"pattern write mission-core/v1/contours/+/agents/%u/+",
"",
]
),
encoding="utf-8",
)
os.chmod(password_path, 0o600)
os.chmod(acl_path, 0o600)
print("Mosquitto password and ACL files prepared.", flush=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,120 @@
[CmdletBinding()]
param(
[string]$Version = "1.38.4",
[string]$ExpectedSha256 = "6c7878ec319471ac85b82443baec2f3fa5dbcf1b6e2da5d5cd2cbb60fff2bb45",
[string]$ConfigurationTemplate = "$PSScriptRoot\mission-core-windows.conf.tmpl"
)
$ErrorActionPreference = "Stop"
$serviceName = "telegraf"
$installRoot = "C:\Program Files\NDC\Mission Core\Telegraf"
$configurationRoot = "C:\ProgramData\NDC\MissionCore\telemetry-agent"
$configurationPath = Join-Path $configurationRoot "telegraf.conf"
$archiveUrl = "https://dl.influxdata.com/telegraf/releases/telegraf-$($Version)_windows_amd64.zip"
$payload = [Console]::In.ReadToEnd() | ConvertFrom-Json
foreach ($name in @(
"MISSIONCORE_CONTOUR_ID",
"MISSIONCORE_AGENT_ID",
"MISSIONCORE_NODE_ID",
"MISSIONCORE_MQTT_HOST",
"MISSIONCORE_MQTT_PORT",
"MISSIONCORE_MQTT_USERNAME",
"MISSIONCORE_MQTT_PASSWORD"
)) {
$value = $payload.$name
if (-not $value) {
throw "Provisioning payload is missing $name"
}
Set-Item -Path "Env:$name" -Value ([string]$value)
}
if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) {
throw "Service '$serviceName' already exists; refusing an implicit replacement"
}
if (-not (Test-Path -LiteralPath $ConfigurationTemplate -PathType Leaf)) {
throw "Configuration template not found: $ConfigurationTemplate"
}
$temporaryRoot = Join-Path $env:TEMP "ndc-mission-core-telegraf-$([Guid]::NewGuid().ToString('N'))"
$archivePath = Join-Path $temporaryRoot "telegraf.zip"
$expandedRoot = Join-Path $temporaryRoot "expanded"
try {
New-Item -ItemType Directory -Path $temporaryRoot, $expandedRoot -Force | Out-Null
Invoke-WebRequest -UseBasicParsing -Uri $archiveUrl -OutFile $archivePath
$actualSha256 = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) {
throw "Telegraf archive SHA256 mismatch"
}
Expand-Archive -LiteralPath $archivePath -DestinationPath $expandedRoot
$sourceExecutable = Get-ChildItem -Path $expandedRoot -Filter "telegraf.exe" -Recurse |
Select-Object -First 1
if (-not $sourceExecutable) {
throw "telegraf.exe is missing from the verified archive"
}
New-Item -ItemType Directory -Path $installRoot, $configurationRoot -Force | Out-Null
Copy-Item -LiteralPath $sourceExecutable.FullName -Destination (Join-Path $installRoot "telegraf.exe")
Copy-Item -LiteralPath $ConfigurationTemplate -Destination $configurationPath
& icacls.exe $configurationRoot /inheritance:r /grant:r `
"*S-1-5-18:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F" | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Failed to restrict the telemetry agent configuration directory"
}
$executable = Join-Path $installRoot "telegraf.exe"
$validationOutput = Join-Path $temporaryRoot "validation.out.log"
$validationError = Join-Path $temporaryRoot "validation.error.log"
$validation = Start-Process -FilePath $executable `
-ArgumentList @("--config", $configurationPath, "--test") `
-NoNewWindow -Wait -PassThru `
-RedirectStandardOutput $validationOutput `
-RedirectStandardError $validationError
if ($validation.ExitCode -ne 0) {
$validationDetail = Get-Content -LiteralPath $validationError -Tail 8 |
Out-String
throw "Telegraf configuration validation failed: $validationDetail"
}
& $executable --service install --config $configurationPath
if ($LASTEXITCODE -ne 0) {
throw "Telegraf service installation failed"
}
$serviceEnvironment = @(
"MISSIONCORE_CONTOUR_ID=$($payload.MISSIONCORE_CONTOUR_ID)",
"MISSIONCORE_AGENT_ID=$($payload.MISSIONCORE_AGENT_ID)",
"MISSIONCORE_NODE_ID=$($payload.MISSIONCORE_NODE_ID)",
"MISSIONCORE_MQTT_HOST=$($payload.MISSIONCORE_MQTT_HOST)",
"MISSIONCORE_MQTT_PORT=$($payload.MISSIONCORE_MQTT_PORT)",
"MISSIONCORE_MQTT_USERNAME=$($payload.MISSIONCORE_MQTT_USERNAME)",
"MISSIONCORE_MQTT_PASSWORD=$($payload.MISSIONCORE_MQTT_PASSWORD)"
)
$serviceRegistryPath = "HKLM:\SYSTEM\CurrentControlSet\Services\$serviceName"
Set-ItemProperty -Path $serviceRegistryPath -Name Environment `
-Type MultiString -Value $serviceEnvironment
Set-ItemProperty -Path $serviceRegistryPath -Name DisplayName `
-Value "NDC Mission Core Telemetry Agent"
& sc.exe config $serviceName DisplayName= "NDC Mission Core Telemetry Agent" | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Failed to apply the NDC service display name"
}
Set-Service -Name $serviceName -StartupType Automatic
Start-Service -Name $serviceName
$service = Get-Service -Name $serviceName
$service.WaitForStatus([ServiceProcess.ServiceControllerStatus]::Running, [TimeSpan]::FromSeconds(20))
[ordered]@{
SchemaVersion = "missioncore.telemetry-agent-install-result/v1"
Agent = "Telegraf"
Version = $Version
ServiceName = $service.Name
DisplayName = $service.DisplayName
Status = $service.Status.ToString()
StartType = $service.StartType.ToString()
Configuration = $configurationPath
Sha256 = $actualSha256
} | ConvertTo-Json -Compress
}
finally {
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
}
@@ -0,0 +1,75 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ConfigurationTemplate
)
$ErrorActionPreference = "Stop"
$serviceName = "telegraf"
$installRoot = "C:\Program Files\NDC\Mission Core\Telegraf"
$configurationRoot = "C:\ProgramData\NDC\MissionCore\telemetry-agent"
$configurationPath = Join-Path $configurationRoot "telegraf.conf"
$executable = Join-Path $installRoot "telegraf.exe"
$serviceRegistryPath = "HKLM:\SYSTEM\CurrentControlSet\Services\$serviceName"
$service = Get-Service -Name $serviceName -ErrorAction Stop
if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) {
throw "NDC Mission Core Telegraf executable is missing"
}
if (-not (Test-Path -LiteralPath $ConfigurationTemplate -PathType Leaf)) {
throw "Configuration template not found: $ConfigurationTemplate"
}
foreach ($entry in @((Get-ItemProperty -Path $serviceRegistryPath).Environment)) {
$name, $value = $entry -split "=", 2
if ($name -and $value) {
Set-Item -Path "Env:$name" -Value $value
}
}
$temporaryRoot = Join-Path $env:TEMP "ndc-mission-core-telegraf-update-$([Guid]::NewGuid().ToString('N'))"
$validationOutput = Join-Path $temporaryRoot "validation.out.log"
$validationError = Join-Path $temporaryRoot "validation.error.log"
$backupRoot = Join-Path $configurationRoot "backups"
$backupPath = Join-Path $backupRoot "telegraf-$([DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ')).conf"
try {
New-Item -ItemType Directory -Path $temporaryRoot, $backupRoot -Force | Out-Null
$validation = Start-Process -FilePath $executable `
-ArgumentList @("--config", $ConfigurationTemplate, "--test") `
-NoNewWindow -Wait -PassThru `
-RedirectStandardOutput $validationOutput `
-RedirectStandardError $validationError
if ($validation.ExitCode -ne 0) {
$validationDetail = Get-Content -LiteralPath $validationError -Tail 8 |
Out-String
throw "Telegraf configuration validation failed: $validationDetail"
}
Copy-Item -LiteralPath $configurationPath -Destination $backupPath
Stop-Service -Name $serviceName
try {
Copy-Item -LiteralPath $ConfigurationTemplate -Destination $configurationPath
Start-Service -Name $serviceName
$service = Get-Service -Name $serviceName
$service.WaitForStatus(
[ServiceProcess.ServiceControllerStatus]::Running,
[TimeSpan]::FromSeconds(20)
)
}
catch {
Copy-Item -LiteralPath $backupPath -Destination $configurationPath
Start-Service -Name $serviceName
throw
}
[ordered]@{
SchemaVersion = "missioncore.telemetry-agent-config-result/v1"
ServiceName = $serviceName
Status = (Get-Service -Name $serviceName).Status.ToString()
Configuration = $configurationPath
Backup = $backupPath
} | ConvertTo-Json -Compress
}
finally {
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
}
@@ -0,0 +1,35 @@
[agent]
interval = "2s"
round_interval = true
omit_hostname = false
[global_tags]
node_id = "${MISSIONCORE_NODE_ID}"
contour_id = "${MISSIONCORE_CONTOUR_ID}"
agent_id = "${MISSIONCORE_AGENT_ID}"
[[inputs.cpu]]
percpu = false
totalcpu = true
report_active = true
[[inputs.mem]]
[[inputs.disk]]
ignore_fs = ["tmpfs", "devtmpfs", "devfs"]
[[inputs.net]]
[[inputs.system]]
[[inputs.nvidia_smi]]
[[inputs.docker]]
endpoint = "unix:///var/run/docker.sock"
container_name_include = []
total = true
[[outputs.mqtt]]
servers = ["tcp://${MISSIONCORE_MQTT_HOST}:${MISSIONCORE_MQTT_PORT}"]
topic = "mission-core/v1/contours/${MISSIONCORE_CONTOUR_ID}/agents/${MISSIONCORE_AGENT_ID}/host"
username = "${MISSIONCORE_MQTT_USERNAME}"
password = "${MISSIONCORE_MQTT_PASSWORD}"
qos = 1
data_format = "json"
json_timestamp_units = "1s"
@@ -0,0 +1,57 @@
[agent]
interval = "2s"
round_interval = true
omit_hostname = false
[global_tags]
node_id = "${MISSIONCORE_NODE_ID}"
contour_id = "${MISSIONCORE_CONTOUR_ID}"
agent_id = "${MISSIONCORE_AGENT_ID}"
[[inputs.cpu]]
percpu = false
totalcpu = true
report_active = true
[[inputs.mem]]
[[inputs.system]]
[[inputs.disk]]
ignore_fs = ["tmpfs", "devtmpfs", "devfs"]
[[inputs.net]]
[[inputs.win_perf_counters]]
[[inputs.win_perf_counters.object]]
ObjectName = "System"
Counters = ["System Up Time"]
Instances = ["------"]
Measurement = "win_system"
[[inputs.nvidia_smi]]
[[inputs.docker]]
endpoint = "npipe:////./pipe/docker_engine"
container_name_include = []
[[inputs.http_response]]
urls = ["http://127.0.0.1:8000/v2/health/ready"]
response_timeout = "2s"
response_status_code = 200
name_override = "missioncore_triton_health"
[[inputs.prometheus]]
urls = ["http://127.0.0.1:8002/metrics"]
metric_version = 1
response_timeout = "2s"
namepass = [
"nv_inference_request_success",
"nv_inference_request_failure",
"nv_inference_count",
]
[[outputs.mqtt]]
servers = ["tcp://${MISSIONCORE_MQTT_HOST}:${MISSIONCORE_MQTT_PORT}"]
topic = "mission-core/v1/contours/${MISSIONCORE_CONTOUR_ID}/agents/${MISSIONCORE_AGENT_ID}/host"
username = "${MISSIONCORE_MQTT_USERNAME}"
password = "${MISSIONCORE_MQTT_PASSWORD}"
qos = 1
data_format = "json"
json_timestamp_units = "1s"
@@ -0,0 +1,43 @@
CREATE EXTENSION IF NOT EXISTS timescaledb;
CREATE TABLE IF NOT EXISTS contour_telemetry_samples (
observed_at TIMESTAMPTZ NOT NULL,
ingested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
contour_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
node_id TEXT NOT NULL,
kind TEXT NOT NULL,
measurement TEXT NOT NULL,
series_key TEXT NOT NULL DEFAULT '',
source_schema TEXT NOT NULL,
source_topic TEXT NOT NULL,
lab_id TEXT,
run_id TEXT,
request_id TEXT,
frame_index BIGINT,
payload JSONB NOT NULL,
PRIMARY KEY (
observed_at,
contour_id,
agent_id,
kind,
measurement,
series_key
)
);
SELECT create_hypertable(
'contour_telemetry_samples',
by_range('observed_at'),
if_not_exists => TRUE
);
CREATE INDEX IF NOT EXISTS contour_telemetry_latest_idx
ON contour_telemetry_samples (
contour_id,
agent_id,
kind,
measurement,
series_key,
observed_at DESC
);