"""Environment auto-discovery for the onboarding wizard. All checks are best-effort — every function returns an empty list on failure so the wizard degrades gracefully in containers, VMs, and minimal environments. """ from __future__ import annotations import json import logging import os import shutil import subprocess from pathlib import Path from typing import Any logger = logging.getLogger(__name__) # Common log file candidates: (id, path, description) _KNOWN_PATHS: list[tuple[str, str, str]] = [ ("syslog", "/var/log/syslog", "System syslog (Debian/Ubuntu)"), ("syslog", "/var/log/messages", "System messages (RHEL/Rocky)"), ("auth", "/var/log/auth.log", "Auth log"), ("kern", "/var/log/kern.log", "Kernel log"), ("nginx-access", "/var/log/nginx/access.log", "Nginx access log"), ("nginx-error", "/var/log/nginx/error.log", "Nginx error log"), ("apache", "/var/log/apache2/access.log", "Apache access log"), ("apache-error", "/var/log/apache2/error.log", "Apache error log"), ("caddy", "/var/log/caddy/access.log", "Caddy access log"), ("docker-daemon","/var/log/docker.log", "Docker daemon log"), ("fail2ban", "/var/log/fail2ban.log", "Fail2ban log"), ("ufw", "/var/log/ufw.log", "UFW firewall log"), ] def _run(cmd: list[str], timeout: float = 5.0) -> str | None: """Run a command and return stdout, or None on any error.""" try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) return result.stdout if result.returncode == 0 else None except Exception: return None def discover_journald() -> list[dict[str, Any]]: """Return a journald source candidate if journalctl is available.""" if not shutil.which("journalctl"): return [] hostname = _run(["hostname"]) or "localhost" hostname = hostname.strip() return [{ "type": "journald", "id": f"journal:{hostname}", "label": f"System journal ({hostname})", "description": "All systemd journal output from this host", "available": True, }] def discover_docker() -> list[dict[str, Any]]: """Return Docker container candidates if Docker is running.""" for runtime in ("docker", "podman"): if not shutil.which(runtime): continue out = _run([runtime, "ps", "--format", "{{json .}}"]) if out is None: continue containers = [] for line in out.splitlines(): line = line.strip() if not line: continue try: obj = json.loads(line) name = obj.get("Names") or obj.get("Name") or obj.get("ID", "unknown") # podman returns a list for Names if isinstance(name, list): name = name[0] if name else "unknown" name = name.lstrip("/") containers.append({ "type": "docker", "id": f"{runtime}:{name}", "label": f"{runtime.capitalize()} — {name}", "description": f"Container log stream for {name}", "container": name, "runtime": runtime, "available": True, }) except (json.JSONDecodeError, KeyError): continue if containers: return containers return [] def discover_files() -> list[dict[str, Any]]: """Return file-based source candidates for well-known log paths.""" found = [] seen_ids: set[str] = set() for source_id, path, description in _KNOWN_PATHS: if not os.path.exists(path): continue # deduplicate when both syslog and messages exist — take first match if source_id in seen_ids: continue seen_ids.add(source_id) found.append({ "type": "file", "id": source_id, "label": description, "path": path, "description": f"Read from {path}", "available": True, }) return found def discover_all() -> dict[str, Any]: """Run all discovery checks and return a structured candidate list.""" candidates: list[dict[str, Any]] = [] candidates.extend(discover_journald()) candidates.extend(discover_docker()) candidates.extend(discover_files()) return { "candidates": candidates, "has_journald": any(c["type"] == "journald" for c in candidates), "has_docker": any(c["type"] == "docker" for c in candidates), "has_files": any(c["type"] == "file" for c in candidates), } def build_sources_yaml(selected: list[dict[str, Any]]) -> str: """Generate sources.yaml content from a list of selected candidates. Each item must have: type, id, and type-specific fields (path, container, etc.). """ lines = [ "# Turnstone log sources — generated by the setup wizard.", "# Edit this file to add, remove, or modify sources.", "sources:", ] for src in selected: src_type = src.get("type", "file") src_id = src.get("id", "unknown") if src_type == "journald": unit = src.get("unit") lines.append(f" - id: {src_id}") lines.append(f" type: journald") if unit: lines.append(f" unit: {unit}") elif src_type == "docker": runtime = src.get("runtime", "docker") container = src.get("container", src_id.split(":")[-1]) lines.append(f" - id: {src_id}") lines.append(f" type: docker") lines.append(f" runtime: {runtime}") lines.append(f" container: {container}") else: path = src.get("path", "") lines.append(f" - id: {src_id}") lines.append(f" path: {path}") return "\n".join(lines) + "\n" def validate_source(src: dict[str, Any]) -> str | None: """Return an error string if the source definition is invalid, else None.""" if not src.get("id"): return "Source is missing 'id'" src_type = src.get("type", "file") if src_type == "file" and not src.get("path"): return f"File source '{src['id']}' is missing 'path'" if src_type == "docker" and not src.get("container"): return f"Docker source '{src['id']}' is missing 'container'" return None