NixOS était SOUSTRAIT du cache faute d'ancre de confiance par fichier, ce qui lui fermait le hors ligne : le magasin est alors la seule source, et l'exception ne laisse rien. Une déclaration arriverait trop tard, la première reconstruction étant le premier téléchargement. L'autorité est donc POINTÉE, consommateur par consommateur, et l'environnement se perd à trois frontières : nix-daemon, activé par socket, qu'un fragment sous /run/systemd/system atteint ; sudo, que « env_keep » traverse — le nix de root parle droit au magasin local et télécharge lui-même ; et la session ssh, ouverte une seconde avant que cloud-init n'écrive le faisceau. Vérifié : installation complète, 0 refus. --- EN --- NixOS was EXEMPTED from the cache for want of a per-file trust anchor, which closed offline deployment to it: the store is then the only source, and an exemption leaves nothing. A declaration would come too late, the first rebuild being the first download. The authority is therefore POINTED AT, consumer by consumer, and the environment is lost at three boundaries: nix-daemon, socket-activated, reached by a drop-in under /run/systemd/system; sudo, crossed by « env_keep » — root's nix talks straight to the local store and downloads itself; and the ssh session, opened one second before cloud-init writes the bundle. Checked: a complete install, 0 refusals. Assisted-by: Claude Opus 5
5889 lines
242 KiB
Python
Executable file
5889 lines
242 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Déploiement rapide de VM Linux (Ubuntu/Debian/Fedora) via cloud-image + qemu-img + cloud-init + virt-install.
|
|
|
|
Choisissez la distribution avec --distro (ubuntu par défaut, debian, fedora)
|
|
et la version avec --version. « --list-images » affiche tout le catalogue et
|
|
les specs minimales. Reprend le workflow des notes :
|
|
1. Télécharge l'image cloud (si absente du cache -> pas de double téléchargement).
|
|
2. Convertit/copie l'image en un qcow2 de travail dédié à la VM.
|
|
3. Redimensionne le disque virtuel.
|
|
4. Génère user-data / meta-data et construit le seed.iso (cidata).
|
|
5. Lance virt-install en important le disque + le seed en CD-ROM.
|
|
|
|
Exemples
|
|
--------
|
|
# Le plus simple : image téléchargée automatiquement (chemin déduit de
|
|
# --distro/--version, mis en cache dans /var/lib/libvirt/images/iso) et
|
|
# outils manquants installés après confirmation.
|
|
sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 \\
|
|
--ssh-key ~/.ssh/id_ed25519.pub
|
|
|
|
# Debian 12 / Fedora 42 (mêmes options, --distro change la source d'image)
|
|
sudo ./script/qemu/deploy_qemu.py --distro debian --version 12 \\
|
|
--name deb12 --ssh-key ~/.ssh/id_ed25519.pub
|
|
sudo ./script/qemu/deploy_qemu.py --distro fedora --version 42 \\
|
|
--name fed42 --ssh-key ~/.ssh/id_ed25519.pub
|
|
|
|
# Voir tout le catalogue (distros, versions, specs minimales)
|
|
./script/qemu/deploy_qemu.py --list-images
|
|
|
|
# Télécharger (et vérifier) une image, sans créer de VM
|
|
sudo ./script/qemu/deploy_qemu.py --download-only --version 24.04 --verify
|
|
|
|
# Déploiement minimal en fournissant explicitement le chemin d'image
|
|
sudo ./qemu-deploy.py /var/lib/libvirt/images/iso/noble.img \\
|
|
--name test-vm --ssh-key ~/.ssh/id_ed25519.pub
|
|
|
|
# Reproduction fidèle des notes (8 Go RAM, 8 vCPU, mot de passe demandé)
|
|
sudo ./qemu-deploy.py /var/lib/libvirt/images/iso/noble.img \\
|
|
--name test-vm --memory 8192 --vcpus 8 --disk-size 120G --ask-password
|
|
|
|
# Voir ce qui serait fait, sans rien exécuter
|
|
./qemu-deploy.py /var/lib/libvirt/images/iso/noble.img --name test-vm --dry-run
|
|
|
|
sudo ./script/qemu/deploy_qemu.py /var/lib/libvirt/images/iso/noble.img --name test-vm --memory 8192 --vcpus 8 --disk-size 120G --ask-password --force
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import contextlib
|
|
import getpass
|
|
import grp
|
|
import gzip
|
|
import hashlib
|
|
import ipaddress
|
|
import os
|
|
import pwd
|
|
import re
|
|
import secrets
|
|
import shlex
|
|
import shutil
|
|
import socket
|
|
import stat as stat_mod
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import textwrap
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
import warnings
|
|
import zlib
|
|
from pathlib import Path
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Registre des distributions : version -> (code, --osinfo, RAM min Mo, disque).
|
|
# Le « code » est le nom de code (Ubuntu/Debian) ou le numéro de release
|
|
# (Fedora). RAM/disque minimaux proviennent de libosinfo (osinfo-db) : ce sont
|
|
# les seuils sous lesquels virt-install avertit. Ils servent de valeurs PAR
|
|
# DÉFAUT — la VM démarre au plus juste sans gaspiller la RAM de l'hôte.
|
|
# --codename / --osinfo / --memory / --disk-size surchargent au besoin.
|
|
# --------------------------------------------------------------------------- #
|
|
# NB : les versions intermédiaires (non-LTS) sont RETIRÉES du miroir
|
|
# cloud-images une fois EOL (leur /current/ renvoie 404). On ne garde donc
|
|
# que les LTS + les intermédiaires encore publiées. À réviser au fil du temps.
|
|
# Disque MINIMUM = 20G partout : un ERPLibre + Odoo installé occupe ~11G, et
|
|
# l'installation (caches pip, sync repo, sources Odoo) en consomme plus en
|
|
# transitoire. Un VM à 10G tombait « Poetry installation error » (disque
|
|
# plein). Le qcow2 est CREUX (sparse) : une taille virtuelle plus grande ne
|
|
# consomme rien tant qu'elle n'est pas remplie -> 20G sans surcoût réel.
|
|
#
|
|
# 20.04 et 22.04 ne sont plus proposées : leur chaîne d'outils ne suffit plus à
|
|
# ERPLibre. Le mur le plus net est pikepdf, qui réclame qpdf >= 12.2, lui-même
|
|
# en C++20 — focal livre GCC 9. S'y ajoutaient Python 3.8 à l'amorçage, node 10,
|
|
# cargo 0.67 et OpenSSL 1.1.1. Chacun avait son contournement ; l'accumulation,
|
|
# non.
|
|
UBUNTU_VERSIONS: dict[str, tuple[str, str, int, str]] = {
|
|
"24.04": ("noble", "ubuntu24.04", 3072, "20G"),
|
|
"25.10": ("questing", "ubuntu25.10", 3072, "20G"),
|
|
"26.04": ("resolute", "ubuntu26.04", 3072, "20G"),
|
|
}
|
|
DEBIAN_VERSIONS: dict[str, tuple[str, str, int, str]] = {
|
|
"11": ("bullseye", "debian11", 1024, "20G"),
|
|
"12": ("bookworm", "debian12", 1024, "20G"),
|
|
"13": ("trixie", "debian13", 1024, "20G"),
|
|
}
|
|
FEDORA_VERSIONS: dict[str, tuple[str, str, int, str]] = {
|
|
"41": ("41", "fedora41", 2048, "20G"),
|
|
"42": ("42", "fedora42", 2048, "20G"),
|
|
"43": ("43", "fedora43", 2048, "20G"),
|
|
"44": ("44", "fedora44", 2048, "20G"),
|
|
}
|
|
# Dérivés RHEL. Le second champ est l'identifiant libosinfo : « almalinux9 » et
|
|
# « almalinux10 » figurent dans osinfo-db, « rocky10 » n'y est pas encore sur
|
|
# les hôtes 24.04 — osinfo_arg() replie alors sur une valeur connue.
|
|
# RAM : libosinfo demande 1,5 Gio en x86_64 ; on retient 2048 comme Fedora.
|
|
ALMALINUX_VERSIONS: dict[str, tuple[str, str, int, str]] = {
|
|
"9": ("9", "almalinux9", 2048, "20G"),
|
|
"10": ("10", "almalinux10", 2048, "20G"),
|
|
}
|
|
ROCKY_VERSIONS: dict[str, tuple[str, str, int, str]] = {
|
|
"9": ("9", "rocky9", 2048, "20G"),
|
|
"10": ("10", "rocky10", 2048, "20G"),
|
|
}
|
|
# openSUSE, deux produits distincts et pas deux versions du même.
|
|
# Leap NUMÉROTÉ, base SLE, ~2 ans de support. C'est le défaut, et le
|
|
# seul des deux à proposer pour une installation qui doit durer.
|
|
# Tumbleweed ROLLING, sans numéro. Gardé comme banc d'essai des ruptures à
|
|
# venir, pas comme cible. Sa dérive d'instantanés est réelle : deux
|
|
# VM déployées le même jour ont vu git-daemon 2.54 sur s390x et
|
|
# 2.55 sur amd64, et l'image livrée est toujours en retard sur les
|
|
# dépôts, d'où le « zypper dup » obligatoire avant toute install.
|
|
#
|
|
# Les deux dépassent le seuil qpdf de pikepdf : aucune compilation de qpdf, ce
|
|
# qui change tout sous émulation s390x.
|
|
#
|
|
# osinfo : « opensuse16.0 » n'est pas encore dans osinfo-db, qui s'arrête à
|
|
# 15.6. osinfo_arg() replie sur le DERNIER id connu de la table — d'où l'ordre,
|
|
# Tumbleweed en second servant de repli à Leap.
|
|
OPENSUSE_VERSIONS: dict[str, tuple[str, str, int, str]] = {
|
|
"16.0": ("16.0", "opensuse16.0", 2048, "20G"),
|
|
"tumbleweed": ("tumbleweed", "opensusetumbleweed", 2048, "20G"),
|
|
}
|
|
# Arch est en rolling release : une seule « version » (latest).
|
|
ARCH_VERSIONS: dict[str, tuple[str, str, int, str]] = {
|
|
"latest": ("latest", "archlinux", 1024, "20G"),
|
|
}
|
|
|
|
# Proxmox VE ne publie AUCUNE image cloud : son ISO est un installateur, et la
|
|
# voie que l'amont documente pour tout le reste est « Proxmox VE sur Debian ».
|
|
# La base est donc l'image cloud Debian, que les paquets pve transforment en
|
|
# hyperviseur. Le numéro est celui de PROXMOX, pas de Debian : PVE 9 = trixie.
|
|
#
|
|
# arm64 est officiel depuis PVE 9 — vérifié dans le dépôt amont, dont le
|
|
# Release de trixie annonce « amd64 arm64 » et dont l'index arm64 sert bien
|
|
# proxmox-ve. bookworm (PVE 8), lui, est amd64 seulement : d'où une seule
|
|
# version au catalogue, celle qui couvre les deux architectures.
|
|
#
|
|
# s390x n'y figure pas et n'y figurera pas par cette voie : l'index
|
|
# « binary-s390x » du dépôt répond 404. Ce n'est pas une difficulté, c'est une
|
|
# absence — et ARCH_DISTRO_SUPPORT la dit à la place d'un échec au montage.
|
|
#
|
|
# RAM/disque : 4 Gio et 32 Go, pas les 1 Gio/20 Go de Debian. Proxmox demande
|
|
# 2 Gio pour lui seul, et l'installation télécharge son propre noyau ; 20 Go ne
|
|
# laisseraient pas la place d'une seule VM invitée.
|
|
PROXMOX_VERSIONS: dict[str, tuple[str, str, int, str]] = {
|
|
"9": ("trixie", "debian13", 4096, "32G"),
|
|
}
|
|
|
|
# Version Debian dont l'image sert de base à chaque version de Proxmox.
|
|
PROXMOX_DEBIAN_BASE: dict[str, str] = {"9": "13"}
|
|
|
|
# distro -> (table des versions, version par défaut).
|
|
# NixOS ne publie AUCUNE image cloud : sa page de téléchargement offre des ISO,
|
|
# des AMI Amazon et Docker, rien d'autre. L'image vient donc d'un tiers qui la
|
|
# reconstruit depuis nixpkgs — d'où le TAG épinglé et la somme figée plus bas,
|
|
# et d'où image_source_note(), qui le dit à l'écran avant de déployer.
|
|
#
|
|
# 40 Go et 2 Go de RAM là où Arch demande 20 et 1 : le store Nix garde chaque
|
|
# génération du système et ne se purge qu'à la main (nix-collect-garbage).
|
|
NIXOS_VERSIONS: dict[str, tuple[str, str, int, str]] = {
|
|
"25.11": ("25.11", "nixos-25.11", 2048, "40G"),
|
|
}
|
|
|
|
DISTROS: dict[str, tuple[dict[str, tuple[str, str, int, str]], str]] = {
|
|
"ubuntu": (UBUNTU_VERSIONS, "24.04"),
|
|
"debian": (DEBIAN_VERSIONS, "12"),
|
|
"fedora": (FEDORA_VERSIONS, "42"),
|
|
"almalinux": (ALMALINUX_VERSIONS, "9"),
|
|
"rocky": (ROCKY_VERSIONS, "10"),
|
|
"opensuse": (OPENSUSE_VERSIONS, "16.0"),
|
|
"arch": (ARCH_VERSIONS, "latest"),
|
|
"nixos": (NIXOS_VERSIONS, "25.11"),
|
|
"proxmox": (PROXMOX_VERSIONS, "9"),
|
|
}
|
|
|
|
# Traduction de l'arch générique (amd64/arm64) vers le nom propre à la distro.
|
|
# s390x s'écrit « s390x » partout (identité), donc aucune entrée n'est requise.
|
|
ARCH_ALIASES: dict[str, dict[str, str]] = {
|
|
"fedora": {"amd64": "x86_64", "arm64": "aarch64"},
|
|
"almalinux": {"amd64": "x86_64", "arm64": "aarch64"},
|
|
"rocky": {"amd64": "x86_64", "arm64": "aarch64"},
|
|
"opensuse": {"amd64": "x86_64", "arm64": "aarch64"},
|
|
"arch": {"amd64": "x86_64", "arm64": "aarch64"},
|
|
}
|
|
|
|
# Architectures non-x86 nécessitant une machine/un amorçage spécifiques (voir
|
|
# virt_install). Émulées en TCG (pas de KVM), donc LENTES, quand elles
|
|
# diffèrent de l'architecture de l'hôte.
|
|
NON_X86_ARCHES: tuple[str, ...] = ("arm64", "s390x")
|
|
|
|
# Distros publiant des images cloud par architecture (vérifié juillet 2026) :
|
|
# - s390x (IBM Z) : Ubuntu seulement (Debian/Fedora : 404 ; Arch : x86/arm).
|
|
# - arm64/aarch64 : Ubuntu, Debian, Fedora (Arch : pas d'image cloud officielle
|
|
# aarch64 sur geo.mirror.pkgbuild.com).
|
|
S390X_DISTROS: tuple[str, ...] = (
|
|
"ubuntu",
|
|
"almalinux",
|
|
"rocky",
|
|
"fedora",
|
|
"opensuse",
|
|
# Debian n'a PAS d'image cloud s390x, et n'en aura pas par cette voie :
|
|
# vérifié sur cloud.debian.org, les arborescences bookworm et trixie ne
|
|
# publient que amd64, arm64, ppc64el et riscv64. Le port s390x existe
|
|
# pourtant — « binary-s390x » répond 200 — et debian-installer livre
|
|
# kernel + initrd pour les deux versions. On y passe donc par
|
|
# l'INSTALLATEUR au lieu d'un qcow2 tout fait : voir uses_installer().
|
|
"debian",
|
|
)
|
|
|
|
# Une distro peut ne publier qu'une PARTIE de ses versions sur une
|
|
# architecture. Déclaration POSITIVE : hors de cette table, toutes les versions
|
|
# du catalogue sont réputées disponibles.
|
|
#
|
|
# Fedora ne construit s390x que pour la version courante, et sur une
|
|
# arborescence à part (« fedora-secondary ») : la 41 et la 42 y sont retirées
|
|
# (404 sur le miroir maître), la 44 n'est pour l'instant que sur certains
|
|
# miroirs tiers. Seule la 43 est servie par dl.fedoraproject.org — vérifié.
|
|
ARCH_ONLY_VERSIONS: dict[str, dict[str, tuple[str, ...]]] = {
|
|
# Debian sur s390x passe par debian-installer, dont les images sont
|
|
# publiées pour bookworm et trixie — vérifié. bullseye est écartée : elle
|
|
# est en fin de vie et son installateur n'a pas été éprouvé ici.
|
|
"s390x": {"fedora": ("43",), "debian": ("12", "13")},
|
|
}
|
|
|
|
# Distros installées par debian-installer plutôt que depuis une image cloud.
|
|
# La différence n'est pas cosmétique : pas de qcow2 à convertir, pas de seed
|
|
# cloud-init, un disque VIERGE et un amorçage kernel+initrd.
|
|
INSTALLER_COMBOS: tuple[tuple[str, str], ...] = (("debian", "s390x"),)
|
|
|
|
# Plancher mémoire de l'installateur : il déplie un système de fichiers entier
|
|
# en RAM, là où une image cloud arrive déjà installée.
|
|
INSTALLER_MIN_RAM = 2048
|
|
|
|
# kernel.debian / initrd.debian du port s390x. « current » suit les mises à
|
|
# jour de l'installateur sans figer un numéro qui périmerait.
|
|
INSTALLER_URL = (
|
|
"https://deb.debian.org/debian/dists/{code}/main/installer-s390x"
|
|
"/current/images/generic/{fichier}"
|
|
)
|
|
|
|
|
|
def uses_installer(distro: str, arch: str) -> bool:
|
|
"""Vrai si cette combinaison s'installe par debian-installer."""
|
|
return (distro, arch) in INSTALLER_COMBOS
|
|
|
|
|
|
def arch_versions(distro: str, arch: str, versions) -> list[str]:
|
|
"""Versions de `distro` réellement publiées pour `arch`."""
|
|
only = ARCH_ONLY_VERSIONS.get(arch, {}).get(distro)
|
|
return [v for v in versions if only is None or v in only]
|
|
|
|
|
|
ARM64_DISTROS: tuple[str, ...] = (
|
|
"ubuntu",
|
|
"debian",
|
|
"fedora",
|
|
"almalinux",
|
|
"rocky",
|
|
"opensuse",
|
|
"proxmox",
|
|
)
|
|
|
|
|
|
# arch générique -> distros la publiant (pour valider --arch tôt).
|
|
ARCH_DISTRO_SUPPORT: dict[str, tuple[str, ...]] = {
|
|
"s390x": S390X_DISTROS,
|
|
"arm64": ARM64_DISTROS,
|
|
}
|
|
|
|
|
|
def host_arch() -> str:
|
|
"""Architecture de l'hôte en jeton générique (amd64/arm64/s390x)."""
|
|
try:
|
|
machine = os.uname().machine
|
|
except (AttributeError, OSError):
|
|
machine = ""
|
|
return {
|
|
"x86_64": "amd64",
|
|
"amd64": "amd64",
|
|
"aarch64": "arm64",
|
|
"arm64": "arm64",
|
|
"s390x": "s390x",
|
|
}.get(machine, "amd64")
|
|
|
|
|
|
# Nœud de rendu DRM : le fichier que le processus QEMU ouvre pour créer un
|
|
# contexte OpenGL (virgl) et donner la 3D à la VM. Un hôte sans GPU — ou
|
|
# lui-même virtualisé sans GPU transmis — n'expose AUCUN « renderD* », et
|
|
# aucune option de ligne de commande ne peut y suppléer : la VM retombe alors
|
|
# sur le rendu logiciel. On teste donc la présence du nœud, pas nos droits
|
|
# dessus : l'accès est accordé par libvirt au démarrage du domaine (cgroup +
|
|
# étiquette), et un test de lecture sous notre propre compte rejetterait à
|
|
# tort un hôte où seul le groupe « render » entre.
|
|
HOST_DRI_DIR = Path("/dev/dri")
|
|
|
|
|
|
def host_render_nodes(directory=HOST_DRI_DIR) -> list[str]:
|
|
"""Nœuds de rendu de l'hôte, triés (ex. ['/dev/dri/renderD128'])."""
|
|
directory = Path(directory)
|
|
try:
|
|
names = sorted(p.name for p in directory.iterdir())
|
|
except OSError:
|
|
return []
|
|
return [str(directory / n) for n in names if n.startswith("renderD")]
|
|
|
|
|
|
def host_gpu_node(directory=HOST_DRI_DIR) -> str:
|
|
"""Nœud de rendu à confier à QEMU, ou '' si l'hôte n'a pas de GPU.
|
|
|
|
Le premier de la liste : sur une machine à plusieurs cartes, renderD128
|
|
est le nœud du GPU primaire. --gpu-node force un autre choix.
|
|
"""
|
|
nodes = host_render_nodes(directory)
|
|
return nodes[0] if nodes else ""
|
|
|
|
|
|
def gpu_decision(mode: str, node: str, screen: bool) -> tuple[bool, str]:
|
|
"""(3D activée, message à dire) pour un mode --gpu et un nœud donnés.
|
|
|
|
Séparée du reste pour être vérifiable sans hôte : c'est ici que se décide
|
|
« par défaut avec GPU s'il existe », et le silence n'est pas une option —
|
|
une VM en rendu logiciel doit dire pourquoi.
|
|
"""
|
|
mode = (mode or "auto").lower()
|
|
if mode == "off":
|
|
return False, ""
|
|
if not screen and mode != "on":
|
|
# Sans écran virtuel, « auto » s'abstient : une VM serveur n'a pas
|
|
# demandé de périphérique vidéo, et lui en poser un d'office change
|
|
# son matériel sans qu'on l'ait voulu.
|
|
return False, ""
|
|
if not node:
|
|
if mode == "on":
|
|
return (
|
|
False,
|
|
" ⚠ GPU demandé mais l'hôte n'a aucun nœud de rendu"
|
|
" (/dev/dri/renderD*) : la VM démarrerait sans écran."
|
|
" Rendu logiciel.",
|
|
)
|
|
return (
|
|
False,
|
|
" GPU : aucun sur l'hôte, rendu logiciel (virgl absent).",
|
|
)
|
|
if not screen:
|
|
# 3D demandée sur une VM sans console : le virtio-gpu est POSÉ quand
|
|
# même, et « egl-headless » n'ouvre aucun port — l'invité reçoit un
|
|
# périphérique DRM accéléré sans écran à regarder. C'est ce qui sert
|
|
# au rendu hors écran et à un émulateur qui tourne dans la VM.
|
|
return (
|
|
True,
|
|
f" GPU : 3D activée par {node} sans écran virtuel"
|
|
" (virtio-gpu accéléré, aucun port ouvert).",
|
|
)
|
|
return True, f" GPU : 3D activée par {node} (virtio-gpu + egl-headless)."
|
|
|
|
|
|
# Ce que QEMU écrit quand le nœud de rendu existe mais qu'EGL n'y démarre
|
|
# pas. Le fichier /dev/dri/renderD* est alors bien là — un GPU virtuel sans
|
|
# pile EGL, un pilote sans GBM, une carte que Mesa ne sait pas ouvrir : la
|
|
# présence du nœud ne prouve donc PAS que la 3D fonctionne, et rien ne le dit
|
|
# avant que QEMU n'essaie.
|
|
EGL_ECHEC = (
|
|
"eglInitialize failed",
|
|
"render node init failed",
|
|
"EGL_NOT_INITIALIZED",
|
|
)
|
|
|
|
|
|
def egl_failed(output: str) -> bool:
|
|
"""La sortie de virt-install accuse-t-elle un EGL inutilisable ?"""
|
|
return any(marque in (output or "") for marque in EGL_ECHEC)
|
|
|
|
|
|
def gpu_apply(
|
|
video: list, mode: str, node: str, screen: bool
|
|
) -> tuple[list, list, str]:
|
|
"""(video, arguments 3D, message) — pour virt-install.
|
|
|
|
Renvoie le `--video` à garder : celui de la 3D REMPLACE le simple
|
|
« --video virtio », il ne s'y ajoute pas. Deux --video donneraient deux
|
|
écrans à la VM, et l'invité n'afficherait le bureau que sur un seul.
|
|
"""
|
|
use_gpu, message = gpu_decision(mode, node, screen)
|
|
if not use_gpu:
|
|
return video, [], message
|
|
return [], gpu_install_args(node), message
|
|
|
|
|
|
def gpu_install_args(node: str) -> list[str]:
|
|
"""Arguments virt-install qui donnent la 3D à la VM.
|
|
|
|
Deux pièces indissociables : l'accélération sur le virtio-gpu, et un
|
|
affichage capable de contexte GL. « egl-headless » joue ce second rôle
|
|
SANS remplacer la console VNC — il n'ouvre aucun port, il n'existe que
|
|
pour porter le contexte OpenGL. C'est la recette documentée pour associer
|
|
3D et VNC, là où <gl enable/> ne vaut que pour SPICE.
|
|
"""
|
|
return [
|
|
"--video",
|
|
"model.type=virtio,model.acceleration.accel3d=on",
|
|
"--graphics",
|
|
f"type=egl-headless,gl.rendernode={node}",
|
|
]
|
|
|
|
|
|
ARCH_CLOUD_BASE = "https://geo.mirror.pkgbuild.com/images/latest"
|
|
|
|
# Image NixOS : release ÉPINGLÉE d'un tiers, jamais « latest ». Une release
|
|
# qui bouge changerait le système de base d'un déploiement à l'autre, et
|
|
# celle-ci n'est signée par personne — la somme ci-dessous est donc la nôtre,
|
|
# relevée une fois à la revue, et vérifiée à chaque téléchargement.
|
|
NIXOS_IMAGE_TAG = "2026.01.18-0057"
|
|
NIXOS_IMAGE_BASE = (
|
|
"https://github.com/cloudnull/nixos-openstack/releases/download"
|
|
)
|
|
NIXOS_IMAGE_SHA256 = (
|
|
"9c7df1786106b3bcbed514d5c16e05b735a42642d3de0c51a690e7d8aba4ebc4"
|
|
)
|
|
|
|
CLOUD_IMG_BASE = "https://cloud-images.ubuntu.com"
|
|
# Debian : cloud.debian.org est un redirecteur qui, selon le réseau, peut
|
|
# renvoyer vers un miroir injoignable. On essaie donc plusieurs bases dans
|
|
# l'ordre (le premier miroir qui répond gagne). Les deux acc.umu.se sont des
|
|
# miroirs Debian officiels de repli.
|
|
DEBIAN_CLOUD_BASES: tuple[str, ...] = (
|
|
"https://cloud.debian.org/images/cloud",
|
|
"https://gemmei.ftp.acc.umu.se/cdimage/cloud",
|
|
"https://laotzu.ftp.acc.umu.se/cdimage/cloud",
|
|
)
|
|
ALMALINUX_CLOUD_BASE = "https://repo.almalinux.org/almalinux"
|
|
OPENSUSE_BASE = "https://download.opensuse.org"
|
|
ROCKY_CLOUD_BASE = "https://dl.rockylinux.org/pub/rocky"
|
|
FEDORA_BASE = "https://download.fedoraproject.org/pub/fedora/linux/releases"
|
|
# Serveur MAÎTRE (pas de redirection MirrorManager) : repli fiable quand le
|
|
# redirecteur envoie sur un miroir incomplet (fréquent en déploiement
|
|
# PARALLÈLE : chaque requête peut tomber sur un miroir différent/désynchronisé).
|
|
FEDORA_BASE_MASTER = "https://dl.fedoraproject.org/pub/fedora/linux/releases"
|
|
# s390x est une architecture SECONDAIRE chez Fedora : ses images ne sont PAS
|
|
# sous /pub/fedora/linux/ mais sous /pub/fedora-secondary/, et seule la
|
|
# version courante y est construite.
|
|
FEDORA_SECONDARY = (
|
|
"https://download.fedoraproject.org/pub/fedora-secondary/releases"
|
|
)
|
|
FEDORA_SECONDARY_MASTER = (
|
|
"https://dl.fedoraproject.org/pub/fedora-secondary/releases"
|
|
)
|
|
|
|
# Répertoire de cache par défaut des images cloud (cohérent avec --disk-dir /
|
|
# --seed-dir). L'écriture y nécessite root : le déploiement tourne de toute
|
|
# façon sous sudo (virt-install). Surchargez avec --image-dir au besoin.
|
|
# Le pool par défaut de libvirt, et son sous-répertoire de cache d'images.
|
|
# Nommés ici et non dans argparse : sudo_facts() doit constater les droits du
|
|
# répertoire que le déploiement utilisera VRAIMENT, et deux écritures du même
|
|
# chemin divergent dès qu'on en change une.
|
|
DEFAULT_DISK_DIR = Path("/var/lib/libvirt/images")
|
|
DEFAULT_IMAGE_DIR = DEFAULT_DISK_DIR / "iso"
|
|
|
|
# Emplacements de la base osinfo-db (détection d'un --osinfo connu).
|
|
OSINFO_DB_DIRS: tuple[str, ...] = (
|
|
"/usr/share/osinfo/os",
|
|
"/usr/local/share/osinfo/os",
|
|
os.path.expanduser("~/.local/share/osinfo/os"),
|
|
)
|
|
|
|
|
|
def distro_arch(distro: str, arch: str) -> str:
|
|
"""Nom d'architecture attendu par la distro (Fedora utilise x86_64)."""
|
|
return ARCH_ALIASES.get(distro, {}).get(arch, arch)
|
|
|
|
|
|
def image_url(distro: str, code: str, arch: str, version: str) -> str:
|
|
"""URL directe (primaire) de l'image cloud (Ubuntu/Debian)."""
|
|
return image_candidates(distro, code, arch, version, dry_run=True)[0]
|
|
|
|
|
|
def image_candidates(
|
|
distro: str, code: str, arch: str, version: str, dry_run: bool = False
|
|
) -> list[str]:
|
|
"""Liste ordonnée d'URL candidates pour l'image cloud. Plusieurs miroirs
|
|
pour Debian ; une seule URL pour Ubuntu/Fedora."""
|
|
a = distro_arch(distro, arch)
|
|
if distro == "ubuntu":
|
|
# /releases/<version>/release/ : présent pour toutes les versions
|
|
# publiées (redirige vers l'image datée), contrairement à /current/
|
|
# qui disparaît quand une version intermédiaire devient EOL.
|
|
return [
|
|
f"{CLOUD_IMG_BASE}/releases/{version}/release/"
|
|
f"ubuntu-{version}-server-cloudimg-{a}.img"
|
|
]
|
|
if distro == "debian":
|
|
return [
|
|
f"{base}/{code}/latest/debian-{version}-genericcloud-{a}.qcow2"
|
|
for base in DEBIAN_CLOUD_BASES
|
|
]
|
|
if distro == "proxmox":
|
|
# C'est bien l'image DEBIAN qu'on télécharge : « proxmox-ve », posé
|
|
# dessus, en fait l'hyperviseur. Le miroir ne connaît que le numéro de
|
|
# Debian, jamais celui de Proxmox.
|
|
return image_candidates(
|
|
"debian", code, arch, PROXMOX_DEBIAN_BASE[version], dry_run
|
|
)
|
|
if distro == "fedora":
|
|
return [resolve_fedora_url(version, arch, dry_run)]
|
|
if distro == "almalinux":
|
|
# Contrairement à Fedora, AlmaLinux publie un lien « latest » STABLE
|
|
# par majeure et par architecture : aucun index HTML à analyser. On
|
|
# vise l'arbre de la MAJEURE (9/, 10/), qui suit la mineure courante —
|
|
# les répertoires mineurs périmés sont retirés du miroir.
|
|
return [
|
|
f"{ALMALINUX_CLOUD_BASE}/{version}/cloud/{a}/images/"
|
|
f"AlmaLinux-{version}-GenericCloud-latest.{a}.qcow2"
|
|
]
|
|
if distro == "rocky":
|
|
# Même principe : l'alias « .latest » suit le point release courant, et
|
|
# les précédents partent au vault — une URL figée casserait.
|
|
return [
|
|
f"{ROCKY_CLOUD_BASE}/{version}/images/{a}/"
|
|
f"Rocky-{version}-GenericCloud.latest.{a}.qcow2"
|
|
]
|
|
if distro == "opensuse":
|
|
# zsystems DOUBLE l'architecture dans le nom du fichier — irrégularité
|
|
# vérifiée dans l'index, pas déduite : « .s390x-s390x-Cloud » contre
|
|
# « .x86_64-Cloud ». Elle vaut pour les DEUX produits.
|
|
tag = f"{a}-{a}" if arch == "s390x" else a
|
|
if version == "tumbleweed":
|
|
# Tumbleweed sépare les architectures secondaires sous /ports/.
|
|
port = {"s390x": "ports/zsystems/", "arm64": "ports/aarch64/"}.get(
|
|
arch, ""
|
|
)
|
|
return [
|
|
f"{OPENSUSE_BASE}/{port}tumbleweed/appliances/"
|
|
f"openSUSE-Tumbleweed-Minimal-VM.{tag}-Cloud.qcow2"
|
|
]
|
|
# Leap, lui, publie TOUTES les architectures dans un seul répertoire :
|
|
# les chemins /ports/ équivalents rendent 404. x86_64, aarch64 et s390x
|
|
# y sont côte à côte (relevé dans l'index de 16.0, les trois en 200).
|
|
return [
|
|
f"{OPENSUSE_BASE}/distribution/leap/{version}/appliances/"
|
|
f"Leap-{version}-Minimal-VM.{tag}-Cloud.qcow2"
|
|
]
|
|
if distro == "arch":
|
|
# Rolling release : image « latest » officielle (cloud-init inclus).
|
|
return [f"{ARCH_CLOUD_BASE}/Arch-Linux-{a}-cloudimg.qcow2"]
|
|
if distro == "nixos":
|
|
# Un seul asset, sans architecture dans son nom : le tiers ne publie
|
|
# que x86_64, ce que ARCH_DISTRO_SUPPORT borne déjà.
|
|
return [f"{NIXOS_IMAGE_BASE}/{NIXOS_IMAGE_TAG}/nixos.qcow2"]
|
|
raise ValueError(f"URL indisponible pour la distro {distro!r}")
|
|
|
|
|
|
def resolve_fedora_url(version: str, arch: str, dry_run: bool) -> str:
|
|
"""Résout l'URL du qcow2 « Fedora Cloud Base Generic » depuis l'index
|
|
HTML des releases (Fedora ne publie pas de lien « latest »). ROBUSTE :
|
|
plusieurs tentatives sur le redirecteur puis repli sur le serveur maître
|
|
(dl.fedoraproject.org) — sinon, en déploiement parallèle, une requête
|
|
tombée sur un miroir incomplet faisait échouer la VM (« image introuvable »)
|
|
alors que l'image existe bel et bien."""
|
|
a = distro_arch("fedora", arch)
|
|
pattern = re.compile(
|
|
rf"Fedora-Cloud-Base-Generic-{version}-[0-9.]+\.{a}\.qcow2"
|
|
)
|
|
# Architecture secondaire (s390x, ppc64le) : autre arborescence.
|
|
primary, master = (
|
|
(FEDORA_SECONDARY, FEDORA_SECONDARY_MASTER)
|
|
if arch == "s390x"
|
|
else (FEDORA_BASE, FEDORA_BASE_MASTER)
|
|
)
|
|
if dry_run:
|
|
index = f"{primary}/{version}/Cloud/{a}/images/"
|
|
return index + f"Fedora-Cloud-Base-Generic-{version}-<build>.{a}.qcow2"
|
|
# On essaie le redirecteur (2 fois : chaque requête peut viser un miroir
|
|
# différent) puis le serveur maître (toujours complet).
|
|
bases = [primary, primary, master]
|
|
last_err = ""
|
|
for base in bases:
|
|
index = f"{base}/{version}/Cloud/{a}/images/"
|
|
try:
|
|
with urllib.request.urlopen(
|
|
index, timeout=30
|
|
) as resp: # noqa: S310
|
|
html = resp.read().decode(errors="replace")
|
|
except Exception as exc: # pragma: no cover - dépend du réseau
|
|
last_err = str(exc)
|
|
continue
|
|
names = sorted(set(pattern.findall(html)))
|
|
if names:
|
|
return index + names[-1]
|
|
sys.exit(
|
|
"Aucune image « Fedora-Cloud-Base-Generic » trouvée pour "
|
|
f"Fedora {version} ({a}) après plusieurs miroirs"
|
|
+ (f" (dernière erreur : {last_err})" if last_err else "")
|
|
)
|
|
|
|
|
|
def image_source_note(distro: str) -> tuple[str, str] | None:
|
|
"""(url, tag) quand l'image ne vient PAS de la distribution elle-même.
|
|
|
|
None pour tout le reste : on ne commente que ce qui sort de l'ordinaire,
|
|
et commenter chaque image noierait la seule qui le mérite.
|
|
|
|
NixOS ne publie aucune image cloud — sa page de téléchargement n'offre que
|
|
des ISO, des AMI Amazon et Docker. Celle-ci est donc reconstruite par un
|
|
tiers, à partir de nixpkgs. Ça se dit AVANT de déployer, pas après : le
|
|
système de base d'une VM n'est pas un détail d'implémentation.
|
|
"""
|
|
if distro != "nixos":
|
|
return None
|
|
return (
|
|
f"{NIXOS_IMAGE_BASE}/{NIXOS_IMAGE_TAG}/nixos.qcow2",
|
|
NIXOS_IMAGE_TAG,
|
|
)
|
|
|
|
|
|
def default_image_name(distro: str, code: str, arch: str, version: str) -> str:
|
|
"""Nom de fichier local pour le cache d'image."""
|
|
a = distro_arch(distro, arch)
|
|
if distro == "ubuntu":
|
|
return f"ubuntu-{version}-server-cloudimg-{a}.img"
|
|
if distro == "debian":
|
|
return f"debian-{version}-genericcloud-{a}.qcow2"
|
|
if distro == "proxmox":
|
|
# Le fichier téléchargé EST celui de Debian : lui donner le même nom
|
|
# de cache fait qu'un déploiement Debian 13 et un Proxmox se
|
|
# PARTAGENT le téléchargement (325 Mio) au lieu d'en faire deux. Sans
|
|
# cette branche, le repli de fin nommait l'image « fedora-cloud-9 ».
|
|
return f"debian-{PROXMOX_DEBIAN_BASE[version]}-genericcloud-{a}.qcow2"
|
|
if distro == "arch":
|
|
return f"arch-linux-{a}-cloudimg.qcow2"
|
|
if distro == "nixos":
|
|
# Le TAG est dans le nom : changer NIXOS_IMAGE_TAG retélécharge, au
|
|
# lieu de réutiliser une image qui ne répond plus à sa somme.
|
|
return f"nixos-{NIXOS_IMAGE_TAG}-{a}.qcow2"
|
|
if distro == "opensuse":
|
|
if version == "tumbleweed":
|
|
return f"opensuse-tumbleweed-minimal-vm-{a}.qcow2"
|
|
return f"opensuse-leap-{version}-minimal-vm-{a}.qcow2"
|
|
if distro in ("almalinux", "rocky"):
|
|
# « latest » est MUTABLE : le nom de cache porte donc la majeure, et
|
|
# une image déjà téléchargée sera réutilisée telle quelle. C'est voulu
|
|
# (reproductibilité d'un déploiement à l'autre) ; supprimer le fichier
|
|
# du cache suffit à repartir sur le build courant.
|
|
return f"{distro}-{version}-genericcloud-{a}.qcow2"
|
|
return f"fedora-cloud-{version}-{a}.qcow2"
|
|
|
|
|
|
def osinfo_known(short_id: str) -> bool:
|
|
"""Vrai si l'id osinfo (ex. « ubuntu26.04 ») figure dans osinfo-db."""
|
|
needle = f"<short-id>{short_id}</short-id>"
|
|
for base in OSINFO_DB_DIRS:
|
|
if not os.path.isdir(base):
|
|
continue
|
|
for root, _dirs, files in os.walk(base):
|
|
for fn in files:
|
|
if not fn.endswith(".xml"):
|
|
continue
|
|
try:
|
|
with open(
|
|
os.path.join(root, fn),
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
) as fh:
|
|
if needle in fh.read():
|
|
return True
|
|
except OSError:
|
|
continue
|
|
return False
|
|
|
|
|
|
def osinfo_arg(osinfo: str, distro: str = "") -> str:
|
|
"""Valeur --osinfo résiliente à un osinfo-db périmé. Si l'id est connu on
|
|
l'utilise ; sinon on retombe sur le DERNIER osinfo connu de la même distro
|
|
(meilleures perfs que « generic », pas d'avertissement) ; en dernier
|
|
recours, détection best-effort. Utile pour une version plus récente que la
|
|
base locale (ex. ubuntu26.04, fedora43+)."""
|
|
if "=" in osinfo or "," in osinfo:
|
|
return osinfo # forme avancée déjà fournie par l'utilisateur
|
|
if osinfo_known(osinfo):
|
|
return osinfo
|
|
# Repli 1 : dernier osinfo connu de la même distro (ordre de la table).
|
|
versions = DISTROS.get(distro, ({}, ""))[0]
|
|
known = [v[1] for v in versions.values() if osinfo_known(v[1])]
|
|
if known:
|
|
fallback = known[-1]
|
|
print(
|
|
f" osinfo « {osinfo} » inconnu (osinfo-db) — repli sur "
|
|
f"« {fallback} » (proche). « sudo apt upgrade osinfo-db » pour "
|
|
"l'entrée exacte."
|
|
)
|
|
return fallback
|
|
# Repli 2 : détection best-effort (évite l'échec, peut donner generic).
|
|
print(
|
|
f" osinfo « {osinfo} » inconnu de la base locale (osinfo-db) — repli "
|
|
"sur la détection auto (detect=on,require=off).\n"
|
|
" Astuce : « sudo apt upgrade osinfo-db » pour des métadonnées à jour."
|
|
)
|
|
return "detect=on,require=off"
|
|
|
|
|
|
def list_images() -> None:
|
|
"""Affiche toutes les distros/versions et leurs specs (--list-images)."""
|
|
print("Images cloud disponibles (distro / version / specs) :\n")
|
|
for distro, (versions, default) in DISTROS.items():
|
|
print(f" {distro} (défaut : {default})")
|
|
for v, (code, osinfo, ram, disk) in versions.items():
|
|
star = "*" if v == default else " "
|
|
note = (
|
|
""
|
|
if osinfo_known(osinfo)
|
|
else " [osinfo local absent → auto]"
|
|
)
|
|
print(
|
|
f" {star} {v:<7} {code:<10} osinfo={osinfo:<12} "
|
|
f"RAM≥{ram}Mo disque≥{disk}{note}"
|
|
)
|
|
print()
|
|
print("Exemple : deploy_qemu.py --distro debian --version 12 --name vm1")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Utilitaires d'exécution
|
|
def repertoire_a_root(chemin: Path) -> tuple | None:
|
|
"""(chemin, propriétaire, mode) si l'écriture y est refusée, sinon None.
|
|
|
|
L'écriture se TESTE, elle ne se déduit pas du mode : une ACL ou un groupe
|
|
peut l'accorder là où « drwxr-xr-x root root » la refuse en apparence, et
|
|
l'inverse existe aussi. Le propriétaire et le mode ne sont lus qu'ENSUITE,
|
|
pour dire au lecteur ce qui bloque.
|
|
"""
|
|
try:
|
|
if not chemin.is_dir() or os.access(chemin, os.W_OK):
|
|
return None
|
|
infos = chemin.stat()
|
|
except OSError:
|
|
return None
|
|
try:
|
|
utilisateur = pwd.getpwuid(infos.st_uid).pw_name
|
|
except (KeyError, OSError):
|
|
utilisateur = str(infos.st_uid)
|
|
try:
|
|
groupe = grp.getgrgid(infos.st_gid).gr_name
|
|
except (KeyError, OSError):
|
|
groupe = str(infos.st_gid)
|
|
return (
|
|
str(chemin),
|
|
f"{utilisateur}:{groupe}",
|
|
stat_mod.filemode(infos.st_mode),
|
|
)
|
|
|
|
|
|
def sudo_facts(disk_dir: Path | None = None, image_dir: Path | None = None):
|
|
"""Pourquoi root est nécessaire ici : des FAITS, constatés sur la machine.
|
|
|
|
Rend une liste de couples (clé, valeurs) et non des phrases : ce script
|
|
les dit en français, le menu TODO les traduit, et la vérification ne vit
|
|
qu'à un endroit.
|
|
|
|
Deux clés. « ecriture » : un répertoire où le déploiement doit écrire et
|
|
ne peut pas — c'est le vrai motif, et il tient au RÉPERTOIRE, pas à
|
|
libvirt. « socket » : qemu:///system répond-il sans sudo, ce qui dit ce
|
|
que le groupe libvirt couvre RÉELLEMENT — appartenir au groupe et en
|
|
disposer dans la session courante sont deux choses.
|
|
|
|
Le tout parce que sudo demande un mot de passe sans jamais dire ce qu'il
|
|
sert à faire : l'invite tombe entre deux lignes de journal, et on la subit
|
|
sans savoir si elle porte sur libvirt, sur un paquet ou sur un fichier.
|
|
"""
|
|
faits = []
|
|
vus = set()
|
|
for chemin in (
|
|
disk_dir or DEFAULT_DISK_DIR,
|
|
image_dir or DEFAULT_IMAGE_DIR,
|
|
):
|
|
bloque = repertoire_a_root(Path(chemin))
|
|
if bloque and bloque[0] not in vus:
|
|
vus.add(bloque[0])
|
|
faits.append(("ecriture", bloque))
|
|
# Trois valeurs et non deux : « absent » quand virsh n'est pas là. Rendre
|
|
# « non » y ferait accuser le groupe libvirt d'un défaut qui n'est pas le
|
|
# sien — il n'y a simplement rien à joindre encore.
|
|
if shutil.which("virsh") is None:
|
|
faits.append(("socket", ("absent",)))
|
|
else:
|
|
faits.append(("socket", ("ok" if libvirt_ready(False) else "non",)))
|
|
return faits
|
|
|
|
|
|
def sudo_lignes(faits) -> list[str]:
|
|
"""Les faits mis en phrases, en français, pour ce script."""
|
|
lignes = []
|
|
for cle, valeurs in faits:
|
|
if cle == "ecriture":
|
|
chemin, proprio, mode = valeurs
|
|
lignes.append(
|
|
f"écrire dans {chemin} — vérifié : {proprio} {mode},"
|
|
" écriture refusée à cet utilisateur"
|
|
)
|
|
if any(cle == "ecriture" for cle, _v in faits):
|
|
lignes.append(
|
|
"le groupe libvirt ouvre la socket qemu:///system, pas ce"
|
|
" répertoire"
|
|
)
|
|
else:
|
|
lignes.append("les gestes système du script (service, groupe)")
|
|
for cle, valeurs in faits:
|
|
if cle == "socket" and valeurs[0] == "non":
|
|
lignes.append(
|
|
"la socket libvirt ne répond pas non plus sans sudo :"
|
|
" groupe absent de cette session, ou libvirt pas démarré"
|
|
)
|
|
return lignes
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
class Runner:
|
|
"""Exécute (ou affiche, en dry-run) les commandes, avec sudo au besoin."""
|
|
|
|
def __init__(self, use_sudo: bool, dry_run: bool) -> None:
|
|
self.use_sudo = use_sudo
|
|
self.dry_run = dry_run
|
|
# L'explication n'est due qu'UNE fois : sudo garde sa réponse quelques
|
|
# minutes, et la répéter à chaque étape noierait le journal.
|
|
self._sudo_dit = False
|
|
|
|
def _annoncer_sudo(self) -> None:
|
|
"""Dit pourquoi root, AVANT que sudo ne réclame le mot de passe.
|
|
|
|
Avant et non après : sudo n'explique jamais ce qu'il sert à faire, et
|
|
un mot de passe tapé sans savoir ce qu'il autorise est donné à
|
|
l'aveugle. Les raisons sont constatées sur la machine, pas affirmées —
|
|
voir sudo_facts().
|
|
"""
|
|
if self._sudo_dit:
|
|
return
|
|
self._sudo_dit = True
|
|
print("\n🔑 sudo va demander votre mot de passe, pour :")
|
|
for ligne in sudo_lignes(sudo_facts()):
|
|
print(f" {ligne}")
|
|
|
|
def run(
|
|
self,
|
|
cmd: list[str],
|
|
*,
|
|
privileged: bool = False,
|
|
check: bool = True,
|
|
capture: bool = False,
|
|
):
|
|
"""Lance la commande. `capture` rend (code, sortie) au lieu de sortir.
|
|
|
|
Sans `capture`, un échec termine le programme : c'est le comportement
|
|
voulu partout où il n'y a rien à rattraper. Avec, l'appelant décide —
|
|
seul l'appel qui SAIT réessayer autrement doit le demander.
|
|
"""
|
|
if privileged and self.use_sudo:
|
|
self._annoncer_sudo()
|
|
cmd = ["sudo", *cmd]
|
|
printable = " ".join(cmd)
|
|
if self.dry_run:
|
|
print(f" [dry-run] {printable}")
|
|
return (0, "") if capture else None
|
|
print(f" $ {printable}")
|
|
if capture:
|
|
# La sortie est réaffichée telle quelle : le journal garde tout,
|
|
# et l'appelant peut lire ce que l'outil a dit.
|
|
res = subprocess.run(
|
|
cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
)
|
|
if res.stdout:
|
|
print(res.stdout, end="")
|
|
return res.returncode, res.stdout or ""
|
|
try:
|
|
subprocess.run(cmd, check=check)
|
|
except subprocess.CalledProcessError as exc:
|
|
# Sortie propre plutôt qu'une trace Python illisible.
|
|
sys.exit(
|
|
f"\nÉchec de la commande (code {exc.returncode}) :\n"
|
|
f" {printable}"
|
|
)
|
|
|
|
|
|
def need_tool(name: str) -> None:
|
|
if shutil.which(name) is None:
|
|
sys.exit(f"Erreur : outil requis introuvable dans le PATH : {name!r}")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Détection et installation des dépendances système
|
|
# --------------------------------------------------------------------------- #
|
|
# Outils indispensables au déploiement complet (le mode --download-only n'en
|
|
# requiert aucun).
|
|
# URI libvirt visée par TOUS les clients (virsh, virt-install). À ne jamais
|
|
# laisser implicite : pour un utilisateur non root, libvirt choisit
|
|
# « qemu:///session », où le réseau « default » N'EXISTE PAS — virt-install
|
|
# échoue alors sur « --network network=default » de façon incompréhensible.
|
|
# Appartenir au groupe libvirt donne le DROIT d'accéder à qemu:///system mais
|
|
# ne change PAS l'URI par défaut : il faut l'imposer.
|
|
LIBVIRT_URI = "qemu:///system"
|
|
|
|
REQUIRED_TOOLS: tuple[str, ...] = ("qemu-img", "virt-install", "virsh")
|
|
# Pour construire le seed cloud-init il faut AU MOINS un de ces outils.
|
|
SEED_TOOLS: tuple[str, ...] = ("cloud-localds", "genisoimage")
|
|
|
|
# outil -> nom de paquet, selon le gestionnaire de paquets détecté.
|
|
TOOL_PACKAGES: dict[str, dict[str, str]] = {
|
|
"apt": {
|
|
"qemu-img": "qemu-utils",
|
|
"virt-install": "virtinst",
|
|
"virsh": "libvirt-clients",
|
|
"cloud-localds": "cloud-image-utils",
|
|
"genisoimage": "genisoimage",
|
|
"openssl": "openssl",
|
|
},
|
|
"dnf": {
|
|
"qemu-img": "qemu-img",
|
|
"virt-install": "virt-install",
|
|
"virsh": "libvirt-client",
|
|
"cloud-localds": "cloud-utils",
|
|
"genisoimage": "genisoimage",
|
|
"openssl": "openssl",
|
|
},
|
|
"pacman": {
|
|
"qemu-img": "qemu-img",
|
|
"virt-install": "virt-install",
|
|
"virsh": "libvirt",
|
|
"cloud-localds": "cloud-image-utils",
|
|
"genisoimage": "cdrtools",
|
|
"openssl": "openssl",
|
|
},
|
|
"zypper": {
|
|
"qemu-img": "qemu-tools",
|
|
"virt-install": "virt-install",
|
|
"virsh": "libvirt-client",
|
|
"cloud-localds": "cloud-utils-cloud-localds",
|
|
"genisoimage": "genisoimage",
|
|
"openssl": "openssl",
|
|
},
|
|
"brew": {
|
|
"qemu-img": "qemu",
|
|
"virt-install": "virt-manager",
|
|
"virsh": "libvirt",
|
|
"cloud-localds": "cdrtools",
|
|
"genisoimage": "cdrtools",
|
|
"openssl": "openssl",
|
|
},
|
|
}
|
|
|
|
# Paquets fournissant le démon libvirt + l'émulateur QEMU système. Ils sont
|
|
# INDISPENSABLES pour exécuter la VM : virsh/virt-install (clients) seuls ne
|
|
# créent pas le socket /var/run/libvirt/libvirt-sock.
|
|
# On inclut le firmware UEFI (OVMF/edk2) : le boot par défaut est UEFI
|
|
# (indispensable pour Debian 13+ ; les images récentes n'ont plus de BIOS).
|
|
DAEMON_PACKAGES: dict[str, list[str]] = {
|
|
"apt": ["libvirt-daemon-system", "qemu-system-x86", "ovmf"],
|
|
"dnf": ["libvirt-daemon-kvm", "qemu-kvm", "edk2-ovmf"],
|
|
"pacman": ["libvirt", "qemu-desktop", "dnsmasq", "edk2-ovmf"],
|
|
"zypper": [
|
|
"libvirt-daemon",
|
|
"libvirt-daemon-qemu",
|
|
"qemu-kvm",
|
|
"qemu-ovmf-x86_64",
|
|
],
|
|
"brew": [],
|
|
}
|
|
|
|
# Émulateurs QEMU système pour architectures non-x86, requis pour --arch
|
|
# <arch> sur un hôte d'architecture différente (émulation TCG). Par arch puis
|
|
# par gestionnaire de paquets. NB apt : qemu-system-misc ne contient PAS s390x
|
|
# (alpha/avr/… seulement) -> paquet dédié qemu-system-s390x ; qemu-system-arm
|
|
# fournit qemu-system-aarch64 ; l'arm64 exige aussi le firmware UEFI AAVMF.
|
|
EMULATOR_PACKAGES: dict[str, dict[str, list[str]]] = {
|
|
"s390x": {
|
|
"apt": ["qemu-system-s390x"],
|
|
"dnf": ["qemu-system-s390x"],
|
|
"pacman": ["qemu-emulators-full"],
|
|
"zypper": ["qemu-s390"],
|
|
"brew": [],
|
|
},
|
|
"arm64": {
|
|
"apt": ["qemu-system-arm", "qemu-efi-aarch64"],
|
|
"dnf": ["qemu-system-aarch64", "edk2-aarch64"],
|
|
"pacman": ["qemu-emulators-full", "edk2-aarch64"],
|
|
"zypper": ["qemu-arm", "qemu-uefi-aarch64"],
|
|
"brew": [],
|
|
},
|
|
}
|
|
|
|
# Binaire émulateur par arch (détection de présence).
|
|
EMULATOR_BINARY: dict[str, str] = {
|
|
"s390x": "qemu-system-s390x",
|
|
"arm64": "qemu-system-aarch64",
|
|
}
|
|
|
|
# Firmwares UEFI AAVMF possibles (arm64) : au moins un doit exister.
|
|
AARCH64_FIRMWARE_PATHS: tuple[str, ...] = (
|
|
"/usr/share/AAVMF/AAVMF_CODE.fd",
|
|
"/usr/share/AAVMF/AAVMF_CODE.no-secboot.fd",
|
|
"/usr/share/edk2/aarch64/QEMU_EFI.fd",
|
|
"/usr/share/edk2/aarch64/QEMU_EFI-silent.fd",
|
|
"/usr/share/qemu-efi-aarch64/QEMU_EFI.fd",
|
|
"/usr/share/edk2-armvirt/aarch64/QEMU_EFI.fd",
|
|
)
|
|
|
|
# Gestionnaires de paquets, dans l'ordre de préférence :
|
|
# (clé TOOL_PACKAGES, binaire à détecter, commande d'installation, sudo, refresh)
|
|
PKG_MANAGERS: tuple[
|
|
tuple[str, str, list[str], bool, list[str] | None], ...
|
|
] = (
|
|
(
|
|
"apt",
|
|
"apt-get",
|
|
["apt-get", "install", "-y"],
|
|
True,
|
|
["apt-get", "update"],
|
|
),
|
|
("dnf", "dnf", ["dnf", "install", "-y"], True, None),
|
|
(
|
|
"pacman",
|
|
"pacman",
|
|
["pacman", "-S", "--needed", "--noconfirm"],
|
|
True,
|
|
None,
|
|
),
|
|
(
|
|
"zypper",
|
|
"zypper",
|
|
["zypper", "--non-interactive", "install"],
|
|
True,
|
|
None,
|
|
),
|
|
("brew", "brew", ["brew", "install"], False, None),
|
|
)
|
|
|
|
|
|
def detect_pkg_manager() -> (
|
|
tuple[str, list[str], bool, list[str] | None] | None
|
|
):
|
|
"""Retourne (clé, cmd d'install, use_sudo, cmd de refresh) ou None."""
|
|
for key, binary, install_cmd, use_sudo, refresh in PKG_MANAGERS:
|
|
if shutil.which(binary):
|
|
return key, install_cmd, use_sudo, refresh
|
|
return None
|
|
|
|
|
|
def missing_tools() -> list[str]:
|
|
"""Binaires requis absents du PATH (dont un outil de seed si aucun présent)."""
|
|
missing = [t for t in REQUIRED_TOOLS if shutil.which(t) is None]
|
|
if not any(shutil.which(t) for t in SEED_TOOLS):
|
|
missing.append(SEED_TOOLS[0]) # on installera cloud-localds
|
|
return missing
|
|
|
|
|
|
def prompt_yes_no(question: str, default: bool = True) -> bool:
|
|
"""Question oui/non. Lit /dev/tty pour rester visible même si stdout est
|
|
redirigé (cas d'un lancement depuis le menu todo)."""
|
|
suffix = " [O/n] " if default else " [o/N] "
|
|
prompt = question + suffix
|
|
try:
|
|
with open("/dev/tty", "r+") as tty:
|
|
tty.write(prompt)
|
|
tty.flush()
|
|
ans = tty.readline().strip().lower()
|
|
except OSError:
|
|
try:
|
|
ans = input(prompt).strip().lower()
|
|
except EOFError:
|
|
return default
|
|
if not ans:
|
|
return default
|
|
return ans in ("o", "oui", "y", "yes")
|
|
|
|
|
|
def daemon_missing() -> bool:
|
|
"""Vrai si le démon libvirt OU l'émulateur QEMU système est absent."""
|
|
libvirtd = shutil.which("libvirtd") or any(
|
|
os.path.exists(p) for p in ("/usr/sbin/libvirtd", "/usr/bin/libvirtd")
|
|
)
|
|
emulator = (
|
|
shutil.which("qemu-system-x86_64")
|
|
or shutil.which("qemu-system-x86")
|
|
or shutil.which("kvm")
|
|
)
|
|
return not (libvirtd and emulator)
|
|
|
|
|
|
def libvirt_ready(use_sudo: bool) -> bool:
|
|
"""Vrai si l'hyperviseur qemu:///system répond (démon libvirt démarré)."""
|
|
if shutil.which("virsh") is None:
|
|
return False
|
|
cmd = (["sudo"] if use_sudo else []) + [
|
|
"virsh",
|
|
"-c",
|
|
"qemu:///system",
|
|
"version",
|
|
]
|
|
try:
|
|
res = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
|
return res.returncode == 0
|
|
except (OSError, subprocess.SubprocessError):
|
|
return False
|
|
|
|
|
|
def ensure_libvirt_service(runner: Runner) -> None:
|
|
"""S'assure que le démon libvirt tourne ; sinon le démarre puis vérifie."""
|
|
if libvirt_ready(runner.use_sudo):
|
|
return
|
|
if shutil.which("systemctl"):
|
|
print(
|
|
" Démarrage du démon libvirt"
|
|
" (systemctl enable --now libvirtd)…"
|
|
)
|
|
runner.run(
|
|
["systemctl", "enable", "--now", "libvirtd"],
|
|
privileged=True,
|
|
check=False,
|
|
)
|
|
runner.run(
|
|
["systemctl", "start", "libvirtd.socket"],
|
|
privileged=True,
|
|
check=False,
|
|
)
|
|
# Le socket peut mettre un instant à apparaître.
|
|
for _ in range(10):
|
|
if libvirt_ready(runner.use_sudo):
|
|
return
|
|
time.sleep(1)
|
|
sys.exit(
|
|
"Erreur : impossible de se connecter à l'hyperviseur libvirt"
|
|
" (/var/run/libvirt/libvirt-sock).\n"
|
|
" Le démon libvirtd n'est pas démarré. Essayez :\n"
|
|
" sudo systemctl enable --now libvirtd\n"
|
|
" puis vérifiez : sudo virsh -c qemu:///system version"
|
|
)
|
|
|
|
|
|
def invoking_user() -> str:
|
|
"""Utilisateur réel derrière l'appel, même sous sudo."""
|
|
return os.environ.get("SUDO_USER") or getpass.getuser()
|
|
|
|
|
|
def ensure_libvirt_group(runner: Runner) -> bool:
|
|
"""Ajoute l'utilisateur au groupe libvirt. Renvoie True s'il l'était déjà.
|
|
|
|
SANS ce groupe, les clients libvirt d'un utilisateur non root retombent sur
|
|
« qemu:///session », où le réseau « default » N'EXISTE PAS : virt-install
|
|
échoue alors sur « --network network=default » alors que tous les paquets
|
|
sont pourtant installés. C'est le trou qui manquait au profil
|
|
« ERPLibre Déploiement » : il installait QEMU sans jamais rendre
|
|
qemu:///system accessible.
|
|
"""
|
|
user = invoking_user()
|
|
if user == "root":
|
|
return True
|
|
lst_group = []
|
|
for group in ("libvirt", "kvm"):
|
|
try:
|
|
grp.getgrnam(group)
|
|
except KeyError:
|
|
continue # groupe absent sur cette distro
|
|
try:
|
|
if user in grp.getgrnam(group).gr_mem:
|
|
continue
|
|
except KeyError:
|
|
pass
|
|
lst_group.append(group)
|
|
if not lst_group:
|
|
print(f" Utilisateur « {user} » déjà dans les groupes libvirt/kvm.")
|
|
return True
|
|
for group in lst_group:
|
|
print(f" Ajout de « {user} » au groupe « {group} »…")
|
|
runner.run(
|
|
["usermod", "-aG", group, user], privileged=True, check=False
|
|
)
|
|
return False
|
|
|
|
|
|
def kernel_modules_stale() -> str:
|
|
"""Renvoie un message si les modules du noyau EN COURS ont disparu.
|
|
|
|
Sur une distro roulante, « make install_os » met le noyau à jour et le
|
|
gestionnaire de paquets SUPPRIME /lib/modules/<noyau en cours>. Tant qu'on
|
|
n'a pas redémarré, « modprobe bridge » échoue et libvirt ne peut pas créer
|
|
virbr0 : « Unable to create bridge virbr0: Package not installed ». Aucun
|
|
paquet ne corrige ça, seul un redémarrage le fait — d'où ce diagnostic.
|
|
"""
|
|
if not shutil.which("uname"):
|
|
return ""
|
|
running = os.uname().release
|
|
path_module = f"/lib/modules/{running}"
|
|
if os.path.isdir(path_module):
|
|
return ""
|
|
return (
|
|
f"Le noyau en cours ({running}) n'a plus ses modules "
|
|
f"({path_module} absent) : il a été mis à jour depuis le démarrage.\n"
|
|
" libvirt ne pourra pas créer le pont virbr0 tant que la machine\n"
|
|
" n'aura pas REDÉMARRÉ. Redémarrez, puis relancez --setup-host."
|
|
)
|
|
|
|
|
|
def ensure_ssh_key(runner: Runner) -> None:
|
|
"""Garantit que l'utilisateur possède une clé publique SSH.
|
|
|
|
Sans clé, cloud-init ne peut en injecter aucune dans la VM créée : elle
|
|
démarre sans accès SSH et l'orchestrateur ne peut plus vérifier son état.
|
|
On en génère donc une (ed25519, sans passphrase) quand il n'y en a pas.
|
|
|
|
La clé doit appartenir à l'UTILISATEUR, pas à root : sous sudo, « ~ »
|
|
désigne /root et la clé y serait inutilisable.
|
|
"""
|
|
user = invoking_user()
|
|
home = os.path.expanduser(f"~{user}")
|
|
ssh_dir = os.path.join(home, ".ssh")
|
|
for name in ("id_ed25519.pub", "id_rsa.pub"):
|
|
path_pub = os.path.join(ssh_dir, name)
|
|
if os.path.exists(path_pub):
|
|
print(f" Clé SSH déjà présente : {path_pub}")
|
|
return
|
|
|
|
path_key = os.path.join(ssh_dir, "id_ed25519")
|
|
print(f" Génération d'une clé SSH ed25519 pour « {user} »…")
|
|
# sudo -u : on exécute EN TANT QUE l'utilisateur, pour que la clé et le
|
|
# répertoire lui appartiennent même quand --setup-host tourne sous sudo.
|
|
prefix = (
|
|
["sudo", "-u", user] if os.geteuid() == 0 and user != "root" else []
|
|
)
|
|
runner.run(prefix + ["mkdir", "-p", "-m", "700", ssh_dir], check=False)
|
|
runner.run(
|
|
prefix
|
|
+ [
|
|
"ssh-keygen",
|
|
"-t",
|
|
"ed25519",
|
|
"-N",
|
|
"",
|
|
"-q",
|
|
"-f",
|
|
path_key,
|
|
"-C",
|
|
f"erplibre-deploy@{socket.gethostname()}",
|
|
],
|
|
check=False,
|
|
)
|
|
if not runner.dry_run and os.path.exists(f"{path_key}.pub"):
|
|
print(f" Clé créée : {path_key}.pub")
|
|
|
|
|
|
def schedule_reboot(runner: Runner) -> None:
|
|
"""Programme un redémarrage DIFFÉRÉ et détaché de la session courante.
|
|
|
|
Un « systemctl reboot » immédiat tuerait le SSH de l'installation, et
|
|
l'orchestrateur compterait la VM en échec alors que tout s'est bien passé.
|
|
On laisse donc quelques secondes pour que la commande distante rende la
|
|
main proprement.
|
|
"""
|
|
if shutil.which("systemd-run"):
|
|
runner.run(
|
|
["systemd-run", "--on-active=5", "systemctl", "reboot"],
|
|
privileged=True,
|
|
check=False,
|
|
)
|
|
return
|
|
runner.run(["shutdown", "-r", "+1"], privileged=True, check=False)
|
|
|
|
|
|
def setup_host(
|
|
runner: Runner,
|
|
assume_yes: bool,
|
|
no_install: bool,
|
|
reboot_if_needed: bool = False,
|
|
assume_yes_reboot: bool = False,
|
|
) -> None:
|
|
"""Prépare l'hôte à faire tourner des VM : paquets, démon, groupe, réseau.
|
|
|
|
Point d'entrée unique du profil d'installation « ERPLibre Déploiement ».
|
|
Il réutilise les mêmes fonctions que le déploiement, donc les noms de
|
|
paquets restent définis à UN SEUL endroit (TOOL_PACKAGES /
|
|
DAEMON_PACKAGES) et restent valides pour apt, dnf, pacman, zypper et brew.
|
|
"""
|
|
print("\n== Préparation de l'hôte pour QEMU/libvirt ==")
|
|
# ensure_tools installe les clients ET, si le démon manque, les paquets de
|
|
# DAEMON_PACKAGES (qemu système + libvirt + firmware UEFI), puis démarre le
|
|
# service. ensure_emulator ne sert QU'À l'émulation croisée (arm64 sur x86)
|
|
# et n'a pas de clé pour l'architecture hôte.
|
|
ensure_tools(runner, assume_yes, no_install, force_daemon=True)
|
|
ensure_libvirt_service(runner)
|
|
already_in_group = ensure_libvirt_group(runner)
|
|
ensure_ssh_key(runner)
|
|
# Diagnostiqué AVANT le réseau : sans les modules du noyau en cours, le
|
|
# pont virbr0 est impossible et l'erreur de virsh est indéchiffrable.
|
|
stale = kernel_modules_stale()
|
|
if stale:
|
|
print(f"\n⚠ {stale}")
|
|
ensure_network("default", runner)
|
|
|
|
if runner.dry_run:
|
|
print("\n[dry-run] Rien n'a été modifié, vérification ignorée.")
|
|
return
|
|
|
|
print("\n== Vérification ==")
|
|
ok = libvirt_ready(runner.use_sudo)
|
|
print(f" hyperviseur qemu:///system : {'OK' if ok else 'INJOIGNABLE'}")
|
|
active, autostart = network_state("default", runner.use_sudo)
|
|
# Le sous-réseau est dit ICI parce que c'est de lui que les VM tireront
|
|
# leur adresse : déplacé, il n'est plus celui que la documentation de
|
|
# libvirt fait attendre.
|
|
cidr = network_cidr("default", runner.use_sudo)
|
|
print(
|
|
f" réseau libvirt « default » : "
|
|
f"{'actif' if active else 'INACTIF'}"
|
|
f" / {'autostart' if autostart else 'PAS autostart'}"
|
|
+ (f" / {cidr}" if cidr else "")
|
|
)
|
|
if not active and stale:
|
|
# Le réseau est déjà « autostart » : après le redémarrage, libvirt le
|
|
# monte tout seul avec les modules du nouveau noyau. Un seul reboot
|
|
# suffit donc à rendre l'hôte utilisable, sans repasser par ici.
|
|
if reboot_if_needed:
|
|
# Le consentement à installer des paquets ne vaut PAS consentement
|
|
# à redémarrer : assume_yes couvre pacman, jamais la machine de
|
|
# celui qui l'a tapé. Une provision sans personne devant l'écran
|
|
# passe --assume-yes-reboot, qui dit explicitement l'autre chose.
|
|
if assume_yes_reboot or prompt_yes_no(
|
|
"\n↻ Redémarrer MAINTENANT ? C'est la seule façon de"
|
|
" retrouver les modules du noyau. Au retour, le réseau"
|
|
" « default » démarrera seul (autostart déjà actif).",
|
|
default=False,
|
|
):
|
|
print("\n↻ Redémarrage programmé (dans quelques secondes).")
|
|
schedule_reboot(runner)
|
|
return
|
|
sys.exit(
|
|
"Erreur : l'hôte n'est pas prêt, redémarrage refusé.\n"
|
|
f" {stale}\n"
|
|
" Redémarrez quand vous le voudrez, puis relancez"
|
|
" --setup-host."
|
|
)
|
|
sys.exit(f"Erreur : l'hôte n'est pas prêt.\n {stale}")
|
|
|
|
if not (ok and active):
|
|
sys.exit(
|
|
"Erreur : l'hôte n'est pas prêt.\n"
|
|
+ (f" {stale}\n" if stale else "")
|
|
+ " Sinon vérifiez :\n"
|
|
" sudo systemctl enable --now libvirtd\n"
|
|
" sudo virsh -c qemu:///system net-start default"
|
|
)
|
|
if not already_in_group:
|
|
print(
|
|
f"\n⚠ « {invoking_user()} » vient d'être ajouté au groupe libvirt."
|
|
" Les groupes ne s'appliquent qu'aux NOUVELLES sessions :"
|
|
" reconnectez-vous (ou « newgrp libvirt »), sinon virt-install"
|
|
" continuera d'utiliser qemu:///session et le réseau « default »"
|
|
" restera introuvable."
|
|
)
|
|
print("\nHôte prêt.")
|
|
|
|
|
|
def ensure_tools(
|
|
runner: Runner,
|
|
assume_yes: bool,
|
|
no_install: bool,
|
|
force_daemon: bool = False,
|
|
) -> None:
|
|
"""Vérifie outils, démon libvirt et émulateur ; installe/démarre ce qui
|
|
manque, puis vérifie la connexion à l'hyperviseur.
|
|
|
|
`force_daemon` réinstalle DAEMON_PACKAGES même quand le démon répond déjà.
|
|
Vécu sur Arch : libvirt était présent (posé par un ancien one-liner qui ne
|
|
listait pas dnsmasq), donc daemon_missing() renvoyait False et dnsmasq
|
|
n'était jamais installé -> « Failed to start network default ». Les
|
|
gestionnaires de paquets ignorent ce qui est déjà là : c'est bon marché.
|
|
"""
|
|
missing = missing_tools()
|
|
need_daemon = daemon_missing() or force_daemon
|
|
|
|
# Tout est là et l'hyperviseur répond : rien à faire.
|
|
if not missing and not need_daemon and libvirt_ready(runner.use_sudo):
|
|
return
|
|
|
|
pm = detect_pkg_manager()
|
|
if pm is None:
|
|
sys.exit(
|
|
" Gestionnaire de paquets non reconnu "
|
|
"(apt/dnf/pacman/zypper/brew).\n"
|
|
" Installez manuellement : " + ", ".join(missing)
|
|
)
|
|
pm_key, install_cmd, use_sudo, refresh = pm
|
|
|
|
pkg_map = TOOL_PACKAGES[pm_key]
|
|
packages = [pkg_map.get(t, t) for t in missing]
|
|
if need_daemon:
|
|
packages += DAEMON_PACKAGES.get(pm_key, [])
|
|
packages = list(dict.fromkeys(packages)) # dédoublonne, ordre gardé
|
|
|
|
if packages:
|
|
label = list(missing)
|
|
if need_daemon:
|
|
label.append("démon libvirt / émulateur QEMU")
|
|
print(" Composants manquants : " + ", ".join(label))
|
|
|
|
if no_install:
|
|
sys.exit(
|
|
" Installation automatique désactivée (--no-install-deps).\n"
|
|
" Installez manuellement : " + " ".join(packages)
|
|
)
|
|
|
|
full_cmd = install_cmd + packages
|
|
printable = " ".join(full_cmd)
|
|
if use_sudo and runner.use_sudo:
|
|
printable = "sudo " + printable
|
|
|
|
print(f" Gestionnaire détecté : {pm_key}")
|
|
print(f" Commande d'installation : {printable}")
|
|
|
|
if not assume_yes and not prompt_yes_no(
|
|
" Installer ces dépendances maintenant ?"
|
|
):
|
|
sys.exit(
|
|
" Installation refusée.\n Commande manuelle : " + printable
|
|
)
|
|
|
|
if refresh:
|
|
runner.run(refresh, privileged=use_sudo, check=False)
|
|
runner.run(full_cmd, privileged=use_sudo)
|
|
|
|
still = missing_tools()
|
|
# Repli : si seul l'outil de seed manque encore (le nom du paquet
|
|
# cloud-localds varie selon la distro), tente genisoimage.
|
|
if still == [SEED_TOOLS[0]]:
|
|
alt = pkg_map.get("genisoimage", "genisoimage")
|
|
print(f" Repli sur {alt} (autre outil de seed cloud-init)…")
|
|
runner.run(install_cmd + [alt], privileged=use_sudo, check=False)
|
|
still = missing_tools()
|
|
if still:
|
|
sys.exit(
|
|
" Outils toujours absents après installation : "
|
|
+ ", ".join(still)
|
|
+ f"\n Essayez manuellement : {printable}"
|
|
)
|
|
print(" Dépendances installées avec succès.")
|
|
|
|
# Démarre le démon (si nécessaire) et vérifie l'accès à l'hyperviseur.
|
|
ensure_libvirt_service(runner)
|
|
|
|
|
|
def emulator_ready(arch: str) -> bool:
|
|
"""Vrai si l'émulateur (et, pour arm64, un firmware UEFI AAVMF) est là."""
|
|
if shutil.which(EMULATOR_BINARY[arch]) is None:
|
|
return False
|
|
if arch == "arm64":
|
|
return any(os.path.exists(p) for p in AARCH64_FIRMWARE_PATHS)
|
|
return True
|
|
|
|
|
|
def ensure_emulator(
|
|
arch: str, runner: Runner, assume_yes: bool, no_install: bool
|
|
) -> None:
|
|
"""Vérifie l'émulateur QEMU système pour `arch` (et le firmware UEFI pour
|
|
arm64), requis quand on émule une architecture différente de l'hôte ;
|
|
l'installe au besoin via le gestionnaire de paquets."""
|
|
binary = EMULATOR_BINARY[arch]
|
|
if emulator_ready(arch):
|
|
return
|
|
pm = detect_pkg_manager()
|
|
if pm is None:
|
|
sys.exit(
|
|
f" Émulateur {arch} introuvable ({binary}) et gestionnaire de "
|
|
"paquets non reconnu.\n Installez-le manuellement."
|
|
)
|
|
pm_key, install_cmd, use_sudo, refresh = pm
|
|
packages = EMULATOR_PACKAGES.get(arch, {}).get(pm_key, [])
|
|
if not packages:
|
|
sys.exit(
|
|
f" Émulateur {arch} introuvable ({binary}) : installez le paquet "
|
|
"QEMU système correspondant de votre distribution "
|
|
"(+ firmware UEFI pour arm64)."
|
|
)
|
|
full_cmd = install_cmd + packages
|
|
printable = ("sudo " if use_sudo and runner.use_sudo else "") + " ".join(
|
|
full_cmd
|
|
)
|
|
print(f" Émulateur {arch} manquant ({binary}).")
|
|
print(f" Commande d'installation : {printable}")
|
|
if no_install:
|
|
sys.exit(
|
|
" Installation automatique désactivée (--no-install-deps).\n"
|
|
" Installez manuellement : " + printable
|
|
)
|
|
if not assume_yes and not prompt_yes_no(
|
|
f" Installer l'émulateur {arch} maintenant ?"
|
|
):
|
|
sys.exit(" Installation refusée.\n Commande manuelle : " + printable)
|
|
if refresh:
|
|
runner.run(refresh, privileged=use_sudo, check=False)
|
|
runner.run(full_cmd, privileged=use_sudo)
|
|
if not emulator_ready(arch):
|
|
sys.exit(
|
|
f" Émulateur {arch} ({binary}) ou firmware toujours absent après "
|
|
f"installation.\n Essayez manuellement : {printable}"
|
|
)
|
|
print(f" Émulateur {arch} installé avec succès.")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Étapes
|
|
# --------------------------------------------------------------------------- #
|
|
# Délai (s) par opération réseau : au-delà, on abandonne le miroir courant.
|
|
# Sans lui, un miroir injoignable ferait pendre le téléchargement à l'infini.
|
|
DOWNLOAD_TIMEOUT = 30
|
|
|
|
|
|
def _download_one(url: str, tmp: Path, timeout: int) -> None:
|
|
"""Télécharge url -> tmp en streaming, avec timeout et barre de %.
|
|
Lève une exception en cas d'échec réseau (miroir suivant à essayer)."""
|
|
is_tty = sys.stdout.isatty()
|
|
last_pct = -1
|
|
req = urllib.request.Request(
|
|
url, headers={"User-Agent": "erplibre-qemu-deploy"}
|
|
)
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
|
|
total = int(resp.headers.get("Content-Length", 0) or 0)
|
|
done = 0
|
|
with open(tmp, "wb") as fh:
|
|
while True:
|
|
chunk = resp.read(1 << 16)
|
|
if not chunk:
|
|
break
|
|
fh.write(chunk)
|
|
done += len(chunk)
|
|
if total <= 0:
|
|
continue
|
|
pct = min(100, done * 100 // total)
|
|
if pct == last_pct:
|
|
continue
|
|
last_pct = pct
|
|
# TTY : une ligne réécrite (\r) ; sinon (capturé par le menu
|
|
# todo) au plus 101 lignes, jamais de flot infini.
|
|
if is_tty:
|
|
print(f"\r {pct:3d}%", end="", flush=True)
|
|
else:
|
|
print(f" {pct:3d}%", flush=True)
|
|
if is_tty:
|
|
print()
|
|
# Vérifie la COMPLÉTUDE : si le serveur a annoncé une taille et qu'on a
|
|
# reçu moins (connexion coupée), le .part est TRONQUÉ. Sans ce contrôle,
|
|
# il était validé comme « complet » -> qcow2 valide mais VIDE (juste
|
|
# l'en-tête) -> VM qui ne boote pas, et cache empoisonné réutilisé ensuite.
|
|
if total > 0 and done < total:
|
|
raise OSError(
|
|
f"téléchargement incomplet : {done}/{total} octets reçus "
|
|
"(connexion interrompue)"
|
|
)
|
|
|
|
|
|
def download_image(
|
|
urls, dest: Path, dry_run: bool, timeout: int = DOWNLOAD_TIMEOUT
|
|
) -> None:
|
|
"""Télécharge l'image (essaie chaque miroir dans l'ordre) si absente du
|
|
cache. Échoue proprement — jamais de blocage infini — grâce au timeout."""
|
|
if isinstance(urls, str):
|
|
urls = [urls]
|
|
if dest.exists() and dest.stat().st_size > 0:
|
|
size_mb = dest.stat().st_size / 1024 / 1024
|
|
print(
|
|
f" Image déjà présente ({size_mb:.0f} Mo), téléchargement"
|
|
f" ignoré : {dest}",
|
|
flush=True,
|
|
)
|
|
return
|
|
if dry_run:
|
|
print(f" [dry-run] téléchargement {urls[0]} -> {dest}", flush=True)
|
|
return
|
|
|
|
try:
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
except PermissionError:
|
|
sys.exit(
|
|
f"\nPermission refusée pour écrire dans {dest.parent}.\n"
|
|
" Relancez avec sudo, ou choisissez --image-dir vers un dossier"
|
|
" accessible en écriture."
|
|
)
|
|
tmp = dest.with_suffix(dest.suffix + ".part")
|
|
errors = []
|
|
had_404 = False
|
|
for i, url in enumerate(urls, 1):
|
|
tag = "" if len(urls) == 1 else f" (miroir {i}/{len(urls)})"
|
|
print(f" Téléchargement{tag} : {url}", flush=True)
|
|
try:
|
|
_download_one(url, tmp, timeout)
|
|
tmp.replace(dest)
|
|
return
|
|
except urllib.error.HTTPError as exc: # image absente sur ce miroir
|
|
tmp.unlink(missing_ok=True)
|
|
had_404 = had_404 or exc.code == 404
|
|
print(f"\n Échec : HTTP {exc.code}", flush=True)
|
|
errors.append(f"{url} -> HTTP {exc.code}")
|
|
except Exception as exc: # réseau/timeout : miroir suivant
|
|
tmp.unlink(missing_ok=True)
|
|
print(f"\n Échec : {exc}", flush=True)
|
|
errors.append(f"{url} -> {exc}")
|
|
hint = (
|
|
"\n Image introuvable (404) : cette version est probablement EOL et"
|
|
" a été retirée du miroir. Choisissez une version LTS encore"
|
|
" supportée (voir --list-images)."
|
|
if had_404
|
|
else "\n Vérifiez la connectivité (IPv6 ?), réessayez plus tard, ou"
|
|
" fournissez un chemin d'image local en argument positionnel."
|
|
)
|
|
# Chemin visé + commande prête à copier pour reprendre le téléchargement
|
|
# manuellement (reprise si un .part existe), puis relancer le déploiement
|
|
# (l'image complète en cache sera réutilisée). curl -C - reprend, -f
|
|
# échoue proprement sur une erreur HTTP.
|
|
resume = (
|
|
f"\n Destination : {dest}"
|
|
f"\n Reprendre le téléchargement manuellement :"
|
|
f"\n sudo curl -fL -C - -o {dest} \\\n {urls[0]}"
|
|
"\n puis relancez le déploiement (l'image en cache sera réutilisée)."
|
|
)
|
|
sys.exit(
|
|
"\nÉchec du téléchargement depuis tous les miroirs :\n "
|
|
+ "\n ".join(errors)
|
|
+ hint
|
|
+ resume
|
|
)
|
|
|
|
|
|
def pinned_sha256(distro: str) -> str:
|
|
"""La somme que le DÉPÔT porte pour l'image de `distro`, ou "".
|
|
|
|
Lue par les deux chemins de déploiement : celui de qemu vérifie le
|
|
fichier qu'il vient de télécharger, celui de Proxmox fait vérifier sur
|
|
l'hôte distant. Deux copies de la somme dériveraient, et la copie oubliée
|
|
serait celle qui laisse passer une image que personne n'a regardée.
|
|
"""
|
|
return NIXOS_IMAGE_SHA256 if distro == "nixos" else ""
|
|
|
|
|
|
def verify_pinned_sha256(
|
|
distro: str, image: Path, dry_run: bool, urls: tuple = ()
|
|
) -> None:
|
|
"""Vérifie une image contre la somme que le DÉPÔT porte pour elle.
|
|
|
|
Sans rapport avec --verify, qui lit un SHA256SUMS publié par la
|
|
distribution : ici l'amont n'en publie aucun, la somme a été relevée une
|
|
fois à la revue, et c'est la seule chose qui distingue l'image revue de
|
|
n'importe quel fichier servi sous la même URL. Elle se vérifie donc
|
|
TOUJOURS, sans drapeau à passer.
|
|
|
|
Une somme qui ne correspond pas ARRÊTE le déploiement : continuer
|
|
reviendrait à installer un système que personne n'a regardé.
|
|
"""
|
|
attendu = pinned_sha256(distro)
|
|
if not attendu or dry_run:
|
|
return
|
|
digest = hashlib.sha256()
|
|
with open(image, "rb") as fh:
|
|
for morceau in iter(lambda: fh.read(1 << 20), b""):
|
|
digest.update(morceau)
|
|
obtenu = digest.hexdigest()
|
|
if obtenu != attendu:
|
|
# Le remède ORDINAIRE ne suffit pas derrière le cache : effacer le
|
|
# fichier le fait retélécharger, et c'est le cache qui resservira les
|
|
# mêmes octets, indéfiniment. Son entrée se retire d'abord — ni
|
|
# « --purge », qui efface tout, ni « --purge-older-than », qu'un objet
|
|
# servi ne vieillit jamais assez pour atteindre.
|
|
remede = ""
|
|
if urls:
|
|
lignes = "\n".join(f"GET {u}" for u in urls)
|
|
remede = (
|
|
"\n\n Si un cache de téléchargement est en place, effacer le "
|
|
"fichier ne suffit pas : retirez-en l'entrée d'abord.\n"
|
|
f" printf '%s\\n' {shlex.quote(lignes)} \\\n"
|
|
" | sudo erplibre_go_qemu_cache --oublie"
|
|
)
|
|
sys.exit(
|
|
f"Erreur : l'image {image} ne correspond pas à la somme que le "
|
|
f"dépôt porte pour elle.\n"
|
|
f" attendu : {attendu}\n"
|
|
f" obtenu : {obtenu}\n"
|
|
" Supprimez le fichier pour le retélécharger. S'il revient "
|
|
"différent, l'amont a republié sous le même tag : le relire "
|
|
"avant de figer la nouvelle somme." + remede
|
|
)
|
|
print(f" Somme sha256 conforme à celle du dépôt ({attendu[:12]}…).")
|
|
|
|
|
|
# Où chaque distribution publie la somme de ses images, et avec quel
|
|
# algorithme.
|
|
#
|
|
# Relevé sur les dépôts eux-mêmes, pas déduit. Les FORMATS se ressemblent —
|
|
# « <empreinte> <nom> », l'astérisque d'Ubuntu en plus — mais ni le nom du
|
|
# fichier ni l'algorithme ne se devinent : Debian publie du sha512 quand tout
|
|
# le reste est en sha256, les familles RHEL nomment leur fichier « CHECKSUM »
|
|
# sans dire l'algorithme, et Arch comme openSUSE posent une somme PAR image
|
|
# plutôt qu'un fichier de répertoire.
|
|
#
|
|
# « {image} » dans le nom désigne la somme voisine ; sans lui, le fichier est
|
|
# cherché dans le répertoire de l'image.
|
|
#
|
|
# Fedora n'y est pas : son image porte un numéro de construction dans son
|
|
# nom, et le fichier de sommes le porte aussi — il ne se déduit pas de l'URL
|
|
# de l'image. Le dire plutôt que de vérifier à moitié.
|
|
SUMS_SOURCE: dict[str, tuple[str, str]] = {
|
|
"ubuntu": ("SHA256SUMS", "sha256"),
|
|
"debian": ("SHA512SUMS", "sha512"),
|
|
"almalinux": ("CHECKSUM", "sha256"),
|
|
"rocky": ("CHECKSUM", "sha256"),
|
|
"opensuse": ("{image}.sha256", "sha256"),
|
|
"arch": ("{image}.SHA256", "sha256"),
|
|
}
|
|
|
|
|
|
def sums_url_for(url: str, distro: str) -> tuple[str, str]:
|
|
"""(URL du fichier de sommes, algorithme) pour cette image, ou ("", "")."""
|
|
source = SUMS_SOURCE.get(distro)
|
|
if not source:
|
|
return "", ""
|
|
nom, algo = source
|
|
repertoire, image = url.rsplit("/", 1)
|
|
if "{image}" in nom:
|
|
return f"{repertoire}/{nom.format(image=image)}", algo
|
|
return f"{repertoire}/{nom}", algo
|
|
|
|
|
|
def expected_sum(sums: str, filename: str) -> str:
|
|
"""L'empreinte que `sums` donne pour `filename`, ou "".
|
|
|
|
Le nom est comparé au DERNIER champ, et en entier. Les six formats du
|
|
catalogue s'écrivent « <empreinte> <nom> » — Ubuntu met une étoile
|
|
devant le sien, que l'on retire. Une comparaison par suffixe confondrait
|
|
« …-amd64.qcow2 » avec « …-ext4-amd64.qcow2 », qui vivent dans le même
|
|
fichier chez AlmaLinux : on rendrait alors la somme d'une autre image, et
|
|
la vérification échouerait en accusant l'image juste.
|
|
|
|
DEUX formes, et la seconde ne se devine pas : Rocky publie « SHA256
|
|
(<nom>) = <empreinte> », la forme des outils BSD, là où les cinq autres
|
|
écrivent l'empreinte en tête. Ne lire que la première rendait "" pour
|
|
Rocky, donc une image jamais vérifiée sans que rien ne le dise.
|
|
|
|
Un fichier signé porte aussi ses lignes de PGP, et le CHECKSUM de Rocky
|
|
des lignes de commentaire donnant la taille : ni les unes ni les autres
|
|
n'ont la forme attendue, et elles tombent d'elles-mêmes.
|
|
"""
|
|
for ligne in sums.splitlines():
|
|
champs = ligne.split()
|
|
if len(champs) == 2:
|
|
empreinte, nom = champs
|
|
if nom.lstrip("*") == filename:
|
|
return empreinte
|
|
elif len(champs) == 4 and champs[2] == "=":
|
|
if champs[1].strip("()") == filename:
|
|
return champs[3]
|
|
return ""
|
|
|
|
|
|
def verify_sha256(
|
|
url: str, image: Path, dry_run: bool, distro: str = "ubuntu"
|
|
) -> None:
|
|
"""Vérifie l'empreinte via les sommes que la distribution publie.
|
|
|
|
UN ÉCHEC DE RÉCUPÉRATION N'ARRÊTE PAS le déploiement, un écart si. Les
|
|
deux ne disent pas la même chose : un fichier de sommes injoignable est
|
|
une panne de disponibilité — miroir en travaux, réseau coupé — et refuser
|
|
de déployer pour cela rendrait la vérification plus coûteuse que le risque
|
|
qu'elle couvre. Une empreinte qui ne correspond PAS est une panne
|
|
d'intégrité, et elle arrête tout.
|
|
"""
|
|
if dry_run:
|
|
print(" [dry-run] vérification des sommes ignorée")
|
|
return
|
|
sums_url, algo = sums_url_for(url, distro)
|
|
if not sums_url:
|
|
print(f" Note : {distro} ne publie pas de sommes lisibles d'ici.")
|
|
return
|
|
filename = url.rsplit("/", 1)[1]
|
|
print(f" Vérification {algo} via {sums_url}")
|
|
try:
|
|
with urllib.request.urlopen( # noqa: S310
|
|
sums_url, timeout=DOWNLOAD_TIMEOUT
|
|
) as resp:
|
|
sums = resp.read().decode()
|
|
except Exception as exc: # pragma: no cover
|
|
print(f" ⚠ sommes injoignables, image NON vérifiée : {exc}")
|
|
return
|
|
|
|
expected = expected_sum(sums, filename)
|
|
if not expected:
|
|
print(f" ⚠ empreinte absente pour {filename}, image NON vérifiée")
|
|
return
|
|
|
|
h = hashlib.new(algo)
|
|
with image.open("rb") as fh:
|
|
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
h.update(chunk)
|
|
if h.hexdigest() != expected:
|
|
image.unlink(
|
|
missing_ok=True
|
|
) # évite la réutilisation du cache corrompu
|
|
sys.exit(
|
|
f"Somme {algo} NON conforme ! Image supprimée : {image}\n"
|
|
f" attendu : {expected}\n obtenu : {h.hexdigest()}"
|
|
)
|
|
print(f" Somme {algo} conforme.")
|
|
|
|
|
|
def hash_password(plain: str) -> str:
|
|
"""Hash SHA-512 crypt ($6$...) via crypt (< 3.13) ou openssl en repli."""
|
|
try:
|
|
with warnings.catch_warnings():
|
|
# crypt est déprécié (retiré en 3.13) : on masque l'avertissement,
|
|
# le repli openssl couvre les versions récentes de Python.
|
|
warnings.simplefilter("ignore", DeprecationWarning)
|
|
import crypt
|
|
|
|
return crypt.crypt(plain, crypt.mksalt(crypt.METHOD_SHA512))
|
|
except (ImportError, AttributeError):
|
|
need_tool("openssl")
|
|
out = subprocess.run(
|
|
["openssl", "passwd", "-6", "-stdin"],
|
|
input=plain + "\n",
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
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, fixe: bool = False
|
|
) -> 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.
|
|
|
|
Un miroir imposé (« override »), ou le premier de la liste quand « fixe »
|
|
est vrai, s'écrit « uri: » et non « search: ». La recherche de cloud-init
|
|
écarte tout miroir dont le nom se résout comme un nom inexistant :
|
|
derrière un résolveur qui répond à tout nom, elle les écarte tous et
|
|
retombe sur le dépôt officiel. Le cache range les index sous leur hôte,
|
|
si bien qu'une VM derrière lui doit tirer du même miroir que les
|
|
précédentes pour retrouver ce qu'elles ont gardé, en ligne comme hors
|
|
ligne.
|
|
"""
|
|
mirrors = (
|
|
[override]
|
|
if override
|
|
else (APT_MIRRORS_PORTS if arch in PORTS_ARCHES else APT_MIRRORS_MAIN)
|
|
)
|
|
|
|
def bloc(nom):
|
|
tete = [f" {nom}:", " - arches: [default]"]
|
|
if override or fixe:
|
|
return tete + [f" uri: {mirrors[0]}"]
|
|
return tete + [" search:"] + [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.
|
|
return ["apt:"] + bloc("primary") + bloc("security")
|
|
|
|
|
|
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 hostname_valide(nom: str) -> str:
|
|
"""Un nom d'hôte acceptable, tiré du nom de la VM.
|
|
|
|
Tout ce qui n'est ni lettre, ni chiffre, ni tiret devient un tiret ; les
|
|
tirets de tête et de queue tombent, un nom d'hôte ne pouvant pas en porter.
|
|
Vide au bout du compte, « vm » sert de repli plutôt que de laisser passer
|
|
un nom que l'invité refusera.
|
|
"""
|
|
propre = re.sub(r"[^A-Za-z0-9-]", "-", nom).strip("-")
|
|
propre = re.sub(r"-{2,}", "-", propre)
|
|
return propre[:63] or "vm"
|
|
|
|
|
|
# La table des alias de fuseaux, telle que tzdata la publie. Nommée ici pour
|
|
# qu'un test puisse en fournir une autre sans dépendre du tzdata de la machine
|
|
# qui l'exécute.
|
|
TZ_ALIASES = "/usr/share/zoneinfo/tzdata.zi"
|
|
|
|
|
|
# Le firmware IMPOSÉ par l'image, sur x86, distribution par distribution.
|
|
#
|
|
# UNE table et deux valeurs, parce que les deux voies de déploiement partent
|
|
# de défauts OPPOSÉS : celle de libvirt amorce en UEFI pour tout le monde,
|
|
# celle de Proxmox part en SeaBIOS. Chaque valeur écarte donc un défaut
|
|
# différent, et chacune nomme un fait de l'IMAGE — pas une préférence.
|
|
#
|
|
# « bios » — la chaîne UEFI de l'image ne démarre pas sur les OVMF courants.
|
|
# Celle de Fedora charge et DÉMARRE son chargeur — le micrologiciel l'annonce
|
|
# — puis se fige sans écrire un octet sur le disque. La même image en BIOS
|
|
# démarre son noyau normalement : ce n'est donc ni l'image, ni la partition
|
|
# EFI, dont le chemin de repli est bien là. Ni l'entropie ni la machine q35 n'y
|
|
# changent rien.
|
|
#
|
|
# « uefi » — l'image n'a AUCUN secteur d'amorçage BIOS. Mesuré sur un
|
|
# Proxmox 9 : en SeaBIOS, une VM NixOS se déclare « running » et sa console
|
|
# reste muette ; la même en OVMF démarre — systemd, cloud-init, réseau.
|
|
#
|
|
# Le symptôme est muet des deux côtés : aucune console, aucun bail DHCP, une
|
|
# VM « en cours d'exécution » qui ne fait rien. D'où cette table plutôt qu'un
|
|
# diagnostic à refaire.
|
|
#
|
|
# Elle reste COURTE plutôt que de renverser un défaut pour tous : Debian 13,
|
|
# mesurée sur le même hôte, démarre en SeaBIOS sans rien devoir à l'UEFI.
|
|
#
|
|
# AVEUGLE À L'ARCHITECTURE : elle ne vaut que sur x86. Sur arm64 il n'y a pas
|
|
# de SeaBIOS, et virt_install tranche par l'architecture avant d'arriver ici ;
|
|
# la lecture Proxmox, elle, ne crée que des VM x86.
|
|
FIRMWARE_IMPOSE: dict[str, str] = {"fedora": "bios", "nixos": "uefi"}
|
|
|
|
|
|
def amorcage_bios(distro: str, demande: bool) -> bool:
|
|
"""Faut-il amorcer en BIOS hérité ?
|
|
|
|
La demande explicite l'emporte, SAUF sur une image sans secteur
|
|
d'amorçage BIOS : « --bios » ne peut pas en inventer un, et la VM se
|
|
déclarerait « running » avec une console muette. Un fait physique ne se
|
|
force pas ; l'appelant en est averti.
|
|
"""
|
|
impose = FIRMWARE_IMPOSE.get(distro)
|
|
if impose == "uefi":
|
|
return False
|
|
return bool(demande) or impose == "bios"
|
|
|
|
|
|
def requiert_uefi(distro: str) -> bool:
|
|
"""L'image de `distro` refuse-t-elle un amorçage BIOS hérité ?
|
|
|
|
Lue par la voie Proxmox, qui part en SeaBIOS et doit donc savoir à qui
|
|
poser « --bios ovmf » et un disque EFI.
|
|
"""
|
|
return FIRMWARE_IMPOSE.get(distro) == "uefi"
|
|
|
|
|
|
def canonical_timezone(tz: str, table: str = TZ_ALIASES) -> str:
|
|
"""Le nom canonique d'un fuseau, quand le système sait le dire.
|
|
|
|
Un alias hérité comme « Canada/Eastern » n'existe plus dans le tzdata de
|
|
plusieurs distributions récentes, qui l'ont relégué à un paquet séparé. La
|
|
VM refuse alors le fuseau, cloud-init marque son exécution en erreur et la
|
|
machine reste en UTC — ce qui ne se voit qu'après coup, sur des horodatages
|
|
à +0000.
|
|
|
|
La table des alias est « /usr/share/zoneinfo/tzdata.zi », dont chaque ligne
|
|
de lien s'écrit « L <canonique> <alias> ». Absente ou illisible, le nom est
|
|
rendu tel quel : un fuseau non traduit vaut mieux qu'un déploiement refusé.
|
|
Elle est lue de l'HÔTE plutôt que recopiée ici, et suit donc les mises à
|
|
jour de tzdata sans qu'on s'en occupe ; « table » n'existe que pour qu'un
|
|
test en fournisse une autre sans dépendre du tzdata de sa machine.
|
|
|
|
DEUX tours, et non un seul. Le format AUTORISE qu'un lien désigne un
|
|
autre lien, et un seul tour rendrait alors un nom qui reste un alias —
|
|
le défaut même qu'on répare. Le tzdata publié n'en contient aucune : tous
|
|
ses liens visent une zone. Le second tour est donc une assurance, au prix
|
|
d'une recherche dans un dictionnaire. Au-delà de deux, la table est
|
|
incohérente et le nom d'origine vaut mieux qu'une boucle.
|
|
|
|
L'INVARIANT que la fonction tient, et que le nombre de tours sert : le nom
|
|
rendu n'est pas lui-même un alias de la table.
|
|
"""
|
|
if not tz:
|
|
return tz
|
|
alias = {}
|
|
try:
|
|
with open(table, encoding="utf-8") as fh:
|
|
for ligne in fh:
|
|
champs = ligne.split()
|
|
if len(champs) >= 3 and champs[0] == "L":
|
|
alias[champs[2]] = champs[1]
|
|
except (OSError, UnicodeDecodeError):
|
|
# Le décodage aussi : un octet hors UTF-8 dans le fichier ferait
|
|
# échouer le déploiement ENTIER pour une traduction de confort.
|
|
return tz
|
|
for _ in range(2):
|
|
tz = alias.get(tz, tz)
|
|
return tz
|
|
|
|
|
|
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 canonical_timezone(out)
|
|
except (OSError, subprocess.SubprocessError):
|
|
pass
|
|
try:
|
|
tz = Path("/etc/timezone").read_text(encoding="utf-8").strip()
|
|
if tz:
|
|
return canonical_timezone(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 canonical_timezone(
|
|
"/".join(parts[parts.index("zoneinfo") + 1 :])
|
|
)
|
|
except OSError:
|
|
pass
|
|
return "UTC"
|
|
|
|
|
|
# Groupes qui donnent accès au GPU DANS L'INVITÉ. Le nœud de rendu y
|
|
# appartient à « root:render » en 0660, et le compte créé par cloud-init n'en
|
|
# fait pas partie : toute application GL retombe alors sur le rendu logiciel,
|
|
# alors même que la négociation VIRGL entre l'hôte et l'invité a réussi. Rien
|
|
# ne le signale — le matériel virtuel est bien accéléré, seul l'accès manque.
|
|
#
|
|
# En session graphique locale, logind pose une ACL sur le nœud pour
|
|
# l'utilisateur du siège actif et la question ne se pose pas. En SSH ou en
|
|
# tty — le cas d'une VM de ce parc — personne ne la pose.
|
|
GPU_GROUPS = ("render", "video")
|
|
|
|
|
|
def gpu_group_block() -> list[str]:
|
|
"""Bloc « groups: » qui CRÉE les groupes GPU avant leur usage.
|
|
|
|
« useradd -G » échoue sur un nom de groupe inconnu, et cloud-init ne crée
|
|
alors pas l'utilisateur du tout : ni mot de passe ni clé SSH, la VM démarre
|
|
et reste inaccessible. « render » est récent et manque des images les plus
|
|
anciennes, donc l'ajouter sans précaution rejouerait cette panne.
|
|
|
|
Le déclarer ici le rend certain d'exister : cloud-init crée les groupes
|
|
AVANT les comptes, et passe sans erreur sur ceux qui existent déjà. Le
|
|
groupe est retrouvé par NOM par les règles udev, donc un GID choisi par
|
|
cloud-init plutôt que par la distribution ne change rien.
|
|
"""
|
|
return ["groups:"] + [f" - {nom}" for nom in GPU_GROUPS]
|
|
|
|
|
|
def user_groups(distro: str, gpu: bool = False) -> str:
|
|
"""Groupes secondaires du compte créé par cloud-init.
|
|
|
|
Le nom du groupe d'administration change d'une famille à l'autre, et un
|
|
nom INCONNU fait échouer « useradd -G » : l'utilisateur n'est alors pas
|
|
créé du tout, donc ni mot de passe ni clé SSH, et la VM démarre
|
|
inaccessible. Le privilège vient de toute façon de la directive « sudo: »
|
|
du cloud-config, pas du groupe — celui-ci n'est qu'une commodité.
|
|
|
|
- Debian, Ubuntu : « sudo ».
|
|
- Famille RHEL (AlmaLinux, Rocky, CentOS, Fedora), Arch : « wheel ».
|
|
- openSUSE : aucun. Son cloud-init par défaut n'en met pas, et rien ne
|
|
garantit « wheel » sur une image Minimal-VM — dans le doute on s'abstient
|
|
plutôt que de risquer un compte non créé."""
|
|
if distro in ("ubuntu", "debian"):
|
|
noms = ["users", "sudo"]
|
|
elif distro == "opensuse":
|
|
noms = ["users"]
|
|
else:
|
|
noms = ["users", "wheel"]
|
|
if gpu:
|
|
noms += list(GPU_GROUPS)
|
|
return ", ".join(noms)
|
|
|
|
|
|
def user_shell(distro: str) -> str:
|
|
"""Shell de connexion du compte créé par cloud-init.
|
|
|
|
sshd REFUSE un compte dont le shell n'existe pas — « User <x> not allowed
|
|
because shell /bin/bash does not exist », avant même l'authentification,
|
|
et la VM est alors inaccessible bien qu'elle démarre et porte la clé.
|
|
|
|
NixOS ne peuple pas /bin : il n'y met que « sh », lui-même lien vers le
|
|
bash du store. C'est donc bash qu'on obtient, en mode POSIX, et non dash.
|
|
Ailleurs /bin/bash existe et reste préférable : sur Debian et Ubuntu,
|
|
/bin/sh EST dash, sans historique ni complétion.
|
|
"""
|
|
return "/bin/sh" if distro == "nixos" else "/bin/bash"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Guide de connexion (/etc/motd) et identité git de la VM
|
|
# --------------------------------------------------------------------------- #
|
|
# Le catalogue couvre quatre gestionnaires de paquets, et l'opérateur change de
|
|
# distribution d'un déploiement à l'autre. Le guide met SOUS LES YEUX, à la
|
|
# connexion, les commandes de la machine où l'on vient d'entrer : apt là où
|
|
# c'est apt, zypper là où c'est zypper.
|
|
#
|
|
# Le mécanisme est /etc/motd, et il est le même partout. Vérifié dans les images
|
|
# cloud elles-mêmes, montées en lecture seule : sshd y est en « PrintMotd no »
|
|
# et c'est pam_motd qui affiche le fichier. Trois conséquences tenues pour
|
|
# acquises ici :
|
|
# - il suffit d'ÉCRIRE /etc/motd. Ajouter « PrintMotd yes » afficherait le
|
|
# guide DEUX FOIS — sshd lit /etc/motd en dur, PAM le lit aussi ;
|
|
# - « ssh hôte 'commande' » ne l'affiche PAS (openssh coupe les deux chemins
|
|
# dès qu'une commande est passée), donc le suivi d'installation reste net.
|
|
# Un « ssh hôte < script » l'afficherait, lui : aucun n'est utilisé ici ;
|
|
# - openSUSE ajoute son « Have a lot of fun... » APRÈS le guide : il vient de
|
|
# /usr/lib/motd.d/welcome, que pam_motd lit après le fichier. On le laisse.
|
|
#
|
|
# Ubuntu n'a PAS de /etc/motd (son postinst base-files ne le crée pas, à la
|
|
# différence de Debian) : le fichier est donc créé, pas remplacé. Sur Debian il
|
|
# écrase les cinq lignes de base-files, ce qui ne fâche pas dpkg — /etc/motd n'y
|
|
# est ni un conffile ni même un fichier du paquet.
|
|
|
|
# Étiquette lisible d'une distribution. openSUSE livre DEUX produits sous un
|
|
# seul nom de distro (Leap, numéroté ; Tumbleweed, rolling) : la version tranche.
|
|
DISTRO_LABELS: dict[str, str] = {
|
|
"ubuntu": "Ubuntu",
|
|
"debian": "Debian",
|
|
"fedora": "Fedora",
|
|
"almalinux": "AlmaLinux",
|
|
"rocky": "Rocky Linux",
|
|
"opensuse": "openSUSE",
|
|
"arch": "Arch Linux",
|
|
"nixos": "NixOS",
|
|
"proxmox": "Proxmox VE",
|
|
}
|
|
|
|
# Gestionnaire de paquets de chaque distribution du catalogue.
|
|
DISTRO_PKG: dict[str, str] = {
|
|
"ubuntu": "apt",
|
|
"debian": "apt",
|
|
"fedora": "dnf",
|
|
"almalinux": "dnf",
|
|
"rocky": "dnf",
|
|
"opensuse": "zypper",
|
|
"arch": "pacman",
|
|
"nixos": "nix",
|
|
# Debian dessous : c'est apt qui sert, et le guide de connexion le dit.
|
|
"proxmox": "apt",
|
|
}
|
|
|
|
|
|
def distro_label(distro: str, version: str) -> str:
|
|
"""« Ubuntu 24.04 », « openSUSE Leap 16.0 », « Arch Linux »…"""
|
|
name = DISTRO_LABELS.get(distro, distro)
|
|
if distro == "opensuse":
|
|
if version == "tumbleweed":
|
|
return f"{name} Tumbleweed"
|
|
return f"{name} Leap {version}"
|
|
if distro == "arch":
|
|
# Rolling release : « latest » n'apprend rien à personne.
|
|
return name
|
|
return f"{name} {version}"
|
|
|
|
|
|
# Aide-mémoire par gestionnaire de paquets : (commande, glose fr, glose en).
|
|
# Chaque ligne vient du manuel amont de l'outil, pas de mémoire, et doit
|
|
# fonctionner TELLE QUELLE — c'est un guide, pas une piste à vérifier.
|
|
#
|
|
# dnf : les formes écrites ici valent pour dnf4 (AlmaLinux/Rocky 9 ET 10, tous
|
|
# deux en dnf 4.x) comme pour dnf5 (Fedora 41+). Les raccourcis de dnf4 ont
|
|
# disparu de dnf5 : « dnf history » seul, « grouplist », « whatprovides »,
|
|
# « list installed » sans tirets y échouent tous. Les formes longues passent
|
|
# partout, et ne coûtent rien.
|
|
PKG_GUIDE: dict[str, tuple[tuple[str, str, str], ...]] = {
|
|
# Rien ne s'y installe « pour de bon » par une commande : ce qui doit
|
|
# rester se DÉCLARE, et le guide le dit dans cet ordre.
|
|
"nix": (
|
|
(
|
|
"nix-shell -p <paquet>",
|
|
"essayer, le temps d'un shell",
|
|
"try it, for one shell",
|
|
),
|
|
(
|
|
"/etc/nixos/configuration.nix",
|
|
"y déclarer ce qui reste",
|
|
"declare there what stays",
|
|
),
|
|
(
|
|
"sudo nixos-rebuild switch",
|
|
"appliquer la déclaration",
|
|
"apply the declaration",
|
|
),
|
|
(
|
|
"sudo nix-collect-garbage -d",
|
|
"libérer les générations",
|
|
"free the generations kept",
|
|
),
|
|
),
|
|
"apt": (
|
|
("sudo apt update", "rafraîchir l'index", "refresh the index"),
|
|
(
|
|
"sudo apt upgrade",
|
|
"mettre à jour le système",
|
|
"upgrade the system",
|
|
),
|
|
("sudo apt install <paquet>", "installer", "install"),
|
|
("sudo apt remove <paquet>", "retirer", "remove"),
|
|
("apt search <motif>", "chercher", "search"),
|
|
("apt show <paquet>", "détails d'un paquet", "package details"),
|
|
("apt list --installed", "lister l'installé", "list installed"),
|
|
("sudo apt autoremove", "purger les orphelins", "purge orphans"),
|
|
),
|
|
"dnf": (
|
|
(
|
|
"sudo dnf upgrade",
|
|
"mettre à jour le système",
|
|
"upgrade the system",
|
|
),
|
|
("sudo dnf install <paquet>", "installer", "install"),
|
|
("sudo dnf remove <paquet>", "retirer", "remove"),
|
|
("dnf check-update", "mises à jour disponibles", "available updates"),
|
|
("dnf search <motif>", "chercher", "search"),
|
|
("dnf info <paquet>", "détails d'un paquet", "package details"),
|
|
("dnf list --installed", "lister l'installé", "list installed"),
|
|
("dnf history list", "journal des opérations", "transaction log"),
|
|
),
|
|
"pacman": (
|
|
(
|
|
"sudo pacman -Syu",
|
|
"mettre à jour le système",
|
|
"upgrade the system",
|
|
),
|
|
# Jamais « -Sy » seul : la base de paquets serait à jour et le système
|
|
# non, donc une installation tirerait des binaires liés à des
|
|
# bibliothèques absentes. Arch ne supporte que la mise à jour complète,
|
|
# d'où la forme « -Syu <paquet> » pour installer.
|
|
(
|
|
"sudo pacman -Syu <paquet>",
|
|
"installer (jamais « -Sy » seul)",
|
|
"install (never a bare « -Sy »)",
|
|
),
|
|
("sudo pacman -Rns <paquet>", "retirer", "remove"),
|
|
("pacman -Ss <motif>", "chercher", "search"),
|
|
("pacman -Si <paquet>", "détails d'un paquet", "package details"),
|
|
("pacman -Q", "lister l'installé", "list installed"),
|
|
("pacman -Qdtq", "orphelins", "orphans"),
|
|
("sudo pacman -Sc", "nettoyer le cache", "clean the cache"),
|
|
),
|
|
}
|
|
|
|
|
|
# Assistant AUR posé sur l'invité Arch par l'amorçage d'installation. Les
|
|
# formes viennent du manuel de yay : il reprend les options de pacman, sauf
|
|
# « -Yc » qui lui est propre. Jamais sous sudo — yay appelle sudo lui-même
|
|
# pour la seule étape qui en a besoin, et le lancer en root fait échouer
|
|
# makepkg, qui refuse de construire sous cet utilisateur.
|
|
AUR_GUIDE: tuple[tuple[str, str, str], ...] = (
|
|
("yay -Syu", "mettre à jour dépôts + AUR", "upgrade repos and AUR"),
|
|
("yay -S <paquet>", "installer depuis l'AUR", "install from the AUR"),
|
|
("yay -Ss <motif>", "chercher dans l'AUR", "search the AUR"),
|
|
("yay -Yc", "retirer les orphelins", "remove orphans"),
|
|
)
|
|
|
|
|
|
# Ce que NixOS change pour ERPLibre, et qu'aucune autre distribution ne
|
|
# demande.
|
|
#
|
|
# Le piège est la deuxième ligne : /etc/nixos/erplibre.nix est RÉÉCRIT à
|
|
# chaque « make install_os » — le script le pose par « sed | tee » depuis le
|
|
# dépôt. Ce qu'on y ajoute disparaît à la mise à jour suivante, sans un mot,
|
|
# et l'on cherche alors pourquoi une dépendance déclarée ne l'est plus. Le
|
|
# fichier où l'on écrit SES déclarations est l'autre.
|
|
#
|
|
# « systemctl cat » plutôt qu'une ligne disant de ne pas faire : l'unité est
|
|
# un lien vers le store, et la voir une fois dit mieux que toute explication
|
|
# pourquoi « systemctl enable » n'a rien à faire ici.
|
|
#
|
|
# « --rollback » est ce qui rend l'édition SÛRE, et ne se devine pas : une
|
|
# déclaration fautive se défait par une commande, là où les autres
|
|
# distributions laissent un système à réparer à la main.
|
|
#
|
|
# « ls /bin » est le piège inverse d'une commande utile — il rend VIDE, et
|
|
# pourtant /bin/bash s'exécute. envfs résout un nom à la demande sans jamais
|
|
# énumérer, donc tout ce qui cherche par motif ne trouve rien, quand le nom
|
|
# exact marche. Le Makefile tient par là (« SHELL := /bin/bash »), et une
|
|
# sonde écrite en glob conclut à tort que l'interpréteur manque.
|
|
#
|
|
# Ce que ce bloc ne reprend PAS : nix-shell, nixos-rebuild switch et
|
|
# nix-collect-garbage sont déjà dans le bloc du gestionnaire de paquets, qui
|
|
# dit comment poser un logiciel. Ici on ne parle que de ce qu'ERPLibre change
|
|
# à NixOS, et de ce que NixOS change à ERPLibre.
|
|
NIXOS_GUIDE: tuple[tuple[str, str, str], ...] = (
|
|
(
|
|
"{el_dir}/conf/nixos/erplibre.nix",
|
|
"la déclaration, dans le dépôt",
|
|
"the declaration, in the checkout",
|
|
),
|
|
(
|
|
"/etc/nixos/erplibre.nix",
|
|
"RÉÉCRITE par make install_os",
|
|
"REWRITTEN by make install_os",
|
|
),
|
|
(
|
|
"/etc/nixos/configuration.nix",
|
|
"make install_os n'y touche pas",
|
|
"make install_os never touches it",
|
|
),
|
|
(
|
|
"sudo nixos-rebuild switch --rollback",
|
|
"revenir à la génération d'avant",
|
|
"back to the previous generation",
|
|
),
|
|
(
|
|
"nixos-version",
|
|
"version du système",
|
|
"the system version",
|
|
),
|
|
(
|
|
"systemctl cat erplibre",
|
|
"vient du store, pas de /etc",
|
|
"from the store, not from /etc",
|
|
),
|
|
(
|
|
"ls /bin",
|
|
"vide ; envfs résout sans lister",
|
|
"empty; envfs resolves, no list",
|
|
),
|
|
)
|
|
|
|
|
|
def nixos_rows(el_dir: str) -> tuple[tuple[str, str, str], ...]:
|
|
"""Le guide NixOS, sa racine d'installation substituée.
|
|
|
|
Le chemin du dépôt est le SEUL du bloc à ne pas être absolu, et le guide
|
|
est lu par quelqu'un qui vient d'entrer en ssh, donc posé dans son foyer.
|
|
Un chemin relatif l'envoie chercher un fichier là où il n'est pas.
|
|
"""
|
|
return tuple(
|
|
(cmd.replace("{el_dir}", el_dir or "~/git/erplibre"), fr, en)
|
|
for cmd, fr, en in NIXOS_GUIDE
|
|
)
|
|
|
|
|
|
# Les outils posés DANS la VM, et les quelques commandes qu'on tape en
|
|
# entrant. Une entrée par clé du catalogue de script/todo/qemu_install.py ;
|
|
# un test garde que toute clé d'ici y existe, sans quoi le guide annoncerait
|
|
# un outil que le menu ne sait pas poser.
|
|
#
|
|
# Le TITRE de bloc est celui du guide, et non le libellé du catalogue : le
|
|
# second nomme une case de menu, en anglais, quand le premier est lu dans la
|
|
# langue de la VM.
|
|
#
|
|
# CE QUI EST ÉCRIT ICI DOIT ÊTRE VRAI SUR LA MACHINE. Le guide est lu par
|
|
# quelqu'un qui vient d'entrer en ssh et qui va TAPER ces lignes : une
|
|
# commande absente envoie chercher un binaire qui n'existe pas, ce qui coûte
|
|
# plus qu'un guide muet. Chaque ligne a été confrontée au code qui l'installe.
|
|
#
|
|
# « {el_dir} » est remplacé par la racine de l'installation, comme dans le
|
|
# bloc ERPLibre.
|
|
TOOL_GUIDE: dict[str, tuple[str, tuple[tuple[str, str, str], ...]]] = {
|
|
# nix POSÉ SUR UNE AUTRE DISTRIBUTION, et non NixOS : il n'y a ici ni
|
|
# /etc/nixos ni nixos-rebuild, et les lignes de NIXOS_GUIDE n'y valent
|
|
# pas. « profile add » et non « install » : l'alias est déprécié depuis
|
|
# nix 2.30, et l'installateur amont sert une version postérieure.
|
|
"nixanywhere": (
|
|
"nix + nixos-anywhere",
|
|
(
|
|
(
|
|
"/etc/nix/nix.conf",
|
|
"nix-command et flakes, activés",
|
|
"nix-command and flakes, enabled",
|
|
),
|
|
(
|
|
"nix shell nixpkgs#<paquet>",
|
|
"essayer, le temps d'un shell",
|
|
"try it, for one shell",
|
|
),
|
|
(
|
|
"nix profile add nixpkgs#<paquet>",
|
|
"l'ajouter à son profil",
|
|
"add it to your profile",
|
|
),
|
|
(
|
|
"nixos-anywhere -f .#<hôte> root@<cible>",
|
|
"installer NixOS à distance",
|
|
"install NixOS remotely",
|
|
),
|
|
),
|
|
),
|
|
"pycharm": (
|
|
"PyCharm",
|
|
(
|
|
(
|
|
"pycharm {el_dir}",
|
|
"ouvrir le dépôt dans l'IDE",
|
|
"open the checkout in the IDE",
|
|
),
|
|
(
|
|
"make pycharm_configure",
|
|
"rejouer exécutions et exclusions du .idea",
|
|
"replay the .idea run configs and exclusions",
|
|
),
|
|
),
|
|
),
|
|
"android": (
|
|
"Android Studio",
|
|
(
|
|
(
|
|
"studio",
|
|
"lancer Android Studio (alias : android-studio)",
|
|
"start Android Studio (alias: android-studio)",
|
|
),
|
|
(
|
|
"grep -cE 'vmx|svm' /proc/cpuinfo",
|
|
"0 : pas de KVM imbriqué, l'émulateur ne tournera pas",
|
|
"0: no nested KVM, the emulator will not run",
|
|
),
|
|
),
|
|
),
|
|
"avd": (
|
|
"Émulateur Android",
|
|
(
|
|
(
|
|
"~/android/emulator/emulator -avd erplibre",
|
|
"ouvrir l'AVD (par ssh -XC)",
|
|
"open the AVD (over ssh -XC)",
|
|
),
|
|
(
|
|
"~/android/platform-tools/adb devices",
|
|
"voir l'émulateur en marche",
|
|
"see the running emulator",
|
|
),
|
|
(
|
|
"id -nG | grep -w kvm",
|
|
"groupe kvm, sinon pas d'émulateur",
|
|
"kvm group, or no emulator",
|
|
),
|
|
),
|
|
),
|
|
"mobile": (
|
|
"ERPLibre mobile",
|
|
(
|
|
(
|
|
"cd {el_dir}/mobile/erplibre_home_mobile",
|
|
"le dépôt de l'application mobile",
|
|
"the mobile app checkout",
|
|
),
|
|
(
|
|
"npm test",
|
|
"rejouer les tests Vitest",
|
|
"replay the Vitest tests",
|
|
),
|
|
(
|
|
"npm start",
|
|
"servir l'application sans Android ni émulateur",
|
|
"serve the app without Android or emulator",
|
|
),
|
|
(
|
|
"tail -40 ~/erplibre-mobile-build.log",
|
|
"le journal détaillé de la compilation",
|
|
"the detailed build log",
|
|
),
|
|
),
|
|
),
|
|
"forgejo": (
|
|
"Forgejo",
|
|
(
|
|
(
|
|
"http://<ip>:3000",
|
|
"forge git, compte et mot de passe erplibre",
|
|
"git forge, account and password erplibre",
|
|
),
|
|
(
|
|
"sudo systemctl status forgejo",
|
|
"état du service",
|
|
"the service's state",
|
|
),
|
|
(
|
|
"sudo journalctl -u forgejo -f",
|
|
"suivre son journal",
|
|
"follow its log",
|
|
),
|
|
),
|
|
),
|
|
"gnome_ext": (
|
|
"Extensions GNOME",
|
|
(
|
|
(
|
|
"extension-manager",
|
|
"le gestionnaire d'extensions, sur le bureau",
|
|
"the extension manager, on the desktop",
|
|
),
|
|
(
|
|
"gnome-extensions enable <uuid>",
|
|
"activer depuis le bureau",
|
|
"enable from the desktop",
|
|
),
|
|
(
|
|
"dbus-run-session -- gnome-extensions enable <uuid>",
|
|
"activer depuis ssh",
|
|
"enable over ssh",
|
|
),
|
|
),
|
|
),
|
|
"aidev": (
|
|
"Outils d'IA",
|
|
(
|
|
("claude", "lancer l'agent de code", "start the coding agent"),
|
|
(
|
|
"ls ~/.claude/commands",
|
|
"les commandes /commit et /todo_* posées",
|
|
"the /commit and /todo_* commands installed",
|
|
),
|
|
(
|
|
"rtk gain",
|
|
"les tokens économisés jusqu'ici",
|
|
"tokens saved so far",
|
|
),
|
|
(
|
|
"starship --version",
|
|
"l'invite du shell, accrochée au démarrage",
|
|
"the shell prompt, hooked at startup",
|
|
),
|
|
),
|
|
),
|
|
}
|
|
|
|
|
|
def tool_rows(cle: str, el_dir: str) -> tuple[str, tuple]:
|
|
"""(libellé, lignes) d'un outil, sa racine d'installation substituée."""
|
|
entree = TOOL_GUIDE.get(cle)
|
|
if not entree:
|
|
return "", ()
|
|
libelle, lignes = entree
|
|
return libelle, tuple(
|
|
(cmd.replace("{el_dir}", el_dir or "~/git/erplibre"), fr, en)
|
|
for cmd, fr, en in lignes
|
|
)
|
|
|
|
|
|
def zypper_guide(rolling: bool) -> tuple[tuple[str, str, str], ...]:
|
|
"""Aide-mémoire zypper. `rolling` : Tumbleweed plutôt que Leap.
|
|
|
|
La ligne de mise à jour n'est PAS la même, et ce n'est pas une préférence de
|
|
style : « up » sur Leap, dont la version est figée, et « dup » sur
|
|
Tumbleweed, où chaque mise à jour est un instantané complet de la
|
|
distribution. La doc amont est catégorique — « on Tumbleweed you will never
|
|
have to use zypper-up » — et « up » y laisse traîner des paquets retirés des
|
|
dépôts, donc des dépendances bancales. Le déploiement sait laquelle des deux
|
|
il installe : autant que le guide le sache aussi.
|
|
"""
|
|
upgrade = (
|
|
("sudo zypper dup", "mettre à jour (rolling)", "upgrade (rolling)")
|
|
if rolling
|
|
else (
|
|
"sudo zypper up",
|
|
"mettre à jour le système",
|
|
"upgrade the system",
|
|
)
|
|
)
|
|
return (
|
|
("sudo zypper ref", "rafraîchir les dépôts", "refresh the repos"),
|
|
upgrade,
|
|
("sudo zypper in <paquet>", "installer", "install"),
|
|
("sudo zypper rm <paquet>", "retirer", "remove"),
|
|
("zypper se <motif>", "chercher", "search"),
|
|
("zypper info <paquet>", "détails d'un paquet", "package details"),
|
|
("zypper se -i", "lister l'installé", "list installed"),
|
|
("zypper lu", "mises à jour disponibles", "available updates"),
|
|
(
|
|
"sudo zypper ps -s",
|
|
"à redémarrer après MAJ",
|
|
"restart after upgrade",
|
|
),
|
|
)
|
|
|
|
|
|
# Lignes valables partout, quelle que soit la distribution.
|
|
SYSTEM_GUIDE: tuple[tuple[str, str, str], ...] = (
|
|
("hostname -I", "adresse IP de la VM", "the VM's IP address"),
|
|
("df -h /", "espace disque", "disk space"),
|
|
("free -h", "mémoire", "memory"),
|
|
)
|
|
# Ajoutées SEULEMENT quand il n'y a pas de section ERPLibre : celle-ci montre
|
|
# déjà les deux commandes, sur un service qui existe vraiment. Sur une VM
|
|
# déployée sans ERPLibre, elles manqueraient.
|
|
SERVICE_GUIDE: tuple[tuple[str, str, str], ...] = (
|
|
("systemctl status <service>", "état d'un service", "a service's state"),
|
|
("journalctl -u <service> -f", "suivre son journal", "follow its log"),
|
|
)
|
|
|
|
|
|
# N'apparaît que sur une VM déployée AVEC un bureau. Vécu : GNOME installé,
|
|
# gdm3 installé, cible graphique par défaut… et la console restait en mode texte.
|
|
# graphical.target était déjà atteinte quand le paquet est arrivé, et une cible
|
|
# active ne rattrape pas un service ajouté après coup. « enable » seul n'y change
|
|
# rien sur Debian et Ubuntu — l'unité n'a pas de WantedBy, seulement un alias —
|
|
# d'où le « --now », qui démarre.
|
|
DESKTOP_GUIDE: tuple[tuple[str, str, str], ...] = (
|
|
(
|
|
"systemctl status display-manager",
|
|
"état du bureau graphique",
|
|
"graphical desktop state",
|
|
),
|
|
(
|
|
"sudo systemctl enable --now gdm",
|
|
"le démarrer (« --now » : enable seul ne suffit pas)",
|
|
'start it ("--now": enable alone does nothing)',
|
|
),
|
|
)
|
|
|
|
|
|
def erplibre_guide(
|
|
el_dir: str, el_make: str = "", editor: str = ""
|
|
) -> tuple[tuple[str, str, str], ...]:
|
|
"""Commandes ERPLibre de la VM.
|
|
|
|
`el_dir` : racine de l'installation (~/git/erplibre en développement,
|
|
/opt/erplibre en production). Toutes les autres lignes sont relatives à ce
|
|
répertoire, d'où le « cd » en tête.
|
|
|
|
`el_make` : cible make qui a installé la VM, réutilisée pour la mettre à
|
|
jour. Vide, le guide s'arrête à « git pull » plutôt que d'annoncer une cible
|
|
qui n'est pas celle du profil retenu.
|
|
|
|
`editor` : éditeur de l'hôte, quand il a pu être déterminé. Sans lui on
|
|
nomme le fichier de configuration sans nommer d'éditeur — « vi » n'est pas
|
|
garanti sur toutes les images cloud, et un guide qui propose une commande
|
|
absente est pire que muet.
|
|
"""
|
|
rows = [
|
|
(f"cd {el_dir}", "aller au dépôt", "go to the checkout"),
|
|
("make todo", "menu ERPLibre (TODO)", "ERPLibre menu (TODO)"),
|
|
]
|
|
if editor:
|
|
rows.append(
|
|
(
|
|
f"{editor} config.conf",
|
|
"éditer le serveur",
|
|
"edit the server",
|
|
)
|
|
)
|
|
else:
|
|
rows.append(
|
|
(
|
|
"config.conf",
|
|
"configuration du serveur",
|
|
"the server's config",
|
|
)
|
|
)
|
|
rows += [
|
|
(
|
|
"sudo systemctl restart erplibre",
|
|
"redémarrer le serveur",
|
|
"restart the server",
|
|
),
|
|
("systemctl status erplibre", "état du serveur", "server state"),
|
|
("journalctl -u erplibre -f", "suivre son journal", "follow its log"),
|
|
("./run.sh -d <base>", "lancer à la main", "run it by hand"),
|
|
(
|
|
"./script/addons/update_addons_all.sh <base>",
|
|
"mise à jour des modules",
|
|
"update the modules",
|
|
),
|
|
(
|
|
f"git pull && make {el_make}" if el_make else "git pull",
|
|
(
|
|
"mise à jour ERPLibre/Odoo"
|
|
if el_make
|
|
else "mettre à jour le dépôt"
|
|
),
|
|
"update ERPLibre/Odoo" if el_make else "update the checkout",
|
|
),
|
|
("http://<ip>:8069", "interface web", "web interface"),
|
|
]
|
|
return tuple(rows)
|
|
|
|
|
|
# Plancher de largeur de l'encadré. Au-dessus, il SUIT le contenu : un cadre
|
|
# plus étroit que ce qu'il encadre serait pire qu'un cadre large. C'est au
|
|
# contenu de rester sous 80 colonnes — un guide qui se replie sur un terminal
|
|
# standard est illisible, et un test le vérifie pour les sept distributions.
|
|
MOTD_MIN_WIDTH = 62
|
|
|
|
|
|
def _pick(pair: tuple[str, str], lang: str) -> str:
|
|
"""Membre fr ou en d'un couple de libellés."""
|
|
return pair[1] if lang == "en" else pair[0]
|
|
|
|
|
|
def gloss_col(*blocks: tuple[tuple[str, str, str], ...]) -> int:
|
|
"""Colonne où commencent les gloses de ces blocs : la commande la plus
|
|
longue, plus deux espaces."""
|
|
return max(len(cmd) for rows in blocks for cmd, _fr, _en in rows) + 2
|
|
|
|
|
|
def motd_block(
|
|
title: str, rows: tuple[tuple[str, str, str], ...], lang: str, col: int
|
|
) -> list[str]:
|
|
"""Un bloc du guide : un titre, puis « commande <espaces> glose » alignées.
|
|
|
|
`col` est donné plutôt que déduit du bloc : les blocs de commandes courtes
|
|
partagent une colonne commune, sans quoi le bloc « système » se tasserait à
|
|
treize caractères là où celui des paquets en occupe trente. Le bloc
|
|
ERPLibre, lui, garde la sienne — sa commande la plus longue fait
|
|
43 caractères, et l'imposer au guide entier ferait déborder les lignes de
|
|
80 colonnes.
|
|
"""
|
|
out = [f" {title}"]
|
|
for cmd, gloss_fr, gloss_en in rows:
|
|
out.append(f" {cmd.ljust(col)}{_pick((gloss_fr, gloss_en), lang)}")
|
|
return out
|
|
|
|
|
|
def build_motd(
|
|
distro: str,
|
|
version: str,
|
|
arch: str,
|
|
lang: str = "fr",
|
|
el_dir: str = "",
|
|
el_make: str = "",
|
|
editor: str = "",
|
|
desktop: bool = False,
|
|
tools: tuple = (),
|
|
) -> str:
|
|
"""Texte du /etc/motd de la VM. Fonction PURE : aucun I/O, donc testable.
|
|
|
|
La section ERPLibre n'apparaît qu'avec `el_dir` : une VM déployée sans
|
|
installation ne doit pas annoncer un dépôt et un service qui n'existent pas.
|
|
Le bloc « Bureau » suit la même règle avec `desktop` : sur un serveur, ces
|
|
deux commandes ne mèneraient à aucune unité.
|
|
"""
|
|
body: list[str] = []
|
|
mgr = DISTRO_PKG.get(distro, "")
|
|
if mgr == "zypper":
|
|
pkg_rows = zypper_guide(version == "tumbleweed")
|
|
else:
|
|
pkg_rows = PKG_GUIDE.get(mgr, ())
|
|
sys_rows = SYSTEM_GUIDE if el_dir else SYSTEM_GUIDE + SERVICE_GUIDE
|
|
narrow = gloss_col(pkg_rows or sys_rows, sys_rows)
|
|
if pkg_rows:
|
|
body += motd_block(
|
|
f"{_pick(('Paquets', 'Packages'), lang)} — {mgr}",
|
|
pkg_rows,
|
|
lang,
|
|
narrow,
|
|
)
|
|
# yay arrive avec l'amorçage d'installation, pas avec l'image : une VM
|
|
# déployée sans installation n'annonce donc pas une commande absente.
|
|
# C'est la règle du bloc ERPLibre ci-dessous, appliquée au même signal.
|
|
if mgr == "pacman" and el_dir:
|
|
body.append("")
|
|
body += motd_block("AUR — yay", AUR_GUIDE, lang, narrow)
|
|
if el_dir:
|
|
body.append("")
|
|
el_rows = erplibre_guide(el_dir, el_make, editor)
|
|
body += motd_block("ERPLibre", el_rows, lang, gloss_col(el_rows))
|
|
# UN BLOC PAR OUTIL POSÉ, titré de son libellé. La liste vient de
|
|
# l'appelant, filtrée par la machine : annoncer un outil que l'
|
|
# architecture ou l'absence de bureau écarte enverrait chercher une
|
|
# commande qui ne sera jamais là. L'ordre est celui de la table, pour que
|
|
# deux VM au même équipement rendent le même guide.
|
|
for cle in TOOL_GUIDE:
|
|
if cle not in (tools or ()):
|
|
continue
|
|
libelle, lignes = tool_rows(cle, el_dir)
|
|
if not lignes:
|
|
continue
|
|
body.append("")
|
|
body += motd_block(libelle, lignes, lang, gloss_col(lignes))
|
|
# Même règle que le bloc AUR : il ne paraît qu'avec une installation,
|
|
# parce que c'est elle qui pose le module dont ces lignes parlent.
|
|
if mgr == "nix" and el_dir:
|
|
body.append("")
|
|
body += motd_block(
|
|
_pick(("NixOS — déclaratif", "NixOS — declarative"), lang),
|
|
nixos_rows(el_dir),
|
|
lang,
|
|
gloss_col(nixos_rows(el_dir)),
|
|
)
|
|
if desktop:
|
|
body.append("")
|
|
body += motd_block(
|
|
_pick(("Bureau", "Desktop"), lang),
|
|
DESKTOP_GUIDE,
|
|
lang,
|
|
gloss_col(DESKTOP_GUIDE),
|
|
)
|
|
body.append("")
|
|
body += motd_block(
|
|
_pick(("Système", "System"), lang), sys_rows, lang, narrow
|
|
)
|
|
title = f"ERPLibre · {distro_label(distro, version)} · {arch}"
|
|
width = max(
|
|
MOTD_MIN_WIDTH, max([len(line) for line in body] + [len(title)]) + 4
|
|
)
|
|
head = [
|
|
"╭" + "─" * (width - 2) + "╮",
|
|
"│ " + title.ljust(width - 4) + " │",
|
|
"╰" + "─" * (width - 2) + "╯",
|
|
]
|
|
foot = [
|
|
"",
|
|
" "
|
|
+ _pick(
|
|
(
|
|
"Guide écrit au déploiement par",
|
|
"Guide written at deploy time by",
|
|
),
|
|
lang,
|
|
)
|
|
+ " script/qemu/deploy_qemu.py",
|
|
]
|
|
return "\n".join(head + [""] + body + foot) + "\n"
|
|
|
|
|
|
def invoking_home() -> Path:
|
|
"""Foyer de l'utilisateur qui a lancé le script, sudo compris.
|
|
|
|
Le script tourne sous sudo : `Path.home()` y renvoie /root, où il n'y a
|
|
aucune configuration git à reprendre.
|
|
"""
|
|
try:
|
|
return Path(os.path.expanduser(f"~{invoking_user()}"))
|
|
except (KeyError, RuntimeError):
|
|
return Path.home()
|
|
|
|
|
|
def _git_global(key: str, home: Path) -> str:
|
|
"""Valeur d'une clé de la configuration git GLOBALE de `home`.
|
|
|
|
HOME est forcé plutôt que de lire ~/.gitconfig à la main : git accepte DEUX
|
|
emplacements pour sa configuration globale (~/.gitconfig et
|
|
~/.config/git/config), et lui poser la question évite de trancher à sa place.
|
|
"""
|
|
try:
|
|
res = subprocess.run(
|
|
["git", "config", "--global", "--get", key],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
env=dict(os.environ, HOME=str(home)),
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return ""
|
|
return res.stdout.strip() if res.returncode == 0 else ""
|
|
|
|
|
|
def host_editor(home: Path) -> str:
|
|
"""Éditeur que l'hôte utilise, dans l'ordre où git le résout lui-même.
|
|
|
|
core.editor, puis $VISUAL/$EDITOR, puis /usr/bin/editor — le lien des
|
|
alternatives Debian, qui est LA réponse à « quel éditeur ce système
|
|
utilise-t-il » quand rien n'est configuré. Ailleurs ce lien n'existe pas et
|
|
on ne devine pas : mieux vaut ne rien écrire que d'imposer un éditeur.
|
|
|
|
GIT_EDITOR est volontairement IGNORÉ. Les outils qui appellent git sans
|
|
interaction le posent à « true » pour empêcher toute ouverture d'éditeur ;
|
|
le recopier dans la VM y désactiverait silencieusement l'éditeur de git.
|
|
"""
|
|
editor = _git_global("core.editor", home)
|
|
if not editor:
|
|
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or ""
|
|
if not editor:
|
|
try:
|
|
editor = Path("/usr/bin/editor").resolve(strict=True).name
|
|
except OSError:
|
|
editor = ""
|
|
return editor.strip()
|
|
|
|
|
|
def editor_binary(editor: str) -> str:
|
|
"""Binaire seul d'une commande d'éditeur (« code --wait » -> « code »).
|
|
|
|
Le guide affiche le binaire, pas la commande complète : les options de git
|
|
(attente de fermeture, fichier temporaire) n'ont pas de sens pour ouvrir un
|
|
fichier de configuration à la main.
|
|
"""
|
|
if not editor.strip():
|
|
return ""
|
|
return editor.split()[0].rsplit("/", 1)[-1]
|
|
|
|
|
|
# Éditeurs que la VM sait se donner : binaire de l'hôte -> (paquet, binaire dans
|
|
# la VM). Le nom du paquet est le MÊME sur apt, dnf, zypper et pacman pour ces
|
|
# trois-là — vérifié pour chacun ; « vi » est fourni par vim, et le paquet
|
|
# neovim installe « nvim ».
|
|
#
|
|
# Cette table est la SEULE autorité, et elle décide de trois choses à la fois :
|
|
# le paquet que l'installation ajoute, la commande que le guide affiche, et la
|
|
# valeur de core.editor dans la VM. Les tenir liées est le point : un
|
|
# « core.editor = code » pointant un binaire absent fait échouer « git commit »
|
|
# (« cannot run code »), et un guide qui nomme une commande absente est pire que
|
|
# muet. Un éditeur hors de cette table est donc ignoré — pas deviné.
|
|
EDITOR_PACKAGES: dict[str, tuple[str, str]] = {
|
|
"vim": ("vim", "vim"),
|
|
"vi": ("vim", "vim"),
|
|
"nvim": ("neovim", "nvim"),
|
|
"neovim": ("neovim", "nvim"),
|
|
"nano": ("nano", "nano"),
|
|
}
|
|
|
|
|
|
def vm_editor(home: Path) -> tuple[str, str]:
|
|
"""(paquet, binaire) de l'éditeur à donner à la VM, ou deux chaînes vides."""
|
|
return EDITOR_PACKAGES.get(editor_binary(host_editor(home)), ("", ""))
|
|
|
|
|
|
def build_gitconfig(name: str, email: str, editor: str) -> str:
|
|
"""~/.gitconfig de la VM. Chaîne vide si l'hôte n'a rien à transmettre.
|
|
|
|
Une VM de développement sert à produire des commits, et un commit sans
|
|
identité est refusé par git (« Please tell me who you are ») : reprendre
|
|
celle de l'hôte évite de la retaper sur chaque machine, et surtout évite les
|
|
commits signés d'un « erplibre@<nom-de-vm> » que personne ne reconnaît.
|
|
|
|
INDENTATION EN ESPACES, jamais en tabulation. git accepte les deux, mais ce
|
|
texte part dans un scalaire bloc YAML où une tabulation en tête de ligne est
|
|
une erreur FATALE : cloud-init rejette alors le user-data en entier et la VM
|
|
démarre sans utilisateur ni clé SSH, donc inaccessible.
|
|
"""
|
|
lines: list[str] = []
|
|
if name or email:
|
|
lines.append("[user]")
|
|
if name:
|
|
lines.append(f" name = {name}")
|
|
if email:
|
|
lines.append(f" email = {email}")
|
|
if editor:
|
|
lines += ["[core]", f" editor = {editor}"]
|
|
return "\n".join(lines) + "\n" if lines else ""
|
|
|
|
|
|
# Les images cloud dont le module « keyboard » de cloud-init ne peut pas
|
|
# aboutir : il finit par « systemctl restart console-setup », service qui
|
|
# n'existe que si le paquet console-setup est installé, et l'image
|
|
# genericcloud de Debian ne le porte pas. Le réglage EST écrit avant cet
|
|
# échec — dans /etc/default/keyboard, que localed et X relisent — mais
|
|
# cloud-init se déclare en erreur, et ce mot masque les vraies pannes dans le
|
|
# compte-rendu de déploiement.
|
|
DISTROS_CLAVIER_PAR_FICHIER = ("debian",)
|
|
|
|
# Un locale se génère à partir de /etc/locale.gen et de nulle part ailleurs :
|
|
# « locale-gen xx_YY.UTF-8 » ne prend pas son argument pour une demande, il
|
|
# ne relit que ce fichier. Une famille qui n'a pas ce fichier — Fedora, la
|
|
# famille RHEL, openSUSE — embarque ses locales déjà générées.
|
|
LOCALE_A_GENERER = re.compile(r"^[a-z]{2,3}_[A-Z]{2}(\.|@|$)")
|
|
|
|
|
|
def locale_bootcmd_lines(locale: str) -> list[str]:
|
|
"""« bootcmd » qui GÉNÈRE le locale avant que cloud-init ne l'applique.
|
|
|
|
Sur Debian, « update-locale LANG=xx_YY.UTF-8 » refuse un locale absent de
|
|
ceux qui sont générés — « invalid locale settings » — et le module
|
|
« locale » se solde par une erreur : la VM reste en C.UTF-8, sans autre
|
|
signe que le compte-rendu de cloud-init, et le décalage ne se voit qu'aux
|
|
messages, longtemps après.
|
|
|
|
« bootcmd » et non « runcmd » : il tourne à l'étape init, AVANT les
|
|
modules de configuration dont « locale » fait partie. runcmd viendrait
|
|
après, et n'aurait plus rien à rattraper.
|
|
|
|
Il tourne aussi à CHAQUE démarrage, d'où le premier grep : sans lui,
|
|
/etc/locale.gen s'allonge d'une ligne identique par boot.
|
|
|
|
La ligne est DÉCOMMENTÉE plutôt qu'ajoutée : le fichier porte déjà le
|
|
catalogue entier en commentaires, et l'ajouter en produirait un doublon —
|
|
cloud-init décommente ensuite la même, à l'étape d'après. Le doublon est
|
|
inoffensif mais il ment sur qui a écrit quoi. L'ajout reste le recours
|
|
pour un locale que le catalogue ne porte pas.
|
|
"""
|
|
if not locale or not LOCALE_A_GENERER.match(locale):
|
|
return []
|
|
ligne = f"{locale} UTF-8"
|
|
corps = (
|
|
"[ -f /etc/locale.gen ] || exit 0; "
|
|
f"grep -qxF '{ligne}' /etc/locale.gen && exit 0; "
|
|
f"sed -i 's/^#[[:space:]]*{ligne}$/{ligne}/' /etc/locale.gen; "
|
|
f"grep -qxF '{ligne}' /etc/locale.gen "
|
|
f"|| echo '{ligne}' >> /etc/locale.gen; locale-gen"
|
|
)
|
|
return ["bootcmd:", f' - [ sh, -c, "{corps}" ]']
|
|
|
|
|
|
def keyboard_lines(distro: str, layout: str, variant: str) -> list[str]:
|
|
"""Bloc « keyboard » de cloud-init, ou rien là où il échouerait."""
|
|
if distro in DISTROS_CLAVIER_PAR_FICHIER:
|
|
return []
|
|
return ["keyboard:", f" layout: {layout}", f" variant: {variant}"]
|
|
|
|
|
|
def keyboard_files(
|
|
distro: str, layout: str, variant: str
|
|
) -> list[tuple[str, str, str, str]]:
|
|
"""/etc/default/keyboard écrit directement, là où cloud-init échoue.
|
|
|
|
C'est le fichier que le module de cloud-init aurait écrit, et celui que
|
|
localed et X relisent : le clavier d'une VM graphique est le même,
|
|
l'erreur de déploiement en moins.
|
|
|
|
La disposition de la CONSOLE texte n'est perdue pour personne : sur cette
|
|
image, ni console-setup ni systemd-vconsole-setup n'existent, et aucun
|
|
des deux chemins ne l'appliquait. Ce que cloud-init y écrivait était une
|
|
valeur que rien ne lisait.
|
|
"""
|
|
if distro not in DISTROS_CLAVIER_PAR_FICHIER:
|
|
return []
|
|
contenu = (
|
|
"XKBMODEL=pc105\n"
|
|
f"XKBLAYOUT={layout}\n"
|
|
f"XKBVARIANT={variant}\n"
|
|
"XKBOPTIONS=\n"
|
|
)
|
|
return [("/etc/default/keyboard", "0644", contenu, "")]
|
|
|
|
|
|
def write_files_lines(
|
|
entries: list[tuple[str, str, str, str]],
|
|
) -> list[str]:
|
|
"""Bloc « write_files » de cloud-init pour des fichiers TEXTE.
|
|
|
|
entries : (chemin, mode, contenu, propriétaire) ; propriétaire vide = root.
|
|
|
|
Deux règles YAML dont le non-respect coûte TOUTE la configuration — une
|
|
erreur de syntaxe fait rejeter le user-data en ENTIER, sans message sur la
|
|
console : la VM démarre nue, sans utilisateur ni clé SSH, inaccessible.
|
|
- Le mode est une CHAÎNE, entre guillemets. « permissions: 644 » non quoté
|
|
est lu comme 644 DÉCIMAL et appliqué tel quel, soit 0o1204 soit le bit
|
|
setuid allumé et des droits absurdes, sans le moindre avertissement.
|
|
- Le contenu est un scalaire bloc « | » indenté de six espaces, dont la
|
|
PREMIÈRE ligne non vide fixe l'indentation de référence : les suivantes
|
|
doivent être au moins aussi indentées. Les caractères d'encadrement
|
|
UTF-8 passent sans échappement.
|
|
|
|
Un propriétaire impose « defer: true » : write_files tourne à l'étape init,
|
|
AVANT la création des utilisateurs, donc le chown vers le compte de la VM
|
|
échouerait. Reporté à l'étape finale, il passe — et le suivi d'installation
|
|
attend de toute façon la fin de cloud-init avant de se connecter.
|
|
"""
|
|
out = ["write_files:"]
|
|
for path, mode, content, owner in entries:
|
|
out.append(f" - path: {path}")
|
|
out.append(f" permissions: '{mode}'")
|
|
if owner:
|
|
out.append(f" owner: {owner}:{owner}")
|
|
out.append(" defer: true")
|
|
out.append(" content: |")
|
|
# textwrap.indent laisse les lignes vides VIDES : six espaces résiduels
|
|
# survivraient au scalaire bloc et se retrouveraient dans le fichier,
|
|
# invisibles en revue et bien présents à l'écran.
|
|
out += textwrap.indent(content.rstrip("\n"), " ").split("\n")
|
|
return out
|
|
|
|
|
|
# Préfixe des fichiers d'accueil embarqués dans l'initrd de l'installateur.
|
|
# Un préfixe, et non un répertoire : le cpio est déplié séquentiellement et les
|
|
# répertoires parents manquants ne sont pas créés — une entrée
|
|
# « erplibre/etc-motd » sans entrée « erplibre » ferait échouer le dépliage de
|
|
# l'initrd ENTIER, donc l'installation. Les fichiers restent à la racine.
|
|
INSTALLER_GUIDE_PREFIX = "erplibre-"
|
|
|
|
|
|
def installer_guide_name(path: str) -> str:
|
|
"""Nom dans l'initrd du fichier destiné au chemin `path` de la VM.
|
|
|
|
« /etc/motd » -> « erplibre-etc-motd ». Un nom PLAT, dérivé du chemin : les
|
|
deux fonctions qui s'en servent (le preseed qui copie, l'initrd qui range)
|
|
le calculent de la même façon, donc elles ne peuvent pas diverger.
|
|
"""
|
|
return INSTALLER_GUIDE_PREFIX + path.strip("/").replace("/", "-")
|
|
|
|
|
|
# Où chaque famille de distribution range ses ancres de confiance, et par
|
|
# quelle commande elle les relit. La même table vit dans
|
|
# script/qemu_cache/rules.go, côté cache ; un test les compare, la dérive
|
|
# entre deux copies étant le seul risque de cette duplication.
|
|
#
|
|
# Les familles portent le nom de leur gestionnaire de paquets, comme
|
|
# « _QEMU_DISTRO_FAMILY » du menu.
|
|
# Trois valeurs par famille : où poser l'ancre, quelle commande relit le
|
|
# magasin, et quel FAISCEAU cette commande régénère.
|
|
CACHE_TRUST = {
|
|
"pacman": (
|
|
"/etc/ca-certificates/trust-source/anchors",
|
|
"trust extract-compat",
|
|
"/etc/ssl/certs/ca-certificates.crt",
|
|
),
|
|
"apt": (
|
|
"/usr/local/share/ca-certificates",
|
|
"update-ca-certificates",
|
|
"/etc/ssl/certs/ca-certificates.crt",
|
|
),
|
|
"dnf": (
|
|
"/etc/pki/ca-trust/source/anchors",
|
|
"update-ca-trust",
|
|
"/etc/pki/tls/certs/ca-bundle.crt",
|
|
),
|
|
# openSUSE n'écrit aucun « /etc/ssl/certs/ca-certificates.crt » : son
|
|
# faisceau est « ca-bundle.pem ». Une variable qui vise un fichier absent
|
|
# fait échouer pip sur « Could not find a suitable TLS CA certificate
|
|
# bundle », en ligne comme hors ligne.
|
|
"zypper": (
|
|
"/etc/pki/trust/anchors",
|
|
"update-ca-certificates",
|
|
"/etc/ssl/ca-bundle.pem",
|
|
),
|
|
}
|
|
|
|
# pip embarque son propre jeu de certificats et IGNORE le magasin système ;
|
|
# npm fait de même. Poser l'autorité suffit à pacman et à apt, pas à eux.
|
|
#
|
|
# Les variables visent le FAISCEAU, non le certificat du cache : pointer
|
|
# celui-là ferait perdre à pip toutes les autres autorités — il échouerait sur
|
|
# le premier hôte que le cache ne déchiffre pas, et le jour où le cache
|
|
# disparaît alors que la VM garde sa variable.
|
|
CACHE_ENV_VARS = ("PIP_CERT", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS")
|
|
|
|
# Ce qu'une VM déployée l'amont du cache coupé reçoit en plus. L'audit de npm
|
|
# interroge un service distant qu'aucun cache ne peut rejouer : hors ligne il
|
|
# échoue à chaque installation sans rien vérifier. La variable reste dans la
|
|
# VM après son installation ; un « npm config set audit true » la contredit.
|
|
# Séparée de CACHE_ENV_VARS, que le binaire du cache recopie et que le test
|
|
# d'accord avec le Go compare.
|
|
OFFLINE_ENV_VARS = (("NPM_CONFIG_AUDIT", "false"),)
|
|
|
|
# Ce qu'une VM déployée l'amont coupé exécute tôt au démarrage. Une image qui
|
|
# active systemd-time-wait-sync — celle d'Arch — retient time-sync.target
|
|
# jusqu'à la première synchronisation NTP, et « cloud-final » est ordonné
|
|
# après : sans serveur de temps joignable, l'étape finale ne démarre jamais, ni
|
|
# les clés d'hôte ssh qu'elle génère, et la VM reste sans ssh. L'arrêter lève
|
|
# l'attente ; une image qui ne l'active pas n'en voit aucun effet. « bootcmd »
|
|
# tourne à l'étape réseau de cloud-init, avant l'étape finale.
|
|
OFFLINE_BOOTCMD = [
|
|
"bootcmd:",
|
|
" - systemctl stop --no-block systemd-time-wait-sync.service || true",
|
|
]
|
|
|
|
# NixOS n'a pas d'ancre de confiance PAR FICHIER : /etc est généré depuis le
|
|
# store et monté en lecture seule, donc la forme de CACHE_TRUST — un
|
|
# répertoire où déposer, une commande qui relit — n'y existe pas. Le
|
|
# téléchargeur n'est pas non plus le même : mesuré, une LECTURE passe par le
|
|
# client et honore une variable de session, mais RÉALISER une dérivation
|
|
# passe par nix-daemon, qui ne la voit pas. Seul un fragment systemd
|
|
# l'atteint.
|
|
#
|
|
# /run/systemd/system est un tmpfs, donc inscriptible quand /etc ne l'est
|
|
# pas : le fragment y vit, et il prend effet sans reconstruction — ce qui est
|
|
# tout l'enjeu, la première reconstruction étant elle-même le premier
|
|
# téléchargement.
|
|
#
|
|
# Le faisceau est la CONCATÉNATION de celui du système et de l'autorité :
|
|
# donner l'autorité seule ferait cesser d'approuver tout le reste.
|
|
NIX_CA_DIR = "/var/lib/erplibre"
|
|
NIX_CA_BUNDLE = f"{NIX_CA_DIR}/ca-bundle.crt"
|
|
NIX_CA_SYSTEME = "/etc/ssl/certs/ca-certificates.crt"
|
|
NIX_DROPIN = "/run/systemd/system/nix-daemon.service.d/10-erplibre-cache.conf"
|
|
|
|
# Les variables qui portent le faisceau aux consommateurs d'une SESSION.
|
|
#
|
|
# Elles sont la seule voie ici : les quatre familles impératives déposent
|
|
# l'autorité dans le magasin du système, que tout lit sans qu'on demande, et
|
|
# qui SURVIT à sudo puisque ce n'est pas un environnement. NixOS n'a pas ce
|
|
# magasin — /etc/ssl/certs est un lien vers le store — donc chaque
|
|
# consommateur doit être pointé, et sudo les efface tous.
|
|
NIX_CA_VARS = (
|
|
"SSL_CERT_FILE",
|
|
"CURL_CA_BUNDLE",
|
|
"GIT_SSL_CAINFO",
|
|
"REQUESTS_CA_BUNDLE",
|
|
"NODE_EXTRA_CA_CERTS",
|
|
"PIP_CERT",
|
|
)
|
|
|
|
|
|
def nix_trust_commands() -> list[str]:
|
|
"""Ce qui fait approuver l'autorité du cache par nix, sans reconstruire.
|
|
|
|
Mesuré sur une VM interceptée : sans cela « nix-shell -p hello » échoue
|
|
sur « self-signed certificate in certificate chain » ; avec, la dérivation
|
|
est réalisée depuis cache.nixos.org à travers le cache, et le magasin
|
|
garde ses .narinfo — ce qui rend le hors ligne possible ensuite.
|
|
"""
|
|
dossier = NIX_DROPIN.rsplit("/", 1)[0]
|
|
return [
|
|
f"mkdir -p {NIX_CA_DIR} {dossier}",
|
|
f"cat {NIX_CA_SYSTEME} {NIX_CA_DIR}/{CACHE_CERT_NAME}"
|
|
f" > {NIX_CA_BUNDLE}",
|
|
f"printf '[Service]\\nEnvironment=NIX_SSL_CERT_FILE=%s\\n"
|
|
f"Environment=CURL_CA_BUNDLE=%s\\n' {NIX_CA_BUNDLE} {NIX_CA_BUNDLE}"
|
|
f" > {NIX_DROPIN}",
|
|
"systemctl daemon-reload",
|
|
# Socket-activé : arrêter les deux fait reprendre l'environnement à
|
|
# la prochaine connexion.
|
|
"systemctl stop nix-daemon 2>/dev/null || true",
|
|
"systemctl restart nix-daemon.socket 2>/dev/null || true",
|
|
# ET sudo, sans quoi rien de ce que la commande exporte ne survit :
|
|
# « sudo nixos-rebuild switch » évalue la configuration en allant
|
|
# chercher des centaines de narinfo, et les demandait toutes sans
|
|
# l'autorité. Les autres familles n'en ont pas besoin pour la même
|
|
# raison qu'elles n'ont pas besoin des variables : leur magasin
|
|
# système n'est pas un environnement.
|
|
commande_sudoers(NIX_CA_VARS),
|
|
]
|
|
|
|
|
|
CACHE_CERT_NAME = "erplibre-cache.crt"
|
|
|
|
# Les familles à qui AUCUN des trois gestes de CACHE_TRUST ne s'applique.
|
|
#
|
|
# « nix » en est une : NixOS n'a pas de dossier d'ancres inscriptible — /usr
|
|
# n'y existe qu'en lecture seule et /etc est produit par le système —, aucune
|
|
# commande de mise à jour du magasin, et son faisceau est un lien du store que
|
|
# seule une reconstruction change. Y recopier une ligne calquée sur apt
|
|
# rendrait le contrôle vert sans rien poser : la VM resterait interceptée et
|
|
# sans autorité, c'est-à-dire muette sur chaque téléchargement HTTPS.
|
|
#
|
|
# Le détournement, lui, porte sur tout le pont. Une VM de ces familles doit
|
|
# donc en être SOUSTRAITE, faute de quoi elle ne télécharge plus rien — et le
|
|
# message qu'elle rendrait, « self-signed certificate in certificate chain »,
|
|
# ne dit rien d'une famille sans magasin.
|
|
# Plus aucune famille n'y figure. « nix » y était tant qu'on ne savait pas
|
|
# lui donner l'autorité : il l'a désormais par un fragment systemd, mesuré.
|
|
# La table reste, et le repli avec elle — une famille future sans magasin
|
|
# retomberait dessus plutôt que d'être interceptée sans rien.
|
|
CACHE_SANS_AUTORITE: frozenset = frozenset()
|
|
|
|
|
|
def cache_sans_autorite(distro: str) -> bool:
|
|
"""La VM de `distro` peut-elle recevoir l'autorité du cache ?
|
|
|
|
Non quand sa famille n'a pas de magasin de certificats où l'écrire. Le
|
|
déploiement l'exempte alors du détournement plutôt que de la laisser
|
|
échouer sur un certificat qu'elle ne peut pas apprendre.
|
|
"""
|
|
famille = cache_family(distro)
|
|
return bool(famille) and famille in CACHE_SANS_AUTORITE
|
|
|
|
|
|
def cache_family(distro: str) -> str:
|
|
"""Famille de gestionnaire de paquets d'un système du catalogue.
|
|
|
|
Lue de DISTRO_PKG, qui est LA table du catalogue, plutôt que recopiée
|
|
ici : une copie dérive, et le système qu'elle oublie est déployé SANS
|
|
l'autorité du cache, alors que le détournement, lui, porte sur tout le
|
|
pont. Chaque téléchargement HTTPS y échoue sur « self-signed certificate
|
|
in certificate chain », message qui ne dit rien d'une table incomplète.
|
|
"""
|
|
return DISTRO_PKG.get(distro, "")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Soustraire une VM au cache : l'exception par adresse MAC
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Le détournement du cache est TRANSPARENT et vaut pour tout le pont. Ne pas
|
|
# donner l'autorité à une VM ne la dispense donc pas d'être interceptée : elle
|
|
# reçoit un certificat qu'elle ne reconnaît pas et échoue sur « self-signed
|
|
# certificate in certificate chain ». La seule exception qui vaille est posée
|
|
# sur l'HÔTE, et elle a besoin d'un identifiant stable — l'adresse MAC, fixée
|
|
# dans la définition du domaine, là où l'adresse IP vient d'un bail.
|
|
#
|
|
# D'où l'ordre imposé ici : la MAC est CHOISIE avant la création, l'exception
|
|
# est posée, et la VM démarre ensuite. L'inverse — créer puis lire la MAC —
|
|
# laisserait la fenêtre où cloud-init télécharge déjà.
|
|
CACHE_BIN = "/usr/local/bin/erplibre_go_qemu_cache"
|
|
CACHE_SERVICE = "erplibre-go-qemu-cache.service"
|
|
|
|
# Le préfixe que QEMU/KVM se voit attribuer. S'en écarter ferait passer la VM
|
|
# pour une machine d'un autre constructeur auprès de ce qui lit les OUI.
|
|
MAC_PREFIXE = "52:54:00"
|
|
|
|
|
|
def mac_du_network(network: str) -> str:
|
|
"""La MAC déjà demandée dans l'argument --network, ou "".
|
|
|
|
Une MAC posée à la main l'emporte : l'appelant sait ce qu'il veut, et lui
|
|
en substituer une autre casserait une réservation DHCP.
|
|
"""
|
|
for champ in network.split(","):
|
|
cle, _, valeur = champ.partition("=")
|
|
if cle.strip() == "mac":
|
|
return valeur.strip()
|
|
return ""
|
|
|
|
|
|
def macs_deja_prises(runner: Runner) -> set[str]:
|
|
"""Les MAC que portent les domaines existants, en minuscules.
|
|
|
|
Libvirt refuse une MAC en double, mais l'erreur arrive au moment de la
|
|
création, après le téléchargement de l'image. La lire avant coûte deux
|
|
appels et rend le refus immédiat.
|
|
"""
|
|
code, sortie = runner.run(
|
|
["virsh", "-c", LIBVIRT_URI, "list", "--all", "--name"],
|
|
privileged=True,
|
|
check=False,
|
|
capture=True,
|
|
)
|
|
if code:
|
|
return set()
|
|
prises: set[str] = set()
|
|
for nom in (sortie or "").split("\n"):
|
|
nom = nom.strip()
|
|
if not nom:
|
|
continue
|
|
code, xml = runner.run(
|
|
["virsh", "-c", LIBVIRT_URI, "domiflist", nom],
|
|
privileged=True,
|
|
check=False,
|
|
capture=True,
|
|
)
|
|
if code:
|
|
continue
|
|
prises.update(
|
|
m.lower()
|
|
for m in re.findall(
|
|
r"\b([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})\b", xml or ""
|
|
)
|
|
)
|
|
return prises
|
|
|
|
|
|
def mac_neuve(prises: set[str]) -> str:
|
|
"""Une MAC du préfixe QEMU qu'aucun domaine ne porte.
|
|
|
|
Le tirage est aléatoire sur trois octets : la collision est improbable,
|
|
mais elle n'apparaîtrait qu'à la création, une fois l'image téléchargée.
|
|
Une poignée d'essais suffit à la rendre impossible en pratique.
|
|
"""
|
|
for _ in range(64):
|
|
octets = [secrets.randbelow(256) for _ in range(3)]
|
|
mac = MAC_PREFIXE + ":" + ":".join(f"{o:02x}" for o in octets)
|
|
if mac not in prises:
|
|
return mac
|
|
raise RuntimeError("aucune adresse MAC libre après 64 tirages")
|
|
|
|
|
|
def network_avec_mac(network: str, mac: str) -> str:
|
|
"""Ajoute « mac=… » à l'argument --network, sans toucher au reste."""
|
|
if mac_du_network(network):
|
|
return network
|
|
return f"{network},mac={mac}"
|
|
|
|
|
|
def cache_bypass_apply(args: argparse.Namespace, runner: Runner) -> str:
|
|
"""Pose l'exception et rend la MAC retenue, ou "" si rien n'est à faire.
|
|
|
|
Rend "" sans se plaindre quand le cache n'est pas là ou ne tourne pas :
|
|
dans ce cas rien n'intercepte, et la VM télécharge en direct — ce que
|
|
l'appelant demandait. Se plaindre alors serait exiger d'installer un cache
|
|
pour pouvoir s'en passer.
|
|
|
|
L'exception est écrite dans un fichier ET posée à chaud dans l'ensemble
|
|
nftables. Le fichier la fait survivre au redémarrage du service ;
|
|
l'ensemble évite d'avoir à reposer les règles, ce qui couperait les
|
|
téléchargements des autres VM en cours.
|
|
"""
|
|
if not getattr(args, "cache_bypass", False):
|
|
return ""
|
|
if not os.path.isfile(CACHE_BIN):
|
|
print(
|
|
" cache absent de cet hôte : rien n'intercepte, rien à excepter"
|
|
)
|
|
return ""
|
|
code, _ = runner.run(
|
|
["systemctl", "is-active", "--quiet", CACHE_SERVICE],
|
|
check=False,
|
|
capture=True,
|
|
)
|
|
if code and not args.dry_run:
|
|
print(" cache arrêté : rien n'intercepte, rien à excepter")
|
|
return ""
|
|
|
|
mac = mac_du_network(args.network) or mac_neuve(macs_deja_prises(runner))
|
|
args.network = network_avec_mac(args.network, mac)
|
|
|
|
geste = (
|
|
f"{shlex.quote(CACHE_BIN)} --bypass-add {shlex.quote(mac)}"
|
|
f" --bypass-name {shlex.quote(args.name)}"
|
|
# La commande passe par sudo, qui retire EL_LANG : la langue du
|
|
# message du binaire va donc en option.
|
|
f" --lang {shlex.quote(langue_des_messages(args))}"
|
|
)
|
|
if shutil.which("nft"):
|
|
# Le binaire écrit le fichier et rend sur sa sortie le geste à chaud.
|
|
runner.run(["sh", "-c", f"{geste} | nft -f -"], privileged=True)
|
|
else:
|
|
# Sans nft, les règles sont des « -A » iptables sans ensemble nommé :
|
|
# l'exception ne peut entrer qu'en reposant la chaîne entière, ce que
|
|
# fait le redémarrage du service.
|
|
runner.run(["sh", "-c", geste], privileged=True)
|
|
runner.run(["systemctl", "restart", CACHE_SERVICE], privileged=True)
|
|
print(f" VM soustraite au cache : {mac}")
|
|
return mac
|
|
|
|
|
|
def langue_des_messages(args: argparse.Namespace) -> str:
|
|
"""La langue des messages : --lang du déploiement, sinon celle de todo.py,
|
|
sinon le français. Rend toujours « fr » ou « en »."""
|
|
langue = (getattr(args, "lang", "") or "").strip().lower()
|
|
if not langue:
|
|
try:
|
|
from script.todo.todo_i18n import get_lang
|
|
|
|
langue = get_lang()
|
|
except Exception:
|
|
langue = "fr"
|
|
return "en" if langue.startswith("en") else "fr"
|
|
|
|
|
|
def cache_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]:
|
|
"""L'autorité du cache, posée par cloud-init à l'étape init.
|
|
|
|
Rend une liste vide quand aucune autorité n'est demandée, ou quand la
|
|
distribution n'est pas dans la table : mieux vaut une VM qui télécharge
|
|
en direct qu'une VM dont le magasin de confiance a reçu un fichier au
|
|
mauvais endroit, où il ne servirait à rien sans que rien ne le dise.
|
|
|
|
Le fichier est écrit à l'étape INIT, donc avant « runcmd » et avant tout
|
|
téléchargement : ce dépôt n'installe aucun paquet par cloud-init et laisse
|
|
« package_update » à faux, si bien que rien ne sort sur le réseau entre le
|
|
démarrage et la commande de confiance.
|
|
"""
|
|
if not args.cache_ca:
|
|
return []
|
|
# Les deux ensemble n'ont pas de sens : une VM exceptée ne rencontre
|
|
# jamais le cache, et lui faire approuver cette autorité poserait dans son
|
|
# magasin une signature dont rien ne se sert. L'exception l'emporte, étant
|
|
# la demande la plus précise.
|
|
if getattr(args, "cache_bypass", False):
|
|
return []
|
|
famille = cache_family(args.distro)
|
|
if famille != "nix" and famille not in CACHE_TRUST:
|
|
return []
|
|
try:
|
|
with open(args.cache_ca, encoding="utf-8") as fh:
|
|
pem = fh.read()
|
|
except OSError:
|
|
# Une autorité illisible ne doit pas faire échouer un déploiement :
|
|
# sans elle, la VM télécharge en direct, ce qui marche.
|
|
return []
|
|
if "BEGIN CERTIFICATE" not in pem:
|
|
return []
|
|
anchors = NIX_CA_DIR if famille == "nix" else CACHE_TRUST[famille][0]
|
|
return [(f"{anchors}/{CACHE_CERT_NAME}", "0644", pem, "")]
|
|
|
|
|
|
def cache_commands(args: argparse.Namespace) -> list[str]:
|
|
"""Ce qui rend l'autorité effective, et ce que pip et npm exigent en plus.
|
|
|
|
Des commandes SHELL, dans cet ordre : relire le magasin de confiance, puis
|
|
écrire les variables dans /etc/environment — que PAM lit pour TOUTE
|
|
session ssh, interactive ou non, ce qui est la seule façon d'atteindre le
|
|
bootstrap d'installation lancé par commande distante.
|
|
|
|
Du shell et non du YAML : cloud-init n'est pas la seule voie de livraison.
|
|
Une VM née sur un hôte Proxmox reçoit les mêmes gestes par ssh, « qm set »
|
|
ne sachant écrire aucun fichier. Une source unique, deux emballages.
|
|
|
|
Chaque commande est tolérante à son propre échec : une autorité déjà
|
|
approuvée, ou une variable déjà écrite, n'est pas une raison d'arrêter.
|
|
"""
|
|
if not cache_files(args):
|
|
return []
|
|
famille = cache_family(args.distro)
|
|
# NixOS ne relit pas un magasin : il n'en a pas de la forme attendue. Ses
|
|
# gestes bâtissent un faisceau, le donnent au démon qui télécharge, et
|
|
# font traverser sudo aux variables qui le désignent. Pas /etc/environment
|
|
# en revanche : son pam_env porte « readenv=0 » et son fichier de
|
|
# configuration est un lien vers le store.
|
|
if famille == "nix":
|
|
return nix_trust_commands()
|
|
_, commande, faisceau = CACHE_TRUST[famille]
|
|
commandes = [f"{commande} || true"]
|
|
for var in CACHE_ENV_VARS:
|
|
commandes.append(
|
|
f"sh -c 'grep -q ^{var}= /etc/environment"
|
|
f" || echo {var}={faisceau} >> /etc/environment'"
|
|
)
|
|
gardees = list(CACHE_ENV_VARS)
|
|
if getattr(args, "offline", False):
|
|
for var, valeur in OFFLINE_ENV_VARS:
|
|
commandes.append(
|
|
f"sh -c 'grep -q ^{var}= /etc/environment"
|
|
f" || echo {var}={valeur} >> /etc/environment'"
|
|
)
|
|
gardees.append(var)
|
|
commandes.append(commande_sudoers(gardees))
|
|
return commandes
|
|
|
|
|
|
CACHE_SUDOERS = "/etc/sudoers.d/erplibre-cache"
|
|
|
|
|
|
def commande_sudoers(variables, fichier: str = CACHE_SUDOERS) -> str:
|
|
"""La commande qui fait traverser « sudo » aux variables du cache.
|
|
|
|
sudo remet l'environnement à zéro. Seul un module PAM qui relit
|
|
/etc/environment pour sudo y ramène les variables, et toutes les
|
|
distributions ne le configurent pas : sans lui, « sudo npm install -g »
|
|
rejette l'autorité du cache sur « self-signed certificate in certificate
|
|
chain », quand le même npm sans sudo l'accepte. « env_keep » les garde
|
|
partout, pourvu que la session appelante les porte — ce que
|
|
cache_env_reload lui assure.
|
|
|
|
Le fichier est écrit sous un nom à point, que sudo ignore, vérifié par
|
|
« visudo -c », puis renommé : un fichier invalide dans sudoers.d rendrait
|
|
sudo inutilisable sur toute la machine. Un échec — visudo absent, pas de
|
|
répertoire sudoers.d — retire le temporaire et ne fait pas échouer la
|
|
commande. Une variable par ligne, sans guillemets : la commande passe
|
|
telle quelle dans un « runcmd » YAML comme dans un « sh -c » par ssh.
|
|
|
|
LE PROFIL DU SYSTÈME EST AJOUTÉ AU PATH. cloud-init ne donne à son
|
|
« runcmd » que le PATH de son propre service, et sur une distribution
|
|
déclarative celui-ci est une liste de chemins du store qui ne contient
|
|
pas sudo : visudo est alors introuvable, la vérification échoue, et le
|
|
garde-fou efface le fichier au lieu de le poser — sans rien dire, puisque
|
|
c'est précisément sa dégradation prévue. Le répertoire n'existe pas sur
|
|
les quatre familles impératives, où l'ajout ne change rien.
|
|
"""
|
|
dossier, nom = fichier.rsplit("/", 1)
|
|
tmp = f"{dossier}/.{nom}"
|
|
return (
|
|
f"sh -c 'PATH=$PATH:/run/current-system/sw/bin;"
|
|
f" for v in {' '.join(variables)};"
|
|
f" do echo Defaults env_keep += $v; done > {tmp}"
|
|
f" && chmod 0440 {tmp} && visudo -cf {tmp} && mv {tmp} {fichier}"
|
|
f' || (rm -f {tmp}; echo " ⚠ {t_sudoers_manque()}")\''
|
|
)
|
|
|
|
|
|
def t_sudoers_manque() -> str:
|
|
"""Ce que dit un sudoers non posé.
|
|
|
|
L'échec est VOLONTAIREMENT sans conséquence sur le déploiement — un
|
|
sudoers.d absent ne vaut pas de tout arrêter. Mais muet, il se paie plus
|
|
tard et ailleurs, sur un « self-signed certificate » que rien ne relie à
|
|
ce geste. La ligne nomme le seul effet qui compte, et ce qu'il faut
|
|
regarder.
|
|
|
|
TROIS CARACTÈRES INTERDITS, et chacun casse ailleurs. L'apostrophe ferme
|
|
la quote simple qui enveloppe toute la commande, et le shell distant
|
|
meurt sur « unexpected EOF ». « : » suivi d'une espace, une accolade ou un
|
|
crochet font lire au YAML de « runcmd » autre chose qu'un scalaire
|
|
simple. Le message ne les porte donc pas, et un test le vérifie.
|
|
"""
|
|
return (
|
|
"sudoers non posé — visudo introuvable, "
|
|
"sudo perdra le faisceau du cache"
|
|
)
|
|
|
|
|
|
def cache_runcmd(args: argparse.Namespace) -> list[str]:
|
|
"""Les mêmes gestes, emballés en éléments de « runcmd » pour cloud-init."""
|
|
return [f" - {c}" for c in cache_commands(args)]
|
|
|
|
|
|
def cache_env_reload(fichier: str = "/etc/environment") -> str:
|
|
"""Relit les variables du cache dans la session shell EN COURS.
|
|
|
|
PAM lit /etc/environment à l'ouverture d'une session, jamais après. Une
|
|
commande distante qui attend cloud-init s'ouvre AVANT que runcmd n'y écrive
|
|
les variables : sa session ne les reçoit pas, alors que chaque « sudo », qui
|
|
rouvre une session PAM, les voit. Un « npm install » lancé sans sudo rejette
|
|
alors l'autorité du cache — « self-signed certificate in certificate chain »
|
|
— là où « sudo npm install -g » réussit une ligne plus haut.
|
|
|
|
Seules les variables du cache sont exportées : relire le fichier entier
|
|
remplacerait aussi le PATH de la session. Rend une instruction shell sans
|
|
séparateur final, sans effet quand le fichier est absent ou ne les porte
|
|
pas, et qui ne fait pas échouer une commande sous « set -e ».
|
|
"""
|
|
motif = "|".join(CACHE_ENV_VARS + tuple(v for v, _ in OFFLINE_ENV_VARS))
|
|
return (
|
|
f'if [ -r {fichier} ]; then eval "$(grep -E "^({motif})=" {fichier}'
|
|
' | sed "s/^/export /")"; fi'
|
|
)
|
|
|
|
|
|
def attente_cloud_final(bornes: int = 150) -> str:
|
|
"""Attend que l'ÉTAPE FINALE de cloud-init ait fini d'écrire.
|
|
|
|
« cloud-init status --wait » rend la main dès que cloud-init se déclare en
|
|
ERREUR, et un module accessoire suffit à l'y mettre — une locale que
|
|
l'invité ne connaît pas, par exemple. Son étape finale, elle, continue :
|
|
c'est elle qui pose l'autorité du cache, les variables de /etc/environment
|
|
et le fichier sudoers. Une session ouverte dans cette seconde-là vit sans
|
|
ces variables pour toute sa durée — PAM ne relit plus le fichier — et
|
|
« sudo » n'a alors rien à conserver, si bien qu'un npm lancé par sudo
|
|
rejette l'autorité du cache sur « self-signed certificate in certificate
|
|
chain ».
|
|
|
|
L'unité tranche, mais PAS par « is-active » : cloud-final est un service
|
|
« oneshot » qui reste ACTIF une fois terminé — is-active y est vrai pour
|
|
toujours, et attendre là-dessus coûte la borne entière à chaque VM, sans
|
|
rien détecter. Seul « activating » dit que l'étape écrit encore.
|
|
|
|
Bornée à `bornes` tours de deux secondes. Sans effet là où l'unité n'existe
|
|
pas : « show » y rend un état vide ou « inactive », jamais « activating » —
|
|
une image sans cloud-init, ou une VM déjà installée, n'attendent rien.
|
|
"""
|
|
return (
|
|
'n=0; while [ "$(systemctl show -p ActiveState --value'
|
|
' cloud-final.service 2>/dev/null)" = activating ]; do n=$((n+1));'
|
|
f" [ $n -ge {bornes} ] && break; sleep 2; done"
|
|
)
|
|
|
|
|
|
def guide_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]:
|
|
"""Fichiers d'accueil de la VM : le guide de connexion, l'identité git.
|
|
|
|
Une seule source pour les deux voies de déploiement — cloud-init l'écrit
|
|
par write_files, l'installateur Debian par son late_command.
|
|
"""
|
|
home = invoking_home()
|
|
editor = "" if args.no_git_identity else vm_editor(home)[1]
|
|
files = [
|
|
(
|
|
"/etc/motd",
|
|
"0644",
|
|
build_motd(
|
|
args.distro,
|
|
args.version,
|
|
args.arch,
|
|
args.lang,
|
|
args.erplibre_dir,
|
|
args.erplibre_make,
|
|
editor,
|
|
bool(args.desktop),
|
|
# « getattr » et non « args.vm_tools » : la voie Proxmox
|
|
# bâtit ses arguments à la main, et un champ qui lui manque
|
|
# ferait avaler le guide par son « except Exception ».
|
|
tuple(
|
|
c.strip()
|
|
for c in getattr(args, "vm_tools", "").split(",")
|
|
if c.strip()
|
|
),
|
|
),
|
|
"",
|
|
)
|
|
]
|
|
if args.no_git_identity:
|
|
return files
|
|
# Ce que le formulaire a saisi PRIME sur l'identité de l'hôte, champ par
|
|
# champ : remplir le seul courriel ne doit pas effacer le nom. Vide, on
|
|
# retombe sur l'hôte, qui reste le comportement par défaut.
|
|
gitconfig = build_gitconfig(
|
|
getattr(args, "git_name", "") or _git_global("user.name", home),
|
|
getattr(args, "git_email", "") or _git_global("user.email", home),
|
|
editor,
|
|
)
|
|
if gitconfig:
|
|
files.append(
|
|
(f"/home/{args.user}/.gitconfig", "0644", gitconfig, args.user)
|
|
)
|
|
return files
|
|
|
|
|
|
def build_cloud_config(
|
|
args: argparse.Namespace, pw_hash: str | None, ssh_keys: list[str]
|
|
) -> str:
|
|
"""Construit le contenu #cloud-config (user-data)."""
|
|
lines: list[str] = ["#cloud-config", f"hostname: {args.hostname}"]
|
|
|
|
# « off » est le seul refus explicite ; « auto » laisse l'hôte décider
|
|
# s'il accélère, mais l'invité porte un virtio-gpu dans les deux cas et
|
|
# l'appartenance aux groupes ne coûte rien quand elle ne sert pas.
|
|
gpu = (getattr(args, "gpu", "auto") or "auto").lower() != "off"
|
|
if gpu:
|
|
lines += gpu_group_block()
|
|
|
|
user_block = [
|
|
"users:",
|
|
f" - name: {args.user}",
|
|
" sudo: ALL=(ALL) NOPASSWD:ALL",
|
|
# Le groupe d'administration N'A PAS le même nom partout, et un nom
|
|
# inconnu fait échouer « useradd -G » : l'utilisateur n'est alors jamais
|
|
# créé, donc ni mot de passe ni clé SSH — la VM démarre et reste
|
|
# inaccessible. C'était déjà la cause du « Debian ne marche pas »
|
|
# (« admin » n'existe pas sur Debian) ; la famille RHEL et Arch ont le
|
|
# même écart, elles n'ont pas de groupe « sudo » mais « wheel ».
|
|
# Le privilège lui-même vient de la ligne « sudo: » ci-dessus, pas du
|
|
# groupe : celui-ci n'est qu'une commodité.
|
|
f" groups: {user_groups(args.distro, gpu)}",
|
|
f" shell: {user_shell(args.distro)}",
|
|
" lock_passwd: false" if pw_hash else " lock_passwd: true",
|
|
]
|
|
if pw_hash:
|
|
user_block.append(f' passwd: "{pw_hash}"')
|
|
if ssh_keys:
|
|
user_block.append(" ssh_authorized_keys:")
|
|
user_block += [f" - {k}" for k in ssh_keys]
|
|
lines += user_block
|
|
|
|
lines.append(f"ssh_pwauth: {'true' if pw_hash else 'false'}")
|
|
lines.append(f"locale: {args.locale}")
|
|
# Canonicalisé ICI, au plus près de l'écriture : un fuseau passé
|
|
# explicitement en ligne de commande mérite la même traduction que celui
|
|
# de l'hôte.
|
|
lines.append(f"timezone: {canonical_timezone(args.timezone)}")
|
|
if getattr(args, "distro", "ubuntu") == "ubuntu":
|
|
lines += apt_mirror_lines(
|
|
getattr(args, "arch", "amd64"),
|
|
getattr(args, "apt_mirror", None),
|
|
fixe=bool(getattr(args, "cache_ca", None)),
|
|
)
|
|
distro = getattr(args, "distro", "ubuntu")
|
|
lines += locale_bootcmd_lines(args.locale)
|
|
lines += keyboard_lines(
|
|
distro, args.keyboard_layout, args.keyboard_variant
|
|
)
|
|
# Guide de connexion et identité git : posés par cloud-init, donc présents
|
|
# dès le PREMIER boot. C'est le point : ils sont là avant l'installation
|
|
# d'ERPLibre, et encore là si elle échoue — le moment où l'on se connecte
|
|
# justement à la main.
|
|
# Trois sources pour un seul bloc « write_files » : le guide de
|
|
# connexion, le clavier là où cloud-init ne sait pas le poser, et les
|
|
# fichiers du cache. write_files_lines reçoit UNE liste et n'émet qu'une
|
|
# clé : les concaténer ne peut pas produire le doublon qui ferait perdre
|
|
# le premier bloc en silence.
|
|
lines += write_files_lines(
|
|
guide_files(args)
|
|
+ keyboard_files(distro, args.keyboard_layout, args.keyboard_variant)
|
|
+ cache_files(args)
|
|
)
|
|
# apt update/upgrade désactivés par défaut : sur un réseau lent/instable
|
|
# ils font pendre cloud-init au 1er boot (et retardent la dispo SSH). SSH
|
|
# est déjà présent dans les images cloud ; on l'active via runcmd sans apt.
|
|
# --apt-update réactive apt update (+ upgrade, sauf --no-upgrade).
|
|
do_update = args.apt_update
|
|
do_upgrade = args.apt_update and not args.no_upgrade
|
|
lines.append(f"package_update: {'true' if do_update else 'false'}")
|
|
lines.append(f"package_upgrade: {'true' if do_upgrade else 'false'}")
|
|
|
|
# On n'installe AUCUN paquet via cloud-init : cela exige le réseau au 1er
|
|
# boot et peut bloquer longtemps (dnf/apt/pacman lents sur réseau lent) —
|
|
# cloud-init reste alors « running » et tout ce qui suit attend. sshd est
|
|
# DÉJÀ présent dans toutes les images cloud (Ubuntu/Debian/Fedora/Arch) ;
|
|
# on l'active seulement (runcmd). Les outils nécessaires (curl/git/make…)
|
|
# sont installés — avec dépôts optimisés — par le bootstrap d'installation.
|
|
packages = list(dict.fromkeys(args.package)) # seulement --package
|
|
if packages:
|
|
lines.append("packages:")
|
|
lines += [f" - {p}" for p in packages]
|
|
# Active et démarre SSH quel que soit le nom du service (ssh sur
|
|
# Debian/Ubuntu, sshd sur Fedora/Arch) — sans quoi la VM peut booter
|
|
# sans SSH accessible.
|
|
if getattr(args, "offline", False):
|
|
lines += OFFLINE_BOOTCMD
|
|
lines += ["runcmd:"]
|
|
# En TÊTE : ce qui suit peut télécharger, et sans magasin de confiance à
|
|
# jour un invité rejette le certificat que le cache présente.
|
|
lines += cache_runcmd(args)
|
|
lines += [
|
|
" - systemctl enable --now ssh 2>/dev/null"
|
|
" || systemctl enable --now sshd 2>/dev/null || true",
|
|
# Getty sur la console qui EXISTE VRAIMENT.
|
|
#
|
|
# Sur s390x, l'image attend /dev/ttysclp0 (généré depuis la ligne de
|
|
# commande noyau) alors que le périphérique réellement présent porte un
|
|
# autre nom : « Timed out waiting for device dev-ttysclp0.device », puis
|
|
# « Dependency failed for serial-getty@ttysclp0 ». La console affiche
|
|
# donc tout le démarrage mais n'offre AUCUNE invite de connexion — elle
|
|
# est en lecture seule par accident, et c'est justement le seul recours
|
|
# 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.
|
|
#
|
|
# Son installation tire plusieurs paquets de virtualisation, et sur une
|
|
# architecture émulée elle tient cloud-final une dizaine de minutes. Or
|
|
# le suivi d'installation attend « cloud-init status: done » : autant
|
|
# 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.
|
|
#
|
|
# Chaque branche de gestionnaire est entre accolades. Sans elles, « && »
|
|
# et « || » se lisent à égalité de gauche à droite : une pose apt
|
|
# réussie enchaîne sur « dnf install », puis sur le « command -v »
|
|
# d'un gestionnaire absent, qui rend 127 sous dash — et le service
|
|
# finit en échec après avoir posé l'agent. Le nom du service est celui
|
|
# qu'attend _qemu_cloud_init_wait, côté hôte.
|
|
" - 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)) ||"
|
|
" (command -v dnf >/dev/null && timeout 300 dnf install -y"
|
|
" qemu-guest-agent) || (command -v pacman >/dev/null && timeout 300"
|
|
" pacman -Sy --noconfirm qemu-guest-agent) || true",
|
|
# Autorise guest-exec (bloqué par défaut sur certaines distros).
|
|
# On VIDE la liste de blocage (block-rpcs/blacklist) plutôt que
|
|
# d'utiliser allow-rpcs (liste BLANCHE : casserait les autres RPC).
|
|
# Les deux clés couvrent les versions anciennes (blacklist) et
|
|
# récentes (block-rpcs) de qemu-ga.
|
|
" - mkdir -p /etc/qemu && printf '[general]\\nblock-rpcs=\\n"
|
|
"blacklist=\\n' > /etc/qemu/qemu-ga.conf || true",
|
|
# Fedora/RHEL bloquent guest-exec via /etc/sysconfig/qemu-ga.
|
|
# « test -f » et NON « [ -f … ] » : en YAML, « - [ » démarre une
|
|
# séquence en flux -> user-data invalide -> cloud-init rejeté (aucun
|
|
# utilisateur créé, VM figée, login console impossible).
|
|
" - test -f /etc/sysconfig/qemu-ga && sed -i"
|
|
" 's/^BLACKLIST_RPC=.*/BLACKLIST_RPC=/'"
|
|
" /etc/sysconfig/qemu-ga || true",
|
|
" - systemctl enable qemu-guest-agent 2>/dev/null"
|
|
" || systemctl enable qemu-ga 2>/dev/null || true",
|
|
# restart (et non enable --now) : recharge la config si l'agent était
|
|
# déjà démarré par l'image cloud.
|
|
" - systemctl restart qemu-guest-agent 2>/dev/null"
|
|
" || systemctl restart qemu-ga 2>/dev/null || true",
|
|
]
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
# network-config (cloud-init v2) : DHCP sur toute interface « e* ». Les images
|
|
# Debian genericcloud ne configurent pas toujours le réseau sans ça (le NIC
|
|
# reste down -> pas d'IP), contrairement à Ubuntu. Inoffensif pour Ubuntu.
|
|
# La clé n'est pas partout un nom d'interface. Les renderers netplan et ENI
|
|
# (Ubuntu, Debian) la tiennent pour une étiquette et désignent l'interface par
|
|
# le « match » ; le renderer networkd (Arch, NixOS) IGNORE le « match » et
|
|
# écrit la clé telle quelle en « Name= ». L'image Arch nomme son NIC eth0, ce
|
|
# que la clé couvre ; une image qui le nomme enp0s2 reçoit un « Name=eth0 »
|
|
# qui ne correspond à rien, systemd-networkd-wait-online attend alors sans
|
|
# fin, et TOUT ce qui suit network-online.target reste en file — sshd-keygen,
|
|
# cloud-init.service, cloud-config.service, sshd lui-même. La VM démarre,
|
|
# applique la clé SSH, et n'est jamais joignable.
|
|
#
|
|
# Un glob en clé réglerait networkd et CASSERAIT netplan, qui refuse net :
|
|
# « Definition ID 'e*' must not use globbing », cloud-init.service en échec,
|
|
# aucun compte créé. Aucune valeur ne contente les deux renderers, d'où
|
|
# network_config_for().
|
|
NETWORK_CONFIG = (
|
|
"version: 2\n"
|
|
"ethernets:\n"
|
|
" eth0:\n"
|
|
" match:\n"
|
|
' name: "e*"\n'
|
|
" dhcp4: true\n"
|
|
" dhcp6: false\n"
|
|
)
|
|
|
|
|
|
def network_config_for(distro: str) -> str | None:
|
|
"""Le network-config à mettre dans le seed, ou None pour n'en pas mettre.
|
|
|
|
None n'est pas « pas de réseau » : privée de configuration réseau par sa
|
|
source de données, cloud-init produit son repli — DHCP sur la première
|
|
interface trouvée, désignée par son VRAI nom. C'est ce qu'il faut là où
|
|
le renderer prend la clé pour un nom d'interface et où ce nom n'est pas
|
|
connu d'avance.
|
|
|
|
NixOS est dans ce cas : son image nomme le NIC selon la machine émulée
|
|
(enp0s2 en q35, ens3 en i440fx). Arch a le même renderer mais nomme le
|
|
sien eth0, ce que la clé du document couvre : il garde donc le document,
|
|
et son réseau ne change pas.
|
|
"""
|
|
return None if distro == "nixos" else NETWORK_CONFIG
|
|
|
|
|
|
def build_seed(
|
|
cloud_cfg: str,
|
|
hostname: str,
|
|
seed_dest: Path,
|
|
runner: Runner,
|
|
network_config: str | None = NETWORK_CONFIG,
|
|
) -> None:
|
|
"""Génère le seed.iso (cidata) et le copie vers seed_dest.
|
|
|
|
`network_config` à None n'écrit PAS de document réseau dans le seed, et
|
|
c'est un réglage à part entière : cloud-init produit alors son repli,
|
|
DHCP sur la première interface trouvée. Voir network_config_for().
|
|
"""
|
|
meta_data = f"instance-id: {hostname}\nlocal-hostname: {hostname}\n"
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
tmp_path = Path(tmp)
|
|
ud = tmp_path / "user-data"
|
|
md = tmp_path / "meta-data"
|
|
nc = tmp_path / "network-config"
|
|
local_iso = tmp_path / "seed.iso"
|
|
|
|
if runner.dry_run:
|
|
print(" [dry-run] user-data qui serait généré :")
|
|
print(textwrap.indent(cloud_cfg, " "))
|
|
print(" [dry-run] network-config :")
|
|
print(
|
|
textwrap.indent(network_config, " ")
|
|
if network_config
|
|
else " (aucun : repli DHCP de cloud-init)"
|
|
)
|
|
else:
|
|
ud.write_text(cloud_cfg)
|
|
md.write_text(meta_data)
|
|
if network_config:
|
|
nc.write_text(network_config)
|
|
|
|
if runner.dry_run or shutil.which("cloud-localds"):
|
|
reseau = ["--network-config", str(nc)] if network_config else []
|
|
runner.run(
|
|
[
|
|
"cloud-localds",
|
|
*reseau,
|
|
str(local_iso),
|
|
str(ud),
|
|
str(md),
|
|
]
|
|
)
|
|
else:
|
|
need_tool("genisoimage")
|
|
runner.run(
|
|
[
|
|
"genisoimage",
|
|
"-output",
|
|
str(local_iso),
|
|
"-volid",
|
|
"cidata",
|
|
"-joliet",
|
|
"-rock",
|
|
str(ud),
|
|
str(md),
|
|
str(nc),
|
|
]
|
|
)
|
|
|
|
(
|
|
seed_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
if not runner.dry_run
|
|
else None
|
|
)
|
|
runner.run(["cp", str(local_iso), str(seed_dest)], privileged=True)
|
|
|
|
|
|
def prepare_disk(
|
|
image: Path, disk: Path, size: str, runner: Runner, force: bool
|
|
) -> None:
|
|
"""Convertit l'image cloud en qcow2 de travail puis redimensionne."""
|
|
if disk.exists() and not force:
|
|
sys.exit(
|
|
f"Le disque {disk} existe déjà. Utilisez --force pour l'écraser."
|
|
)
|
|
runner.run(
|
|
[
|
|
"qemu-img",
|
|
"convert",
|
|
"-f",
|
|
"qcow2",
|
|
"-O",
|
|
"qcow2",
|
|
str(image),
|
|
str(disk),
|
|
],
|
|
privileged=True,
|
|
)
|
|
runner.run(["qemu-img", "resize", str(disk), size], privileged=True)
|
|
|
|
|
|
def _ip_taken(ip: str) -> bool:
|
|
"""Adresse déjà occupée, même par une machine qui ne parle pas SSH.
|
|
|
|
Un simple essai sur le port 22 ne suffit pas : il laisse passer toute
|
|
machine éteinte au moment du choix, ou dont sshd est filtré. Vécu — une
|
|
adresse attribuée à une VM Debian neuve appartenait déjà à une machine du
|
|
parc, et l'installation ERPLibre s'est déroulée SUR CETTE DERNIÈRE. Le
|
|
journal ne le disait qu'à demi-mot : « git is already the newest
|
|
version », impossible sur un système que d-i vient de poser.
|
|
|
|
On interroge donc trois choses : le voisinage ARP de l'hôte, qui connaît
|
|
ce qui a parlé récemment ; ICMP, qui répond même sans service ; puis SSH.
|
|
"""
|
|
try:
|
|
neigh = subprocess.run(
|
|
["ip", "neigh", "show", ip],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
).stdout
|
|
# « FAILED » signifie justement que personne n'a répondu.
|
|
if ip in neigh and "FAILED" not in neigh:
|
|
return True
|
|
except (OSError, subprocess.SubprocessError):
|
|
pass
|
|
try:
|
|
if (
|
|
subprocess.run(
|
|
["ping", "-c", "1", "-W", "1", ip],
|
|
capture_output=True,
|
|
timeout=5,
|
|
).returncode
|
|
== 0
|
|
):
|
|
return True
|
|
except (OSError, subprocess.SubprocessError):
|
|
pass
|
|
return _ip_reachable(ip, port=22, timeout=1.5)
|
|
|
|
|
|
def static_net_plan(
|
|
net: str | None, use_sudo: bool, name: str
|
|
) -> dict[str, str] | None:
|
|
"""Adresse fixe libre pour une VM installée par debian-installer.
|
|
|
|
L'initrd s390x ne contient QUE « netcfg-static » : le journal de d-i
|
|
montre « Menu item 'netcfg-static' selected », jamais netcfg-dhcp, puis
|
|
« Taking down interface enc1 ». Aucun DHCP n'est tenté — c'est la
|
|
convention IBM Z, où la configuration réseau se donne au parmfile. Il
|
|
faut donc fournir une adresse, et elle doit être libre.
|
|
|
|
On la prend en HAUT de la plage : dnsmasq attribue depuis le bas, donc
|
|
les collisions avec un bail futur sont les plus improbables là.
|
|
"""
|
|
if not net:
|
|
return None
|
|
cmd = ["virsh", "-c", LIBVIRT_URI, "net-dumpxml", net]
|
|
if use_sudo:
|
|
cmd.insert(0, "sudo")
|
|
try:
|
|
xml = subprocess.run(
|
|
cmd, capture_output=True, text=True, timeout=20
|
|
).stdout
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
m = re.search(r"<ip address='([\d.]+)' netmask='([\d.]+)'", xml)
|
|
if not m:
|
|
return None
|
|
gateway, netmask = m.group(1), m.group(2)
|
|
base = gateway.rsplit(".", 1)[0]
|
|
taken = {gateway}
|
|
lease_cmd = ["virsh", "-c", LIBVIRT_URI, "net-dhcp-leases", net]
|
|
if use_sudo:
|
|
lease_cmd.insert(0, "sudo")
|
|
try:
|
|
out = subprocess.run(
|
|
lease_cmd, capture_output=True, text=True, timeout=20
|
|
).stdout
|
|
taken |= set(re.findall(r"(\d+\.\d+\.\d+\.\d+)/\d+", out))
|
|
except (OSError, subprocess.SubprocessError):
|
|
pass
|
|
# Départ DÉTERMINISTE, tiré du nom de la VM. Un simple « première libre
|
|
# en partant du haut » donne la MÊME adresse à deux VM déployées en
|
|
# parallèle : aucune des deux n'est encore montée quand l'autre cherche,
|
|
# donc aucune ne voit l'autre. Vécu — debian-12 et debian-13 ont tous
|
|
# deux pris .250 et se sont disputé l'adresse, une seule survivant.
|
|
# Le nom, lui, diffère toujours, et le tirage reste stable d'un
|
|
# redéploiement à l'autre.
|
|
start = zlib.crc32(name.encode()) % 50
|
|
for offset in range(50):
|
|
last = 200 + (start + offset) % 50
|
|
ip = f"{base}.{last}"
|
|
if ip in taken or _ip_taken(ip):
|
|
continue
|
|
return {
|
|
"ip": ip,
|
|
"netmask": netmask,
|
|
"gateway": gateway,
|
|
"dns": gateway,
|
|
}
|
|
return None
|
|
|
|
|
|
def build_preseed(
|
|
args: argparse.Namespace,
|
|
pw_hash: str | None,
|
|
ssh_keys: list[str],
|
|
static: dict[str, str] | None = None,
|
|
) -> str:
|
|
"""Preseed debian-installer équivalent au cloud-config des autres distros.
|
|
|
|
Il doit couvrir EXACTEMENT ce que cloud-init fait ailleurs : nom d'hôte,
|
|
utilisateur, clés SSH, sudo sans mot de passe, fuseau, paquets de base.
|
|
Tout ce qui manque ici devient une question posée à l'écran, et
|
|
l'installation s'arrête sur une console que personne ne regarde.
|
|
|
|
« priority=critical » suffit à ne pas poser les questions restantes ; il
|
|
ne dispense PAS de répondre à celles qui n'ont pas de défaut, d'où le
|
|
partitionnement et le miroir écrits explicitement.
|
|
"""
|
|
user = args.user
|
|
# Sans mot de passe utilisable, d-i s'arrête sur la création du compte :
|
|
# « ! » est un hachage volontairement invalide — la connexion se fera par
|
|
# clé, comme le cloud-config le prévoit lui aussi.
|
|
crypted = pw_hash or "!"
|
|
lines = [
|
|
"d-i debian-installer/locale string en_US.UTF-8",
|
|
"d-i keyboard-configuration/xkb-keymap select us",
|
|
# Question propre à s390x, posée par le udeb « s390-netdevice » et
|
|
# inexistante ailleurs : le matériel Z offre ctc, qeth, iucv ou
|
|
# virtio, et d-i ne devine pas. Elle n'a AUCUNE valeur par défaut,
|
|
# donc « priority=critical » ne la saute pas — l'installation se
|
|
# figeait dessus, sur le premier choix de la liste (ctc), en
|
|
# n'affichant rien d'autre qu'un écran bleu. Mesuré.
|
|
"d-i s390-netdevice/choose_networktype select virtio",
|
|
# « auto » évite la question du choix d'interface : sous virtio-ccw
|
|
# elle s'appelle enc1 et non eth0, et le nom n'est pas devinable.
|
|
f"d-i netcfg/get_hostname string {args.hostname}",
|
|
# Adresse fixe : sans elle, netcfg-static pose la question a l'ecran
|
|
# et l'installation s'arrete la, indefiniment.
|
|
*(
|
|
[
|
|
"d-i netcfg/disable_autoconfig boolean true",
|
|
"d-i netcfg/disable_dhcp boolean true",
|
|
f"d-i netcfg/get_ipaddress string {static['ip']}",
|
|
f"d-i netcfg/get_netmask string {static['netmask']}",
|
|
f"d-i netcfg/get_gateway string {static['gateway']}",
|
|
f"d-i netcfg/get_nameservers string {static['dns']}",
|
|
"d-i netcfg/confirm_static boolean true",
|
|
]
|
|
if static
|
|
else []
|
|
),
|
|
"d-i netcfg/get_domain string localdomain",
|
|
"d-i netcfg/hostname string " + args.hostname,
|
|
"d-i mirror/country string manual",
|
|
"d-i mirror/http/hostname string deb.debian.org",
|
|
"d-i mirror/http/directory string /debian",
|
|
"d-i mirror/http/proxy string",
|
|
"d-i passwd/root-login boolean false",
|
|
"d-i passwd/user-fullname string ERPLibre",
|
|
f"d-i passwd/username string {user}",
|
|
f"d-i passwd/user-password-crypted password {crypted}",
|
|
# network-console : sur IBM Z, d-i propose systématiquement de
|
|
# poursuivre par SSH — la console y est historiquement limitée. Il
|
|
# refuse un mot de passe vide et bloque l'installation non assistée.
|
|
# Ce secret ne vit QUE le temps de l'installateur, sur le réseau
|
|
# libvirt, et disparaît avec lui : il ne donne accès à rien ensuite.
|
|
"d-i network-console/password password erplibre",
|
|
"d-i network-console/password-again password erplibre",
|
|
"d-i clock-setup/utc boolean true",
|
|
f"d-i time/zone string {canonical_timezone(args.timezone)}",
|
|
"d-i clock-setup/ntp boolean true",
|
|
# Le disque est nommé : sur s390x virtio-ccw il n'y en a qu'un, mais
|
|
# d-i pose quand même la question quand rien ne le désigne.
|
|
# Ce que partman voit reellement, ecrit sur la console : « No root
|
|
# file system is defined » ne distingue pas « disque absent » de
|
|
# « recette non appliquee », et les deux se corrigent differemment.
|
|
# Toute commande preseedee DOIT rendre 0 : d-i bloque sur « Failed to
|
|
# run preseeded command » sinon, et le diagnostic devient le blocage.
|
|
# Vecu — un « ls /dev/dasd* » sans correspondance suffisait.
|
|
"d-i partman/early_command string cat /proc/partitions > /dev/console"
|
|
" ; ls /lib/partman/automatically_partition/ > /dev/console 2>&1"
|
|
" ; true",
|
|
# partman-auto RECLAME explicitement : il n'est pas tire d'office sur
|
|
# s390x, ou la voie attendue est le partitionnement DASD manuel.
|
|
# Mesure dans l'installateur : « /lib/partman/automatically_partition/
|
|
# No such file or directory », et la liste des udebs recuperes montre
|
|
# partman-base, -utils, -partitioning, -target… mais jamais -auto.
|
|
# Sans lui, aucune recette ne s'applique et partman s'arrete sur
|
|
# « No root file system is defined ».
|
|
"d-i anna/choose_modules string partman-auto",
|
|
"d-i partman-auto/disk string /dev/vda",
|
|
"d-i partman-auto/method string regular",
|
|
"d-i partman-auto/choose_recipe select atomic",
|
|
"d-i partman/default_filesystem string ext4",
|
|
"d-i partman-partitioning/confirm_write_new_label boolean true",
|
|
"d-i partman/choose_partition select finish",
|
|
"d-i partman/confirm boolean true",
|
|
"d-i partman/confirm_nooverwrite boolean true",
|
|
"tasksel tasksel/first multiselect ssh-server",
|
|
"d-i pkgsel/include string openssh-server sudo python3"
|
|
" qemu-guest-agent ca-certificates",
|
|
"d-i pkgsel/upgrade select none",
|
|
"popularity-contest popularity-contest/participate boolean false",
|
|
"d-i finish-install/reboot_in_progress note",
|
|
]
|
|
# late_command : tout ce que le preseed ne sait pas exprimer. « in-target »
|
|
# exécute DANS le système installé ; les redirections, elles, restent dans
|
|
# l'installateur et doivent donc viser /target.
|
|
post = [
|
|
f"in-target usermod -aG sudo {user}",
|
|
f"echo '{user} ALL=(ALL) NOPASSWD:ALL' > /target/etc/sudoers.d/{user}",
|
|
f"chmod 440 /target/etc/sudoers.d/{user}",
|
|
]
|
|
# Accès au GPU, comme le cloud-config le donne aux autres distributions :
|
|
# sans ces groupes, toute application GL de l'invité retombe sur le rendu
|
|
# logiciel. « groupadd -f » ne fait rien si le groupe existe et ne rend
|
|
# jamais d'erreur, là où « usermod -aG » sur un nom inconnu échoue.
|
|
if (getattr(args, "gpu", "auto") or "auto").lower() != "off":
|
|
post += [f"in-target groupadd -f {nom}" for nom in GPU_GROUPS]
|
|
post.append(f"in-target usermod -aG {','.join(GPU_GROUPS)} {user}")
|
|
if ssh_keys:
|
|
post.append(f"mkdir -p /target/home/{user}/.ssh")
|
|
for key in ssh_keys:
|
|
post.append(
|
|
f"echo '{key}' >> /target/home/{user}/.ssh/authorized_keys"
|
|
)
|
|
post += [
|
|
f"in-target chown -R {user}:{user} /home/{user}/.ssh",
|
|
f"chmod 700 /target/home/{user}/.ssh",
|
|
f"chmod 600 /target/home/{user}/.ssh/authorized_keys",
|
|
]
|
|
# Guide de connexion et identité git : les mêmes fichiers que sur les autres
|
|
# distributions, mais ici il n'y a pas de cloud-init pour les écrire. Ils
|
|
# voyagent DANS l'initrd, à côté du preseed, et le late_command ne fait que
|
|
# les copier.
|
|
#
|
|
# Pourquoi pas leur contenu dans le preseed : la valeur d'une question tient
|
|
# sur UNE ligne, et celle-ci fait déjà 1165 caractères avec une clé RSA-4096.
|
|
# Y ajouter 1,2 Kio de guide — encodé ou en trente echo, la longueur est la
|
|
# même — doublerait une ligne dont aucune limite n'est documentée pour
|
|
# cdebconf. Et une troncature ne coûterait pas le guide : elle couperait le
|
|
# late_command au milieu, donc ni sudoers ni clé SSH, donc une VM
|
|
# inaccessible.
|
|
for path, mode, _content, owner in guide_files(args):
|
|
src = "/" + installer_guide_name(path)
|
|
post.append(f"cp {src} /target{path} || true")
|
|
post.append(f"chmod {mode} /target{path} || true")
|
|
if owner:
|
|
post.append(f"in-target chown {owner}:{owner} {path} || true")
|
|
# Le code de sortie du late_command est celui de sa DERNIÈRE commande, et
|
|
# d-i s'arrête sur « Failed to run preseeded command » dès qu'il n'est pas
|
|
# nul. Sans ce « true », un chmod qui échoue bloque l'installation sur un
|
|
# écran que personne ne regarde — c'est déjà la garde de
|
|
# partman/early_command, quelques lignes plus haut.
|
|
post.append("true")
|
|
# Diagnostic réseau, écrit sur la console AVANT que netcfg ne décide.
|
|
# netcfg n'essaie aucun DHCP sur s390x et tombe droit sur l'adressage
|
|
# statique ; ses propres traces vont dans le syslog INTERNE de d-i, qu'on
|
|
# ne peut lire qu'en ouvrant un shell à la main. Ces quelques lignes
|
|
# atterrissent, elles, dans le journal de console — donc dans un fichier
|
|
# qu'il suffit de lire après coup. La sonde DHCP est celle de busybox,
|
|
# bornée à trois essais, et ne configure rien de durable.
|
|
early = [
|
|
# LE correctif, pas un diagnostic : on ALLUME la carte.
|
|
#
|
|
# Mesuré dans l'installateur : « enc1: <BROADCAST,MULTICAST> …
|
|
# qdisc noop » — ni UP ni LOWER_UP — alors qu'un udhcpc manuel
|
|
# obtenait un bail en deux secondes. Le réseau n'a jamais été en
|
|
# cause ; netcfg teste l'état du lien AVANT d'essayer, ne le voit
|
|
# pas, saute le DHCP et demande une adresse statique.
|
|
#
|
|
# Sur s390x c'est le udeb s390-netdevice qui active le périphérique.
|
|
# En preseedant sa question pour qu'il ne s'affiche plus, on
|
|
# court-circuite aussi cette activation. On la refait donc ici, avant
|
|
# que netcfg ne décide.
|
|
"ip link set enc1 up > /dev/console 2>&1",
|
|
"echo '=== EL: enc1 activee avant netcfg ===' > /dev/console",
|
|
"ip -o link show enc1 > /dev/console 2>&1",
|
|
# Même garde que partman/early_command : « ip -o link show » rend 1
|
|
# quand l'interface n'existe pas, et le code de sortie du early_command
|
|
# est celui de sa dernière commande. Une ligne de DIAGNOSTIC bloquait
|
|
# donc l'installation qu'elle devait servir à comprendre.
|
|
"true",
|
|
]
|
|
lines.append("d-i preseed/early_command string " + " ; ".join(early))
|
|
lines.append("d-i preseed/late_command string " + " ; ".join(post))
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def build_installer_initrd(
|
|
preseed: str,
|
|
initrd_src: Path,
|
|
out: Path,
|
|
runner: Runner,
|
|
guide: list[tuple[str, str, str, str]] | None = None,
|
|
) -> None:
|
|
"""Glisse le preseed DANS l'initrd de l'installateur.
|
|
|
|
Servir le preseed en HTTP est l'autre voie documentée, mais elle ajoute un
|
|
serveur à faire vivre pendant toute l'installation et une dépendance à
|
|
l'ordre d'obtention de l'adresse. Embarquer le fichier ne dépend de rien :
|
|
d-i lit « /preseed.cfg » à la racine de l'initrd avant même le réseau.
|
|
|
|
La méthode est celle de la documentation Debian — décompresser, ajouter le
|
|
fichier au cpio, recompresser — et non une concaténation d'archives, que
|
|
le noyau accepte mais que d-i ne parcourt pas de la même façon.
|
|
|
|
`guide` : les fichiers d'accueil de la VM (guide de connexion, identité
|
|
git), rangés à côté du preseed. L'initrd EST le système de fichiers de
|
|
l'installateur : le late_command n'a plus qu'à les copier vers /target,
|
|
sans avoir à transporter leur contenu dans une valeur de preseed.
|
|
"""
|
|
if runner.dry_run:
|
|
print(f"[dry-run] preseed -> {out}")
|
|
for path, _mode, _content, _owner in guide or []:
|
|
print(f"[dry-run] + {installer_guide_name(path)} -> {path}")
|
|
return
|
|
if not shutil.which("cpio"):
|
|
sys.exit(
|
|
"cpio est requis pour embarquer le preseed dans l'initrd.\n"
|
|
" Debian/Ubuntu : sudo apt-get install cpio"
|
|
)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
work = Path(tmp)
|
|
# Le mot de passe utilisateur y est HACHÉ (user-password-crypted).
|
|
# Reste celui de network-console, une valeur fixe et publique dont
|
|
# le composant est désactivé plus bas. Le répertoire temporaire est
|
|
# déjà en 0700 ; le mode explicite vaut pour qui lirait ce code.
|
|
cfg = work / "preseed.cfg"
|
|
cfg.touch(mode=0o600)
|
|
cfg.write_text(preseed, encoding="utf-8")
|
|
members = ["preseed.cfg"]
|
|
for path, _mode, content, _owner in guide or []:
|
|
name = installer_guide_name(path)
|
|
(work / name).write_text(content, encoding="utf-8")
|
|
members.append(name)
|
|
# network-console DÉSACTIVÉ, par le levier que d-i prévoit pour cela.
|
|
#
|
|
# Sur IBM Z, d-i propose de poursuivre par SSH — la console y est
|
|
# historiquement limitée. Ce n'est pas une question à laquelle
|
|
# répondre : le composant démarre sshd puis ATTEND une connexion de
|
|
# l'utilisateur « installer », indéfiniment. Preseeder son mot de
|
|
# passe le fait avancer d'un écran, pas davantage — mesuré.
|
|
#
|
|
# Un composant dont « .isinstallable » sort en erreur est retiré du
|
|
# menu. L'original le fait déjà quand sshd tourne ; on le remplace par
|
|
# un refus inconditionnel. Le fichier ajouté APRÈS l'original prend sa
|
|
# place : le noyau déplie le cpio séquentiellement et le dernier
|
|
# écrit gagne.
|
|
gate = work / "var/lib/dpkg/info"
|
|
gate.mkdir(parents=True, exist_ok=True)
|
|
target = gate / "network-console.isinstallable"
|
|
target.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8")
|
|
target.chmod(0o755)
|
|
plain = work / "initrd"
|
|
with gzip.open(initrd_src, "rb") as src, open(plain, "wb") as dst:
|
|
shutil.copyfileobj(src, dst)
|
|
members.append("var/lib/dpkg/info/network-console.isinstallable")
|
|
subprocess.run(
|
|
["cpio", "-H", "newc", "-o", "-A", "-F", str(plain)],
|
|
input="\n".join(members) + "\n",
|
|
text=True,
|
|
cwd=work,
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(plain, "rb") as src, gzip.open(out, "wb") as dst:
|
|
shutil.copyfileobj(src, dst)
|
|
print(f" preseed embarqué dans {out} ({out.stat().st_size} octets)")
|
|
|
|
|
|
def create_blank_disk(
|
|
disk: Path, size: str, runner: Runner, force: bool
|
|
) -> None:
|
|
"""Disque VIERGE : l'installateur écrit tout, il n'y a rien à convertir."""
|
|
if disk.exists() and not force:
|
|
sys.exit(
|
|
f"Le disque {disk} existe déjà. Utilisez --force pour l'écraser."
|
|
)
|
|
runner.run(
|
|
["qemu-img", "create", "-f", "qcow2", str(disk), size],
|
|
privileged=True,
|
|
)
|
|
|
|
|
|
def network_name(network_arg: str) -> str | None:
|
|
"""Extrait NAME de « network=NAME,... » ; None si c'est un bridge, etc."""
|
|
for part in network_arg.split(","):
|
|
if part.strip().startswith("network="):
|
|
return part.split("=", 1)[1].strip()
|
|
return None
|
|
|
|
|
|
def c_locale_env() -> dict[str, str]:
|
|
"""L'environnement des commandes dont on PARSE la sortie.
|
|
|
|
virsh TRADUIT ses étiquettes : sous une locale française, « net-info »
|
|
répond « Actif : non » et « Démarrage automatique : oui », où un motif
|
|
« Active: yes » lit toujours faux. Un réseau démarré passait alors pour
|
|
éteint, l'hôte était déclaré « pas prêt » quel que soit son état, et le
|
|
message invitait à redémarrer pour rien. Le même geste, pour la même
|
|
raison, est dans script/todo/qemu_manage.py.
|
|
"""
|
|
return {**os.environ, "LC_ALL": "C", "LANG": "C"}
|
|
|
|
|
|
def virsh_out(args: list[str], use_sudo: bool, timeout: int = 20) -> str:
|
|
"""La sortie d'un virsh, en anglais, ou '' s'il n'a rien pu dire."""
|
|
cmd = (["sudo"] if use_sudo else []) + ["virsh", "-c", LIBVIRT_URI, *args]
|
|
try:
|
|
res = subprocess.run(
|
|
cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
env=c_locale_env(),
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return ""
|
|
return res.stdout if res.returncode == 0 else ""
|
|
|
|
|
|
def network_state(name: str, use_sudo: bool) -> tuple[bool, bool]:
|
|
"""(actif, autostart) d'un réseau libvirt, via « virsh net-info »."""
|
|
out = virsh_out(["net-info", name], use_sudo, timeout=15)
|
|
active = bool(re.search(r"Active:\s*yes", out, re.IGNORECASE))
|
|
autostart = bool(re.search(r"Autostart:\s*yes", out, re.IGNORECASE))
|
|
return (active, autostart)
|
|
|
|
|
|
# Le troisième octet où commence la recherche d'un /24 libre. 122 est celui du
|
|
# « default » de libvirt, 123 celui de beaucoup d'installations toutes faites :
|
|
# partir au-dessus des deux coûte un octet. long_test/deep_qemu.py part du même
|
|
# nombre, en le déduisant de la profondeur là où il ne peut pas sonder.
|
|
LIBVIRT_NET_BASE = 131
|
|
|
|
|
|
def interface_de_ligne(ligne: str) -> str:
|
|
"""L'interface que porte une ligne de « ip route » ou « ip -o addr ».
|
|
|
|
Deux grammaires pour une seule question. Une route nomme son interface
|
|
après « dev » ; « ip -o -4 addr » la donne en deuxième champ, la ligne
|
|
commençant par son index (« 3: virbr0 inet … »). Le « -o » est ce qui
|
|
rend la seconde lisible : sans lui, l'interface est sur une ligne et ses
|
|
adresses sur les suivantes, plus rattachables l'une à l'autre.
|
|
"""
|
|
mots = ligne.split()
|
|
if "dev" in mots:
|
|
i = mots.index("dev")
|
|
if i + 1 < len(mots):
|
|
return mots[i + 1]
|
|
if len(mots) >= 2 and mots[0].rstrip(":").isdigit():
|
|
# « eth0@if12 » sur une interface appairée : le nom est avant l'arobase.
|
|
return mots[1].rstrip(":").split("@")[0]
|
|
return ""
|
|
|
|
|
|
def host_networks(exclure_ponts=()) -> list[ipaddress.IPv4Network]:
|
|
"""Les réseaux IPv4 que l'hôte porte ou route déjà.
|
|
|
|
Les deux, adresses ET routes : une interface peut router un réseau sans y
|
|
porter d'adresse, et c'est la ROUTE qui décide où part un paquet.
|
|
|
|
`exclure_ponts` écarte les interfaces dont le réseau EST celui qu'on
|
|
examine. Un réseau libvirt actif porte son propre /24 sur son pont, et le
|
|
route : compté comme « déjà pris par l'hôte », il se trouve en collision
|
|
avec lui-même. Le verdict était alors le même sur toute machine où le
|
|
réseau tournait, quel que soit son sous-réseau — d'où un déplacement là où
|
|
rien n'entrait en conflit, sous des VM qui perdaient leur passerelle. La
|
|
question posée est « quelqu'un D'AUTRE occupe-t-il ce /24 », et le pont du
|
|
réseau examiné n'est pas quelqu'un d'autre.
|
|
"""
|
|
# Un nom vide n'exclut RIEN : network_bridge rend '' quand le XML est
|
|
# illisible, et le garder dans l'ensemble écarterait toute ligne dont
|
|
# l'interface ne se lit pas — soit, sur une grammaire inattendue, la
|
|
# totalité de ce que l'hôte occupe.
|
|
exclure = {pont for pont in (exclure_ponts or ()) if pont}
|
|
vus: list[ipaddress.IPv4Network] = []
|
|
commandes = (
|
|
["ip", "-4", "route", "show"],
|
|
# « -o » : une adresse par ligne, sinon l'interface est sur la ligne
|
|
# d'en-tête et l'adresse sur la suivante, donc plus rattachables.
|
|
["ip", "-o", "-4", "addr", "show"],
|
|
)
|
|
for args in commandes:
|
|
try:
|
|
out = subprocess.run(
|
|
args,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
env=c_locale_env(),
|
|
).stdout
|
|
except (OSError, subprocess.SubprocessError):
|
|
continue
|
|
for ligne in out.splitlines():
|
|
if interface_de_ligne(ligne) in exclure:
|
|
continue
|
|
for cidr in re.findall(r"\b(\d+\.\d+\.\d+\.\d+/\d+)\b", ligne):
|
|
try:
|
|
vus.append(ipaddress.ip_network(cidr, strict=False))
|
|
except ValueError:
|
|
continue
|
|
return vus
|
|
|
|
|
|
def network_cidr(name: str, use_sudo: bool) -> str:
|
|
"""Le réseau servi par un réseau libvirt (« 192.168.122.0/24 »), ou ''."""
|
|
return cidr_from_network_xml(virsh_out(["net-dumpxml", name], use_sudo))
|
|
|
|
|
|
def bridge_from_network_xml(xml: str) -> str:
|
|
"""Le pont déclaré par un XML de réseau libvirt (« virbr0 »), ou ''."""
|
|
m = re.search(r"<bridge[^>]*name='([^']+)'", xml)
|
|
return m.group(1) if m else ""
|
|
|
|
|
|
def network_bridge(name: str, use_sudo: bool) -> str:
|
|
"""Le pont que ce réseau libvirt monte, ou ''.
|
|
|
|
C'est l'interface qui portera son adresse .1 une fois le réseau démarré :
|
|
la seule que la recherche de collision doit s'interdire de compter.
|
|
"""
|
|
return bridge_from_network_xml(virsh_out(["net-dumpxml", name], use_sudo))
|
|
|
|
|
|
def cidr_from_network_xml(xml: str) -> str:
|
|
"""Le réseau déclaré par un XML de réseau libvirt, ou ''.
|
|
|
|
Le masque est écrit en quatre octets (« 255.255.255.0 ») et non en
|
|
longueur de préfixe : ipaddress accepte les deux formes, le reste du code
|
|
n'en manipule qu'une.
|
|
"""
|
|
m = re.search(r"<ip address='([\d.]+)' netmask='([\d.]+)'", xml)
|
|
if not m:
|
|
return ""
|
|
try:
|
|
return str(
|
|
ipaddress.ip_network(f"{m.group(1)}/{m.group(2)}", strict=False)
|
|
)
|
|
except ValueError:
|
|
return ""
|
|
|
|
|
|
def network_collision(cidr: str, hote: list) -> str:
|
|
"""Ce que l'hôte route déjà dans ce réseau, ou '' s'il est libre.
|
|
|
|
Le « default » de libvirt sert 192.168.122.0/24. Une machine qui VIT dans
|
|
ce réseau — toute VM déployée par ce dépôt en sert une — ne peut pas le
|
|
servir à son tour : l'adresse .1 du pont est celle de sa propre
|
|
passerelle. virsh refuse d'ailleurs le démarrage (« Network is already in
|
|
use by interface eth0 »), mais seulement quand la route est LÀ : au
|
|
démarrage de la machine, libvirtd monte ses réseaux avant que le bail DHCP
|
|
ne soit arrivé, plus rien ne signale la collision, et l'hôte perd sa
|
|
passerelle au profit du pont.
|
|
"""
|
|
if not cidr:
|
|
return ""
|
|
try:
|
|
mien = ipaddress.ip_network(cidr, strict=False)
|
|
except ValueError:
|
|
return ""
|
|
for autre in hote:
|
|
if mien.overlaps(autre):
|
|
return str(autre)
|
|
return ""
|
|
|
|
|
|
def libvirt_networks_cidrs(name: str, use_sudo: bool) -> list:
|
|
"""Les réseaux servis par les AUTRES réseaux libvirt de l'hôte.
|
|
|
|
Un réseau inactif ne route rien : il n'apparaît donc pas dans host_networks
|
|
et rien n'empêcherait de lui reprendre son sous-réseau, pour buter dessus
|
|
au premier démarrage des deux.
|
|
"""
|
|
out = []
|
|
for autre in virsh_out(["net-list", "--all", "--name"], use_sudo).split():
|
|
if autre == name:
|
|
continue
|
|
cidr = network_cidr(autre, use_sudo)
|
|
if cidr:
|
|
try:
|
|
out.append(ipaddress.ip_network(cidr, strict=False))
|
|
except ValueError:
|
|
pass
|
|
return out
|
|
|
|
|
|
def free_subnet(pris: list) -> str:
|
|
"""Un préfixe « 192.168.X » qu'aucun réseau connu ne recouvre, ou ''."""
|
|
for troisieme in range(LIBVIRT_NET_BASE, 255):
|
|
candidat = ipaddress.ip_network(f"192.168.{troisieme}.0/24")
|
|
if not any(candidat.overlaps(autre) for autre in pris):
|
|
return f"192.168.{troisieme}"
|
|
return ""
|
|
|
|
|
|
def moved_network_xml(xml: str, ancien: str, nouveau: str) -> str:
|
|
"""Le XML du réseau, son sous-réseau déplacé, le reste INTACT.
|
|
|
|
Réécrit plutôt que reconstruit : l'UUID, le nom du pont et son adresse
|
|
MAC restent en place, si bien que le réseau garde son identité au lieu
|
|
d'en prendre une nouvelle — et les domaines qui le nomment le retrouvent.
|
|
"""
|
|
return xml.replace(f"{ancien}.", f"{nouveau}.")
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def fichier_xml_temporaire(contenu: str, prefixe: str = "erplibre-net-"):
|
|
"""Un XML posé sur le disque le temps d'un « virsh net-define », puis ôté.
|
|
|
|
Créé par mkstemp, donc à un nom que personne ne peut prédire, et retiré à
|
|
la sortie même si virsh échoue. Un nom composé — d'un nom de réseau, par
|
|
exemple — est un chemin PRÉVISIBLE dans un répertoire où tout le monde
|
|
écrit : qui l'occupe d'avance par un lien symbolique choisit ce que root
|
|
va définir, et le laisser derrière donne à lire la configuration du parc.
|
|
|
|
Le fichier est lisible par tous : virsh le lit sous root, et le fichier
|
|
d'un utilisateur non privilégié ne l'est pas toujours. Ce qu'il contient
|
|
ne sort pas de la définition du réseau, que « net-dumpxml » rend à qui
|
|
peut déjà joindre libvirt.
|
|
"""
|
|
fd, chemin = tempfile.mkstemp(prefix=prefixe, suffix=".xml")
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
fh.write(contenu)
|
|
os.chmod(chemin, 0o644)
|
|
yield chemin
|
|
finally:
|
|
try:
|
|
os.unlink(chemin)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def define_network_xml(xml: str, runner: Runner) -> None:
|
|
"""Redéfinit un réseau libvirt à partir de son XML."""
|
|
with fichier_xml_temporaire(xml) as chemin:
|
|
runner.run(
|
|
["virsh", "-c", LIBVIRT_URI, "net-define", chemin],
|
|
privileged=True,
|
|
check=False,
|
|
)
|
|
|
|
|
|
def move_network(name: str, cidr: str, collision: str, runner: Runner) -> str:
|
|
"""Redéfinit le réseau sur un /24 libre. Rend le nouveau préfixe, ou ''.
|
|
|
|
Une REDÉFINITION et non un démarrage : elle ne demande ni pont ni module
|
|
du noyau, donc elle réussit même sur une machine dont le noyau a été
|
|
remplacé depuis le démarrage. C'est ce qui permet d'armer l'autostart
|
|
avant le redémarrage sans armer une collision.
|
|
"""
|
|
xml = virsh_out(["net-dumpxml", name], runner.use_sudo)
|
|
if not xml:
|
|
print(f" ⚠ réseau « {name} » illisible : rien n'est déplacé.")
|
|
return ""
|
|
ancien = cidr.rsplit(".", 1)[0].rsplit("/", 1)[0]
|
|
libre = free_subnet(
|
|
host_networks(exclure_ponts=[bridge_from_network_xml(xml)])
|
|
+ libvirt_networks_cidrs(name, runner.use_sudo)
|
|
)
|
|
if not libre:
|
|
print(" ⚠ aucun /24 libre en 192.168.x : rien n'est déplacé.")
|
|
return ""
|
|
print(
|
|
f" Le réseau « {name} » sert {cidr}, que l'hôte route déjà"
|
|
f" ({collision})."
|
|
)
|
|
print(
|
|
f" Déplacé sur {libre}.0/24 : sinon le pont prendrait l'adresse de"
|
|
" la passerelle de cette machine, qui perdrait son accès au réseau"
|
|
" au prochain démarrage."
|
|
)
|
|
define_network_xml(moved_network_xml(xml, ancien, libre), runner)
|
|
return libre
|
|
|
|
|
|
def ensure_network(name: str | None, runner: Runner) -> None:
|
|
"""Rend le réseau libvirt utilisable, et SÛR pour le prochain démarrage.
|
|
|
|
Trois gestes, et leur ordre est tout :
|
|
|
|
0. ABATTRE un réseau actif qui recouvre une route de l'hôte : c'est la
|
|
machine déjà cassée, et son pont porte l'adresse de la passerelle.
|
|
1. DÉPLACER le réseau s'il recouvre ce que l'hôte route déjà. Le
|
|
« default » de libvirt sert 192.168.122.0/24, et toute VM déployée par
|
|
ce dépôt vit dans ce réseau : son pont y prendrait l'adresse .1, celle
|
|
de sa propre passerelle. La redéfinition ne demande ni pont ni module
|
|
du noyau, donc elle passe même quand le démarrage, lui, ne passe pas.
|
|
2. DÉMARRER, puis relire l'état plutôt que de croire le code de retour.
|
|
3. ARMER l'autostart SEULEMENT si le sous-réseau est libre de collision.
|
|
C'est le geste qui cassait la machine : armé sur un réseau en
|
|
collision, libvirtd le monte au démarrage AVANT que le bail DHCP de
|
|
l'hôte ne soit là — plus aucune route ne signale la collision, virbr0
|
|
prend l'adresse de la passerelle, et l'hôte n'a plus de réseau. Un
|
|
réseau qui n'a pas pu démarrer faute de modules du noyau reste, lui,
|
|
armé : c'est ce qui rend l'hôte utilisable en UN seul redémarrage.
|
|
"""
|
|
if not name:
|
|
return
|
|
if runner.dry_run:
|
|
print(f" [dry-run] réseau libvirt '{name}' vérifié et activé")
|
|
print(" [dry-run] sous-réseau déplacé s'il entre en collision")
|
|
runner.run(
|
|
["virsh", "-c", LIBVIRT_URI, "net-start", name],
|
|
privileged=True,
|
|
check=False,
|
|
)
|
|
runner.run(
|
|
["virsh", "-c", LIBVIRT_URI, "net-autostart", name],
|
|
privileged=True,
|
|
check=False,
|
|
)
|
|
return
|
|
active, autostart = network_state(name, runner.use_sudo)
|
|
cidr = network_cidr(name, runner.use_sudo)
|
|
# Son propre pont ne compte pas : un réseau démarré porte et route son /24
|
|
# là, et se verrait sinon en collision avec lui-même sur toute machine.
|
|
pont = network_bridge(name, runner.use_sudo)
|
|
collision = network_collision(cidr, host_networks(exclure_ponts=[pont]))
|
|
|
|
if collision:
|
|
# Un réseau ACTIF en collision est la machine DÉJÀ privée de réseau :
|
|
# libvirt refuse ce démarrage quand la route est là, donc le pont a
|
|
# pris l'adresse de la passerelle AVANT elle, au démarrage. L'abattre
|
|
# est ce qui rend l'accès au réseau à l'hôte, et tout de suite.
|
|
if active:
|
|
print(
|
|
f" ⚠ le réseau « {name} » est actif SUR {collision}, que"
|
|
" cette machine route : son pont porte l'adresse de la"
|
|
" passerelle. Arrêté pour rendre l'accès au réseau."
|
|
)
|
|
runner.run(
|
|
["virsh", "-c", LIBVIRT_URI, "net-destroy", name],
|
|
privileged=True,
|
|
check=False,
|
|
)
|
|
active = False
|
|
libre = move_network(name, cidr, collision, runner)
|
|
if libre:
|
|
cidr = network_cidr(name, runner.use_sudo)
|
|
collision = network_collision(
|
|
cidr, host_networks(exclure_ponts=[pont])
|
|
)
|
|
|
|
if active and autostart and not collision:
|
|
print(f" Réseau libvirt '{name}' déjà actif.")
|
|
return
|
|
|
|
if not active:
|
|
print(f" Configuration du réseau libvirt '{name}'…")
|
|
runner.run(
|
|
["virsh", "-c", LIBVIRT_URI, "net-start", name],
|
|
privileged=True,
|
|
check=False,
|
|
)
|
|
active, autostart = network_state(name, runner.use_sudo)
|
|
|
|
if collision:
|
|
# Rien n'a pu le rendre sûr : le désarmer est la seule chose qui
|
|
# protège le prochain démarrage.
|
|
if autostart:
|
|
print(
|
|
f" ⚠ autostart RETIRÉ au réseau « {name} » : il recouvre"
|
|
f" {collision}, et le monter au démarrage priverait cette"
|
|
" machine de sa passerelle."
|
|
)
|
|
runner.run(
|
|
[
|
|
"virsh",
|
|
"-c",
|
|
LIBVIRT_URI,
|
|
"net-autostart",
|
|
"--disable",
|
|
name,
|
|
],
|
|
privileged=True,
|
|
check=False,
|
|
)
|
|
return
|
|
|
|
if not autostart:
|
|
runner.run(
|
|
["virsh", "-c", LIBVIRT_URI, "net-autostart", name],
|
|
privileged=True,
|
|
check=False,
|
|
)
|
|
|
|
|
|
def virt_install(
|
|
args: argparse.Namespace,
|
|
disk: Path,
|
|
seed: Path,
|
|
osinfo: str,
|
|
runner: Runner,
|
|
installer: tuple[Path, Path] | None = None,
|
|
) -> None:
|
|
# É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()
|
|
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
|
|
# ttyS0. Ailleurs (x86/arm64), console série classique.
|
|
console_target = "sclp" if args.arch == "s390x" else "serial"
|
|
console_log = f"/var/log/libvirt/qemu/{args.name}-console.log"
|
|
# Écran virtuel pour une VM graphique. s390x en est écarté : QEMU y expose
|
|
# bien « virtio-gpu-ccw », mais rien ne garantit que le noyau s390x de la
|
|
# distribution embarque le pilote DRM virtio-gpu — la VM démarrerait alors
|
|
# sur un écran noir. Le bureau distant, lui, marche partout : c'est la voie
|
|
# retenue pour cette architecture, et le message le dit.
|
|
graphics = args.graphics
|
|
video = []
|
|
if args.desktop and graphics == "none":
|
|
if args.arch == "s390x":
|
|
print(
|
|
"\n s390x : pas d'écran virtuel (pilote DRM non garanti)."
|
|
"\n Le bureau sera accessible à distance, par le réseau."
|
|
)
|
|
else:
|
|
# VNC sur la boucle locale, PAS « spice,listen=none ».
|
|
#
|
|
# « listen=none » est le défaut de virt-install et il n'expose
|
|
# rien : QEMU crée l'affichage mais n'ouvre AUCUN socket TCP, seul
|
|
# le canal libvirt y mène. Un virt-manager tournant sur la machine
|
|
# même y accède ; rien d'autre. Or l'hôte QEMU est lui-même une VM
|
|
# ici — la console était donc inatteignable par construction, et
|
|
# aucun « ssh -L » ne pouvait y remédier : il n'y avait pas de port
|
|
# où aboutir.
|
|
#
|
|
# 127.0.0.1 n'expose rien au réseau non plus : le port n'est
|
|
# joignable que depuis l'hôte, donc à travers un tunnel SSH. VNC
|
|
# plutôt que SPICE parce qu'il tient en UN port — un seul « -L »
|
|
# suffit, avec n'importe quel client. Pour revenir au comportement
|
|
# d'avant : --graphics spice,listen=none
|
|
graphics = "vnc,listen=127.0.0.1"
|
|
video = ["--video", "virtio"]
|
|
# 3D : allumée d'office quand l'hôte a un GPU (--gpu auto). Une VM
|
|
# graphique sans accélération rend tout par le processeur — le bureau
|
|
# comme l'émulateur Android qui tourne dedans — et c'est le défaut le plus
|
|
# coûteux qu'on puisse laisser en place sans le dire.
|
|
gpu_node = args.gpu_node or host_gpu_node()
|
|
video_sans_3d = list(video)
|
|
video, gpu_args, gpu_msg = gpu_apply(
|
|
video, args.gpu, gpu_node, graphics != "none"
|
|
)
|
|
if gpu_msg:
|
|
print(gpu_msg)
|
|
cmd = [
|
|
"virt-install",
|
|
# Sans --connect, un utilisateur non root vise qemu:///session : le
|
|
# réseau « default » n'y existe pas et la création échoue même quand
|
|
# tous les paquets sont installés et le réseau actif côté système.
|
|
"--connect",
|
|
LIBVIRT_URI,
|
|
"--name",
|
|
args.name,
|
|
"--memory",
|
|
str(args.memory),
|
|
"--vcpus",
|
|
str(args.vcpus),
|
|
]
|
|
if installer:
|
|
kernel, initrd = installer
|
|
# « --install » et non « --boot » : virt-install écrit alors DEUX
|
|
# configurations — celle de l'installation, transitoire, et celle du
|
|
# système installé. Avec « --boot kernel=… » la VM repartirait sur
|
|
# l'installateur à chaque démarrage, indéfiniment.
|
|
#
|
|
# console=ttysclp0 : s390x n'a pas de port série ISA. Sans cet
|
|
# argument l'installateur tourne sur une console invisible, et un
|
|
# échec ne laisse aucune trace lisible.
|
|
cmd += [
|
|
"--install",
|
|
f"kernel={kernel},initrd={initrd},"
|
|
# PAS de « auto=true » : il vise le preseed par URL et réordonne
|
|
# l'installation pour monter le réseau AVANT tout le reste. Le
|
|
# nôtre est local, il n'y a rien à aller chercher — et c'est
|
|
# précisément là que netcfg dérapait.
|
|
"kernel_args=priority=critical "
|
|
"preseed/file=/preseed.cfg console=ttysclp0",
|
|
]
|
|
else:
|
|
cmd.append("--import")
|
|
cmd += [
|
|
"--disk",
|
|
f"path={disk},format=qcow2,bus=virtio",
|
|
]
|
|
# Le seed n'existe QUE sur la voie image cloud. Sous debian-installer, le
|
|
# preseed voyage dans l'initrd et un second disque ne ferait qu'ajouter un
|
|
# /dev/vdb dont partman-auto devrait être protégé.
|
|
if not installer:
|
|
# Seed cloud-init attaché comme DISQUE virtio en lecture seule (et non
|
|
# en CD-ROM) : le pilote virtio-blk est dans l'initramfs, donc le
|
|
# volume « cidata » est visible dès init-local et cloud-init le lit.
|
|
# En CD-ROM, l'initramfs Debian ne charge pas sr_mod à temps -> le
|
|
# seed n'est pas vu et rien ne s'applique (Ubuntu, lui, tolère le CD).
|
|
# Sur s390x, bus=virtio est mappé en virtio-ccw par libvirt.
|
|
cmd += ["--disk", f"path={seed},readonly=on,bus=virtio"]
|
|
cmd += [
|
|
"--osinfo",
|
|
osinfo,
|
|
"--network",
|
|
args.network,
|
|
"--console",
|
|
# Journal de console, pour toutes les voies. Une console « pty » seule
|
|
# ne garde rien : quand d-i échoue, ou qu'une image cloud reste bloquée
|
|
# avant son serveur ssh, la VM l'écrit à un écran que personne ne
|
|
# regarde, et il ne reste RIEN à lire ensuite. Le fichier, lui, survit
|
|
# à l'arrêt du domaine.
|
|
f"pty,target_type={console_target},log.file={console_log}",
|
|
# Canal virtio de l'agent invité (org.qemu.guest_agent.0) : permet à
|
|
# virsh de piloter la VM SANS réseau (ex. étendre le FS invité après
|
|
# un redimensionnement de disque). Inoffensif si l'agent est absent.
|
|
"--channel",
|
|
"unix,target.type=virtio,target.name=org.qemu.guest_agent.0",
|
|
]
|
|
# « --graphics none » dit « aucun affichage » : le poser à côté d'un
|
|
# « egl-headless », qui EST un affichage, se contredit. Sur une VM sans
|
|
# console dont la 3D est demandée, egl-headless reste donc le seul.
|
|
graphics_omis = bool(gpu_args) and graphics == "none"
|
|
if not graphics_omis:
|
|
cmd += ["--graphics", graphics]
|
|
i_gpu = len(cmd)
|
|
cmd += video + gpu_args
|
|
if args.arch == "s390x":
|
|
# s390x (IBM Z) : machine s390-ccw-virtio, amorçage IPL/zipl depuis le
|
|
# disque (ni BIOS ni UEFI/OVMF -> aucun --boot).
|
|
cmd += ["--arch", "s390x", "--machine", "s390-ccw-virtio"]
|
|
elif args.arch == "arm64":
|
|
# arm64/aarch64 : machine « virt » + UEFI (firmware AAVMF). Les images
|
|
# cloud arm64 n'ont pas de BIOS -> UEFI obligatoire. Secure Boot
|
|
# DÉSACTIVÉ (comme x86) : sinon libvirt sélectionne l'AAVMF « secure »
|
|
# à clés Microsoft enrôlées et la VM RESTE FIGÉE au firmware (aucun
|
|
# boot, aucune IP). secure-boot=no -> AAVMF non-SB, boot OK.
|
|
cmd += [
|
|
"--arch",
|
|
"aarch64",
|
|
"--machine",
|
|
"virt",
|
|
"--boot",
|
|
"uefi,firmware.feature0.name=secure-boot,"
|
|
"firmware.feature0.enabled=no",
|
|
]
|
|
elif not args.bios:
|
|
# Boot UEFI par défaut (x86) : Debian 13 (trixie) et les images cloud
|
|
# récentes n'embarquent plus le chargeur BIOS/GRUB-pc et partent en
|
|
# boucle « Booting... » en SeaBIOS. --bios force l'ancien BIOS, que
|
|
# certaines distributions exigent — voir FIRMWARE_IMPOSE.
|
|
# Secure Boot DÉSACTIVÉ : le chargeur d'Arch (GRUB) n'est pas signé et
|
|
# OVMF Secure Boot le refuse (« Access Denied » -> pas de boot).
|
|
cmd += [
|
|
"--boot",
|
|
"uefi,firmware.feature0.name=secure-boot,"
|
|
"firmware.feature0.enabled=no",
|
|
]
|
|
if emulated:
|
|
# Architecture différente de l'hôte -> émulation logicielle TCG
|
|
# (pas de KVM). LENT.
|
|
cmd += ["--virt-type", "qemu"]
|
|
if not args.attach_console:
|
|
cmd.append("--noautoconsole")
|
|
if installer:
|
|
# Sans « --wait 0 », virt-install RESTE au premier plan jusqu'à la
|
|
# fin de l'installation. Sous émulation s390x elle se compte en
|
|
# heures, et le déploiement parallèle attendrait chaque VM l'une
|
|
# après l'autre. La configuration finale est déjà écrite : rendre
|
|
# la main n'abandonne rien.
|
|
cmd += ["--wait", "0"]
|
|
# virtinst écrit un journal de debug dans ~/.cache/virt-manager ; sous
|
|
# sudo, HOME/cache peut être inaccessible -> l'écriture échoue et Python
|
|
# déverse un « Logging error » (le pavé « Fetched capabilities … »). On
|
|
# force un cache/HOME ÉCRIVABLE via « env VAR=… » (traverse sudo) pour
|
|
# que le journal s'écrive silencieusement.
|
|
# Chemin propre à l'UID : un répertoire partagé finit créé par root lors
|
|
# d'un premier passage sous sudo, puis devient illisible pour l'utilisateur
|
|
# (« Error setting up logfile: No write access to … /virt-manager »).
|
|
cache_dir = f"/var/tmp/erplibre-virtinst-{os.getuid()}"
|
|
try:
|
|
os.makedirs(cache_dir, mode=0o700, exist_ok=True)
|
|
except OSError:
|
|
pass
|
|
log_env = [
|
|
"env",
|
|
f"XDG_CACHE_HOME={cache_dir}",
|
|
f"HOME={cache_dir}",
|
|
]
|
|
if not gpu_args:
|
|
runner.run(log_env + cmd, privileged=True)
|
|
return
|
|
# La 3D est le SEUL argument dont l'échec se rattrape : le nœud de rendu
|
|
# existe, mais QEMU n'arrive pas à y démarrer EGL. Rien ne permet de le
|
|
# savoir avant d'essayer, donc on essaie, et on retire la 3D si c'est
|
|
# elle qui a fait tomber le domaine.
|
|
code, sortie = runner.run(log_env + cmd, privileged=True, capture=True)
|
|
if code == 0:
|
|
return
|
|
if not egl_failed(sortie):
|
|
sys.exit(
|
|
f"\nÉchec de la commande (code {code}) :\n"
|
|
f" {' '.join(log_env + cmd)}"
|
|
)
|
|
# Le repli vaut AUSSI pour « --gpu on ». Une VM qu'on n'a pas est pire
|
|
# qu'une VM sans 3D, et le repli n'est pas silencieux : il le dit, en
|
|
# nommant ce qui a été demandé et ce qui a été obtenu.
|
|
demande = (args.gpu or "auto").lower() == "on"
|
|
print(
|
|
f"\n ⚠ EGL ne démarre pas sur {gpu_node} :"
|
|
f" {'la 3D DEMANDÉE est retirée' if demande else 'la 3D est retirée'}"
|
|
"\n et la VM recréée en rendu logiciel."
|
|
" « --gpu off » évite cet essai."
|
|
)
|
|
# Rendre l'affichage écarté plus haut : sans lui, la VM repartirait sans
|
|
# « --graphics none », donc avec le défaut de virt-install, qui n'est pas
|
|
# ce qu'on avait demandé.
|
|
rendu = [] if not graphics_omis else ["--graphics", graphics]
|
|
cmd_sans_3d = (
|
|
cmd[:i_gpu]
|
|
+ rendu
|
|
+ video_sans_3d
|
|
+ cmd[i_gpu + len(video) + len(gpu_args) :]
|
|
)
|
|
# Le domaine défini par l'essai raté doit partir : sans quoi virt-install
|
|
# refuse le nom, et la VM resterait celle qui ne démarre pas.
|
|
runner.run(
|
|
["virsh", "--connect", LIBVIRT_URI, "undefine", args.name, "--nvram"],
|
|
privileged=True,
|
|
check=False,
|
|
)
|
|
runner.run(log_env + cmd_sans_3d, privileged=True)
|
|
|
|
|
|
def watch_and_restart(name: str, runner: Runner) -> None:
|
|
"""Rallume la VM quand debian-installer a fini et l'a éteinte.
|
|
|
|
virt-install mène l'installation en DEUX temps : un amorçage transitoire
|
|
sur kernel+initrd, puis la configuration définitive, qui démarre sur le
|
|
disque. Le passage de l'un à l'autre se fait par un arrêt — l'installateur
|
|
redémarre, libvirt détruit le domaine transitoire — et c'est virt-install
|
|
qui rallume ensuite. Avec « --wait 0 » il est déjà parti : le domaine
|
|
reste « shut off », disque installé et XML correct, mais éteint.
|
|
|
|
On ne peut pas pour autant laisser virt-install attendre : sous émulation
|
|
l'installation dure des heures, et le déploiement rendrait la main à ce
|
|
rythme-là. Un veilleur détaché fait donc le dernier geste.
|
|
"""
|
|
if runner.dry_run:
|
|
print(f"[dry-run] veilleur de redémarrage pour {name}")
|
|
return
|
|
sudo = "sudo " if runner.use_sudo else ""
|
|
# 6 h de garde : bien au-delà d'une installation émulée, et le veilleur
|
|
# meurt de lui-même si quelque chose a mal tourné.
|
|
script = (
|
|
f"for i in $(seq 1 720); do "
|
|
f" s=$({sudo}virsh -c {LIBVIRT_URI} domstate {name} 2>/dev/null); "
|
|
f' if [ "$s" = "shut off" ]; then '
|
|
f" {sudo}virsh -c {LIBVIRT_URI} start {name} >/dev/null 2>&1; "
|
|
f" exit 0; "
|
|
f" fi; "
|
|
f" sleep 30; "
|
|
f"done"
|
|
)
|
|
try:
|
|
subprocess.Popen(
|
|
["/bin/sh", "-c", script],
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
start_new_session=True,
|
|
)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
print(f" ⚠ veilleur non lancé ({exc}) ; démarrer à la main :")
|
|
print(f" sudo virsh start {name}")
|
|
return
|
|
print(
|
|
f" Veilleur lancé : {name} sera rallumée dès que l'installateur"
|
|
" l'aura éteinte."
|
|
)
|
|
|
|
|
|
def _ip_reachable(ip: str, port: int = 22, timeout: float = 3) -> bool:
|
|
"""Vrai si le port SSH répond (distingue le bail actif du bail périmé)."""
|
|
try:
|
|
with socket.create_connection((ip, port), timeout=timeout):
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def wait_for_ip(name: str, use_sudo: bool, timeout: int) -> str | None:
|
|
"""Interroge les baux DHCP libvirt jusqu'à obtenir l'IPv4 de la VM. Une VM
|
|
peut avoir PLUSIEURS baux (l'image demande d'abord une IP avec le hostname
|
|
par défaut « ubuntu », puis cloud-init fixe le vrai hostname -> 2e bail) :
|
|
on renvoie une IP JOIGNABLE (sshd up), sinon la plus récente, jamais
|
|
aveuglément la 1re (souvent le bail précoce périmé, « No route to host »).
|
|
"""
|
|
base = (["sudo"] if use_sudo else []) + [
|
|
"virsh",
|
|
"domifaddr",
|
|
name,
|
|
"--source",
|
|
"lease",
|
|
]
|
|
deadline = time.time() + timeout
|
|
ips: list[str] = []
|
|
while time.time() < deadline:
|
|
res = subprocess.run(base, capture_output=True, text=True)
|
|
ips = re.findall(r"(\d+\.\d+\.\d+\.\d+)", res.stdout)
|
|
for ip in ips:
|
|
if _ip_reachable(ip):
|
|
return ip
|
|
time.sleep(3)
|
|
return ips[-1] if ips else None
|
|
|
|
|
|
def ssh_command(user: str, ip: str, has_key: bool) -> str:
|
|
"""Commande SSH adaptée : clé -> connexion simple ; mot de passe seul ->
|
|
force le mot de passe pour éviter « Too many authentication failures »."""
|
|
if has_key:
|
|
return f"ssh {user}@{ip}"
|
|
return (
|
|
f"ssh -o IdentitiesOnly=yes -o PreferredAuthentications=password "
|
|
f"{user}@{ip}"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# CLI
|
|
# --------------------------------------------------------------------------- #
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
versions_help = "\n".join(
|
|
f" {distro:<7} {default:<7} (défaut) versions : "
|
|
+ ", ".join(versions)
|
|
for distro, (versions, default) in DISTROS.items()
|
|
)
|
|
p = argparse.ArgumentParser(
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
description=__doc__,
|
|
epilog="Distros et versions (--distro / --version), specs via "
|
|
"--list-images :\n" + versions_help,
|
|
)
|
|
p.add_argument(
|
|
"image_path",
|
|
type=Path,
|
|
nargs="?",
|
|
default=None,
|
|
help="Chemin de cache de l'image cloud. Optionnel : si absent, il est "
|
|
"déduit de --distro/--version/--codename + --arch dans --image-dir. "
|
|
"Si le fichier existe, il n'est PAS re-téléchargé.",
|
|
)
|
|
|
|
g_img = p.add_argument_group("Image")
|
|
g_img.add_argument(
|
|
"--distro",
|
|
default="ubuntu",
|
|
choices=DISTROS,
|
|
help="Distribution : ubuntu, debian ou fedora (défaut : ubuntu).",
|
|
)
|
|
g_img.add_argument(
|
|
"--version",
|
|
default=None,
|
|
help="Version de la distro (défaut : la version par défaut de la "
|
|
"distro). Voir --list-images pour la liste complète.",
|
|
)
|
|
g_img.add_argument(
|
|
"--codename",
|
|
help="Force le nom de code / la release (surcharge --version).",
|
|
)
|
|
g_img.add_argument(
|
|
"--arch",
|
|
default="amd64",
|
|
help="Architecture de l'image (défaut : amd64). amd64/x86_64, "
|
|
"arm64/aarch64 (Ubuntu/Debian/Fedora) ou s390x (Ubuntu). Toute arch "
|
|
"différente de l'hôte est ÉMULÉE (TCG, lente).",
|
|
)
|
|
g_img.add_argument(
|
|
"--image-dir",
|
|
type=Path,
|
|
default=DEFAULT_IMAGE_DIR,
|
|
help="Répertoire de cache des images cloud quand image_path est "
|
|
f"omis (défaut : {DEFAULT_IMAGE_DIR}).",
|
|
)
|
|
g_img.add_argument(
|
|
"--verify",
|
|
action="store_true",
|
|
help="Sans effet : la vérification est désormais le DÉFAUT pour "
|
|
"toute distribution qui publie ses sommes. Gardé pour que les "
|
|
"commandes déjà écrites continuent de marcher.",
|
|
)
|
|
g_img.add_argument(
|
|
"--no-verify",
|
|
action="store_true",
|
|
help="Ne vérifie PAS l'image contre les sommes publiées par sa "
|
|
"distribution. À réserver aux essais hors ligne : une image "
|
|
"substituée sur un miroir passerait alors sans un mot.",
|
|
)
|
|
|
|
g_vm = p.add_argument_group("VM")
|
|
g_vm.add_argument(
|
|
"--name",
|
|
help="Nom de la VM (virsh). Requis pour déployer ; inutile avec "
|
|
"--download-only.",
|
|
)
|
|
g_vm.add_argument(
|
|
"--hostname", help="Nom d'hôte interne (défaut : --name)."
|
|
)
|
|
g_vm.add_argument(
|
|
"--memory",
|
|
type=int,
|
|
default=None,
|
|
help="RAM en Mo (défaut : minimum requis par la version choisie, "
|
|
"voir --list-images).",
|
|
)
|
|
g_vm.add_argument(
|
|
"--vcpus", type=int, default=2, help="Nombre de vCPU (défaut : 2)."
|
|
)
|
|
g_vm.add_argument(
|
|
"--disk-size",
|
|
default=None,
|
|
help="Taille du disque virtuel, ex. 120G (défaut : minimum requis "
|
|
"par la version choisie, voir --list-images).",
|
|
)
|
|
g_vm.add_argument(
|
|
"--disk-dir",
|
|
type=Path,
|
|
default=DEFAULT_DISK_DIR,
|
|
help=f"Répertoire du qcow2 de travail (défaut : {DEFAULT_DISK_DIR}).",
|
|
)
|
|
g_vm.add_argument(
|
|
"--seed-dir",
|
|
type=Path,
|
|
default=DEFAULT_IMAGE_DIR,
|
|
help=f"Répertoire du seed.iso (défaut : {DEFAULT_IMAGE_DIR}).",
|
|
)
|
|
g_vm.add_argument(
|
|
"--network",
|
|
default="network=default,model=virtio",
|
|
help="Argument --network de virt-install.",
|
|
)
|
|
g_vm.add_argument(
|
|
"--graphics",
|
|
default="none",
|
|
help="Argument --graphics (défaut : none).",
|
|
)
|
|
g_vm.add_argument(
|
|
"--desktop",
|
|
action="store_true",
|
|
help="VM graphique : écran virtuel SPICE là où l'architecture le "
|
|
"permet. Les paquets GNOME sont posés par la commande d'installation, "
|
|
"pas ici.",
|
|
)
|
|
g_vm.add_argument(
|
|
"--vm-tools",
|
|
default="",
|
|
help="Clés des outils que la commande d'installation va poser, "
|
|
"séparées par des virgules. Ils ne sont PAS installés ici : la liste "
|
|
"ne sert qu'au guide de connexion, qui les annonce et donne les "
|
|
"quelques commandes dont on a besoin en entrant.",
|
|
)
|
|
g_vm.add_argument(
|
|
"--gpu",
|
|
choices=("auto", "on", "off"),
|
|
default="auto",
|
|
help="Accélération 3D par le GPU de l'hôte : auto (défaut, activée "
|
|
"si un /dev/dri/renderD* existe), on (forcer), off (rendu logiciel).",
|
|
)
|
|
g_vm.add_argument(
|
|
"--gpu-node",
|
|
default="",
|
|
help="Nœud de rendu à utiliser (défaut : le premier trouvé). Utile "
|
|
"sur un hôte à plusieurs cartes.",
|
|
)
|
|
g_vm.add_argument(
|
|
"--osinfo", help="Force la valeur --osinfo (sinon déduite)."
|
|
)
|
|
g_vm.add_argument(
|
|
"--attach-console",
|
|
action="store_true",
|
|
help="Attache la console série (sinon --noautoconsole).",
|
|
)
|
|
g_vm.add_argument(
|
|
"--bios",
|
|
action="store_true",
|
|
help="Force l'amorçage BIOS hérité au lieu d'UEFI. UEFI est le "
|
|
"défaut, sauf pour les distributions dont la chaîne UEFI ne démarre "
|
|
"pas (Fedora), où le BIOS est retenu d'office.",
|
|
)
|
|
|
|
g_cloud = p.add_argument_group("cloud-init")
|
|
g_cloud.add_argument(
|
|
"--user",
|
|
default="erplibre",
|
|
help="Utilisateur créé (défaut : erplibre).",
|
|
)
|
|
g_cloud.add_argument(
|
|
"--password", help="Mot de passe en clair (déconseillé : visible)."
|
|
)
|
|
g_cloud.add_argument(
|
|
"--ask-password",
|
|
action="store_true",
|
|
help="Demande le mot de passe de façon interactive (sûr).",
|
|
)
|
|
g_cloud.add_argument(
|
|
"--password-hash",
|
|
help="Empreinte $6$... déjà calculée (openssl passwd -6).",
|
|
)
|
|
g_cloud.add_argument(
|
|
"--ssh-key",
|
|
action="append",
|
|
default=[],
|
|
metavar="FICHIER",
|
|
help="Fichier de clé publique SSH à injecter (répétable).",
|
|
)
|
|
g_cloud.add_argument(
|
|
"--locale",
|
|
default="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(
|
|
"--keyboard-layout",
|
|
default="ca",
|
|
help="Disposition clavier (défaut : ca).",
|
|
)
|
|
g_cloud.add_argument(
|
|
"--keyboard-variant",
|
|
default="multix",
|
|
help="Variante clavier (défaut : multix).",
|
|
)
|
|
g_cloud.add_argument(
|
|
"--package",
|
|
action="append",
|
|
default=[],
|
|
metavar="PKG",
|
|
help="Paquet APT additionnel (répétable).",
|
|
)
|
|
g_cloud.add_argument(
|
|
"--no-upgrade",
|
|
action="store_true",
|
|
help="N'exécute pas package_upgrade au premier boot.",
|
|
)
|
|
g_cloud.add_argument(
|
|
"--lang",
|
|
choices=("fr", "en"),
|
|
default="fr",
|
|
help="Langue du guide affiché à la connexion SSH (défaut : fr). "
|
|
"todo.py passe la langue de son menu.",
|
|
)
|
|
g_cloud.add_argument(
|
|
"--erplibre-dir",
|
|
default="",
|
|
metavar="CHEMIN",
|
|
help="Racine d'ERPLibre dans la VM (~/git/erplibre en dev, "
|
|
"/opt/erplibre en prod). Ajoute la section ERPLibre au guide de "
|
|
"connexion. Vide, elle est omise : une VM déployée sans installation "
|
|
"n'annonce pas un dépôt et un service qui n'existent pas.",
|
|
)
|
|
g_cloud.add_argument(
|
|
"--erplibre-make",
|
|
default="",
|
|
metavar="CIBLE",
|
|
help="Cible make qui a installé la VM (ex. install_odoo_18), reprise "
|
|
"dans le guide pour la mettre à jour. Vide : le guide s'arrête à "
|
|
"« git pull » plutôt que d'annoncer une cible qui n'est pas la bonne.",
|
|
)
|
|
g_cloud.add_argument(
|
|
"--git-name",
|
|
default="",
|
|
help="Nom pour le ~/.gitconfig de la VM (défaut : celui de l'hôte).",
|
|
)
|
|
g_cloud.add_argument(
|
|
"--git-email",
|
|
default="",
|
|
help="Courriel pour le ~/.gitconfig de la VM (défaut : l'hôte).",
|
|
)
|
|
g_cloud.add_argument(
|
|
"--no-git-identity",
|
|
action="store_true",
|
|
help="N'injecte pas l'identité git de l'hôte (user.name, user.email, "
|
|
"core.editor) dans le ~/.gitconfig de la VM.",
|
|
)
|
|
g_cloud.add_argument(
|
|
"--cache-ca",
|
|
default="",
|
|
help="Chemin, SUR L'HÔTE, du certificat de l'autorité du cache de "
|
|
"téléchargement (erplibre_go_qemu_cache). Fourni, la VM approuve "
|
|
"cette autorité dès son premier démarrage et ses téléchargements "
|
|
"passent par le cache. Absent, rien n'est posé.",
|
|
)
|
|
g_cloud.add_argument(
|
|
"--offline",
|
|
action="store_true",
|
|
help="La VM est déployée l'amont du cache coupé : ce qu'aucun cache ne "
|
|
"peut rejouer y est désactivé, l'audit de npm d'abord. Sans effet "
|
|
"sans --cache-ca.",
|
|
)
|
|
g_cloud.add_argument(
|
|
"--cache-bypass",
|
|
action="store_true",
|
|
help="Soustrait CETTE VM au cache de téléchargement : une exception "
|
|
"par adresse MAC est posée sur l'hôte avant la création, et la VM "
|
|
"télécharge en direct. Sans cela, retirer --cache-ca ne suffit pas — "
|
|
"le détournement est transparent et la VM échouerait sur un "
|
|
"certificat inconnu.",
|
|
)
|
|
g_cloud.add_argument(
|
|
"--apt-update",
|
|
action="store_true",
|
|
help="Exécute « apt update » au 1er boot (package_update). Désactivé "
|
|
"par défaut : évite que cloud-init se bloque sur un miroir lent/"
|
|
"injoignable (SSH est déjà présent dans les images cloud).",
|
|
)
|
|
|
|
g_run = p.add_argument_group("Exécution")
|
|
g_run.add_argument(
|
|
"--no-sudo",
|
|
action="store_true",
|
|
help="N'utilise pas sudo pour les étapes privilégiées.",
|
|
)
|
|
g_run.add_argument(
|
|
"--force",
|
|
action="store_true",
|
|
help="Écrase le qcow2 de travail existant.",
|
|
)
|
|
g_run.add_argument(
|
|
"--no-wait-ip",
|
|
action="store_true",
|
|
help="N'attend pas l'attribution de l'IP à la fin.",
|
|
)
|
|
g_run.add_argument(
|
|
"--ip-timeout",
|
|
type=int,
|
|
default=90,
|
|
metavar="SEC",
|
|
help="Délai max d'attente de l'IP DHCP (défaut : 90 s).",
|
|
)
|
|
g_run.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Affiche les commandes et le user-data sans rien exécuter.",
|
|
)
|
|
g_run.add_argument(
|
|
"--download-only",
|
|
action="store_true",
|
|
help="Télécharge (et vérifie si --verify) l'image cloud puis quitte, "
|
|
"sans créer de VM. --name n'est pas requis.",
|
|
)
|
|
g_run.add_argument(
|
|
"-y",
|
|
"--assume-yes",
|
|
action="store_true",
|
|
help="Accepte automatiquement l'installation des dépendances manquantes.",
|
|
)
|
|
g_run.add_argument(
|
|
"--no-install-deps",
|
|
action="store_true",
|
|
help="N'installe jamais les dépendances manquantes (échoue si absentes).",
|
|
)
|
|
g_run.add_argument(
|
|
"--setup-host",
|
|
action="store_true",
|
|
help="Prépare l'hôte (paquets QEMU/libvirt, démon, groupe libvirt, "
|
|
"réseau default) puis quitte. Ne déploie aucune VM.",
|
|
)
|
|
g_run.add_argument(
|
|
"--reboot-if-needed",
|
|
action="store_true",
|
|
help="Avec --setup-host : PROPOSE un redémarrage si le noyau a été "
|
|
"mis à jour depuis le démarrage (sinon libvirt ne peut pas créer "
|
|
"virbr0). La question est posée sur /dev/tty et vaut non par défaut.",
|
|
)
|
|
g_run.add_argument(
|
|
"--assume-yes-reboot",
|
|
action="store_true",
|
|
help="Redémarre sans poser la question. Réservé à une provision "
|
|
"sans personne devant l'écran ; --assume-yes ne l'implique pas.",
|
|
)
|
|
g_run.add_argument(
|
|
"--list-images",
|
|
action="store_true",
|
|
help="Liste les distros/versions disponibles et leurs specs, "
|
|
"puis quitte.",
|
|
)
|
|
return p
|
|
|
|
|
|
def resolve_password(args: argparse.Namespace) -> str | None:
|
|
if args.password_hash:
|
|
return args.password_hash
|
|
if args.ask_password:
|
|
pw = getpass.getpass("Mot de passe pour l'utilisateur : ")
|
|
if pw != getpass.getpass("Confirmer : "):
|
|
sys.exit("Les mots de passe ne correspondent pas.")
|
|
return hash_password(pw)
|
|
if args.password:
|
|
return hash_password(args.password)
|
|
return None
|
|
|
|
|
|
def load_ssh_keys(paths: list[str]) -> list[str]:
|
|
keys: list[str] = []
|
|
for path in paths:
|
|
p = Path(path).expanduser()
|
|
if not p.exists():
|
|
sys.exit(f"Clé SSH introuvable : {p}")
|
|
keys.append(p.read_text().strip())
|
|
return keys
|
|
|
|
|
|
def main() -> None:
|
|
# Sortie ligne par ligne même quand stdout est un tube (menu todo) : sinon
|
|
# les en-têtes restent bufferisés et le déploiement paraît « gelé ».
|
|
try:
|
|
sys.stdout.reconfigure(line_buffering=True)
|
|
except (AttributeError, ValueError):
|
|
pass
|
|
|
|
args = build_parser().parse_args()
|
|
|
|
if args.list_images:
|
|
list_images()
|
|
return
|
|
|
|
# Préparation de l'hôte : aucune image, aucune distro cible, aucun --name.
|
|
# Traité AVANT la résolution de version/image pour rester utilisable seul.
|
|
if args.setup_host:
|
|
setup_host(
|
|
Runner(
|
|
use_sudo=not args.no_sudo and os.geteuid() != 0,
|
|
dry_run=args.dry_run,
|
|
),
|
|
args.assume_yes,
|
|
args.no_install_deps,
|
|
args.reboot_if_needed,
|
|
args.assume_yes_reboot,
|
|
)
|
|
return
|
|
|
|
versions, default_version = DISTROS[args.distro]
|
|
if args.version is None:
|
|
args.version = default_version
|
|
if args.version not in versions:
|
|
sys.exit(
|
|
f"Version {args.version!r} inconnue pour {args.distro}. "
|
|
f"Choix : {', '.join(versions)} (voir --list-images)."
|
|
)
|
|
# Certaines architectures ne sont publiées que par une partie des distros
|
|
# (voir ARCH_DISTRO_SUPPORT) : on valide tôt plutôt qu'échouer au download.
|
|
supported = ARCH_DISTRO_SUPPORT.get(args.arch)
|
|
if supported is not None and args.distro not in supported:
|
|
sys.exit(
|
|
f"Architecture {args.arch} indisponible pour {args.distro!r} : "
|
|
f"images cloud publiées seulement pour {', '.join(supported)}."
|
|
)
|
|
# Toutes les versions d'une distro ne sont pas publiées sur toutes les
|
|
# architectures : le dire ici plutôt que d'échouer au téléchargement.
|
|
ok_versions = arch_versions(args.distro, args.arch, versions)
|
|
if args.version not in ok_versions:
|
|
sys.exit(
|
|
f"{args.distro} {args.version} n'est pas publié en {args.arch}.\n"
|
|
f" Versions disponibles : {', '.join(ok_versions) or 'aucune'}."
|
|
)
|
|
code, default_osinfo, min_ram, min_disk = versions[args.version]
|
|
if args.codename:
|
|
code = args.codename
|
|
osinfo = args.osinfo or default_osinfo
|
|
# Dimensionnement par défaut = minimum requis par la version (libosinfo).
|
|
if args.memory is None:
|
|
args.memory = min_ram
|
|
if args.disk_size is None:
|
|
args.disk_size = min_disk
|
|
# Un nombre nu (« 30 ») serait pris pour des OCTETS par qemu-img et ferait
|
|
# échouer le resize : on suppose des Go par défaut (« 30 » -> « 30G »).
|
|
if re.fullmatch(r"\d+", str(args.disk_size)):
|
|
args.disk_size = f"{args.disk_size}G"
|
|
urls = image_candidates(
|
|
args.distro, code, args.arch, args.version, args.dry_run
|
|
)
|
|
url = urls[0]
|
|
# PAR DÉFAUT, et pour toute distribution qui publie ses sommes. C'était
|
|
# un drapeau, et réservé à Ubuntu : les huit autres images arrivaient
|
|
# sans que rien ne les vérifie, alors que leurs éditeurs publient tous
|
|
# une somme à côté. Le coût est une lecture du fichier téléchargé ; le
|
|
# risque couvert est une image substituée sur un miroir.
|
|
do_verify = not args.no_verify and args.distro in SUMS_SOURCE
|
|
if not args.no_verify and not do_verify:
|
|
print(
|
|
f" Note : aucune somme publiée n'est lisible d'ici pour "
|
|
f"{args.distro}."
|
|
)
|
|
|
|
# Chemin de l'image : déduit automatiquement si non fourni.
|
|
if args.image_path is None:
|
|
args.image_path = args.image_dir / default_image_name(
|
|
args.distro, code, args.arch, args.version
|
|
)
|
|
|
|
runner = Runner(
|
|
use_sudo=not args.no_sudo and os.geteuid() != 0, dry_run=args.dry_run
|
|
)
|
|
|
|
# -- Mode téléchargement seul : aucun outil ni VM requis. --------------
|
|
if args.download_only:
|
|
print(
|
|
f"\n== Téléchargement image cloud "
|
|
f"({args.distro} {args.version} / {code}) =="
|
|
)
|
|
print(f" Destination : {args.image_path}")
|
|
download_image(urls, args.image_path, args.dry_run)
|
|
verify_pinned_sha256(
|
|
args.distro, args.image_path, args.dry_run, tuple(urls)
|
|
)
|
|
if do_verify:
|
|
verify_sha256(url, args.image_path, args.dry_run, args.distro)
|
|
print("\nTerminé (téléchargement seul).")
|
|
return
|
|
|
|
# -- Déploiement complet -----------------------------------------------
|
|
if not args.name:
|
|
sys.exit(
|
|
"Erreur : --name est requis pour déployer une VM "
|
|
"(ou utilisez --download-only)."
|
|
)
|
|
# Un nom d'hôte ne connaît que lettres, chiffres et tirets : le souligné
|
|
# y est refusé, et la VM garde alors le nom générique de son image sans que
|
|
# rien d'autre qu'un avertissement de cloud-init ne le dise. Le nom de
|
|
# DOMAINE, lui, peut le porter — les deux ne se ressemblent qu'en général.
|
|
args.hostname = args.hostname or hostname_valide(args.name)
|
|
demande_bios = args.bios
|
|
args.bios = amorcage_bios(args.distro, args.bios)
|
|
if demande_bios and not args.bios:
|
|
print(
|
|
f"\n --bios ignoré : l'image {args.distro} n'a pas de secteur"
|
|
" d'amorçage BIOS. Forcé, elle se déclarerait « running » avec"
|
|
" une console muette."
|
|
)
|
|
|
|
pw_hash = resolve_password(args)
|
|
ssh_keys = load_ssh_keys(args.ssh_key)
|
|
if not pw_hash and not ssh_keys:
|
|
print(
|
|
"ATTENTION : ni mot de passe ni clé SSH -> connexion impossible à la VM.\n"
|
|
" Ajoutez --ssh-key, --ask-password ou --password-hash.\n",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
disk = args.disk_dir / f"{args.name}.qcow2"
|
|
seed = args.seed_dir / f"{args.name}-seed.iso"
|
|
|
|
if not args.dry_run:
|
|
ensure_tools(runner, args.assume_yes, args.no_install_deps)
|
|
# Émulation requise si l'arch diffère de l'hôte : installe l'émulateur
|
|
# QEMU système adéquat (+ firmware UEFI pour arm64) et prévient de la
|
|
# lenteur.
|
|
if args.arch in EMULATOR_BINARY and args.arch != host_arch():
|
|
ensure_emulator(
|
|
args.arch, runner, args.assume_yes, args.no_install_deps
|
|
)
|
|
print(
|
|
f" Note : {args.arch} est ÉMULÉ (TCG) sur cet hôte "
|
|
f"({host_arch()}) — le boot et l'installation seront "
|
|
"nettement plus lents que l'architecture native."
|
|
)
|
|
|
|
installer: tuple[Path, Path] | None = None
|
|
if uses_installer(args.distro, args.arch):
|
|
# Voie debian-installer : aucune image cloud n'existe pour cette
|
|
# combinaison, on télécharge l'installateur et on part d'un disque nu.
|
|
cache = args.image_path.parent
|
|
kernel = cache / f"debian-{args.version}-s390x-kernel"
|
|
initrd_src = cache / f"debian-{args.version}-s390x-initrd.gz"
|
|
initrd = cache / f"{args.name}-initrd.gz"
|
|
# Le dimensionnement du catalogue vient des images cloud, où le
|
|
# système est DÉJÀ installé. debian-installer, lui, déplie un système
|
|
# de fichiers complet en mémoire avant d'écrire quoi que ce soit :
|
|
# 1024 Mio est le plancher annoncé par Debian, sans marge, et un
|
|
# manque de mémoire s'y manifeste par un écran figé sans message.
|
|
# On relève le plancher, en le disant — un réglage explicite plus haut
|
|
# n'est jamais abaissé.
|
|
if args.memory < INSTALLER_MIN_RAM:
|
|
print(
|
|
f" Mémoire portée à {INSTALLER_MIN_RAM} Mio pour"
|
|
f" l'installateur (catalogue : {args.memory})."
|
|
)
|
|
args.memory = INSTALLER_MIN_RAM
|
|
print(f"\n== 1/5 Installateur Debian {args.version} ({code}) s390x ==")
|
|
download_image(
|
|
[INSTALLER_URL.format(code=code, fichier="kernel.debian")],
|
|
kernel,
|
|
args.dry_run,
|
|
)
|
|
download_image(
|
|
[INSTALLER_URL.format(code=code, fichier="initrd.debian")],
|
|
initrd_src,
|
|
args.dry_run,
|
|
)
|
|
|
|
print(f"\n== 2-3/5 Disque vierge {disk} ({args.disk_size}) ==")
|
|
create_blank_disk(disk, args.disk_size, runner, args.force)
|
|
|
|
print(f"\n== 4/5 Preseed embarqué dans l'initrd ==")
|
|
static = static_net_plan(
|
|
network_name(args.network), not args.dry_run, args.name
|
|
)
|
|
if static:
|
|
print(
|
|
f" Adresse fixe retenue : {static['ip']}"
|
|
f" (passerelle {static['gateway']})"
|
|
)
|
|
else:
|
|
print(
|
|
" ⚠ Aucune adresse fixe déterminée : netcfg-static posera"
|
|
" la question à l'écran et l'installation s'arrêtera."
|
|
)
|
|
build_installer_initrd(
|
|
build_preseed(args, pw_hash, ssh_keys, static),
|
|
initrd_src,
|
|
initrd,
|
|
runner,
|
|
)
|
|
installer = (kernel, initrd)
|
|
else:
|
|
print(
|
|
f"\n== 1/5 Image cloud ({args.distro} {args.version} / {code}) =="
|
|
)
|
|
download_image(urls, args.image_path, args.dry_run)
|
|
verify_pinned_sha256(
|
|
args.distro, args.image_path, args.dry_run, tuple(urls)
|
|
)
|
|
if do_verify:
|
|
verify_sha256(url, args.image_path, args.dry_run, args.distro)
|
|
|
|
print(f"\n== 2-3/5 Disque de travail {disk} ({args.disk_size}) ==")
|
|
prepare_disk(args.image_path, disk, args.disk_size, runner, args.force)
|
|
|
|
print(f"\n== 4/5 Seed cloud-init {seed} ==")
|
|
cloud_cfg = build_cloud_config(args, pw_hash, ssh_keys)
|
|
build_seed(
|
|
cloud_cfg,
|
|
args.hostname,
|
|
seed,
|
|
runner,
|
|
network_config_for(args.distro),
|
|
)
|
|
|
|
resolved_osinfo = osinfo_arg(osinfo, args.distro)
|
|
print(f"\n== 5/5 virt-install (--osinfo {resolved_osinfo}) ==")
|
|
ensure_network(network_name(args.network), runner)
|
|
# Avant la création, et non après : la VM télécharge dès cloud-init.
|
|
# Une famille sans magasin de certificats ne peut pas apprendre
|
|
# l'autorité du cache : on l'en SOUSTRAIT plutôt que de la laisser buter
|
|
# sur un certificat inconnu à chaque téléchargement. Décidé ici et non
|
|
# demandé à l'opérateur — c'est le catalogue qui sait.
|
|
#
|
|
# JAMAIS HORS LIGNE, et c'est la même raison que sur la voie Proxmox :
|
|
# l'amont est alors coupé et le magasin est la SEULE source. Soustraire
|
|
# la VM ne la ferait pas télécharger en direct — cela ne lui laisserait
|
|
# RIEN. Les deux issues échouent pour une distribution sans magasin de
|
|
# certificats : exceptée, elle n'a plus de source ; interceptée, elle bute
|
|
# sur un certificat qu'elle ne reconnaît pas. Le dire AVANT vaut mieux
|
|
# qu'une heure d'installation pour y arriver.
|
|
sans_magasin = cache_sans_autorite(getattr(args, "distro", ""))
|
|
if sans_magasin and getattr(args, "offline", False):
|
|
print(
|
|
f"\n ⚠ {args.distro} n'a pas de magasin de certificats, et"
|
|
" l'amont du cache est coupé."
|
|
"\n Aucune source ne lui reste : l'exception la priverait du"
|
|
" magasin, et sans elle"
|
|
"\n chaque téléchargement bute sur un certificat inconnu."
|
|
)
|
|
elif sans_magasin and not getattr(args, "cache_bypass", False):
|
|
args.cache_bypass = True
|
|
print(
|
|
f"\n {args.distro} n'a pas de magasin de certificats :"
|
|
" la VM est soustraite au cache et téléchargera en direct."
|
|
)
|
|
cache_bypass_apply(args, runner)
|
|
virt_install(args, disk, seed, resolved_osinfo, runner, installer)
|
|
if installer:
|
|
watch_and_restart(args.name, runner)
|
|
|
|
has_key = bool(ssh_keys)
|
|
print("\nTerminé. Suivi :")
|
|
print(" virsh list --all")
|
|
print(f" virsh console {args.name} # Ctrl+] pour quitter")
|
|
|
|
if args.dry_run:
|
|
print("\n Connexion SSH (IP attribuée au 1er boot) :")
|
|
print(f" {ssh_command(args.user, '<IP>', has_key)}")
|
|
return
|
|
if args.no_wait_ip:
|
|
print(
|
|
f"\n Récupérer l'IP : virsh domifaddr {args.name} --source lease"
|
|
)
|
|
return
|
|
|
|
print(f"\n Attente de l'IP (bail DHCP, max {args.ip_timeout} s)…")
|
|
ip = wait_for_ip(args.name, runner.use_sudo, args.ip_timeout)
|
|
if ip:
|
|
print(f" IP : {ip}")
|
|
print(" Connexion SSH :")
|
|
print(f" {ssh_command(args.user, ip, has_key)}")
|
|
else:
|
|
print(" IP non obtenue dans le délai (cloud-init encore en cours ?).")
|
|
print(f" Réessayez : virsh domifaddr {args.name} --source lease")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|