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)
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
import ast
|
||||||
import configparser
|
import configparser
|
||||||
import datetime
|
import datetime
|
||||||
import getpass
|
import getpass
|
||||||
|
|
@ -1318,19 +1319,207 @@ class TODO:
|
||||||
names = self._qemu_pick_domains()
|
names = self._qemu_pick_domains()
|
||||||
if not names:
|
if not names:
|
||||||
return
|
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)
|
ip_map = self._qemu_resolve_ips(names, timeout=60)
|
||||||
written = 0
|
written = []
|
||||||
for name in names:
|
for name in names:
|
||||||
ip = ip_map.get(name)
|
ip = ip_map.get(name)
|
||||||
if not ip:
|
if not ip:
|
||||||
print(f" ⏭ {name}: {t('no IP')}")
|
print(f" ⏭ {name}: {t('no IP')}")
|
||||||
continue
|
continue
|
||||||
self._write_ssh_config_entry(name, "erplibre", ip)
|
self._write_ssh_config_entry(name, "erplibre", ip)
|
||||||
written += 1
|
written.append(name)
|
||||||
print(
|
print(
|
||||||
f"\n✅ {written} {self._plural(t('entry'), written)}"
|
f"\n✅ {len(written)} {self._plural(t('entry'), len(written))}"
|
||||||
f" {t('written')} ({len(names) - written} {t('skipped')})"
|
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):
|
def _qemu_ssh_probe_remote(self, alias):
|
||||||
"""VM libvirt vues DEPUIS `alias` : [(nom, ip)].
|
"""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
|
max_depth = max(1, int(raw)) if raw else self._QEMU_SSH_DEPTH
|
||||||
except ValueError:
|
except ValueError:
|
||||||
max_depth = self._QEMU_SSH_DEPTH
|
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.
|
# Niveau 0 : les VM locales, jointes directement.
|
||||||
ip_map = self._qemu_resolve_ips(roots, timeout=60)
|
ip_map = self._qemu_resolve_ips(roots, timeout=60)
|
||||||
aliases = {} # alias -> (nom, ip, parent|None)
|
aliases = {} # alias -> (nom, ip, parent|None)
|
||||||
|
hosts_libvirt = [] # machines qui font tourner libvirt
|
||||||
frontier = []
|
frontier = []
|
||||||
for name in roots:
|
for name in roots:
|
||||||
ip = ip_map.get(name)
|
ip = ip_map.get(name)
|
||||||
|
|
@ -1397,6 +1590,11 @@ class TODO:
|
||||||
for depth in range(1, max_depth + 1):
|
for depth in range(1, max_depth + 1):
|
||||||
if not frontier:
|
if not frontier:
|
||||||
break
|
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(
|
print(
|
||||||
f"\n🔎 {t('Level')} {depth} — "
|
f"\n🔎 {t('Level')} {depth} — "
|
||||||
f"{len(frontier)} {t('machines to probe')}"
|
f"{len(frontier)} {t('machines to probe')}"
|
||||||
|
|
@ -1407,6 +1605,7 @@ class TODO:
|
||||||
if found is None:
|
if found is None:
|
||||||
print(f" ⏭ {parent}: {t('unreachable or no libvirt')}")
|
print(f" ⏭ {parent}: {t('unreachable or no libvirt')}")
|
||||||
continue
|
continue
|
||||||
|
hosts_libvirt.append(parent)
|
||||||
if not found:
|
if not found:
|
||||||
print(f" · {parent}: {t('no nested VM')}")
|
print(f" · {parent}: {t('no nested VM')}")
|
||||||
continue
|
continue
|
||||||
|
|
@ -1434,6 +1633,10 @@ class TODO:
|
||||||
via = f" ({t('via')} {parent})" if parent else ""
|
via = f" ({t('via')} {parent})" if parent else ""
|
||||||
print(f" ssh {alias:<34} {ip}{via}")
|
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):
|
def _qemu_stats(self):
|
||||||
"""Statistiques d'utilisation de QEMU, et remise à zéro.
|
"""Statistiques d'utilisation de QEMU, et remise à zéro.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1452,6 +1452,58 @@ TRANSLATIONS = {
|
||||||
"fr": "aucune VM imbriquée",
|
"fr": "aucune VM imbriquée",
|
||||||
"en": "no nested VM",
|
"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": {
|
"SSH hosts written": {
|
||||||
"fr": "Hôtes SSH écrits",
|
"fr": "Hôtes SSH écrits",
|
||||||
"en": "SSH hosts written",
|
"en": "SSH hosts written",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue