#!/usr/bin/env python3 """ deploy_shoottracker.py ────────────────────── Déploie ShootTracker sur domench.fr via Gitea + Coolify. Architecture : Docker Compose — un seul dépôt contient • l'app principale (frontend React + backend Node/Express + SQLite) • le microservice IA (FastAPI + YOLOv8) Usage : python deploy_shoottracker.py Prérequis (variables d'environnement ou saisie interactive) : GITEA_URL ex. https://git.domench.fr GITEA_USER ex. gael GITEA_TOKEN token API Gitea avec droits repo COOLIFY_URL ex. https://coolify.domench.fr COOLIFY_TOKEN token API Coolify (Settings → API) """ import os import sys import secrets import subprocess import json import time import textwrap import urllib.request import urllib.parse import urllib.error # ─── Couleurs terminal ──────────────────────────────────────────────────────── GREEN = "\033[92m" YELLOW = "\033[93m" RED = "\033[91m" CYAN = "\033[96m" BOLD = "\033[1m" RESET = "\033[0m" def ok(msg): print(f"{GREEN}✓ {msg}{RESET}") def warn(msg): print(f"{YELLOW}⚠ {msg}{RESET}") def err(msg): print(f"{RED}✗ {msg}{RESET}") def info(msg): print(f"{CYAN}→ {msg}{RESET}") def step(n, msg): print(f"\n{BOLD}── Étape {n} : {msg} ──{RESET}") # ─── HTTP helpers ───────────────────────────────────────────────────────────── def api_call(url, method="GET", data=None, headers=None, token=None, base_url=None): """Appel HTTP JSON simple (sans dépendance externe).""" full_url = url if url.startswith("http") else f"{base_url}{url}" body = json.dumps(data).encode() if data is not None else None h = {"Content-Type": "application/json", "Accept": "application/json"} if token: h["Authorization"] = f"Bearer {token}" if headers: h.update(headers) req = urllib.request.Request(full_url, data=body, headers=h, method=method) try: with urllib.request.urlopen(req, timeout=30) as resp: raw = resp.read() return json.loads(raw) if raw else {} except urllib.error.HTTPError as e: body_text = e.read().decode(errors="replace") raise RuntimeError(f"HTTP {e.code} {e.reason} — {body_text}") from e # ─── Configuration ──────────────────────────────────────────────────────────── def get_config(): def ask(env, prompt, secret=False): val = os.environ.get(env, "").strip() if val: return val if secret: import getpass return getpass.getpass(f" {prompt} : ").strip() return input(f" {prompt} : ").strip() print(f"\n{BOLD}=== Déploiement ShootTracker ==={RESET}") print("Renseignez les paramètres (ou définissez les variables d'environnement) :\n") cfg = { "gitea_url": ask("GITEA_URL", "Gitea URL (ex: https://git.domench.fr)").rstrip("/"), "gitea_user": ask("GITEA_USER", "Gitea username"), "gitea_token": ask("GITEA_TOKEN", "Gitea API token", secret=True), "coolify_url": ask("COOLIFY_URL", "Coolify URL (ex: https://coolify.domench.fr)").rstrip("/"), "coolify_token": ask("COOLIFY_TOKEN","Coolify API token", secret=True), } # Paramètres optionnels avec valeurs par défaut cfg["repo_name"] = os.environ.get("REPO_NAME", "shoottracker") cfg["app_domain"] = os.environ.get("APP_DOMAIN", "shoottracker.domench.fr") # JWT_SECRET généré automatiquement (64 octets hex = 128 chars) cfg["jwt_secret"] = secrets.token_hex(64) return cfg # ─── Étape 1 : Créer le dépôt Gitea ───────────────────────────────────────── def create_gitea_repo(cfg): step(1, "Création du dépôt Gitea") base = cfg["gitea_url"] tok = cfg["gitea_token"] user = cfg["gitea_user"] name = cfg["repo_name"] # Vérifier si le dépôt existe déjà try: api_call(f"{base}/api/v1/repos/{user}/{name}", token=tok) warn(f"Le dépôt {user}/{name} existe déjà → on continue") return f"{base}/{user}/{name}.git" except RuntimeError: pass # N'existe pas encore resp = api_call( f"{base}/api/v1/user/repos", method="POST", data={ "name": name, "description": "ShootTracker — application de suivi des performances de tir sportif", "private": True, "auto_init": False, }, token=tok, ) ok(f"Dépôt créé : {resp['html_url']}") return resp["clone_url"] # ─── Étape 2 : Push du code vers Gitea ─────────────────────────────────────── def push_code(cfg, clone_url): step(2, "Push du code vers Gitea") script_dir = os.path.dirname(os.path.abspath(__file__)) os.chdir(script_dir) # Construire l'URL authentifiée from urllib.parse import urlparse, urlunparse parsed = urlparse(clone_url) auth_url = urlunparse(parsed._replace( netloc=f"{cfg['gitea_user']}:{cfg['gitea_token']}@{parsed.netloc}" )) def run(cmd, **kw): result = subprocess.run(cmd, shell=True, capture_output=True, text=True, **kw) if result.returncode != 0: raise RuntimeError(f"Commande échouée : {cmd}\n{result.stderr}") return result.stdout.strip() # Init git si nécessaire if not os.path.isdir(".git"): run("git init") run("git checkout -b main") ok("Dépôt git initialisé") # .gitignore gitignore = textwrap.dedent("""\ node_modules/ dist/ .env data/ *.db __pycache__/ .pytest_cache/ *.pyc .DS_Store """) with open(".gitignore", "w") as f: f.write(gitignore) # Remote try: run("git remote remove origin") except RuntimeError: pass run(f"git remote add origin {auth_url}") run("git add -A") try: run('git commit -m "feat: ShootTracker — SQLite + JWT + YOLOv8 AI"') except RuntimeError: warn("Rien à committer (arbre propre)") run("git push -u origin main --force") ok("Code pushé sur Gitea") # ─── Étape 3 : Créer l'application Coolify ─────────────────────────────────── def create_coolify_app(cfg, clone_url): step(3, "Création de l'application Coolify") base = cfg["coolify_url"] tok = cfg["coolify_token"] # Récupérer le premier serveur disponible servers = api_call(f"{base}/api/v1/servers", token=tok) if not servers: raise RuntimeError("Aucun serveur trouvé dans Coolify") server_uuid = servers[0]["uuid"] info(f"Serveur Coolify : {servers[0].get('name', server_uuid)}") # Variables d'environnement de l'application env_vars = "\n".join([ f"NODE_ENV=production", f"PORT=3001", f"JWT_SECRET={cfg['jwt_secret']}", f"AI_SERVICE_URL=http://shoottracker-ai:8000", f"CORS_ORIGIN=https://{cfg['app_domain']}", f"UPLOADS_DIR=/app/data/uploads", ]) payload = { "name": "shoottracker", "description": "ShootTracker — tir sportif IA", "server_uuid": server_uuid, "git_repository": clone_url, "git_branch": "main", "build_pack": "dockercompose", "docker_compose_location": "/docker-compose.yml", "fqdn": f"https://{cfg['app_domain']}", "ports_exposes": "3001", "environment_variables": env_vars, "instant_deploy": False, } # Vérifier si l'app existe déjà try: apps = api_call(f"{base}/api/v1/applications", token=tok) existing = next((a for a in apps if a.get("name") == "shoottracker"), None) if existing: app_uuid = existing["uuid"] warn(f"Application 'shoottracker' existe déjà (uuid={app_uuid}) → mise à jour des variables") # Mettre à jour les env vars api_call( f"{base}/api/v1/applications/{app_uuid}", method="PATCH", data={"environment_variables": env_vars}, token=tok, ) ok("Variables d'environnement mises à jour") return app_uuid except Exception: pass resp = api_call( f"{base}/api/v1/applications", method="POST", data=payload, token=tok, ) app_uuid = resp.get("uuid") or resp.get("id") ok(f"Application créée (uuid={app_uuid})") return app_uuid # ─── Étape 4 : Déployer ─────────────────────────────────────────────────────── def trigger_deploy(cfg, app_uuid): step(4, "Déclenchement du déploiement") base = cfg["coolify_url"] tok = cfg["coolify_token"] try: resp = api_call( f"{base}/api/v1/applications/{app_uuid}/deploy", method="POST", token=tok, ) deploy_id = resp.get("deployment_uuid") or resp.get("id", "—") ok(f"Déploiement déclenché (id={deploy_id})") info(f"Suivez l'avancement sur : {base}/project/shoottracker/deployments") except RuntimeError as e: warn(f"Impossible de déclencher via API : {e}") info("Déclenchez manuellement depuis l'interface Coolify") # ─── Résumé final ───────────────────────────────────────────────────────────── def print_summary(cfg): box = "═" * 60 print(f"\n{GREEN}{BOLD}{box}") print(" ✓ ShootTracker déployé avec succès !") print(box + RESET) print(f""" 🌐 App : https://{cfg['app_domain']} 📦 Gitea : {cfg['gitea_url']}/{cfg['gitea_user']}/{cfg['repo_name']} 🚀 Coolify : {cfg['coolify_url']} {YELLOW}⚠ Conservez ce JWT_SECRET en lieu sûr (non récupérable) :{RESET} {BOLD}{cfg['jwt_secret']}{RESET} Données persistées dans le volume Docker : shoottracker_data (SQLite DB + photos cibles + avatars) """) # ─── Main ───────────────────────────────────────────────────────────────────── def main(): try: cfg = get_config() clone_url = create_gitea_repo(cfg) push_code(cfg, clone_url) app_uuid = create_coolify_app(cfg, clone_url) trigger_deploy(cfg, app_uuid) print_summary(cfg) except KeyboardInterrupt: print(f"\n{YELLOW}Annulé.{RESET}") sys.exit(1) except RuntimeError as e: err(str(e)) sys.exit(1) if __name__ == "__main__": main()