refactor: docker implementation + health endpoint

This commit is contained in:
Shrev Dev
2025-10-21 10:51:12 -05:00
parent 2389e37286
commit 1d79fca115
7 changed files with 155 additions and 57 deletions
+25 -24
View File
@@ -1,46 +1,47 @@
# Build stage # Build stage
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
# Copy package files # Install deps
COPY package*.json ./ COPY package*.json ./
COPY tsconfig.json ./ COPY tsconfig.json ./
# Install dependencies
RUN npm ci RUN npm ci
# Copy source code # Build
COPY src ./src COPY src ./src
# Build TypeScript
RUN npm run build RUN npm run build
# Production stage # Runtime stage
FROM node:20-alpine FROM node:20-alpine
WORKDIR /app WORKDIR /app
# Copy package files ENV NODE_ENV=production
# Copy package files and install production deps
COPY package*.json ./ COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# Install production dependencies only # Copy built app (make sure dist exists)
RUN npm ci --only=production && \
npm cache clean --force
# Copy built application
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
# Create non-root user # Create a data directory now (final perms fixed at runtime)
RUN addgroup -g 1001 -S appuser && \ RUN mkdir -p /app/data
adduser -u 1001 -S appuser -G appuser
# Change ownership # Install su-exec for privilege drop and wget for health checks
RUN chown -R appuser:appuser /app RUN apk add --no-cache su-exec wget
# Switch to non-root user # Add entrypoint
USER appuser COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Start the application # 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"] CMD ["node", "dist/index.js"]
+10
View File
@@ -41,6 +41,14 @@ docker-compose up -d
# or: npm install && npm run build && npm start # 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) ## Step 3: Configure in Discord (1 minute)
Type these commands in Discord: Type these commands in Discord:
@@ -95,6 +103,8 @@ The bot supports multiple Discord servers independently! Each server has its own
- Check `.env` credentials - Check `.env` credentials
- View logs: `docker-compose logs -f` - View logs: `docker-compose logs -f`
- Try `/config` in Discord - Try `/config` in Discord
- Check container health: `docker-compose ps`
- Test health endpoint: `curl http://localhost:3000/health`
### Permission denied ### Permission denied
- Add your Discord user ID to `ADMIN_USER_IDS` in `.env` - Add your Discord user ID to `ADMIN_USER_IDS` in `.env`
+26 -1
View File
@@ -82,7 +82,10 @@ docker run -d \
-e UPTIME_KUMA_URL=your_url \ -e UPTIME_KUMA_URL=your_url \
-e UPTIME_KUMA_USERNAME=your_username \ -e UPTIME_KUMA_USERNAME=your_username \
-e UPTIME_KUMA_PASSWORD=your_password \ -e UPTIME_KUMA_PASSWORD=your_password \
-e PUID=1001 \
-e PGID=1001 \
-v bot-data:/app/data \ -v bot-data:/app/data \
--restart unless-stopped \
boker02/uptime-kuma-discord-bot:latest 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 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 ### Persistent Data
Configuration saved to Docker volume `bot-data`: Configuration saved to Docker volume `botdata`:
- Channel ID - Channel ID
- Message IDs (for reuse) - Message IDs (for reuse)
- Tracked monitors - Tracked monitors
- Groups and assignments - Groups and assignments
- Custom title - 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 ## Security
1. **Secure `.env`**: Never commit to version control 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` - Message IDs saved to `data/bot-config.json`
- Check logs for message handling status - 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 ## Documentation Files
- **[README.md](README.md)** - This file - **[README.md](README.md)** - This file
+6 -31
View File
@@ -1,52 +1,27 @@
version: '3.8'
services: services:
uptime-kuma-discord-bot: uptime-kuma-discord-bot:
build: image: boker02/uptime-kuma-discord-bot:latest
context: .
dockerfile: Dockerfile
container_name: uptime-kuma-discord-bot container_name: uptime-kuma-discord-bot
restart: unless-stopped restart: unless-stopped
environment: environment:
# Discord Configuration
DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN} DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN}
# Uptime Kuma Configuration
UPTIME_KUMA_URL: ${UPTIME_KUMA_URL:-http://localhost:3001} UPTIME_KUMA_URL: ${UPTIME_KUMA_URL:-http://localhost:3001}
UPTIME_KUMA_USERNAME: ${UPTIME_KUMA_USERNAME} UPTIME_KUMA_USERNAME: ${UPTIME_KUMA_USERNAME}
UPTIME_KUMA_PASSWORD: ${UPTIME_KUMA_PASSWORD} UPTIME_KUMA_PASSWORD: ${UPTIME_KUMA_PASSWORD}
# Bot Configuration
UPDATE_INTERVAL: ${UPDATE_INTERVAL:-60} UPDATE_INTERVAL: ${UPDATE_INTERVAL:-60}
EMBED_COLOR: ${EMBED_COLOR:-5814783} EMBED_COLOR: ${EMBED_COLOR:-5814783}
# Admin User IDs (comma-separated Discord user IDs)
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-} ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
# Data directory for persistent configuration PUID: 1001
PGID: 1001
DATA_DIR: /app/data DATA_DIR: /app/data
# Persistent volume for bot configuration
volumes: volumes:
- bot-data:/app/data - botdata:/app/data
# If Uptime Kuma is running in the same Docker network
# networks:
# - uptime-kuma-network
# Logging configuration
logging: logging:
driver: "json-file" driver: json-file
options: options:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
volumes: volumes:
bot-data: botdata:
driver: local
# Uncomment if using Docker network
# networks:
# uptime-kuma-network:
# external: true
+43
View File
@@ -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}" "$@"
+40
View File
@@ -3,6 +3,7 @@ import { configStorage } from './config/storage';
import { UptimeKumaService } from './services/uptime-kuma.service'; import { UptimeKumaService } from './services/uptime-kuma.service';
import { DiscordService } from './services/discord.service'; import { DiscordService } from './services/discord.service';
import { Logger } from './utils/logger'; import { Logger } from './utils/logger';
import * as http from 'http';
class UptimeKumaDiscordBot { class UptimeKumaDiscordBot {
private uptimeKuma: UptimeKumaService; private uptimeKuma: UptimeKumaService;
@@ -10,6 +11,7 @@ class UptimeKumaDiscordBot {
private updateInterval: NodeJS.Timeout | null = null; private updateInterval: NodeJS.Timeout | null = null;
private logger: Logger; private logger: Logger;
private isShuttingDown = false; private isShuttingDown = false;
private healthServer: http.Server | null = null;
constructor() { constructor() {
this.uptimeKuma = new UptimeKumaService(); this.uptimeKuma = new UptimeKumaService();
@@ -55,6 +57,8 @@ class UptimeKumaDiscordBot {
this.startUpdateInterval(); this.startUpdateInterval();
this.startHealthServer();
this.logger.info('Bot started successfully!'); this.logger.info('Bot started successfully!');
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to start bot: ${error.message}`); 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<void> { private async shutdown(): Promise<void> {
if (this.isShuttingDown) { if (this.isShuttingDown) {
return; return;
@@ -145,6 +180,11 @@ class UptimeKumaDiscordBot {
this.updateInterval = null; this.updateInterval = null;
} }
if (this.healthServer) {
this.healthServer.close();
this.healthServer = null;
}
this.uptimeKuma.disconnect(); this.uptimeKuma.disconnect();
this.discord.disconnect(); this.discord.disconnect();
+4
View File
@@ -33,6 +33,10 @@ export class DiscordService {
return this.client; return this.client;
} }
public isConnected(): boolean {
return this.client.isReady();
}
public async connect(): Promise<void> { public async connect(): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.client.once('ready', async (client) => { this.client.once('ready', async (client) => {