refactor(observability): drop file-SD writer for http_sd_configs
Slice 4 of observability-service-registry. Removes the shared-file Prometheus bridge; external Prometheus now consumes node-exporter targets via http_sd_configs against GET /api/monitoring/prometheus-targets. - services/targets.py: removed write_prometheus_targets() (the file writer) and its json/Path/get_settings imports; updated module docstring. build_node_exporter_targets() is unchanged and still powers the HTTP endpoint. - main.py: removed the startup write_prometheus_targets call. - routers/settings.py: removed the _write_prometheus_targets helper and its three post machine create/update/delete call sites + the now-unused targets import. - config.py: removed the prometheus_file_sd_dir field. - docker-compose.yml / docker-compose.dev.yml: removed the PROMETHEUS_FILE_SD_DIR backend env var. - tests: removed TestWritePrometheusTargets + the write_prometheus_targets import in test_targets.py; rewrote the two TestSettingsMachines tests to assert machines appear/disappear from /api/monitoring/prometheus-targets (the surviving HTTP path) instead of the removed file-writer side effect. ruff clean; 239 backend tests pass.
This commit is contained in:
@@ -54,7 +54,6 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Observability
|
# Observability
|
||||||
prometheus_enabled: bool = True
|
prometheus_enabled: bool = True
|
||||||
prometheus_file_sd_dir: str = "/app/backend/.cache/prometheus-file-sd"
|
|
||||||
alertmanager_url: str = ""
|
alertmanager_url: str = ""
|
||||||
alertmanager_webhook_url: str = "" # Optional receiver for alertmanager webhook notifications
|
alertmanager_webhook_url: str = "" # Optional receiver for alertmanager webhook notifications
|
||||||
|
|
||||||
|
|||||||
@@ -44,12 +44,6 @@ async def lifespan(app: FastAPI):
|
|||||||
validate_encryption_key()
|
validate_encryption_key()
|
||||||
logger.info("Backend startup complete: %s", describe_settings(settings))
|
logger.info("Backend startup complete: %s", describe_settings(settings))
|
||||||
logger.info("Managed known_hosts will be populated lazily on first successful SSH connection")
|
logger.info("Managed known_hosts will be populated lazily on first successful SSH connection")
|
||||||
try:
|
|
||||||
from media_library_viewer_api.services.targets import write_prometheus_targets
|
|
||||||
|
|
||||||
write_prometheus_targets(get_settings_store())
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to write Prometheus file-SD targets during startup")
|
|
||||||
try:
|
try:
|
||||||
get_settings_store().ensure_defaults()
|
get_settings_store().ensure_defaults()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ from media_library_viewer_api.services.db_maintenance import remove_sqlite_datab
|
|||||||
from media_library_viewer_api.services.known_hosts import has_known_host
|
from media_library_viewer_api.services.known_hosts import has_known_host
|
||||||
from media_library_viewer_api.services.media_index import MediaIndex
|
from media_library_viewer_api.services.media_index import MediaIndex
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
from media_library_viewer_api.services.targets import write_prometheus_targets
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -121,14 +120,6 @@ def _validate_saved_machine_ssh(machine: MonitoringMachineInput, store: Settings
|
|||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
def _write_prometheus_targets(store: SettingsStore) -> None:
|
|
||||||
"""Regenerate Prometheus file-SD targets after machine changes."""
|
|
||||||
try:
|
|
||||||
write_prometheus_targets(store)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to write Prometheus file-SD targets")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/machines/test-ssh")
|
@router.post("/machines/test-ssh")
|
||||||
def test_machine_ssh(
|
def test_machine_ssh(
|
||||||
machine: MonitoringMachineInput,
|
machine: MonitoringMachineInput,
|
||||||
@@ -189,7 +180,6 @@ def post_machine(
|
|||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine.id)
|
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine.id)
|
||||||
_write_prometheus_targets(store)
|
|
||||||
saved_machine = MonitoringMachineInput.model_validate(saved)
|
saved_machine = MonitoringMachineInput.model_validate(saved)
|
||||||
_validate_saved_machine_ssh(saved_machine, store)
|
_validate_saved_machine_ssh(saved_machine, store)
|
||||||
return saved
|
return saved
|
||||||
@@ -204,7 +194,6 @@ def put_machine(
|
|||||||
if not store.get_machine(machine_id):
|
if not store.get_machine(machine_id):
|
||||||
raise HTTPException(status_code=404, detail="Machine not found")
|
raise HTTPException(status_code=404, detail="Machine not found")
|
||||||
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine_id)
|
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine_id)
|
||||||
_write_prometheus_targets(store)
|
|
||||||
saved_machine = MonitoringMachineInput.model_validate(saved)
|
saved_machine = MonitoringMachineInput.model_validate(saved)
|
||||||
_validate_saved_machine_ssh(saved_machine, store)
|
_validate_saved_machine_ssh(saved_machine, store)
|
||||||
return saved
|
return saved
|
||||||
@@ -215,7 +204,6 @@ def delete_machine(machine_id: str, store: SettingsStore = Depends(get_settings_
|
|||||||
if not store.get_machine(machine_id):
|
if not store.get_machine(machine_id):
|
||||||
raise HTTPException(status_code=404, detail="Machine not found")
|
raise HTTPException(status_code=404, detail="Machine not found")
|
||||||
store.delete_machine(machine_id)
|
store.delete_machine(machine_id)
|
||||||
_write_prometheus_targets(store)
|
|
||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
"""Prometheus file-based service discovery target management.
|
"""Prometheus Node Exporter target discovery.
|
||||||
|
|
||||||
The backend owns the list of remote Node Exporter targets so that operators can
|
The backend owns the list of remote Node Exporter targets so that operators can
|
||||||
enable scraping per machine from the Manage UI. Prometheus reads the generated
|
enable scraping per machine from the Manage UI. The list is exposed over HTTP at
|
||||||
JSON file via `file_sd_configs`; this keeps Prometheus config static and pushes
|
``GET /api/monitoring/prometheus-targets`` and consumed by an external Prometheus
|
||||||
machine-specific changes into a file it can reload.
|
via ``http_sd_configs`` (no shared volume required).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from media_library_viewer_api.config import get_settings
|
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -34,10 +31,9 @@ def _scrape_address(machine: dict[str, Any]) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def build_node_exporter_targets(store: SettingsStore) -> list[dict[str, Any]]:
|
def build_node_exporter_targets(store: SettingsStore) -> list[dict[str, Any]]:
|
||||||
"""Build a file-SD target list for all enabled SSH machines.
|
"""Build an http-SD target list for all enabled SSH machines.
|
||||||
|
|
||||||
Local machines are excluded because the Compose-managed node-exporter
|
Local machines are excluded because the Docker host is scraped directly.
|
||||||
service already covers the Docker host.
|
|
||||||
"""
|
"""
|
||||||
targets: list[dict[str, Any]] = []
|
targets: list[dict[str, Any]] = []
|
||||||
for machine in store.list_machines():
|
for machine in store.list_machines():
|
||||||
@@ -60,18 +56,3 @@ def build_node_exporter_targets(store: SettingsStore) -> list[dict[str, Any]]:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
return targets
|
return targets
|
||||||
|
|
||||||
|
|
||||||
def write_prometheus_targets(store: SettingsStore, file_sd_dir: Path | None = None) -> Path:
|
|
||||||
"""Render and persist Prometheus file-SD targets.
|
|
||||||
|
|
||||||
Returns the path written so callers can log or expose it.
|
|
||||||
"""
|
|
||||||
settings = get_settings()
|
|
||||||
file_sd_dir = file_sd_dir or Path(settings.prometheus_file_sd_dir)
|
|
||||||
file_sd_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
file_path = file_sd_dir / "node_exporter_targets.json"
|
|
||||||
targets = build_node_exporter_targets(store)
|
|
||||||
file_path.write_text(json.dumps(targets, indent=2), encoding="utf-8")
|
|
||||||
logger.info("Wrote %s node_exporter targets to %s", len(targets), file_path)
|
|
||||||
return file_path
|
|
||||||
|
|||||||
+24
-23
@@ -702,27 +702,26 @@ class TestResolveServiceRecord:
|
|||||||
|
|
||||||
|
|
||||||
class TestSettingsMachines:
|
class TestSettingsMachines:
|
||||||
def test_post_machine_rewrites_prometheus_targets(self, test_client):
|
def test_machine_appears_in_prometheus_targets(self, test_client):
|
||||||
with patch("media_library_viewer_api.routers.settings.write_prometheus_targets") as write_targets:
|
store = app.dependency_overrides[get_settings_store]()
|
||||||
with patch("media_library_viewer_api.routers.settings._validate_saved_machine_ssh"):
|
store.upsert_machine(
|
||||||
response = test_client.post(
|
{
|
||||||
"/api/settings/machines",
|
"name": "remote1",
|
||||||
json={
|
"mode": "ssh",
|
||||||
"name": "remote1",
|
"enabled": True,
|
||||||
"mode": "ssh",
|
"services": ["monitoring"],
|
||||||
"enabled": True,
|
"host": "10.0.0.5",
|
||||||
"services": ["monitoring"],
|
"username": "u",
|
||||||
"host": "10.0.0.5",
|
"node_exporter_enabled": True,
|
||||||
"username": "u",
|
"node_exporter_port": 9200,
|
||||||
"node_exporter_enabled": True,
|
"node_exporter_scrape_host": "1.2.3.4",
|
||||||
"node_exporter_port": 9200,
|
}
|
||||||
"node_exporter_scrape_host": "1.2.3.4",
|
)
|
||||||
},
|
targets = test_client.get("/api/monitoring/prometheus-targets").json()
|
||||||
)
|
assert len(targets) == 1
|
||||||
assert response.status_code == 201
|
assert targets[0]["targets"] == ["1.2.3.4:9200"]
|
||||||
write_targets.assert_called_once()
|
|
||||||
|
|
||||||
def test_delete_machine_rewrites_prometheus_targets(self, test_client):
|
def test_delete_machine_removed_from_prometheus_targets(self, test_client):
|
||||||
store = app.dependency_overrides[get_settings_store]()
|
store = app.dependency_overrides[get_settings_store]()
|
||||||
machine = store.upsert_machine(
|
machine = store.upsert_machine(
|
||||||
{
|
{
|
||||||
@@ -732,12 +731,14 @@ class TestSettingsMachines:
|
|||||||
"services": ["monitoring"],
|
"services": ["monitoring"],
|
||||||
"host": "10.0.0.5",
|
"host": "10.0.0.5",
|
||||||
"username": "u",
|
"username": "u",
|
||||||
|
"node_exporter_enabled": True,
|
||||||
|
"node_exporter_port": 9200,
|
||||||
|
"node_exporter_scrape_host": "1.2.3.4",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
with patch("media_library_viewer_api.routers.settings.write_prometheus_targets") as write_targets:
|
response = test_client.delete(f"/api/settings/machines/{machine['id']}")
|
||||||
response = test_client.delete(f"/api/settings/machines/{machine['id']}")
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
write_targets.assert_called_once()
|
assert test_client.get("/api/monitoring/prometheus-targets").json() == []
|
||||||
|
|
||||||
|
|
||||||
def _am_service(name="Alertmanager", **config):
|
def _am_service(name="Alertmanager", **config):
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
"""Tests for Prometheus file-based service discovery target generation."""
|
"""Tests for Prometheus Node Exporter target discovery."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
from media_library_viewer_api.services.targets import (
|
from media_library_viewer_api.services.targets import build_node_exporter_targets
|
||||||
build_node_exporter_targets,
|
|
||||||
write_prometheus_targets,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -88,22 +85,3 @@ class TestBuildNodeExporterTargets:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
assert build_node_exporter_targets(store) == []
|
assert build_node_exporter_targets(store) == []
|
||||||
|
|
||||||
|
|
||||||
class TestWritePrometheusTargets:
|
|
||||||
def test_writes_valid_json(self, store: SettingsStore, tmp_path: Path):
|
|
||||||
store.upsert_machine(
|
|
||||||
{
|
|
||||||
"name": "remote1",
|
|
||||||
"mode": "ssh",
|
|
||||||
"host": "10.0.0.5",
|
|
||||||
"username": "u",
|
|
||||||
"node_exporter_enabled": True,
|
|
||||||
"node_exporter_port": 9200,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
file_path = write_prometheus_targets(store, tmp_path)
|
|
||||||
assert file_path.exists()
|
|
||||||
assert file_path.name == "node_exporter_targets.json"
|
|
||||||
targets = build_node_exporter_targets(store)
|
|
||||||
assert len(targets) == 1
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ services:
|
|||||||
LOG_FORMAT: ${LOG_FORMAT:-text}
|
LOG_FORMAT: ${LOG_FORMAT:-text}
|
||||||
PROMETHEUS_ENABLED: "true"
|
PROMETHEUS_ENABLED: "true"
|
||||||
SSH_KNOWN_HOSTS_PATH: /app/backend/.cache/known_hosts
|
SSH_KNOWN_HOSTS_PATH: /app/backend/.cache/known_hosts
|
||||||
PROMETHEUS_FILE_SD_DIR: /app/backend/.cache/prometheus-file-sd
|
|
||||||
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-}
|
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-}
|
||||||
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
|
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
|
||||||
MANAGE_ENCRYPTION_KEY: ${MANAGE_ENCRYPTION_KEY:?set MANAGE_ENCRYPTION_KEY in your .env}
|
MANAGE_ENCRYPTION_KEY: ${MANAGE_ENCRYPTION_KEY:?set MANAGE_ENCRYPTION_KEY in your .env}
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ services:
|
|||||||
SMTP_USE_SSL: ${SMTP_USE_SSL:-false}
|
SMTP_USE_SSL: ${SMTP_USE_SSL:-false}
|
||||||
SMTP_TIMEOUT: ${SMTP_TIMEOUT:-30}
|
SMTP_TIMEOUT: ${SMTP_TIMEOUT:-30}
|
||||||
SSH_KNOWN_HOSTS_PATH: /app/backend/.cache/known_hosts
|
SSH_KNOWN_HOSTS_PATH: /app/backend/.cache/known_hosts
|
||||||
PROMETHEUS_FILE_SD_DIR: ${PROMETHEUS_FILE_SD_DIR:-/app/backend/.cache/prometheus-file-sd}
|
|
||||||
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-}
|
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-}
|
||||||
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
|
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
|
||||||
MANAGE_ENCRYPTION_KEY: ${MANAGE_ENCRYPTION_KEY:?generate one with python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"}
|
MANAGE_ENCRYPTION_KEY: ${MANAGE_ENCRYPTION_KEY:?generate one with python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"}
|
||||||
|
|||||||
@@ -621,33 +621,33 @@ export function ObservabilityPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{selectedMachine ? (
|
{selectedMachine ? (
|
||||||
GRAFANA_BASE_URL ? (
|
GRAFANA_BASE_URL ? (
|
||||||
<>
|
<>
|
||||||
<GrafanaLinkCard
|
<GrafanaLinkCard
|
||||||
title={`${selectedMachine.name} metrics`}
|
title={`${selectedMachine.name} metrics`}
|
||||||
description="Open the Node Exporter overview dashboard for this machine in Grafana."
|
description="Open the Node Exporter overview dashboard for this machine in Grafana."
|
||||||
href={nodeExporterDashboardUrl}
|
href={nodeExporterDashboardUrl}
|
||||||
|
/>
|
||||||
|
<GrafanaLinkCard
|
||||||
|
title={`${selectedMachine.name} logs`}
|
||||||
|
description="Explore Loki logs for this machine in Grafana."
|
||||||
|
href={logsUrl}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={Gauge}
|
||||||
|
title="No Grafana service configured"
|
||||||
|
description="Add a Grafana service instance to enable deep-links to dashboards and logs."
|
||||||
|
action={
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link to="/services">Open Services</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<GrafanaLinkCard
|
)
|
||||||
title={`${selectedMachine.name} logs`}
|
|
||||||
description="Explore Loki logs for this machine in Grafana."
|
|
||||||
href={logsUrl}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
|
||||||
icon={Gauge}
|
|
||||||
title="No Grafana service configured"
|
|
||||||
description="Add a Grafana service instance to enable deep-links to dashboards and logs."
|
|
||||||
action={
|
|
||||||
<Button variant="outline" size="sm" asChild>
|
|
||||||
<Link to="/services">Open Services</Link>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ServerOff}
|
icon={ServerOff}
|
||||||
title="No machine selected"
|
title="No machine selected"
|
||||||
|
|||||||
Reference in New Issue
Block a user