[UPD] script qemu: show adress ip after launch

This commit is contained in:
Mathieu Benoit 2026-07-04 22:25:25 -04:00
parent 6f087d2a9b
commit dc42bb715a

View file

@ -29,11 +29,13 @@ import argparse
import getpass import getpass
import hashlib import hashlib
import os import os
import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import textwrap import textwrap
import time
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
@ -49,6 +51,7 @@ UBUNTU_VERSIONS: dict[str, tuple[str, str]] = {
"24.10": ("oracular", "ubuntu24.10"), "24.10": ("oracular", "ubuntu24.10"),
"25.04": ("plucky", "ubuntu25.04"), "25.04": ("plucky", "ubuntu25.04"),
"25.10": ("questing", "ubuntu25.10"), "25.10": ("questing", "ubuntu25.10"),
# "26.04": ("resolute", "ubuntu26.04"),
} }
CLOUD_IMG_BASE = "https://cloud-images.ubuntu.com" CLOUD_IMG_BASE = "https://cloud-images.ubuntu.com"
@ -69,7 +72,9 @@ class Runner:
self.use_sudo = use_sudo self.use_sudo = use_sudo
self.dry_run = dry_run self.dry_run = dry_run
def run(self, cmd: list[str], *, privileged: bool = False) -> None: def run(
self, cmd: list[str], *, privileged: bool = False, check: bool = True
) -> None:
if privileged and self.use_sudo: if privileged and self.use_sudo:
cmd = ["sudo", *cmd] cmd = ["sudo", *cmd]
printable = " ".join(cmd) printable = " ".join(cmd)
@ -77,7 +82,7 @@ class Runner:
print(f" [dry-run] {printable}") print(f" [dry-run] {printable}")
return return
print(f" $ {printable}") print(f" $ {printable}")
subprocess.run(cmd, check=True) subprocess.run(cmd, check=check)
def need_tool(name: str) -> None: def need_tool(name: str) -> None:
@ -92,7 +97,9 @@ def download_image(url: str, dest: Path, dry_run: bool) -> None:
"""Télécharge l'image seulement si elle n'existe pas déjà (cache).""" """Télécharge l'image seulement si elle n'existe pas déjà (cache)."""
if dest.exists() and dest.stat().st_size > 0: if dest.exists() and dest.stat().st_size > 0:
size_mb = dest.stat().st_size / 1024 / 1024 size_mb = dest.stat().st_size / 1024 / 1024
print(f" Image déjà présente ({size_mb:.0f} Mo), téléchargement ignoré : {dest}") print(
f" Image déjà présente ({size_mb:.0f} Mo), téléchargement ignoré : {dest}"
)
return return
if dry_run: if dry_run:
print(f" [dry-run] téléchargement {url} -> {dest}") print(f" [dry-run] téléchargement {url} -> {dest}")
@ -108,7 +115,9 @@ def download_image(url: str, dest: Path, dry_run: bool) -> None:
print(f"\r {pct:3d}%", end="", flush=True) print(f"\r {pct:3d}%", end="", flush=True)
try: try:
urllib.request.urlretrieve(url, tmp, _progress) # noqa: S310 (URL contrôlée) urllib.request.urlretrieve(
url, tmp, _progress
) # noqa: S310 (URL contrôlée)
except Exception as exc: # pragma: no cover - dépend du réseau except Exception as exc: # pragma: no cover - dépend du réseau
tmp.unlink(missing_ok=True) tmp.unlink(missing_ok=True)
sys.exit(f"\nÉchec du téléchargement : {exc}") sys.exit(f"\nÉchec du téléchargement : {exc}")
@ -131,8 +140,11 @@ def verify_sha256(url: str, image: Path, dry_run: bool) -> None:
sys.exit(f"Impossible de récupérer SHA256SUMS : {exc}") sys.exit(f"Impossible de récupérer SHA256SUMS : {exc}")
expected = next( expected = next(
(line.split()[0] for line in sums.splitlines() (
if line.strip().endswith(filename)), line.split()[0]
for line in sums.splitlines()
if line.strip().endswith(filename)
),
None, None,
) )
if expected is None: if expected is None:
@ -143,7 +155,9 @@ def verify_sha256(url: str, image: Path, dry_run: bool) -> None:
for chunk in iter(lambda: fh.read(1 << 20), b""): for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk) h.update(chunk)
if h.hexdigest() != expected: if h.hexdigest() != expected:
sys.exit(f"SHA256 NON conforme !\n attendu : {expected}\n obtenu : {h.hexdigest()}") sys.exit(
f"SHA256 NON conforme !\n attendu : {expected}\n obtenu : {h.hexdigest()}"
)
print(" SHA256 conforme.") print(" SHA256 conforme.")
@ -151,18 +165,23 @@ def hash_password(plain: str) -> str:
"""Hash SHA-512 crypt ($6$...) via crypt (< 3.13) ou openssl en repli.""" """Hash SHA-512 crypt ($6$...) via crypt (< 3.13) ou openssl en repli."""
try: try:
import crypt # supprimé en Python 3.13 import crypt # supprimé en Python 3.13
return crypt.crypt(plain, crypt.mksalt(crypt.METHOD_SHA512)) return crypt.crypt(plain, crypt.mksalt(crypt.METHOD_SHA512))
except (ImportError, AttributeError): except (ImportError, AttributeError):
need_tool("openssl") need_tool("openssl")
out = subprocess.run( out = subprocess.run(
["openssl", "passwd", "-6", "-stdin"], ["openssl", "passwd", "-6", "-stdin"],
input=plain + "\n", capture_output=True, text=True, check=True, input=plain + "\n",
capture_output=True,
text=True,
check=True,
) )
return out.stdout.strip() return out.stdout.strip()
def build_cloud_config(args: argparse.Namespace, pw_hash: str | None, def build_cloud_config(
ssh_keys: list[str]) -> str: args: argparse.Namespace, pw_hash: str | None, ssh_keys: list[str]
) -> str:
"""Construit le contenu #cloud-config (user-data).""" """Construit le contenu #cloud-config (user-data)."""
lines: list[str] = ["#cloud-config", f"hostname: {args.hostname}"] lines: list[str] = ["#cloud-config", f"hostname: {args.hostname}"]
@ -193,12 +212,15 @@ def build_cloud_config(args: argparse.Namespace, pw_hash: str | None,
packages = ["qemu-guest-agent", *args.package] packages = ["qemu-guest-agent", *args.package]
lines.append("packages:") lines.append("packages:")
lines += [f" - {p}" for p in dict.fromkeys(packages)] # dédoublonne, ordre gardé lines += [
f" - {p}" for p in dict.fromkeys(packages)
] # dédoublonne, ordre gardé
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
def build_seed(cloud_cfg: str, hostname: str, seed_dest: Path, def build_seed(
runner: Runner) -> None: cloud_cfg: str, hostname: str, seed_dest: Path, runner: Runner
) -> None:
"""Génère le seed.iso (cidata) et le copie vers seed_dest.""" """Génère le seed.iso (cidata) et le copie vers seed_dest."""
meta_data = f"instance-id: {hostname}\nlocal-hostname: {hostname}\n" meta_data = f"instance-id: {hostname}\nlocal-hostname: {hostname}\n"
@ -219,47 +241,133 @@ def build_seed(cloud_cfg: str, hostname: str, seed_dest: Path,
runner.run(["cloud-localds", str(local_iso), str(ud), str(md)]) runner.run(["cloud-localds", str(local_iso), str(ud), str(md)])
else: else:
need_tool("genisoimage") need_tool("genisoimage")
runner.run([ runner.run(
"genisoimage", "-output", str(local_iso), [
"-volid", "cidata", "-joliet", "-rock", str(ud), str(md), "genisoimage",
]) "-output",
str(local_iso),
"-volid",
"cidata",
"-joliet",
"-rock",
str(ud),
str(md),
]
)
seed_dest.parent.mkdir(parents=True, exist_ok=True) if not runner.dry_run else None (
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) runner.run(["cp", str(local_iso), str(seed_dest)], privileged=True)
def prepare_disk(image: Path, disk: Path, size: str, runner: Runner, def prepare_disk(
force: bool) -> None: image: Path, disk: Path, size: str, runner: Runner, force: bool
) -> None:
"""Convertit l'image cloud en qcow2 de travail puis redimensionne.""" """Convertit l'image cloud en qcow2 de travail puis redimensionne."""
if disk.exists() and not force: if disk.exists() and not force:
sys.exit(f"Le disque {disk} existe déjà. Utilisez --force pour l'écraser.") sys.exit(
runner.run([ f"Le disque {disk} existe déjà. Utilisez --force pour l'écraser."
"qemu-img", "convert", "-f", "qcow2", "-O", "qcow2", )
str(image), str(disk), runner.run(
], privileged=True) [
"qemu-img",
"convert",
"-f",
"qcow2",
"-O",
"qcow2",
str(image),
str(disk),
],
privileged=True,
)
runner.run(["qemu-img", "resize", str(disk), size], privileged=True) runner.run(["qemu-img", "resize", str(disk), size], privileged=True)
def virt_install(args: argparse.Namespace, disk: Path, seed: Path, def network_name(network_arg: str) -> str | None:
osinfo: str, runner: Runner) -> 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 ensure_network(name: str | None, runner: Runner) -> None:
"""Active le réseau libvirt (idempotent : tolère « déjà actif »)."""
if not name:
return
print(f" Activation du réseau libvirt '{name}' (si nécessaire)")
runner.run(["virsh", "net-start", name], privileged=True, check=False)
runner.run(["virsh", "net-autostart", name], privileged=True, check=False)
def virt_install(
args: argparse.Namespace,
disk: Path,
seed: Path,
osinfo: str,
runner: Runner,
) -> None:
cmd = [ cmd = [
"virt-install", "virt-install",
"--name", args.name, "--name",
"--memory", str(args.memory), args.name,
"--vcpus", str(args.vcpus), "--memory",
str(args.memory),
"--vcpus",
str(args.vcpus),
"--import", "--import",
"--disk", f"path={disk},format=qcow2,bus=virtio", "--disk",
"--disk", f"path={seed},device=cdrom", f"path={disk},format=qcow2,bus=virtio",
"--osinfo", osinfo, "--disk",
"--network", args.network, f"path={seed},device=cdrom",
"--graphics", args.graphics, "--osinfo",
"--console", "pty,target_type=serial", osinfo,
"--network",
args.network,
"--graphics",
args.graphics,
"--console",
"pty,target_type=serial",
] ]
if not args.attach_console: if not args.attach_console:
cmd.append("--noautoconsole") cmd.append("--noautoconsole")
runner.run(cmd, privileged=True) runner.run(cmd, privileged=True)
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."""
base = (["sudo"] if use_sudo else []) + [
"virsh",
"domifaddr",
name,
"--source",
"lease",
]
deadline = time.time() + timeout
while time.time() < deadline:
res = subprocess.run(base, capture_output=True, text=True)
m = re.search(r"(\d+\.\d+\.\d+\.\d+)", res.stdout)
if m:
return m.group(1)
time.sleep(3)
return 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 # CLI
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@ -272,65 +380,164 @@ def build_parser() -> argparse.ArgumentParser:
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
description=__doc__, description=__doc__,
epilog="Versions Ubuntu disponibles (--version) et URL de l'image cloud :\n" epilog="Versions Ubuntu disponibles (--version) et URL de l'image cloud :\n"
+ versions_help, + versions_help,
)
p.add_argument(
"image_path",
type=Path,
help="Chemin de cache de l'image cloud (.img). "
"Si le fichier existe, il n'est PAS re-téléchargé.",
) )
p.add_argument("image_path", type=Path,
help="Chemin de cache de l'image cloud (.img). "
"Si le fichier existe, il n'est PAS re-téléchargé.")
g_img = p.add_argument_group("Image") g_img = p.add_argument_group("Image")
g_img.add_argument("--version", default="24.04", choices=UBUNTU_VERSIONS, g_img.add_argument(
help="Version Ubuntu (défaut : 24.04). Voir la liste ci-dessous.") "--version",
g_img.add_argument("--codename", help="Force le nom de code (surcharge --version).") default="24.04",
g_img.add_argument("--arch", default="amd64", choices=UBUNTU_VERSIONS,
help="Architecture de l'image (défaut : amd64).") help="Version Ubuntu (défaut : 24.04). Voir la liste ci-dessous.",
g_img.add_argument("--verify", action="store_true", )
help="Vérifie l'empreinte SHA256 après téléchargement (recommandé).") g_img.add_argument(
"--codename", help="Force le nom de code (surcharge --version)."
)
g_img.add_argument(
"--arch",
default="amd64",
help="Architecture de l'image (défaut : amd64).",
)
g_img.add_argument(
"--verify",
action="store_true",
help="Vérifie l'empreinte SHA256 après téléchargement (recommandé).",
)
g_vm = p.add_argument_group("VM") g_vm = p.add_argument_group("VM")
g_vm.add_argument("--name", required=True, help="Nom de la VM (virsh).") g_vm.add_argument("--name", required=True, help="Nom de la VM (virsh).")
g_vm.add_argument("--hostname", help="Nom d'hôte interne (défaut : --name).") g_vm.add_argument(
g_vm.add_argument("--memory", type=int, default=8192, help="RAM en Mo (défaut : 8192).") "--hostname", help="Nom d'hôte interne (défaut : --name)."
g_vm.add_argument("--vcpus", type=int, default=4, help="Nombre de vCPU (défaut : 4).") )
g_vm.add_argument("--disk-size", default="20G", g_vm.add_argument(
help="Taille du disque virtuel, ex. 120G (défaut : 20G).") "--memory", type=int, default=8192, help="RAM en Mo (défaut : 8192)."
g_vm.add_argument("--disk-dir", type=Path, default=Path("/var/lib/libvirt/images"), )
help="Répertoire du qcow2 de travail (défaut : /var/lib/libvirt/images).") g_vm.add_argument(
g_vm.add_argument("--seed-dir", type=Path, "--vcpus", type=int, default=4, help="Nombre de vCPU (défaut : 4)."
default=Path("/var/lib/libvirt/images/iso"), )
help="Répertoire du seed.iso (défaut : .../images/iso).") g_vm.add_argument(
g_vm.add_argument("--network", default="network=default,model=virtio", "--disk-size",
help="Argument --network de virt-install.") default="20G",
g_vm.add_argument("--graphics", default="none", help="Argument --graphics (défaut : none).") help="Taille du disque virtuel, ex. 120G (défaut : 20G).",
g_vm.add_argument("--osinfo", help="Force la valeur --osinfo (sinon déduite).") )
g_vm.add_argument("--attach-console", action="store_true", g_vm.add_argument(
help="Attache la console série (sinon --noautoconsole).") "--disk-dir",
type=Path,
default=Path("/var/lib/libvirt/images"),
help="Répertoire du qcow2 de travail (défaut : /var/lib/libvirt/images).",
)
g_vm.add_argument(
"--seed-dir",
type=Path,
default=Path("/var/lib/libvirt/images/iso"),
help="Répertoire du seed.iso (défaut : .../images/iso).",
)
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(
"--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_cloud = p.add_argument_group("cloud-init") 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(
g_cloud.add_argument("--password", help="Mot de passe en clair (déconseillé : visible).") "--user",
g_cloud.add_argument("--ask-password", action="store_true", default="erplibre",
help="Demande le mot de passe de façon interactive (sûr).") help="Utilisateur créé (défaut : erplibre).",
g_cloud.add_argument("--password-hash", )
help="Empreinte $6$... déjà calculée (openssl passwd -6).") g_cloud.add_argument(
g_cloud.add_argument("--ssh-key", action="append", default=[], metavar="FICHIER", "--password", help="Mot de passe en clair (déconseillé : visible)."
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(
g_cloud.add_argument("--keyboard-layout", default="ca", help="Disposition clavier (défaut : ca).") "--ask-password",
g_cloud.add_argument("--keyboard-variant", default="multix", action="store_true",
help="Variante clavier (défaut : multix).") help="Demande le mot de passe de façon interactive (sûr).",
g_cloud.add_argument("--package", action="append", default=[], metavar="PKG", )
help="Paquet APT additionnel (répétable).") g_cloud.add_argument(
g_cloud.add_argument("--no-upgrade", action="store_true", "--password-hash",
help="N'exécute pas package_upgrade au premier boot.") 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(
"--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_run = p.add_argument_group("Exécution") g_run = p.add_argument_group("Exécution")
g_run.add_argument("--no-sudo", action="store_true", g_run.add_argument(
help="N'utilise pas sudo pour les étapes privilégiées.") "--no-sudo",
g_run.add_argument("--force", action="store_true", action="store_true",
help="Écrase le qcow2 de travail existant.") help="N'utilise pas sudo pour les étapes privilégiées.",
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(
"--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.",
)
return p return p
@ -370,12 +577,15 @@ def main() -> None:
pw_hash = resolve_password(args) pw_hash = resolve_password(args)
ssh_keys = load_ssh_keys(args.ssh_key) ssh_keys = load_ssh_keys(args.ssh_key)
if not pw_hash and not ssh_keys: if not pw_hash and not ssh_keys:
print("ATTENTION : ni mot de passe ni clé SSH -> connexion impossible à la VM.\n" print(
" Ajoutez --ssh-key, --ask-password ou --password-hash.\n", "ATTENTION : ni mot de passe ni clé SSH -> connexion impossible à la VM.\n"
file=sys.stderr) " Ajoutez --ssh-key, --ask-password ou --password-hash.\n",
file=sys.stderr,
)
runner = Runner(use_sudo=not args.no_sudo and os.geteuid() != 0, runner = Runner(
dry_run=args.dry_run) use_sudo=not args.no_sudo and os.geteuid() != 0, dry_run=args.dry_run
)
disk = args.disk_dir / f"{args.name}.qcow2" disk = args.disk_dir / f"{args.name}.qcow2"
seed = args.seed_dir / f"{args.name}-seed.iso" seed = args.seed_dir / f"{args.name}-seed.iso"
@ -396,11 +606,33 @@ def main() -> None:
build_seed(cloud_cfg, args.hostname, seed, runner) build_seed(cloud_cfg, args.hostname, seed, runner)
print(f"\n== 5/5 virt-install (--osinfo {osinfo}) ==") print(f"\n== 5/5 virt-install (--osinfo {osinfo}) ==")
ensure_network(network_name(args.network), runner)
virt_install(args, disk, seed, osinfo, runner) virt_install(args, disk, seed, osinfo, runner)
print(f"\nTerminé. Suivi/connexion :\n" has_key = bool(ssh_keys)
f" virsh list --all\n" print("\nTerminé. Suivi :")
f" virsh console {args.name} # Ctrl+] pour quitter") 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__": if __name__ == "__main__":