Compare commits

..

No commits in common. "ca796a67a4ae03b6a911d838b56ea6b14c1546da" and "935919e07ba1ac92a83712a907e67fac06eb2d39" have entirely different histories.

6 changed files with 169 additions and 964 deletions

View file

@ -4,30 +4,24 @@
<!----------------------------> <!---------------------------->
<!-- [en] --> <!-- [en] -->
# QEMU/KVM — Linux VM deployment (Ubuntu / Debian / Fedora) # QEMU/KVM — Ubuntu VM deployment
`deploy_qemu.py` deploys a Linux VM (libvirt/KVM) from an official cloud `deploy_qemu.py` deploys an Ubuntu VM (libvirt/KVM) from an official Ubuntu
image, using `qemu-img` + `cloud-init` + `virt-install`. Pick the cloud image, using `qemu-img` + `cloud-init` + `virt-install`. It:
distribution with `--distro` (`ubuntu` default, `debian`, `fedora`) and the
release with `--version`; run `--list-images` to see the full catalogue with
minimum specs. It:
1. **Downloads the cloud image by itself** (cached, no double download). 1. **Downloads the Ubuntu cloud image by itself** (cached, no double download).
2. Converts it to a dedicated qcow2 working disk and resizes it. 2. Converts it to a dedicated qcow2 working disk and resizes it.
3. Generates `user-data` / `meta-data` and builds the `seed.iso` (cloud-init). 3. Generates `user-data` / `meta-data` and builds the `seed.iso` (cloud-init).
4. Runs `virt-install` importing the disk + the seed as a CD-ROM. 4. Runs `virt-install` importing the disk + the seed as a CD-ROM.
5. Waits for the DHCP lease and prints the SSH command. 5. Waits for the DHCP lease and prints the SSH command.
<!-- [fr] --> <!-- [fr] -->
# QEMU/KVM — Déploiement de VM Linux (Ubuntu / Debian / Fedora) # QEMU/KVM — Déploiement de VM Ubuntu
`deploy_qemu.py` déploie une VM Linux (libvirt/KVM) à partir d'une image `deploy_qemu.py` déploie une VM Ubuntu (libvirt/KVM) à partir d'une image
cloud officielle, via `qemu-img` + `cloud-init` + `virt-install`. Choisissez cloud Ubuntu officielle, via `qemu-img` + `cloud-init` + `virt-install`. Il :
la distribution avec `--distro` (`ubuntu` par défaut, `debian`, `fedora`) et
la version avec `--version` ; `--list-images` affiche tout le catalogue avec
les specs minimales. Il :
1. **Télécharge lui-même l'image cloud** (mise en cache, sans double 1. **Télécharge lui-même l'image cloud Ubuntu** (mise en cache, sans double
téléchargement). téléchargement).
2. La convertit en un disque de travail qcow2 dédié et le redimensionne. 2. La convertit en un disque de travail qcow2 dédié et le redimensionne.
3. Génère `user-data` / `meta-data` et construit le `seed.iso` (cloud-init). 3. Génère `user-data` / `meta-data` et construit le `seed.iso` (cloud-init).
@ -207,16 +201,11 @@ parameters and builds the command for you.
## Main options ## Main options
- `--distro``ubuntu` (default), `debian` or `fedora`. - `--version` — Ubuntu version (default `24.04`).
- `--version` — release for the distro (default: the distro's default).
- `--list-images` — print all distros/versions and their specs, then exit.
- `--image-dir` — image cache directory (default `/var/lib/libvirt/images/iso`). - `--image-dir` — image cache directory (default `/var/lib/libvirt/images/iso`).
- `--download-only` — download the image then exit (no VM). - `--download-only` — download the image then exit (no VM).
- `--name` — VM name (required for deployment). - `--name` — VM name (required for deployment).
- `--memory`, `--vcpus`, `--disk-size` — VM sizing. When omitted, `--memory` - `--memory`, `--vcpus`, `--disk-size` — VM sizing.
and `--disk-size` default to the **minimum required by the chosen version**
(libosinfo values, see `--list-images`: Ubuntu 24.04+ → 3072 MB/20G, Debian
→ 1024 MB/10G, Fedora → 2048 MB/15G); `--vcpus` defaults to 2.
- `--ssh-key`, `--ask-password`, `--password-hash` — authentication. - `--ssh-key`, `--ask-password`, `--password-hash` — authentication.
- `-y` / `--assume-yes` — auto-accept dependency installation. - `-y` / `--assume-yes` — auto-accept dependency installation.
- `--no-install-deps` — never auto-install dependencies. - `--no-install-deps` — never auto-install dependencies.
@ -239,18 +228,12 @@ vous.
## Principales options ## Principales options
- `--distro``ubuntu` (défaut), `debian` ou `fedora`. - `--version` — version Ubuntu (défaut `24.04`).
- `--version` — version de la distro (défaut : celle par défaut de la distro).
- `--list-images` — affiche toutes les distros/versions et leurs specs.
- `--image-dir` — répertoire de cache des images (défaut - `--image-dir` — répertoire de cache des images (défaut
`/var/lib/libvirt/images/iso`). `/var/lib/libvirt/images/iso`).
- `--download-only` — télécharge l'image puis quitte (sans VM). - `--download-only` — télécharge l'image puis quitte (sans VM).
- `--name` — nom de la VM (requis pour le déploiement). - `--name` — nom de la VM (requis pour le déploiement).
- `--memory`, `--vcpus`, `--disk-size` — dimensionnement de la VM. Omis, - `--memory`, `--vcpus`, `--disk-size` — dimensionnement de la VM.
`--memory` et `--disk-size` prennent le **minimum requis par la version**
choisie (valeurs libosinfo, voir `--list-images` : Ubuntu 24.04+ →
3072 Mo/20G, Debian → 1024 Mo/10G, Fedora → 2048 Mo/15G) ; `--vcpus`
vaut 2 par défaut.
- `--ssh-key`, `--ask-password`, `--password-hash` — authentification. - `--ssh-key`, `--ask-password`, `--password-hash` — authentification.
- `-y` / `--assume-yes` — accepte automatiquement l'installation des - `-y` / `--assume-yes` — accepte automatiquement l'installation des
dépendances. dépendances.

View file

@ -1,13 +1,10 @@
# QEMU/KVM — Déploiement de VM Linux (Ubuntu / Debian / Fedora) # QEMU/KVM — Déploiement de VM Ubuntu
`deploy_qemu.py` déploie une VM Linux (libvirt/KVM) à partir d'une image `deploy_qemu.py` déploie une VM Ubuntu (libvirt/KVM) à partir d'une image
cloud officielle, via `qemu-img` + `cloud-init` + `virt-install`. Choisissez cloud Ubuntu officielle, via `qemu-img` + `cloud-init` + `virt-install`. Il :
la distribution avec `--distro` (`ubuntu` par défaut, `debian`, `fedora`) et
la version avec `--version` ; `--list-images` affiche tout le catalogue avec
les specs minimales. Il :
1. **Télécharge lui-même l'image cloud** (mise en cache, sans double 1. **Télécharge lui-même l'image cloud Ubuntu** (mise en cache, sans double
téléchargement). téléchargement).
2. La convertit en un disque de travail qcow2 dédié et le redimensionne. 2. La convertit en un disque de travail qcow2 dédié et le redimensionne.
3. Génère `user-data` / `meta-data` et construit le `seed.iso` (cloud-init). 3. Génère `user-data` / `meta-data` et construit le `seed.iso` (cloud-init).
@ -118,18 +115,12 @@ vous.
## Principales options ## Principales options
- `--distro``ubuntu` (défaut), `debian` ou `fedora`. - `--version` — version Ubuntu (défaut `24.04`).
- `--version` — version de la distro (défaut : celle par défaut de la distro).
- `--list-images` — affiche toutes les distros/versions et leurs specs.
- `--image-dir` — répertoire de cache des images (défaut - `--image-dir` — répertoire de cache des images (défaut
`/var/lib/libvirt/images/iso`). `/var/lib/libvirt/images/iso`).
- `--download-only` — télécharge l'image puis quitte (sans VM). - `--download-only` — télécharge l'image puis quitte (sans VM).
- `--name` — nom de la VM (requis pour le déploiement). - `--name` — nom de la VM (requis pour le déploiement).
- `--memory`, `--vcpus`, `--disk-size` — dimensionnement de la VM. Omis, - `--memory`, `--vcpus`, `--disk-size` — dimensionnement de la VM.
`--memory` et `--disk-size` prennent le **minimum requis par la version**
choisie (valeurs libosinfo, voir `--list-images` : Ubuntu 24.04+ →
3072 Mo/20G, Debian → 1024 Mo/10G, Fedora → 2048 Mo/15G) ; `--vcpus`
vaut 2 par défaut.
- `--ssh-key`, `--ask-password`, `--password-hash` — authentification. - `--ssh-key`, `--ask-password`, `--password-hash` — authentification.
- `-y` / `--assume-yes` — accepte automatiquement l'installation des - `-y` / `--assume-yes` — accepte automatiquement l'installation des
dépendances. dépendances.

View file

@ -1,13 +1,10 @@
# QEMU/KVM — Linux VM deployment (Ubuntu / Debian / Fedora) # QEMU/KVM — Ubuntu VM deployment
`deploy_qemu.py` deploys a Linux VM (libvirt/KVM) from an official cloud `deploy_qemu.py` deploys an Ubuntu VM (libvirt/KVM) from an official Ubuntu
image, using `qemu-img` + `cloud-init` + `virt-install`. Pick the cloud image, using `qemu-img` + `cloud-init` + `virt-install`. It:
distribution with `--distro` (`ubuntu` default, `debian`, `fedora`) and the
release with `--version`; run `--list-images` to see the full catalogue with
minimum specs. It:
1. **Downloads the cloud image by itself** (cached, no double download). 1. **Downloads the Ubuntu cloud image by itself** (cached, no double download).
2. Converts it to a dedicated qcow2 working disk and resizes it. 2. Converts it to a dedicated qcow2 working disk and resizes it.
3. Generates `user-data` / `meta-data` and builds the `seed.iso` (cloud-init). 3. Generates `user-data` / `meta-data` and builds the `seed.iso` (cloud-init).
4. Runs `virt-install` importing the disk + the seed as a CD-ROM. 4. Runs `virt-install` importing the disk + the seed as a CD-ROM.
@ -111,16 +108,11 @@ parameters and builds the command for you.
## Main options ## Main options
- `--distro``ubuntu` (default), `debian` or `fedora`. - `--version` — Ubuntu version (default `24.04`).
- `--version` — release for the distro (default: the distro's default).
- `--list-images` — print all distros/versions and their specs, then exit.
- `--image-dir` — image cache directory (default `/var/lib/libvirt/images/iso`). - `--image-dir` — image cache directory (default `/var/lib/libvirt/images/iso`).
- `--download-only` — download the image then exit (no VM). - `--download-only` — download the image then exit (no VM).
- `--name` — VM name (required for deployment). - `--name` — VM name (required for deployment).
- `--memory`, `--vcpus`, `--disk-size` — VM sizing. When omitted, `--memory` - `--memory`, `--vcpus`, `--disk-size` — VM sizing.
and `--disk-size` default to the **minimum required by the chosen version**
(libosinfo values, see `--list-images`: Ubuntu 24.04+ → 3072 MB/20G, Debian
→ 1024 MB/10G, Fedora → 2048 MB/15G); `--vcpus` defaults to 2.
- `--ssh-key`, `--ask-password`, `--password-hash` — authentication. - `--ssh-key`, `--ask-password`, `--password-hash` — authentication.
- `-y` / `--assume-yes` — auto-accept dependency installation. - `-y` / `--assume-yes` — auto-accept dependency installation.
- `--no-install-deps` — never auto-install dependencies. - `--no-install-deps` — never auto-install dependencies.

View file

@ -1,10 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Déploiement rapide de VM Linux (Ubuntu/Debian/Fedora) via cloud-image + qemu-img + cloud-init + virt-install. """Déploiement rapide de VM Ubuntu (cloud-image) avec qemu-img + cloud-init + virt-install.
Choisissez la distribution avec --distro (ubuntu par défaut, debian, fedora) Reprend le workflow des notes :
et la version avec --version. « --list-images » affiche tout le catalogue et 1. Télécharge l'image cloud Ubuntu (si absente du cache -> pas de double téléchargement).
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. 2. Convertit/copie l'image en un qcow2 de travail dédié à la VM.
3. Redimensionne le disque virtuel. 3. Redimensionne le disque virtuel.
4. Génère user-data / meta-data et construit le seed.iso (cidata). 4. Génère user-data / meta-data et construit le seed.iso (cidata).
@ -13,20 +11,11 @@ les specs minimales. Reprend le workflow des notes :
Exemples Exemples
-------- --------
# Le plus simple : image téléchargée automatiquement (chemin déduit de # 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 # --version, mis en cache dans /var/lib/libvirt/images/iso) et outils
# outils manquants installés après confirmation. # manquants installés après confirmation.
sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 \\ sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 \\
--ssh-key ~/.ssh/id_ed25519.pub --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 # 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 sudo ./script/qemu/deploy_qemu.py --download-only --version 24.04 --verify
@ -60,186 +49,36 @@ import urllib.request
from pathlib import Path from pathlib import Path
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Registre des distributions : version -> (code, --osinfo, RAM min Mo, disque). # Table des versions Ubuntu -> (nom de code, valeur --osinfo/libosinfo)
# Le « code » est le nom de code (Ubuntu/Debian) ou le numéro de release # Le nom de code sert à construire l'URL de l'image cloud. Les valeurs LTS sont
# (Fedora). RAM/disque minimaux proviennent de libosinfo (osinfo-db) : ce sont # stables ; --codename et --osinfo permettent de surcharger si la table vieillit.
# 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.
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
UBUNTU_VERSIONS: dict[str, tuple[str, str, int, str]] = { UBUNTU_VERSIONS: dict[str, tuple[str, str]] = {
"20.04": ("focal", "ubuntu20.04", 2048, "5G"), "20.04": ("focal", "ubuntu20.04"),
"22.04": ("jammy", "ubuntu22.04", 2048, "10G"), "22.04": ("jammy", "ubuntu22.04"),
"24.04": ("noble", "ubuntu24.04", 3072, "20G"), "24.04": ("noble", "ubuntu24.04"),
"24.10": ("oracular", "ubuntu24.10", 3072, "20G"), "24.10": ("oracular", "ubuntu24.10"),
"25.04": ("plucky", "ubuntu25.04", 3072, "20G"), "25.04": ("plucky", "ubuntu25.04"),
"25.10": ("questing", "ubuntu25.10", 3072, "20G"), "25.10": ("questing", "ubuntu25.10"),
"26.04": ("resolute", "ubuntu26.04", 3072, "20G"), # "26.04": ("resolute", "ubuntu26.04"),
}
DEBIAN_VERSIONS: dict[str, tuple[str, str, int, str]] = {
"11": ("bullseye", "debian11", 1024, "10G"),
"12": ("bookworm", "debian12", 1024, "10G"),
"13": ("trixie", "debian13", 1024, "10G"),
}
FEDORA_VERSIONS: dict[str, tuple[str, str, int, str]] = {
"41": ("41", "fedora41", 2048, "15G"),
"42": ("42", "fedora42", 2048, "15G"),
"43": ("43", "fedora43", 2048, "15G"),
"44": ("44", "fedora44", 2048, "15G"),
}
# distro -> (table des versions, version par défaut).
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"),
}
# Traduction de l'arch générique (amd64/arm64) vers le nom propre à la distro.
ARCH_ALIASES: dict[str, dict[str, str]] = {
"fedora": {"amd64": "x86_64", "arm64": "aarch64"},
} }
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"
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 /
# --seed-dir). L'écriture y nécessite root : le déploiement tourne de toute # --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. # façon sous sudo (virt-install). Surchargez avec --image-dir au besoin.
DEFAULT_IMAGE_DIR = Path("/var/lib/libvirt/images/iso") DEFAULT_IMAGE_DIR = Path("/var/lib/libvirt/images/iso")
# Emplacements de la base osinfo-db (détection d'un --osinfo connu).
OSINFO_DB_DIRS: tuple[str, ...] = ( def image_url(codename: str, arch: str) -> str:
"/usr/share/osinfo/os", """URL de l'image cloud « current » pour un nom de code + architecture."""
"/usr/local/share/osinfo/os", return f"{CLOUD_IMG_BASE}/{codename}/current/{codename}-server-cloudimg-{arch}.img"
os.path.expanduser("~/.local/share/osinfo/os"),
)
def distro_arch(distro: str, arch: str) -> str: def default_image_name(codename: str, arch: str) -> str:
"""Nom d'architecture attendu par la distro (Fedora utilise x86_64).""" """Nom de fichier local dérivé du nom de code + architecture."""
return ARCH_ALIASES.get(distro, {}).get(arch, arch) return f"{codename}-server-cloudimg-{arch}.img"
def image_url(distro: str, code: str, arch: str, version: str) -> str:
"""URL directe de l'image cloud (Ubuntu/Debian). Fedora est résolu à
part via resolve_fedora_url (pas de lien « latest » stable)."""
a = distro_arch(distro, arch)
if distro == "ubuntu":
return (
f"{CLOUD_IMG_BASE}/{code}/current/"
f"{code}-server-cloudimg-{a}.img"
)
if distro == "debian":
return (
f"{DEBIAN_CLOUD_BASE}/{code}/latest/"
f"debian-{version}-genericcloud-{a}.qcow2"
)
raise ValueError(f"URL directe 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 »)."""
a = distro_arch("fedora", arch)
index = f"{FEDORA_BASE}/{version}/Cloud/{a}/images/"
pattern = re.compile(
rf"Fedora-Cloud-Base-Generic-{version}-[0-9.]+\.{a}\.qcow2"
)
if dry_run:
return index + f"Fedora-Cloud-Base-Generic-{version}-<build>.{a}.qcow2"
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
sys.exit(f"Impossible de lister les images Fedora {version} : {exc}")
names = sorted(set(pattern.findall(html)))
if not names:
sys.exit(
"Aucune image « Fedora-Cloud-Base-Generic » trouvée pour "
f"Fedora {version} ({a}) dans {index}"
)
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:
"""Nom de fichier local pour le cache d'image."""
a = distro_arch(distro, arch)
if distro == "ubuntu":
return f"{code}-server-cloudimg-{a}.img"
if distro == "debian":
return f"debian-{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) -> str:
"""Valeur --osinfo résiliente à un osinfo-db périmé : si l'id est connu on
l'utilise ; sinon on bascule en détection best-effort au lieu d'échouer
(utile pour une distro 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
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")
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@ -574,23 +413,11 @@ def download_image(url: str, dest: Path, dry_run: bool) -> None:
) )
print(f" Téléchargement {url}") 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()
last_pct = [-1]
def _progress(block_num: int, block_size: int, total: int) -> None: def _progress(block_num: int, block_size: int, total: int) -> None:
# N'émet qu'au changement de pourcentage entier : sur un TTY, réécrit if total > 0:
# la même ligne (\r) ; sinon (sortie capturée par le menu todo) au plus pct = min(100, block_num * block_size * 100 // total)
# 101 lignes au lieu de centaines de répétitions du même pourcentage.
if total <= 0:
return
pct = min(100, block_num * block_size * 100 // total)
if pct == last_pct[0]:
return
last_pct[0] = pct
if is_tty:
print(f"\r {pct:3d}%", end="", flush=True) print(f"\r {pct:3d}%", end="", flush=True)
else:
print(f" {pct:3d}%", flush=True)
try: try:
urllib.request.urlretrieve( urllib.request.urlretrieve(
@ -599,8 +426,7 @@ def download_image(url: str, dest: Path, dry_run: bool) -> None:
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}")
if is_tty: print()
print()
tmp.replace(dest) tmp.replace(dest)
@ -778,42 +604,13 @@ def network_name(network_arg: str) -> str | None:
return None return None
def network_state(name: str, use_sudo: bool) -> tuple[bool, bool]:
"""(actif, autostart) d'un réseau libvirt, via « virsh net-info »."""
cmd = (["sudo"] if use_sudo else []) + ["virsh", "net-info", name]
try:
res = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
except (OSError, subprocess.SubprocessError):
return (False, False)
active = bool(re.search(r"Active:\s*yes", res.stdout, re.IGNORECASE))
autostart = bool(re.search(r"Autostart:\s*yes", res.stdout, re.IGNORECASE))
return (active, autostart)
def ensure_network(name: str | None, runner: Runner) -> None: def ensure_network(name: str | None, runner: Runner) -> None:
"""Active le réseau libvirt si besoin. On vérifie d'abord son état pour """Active le réseau libvirt (idempotent : tolère « déjà actif »)."""
éviter le faux « error: network is already active » de virsh quand il
tourne déjà (message purement bruyant, sans conséquence)."""
if not name: if not name:
return return
if runner.dry_run: print(f" Activation du réseau libvirt '{name}' (si nécessaire)")
print(f" [dry-run] réseau libvirt '{name}' activé si nécessaire") runner.run(["virsh", "net-start", name], privileged=True, check=False)
runner.run(["virsh", "net-start", name], privileged=True, check=False) runner.run(["virsh", "net-autostart", name], privileged=True, check=False)
runner.run(
["virsh", "net-autostart", name], privileged=True, check=False
)
return
active, autostart = network_state(name, runner.use_sudo)
if active and autostart:
print(f" Réseau libvirt '{name}' déjà actif.")
return
print(f" Configuration du réseau libvirt '{name}'")
if not active:
runner.run(["virsh", "net-start", name], privileged=True, check=False)
if not autostart:
runner.run(
["virsh", "net-autostart", name], privileged=True, check=False
)
def virt_install( def virt_install(
@ -885,42 +682,34 @@ def ssh_command(user: str, ip: str, has_key: bool) -> str:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
versions_help = "\n".join( versions_help = "\n".join(
f" {distro:<7} {default:<7} (défaut) versions : " f" {v:<7} {code:<10} {image_url(code, 'amd64')}"
+ ", ".join(versions) for v, (code, _osinfo) in UBUNTU_VERSIONS.items()
for distro, (versions, default) in DISTROS.items()
) )
p = argparse.ArgumentParser( p = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
description=__doc__, description=__doc__,
epilog="Distros et versions (--distro / --version), specs via " epilog="Versions Ubuntu disponibles (--version) et URL de l'image cloud :\n"
"--list-images :\n" + versions_help, + versions_help,
) )
p.add_argument( p.add_argument(
"image_path", "image_path",
type=Path, type=Path,
nargs="?", nargs="?",
default=None, default=None,
help="Chemin de cache de l'image cloud. Optionnel : si absent, il est " help="Chemin de cache de l'image cloud (.img). Optionnel : si absent, "
"déduit de --distro/--version/--codename + --arch dans --image-dir. " "il est déduit de --version/--codename + --arch dans --image-dir. "
"Si le fichier existe, il n'est PAS re-téléchargé.", "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(
"--distro",
default="ubuntu",
choices=DISTROS,
help="Distribution : ubuntu, debian ou fedora (défaut : ubuntu).",
)
g_img.add_argument( g_img.add_argument(
"--version", "--version",
default=None, default="24.04",
help="Version de la distro (défaut : la version par défaut de la " choices=UBUNTU_VERSIONS,
"distro). Voir --list-images pour la liste complète.", help="Version Ubuntu (défaut : 24.04). Voir la liste ci-dessous.",
) )
g_img.add_argument( g_img.add_argument(
"--codename", "--codename", help="Force le nom de code (surcharge --version)."
help="Force le nom de code / la release (surcharge --version).",
) )
g_img.add_argument( g_img.add_argument(
"--arch", "--arch",
@ -950,20 +739,15 @@ def build_parser() -> argparse.ArgumentParser:
"--hostname", help="Nom d'hôte interne (défaut : --name)." "--hostname", help="Nom d'hôte interne (défaut : --name)."
) )
g_vm.add_argument( g_vm.add_argument(
"--memory", "--memory", type=int, default=8192, help="RAM en Mo (défaut : 8192)."
type=int,
default=None,
help="RAM en Mo (défaut : minimum requis par la version choisie, "
"voir --list-images).",
) )
g_vm.add_argument( g_vm.add_argument(
"--vcpus", type=int, default=2, help="Nombre de vCPU (défaut : 2)." "--vcpus", type=int, default=4, help="Nombre de vCPU (défaut : 4)."
) )
g_vm.add_argument( g_vm.add_argument(
"--disk-size", "--disk-size",
default=None, default="20G",
help="Taille du disque virtuel, ex. 120G (défaut : minimum requis " help="Taille du disque virtuel, ex. 120G (défaut : 20G).",
"par la version choisie, voir --list-images).",
) )
g_vm.add_argument( g_vm.add_argument(
"--disk-dir", "--disk-dir",
@ -1094,12 +878,6 @@ def build_parser() -> argparse.ArgumentParser:
action="store_true", action="store_true",
help="N'installe jamais les dépendances manquantes (échoue si absentes).", help="N'installe jamais les dépendances manquantes (échoue si absentes).",
) )
g_run.add_argument(
"--list-images",
action="store_true",
help="Liste les distros/versions disponibles et leurs specs, "
"puis quitte.",
)
return p return p
@ -1129,43 +907,16 @@ def load_ssh_keys(paths: list[str]) -> list[str]:
def main() -> None: def main() -> None:
args = build_parser().parse_args() args = build_parser().parse_args()
if args.list_images: codename, default_osinfo = UBUNTU_VERSIONS[args.version]
list_images()
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)."
)
code, default_osinfo, min_ram, min_disk = versions[args.version]
if args.codename: if args.codename:
code = args.codename codename = args.codename
osinfo = args.osinfo or default_osinfo osinfo = args.osinfo or default_osinfo
# Dimensionnement par défaut = minimum requis par la version (libosinfo). url = image_url(codename, args.arch)
if args.memory is None:
args.memory = min_ram
if args.disk_size is None:
args.disk_size = min_disk
url = resolve_image_url(
args.distro, code, args.arch, args.version, args.dry_run
)
# --verify s'appuie sur un SHA256SUMS style Ubuntu ; Debian/Fedora
# publient des sommes dans un autre format -> on saute proprement.
do_verify = args.verify and args.distro == "ubuntu"
if args.verify and not do_verify:
print(
f" Note : --verify n'est pris en charge que pour ubuntu "
f"(ignoré pour {args.distro})."
)
# Chemin de l'image : déduit automatiquement si non fourni. # Chemin de l'image : déduit automatiquement si non fourni.
if args.image_path is None: if args.image_path is None:
args.image_path = args.image_dir / default_image_name( args.image_path = args.image_dir / default_image_name(
args.distro, code, args.arch, args.version codename, args.arch
) )
runner = Runner( runner = Runner(
@ -1175,12 +926,11 @@ def main() -> None:
# -- Mode téléchargement seul : aucun outil ni VM requis. -------------- # -- Mode téléchargement seul : aucun outil ni VM requis. --------------
if args.download_only: if args.download_only:
print( print(
f"\n== Téléchargement image cloud " f"\n== Téléchargement image cloud ({args.version} / {codename}) =="
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(url, args.image_path, args.dry_run)
if do_verify: if args.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).")
return return
@ -1208,9 +958,9 @@ def main() -> None:
if not args.dry_run: if not args.dry_run:
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.version} / {codename}) ==")
download_image(url, args.image_path, args.dry_run) download_image(url, args.image_path, args.dry_run)
if do_verify: if args.verify:
verify_sha256(url, args.image_path, args.dry_run) verify_sha256(url, args.image_path, args.dry_run)
print(f"\n== 2-3/5 Disque de travail {disk} ({args.disk_size}) ==") print(f"\n== 2-3/5 Disque de travail {disk} ({args.disk_size}) ==")
@ -1220,10 +970,9 @@ def main() -> None:
cloud_cfg = build_cloud_config(args, pw_hash, ssh_keys) cloud_cfg = build_cloud_config(args, pw_hash, ssh_keys)
build_seed(cloud_cfg, args.hostname, seed, runner) build_seed(cloud_cfg, args.hostname, seed, runner)
resolved_osinfo = osinfo_arg(osinfo) print(f"\n== 5/5 virt-install (--osinfo {osinfo}) ==")
print(f"\n== 5/5 virt-install (--osinfo {resolved_osinfo}) ==")
ensure_network(network_name(args.network), runner) ensure_network(network_name(args.network), runner)
virt_install(args, disk, seed, resolved_osinfo, runner) virt_install(args, disk, seed, osinfo, runner)
has_key = bool(ssh_keys) has_key = bool(ssh_keys)
print("\nTerminé. Suivi :") print("\nTerminé. Suivi :")

View file

@ -5,11 +5,9 @@
import configparser import configparser
import datetime import datetime
import getpass import getpass
import inspect
import json import json
import logging import logging
import os import os
import re
import shlex import shlex
import shutil import shutil
import subprocess import subprocess
@ -27,7 +25,7 @@ from script.config import config_file
from script.execute import execute from script.execute import execute
from script.todo.database_manager import DatabaseManager from script.todo.database_manager import DatabaseManager
from script.todo.kdbx_manager import KdbxManager from script.todo.kdbx_manager import KdbxManager
from script.todo.todo_i18n import lang_is_configured, set_lang, t from script.todo.todo_i18n import get_lang, lang_is_configured, set_lang, t
from script.todo.version_manager import get_odoo_version from script.todo.version_manager import get_odoo_version
ERROR_LOG_PATH = ".erplibre.error.txt" ERROR_LOG_PATH = ".erplibre.error.txt"
@ -124,7 +122,7 @@ class TODO:
self._ask_language() self._ask_language()
print(t("Opening TODO ...")) print(t("Opening TODO ..."))
print(f"🤖 {t('=> Enter your choice by number and press Enter!')}") print(f"🤖 {t('=> Enter your choice by number and press Enter!')}")
help_info = f"""{self._menu_header()} help_info = f"""{t("Command:")}
[1] {t("Execute")} [1] {t("Execute")}
[2] {t("Install")} [2] {t("Install")}
[3] {t("Question")} [3] {t("Question")}
@ -170,7 +168,7 @@ class TODO:
def execute_prompt_ia(self): def execute_prompt_ia(self):
while True: while True:
help_info = f"""{self._menu_header()} help_info = f"""{t("Command:")}
[0] {t("Back")} [0] {t("Back")}
{t("Write your question ")}""" {t("Write your question ")}"""
status = click.prompt(help_info) status = click.prompt(help_info)
@ -196,7 +194,7 @@ class TODO:
print() print()
def prompt_execute(self): def prompt_execute(self):
help_info = f"""{self._menu_header()} help_info = f"""{t("Command:")}
[1] {t("Automation - Demonstration of developed features")} [1] {t("Automation - Demonstration of developed features")}
[2] {t("Code - Developer tools")} [2] {t("Code - Developer tools")}
[3] {t("Config - Configuration file management")} [3] {t("Config - Configuration file management")}
@ -293,7 +291,7 @@ class TODO:
.strip() .strip()
.lower() .lower()
) )
if self._is_yes(first_installation_input): if first_installation_input == "y":
cmd = "./script/version/update_env_version.py --install" cmd = "./script/version/update_env_version.py --install"
self.execute.exec_command_live(cmd, source_erplibre=True) self.execute.exec_command_live(cmd, source_erplibre=True)
print("Wait after OS installation before continue.") print("Wait after OS installation before continue.")
@ -323,7 +321,7 @@ class TODO:
pycharm_configuration_input = ( pycharm_configuration_input = (
input("💬 Open Pycharm? (Y/N): ").strip().lower() input("💬 Open Pycharm? (Y/N): ").strip().lower()
) )
if self._is_yes(pycharm_configuration_input): if pycharm_configuration_input == "y":
pycharm_bin = "pycharm" if has_pycharm else "pycharm-community" pycharm_bin = "pycharm" if has_pycharm else "pycharm-community"
cmd = f"cd {os.getcwd()} && {pycharm_bin} ./" cmd = f"cd {os.getcwd()} && {pycharm_bin} ./"
@ -514,48 +512,8 @@ class TODO:
if callback: if callback:
callback(instance) callback(instance)
# Étiquettes du fil d'Ariane par méthode de menu. Le fil est dérivé de la
# pile d'appels (aucune méthode de menu à modifier). Labels courts et
# stables, pensés pour être copiés afin de situer précisément un menu.
_MENU_LABELS = {
"run": "TODO",
"prompt_execute": "Execute",
"execute_prompt_ia": "Question",
"prompt_install": "Install",
"prompt_execute_function": "Automation",
"prompt_execute_code": "Code",
"prompt_execute_config": "Config",
"prompt_execute_database": "Database",
"prompt_execute_doc": "Doc",
"prompt_execute_git": "Git",
"prompt_execute_git_local_server": "Git local server",
"prompt_execute_gpt_code": "GPT code",
"prompt_execute_process": "Process",
"prompt_execute_instance": "Run",
"prompt_execute_rtk": "RTK",
"prompt_execute_update": "Update",
"prompt_execute_deploy": "Deploy",
"prompt_execute_qemu": "QEMU/KVM",
}
def _menu_header(self):
"""En-tête de menu : fil d'Ariane (dérivé de la pile d'appels) suivi de
la ligne « Commande : ». Le fil situe le menu courant et se copie pour
décrire sans ambiguïté l'on se trouve."""
crumbs = []
for frame_info in reversed(inspect.stack()):
if frame_info.frame.f_locals.get("self") is not self:
continue
label = self._MENU_LABELS.get(frame_info.function)
if label and (not crumbs or crumbs[-1] != label):
crumbs.append(label)
header = ""
if crumbs:
header = "📍 " + " ".join(crumbs) + "\n"
return header + t("Command:")
def fill_help_info(self, choices): def fill_help_info(self, choices):
help_info = self._menu_header() + "\n" help_info = t("Command:") + "\n"
help_end = f"[0] {t('Back')}\n" help_end = f"[0] {t('Back')}\n"
for i, instance in enumerate(choices): for i, instance in enumerate(choices):
desc_key = instance.get("prompt_description_key") desc_key = instance.get("prompt_description_key")
@ -777,48 +735,16 @@ class TODO:
return path return path
return "" return ""
# distro -> (versions affichées, version par défaut). Source de vérité = def _qemu_prompt_version(self):
# deploy_qemu.py ; ceci ne sert qu'au sélecteur interactif. """Demande la version Ubuntu ; retourne une chaîne (défaut 24.04)."""
_QEMU_DISTROS = { versions = ["20.04", "22.04", "24.04", "24.10", "25.04", "25.10"]
"ubuntu": ( print(f"\n{t('Ubuntu version:')}")
["20.04", "22.04", "24.04", "24.10", "25.04", "25.10", "26.04"],
"24.04",
),
"debian": (["11", "12", "13"], "12"),
"fedora": (["41", "42", "43", "44"], "42"),
}
def _qemu_prompt_distro(self):
"""Demande la distribution (défaut : ubuntu)."""
distros = list(self._QEMU_DISTROS)
print(f"\n{t('Distribution:')}")
for i, d in enumerate(distros, 1):
print(f" [{i}] {d}")
sel = input(t("Choice (number or name, default: ubuntu): ")).strip()
if not sel:
return "ubuntu"
try:
idx = int(sel) - 1
if 0 <= idx < len(distros):
return distros[idx]
except ValueError:
if sel in distros:
return sel
print(t("Invalid selection, using ubuntu"))
return "ubuntu"
def _qemu_prompt_version(self, distro):
"""Demande la version pour la distro (défaut = version par défaut)."""
versions, default = self._QEMU_DISTROS.get(distro, ([], ""))
print(f"\n{t('Version for')} {distro} :")
for i, v in enumerate(versions, 1): for i, v in enumerate(versions, 1):
suffix = " *" if v == default else "" suffix = " (LTS)" if v in ("20.04", "22.04", "24.04") else ""
print(f" [{i}] {v}{suffix}") print(f" [{i}] {v}{suffix}")
sel = input( sel = input(t("Choice (number or version, default: 24.04): ")).strip()
f"{t('Choice (number or version, blank = default):')} "
).strip()
if not sel: if not sel:
return default return "24.04"
try: try:
idx = int(sel) - 1 idx = int(sel) - 1
if 0 <= idx < len(versions): if 0 <= idx < len(versions):
@ -826,14 +752,8 @@ class TODO:
except ValueError: except ValueError:
if sel in versions: if sel in versions:
return sel return sel
print(f"{t('Invalid selection, using')} {default}") print(t("Invalid selection, using 24.04"))
return default return "24.04"
def _qemu_list_images(self):
"""Affiche la liste des distros/versions et leurs specs."""
cmd = f"{self._qemu_script_path()} --list-images"
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
def prompt_execute_qemu(self): def prompt_execute_qemu(self):
print(f"🤖 {t('Deploy a QEMU/KVM virtual machine (libvirt)!')}") print(f"🤖 {t('Deploy a QEMU/KVM virtual machine (libvirt)!')}")
@ -842,21 +762,15 @@ class TODO:
print(f"{t('QEMU deploy script not found: ')}{script_path}") print(f"{t('QEMU deploy script not found: ')}{script_path}")
return False return False
choices = [ choices = [
{"prompt_description": t("Deploy a new VM")}, {"prompt_description": t("Deploy a new Ubuntu VM")},
{ {
"prompt_description": t( "prompt_description": t(
"Preview a deployment (dry-run, no sudo)" "Preview a deployment (dry-run, no sudo)"
) )
}, },
{"prompt_description": t("Download a cloud image only")}, {"prompt_description": t("Download an Ubuntu cloud image only")},
{"prompt_description": t("List VMs (virsh list --all)")}, {"prompt_description": t("List VMs (virsh list --all)")},
{"prompt_description": t("Show a VM IP address")}, {"prompt_description": t("Show a VM IP address")},
{"prompt_description": t("List available images and specs")},
{
"prompt_description": t(
"Deploy ERPLibre infra (one minimal VM per image)"
)
},
] ]
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:
@ -878,10 +792,6 @@ class TODO:
self._qemu_list_vms() self._qemu_list_vms()
elif status == "5": elif status == "5":
self._qemu_show_ip() self._qemu_show_ip()
elif status == "6":
self._qemu_list_images()
elif status == "7":
self._qemu_deploy_infra()
else: else:
cmd_no_found = True cmd_no_found = True
try: try:
@ -901,13 +811,10 @@ class TODO:
if not name: if not name:
print(t("VM name is required!")) print(t("VM name is required!"))
return return
distro = self._qemu_prompt_distro() version = self._qemu_prompt_version()
version = self._qemu_prompt_version(distro) memory = input(t("RAM in MB (default: 8192): ")).strip() or "8192"
# Laisser vide => le script applique le minimum requis par la version vcpus = input(t("vCPUs (default: 4): ")).strip() or "4"
# choisie (libosinfo). On n'envoie alors pas le flag. disk_size = input(t("Disk size (default: 20G): ")).strip() or "20G"
memory = input(t("RAM in MB (blank = version minimum): ")).strip()
vcpus = input(t("vCPUs (default: 2): ")).strip()
disk_size = input(t("Disk size (blank = version minimum): ")).strip()
default_key = self._qemu_default_ssh_key() default_key = self._qemu_default_ssh_key()
key_hint = default_key or t("none") key_hint = default_key or t("none")
@ -921,26 +828,35 @@ class TODO:
use_password = False use_password = False
if not ssh_key: if not ssh_key:
ans = input(t("No SSH key found. Set a password instead? (Y/n): ")) ans = (
# Défaut oui : tout sauf une réponse négative explicite vaut oui. input(t("No SSH key found. Set a password instead? (Y/n): "))
use_password = not self._is_no(ans) .strip()
.lower()
)
use_password = ans != "n"
force = False force = False
if not dry_run: if not dry_run:
ans = input(t("Overwrite existing VM disk if present? (y/N): ")) ans = (
force = self._is_yes(ans) input(t("Overwrite existing VM disk if present? (y/N): "))
.strip()
.lower()
)
force = ans == "y"
parts = [] parts = []
if not dry_run: if not dry_run:
parts.append("sudo") parts.append("sudo")
parts.append(script_path) parts.append(script_path)
parts += ["--name", name, "--distro", distro, "--version", version] parts += ["--name", name, "--version", version]
if memory: parts += [
parts += ["--memory", memory] "--memory",
if vcpus: memory,
parts += ["--vcpus", vcpus] "--vcpus",
if disk_size: vcpus,
parts += ["--disk-size", disk_size] "--disk-size",
disk_size,
]
if ssh_key: if ssh_key:
parts += ["--ssh-key", ssh_key] parts += ["--ssh-key", ssh_key]
if use_password: if use_password:
@ -958,19 +874,16 @@ class TODO:
def _qemu_download_image(self): def _qemu_download_image(self):
script_path = self._qemu_script_path() script_path = self._qemu_script_path()
distro = self._qemu_prompt_distro() version = self._qemu_prompt_version()
version = self._qemu_prompt_version(distro) ans = input(t("Verify SHA256 after download? (y/N): ")).strip().lower()
ans = input(t("Verify SHA256 after download? (y/N): "))
parts = [ parts = [
"sudo", "sudo",
script_path, script_path,
"--download-only", "--download-only",
"--distro",
distro,
"--version", "--version",
version, version,
] ]
if self._is_yes(ans): if ans == "y":
parts.append("--verify") parts.append("--verify")
cmd = " ".join(shlex.quote(p) for p in parts) cmd = " ".join(shlex.quote(p) for p in parts)
print(f"{t('Will execute:')} {cmd}") print(f"{t('Will execute:')} {cmd}")
@ -982,11 +895,7 @@ class TODO:
self.execute.exec_command_live(cmd, source_erplibre=False) self.execute.exec_command_live(cmd, source_erplibre=False)
def _qemu_show_ip(self): def _qemu_show_ip(self):
# Affiche d'abord les VM (avec leur ID) pour que l'utilisateur sache name = input(t("VM name: ")).strip()
# quel nom/ID saisir, puis demande lequel.
self._qemu_list_vms()
print()
name = input(t("VM name or ID: ")).strip()
if not name: if not name:
print(t("VM name is required!")) print(t("VM name is required!"))
return return
@ -994,306 +903,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)
# ------------------------------------------------------------------ #
# QEMU : déploiement d'un parc « infra ERPLibre »
# ------------------------------------------------------------------ #
ERPLIBRE_GIT_URL = "https://github.com/erplibre/erplibre"
def _qemu_import_module(self):
"""Importe deploy_qemu.py comme module (source de vérité des specs)."""
import importlib.util
path = self._qemu_script_path()
spec = importlib.util.spec_from_file_location("deploy_qemu", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@staticmethod
def _qemu_infra_name(distro, version):
"""Nom de VM stable pour le parc, ex. erplibre-ubuntu-2404."""
return f"erplibre-{distro}-{version.replace('.', '')}"
@staticmethod
def _parse_disk_gb(size):
"""« 20G » -> 20 (Go, best effort)."""
m = re.match(r"\s*(\d+)", str(size))
return int(m.group(1)) if m else 0
@staticmethod
def _is_yes(ans):
"""Réponse affirmative, FR et EN (o/oui/y/yes)."""
return ans.strip().lower() in ("y", "yes", "o", "oui")
@staticmethod
def _is_no(ans):
"""Réponse négative explicite, FR et EN (n/no/non). Utile pour les
invites « défaut oui » tout sauf « non » vaut oui."""
return ans.strip().lower() in ("n", "no", "non")
@staticmethod
def _host_free_ram_mb():
"""RAM disponible de l'hôte en Mo (MemAvailable), 0 si inconnu."""
try:
with open("/proc/meminfo") as fh:
for line in fh:
if line.startswith("MemAvailable:"):
return int(line.split()[1]) // 1024
except OSError:
pass
return 0
@staticmethod
def _parse_index_selection(raw, options):
"""« 1 3 » ou « 1,3 » -> sous-liste d'options (indices 1-based)."""
chosen = []
for tok in re.split(r"[\s,]+", raw.strip()):
if not tok:
continue
try:
idx = int(tok) - 1
except ValueError:
if tok in options and tok not in chosen:
chosen.append(tok)
continue
if 0 <= idx < len(options) and options[idx] not in chosen:
chosen.append(options[idx])
return chosen
def _qemu_domain_exists(self, name):
"""Vrai si une VM libvirt de ce nom est déjà définie."""
try:
res = subprocess.run(
["sudo", "virsh", "dominfo", name],
capture_output=True,
text=True,
timeout=15,
)
return res.returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def _qemu_vm_ip(self, name, timeout=90):
"""Attend puis renvoie l'IPv4 d'une VM (bail DHCP), sinon None."""
deadline = time.time() + timeout
while time.time() < deadline:
try:
res = subprocess.run(
["sudo", "virsh", "domifaddr", name, "--source", "lease"],
capture_output=True,
text=True,
timeout=15,
)
except (OSError, subprocess.SubprocessError):
return None
m = re.search(r"(\d+\.\d+\.\d+\.\d+)", res.stdout)
if m:
return m.group(1)
time.sleep(3)
return None
def _qemu_pick_branch(self):
"""Liste les branches distantes d'ERPLibre et en fait choisir une."""
print(f"\n{t('Fetching ERPLibre branch list...')}")
branches = []
try:
res = subprocess.run(
["git", "ls-remote", "--heads", self.ERPLIBRE_GIT_URL],
capture_output=True,
text=True,
timeout=30,
)
for line in res.stdout.splitlines():
ref = line.split("\t")[-1]
if ref.startswith("refs/heads/"):
branches.append(ref[len("refs/heads/") :])
except (OSError, subprocess.SubprocessError):
pass
default = (
"master"
if "master" in branches
else (branches[0] if branches else "master")
)
if not branches:
return (
input(f"{t('Branch (default:')} {default}): ").strip()
or default
)
branches.sort()
print(f"{t('Branches:')}")
for i, b in enumerate(branches, 1):
star = " *" if b == default else ""
print(f" [{i}] {b}{star}")
sel = input(f"{t('Choice (number or name, default:')} {default}): ")
sel = sel.strip()
if not sel:
return default
try:
idx = int(sel) - 1
if 0 <= idx < len(branches):
return branches[idx]
except ValueError:
if sel in branches:
return sel
return default
def _qemu_install_erplibre_vm(self, name, ssh_key, branch):
"""Clone ERPLibre (branche donnée) dans ~/git/erplibre de la VM."""
ip = self._qemu_vm_ip(name)
if not ip:
print(f" {name}: {t('no IP obtained, ERPLibre clone skipped.')}")
return
remote = (
"set -e; mkdir -p ~/git; "
"command -v git >/dev/null 2>&1 || "
"{ sudo apt-get install -y git || sudo dnf install -y git; }; "
"if [ -d ~/git/erplibre/.git ]; then "
"echo 'ERPLibre already present'; else "
f"git clone --branch {shlex.quote(branch)} "
f"{self.ERPLIBRE_GIT_URL} ~/git/erplibre; fi"
)
ssh_opts = (
"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "
"-o ConnectTimeout=15"
)
cmd = f"ssh {ssh_opts} erplibre@{ip} {shlex.quote(remote)}"
print(f"\n 📦 {name} ({ip}): {t('cloning ERPLibre')} ({branch})")
print(f" {t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
def _qemu_deploy_infra(self):
print(f"🏗 {t('Deploy ERPLibre infra: one minimal VM per image')}")
try:
mod = self._qemu_import_module()
except Exception as exc:
print(f"{t('Cannot load QEMU catalog: ')}{exc}")
return
distros = list(mod.DISTROS)
# 1) Distributions (multi-sélection) ou catalogue complet.
print(f"\n{t('Distributions:')}")
for i, d in enumerate(distros, 1):
vers = ", ".join(mod.DISTROS[d][0])
print(f" [{i}] {d} ({vers})")
print(f" [all] {t('Whole catalog (every version)')}")
raw = input(t("Selection (numbers, or 'all', default: all): ")).strip()
catalog_all = raw.lower() in ("", "all", "*")
sel_distros = (
distros
if catalog_all
else self._parse_index_selection(raw.lower(), distros)
)
if not sel_distros:
print(t("Nothing selected."))
return
# 2) Versions par distro (multi-sélection) ; « all » si catalogue.
selected = [] # (distro, version, ram_mb, disk_str)
for d in sel_distros:
versions_map = mod.DISTROS[d][0]
vlist = list(versions_map)
if catalog_all:
chosen = vlist
else:
print(f"\n{t('Versions for')} {d} :")
for i, v in enumerate(vlist, 1):
_c, _o, ram, disk = versions_map[v]
print(f" [{i}] {v} (RAM≥{ram}Mo, {disk})")
print(f" [all] {t('select all')}")
r = input(
t("Selection (numbers, or 'all', default: all): ")
).strip()
chosen = (
vlist
if r.lower() in ("", "all", "*")
else self._parse_index_selection(r.lower(), vlist)
)
for v in chosen:
_c, _o, ram, disk = versions_map[v]
selected.append((d, v, ram, disk))
if not selected:
print(t("Nothing selected."))
return
# 3) Plan + estimation des ressources.
total_ram = sum(s[2] for s in selected)
total_disk = sum(self._parse_disk_gb(s[3]) for s in selected)
free_ram = self._host_free_ram_mb()
print(f"\n{t('Deployment plan')} ({len(selected)} VM) :")
for d, v, ram, disk in selected:
name = self._qemu_infra_name(d, v)
print(f" - {name:<26} {d} {v:<7} RAM {ram}Mo {t('disk')} {disk}")
print(f"\n {t('Total RAM (all running):')} {total_ram} Mo")
print(f" {t('Total virtual disk (thin qcow2):')} ~{total_disk} G")
if free_ram:
print(f" {t('Host RAM available:')} {free_ram} Mo")
if total_ram > free_ram:
warn = t(
"Total RAM exceeds host free RAM: not all VMs will run"
" at once."
)
print(f"{warn}")
# Clé SSH (partagée par tout le parc).
default_key = self._qemu_default_ssh_key()
key_hint = default_key or t("none")
ssh_key = input(f"{t('SSH public key path')} ({key_hint}): ").strip()
if not ssh_key:
ssh_key = default_key
if ssh_key:
ssh_key = os.path.expanduser(ssh_key)
# 4) Option : installer ERPLibre dans ~/git/erplibre de chaque VM.
install_branch = None
ans = input(
t("Install ERPLibre into ~/git/erplibre on each VM? (y/N): ")
)
if self._is_yes(ans):
install_branch = self._qemu_pick_branch()
# 5) Confirmation puis déploiement séquentiel.
ans = input(f"\n{t('Deploy these VMs now? (y/N): ')}")
if not self._is_yes(ans):
print(t("Cancelled."))
return
script_path = self._qemu_script_path()
deployed = []
for d, v, _ram, _disk in selected:
name = self._qemu_infra_name(d, v)
if self._qemu_domain_exists(name):
print(f"\n{name}: {t('already exists, skipped.')}")
deployed.append(name)
continue
parts = [
"sudo",
script_path,
"--distro",
d,
"--version",
v,
"--name",
name,
]
if ssh_key:
parts += ["--ssh-key", ssh_key]
if install_branch:
parts += ["--package", "git"]
parts.append("-y")
cmd = " ".join(shlex.quote(p) for p in parts)
print(f"\n{name}: {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
deployed.append(name)
# 6) Installation ERPLibre (clone) si demandée.
if install_branch:
print(f"\n{t('Cloning ERPLibre on each VM')} ({install_branch})…")
for name in deployed:
self._qemu_install_erplibre_vm(name, ssh_key, install_branch)
print(f"\n{t('ERPLibre infra deployment done.')}")
print(f" {t('Manage with:')} sudo virsh list --all")
def _deploy_clone_erplibre(self): def _deploy_clone_erplibre(self):
default_path = os.path.expanduser("~/erplibre") default_path = os.path.expanduser("~/erplibre")
target_path = ( target_path = (
@ -1935,8 +1544,10 @@ class TODO:
if os.path.exists(dest_file): if os.path.exists(dest_file):
print(f"{t('File already exists: ')}{dest_file}") print(f"{t('File already exists: ')}{dest_file}")
overwrite = input(t("Do you want to overwrite the file? (y/Y): ")) overwrite = input(
if not self._is_yes(overwrite): t("Do you want to overwrite the file? (y/Y): ")
).strip()
if overwrite not in ("y", "Y"):
print(t("Nothing to do.")) print(t("Nothing to do."))
return return
@ -2421,8 +2032,11 @@ class TODO:
print(f"\n{t('Tests failed with return code')} {status_code}") print(f"\n{t('Tests failed with return code')} {status_code}")
# Step 4: Cleanup # Step 4: Cleanup
keep_input = input(t("Keep the temporary database? (y/N): ")) lang = get_lang()
keep = self._is_yes(keep_input) keep_input = (
input(t("Keep the temporary database? (y/N): ")).strip().lower()
)
keep = keep_input in (("o", "oui") if lang == "fr" else ("y", "yes"))
if keep: if keep:
print(f"{t('Database kept')}: {db_name}") print(f"{t('Database kept')}: {db_name}")
else: else:
@ -2747,7 +2361,7 @@ class TODO:
git_repo_update_input = input( git_repo_update_input = input(
"💬 Would you like to fetch all your git repositories, you need it (y/Y) : " "💬 Would you like to fetch all your git repositories, you need it (y/Y) : "
) )
if self._is_yes(git_repo_update_input): if git_repo_update_input.strip().lower() == "y":
status = self.execute.exec_command_live( status = self.execute.exec_command_live(
f"./script/manifest/update_manifest_local_dev.sh", f"./script/manifest/update_manifest_local_dev.sh",
source_erplibre=False, source_erplibre=False,
@ -2865,7 +2479,7 @@ class TODO:
do_personalize = input( do_personalize = input(
"Do you want to personalize the mobile application (Y) : " "Do you want to personalize the mobile application (Y) : "
) )
if self._is_yes(do_personalize): if do_personalize.strip().lower() == "y":
project_name = ( project_name = (
input( input(
f'Your project name (Separate by space in title), default "{default_project_name}" : ' f'Your project name (Separate by space in title), default "{default_project_name}" : '
@ -2890,14 +2504,19 @@ class TODO:
).strip() ).strip()
or default_project_note_subject or default_project_note_subject
) )
do_debug = self._is_yes( do_debug = (
input("Compilation with debug information, default No (Y) : ") input("Compilation with debug information, default No (Y) : ")
.strip()
.lower()
== "y"
) )
do_change_picture_menu = self._is_yes( do_change_picture_menu = (
input( input(
"Want to change picture from menu, you need" "Want to change picture from menu, you need android-studio (Y) : "
" android-studio (Y) : "
) )
.strip()
.lower()
== "y"
) )
# Rename with script bash # Rename with script bash

View file

@ -997,17 +997,17 @@ TRANSLATIONS = {
"fr": "Script de déploiement QEMU introuvable : ", "fr": "Script de déploiement QEMU introuvable : ",
"en": "QEMU deploy script not found: ", "en": "QEMU deploy script not found: ",
}, },
"Deploy a new VM": { "Deploy a new Ubuntu VM": {
"fr": "Déployer une nouvelle VM", "fr": "Déployer une nouvelle VM Ubuntu",
"en": "Deploy a new VM", "en": "Deploy a new Ubuntu VM",
}, },
"Preview a deployment (dry-run, no sudo)": { "Preview a deployment (dry-run, no sudo)": {
"fr": "Prévisualiser un déploiement (dry-run, sans sudo)", "fr": "Prévisualiser un déploiement (dry-run, sans sudo)",
"en": "Preview a deployment (dry-run, no sudo)", "en": "Preview a deployment (dry-run, no sudo)",
}, },
"Download a cloud image only": { "Download an Ubuntu cloud image only": {
"fr": "Télécharger seulement une image cloud", "fr": "Télécharger seulement une image cloud Ubuntu",
"en": "Download a cloud image only", "en": "Download an Ubuntu cloud image only",
}, },
"List VMs (virsh list --all)": { "List VMs (virsh list --all)": {
"fr": "Lister les VM (virsh list --all)", "fr": "Lister les VM (virsh list --all)",
@ -1017,142 +1017,17 @@ TRANSLATIONS = {
"fr": "Afficher l'adresse IP d'une VM", "fr": "Afficher l'adresse IP d'une VM",
"en": "Show a VM IP address", "en": "Show a VM IP address",
}, },
"List available images and specs": { "Ubuntu version:": {
"fr": "Lister les images disponibles et leurs specs", "fr": "Version Ubuntu :",
"en": "List available images and specs", "en": "Ubuntu version:",
}, },
"Distribution:": { "Choice (number or version, default: 24.04): ": {
"fr": "Distribution :", "fr": "Choix (numéro ou version, défaut : 24.04) : ",
"en": "Distribution:", "en": "Choice (number or version, default: 24.04): ",
}, },
"Choice (number or name, default: ubuntu): ": { "Invalid selection, using 24.04": {
"fr": "Choix (numéro ou nom, défaut : ubuntu) : ", "fr": "Sélection invalide, utilisation de 24.04",
"en": "Choice (number or name, default: ubuntu): ", "en": "Invalid selection, using 24.04",
},
"Invalid selection, using ubuntu": {
"fr": "Sélection invalide, utilisation d'ubuntu",
"en": "Invalid selection, using ubuntu",
},
"Version for": {
"fr": "Version pour",
"en": "Version for",
},
"Choice (number or version, blank = default):": {
"fr": "Choix (numéro ou version, vide = défaut) :",
"en": "Choice (number or version, blank = default):",
},
"Invalid selection, using": {
"fr": "Sélection invalide, utilisation de",
"en": "Invalid selection, using",
},
"select all": {
"fr": "tout sélectionner",
"en": "select all",
},
"Deploy ERPLibre infra (one minimal VM per image)": {
"fr": "Déployer l'infra ERPLibre (une VM minimale par image)",
"en": "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",
"en": "Deploy ERPLibre infra: one minimal VM per image",
},
"Cannot load QEMU catalog: ": {
"fr": "Impossible de charger le catalogue QEMU : ",
"en": "Cannot load QEMU catalog: ",
},
"Distributions:": {
"fr": "Distributions :",
"en": "Distributions:",
},
"Whole catalog (every version)": {
"fr": "Tout le catalogue (chaque version)",
"en": "Whole catalog (every version)",
},
"Selection (numbers, or 'all', default: all): ": {
"fr": "Sélection (numéros, ou « all », défaut : all) : ",
"en": "Selection (numbers, or 'all', default: all): ",
},
"Nothing selected.": {
"fr": "Rien de sélectionné.",
"en": "Nothing selected.",
},
"Deployment plan": {
"fr": "Plan de déploiement",
"en": "Deployment plan",
},
"disk": {
"fr": "disque",
"en": "disk",
},
"Total RAM (all running):": {
"fr": "RAM totale (toutes actives) :",
"en": "Total RAM (all running):",
},
"Total virtual disk (thin qcow2):": {
"fr": "Disque virtuel total (qcow2 thin) :",
"en": "Total virtual disk (thin qcow2):",
},
"Host RAM available:": {
"fr": "RAM disponible de l'hôte :",
"en": "Host RAM available:",
},
"Total RAM exceeds host free RAM: not all VMs will run at once.": {
"fr": "La RAM totale dépasse la RAM libre de l'hôte : les VM ne "
"tourneront pas toutes en même temps.",
"en": "Total RAM exceeds host free RAM: not all VMs will run at once.",
},
"Install ERPLibre into ~/git/erplibre on each VM? (y/N): ": {
"fr": "Installer ERPLibre dans ~/git/erplibre sur chaque VM ? (o/N) : ",
"en": "Install ERPLibre into ~/git/erplibre on each VM? (y/N): ",
},
"Deploy these VMs now? (y/N): ": {
"fr": "Déployer ces VM maintenant ? (o/N) : ",
"en": "Deploy these VMs now? (y/N): ",
},
"Cancelled.": {
"fr": "Annulé.",
"en": "Cancelled.",
},
"already exists, skipped.": {
"fr": "existe déjà, ignorée.",
"en": "already exists, skipped.",
},
"Cloning ERPLibre on each VM": {
"fr": "Clonage d'ERPLibre sur chaque VM",
"en": "Cloning ERPLibre on each VM",
},
"ERPLibre infra deployment done.": {
"fr": "Déploiement de l'infra ERPLibre terminé.",
"en": "ERPLibre infra deployment done.",
},
"Manage with:": {
"fr": "Gérer avec :",
"en": "Manage with:",
},
"Fetching ERPLibre branch list...": {
"fr": "Récupération de la liste des branches ERPLibre...",
"en": "Fetching ERPLibre branch list...",
},
"Branch (default:": {
"fr": "Branche (défaut :",
"en": "Branch (default:",
},
"Branches:": {
"fr": "Branches :",
"en": "Branches:",
},
"Choice (number or name, default:": {
"fr": "Choix (numéro ou nom, défaut :",
"en": "Choice (number or name, default:",
},
"no IP obtained, ERPLibre clone skipped.": {
"fr": "aucune IP obtenue, clonage ERPLibre ignoré.",
"en": "no IP obtained, ERPLibre clone skipped.",
},
"cloning ERPLibre": {
"fr": "clonage d'ERPLibre",
"en": "cloning ERPLibre",
}, },
"VM name (required): ": { "VM name (required): ": {
"fr": "Nom de la VM (requis) : ", "fr": "Nom de la VM (requis) : ",
@ -1166,21 +1041,17 @@ TRANSLATIONS = {
"fr": "Nom de la VM : ", "fr": "Nom de la VM : ",
"en": "VM name: ", "en": "VM name: ",
}, },
"VM name or ID: ": { "RAM in MB (default: 8192): ": {
"fr": "Nom ou ID de la VM : ", "fr": "RAM en Mo (défaut : 8192) : ",
"en": "VM name or ID: ", "en": "RAM in MB (default: 8192): ",
}, },
"RAM in MB (blank = version minimum): ": { "vCPUs (default: 4): ": {
"fr": "RAM en Mo (vide = minimum de la version) : ", "fr": "vCPU (défaut : 4) : ",
"en": "RAM in MB (blank = version minimum): ", "en": "vCPUs (default: 4): ",
}, },
"vCPUs (default: 2): ": { "Disk size (default: 20G): ": {
"fr": "vCPU (défaut : 2) : ", "fr": "Taille du disque (défaut : 20G) : ",
"en": "vCPUs (default: 2): ", "en": "Disk size (default: 20G): ",
},
"Disk size (blank = version minimum): ": {
"fr": "Taille du disque (vide = minimum de la version) : ",
"en": "Disk size (blank = version minimum): ",
}, },
"SSH public key path": { "SSH public key path": {
"fr": "Chemin de la clé publique SSH", "fr": "Chemin de la clé publique SSH",