diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 8a5ba3b..480739d 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -1,70 +1,46 @@ -name: CI/CD +name: ci on: push: - branches: [main, dev] + branches: [dev] pull_request: branches: [main] jobs: - lint: + ci: runs-on: ubuntu-latest steps: - - name: Checkout + - name: Recuperation du depot run: | - git clone https://omegakubeserv.tail951d2f.ts.net/Omega_Kube/VisionApp.git . + set -euo pipefail + rm -rf repo + git clone https://omegakubeserv.tail951d2f.ts.net/Omega_Kube/VisionApp.git repo + cd repo git checkout "${{ github.sha }}" + shell: bash - - name: Setup Python + - name: Installation de Python run: | + set -euo pipefail apt-get update && apt-get install -y python3 python3-pip python3-venv python3 -m venv .venv . .venv/bin/activate - pip install -r requirements.txt -r requirements-dev.txt + pip install -r repo/requirements.txt -r repo/requirements-dev.txt + shell: bash - - name: Ruff lint + - name: Lint (ruff) run: | + set -euo pipefail . .venv/bin/activate + cd repo ruff check . - - - name: Ruff format check - run: | - . .venv/bin/activate ruff format --check . + shell: bash - test: - runs-on: ubuntu-latest - steps: - - name: Checkout - run: | - git clone https://omegakubeserv.tail951d2f.ts.net/Omega_Kube/VisionApp.git . - git checkout "${{ github.sha }}" - - - name: Setup Python - run: | - apt-get update && apt-get install -y python3 python3-pip python3-venv - python3 -m venv .venv - . .venv/bin/activate - pip install -r requirements.txt -r requirements-dev.txt - - - name: Syntax check - run: | - . .venv/bin/activate - python3 -m py_compile bot.py - python3 -m py_compile vision.py - python3 -m py_compile config.py - - - name: Run tests + - name: Tests (pytest) run: | + set -euo pipefail . .venv/bin/activate + cd repo pytest tests/ -v || true - - deploy: - needs: [lint, test] - if: github.ref == 'refs/heads/main' && github.event_name == 'push' - runs-on: ubuntu-latest - steps: - - name: Deploy to production - run: | - apt-get update && apt-get install -y sshpass openssh-client - sshpass -p "${{ secrets.PROD_PASSWORD }}" ssh -o StrictHostKeyChecking=no ${{ secrets.PROD_USER }}@${{ secrets.PROD_HOST }} "cd /home/discord/Bots/VisionApp && git pull origin main --rebase && source venv/bin/activate && pip install -r requirements.txt && echo '${{ secrets.PROD_PASSWORD }}' | sudo -S systemctl restart vision-studio && echo 'Deploy OK'" + shell: bash diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml new file mode 100644 index 0000000..d07ca77 --- /dev/null +++ b/.forgejo/workflows/deploy.yml @@ -0,0 +1,166 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# Vision Studio — Deploiement automatique via Forgejo Actions +# +# Declencheur : push sur `main`. +# Runner : forgejo-runner self-hosted (label omhk-deploy, sur .22). +# Cible : 192.168.1.117 -> conteneur Docker `vision-bot` +# (/home/discord/Bots/VisionApp, bind-mount .:/app). +# +# Pipeline : syntaxe -> rsync -> deploy -> health check -> rollback auto. +# Calque du deploy.yml de Kuby. +# ═══════════════════════════════════════════════════════════════════════════ +name: deploy + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: omhk-deploy + steps: + # ═══ CLONE ═════════════════════════════════════════════════════════ + - name: Recuperation du depot (clone interne Forgejo) + run: | + set -euo pipefail + rm -rf repo + git clone --depth 20 "http://192.168.1.22:3000/Omega_Kube/VisionApp.git" repo + shell: bash + + - name: Verification des outils de livraison + run: | + set -euo pipefail + command -v rsync && command -v ssh && command -v curl + shell: bash + + - name: Configuration de la cle SSH (secret VISION_SSH_KEY) + env: + VISION_SSH_KEY: ${{ secrets.VISION_SSH_KEY }} + run: | + set -euo pipefail + mkdir -p ~/.ssh + chmod 700 ~/.ssh + printf '%s\n' "$VISION_SSH_KEY" > ~/.ssh/id_vision + chmod 600 ~/.ssh/id_vision + ssh-keyscan -H 192.168.1.117 >> ~/.ssh/known_hosts 2>/dev/null + shell: bash + + # ═══ TEST SYNTAXE (sur .22, rapide) ══════════════════════════════ + - name: Test syntaxe Python + run: | + set -euo pipefail + cd repo + NPROC=$(nproc 2>/dev/null || echo 2) + ERR_FILE=$(mktemp) + find . -name "*.py" \ + -not -path "*/backups/*" \ + -not -path "*/__pycache__/*" \ + -not -path "*/.git/*" \ + -not -path "*/venv/*" \ + -not -path "*/.venv/*" \ + -print0 | xargs -0 -P "$NPROC" -I {} bash -c 'python3 -m py_compile "$1" 2>>"$2" || echo "ERREUR: $1" >>"$2"' _ {} "$ERR_FILE" + if [ -s "$ERR_FILE" ]; then + cat "$ERR_FILE" + rm -f "$ERR_FILE" + echo "DEPLOY ANNULE: erreurs de syntaxe" + exit 1 + fi + rm -f "$ERR_FILE" + echo "Syntaxe Python: OK" + shell: bash + + - name: Sauvegarde commit precedent (pour rollback) + run: | + set -euo pipefail + cd repo + PREV=$(git rev-parse HEAD~1 2>/dev/null || echo "") + echo "PREV_COMMIT=$PREV" >> "$GITHUB_ENV" + echo "Commit precedent: $PREV" + shell: bash + + # ═══ LIVRAISON ════════════════════════════════════════════════════ + - name: Livraison du code (rsync) + run: | + set -euo pipefail + cd repo + rsync -az --no-o --no-g \ + --exclude '.git/' --exclude '.env' --exclude '.env.*' \ + --exclude 'data/' --exclude 'logs/' --exclude '*.log' --exclude 'nohup.out' \ + --exclude '__pycache__/' --exclude 'venv/' --exclude '.venv/' \ + --exclude '*.db' --exclude '*.db-journal' --exclude '*.db-wal' --exclude '*.db-shm' \ + --exclude '.vscode/' --exclude '.idea/' \ + -e "ssh -i ~/.ssh/id_vision -o StrictHostKeyChecking=no -o UserKnownHostsFile=~/.ssh/known_hosts" \ + . \ + discord@192.168.1.117:/home/discord/Bots/VisionApp/ + shell: bash + + # ═══ DEPLOY ═══════════════════════════════════════════════════════ + - name: Deploiement du conteneur (docker compose, cote .117) + run: | + set -euo pipefail + cd repo + COMMIT="$(git rev-parse --short HEAD)" + CHANGED="$(git diff --name-only HEAD~20 HEAD 2>/dev/null || true)" + REBUILD="false" + if echo "$CHANGED" | grep -qE '(^|/)(requirements\.txt|Dockerfile)$'; then + REBUILD="true" + fi + ssh -i ~/.ssh/id_vision -o StrictHostKeyChecking=no -o UserKnownHostsFile=~/.ssh/known_hosts \ + discord@192.168.1.117 \ + "bash /home/discord/Bots/VisionApp/scripts/deploy_runner.sh main '${COMMIT}' '${REBUILD}'" + shell: bash + + # ═══ HEALTH CHECK ═════════════════════════════════════════════════ + - name: Health check post-deploy + id: health_check + run: | + set -euo pipefail + echo "Attente du demarrage du bot..." + for i in $(seq 1 10); do + sleep 3 + STATUS=$(curl -s -m 5 http://192.168.1.117:1890/health 2>/dev/null || echo "") + if echo "$STATUS" | grep -q '"status":"ok"'; then + echo "Health check OK (tentative $i)" + exit 0 + fi + echo "Tentative $i/10..." + done + echo "HEALTH CHECK FAILED apres 10 tentatives" + exit 1 + shell: bash + + # ═══ ROLLBACK ═════════════════════════════════════════════════════ + - name: Rollback vers commit precedent + if: failure() && steps.health_check.outcome == 'failure' + run: | + set -euo pipefail + if [ -z "${PREV_COMMIT:-}" ]; then + echo "Pas de commit precedent — rollback impossible" + exit 1 + fi + echo "═══ ROLLBACK vers ${PREV_COMMIT} ═══" + cd repo + git checkout "$PREV_COMMIT" + rsync -az --no-o --no-g \ + --exclude '.git/' --exclude '.env' --exclude '.env.*' \ + --exclude 'data/' --exclude 'logs/' --exclude '*.log' --exclude 'nohup.out' \ + --exclude '__pycache__/' --exclude 'venv/' --exclude '.venv/' \ + --exclude '*.db' --exclude '*.db-journal' --exclude '*.db-wal' --exclude '*.db-shm' \ + --exclude '.vscode/' --exclude '.idea/' \ + -e "ssh -i ~/.ssh/id_vision -o StrictHostKeyChecking=no -o UserKnownHostsFile=~/.ssh/known_hosts" \ + . \ + discord@192.168.1.117:/home/discord/Bots/VisionApp/ + ssh -i ~/.ssh/id_vision -o StrictHostKeyChecking=no -o UserKnownHostsFile=~/.ssh/known_hosts \ + discord@192.168.1.117 \ + "bash /home/discord/Bots/VisionApp/scripts/deploy_runner.sh main '${PREV_COMMIT}' false" + echo "ROLLBACK termine" + exit 1 + shell: bash + + - name: Verification finale + run: | + set -euo pipefail + ssh -i ~/.ssh/id_vision -o StrictHostKeyChecking=no -o UserKnownHostsFile=~/.ssh/known_hosts \ + discord@192.168.1.117 \ + "docker ps --filter name=vision-bot --format '{{.Names}} {{.Status}}' && curl -s -m 5 http://localhost:1890/health && echo" + shell: bash diff --git a/Dockerfile b/Dockerfile index 8bf24ca..c1ea67f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,6 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD python -c "import discord; print('ok')" || exit 1 + CMD curl -fsS http://localhost:1890/health || exit 1 CMD ["python", "vision.py"] diff --git a/config.py b/config.py index 1a62ff6..55cc57f 100644 --- a/config.py +++ b/config.py @@ -15,6 +15,7 @@ LOGS_DIR.mkdir(exist_ok=True) DISCORD_TOKEN = os.getenv("DISCORD_TOKEN", "") APPLICATION_ID = os.getenv("APPLICATION_ID", "") DEV = os.getenv("DEV", "").strip() in ("1", "true", "yes") +HEALTH_PORT = int(os.getenv("HEALTH_PORT", "1890")) FORGEJO_URL = os.getenv("FORGEJO_URL", "") FORGEJO_TOKEN = os.getenv("FORGEJO_TOKEN", "") diff --git a/docker-compose.yml b/docker-compose.yml index 018953e..07b2455 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,15 +3,20 @@ services: build: context: . dockerfile: Dockerfile - container_name: vision-studio + image: vision-vision + container_name: vision-bot restart: unless-stopped env_file: .env environment: - VISION_INSTANCE=prod + - HEALTH_PORT=1890 dns: - 1.1.1.1 - 8.8.8.8 + ports: + - "1890:1890" volumes: + - .:/app - vision-data:/app/data - vision-logs:/app/logs deploy: diff --git a/health.py b/health.py new file mode 100644 index 0000000..0e74efb --- /dev/null +++ b/health.py @@ -0,0 +1,27 @@ +import logging + +from aiohttp import web + +logger = logging.getLogger("vision") + + +async def start_health_server(port: int, host: str = "0.0.0.0"): + """Demarre un mini serveur HTTP expose par le conteneur Docker. + + Le runner de deploiement interroge /health pour valider le demarrage + (health check post-deploy, comme Kuby). + """ + + async def health(_request: web.Request) -> web.Response: + return web.Response(text='{"status":"ok"}', content_type="application/json") + + app = web.Application() + app.router.add_get("/health", health) + app.router.add_get("/api/health", health) + + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, host, port) + await site.start() + logger.info("Health server demarre sur %s:%s", host, port) + return runner diff --git a/scripts/deploy_runner.sh b/scripts/deploy_runner.sh new file mode 100755 index 0000000..4c2c283 --- /dev/null +++ b/scripts/deploy_runner.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# ══════════════════════════════════════════════════════════════════════════════ +# Vision Studio — Script de deploiement via Forgejo Actions (hote .117) +# +# Calque du deploy_runner.sh de Kuby. +# Tourne sur l'HOTE .117 (utilisateur discord), livre par rsync depuis +# .forgejo/workflows/deploy.yml puis execute a distance en SSH. +# +# Architecture prod .117 : +# - Vision Studio tourne dans Docker : service `vision` -> conteneur `vision-bot` +# (image `vision-vision`, restart: unless-stopped, code bind-mount .:/app). +# - Le code est livre par rsync sur /home/discord/Bots/VisionApp : +# un `docker compose restart` suffit pour recharger du code pur. +# - Si requirements.txt ou Dockerfile changent -> `docker compose build` +# est necessaire (pip install ne persiste pas). Le flag `rebuild` est +# calcule par le workflow (diff git) et passe en argument. +# +# Usage : deploy_runner.sh +# ══════════════════════════════════════════════════════════════════════════════ +set -uo pipefail + +ROOT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)" +BRANCH="${1:-main}" +COMMIT="${2:-inconnu}" +REBUILD="${3:-false}" +LOCK_FILE="/tmp/vision_deploy_runner.lock" +DEPLOY_LOG="${ROOT_DIR}/data/deploy.log" +CONTAINER="vision-bot" + +log() { echo "[deploy] $(date '+%F %T') $*"; } + +acquire_lock() { + if [ -f "$LOCK_FILE" ]; then + local pid + pid=$(cat "$LOCK_FILE" 2>/dev/null) + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + log "ERROR: deploy deja en cours (PID $pid)" + exit 1 + fi + log "WARN: lock orphelin, nettoyage" + rm -f "$LOCK_FILE" + fi + echo $$ > "$LOCK_FILE" +} +release_lock() { rm -f "$LOCK_FILE"; } +trap release_lock EXIT + +cd "$ROOT_DIR" +acquire_lock + +log "Deploy demarre — branch=$BRANCH commit=$COMMIT rebuild=$REBUILD" + +if ! command -v docker &>/dev/null; then + log "ERROR: docker introuvable sur l'hote" + exit 1 +fi + +if [ "$REBUILD" = "true" ]; then + log "requirements/Dockerfile modifies -> rebuild image Docker..." + if ! docker compose build --no-cache vision; then + log "ERROR: docker compose build echoue" + exit 1 + fi + docker compose up -d --force-recreate vision +else + log "Rechargement du code (bind-mount) -> redemarrage conteneur..." + docker compose up -d vision + docker compose restart vision +fi + +sleep 5 + +if ! docker ps --filter "name=${CONTAINER}" --filter "status=running" --format '{{.Names}}' | grep -q "${CONTAINER}"; then + log "ERROR: conteneur ${CONTAINER} non demarre" + docker logs --tail 40 "${CONTAINER}" 2>&1 || true + exit 1 +fi + +mkdir -p "$(dirname "$DEPLOY_LOG")" +echo "$(date '+%F %T') | $BRANCH | $COMMIT | rebuild=$REBUILD | auto" >> "$DEPLOY_LOG" + +log "Deploy termine — conteneur ${CONTAINER} en marche" diff --git a/vision.py b/vision.py index 9952676..af44c18 100644 --- a/vision.py +++ b/vision.py @@ -4,6 +4,7 @@ import sys import config from bot import VisionBot +from health import start_health_server logging.basicConfig( level=logging.INFO, @@ -24,8 +25,10 @@ async def main(): instance = "DEV" if config.DEV else "PROD" logger.info(f"Lancement de Vision Studio [{instance}]...") - bot = VisionBot() + await start_health_server(config.HEALTH_PORT) + + bot = VisionBot() try: await bot.start(config.DISCORD_TOKEN) except KeyboardInterrupt: