From caf7aaa90428a66a451b990c3e24e532b9f36723 Mon Sep 17 00:00:00 2001 From: Robert J Spencer Date: Fri, 15 Nov 2024 11:17:33 +0800 Subject: [PATCH] initial commit --- .github/workflows/build.yaml | 56 ++++++++++ Dockerfile | 57 +++++++++++ README.md | 3 + diun2homer.py | 191 +++++++++++++++++++++++++++++++++++ docker-compose.yaml | 21 ++++ pyproject.toml | 13 +++ requirements.txt | 3 + 7 files changed, 344 insertions(+) create mode 100644 .github/workflows/build.yaml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 diun2homer.py create mode 100644 docker-compose.yaml create mode 100644 pyproject.toml create mode 100644 requirements.txt diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..583bd26 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,56 @@ +name: Build and Push Docker Image + +on: + push: + branches: [ main ] + tags: [ 'v*' ] + pull_request: + branches: [ main ] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to the Container registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,format=long + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e092a6c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +# Build stage +FROM python:3.11-slim AS builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && \ + apt-get install -y --no-install-recommends gcc && \ + rm -rf /var/lib/apt/lists/* && \ + apt-get clean && \ + rm -rf /var/cache/apt/* && \ + pip install --upgrade pip && \ + pip install --upgrade uv + +# Copy dependency files +COPY pyproject.toml ./ + +# Create virtual environment and install dependencies +RUN python -m venv .venv && \ + . .venv/bin/activate && \ + uv pip install -e . + +# Final stage +FROM python:3.11-slim + +WORKDIR /app + +# Create non-root user +RUN useradd -m -u 1000 appuser + +# Copy virtual environment from builder +COPY --from=builder /app/.venv ./.venv +COPY --from=builder /app/pyproject.toml ./ + +# Copy application code +COPY diun2homer.py ./ + +# Create data directory and set permissions +RUN mkdir -p /app/data && \ + chown -R appuser:appuser /app + +# Switch to non-root user +USER appuser + +# Set environment variables +ENV PATH="/app/.venv/bin:$PATH" \ + PYTHONPATH="/app" \ + PYTHONUNBUFFERED=1 + +# Volume for persistent data +VOLUME /app/data + +# Expose port +EXPOSE 8000 + +# Run the application +CMD ["python", "diun2homer.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c4d0774 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Diun to Homer + +A FastAPI server that receives events from [Diun](https://crazymax.dev/diun/) (Docker Image Update Notifier) via the webhook notifier and presents them in a format compatible with [Homer Dashboard](https://github.com/bastienwirtz/homer). This allows you to see your Docker image update notifications directly in your Homer dashboard. diff --git a/diun2homer.py b/diun2homer.py new file mode 100644 index 0000000..1f479be --- /dev/null +++ b/diun2homer.py @@ -0,0 +1,191 @@ +from fastapi import FastAPI, Request +from pydantic import BaseModel +from typing import List, Optional +import sqlite3 +from datetime import datetime +import json +import logging +import os +import traceback +import sys + +app = FastAPI() + +# Configure logging based on DEBUG environment variable +DEBUG = os.getenv('DEBUG', 'false').lower() == 'true' +logging.basicConfig( + level=logging.DEBUG if DEBUG else logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(sys.stdout), + logging.FileHandler('diun2homer.log') + ] +) +logger = logging.getLogger('diun2homer') + +# Add this near the top of the file, after imports +DATABASE_NAME = 'diun2homer.db' + +# Initialize SQLite database +def init_db(): + try: + logger.info("Initializing database...") + conn = sqlite3.connect(DATABASE_NAME) + c = conn.cursor() + c.execute(''' + CREATE TABLE IF NOT EXISTS events + (id INTEGER PRIMARY KEY AUTOINCREMENT, + image TEXT, + status TEXT, + platform TEXT, + tag TEXT, + message TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP) + ''') + conn.commit() + conn.close() + logger.info("Database initialization successful") + except Exception as e: + logger.error(f"Database initialization failed: {str(e)}") + logger.debug(f"Detailed error: {traceback.format_exc()}") + raise + +# Diun webhook payload model +class DiunPayload(BaseModel): + status: str + image: str + platform: Optional[str] + tag: Optional[str] + message: str + + class Config: + extra = "allow" # Allow additional fields for future compatibility + +# Convert Diun status to Homer message style +def get_homer_style(status: str) -> str: + status_map = { + "new": "is-info", + "update": "is-success", + "error": "is-danger" + } + result = status_map.get(status.lower(), "is-warning") + logger.debug(f"Mapped status '{status}' to homer style '{result}'") + return result + +# Store diun event data +def store_diun_payload(payload: DiunPayload): + try: + logger.info(f"Storing diun event data for image: {payload.image}") + if DEBUG: + logger.debug(f"Full payload: {payload.json(indent=2)}") + + conn = sqlite3.connect(DATABASE_NAME) + c = conn.cursor() + c.execute(''' + INSERT INTO events (image, status, platform, tag, message) + VALUES (?, ?, ?, ?, ?) + ''', ( + payload.image, + payload.status, + payload.platform, + payload.tag, + payload.message + )) + conn.commit() + conn.close() + logger.info("Successfully stored diun event data") + except Exception as e: + logger.error(f"Failed to store diun event data: {str(e)}") + logger.debug(f"Detailed error: {traceback.format_exc()}") + raise + +# Get stored events in Homer format +def get_homer_messages() -> List[dict]: + try: + logger.info("Retrieving messages for Homer") + conn = sqlite3.connect(DATABASE_NAME) + c = conn.cursor() + c.execute('SELECT image, status, message, timestamp FROM events ORDER BY timestamp DESC') + rows = c.fetchall() + conn.close() + + messages = [] + for row in rows: + image, status, message, timestamp = row + message_data = { + "style": get_homer_style(status), + "title": image, + "content": f"{message} ({timestamp})" + } + messages.append(message_data) + + logger.info(f"Retrieved {len(messages)} messages") + if DEBUG: + logger.debug(f"Full messages data: {json.dumps(messages, indent=2)}") + return messages + except Exception as e: + logger.error(f"Failed to retrieve messages: {str(e)}") + logger.debug(f"Detailed error: {traceback.format_exc()}") + raise + +# Initialize database on startup +@app.on_event("startup") +async def startup_event(): + logger.info("Starting diun2homer") + init_db() + +# Diun webhook endpoint +@app.post("/diun") +async def diun(payload: DiunPayload, request: Request): + try: + client_host = request.client.host + logger.info(f"Received webhook from {client_host} for image: {payload.image}") + if DEBUG: + logger.debug(f"Request headers: {dict(request.headers)}") + logger.debug(f"Raw payload: {await request.body()}") + logger.debug(f"Parsed payload: {payload.json(indent=2)}") + + store_diun_payload(payload) + logger.info("Successfully processed webhook") + return {"status": "success"} + except Exception as e: + logger.error(f"Webhook processing failed: {str(e)}") + logger.debug(f"Detailed error: {traceback.format_exc()}") + raise + +# Homer messages endpoint +@app.get("/homer") +async def homer(request: Request): + try: + client_host = request.client.host + logger.info(f"Messages requested from {client_host}") + if DEBUG: + logger.debug(f"Request headers: {dict(request.headers)}") + + result = get_homer_messages() + logger.info(f"Successfully returned {len(result)} messages") + return result + except Exception as e: + logger.error(f"Messages request failed: {str(e)}") + logger.debug(f"Detailed error: {traceback.format_exc()}") + raise + +# Health check endpoint +@app.get("/health") +async def health(): + try: + # Test database connection + conn = sqlite3.connect(DATABASE_NAME) + conn.cursor() + conn.close() + logger.info("Health check successful") + return {"status": "healthy"} + except Exception as e: + logger.error(f"Health check failed: {str(e)}") + logger.debug(f"Detailed error: {traceback.format_exc()}") + return {"status": "unhealthy", "error": str(e)} + +if __name__ == "__main__": + import uvicorn + logger.info("Starting server...") + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..7863f1a --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,21 @@ +version: '3.8' + +services: + diun2homer: + image: ghcr.io/${GITHUB_REPOSITORY:Rjvs/diun2homer}:latest + ports: + - "8000:8000" + volumes: + - diun2homer-data:/app/data + environment: + - DEBUG=false + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + +volumes: + diun2homer-data: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a994156 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "diun2homer" +version = "0.1.0" +description = "FastAPI proxy receiving Diun webhooks and making them available to Homer" +authors = [ + {name = "Robert Spencer", email = "rj@game.net.au"} +] +requires-python = ">=3.11" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fe012dc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.109.0 +uvicorn>=0.27.0 +pydantic>=2.5.0