feat: deploy agent from any git URL (from_git / from_git_local)

- DeployConfig: add git_url, main_script, apt_deps fields
- Deployer: new _deploy_from_git() and _detect_main_script() methods
  Auto-detects main script (agent_*.py > main.py > grep __main__)
  Uses minimal apt defaults, reads requirements.txt for pip deps
- deploy skill: add from_git and from_git_local actions
  from_git <url> <nom> <host> <user> password|key <cred> <xmpp_jid> <xmpp_pass> <mqtt_host> [main_script]
  from_git_local <url> <nom> <xmpp_jid> <xmpp_pass> <mqtt_host> [main_script]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 19:12:30 +00:00
parent 6aac8a4e3b
commit 9668304187
2 changed files with 134 additions and 5 deletions
+86 -4
View File
@@ -18,7 +18,7 @@ CATALOG_PATH = "/opt/agent_deploy/config/catalog.json"
@dataclass
class DeployConfig:
"""Paramètres d'un déploiement."""
agent_type: str # type d'agent : debian, ansible, deploy...
agent_type: str # type d'agent : debian, ansible, deploy... ou "custom"
agent_name: str # nom choisi par l'utilisateur (ex: "debian-prod")
host: str # IP ou hostname cible
ssh_user: str # utilisateur SSH
@@ -30,6 +30,9 @@ class DeployConfig:
mqtt_port: int = 1883
local: bool = False # installation locale (pas de SSH)
extra: dict = field(default_factory=dict)
git_url: Optional[str] = None # bypass catalogue : URL git directe
main_script: Optional[str] = None # script principal (None = auto-détection)
apt_deps: list = field(default_factory=list) # override des dépendances apt
class AgentCatalog:
@@ -123,8 +126,16 @@ class Deployer:
self._cb = progress_cb or (lambda msg: logger.info(msg))
self._ssh = None
# Dépendances apt minimales pour tout agent Python custom
_DEFAULT_APT_DEPS = ["python3", "python3-pip", "python3-venv", "git"]
def deploy(self) -> tuple[bool, str]:
"""Lance le déploiement. Retourne (succès, message)."""
# ── Mode "from_git" : repo arbitraire, sans catalogue ──────────────
if self.cfg.git_url:
return self._deploy_from_git()
# ── Mode catalogue ──────────────────────────────────────────────────
agent_info = self.catalog.get(self.cfg.agent_type)
if not agent_info:
return False, f"Type d'agent inconnu : '{self.cfg.agent_type}'. Disponibles : {self.catalog.list_types()}"
@@ -164,6 +175,73 @@ class Deployer:
f"JID XMPP : {self.cfg.xmpp_jid}"
)
def _deploy_from_git(self) -> tuple[bool, str]:
"""Déploie un agent depuis une URL git arbitraire (sans catalogue)."""
install_path = f"/opt/{self.cfg.agent_name}"
service_name = self.cfg.agent_name
apt_deps = self.cfg.apt_deps or self._DEFAULT_APT_DEPS
# Le main_script sera détecté après le clone si non fourni
main_script_holder = [self.cfg.main_script]
def detect_script():
if main_script_holder[0]:
return True, main_script_holder[0]
ok, result = self._detect_main_script(install_path)
if ok:
main_script_holder[0] = result
return ok, result
steps = [
("Connexion SSH", lambda: self._connect()),
("Dépendances système", lambda: self._install_apt(apt_deps)),
("Clonage du dépôt", lambda: self._clone_repo(self.cfg.git_url, install_path)),
("Détection script", detect_script),
("Environnement Python", lambda: self._setup_venv(install_path, [])),
("Configuration", lambda: self._write_config(install_path, "custom")),
("Service systemd", lambda: self._setup_service(install_path, service_name, main_script_holder[0])),
("Démarrage", lambda: self._start_service(service_name)),
]
for step_name, step_fn in steps:
self._cb(f"{step_name}...")
try:
ok, msg = step_fn()
if not ok:
self._cb(f"{step_name} échoué : {msg}")
self._disconnect()
return False, f"Échec à l'étape '{step_name}' : {msg}"
self._cb(f"{step_name}" + (f"{msg}" if step_name == "Détection script" else ""))
except Exception as e:
self._disconnect()
return False, f"Exception lors de '{step_name}' : {e}"
self._disconnect()
return True, (
f"Agent '{self.cfg.agent_name}' déployé depuis {self.cfg.git_url}\n"
f"Script : {main_script_holder[0]}\n"
f"Service : {service_name}\n"
f"Chemin : {install_path}\n"
f"JID XMPP : {self.cfg.xmpp_jid}"
)
def _detect_main_script(self, install_path: str) -> tuple[bool, str]:
"""Détecte automatiquement le script principal dans le repo cloné."""
# Priorité : agent_*.py > main.py > premier .py avec __main__
cmd = (
f"cd {install_path} && ("
f"ls agent_*.py 2>/dev/null | head -1 || "
f"ls main.py 2>/dev/null || "
f"grep -rl '__main__' *.py 2>/dev/null | head -1 || "
f"ls *.py 2>/dev/null | grep -v 'setup.py\\|conf' | head -1"
f")"
)
ok, out = self._run_remote(cmd)
script = out.strip().splitlines()[0].strip() if out.strip() else ""
if not script:
return False, "Aucun script Python trouvé à la racine du dépôt"
return True, script
# ──────────────────────────────────────────────
# Étapes de déploiement
# ──────────────────────────────────────────────
@@ -241,9 +319,13 @@ class Deployer:
def _write_config(self, install_path: str, template: str) -> tuple[bool, str]:
"""Écrit config.json avec les paramètres fournis par l'utilisateur."""
# Récupère les extra_config du catalogue pour ce type d'agent
agent_info = self.catalog.get(self.cfg.agent_type) or {}
catalog_extra = agent_info.get("extra_config", {})
# Pour un déploiement custom (from_git), pas d'infos catalogue
if template == "custom":
agent_info, catalog_extra = {}, {}
else:
# Récupère les extra_config du catalogue pour ce type d'agent
agent_info = self.catalog.get(self.cfg.agent_type) or {}
catalog_extra = agent_info.get("extra_config", {})
# Résout les templates dans les valeurs (ex: /opt/{agent_name}/config/catalog.json)
resolved_extra = {}
+48 -1
View File
@@ -21,7 +21,9 @@ DESCRIPTION = "Déployer un agent sur une machine distante (SSH) ou locale"
USAGE = (
"SKILL:deploy ARGS:catalog — liste les agents déployables\n"
"SKILL:deploy ARGS:start <type> <nom> <host> <user> password <mdp> <xmpp_jid> <xmpp_pass> <mqtt_host>\n"
"SKILL:deploy ARGS:local <type> <nom> <xmpp_jid> <xmpp_pass> <mqtt_host>"
"SKILL:deploy ARGS:local <type> <nom> <xmpp_jid> <xmpp_pass> <mqtt_host>\n"
"SKILL:deploy ARGS:from_git <git_url> <nom> <host> <user> password|key <credential> <xmpp_jid> <xmpp_pass> <mqtt_host> [main_script]\n"
"SKILL:deploy ARGS:from_git_local <git_url> <nom> <xmpp_jid> <xmpp_pass> <mqtt_host> [main_script]"
)
@@ -77,6 +79,51 @@ def run(args: str, context) -> str:
)
return _do_deploy(cfg, context)
if action == "from_git":
# ARGS: from_git <git_url> <nom> <host> <user> password|key <credential> <xmpp_jid> <xmpp_pass> <mqtt_host> [main_script]
p = rest.split()
if len(p) < 9:
return (
"Format : from_git <git_url> <nom> <host> <user> password|key <credential> "
"<xmpp_jid> <xmpp_pass> <mqtt_host> [main_script]"
)
cfg = DeployConfig(
agent_type="custom",
agent_name=p[1],
host=p[2],
ssh_user=p[3],
ssh_auth=p[4],
ssh_credential=p[5],
xmpp_jid=p[6],
xmpp_password=p[7],
mqtt_host=p[8],
mqtt_port=int(p[9]) if len(p) > 9 and p[9].isdigit() else 1883,
git_url=p[0],
main_script=p[10] if len(p) > 10 else None,
)
return _do_deploy(cfg, context)
if action == "from_git_local":
# ARGS: from_git_local <git_url> <nom> <xmpp_jid> <xmpp_pass> <mqtt_host> [main_script]
p = rest.split()
if len(p) < 5:
return "Format : from_git_local <git_url> <nom> <xmpp_jid> <xmpp_pass> <mqtt_host> [main_script]"
cfg = DeployConfig(
agent_type="custom",
agent_name=p[1],
host="localhost",
ssh_user="root",
ssh_auth="",
ssh_credential="",
xmpp_jid=p[2],
xmpp_password=p[3],
mqtt_host=p[4],
local=True,
git_url=p[0],
main_script=p[5] if len(p) > 5 else None,
)
return _do_deploy(cfg, context)
if action == "status":
host = rest.strip()
if not host: