[IMP] script qemu to create and deploy new VM

This commit is contained in:
Mathieu Benoit 2026-07-04 03:37:35 -04:00
parent 2d549025ec
commit 6f087d2a9b

407
script/qemu/deploy_qemu.py Executable file
View file

@ -0,0 +1,407 @@
#!/usr/bin/env python3
"""Déploiement rapide de VM Ubuntu (cloud-image) avec qemu-img + cloud-init + virt-install.
Reprend le workflow des notes :
1. Télécharge l'image cloud Ubuntu (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
--------
# Déploiement minimal (clé SSH, aucun mot de passe)
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 getpass
import hashlib
import os
import shutil
import subprocess
import sys
import tempfile
import textwrap
import urllib.request
from pathlib import Path
# --------------------------------------------------------------------------- #
# Table des versions Ubuntu -> (nom de code, valeur --osinfo/libosinfo)
# Le nom de code sert à construire l'URL de l'image cloud. Les valeurs LTS sont
# stables ; --codename et --osinfo permettent de surcharger si la table vieillit.
# --------------------------------------------------------------------------- #
UBUNTU_VERSIONS: dict[str, tuple[str, str]] = {
"20.04": ("focal", "ubuntu20.04"),
"22.04": ("jammy", "ubuntu22.04"),
"24.04": ("noble", "ubuntu24.04"),
"24.10": ("oracular", "ubuntu24.10"),
"25.04": ("plucky", "ubuntu25.04"),
"25.10": ("questing", "ubuntu25.10"),
}
CLOUD_IMG_BASE = "https://cloud-images.ubuntu.com"
def image_url(codename: str, arch: str) -> str:
"""URL de l'image cloud « current » pour un nom de code + architecture."""
return f"{CLOUD_IMG_BASE}/{codename}/current/{codename}-server-cloudimg-{arch}.img"
# --------------------------------------------------------------------------- #
# Utilitaires d'exécution
# --------------------------------------------------------------------------- #
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
def run(self, cmd: list[str], *, privileged: bool = False) -> None:
if privileged and self.use_sudo:
cmd = ["sudo", *cmd]
printable = " ".join(cmd)
if self.dry_run:
print(f" [dry-run] {printable}")
return
print(f" $ {printable}")
subprocess.run(cmd, check=True)
def need_tool(name: str) -> None:
if shutil.which(name) is None:
sys.exit(f"Erreur : outil requis introuvable dans le PATH : {name!r}")
# --------------------------------------------------------------------------- #
# Étapes
# --------------------------------------------------------------------------- #
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)."""
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 ignoré : {dest}")
return
if dry_run:
print(f" [dry-run] téléchargement {url} -> {dest}")
return
dest.parent.mkdir(parents=True, exist_ok=True)
print(f" Téléchargement {url}")
tmp = dest.with_suffix(dest.suffix + ".part")
def _progress(block_num: int, block_size: int, total: int) -> None:
if total > 0:
pct = min(100, block_num * block_size * 100 // total)
print(f"\r {pct:3d}%", end="", flush=True)
try:
urllib.request.urlretrieve(url, tmp, _progress) # noqa: S310 (URL contrôlée)
except Exception as exc: # pragma: no cover - dépend du réseau
tmp.unlink(missing_ok=True)
sys.exit(f"\nÉchec du téléchargement : {exc}")
print()
tmp.replace(dest)
def verify_sha256(url: str, image: Path, dry_run: bool) -> None:
"""Vérifie l'empreinte via le SHA256SUMS publié dans le même répertoire."""
if dry_run:
print(" [dry-run] vérification SHA256 ignorée")
return
sums_url = url.rsplit("/", 1)[0] + "/SHA256SUMS"
filename = url.rsplit("/", 1)[1]
print(f" Vérification SHA256 via {sums_url}")
try:
with urllib.request.urlopen(sums_url) as resp: # noqa: S310
sums = resp.read().decode()
except Exception as exc: # pragma: no cover
sys.exit(f"Impossible de récupérer SHA256SUMS : {exc}")
expected = next(
(line.split()[0] for line in sums.splitlines()
if line.strip().endswith(filename)),
None,
)
if expected is None:
sys.exit(f"Empreinte introuvable pour {filename} dans SHA256SUMS")
h = hashlib.sha256()
with image.open("rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk)
if h.hexdigest() != expected:
sys.exit(f"SHA256 NON conforme !\n attendu : {expected}\n obtenu : {h.hexdigest()}")
print(" SHA256 conforme.")
def hash_password(plain: str) -> str:
"""Hash SHA-512 crypt ($6$...) via crypt (< 3.13) ou openssl en repli."""
try:
import crypt # supprimé en Python 3.13
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()
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}"]
user_block = [
"users:",
f" - name: {args.user}",
" sudo: ALL=(ALL) NOPASSWD:ALL",
" groups: users, admin",
" shell: /bin/bash",
" 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}")
lines += [
"keyboard:",
f" layout: {args.keyboard_layout}",
f" variant: {args.keyboard_variant}",
]
lines.append("package_update: true")
lines.append(f"package_upgrade: {'false' if args.no_upgrade else 'true'}")
packages = ["qemu-guest-agent", *args.package]
lines.append("packages:")
lines += [f" - {p}" for p in dict.fromkeys(packages)] # dédoublonne, ordre gardé
return "\n".join(lines) + "\n"
def build_seed(cloud_cfg: str, hostname: str, seed_dest: Path,
runner: Runner) -> None:
"""Génère le seed.iso (cidata) et le copie vers seed_dest."""
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"
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, " "))
else:
ud.write_text(cloud_cfg)
md.write_text(meta_data)
if runner.dry_run or shutil.which("cloud-localds"):
runner.run(["cloud-localds", 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),
])
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 virt_install(args: argparse.Namespace, disk: Path, seed: Path,
osinfo: str, runner: Runner) -> None:
cmd = [
"virt-install",
"--name", args.name,
"--memory", str(args.memory),
"--vcpus", str(args.vcpus),
"--import",
"--disk", f"path={disk},format=qcow2,bus=virtio",
"--disk", f"path={seed},device=cdrom",
"--osinfo", osinfo,
"--network", args.network,
"--graphics", args.graphics,
"--console", "pty,target_type=serial",
]
if not args.attach_console:
cmd.append("--noautoconsole")
runner.run(cmd, privileged=True)
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def build_parser() -> argparse.ArgumentParser:
versions_help = "\n".join(
f" {v:<7} {code:<10} {image_url(code, 'amd64')}"
for v, (code, _osinfo) in UBUNTU_VERSIONS.items()
)
p = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
description=__doc__,
epilog="Versions Ubuntu disponibles (--version) et URL de l'image cloud :\n"
+ 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é.")
g_img = p.add_argument_group("Image")
g_img.add_argument("--version", default="24.04", choices=UBUNTU_VERSIONS,
help="Version Ubuntu (défaut : 24.04). Voir la liste ci-dessous.")
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.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("--memory", type=int, default=8192, help="RAM en Mo (défaut : 8192).")
g_vm.add_argument("--vcpus", type=int, default=4, help="Nombre de vCPU (défaut : 4).")
g_vm.add_argument("--disk-size", default="20G",
help="Taille du disque virtuel, ex. 120G (défaut : 20G).")
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("--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.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("--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.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("--dry-run", action="store_true",
help="Affiche les commandes et le user-data sans rien exécuter.")
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:
args = build_parser().parse_args()
args.hostname = args.hostname or args.name
codename, default_osinfo = UBUNTU_VERSIONS[args.version]
if args.codename:
codename = args.codename
osinfo = args.osinfo or default_osinfo
url = image_url(codename, args.arch)
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)
runner = Runner(use_sudo=not args.no_sudo and os.geteuid() != 0,
dry_run=args.dry_run)
disk = args.disk_dir / f"{args.name}.qcow2"
seed = args.seed_dir / f"{args.name}-seed.iso"
if not args.dry_run:
for tool in ("qemu-img", "virt-install"):
need_tool(tool)
print(f"\n== 1/5 Image cloud ({args.version} / {codename}) ==")
download_image(url, args.image_path, args.dry_run)
if args.verify:
verify_sha256(url, args.image_path, args.dry_run)
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)
print(f"\n== 5/5 virt-install (--osinfo {osinfo}) ==")
virt_install(args, disk, seed, osinfo, runner)
print(f"\nTerminé. Suivi/connexion :\n"
f" virsh list --all\n"
f" virsh console {args.name} # Ctrl+] pour quitter")
if __name__ == "__main__":
main()