From 1d79fca11514b141fb01b969ede445a64a8de133 Mon Sep 17 00:00:00 2001 From: Shrev Dev Date: Tue, 21 Oct 2025 10:51:12 -0500 Subject: [PATCH] refactor: docker implementation + health endpoint --- Dockerfile | 49 +++++++++++++++++---------------- QUICKSTART.md | 10 +++++++ README.md | 27 +++++++++++++++++- docker-compose.yml | 39 +++++--------------------- docker/entrypoint.sh | 43 +++++++++++++++++++++++++++++ src/index.ts | 40 +++++++++++++++++++++++++++ src/services/discord.service.ts | 4 +++ 7 files changed, 155 insertions(+), 57 deletions(-) create mode 100644 docker/entrypoint.sh diff --git a/Dockerfile b/Dockerfile index e9c1a5e..b487c33 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,46 +1,47 @@ # Build stage FROM node:20-alpine AS builder - WORKDIR /app -# Copy package files +# Install deps COPY package*.json ./ COPY tsconfig.json ./ - -# Install dependencies RUN npm ci -# Copy source code +# Build COPY src ./src - -# Build TypeScript RUN npm run build -# Production stage +# Runtime stage FROM node:20-alpine - WORKDIR /app -# Copy package files +ENV NODE_ENV=production + +# Copy package files and install production deps COPY package*.json ./ +RUN npm ci --only=production && npm cache clean --force -# Install production dependencies only -RUN npm ci --only=production && \ - npm cache clean --force - -# Copy built application +# Copy built app (make sure dist exists) COPY --from=builder /app/dist ./dist -# Create non-root user -RUN addgroup -g 1001 -S appuser && \ - adduser -u 1001 -S appuser -G appuser +# Create a data directory now (final perms fixed at runtime) +RUN mkdir -p /app/data -# Change ownership -RUN chown -R appuser:appuser /app +# Install su-exec for privilege drop and wget for health checks +RUN apk add --no-cache su-exec wget -# Switch to non-root user -USER appuser +# Add entrypoint +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh -# Start the application -CMD ["node", "dist/index.js"] +# Encourage a named volume by default (optional but nice) +VOLUME ["/app/data"] +# Default PUID/PGID can be overridden at runtime +ENV PUID=1001 PGID=1001 DATA_DIR=/app/data HEALTH_PORT=3000 + +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 +ENTRYPOINT ["/entrypoint.sh"] +CMD ["node", "dist/index.js"] \ No newline at end of file diff --git a/QUICKSTART.md b/QUICKSTART.md index 4c2321a..6601800 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -41,6 +41,14 @@ docker-compose up -d # or: npm install && npm run build && npm start ``` +### Docker Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `PUID`/`PGID` | User/group IDs | `1001` | +| `DATA_DIR` | Data directory path | `/app/data` | +| `HEALTH_PORT` | Health check port | `3000` | + ## Step 3: Configure in Discord (1 minute) Type these commands in Discord: @@ -95,6 +103,8 @@ The bot supports multiple Discord servers independently! Each server has its own - Check `.env` credentials - View logs: `docker-compose logs -f` - Try `/config` in Discord +- Check container health: `docker-compose ps` +- Test health endpoint: `curl http://localhost:3000/health` ### Permission denied - Add your Discord user ID to `ADMIN_USER_IDS` in `.env` diff --git a/README.md b/README.md index e59a103..d41f801 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,10 @@ docker run -d \ -e UPTIME_KUMA_URL=your_url \ -e UPTIME_KUMA_USERNAME=your_username \ -e UPTIME_KUMA_PASSWORD=your_password \ + -e PUID=1001 \ + -e PGID=1001 \ -v bot-data:/app/data \ + --restart unless-stopped \ boker02/uptime-kuma-discord-bot:latest ``` @@ -220,15 +223,31 @@ docker network connect uptime-kuma-network uptime-kuma UPTIME_KUMA_URL=http://uptime-kuma:3001 ``` +### Health Check + +The bot includes a health check endpoint at `/health` that returns: +- `200` if both Discord and Uptime Kuma are connected +- `503` if either service is disconnected + +Docker health check runs every 30 seconds and will mark the container as unhealthy if the bot can't connect to both services. + ### Persistent Data -Configuration saved to Docker volume `bot-data`: +Configuration saved to Docker volume `botdata`: - Channel ID - Message IDs (for reuse) - Tracked monitors - Groups and assignments - Custom title +### Docker Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `PUID`/`PGID` | User/group IDs | `1001` | +| `DATA_DIR` | Data directory path | `/app/data` | +| `HEALTH_PORT` | Health check port | `3000` | + ## Security 1. **Secure `.env`**: Never commit to version control @@ -263,6 +282,12 @@ Configuration saved to Docker volume `bot-data`: - Message IDs saved to `data/bot-config.json` - Check logs for message handling status +### Docker-specific issues +- **Container unhealthy**: Check health endpoint `curl http://localhost:3000/health` +- **Container won't start**: Verify environment variables are set correctly +- **Data not persisting**: Ensure the `botdata` volume is properly mounted +- **Permission errors**: Adjust `PUID`/`PGID` in docker-compose.yml if needed + ## Documentation Files - **[README.md](README.md)** - This file diff --git a/docker-compose.yml b/docker-compose.yml index 7c4aa12..56b8a6b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,52 +1,27 @@ -version: '3.8' - services: uptime-kuma-discord-bot: - build: - context: . - dockerfile: Dockerfile + image: boker02/uptime-kuma-discord-bot:latest container_name: uptime-kuma-discord-bot restart: unless-stopped environment: - # Discord Configuration DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN} - - # Uptime Kuma Configuration UPTIME_KUMA_URL: ${UPTIME_KUMA_URL:-http://localhost:3001} UPTIME_KUMA_USERNAME: ${UPTIME_KUMA_USERNAME} UPTIME_KUMA_PASSWORD: ${UPTIME_KUMA_PASSWORD} - - # Bot Configuration UPDATE_INTERVAL: ${UPDATE_INTERVAL:-60} EMBED_COLOR: ${EMBED_COLOR:-5814783} - - # Admin User IDs (comma-separated Discord user IDs) ADMIN_USER_IDS: ${ADMIN_USER_IDS:-} - - # Data directory for persistent configuration + + PUID: 1001 + PGID: 1001 DATA_DIR: /app/data - - # Persistent volume for bot configuration volumes: - - bot-data:/app/data - - # If Uptime Kuma is running in the same Docker network - # networks: - # - uptime-kuma-network - - # Logging configuration + - botdata:/app/data logging: - driver: "json-file" + driver: json-file options: max-size: "10m" max-file: "3" volumes: - bot-data: - driver: local - -# Uncomment if using Docker network -# networks: -# uptime-kuma-network: -# external: true - + botdata: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..550c896 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,43 @@ +#!/bin/sh +set -e + +# Defaults (can be overridden via env) +PUID="${PUID:-1001}" +PGID="${PGID:-1001}" +DATA_DIR="${DATA_DIR:-/app/data}" + +# Create group/user if they don't exist yet +# -S: system user/group (no home), -H: no home dir +if ! getent group "${PGID}" >/dev/null 2>&1; then + addgroup -g "${PGID}" -S appgroup || true +else + # If group with that GID exists, reuse its name + appgroup="$(getent group "${PGID}" | cut -d: -f1)" + [ -n "$appgroup" ] || appgroup="appgroup" +fi + +if ! getent passwd "${PUID}" >/dev/null 2>&1; then + adduser -u "${PUID}" -S appuser -G appgroup -H || true +else + appuser="$(getent passwd "${PUID}" | cut -d: -f1)" + [ -n "$appuser" ] || appuser="appuser" +fi + +# Ensure data dir exists +mkdir -p "${DATA_DIR}" + +# Try to chown when needed (bind mounts included). If it fails (e.g., read-only), keep going but warn. +if [ -n "${CHOWN_DATA_DIR:-1}" ]; then + if ! chown -R "${PUID}:${PGID}" "${DATA_DIR}" 2>/dev/null; then + echo "[WARN] Could not chown ${DATA_DIR} to ${PUID}:${PGID}. If you see EACCES, fix host perms." + fi +fi + +# Sanity: is it writable now? +if ! su-exec "${PUID}:${PGID}" sh -c "test -w '${DATA_DIR}'"; then + echo "[ERROR] ${DATA_DIR} is not writable by ${PUID}:${PGID}. Check your bind-mount ownership/ACLs." + exit 13 +fi + +# Run the app as the requested UID/GID +exec su-exec "${PUID}:${PGID}" "$@" diff --git a/src/index.ts b/src/index.ts index 176f106..f8b2db6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { configStorage } from './config/storage'; import { UptimeKumaService } from './services/uptime-kuma.service'; import { DiscordService } from './services/discord.service'; import { Logger } from './utils/logger'; +import * as http from 'http'; class UptimeKumaDiscordBot { private uptimeKuma: UptimeKumaService; @@ -10,6 +11,7 @@ class UptimeKumaDiscordBot { private updateInterval: NodeJS.Timeout | null = null; private logger: Logger; private isShuttingDown = false; + private healthServer: http.Server | null = null; constructor() { this.uptimeKuma = new UptimeKumaService(); @@ -55,6 +57,8 @@ class UptimeKumaDiscordBot { this.startUpdateInterval(); + this.startHealthServer(); + this.logger.info('Bot started successfully!'); } catch (error: any) { this.logger.error(`Failed to start bot: ${error.message}`); @@ -130,6 +134,37 @@ class UptimeKumaDiscordBot { } } + private startHealthServer(): void { + const port = parseInt(process.env.HEALTH_PORT || '3000', 10); + + this.healthServer = http.createServer((req, res) => { + if (req.url === '/health' && req.method === 'GET') { + const isHealthy = this.discord.isConnected() && this.uptimeKuma.isConnected(); + const status = isHealthy ? 'healthy' : 'unhealthy'; + const statusCode = isHealthy ? 200 : 503; + + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status, + discord: this.discord.isConnected() ? 'connected' : 'disconnected', + uptimeKuma: this.uptimeKuma.isConnected() ? 'connected' : 'disconnected', + timestamp: new Date().toISOString() + })); + } else { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not Found'); + } + }); + + this.healthServer.listen(port, '0.0.0.0', () => { + this.logger.info(`Health check server listening on port ${port}`); + }); + + this.healthServer.on('error', (error: any) => { + this.logger.error(`Health server error: ${error.message}`); + }); + } + private async shutdown(): Promise { if (this.isShuttingDown) { return; @@ -145,6 +180,11 @@ class UptimeKumaDiscordBot { this.updateInterval = null; } + if (this.healthServer) { + this.healthServer.close(); + this.healthServer = null; + } + this.uptimeKuma.disconnect(); this.discord.disconnect(); diff --git a/src/services/discord.service.ts b/src/services/discord.service.ts index 8559a45..aec172d 100644 --- a/src/services/discord.service.ts +++ b/src/services/discord.service.ts @@ -33,6 +33,10 @@ export class DiscordService { return this.client; } + public isConnected(): boolean { + return this.client.isReady(); + } + public async connect(): Promise { return new Promise((resolve, reject) => { this.client.once('ready', async (client) => {