Compare commits

...

3 commits

Author SHA1 Message Date
2020e15b5e [IMP] script todo: group the Deploy menu into sections (SSH, ...)
fill_help_info() now renders {"section": "..."} entries as a header line
without consuming a number, so numbering stays continuous over the real
commands (the hardcoded elif chains keep matching). The Deploy menu is
split into "Local", "SSH (remote host)" and "Virtualization &
notifications", making the long SSH block easy to scan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 07:30:06 +00:00
ef78b9d315 [IMP] script todo: add "Delete VM(s)" QEMU command
New QEMU/KVM menu entry to remove one, several or all VMs. It lists the
defined domains, lets you pick by number or "all", asks whether to also
delete the disk images (the qcow2 working disk + the seed ISO), shows
what will be removed and asks for confirmation. Each VM is powered off
(destroy) then undefined (--nvram, with a fallback for older virsh);
disks are only deleted when explicitly requested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 07:26:28 +00:00
f5923d0fbb [FIX] script qemu: reliable image download + SSH server by default
The Debian deploy appeared frozen: cloud.debian.org sent this host to an
unreachable mirror and urlretrieve had no timeout, so it hung forever;
worse, output was block-buffered under the todo pipe so nothing showed.

- Stream downloads via urlopen with a 30s per-operation timeout: a dead
  mirror now fails fast instead of hanging.
- Try several Debian mirrors in order (cloud.debian.org, then two
  acc.umu.se mirrors) — first responsive one wins.
- Reconfigure stdout to line-buffered in main() so headers and progress
  appear live even when captured by the menu.
- Add openssh-server to the cloud-init packages and a runcmd enabling
  ssh/sshd, so every VM (Debian genericcloud included) is SSH-reachable.
- Add a timeout to the SHA256SUMS fetch too.

Validated: debian-11 fails over from cloud.debian.org to gemmei and
completes; debian-12 downloads fully.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 07:24:36 +00:00
3 changed files with 256 additions and 58 deletions

View file

@ -101,7 +101,15 @@ ARCH_ALIASES: dict[str, dict[str, str]] = {
} }
CLOUD_IMG_BASE = "https://cloud-images.ubuntu.com" CLOUD_IMG_BASE = "https://cloud-images.ubuntu.com"
DEBIAN_CLOUD_BASE = "https://cloud.debian.org/images/cloud" # 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",
)
FEDORA_BASE = "https://download.fedoraproject.org/pub/fedora/linux/releases" FEDORA_BASE = "https://download.fedoraproject.org/pub/fedora/linux/releases"
# Répertoire de cache par défaut des images cloud (cohérent avec --disk-dir / # Répertoire de cache par défaut des images cloud (cohérent avec --disk-dir /
@ -123,20 +131,29 @@ def distro_arch(distro: str, arch: str) -> str:
def image_url(distro: str, code: str, arch: str, version: str) -> str: def image_url(distro: str, code: str, arch: str, version: str) -> str:
"""URL directe de l'image cloud (Ubuntu/Debian). Fedora est résolu à """URL directe (primaire) de l'image cloud (Ubuntu/Debian)."""
part via resolve_fedora_url (pas de lien « latest » stable).""" 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) a = distro_arch(distro, arch)
if distro == "ubuntu": if distro == "ubuntu":
return ( return [
f"{CLOUD_IMG_BASE}/{code}/current/" f"{CLOUD_IMG_BASE}/{code}/current/"
f"{code}-server-cloudimg-{a}.img" f"{code}-server-cloudimg-{a}.img"
) ]
if distro == "debian": if distro == "debian":
return ( return [
f"{DEBIAN_CLOUD_BASE}/{code}/latest/" f"{base}/{code}/latest/debian-{version}-genericcloud-{a}.qcow2"
f"debian-{version}-genericcloud-{a}.qcow2" for base in DEBIAN_CLOUD_BASES
) ]
raise ValueError(f"URL directe indisponible pour la distro {distro!r}") if distro == "fedora":
return [resolve_fedora_url(version, arch, dry_run)]
raise ValueError(f"URL indisponible pour la distro {distro!r}")
def resolve_fedora_url(version: str, arch: str, dry_run: bool) -> str: def resolve_fedora_url(version: str, arch: str, dry_run: bool) -> str:
@ -163,15 +180,6 @@ def resolve_fedora_url(version: str, arch: str, dry_run: bool) -> str:
return index + names[-1] return index + names[-1]
def resolve_image_url(
distro: str, code: str, arch: str, version: str, dry_run: bool
) -> str:
"""URL de l'image cloud, tous distros confondus."""
if distro == "fedora":
return resolve_fedora_url(version, arch, dry_run)
return image_url(distro, code, arch, version)
def default_image_name(distro: str, code: str, arch: str, version: str) -> str: def default_image_name(distro: str, code: str, arch: str, version: str) -> str:
"""Nom de fichier local pour le cache d'image.""" """Nom de fichier local pour le cache d'image."""
a = distro_arch(distro, arch) a = distro_arch(distro, arch)
@ -552,16 +560,62 @@ def ensure_tools(runner: Runner, assume_yes: bool, no_install: bool) -> None:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Étapes # Étapes
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def download_image(url: str, dest: Path, dry_run: bool) -> None: # Délai (s) par opération réseau : au-delà, on abandonne le miroir courant.
"""Télécharge l'image seulement si elle n'existe pas déjà (cache).""" # 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()
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: 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( print(
f" Image déjà présente ({size_mb:.0f} Mo), téléchargement ignoré : {dest}" f" Image déjà présente ({size_mb:.0f} Mo), téléchargement"
f" ignoré : {dest}",
flush=True,
) )
return return
if dry_run: if dry_run:
print(f" [dry-run] téléchargement {url} -> {dest}") print(f" [dry-run] téléchargement {urls[0]} -> {dest}", flush=True)
return return
try: try:
@ -572,36 +626,25 @@ def download_image(url: str, dest: Path, dry_run: bool) -> None:
" Relancez avec sudo, ou choisissez --image-dir vers un dossier" " Relancez avec sudo, ou choisissez --image-dir vers un dossier"
" accessible en écriture." " accessible en écriture."
) )
print(f" Téléchargement {url}")
tmp = dest.with_suffix(dest.suffix + ".part") tmp = dest.with_suffix(dest.suffix + ".part")
is_tty = sys.stdout.isatty() errors = []
last_pct = [-1] for i, url in enumerate(urls, 1):
tag = "" if len(urls) == 1 else f" (miroir {i}/{len(urls)})"
def _progress(block_num: int, block_size: int, total: int) -> None: print(f" Téléchargement{tag} : {url}", flush=True)
# N'émet qu'au changement de pourcentage entier : sur un TTY, réécrit try:
# la même ligne (\r) ; sinon (sortie capturée par le menu todo) au plus _download_one(url, tmp, timeout)
# 101 lignes au lieu de centaines de répétitions du même pourcentage. tmp.replace(dest)
if total <= 0:
return return
pct = min(100, block_num * block_size * 100 // total) except Exception as exc: # réseau/timeout : on tente le miroir suivant
if pct == last_pct[0]: tmp.unlink(missing_ok=True)
return print(f"\n Échec : {exc}", flush=True)
last_pct[0] = pct errors.append(f"{url} -> {exc}")
if is_tty: sys.exit(
print(f"\r {pct:3d}%", end="", flush=True) "\nÉchec du téléchargement depuis tous les miroirs :\n "
else: + "\n ".join(errors)
print(f" {pct:3d}%", flush=True) + "\n Vérifiez la connectivité (IPv6 ?), réessayez plus tard, ou "
"fournissez un chemin d'image local en argument positionnel."
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}")
if is_tty:
print()
tmp.replace(dest)
def verify_sha256(url: str, image: Path, dry_run: bool) -> None: def verify_sha256(url: str, image: Path, dry_run: bool) -> None:
@ -613,7 +656,9 @@ def verify_sha256(url: str, image: Path, dry_run: bool) -> None:
filename = url.rsplit("/", 1)[1] filename = url.rsplit("/", 1)[1]
print(f" Vérification SHA256 via {sums_url}") print(f" Vérification SHA256 via {sums_url}")
try: try:
with urllib.request.urlopen(sums_url) as resp: # noqa: S310 with urllib.request.urlopen( # noqa: S310
sums_url, timeout=DOWNLOAD_TIMEOUT
) as resp:
sums = resp.read().decode() sums = resp.read().decode()
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
sys.exit(f"Impossible de récupérer SHA256SUMS : {exc}") sys.exit(f"Impossible de récupérer SHA256SUMS : {exc}")
@ -693,11 +738,20 @@ def build_cloud_config(
lines.append("package_update: true") lines.append("package_update: true")
lines.append(f"package_upgrade: {'false' if args.no_upgrade else 'true'}") lines.append(f"package_upgrade: {'false' if args.no_upgrade else 'true'}")
packages = ["qemu-guest-agent", *args.package] # openssh-server : garantit un serveur SSH sur toutes les distros (les
# images Debian genericcloud notamment ne l'ont pas toujours activé).
packages = ["qemu-guest-agent", "openssh-server", *args.package]
lines.append("packages:") lines.append("packages:")
lines += [ lines += [
f" - {p}" for p in dict.fromkeys(packages) f" - {p}" for p in dict.fromkeys(packages)
] # dédoublonne, ordre gardé ] # dédoublonne, ordre gardé
# Active et démarre SSH quel que soit le nom du service (ssh sur
# Debian/Ubuntu, sshd sur Fedora) — sans quoi la VM peut booter sans SSH.
lines += [
"runcmd:",
" - systemctl enable --now ssh 2>/dev/null"
" || systemctl enable --now sshd 2>/dev/null || true",
]
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
@ -1127,6 +1181,13 @@ def load_ssh_keys(paths: list[str]) -> list[str]:
def main() -> None: 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() args = build_parser().parse_args()
if args.list_images: if args.list_images:
@ -1150,9 +1211,10 @@ def main() -> None:
args.memory = min_ram args.memory = min_ram
if args.disk_size is None: if args.disk_size is None:
args.disk_size = min_disk args.disk_size = min_disk
url = resolve_image_url( urls = image_candidates(
args.distro, code, args.arch, args.version, args.dry_run args.distro, code, args.arch, args.version, args.dry_run
) )
url = urls[0]
# --verify s'appuie sur un SHA256SUMS style Ubuntu ; Debian/Fedora # --verify s'appuie sur un SHA256SUMS style Ubuntu ; Debian/Fedora
# publient des sommes dans un autre format -> on saute proprement. # publient des sommes dans un autre format -> on saute proprement.
do_verify = args.verify and args.distro == "ubuntu" do_verify = args.verify and args.distro == "ubuntu"
@ -1179,7 +1241,7 @@ def main() -> None:
f"({args.distro} {args.version} / {code}) ==" f"({args.distro} {args.version} / {code}) =="
) )
print(f" Destination : {args.image_path}") print(f" Destination : {args.image_path}")
download_image(url, args.image_path, args.dry_run) download_image(urls, args.image_path, args.dry_run)
if do_verify: if do_verify:
verify_sha256(url, args.image_path, args.dry_run) verify_sha256(url, args.image_path, args.dry_run)
print("\nTerminé (téléchargement seul).") print("\nTerminé (téléchargement seul).")
@ -1209,7 +1271,7 @@ def main() -> None:
ensure_tools(runner, args.assume_yes, args.no_install_deps) ensure_tools(runner, args.assume_yes, args.no_install_deps)
print(f"\n== 1/5 Image cloud ({args.distro} {args.version} / {code}) ==") print(f"\n== 1/5 Image cloud ({args.distro} {args.version} / {code}) ==")
download_image(url, args.image_path, args.dry_run) download_image(urls, args.image_path, args.dry_run)
if do_verify: if do_verify:
verify_sha256(url, args.image_path, args.dry_run) verify_sha256(url, args.image_path, args.dry_run)

View file

@ -555,15 +555,24 @@ class TODO:
return header + t("Command:") return header + t("Command:")
def fill_help_info(self, choices): def fill_help_info(self, choices):
# Une entrée {"section": "..."} affiche un titre de section SANS
# consommer de numéro : la numérotation reste continue sur les vraies
# commandes (compatible avec les elif codés en dur des menus).
help_info = self._menu_header() + "\n" help_info = self._menu_header() + "\n"
help_end = f"[0] {t('Back')}\n" help_end = f"[0] {t('Back')}\n"
for i, instance in enumerate(choices): n = 0
for instance in choices:
section = instance.get("section")
if section:
help_info += f"\n── {section} ──\n"
continue
n += 1
desc_key = instance.get("prompt_description_key") desc_key = instance.get("prompt_description_key")
if desc_key: if desc_key:
desc = t(desc_key) desc = t(desc_key)
else: else:
desc = instance["prompt_description"] desc = instance["prompt_description"]
help_info += f"[{i + 1}] " + desc + "\n" help_info += f"[{n}] " + desc + "\n"
help_info += help_end help_info += help_end
return help_info return help_info
@ -692,8 +701,10 @@ class TODO:
def prompt_execute_deploy(self): def prompt_execute_deploy(self):
print(f"🤖 {t('Deploy ERPLibre to a local directory!')}") print(f"🤖 {t('Deploy ERPLibre to a local directory!')}")
choices = [ choices = [
{"section": t("Local")},
{"prompt_description": t("Clone ERPLibre locally (git clone)")}, {"prompt_description": t("Clone ERPLibre locally (git clone)")},
{"prompt_description": t("Configure sshfs")}, {"prompt_description": t("Configure sshfs")},
{"section": t("SSH (remote host)")},
{"prompt_description": t("SSH - Check connection")}, {"prompt_description": t("SSH - Check connection")},
{"prompt_description": t("SSH - Sync files (rsync)")}, {"prompt_description": t("SSH - Sync files (rsync)")},
{"prompt_description": t("SSH - Install ERPLibre")}, {"prompt_description": t("SSH - Install ERPLibre")},
@ -705,6 +716,7 @@ class TODO:
{"prompt_description": t("SSH - Run make target")}, {"prompt_description": t("SSH - Run make target")},
{"prompt_description": t("SSH - Install systemd service")}, {"prompt_description": t("SSH - Install systemd service")},
{"prompt_description": t("SSH - Configure nginx + SSL")}, {"prompt_description": t("SSH - Configure nginx + SSL")},
{"section": t("Virtualization & notifications")},
{ {
"prompt_description": t( "prompt_description": t(
"Deploy - Install NTFY notification server" "Deploy - Install NTFY notification server"
@ -857,6 +869,7 @@ class TODO:
"Deploy ERPLibre infra (one minimal VM per image)" "Deploy ERPLibre infra (one minimal VM per image)"
) )
}, },
{"prompt_description": t("Delete VM(s)")},
] ]
config_entries = self.config_file.get_config("qemu_from_makefile") config_entries = self.config_file.get_config("qemu_from_makefile")
if config_entries: if config_entries:
@ -882,6 +895,8 @@ class TODO:
self._qemu_list_images() self._qemu_list_images()
elif status == "7": elif status == "7":
self._qemu_deploy_infra() self._qemu_deploy_infra()
elif status == "8":
self._qemu_delete_vm()
else: else:
cmd_no_found = True cmd_no_found = True
try: try:
@ -994,6 +1009,75 @@ class TODO:
print(f"{t('Will execute:')} {cmd}") print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False) self.execute.exec_command_live(cmd, source_erplibre=False)
def _qemu_list_domains(self):
"""Noms des VM libvirt définies (via virsh)."""
try:
res = subprocess.run(
["sudo", "virsh", "list", "--all", "--name"],
capture_output=True,
text=True,
timeout=15,
)
except (OSError, subprocess.SubprocessError):
return []
return [n for n in res.stdout.split() if n.strip()]
def _qemu_delete_vm(self):
"""Efface une ou plusieurs VM (arrêt + undefine), disques en option."""
self._qemu_list_vms()
print()
names = self._qemu_list_domains()
if not names:
print(t("No VM found."))
return
print(f"\n{t('Select VMs to delete:')}")
for i, n in enumerate(names, 1):
print(f" [{i}] {n}")
print(f" [all] {t('select all')}")
raw = input(t("Selection (numbers, or 'all'): ")).strip()
if not raw:
print(t("Nothing selected."))
return
if raw.lower() in ("all", "*"):
chosen = list(names)
else:
chosen = self._parse_index_selection(raw.lower(), names)
if not chosen:
print(t("Nothing selected."))
return
del_disks = self._is_yes(
input(t("Also delete disk images (qcow2 + seed ISO)? (y/N): "))
)
print(f"\n{t('Will delete:')} {', '.join(chosen)}")
if del_disks:
print(f" + {t('disk images and seed ISOs')}")
else:
print(f" ({t('disks kept')})")
if not self._is_yes(input(t("Confirm deletion? (y/N): "))):
print(t("Cancelled."))
return
disk_dir = "/var/lib/libvirt/images"
seed_dir = "/var/lib/libvirt/images/iso"
for name in chosen:
q = shlex.quote(name)
# Éteindre si en cours, puis retirer la définition (+ nvram si
# UEFI ; repli sans l'option pour les vieilles versions de virsh).
cmd = (
f"sudo virsh destroy {q} 2>/dev/null; "
f"sudo virsh undefine {q} --nvram 2>/dev/null "
f"|| sudo virsh undefine {q}"
)
if del_disks:
disk = shlex.quote(f"{disk_dir}/{name}.qcow2")
seed = shlex.quote(f"{seed_dir}/{name}-seed.iso")
cmd += f"; sudo rm -f {disk} {seed}"
print(f"\n{name}: {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
print(f"\n{t('Deletion done.')}")
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# QEMU : déploiement d'un parc « infra ERPLibre » # QEMU : déploiement d'un parc « infra ERPLibre »
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View file

@ -141,6 +141,18 @@ TRANSLATIONS = {
"fr": "Configurer sshfs", "fr": "Configurer sshfs",
"en": "Configure sshfs", "en": "Configure sshfs",
}, },
"Local": {
"fr": "Local",
"en": "Local",
},
"SSH (remote host)": {
"fr": "SSH (hôte distant)",
"en": "SSH (remote host)",
},
"Virtualization & notifications": {
"fr": "Virtualisation & notifications",
"en": "Virtualization & notifications",
},
"SSH address input method": { "SSH address input method": {
"fr": "Méthode de saisie de l'adresse SSH", "fr": "Méthode de saisie de l'adresse SSH",
"en": "SSH address input method", "en": "SSH address input method",
@ -1049,6 +1061,46 @@ TRANSLATIONS = {
"fr": "tout sélectionner", "fr": "tout sélectionner",
"en": "select all", "en": "select all",
}, },
"Delete VM(s)": {
"fr": "Effacer une ou plusieurs VM",
"en": "Delete VM(s)",
},
"No VM found.": {
"fr": "Aucune VM trouvée.",
"en": "No VM found.",
},
"Select VMs to delete:": {
"fr": "Sélectionner les VM à effacer :",
"en": "Select VMs to delete:",
},
"Selection (numbers, or 'all'): ": {
"fr": "Sélection (numéros, ou « all ») : ",
"en": "Selection (numbers, or 'all'): ",
},
"Also delete disk images (qcow2 + seed ISO)? (y/N): ": {
"fr": "Effacer aussi les disques (qcow2 + seed ISO) ? (o/N) : ",
"en": "Also delete disk images (qcow2 + seed ISO)? (y/N): ",
},
"Will delete:": {
"fr": "Sera effacé :",
"en": "Will delete:",
},
"disk images and seed ISOs": {
"fr": "les disques et les seed ISO",
"en": "disk images and seed ISOs",
},
"disks kept": {
"fr": "disques conservés",
"en": "disks kept",
},
"Confirm deletion? (y/N): ": {
"fr": "Confirmer l'effacement ? (o/N) : ",
"en": "Confirm deletion? (y/N): ",
},
"Deletion done.": {
"fr": "Effacement terminé.",
"en": "Deletion done.",
},
"Deploy ERPLibre infra (one minimal VM per image)": { "Deploy ERPLibre infra (one minimal VM per image)": {
"fr": "Déployer l'infra ERPLibre (une VM minimale par image)", "fr": "Déployer l'infra ERPLibre (une VM minimale par image)",
"en": "Deploy ERPLibre infra (one minimal VM per image)", "en": "Deploy ERPLibre infra (one minimal VM per image)",