Compare commits

..

No commits in common. "2020e15b5e5bde0ee5c1727b11e65f73a498511f" and "ca796a67a4ae03b6a911d838b56ea6b14c1546da" have entirely different histories.

3 changed files with 58 additions and 256 deletions

View file

@ -101,15 +101,7 @@ 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.debian.org est un redirecteur qui, selon le réseau, peut DEBIAN_CLOUD_BASE = "https://cloud.debian.org/images/cloud"
# 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 /
@ -131,29 +123,20 @@ 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 (primaire) de l'image cloud (Ubuntu/Debian).""" """URL directe de l'image cloud (Ubuntu/Debian). Fedora est résolu à
return image_candidates(distro, code, arch, version, dry_run=True)[0] part via resolve_fedora_url (pas de lien « latest » stable)."""
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"{base}/{code}/latest/debian-{version}-genericcloud-{a}.qcow2" f"{DEBIAN_CLOUD_BASE}/{code}/latest/"
for base in DEBIAN_CLOUD_BASES f"debian-{version}-genericcloud-{a}.qcow2"
] )
if distro == "fedora": raise ValueError(f"URL directe indisponible pour la distro {distro!r}")
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:
@ -180,6 +163,15 @@ 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)
@ -560,62 +552,16 @@ def ensure_tools(runner: Runner, assume_yes: bool, no_install: bool) -> None:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Étapes # Étapes
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Délai (s) par opération réseau : au-delà, on abandonne le miroir courant. def download_image(url: str, dest: Path, dry_run: bool) -> None:
# Sans lui, un miroir injoignable ferait pendre le téléchargement à l'infini. """Télécharge l'image seulement si elle n'existe pas déjà (cache)."""
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" f" Image déjà présente ({size_mb:.0f} Mo), téléchargement ignoré : {dest}"
f" ignoré : {dest}",
flush=True,
) )
return return
if dry_run: if dry_run:
print(f" [dry-run] téléchargement {urls[0]} -> {dest}", flush=True) print(f" [dry-run] téléchargement {url} -> {dest}")
return return
try: try:
@ -626,25 +572,36 @@ def download_image(
" 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")
errors = [] is_tty = sys.stdout.isatty()
for i, url in enumerate(urls, 1): last_pct = [-1]
tag = "" if len(urls) == 1 else f" (miroir {i}/{len(urls)})"
print(f" Téléchargement{tag} : {url}", flush=True) def _progress(block_num: int, block_size: int, total: int) -> None:
try: # N'émet qu'au changement de pourcentage entier : sur un TTY, réécrit
_download_one(url, tmp, timeout) # la même ligne (\r) ; sinon (sortie capturée par le menu todo) au plus
tmp.replace(dest) # 101 lignes au lieu de centaines de répétitions du même pourcentage.
if total <= 0:
return return
except Exception as exc: # réseau/timeout : on tente le miroir suivant pct = min(100, block_num * block_size * 100 // total)
tmp.unlink(missing_ok=True) if pct == last_pct[0]:
print(f"\n Échec : {exc}", flush=True) return
errors.append(f"{url} -> {exc}") last_pct[0] = pct
sys.exit( if is_tty:
"\nÉchec du téléchargement depuis tous les miroirs :\n " print(f"\r {pct:3d}%", end="", flush=True)
+ "\n ".join(errors) else:
+ "\n Vérifiez la connectivité (IPv6 ?), réessayez plus tard, ou " print(f" {pct:3d}%", flush=True)
"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:
@ -656,9 +613,7 @@ 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( # noqa: S310 with urllib.request.urlopen(sums_url) as resp: # 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}")
@ -738,20 +693,11 @@ 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'}")
# openssh-server : garantit un serveur SSH sur toutes les distros (les packages = ["qemu-guest-agent", *args.package]
# 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"
@ -1181,13 +1127,6 @@ 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:
@ -1211,10 +1150,9 @@ 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
urls = image_candidates( url = resolve_image_url(
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"
@ -1241,7 +1179,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(urls, args.image_path, args.dry_run) download_image(url, 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).")
@ -1271,7 +1209,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(urls, args.image_path, args.dry_run) download_image(url, 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,24 +555,15 @@ 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"
n = 0 for i, instance in enumerate(choices):
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"[{n}] " + desc + "\n" help_info += f"[{i + 1}] " + desc + "\n"
help_info += help_end help_info += help_end
return help_info return help_info
@ -701,10 +692,8 @@ 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")},
@ -716,7 +705,6 @@ 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"
@ -869,7 +857,6 @@ 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:
@ -895,8 +882,6 @@ 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:
@ -1009,75 +994,6 @@ 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,18 +141,6 @@ 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",
@ -1061,46 +1049,6 @@ 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)",