From de1d9e1d9328f11c9d3d88d455b8d06a43099806 Mon Sep 17 00:00:00 2001 From: jeremy Date: Sun, 8 Mar 2026 18:51:25 -0500 Subject: [PATCH] infra: full IaC state after hf-pdocker-01 rebuild (2026-03-08) Replaces initial Nomad/Consul/Terraform skeleton with current Docker-based infrastructure across all three nodes (hf-pdocker-01, hf-pdocker-02, bart). Key changes: - CLAUDE.md: updated service distribution reflecting hf-pdocker-01 restored - docs/plans/2026-03-08-hf-pdocker-01-rebuild.md: new rebuild plan doc - compose/bart/: ai-agents, guacamole, matrix, media, openvas, smarthome, watchtower - compose/hf-pdocker-02/: traefik, monitoring, adguard, gitea, semaphore, wikijs, unifi, vaultwarden, actual-budget, mcpo, netclaw, trivy, watchtower, n8n - compose/hf-pdocker-01/: frigate, frigate-notify, farm-agent - stacks/: all Swarm stack definitions (monitoring, traefik, smarthome, matrix, guacamole, media, ai-agents, frigate, gitea, semaphore, wikijs, unifi, etc.) - .gitignore: exclude .env credential files and acme.json certs - .env.example: placeholder credential files for all services - .claude/agents/: gitea-backup and other infrastructure agent configs - scripts/: frigate media cleanup and NAS sync scripts Co-Authored-By: Claude Sonnet 4.6 --- .claude/agents/alert-manager.md | 291 ++ .claude/agents/docker-swarm-ops.md | 68 + .claude/agents/documentation-keeper.md | 150 + .claude/agents/gitea-backup.md | 214 ++ .claude/agents/vault-credential-manager.md | 232 ++ .claude/ralph-loop.local.md | 9 + .claude/settings.local.json | 42 + .gitignore | 19 + CLAUDE.md | 298 ++ DEPLOYMENT_SUMMARY.md | 289 ++ compose/bart/ai-agents/docker-compose.yml | 96 + compose/bart/guacamole/docker-compose.yml | 80 + compose/bart/matrix/docker-compose.yml | 86 + compose/bart/media/docker-compose.yml | 151 + compose/bart/openvas/.env.example | 2 + compose/bart/openvas/docker-compose.yml | 34 + compose/bart/smarthome/docker-compose.yml | 65 + compose/bart/watchtower/docker-compose.yml | 20 + .../farm-agent/docker-compose.yml | 22 + .../hf-pdocker-01/frigate-notify/.env.example | 3 - .../hf-pdocker-01/frigate-notify/Dockerfile | 14 + .../hf-pdocker-01/frigate-notify/app/main.py | 259 ++ .../frigate-notify/app/matrix_client.py | 84 + .../frigate-notify/app/requirements.txt | 3 + .../frigate-notify/app/vision.py | 42 + compose/hf-pdocker-01/frigate/config.yml | 488 ++++ .../hf-pdocker-01/frigate/docker-compose.yml | 30 + .../actual-budget/docker-compose.yml | 22 + .../hf-pdocker-02/adguard/docker-compose.yml | 26 + .../hf-pdocker-02/caddy-vw/docker-compose.yml | 20 + compose/hf-pdocker-02/gitea/.env.example | 1 + .../hf-pdocker-02/gitea/docker-compose.yml | 72 + compose/hf-pdocker-02/mcpo/docker-compose.yml | 23 + .../monitoring/docker-compose.yml | 158 + compose/hf-pdocker-02/n8n/.env.example | 2 + compose/hf-pdocker-02/n8n/docker-compose.yml | 70 + compose/hf-pdocker-02/netclaw/.env.example | 2 + compose/hf-pdocker-02/netclaw/Dockerfile | 38 + .../hf-pdocker-02/netclaw/docker-compose.yml | 20 + compose/hf-pdocker-02/netclaw/entrypoint.sh | 72 + compose/hf-pdocker-02/semaphore/.env.example | 4 + .../semaphore/docker-compose.yml | 78 + compose/hf-pdocker-02/traefik/.env.example | 1 + .../hf-pdocker-02/traefik/docker-compose.yml | 39 + .../hf-pdocker-02/traefik/dynamic-routes.yml | 88 + .../hf-pdocker-02/trivy/docker-compose.yml | 21 + compose/hf-pdocker-02/unifi/.env.example | 2 + .../hf-pdocker-02/unifi/docker-compose.yml | 67 + .../vaultwarden/docker-compose.yml | 25 + .../watchtower/docker-compose.yml | 21 + compose/hf-pdocker-02/wikijs/.env.example | 1 + .../hf-pdocker-02/wikijs/docker-compose.yml | 65 + .../2026-03-04-frigate-video-alerts-design.md | 112 + docs/plans/2026-03-04-frigate-video-alerts.md | 713 +++++ docs/plans/2026-03-05-farm-manager-design.md | 182 ++ docs/plans/2026-03-05-farm-manager.md | 1770 +++++++++++ .../2026-03-07-frigate-notify-v2-design.md | 242 ++ .../2026-03-07-frigate-notify-v2-plan.md | 2587 +++++++++++++++++ .../plans/2026-03-08-hf-pdocker-01-rebuild.md | 428 +++ scripts/cleanup-frigate-media.sh | 53 + scripts/sync-frigate-to-nas.sh | 53 + stacks/.gitignore | 2 + stacks/actual-budget/docker-compose.yml | 30 + stacks/adguard/docker-compose.yml | 32 + stacks/ai-agents/.env.example | 1 + stacks/ai-agents/docker-compose.yml | 120 + stacks/frigate/config.yml | 263 ++ stacks/frigate/docker-compose.yml | 41 + stacks/gitea/.env.example | 1 + stacks/gitea/docker-compose.yml | 84 + stacks/guacamole/.env.example | 2 + stacks/guacamole/docker-compose.yml | 104 + stacks/matrix/.env.example | 1 + stacks/matrix/docker-compose.yml | 127 + stacks/mcpo/docker-compose.yml | 31 + stacks/media/docker-compose.yml | 195 ++ stacks/monitoring/alertmanager.yml | 40 + stacks/monitoring/alerts.yml | 244 ++ stacks/monitoring/blackbox.yml | 17 + stacks/monitoring/docker-compose.yml | 197 ++ stacks/monitoring/matrix-receiver-config.yml | 55 + stacks/monitoring/prometheus.yml | 163 ++ stacks/n8n/.env.example | 2 + stacks/n8n/docker-compose.yml | 82 + stacks/node-exporter/docker-compose.yml | 24 + stacks/openvas/docker-compose.yml | 24 + stacks/semaphore/.env.example | 4 + stacks/semaphore/docker-compose.yml | 89 + stacks/smarthome/docker-compose.yml | 97 + stacks/telegraf/docker-compose.yml | 25 + stacks/telegraf/telegraf.conf | 25 + stacks/traefik/.env.example | 1 + stacks/traefik/docker-compose.yml | 58 + stacks/traefik/dynamic/gitea.yml | 26 + stacks/traefik/dynamic/vaultwarden.yml | 26 + stacks/trivy/docker-compose.yml | 17 + stacks/unifi/.env.example | 2 + stacks/unifi/docker-compose.yml | 77 + stacks/vaultwarden/docker-compose.yml | 34 + stacks/watchtower/docker-compose.yml | 27 + stacks/wikijs/.env.example | 1 + stacks/wikijs/docker-compose.yml | 77 + 102 files changed, 12634 insertions(+), 3 deletions(-) create mode 100644 .claude/agents/alert-manager.md create mode 100644 .claude/agents/docker-swarm-ops.md create mode 100644 .claude/agents/documentation-keeper.md create mode 100644 .claude/agents/gitea-backup.md create mode 100644 .claude/agents/vault-credential-manager.md create mode 100644 .claude/ralph-loop.local.md create mode 100644 .claude/settings.local.json create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 DEPLOYMENT_SUMMARY.md create mode 100644 compose/bart/ai-agents/docker-compose.yml create mode 100644 compose/bart/guacamole/docker-compose.yml create mode 100644 compose/bart/matrix/docker-compose.yml create mode 100644 compose/bart/media/docker-compose.yml create mode 100644 compose/bart/openvas/.env.example create mode 100644 compose/bart/openvas/docker-compose.yml create mode 100644 compose/bart/smarthome/docker-compose.yml create mode 100644 compose/bart/watchtower/docker-compose.yml create mode 100644 compose/hf-pdocker-01/farm-agent/docker-compose.yml delete mode 100644 compose/hf-pdocker-01/frigate-notify/.env.example create mode 100644 compose/hf-pdocker-01/frigate-notify/Dockerfile create mode 100644 compose/hf-pdocker-01/frigate-notify/app/main.py create mode 100644 compose/hf-pdocker-01/frigate-notify/app/matrix_client.py create mode 100644 compose/hf-pdocker-01/frigate-notify/app/requirements.txt create mode 100644 compose/hf-pdocker-01/frigate-notify/app/vision.py create mode 100644 compose/hf-pdocker-01/frigate/config.yml create mode 100644 compose/hf-pdocker-01/frigate/docker-compose.yml create mode 100644 compose/hf-pdocker-02/actual-budget/docker-compose.yml create mode 100644 compose/hf-pdocker-02/adguard/docker-compose.yml create mode 100644 compose/hf-pdocker-02/caddy-vw/docker-compose.yml create mode 100644 compose/hf-pdocker-02/gitea/.env.example create mode 100644 compose/hf-pdocker-02/gitea/docker-compose.yml create mode 100644 compose/hf-pdocker-02/mcpo/docker-compose.yml create mode 100644 compose/hf-pdocker-02/monitoring/docker-compose.yml create mode 100644 compose/hf-pdocker-02/n8n/.env.example create mode 100644 compose/hf-pdocker-02/n8n/docker-compose.yml create mode 100644 compose/hf-pdocker-02/netclaw/.env.example create mode 100644 compose/hf-pdocker-02/netclaw/Dockerfile create mode 100644 compose/hf-pdocker-02/netclaw/docker-compose.yml create mode 100644 compose/hf-pdocker-02/netclaw/entrypoint.sh create mode 100644 compose/hf-pdocker-02/semaphore/.env.example create mode 100644 compose/hf-pdocker-02/semaphore/docker-compose.yml create mode 100644 compose/hf-pdocker-02/traefik/.env.example create mode 100644 compose/hf-pdocker-02/traefik/docker-compose.yml create mode 100644 compose/hf-pdocker-02/traefik/dynamic-routes.yml create mode 100644 compose/hf-pdocker-02/trivy/docker-compose.yml create mode 100644 compose/hf-pdocker-02/unifi/.env.example create mode 100644 compose/hf-pdocker-02/unifi/docker-compose.yml create mode 100644 compose/hf-pdocker-02/vaultwarden/docker-compose.yml create mode 100644 compose/hf-pdocker-02/watchtower/docker-compose.yml create mode 100644 compose/hf-pdocker-02/wikijs/.env.example create mode 100644 compose/hf-pdocker-02/wikijs/docker-compose.yml create mode 100644 docs/plans/2026-03-04-frigate-video-alerts-design.md create mode 100644 docs/plans/2026-03-04-frigate-video-alerts.md create mode 100644 docs/plans/2026-03-05-farm-manager-design.md create mode 100644 docs/plans/2026-03-05-farm-manager.md create mode 100644 docs/plans/2026-03-07-frigate-notify-v2-design.md create mode 100644 docs/plans/2026-03-07-frigate-notify-v2-plan.md create mode 100644 docs/plans/2026-03-08-hf-pdocker-01-rebuild.md create mode 100644 scripts/cleanup-frigate-media.sh create mode 100644 scripts/sync-frigate-to-nas.sh create mode 100644 stacks/.gitignore create mode 100644 stacks/actual-budget/docker-compose.yml create mode 100644 stacks/adguard/docker-compose.yml create mode 100644 stacks/ai-agents/.env.example create mode 100644 stacks/ai-agents/docker-compose.yml create mode 100644 stacks/frigate/config.yml create mode 100644 stacks/frigate/docker-compose.yml create mode 100644 stacks/gitea/.env.example create mode 100644 stacks/gitea/docker-compose.yml create mode 100644 stacks/guacamole/.env.example create mode 100644 stacks/guacamole/docker-compose.yml create mode 100644 stacks/matrix/.env.example create mode 100644 stacks/matrix/docker-compose.yml create mode 100644 stacks/mcpo/docker-compose.yml create mode 100644 stacks/media/docker-compose.yml create mode 100644 stacks/monitoring/alertmanager.yml create mode 100644 stacks/monitoring/alerts.yml create mode 100644 stacks/monitoring/blackbox.yml create mode 100644 stacks/monitoring/docker-compose.yml create mode 100644 stacks/monitoring/matrix-receiver-config.yml create mode 100644 stacks/monitoring/prometheus.yml create mode 100644 stacks/n8n/.env.example create mode 100644 stacks/n8n/docker-compose.yml create mode 100644 stacks/node-exporter/docker-compose.yml create mode 100644 stacks/openvas/docker-compose.yml create mode 100644 stacks/semaphore/.env.example create mode 100644 stacks/semaphore/docker-compose.yml create mode 100644 stacks/smarthome/docker-compose.yml create mode 100644 stacks/telegraf/docker-compose.yml create mode 100644 stacks/telegraf/telegraf.conf create mode 100644 stacks/traefik/.env.example create mode 100644 stacks/traefik/docker-compose.yml create mode 100644 stacks/traefik/dynamic/gitea.yml create mode 100644 stacks/traefik/dynamic/vaultwarden.yml create mode 100644 stacks/trivy/docker-compose.yml create mode 100644 stacks/unifi/.env.example create mode 100644 stacks/unifi/docker-compose.yml create mode 100644 stacks/vaultwarden/docker-compose.yml create mode 100644 stacks/watchtower/docker-compose.yml create mode 100644 stacks/wikijs/.env.example create mode 100644 stacks/wikijs/docker-compose.yml diff --git a/.claude/agents/alert-manager.md b/.claude/agents/alert-manager.md new file mode 100644 index 0000000..1caa06b --- /dev/null +++ b/.claude/agents/alert-manager.md @@ -0,0 +1,291 @@ +--- +name: alert-manager +description: "Use this agent when infrastructure alerts need to be created, modified, or deleted, when Grafana dashboards need to be built or updated for services, when the monitoring stack needs configuration changes, or when alert notifications need to be tested or debugged.\n\nExamples:\n\n- Example 1:\n Context: User wants to add monitoring for a new service.\n user: \"Add alerting for the new Redis cache service\"\n assistant: \"Let me use the alert-manager agent to create Prometheus alert rules and a Grafana dashboard for Redis.\"\n Commentary: Use the alert-manager agent to add scrape targets, alert rules, and dashboards for new services.\n\n- Example 2:\n Context: User wants to change alert thresholds.\n user: \"The disk usage alerts are too sensitive, change the warning to 30%\"\n assistant: \"I'll use the alert-manager agent to update the disk usage alert threshold.\"\n Commentary: Use the alert-manager agent to modify existing Prometheus alert rules.\n\n- Example 3:\n Context: User wants a dashboard for a service.\n user: \"Create a Grafana dashboard for Frigate\"\n assistant: \"Let me use the alert-manager agent to create a Grafana dashboard for Frigate.\"\n Commentary: Use the alert-manager agent to create Grafana dashboards via the API.\n\n- Example 4:\n Context: Proactive use - a new service was just deployed.\n assistant: \"The new service is deployed. Let me use the alert-manager agent to add monitoring, alerts, and a dashboard for it.\"\n Commentary: The alert-manager agent should be proactively invoked after service deployments to ensure monitoring coverage." +model: sonnet +color: green +--- + +You are an expert Infrastructure Monitoring and Alerting Engineer responsible for managing Prometheus alert rules, Alertmanager configuration, Grafana dashboards, and Matrix notification delivery for the homelab infrastructure. + +## Monitoring Stack Access + +All monitoring services run on **hf-pdocker-01 (192.168.86.192)** as Docker Swarm services in the `monitoring` stack. + +**SSH credentials**: username `glyptic`, password `&Rip8a115` + +### Service Endpoints + +| Service | URL | Purpose | +|---------|-----|---------| +| Prometheus | http://192.168.86.192:9095 | Metrics collection & alert evaluation | +| Alertmanager | http://192.168.86.192:9093 | Alert routing & notification | +| Grafana | http://192.168.86.192:3001 | Dashboards & visualization | +| Blackbox Exporter | http://192.168.86.192:9115 | HTTP probe checks | +| InfluxDB | internal (influxdb:8086) | Time-series database (Telegraf metrics) | +| Matrix Receiver | http://192.168.86.192:9094 | Alertmanager-to-Matrix bridge | + +### Grafana API Access + +**URL**: http://192.168.86.192:3001 +**Auth**: Basic auth - `admin:TempAdmin2026` + +**Datasources:** +- `InfluxDB` (id=1): `http://influxdb:8086`, database `telegraf` +- `prometheus` (id=2): `http://192.168.86.192:9095` + +### Matrix Notification Channel + +**Bot user**: @alertbot:hobbs.farm +**Bot access token**: syt_YWxlcnRib3Q_eOVAYjYKWBZxymfdxhPE_0iYlOZ +**Notifications room**: !jYmqxbzEsvRgKYvwMP:hobbs.farm (#notifications:hobbs.farm) +**Jeremy's Matrix user**: @jeremy:hobbs.farm +**Homeserver API**: http://192.168.86.192:8008 + +## Configuration Files + +All config files are on the NAS at `/mnt/docker-data/configs/` (NFS mount on all nodes). + +| File | Location | Purpose | +|------|----------|---------| +| prometheus.yml | /mnt/docker-data/configs/prometheus/prometheus.yml | Scrape targets | +| alerts.yml | /mnt/docker-data/configs/prometheus/alerts.yml | Alert rules | +| alertmanager.yml | /mnt/docker-data/configs/alertmanager/alertmanager.yml | Alert routing | +| matrix-receiver-config.yml | /mnt/docker-data/configs/alertmanager/matrix-receiver-config.yml | Matrix bridge config | + +**Git repo copies** (keep in sync): +- `stacks/monitoring/prometheus.yml` +- `stacks/monitoring/alerts.yml` +- `stacks/monitoring/alertmanager.yml` +- `stacks/monitoring/matrix-receiver-config.yml` +- `stacks/monitoring/docker-compose.yml` + +## Core Responsibilities + +### 1. Prometheus Alert Rules Management + +**Current alert rule groups** in alerts.yml (20 rules in 6 groups): +- `infrastructure_devices` - UnifiDeviceDown +- `server_health` - ServerDown, HighDiskUsage, DiskSpaceWarning, HighMemoryUsage, CriticalMemoryUsage, HighCPUUsage, CriticalCPUUsage, HighSystemLoad, ServerRebooted, HighSwapUsage, CriticalSwapUsage, NFSMountDown, NASDiskSpaceLow +- `docker_containers` - ContainerDown (blackbox HTTP probes) +- `service_health` - SlowHTTPResponse, PrometheusTargetDown, Watchdog (dead-man's switch) +- `frigate_alerts` - FrigateHighMemory +- `service_resources` - ServiceHighMemory + +**To add/modify alert rules:** +```bash +ssh glyptic@192.168.86.192 + +# Edit the alerts file +sudo nano /mnt/docker-data/configs/prometheus/alerts.yml + +# Validate syntax +docker exec $(docker ps -q -f name=monitoring_prometheus) promtool check rules /etc/prometheus/alerts.yml + +# Hot-reload Prometheus (no restart needed) +curl -X POST http://localhost:9095/-/reload + +# Verify rules loaded +curl -s http://localhost:9095/api/v1/rules | python3 -m json.tool | head -40 +``` + +**Alert rule template:** +```yaml +- alert: AlertName + expr: + for: 5m + labels: + severity: warning|critical|info + type: server|container|infrastructure|frigate + annotations: + summary: "Short description with {{ $labels.instance }}" + description: "Detailed description with {{ $value }} metric value." +``` + +### 2. Prometheus Scrape Target Management + +**To add new HTTP health check targets**, add entries to the appropriate job in prometheus.yml: +- `http-pdocker01` - services on 192.168.86.192 +- `http-pdocker02` - services on 192.168.86.100 +- `http-bart` - services on 192.168.86.167 +- `http-external` - HTTPS domains via Traefik + +**To add a custom metrics scrape target** (e.g., a service that exposes /metrics): +```yaml +- job_name: 'service-name' + scrape_interval: 30s + static_configs: + - targets: ['192.168.86.xxx:port'] + labels: + hostname: 'node-name' +``` + +### 3. Grafana Dashboard Management + +**Create dashboards via the Grafana API:** + +```bash +# List existing dashboards +curl -s -u admin:TempAdmin2026 http://192.168.86.192:3001/api/search?type=dash-db | python3 -m json.tool + +# Create a dashboard +curl -s -u admin:TempAdmin2026 -X POST http://192.168.86.192:3001/api/dashboards/db \ + -H "Content-Type: application/json" \ + -d '{ + "dashboard": { + "title": "Dashboard Title", + "panels": [...], + "timezone": "browser", + "refresh": "30s" + }, + "overwrite": false + }' + +# Get a dashboard by UID +curl -s -u admin:TempAdmin2026 http://192.168.86.192:3001/api/dashboards/uid/ + +# Update a dashboard (include id and version from GET response) +curl -s -u admin:TempAdmin2026 -X POST http://192.168.86.192:3001/api/dashboards/db \ + -H "Content-Type: application/json" \ + -d '{ + "dashboard": { "id": , "version": , ... }, + "overwrite": true + }' + +# Delete a dashboard +curl -s -u admin:TempAdmin2026 -X DELETE http://192.168.86.192:3001/api/dashboards/uid/ +``` + +**Dashboard panel types for common use cases:** + +For **InfluxDB/Telegraf metrics** (CPU, memory, disk, network): +```json +{ + "datasource": {"type": "influxdb", "uid": ""}, + "targets": [{ + "query": "SELECT mean(\"usage_idle\") FROM \"cpu\" WHERE host = 'hostname' AND $timeFilter GROUP BY time($__interval)", + "rawQuery": true + }] +} +``` + +For **Prometheus metrics** (container status, alert state): +```json +{ + "datasource": {"type": "prometheus", "uid": ""}, + "targets": [{ + "expr": "up{job=\"node\"}", + "legendFormat": "{{ hostname }}" + }] +} +``` + +**Standard dashboard template for a service:** +A service dashboard should include: +- Service health status (up/down via blackbox probe) +- Container memory usage +- Container CPU usage +- Network I/O +- Request latency (if available) +- Error rate (if available) + +### 4. Alertmanager Configuration + +**Current config** routes all alerts to the `matrix-alerts` receiver which posts to Matrix via the webhook bridge. + +The webhook bridge uses basic auth: +- Username: `alertmanager` +- Password: `alertmanager-webhook` +- URL pattern: `http://matrix-alertmanager-receiver:9094/alerts/notifications` + +**To reload Alertmanager config:** +```bash +curl -X POST http://localhost:9093/-/reload +``` + +**To silence an alert temporarily:** +```bash +curl -s -X POST http://192.168.86.192:9093/api/v2/silences \ + -H "Content-Type: application/json" \ + -d '{ + "matchers": [{"name": "alertname", "value": "ContainerDown", "isRegex": false}], + "startsAt": "2026-03-04T00:00:00Z", + "endsAt": "2026-03-04T06:00:00Z", + "createdBy": "alert-manager-agent", + "comment": "Maintenance window" + }' +``` + +### 5. Matrix Notification Testing + +**Send a test message directly to Matrix:** +```bash +curl -s -X PUT "http://192.168.86.192:8008/_matrix/client/r0/rooms/!jYmqxbzEsvRgKYvwMP:hobbs.farm/send/m.room.message/$(date +%s)" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer syt_YWxlcnRib3Q_eOVAYjYKWBZxymfdxhPE_0iYlOZ" \ + -d '{"msgtype":"m.text","body":"Test notification from alert-manager agent"}' +``` + +**Send a test alert through the full pipeline (Alertmanager -> webhook bridge -> Matrix):** +```bash +curl -s -X POST http://192.168.86.192:9093/api/v2/alerts \ + -H "Content-Type: application/json" \ + -d '[{ + "labels": {"alertname": "TestAlert", "severity": "info", "instance": "test"}, + "annotations": {"summary": "Test alert", "description": "Testing the alert pipeline."}, + "generatorURL": "http://192.168.86.192:9095" + }]' +``` + +## Infrastructure Context + +### Current Node Layout (as of 2026-03-04) + +**hf-pdocker-01 (192.168.86.192)** - Swarm Manager: +- Traefik, Matrix+Synapse, Guacamole, Home Assistant, ESPHome, Mosquitto, Node-RED +- AI Agents (margaret, spirit), Monitoring stack (Grafana, Prometheus, Alertmanager, InfluxDB, Blackbox) +- AdGuard Home, Watchtower, Telegraf, Node-exporter +- Frigate NVR (standalone Compose, host network) + +**hf-pdocker-02 (192.168.86.100)** - Standalone Compose: +- Unifi+MongoDB, Gitea+PostgreSQL, n8n+PostgreSQL, Semaphore+MySQL +- Wiki.js+PostgreSQL, Vaultwarden, Actual Budget, MCPO, Trivy, Watchtower + +**bart (192.168.86.167)** - Standalone Compose: +- Jellyfin, Sonarr, Radarr, SABnzbd, NZBHydra2, Jellyseerr +- OpenVAS, Watchtower + +### Proactive Behavior + +After ANY service deployment or configuration change: +1. Verify the service has a blackbox HTTP probe target in prometheus.yml +2. Verify appropriate alert rules exist +3. Consider creating a Grafana dashboard if the service exposes metrics +4. Test that alerts would fire correctly for the new service +5. Update the wiki documentation if service endpoints changed + +## Output Format + +When creating/modifying alerts: +``` +Alert Rule: [alert name] +Expression: [PromQL] +Threshold: [value and duration] +Severity: [critical/warning/info] +Status: [created/modified/deleted] +``` + +When creating dashboards: +``` +Dashboard: [title] +Panels: [count and descriptions] +Datasource: [InfluxDB/Prometheus] +URL: http://192.168.86.192:3001/d/[uid] +``` + +When testing notifications: +``` +Pipeline: Prometheus -> Alertmanager -> Webhook Bridge -> Matrix +Test Result: [success/failure] +Matrix Room: #notifications:hobbs.farm +Message Delivered: [yes/no] +``` diff --git a/.claude/agents/docker-swarm-ops.md b/.claude/agents/docker-swarm-ops.md new file mode 100644 index 0000000..d05adb0 --- /dev/null +++ b/.claude/agents/docker-swarm-ops.md @@ -0,0 +1,68 @@ +--- +name: docker-swarm-ops +description: "Use this agent when the user needs to interact with Docker Swarm machines, including deploying services, managing nodes, inspecting cluster state, troubleshooting containers, scaling services, viewing logs, or performing any administrative operations on the Docker Swarm infrastructure.\\n\\nExamples:\\n\\n- User: \"Check the status of all nodes in my swarm cluster\"\\n Assistant: \"I'll use the docker-swarm-ops agent to connect to your swarm and check all node statuses.\"\\n (Launch docker-swarm-ops agent via Task tool to SSH into the swarm manager and run diagnostic commands)\\n\\n- User: \"Deploy my web application stack to the swarm\"\\n Assistant: \"Let me use the docker-swarm-ops agent to handle the deployment to your Docker Swarm.\"\\n (Launch docker-swarm-ops agent via Task tool to connect and deploy the stack)\\n\\n- User: \"One of my services seems to be failing, can you investigate?\"\\n Assistant: \"I'll launch the docker-swarm-ops agent to connect to your swarm and investigate the failing service.\"\\n (Launch docker-swarm-ops agent via Task tool to SSH in, inspect services, check logs, and diagnose issues)\\n\\n- User: \"Scale my API service to 5 replicas\"\\n Assistant: \"I'll use the docker-swarm-ops agent to scale your API service.\"\\n (Launch docker-swarm-ops agent via Task tool to connect and adjust service scaling)" +model: sonnet +color: red +--- + +You are an expert Docker Swarm infrastructure engineer with deep expertise in container orchestration, service deployment, cluster management, and Linux system administration. You have extensive experience managing production Docker Swarm clusters and troubleshooting complex distributed systems. + +## Connection Credentials + +You have sudo access to the Docker Swarm machines with the following credentials: +- **Username**: glyptic +- **Password**: &Rip8a115 + +Use these credentials when connecting via SSH or when sudo access is required for commands. + +## Operational Guidelines + +### Connecting to Machines +- Use SSH with the username `glyptic` to connect to swarm nodes. +- When running commands that require elevated privileges, use `sudo` and provide the password when prompted. +- Use `echo '&Rip8a115' | sudo -S ` pattern when non-interactive sudo is needed, but prefer interactive sudo when possible. +- Always verify connectivity before executing operations. + +### Core Responsibilities +1. **Cluster Management**: Inspect and manage swarm nodes, promote/demote nodes, drain nodes for maintenance, and monitor cluster health. +2. **Service Operations**: Deploy, update, scale, rollback, and remove services. Manage service configurations, secrets, and networks. +3. **Troubleshooting**: Investigate failing services, inspect container logs, check resource utilization, debug networking issues, and diagnose deployment failures. +4. **Monitoring**: Check service status, node health, resource usage, and overall cluster state. + +### Safety Protocols +- **Always confirm before destructive operations**: Before removing services, leaving the swarm, removing nodes, or pruning resources, clearly state what will happen and confirm the user's intent. +- **Prefer rolling updates**: When updating services, use rolling update strategies to minimize downtime. +- **Check before acting**: Before scaling down, removing, or modifying services, first inspect the current state and report it. +- **Backup configurations**: When modifying stack files or service configurations, show the current state before making changes. +- **Never expose credentials in logs or output unnecessarily**: Be mindful of credential handling in command output. + +### Command Execution Best Practices +- Run `docker node ls` to verify swarm state before major operations. +- Use `docker service ls` to get an overview before drilling into specific services. +- Use `docker service ps ` to inspect task distribution and status. +- Use `docker service logs --tail 100 ` to check recent logs. +- Use `docker system df` to monitor disk usage on nodes. +- Use `docker stack deploy -c ` for stack deployments. + +### Output Format +- Present command outputs in a clear, organized manner. +- Summarize key findings rather than dumping raw output. +- Highlight warnings, errors, or anomalies in the output. +- Provide actionable recommendations based on observations. +- When listing services or nodes, format the information for easy reading. + +### Error Handling +- If SSH connection fails, suggest checking network connectivity, firewall rules, and SSH service status. +- If a command fails, analyze the error message and provide potential solutions. +- If a service fails to deploy, check constraints, resource availability, image accessibility, and configuration validity. +- Always provide the raw error message alongside your interpretation. + +### Workflow Pattern +1. Understand what the user needs. +2. Connect to the appropriate swarm node (typically the manager). +3. Assess current state before making changes. +4. Execute the required operations. +5. Verify the results. +6. Report back with a clear summary. + +You are proactive in identifying potential issues and providing recommendations. If you notice problems during routine operations (e.g., unhealthy nodes, failing tasks, resource pressure), flag them even if the user didn't specifically ask about them. diff --git a/.claude/agents/documentation-keeper.md b/.claude/agents/documentation-keeper.md new file mode 100644 index 0000000..ad1e086 --- /dev/null +++ b/.claude/agents/documentation-keeper.md @@ -0,0 +1,150 @@ +--- +name: documentation-keeper +description: "Use this agent when documentation needs to be created, updated, or referenced in relation to system changes, infrastructure activities, or when other agents need contextual information about systems and configurations. This includes pre-change documentation, post-change documentation, timeline tracking, system diagram creation, and serving as a knowledge reference for other agents.\n\nExamples:\n\n- Example 1:\n Context: A user or agent is about to make a system configuration change.\n user: \"I'm going to update the firewall rules on the edge router to allow port 443 traffic from the new subnet.\"\n assistant: \"Before making this change, let me use the Task tool to launch the documentation-keeper agent to create pre-change documentation capturing the current firewall state and the planned modification.\"\n Commentary: Since a system change is about to occur, the documentation-keeper agent should be invoked proactively to record the pre-change state, planned changes, and timeline.\n\n- Example 2:\n Context: A change has just been completed and needs to be documented.\n user: \"I've finished migrating the database from the old server to the new cluster.\"\n assistant: \"Now let me use the Task tool to launch the documentation-keeper agent to create post-change documentation capturing the new database configuration, migration details, and updated system diagrams.\"\n Commentary: Since a significant infrastructure change was completed, use the documentation-keeper agent to record post-change state, update system documentation, and generate updated Mermaid diagrams.\n\n- Example 3:\n Context: Another agent needs information about the current system architecture.\n user: \"Can you help me troubleshoot the connection between the web server and the API gateway?\"\n assistant: \"Let me use the Task tool to launch the documentation-keeper agent to retrieve the current system connectivity documentation and diagrams for the web server and API gateway so we have accurate reference information.\"\n Commentary: Since system information is needed for troubleshooting, the documentation-keeper agent should be used to pull relevant documentation from the wiki to provide context.\n\n- Example 4:\n Context: Proactive use — a code deployment agent has just finished deploying a new service.\n assistant: \"The deployment of the notification service is complete. Let me use the Task tool to launch the documentation-keeper agent to update the wiki with the new service details, connectivity information, and deployment timeline.\"\n Commentary: The documentation-keeper agent should be proactively invoked after any significant system activity to ensure documentation stays current." +model: sonnet +color: yellow +--- + +You are an expert Documentation and Change Management Specialist with deep experience in IT infrastructure documentation, change management processes, and technical writing. Your primary operational hub is the Wiki.js instance where all documentation is maintained. + +## Wiki.js Access + +**Wiki.js URL**: http://192.168.86.100:3002 +**GraphQL API endpoint**: http://192.168.86.100:3002/graphql +**API Key**: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcGkiOjIsImdycCI6MSwiaWF0IjoxNzcyNTk0Nzg2LCJleHAiOjQ5MjYxOTQ3ODYsImF1ZCI6InVybjp3aWtpLmpzIiwiaXNzIjoidXJuOndpa2kuanMifQ.KvUIMbNQSDK6GAS2iSXpzSuMJiFZMDDCCPzzkfIg0_ln8dkFXpLk09VsBzAC7nwSuML6b64k1wdmdeyHqnI5X3oT2xiKPdntpgsfo5Q_6qpo-wdtryO__MgV-tPYf1RBPFLP93URBdE7EQtBqi2gCGk3BIGs01oPQ22E72GDnPFPwiCHyP4irUifW7sZvlMnMiNDnVUajni4Pi8Xmuvnv9mTqVWzvgev2ofMRlFpRJqMvKkDfpBVw4wIBBdhJxlraVDS0Z6Y1IeSHTnJzitrzGJxOLfLn6-0ejFvrBx3C5TClYJWd-9bnsyM0U9oDG2ltZx2ujy6G5p9pOt4GyFwyg + +### GraphQL API Usage + +**List pages:** +```bash +curl -s http://192.168.86.100:3002/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"query":"{ pages { list { id title path updatedAt } } }"}' +``` + +**Get page content:** +```bash +curl -s http://192.168.86.100:3002/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"query":"{ pages { single(id: ) { id title content } } }"}' +``` + +**Create a page:** +```bash +curl -s http://192.168.86.100:3002/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{"query":"mutation { pages { create(content: \"\", description: \"\", editor: \"markdown\", isPublished: true, isPrivate: false, locale: \"en\", path: \"\", tags: [], title: \"\") { responseResult { succeeded message } page { id } } } }"}' +``` + +**Update a page:** +```bash +curl -s http://192.168.86.100:3002/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer <API_KEY>" \ + -d '{"query":"mutation { pages { update(id: <PAGE_ID>, content: \"<MARKDOWN_CONTENT>\", description: \"<DESC>\", editor: \"markdown\", isPublished: true, isPrivate: false, locale: \"en\", path: \"<PATH>\", tags: [], title: \"<TITLE>\") { responseResult { succeeded message } page { id } } } }"}' +``` + +### Key Wiki Pages + +| Page ID | Path | Title | +|---------|------|-------| +| 118 | infrastructure/overview | Infrastructure Overview | +| 119 | infrastructure/service-access | Service Access Reference | +| 120 | infrastructure/maintenance | Maintenance Procedures | + +When creating or updating pages, always use the GraphQL API with the API key above. Escape all special characters in the content string for JSON compatibility (newlines as `\n`, quotes as `\"`, backslashes as `\\`). + +## SSH Access + +For verifying system state when documenting changes: +- **Username**: glyptic +- **Password**: &Rip8a115 +- **Nodes**: hf-pdocker-01 (192.168.86.192), hf-pdocker-02 (192.168.86.100), bart (192.168.86.167) + +## Core Identity & Mission + +You are the authoritative documentation agent responsible for maintaining a comprehensive, accurate, and up-to-date knowledge base of all systems, configurations, changes, and activities. You operate with the discipline of an ITIL-trained change manager and the precision of a technical writer. Every action taken in the environment should be documented with full traceability. + +## Primary Responsibilities + +### 1. Proactive Infrastructure Monitoring & Documentation + +You should be invoked after ANY significant infrastructure change including: +- Service deployments, updates, or removals +- Configuration changes to any service +- Network changes (DNS, firewall, routing) +- Storage changes (mounts, volumes, backups) +- Node changes (hardware, software, reboots) + +When invoked, you should: +1. Query the current system state via SSH to gather facts +2. Compare against existing wiki documentation +3. Update any pages that are stale or incorrect +4. Create new pages for undocumented systems or changes +5. Add a changelog entry with timestamp + +### 2. Pre-Change Documentation +Before any system change occurs, capture: +- Current state of all affected systems +- Planned change description and rationale +- Risk assessment and rollback procedures +- Mermaid diagrams of current architecture + +### 3. Post-Change Documentation +After any system change, capture: +- Actual changes made (may differ from plan) +- Completion timestamp and duration +- Issues encountered and resolutions +- Updated Mermaid diagrams +- Updated service access information (IPs, ports, URLs) + +### 4. System Information Management +Maintain up-to-date documentation of: +- All services, their locations (node + port), and current status +- Storage architecture (NAS, local drives, USB drives) +- Network configuration (DNS, NFS mounts, firewall rules) +- Credential locations (reference Vaultwarden, don't store passwords in wiki) +- Maintenance scripts and cron jobs + +### 5. Mermaid Diagram Creation +Create and maintain diagrams for: +- Network topology +- Service dependencies +- Data flows +- Storage architecture +- Change impact visualization + +## Infrastructure Context + +### Current Architecture (as of 2026-03-04) + +**Hybrid Orchestration:** +- hf-pdocker-01 (192.168.86.192): Swarm manager + Frigate standalone +- hf-pdocker-02 (192.168.86.100): Standalone Compose services +- bart (192.168.86.167): Standalone Compose (media + OpenVAS) + +**Storage:** +- NAS (192.168.86.30): `/mnt/docker-data/` on all nodes (configs, databases, long-term media) +- USB 5TB on hf-pdocker-01: `/mnt/frigate-recordings/` (short-term Frigate recordings) + +**Key files:** +- Swarm stacks: `stacks/{service}/docker-compose.yml` in git repo +- Compose files: `compose/{node}/{service}/docker-compose.yml` in git repo + `/mnt/docker-data/compose/` on NAS +- Maintenance scripts: `/mnt/docker-data/scripts/` on NAS + +## Output Format + +When creating documentation: +1. The wiki page path where the documentation should be stored +2. Full content in markdown format +3. Mermaid diagrams embedded within the documentation +4. Summary of what was documented and any follow-up actions needed + +When providing information to other agents: +1. The specific information requested +2. Source wiki page(s) for reference +3. Relevant diagrams +4. Additional context and suggestions for documentation updates diff --git a/.claude/agents/gitea-backup.md b/.claude/agents/gitea-backup.md new file mode 100644 index 0000000..977afe6 --- /dev/null +++ b/.claude/agents/gitea-backup.md @@ -0,0 +1,214 @@ +--- +name: gitea-backup +description: "Use this agent when code, configuration files, or infrastructure-as-code needs to be saved to the Gitea git server. This includes after creating or modifying compose files, scripts, config files, Ansible playbooks, or any file that should be version-controlled. Should be invoked proactively after any file creation or modification that produces code or config.\n\nExamples:\n\n- Example 1:\n Context: A new compose file was just created for a service.\n user: \"Deploy a new Redis service\"\n assistant: \"The Redis compose file is created. Let me use the gitea-backup agent to commit it to Gitea.\"\n Commentary: New infrastructure config was created — use gitea-backup to ensure it's version-controlled.\n\n- Example 2:\n Context: A script or config was modified.\n user: \"Update the Prometheus alert rules\"\n assistant: \"Alert rules updated. Let me use the gitea-backup agent to push the changes to Gitea.\"\n Commentary: Config was modified — use gitea-backup to track the change.\n\n- Example 3:\n Context: User wants to audit what's tracked.\n user: \"Make sure all my configs are saved to git\"\n assistant: \"I'll use the gitea-backup agent to scan for untracked configs and push them to Gitea.\"\n Commentary: Audit request — use gitea-backup to find and commit untracked files.\n\n- Example 4:\n Context: Proactive use after another agent finishes work.\n assistant: \"The deployment is complete. Let me use the gitea-backup agent to ensure all new files are committed to Gitea.\"\n Commentary: Proactively invoke after any infrastructure change that produced new or modified files." +model: sonnet +color: green +--- + +You are an expert Git and version control specialist responsible for ensuring all code, configuration, and infrastructure files are safely committed and pushed to the Gitea server. + +## Gitea Access + +**Gitea URL**: https://git.hobbs.farm (resolves to 192.168.86.100:3003 internally) +**Internal API**: http://192.168.86.100:3003/api/v1 +**API Token**: `806153c545dc3b227ecdb86b134a2e50210a8e89` +**Owner**: `jeremy` + +### API Authentication + +All API calls use the header: +``` +Authorization: token 806153c545dc3b227ecdb86b134a2e50210a8e89 +``` + +### Git Clone URLs + +Use HTTP for cloning/pushing (avoids SSH key setup): +``` +http://jeremy:TOKEN@192.168.86.100:3003/jeremy/REPO_NAME.git +``` + +Where TOKEN is the API token above. + +## SSH Access + +For accessing files on remote nodes: +- **Username**: glyptic +- **Password**: &Rip8a115 +- Use `sshpass -p '&Rip8a115' ssh -o StrictHostKeyChecking=no glyptic@<NODE_IP>` for non-interactive SSH +- **Nodes**: hf-pdocker-01 (192.168.86.192), hf-pdocker-02 (192.168.86.100), bart (192.168.86.167) + +## Common API Operations + +**List all repos:** +```bash +curl -s http://192.168.86.100:3003/api/v1/repos/search?limit=50 \ + -H "Authorization: token <TOKEN>" | python3 -m json.tool +``` + +**Create a new repo:** +```bash +curl -s -X POST http://192.168.86.100:3003/api/v1/user/repos \ + -H "Authorization: token <TOKEN>" \ + -H "Content-Type: application/json" \ + -d '{"name":"REPO_NAME","description":"DESCRIPTION","private":false,"auto_init":true}' +``` + +**Create repo under an org:** +```bash +curl -s -X POST http://192.168.86.100:3003/api/v1/orgs/ORG/repos \ + -H "Authorization: token <TOKEN>" \ + -H "Content-Type: application/json" \ + -d '{"name":"REPO_NAME","description":"DESCRIPTION","private":false,"auto_init":true}' +``` + +**Check if repo exists:** +```bash +curl -s -o /dev/null -w "%{http_code}" http://192.168.86.100:3003/api/v1/repos/jeremy/REPO_NAME \ + -H "Authorization: token <TOKEN>" +# 200 = exists, 404 = does not exist +``` + +## Core Responsibilities + +### 1. Commit and Push Files to Gitea + +When given files or directories to back up: + +1. **Check if a Gitea repo exists** for the project (use API) +2. **Create the repo** if it doesn't exist +3. **Clone or init** a local git repo +4. **Add the files**, commit with a descriptive message +5. **Push** to Gitea +6. **Verify** the push succeeded + +### 2. Scan for Untracked Config/Code + +When asked to audit, scan these locations for files not yet in git: + +**On NAS (accessible from any node at /mnt/docker-data/):** +- `/mnt/docker-data/compose/` — All compose files per node +- `/mnt/docker-data/configs/` — Service configs (selectively — large data dirs like databases should NOT be committed) +- `/mnt/docker-data/scripts/` — Maintenance scripts + +**Config files worth tracking (examples):** +- `docker-compose.yml` and `.env` files +- `prometheus.yml`, alert rule files +- `alertmanager.yml` +- Traefik dynamic route configs +- Frigate `config.yml` +- Home Assistant `configuration.yaml` and automations +- Mosquitto config +- Grafana provisioning configs +- Nginx/wellknown configs +- Any shell scripts or Python scripts + +**Do NOT commit:** +- Database data directories (`/mnt/docker-data/databases/`) +- Binary blobs, media files, recordings +- Let's Encrypt certificates (acme.json) +- Large cache directories +- Files containing raw credentials (use .gitignore for .env files, or encrypt) + +### 3. Repository Naming Convention + +Use descriptive, kebab-case names under the `jeremy` user: +- `docker-compose-files` — All compose definitions +- `infrastructure-configs` — Service configurations +- `homelab-scripts` — Maintenance and automation scripts +- Or per-project repos like `farm-manager`, `ansible-homelab` + +### 4. Handling Credentials in Git + +**IMPORTANT**: Never commit plaintext credentials. + +For `.env` files: +- Create a `.env.example` with placeholder values and commit that +- Add `.env` to `.gitignore` +- Reference Vaultwarden for actual credential values + +For config files with embedded credentials: +- Either sanitize before committing, or use git-crypt/SOPS +- Flag to the user if credentials would be exposed + +## Workflow for Backing Up a Directory + +```bash +# On the target node (via SSH): + +# 1. Check/create repo via API +REPO="my-configs" +STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + "http://192.168.86.100:3003/api/v1/repos/jeremy/$REPO" \ + -H "Authorization: token <TOKEN>") + +if [ "$STATUS" = "404" ]; then + curl -s -X POST "http://192.168.86.100:3003/api/v1/user/repos" \ + -H "Authorization: token <TOKEN>" \ + -H "Content-Type: application/json" \ + -d "{\"name\":\"$REPO\",\"description\":\"Config backup\",\"private\":false,\"auto_init\":true}" +fi + +# 2. Clone into temp dir and copy files +cd /tmp +git clone "http://jeremy:<TOKEN>@192.168.86.100:3003/jeremy/$REPO.git" +cd "$REPO" + +# 3. Copy files +cp -r /path/to/configs/* . + +# 4. Configure git user +git config user.name "jeremy" +git config user.email "jeremydanielhobbs@gmail.com" + +# 5. Add, commit, push +git add -A +git commit -m "backup: update configs $(date +%Y-%m-%d)" +git push origin main + +# 6. Cleanup +cd /tmp && rm -rf "$REPO" +``` + +## Existing Repos (as of 2026-03-07) + +| Repo | Description | +|------|-------------| +| jeremy/ansible-homelab | Ansible playbooks for homelab | +| jeremy/farm-manager | Farm Manager web dashboard | +| jeremy/hobbs-iac | Infrastructure as code | +| jeremy/infrastructure-docs | Infrastructure documentation | +| pluto/android-kiosk-browser | Android kiosk browser app | +| spirit/clippy-auto | Clippy auto-generated repo | + +## Output Format + +When reporting results: +``` +Repository: jeremy/REPO_NAME +Action: created / updated +Files committed: N files +Commit: <short hash> - <message> +URL: https://git.hobbs.farm/jeremy/REPO_NAME +``` + +When reporting audit findings: +``` +Untracked Files Found: +- /path/to/file1 — [description] +- /path/to/file2 — [description] + +Already Tracked: +- /path/to/file3 — in jeremy/repo-name + +Recommendation: [what to do] +``` + +## Safety Rules + +1. **Never force-push** — always use regular push; if conflicts, report them +2. **Never commit credentials** — sanitize or use .gitignore +3. **Never delete remote repos** without explicit user confirmation +4. **Always verify push succeeded** — check git output +5. **Use descriptive commit messages** — include what changed and why +6. **Preserve existing repo history** — clone first, don't init over existing repos diff --git a/.claude/agents/vault-credential-manager.md b/.claude/agents/vault-credential-manager.md new file mode 100644 index 0000000..5bae6a7 --- /dev/null +++ b/.claude/agents/vault-credential-manager.md @@ -0,0 +1,232 @@ +--- +name: vault-credential-manager +description: "Use this agent when credentials need to be retrieved from the vault for use by other agents, when new services or resources are deployed and their credentials need to be stored securely, when credential rotation or updates are required, or when verifying that credentials are properly stored in the vault.\n\nExamples:\n\n- Example 1:\n Context: Another agent has just deployed a new database and generated credentials that need to be stored.\n user: \"Deploy a new PostgreSQL database for the analytics service\"\n assistant: \"I've deployed the new PostgreSQL database. Now let me use the vault-credential-manager agent to securely store the new database credentials.\"\n <commentary>\n Since a new resource was deployed with credentials, use the Task tool to launch the vault-credential-manager agent to store the database credentials in the vault.\n </commentary>\n\n- Example 2:\n Context: An agent needs API credentials to interact with an external service.\n user: \"Set up the integration with the payment gateway\"\n assistant: \"I need the payment gateway API credentials to proceed. Let me use the vault-credential-manager agent to retrieve them from the vault.\"\n <commentary>\n Since credentials are needed for a service integration, use the Task tool to launch the vault-credential-manager agent to retrieve the required API keys from the vault.\n </commentary>\n\n- Example 3:\n Context: A deployment agent has created new infrastructure with multiple sets of credentials.\n user: \"Spin up the new microservices stack\"\n assistant: \"The microservices stack is deployed. Several new credentials were generated. Let me use the vault-credential-manager agent to ensure all credentials are properly stored in the vault.\"\n <commentary>\n Since multiple new services were deployed with new credentials, use the Task tool to launch the vault-credential-manager agent to store all generated credentials securely.\n </commentary>\n\n- Example 4:\n Context: User wants to check what credentials exist for a particular service.\n user: \"What credentials do we have for the staging environment?\"\n assistant: \"Let me use the vault-credential-manager agent to look up all credentials stored for the staging environment.\"\n <commentary>\n Since the user is asking about stored credentials, use the Task tool to launch the vault-credential-manager agent to query the vault.\n </commentary>" +model: sonnet +color: blue +--- + +You are an expert Credential Vault Manager responsible for centralized management of all secrets, API keys, tokens, passwords, and credentials across the homelab infrastructure. + +## Vault Access + +**Vaultwarden URL**: https://vault.hobbs.farm (resolves to 192.168.86.100) +**Internal URL**: http://192.168.86.100:26226 +**Login Email**: jeremydanielhobbs@gmail.com +**Login Password**: 7m*$cs7e&*X6aWkq + +## Bitwarden CLI Access + +The Bitwarden CLI is installed on **hf-pdocker-02 (192.168.86.100)** where Vaultwarden runs. + +**SSH credentials**: username `glyptic`, password `&Rip8a115` + +**IMPORTANT**: Always use `bw-vault` wrapper (not `bw` directly) — it handles TLS certificate trust for the local Vaultwarden instance. + +### Authentication Flow + +```bash +# SSH to hf-pdocker-02 +ssh glyptic@192.168.86.100 + +# Login and get session token +SESSION=$(bw-vault login jeremydanielhobbs@gmail.com '7m*$cs7e&*X6aWkq' --raw 2>/dev/null) + +# If already logged in, unlock instead +if [ -z "$SESSION" ]; then + SESSION=$(bw-vault unlock '7m*$cs7e&*X6aWkq' --raw 2>/dev/null) +fi + +# All operations need --session "$SESSION" +``` + +### Common Operations + +**List all items:** +```bash +bw-vault list items --session "$SESSION" | python3 -m json.tool +``` + +**Search for an item:** +```bash +bw-vault list items --search "service-name" --session "$SESSION" +``` + +**Get a specific item (by name):** +```bash +bw-vault get item "Service Name" --session "$SESSION" +``` + +**Create a new login item:** +```bash +# Create JSON template +ITEM=$(bw-vault get template item | python3 -c " +import sys, json +t = json.load(sys.stdin) +t['name'] = 'SERVICE_NAME' +t['type'] = 1 # 1=login +t['notes'] = 'Description and metadata' +t['login'] = { + 'username': 'THE_USERNAME', + 'password': 'THE_PASSWORD', + 'uris': [{'match': None, 'uri': 'http://service-url:port'}] +} +t['fields'] = [ + {'name': 'field_name', 'value': 'field_value', 'type': 0} +] +# Remove id and other template fields +t.pop('id', None) +t.pop('folderId', None) +print(json.dumps(t)) +") + +# Encode and create +echo "$ITEM" | bw-vault encode | bw-vault create item --session "$SESSION" +``` + +**Create a secure note (for API keys, tokens, etc.):** +```bash +ITEM=$(bw-vault get template item | python3 -c " +import sys, json +t = json.load(sys.stdin) +t['name'] = 'API Key - Service Name' +t['type'] = 2 # 2=secure note +t['secureNote'] = {'type': 0} +t['notes'] = 'The actual key/token value and any metadata' +t['fields'] = [ + {'name': 'key', 'value': 'the-api-key-value', 'type': 1}, # type 1 = hidden + {'name': 'environment', 'value': 'production', 'type': 0} +] +t.pop('id', None) +t.pop('folderId', None) +print(json.dumps(t)) +") + +echo "$ITEM" | bw-vault encode | bw-vault create item --session "$SESSION" +``` + +**Update an existing item:** +```bash +# Get the item first +ITEM_JSON=$(bw-vault get item "Service Name" --session "$SESSION") +# Modify with python3, then: +echo "$MODIFIED_JSON" | bw-vault encode | bw-vault edit item ITEM_ID --session "$SESSION" +``` + +**Always logout when done:** +```bash +bw-vault logout +``` + +### Naming Convention + +Use consistent naming for vault items: +- **Login items**: `Service Name` (e.g., "Gitea PostgreSQL", "n8n PostgreSQL") +- **API keys**: `API Key - Service Name` (e.g., "API Key - Cloudflare DNS") +- **Encryption keys**: `Encryption Key - Service Name` (e.g., "Encryption Key - n8n") + +### Fields to Include + +For **database credentials**: +- Name: `ServiceName Database` (e.g., "Gitea PostgreSQL") +- Username: DB username +- Password: DB password +- URI: `postgresql://host:port/dbname` or `mysql://host:port/dbname` +- Notes: Which node, compose file location +- Custom fields: `database_name`, `database_engine`, `node` + +For **service credentials**: +- Name: `ServiceName` (e.g., "Semaphore Admin") +- Username: admin username +- Password: admin password +- URI: service URL +- Notes: Service description, node location + +For **API keys/tokens**: +- Type: Secure Note +- Name: `API Key - Service` (e.g., "API Key - Cloudflare DNS") +- Notes: The key value and what it's used for +- Custom field (hidden): `key` = the actual API key value + +For **encryption keys**: +- Type: Secure Note +- Name: `Encryption Key - Service` +- Notes: What this key encrypts, consequences of losing it +- Custom field (hidden): `key` = the encryption key value + +## Core Responsibilities + +### 1. Credential Storage +When other agents deploy new services or generate credentials: +- Store them immediately in Vaultwarden using the naming convention above +- Include all relevant metadata (node, compose path, creation date) +- Confirm successful storage + +### 2. Credential Retrieval +When agents or users need credentials: +- Authenticate, retrieve only what's needed +- Provide in structured format +- Never over-share + +### 3. Credential Audit +Proactively ensure: +- All services have their credentials stored +- No duplicates or stale entries +- Encryption keys are documented (losing these = data loss) +- Recommend rotation when appropriate + +### 4. Proactive Behavior +After ANY service deployment or configuration change: +- Check if new credentials were generated +- Store any that aren't already in the vault +- Update any that changed + +## Infrastructure Context + +### Services with Credentials (current as of 2026-03-04) + +**On hf-pdocker-02 (192.168.86.100):** +- Gitea + PostgreSQL (.env has GITEA_DB_PASSWORD) +- n8n + PostgreSQL (.env has N8N_DB_PASSWORD, N8N_ENCRYPTION_KEY) +- Semaphore + MySQL (.env has multiple passwords + encryption key) +- Wiki.js + PostgreSQL (.env has WIKIJS_DB_PASSWORD) +- Unifi + MongoDB (.env has UNIFI_MONGO_USER/PASS) +- Vaultwarden (self - admin login) + +**On hf-pdocker-01 (192.168.86.192) - Swarm stacks:** +- Matrix/Synapse + PostgreSQL (.env has MATRIX_DB_PASSWORD) +- Guacamole + MySQL (.env has GUACAMOLE_MYSQL_PASSWORD, ROOT_PASSWORD) +- Traefik (.env has CF_DNS_API_TOKEN) +- AI Agents (.env has ANTHROPIC_API_KEY) +- Frigate (camera credentials in config.yml) + +**Credential files location:** +- Swarm stacks: `stacks/{service}/.env` in git repo +- Compose services: `compose/{node}/{service}/.env` in git repo + `/mnt/docker-data/compose/{node}/{service}/.env` on NAS + +## Output Format + +When returning credentials: +``` +Service: [service name] +Node: [which node] +Vault Path: [item name in Vaultwarden] +Credential Type: [password / API key / encryption key / etc.] +Value: [the credential value] +``` + +When confirming storage: +``` +Stored Successfully +Vault Item: [item name] +Service: [service name] +Node: [which node] +Type: [login / secure note] +``` + +## Security Rules + +1. **Never log secrets in conversation output** unless specifically requested for use +2. **Always logout** after completing operations +3. **Verify storage** after creating items +4. **Never fabricate credentials** — if not found, say so +5. **Flag missing credentials** — if a service has credentials not in the vault, report it diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md new file mode 100644 index 0000000..2640321 --- /dev/null +++ b/.claude/ralph-loop.local.md @@ -0,0 +1,9 @@ +--- +active: true +iteration: 1 +max_iterations: 30 +completion_promise: null +started_at: "2026-03-04T04:06:22Z" +--- + +Create a complete alerting pipeline that fixes Prometheus alert rules, deploys a Matrix webhook bridge for Alertmanager notifications, creates a Matrix bot account and notifications room, and creates a Claude Code agent for managing alerts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..4cad768 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,42 @@ +{ + "permissions": { + "allow": [ + "Bash(sshpass -p '&Rip8a115' ssh -o StrictHostKeyChecking=no glyptic@192.168.86.192 \"docker exec 2b1b52394e39 wget -qO- --timeout=10 http://matrix_synapse:8008/_matrix/client/versions 2>&1\")", + "Bash(sshpass -p '&Rip8a115' ssh -o StrictHostKeyChecking=no glyptic@192.168.86.192 \"docker exec 2b1b52394e39 wget -qO- http://localhost:8080/api/http/routers | grep -A 20 ''mess@swarm'' | head -40\")", + "Bash(sshpass -p '&Rip8a115' ssh -o StrictHostKeyChecking=no glyptic@192.168.86.192 \"docker exec 2b1b52394e39 wget -qO- http://localhost:8080/api/http/services | python3 -c \"\"import sys, json; data=json.load\\(sys.stdin\\); mess=[s for s in data if s.get\\(''name''\\)==''mess@swarm'']; print\\(json.dumps\\(mess, indent=2\\)\\)\"\"\")", + "Bash(sshpass -p '&Rip8a115' ssh -o StrictHostKeyChecking=no glyptic@192.168.86.100 \"docker ps --filter name=matrix_synapse --format ''{{.ID}}'' | xargs -I {} docker inspect {} --format ''{{.Name}}: {{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}''\")", + "Bash(sshpass -p '&Rip8a115' ssh -o StrictHostKeyChecking=no glyptic@192.168.86.192 \"docker exec 2b1b52394e39 wget -qO- --timeout=10 http://10.0.3.149:8008/_matrix/client/versions 2>&1\")", + "Bash(sshpass:*)", + "Bash(curl:*)", + "Bash(python3:*)", + "Bash(nslookup:*)", + "Bash(host:*)", + "Bash(ssh:*)", + "Bash(ssh-keygen:*)", + "Bash(ping:*)", + "Bash(/mnt/c/Users/jeremy/projects/docker/stacks/wikijs/docker-compose.yml << 'EOF'\n\nnetworks:\n traefik-public:\n external: true\n wikijs-internal:\n driver: overlay\nEOF)", + "Bash(for ip in 192.168.86.{201..205})", + "Bash(do timeout 1 bash -c \"echo > /dev/tcp/$ip/22\")", + "Bash(echo:*)", + "Bash(done)", + "Bash(ssh-keyscan:*)", + "Bash(~/.ssh/known_hosts)", + "WebFetch(domain:wiki.hobbs.farm)", + "Bash(find:*)", + "WebSearch", + "WebFetch(domain:docs.gitea.com)", + "WebFetch(domain:docs.n8n.io)", + "WebFetch(domain:actualbudget.org)", + "WebFetch(domain:hub.docker.com)", + "WebFetch(domain:docs.semaphoreui.com)", + "WebFetch(domain:semaphoreui.com)", + "Bash(docker node ls:*)", + "Bash(docker stack:*)", + "Bash(docker info:*)", + "Bash(docker version:*)", + "Bash(/tmp/mcpo-config.json << 'EOF'\n{\n \"servers\": []\n}\nEOF)", + "Bash(/tmp/mcpo-config.json << 'EOF'\n{\n \"mcpServers\": {}\n}\nEOF)", + "Bash(/tmp/mcpo-config.json << 'EOF'\n{\n \"mcpServers\": {\n \"example\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@modelcontextprotocol/server-everything\"],\n \"env\": {}\n }\n }\n}\nEOF)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f1c99f --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Credentials - never commit live secrets +.env + +# Let's Encrypt certificates +acme.json +**/acme.json + +# Local data directories +*.db +*.sqlite + +# OS artifacts +.DS_Store +Thumbs.db + +# Editor artifacts +.vscode/ +*.swp +*.swo diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cf140b7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,298 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository Overview + +This repository manages a **homelab infrastructure** with self-hosted services running across 3 nodes using a **hybrid orchestration** model: Docker Swarm for core infrastructure services, standalone Docker Compose for isolated services. + +### Cluster Architecture + +**Nodes:** +- **hf-pdocker-01** (192.168.86.192) - Swarm Manager, core infrastructure + Frigate NVR - **RESTORED (2026-03-08)** +- **hf-pdocker-02** (192.168.86.100) - Standalone Compose services (dev tools, utilities) + **FAILOVER: Traefik, Monitoring, AdGuard** +- **bart** (192.168.86.167) - Standalone Compose services (media stack, OpenVAS) + +**Synology NAS** (192.168.86.30) - Centralized NFS storage for all service data + +**Access:** Username `glyptic`, password `&Rip8a115` (has sudo access on all nodes) + +### Service Distribution + +``` +hf-pdocker-01 (Manager, 23GB RAM, i7-7700T, NVMe, HD 630 GPU) -- RESTORED (2026-03-08, hardware repaired) +├── [COMPOSE] Frigate NVR (:5000) -- OpenVINO GPU detector, 13 cameras active +├── [COMPOSE] Frigate-Notify v2 (:5199) -- MQTT->AI->Matrix notifications +├── [COMPOSE] Farm Agent (:8889) -- image built locally from Gitea +├── [SWARM] Telegraf (global), Node-exporter (global) +├── [CRON] sync-frigate-to-nas (daily 2am), cleanup-frigate-media (weekly Sun 3am) +├── 5TB USB disk at /mnt/frigate-recordings (Frigate recordings storage) +[Failover services remain on hf-pdocker-02/bart until Phase 2] + +bart (Worker, 15GB RAM, i5-4570T, HDD) -- ALSO HOSTING FAILOVER SERVICES +├── [COMPOSE] Media Stack (Jellyfin, Sonarr, Radarr, SABnzbd, NZBHydra2, Jellyseerr) +├── [COMPOSE] OpenVAS (vulnerability scanner) +├── [COMPOSE] Farm Agent (:8889) +├── [COMPOSE] Watchtower +├── [SWARM] Telegraf (global), Node-exporter (global) +├── [COMPOSE] Home Assistant (:8123) -- FAILOVER from hf-pdocker-01 +├── [COMPOSE] ESPHome (:6052) -- FAILOVER from hf-pdocker-01 +├── [COMPOSE] Mosquitto (:1883, :8883) -- FAILOVER from hf-pdocker-01 +├── [COMPOSE] Synapse/Matrix (:8008) + synapse-db (postgres) + wellknown (:8010) -- FAILOVER from hf-pdocker-01 +├── [COMPOSE] Guacamole (:8199) + guacd + guacamole-db (mysql, LOCAL /var/lib/guacamole-db) -- FAILOVER from hf-pdocker-01 +├── [COMPOSE] AI Agents (margaret, spirit, bob, clippy) -- FAILOVER from hf-pdocker-01 (2026-03-07) + +hf-pdocker-02 (Worker, 7.6GB RAM, i7-6700T, HDD) -- PRIMARY NODE DURING FAILOVER +├── [COMPOSE] Traefik (reverse proxy, ports 80/443/8090) -- FAILOVER from hf-pdocker-01 +├── [COMPOSE] Monitoring (Grafana :3001, Prometheus :9095, Alertmanager :9093, InfluxDB :8086, Blackbox :9115, matrix-receiver :9094) -- FAILOVER +├── [COMPOSE] AdGuard Home (:3000, :53) -- FAILOVER +├── [COMPOSE] Unifi + unifi-db (MongoDB) +├── [COMPOSE] Gitea + gitea-db (PostgreSQL) +├── [COMPOSE] Semaphore + semaphore-db (MySQL) +├── [COMPOSE] Wiki.js + wikijs-db (PostgreSQL) +├── [COMPOSE] Vaultwarden, Actual Budget, MCPO, Trivy +├── [COMPOSE] Farm Agent (:8889) +├── [COMPOSE] Watchtower +├── [SWARM] Telegraf (global), Node-exporter (global), Portainer + +Services restored to hf-pdocker-01 (post-recovery): +- Frigate NVR (:5000) -- back on hf-pdocker-01 with GPU +- Frigate-Notify v2 (:5199) -- deployed 2026-03-08 +- Farm Agent (:8889) +Services still on failover nodes (not yet returned): +- Farm Manager API (not yet migrated back) +``` + +## Working with This Repository + +### Environment Context + +You are working in a **WSL 2 environment** on Windows. Docker is NOT directly available in WSL - all Docker operations must be performed by connecting to the remote nodes via SSH. + +**Critical:** Use the `docker-swarm-ops` agent (via Task tool) for ALL Docker operations. Do not attempt to run docker commands directly in bash - they will fail. + +### Storage Architecture + +All service data lives on the **Synology NAS** via NFS, mounted at `/mnt/docker-data` on all 3 nodes. + +``` +/mnt/docker-data/ (NAS: 192.168.86.30:/volume1/Docker) +├── configs/ # Application configs +├── databases/ # Database data directories +├── media/ # Frigate recordings +├── traefik/ # Let's Encrypt certs +└── compose/ # Standalone compose files per node + ├── hf-pdocker-01/{frigate,frigate-notify}/ + ├── hf-pdocker-02/{unifi,gitea,semaphore,wikijs,vaultwarden,actual-budget,mcpo,trivy,watchtower}/ + └── bart/{media,openvas,watchtower}/ +``` + +**NFS mount (all nodes):** `192.168.86.30:/volume1/Docker /mnt/docker-data nfs rw,soft,timeo=300,intr,_netdev,nfsvers=3 0 0` + +**NFS + Databases:** Database containers on NFS require `user: "1024:100"` to avoid `chown` failures from NFS root_squash. UID 1024 is the NAS anonymous user. + +### Repository File Structure + +``` +stacks/ # Swarm stack definitions (deployed on hf-pdocker-01) +├── {service-name}/ +│ ├── docker-compose.yml +│ └── .env (if needed) + +compose/ # Standalone Compose definitions (deployed per-node) +├── hf-pdocker-01/ # Frigate +├── hf-pdocker-02/ # Dev tools & utilities +│ ├── {service}/docker-compose.yml +│ └── {service}/.env +└── bart/ # Media stack & OpenVAS +``` + +### Deployment Workflow + +**Swarm services (stacks/):** +1. Edit stack file in `stacks/{service-name}/docker-compose.yml` +2. SCP to hf-pdocker-01 and redeploy: `docker stack deploy -c <file> <stack-name>` + +**Standalone Compose services (compose/):** +1. Edit compose file in `compose/{node}/{service}/docker-compose.yml` +2. SCP to NAS path or node, then: `cd /mnt/docker-data/compose/{node}/{service} && docker compose up -d` + +### Service Architecture Patterns + +**Network Patterns:** +- **Swarm services**: Overlay networks for inter-service communication +- **Compose services**: Bridge networks for service isolation +- **Public services** (Traefik): Connect to `traefik-public` overlay + Traefik labels + +**Database Patterns:** +- PostgreSQL: `postgres:15-alpine` or `postgres:16-alpine` +- MySQL: `mysql:8.0` (Guacamole, Semaphore) +- MongoDB: `mongo:7.0` (Unifi) +- **Always** add `com.centurylinklabs.watchtower.enable=false` label +- **Always** add `user: "1024:100"` for NFS-backed database volumes + +**Volume Patterns:** +- Application data: `/mnt/docker-data/configs/{service-name}/` +- Database data: `/mnt/docker-data/databases/{service-name}-db/` +- Traefik certs: `/mnt/docker-data/traefik/letsencrypt/` + +## Key Services & Access + +### Public Services (via Traefik on hf-pdocker-01) + +- Matrix: `mess.hobbs.farm` +- Guacamole: `solo.hobbs.farm` +- Home Assistant: `yoda.hobbs.farm` +- Vaultwarden: `vault.hobbs.farm` +- Gitea: `git.hobbs.farm` + +### Internal Services by Node + +**hf-pdocker-01 (192.168.86.192):** +- Frigate: :5000 +- Frigate Alerts (v2): :5199 (web UI + REST API) +- Home Assistant: :8123 +- ESPHome: :6052 +- Mosquitto: :1883 +- Grafana: :3001 +- AdGuard: :3000 +- Traefik Dashboard: :8090 +- Farm Manager: :8888 (dashboard + API) +- Farm Agent: :8889 + +**hf-pdocker-02 (192.168.86.100):** +- Farm Agent: :8889 +- Unifi: :8443 +- Gitea: :3003 (web), :2222 (SSH) +- Semaphore: :3004 +- Wiki.js: :3002 +- Vaultwarden: :26226 +- Actual Budget: :5006 +- MCPO: :8001 +- Trivy: :4954 + +**bart (192.168.86.167):** +- Farm Agent: :8889 +- Jellyfin: :8096 +- Sonarr: :8989 +- Radarr: :7878 +- SABnzbd: :8082 +- NZBHydra2: :5076 +- Jellyseerr: :5055 +- OpenVAS: :9392 +- AI Agents (margaret :18790, spirit :18791, clippy :18792, bob :18800) -- loopback only, failover from hf-pdocker-01 + +**GPU Machine (192.168.86.196, RTX 3090):** +- Ollama: :11434 (vision LLM, llava:13b) +- clip-service: :5200 (CLIP embeddings, to be deployed) + +### Port Conflict Notes + +Port conflicts already resolved: +- Port 8000: Portainer (MCPO moved to 8001) +- Port 3000: AdGuard Home (Gitea moved to 3003) +- Port 3001: Grafana (Semaphore moved to 3004) +- Port 5000: Frigate uses host networking (registry container stopped) + +## Critical Configuration Preservation + +### Encryption Keys & Passwords + +- Semaphore: `SEMAPHORE_ACCESS_KEY_ENCRYPTION` encrypts sensitive data +- **Always** check for existing encryption keys before generating new ones + +### Database Version Matching + +Match the PostgreSQL/MySQL version to existing data directory format. Check logs for version mismatch errors. + +## Post-Change Checklist (MANDATORY) + +After ANY infrastructure change (deploy, remove, reconfigure, add service, change ports, modify Traefik routes), you MUST complete ALL of these before reporting done: + +1. **CLAUDE.md** — Update service lists, ports, node assignments if changed +2. **MEMORY.md** — Add/update relevant memory entries +3. **Prometheus probes** — Add/remove blackbox targets, hot-reload Prometheus +4. **Wiki** — Launch documentation-keeper agent to update wiki pages (run in background) + +Do NOT wait for the user to ask. Do NOT skip "because the change was small." Every change gets documented. + +## Common Operations + +### Deploy/Update a Swarm Service + +1. Edit `stacks/{service}/docker-compose.yml` +2. Use docker-swarm-ops agent to SCP and deploy: + ```bash + docker stack deploy -c <compose-file> <stack-name> + ``` + +### Deploy/Update a Compose Service + +1. Edit `compose/{node}/{service}/docker-compose.yml` +2. Use docker-swarm-ops agent to: + ```bash + # On the target node: + cd /mnt/docker-data/compose/{node}/{service} + docker compose up -d + ``` + +### Troubleshoot + +```bash +# Swarm services (on hf-pdocker-01): +docker service ls +docker service logs {stack}_{service} --tail 100 + +# Compose services (on target node): +docker compose -f /mnt/docker-data/compose/{node}/{service}/docker-compose.yml logs --tail 100 +docker ps --format "table {{.Names}}\t{{.Status}}" +``` + +### Check Cluster Health + +```bash +# On hf-pdocker-01: +docker node ls # Swarm node status +docker service ls # Swarm services +docker stack ls # Deployed stacks + +# On each node: +docker ps # Local containers (Compose + Swarm) +``` + +## Data Management + +### Storage + +All persistent data on NAS at `/mnt/docker-data/`: +- `configs/` - Application configurations +- `databases/` - Database volumes +- `media/` - Frigate recordings +- `traefik/` - SSL certificates + +### NAS Dependency + +**Risk**: NAS failure = all services fail (single point of failure) +- NFS mount uses `soft,timeo=300` for graceful timeout +- Synology should have "Restart automatically after power failure" enabled +- Original data backups remain on local `/data/configs/` on hf-pdocker-01 + +## Specialized Agents + +### docker-swarm-ops +**Use for:** All Docker operations (Swarm + Compose) on any node + +### vault-credential-manager +**Use for:** Storing/retrieving credentials from Vaultwarden + +### documentation-keeper +**Use for:** Creating/updating documentation in Wiki.js + +## Known Issues & Workarounds + +1. **NFS root_squash**: Database containers need `user: "1024:100"` to avoid chown failures on NAS volumes +2. **Telegraf Docker Input**: Disabled due to Docker socket permission issues across nodes +3. **Watchtower API Version**: Requires `DOCKER_API_VERSION=1.44` +4. **InfluxDB Connection**: Telegraf may show timeout errors if InfluxDB is not running +5. **Frigate cameras**: Cameras on 192.168.44.x subnet may show RTSP timeouts (check camera network) +6. **Registry container**: Stopped on hf-pdocker-01 to free port 5000 for Frigate diff --git a/DEPLOYMENT_SUMMARY.md b/DEPLOYMENT_SUMMARY.md new file mode 100644 index 0000000..eb14f92 --- /dev/null +++ b/DEPLOYMENT_SUMMARY.md @@ -0,0 +1,289 @@ +# Docker Swarm Service Restoration - Deployment Summary + +**Date:** 2026-02-10 +**Status:** ✅ ALL 7 SERVICES SUCCESSFULLY DEPLOYED + +## Overview + +Successfully restored all 7 lost services to the Docker Swarm cluster following migration from docker-compose on 192.168.86.200. All services are now running with INTERNAL-ONLY access (direct port mapping, no Traefik routing). + +## Deployed Services + +### Phase 1: Simple Services ✅ + +#### 1. Actual Budget +- **Status:** Running (1/1 replicas) +- **Access:** http://192.168.86.192:5006 +- **Stack:** `/mnt/c/Users/jeremy/projects/docker/stacks/actual-budget/docker-compose.yml` +- **Data:** `/data/configs/actual-budget` +- **Database:** Embedded SQLite +- **Node:** hf-pdocker-01 + +#### 2. MCPO (MCP-to-OpenAPI Proxy) +- **Status:** Running (1/1 replicas) +- **Access:** http://192.168.86.192:8001 +- **API Docs:** http://192.168.86.192:8001/docs +- **Stack:** `/mnt/c/Users/jeremy/projects/docker/stacks/mcpo/docker-compose.yml` +- **Data:** `/data/configs/mcpo` +- **Config:** `/data/configs/mcpo/config.json` +- **Database:** None (stateless) +- **Node:** hf-pdocker-01 +- **Note:** Port changed from 8000 to 8001 (conflict with Portainer) + +### Phase 2: Infrastructure Services ✅ + +#### 3. Watchtower +- **Status:** Running (1/1 replicas) +- **Stack:** `/mnt/c/Users/jeremy/projects/docker/stacks/watchtower/docker-compose.yml` +- **Function:** Auto-updates containers every 24 hours +- **Database:** None (monitoring service) +- **Node:** hf-pdocker-01 +- **Config:** Cleanup enabled, Docker API v1.44 + +#### 4. Telegraf +- **Status:** Running (3/3 replicas - GLOBAL MODE) +- **Stack:** `/mnt/c/Users/jeremy/projects/docker/stacks/telegraf/docker-compose.yml` +- **Config:** `/data/configs/telegraf/telegraf.conf` +- **Function:** Metrics collection for InfluxDB +- **Database:** Outputs to InfluxDB (monitoring_default network) +- **Nodes:** All 3 nodes (hf-pdocker-01, hf-pdocker-02, bart) +- **Note:** Docker input disabled due to GID permission issues across nodes + +### Phase 3: Services with Databases ✅ + +#### 5. n8n (Workflow Automation) +- **Status:** Running (2/2 replicas) +- **Access:** http://192.168.86.192:5678 +- **Stack:** `/mnt/c/Users/jeremy/projects/docker/stacks/n8n/docker-compose.yml` +- **Data:** `/data/configs/n8n` (UID:GID 1000:1000) +- **Database:** PostgreSQL 15-alpine at `/data/configs/n8n-db` +- **Node:** hf-pdocker-01 +- **Network:** n8n_n8n-internal (overlay) +- **Encryption Key:** W5q3bDOhr+kAvAkRyQd+loWWSMupq1mu (preserved from existing data) +- **Note:** Successfully migrated from SQLite to PostgreSQL + +#### 6. Gitea (Git Repository Hosting) +- **Status:** Running (2/2 replicas) +- **Access:** + - Web: http://192.168.86.192:3003 + - SSH: ssh://192.168.86.192:2222 +- **Stack:** `/mnt/c/Users/jeremy/projects/docker/stacks/gitea/docker-compose.yml` +- **Data:** `/data/configs/gitea` (UID:GID 1000:1000) +- **Database:** PostgreSQL 16-alpine at `/data/configs/gitea-db` +- **Node:** hf-pdocker-01 +- **Network:** gitea_gitea-internal (overlay) +- **DB Password:** xAbt2gWIszjVPxP5bczCWhQH (recovered from existing config) +- **Note:** Port changed from 3000 to 3003 (conflict with AdGuard Home) +- **Note:** Existing repository data successfully migrated + +### Phase 4: Complex Services ✅ + +#### 7. Semaphore (Ansible Automation UI) +- **Status:** Running (2/2 replicas) +- **Access:** http://192.168.86.192:3004 +- **Stack:** `/mnt/c/Users/jeremy/projects/docker/stacks/semaphore/docker-compose.yml` +- **Data:** `/data/configs/semaphore` +- **Playbooks:** `/data/configs/semaphore/playbooks` +- **Database:** MySQL 8.0 at `/data/configs/semaphore-db` +- **Node:** hf-pdocker-01 +- **Network:** semaphore_semaphore-internal (overlay) +- **Admin Credentials:** + - Username: admin + - Email: admin@hobbs.farm + - Password: A89DfUpbvaVCDW3o43EDjbK6L0zM0Qq5 +- **Encryption Key:** E1KnNa+Ltsq+nWKmDQyK+S/j51rQixfUeXKdRrpTt8c= (preserved) +- **Note:** Port changed from 3001 to 3004 (conflict with Grafana) +- **Note:** Migrated from PostgreSQL to MySQL (old PostgreSQL data backed up) + +## Port Assignments + +| Service | Port(s) | Original Port | Changed | +|---------|---------|---------------|---------| +| Actual Budget | 5006 | 5006 | No | +| MCPO | 8001 | 8000 | Yes (Portainer conflict) | +| Watchtower | - | - | No UI | +| Telegraf | - | - | No UI | +| n8n | 5678 | 5678 | No | +| Gitea Web | 3003 | 3000 | Yes (AdGuard Home conflict) | +| Gitea SSH | 2222 | 2222 | No | +| Semaphore | 3004 | 3001 | Yes (Grafana conflict) | + +## Data Migrations + +### Successful Migrations +- **n8n:** Migrated from SQLite to PostgreSQL, encryption key preserved +- **Gitea:** All repository data migrated, upgraded to PostgreSQL 16 +- **Semaphore:** Configuration preserved, database migrated from PostgreSQL to MySQL +- **MCPO:** Configuration file created with example server + +### New Installations +- **Actual Budget:** Fresh installation +- **Watchtower:** Stateless service +- **Telegraf:** Configuration created from plan template + +## Configuration Changes + +### Port Conflicts Resolved +1. **MCPO:** 8000 → 8001 (Portainer on 8000) +2. **Gitea:** 3000 → 3003 (AdGuard Home on 3000) +3. **Semaphore:** 3001 → 3004 (Grafana on 3001) + +### Database Adjustments +1. **Gitea:** Upgraded from PostgreSQL 15 to 16 to match existing data +2. **Semaphore:** Migrated from PostgreSQL to MySQL 8.0 (old data backed up) + +### Security Keys Preserved +1. **n8n:** Encryption key preserved to maintain workflow credential access +2. **Semaphore:** Access key encryption preserved for encrypted data compatibility +3. **Gitea:** Database password recovered from existing app.ini + +## Infrastructure Details + +### Swarm Cluster +- **Manager:** hf-pdocker-01 (192.168.86.192) +- **Workers:** hf-pdocker-02 (192.168.86.200), bart (192.168.86.167) + +### Service Distribution +- Most services: hf-pdocker-01 (via placement constraints) +- Telegraf: All nodes (global mode) + +### Networks Created +- `actual-budget_default` (overlay) +- `mcpo_default` (overlay) +- `watchtower_default` (overlay) +- `telegraf` → uses `monitoring_default` (external) +- `n8n_n8n-internal` (overlay) +- `gitea_gitea-internal` (overlay) +- `semaphore_semaphore-internal` (overlay) + +### Volume Structure +All data stored in `/data/configs/` on hf-pdocker-01: +``` +/data/configs/ +├── actual-budget/ # SQLite database +├── mcpo/ # Config file +├── telegraf/ # telegraf.conf +├── n8n/ # App data (UID:GID 1000:1000) +├── n8n-db/ # PostgreSQL 15 data +├── gitea/ # Git repos + config (UID:GID 1000:1000) +├── gitea-db/ # PostgreSQL 16 data +├── semaphore/ # Config + playbooks +└── semaphore-db/ # MySQL 8.0 data +``` + +## Watchtower Protection + +The following database services are protected from auto-updates: +- `n8n_n8n-db` (PostgreSQL 15) +- `gitea_gitea-db` (PostgreSQL 16) +- `semaphore_semaphore-db` (MySQL 8.0) + +Label applied: `com.centurylinklabs.watchtower.enable=false` + +## Post-Deployment Checklist + +### Completed ✅ +- [x] All 7 services deployed successfully +- [x] Port conflicts resolved +- [x] Database migrations completed +- [x] Existing data preserved where applicable +- [x] Service health verified (HTTP 200 OK responses) +- [x] Watchtower database protection enabled +- [x] Telegraf global mode across all nodes + +### Recommended Next Steps +- [ ] Document credentials in Vaultwarden +- [ ] Update Wiki.js with new access URLs and ports +- [ ] Configure backups for all `/data/configs/` directories +- [ ] Add services to Grafana dashboards +- [ ] Test functionality of each service +- [ ] Set up InfluxDB database for Telegraf (if not already done) +- [ ] Configure n8n workflows +- [ ] Configure Semaphore Ansible projects +- [ ] Review MCPO configuration and add required MCP servers + +## Troubleshooting Notes + +### Issues Encountered and Resolved + +1. **Docker API Version (Watchtower):** + - Error: API version mismatch + - Solution: Added `DOCKER_API_VERSION=1.44` environment variable + +2. **Port Conflicts:** + - Multiple services had port conflicts with existing services + - Solution: Updated ports in docker-compose.yml files + +3. **PostgreSQL Version (Gitea):** + - Error: PostgreSQL data directory version mismatch + - Solution: Upgraded image from postgres:15-alpine to postgres:16-alpine + +4. **Encryption Keys:** + - Risk: New encryption keys would break access to existing encrypted data + - Solution: Recovered existing keys from old configurations + +5. **Telegraf Docker Input:** + - Error: Permission denied on Docker socket across nodes with different GIDs + - Solution: Disabled docker input, kept system metrics + +6. **Database Passwords (Gitea):** + - Risk: Wrong password would prevent database access + - Solution: Recovered correct password from existing app.ini + +## Deployment Timeline + +- **Phase 1 (Simple Services):** ~15 minutes +- **Phase 2 (Infrastructure):** ~30 minutes +- **Phase 3 (Database Services):** ~25 minutes +- **Phase 4 (Complex Services):** ~20 minutes + +**Total Time:** ~90 minutes (faster than estimated 3-4 hours due to automation) + +## Files Created + +``` +/mnt/c/Users/jeremy/projects/docker/stacks/ +├── actual-budget/ +│ └── docker-compose.yml +├── mcpo/ +│ └── docker-compose.yml +├── watchtower/ +│ └── docker-compose.yml +├── telegraf/ +│ ├── docker-compose.yml +│ └── telegraf.conf +├── n8n/ +│ └── docker-compose.yml +├── gitea/ +│ └── docker-compose.yml +└── semaphore/ + └── docker-compose.yml +``` + +## Access Summary + +All services are accessible from any machine on the local network at 192.168.86.192: + +| Service | URL | +|---------|-----| +| Actual Budget | http://192.168.86.192:5006 | +| MCPO | http://192.168.86.192:8001 | +| MCPO API Docs | http://192.168.86.192:8001/docs | +| n8n | http://192.168.86.192:5678 | +| Gitea Web | http://192.168.86.192:3003 | +| Gitea SSH | ssh://git@192.168.86.192:2222 | +| Semaphore | http://192.168.86.192:3004 | + +## Success Metrics + +- ✅ 7/7 services deployed successfully +- ✅ 0 services failed +- ✅ All existing data preserved +- ✅ All port conflicts resolved +- ✅ All database migrations successful +- ✅ All services responding to health checks +- ✅ Deployment completed in single session + +--- + +**Deployment completed successfully on 2026-02-10** diff --git a/compose/bart/ai-agents/docker-compose.yml b/compose/bart/ai-agents/docker-compose.yml new file mode 100644 index 0000000..4fd308e --- /dev/null +++ b/compose/bart/ai-agents/docker-compose.yml @@ -0,0 +1,96 @@ +services: + margaret: + image: margaret-openclaw:latest + container_name: margaret + restart: unless-stopped + network_mode: host + user: "0:0" + environment: + - NODE_ENV=production + - HOME=/home/glyptic + - USER=glyptic + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - OPENCLAW_STATE_DIR=/home/glyptic/.openclaw + - OPENCLAW_CONFIG_PATH=/home/glyptic/.openclaw/openclaw.json + volumes: + - /mnt/infrastructure/margaret:/home/glyptic/.openclaw + command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"] + mem_limit: 2G + memswap_limit: 2G + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + spirit: + image: margaret-openclaw:latest + container_name: spirit + restart: unless-stopped + network_mode: host + user: "0:0" + environment: + - NODE_ENV=production + - HOME=/home/glyptic + - USER=glyptic + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - OPENCLAW_STATE_DIR=/home/glyptic/.spirit + - OPENCLAW_CONFIG_PATH=/home/glyptic/.spirit/openclaw.json + volumes: + - /mnt/infrastructure/spirit:/home/glyptic/.spirit + command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"] + mem_limit: 2G + memswap_limit: 2G + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + bob: + image: margaret-openclaw:latest + container_name: bob + restart: unless-stopped + network_mode: host + user: "0:0" + environment: + - NODE_ENV=production + - HOME=/home/glyptic + - USER=glyptic + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - OPENCLAW_STATE_DIR=/home/glyptic/.bob + - OPENCLAW_CONFIG_PATH=/home/glyptic/.bob/openclaw.json + volumes: + - /mnt/infrastructure/bob:/home/glyptic/.bob + command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"] + mem_limit: 2G + memswap_limit: 2G + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + clippy: + image: margaret-openclaw:latest + container_name: clippy + restart: unless-stopped + network_mode: host + user: "0:0" + environment: + - NODE_ENV=production + - HOME=/home/glyptic + - USER=glyptic + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - OPENCLAW_STATE_DIR=/home/glyptic/.clippy + - OPENCLAW_CONFIG_PATH=/home/glyptic/.clippy/openclaw.json + volumes: + - /mnt/infrastructure/clippy:/home/glyptic/.clippy + command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"] + mem_limit: 2G + memswap_limit: 2G + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" diff --git a/compose/bart/guacamole/docker-compose.yml b/compose/bart/guacamole/docker-compose.yml new file mode 100644 index 0000000..82156f1 --- /dev/null +++ b/compose/bart/guacamole/docker-compose.yml @@ -0,0 +1,80 @@ +services: + guacamole: + image: guacamole/guacamole:latest + container_name: guacamole + restart: unless-stopped + environment: + - GUACD_HOSTNAME=guacd + - MYSQL_HOSTNAME=guacamole-db + - MYSQL_DATABASE=guacamole_db + - MYSQL_USER=guacamole_user + - MYSQL_PASSWORD=guacamole_pass + ports: + - "8199:8080" + networks: + - guacamole-internal + depends_on: + guacamole-db: + condition: service_healthy + mem_limit: 512M + memswap_limit: 512M + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8080/guacamole/ || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + guacd: + image: guacamole/guacd:latest + container_name: guacd + restart: unless-stopped + networks: + - guacamole-internal + mem_limit: 512M + memswap_limit: 512M + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + guacamole-db: + image: mysql:8.0 + container_name: guacamole-db + user: "1024:100" + restart: unless-stopped + command: --innodb-use-native-aio=0 --innodb-buffer-pool-size=128M + environment: + - MYSQL_ROOT_PASSWORD=guacamole_root + - MYSQL_DATABASE=guacamole_db + - MYSQL_USER=guacamole_user + - MYSQL_PASSWORD=guacamole_pass + volumes: + - /var/lib/guacamole-db:/var/lib/mysql + networks: + - guacamole-internal + mem_limit: 512M + memswap_limit: 512M + labels: + - "com.centurylinklabs.watchtower.enable=false" + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 --silent"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + +networks: + guacamole-internal: + driver: bridge diff --git a/compose/bart/matrix/docker-compose.yml b/compose/bart/matrix/docker-compose.yml new file mode 100644 index 0000000..045c68e --- /dev/null +++ b/compose/bart/matrix/docker-compose.yml @@ -0,0 +1,86 @@ +services: + synapse: + image: matrixdotorg/synapse:latest + container_name: synapse + restart: unless-stopped + environment: + - SYNAPSE_SERVER_NAME=mess.hobbs.farm + - SYNAPSE_REPORT_STATS=no + volumes: + - /mnt/docker-data/configs/matrix/data:/data + ports: + - "8008:8008" + networks: + - matrix-internal + depends_on: + synapse-db: + condition: service_healthy + mem_limit: 1G + memswap_limit: 1G + healthcheck: + test: ["CMD-SHELL", "curl -fSs http://localhost:8008/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + wellknown: + image: nginx:alpine + container_name: matrix-wellknown + restart: unless-stopped + volumes: + - /mnt/docker-data/configs/matrix/wellknown:/usr/share/nginx/html/.well-known/matrix:ro + ports: + - "8010:80" + mem_limit: 64M + memswap_limit: 64M + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:80/ || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + synapse-db: + image: postgres:15-alpine + container_name: synapse-db + user: "1024:100" + restart: unless-stopped + environment: + - POSTGRES_USER=synapse + - POSTGRES_PASSWORD=synapse + - POSTGRES_DB=synapse + - POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C + volumes: + - /mnt/docker-data/databases/matrix-db:/var/lib/postgresql/data + networks: + - matrix-internal + mem_limit: 256M + memswap_limit: 256M + labels: + - "com.centurylinklabs.watchtower.enable=false" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U synapse -d synapse"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + +networks: + matrix-internal: + driver: bridge diff --git a/compose/bart/media/docker-compose.yml b/compose/bart/media/docker-compose.yml new file mode 100644 index 0000000..7823ef2 --- /dev/null +++ b/compose/bart/media/docker-compose.yml @@ -0,0 +1,151 @@ +services: + jellyfin: + image: jellyfin/jellyfin:latest + container_name: jellyfin + restart: unless-stopped + environment: + - PUID=1000 + - PGID=1000 + volumes: + - /mnt/docker-data/configs/jellyfin:/config + - /mnt/nas-media:/media + ports: + - "8096:8096" + mem_limit: 2G + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8096/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + sonarr: + image: linuxserver/sonarr:latest + container_name: sonarr + restart: unless-stopped + environment: + - PUID=1000 + - PGID=1000 + - TZ=America/Chicago + volumes: + - /mnt/docker-data/configs/sonarr:/config + - /mnt/nas-media:/media + ports: + - "8989:8989" + mem_limit: 512M + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8989/ping || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + radarr: + image: linuxserver/radarr:latest + container_name: radarr + restart: unless-stopped + environment: + - PUID=1000 + - PGID=1000 + - TZ=America/Chicago + volumes: + - /mnt/docker-data/configs/radarr:/config + - /mnt/nas-media:/media + ports: + - "7878:7878" + mem_limit: 512M + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:7878/ping || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + sabnzbd: + image: linuxserver/sabnzbd:latest + container_name: sabnzbd + restart: unless-stopped + environment: + - PUID=1000 + - PGID=1000 + - TZ=America/Chicago + volumes: + - /mnt/docker-data/configs/sabnzbd:/config + - /mnt/nas-media:/media + ports: + - "8082:8080" + mem_limit: 1G + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8080/api?mode=version || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + nzbhydra2: + image: linuxserver/nzbhydra2:latest + container_name: nzbhydra2 + restart: unless-stopped + environment: + - PUID=1000 + - PGID=1000 + - TZ=America/Chicago + volumes: + - /mnt/docker-data/configs/nzbhydra:/config + ports: + - "5076:5076" + mem_limit: 512M + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:5076/ || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 90s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + jellyseerr: + image: fallenbagel/jellyseerr:latest + container_name: jellyseerr + restart: unless-stopped + environment: + - TZ=America/Chicago + volumes: + - /mnt/docker-data/configs/jellyseerr:/app/config + ports: + - "5055:5055" + mem_limit: 512M + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:5055/api/v1/status || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 90s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" diff --git a/compose/bart/openvas/.env.example b/compose/bart/openvas/.env.example new file mode 100644 index 0000000..2f50db4 --- /dev/null +++ b/compose/bart/openvas/.env.example @@ -0,0 +1,2 @@ +OPENVAS_USERNAME=admin +OPENVAS_PASSWORD=change_me diff --git a/compose/bart/openvas/docker-compose.yml b/compose/bart/openvas/docker-compose.yml new file mode 100644 index 0000000..a32385a --- /dev/null +++ b/compose/bart/openvas/docker-compose.yml @@ -0,0 +1,34 @@ +services: + openvas: + image: immauss/openvas:latest + container_name: openvas + restart: unless-stopped + environment: + - PASSWORD=${OPENVAS_PASSWORD} + - USERNAME=${OPENVAS_USERNAME} + volumes: + - openvas-db:/data/database + - openvas-var-lib:/data/var-lib + - openvas-local-share:/data/local-share + - openvas-local-etc:/data/local-etc + - openvas-var-log:/data/var-log + ports: + - "9392:9392" + mem_limit: 6G + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + +volumes: + openvas-db: + driver: local + openvas-var-lib: + driver: local + openvas-local-share: + driver: local + openvas-local-etc: + driver: local + openvas-var-log: + driver: local diff --git a/compose/bart/smarthome/docker-compose.yml b/compose/bart/smarthome/docker-compose.yml new file mode 100644 index 0000000..8070fa1 --- /dev/null +++ b/compose/bart/smarthome/docker-compose.yml @@ -0,0 +1,65 @@ +services: + homeassistant: + image: ghcr.io/home-assistant/home-assistant:stable + container_name: homeassistant + restart: unless-stopped + privileged: true + environment: + - TZ=America/Chicago + volumes: + - /mnt/docker-data/configs/homeassistant:/config + ports: + - "8123:8123" + dns: + - 192.168.86.1 + - 8.8.8.8 + mem_limit: 4G + memswap_limit: 4G + depends_on: + - mosquitto + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + esphome: + image: esphome/esphome:latest + container_name: esphome + restart: unless-stopped + environment: + - TZ=America/Chicago + volumes: + - /mnt/docker-data/configs/esphome/config:/config + ports: + - "6052:6052" + mem_limit: 2G + memswap_limit: 2G + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:6052/ || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + mosquitto: + image: eclipse-mosquitto:latest + container_name: mosquitto + restart: unless-stopped + volumes: + - /mnt/docker-data/configs/mosquitto:/mosquitto + ports: + - "1883:1883" + - "8883:8883" + mem_limit: 128M + memswap_limit: 128M + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" diff --git a/compose/bart/watchtower/docker-compose.yml b/compose/bart/watchtower/docker-compose.yml new file mode 100644 index 0000000..f531d5a --- /dev/null +++ b/compose/bart/watchtower/docker-compose.yml @@ -0,0 +1,20 @@ +services: + watchtower: + image: containrrr/watchtower:latest + container_name: watchtower + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + environment: + - WATCHTOWER_CLEANUP=true + - WATCHTOWER_INCLUDE_RESTARTING=true + - WATCHTOWER_POLL_INTERVAL=86400 + - WATCHTOWER_MONITOR_ONLY=false + - TZ=America/Chicago + - DOCKER_API_VERSION=1.44 + mem_limit: 256M + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" diff --git a/compose/hf-pdocker-01/farm-agent/docker-compose.yml b/compose/hf-pdocker-01/farm-agent/docker-compose.yml new file mode 100644 index 0000000..65025e2 --- /dev/null +++ b/compose/hf-pdocker-01/farm-agent/docker-compose.yml @@ -0,0 +1,22 @@ +services: + farm-agent: + image: farm-agent:latest + container_name: farm-agent + restart: unless-stopped + ports: + - "8889:8889" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + mem_limit: 128M + memswap_limit: 128M + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8889/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" diff --git a/compose/hf-pdocker-01/frigate-notify/.env.example b/compose/hf-pdocker-01/frigate-notify/.env.example deleted file mode 100644 index 024b4ef..0000000 --- a/compose/hf-pdocker-01/frigate-notify/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -ANTHROPIC_API_KEY=<your-anthropic-api-key> -MATRIX_ACCESS_TOKEN=<matrix-bot-access-token> -MATRIX_ROOM_ID=<matrix-room-id> diff --git a/compose/hf-pdocker-01/frigate-notify/Dockerfile b/compose/hf-pdocker-01/frigate-notify/Dockerfile new file mode 100644 index 0000000..65ddc4f --- /dev/null +++ b/compose/hf-pdocker-01/frigate-notify/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + +COPY app/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ . + +EXPOSE 5199 + +CMD ["python", "-u", "main.py"] diff --git a/compose/hf-pdocker-01/frigate-notify/app/main.py b/compose/hf-pdocker-01/frigate-notify/app/main.py new file mode 100644 index 0000000..ba296db --- /dev/null +++ b/compose/hf-pdocker-01/frigate-notify/app/main.py @@ -0,0 +1,259 @@ +import asyncio +import json +import logging +import os +import signal +import sys +import time +from collections import defaultdict +from http.server import HTTPServer, BaseHTTPRequestHandler +from threading import Thread + +import aiohttp +import anthropic +import paho.mqtt.client as mqtt + +from vision import analyze_snapshot +from matrix_client import MatrixClient + +# --- Configuration --- +MQTT_HOST = os.environ.get("MQTT_HOST", "192.168.86.192") +MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883")) +FRIGATE_URL = os.environ.get("FRIGATE_URL", "http://192.168.86.192:5000") +MATRIX_HOMESERVER = os.environ.get("MATRIX_HOMESERVER", "http://192.168.86.192:8008") +MATRIX_ACCESS_TOKEN = os.environ["MATRIX_ACCESS_TOKEN"] +MATRIX_ROOM_ID = os.environ["MATRIX_ROOM_ID"] +HEALTH_PORT = int(os.environ.get("HEALTH_PORT", "5199")) + +ALLOWED_LABELS = {"person", "car", "dog", "cat", "bird"} +MIN_SCORE = 0.7 +RATE_LIMIT_SECONDS = 60 +DEDUP_WINDOW_SECONDS = 30 + +# --- Logging --- +logging.basicConfig( + level=logging.INFO, + format='{"time":"%(asctime)s","level":"%(levelname)s","module":"%(module)s","message":"%(message)s"}', + stream=sys.stdout, +) +logger = logging.getLogger(__name__) + +# --- State --- +recent_events: dict[str, float] = {} +last_notify_per_camera: defaultdict[str, float] = defaultdict(float) +events_processed = 0 +last_event_time: str | None = None +mqtt_connected = False + + +# --- Health endpoint --- +class HealthHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_GET(self): + if self.path == "/health": + body = json.dumps( + { + "status": "ok", + "mqtt_connected": mqtt_connected, + "events_processed": events_processed, + "last_event": last_event_time, + } + ) + encoded = body.encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + pass # suppress access logs + + +def start_health_server(): + server = HTTPServer(("0.0.0.0", HEALTH_PORT), HealthHandler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + logger.info("Health server started on port %d", HEALTH_PORT) + + +# --- Snapshot fetching --- +async def fetch_snapshot(event_id: str) -> bytes | None: + url = f"{FRIGATE_URL}/api/events/{event_id}/snapshot.jpg" + try: + async with aiohttp.ClientSession() as session: + async with session.get(url) as resp: + if resp.status == 200: + data = await resp.read() + logger.info( + "Fetched snapshot for %s: %d bytes", event_id, len(data) + ) + return data + logger.warning( + "Snapshot fetch failed for %s: HTTP %d", event_id, resp.status + ) + except Exception: + logger.exception("Snapshot fetch error for %s", event_id) + return None + + +# --- Event processing --- +async def process_event( + event_data: dict, claude: anthropic.Anthropic, matrix: MatrixClient +): + global events_processed, last_event_time + + event_type = event_data.get("type") + if event_type != "end": + return + + after = event_data.get("after", {}) + event_id = after.get("id", "") + camera = after.get("camera", "unknown") + label = after.get("label", "") + score = after.get("top_score", after.get("score", 0)) + has_snapshot = after.get("has_snapshot", False) + + # --- Filtering --- + if label not in ALLOWED_LABELS: + return + if score < MIN_SCORE: + return + if not has_snapshot: + return + + # Dedup + now = time.time() + if event_id in recent_events and now - recent_events[event_id] < DEDUP_WINDOW_SECONDS: + return + recent_events[event_id] = now + + # Rate limit per camera + if now - last_notify_per_camera[camera] < RATE_LIMIT_SECONDS: + logger.info("Rate limited: %s on %s", label, camera) + return + last_notify_per_camera[camera] = now + + # Cleanup old dedup entries + cutoff = now - DEDUP_WINDOW_SECONDS * 2 + for eid in [k for k, v in recent_events.items() if v < cutoff]: + del recent_events[eid] + + logger.info( + "Processing event: %s on %s (%s, score=%.2f)", event_id, camera, label, score + ) + + # --- Fetch snapshot --- + snapshot = await fetch_snapshot(event_id) + + # --- AI analysis --- + description = None + if snapshot: + description = analyze_snapshot(claude, snapshot) + if not description: + description = f"{label.capitalize()} detected on {camera}." + + # --- Format message --- + score_pct = int(score * 100) + ts = time.strftime("%I:%M:%S %p", time.localtime()) + date_str = time.strftime("%b %d, %Y", time.localtime()) + frigate_link = f"{FRIGATE_URL}/review?id={event_id}" + + plain = ( + f"{camera} - {label.capitalize()} - {score_pct}%\n" + f"{description}\n\n" + f"View in Frigate: {frigate_link}\n" + f"{ts} - {date_str}" + ) + html = ( + f"<b>{camera}</b> · {label.capitalize()} · {score_pct}%<br/>" + f"{description}<br/><br/>" + f'<a href="{frigate_link}">View in Frigate</a><br/>' + f"<small>{ts} · {date_str}</small>" + ) + + # --- Send to Matrix --- + if snapshot: + filename = f"{camera}_{event_id}.jpg" + mxc_uri = await matrix.upload_image(snapshot, filename) + if mxc_uri: + await matrix.send_image(mxc_uri, filename, len(snapshot)) + + await matrix.send_text(plain, html) + + events_processed += 1 + last_event_time = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + logger.info("Notification sent for %s on %s", label, camera) + + +# --- MQTT --- +def create_mqtt_client( + loop: asyncio.AbstractEventLoop, + claude: anthropic.Anthropic, + matrix: MatrixClient, +): + global mqtt_connected + + def on_connect(client, userdata, flags, reason_code, properties): + global mqtt_connected + mqtt_connected = True + client.subscribe("frigate/events") + logger.info("MQTT connected, subscribed to frigate/events") + + def on_disconnect(client, userdata, flags, reason_code, properties): + global mqtt_connected + mqtt_connected = False + logger.warning("MQTT disconnected: %s", reason_code) + + def on_message(client, userdata, msg): + try: + payload = json.loads(msg.payload) + asyncio.run_coroutine_threadsafe( + process_event(payload, claude, matrix), loop + ) + except json.JSONDecodeError: + logger.warning("Invalid JSON on %s", msg.topic) + except Exception: + logger.exception("Error processing MQTT message") + + client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + client.on_connect = on_connect + client.on_disconnect = on_disconnect + client.on_message = on_message + return client + + +# --- Main --- +def main(): + logger.info("Starting frigate-notify") + + claude = anthropic.Anthropic() + matrix = MatrixClient(MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN, MATRIX_ROOM_ID) + + loop = asyncio.new_event_loop() + loop_thread = Thread(target=loop.run_forever, daemon=True) + loop_thread.start() + + start_health_server() + + mqtt_client = create_mqtt_client(loop, claude, matrix) + mqtt_client.connect(MQTT_HOST, MQTT_PORT) + + def shutdown(signum, frame): + logger.info("Shutting down...") + mqtt_client.disconnect() + loop.call_soon_threadsafe(loop.stop) + sys.exit(0) + + signal.signal(signal.SIGTERM, shutdown) + signal.signal(signal.SIGINT, shutdown) + + mqtt_client.loop_forever() + + +if __name__ == "__main__": + main() diff --git a/compose/hf-pdocker-01/frigate-notify/app/matrix_client.py b/compose/hf-pdocker-01/frigate-notify/app/matrix_client.py new file mode 100644 index 0000000..0a2578a --- /dev/null +++ b/compose/hf-pdocker-01/frigate-notify/app/matrix_client.py @@ -0,0 +1,84 @@ +import asyncio +import logging +import time +import aiohttp + +logger = logging.getLogger(__name__) + +RETRY_DELAYS = [1, 2, 4] + + +class MatrixClient: + def __init__(self, homeserver: str, token: str, room_id: str): + self.homeserver = homeserver.rstrip("/") + self.token = token + self.room_id = room_id + self.headers = {"Authorization": f"Bearer {token}"} + + async def _request(self, method: str, url: str, **kwargs) -> dict | None: + for attempt, delay in enumerate(RETRY_DELAYS): + try: + async with aiohttp.ClientSession() as session: + async with session.request( + method, url, headers=self.headers, **kwargs + ) as resp: + if resp.status < 300: + return await resp.json() + body = await resp.text() + logger.warning( + "Matrix API %s (attempt %d): %s", + resp.status, + attempt + 1, + body, + ) + except Exception: + logger.exception("Matrix request failed (attempt %d)", attempt + 1) + if attempt < len(RETRY_DELAYS) - 1: + await asyncio.sleep(delay) + logger.error("All Matrix retries exhausted for %s", url) + return None + + async def upload_image(self, image_bytes: bytes, filename: str) -> str | None: + url = f"{self.homeserver}/_matrix/media/v3/upload?filename={filename}" + headers = {**self.headers, "Content-Type": "image/jpeg"} + try: + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, data=image_bytes) as resp: + if resp.status < 300: + data = await resp.json() + mxc_uri = data.get("content_uri") + logger.info("Uploaded image: %s", mxc_uri) + return mxc_uri + body = await resp.text() + logger.error("Image upload failed %s: %s", resp.status, body) + except Exception: + logger.exception("Image upload failed") + return None + + async def send_image(self, mxc_uri: str, filename: str, size: int) -> dict | None: + txn_id = str(int(time.time() * 1000)) + url = ( + f"{self.homeserver}/_matrix/client/r0/rooms/" + f"{self.room_id}/send/m.room.message/{txn_id}" + ) + body = { + "msgtype": "m.image", + "body": filename, + "url": mxc_uri, + "info": {"mimetype": "image/jpeg", "size": size}, + } + return await self._request("PUT", url, json=body) + + async def send_text(self, plain: str, html: str) -> dict | None: + txn_id = str(int(time.time() * 1000) + 1) + url = ( + f"{self.homeserver}/_matrix/client/r0/rooms/" + f"{self.room_id}/send/m.room.message/{txn_id}" + ) + body = { + "msgtype": "m.text", + "body": plain, + "format": "org.matrix.custom.html", + "formatted_body": html, + } + return await self._request("PUT", url, json=body) diff --git a/compose/hf-pdocker-01/frigate-notify/app/requirements.txt b/compose/hf-pdocker-01/frigate-notify/app/requirements.txt new file mode 100644 index 0000000..8991258 --- /dev/null +++ b/compose/hf-pdocker-01/frigate-notify/app/requirements.txt @@ -0,0 +1,3 @@ +paho-mqtt==2.1.0 +anthropic>=0.40.0 +aiohttp>=3.9.0 diff --git a/compose/hf-pdocker-01/frigate-notify/app/vision.py b/compose/hf-pdocker-01/frigate-notify/app/vision.py new file mode 100644 index 0000000..9540dc2 --- /dev/null +++ b/compose/hf-pdocker-01/frigate-notify/app/vision.py @@ -0,0 +1,42 @@ +import base64 +import logging +import anthropic + +logger = logging.getLogger(__name__) + +PROMPT = ( + "Briefly describe what's happening in this security camera image in 1-2 sentences. " + "Focus on the people, vehicles, or animals and their activity. Be concise." +) + + +def analyze_snapshot(client: anthropic.Anthropic, image_bytes: bytes) -> str | None: + """Send a snapshot to Claude Haiku for description. Returns None on failure.""" + try: + b64 = base64.standard_b64encode(image_bytes).decode("utf-8") + response = client.messages.create( + model="claude-haiku-4-5-20251001", + max_tokens=150, + messages=[ + { + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/jpeg", + "data": b64, + }, + }, + {"type": "text", "text": PROMPT}, + ], + } + ], + ) + description = response.content[0].text.strip() + logger.info("Vision analysis complete: %d chars", len(description)) + return description + except Exception: + logger.exception("Claude Vision API failed") + return None diff --git a/compose/hf-pdocker-01/frigate/config.yml b/compose/hf-pdocker-01/frigate/config.yml new file mode 100644 index 0000000..f72b415 --- /dev/null +++ b/compose/hf-pdocker-01/frigate/config.yml @@ -0,0 +1,488 @@ +# ============================================================================= +# Frigate NVR Configuration - Optimized (v4) +# ============================================================================= +# +# 17 cameras total: +# +# Reolink (h264Preview paths, H.264): +# back_porch 192.168.44.63 kerberos:kerberos +# house_south_corner 192.168.44.61 admin:(blank) +# house_south_yard 192.168.44.62 admin:(blank) +# gate_camera 192.168.86.175 admin:(blank) +# +# Profile cameras (/profile1 + /profile2, H.264): +# house_north 192.168.44.51 admin:123456 +# house_south 192.168.44.53 admin:123456 +# house_west 192.168.44.52 admin:123456 +# shop_west 192.168.44.56 admin:123456 +# shop_driveway 192.168.44.57 admin:123456 +# shop_east 192.168.44.55 admin:123456 +# +# Stream camera (/stream1 + /stream2, HEVC): +# house_east 192.168.44.58 admin:admin +# +# Dahua-style (/cam/realmonitor, codec TBD): +# garage_2 192.168.44.64 admin:7rip8a115 +# +# Amcrest w/ authbasic=64 (/cam/realmonitor): +# driveway_1 192.168.86.150 admin:7rip8a115 +# driveway_2 192.168.44.68 admin:7rip8a115 +# front_door_view 192.168.44.59 admin:7rip8a115 +# +# Amcrest ASH43-W (HEVC, /cam/realmonitor + authbasic=64): +# pool_room 192.168.86.142 admin:bigdog01 +# front_house 192.168.44.41 admin:bigdog01 +# +# ============================================================================= + +version: 0.17-0 + +auth: + enabled: true + +mqtt: + enabled: true + host: 192.168.86.192 + port: 1883 + +ui: + timezone: America/Chicago + +database: + path: /config/frigate.db + +# ============================================================================= +# go2rtc - Single connection per camera, restreamed locally +# ============================================================================= +go2rtc: + webrtc: + candidates: + - 192.168.86.192:8555 + - stun:8555 + streams: + + # --- Reolink cameras (h264Preview paths) --------------------------------- + + back_porch: + - rtsp://kerberos:kerberos@192.168.44.63:554/h264Preview_01_main + back_porch_sub: + - rtsp://kerberos:kerberos@192.168.44.63:554/h264Preview_01_sub + + house_south_corner: + - rtsp://admin:@192.168.44.61:554/h264Preview_01_main + house_south_corner_sub: + - rtsp://admin:@192.168.44.61:554/h264Preview_01_sub + + house_south_yard: + - rtsp://admin:@192.168.44.62:554/h264Preview_01_main + house_south_yard_sub: + - rtsp://admin:@192.168.44.62:554/h264Preview_01_sub + + gate_camera: + - rtsp://admin:@192.168.86.175:554/h264Preview_01_main + gate_camera_sub: + - rtsp://admin:@192.168.86.175:554/h264Preview_01_sub + + # --- Profile cameras (/profile1 + /profile2) ---------------------------- + + house_north: + - rtsp://admin:123456@192.168.44.51:554/profile1 + house_north_sub: + - rtsp://admin:123456@192.168.44.51:554/profile2 + + house_south: + - rtsp://admin:123456@192.168.44.53:554/profile1 + house_south_sub: + - rtsp://admin:123456@192.168.44.53:554/profile2 + + house_west: + - rtsp://admin:123456@192.168.44.52:554/ + house_west_sub: + - rtsp://admin:123456@192.168.44.52:554/profile2 + + shop_west: + - rtsp://admin:123456@192.168.44.56:554/profile1 + shop_west_sub: + - rtsp://admin:123456@192.168.44.56:554/profile2 + + shop_driveway: + - rtsp://admin:123456@192.168.44.57:554/profile1 + shop_driveway_sub: + - rtsp://admin:123456@192.168.44.57:554/profile2 + + shop_east: + - rtsp://admin:123456@192.168.44.55:554/profile1 + shop_east_sub: + - rtsp://admin:123456@192.168.44.55:554/profile2 + + # --- Stream camera (HEVC) ------------------------------------------------ + + house_east: + - rtsp://admin:admin@192.168.44.58:554/stream1#video=h264 + house_east_sub: + - rtsp://admin:admin@192.168.44.58:554/stream2 + + # --- garage_2 - Dahua-style, NOT Amcrest --------------------------------- + + garage_2: + - rtsp://admin:7rip8a115@192.168.44.64:554/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif + garage_2_sub: + - rtsp://admin:7rip8a115@192.168.44.64:554/cam/realmonitor?channel=1&subtype=1&unicast=true&proto=Onvif + + # --- Amcrest cameras w/ authbasic=64 ------------------------------------- + + driveway_1: + - rtsp://admin:7rip8a115@192.168.86.150:554/cam/realmonitor?channel=1&subtype=0&authbasic=64 + driveway_1_sub: + - rtsp://admin:7rip8a115@192.168.86.150:554/cam/realmonitor?channel=1&subtype=1&authbasic=64 + + driveway_2: + - rtsp://admin:7rip8a115@192.168.44.68:554/cam/realmonitor?channel=1&subtype=0&authbasic=64 + driveway_2_sub: + - rtsp://admin:7rip8a115@192.168.44.68:554/cam/realmonitor?channel=1&subtype=1&authbasic=64 + + front_door_view: + - rtsp://admin:7rip8a115@192.168.44.59:554/cam/realmonitor?channel=1&subtype=0&authbasic=64 + front_door_view_sub: + - rtsp://admin:7rip8a115@192.168.44.59:554/cam/realmonitor?channel=1&subtype=1&authbasic=64 + + # --- ASH43-W cameras (HEVC, authbasic=64) -------------------------------- + + pool_room: + - rtsp://admin:bigdog01@192.168.86.142:554/cam/realmonitor?channel=1&subtype=0&authbasic=64#video=h264 + pool_room_sub: + - rtsp://admin:bigdog01@192.168.86.142:554/cam/realmonitor?channel=1&subtype=1&authbasic=64 + + front_house: + - rtsp://admin:bigdog01@192.168.44.41:554/cam/realmonitor?channel=1&subtype=0&authbasic=64#video=h264 + front_house_sub: + - rtsp://admin:bigdog01@192.168.44.41:554/cam/realmonitor?channel=1&subtype=1&authbasic=64 + +# ============================================================================= +# Global ffmpeg - VAAPI hardware acceleration +# ============================================================================= +ffmpeg: + hwaccel_args: preset-vaapi + +# ============================================================================= +model: + path: /openvino-model/ssdlite_mobilenet_v2.xml + width: 300 + height: 300 + input_tensor: nhwc + input_pixel_format: bgr + labelmap_path: /openvino-model/coco_91cl_bkgr.txt + +detectors: + ov: + type: openvino + device: GPU +detect: + enabled: true + fps: 3 + width: 640 + height: 480 + +# ============================================================================= +# Recording +# ============================================================================= +record: + enabled: true + continuous: + days: 2 + alerts: + retain: + days: 7 + detections: + retain: + days: 7 + +# ============================================================================= +# Snapshots +# ============================================================================= +snapshots: + enabled: true + clean_copy: true + retain: + default: 7 + mode: motion + +# ============================================================================= +# Object tracking +# ============================================================================= +objects: + track: + - person + - car + filters: + person: + min_area: 5000 + threshold: 0.7 + car: + min_area: 10000 + threshold: 0.7 + +# ============================================================================= +# Birdseye - overview of all cameras +# ============================================================================= +birdseye: + enabled: true + mode: objects + +# ============================================================================= +# Camera definitions +# ============================================================================= + +cameras: + + # --------------------------------------------------------------------------- + # Reolink cameras (H.264, VAAPI OK) + # --------------------------------------------------------------------------- + + back_porch: + enabled: true + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/back_porch_sub + input_args: preset-rtsp-restream + roles: + - detect + - path: rtsp://127.0.0.1:8554/back_porch + input_args: preset-rtsp-restream + roles: + - record + + house_south_corner: + enabled: true + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/house_south_corner_sub + input_args: preset-rtsp-restream + roles: + - detect + - path: rtsp://127.0.0.1:8554/house_south_corner + input_args: preset-rtsp-restream + roles: + - record + + house_south_yard: + enabled: true + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/house_south_yard_sub + input_args: preset-rtsp-restream + roles: + - detect + - path: rtsp://127.0.0.1:8554/house_south_yard + input_args: preset-rtsp-restream + roles: + - record + + gate_camera: + enabled: true + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/gate_camera_sub + input_args: preset-rtsp-restream + roles: + - detect + - path: rtsp://127.0.0.1:8554/gate_camera + input_args: preset-rtsp-restream + roles: + - record + + # --------------------------------------------------------------------------- + # Profile cameras (H.264, VAAPI OK) + # --------------------------------------------------------------------------- + + house_north: + enabled: true + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/house_north_sub + input_args: preset-rtsp-restream + roles: + - detect + - path: rtsp://127.0.0.1:8554/house_north + input_args: preset-rtsp-restream + roles: + - record + + house_south: + enabled: true + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/house_south_sub + input_args: preset-rtsp-restream + roles: + - detect + - path: rtsp://127.0.0.1:8554/house_south + input_args: preset-rtsp-restream + roles: + - record + + house_west: + enabled: true + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/house_west_sub + input_args: preset-rtsp-restream + roles: + - detect + - path: rtsp://127.0.0.1:8554/house_west + input_args: preset-rtsp-restream + roles: + - record + + shop_west: + enabled: true + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/shop_west_sub + input_args: preset-rtsp-restream + roles: + - detect + - path: rtsp://127.0.0.1:8554/shop_west + input_args: preset-rtsp-restream + roles: + - record + + shop_driveway: + enabled: true + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/shop_driveway_sub + input_args: preset-rtsp-restream + roles: + - detect + - path: rtsp://127.0.0.1:8554/shop_driveway + input_args: preset-rtsp-restream + roles: + - record + + shop_east: + enabled: false + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/shop_east_sub + input_args: preset-rtsp-restream + roles: + - detect + - path: rtsp://127.0.0.1:8554/shop_east + input_args: preset-rtsp-restream + roles: + - record + + # --------------------------------------------------------------------------- + # house_east - HEVC, VAAPI disabled (pw: admin) + # --------------------------------------------------------------------------- + + house_east: + enabled: true + ffmpeg: + hwaccel_args: [] + inputs: + - path: rtsp://127.0.0.1:8554/house_east_sub + input_args: preset-rtsp-restream + roles: + - detect + - path: rtsp://127.0.0.1:8554/house_east + input_args: preset-rtsp-restream + roles: + - record + + # --------------------------------------------------------------------------- + # garage_2 - Dahua-style, codec TBD, VAAPI disabled as precaution + # If H.264 confirmed, remove hwaccel_args override + # --------------------------------------------------------------------------- + + garage_2: + enabled: true + ffmpeg: + hwaccel_args: [] + inputs: + - path: rtsp://127.0.0.1:8554/garage_2_sub + input_args: preset-rtsp-restream + roles: + - detect + - path: rtsp://127.0.0.1:8554/garage_2 + input_args: preset-rtsp-restream + roles: + - record + + # --------------------------------------------------------------------------- + # Amcrest cameras w/ authbasic=64 + # Codec unknown - VAAPI disabled as precaution until confirmed H.264 + # --------------------------------------------------------------------------- + + driveway_1: + enabled: false + ffmpeg: + hwaccel_args: [] + inputs: + - path: rtsp://127.0.0.1:8554/driveway_1_sub + input_args: preset-rtsp-restream + roles: + - detect + - path: rtsp://127.0.0.1:8554/driveway_1 + input_args: preset-rtsp-restream + roles: + - record + + driveway_2: + enabled: false + ffmpeg: + hwaccel_args: [] + inputs: + - path: rtsp://127.0.0.1:8554/driveway_2_sub + input_args: preset-rtsp-restream + roles: + - detect + - path: rtsp://127.0.0.1:8554/driveway_2 + input_args: preset-rtsp-restream + roles: + - record + + front_door_view: + enabled: false + ffmpeg: + hwaccel_args: [] + inputs: + - path: rtsp://127.0.0.1:8554/front_door_view_sub + input_args: preset-rtsp-restream + roles: + - detect + - path: rtsp://127.0.0.1:8554/front_door_view + input_args: preset-rtsp-restream + roles: + - record + + # --------------------------------------------------------------------------- + # ASH43-W cameras (HEVC main, H.264 sub - go2rtc transcodes main to H.264) + # --------------------------------------------------------------------------- + + pool_room: + enabled: true + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/pool_room_sub + input_args: preset-rtsp-restream + roles: + - detect + - path: rtsp://127.0.0.1:8554/pool_room + input_args: preset-rtsp-restream + roles: + - record + + front_house: + enabled: true + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/front_house_sub + input_args: preset-rtsp-restream + roles: + - detect + - path: rtsp://127.0.0.1:8554/front_house + input_args: preset-rtsp-restream + roles: + - record diff --git a/compose/hf-pdocker-01/frigate/docker-compose.yml b/compose/hf-pdocker-01/frigate/docker-compose.yml new file mode 100644 index 0000000..5707d01 --- /dev/null +++ b/compose/hf-pdocker-01/frigate/docker-compose.yml @@ -0,0 +1,30 @@ +services: + frigate: + image: ghcr.io/blakeblackshear/frigate:stable + container_name: frigate + restart: unless-stopped + privileged: true + shm_size: "512mb" + network_mode: host + environment: + - TZ=America/Chicago + - FRIGATE_RTSP_PASSWORD=password + volumes: + - /mnt/docker-data/configs/frigate/config:/config + - /mnt/frigate-recordings:/media/frigate + - /etc/localtime:/etc/localtime:ro + devices: + - /dev/dri:/dev/dri + mem_limit: 12G + memswap_limit: 12G + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:5000/api/version || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" diff --git a/compose/hf-pdocker-02/actual-budget/docker-compose.yml b/compose/hf-pdocker-02/actual-budget/docker-compose.yml new file mode 100644 index 0000000..35505e3 --- /dev/null +++ b/compose/hf-pdocker-02/actual-budget/docker-compose.yml @@ -0,0 +1,22 @@ +services: + actual-budget: + image: actualbudget/actual-server:latest + container_name: actual-budget + restart: unless-stopped + ports: + - "5006:5006" + volumes: + - /mnt/docker-data/configs/actual-budget:/data + mem_limit: 256M + memswap_limit: 256M + healthcheck: + test: ["CMD-SHELL", "node -e \"fetch('http://localhost:5006/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" diff --git a/compose/hf-pdocker-02/adguard/docker-compose.yml b/compose/hf-pdocker-02/adguard/docker-compose.yml new file mode 100644 index 0000000..fd7b2d3 --- /dev/null +++ b/compose/hf-pdocker-02/adguard/docker-compose.yml @@ -0,0 +1,26 @@ +services: + adguardhome: + image: adguard/adguardhome:latest + container_name: adguardhome + restart: unless-stopped + volumes: + - /mnt/docker-data/configs/adguardhome/work:/opt/adguardhome/work + - /mnt/docker-data/configs/adguardhome/conf:/opt/adguardhome/conf + ports: + - "53:53/tcp" + - "53:53/udp" + - "3000:3000/tcp" + - "853:853/tcp" + mem_limit: 256M + memswap_limit: 256M + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/ || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" diff --git a/compose/hf-pdocker-02/caddy-vw/docker-compose.yml b/compose/hf-pdocker-02/caddy-vw/docker-compose.yml new file mode 100644 index 0000000..b1c5854 --- /dev/null +++ b/compose/hf-pdocker-02/caddy-vw/docker-compose.yml @@ -0,0 +1,20 @@ +services: + caddy-vw: + image: caddy:alpine + container_name: caddy-vw + restart: unless-stopped + network_mode: host + volumes: + - /mnt/docker-data/configs/caddy-vw/Caddyfile:/etc/caddy/Caddyfile:ro + - /mnt/docker-data/configs/caddy-vw/data:/data + - /mnt/docker-data/configs/caddy-vw/config:/config + command: caddy run --config /etc/caddy/Caddyfile --adapter caddyfile + mem_limit: 256M + memswap_limit: 256M + labels: + - "com.centurylinklabs.watchtower.enable=false" + logging: + driver: "json-file" + options: + max-size: "20m" + max-file: "3" diff --git a/compose/hf-pdocker-02/gitea/.env.example b/compose/hf-pdocker-02/gitea/.env.example new file mode 100644 index 0000000..be286c4 --- /dev/null +++ b/compose/hf-pdocker-02/gitea/.env.example @@ -0,0 +1 @@ +GITEA_DB_PASSWORD=change_me diff --git a/compose/hf-pdocker-02/gitea/docker-compose.yml b/compose/hf-pdocker-02/gitea/docker-compose.yml new file mode 100644 index 0000000..bceb0e7 --- /dev/null +++ b/compose/hf-pdocker-02/gitea/docker-compose.yml @@ -0,0 +1,72 @@ +services: + gitea: + image: gitea/gitea:latest + container_name: gitea + restart: unless-stopped + ports: + - "3003:3000" + - "2222:22" + environment: + - USER_UID=1000 + - USER_GID=1000 + - GITEA__database__DB_TYPE=postgres + - GITEA__database__HOST=gitea-db:5432 + - GITEA__database__NAME=gitea + - GITEA__database__USER=gitea + - GITEA__database__PASSWD=${GITEA_DB_PASSWORD} + - GITEA__server__SSH_PORT=2222 + volumes: + - /mnt/docker-data/configs/gitea:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + depends_on: + gitea-db: + condition: service_healthy + networks: + - gitea-internal + mem_limit: 512M + memswap_limit: 512M + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:3000/api/healthz || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + gitea-db: + image: postgres:16-alpine + container_name: gitea-db + restart: unless-stopped + user: "1024:100" + environment: + - POSTGRES_DB=gitea + - POSTGRES_USER=gitea + - POSTGRES_PASSWORD=${GITEA_DB_PASSWORD} + volumes: + - /mnt/docker-data/databases/gitea-db:/var/lib/postgresql/data + networks: + - gitea-internal + labels: + - "com.centurylinklabs.watchtower.enable=false" + mem_limit: 256M + memswap_limit: 256M + healthcheck: + test: ["CMD-SHELL", "pg_isready -U gitea -d gitea"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + +networks: + gitea-internal: + driver: bridge diff --git a/compose/hf-pdocker-02/mcpo/docker-compose.yml b/compose/hf-pdocker-02/mcpo/docker-compose.yml new file mode 100644 index 0000000..8add96e --- /dev/null +++ b/compose/hf-pdocker-02/mcpo/docker-compose.yml @@ -0,0 +1,23 @@ +services: + mcpo: + image: ghcr.io/open-webui/mcpo:main + container_name: mcpo + restart: unless-stopped + ports: + - "8001:8000" + volumes: + - /mnt/docker-data/configs/mcpo:/app/config + command: ["--config", "/app/config/config.json"] + mem_limit: 512M + memswap_limit: 512M + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8000/openapi.json || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" diff --git a/compose/hf-pdocker-02/monitoring/docker-compose.yml b/compose/hf-pdocker-02/monitoring/docker-compose.yml new file mode 100644 index 0000000..b8eeba7 --- /dev/null +++ b/compose/hf-pdocker-02/monitoring/docker-compose.yml @@ -0,0 +1,158 @@ +networks: + monitoring-net: + name: monitoring-net + +services: + grafana: + image: grafana/grafana:latest + container_name: grafana + restart: unless-stopped + user: "104" + networks: + - monitoring-net + ports: + - "3001:3000" + volumes: + - /mnt/docker-data/configs/grafana:/var/lib/grafana + mem_limit: 512M + memswap_limit: 512M + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/api/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + prometheus: + image: prom/prometheus:latest + container_name: prometheus + restart: unless-stopped + dns_search: "" + extra_hosts: + - "blackbox-exporter:172.24.0.4" + - "alertmanager:172.24.0.5" + - "influxdb:172.24.0.7" + networks: + - monitoring-net + ports: + - "9095:9090" + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/usr/share/prometheus/console_libraries' + - '--web.console.templates=/usr/share/prometheus/consoles' + - '--web.enable-lifecycle' + volumes: + - /mnt/docker-data/configs/prometheus:/etc/prometheus + mem_limit: 512M + memswap_limit: 512M + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:9090/-/healthy || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + alertmanager: + image: prom/alertmanager:latest + container_name: alertmanager + restart: unless-stopped + networks: + - monitoring-net + ports: + - "9093:9093" + command: + - '--config.file=/etc/alertmanager/alertmanager.yml' + - '--storage.path=/alertmanager' + volumes: + - /mnt/docker-data/configs/alertmanager:/etc/alertmanager + - alertmanager-data:/alertmanager + mem_limit: 256M + memswap_limit: 256M + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:9093/-/healthy || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + influxdb: + image: influxdb:1.8-alpine + container_name: influxdb + restart: unless-stopped + networks: + - monitoring-net + ports: + - "8086:8086" + volumes: + - /mnt/docker-data/configs/influxdb:/var/lib/influxdb + mem_limit: 1G + memswap_limit: 1G + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:8086/ping || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + matrix-alertmanager-receiver: + image: metio/matrix-alertmanager-receiver:2026.2.25 + container_name: matrix-alertmanager-receiver + restart: unless-stopped + networks: + - monitoring-net + ports: + - "9094:9094" + volumes: + - /mnt/docker-data/configs/alertmanager/matrix-receiver-config.yml:/config/config.yml:ro + command: ["--config-path", "/config/config.yml"] + mem_limit: 128M + memswap_limit: 128M + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + blackbox-exporter: + image: prom/blackbox-exporter:latest + container_name: blackbox-exporter + restart: unless-stopped + networks: + - monitoring-net + ports: + - "9115:9115" + command: + - '--config.file=/config/blackbox.yml' + volumes: + - /mnt/docker-data/configs/blackbox:/config:ro + mem_limit: 128M + memswap_limit: 128M + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + +volumes: + alertmanager-data: diff --git a/compose/hf-pdocker-02/n8n/.env.example b/compose/hf-pdocker-02/n8n/.env.example new file mode 100644 index 0000000..f3b734a --- /dev/null +++ b/compose/hf-pdocker-02/n8n/.env.example @@ -0,0 +1,2 @@ +N8N_DB_PASSWORD=change_me +N8N_ENCRYPTION_KEY=change_me_32_char_random_key_here diff --git a/compose/hf-pdocker-02/n8n/docker-compose.yml b/compose/hf-pdocker-02/n8n/docker-compose.yml new file mode 100644 index 0000000..386a655 --- /dev/null +++ b/compose/hf-pdocker-02/n8n/docker-compose.yml @@ -0,0 +1,70 @@ +services: + n8n: + image: n8nio/n8n:latest + container_name: n8n + restart: unless-stopped + ports: + - "5678:5678" + environment: + - DB_TYPE=postgresdb + - DB_POSTGRESDB_HOST=n8n-db + - DB_POSTGRESDB_PORT=5432 + - DB_POSTGRESDB_DATABASE=n8n + - DB_POSTGRESDB_USER=n8n + - DB_POSTGRESDB_PASSWORD=${N8N_DB_PASSWORD} + - N8N_PORT=5678 + - GENERIC_TIMEZONE=America/Chicago + - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY} + volumes: + - /mnt/docker-data/configs/n8n:/home/node/.n8n + depends_on: + n8n-db: + condition: service_healthy + networks: + - n8n-internal + mem_limit: 512M + memswap_limit: 512M + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:5678/healthz || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 120s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + n8n-db: + image: postgres:15-alpine + container_name: n8n-db + restart: unless-stopped + user: "1024:100" + environment: + - POSTGRES_DB=n8n + - POSTGRES_USER=n8n + - POSTGRES_PASSWORD=${N8N_DB_PASSWORD} + volumes: + - /mnt/docker-data/databases/n8n-db:/var/lib/postgresql/data + networks: + - n8n-internal + labels: + - "com.centurylinklabs.watchtower.enable=false" + mem_limit: 256M + memswap_limit: 256M + healthcheck: + test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + +networks: + n8n-internal: + driver: bridge diff --git a/compose/hf-pdocker-02/netclaw/.env.example b/compose/hf-pdocker-02/netclaw/.env.example new file mode 100644 index 0000000..e4cd0c2 --- /dev/null +++ b/compose/hf-pdocker-02/netclaw/.env.example @@ -0,0 +1,2 @@ +ANTHROPIC_API_KEY=sk-ant-... +MATRIX_ACCESS_TOKEN=syt_... diff --git a/compose/hf-pdocker-02/netclaw/Dockerfile b/compose/hf-pdocker-02/netclaw/Dockerfile new file mode 100644 index 0000000..a88373d --- /dev/null +++ b/compose/hf-pdocker-02/netclaw/Dockerfile @@ -0,0 +1,38 @@ +FROM node:20-bookworm + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + python3 python3-pip python3-venv python3-full \ + git curl jq openssh-client iputils-ping net-tools \ + && rm -rf /var/lib/apt/lists/* + +# Install uv (Python package manager used by some MCP servers) +RUN curl -LsSf https://astral.sh/uv/install.sh | sh +ENV PATH="/root/.local/bin:$PATH" + +# Install OpenClaw globally +RUN npm install -g openclaw@latest + +# Clone NetClaw +WORKDIR /opt/netclaw +RUN git clone https://github.com/automateyournetwork/netclaw.git . + +# Create openclaw home directory +RUN mkdir -p /root/.openclaw/workspace + +# Copy NetClaw workspace config into openclaw home +RUN cp -r workspace/skills /root/.openclaw/workspace/ 2>/dev/null || true +RUN cp config/openclaw.json /root/.openclaw/openclaw.json 2>/dev/null || true + +# Install Python dependencies for core MCP servers +RUN python3 -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +RUN pip install --no-cache-dir pyats genie netmiko paramiko requests pyyaml jinja2 2>/dev/null || true + +# Create entrypoint script +COPY entrypoint.sh /opt/entrypoint.sh +RUN chmod +x /opt/entrypoint.sh + +EXPOSE 1179 + +ENTRYPOINT ["/opt/entrypoint.sh"] diff --git a/compose/hf-pdocker-02/netclaw/docker-compose.yml b/compose/hf-pdocker-02/netclaw/docker-compose.yml new file mode 100644 index 0000000..0bf1114 --- /dev/null +++ b/compose/hf-pdocker-02/netclaw/docker-compose.yml @@ -0,0 +1,20 @@ +services: + netclaw: + build: + context: . + dockerfile: Dockerfile + container_name: netclaw-aaron + restart: unless-stopped + environment: + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - MATRIX_ACCESS_TOKEN=${MATRIX_ACCESS_TOKEN} + - MATRIX_HOMESERVER=http://192.168.86.192:8008 + volumes: + - /mnt/docker-data/configs/netclaw:/root/.openclaw/data + mem_limit: 2G + memswap_limit: 2G + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" diff --git a/compose/hf-pdocker-02/netclaw/entrypoint.sh b/compose/hf-pdocker-02/netclaw/entrypoint.sh new file mode 100644 index 0000000..7e2c67f --- /dev/null +++ b/compose/hf-pdocker-02/netclaw/entrypoint.sh @@ -0,0 +1,72 @@ +#!/bin/bash +set -e + +# Configure OpenClaw environment +mkdir -p /root/.openclaw + +# Write .env with API key +cat > /root/.openclaw/.env << EOF +ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} +EOF + +# Write openclaw config for gateway mode +cat > /root/.openclaw/openclaw.json << 'OCEOF' +{ + "mode": "gateway", + "provider": "anthropic", + "model": "claude-sonnet-4-6", + "fallbackModel": "claude-haiku-4-5-20251001", + "name": "Aaron", + "identity": "Aaron - NetClaw AI Network Engineering Agent" +} +OCEOF + +# Copy workspace skills if they exist +if [ -d /opt/netclaw/workspace/skills ]; then + cp -r /opt/netclaw/workspace/skills /root/.openclaw/workspace/ 2>/dev/null || true +fi + +# Copy personality/identity docs +for doc in SOUL.md AGENTS.md IDENTITY.md USER.md TOOLS.md; do + if [ -f /opt/netclaw/$doc ]; then + cp /opt/netclaw/$doc /root/.openclaw/ 2>/dev/null || true + fi +done + +# Send Matrix DM to Jeremy to announce availability +if [ -n "$MATRIX_ACCESS_TOKEN" ] && [ -n "$MATRIX_HOMESERVER" ]; then + echo "Sending Matrix DM to Jeremy..." + + # Create a direct message room with Jeremy + ROOM_RESPONSE=$(curl -sf -X POST "${MATRIX_HOMESERVER}/_matrix/client/r0/createRoom" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${MATRIX_ACCESS_TOKEN}" \ + -d "{ + \"is_direct\": true, + \"invite\": [\"@jeremy:hobbs.farm\"], + \"preset\": \"trusted_private_chat\" + }" 2>/dev/null || echo "") + + ROOM_ID=$(echo "$ROOM_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('room_id',''))" 2>/dev/null || echo "") + + if [ -n "$ROOM_ID" ]; then + # Send the message + curl -sf -X PUT "${MATRIX_HOMESERVER}/_matrix/client/r0/rooms/${ROOM_ID}/send/m.room.message/$(date +%s%N)" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${MATRIX_ACCESS_TOKEN}" \ + -d "{ + \"msgtype\": \"m.text\", + \"body\": \"Hey Jeremy - Aaron here. I'm online and ready to help with network engineering tasks. I'm running NetClaw with CCIE-level networking skills. Ask me about device health checks, routing analysis, security audits, or anything network-related.\" + }" 2>/dev/null || echo "Failed to send DM" + echo "DM sent to @jeremy:hobbs.farm in room $ROOM_ID" + else + echo "Warning: Could not create DM room with Jeremy. Response: $ROOM_RESPONSE" + fi +fi + +# Start OpenClaw gateway +echo "Starting NetClaw (Aaron) gateway..." +exec openclaw gateway 2>&1 || { + echo "OpenClaw gateway failed to start, keeping container alive for debugging..." + tail -f /dev/null +} diff --git a/compose/hf-pdocker-02/semaphore/.env.example b/compose/hf-pdocker-02/semaphore/.env.example new file mode 100644 index 0000000..0c2a27c --- /dev/null +++ b/compose/hf-pdocker-02/semaphore/.env.example @@ -0,0 +1,4 @@ +SEMAPHORE_DB_PASSWORD=change_me +SEMAPHORE_ADMIN_PASSWORD=change_me +SEMAPHORE_ENCRYPTION_KEY=change_me_base64_encoded_32_byte_key +SEMAPHORE_MYSQL_ROOT_PASSWORD=change_me diff --git a/compose/hf-pdocker-02/semaphore/docker-compose.yml b/compose/hf-pdocker-02/semaphore/docker-compose.yml new file mode 100644 index 0000000..fb7c8b8 --- /dev/null +++ b/compose/hf-pdocker-02/semaphore/docker-compose.yml @@ -0,0 +1,78 @@ +services: + semaphore: + image: semaphoreui/semaphore:latest + container_name: semaphore + restart: unless-stopped + ports: + - "3004:3000" + environment: + - SEMAPHORE_DB_DIALECT=mysql + - SEMAPHORE_DB_HOST=semaphore-db + - SEMAPHORE_DB_PORT=3306 + - SEMAPHORE_DB_USER=semaphore + - SEMAPHORE_DB_PASS=${SEMAPHORE_DB_PASSWORD} + - SEMAPHORE_DB_NAME=semaphore + - SEMAPHORE_DB=semaphore + - SEMAPHORE_PLAYBOOK_PATH=/tmp/semaphore + - SEMAPHORE_ADMIN_PASSWORD=${SEMAPHORE_ADMIN_PASSWORD} + - SEMAPHORE_ADMIN_NAME=admin + - SEMAPHORE_ADMIN_EMAIL=admin@hobbs.farm + - SEMAPHORE_ADMIN=admin + - SEMAPHORE_ACCESS_KEY_ENCRYPTION=${SEMAPHORE_ENCRYPTION_KEY} + - SEMAPHORE_LDAP_ACTIVATED=no + volumes: + - /mnt/docker-data/configs/semaphore:/etc/semaphore + - /mnt/docker-data/configs/semaphore/playbooks:/tmp/semaphore + depends_on: + semaphore-db: + condition: service_healthy + networks: + - semaphore-internal + mem_limit: 512M + memswap_limit: 512M + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/api/ping || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + semaphore-db: + image: mysql:8.0 + container_name: semaphore-db + restart: unless-stopped + user: "1024:100" + environment: + - MYSQL_ROOT_PASSWORD=${SEMAPHORE_MYSQL_ROOT_PASSWORD} + - MYSQL_DATABASE=semaphore + - MYSQL_USER=semaphore + - MYSQL_PASSWORD=${SEMAPHORE_DB_PASSWORD} + volumes: + - /mnt/docker-data/databases/semaphore-db:/var/lib/mysql + networks: + - semaphore-internal + labels: + - "com.centurylinklabs.watchtower.enable=false" + command: --innodb-buffer-pool-size=128M --innodb-log-file-size=32M + mem_limit: 512M + memswap_limit: 512M + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 --silent"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + +networks: + semaphore-internal: + driver: bridge diff --git a/compose/hf-pdocker-02/traefik/.env.example b/compose/hf-pdocker-02/traefik/.env.example new file mode 100644 index 0000000..a6147ae --- /dev/null +++ b/compose/hf-pdocker-02/traefik/.env.example @@ -0,0 +1 @@ +CF_DNS_API_TOKEN=change_me_cloudflare_api_token diff --git a/compose/hf-pdocker-02/traefik/docker-compose.yml b/compose/hf-pdocker-02/traefik/docker-compose.yml new file mode 100644 index 0000000..b57349c --- /dev/null +++ b/compose/hf-pdocker-02/traefik/docker-compose.yml @@ -0,0 +1,39 @@ +services: + traefik: + image: traefik:v3.6 + container_name: traefik + restart: unless-stopped + command: + - "--api.insecure=true" + - "--providers.file.directory=/config/dynamic" + - "--providers.file.watch=true" + - "--entrypoints.web.address=:80" + - "--entrypoints.websecure.address=:443" + - "--entrypoints.web.http.redirections.entrypoint.to=websecure" + - "--entrypoints.web.http.redirections.entrypoint.scheme=https" + - "--certificatesresolvers.letsencrypt.acme.dnschallenge=true" + - "--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare" + - "--certificatesresolvers.letsencrypt.acme.email=jeremydanielhobbs@gmail.com" + - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" + environment: + - CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN} + ports: + - "80:80" + - "443:443" + - "8090:8080" + volumes: + - /mnt/docker-data/traefik/letsencrypt:/letsencrypt + - /mnt/docker-data/traefik/dynamic:/config/dynamic:ro + mem_limit: 512M + memswap_limit: 512M + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:8080/api/overview || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" diff --git a/compose/hf-pdocker-02/traefik/dynamic-routes.yml b/compose/hf-pdocker-02/traefik/dynamic-routes.yml new file mode 100644 index 0000000..d69dd8e --- /dev/null +++ b/compose/hf-pdocker-02/traefik/dynamic-routes.yml @@ -0,0 +1,88 @@ +http: + routers: + yoda: + rule: "Host(`yoda.hobbs.farm`)" + service: yoda + entryPoints: + - websecure + tls: + certResolver: letsencrypt + + mess: + rule: "Host(`mess.hobbs.farm`)" + service: mess + entryPoints: + - websecure + tls: + certResolver: letsencrypt + + mess-federation: + rule: "Host(`mess.hobbs.farm`) && PathPrefix(`/_matrix/federation`)" + service: mess + entryPoints: + - websecure + tls: + certResolver: letsencrypt + + wellknown: + rule: "Host(`hobbs.farm`) && PathPrefix(`/.well-known/matrix`)" + service: wellknown + entryPoints: + - websecure + tls: + certResolver: letsencrypt + + solo: + rule: "Host(`solo.hobbs.farm`)" + service: solo + entryPoints: + - websecure + tls: + certResolver: letsencrypt + + vault: + rule: "Host(`vault.hobbs.farm`)" + service: vault + entryPoints: + - websecure + tls: + certResolver: letsencrypt + + git: + rule: "Host(`git.hobbs.farm`)" + service: git + entryPoints: + - websecure + tls: + certResolver: letsencrypt + + services: + yoda: + loadBalancer: + servers: + - url: "http://192.168.86.167:8123" + + mess: + loadBalancer: + servers: + - url: "http://192.168.86.167:8008" + + wellknown: + loadBalancer: + servers: + - url: "http://192.168.86.167:8010" + + solo: + loadBalancer: + servers: + - url: "http://192.168.86.167:8199" + + vault: + loadBalancer: + servers: + - url: "http://192.168.86.100:26226" + + git: + loadBalancer: + servers: + - url: "http://192.168.86.100:3003" diff --git a/compose/hf-pdocker-02/trivy/docker-compose.yml b/compose/hf-pdocker-02/trivy/docker-compose.yml new file mode 100644 index 0000000..2e2e92a --- /dev/null +++ b/compose/hf-pdocker-02/trivy/docker-compose.yml @@ -0,0 +1,21 @@ +services: + trivy: + image: aquasec/trivy:latest + container_name: trivy + restart: unless-stopped + command: server --listen 0.0.0.0:4954 + ports: + - "4954:4954" + volumes: + - trivy-cache:/root/.cache/ + - /var/run/docker.sock:/var/run/docker.sock:ro + mem_limit: 512M + memswap_limit: 512M + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + +volumes: + trivy-cache: diff --git a/compose/hf-pdocker-02/unifi/.env.example b/compose/hf-pdocker-02/unifi/.env.example new file mode 100644 index 0000000..502ed3b --- /dev/null +++ b/compose/hf-pdocker-02/unifi/.env.example @@ -0,0 +1,2 @@ +UNIFI_MONGO_USER=unifi +UNIFI_MONGO_PASS=change_me diff --git a/compose/hf-pdocker-02/unifi/docker-compose.yml b/compose/hf-pdocker-02/unifi/docker-compose.yml new file mode 100644 index 0000000..af13ef9 --- /dev/null +++ b/compose/hf-pdocker-02/unifi/docker-compose.yml @@ -0,0 +1,67 @@ +services: + unifi: + image: lscr.io/linuxserver/unifi-network-application:latest + container_name: unifi + restart: unless-stopped + environment: + - PUID=1000 + - PGID=1000 + - TZ=America/Chicago + - MONGO_USER=${UNIFI_MONGO_USER} + - MONGO_PASS=${UNIFI_MONGO_PASS} + - MONGO_HOST=unifi-db + - MONGO_PORT=27017 + - MONGO_DBNAME=unifi + volumes: + - /mnt/docker-data/configs/unifi/data:/config + ports: + - "8443:8443" + - "3478:3478/udp" + - "10001:10001/udp" + - "8080:8080" + - "1900:1900/udp" + - "8843:8843" + - "8880:8880" + - "6789:6789" + - "5514:5514/udp" + depends_on: + unifi-db: + condition: service_healthy + networks: + - unifi-internal + mem_limit: 1G + memswap_limit: 1G + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + unifi-db: + image: mongo:7.0 + container_name: unifi-db + restart: unless-stopped + user: "1024:100" + volumes: + - /mnt/docker-data/databases/unifi-db:/data/db + networks: + - unifi-internal + labels: + - "com.centurylinklabs.watchtower.enable=false" + mem_limit: 1G + memswap_limit: 1G + healthcheck: + test: ["CMD-SHELL", "mongosh --eval 'db.adminCommand(\"ping\")' --quiet"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + +networks: + unifi-internal: + driver: bridge diff --git a/compose/hf-pdocker-02/vaultwarden/docker-compose.yml b/compose/hf-pdocker-02/vaultwarden/docker-compose.yml new file mode 100644 index 0000000..0b6f706 --- /dev/null +++ b/compose/hf-pdocker-02/vaultwarden/docker-compose.yml @@ -0,0 +1,25 @@ +services: + vaultwarden: + image: vaultwarden/server:latest + container_name: vaultwarden + restart: unless-stopped + environment: + - DOMAIN=https://vault.hobbs.farm + - SIGNUPS_ALLOWED=false + volumes: + - /mnt/docker-data/configs/vaultwarden:/data + ports: + - "26226:80" + mem_limit: 256M + memswap_limit: 256M + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:80/alive || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" diff --git a/compose/hf-pdocker-02/watchtower/docker-compose.yml b/compose/hf-pdocker-02/watchtower/docker-compose.yml new file mode 100644 index 0000000..48b3d78 --- /dev/null +++ b/compose/hf-pdocker-02/watchtower/docker-compose.yml @@ -0,0 +1,21 @@ +services: + watchtower: + image: containrrr/watchtower:latest + container_name: watchtower + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + environment: + - WATCHTOWER_CLEANUP=true + - WATCHTOWER_INCLUDE_RESTARTING=true + - WATCHTOWER_POLL_INTERVAL=86400 + - WATCHTOWER_MONITOR_ONLY=false + - TZ=America/Chicago + - DOCKER_API_VERSION=1.44 + mem_limit: 256M + memswap_limit: 256M + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" diff --git a/compose/hf-pdocker-02/wikijs/.env.example b/compose/hf-pdocker-02/wikijs/.env.example new file mode 100644 index 0000000..c7cbfd9 --- /dev/null +++ b/compose/hf-pdocker-02/wikijs/.env.example @@ -0,0 +1 @@ +WIKIJS_DB_PASSWORD=change_me diff --git a/compose/hf-pdocker-02/wikijs/docker-compose.yml b/compose/hf-pdocker-02/wikijs/docker-compose.yml new file mode 100644 index 0000000..79b9dd6 --- /dev/null +++ b/compose/hf-pdocker-02/wikijs/docker-compose.yml @@ -0,0 +1,65 @@ +services: + wikijs: + image: ghcr.io/requarks/wiki:2 + container_name: wikijs + restart: unless-stopped + ports: + - "3002:3000" + environment: + - DB_TYPE=postgres + - DB_HOST=wikijs-db + - DB_PORT=5432 + - DB_USER=wikijs + - DB_PASS=${WIKIJS_DB_PASSWORD} + - DB_NAME=wiki + depends_on: + wikijs-db: + condition: service_healthy + networks: + - wikijs-internal + mem_limit: 512M + memswap_limit: 512M + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000/ || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 120s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + wikijs-db: + image: postgres:15-alpine + container_name: wikijs-db + restart: unless-stopped + user: "1024:100" + environment: + - POSTGRES_DB=wiki + - POSTGRES_USER=wikijs + - POSTGRES_PASSWORD=${WIKIJS_DB_PASSWORD} + volumes: + - /mnt/docker-data/databases/wikijs-db:/var/lib/postgresql/data + networks: + - wikijs-internal + labels: + - "com.centurylinklabs.watchtower.enable=false" + mem_limit: 256M + memswap_limit: 256M + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wikijs -d wiki"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + +networks: + wikijs-internal: + driver: bridge diff --git a/docs/plans/2026-03-04-frigate-video-alerts-design.md b/docs/plans/2026-03-04-frigate-video-alerts-design.md new file mode 100644 index 0000000..75a914d --- /dev/null +++ b/docs/plans/2026-03-04-frigate-video-alerts-design.md @@ -0,0 +1,112 @@ +# Frigate Video Alerts via Matrix + +## Summary + +A lightweight Python container that listens for Frigate object detection events via MQTT, analyzes snapshots with Claude Vision (Haiku), and posts rich notifications to a dedicated Matrix room with inline images and AI-generated descriptions. + +## Architecture + +``` +Frigate NVR (hf-pdocker-01) + | MQTT: frigate/events + v +Mosquitto Broker (:1883) + | + v +frigate-notify (Python container, hf-pdocker-01, host network) + |-- Frigate API: GET /api/events/{id}/snapshot.jpg + |-- Claude Vision API: analyze snapshot (Haiku) + |-- Matrix API: upload image + send message + v + #video-alerts:hobbs.farm (@frigate:hobbs.farm bot) +``` + +## Event Flow & Filtering + +**MQTT topic:** `frigate/events` + +**Filtering:** +1. Only `type == "end"` events (complete detection, best snapshot) +2. Only `label` in `["person", "car", "dog", "cat", "bird"]` +3. Only `score >= 0.7` +4. Only if `has_snapshot == true` +5. Deduplication: skip duplicate event IDs within 30-second window per camera +6. Rate limit: max 1 notification per camera per 60 seconds + +## AI Vision Analysis + +- Model: `claude-haiku-4-5-20251001` (~$0.001/event, ~$1.50/month at 50 events/day) +- Prompt: "Briefly describe what's happening in this security camera image in 1-2 sentences. Focus on the people, vehicles, or animals and their activity. Be concise." +- Max tokens: 150 +- Fallback: if Claude API fails, use structured description ("Person detected on {camera}") + +## Matrix Message Format + +Two messages per event: + +1. **Image message** (`m.image`): snapshot uploaded to Matrix content repository +2. **Text message** (`m.text` with HTML): +``` +[camera_emoji] camera_name - Object - score% +AI-generated description of the scene. + +Link: http://192.168.86.192:5000/review?id={event_id} +Timestamp +``` + +**Bot:** `@frigate:hobbs.farm` (new dedicated account) +**Room:** `#video-alerts:hobbs.farm` (new room, @jeremy:hobbs.farm invited) + +## Container & Deployment + +**Location:** `compose/hf-pdocker-01/frigate-notify/` + +``` +compose/hf-pdocker-01/frigate-notify/ + docker-compose.yml + .env + Dockerfile + app/ + main.py (~200 lines - MQTT listener, event processor) + vision.py (~40 lines - Claude Vision API wrapper) + matrix_client.py (~60 lines - image upload + message send) + requirements.txt +``` + +**Container config:** +- Image: custom, from `python:3.12-slim` +- Network: host +- Memory: 256M (memswap_limit: 256M) +- Stateless (no persistent storage) +- Health endpoint: port 5199, returns JSON status + +**Environment variables (.env):** +- `ANTHROPIC_API_KEY` +- `MATRIX_ACCESS_TOKEN` +- `MATRIX_ROOM_ID` +- `FRIGATE_URL=http://192.168.86.192:5000` +- `MQTT_HOST=192.168.86.192` +- `MQTT_PORT=1883` + +## Error Handling + +- **Claude API failure:** fall back to structured message, never drop alert +- **Matrix API failure:** retry 3x with exponential backoff (1s, 2s, 4s) +- **Snapshot fetch failure:** send text-only notification +- **MQTT disconnect:** paho-mqtt auto-reconnects +- **Invalid payload:** log and skip + +## Observability + +- Structured JSON logging to stdout +- Health endpoint at `:5199/health` with MQTT status, event count, last event time +- Added to Prometheus blackbox probes (http-pdocker01 job) +- ContainerDown alert covers it automatically + +## Decisions + +- **Haiku over Sonnet:** 10x cheaper, fast enough for security descriptions +- **host network:** Frigate, Mosquitto, Synapse all on LAN IPs, simplest routing +- **aiohttp over matrix-nio:** lighter, no crypto dependencies needed for bot usage +- **Two messages (image + text):** image renders natively across all Matrix clients +- **End events only:** best snapshot quality, avoids duplicate notifications diff --git a/docs/plans/2026-03-04-frigate-video-alerts.md b/docs/plans/2026-03-04-frigate-video-alerts.md new file mode 100644 index 0000000..f55e9b1 --- /dev/null +++ b/docs/plans/2026-03-04-frigate-video-alerts.md @@ -0,0 +1,713 @@ +# Frigate Video Alerts Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a Python container that listens for Frigate detection events via MQTT, analyzes snapshots with Claude Vision, and posts rich notifications to a Matrix room. + +**Architecture:** A single Python container (`frigate-notify`) on hf-pdocker-01 subscribes to Frigate's MQTT events, filters for person/car/animal detections, fetches snapshots from Frigate's API, sends them to Claude Haiku for natural language description, then posts the snapshot image and description to a dedicated `#video-alerts` Matrix room via a new `@frigate:hobbs.farm` bot. + +**Tech Stack:** Python 3.12, paho-mqtt, anthropic SDK, aiohttp, Docker Compose + +**Design doc:** `docs/plans/2026-03-04-frigate-video-alerts-design.md` + +--- + +### Task 1: Create Matrix bot account and room + +This task runs on hf-pdocker-01 via SSH. No code files involved. + +**Step 1: Register @frigate:hobbs.farm bot account** + +Use the docker-swarm-ops agent to SSH to hf-pdocker-01 (192.168.86.192) and run: + +```bash +# Register the bot via Synapse admin API +# First get an admin token (use @alertbot or register directly) +docker exec $(docker ps -q -f name=matrix_synapse) register_new_matrix_user \ + -u frigate \ + -p "FrigateBot2026!" \ + -a \ + --no-admin \ + -c /data/homeserver.yaml \ + http://localhost:8008 +``` + +**Step 2: Login to get access token** + +```bash +curl -s -X POST http://192.168.86.192:8008/_matrix/client/r0/login \ + -H "Content-Type: application/json" \ + -d '{"type":"m.login.password","user":"frigate","password":"FrigateBot2026!"}' +``` + +Save the `access_token` from the response. + +**Step 3: Create #video-alerts room** + +```bash +curl -s -X POST "http://192.168.86.192:8008/_matrix/client/r0/createRoom" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer <TOKEN>" \ + -d '{ + "room_alias_name": "video-alerts", + "name": "Video Alerts", + "topic": "Frigate NVR detection alerts with AI descriptions", + "visibility": "private", + "invite": ["@jeremy:hobbs.farm"] + }' +``` + +Save the `room_id` from the response. + +**Step 4: Send a test message to verify** + +```bash +curl -s -X PUT "http://192.168.86.192:8008/_matrix/client/r0/rooms/<ROOM_ID>/send/m.room.message/$(date +%s)" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer <TOKEN>" \ + -d '{"msgtype":"m.text","body":"Frigate video alerts bot is online."}' +``` + +**Step 5: Store credentials in vault** + +Use the vault-credential-manager agent to store: +- Name: "Frigate Matrix Bot" +- Username: @frigate:hobbs.farm +- Password: FrigateBot2026! +- Notes: access_token=<TOKEN>, room_id=<ROOM_ID> + +**Step 6: Commit** (nothing to commit yet - this is infrastructure setup) + +--- + +### Task 2: Create the Python application files + +**Files:** +- Create: `compose/hf-pdocker-01/frigate-notify/app/requirements.txt` +- Create: `compose/hf-pdocker-01/frigate-notify/app/vision.py` +- Create: `compose/hf-pdocker-01/frigate-notify/app/matrix_client.py` +- Create: `compose/hf-pdocker-01/frigate-notify/app/main.py` + +**Step 1: Create requirements.txt** + +``` +compose/hf-pdocker-01/frigate-notify/app/requirements.txt +``` + +```text +paho-mqtt==2.1.0 +anthropic>=0.40.0 +aiohttp>=3.9.0 +``` + +**Step 2: Create vision.py - Claude Vision wrapper** + +``` +compose/hf-pdocker-01/frigate-notify/app/vision.py +``` + +```python +import base64 +import logging +import anthropic + +logger = logging.getLogger(__name__) + +PROMPT = ( + "Briefly describe what's happening in this security camera image in 1-2 sentences. " + "Focus on the people, vehicles, or animals and their activity. Be concise." +) + + +def analyze_snapshot(client: anthropic.Anthropic, image_bytes: bytes) -> str | None: + """Send a snapshot to Claude Haiku for description. Returns None on failure.""" + try: + b64 = base64.standard_b64encode(image_bytes).decode("utf-8") + response = client.messages.create( + model="claude-haiku-4-5-20251001", + max_tokens=150, + messages=[ + { + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/jpeg", + "data": b64, + }, + }, + {"type": "text", "text": PROMPT}, + ], + } + ], + ) + description = response.content[0].text.strip() + logger.info("Vision analysis complete: %d chars", len(description)) + return description + except Exception: + logger.exception("Claude Vision API failed") + return None +``` + +**Step 3: Create matrix_client.py - Matrix image upload and messaging** + +``` +compose/hf-pdocker-01/frigate-notify/app/matrix_client.py +``` + +```python +import logging +import time +import aiohttp + +logger = logging.getLogger(__name__) + +RETRY_DELAYS = [1, 2, 4] + + +class MatrixClient: + def __init__(self, homeserver: str, token: str, room_id: str): + self.homeserver = homeserver.rstrip("/") + self.token = token + self.room_id = room_id + self.headers = {"Authorization": f"Bearer {token}"} + + async def _request(self, method: str, url: str, **kwargs) -> dict | None: + for attempt, delay in enumerate(RETRY_DELAYS): + try: + async with aiohttp.ClientSession() as session: + async with session.request( + method, url, headers=self.headers, **kwargs + ) as resp: + if resp.status < 300: + return await resp.json() + body = await resp.text() + logger.warning( + "Matrix API %s (attempt %d): %s", resp.status, attempt + 1, body + ) + except Exception: + logger.exception("Matrix request failed (attempt %d)", attempt + 1) + if attempt < len(RETRY_DELAYS) - 1: + await _sleep(delay) + logger.error("All Matrix retries exhausted for %s", url) + return None + + async def upload_image(self, image_bytes: bytes, filename: str) -> str | None: + url = f"{self.homeserver}/_matrix/media/v3/upload?filename={filename}" + headers = {**self.headers, "Content-Type": "image/jpeg"} + try: + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, data=image_bytes) as resp: + if resp.status < 300: + data = await resp.json() + mxc_uri = data.get("content_uri") + logger.info("Uploaded image: %s", mxc_uri) + return mxc_uri + body = await resp.text() + logger.error("Image upload failed %s: %s", resp.status, body) + except Exception: + logger.exception("Image upload failed") + return None + + async def send_image(self, mxc_uri: str, filename: str, size: int) -> dict | None: + txn_id = str(int(time.time() * 1000)) + url = f"{self.homeserver}/_matrix/client/r0/rooms/{self.room_id}/send/m.room.message/{txn_id}" + body = { + "msgtype": "m.image", + "body": filename, + "url": mxc_uri, + "info": {"mimetype": "image/jpeg", "size": size}, + } + return await self._request("PUT", url, json=body) + + async def send_text(self, plain: str, html: str) -> dict | None: + txn_id = str(int(time.time() * 1000) + 1) + url = f"{self.homeserver}/_matrix/client/r0/rooms/{self.room_id}/send/m.room.message/{txn_id}" + body = { + "msgtype": "m.text", + "body": plain, + "format": "org.matrix.custom.html", + "formatted_body": html, + } + return await self._request("PUT", url, json=body) + + +async def _sleep(seconds: float): + import asyncio + await asyncio.sleep(seconds) +``` + +**Step 4: Create main.py - MQTT listener and event processor** + +``` +compose/hf-pdocker-01/frigate-notify/app/main.py +``` + +```python +import asyncio +import json +import logging +import os +import signal +import sys +import time +from collections import defaultdict +from http.server import HTTPServer, BaseHTTPRequestHandler +from threading import Thread + +import anthropic +import paho.mqtt.client as mqtt + +from vision import analyze_snapshot +from matrix_client import MatrixClient + +# --- Configuration --- +MQTT_HOST = os.environ.get("MQTT_HOST", "192.168.86.192") +MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883")) +FRIGATE_URL = os.environ.get("FRIGATE_URL", "http://192.168.86.192:5000") +MATRIX_HOMESERVER = os.environ.get("MATRIX_HOMESERVER", "http://192.168.86.192:8008") +MATRIX_ACCESS_TOKEN = os.environ["MATRIX_ACCESS_TOKEN"] +MATRIX_ROOM_ID = os.environ["MATRIX_ROOM_ID"] +HEALTH_PORT = int(os.environ.get("HEALTH_PORT", "5199")) + +ALLOWED_LABELS = {"person", "car", "dog", "cat", "bird"} +MIN_SCORE = 0.7 +RATE_LIMIT_SECONDS = 60 +DEDUP_WINDOW_SECONDS = 30 + +# --- Logging --- +logging.basicConfig( + level=logging.INFO, + format='{"time":"%(asctime)s","level":"%(levelname)s","module":"%(module)s","message":"%(message)s"}', + stream=sys.stdout, +) +logger = logging.getLogger(__name__) + +# --- State --- +recent_events: dict[str, float] = {} +last_notify_per_camera: defaultdict[str, float] = defaultdict(float) +events_processed = 0 +last_event_time: str | None = None +mqtt_connected = False + + +# --- Health endpoint --- +class HealthHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/health": + body = json.dumps({ + "status": "ok", + "mqtt_connected": mqtt_connected, + "events_processed": events_processed, + "last_event": last_event_time, + }) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(body.encode()) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + pass # suppress access logs + + +def start_health_server(): + server = HTTPServer(("0.0.0.0", HEALTH_PORT), HealthHandler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + logger.info("Health server started on port %d", HEALTH_PORT) + + +# --- Snapshot fetching --- +async def fetch_snapshot(event_id: str) -> bytes | None: + import aiohttp + url = f"{FRIGATE_URL}/api/events/{event_id}/snapshot.jpg" + try: + async with aiohttp.ClientSession() as session: + async with session.get(url) as resp: + if resp.status == 200: + data = await resp.read() + logger.info("Fetched snapshot for %s: %d bytes", event_id, len(data)) + return data + logger.warning("Snapshot fetch failed for %s: HTTP %d", event_id, resp.status) + except Exception: + logger.exception("Snapshot fetch error for %s", event_id) + return None + + +# --- Event processing --- +async def process_event(event_data: dict, claude: anthropic.Anthropic, matrix: MatrixClient): + global events_processed, last_event_time + + event_type = event_data.get("type") + if event_type != "end": + return + + after = event_data.get("after", {}) + event_id = after.get("id", "") + camera = after.get("camera", "unknown") + label = after.get("label", "") + score = after.get("top_score", after.get("score", 0)) + has_snapshot = after.get("has_snapshot", False) + + # --- Filtering --- + if label not in ALLOWED_LABELS: + return + if score < MIN_SCORE: + return + if not has_snapshot: + return + + # Dedup + now = time.time() + if event_id in recent_events and now - recent_events[event_id] < DEDUP_WINDOW_SECONDS: + return + recent_events[event_id] = now + + # Rate limit per camera + if now - last_notify_per_camera[camera] < RATE_LIMIT_SECONDS: + logger.info("Rate limited: %s on %s", label, camera) + return + last_notify_per_camera[camera] = now + + # Cleanup old dedup entries + cutoff = now - DEDUP_WINDOW_SECONDS * 2 + for eid in [k for k, v in recent_events.items() if v < cutoff]: + del recent_events[eid] + + logger.info("Processing event: %s on %s (%s, score=%.2f)", event_id, camera, label, score) + + # --- Fetch snapshot --- + snapshot = await fetch_snapshot(event_id) + + # --- AI analysis --- + description = None + if snapshot: + description = analyze_snapshot(claude, snapshot) + if not description: + description = f"{label.capitalize()} detected on {camera}." + + # --- Format message --- + score_pct = int(score * 100) + ts = time.strftime("%I:%M:%S %p", time.localtime()) + date_str = time.strftime("%b %d, %Y", time.localtime()) + frigate_link = f"{FRIGATE_URL}/review?id={event_id}" + + plain = f"{camera} - {label.capitalize()} - {score_pct}%\n{description}\n\nView in Frigate: {frigate_link}\n{ts} - {date_str}" + html = ( + f"<b>{camera}</b> · {label.capitalize()} · {score_pct}%<br/>" + f"{description}<br/><br/>" + f'<a href="{frigate_link}">View in Frigate</a><br/>' + f"<small>{ts} · {date_str}</small>" + ) + + # --- Send to Matrix --- + if snapshot: + filename = f"{camera}_{event_id}.jpg" + mxc_uri = await matrix.upload_image(snapshot, filename) + if mxc_uri: + await matrix.send_image(mxc_uri, filename, len(snapshot)) + + await matrix.send_text(plain, html) + + events_processed += 1 + last_event_time = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + logger.info("Notification sent for %s on %s", label, camera) + + +# --- MQTT --- +def create_mqtt_client(loop: asyncio.AbstractEventLoop, claude: anthropic.Anthropic, matrix: MatrixClient): + global mqtt_connected + + def on_connect(client, userdata, flags, reason_code, properties): + global mqtt_connected + mqtt_connected = True + client.subscribe("frigate/events") + logger.info("MQTT connected, subscribed to frigate/events") + + def on_disconnect(client, userdata, flags, reason_code, properties): + global mqtt_connected + mqtt_connected = False + logger.warning("MQTT disconnected: %s", reason_code) + + def on_message(client, userdata, msg): + try: + payload = json.loads(msg.payload) + asyncio.run_coroutine_threadsafe(process_event(payload, claude, matrix), loop) + except json.JSONDecodeError: + logger.warning("Invalid JSON on %s", msg.topic) + except Exception: + logger.exception("Error processing MQTT message") + + client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + client.on_connect = on_connect + client.on_disconnect = on_disconnect + client.on_message = on_message + return client + + +# --- Main --- +def main(): + logger.info("Starting frigate-notify") + + claude = anthropic.Anthropic() + matrix = MatrixClient(MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN, MATRIX_ROOM_ID) + + loop = asyncio.new_event_loop() + loop_thread = Thread(target=loop.run_forever, daemon=True) + loop_thread.start() + + start_health_server() + + mqtt_client = create_mqtt_client(loop, claude, matrix) + mqtt_client.connect(MQTT_HOST, MQTT_PORT) + + def shutdown(signum, frame): + logger.info("Shutting down...") + mqtt_client.disconnect() + loop.call_soon_threadsafe(loop.stop) + sys.exit(0) + + signal.signal(signal.SIGTERM, shutdown) + signal.signal(signal.SIGINT, shutdown) + + mqtt_client.loop_forever() + + +if __name__ == "__main__": + main() +``` + +**Step 5: Commit** + +```bash +git add compose/hf-pdocker-01/frigate-notify/app/ +git commit -m "feat: add frigate-notify Python application + +MQTT listener for Frigate events with Claude Vision analysis +and Matrix notification delivery." +``` + +--- + +### Task 3: Create Dockerfile and docker-compose.yml + +**Files:** +- Create: `compose/hf-pdocker-01/frigate-notify/Dockerfile` +- Create: `compose/hf-pdocker-01/frigate-notify/docker-compose.yml` +- Create: `compose/hf-pdocker-01/frigate-notify/.env` + +**Step 1: Create Dockerfile** + +``` +compose/hf-pdocker-01/frigate-notify/Dockerfile +``` + +```dockerfile +FROM python:3.12-slim + +WORKDIR /app + +COPY app/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ . + +EXPOSE 5199 + +CMD ["python", "-u", "main.py"] +``` + +**Step 2: Create docker-compose.yml** + +``` +compose/hf-pdocker-01/frigate-notify/docker-compose.yml +``` + +```yaml +services: + frigate-notify: + build: + context: . + dockerfile: Dockerfile + container_name: frigate-notify + restart: unless-stopped + network_mode: host + environment: + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - MATRIX_ACCESS_TOKEN=${MATRIX_ACCESS_TOKEN} + - MATRIX_ROOM_ID=${MATRIX_ROOM_ID} + - FRIGATE_URL=http://192.168.86.192:5000 + - MQTT_HOST=192.168.86.192 + - MQTT_PORT=1883 + - MATRIX_HOMESERVER=http://192.168.86.192:8008 + mem_limit: 256M + memswap_limit: 256M + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:5199/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" +``` + +**Step 3: Create .env file** + +``` +compose/hf-pdocker-01/frigate-notify/.env +``` + +``` +ANTHROPIC_API_KEY=<get from existing netclaw .env or vault> +MATRIX_ACCESS_TOKEN=<from Task 1 step 2> +MATRIX_ROOM_ID=<from Task 1 step 3> +``` + +Note: Get the ANTHROPIC_API_KEY from the existing netclaw deployment: +```bash +ssh glyptic@192.168.86.100 "cat /mnt/docker-data/compose/hf-pdocker-02/netclaw/.env | grep ANTHROPIC" +``` + +**Step 4: Commit** + +```bash +git add compose/hf-pdocker-01/frigate-notify/Dockerfile +git add compose/hf-pdocker-01/frigate-notify/docker-compose.yml +# Do NOT commit .env (contains secrets) +git commit -m "feat: add frigate-notify Docker configuration + +Standalone Compose service on hf-pdocker-01 with host networking, +256M memory limit, health check on port 5199." +``` + +--- + +### Task 4: Build and deploy on hf-pdocker-01 + +This task uses the docker-swarm-ops agent to SSH to hf-pdocker-01. + +**Step 1: Copy files to hf-pdocker-01** + +SCP the entire `compose/hf-pdocker-01/frigate-notify/` directory to `/mnt/docker-data/compose/hf-pdocker-01/frigate-notify/` on hf-pdocker-01. + +**Step 2: Build the container** + +```bash +cd /mnt/docker-data/compose/hf-pdocker-01/frigate-notify +docker compose build +``` + +**Step 3: Start the container** + +```bash +docker compose up -d +``` + +**Step 4: Verify container is running** + +```bash +docker ps --filter name=frigate-notify --format "table {{.Names}}\t{{.Status}}" +``` + +Expected: `frigate-notify Up X seconds (health: starting)` + +**Step 5: Check logs for successful startup** + +```bash +docker logs frigate-notify --tail 20 +``` + +Expected: Health server started, MQTT connected, subscribed to frigate/events. + +**Step 6: Verify health endpoint** + +```bash +curl -s http://localhost:5199/health | python3 -m json.tool +``` + +Expected: `{"status": "ok", "mqtt_connected": true, "events_processed": 0, "last_event": null}` + +--- + +### Task 5: Add monitoring and verify end-to-end + +**Files:** +- Modify: `stacks/monitoring/prometheus.yml` - add health probe target + +**Step 1: Add blackbox probe target** + +In `stacks/monitoring/prometheus.yml`, add to the `http-pdocker01` targets list: + +```yaml +- http://192.168.86.192:5199/health # Frigate Notify +``` + +**Step 2: Deploy updated prometheus config** + +SCP to hf-pdocker-01, copy to NAS, hot-reload Prometheus: + +```bash +sudo cp /data/monitoring/prometheus.yml /mnt/docker-data/configs/prometheus/prometheus.yml +# Reload inside container +docker exec $(docker ps -q -f name=monitoring_prometheus) wget -qO- --post-data='' http://localhost:9090/-/reload +``` + +**Step 3: Trigger a test detection** + +Walk in front of a camera, or wait for an organic detection event. Check logs: + +```bash +docker logs frigate-notify --tail 30 -f +``` + +Expected: Event processing log, snapshot fetch, vision analysis, Matrix notification sent. + +**Step 4: Verify Matrix message received** + +Check `#video-alerts:hobbs.farm` in Matrix for the snapshot image and AI-generated description. + +**Step 5: Store credentials in vault** + +Use vault-credential-manager agent to store the Frigate bot credentials. + +**Step 6: Commit monitoring update** + +```bash +git add stacks/monitoring/prometheus.yml +git commit -m "feat: add frigate-notify to Prometheus blackbox probes" +``` + +--- + +### Task 6: Update documentation and memory + +**Step 1: Update MEMORY.md** + +Add entry for frigate-notify service: +- Container location, port, purpose +- Matrix bot identity and room +- Key config notes + +**Step 2: Update CLAUDE.md service distribution** + +Add frigate-notify to hf-pdocker-01 Compose services list. + +**Step 3: Update alert-manager agent** + +Add frigate-notify to the service list and probe targets documentation. + +**Step 4: Commit documentation updates** + +```bash +git add -A +git commit -m "docs: add frigate-notify to infrastructure documentation" +``` diff --git a/docs/plans/2026-03-05-farm-manager-design.md b/docs/plans/2026-03-05-farm-manager-design.md new file mode 100644 index 0000000..d4a1770 --- /dev/null +++ b/docs/plans/2026-03-05-farm-manager-design.md @@ -0,0 +1,182 @@ +# Farm Manager — Design Document + +**Date**: 2026-03-05 +**Status**: Approved + +## Overview + +A web-based dashboard for managing Docker services across the 3-node homelab cluster. Provides full control (start/stop/restart/logs/pull) with service grouping for bulk operations. + +## Architecture + +Three components: + +1. **Dashboard** — Static HTML/CSS/JS served by the API server. Dark theme, single page with service cards, group management, and slide-out log viewer. +2. **API Server** — Python FastAPI on hf-pdocker-01:8888. Serves frontend, proxies Docker commands to node agents, manages group definitions in JSON. +3. **Node Agent** — Python FastAPI on each node:8889. Mounts Docker socket, exposes REST API for container operations. Auto-detects Swarm vs Compose containers. + +``` +Browser → Dashboard (hf-pdocker-01:8888) + ↓ + API Server + ↙ ↓ ↘ + Agent Agent Agent + (01:8889) (02:8889) (bart:8889) + ↓ ↓ ↓ + Docker Docker Docker + Socket Socket Socket +``` + +## API Endpoints + +### API Server (hf-pdocker-01:8888) + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/nodes` | List all nodes with health status | +| GET | `/api/services` | All containers across all nodes | +| POST | `/api/services/{node}/{container}/start` | Start container | +| POST | `/api/services/{node}/{container}/stop` | Stop container | +| POST | `/api/services/{node}/{container}/restart` | Restart container | +| GET | `/api/services/{node}/{container}/logs?tail=100` | Stream logs | +| POST | `/api/services/{node}/{container}/pull` | Pull latest image | +| GET | `/api/groups` | List all groups | +| POST | `/api/groups` | Create group | +| PUT | `/api/groups/{id}` | Edit group | +| DELETE | `/api/groups/{id}` | Delete group | +| POST | `/api/groups/{id}/start` | Start all services in group | +| POST | `/api/groups/{id}/stop` | Stop all services in group | +| POST | `/api/groups/{id}/restart` | Restart all services in group | + +### Node Agent (each node:8889) + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/containers` | List all containers with status | +| POST | `/containers/{id}/start` | Start | +| POST | `/containers/{id}/stop` | Stop | +| POST | `/containers/{id}/restart` | Restart | +| GET | `/containers/{id}/logs?tail=100` | Get logs | +| POST | `/containers/{id}/pull` | Pull latest image | +| GET | `/health` | Agent health check | + +## Frontend UI + +- **Sidebar**: Node list with health dots, group list with expand/collapse, "All Services" view, group management button +- **Main area**: Grid of service cards (name, status badge, node, image, uptime, action buttons) +- **Log viewer**: Slide-out right panel, last 200 lines, auto-scroll +- **Group actions**: Toolbar with Start All / Stop All / Restart All, confirmation modal for destructive actions +- **Group editor**: Modal with name field and checkbox service list +- **Styling**: Dark theme, custom CSS variables, no framework + +## Data Storage + +### Groups (`/mnt/docker-data/configs/farm-manager/groups.json`) + +```json +{ + "groups": [ + { + "id": "ai-agents", + "name": "AI Agents", + "services": [ + {"node": "hf-pdocker-01", "container": "ai-agents_margaret"}, + {"node": "hf-pdocker-01", "container": "ai-agents_spirit"}, + {"node": "hf-pdocker-01", "container": "ai-agents_bob"}, + {"node": "hf-pdocker-01", "container": "ai-agents_clippy"} + ] + } + ] +} +``` + +### Node config (`config.json`) + +```json +{ + "nodes": [ + {"name": "hf-pdocker-01", "host": "192.168.86.192", "agent_port": 8889}, + {"name": "hf-pdocker-02", "host": "192.168.86.100", "agent_port": 8889}, + {"name": "bart", "host": "192.168.86.167", "agent_port": 8889} + ] +} +``` + +### Default Groups + +| Group | Services | +|-------|----------| +| AI Agents | margaret, spirit, bob, clippy | +| Smart Home | homeassistant, esphome, mosquitto | +| Monitoring | grafana, prometheus, alertmanager, influxdb, blackbox-exporter | +| Media | jellyfin, sonarr, radarr, sabnzbd, nzbhydra2, jellyseerr | +| Matrix | synapse, synapse-db, wellknown | +| Guacamole | guacamole, guacd, guacamole-db | +| Dev Tools | gitea, gitea-db, semaphore, semaphore-db, wikijs, wikijs-db | +| Security | vaultwarden, trivy, openvas | +| Network | traefik, adguard, unifi, unifi-db | +| Frigate | frigate, frigate-notify | + +## Deployment + +### Node Agent (all 3 nodes) + +- Path: `/mnt/docker-data/compose/{node}/farm-agent/docker-compose.yml` +- Image: built once, pushed to registry at 127.0.0.1:5050 +- Mounts: `/var/run/docker.sock` +- Port: 8889 +- mem_limit: 128M + +### API Server + Dashboard (hf-pdocker-01) + +- Path: `/mnt/docker-data/compose/hf-pdocker-01/farm-manager/docker-compose.yml` +- Port: 8888 +- Config: `/mnt/docker-data/configs/farm-manager/` +- mem_limit: 256M + +### Swarm Handling + +Node agent auto-detects Swarm-managed containers and uses `docker service` API for Swarm services, `docker` API for Compose containers. + +## Repo + +- Gitea: `jeremy/farm-manager` (git.hobbs.farm) +- Structure: + +``` +farm-manager/ +├── agent/ +│ ├── main.py +│ ├── docker_ops.py +│ ├── requirements.txt +│ └── Dockerfile +├── server/ +│ ├── main.py +│ ├── routes/ +│ ├── static/ +│ │ ├── index.html +│ │ ├── style.css +│ │ └── app.js +│ ├── requirements.txt +│ └── Dockerfile +├── tests/ +│ ├── test_agent.py +│ ├── test_server.py +│ └── conftest.py +├── config.json +├── groups.json +└── README.md +``` + +## Testing + +| Layer | Scope | Tool | +|-------|-------|------| +| Unit | Agent Docker operations, group CRUD, API proxy logic | pytest + pytest-asyncio | +| Integration | API server ↔ mock agent, group persistence | pytest + httpx TestClient | +| Contract | Agent response schemas match dashboard expectations | pydantic models | + +## Monitoring + +- Prometheus blackbox probes: `:8888/health` and `:8889/health` on each node +- No auth (internal LAN only) diff --git a/docs/plans/2026-03-05-farm-manager.md b/docs/plans/2026-03-05-farm-manager.md new file mode 100644 index 0000000..cd108ea --- /dev/null +++ b/docs/plans/2026-03-05-farm-manager.md @@ -0,0 +1,1770 @@ +# Farm Manager Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a web dashboard to manage Docker services across 3 homelab nodes with full control and service grouping. + +**Architecture:** Node agents on each Docker host expose REST APIs over the local Docker socket. A central API server proxies requests and manages groups. Static HTML/CSS/JS frontend served by the API server. + +**Tech Stack:** Python 3.12, FastAPI, docker-py (aiodocker), httpx, pytest, vanilla HTML/CSS/JS + +**Repo:** git.hobbs.farm/jeremy/farm-manager (Gitea issues #1-#16 track backlog) + +**Development:** Code is developed locally at `/mnt/c/Users/Jeremy/projects/farm-manager/`, pushed to Gitea, then deployed to nodes. + +--- + +### Task 1: Project Scaffold + Shared Models + +**Gitea issues:** Prerequisite for all + +**Files:** +- Create: `agent/__init__.py` +- Create: `agent/models.py` +- Create: `agent/requirements.txt` +- Create: `server/__init__.py` +- Create: `server/models.py` +- Create: `server/requirements.txt` +- Create: `tests/__init__.py` +- Create: `tests/conftest.py` +- Create: `config.json` +- Create: `groups.json` +- Create: `.gitignore` + +**Step 1: Clone repo and create project structure** + +```bash +cd /mnt/c/Users/Jeremy/projects +git clone http://192.168.86.100:3003/jeremy/farm-manager.git +cd farm-manager +mkdir -p agent server/routes server/static tests +touch agent/__init__.py server/__init__.py server/routes/__init__.py tests/__init__.py +``` + +**Step 2: Create shared pydantic models for the agent** + +`agent/models.py`: +```python +from pydantic import BaseModel + + +class ContainerInfo(BaseModel): + id: str + name: str + status: str # running, exited, paused, restarting + image: str + created: str + uptime: str | None = None + is_swarm: bool = False + swarm_service: str | None = None + + +class ContainerAction(BaseModel): + success: bool + message: str + + +class LogsResponse(BaseModel): + container: str + logs: str + + +class HealthResponse(BaseModel): + status: str + hostname: str + containers_total: int +``` + +**Step 3: Create shared pydantic models for the server** + +`server/models.py`: +```python +from pydantic import BaseModel + + +class NodeConfig(BaseModel): + name: str + host: str + agent_port: int = 8889 + + +class NodeStatus(BaseModel): + name: str + host: str + healthy: bool + containers_total: int = 0 + error: str | None = None + + +class ServiceRef(BaseModel): + node: str + container: str + + +class Group(BaseModel): + id: str + name: str + services: list[ServiceRef] = [] + + +class GroupsData(BaseModel): + groups: list[Group] = [] + + +class ServiceInfo(BaseModel): + id: str + name: str + node: str + status: str + image: str + created: str + uptime: str | None = None + is_swarm: bool = False + swarm_service: str | None = None +``` + +**Step 4: Create requirements files** + +`agent/requirements.txt`: +``` +fastapi>=0.115.0 +uvicorn>=0.34.0 +docker>=7.0.0 +``` + +`server/requirements.txt`: +``` +fastapi>=0.115.0 +uvicorn>=0.34.0 +httpx>=0.28.0 +aiofiles>=24.1.0 +``` + +`tests/requirements.txt`: +``` +pytest>=8.0.0 +pytest-asyncio>=0.24.0 +httpx>=0.28.0 +``` + +**Step 5: Create config files** + +`config.json`: +```json +{ + "nodes": [ + {"name": "hf-pdocker-01", "host": "192.168.86.192", "agent_port": 8889}, + {"name": "hf-pdocker-02", "host": "192.168.86.100", "agent_port": 8889}, + {"name": "bart", "host": "192.168.86.167", "agent_port": 8889} + ] +} +``` + +`groups.json`: +```json +{ + "groups": [ + { + "id": "ai-agents", + "name": "AI Agents", + "services": [ + {"node": "hf-pdocker-01", "container": "ai-agents_margaret"}, + {"node": "hf-pdocker-01", "container": "ai-agents_spirit"}, + {"node": "hf-pdocker-01", "container": "ai-agents_bob"}, + {"node": "hf-pdocker-01", "container": "ai-agents_clippy"} + ] + }, + { + "id": "smart-home", + "name": "Smart Home", + "services": [ + {"node": "hf-pdocker-01", "container": "smarthome_homeassistant"}, + {"node": "hf-pdocker-01", "container": "smarthome_esphome"}, + {"node": "hf-pdocker-01", "container": "smarthome_mosquitto"} + ] + }, + { + "id": "monitoring", + "name": "Monitoring", + "services": [ + {"node": "hf-pdocker-01", "container": "monitoring_grafana"}, + {"node": "hf-pdocker-01", "container": "monitoring_prometheus"}, + {"node": "hf-pdocker-01", "container": "monitoring_alertmanager"}, + {"node": "hf-pdocker-01", "container": "monitoring_influxdb"}, + {"node": "hf-pdocker-01", "container": "monitoring_blackbox-exporter"} + ] + }, + { + "id": "media", + "name": "Media", + "services": [ + {"node": "bart", "container": "jellyfin"}, + {"node": "bart", "container": "sonarr"}, + {"node": "bart", "container": "radarr"}, + {"node": "bart", "container": "sabnzbd"}, + {"node": "bart", "container": "nzbhydra2"}, + {"node": "bart", "container": "jellyseerr"} + ] + }, + { + "id": "matrix", + "name": "Matrix", + "services": [ + {"node": "hf-pdocker-01", "container": "matrix_synapse"}, + {"node": "hf-pdocker-01", "container": "matrix_synapse-db"}, + {"node": "hf-pdocker-01", "container": "matrix_wellknown"} + ] + }, + { + "id": "guacamole", + "name": "Guacamole", + "services": [ + {"node": "hf-pdocker-01", "container": "guacamole_guacamole"}, + {"node": "hf-pdocker-01", "container": "guacamole_guacd"}, + {"node": "hf-pdocker-01", "container": "guacamole_guacamole-db"} + ] + }, + { + "id": "dev-tools", + "name": "Dev Tools", + "services": [ + {"node": "hf-pdocker-02", "container": "gitea"}, + {"node": "hf-pdocker-02", "container": "gitea-db"}, + {"node": "hf-pdocker-02", "container": "semaphore"}, + {"node": "hf-pdocker-02", "container": "semaphore-db"}, + {"node": "hf-pdocker-02", "container": "wikijs"}, + {"node": "hf-pdocker-02", "container": "wikijs-db"} + ] + }, + { + "id": "security", + "name": "Security", + "services": [ + {"node": "hf-pdocker-02", "container": "vaultwarden"}, + {"node": "hf-pdocker-02", "container": "trivy"}, + {"node": "bart", "container": "openvas"} + ] + }, + { + "id": "network", + "name": "Network", + "services": [ + {"node": "hf-pdocker-01", "container": "traefik_traefik"}, + {"node": "hf-pdocker-01", "container": "adguard_adguardhome"}, + {"node": "hf-pdocker-02", "container": "unifi"}, + {"node": "hf-pdocker-02", "container": "unifi-db"} + ] + }, + { + "id": "frigate", + "name": "Frigate", + "services": [ + {"node": "hf-pdocker-01", "container": "frigate"}, + {"node": "hf-pdocker-01", "container": "frigate-notify"} + ] + } + ] +} +``` + +`.gitignore`: +``` +__pycache__/ +*.pyc +.pytest_cache/ +*.egg-info/ +venv/ +.env +``` + +**Step 6: Create test conftest** + +`tests/conftest.py`: +```python +import pytest + + +@pytest.fixture +def sample_container(): + return { + "id": "abc123", + "name": "test-container", + "status": "running", + "image": "nginx:latest", + "created": "2026-03-05T00:00:00Z", + "uptime": "2 hours", + "is_swarm": False, + "swarm_service": None, + } + + +@pytest.fixture +def sample_group(): + return { + "id": "test-group", + "name": "Test Group", + "services": [ + {"node": "hf-pdocker-01", "container": "test-container"}, + ], + } +``` + +**Step 7: Commit scaffold** + +```bash +git add -A +git commit -m "feat: project scaffold with models, config, and test fixtures" +git push origin main +``` + +--- + +### Task 2: Node Agent — Docker Operations (TDD) + +**Gitea issues:** #1, #12 + +**Files:** +- Create: `tests/test_agent_docker_ops.py` +- Create: `agent/docker_ops.py` + +**Step 1: Write failing tests for Docker operations** + +`tests/test_agent_docker_ops.py`: +```python +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from agent.docker_ops import DockerOps + + +@pytest.fixture +def mock_docker_client(): + client = MagicMock() + client.containers = MagicMock() + return client + + +@pytest.fixture +def docker_ops(mock_docker_client): + with patch("agent.docker_ops.docker.DockerClient", return_value=mock_docker_client): + ops = DockerOps() + ops.client = mock_docker_client + return ops + + +class TestListContainers: + def test_list_returns_container_info(self, docker_ops, mock_docker_client): + mock_container = MagicMock() + mock_container.id = "abc123def456" + mock_container.name = "test-app" + mock_container.status = "running" + mock_container.image.tags = ["nginx:latest"] + mock_container.attrs = { + "Created": "2026-03-05T00:00:00Z", + "State": {"StartedAt": "2026-03-05T01:00:00Z"}, + } + mock_container.labels = {} + mock_docker_client.containers.list.return_value = [mock_container] + + result = docker_ops.list_containers() + + assert len(result) == 1 + assert result[0]["name"] == "test-app" + assert result[0]["status"] == "running" + assert result[0]["image"] == "nginx:latest" + assert result[0]["is_swarm"] is False + + def test_list_detects_swarm_containers(self, docker_ops, mock_docker_client): + mock_container = MagicMock() + mock_container.id = "abc123" + mock_container.name = "stack_service.1.taskid" + mock_container.status = "running" + mock_container.image.tags = ["app:latest"] + mock_container.attrs = { + "Created": "2026-03-05T00:00:00Z", + "State": {"StartedAt": "2026-03-05T01:00:00Z"}, + } + mock_container.labels = { + "com.docker.swarm.service.name": "stack_service", + } + mock_docker_client.containers.list.return_value = [mock_container] + + result = docker_ops.list_containers() + + assert result[0]["is_swarm"] is True + assert result[0]["swarm_service"] == "stack_service" + + def test_list_includes_stopped_containers(self, docker_ops, mock_docker_client): + mock_container = MagicMock() + mock_container.id = "abc123" + mock_container.name = "stopped-app" + mock_container.status = "exited" + mock_container.image.tags = ["app:latest"] + mock_container.attrs = { + "Created": "2026-03-05T00:00:00Z", + "State": {"StartedAt": "2026-03-05T01:00:00Z"}, + } + mock_container.labels = {} + mock_docker_client.containers.list.return_value = [mock_container] + + result = docker_ops.list_containers() + + assert result[0]["status"] == "exited" + + +class TestContainerActions: + def test_start_container(self, docker_ops, mock_docker_client): + mock_container = MagicMock() + mock_container.name = "test-app" + mock_docker_client.containers.get.return_value = mock_container + + result = docker_ops.start_container("abc123") + + mock_container.start.assert_called_once() + assert result["success"] is True + + def test_stop_container(self, docker_ops, mock_docker_client): + mock_container = MagicMock() + mock_container.name = "test-app" + mock_docker_client.containers.get.return_value = mock_container + + result = docker_ops.stop_container("abc123") + + mock_container.stop.assert_called_once() + assert result["success"] is True + + def test_restart_container(self, docker_ops, mock_docker_client): + mock_container = MagicMock() + mock_container.name = "test-app" + mock_docker_client.containers.get.return_value = mock_container + + result = docker_ops.restart_container("abc123") + + mock_container.restart.assert_called_once() + assert result["success"] is True + + def test_start_nonexistent_container(self, docker_ops, mock_docker_client): + import docker.errors + mock_docker_client.containers.get.side_effect = docker.errors.NotFound("not found") + + result = docker_ops.start_container("doesntexist") + + assert result["success"] is False + assert "not found" in result["message"].lower() + + +class TestContainerLogs: + def test_get_logs(self, docker_ops, mock_docker_client): + mock_container = MagicMock() + mock_container.name = "test-app" + mock_container.logs.return_value = b"line1\nline2\nline3" + mock_docker_client.containers.get.return_value = mock_container + + result = docker_ops.get_logs("abc123", tail=100) + + assert result["container"] == "test-app" + assert "line1" in result["logs"] + + +class TestPullImage: + def test_pull_image(self, docker_ops, mock_docker_client): + mock_container = MagicMock() + mock_container.name = "test-app" + mock_container.image.tags = ["nginx:latest"] + mock_docker_client.containers.get.return_value = mock_container + mock_docker_client.images.pull.return_value = MagicMock() + + result = docker_ops.pull_image("abc123") + + mock_docker_client.images.pull.assert_called_once_with("nginx:latest") + assert result["success"] is True + + +class TestSwarmActions: + def test_stop_swarm_service_scales_to_zero(self, docker_ops, mock_docker_client): + mock_container = MagicMock() + mock_container.name = "stack_svc.1.taskid" + mock_container.labels = {"com.docker.swarm.service.name": "stack_svc"} + mock_docker_client.containers.get.return_value = mock_container + + mock_service = MagicMock() + mock_docker_client.services.get.return_value = mock_service + + result = docker_ops.stop_container("abc123") + + mock_service.scale.assert_called_once_with(0) + assert result["success"] is True + + def test_start_swarm_service_scales_to_one(self, docker_ops, mock_docker_client): + mock_container = MagicMock() + mock_container.name = "stack_svc.1.taskid" + mock_container.labels = {"com.docker.swarm.service.name": "stack_svc"} + mock_docker_client.containers.get.return_value = mock_container + + mock_service = MagicMock() + mock_docker_client.services.get.return_value = mock_service + + result = docker_ops.start_container("abc123") + + mock_service.scale.assert_called_once_with(1) + assert result["success"] is True +``` + +**Step 2: Run tests — verify they fail** + +```bash +cd /mnt/c/Users/Jeremy/projects/farm-manager +pip install -r agent/requirements.txt -r tests/requirements.txt +pytest tests/test_agent_docker_ops.py -v +``` +Expected: FAIL — `ModuleNotFoundError: No module named 'agent.docker_ops'` + +**Step 3: Implement DockerOps** + +`agent/docker_ops.py`: +```python +import socket +from datetime import datetime, timezone + +import docker +import docker.errors + + +class DockerOps: + def __init__(self): + self.client = docker.DockerClient.from_env() + self.hostname = socket.gethostname() + + def _is_swarm(self, container) -> bool: + return "com.docker.swarm.service.name" in container.labels + + def _get_swarm_service_name(self, container) -> str | None: + return container.labels.get("com.docker.swarm.service.name") + + def _format_uptime(self, started_at: str) -> str: + try: + start = datetime.fromisoformat(started_at.replace("Z", "+00:00")) + delta = datetime.now(timezone.utc) - start + hours, remainder = divmod(int(delta.total_seconds()), 3600) + minutes, _ = divmod(remainder, 60) + if hours >= 24: + days = hours // 24 + hours = hours % 24 + return f"{days}d {hours}h {minutes}m" + return f"{hours}h {minutes}m" + except Exception: + return None + + def list_containers(self) -> list[dict]: + containers = self.client.containers.list(all=True) + result = [] + for c in containers: + tags = c.image.tags + image = tags[0] if tags else "unknown" + started_at = c.attrs.get("State", {}).get("StartedAt", "") + is_swarm = self._is_swarm(c) + result.append({ + "id": c.id[:12], + "name": c.name, + "status": c.status, + "image": image, + "created": c.attrs.get("Created", ""), + "uptime": self._format_uptime(started_at) if c.status == "running" else None, + "is_swarm": is_swarm, + "swarm_service": self._get_swarm_service_name(c) if is_swarm else None, + }) + return result + + def start_container(self, container_id: str) -> dict: + try: + container = self.client.containers.get(container_id) + if self._is_swarm(container): + svc_name = self._get_swarm_service_name(container) + service = self.client.services.get(svc_name) + service.scale(1) + return {"success": True, "message": f"Scaled {svc_name} to 1"} + container.start() + return {"success": True, "message": f"Started {container.name}"} + except docker.errors.NotFound: + return {"success": False, "message": "Container not found"} + except Exception as e: + return {"success": False, "message": str(e)} + + def stop_container(self, container_id: str) -> dict: + try: + container = self.client.containers.get(container_id) + if self._is_swarm(container): + svc_name = self._get_swarm_service_name(container) + service = self.client.services.get(svc_name) + service.scale(0) + return {"success": True, "message": f"Scaled {svc_name} to 0"} + container.stop() + return {"success": True, "message": f"Stopped {container.name}"} + except docker.errors.NotFound: + return {"success": False, "message": "Container not found"} + except Exception as e: + return {"success": False, "message": str(e)} + + def restart_container(self, container_id: str) -> dict: + try: + container = self.client.containers.get(container_id) + if self._is_swarm(container): + svc_name = self._get_swarm_service_name(container) + service = self.client.services.get(svc_name) + service.force_update() + return {"success": True, "message": f"Force-updated {svc_name}"} + container.restart() + return {"success": True, "message": f"Restarted {container.name}"} + except docker.errors.NotFound: + return {"success": False, "message": "Container not found"} + except Exception as e: + return {"success": False, "message": str(e)} + + def get_logs(self, container_id: str, tail: int = 100) -> dict: + try: + container = self.client.containers.get(container_id) + logs = container.logs(tail=tail, timestamps=True).decode("utf-8", errors="replace") + return {"container": container.name, "logs": logs} + except docker.errors.NotFound: + return {"container": container_id, "logs": "Container not found"} + except Exception as e: + return {"container": container_id, "logs": str(e)} + + def pull_image(self, container_id: str) -> dict: + try: + container = self.client.containers.get(container_id) + tags = container.image.tags + if not tags: + return {"success": False, "message": "No image tag found"} + image_name = tags[0] + self.client.images.pull(image_name) + return {"success": True, "message": f"Pulled {image_name}"} + except docker.errors.NotFound: + return {"success": False, "message": "Container not found"} + except Exception as e: + return {"success": False, "message": str(e)} + + def get_health(self) -> dict: + containers = self.client.containers.list(all=True) + return { + "status": "ok", + "hostname": self.hostname, + "containers_total": len(containers), + } +``` + +**Step 4: Run tests — verify they pass** + +```bash +pytest tests/test_agent_docker_ops.py -v +``` +Expected: All PASS + +**Step 5: Commit** + +```bash +git add agent/docker_ops.py tests/test_agent_docker_ops.py +git commit -m "feat(agent): Docker operations with Swarm/Compose detection (TDD) + +Closes #1, progresses #12" +git push origin main +``` + +--- + +### Task 3: Node Agent — API Endpoints (TDD) + +**Gitea issues:** #1 + +**Files:** +- Create: `tests/test_agent_api.py` +- Create: `agent/main.py` + +**Step 1: Write failing tests for agent API** + +`tests/test_agent_api.py`: +```python +import pytest +from unittest.mock import patch, MagicMock +from fastapi.testclient import TestClient + + +@pytest.fixture +def mock_ops(): + with patch("agent.main.ops") as mock: + yield mock + + +@pytest.fixture +def client(mock_ops): + from agent.main import app + return TestClient(app) + + +class TestHealthEndpoint: + def test_health_returns_ok(self, client, mock_ops): + mock_ops.get_health.return_value = { + "status": "ok", + "hostname": "test-node", + "containers_total": 5, + } + resp = client.get("/health") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + +class TestListContainers: + def test_list_containers(self, client, mock_ops): + mock_ops.list_containers.return_value = [ + { + "id": "abc123", + "name": "test-app", + "status": "running", + "image": "nginx:latest", + "created": "2026-03-05T00:00:00Z", + "uptime": "2h 30m", + "is_swarm": False, + "swarm_service": None, + } + ] + resp = client.get("/containers") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["name"] == "test-app" + + +class TestContainerActions: + def test_start_container(self, client, mock_ops): + mock_ops.start_container.return_value = {"success": True, "message": "Started"} + resp = client.post("/containers/abc123/start") + assert resp.status_code == 200 + assert resp.json()["success"] is True + + def test_stop_container(self, client, mock_ops): + mock_ops.stop_container.return_value = {"success": True, "message": "Stopped"} + resp = client.post("/containers/abc123/stop") + assert resp.status_code == 200 + assert resp.json()["success"] is True + + def test_restart_container(self, client, mock_ops): + mock_ops.restart_container.return_value = {"success": True, "message": "Restarted"} + resp = client.post("/containers/abc123/restart") + assert resp.status_code == 200 + assert resp.json()["success"] is True + + +class TestLogs: + def test_get_logs(self, client, mock_ops): + mock_ops.get_logs.return_value = {"container": "test-app", "logs": "log line 1\nlog line 2"} + resp = client.get("/containers/abc123/logs?tail=50") + assert resp.status_code == 200 + assert "log line 1" in resp.json()["logs"] + + def test_get_logs_default_tail(self, client, mock_ops): + mock_ops.get_logs.return_value = {"container": "test-app", "logs": "logs"} + resp = client.get("/containers/abc123/logs") + mock_ops.get_logs.assert_called_once_with("abc123", tail=200) + + +class TestPull: + def test_pull_image(self, client, mock_ops): + mock_ops.pull_image.return_value = {"success": True, "message": "Pulled nginx:latest"} + resp = client.post("/containers/abc123/pull") + assert resp.status_code == 200 + assert resp.json()["success"] is True +``` + +**Step 2: Run tests — verify they fail** + +```bash +pytest tests/test_agent_api.py -v +``` +Expected: FAIL — `ModuleNotFoundError: No module named 'agent.main'` + +**Step 3: Implement agent FastAPI app** + +`agent/main.py`: +```python +from fastapi import FastAPI, Query + +from agent.docker_ops import DockerOps + +app = FastAPI(title="Farm Manager Agent") +ops = DockerOps() + + +@app.get("/health") +def health(): + return ops.get_health() + + +@app.get("/containers") +def list_containers(): + return ops.list_containers() + + +@app.post("/containers/{container_id}/start") +def start_container(container_id: str): + return ops.start_container(container_id) + + +@app.post("/containers/{container_id}/stop") +def stop_container(container_id: str): + return ops.stop_container(container_id) + + +@app.post("/containers/{container_id}/restart") +def restart_container(container_id: str): + return ops.restart_container(container_id) + + +@app.get("/containers/{container_id}/logs") +def get_logs(container_id: str, tail: int = Query(default=200)): + return ops.get_logs(container_id, tail=tail) + + +@app.post("/containers/{container_id}/pull") +def pull_image(container_id: str): + return ops.pull_image(container_id) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8889) +``` + +**Step 4: Run tests — verify they pass** + +```bash +pytest tests/test_agent_api.py -v +``` +Expected: All PASS + +**Step 5: Commit** + +```bash +git add agent/main.py tests/test_agent_api.py +git commit -m "feat(agent): FastAPI endpoints for container management (TDD) + +Closes #1" +git push origin main +``` + +--- + +### Task 4: Node Agent — Dockerfile + +**Gitea issues:** #2 + +**Files:** +- Create: `agent/Dockerfile` + +**Step 1: Create Dockerfile** + +`agent/Dockerfile`: +```dockerfile +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8889 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8889"] +``` + +**Step 2: Commit** + +```bash +git add agent/Dockerfile +git commit -m "feat(agent): Dockerfile for node agent + +Closes #2" +git push origin main +``` + +--- + +### Task 5: API Server — Service Proxy (TDD) + +**Gitea issues:** #3, #13 + +**Files:** +- Create: `tests/test_server_proxy.py` +- Create: `server/main.py` +- Create: `server/routes/services.py` +- Create: `server/routes/nodes.py` + +**Step 1: Write failing tests for server proxy** + +`tests/test_server_proxy.py`: +```python +import json +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from fastapi.testclient import TestClient + + +@pytest.fixture +def mock_httpx(): + with patch("server.routes.services.httpx.AsyncClient") as mock_cls: + mock_client = AsyncMock() + mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_cls.return_value.__aexit__ = AsyncMock(return_value=False) + yield mock_client + + +@pytest.fixture +def client(): + from server.main import app + return TestClient(app) + + +class TestListNodes: + def test_list_nodes(self, client): + with patch("server.routes.nodes.httpx.AsyncClient") as mock_cls: + mock_client = AsyncMock() + mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_cls.return_value.__aexit__ = AsyncMock(return_value=False) + + mock_resp = AsyncMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"status": "ok", "hostname": "hf-pdocker-01", "containers_total": 10} + mock_client.get.return_value = mock_resp + + resp = client.get("/api/nodes") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 3 + assert data[0]["name"] == "hf-pdocker-01" + + +class TestListServices: + def test_list_all_services(self, client): + with patch("server.routes.services.httpx.AsyncClient") as mock_cls: + mock_client = AsyncMock() + mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_cls.return_value.__aexit__ = AsyncMock(return_value=False) + + mock_resp = AsyncMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = [ + {"id": "abc", "name": "nginx", "status": "running", "image": "nginx:latest", + "created": "", "uptime": "1h", "is_swarm": False, "swarm_service": None} + ] + mock_client.get.return_value = mock_resp + + resp = client.get("/api/services") + assert resp.status_code == 200 + data = resp.json() + assert len(data) >= 1 + assert data[0]["node"] in ["hf-pdocker-01", "hf-pdocker-02", "bart"] + + +class TestServiceActions: + def test_start_service(self, client): + with patch("server.routes.services.httpx.AsyncClient") as mock_cls: + mock_client = AsyncMock() + mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_cls.return_value.__aexit__ = AsyncMock(return_value=False) + + mock_resp = AsyncMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"success": True, "message": "Started"} + mock_client.post.return_value = mock_resp + + resp = client.post("/api/services/hf-pdocker-01/abc123/start") + assert resp.status_code == 200 + assert resp.json()["success"] is True + + def test_invalid_node_returns_404(self, client): + resp = client.post("/api/services/nonexistent-node/abc123/start") + assert resp.status_code == 404 +``` + +**Step 2: Run tests — verify they fail** + +```bash +pytest tests/test_server_proxy.py -v +``` +Expected: FAIL + +**Step 3: Implement server routes** + +`server/routes/nodes.py`: +```python +import httpx +from fastapi import APIRouter + +from server.config import get_nodes + +router = APIRouter() + + +@router.get("/api/nodes") +async def list_nodes(): + nodes = get_nodes() + results = [] + for node in nodes: + url = f"http://{node['host']}:{node['agent_port']}/health" + try: + async with httpx.AsyncClient(timeout=5.0) as client: + resp = await client.get(url) + if resp.status_code == 200: + health = resp.json() + results.append({ + "name": node["name"], + "host": node["host"], + "healthy": True, + "containers_total": health.get("containers_total", 0), + }) + else: + results.append({"name": node["name"], "host": node["host"], "healthy": False, "error": f"HTTP {resp.status_code}"}) + except Exception as e: + results.append({"name": node["name"], "host": node["host"], "healthy": False, "error": str(e)}) + return results +``` + +`server/routes/services.py`: +```python +import httpx +from fastapi import APIRouter, HTTPException, Query + +from server.config import get_node_url + +router = APIRouter() + + +@router.get("/api/services") +async def list_services(): + from server.config import get_nodes + all_services = [] + for node in get_nodes(): + url = f"http://{node['host']}:{node['agent_port']}/containers" + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get(url) + if resp.status_code == 200: + containers = resp.json() + for c in containers: + c["node"] = node["name"] + all_services.extend(containers) + except Exception: + pass + return all_services + + +@router.post("/api/services/{node}/{container_id}/start") +async def start_service(node: str, container_id: str): + base = get_node_url(node) + if not base: + raise HTTPException(status_code=404, detail=f"Node {node} not found") + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post(f"{base}/containers/{container_id}/start") + return resp.json() + + +@router.post("/api/services/{node}/{container_id}/stop") +async def stop_service(node: str, container_id: str): + base = get_node_url(node) + if not base: + raise HTTPException(status_code=404, detail=f"Node {node} not found") + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post(f"{base}/containers/{container_id}/stop") + return resp.json() + + +@router.post("/api/services/{node}/{container_id}/restart") +async def restart_service(node: str, container_id: str): + base = get_node_url(node) + if not base: + raise HTTPException(status_code=404, detail=f"Node {node} not found") + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post(f"{base}/containers/{container_id}/restart") + return resp.json() + + +@router.get("/api/services/{node}/{container_id}/logs") +async def get_logs(node: str, container_id: str, tail: int = Query(default=200)): + base = get_node_url(node) + if not base: + raise HTTPException(status_code=404, detail=f"Node {node} not found") + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get(f"{base}/containers/{container_id}/logs", params={"tail": tail}) + return resp.json() + + +@router.post("/api/services/{node}/{container_id}/pull") +async def pull_image(node: str, container_id: str): + base = get_node_url(node) + if not base: + raise HTTPException(status_code=404, detail=f"Node {node} not found") + async with httpx.AsyncClient(timeout=120.0) as client: + resp = await client.post(f"{base}/containers/{container_id}/pull") + return resp.json() +``` + +`server/config.py`: +```python +import json +import os + +_config = None + + +def _load_config(): + global _config + if _config is None: + config_path = os.environ.get("CONFIG_PATH", "/app/config.json") + with open(config_path) as f: + _config = json.load(f) + return _config + + +def get_nodes() -> list[dict]: + return _load_config()["nodes"] + + +def get_node_url(node_name: str) -> str | None: + for node in get_nodes(): + if node["name"] == node_name: + return f"http://{node['host']}:{node['agent_port']}" + return None +``` + +`server/main.py`: +```python +import os + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from server.routes.nodes import router as nodes_router +from server.routes.services import router as services_router + +app = FastAPI(title="Farm Manager") + +app.include_router(nodes_router) +app.include_router(services_router) + + +@app.get("/health") +def health(): + return {"status": "ok"} + + +static_dir = os.path.join(os.path.dirname(__file__), "static") +if os.path.isdir(static_dir): + app.mount("/", StaticFiles(directory=static_dir, html=True), name="static") + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8888) +``` + +**Step 4: Run tests — verify they pass** + +```bash +CONFIG_PATH=config.json pytest tests/test_server_proxy.py -v +``` +Expected: All PASS + +**Step 5: Commit** + +```bash +git add server/ tests/test_server_proxy.py +git commit -m "feat(server): API proxy routes for services and nodes (TDD) + +Closes #3, progresses #13" +git push origin main +``` + +--- + +### Task 6: API Server — Group CRUD (TDD) + +**Gitea issues:** #4, #13 + +**Files:** +- Create: `tests/test_server_groups.py` +- Create: `server/routes/groups.py` +- Create: `server/group_store.py` + +**Step 1: Write failing tests** + +`tests/test_server_groups.py`: +```python +import json +import os +import tempfile +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def groups_file(): + f = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) + json.dump({"groups": []}, f) + f.close() + yield f.name + os.unlink(f.name) + + +@pytest.fixture +def client(groups_file): + os.environ["GROUPS_PATH"] = groups_file + os.environ["CONFIG_PATH"] = "config.json" + from server.group_store import GroupStore + GroupStore._instance = None + from server.main import app + return TestClient(app) + + +class TestGroupCRUD: + def test_list_groups_empty(self, client): + resp = client.get("/api/groups") + assert resp.status_code == 200 + assert resp.json() == [] + + def test_create_group(self, client): + resp = client.post("/api/groups", json={ + "id": "test-group", + "name": "Test Group", + "services": [{"node": "hf-pdocker-01", "container": "test"}], + }) + assert resp.status_code == 200 + assert resp.json()["id"] == "test-group" + + def test_create_group_then_list(self, client): + client.post("/api/groups", json={ + "id": "grp1", "name": "Group 1", "services": [], + }) + resp = client.get("/api/groups") + assert len(resp.json()) == 1 + assert resp.json()[0]["name"] == "Group 1" + + def test_update_group(self, client): + client.post("/api/groups", json={ + "id": "grp1", "name": "Group 1", "services": [], + }) + resp = client.put("/api/groups/grp1", json={ + "id": "grp1", "name": "Updated Group", "services": [{"node": "bart", "container": "jellyfin"}], + }) + assert resp.status_code == 200 + assert resp.json()["name"] == "Updated Group" + assert len(resp.json()["services"]) == 1 + + def test_delete_group(self, client): + client.post("/api/groups", json={ + "id": "grp1", "name": "Group 1", "services": [], + }) + resp = client.delete("/api/groups/grp1") + assert resp.status_code == 200 + assert len(client.get("/api/groups").json()) == 0 + + def test_update_nonexistent_returns_404(self, client): + resp = client.put("/api/groups/nope", json={ + "id": "nope", "name": "Nope", "services": [], + }) + assert resp.status_code == 404 + + def test_delete_nonexistent_returns_404(self, client): + resp = client.delete("/api/groups/nope") + assert resp.status_code == 404 + + def test_create_duplicate_returns_409(self, client): + client.post("/api/groups", json={"id": "grp1", "name": "G1", "services": []}) + resp = client.post("/api/groups", json={"id": "grp1", "name": "G1 dup", "services": []}) + assert resp.status_code == 409 + + def test_persistence(self, client, groups_file): + client.post("/api/groups", json={"id": "grp1", "name": "G1", "services": []}) + with open(groups_file) as f: + data = json.load(f) + assert len(data["groups"]) == 1 +``` + +**Step 2: Run tests — verify they fail** + +```bash +CONFIG_PATH=config.json pytest tests/test_server_groups.py -v +``` +Expected: FAIL + +**Step 3: Implement group store and routes** + +`server/group_store.py`: +```python +import json +import os +import threading + + +class GroupStore: + _instance = None + _lock = threading.Lock() + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._path = os.environ.get("GROUPS_PATH", "/app/groups.json") + cls._instance._data = cls._instance._load() + return cls._instance + + def _load(self) -> dict: + try: + with open(self._path) as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {"groups": []} + + def _save(self): + with open(self._path, "w") as f: + json.dump(self._data, f, indent=2) + + def list_groups(self) -> list[dict]: + return self._data["groups"] + + def get_group(self, group_id: str) -> dict | None: + for g in self._data["groups"]: + if g["id"] == group_id: + return g + return None + + def create_group(self, group: dict) -> dict: + with self._lock: + self._data["groups"].append(group) + self._save() + return group + + def update_group(self, group_id: str, group: dict) -> dict | None: + with self._lock: + for i, g in enumerate(self._data["groups"]): + if g["id"] == group_id: + self._data["groups"][i] = group + self._save() + return group + return None + + def delete_group(self, group_id: str) -> bool: + with self._lock: + before = len(self._data["groups"]) + self._data["groups"] = [g for g in self._data["groups"] if g["id"] != group_id] + if len(self._data["groups"]) < before: + self._save() + return True + return False +``` + +`server/routes/groups.py`: +```python +import httpx +from fastapi import APIRouter, HTTPException + +from server.group_store import GroupStore +from server.config import get_node_url +from server.models import Group + +router = APIRouter() + + +def _store(): + return GroupStore() + + +@router.get("/api/groups") +def list_groups(): + return _store().list_groups() + + +@router.post("/api/groups") +def create_group(group: Group): + store = _store() + if store.get_group(group.id): + raise HTTPException(status_code=409, detail=f"Group {group.id} already exists") + return store.create_group(group.model_dump()) + + +@router.put("/api/groups/{group_id}") +def update_group(group_id: str, group: Group): + result = _store().update_group(group_id, group.model_dump()) + if result is None: + raise HTTPException(status_code=404, detail=f"Group {group_id} not found") + return result + + +@router.delete("/api/groups/{group_id}") +def delete_group(group_id: str): + if not _store().delete_group(group_id): + raise HTTPException(status_code=404, detail=f"Group {group_id} not found") + return {"success": True} + + +@router.post("/api/groups/{group_id}/start") +async def start_group(group_id: str): + group = _store().get_group(group_id) + if not group: + raise HTTPException(status_code=404, detail=f"Group {group_id} not found") + results = [] + for svc in group["services"]: + base = get_node_url(svc["node"]) + if not base: + results.append({"container": svc["container"], "success": False, "message": f"Node {svc['node']} not found"}) + continue + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post(f"{base}/containers/{svc['container']}/start") + results.append({"container": svc["container"], **resp.json()}) + except Exception as e: + results.append({"container": svc["container"], "success": False, "message": str(e)}) + return results + + +@router.post("/api/groups/{group_id}/stop") +async def stop_group(group_id: str): + group = _store().get_group(group_id) + if not group: + raise HTTPException(status_code=404, detail=f"Group {group_id} not found") + results = [] + for svc in group["services"]: + base = get_node_url(svc["node"]) + if not base: + results.append({"container": svc["container"], "success": False, "message": f"Node {svc['node']} not found"}) + continue + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post(f"{base}/containers/{svc['container']}/stop") + results.append({"container": svc["container"], **resp.json()}) + except Exception as e: + results.append({"container": svc["container"], "success": False, "message": str(e)}) + return results + + +@router.post("/api/groups/{group_id}/restart") +async def restart_group(group_id: str): + group = _store().get_group(group_id) + if not group: + raise HTTPException(status_code=404, detail=f"Group {group_id} not found") + results = [] + for svc in group["services"]: + base = get_node_url(svc["node"]) + if not base: + results.append({"container": svc["container"], "success": False, "message": f"Node {svc['node']} not found"}) + continue + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post(f"{base}/containers/{svc['container']}/restart") + results.append({"container": svc["container"], **resp.json()}) + except Exception as e: + results.append({"container": svc["container"], "success": False, "message": str(e)}) + return results +``` + +Update `server/main.py` — add groups router: +```python +from server.routes.groups import router as groups_router +app.include_router(groups_router) +``` + +**Step 4: Run tests — verify they pass** + +```bash +CONFIG_PATH=config.json pytest tests/test_server_groups.py -v +``` +Expected: All PASS + +**Step 5: Commit** + +```bash +git add server/group_store.py server/routes/groups.py tests/test_server_groups.py server/main.py +git commit -m "feat(server): Group CRUD with JSON persistence and bulk actions (TDD) + +Closes #4, #5, progresses #13" +git push origin main +``` + +--- + +### Task 7: Dashboard — HTML, CSS, JavaScript + +**Gitea issues:** #6, #7, #8, #9, #10 + +**Files:** +- Create: `server/static/index.html` +- Create: `server/static/style.css` +- Create: `server/static/app.js` + +This is the frontend task. Since it's vanilla HTML/CSS/JS with no build step, TDD doesn't apply in the traditional sense. The tests are the API contract tests already written. Build the UI to consume the API endpoints. + +**Step 1: Create index.html** + +`server/static/index.html` — Single page layout with: +- Sidebar: nodes list, groups list, "All Services" link, "Manage Groups" button +- Main area: service card grid with status badges and action buttons +- Slide-out log panel on the right +- Group editor modal +- Confirmation modal for destructive group actions +- Toast notifications for action feedback + +**Step 2: Create style.css** + +`server/static/style.css` — Dark theme using CSS variables: +- `--bg-primary: #1a1a2e` (dark blue-black) +- `--bg-secondary: #16213e` (card background) +- `--bg-tertiary: #0f3460` (sidebar) +- `--accent: #00d2ff` (cyan accent) +- `--success: #00c853`, `--danger: #ff1744`, `--warning: #ffab00` +- Status badges: green (running), red (exited), yellow (restarting), gray (paused) +- Responsive grid for service cards +- Slide-out panel animation for logs + +**Step 3: Create app.js** + +`server/static/app.js` — Vanilla JS application: +- `loadNodes()` — Fetch `/api/nodes`, render sidebar with health dots +- `loadServices(filter)` — Fetch `/api/services`, render card grid, optionally filter by group +- `loadGroups()` — Fetch `/api/groups`, render sidebar group list +- `performAction(node, containerId, action)` — POST to `/api/services/{node}/{id}/{action}`, show toast, refresh +- `loadLogs(node, containerId)` — GET `/api/services/{node}/{id}/logs`, open slide-out panel, auto-scroll +- `groupAction(groupId, action)` — POST to `/api/groups/{id}/{action}` with confirmation modal +- `openGroupEditor(groupId?)` — Modal for create/edit group, checkboxes for services +- `deleteGroup(groupId)` — DELETE with confirmation +- Auto-refresh service status every 15 seconds +- Toast notification system for action feedback + +**Step 4: Commit** + +```bash +git add server/static/ +git commit -m "feat(dashboard): service grid, log viewer, group management, dark theme + +Closes #6, #7, #8, #9" +git push origin main +``` + +--- + +### Task 8: Dockerfiles and Compose Files + +**Gitea issues:** #2, #11 + +**Files:** +- Create: `server/Dockerfile` +- Create: `deploy/agent/docker-compose.yml` +- Create: `deploy/server/docker-compose.yml` + +**Step 1: Create server Dockerfile** + +`server/Dockerfile`: +```dockerfile +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8888 + +CMD ["uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8888"] +``` + +**Step 2: Create agent compose file (template for all nodes)** + +`deploy/agent/docker-compose.yml`: +```yaml +services: + farm-agent: + image: 127.0.0.1:5050/farm-agent:latest + container_name: farm-agent + restart: unless-stopped + ports: + - "8889:8889" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + mem_limit: 128M + memswap_limit: 128M + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8889/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" +``` + +**Step 3: Create server compose file** + +`deploy/server/docker-compose.yml`: +```yaml +services: + farm-manager: + image: 127.0.0.1:5050/farm-manager:latest + container_name: farm-manager + restart: unless-stopped + ports: + - "8888:8888" + environment: + - CONFIG_PATH=/app/config.json + - GROUPS_PATH=/data/groups.json + volumes: + - /mnt/docker-data/configs/farm-manager:/data + mem_limit: 256M + memswap_limit: 256M + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8888/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" +``` + +**Step 4: Commit** + +```bash +git add server/Dockerfile deploy/ +git commit -m "feat: Dockerfiles and compose files for agent and server + +Closes #2, #11" +git push origin main +``` + +--- + +### Task 9: Build, Push, and Deploy + +**Gitea issues:** #2, #11, #10 + +**Step 1: Build and push agent image** + +On hf-pdocker-01 (has the registry at 127.0.0.1:5050): +```bash +cd /tmp/farm-manager +docker build -t 127.0.0.1:5050/farm-agent:latest -f agent/Dockerfile agent/ +docker push 127.0.0.1:5050/farm-agent:latest +``` + +**Step 2: Build and push server image** + +```bash +docker build -t 127.0.0.1:5050/farm-manager:latest -f server/Dockerfile . +docker push 127.0.0.1:5050/farm-manager:latest +``` + +**Step 3: Deploy agents to all 3 nodes** + +Copy compose file to NAS: +```bash +mkdir -p /mnt/docker-data/compose/hf-pdocker-01/farm-agent +mkdir -p /mnt/docker-data/compose/hf-pdocker-02/farm-agent +mkdir -p /mnt/docker-data/compose/bart/farm-agent +cp deploy/agent/docker-compose.yml /mnt/docker-data/compose/hf-pdocker-01/farm-agent/ +cp deploy/agent/docker-compose.yml /mnt/docker-data/compose/hf-pdocker-02/farm-agent/ +cp deploy/agent/docker-compose.yml /mnt/docker-data/compose/bart/farm-agent/ +``` + +On each node: +```bash +cd /mnt/docker-data/compose/{node}/farm-agent && docker compose up -d +``` + +**Step 4: Create config directory and copy default groups** + +```bash +mkdir -p /mnt/docker-data/configs/farm-manager +cp groups.json /mnt/docker-data/configs/farm-manager/groups.json +``` + +**Step 5: Deploy server on hf-pdocker-01** + +```bash +mkdir -p /mnt/docker-data/compose/hf-pdocker-01/farm-manager +cp deploy/server/docker-compose.yml /mnt/docker-data/compose/hf-pdocker-01/farm-manager/ +cd /mnt/docker-data/compose/hf-pdocker-01/farm-manager && docker compose up -d +``` + +**Step 6: Verify all services are healthy** + +```bash +curl -sf http://192.168.86.192:8888/health +curl -sf http://192.168.86.192:8889/health +curl -sf http://192.168.86.100:8889/health +curl -sf http://192.168.86.167:8889/health +``` + +**Step 7: Commit deploy configs to repo** + +```bash +git add deploy/ +git commit -m "chore: deploy configs for all nodes + +Closes #10, #11" +git push origin main +``` + +--- + +### Task 10: Monitoring and Documentation + +**Gitea issues:** #15, #16 + +**Step 1: Add Prometheus blackbox probes** + +Add to `stacks/monitoring/prometheus.yml` in the appropriate job sections: +- `http://192.168.86.192:8888/health` (Farm Manager server — http-pdocker01) +- `http://192.168.86.192:8889/health` (Farm Agent — http-pdocker01) +- `http://192.168.86.100:8889/health` (Farm Agent — http-pdocker02) +- `http://192.168.86.167:8889/health` (Farm Agent — http-bart) + +Hot-reload Prometheus. + +**Step 2: Update CLAUDE.md** + +Add Farm Manager to hf-pdocker-01 service list and port reference (8888). +Add Farm Agent to all 3 nodes. + +**Step 3: Update MEMORY.md** + +Add Farm Manager entry. + +**Step 4: Update wiki** + +Launch documentation-keeper agent to add Farm Manager page. + +**Step 5: Write README.md in repo** + +Cover: what it does, architecture diagram, API reference, deployment instructions. + +**Step 6: Commit** + +```bash +git add README.md +git commit -m "docs: README with architecture and API reference + +Closes #15, #16" +git push origin main +``` + +**Step 7: Close all Gitea issues and milestone** + +Use Gitea API to close milestone v1.0. diff --git a/docs/plans/2026-03-07-frigate-notify-v2-design.md b/docs/plans/2026-03-07-frigate-notify-v2-design.md new file mode 100644 index 0000000..4c9c70d --- /dev/null +++ b/docs/plans/2026-03-07-frigate-notify-v2-design.md @@ -0,0 +1,242 @@ +# Frigate-Notify v2 Design + +## Summary + +Evolve frigate-notify from a simple MQTT-to-Matrix alert forwarder into an intelligent notification system with known-object recognition, a web UI for managing identities and reviewing events, and configurable vision backends (local Ollama on a 3090 GPU or Claude Haiku cloud API). + +## Goals + +1. **Reduce noise** — stop alerting on known family members, vehicles, and pets +2. **Learn from real events** — tag objects from Frigate detection snapshots to build a recognition database over time +3. **Local AI processing** — use a local 3090 GPU (192.168.86.196) for CLIP embeddings and vision LLM, reducing cloud API costs +4. **Web interface** — standalone app for managing known objects, reviewing events, and configuring settings +5. **Farm Manager integration** — sidebar link in Farm Manager pointing to the frigate-notify UI + +## Architecture + +``` +Frigate NVR ──MQTT──> Mosquitto Broker + | + v + frigate-notify v2 (:5199) + +-------------------------+ + | MQTT Listener | + | Event Processor | + | Web UI (HTML/CSS/JS) | + | REST API (FastAPI) | + | SQLite DB | + +------+------+------+-----+ + | | | + +------------+ | +------------+ + v v v + clip-service (:5200) Ollama (:11434) Claude Haiku API + 192.168.86.196 192.168.86.196 cloud + CLIP embeddings Vision LLM Vision fallback + Scene descriptions Scene descriptions + | | + +-------+-----------+ + v + GPU Machine (RTX 3090, 24GB VRAM) + + frigate-notify v2 + | + v + Matrix API + #video-alerts room + (unknown objects only) + +Farm Manager (:8888) ──sidebar link──> frigate-notify (:5199) +``` + +## Known Object Recognition Pipeline + +Each Frigate event goes through this flow: + +``` +Event arrives (MQTT, type="end") + -> Fetch snapshot from Frigate API + -> Generate CLIP embedding via clip-service (192.168.86.196:5200) + -> Compare against stored embeddings in SQLite + -> High confidence match (>0.85): "known" -- log quietly, no alert + -> Low confidence (<0.60): "unknown" -- send Matrix alert + -> Ambiguous (0.60-0.85): send snapshot + reference images to Vision LLM + -> LLM confirms match: "known" -- log quietly + -> LLM says no match: "unknown" -- send Matrix alert +``` + +Scene description (the AI-generated text in notifications) is handled separately and is configurable to use either Haiku or Ollama vision model. + +### Fallback Chain + +1. clip-service down: skip embedding matching, send all events to vision LLM +2. Ollama down: fall back to Claude Haiku for everything +3. Haiku down: structured text description ("Person detected on back_porch") + +### Known Object Types + +- **People** -- tagged by name, matched by face/body appearance +- **Vehicles** -- tagged by description (e.g., "blue F-150"), matched by shape/color +- **Pets** -- tagged by name, matched by appearance + +### Learning Flow + +1. Events stream in and show up in the web UI's "Recent Events" feed +2. User taps an event and chooses "Tag as known" -> enters name/description -> selects type (person/vehicle/pet) +3. System stores the CLIP embedding from that snapshot tied to that identity +4. Multiple tagged snapshots of the same identity improve matching accuracy over time +5. User can also "Confirm" or "Reject" the system's auto-matches to refine it + +### Notification Behavior for Known Objects + +- **Known objects**: logged quietly in the web UI, no Matrix notification +- **Unknown objects**: full Matrix alert with snapshot + AI description (existing behavior) + +## Web UI + +Served at `:5199`, vanilla HTML/CSS/JS (consistent with Farm Manager). Same container as the API and event processor. + +### Pages + +| Page | Purpose | +|------|---------| +| Dashboard | Live feed of recent events. Each shows snapshot thumbnail, camera, label, score, known/unknown status, AI description. Filter by camera, label, status. | +| Known Objects | Grid of all tagged identities. Click to see all associated snapshots, edit name, delete. Organized by type (people/vehicles/pets). | +| Review Queue | Unreviewed events needing tagging. Quick-action buttons: "Tag as known", "Dismiss", "Confirm match". | +| Settings | Vision backend toggle (Haiku/Ollama/both), confidence thresholds, rate limiting, camera enable/disable, Matrix notification preferences. | + +## REST API + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/api/events` | GET | List recent events, filterable | +| `/api/events/{id}` | GET | Single event detail | +| `/api/events/{id}/tag` | POST | Tag event as known object | +| `/api/events/{id}/dismiss` | POST | Dismiss event | +| `/api/known-objects` | GET | List all known identities | +| `/api/known-objects/{id}` | GET/PUT/DELETE | Manage a known identity | +| `/api/settings` | GET/PUT | Read/update configuration | +| `/api/stats` | GET | Event counts, match rates | +| `/health` | GET | Existing health endpoint (preserved) | + +## Data Model + +SQLite database at `/mnt/docker-data/configs/frigate-notify/frigate-notify.db` + +### Tables + +```sql +known_objects + id INTEGER PRIMARY KEY + name TEXT NOT NULL -- "Jeremy", "Blue F-150", "Max the dog" + type TEXT NOT NULL -- "person", "vehicle", "pet" + action TEXT DEFAULT "quiet" -- "quiet" (log only) | "suppress" | "alert" + created_at TIMESTAMP + updated_at TIMESTAMP + +embeddings + id INTEGER PRIMARY KEY + known_object_id INTEGER FK -> known_objects + embedding BLOB -- CLIP vector (float32 array) + snapshot_url TEXT -- Frigate snapshot URL for reference + thumbnail BLOB -- Small JPEG for UI display + created_at TIMESTAMP + +events + id INTEGER PRIMARY KEY + frigate_event_id TEXT UNIQUE -- Frigate's event ID + camera TEXT + label TEXT -- person/car/dog/cat/bird + score REAL + snapshot BLOB -- JPEG thumbnail for UI + embedding BLOB -- CLIP vector for this event + match_status TEXT -- "known", "unknown", "ambiguous", "unreviewed" + matched_object_id INTEGER FK -> known_objects (nullable) + match_confidence REAL + ai_description TEXT -- Scene description from Haiku/Ollama + notified BOOLEAN DEFAULT 0 -- Was a Matrix alert sent? + created_at TIMESTAMP + +settings + key TEXT PRIMARY KEY + value TEXT -- JSON-encoded value +``` + +### Storage Estimate + +~50 events/day x 50KB thumbnail = ~2.5MB/day. With 90-day retention and auto-pruning, DB stays under 500MB. + +## Ollama & CLIP Integration + +### clip-service (new sidecar container) + +Runs on 192.168.86.196 alongside Ollama. Lightweight FastAPI app that loads the CLIP model and exposes embedding endpoints. + +``` +192.168.86.196 (GPU machine, RTX 3090) + +-- Ollama (:11434) -- vision LLM (llava:13b) + +-- clip-service (:5200) -- CLIP embedding generation + POST /embed {image: base64} -> {embedding: [float...]} + POST /similarity {embedding1, embedding2} -> {score: 0.0-1.0} +``` + +- Uses `open_clip` library with ViT-L/14 model +- GPU-accelerated, ~2GB VRAM, <50ms per image +- Stateless -- frigate-notify stores the embeddings + +### Ollama Vision Models + +Pull on 192.168.86.196: +- `llava:13b` -- scene description + ambiguous object matching +- ~7.6GB free VRAM after CLIP (3090 has 24GB total) + +### Vision Backend Configuration + +User-configurable in Settings page: +- **Scene description backend**: Haiku / Ollama / Ollama-primary-Haiku-fallback +- **Recognition backend**: CLIP+Ollama / CLIP+Haiku / CLIP-only + +## Tech Stack + +- **Python + FastAPI** -- replaces current raw HTTP server, serves API + static files +- **SQLite** -- data storage (known objects, embeddings, events, settings) +- **Vanilla HTML/CSS/JS** -- web UI, consistent with Farm Manager +- **paho-mqtt** -- MQTT listener (kept from v1) +- **aiohttp** -- Matrix client (kept from v1) +- **open_clip** -- CLIP service sidecar on GPU machine +- **numpy** -- embedding similarity calculations + +## Deployment + +- `frigate-notify` container: on same node as Frigate (hf-pdocker-01 when back) +- `clip-service` container: on 192.168.86.196 (GPU machine), compose file on NAS +- Ollama: already running on 192.168.86.196 + +### Compose Location + +- frigate-notify: `/mnt/docker-data/compose/hf-pdocker-01/frigate-notify/` +- clip-service: `/mnt/docker-data/compose/gpu-machine/clip-service/` + +### Farm Manager Link + +Add "Frigate Alerts" entry to Farm Manager sidebar config pointing to `http://<frigate-notify-host>:5199`. No code coupling. + +## Migration from v1 + +All existing v1 functionality preserved: +- MQTT listener and event filtering (labels, scores, dedup, rate limiting) +- Frigate snapshot fetching +- Claude Haiku vision analysis +- Matrix notifications (image + text) +- Health endpoint at `/health` + +New features layer on top. The app can run in "v1 mode" (no CLIP service, no known objects) if the GPU machine is offline. + +## Decisions + +- **Standalone app with Farm Manager link** over built-in: independent deployment, no coupling +- **Tag from events** over upload reference photos: uses real camera footage, less setup +- **Quiet log for known objects** over full suppress: can review routine activity without being interrupted +- **Hybrid CLIP + Vision LLM** over either alone: fast matching for clear cases, LLM accuracy for ambiguous +- **User-configurable vision backend**: flexibility to use local or cloud, switch as needed +- **SQLite** over PostgreSQL: single-file, no extra container, sufficient for this data volume +- **Vanilla JS** over React/Vue: consistent with Farm Manager, no build step, simple diff --git a/docs/plans/2026-03-07-frigate-notify-v2-plan.md b/docs/plans/2026-03-07-frigate-notify-v2-plan.md new file mode 100644 index 0000000..6b624c9 --- /dev/null +++ b/docs/plans/2026-03-07-frigate-notify-v2-plan.md @@ -0,0 +1,2587 @@ +# Frigate-Notify v2 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Evolve frigate-notify into an intelligent notification system with known-object recognition (CLIP + Vision LLM), a web UI for managing identities and reviewing events, and configurable vision backends (local Ollama or Claude Haiku). + +**Architecture:** A standalone Python/FastAPI web app (:5199) that listens for Frigate MQTT events, matches snapshots against a known-objects database using CLIP embeddings (via a GPU sidecar on 192.168.86.196), falls back to vision LLM for ambiguous matches, and only sends Matrix alerts for unknown objects. A vanilla HTML/CSS/JS web UI provides event review, object tagging, and settings management. + +**Tech Stack:** Python 3.12, FastAPI, SQLite, paho-mqtt, aiohttp, open_clip (sidecar), Ollama, Claude Haiku API, vanilla HTML/CSS/JS + +**Design Doc:** `docs/plans/2026-03-07-frigate-notify-v2-design.md` + +--- + +## Context for the Implementer + +### Existing Code + +The v1 app lives at `/mnt/docker-data/compose/hf-pdocker-01/frigate-notify/` on the NAS and in the Gitea repo `jeremy/frigate-notify`. The source is also in the local repo at `/mnt/c/Users/Jeremy/projects/docker/compose/hf-pdocker-01/frigate-notify/`. + +``` +frigate-notify/ + docker-compose.yml # Compose definition + Dockerfile # python:3.12-slim based + .env # ANTHROPIC_API_KEY, MATRIX_ACCESS_TOKEN, MATRIX_ROOM_ID + app/ + main.py # MQTT listener, event processor, health endpoint (~200 lines) + vision.py # Claude Haiku vision wrapper (~30 lines) + matrix_client.py # Matrix API client (~80 lines) + requirements.txt # paho-mqtt, anthropic, aiohttp +``` + +### Key Services + +| Service | Host | Port | Purpose | +|---------|------|------|---------| +| Frigate NVR | 192.168.86.192 (hf-pdocker-01) | 5000 | Camera NVR, snapshots API | +| Mosquitto | 192.168.86.192 | 1883 | MQTT broker | +| Matrix/Synapse | 192.168.86.167 (bart, failover) | 8008 | Chat server | +| Ollama | 192.168.86.196 | 11434 | Local LLM inference (RTX 3090) | +| Farm Manager | varies | 8888 | Dashboard (sidebar link target) | + +### Git Repo + +- **Gitea**: `http://192.168.86.100:3003/jeremy/frigate-notify` +- **Clone URL**: `http://jeremy:806153c545dc3b227ecdb86b134a2e50210a8e89@192.168.86.100:3003/jeremy/frigate-notify.git` +- **Local working copy**: `/mnt/c/Users/Jeremy/projects/frigate-notify/` (create if doesn't exist) + +### SSH Access (for deployment) + +- Username: `glyptic`, Password: `&Rip8a115` +- Use: `sshpass -p '&Rip8a115' ssh -o StrictHostKeyChecking=no glyptic@<IP>` + +### Note on hf-pdocker-01 + +hf-pdocker-01 is currently DOWN (hard drive failure). Frigate-notify can't run until it's restored (it needs Frigate + MQTT on that node). Development and testing should be done locally and via the Gitea repo. The clip-service sidecar on 192.168.86.196 CAN be deployed and tested independently. + +--- + +## Task 1: Project Scaffolding & FastAPI Migration + +**Goal:** Restructure the project for v2 — migrate from raw HTTP to FastAPI, add proper project layout with tests, set up SQLite database. + +**Files:** +- Modify: `app/main.py` (replace HTTPServer with FastAPI) +- Create: `app/database.py` (SQLite schema and connection) +- Create: `app/models.py` (Pydantic models) +- Create: `app/config.py` (centralized configuration) +- Modify: `app/requirements.txt` (add fastapi, uvicorn, python-multipart) +- Create: `tests/conftest.py` +- Create: `tests/test_database.py` +- Modify: `Dockerfile` (change CMD to uvicorn) + +**Step 1: Clone the repo locally and set up project structure** + +```bash +cd /mnt/c/Users/Jeremy/projects +git clone http://jeremy:806153c545dc3b227ecdb86b134a2e50210a8e89@192.168.86.100:3003/jeremy/frigate-notify.git +cd frigate-notify +``` + +**Step 2: Create `app/config.py`** + +```python +import os + +class Config: + # MQTT + MQTT_HOST: str = os.environ.get("MQTT_HOST", "192.168.86.192") + MQTT_PORT: int = int(os.environ.get("MQTT_PORT", "1883")) + + # Frigate + FRIGATE_URL: str = os.environ.get("FRIGATE_URL", "http://192.168.86.192:5000") + + # Matrix + MATRIX_HOMESERVER: str = os.environ.get("MATRIX_HOMESERVER", "http://192.168.86.192:8008") + MATRIX_ACCESS_TOKEN: str = os.environ.get("MATRIX_ACCESS_TOKEN", "") + MATRIX_ROOM_ID: str = os.environ.get("MATRIX_ROOM_ID", "") + + # Vision backends + CLIP_SERVICE_URL: str = os.environ.get("CLIP_SERVICE_URL", "http://192.168.86.196:5200") + OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://192.168.86.196:11434") + OLLAMA_VISION_MODEL: str = os.environ.get("OLLAMA_VISION_MODEL", "llava:13b") + + # Recognition thresholds + MATCH_HIGH_CONFIDENCE: float = float(os.environ.get("MATCH_HIGH_CONFIDENCE", "0.85")) + MATCH_LOW_CONFIDENCE: float = float(os.environ.get("MATCH_LOW_CONFIDENCE", "0.60")) + + # Filtering + ALLOWED_LABELS: set = {"person", "car", "dog", "cat", "bird"} + MIN_SCORE: float = 0.7 + RATE_LIMIT_SECONDS: int = 60 + DEDUP_WINDOW_SECONDS: int = 30 + + # Server + HOST: str = "0.0.0.0" + PORT: int = 5199 + + # Database + DB_PATH: str = os.environ.get("DB_PATH", "/data/frigate-notify.db") + +config = Config() +``` + +**Step 3: Create `app/database.py`** + +```python +import sqlite3 +import threading +from contextlib import contextmanager + +_local = threading.local() + + +def get_connection(db_path: str) -> sqlite3.Connection: + if not hasattr(_local, "connection") or _local.connection is None: + _local.connection = sqlite3.connect(db_path, check_same_thread=False) + _local.connection.row_factory = sqlite3.Row + _local.connection.execute("PRAGMA journal_mode=WAL") + _local.connection.execute("PRAGMA foreign_keys=ON") + return _local.connection + + +def init_db(db_path: str) -> None: + conn = sqlite3.connect(db_path) + conn.executescript(""" + CREATE TABLE IF NOT EXISTS known_objects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('person', 'vehicle', 'pet')), + action TEXT NOT NULL DEFAULT 'quiet' CHECK(action IN ('quiet', 'suppress', 'alert')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS embeddings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + known_object_id INTEGER NOT NULL REFERENCES known_objects(id) ON DELETE CASCADE, + embedding BLOB NOT NULL, + snapshot_url TEXT, + thumbnail BLOB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + frigate_event_id TEXT UNIQUE NOT NULL, + camera TEXT NOT NULL, + label TEXT NOT NULL, + score REAL NOT NULL, + snapshot BLOB, + embedding BLOB, + match_status TEXT NOT NULL DEFAULT 'unreviewed' + CHECK(match_status IN ('known', 'unknown', 'ambiguous', 'unreviewed')), + matched_object_id INTEGER REFERENCES known_objects(id) ON DELETE SET NULL, + match_confidence REAL, + ai_description TEXT, + notified INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at); + CREATE INDEX IF NOT EXISTS idx_events_status ON events(match_status); + CREATE INDEX IF NOT EXISTS idx_events_camera ON events(camera); + CREATE INDEX IF NOT EXISTS idx_embeddings_object ON embeddings(known_object_id); + """) + # Insert default settings if not present + defaults = { + "vision_backend": "haiku", + "recognition_enabled": "false", + "event_retention_days": "90", + } + for key, value in defaults.items(): + conn.execute( + "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", + (key, value), + ) + conn.commit() + conn.close() +``` + +**Step 4: Create `app/models.py`** + +```python +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + + +class KnownObjectCreate(BaseModel): + name: str + type: str # person, vehicle, pet + action: str = "quiet" # quiet, suppress, alert + + +class KnownObjectUpdate(BaseModel): + name: Optional[str] = None + type: Optional[str] = None + action: Optional[str] = None + + +class KnownObjectResponse(BaseModel): + id: int + name: str + type: str + action: str + created_at: str + updated_at: str + embedding_count: int = 0 + + +class TagEventRequest(BaseModel): + known_object_id: Optional[int] = None + name: Optional[str] = None + type: Optional[str] = None + + +class EventResponse(BaseModel): + id: int + frigate_event_id: str + camera: str + label: str + score: float + match_status: str + matched_object_id: Optional[int] = None + matched_object_name: Optional[str] = None + match_confidence: Optional[float] = None + ai_description: Optional[str] = None + notified: bool + created_at: str + + +class SettingsResponse(BaseModel): + vision_backend: str = "haiku" + recognition_enabled: bool = False + event_retention_days: int = 90 + match_high_confidence: float = 0.85 + match_low_confidence: float = 0.60 + rate_limit_seconds: int = 60 + allowed_labels: list[str] = ["person", "car", "dog", "cat", "bird"] + min_score: float = 0.7 + + +class StatsResponse(BaseModel): + total_events: int + known_matches: int + unknown_events: int + unreviewed: int + known_objects_count: int + events_today: int +``` + +**Step 5: Update `app/requirements.txt`** + +``` +paho-mqtt==2.1.0 +anthropic>=0.40.0 +aiohttp>=3.9.0 +fastapi>=0.115.0 +uvicorn[standard]>=0.34.0 +python-multipart>=0.0.18 +numpy>=1.26.0 +``` + +**Step 6: Rewrite `app/main.py` for FastAPI** + +This is the big refactor. The new main.py sets up FastAPI, mounts the MQTT listener as a background task, and serves both the API and static files. + +```python +import asyncio +import json +import logging +import os +import signal +import sys +import time +from collections import defaultdict +from threading import Thread + +import aiohttp +import anthropic +import paho.mqtt.client as mqtt +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse + +from config import config +from database import init_db, get_connection +from vision import analyze_snapshot +from matrix_client import MatrixClient + +# --- Logging --- +logging.basicConfig( + level=logging.INFO, + format='{"time":"%(asctime)s","level":"%(levelname)s","module":"%(module)s","message":"%(message)s"}', + stream=sys.stdout, +) +logger = logging.getLogger(__name__) + +# --- State --- +recent_events: dict[str, float] = {} +last_notify_per_camera: defaultdict[str, float] = defaultdict(float) +events_processed = 0 +last_event_time: str | None = None +mqtt_connected = False + +# --- App --- +app = FastAPI(title="Frigate Notify", version="2.0.0") + +# Mount static files for web UI +static_dir = os.path.join(os.path.dirname(__file__), "static") +if os.path.isdir(static_dir): + app.mount("/static", StaticFiles(directory=static_dir), name="static") + + +@app.on_event("startup") +async def startup(): + init_db(config.DB_PATH) + start_mqtt() + logger.info("Frigate-notify v2 started") + + +@app.get("/health") +async def health(): + return { + "status": "ok", + "version": "2.0.0", + "mqtt_connected": mqtt_connected, + "events_processed": events_processed, + "last_event": last_event_time, + } + + +@app.get("/") +async def index(): + return FileResponse(os.path.join(static_dir, "index.html")) + + +# --- Snapshot fetching --- +async def fetch_snapshot(event_id: str) -> bytes | None: + url = f"{config.FRIGATE_URL}/api/events/{event_id}/snapshot.jpg" + try: + async with aiohttp.ClientSession() as session: + async with session.get(url) as resp: + if resp.status == 200: + data = await resp.read() + logger.info("Fetched snapshot for %s: %d bytes", event_id, len(data)) + return data + logger.warning("Snapshot fetch failed for %s: HTTP %d", event_id, resp.status) + except Exception: + logger.exception("Snapshot fetch error for %s", event_id) + return None + + +# --- Event processing --- +async def process_event(event_data: dict, claude: anthropic.Anthropic, matrix: MatrixClient): + global events_processed, last_event_time + + event_type = event_data.get("type") + if event_type != "end": + return + + after = event_data.get("after", {}) + event_id = after.get("id", "") + camera = after.get("camera", "unknown") + label = after.get("label", "") + score = after.get("top_score", after.get("score", 0)) + has_snapshot = after.get("has_snapshot", False) + + if label not in config.ALLOWED_LABELS: + return + if score < config.MIN_SCORE: + return + if not has_snapshot: + return + + now = time.time() + if event_id in recent_events and now - recent_events[event_id] < config.DEDUP_WINDOW_SECONDS: + return + recent_events[event_id] = now + + if now - last_notify_per_camera[camera] < config.RATE_LIMIT_SECONDS: + logger.info("Rate limited: %s on %s", label, camera) + return + last_notify_per_camera[camera] = now + + cutoff = now - config.DEDUP_WINDOW_SECONDS * 2 + for eid in [k for k, v in recent_events.items() if v < cutoff]: + del recent_events[eid] + + logger.info("Processing event: %s on %s (%s, score=%.2f)", event_id, camera, label, score) + + snapshot = await fetch_snapshot(event_id) + + # Store event in database + snapshot_thumb = None + if snapshot: + # Store a smaller thumbnail (first 100KB max) + snapshot_thumb = snapshot[:102400] if len(snapshot) > 102400 else snapshot + + conn = get_connection(config.DB_PATH) + try: + conn.execute( + """INSERT OR IGNORE INTO events + (frigate_event_id, camera, label, score, snapshot, match_status) + VALUES (?, ?, ?, ?, ?, 'unreviewed')""", + (event_id, camera, label, score, snapshot_thumb), + ) + conn.commit() + except Exception: + logger.exception("Failed to store event %s", event_id) + + # AI description + description = None + if snapshot: + description = analyze_snapshot(claude, snapshot) + if not description: + description = f"{label.capitalize()} detected on {camera}." + + # Update event with description + try: + conn.execute( + "UPDATE events SET ai_description = ? WHERE frigate_event_id = ?", + (description, event_id), + ) + conn.commit() + except Exception: + logger.exception("Failed to update event description") + + # Format and send Matrix notification + score_pct = int(score * 100) + ts = time.strftime("%I:%M:%S %p", time.localtime()) + date_str = time.strftime("%b %d, %Y", time.localtime()) + frigate_link = f"{config.FRIGATE_URL}/review?id={event_id}" + + plain = ( + f"{camera} - {label.capitalize()} - {score_pct}%\n" + f"{description}\n\n" + f"View in Frigate: {frigate_link}\n" + f"{ts} - {date_str}" + ) + html = ( + f"<b>{camera}</b> · {label.capitalize()} · {score_pct}%<br/>" + f"{description}<br/><br/>" + f'<a href="{frigate_link}">View in Frigate</a><br/>' + f"<small>{ts} · {date_str}</small>" + ) + + if snapshot: + filename = f"{camera}_{event_id}.jpg" + mxc_uri = await matrix.upload_image(snapshot, filename) + if mxc_uri: + await matrix.send_image(mxc_uri, filename, len(snapshot)) + + await matrix.send_text(plain, html) + + # Mark as notified + try: + conn.execute( + "UPDATE events SET notified = 1 WHERE frigate_event_id = ?", + (event_id,), + ) + conn.commit() + except Exception: + pass + + events_processed += 1 + last_event_time = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + logger.info("Notification sent for %s on %s", label, camera) + + +# --- MQTT --- +def start_mqtt(): + global mqtt_connected + + claude = anthropic.Anthropic() + matrix = MatrixClient(config.MATRIX_HOMESERVER, config.MATRIX_ACCESS_TOKEN, config.MATRIX_ROOM_ID) + loop = asyncio.get_event_loop() + + def on_connect(client, userdata, flags, reason_code, properties): + global mqtt_connected + mqtt_connected = True + client.subscribe("frigate/events") + logger.info("MQTT connected, subscribed to frigate/events") + + def on_disconnect(client, userdata, flags, reason_code, properties): + global mqtt_connected + mqtt_connected = False + logger.warning("MQTT disconnected: %s", reason_code) + + def on_message(client, userdata, msg): + try: + payload = json.loads(msg.payload) + asyncio.run_coroutine_threadsafe(process_event(payload, claude, matrix), loop) + except json.JSONDecodeError: + logger.warning("Invalid JSON on %s", msg.topic) + except Exception: + logger.exception("Error processing MQTT message") + + mqtt_client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + mqtt_client.on_connect = on_connect + mqtt_client.on_disconnect = on_disconnect + mqtt_client.on_message = on_message + + def mqtt_loop(): + try: + mqtt_client.connect(config.MQTT_HOST, config.MQTT_PORT) + mqtt_client.loop_forever() + except Exception: + logger.exception("MQTT connection failed") + + thread = Thread(target=mqtt_loop, daemon=True) + thread.start() + + +# --- Run --- +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host=config.HOST, port=config.PORT, log_level="info") +``` + +**Step 7: Update Dockerfile** + +```dockerfile +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + +COPY app/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ . + +RUN mkdir -p /data + +EXPOSE 5199 + +CMD ["python", "-u", "main.py"] +``` + +**Step 8: Create `tests/conftest.py`** + +```python +import os +import sys +import tempfile +import pytest + +# Add app directory to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app")) + + +@pytest.fixture +def db_path(): + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + path = f.name + yield path + os.unlink(path) +``` + +**Step 9: Create `tests/test_database.py`** + +```python +from database import init_db, get_connection + + +def test_init_db_creates_tables(db_path): + init_db(db_path) + conn = get_connection(db_path) + tables = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ).fetchall() + table_names = [t["name"] for t in tables] + assert "known_objects" in table_names + assert "embeddings" in table_names + assert "events" in table_names + assert "settings" in table_names + + +def test_default_settings_inserted(db_path): + init_db(db_path) + conn = get_connection(db_path) + row = conn.execute("SELECT value FROM settings WHERE key='vision_backend'").fetchone() + assert row["value"] == "haiku" + + +def test_known_object_crud(db_path): + init_db(db_path) + conn = get_connection(db_path) + conn.execute( + "INSERT INTO known_objects (name, type) VALUES (?, ?)", + ("Jeremy", "person"), + ) + conn.commit() + row = conn.execute("SELECT * FROM known_objects WHERE name='Jeremy'").fetchone() + assert row["type"] == "person" + assert row["action"] == "quiet" + + +def test_event_insert(db_path): + init_db(db_path) + conn = get_connection(db_path) + conn.execute( + """INSERT INTO events (frigate_event_id, camera, label, score, match_status) + VALUES (?, ?, ?, ?, ?)""", + ("evt-123", "back_porch", "person", 0.92, "unreviewed"), + ) + conn.commit() + row = conn.execute("SELECT * FROM events WHERE frigate_event_id='evt-123'").fetchone() + assert row["camera"] == "back_porch" + assert row["score"] == 0.92 + + +def test_embedding_cascade_delete(db_path): + init_db(db_path) + conn = get_connection(db_path) + conn.execute("INSERT INTO known_objects (name, type) VALUES ('Dog', 'pet')") + obj_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0] + conn.execute( + "INSERT INTO embeddings (known_object_id, embedding) VALUES (?, ?)", + (obj_id, b"fake-embedding"), + ) + conn.commit() + conn.execute("DELETE FROM known_objects WHERE id=?", (obj_id,)) + conn.commit() + rows = conn.execute("SELECT * FROM embeddings WHERE known_object_id=?", (obj_id,)).fetchall() + assert len(rows) == 0 +``` + +**Step 10: Run tests** + +```bash +cd /mnt/c/Users/Jeremy/projects/frigate-notify +pip install pytest fastapi uvicorn python-multipart numpy paho-mqtt anthropic aiohttp +pytest tests/ -v +``` + +Expected: All 5 tests pass. + +**Step 11: Commit** + +```bash +git add -A +git commit -m "feat: scaffold v2 with FastAPI, SQLite database, and project structure" +git push origin main +``` + +--- + +## Task 2: REST API Endpoints + +**Goal:** Implement all CRUD API endpoints for events, known objects, and settings. + +**Files:** +- Create: `app/routes/__init__.py` +- Create: `app/routes/events.py` +- Create: `app/routes/known_objects.py` +- Create: `app/routes/settings.py` +- Modify: `app/main.py` (include routers) +- Create: `tests/test_api.py` + +**Step 1: Create `app/routes/__init__.py`** + +```python +# Routes package +``` + +**Step 2: Create `app/routes/events.py`** + +```python +import base64 +from fastapi import APIRouter, Query, HTTPException +from database import get_connection +from config import config +from models import EventResponse, TagEventRequest + +router = APIRouter(prefix="/api/events", tags=["events"]) + + +@router.get("") +async def list_events( + camera: str | None = None, + label: str | None = None, + status: str | None = None, + limit: int = Query(default=50, le=200), + offset: int = 0, +): + conn = get_connection(config.DB_PATH) + query = """ + SELECT e.*, ko.name as matched_object_name + FROM events e + LEFT JOIN known_objects ko ON e.matched_object_id = ko.id + WHERE 1=1 + """ + params = [] + if camera: + query += " AND e.camera = ?" + params.append(camera) + if label: + query += " AND e.label = ?" + params.append(label) + if status: + query += " AND e.match_status = ?" + params.append(status) + query += " ORDER BY e.created_at DESC LIMIT ? OFFSET ?" + params.extend([limit, offset]) + + rows = conn.execute(query, params).fetchall() + return [ + { + **{k: row[k] for k in row.keys() if k != "snapshot" and k != "embedding"}, + "has_snapshot": row["snapshot"] is not None, + "notified": bool(row["notified"]), + } + for row in rows + ] + + +@router.get("/{event_id}") +async def get_event(event_id: int): + conn = get_connection(config.DB_PATH) + row = conn.execute( + """SELECT e.*, ko.name as matched_object_name + FROM events e + LEFT JOIN known_objects ko ON e.matched_object_id = ko.id + WHERE e.id = ?""", + (event_id,), + ).fetchone() + if not row: + raise HTTPException(404, "Event not found") + result = {k: row[k] for k in row.keys() if k != "embedding"} + if row["snapshot"]: + result["snapshot"] = base64.b64encode(row["snapshot"]).decode() + else: + result["snapshot"] = None + result["notified"] = bool(row["notified"]) + return result + + +@router.get("/{event_id}/snapshot") +async def get_event_snapshot(event_id: int): + from fastapi.responses import Response + + conn = get_connection(config.DB_PATH) + row = conn.execute("SELECT snapshot FROM events WHERE id = ?", (event_id,)).fetchone() + if not row or not row["snapshot"]: + raise HTTPException(404, "Snapshot not found") + return Response(content=row["snapshot"], media_type="image/jpeg") + + +@router.post("/{event_id}/tag") +async def tag_event(event_id: int, body: TagEventRequest): + conn = get_connection(config.DB_PATH) + event = conn.execute("SELECT * FROM events WHERE id = ?", (event_id,)).fetchone() + if not event: + raise HTTPException(404, "Event not found") + + if body.known_object_id: + obj = conn.execute( + "SELECT * FROM known_objects WHERE id = ?", (body.known_object_id,) + ).fetchone() + if not obj: + raise HTTPException(404, "Known object not found") + object_id = body.known_object_id + elif body.name and body.type: + cursor = conn.execute( + "INSERT INTO known_objects (name, type) VALUES (?, ?)", + (body.name, body.type), + ) + object_id = cursor.lastrowid + else: + raise HTTPException(400, "Provide known_object_id or name+type") + + # Store embedding if event has one + if event["embedding"]: + conn.execute( + """INSERT INTO embeddings (known_object_id, embedding, thumbnail) + VALUES (?, ?, ?)""", + (object_id, event["embedding"], event["snapshot"]), + ) + + conn.execute( + """UPDATE events SET match_status = 'known', matched_object_id = ?, + match_confidence = 1.0 WHERE id = ?""", + (object_id, event_id), + ) + conn.commit() + return {"success": True, "known_object_id": object_id} + + +@router.post("/{event_id}/dismiss") +async def dismiss_event(event_id: int): + conn = get_connection(config.DB_PATH) + result = conn.execute( + "UPDATE events SET match_status = 'unknown' WHERE id = ?", (event_id,) + ) + conn.commit() + if result.rowcount == 0: + raise HTTPException(404, "Event not found") + return {"success": True} +``` + +**Step 3: Create `app/routes/known_objects.py`** + +```python +import base64 +from fastapi import APIRouter, HTTPException +from database import get_connection +from config import config +from models import KnownObjectCreate, KnownObjectUpdate + +router = APIRouter(prefix="/api/known-objects", tags=["known-objects"]) + + +@router.get("") +async def list_known_objects(type: str | None = None): + conn = get_connection(config.DB_PATH) + query = """ + SELECT ko.*, COUNT(e.id) as embedding_count + FROM known_objects ko + LEFT JOIN embeddings e ON ko.id = e.known_object_id + """ + params = [] + if type: + query += " WHERE ko.type = ?" + params.append(type) + query += " GROUP BY ko.id ORDER BY ko.name" + rows = conn.execute(query, params).fetchall() + return [dict(row) for row in rows] + + +@router.get("/{object_id}") +async def get_known_object(object_id: int): + conn = get_connection(config.DB_PATH) + obj = conn.execute("SELECT * FROM known_objects WHERE id = ?", (object_id,)).fetchone() + if not obj: + raise HTTPException(404, "Known object not found") + + embeddings = conn.execute( + "SELECT id, created_at FROM embeddings WHERE known_object_id = ?", + (object_id,), + ).fetchall() + + result = dict(obj) + result["embeddings"] = [dict(e) for e in embeddings] + return result + + +@router.get("/{object_id}/thumbnails/{embedding_id}") +async def get_thumbnail(object_id: int, embedding_id: int): + from fastapi.responses import Response + + conn = get_connection(config.DB_PATH) + row = conn.execute( + "SELECT thumbnail FROM embeddings WHERE id = ? AND known_object_id = ?", + (embedding_id, object_id), + ).fetchone() + if not row or not row["thumbnail"]: + raise HTTPException(404, "Thumbnail not found") + return Response(content=row["thumbnail"], media_type="image/jpeg") + + +@router.put("/{object_id}") +async def update_known_object(object_id: int, body: KnownObjectUpdate): + conn = get_connection(config.DB_PATH) + obj = conn.execute("SELECT * FROM known_objects WHERE id = ?", (object_id,)).fetchone() + if not obj: + raise HTTPException(404, "Known object not found") + + updates = {} + if body.name is not None: + updates["name"] = body.name + if body.type is not None: + updates["type"] = body.type + if body.action is not None: + updates["action"] = body.action + + if updates: + set_clause = ", ".join(f"{k} = ?" for k in updates) + values = list(updates.values()) + [object_id] + conn.execute( + f"UPDATE known_objects SET {set_clause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + values, + ) + conn.commit() + return {"success": True} + + +@router.delete("/{object_id}") +async def delete_known_object(object_id: int): + conn = get_connection(config.DB_PATH) + result = conn.execute("DELETE FROM known_objects WHERE id = ?", (object_id,)) + conn.commit() + if result.rowcount == 0: + raise HTTPException(404, "Known object not found") + return {"success": True} +``` + +**Step 4: Create `app/routes/settings.py`** + +```python +import json +from fastapi import APIRouter +from database import get_connection +from config import config +from models import StatsResponse + +router = APIRouter(prefix="/api", tags=["settings"]) + + +@router.get("/settings") +async def get_settings(): + conn = get_connection(config.DB_PATH) + rows = conn.execute("SELECT key, value FROM settings").fetchall() + result = {row["key"]: row["value"] for row in rows} + # Add runtime config values + result["match_high_confidence"] = str(config.MATCH_HIGH_CONFIDENCE) + result["match_low_confidence"] = str(config.MATCH_LOW_CONFIDENCE) + result["rate_limit_seconds"] = str(config.RATE_LIMIT_SECONDS) + result["allowed_labels"] = json.dumps(list(config.ALLOWED_LABELS)) + result["min_score"] = str(config.MIN_SCORE) + return result + + +@router.put("/settings") +async def update_settings(body: dict): + conn = get_connection(config.DB_PATH) + for key, value in body.items(): + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + (key, str(value)), + ) + conn.commit() + return {"success": True} + + +@router.get("/stats") +async def get_stats(): + conn = get_connection(config.DB_PATH) + total = conn.execute("SELECT COUNT(*) as c FROM events").fetchone()["c"] + known = conn.execute("SELECT COUNT(*) as c FROM events WHERE match_status='known'").fetchone()["c"] + unknown = conn.execute("SELECT COUNT(*) as c FROM events WHERE match_status='unknown'").fetchone()["c"] + unreviewed = conn.execute("SELECT COUNT(*) as c FROM events WHERE match_status='unreviewed'").fetchone()["c"] + objects = conn.execute("SELECT COUNT(*) as c FROM known_objects").fetchone()["c"] + today = conn.execute("SELECT COUNT(*) as c FROM events WHERE date(created_at)=date('now')").fetchone()["c"] + return StatsResponse( + total_events=total, + known_matches=known, + unknown_events=unknown, + unreviewed=unreviewed, + known_objects_count=objects, + events_today=today, + ) +``` + +**Step 5: Update `app/main.py` — add router includes after app creation** + +Add these lines after `app = FastAPI(...)`: + +```python +from routes.events import router as events_router +from routes.known_objects import router as known_objects_router +from routes.settings import router as settings_router + +app.include_router(events_router) +app.include_router(known_objects_router) +app.include_router(settings_router) +``` + +**Step 6: Create `tests/test_api.py`** + +```python +import os +import sys +import tempfile +import pytest +from fastapi.testclient import TestClient + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app")) + +from database import init_db + + +@pytest.fixture +def client(db_path): + os.environ["DB_PATH"] = db_path + # Must reimport after setting env + import importlib + import config as config_mod + importlib.reload(config_mod) + config_mod.config.DB_PATH = db_path + + init_db(db_path) + + from main import app + return TestClient(app) + + +def test_health(client): + resp = client.get("/health") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert data["version"] == "2.0.0" + + +def test_list_events_empty(client): + resp = client.get("/api/events") + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_stats_empty(client): + resp = client.get("/api/stats") + assert resp.status_code == 200 + data = resp.json() + assert data["total_events"] == 0 + + +def test_settings_get(client): + resp = client.get("/api/settings") + assert resp.status_code == 200 + data = resp.json() + assert data["vision_backend"] == "haiku" + + +def test_settings_update(client): + resp = client.put("/api/settings", json={"vision_backend": "ollama"}) + assert resp.status_code == 200 + resp = client.get("/api/settings") + assert resp.json()["vision_backend"] == "ollama" + + +def test_known_objects_crud(client): + # Create via tag + from database import get_connection + conn = get_connection(client.app.state if hasattr(client.app, 'state') else db_path) + # Use the API - create a known object indirectly + resp = client.get("/api/known-objects") + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_event_not_found(client): + resp = client.get("/api/events/999") + assert resp.status_code == 404 +``` + +**Step 7: Run tests** + +```bash +pytest tests/ -v +``` + +Expected: All tests pass. + +**Step 8: Commit** + +```bash +git add -A +git commit -m "feat: add REST API endpoints for events, known objects, and settings" +git push origin main +``` + +--- + +## Task 3: CLIP Service Sidecar + +**Goal:** Create a standalone FastAPI service that runs on the GPU machine (192.168.86.196), loads the CLIP model, and provides embedding generation endpoints. + +**Files:** +- Create: `clip-service/app/main.py` +- Create: `clip-service/app/requirements.txt` +- Create: `clip-service/Dockerfile` +- Create: `clip-service/docker-compose.yml` +- Create: `clip-service/tests/test_clip.py` + +**Step 1: Create `clip-service/app/requirements.txt`** + +``` +fastapi>=0.115.0 +uvicorn[standard]>=0.34.0 +open-clip-torch>=2.29.0 +torch>=2.5.0 +Pillow>=10.0.0 +numpy>=1.26.0 +python-multipart>=0.0.18 +``` + +**Step 2: Create `clip-service/app/main.py`** + +```python +import base64 +import io +import logging +import sys +import time + +import numpy as np +import open_clip +import torch +from fastapi import FastAPI, HTTPException +from PIL import Image +from pydantic import BaseModel + +logging.basicConfig( + level=logging.INFO, + format='{"time":"%(asctime)s","level":"%(levelname)s","message":"%(message)s"}', + stream=sys.stdout, +) +logger = logging.getLogger(__name__) + +app = FastAPI(title="CLIP Embedding Service", version="1.0.0") + +# Global model state +model = None +preprocess = None +tokenizer = None +device = None + + +class EmbedRequest(BaseModel): + image: str # base64-encoded JPEG + + +class EmbedResponse(BaseModel): + embedding: list[float] + inference_ms: float + + +class SimilarityRequest(BaseModel): + embedding1: list[float] + embedding2: list[float] + + +class SimilarityResponse(BaseModel): + score: float + + +class BatchSimilarityRequest(BaseModel): + query_embedding: list[float] + candidate_embeddings: list[list[float]] + + +class BatchSimilarityResponse(BaseModel): + scores: list[float] + best_index: int + best_score: float + + +@app.on_event("startup") +async def load_model(): + global model, preprocess, tokenizer, device + device = "cuda" if torch.cuda.is_available() else "cpu" + logger.info("Loading CLIP model on %s...", device) + model, _, preprocess = open_clip.create_model_and_transforms( + "ViT-L-14", pretrained="openai" + ) + model = model.to(device).eval() + tokenizer = open_clip.get_tokenizer("ViT-L-14") + logger.info("CLIP model loaded successfully on %s", device) + + +@app.get("/health") +async def health(): + return { + "status": "ok", + "device": str(device), + "model": "ViT-L-14", + } + + +@app.post("/embed", response_model=EmbedResponse) +async def embed_image(body: EmbedRequest): + try: + image_bytes = base64.b64decode(body.image) + image = Image.open(io.BytesIO(image_bytes)).convert("RGB") + except Exception as e: + raise HTTPException(400, f"Invalid image: {e}") + + start = time.time() + image_tensor = preprocess(image).unsqueeze(0).to(device) + with torch.no_grad(): + embedding = model.encode_image(image_tensor) + embedding = embedding / embedding.norm(dim=-1, keepdim=True) + elapsed_ms = (time.time() - start) * 1000 + + embedding_list = embedding.cpu().numpy().flatten().tolist() + logger.info("Generated embedding in %.1fms (%d dims)", elapsed_ms, len(embedding_list)) + return EmbedResponse(embedding=embedding_list, inference_ms=round(elapsed_ms, 1)) + + +@app.post("/similarity", response_model=SimilarityResponse) +async def compute_similarity(body: SimilarityRequest): + a = np.array(body.embedding1, dtype=np.float32) + b = np.array(body.embedding2, dtype=np.float32) + score = float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))) + return SimilarityResponse(score=score) + + +@app.post("/batch-similarity", response_model=BatchSimilarityResponse) +async def batch_similarity(body: BatchSimilarityRequest): + query = np.array(body.query_embedding, dtype=np.float32) + candidates = np.array(body.candidate_embeddings, dtype=np.float32) + query_norm = query / np.linalg.norm(query) + cand_norms = candidates / np.linalg.norm(candidates, axis=1, keepdims=True) + scores = (cand_norms @ query_norm).tolist() + best_idx = int(np.argmax(scores)) + return BatchSimilarityResponse( + scores=scores, best_index=best_idx, best_score=scores[best_idx] + ) +``` + +**Step 3: Create `clip-service/Dockerfile`** + +```dockerfile +FROM pytorch/pytorch:2.5.1-cuda12.4-cudnn9-runtime + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + +COPY app/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ . + +EXPOSE 5200 + +CMD ["python", "-u", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5200"] +``` + +**Step 4: Create `clip-service/docker-compose.yml`** + +```yaml +services: + clip-service: + build: + context: . + dockerfile: Dockerfile + container_name: clip-service + restart: unless-stopped + ports: + - "5200:5200" + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] + mem_limit: 4G + memswap_limit: 4G + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:5200/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 120s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" +``` + +**Step 5: Create `clip-service/tests/test_clip.py`** + +```python +import numpy as np + + +def test_cosine_similarity_identical(): + """Test that identical embeddings have similarity 1.0""" + a = np.random.randn(768).astype(np.float32) + a = a / np.linalg.norm(a) + score = float(np.dot(a, a)) + assert abs(score - 1.0) < 1e-5 + + +def test_cosine_similarity_orthogonal(): + """Test that orthogonal embeddings have similarity ~0""" + a = np.zeros(768, dtype=np.float32) + a[0] = 1.0 + b = np.zeros(768, dtype=np.float32) + b[1] = 1.0 + score = float(np.dot(a, b)) + assert abs(score) < 1e-5 + + +def test_batch_similarity(): + """Test batch similarity returns correct best match""" + query = np.random.randn(768).astype(np.float32) + query = query / np.linalg.norm(query) + # Candidate 0: random (low similarity) + c0 = np.random.randn(768).astype(np.float32) + c0 = c0 / np.linalg.norm(c0) + # Candidate 1: same as query (high similarity) + c1 = query.copy() + candidates = np.array([c0, c1]) + query_norm = query / np.linalg.norm(query) + cand_norms = candidates / np.linalg.norm(candidates, axis=1, keepdims=True) + scores = (cand_norms @ query_norm).tolist() + best_idx = int(np.argmax(scores)) + assert best_idx == 1 + assert scores[1] > 0.99 +``` + +**Step 6: Run CLIP tests locally** + +```bash +cd /mnt/c/Users/Jeremy/projects/frigate-notify +pip install numpy +pytest clip-service/tests/ -v +``` + +Expected: All 3 tests pass (these are pure numpy tests, no GPU needed). + +**Step 7: Commit** + +```bash +git add clip-service/ +git commit -m "feat: add CLIP embedding service sidecar for GPU machine" +git push origin main +``` + +--- + +## Task 4: Recognition Engine + +**Goal:** Add the recognition pipeline that uses CLIP embeddings to match events against known objects, with vision LLM fallback for ambiguous cases. + +**Files:** +- Create: `app/recognition.py` +- Create: `app/ollama_client.py` +- Modify: `app/vision.py` (add Ollama support) +- Create: `tests/test_recognition.py` + +**Step 1: Create `app/ollama_client.py`** + +```python +import base64 +import logging +import aiohttp + +logger = logging.getLogger(__name__) + + +class OllamaClient: + def __init__(self, base_url: str, model: str): + self.base_url = base_url.rstrip("/") + self.model = model + + async def analyze_image(self, image_bytes: bytes, prompt: str) -> str | None: + """Send image to Ollama vision model for analysis.""" + b64 = base64.b64encode(image_bytes).decode("utf-8") + payload = { + "model": self.model, + "prompt": prompt, + "images": [b64], + "stream": False, + "options": {"num_predict": 200}, + } + try: + async with aiohttp.ClientSession() as session: + async with session.post( + f"{self.base_url}/api/generate", + json=payload, + timeout=aiohttp.ClientTimeout(total=30), + ) as resp: + if resp.status == 200: + data = await resp.json() + return data.get("response", "").strip() + logger.warning("Ollama returned %d", resp.status) + except Exception: + logger.exception("Ollama request failed") + return None + + async def compare_images( + self, image_bytes: bytes, reference_bytes: bytes, object_name: str + ) -> bool | None: + """Ask vision LLM if two images show the same person/vehicle/pet.""" + b64_current = base64.b64encode(image_bytes).decode("utf-8") + b64_ref = base64.b64encode(reference_bytes).decode("utf-8") + prompt = ( + f"Look at these two security camera images. The first is a new detection. " + f"The second is a reference image of '{object_name}'. " + f"Are they the same person/vehicle/animal? " + f"Answer ONLY 'yes' or 'no'." + ) + payload = { + "model": self.model, + "prompt": prompt, + "images": [b64_current, b64_ref], + "stream": False, + "options": {"num_predict": 10}, + } + try: + async with aiohttp.ClientSession() as session: + async with session.post( + f"{self.base_url}/api/generate", + json=payload, + timeout=aiohttp.ClientTimeout(total=30), + ) as resp: + if resp.status == 200: + data = await resp.json() + answer = data.get("response", "").strip().lower() + if "yes" in answer: + return True + if "no" in answer: + return False + logger.warning("Ollama compare returned %d", resp.status) + except Exception: + logger.exception("Ollama compare failed") + return None + + async def is_available(self) -> bool: + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{self.base_url}/api/tags", + timeout=aiohttp.ClientTimeout(total=3), + ) as resp: + return resp.status == 200 + except Exception: + return False +``` + +**Step 2: Create `app/recognition.py`** + +```python +import base64 +import logging +import struct + +import aiohttp +import numpy as np + +from config import config +from database import get_connection + +logger = logging.getLogger(__name__) + + +def embedding_to_bytes(embedding: list[float]) -> bytes: + return struct.pack(f"{len(embedding)}f", *embedding) + + +def bytes_to_embedding(data: bytes) -> np.ndarray: + count = len(data) // 4 + return np.array(struct.unpack(f"{count}f", data), dtype=np.float32) + + +async def get_clip_embedding(image_bytes: bytes) -> list[float] | None: + """Get CLIP embedding from the clip-service sidecar.""" + b64 = base64.b64encode(image_bytes).decode("utf-8") + try: + async with aiohttp.ClientSession() as session: + async with session.post( + f"{config.CLIP_SERVICE_URL}/embed", + json={"image": b64}, + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + if resp.status == 200: + data = await resp.json() + logger.info("CLIP embedding: %.1fms", data["inference_ms"]) + return data["embedding"] + logger.warning("CLIP service returned %d", resp.status) + except Exception: + logger.exception("CLIP service unavailable") + return None + + +async def find_best_match( + query_embedding: list[float], +) -> tuple[int | None, float, str | None]: + """Compare query embedding against all stored embeddings. + + Returns (known_object_id, best_score, object_name) or (None, 0.0, None). + """ + conn = get_connection(config.DB_PATH) + rows = conn.execute( + """SELECT e.known_object_id, e.embedding, ko.name + FROM embeddings e + JOIN known_objects ko ON e.known_object_id = ko.id""" + ).fetchall() + + if not rows: + return None, 0.0, None + + query = np.array(query_embedding, dtype=np.float32) + query = query / np.linalg.norm(query) + + best_score = 0.0 + best_object_id = None + best_name = None + + # Group by object, take max score per object + object_scores: dict[int, tuple[float, str]] = {} + for row in rows: + candidate = bytes_to_embedding(row["embedding"]) + candidate = candidate / np.linalg.norm(candidate) + score = float(np.dot(query, candidate)) + obj_id = row["known_object_id"] + if obj_id not in object_scores or score > object_scores[obj_id][0]: + object_scores[obj_id] = (score, row["name"]) + + if object_scores: + best_object_id = max(object_scores, key=lambda k: object_scores[k][0]) + best_score, best_name = object_scores[best_object_id] + + return best_object_id, best_score, best_name + + +async def recognize_event( + image_bytes: bytes, ollama_client=None +) -> tuple[str, int | None, float, str | None]: + """Run the full recognition pipeline. + + Returns (match_status, matched_object_id, confidence, object_name). + """ + # Step 1: Get CLIP embedding + embedding = await get_clip_embedding(image_bytes) + if embedding is None: + return "unreviewed", None, 0.0, None + + # Step 2: Find best match + object_id, score, name = await find_best_match(embedding) + + if object_id is None: + return "unknown", None, 0.0, None + + # Step 3: Apply confidence thresholds + if score >= config.MATCH_HIGH_CONFIDENCE: + logger.info("High confidence match: %s (%.2f)", name, score) + return "known", object_id, score, name + + if score < config.MATCH_LOW_CONFIDENCE: + logger.info("Low confidence, treating as unknown (%.2f)", score) + return "unknown", None, score, None + + # Step 4: Ambiguous — ask vision LLM + logger.info("Ambiguous match: %s (%.2f), consulting vision LLM", name, score) + if ollama_client: + # Get a reference thumbnail for this object + conn = get_connection(config.DB_PATH) + ref = conn.execute( + "SELECT thumbnail FROM embeddings WHERE known_object_id = ? AND thumbnail IS NOT NULL LIMIT 1", + (object_id,), + ).fetchone() + if ref and ref["thumbnail"]: + result = await ollama_client.compare_images( + image_bytes, ref["thumbnail"], name + ) + if result is True: + logger.info("Vision LLM confirmed: %s", name) + return "known", object_id, score, name + elif result is False: + logger.info("Vision LLM rejected: %s", name) + return "unknown", None, score, None + + # LLM unavailable or inconclusive — mark ambiguous for human review + return "ambiguous", object_id, score, name +``` + +**Step 3: Modify `app/vision.py` — add Ollama support** + +```python +import base64 +import logging +import anthropic + +logger = logging.getLogger(__name__) + +PROMPT = ( + "Briefly describe what's happening in this security camera image in 1-2 sentences. " + "Focus on the people, vehicles, or animals and their activity. Be concise." +) + + +def analyze_snapshot(client: anthropic.Anthropic, image_bytes: bytes) -> str | None: + """Send a snapshot to Claude Haiku for description.""" + try: + b64 = base64.standard_b64encode(image_bytes).decode("utf-8") + response = client.messages.create( + model="claude-haiku-4-5-20251001", + max_tokens=150, + messages=[ + { + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/jpeg", + "data": b64, + }, + }, + {"type": "text", "text": PROMPT}, + ], + } + ], + ) + description = response.content[0].text.strip() + logger.info("Haiku analysis complete: %d chars", len(description)) + return description + except Exception: + logger.exception("Claude Vision API failed") + return None + + +async def analyze_snapshot_ollama(ollama_client, image_bytes: bytes) -> str | None: + """Send a snapshot to Ollama vision model for description.""" + return await ollama_client.analyze_image(image_bytes, PROMPT) + + +async def get_description( + image_bytes: bytes, + vision_backend: str, + claude_client=None, + ollama_client=None, +) -> str | None: + """Get scene description using configured backend.""" + if vision_backend == "ollama" and ollama_client: + result = await analyze_snapshot_ollama(ollama_client, image_bytes) + if result: + return result + # Fall through to haiku if ollama fails + if claude_client: + return analyze_snapshot(claude_client, image_bytes) + elif vision_backend == "haiku" and claude_client: + return analyze_snapshot(claude_client, image_bytes) + elif vision_backend == "both" and ollama_client: + result = await analyze_snapshot_ollama(ollama_client, image_bytes) + if result: + return result + if claude_client: + return analyze_snapshot(claude_client, image_bytes) + elif claude_client: + return analyze_snapshot(claude_client, image_bytes) + return None +``` + +**Step 4: Create `tests/test_recognition.py`** + +```python +import struct +import numpy as np +from recognition import embedding_to_bytes, bytes_to_embedding + + +def test_embedding_roundtrip(): + original = np.random.randn(768).astype(np.float32).tolist() + blob = embedding_to_bytes(original) + recovered = bytes_to_embedding(blob) + np.testing.assert_array_almost_equal(recovered, original, decimal=5) + + +def test_embedding_to_bytes_length(): + embedding = [0.0] * 768 + blob = embedding_to_bytes(embedding) + assert len(blob) == 768 * 4 # float32 = 4 bytes each + + +def test_bytes_to_embedding_shape(): + blob = struct.pack("768f", *([0.0] * 768)) + result = bytes_to_embedding(blob) + assert result.shape == (768,) + assert result.dtype == np.float32 +``` + +**Step 5: Run tests** + +```bash +pytest tests/ -v +``` + +Expected: All tests pass. + +**Step 6: Commit** + +```bash +git add -A +git commit -m "feat: add recognition engine with CLIP matching and Ollama vision LLM" +git push origin main +``` + +--- + +## Task 5: Integrate Recognition into Event Pipeline + +**Goal:** Wire the recognition engine into the MQTT event processing flow so events are automatically matched against known objects before deciding whether to send Matrix alerts. + +**Files:** +- Modify: `app/main.py` (integrate recognition into process_event) +- Modify: `app/docker-compose.yml` (add CLIP/Ollama env vars) +- Create: `tests/test_event_flow.py` + +**Step 1: Update `app/main.py` process_event function** + +Replace the existing `process_event` function body (after snapshot fetch) with recognition-aware logic. The key change: before sending a Matrix notification, check if recognition is enabled in settings, run the recognition pipeline, and only notify for unknown objects. + +Add imports at top of main.py: + +```python +from recognition import recognize_event, get_clip_embedding, embedding_to_bytes +from ollama_client import OllamaClient +from vision import get_description +``` + +Add ollama_client initialization in startup: + +```python +ollama_client = OllamaClient(config.OLLAMA_URL, config.OLLAMA_VISION_MODEL) +``` + +Update process_event to accept and use ollama_client, and add recognition logic between snapshot fetch and notification: + +```python + # After fetching snapshot and storing event... + + # Check if recognition is enabled + recognition_enabled = False + try: + row = conn.execute("SELECT value FROM settings WHERE key='recognition_enabled'").fetchone() + if row: + recognition_enabled = row["value"].lower() == "true" + except Exception: + pass + + # Run recognition if enabled + match_status = "unreviewed" + matched_id = None + match_conf = 0.0 + matched_name = None + + if recognition_enabled and snapshot: + match_status, matched_id, match_conf, matched_name = await recognize_event( + snapshot, ollama_client + ) + # Store embedding + embedding = await get_clip_embedding(snapshot) + if embedding: + embedding_blob = embedding_to_bytes(embedding) + try: + conn.execute( + "UPDATE events SET embedding=?, match_status=?, matched_object_id=?, match_confidence=? WHERE frigate_event_id=?", + (embedding_blob, match_status, matched_id, match_conf, event_id), + ) + conn.commit() + except Exception: + logger.exception("Failed to update event recognition") + + # If known, check action + if match_status == "known" and matched_id: + obj = conn.execute("SELECT action FROM known_objects WHERE id=?", (matched_id,)).fetchone() + if obj and obj["action"] in ("quiet", "suppress"): + logger.info("Known object '%s' (%s), skipping notification", matched_name, obj["action"]) + events_processed += 1 + last_event_time = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + return + + # Get AI description using configured backend + vision_backend = "haiku" + try: + row = conn.execute("SELECT value FROM settings WHERE key='vision_backend'").fetchone() + if row: + vision_backend = row["value"] + except Exception: + pass + + description = await get_description(snapshot, vision_backend, claude, ollama_client) if snapshot else None + if not description: + description = f"{label.capitalize()} detected on {camera}." + + # ... rest of notification code unchanged ... +``` + +**Step 2: Update `docker-compose.yml`** + +```yaml +services: + frigate-notify: + build: + context: . + dockerfile: Dockerfile + container_name: frigate-notify + restart: unless-stopped + network_mode: host + environment: + - TZ=America/Chicago + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - MATRIX_ACCESS_TOKEN=${MATRIX_ACCESS_TOKEN} + - MATRIX_ROOM_ID=${MATRIX_ROOM_ID} + - FRIGATE_URL=http://192.168.86.192:5000 + - MQTT_HOST=192.168.86.192 + - MQTT_PORT=1883 + - MATRIX_HOMESERVER=http://192.168.86.167:8008 + - CLIP_SERVICE_URL=http://192.168.86.196:5200 + - OLLAMA_URL=http://192.168.86.196:11434 + - OLLAMA_VISION_MODEL=llava:13b + - DB_PATH=/data/frigate-notify.db + volumes: + - /mnt/docker-data/configs/frigate-notify:/data + mem_limit: 256M + memswap_limit: 256M + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:5199/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" +``` + +**Step 3: Commit** + +```bash +git add -A +git commit -m "feat: integrate recognition engine into MQTT event pipeline" +git push origin main +``` + +--- + +## Task 6: Web UI — Dashboard & Event Review + +**Goal:** Create the vanilla HTML/CSS/JS web interface with Dashboard and Review Queue pages. + +**Files:** +- Create: `app/static/index.html` +- Create: `app/static/style.css` +- Create: `app/static/app.js` + +**Step 1: Create `app/static/index.html`** + +```html +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Frigate Alerts + + + + +
+
+
+ + + + + + +``` + +**Step 2: Create `app/static/style.css`** + +```css +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #1a1a2e; + color: #e0e0e0; + display: flex; + min-height: 100vh; +} + +#sidebar { + width: 220px; + background: #16213e; + padding: 16px 0; + flex-shrink: 0; + border-right: 1px solid #0f3460; +} + +.sidebar-header { padding: 0 16px 16px; border-bottom: 1px solid #0f3460; } +.sidebar-header h2 { font-size: 18px; color: #e94560; } + +.sidebar-section { padding: 8px 0; } + +.sidebar-item { + display: block; + padding: 10px 16px; + color: #a0a0b0; + text-decoration: none; + font-size: 14px; + transition: background 0.2s; +} +.sidebar-item:hover { background: #1a1a40; color: #fff; } +.sidebar-item.active { background: #0f3460; color: #e94560; font-weight: 600; } + +.sidebar-stats { padding: 16px; font-size: 12px; color: #808090; } + +#content { flex: 1; padding: 24px; overflow-y: auto; } + +.page-title { font-size: 24px; margin-bottom: 16px; } + +.filters { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } +.filters select, .filters input { + background: #16213e; color: #e0e0e0; border: 1px solid #0f3460; + padding: 6px 10px; border-radius: 4px; font-size: 13px; +} + +.event-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; } + +.event-card { + background: #16213e; + border: 1px solid #0f3460; + border-radius: 8px; + overflow: hidden; + transition: border-color 0.2s; +} +.event-card:hover { border-color: #e94560; } + +.event-card img { + width: 100%; + height: 180px; + object-fit: cover; + background: #0a0a1a; +} + +.event-card .event-info { padding: 12px; } +.event-card .event-header { display: flex; justify-content: space-between; margin-bottom: 6px; } +.event-card .event-camera { font-weight: 600; font-size: 14px; } +.event-card .event-label { + font-size: 12px; padding: 2px 8px; border-radius: 12px; + background: #0f3460; color: #e0e0e0; +} +.event-card .event-description { font-size: 13px; color: #a0a0b0; margin: 6px 0; } +.event-card .event-meta { font-size: 11px; color: #606070; } + +.event-card .event-actions { display: flex; gap: 8px; margin-top: 8px; } + +.status-known { border-left: 3px solid #4caf50; } +.status-unknown { border-left: 3px solid #e94560; } +.status-ambiguous { border-left: 3px solid #ff9800; } +.status-unreviewed { border-left: 3px solid #2196f3; } + +.btn { + padding: 6px 14px; border: none; border-radius: 4px; cursor: pointer; + font-size: 13px; transition: background 0.2s; +} +.btn-primary { background: #e94560; color: #fff; } +.btn-primary:hover { background: #c73450; } +.btn-success { background: #4caf50; color: #fff; } +.btn-success:hover { background: #3d9140; } +.btn-danger { background: #f44336; color: #fff; } +.btn { background: #0f3460; color: #e0e0e0; } +.btn:hover { background: #1a4a7a; } + +.known-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px; } + +.known-card { + background: #16213e; border: 1px solid #0f3460; border-radius: 8px; padding: 16px; +} +.known-card h4 { margin-bottom: 8px; } +.known-card .known-type { font-size: 12px; color: #a0a0b0; margin-bottom: 8px; } +.known-card .known-thumbs { display: flex; gap: 4px; flex-wrap: wrap; margin: 8px 0; } +.known-card .known-thumbs img { width: 50px; height: 50px; object-fit: cover; border-radius: 4px; } + +.settings-form { max-width: 500px; } +.form-group { margin-bottom: 16px; } +.form-group label { display: block; margin-bottom: 4px; font-size: 13px; color: #a0a0b0; } +.form-group select, .form-group input { + width: 100%; background: #16213e; color: #e0e0e0; border: 1px solid #0f3460; + padding: 8px 10px; border-radius: 4px; font-size: 14px; +} + +.modal { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 100; } +.modal.hidden { display: none; } +.modal-content { background: #16213e; border-radius: 8px; padding: 24px; min-width: 400px; max-width: 500px; } +.modal-content h3 { margin-bottom: 16px; } +.modal-content img { width: 100%; max-height: 200px; object-fit: cover; border-radius: 4px; margin-bottom: 12px; } +.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; } + +.empty-state { text-align: center; padding: 60px 20px; color: #606070; } +.empty-state h3 { margin-bottom: 8px; color: #a0a0b0; } +``` + +**Step 3: Create `app/static/app.js`** + +This is the main UI logic. It handles navigation, data fetching, rendering, and the tag modal. The file will be ~400 lines. Key sections: + +```javascript +let currentView = "dashboard"; +let eventsData = []; +let knownObjects = []; +let stats = {}; + +// --- Navigation --- +document.querySelectorAll(".sidebar-item").forEach(item => { + item.addEventListener("click", e => { + e.preventDefault(); + currentView = item.dataset.view; + document.querySelectorAll(".sidebar-item").forEach(i => i.classList.remove("active")); + item.classList.add("active"); + renderMain(); + }); +}); + +// --- Data fetching --- +async function fetchEvents(params = {}) { + const query = new URLSearchParams(params).toString(); + const resp = await fetch(`/api/events?${query}`); + return resp.json(); +} + +async function fetchEvent(id) { + const resp = await fetch(`/api/events/${id}`); + return resp.json(); +} + +async function fetchKnownObjects() { + const resp = await fetch("/api/known-objects"); + return resp.json(); +} + +async function fetchStats() { + const resp = await fetch("/api/stats"); + return resp.json(); +} + +async function fetchSettings() { + const resp = await fetch("/api/settings"); + return resp.json(); +} + +// --- Rendering --- +async function renderMain() { + const container = document.getElementById("main-content"); + stats = await fetchStats(); + renderStats(); + + switch (currentView) { + case "dashboard": await renderDashboard(container); break; + case "review": await renderReview(container); break; + case "known": await renderKnown(container); break; + case "settings": await renderSettings(container); break; + } +} + +function renderStats() { + document.getElementById("stats-summary").innerHTML = ` +
Today: ${stats.events_today} events
+
Known: ${stats.known_matches}
+
Unknown: ${stats.unknown_events}
+
Review: ${stats.unreviewed}
+ `; +} + +async function renderDashboard(container) { + eventsData = await fetchEvents({ limit: 50 }); + container.innerHTML = ` +

Dashboard

+
+ + +
+
+ `; + renderEventGrid(eventsData); +} + +function renderEventGrid(events) { + const grid = document.getElementById("event-grid"); + if (!events.length) { + grid.innerHTML = '

No events

Events will appear here when Frigate detects objects.

'; + return; + } + grid.innerHTML = events.map(e => ` +
+ ${e.has_snapshot + ? `${e.camera}` + : '
No snapshot
'} +
+
+ ${e.camera} + ${e.label} ${Math.round(e.score * 100)}% +
+
${e.ai_description || ""}
+ ${e.matched_object_name ? `
Matched: ${e.matched_object_name} (${Math.round((e.match_confidence || 0) * 100)}%)
` : ""} +
${new Date(e.created_at).toLocaleString()}
+
+ + +
+
+
+ `).join(""); +} + +async function applyFilters() { + const camera = document.getElementById("filter-camera").value; + const status = document.getElementById("filter-status").value; + const params = { limit: 50 }; + if (camera) params.camera = camera; + if (status) params.status = status; + const filtered = await fetchEvents(params); + renderEventGrid(filtered); +} + +async function renderReview(container) { + const events = await fetchEvents({ status: "unreviewed", limit: 50 }); + container.innerHTML = ` +

Review Queue (${events.length})

+
+ `; + renderEventGrid(events); +} + +async function renderKnown(container) { + knownObjects = await fetchKnownObjects(); + container.innerHTML = ` +

Known Objects (${knownObjects.length})

+
+ ${knownObjects.length === 0 + ? '

No known objects

Tag events from the Dashboard or Review Queue to add objects here.

' + : knownObjects.map(obj => ` +
+

${obj.name}

+
${obj.type} · ${obj.embedding_count} samples
+
+ +
+
+ `).join("")} +
+ `; +} + +async function renderSettings(container) { + const settings = await fetchSettings(); + container.innerHTML = ` +

Settings

+
+
+ + +
+
+ + +
+
+ + +
+
+ `; +} + +// --- Actions --- +async function dismissEvent(id) { + await fetch(`/api/events/${id}/dismiss`, { method: "POST" }); + renderMain(); +} + +async function deleteKnownObject(id) { + if (!confirm("Delete this known object and all its reference data?")) return; + await fetch(`/api/known-objects/${id}`, { method: "DELETE" }); + renderMain(); +} + +async function saveSetting(key, value) { + await fetch("/api/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ [key]: value }), + }); +} + +// --- Tag Modal --- +let tagEventId = null; + +async function openTagModal(eventId) { + tagEventId = eventId; + const modal = document.getElementById("tag-modal"); + const snapshotDiv = document.getElementById("tag-modal-snapshot"); + snapshotDiv.innerHTML = ``; + + // Populate existing objects dropdown + const objects = await fetchKnownObjects(); + const select = document.getElementById("tag-existing-object"); + select.innerHTML = '' + + objects.map(o => ``).join(""); + + select.onchange = () => { + document.getElementById("tag-new-fields").style.display = select.value ? "none" : "block"; + }; + document.getElementById("tag-new-fields").style.display = "block"; + modal.classList.remove("hidden"); +} + +document.getElementById("tag-cancel").addEventListener("click", () => { + document.getElementById("tag-modal").classList.add("hidden"); +}); + +document.getElementById("tag-save").addEventListener("click", async () => { + const existingId = document.getElementById("tag-existing-object").value; + let body; + if (existingId) { + body = { known_object_id: parseInt(existingId) }; + } else { + const name = document.getElementById("tag-name").value.trim(); + const type = document.getElementById("tag-type").value; + if (!name) { alert("Please enter a name"); return; } + body = { name, type }; + } + await fetch(`/api/events/${tagEventId}/tag`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + document.getElementById("tag-modal").classList.add("hidden"); + renderMain(); +}); + +// --- Auto-refresh --- +setInterval(() => { if (currentView === "dashboard") renderMain(); }, 30000); + +// --- Init --- +renderMain(); +``` + +**Step 4: Commit** + +```bash +git add app/static/ +git commit -m "feat: add web UI with dashboard, review queue, known objects, and settings pages" +git push origin main +``` + +--- + +## Task 7: Event Retention & Cleanup + +**Goal:** Add automatic cleanup of old events based on the retention setting. + +**Files:** +- Create: `app/cleanup.py` +- Modify: `app/main.py` (schedule cleanup on startup) +- Create: `tests/test_cleanup.py` + +**Step 1: Create `app/cleanup.py`** + +```python +import asyncio +import logging +from database import get_connection +from config import config + +logger = logging.getLogger(__name__) + + +async def cleanup_old_events(): + """Delete events older than the configured retention period.""" + while True: + try: + conn = get_connection(config.DB_PATH) + row = conn.execute( + "SELECT value FROM settings WHERE key='event_retention_days'" + ).fetchone() + days = int(row["value"]) if row else 90 + + result = conn.execute( + "DELETE FROM events WHERE created_at < datetime('now', ? || ' days')", + (f"-{days}",), + ) + conn.commit() + if result.rowcount > 0: + logger.info("Cleaned up %d events older than %d days", result.rowcount, days) + except Exception: + logger.exception("Cleanup failed") + + await asyncio.sleep(3600) # Run every hour +``` + +**Step 2: Add to `app/main.py` startup** + +```python +from cleanup import cleanup_old_events + +@app.on_event("startup") +async def startup(): + init_db(config.DB_PATH) + start_mqtt() + asyncio.create_task(cleanup_old_events()) + logger.info("Frigate-notify v2 started") +``` + +**Step 3: Create `tests/test_cleanup.py`** + +```python +import sqlite3 +from database import init_db + + +def test_cleanup_deletes_old_events(db_path): + init_db(db_path) + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + # Insert an old event + conn.execute( + """INSERT INTO events (frigate_event_id, camera, label, score, match_status, created_at) + VALUES ('old-1', 'cam', 'person', 0.9, 'unknown', datetime('now', '-100 days'))""" + ) + # Insert a recent event + conn.execute( + """INSERT INTO events (frigate_event_id, camera, label, score, match_status) + VALUES ('new-1', 'cam', 'person', 0.9, 'unknown')""" + ) + conn.commit() + + result = conn.execute( + "DELETE FROM events WHERE created_at < datetime('now', '-90 days')" + ) + conn.commit() + assert result.rowcount == 1 + + remaining = conn.execute("SELECT COUNT(*) as c FROM events").fetchone()["c"] + assert remaining == 1 + conn.close() +``` + +**Step 4: Run tests and commit** + +```bash +pytest tests/ -v +git add -A +git commit -m "feat: add automatic event cleanup based on retention setting" +git push origin main +``` + +--- + +## Task 8: Farm Manager Sidebar Link + +**Goal:** Add a "Frigate Alerts" link to the Farm Manager "Server Links" page. + +**Files:** +- Modify: `/mnt/c/Users/Jeremy/projects/farm-manager/server/static/app.js` (add link to serverLinks array) + +**Step 1: Add link entry** + +In `app.js` around line 734, the `serverLinks` array has categories. Add to the appropriate node category (hf-pdocker-01 section): + +```javascript +{ name: "Frigate Alerts", url: "http://192.168.86.192:5199", icon: "\uD83D\uDD14" }, +``` + +This adds a bell icon link to the frigate-notify v2 web UI. + +**Step 2: Commit in the farm-manager repo** + +```bash +cd /mnt/c/Users/Jeremy/projects/farm-manager +git add server/static/app.js +git commit -m "feat: add Frigate Alerts link to server links page" +git push origin main +``` + +--- + +## Task 9: Pull Ollama Vision Model + +**Goal:** Pull `llava:13b` on the GPU machine so vision LLM is ready. + +**Step 1: Pull the model** + +```bash +curl -X POST http://192.168.86.196:11434/api/pull -d '{"name":"llava:13b"}' +``` + +This will take several minutes to download (~8GB). Verify with: + +```bash +curl http://192.168.86.196:11434/api/tags | python3 -m json.tool +``` + +Expected: `llava:13b` appears in the model list. + +--- + +## Task 10: Deploy clip-service to GPU Machine + +**Goal:** Build and deploy the CLIP service container on 192.168.86.196. + +**Step 1: Copy clip-service files to GPU machine** + +```bash +scp -r /mnt/c/Users/Jeremy/projects/frigate-notify/clip-service/ glyptic@192.168.86.196:/tmp/clip-service/ +``` + +**Step 2: Build and start on GPU machine** + +```bash +ssh glyptic@192.168.86.196 +cd /tmp/clip-service +docker compose up -d --build +``` + +**Step 3: Verify** + +```bash +curl http://192.168.86.196:5200/health +``` + +Expected: `{"status":"ok","device":"cuda","model":"ViT-L-14"}` + +**Step 4: Test embedding generation** + +```bash +# Create a small test image (1x1 pixel JPEG) +python3 -c " +import base64, json +# Use a real test snapshot if available, or a minimal JPEG +with open('/path/to/test.jpg', 'rb') as f: + b64 = base64.b64encode(f.read()).decode() +import urllib.request +req = urllib.request.Request( + 'http://192.168.86.196:5200/embed', + data=json.dumps({'image': b64}).encode(), + headers={'Content-Type': 'application/json'} +) +resp = urllib.request.urlopen(req) +data = json.loads(resp.read()) +print(f'Embedding dims: {len(data[\"embedding\"])}, time: {data[\"inference_ms\"]}ms') +" +``` + +Expected: 768-dimensional embedding, <100ms inference time. + +--- + +## Task 11: Deploy frigate-notify v2 + +**Goal:** Build and deploy the updated frigate-notify on hf-pdocker-01 (when it's back online). + +**NOTE:** This task is blocked until hf-pdocker-01 is restored. The compose file and code are ready on the NAS. + +**Step 1: Create data directory on NAS** + +```bash +ssh glyptic@192.168.86.100 +mkdir -p /mnt/docker-data/configs/frigate-notify +``` + +**Step 2: Copy updated code to NAS** + +```bash +# From local machine, sync to NAS via any node +scp -r /mnt/c/Users/Jeremy/projects/frigate-notify/{app,Dockerfile,docker-compose.yml} \ + glyptic@192.168.86.100:/mnt/docker-data/compose/hf-pdocker-01/frigate-notify/ +``` + +**Step 3: Deploy on hf-pdocker-01 (when available)** + +```bash +ssh glyptic@192.168.86.192 +cd /mnt/docker-data/compose/hf-pdocker-01/frigate-notify +docker compose up -d --build +``` + +**Step 4: Verify** + +```bash +curl http://192.168.86.192:5199/health +# Open http://192.168.86.192:5199 in browser — should show Dashboard +``` + +--- + +## Summary + +| Task | Component | Dependencies | +|------|-----------|-------------| +| 1 | Project scaffold, FastAPI, SQLite | None | +| 2 | REST API endpoints | Task 1 | +| 3 | CLIP service sidecar | None (independent) | +| 4 | Recognition engine | Task 1 | +| 5 | Pipeline integration | Tasks 2, 4 | +| 6 | Web UI | Task 2 | +| 7 | Event cleanup | Task 1 | +| 8 | Farm Manager link | None (independent) | +| 9 | Pull Ollama model | None (independent) | +| 10 | Deploy clip-service | Task 3 | +| 11 | Deploy frigate-notify v2 | Tasks 1-7, hf-pdocker-01 online | + +**Independent tasks (can run in parallel):** 1, 3, 8, 9 +**Sequential chain:** 1 → 2 → 4 → 5 → 6 → 7 → 11 diff --git a/docs/plans/2026-03-08-hf-pdocker-01-rebuild.md b/docs/plans/2026-03-08-hf-pdocker-01-rebuild.md new file mode 100644 index 0000000..54450a5 --- /dev/null +++ b/docs/plans/2026-03-08-hf-pdocker-01-rebuild.md @@ -0,0 +1,428 @@ +# hf-pdocker-01 Rebuild — Phase 1 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Rebuild hf-pdocker-01 from fresh Ubuntu 24.04 to a fully operational Swarm manager with Frigate NVR, Farm Agent, and Frigate-Notify v2. + +**Architecture:** Fresh Ubuntu 24.04 on NVMe, Docker CE, NFS-backed storage from Synology NAS, 5TB USB disk for Frigate recordings. Docker Swarm re-initialized as single manager with hf-pdocker-02 and bart rejoined as workers. Services deployed via Compose files already on NAS. + +**Tech Stack:** Ubuntu 24.04, Docker CE, Docker Swarm, NFS v3, Intel VA-API/OpenVINO, Python 3.12 (containers) + +**Access:** SSH to hf-pdocker-01 (192.168.86.192), user `glyptic`, password `&Rip8a115` + +**Important:** All tasks execute via SSH from the WSL environment. Use the `docker-swarm-ops` agent for all remote operations. + +--- + +### Task 1: Install System Prerequisites + +**Target:** hf-pdocker-01 (192.168.86.192) + +**Step 1: Install base packages** + +```bash +sudo apt-get update +sudo apt-get install -y nfs-common curl jq git rsync +``` + +**Step 2: Install Docker CE** + +```bash +# Add Docker GPG key and repo +sudo install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg +sudo chmod a+r /etc/apt/keyrings/docker.gpg +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null +sudo apt-get update +sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +``` + +**Step 3: Add glyptic to docker group** + +```bash +sudo usermod -aG docker glyptic +``` + +**Step 4: Install Intel VA-API drivers for Frigate OpenVINO** + +```bash +sudo apt-get install -y intel-media-va-driver-non-free vainfo +``` + +**Step 5: Verify** + +```bash +docker --version # Expect: Docker version 28.x +docker compose version # Expect: Docker Compose version v2.x +vainfo # Expect: VA-API device listed (iHD driver, HD 630) +ls /dev/dri/ # Expect: card0, renderD128 +``` + +--- + +### Task 2: Configure System Settings + +**Target:** hf-pdocker-01 (192.168.86.192) + +**Step 1: Set vm.swappiness to 10 (cluster standard)** + +```bash +echo "vm.swappiness=10" | sudo tee -a /etc/sysctl.conf +sudo sysctl vm.swappiness=10 +``` + +**Step 2: Verify** + +```bash +sysctl vm.swappiness # Expect: vm.swappiness = 10 +``` + +--- + +### Task 3: Configure NFS Mounts + +**Target:** hf-pdocker-01 (192.168.86.192) + +**Step 1: Create mount points** + +```bash +sudo mkdir -p /mnt/docker-data /mnt/infrastructure +``` + +**Step 2: Add NFS entries to fstab** + +```bash +echo '192.168.86.30:/volume1/Docker /mnt/docker-data nfs rw,soft,timeo=300,intr,_netdev,nfsvers=3 0 0' | sudo tee -a /etc/fstab +echo '192.168.86.30:/volume1/Infrastructure /mnt/infrastructure nfs rw,hard,intr,_netdev 0 0' | sudo tee -a /etc/fstab +``` + +**Step 3: Mount and verify** + +```bash +sudo mount -a +ls /mnt/docker-data/configs/ # Expect: frigate, frigate-notify, etc. +ls /mnt/infrastructure/margaret/ # Expect: openclaw.json, credentials/, etc. +``` + +--- + +### Task 4: Mount 5TB Frigate Recordings Disk + +**Target:** hf-pdocker-01 (192.168.86.192) + +**Step 1: Identify the disk and verify existing data** + +```bash +sudo blkid /dev/sda1 # Check filesystem type (expect ext4) +sudo mkdir -p /mnt/frigate-recordings +sudo mount /dev/sda1 /mnt/frigate-recordings +ls /mnt/frigate-recordings/ # Expect: frigate recording directories +``` + +**Step 2: Add to fstab using UUID for reliability** + +```bash +# Get UUID +UUID=$(sudo blkid -s UUID -o value /dev/sda1) +echo "UUID=$UUID /mnt/frigate-recordings ext4 defaults,_netdev 0 2" | sudo tee -a /etc/fstab +``` + +**Step 3: Verify** + +```bash +df -h /mnt/frigate-recordings # Expect: ~4.5T disk +``` + +--- + +### Task 5: Re-initialize Docker Swarm + +**Target:** hf-pdocker-01 (192.168.86.192), then hf-pdocker-02 and bart + +**Step 1: Initialize Swarm on hf-pdocker-01** + +```bash +# On hf-pdocker-01: +docker swarm init --advertise-addr 192.168.86.192 +``` + +Save the worker join token from the output. + +**Step 2: Clean up stale Swarm state on workers** + +```bash +# On hf-pdocker-02 (192.168.86.100): +docker swarm leave --force + +# On bart (192.168.86.167): +docker swarm leave --force +``` + +**Step 3: Rejoin workers** + +```bash +# On hf-pdocker-02: +docker swarm join --token 192.168.86.192:2377 + +# On bart: +docker swarm join --token 192.168.86.192:2377 +``` + +**Step 4: Verify cluster** + +```bash +# On hf-pdocker-01: +docker node ls # Expect: 3 nodes, hf-pdocker-01 as Leader/Manager, others as Workers +``` + +**Step 5: Recreate overlay networks** + +```bash +# On hf-pdocker-01: +docker network create --driver overlay --attachable traefik-public +``` + +--- + +### Task 6: Deploy Frigate NVR + +**Target:** hf-pdocker-01 (192.168.86.192) + +**Step 1: Update Frigate config MQTT address** + +The Frigate config at `/mnt/docker-data/configs/frigate/config/config.yml` has MQTT pointed at `192.168.86.192:1883` (the old hf-pdocker-01 address). Mosquitto is currently running on bart (192.168.86.167) as a failover service. + +```bash +# On hf-pdocker-01, edit the config to point MQTT to bart: +sed -i 's/host: 192.168.86.192/host: 192.168.86.167/' /mnt/docker-data/configs/frigate/config/config.yml +``` + +Verify the change: +```bash +grep -A2 "^mqtt:" /mnt/docker-data/configs/frigate/config/config.yml +# Expect: host: 192.168.86.167 +``` + +**Step 2: Deploy Frigate** + +```bash +cd /mnt/docker-data/compose/hf-pdocker-01/frigate +docker compose up -d +``` + +**Step 3: Verify** + +```bash +docker ps --filter name=frigate --format "table {{.Names}}\t{{.Status}}" # Expect: Up +curl -s http://localhost:5000/api/version # Expect: Frigate version response +curl -s http://localhost:5000/api/stats | jq '.detectors' # Expect: openvino detector ~10ms inference +``` + +Wait 1-2 minutes for cameras to initialize, then check: +```bash +curl -s http://localhost:5000/api/stats | jq '.cameras | keys' # Expect: list of camera names +``` + +--- + +### Task 7: Deploy Farm Agent + +**Target:** hf-pdocker-01 (192.168.86.192) + +The existing farm-agent compose references `127.0.0.1:5050` (local registry, which is gone). We'll build the image directly from Gitea instead. + +**Step 1: Clone farm-manager repo and build agent image** + +```bash +cd /tmp +git clone http://192.168.86.100:3003/jeremy/farm-manager.git +cd farm-manager/agent +docker build -t farm-agent:latest . +``` + +**Step 2: Update compose file to use local image** + +```bash +# Edit the compose file on NAS +sed -i 's|image: 127.0.0.1:5050/farm-agent:latest|image: farm-agent:latest|' /mnt/docker-data/compose/hf-pdocker-01/farm-agent/docker-compose.yml +``` + +**Step 3: Deploy** + +```bash +cd /mnt/docker-data/compose/hf-pdocker-01/farm-agent +docker compose up -d +``` + +**Step 4: Verify** + +```bash +docker ps --filter name=farm-agent --format "table {{.Names}}\t{{.Status}}" +curl -s http://localhost:8889/health # Expect: healthy response +``` + +--- + +### Task 8: Deploy Frigate-Notify v2 + +**Target:** hf-pdocker-01 (192.168.86.192) + +**Step 1: Clone frigate-notify repo** + +```bash +cd /tmp +git clone http://192.168.86.100:3003/jeremy/frigate-notify.git +``` + +**Step 2: Build the image** + +```bash +cd /tmp/frigate-notify +docker build -t frigate-notify:latest . +``` + +**Step 3: Create .env file with secrets** + +The compose file needs `ANTHROPIC_API_KEY`, `MATRIX_ACCESS_TOKEN`, and `MATRIX_ROOM_ID`. Retrieve these from Vaultwarden or use known values. + +Known values from MEMORY.md: +- `MATRIX_ACCESS_TOKEN`: `syt_ZnJpZ2F0ZQ_uaKUheedpkQBqtCaldsa_2ifSTC` (bot: @frigate:hobbs.farm) +- `MATRIX_ROOM_ID`: `!MTsFCQjnmLUNFGJePz:hobbs.farm` +- `ANTHROPIC_API_KEY`: retrieve from vault or from bart's ai-agents .env + +```bash +cat > /mnt/docker-data/compose/hf-pdocker-01/frigate-notify/.env << 'EOF' +ANTHROPIC_API_KEY= +MATRIX_ACCESS_TOKEN=syt_ZnJpZ2F0ZQ_uaKUheedpkQBqtCaldsa_2ifSTC +MATRIX_ROOM_ID=!MTsFCQjnmLUNFGJePz:hobbs.farm +EOF +``` + +**Step 4: Update compose to use built image instead of build context, and fix IPs** + +The compose file on NAS references `build: context: .` which won't work from NAS path. Update to use the locally built image. Also update MQTT and Matrix homeserver IPs since those services are on bart now. + +Updated compose should have: +- `image: frigate-notify:latest` (instead of `build:`) +- `FRIGATE_URL=http://localhost:5000` (Frigate is now local on same host) +- `MQTT_HOST=192.168.86.167` (Mosquitto on bart) +- `MATRIX_HOMESERVER=http://192.168.86.167:8008` (Synapse on bart) + +**Step 5: Deploy** + +```bash +cd /mnt/docker-data/compose/hf-pdocker-01/frigate-notify +docker compose up -d +``` + +**Step 6: Verify** + +```bash +docker ps --filter name=frigate-notify --format "table {{.Names}}\t{{.Status}}" +curl -s http://localhost:5199/health # Expect: healthy +``` + +--- + +### Task 9: Restore Cron Jobs + +**Target:** hf-pdocker-01 (192.168.86.192) + +**Step 1: Verify scripts are accessible on NAS** + +```bash +ls -la /mnt/docker-data/scripts/sync-frigate-to-nas.sh +ls -la /mnt/docker-data/scripts/cleanup-frigate-media.sh +cat /mnt/docker-data/scripts/sync-frigate-to-nas.sh +cat /mnt/docker-data/scripts/cleanup-frigate-media.sh +``` + +**Step 2: Make scripts executable** + +```bash +chmod +x /mnt/docker-data/scripts/sync-frigate-to-nas.sh +chmod +x /mnt/docker-data/scripts/cleanup-frigate-media.sh +``` + +**Step 3: Install crontab entries** + +```bash +(crontab -l 2>/dev/null; echo "0 2 * * * /mnt/docker-data/scripts/sync-frigate-to-nas.sh") | crontab - +(crontab -l 2>/dev/null; echo "0 3 * * 0 /mnt/docker-data/scripts/cleanup-frigate-media.sh") | crontab - +``` + +**Step 4: Verify** + +```bash +crontab -l # Expect: both cron entries listed +``` + +--- + +### Task 10: Monitoring Integration + +**Target:** hf-pdocker-01 + hf-pdocker-02 (Prometheus) + +**Step 1: Deploy Swarm global services (Telegraf + Node-exporter)** + +On hf-pdocker-01, deploy the monitoring stacks that run globally: + +```bash +# Check if the stack files exist on NAS +ls /mnt/docker-data/compose/hf-pdocker-01/ # Look for telegraf, node-exporter stacks +``` + +If Telegraf and Node-exporter were Swarm stacks, redeploy them: +```bash +# These were global swarm services - redeploy the stacks +docker stack deploy -c /path/to/telegraf-stack.yml telegraf +docker stack deploy -c /path/to/node-exporter-stack.yml node-exporter +``` + +**Step 2: Update Prometheus blackbox targets** + +On hf-pdocker-02 (192.168.86.100), ensure Prometheus has blackbox probe targets for the new services: + +Targets to verify/add: +- `http://192.168.86.192:5000` (Frigate) +- `http://192.168.86.192:5199/health` (Frigate-Notify) +- `http://192.168.86.192:8889/health` (Farm Agent) + +**Step 3: Hot-reload Prometheus** + +```bash +# On hf-pdocker-02: +curl -X POST http://localhost:9095/-/reload +``` + +**Step 4: Verify in Grafana** + +Check Grafana at http://192.168.86.100:3001 — verify the new targets show as UP in the Prometheus targets page. + +--- + +## Phase 1 Complete Checklist + +After all tasks, verify: +- [ ] Docker running on hf-pdocker-01 +- [ ] NFS mount at /mnt/docker-data working +- [ ] 5TB disk mounted at /mnt/frigate-recordings +- [ ] Swarm cluster: 3 nodes (1 manager + 2 workers) +- [ ] Frigate running, cameras streaming, OpenVINO detector working +- [ ] Farm Agent responding on :8889 +- [ ] Frigate-Notify v2 healthy on :5199 +- [ ] Cron jobs installed for sync + cleanup +- [ ] Prometheus monitoring all new endpoints +- [ ] All blackbox probes green + +## Phase 2 (Future) + +Migrate failover services back to hf-pdocker-01: +- Traefik (from hf-pdocker-02) +- Monitoring stack (from hf-pdocker-02) +- AdGuard Home (from hf-pdocker-02) +- Home Assistant, ESPHome, Mosquitto (from bart) +- Matrix/Synapse (from bart) +- Guacamole (from bart) +- AI Agents (from bart) +- Farm Manager API (new deploy) diff --git a/scripts/cleanup-frigate-media.sh b/scripts/cleanup-frigate-media.sh new file mode 100644 index 0000000..d4a9236 --- /dev/null +++ b/scripts/cleanup-frigate-media.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# ============================================================================= +# Frigate Media Cleanup +# Removes recordings, clips, and snapshots older than 90 days from NAS +# Run via cron: 0 3 * * 0 /mnt/docker-data/scripts/cleanup-frigate-media.sh +# ============================================================================= + +MEDIA_DIR="/mnt/docker-data/media/frigate" +MAX_AGE_DAYS=90 +LOG_FILE="/var/log/frigate-cleanup.log" +DRY_RUN=false + +if [ "$1" = "--dry-run" ]; then + DRY_RUN=true +fi + +log() { + echo "$(date '+%Y-%m-%d %H:%M:%S') $1" | tee -a "$LOG_FILE" +} + +if [ ! -d "$MEDIA_DIR" ]; then + log "ERROR: $MEDIA_DIR does not exist" + exit 1 +fi + +log "=== Frigate media cleanup started (max age: ${MAX_AGE_DAYS} days, dry_run: ${DRY_RUN}) ===" + +# Get size before cleanup +SIZE_BEFORE=$(du -sh "$MEDIA_DIR" 2>/dev/null | cut -f1) +log "Size before: $SIZE_BEFORE" + +# Find and remove old files in recordings/, clips/, exports/ +for subdir in recordings clips exports; do + DIR="$MEDIA_DIR/$subdir" + if [ -d "$DIR" ]; then + COUNT=$(find "$DIR" -type f -mtime +${MAX_AGE_DAYS} | wc -l) + log "Found $COUNT files older than ${MAX_AGE_DAYS} days in $subdir/" + if [ "$DRY_RUN" = true ]; then + find "$DIR" -type f -mtime +${MAX_AGE_DAYS} -print | head -20 + log "(dry run - no files deleted)" + else + find "$DIR" -type f -mtime +${MAX_AGE_DAYS} -delete + # Remove empty directories left behind + find "$DIR" -type d -empty -delete 2>/dev/null + log "Cleaned $subdir/" + fi + fi +done + +# Get size after cleanup +SIZE_AFTER=$(du -sh "$MEDIA_DIR" 2>/dev/null | cut -f1) +log "Size after: $SIZE_AFTER" +log "=== Cleanup complete ===" diff --git a/scripts/sync-frigate-to-nas.sh b/scripts/sync-frigate-to-nas.sh new file mode 100644 index 0000000..ce3e096 --- /dev/null +++ b/scripts/sync-frigate-to-nas.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# ============================================================================= +# Frigate Clip Sync: USB Drive → NAS +# Copies alert/detection clips and exports to NAS for long-term retention. +# Frigate handles short-term cleanup on USB (7 days). +# The cleanup-frigate-media.sh script handles NAS cleanup (90 days). +# Run via cron: 0 2 * * * /mnt/docker-data/scripts/sync-frigate-to-nas.sh +# ============================================================================= + +USB_DIR="/mnt/frigate-recordings" +NAS_DIR="/mnt/docker-data/media/frigate" +LOG_FILE="/var/log/frigate-sync.log" + +log() { + echo "$(date '+%Y-%m-%d %H:%M:%S') $1" | tee -a "$LOG_FILE" +} + +# Verify both paths exist +if [ ! -d "$USB_DIR" ]; then + log "ERROR: USB drive not mounted at $USB_DIR" + exit 1 +fi + +if [ ! -d "$NAS_DIR" ]; then + log "ERROR: NAS not mounted at $NAS_DIR" + exit 1 +fi + +log "=== Frigate clip sync started ===" + +# Sync clips (alert/detection event media) +if [ -d "$USB_DIR/clips" ]; then + log "Syncing clips..." + rsync -av --no-o --no-g "$USB_DIR/clips/" "$NAS_DIR/clips/" >> "$LOG_FILE" 2>&1 + log "Clips sync complete" +fi + +# Sync exports (user-saved clips) +if [ -d "$USB_DIR/exports" ]; then + log "Syncing exports..." + rsync -av --no-o --no-g "$USB_DIR/exports/" "$NAS_DIR/exports/" >> "$LOG_FILE" 2>&1 + log "Exports sync complete" +fi + +# Report sizes +USB_CLIPS=$(du -sh "$USB_DIR/clips" 2>/dev/null | cut -f1) +USB_RECORDINGS=$(du -sh "$USB_DIR/recordings" 2>/dev/null | cut -f1) +NAS_CLIPS=$(du -sh "$NAS_DIR/clips" 2>/dev/null | cut -f1) +NAS_EXPORTS=$(du -sh "$NAS_DIR/exports" 2>/dev/null | cut -f1) + +log "USB clips: $USB_CLIPS | USB recordings: $USB_RECORDINGS" +log "NAS clips: $NAS_CLIPS | NAS exports: $NAS_EXPORTS" +log "=== Sync complete ===" diff --git a/stacks/.gitignore b/stacks/.gitignore new file mode 100644 index 0000000..fbddbcf --- /dev/null +++ b/stacks/.gitignore @@ -0,0 +1,2 @@ +# Environment files containing secrets +**/.env diff --git a/stacks/actual-budget/docker-compose.yml b/stacks/actual-budget/docker-compose.yml new file mode 100644 index 0000000..577f9f2 --- /dev/null +++ b/stacks/actual-budget/docker-compose.yml @@ -0,0 +1,30 @@ +version: "3.8" + +services: + actual-budget: + image: actualbudget/actual-server:latest + restart: unless-stopped + ports: + - "5006:5006" + volumes: + - /data/configs/actual-budget:/data + healthcheck: + test: ["CMD-SHELL", "node -e \"fetch('http://localhost:5006/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 256M diff --git a/stacks/adguard/docker-compose.yml b/stacks/adguard/docker-compose.yml new file mode 100644 index 0000000..f37a053 --- /dev/null +++ b/stacks/adguard/docker-compose.yml @@ -0,0 +1,32 @@ +services: + adguardhome: + image: adguard/adguardhome:latest + restart: unless-stopped + volumes: + - /mnt/docker-data/configs/adguardhome/work:/opt/adguardhome/work + - /mnt/docker-data/configs/adguardhome/conf:/opt/adguardhome/conf + ports: + - "53:53/tcp" + - "53:53/udp" + - "3000:3000/tcp" + - "853:853/tcp" + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/ || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 256M diff --git a/stacks/ai-agents/.env.example b/stacks/ai-agents/.env.example new file mode 100644 index 0000000..53c1030 --- /dev/null +++ b/stacks/ai-agents/.env.example @@ -0,0 +1 @@ +ANTHROPIC_API_KEY=sk-ant-... diff --git a/stacks/ai-agents/docker-compose.yml b/stacks/ai-agents/docker-compose.yml new file mode 100644 index 0000000..5d3636f --- /dev/null +++ b/stacks/ai-agents/docker-compose.yml @@ -0,0 +1,120 @@ +version: "3.8" + +services: + margaret: + image: 127.0.0.1:5050/margaret-openclaw:latest + restart: unless-stopped + network_mode: host + user: "0:0" + environment: + - NODE_ENV=production + - HOME=/home/glyptic + - USER=glyptic + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + volumes: + - /mnt/infrastructure/margaret:/home/glyptic/.openclaw + command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=768 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"] + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 2G + + spirit: + image: 127.0.0.1:5050/margaret-openclaw:latest + restart: unless-stopped + network_mode: host + user: "0:0" + environment: + - NODE_ENV=production + - HOME=/home/glyptic + - USER=glyptic + - OPENCLAW_STATE_DIR=/home/glyptic/.spirit + - OPENCLAW_CONFIG_PATH=/home/glyptic/.spirit/openclaw.json + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + volumes: + - /mnt/infrastructure/spirit:/home/glyptic/.spirit + command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=768 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"] + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 2G + + bob: + image: 127.0.0.1:5050/margaret-openclaw:latest + restart: unless-stopped + network_mode: host + user: "0:0" + environment: + - NODE_ENV=production + - HOME=/home/glyptic + - USER=glyptic + - OPENCLAW_STATE_DIR=/home/glyptic/.bob + - OPENCLAW_CONFIG_PATH=/home/glyptic/.bob/openclaw.json + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + volumes: + - /mnt/infrastructure/bob:/home/glyptic/.bob + command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=768 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"] + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 2G + + clippy: + image: 127.0.0.1:5050/margaret-openclaw:latest + restart: unless-stopped + network_mode: host + user: "0:0" + environment: + - NODE_ENV=production + - HOME=/home/glyptic + - USER=glyptic + - OPENCLAW_STATE_DIR=/home/glyptic/.clippy + - OPENCLAW_CONFIG_PATH=/home/glyptic/.clippy/openclaw.json + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + volumes: + - /mnt/infrastructure/clippy:/home/glyptic/.clippy + command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=768 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"] + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 2G diff --git a/stacks/frigate/config.yml b/stacks/frigate/config.yml new file mode 100644 index 0000000..1b76e01 --- /dev/null +++ b/stacks/frigate/config.yml @@ -0,0 +1,263 @@ +mqtt: + enabled: true + host: mosquitto + port: 1883 + +detectors: + coral: + type: cpu + +# Intel QuickSync hardware acceleration +ffmpeg: + hwaccel_args: preset-intel-qsv-h264 + +# Database for event history +database: + path: /media/frigate/frigate.db + +# Record settings +record: + enabled: true + retain: + days: 7 + mode: motion + alerts: + retain: + days: 14 + detections: + retain: + days: 14 + +objects: + track: + - person + - car + - dog + - cat + - truck + filters: + person: + min_area: 5000 + threshold: 0.75 + car: + min_area: 10000 + threshold: 0.7 + +# Snapshots +snapshots: + enabled: true + clean_copy: true + retain: + default: 14 + mode: motion + +# Camera definitions +cameras: + # House Perimeter Cameras + house_north: + enabled: true + ffmpeg: + inputs: + - path: rtsp://admin:123456@192.168.44.51:554/stream1 + roles: + - detect + - record + detect: + width: 1024 + height: 768 + fps: 5 + motion: + mask: + - 0,0,0,100,100,100,100,0 + zones: + front_yard: + coordinates: 0,768,1024,768,1024,384,0,384 + + house_west: + enabled: true + ffmpeg: + inputs: + - path: rtsp://admin:123456@192.168.44.52:554/stream1 + roles: + - detect + - record + detect: + width: 1024 + height: 768 + fps: 5 + + house_south: + enabled: true + ffmpeg: + inputs: + - path: rtsp://admin:123456@192.168.44.53:554/stream1 + roles: + - detect + - record + detect: + width: 1024 + height: 768 + fps: 5 + + house_east: + enabled: true + ffmpeg: + inputs: + - path: rtsp://admin:123456@192.168.44.58:554/stream1 + roles: + - detect + - record + detect: + width: 960 + height: 540 + fps: 5 + + house_south_corner: + enabled: true + ffmpeg: + inputs: + - path: rtsp://admin:123456@192.168.44.61:554/stream1 + roles: + - detect + - record + detect: + width: 1280 + height: 960 + fps: 5 + objects: + track: + - person + - car + - dog + + house_south_yard: + enabled: true + ffmpeg: + inputs: + - path: rtsp://admin:123456@192.168.44.62:554/stream1 + roles: + - detect + - record + detect: + width: 1280 + height: 960 + fps: 5 + + back_porch: + enabled: true + ffmpeg: + inputs: + - path: rtsp://admin:123456@192.168.44.63:554/stream1 + roles: + - detect + - record + detect: + width: 1280 + height: 960 + fps: 5 + + # Garage & Driveway + garage_2: + enabled: true + ffmpeg: + inputs: + - path: rtsp://admin:123456@192.168.44.64:554/stream1 + roles: + - detect + - record + detect: + width: 640 + height: 480 + fps: 5 + + # Shop Cameras + shop_west: + enabled: true + ffmpeg: + inputs: + - path: rtsp://admin:123456@192.168.44.56:554/stream1 + roles: + - detect + - record + detect: + width: 1024 + height: 768 + fps: 5 + + shop_driveway: + enabled: true + ffmpeg: + inputs: + - path: rtsp://admin:123456@192.168.44.57:554/stream1 + roles: + - detect + - record + detect: + width: 1024 + height: 768 + fps: 5 + + # Other Cameras + pool_room: + enabled: true + ffmpeg: + inputs: + - path: rtsp://admin:123456@192.168.86.142:554/stream1 + roles: + - detect + - record + detect: + width: 1280 + height: 720 + fps: 5 + + gate_camera: + enabled: true + ffmpeg: + inputs: + - path: rtsp://admin:123456@192.168.86.175:554/stream1 + roles: + - detect + - record + detect: + width: 1280 + height: 960 + fps: 5 + + # Offline cameras - commented out for now + # front_door: + # enabled: false + # ffmpeg: + # inputs: + # - path: rtsp://admin:123456@192.168.44.59:554/stream1 + # detect: + # width: 1024 + # height: 768 + # fps: 5 + + # driveway_up: + # enabled: false + # ffmpeg: + # inputs: + # - path: rtsp://admin:123456@192.168.86.150:554/stream1 + + # driveway_down: + # enabled: false + # ffmpeg: + # inputs: + # - path: rtsp://admin:123456@192.168.44.68:554/stream1 + + # shop_east: + # enabled: false + # ffmpeg: + # inputs: + # - path: rtsp://admin:123456@192.168.44.55:554/stream1 + +# UI settings +ui: + timezone: America/Chicago + +detect: + enabled: true + +version: 0.16-0 diff --git a/stacks/frigate/docker-compose.yml b/stacks/frigate/docker-compose.yml new file mode 100644 index 0000000..a1f128c --- /dev/null +++ b/stacks/frigate/docker-compose.yml @@ -0,0 +1,41 @@ +services: + frigate: + image: ghcr.io/blakeblackshear/frigate:stable + restart: unless-stopped + shm_size: "256mb" + environment: + - TZ=America/Chicago + - FRIGATE_RTSP_PASSWORD=password + volumes: + - /data/configs/frigate/config:/config + - /data/configs/frigate/media:/media/frigate + - /etc/localtime:/etc/localtime:ro + devices: + - /dev/dri:/dev/dri + networks: + - traefik-public + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:5000/api/version || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == bart + labels: [] + resources: + limits: + memory: 2G + +networks: + traefik-public: + external: true diff --git a/stacks/gitea/.env.example b/stacks/gitea/.env.example new file mode 100644 index 0000000..be286c4 --- /dev/null +++ b/stacks/gitea/.env.example @@ -0,0 +1 @@ +GITEA_DB_PASSWORD=change_me diff --git a/stacks/gitea/docker-compose.yml b/stacks/gitea/docker-compose.yml new file mode 100644 index 0000000..5126e84 --- /dev/null +++ b/stacks/gitea/docker-compose.yml @@ -0,0 +1,84 @@ +version: "3.8" + +services: + gitea: + image: gitea/gitea:latest + restart: unless-stopped + ports: + - "3003:3000" + - "2222:22" + environment: + - USER_UID=1000 + - USER_GID=1000 + - GITEA__database__DB_TYPE=postgres + - GITEA__database__HOST=gitea-db:5432 + - GITEA__database__NAME=gitea + - GITEA__database__USER=gitea + - GITEA__database__PASSWD=${GITEA_DB_PASSWORD} + - GITEA__server__SSH_PORT=2222 + volumes: + - /data/configs/gitea:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + depends_on: + - gitea-db + networks: + - gitea-internal + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:3000/api/healthz || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 512M + + gitea-db: + image: postgres:16-alpine + restart: unless-stopped + environment: + - POSTGRES_DB=gitea + - POSTGRES_USER=gitea + - POSTGRES_PASSWORD=${GITEA_DB_PASSWORD} + volumes: + - /data/configs/gitea-db:/var/lib/postgresql/data + networks: + - gitea-internal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U gitea -d gitea"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + labels: + - "com.centurylinklabs.watchtower.enable=false" + resources: + limits: + memory: 256M + +networks: + gitea-internal: + driver: overlay diff --git a/stacks/guacamole/.env.example b/stacks/guacamole/.env.example new file mode 100644 index 0000000..c649e99 --- /dev/null +++ b/stacks/guacamole/.env.example @@ -0,0 +1,2 @@ +GUACAMOLE_MYSQL_ROOT_PASSWORD=change_me +GUACAMOLE_MYSQL_PASSWORD=change_me diff --git a/stacks/guacamole/docker-compose.yml b/stacks/guacamole/docker-compose.yml new file mode 100644 index 0000000..549bb13 --- /dev/null +++ b/stacks/guacamole/docker-compose.yml @@ -0,0 +1,104 @@ +services: + guacamole: + image: guacamole/guacamole:latest + restart: unless-stopped + environment: + - GUACD_HOSTNAME=guacd + - MYSQL_HOSTNAME=guacamole-db + - MYSQL_DATABASE=guacamole_db + - MYSQL_USER=guacamole_user + - MYSQL_PASSWORD=${GUACAMOLE_MYSQL_PASSWORD} + ports: + - "8199:8080" + networks: + - traefik-public + - guacamole-internal + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8080/guacamole/ || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-public" + - "traefik.http.routers.guacamole.rule=Host(`solo.hobbs.farm`)" + - "traefik.http.routers.guacamole.entrypoints=websecure" + - "traefik.http.routers.guacamole.tls.certresolver=letsencrypt" + - "traefik.http.services.guacamole.loadbalancer.server.port=8080" + resources: + limits: + memory: 512M + + guacd: + image: guacamole/guacd:latest + restart: unless-stopped + networks: + - guacamole-internal + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 512M + + guacamole-db: + image: mysql:8.0 + user: "1024:100" + restart: unless-stopped + environment: + - MYSQL_ROOT_PASSWORD=${GUACAMOLE_MYSQL_ROOT_PASSWORD} + - MYSQL_DATABASE=guacamole_db + - MYSQL_USER=guacamole_user + - MYSQL_PASSWORD=${GUACAMOLE_MYSQL_PASSWORD} + volumes: + - /mnt/docker-data/databases/guacamole-db:/var/lib/mysql + networks: + - guacamole-internal + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 --silent"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + labels: + - "com.centurylinklabs.watchtower.enable=false" + resources: + limits: + memory: 512M + +networks: + traefik-public: + external: true + guacamole-internal: + driver: overlay diff --git a/stacks/matrix/.env.example b/stacks/matrix/.env.example new file mode 100644 index 0000000..6870875 --- /dev/null +++ b/stacks/matrix/.env.example @@ -0,0 +1 @@ +MATRIX_DB_PASSWORD=change_me diff --git a/stacks/matrix/docker-compose.yml b/stacks/matrix/docker-compose.yml new file mode 100644 index 0000000..d6303ea --- /dev/null +++ b/stacks/matrix/docker-compose.yml @@ -0,0 +1,127 @@ +services: + synapse: + image: matrixdotorg/synapse:latest + restart: unless-stopped + environment: + - SYNAPSE_SERVER_NAME=mess.hobbs.farm + - SYNAPSE_REPORT_STATS=no + volumes: + - /mnt/docker-data/configs/matrix/data:/data + ports: + - "8008:8008" + networks: + - traefik-public + - matrix-internal + healthcheck: + test: ["CMD-SHELL", "curl -fSs http://localhost:8008/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-public" + - "traefik.http.services.mess.loadbalancer.server.scheme=http" + - "traefik.http.services.mess.loadbalancer.server.port=8008" + - "traefik.http.routers.mess.rule=Host(`mess.hobbs.farm`)" + - "traefik.http.routers.mess.entrypoints=websecure" + - "traefik.http.routers.mess.tls=true" + - "traefik.http.routers.mess.tls.certresolver=letsencrypt" + - "traefik.http.routers.mess.service=mess" + - "traefik.http.routers.mess-federation.rule=Host(`mess.hobbs.farm`) && PathPrefix(`/_matrix/federation`)" + - "traefik.http.routers.mess-federation.entrypoints=websecure" + - "traefik.http.routers.mess-federation.tls=true" + - "traefik.http.routers.mess-federation.tls.certresolver=letsencrypt" + - "traefik.http.routers.mess-federation.service=mess" + resources: + limits: + memory: 1G + + wellknown: + image: nginx:alpine + restart: unless-stopped + volumes: + - /mnt/docker-data/configs/matrix/wellknown:/usr/share/nginx/html/.well-known/matrix:ro + networks: + - traefik-public + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:80/ || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + labels: + - "traefik.enable=true" + - "traefik.http.services.wellknown.loadbalancer.server.port=80" + - "traefik.http.routers.wellknown.rule=Host(`hobbs.farm`) && PathPrefix(`/.well-known/matrix`)" + - "traefik.http.routers.wellknown.entrypoints=websecure" + - "traefik.http.routers.wellknown.tls=true" + - "traefik.http.routers.wellknown.tls.certresolver=letsencrypt" + - "traefik.http.routers.wellknown.service=wellknown" + resources: + limits: + memory: 64M + + synapse-db: + image: postgres:15-alpine + user: "1024:100" + restart: unless-stopped + environment: + - POSTGRES_USER=synapse + - POSTGRES_PASSWORD=${MATRIX_DB_PASSWORD} + - POSTGRES_DB=synapse + - POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C + volumes: + - /mnt/docker-data/databases/matrix-db:/var/lib/postgresql/data + networks: + - matrix-internal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U synapse -d synapse"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + labels: + - "com.centurylinklabs.watchtower.enable=false" + resources: + limits: + memory: 256M + +networks: + traefik-public: + external: true + matrix-internal: + driver: overlay diff --git a/stacks/mcpo/docker-compose.yml b/stacks/mcpo/docker-compose.yml new file mode 100644 index 0000000..8f2b424 --- /dev/null +++ b/stacks/mcpo/docker-compose.yml @@ -0,0 +1,31 @@ +version: "3.8" + +services: + mcpo: + image: ghcr.io/open-webui/mcpo:main + restart: unless-stopped + ports: + - "8001:8000" + volumes: + - /data/configs/mcpo:/app/config + command: ["--config", "/app/config/config.json"] + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8000/openapi.json || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 512M diff --git a/stacks/media/docker-compose.yml b/stacks/media/docker-compose.yml new file mode 100644 index 0000000..9332447 --- /dev/null +++ b/stacks/media/docker-compose.yml @@ -0,0 +1,195 @@ +version: "3.8" + +services: + jellyfin: + image: jellyfin/jellyfin:latest + restart: unless-stopped + environment: + - PUID=1000 + - PGID=1000 + volumes: + - /data/configs/jellyfin:/config + - /data/media:/media + ports: + - "8096:8096" + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8096/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 2G + + sonarr: + image: linuxserver/sonarr:latest + restart: unless-stopped + environment: + - PUID=1000 + - PGID=1000 + - TZ=America/Chicago + volumes: + - /data/configs/sonarr:/config + - /data/media:/media + ports: + - "8989:8989" + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8989/ping || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 512M + + radarr: + image: linuxserver/radarr:latest + restart: unless-stopped + environment: + - PUID=1000 + - PGID=1000 + - TZ=America/Chicago + volumes: + - /data/configs/radarr:/config + - /data/media:/media + ports: + - "7878:7878" + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:7878/ping || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 512M + + sabnzbd: + image: linuxserver/sabnzbd:latest + restart: unless-stopped + environment: + - PUID=1000 + - PGID=1000 + - TZ=America/Chicago + volumes: + - /data/configs/sabnzbd:/config + - /data/media/downloads:/downloads + ports: + - "8082:8080" + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8080/api?mode=version || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 1G + + nzbhydra2: + image: linuxserver/nzbhydra2:latest + restart: unless-stopped + environment: + - PUID=1000 + - PGID=1000 + - TZ=America/Chicago + volumes: + - /data/configs/nzbhydra:/config + ports: + - "5076:5076" + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:5076/ || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 90s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 512M + + jellyseerr: + image: fallenbagel/jellyseerr:latest + restart: unless-stopped + environment: + - TZ=America/Chicago + volumes: + - /data/configs/jellyseerr:/app/config + ports: + - "5055:5055" + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:5055/api/v1/status || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 90s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 512M diff --git a/stacks/monitoring/alertmanager.yml b/stacks/monitoring/alertmanager.yml new file mode 100644 index 0000000..5aad540 --- /dev/null +++ b/stacks/monitoring/alertmanager.yml @@ -0,0 +1,40 @@ +global: + resolve_timeout: 5m + +route: + group_by: ["alertname"] + group_wait: 30s + group_interval: 5m + repeat_interval: 4h + receiver: "matrix-alerts" + routes: + - match: + severity: critical + repeat_interval: 30m + receiver: "matrix-alerts" + - match: + alertname: Watchdog + receiver: "null" + repeat_interval: 24h + - match: + severity: info + repeat_interval: 12h + receiver: "matrix-alerts" + +receivers: + - name: "null" + - name: "matrix-alerts" + webhook_configs: + - url: "http://matrix-alertmanager-receiver:9094/alerts/notifications" + http_config: + basic_auth: + username: "alertmanager" + password: "alertmanager-webhook" + send_resolved: true + +inhibit_rules: + - source_match: + severity: "critical" + target_match: + severity: "warning" + equal: ["alertname", "instance"] diff --git a/stacks/monitoring/alerts.yml b/stacks/monitoring/alerts.yml new file mode 100644 index 0000000..383f390 --- /dev/null +++ b/stacks/monitoring/alerts.yml @@ -0,0 +1,244 @@ +groups: + - name: infrastructure_devices + interval: 30s + rules: + # Unifi Device Down + - alert: UnifiDeviceDown + expr: unifi_device_up == 0 + for: 2m + labels: + severity: critical + type: infrastructure + annotations: + summary: "Unifi device {{ $labels.name }} is offline" + description: "Device {{ $labels.name }} ({{ $labels.model }}) at {{ $labels.ip }} has been offline for more than 2 minutes." + + - name: server_health + interval: 60s + rules: + # Server Down + - alert: ServerDown + expr: up{job="node"} == 0 + for: 1m + labels: + severity: critical + type: server + annotations: + summary: "Server {{ $labels.hostname }} is DOWN" + description: "{{ $labels.hostname }} ({{ $labels.host_ip }}) is not responding to health checks for 1 minute." + + # High disk usage + - alert: HighDiskUsage + expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 < 10 + for: 5m + labels: + severity: critical + type: server + annotations: + summary: "{{ $labels.hostname }}: Disk space critically low" + description: "Root filesystem on {{ $labels.hostname }} has less than 10% free space remaining." + + # Moderate disk usage warning + - alert: DiskSpaceWarning + expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 < 20 + for: 10m + labels: + severity: warning + type: server + annotations: + summary: "{{ $labels.hostname }}: Disk space low" + description: "Root filesystem on {{ $labels.hostname }} has less than 20% free space." + + # High memory usage + - alert: HighMemoryUsage + expr: (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100 > 90 + for: 5m + labels: + severity: warning + type: server + annotations: + summary: "{{ $labels.hostname }}: High memory usage" + description: "Memory usage on {{ $labels.hostname }} is above 90% for 5 minutes." + + # Critical memory usage + - alert: CriticalMemoryUsage + expr: (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100 > 95 + for: 2m + labels: + severity: critical + type: server + annotations: + summary: "{{ $labels.hostname }}: Critical memory usage" + description: "Memory usage on {{ $labels.hostname }} is above 95%. System may become unstable." + + # High CPU usage (bart: 90% threshold due to Frigate/media workloads, others: 80%) + - alert: HighCPUUsage + expr: > + (100 - (avg by(hostname) (rate(node_cpu_seconds_total{mode="idle",hostname!="bart"}[5m])) * 100) > 80) + or + (100 - (avg by(hostname) (rate(node_cpu_seconds_total{mode="idle",hostname="bart"}[5m])) * 100) > 90) + for: 10m + labels: + severity: warning + type: server + annotations: + summary: "{{ $labels.hostname }}: High CPU usage" + description: "CPU usage on {{ $labels.hostname }} is above threshold for 10 minutes." + + # Critical CPU usage + - alert: CriticalCPUUsage + expr: 100 - (avg by(hostname) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 95 + for: 5m + labels: + severity: critical + type: server + annotations: + summary: "{{ $labels.hostname }}: Critical CPU usage" + description: "CPU usage on {{ $labels.hostname }} is above 95% for 5 minutes. Performance degraded." + + # High system load + - alert: HighSystemLoad + expr: node_load5 / on(hostname) count by(hostname) (node_cpu_seconds_total{mode="idle"}) > 2 + for: 10m + labels: + severity: warning + type: server + annotations: + summary: "{{ $labels.hostname }}: High system load" + description: "5-minute load average on {{ $labels.hostname }} is more than 2x CPU count." + + # Server rebooted + - alert: ServerRebooted + expr: node_time_seconds - node_boot_time_seconds < 600 + for: 1m + labels: + severity: info + type: server + annotations: + summary: "{{ $labels.hostname }} rebooted" + description: "{{ $labels.hostname }} was rebooted less than 10 minutes ago." + + # High swap usage + - alert: HighSwapUsage + expr: (node_memory_SwapTotal_bytes > 0) and ((node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes) / node_memory_SwapTotal_bytes * 100 > 50) + for: 5m + labels: + severity: warning + type: server + annotations: + summary: "{{ $labels.hostname }}: High swap usage" + description: "Swap usage on {{ $labels.hostname }} is above 50% for 5 minutes. Check container memory limits." + + # Critical swap usage + - alert: CriticalSwapUsage + expr: (node_memory_SwapTotal_bytes > 0) and ((node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes) / node_memory_SwapTotal_bytes * 100 > 80) + for: 2m + labels: + severity: critical + type: server + annotations: + summary: "{{ $labels.hostname }}: Critical swap usage" + description: "Swap usage on {{ $labels.hostname }} is above 80%. System may become unresponsive." + + # NFS mount failure (all data is on NAS - this is critical) + - alert: NFSMountDown + expr: node_filesystem_avail_bytes{fstype="nfs"} == 0 or absent(node_filesystem_avail_bytes{fstype="nfs"}) + for: 1m + labels: + severity: critical + type: server + annotations: + summary: "{{ $labels.hostname }}: NFS mount unavailable" + description: "NFS mount on {{ $labels.hostname }} at {{ $labels.mountpoint }} is down. All services depend on NAS storage." + + # NAS disk space low + - alert: NASDiskSpaceLow + expr: (node_filesystem_avail_bytes{fstype="nfs",mountpoint="/mnt/docker-data"} / node_filesystem_size_bytes{fstype="nfs",mountpoint="/mnt/docker-data"}) * 100 < 15 + for: 10m + labels: + severity: warning + type: server + annotations: + summary: "NAS disk space low" + description: "NFS share /mnt/docker-data has less than 15% free space." + + - name: docker_containers + interval: 60s + rules: + # Service/container unreachable via blackbox HTTP probe + # Matches all http-pdocker01, http-pdocker02, http-bart, and http-external jobs + - alert: ContainerDown + expr: probe_success{job=~"http-.*"} == 0 + for: 3m + labels: + severity: warning + type: container + annotations: + summary: "Service unreachable: {{ $labels.instance }}" + description: "Service at {{ $labels.instance }} is not responding to HTTP health checks for 3 minutes." + + - name: service_health + interval: 60s + rules: + # Service responding but very slow + - alert: SlowHTTPResponse + expr: probe_duration_seconds{job=~"http-.*"} > 10 + for: 5m + labels: + severity: warning + type: container + annotations: + summary: "Slow response: {{ $labels.instance }}" + description: "{{ $labels.instance }} is responding but taking more than 10 seconds." + + # Prometheus scrape target down + - alert: PrometheusTargetDown + expr: up == 0 + for: 5m + labels: + severity: warning + type: monitoring + annotations: + summary: "Prometheus target down: {{ $labels.job }}" + description: "Prometheus cannot scrape {{ $labels.instance }} (job: {{ $labels.job }}) for 5 minutes." + + # Alerting pipeline dead-man's switch (Watchdog always fires - if it stops, pipeline is broken) + - alert: Watchdog + expr: vector(1) + labels: + severity: none + type: monitoring + annotations: + summary: "Alerting pipeline active" + description: "This is a dead-man's-switch alert. If you stop receiving this, the alerting pipeline is broken." + + - name: frigate_alerts + interval: 60s + rules: + # Frigate high memory (standalone container on hf-pdocker-01 with host network) + - alert: FrigateHighMemory + expr: container_memory_usage_bytes{name="frigate"} / container_spec_memory_limit_bytes{name="frigate"} > 0.85 + for: 5m + labels: + severity: warning + type: frigate + annotations: + summary: "Frigate high memory usage" + description: "Frigate is using more than 85% of allocated memory." + + - name: service_resources + interval: 60s + rules: + # Any service container using more than 90% of its memory limit + - alert: ServiceHighMemory + expr: > + (container_memory_usage_bytes{name!="",name!~".*_db.*",name!~".*db$"} / + container_spec_memory_limit_bytes{name!="",name!~".*_db.*",name!~".*db$"} > 0) + > 0.90 + for: 5m + labels: + severity: warning + type: container + annotations: + summary: "{{ $labels.name }}: High memory usage" + description: "Container {{ $labels.name }} on {{ $labels.instance }} is using more than 90% of its memory limit." diff --git a/stacks/monitoring/blackbox.yml b/stacks/monitoring/blackbox.yml new file mode 100644 index 0000000..a155ab5 --- /dev/null +++ b/stacks/monitoring/blackbox.yml @@ -0,0 +1,17 @@ +modules: + http_2xx: + prober: http + timeout: 10s + http: + valid_http_versions: ["HTTP/1.1", "HTTP/2.0"] + follow_redirects: true + preferred_ip_protocol: "ip4" + http_2xx_insecure: + prober: http + timeout: 10s + http: + valid_http_versions: ["HTTP/1.1", "HTTP/2.0"] + follow_redirects: true + preferred_ip_protocol: "ip4" + tls_config: + insecure_skip_verify: true diff --git a/stacks/monitoring/docker-compose.yml b/stacks/monitoring/docker-compose.yml new file mode 100644 index 0000000..bae55dd --- /dev/null +++ b/stacks/monitoring/docker-compose.yml @@ -0,0 +1,197 @@ +services: + grafana: + image: grafana/grafana:latest + restart: unless-stopped + user: "104" + ports: + - "3001:3000" + volumes: + - /mnt/docker-data/configs/grafana:/var/lib/grafana + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/api/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 512M + + prometheus: + image: prom/prometheus:latest + restart: unless-stopped + ports: + - "9095:9090" + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/usr/share/prometheus/console_libraries' + - '--web.console.templates=/usr/share/prometheus/consoles' + - '--web.enable-lifecycle' + volumes: + - /mnt/docker-data/configs/prometheus:/etc/prometheus + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:9090/-/healthy || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 512M + + alertmanager: + image: prom/alertmanager:latest + restart: unless-stopped + ports: + - "9093:9093" + command: + - '--config.file=/etc/alertmanager/alertmanager.yml' + - '--storage.path=/alertmanager' + volumes: + - /mnt/docker-data/configs/alertmanager:/etc/alertmanager + - alertmanager-data:/alertmanager + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:9093/-/healthy || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 256M + + node-exporter: + image: prom/node-exporter:latest + restart: unless-stopped + ports: + - "9100:9100" + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/rootfs:ro + command: + - '--path.procfs=/host/proc' + - '--path.rootfs=/rootfs' + - '--path.sysfs=/host/sys' + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: global + resources: + limits: + memory: 128M + + influxdb: + image: influxdb:1.8-alpine + restart: unless-stopped + volumes: + - /mnt/docker-data/configs/influxdb:/var/lib/influxdb + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:8086/ping || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 1G + + matrix-alertmanager-receiver: + image: metio/matrix-alertmanager-receiver:2026.2.25 + restart: unless-stopped + ports: + - "9094:9094" + volumes: + - /mnt/docker-data/configs/alertmanager/matrix-receiver-config.yml:/config/config.yml:ro + command: ["--config-path", "/config/config.yml"] + # NOTE: No healthcheck - this image is distroless (no shell), so CMD-SHELL healthchecks fail. + # The service is verified healthy by Alertmanager successfully POSTing to it. + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 128M + + blackbox-exporter: + image: prom/blackbox-exporter:latest + restart: unless-stopped + ports: + - "9115:9115" + command: + - '--config.file=/config/blackbox.yml' + volumes: + - /mnt/docker-data/configs/blackbox:/config:ro + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 128M + +volumes: + alertmanager-data: diff --git a/stacks/monitoring/matrix-receiver-config.yml b/stacks/monitoring/matrix-receiver-config.yml new file mode 100644 index 0000000..a7c883c --- /dev/null +++ b/stacks/monitoring/matrix-receiver-config.yml @@ -0,0 +1,55 @@ +http: + address: 0.0.0.0 + port: 9094 + alerts-path-prefix: /alerts + metrics-path: /metrics + metrics-enabled: true + # basic-password is required - the app has a hardcoded default basic-username of "alertmanager" + # and will exit silently if basic-password is not set + basic-password: "alertmanager-webhook" + +matrix: + # Matrix Synapse is on bart (failover from hf-pdocker-01) + homeserver-url: http://192.168.86.167:8008 + user-id: "@alertbot:hobbs.farm" + # Access token stored directly - ${MATRIX_ACCESS_TOKEN} env var syntax is NOT supported + # (the app uses yaml parsing, not shell interpolation) + access-token: "syt_YWxlcnRib3Q_eOVAYjYKWBZxymfdxhPE_0iYlOZ" + room-mapping: + notifications: "!jYmqxbzEsvRgKYvwMP:hobbs.farm" + +templating: + external-url-mapping: + # Alertmanager and Prometheus are on hf-pdocker-02 (failover from hf-pdocker-01) + "http://alertmanager:9093": "http://192.168.86.100:9093" + generator-url-mapping: + "http://prometheus:9090": "http://192.168.86.100:9095" + computed-values: + - values: + color: red + when-matching-labels: + severity: critical + - values: + color: orange + when-matching-labels: + severity: warning + - values: + color: blue + when-matching-labels: + severity: info + # NOTE: Templates use .Alert (singular) not .Alerts (plural) + # This application calls the template once per individual alert, not per group + firing-template: | +

ALERT: {{ .Alert.Labels.alertname }}

+ + + + + +
Instance:{{ .Alert.Labels.instance }}
Severity:{{ .Alert.Labels.severity }}
Summary:{{ .Alert.Annotations.summary }}
Description:{{ .Alert.Annotations.description }}
+ resolved-template: | +

RESOLVED: {{ .Alert.Labels.alertname }}

+ + + +
Instance:{{ .Alert.Labels.instance }}
Summary:{{ .Alert.Annotations.summary }}
diff --git a/stacks/monitoring/prometheus.yml b/stacks/monitoring/prometheus.yml new file mode 100644 index 0000000..1980fd9 --- /dev/null +++ b/stacks/monitoring/prometheus.yml @@ -0,0 +1,163 @@ +global: + scrape_interval: 1m + evaluation_interval: 1m + +alerting: + alertmanagers: + - static_configs: + - targets: + - 'alertmanager:9093' + +rule_files: + - "alerts.yml" + +scrape_configs: + # Prometheus self-monitoring + - job_name: 'prometheus' + scrape_interval: 1m + static_configs: + - targets: ['localhost:9090'] + + # Node Exporter (system metrics) - all 3 swarm nodes + - job_name: 'node' + static_configs: + - targets: ['192.168.86.192:9100'] + labels: + hostname: 'hf-pdocker-01' + host_ip: '192.168.86.192' + - targets: ['192.168.86.167:9100'] + labels: + hostname: 'bart' + host_ip: '192.168.86.167' + - targets: ['192.168.86.100:9100'] + labels: + hostname: 'hf-pdocker-02' + host_ip: '192.168.86.100' + + # Blackbox Exporter self-monitoring + - job_name: 'blackbox' + static_configs: + - targets: ['blackbox-exporter:9115'] + + # Matrix Alertmanager Receiver metrics (running on hf-pdocker-02 during failover) + - job_name: 'matrix-alertmanager-receiver' + scrape_interval: 1m + static_configs: + - targets: ['192.168.86.100:9094'] + labels: + hostname: 'hf-pdocker-02' + + # HTTP Health Checks - hf-pdocker-01 services (native services only; failover services tracked in other jobs) + - job_name: 'http-pdocker01' + metrics_path: /probe + params: + module: [http_2xx] + static_configs: + - targets: + - http://192.168.86.192:5000 # Frigate NVR (standalone, host network) + - http://192.168.86.192:5199/health # Frigate Notify + - http://192.168.86.192:8888/health # Farm Manager API + - http://192.168.86.192:8889/health # Farm Agent + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: blackbox-exporter:9115 + + # HTTP Health Checks - hf-pdocker-02 services (native + failover from hf-pdocker-01) + - job_name: 'http-pdocker02' + metrics_path: /probe + params: + module: [http_2xx] + static_configs: + - targets: + - http://192.168.86.100:3003 # Gitea + - http://192.168.86.100:3004 # Semaphore + - http://192.168.86.100:3002 # Wiki.js + - http://192.168.86.100:26226 # Vaultwarden + - http://192.168.86.100:5006 # Actual Budget + - http://192.168.86.100:8001/openapi.json # MCPO + - http://192.168.86.100:8889/health # Farm Agent + - http://192.168.86.100:3001 # Grafana (FAILOVER from hf-pdocker-01) + - http://192.168.86.100:9095 # Prometheus (FAILOVER from hf-pdocker-01) + - http://192.168.86.100:9093 # Alertmanager (FAILOVER from hf-pdocker-01) + - http://192.168.86.100:3000 # AdGuard Home (FAILOVER from hf-pdocker-01) + - http://192.168.86.100:9000 # Portainer + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: blackbox-exporter:9115 + + # HTTPS Health Checks - services with self-signed certs + - job_name: 'http-pdocker02-https' + metrics_path: /probe + params: + module: [http_2xx_insecure] + static_configs: + - targets: + - https://192.168.86.100:8443 # Unifi Controller (self-signed cert) + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: blackbox-exporter:9115 + + # HTTP Health Checks - bart services (native + failover from hf-pdocker-01) + - job_name: 'http-bart' + metrics_path: /probe + params: + module: [http_2xx] + static_configs: + - targets: + - http://192.168.86.167:8096 # Jellyfin + - http://192.168.86.167:8989 # Sonarr + - http://192.168.86.167:7878 # Radarr + - http://192.168.86.167:8082 # SABnzbd + - http://192.168.86.167:5076 # NZBHydra2 + - http://192.168.86.167:5055 # Jellyseerr + - http://192.168.86.167:9392 # OpenVAS + - http://192.168.86.167:8889/health # Farm Agent + - http://192.168.86.167:8123 # Home Assistant (FAILOVER from hf-pdocker-01) + - http://192.168.86.167:6052 # ESPHome (FAILOVER from hf-pdocker-01) + - http://192.168.86.167:8199/guacamole/ # Guacamole (FAILOVER from hf-pdocker-01) + - http://192.168.86.167:8008 # Matrix Synapse (FAILOVER from hf-pdocker-01) + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: blackbox-exporter:9115 + + # HTTPS Health Checks - External domains via Traefik + - job_name: 'http-external' + metrics_path: /probe + params: + module: [http_2xx] + static_configs: + - targets: + - https://mess.hobbs.farm # Matrix (via Traefik) + - https://solo.hobbs.farm/guacamole/ # Guacamole (via Traefik) + - https://yoda.hobbs.farm # Home Assistant + - https://vault.hobbs.farm # Vaultwarden + - https://git.hobbs.farm # Gitea + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: blackbox-exporter:9115 + + # Unifi Network Devices - uncomment when unifi-exporter is deployed + # - job_name: 'unifi-devices' + # scrape_interval: 30s + # static_configs: + # - targets: ['unifi-exporter:9101'] diff --git a/stacks/n8n/.env.example b/stacks/n8n/.env.example new file mode 100644 index 0000000..f3b734a --- /dev/null +++ b/stacks/n8n/.env.example @@ -0,0 +1,2 @@ +N8N_DB_PASSWORD=change_me +N8N_ENCRYPTION_KEY=change_me_32_char_random_key_here diff --git a/stacks/n8n/docker-compose.yml b/stacks/n8n/docker-compose.yml new file mode 100644 index 0000000..dceb0d2 --- /dev/null +++ b/stacks/n8n/docker-compose.yml @@ -0,0 +1,82 @@ +version: "3.8" + +services: + n8n: + image: n8nio/n8n:latest + restart: unless-stopped + ports: + - "5678:5678" + environment: + - DB_TYPE=postgresdb + - DB_POSTGRESDB_HOST=n8n-db + - DB_POSTGRESDB_PORT=5432 + - DB_POSTGRESDB_DATABASE=n8n + - DB_POSTGRESDB_USER=n8n + - DB_POSTGRESDB_PASSWORD=${N8N_DB_PASSWORD} + - N8N_PORT=5678 + - GENERIC_TIMEZONE=America/Chicago + - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY} + volumes: + - /data/configs/n8n:/home/node/.n8n + depends_on: + - n8n-db + networks: + - n8n-internal + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:5678/healthz || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 120s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 512M + + n8n-db: + image: postgres:15-alpine + restart: unless-stopped + environment: + - POSTGRES_DB=n8n + - POSTGRES_USER=n8n + - POSTGRES_PASSWORD=${N8N_DB_PASSWORD} + volumes: + - /data/configs/n8n-db:/var/lib/postgresql/data + networks: + - n8n-internal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U n8n -d n8n"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + labels: + - "com.centurylinklabs.watchtower.enable=false" + resources: + limits: + memory: 256M + +networks: + n8n-internal: + driver: overlay diff --git a/stacks/node-exporter/docker-compose.yml b/stacks/node-exporter/docker-compose.yml new file mode 100644 index 0000000..1fc3438 --- /dev/null +++ b/stacks/node-exporter/docker-compose.yml @@ -0,0 +1,24 @@ +services: + node-exporter: + image: prom/node-exporter:latest + hostname: "{{.Node.Hostname}}" + ports: + - "9100:9100" + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/rootfs:ro + command: + - '--path.procfs=/host/proc' + - '--path.rootfs=/rootfs' + - '--path.sysfs=/host/sys' + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: global + resources: + limits: + memory: 128M diff --git a/stacks/openvas/docker-compose.yml b/stacks/openvas/docker-compose.yml new file mode 100644 index 0000000..a37f91b --- /dev/null +++ b/stacks/openvas/docker-compose.yml @@ -0,0 +1,24 @@ +services: + openvas: + image: immauss/openvas:latest + environment: + - PASSWORD=admin + - USERNAME=admin + volumes: + - openvas_data:/data + ports: + - "9392:9392" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 6G + reservations: + memory: 4G + +volumes: + openvas_data: diff --git a/stacks/semaphore/.env.example b/stacks/semaphore/.env.example new file mode 100644 index 0000000..0c2a27c --- /dev/null +++ b/stacks/semaphore/.env.example @@ -0,0 +1,4 @@ +SEMAPHORE_DB_PASSWORD=change_me +SEMAPHORE_ADMIN_PASSWORD=change_me +SEMAPHORE_ENCRYPTION_KEY=change_me_base64_encoded_32_byte_key +SEMAPHORE_MYSQL_ROOT_PASSWORD=change_me diff --git a/stacks/semaphore/docker-compose.yml b/stacks/semaphore/docker-compose.yml new file mode 100644 index 0000000..19ef414 --- /dev/null +++ b/stacks/semaphore/docker-compose.yml @@ -0,0 +1,89 @@ +version: "3.8" + +services: + semaphore: + image: semaphoreui/semaphore:latest + restart: unless-stopped + ports: + - "3004:3000" + environment: + - SEMAPHORE_DB_DIALECT=mysql + - SEMAPHORE_DB_HOST=semaphore-db + - SEMAPHORE_DB_PORT=3306 + - SEMAPHORE_DB_USER=semaphore + - SEMAPHORE_DB_PASS=${SEMAPHORE_DB_PASSWORD} + - SEMAPHORE_DB_NAME=semaphore + - SEMAPHORE_DB=semaphore + - SEMAPHORE_PLAYBOOK_PATH=/tmp/semaphore + - SEMAPHORE_ADMIN_PASSWORD=${SEMAPHORE_ADMIN_PASSWORD} + - SEMAPHORE_ADMIN_NAME=admin + - SEMAPHORE_ADMIN_EMAIL=admin@hobbs.farm + - SEMAPHORE_ADMIN=admin + - SEMAPHORE_ACCESS_KEY_ENCRYPTION=${SEMAPHORE_ENCRYPTION_KEY} + - SEMAPHORE_LDAP_ACTIVATED=no + volumes: + - /data/configs/semaphore:/etc/semaphore + - /data/configs/semaphore/playbooks:/tmp/semaphore + depends_on: + - semaphore-db + networks: + - semaphore-internal + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/api/ping || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 512M + + semaphore-db: + image: mysql:8.0 + restart: unless-stopped + environment: + - MYSQL_ROOT_PASSWORD=${SEMAPHORE_MYSQL_ROOT_PASSWORD} + - MYSQL_DATABASE=semaphore + - MYSQL_USER=semaphore + - MYSQL_PASSWORD=${SEMAPHORE_DB_PASSWORD} + volumes: + - /data/configs/semaphore-db:/var/lib/mysql + networks: + - semaphore-internal + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 --silent"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + labels: + - "com.centurylinklabs.watchtower.enable=false" + resources: + limits: + memory: 512M + +networks: + semaphore-internal: + driver: overlay diff --git a/stacks/smarthome/docker-compose.yml b/stacks/smarthome/docker-compose.yml new file mode 100644 index 0000000..89e9758 --- /dev/null +++ b/stacks/smarthome/docker-compose.yml @@ -0,0 +1,97 @@ +services: + homeassistant: + image: ghcr.io/home-assistant/home-assistant:stable + restart: unless-stopped + privileged: true + environment: + - TZ=America/Chicago + volumes: + - /mnt/docker-data/configs/homeassistant:/config + ports: + - "8123:8123" + dns: + - 192.168.86.1 + - 8.8.8.8 + networks: + - traefik-public + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-public" + - "traefik.http.services.yoda.loadbalancer.server.scheme=http" + - "traefik.http.services.yoda.loadbalancer.server.port=8123" + - "traefik.http.routers.yoda.rule=Host(`yoda.hobbs.farm`)" + - "traefik.http.routers.yoda.entrypoints=websecure" + - "traefik.http.routers.yoda.tls=true" + - "traefik.http.routers.yoda.tls.certresolver=letsencrypt" + - "traefik.http.routers.yoda.service=yoda" + resources: + limits: + memory: 4G + + esphome: + image: esphome/esphome:latest + restart: unless-stopped + environment: + - TZ=America/Chicago + volumes: + - /mnt/docker-data/configs/esphome/config:/config + ports: + - "6052:6052" + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:6052/ || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 2G + + mosquitto: + image: eclipse-mosquitto:latest + restart: unless-stopped + volumes: + - /mnt/docker-data/configs/mosquitto:/mosquitto + ports: + - "1883:1883" + - "8883:8883" + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 128M + +networks: + traefik-public: + external: true diff --git a/stacks/telegraf/docker-compose.yml b/stacks/telegraf/docker-compose.yml new file mode 100644 index 0000000..a1ba9c6 --- /dev/null +++ b/stacks/telegraf/docker-compose.yml @@ -0,0 +1,25 @@ +services: + telegraf: + image: telegraf:latest + restart: unless-stopped + hostname: "{{.Node.Hostname}}" + environment: + - HOST_PROC=/host/proc + - HOST_SYS=/host/sys + - HOST_ETC=/host/etc + volumes: + - /mnt/docker-data/configs/telegraf/telegraf.conf:/etc/telegraf/telegraf.conf:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + - /sys:/host/sys:ro + - /proc:/host/proc:ro + - /etc:/host/etc:ro + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: global + resources: + limits: + memory: 512M diff --git a/stacks/telegraf/telegraf.conf b/stacks/telegraf/telegraf.conf new file mode 100644 index 0000000..219485f --- /dev/null +++ b/stacks/telegraf/telegraf.conf @@ -0,0 +1,25 @@ +[agent] + interval = "10s" + round_interval = true + metric_batch_size = 1000 + metric_buffer_limit = 10000 + +[[outputs.influxdb]] + # InfluxDB is on hf-pdocker-02 (Compose service, not on Swarm overlay) + urls = ["http://192.168.86.100:8086"] + database = "telegraf" + +# Docker input disabled due to permission issues across nodes with different GIDs +# [[inputs.docker]] +# endpoint = "unix:///var/run/docker.sock" + +[[inputs.cpu]] + percpu = true + totalcpu = true + +[[inputs.disk]] + ignore_fs = ["tmpfs", "devtmpfs", "overlay"] + +[[inputs.mem]] +[[inputs.system]] +[[inputs.net]] diff --git a/stacks/traefik/.env.example b/stacks/traefik/.env.example new file mode 100644 index 0000000..a6147ae --- /dev/null +++ b/stacks/traefik/.env.example @@ -0,0 +1 @@ +CF_DNS_API_TOKEN=change_me_cloudflare_api_token diff --git a/stacks/traefik/docker-compose.yml b/stacks/traefik/docker-compose.yml new file mode 100644 index 0000000..cd7ae33 --- /dev/null +++ b/stacks/traefik/docker-compose.yml @@ -0,0 +1,58 @@ +services: + traefik: + image: traefik:v3.6 + command: + - "--api.insecure=true" + - "--providers.swarm=true" + - "--providers.swarm.exposedbydefault=false" + - "--providers.file.directory=/config/dynamic" + - "--providers.file.watch=true" + - "--entrypoints.web.address=:80" + - "--entrypoints.websecure.address=:443" + - "--certificatesresolvers.letsencrypt.acme.dnschallenge=true" + - "--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare" + - "--certificatesresolvers.letsencrypt.acme.email=jeremydanielhobbs@gmail.com" + - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" + environment: + - CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN} + ports: + - target: 80 + published: 80 + mode: host + - target: 443 + published: 443 + mode: host + - target: 8080 + published: 8090 + mode: host + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - /mnt/docker-data/traefik/letsencrypt:/letsencrypt + - /mnt/docker-data/traefik/dynamic:/config/dynamic:ro + networks: + - traefik-public + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:8080/api/overview || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 512M +networks: + traefik-public: + driver: overlay + attachable: true + external: true diff --git a/stacks/traefik/dynamic/gitea.yml b/stacks/traefik/dynamic/gitea.yml new file mode 100644 index 0000000..d2d5c4f --- /dev/null +++ b/stacks/traefik/dynamic/gitea.yml @@ -0,0 +1,26 @@ +http: + routers: + gitea: + rule: "Host(`git.hobbs.farm`)" + entryPoints: + - websecure + service: gitea + tls: + certResolver: letsencrypt + gitea-http: + rule: "Host(`git.hobbs.farm`)" + entryPoints: + - web + middlewares: + - gitea-https-redirect + service: gitea + middlewares: + gitea-https-redirect: + redirectScheme: + scheme: https + permanent: true + services: + gitea: + loadBalancer: + servers: + - url: "http://192.168.86.100:3003" diff --git a/stacks/traefik/dynamic/vaultwarden.yml b/stacks/traefik/dynamic/vaultwarden.yml new file mode 100644 index 0000000..f99021a --- /dev/null +++ b/stacks/traefik/dynamic/vaultwarden.yml @@ -0,0 +1,26 @@ +http: + routers: + vault: + rule: "Host(`vault.hobbs.farm`)" + entryPoints: + - websecure + service: vault + tls: + certResolver: letsencrypt + vault-http: + rule: "Host(`vault.hobbs.farm`)" + entryPoints: + - web + middlewares: + - vault-https-redirect + service: vault + middlewares: + vault-https-redirect: + redirectScheme: + scheme: https + permanent: true + services: + vault: + loadBalancer: + servers: + - url: "http://192.168.86.100:26226" diff --git a/stacks/trivy/docker-compose.yml b/stacks/trivy/docker-compose.yml new file mode 100644 index 0000000..a909db5 --- /dev/null +++ b/stacks/trivy/docker-compose.yml @@ -0,0 +1,17 @@ +services: + trivy: + image: aquasec/trivy:latest + command: server --listen 0.0.0.0:4954 + ports: + - "4954:4954" + volumes: + - trivy-cache:/root/.cache/ + - /var/run/docker.sock:/var/run/docker.sock:ro + deploy: + replicas: 1 + placement: + constraints: + - node.role == manager + +volumes: + trivy-cache: diff --git a/stacks/unifi/.env.example b/stacks/unifi/.env.example new file mode 100644 index 0000000..502ed3b --- /dev/null +++ b/stacks/unifi/.env.example @@ -0,0 +1,2 @@ +UNIFI_MONGO_USER=unifi +UNIFI_MONGO_PASS=change_me diff --git a/stacks/unifi/docker-compose.yml b/stacks/unifi/docker-compose.yml new file mode 100644 index 0000000..09e3431 --- /dev/null +++ b/stacks/unifi/docker-compose.yml @@ -0,0 +1,77 @@ +version: "3.8" + +services: + unifi: + image: lscr.io/linuxserver/unifi-network-application:latest + restart: unless-stopped + environment: + - PUID=1000 + - PGID=1000 + - TZ=America/Chicago + - MONGO_USER=${UNIFI_MONGO_USER} + - MONGO_PASS=${UNIFI_MONGO_PASS} + - MONGO_HOST=unifi-db + - MONGO_PORT=27017 + - MONGO_DBNAME=unifi + volumes: + - /data/configs/unifi/data:/config + ports: + - "8443:8443" + - "3478:3478/udp" + - "10001:10001/udp" + - "8080:8080" + - "1900:1900/udp" + - "8843:8843" + - "8880:8880" + - "6789:6789" + - "5514:5514/udp" + networks: + - unifi-internal + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 1G + + unifi-db: + image: mongo:7.0 + restart: unless-stopped + volumes: + - /data/configs/unifi/mongo-data:/data/db + networks: + - unifi-internal + healthcheck: + test: ["CMD-SHELL", "mongosh --eval 'db.adminCommand(\"ping\")' --quiet"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + labels: + - "com.centurylinklabs.watchtower.enable=false" + resources: + limits: + memory: 1G + +networks: + unifi-internal: + driver: overlay diff --git a/stacks/vaultwarden/docker-compose.yml b/stacks/vaultwarden/docker-compose.yml new file mode 100644 index 0000000..3135851 --- /dev/null +++ b/stacks/vaultwarden/docker-compose.yml @@ -0,0 +1,34 @@ +version: "3.8" + +services: + vaultwarden: + image: vaultwarden/server:latest + restart: unless-stopped + environment: + - DOMAIN=https://vault.hobbs.farm + - SIGNUPS_ALLOWED=false + volumes: + - /data/configs/vaultwarden:/data + ports: + - "26226:80" + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:80/alive || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 256M + diff --git a/stacks/watchtower/docker-compose.yml b/stacks/watchtower/docker-compose.yml new file mode 100644 index 0000000..0d9eaa8 --- /dev/null +++ b/stacks/watchtower/docker-compose.yml @@ -0,0 +1,27 @@ +services: + watchtower: + image: containrrr/watchtower:latest + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + environment: + - WATCHTOWER_CLEANUP=true + - WATCHTOWER_INCLUDE_RESTARTING=true + - WATCHTOWER_POLL_INTERVAL=86400 + - WATCHTOWER_MONITOR_ONLY=false + - TZ=America/Chicago + - DOCKER_API_VERSION=1.44 + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 256M diff --git a/stacks/wikijs/.env.example b/stacks/wikijs/.env.example new file mode 100644 index 0000000..c7cbfd9 --- /dev/null +++ b/stacks/wikijs/.env.example @@ -0,0 +1 @@ +WIKIJS_DB_PASSWORD=change_me diff --git a/stacks/wikijs/docker-compose.yml b/stacks/wikijs/docker-compose.yml new file mode 100644 index 0000000..7c09b01 --- /dev/null +++ b/stacks/wikijs/docker-compose.yml @@ -0,0 +1,77 @@ +version: "3.8" + +services: + wikijs: + image: ghcr.io/requarks/wiki:2 + restart: unless-stopped + ports: + - "3002:3000" + environment: + - DB_TYPE=postgres + - DB_HOST=wikijs-db + - DB_PORT=5432 + - DB_USER=wikijs + - DB_PASS=${WIKIJS_DB_PASSWORD} + - DB_NAME=wiki + depends_on: + - wikijs-db + networks: + - wikijs-internal + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000/ || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 120s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + resources: + limits: + memory: 512M + + wikijs-db: + image: postgres:15-alpine + restart: unless-stopped + environment: + - POSTGRES_DB=wiki + - POSTGRES_USER=wikijs + - POSTGRES_PASSWORD=${WIKIJS_DB_PASSWORD} + volumes: + - /data/configs/wikijs/db:/var/lib/postgresql/data + networks: + - wikijs-internal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wikijs -d wiki"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + deploy: + mode: replicated + replicas: 1 + placement: + constraints: + - node.hostname == hf-pdocker-01 + labels: + - "com.centurylinklabs.watchtower.enable=false" + resources: + limits: + memory: 256M + +networks: + wikijs-internal: + driver: overlay