From ea1103f52b170055544bafa6d808966336ccdd90 Mon Sep 17 00:00:00 2001 From: Europium 63 Date: Mon, 26 Oct 2026 15:44:55 -0700 Subject: [PATCH] =?UTF-8?q?Initial=20public=20release=20=E2=80=94=20Inspec?= =?UTF-8?q?tor=20v0.7.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .dockerignore | 18 + CHANGELOG.md | 606 +++++++ Dockerfile | 27 + INSTALL.md | 70 + LICENSE | 21 + README.md | 139 ++ compose.yml | 29 + config.example.yml | 137 ++ requirements.txt | 5 + src/inspector/__init__.py | 1 + src/inspector/assets/apple-touch-icon.png | Bin 0 -> 17048 bytes src/inspector/assets/favicon-96x96.png | Bin 0 -> 7793 bytes src/inspector/assets/favicon.ico | Bin 0 -> 15086 bytes src/inspector/assets/favicon.svg | 1 + .../assets/inspector_icon_outline.png | Bin 0 -> 25693 bytes src/inspector/assets/site.webmanifest | 21 + .../assets/web-app-manifest-192x192.png | Bin 0 -> 15084 bytes .../assets/web-app-manifest-512x512.png | Bin 0 -> 36966 bytes src/inspector/config.py | 502 ++++++ src/inspector/dashboard.py | 1601 +++++++++++++++++ src/inspector/docker_client.py | 67 + src/inspector/eventstore.py | 219 +++ src/inspector/formatting.py | 27 + src/inspector/health.py | 71 + src/inspector/health_summary.py | 94 + src/inspector/http_check.py | 65 + src/inspector/logging_setup.py | 74 + src/inspector/main.py | 519 ++++++ src/inspector/maintenance.py | 142 ++ src/inspector/notification_mute.py | 93 + src/inspector/notifier.py | 149 ++ src/inspector/portainer_client.py | 182 ++ src/inspector/recovery.py | 544 ++++++ src/inspector/state.py | 79 + src/inspector/uptime_kuma.py | 76 + 35 files changed, 5579 insertions(+) create mode 100755 .dockerignore create mode 100755 CHANGELOG.md create mode 100755 Dockerfile create mode 100755 INSTALL.md create mode 100755 LICENSE create mode 100755 README.md create mode 100755 compose.yml create mode 100755 config.example.yml create mode 100755 requirements.txt create mode 100755 src/inspector/__init__.py create mode 100755 src/inspector/assets/apple-touch-icon.png create mode 100755 src/inspector/assets/favicon-96x96.png create mode 100755 src/inspector/assets/favicon.ico create mode 100755 src/inspector/assets/favicon.svg create mode 100755 src/inspector/assets/inspector_icon_outline.png create mode 100755 src/inspector/assets/site.webmanifest create mode 100755 src/inspector/assets/web-app-manifest-192x192.png create mode 100755 src/inspector/assets/web-app-manifest-512x512.png create mode 100755 src/inspector/config.py create mode 100755 src/inspector/dashboard.py create mode 100755 src/inspector/docker_client.py create mode 100755 src/inspector/eventstore.py create mode 100755 src/inspector/formatting.py create mode 100755 src/inspector/health.py create mode 100755 src/inspector/health_summary.py create mode 100755 src/inspector/http_check.py create mode 100755 src/inspector/logging_setup.py create mode 100755 src/inspector/main.py create mode 100755 src/inspector/maintenance.py create mode 100755 src/inspector/notification_mute.py create mode 100755 src/inspector/notifier.py create mode 100755 src/inspector/portainer_client.py create mode 100755 src/inspector/recovery.py create mode 100755 src/inspector/state.py create mode 100755 src/inspector/uptime_kuma.py diff --git a/.dockerignore b/.dockerignore new file mode 100755 index 0000000..9dbb0a9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.gitignore +.dockerignore +.aider.conf.yml +__pycache__ +*.pyc +.venv +venv +docs +logo +logs/*.log +logs/*.log.* +config.yml +README.md +AGENTS.md +CLAUDE.md +GEMINI.md +CONVENTIONS.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100755 index 0000000..31df90e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,606 @@ +# Changelog + +All notable changes to Inspector are documented here. + +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +## [0.7.0] — 2026-10-26 + +### Added + +- **Web settings page** at `/settings` for editing `config.yml` without SSH: + per-section forms for Inspector loop, Gotify, Pushover, Dashboard, + Database, Health summary, Maintenance, Notification mute, and per-container + tuning (thresholds, cooldowns, `restart_on`, color). +- Saves round-trip through ruamel.yaml so comments and key order in + `config.yml` are preserved. Each save keeps a `.bak` of the prior file. + (In the Docker container `.bak` lives in the overlay fs because + `config.yml` is a single-file bind mount; the live file is still updated + cleanly.) +- In-process hot reload via a new `ConfigRef`: edits to container-level + settings and `inspector.dry_run`/`heartbeat`/`interval_seconds` take + effect on the next loop iteration without a container restart. Fields + requiring restart are explicitly labeled in the UI. +- CSRF token on all cookie-authenticated POSTs (Settings forms, + maintenance toggle, mute toggle). Basic Auth clients bypass the check + because they prove identity on every request. +- Secret fields (Gotify token, Pushover token/user_key, dashboard password) + render masked and treat the `__UNCHANGED__` sentinel as "keep existing"; + real secret values are never echoed into HTML. + +### Changed + +- `main.run_loop()` now takes a `ConfigRef`, re-reads config each + iteration, and rebuilds `enabled_configs`/`check_order` so edits apply + without a restart. +- `DashboardServer` constructor simplified: now takes `ConfigRef`, + `states`, `maintenance`, `notification_mute` (per-section configs are + resolved live via `ConfigRef.get()`). +- Added `ruamel.yaml==0.18.6` to `requirements.txt`. + +--- + +## [0.6.0] — 2026-10-26 + +### Added + +- **Pushover notifications** as a second independent channel alongside Gotify. + Enable either, both, or neither under `notifications.pushover`; alerts fan + out to every enabled channel. Supports optional `device` and `sound` + overrides. +- Priority mapping from Inspector's Gotify-style integer scale to Pushover's + `-2..2` range: `<=2 → -1`, `3..4 → 0`, `5..6 → 1`, `>=7 → 2`. Emergency + tier (`2`) ships default `retry`/`expire` values and triggers the usual + Pushover acknowledgement prompt. +- `MultiNotifier` internally fans each `notify()` call out to every enabled + channel; a crash in one channel never prevents the others from running. + +--- + +## [0.5.5] — 2026-06-18 + +### Changed + +- Timestamp display format shortened: year and timezone abbreviation (PST/PDT) removed. + Timestamps now render as `MM-DD HH:MM AM/PM` (e.g. `06-18 09:30 AM`). + +--- + +## [0.5.4] — 2026-06-17 + +### Added + +- Dashboard cards now surface **scheduled maintenance** for containers opted into the recurring + daily window (`maintenance_window: true`): + - while idle, a meta-row `🔧 Next maintenance: in ( – )` shows when + the next window begins, with the window times in the dashboard's local format; + - while the window is active, the phase row shows `🔧 In maintenance — ends in ` + so it's clear when normal monitoring resumes. +- All countdowns reuse the existing 1-second client-side ticker (no page reload needed). +- `/api/status` now returns `maintenance_scheduled` (bool) and `maintenance_window_start` / + `maintenance_window_end` (ISO UTC or null) per container. + +--- + +## [0.5.3] — 2026-06-15 + +### Added + +- Dashboard status cards now show a **phase row** below the error bar, indicating what + Inspector is currently doing for each container: + - `⏱ Cooldown — Xs remaining` — post-restart cooldown is active (countdown to expiry) + - `⏱ Grace period — Xs remaining` — container is booting post-restart; failures not counted + - `⚠ Restart limit — resets in Xs` — hourly restart cap hit; countdown to when it clears + - `⚠ Failing X/Y — next check in Zs` — error count is accumulating toward the restart threshold + - `👁 Observe only` — `restart_on: []` is set; Inspector tracks status but never acts + - `🔧 In maintenance` — container is in a maintenance window + - `Next check in Zs` — container is healthy; soft reminder of the monitoring cadence +- All countdown timers update **every second in the browser** without a page reload (JS + `setInterval`); the existing 30 s auto-refresh keeps data fresh. +- `/api/status` now returns `phase`, `phase_until` (ISO UTC or null), `last_check_at` + (ISO UTC or null), and `interval_seconds` per container entry. + +--- + +## [0.5.2] — 2026-06-15 + +### Added + +- Portainer environments now support `api_token: "ptr_..."` to embed the API token + directly in `config.yml`, as an alternative to `api_token_env` (env var lookup). + `api_token` takes precedence when both are set. Useful when injecting env vars into + the Inspector container is inconvenient. +- Improved token-not-found error message: now mentions both `api_token` and + `api_token_env` options so the fix is self-evident in the logs. +- `_find_stack()` now logs a debug line with how many stacks were scanned and which + `endpoint_id` was used, aiding multi-environment Portainer diagnosis. + +--- + +## [0.5.1] — 2026-06-15 + +### Fixed + +- `health_summary` digest crash: `KeyError: 'portainer_redeploy_failed'` on the + first periodic digest when event history contained Portainer redeploy events. + `health_summary._LABELS` was missing the three Portainer event types + (`portainer_redeploy`, `portainer_redeploy_failed`, `portainer_redeploy_skipped`) + that were added to `SUMMARY_EVENT_TYPES` in v0.4.0. + +--- + +## [0.5.0] — 2026-06-16 + +### Added +- New per-container `recovery_action: restart_then_redeploy` — tiered recovery + for VPN-routed containers. Inspector first tries a plain `docker restart` + (which fixes the common case where the VPN sidecar was merely *restarted*, + keeping the same container id) and only escalates to a Portainer stack + redeploy when the restart errors (e.g. the stale-namespace + "No such container" case after the sidecar is *recreated*) or the container + is still failing on the next recovery cycle. Escalation is non-blocking — it + happens on the following cycle, not via an inline wait. Requires + `portainer_stack_name` like `portainer_stack_redeploy`, and honors the same + `depends_on` gating. + +## [0.4.2] — 2026-06-15 + +### Added +- Result notifications for recovery actions: a successful `docker restart` / + `start` now sends a `✅ … completed` notification, and a successful Portainer + stack redeploy sends `✅ … redeployed`. A failed post-redeploy HTTP recheck + sends `⚠️ … recheck failed`. +- "Testing the Portainer API" guide in the README (uses `python3` + `requests`, + since the container image has no `curl`). + +### Changed +- Portainer API errors now include the HTTP status code and response body in the + failure message/log (e.g. `HTTP 401: {"message":"Invalid JWT token"}`), making + misconfigured tokens/endpoints far easier to diagnose. Added intermediate + debug logging for stack lookup and a warning when a stack name isn't found. + +--- + +## [0.4.1] — 2026-06-15 + +### Fixed +- Per-container `restart_on: []` (empty list) now correctly disables all + restart triggers for that container. Previously, an empty list was + treated as "not set" and silently fell back to the default + `[UNHEALTHY, NOT RUNNING]`, so a container couldn't be configured as + observe-only (e.g. for `depends_on` status tracking without Inspector + restarting it itself). Omitting `restart_on` entirely still defaults to + `[UNHEALTHY, NOT RUNNING]`. + +--- + +## [0.4.0] — 2026-06-15 + +### Added +- New per-container `recovery_action`: `restart_container` (default, + unchanged behavior), `start_container` (calls `docker start` instead of + `docker restart`), and `portainer_stack_redeploy`. +- `portainer_stack_redeploy` recovers containers whose + `network_mode: container:` namespace goes stale after the VPN sidecar + (e.g. Gluetun) is recreated — the `"joining network namespace of container: + No such container"` failure a plain restart can't fix. It redeploys the + owning Portainer stack via the Portainer API. New `integrations.portainer` + config: named environments (token loaded from `api_token_env`, never + logged or stored), `default_environment`, `stack_redeploy_cooldown_seconds`, + `post_redeploy_wait_seconds`. New per-container `portainer_environment` / + `portainer_stack_name`. +- New per-container `depends_on` / `vpn_routed`: containers are checked (and + recovered) in dependency order each cycle, so a VPN sidecar is recovered + before its dependents. Recovery is deferred — without consuming the + container's cooldown — while its `depends_on` container isn't + healthy/starting; `vpn_routed: true` logs why. +- Dry-run extended to cover the new actions, including the Portainer + redeploy flow (`DRY RUN: would resolve/redeploy/wait Portainer ...`). +- New event types: `portainer_redeploy`, `portainer_redeploy_failed`, + `portainer_redeploy_skipped`. +- See [Portainer stack redeploy recovery](README.md#portainer-stack-redeploy-recovery) + in the README. +- New per-container notification mute: `notification_mute` config + (`enabled`, `dir`, `default_hours`, default 12h auto-expiring), dashboard + "Mute notifications"/"Unmute notifications" button + "Muted" badge, + `POST /notifications//mute|unmute`. Muting silences outbound Gotify + notifications only — monitoring, failure counting, restarts/recovery, + cooldowns, event history, and the health-summary digest continue + unaffected. New event types `notification_mute_enter`/`exit`. See + [Muting notifications](README.md#muting-notifications) in the README. + +All new fields are optional and backward-compatible; existing configs are +unaffected. `recovery_action: recreate_container` is **not** implemented and +is rejected at config load. + +--- + +## [0.3.1] — 2026-06-14 + +### Changed +- `http_check` now classifies outcomes into three explicit tiers: the + configured `expected_status` = up, a connection refused / timeout / DNS + failure = unreachable (reported as the synthetic status code `0`), and any + other code = "responding but unexpected". Connection-level failures were + already treated as failed checks (→ `UNREACHABLE`); this makes the + distinction explicit in the result (`HttpCheckResult.status_code`) and in + the log / notification messages. Matching remains exact — the README now + documents how to find a container's actual status code. + +--- + +## [0.3.0] — 2026-06-14 + +### Added +- Opt-in "unreachable" detection per container: even when Docker reports a + container as healthy/running, Inspector can independently verify it's + actually reachable via an HTTP/HTML health check (`http_check`) and/or a + shared [Uptime Kuma](https://github.com/louislam/uptime-kuma) status page + (`integrations.uptime_kuma` + per-container `uptime_kuma.monitor_name`). + A failing check on an otherwise-healthy container surfaces as a new + `UNREACHABLE` status (red badge on the dashboard); opt it into a + container's `restart_on` to have it count toward failures/restarts like + any other status. Both checks default to disabled and never crash + Inspector. +- Events page (`/events`) redesigned as a daily view: shows one calendar + day (12:00 AM-11:59 PM US Pacific) at a time, defaulting to today, with + "Prev day" / "Next day" navigation (`?date=YYYY-MM-DD`, also supported by + `/api/events`). The timeline now always renders all 24 hourly markers + (`12 AM`..`11 PM`) so you can scroll directly to a time of day, plus a + trailing "11 PM (yesterday)" section for the 1-hour lookback overlap from + the previous day. + +### Changed +- All dashboard and Gotify notification timestamps now render in US + Pacific time (`PDT`/`PST`, DST-aware) instead of UTC, e.g. + `2026-06-14 03:45 PM PDT`. JSON API endpoints are unaffected and continue + to return raw UTC ISO 8601 timestamps. + +--- + +## [0.2.6] — 2026-06-14 + +### Changed +- Gotify notifications reformatted as Markdown (`extras.client::display.contentType: + text/markdown`) with richer context per message: error counts and + thresholds, previous/current status, Docker error details for failed + restarts, restart/cooldown info, and a formatted timestamp. The + health-history digest is now a Markdown bulleted list with a header. +- Dashboard timestamps (container "Last restart" / "Cooldown until", and + the events timeline) now render as 12-hour time with AM/PM (e.g. + `2026-06-14 03:45 PM UTC`) instead of raw ISO 8601. + +### Fixed +- "Last restart" no longer reverts to "Never" after Inspector itself + restarts: on startup, with `database.enabled: true`, each container's + most recent recorded restart (and any still-active cooldown) is restored + from the event history database. + +--- + +## [0.2.5] — 2026-06-14 + +### Added +- Per-container identity colors on the dashboard: each container card gets + a soft background tint and colored name from a 16-color auto-assigned + palette, and the same color carries over to that container's chip on the + events timeline. Override the auto-assigned color with an optional + `color: "#rrggbb"` field per container in `config.yml`. + +--- + +## [0.2.4] — 2026-06-14 + +### Fixed +- Sign out: removed the redirect-if-authenticated on `GET /login` so sign + out always reaches the login form, even with a cached browser Basic Auth + session. +- Mobile header: version/refresh text now hides on narrow screens, and + sign-out renders as a small outlined button beside the brand. +- Status page error bar: shows 100% green when a container has zero errors + (previously rendered as an invisible 0-width bar) and a proportional red + fill once errors are detected. +- Container cards: hide the cooldown row when no cooldown is active, and + show "Never" instead of "—" when a container has no restart history. +- Events page: the "event history not enabled" message now includes the + `database: enabled: true` config snippet needed to turn it on. + +--- + +## [0.2.3] — 2026-06-14 + +### Changed +- Replaced the browser-native Basic Auth prompt with a styled dark-theme + login page backed by a session cookie (24h expiry); HTTP Basic Auth still + works for API clients. Added a "Sign out" link, and made the dashboard + header, stats grid, and card grid responsive on mobile. + +--- + +## [0.2.2] — 2026-06-14 + +### Added +- Dashboard pages now set a full favicon/app-icon set (favicon.ico, + favicon.svg, favicon-96x96.png, apple-touch-icon.png, web app manifest + icons, site.webmanifest) generated from the Inspector icon. Served from + new `GET /static/` endpoints alongside the existing icon route + (Basic Auth required, same as every other dashboard route). + +--- + +## [0.2.1] — 2026-06-14 + +### Changed +- Web dashboard redesigned: dark "card grid" theme. Each container gets its + own card (status badge, error-count progress bar, cooldown/last-restart + details, maintenance badge and toggle button) instead of a plain table; + the events page renders a colour-coded vertical timeline instead of a + table. The header now shows the Inspector icon plus an "Inspector" / + "Monitor. Restart. Report." wordmark set in + [Google Sans Flex](https://fonts.google.com/specimen/Google+Sans+Flex) + (loaded from Google Fonts; falls back to the system sans-serif if + unavailable). New `GET /static/icon.png` endpoint (Basic Auth required, + same as every other dashboard route) serves the icon from a new + `src/inspector/assets/` package directory. + +--- + +## [0.2.0] — 2026-06-14 + +### Added +- Optional embedded web dashboard (`dashboard.enabled`, default `false`), + protected by HTTP Basic Auth (`dashboard.username` / `dashboard.password`). + If enabled with an empty password, Inspector logs an error and refuses to + start the dashboard (fails closed). Runs in the same process as the + monitoring loop — no extra container, zero new dependencies (stdlib + `http.server`). + - **Status page (`/`, JSON at `/api/status`)** — live per-container table: + current status, error count vs. threshold, cooldown expiry, last restart + time, and whether it's in a maintenance window. Auto-refreshes every + 30 seconds. + - **Events page (`/events`, JSON at `/api/events`)** — last 100 rows from + event history, if `database.enabled: true`. + - **Maintenance toggle** — when `maintenance.enabled: true`, per-container + "Enter/Exit maintenance" buttons `POST /maintenance//enter|exit`, + touching/removing the same marker files described under "Ad-hoc marker + files". Returns `409` if maintenance windows are disabled, `404` for an + unknown container. + New module `src/inspector/dashboard.py`, new config section `dashboard:` + in `config.example.yml`. +- `MaintenanceTracker.peek(name)` — read-only maintenance check (no + transition logging, no eventstore writes) used by the dashboard. +- `tests/test_dashboard.py` — unit tests covering the disabled no-op, the + empty-password refusal, Basic Auth enforcement, the status page and API, + the maintenance toggle (enter/exit, disabled-config 409, unknown-container + 404), and the events page/API with and without event history enabled. + +--- + +## [0.1.11] — 2026-06-14 + +### Added +- Optional maintenance windows (`maintenance.enabled`, default `false`). + Two complementary mechanisms, both gated by this one flag: + - **Recurring daily window** — containers with `maintenance_window: true` + are "in maintenance" every day between `maintenance.start` and + `maintenance.end` (default `00:00`–`06:00`, local time via `TZ`, may + wrap past midnight). + - **Ad-hoc marker files** — `touch`/`rm` + `/` (default + `/app/data/maintenance/`) for unplanned maintenance. A + marker older than `maintenance.max_hours` (default `8`) auto-expires. + + While either mechanism is active for a container, Inspector is fully + silent for it: no failure counting, restarts, notifications, + status-change logging, or event recording. Only one-time + "entering/exiting maintenance window" log lines and new + `maintenance_enter`/`maintenance_exit` events (excluded from the + health-history digest) mark the transitions. State (error counts, + cooldowns, restart history) carries over unchanged across maintenance. + New module `src/inspector/maintenance.py`, new config section + `maintenance:` in `config.example.yml`, new per-container + `maintenance_window` field. +- `tests/test_maintenance.py` — unit tests covering the disabled no-op, + marker-file activation and auto-expiry, the recurring window (including + midnight wraparound), enter/exit transition logging and event recording, + and safe handling of marker-check errors. + +--- + +## [0.1.10] — 2026-06-14 + +### Added +- Optional periodic health-history digest (`health_summary.enabled`, + default `false`). When enabled, Inspector logs a per-container summary + of notable activity (failures, restarts, restart-limit hits, recoveries, + not-found events — status changes are excluded) since the last digest, + every `health_summary.interval_hours` (default `24`). The first digest + fires immediately on startup, covering the last `interval_hours`. The + digest is always logged; it is pushed to Gotify (at + `health_summary.priority`, default `3`) only if any container had + activity in the window. Depends on `database.enabled: true` for real + data — if `health_summary.enabled` is `true` but event history isn't + enabled, Inspector logs a one-time startup warning and digests report + "no issues" for every container. New module + `src/inspector/health_summary.py`, new config section + `health_summary:` in `config.example.yml`. +- `EventStore.summarize(since)` — per-container counts of notable event + types at/after a timestamp, and `EventStore.enabled` property. +- `tests/test_health_summary.py` — unit tests covering the disabled + no-op, the database-disabled warning, immediate first report with a + capped window, interval gating, message formatting, and Gotify + activity gating. + +--- + +## [0.1.9] — 2026-06-13 + +### Added +- Optional SQLite event history (`database.enabled`, default `false`). + When enabled, Inspector persists notable per-container events (status + changes, recoveries, failures, restarts, restart-limit hits, + container-not-found) to a local SQLite database at `database.path` + (default `/app/data/events.db`), surviving container restarts. Old + events are pruned according to `database.retention_days` (default 30) + on startup and once per 24h thereafter. The database is opened in WAL + mode for future concurrent read access (e.g. a read-only dashboard). + A database error is logged but never crashes Inspector. New module + `src/inspector/eventstore.py`, new config section `database:` in + `config.example.yml`, new `./data` volume mount in `compose.yml` / + `compose.prod.yml`, new `/app/data` directory in `Dockerfile`. +- `tests/test_eventstore.py` — unit tests covering the disabled no-op, + row insertion, WAL mode, and retention pruning. + +### Changed +- `_check_one()` signature gains an `eventstore: EventStore` parameter + (always passed; no-ops internally when `database.enabled` is `false`), + mirroring the existing `notifier: GotifyNotifier` pattern. +- README "What Inspector does not do" updated: live `ContainerState` is + still in-memory and resets on restart, but event *history* can now + persist via the new opt-in SQLite database. + +--- + +## [0.1.8] — 2026-06-13 + +### Changed +- Log format overhauled for readability: container names are now + left-padded into a fixed-width column, the redundant logger-name field + (`inspector:`) was dropped from both the file and console formatters, and + a blank line now brackets each failure → restart → recovery "incident" so + it stands out as a block. Message wording adjusted accordingly (e.g. + `gotify Restarting due to 3 consecutive UNHEALTHY checks` instead of + `Restarting gotify due to 3 consecutive UNHEALTHY checks`). + +--- + +## [0.1.7] — 2026-06-13 + +### Fixed +- Docker socket permission errors at startup no longer crash with a raw + Python traceback. `run_loop()` now catches the connection failure and + logs an actionable message pointing at `DOCKER_GID` + (`stat -c '%g' /var/run/docker.sock`). + +--- + +## [0.1.6] — 2026-06-13 + +### Added +- `tests/test_config.py` — 4 unit tests covering config loading, defaults, + and validation (non-mapping containers rejected, unknown `restart_on` + statuses rejected). +- `tests/test_health.py` — 8 unit tests covering all Docker status + classifications (HEALTHY, UNHEALTHY, STARTING, NO HEALTH CHECK, + NOT RUNNING, UNKNOWN, missing state, ERROR constant). +- `tests/test_state.py` — 10 unit tests covering failure counting, restart + decision logic, and the full per-container state machine (dry-run, + grace period, cooldown, hourly restart limit, recovery). + +### Changed +- CI workflow now runs the full unit test suite + (`python3 -m unittest discover -s tests -v`) before building the image. + +--- + +## [0.1.5] — 2026-06-13 + +### Changed +- CI and release workflows reverted to `runs-on: ubuntu-latest` (the + self-hosted runner approach introduced in v0.1.3 was abandoned — the + Gitea-hosted runner handles builds reliably). +- Gitea runner setup guide (`docs/gitea_runner_setup.md`, + `ops/gitea-runner/`) updated with label-mismatch troubleshooting and + clearer registration instructions. + +### Added +- README documents the verified release baseline (v0.1.5) and the + step-by-step release flow. + +--- + +## [0.1.4] — 2026-06-13 + +### Added +- Inspector logo and wordmark assets: `logo/inspector_icon.{png,svg}`, + `logo/inspector_wordmark.{png,svg}`, `logo/inspector_icon_chip.{png,svg}`. +- README header now displays the Inspector wordmark image. + +### Changed +- Previous logo files moved to `logo/archive/`. +- `.gitignore` — added `act_data/` (local `act` runner data directory). + +--- + +## [0.1.3] — 2026-06-13 + +### Changed +- CI and release workflows switched to `runs-on: self-hosted`. +- CI simplified to use `docker build` directly (removed + `docker/setup-buildx-action` dependency). + +--- + +## [0.1.2] — 2026-06-13 + +### Changed +- Release workflow rewritten to use native `docker login` / `docker push` + shell commands, replacing the `docker/build-push-action` third-party + action. + +### Added +- `workflow_dispatch` trigger on the release workflow — enables manual + image builds without a tag push (useful for testing the CI publish path). + +--- + +## [0.1.1] — 2026-06-13 + +### Fixed +- Release workflow secret names corrected: + `GITEA_REGISTRY_USER` → `REGISTRY_USER`, + `GITEA_REGISTRY_TOKEN` → `REGISTRY_TOKEN`. + The v0.1.0 release workflow referenced the wrong secret names, causing + the publish step to fail. + +--- + +## [0.1.0] — 2026-06-13 + +### Added +- Initial Inspector v1: self-hosted Python Docker container health watcher. +- Multi-container support via a single `config.yml` — replaces the old + pattern of one script + one systemd service per watched container. +- Per-container state machine with independent failure counters. +- Health status classification: HEALTHY, UNHEALTHY, STARTING, + NO HEALTH CHECK, NOT RUNNING, CONTAINER NOT FOUND, ERROR, UNKNOWN. +- Restart logic: fires after `max_error_count` consecutive failures. +- Cooldown: post-restart window that blocks further restarts. +- Grace period: post-restart window that suppresses failure counting while + a container boots. +- Hourly restart cap (`max_restarts_per_hour`) to break endless loops; + fires a critical Gotify alert when hit. +- Optional Gotify notifications on state transitions (not per-loop). +- Dry-run mode: logs decisions without executing restarts. +- Rotating log file (`TimedRotatingFileHandler`, midnight, 30-day retention) + with colored console output and plain-text file output. +- `Dockerfile` — `python:3.12-slim` base, non-root `inspector` user + (UID/GID 1000). +- `compose.yml` — dev compose: builds from source, mounts Docker socket. +- `compose.prod.yml` — prod compose: pulls published image from Gitea + registry, no local build. +- `requirements.txt` — pinned: `docker==7.1.0`, `PyYAML==6.0.2`, + `requests==2.32.3`, `colorlog==6.9.0`. +- `Makefile` with `build`, `smoke`, `compose-config`, `tag`, `push-local`, + `release`, `clean`, `version`, `help` targets. +- Gitea Actions CI (`ci.yml`): byte-compile + Docker build on every push. +- Gitea Actions release (`release.yml`): build and publish four image tags + on `v*.*.*` tag push. +- `scripts/bump_version.sh` — updates `__version__` in `src/inspector/__init__.py`. +- `scripts/smoke_test.sh` — end-to-end dry-run test against the live + Docker socket. +- README with full feature documentation, configuration reference, + Portainer deployment guide, and troubleshooting table. diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000..b884186 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY src/ /app/src/ +RUN chmod -R a+rX /app/src + +ENV PYTHONPATH=/app/src + +ARG INSPECTOR_UID=1000 +ARG INSPECTOR_GID=1000 +RUN groupadd --gid ${INSPECTOR_GID} inspector \ + && useradd --uid ${INSPECTOR_UID} --gid inspector --home-dir /app --shell /usr/sbin/nologin inspector \ + && mkdir -p /app/logs /app/data \ + && chown -R inspector:inspector /app/logs /app/data + +USER inspector + +CMD ["python", "-m", "inspector.main", "--config", "/app/config.yml"] diff --git a/INSTALL.md b/INSTALL.md new file mode 100755 index 0000000..ef6ccec --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,70 @@ +# Install + +Inspector watches Docker containers, restarts the ones you tell it to, +and notifies you via Gotify and/or Pushover. All configuration lives in +a single `config.yml` that you can edit by hand or from the built-in web +settings page. + +## Requirements + +- Docker Engine 24+ and `docker compose` v2. +- A machine that can reach `/var/run/docker.sock` (any Docker host). +- Optional: a Gotify server and/or a Pushover account for notifications. + +## Quickstart + +```bash +# 1. Grab the compose file and example config +mkdir -p ~/docker/inspector && cd ~/docker/inspector +curl -L -o compose.yml https://gitea.euronvault.com/euronvault/inspector/raw/branch/main/compose.yml +curl -L -o config.yml https://gitea.euronvault.com/euronvault/inspector/raw/branch/main/config.example.yml + +# 2. Edit config.yml — at minimum, list the containers you want watched +# under the `containers:` key. +$EDITOR config.yml + +# 3. Set your Docker group GID so the container can read the socket. +echo "DOCKER_GID=$(getent group docker | cut -d: -f3)" > .env + +# 4. Pull and run +docker compose pull +docker compose up -d +docker compose logs -f inspector +``` + +If `dashboard.enabled: true` in your config, visit +`http://:8080/` after startup to see the dashboard, and +`http://:8080/settings` to edit the config from the browser. + +## Pinning a version + +Prefer an immutable tag over `latest` in production: + +```bash +# .env +INSPECTOR_TAG=0.7.0 +``` + +Images are published at `gitea.euronvault.com/euronvault/inspector`. + +## Upgrading + +```bash +docker compose pull +docker compose up -d +``` + +Your `config.yml` is persisted outside the container, so upgrades don't +touch it. The settings page keeps a `.bak` of the previous file next to +`config.yml` on every save (when the directory is bind-mounted; see the +README for the Docker caveat). + +## Uninstall + +```bash +docker compose down +rm -rf logs data config.yml compose.yml .env +``` + +Inspector doesn't store anything outside the directory it was started +from. diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..89c7ad8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Europium 63 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100755 index 0000000..e1a1b0e --- /dev/null +++ b/README.md @@ -0,0 +1,139 @@ +# Inspector + +Inspector is a small, opinionated Docker-watchdog. It polls the Docker +daemon, applies per-container restart rules, pings Gotify and/or +Pushover when things go wrong, and gives you a dashboard that shows +what's happening right now and what happened earlier today. + +Built for home-lab and small-fleet operators who want "it just works" +container recovery without standing up a full observability stack. + +## What Inspector does + +- Polls each configured container on a fixed interval and classifies it: + `HEALTHY`, `UNHEALTHY`, `STARTING`, `NOT RUNNING`, `ERROR`, + `UNREACHABLE`, `CONTAINER NOT FOUND`, `NO HEALTH CHECK`, `UNKNOWN`. +- Restarts a container when it crosses a configured error threshold — + with cooldowns, grace periods, and an hourly restart cap so a flapping + container doesn't DDoS your host. +- Opt-in HTTP reachability checks per container (useful for VPN-routed + containers whose tunnel drops while Docker still reports "healthy"). +- Opt-in Uptime Kuma integration as a second reachability source. +- Advanced recovery via Portainer stack redeploy for containers whose + network namespace is tied to a VPN sidecar. +- Sends notifications on transitions, not on every poll — one line when + something breaks, one line when it recovers. +- Optional SQLite event history + a daily/weekly digest. +- Scheduled maintenance windows (don't act during 2–6 AM). +- Manual notification mute per container, from the dashboard. +- Embedded web dashboard + events timeline + **web settings page** for + editing `config.yml` without SSH. + +## What Inspector is not + +- Not a metrics system. It doesn't scrape Prometheus or push + dashboards; use Grafana / Netdata / your real monitoring stack for + that. +- Not an orchestrator. It only acts on containers you've explicitly + listed in `config.yml`. +- Not multi-host. One Inspector per Docker socket. + +## Install + +See [`INSTALL.md`](./INSTALL.md). TL;DR: + +```bash +mkdir -p ~/docker/inspector && cd ~/docker/inspector +curl -L -o compose.yml https://gitea.euronvault.com/euronvault/inspector/raw/branch/main/compose.yml +curl -L -o config.yml https://gitea.euronvault.com/euronvault/inspector/raw/branch/main/config.example.yml +$EDITOR config.yml +echo "DOCKER_GID=$(getent group docker | cut -d: -f3)" > .env +docker compose up -d +``` + +## Configuration + +Everything lives in `config.yml` next to the compose file. Start from +[`config.example.yml`](./config.example.yml) — every key is commented. +Highlights: + +- `inspector.interval_seconds` — poll cadence. +- `inspector.dry_run` — classify + log, never restart. +- `notifications.gotify` / `notifications.pushover` — enable either, + both, or neither. +- `containers..max_error_count` / `cooldown_seconds` / + `grace_period_seconds` / `max_restarts_per_hour` — the restart knobs. +- `containers..restart_on` — which statuses trigger a restart + (leave empty for observe-only). +- `containers..http_check` — opt-in HTTP ping. +- `containers..recovery_action` — `restart_container` (default) + or the advanced Portainer-based options. + +### Dashboard and settings page + +If `dashboard.enabled: true`, Inspector exposes a small web UI on +`:8080`: + +- `/` — container cards with current status, error bar, phase + (cooldown / grace / failing / observe only / maintenance), and + maintenance / mute toggles. +- `/events` — per-day timeline of status changes, restarts, recoveries, + and failures (requires `database.enabled: true`). +- `/settings` — forms for every top-level config section plus one card + per container. Saves preserve comments and key order in `config.yml` + and keep a `.bak`. Fields that take effect on the next loop iteration + (per-container thresholds, `dry_run`, `heartbeat`, `interval_seconds`) + hot-apply; fields bound at startup are labeled **requires restart**. + +Note on `.bak` inside Docker: when `config.yml` is bind-mounted as a +single file (the compose default), the `.bak` lives in the container's +overlay fs — the live file still updates cleanly, but the backup is +lost when the container is removed. To keep the `.bak` on the host, +bind-mount the directory instead of the single file. + +### Notifications + +Both channels are independent. If both are enabled, every alert goes to +both. Inspector sends on transitions: + +``` +🚨 first failure threshold crossed +🔁 container restart triggered +✅ restart / start completed +✅ container recovered +🛑 restart failed / hourly limit exceeded +❓ configured container not found +``` + +Gotify messages are Markdown; Pushover doesn't render Markdown so the +`**bold**` passthrough is cosmetic — bodies are identical. + +Pushover priorities map from Inspector's Gotify-style scale (default 5) +to Pushover's `-2..2` range: `<=2 → -1`, `3..4 → 0`, `5..6 → 1`, +`>=7 → 2`. Priority 2 (emergency) ships default `retry` / `expire` and +your Pushover client prompts for acknowledgement. + +## Docker socket security + +Inspector talks to `/var/run/docker.sock`. That socket is effectively +root on your host: anything that can write to it can start, stop, +inspect, and (through bind mounts) read any file as root. Inspector +mounts it read-write because it needs to restart containers. + +Follow the usual home-lab precautions: + +- Don't publish the dashboard port on an untrusted network. Use a + reverse proxy with auth, a tailnet, or SSH tunneling. +- Set a strong `dashboard.password`. +- Store Portainer / Gotify / Pushover tokens in the config file on a + host-only path with permissions `600`. + +## Versioning + +Semantic versioning (`MAJOR.MINOR.PATCH`). Image tags are published for +every release; `latest` tracks the newest tag. Pin a specific version +in production — see `INSTALL.md`. + +## License + +[MIT](./LICENSE). diff --git a/compose.yml b/compose.yml new file mode 100755 index 0000000..1f5e391 --- /dev/null +++ b/compose.yml @@ -0,0 +1,29 @@ +# Inspector compose file for buddies / self-hosters. +# Pulls the published image from gitea.euronvault.com. +# +# Before `docker compose up -d`: +# 1. Copy config.example.yml -> config.yml and edit it. +# 2. Set DOCKER_GID to your host's docker group GID: +# getent group docker | cut -d: -f3 +# Export it in your shell or put it in a .env file next to this file. +# 3. Optionally set INSPECTOR_TAG to pin a version +# (default: latest). Prefer a pinned X.Y.Z for production. +services: + inspector: + image: gitea.euronvault.com/euronvault/inspector:${INSPECTOR_TAG:-latest} + container_name: inspector + restart: unless-stopped + # GID of the docker group on the host so the non-root user inside the + # container can read /var/run/docker.sock. Required. + group_add: + - "${DOCKER_GID:-999}" + ports: + # Only published if dashboard.enabled is true in config.yml. + - "${INSPECTOR_PORT:-8080}:8080" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./config.yml:/app/config.yml + - ./logs:/app/logs + - ./data:/app/data + environment: + - TZ=${TZ:-UTC} diff --git a/config.example.yml b/config.example.yml new file mode 100755 index 0000000..b7f77be --- /dev/null +++ b/config.example.yml @@ -0,0 +1,137 @@ +# Inspector example configuration. +# Copy to config.yml, edit, and mount into the container at /app/config.yml. + +inspector: + interval_seconds: 60 + log_level: INFO + dry_run: false + log_dir: /app/logs + log_filename: inspector.log + log_retention_days: 30 + heartbeat: false + +notifications: + # Gotify: https://gotify.net + gotify: + enabled: false + url: "" + token: "" + priority: 5 + + # Pushover: https://pushover.net + # Independent of Gotify — enable either, both, or neither. If both are + # enabled, every alert is sent to both. Inspector's priority is an + # integer on a Gotify-style scale (default 5) and is mapped to + # Pushover's -2..2 range at send time: <=2 -> -1, 3..4 -> 0, + # 5..6 -> 1, >=7 -> 2. + pushover: + enabled: false + token: "" # Pushover application API token + user_key: "" # Pushover user or group key + priority: 5 + device: "" # optional target device; blank = all devices + sound: "" # optional sound name; blank = user default + +# Persist a durable per-container event history to a local SQLite DB. +# Enables the /events page and the Health summary digest. +database: + enabled: false + path: /app/data/events.db + retention_days: 30 + +# Periodic digest of per-container events, delivered through the enabled +# notification channels. Requires database.enabled: true to have data. +health_summary: + enabled: false + interval_hours: 24 + priority: 3 + +# Recurring daily maintenance window. Containers that opt in with +# `maintenance_window: true` are not acted on during this window. +maintenance: + enabled: false + start: "00:00" + end: "06:00" + dir: /app/data/maintenance + max_hours: 8 + +# Manual notification muting per container (toggled from the dashboard). +notification_mute: + enabled: false + dir: /app/data/muted + default_hours: 12 + +# Embedded web dashboard. Enable with a non-empty password. +# Visit http://:/ for status, /events for history, +# /settings to edit this file from the browser. +dashboard: + enabled: false + host: 0.0.0.0 + port: 8080 + username: admin + password: "" + +# Optional external integrations. +integrations: + # Uptime Kuma: optionally use a public status page as an extra + # reachability check per container. + uptime_kuma: + enabled: false + url: "https://status.example.com" + status_page_slug: "default" + + # Portainer API integration: used by the advanced recovery actions + # `portainer_stack_redeploy` and `restart_then_redeploy`, which can + # recover containers whose network namespace is tied to a VPN sidecar. + # Leave disabled unless you need it. + # + # Token options (pick one per environment): + # api_token_env: "ENV_VAR_NAME" — reads token from that env var at + # runtime (preferred; not stored in config) + # api_token: "ptr_..." — embed token directly in this file + portainer: + enabled: false + default_environment: local_docker + stack_redeploy_cooldown_seconds: 300 + post_redeploy_wait_seconds: 90 + environments: + local_docker: + base_url: "http://portainer.example.local:9000" + api_token_env: "PORTAINER_API_TOKEN" + endpoint_id: 1 + verify_ssl: false + +containers: + # Minimal entry: Inspector only acts on containers listed here. + # Each key is the Docker container name on your host. + example-web: + enabled: true + max_error_count: 5 + cooldown_seconds: 300 + grace_period_seconds: 120 + max_restarts_per_hour: 3 + restart_on: + - UNHEALTHY + - NOT RUNNING + - ERROR + + # Example with an opt-in HTTP reachability check. Useful when Docker + # reports a container as healthy but it's not actually serving + # requests (common with VPN-routed containers whose tunnel drops). + example-api: + enabled: true + max_error_count: 5 + cooldown_seconds: 300 + grace_period_seconds: 120 + max_restarts_per_hour: 3 + color: "#60a5fa" + restart_on: + - UNHEALTHY + - NOT RUNNING + - ERROR + - UNREACHABLE + http_check: + enabled: true + url: "http://example-api:8080/health" + timeout_seconds: 5 + expected_status: 200 diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..253a993 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +docker==7.1.0 +PyYAML==6.0.2 +requests==2.32.3 +colorlog==6.9.0 +ruamel.yaml==0.18.6 diff --git a/src/inspector/__init__.py b/src/inspector/__init__.py new file mode 100755 index 0000000..49e0fc1 --- /dev/null +++ b/src/inspector/__init__.py @@ -0,0 +1 @@ +__version__ = "0.7.0" diff --git a/src/inspector/assets/apple-touch-icon.png b/src/inspector/assets/apple-touch-icon.png new file mode 100755 index 0000000000000000000000000000000000000000..f931a4e8966bfbf9ece7a11f44559dd5fa15ab7b GIT binary patch literal 17048 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 NjiMk>#yb4=e*uudD^&mh literal 0 HcmV?d00001 diff --git a/src/inspector/assets/favicon.svg b/src/inspector/assets/favicon.svg new file mode 100755 index 0000000..9fa4569 --- /dev/null +++ b/src/inspector/assets/favicon.svg @@ -0,0 +1 @@ +RealFaviconGeneratorhttps://realfavicongenerator.net \ No newline at end of file diff --git a/src/inspector/assets/inspector_icon_outline.png b/src/inspector/assets/inspector_icon_outline.png new file mode 100755 index 0000000000000000000000000000000000000000..e9e5e25db033ce16cf9026305dcff0b471922c3b GIT binary patch literal 25693 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 literal 0 HcmV?d00001 diff --git a/src/inspector/assets/site.webmanifest b/src/inspector/assets/site.webmanifest new file mode 100755 index 0000000..2076f60 --- /dev/null +++ b/src/inspector/assets/site.webmanifest @@ -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" +} diff --git a/src/inspector/assets/web-app-manifest-192x192.png b/src/inspector/assets/web-app-manifest-192x192.png new file mode 100755 index 0000000000000000000000000000000000000000..1273bc90367b682242e390c41fc07f68cd740b89 GIT binary patch literal 15084 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 adk*G_&AK*fqIfGnFaaBbH(d6K$^8eRin!DO literal 0 HcmV?d00001 diff --git a/src/inspector/config.py b/src/inspector/config.py new file mode 100755 index 0000000..863537c --- /dev/null +++ b/src/inspector/config.py @@ -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 diff --git a/src/inspector/dashboard.py b/src/inspector/dashboard.py new file mode 100755 index 0000000..7edd4e4 --- /dev/null +++ b/src/inspector/dashboard.py @@ -0,0 +1,1601 @@ +from __future__ import annotations + +import json +import logging +import re +import secrets +import sqlite3 +import threading +import time +from base64 import b64decode +from datetime import date, datetime, timedelta, timezone +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from importlib import resources +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +from . import __version__ +from .config import ( + AppConfig, + ConfigRef, + ContainerConfig, + build_config_from_raw, + write_config_raw, +) +from .formatting import PACIFIC, format_timestamp +from .health import CONTAINER_NOT_FOUND, ERROR, HEALTHY, NOT_RUNNING, UNHEALTHY, UNREACHABLE +from .maintenance import MaintenanceTracker +from .notification_mute import NotificationMuteTracker +from .state import ContainerState, utcnow + + +log = logging.getLogger("inspector.dashboard") + +_SESSION_MAX_AGE = 86400 # seconds a login cookie stays valid +_UNCHANGED_SECRET_PLACEHOLDER = "__UNCHANGED__" # UI sentinel: keep existing secret value + +# Config keys that require an Inspector container restart to take effect. +# Everything else edited through /settings hot-applies on the next loop +# iteration via ConfigRef. +_RESTART_REQUIRED_SECTIONS = { + "inspector_logging", # log_dir/log_filename/log_level/log_retention_days + "database", + "health_summary", + "maintenance", + "notification_mute", + "dashboard", # host/port bound at startup; username/password hot-apply + "integrations", +} + +# 16-color palette (Tailwind 400-level), evenly spread around the hue wheel. +# Used for auto-assigning container identity colors; wraps at 16. +_CONTAINER_PALETTE = [ + "#60a5fa", # blue + "#34d399", # emerald + "#f472b6", # pink + "#fb923c", # orange + "#a78bfa", # violet + "#2dd4bf", # teal + "#fbbf24", # amber + "#818cf8", # indigo + "#e879f9", # fuchsia + "#22d3ee", # cyan + "#4ade80", # green + "#c084fc", # purple + "#38bdf8", # sky + "#fb7185", # rose + "#a3e635", # lime + "#facc15", # yellow +] + +_HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$") + + +def _resolve_color(cfg: ContainerConfig, index: int) -> str: + c = cfg.color.strip() + return c if _HEX_COLOR_RE.match(c) else _CONTAINER_PALETTE[index % len(_CONTAINER_PALETTE)] + + +def _color_map( + states: dict[str, ContainerState], + containers: dict[str, ContainerConfig], +) -> dict[str, str]: + return {name: _resolve_color(containers[name], i) for i, name in enumerate(states)} + + +def _compute_phase( + state: ContainerState, + cfg: ContainerConfig, + maintenance_active: bool, + now: datetime, +) -> tuple[str, datetime | None]: + """Return (phase_name, phase_until) for the current Inspector logic state. + + phase_until is the UTC datetime when this phase is expected to end, or None + when the phase has no fixed end time (maintenance, observe_only, ok, failing). + """ + if maintenance_active: + return "maintenance", None + if state.in_cooldown(now): + return "cooldown", state.cooldown_until + if state.in_grace(now, cfg.grace_period_seconds) and state.last_restart_at is not None: + grace_until = state.last_restart_at + timedelta(seconds=cfg.grace_period_seconds) + return "grace", grace_until + if state.restarts_last_hour(now) >= cfg.max_restarts_per_hour: + hour_ago = now - timedelta(seconds=3600) + recent = sorted(t for t in state.restart_history if t >= hour_ago) + limit_until = (min(recent) + timedelta(seconds=3600)) if recent else None + return "restart_limit", limit_until + if not cfg.restart_on: + return "observe_only", None + if state.error_count > 0: + return "failing", None + return "ok", None + + +def _status_snapshot( + states: dict[str, ContainerState], + containers: dict[str, ContainerConfig], + maintenance: MaintenanceTracker, + notification_mute: NotificationMuteTracker, + interval_seconds: int = 60, +) -> list[dict]: + now = utcnow() + rows = [] + for i, (name, state) in enumerate(states.items()): + cfg = containers[name] + cooldown_until = state.cooldown_until + last_restart_at = state.last_restart_at + muted_until = notification_mute.muted_until(name) + maintenance_active = maintenance.peek(name) + phase, phase_until = _compute_phase(state, cfg, maintenance_active, now) + window = maintenance.next_scheduled_window(name) + if window is not None: + window_start = window[0].astimezone(timezone.utc).isoformat() + window_end = window[1].astimezone(timezone.utc).isoformat() + else: + window_start = window_end = None + rows.append( + { + "name": name, + "current_status": state.current_status, + "error_count": state.error_count, + "max_error_count": cfg.max_error_count, + "cooldown_until": cooldown_until.isoformat() if cooldown_until else None, + "last_restart_at": last_restart_at.isoformat() if last_restart_at else None, + "maintenance_active": maintenance_active, + "maintenance_scheduled": window is not None, + "maintenance_window_start": window_start, + "maintenance_window_end": window_end, + "notification_muted": muted_until is not None, + "notification_muted_until": muted_until.isoformat() if muted_until else None, + "color": _resolve_color(cfg, i), + "phase": phase, + "phase_until": phase_until.isoformat() if phase_until else None, + "last_check_at": state.last_check_at.isoformat() if state.last_check_at else None, + "interval_seconds": interval_seconds, + } + ) + return rows + + +def _parse_date_param(value: str | None) -> date: + """Parse a ?date=YYYY-MM-DD query param; falls back to "today" in + America/Los_Angeles on missing/invalid input.""" + if value: + try: + return date.fromisoformat(value) + except ValueError: + pass + return datetime.now(PACIFIC).date() + + +def _day_bounds_utc(day: date) -> tuple[datetime, datetime]: + """UTC [start, end) bounds for a Pacific calendar day, extended one hour + into the previous day for the lookback-overlap.""" + midnight = datetime.combine(day, datetime.min.time(), tzinfo=PACIFIC) + start = (midnight - timedelta(hours=1)).astimezone(timezone.utc) + end = (midnight + timedelta(days=1)).astimezone(timezone.utc) + return start, end + + +def _read_events_range(db_path: str, start: datetime, end: datetime) -> list[dict]: + try: + conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + try: + cur = conn.execute( + "SELECT ts, container, event_type, old_status, new_status, error_count, message " + "FROM events WHERE ts >= ? AND ts < ? ORDER BY ts DESC", + (start.isoformat(), end.isoformat()), + ) + columns = [c[0] for c in cur.description] + return [dict(zip(columns, row)) for row in cur.fetchall()] + finally: + conn.close() + except Exception as exc: + log.debug("dashboard: event history unavailable: %s", exc) + return [] + + +def _hour_markers(day: date) -> list[datetime]: + """24 Pacific-local hour boundaries for `day`, 00:00..23:00 ascending.""" + midnight = datetime.combine(day, datetime.min.time(), tzinfo=PACIFIC) + return [midnight + timedelta(hours=h) for h in range(24)] + + +def _group_events_by_hour( + events: list[dict], day: date +) -> tuple[list[dict], dict[datetime, list[dict]]]: + """Split `events` (ts as UTC ISO strings) into: + - buckets: each of the 24 hour-marker datetimes (Pacific) -> events + whose Pacific-local hour matches (newest-first, preserving input order) + - overlap: events from the previous day's 11pm-midnight Pacific hour + (the 1hr lookback window) + """ + markers = _hour_markers(day) + buckets: dict[datetime, list[dict]] = {m: [] for m in markers} + overlap: list[dict] = [] + prev_hour_start = markers[0] - timedelta(hours=1) + + for ev in events: + try: + ts = datetime.fromisoformat(ev["ts"]).astimezone(PACIFIC) + except (ValueError, KeyError): + continue + if prev_hour_start <= ts < markers[0]: + overlap.append(ev) + continue + hour_start = ts.replace(minute=0, second=0, microsecond=0) + if hour_start in buckets: + buckets[hour_start].append(ev) + + return overlap, buckets + + +def _escape(value: object) -> str: + text = "" if value is None else str(value) + return ( + text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +_STATUS_BADGE_CLASS = { + HEALTHY: "healthy", + UNHEALTHY: "unhealthy", + NOT_RUNNING: "unhealthy", + CONTAINER_NOT_FOUND: "unhealthy", + ERROR: "unhealthy", + UNREACHABLE: "unhealthy", +} + + +def _status_badge_class(status: str | None) -> str: + return _STATUS_BADGE_CLASS.get(status or "", "muted") + + +_EVENT_DOT_CLASS = { + "restart": "restart", + "restart_dry_run": "restart", + "restart_failed": "failure", + "restart_limit": "failure", + "failure": "failure", + "not_found": "failure", + "recovery": "recovery", + "maintenance_enter": "maintenance", + "maintenance_exit": "maintenance", +} + + +def _event_dot_class(event_type: str) -> str: + return _EVENT_DOT_CLASS.get(event_type, "") + + +def _load_asset(name: str) -> bytes: + try: + return (resources.files(__package__) / "assets" / name).read_bytes() + except Exception as exc: + log.debug("dashboard: could not load asset %r: %s", name, exc) + return b"" + + +_ICON_PNG = _load_asset("inspector_icon_outline.png") + +# Filename -> Content-Type, for files in src/inspector/assets/ served at /static/. +_STATIC_FILES = { + "icon.png": "image/png", + "favicon.ico": "image/x-icon", + "favicon.svg": "image/svg+xml", + "favicon-96x96.png": "image/png", + "apple-touch-icon.png": "image/png", + "web-app-manifest-192x192.png": "image/png", + "web-app-manifest-512x512.png": "image/png", + "site.webmanifest": "application/manifest+json", +} +_STATIC_ASSETS = { + "icon.png": _ICON_PNG, + **{name: _load_asset(name) for name in _STATIC_FILES if name != "icon.png"}, +} + + +_STYLE = """""" + + +def _page_head(refresh: bool, active: str) -> str: + refresh_meta = '\n' if refresh else "" + status_class = "active" if active == "status" else "" + events_class = "active" if active == "events" else "" + settings_class = "active" if active == "settings" else "" + meta_text = f"v{__version__}" + if refresh: + meta_text += " · auto-refreshes every 30s" + return ( + "\n" + '\n\n' + '\n' + '\n' + "Inspector\n" + '\n' + '\n' + '\n' + '\n' + '\n' + + refresh_meta + + '\n' + '\n' + '\n' + + _STYLE + + "\n\n\n" + "
\n" + '
\n' + ' Inspector\n' + "
\n" + '
Inspector
\n' + '
Monitor. Restart. Report.
\n' + "
\n" + "
\n" + '
\n' + f' Status\n' + f' Events\n' + f' Settings\n' + "
\n" + f'
{meta_text}Sign out
\n' + "
\n" + "
\n" + ) + + +_PAGE_TAIL = ( + "
\n" + f"
Inspector v{__version__} — Monitor. Restart. Report.
\n" + "\n" + "\n\n" +) + + +def _render_status_page( + rows: list[dict], + maintenance_enabled: bool, + notification_mute_enabled: bool, + csrf_token: str, +) -> bytes: + healthy = sum(1 for r in rows if _status_badge_class(r["current_status"]) == "healthy") + issues = sum(1 for r in rows if _status_badge_class(r["current_status"]) == "unhealthy") + in_maintenance = sum(1 for r in rows if r["maintenance_active"]) + + parts = [_page_head(refresh=True, active="status")] + parts.append('
\n') + parts.append( + f'
' + f'
{len(rows)}
Containers
\n' + ) + parts.append( + f'
' + f'
{healthy}
Healthy
\n' + ) + parts.append( + f'
' + f'
{issues}
Issues
\n' + ) + parts.append( + f'
' + f'
{in_maintenance}
In maintenance
\n' + ) + parts.append("
\n") + + parts.append('

Containers

\n') + parts.append('
\n') + for row in rows: + name = _escape(row["name"]) + status = row["current_status"] or "UNKNOWN" + max_error_count = row["max_error_count"] + error_count = row["error_count"] + if error_count == 0: + pct, bar_class = 100, "ok" + else: + pct = min(100, round(error_count / max_error_count * 100)) if max_error_count > 0 else 100 + bar_class = "warn" + cooldown = _escape(format_timestamp(row["cooldown_until"])) + last_restart = _escape(format_timestamp(row["last_restart_at"])) or "Never" + + col = _escape(row["color"]) + parts.append(f'
\n') + parts.append('
') + parts.append(f'{name}') + parts.append('') + parts.append(f'{_escape(status)}') + if row["maintenance_active"]: + parts.append('Maintenance') + if row["notification_muted"]: + parts.append('🔇 Muted') + parts.append("") + parts.append("
\n") + parts.append( + f'
Errors' + f'
' + f"{error_count}/{max_error_count}
\n" + ) + + # Phase row with live countdown timer + phase = row.get("phase", "ok") + phase_until = row.get("phase_until") + last_check_at = row.get("last_check_at") + interval_s = row.get("interval_seconds", 60) + if phase == "cooldown" and phase_until: + parts.append( + f'
' + f'⏱ Cooldown — remaining' + f"
\n" + ) + elif phase == "grace" and phase_until: + parts.append( + f'
' + f'⏱ Grace period — remaining' + f"
\n" + ) + elif phase == "restart_limit": + if phase_until: + parts.append( + f'
' + f'⚠ Restart limit — resets in ' + f"
\n" + ) + else: + parts.append('
⚠ Restart limit reached
\n') + elif phase == "failing": + if last_check_at: + try: + next_ts = (datetime.fromisoformat(last_check_at) + timedelta(seconds=interval_s)).isoformat() + parts.append( + f'
' + f'⚠ Failing {error_count}/{max_error_count} — next check ' + f'' + f"
\n" + ) + except ValueError: + parts.append(f'
⚠ Failing {error_count}/{max_error_count}
\n') + else: + parts.append(f'
⚠ Failing {error_count}/{max_error_count}
\n') + elif phase == "observe_only": + parts.append('
👁 Observe only
\n') + elif phase == "maintenance": + window_start = row.get("maintenance_window_start") + window_end = row.get("maintenance_window_end") + in_scheduled_window = False + if window_start and window_end: + try: + now_dt = utcnow() + in_scheduled_window = ( + datetime.fromisoformat(window_start) <= now_dt < datetime.fromisoformat(window_end) + ) + except ValueError: + in_scheduled_window = False + if in_scheduled_window: + parts.append( + f'
' + f'🔧 In maintenance — ends in ' + f"
\n" + ) + else: + parts.append('
🔧 In maintenance
\n') + else: # "ok" + if last_check_at: + try: + next_ts = (datetime.fromisoformat(last_check_at) + timedelta(seconds=interval_s)).isoformat() + parts.append( + f'
' + f'Next check ' + f"
\n" + ) + except ValueError: + pass + + parts.append('
') + if cooldown: + parts.append(f"
Cooldown until: {cooldown}
") + parts.append(f"
Last restart: {last_restart}
") + if row["notification_muted"]: + muted_until = _escape(format_timestamp(row["notification_muted_until"])) + parts.append(f"
Notifications muted until: {muted_until}
") + if row.get("maintenance_scheduled") and not row["maintenance_active"]: + win_start = row.get("maintenance_window_start") + win_end = row.get("maintenance_window_end") + if win_start and win_end: + start_disp = _escape(format_timestamp(win_start)) + end_disp = _escape(format_timestamp(win_end)) + parts.append( + f'
🔧 Next maintenance: in ' + f' ' + f"({start_disp} – {end_disp})
" + ) + parts.append("
\n") + + action_buttons: list[str] = [] + csrf_field = f'' + if maintenance_enabled: + action = "exit" if row["maintenance_active"] else "enter" + label = "Exit maintenance" if row["maintenance_active"] else "Enter maintenance" + btn_class = "action exit" if row["maintenance_active"] else "action" + action_buttons.append( + f'
{csrf_field}' + f'
' + ) + if notification_mute_enabled: + action = "unmute" if row["notification_muted"] else "mute" + label = "Unmute notifications" if row["notification_muted"] else "Mute notifications" + btn_class = "action exit" if row["notification_muted"] else "action" + action_buttons.append( + f'
{csrf_field}' + f'
' + ) + if action_buttons: + parts.append('
' + "".join(action_buttons) + "
\n") + parts.append("
\n") + parts.append("
\n") + parts.append(_PAGE_TAIL) + return "".join(parts).encode("utf-8") + + +def _render_events_page( + events: list[dict], + database_enabled: bool, + selected_day: date, + container_colors: dict[str, str] | None = None, +) -> bytes: + parts = [_page_head(refresh=False, active="events")] + + prev_day = (selected_day - timedelta(days=1)).isoformat() + next_day = (selected_day + timedelta(days=1)).isoformat() + today = datetime.now(PACIFIC).date() + day_label = selected_day.strftime("%A, %B %-d, %Y") + + parts.append('
\n') + parts.append(f'← Prev day\n') + parts.append(f'{_escape(day_label)}\n') + if selected_day >= today: + parts.append('Next day →\n') + else: + parts.append(f'Next day →\n') + parts.append("
\n") + + parts.append('

Events

\n') + + if not database_enabled: + parts.append( + '

Event history is not enabled. To enable it, add' + " database:
  enabled: true
to" + " config.yml and restart Inspector.

\n" + ) + parts.append(_PAGE_TAIL) + return "".join(parts).encode("utf-8") + + overlap, buckets = _group_events_by_hour(events, selected_day) + + parts.append('
\n') + for marker in reversed(_hour_markers(selected_day)): + label = marker.strftime("%-I %p") # "12 AM" .. "11 PM" + parts.append( + '
' + f"{_escape(label)}
\n" + ) + for ev in buckets[marker]: + parts.append(_render_event_item(ev, container_colors)) + + if overlap: + parts.append( + '
' + "11 PM (yesterday)
\n" + ) + for ev in overlap: + parts.append(_render_event_item(ev, container_colors)) + + parts.append("
\n") + parts.append(_PAGE_TAIL) + return "".join(parts).encode("utf-8") + + +def _render_event_item(ev: dict, container_colors: dict[str, str] | None) -> str: + old = _escape(ev.get("old_status")) or "—" + new = _escape(ev.get("new_status")) or "—" + message = _escape(ev.get("message")) + if ev.get("old_status") or ev.get("new_status"): + msg = f"{old} → {new} — {message}" + else: + msg = message + col = (container_colors or {}).get(ev["container"], "") + item_style = f' style="--col:{_escape(col)}"' if col else "" + con_class = "tl-container tl-chip" if col else "tl-container" + return ( + f'
' + f'' + '
' + f'{_escape(format_timestamp(ev["ts"]))}' + f'{_escape(ev["container"])}' + f'{_escape(ev["event_type"])}' + f"
{msg}
\n" + ) + + +def _render_login_page(error: bool = False) -> bytes: + error_html = ( + '\n' if error else "" + ) + head = ( + "\n" + '\n\n' + '\n' + '\n' + "Inspector — Sign in\n" + '\n' + '\n' + '\n' + '\n' + '\n' + '\n' + '\n' + '\n' + + _STYLE + + "\n\n\n" + '\n" + "\n\n" + ) + return head.encode("utf-8") + + +# --- Settings page ---------------------------------------------------------- + +from urllib.parse import quote as _url_quote # noqa: E402 + + +class _SettingsError(ValueError): + pass + + +def _form_str(form: dict, key: str, default: str = "") -> str: + return form.get(key, [default])[0] + + +def _form_bool(form: dict, key: str) -> bool: + return _form_str(form, key, "").lower() in ("1", "true", "on", "yes") + + +def _form_int(form: dict, key: str, default: int = 0) -> int: + try: + return int(_form_str(form, key, str(default))) + except (TypeError, ValueError): + return default + + +def _apply_secret(form: dict, raw_section: dict, form_key: str, yaml_key: str) -> None: + """Store form value under raw_section[yaml_key] unless it's the mask placeholder.""" + submitted = _form_str(form, form_key, "") + if submitted == _UNCHANGED_SECRET_PLACEHOLDER: + return + raw_section[yaml_key] = submitted + + +def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> None: + """Validate the form, merge into the raw config, write atomically, swap ref.""" + raw = config_ref.get_raw() + + if section == "inspector": + insp = raw.setdefault("inspector", {}) + insp["interval_seconds"] = _form_int(form, "interval_seconds", 60) + insp["log_level"] = _form_str(form, "log_level", "INFO").upper() + insp["dry_run"] = _form_bool(form, "dry_run") + insp["heartbeat"] = _form_bool(form, "heartbeat") + insp["log_retention_days"] = _form_int(form, "log_retention_days", 30) + elif section == "gotify": + notif = raw.setdefault("notifications", {}) + gotify = notif.setdefault("gotify", {}) + gotify["enabled"] = _form_bool(form, "enabled") + gotify["url"] = _form_str(form, "url", "") + _apply_secret(form, gotify, "token", "token") + gotify["priority"] = _form_int(form, "priority", 5) + elif section == "pushover": + notif = raw.setdefault("notifications", {}) + pushover = notif.setdefault("pushover", {}) + pushover["enabled"] = _form_bool(form, "enabled") + _apply_secret(form, pushover, "token", "token") + _apply_secret(form, pushover, "user_key", "user_key") + pushover["priority"] = _form_int(form, "priority", 5) + pushover["device"] = _form_str(form, "device", "") + pushover["sound"] = _form_str(form, "sound", "") + elif section == "dashboard": + dash = raw.setdefault("dashboard", {}) + dash["enabled"] = _form_bool(form, "enabled") + dash["username"] = _form_str(form, "username", "admin") + _apply_secret(form, dash, "password", "password") + elif section == "database": + db = raw.setdefault("database", {}) + db["enabled"] = _form_bool(form, "enabled") + db["retention_days"] = _form_int(form, "retention_days", 30) + elif section == "health_summary": + hs = raw.setdefault("health_summary", {}) + hs["enabled"] = _form_bool(form, "enabled") + hs["interval_hours"] = _form_int(form, "interval_hours", 24) + hs["priority"] = _form_int(form, "priority", 3) + elif section == "maintenance": + m = raw.setdefault("maintenance", {}) + m["enabled"] = _form_bool(form, "enabled") + m["start"] = _form_str(form, "start", "00:00") + m["end"] = _form_str(form, "end", "06:00") + m["max_hours"] = _form_int(form, "max_hours", 8) + elif section == "notification_mute": + nm = raw.setdefault("notification_mute", {}) + nm["enabled"] = _form_bool(form, "enabled") + nm["default_hours"] = _form_int(form, "default_hours", 12) + elif section.startswith("container:"): + name = section.split(":", 1)[1] + containers = raw.get("containers") or {} + if name not in containers: + raise _SettingsError(f"unknown container {name!r}") + cfg = containers[name] or {} + cfg["enabled"] = _form_bool(form, "enabled") + cfg["max_error_count"] = _form_int(form, "max_error_count", 5) + cfg["cooldown_seconds"] = _form_int(form, "cooldown_seconds", 300) + cfg["grace_period_seconds"] = _form_int(form, "grace_period_seconds", 120) + cfg["max_restarts_per_hour"] = _form_int(form, "max_restarts_per_hour", 3) + cfg["color"] = _form_str(form, "color", "") + restart_on = form.get("restart_on", []) + if restart_on: + cfg["restart_on"] = [s.upper() for s in restart_on] + containers[name] = cfg + raw["containers"] = containers + else: + raise _SettingsError(f"unknown settings section {section!r}") + + # Validate before writing. + try: + new_app = build_config_from_raw(raw) + except Exception as exc: + raise _SettingsError(str(exc)) from exc + + try: + write_config_raw(config_ref.path, raw) + except OSError as exc: + raise _SettingsError(f"could not write config.yml: {exc}") from exc + + config_ref.swap(raw, new_app) + log.info("Settings saved: section=%s", section) + + +_RESTART_LABEL = ( + '' + "requires restart" +) + + +def _settings_field( + label: str, + name: str, + value: str, + *, + kind: str = "text", + hint: str = "", + requires_restart: bool = False, +) -> str: + label_html = _escape(label) + if requires_restart: + label_html += " " + _RESTART_LABEL + hint_html = f'
{_escape(hint)}
' if hint else "" + if kind == "checkbox": + checked = "checked" if value in ("1", "True", "true", "on") else "" + return ( + f'
' + f'{hint_html}
" + ) + return ( + f'
' + f'' + f'{hint_html}
' + ) + + +def _settings_secret_field(label: str, name: str, has_value: bool) -> str: + hint = "leave as-is to keep the current value; empty to clear" + placeholder = _UNCHANGED_SECRET_PLACEHOLDER if has_value else "" + return ( + f'
' + f'' + f'' + f'
{_escape(hint)}
' + ) + + +def _render_settings_page(app: AppConfig, csrf_token: str, flash: str) -> bytes: + def _form(section: str, title: str, body: str) -> str: + return ( + f'

{_escape(title)}

' + f'
' + f'' + f'{body}' + f'' + f"
\n" + ) + + parts = [_page_head(refresh=False, active="settings")] + + if flash: + if flash.startswith("ok:"): + parts.append( + f'
Saved: {_escape(flash[3:])}. ' + "Fields marked requires restart take effect after Inspector restarts.
\n" + ) + elif flash.startswith("error:"): + parts.append(f'
{_escape(flash[6:])}
\n') + + parts.append('

Settings

\n') + parts.append('
\n') + + # Inspector + insp = app.inspector + body = ( + _settings_field("interval_seconds", "interval_seconds", str(insp.interval_seconds), kind="number") + + _settings_field("log_level", "log_level", insp.log_level, hint="DEBUG, INFO, WARNING, ERROR") + + _settings_field("dry_run", "dry_run", "on" if insp.dry_run else "", kind="checkbox") + + _settings_field("heartbeat", "heartbeat", "on" if insp.heartbeat else "", kind="checkbox") + + _settings_field("log_retention_days", "log_retention_days", str(insp.log_retention_days), kind="number", requires_restart=True) + ) + parts.append(_form("inspector", "Inspector", body)) + + # Gotify + g = app.gotify + body = ( + _settings_field("enabled", "enabled", "on" if g.enabled else "", kind="checkbox", requires_restart=True) + + _settings_field("url", "url", g.url, requires_restart=True) + + _settings_secret_field("token", "token", bool(g.token)) + + _settings_field("priority", "priority", str(g.priority), kind="number") + ) + parts.append(_form("gotify", "Gotify notifications", body)) + + # Pushover + p = app.pushover + body = ( + _settings_field("enabled", "enabled", "on" if p.enabled else "", kind="checkbox", requires_restart=True) + + _settings_secret_field("token", "token", bool(p.token)) + + _settings_secret_field("user_key", "user_key", bool(p.user_key)) + + _settings_field("priority", "priority", str(p.priority), kind="number") + + _settings_field("device", "device", p.device, hint="optional; blank = all devices") + + _settings_field("sound", "sound", p.sound, hint="optional; blank = user default") + ) + parts.append(_form("pushover", "Pushover notifications", body)) + + # Dashboard + d = app.dashboard + body = ( + _settings_field("enabled", "enabled", "on" if d.enabled else "", kind="checkbox", requires_restart=True) + + _settings_field("username", "username", d.username) + + _settings_secret_field("password", "password", bool(d.password)) + ) + parts.append(_form("dashboard", "Dashboard", body)) + + # Database + db = app.database + body = ( + _settings_field("enabled", "enabled", "on" if db.enabled else "", kind="checkbox", requires_restart=True) + + _settings_field("retention_days", "retention_days", str(db.retention_days), kind="number", requires_restart=True) + ) + parts.append(_form("database", "Event history (SQLite)", body)) + + # Health summary + hs = app.health_summary + body = ( + _settings_field("enabled", "enabled", "on" if hs.enabled else "", kind="checkbox", requires_restart=True) + + _settings_field("interval_hours", "interval_hours", str(hs.interval_hours), kind="number", requires_restart=True) + + _settings_field("priority", "priority", str(hs.priority), kind="number") + ) + parts.append(_form("health_summary", "Health summary digest", body)) + + # Maintenance window + m = app.maintenance + body = ( + _settings_field("enabled", "enabled", "on" if m.enabled else "", kind="checkbox", requires_restart=True) + + _settings_field("start", "start", m.start, hint="HH:MM, 24h") + + _settings_field("end", "end", m.end, hint="HH:MM, 24h") + + _settings_field("max_hours", "max_hours", str(m.max_hours), kind="number") + ) + parts.append(_form("maintenance", "Scheduled maintenance window", body)) + + # Notification mute + nm = app.notification_mute + body = ( + _settings_field("enabled", "enabled", "on" if nm.enabled else "", kind="checkbox", requires_restart=True) + + _settings_field("default_hours", "default_hours", str(nm.default_hours), kind="number") + ) + parts.append(_form("notification_mute", "Notification mute", body)) + + # Containers (edit of existing only; add/remove requires YAML edit + restart) + for c in app.containers: + restart_on_opts = ["HEALTHY", "UNHEALTHY", "STARTING", "NO HEALTH CHECK", + "NOT RUNNING", "CONTAINER NOT FOUND", "ERROR", "UNKNOWN", "UNREACHABLE"] + checkboxes = [] + for s in restart_on_opts: + checked = "checked" if s in c.restart_on else "" + checkboxes.append( + f'' + ) + restart_on_html = ( + '
' + f'
{"".join(checkboxes)}
' + ) + body = ( + _settings_field("enabled", "enabled", "on" if c.enabled else "", kind="checkbox") + + _settings_field("max_error_count", "max_error_count", str(c.max_error_count), kind="number") + + _settings_field("cooldown_seconds", "cooldown_seconds", str(c.cooldown_seconds), kind="number") + + _settings_field("grace_period_seconds", "grace_period_seconds", str(c.grace_period_seconds), kind="number") + + _settings_field("max_restarts_per_hour", "max_restarts_per_hour", str(c.max_restarts_per_hour), kind="number") + + _settings_field("color", "color", c.color, hint="hex, e.g. #60a5fa; blank = auto") + + restart_on_html + ) + parts.append(_form(f"container:{c.name}", f"Container — {c.name}", body)) + + parts.append("
\n") + parts.append( + '

To add or remove a container, ' + "edit config.yml and restart Inspector.

\n" + ) + parts.append(_PAGE_TAIL) + return "".join(parts).encode("utf-8") + + +class _DashboardHTTPServer(ThreadingHTTPServer): + daemon_threads = True + allow_reuse_address = True + + def __init__( + self, + address: tuple[str, int], + handler_cls, + *, + config_ref: ConfigRef, + states: dict[str, ContainerState], + maintenance: MaintenanceTracker, + notification_mute: NotificationMuteTracker, + ): + super().__init__(address, handler_cls) + self.config_ref = config_ref + self.states = states + self.maintenance = maintenance + self.notification_mute = notification_mute + # token -> (expiry_timestamp, csrf_token) + self._sessions: dict[str, tuple[float, str]] = {} + + def current_app(self) -> AppConfig: + return self.config_ref.get() + + def current_containers(self) -> dict[str, ContainerConfig]: + return {c.name: c for c in self.config_ref.get().containers} + + +class _Handler(BaseHTTPRequestHandler): + server: _DashboardHTTPServer + + def log_message(self, fmt: str, *args) -> None: + log.debug("%s - " + fmt, self.address_string(), *args) + + def _session_token(self) -> str | None: + for part in self.headers.get("Cookie", "").split(";"): + k, _, v = part.strip().partition("=") + if k.strip() == "inspector_session": + return v.strip() + return None + + def _is_authenticated(self) -> bool: + # Session cookie + now = time.time() + token = self._session_token() + if token: + entry = self.server._sessions.get(token) + if entry and now < entry[0]: + return True + self.server._sessions.pop(token, None) + + # Basic Auth + header = self.headers.get("Authorization", "") + if header.startswith("Basic "): + try: + decoded = b64decode(header[len("Basic "):]).decode("utf-8") + username, _, password = decoded.partition(":") + except Exception: + username, password = "", "" + cfg = self.server.current_app().dashboard + if secrets.compare_digest(username, cfg.username) and secrets.compare_digest( + password, cfg.password + ): + return True + + return False + + def _csrf_token_for_session(self) -> str: + """Return the CSRF token bound to the current session, or '' if none.""" + token = self._session_token() + if not token: + return "" + entry = self.server._sessions.get(token) + return entry[1] if entry else "" + + def _check_csrf(self, submitted: str) -> bool: + expected = self._csrf_token_for_session() + if not expected or not submitted: + return False + return secrets.compare_digest(expected, submitted) + + def _check_auth(self) -> bool: + if self._is_authenticated(): + return True + + # Browsers get a redirect to the login page; API clients get a plain 401. + if "text/html" in self.headers.get("Accept", ""): + self.send_response(HTTPStatus.SEE_OTHER) + self.send_header("Location", "/login") + self.send_header("Content-Length", "0") + self.end_headers() + else: + self.send_response(HTTPStatus.UNAUTHORIZED) + self.send_header("WWW-Authenticate", 'Basic realm="Inspector"') + self.send_header("Content-Length", "0") + self.end_headers() + return False + + def _send_bytes(self, status: HTTPStatus, content_type: str, body: bytes) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: + parsed = urlparse(self.path) + path = parsed.path + query = parse_qs(parsed.query) + server = self.server + + # Unprotected routes (served before auth check) + if path == "/login": + self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", _render_login_page()) + return + + if path == "/logout": + for part in self.headers.get("Cookie", "").split(";"): + k, _, v = part.strip().partition("=") + if k.strip() == "inspector_session": + server._sessions.pop(v.strip(), None) + self.send_response(HTTPStatus.SEE_OTHER) + self.send_header("Location", "/login") + self.send_header("Set-Cookie", "inspector_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0") + self.send_header("Content-Length", "0") + self.end_headers() + return + + if path.startswith("/static/") and path[len("/static/"):] in _STATIC_FILES: + name = path[len("/static/"):] + data = _STATIC_ASSETS[name] + if data: + self._send_bytes(HTTPStatus.OK, _STATIC_FILES[name], data) + else: + self._send_bytes(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"not found") + return + + if not self._check_auth(): + return + + app = server.current_app() + containers = server.current_containers() + interval_s = app.inspector.interval_seconds + + if path == "/": + rows = _status_snapshot(server.states, containers, server.maintenance, server.notification_mute, interval_s) + csrf = self._csrf_token_for_session() + body = _render_status_page(rows, app.maintenance.enabled, app.notification_mute.enabled, csrf) + self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body) + elif path == "/api/status": + rows = _status_snapshot(server.states, containers, server.maintenance, server.notification_mute, interval_s) + body = json.dumps(rows).encode("utf-8") + self._send_bytes(HTTPStatus.OK, "application/json", body) + elif path == "/events": + selected_day = _parse_date_param(query.get("date", [None])[0]) + colors = _color_map(server.states, containers) + if app.database.enabled: + start, end = _day_bounds_utc(selected_day) + events = _read_events_range(app.database.path, start, end) + else: + events = [] + body = _render_events_page(events, app.database.enabled, selected_day, colors) + self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body) + elif path == "/api/events": + selected_day = _parse_date_param(query.get("date", [None])[0]) + if app.database.enabled: + start, end = _day_bounds_utc(selected_day) + events = _read_events_range(app.database.path, start, end) + else: + events = [] + body = json.dumps(events).encode("utf-8") + self._send_bytes(HTTPStatus.OK, "application/json", body) + elif path == "/settings": + csrf = self._csrf_token_for_session() + flash = query.get("flash", [""])[0] + body = _render_settings_page(app, csrf, flash) + self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body) + else: + self._send_bytes(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"not found") + + def do_POST(self) -> None: + path = urlparse(self.path).path + server = self.server + + if path == "/login": + length = int(self.headers.get("Content-Length", 0)) + raw = self.rfile.read(length) if length > 0 else b"" + data = parse_qs(raw.decode("utf-8", errors="replace")) + username = data.get("username", [""])[0] + password = data.get("password", [""])[0] + cfg = server.current_app().dashboard + if secrets.compare_digest(username, cfg.username) and secrets.compare_digest( + password, cfg.password + ): + token = secrets.token_urlsafe(32) + csrf = secrets.token_urlsafe(32) + server._sessions[token] = (time.time() + _SESSION_MAX_AGE, csrf) + self.send_response(HTTPStatus.SEE_OTHER) + self.send_header("Location", "/") + self.send_header( + "Set-Cookie", + f"inspector_session={token}; Path=/; HttpOnly; SameSite=Lax", + ) + self.send_header("Content-Length", "0") + self.end_headers() + else: + self._send_bytes( + HTTPStatus.UNAUTHORIZED, "text/html; charset=utf-8", _render_login_page(error=True) + ) + return + + if not self._check_auth(): + return + + length = int(self.headers.get("Content-Length", 0)) + raw_body = self.rfile.read(length) if length > 0 else b"" + form = parse_qs(raw_body.decode("utf-8", errors="replace")) + + # Only cookie-authenticated requests need a CSRF token. Basic Auth + # clients prove identity on every request so they're not subject to + # the CSRF attack vector. + if self._session_token() is not None: + if not self._check_csrf(form.get("csrf", [""])[0]): + self._send_bytes(HTTPStatus.FORBIDDEN, "text/plain; charset=utf-8", b"csrf token invalid") + return + + app = server.current_app() + containers = server.current_containers() + parts = path.strip("/").split("/") + + if len(parts) == 3 and parts[0] == "maintenance" and parts[2] in ("enter", "exit"): + name, action = parts[1], parts[2] + if name not in containers: + self._send_bytes(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"unknown container") + return + if not app.maintenance.enabled: + self._send_bytes( + HTTPStatus.CONFLICT, + "text/plain; charset=utf-8", + b"maintenance windows are disabled in config", + ) + return + + marker_dir = Path(app.maintenance.dir) + marker = marker_dir / name + if action == "enter": + marker_dir.mkdir(parents=True, exist_ok=True) + marker.touch() + else: + marker.unlink(missing_ok=True) + + self.send_response(HTTPStatus.SEE_OTHER) + self.send_header("Location", "/") + self.send_header("Content-Length", "0") + self.end_headers() + return + + if len(parts) == 3 and parts[0] == "notifications" and parts[2] in ("mute", "unmute"): + name, action = parts[1], parts[2] + if name not in containers: + self._send_bytes(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"unknown container") + return + if not app.notification_mute.enabled: + self._send_bytes( + HTTPStatus.CONFLICT, + "text/plain; charset=utf-8", + b"notification muting is disabled in config", + ) + return + + marker_dir = Path(app.notification_mute.dir) + marker = marker_dir / name + if action == "mute": + marker_dir.mkdir(parents=True, exist_ok=True) + marker.touch() + else: + marker.unlink(missing_ok=True) + + self.send_response(HTTPStatus.SEE_OTHER) + self.send_header("Location", "/") + self.send_header("Content-Length", "0") + self.end_headers() + return + + if len(parts) == 2 and parts[0] == "settings": + section = parts[1] + try: + _apply_settings_update(server.config_ref, section, form) + except _SettingsError as exc: + flash = f"error:{exc}" + self.send_response(HTTPStatus.SEE_OTHER) + self.send_header("Location", f"/settings?flash={_url_quote(flash)}") + self.send_header("Content-Length", "0") + self.end_headers() + return + self.send_response(HTTPStatus.SEE_OTHER) + self.send_header("Location", f"/settings?flash={_url_quote('ok:' + section)}") + self.send_header("Content-Length", "0") + self.end_headers() + return + + self._send_bytes(HTTPStatus.NOT_FOUND, "text/plain; charset=utf-8", b"not found") + + +class DashboardServer: + """Optional embedded web dashboard. Always safe to construct and call: + a complete no-op when dashboard.enabled is false. Refuses to start + (logs an error) if enabled with an empty password, to avoid + accidentally exposing an unauthenticated dashboard. + """ + + def __init__( + self, + config_ref: ConfigRef, + states: dict[str, ContainerState], + maintenance: MaintenanceTracker, + notification_mute: NotificationMuteTracker, + ): + self._config_ref = config_ref + self._states = states + self._maintenance = maintenance + self._notification_mute = notification_mute + self._httpd: _DashboardHTTPServer | None = None + self._thread: threading.Thread | None = None + + @property + def enabled(self) -> bool: + return self._config_ref.get().dashboard.enabled + + def start(self) -> None: + dash_cfg = self._config_ref.get().dashboard + if not dash_cfg.enabled: + return + if not dash_cfg.password: + log.error( + "dashboard.enabled is true but dashboard.password is empty; " + "refusing to start the dashboard" + ) + return + + self._httpd = _DashboardHTTPServer( + (dash_cfg.host, dash_cfg.port), + _Handler, + config_ref=self._config_ref, + states=self._states, + maintenance=self._maintenance, + notification_mute=self._notification_mute, + ) + self._thread = threading.Thread( + target=self._httpd.serve_forever, name="dashboard", daemon=True + ) + self._thread.start() + log.info( + "Dashboard listening on %s:%d", + dash_cfg.host, + self._httpd.server_address[1], + ) + + def stop(self) -> None: + if self._httpd is None: + return + self._httpd.shutdown() + self._httpd.server_close() + if self._thread is not None: + self._thread.join(timeout=5) + self._httpd = None + self._thread = None diff --git a/src/inspector/docker_client.py b/src/inspector/docker_client.py new file mode 100755 index 0000000..3011147 --- /dev/null +++ b/src/inspector/docker_client.py @@ -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}" diff --git a/src/inspector/eventstore.py b/src/inspector/eventstore.py new file mode 100755 index 0000000..216c83d --- /dev/null +++ b/src/inspector/eventstore.py @@ -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 diff --git a/src/inspector/formatting.py b/src/inspector/formatting.py new file mode 100755 index 0000000..c4655ae --- /dev/null +++ b/src/inspector/formatting.py @@ -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") diff --git a/src/inspector/health.py b/src/inspector/health.py new file mode 100755 index 0000000..7a0c0ec --- /dev/null +++ b/src/inspector/health.py @@ -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}", + ) diff --git a/src/inspector/health_summary.py b/src/inspector/health_summary.py new file mode 100755 index 0000000..4fc59eb --- /dev/null +++ b/src/inspector/health_summary.py @@ -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, + ) diff --git a/src/inspector/http_check.py b/src/inspector/http_check.py new file mode 100755 index 0000000..6dcc441 --- /dev/null +++ b/src/inspector/http_check.py @@ -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)" + ), + ) diff --git a/src/inspector/logging_setup.py b/src/inspector/logging_setup.py new file mode 100755 index 0000000..2d09f0a --- /dev/null +++ b/src/inspector/logging_setup.py @@ -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 diff --git a/src/inspector/main.py b/src/inspector/main.py new file mode 100755 index 0000000..c0c55a8 --- /dev/null +++ b/src/inspector/main.py @@ -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()) diff --git a/src/inspector/maintenance.py b/src/inspector/maintenance.py new file mode 100755 index 0000000..cfa8091 --- /dev/null +++ b/src/inspector/maintenance.py @@ -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 / 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) diff --git a/src/inspector/notification_mute.py b/src/inspector/notification_mute.py new file mode 100755 index 0000000..10ce5cb --- /dev/null +++ b/src/inspector/notification_mute.py @@ -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) diff --git a/src/inspector/notifier.py b/src/inspector/notifier.py new file mode 100755 index 0000000..5538c1b --- /dev/null +++ b/src/inspector/notifier.py @@ -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 diff --git a/src/inspector/portainer_client.py b/src/inspector/portainer_client.py new file mode 100755 index 0000000..befd1fd --- /dev/null +++ b/src/inspector/portainer_client.py @@ -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", "") diff --git a/src/inspector/recovery.py b/src/inspector/recovery.py new file mode 100755 index 0000000..a66f58b --- /dev/null +++ b/src/inspector/recovery.py @@ -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:` 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:` 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) diff --git a/src/inspector/state.py b/src/inspector/state.py new file mode 100755 index 0000000..86a002e --- /dev/null +++ b/src/inspector/state.py @@ -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 diff --git a/src/inspector/uptime_kuma.py b/src/inspector/uptime_kuma.py new file mode 100755 index 0000000..fb82546 --- /dev/null +++ b/src/inspector/uptime_kuma.py @@ -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)