Initial public release — Inspector v0.7.0

Docker-watchdog with per-container restart rules, Gotify/Pushover
notifications, and an embedded dashboard + web settings page.

See README.md for an overview and INSTALL.md for the Docker quickstart.
This commit is contained in:
Europium 63 committed 2026-10-26 15:44:55 -07:00
commit ea1103f52b
35 files changed
+5579

No files matched your search

+1
View File
@@ -0,0 +1 @@
__version__ = "0.7.0"
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="430" viewBox="0 0 512 430"><metadata><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"><rdf:Description><dc:creator>RealFaviconGenerator</dc:creator><dc:source>https://realfavicongenerator.net</dc:source></rdf:Description></rdf:RDF></metadata><image width="512" height="430" xlink:href="data:image/png;base64,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 truncated

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+21
View File
@@ -0,0 +1,21 @@
{
"name": "Inspector",
"short_name": "Inspector",
"icons": [
{
"src": "/static/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/static/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#0b1220",
"background_color": "#0b1220",
"display": "standalone"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+502
View File
@@ -0,0 +1,502 @@
from __future__ import annotations
import io
import os
import shutil
import threading
from dataclasses import dataclass, field
from pathlib import Path
import yaml
from ruamel.yaml import YAML
VALID_STATUSES = {
"HEALTHY",
"UNHEALTHY",
"STARTING",
"NO HEALTH CHECK",
"NOT RUNNING",
"CONTAINER NOT FOUND",
"ERROR",
"UNKNOWN",
"UNREACHABLE",
}
VALID_RECOVERY_ACTIONS = {
"restart_container",
"start_container",
"portainer_stack_redeploy",
"restart_then_redeploy",
}
@dataclass(frozen=True)
class InspectorConfig:
interval_seconds: int = 60
log_level: str = "INFO"
dry_run: bool = False
log_dir: str = "/app/logs"
log_filename: str = "inspector.log"
log_retention_days: int = 30
heartbeat: bool = False
@dataclass(frozen=True)
class GotifyConfig:
enabled: bool = False
url: str = ""
token: str = ""
priority: int = 5
@dataclass(frozen=True)
class PushoverConfig:
enabled: bool = False
token: str = "" # Pushover application API token
user_key: str = "" # Pushover user or group key
priority: int = 5 # Inspector-scale priority, mapped to Pushover's -2..2 at send time
device: str = "" # optional target device name; blank = all devices
sound: str = "" # optional sound name; blank = user default
@dataclass(frozen=True)
class DatabaseConfig:
enabled: bool = False
path: str = "/app/data/events.db"
retention_days: int = 30
@dataclass(frozen=True)
class HealthSummaryConfig:
enabled: bool = False
interval_hours: int = 24
priority: int = 3
@dataclass(frozen=True)
class MaintenanceConfig:
enabled: bool = False
start: str = "00:00"
end: str = "06:00"
dir: str = "/app/data/maintenance"
max_hours: int = 8
@dataclass(frozen=True)
class NotificationMuteConfig:
enabled: bool = False
dir: str = "/app/data/muted"
default_hours: int = 12
@dataclass(frozen=True)
class DashboardConfig:
enabled: bool = False
host: str = "0.0.0.0"
port: int = 8080
username: str = "admin"
password: str = ""
@dataclass(frozen=True)
class HttpCheckConfig:
enabled: bool = False
url: str = ""
timeout_seconds: float = 5.0
expected_status: int = 200
@dataclass(frozen=True)
class UptimeKumaMonitorConfig:
enabled: bool = False
monitor_name: str = ""
@dataclass(frozen=True)
class UptimeKumaConfig:
enabled: bool = False
url: str = ""
status_page_slug: str = ""
@dataclass(frozen=True)
class PortainerEnvironmentConfig:
base_url: str = ""
api_token_env: str = "" # name of env var holding the token (preferred; token never stored)
api_token: str = "" # token embedded directly in config (use api_token_env for security)
endpoint_id: int = 0
verify_ssl: bool = True
@dataclass(frozen=True)
class PortainerConfig:
enabled: bool = False
default_environment: str = ""
stack_redeploy_cooldown_seconds: int = 300
post_redeploy_wait_seconds: int = 90
environments: dict[str, PortainerEnvironmentConfig] = field(default_factory=dict)
@dataclass(frozen=True)
class IntegrationsConfig:
uptime_kuma: UptimeKumaConfig
portainer: PortainerConfig
@dataclass(frozen=True)
class ContainerConfig:
name: str
enabled: bool = True
max_error_count: int = 5
cooldown_seconds: int = 300
grace_period_seconds: int = 120
max_restarts_per_hour: int = 3
restart_on: frozenset[str] = field(default_factory=lambda: frozenset({"UNHEALTHY", "NOT RUNNING"}))
maintenance_window: bool = False
color: str = "" # optional dashboard accent color, e.g. "#60a5fa"
http_check: HttpCheckConfig = field(default_factory=HttpCheckConfig)
uptime_kuma: UptimeKumaMonitorConfig = field(default_factory=UptimeKumaMonitorConfig)
depends_on: str = ""
vpn_routed: bool = False
recovery_action: str = "" # "" -> default restart_container behavior
portainer_environment: str = ""
portainer_stack_name: str = ""
@dataclass(frozen=True)
class AppConfig:
inspector: InspectorConfig
gotify: GotifyConfig
pushover: PushoverConfig
database: DatabaseConfig
health_summary: HealthSummaryConfig
maintenance: MaintenanceConfig
notification_mute: NotificationMuteConfig
dashboard: DashboardConfig
integrations: IntegrationsConfig
containers: tuple[ContainerConfig, ...]
def load_config(path: str | Path) -> AppConfig:
raw = yaml.safe_load(Path(path).read_text()) or {}
return build_config_from_raw(raw)
def build_config_from_raw(raw: dict) -> AppConfig:
"""Validate and materialize an AppConfig from an already-parsed dict.
Used both by load_config() on startup and by the settings-page save path
to validate an edited raw dict before writing it back to disk.
"""
insp_raw = raw.get("inspector", {}) or {}
inspector = InspectorConfig(
interval_seconds=int(insp_raw.get("interval_seconds", 60)),
log_level=str(insp_raw.get("log_level", "INFO")).upper(),
dry_run=bool(insp_raw.get("dry_run", False)),
log_dir=str(insp_raw.get("log_dir", "/app/logs")),
log_filename=str(insp_raw.get("log_filename", "inspector.log")),
log_retention_days=int(insp_raw.get("log_retention_days", 30)),
heartbeat=bool(insp_raw.get("heartbeat", False)),
)
gotify_raw = ((raw.get("notifications") or {}).get("gotify")) or {}
gotify = GotifyConfig(
enabled=bool(gotify_raw.get("enabled", False)),
url=str(gotify_raw.get("url", "") or "").rstrip("/"),
token=str(gotify_raw.get("token", "") or ""),
priority=int(gotify_raw.get("priority", 5)),
)
pushover_raw = ((raw.get("notifications") or {}).get("pushover")) or {}
pushover = PushoverConfig(
enabled=bool(pushover_raw.get("enabled", False)),
token=str(pushover_raw.get("token", "") or ""),
user_key=str(pushover_raw.get("user_key", "") or ""),
priority=int(pushover_raw.get("priority", 5)),
device=str(pushover_raw.get("device", "") or ""),
sound=str(pushover_raw.get("sound", "") or ""),
)
if pushover.enabled and (not pushover.token or not pushover.user_key):
raise ValueError(
"config: notifications.pushover.enabled is true but token and/or user_key is empty"
)
db_raw = raw.get("database") or {}
database = DatabaseConfig(
enabled=bool(db_raw.get("enabled", False)),
path=str(db_raw.get("path", "/app/data/events.db")),
retention_days=int(db_raw.get("retention_days", 30)),
)
hs_raw = raw.get("health_summary") or {}
health_summary = HealthSummaryConfig(
enabled=bool(hs_raw.get("enabled", False)),
interval_hours=int(hs_raw.get("interval_hours", 24)),
priority=int(hs_raw.get("priority", 3)),
)
maint_raw = raw.get("maintenance") or {}
maintenance = MaintenanceConfig(
enabled=bool(maint_raw.get("enabled", False)),
start=str(maint_raw.get("start", "00:00")),
end=str(maint_raw.get("end", "06:00")),
dir=str(maint_raw.get("dir", "/app/data/maintenance")),
max_hours=int(maint_raw.get("max_hours", 8)),
)
notif_mute_raw = raw.get("notification_mute") or {}
notification_mute = NotificationMuteConfig(
enabled=bool(notif_mute_raw.get("enabled", False)),
dir=str(notif_mute_raw.get("dir", "/app/data/muted")),
default_hours=int(notif_mute_raw.get("default_hours", 12)),
)
dash_raw = raw.get("dashboard") or {}
dashboard = DashboardConfig(
enabled=bool(dash_raw.get("enabled", False)),
host=str(dash_raw.get("host", "0.0.0.0")),
port=int(dash_raw.get("port", 8080)),
username=str(dash_raw.get("username", "admin")),
password=str(dash_raw.get("password", "")),
)
integrations_raw = raw.get("integrations") or {}
uk_raw = integrations_raw.get("uptime_kuma") or {}
uptime_kuma = UptimeKumaConfig(
enabled=bool(uk_raw.get("enabled", False)),
url=str(uk_raw.get("url", "") or "").rstrip("/"),
status_page_slug=str(uk_raw.get("status_page_slug", "") or ""),
)
portainer_raw = integrations_raw.get("portainer") or {}
portainer_envs_raw = portainer_raw.get("environments") or {}
if not isinstance(portainer_envs_raw, dict):
raise ValueError(
"config: 'integrations.portainer.environments' must be a mapping of name -> settings"
)
portainer_environments: dict[str, PortainerEnvironmentConfig] = {}
for env_name, env_settings in portainer_envs_raw.items():
env_settings = env_settings or {}
portainer_environments[str(env_name)] = PortainerEnvironmentConfig(
base_url=str(env_settings.get("base_url", "") or "").rstrip("/"),
api_token_env=str(env_settings.get("api_token_env", "") or ""),
api_token=str(env_settings.get("api_token", "") or ""),
endpoint_id=int(env_settings.get("endpoint_id", 0)),
verify_ssl=bool(env_settings.get("verify_ssl", True)),
)
portainer = PortainerConfig(
enabled=bool(portainer_raw.get("enabled", False)),
default_environment=str(portainer_raw.get("default_environment", "") or ""),
stack_redeploy_cooldown_seconds=int(portainer_raw.get("stack_redeploy_cooldown_seconds", 300)),
post_redeploy_wait_seconds=int(portainer_raw.get("post_redeploy_wait_seconds", 90)),
environments=portainer_environments,
)
integrations = IntegrationsConfig(uptime_kuma=uptime_kuma, portainer=portainer)
containers_raw = raw.get("containers") or {}
if not isinstance(containers_raw, dict):
raise ValueError("config: 'containers' must be a mapping of name -> settings")
containers: list[ContainerConfig] = []
for name, settings in containers_raw.items():
settings = settings or {}
restart_on_raw = settings.get("restart_on")
if restart_on_raw is None:
restart_on_raw = ["UNHEALTHY", "NOT RUNNING"]
restart_on = frozenset(str(s).upper() for s in restart_on_raw)
unknown = restart_on - VALID_STATUSES
if unknown:
raise ValueError(
f"config: container {name!r} has unknown restart_on values: {sorted(unknown)}"
)
http_check_raw = settings.get("http_check") or {}
http_check = HttpCheckConfig(
enabled=bool(http_check_raw.get("enabled", False)),
url=str(http_check_raw.get("url", "") or ""),
timeout_seconds=float(http_check_raw.get("timeout_seconds", 5.0)),
expected_status=int(http_check_raw.get("expected_status", 200)),
)
uptime_kuma_raw = settings.get("uptime_kuma") or {}
uptime_kuma = UptimeKumaMonitorConfig(
enabled=bool(uptime_kuma_raw.get("enabled", False)),
monitor_name=str(uptime_kuma_raw.get("monitor_name", "") or ""),
)
depends_on = str(settings.get("depends_on", "") or "").strip()
vpn_routed = bool(settings.get("vpn_routed", False))
recovery_action = str(settings.get("recovery_action", "") or "").strip()
portainer_environment = str(settings.get("portainer_environment", "") or "").strip()
portainer_stack_name = str(settings.get("portainer_stack_name", "") or "").strip()
if recovery_action and recovery_action not in VALID_RECOVERY_ACTIONS:
raise ValueError(
f"config: container {name!r} has unknown recovery_action "
f"{recovery_action!r}; expected one of {sorted(VALID_RECOVERY_ACTIONS)}"
)
if (
recovery_action in ("portainer_stack_redeploy", "restart_then_redeploy")
and not portainer_stack_name
):
raise ValueError(
f"config: container {name!r} has recovery_action "
f"{recovery_action!r} but no portainer_stack_name"
)
containers.append(
ContainerConfig(
name=str(name),
enabled=bool(settings.get("enabled", True)),
max_error_count=int(settings.get("max_error_count", 5)),
cooldown_seconds=int(settings.get("cooldown_seconds", 300)),
grace_period_seconds=int(settings.get("grace_period_seconds", 120)),
max_restarts_per_hour=int(settings.get("max_restarts_per_hour", 3)),
restart_on=restart_on,
maintenance_window=bool(settings.get("maintenance_window", False)),
color=str(settings.get("color", "") or "").strip(),
http_check=http_check,
uptime_kuma=uptime_kuma,
depends_on=depends_on,
vpn_routed=vpn_routed,
recovery_action=recovery_action,
portainer_environment=portainer_environment,
portainer_stack_name=portainer_stack_name,
)
)
container_names = {c.name for c in containers}
for c in containers:
if c.depends_on and c.depends_on not in container_names:
raise ValueError(
f"config: container {c.name!r} has depends_on={c.depends_on!r}, "
f"which is not a configured container"
)
return AppConfig(
inspector=inspector,
gotify=gotify,
pushover=pushover,
database=database,
health_summary=health_summary,
maintenance=maintenance,
notification_mute=notification_mute,
dashboard=dashboard,
integrations=integrations,
containers=tuple(containers),
)
# --- Roundtrip load/write (preserves comments and key order) ----------------
_yaml_rt = YAML(typ="rt")
_yaml_rt.preserve_quotes = True
_yaml_rt.indent(mapping=2, sequence=4, offset=2)
_yaml_rt.width = 4096 # don't line-wrap scalar values
def load_raw(path: str | Path):
"""Load config.yml as a ruamel CommentedMap preserving comments."""
with open(path, "r", encoding="utf-8") as fh:
data = _yaml_rt.load(fh)
return data if data is not None else {}
def dump_raw(raw) -> str:
"""Serialize a raw (ruamel) config dict back to YAML text."""
buf = io.StringIO()
_yaml_rt.dump(raw, buf)
return buf.getvalue()
def write_config_raw(path: str | Path, raw) -> None:
"""Write `raw` back to `path`, keeping a `.bak` of the prior file.
Prefers an atomic tempfile + os.replace() swap. Falls back to an
in-place write when the target is a single-file bind mount (as in
the Inspector Docker container, where /app/config.yml is a mount
point and can't be replaced from inside the container). The
fallback still writes the full text at once (not incrementally),
so a reader can see the old-or-new content, never partial lines
from this writer.
"""
path = Path(path)
text = dump_raw(raw)
tmp_path = path.with_suffix(path.suffix + ".tmp")
bak_path = path.with_suffix(path.suffix + ".bak")
if path.exists():
try:
shutil.copy2(path, bak_path)
except OSError:
pass # non-fatal; we still want to try the write
def _fsync(fh) -> None:
try:
os.fsync(fh.fileno())
except OSError:
pass
try:
with open(tmp_path, "w", encoding="utf-8") as fh:
fh.write(text)
fh.flush()
_fsync(fh)
os.replace(tmp_path, path)
except OSError:
# Cross-device link (bind-mounted single file) or similar.
if tmp_path.exists():
try:
tmp_path.unlink()
except OSError:
pass
with open(path, "w", encoding="utf-8") as fh:
fh.write(text)
fh.flush()
_fsync(fh)
class ConfigRef:
"""Thread-safe holder for the live AppConfig and its raw ruamel dict.
Writers call `reload_from_disk()` or `swap(new_raw, new_app)` under the
lock. Readers call `.get()` to grab the current AppConfig atomically.
"""
def __init__(self, path: str | Path, app: AppConfig, raw) -> None:
self._path = Path(path)
self._app = app
self._raw = raw
self._lock = threading.Lock()
@property
def path(self) -> Path:
return self._path
def get(self) -> AppConfig:
with self._lock:
return self._app
def get_raw(self):
"""Return a deep copy of the current raw dict for safe mutation."""
import copy
with self._lock:
return copy.deepcopy(self._raw)
def swap(self, new_raw, new_app: AppConfig) -> None:
with self._lock:
self._raw = new_raw
self._app = new_app
def reload_from_disk(self) -> AppConfig:
"""Re-read config.yml from disk and swap in the new AppConfig."""
with self._lock:
raw = load_raw(self._path)
app = build_config_from_raw(raw)
self._raw = raw
self._app = app
return app
+1601
View File
File diff suppressed because it is too large. Load diff
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
import logging
import docker
from docker.errors import APIError, NotFound
from .health import CONTAINER_NOT_FOUND, ERROR, HealthResult, classify
log = logging.getLogger("inspector.docker")
def build_client() -> docker.DockerClient:
return docker.from_env()
def get_container_status(client: docker.DockerClient, name: str) -> HealthResult:
try:
container = client.containers.get(name)
return classify(name, container.attrs)
except NotFound:
return HealthResult(
container_name=name,
status=CONTAINER_NOT_FOUND,
message=f"container {name!r} not found",
)
except APIError as exc:
log.error("Docker API error for %s: %s", name, exc)
return HealthResult(
container_name=name,
status=ERROR,
message=f"docker api error: {exc}",
)
except Exception as exc: # defensive: never let the loop die
log.exception("Unexpected error inspecting %s: %s", name, exc)
return HealthResult(
container_name=name,
status=ERROR,
message=f"unexpected: {exc}",
)
def restart_container(client: docker.DockerClient, name: str) -> tuple[bool, str | None]:
try:
container = client.containers.get(name)
container.restart()
return True, None
except NotFound:
return False, "container not found"
except APIError as exc:
return False, f"docker api error: {exc}"
except Exception as exc:
return False, f"unexpected: {exc}"
def start_container(client: docker.DockerClient, name: str) -> tuple[bool, str | None]:
try:
container = client.containers.get(name)
container.start()
return True, None
except NotFound:
return False, "container not found"
except APIError as exc:
return False, f"docker api error: {exc}"
except Exception as exc:
return False, f"unexpected: {exc}"
+219
View File
@@ -0,0 +1,219 @@
from __future__ import annotations
import logging
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from .config import ContainerConfig, DatabaseConfig
from .state import ContainerState, utcnow
log = logging.getLogger("inspector.eventstore")
EVENT_STATUS_CHANGE = "status_change"
EVENT_RECOVERY = "recovery"
EVENT_NOT_FOUND = "not_found"
EVENT_FAILURE = "failure"
EVENT_RESTART_LIMIT = "restart_limit"
EVENT_RESTART_DRYRUN = "restart_dry_run"
EVENT_RESTART = "restart"
EVENT_RESTART_FAILED = "restart_failed"
EVENT_PORTAINER_REDEPLOY = "portainer_redeploy"
EVENT_PORTAINER_REDEPLOY_FAILED = "portainer_redeploy_failed"
EVENT_PORTAINER_REDEPLOY_SKIPPED = "portainer_redeploy_skipped"
EVENT_MAINTENANCE_ENTER = "maintenance_enter"
EVENT_MAINTENANCE_EXIT = "maintenance_exit"
EVENT_NOTIFICATION_MUTE_ENTER = "notification_mute_enter"
EVENT_NOTIFICATION_MUTE_EXIT = "notification_mute_exit"
# Event types considered "notable" for the health-history summary digest.
# EVENT_STATUS_CHANGE, EVENT_MAINTENANCE_ENTER, EVENT_MAINTENANCE_EXIT,
# EVENT_NOTIFICATION_MUTE_ENTER, and EVENT_NOTIFICATION_MUTE_EXIT are
# excluded: they're bookkeeping/transition markers, not failure activity,
# and would dominate the digest with noise.
SUMMARY_EVENT_TYPES = (
EVENT_FAILURE,
EVENT_RESTART,
EVENT_RESTART_DRYRUN,
EVENT_RESTART_FAILED,
EVENT_RESTART_LIMIT,
EVENT_RECOVERY,
EVENT_NOT_FOUND,
EVENT_PORTAINER_REDEPLOY,
EVENT_PORTAINER_REDEPLOY_FAILED,
EVENT_PORTAINER_REDEPLOY_SKIPPED,
)
_SCHEMA = """
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
container TEXT NOT NULL,
event_type TEXT NOT NULL,
old_status TEXT,
new_status TEXT,
error_count INTEGER,
message TEXT
);
CREATE INDEX IF NOT EXISTS idx_events_container_ts ON events (container, ts);
CREATE INDEX IF NOT EXISTS idx_events_ts ON events (ts);
"""
class EventStore:
"""Durable per-container event history, backed by SQLite.
Always safe to construct and call: when disabled, or if the database
file can't be opened, every method is a no-op. A database error is
logged but never crashes Inspector, same philosophy as GotifyNotifier.
"""
def __init__(self, config: DatabaseConfig):
self._config = config
self._conn: sqlite3.Connection | None = None
self._last_prune: datetime | None = None
if not config.enabled:
return
try:
path = Path(config.path)
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(path))
conn.execute("PRAGMA journal_mode=WAL")
conn.executescript(_SCHEMA)
self._conn = conn
log.info("Event history database opened: %s", path)
self.prune()
except Exception as exc:
log.error("Event history database unavailable (%s); continuing without it", exc)
self._conn = None
@property
def enabled(self) -> bool:
return self._conn is not None
def record(
self,
container: str,
event_type: str,
old_status: str | None = None,
new_status: str | None = None,
error_count: int | None = None,
message: str | None = None,
) -> None:
if self._conn is None:
return
try:
self._conn.execute(
"INSERT INTO events "
"(ts, container, event_type, old_status, new_status, error_count, message) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
utcnow().isoformat(),
container,
event_type,
old_status,
new_status,
error_count,
message,
),
)
self._conn.commit()
except Exception as exc:
log.error("Failed to record event (%s/%s): %s", container, event_type, exc)
def prune(self) -> None:
if self._conn is None:
return
try:
cutoff = (utcnow() - timedelta(days=self._config.retention_days)).isoformat()
cur = self._conn.execute("DELETE FROM events WHERE ts < ?", (cutoff,))
self._conn.commit()
if cur.rowcount:
log.info(
"Event history: pruned %d row(s) older than %d days",
cur.rowcount,
self._config.retention_days,
)
self._last_prune = utcnow()
except Exception as exc:
log.error("Event history prune failed: %s", exc)
def maybe_prune(self) -> None:
if self._conn is None:
return
now = utcnow()
if self._last_prune is None or (now - self._last_prune) >= timedelta(hours=24):
self.prune()
def summarize(self, since: datetime) -> dict[str, dict[str, int]]:
"""Per-container counts of notable events at/after `since`.
Returns {container: {event_type: count}}. Containers with zero
matching events are absent from the result. Returns {} if disabled
or on any error.
"""
if self._conn is None:
return {}
try:
placeholders = ",".join("?" for _ in SUMMARY_EVENT_TYPES)
cur = self._conn.execute(
f"SELECT container, event_type, COUNT(*) FROM events "
f"WHERE ts >= ? AND event_type IN ({placeholders}) "
f"GROUP BY container, event_type",
(since.isoformat(), *SUMMARY_EVENT_TYPES),
)
result: dict[str, dict[str, int]] = {}
for container, event_type, count in cur.fetchall():
result.setdefault(container, {})[event_type] = count
return result
except Exception as exc:
log.error("Event history summarize failed: %s", exc)
return {}
def restore_last_restarts(
self,
states: dict[str, ContainerState],
configs: dict[str, ContainerConfig],
) -> None:
"""Backfill last_restart_at (and any still-active cooldown) from the
most recent recorded restart per container.
Called once on startup so the dashboard doesn't show "Never" just
because Inspector itself restarted, even though the container's
last actual restart is recorded in the event history. No-op when
disabled or on any error.
"""
if self._conn is None:
return
now = utcnow()
for name, state in states.items():
try:
cur = self._conn.execute(
"SELECT ts FROM events WHERE container = ? AND event_type = ? "
"ORDER BY ts DESC LIMIT 1",
(name, EVENT_RESTART),
)
row = cur.fetchone()
except Exception as exc:
log.error("Event history: failed to read last restart for %s: %s", name, exc)
continue
if row is None:
continue
last_restart = datetime.fromisoformat(row[0])
state.last_restart_at = last_restart
state.restart_history.append(last_restart)
cooldown_until = last_restart + timedelta(seconds=configs[name].cooldown_seconds)
if cooldown_until > now:
state.cooldown_until = cooldown_until
def close(self) -> None:
if self._conn is not None:
try:
self._conn.close()
finally:
self._conn = None
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
PACIFIC = ZoneInfo("America/Los_Angeles")
def format_timestamp(value: datetime | str | None) -> str:
"""Render a timestamp in US Pacific time, e.g. '06-14 03:45 PM'.
Accepts a datetime, an ISO 8601 string (as stored by EventStore /
ContainerState), or None/"" (returns ""). Naive datetimes/strings are
assumed to be UTC (matching utcnow()/EventStore storage). Unparseable
strings are returned unchanged.
"""
if not value:
return ""
if isinstance(value, str):
try:
value = datetime.fromisoformat(value)
except ValueError:
return value
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(PACIFIC).strftime("%m-%d %I:%M %p")
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
HEALTHY = "HEALTHY"
UNHEALTHY = "UNHEALTHY"
STARTING = "STARTING"
NO_HEALTH_CHECK = "NO HEALTH CHECK"
NOT_RUNNING = "NOT RUNNING"
CONTAINER_NOT_FOUND = "CONTAINER NOT FOUND"
ERROR = "ERROR"
UNKNOWN = "UNKNOWN"
UNREACHABLE = "UNREACHABLE"
@dataclass(frozen=True)
class HealthResult:
container_name: str
status: str
docker_status: str | None = None
health_status: str | None = None
message: str | None = None
def classify(container_name: str, inspect_data: dict[str, Any]) -> HealthResult:
state = (inspect_data or {}).get("State") or {}
docker_status = state.get("Status")
health = (state.get("Health") or {}).get("Status")
if docker_status is None:
return HealthResult(
container_name=container_name,
status=UNKNOWN,
docker_status=docker_status,
health_status=health,
message="missing State.Status",
)
if docker_status != "running":
return HealthResult(
container_name=container_name,
status=NOT_RUNNING,
docker_status=docker_status,
health_status=health,
message=f"docker status: {docker_status}",
)
if health is None:
return HealthResult(
container_name=container_name,
status=NO_HEALTH_CHECK,
docker_status=docker_status,
health_status=None,
)
if health == "healthy":
return HealthResult(container_name, HEALTHY, docker_status, health)
if health == "unhealthy":
return HealthResult(container_name, UNHEALTHY, docker_status, health)
if health == "starting":
return HealthResult(container_name, STARTING, docker_status, health)
return HealthResult(
container_name=container_name,
status=UNKNOWN,
docker_status=docker_status,
health_status=health,
message=f"unknown health status: {health}",
)
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from .config import HealthSummaryConfig
from .eventstore import SUMMARY_EVENT_TYPES, EventStore
from .notifier import GotifyNotifier
from .state import utcnow
log = logging.getLogger("inspector.health_summary")
_LABELS = {
"failure": "failures",
"restart": "restarts",
"restart_dry_run": "dry-run restarts",
"restart_failed": "restart failures",
"restart_limit": "restart-limit hits",
"recovery": "recoveries",
"not_found": "not-found events",
"portainer_redeploy": "Portainer redeploys",
"portainer_redeploy_failed": "Portainer redeploy failures",
"portainer_redeploy_skipped": "Portainer redeploys skipped",
}
class HealthSummaryReporter:
"""Periodic digest of per-container health activity from EventStore.
Always safe to construct and call: no-ops when health_summary.enabled
is false. If enabled but EventStore isn't recording (database.enabled
is false, or the database failed to open), logs a one-time startup
warning and digests will always show "no issues".
"""
def __init__(self, config: HealthSummaryConfig, eventstore: EventStore, notifier: GotifyNotifier):
self._config = config
self._eventstore = eventstore
self._notifier = notifier
self._last_report: datetime | None = None
if config.enabled and not eventstore.enabled:
log.warning(
"health_summary.enabled is true but database.enabled is false "
"(or the event database failed to open); digests will report "
"no data until event history is enabled"
)
@property
def enabled(self) -> bool:
return self._config.enabled
def maybe_report(self, container_names: list[str]) -> None:
if not self.enabled:
return
now = utcnow()
if self._last_report is not None and (now - self._last_report) < timedelta(hours=self._config.interval_hours):
return
since = self._last_report or (now - timedelta(hours=self._config.interval_hours))
self._report(container_names, since=since, now=now)
self._last_report = now
def _report(self, container_names: list[str], since: datetime, now: datetime) -> None:
counts = self._eventstore.summarize(since)
lines: list[str] = []
md_lines: list[str] = []
any_activity = False
for name in sorted(container_names):
c = counts.get(name, {})
parts = [f"{_LABELS[t]}={c[t]}" for t in SUMMARY_EVENT_TYPES if c.get(t)]
if parts:
any_activity = True
detail = ", ".join(parts)
else:
detail = "no issues"
lines.append(f"{name}: {detail}")
md_lines.append(f"- **{name}**: {detail}")
hours = max(1, round((now - since).total_seconds() / 3600))
log.info("Health summary (last %dh):", hours)
for line in lines:
log.info(" %s", line)
if any_activity:
body = f"### 📋 Health summary — last {hours}h\n\n" + "\n".join(md_lines)
self._notifier.notify(
title="📋 Inspector: health summary",
message=body,
priority=self._config.priority,
)
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
import requests
from .config import HttpCheckConfig
log = logging.getLogger("inspector.http_check")
@dataclass(frozen=True)
class HttpCheckResult:
ok: bool
status_code: int = 0 # synthetic 0 == connection-level failure (refused/timeout/DNS)
message: str | None = None
def check_http(config: HttpCheckConfig) -> HttpCheckResult:
"""GET config.url and classify the outcome into three tiers:
- status_code == expected_status -> ok=True (up)
- status_code == 0 -> ok=False (unreachable: connection
refused / timeout / DNS failure)
- any other code -> ok=False (responding, but the
status code is unexpected)
Never raises. Connection-level failures return before any comparison to
expected_status, so they can never be mistaken for "up".
"""
url = config.url
try:
resp = requests.get(url, timeout=config.timeout_seconds)
except requests.Timeout:
return HttpCheckResult(
ok=False, status_code=0, message=f"HTTP check unreachable (timed out): {url}"
)
except requests.ConnectionError as exc:
return HttpCheckResult(
ok=False,
status_code=0,
message=f"HTTP check unreachable (connection failed): {url} ({exc})",
)
except requests.RequestException as exc:
return HttpCheckResult(
ok=False, status_code=0, message=f"HTTP check failed: {url} ({exc})"
)
except Exception as exc:
log.exception("Unexpected error in http_check for %s: %s", url, exc)
return HttpCheckResult(
ok=False, status_code=0, message=f"HTTP check error: {url} ({exc})"
)
code = resp.status_code
if code == config.expected_status:
return HttpCheckResult(ok=True, status_code=code)
return HttpCheckResult(
ok=False,
status_code=code,
message=(
f"HTTP check unexpected status: {url} returned {code}, "
f"expected {config.expected_status} (responding but unexpected)"
),
)
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
import logging
import os
from logging.handlers import TimedRotatingFileHandler
from pathlib import Path
import colorlog
from .config import InspectorConfig
class _BlankLineFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
if record.getMessage() == "":
return ""
return super().format(record)
def setup_logging(config: InspectorConfig) -> logging.Logger:
class _BlankLineColoredFormatter(colorlog.ColoredFormatter):
def format(self, record: logging.LogRecord) -> str:
if record.getMessage() == "":
return ""
return super().format(record)
log_dir = Path(config.log_dir)
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / config.log_filename
root = logging.getLogger()
root.setLevel(config.log_level)
for handler in list(root.handlers):
root.removeHandler(handler)
file_handler = TimedRotatingFileHandler(
filename=str(log_path),
when="midnight",
interval=1,
backupCount=config.log_retention_days,
encoding="utf-8",
utc=False,
)
file_handler.setFormatter(
_BlankLineFormatter(
fmt="%(asctime)s %(levelname)-8s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
root.addHandler(file_handler)
console_handler = colorlog.StreamHandler()
console_handler.setFormatter(
_BlankLineColoredFormatter(
fmt="%(log_color)s%(asctime)s %(levelname)-8s%(reset)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
log_colors={
"DEBUG": "cyan",
"INFO": "green",
"WARNING": "yellow",
"ERROR": "red",
"CRITICAL": "bold_red",
},
)
)
root.addHandler(console_handler)
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("docker").setLevel(logging.WARNING)
logger = logging.getLogger("inspector")
logger.info("Logging initialized: file=%s level=%s", log_path, config.log_level)
return logger
+519
View File
@@ -0,0 +1,519 @@
from __future__ import annotations
import argparse
import logging
import signal
import sys
import time
from datetime import datetime
from pathlib import Path
from . import __version__
from .config import AppConfig, ConfigRef, ContainerConfig, load_config, load_raw
from .dashboard import DashboardServer
from .docker_client import build_client, get_container_status
from .eventstore import (
EVENT_FAILURE,
EVENT_NOT_FOUND,
EVENT_RECOVERY,
EVENT_RESTART_LIMIT,
EVENT_STATUS_CHANGE,
EventStore,
)
from .formatting import format_timestamp
from .health import (
CONTAINER_NOT_FOUND,
HEALTHY,
NO_HEALTH_CHECK,
NOT_RUNNING,
STARTING,
UNHEALTHY,
UNKNOWN,
UNREACHABLE,
HealthResult,
)
from .health_summary import HealthSummaryReporter
from .http_check import check_http
from .logging_setup import setup_logging
from .maintenance import MaintenanceTracker
from .notification_mute import NotificationMuteTracker
from .notifier import GotifyNotifier, MultiNotifier, NullNotifier, PushoverNotifier
from .portainer_client import PortainerClient
from .recovery import perform_recovery
from .uptime_kuma import HeartbeatSnapshot, UptimeKumaClient
from .state import (
ContainerState,
should_count_failure,
should_restart,
utcnow,
)
log = logging.getLogger("inspector")
_stop = False
def _request_stop(signum, _frame):
global _stop
log.info("Received signal %s; shutting down after current loop", signum)
_stop = True
def _apply_reachability_override(
container_config: ContainerConfig,
result: HealthResult,
uptime_kuma_snapshot: HeartbeatSnapshot | None,
) -> HealthResult:
"""Override an otherwise-OK Docker status with UNREACHABLE if an
enabled per-container reachability check (HTTP or Uptime Kuma) says the
container can't actually be reached.
Docker failure statuses (UNHEALTHY, NOT_RUNNING, ERROR,
CONTAINER_NOT_FOUND, UNKNOWN) are never masked. HTTP check takes
precedence over Uptime Kuma if both are enabled and failing.
"""
if result.status not in (HEALTHY, STARTING, NO_HEALTH_CHECK):
return result
name = container_config.name
http_cfg = container_config.http_check
if http_cfg.enabled:
check = check_http(http_cfg)
if not check.ok:
return HealthResult(
container_name=name,
status=UNREACHABLE,
docker_status=result.docker_status,
health_status=result.health_status,
message=check.message,
)
uk_cfg = container_config.uptime_kuma
if uk_cfg.enabled and uptime_kuma_snapshot is not None:
if uptime_kuma_snapshot.is_up(uk_cfg.monitor_name) is False:
return HealthResult(
container_name=name,
status=UNREACHABLE,
docker_status=result.docker_status,
health_status=result.health_status,
message=f"Uptime Kuma monitor '{uk_cfg.monitor_name}' is down",
)
return result
def _check_one(
container_config: ContainerConfig,
state: ContainerState,
result: HealthResult,
notifier: GotifyNotifier,
eventstore: EventStore,
maintenance: MaintenanceTracker,
notification_mute: NotificationMuteTracker,
client,
dry_run: bool,
width: int,
uptime_kuma_snapshot: HeartbeatSnapshot | None,
app: AppConfig,
states: dict[str, ContainerState],
portainer_client: PortainerClient,
stack_cooldowns: dict[str, datetime],
) -> None:
name = container_config.name
now = utcnow()
state.last_check_at = now
state.previous_status = state.current_status
state.current_status = result.status
result = _apply_reachability_override(container_config, result, uptime_kuma_snapshot)
state.current_status = result.status # may now be UNREACHABLE
if maintenance.is_active(name):
return
if notification_mute.is_muted(name):
notifier = NullNotifier()
if state.previous_status != state.current_status:
log.info(
"%-*s status: %s -> %s%s",
width,
name,
state.previous_status or "INIT",
state.current_status,
f" ({result.message})" if result.message else "",
)
eventstore.record(
container=name,
event_type=EVENT_STATUS_CHANGE,
old_status=state.previous_status,
new_status=state.current_status,
error_count=state.error_count,
message=result.message,
)
if (
state.previous_status is not None
and state.previous_status != HEALTHY
and state.current_status == HEALTHY
and state.last_notification_state != HEALTHY
):
log.info("%-*s recovered: %s -> HEALTHY", width, name, state.previous_status)
notifier.notify(
title=f"✅ Inspector: {name} recovered",
message=(
f"**{name}** is back to **HEALTHY** (was *{state.previous_status}*).\n\n"
f"- Errors before recovery: **{state.error_count}**\n"
f"- Time: {format_timestamp(now)}"
),
priority=3,
)
eventstore.record(
container=name,
event_type=EVENT_RECOVERY,
old_status=state.previous_status,
new_status=HEALTHY,
error_count=state.error_count,
)
state.last_notification_state = HEALTHY
state.error_count = 0
state.restart_limit_notified = False
state.restarted_this_episode = False
log.info("")
if result.status == CONTAINER_NOT_FOUND:
if state.last_notification_state != CONTAINER_NOT_FOUND:
log.error("%-*s container not found", width, name)
notifier.notify(
title=f"❓ Inspector: {name} not found",
message=(
f"**{name}** is missing from Docker.\n\n"
+ (f"- Detail: {result.message}\n" if result.message else "")
+ f"- Last known status: *{state.previous_status or 'unknown'}*\n"
f"- Time: {format_timestamp(now)}"
),
priority=7,
)
eventstore.record(
container=name,
event_type=EVENT_NOT_FOUND,
old_status=state.previous_status,
new_status=CONTAINER_NOT_FOUND,
message=result.message,
)
state.last_notification_state = CONTAINER_NOT_FOUND
return
in_grace = state.in_grace(now, container_config.grace_period_seconds)
if in_grace and result.status != HEALTHY:
log.debug("%-*s in grace period; not counting %s", width, name, result.status)
if state.in_cooldown(now):
remaining = int((state.cooldown_until - now).total_seconds())
log.debug("%-*s in cooldown for %ss", width, name, remaining)
return
if should_count_failure(result.status, container_config, in_grace):
state.error_count += 1
if state.error_count == 1:
log.info("")
log.warning(
"%-*s failure: status=%s count=%d/%d",
width,
name,
result.status,
state.error_count,
container_config.max_error_count,
)
if state.error_count == 1 or state.last_notification_state != result.status:
notifier.notify(
title=f"🚨 Inspector: {name} is {result.status}",
message=(
f"**{name}** is **{result.status}**"
+ (f" — {result.message}" if result.message else "")
+ ".\n\n"
f"- Error count: **{state.error_count}/{container_config.max_error_count}**\n"
f"- Previous status: *{state.previous_status or 'unknown'}*\n"
f"- Time: {format_timestamp(now)}"
),
priority=5,
)
eventstore.record(
container=name,
event_type=EVENT_FAILURE,
old_status=state.previous_status,
new_status=result.status,
error_count=state.error_count,
message=result.message,
)
state.last_notification_state = result.status
elif result.status == HEALTHY:
if state.error_count != 0:
log.info("%-*s healthy again; resetting error count", width, name)
state.error_count = 0
state.restarted_this_episode = False
if state.error_count >= container_config.max_error_count:
ok, reason = should_restart(state, container_config, now)
if not ok and reason == "hourly restart limit reached":
if not state.restart_limit_notified:
log.critical(
"%-*s exceeded max_restarts_per_hour (%d); pausing restarts",
width,
name,
container_config.max_restarts_per_hour,
)
notifier.notify(
title=f"🛑 Inspector: {name} restart limit reached",
message=(
f"**{name}** hit its restart limit: "
f"**{container_config.max_restarts_per_hour}** restart(s)/hour.\n\n"
f"- Current status: **{result.status}**\n"
f"- Restarts paused until the hourly window clears\n"
f"- Time: {format_timestamp(now)}"
),
priority=9,
)
eventstore.record(
container=name,
event_type=EVENT_RESTART_LIMIT,
old_status=state.previous_status,
new_status=result.status,
error_count=state.error_count,
message=f"max_restarts_per_hour={container_config.max_restarts_per_hour}",
)
state.restart_limit_notified = True
return
if ok:
dep = container_config.depends_on
if dep:
dep_status = states[dep].current_status if dep in states else None
if dep_status not in (HEALTHY, STARTING, NO_HEALTH_CHECK):
if container_config.vpn_routed:
log.info(
"%-*s is vpn_routed and depends on %s; deferring recovery "
"because %s is %s",
width, name, dep, dep, dep_status,
)
return
perform_recovery(
app=app,
container_config=container_config,
state=state,
result=result,
notifier=notifier,
eventstore=eventstore,
portainer_client=portainer_client,
stack_cooldowns=stack_cooldowns,
client=client,
dry_run=dry_run,
width=width,
now=now,
)
def _topological_order(enabled_configs: dict[str, ContainerConfig]) -> list[str]:
"""Order enabled container names so a container with `depends_on`
referencing another *enabled* container is checked after its dependency,
preserving relative config-file order otherwise.
Falls back to config order (with a warning) on a dependency cycle, or
per-container (with a warning) if `depends_on` points to a disabled or
not-enabled container.
"""
names = list(enabled_configs.keys())
deps: dict[str, str | None] = {}
for name in names:
dep = enabled_configs[name].depends_on
if dep and dep in enabled_configs:
deps[name] = dep
else:
if dep and dep not in enabled_configs:
log.warning(
"%s depends_on %r, which is disabled or not found among enabled "
"containers; ignoring dependency ordering for %s",
name, dep, name,
)
deps[name] = None
order: list[str] = []
visiting: set[str] = set()
visited: set[str] = set()
cycle_detected = False
def visit(n: str) -> None:
nonlocal cycle_detected
if n in visited:
return
if n in visiting:
cycle_detected = True
return
visiting.add(n)
dep = deps.get(n)
if dep is not None:
visit(dep)
visiting.discard(n)
visited.add(n)
order.append(n)
for name in names:
visit(name)
if cycle_detected:
log.warning(
"Dependency cycle detected in container 'depends_on' configuration; "
"falling back to config-file order"
)
return names
return order
def _build_notifier(app: AppConfig) -> MultiNotifier:
return MultiNotifier([GotifyNotifier(app.gotify), PushoverNotifier(app.pushover)])
def run_loop(config_ref: ConfigRef) -> int:
app = config_ref.get()
notifier = _build_notifier(app)
uptime_kuma_client = UptimeKumaClient(app.integrations.uptime_kuma)
eventstore = EventStore(app.database)
health_summary = HealthSummaryReporter(app.health_summary, eventstore, notifier)
maintenance = MaintenanceTracker(
app.maintenance,
eventstore,
scheduled_containers={c.name for c in app.containers if c.maintenance_window},
)
notification_mute = NotificationMuteTracker(app.notification_mute, eventstore)
try:
client = build_client()
client.ping()
log.info("Docker connection successful")
except Exception as exc:
if "Permission denied" in str(exc):
log.critical(
"Permission denied on /var/run/docker.sock. Set DOCKER_GID to "
"the host docker group GID: stat -c '%g' /var/run/docker.sock"
)
else:
log.critical("Cannot reach Docker daemon: %s", exc)
return 2
states: dict[str, ContainerState] = {
c.name: ContainerState(name=c.name) for c in app.containers if c.enabled
}
enabled_configs = {c.name: c for c in app.containers if c.enabled}
name_width = max((len(name) for name in enabled_configs), default=0)
eventstore.restore_last_restarts(states, enabled_configs)
check_order = _topological_order(enabled_configs)
portainer_client = PortainerClient(app.integrations.portainer)
stack_cooldowns: dict[str, datetime] = {}
dashboard = DashboardServer(
config_ref,
states,
maintenance,
notification_mute,
)
dashboard.start()
log.info(
"Inspector %s started; watching %d container(s); interval=%ss dry_run=%s",
__version__,
len(states),
app.inspector.interval_seconds,
app.inspector.dry_run,
)
while not _stop:
app = config_ref.get() # re-read each iteration so settings-page edits take effect
notifier = _build_notifier(app)
# Rebuild enabled_configs / check_order from current config so edits
# to per-container settings apply next cycle. Containers added via
# the settings page are logged as 'requires restart' because the
# states dict is built once at startup.
current_enabled = {c.name: c for c in app.containers if c.enabled}
new_names = current_enabled.keys() - states.keys()
if new_names:
log.warning(
"Containers added in config require Inspector restart to begin monitoring: %s",
sorted(new_names),
)
enabled_configs = {n: c for n, c in current_enabled.items() if n in states}
check_order = _topological_order(enabled_configs)
uptime_kuma_snapshot = uptime_kuma_client.fetch()
for name in check_order:
cfg = enabled_configs[name]
result = get_container_status(client, name)
try:
_check_one(
container_config=cfg,
state=states[name],
result=result,
notifier=notifier,
eventstore=eventstore,
maintenance=maintenance,
notification_mute=notification_mute,
client=client,
dry_run=app.inspector.dry_run,
width=name_width,
uptime_kuma_snapshot=uptime_kuma_snapshot,
app=app,
states=states,
portainer_client=portainer_client,
stack_cooldowns=stack_cooldowns,
)
except Exception as exc:
log.exception("%-*s error processing check: %s", name_width, name, exc)
if app.inspector.heartbeat:
log.info("heartbeat: %d containers checked", len(states))
eventstore.maybe_prune()
health_summary.maybe_report(list(enabled_configs.keys()))
# interruptible sleep
slept = 0
while slept < app.inspector.interval_seconds and not _stop:
time.sleep(min(1, app.inspector.interval_seconds - slept))
slept += 1
dashboard.stop()
eventstore.close()
log.info("Inspector stopped")
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="inspector", description="Docker container health watcher")
parser.add_argument("--config", "-c", default="/app/config.yml", help="Path to config.yml")
parser.add_argument("--version", action="version", version=f"inspector {__version__}")
args = parser.parse_args(argv)
config_path = Path(args.config)
if not config_path.exists():
print(f"config file not found: {config_path}", file=sys.stderr)
return 2
app = load_config(config_path)
raw = load_raw(config_path)
config_ref = ConfigRef(config_path, app, raw)
setup_logging(app.inspector)
signal.signal(signal.SIGTERM, _request_stop)
signal.signal(signal.SIGINT, _request_stop)
return run_loop(config_ref)
if __name__ == "__main__":
raise SystemExit(main())
+142
View File
@@ -0,0 +1,142 @@
from __future__ import annotations
import logging
from datetime import datetime, time, timedelta
from pathlib import Path
from .config import MaintenanceConfig
from .eventstore import EVENT_MAINTENANCE_ENTER, EVENT_MAINTENANCE_EXIT, EventStore
from .state import localnow, utcnow
log = logging.getLogger("inspector.maintenance")
def _parse_hhmm(value: str) -> time:
hour, minute = value.split(":")
return time(int(hour), int(minute))
def _in_window(now_time: time, start: time, end: time) -> bool:
if start <= end:
return start <= now_time < end
return now_time >= start or now_time < end # wraps past midnight
def _next_window(now: datetime, start: time, end: time) -> tuple[datetime, datetime]:
"""Return the recurring daily maintenance window as (start, end) datetimes,
in the same (naive) timezone as ``now``. If ``now`` is inside a window, that
active window is returned; otherwise the next upcoming one. Handles both
same-day and past-midnight (start > end) windows.
Caller must ensure start != end (a zero/full-day window is meaningless here).
"""
start_secs = start.hour * 3600 + start.minute * 60 + start.second
end_secs = end.hour * 3600 + end.minute * 60 + end.second
duration = timedelta(seconds=(end_secs - start_secs) % 86400)
today = now.date()
candidates = [
datetime.combine(today + timedelta(days=offset), start)
for offset in (-1, 0, 1)
]
upcoming: list[datetime] = []
for s in candidates:
if s <= now < s + duration:
return s, s + duration
if s > now:
upcoming.append(s)
s = min(upcoming)
return s, s + duration
class MaintenanceTracker:
"""Per-container maintenance windows: recurring daily schedule OR
ad-hoc marker files. Always safe to construct and call: a complete
no-op when maintenance.enabled is false.
- Recurring window: containers with maintenance_window: true in their
config are "in maintenance" every day between maintenance.start and
maintenance.end (local time, HH:MM, may wrap past midnight).
- Marker files: touch <maintenance.dir>/<container> to start, rm to
end. A marker older than maintenance.max_hours auto-expires.
Logs one INFO line on enter/exit and records
EVENT_MAINTENANCE_ENTER/EVENT_MAINTENANCE_EXIT (if event history is
enabled) on each transition.
"""
def __init__(self, config: MaintenanceConfig, eventstore: EventStore, scheduled_containers: set[str]):
self._config = config
self._eventstore = eventstore
self._scheduled = scheduled_containers
self._start = _parse_hhmm(config.start)
self._end = _parse_hhmm(config.end)
self._active: dict[str, bool] = {}
@property
def enabled(self) -> bool:
return self._config.enabled
def is_active(self, name: str) -> bool:
if not self.enabled:
return False
active = self._marker_active(name) or self._scheduled_active(name)
was_active = self._active.get(name, False)
if active != was_active:
self._log_transition(name, active)
self._active[name] = active
return active
def peek(self, name: str) -> bool:
"""Read-only maintenance check for display purposes (e.g. the
dashboard). No transition logging, no eventstore writes, and no
update to internal tracking state. Safe to call from another
thread."""
if not self.enabled:
return False
return self._marker_active(name) or self._scheduled_active(name)
def next_scheduled_window(self, name: str) -> tuple[datetime, datetime] | None:
"""Return the current or next recurring maintenance window for a
scheduled container as (start, end) naive-local datetimes, or None if
maintenance is disabled, the container has no recurring window, or the
window is empty (start == end). Read-only; safe to call from the
dashboard thread."""
if not self.enabled or name not in self._scheduled or self._start == self._end:
return None
return _next_window(localnow(), self._start, self._end)
def _scheduled_active(self, name: str) -> bool:
if name not in self._scheduled:
return False
return _in_window(localnow().time(), self._start, self._end)
def _marker_active(self, name: str) -> bool:
marker = Path(self._config.dir) / name
try:
if not marker.is_file():
return False
age_hours = (utcnow().timestamp() - marker.stat().st_mtime) / 3600
if age_hours > self._config.max_hours:
if self._active.get(name, False):
log.warning(
"%s maintenance marker expired after %.1fh (max_hours=%d); "
"resuming normal monitoring",
name, age_hours, self._config.max_hours,
)
return False
return True
except Exception as exc:
log.error("%s maintenance marker check failed: %s", name, exc)
return False
def _log_transition(self, name: str, active: bool) -> None:
if active:
log.info("%s entering maintenance window", name)
self._eventstore.record(container=name, event_type=EVENT_MAINTENANCE_ENTER)
else:
log.info("%s exiting maintenance window; resuming normal monitoring", name)
self._eventstore.record(container=name, event_type=EVENT_MAINTENANCE_EXIT)
+93
View File
@@ -0,0 +1,93 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from pathlib import Path
from .config import NotificationMuteConfig
from .eventstore import EVENT_NOTIFICATION_MUTE_ENTER, EVENT_NOTIFICATION_MUTE_EXIT, EventStore
from .state import utcnow
log = logging.getLogger("inspector.notification_mute")
class NotificationMuteTracker:
"""Per-container ad-hoc notification mute via marker files under
notification_mute.dir. Always safe to construct and call: a complete
no-op when notification_mute.enabled is false.
Muting silences outbound Gotify notifications only -- monitoring,
failure counting, cooldowns, restarts/recovery, and event-history
recording continue unchanged. A marker older than
notification_mute.default_hours auto-expires.
Logs one INFO line on mute/unmute and records
EVENT_NOTIFICATION_MUTE_ENTER/EVENT_NOTIFICATION_MUTE_EXIT (if event
history is enabled) on each transition.
"""
def __init__(self, config: NotificationMuteConfig, eventstore: EventStore):
self._config = config
self._eventstore = eventstore
self._active: dict[str, bool] = {}
@property
def enabled(self) -> bool:
return self._config.enabled
def is_muted(self, name: str) -> bool:
if not self.enabled:
return False
active = self._marker_expiry(name) is not None
was_active = self._active.get(name, False)
if active != was_active:
self._log_transition(name, active)
self._active[name] = active
return active
def peek(self, name: str) -> bool:
"""Read-only mute check for display (e.g. the dashboard). No
transition logging, no eventstore writes, no state update. Safe to
call from another thread."""
if not self.enabled:
return False
return self._marker_expiry(name) is not None
def muted_until(self, name: str) -> datetime | None:
"""Expiry time for an active mute, or None if not muted/disabled."""
if not self.enabled:
return None
return self._marker_expiry(name)
def _marker_expiry(self, name: str) -> datetime | None:
marker = Path(self._config.dir) / name
try:
if not marker.is_file():
return None
mtime = marker.stat().st_mtime
age_hours = (utcnow().timestamp() - mtime) / 3600
if age_hours > self._config.default_hours:
if self._active.get(name, False):
log.warning(
"%s notification mute expired after %.1fh (default_hours=%d); "
"alerts resumed",
name, age_hours, self._config.default_hours,
)
return None
return datetime.fromtimestamp(mtime, tz=timezone.utc) + timedelta(
hours=self._config.default_hours
)
except Exception as exc:
log.error("%s notification mute marker check failed: %s", name, exc)
return None
def _log_transition(self, name: str, active: bool) -> None:
if active:
log.info("%s notifications muted", name)
self._eventstore.record(container=name, event_type=EVENT_NOTIFICATION_MUTE_ENTER)
else:
log.info("%s notifications unmuted; alerts resumed", name)
self._eventstore.record(container=name, event_type=EVENT_NOTIFICATION_MUTE_EXIT)
+149
View File
@@ -0,0 +1,149 @@
from __future__ import annotations
import logging
from typing import Iterable
import requests
from .config import GotifyConfig, PushoverConfig
log = logging.getLogger("inspector.notify")
class GotifyNotifier:
def __init__(self, config: GotifyConfig, timeout: float = 5.0):
self._config = config
self._timeout = timeout
@property
def enabled(self) -> bool:
return self._config.enabled and bool(self._config.url) and bool(self._config.token)
def notify(self, title: str, message: str, priority: int | None = None) -> None:
if not self.enabled:
return
url = f"{self._config.url}/message"
payload = {
"title": title,
"message": message,
"priority": self._config.priority if priority is None else priority,
"extras": {
"client::display": {"contentType": "text/markdown"},
},
}
try:
resp = requests.post(
url,
params={"token": self._config.token},
json=payload,
timeout=self._timeout,
)
if resp.status_code >= 400:
log.error(
"Gotify notification failed: status=%s body=%s",
resp.status_code,
resp.text[:200],
)
except requests.RequestException as exc:
log.error("Gotify notification failed: %s", exc)
except Exception as exc:
log.exception("Gotify notifier crashed: %s", exc)
# Pushover's priority range is -2..2. Inspector uses a Gotify-style scale
# (roughly 1..10). Mapping chosen 2026-10-26 (see plan file).
# priority 2 ("emergency") requires retry+expire; defaults below.
_PUSHOVER_EMERGENCY_RETRY_SECONDS = 60
_PUSHOVER_EMERGENCY_EXPIRE_SECONDS = 3600
def _map_priority_to_pushover(inspector_priority: int) -> int:
if inspector_priority <= 2:
return -1
if inspector_priority <= 4:
return 0
if inspector_priority <= 6:
return 1
return 2
class PushoverNotifier:
API_URL = "https://api.pushover.net/1/messages.json"
def __init__(self, config: PushoverConfig, timeout: float = 5.0):
self._config = config
self._timeout = timeout
@property
def enabled(self) -> bool:
return (
self._config.enabled
and bool(self._config.token)
and bool(self._config.user_key)
)
def notify(self, title: str, message: str, priority: int | None = None) -> None:
if not self.enabled:
return
inspector_priority = self._config.priority if priority is None else priority
pushover_priority = _map_priority_to_pushover(inspector_priority)
data = {
"token": self._config.token,
"user": self._config.user_key,
"title": title,
"message": message,
"priority": pushover_priority,
}
if self._config.device:
data["device"] = self._config.device
if self._config.sound:
data["sound"] = self._config.sound
if pushover_priority == 2:
data["retry"] = _PUSHOVER_EMERGENCY_RETRY_SECONDS
data["expire"] = _PUSHOVER_EMERGENCY_EXPIRE_SECONDS
try:
resp = requests.post(self.API_URL, data=data, timeout=self._timeout)
if resp.status_code >= 400:
log.error(
"Pushover notification failed: status=%s body=%s",
resp.status_code,
resp.text[:200],
)
except requests.RequestException as exc:
log.error("Pushover notification failed: %s", exc)
except Exception as exc:
log.exception("Pushover notifier crashed: %s", exc)
class MultiNotifier:
"""Fan out notify() to every enabled child notifier.
Enabled is true if any child is enabled. A failure in one child never
prevents the others from running.
"""
def __init__(self, notifiers: Iterable[object]):
self._notifiers = list(notifiers)
@property
def enabled(self) -> bool:
return any(getattr(n, "enabled", False) for n in self._notifiers)
def notify(self, title: str, message: str, priority: int | None = None) -> None:
for n in self._notifiers:
try:
n.notify(title=title, message=message, priority=priority)
except Exception as exc:
log.exception("notifier %r crashed: %s", type(n).__name__, exc)
class NullNotifier:
"""No-op notifier: used to silence a muted container's alerts for one
_check_one call. Mirrors the notify() signature of the real notifiers."""
def notify(self, title: str, message: str, priority: int | None = None) -> None:
pass
+182
View File
@@ -0,0 +1,182 @@
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
import requests
from .config import PortainerConfig, PortainerEnvironmentConfig
log = logging.getLogger("inspector.portainer")
@dataclass(frozen=True)
class PortainerRedeployResult:
ok: bool
message: str = ""
class PortainerClient:
"""Talks to one or more named Portainer environments to redeploy stacks.
Always safe to construct and call: never raises. Any error (missing
environment, missing token env var, network failure, stack not found,
unexpected response) yields PortainerRedeployResult(ok=False, message=...).
Security: the API token value is read from the OS environment per-call
and is never logged, returned, or included in any message. Only the
*name* of the environment variable and the fact that "a Portainer API
call was attempted" are logged.
"""
def __init__(self, config: PortainerConfig, timeout: float = 15.0):
self._config = config
self._timeout = timeout
@property
def enabled(self) -> bool:
return self._config.enabled
def _resolve_environment(self, environment_name: str) -> PortainerEnvironmentConfig | None:
return self._config.environments.get(environment_name)
def _load_token(self, env_cfg: PortainerEnvironmentConfig) -> str | None:
if env_cfg.api_token:
return env_cfg.api_token
if env_cfg.api_token_env:
return os.environ.get(env_cfg.api_token_env)
return None
def redeploy_stack(self, environment_name: str, stack_name: str) -> PortainerRedeployResult:
env_cfg = self._resolve_environment(environment_name)
if env_cfg is None:
return PortainerRedeployResult(
ok=False,
message=(
f"Portainer environment {environment_name!r} is not configured "
f"under integrations.portainer.environments"
),
)
if not env_cfg.base_url:
return PortainerRedeployResult(
ok=False,
message=f"Portainer environment {environment_name!r} has no base_url configured",
)
token = self._load_token(env_cfg)
if not token:
if env_cfg.api_token_env:
msg = (
f"Portainer API token not found in environment variable "
f"{env_cfg.api_token_env!r} for environment {environment_name!r}. "
f"Ensure the env var is set in the Inspector container, or use "
f"'api_token' to embed the token directly in config.yml."
)
else:
msg = (
f"Portainer environment {environment_name!r} has no API token configured. "
f"Set 'api_token_env' (name of an env var) or 'api_token' (direct value) "
f"under integrations.portainer.environments.{environment_name}."
)
return PortainerRedeployResult(ok=False, message=msg)
headers = {"X-API-Key": token}
verify = env_cfg.verify_ssl
log.info(
"Portainer API call attempted: environment=%s endpoint_id=%s stack=%s",
environment_name, env_cfg.endpoint_id, stack_name,
)
try:
stack = self._find_stack(env_cfg, headers, verify, stack_name)
if stack is None:
log.warning(
"stack %r not found in Portainer environment %r (endpoint_id=%s)",
stack_name, environment_name, env_cfg.endpoint_id,
)
return PortainerRedeployResult(
ok=False,
message=(
f"Stack {stack_name!r} not found in Portainer environment "
f"{environment_name!r} (endpoint_id={env_cfg.endpoint_id})"
),
)
stack_id = stack["Id"]
log.debug("found stack %r with Id=%s", stack_name, stack_id)
file_content = self._fetch_stack_file(env_cfg, headers, verify, stack_id)
env_vars = stack.get("Env") or []
log.debug("sending PUT /api/stacks/%s for stack %r", stack_id, stack_name)
resp = requests.put(
f"{env_cfg.base_url}/api/stacks/{stack_id}",
params={"endpointId": env_cfg.endpoint_id},
headers=headers,
json={
"stackFileContent": file_content,
"env": env_vars,
"prune": False,
"pullImage": True,
},
timeout=self._timeout,
verify=verify,
)
resp.raise_for_status()
except requests.HTTPError as exc:
body = ""
try:
body = exc.response.text[:500]
except Exception:
pass
detail = f"HTTP {exc.response.status_code}: {body}" if body else str(exc)
log.error(
"Portainer stack redeploy failed: environment=%s stack=%s error=%s",
environment_name, stack_name, detail,
)
return PortainerRedeployResult(ok=False, message=f"Portainer API error: {detail}")
except requests.RequestException as exc:
log.error(
"Portainer stack redeploy failed: environment=%s stack=%s error=%s",
environment_name, stack_name, exc,
)
return PortainerRedeployResult(ok=False, message=f"Portainer API request failed: {exc}")
except Exception as exc:
log.exception(
"Portainer stack redeploy crashed: environment=%s stack=%s",
environment_name, stack_name,
)
return PortainerRedeployResult(ok=False, message=f"unexpected error: {exc}")
log.info(
"Portainer stack redeploy succeeded: environment=%s stack=%s stack_id=%s",
environment_name, stack_name, stack_id,
)
return PortainerRedeployResult(ok=True, message=f"redeployed stack {stack_name!r}")
def _find_stack(self, env_cfg, headers, verify, stack_name):
resp = requests.get(
f"{env_cfg.base_url}/api/stacks", headers=headers, timeout=self._timeout, verify=verify,
)
resp.raise_for_status()
stacks = resp.json() or []
log.debug(
"_find_stack: %d stacks total; searching name=%r endpoint_id=%s",
len(stacks), stack_name, env_cfg.endpoint_id,
)
for stack in stacks:
if stack.get("Name") == stack_name and stack.get("EndpointId") == env_cfg.endpoint_id:
return stack
return None
def _fetch_stack_file(self, env_cfg, headers, verify, stack_id) -> str:
log.debug("fetching compose file for stack_id=%s", stack_id)
resp = requests.get(
f"{env_cfg.base_url}/api/stacks/{stack_id}/file",
headers=headers, timeout=self._timeout, verify=verify,
)
resp.raise_for_status()
return (resp.json() or {}).get("StackFileContent", "")
+544
View File
@@ -0,0 +1,544 @@
from __future__ import annotations
import logging
import time
from datetime import datetime, timedelta
from .config import AppConfig, ContainerConfig, PortainerConfig
from .docker_client import restart_container, start_container
from .eventstore import (
EVENT_PORTAINER_REDEPLOY,
EVENT_PORTAINER_REDEPLOY_FAILED,
EVENT_PORTAINER_REDEPLOY_SKIPPED,
EVENT_RESTART,
EVENT_RESTART_DRYRUN,
EVENT_RESTART_FAILED,
EventStore,
)
from .formatting import format_timestamp
from .health import HealthResult
from .http_check import check_http
from .notifier import GotifyNotifier
from .portainer_client import PortainerClient
from .state import ContainerState
log = logging.getLogger("inspector.recovery")
_STALE_NAMESPACE_MARKERS = (
"joining network namespace of container",
"no such container",
)
def is_stale_vpn_namespace_error(message: str | None) -> bool:
"""Case-insensitive substring match for the Docker error text emitted
when a VPN-routed container's `network_mode: container:<vpn>` target no
longer exists (the VPN container was recreated)."""
if not message:
return False
lowered = message.lower()
return any(marker in lowered for marker in _STALE_NAMESPACE_MARKERS)
def resolve_portainer_environment(
container_config: ContainerConfig,
portainer_config: PortainerConfig,
) -> tuple[str | None, str | None]:
"""Resolve the Portainer environment for this container's redeploy.
Returns (environment_name, error); exactly one is non-None.
"""
name = container_config.name
if container_config.portainer_environment:
return container_config.portainer_environment, None
if portainer_config.default_environment:
return portainer_config.default_environment, None
configured = list(portainer_config.environments.keys())
if len(configured) == 1:
only_env = configured[0]
log.info(
"resolved Portainer environment %s for %s (only environment configured)",
only_env, name,
)
return only_env, None
return None, (
f"{name} requests recovery_action 'portainer_stack_redeploy' but "
f"integrations.portainer.default_environment is not set and "
f"{len(configured)} environments are configured "
f"({sorted(configured)}); cannot resolve which Portainer environment to use"
)
def stack_cooldown_key(environment: str, stack_name: str) -> str:
return f"{environment}:{stack_name}"
def in_stack_cooldown(stack_cooldowns: dict[str, datetime], key: str, now: datetime) -> bool:
until = stack_cooldowns.get(key)
return until is not None and now < until
def perform_recovery(
*,
app: AppConfig,
container_config: ContainerConfig,
state: ContainerState,
result: HealthResult,
notifier: GotifyNotifier,
eventstore: EventStore,
portainer_client: PortainerClient,
stack_cooldowns: dict[str, datetime],
client,
dry_run: bool,
width: int,
now: datetime,
) -> None:
action = container_config.recovery_action or "restart_container"
if action == "portainer_stack_redeploy":
_perform_portainer_redeploy(
app=app,
container_config=container_config,
state=state,
result=result,
notifier=notifier,
eventstore=eventstore,
portainer_client=portainer_client,
stack_cooldowns=stack_cooldowns,
dry_run=dry_run,
width=width,
now=now,
)
return
if action == "restart_then_redeploy":
_perform_restart_then_redeploy(
app=app,
container_config=container_config,
state=state,
result=result,
notifier=notifier,
eventstore=eventstore,
portainer_client=portainer_client,
stack_cooldowns=stack_cooldowns,
client=client,
dry_run=dry_run,
width=width,
now=now,
)
return
_perform_docker_action(
action=action,
container_config=container_config,
state=state,
result=result,
notifier=notifier,
eventstore=eventstore,
client=client,
dry_run=dry_run,
width=width,
now=now,
)
def _perform_restart_then_redeploy(
*,
app: AppConfig,
container_config: ContainerConfig,
state: ContainerState,
result: HealthResult,
notifier: GotifyNotifier,
eventstore: EventStore,
portainer_client: PortainerClient,
stack_cooldowns: dict[str, datetime],
client,
dry_run: bool,
width: int,
now: datetime,
) -> None:
"""Tiered recovery: try `docker restart` first, escalate to a Portainer stack
redeploy only when needed.
Tier 1 (first failure episode): attempt a plain `docker restart`. A VPN-routed
container whose sidecar was merely *restarted* (same container id) re-establishes
its network this way, avoiding a full redeploy.
Escalate to redeploy when either:
- the restart command itself errors (e.g. the sidecar was *recreated*, so the
stored `network_mode: container:<old-id>` is stale -> "No such container"), or
- the container is still failing on a later recovery cycle
(`state.restarted_this_episode` is already set), meaning the restart didn't stick.
The episode flag resets when the container is next seen HEALTHY (see main._check_one).
"""
name = container_config.name
def redeploy() -> None:
_perform_portainer_redeploy(
app=app,
container_config=container_config,
state=state,
result=result,
notifier=notifier,
eventstore=eventstore,
portainer_client=portainer_client,
stack_cooldowns=stack_cooldowns,
dry_run=dry_run,
width=width,
now=now,
)
# Already restarted this episode and still failing -> escalate to redeploy.
if state.restarted_this_episode:
log.warning(
"%-*s still failing after a restart; escalating to Portainer stack redeploy",
width, name,
)
redeploy()
return
if dry_run:
log.warning("%-*s DRY RUN: would restart (tier 1 of restart_then_redeploy)", width, name)
state.record_restart(now, container_config.cooldown_seconds)
state.restarted_this_episode = True
eventstore.record(
container=name,
event_type=EVENT_RESTART_DRYRUN,
old_status=state.previous_status,
new_status=result.status,
error_count=state.error_count,
message=f"would restart due to {result.status} (tier 1, then redeploy if still failing)",
)
return
log.warning(
"%-*s restarting (tier 1) due to %d consecutive %s checks",
width, name, state.error_count, result.status,
)
notifier.notify(
title=f"🔁 Inspector: restarting {name}",
message=(
f"Restarting **{name}** after **{state.error_count}** consecutive "
f"**{result.status}** check(s).\n\n"
f"- If it doesn't recover, Inspector escalates to a Portainer stack redeploy\n"
f"- Cooldown: **{container_config.cooldown_seconds}s** after restart\n"
f"- Time: {format_timestamp(now)}"
),
priority=6,
)
success, err = restart_container(client, name)
if success:
state.record_restart(now, container_config.cooldown_seconds)
state.restarted_this_episode = True
state.last_notification_state = "RESTARTED"
state.restart_limit_notified = False
log.info(
"%-*s restart succeeded; cooldown until %s (will redeploy if still failing)",
width, name, state.cooldown_until.isoformat(),
)
eventstore.record(
container=name,
event_type=EVENT_RESTART,
old_status=state.previous_status,
new_status=result.status,
error_count=state.error_count,
message=f"restart succeeded; cooldown until {state.cooldown_until.isoformat()}",
)
notifier.notify(
title=f"✅ Inspector: {name} restart completed",
message=(
f"**{name}** was successfully restarted by Docker.\n\n"
f"- Previous status: *{result.status}*\n"
f"- Cooldown until: {state.cooldown_until.isoformat()}\n"
f"- Time: {format_timestamp(now)}"
),
priority=3,
)
return
# Restart failed -> escalate to redeploy this same round.
log.error("%-*s restart failed: %s; escalating to Portainer stack redeploy", width, name, err)
if is_stale_vpn_namespace_error(err):
log.info(
"%-*s restart failure is a stale VPN namespace error; redeploy is required",
width, name,
)
eventstore.record(
container=name,
event_type=EVENT_RESTART_FAILED,
old_status=state.previous_status,
new_status=result.status,
error_count=state.error_count,
message=f"restart failed ({err}); escalating to redeploy",
)
redeploy()
def _perform_docker_action(
*,
action: str,
container_config: ContainerConfig,
state: ContainerState,
result: HealthResult,
notifier: GotifyNotifier,
eventstore: EventStore,
client,
dry_run: bool,
width: int,
now: datetime,
) -> None:
name = container_config.name
verb = "restart" if action == "restart_container" else "start"
if dry_run:
log.warning("%-*s DRY RUN: would %s due to %s status", width, name, verb, result.status)
state.record_restart(now, container_config.cooldown_seconds)
eventstore.record(
container=name,
event_type=EVENT_RESTART_DRYRUN,
old_status=state.previous_status,
new_status=result.status,
error_count=state.error_count,
message=f"would {verb} due to {result.status}",
)
return
log.warning(
"%-*s %sing due to %d consecutive %s checks",
width, name, verb.capitalize(), state.error_count, result.status,
)
notifier.notify(
title=f"🔁 Inspector: {verb}ing {name}",
message=(
f"{verb.capitalize()}ing **{name}** after **{state.error_count}** consecutive "
f"**{result.status}** check(s).\n\n"
f"- Cooldown: **{container_config.cooldown_seconds}s** after {verb}\n"
f"- Time: {format_timestamp(now)}"
),
priority=6,
)
if action == "restart_container":
success, err = restart_container(client, name)
else:
success, err = start_container(client, name)
if success:
state.record_restart(now, container_config.cooldown_seconds)
state.last_notification_state = "RESTARTED"
state.restart_limit_notified = False
log.info("%-*s %s succeeded; cooldown until %s", width, name, verb, state.cooldown_until.isoformat())
eventstore.record(
container=name,
event_type=EVENT_RESTART,
old_status=state.previous_status,
new_status=result.status,
error_count=state.error_count,
message=f"{verb} succeeded; cooldown until {state.cooldown_until.isoformat()}",
)
notifier.notify(
title=f"✅ Inspector: {name} {verb} completed",
message=(
f"**{name}** was successfully {verb}ed by Docker.\n\n"
f"- Previous status: *{result.status}*\n"
f"- Cooldown until: {state.cooldown_until.isoformat()}\n"
f"- Time: {format_timestamp(now)}"
),
priority=3,
)
else:
log.error("%-*s %s failed: %s", width, name, verb, err)
notifier.notify(
title=f"🛑 Inspector: {name} {verb} FAILED",
message=(
f"{verb.capitalize()} of **{name}** failed.\n\n"
f"- Status: **{result.status}**\n"
f"- Error count: **{state.error_count}**\n"
f"- Time: {format_timestamp(now)}\n\n"
f"```\n{err}\n```"
),
priority=9,
)
eventstore.record(
container=name,
event_type=EVENT_RESTART_FAILED,
old_status=state.previous_status,
new_status=result.status,
error_count=state.error_count,
message=str(err),
)
def _perform_portainer_redeploy(
*,
app: AppConfig,
container_config: ContainerConfig,
state: ContainerState,
result: HealthResult,
notifier: GotifyNotifier,
eventstore: EventStore,
portainer_client: PortainerClient,
stack_cooldowns: dict[str, datetime],
dry_run: bool,
width: int,
now: datetime,
) -> None:
name = container_config.name
portainer_config = app.integrations.portainer
stack_name = container_config.portainer_stack_name
if is_stale_vpn_namespace_error(result.message):
log.info("%-*s detected stale VPN network namespace error", width, name)
if not portainer_config.enabled:
log.warning(
"%-*s requests recovery_action 'portainer_stack_redeploy' but "
"integrations.portainer.enabled is false; skipping",
width, name,
)
eventstore.record(
container=name,
event_type=EVENT_PORTAINER_REDEPLOY_SKIPPED,
old_status=state.previous_status,
new_status=result.status,
error_count=state.error_count,
message="integrations.portainer.enabled is false",
)
state.record_restart(now, container_config.cooldown_seconds)
return
environment, error = resolve_portainer_environment(container_config, portainer_config)
if error:
log.error("%-*s %s", width, name, error)
eventstore.record(
container=name,
event_type=EVENT_PORTAINER_REDEPLOY_SKIPPED,
old_status=state.previous_status,
new_status=result.status,
error_count=state.error_count,
message=error,
)
state.record_restart(now, container_config.cooldown_seconds)
return
key = stack_cooldown_key(environment, stack_name)
if in_stack_cooldown(stack_cooldowns, key, now):
log.info("Portainer stack redeploy skipped due to cooldown for %s", key)
eventstore.record(
container=name,
event_type=EVENT_PORTAINER_REDEPLOY_SKIPPED,
old_status=state.previous_status,
new_status=result.status,
error_count=state.error_count,
message=f"cooldown active for {key}",
)
state.record_restart(now, container_config.cooldown_seconds)
return
wait_seconds = portainer_config.post_redeploy_wait_seconds
if dry_run:
log.warning("%-*s DRY RUN: would resolve Portainer environment %s", width, name, environment)
log.warning("%-*s DRY RUN: would redeploy Portainer stack %s", width, name, key)
log.warning("%-*s DRY RUN: would wait %d seconds after stack redeploy", width, name, wait_seconds)
state.record_restart(now, container_config.cooldown_seconds)
eventstore.record(
container=name,
event_type=EVENT_RESTART_DRYRUN,
old_status=state.previous_status,
new_status=result.status,
error_count=state.error_count,
message=f"would redeploy Portainer stack {key}",
)
return
log.info("resolved Portainer environment %s for %s", environment, name)
log.warning("triggering Portainer stack redeploy for environment %s stack %s", environment, stack_name)
notifier.notify(
title=f"🔁 Inspector: redeploying Portainer stack {stack_name}",
message=(
f"Redeploying Portainer stack **{stack_name}** (environment **{environment}**) "
f"to recover **{name}**.\n\n"
f"- Status: **{result.status}**\n"
f"- Time: {format_timestamp(now)}"
),
priority=6,
)
redeploy_result = portainer_client.redeploy_stack(environment, stack_name)
if not redeploy_result.ok:
log.error("%-*s Portainer stack redeploy failed for %s: %s", width, name, key, redeploy_result.message)
notifier.notify(
title=f"🛑 Inspector: Portainer redeploy FAILED for {stack_name}",
message=(
f"Portainer redeploy of **{stack_name}** (environment **{environment}**) failed.\n\n"
f"- Triggered by: **{name}** ({result.status})\n"
f"- Time: {format_timestamp(now)}\n\n"
f"```\n{redeploy_result.message}\n```"
),
priority=9,
)
eventstore.record(
container=name,
event_type=EVENT_PORTAINER_REDEPLOY_FAILED,
old_status=state.previous_status,
new_status=result.status,
error_count=state.error_count,
message=redeploy_result.message,
)
state.record_restart(now, container_config.cooldown_seconds)
return
stack_cooldowns[key] = now + timedelta(seconds=portainer_config.stack_redeploy_cooldown_seconds)
log.info("%-*s Portainer stack redeploy succeeded for %s", width, name, key)
eventstore.record(
container=name,
event_type=EVENT_PORTAINER_REDEPLOY,
old_status=state.previous_status,
new_status=result.status,
error_count=state.error_count,
message=f"redeployed stack {key}",
)
notifier.notify(
title=f"✅ Inspector: Portainer stack {stack_name} redeployed",
message=(
f"Portainer stack **{stack_name}** (environment **{environment}**) was "
f"successfully redeployed to recover **{name}**.\n\n"
f"- Triggered by: *{result.status}*\n"
f"- Post-redeploy wait: {wait_seconds}s\n"
f"- Time: {format_timestamp(now)}"
),
priority=4,
)
log.info("waiting %d seconds after stack redeploy", wait_seconds)
time.sleep(wait_seconds)
if container_config.http_check.enabled:
recheck = check_http(container_config.http_check)
if recheck.ok:
log.info("recheck succeeded for %s", name)
else:
log.warning("recheck failed for %s: %s", name, recheck.message)
notifier.notify(
title=f"⚠️ Inspector: {name} recheck failed after redeploy",
message=(
f"Portainer stack **{stack_name}** was redeployed, but the HTTP recheck "
f"for **{name}** still reports a failure.\n\n"
f"- Recheck result: *{recheck.message}*\n"
f"- Time: {format_timestamp(now)}"
),
priority=6,
)
state.record_restart(now, container_config.cooldown_seconds)
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from .config import ContainerConfig
from .health import NO_HEALTH_CHECK, STARTING
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def localnow() -> datetime:
return datetime.now()
@dataclass
class ContainerState:
name: str
current_status: str | None = None
previous_status: str | None = None
error_count: int = 0
cooldown_until: datetime | None = None
last_restart_at: datetime | None = None
restart_history: deque[datetime] = field(default_factory=lambda: deque(maxlen=64))
last_notification_state: str | None = None
restart_limit_notified: bool = False
restarted_this_episode: bool = False
last_check_at: datetime | None = None
def in_cooldown(self, now: datetime) -> bool:
return self.cooldown_until is not None and now < self.cooldown_until
def in_grace(self, now: datetime, grace_seconds: int) -> bool:
if self.last_restart_at is None or grace_seconds <= 0:
return False
return now < self.last_restart_at + timedelta(seconds=grace_seconds)
def restarts_last_hour(self, now: datetime) -> int:
cutoff = now - timedelta(hours=1)
return sum(1 for ts in self.restart_history if ts >= cutoff)
def record_restart(self, now: datetime, cooldown_seconds: int) -> None:
self.last_restart_at = now
self.cooldown_until = now + timedelta(seconds=cooldown_seconds)
self.restart_history.append(now)
self.error_count = 0
def should_count_failure(
status: str,
container_config: ContainerConfig,
in_grace: bool,
) -> bool:
if status == STARTING and STARTING not in container_config.restart_on:
return False
if status == NO_HEALTH_CHECK and NO_HEALTH_CHECK not in container_config.restart_on:
return False
if status not in container_config.restart_on:
return False
if in_grace:
return False
return True
def should_restart(
state: ContainerState,
container_config: ContainerConfig,
now: datetime,
) -> tuple[bool, str | None]:
if state.in_cooldown(now):
return False, "in cooldown"
if state.error_count < container_config.max_error_count:
return False, "below threshold"
if state.restarts_last_hour(now) >= container_config.max_restarts_per_hour:
return False, "hourly restart limit reached"
return True, None
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
import requests
from .config import UptimeKumaConfig
log = logging.getLogger("inspector.uptime_kuma")
@dataclass(frozen=True)
class HeartbeatSnapshot:
up_by_monitor: dict[str, bool]
def is_up(self, monitor_name: str) -> bool | None:
return self.up_by_monitor.get(monitor_name)
_EMPTY = HeartbeatSnapshot(up_by_monitor={})
class UptimeKumaClient:
"""Polls a public Uptime Kuma status-page heartbeat endpoint.
Always safe to construct and call: disabled config, network errors, or
an unexpected response shape all yield an empty snapshot (every
`is_up()` returns None, meaning "no signal") rather than raising.
"""
def __init__(self, config: UptimeKumaConfig, timeout: float = 5.0):
self._config = config
self._timeout = timeout
@property
def enabled(self) -> bool:
return (
self._config.enabled
and bool(self._config.url)
and bool(self._config.status_page_slug)
)
def fetch(self) -> HeartbeatSnapshot:
if not self.enabled:
return _EMPTY
url = f"{self._config.url}/api/status-page/heartbeat/{self._config.status_page_slug}"
try:
resp = requests.get(url, timeout=self._timeout)
resp.raise_for_status()
data = resp.json()
return self._parse(data)
except Exception as exc:
log.warning("Uptime Kuma heartbeat fetch failed (%s): %s", url, exc)
return _EMPTY
def _parse(self, data: dict) -> HeartbeatSnapshot:
# Expected shape:
# {"publicGroupList": [{"monitorList": [{"id": 1, "name": "..."}]}],
# "heartbeatList": {"1": [{"status": 1, ...}, ...]}} # 1 == up
up_by_monitor: dict[str, bool] = {}
id_to_name: dict[int, str] = {}
for group in data.get("publicGroupList") or []:
for monitor in group.get("monitorList") or []:
mid, mname = monitor.get("id"), monitor.get("name")
if mid is not None and mname:
id_to_name[mid] = mname
heartbeat_list = data.get("heartbeatList") or {}
for mid, name in id_to_name.items():
beats = heartbeat_list.get(str(mid)) or heartbeat_list.get(mid)
if beats:
up_by_monitor[name] = beats[-1].get("status") == 1
return HeartbeatSnapshot(up_by_monitor=up_by_monitor)