[IMP] qemu: a VM one can reach, and a first boot that does not stall
The monitor froze the address given at launch, so it lost the VM as soon as cloud-init renamed the host and DHCP handed out another lease. It now re-resolves at each attempt, in the views too, and reads virsh without sudo — « sudo -n » fails in a detached session with no tty. First boot also stopped paying for what it does not need: the guest agent leaves cloud-init, snapd and locale-gen go, apt takes the fastest mirror. The timezone follows the host. And when KVM is missing, the deployment says so before the wait instead of being mysteriously fifteen times slower. --- FR --- Le suivi figeait l'adresse connue au lancement : il perdait donc la VM dès que cloud-init posait le vrai nom d'hôte et que DHCP donnait un autre bail. Il la ré-résout désormais à chaque tentative, dans les vues aussi, et lit virsh sans sudo — « sudo -n » échoue dans une session détachée, sans tty. Le premier démarrage cesse aussi de payer l'inutile : l'agent invité sort de cloud-init, snapd et locale-gen disparaissent, apt prend le miroir le plus rapide. Le fuseau suit l'hôte. Et faute de KVM, le déploiement le dit avant l'attente, au lieu d'être quinze fois plus lent sans raison visible. Assisted-by: Claude Opus 5
This commit is contained in:
parent
c84fbc559d
commit
60fb60e156
8 changed files with 1256 additions and 24 deletions
|
|
@ -377,9 +377,56 @@ sudo modprobe -r kvm_intel && sudo modprobe kvm_intel # ou / or reboot
|
||||||
```
|
```
|
||||||
|
|
||||||
<!-- [en] -->
|
<!-- [en] -->
|
||||||
|
On **s390x and arm64** the parameter lives on the `kvm` module itself, not on
|
||||||
|
`kvm_intel` / `kvm_amd` — and `/sys/module/kvm/parameters/nested` does not even
|
||||||
|
exist on x86. Reading the wrong file returns a reassuring `0` that commands
|
||||||
|
nothing:
|
||||||
|
|
||||||
|
<!-- [fr] -->
|
||||||
|
Sur **s390x et arm64**, le paramètre vit sur le module `kvm` lui-même, et non
|
||||||
|
sur `kvm_intel` / `kvm_amd` — et `/sys/module/kvm/parameters/nested` n'existe
|
||||||
|
même pas sur x86. Lire le mauvais fichier renvoie un `0` rassurant qui ne
|
||||||
|
commande rien :
|
||||||
|
|
||||||
|
<!-- [common] -->
|
||||||
|
```bash
|
||||||
|
echo "options kvm nested=1" | sudo tee /etc/modprobe.d/kvm-nested.conf
|
||||||
|
sudo modprobe -r kvm && sudo modprobe kvm # ou / or reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- [en] -->
|
||||||
|
`nested` on a machine means « let MY guests run VMs ». To accelerate a VM
|
||||||
|
created on host H, the setting belongs to the hypervisor **above** H, not to H
|
||||||
|
itself. The one command that settles it, run on H:
|
||||||
|
|
||||||
|
<!-- [fr] -->
|
||||||
|
`nested` sur une machine signifie « j'autorise MES invités à faire tourner des
|
||||||
|
VM ». Pour accélérer une VM créée sur l'hôte H, le réglage appartient à
|
||||||
|
l'hyperviseur **au-dessus** de H, pas à H. La commande qui tranche, sur H :
|
||||||
|
|
||||||
|
<!-- [common] -->
|
||||||
|
```bash
|
||||||
|
ls -l /dev/kvm # absent -> pas d'imbrication, tout sera émulé
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- [en] -->
|
||||||
|
Measured on an s390x host that was itself a KVM guest without nesting:
|
||||||
|
`/dev/kvm` absent, `virsh dumpxml` showing `<domain type='qemu'>`, and a
|
||||||
|
7 min 30 boot instead of well under a minute. `systemd-detect-virt` inside the
|
||||||
|
VM is **not** proof of acceleration — on s390x, QEMU fabricates the STSI answer
|
||||||
|
and reports `kvm` even under TCG. Only `<domain type=…>` on the host is
|
||||||
|
conclusive.
|
||||||
|
|
||||||
If nesting is unavailable, QEMU still runs via software emulation (TCG) — it
|
If nesting is unavailable, QEMU still runs via software emulation (TCG) — it
|
||||||
works but is slow.
|
works but is slow.
|
||||||
|
|
||||||
|
<!-- [fr] -->
|
||||||
|
Mesuré sur un hôte s390x lui-même invité KVM sans imbrication : `/dev/kvm`
|
||||||
|
absent, `virsh dumpxml` affichant `<domain type='qemu'>`, et un démarrage de
|
||||||
|
7 min 30 au lieu de bien moins d'une minute. `systemd-detect-virt` dans la VM
|
||||||
|
ne prouve **pas** l'accélération — sur s390x, QEMU fabrique la réponse STSI et
|
||||||
|
annonce `kvm` même en TCG. Seul `<domain type=…>` sur l'hôte fait foi.
|
||||||
|
|
||||||
### Bridge for external access
|
### Bridge for external access
|
||||||
|
|
||||||
A NAT VM is isolated; a **bridged** VM gets an IP directly on the LAN,
|
A NAT VM is isolated; a **bridged** VM gets an IP directly on the LAN,
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,37 @@ echo "options kvm_intel nested=1" | sudo tee /etc/modprobe.d/kvm-nested.conf
|
||||||
sudo modprobe -r kvm_intel && sudo modprobe kvm_intel # ou / or reboot
|
sudo modprobe -r kvm_intel && sudo modprobe kvm_intel # ou / or reboot
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Sur **s390x et arm64**, le paramètre vit sur le module `kvm` lui-même, et non
|
||||||
|
sur `kvm_intel` / `kvm_amd` — et `/sys/module/kvm/parameters/nested` n'existe
|
||||||
|
même pas sur x86. Lire le mauvais fichier renvoie un `0` rassurant qui ne
|
||||||
|
commande rien :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "options kvm nested=1" | sudo tee /etc/modprobe.d/kvm-nested.conf
|
||||||
|
sudo modprobe -r kvm && sudo modprobe kvm # ou / or reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
`nested` sur une machine signifie « j'autorise MES invités à faire tourner des
|
||||||
|
VM ». Pour accélérer une VM créée sur l'hôte H, le réglage appartient à
|
||||||
|
l'hyperviseur **au-dessus** de H, pas à H. La commande qui tranche, sur H :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -l /dev/kvm # absent -> pas d'imbrication, tout sera émulé
|
||||||
|
```
|
||||||
|
|
||||||
|
Mesuré sur un hôte s390x lui-même invité KVM sans imbrication : `/dev/kvm`
|
||||||
|
absent, `virsh dumpxml` affichant `<domain type='qemu'>`, et un démarrage de
|
||||||
|
7 min 30 au lieu de bien moins d'une minute. `systemd-detect-virt` dans la VM
|
||||||
|
ne prouve **pas** l'accélération — sur s390x, QEMU fabrique la réponse STSI et
|
||||||
|
annonce `kvm` même en TCG. Seul `<domain type=…>` sur l'hôte fait foi.
|
||||||
|
|
||||||
|
### Bridge for external access
|
||||||
|
|
||||||
|
A NAT VM is isolated; a **bridged** VM gets an IP directly on the LAN,
|
||||||
|
reachable by any machine. On the KVM host, create a bridge `br0` over the
|
||||||
|
physical NIC (**wired only** — Wi-Fi cannot be bridged). netplan (Ubuntu
|
||||||
|
server) — replace `enp3s0` with your interface:
|
||||||
|
|
||||||
Si l'imbrication est indisponible, QEMU tourne quand même en émulation
|
Si l'imbrication est indisponible, QEMU tourne quand même en émulation
|
||||||
logicielle (TCG) — ça marche mais c'est lent.
|
logicielle (TCG) — ça marche mais c'est lent.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -197,16 +197,34 @@ echo "options kvm_intel nested=1" | sudo tee /etc/modprobe.d/kvm-nested.conf
|
||||||
sudo modprobe -r kvm_intel && sudo modprobe kvm_intel # ou / or reboot
|
sudo modprobe -r kvm_intel && sudo modprobe kvm_intel # ou / or reboot
|
||||||
```
|
```
|
||||||
|
|
||||||
|
On **s390x and arm64** the parameter lives on the `kvm` module itself, not on
|
||||||
|
`kvm_intel` / `kvm_amd` — and `/sys/module/kvm/parameters/nested` does not even
|
||||||
|
exist on x86. Reading the wrong file returns a reassuring `0` that commands
|
||||||
|
nothing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "options kvm nested=1" | sudo tee /etc/modprobe.d/kvm-nested.conf
|
||||||
|
sudo modprobe -r kvm && sudo modprobe kvm # ou / or reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
`nested` on a machine means « let MY guests run VMs ». To accelerate a VM
|
||||||
|
created on host H, the setting belongs to the hypervisor **above** H, not to H
|
||||||
|
itself. The one command that settles it, run on H:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -l /dev/kvm # absent -> pas d'imbrication, tout sera émulé
|
||||||
|
```
|
||||||
|
|
||||||
|
Measured on an s390x host that was itself a KVM guest without nesting:
|
||||||
|
`/dev/kvm` absent, `virsh dumpxml` showing `<domain type='qemu'>`, and a
|
||||||
|
7 min 30 boot instead of well under a minute. `systemd-detect-virt` inside the
|
||||||
|
VM is **not** proof of acceleration — on s390x, QEMU fabricates the STSI answer
|
||||||
|
and reports `kvm` even under TCG. Only `<domain type=…>` on the host is
|
||||||
|
conclusive.
|
||||||
|
|
||||||
If nesting is unavailable, QEMU still runs via software emulation (TCG) — it
|
If nesting is unavailable, QEMU still runs via software emulation (TCG) — it
|
||||||
works but is slow.
|
works but is slow.
|
||||||
|
|
||||||
### Bridge for external access
|
|
||||||
|
|
||||||
A NAT VM is isolated; a **bridged** VM gets an IP directly on the LAN,
|
|
||||||
reachable by any machine. On the KVM host, create a bridge `br0` over the
|
|
||||||
physical NIC (**wired only** — Wi-Fi cannot be bridged). netplan (Ubuntu
|
|
||||||
server) — replace `enp3s0` with your interface:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# /etc/netplan/01-br0.yaml
|
# /etc/netplan/01-br0.yaml
|
||||||
network:
|
network:
|
||||||
|
|
|
||||||
|
|
@ -1163,6 +1163,130 @@ def hash_password(plain: str) -> str:
|
||||||
return out.stdout.strip()
|
return out.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
# Miroirs apt, du plus rapide au dernier recours. cloud-init prend le PREMIER
|
||||||
|
# joignable de la liste « search » : l'ordre est donc la priorité.
|
||||||
|
#
|
||||||
|
# Mesuré depuis Montréal sur l'index main/s390x (1,6 Mo) :
|
||||||
|
# ports.ubuntu.com 1,61 s 1,0 Mo/s
|
||||||
|
# mirror.csclub.uwaterloo.ca 0,32 s 5,2 Mo/s
|
||||||
|
# mirror.us.leaseweb.net 0,88 s 1,9 Mo/s
|
||||||
|
#
|
||||||
|
# Le chemin diffère selon l'architecture : les arches « ports » (s390x, arm64,
|
||||||
|
# ppc64el, riscv64) ne sont PAS sur archive.ubuntu.com, et amd64 n'est pas sur
|
||||||
|
# ports.ubuntu.com. Une seule liste servirait donc la moitié des cas en 404.
|
||||||
|
APT_MIRRORS_PORTS = [
|
||||||
|
"http://mirror.csclub.uwaterloo.ca/ubuntu-ports",
|
||||||
|
"http://mirror.us.leaseweb.net/ubuntu-ports",
|
||||||
|
"http://ports.ubuntu.com/ubuntu-ports",
|
||||||
|
]
|
||||||
|
APT_MIRRORS_MAIN = [
|
||||||
|
"http://mirror.csclub.uwaterloo.ca/ubuntu",
|
||||||
|
"http://mirror.us.leaseweb.net/ubuntu",
|
||||||
|
"http://archive.ubuntu.com/ubuntu",
|
||||||
|
]
|
||||||
|
PORTS_ARCHES = ("s390x", "arm64", "aarch64", "ppc64el", "riscv64")
|
||||||
|
|
||||||
|
|
||||||
|
def apt_mirror_lines(arch: str, override: str | None = None) -> list[str]:
|
||||||
|
"""Bloc « apt: » du cloud-config, ou [] si rien à écrire.
|
||||||
|
|
||||||
|
Ubuntu seulement : Debian, Fedora et Arch ont leurs propres dépôts, et
|
||||||
|
« search » y écrirait des URI qui n'existent pas.
|
||||||
|
"""
|
||||||
|
mirrors = (
|
||||||
|
[override]
|
||||||
|
if override
|
||||||
|
else (APT_MIRRORS_PORTS if arch in PORTS_ARCHES else APT_MIRRORS_MAIN)
|
||||||
|
)
|
||||||
|
lines = ["apt:", " primary:", " - arches: [default]", " search:"]
|
||||||
|
lines += [f" - {m}" for m in mirrors]
|
||||||
|
# La sécurité suit le même dépôt pour les arches ports ; sur amd64 elle a
|
||||||
|
# son propre hôte, que les miroirs répliquent sous le même chemin.
|
||||||
|
lines += [" security:", " - arches: [default]", " search:"]
|
||||||
|
lines += [f" - {m}" for m in mirrors]
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def kvm_available() -> bool:
|
||||||
|
"""L'accélération matérielle est-elle réellement utilisable ici ?
|
||||||
|
|
||||||
|
« Même architecture que l'hôte » ne suffit PAS à conclure à KVM : dans une
|
||||||
|
VM sans virtualisation imbriquée, libvirt bascule SILENCIEUSEMENT en TCG.
|
||||||
|
Mesuré sur erplibre01, lui-même invité KVM : une VM s390x sur hôte s390x
|
||||||
|
est sortie en « <domain type='qemu'> », soit de l'émulation intégrale — et
|
||||||
|
un démarrage de 7 min 30 au lieu de quelques dizaines de secondes, sans
|
||||||
|
que rien ne le signale.
|
||||||
|
|
||||||
|
/dev/kvm est le test que fait QEMU lui-même. Mais l'ACCÈS n'est concluant
|
||||||
|
que si on est root : libvirt, lui, tourne en root et se moque de notre
|
||||||
|
appartenance au groupe kvm. Tester nos propres droits en non-root ferait
|
||||||
|
crier « pas de KVM » à un utilisateur simplement hors du groupe.
|
||||||
|
"""
|
||||||
|
if not os.path.exists("/dev/kvm"):
|
||||||
|
return False
|
||||||
|
if os.geteuid() == 0:
|
||||||
|
return os.access("/dev/kvm", os.R_OK | os.W_OK)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def nested_module() -> str:
|
||||||
|
"""Module noyau portant le paramètre « nested » sur CET hôte.
|
||||||
|
|
||||||
|
Le nom change selon l'architecture, et se tromper de module fait lire un
|
||||||
|
« 0 » rassurant sur un fichier qui ne commande rien : s390x et arm64
|
||||||
|
l'exposent sur « kvm », x86 sur « kvm_intel » ou « kvm_amd » selon le
|
||||||
|
fabricant — et /sys/module/kvm/parameters/nested n'y existe même pas.
|
||||||
|
"""
|
||||||
|
arch = host_arch()
|
||||||
|
if arch != "amd64":
|
||||||
|
return "kvm"
|
||||||
|
try:
|
||||||
|
info = Path("/proc/cpuinfo").read_text(errors="replace")
|
||||||
|
except OSError:
|
||||||
|
return "kvm_intel"
|
||||||
|
return "kvm_amd" if " svm" in info else "kvm_intel"
|
||||||
|
|
||||||
|
|
||||||
|
def host_timezone() -> str:
|
||||||
|
"""Fuseau de l'hôte, au format zoneinfo (« America/Montreal »).
|
||||||
|
|
||||||
|
Défaut des VM déployées : une VM qui hérite du fuseau de la machine qui la
|
||||||
|
crée horodate ses journaux, ses commits et ses bases comme son opérateur.
|
||||||
|
Sans cela elle démarre en UTC, ce qui ne se voit qu'après coup — des commits
|
||||||
|
à +0000 alors que tout le reste du dépôt est en -0400.
|
||||||
|
|
||||||
|
Trois sources, de la plus fiable à la plus rustique ; « UTC » en dernier
|
||||||
|
recours plutôt qu'une exception, car un fuseau indéterminable ne doit pas
|
||||||
|
empêcher un déploiement.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
["timedatectl", "show", "-p", "Timezone", "--value"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
).stdout.strip()
|
||||||
|
if out:
|
||||||
|
return out
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
tz = Path("/etc/timezone").read_text(encoding="utf-8").strip()
|
||||||
|
if tz:
|
||||||
|
return tz
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
# /etc/localtime est un lien vers /usr/share/zoneinfo/<Zone>
|
||||||
|
target = Path("/etc/localtime").resolve()
|
||||||
|
parts = target.parts
|
||||||
|
if "zoneinfo" in parts:
|
||||||
|
return "/".join(parts[parts.index("zoneinfo") + 1 :])
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return "UTC"
|
||||||
|
|
||||||
|
|
||||||
def build_cloud_config(
|
def build_cloud_config(
|
||||||
args: argparse.Namespace, pw_hash: str | None, ssh_keys: list[str]
|
args: argparse.Namespace, pw_hash: str | None, ssh_keys: list[str]
|
||||||
) -> str:
|
) -> str:
|
||||||
|
|
@ -1189,6 +1313,11 @@ def build_cloud_config(
|
||||||
|
|
||||||
lines.append(f"ssh_pwauth: {'true' if pw_hash else 'false'}")
|
lines.append(f"ssh_pwauth: {'true' if pw_hash else 'false'}")
|
||||||
lines.append(f"locale: {args.locale}")
|
lines.append(f"locale: {args.locale}")
|
||||||
|
lines.append(f"timezone: {args.timezone}")
|
||||||
|
if getattr(args, "distro", "ubuntu") == "ubuntu":
|
||||||
|
lines += apt_mirror_lines(
|
||||||
|
getattr(args, "arch", "amd64"), getattr(args, "apt_mirror", None)
|
||||||
|
)
|
||||||
lines += [
|
lines += [
|
||||||
"keyboard:",
|
"keyboard:",
|
||||||
f" layout: {args.keyboard_layout}",
|
f" layout: {args.keyboard_layout}",
|
||||||
|
|
@ -1220,15 +1349,41 @@ def build_cloud_config(
|
||||||
"runcmd:",
|
"runcmd:",
|
||||||
" - systemctl enable --now ssh 2>/dev/null"
|
" - systemctl enable --now ssh 2>/dev/null"
|
||||||
" || systemctl enable --now sshd 2>/dev/null || true",
|
" || systemctl enable --now sshd 2>/dev/null || true",
|
||||||
# qemu-guest-agent : installé + activé APRÈS sshd (donc SSH reste
|
# Getty sur la console qui EXISTE VRAIMENT.
|
||||||
# disponible tout de suite, sans attendre le réseau). Tout est
|
#
|
||||||
# « || true » : si l'installation échoue (réseau lent/absent), le boot
|
# Sur s390x, l'image attend /dev/ttysclp0 (généré depuis la ligne de
|
||||||
# n'est pas bloqué. La plupart des images cloud l'incluent déjà.
|
# commande noyau) alors que le périphérique réellement présent porte un
|
||||||
# Rafraîchit d'abord l'index (les images cloud n'ont PAS de listes apt
|
# autre nom : « Timed out waiting for device dev-ttysclp0.device », puis
|
||||||
# -> sinon « Unable to locate package »), puis installe. timeout : un
|
# « Dependency failed for serial-getty@ttysclp0 ». La console affiche
|
||||||
# miroir lent ne bloque JAMAIS cloud-init. Non fatal (|| true).
|
# donc tout le démarrage mais n'offre AUCUNE invite de connexion — elle
|
||||||
# Sous-shell ( ) et NON accolades { } : « { » est un indicateur YAML.
|
# est en lecture seule par accident, et c'est justement le seul recours
|
||||||
" - (command -v apt-get >/dev/null && (timeout 120 apt-get update -qq"
|
# quand SSH ne répond pas. On active le getty sur le premier
|
||||||
|
# périphérique console présent. Ailleurs (x86/arm64) ttyS0 existe et son
|
||||||
|
# getty tourne déjà : « enable --now » n'y change rien.
|
||||||
|
" - for d in ttysclp0 sclp_line0 hvc0 ttyS0; do"
|
||||||
|
" test -c /dev/$d && systemctl enable --now serial-getty@$d.service"
|
||||||
|
" 2>/dev/null && break; done || true",
|
||||||
|
# qemu-guest-agent : installé APRÈS sshd, et surtout HORS de cloud-init.
|
||||||
|
#
|
||||||
|
# Mesuré sur Ubuntu 26.04 s390x : « apt-get install qemu-guest-agent »
|
||||||
|
# tire liburing2, ubuntu-helper-virt-hwe et ubuntu-virt depuis
|
||||||
|
# ports.ubuntu.com, et cloud-final tourne 9 min 47. Or le suivi
|
||||||
|
# d'installation attend « cloud-init status: done » : dix minutes
|
||||||
|
# d'attente pour un paquet accessoire, avant même de commencer le
|
||||||
|
# travail utile.
|
||||||
|
#
|
||||||
|
# systemd-run --no-block rend la main tout de suite : cloud-init termine
|
||||||
|
# en quelques secondes et l'agent apparaît quand il apparaît. On saute
|
||||||
|
# aussi l'installation quand qemu-ga est déjà là, ce qui est le cas de
|
||||||
|
# beaucoup d'images. Repli en ligne si systemd-run manque.
|
||||||
|
" - command -v qemu-ga >/dev/null 2>&1 ||"
|
||||||
|
" systemd-run --no-block --unit=erplibre-qga --collect"
|
||||||
|
" /bin/sh -c 'command -v apt-get >/dev/null && { apt-get update -qq"
|
||||||
|
" || true; apt-get install -y qemu-guest-agent; }"
|
||||||
|
" || command -v dnf >/dev/null && dnf install -y qemu-guest-agent"
|
||||||
|
" || command -v pacman >/dev/null && pacman -Sy --noconfirm"
|
||||||
|
" qemu-guest-agent' 2>/dev/null"
|
||||||
|
" || (command -v apt-get >/dev/null && (timeout 120 apt-get update -qq"
|
||||||
" || true; timeout 300 apt-get install -y qemu-guest-agent)) ||"
|
" || true; timeout 300 apt-get install -y qemu-guest-agent)) ||"
|
||||||
" (command -v dnf >/dev/null && timeout 300 dnf install -y"
|
" (command -v dnf >/dev/null && timeout 300 dnf install -y"
|
||||||
" qemu-guest-agent) || (command -v pacman >/dev/null && timeout 300"
|
" qemu-guest-agent) || (command -v pacman >/dev/null && timeout 300"
|
||||||
|
|
@ -1429,7 +1584,25 @@ def virt_install(
|
||||||
runner: Runner,
|
runner: Runner,
|
||||||
) -> None:
|
) -> None:
|
||||||
# Émulée (TCG, pas de KVM) si l'arch demandée diffère de celle de l'hôte.
|
# Émulée (TCG, pas de KVM) si l'arch demandée diffère de celle de l'hôte.
|
||||||
|
# Deux causes d'émulation, à ne pas confondre : une architecture étrangère
|
||||||
|
# (voulue, on la choisit), et une absence de KVM (subie, et invisible). La
|
||||||
|
# seconde ne se déduit PAS de l'architecture — dans une VM sans
|
||||||
|
# virtualisation imbriquée, libvirt retombe en TCG sans rien dire.
|
||||||
emulated = args.arch != host_arch()
|
emulated = args.arch != host_arch()
|
||||||
|
if not emulated and not kvm_available():
|
||||||
|
emulated = True
|
||||||
|
print(
|
||||||
|
"⚠ KVM indisponible (/dev/kvm) : cette VM sera ÉMULÉE, donc TRÈS"
|
||||||
|
" lente."
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
" Cause habituelle : l'hôte est lui-même une VM sans"
|
||||||
|
" virtualisation imbriquée."
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
" Vérifier : systemd-detect-virt et"
|
||||||
|
' virsh capabilities | grep "domain type"'
|
||||||
|
)
|
||||||
# s390x n'a pas de port série ISA : la console est SCLP (ttysclp0), et non
|
# s390x n'a pas de port série ISA : la console est SCLP (ttysclp0), et non
|
||||||
# ttyS0. Ailleurs (x86/arm64), console série classique.
|
# ttyS0. Ailleurs (x86/arm64), console série classique.
|
||||||
console_target = "sclp" if args.arch == "s390x" else "serial"
|
console_target = "sclp" if args.arch == "s390x" else "serial"
|
||||||
|
|
@ -1730,6 +1903,24 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
default="fr_CA.UTF-8",
|
default="fr_CA.UTF-8",
|
||||||
help="Locale (défaut : fr_CA.UTF-8).",
|
help="Locale (défaut : fr_CA.UTF-8).",
|
||||||
)
|
)
|
||||||
|
g_cloud.add_argument(
|
||||||
|
"--apt-mirror",
|
||||||
|
metavar="URI",
|
||||||
|
help=(
|
||||||
|
"Miroir apt unique (Ubuntu). Par défaut, cloud-init essaie dans"
|
||||||
|
" l'ordre les miroirs les plus rapides mesurés puis le dépôt"
|
||||||
|
" officiel."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
g_cloud.add_argument(
|
||||||
|
"--timezone",
|
||||||
|
default=host_timezone(),
|
||||||
|
metavar="ZONE",
|
||||||
|
help=(
|
||||||
|
"Fuseau horaire de la VM, au format zoneinfo "
|
||||||
|
f"(défaut : celui de l'hôte, {host_timezone()})."
|
||||||
|
),
|
||||||
|
)
|
||||||
g_cloud.add_argument(
|
g_cloud.add_argument(
|
||||||
"--keyboard-layout",
|
"--keyboard-layout",
|
||||||
default="ca",
|
default="ca",
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,7 @@ def build_spec(vms, domains, form):
|
||||||
"vms": [vm for vm in vms if vm["name"] not in known],
|
"vms": [vm for vm in vms if vm["name"] not in known],
|
||||||
"existing": [vm["name"] for vm in vms if vm["name"] in known],
|
"existing": [vm["name"] for vm in vms if vm["name"] in known],
|
||||||
"ssh_key": form["ssh_key"],
|
"ssh_key": form["ssh_key"],
|
||||||
|
"timezone": form.get("timezone", ""),
|
||||||
"install": form["install"],
|
"install": form["install"],
|
||||||
"add_ssh_config": form["add_ssh_config"],
|
"add_ssh_config": form["add_ssh_config"],
|
||||||
"parallelism": form["parallelism"],
|
"parallelism": form["parallelism"],
|
||||||
|
|
@ -416,6 +417,12 @@ def run_deploy_form(ctx, run_app: bool = True):
|
||||||
value=defaults.get("monitor", True),
|
value=defaults.get("monitor", True),
|
||||||
id="f_monitor",
|
id="f_monitor",
|
||||||
)
|
)
|
||||||
|
yield Static(t("Timezone"), classes="grouptitle")
|
||||||
|
yield Input(
|
||||||
|
value=ctx.get("timezone") or "",
|
||||||
|
placeholder=t("Timezone for the VMs"),
|
||||||
|
id="f_tz",
|
||||||
|
)
|
||||||
yield Static("SSH", classes="grouptitle")
|
yield Static("SSH", classes="grouptitle")
|
||||||
yield Input(
|
yield Input(
|
||||||
value=ctx.get("ssh_key") or "",
|
value=ctx.get("ssh_key") or "",
|
||||||
|
|
@ -631,6 +638,11 @@ def run_deploy_form(ctx, run_app: bool = True):
|
||||||
else f"x{self.profile}"
|
else f"x{self.profile}"
|
||||||
),
|
),
|
||||||
"ssh_key": os.path.expanduser(key) if key else "",
|
"ssh_key": os.path.expanduser(key) if key else "",
|
||||||
|
# Un champ vidé retombe sur le fuseau de l'hôte plutôt que sur
|
||||||
|
# rien : sans valeur, la VM démarrerait en UTC.
|
||||||
|
"timezone": self.query_one("#f_tz", Input).value.strip()
|
||||||
|
or ctx.get("timezone")
|
||||||
|
or "",
|
||||||
"install": install,
|
"install": install,
|
||||||
"add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value,
|
"add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value,
|
||||||
"parallelism": self.query_one("#f_par", Select).value,
|
"parallelism": self.query_one("#f_par", Select).value,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
|
|
@ -73,7 +74,9 @@ def list_install_runs() -> list:
|
||||||
return runs
|
return runs
|
||||||
|
|
||||||
|
|
||||||
def _launch_one(ip: str, remote_cmd: str, log_path: str) -> None:
|
def _launch_one(
|
||||||
|
ip: str, remote_cmd: str, log_path: str, name: str = ""
|
||||||
|
) -> None:
|
||||||
"""Lance une install SSH DÉTACHÉE : attend le sshd, exécute, journalise
|
"""Lance une install SSH DÉTACHÉE : attend le sshd, exécute, journalise
|
||||||
la sortie puis écrit le marqueur de fin avec le code de sortie."""
|
la sortie puis écrit le marqueur de fin avec le code de sortie."""
|
||||||
# Sonde de disponibilité : on attend que sshd réponde ET que cloud-init
|
# Sonde de disponibilité : on attend que sshd réponde ET que cloud-init
|
||||||
|
|
@ -95,19 +98,72 @@ def _launch_one(ip: str, remote_cmd: str, log_path: str) -> None:
|
||||||
msg_wait = t("Waiting for the VM to start (boot + cloud-init)")
|
msg_wait = t("Waiting for the VM to start (boot + cloud-init)")
|
||||||
msg_slow = t("(an emulated architecture can be slow; this is normal)")
|
msg_slow = t("(an emulated architecture can be slow; this is normal)")
|
||||||
msg_ready = t("VM ready - starting the ERPLibre install")
|
msg_ready = t("VM ready - starting the ERPLibre install")
|
||||||
|
# L'IP est RÉSOLUE À CHAQUE TOUR, jamais figée. Au 1er boot la VM prend un
|
||||||
|
# bail sous le nom par défaut de l'image, puis cloud-init pose le vrai nom
|
||||||
|
# d'hôte et le client DHCP en redemande un AUTRE. L'adresse connue au
|
||||||
|
# lancement devient donc morte en cours de route, et l'attente échouait
|
||||||
|
# 20 minutes durant sur une VM parfaitement saine (vécu : bail .247 périmé
|
||||||
|
# pendant que la VM vivait en .248).
|
||||||
|
#
|
||||||
|
# L'agent invité fait foi : il répond depuis l'intérieur, là où le bail
|
||||||
|
# dnsmasq garde les deux adresses sans dire laquelle est vivante. « sudo -n »
|
||||||
|
# car ce script tourne DÉTACHÉ : une demande de mot de passe le bloquerait
|
||||||
|
# sans que personne ne la voie. Sans réponse, on garde l'adresse courante.
|
||||||
|
# L'agent ne suffit PAS comme source unique : son paquet s'installe hors de
|
||||||
|
# cloud-init pour ne pas retarder le démarrage, donc il arrive tard — et
|
||||||
|
# pendant tout ce temps la ré-résolution ne renvoyait rien et gardait
|
||||||
|
# l'adresse morte. C'est le défaut qui a fait échouer le premier correctif.
|
||||||
|
#
|
||||||
|
# Repli sur les baux : dnsmasq les garde tous les deux sans dire lequel est
|
||||||
|
# vivant, on tranche donc en TESTANT le port 22 — le seul critère qui compte
|
||||||
|
# ici, puisque c'est par là que l'installation passera. Le bail périmé ne
|
||||||
|
# répond pas, le bon répond.
|
||||||
|
# virsh SANS sudo d'abord. Ce script tourne détaché, sans tty : « sudo -n »
|
||||||
|
# y échoue dès que l'hôte exige une authentification interactive — vécu sur
|
||||||
|
# erplibre01 (« sudo-rs: interactive authentication is required »), et la
|
||||||
|
# ré-résolution restait alors muette sans laisser la moindre trace.
|
||||||
|
# Appartenir au groupe libvirt suffit pour joindre qemu:///system, ce que
|
||||||
|
# « deploy_qemu.py --setup-host » configure déjà. sudo -n reste en repli
|
||||||
|
# pour les hôtes où le groupe manque.
|
||||||
|
name_q = shlex.quote(name) if name else ""
|
||||||
|
vsh = (
|
||||||
|
'vsh() { virsh --connect qemu:///system "$@" 2>/dev/null '
|
||||||
|
'|| sudo -n virsh --connect qemu:///system "$@" 2>/dev/null; }; '
|
||||||
|
)
|
||||||
|
refresh = (
|
||||||
|
(
|
||||||
|
f"n=$(vsh domifaddr {name_q} --source agent "
|
||||||
|
"| grep -oE '([0-9]{1,3}\\.){3}[0-9]{1,3}' "
|
||||||
|
"| grep -v '^127\\.' | head -1); "
|
||||||
|
'if [ -z "$n" ]; then '
|
||||||
|
f"for c in $(vsh domifaddr {name_q} --source lease "
|
||||||
|
"| grep -oE '([0-9]{1,3}\\.){3}[0-9]{1,3}' "
|
||||||
|
"| grep -v '^127\\.'); do "
|
||||||
|
'timeout 2 bash -c "echo > /dev/tcp/$c/22" 2>/dev/null '
|
||||||
|
'&& n="$c"; done; fi; '
|
||||||
|
'[ -n "$n" ] && ip="$n"; '
|
||||||
|
)
|
||||||
|
if name
|
||||||
|
else ""
|
||||||
|
)
|
||||||
wrapper = (
|
wrapper = (
|
||||||
|
f"ip={shlex.quote(ip)}; "
|
||||||
|
f"{vsh if name else ''}"
|
||||||
f"echo {shlex.quote('== ' + msg_wait + ' ==')} >> {log_q}; "
|
f"echo {shlex.quote('== ' + msg_wait + ' ==')} >> {log_q}; "
|
||||||
f"echo {shlex.quote(' ' + msg_slow)} >> {log_q}; "
|
f"echo {shlex.quote(' ' + msg_slow)} >> {log_q}; "
|
||||||
f"for i in $(seq 1 240); do "
|
f"for i in $(seq 1 240); do "
|
||||||
f"st=$(ssh {SSH_OPTS} -o BatchMode=yes erplibre@{ip} "
|
f"{refresh}"
|
||||||
|
f'st=$(ssh {SSH_OPTS} -o BatchMode=yes "erplibre@$ip" '
|
||||||
f"{shlex.quote(ci_probe)} 2>/dev/null); "
|
f"{shlex.quote(ci_probe)} 2>/dev/null); "
|
||||||
f'case "$st" in '
|
f'case "$st" in '
|
||||||
f"*done*|*disabled*|*error*|*degraded*|*nocloudinit*) break;; "
|
f"*done*|*disabled*|*error*|*degraded*|*nocloudinit*) break;; "
|
||||||
f"esac; "
|
f"esac; "
|
||||||
f'if [ $((i % 6)) -eq 0 ]; then echo " ... $((i*5))s" >> {log_q}; fi; '
|
f"if [ $((i % 6)) -eq 0 ]; then "
|
||||||
|
f'echo " ... $((i*5))s ($ip)" >> {log_q}; fi; '
|
||||||
f"sleep 5; done; "
|
f"sleep 5; done; "
|
||||||
f"echo {shlex.quote('== ' + msg_ready + ' ==')} >> {log_q}; "
|
f"echo {shlex.quote('== ' + msg_ready + ' ==')} >> {log_q}; "
|
||||||
f"ssh {SSH_OPTS} erplibre@{ip} {shlex.quote(remote_cmd)} "
|
f'echo " → $ip" >> {log_q}; '
|
||||||
|
f'ssh {SSH_OPTS} "erplibre@$ip" {shlex.quote(remote_cmd)} '
|
||||||
f">> {log_q} 2>&1; "
|
f">> {log_q} 2>&1; "
|
||||||
f'echo "{EXIT_MARKER} $?" >> {log_q}'
|
f'echo "{EXIT_MARKER} $?" >> {log_q}'
|
||||||
)
|
)
|
||||||
|
|
@ -156,7 +212,7 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str:
|
||||||
# En-tête d'emblée (date/distro/version/arch) : le log n'est jamais
|
# En-tête d'emblée (date/distro/version/arch) : le log n'est jamais
|
||||||
# vide, l'utilisateur voit tout de suite QUOI s'installe.
|
# vide, l'utilisateur voit tout de suite QUOI s'installe.
|
||||||
Path(log_path).write_text(_log_header(vm, branch, when))
|
Path(log_path).write_text(_log_header(vm, branch, when))
|
||||||
_launch_one(vm["ip"], remote_cmd, log_path)
|
_launch_one(vm["ip"], remote_cmd, log_path, vm["name"])
|
||||||
entries.append(
|
entries.append(
|
||||||
{
|
{
|
||||||
"name": vm["name"],
|
"name": vm["name"],
|
||||||
|
|
@ -594,6 +650,57 @@ def browser_install_command(browser="w3m") -> list | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def virsh_ip(name: str) -> str:
|
||||||
|
"""Adresse ACTUELLE d'une VM, ou '' si indéterminable.
|
||||||
|
|
||||||
|
Même logique que la sonde du wrapper détaché, et pour la même raison : le
|
||||||
|
bail que la VM prend au premier démarrage sous le nom par défaut de l'image
|
||||||
|
est remplacé dès que cloud-init pose le vrai nom d'hôte. L'agent invité fait
|
||||||
|
foi ; sans lui, on départage les baux en testant le port 22.
|
||||||
|
|
||||||
|
virsh SANS sudo d'abord (groupe libvirt), « sudo -n » en repli : sur un hôte
|
||||||
|
exigeant une authentification interactive, sudo échoue et ne doit pas
|
||||||
|
empêcher la lecture.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def run(source):
|
||||||
|
for pre in ([], ["sudo", "-n"]):
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
pre
|
||||||
|
+ [
|
||||||
|
"virsh",
|
||||||
|
"--connect",
|
||||||
|
"qemu:///system",
|
||||||
|
"domifaddr",
|
||||||
|
name,
|
||||||
|
"--source",
|
||||||
|
source,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
env={**os.environ, "LC_ALL": "C", "LANG": "C"},
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
continue
|
||||||
|
if res.returncode == 0:
|
||||||
|
return res.stdout
|
||||||
|
return ""
|
||||||
|
|
||||||
|
found = re.findall(r"\b(\d{1,3}(?:\.\d{1,3}){3})/", run("agent"))
|
||||||
|
for ip in found:
|
||||||
|
if not ip.startswith("127."):
|
||||||
|
return ip
|
||||||
|
# Sans agent : plusieurs baux possibles, dont un périmé. Le port 22 tranche.
|
||||||
|
for ip in re.findall(r"\b(\d{1,3}(?:\.\d{1,3}){3})/", run("lease")):
|
||||||
|
if ip.startswith("127."):
|
||||||
|
continue
|
||||||
|
if _port_open(ip, 22):
|
||||||
|
return ip
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def virsh_domstates() -> dict:
|
def virsh_domstates() -> dict:
|
||||||
"""{nom: état} de tous les domaines libvirt (« virsh list --all »). Sert à
|
"""{nom: état} de tous les domaines libvirt (« virsh list --all »). Sert à
|
||||||
détecter une VM EN PAUSE ou EFFACÉE pendant le suivi. Un seul appel virsh
|
détecter une VM EN PAUSE ou EFFACÉE pendant le suivi. Un seul appel virsh
|
||||||
|
|
@ -705,6 +812,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
("q", "quit", "Quitter (détaché)"),
|
("q", "quit", "Quitter (détaché)"),
|
||||||
("s", "ssh", "SSH"),
|
("s", "ssh", "SSH"),
|
||||||
|
("v", "console", "Console (virsh)"),
|
||||||
("w", "web", "Web (navigateur CLI)"),
|
("w", "web", "Web (navigateur CLI)"),
|
||||||
("f", "follow", "Suivre"),
|
("f", "follow", "Suivre"),
|
||||||
("c", "copy_log", "Copier log"),
|
("c", "copy_log", "Copier log"),
|
||||||
|
|
@ -829,9 +937,9 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
bar = self.query_one("#sshbar", Static)
|
bar = self.query_one("#sshbar", Static)
|
||||||
if vm:
|
if vm:
|
||||||
bar.update(
|
bar.update(
|
||||||
f" {vm['ssh']} (s = SSH · w = web :8069 · "
|
f" {vm['ssh']} (s = SSH · v = console · "
|
||||||
"c = copier le log · d = détails erreurs · "
|
"w = web :8069 · c = copier le log · "
|
||||||
"Maj+glisser = sélectionner)\n"
|
"d = détails erreurs · Maj+glisser = sélectionner)\n"
|
||||||
f" Log : {vm['log']}"
|
f" Log : {vm['log']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1100,6 +1208,24 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
for vm in vms:
|
for vm in vms:
|
||||||
self._domstate[vm["name"]] = states.get(vm["name"], "gone")
|
self._domstate[vm["name"]] = states.get(vm["name"], "gone")
|
||||||
|
|
||||||
|
# L'adresse est relue au même rythme. Le processus détaché suivait
|
||||||
|
# déjà la VM quand son bail changeait, mais les VUES gardaient celle
|
||||||
|
# du lancement : la barre proposait « ssh erplibre@…222 » alors que
|
||||||
|
# l'installation parlait à …223, et la touche « s » y menait aussi.
|
||||||
|
# Rafraîchir ici plutôt que dans un tick à part évite un second
|
||||||
|
# appel virsh par VM — celui-ci est déjà le relevé lent.
|
||||||
|
changed = False
|
||||||
|
for vm in vms:
|
||||||
|
if self._domstate.get(vm["name"]) == "gone":
|
||||||
|
continue
|
||||||
|
ip = await asyncio.to_thread(virsh_ip, vm["name"])
|
||||||
|
if ip and ip != vm.get("ip"):
|
||||||
|
vm["ip"] = ip
|
||||||
|
vm["ssh"] = f"ssh erplibre@{ip}"
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
self._refresh_ssh()
|
||||||
|
|
||||||
# -- events --------------------------------------------------------- #
|
# -- events --------------------------------------------------------- #
|
||||||
def on_data_table_row_highlighted(self, event) -> None:
|
def on_data_table_row_highlighted(self, event) -> None:
|
||||||
# DEBOUNCE : RowHighlighted se déclenche à CHAQUE mouvement du
|
# DEBOUNCE : RowHighlighted se déclenche à CHAQUE mouvement du
|
||||||
|
|
@ -1133,6 +1259,34 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
||||||
with self.suspend():
|
with self.suspend():
|
||||||
os.system(f"ssh {SSH_OPTS} erplibre@{vm['ip']} || true")
|
os.system(f"ssh {SSH_OPTS} erplibre@{vm['ip']} || true")
|
||||||
|
|
||||||
|
def action_console(self) -> None:
|
||||||
|
"""Console série de la VM, sans quitter le suivi.
|
||||||
|
|
||||||
|
Le seul recours quand SSH ne répond pas : elle ne dépend ni du
|
||||||
|
réseau de la VM, ni de sshd, ni d'une IP — donc elle montre un
|
||||||
|
démarrage bloqué, un cloud-init encore en cours ou un réseau sans
|
||||||
|
bail, que le suivi ne peut que constater de loin.
|
||||||
|
|
||||||
|
« suspend() » rend le terminal avant d'appeler virsh : sudo peut y
|
||||||
|
demander son mot de passe et la console prendre le clavier, ce qui
|
||||||
|
casserait l'affichage si Textual le tenait encore.
|
||||||
|
"""
|
||||||
|
vm = self._vm_by_name(self._selected)
|
||||||
|
if not vm:
|
||||||
|
return
|
||||||
|
name = shlex.quote(vm["name"])
|
||||||
|
with self.suspend():
|
||||||
|
# La console n'affiche que ce qui arrive APRÈS l'attachement :
|
||||||
|
# sur une VM déjà démarrée l'écran reste noir tant qu'on n'a
|
||||||
|
# rien envoyé. On le dit, plutôt que de laisser croire à un gel.
|
||||||
|
print(f"\n→ virsh console {vm['name']}")
|
||||||
|
print(
|
||||||
|
" Écran vide ? Appuyez sur Entrée : la console ne montre"
|
||||||
|
" que la sortie qui suit l'attachement."
|
||||||
|
)
|
||||||
|
print(" Ctrl+] puis Entrée pour revenir au suivi.\n")
|
||||||
|
os.system(f"sudo virsh console {name} || true")
|
||||||
|
|
||||||
def action_web(self) -> None:
|
def action_web(self) -> None:
|
||||||
"""Ouvre l'UI web de la VM (Odoo :8069) dans un navigateur CLI
|
"""Ouvre l'UI web de la VM (Odoo :8069) dans un navigateur CLI
|
||||||
(browsh/carbonyl/w3m/links/elinks/lynx). Surtout utile une fois
|
(browsh/carbonyl/w3m/links/elinks/lynx). Surtout utile une fois
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import ast
|
||||||
import configparser
|
import configparser
|
||||||
import datetime
|
import datetime
|
||||||
import getpass
|
import getpass
|
||||||
|
import grp
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -1118,6 +1119,46 @@ class TODO:
|
||||||
# profil « ERPLibre Déploiement (+ QEMU + dev) » installe QEMU DANS la VM,
|
# profil « ERPLibre Déploiement (+ QEMU + dev) » installe QEMU DANS la VM,
|
||||||
# donc un parc à deux niveaux est le cas courant.
|
# donc un parc à deux niveaux est le cas courant.
|
||||||
_QEMU_SSH_DEPTH = 2
|
_QEMU_SSH_DEPTH = 2
|
||||||
|
# Sonde exécutée SUR une machine. Première ligne « LIBVIRT<TAB>yes|no »,
|
||||||
|
# puis un couple « nom<TAB>ip » par VM. Une seule connexion SSH par
|
||||||
|
# niveau plutôt qu'une par VM ; le bail dnsmasq peut manquer, d'où le
|
||||||
|
# repli sur l'agent invité.
|
||||||
|
#
|
||||||
|
# La première ligne est indispensable : sans virsh, la boucle ne tourne
|
||||||
|
# simplement pas et la sonde sortirait VIDE avec un code 0 — impossible
|
||||||
|
# alors de distinguer « pas de QEMU ici » de « QEMU présent, aucune VM ».
|
||||||
|
# Sonde exécutée à DISTANCE, dans une session SSH non interactive.
|
||||||
|
#
|
||||||
|
# « sudo virsh » y échoue dès que l'hôte demande un mot de passe — vécu sur
|
||||||
|
# erplibre01 (sudo-rs) — et la sonde répondait alors « pas de QEMU » sur une
|
||||||
|
# machine qui en fait tourner. On essaie donc virsh SANS sudo d'abord, via
|
||||||
|
# qemu:///system : appartenir au groupe libvirt suffit, sans tty.
|
||||||
|
#
|
||||||
|
# « --connect qemu:///system » est indispensable dans ce cas : sans lui, un
|
||||||
|
# utilisateur non root tombe sur qemu:///session, qui répond correctement…
|
||||||
|
# une liste VIDE. On aurait alors « QEMU présent, aucune VM », ce qui est
|
||||||
|
# pire qu'une erreur puisque c'est plausible.
|
||||||
|
#
|
||||||
|
# Trois réponses et non deux : « denied » distingue « virsh est là mais
|
||||||
|
# inaccessible » de « pas de QEMU ici », deux situations qui appellent des
|
||||||
|
# gestes opposés.
|
||||||
|
_QEMU_SSH_PROBE = (
|
||||||
|
'vsh() { virsh --connect qemu:///system "$@" 2>/dev/null '
|
||||||
|
'|| sudo -n virsh --connect qemu:///system "$@" 2>/dev/null; }; '
|
||||||
|
"if ! command -v virsh >/dev/null 2>&1; then "
|
||||||
|
"printf 'LIBVIRT\\tno\\n'; exit 0; fi; "
|
||||||
|
"vms=$(vsh list --all --name) || "
|
||||||
|
"{ printf 'LIBVIRT\\tdenied\\n'; exit 0; }; "
|
||||||
|
"printf 'LIBVIRT\\tyes\\n'; "
|
||||||
|
"for n in $vms; do "
|
||||||
|
'ip=$(vsh domifaddr "$n" --source lease '
|
||||||
|
"| grep -oE '([0-9]{1,3}\\.){3}[0-9]{1,3}' | head -1); "
|
||||||
|
'if [ -z "$ip" ]; then '
|
||||||
|
'ip=$(vsh domifaddr "$n" --source agent '
|
||||||
|
"| grep -oE '([0-9]{1,3}\\.){3}[0-9]{1,3}' "
|
||||||
|
"| grep -v '^127\\.' | head -1); fi; "
|
||||||
|
'printf "%s\\t%s\\n" "$n" "$ip"; done'
|
||||||
|
)
|
||||||
|
|
||||||
def _qemu_ssh_pick_roots(self):
|
def _qemu_ssh_pick_roots(self):
|
||||||
"""D'où partir pour configurer ~/.ssh/config. Renvoie une liste de
|
"""D'où partir pour configurer ~/.ssh/config. Renvoie une liste de
|
||||||
|
|
@ -1510,6 +1551,206 @@ class TODO:
|
||||||
self._ssh_deploy_keys([alias])
|
self._ssh_deploy_keys([alias])
|
||||||
return self._qemu_ssh_probe_remote(alias)
|
return self._qemu_ssh_probe_remote(alias)
|
||||||
|
|
||||||
|
def _qemu_ssh_probe_remote(self, alias):
|
||||||
|
"""Sonde `alias`. Renvoie (statut, données) :
|
||||||
|
|
||||||
|
("ok", [(nom, ip)]) libvirt répond, voici ses VM
|
||||||
|
("nolibvirt", []) joignable, mais pas de QEMU
|
||||||
|
("auth", message) ssh a refusé l'identité
|
||||||
|
("net", message) injoignable (éteint, DNS, port fermé…)
|
||||||
|
|
||||||
|
Passe par « ssh <alias> », donc par le bloc ~/.ssh/config qu'on vient
|
||||||
|
d'écrire : le ProxyJump du parent s'applique tout seul et la même
|
||||||
|
sonde marche à n'importe quelle profondeur.
|
||||||
|
|
||||||
|
« libvirt présent » et « a des VM » sont deux choses distinctes : une
|
||||||
|
machine avec QEMU mais sans VM mérite quand même sa connexion
|
||||||
|
virt-manager, une machine sans QEMU n'en veut aucune."""
|
||||||
|
cmd = [
|
||||||
|
"ssh",
|
||||||
|
"-o",
|
||||||
|
"BatchMode=yes",
|
||||||
|
"-o",
|
||||||
|
"ConnectTimeout=10",
|
||||||
|
alias,
|
||||||
|
self._QEMU_SSH_PROBE,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
cmd, capture_output=True, text=True, timeout=90
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return "net", t("timed out")
|
||||||
|
except (OSError, subprocess.SubprocessError) as exc:
|
||||||
|
return "net", str(exc)
|
||||||
|
if res.returncode != 0:
|
||||||
|
detail = (res.stderr or "").strip().splitlines()
|
||||||
|
message = detail[-1] if detail else f"exit {res.returncode}"
|
||||||
|
return self._ssh_error_kind(res.stderr), message
|
||||||
|
libvirt = "no"
|
||||||
|
found = []
|
||||||
|
for line in res.stdout.splitlines():
|
||||||
|
parts = line.strip().split("\t")
|
||||||
|
if len(parts) != 2 or not parts[0]:
|
||||||
|
continue
|
||||||
|
if parts[0] == "LIBVIRT":
|
||||||
|
libvirt = parts[1].strip()
|
||||||
|
continue
|
||||||
|
found.append((parts[0], parts[1].strip()))
|
||||||
|
if libvirt == "yes":
|
||||||
|
return "ok", found
|
||||||
|
# « denied » : virsh est installé mais refuse de répondre — sudo
|
||||||
|
# interactif, ou utilisateur hors du groupe libvirt. Le confondre avec
|
||||||
|
# « pas de QEMU » envoyait chercher un problème qui n'existe pas.
|
||||||
|
return ("denied" if libvirt == "denied" else "nolibvirt"), found
|
||||||
|
|
||||||
|
def _qemu_ssh_walk(self, roots, max_depth):
|
||||||
|
"""Descend le parc depuis `roots` et écrit un ProxyJump par niveau.
|
||||||
|
|
||||||
|
Une VM du profil « Déploiement » héberge elle-même des VM : celles-ci
|
||||||
|
n'ont pas d'IP joignable depuis l'hôte, seulement depuis leur parent.
|
||||||
|
ProxyJump enchaîne les sauts, et la chaîne se construit d'elle-même
|
||||||
|
puisque le parent est déjà dans ~/.ssh/config quand on écrit l'enfant.
|
||||||
|
|
||||||
|
Une racine sans IP est un hôte DÉJÀ décrit dans ~/.ssh/config : son
|
||||||
|
adresse y est, on ne la réécrit pas, on part simplement de lui.
|
||||||
|
"""
|
||||||
|
# Clé existante s'il y en a une : elle va dans IdentityFile. Si une
|
||||||
|
# clé est créée plus tard, en réaction à un refus, les entrées
|
||||||
|
# suivantes la reprendront.
|
||||||
|
identity = self._ssh_private_key(self._qemu_default_ssh_key())
|
||||||
|
|
||||||
|
entries = [] # un enregistrement par machine écrite
|
||||||
|
taken = set() # tous les noms d'hôte déjà attribués
|
||||||
|
chain_of = {} # alias -> nom chaîné « parent+enfant »
|
||||||
|
user_of = {} # alias -> compte de connexion
|
||||||
|
hosts_libvirt = [] # machines qui font tourner QEMU
|
||||||
|
frontier = []
|
||||||
|
for root in roots:
|
||||||
|
alias, ip = root["alias"], root.get("ip")
|
||||||
|
# Le compte vient de ~/.ssh/config quand il y est déclaré : un
|
||||||
|
# hôte adopté n'est pas forcément une VM ERPLibre.
|
||||||
|
user_of[alias] = root.get("user") or self.QEMU_VM_USER
|
||||||
|
if ip:
|
||||||
|
self._write_ssh_config_entry(
|
||||||
|
alias, user_of[alias], ip, identity_file=identity
|
||||||
|
)
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"names": [alias],
|
||||||
|
"ip": ip,
|
||||||
|
"parent": None,
|
||||||
|
"user": user_of[alias],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
taken.add(alias)
|
||||||
|
chain_of[alias] = alias
|
||||||
|
frontier.append(alias)
|
||||||
|
|
||||||
|
# `max_depth` compte les NIVEAUX de machines, racines comprises : une
|
||||||
|
# profondeur de 1 s'arrête donc ici, sans rien sonder.
|
||||||
|
for depth in range(1, max_depth):
|
||||||
|
if not frontier:
|
||||||
|
break
|
||||||
|
print(
|
||||||
|
f"\n🔎 {t('Level')} {depth + 1} — "
|
||||||
|
f"{len(frontier)} {t('machines to probe')}"
|
||||||
|
)
|
||||||
|
next_frontier = []
|
||||||
|
for parent in frontier:
|
||||||
|
status, found = self._qemu_ssh_probe_remote(parent)
|
||||||
|
if status == "auth":
|
||||||
|
# C'EST ici qu'une clé manquante se manifeste, pas avant :
|
||||||
|
# on ne parle d'identité qu'une fois l'identité refusée.
|
||||||
|
status, found = self._qemu_ssh_retry_with_key(
|
||||||
|
parent, found
|
||||||
|
)
|
||||||
|
# Une clé a pu naître de cet échange : les entrées
|
||||||
|
# écrites ensuite doivent la nommer.
|
||||||
|
identity = (
|
||||||
|
self._ssh_private_key(self._qemu_default_ssh_key())
|
||||||
|
or identity
|
||||||
|
)
|
||||||
|
if status in ("auth", "net"):
|
||||||
|
label = (
|
||||||
|
t("access refused")
|
||||||
|
if status == "auth"
|
||||||
|
else t("unreachable")
|
||||||
|
)
|
||||||
|
print(f" ⏭ {parent}: {label} — {found}")
|
||||||
|
continue
|
||||||
|
if status == "denied":
|
||||||
|
# virsh est là mais ne répond pas : c'est un DROIT qui
|
||||||
|
# manque, pas un logiciel. Le dire, et donner le geste.
|
||||||
|
print(
|
||||||
|
f" 🔒 {parent}: "
|
||||||
|
f"{t('virsh present but not accessible')}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" {t('Add the user to the libvirt group there:')}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if status != "ok":
|
||||||
|
print(f" · {parent}: {t('no QEMU/libvirt here')}")
|
||||||
|
continue
|
||||||
|
# Une machine avec QEMU vaut sa connexion virt-manager, même
|
||||||
|
# sans VM : c'est là qu'on pourra en créer.
|
||||||
|
hosts_libvirt.append(parent)
|
||||||
|
if not found:
|
||||||
|
print(f" · {parent}: {t('QEMU present, no VM')}")
|
||||||
|
continue
|
||||||
|
for child, ip in found:
|
||||||
|
if not ip:
|
||||||
|
print(f" ⏭ {parent} › {child}: {t('no IP')}")
|
||||||
|
continue
|
||||||
|
# UN SEUL nom, le nom CHAÎNÉ : il dit où vit la VM et ne
|
||||||
|
# peut heurter aucune autre machine. Y ajouter le nom
|
||||||
|
# court ne ferait que répéter la fin de la chaîne.
|
||||||
|
chain = f"{chain_of[parent]}+{child}"
|
||||||
|
if chain in taken:
|
||||||
|
continue # déjà vu (cycle)
|
||||||
|
# L'invitée hérite du compte de son parent : elle a été
|
||||||
|
# créée par lui, avec la même convention.
|
||||||
|
user_of[chain] = user_of[parent]
|
||||||
|
self._write_ssh_config_entry(
|
||||||
|
chain,
|
||||||
|
user_of[chain],
|
||||||
|
ip,
|
||||||
|
proxy_jump=parent,
|
||||||
|
identity_file=identity,
|
||||||
|
)
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"names": [chain],
|
||||||
|
"ip": ip,
|
||||||
|
"parent": parent,
|
||||||
|
"user": user_of[chain],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
taken.add(chain)
|
||||||
|
chain_of[chain] = chain
|
||||||
|
next_frontier.append(chain)
|
||||||
|
frontier = next_frontier
|
||||||
|
|
||||||
|
print(f"\n── {t('SSH hosts written')} ──")
|
||||||
|
for item in entries:
|
||||||
|
via = f" ({t('via')} {item['parent']})" if item["parent"] else ""
|
||||||
|
print(
|
||||||
|
f" ssh {' '.join(item['names']):<40}"
|
||||||
|
f" {item['user']}@{item['ip']}{via}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Les machines qui hébergent QEMU sont celles qui valent d'être
|
||||||
|
# ajoutées à virt-manager : c'est de là qu'on pilote leurs invitées.
|
||||||
|
# On y présente le nom CHAÎNÉ, pour que l'imbrication se lise aussi
|
||||||
|
# dans l'interface graphique et pas seulement dans ~/.ssh/config.
|
||||||
|
self._virt_manager_offer(
|
||||||
|
[
|
||||||
|
(chain_of.get(alias, alias), user_of.get(alias, ""))
|
||||||
|
for alias in 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.
|
||||||
|
|
||||||
|
|
@ -4330,6 +4571,82 @@ class TODO:
|
||||||
print(f" {t('~/.ssh/config:')} {cfg}")
|
print(f" {t('~/.ssh/config:')} {cfg}")
|
||||||
print(f" {t('Parallelism:')} {spec['parallelism']} {t('at a time')}")
|
print(f" {t('Parallelism:')} {spec['parallelism']} {t('at a time')}")
|
||||||
|
|
||||||
|
def _qemu_build_deploy_parts(
|
||||||
|
self,
|
||||||
|
d,
|
||||||
|
v,
|
||||||
|
arch,
|
||||||
|
name,
|
||||||
|
eram,
|
||||||
|
evcpus,
|
||||||
|
disk,
|
||||||
|
ssh_key,
|
||||||
|
branch,
|
||||||
|
dry_run,
|
||||||
|
timezone=None,
|
||||||
|
locale=None,
|
||||||
|
):
|
||||||
|
"""Construit la commande deploy_qemu.py d'UNE VM (utilisée pour l'aperçu
|
||||||
|
dry-run ET le déploiement réel)."""
|
||||||
|
parts = [] if dry_run else ["sudo"]
|
||||||
|
parts += [
|
||||||
|
self._qemu_script_path(),
|
||||||
|
"--distro",
|
||||||
|
d,
|
||||||
|
"--version",
|
||||||
|
v,
|
||||||
|
"--name",
|
||||||
|
name,
|
||||||
|
"--memory",
|
||||||
|
str(eram),
|
||||||
|
"--vcpus",
|
||||||
|
str(evcpus),
|
||||||
|
"--password",
|
||||||
|
"erplibre",
|
||||||
|
]
|
||||||
|
if not dry_run:
|
||||||
|
# --no-wait-ip : ne bloque pas 90s/VM, l'IP est collectée après.
|
||||||
|
parts.append("--no-wait-ip")
|
||||||
|
if arch and arch != "amd64":
|
||||||
|
parts += ["--arch", arch]
|
||||||
|
if ssh_key:
|
||||||
|
parts += ["--ssh-key", ssh_key]
|
||||||
|
if timezone:
|
||||||
|
# Toujours explicite, jamais implicite : la commande affichée en
|
||||||
|
# dry-run doit produire la même VM si on la rejoue depuis une autre
|
||||||
|
# machine, dont le fuseau serait différent.
|
||||||
|
parts += ["--timezone", timezone]
|
||||||
|
if locale:
|
||||||
|
parts += ["--locale", locale]
|
||||||
|
if branch:
|
||||||
|
# ERPLibre dépasse le minimum : +5 Go de disque.
|
||||||
|
bigger = self._parse_disk_gb(disk) + self.ERPLIBRE_EXTRA_DISK_GB
|
||||||
|
parts += ["--disk-size", f"{bigger}G"]
|
||||||
|
parts.append("--dry-run" if dry_run else "-y")
|
||||||
|
return parts
|
||||||
|
|
||||||
|
def _qemu_deploy_parts_for(self, vm, spec, dry_run=False):
|
||||||
|
"""Commande deploy_qemu.py d'une VM de la spec.
|
||||||
|
|
||||||
|
POINT DE PASSAGE UNIQUE des deux interfaces : le formulaire TUI et les
|
||||||
|
invites en ligne produisent la même spec, donc forcément la même
|
||||||
|
commande. C'est ce qui rend leur divergence vérifiable par un test."""
|
||||||
|
install = spec.get("install")
|
||||||
|
return self._qemu_build_deploy_parts(
|
||||||
|
vm["distro"],
|
||||||
|
vm["version"],
|
||||||
|
vm["arch"],
|
||||||
|
vm["name"],
|
||||||
|
vm["ram"],
|
||||||
|
vm["vcpus"],
|
||||||
|
vm["disk"],
|
||||||
|
spec.get("ssh_key"),
|
||||||
|
install["branch"] if install else None,
|
||||||
|
dry_run=dry_run,
|
||||||
|
timezone=spec.get("timezone"),
|
||||||
|
locale=spec.get("locale"),
|
||||||
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------- #
|
# ---------------------------------------------------------------- #
|
||||||
# Déploiement : catalogue (pur) -> collecte (CLI ou TUI) -> exécution
|
# Déploiement : catalogue (pur) -> collecte (CLI ou TUI) -> exécution
|
||||||
# ---------------------------------------------------------------- #
|
# ---------------------------------------------------------------- #
|
||||||
|
|
@ -4391,6 +4708,223 @@ class TODO:
|
||||||
existing = [vm["name"] for vm in vms if vm["name"] in known]
|
existing = [vm["name"] for vm in vms if vm["name"] in known]
|
||||||
return pending, existing
|
return pending, existing
|
||||||
|
|
||||||
|
def _qemu_check_libvirt_group(self):
|
||||||
|
"""Prévient si virsh n'est pas joignable sans sudo, et propose de régler.
|
||||||
|
|
||||||
|
Le suivi d'installation tourne DÉTACHÉ, sans tty : il ne peut pas
|
||||||
|
répondre à une demande de mot de passe. « sudo -n » y échoue sur tout
|
||||||
|
hôte exigeant une authentification interactive (vécu sur erplibre01 avec
|
||||||
|
sudo-rs), et la VM devient alors introuvable dès que son bail DHCP
|
||||||
|
change. Le groupe libvirt est la seule voie qui n'exige ni root ni tty.
|
||||||
|
|
||||||
|
Vérifié AVANT de créer quoi que ce soit : découvrir le problème après
|
||||||
|
vingt minutes d'installation coûte bien plus cher qu'une question ici.
|
||||||
|
"""
|
||||||
|
probe = subprocess.run(
|
||||||
|
["virsh", "--connect", "qemu:///system", "list", "--name"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if probe.returncode == 0:
|
||||||
|
return # joignable sans sudo : rien à signaler
|
||||||
|
|
||||||
|
user = getpass.getuser()
|
||||||
|
# Être dans /etc/group ne suffit pas : les groupes d'un processus sont
|
||||||
|
# figés à l'ouverture de session. Distinguer les deux cas évite de
|
||||||
|
# proposer un usermod déjà fait, et dit la vraie action attendue.
|
||||||
|
try:
|
||||||
|
declared = user in grp.getgrnam("libvirt").gr_mem
|
||||||
|
except KeyError:
|
||||||
|
declared = False
|
||||||
|
try:
|
||||||
|
active = grp.getgrnam("libvirt").gr_gid in os.getgroups()
|
||||||
|
except KeyError:
|
||||||
|
active = False
|
||||||
|
|
||||||
|
print(f"\n⚠ {t('virsh cannot reach qemu:///system without sudo.')}")
|
||||||
|
print(f" {t('The install monitor runs detached and cannot type a')}")
|
||||||
|
print(
|
||||||
|
f" {t('password: it would lose the VM when its lease moves.')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if declared and not active:
|
||||||
|
print(
|
||||||
|
f"\n {t('You are in the libvirt group, but this session')}"
|
||||||
|
)
|
||||||
|
print(f" {t('predates it. Log out and back in, or run:')}")
|
||||||
|
print(f" newgrp libvirt")
|
||||||
|
return
|
||||||
|
if active:
|
||||||
|
# Groupe présent mais virsh échoue quand même : libvirtd arrêté,
|
||||||
|
# socket absente… La cause n'est pas le groupe, ne pas la maquiller.
|
||||||
|
print(f"\n {t('Group is active, so the cause is elsewhere:')}")
|
||||||
|
print(f" {(probe.stderr or '').strip()[:200]}")
|
||||||
|
return
|
||||||
|
|
||||||
|
cmd = f"sudo usermod -aG libvirt {shlex.quote(user)}"
|
||||||
|
print(f"\n {t('Add your user to the libvirt group?')}")
|
||||||
|
print(f" {cmd}")
|
||||||
|
if not self._is_yes_default_yes(input(t("Run it now? (Y/n): "))):
|
||||||
|
return
|
||||||
|
if os.system(cmd) != 0:
|
||||||
|
print(f" ⚠ {t('Command failed.')}")
|
||||||
|
return
|
||||||
|
print(f"\n✅ {t('Added. Log out and back in for it to take effect,')}")
|
||||||
|
print(f" {t('or start a new shell with: newgrp libvirt')}")
|
||||||
|
|
||||||
|
def _qemu_check_kvm(self):
|
||||||
|
"""Prévient quand les VM seront ÉMULÉES faute de KVM.
|
||||||
|
|
||||||
|
« Même architecture que l'hôte » ne veut pas dire accélérée : dans une
|
||||||
|
VM sans virtualisation imbriquée, libvirt bascule en TCG sans le dire.
|
||||||
|
Mesuré : une VM s390x sur un hôte s390x lui-même invité KVM est sortie
|
||||||
|
en « <domain type='qemu'> » et a démarré en 7 min 30. Le savoir avant
|
||||||
|
d'attendre vaut mieux que de chercher la cause après."""
|
||||||
|
try:
|
||||||
|
mod = self._qemu_import_module()
|
||||||
|
if mod.kvm_available():
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
module = mod.nested_module()
|
||||||
|
except Exception:
|
||||||
|
module = "kvm"
|
||||||
|
print(f"\n⚠ {t('KVM is unavailable: the VMs will be EMULATED.')}")
|
||||||
|
print(f" {t('A boot then takes 10-15 min, not under a minute.')}")
|
||||||
|
print(
|
||||||
|
f" {t('Cause: /dev/kvm is missing. This host is itself a VM')}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" {t('whose hypervisor does not expose nested virtualization.')}"
|
||||||
|
)
|
||||||
|
print(f"\n {t('To fix it ON THE PARENT HYPERVISOR, not here:')}")
|
||||||
|
print(
|
||||||
|
f' echo "options {module} nested=1"'
|
||||||
|
f" | sudo tee /etc/modprobe.d/kvm-nested.conf"
|
||||||
|
)
|
||||||
|
print(f" sudo modprobe -r {module} && sudo modprobe {module}")
|
||||||
|
print(
|
||||||
|
f" {t('then set this VM to the host-passthrough CPU mode and')}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" {t('stop it and start it again - a reboot is not enough.')}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"\n {t('Without access to that hypervisor, nothing to do here.')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _qemu_ask_ui(self):
|
||||||
|
"""Interface du déploiement : formulaire TUI ou invites en ligne.
|
||||||
|
La préférence peut trancher d'avance (menu Configuration) ; « ask »
|
||||||
|
pose la question."""
|
||||||
|
pref = todo_prefs.get("qemu_deploy_ui")
|
||||||
|
if pref in ("tui", "cli"):
|
||||||
|
return pref
|
||||||
|
print(f"\n{t('Interface:')}")
|
||||||
|
print(f" [1] {t('TUI form')} *")
|
||||||
|
print(f" [2] {t('Classic questions (line by line)')}")
|
||||||
|
print(f" {t('(change the default in TODO > Configuration)')}")
|
||||||
|
sel = input(t("Choice (1-2, default 1): ")).strip()
|
||||||
|
return "cli" if sel == "2" else "tui"
|
||||||
|
|
||||||
|
def _qemu_form_context(self, mod):
|
||||||
|
"""Données préchargées pour le formulaire TUI.
|
||||||
|
|
||||||
|
TOUT ce qui exige sudo (liste des domaines) ou le réseau (branches)
|
||||||
|
est fait ICI, pendant que le terminal est encore à nous : une invite
|
||||||
|
de mot de passe pendant que Textual affiche casserait l'écran."""
|
||||||
|
native = self._native_arch()
|
||||||
|
arches = ["amd64", "arm64", "s390x"]
|
||||||
|
if native not in arches:
|
||||||
|
arches.insert(0, native)
|
||||||
|
arches.append("all")
|
||||||
|
|
||||||
|
catalog = {}
|
||||||
|
for a in arches:
|
||||||
|
distros = list(mod.DISTROS)
|
||||||
|
if a != "all":
|
||||||
|
allowed = self._qemu_arch_distros(a)
|
||||||
|
if allowed is not None:
|
||||||
|
distros = [d for d in distros if d in allowed]
|
||||||
|
entries = self._qemu_catalog_entries(mod, distros, a)
|
||||||
|
for e in entries:
|
||||||
|
# Le nom est calculé ici : le formulaire reste pure donnée.
|
||||||
|
e["name"] = self._qemu_infra_name(
|
||||||
|
e["distro"], e["version"], e["arch"]
|
||||||
|
)
|
||||||
|
catalog[a] = entries
|
||||||
|
|
||||||
|
print(f"\n{t('Loading (VM list, branches)...')}")
|
||||||
|
return {
|
||||||
|
"catalog": catalog,
|
||||||
|
"arches": arches,
|
||||||
|
"native": native,
|
||||||
|
"domains": self._qemu_list_domains(),
|
||||||
|
"branches": self._qemu_branch_list() or ["master"],
|
||||||
|
"install_profiles": self._qemu_install_profiles(),
|
||||||
|
"ssh_key": self._qemu_default_ssh_key(),
|
||||||
|
"timezone": self._qemu_host_timezone(),
|
||||||
|
"host_cpu": os.cpu_count() or 2,
|
||||||
|
"free_ram": self._host_free_ram_mb(),
|
||||||
|
"base_vcpus": self._QEMU_BASE_VCPUS,
|
||||||
|
"cpu_presets": self._QEMU_CPU_PRESETS,
|
||||||
|
"ram_presets": self._QEMU_RAM_PRESETS,
|
||||||
|
"disk_presets": self._QEMU_DISK_PRESETS,
|
||||||
|
"extra_disk_gb": self.ERPLIBRE_EXTRA_DISK_GB,
|
||||||
|
"defaults": {
|
||||||
|
"install": True,
|
||||||
|
"add_ssh_config": True,
|
||||||
|
"monitor": True,
|
||||||
|
"prod": False,
|
||||||
|
},
|
||||||
|
# L'aperçu passe par le MÊME constructeur que le déploiement.
|
||||||
|
"build_command": lambda vm, spec, dry: " ".join(
|
||||||
|
shlex.quote(p)
|
||||||
|
for p in self._qemu_deploy_parts_for(vm, spec, dry_run=dry)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _qemu_deploy(self, dry_run=False):
|
||||||
|
"""Déploiement d'un parc de VM, en trois temps : collecte des choix
|
||||||
|
(formulaire TUI ou invites en ligne), aperçu ou exécution. Les deux
|
||||||
|
interfaces produisent la MÊME spec."""
|
||||||
|
print(f"🚀 {t('Deploy ERPLibre VM(s)!')}")
|
||||||
|
try:
|
||||||
|
mod = self._qemu_import_module()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"{t('Cannot load QEMU catalog: ')}{exc}")
|
||||||
|
return
|
||||||
|
# Rappel de la dernière installation enregistrée (si historique).
|
||||||
|
last = self._qemu_last_run_line()
|
||||||
|
if last:
|
||||||
|
print(last)
|
||||||
|
|
||||||
|
self._qemu_check_libvirt_group()
|
||||||
|
self._qemu_check_kvm()
|
||||||
|
|
||||||
|
if self._qemu_ask_ui() == "tui":
|
||||||
|
spec = self._qemu_deploy_form(mod, dry_run)
|
||||||
|
if spec is None:
|
||||||
|
return
|
||||||
|
if spec: # None = annulé, {} = repli sur la CLI
|
||||||
|
self._qemu_run_spec(spec)
|
||||||
|
return
|
||||||
|
|
||||||
|
got = self._qemu_collect_vms_cli(mod)
|
||||||
|
if not got:
|
||||||
|
return
|
||||||
|
res_label, vms = got
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self._qemu_print_dry_run(vms)
|
||||||
|
return
|
||||||
|
|
||||||
|
spec = self._qemu_collect_options_cli(vms, res_label)
|
||||||
|
if not spec:
|
||||||
|
return
|
||||||
|
self._qemu_run_spec(spec)
|
||||||
|
|
||||||
def _qemu_deploy_form(self, mod, dry_run):
|
def _qemu_deploy_form(self, mod, dry_run):
|
||||||
"""Ouvre le formulaire TUI. Renvoie la spec, None si annulé, ou {}
|
"""Ouvre le formulaire TUI. Renvoie la spec, None si annulé, ou {}
|
||||||
pour retomber sur les invites en ligne (textual absent)."""
|
pour retomber sur les invites en ligne (textual absent)."""
|
||||||
|
|
@ -4604,6 +5138,143 @@ class TODO:
|
||||||
)
|
)
|
||||||
print(f" ⚠ {warn}")
|
print(f" ⚠ {warn}")
|
||||||
|
|
||||||
|
def _qemu_host_timezone(self):
|
||||||
|
"""Fuseau de l'hôte. Défini une seule fois, dans deploy_qemu.py, qui
|
||||||
|
est aussi ce qui l'écrit dans le cloud-config : l'invite ne peut donc
|
||||||
|
pas proposer un défaut différent de celui réellement appliqué."""
|
||||||
|
try:
|
||||||
|
mod = self._qemu_import_module()
|
||||||
|
return mod.host_timezone()
|
||||||
|
except Exception:
|
||||||
|
return "UTC"
|
||||||
|
|
||||||
|
def _qemu_ask_timezone(self):
|
||||||
|
"""Fuseau des VM à créer, celui de l'hôte par défaut.
|
||||||
|
|
||||||
|
Une VM qui hérite du fuseau de son opérateur horodate ses journaux et
|
||||||
|
ses bases comme lui ; en UTC l'écart ne se remarque qu'après coup."""
|
||||||
|
default = self._qemu_host_timezone()
|
||||||
|
answer = input(f"{t('Timezone for the VMs')} ({default}): ").strip()
|
||||||
|
if not answer:
|
||||||
|
return default
|
||||||
|
# Un fuseau inconnu ne casse pas cloud-init : il l'ignore en silence et
|
||||||
|
# la VM reste en UTC. Mieux vaut le refuser ici que le découvrir plus
|
||||||
|
# tard sur des horodatages faux.
|
||||||
|
if not os.path.exists(os.path.join("/usr/share/zoneinfo", answer)):
|
||||||
|
print(f"⚠ {t('Unknown timezone, keeping')} {default}")
|
||||||
|
return default
|
||||||
|
return answer
|
||||||
|
|
||||||
|
def _qemu_ask_locale(self):
|
||||||
|
"""Locale des VM. « C.UTF-8 » par défaut : les autres déclenchent un
|
||||||
|
locale-gen dans l'invité, mesuré à 36 s sur s390x — payé à chaque
|
||||||
|
déploiement pour un confort dont une VM jetable n'a pas besoin."""
|
||||||
|
default = "C.UTF-8"
|
||||||
|
answer = input(f"{t('Locale for the VMs')} ({default}): ").strip()
|
||||||
|
return answer or default
|
||||||
|
|
||||||
|
def _qemu_collect_options_cli(self, vms, res_label):
|
||||||
|
"""Invites en ligne : clé SSH, installation ERPLibre, ~/.ssh/config,
|
||||||
|
parallélisme, puis récapitulatif et confirmation.
|
||||||
|
Renvoie la spec complète, ou None si l'utilisateur renonce."""
|
||||||
|
# Clé SSH (partagée par tout le parc). Sans clé, cloud-init n'en
|
||||||
|
# injecte aucune : la VM démarre sans accès SSH, donc sans
|
||||||
|
# installation ni vérification possibles. On propose donc d'en créer
|
||||||
|
# une plutôt que de laisser passer un déploiement inutilisable.
|
||||||
|
default_key = self._qemu_default_ssh_key()
|
||||||
|
if not default_key:
|
||||||
|
print(f"\n⚠ {t('No SSH public key found in ~/.ssh.')}")
|
||||||
|
print(f" {t('Without one the VMs start with no SSH access.')}")
|
||||||
|
if self._is_yes_default_yes(input(t("Generate one now? (Y/n): "))):
|
||||||
|
default_key = self._ssh_ensure_key()
|
||||||
|
key_hint = default_key or t("none")
|
||||||
|
ssh_key = input(f"{t('SSH public key path')} ({key_hint}): ").strip()
|
||||||
|
if not ssh_key:
|
||||||
|
ssh_key = default_key
|
||||||
|
if ssh_key:
|
||||||
|
ssh_key = os.path.expanduser(ssh_key)
|
||||||
|
|
||||||
|
timezone = self._qemu_ask_timezone()
|
||||||
|
locale = self._qemu_ask_locale()
|
||||||
|
|
||||||
|
# 4) Option : installer ERPLibre dans ~/git/erplibre de chaque VM.
|
||||||
|
install = None
|
||||||
|
ans = input(
|
||||||
|
t("Install ERPLibre into ~/git/erplibre on each VM? (Y/n): ")
|
||||||
|
)
|
||||||
|
if self._is_yes_default_yes(ans):
|
||||||
|
branch = self._qemu_pick_branch()
|
||||||
|
# dev (~/git, SELinux relâché) vs prod (/opt, confiné)
|
||||||
|
prod = self._qemu_ask_prod()
|
||||||
|
label, cmd = self._qemu_pick_install_profile()
|
||||||
|
monitor = self._is_yes_default_yes(
|
||||||
|
input(t("Interactive monitoring dashboard? (y/N): "))
|
||||||
|
)
|
||||||
|
install = {
|
||||||
|
"branch": branch,
|
||||||
|
"prod": prod,
|
||||||
|
"label": label,
|
||||||
|
"cmd": cmd,
|
||||||
|
"monitor": monitor,
|
||||||
|
}
|
||||||
|
|
||||||
|
add_ssh_config = self._is_yes_default_yes(
|
||||||
|
input(t("Add each VM to ~/.ssh/config? (Y/n): "))
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5) Sépare les VM à CRÉER des déjà existantes AVANT de proposer le
|
||||||
|
# parallélisme : on connaît alors le vrai nombre à déployer (affiché
|
||||||
|
# dans le prompt) et on peut numéroter chaque tâche.
|
||||||
|
pending, existing = self._qemu_split_existing(
|
||||||
|
vms, self._qemu_list_domains()
|
||||||
|
)
|
||||||
|
n_jobs = len(pending)
|
||||||
|
|
||||||
|
# Collisions de noms : une VM déjà définie est ignorée (rien n'est
|
||||||
|
# écrasé), mais un qcow2 orphelin fera ÉCHOUER deploy_qemu, qui refuse
|
||||||
|
# d'écraser sans --force. On le dit avant, pas après l'attente.
|
||||||
|
if not self._qemu_confirm_collisions(
|
||||||
|
existing, [vm["name"] for vm in pending]
|
||||||
|
):
|
||||||
|
print(t("Cancelled."))
|
||||||
|
return None
|
||||||
|
if not pending:
|
||||||
|
print(t("Nothing to create - every VM already exists."))
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Nombre de déploiements en parallèle. Défaut = nombre de CPU de l'hôte
|
||||||
|
# (borné par le nombre de VM). On affiche aussi le NB DE VM à déployer.
|
||||||
|
default_par = min(n_jobs, os.cpu_count() or 4) or 1
|
||||||
|
raw = input(
|
||||||
|
f"{t('Parallel deployments (default:')} {default_par}, "
|
||||||
|
f"{n_jobs} {t('VMs')}): "
|
||||||
|
).strip()
|
||||||
|
try:
|
||||||
|
parallelism = max(1, int(raw)) if raw else default_par
|
||||||
|
except ValueError:
|
||||||
|
parallelism = default_par
|
||||||
|
|
||||||
|
spec = {
|
||||||
|
"res_label": res_label,
|
||||||
|
"vms": pending,
|
||||||
|
"existing": existing,
|
||||||
|
"ssh_key": ssh_key,
|
||||||
|
"timezone": timezone,
|
||||||
|
"locale": locale,
|
||||||
|
"install": install,
|
||||||
|
"add_ssh_config": add_ssh_config,
|
||||||
|
"parallelism": parallelism,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 6) Récapitulatif final, puis confirmation. Toutes les réponses
|
||||||
|
# données jusqu'ici sont rassemblées ici : c'est le dernier point où
|
||||||
|
# une erreur de saisie se rattrape sans avoir rien créé.
|
||||||
|
self._qemu_print_recap(spec, existing)
|
||||||
|
if not self._confirm_or_discard(t("Deploy these VMs now? (Y/n): ")):
|
||||||
|
print(t("Cancelled."))
|
||||||
|
return None
|
||||||
|
return spec
|
||||||
|
|
||||||
def _qemu_deploy_jobs_cli(self, jobs, workers):
|
def _qemu_deploy_jobs_cli(self, jobs, workers):
|
||||||
"""Déploiement parallèle, sortie texte. Renvoie
|
"""Déploiement parallèle, sortie texte. Renvoie
|
||||||
[(nom, rc, sortie, durée)] — même contrat que la vue TUI."""
|
[(nom, rc, sortie, durée)] — même contrat que la vue TUI."""
|
||||||
|
|
|
||||||
|
|
@ -1619,6 +1619,14 @@ TRANSLATIONS = {
|
||||||
"fr": "pas de QEMU/libvirt ici",
|
"fr": "pas de QEMU/libvirt ici",
|
||||||
"en": "no QEMU/libvirt here",
|
"en": "no QEMU/libvirt here",
|
||||||
},
|
},
|
||||||
|
"virsh present but not accessible": {
|
||||||
|
"fr": "virsh présent mais inaccessible (droits)",
|
||||||
|
"en": "virsh present but not accessible (permissions)",
|
||||||
|
},
|
||||||
|
"Add the user to the libvirt group there:": {
|
||||||
|
"fr": "Ajoutez l'utilisateur au groupe libvirt là-bas :",
|
||||||
|
"en": "Add the user to the libvirt group there:",
|
||||||
|
},
|
||||||
"QEMU present, no VM": {
|
"QEMU present, no VM": {
|
||||||
"fr": "QEMU présent, aucune VM",
|
"fr": "QEMU présent, aucune VM",
|
||||||
"en": "QEMU present, no VM",
|
"en": "QEMU present, no VM",
|
||||||
|
|
@ -3277,6 +3285,106 @@ TRANSLATIONS = {
|
||||||
"fr": "Chemin de la clé publique SSH",
|
"fr": "Chemin de la clé publique SSH",
|
||||||
"en": "SSH public key path",
|
"en": "SSH public key path",
|
||||||
},
|
},
|
||||||
|
"Timezone": {
|
||||||
|
"fr": "Fuseau horaire",
|
||||||
|
"en": "Timezone",
|
||||||
|
},
|
||||||
|
"virsh cannot reach qemu:///system without sudo.": {
|
||||||
|
"fr": "virsh ne peut pas joindre qemu:///system sans sudo.",
|
||||||
|
"en": "virsh cannot reach qemu:///system without sudo.",
|
||||||
|
},
|
||||||
|
"The install monitor runs detached and cannot type a": {
|
||||||
|
"fr": "Le suivi d'installation tourne détaché et ne peut pas saisir",
|
||||||
|
"en": "The install monitor runs detached and cannot type a",
|
||||||
|
},
|
||||||
|
"password: it would lose the VM when its lease moves.": {
|
||||||
|
"fr": "de mot de passe : il perdrait la VM au changement de bail.",
|
||||||
|
"en": "password: it would lose the VM when its lease moves.",
|
||||||
|
},
|
||||||
|
"You are in the libvirt group, but this session": {
|
||||||
|
"fr": "Vous êtes dans le groupe libvirt, mais cette session est",
|
||||||
|
"en": "You are in the libvirt group, but this session",
|
||||||
|
},
|
||||||
|
"predates it. Log out and back in, or run:": {
|
||||||
|
"fr": "antérieure. Reconnectez-vous, ou lancez :",
|
||||||
|
"en": "predates it. Log out and back in, or run:",
|
||||||
|
},
|
||||||
|
"Group is active, so the cause is elsewhere:": {
|
||||||
|
"fr": "Le groupe est actif : la cause est donc ailleurs.",
|
||||||
|
"en": "Group is active, so the cause is elsewhere:",
|
||||||
|
},
|
||||||
|
"Add your user to the libvirt group?": {
|
||||||
|
"fr": "Ajouter votre utilisateur au groupe libvirt ?",
|
||||||
|
"en": "Add your user to the libvirt group?",
|
||||||
|
},
|
||||||
|
"Run it now? (Y/n): ": {
|
||||||
|
"fr": "L'exécuter maintenant ? (O/n) : ",
|
||||||
|
"en": "Run it now? (Y/n): ",
|
||||||
|
},
|
||||||
|
"Command failed.": {
|
||||||
|
"fr": "La commande a échoué.",
|
||||||
|
"en": "Command failed.",
|
||||||
|
},
|
||||||
|
"Added. Log out and back in for it to take effect,": {
|
||||||
|
"fr": "Ajouté. Reconnectez-vous pour que ça prenne effet,",
|
||||||
|
"en": "Added. Log out and back in for it to take effect,",
|
||||||
|
},
|
||||||
|
"or start a new shell with: newgrp libvirt": {
|
||||||
|
"fr": "ou ouvrez un shell neuf avec : newgrp libvirt",
|
||||||
|
"en": "or start a new shell with: newgrp libvirt",
|
||||||
|
},
|
||||||
|
"Timezone for the VMs": {
|
||||||
|
"fr": "Fuseau horaire des VM",
|
||||||
|
"en": "Timezone for the VMs",
|
||||||
|
},
|
||||||
|
"Locale for the VMs": {
|
||||||
|
"fr": "Locale des VM",
|
||||||
|
"en": "Locale for the VMs",
|
||||||
|
},
|
||||||
|
"KVM is unavailable: the VMs will be EMULATED.": {
|
||||||
|
"fr": "KVM indisponible : les VM seront ÉMULÉES.",
|
||||||
|
"en": "KVM is unavailable: the VMs will be EMULATED.",
|
||||||
|
},
|
||||||
|
"A boot then takes 10-15 min, not under a minute.": {
|
||||||
|
"fr": "Un démarrage prend alors 10 à 15 min, pas moins d'une minute.",
|
||||||
|
"en": "A boot then takes 10-15 min, not under a minute.",
|
||||||
|
},
|
||||||
|
"Cause: /dev/kvm is missing. This host is itself a VM": {
|
||||||
|
"fr": "Cause : /dev/kvm est absent. Cet hôte est lui-même une VM",
|
||||||
|
"en": "Cause: /dev/kvm is missing. This host is itself a VM",
|
||||||
|
},
|
||||||
|
"whose hypervisor does not expose nested virtualization.": {
|
||||||
|
"fr": "dont l'hyperviseur n'expose pas la virtualisation imbriquée.",
|
||||||
|
"en": "whose hypervisor does not expose nested virtualization.",
|
||||||
|
},
|
||||||
|
"To fix it ON THE PARENT HYPERVISOR, not here:": {
|
||||||
|
"fr": "Pour corriger, SUR L'HYPERVISEUR PARENT, pas ici :",
|
||||||
|
"en": "To fix it ON THE PARENT HYPERVISOR, not here:",
|
||||||
|
},
|
||||||
|
"then set this VM to the host-passthrough CPU mode and": {
|
||||||
|
"fr": "puis donner à cette VM le mode CPU « host-passthrough », et",
|
||||||
|
"en": "then set this VM to the host-passthrough CPU mode and",
|
||||||
|
},
|
||||||
|
"stop it and start it again - a reboot is not enough.": {
|
||||||
|
"fr": "l'arrêter puis la redémarrer — un reboot ne suffit pas.",
|
||||||
|
"en": "stop it and start it again - a reboot is not enough.",
|
||||||
|
},
|
||||||
|
"Without access to that hypervisor, nothing to do here.": {
|
||||||
|
"fr": "Sans accès à cet hyperviseur, il n'y a rien à régler ici.",
|
||||||
|
"en": "Without access to that hypervisor, nothing to do here.",
|
||||||
|
},
|
||||||
|
"Usual cause: this host is itself a VM without nested": {
|
||||||
|
"fr": "Cause habituelle : cet hôte est lui-même une VM sans",
|
||||||
|
"en": "Usual cause: this host is itself a VM without nested",
|
||||||
|
},
|
||||||
|
"virtualization. Check: systemd-detect-virt": {
|
||||||
|
"fr": "virtualisation imbriquée. Vérifier : systemd-detect-virt",
|
||||||
|
"en": "virtualization. Check: systemd-detect-virt",
|
||||||
|
},
|
||||||
|
"Unknown timezone, keeping": {
|
||||||
|
"fr": "Fuseau inconnu, on garde",
|
||||||
|
"en": "Unknown timezone, keeping",
|
||||||
|
},
|
||||||
"none": {
|
"none": {
|
||||||
"fr": "aucune",
|
"fr": "aucune",
|
||||||
"en": "none",
|
"en": "none",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue