This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Set workdir
|
||||
WORKDIR /app
|
||||
|
||||
# Copy requirements first (better caching)
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Run script
|
||||
CMD ["python", "main.py"]
|
||||
@@ -0,0 +1,112 @@
|
||||
# Docker Webhook Updater
|
||||
|
||||
A Docker container that monitors a Docker image for updates and triggers a webhook when a new version is detected.
|
||||
|
||||
## Features
|
||||
|
||||
- Monitors Docker images from any registry (Docker Hub, GHCR, Quay.io, custom registries)
|
||||
- Detects image updates by comparing SHA256 digests
|
||||
- Triggers webhook with bearer token authentication
|
||||
- Persistent SHA storage across container restarts
|
||||
- Configurable check interval
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Required | Example |
|
||||
| ----------------- | --------------------------------------- | -------- | ----------------------------------------------------- |
|
||||
| `DOCKER_REPO_URL` | Docker registry URL | Yes | `docker.io`, `ghcr.io`, `quay.io`, or custom registry |
|
||||
| `DOCKER_IMAGE` | Full image name with tag | Yes | `username/image:latest`, `library/nginx:1.25` |
|
||||
| `WEBHOOK_URL` | URL to call when image is updated | Yes | `https://example.com/webhook` |
|
||||
| `WEBHOOK_TOKEN` | Bearer token for webhook authentication | Yes | `your-secret-token` |
|
||||
| `CHECK_INTERVAL` | Seconds between checks (default: 300) | No | `600` |
|
||||
|
||||
## Usage
|
||||
|
||||
### Docker Hub Example
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-e DOCKER_REPO_URL=docker.io \
|
||||
-e DOCKER_IMAGE=library/nginx:latest \
|
||||
-e WEBHOOK_URL=https://example.com/webhook \
|
||||
-e WEBHOOK_TOKEN=your-secret-token \
|
||||
-e CHECK_INTERVAL=300 \
|
||||
your-registry/docker-webhook-updater:latest
|
||||
```
|
||||
|
||||
### GitHub Container Registry Example
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-e DOCKER_REPO_URL=ghcr.io \
|
||||
-e DOCKER_IMAGE=username/repo:latest \
|
||||
-e WEBHOOK_URL=https://example.com/webhook \
|
||||
-e WEBHOOK_TOKEN=your-secret-token \
|
||||
your-registry/docker-webhook-updater:latest
|
||||
```
|
||||
|
||||
### Docker Compose Example
|
||||
|
||||
```yaml
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
webhook-updater:
|
||||
image: your-registry/docker-webhook-updater:latest
|
||||
environment:
|
||||
- DOCKER_REPO_URL=docker.io
|
||||
- DOCKER_IMAGE=library/nginx:latest
|
||||
- WEBHOOK_URL=https://example.com/webhook
|
||||
- WEBHOOK_TOKEN=your-secret-token
|
||||
- CHECK_INTERVAL=300
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. The service periodically checks the Docker registry for the specified image
|
||||
2. It retrieves the SHA256 digest of the image manifest
|
||||
3. Compares the current SHA with the previously stored SHA
|
||||
4. If different, triggers the webhook with a POST request containing:
|
||||
```json
|
||||
{
|
||||
"event": "image_updated",
|
||||
"image": "username/image:latest",
|
||||
"repo_url": "docker.io"
|
||||
}
|
||||
```
|
||||
5. Updates the stored SHA only after successful webhook trigger
|
||||
|
||||
## Webhook Request Format
|
||||
|
||||
When an image update is detected, the service sends a POST request to the webhook URL:
|
||||
|
||||
- **Method**: POST
|
||||
- **Headers**:
|
||||
- `Authorization: Bearer {WEBHOOK_TOKEN}`
|
||||
- `Content-Type: application/json`
|
||||
- **Body**:
|
||||
```json
|
||||
{
|
||||
"event": "image_updated",
|
||||
"image": "username/image:tag",
|
||||
"repo_url": "docker.io"
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The SHA is stored in `/app/sha.txt` inside the container
|
||||
- On first run, the SHA is stored without triggering the webhook
|
||||
- If the webhook fails, the SHA is not updated, so it will retry on the next check
|
||||
- For private Docker images, authentication may be required (not currently supported)
|
||||
- The service runs continuously and checks at the specified interval
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
docker build -t docker-webhook-updater:latest .
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
from typing import Optional
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Environment variables
|
||||
DOCKER_REPO_URL = os.getenv('DOCKER_REPO_URL')
|
||||
DOCKER_IMAGE = os.getenv('DOCKER_IMAGE')
|
||||
WEBHOOK_URL = os.getenv('WEBHOOK_URL')
|
||||
WEBHOOK_TOKEN = os.getenv('WEBHOOK_TOKEN')
|
||||
|
||||
# SHA storage file
|
||||
SHA_FILE = '/app/sha.txt'
|
||||
|
||||
# Check interval in seconds (default: 300 = 5 minutes)
|
||||
CHECK_INTERVAL = int(os.getenv('CHECK_INTERVAL', '300'))
|
||||
|
||||
|
||||
def validate_env_vars():
|
||||
"""Validate that all required environment variables are set."""
|
||||
missing = []
|
||||
if not DOCKER_REPO_URL:
|
||||
missing.append('DOCKER_REPO_URL')
|
||||
if not DOCKER_IMAGE:
|
||||
missing.append('DOCKER_IMAGE')
|
||||
if not WEBHOOK_URL:
|
||||
missing.append('WEBHOOK_URL')
|
||||
if not WEBHOOK_TOKEN:
|
||||
missing.append('WEBHOOK_TOKEN')
|
||||
|
||||
if missing:
|
||||
logger.error(f"Missing required environment variables: {', '.join(missing)}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def get_image_sha(repo_url: str, image: str) -> Optional[str]:
|
||||
"""
|
||||
Get the SHA256 digest of the Docker image from the registry.
|
||||
|
||||
Args:
|
||||
repo_url: Docker registry URL (e.g., docker.io, ghcr.io, or custom registry)
|
||||
image: Full image name with tag (e.g., 'username/image:tag' or 'username/image:latest')
|
||||
|
||||
Returns:
|
||||
SHA256 digest string or None if failed
|
||||
"""
|
||||
try:
|
||||
# Parse image name and tag
|
||||
if ':' in image:
|
||||
image_name, tag = image.rsplit(':', 1)
|
||||
else:
|
||||
image_name = image
|
||||
tag = 'latest'
|
||||
|
||||
# Normalize repo URL (remove https://, http://, trailing /)
|
||||
repo_url_normalized = repo_url.rstrip('/').replace('https://', '').replace('http://', '')
|
||||
|
||||
# Construct manifest URL
|
||||
# For Docker Hub: https://registry-1.docker.io/v2/{namespace}/{image}/manifests/{tag}
|
||||
# For other registries: https://{repo_url}/v2/{image}/manifests/{tag}
|
||||
if repo_url_normalized in ('docker.io', 'hub.docker.com', ''):
|
||||
# Docker Hub uses registry-1.docker.io
|
||||
registry_url = 'https://registry-1.docker.io'
|
||||
# For Docker Hub, image_name should be 'username/image' or 'library/image' for official images
|
||||
# If no namespace, assume it's an official image (library namespace)
|
||||
if '/' not in image_name:
|
||||
image_name = f'library/{image_name}'
|
||||
manifest_url = f"{registry_url}/v2/{image_name}/manifests/{tag}"
|
||||
else:
|
||||
# Other registries (ghcr.io, quay.io, custom registries)
|
||||
registry_url = f"https://{repo_url_normalized}"
|
||||
manifest_url = f"{registry_url}/v2/{image_name}/manifests/{tag}"
|
||||
|
||||
logger.debug(f"Fetching manifest from: {manifest_url}")
|
||||
|
||||
# Request manifest with Accept header for schema v2
|
||||
headers = {
|
||||
'Accept': 'application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json'
|
||||
}
|
||||
|
||||
response = requests.get(manifest_url, headers=headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
# Get SHA from Docker-Content-Digest header (preferred)
|
||||
sha = response.headers.get('Docker-Content-Digest')
|
||||
|
||||
if not sha:
|
||||
# Fallback: try to get from response if it's a manifest list
|
||||
try:
|
||||
manifest = response.json()
|
||||
# For manifest lists, get the digest from the first platform-specific manifest
|
||||
if manifest.get('mediaType') == 'application/vnd.docker.distribution.manifest.list.v2+json':
|
||||
if 'manifests' in manifest and len(manifest['manifests']) > 0:
|
||||
sha = manifest['manifests'][0].get('digest')
|
||||
elif 'config' in manifest and 'digest' in manifest['config']:
|
||||
sha = manifest['config']['digest']
|
||||
elif 'digest' in manifest:
|
||||
sha = manifest['digest']
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not parse manifest JSON: {e}")
|
||||
|
||||
if sha:
|
||||
logger.info(f"Current SHA for {image}: {sha[:16]}...")
|
||||
return sha
|
||||
else:
|
||||
logger.warning(f"Could not extract SHA from manifest response")
|
||||
return None
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.error(f"Image not found: {image} (tag: {tag})")
|
||||
elif e.response.status_code == 401:
|
||||
logger.error(f"Authentication required for {repo_url}. Private images may require authentication.")
|
||||
else:
|
||||
logger.error(f"HTTP error fetching image SHA: {e}")
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to fetch image SHA: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error fetching image SHA: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def load_stored_sha() -> Optional[str]:
|
||||
"""Load the previously stored SHA from file."""
|
||||
try:
|
||||
if os.path.exists(SHA_FILE):
|
||||
with open(SHA_FILE, 'r') as f:
|
||||
sha = f.read().strip()
|
||||
if sha:
|
||||
logger.debug(f"Loaded stored SHA: {sha}")
|
||||
return sha
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load stored SHA: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def save_sha(sha: str):
|
||||
"""Save the SHA to file."""
|
||||
try:
|
||||
with open(SHA_FILE, 'w') as f:
|
||||
f.write(sha)
|
||||
logger.debug(f"Saved SHA: {sha}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save SHA: {e}")
|
||||
|
||||
|
||||
def trigger_webhook(url: str, token: str) -> bool:
|
||||
"""
|
||||
Trigger the webhook with bearer token authentication.
|
||||
|
||||
Args:
|
||||
url: Webhook URL
|
||||
token: Bearer token
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
headers = {
|
||||
'Authorization': f'Bearer {token}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
# Send POST request (you can modify payload if needed)
|
||||
payload = {
|
||||
'event': 'image_updated',
|
||||
'image': DOCKER_IMAGE,
|
||||
'repo_url': DOCKER_REPO_URL
|
||||
}
|
||||
|
||||
logger.info(f"Triggering webhook: {url}")
|
||||
response = requests.post(url, json=payload, headers=headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.info(f"Webhook triggered successfully (status: {response.status_code})")
|
||||
return True
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to trigger webhook: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error triggering webhook: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Main loop to check for image updates."""
|
||||
logger.info("Starting Docker Webhook Updater")
|
||||
logger.info(f"Monitoring: {DOCKER_REPO_URL}/{DOCKER_IMAGE}")
|
||||
logger.info(f"Webhook URL: {WEBHOOK_URL}")
|
||||
logger.info(f"Check interval: {CHECK_INTERVAL} seconds")
|
||||
|
||||
validate_env_vars()
|
||||
|
||||
# Load initial SHA
|
||||
stored_sha = load_stored_sha()
|
||||
if stored_sha:
|
||||
logger.info(f"Initial stored SHA: {stored_sha}")
|
||||
else:
|
||||
logger.info("No stored SHA found. Will trigger webhook on first check if image exists.")
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Get current SHA from registry
|
||||
current_sha = get_image_sha(DOCKER_REPO_URL, DOCKER_IMAGE)
|
||||
|
||||
if current_sha:
|
||||
# Compare with stored SHA
|
||||
if stored_sha and current_sha != stored_sha:
|
||||
logger.info(f"Image updated! Old SHA: {stored_sha[:16]}..., New SHA: {current_sha[:16]}...")
|
||||
|
||||
# Trigger webhook
|
||||
if trigger_webhook(WEBHOOK_URL, WEBHOOK_TOKEN):
|
||||
# Save new SHA only if webhook was successful
|
||||
save_sha(current_sha)
|
||||
stored_sha = current_sha
|
||||
else:
|
||||
logger.warning("Webhook failed. SHA not updated. Will retry on next check.")
|
||||
elif not stored_sha:
|
||||
# First run - just store the SHA without triggering webhook
|
||||
logger.info(f"First run: storing initial SHA: {current_sha[:16]}...")
|
||||
save_sha(current_sha)
|
||||
stored_sha = current_sha
|
||||
else:
|
||||
logger.debug(f"No update detected. SHA: {current_sha[:16]}...")
|
||||
else:
|
||||
logger.warning("Could not fetch image SHA. Will retry on next check.")
|
||||
|
||||
# Wait before next check
|
||||
logger.debug(f"Waiting {CHECK_INTERVAL} seconds before next check...")
|
||||
time.sleep(CHECK_INTERVAL)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Received interrupt signal. Shutting down...")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in main loop: {e}")
|
||||
logger.info(f"Waiting {CHECK_INTERVAL} seconds before retry...")
|
||||
time.sleep(CHECK_INTERVAL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user