Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
3a0ad78
Refactor log retrieval in get_logs endpoint and improve log formattin…
gbrault Dec 2, 2025
56eb25e
Synchroniser les jobs de la base de données avec le crontab système a…
gbrault Dec 2, 2025
499813f
Améliorer la mise en forme du tableau des jobs et ajouter des informa…
gbrault Dec 2, 2025
cd063c0
Ajouter le chemin complet pour la commande ts dans la fonction add_lo…
gbrault Dec 2, 2025
407f37e
Corriger les chemins des fichiers journaux en ajoutant os.getcwd() po…
gbrault Dec 2, 2025
5f47a72
Améliorer l'affichage des logs en ajoutant des détails sur le job, y …
gbrault Dec 2, 2025
67d0992
Ajout de la gestion des jobs en arrière-plan avec verrouillage pour é…
gbrault Dec 2, 2025
0235a11
Ajout de la journalisation pour le lancement des jobs et les erreurs …
gbrault Dec 2, 2025
f5c7ea9
Ajout de la journalisation et gestion des erreurs dans les fonctions …
gbrault Dec 2, 2025
3c5c5dc
Simplification du détachement du processus en supprimant les options …
gbrault Dec 2, 2025
62c6650
Refactor le lancement des jobs en arrière-plan en utilisant un script…
gbrault Dec 2, 2025
bd57096
Ajout de la configuration CORS dans main.py et amélioration de la jou…
gbrault Dec 2, 2025
fdc2ced
Ajout d'une réponse JSON explicite pour la fonction run_job afin d'am…
gbrault Dec 2, 2025
e219381
Remplacement de $.ajax par fetch pour améliorer la compatibilité avec…
gbrault Dec 2, 2025
f16976b
Correction de la syntaxe des en-têtes dans la fonction de lancement d…
gbrault Dec 2, 2025
c7ddcca
Refactor main.js pour remplacer jQuery par JavaScript vanilla, amélio…
gbrault Dec 2, 2025
fdc603b
Ajout des fonctionnalités pour effacer et rafraîchir les logs des job…
gbrault Dec 2, 2025
9a8a91a
Modification du titre dans le menu pour afficher "Cron Jobs Manager" …
gbrault Dec 2, 2025
26773a9
Ajout d'un wrapper pour les jobs cron afin de gérer le système de loc…
gbrault Dec 2, 2025
895837e
Ajout de la fonctionnalité de popups "Show Command" avec gestion de l…
gbrault Dec 2, 2025
a30e2f4
Amélioration de la gestion des erreurs et des alertes lors de l'exécu…
gbrault Dec 2, 2025
e3221e6
Ajout de la gestion sécurisée des commandes en utilisant shlex.quote …
gbrault Dec 2, 2025
1ebe2c2
Amélioration de la gestion des fichiers de lock pour les jobs cron. A…
gbrault Dec 2, 2025
722be31
Ajout de la journalisation pour le wrapper des jobs cron. Enregistrem…
gbrault Dec 2, 2025
b752ef2
Amélioration de la journalisation dans la fonction is_job_running. Ch…
gbrault Dec 2, 2025
7ed04c9
Ajout de la possibilité de lancer manuellement un job avec la command…
gbrault Dec 2, 2025
abb10ed
Remplacement de subprocess.run par os.system pour l'exécution des com…
gbrault Dec 2, 2025
bbca5bf
Remplacement de subprocess.run par os.system pour l'exécution des com…
gbrault Dec 2, 2025
5c6771a
Suppression du wrapper cron dans la fonction add_log_file pour simpli…
gbrault Dec 3, 2025
610db67
Ajout de la fonction get_cron_description pour générer des descriptio…
gbrault Dec 3, 2025
524abc9
Mise à jour de la gestion des descriptions cron pour utiliser cron-de…
gbrault Dec 3, 2025
f9dae8d
Amélioration de la fonction add_log_file pour gérer l'exécution manue…
gbrault Dec 3, 2025
270bd32
Ajout de la gestion de l'activation/désactivation des jobs dans le cr…
gbrault Dec 4, 2025
b35e2bd
Ajout de la fonctionnalité de fermeture du modal. Implémentation de l…
gbrault Dec 4, 2025
c79e11e
Amélioration de la gestion de la fermeture du modal en rendant le cod…
gbrault Dec 4, 2025
bace644
Amélioration de l'interface utilisateur : mise à jour des boutons d'a…
gbrault Dec 4, 2025
0a22ad7
Mise à jour des messages et des commentaires en anglais dans le code …
gbrault Dec 4, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
323 changes: 314 additions & 9 deletions cronservice.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,39 @@
from crontab import CronTab
from croniter import croniter
from datetime import datetime
from datetime import datetime, timedelta
import getpass
import subprocess
import os
import sys
import psutil
from pathlib import Path
import logging
import shlex
from cron_descriptor import get_description, Options

from utils import add_log_file, Command, Name, Schedule, delete_log_file

logger = logging.getLogger(__name__)

_user = getpass.getuser()

_cron = CronTab(user=_user)


def add_cron_job(comm: Command, name: Name, sched: Schedule) -> None:
def add_cron_job(comm: Command, name: Name, sched: Schedule, job_id: int) -> None:
if croniter.is_valid(sched):
job = _cron.new(command=add_log_file(comm, name), comment=name)
job = _cron.new(command=add_log_file(comm, name, job_id), comment=name)
job.setall(sched)
_cron.write()
else:
raise ValueError("Invalid Cron Expression")


def update_cron_job(comm: Command, name: Name, sched: Schedule, old_name: Name) -> None:
def update_cron_job(comm: Command, name: Name, sched: Schedule, old_name: Name, job_id: int) -> None:
match = _cron.find_comment(old_name)
job = list(match)[0]
job.setall(sched)
job.set_command(add_log_file(comm, name))
job.set_command(add_log_file(comm, name, job_id))
job.set_comment(name)
_cron.write()

Expand All @@ -34,10 +44,213 @@ def delete_cron_job(name: Name) -> None:
delete_log_file(name)


def run_manually(name: Name) -> None:
match = _cron.find_comment(name)
job = list(match)[0]
job.run()
def get_lock_file_path(job_id: int) -> Path:
"""Retourne le chemin du fichier lock pour un job"""
return Path(f"/tmp/crontab_job_{job_id}.lock")


def is_job_running(job_id: int) -> tuple[bool, int | None]:
"""
Vérifie si un job est déjà en cours d'exécution.

Returns:
tuple: (is_running, pid) - True si le job tourne, False sinon, avec le PID ou None
"""
lock_file = get_lock_file_path(job_id)

logger.info(f"Checking lock file: {lock_file}, exists: {lock_file.exists()}")

if not lock_file.exists():
logger.info(f"No lock file for job {job_id}")
return False, None

try:
with open(lock_file, 'r') as f:
pid = int(f.read().strip())

logger.info(f"Lock file contains PID: {pid}")

# Vérifier si le process existe toujours
if psutil.pid_exists(pid):
logger.info(f"PID {pid} exists in system")
try:
process = psutil.Process(pid)
status = process.status()
logger.info(f"Process {pid} status: {status}")
# Vérifier que le process n'est pas un zombie
if status != psutil.STATUS_ZOMBIE:
logger.info(f"Job {job_id} is running with PID {pid}")
return True, pid
else:
logger.info(f"Process {pid} is zombie, cleaning lock")
except (psutil.NoSuchProcess, psutil.AccessDenied) as e:
logger.info(f"Process check failed: {e}")
pass
else:
logger.info(f"PID {pid} does not exist")

# Le process n'existe plus, nettoyer le lock
lock_file.unlink()
logger.info(f"Cleaned stale lock file for job {job_id}")
return False, None

except (ValueError, FileNotFoundError):
# Fichier lock corrompu ou supprimé entre-temps
if lock_file.exists():
lock_file.unlink()
return False, None


def create_lock(job_id: int, pid: int) -> None:
"""Crée un fichier lock avec le PID du process"""
lock_file = get_lock_file_path(job_id)
with open(lock_file, 'w') as f:
f.write(str(pid))


def release_lock(job_id: int) -> None:
"""Supprime le fichier lock"""
lock_file = get_lock_file_path(job_id)
if lock_file.exists():
lock_file.unlink()


def run_manually(name: Name, job_id: int, db_command: str) -> dict:
"""
Lance un job manuellement en arrière-plan de manière non-bloquante.

Args:
name: Nom du job
job_id: ID du job
db_command: Commande originale depuis la DB (sans wrapper cron)

Returns:
dict: Informations sur le lancement (success, message, pid)
"""
# Vérifier si le job est déjà en cours d'exécution
is_running, existing_pid = is_job_running(job_id)
if is_running:
logger.warning(f"Job {job_id} ({name}) already running with PID {existing_pid}")
return {
"success": False,
"message": f"Job already running with PID {existing_pid}",
"pid": existing_pid
}

try:
# Utiliser la commande originale (DB) avec logging, pas celle du crontab (qui a le wrapper)
from utils import add_log_file
command = add_log_file(db_command, name, job_id=None) # Sans wrapper cron

logger.info(f"Launching job {job_id} ({name}) in background")
logger.debug(f"Command to execute: {command}")

# Créer un script Python wrapper qui gère l'exécution
wrapper_script = f"""#!/usr/bin/env python3
import subprocess
import sys
import os
import signal
import logging

# Configuration du logging
logging.basicConfig(
filename='/tmp/crontab_wrapper_{job_id}.log',
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)

logger.info("Wrapper started for job {job_id}")

# Ignorer SIGHUP pour survivre à la fermeture de la session parent
signal.signal(signal.SIGHUP, signal.SIG_IGN)

lock_file = "/tmp/crontab_job_{job_id}.lock"

try:
command = {repr(command)}
logger.info(f"Executing command: {{command}}")

# Exécuter la commande via shell
result = subprocess.run(
command,
shell=True,
capture_output=False
)

logger.info(f"Command finished with return code: {{result.returncode}}")
sys.exit(result.returncode)
finally:
# Nettoyer le lock
logger.info("Cleaning up lock file")
try:
os.remove(lock_file)
logger.info("Lock file removed")
except Exception as e:
logger.error(f"Failed to remove lock: {{e}}")
"""

# Créer un fichier temporaire pour le script wrapper
wrapper_path = f"/tmp/crontab_wrapper_{job_id}.py"
with open(wrapper_path, 'w') as f:
f.write(wrapper_script)
os.chmod(wrapper_path, 0o755)

logger.debug(f"Wrapper script created at {wrapper_path}")

# Lancer le processus en arrière-plan détaché
process = subprocess.Popen(
[sys.executable, wrapper_path],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
start_new_session=True,
close_fds=True,
)

pid = process.pid

# Créer immédiatement le lock avec le PID du wrapper
create_lock(job_id, pid)
logger.info(f"Lock created for job {job_id} with PID {pid}")

# Attendre 0.5s et vérifier que le process existe toujours
import time
time.sleep(0.5)

if not psutil.pid_exists(pid):
release_lock(job_id)
logger.error(f"Wrapper process {pid} died immediately - check wrapper script")
return {
"success": False,
"message": "Le wrapper a échoué au démarrage. Vérifiez les logs.",
"pid": None
}

# Ne pas attendre la fin du processus
logger.info(f"Job {job_id} ({name}) launched successfully with PID {pid}")

return {
"success": True,
"message": f"Job lancé en arrière-plan (PID: {pid}). Consultez les logs pour suivre l'exécution.",
"pid": pid
}

except IndexError:
logger.error(f"Job {job_id} ({name}) not found in crontab")
return {
"success": False,
"message": "Job not found in crontab",
"pid": None
}
except Exception as e:
logger.error(f"Error launching job {job_id} ({name}): {str(e)}", exc_info=True)
return {
"success": False,
"message": f"Error launching job: {str(e)}",
"pid": None
}


def get_next_schedule(name: Name) -> str:
Expand All @@ -48,3 +261,95 @@ def get_next_schedule(name: Name) -> str:
return schedule.get_next().strftime("%d/%m/%Y %H:%M:%S").replace("/", "-")
except IndexError:
return None


def get_cron_description(schedule: str, locale: str = "en") -> str:
"""
Génère une description lisible d'une expression cron.

Args:
schedule: Expression cron (ex: "0 2 * * *")
locale: Code locale à 2 lettres (ex: "fr", "en")

Returns:
str: Description localisée ou expression brute en cas d'erreur
"""
try:
# cron-descriptor 2.x utilise Options() au lieu de locale_code
# La locale est configurée via les Options
options = Options()
options.throw_exception_on_parse_error = False
options.use_24hour_time_format = True

# Note: cron-descriptor 2.x ne supporte pas facilement les locales dynamiques
# La locale doit être configurée globalement ou via des options spécifiques
# Pour l'instant, on utilise la locale par défaut (anglais)
return get_description(schedule, options=options)
except Exception as e:
logger.warning(f"Failed to get cron description for '{schedule}': {e}")
return schedule # Fallback sur l'expression brute


def sync_job_to_cron(comm: Command, name: Name, sched: Schedule, job_id: int, is_active: bool = True) -> None:
"""Synchronise un job de la DB vers le crontab système"""
# Vérifier si le job existe déjà dans le crontab
existing_jobs = list(_cron.find_comment(name))

if existing_jobs:
# Mettre à jour le job existant
job = existing_jobs[0]
job.setall(sched)
job.set_command(add_log_file(comm, name, job_id))
job.enable(is_active) # Activer ou commenter le job
else:
# Créer un nouveau job
if croniter.is_valid(sched):
job = _cron.new(command=add_log_file(comm, name, job_id), comment=name)
job.setall(sched)
job.enable(is_active) # Activer ou commenter le job

_cron.write()


def enable_cron_job(name: Name, enable: bool = True) -> bool:
"""
Active ou désactive un job dans le crontab.

Args:
name: Nom du job (commentaire dans le crontab)
enable: True pour activer, False pour désactiver (commenter avec #)

Returns:
bool: True si l'opération a réussi, False sinon
"""
try:
match = _cron.find_comment(name)
job = list(match)[0]
job.enable(enable)
_cron.write()
logger.info(f"Job '{name}' {'enabled' if enable else 'disabled'} successfully")
return True
except IndexError:
logger.error(f"Job '{name}' not found in crontab")
return False
except Exception as e:
logger.error(f"Error enabling/disabling job '{name}': {e}")
return False


def is_job_enabled(name: Name) -> bool:
"""
Vérifie si un job est activé dans le crontab.

Args:
name: Nom du job (commentaire dans le crontab)

Returns:
bool: True si le job est activé, False s'il est désactivé ou non trouvé
"""
try:
match = _cron.find_comment(name)
job = list(match)[0]
return job.is_enabled()
except IndexError:
return False
Loading