diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8ff8f7a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,46 @@ +# ── Dépendances (réinstallées dans le Dockerfile) ──────────────────────────── +node_modules +npm-debug.log* +yarn-error.log* +.yarn + +# ── Secrets (ne jamais inclure dans l'image) ───────────────────────────────── +.env +.env.* +!.env.example + +# ── Git ─────────────────────────────────────────────────────────────────────── +.git +.gitignore +.gitattributes + +# ── Docker (évite les boucles de contexte) ─────────────────────────────────── +Dockerfile* +docker-compose*.yml +.dockerignore + +# ── Documentation ───────────────────────────────────────────────────────────── +README.md +*.md +docs/ + +# ── Système / IDE ───────────────────────────────────────────────────────────── +.DS_Store +Thumbs.db +.vscode +.idea +*.swp +*.swo + +# ── Logs & temporaires ──────────────────────────────────────────────────────── +logs/ +*.log +tmp/ +.tmp/ + +# ── Tests ───────────────────────────────────────────────────────────────────── +test/ +tests/ +*.test.js +*.spec.js +coverage/ diff --git a/.gitignore b/.gitignore index 307beae..51f7b24 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,24 @@ -/.env -/node_modules \ No newline at end of file +# Dépendances +node_modules/ + +# Secrets — NE JAMAIS COMMITTER +.env +.env.local +.env.*.local + +# Logs +*.log +logs/ + +# Fichiers système +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Docker runtime +tmp/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eccadfc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,76 @@ +# ═══════════════════════════════════════════════════════════════════════════════ +# Stage 1 — Installation des dépendances npm +# ═══════════════════════════════════════════════════════════════════════════════ +FROM node:20-slim AS deps + +WORKDIR /app + +# Libs nécessaires pour que @napi-rs/canvas télécharge/lie ses binaires +RUN apt-get update && apt-get install -y --no-install-recommends \ + libfontconfig1 \ + libfreetype6 \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY package*.json ./ + +# Installation de PROD seulement (pas devDeps) +RUN npm ci --omit=dev --no-audit --no-fund + +# ═══════════════════════════════════════════════════════════════════════════════ +# Stage 2 — Image finale (allégée) +# ═══════════════════════════════════════════════════════════════════════════════ +FROM node:20-slim AS runtime + +LABEL org.opencontainers.image.title="Discord Météo Bot" +LABEL org.opencontainers.image.description="Bot Discord météo ultra-détaillé — Open-Meteo, QuickChart, calcul astronomique" +LABEL org.opencontainers.image.source="https://github.com/ton-repo/discord-meteo-bot" + +# ── Dépendances système runtime ──────────────────────────────────────────────── +# libfontconfig1 + libfreetype6 : runtime @napi-rs/canvas +# fonts-dejavu-core : polices fiables pour le rendu canvas +# fonts-noto : fallback unicode large (emoji inclus si disponible) +# ca-certificates : requêtes HTTPS (Open-Meteo, Discord, QuickChart) +# tzdata : gestion correcte des fuseaux horaires +RUN apt-get update && apt-get install -y --no-install-recommends \ + fontconfig \ + libfontconfig1 \ + libfreetype6 \ + fonts-dejavu-core \ + ca-certificates \ + tzdata \ + && fc-cache -f \ + && rm -rf /var/lib/apt/lists/* + +# ── Variables d'environnement ────────────────────────────────────────────────── +ENV NODE_ENV=production \ + TZ=Europe/Paris \ + NODE_OPTIONS="--max-old-space-size=384" + +WORKDIR /app + +# ── Copie depuis le stage deps ───────────────────────────────────────────────── +COPY --from=deps /app/node_modules ./node_modules + +# ── Code source ─────────────────────────────────────────────────────────────── +COPY index.js ./ +COPY healthcheck.js ./ +COPY commands/ ./commands/ +COPY services/ ./services/ + +# ── Utilisateur non-root (sécurité) ─────────────────────────────────────────── +RUN groupadd --gid 1001 botgroup \ + && useradd --uid 1001 --gid botgroup --shell /bin/false --no-create-home botuser \ + && chown -R botuser:botgroup /app + +USER botuser + +# ── Healthcheck via fichier heartbeat écrit par le bot ──────────────────────── +HEALTHCHECK \ + --interval=60s \ + --timeout=10s \ + --start-period=45s \ + --retries=3 \ + CMD node healthcheck.js + +CMD ["node", "index.js"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3f353fa --- /dev/null +++ b/Makefile @@ -0,0 +1,99 @@ +# ─── Discord Météo Bot — Makefile ──────────────────────────────────────────── +# Usage : make +# Nécessite : docker, docker compose + +.PHONY: help build up down restart logs status shell clean rebuild update + +# Nom du service (doit correspondre à docker-compose.yml) +SERVICE = meteo-bot + +# Couleurs +GREEN = \033[0;32m +YELLOW = \033[0;33m +CYAN = \033[0;36m +RESET = \033[0m + +help: ## 📖 Afficher cette aide + @echo "" + @echo "$(CYAN)Discord Météo Bot — Commandes disponibles$(RESET)" + @echo "════════════════════════════════════════════" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | sort \ + | awk 'BEGIN {FS = ":.*?## "}; {printf " $(GREEN)%-15s$(RESET) %s\n", $$1, $$2}' + @echo "" + +# ── Build ────────────────────────────────────────────────────────────────────── +build: ## 🔨 Builder l'image Docker + @echo "$(YELLOW)→ Build de l'image...$(RESET)" + docker compose build + +rebuild: ## 🔨 Rebuild complet sans cache + @echo "$(YELLOW)→ Rebuild sans cache...$(RESET)" + docker compose build --no-cache + +# ── Cycle de vie ────────────────────────────────────────────────────────────── +up: ## 🚀 Démarrer le bot (build si nécessaire) + @echo "$(GREEN)→ Démarrage du bot...$(RESET)" + docker compose up -d --build + @echo "$(GREEN)✅ Bot démarré. Logs : make logs$(RESET)" + +down: ## 🛑 Arrêter et supprimer le conteneur + @echo "$(YELLOW)→ Arrêt du bot...$(RESET)" + docker compose down + +restart: ## 🔄 Redémarrer le bot + @echo "$(YELLOW)→ Redémarrage...$(RESET)" + docker compose restart $(SERVICE) + +stop: ## ⏸️ Stopper sans supprimer le conteneur + docker compose stop $(SERVICE) + +start: ## ▶️ Démarrer un conteneur déjà créé + docker compose start $(SERVICE) + +# ── Monitoring ──────────────────────────────────────────────────────────────── +logs: ## 📋 Suivre les logs en temps réel (Ctrl+C pour quitter) + docker compose logs -f --tail=50 $(SERVICE) + +status: ## 📊 Statut du conteneur (santé, ressources) + @echo "$(CYAN)── Statut ──────────────────────────────────────────$(RESET)" + @docker compose ps + @echo "" + @echo "$(CYAN)── Santé ───────────────────────────────────────────$(RESET)" + @docker inspect --format='Health: {{.State.Health.Status}} | Uptime: {{.State.StartedAt}}' discord-meteo-bot 2>/dev/null || echo "Conteneur non démarré" + @echo "" + @echo "$(CYAN)── Ressources ──────────────────────────────────────$(RESET)" + @docker stats discord-meteo-bot --no-stream --format "CPU: {{.CPUPerc}} | RAM: {{.MemUsage}}" 2>/dev/null || true + +health: ## 🩺 Lancer le healthcheck manuellement + docker exec discord-meteo-bot node healthcheck.js + +# ── Shell ────────────────────────────────────────────────────────────────────── +shell: ## 🐚 Ouvrir un shell dans le conteneur (debug) + docker exec -it discord-meteo-bot /bin/bash + +# ── Dev ─────────────────────────────────────────────────────────────────────── +dev: ## 🛠️ Démarrer en mode développement (hot-reload + debug) + docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build + +dev-down: ## 🛑 Arrêter le mode développement + docker compose -f docker-compose.yml -f docker-compose.dev.yml down + +# ── Nettoyage ───────────────────────────────────────────────────────────────── +clean: ## 🧹 Supprimer conteneur + volumes + image locale + @echo "$(YELLOW)→ Nettoyage...$(RESET)" + docker compose down -v --rmi local + @echo "$(GREEN)✅ Nettoyage terminé$(RESET)" + +prune: ## 🧹 Nettoyer les images et couches Docker inutilisées (global) + docker image prune -f + docker builder prune -f + +# ── Déploiement ─────────────────────────────────────────────────────────────── +update: ## ⬆️ Mettre à jour le bot (pull git + rebuild + restart) + @echo "$(CYAN)→ Mise à jour...$(RESET)" + git pull + docker compose build --no-cache + docker compose up -d + @echo "$(GREEN)✅ Mise à jour terminée$(RESET)" + @make logs diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..6be9c75 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,45 @@ +# ─── Override développement ─────────────────────────────────────────────────── +# Usage : docker compose -f docker-compose.yml -f docker-compose.dev.yml up +# +# Différences avec la prod : +# • Hot-reload via --watch (Node 20+) +# • Code source monté en volume (pas besoin de rebuild à chaque modif) +# • DEBUG_METEO activé +# • Ressources non limitées +# • Pas de healthcheck (démarre plus vite) + +services: + + meteo-bot: + build: + target: deps # Utilise seulement le stage deps (plus rapide) + image: discord-meteo-bot:dev + container_name: discord-meteo-bot-dev + + # Monter le code source en live (hot-reload) + volumes: + - .:/app + - /app/node_modules # Conserver les node_modules du conteneur + + command: node --watch index.js + + environment: + NODE_ENV: development + DEBUG_METEO: "true" + TZ: Europe/Paris + + # Pas de limite en dev + deploy: + resources: + limits: + memory: 1G + cpus: "2.0" + + healthcheck: + disable: true + + logging: + driver: json-file + options: + max-size: "5m" + max-file: "2" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1d120f7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +# ─── Discord Météo Bot — Docker Compose (production) ───────────────────────── +# Usage : +# docker compose up -d --build → Build + démarrage en arrière-plan +# docker compose logs -f → Suivre les logs en temps réel +# docker compose restart meteo-bot → Redémarrer le bot +# docker compose down → Arrêter et supprimer les conteneurs + +services: + + meteo-bot: + build: + context: . + dockerfile: Dockerfile + # Forcer le rebuild si le code change : + # docker compose build --no-cache + image: discord-meteo-bot:latest + container_name: discord-meteo-bot + restart: unless-stopped + + # ── Variables d'environnement ───────────────────────────────────────────── + # Le fichier .env est lu au démarrage du conteneur (jamais copié dans l'image) + env_file: + - .env + environment: + NODE_ENV: production + TZ: Europe/Paris + + # ── Ressources ──────────────────────────────────────────────────────────── + deploy: + resources: + limits: + memory: 512M # @napi-rs/canvas peut consommer ~150-200 Mo pour le canvas + cpus: "0.75" + reservations: + memory: 128M + cpus: "0.10" + + # ── Healthcheck (reprend la config du Dockerfile, peut être surchargé ici) ─ + healthcheck: + test: ["CMD", "node", "healthcheck.js"] + interval: 60s + timeout: 10s + start_period: 45s + retries: 3 + + # ── Logs ────────────────────────────────────────────────────────────────── + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + labels: "service=discord-meteo-bot" diff --git a/healthcheck.js b/healthcheck.js new file mode 100644 index 0000000..734120b --- /dev/null +++ b/healthcheck.js @@ -0,0 +1,42 @@ +'use strict'; +/** + * Script de healthcheck Docker. + * Le bot principal écrit /tmp/.bot-alive toutes les 30s. + * Ce script lit le timestamp et exit 0 (sain) ou 1 (malade). + * + * Appelé par : HEALTHCHECK CMD node healthcheck.js + */ + +const { readFileSync } = require('fs'); + +const HEARTBEAT_FILE = '/tmp/.bot-alive'; +const MAX_AGE_MS = 2 * 60 * 1000; // 2 minutes = 4 cycles de 30s manqués + +try { + const raw = readFileSync(HEARTBEAT_FILE, 'utf8').trim(); + const ts = parseInt(raw, 10); + + if (isNaN(ts)) { + console.error('[healthcheck] ❌ Fichier heartbeat corrompu'); + process.exit(1); + } + + const ageMs = Date.now() - ts; + const ageSec = Math.round(ageMs / 1000); + + if (ageMs > MAX_AGE_MS) { + console.error(`[healthcheck] ❌ Bot inactif depuis ${ageSec}s (seuil : ${MAX_AGE_MS / 1000}s)`); + process.exit(1); + } + + console.log(`[healthcheck] ✅ Bot actif — dernier heartbeat il y a ${ageSec}s`); + process.exit(0); + +} catch (err) { + if (err.code === 'ENOENT') { + console.error('[healthcheck] ❌ Fichier heartbeat absent (bot pas encore prêt ou crashé)'); + } else { + console.error('[healthcheck] ❌ Erreur lecture heartbeat :', err.message); + } + process.exit(1); +} diff --git a/index.js b/index.js index b94d858..57e38c3 100644 --- a/index.js +++ b/index.js @@ -7,6 +7,12 @@ const cron = require('node-cron'); const path = require('path'); const fs = require('fs'); +// ─── Heartbeat Docker healthcheck ───────────────────────────────────────────── +const HEARTBEAT_FILE = '/tmp/.bot-alive'; +const writeHeartbeat = () => { + try { fs.writeFileSync(HEARTBEAT_FILE, Date.now().toString()); } catch (_) {} +}; + // ─── Validation des variables d'environnement ───────────────────────────────── const REQUIRED_ENV = ['DISCORD_TOKEN', 'CHANNEL_ID', 'LAT', 'LON']; for (const key of REQUIRED_ENV) { @@ -38,6 +44,10 @@ client.once('ready', async () => { console.log(`📍 Localisation : ${process.env.LOCATION_NAME || `${process.env.LAT}°, ${process.env.LON}°`}`); console.log(`⏰ Message quotidien : ${process.env.SEND_HOUR}h${String(process.env.SEND_MINUTE || 0).padStart(2,'0')}\n`); + // Heartbeat immédiat + toutes les 30s pour Docker healthcheck + writeHeartbeat(); + setInterval(writeHeartbeat, 30_000); + // ── Enregistrement global des commandes slash ────────────────────────────── const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN); const payload = [...client.commands.values()].map(c => c.data.toJSON());