From efb428b4e75bed3d607f20ef58a99cd443a623d1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Mar 2026 09:33:11 +0000 Subject: [PATCH] =?UTF-8?q?Ajout=20syst=C3=A8me=20de=20synchronisation=20a?= =?UTF-8?q?utomatique=20Gitea=20=E2=86=92=20GitHub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ajout du script sync_gitea_to_github.py pour synchroniser tous les repos - Ajout du script init_sync_job.py pour créer le cron job dans CronHub - Ajout du champ 'category' au modèle Job pour classification - Configuration du job pour exécution toutes les heures (0 * * * *) - Documentation complète dans scripts/README_SYNC.md - Ajout .gitignore pour exclure __pycache__ et fichiers temporaires Co-authored-by: Cédric TOMAZ --- .gitignore | 41 +++++++ README.md | 26 ++++- app/main.py | 30 +++-- scripts/README_SYNC.md | 128 ++++++++++++++++++++++ scripts/init_sync_job.py | 126 +++++++++++++++++++++ scripts/sync_gitea_to_github.py | 188 ++++++++++++++++++++++++++++++++ 6 files changed, 526 insertions(+), 13 deletions(-) create mode 100644 .gitignore create mode 100644 scripts/README_SYNC.md create mode 100755 scripts/init_sync_job.py create mode 100755 scripts/sync_gitea_to_github.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..669521e --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Database +*.db +*.db-shm +*.db-wal +data/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md index b132b33..31d44d8 100644 --- a/README.md +++ b/README.md @@ -43,12 +43,22 @@ CronHub permet de créer, gérer et monitorer des tâches planifiées (cron jobs --- -## 📊 Jobs configurés (35 au total) +## 📊 Jobs configurés | Catégorie | Nombre | Exemples | |-----------|--------|---------| | Système / MH / Plex / Location | 11 | Backups, monitoring, rapports Plex | | Potager / Maison | 24 | Arrosage, alertes, automatisations maison | +| Sync | 1 | Synchronisation Gitea → GitHub | + +### 🔄 Job de synchronisation Gitea → GitHub + +Un job automatique synchronise tous les dépôts de Gitea (`http://192.168.1.29:3500/perco`) vers GitHub (`percolouco`) : + +- **Fréquence** : Toutes les heures (`0 * * * *`) +- **Mode** : Mirror (toutes les branches et tags) +- **Scripts** : Voir `scripts/README_SYNC.md` pour la configuration complète +- **Tokens requis** : `GITHUB_TOKEN` et `GITEA_TOKEN` (configurés comme secrets) --- @@ -119,12 +129,16 @@ curl https://cronhub.nas.percolouco.com/api/jobs/{id}/logs?limit=20 cronhub/ ├── app/ │ ├── __init__.py -│ └── main.py # Application FastAPI (routes, scheduler, DB) +│ └── main.py # Application FastAPI (routes, scheduler, DB) +├── scripts/ +│ ├── sync_gitea_to_github.py # Script de synchronisation Gitea → GitHub +│ ├── init_sync_job.py # Initialisation du job de sync dans CronHub +│ └── README_SYNC.md # Documentation du système de sync ├── templates/ -│ ├── index.html # Dashboard (avec filtre par catégorie) -│ ├── job_detail.html # Détail d'un job + logs -│ └── job_form.html # Formulaire création/édition (champ category) -├── data/ # Volume persistant (SQLite) +│ ├── index.html # Dashboard (avec filtre par catégorie) +│ ├── job_detail.html # Détail d'un job + logs +│ └── job_form.html # Formulaire création/édition (champ category) +├── data/ # Volume persistant (SQLite) ├── Dockerfile ├── docker-compose.yml └── requirements.txt diff --git a/app/main.py b/app/main.py index 8f7318f..b205fd2 100644 --- a/app/main.py +++ b/app/main.py @@ -42,6 +42,7 @@ def init_db(): schedule TEXT NOT NULL, command TEXT NOT NULL, description TEXT DEFAULT '', + category TEXT DEFAULT '', enabled INTEGER DEFAULT 1, last_run TEXT, last_status TEXT DEFAULT 'never', @@ -59,7 +60,16 @@ def init_db(): ); """) conn.commit() - conn.close() + + # Migration: ajouter la colonne category si elle n'existe pas + try: + conn = get_db() + conn.execute("ALTER TABLE jobs ADD COLUMN category TEXT DEFAULT ''") + conn.commit() + conn.close() + except sqlite3.OperationalError: + # La colonne existe déjà + pass def row_to_dict(row) -> dict: @@ -220,6 +230,7 @@ class JobCreate(BaseModel): schedule: str command: str description: Optional[str] = "" + category: Optional[str] = "" enabled: Optional[bool] = True @@ -228,6 +239,7 @@ class JobUpdate(BaseModel): schedule: Optional[str] = None command: Optional[str] = None description: Optional[str] = None + category: Optional[str] = None enabled: Optional[bool] = None @@ -255,9 +267,9 @@ def api_create_job(payload: JobCreate): now = datetime.now().isoformat() conn = get_db() conn.execute( - "INSERT INTO jobs (id,name,schedule,command,description,enabled,created_at) VALUES (?,?,?,?,?,?,?)", + "INSERT INTO jobs (id,name,schedule,command,description,category,enabled,created_at) VALUES (?,?,?,?,?,?,?,?)", (job_id, payload.name, payload.schedule, payload.command, - payload.description or "", int(payload.enabled), now) + payload.description or "", payload.category or "", int(payload.enabled), now) ) conn.commit() conn.close() @@ -295,6 +307,8 @@ def api_update_job(job_id: str, payload: JobUpdate): updates["command"] = payload.command if payload.description is not None: updates["description"] = payload.description + if payload.category is not None: + updates["category"] = payload.category if payload.enabled is not None: updates["enabled"] = int(payload.enabled) @@ -398,6 +412,7 @@ def ui_create_job( schedule: str = Form(...), command: str = Form(...), description: str = Form(""), + category: str = Form(""), enabled: str = Form("on"), ): try: @@ -418,8 +433,8 @@ def ui_create_job( is_enabled = 1 if enabled == "on" else 0 conn = get_db() conn.execute( - "INSERT INTO jobs (id,name,schedule,command,description,enabled,created_at) VALUES (?,?,?,?,?,?,?)", - (job_id, name, schedule, command, description, is_enabled, now) + "INSERT INTO jobs (id,name,schedule,command,description,category,enabled,created_at) VALUES (?,?,?,?,?,?,?,?)", + (job_id, name, schedule, command, description, category, is_enabled, now) ) conn.commit() conn.close() @@ -465,6 +480,7 @@ def ui_update_job( schedule: str = Form(...), command: str = Form(...), description: str = Form(""), + category: str = Form(""), enabled: str = Form("off"), ): job = get_job(job_id) @@ -485,8 +501,8 @@ def ui_update_job( is_enabled = 1 if enabled == "on" else 0 conn = get_db() conn.execute( - "UPDATE jobs SET name=?,schedule=?,command=?,description=?,enabled=? WHERE id=?", - (name, schedule, command, description, is_enabled, job_id) + "UPDATE jobs SET name=?,schedule=?,command=?,description=?,category=?,enabled=? WHERE id=?", + (name, schedule, command, description, category, is_enabled, job_id) ) conn.commit() conn.close() diff --git a/scripts/README_SYNC.md b/scripts/README_SYNC.md new file mode 100644 index 0000000..91017ed --- /dev/null +++ b/scripts/README_SYNC.md @@ -0,0 +1,128 @@ +# Synchronisation Gitea → GitHub + +Ce dossier contient les scripts pour synchroniser automatiquement tous les dépôts Gitea vers GitHub. + +## 📋 Description + +Le système synchronise tous les dépôts de l'utilisateur `perco` depuis le serveur Gitea (`http://192.168.1.29:3500`) vers le compte GitHub `percolouco`, en mode mirror (toutes les branches et tags). + +## 🔑 Configuration des secrets + +Les tokens d'accès doivent être configurés comme secrets dans le Cursor Dashboard (Cloud Agents > Secrets) : + +1. **GITHUB_TOKEN** (requis) + - Token d'accès personnel GitHub avec les permissions `repo` + - Générer sur : https://github.com/settings/tokens + - Scopes nécessaires : `repo`, `workflow` + +2. **GITEA_TOKEN** (optionnel mais recommandé) + - Token d'accès Gitea pour les dépôts privés + - Générer sur : http://192.168.1.29:3500/user/settings/applications + +## 📂 Fichiers + +- **`sync_gitea_to_github.py`** : Script principal de synchronisation +- **`init_sync_job.py`** : Script d'initialisation du cron job dans CronHub +- **`README_SYNC.md`** : Ce fichier + +## 🚀 Installation + +### 1. Configurer les secrets + +Dans le Cursor Dashboard : +- Allez dans Cloud Agents > Secrets +- Ajoutez `GITHUB_TOKEN` avec votre token GitHub +- Ajoutez `GITEA_TOKEN` avec votre token Gitea (optionnel) + +### 2. Initialiser le cron job + +Exécutez le script d'initialisation pour créer le job dans CronHub : + +```bash +python3 /opt/container/cronhub/scripts/init_sync_job.py +``` + +Le job sera configuré pour s'exécuter **toutes les heures** (schedule cron : `0 * * * *`). + +### 3. Vérifier + +Accédez à CronHub pour vérifier que le job a été créé : +- URL : https://cronhub.nas.percolouco.com +- Nom du job : "Sync Gitea → GitHub" +- Catégorie : "sync" + +## 🔧 Fonctionnement + +Le script effectue les opérations suivantes : + +1. **Liste les dépôts Gitea** via l'API Gitea (`/api/v1/user/repos`) +2. Pour chaque dépôt : + - Vérifie si le dépôt existe sur GitHub + - Crée le dépôt sur GitHub s'il n'existe pas + - Clone le dépôt Gitea en mode `--mirror` (toutes les branches et tags) + - Push en mode `--mirror` vers GitHub +3. **Affiche un résumé** avec le nombre de réussites/échecs + +## 📊 Logs et monitoring + +Les logs d'exécution sont disponibles dans CronHub : +- Accédez au détail du job "Sync Gitea → GitHub" +- Consultez l'historique des exécutions +- Stdout/stderr sont capturés pour chaque exécution + +## 🧪 Test manuel + +Pour tester le script manuellement (hors cron) : + +```bash +# Définir les tokens temporairement +export GITHUB_TOKEN="votre_token_github" +export GITEA_TOKEN="votre_token_gitea" + +# Exécuter le script +python3 /opt/container/cronhub/scripts/sync_gitea_to_github.py +``` + +Ou via CronHub, en utilisant le bouton "Run Now" sur la page de détail du job. + +## 📝 Notes importantes + +- **Mode mirror** : Synchronise TOUTES les branches et tags (pas seulement `main`) +- **Dépôts privés** : Les dépôts privés sur Gitea restent privés sur GitHub +- **Timeout** : Chaque opération git a un timeout de 5 minutes (300s) +- **Exécution via nsenter** : Le script s'exécute dans le namespace de l'hôte Docker pour accès à git et python3 + +## 🔍 Dépannage + +### Le script échoue avec "GITHUB_TOKEN non défini" + +Vérifiez que le secret `GITHUB_TOKEN` est bien configuré dans le Cursor Dashboard et qu'il est injecté dans l'environnement du container CronHub. + +### Erreur lors du clone Gitea + +Vérifiez que : +- Le serveur Gitea est accessible (`http://192.168.1.29:3500`) +- Le token Gitea est valide (pour les dépôts privés) +- L'utilisateur `perco` existe et a des dépôts + +### Erreur lors du push GitHub + +Vérifiez que : +- Le token GitHub a les bonnes permissions (`repo`, `workflow`) +- Le compte GitHub `percolouco` existe +- Il n'y a pas de limite de taille de dépôt atteinte + +## 📅 Modification du planning + +Pour changer la fréquence de synchronisation : + +1. Éditez le job dans CronHub (interface web ou API) +2. Modifiez le champ `schedule` avec une nouvelle expression cron : + - Toutes les heures : `0 * * * *` (actuel) + - Toutes les 2 heures : `0 */2 * * *` + - Tous les jours à 3h : `0 3 * * *` + - Toutes les 30 minutes : `*/30 * * * *` + +## 📄 Licence + +Projet privé — perco / NAS domestique diff --git a/scripts/init_sync_job.py b/scripts/init_sync_job.py new file mode 100755 index 0000000..e545ea2 --- /dev/null +++ b/scripts/init_sync_job.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +""" +Script d'initialisation du job de synchronisation Gitea → GitHub dans CronHub +Crée ou met à jour le cron job pour une exécution toutes les heures +""" + +import json +import sys +from urllib.request import urlopen, Request +from urllib.error import URLError, HTTPError + +# Configuration +CRONHUB_URL = "https://cronhub.nas.percolouco.com" +JOB_NAME = "Sync Gitea → GitHub" +JOB_CATEGORY = "sync" +JOB_SCHEDULE = "0 * * * *" # Toutes les heures à la minute 0 +JOB_COMMAND = "nsenter --target 1 --mount --uts --ipc --net --pid -- python3 /opt/container/cronhub/scripts/sync_gitea_to_github.py" +JOB_DESCRIPTION = "Synchronisation automatique des dépôts Gitea (http://192.168.1.29:3500/perco) vers GitHub (percolouco)" + +def log(message): + """Affiche un message""" + print(f"[INFO] {message}") + +def api_request(url, method="GET", data=None): + """Effectue une requête HTTP vers l'API CronHub""" + headers = {"Content-Type": "application/json"} + if data: + data = json.dumps(data).encode('utf-8') + + req = Request(url, headers=headers, data=data, method=method) + try: + with urlopen(req, timeout=30) as response: + return json.loads(response.read().decode('utf-8')) + except HTTPError as e: + error_msg = e.read().decode('utf-8') + log(f"Erreur HTTP {e.code} pour {url}: {error_msg}") + return None + except URLError as e: + log(f"Erreur URL pour {url}: {e.reason}") + return None + +def find_existing_job(): + """Cherche si un job avec le même nom existe déjà""" + log(f"Recherche d'un job existant nommé '{JOB_NAME}'...") + url = f"{CRONHUB_URL}/api/jobs" + jobs = api_request(url) + + if jobs is None: + log("Impossible de récupérer la liste des jobs") + return None + + for job in jobs: + if job.get("name") == JOB_NAME: + log(f"Job existant trouvé: ID={job['id']}") + return job + + log("Aucun job existant trouvé") + return None + +def create_job(): + """Crée un nouveau job dans CronHub""" + log("Création d'un nouveau job...") + url = f"{CRONHUB_URL}/api/jobs" + data = { + "name": JOB_NAME, + "schedule": JOB_SCHEDULE, + "command": JOB_COMMAND, + "description": JOB_DESCRIPTION, + "category": JOB_CATEGORY, + "enabled": True + } + + result = api_request(url, method="POST", data=data) + if result: + log(f"✓ Job créé avec succès: ID={result['id']}") + return True + else: + log("✗ Échec de la création du job") + return False + +def update_job(job_id): + """Met à jour un job existant dans CronHub""" + log(f"Mise à jour du job existant: ID={job_id}") + url = f"{CRONHUB_URL}/api/jobs/{job_id}" + data = { + "name": JOB_NAME, + "schedule": JOB_SCHEDULE, + "command": JOB_COMMAND, + "description": JOB_DESCRIPTION, + "category": JOB_CATEGORY, + "enabled": True + } + + result = api_request(url, method="PUT", data=data) + if result: + log(f"✓ Job mis à jour avec succès") + return True + else: + log("✗ Échec de la mise à jour du job") + return False + +def main(): + """Fonction principale""" + log("=== Initialisation du job de synchronisation Gitea → GitHub ===") + + # Vérifier si le job existe déjà + existing_job = find_existing_job() + + if existing_job: + # Mettre à jour le job existant + success = update_job(existing_job["id"]) + else: + # Créer un nouveau job + success = create_job() + + if success: + log("=== Configuration terminée avec succès ===") + log(f"Le job s'exécutera toutes les heures (schedule: {JOB_SCHEDULE})") + log(f"Vérifiez sur {CRONHUB_URL}") + sys.exit(0) + else: + log("=== Échec de la configuration ===") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/scripts/sync_gitea_to_github.py b/scripts/sync_gitea_to_github.py new file mode 100755 index 0000000..2e4cc52 --- /dev/null +++ b/scripts/sync_gitea_to_github.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +Script de synchronisation des dépôts Gitea vers GitHub +Synchronise tous les dépôts depuis http://192.168.1.29:3500/perco vers GitHub (percolouco) +""" + +import os +import sys +import json +import subprocess +import tempfile +import shutil +from urllib.request import urlopen, Request +from urllib.error import URLError, HTTPError + +# Configuration +GITEA_URL = "http://192.168.1.29:3500" +GITEA_USER = "perco" +GITHUB_USER = "percolouco" + +# Les tokens doivent être configurés comme secrets dans Cursor Dashboard +GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "") +GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "") + +def log(message): + """Affiche un message avec timestamp""" + from datetime import datetime + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"[{timestamp}] {message}") + +def api_request(url, token=None, method="GET", data=None): + """Effectue une requête HTTP avec gestion du token""" + headers = {"Accept": "application/json"} + if token: + headers["Authorization"] = f"token {token}" + if data: + headers["Content-Type"] = "application/json" + data = json.dumps(data).encode('utf-8') + + req = Request(url, headers=headers, data=data, method=method) + try: + with urlopen(req, timeout=30) as response: + return json.loads(response.read().decode('utf-8')) + except HTTPError as e: + log(f"Erreur HTTP {e.code} pour {url}: {e.read().decode('utf-8')}") + return None + except URLError as e: + log(f"Erreur URL pour {url}: {e.reason}") + return None + +def get_gitea_repos(): + """Liste tous les dépôts de l'utilisateur Gitea""" + log(f"Récupération des dépôts Gitea pour {GITEA_USER}...") + url = f"{GITEA_URL}/api/v1/user/repos" + repos = api_request(url, token=GITEA_TOKEN) + + if repos is None: + log("Erreur lors de la récupération des dépôts Gitea") + return [] + + log(f"Trouvé {len(repos)} dépôts Gitea") + return repos + +def github_repo_exists(repo_name): + """Vérifie si un dépôt existe sur GitHub""" + url = f"https://api.github.com/repos/{GITHUB_USER}/{repo_name}" + result = api_request(url, token=GITHUB_TOKEN) + return result is not None + +def create_github_repo(repo_name, description="", is_private=False): + """Crée un dépôt sur GitHub""" + log(f"Création du dépôt GitHub: {repo_name}") + url = "https://api.github.com/user/repos" + data = { + "name": repo_name, + "description": description, + "private": is_private, + "auto_init": False + } + result = api_request(url, token=GITHUB_TOKEN, method="POST", data=data) + return result is not None + +def sync_repo(gitea_repo): + """Synchronise un dépôt Gitea vers GitHub en mode mirror""" + repo_name = gitea_repo["name"] + log(f"Début de la synchronisation: {repo_name}") + + # Construire l'URL clone avec token + clone_url = gitea_repo["clone_url"] + if GITEA_TOKEN: + # Insérer le token dans l'URL + clone_url = clone_url.replace("http://", f"http://oauth2:{GITEA_TOKEN}@") + + # URL GitHub avec token + github_url = f"https://{GITHUB_TOKEN}@github.com/{GITHUB_USER}/{repo_name}.git" + + # Créer le dépôt GitHub s'il n'existe pas + if not github_repo_exists(repo_name): + description = gitea_repo.get("description", "") + is_private = gitea_repo.get("private", False) + if not create_github_repo(repo_name, description, is_private): + log(f"Échec de la création du dépôt GitHub: {repo_name}") + return False + + # Créer un répertoire temporaire + temp_dir = tempfile.mkdtemp(prefix=f"gitea_sync_{repo_name}_") + + try: + log(f"Clone mirror depuis Gitea: {repo_name}") + # Clone en mode mirror + result = subprocess.run( + ["git", "clone", "--mirror", clone_url, temp_dir], + capture_output=True, + text=True, + timeout=300 + ) + + if result.returncode != 0: + log(f"Erreur lors du clone: {result.stderr}") + return False + + log(f"Push mirror vers GitHub: {repo_name}") + # Push en mode mirror vers GitHub + result = subprocess.run( + ["git", "push", "--mirror", github_url], + cwd=temp_dir, + capture_output=True, + text=True, + timeout=300 + ) + + if result.returncode != 0: + log(f"Erreur lors du push: {result.stderr}") + return False + + log(f"✓ Synchronisation réussie: {repo_name}") + return True + + except subprocess.TimeoutExpired: + log(f"Timeout lors de la synchronisation de {repo_name}") + return False + except Exception as e: + log(f"Erreur lors de la synchronisation de {repo_name}: {e}") + return False + finally: + # Nettoyer le répertoire temporaire + try: + shutil.rmtree(temp_dir) + except Exception as e: + log(f"Erreur lors du nettoyage de {temp_dir}: {e}") + +def main(): + """Fonction principale""" + log("=== Début de la synchronisation Gitea → GitHub ===") + + # Vérifier les tokens + if not GITEA_TOKEN: + log("ATTENTION: GITEA_TOKEN non défini, l'accès aux dépôts privés peut échouer") + if not GITHUB_TOKEN: + log("ERREUR: GITHUB_TOKEN non défini (requis)") + sys.exit(1) + + # Récupérer la liste des dépôts + repos = get_gitea_repos() + if not repos: + log("Aucun dépôt à synchroniser") + return + + # Synchroniser chaque dépôt + success_count = 0 + fail_count = 0 + + for repo in repos: + if sync_repo(repo): + success_count += 1 + else: + fail_count += 1 + + # Résumé + log("=== Synchronisation terminée ===") + log(f"Réussis: {success_count}/{len(repos)}") + log(f"Échecs: {fail_count}/{len(repos)}") + + if fail_count > 0: + sys.exit(1) + +if __name__ == "__main__": + main()