diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..d2a4469 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,20 @@ +{ + "permissions": { + "allow": [ + "WebSearch", + "WebFetch(domain:github.com)", + "WebFetch(domain:raw.githubusercontent.com)", + "Bash(gh issue *)", + "Bash(gh pr *)", + "Bash(gh release *)", + "Bash(git stash *)", + "Bash(git fetch *)", + "Bash(docker compose *)", + "Bash(docker build *)", + "Bash(docker run *)", + "Bash(python -c *)", + "Bash(pip install *)", + "Bash(ruff *)" + ] + } +} diff --git a/.gitignore b/.gitignore index ce9ac70..a8723b9 100644 --- a/.gitignore +++ b/.gitignore @@ -27,8 +27,10 @@ config.py *.pid *.sock config/ +config-dev/ images/ docker-compose.override.yml +docker-compose-dev.yml # Virtual environments venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0d23e81 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,115 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +--- + +## [0.3.0] — 2026-05-12 + +### Added +- Shared logging setup module consumed by `main.py` and all notifier + modules. No more duplicated handler configuration across files. +- Shared retry helper. DNS notifier now retries with backoff using the + same policy as the STD notifier. +- Common kwargs contract: every notifier module's `register()` + receives a documented base set of kwargs (`container_name`, + `container_id`, `docker_host`, `docker_status`, `image_name`, + `stack_name`, `started_at`, `action`). Notifier-specific extras + are layered on top. +- Boolean and integer coercion for STD health-check and sort-priority + labels at the notifier boundary. `dockernotifier.std.internal.health` + and `dockernotifier.std.external.health` label values are converted + from string to bool, and `dockernotifier.std.sort.priority` is + converted from string to int, before being sent to STD. +- PRD §3.3 documents the notifier module contract. +- `notifiers/_template.py` reference implementation for adding new + notifier targets. + +### Changed +- STD notifier emits canonical key names (`host`, `group_name`, + `internal_health_check_enabled`, `image_icon`, `sort_priority`, ...) + and posts to `/api/v1/register` instead of `/api/register`. The + notifier translates legacy label-derived keys to canonical names + client-side; unknown keys are dropped before sending so STD's + strict pydantic validator does not reject the request. +- Stack-name resolution: when `com.docker.compose.project` is missing, + the notifier passes `stack_name=None` rather than splitting the + container name on `_`. +- Replaced per-notifier `DNS_LOG_TO_STDOUT` and `STD_LOG_TO_STDOUT` + env vars with a single `NOTIFIER_LOG_TO_STDOUT`. Default unchanged + (console on). Operators who previously set `DNS_LOG_TO_STDOUT=0` or + `STD_LOG_TO_STDOUT=0` should switch to `NOTIFIER_LOG_TO_STDOUT=0`. +- DNS notifier now raises on HTTP 4xx/5xx responses from Technitium + and retries transient failures. Previously HTTP errors were silently + logged as successes. +- DNS notifier's "triggered for" log line now reports the actual Docker + action (e.g. `start`, `boot`, `refresh`) instead of the literal string + `"event"`. Operators with log filters or alerts that grep specifically + for `due to "event"` will need to update them. + +### Removed +- Unused `trigger_reason` parameter from the DNS notifier's `register()` + signature. +- Inline `watched_actions` literal in `main()`; the Docker event loop + now reads from the module-level `WATCHED_DOCKER_ACTIONS` constant. + +### Fixed +- Real Docker actions and synthetic actions (`boot`, `refresh`) are now + declared as separate constants in `main.py` (`WATCHED_DOCKER_ACTIONS` + and `SYNTHETIC_ACTIONS`) and composed into `NOTIFIER_TRIGGERS`. No + behavior change — clarifies what the notifier subscribes to from + Docker vs. what it injects internally. +- Misleading comment on `STD_REFRESH_SECONDS` ("60 minutes" → "60 + seconds"). + +--- + +## [0.2.3] — 2026-XX-XX + +Released. Detailed notes not retained. + +## [0.2.2] — 2026-XX-XX +Released. + +## [0.2.1] — 2026-XX-XX +Released. + +## [0.2.0] — 2026-XX-XX +Released. + +## [0.1.5] — 2026-XX-XX +Released. + +## [0.1.4] — 2026-XX-XX +Released. + +## [0.1.3] — 2026-XX-XX +Released. + +## [0.1.2] — 2026-XX-XX +Released. + +## [0.1.1] — 2026-XX-XX +Released. + +## [0.1.0] — 2026-XX-XX + +Initial public release. + +[Unreleased]: https://github.com/crzykidd/docker-api-notifier/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/crzykidd/docker-api-notifier/releases/tag/v0.3.0 +[0.2.3]: https://github.com/crzykidd/docker-api-notifier/releases/tag/v0.2.3 +[0.2.2]: https://github.com/crzykidd/docker-api-notifier/releases/tag/v0.2.2 +[0.2.1]: https://github.com/crzykidd/docker-api-notifier/releases/tag/v0.2.1 +[0.2.0]: https://github.com/crzykidd/docker-api-notifier/releases/tag/v0.2.0 +[0.1.5]: https://github.com/crzykidd/docker-api-notifier/releases/tag/v0.1.5 +[0.1.4]: https://github.com/crzykidd/docker-api-notifier/releases/tag/v0.1.4 +[0.1.3]: https://github.com/crzykidd/docker-api-notifier/releases/tag/v0.1.3 +[0.1.2]: https://github.com/crzykidd/docker-api-notifier/releases/tag/v0.1.2 +[0.1.1]: https://github.com/crzykidd/docker-api-notifier/releases/tag/v0.1.1 +[0.1.0]: https://github.com/crzykidd/docker-api-notifier/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4ad6082 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,125 @@ +# docker-api-notifier — Claude Code Instructions + +## Always + +- After any change that affects architecture, dependencies, supported + notifier targets, or the wire contract with downstream consumers, + update `docs/PRD.md` and `README.md` accordingly. +- After completing a phase, update `README.md` with what has been built. +- Never leave PRD or README out of sync with the codebase. + +## Commit style + +- `feat:` new feature +- `chore:` config, tooling, maintenance +- `fix:` bug fix +- `docs:` documentation only changes +- `refactor:` non-behavior-changing internal cleanup + +## Stack + +- Python 3.11 +- `docker` (Docker SDK for Python) — Docker event subscription +- `requests` — DNS notifier HTTP calls +- `tenacity` — retry-with-backoff for downstream notifier calls +- Container: single-image Docker, no compose orchestration of its own + - `Dockerfile` — production image + - `docker-compose.yml` — example deployment + +## Configuration + +- All configuration is via environment variables. There is no config file. +- Per-container behavior comes from `dockernotifier.*` labels on the + containers being watched. +- Full ENV reference: `README.md` → Environment Variables section. + +## Project Documentation + +- Full PRD is at `docs/PRD.md` — read this before starting any phase. +- Project history (structural events, milestones) at `docs/HISTORY.md`. +- `README.md` at the root — keep it current with what has been built. +- Commit doc updates in the same commit as the code changes they describe. + +## Build Status + +Current shipped release: **v0.2.3** (latest tag on `main`) + +Next release target: **v0.3.0** — cleanup release. Cannot ship until +**STD v0.5.0** is released (v0.3.0 emits canonical keys against +`/api/v1/register`, which STD v0.5.0 introduces). + +- Phase 1 — Documentation baseline: IN PROGRESS +- Phase 2 — Logging consolidation: NOT STARTED +- Phase 3 — Shared retry helper: NOT STARTED +- Phase 4 — Stack-name fallback fix: NOT STARTED +- Phase 5 — `watched_actions` / `NOTIFIER_TRIGGERS` cleanup: NOT STARTED +- Phase 6 — Drop `trigger_reason` param: NOT STARTED +- Phase 7 — Switch to `/api/v1/register` + canonical keys: NOT STARTED + *(blocked until STD v0.5.0 is released)* + +## Git Workflow + +- Work on `dev` branch for all changes. +- Push to `dev` freely — builds `:dev` images. +- When ready to release: + - Create PR `dev` → `main` on GitHub. + - Merge after CI passes. + - Tag release from `main` via the GitHub Releases UI. +- Never push directly to `main`. +- Branch protection on `main` requires PR + green build, blocks + force-push and deletion. +- Do NOT add `Co-authored-by` to commits. + +## Release Process + +- Push to `dev` — GitHub Actions builds and pushes `:dev` and + `:sha-` images to Docker Hub. +- Push to `main` (via PR from `dev`) — GitHub Actions builds and pushes + `:latest` and `:sha-` images. +- When the build is stable and ready to ship: + 1. GitHub → Releases → Draft new release. + 2. Create a new tag in `vX.Y.Z` format. + 3. Publish the release. + 4. GitHub Actions builds and pushes `:latest`, `:X.Y.Z`, and `:X` to + Docker Hub. + +## Changelog Process + +- `CHANGELOG.md` lives at repo root. +- Follow Keep a Changelog format (keepachangelog.com). +- Add entries to `[Unreleased]` section as features are built. +- User-facing language only — describe what changed for the operator. +- Categories: Added, Changed, Fixed, Security, Deprecated, Removed. +- On release: move `[Unreleased]` to a new version section dated today. +- GitHub release body = that version's CHANGELOG section (single source + of truth). + +## Cross-Repo Coordination + +This project is paired with +[service-tracker-dashboard](https://github.com/crzykidd/service-tracker-dashboard). +The contract is: + +- **STD** owns the wire contract for the register endpoint. +- **Notifier** is a producer — it sends what STD documents. +- Wire-format changes start in STD. The notifier follows. +- For v0.3.0 specifically: STD v0.5.0 must ship first; this notifier + release switches to canonical keys + `/api/v1/register` after. + +## Notifier Module Conventions + +The notifier module contract is documented in `docs/PRD.md` §3.3. +A reference template is at `notifiers/_template.py`. + +In short: one module per downstream system under `notifiers/`, +exposing `register(**kwargs)`. Modules consume shared logging +(`logging_setup`) and shared retry (`retry`). They own their own +auth and wire format. + +When adding a new notifier: copy `notifiers/_template.py`, replace +the placeholders, wire it into `main.py`'s `NOTIFIER_TRIGGERS` and +dispatch, document env vars and labels in `README.md`. + +## Git Rules + +- Do NOT add `Co-authored-by` lines to commit messages. diff --git a/README.md b/README.md index 2efe3e4..91a96bf 100644 --- a/README.md +++ b/README.md @@ -4,100 +4,148 @@ ![Python](https://img.shields.io/badge/python-3.11-blue?logo=python) ![License](https://img.shields.io/badge/license-MIT-green) -A lightweight, event-driven Docker monitor that automatically updates DNS entries and service dashboards based on container events and metadata. +A lightweight, event-driven Docker monitor that listens to the Docker socket +on a host and fans out container events to multiple downstream notifiers. -I started this project to solve updating my Technitium DNS server when a host started up on a docker host. So that is the first task this container does. +It was built to solve two specific problems in a homelab: -Then as I was working I was struggling with how to update my dashboard via API etc. So I decided to write a self defining dashboard. So this notifier also can be enabled to send updates to [STD-Service Tracker Dashboard](https://github.com/crzykidd/service-tracker-dashboard). +- Keep a Technitium DNS server's records in sync with what's actually + running on each Docker host. +- Push container metadata (URLs, health-check flags, grouping, icons) to a + self-hosted dashboard ([Service Tracker Dashboard](https://github.com/crzykidd/service-tracker-dashboard)) + so the dashboard config is driven by `docker-compose` labels rather than + hand-edited. -While STD has a small config file and some manual setting options. The real design was to specify tags in docker compose so that your config would define and update the dashboard. +Each container opts in to notifiers via labels, so you can run this on +every Docker host without it touching things you didn't ask it to touch. --- -## 📚 Table of Contents +## Table of Contents -1. [What It Does](#1-what-it-does) -2. [Environment Variables](#2-environment-variables) -3. [Labels You Can Use](#3-labels-you-can-use) -4. [Docker Compose Example](#4-docker-compose-example) -5. [Building Locally](#5-building-locally) +1. [What It Does](#what-it-does) +2. [Environment Variables](#environment-variables) +3. [Container Labels](#container-labels) +4. [Docker Compose Example](#docker-compose-example) +5. [How It Works](#how-it-works) +6. [Building Locally](#building-locally) --- -## 1. What It Does +## What It Does -`docker-api-notifier` listens for Docker events (start, stop, die, etc.) and sends updates to external systems. +`docker-api-notifier` connects to the Docker socket on its host and: -Supported integrations: -- 🧭 **Technitium DNS** – updates DNS records. -- 📊 **Service Tracker Dashboard** – sends metadata and health checks. +1. Scans every running container at startup ("boot" pass). +2. Subscribes to the Docker event stream for ongoing changes (`start`, + `stop`, `die`, `pause`, `unpause`, `destroy`, `kill`, `update`). +3. Re-scans every running container on a periodic interval as a + self-healing measure (default every 60 seconds). + +For each event, it reads the container's labels and dispatches to whichever +notifiers the container has opted in to via `dockernotifier.notifiers`. + +Supported notifiers today: + +- **Technitium DNS** — adds/updates a CNAME record on container start. +- **Service Tracker Dashboard (STD)** — POSTs container metadata to + STD's register endpoint. + +The notifier is a generic event fan-out. STD is one consumer; new +notifier targets can be added without touching the core event loop. --- -## 2. Environment Variables +## Environment Variables -### 🛠 General +### General -| Variable | Required | Default | Description | -|------------------------|----------|---------|-------------| -| TZ | No | `UTC` | Timezone for logging. | +| Variable | Required | Default | Description | +|---------------------------|----------|---------|-------------| +| `TZ` | No | `UTC` | Timezone for log timestamps. | +| `STD_REFRESH_SECONDS` | No | `60` | Periodic re-scan interval in **seconds**. | +| `NOTIFIER_LOG_TO_STDOUT` | No | `1` | Set to `0` to silence console output. Logs still go to `/config/notifier.log`. Replaces the per-notifier `DNS_LOG_TO_STDOUT` and `STD_LOG_TO_STDOUT` vars, which are no longer recognized. | -### 🌐 DNS (Technitium) +### Technitium DNS -| Variable | Required | Description | -|------------------------|----------|-------------| -| DNS_SERVER_URL | ✅ Yes | URL to your DNS API. | -| DNS_SERVER_API_TOKEN | ✅ Yes | Auth token for DNS server. | -| DNS_SERVER_TYPE | No | Optional descriptor. | +| Variable | Required | Description | +|-------------------------|----------|-------------| +| `DNS_SERVER_URL` | Yes (for DNS) | Full URL to the Technitium add-record endpoint. | +| `DNS_SERVER_API_TOKEN` | Yes (for DNS) | API token for the DNS server. | +| `DNS_SERVER_TYPE` | No | Optional descriptor (informational only). | -### 📊 Service Tracker Dashboard +### Service Tracker Dashboard -| Variable | Required | Description | -|--------------------|----------|-------------| -| STD_URL | ✅ Yes | Dashboard API endpoint. | -| STD_API_TOKEN | ✅ Yes | API token for dashboard. | -| STD_LOG_TO_STDOUT| No | Set to `0` to disable console logs. | -| STD_REFRESH_SECONDS | No | `300` Interval in seconds to check container state and update API | +> **Requires STD v0.5.0 or later.** Starting in notifier v0.3.0, the +> notifier posts to STD's `/api/v1/register` endpoint using STD's +> canonical schema. Earlier STD versions do not expose that endpoint +> and will return 404. + +| Variable | Required | Description | +|-------------------|----------|-------------| +| `STD_URL` | Yes (for STD) | Base URL of the STD instance, e.g. `http://std.example.com:8815`. | +| `STD_API_TOKEN` | Yes (for STD) | Bearer token configured on the STD side. | + +If a notifier's required env vars are missing, that notifier silently +no-ops — the container won't fail to start. This is intentional so you +can run the same image with only DNS, only STD, or both. --- -## 3. Labels You Can Use +## Container Labels + +You opt a container in to notification by adding labels to it. +None of these labels are required to run the notifier itself; they're +read off the containers being watched. -Add labels to your containers to control what happens when they're started or updated. +### Notifier selection -### 🔧 Required for Activation +| Label | Description | +|-----------------------------|-------------| +| `dockernotifier.notifiers` | Comma-separated list of notifiers to run for this container. Valid values: `dns`, `service-tracker-dashboard`. | -| Label | Required | Description | -|------------------------------|----------|-------------| -| dockernotifier.notifiers | ✅ Yes | List of notifiers to run, e.g. `dns,service-tracker-dashboard`. | +### DNS labels -### 🌐 DNS Labels +All three are required for the DNS notifier to act on a container. -| Label | Required | Description | -|----------------------------------------|----------|-------------| -| dockernotifier.dns.containerhostname | ✅ Yes for DNS | Hostname (e.g., `sonarr`). | -| dockernotifier.dns.containerzone | ✅ Yes for DNS | Zone/domain (e.g., `home.local`). | -| dockernotifier.dns.dockerdomain | ✅ Yes for DNS | Docker host domain (e.g., `docker`). | +| Label | Description | +|-----------------------------------------|-------------| +| `dockernotifier.dns.containerhostname` | Hostname portion of the record (e.g. `sonarr`). | +| `dockernotifier.dns.containerzone` | Zone/domain (e.g. `home.local`). | +| `dockernotifier.dns.dockerdomain` | Docker host domain (e.g. `docker`). The CNAME will point at `.`. | -### 📊 Service Tracker Labels +### STD labels -| Label | Required | Description | -|----------------------------------------|----------|-------------| -| dockernotifier.std.internalurl | No | Internal service URL. | -| dockernotifier.std.externalurl | No | Public URL. | -| dockernotifier.std.internal.health | No | Internal health check. | -| dockernotifier.std.external.health | No | External health check. | -| dockernotifier.std.group | No | Group name for dashboard. | -| dockernotifier.std.icon | No | Icon file name (e.g. `sonarr.svg`)| +All STD labels are optional. Anything you set gets forwarded to STD; STD +applies its own defaults for anything you don't. + +| Label | Description | +|---------------------------------------------|-------------| +| `dockernotifier.std.internalurl` | Internal service URL. | +| `dockernotifier.std.externalurl` | Public/external URL. | +| `dockernotifier.std.internal.health` | `true`/`false` — enable internal health check. | +| `dockernotifier.std.external.health` | `true`/`false` — enable external health check. | +| `dockernotifier.std.group` | Group label for dashboard organization. | +| `dockernotifier.std.icon` | Icon filename (e.g. `sonarr.svg`). | +| `dockernotifier.std.sort.priority` | Numeric sort order within a group. | + +> **Note on label naming.** Container label keys (the +> `dockernotifier.std.*` keys you set on watched containers) are +> unchanged. Internally, notifier v0.3.0 translates them to STD's +> canonical wire-format keys (`group_name`, `image_icon`, +> `internal_health_check_enabled`, `sort_priority`, ...) before +> posting to STD's `/api/v1/register` endpoint. Boolean and integer +> coercion happens at the same boundary, so the values STD receives +> are actual `bool`/`int` rather than strings. --- -## 4. Docker Compose Example +## Docker Compose Example ```yaml services: docker-api-notifier: - build: . + image: crzykidd/docker-api-notifier:latest container_name: docker-api-notifier environment: - DNS_SERVER_TYPE=Technitium @@ -106,9 +154,92 @@ services: - STD_URL=http://std.example.com:8815 - STD_API_TOKEN=TOKENFROMSTDSERVER - TZ=America/Los_Angeles - - STD_REFRESH_SECONDS=300 + - STD_REFRESH_SECONDS=60 volumes: - /var/run/docker.sock:/var/run/docker.sock - /etc/hostname:/etc/host_hostname:ro - /var/docker/docker-api-notifier:/config restart: unless-stopped +``` + +Volumes: + +- `/var/run/docker.sock` — required, this is how the notifier reads + events. +- `/etc/hostname` mounted as `/etc/host_hostname` — used so the + notifier reports the **host's** hostname, not the container's, when + posting to downstream notifiers. +- `/config` — log file lives here (`notifier.log`, rotated at 10 MB). + +--- + +## How It Works + +``` + ┌────────────────────┐ + │ Docker socket │ + │ (events stream) │ + └─────────┬──────────┘ + │ + ┌───────▼────────┐ + │ main.py │ ← reads events, + │ event loop │ enriches with labels, + └───────┬────────┘ decides who to call + │ + ┌───────────────────┼────────────────────┐ + │ │ + ┌────────▼─────────┐ ┌───────────▼──────────┐ + │ technitium_dns │ │ service_tracker_ │ + │ .register() │ │ dashboard.register() │ + └──────────────────┘ └──────────────────────┘ + │ │ + ▼ ▼ + Technitium API STD /api/v1/register +``` + +- Container events arrive from the Docker socket and are filtered against + a whitelist of actions the notifier cares about. +- Per event, container labels are read; only containers with + `dockernotifier.notifiers` set are processed. +- Each enabled notifier is a Python module under `notifiers/` exposing a + `register(...)` function. Adding a new notifier target is a matter of + dropping a new module and wiring it into the dispatch in `main.py`. +- Both notifiers share a single retry-with-backoff policy + (`tenacity`, exposed via the `with_retry` decorator in `retry.py`) for + transient network failures: 3 attempts, exponential backoff 2s/4s/8s + capped at 10s, retries on `requests.RequestException`. The DNS + notifier also calls `raise_for_status()` so HTTP 4xx/5xx responses + from Technitium trigger retries instead of being logged as + successes. + +--- + +## Building Locally + +```bash +git clone https://github.com/crzykidd/docker-api-notifier.git +cd docker-api-notifier +docker build -t docker-api-notifier:dev . +``` + +Run pointed at your dev Docker socket and a scratch config dir. + +--- + +## Versioning & Releases + +- `:latest` follows the `main` branch — CI-verified pre-release. +- `:dev` follows the `dev` branch — work in progress. +- `:sha-` is published for every push for exact pinning. +- Semver-tagged images (`:0.3.0`, `:0`) are published from GitHub Releases. + +Branch protection: PRs into `main` must pass the build check; force-push +and branch deletion are blocked. Work happens on `dev`, opens a PR to +`main`, and merges only when CI is green. Release tags are cut from the +GitHub Releases UI on `main`. + +--- + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/READMEOLD.md b/READMEOLD.md new file mode 100644 index 0000000..2efe3e4 --- /dev/null +++ b/READMEOLD.md @@ -0,0 +1,114 @@ +# 🚀 Docker API Notifier + +![Docker Image](https://img.shields.io/badge/docker-ready-blue?logo=docker) +![Python](https://img.shields.io/badge/python-3.11-blue?logo=python) +![License](https://img.shields.io/badge/license-MIT-green) + +A lightweight, event-driven Docker monitor that automatically updates DNS entries and service dashboards based on container events and metadata. + +I started this project to solve updating my Technitium DNS server when a host started up on a docker host. So that is the first task this container does. + +Then as I was working I was struggling with how to update my dashboard via API etc. So I decided to write a self defining dashboard. So this notifier also can be enabled to send updates to [STD-Service Tracker Dashboard](https://github.com/crzykidd/service-tracker-dashboard). + +While STD has a small config file and some manual setting options. The real design was to specify tags in docker compose so that your config would define and update the dashboard. + +--- + +## 📚 Table of Contents + +1. [What It Does](#1-what-it-does) +2. [Environment Variables](#2-environment-variables) +3. [Labels You Can Use](#3-labels-you-can-use) +4. [Docker Compose Example](#4-docker-compose-example) +5. [Building Locally](#5-building-locally) + +--- + +## 1. What It Does + +`docker-api-notifier` listens for Docker events (start, stop, die, etc.) and sends updates to external systems. + +Supported integrations: +- 🧭 **Technitium DNS** – updates DNS records. +- 📊 **Service Tracker Dashboard** – sends metadata and health checks. + +--- + +## 2. Environment Variables + +### 🛠 General + +| Variable | Required | Default | Description | +|------------------------|----------|---------|-------------| +| TZ | No | `UTC` | Timezone for logging. | + +### 🌐 DNS (Technitium) + +| Variable | Required | Description | +|------------------------|----------|-------------| +| DNS_SERVER_URL | ✅ Yes | URL to your DNS API. | +| DNS_SERVER_API_TOKEN | ✅ Yes | Auth token for DNS server. | +| DNS_SERVER_TYPE | No | Optional descriptor. | + +### 📊 Service Tracker Dashboard + +| Variable | Required | Description | +|--------------------|----------|-------------| +| STD_URL | ✅ Yes | Dashboard API endpoint. | +| STD_API_TOKEN | ✅ Yes | API token for dashboard. | +| STD_LOG_TO_STDOUT| No | Set to `0` to disable console logs. | +| STD_REFRESH_SECONDS | No | `300` Interval in seconds to check container state and update API | + +--- + +## 3. Labels You Can Use + +Add labels to your containers to control what happens when they're started or updated. + +### 🔧 Required for Activation + +| Label | Required | Description | +|------------------------------|----------|-------------| +| dockernotifier.notifiers | ✅ Yes | List of notifiers to run, e.g. `dns,service-tracker-dashboard`. | + +### 🌐 DNS Labels + +| Label | Required | Description | +|----------------------------------------|----------|-------------| +| dockernotifier.dns.containerhostname | ✅ Yes for DNS | Hostname (e.g., `sonarr`). | +| dockernotifier.dns.containerzone | ✅ Yes for DNS | Zone/domain (e.g., `home.local`). | +| dockernotifier.dns.dockerdomain | ✅ Yes for DNS | Docker host domain (e.g., `docker`). | + +### 📊 Service Tracker Labels + +| Label | Required | Description | +|----------------------------------------|----------|-------------| +| dockernotifier.std.internalurl | No | Internal service URL. | +| dockernotifier.std.externalurl | No | Public URL. | +| dockernotifier.std.internal.health | No | Internal health check. | +| dockernotifier.std.external.health | No | External health check. | +| dockernotifier.std.group | No | Group name for dashboard. | +| dockernotifier.std.icon | No | Icon file name (e.g. `sonarr.svg`)| + +--- + +## 4. Docker Compose Example + +```yaml +services: + docker-api-notifier: + build: . + container_name: docker-api-notifier + environment: + - DNS_SERVER_TYPE=Technitium + - DNS_SERVER_URL=http://dns.example.com:5380/api/zones/records/add + - DNS_SERVER_API_TOKEN=TOKENFROMDNSSERVER + - STD_URL=http://std.example.com:8815 + - STD_API_TOKEN=TOKENFROMSTDSERVER + - TZ=America/Los_Angeles + - STD_REFRESH_SECONDS=300 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /etc/hostname:/etc/host_hostname:ro + - /var/docker/docker-api-notifier:/config + restart: unless-stopped diff --git a/docs/HISTORY.md b/docs/HISTORY.md new file mode 100644 index 0000000..2ed5d39 --- /dev/null +++ b/docs/HISTORY.md @@ -0,0 +1,69 @@ +# docker-api-notifier Project History + +This file documents structural events in the project's history — things +that don't fit neatly in a changelog entry but are worth knowing about. + +For the feature-level changelog, see [CHANGELOG.md](../CHANGELOG.md). + +--- + +## Documentation baseline (v0.3.0 cycle) + +Through v0.2.3, the project shipped with a single `README.md` and no +PRD or HISTORY file. As part of the v0.3.0 cleanup work, this document, +[`PRD.md`](./PRD.md), and a project-level [`CLAUDE.md`](../CLAUDE.md) +were added. + +### Why + +The notifier was reaching the point where its rough edges (asymmetric +retry, fragile stack-name fallback, half-wired event triggers) needed +explicit listing somewhere durable. The PRD captures that list and the +intended end state. CLAUDE.md captures the workflow conventions that had +been informal up to that point. + +### What changed + +- `docs/PRD.md` added — current state, planned v0.3.0 changes, scope. +- `docs/HISTORY.md` (this file) added. +- `CLAUDE.md` added at repo root. +- `CHANGELOG.md` reformatted to Keep a Changelog conventions; existing + v0.1.x and v0.2.x tags listed as stub entries (detailed notes for + pre-v0.3.0 versions are not retained). + +### Impact on existing installs + +None. Documentation only. + +--- + +## Cross-repo wire contract change (v0.3.0) + +v0.3.0 is the first release of this project that emits canonical key +names to the Service Tracker Dashboard register endpoint. Prior to +v0.3.0, the STD notifier sent a free-form kwargs dict whose key names +were derived directly from `dockernotifier.std.*` label suffixes — +including legacy variants like `internal.health` (with a literal dot). + +### Why + +The dashboard side accumulated key-remapping logic to absorb this +variation. The remapping was undocumented and silent. v0.3.0 of the +notifier and v0.5.0 of STD together establish a canonical wire shape +documented in STD's PRD and validated by a pydantic schema. + +### What changed (notifier side) + +- STD notifier targets `/api/v1/register` (new in STD v0.5.0) instead + of `/api/register`. +- Outbound payload uses canonical keys: `host`, `group`, + `internal_health_check_enabled`, `external_health_check_enabled`, + `internal_url`, `external_url`. + +### Coordination + +- STD v0.5.0 must ship before notifier v0.3.0. +- Old notifier deployments (v0.2.x) continue to work against STD v0.5.0 + via STD's compat shim until STD v0.6.0. +- Notifier v0.3.0+ deployments are required before upgrading any STD + instance to v0.6.0. diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 0000000..3bdbee6 --- /dev/null +++ b/docs/PRD.md @@ -0,0 +1,364 @@ +# docker-api-notifier — Product Requirements Document + +> **Status:** living document. Update alongside any change that affects +> architecture, behavior, supported notifier targets, or the contract +> with downstream consumers. + +## Revision History + +| Version | Date | Changes | +|---------|------------|---------| +| 0.1 | 2026-05-10 | Initial PRD. Documents current shipped behavior at v0.2.3 and the planned v0.3.0 cleanup. | + +--- + +## Table of Contents + +1. [Product Overview](#1-product-overview) +2. [Scope](#2-scope) +3. [Architecture](#3-architecture) +4. [Configuration Model](#4-configuration-model) +5. [Current State (v0.2.3)](#5-current-state-v023) +6. [v0.3.0 — Cleanup Release](#6-v030--cleanup-release) +7. [Versioning, Branches, and Releases](#7-versioning-branches-and-releases) +8. [Cross-Repo Coordination](#8-cross-repo-coordination) +9. [Open Questions](#9-open-questions) + +--- + +## 1. Product Overview + +`docker-api-notifier` runs as a sidecar on each Docker host. Its job is to +react to container lifecycle events on that host and notify external +systems so they stay in sync with reality without a human in the loop. + +### 1.1 Original problem space + +- DNS records for containers drifted whenever stacks moved between hosts. +- Service dashboards became stale and required manual upkeep. + +### 1.2 Solution shape + +- Read Docker events from the local socket. +- Read `dockernotifier.*` labels from each container to learn what each + container wants. +- Fan out to one or more notifier modules (DNS, dashboard, future). + +The notifier deliberately holds no state. Each event is processed +independently against current container metadata. + +### 1.3 Design principles + +- **Opt-in per container.** No labels means no notification. Run safely + alongside containers that don't know or care about this notifier. +- **Independent notifier modules.** Each downstream system is its own + module under `notifiers/` with its own auth, retry, and payload shape. +- **No state.** No database, no cache, no queue. Everything is derived + from the live Docker socket plus environment variables. +- **One instance per host.** Multi-host coordination is out of scope. + +--- + +## 2. Scope + +### 2.1 In scope + +- Watching Docker events on a single host per running instance. +- Periodic re-scans as a self-healing measure for missed events. +- Dispatching to pluggable notifier modules under `notifiers/`. +- Reading container metadata exclusively from labels (no shared config + file describing services). + +### 2.2 Out of scope + +- Multi-host orchestration. One notifier instance per host. +- Storing or rendering service state (that's STD's job). +- Acting as an authoritative source for DNS or dashboard config; both + are derived from container labels. +- Bidirectional sync. The notifier writes outward only. + +--- + +## 3. Architecture + +``` +┌────────────────────────────────┐ +│ Docker host │ +│ │ +│ ┌──────────────┐ │ +│ │ Containers │ │ +│ │ with labels │ │ +│ └──────┬───────┘ │ +│ │ │ +│ ┌──────▼─────────┐ │ +│ │ Docker socket │ │ +│ └──────┬─────────┘ │ +│ │ │ +│ ┌──────▼─────────────────┐ │ +│ │ docker-api-notifier │ │ +│ │ main.py event loop │ │ +│ │ ├─ boot scan │ │ +│ │ ├─ event subscription│ │ +│ │ └─ periodic re-scan │ │ +│ │ │ │ +│ │ notifiers/ │ │ +│ │ ├─ technitium_dns │ │ +│ │ └─ service_tracker_ │ │ +│ │ dashboard │ │ +│ └────┬───────────┬───────┘ │ +└───────│───────────│────────────┘ + ▼ ▼ + Technitium STD instance + DNS server /api/v1/register +``` + +### 3.1 Module responsibilities + +- **`main.py`** — Docker client setup, event subscription, label parsing, + per-notifier dispatch, periodic re-scan thread. +- **`notifiers/.py`** — one module per downstream system. Each + exposes a `register(...)` function and owns its own retry policy, + authentication, and payload shape. +- **Common concerns** that should live outside individual notifier + modules: logging configuration, retry helpers, label-to-payload + mapping. (Today these are partially duplicated; see §5.) + +### 3.2 Event flow + +1. Boot pass on startup — every running container is processed with + `action="boot"`. +2. Docker event subscription — events whose `Action` is in + `watched_actions` are processed live. +3. Periodic loop — every `STD_REFRESH_SECONDS` (default 60s), every + running container is reprocessed with `action="refresh"`. + +The periodic loop exists for resilience: if the notifier missed an +event (network blip, container crash mid-event), the next refresh pass +catches it. + +### 3.3 Notifier Module Contract + +Every notifier module under `notifiers/` follows the same shape so +that adding a new downstream target is a small, mechanical change. + +#### File layout + +- One module per downstream system: `notifiers/.py`. +- The module exposes a single public function: + `register(**kwargs) -> None`. +- The module owns its own auth handling, payload construction, + and wire format. It does not own logging configuration or + retry policy — both are shared. + +#### Required imports + +```python +from logging_setup import get_logger +from retry import with_retry + +logger = get_logger("_notifier") +``` + +The logger name should be `_notifier` so log lines remain +filterable per-target. + +#### The base kwargs contract + +`main.py` invokes `register(**kwargs)` with the following keyword +arguments guaranteed present: + +| Key | Type | Meaning | +|-----|------|---------| +| `container_name` | str | Container name (no leading `/`) | +| `container_id` | str | Full Docker container ID | +| `docker_host` | str | The host this notifier instance runs on | +| `docker_status` | str | Container state (e.g. "running", "exited") | +| `image_name` | str | Image reference from container config | +| `stack_name` | Optional[str] | `com.docker.compose.project` label or `None` | +| `started_at` | str | ISO timestamp from container state | +| `action` | str | The action that triggered this call (e.g. "start", "boot", "refresh") | + +Modules may additionally receive notifier-specific extras (typically +from stripped label namespaces). A module reading any extra should +use `kwargs.get(...)` with a sensible default rather than relying +on presence. + +#### Required behavior + +A `register()` implementation must: + +1. Read its own required env vars (e.g. `_URL`, + `_API_TOKEN`). Return early with a single info log line + if any are missing — do not raise. +2. Translate the kwargs into the downstream system's wire format. + The translation lives inside the module, not in `main.py`. +3. Send the request, using `@with_retry` on the network call. +4. Catch `requests.RequestException` after retries; log and return. + Do not let transient failures kill the event loop in `main.py`. +5. Not catch broader exceptions — programming errors should propagate + to `main.py`'s outer try/except for visibility. + +#### Wiring a new notifier into dispatch + +In `main.py`: + +1. Add the module's name to `NOTIFIER_TRIGGERS`, declaring which + actions the notifier responds to (drawn from + `WATCHED_DOCKER_ACTIONS` and `SYNTHETIC_ACTIONS`). +2. Add a dispatch branch in `handle_container_event` that calls the + module's `register(**base_kwargs, **target_specific_extras)`. + +In `README.md`: + +3. Document the module's required env vars. +4. Document any `dockernotifier..*` labels operators set. + +A reference implementation lives at `notifiers/_template.py`. + +--- + +## 4. Configuration Model + +All configuration is via environment variables. There is no config file. + +Two reasons: + +1. The notifier is meant to be one-line-deployable on every host. A + shared config file would be one more thing to template and sync. +2. Per-container behavior comes from labels on those containers, which + is the right place for it — the people writing the compose files + know what they want. + +Environment variables are documented in the `README.md`. + +--- + +## 5. Current State (v0.2.3) + +Tags shipped on `main`: v0.1.0 → v0.2.3. + +### 5.1 What works today + +- Docker event subscription with a fixed action whitelist. +- Boot-time full scan. +- Periodic re-scan thread. +- DNS notifier with no retry, requests-based, fire-and-forget. +- STD notifier with `tenacity`-backed retry, bearer-token auth. +- Label-driven notifier opt-in via `dockernotifier.notifiers`. +- Per-notifier label namespaces (`dockernotifier.dns.*`, + `dockernotifier.std.*`). + +### 5.2 Known issues at v0.2.3 (targeted for v0.3.0) + +| ID | Area | Issue | +|-----|-----------------------|--------------------------------------------------------------------------------------------------------| +| N1 | DNS notifier | **Resolved in v0.3.0.** No retry on transient failure. STD notifier uses `tenacity`; DNS doesn't. Asymmetric. Fixed by extracting a shared `with_retry` decorator (`retry.py`) consumed by both notifiers. DNS also now calls `raise_for_status()` so HTTP 4xx/5xx trigger retries instead of being silently logged as successes. | +| N2 | DNS notifier | **Resolved in v0.3.0.** `trigger_reason` parameter accepted but never used. Removed from the `register()` signature; the trigger log line now reads `action` from the common kwargs contract instead, so it reports the real Docker action (`start`, `boot`, `refresh`) rather than the literal default `"event"`. | +| N3 | Logging | **Resolved in v0.3.0.** Log handler setup duplicated across `main.py`, `notifiers/technitium_dns.py`, and `notifiers/service_tracker_dashboard.py`. | +| N4 | Event handling | **Resolved in v0.3.0.** `"refresh"` is in `NOTIFIER_TRIGGERS["service-tracker-dashboard"]` but not in `watched_actions`, so it never fires from the event stream. The periodic loop is the only path that uses it. | +| N5 | Stack-name fallback | **Resolved in v0.3.0.** When `com.docker.compose.project` is missing, falls back to splitting `container.name` on `_`. Fragile and wrong for any container whose name contains an underscore for unrelated reasons. Fixed by removing the fallback entirely: `stack_name` is now `None` when the label is absent, and each notifier handles that case explicitly. | +| N6 | Comments | **Resolved in v0.3.0.** `STD_REFRESH_SECONDS` default comment in `main.py` says "60 minutes" but the value is 60 seconds. | +| N7 | Wire contract | **Resolved in v0.3.0.** STD notifier now translates its working kwargs dict into STD v0.5.0's canonical schema (`host`, `group_name`, `image_icon`, `internal_health_check_enabled`, `external_health_check_enabled`, `sort_priority`, ...) and posts to `/api/v1/register`. Bool and int coercion happens at the notifier boundary; unknown keys are dropped client-side so STD's strict pydantic validator does not reject the request. | + +### 5.3 Minor housekeeping (not blocking v0.3.0 but worth doing) + +- No CI lint job. Consider adding `ruff` to mirror downstream practices. +- `requirements.txt` is unpinned. Pinning would protect reproducibility. + +--- + +## 6. v0.3.0 — Cleanup Release + +**Required STD v0.5.0 to ship first**, because v0.3.0 emits canonical +key names against `/api/v1/register`. STD v0.5.0 introduced that +endpoint. + +### 6.1 Goals + +- Resolved every issue in §5.2. +- Established a small set of internal modules that future notifier targets + can rely on (logging, retry helper). +- Switched STD notifier to canonical key names + `/api/v1/register`. + +### 6.2 Behavior changes visible to operators + +None for end users. Container labels and environment variables continue +to work exactly as before. The wire payload to STD changes, but STD's +v0.5.0 compat shim accepts both old and new shapes during the overlap +window. + +### 6.3 Internal changes + +- Single shared logging setup module; notifier modules and `main.py` + consume it instead of re-declaring handlers. +- Shared retry helper used by both DNS and STD notifiers; symmetric + retry policy. +- Stack-name resolution falls back to `None` rather than splitting on + `_`. Downstream notifiers handle the missing-stack case explicitly. +- `"refresh"` removed from `NOTIFIER_TRIGGERS` (it's not an action; + the periodic loop calls `handle_container_event(... action="refresh")` + directly and the dispatch logic is rewritten to not consult the + trigger map for synthetic actions). +- DNS notifier signature drops `trigger_reason`. +- Comment fix on `STD_REFRESH_SECONDS`. + +### 6.4 Out of scope for v0.3.0 + +- New notifier targets (Slack, ntfy, etc.). The cleanup makes adding + these easier later, but none ship in v0.3.0. +- Multi-host coordination. +- A config file. Env vars + labels remain the only inputs. +- Test suite. Worth doing eventually (see §9), not in v0.3.0. + +--- + +## 7. Versioning, Branches, and Releases + +- `main` is the default branch and the source of truth for releases. +- All work happens on `dev`. PR `dev` → `main` when ready to release. +- Branch protection: require PR + green build check, block force-push, + block deletion. +- Image tags follow `.github/workflows/docker-publish.yml`: + - push to `dev` → `:dev` and `:sha-` + - push to `main` → `:latest` and `:sha-` + - GitHub Release published → `:latest`, `:`, `:` +- Tags are cut from the GitHub Releases UI against `main`. + +--- + +## 8. Cross-Repo Coordination + +This project is paired with +[service-tracker-dashboard](https://github.com/crzykidd/service-tracker-dashboard). + +### 8.1 Contract ownership + +STD owns the wire contract for the register endpoint. The notifier is +a producer — it sends what STD documents. Wire-format changes start in +STD; the notifier follows. + +### 8.2 Release ordering for the v0.5.0 / v0.3.0 cycle + +1. STD v0.5.0 ships with `/api/v1/register` (canonical keys) and the + compat shim on `/api/register` (legacy keys, deprecated). +2. Notifier v0.3.0 ships with canonical keys against + `/api/v1/register`. +3. STD v0.6.0 (later) removes `/api/register` and the compat shim. + +Operators must upgrade the notifier to v0.3.0+ before STD v0.6.0. + +--- + +## 9. Open Questions + +- **Test coverage.** No tests exist today. Worth investing in a small + suite that fakes the Docker client and asserts dispatch behavior? +- **Health check of notifier itself.** Currently the only liveness + signal is "the container is running." A `/health` endpoint or + heartbeat to STD might be valuable. +- **Backoff state across restarts.** Retry state is in-process. If the + notifier crashes mid-burst it loses its retry queue. Probably + acceptable for a homelab; flag for review if scale grows. +- **New notifier targets.** Likely candidates if needed: Slack, Discord, + ntfy, generic webhook. Each one is ~1 module under `notifiers/` plus + env vars. diff --git a/logging_setup.py b/logging_setup.py new file mode 100644 index 0000000..d1120de --- /dev/null +++ b/logging_setup.py @@ -0,0 +1,51 @@ +""" +Shared logging configuration for docker-api-notifier and its notifier +modules. + +Usage from any module: + + from logging_setup import get_logger + logger = get_logger(__name__) + +The first call configures handlers on the root logger. Subsequent +calls are no-ops with respect to handler setup. + +Environment variables: + NOTIFIER_LOG_TO_STDOUT "0" disables console output. Default "1". +""" + +import logging +import os +from logging.handlers import RotatingFileHandler + +LOG_FILE = "/config/notifier.log" +LOG_FORMAT = "[%(asctime)s] [%(levelname)s] %(message)s" +LOG_MAX_BYTES = 10 * 1024 * 1024 +LOG_BACKUP_COUNT = 4 + +_configured = False + + +def _configure_once(): + global _configured + if _configured: + return + formatter = logging.Formatter(LOG_FORMAT) + file_handler = RotatingFileHandler( + LOG_FILE, maxBytes=LOG_MAX_BYTES, backupCount=LOG_BACKUP_COUNT + ) + file_handler.setFormatter(formatter) + root = logging.getLogger() + root.setLevel(logging.INFO) + root.addHandler(file_handler) + if os.environ.get("NOTIFIER_LOG_TO_STDOUT", "1") == "1": + console = logging.StreamHandler() + console.setFormatter(formatter) + root.addHandler(console) + _configured = True + + +def get_logger(name: str) -> logging.Logger: + """Return a configured logger for the given module name.""" + _configure_once() + return logging.getLogger(name) diff --git a/main.py b/main.py index f3e2a5c..9637535 100644 --- a/main.py +++ b/main.py @@ -4,35 +4,28 @@ from notifiers import technitium_dns, service_tracker_dashboard import threading import time -import logging -from logging.handlers import RotatingFileHandler +from logging_setup import get_logger -# === Logging Setup === -log_formatter = logging.Formatter('[%(asctime)s] [%(levelname)s] %(message)s') -log_handler = RotatingFileHandler( - "/config/notifier.log", maxBytes=10 * 1024 * 1024, backupCount=4 -) +logger = get_logger("main") -log_handler.setFormatter(log_formatter) -log_handler.setLevel(logging.INFO) +# === Settings === +logger.debug("main.py is running") +STD_REFRESH_SECONDS = int(os.environ.get("STD_REFRESH_SECONDS", "60")) # Default to 60 seconds -logger = logging.getLogger() -logger.setLevel(logging.INFO) -logger.addHandler(log_handler) +# Real Docker events the notifier subscribes to. +WATCHED_DOCKER_ACTIONS = frozenset({ + "start", "stop", "die", "pause", "unpause", + "destroy", "kill", "update", +}) -# Optional: also log to console (stdout) -console_handler = logging.StreamHandler() -console_handler.setFormatter(log_formatter) -logger.addHandler(console_handler) +# Synthetic actions the notifier injects (not from Docker). +SYNTHETIC_ACTIONS = frozenset({"boot", "refresh"}) -# === Settings === -logger.debug("main.py is running") -STD_REFRESH_SECONDS = int(os.environ.get("STD_REFRESH_SECONDS", "60")) # Default to 60 minutes +# Per-notifier action sets. Each notifier declares the actions it wants +# to be invoked for, drawn from WATCHED_DOCKER_ACTIONS and SYNTHETIC_ACTIONS. NOTIFIER_TRIGGERS = { "dns": {"boot", "start"}, - "service-tracker-dashboard": { - "boot", "start", "stop", "die", "pause", "unpause", "destroy", "kill", "update", "refresh" - } + "service-tracker-dashboard": WATCHED_DOCKER_ACTIONS | SYNTHETIC_ACTIONS, } @@ -66,55 +59,49 @@ def handle_container_event(container, docker_host, action): if not notifier_list_raw: return notifier_list = [n.strip() for n in notifier_list_raw.split(",") if n.strip()] - container_name = container.name - # Metadata - container_hostname = labels.get("dockernotifier.dns.containerhostname") - zone_label = labels.get("dockernotifier.dns.containerzone") - docker_domain = labels.get("dockernotifier.dns.dockerdomain") - container_fqdn = f"{container_hostname}.{zone_label}" if container_hostname and zone_label else None - stack_name = labels.get("com.docker.compose.project") - if not stack_name and "_" in container.name: - stack_name = container.name.split('_')[0] + base_kwargs = { + "container_name": container.name, + "container_id": container.id, + "docker_host": docker_host, + "docker_status": container.attrs["State"]["Status"], + "image_name": container.attrs["Config"]["Image"], + "stack_name": labels.get("com.docker.compose.project"), + "started_at": container.attrs["State"]["StartedAt"], + "action": action, + } + + logger.info(f"[MATCH] Container {action.upper()}: {container.name}") - logger.info(f"[MATCH] Container {action.upper()}: {container_name}") + if action in NOTIFIER_TRIGGERS["dns"] and "dns" in notifier_list: + container_hostname = labels.get("dockernotifier.dns.containerhostname") + zone_label = labels.get("dockernotifier.dns.containerzone") + docker_domain = labels.get("dockernotifier.dns.dockerdomain") + container_fqdn = ( + f"{container_hostname}.{zone_label}" + if container_hostname and zone_label + else None + ) - if action in {"boot", "start"} and "dns" in notifier_list: if container_fqdn and docker_domain and zone_label: - logger.info(f"DNS notifier triggered for {container_name} on {action}") + logger.info(f"DNS notifier triggered for {container.name} on {action}") technitium_dns.register( + **base_kwargs, container_fqdn=container_fqdn, zone=zone_label, value=f"{docker_host}.{docker_domain}", - container_name=container_name, - docker_host=docker_host, - stack_name=stack_name ) else: - logger.warning(f"Missing DNS label info for {container_name}, skipping DNS registration") + logger.warning(f"Missing DNS label info for {container.name}, skipping DNS registration") if "service-tracker-dashboard" in notifier_list and action in NOTIFIER_TRIGGERS["service-tracker-dashboard"]: - logger.info(f"STD notifier triggered for {container_name} on {action}") - # Dynamically extract all dockernotifier.std.* labels - std_labels = { + logger.info(f"STD notifier triggered for {container.name} on {action}") + std_extras = { key.replace("dockernotifier.std.", ""): value for key, value in labels.items() if key.startswith("dockernotifier.std.") } - - # Add base metadata (you can omit or include as needed) - std_labels.update({ - "container_name": container_name, - "docker_host": docker_host, - "container_id": container.id, - "docker_status": container.attrs["State"]["Status"], - "image_name": container.attrs["Config"]["Image"], - "stack_name": stack_name, - "started_at": container.attrs["State"]["StartedAt"] - }) - - # Send to notifier - service_tracker_dashboard.register(**std_labels) + service_tracker_dashboard.register(**base_kwargs, **std_extras) def main(): client = docker.from_env() @@ -130,10 +117,9 @@ def main(): threading.Thread(target=periodic_update_loop, args=(docker_host,), daemon=True).start() - watched_actions = {"start", "stop", "die", "pause", "unpause", "destroy", "kill", "update"} for event in client.events(decode=True): action = event.get("Action") - if action not in watched_actions: + if action not in WATCHED_DOCKER_ACTIONS: continue container_id = event.get("id") try: diff --git a/notifiers/_template.py b/notifiers/_template.py new file mode 100644 index 0000000..45eb786 --- /dev/null +++ b/notifiers/_template.py @@ -0,0 +1,105 @@ +""" +Reference template for a new notifier module. + +This file is NOT wired into `main.py`'s dispatch. It exists as a +copy-and-modify starting point for adding a new downstream target. + +To add a real notifier: + +1. Copy this file to `notifiers/.py` (e.g. `slack.py`). +2. Replace `_TARGET` placeholders with your target name. +3. Define your wire format in `_to_payload()`. +4. Wire the module into `main.py`'s dispatch (see PRD section 3.3). +5. Document env vars and labels in `README.md`. +6. Remove this top docstring; replace with module-specific docs. + +The structure below follows the notifier module contract documented +in PRD section 3.3. +""" + +import os +import json +from datetime import datetime + +import requests + +from logging_setup import get_logger +from retry import with_retry + +# Replace "_template" with your target name (e.g. "slack"). +logger = get_logger("_template_notifier") + + +@with_retry +def _send(endpoint: str, payload: dict, headers: dict) -> requests.Response: + """The actual network call, wrapped with shared retry policy.""" + response = requests.post(endpoint, json=payload, headers=headers) + response.raise_for_status() + return response + + +def _to_payload(kwargs: dict) -> dict: + """ + Translate the notifier's working kwargs into the downstream + system's wire format. + + This is where target-specific shaping lives. Examples: + - Map field names to what the downstream API expects. + - Coerce types (string label values -> int / bool). + - Drop fields the target doesn't care about. + - Add target-specific fields (e.g. a Slack channel ID). + """ + # Example: pass through the base contract verbatim. + # Replace with real translation for your target. + return { + "container": kwargs.get("container_name"), + "host": kwargs.get("docker_host"), + "status": kwargs.get("docker_status"), + "action": kwargs.get("action"), + "at": kwargs.get("timestamp", datetime.now().isoformat()), + } + + +def register(**kwargs) -> None: + """ + Entry point invoked by `main.py`. + + Receives the base kwargs contract (PRD section 3.3) plus any + extras `main.py` passes for this specific notifier (typically + stripped `dockernotifier..*` labels). + """ + # 1. Read required env vars. Return early if any are missing. + target_url = os.environ.get("_TEMPLATE_URL") + api_token = os.environ.get("_TEMPLATE_API_TOKEN") + if not target_url or not api_token: + logger.info( + "Not enabling _template integration — missing " + "_TEMPLATE_URL or _TEMPLATE_API_TOKEN" + ) + return + + container_name = kwargs.get("container_name", "") + action = kwargs.get("action", "event") + logger.info(f'_template notifier triggered for "{container_name}" on "{action}"') + + # 2. Translate to wire format. + payload = _to_payload(kwargs) + + # 3. Build request. + headers = { + "Authorization": f"Bearer {api_token}", + "Content-Type": "application/json", + } + + logger.debug("Sending payload:") + logger.debug(json.dumps(payload, indent=2)) + + # 4. Send, catching only RequestException (transient failures). + # Programming errors propagate to main.py's outer try/except. + try: + _send(target_url, payload, headers) + logger.debug(f"Successfully sent _template event for: {container_name}") + except requests.RequestException as e: + logger.error( + f"Failed to send _template event for '{container_name}' after retries: {e}" + ) diff --git a/notifiers/service_tracker_dashboard.py b/notifiers/service_tracker_dashboard.py index efb81d2..89111e5 100644 --- a/notifiers/service_tracker_dashboard.py +++ b/notifiers/service_tracker_dashboard.py @@ -2,36 +2,73 @@ import requests from datetime import datetime import json -from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type -import logging -from logging.handlers import RotatingFileHandler - -NOTIFIER_LOG_FILE = "/config/notifier.log" - -# === Logging Setup === -logger = logging.getLogger("std_notifier") -if not logger.handlers: - log_formatter = logging.Formatter('[%(asctime)s] [%(levelname)s] %(message)s') - log_handler = RotatingFileHandler( - NOTIFIER_LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=1 - ) - log_handler.setFormatter(log_formatter) - logger.setLevel(logging.INFO) - logger.addHandler(log_handler) - - # Optional console logging - if os.environ.get("STD_LOG_TO_STDOUT", "1") == "1": - console_handler = logging.StreamHandler() - console_handler.setFormatter(log_formatter) - logger.addHandler(console_handler) - - -@retry( - reraise=True, - stop=stop_after_attempt(3), - wait=wait_exponential(multiplier=1, min=2, max=10), - retry=retry_if_exception_type(requests.RequestException) -) +from logging_setup import get_logger +from retry import with_retry + +logger = get_logger("std_notifier") + + +# Map from "what arrives in kwargs" to "what STD's canonical schema expects". +# Source keys come from a mix of the base kwargs contract and stripped +# `dockernotifier.std.*` labels. +_LEGACY_TO_CANONICAL = { + # Base kwargs renames + "docker_host": "host", + # Label-derived renames + "group": "group_name", + "internal.health": "internal_health_check_enabled", + "internal_health": "internal_health_check_enabled", + "external.health": "external_health_check_enabled", + "external_health": "external_health_check_enabled", + "icon": "image_icon", + "sort.priority": "sort_priority", +} + +# Keys that should pass through unchanged. +_PASSTHROUGH = { + "container_name", "container_id", "docker_status", "stack_name", + "started_at", "image_name", "internalurl", "externalurl", + "timestamp", +} + +# Keys in the canonical schema that need type coercion from string. +_BOOL_FIELDS = {"internal_health_check_enabled", "external_health_check_enabled"} +_INT_FIELDS = {"sort_priority"} + + +def _to_canonical(kwargs: dict) -> dict: + """ + Translate the notifier's working kwargs dict into a payload + matching STD v0.5.0's canonical schema for /api/v1/register. + + Unknown keys are dropped (with a debug log). Type coercion is + applied for boolean and integer fields; coercion failures cause + the field to be dropped with a warning. + """ + out = {} + for key, value in kwargs.items(): + if value is None: + continue + canonical_key = _LEGACY_TO_CANONICAL.get(key, key) + if canonical_key not in _PASSTHROUGH \ + and canonical_key not in _LEGACY_TO_CANONICAL.values(): + logger.debug(f"Dropping unknown key '{key}' from STD payload") + continue + if canonical_key in _BOOL_FIELDS: + value = str(value).strip().lower() in ("true", "1", "yes") + elif canonical_key in _INT_FIELDS: + try: + value = int(value) + except (TypeError, ValueError): + logger.warning( + f"Could not coerce {canonical_key}='{value}' to int; dropping" + ) + continue + out[canonical_key] = value + return out + + +@with_retry def post_with_retry(endpoint, payload, headers): response = requests.post(endpoint, json=payload, headers=headers) response.raise_for_status() @@ -39,6 +76,14 @@ def post_with_retry(endpoint, payload, headers): def register(**kwargs): + """ + Register a container with the Service Tracker Dashboard. + + Receives the common notifier base kwargs contract (see PRD §3.3) + plus all stripped `dockernotifier.std.*` labels. The merged dict + is translated into STD v0.5.0's canonical schema via + `_to_canonical()` before being posted to `/api/v1/register`. + """ dashboard_url = os.environ.get("STD_URL") api_token = os.environ.get("STD_API_TOKEN") @@ -46,28 +91,33 @@ def register(**kwargs): logger.info("Not enabling Service Tracker Dashboard integration — missing STD_URL or STD_API_TOKEN") return - # Fallback timestamp if not provided kwargs.setdefault("timestamp", datetime.now().isoformat()) container_name = kwargs.get("container_name") - docker_host = kwargs.get("docker_host") + action = kwargs.get("action", "event") + logger.info(f'STD notifier triggered for "{container_name}" on "{action}"') - trigger_reason = kwargs.get("docker_status") or "event" - logger.info(f'STD notifier triggered for "{container_name}" due to "{trigger_reason}"') + payload = _to_canonical(kwargs) - endpoint = f"{dashboard_url.rstrip('/')}/api/register" + if "host" not in payload or "container_name" not in payload: + logger.error( + f"STD payload missing required fields after canonical translation; " + f"skipping. Payload keys: {sorted(payload.keys())}" + ) + return + endpoint = f"{dashboard_url.rstrip('/')}/api/v1/register" headers = { "Authorization": f"Bearer {api_token}", - "Content-Type": "application/json" + "Content-Type": "application/json", } logger.debug("Sending registration payload:") - logger.debug(json.dumps(kwargs, indent=2)) + logger.debug(json.dumps(payload, indent=2)) logger.debug(f"Endpoint: {endpoint}") try: - post_with_retry(endpoint, kwargs, headers) - logger.debug(f"Successfully registered: {container_name} on {docker_host}") + post_with_retry(endpoint, payload, headers) + logger.debug(f"Successfully registered: {container_name} on {payload.get('host')}") except requests.RequestException as e: logger.error(f"Failed to register container '{container_name}' after retries: {e}") diff --git a/notifiers/technitium_dns.py b/notifiers/technitium_dns.py index 92a1d66..4fd83d4 100644 --- a/notifiers/technitium_dns.py +++ b/notifiers/technitium_dns.py @@ -2,37 +2,43 @@ import requests import urllib.parse from datetime import datetime -import logging -from logging.handlers import RotatingFileHandler - -NOTIFIER_LOG_FILE = "/config/notifier.log" - -# === Logging Setup === -logger = logging.getLogger("dns_notifier") -if not logger.handlers: - log_formatter = logging.Formatter('[%(asctime)s] [%(levelname)s] %(message)s') - log_handler = RotatingFileHandler( - NOTIFIER_LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=1 - ) - log_handler.setFormatter(log_formatter) - logger.setLevel(logging.INFO) - logger.addHandler(log_handler) - - # Optional: also log to console if running standalone - if os.environ.get("DNS_LOG_TO_STDOUT", "1") == "1": - console_handler = logging.StreamHandler() - console_handler.setFormatter(log_formatter) - logger.addHandler(console_handler) - - -def register(container_fqdn, zone, value, container_name, docker_host, stack_name=None, trigger_reason="event"): +from logging_setup import get_logger +from retry import with_retry + +logger = get_logger("dns_notifier") + + +@with_retry +def _do_dns_update(dns_url, params): + response = requests.get(dns_url, params=params) + response.raise_for_status() + return response + + +def register(*, container_fqdn, zone, value, **kwargs): + """ + Register a CNAME with Technitium DNS. + + DNS-specific (required): container_fqdn, zone, value. + + Accepts the common notifier base kwargs contract via **kwargs; + container_name, docker_host, and stack_name are read out for + log lines and the record comment. Unrecognised kwargs are + ignored, which keeps the signature forward-compatible as the + contract grows. + """ dns_url = os.environ.get("DNS_SERVER_URL") token = os.environ.get("DNS_SERVER_API_TOKEN") if not dns_url or not token: logger.error("Missing DNS_SERVER_URL or DNS_SERVER_API_TOKEN") return - logger.info(f'DNS notifier triggered for "{container_name}" due to "{trigger_reason}"') + container_name = kwargs.get("container_name", "") + docker_host = kwargs.get("docker_host", "") + stack_name = kwargs.get("stack_name") + action = kwargs.get("action", "") + + logger.info(f'DNS notifier triggered for "{container_name}" due to "{action}"') timestamp = datetime.now().strftime("%Y-%m-%dT%H:%M:%S%z") if stack_name: @@ -52,7 +58,7 @@ def register(container_fqdn, zone, value, container_name, docker_host, stack_nam } try: - response = requests.get(dns_url, params=params) + response = _do_dns_update(dns_url, params) logger.info(f'DNS update response for {container_fqdn}: {response.text}') - except Exception as e: - logger.error(f'DNS update failed for {container_name}: {e}') + except requests.RequestException as e: + logger.error(f'DNS update failed for {container_name} after retries: {e}') diff --git a/retry.py b/retry.py new file mode 100644 index 0000000..5f1d814 --- /dev/null +++ b/retry.py @@ -0,0 +1,59 @@ +""" +Shared retry policy for notifier modules. + +Wraps `tenacity` with the project-wide retry policy. Use as a +decorator on functions that make outbound HTTP calls: + + from retry import with_retry + + @with_retry + def my_outbound_call(...): + ... + +The retry policy: + - 3 attempts total + - Exponential backoff: 2s, 4s, 8s (capped at 10s) + - Retries only on `requests.RequestException` (network errors, + timeouts, connection errors, HTTP errors raised via + `raise_for_status()`). + - Re-raises the last exception if all attempts fail. + +These values are deliberately module-level constants so a future +parameterized version (e.g. per-notifier policy presets) is a small +refactor rather than a redesign. + +Idempotency: `with_retry` assumes the wrapped operation is idempotent. +If a request succeeds on the server but the response is lost in +transit, retrying will re-issue the same request. Both current +consumers (STD `/api/register` upsert, Technitium DNS with +`overwrite=true`) are idempotent. A future notifier wrapping a +non-idempotent operation must NOT use `with_retry` and should +implement explicit single-attempt error handling instead. +""" + +import requests +from tenacity import ( + retry, + stop_after_attempt, + wait_exponential, + retry_if_exception_type, +) + +# Policy values — change here, not at call sites. +MAX_ATTEMPTS = 3 +BACKOFF_MULTIPLIER = 1 +BACKOFF_MIN_SECONDS = 2 +BACKOFF_MAX_SECONDS = 10 +RETRY_ON = (requests.RequestException,) + + +with_retry = retry( + reraise=True, + stop=stop_after_attempt(MAX_ATTEMPTS), + wait=wait_exponential( + multiplier=BACKOFF_MULTIPLIER, + min=BACKOFF_MIN_SECONDS, + max=BACKOFF_MAX_SECONDS, + ), + retry=retry_if_exception_type(RETRY_ON), +)