├── .dockerignore ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .nuke ├── config └── parameters.json ├── Dockerfile ├── GitVersion.yml ├── README.md ├── app ├── api │ ├── api.py │ └── log_config.py ├── config.json ├── entrypoint.ps1 ├── requirements.txt ├── run-update.ps1 └── watchdog.psm1 ├── build-with-agent.ps1 ├── build.ps1 ├── config.overrides.json ├── docker-compose.example ├── docker-compose.yml ├── run.ps1 ├── run.sh └── vscode.code-workspace /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | __pycache__ 4 | *.pyc 5 | *.pyo 6 | *.pyd 7 | .Python 8 | env/ 9 | venv/ -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Container-CI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | 9 | permissions: 10 | packages: write 11 | contents: write 12 | 13 | jobs: 14 | Container-CI: 15 | runs-on: ubuntu-latest 16 | container: 17 | image: ghcr.io/the-running-dev/build-agent:latest 18 | 19 | steps: 20 | - name: Checkout Repository 21 | uses: actions/checkout@v3 22 | with: 23 | fetch-depth: 0 24 | 25 | - name: Container CI 26 | run: container-ci 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | RepositoryToken: ${{ secrets.GITHUBPACKAGESTOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | build-local.ps1 3 | 4 | .env 5 | 6 | .idea/ 7 | 8 | .resolved-version 9 | 10 | .nuke/temp/ 11 | -------------------------------------------------------------------------------- /.nuke/config: -------------------------------------------------------------------------------- 1 | ImageTag = watchdog 2 | Repository = ghcr.io/the-running-dev 3 | RepositoryUsername = the-running-dev -------------------------------------------------------------------------------- /.nuke/parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "build.schema.json" 3 | } 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build Python virtual environment 2 | FROM python:3.12-slim-bookworm AS builder 3 | 4 | ENV VIRTUAL_ENV=/opt/venv 5 | ENV PATH="$VIRTUAL_ENV/bin:$PATH" 6 | 7 | WORKDIR /build 8 | 9 | # Copy only requirements first for caching 10 | COPY app/requirements.txt . 11 | 12 | # Create virtual environment and install Python packages 13 | RUN python -m venv $VIRTUAL_ENV && \ 14 | $VIRTUAL_ENV/bin/pip install --upgrade pip && \ 15 | $VIRTUAL_ENV/bin/pip install --no-cache-dir -r requirements.txt 16 | 17 | # Stage 2: Final runtime image with preserved Python environment 18 | FROM python:3.12-slim-bookworm AS final 19 | 20 | # Set environment 21 | ENV PYTHONDONTWRITEBYTECODE=1 \ 22 | PYTHONUNBUFFERED=1 \ 23 | TZ=UTC \ 24 | LANG=en_US.UTF-8 \ 25 | LANGUAGE=en_US:en \ 26 | LC_ALL=en_US.UTF-8 \ 27 | PORT=80 \ 28 | DISCORD_WEBHOOK_URL="" \ 29 | TIME_ZONE=UTC \ 30 | VENV_PATH=/opt/venv \ 31 | PATH="/opt/venv/bin:$PATH" 32 | 33 | LABEL maintainer="ben@subzerodev.com" \ 34 | version="1.0" \ 35 | description="Watchdog container with Flask API, Docker health monitoring, and PowerShell logic." 36 | 37 | # Install dependencies and PowerShell 38 | RUN apt-get update && \ 39 | apt-get install -y curl gnupg ca-certificates apt-transport-https lsb-release && \ 40 | # Microsoft repo for PowerShell 41 | curl -sSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft.gpg && \ 42 | echo "deb [signed-by=/usr/share/keyrings/microsoft.gpg] https://packages.microsoft.com/repos/microsoft-debian-bookworm-prod bookworm main" > /etc/apt/sources.list.d/microsoft.list && \ 43 | # Docker repo for Compose plugin 44 | curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker.gpg && \ 45 | echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list && \ 46 | apt-get update && \ 47 | apt-get install -y --no-install-recommends \ 48 | powershell \ 49 | cron \ 50 | locales \ 51 | tzdata \ 52 | docker.io \ 53 | docker-compose-plugin \ 54 | jq \ 55 | unzip \ 56 | nano \ 57 | procps && \ 58 | echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && \ 59 | locale-gen && \ 60 | apt-get clean && rm -rf /var/lib/apt/lists/* 61 | 62 | # Set working directory 63 | WORKDIR /app 64 | 65 | # Copy built venv and app source 66 | COPY --from=builder /opt/venv /opt/venv 67 | COPY ./app /app 68 | 69 | # Ensure entrypoint is executable 70 | RUN chmod +x /app/entrypoint.ps1 71 | 72 | EXPOSE ${PORT} 73 | 74 | # Healthcheck for Flask API 75 | HEALTHCHECK --interval=60s --timeout=10s --start-period=10s --retries=3 \ 76 | CMD curl --fail http://localhost:${PORT}/health || exit 1 77 | 78 | # Run entrypoint 79 | ENTRYPOINT ["pwsh", "/app/entrypoint.ps1"] 80 | -------------------------------------------------------------------------------- /GitVersion.yml: -------------------------------------------------------------------------------- 1 | mode: ContinuousDeployment 2 | next-version: v0.0.0 3 | 4 | branches: 5 | main: 6 | regex: ^main$ 7 | increment: Minor 8 | feature: 9 | regex: ^features?[/-] 10 | increment: Inherit 11 | source-branches: [main] 12 | release: 13 | regex: ^releases?[/-] 14 | increment: Patch 15 | source-branches: [main] 16 | hotfix: 17 | regex: ^hotfix(es)?[/-] 18 | increment: Patch 19 | source-branches: [main] 20 | pull-request: 21 | regex: ^(pull|pr)[/-] 22 | increment: Inherit 23 | source-branches: [main, feature, release, hotfix] 24 | 25 | ignore: 26 | sha: [] 27 | commits-before: 2024-01-01T00:00:00Z 28 | 29 | commit-message-incrementing: Enabled 30 | tag-prefix: v -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Docker-Watchdog 2 | 3 | 4 | - [Docker-Watchdog](#docker-watchdog) 5 | - [🚀 Features](#-features) 6 | - [🏗️ Architecture \& Workflow](#️-architecture--workflow) 7 | - [Workflow Diagram](#workflow-diagram) 8 | - [📁 Project Structure](#-project-structure) 9 | - [🧩 Module \& Script Roles](#-module--script-roles) 10 | - [PowerShell Module: `watchdog.psm1`](#powershell-module-watchdogpsm1) 11 | - [Flask API: `api.py`](#flask-api-apipy) 12 | - [Entrypoint: `entrypoint.ps1`](#entrypoint-entrypointps1) 13 | - [Update Script: `run-update.ps1`](#update-script-run-updateps1) 14 | - [Dockerfile](#dockerfile) 15 | - [📋 Requirements](#-requirements) 16 | - [🔧 Installation](#-installation) 17 | - [Using Docker Compose (Recommended)](#using-docker-compose-recommended) 18 | - [Using Docker CLI](#using-docker-cli) 19 | - [⚙️ Configuration](#️-configuration) 20 | - [Configuration Files](#configuration-files) 21 | - [Configuration Options](#configuration-options) 22 | - [Environment Variables](#environment-variables) 23 | - [Container Dependencies](#container-dependencies) 24 | - [🌐 API Reference](#-api-reference) 25 | - [Endpoints](#endpoints) 26 | - [`GET /health`](#get-health) 27 | - [`POST /restart`](#post-restart) 28 | - [`POST /restart-project`](#post-restart-project) 29 | - [API Security](#api-security) 30 | - [📝 Real-World Usage \& Advanced Scenarios](#-real-world-usage--advanced-scenarios) 31 | - [🖥️ Uptime Kuma Monitor Setup](#️-uptime-kuma-monitor-setup) 32 | - [CI/CD Integration](#cicd-integration) 33 | - [Custom Health Monitoring](#custom-health-monitoring) 34 | - [Edge Cases \& Troubleshooting](#edge-cases--troubleshooting) 35 | - [📊 Monitoring \& Logging](#-monitoring--logging) 36 | - [🔒 Security Considerations](#-security-considerations) 37 | - [🛠️ Building from Source](#️-building-from-source) 38 | - [Publishing to GitHub Container Registry](#publishing-to-github-container-registry) 39 | - [🧪 Testing \& Development](#-testing--development) 40 | - [📦 Image Publishing](#-image-publishing) 41 | - [🏥 Container Healthcheck Requirements](#-container-healthcheck-requirements) 42 | - [📝 Changelog](#-changelog) 43 | - [📜 License](#-license) 44 | - [👥 Contributing](#-contributing) 45 | - [📞 Contact](#-contact) 46 | 47 | 48 | ![Docker-Watchdog Logo](https://img.shields.io/badge/Docker-Watchdog-blue?style=for-the-badge&logo=docker) 49 | ![PowerShell](https://img.shields.io/badge/PowerShell-5391FE?style=for-the-badge&logo=powershell&logoColor=white) 50 | ![Python](https://img.shields.io/badge/Python-3776AB?style=for-the-badge&logo=python&logoColor=white) 51 | ![Flask](https://img.shields.io/badge/Flask-000000?style=for-the-badge&logo=flask&logoColor=white) 52 | ![Docker](https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white) 53 | 54 | Docker-Watchdog is a robust, containerized management and automation tool for Docker Compose environments. It automatically keeps your containers up-to-date, monitors their health, and provides a RESTful API for integration with monitoring tools like Uptime Kuma. It is designed for self-hosters and DevOps engineers who want hands-off, reliable, and observable Docker operations. 55 | 56 | --- 57 | 58 | ## 🚀 Features 59 | 60 | - **Automated Container Updates**: Scheduled Docker Compose project updates via configurable cron jobs that automatically pull the latest images and recreate containers. 61 | - **Health Monitoring**: Real-time container health monitoring with automatic recovery actions for unhealthy containers. 62 | - **Discord Notifications**: Detailed notifications about container updates, restarts, and health status via Discord webhooks. 63 | - **REST API**: Flask-based API for remote management and integration with monitoring tools like Uptime Kuma. 64 | - **Dependency Management**: Custom container dependency configurations to ensure proper restart order when dependent services need to be restarted. 65 | - **PowerShell Core**: Cross-platform compatibility using PowerShell Core for robust container management. 66 | - **Project Filtering**: Include/exclude specific Docker Compose projects from monitoring and updates. 67 | - **Containerized Solution**: Runs as a container itself for easy deployment and integration into your existing Docker environment. 68 | - **Timezone Support**: Configurable timezone for accurate logging and scheduling. 69 | - **Intelligent Restart Logic**: Only restarts containers when actual image updates are detected. 70 | - **Comprehensive Logging**: All actions and errors are logged with timestamps and levels, both to file and stdout. 71 | - **Robust Error Handling**: All API endpoints and scripts include detailed error handling and validation. 72 | 73 | --- 74 | 75 | ## 🏗️ Architecture & Workflow 76 | 77 | Docker-Watchdog is composed of three tightly integrated layers: 78 | 79 | 1. **PowerShell Module (`watchdog.psm1`)** 80 | - Handles all Docker Compose project discovery, update logic, health checks, dependency management, and notification logic. 81 | - Exposes functions for project scanning, container restarts, cron job management, and more. 82 | 2. **Flask API (`api.py`)** 83 | - Provides a RESTful interface for external tools (e.g., Uptime Kuma) to trigger container or project restarts. 84 | - Handles payload validation, error reporting, and notification dispatch. 85 | 3. **Docker Infrastructure** 86 | - Containerized with a multi-stage Dockerfile, using PowerShell Core and Python in a single image. 87 | - Uses Docker Compose for deployment and volume mounting for configuration and project access. 88 | 89 | ### Workflow Diagram 90 | 91 | ```mermaid 92 | flowchart TD 93 | Kuma["Uptime Kuma / API Client"] 94 | FlaskAPI["Flask API (api.py)"] 95 | PSModule["PowerShell Module (watchdog.psm1)"] 96 | Docker["Docker Engine"] 97 | Discord["Discord"] 98 | 99 | Kuma --> FlaskAPI 100 | Kuma --> FlaskAPI 101 | FlaskAPI --> PSModule 102 | PSModule --> Docker 103 | PSModule --> Discord 104 | FlaskAPI --> Discord 105 | ``` 106 | 107 | - Health events are also monitored directly by the PowerShell module via `docker events`. 108 | - Notifications are sent to Discord via webhooks from both PowerShell and Python layers. 109 | 110 | --- 111 | 112 | ## 📁 Project Structure 113 | 114 | ```text 115 | Docker-Watchdog/ 116 | ├── app/ # Application source code 117 | │ ├── api/ # Flask API code 118 | │ │ ├── api.py # API implementation 119 | │ │ └── log_config.py # Logging configuration 120 | │ ├── config.json # Default configuration 121 | │ ├── entrypoint.ps1 # Container entry point script 122 | │ ├── requirements.txt # Python dependencies 123 | │ ├── run-update.ps1 # Script to run update process 124 | │ └── watchdog.psm1 # PowerShell module with core functionality 125 | ├── build.ps1 # Build script for GitHub Container Registry 126 | ├── config.overrides.json # User configuration overrides 127 | ├── docker-compose.example # Example Docker Compose file 128 | ├── docker-compose.yml # Docker Compose file for deployment 129 | └── Dockerfile # Multi-stage build definition 130 | ``` 131 | 132 | --- 133 | 134 | ## 🧩 Module & Script Roles 135 | 136 | ### PowerShell Module: `watchdog.psm1` 137 | 138 | - **Project Discovery**: Scans the projects directory for Docker Compose files. 139 | - **Update Logic**: Pulls new images, restarts only if updates are detected, and prunes unused resources. 140 | - **Health Monitoring**: Listens to Docker health events and triggers restarts (with dependency handling) for unhealthy containers. 141 | - **Dependency Management**: Ensures dependent containers are restarted in the correct order. 142 | - **Notifications**: Sends Discord notifications for updates, restarts, and failures. 143 | - **Cron Management**: Installs and manages cron jobs for scheduled updates. 144 | - **Configuration**: Loads and merges settings from `config.json` and `config.overrides.json`. 145 | - **Logging**: All actions are logged with timestamps and levels. 146 | 147 | ### Flask API: `api.py` 148 | 149 | - `/health`: Simple health check endpoint. 150 | - `/restart`: Accepts Uptime Kuma-style payloads to restart a specific container, with validation and notification. 151 | - `/restart-project`: Accepts project restart requests, pulls latest images, and recreates containers for a named project. 152 | - **Error Handling**: All endpoints validate payloads and return detailed error messages. 153 | - **Logging**: Uses a custom logger with timezone-aware formatting (see `log_config.py`). 154 | 155 | ### Entrypoint: `entrypoint.ps1` 156 | 157 | - Loads the PowerShell module, sets up environment/config, starts the Flask API (via Gunicorn), installs the updater cron job, and launches the health monitoring loop. 158 | 159 | ### Update Script: `run-update.ps1` 160 | 161 | - Loads the PowerShell module and triggers a one-off update sweep (used by cron). 162 | 163 | ### Dockerfile 164 | 165 | - Multi-stage build: Installs Python, PowerShell, Docker CLI, Docker Compose plugin, and all dependencies. 166 | - Copies all scripts and configuration into the image. 167 | - Sets up the entrypoint and healthcheck. 168 | 169 | --- 170 | 171 | ## 📋 Requirements 172 | 173 | - Docker 174 | - Docker Compose (v2, as a plugin) 175 | - Docker socket access (for container management) 176 | - Volume mounts for Docker Compose projects 177 | 178 | --- 179 | 180 | ## 🔧 Installation 181 | 182 | ### Using Docker Compose (Recommended) 183 | 184 | 1. Create a `docker-compose.yml` file based on the example provided: 185 | 186 | ```yaml 187 | x-commonKeys: &commonOptions 188 | restart: always 189 | stdin_open: true 190 | tty: true 191 | 192 | x-dnsServers: &dnsServers 193 | dns: 194 | - 45.90.28.29 195 | - 45.90.30.29 196 | 197 | services: 198 | watchdog: 199 | image: ghcr.io/the-running-dev/watchdog:latest 200 | container_name: watchdog 201 | volumes: 202 | - ./config.overrides.json:/app/config.overrides.json 203 | - /path/to/your/projects:/projects 204 | - ~/.docker/config.json:/root/.docker/config.json:ro 205 | - /var/run/docker.sock:/var/run/docker.sock 206 | ports: 207 | - 7000:80 208 | <<: [*dnsServers, *commonOptions] 209 | ``` 210 | 211 | 2. Create a `config.overrides.json` file to override default settings: 212 | 213 | ```json 214 | { 215 | "ContainerDependencies": { 216 | "vpn": ["torrents", "newsgroups"] 217 | }, 218 | "DiscordWebhookUrl": "YOUR_DISCORD_WEBHOOK_URL", 219 | "ExcludeProjects": [], 220 | "SendUpdaterNotifications": true, 221 | "SendMonitorNotifications": true, 222 | "SendAPINotifications": true, 223 | "UpdaterTest": false, 224 | "UpdaterCronJob": true, 225 | "UpdaterNotificationTitle": "Containers Update" 226 | } 227 | ``` 228 | 229 | 3. Start the container: 230 | 231 | ```powershell 232 | docker compose up -d 233 | ``` 234 | 235 | ### Using Docker CLI 236 | 237 | ```powershell 238 | docker run -d \ 239 | --name watchdog \ 240 | -p 7000:80 \ 241 | -v ./config.overrides.json:/app/config.overrides.json \ 242 | -v /path/to/your/projects:/projects \ 243 | -v ~/.docker/config.json:/root/.docker/config.json:ro \ 244 | -v /var/run/docker.sock:/var/run/docker.sock \ 245 | ghcr.io/the-running-dev/watchdog:latest 246 | ``` 247 | 248 | --- 249 | 250 | ## ⚙️ Configuration 251 | 252 | ### Configuration Files 253 | 254 | - `config.json`: Default configuration (inside the container) 255 | - `config.overrides.json`: User-defined overrides (mounted as a volume) 256 | 257 | ### Configuration Options 258 | 259 | | Option | Description | Default | 260 | |--------|-------------|---------| 261 | | `ProjectsDirectory` | Directory containing Docker Compose projects | `/projects` | 262 | | `CronFilePath` | Path to cron file | `/etc/cron.d/watchdog` | 263 | | `CronSchedule` | Cron schedule for updates | `0 4 * * *` (4 AM daily) | 264 | | `ContainerDependencies` | Container dependencies map | `{}` | 265 | | `DiscordWebhookUrl` | Discord webhook URL for notifications | `""` | 266 | | `ExcludeProjects` | Projects to exclude from updates | `[]` | 267 | | `IncludeProjects` | Projects to include (if empty, include all) | `[]` | 268 | | `SendUpdaterNotifications` | Send notifications for updates | `true` | 269 | | `SendMonitorNotifications` | Send notifications for monitoring events | `true` | 270 | | `SendAPINotifications` | Send notifications for API events | `true` | 271 | | `UpdaterTest` | Run updater in test mode | `false` | 272 | | `UpdaterCronJob` | Enable updater cron job | `true` | 273 | | `UpdaterNotificationTitle` | Title for update notifications | `Containers Update` | 274 | 275 | #### Environment Variables 276 | 277 | You can override most configuration options using environment variables. Common variables include: 278 | 279 | - `PORT`: The port the Flask API listens on (default: 80) 280 | - `DISCORD_WEBHOOK_URL`: Discord webhook for notifications 281 | - `TIME_ZONE`: Timezone for logs and scheduling (e.g., `America/New_York`) 282 | - `SEND_API_NOTIFICATIONS`: Enable/disable API notifications (`true`/`false`) 283 | 284 | ### Container Dependencies 285 | 286 | Define container dependencies to ensure proper restart order. For example: 287 | 288 | ```json 289 | "ContainerDependencies": { 290 | "vpn": ["torrents", "newsgroups"] 291 | } 292 | ``` 293 | 294 | In this example, if the `vpn` container is restarted, the `torrents` and `newsgroups` containers will also be restarted in that order. 295 | 296 | --- 297 | 298 | ## 🌐 API Reference 299 | 300 | The Docker-Watchdog API is available on port 80 within the container (mapped to your chosen external port). The API is built with Flask and uses Gunicorn as the WSGI server for production deployments. 301 | 302 | ### Endpoints 303 | 304 | #### `GET /health` 305 | 306 | - **Description**: Health check endpoint. 307 | - **Response**: 308 | 309 | ```json 310 | { 311 | "status": "healthy" 312 | } 313 | ``` 314 | 315 | #### `POST /restart` 316 | 317 | - **Description**: Restart a Docker container based on a monitoring payload (e.g., from Uptime Kuma). 318 | - **Request Example**: 319 | 320 | ```json 321 | { 322 | "monitor": { 323 | "name": "my-service", 324 | "description": "container-my-service", 325 | "url": "http://localhost:8080" 326 | }, 327 | "heartbeat": { 328 | "status": 0, 329 | "timezone": "UTC" 330 | } 331 | } 332 | ``` 333 | 334 | - **Response (Success)**: 335 | 336 | ```json 337 | { 338 | "status": "my-service Restarted" 339 | } 340 | ``` 341 | 342 | - **Response (Container Not Found)**: 343 | 344 | ```json 345 | { 346 | "error": "container_not_found", 347 | "details": "Container 'my-service' Not Found", 348 | "container": "my-service" 349 | } 350 | ``` 351 | 352 | - **Response (Error)**: 353 | 354 | ```json 355 | { 356 | "error": "failed", 357 | "details": "...error message...", 358 | "container": "my-service" 359 | } 360 | ``` 361 | 362 | #### `POST /restart-project` 363 | 364 | - **Description**: Restart a Docker Compose project by name, pulling latest images and recreating containers. 365 | - **Request Example**: 366 | 367 | ```json 368 | { 369 | "event": "update-project", 370 | "data": { 371 | "projectId": "myproject", 372 | "additionalArgs": ["--remove-orphans"], 373 | "isTest": false 374 | } 375 | } 376 | ``` 377 | 378 | - **Response (Success)**: 379 | 380 | ```json 381 | { 382 | "status": "Project 'myproject' Restarted Successfully.", 383 | "additionalArgs": ["--remove-orphans"] 384 | } 385 | ``` 386 | 387 | - **Response (Error)**: 388 | 389 | ```json 390 | { 391 | "error": "Docker Compose Pull Failed", 392 | "details": "...error message..." 393 | } 394 | ``` 395 | 396 | ### API Security 397 | 398 | - The API is intended for use within trusted networks or behind a reverse proxy with authentication. 399 | - No authentication is enabled by default; add a reverse proxy (e.g., Traefik, Nginx) for production security. 400 | 401 | --- 402 | 403 | ## 📝 Real-World Usage & Advanced Scenarios 404 | 405 | ## 🖥️ Uptime Kuma Monitor Setup 406 | 407 | - Configure Uptime Kuma to send a webhook to `/restart` when a container is detected as unhealthy. 408 | - Docker-Watchdog will validate the payload, restart the container, and send a Discord notification. 409 | - Example: to integrate Uptime Kuma with Docker-Watchdog for a container (e.g., `container-torrents`): 410 | 411 | 1. **Set up a webhook notification to Docker-Watchdog:** 412 | - Go to **Settings > Notifications > Set Up Notification** in Uptime Kuma. 413 | - **Name:** Watchdog 414 | - **Notification Type:** Webhook 415 | - **Post URL:** `http://:7000/restart` 416 | - **Request Body Preset:** `application/json` 417 | 418 | 2. **Create a monitor and enable the Watchdog notification:** 419 | - Create a new monitor for your container 420 | - **Enable the Watchdog notification** for this monitor. 421 | - In the **Description** field, enter the container to be restarted (e.g., `container-torrents` will restart the `torrents` container). 422 | - The description is used by Docker-Watchdog to map the monitor to the correct container. 423 | 424 | **Example Description:** 425 | ``` 426 | container-torrents 427 | ``` 428 | 429 | **Example Payload Sent by Uptime Kuma:** 430 | ```json 431 | { 432 | "monitor": { 433 | "name": "container-torrents", 434 | "description": "container-torrents", 435 | }, 436 | "heartbeat": { 437 | "status": 0, 438 | "timezone": "UTC" 439 | } 440 | } 441 | ``` 442 | 443 | 3. **How it works:** 444 | - When Uptime Kuma detects the container is down/unhealthy, it sends a webhook to Docker-Watchdog. 445 | - Docker-Watchdog parses the payload, restarts the container specified in the description, and sends a notification (e.g., to Discord). 446 | 447 | --- 448 | 449 | ### CI/CD Integration 450 | 451 | - Use the `/restart-project` endpoint in your CI/CD pipeline to trigger zero-downtime rolling updates after a new image is pushed. 452 | - Example GitHub Actions step: 453 | 454 | ```yaml 455 | - name: Trigger Watchdog Project Restart 456 | run: | 457 | curl -X POST http://your-watchdog-host:7000/restart-project \ 458 | -H 'Content-Type: application/json' \ 459 | -d '{"event":"update-project","data":{"projectId":"myproject","additionalArgs":["--remove-orphans"],"isTest":false}}' 460 | ``` 461 | 462 | ### Custom Health Monitoring 463 | 464 | - Use the PowerShell module directly to build custom health checks or restart logic. 465 | - Extend the Discord notification logic for other chat platforms by modifying the notification functions. 466 | 467 | ### Edge Cases & Troubleshooting 468 | 469 | - **Cron Not Running**: Ensure the container is started with `--cap-add=SYS_TIME` if you need to set the system time or run cron jobs in some environments. 470 | - **Docker Socket Permissions**: The container must have access to `/var/run/docker.sock` and the user must have permission to manage Docker. 471 | - **Project Not Detected**: Ensure your Compose files are named `docker-compose.yml` or `docker-compose.*.yml` and are in the mapped projects directory. 472 | - **Timezone Issues**: Set the `TZ` environment variable to a valid IANA timezone string. 473 | 474 | --- 475 | 476 | ## 📊 Monitoring & Logging 477 | 478 | - All actions, errors, and health events are logged to `/tmp/watchdog.log` and to stdout. 479 | - Log entries include timestamps (with timezone offset), log level, and message. 480 | - The Flask API uses a custom logger with timezone-aware formatting (see `log_config.py`). 481 | 482 | --- 483 | 484 | ## 🔒 Security Considerations 485 | 486 | - The API is unauthenticated by default. For production, restrict access to trusted networks or use a reverse proxy with authentication. 487 | - Discord webhook URLs should be kept secret; do not commit them to version control. 488 | - The container requires access to the Docker socket; only run on trusted hosts. 489 | 490 | --- 491 | 492 | ## 🛠️ Building from Source 493 | 494 | ```powershell 495 | # Clone the repository 496 | git clone https://github.com/the-running-dev/docker-watchdog.git 497 | cd docker-watchdog 498 | 499 | # Build the image locally 500 | docker build -t watchdog:local . 501 | 502 | # Run the container 503 | docker compose -f docker-compose.example up -d 504 | ``` 505 | 506 | ### Publishing to GitHub Container Registry 507 | 508 | The project includes scripts for publishing the image to GitHub Container Registry: 509 | 510 | ```powershell 511 | # Set your GitHub PAT with package write permissions 512 | $env:GitHubPackagesToken = 'your-github-pat' 513 | 514 | # Build and push 515 | ./build.ps1 516 | ``` 517 | 518 | --- 519 | 520 | ## 🧪 Testing & Development 521 | 522 | - To build and run locally, use the provided `docker-compose.yml` or `docker-compose.example`. 523 | - For local development, you can mount your source code and config files directly into the container. 524 | - Use `run.ps1` or `run.sh` for quick local rebuilds. 525 | - All scripts are cross-platform (Linux/Windows/Mac) via PowerShell Core. 526 | 527 | --- 528 | 529 | ## 📦 Image Publishing 530 | 531 | To publish your own image to GitHub Container Registry: 532 | 533 | ```powershell 534 | # Set your GitHub PAT with package write permissions 535 | $env:GitHubPackagesToken = 'your-github-pat' 536 | 537 | # Build and push 538 | ./build.ps1 539 | ``` 540 | 541 | --- 542 | 543 | ## 🏥 Container Healthcheck Requirements 544 | 545 | For Docker-Watchdog to monitor and automatically recover containers, your Docker Compose services **must define a healthcheck**. The healthcheck status is used to determine if a container is healthy or needs to be restarted. 546 | 547 | **Example Docker Compose healthcheck:** 548 | 549 | ```yaml 550 | services: 551 | torrents: 552 | image: your-torrent-image 553 | healthcheck: 554 | test: ["CMD", "curl", "-f", "http://localhost:8080/health"] 555 | interval: 30s 556 | timeout: 10s 557 | retries: 3 558 | start_period: 10s 559 | ``` 560 | 561 | - The `healthcheck` section is required for each service you want Docker-Watchdog to monitor. 562 | - The health status (`healthy`/`unhealthy`) is what triggers automatic restarts and notifications. 563 | 564 | --- 565 | 566 | ## 📝 Changelog 567 | 568 | See [CHANGELOG.md](CHANGELOG.md) for release notes and version history. 569 | 570 | --- 571 | 572 | ## 📜 License 573 | 574 | [MIT License](LICENSE) 575 | 576 | --- 577 | 578 | ## 👥 Contributing 579 | 580 | Contributions are welcome! Please feel free to submit a Pull Request. 581 | 582 | --- 583 | 584 | ## 📞 Contact 585 | 586 | For questions or support, please open an issue on GitHub. 587 | 588 | --- 589 | 590 | Made with ❤️ by the Running Dev -------------------------------------------------------------------------------- /app/api/api.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytz 3 | import requests 4 | import subprocess 5 | from datetime import datetime 6 | from flask import Flask, request, jsonify 7 | 8 | from api.log_config import setup_logger 9 | 10 | SEND_NOTIFICATIONS = os.getenv("SendAPINotifications", "true").lower() in ("1", "true", "yes", "on") 11 | DISCORD_WEBHOOK_URL = os.getenv("DiscordWebhookUrl") 12 | TIME_ZONE = os.getenv("TimeZone", "UTC") 13 | 14 | log = setup_logger(__name__) 15 | 16 | app = Flask(__name__) 17 | 18 | @app.route("/health", methods=["GET"]) 19 | def health_check(): 20 | return jsonify({"status": "healthy"}), 200 21 | 22 | @app.route("/restart", methods=["POST"]) 23 | def restart(): 24 | data = request.get_json() 25 | 26 | if not data: 27 | log.warning("No JSON Payload Received.") 28 | 29 | return jsonify({"error": "No JSON Received"}), 400 30 | 31 | monitor = data.get("monitor") 32 | heartbeat = data.get("heartbeat") 33 | 34 | # If both monitor and heartbeat are None, redirect to /health 35 | if monitor is None and heartbeat is None: 36 | log.info("Both 'monitor' and 'heartbeat' are None. Redirecting to /health.") 37 | 38 | return health_check() 39 | 40 | if monitor is None: 41 | log.warning("No 'monitor' Object in Payload.") 42 | 43 | return jsonify({"error": "No 'monitor' Object in Payload"}), 400 44 | 45 | if heartbeat is None: 46 | log.warning("No 'heartbeat' Object in Payload.") 47 | 48 | return jsonify({"error": "No 'heartbeat' Object in Payload"}), 400 49 | 50 | monitor = monitor or {} 51 | heartbeat = heartbeat or {} 52 | status = heartbeat.get("status") 53 | description = monitor.get("description", "") 54 | service_url = monitor.get("url", "") 55 | 56 | container_name = None 57 | if description and description.startswith("container-"): 58 | container_name = description.split("container-", 1)[1] 59 | 60 | if not container_name: 61 | container_name = monitor.get("name") 62 | 63 | if not container_name: 64 | log.error("No Container Could be Determined from Payload.") 65 | 66 | return jsonify({"error": "No Container Name Found"}), 400 67 | 68 | if status != 0: 69 | return jsonify({"message": f"Ignored {container_name}, Status: {status}"}), 200 70 | 71 | # Check if the container exists 72 | check = subprocess.run( 73 | ["docker", "ps", "-a", "--format", "{{.Names}}"], 74 | capture_output=True, 75 | text=True 76 | ) 77 | 78 | existing_containers = check.stdout.strip().splitlines() 79 | if container_name not in existing_containers: 80 | log.warning(f"Container '{container_name}' Not Found.") 81 | 82 | return jsonify({ 83 | "error": "container_not_found", 84 | "details": f"Container '{container_name}' Not Found", 85 | "container": container_name 86 | }), 404 87 | 88 | # Restart the container 89 | result = subprocess.run(["docker", "restart", container_name], capture_output=True, text=True) 90 | 91 | if result.returncode == 0: 92 | log.info(f"Container '{container_name}' Restarted Successfully.") 93 | 94 | service_name = monitor.get("name", container_name) 95 | time_zone = heartbeat.get("timezone") or TIME_ZONE 96 | 97 | if SEND_NOTIFICATIONS: 98 | send_discord_notification( 99 | service_name=service_name, 100 | service_url=service_url, 101 | time_zone=time_zone 102 | ) 103 | 104 | return jsonify({"status": f"{container_name} Restarted"}), 200 105 | else: 106 | log.error(f"Failed to Restart '{container_name}': {result.stderr.strip()}") 107 | 108 | return jsonify({ 109 | "error": "failed", 110 | "details": result.stderr.strip(), 111 | "container": container_name 112 | }), 500 113 | 114 | @app.route("/restart-project", methods=["POST"]) 115 | def restart_project(): 116 | data = request.get_json() 117 | 118 | log.info(f"/restart-project payload: {data}") 119 | 120 | # Expecting payload: {"event": "update-project", "data": {"projectId": ..., "additionalArgs": ..., "isTest": ...}} 121 | if not data or data.get("event") != "update-project" or "data" not in data: 122 | log.warning("Invalid Payload: Missing 'event' or 'data' Field.") 123 | 124 | return jsonify({"error": "Invalid Payload: Missing 'event' or 'data' Field."}), 400 125 | 126 | event_data = data["data"] 127 | project_name = event_data.get("projectId") 128 | additional_args = event_data.get("additionalArgs", []) 129 | is_test = event_data.get("isTest", False) 130 | project_dir = "." 131 | 132 | if not project_name: 133 | log.warning("No 'projectId' Provided in Payload.") 134 | 135 | return jsonify({"error": "No 'projectId' Provided in Payload."}), 400 136 | 137 | if is_test: 138 | log.info("Test Mode Enabled, Skipping Restart.") 139 | 140 | return jsonify({"status": f"Test Mode: Would Restart Project '{project_name}' (additionalArgs={additional_args})"}), 200 141 | 142 | compose_base = ["docker", "compose", "-p", project_name] 143 | 144 | if additional_args and isinstance(additional_args, list): 145 | compose_base += additional_args 146 | elif additional_args and isinstance(additional_args, str): 147 | compose_base += [additional_args] 148 | 149 | # Step 1: docker compose pull 150 | pull_cmd = compose_base + ["pull"] 151 | pull_proc = subprocess.run(pull_cmd, cwd=project_dir, capture_output=True, text=True) 152 | 153 | if pull_proc.returncode != 0: 154 | log.error(f"Docker Compose Pull Failed: {pull_proc.stderr.strip()}") 155 | 156 | return jsonify({"error": "Docker Compose Pull Failed", "details": pull_proc.stderr.strip()}), 500 157 | 158 | # Step 2: docker compose up -d 159 | up_cmd = compose_base + ["up", "-d"] 160 | up_proc = subprocess.run(up_cmd, cwd=project_dir, capture_output=True, text=True) 161 | 162 | if up_proc.returncode != 0: 163 | log.error(f"Docker Compose Up Failed: {up_proc.stderr.strip()}") 164 | 165 | return jsonify({"error": "Docker Compose Up Failed", "details": up_proc.stderr.strip()}), 500 166 | 167 | log.info(f"Project '{project_name}' Restarted Successfully (additionalArgs={additional_args}).") 168 | 169 | return jsonify({"status": f"Project '{project_name}' Restarted Successfully.", "additionalArgs": additional_args}), 200 170 | 171 | def send_discord_notification(service_name: str, service_url: str, time_zone: str): 172 | if not DISCORD_WEBHOOK_URL: 173 | log.warning("DISCORD_WEBHOOK_URL not Set — Skipping Notification.") 174 | 175 | return 176 | 177 | try: 178 | now = datetime.now(pytz.timezone(time_zone)).strftime("%Y-%m-%d %H:%M:%S") 179 | except Exception: 180 | now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") 181 | 182 | embed = { 183 | "title": f"✅ {service_name} Restarted! ✅", 184 | "color": 0x00ff00, 185 | "fields": [ 186 | {"name": "Name", "value": service_name, "inline": False}, 187 | {"name": "URL", "value": service_url or 'N/A', "inline": False}, 188 | {"name": "Time", "value": now, "inline": False} 189 | ], 190 | "footer": { 191 | "text": "Uptime Kuma Auto Recovery" 192 | } 193 | } 194 | 195 | payload = { 196 | "embeds": [embed] 197 | } 198 | 199 | try: 200 | response = requests.post(DISCORD_WEBHOOK_URL, json=payload) 201 | 202 | if response.status_code in (200, 204): 203 | log.info(f"✅ Discord Notification Sent for {service_name}") 204 | else: 205 | log.warning(f"⚠️ Discord Webhook Returned {response.status_code}: {response.text}") 206 | except Exception as e: 207 | log.error(f"❌ Failed to Send Discord Notification: {e}") 208 | 209 | if __name__ == "__main__": 210 | app.run(host="0.0.0.0", port=7000, debug=False) -------------------------------------------------------------------------------- /app/api/log_config.py: -------------------------------------------------------------------------------- 1 | # log_config.py 2 | 3 | import os 4 | import logging 5 | import pytz 6 | from datetime import datetime 7 | 8 | TIME_ZONE = os.getenv("TimeZone", "UTC") 9 | 10 | class TZFormatter(logging.Formatter): 11 | def __init__(self, fmt=None, datefmt=None): 12 | self.timezone = pytz.timezone(TIME_ZONE) 13 | super().__init__(fmt=fmt, datefmt=datefmt) 14 | 15 | def formatTime(self, record, datefmt=None): 16 | dt = datetime.fromtimestamp(record.created, self.timezone) 17 | 18 | # Format with timezone offset included 19 | return dt.strftime("%Y-%m-%d %H:%M:%S %z") 20 | 21 | def setup_logger(name: str = __name__) -> logging.Logger: 22 | handler = logging.StreamHandler() 23 | formatter = TZFormatter( 24 | fmt="[%(asctime)s] [%(levelname)s] %(message)s", 25 | ) 26 | handler.setFormatter(formatter) 27 | 28 | logger = logging.getLogger(name) 29 | logger.setLevel(logging.INFO) 30 | logger.addHandler(handler) 31 | logger.propagate = False 32 | 33 | return logger -------------------------------------------------------------------------------- /app/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "ProjectsDirectory": "/projects", 3 | "CronFilePath": "/etc/cron.d/watchdog", 4 | "CronSchedule": "0 4 * * *", 5 | "ContainerDependencies": {}, 6 | "DiscordWebhookUrl": "", 7 | "DockerComposeAdditionalArgs": [ 8 | "--always-recreate-deps", 9 | "--remove-orphans" 10 | ], 11 | "ExcludeProjects": [], 12 | "IncludeProjects": [], 13 | "LogFilePath": "/tmp/watchdog.log", 14 | "LockFilePath": "/tmp/watchdog.lock", 15 | "SendUpdaterNotifications": true, 16 | "SendMonitorNotifications": true, 17 | "SendAPINotifications": true, 18 | "UpdaterTest": false, 19 | "UpdaterCronJob": true, 20 | "UpdaterNotificationTitle": "Containers Update" 21 | } -------------------------------------------------------------------------------- /app/entrypoint.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env pwsh 2 | $ErrorActionPreference = "Stop" 3 | 4 | $gunicorn = "/opt/venv/bin/gunicorn" 5 | 6 | if ($env:TZ) { 7 | $zoneinfo = "/usr/share/zoneinfo/$($env:TZ)" 8 | 9 | if (Test-Path $zoneinfo) { 10 | Remove-Item -Force /etc/localtime 11 | Copy-Item $zoneinfo /etc/localtime 12 | Set-Content /etc/timezone $env:TZ 13 | } 14 | } 15 | 16 | Import-Module "$PSScriptRoot/watchdog.psm1" 17 | 18 | Get-Config 19 | 20 | if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { 21 | Write-Error "❌ ERROR: Write-Log() Function Not Found. Failed to Load watchdog.psm1" 22 | 23 | exit 1 24 | } 25 | 26 | # Ensure PORT is set 27 | if (-not $env:PORT) { 28 | $env:PORT = "80" 29 | 30 | Write-Log "⚠️ PORT Not Set, Using Default: 80" 31 | } 32 | 33 | # Check required files 34 | if (-not (Test-Path "/app/api/api.py")) { 35 | Write-Error "❌ ERROR: api.py Not Found!" 36 | 37 | exit 1 38 | } 39 | if (-not (Test-Path $gunicorn)) { 40 | Write-Log "❌ ERROR: Gunicorn Not Found at $gunicorn" 41 | 42 | exit 1 43 | } 44 | 45 | Set-APIConfig 46 | 47 | # Start Flask API via Gunicorn 48 | Write-Log "🚀 Starting Flask API On Port $($env:PORT), Notifications: $($env:SendAPINotifications)..." 49 | 50 | Start-Process -FilePath $gunicorn ` 51 | -ArgumentList "-w", "2", "--timeout", "120", "-b", "0.0.0.0:$env:PORT", "api.api:app" ` 52 | -NoNewWindow -PassThru | ForEach-Object { $apiPid = $_.Id } 53 | 54 | Start-Sleep -Seconds 2 55 | 56 | if (-not (Get-Process -Id $apiPid -ErrorAction SilentlyContinue)) { 57 | Write-Log "❌ ERROR: Flask API Failed to Start!" 58 | 59 | exit 1 60 | } 61 | 62 | # Handle shutdown 63 | $null = Register-EngineEvent PowerShell.Exiting -Action { 64 | Write-Log "🔻 Shutting Down..." 65 | 66 | try { 67 | Stop-Process -Id $apiPid -Force -ErrorAction SilentlyContinue 68 | } catch {} 69 | 70 | Write-Log "👋 Goodbye!" 71 | } 72 | 73 | Install-Updater 74 | 75 | Start-Watchdog -------------------------------------------------------------------------------- /app/requirements.txt: -------------------------------------------------------------------------------- 1 | gunicorn 2 | flask 3 | requests 4 | pytz -------------------------------------------------------------------------------- /app/run-update.ps1: -------------------------------------------------------------------------------- 1 | Import-Module (Join-Path $PSScriptRoot "watchdog.psm1") -Force 2 | 3 | Get-Config 4 | 5 | $update = Start-Update -------------------------------------------------------------------------------- /app/watchdog.psm1: -------------------------------------------------------------------------------- 1 | $script:config = @{ 2 | ProjectsDirectory = "/projects" 3 | ContainerDependencies = @{} 4 | CronFilePath = "/etc/cron.d/watchdog" 5 | CronSchedule = "0 4 * * *" 6 | DiscordWebhookUrl = "" 7 | DockerComposeAdditionalArgs = @( 8 | "--always-recreate-deps" 9 | "--remove-orphans" 10 | ) 11 | ExcludeProjects = @() 12 | IncludeProjects = @() 13 | LogFilePath = "/tmp/watchdog.log" 14 | LockFilePath = "/tmp/watchdog.lock" 15 | PulledImages = @{} 16 | SendUpdaterNotifications = $true 17 | SendMonitorNotifications = $false 18 | SendAPINotifications = $false 19 | UpdaterTest = true 20 | UpdaterCronJob = true 21 | UpdaterNotificationTitle = "Containers Update" 22 | } 23 | 24 | function Add-UpdaterCronJob { 25 | if (-not $script:config.UpdaterCronJob) { 26 | Write-Warning "🛠️ Skipping Cron Job Setup" 27 | 28 | return 29 | } 30 | 31 | $scriptPath = Join-Path $PSScriptRoot "run-update.ps1" 32 | 33 | Write-Info "🛠️ Setting Up Cron Job for Docker Updater" 34 | 35 | # Read schedule 36 | $schedule = $script:config.CronSchedule | Where-Object { $_ -notmatch '^\s*#' -and $_.Trim() -ne '' } | Select-Object -First 1 37 | if (-not $schedule) { 38 | Write-Error "❌ ERROR: No Valid Cron Expression Found in Configuration File. Exiting..." 39 | 40 | return 41 | } 42 | 43 | # Build cron command 44 | $cronCommand = "$schedule root /usr/bin/pwsh -NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$scriptPath`" >> /var/log/watchdog.log 2>&1`n" 45 | 46 | # Write cron.d entry 47 | Set-Content -Path $script:config.CronFilePath -Value $cronCommand 48 | 49 | chmod 0644 $script:config.CronFilePath 50 | 51 | Write-Info "🕒 Installed Cron Job: $cronCommand" 52 | 53 | # Start cron 54 | if (Get-Command service -ErrorAction SilentlyContinue) { 55 | & service cron start 56 | 57 | Write-Info "🚀 Cron Daemon Started" 58 | } elseif (Get-Command crond -ErrorAction SilentlyContinue) { 59 | & crond 60 | 61 | Write-Info "🚀 Crond Started (Alpine Style)" 62 | } else { 63 | Write-Warning "⚠️ No Cron Service Found to Start!" 64 | } 65 | } 66 | 67 | function Confirm-ImageUpdated { 68 | param ( 69 | [Parameter(Mandatory)][string]$image, 70 | [Parameter(Mandatory)][string]$container 71 | ) 72 | 73 | try { 74 | if (-not $script:config.PulledImages.ContainsKey($image)) { 75 | if ($script:config.UpdaterTest) { 76 | Write-Info "`tℹ️ Simulating Pulling Image $image" 77 | 78 | return $true 79 | } else { 80 | Invoke-DockerPull $image 81 | } 82 | 83 | $script:config.PulledImages[$image] = $true 84 | } 85 | 86 | $latestImageId = (Invoke-DockerImageInspect $image | ConvertFrom-Json)[0].Id 87 | $runningImageId = (Invoke-DockerInspect $container | ConvertFrom-Json)[0].Image 88 | 89 | if ($latestImageId -ne $runningImageId) { 90 | Write-Error "`t✅ New Images Exists..." 91 | } 92 | 93 | return $latestImageId -ne $runningImageId 94 | } catch { 95 | Write-Error "⚠️ Failed to Compare Image for $container. Assuming Update is Needed." 96 | 97 | return $true 98 | } 99 | } 100 | 101 | function Confirm-IncludeProject { 102 | param ( 103 | [Parameter(Mandatory)][string]$projectName 104 | ) 105 | 106 | if ($script:config.IncludeProjects.Count -gt 0) { 107 | return $script:config.IncludeProjects -contains $projectName 108 | } else { 109 | return -not ($script:config.ExcludeProjects -contains $projectName) 110 | } 111 | } 112 | 113 | function Confirm-PreflightCheck { 114 | Write-Info "🔍 Pre-Flight Checks..." 115 | 116 | if (-not (Test-Path $script:config.ProjectsDirectory)) { 117 | Write-Error "❌ Base Directory '$($script:config.ProjectsDirectory)' Not Found...Exiting" 118 | 119 | return $false 120 | } 121 | 122 | if ($script:config.DiscordWebhookUrl -notmatch "^https://discord.com/api/webhooks/") { 123 | Write-Error "❌ Discord Webhook URL is Invalid...Disabling Notifications" 124 | 125 | $script:config.SendUpdaterNotifications = $false 126 | $script:config.SendMonitorNotifications = $false 127 | $script:config.SendAPINotifications = $false 128 | } 129 | 130 | if (-not (Get-ChildItem -Recurse -Filter 'docker-compose*' $script:config.ProjectsDirectory -File -ErrorAction SilentlyContinue)) { 131 | Write-Error "🗂️ Did You Map '$($script:config.ProjectsDirectory)' as a Docker Volume?" 132 | Write-Error "❌ No Compose Files Found in Base Directory '$($script:config.ProjectsDirectory)'...Exiting" 133 | 134 | return $false 135 | } 136 | 137 | Write-Info "✅ Pre-Flight Checks Passed." 138 | 139 | return $true 140 | } 141 | 142 | function Convert-ToCapitalCase { 143 | [CmdletBinding()] 144 | param ( 145 | [Parameter(Mandatory, ValueFromPipeline)][string]$inputString 146 | ) 147 | 148 | process { 149 | $words = ($InputString -creplace '([a-z])([A-Z])', '$1 $2') 150 | $culture = [System.Globalization.CultureInfo]::InvariantCulture 151 | $titleCased = $culture.TextInfo.ToTitleCase($words.ToLower()) 152 | 153 | return $titleCased 154 | } 155 | } 156 | 157 | function Get-ProjectsWithUpdatedImages { 158 | $projects = @() 159 | 160 | Write-Info "🔍 Scanning for Docker Compose Projects in $($script:config.ProjectsDirectory)..." 161 | 162 | $composeFiles = Get-ChildItem ` 163 | -File ` 164 | -Path $script:config.ProjectsDirectory ` 165 | -Recurse ` 166 | -Filter 'docker-compose*.yml' | Sort-Object FullName 167 | 168 | if (-not $composeFiles) { 169 | Write-Error "❌ No Docker Compose Files Found in $($script:config.ProjectsDirectory)...Exiting" 170 | 171 | return $projects 172 | } 173 | 174 | foreach ($composeFile in $composeFiles) { 175 | $project = Split-Path $composeFile.FullName -Parent | Split-Path -Leaf 176 | $configDir = Split-Path $composeFile.FullName -Parent 177 | $containers = @() 178 | 179 | Write-Info "$($project):" 180 | 181 | if (-not (Confirm-IncludeProject $project)) { 182 | Write-Warning "`t❌ Skipped (Excluded)" 183 | 184 | continue 185 | } 186 | 187 | $containers = @(& docker ps --filter "label=com.docker.compose.project=$project" --format '{{.Names}}={{.Image}}' | ForEach-Object { 188 | $parts = $_ -split '=' 189 | 190 | if ($parts.Count -eq 2) { 191 | [PSCustomObject]@{ 192 | Name = $parts[0].Trim() 193 | Image = $parts[1].Trim() 194 | } 195 | } 196 | }) 197 | 198 | if ($containers.Count -eq 0) { 199 | Write-Info "`tℹ️ No Running Services...Skipping." 200 | 201 | continue 202 | } 203 | 204 | $updatedImages = 0 205 | $containers | ForEach-Object { 206 | $name = $_.Name.Trim() 207 | $image = $_.Image.Trim() 208 | $hasUpdatedImages = Confirm-ImageUpdated -image $image -container $name 209 | 210 | if ($hasUpdatedImages) { 211 | $updatedImages++ 212 | } 213 | } 214 | 215 | if ($updatedImages -eq 0) { 216 | Write-Info "`tℹ️ No Image Updates...Skipping." 217 | 218 | continue 219 | } 220 | 221 | $projects += [PSCustomObject]@{ 222 | Name = $project 223 | Directory = $configDir 224 | Config = Split-Path $composeFile -Leaf 225 | ComposeFile = $composeFile.FullName 226 | Containers = $containers 227 | HasUpdatedImages = $updatedImages -gt 0 228 | } 229 | } 230 | 231 | return $projects 232 | } 233 | 234 | function Set-APIConfig { 235 | $env:SendAPINotifications = $script:config.SendAPINotifications 236 | $env:DiscordWebhookUrl = $script:config.DiscordWebhookUrl 237 | $env:TimeZone = ([System.TimeZoneInfo]::Local).Id 238 | } 239 | 240 | function Get-Config { 241 | $configFilePath = Join-Path $PSScriptRoot "config.json" 242 | $configOverridesPath = Join-Path $PSScriptRoot "config.overrides.json" 243 | 244 | if (Test-Path $configFilePath) { 245 | Write-Info "Configuration File: $configFilePath" 246 | 247 | $baseConfig = Read-ConfigFile $configFilePath 248 | 249 | foreach ($key in $baseConfig.Keys) { 250 | $script:config[$key] = $baseConfig[$key] 251 | } 252 | } else { 253 | Write-Warning "No config.json Found — Using Defaults." 254 | } 255 | 256 | if (Test-Path $configOverridesPath) { 257 | Write-Info "Configuration Overrides: $configOverridesPath" 258 | 259 | $overrides = Read-ConfigFile $configOverridesPath 260 | 261 | foreach ($key in $overrides.Keys) { 262 | $script:config[$key] = $overrides[$key] 263 | } 264 | } 265 | 266 | Write-Info "🔍 Configuration" 267 | 268 | $script:config.GetEnumerator() | Sort-Object Name | ForEach-Object { 269 | $key = $_.Key | Convert-ToCapitalCase 270 | $value = $_.Value 271 | 272 | if ($_.Key -eq "ContainerDependencies") { 273 | $value = ($value.GetEnumerator() | Sort-Object Name | ForEach-Object { 274 | "$($_.Key): $($_.Value)" 275 | }) -join ", " 276 | } 277 | elseif ($_.Key -eq "DiscordWebhookUrl") { 278 | $value = "********" 279 | } 280 | elseif ($_.Key -eq "PulledImages") { 281 | return 282 | } 283 | 284 | Write-Info "$($key): $value" 285 | } 286 | } 287 | 288 | function Get-Lock { 289 | if (Test-Path $script:config.LockFilePath) { 290 | Write-Error "Lock File Exists. Exiting...." 291 | 292 | return 293 | } 294 | 295 | New-Item -ItemType File -Path $script:config.LockFilePath | Out-Null 296 | 297 | Register-EngineEvent PowerShell.Exiting -Action { 298 | Remove-Lock 299 | } | Out-Null 300 | } 301 | 302 | function Get-TimeStamp { 303 | try { 304 | $tz = [System.TimeZoneInfo]::Local 305 | $utcNow = (Get-Date).ToUniversalTime() 306 | $localTime = [System.TimeZoneInfo]::ConvertTimeFromUtc($utcNow, $tz) 307 | 308 | $offset = $tz.GetUtcOffset($localTime) 309 | $sign = if ($offset.TotalMinutes -lt 0) { "-" } else { "+" } 310 | $offsetFormatted = "{0}{1:00}:{2:00}" -f $sign, [math]::Abs($offset.Hours), [math]::Abs($offset.Minutes) 311 | 312 | return "{0} {1}" -f $localTime.ToString("yyyy-MM-dd HH:mm:ss"), $offsetFormatted 313 | } catch { 314 | $fallbackTime = (Get-Date).ToUniversalTime() 315 | 316 | return "{0} +00:00" -f $fallbackTime.ToString("yyyy-MM-dd HH:mm:ss") 317 | } 318 | } 319 | 320 | function Install-Updater { 321 | Write-Log "🔁 Installing Updater, Test: $($script:config.UpdaterTest)..." 322 | 323 | Add-UpdaterCronJob 324 | 325 | Write-Log "🔁 Starting Updater..." 326 | 327 | if (-not (Start-Update)) { 328 | Write-Error "❌ Failed to Start Updater" 329 | 330 | return 331 | } 332 | } 333 | 334 | function Invoke-Docker { 335 | param ( 336 | [Parameter(Mandatory)][string[]]$arguments 337 | ) 338 | 339 | try { 340 | & docker @arguments 341 | } catch { 342 | Write-Error "❌ Failed to Execute Docker Command: $_" 343 | } 344 | } 345 | 346 | function Invoke-DockerInspect { 347 | param ( 348 | [Parameter(Mandatory)][string[]]$arguments 349 | ) 350 | 351 | try { 352 | & docker inspect @arguments 353 | } catch { 354 | Write-Error "❌ Failed to Execute Docker Inspect: $_" 355 | } 356 | } 357 | 358 | function Invoke-DockerImageInspect { 359 | param ( 360 | [Parameter(Mandatory)][string[]]$arguments 361 | ) 362 | 363 | try { 364 | & docker image inspect @arguments 365 | } catch { 366 | Write-Error "❌ Failed to Execute Docker Inspect: $_" 367 | } 368 | } 369 | 370 | function Invoke-DockerPull { 371 | param ( 372 | [Parameter(Mandatory)][string[]]$arguments 373 | ) 374 | 375 | try { 376 | & docker pull @arguments *> $null 377 | } catch { 378 | Write-Error "❌ Failed to Execute Docker Pull: $_" 379 | } 380 | } 381 | 382 | function Invoke-DockerCompose { 383 | param ( 384 | [Parameter(Mandatory)][string[]]$arguments 385 | ) 386 | 387 | if ($script:config.UpdaterTest) { 388 | Write-Info "`tℹ️ Simulating 'docker compose $($arguments -join ' '))'" 389 | 390 | return 391 | } 392 | 393 | try { 394 | & docker compose @arguments 395 | } catch { 396 | Write-Error "❌ Failed to Execute Docker Compose: $_" 397 | } 398 | } 399 | 400 | function Invoke-DockerComposeRestart { 401 | param ( 402 | [Parameter(Mandatory)][string]$file 403 | ) 404 | 405 | try { 406 | $composeArgs = @('--file', $file, 'up', '-d') + ($script:config.DockerComposeAdditionalArgs | ForEach-Object { $_.ToString() }) 407 | 408 | & docker compose @composeArgs *> $null 409 | } catch { 410 | Write-Error "❌ Failed to Execute Docker Compose Up: $_" 411 | } 412 | } 413 | 414 | function Restart-Container { 415 | param ( 416 | [Parameter(Mandatory)][string]$name 417 | ) 418 | 419 | if (-not (Test-ContainerExists -name $name)) { 420 | Write-Warning "⚠️ Container '$name' Does Not Exist. Skipping Restart." 421 | 422 | return 423 | } 424 | 425 | Write-Info "Restarting $name..." 426 | 427 | try { 428 | $null = & docker restart $name -ErrorAction Stop 429 | 430 | if ($script:config.SendMonitorNotifications) { 431 | Send-DiscordNotificationForServiceRestart $name 432 | } 433 | 434 | Write-Info "$name Restarted." 435 | } catch { 436 | Write-Error "❌ Failed to Restart $($name): $($_.Exception.Message)" 437 | } 438 | } 439 | 440 | function Restart-Unhealthy { 441 | param ( 442 | [Parameter(Mandatory)][string]$container, 443 | [Parameter(Mandatory)][hashtable]$dependenciesMap 444 | ) 445 | 446 | $dependents = $dependenciesMap[$container] 447 | 448 | if ($dependents) { 449 | Write-Info "❌ $container is Unhealthy. Restarting and Cascading..." 450 | 451 | Restart-Container -Name $container 452 | 453 | if (Wait-ForHealthy -Name $container) { 454 | foreach ($dep in $dependents) { 455 | Write-Info "🔁 Restarting Dependent: $dep" 456 | 457 | Restart-Container -Name $dep 458 | } 459 | } else { 460 | Write-Warning "⚠️ $container Did Not Become Healthy. Skipping Dependent Restarts." 461 | } 462 | } else { 463 | Write-Warning "❌ $container is Unhealthy. Restarting..." 464 | 465 | Restart-Container -Name $Container 466 | } 467 | } 468 | 469 | function Read-ConfigFile { 470 | param ( 471 | [Parameter(Mandatory)][string[]]$file 472 | ) 473 | 474 | $config = @{} 475 | $trueValues = @("1", "true", "yes", "on") 476 | $falseValues = @("0", "false", "no", "off") 477 | 478 | (Get-Content $file -Raw | ConvertFrom-Json).PSObject.Properties | ForEach-Object { 479 | $key = $_.Name 480 | $value = $_.Value 481 | 482 | if ($trueValues -contains $value) { 483 | $value = $true 484 | } elseif ($falseValues -contains $value) { 485 | $value = $false 486 | } 487 | elseif ($key -eq "ContainerDependencies") { 488 | $value = @{} 489 | 490 | foreach ($dep in $_.Value.PSObject.Properties) { 491 | $value[$dep.Name] = $dep.Value 492 | } 493 | } elseif ($key -eq "DiscordWebhookUrl") { 494 | if ($value -notmatch "^https://discord.com/api/webhooks/") { 495 | $value = "" 496 | } 497 | } elseif ($key -eq "SendUpdaterNotifications" -or $key -eq "SendMonitorNotifications" -or $key -eq "SendAPINotifications") { 498 | if ($trueValues -contains $value) { 499 | $value = $true 500 | } else { 501 | $value = $false 502 | } 503 | } elseif ($key -eq "CronSchedule") { 504 | # Accepts standard cron, step values (e.g., 0/30, */5), and asterisk 505 | $cronRegex = '^(\s*([0-5]?\d|\*|[0-5]?\d/\d+|\*/\d+)\s+([01]?\d|2[0-3]|\*|[01]?\d/\d+|\*/\d+)\s+([1-9]|[12]\d|3[01]|\*|[1-9]|[12]\d|3[01]/\d+|\*/\d+)\s+([1-9]|1[0-2]|\*|[1-9]|1[0-2]/\d+|\*/\d+)\s+([0-6]|\*|[0-6]/\d+|\*/\d+)\s*)$' 506 | 507 | if (-not [regex]::IsMatch($value, $cronRegex)) { 508 | Write-Error "❌ Invalid Cron Schedule: $($value)" 509 | } 510 | } 511 | elseif ($key -eq "UpdaterTest") { 512 | if ($trueValues -contains $value) { 513 | $value = $true 514 | } else { 515 | $value = $false 516 | } 517 | } elseif ($key -eq "UpdaterCronJob") { 518 | if ($trueValues -contains $value) { 519 | $value = $true 520 | } else { 521 | $value = $false 522 | } 523 | } 524 | 525 | if ($key -eq "IncludeProjects" -or $key -eq "ExcludeProjects") { 526 | $value = $value -split "," 527 | } 528 | 529 | $config[$key] = $value 530 | } 531 | 532 | return $config 533 | } 534 | 535 | function Remove-Lock { 536 | Remove-Item -Path $script:config.LockFilePath -Force 537 | } 538 | 539 | function Get-DiscordTimestamp { 540 | # Discord requires ISO 8601 UTC format 541 | return (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") 542 | } 543 | 544 | function Send-DiscordNotification { 545 | param ( 546 | [Parameter(Mandatory)][string]$title, 547 | [Parameter(Mandatory)][string]$description, 548 | [Parameter()][int]$color = 65280, 549 | [Parameter()][array]$fields = @(), 550 | [Parameter()][hashtable]$footer = @{} 551 | ) 552 | 553 | $embed = @{ 554 | title = $title 555 | description = $description 556 | color = $color 557 | timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") 558 | fields = $fields 559 | } 560 | # Ensure footer has at least a text field or remove it 561 | if ($footer.Count -ne 0) { 562 | $embed.footer = $footer 563 | } 564 | 565 | $payload = @{ embeds = @($embed) } | ConvertTo-Json -Depth 10 566 | 567 | try { 568 | Invoke-RestMethod ` 569 | -Uri $script:config.DiscordWebhookUrl ` 570 | -Method Post ` 571 | -ContentType "application/json" ` 572 | -Body $payload 573 | 574 | Write-Info "✅ Discord Notification Sent Successfully." 575 | } catch { 576 | if ($_.Exception.Response -and $_.Exception.Response.StatusCode) { 577 | $statusCode = $_.Exception.Response.StatusCode.Value__ 578 | $responseText = $_.ErrorDetails.Message 579 | 580 | Write-Warning "⚠️ Discord Webhook Returned ${statusCode}: $responseText" 581 | } else { 582 | Write-Error "❌ Failed to Send Discord Notification: $_" 583 | } 584 | } 585 | } 586 | 587 | 588 | function Send-DiscordNotificationForServiceRestart { 589 | param ( 590 | [Parameter(Mandatory)][string]$serviceName 591 | ) 592 | 593 | $timestamp = Get-TimeStamp 594 | 595 | Send-DiscordNotification 596 | -title "✅ $serviceName Restarted! ✅" ` 597 | -fields @( 598 | @{ name = "Service Name"; value = $serviceName; inline = $false } 599 | @{ name = "Time"; value = $timestamp; inline = $false } 600 | ) ` 601 | -footer @{ text = "Auto Recovery" } 602 | } 603 | 604 | function Restart-Project { 605 | param ( 606 | [Parameter(Mandatory)][PSCustomObject]$project 607 | ) 608 | 609 | $result = @{ 610 | Processed = $true 611 | Skipped = $false 612 | Summary = "" 613 | } 614 | 615 | Write-Info "➡️ Restarting $($project.Name)..." 616 | 617 | Invoke-DockerComposeRestart $project.ComposeFile 618 | 619 | Start-Sleep -Seconds 10 620 | 621 | $unhealthyContainers = @() 622 | 623 | $project.Containers | ForEach-Object { 624 | $name = $_.Name.Trim() 625 | 626 | if (-not (Wait-ForHealthy -name $name -maxWait 120)) { 627 | Write-Error "`t❌ Unhealthy Container After Restart: $name" 628 | 629 | $unhealthyContainers += $name 630 | } 631 | } 632 | 633 | if ($unhealthyContainers.Count -eq 0) { 634 | Write-Info "`t✅ All Services Healthy..." 635 | 636 | $result.Summary = " - ${project.Name}: ✅ All Services Healthy." 637 | } else { 638 | Write-Warning "`t⚠️ Services Unhealthy..." 639 | 640 | $unhealthyList = $unhealthyContainers -join "`n - " 641 | $result.Summary = " - ${project.Name}: ⚠️ Services Unhealthy:`n - $unhealthyList." 642 | } 643 | 644 | return [PSCustomObject]$result 645 | } 646 | 647 | function Start-Update { 648 | try { 649 | Write-Info "Starting Sweep: $(Get-TimeStamp)" 650 | 651 | Get-Lock 652 | 653 | if (-not (Confirm-PreflightCheck)) { 654 | Write-Error "❌ Preflight Check Failed...Exiting" 655 | 656 | return $false 657 | } 658 | 659 | $processedCount = 0 660 | $skippedCount = 0 661 | $summary = "" 662 | 663 | foreach ($project in Get-ProjectsWithUpdatedImages) { 664 | $result = Restart-Project $project 665 | 666 | if ($result.Processed) { 667 | $processedCount++ 668 | } 669 | 670 | if ($result.Skipped) { 671 | $skippedCount++ 672 | } 673 | 674 | $summary += "`n$($result.Summary)" 675 | } 676 | 677 | Invoke-Docker @("system", "prune", "--all", "--force") | Out-Null 678 | Write-Info "✅ Cleaned Up Dangling Images, Volumes and Networks" 679 | 680 | Write-Info "Processed $processedCount, Skipped $skippedCount" 681 | Write-Info "Finished Sweep: $(Get-TimeStamp)" 682 | 683 | if ($summary -and $script:config.SendUpdaterNotifications) { 684 | Send-DiscordNotification ` 685 | -title "$($script:config.UpdaterNotificationTitle)" ` 686 | -description $summary ` 687 | -color 5763719 ` 688 | -footer @{ text = "Update Complete" } 689 | } 690 | } finally { 691 | Remove-Lock 692 | } 693 | 694 | return $true 695 | } 696 | 697 | function Start-Watchdog { 698 | Write-Log "🐶 Starting Watchdog Monitor, Notifications: $($script:config.SendMonitorNotifications)..." 699 | 700 | $dockerArgs = @( 701 | "events", 702 | "--filter", "event=health_status", 703 | "--filter", "type=container", 704 | "--format", "{{json .}}" 705 | ) 706 | 707 | try { 708 | & docker @dockerArgs | ForEach-Object { 709 | $maxWaitSeconds = 1200 710 | $waitedSeconds = 0 711 | 712 | while (Test-Path $script:config.LockFilePath) { 713 | Write-Log "⏸ Watchdog Paused — Lock File Detected — Waiting..." 714 | Start-Sleep -Seconds 30 715 | $waitedSeconds += 30 716 | 717 | if ($waitedSeconds -ge $maxWaitSeconds) { 718 | Write-Warning "⏱️ Lock File Still Present After $($maxWaitSeconds / 60) Minutes, Skipping Event..." 719 | 720 | break 721 | } 722 | } 723 | 724 | $line = $_ 725 | 726 | if (-not $line -or -not ($line -match "^\{")) { 727 | return 728 | } 729 | 730 | try { 731 | $e = $line | ConvertFrom-Json 732 | $container = $e?.Actor?.Attributes?.name 733 | $status = $e?.status 734 | 735 | if ($status -eq "health_status: unhealthy" -and $container) { 736 | Restart-Unhealthy -container $container -dependenciesMap $script:config.ContainerDependencies 737 | } 738 | } catch { 739 | Write-Error "⚠️ Failed to Parse Event: $_" 740 | } 741 | } 742 | } catch { 743 | Write-Error "❌ Docker Events Stream Failed: $_" 744 | } 745 | 746 | Write-Info "🔍 Watchdog Started. Listening to Docker Events..." 747 | } 748 | 749 | function Test-ContainerExists { 750 | param ( 751 | [Parameter(Mandatory)][string]$name 752 | ) 753 | try { 754 | return (& docker ps -a --format '{{.Names}}') -contains $name 755 | } catch { 756 | Write-Error "⚠️ Failed to List Containers: $_" 757 | 758 | return $false 759 | } 760 | } 761 | function Test-ServiceHealth { 762 | param ( 763 | [string]$containerName 764 | ) 765 | 766 | try { 767 | $status = (& docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' $containerName) 2>$null 768 | } catch { 769 | $status = "notfound" 770 | } 771 | 772 | return $status -eq 'healthy' -or $status -eq 'running' 773 | } 774 | 775 | function Wait-ForHealthy { 776 | param ( 777 | [Parameter(Mandatory)][string]$name, 778 | [int]$maxWait = 60 779 | ) 780 | 781 | if (-not (Test-ContainerExists $name)) { 782 | Write-Warning "⚠️ Container '$name' Does Not Exist. Cannot Check Health." 783 | 784 | return $false 785 | } 786 | 787 | $waited = 0 788 | while ($waited -lt $maxWait) { 789 | if (Test-ServiceHealth $name) { 790 | Write-Info "`t✅ $name is Healthy..." 791 | 792 | return $true 793 | } 794 | 795 | Start-Sleep -Seconds 10 796 | 797 | $waited += 10 798 | } 799 | 800 | Write-Warning "`t⚠️ $name Not Healthy ($maxWait Seconds)..." 801 | 802 | return $false 803 | } 804 | 805 | function Write-Error { 806 | param ( 807 | [Parameter(Mandatory)][string]$message 808 | ) 809 | 810 | Write-Log $message -Level ERROR 811 | } 812 | 813 | function Write-Info { 814 | param ( 815 | [Parameter(Mandatory)][string]$message 816 | ) 817 | 818 | Write-Log $message -Level INFO 819 | } 820 | 821 | function Write-Warning { 822 | param ( 823 | [Parameter(Mandatory)][string]$message 824 | ) 825 | 826 | Write-Log $message -Level WARN 827 | } 828 | 829 | function Write-Log { 830 | param ( 831 | [Parameter(Mandatory)][string]$message, 832 | [ValidateSet("INFO", "WARN", "ERROR")][string]$level = "INFO" 833 | ) 834 | 835 | $timestamp = Get-TimeStamp 836 | 837 | Write-Host "[$timestamp] [$level] $message" 838 | 839 | if ($script:config.LogFilePath) { 840 | "$timestamp [$level] $message" | Out-File -FilePath $script:config.LogFilePath -Encoding UTF8 -Append 841 | } 842 | } 843 | 844 | Export-ModuleMember ` 845 | Add-UpdateCronJob, ` 846 | Confirm-ImageUpdated, ` 847 | Confirm-IncludeProject, ` 848 | Confirm-PreflightCheck, ` 849 | Convert-ToCapitalCase, ` 850 | Get-Config, ` 851 | Get-ProjectsWithUpdatedImages, ` 852 | Get-Lock, ` 853 | Get-TimeStamp, ` 854 | Install-Updater, ` 855 | Invoke-Docker, ` 856 | Invoke-DockerCompose, ` 857 | Invoke-DockerComposeRestart, ` 858 | Read-ConfigFile, ` 859 | Remove-Lock,` 860 | Restart-Container, ` 861 | Restart-Unhealthy, ` 862 | Send-DiscordNotification, ` 863 | Set-APIConfig, 864 | Restart-Project, ` 865 | Start-Update, ` 866 | Start-Watchdog, ` 867 | Test-ContainerExists, ` 868 | Test-ServiceHealth, ` 869 | Wait-ForHealthy, 870 | Write-Error, ` 871 | Write-Info, ` 872 | Write-Warning, ` 873 | Write-Log -------------------------------------------------------------------------------- /build-with-agent.ps1: -------------------------------------------------------------------------------- 1 | param ( 2 | [switch]$interactive, 3 | [string]$imageName = "ghcr.io/the-running-dev/build-agent:latest", 4 | [string]$projectPath = "$(Resolve-Path .)" 5 | ) 6 | 7 | $absoluteProjectPath = (Resolve-Path $projectPath).Path 8 | 9 | Write-Host "🛠️ Using Image: $imageName" 10 | Write-Host "📂 Mounting Project: $absoluteProjectPath" 11 | 12 | $dockerArgs = @('--rm') 13 | 14 | if ($interactive.IsPresent) { 15 | $dockerArgs += '-it' 16 | } 17 | 18 | $dockerArgs += @( 19 | '-v', "${absoluteProjectPath}:/workspace", 20 | '-w', '/workspace', 21 | '-e', 'DOCKER_HOST=tcp://host.docker.internal:2375', 22 | $imageName, 23 | 'pwsh' 24 | ) 25 | 26 | if (-not $interactive.IsPresent) { 27 | $dockerArgs += @('-Command', 'docker-ci') 28 | } 29 | 30 | docker run @dockerArgs -------------------------------------------------------------------------------- /build.ps1: -------------------------------------------------------------------------------- 1 | # Validate that the GitHub token is set 2 | if (-not $env:PackagesToken -or [string]::IsNullOrWhiteSpace($env:PackagesToken)) { 3 | Write-Host "❌ Packages Token is required..." 4 | 5 | exit 1 6 | } 7 | 8 | $imageTag = "watchdog" 9 | $ghcrImageTag = "ghcr.io/the-running-dev/$($imageTag):latest" 10 | $gitHubUsername = "the-running-dev" 11 | 12 | # Build the Docker image 13 | Write-Host "🐳 Building the Docker image..." 14 | & docker build -t $imageTag . 15 | 16 | # Tag the image for GitHub Container Registry 17 | Write-Host "🏷️ Tagging the Docker image for GitHub Container Registry..." 18 | & docker tag $imageTag $ghcrImageTag 19 | 20 | # Authenticate with GitHub Container Registry 21 | Write-Host "🔐 Authenticating with GitHub Container Registry..." 22 | $env:PackagesToken | & docker login ghcr.io -u $gitHubUsername --password-stdin 23 | 24 | # Push the image 25 | Write-Host "🚀 Pushing the Docker image to GitHub Container Registry..." 26 | & docker push $ghcrImageTag 27 | -------------------------------------------------------------------------------- /config.overrides.json: -------------------------------------------------------------------------------- 1 | { 2 | "ContainerDependencies": { 3 | "vpn": ["torrents", "newsgroups"] 4 | }, 5 | "DiscordWebhookUrl": "", 6 | "ExcludeProjects": [], 7 | "SendUpdaterNotifications": true, 8 | "SendMonitorNotifications": true, 9 | "SendAPINotifications": true, 10 | "UpdaterTest": false, 11 | "UpdaterCronJob": true, 12 | "UpdaterNotificationTitle": "Containers Update" 13 | } -------------------------------------------------------------------------------- /docker-compose.example: -------------------------------------------------------------------------------- 1 | x-commonKeys: &commonOptions 2 | restart: always 3 | stdin_open: true 4 | tty: true 5 | 6 | x-dnsServers: &dnsServers 7 | dns: 8 | - 45.90.28.29 9 | - 45.90.30.29 10 | 11 | services: 12 | watchdog: 13 | image: ghcr.io/the-running-dev/watchdog:latest 14 | container_name: watchdog 15 | environment: 16 | - TZ=${TIME_ZONE:-UTC} 17 | volumes: 18 | - ./config.overrides.json:/app/config.overrides.json 19 | - /share/CACHEDEV1_DATA/System/apps:/projects/apps 20 | - ~/.docker/config.json:/root/.docker/config.json:ro 21 | - /var/run/docker.sock:/var/run/docker.sock 22 | ports: 23 | - 7000:80 24 | <<: [*dnsServers, *commonOptions] -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | x-commonKeys: &commonOptions 2 | restart: always 3 | stdin_open: true 4 | tty: true 5 | 6 | x-dnsServers: &dnsServers 7 | dns: 8 | - 45.90.28.29 9 | - 45.90.30.29 10 | 11 | services: 12 | watchdog: 13 | image: watchdog 14 | build: 15 | context: . 16 | dockerfile: Dockerfile 17 | container_name: watchdog 18 | environment: 19 | - TZ=${TIME_ZONE:-UTC} 20 | volumes: 21 | - ./config.overrides.json:/app/config.overrides.json 22 | - /share/CACHEDEV1_DATA/System/apps:/projects/apps 23 | - ~/.docker/config.json:/root/.docker/config.json:ro 24 | - /var/run/docker.sock:/var/run/docker.sock 25 | ports: 26 | - 7000:80 27 | <<: [*dnsServers, *commonOptions] -------------------------------------------------------------------------------- /run.ps1: -------------------------------------------------------------------------------- 1 | & docker compose down --volumes --remove-orphans 2 | 3 | & docker compose up --build -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source ~/.aliases 4 | 5 | docker compose down --volumes --remove-orphans 6 | 7 | docker compose up --build -d -------------------------------------------------------------------------------- /vscode.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": {} 8 | } --------------------------------------------------------------------------------