refactor: unify SSH machines as services
This commit is contained in:
@@ -1,9 +1,4 @@
|
||||
"""Persistent application settings stored in a small SQLite database.
|
||||
|
||||
The store manages machine definitions, machine services, and per-machine
|
||||
application configuration so the frontend can present local and remote targets
|
||||
in the same UI.
|
||||
"""
|
||||
"""Persistent application settings stored in a small SQLite database."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -23,31 +18,6 @@ from media_library_viewer_api.models.widgets import _validate_config_keys
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
|
||||
LOCAL_MACHINE_ID = "local"
|
||||
DEFAULT_SERVICES = ["monitoring", "files"]
|
||||
|
||||
|
||||
def _default_local_machine() -> dict[str, Any]:
|
||||
return {
|
||||
"id": LOCAL_MACHINE_ID,
|
||||
"name": "This machine",
|
||||
"mode": "local",
|
||||
"enabled": True,
|
||||
"services": list(DEFAULT_SERVICES),
|
||||
"host": "",
|
||||
"port": 22,
|
||||
"username": "",
|
||||
"key_directory": "",
|
||||
"key_name": "",
|
||||
"ssh_key_id": "",
|
||||
"ssh_private_key": "",
|
||||
"ssh_private_key_passphrase": "",
|
||||
"password": "",
|
||||
"node_exporter_enabled": False,
|
||||
"node_exporter_port": 9100,
|
||||
"node_exporter_scrape_host": "",
|
||||
"notes": "",
|
||||
}
|
||||
|
||||
|
||||
class SettingsStore:
|
||||
@@ -66,23 +36,6 @@ class SettingsStore:
|
||||
|
||||
def init_schema(self) -> None:
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS monitoring_machines (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL,
|
||||
config_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_monitoring_machines_mode ON monitoring_machines(mode)")
|
||||
# The legacy SSH-scraping monitor (MonitoringPoller) was decommissioned;
|
||||
# metrics now live in Prometheus/node_exporter. Drop the orphan
|
||||
# table on startup so existing databases get a clean slate.
|
||||
conn.execute("DROP TABLE IF EXISTS monitoring_machine_actions")
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -112,7 +65,7 @@ class SettingsStore:
|
||||
task_type TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL,
|
||||
default_service_id TEXT NOT NULL,
|
||||
service_id TEXT NOT NULL,
|
||||
notes TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
@@ -120,11 +73,15 @@ class SettingsStore:
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_saved_tasks_name ON saved_tasks(name)")
|
||||
# saved_tasks.default_machine_id → default_service_id (saved tasks now
|
||||
# target ssh_tasks service instances). Migrate existing columns.
|
||||
# Migrate legacy task ownership column names in place.
|
||||
saved_tasks_cols = {row[1] for row in conn.execute("PRAGMA table_info(saved_tasks)").fetchall()}
|
||||
if "default_service_id" not in saved_tasks_cols and "default_machine_id" in saved_tasks_cols:
|
||||
conn.execute("ALTER TABLE saved_tasks RENAME COLUMN default_machine_id TO default_service_id")
|
||||
if "service_id" not in saved_tasks_cols:
|
||||
legacy_column = next(
|
||||
(column for column in ("default_service_id", "default_machine_id") if column in saved_tasks_cols),
|
||||
None,
|
||||
)
|
||||
if legacy_column:
|
||||
conn.execute(f"ALTER TABLE saved_tasks RENAME COLUMN {legacy_column} TO service_id")
|
||||
# Run history for saved tasks now lives in service_task_runs; the
|
||||
# legacy machine-based table is dropped.
|
||||
conn.execute("DROP TABLE IF EXISTS saved_task_runs")
|
||||
@@ -276,191 +233,139 @@ class SettingsStore:
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._migrate_remote_machine_services(conn)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
items = [part.strip() for part in value.split(",")]
|
||||
elif isinstance(value, list):
|
||||
items = [str(part).strip() for part in value]
|
||||
else:
|
||||
items = list(fallback or DEFAULT_SERVICES)
|
||||
services = [item for item in items if item]
|
||||
if not services:
|
||||
services = list(fallback or DEFAULT_SERVICES)
|
||||
deduped: list[str] = []
|
||||
for service in services:
|
||||
if service not in deduped:
|
||||
deduped.append(service)
|
||||
return deduped
|
||||
def _migrate_remote_machine_services(self, conn: sqlite3.Connection) -> None:
|
||||
"""Migrate legacy SSH endpoints into encrypted ``remote_machine`` services.
|
||||
|
||||
def _row_to_machine(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||
data = json.loads(row["config_json"])
|
||||
default_services = DEFAULT_SERVICES if row["id"] == LOCAL_MACHINE_ID else []
|
||||
services = self._normalize_services(data.get("services"), default_services)
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"mode": row["mode"],
|
||||
"enabled": bool(row["enabled"]),
|
||||
"services": services,
|
||||
"host": data.get("host", ""),
|
||||
"port": int(data.get("port", 22) or 22),
|
||||
"username": data.get("username", ""),
|
||||
"key_directory": data.get("key_directory", ""),
|
||||
"key_name": data.get("key_name", ""),
|
||||
"ssh_key_id": data.get("ssh_key_id", ""),
|
||||
"ssh_private_key_set": bool(data.get("ssh_private_key")),
|
||||
"ssh_private_key_passphrase_set": bool(data.get("ssh_private_key_passphrase")),
|
||||
"password_set": bool(data.get("password")),
|
||||
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
|
||||
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
|
||||
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
|
||||
"notes": data.get("notes", ""),
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
Local placeholders are deliberately skipped. Invalid legacy rows abort
|
||||
the transaction, retaining the source table instead of silently losing
|
||||
credential material.
|
||||
"""
|
||||
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
conn.execute("UPDATE services SET service_type = 'remote_machine' WHERE service_type = 'ssh_tasks'")
|
||||
if "monitoring_machines" not in tables:
|
||||
return
|
||||
|
||||
def _normalize_machine_payload(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
machine_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
current = self.get_machine(machine_id) if machine_id else None
|
||||
machine_id = str(payload.get("id") or machine_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12]
|
||||
mode = str(payload.get("mode") or (current or {}).get("mode") or "local").strip().lower()
|
||||
if mode not in {"local", "ssh"}:
|
||||
mode = "local"
|
||||
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
|
||||
name = str(payload.get("name") or (current or {}).get("name") or "").strip() or (
|
||||
"This machine" if mode == "local" else machine_id
|
||||
)
|
||||
services = self._normalize_services(payload.get("services"), (current or {}).get("services", []))
|
||||
from media_library_viewer_api.services.secrets import encrypt_value
|
||||
|
||||
def _current_str(field: str, default: str = "") -> str:
|
||||
return str(
|
||||
payload.get(field) if payload.get(field) is not None else (current or {}).get(field, default) or default
|
||||
).strip()
|
||||
|
||||
host = _current_str("host")
|
||||
port = int(payload.get("port") or (current or {}).get("port", 22) or 22)
|
||||
username = _current_str("username")
|
||||
key_directory = _current_str("key_directory")
|
||||
key_name = _current_str("key_name")
|
||||
ssh_key_id = _current_str("ssh_key_id")
|
||||
ssh_private_key = payload.get("ssh_private_key")
|
||||
if ssh_private_key in (None, ""):
|
||||
ssh_private_key = (current or {}).get("ssh_private_key", "")
|
||||
ssh_private_key = str(ssh_private_key or "")
|
||||
ssh_private_key_passphrase = payload.get("ssh_private_key_passphrase")
|
||||
if ssh_private_key_passphrase in (None, ""):
|
||||
ssh_private_key_passphrase = (current or {}).get("ssh_private_key_passphrase", "")
|
||||
ssh_private_key_passphrase = str(ssh_private_key_passphrase or "")
|
||||
password = payload.get("password")
|
||||
if password in (None, ""):
|
||||
password = (current or {}).get("password", "")
|
||||
password = str(password or "")
|
||||
node_exporter_enabled = bool(
|
||||
payload.get("node_exporter_enabled")
|
||||
if payload.get("node_exporter_enabled") is not None
|
||||
else (current or {}).get("node_exporter_enabled", False)
|
||||
)
|
||||
node_exporter_port_raw = payload.get("node_exporter_port")
|
||||
if node_exporter_port_raw is None:
|
||||
node_exporter_port_raw = (current or {}).get("node_exporter_port", 9100)
|
||||
node_exporter_port = int(node_exporter_port_raw or 9100)
|
||||
node_exporter_scrape_host = _current_str("node_exporter_scrape_host")
|
||||
notes = _current_str("notes")
|
||||
if mode == "local":
|
||||
host = host or "localhost"
|
||||
username = username or ""
|
||||
return {
|
||||
"id": machine_id,
|
||||
"name": name,
|
||||
"mode": mode,
|
||||
"enabled": enabled,
|
||||
"services": services,
|
||||
"host": host,
|
||||
"port": port,
|
||||
"username": username,
|
||||
"key_directory": key_directory,
|
||||
"key_name": key_name,
|
||||
"ssh_key_id": ssh_key_id,
|
||||
"ssh_private_key": ssh_private_key,
|
||||
"ssh_private_key_passphrase": ssh_private_key_passphrase,
|
||||
"password": password,
|
||||
"node_exporter_enabled": node_exporter_enabled,
|
||||
"node_exporter_port": node_exporter_port,
|
||||
"node_exporter_scrape_host": node_exporter_scrape_host,
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
def _seed_local_machine(self) -> None:
|
||||
"""Seed the default local machine if none exists."""
|
||||
machine = _default_local_machine()
|
||||
now = int(time.time())
|
||||
config = {
|
||||
"services": machine["services"],
|
||||
"host": machine["host"],
|
||||
"port": machine["port"],
|
||||
"username": machine["username"],
|
||||
"key_directory": machine["key_directory"],
|
||||
"key_name": machine["key_name"],
|
||||
"ssh_key_id": machine.get("ssh_key_id", ""),
|
||||
"ssh_private_key": "",
|
||||
"ssh_private_key_passphrase": "",
|
||||
"password": "",
|
||||
"node_exporter_enabled": machine["node_exporter_enabled"],
|
||||
"node_exporter_port": machine["node_exporter_port"],
|
||||
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
|
||||
"notes": machine["notes"],
|
||||
}
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM monitoring_machines ORDER BY created_at, id").fetchall()
|
||||
for row in rows:
|
||||
try:
|
||||
data = json.loads(row["config_json"] or "{}")
|
||||
except (TypeError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeError(f"Legacy machine {row['id']!r} has invalid config JSON") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError(f"Legacy machine {row['id']!r} config must be an object")
|
||||
if str(row["mode"] or "").lower() != "ssh":
|
||||
continue
|
||||
old_id = str(row["id"])
|
||||
target_id = self._remote_machine_target_id(conn, old_id)
|
||||
ssh_key_id = self._migrate_inline_ssh_key(conn, row, data, old_id)
|
||||
config = self._legacy_remote_machine_config(data, ssh_key_id, old_id)
|
||||
secrets = self._legacy_remote_machine_secrets(data, encrypt_value)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO monitoring_machines (id, name, mode, enabled, config_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO services (
|
||||
id, service_type, name, config_json, secrets_json, enabled, created_at, updated_at
|
||||
) VALUES (?, 'remote_machine', ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING
|
||||
""",
|
||||
(
|
||||
machine["id"],
|
||||
machine["name"],
|
||||
machine["mode"],
|
||||
1,
|
||||
target_id,
|
||||
row["name"],
|
||||
json.dumps(config),
|
||||
now,
|
||||
now,
|
||||
json.dumps(secrets),
|
||||
row["enabled"],
|
||||
row["created_at"],
|
||||
row["updated_at"],
|
||||
),
|
||||
)
|
||||
if target_id != old_id:
|
||||
conn.execute("UPDATE saved_tasks SET service_id = ? WHERE service_id = ?", (target_id, old_id))
|
||||
conn.execute("UPDATE service_task_runs SET service_id = ? WHERE service_id = ?", (target_id, old_id))
|
||||
conn.execute("UPDATE dashboard_widgets SET service_id = ? WHERE service_id = ?", (target_id, old_id))
|
||||
conn.execute("DROP TABLE monitoring_machines")
|
||||
|
||||
def _seed_dashboard_widgets(self) -> None:
|
||||
"""Default widget seeding was removed.
|
||||
@staticmethod
|
||||
def _remote_machine_target_id(conn: sqlite3.Connection, old_id: str) -> str:
|
||||
existing = conn.execute("SELECT service_type FROM services WHERE id = ?", (old_id,)).fetchone()
|
||||
if not existing or existing[0] == "remote_machine":
|
||||
return old_id
|
||||
base = f"remote-machine-{old_id}"
|
||||
target_id, suffix = base, 2
|
||||
while conn.execute("SELECT 1 FROM services WHERE id = ?", (target_id,)).fetchone():
|
||||
target_id = f"{base}-{suffix}"
|
||||
suffix += 1
|
||||
return target_id
|
||||
|
||||
Widgets are now service-bound (or built-in). A fresh install starts with
|
||||
no widgets; the user configures services and adds widgets from the UI.
|
||||
Kept as a no-op so :meth:`ensure_defaults` callers are unchanged.
|
||||
"""
|
||||
return None
|
||||
def _migrate_inline_ssh_key(
|
||||
self, conn: sqlite3.Connection, row: sqlite3.Row, data: dict[str, Any], old_id: str
|
||||
) -> str:
|
||||
ssh_key_id = str(data.get("ssh_key_id") or "").strip()
|
||||
inline_key = str(data.get("ssh_private_key") or "")
|
||||
if not inline_key or ssh_key_id:
|
||||
return ssh_key_id
|
||||
base = f"legacy-key-{old_id}"
|
||||
ssh_key_id, suffix = base, 2
|
||||
while conn.execute("SELECT 1 FROM ssh_keys WHERE id = ?", (ssh_key_id,)).fetchone():
|
||||
ssh_key_id = f"{base}-{suffix}"
|
||||
suffix += 1
|
||||
summary = self._private_key_summary(inline_key)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO ssh_keys (
|
||||
id, name, private_key, passphrase, public_key, fingerprint, notes, created_at, updated_at
|
||||
) VALUES (?, ?, ?, '', ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
ssh_key_id,
|
||||
f"Migrated key for {row['name']}",
|
||||
inline_key,
|
||||
summary["public_key"],
|
||||
summary["fingerprint"],
|
||||
"Migrated from legacy remote machine",
|
||||
row["created_at"],
|
||||
row["updated_at"],
|
||||
),
|
||||
)
|
||||
return ssh_key_id
|
||||
|
||||
@staticmethod
|
||||
def _legacy_remote_machine_config(data: dict[str, Any], ssh_key_id: str, machine_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
port = int(data.get("port") or 22)
|
||||
timeout = int(data.get("timeout_seconds") or 30)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RuntimeError(f"Legacy machine {machine_id!r} has invalid SSH port or timeout") from exc
|
||||
if not 1 <= port <= 65535 or timeout <= 0:
|
||||
raise RuntimeError(f"Legacy machine {machine_id!r} has invalid SSH port or timeout")
|
||||
return {
|
||||
"host": str(data.get("host") or ""),
|
||||
"port": port,
|
||||
"username": str(data.get("username") or ""),
|
||||
"ssh_key_id": ssh_key_id,
|
||||
"timeout_seconds": timeout,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _legacy_remote_machine_secrets(data: dict[str, Any], encrypt_value: Any) -> dict[str, str]:
|
||||
secrets: dict[str, str] = {}
|
||||
for legacy, secret in (("ssh_private_key_passphrase", "passphrase"), ("password", "password")):
|
||||
value = str(data.get(legacy) or "")
|
||||
if value:
|
||||
secrets[secret] = encrypt_value(value)
|
||||
return secrets
|
||||
|
||||
def ensure_defaults(self) -> None:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
|
||||
if not row or int(row[0]) == 0:
|
||||
self._seed_local_machine()
|
||||
self._migrate_jellyseerr_into_jellyfin()
|
||||
self._migrate_jellyseerr_api_key_to_secret()
|
||||
|
||||
def _migrate_jellyseerr_api_key_to_secret(self) -> None:
|
||||
"""Move Jellyfin's plaintext ``jellyseerr_api_key`` from config into secrets.
|
||||
|
||||
The key was originally a plaintext config field; it is now a secret.
|
||||
Idempotent: once no Jellyfin config carries the key this is a no-op. Uses
|
||||
a direct UPDATE so existing (encrypted) secrets are preserved untouched
|
||||
rather than re-encrypted.
|
||||
"""
|
||||
"""Move Jellyfin's plaintext ``jellyseerr_api_key`` from config into secrets."""
|
||||
from media_library_viewer_api.services.secrets import encrypt_value
|
||||
|
||||
self.init_schema()
|
||||
moved = 0
|
||||
for row in self.list_services("jellyfin"):
|
||||
config = dict(row.get("config") or {})
|
||||
@@ -476,27 +381,15 @@ class SettingsStore:
|
||||
"UPDATE services SET config_json = ?, secrets_json = ?, updated_at = ? WHERE id = ?",
|
||||
(json.dumps(config), json.dumps(secrets_blob), int(time.time()), row["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
moved += 1
|
||||
logger.info(
|
||||
"migrated jellyseerr_api_key config->secret for jellyfin service %r",
|
||||
row["name"],
|
||||
)
|
||||
logger.info("migrated jellyseerr_api_key config->secret for jellyfin service %r", row["name"])
|
||||
if moved:
|
||||
logger.info("migrated jellyseerr_api_key to secret for %s jellyfin service(s)", moved)
|
||||
|
||||
def _migrate_jellyseerr_into_jellyfin(self) -> None:
|
||||
"""Absorb standalone ``jellyseerr`` services into their paired Jellyfin.
|
||||
|
||||
Idempotent: once no ``jellyseerr`` rows remain the method is a no-op.
|
||||
Pairing policy: exactly-one Jellyfin merges; multiple picks the first
|
||||
Jellyfin whose ``jellyseerr_url`` is still empty; no Jellyfin or all
|
||||
paired -> drop with a logged warning.
|
||||
"""
|
||||
"""Absorb standalone ``jellyseerr`` services into their paired Jellyfin."""
|
||||
from media_library_viewer_api.services.secrets import decrypt_value
|
||||
|
||||
self.init_schema()
|
||||
jellyseerr_rows: list[sqlite3.Row] = []
|
||||
with self.connect() as conn:
|
||||
jellyseerr_rows = conn.execute(
|
||||
"SELECT * FROM services WHERE service_type = 'jellyseerr' ORDER BY name ASC"
|
||||
@@ -510,28 +403,23 @@ class SettingsStore:
|
||||
js_secrets = json.loads(js_row["secrets_json"] or "{}")
|
||||
js_url = str(js_config.get("base_url", "")).strip()
|
||||
js_api_key = str(js_secrets.get("api_key", "")).strip()
|
||||
# Decrypt the api_key (secrets are stored encrypted; config is plaintext).
|
||||
if js_api_key:
|
||||
try:
|
||||
js_api_key = decrypt_value(js_api_key)
|
||||
except Exception:
|
||||
logger.warning("could not decrypt jellyseerr api_key for %r", js_row["name"])
|
||||
js_api_key = ""
|
||||
js_name = js_row["name"]
|
||||
|
||||
target = None
|
||||
if len(jellyfin_rows) == 1:
|
||||
target = jellyfin_rows[0]
|
||||
elif len(jellyfin_rows) > 1:
|
||||
for jf in jellyfin_rows:
|
||||
if not str(jf["config"].get("jellyseerr_url", "")).strip():
|
||||
target = jf
|
||||
break
|
||||
target = next(
|
||||
(row for row in jellyfin_rows if not str(row["config"].get("jellyseerr_url", "")).strip()),
|
||||
None,
|
||||
)
|
||||
|
||||
if target:
|
||||
# list_services returns the stored (encrypted) secrets blob, so
|
||||
# decrypt the existing Jellyfin api_key before handing it back to
|
||||
# upsert_service (which re-encrypts) — otherwise it double-encrypts.
|
||||
target_api_key = str(target["secrets"].get("api_key") or "")
|
||||
if target_api_key:
|
||||
try:
|
||||
@@ -549,142 +437,14 @@ class SettingsStore:
|
||||
"config": merged_config,
|
||||
"enabled": target["enabled"],
|
||||
},
|
||||
secret_values={
|
||||
"api_key": target_api_key,
|
||||
"jellyseerr_api_key": js_api_key,
|
||||
},
|
||||
secret_values={"api_key": target_api_key, "jellyseerr_api_key": js_api_key},
|
||||
)
|
||||
logger.info("migrated jellyseerr service %r into jellyfin service %r", js_name, target["name"])
|
||||
logger.info("migrated jellyseerr service %r into jellyfin service %r", js_row["name"], target["name"])
|
||||
else:
|
||||
logger.warning(
|
||||
"dropped unpaired jellyseerr service %r; reconfigure manually on the Jellyfin instance",
|
||||
js_name,
|
||||
)
|
||||
logger.warning("dropped unpaired jellyseerr service %r; reconfigure manually", js_row["name"])
|
||||
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM services WHERE id = ?", (js_row["id"],))
|
||||
conn.commit()
|
||||
|
||||
def list_machines(self) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM monitoring_machines ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END, name COLLATE NOCASE",
|
||||
(LOCAL_MACHINE_ID,),
|
||||
).fetchall()
|
||||
return [self._row_to_machine(row) for row in rows]
|
||||
|
||||
def get_machine(self, machine_id: str | None) -> dict[str, Any] | None:
|
||||
if not machine_id:
|
||||
return None
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM monitoring_machines WHERE id = ?", (machine_id,)).fetchone()
|
||||
return self._row_to_machine(row) if row else None
|
||||
|
||||
def get_machine_config(self, machine_id: str | None) -> dict[str, Any] | None:
|
||||
"""Return the full machine config including secrets."""
|
||||
if not machine_id:
|
||||
return None
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM monitoring_machines WHERE id = ?", (machine_id,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
data = json.loads(row["config_json"])
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"mode": row["mode"],
|
||||
"enabled": bool(row["enabled"]),
|
||||
"services": self._normalize_services(
|
||||
data.get("services"),
|
||||
DEFAULT_SERVICES if row["id"] == LOCAL_MACHINE_ID else [],
|
||||
),
|
||||
"host": data.get("host", ""),
|
||||
"port": int(data.get("port", 22) or 22),
|
||||
"username": data.get("username", ""),
|
||||
"key_directory": data.get("key_directory", ""),
|
||||
"key_name": data.get("key_name", ""),
|
||||
"ssh_key_id": data.get("ssh_key_id", ""),
|
||||
"ssh_private_key": data.get("ssh_private_key", ""),
|
||||
"ssh_private_key_passphrase": data.get("ssh_private_key_passphrase", ""),
|
||||
"password": data.get("password", ""),
|
||||
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
|
||||
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
|
||||
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
|
||||
"notes": data.get("notes", ""),
|
||||
}
|
||||
|
||||
def list_machines_for_service(self, service: str) -> list[dict[str, Any]]:
|
||||
return [
|
||||
machine
|
||||
for machine in self.list_machines()
|
||||
if service in machine.get("services", []) and machine.get("enabled")
|
||||
]
|
||||
|
||||
def get_machine_for_service(self, service: str, machine_id: str | None = None) -> dict[str, Any] | None:
|
||||
if machine_id:
|
||||
machine = self.get_machine(machine_id)
|
||||
if machine and service in machine.get("services", []) and machine.get("enabled"):
|
||||
return machine
|
||||
return machine if machine else None
|
||||
machines = self.list_machines_for_service(service)
|
||||
return machines[0] if machines else None
|
||||
|
||||
def upsert_machine(self, payload: dict[str, Any], machine_id: str | None = None) -> dict[str, Any]:
|
||||
self.init_schema()
|
||||
machine = self._normalize_machine_payload(payload, machine_id)
|
||||
now = int(time.time())
|
||||
config = {
|
||||
"services": machine["services"],
|
||||
"host": machine["host"],
|
||||
"port": machine["port"],
|
||||
"username": machine["username"],
|
||||
"key_directory": machine["key_directory"],
|
||||
"key_name": machine["key_name"],
|
||||
"ssh_key_id": machine.get("ssh_key_id", ""),
|
||||
"ssh_private_key": machine["ssh_private_key"],
|
||||
"ssh_private_key_passphrase": machine["ssh_private_key_passphrase"],
|
||||
"password": machine["password"],
|
||||
"node_exporter_enabled": machine["node_exporter_enabled"],
|
||||
"node_exporter_port": machine["node_exporter_port"],
|
||||
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
|
||||
"notes": machine["notes"],
|
||||
}
|
||||
with self.connect() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT created_at FROM monitoring_machines WHERE id = ?",
|
||||
(machine["id"],),
|
||||
).fetchone()
|
||||
created_at = int(existing[0]) if existing else now
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO monitoring_machines (id, name, mode, enabled, config_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
mode = excluded.mode,
|
||||
enabled = excluded.enabled,
|
||||
config_json = excluded.config_json,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
machine["id"],
|
||||
machine["name"],
|
||||
machine["mode"],
|
||||
1 if machine["enabled"] else 0,
|
||||
json.dumps(config),
|
||||
created_at,
|
||||
now,
|
||||
),
|
||||
)
|
||||
return self.get_machine(machine["id"]) or machine
|
||||
|
||||
def delete_machine(self, machine_id: str) -> None:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM monitoring_machines WHERE id = ?", (machine_id,))
|
||||
|
||||
@staticmethod
|
||||
def _private_key_summary(private_key: str) -> dict[str, str]:
|
||||
@@ -755,10 +515,9 @@ class SettingsStore:
|
||||
|
||||
def list_ssh_keys(self) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
machines = self.list_machines()
|
||||
usage_counts: dict[str, int] = {}
|
||||
for machine in machines:
|
||||
ssh_key_id = str(machine.get("ssh_key_id") or "").strip()
|
||||
for service in self.list_services("remote_machine"):
|
||||
ssh_key_id = str((service.get("config") or {}).get("ssh_key_id") or "").strip()
|
||||
if ssh_key_id:
|
||||
usage_counts[ssh_key_id] = usage_counts.get(ssh_key_id, 0) + 1
|
||||
with self.connect() as conn:
|
||||
@@ -833,7 +592,7 @@ class SettingsStore:
|
||||
"task_type": row["task_type"],
|
||||
"content": row["content"],
|
||||
"enabled": bool(row["enabled"]),
|
||||
"default_service_id": row["default_service_id"],
|
||||
"service_id": row["service_id"],
|
||||
"notes": row["notes"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
@@ -850,10 +609,10 @@ class SettingsStore:
|
||||
payload.get("content") if payload.get("content") is not None else (current or {}).get("content", "") or ""
|
||||
)
|
||||
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
|
||||
default_service_id = str(
|
||||
payload.get("default_service_id")
|
||||
if payload.get("default_service_id") is not None
|
||||
else (current or {}).get("default_service_id", "") or ""
|
||||
service_id = str(
|
||||
payload.get("service_id")
|
||||
if payload.get("service_id") is not None
|
||||
else (current or {}).get("service_id", "") or ""
|
||||
).strip()
|
||||
notes = str(
|
||||
payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or ""
|
||||
@@ -864,7 +623,7 @@ class SettingsStore:
|
||||
"task_type": task_type,
|
||||
"content": content,
|
||||
"enabled": enabled,
|
||||
"default_service_id": default_service_id,
|
||||
"service_id": service_id,
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
@@ -892,7 +651,7 @@ class SettingsStore:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO saved_tasks (
|
||||
id, name, task_type, content, enabled, default_service_id,
|
||||
id, name, task_type, content, enabled, service_id,
|
||||
notes, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -901,7 +660,7 @@ class SettingsStore:
|
||||
task_type = excluded.task_type,
|
||||
content = excluded.content,
|
||||
enabled = excluded.enabled,
|
||||
default_service_id = excluded.default_service_id,
|
||||
service_id = excluded.service_id,
|
||||
notes = excluded.notes,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
@@ -911,7 +670,7 @@ class SettingsStore:
|
||||
task["task_type"],
|
||||
task["content"],
|
||||
1 if task["enabled"] else 0,
|
||||
task["default_service_id"],
|
||||
task["service_id"],
|
||||
task["notes"],
|
||||
created_at,
|
||||
now,
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Prometheus Node Exporter target discovery.
|
||||
|
||||
The backend owns the list of remote Node Exporter targets so that operators can
|
||||
enable scraping per machine from the Manage UI. The list is exposed over HTTP at
|
||||
``GET /api/monitoring/prometheus-targets`` and consumed by an external Prometheus
|
||||
via ``http_sd_configs`` (no shared volume required).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NODE_EXPORTER_PORT = 9100
|
||||
|
||||
|
||||
def _scrape_address(machine: dict[str, Any]) -> str | None:
|
||||
"""Return host:port for the Node Exporter on a machine, or None if disabled."""
|
||||
if not machine.get("node_exporter_enabled"):
|
||||
return None
|
||||
scrape_host = str(machine.get("node_exporter_scrape_host") or "").strip()
|
||||
host = scrape_host or str(machine.get("host") or "").strip()
|
||||
if not host or host == "localhost":
|
||||
return None
|
||||
port = int(machine.get("node_exporter_port") or DEFAULT_NODE_EXPORTER_PORT)
|
||||
return f"{host}:{port}"
|
||||
|
||||
|
||||
def build_node_exporter_targets(store: SettingsStore) -> list[dict[str, Any]]:
|
||||
"""Build an http-SD target list for all enabled SSH machines.
|
||||
|
||||
Local machines are excluded because the Docker host is scraped directly.
|
||||
"""
|
||||
targets: list[dict[str, Any]] = []
|
||||
for machine in store.list_machines():
|
||||
if not machine.get("enabled"):
|
||||
continue
|
||||
if str(machine.get("mode") or "local").strip().lower() != "ssh":
|
||||
continue
|
||||
address = _scrape_address(machine)
|
||||
if not address:
|
||||
continue
|
||||
targets.append(
|
||||
{
|
||||
"targets": [address],
|
||||
"labels": {
|
||||
"job": "node-exporter-remote",
|
||||
"machine_id": str(machine.get("id") or ""),
|
||||
"machine_name": str(machine.get("name") or ""),
|
||||
"instance": address,
|
||||
},
|
||||
}
|
||||
)
|
||||
return targets
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Shared runner for saved tasks over SSH task services.
|
||||
"""Shared runner for saved tasks over Remote machine services.
|
||||
|
||||
Both the Actions page (``routers/tasks.py``) and the SSH task widget
|
||||
(``widgets/sources.py``) run saved tasks against ``ssh_tasks`` service instances.
|
||||
(``widgets/sources.py``) run saved tasks against ``remote_machine`` service instances.
|
||||
This module is the single execution path: build the client from the service
|
||||
record, render the command, run it with the service timeout, append a
|
||||
``service_task_runs`` row, and return the result.
|
||||
@@ -40,12 +40,12 @@ class TaskRunResult:
|
||||
|
||||
|
||||
def build_ssh_client(store: SettingsStore, service: "ServiceRecord") -> RemoteSSHClient:
|
||||
"""Build an SSH client from an ssh_tasks service instance + referenced key."""
|
||||
"""Build an SSH client from an remote_machine service instance + referenced key."""
|
||||
config = service.config
|
||||
host = str(config.get("host") or "").strip()
|
||||
username = str(config.get("username") or "").strip()
|
||||
if not host or not username:
|
||||
raise ValueError("SSH task service is missing host or username")
|
||||
raise ValueError("Remote machine service is missing host or username")
|
||||
|
||||
settings = get_settings()
|
||||
private_key = ""
|
||||
@@ -65,6 +65,7 @@ def build_ssh_client(store: SettingsStore, service: "ServiceRecord") -> RemoteSS
|
||||
port=int(config.get("port") or 22),
|
||||
private_key=private_key or None,
|
||||
private_key_passphrase=key_passphrase or None,
|
||||
password=str(service.secrets.get("password") or "") or None,
|
||||
known_hosts_path=str(settings.ssh_known_hosts_file),
|
||||
timeout=int(config.get("timeout_seconds") or 30),
|
||||
)
|
||||
@@ -88,7 +89,7 @@ def run_saved_task(
|
||||
*,
|
||||
timeout: int | None = None,
|
||||
) -> TaskRunResult:
|
||||
"""Run a saved task on an ssh_tasks service instance and log the run.
|
||||
"""Run a saved task on an remote_machine service instance and log the run.
|
||||
|
||||
The ``timeout`` defaults to the service's ``timeout_seconds`` config. The run
|
||||
is recorded in ``service_task_runs`` regardless of outcome (success, failure,
|
||||
|
||||
Reference in New Issue
Block a user