Integrate claude, improve selenium and support qemu #1
2 changed files with 259 additions and 4 deletions
|
|
@ -2,6 +2,7 @@
|
|||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
import ast
|
||||
import configparser
|
||||
import datetime
|
||||
import getpass
|
||||
|
|
@ -1318,19 +1319,207 @@ class TODO:
|
|||
names = self._qemu_pick_domains()
|
||||
if not names:
|
||||
return
|
||||
deploy_key = self._is_yes_default_yes(
|
||||
input(t("Create the SSH key if missing and deploy it? (Y/n): "))
|
||||
)
|
||||
ip_map = self._qemu_resolve_ips(names, timeout=60)
|
||||
written = 0
|
||||
written = []
|
||||
for name in names:
|
||||
ip = ip_map.get(name)
|
||||
if not ip:
|
||||
print(f" ⏭ {name}: {t('no IP')}")
|
||||
continue
|
||||
self._write_ssh_config_entry(name, "erplibre", ip)
|
||||
written += 1
|
||||
written.append(name)
|
||||
print(
|
||||
f"\n✅ {written} {self._plural(t('entry'), written)}"
|
||||
f" {t('written')} ({len(names) - written} {t('skipped')})"
|
||||
f"\n✅ {len(written)} {self._plural(t('entry'), len(written))}"
|
||||
f" {t('written')} ({len(names) - len(written)} {t('skipped')})"
|
||||
)
|
||||
if deploy_key:
|
||||
self._ssh_deploy_keys(written)
|
||||
|
||||
def _ssh_ensure_key(self):
|
||||
"""Chemin de la clé PUBLIQUE, générée si aucune n'existe.
|
||||
|
||||
Sans clé, ssh-copy-id n'a rien à déployer. On en crée une ed25519 sans
|
||||
passphrase — le même choix que `deploy_qemu.ensure_ssh_key`, pour que
|
||||
les VM créées et celles adoptées ici partagent la même clé."""
|
||||
existing = self._qemu_default_ssh_key()
|
||||
if existing:
|
||||
return existing
|
||||
path = os.path.expanduser("~/.ssh/id_ed25519")
|
||||
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
|
||||
print(f"🔑 {t('Generating an ed25519 SSH key')}: {path}")
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["ssh-keygen", "-t", "ed25519", "-N", "", "-f", path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
print(f" ⚠ {t('Cannot generate the key')}: {exc}")
|
||||
return ""
|
||||
if res.returncode != 0:
|
||||
print(f" ⚠ {t('Cannot generate the key')}: {res.stderr.strip()}")
|
||||
return ""
|
||||
return f"{path}.pub"
|
||||
|
||||
@staticmethod
|
||||
def _ssh_key_accepted(alias):
|
||||
"""Vrai si la connexion par CLÉ passe déjà (aucun mot de passe).
|
||||
|
||||
`PasswordAuthentication=no` est le point clé : sans lui, ssh
|
||||
basculerait sur le mot de passe et on croirait la clé installée."""
|
||||
try:
|
||||
res = subprocess.run(
|
||||
[
|
||||
"ssh",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"PasswordAuthentication=no",
|
||||
"-o",
|
||||
"ConnectTimeout=10",
|
||||
alias,
|
||||
"true",
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=45,
|
||||
)
|
||||
return res.returncode == 0
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
def _ssh_deploy_keys(self, aliases):
|
||||
"""Déploie la clé publique sur les hôtes qui ne l'ont pas encore.
|
||||
|
||||
ssh-copy-id passe par ssh, donc par ~/.ssh/config : le ProxyJump d'une
|
||||
VM imbriquée s'applique tout seul. Le mot de passe est demandé
|
||||
directement dans le terminal (pas de capture de la sortie), sinon
|
||||
l'invite serait invisible."""
|
||||
if not aliases:
|
||||
return
|
||||
pub = self._ssh_ensure_key()
|
||||
if not pub:
|
||||
return
|
||||
print(
|
||||
f"\n🔑 {t('Deploying the key on')} {len(aliases)} "
|
||||
f"{self._plural(t('host'), len(aliases))} ({pub})"
|
||||
)
|
||||
n_ok = n_skip = n_fail = 0
|
||||
for alias in aliases:
|
||||
if self._ssh_key_accepted(alias):
|
||||
print(f" · {alias}: {t('key already accepted')}")
|
||||
n_skip += 1
|
||||
continue
|
||||
print(f" ⤴ ssh-copy-id {alias}")
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["ssh-copy-id", "-i", pub, alias], timeout=180
|
||||
)
|
||||
ok = res.returncode == 0
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
print(f" ⚠ {exc}")
|
||||
ok = False
|
||||
if ok:
|
||||
n_ok += 1
|
||||
else:
|
||||
n_fail += 1
|
||||
print(
|
||||
f" {n_ok} {t('deployed')} · {n_skip} {t('already there')} · "
|
||||
f"{n_fail} {t('failed')}"
|
||||
)
|
||||
|
||||
# Connexions de virt-manager : stockées dans GSettings, pas dans un
|
||||
# fichier. Le schéma est le même depuis des années (virt-manager 5.1
|
||||
# inclus) ; on LIT d'abord, et on n'écrit que si la lecture a marché.
|
||||
_VIRT_MANAGER_SCHEMA = "org.virt-manager.virt-manager.connections"
|
||||
|
||||
def _virt_manager_uris(self):
|
||||
"""URI déjà connues de virt-manager, ou None s'il n'est pas là."""
|
||||
if not shutil.which("virt-manager") or not shutil.which("gsettings"):
|
||||
return None
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["gsettings", "get", self._VIRT_MANAGER_SCHEMA, "uris"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
if res.returncode != 0:
|
||||
return None
|
||||
# GSettings rend du littéral Python : ['a', 'b'] ou @as [].
|
||||
raw = res.stdout.strip()
|
||||
if raw.startswith("@as "):
|
||||
raw = raw[4:].strip()
|
||||
try:
|
||||
value = ast.literal_eval(raw)
|
||||
except (ValueError, SyntaxError):
|
||||
return None
|
||||
return [str(item) for item in value] if isinstance(value, list) else []
|
||||
|
||||
def _virt_manager_add(self, uris):
|
||||
"""Ajoute les URI manquantes à virt-manager. Renvoie le nb ajouté."""
|
||||
current = self._virt_manager_uris()
|
||||
if current is None:
|
||||
return 0
|
||||
missing = [uri for uri in uris if uri not in current]
|
||||
if not missing:
|
||||
print(f" · {t('virt-manager: every connection already there')}")
|
||||
return 0
|
||||
merged = current + missing
|
||||
literal = "[" + ", ".join(f"'{uri}'" for uri in merged) + "]"
|
||||
try:
|
||||
res = subprocess.run(
|
||||
[
|
||||
"gsettings",
|
||||
"set",
|
||||
self._VIRT_MANAGER_SCHEMA,
|
||||
"uris",
|
||||
literal,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
print(f" ⚠ virt-manager: {exc}")
|
||||
return 0
|
||||
if res.returncode != 0:
|
||||
print(f" ⚠ virt-manager: {res.stderr.strip()}")
|
||||
return 0
|
||||
for uri in missing:
|
||||
print(f" ✅ virt-manager: {uri}")
|
||||
return len(missing)
|
||||
|
||||
def _virt_manager_offer(self, hosts):
|
||||
"""Propose d'ajouter à virt-manager les machines qui font tourner
|
||||
libvirt, pour piloter leurs VM depuis l'interface graphique locale.
|
||||
|
||||
On passe par l'ALIAS SSH et non par l'IP : le transport qemu+ssh
|
||||
utilise le binaire ssh, donc ~/.ssh/config — l'alias porte déjà
|
||||
l'adresse ET le ProxyJump, ce qu'une IP brute ne saurait pas faire
|
||||
pour une VM imbriquée."""
|
||||
if self._virt_manager_uris() is None:
|
||||
return
|
||||
uris = ["qemu:///system"] + [
|
||||
f"qemu+ssh://erplibre@{alias}/system" for alias in hosts
|
||||
]
|
||||
print(f"\n🖥 {t('virt-manager detected')}")
|
||||
for uri in uris:
|
||||
print(f" {uri}")
|
||||
if not self._is_yes_default_yes(
|
||||
input(t("Add the missing connections to virt-manager? (Y/n): "))
|
||||
):
|
||||
return
|
||||
added = self._virt_manager_add(uris)
|
||||
if added:
|
||||
# virt-manager réécrit ses réglages en quittant : s'il tourne, il
|
||||
# écraserait ce qu'on vient d'ajouter.
|
||||
print(f" ⚠ {t('Restart virt-manager if it is open.')}")
|
||||
|
||||
def _qemu_ssh_probe_remote(self, alias):
|
||||
"""VM libvirt vues DEPUIS `alias` : [(nom, ip)].
|
||||
|
|
@ -1380,10 +1569,14 @@ class TODO:
|
|||
max_depth = max(1, int(raw)) if raw else self._QEMU_SSH_DEPTH
|
||||
except ValueError:
|
||||
max_depth = self._QEMU_SSH_DEPTH
|
||||
deploy_key = self._is_yes_default_yes(
|
||||
input(t("Create the SSH key if missing and deploy it? (Y/n): "))
|
||||
)
|
||||
|
||||
# Niveau 0 : les VM locales, jointes directement.
|
||||
ip_map = self._qemu_resolve_ips(roots, timeout=60)
|
||||
aliases = {} # alias -> (nom, ip, parent|None)
|
||||
hosts_libvirt = [] # machines qui font tourner libvirt
|
||||
frontier = []
|
||||
for name in roots:
|
||||
ip = ip_map.get(name)
|
||||
|
|
@ -1397,6 +1590,11 @@ class TODO:
|
|||
for depth in range(1, max_depth + 1):
|
||||
if not frontier:
|
||||
break
|
||||
# La clé est déployée AVANT de sonder : la sonde utilise
|
||||
# BatchMode, donc sans clé acceptée elle échouerait et le niveau
|
||||
# suivant resterait invisible.
|
||||
if deploy_key:
|
||||
self._ssh_deploy_keys(frontier)
|
||||
print(
|
||||
f"\n🔎 {t('Level')} {depth} — "
|
||||
f"{len(frontier)} {t('machines to probe')}"
|
||||
|
|
@ -1407,6 +1605,7 @@ class TODO:
|
|||
if found is None:
|
||||
print(f" ⏭ {parent}: {t('unreachable or no libvirt')}")
|
||||
continue
|
||||
hosts_libvirt.append(parent)
|
||||
if not found:
|
||||
print(f" · {parent}: {t('no nested VM')}")
|
||||
continue
|
||||
|
|
@ -1434,6 +1633,10 @@ class TODO:
|
|||
via = f" ({t('via')} {parent})" if parent else ""
|
||||
print(f" ssh {alias:<34} {ip}{via}")
|
||||
|
||||
# Les machines qui hébergent des VM sont celles qui valent d'être
|
||||
# ajoutées à virt-manager : c'est de là qu'on pilote leurs invitées.
|
||||
self._virt_manager_offer(hosts_libvirt)
|
||||
|
||||
def _qemu_stats(self):
|
||||
"""Statistiques d'utilisation de QEMU, et remise à zéro.
|
||||
|
||||
|
|
|
|||
|
|
@ -1452,6 +1452,58 @@ TRANSLATIONS = {
|
|||
"fr": "aucune VM imbriquée",
|
||||
"en": "no nested VM",
|
||||
},
|
||||
"Create the SSH key if missing and deploy it? (Y/n): ": {
|
||||
"fr": "Créer la clé SSH si absente et la déployer ? "
|
||||
"(O/n, défaut : oui) : ",
|
||||
"en": "Create the SSH key if missing and deploy it? "
|
||||
"(Y/n, default: yes): ",
|
||||
},
|
||||
"Generating an ed25519 SSH key": {
|
||||
"fr": "Génération d'une clé SSH ed25519",
|
||||
"en": "Generating an ed25519 SSH key",
|
||||
},
|
||||
"Cannot generate the key": {
|
||||
"fr": "Impossible de générer la clé",
|
||||
"en": "Cannot generate the key",
|
||||
},
|
||||
"Deploying the key on": {
|
||||
"fr": "Déploiement de la clé sur",
|
||||
"en": "Deploying the key on",
|
||||
},
|
||||
"host": {
|
||||
"fr": "hôte",
|
||||
"en": "host",
|
||||
},
|
||||
"key already accepted": {
|
||||
"fr": "clé déjà acceptée",
|
||||
"en": "key already accepted",
|
||||
},
|
||||
"deployed": {
|
||||
"fr": "déployée(s)",
|
||||
"en": "deployed",
|
||||
},
|
||||
"already there": {
|
||||
"fr": "déjà en place",
|
||||
"en": "already there",
|
||||
},
|
||||
"virt-manager detected": {
|
||||
"fr": "virt-manager détecté",
|
||||
"en": "virt-manager detected",
|
||||
},
|
||||
"virt-manager: every connection already there": {
|
||||
"fr": "virt-manager : toutes les connexions sont déjà là",
|
||||
"en": "virt-manager: every connection already there",
|
||||
},
|
||||
"Add the missing connections to virt-manager? (Y/n): ": {
|
||||
"fr": "Ajouter les connexions manquantes à virt-manager ? "
|
||||
"(O/n, défaut : oui) : ",
|
||||
"en": "Add the missing connections to virt-manager? "
|
||||
"(Y/n, default: yes): ",
|
||||
},
|
||||
"Restart virt-manager if it is open.": {
|
||||
"fr": "Redémarrer virt-manager s'il est ouvert.",
|
||||
"en": "Restart virt-manager if it is open.",
|
||||
},
|
||||
"SSH hosts written": {
|
||||
"fr": "Hôtes SSH écrits",
|
||||
"en": "SSH hosts written",
|
||||
|
|
|
|||
Loading…
Reference in a new issue