From 6dec9970cc456dfbb3b5e31898c3a55485005d09 Mon Sep 17 00:00:00 2001 From: Matthew Andersen Date: Sun, 10 May 2026 13:46:54 -0700 Subject: [PATCH 01/15] update to docs and readme --- .claude/settings.json | 20 +++ CHANGELOG.md | 85 +++++++++++++ CLAUDE.md | 124 ++++++++++++++++++ README.md | 233 +++++++++++++++++++++++++--------- READMEOLD.md | 114 +++++++++++++++++ docs/HISTORY.md | 69 ++++++++++ docs/PRD.md | 285 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 873 insertions(+), 57 deletions(-) create mode 100644 .claude/settings.json create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 READMEOLD.md create mode 100644 docs/HISTORY.md create mode 100644 docs/PRD.md 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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7836bae --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,85 @@ +# 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] + +> Targeting v0.3.0. **Must ship after STD v0.5.0** — this release starts +> emitting canonical keys against `/api/v1/register`, which STD v0.5.0 +> introduces. + +### 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. + +### Changed +- STD notifier emits canonical key names (`host`, `group`, + `internal_health_check_enabled`, ...) and posts to + `/api/v1/register` instead of `/api/register`. Old STD instances + continue to work via STD's compat shim until STD v0.6.0. +- Stack-name resolution: when `com.docker.compose.project` is missing, + the notifier passes `stack_name=None` rather than splitting the + container name on `_`. + +### Removed +- Unused `trigger_reason` parameter from the DNS notifier's `register()` + signature. +- Stale `"refresh"` entry in `NOTIFIER_TRIGGERS`. The periodic re-scan + path no longer consults the trigger map. + +### Fixed +- `watched_actions` now includes everything `NOTIFIER_TRIGGERS` + references, so events the notifier claims to support actually fire. +- 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.2.3...HEAD +[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..726ae92 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,124 @@ +# 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 + +- One module per downstream system, under `notifiers/`. +- Each module exposes a `register(...)` function. +- Each module owns its own auth handling and payload shape — wire format + is decided by the downstream system, not by this code. +- Retry policy is shared via the common retry helper (v0.3.0+). +- Logging configuration is shared via a single setup module (v0.3.0+) — + individual notifiers must not declare their own handlers. +- Adding a new notifier target: drop a new module under `notifiers/`, + wire it into the dispatch in `main.py`, document the env vars in + `README.md`, and add a section to `PRD.md` §3. + +## Git Rules + +- Do NOT add `Co-authored-by` lines to commit messages. diff --git a/README.md b/README.md index 2efe3e4..d60da56 100644 --- a/README.md +++ b/README.md @@ -4,100 +4,141 @@ ![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**. | -### 🌐 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 | +| 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. | +| `STD_LOG_TO_STDOUT` | No | Set to `0` to silence the STD notifier's stdout logging. | +| `DNS_LOG_TO_STDOUT` | No | Set to `0` to silence the DNS notifier's stdout logging. | + +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. + +### Notifier selection -Add labels to your containers to control what happens when they're started or updated. +| Label | Description | +|-----------------------------|-------------| +| `dockernotifier.notifiers` | Comma-separated list of notifiers to run for this container. Valid values: `dns`, `service-tracker-dashboard`. | -### 🔧 Required for Activation +### DNS labels -| Label | Required | Description | -|------------------------------|----------|-------------| -| dockernotifier.notifiers | ✅ Yes | List of notifiers to run, e.g. `dns,service-tracker-dashboard`. | +All three are required for the DNS notifier to act on a container. -### 🌐 DNS Labels +| 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 `.`. | -| 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`). | +### STD labels -### 📊 Service Tracker Labels +All STD labels are optional. Anything you set gets forwarded to STD; STD +applies its own defaults for anything you don't. -| 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`)| +| 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.** Today STD accepts both legacy variants like +> `dockernotifier.std.internal.health` and canonical forms. Starting in +> notifier v0.3.0 + STD v0.5.0, the notifier emits canonical key names on +> the wire to a new `/api/v1/register` endpoint. Old notifier deployments +> continue to work against STD's compat shim until STD v0.6.0. --- -## 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 +147,87 @@ 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`. +- The STD notifier uses retry-with-backoff (`tenacity`) for transient + network failures. + +--- + +## 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..2bd9af8 --- /dev/null +++ b/docs/PRD.md @@ -0,0 +1,285 @@ +# 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. + +--- + +## 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 | No retry on transient failure. STD notifier uses `tenacity`; DNS doesn't. Asymmetric. | +| N2 | DNS notifier | `trigger_reason` parameter accepted but never used. | +| N3 | Logging | Log handler setup duplicated across `main.py`, `notifiers/technitium_dns.py`, and `notifiers/service_tracker_dashboard.py`. | +| N4 | Event handling | `"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 | 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. | +| N6 | Comments | `STD_REFRESH_SECONDS` default comment in `main.py` says "60 minutes" but the value is 60 seconds. | +| N7 | Wire contract | STD notifier currently sends a free-form kwargs dict. Needs to align with STD v0.5.0 canonical key names and target the new `/api/v1/register` endpoint. | + +### 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 + +**Cannot ship until STD v0.5.0 is released**, because v0.3.0 starts +emitting canonical key names against `/api/v1/register`. STD v0.5.0 +introduces that endpoint. + +### 6.1 Goals + +- Resolve every issue in §5.2. +- Establish a small set of internal modules that future notifier targets + can rely on (logging, retry helper). +- Switch 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. From 25bccd0dd0195860b2b01718239a1f3a2c987308 Mon Sep 17 00:00:00 2001 From: Matthew Andersen Date: Mon, 11 May 2026 19:53:46 -0700 Subject: [PATCH 02/15] chore: ignore config-dev/ used by docker-compose-dev.yml --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ce9ac70..c50d736 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ config.py *.pid *.sock config/ +config-dev/ images/ docker-compose.override.yml From e6ab6ebd70c01c3065a022a71865495b2ca0004e Mon Sep 17 00:00:00 2001 From: Matthew Andersen Date: Tue, 12 May 2026 00:03:57 -0700 Subject: [PATCH 03/15] fix: correct misleading comment on STD_REFRESH_SECONDS default --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index f3e2a5c..4f7b408 100644 --- a/main.py +++ b/main.py @@ -27,7 +27,7 @@ # === Settings === logger.debug("main.py is running") -STD_REFRESH_SECONDS = int(os.environ.get("STD_REFRESH_SECONDS", "60")) # Default to 60 minutes +STD_REFRESH_SECONDS = int(os.environ.get("STD_REFRESH_SECONDS", "60")) # Default to 60 seconds NOTIFIER_TRIGGERS = { "dns": {"boot", "start"}, "service-tracker-dashboard": { From e40c1baa8e645e46690b034f79aec36be08436aa Mon Sep 17 00:00:00 2001 From: Matthew Andersen Date: Tue, 12 May 2026 10:13:45 -0700 Subject: [PATCH 04/15] refactor: separate real and synthetic actions in dispatch constants --- CHANGELOG.md | 11 +++++++---- main.py | 19 ++++++++++++++----- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7836bae..6c3a4f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,12 +29,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - Unused `trigger_reason` parameter from the DNS notifier's `register()` signature. -- Stale `"refresh"` entry in `NOTIFIER_TRIGGERS`. The periodic re-scan - path no longer consults the trigger map. +- Inline `watched_actions` literal in `main()`; the Docker event loop + now reads from the module-level `WATCHED_DOCKER_ACTIONS` constant. ### Fixed -- `watched_actions` now includes everything `NOTIFIER_TRIGGERS` - references, so events the notifier claims to support actually fire. +- 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"). diff --git a/main.py b/main.py index 4f7b408..1e0003b 100644 --- a/main.py +++ b/main.py @@ -28,11 +28,21 @@ # === Settings === logger.debug("main.py is running") STD_REFRESH_SECONDS = int(os.environ.get("STD_REFRESH_SECONDS", "60")) # Default to 60 seconds + +# Real Docker events the notifier subscribes to. +WATCHED_DOCKER_ACTIONS = frozenset({ + "start", "stop", "die", "pause", "unpause", + "destroy", "kill", "update", +}) + +# Synthetic actions the notifier injects (not from Docker). +SYNTHETIC_ACTIONS = frozenset({"boot", "refresh"}) + +# 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, } @@ -130,10 +140,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: From ae24c3989af813d99bbd0abd4e8b21c2e9b41cfb Mon Sep 17 00:00:00 2001 From: Matthew Andersen Date: Tue, 12 May 2026 10:18:48 -0700 Subject: [PATCH 05/15] refactor: extract shared logging setup into logging_setup module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds logging_setup.py exposing get_logger(name). main.py and both notifier modules now consume it instead of declaring their own RotatingFileHandler / StreamHandler / formatter blocks. Logger names, log format, destination, and rotation size are unchanged. backupCount is unified at 4 (matching main.py; notifier modules had 1). Replaces DNS_LOG_TO_STDOUT and STD_LOG_TO_STDOUT with a single NOTIFIER_LOG_TO_STDOUT (default "1" — console on, behavior preserved for operators who never set the old vars). --- CHANGELOG.md | 4 ++ README.md | 11 +++--- logging_setup.py | 51 ++++++++++++++++++++++++++ main.py | 23 ++---------- notifiers/service_tracker_dashboard.py | 22 +---------- notifiers/technitium_dns.py | 22 +---------- 6 files changed, 67 insertions(+), 66 deletions(-) create mode 100644 logging_setup.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c3a4f4..74cbc86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`. ### Removed - Unused `trigger_reason` parameter from the DNS notifier's `register()` diff --git a/README.md b/README.md index d60da56..f7eb5eb 100644 --- a/README.md +++ b/README.md @@ -60,10 +60,11 @@ notifier targets can be added without touching the core event loop. ### General -| Variable | Required | Default | Description | -|-------------------------|----------|---------|-------------| -| `TZ` | No | `UTC` | Timezone for log timestamps. | -| `STD_REFRESH_SECONDS` | No | `60` | Periodic re-scan interval in **seconds**. | +| 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. | ### Technitium DNS @@ -79,8 +80,6 @@ notifier targets can be added without touching the core event loop. |-------------------|----------|-------------| | `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. | -| `STD_LOG_TO_STDOUT` | No | Set to `0` to silence the STD notifier's stdout logging. | -| `DNS_LOG_TO_STDOUT` | No | Set to `0` to silence the DNS notifier's stdout logging. | 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 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 1e0003b..0108c8b 100644 --- a/main.py +++ b/main.py @@ -4,26 +4,9 @@ from notifiers import technitium_dns, service_tracker_dashboard import threading import time -import logging -from logging.handlers import RotatingFileHandler - -# === Logging Setup === -log_formatter = logging.Formatter('[%(asctime)s] [%(levelname)s] %(message)s') -log_handler = RotatingFileHandler( - "/config/notifier.log", maxBytes=10 * 1024 * 1024, backupCount=4 -) - -log_handler.setFormatter(log_formatter) -log_handler.setLevel(logging.INFO) - -logger = logging.getLogger() -logger.setLevel(logging.INFO) -logger.addHandler(log_handler) - -# Optional: also log to console (stdout) -console_handler = logging.StreamHandler() -console_handler.setFormatter(log_formatter) -logger.addHandler(console_handler) +from logging_setup import get_logger + +logger = get_logger("main") # === Settings === logger.debug("main.py is running") diff --git a/notifiers/service_tracker_dashboard.py b/notifiers/service_tracker_dashboard.py index efb81d2..c9eb950 100644 --- a/notifiers/service_tracker_dashboard.py +++ b/notifiers/service_tracker_dashboard.py @@ -3,27 +3,9 @@ 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 +from logging_setup import get_logger -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) +logger = get_logger("std_notifier") @retry( diff --git a/notifiers/technitium_dns.py b/notifiers/technitium_dns.py index 92a1d66..5f5bfef 100644 --- a/notifiers/technitium_dns.py +++ b/notifiers/technitium_dns.py @@ -2,27 +2,9 @@ import requests import urllib.parse from datetime import datetime -import logging -from logging.handlers import RotatingFileHandler +from logging_setup import get_logger -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) +logger = get_logger("dns_notifier") def register(container_fqdn, zone, value, container_name, docker_host, stack_name=None, trigger_reason="event"): From 5318882b84096debc3db9583b22fae92fcbb653e Mon Sep 17 00:00:00 2001 From: Matthew Andersen Date: Tue, 12 May 2026 10:19:10 -0700 Subject: [PATCH 06/15] update gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c50d736..a8723b9 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ config/ config-dev/ images/ docker-compose.override.yml +docker-compose-dev.yml # Virtual environments venv/ From 7c753cd1846b1c455567a8970cc86d4f7108991a Mon Sep 17 00:00:00 2001 From: Matthew Andersen Date: Tue, 12 May 2026 10:41:52 -0700 Subject: [PATCH 07/15] feat: shared retry helper; DNS notifier inherits retry on transient failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds retry.py at the repo root exposing a single `with_retry` decorator that wraps tenacity with the project-wide policy (3 attempts, exponential backoff 2s/4s/8s capped at 10s, retries on requests.RequestException). Both notifier modules now consume it; the inline @retry block in service_tracker_dashboard.py is removed. DNS notifier behavior changes (intentional, called out in CHANGELOG): - Calls raise_for_status() so HTTP 4xx/5xx from Technitium triggers the retry path instead of being silently logged as success. - Except clause narrowed from bare Exception to requests.RequestException. Programming errors (e.g. malformed params) now propagate to the outer handler in main.py rather than being swallowed. The early-return on missing DNS_SERVER_URL / DNS_SERVER_API_TOKEN still fires before any retry logic, so DNS remains inert when the env vars are absent. PRD §5.2 issue N1 marked resolved. README updated to describe the shared policy. Dev smoke test: brought up docker-compose-dev.yml, restarted, confirmed clean boot and STD register flow. Spun up a throwaway nginx with dockernotifier.notifiers=dns to exercise the DNS path; logged the expected "Missing DNS_SERVER_URL or DNS_SERVER_API_TOKEN" no-op line with no crash. --- CHANGELOG.md | 3 ++ README.md | 9 +++- docs/PRD.md | 2 +- notifiers/service_tracker_dashboard.py | 9 +--- notifiers/technitium_dns.py | 14 ++++-- retry.py | 59 ++++++++++++++++++++++++++ 6 files changed, 83 insertions(+), 13 deletions(-) create mode 100644 retry.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 74cbc86..ecebd79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. ### Removed - Unused `trigger_reason` parameter from the DNS notifier's `register()` diff --git a/README.md b/README.md index f7eb5eb..c05ddaf 100644 --- a/README.md +++ b/README.md @@ -196,8 +196,13 @@ Volumes: - 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`. -- The STD notifier uses retry-with-backoff (`tenacity`) for transient - network failures. +- 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. --- diff --git a/docs/PRD.md b/docs/PRD.md index 2bd9af8..110f013 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -173,7 +173,7 @@ Tags shipped on `main`: v0.1.0 → v0.2.3. | ID | Area | Issue | |-----|-----------------------|--------------------------------------------------------------------------------------------------------| -| N1 | DNS notifier | No retry on transient failure. STD notifier uses `tenacity`; DNS doesn't. Asymmetric. | +| 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 | `trigger_reason` parameter accepted but never used. | | N3 | Logging | Log handler setup duplicated across `main.py`, `notifiers/technitium_dns.py`, and `notifiers/service_tracker_dashboard.py`. | | N4 | Event handling | `"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. | diff --git a/notifiers/service_tracker_dashboard.py b/notifiers/service_tracker_dashboard.py index c9eb950..11c4815 100644 --- a/notifiers/service_tracker_dashboard.py +++ b/notifiers/service_tracker_dashboard.py @@ -2,18 +2,13 @@ import requests from datetime import datetime import json -from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type from logging_setup import get_logger +from retry import with_retry logger = get_logger("std_notifier") -@retry( - reraise=True, - stop=stop_after_attempt(3), - wait=wait_exponential(multiplier=1, min=2, max=10), - retry=retry_if_exception_type(requests.RequestException) -) +@with_retry def post_with_retry(endpoint, payload, headers): response = requests.post(endpoint, json=payload, headers=headers) response.raise_for_status() diff --git a/notifiers/technitium_dns.py b/notifiers/technitium_dns.py index 5f5bfef..e42096a 100644 --- a/notifiers/technitium_dns.py +++ b/notifiers/technitium_dns.py @@ -3,10 +3,18 @@ import urllib.parse from datetime import datetime 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, container_name, docker_host, stack_name=None, trigger_reason="event"): dns_url = os.environ.get("DNS_SERVER_URL") token = os.environ.get("DNS_SERVER_API_TOKEN") @@ -34,7 +42,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), +) From 2a829cc85fc38e7b44905ebc6822cf1995fb4930 Mon Sep 17 00:00:00 2001 From: Matthew Andersen Date: Tue, 12 May 2026 10:45:42 -0700 Subject: [PATCH 08/15] feat: introduce common kwargs contract for notifier dispatch main.py builds a single base_kwargs dict in handle_container_event and passes it to every notifier alongside any notifier-specific extras. Adds `action` to the contract so notifier modules see why they were invoked without inventing per-module conventions. Also removes the brittle stack-name fallback: when `com.docker.compose.project` is missing, stack_name is now None rather than the first `_`-split segment of the container name. --- CHANGELOG.md | 5 +++++ docs/PRD.md | 2 +- main.py | 62 ++++++++++++++++++++++++---------------------------- 3 files changed, 34 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecebd79..a4a6a6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. ### Changed - STD notifier emits canonical key names (`host`, `group`, diff --git a/docs/PRD.md b/docs/PRD.md index 110f013..0c0e15b 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -177,7 +177,7 @@ Tags shipped on `main`: v0.1.0 → v0.2.3. | N2 | DNS notifier | `trigger_reason` parameter accepted but never used. | | N3 | Logging | Log handler setup duplicated across `main.py`, `notifiers/technitium_dns.py`, and `notifiers/service_tracker_dashboard.py`. | | N4 | Event handling | `"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 | 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. | +| 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 | `STD_REFRESH_SECONDS` default comment in `main.py` says "60 minutes" but the value is 60 seconds. | | N7 | Wire contract | STD notifier currently sends a free-form kwargs dict. Needs to align with STD v0.5.0 canonical key names and target the new `/api/v1/register` endpoint. | diff --git a/main.py b/main.py index 0108c8b..9637535 100644 --- a/main.py +++ b/main.py @@ -59,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}") + + 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 + ) - logger.info(f"[MATCH] Container {action.upper()}: {container_name}") - - 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() From a11aa888a49433a7cea1a08f214ab65356fcde5f Mon Sep 17 00:00:00 2001 From: Matthew Andersen Date: Tue, 12 May 2026 10:46:09 -0700 Subject: [PATCH 09/15] refactor: technitium_dns.register accepts base kwargs contract Signature is now keyword-only DNS extras (container_fqdn, zone, value) plus **kwargs for the common notifier base contract. container_name, docker_host, and stack_name are read out of kwargs for the log line and record comment, with sane fallbacks if absent. trigger_reason stays for now and is dropped in a later session. --- notifiers/technitium_dns.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/notifiers/technitium_dns.py b/notifiers/technitium_dns.py index e42096a..28dcefd 100644 --- a/notifiers/technitium_dns.py +++ b/notifiers/technitium_dns.py @@ -15,13 +15,28 @@ def _do_dns_update(dns_url, params): return response -def register(container_fqdn, zone, value, container_name, docker_host, stack_name=None, trigger_reason="event"): +def register(*, container_fqdn, zone, value, trigger_reason="event", **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 + container_name = kwargs.get("container_name", "") + docker_host = kwargs.get("docker_host", "") + stack_name = kwargs.get("stack_name") + logger.info(f'DNS notifier triggered for "{container_name}" due to "{trigger_reason}"') timestamp = datetime.now().strftime("%Y-%m-%dT%H:%M:%S%z") From 160c5f4ad8142a54568cef5b6d337bdc2b0ffa2b Mon Sep 17 00:00:00 2001 From: Matthew Andersen Date: Tue, 12 May 2026 10:46:27 -0700 Subject: [PATCH 10/15] docs: document base kwargs contract on STD notifier register() Adds a docstring describing the input contract the function already accepts: common base kwargs from main.py plus stripped `dockernotifier.std.*` labels, forwarded verbatim as the JSON payload. No behavior change. --- notifiers/service_tracker_dashboard.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/notifiers/service_tracker_dashboard.py b/notifiers/service_tracker_dashboard.py index 11c4815..551a65d 100644 --- a/notifiers/service_tracker_dashboard.py +++ b/notifiers/service_tracker_dashboard.py @@ -16,6 +16,16 @@ 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 forwarded as the JSON payload to STD's register endpoint. + + The wire format is updated to STD's canonical schema in a later + session (SESSION 07). This session keeps the legacy wire format. + """ dashboard_url = os.environ.get("STD_URL") api_token = os.environ.get("STD_API_TOKEN") From 483eb4ee1d2722e83a08146f8854820fde58dea2 Mon Sep 17 00:00:00 2001 From: Matthew Andersen Date: Tue, 12 May 2026 10:51:15 -0700 Subject: [PATCH 11/15] refactor: drop unused trigger_reason from DNS notifier; log action instead --- CHANGELOG.md | 4 ++++ docs/PRD.md | 2 +- notifiers/technitium_dns.py | 5 +++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4a6a6d..f78b43f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.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()` diff --git a/docs/PRD.md b/docs/PRD.md index 0c0e15b..b582c1a 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -174,7 +174,7 @@ Tags shipped on `main`: v0.1.0 → v0.2.3. | 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 | `trigger_reason` parameter accepted but never used. | +| 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 | Log handler setup duplicated across `main.py`, `notifiers/technitium_dns.py`, and `notifiers/service_tracker_dashboard.py`. | | N4 | Event handling | `"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. | diff --git a/notifiers/technitium_dns.py b/notifiers/technitium_dns.py index 28dcefd..4fd83d4 100644 --- a/notifiers/technitium_dns.py +++ b/notifiers/technitium_dns.py @@ -15,7 +15,7 @@ def _do_dns_update(dns_url, params): return response -def register(*, container_fqdn, zone, value, trigger_reason="event", **kwargs): +def register(*, container_fqdn, zone, value, **kwargs): """ Register a CNAME with Technitium DNS. @@ -36,8 +36,9 @@ def register(*, container_fqdn, zone, value, trigger_reason="event", **kwargs): 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 "{trigger_reason}"') + 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: From b2b9bcffbb20e6c4332b617478011669f9b007af Mon Sep 17 00:00:00 2001 From: Matthew Andersen Date: Tue, 12 May 2026 10:53:29 -0700 Subject: [PATCH 12/15] feat: STD notifier emits canonical schema and targets /api/v1/register --- CHANGELOG.md | 21 ++++-- README.md | 18 ++++-- docs/PRD.md | 2 +- notifiers/service_tracker_dashboard.py | 89 ++++++++++++++++++++++---- 4 files changed, 104 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f78b43f..cfc8cf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -> Targeting v0.3.0. **Must ship after STD v0.5.0** — this release starts -> emitting canonical keys against `/api/v1/register`, which STD v0.5.0 -> introduces. +> Targeting v0.3.0. **Requires STD v0.5.0 or later.** This release +> emits payloads to `/api/v1/register` using STD's canonical schema; +> earlier STD versions do not expose that endpoint. ### Added - Shared logging setup module consumed by `main.py` and all notifier @@ -21,12 +21,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `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. ### Changed -- STD notifier emits canonical key names (`host`, `group`, - `internal_health_check_enabled`, ...) and posts to - `/api/v1/register` instead of `/api/register`. Old STD instances - continue to work via STD's compat shim until STD v0.6.0. +- 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 `_`. diff --git a/README.md b/README.md index c05ddaf..91a96bf 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,11 @@ notifier targets can be added without touching the core event loop. ### Service Tracker Dashboard +> **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`. | @@ -124,11 +129,14 @@ applies its own defaults for anything you don't. | `dockernotifier.std.icon` | Icon filename (e.g. `sonarr.svg`). | | `dockernotifier.std.sort.priority` | Numeric sort order within a group. | -> **Note on label naming.** Today STD accepts both legacy variants like -> `dockernotifier.std.internal.health` and canonical forms. Starting in -> notifier v0.3.0 + STD v0.5.0, the notifier emits canonical key names on -> the wire to a new `/api/v1/register` endpoint. Old notifier deployments -> continue to work against STD's compat shim until STD v0.6.0. +> **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. --- diff --git a/docs/PRD.md b/docs/PRD.md index b582c1a..029e042 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -179,7 +179,7 @@ Tags shipped on `main`: v0.1.0 → v0.2.3. | N4 | Event handling | `"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 | `STD_REFRESH_SECONDS` default comment in `main.py` says "60 minutes" but the value is 60 seconds. | -| N7 | Wire contract | STD notifier currently sends a free-form kwargs dict. Needs to align with STD v0.5.0 canonical key names and target the new `/api/v1/register` endpoint. | +| 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) diff --git a/notifiers/service_tracker_dashboard.py b/notifiers/service_tracker_dashboard.py index 551a65d..89111e5 100644 --- a/notifiers/service_tracker_dashboard.py +++ b/notifiers/service_tracker_dashboard.py @@ -8,6 +8,66 @@ 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) @@ -21,10 +81,8 @@ def register(**kwargs): Receives the common notifier base kwargs contract (see PRD §3.3) plus all stripped `dockernotifier.std.*` labels. The merged dict - is forwarded as the JSON payload to STD's register endpoint. - - The wire format is updated to STD's canonical schema in a later - session (SESSION 07). This session keeps the legacy wire format. + 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") @@ -33,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}") From f771780d88346bbf5351893b65410809c7c11400 Mon Sep 17 00:00:00 2001 From: Matthew Andersen Date: Tue, 12 May 2026 11:03:03 -0700 Subject: [PATCH 13/15] docs: document notifier module contract in PRD section 3.3 --- CHANGELOG.md | 1 + CLAUDE.md | 21 +++++++------- docs/PRD.md | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfc8cf5..61ea33a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. ### Changed - STD notifier emits canonical key names (`host`, `group_name`, diff --git a/CLAUDE.md b/CLAUDE.md index 726ae92..4ad6082 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,16 +108,17 @@ The contract is: ## Notifier Module Conventions -- One module per downstream system, under `notifiers/`. -- Each module exposes a `register(...)` function. -- Each module owns its own auth handling and payload shape — wire format - is decided by the downstream system, not by this code. -- Retry policy is shared via the common retry helper (v0.3.0+). -- Logging configuration is shared via a single setup module (v0.3.0+) — - individual notifiers must not declare their own handlers. -- Adding a new notifier target: drop a new module under `notifiers/`, - wire it into the dispatch in `main.py`, document the env vars in - `README.md`, and add a section to `PRD.md` §3. +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 diff --git a/docs/PRD.md b/docs/PRD.md index 029e042..2560163 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -136,6 +136,85 @@ 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 From adb7c42bca5a14bd7c500d74812e39c7bd660fc7 Mon Sep 17 00:00:00 2001 From: Matthew Andersen Date: Tue, 12 May 2026 11:03:33 -0700 Subject: [PATCH 14/15] feat: add notifiers/_template.py as reference implementation --- CHANGELOG.md | 2 + notifiers/_template.py | 105 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 notifiers/_template.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 61ea33a..006d740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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`, 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}" + ) From c555eb8469ea081ffc3d0244c304c6da07d3c4b7 Mon Sep 17 00:00:00 2001 From: Matthew Andersen Date: Tue, 12 May 2026 23:42:53 -0700 Subject: [PATCH 15/15] chore: prepare v0.3.0 release - Promote CHANGELOG [Unreleased] to [0.3.0] dated today. - Open a new empty [Unreleased] section. - Update CHANGELOG link references. - Update PRD voice/tense for shipped v0.3.0 work. - Update README forward-looking language to shipped voice. --- CHANGELOG.md | 9 +++++---- docs/PRD.md | 18 +++++++++--------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 006d740..0d23e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -> Targeting v0.3.0. **Requires STD v0.5.0 or later.** This release -> emits payloads to `/api/v1/register` using STD's canonical schema; -> earlier STD versions do not expose that endpoint. +--- + +## [0.3.0] — 2026-05-12 ### Added - Shared logging setup module consumed by `main.py` and all notifier @@ -101,7 +101,8 @@ Released. Initial public release. -[Unreleased]: https://github.com/crzykidd/docker-api-notifier/compare/v0.2.3...HEAD +[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 diff --git a/docs/PRD.md b/docs/PRD.md index 2560163..3bdbee6 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -254,10 +254,10 @@ Tags shipped on `main`: v0.1.0 → v0.2.3. |-----|-----------------------|--------------------------------------------------------------------------------------------------------| | 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 | Log handler setup duplicated across `main.py`, `notifiers/technitium_dns.py`, and `notifiers/service_tracker_dashboard.py`. | -| N4 | Event handling | `"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. | +| 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 | `STD_REFRESH_SECONDS` default comment in `main.py` says "60 minutes" but the value is 60 seconds. | +| 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) @@ -269,16 +269,16 @@ Tags shipped on `main`: v0.1.0 → v0.2.3. ## 6. v0.3.0 — Cleanup Release -**Cannot ship until STD v0.5.0 is released**, because v0.3.0 starts -emitting canonical key names against `/api/v1/register`. STD v0.5.0 -introduces that endpoint. +**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 -- Resolve every issue in §5.2. -- Establish a small set of internal modules that future notifier targets +- Resolved every issue in §5.2. +- Established a small set of internal modules that future notifier targets can rely on (logging, retry helper). -- Switch STD notifier to canonical key names + `/api/v1/register`. +- Switched STD notifier to canonical key names + `/api/v1/register`. ### 6.2 Behavior changes visible to operators