From b664128a055f54702362bfd31605dee55f9498d6 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 1 Aug 2026 05:37:02 -0400 Subject: [PATCH] [IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resources per VM offered x1..x4 only — anything else meant deploying with the wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32. The two places ask the SAME three questions, so they are one definition each (_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that would drift. vCPU is no longer a single global value: it travels per VM through the whole chain, down to --vcpus. Overcommit is allowed on an explicit choice — KVM permits more vCPU than cores — and only warned about. The x1..x4 path still caps at the host core count: that one is an automatic computation, not a decision. Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they are what one wants nearly every time, and typing « o » on each was noise. Flipping a destructive-by-omission default demanded the guards below. Before deploying, a final review lists everything that will change — VMs to create with their effective disk (ERPLibre's +5 G included), VMs left untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism. Answering no asks for confirmation rather than dropping every answer given over a dozen prompts. Name collisions are now reported BEFORE the wait, with their two very different consequences: an already-defined VM is skipped and nothing is overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail, since it refuses to overwrite without --force. Continuing requires an explicit yes; the default is no. Also translated two strings of this flow that had no entry and stayed in English (« Resolving VM IPs… », « no IP »). Verified by driving the prompts with scripted answers: custom profile applied and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but accepted, review with and without ERPLibre install, no → no → deploy, no → yes → cancel, and collisions defaulting to no. Rendering checked in fr and en. Co-Authored-By: Claude Opus 4.8 (1M context) --- script/todo/todo.py | 339 ++++++++++++++++++++++++++++++--------- script/todo/todo_i18n.py | 159 ++++++++++++++++-- 2 files changed, 414 insertions(+), 84 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index 47cbd15..577455c 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -3579,6 +3579,10 @@ class TODO: 131072, 262144, ) + # KVM autorise plus de vCPU que de cœurs (surengagement) : on prévient + # plutôt que d'écrêter, contrairement au multiplicateur x1..x4 qui, lui, + # est un calcul automatique et se borne aux cœurs de l'hôte. + _QEMU_CPU_PRESETS = (1, 2, 4, 6, 8, 16, 24, 32) @staticmethod def _plural(word, count): @@ -3634,15 +3638,72 @@ class TODO: return "" return answer + # Les trois invites ci-dessous sont posées à DEUX endroits — le profil + # global « Personnalisé » et la personnalisation par VM. Elles vivent donc + # ici : une seule définition, mêmes suggestions, mêmes validations. + # Chacune renvoie None pour « garder la valeur actuelle ». + + def _qemu_ask_disk(self, label, current): + raw = self._qemu_ask_value(label, current, self._QEMU_DISK_PRESETS) + if not raw: + return None + parsed = self._qemu_parse_disk(raw) + if not parsed: + print(f" ⚠ {t('Invalid size.')}") + return parsed + + def _qemu_ask_ram(self, label, current): + raw = self._qemu_ask_value( + label, current, self._QEMU_RAM_PRESETS, fmt=self._qemu_ram_label + ) + if not raw: + return None + try: + mb = int(raw) + except ValueError: + mb = 0 + if mb <= 0: + print(f" ⚠ {t('Invalid size.')}") + return None + return mb + + def _qemu_ask_cpu(self, label, current, host_cpu): + raw = self._qemu_ask_value(label, current, self._QEMU_CPU_PRESETS) + if not raw: + return None + try: + n = int(raw) + except ValueError: + n = 0 + if n <= 0: + print(f" ⚠ {t('Invalid vCPU count.')}") + return None + if n > host_cpu: + print(f" ⚠ {t('More vCPU than host cores')} ({host_cpu}).") + return n + + @staticmethod + def _qemu_shared_value(values, fmt=str): + """Valeur commune à toutes les VM, ou « varié » si elles diffèrent. + Sert d'« actuel » aux invites globales, où une seule réponse couvre un + parc qui n'est pas forcément homogène.""" + uniq = set(values) + return fmt(uniq.pop()) if len(uniq) == 1 else t("varies") + # vCPU de base (x1) par VM. Le multiplicateur monte de là. _QEMU_BASE_VCPUS = 2 - def _qemu_prompt_resource_mult(self, selected, host_cpu, free_ram): - """Multiplicateur de ressources par VM : x1 (minimum catalogue) .. x4. - Multiplie la RAM (base = minimum de la version) et les vCPU (base - _QEMU_BASE_VCPUS), en bornant les vCPU au nombre de cœurs de l'hôte et - en signalant si la RAM totale dépasse la RAM libre. Renvoie (mult, - base_vcpus).""" + def _qemu_prompt_resources(self, selected, host_cpu, free_ram): + """Ressources par VM : multiplicateur x1..x4, ou « Personnalisé ». + + x1..x4 multiplie la RAM (base = minimum de la version) et les vCPU + (base _QEMU_BASE_VCPUS) en bornant ces derniers aux cœurs de l'hôte. + « Personnalisé » pose les mêmes trois questions que la personnalisation + par VM, mais une seule fois pour tout le parc. + + `selected` = liste de (d, v, ram_min, disk, arch). Renvoie + (label, selected) où selected porte désormais les valeurs FINALES et + un vCPU par VM : (d, v, ram, disk, arch, vcpus).""" base_ram = sum(s[2] for s in selected) # RAM min totale (x1) base_vcpus = self._QEMU_BASE_VCPUS print(f"\n{t('Resources per VM (x1 = catalog minimum):')}") @@ -3664,31 +3725,63 @@ class TODO: f" [{n}] x{n}{star} {vcpus} vCPU/VM, " f"{t('total RAM')} ~{total} Mo{warn}" ) - sel = input(f"{t('Choice (1-4, default 1):')} ").strip() + print(f" [5] {t('Custom - set vCPU, RAM and disk')}") + sel = input(f"{t('Choice (1-5, default 1):')} ").strip() try: - n = int(sel) - if 1 <= n <= 4: - return n, base_vcpus + mult = int(sel) except ValueError: - pass - return 1, base_vcpus + mult = 1 + if not 1 <= mult <= 5: + mult = 1 - def _qemu_customize_vms(self, selected): - """Personnalise chaque VM avant déploiement : NOM, taille de DISQUE et - RAM. `selected` = liste de (d, v, ram, disk, a) où ram (Mo) et disk - (« 20G ») sont les valeurs FINALES (multiplicateur déjà appliqué). + if mult != 5: + vcpus = min(base_vcpus * mult, host_cpu) + return f"x{mult}", [ + (d, v, ram * mult, disk, a, vcpus) + for (d, v, ram, disk, a) in selected + ] + + # Personnalisé : une réponse vide garde la valeur du catalogue, qui + # peut différer d'une VM à l'autre — d'où « varié » comme « actuel ». + cpu = self._qemu_ask_cpu( + t("vCPU per VM, blank = keep"), base_vcpus, host_cpu + ) + ram = self._qemu_ask_ram( + t("New RAM in MB, blank = keep"), + self._qemu_shared_value([s[2] for s in selected]), + ) + disk = self._qemu_ask_disk( + t("New disk size in G, blank = keep"), + self._qemu_shared_value([s[3] for s in selected]), + ) + return t("custom"), [ + ( + d, + v, + ram or vram, + disk or vdisk, + a, + cpu or base_vcpus, + ) + for (d, v, vram, vdisk, a) in selected + ] + + def _qemu_customize_vms(self, selected, host_cpu): + """Personnalise chaque VM avant déploiement : NOM, DISQUE, RAM et vCPU. + `selected` = liste de (d, v, ram, disk, a, vcpus) où les valeurs sont + déjà FINALES (profil de ressources appliqué). Renvoie (names, selected_maj). Défaut : rien ne change.""" names = [ - self._qemu_infra_name(d, v, a) for d, v, _r, _dk, a in selected + self._qemu_infra_name(d, v, a) for d, v, _r, _dk, a, _c in selected ] - sel = [list(s) for s in selected] # mutable (d, v, ram, disk, a) + sel = [list(s) for s in selected] # mutable def show(): print(f"\n{t('VMs (default = no change):')}") for i, (nm, s) in enumerate(zip(names, sel), 1): - d, v, ram, disk, a = s + d, v, ram, disk, a, vcpus = s print( - f" [{i}] {nm} ({d} {v} [{a}]) " + f" [{i}] {nm} ({d} {v} [{a}]) {vcpus} vCPU " f"RAM {ram}Mo {t('disk')} {disk}" ) @@ -3705,38 +3798,119 @@ class TODO: continue if not (0 <= i < len(sel)): continue - # Pour la VM i : nom, disque, RAM (vide = garder la valeur). + # Pour la VM i : nom, disque, RAM, vCPU (vide = garder la valeur). new = input( f" {names[i]} — {t('new name (blank = keep):')} " ).strip() if new: names[i] = new - dk = self._qemu_ask_value( - t("New disk size in G, blank = keep"), - sel[i][3], - self._QEMU_DISK_PRESETS, + dk = self._qemu_ask_disk( + t("New disk size in G, blank = keep"), sel[i][3] ) if dk: - parsed = self._qemu_parse_disk(dk) - if parsed: - sel[i][3] = parsed - else: - print(f" ⚠ {t('Invalid size.')}") - rm = self._qemu_ask_value( - t("New RAM in MB, blank = keep"), - sel[i][2], - self._QEMU_RAM_PRESETS, - fmt=self._qemu_ram_label, + sel[i][3] = dk + rm = self._qemu_ask_ram( + t("New RAM in MB, blank = keep"), sel[i][2] ) if rm: - try: - sel[i][2] = int(rm) - except ValueError: - print(f" ⚠ {t('Invalid size.')}") + sel[i][2] = rm + cpu = self._qemu_ask_cpu( + t("New vCPU count, blank = keep"), sel[i][5], host_cpu + ) + if cpu: + sel[i][5] = cpu if len(set(names)) != len(names): print(f" ⚠ {t('Duplicate names detected; keeping as entered.')}") return names, [tuple(s) for s in sel] + def _confirm_or_discard(self, question): + """Confirmation à défaut OUI, avec double validation sur le NON. + + Un « non » distrait ferait perdre toutes les réponses déjà saisies. On + ne renonce donc que si l'abandon est confirmé ; sinon on repose la + question.""" + while True: + if self._is_yes_default_yes(input(f"\n{question}")): + return True + if self._is_yes( + input(f" {t('Discard everything and start over? (y/N): ')}") + ): + return False + + def _qemu_confirm_collisions(self, deployed, pending): + """Signale les noms qui heurtent l'existant, et demande confirmation. + + Deux cas, de gravité différente : une VM déjà définie est simplement + ignorée — rien n'est écrasé — tandis qu'un qcow2 resté seul (VM + supprimée sans son disque) fait échouer deploy_qemu, qui refuse + d'écraser sans --force. Défaut NON : on ne poursuit que sur un « oui » + explicite.""" + orphans = [] + for name, *_rest in pending: + path = f"/var/lib/libvirt/images/{name}.qcow2" + if os.path.exists(path): + orphans.append((name, path)) + if not deployed and not orphans: + return True + print(f"\n⚠ {t('Name collisions detected')} :") + skipped = t("VM already defined - SKIPPED, nothing overwritten") + for name in deployed: + print(f" {name:<28.28} {skipped}") + for name, path in orphans: + print( + f" {name:<28.28} " + f"{t('disk present without VM - deployment will FAIL')}" + ) + print(f" {'':<28} {path}") + print(f" {'':<28} {t('Remove it by hand, or rename the VM.')}") + return self._is_yes( + input(f"{t('Continue despite these collisions? (y/N): ')}") + ) + + def _qemu_print_recap( + self, + pending, + deployed, + branch, + install_label, + install_prod, + ssh_key, + add_ssh_config, + parallelism, + ): + """État final soumis à approbation : tout ce qui va changer sur + l'hôte, y compris ce qui ne changera PAS (VM existantes).""" + print(f"\n── {t('Final review before deployment')} ──") + print(f" {t('VMs to create:')} {len(pending)}") + for name, d, v, ram, disk, a, vcpus in pending: + # Le disque annoncé est celui qui sera réellement créé : ERPLibre + # ajoute ERPLIBRE_EXTRA_DISK_GB à la demande initiale. + gigs = self._parse_disk_gb(disk) + ( + self.ERPLIBRE_EXTRA_DISK_GB if branch else 0 + ) + print( + f" {name:<30} {d} {v:<7} [{a:<5}] " + f"{vcpus} vCPU RAM {ram}Mo {t('disk')} {gigs}G" + ) + if deployed: + print(f" {t('Existing, left untouched:')} {', '.join(deployed)}") + if branch: + env = ( + t("production (/opt, confined)") + if install_prod + else t("development (~/git)") + ) + print( + f" {t('ERPLibre install:')} {t('branch')} {branch}, " + f"{t('profile')} {install_label}, {env}" + ) + else: + print(f" {t('ERPLibre install:')} {t('no')}") + print(f" {t('SSH key:')} {ssh_key or t('none')}") + cfg = t("one entry per VM") if add_ssh_config else t("untouched") + print(f" {t('~/.ssh/config:')} {cfg}") + print(f" {t('Parallelism:')} {parallelism} {t('at a time')}") + def _qemu_build_deploy_parts( self, d, v, arch, name, eram, evcpus, disk, ssh_key, branch, dry_run ): @@ -3917,35 +4091,35 @@ class TODO: print(t("Nothing selected.")) return - # 2b) Multiplicateur de ressources par VM (x1 = minimum .. x4) : monte - # la RAM et les vCPU selon ce que l'hôte peut fournir. + # 2b) Ressources par VM : multiplicateur x1..x4 ou « Personnalisé ». + # Le profil est CUIT dans `selected`, qui porte dès lors les valeurs + # finales — RAM, disque et vCPU — pour chaque VM. host_cpu = os.cpu_count() or 2 free_ram = self._host_free_ram_mb() - res_mult, base_vcpus = self._qemu_prompt_resource_mult( + res_label, selected = self._qemu_prompt_resources( selected, host_cpu, free_ram ) - # On CUIT le multiplicateur dans `selected` (RAM finale) et on fixe un - # nombre de vCPU uniforme -> la personnalisation par VM (RAM absolue) - # n'est plus ambiguë. vCPU n'est pas éditable par VM (choix global). - evcpus = min(base_vcpus * res_mult, host_cpu) - selected = [ - (d, v, ram * res_mult, disk, a) - for (d, v, ram, disk, a) in selected - ] - # 2c) Personnalisation par VM : nom, disque, RAM (à la demande). - names, selected = self._qemu_customize_vms(selected) + # 2c) Personnalisation par VM : nom, disque, RAM, vCPU (à la demande). + names, selected = self._qemu_customize_vms(selected, host_cpu) # 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) - print(f"\n{t('Deployment plan')} ({len(selected)} VM, x{res_mult}) :") - for i, (d, v, ram, disk, a) in enumerate(selected): + total_cpu = sum(s[5] for s in selected) + print(f"\n{t('Deployment plan')} ({len(selected)} VM, {res_label}) :") + for i, (d, v, ram, disk, a, vcpus) in enumerate(selected): print( f" - {names[i]:<30} {d} {v:<7} [{a:<5}] " - f"{evcpus} vCPU RAM {ram}Mo {t('disk')} {disk}" + f"{vcpus} vCPU RAM {ram}Mo {t('disk')} {disk}" ) - print(f"\n {t('Total RAM (all running):')} {total_ram} Mo") + cpu_warn = ( + f" ⚠ {t('> host cores')} ({host_cpu})" + if (total_cpu > host_cpu) + else "" + ) + print(f"\n {t('Total vCPU (all running):')} {total_cpu}{cpu_warn}") + print(f" {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") @@ -3961,14 +4135,14 @@ class TODO: if dry_run: default_key = self._qemu_default_ssh_key() print(f"\n{t('Preview (dry-run):')}") - for i, (d, v, ram, disk, a) in enumerate(selected): + for i, (d, v, ram, disk, a, vcpus) in enumerate(selected): parts = self._qemu_build_deploy_parts( d, v, a, names[i], ram, - evcpus, + vcpus, disk, default_key, None, @@ -3993,35 +4167,45 @@ class TODO: False # dev (~/git, SELinux dev) vs prod (/opt, confiné) ) install_cmd = None # commande finale selon le profil choisi + install_label = None ans = input( - t("Install ERPLibre into ~/git/erplibre on each VM? (y/N): ") + t("Install ERPLibre into ~/git/erplibre on each VM? (Y/n): ") ) - if self._is_yes(ans): + if self._is_yes_default_yes(ans): install_branch = self._qemu_pick_branch() install_prod = self._qemu_ask_prod() - _label, install_cmd = self._qemu_pick_install_profile() + install_label, install_cmd = self._qemu_pick_install_profile() install_monitor = self._is_yes_default_yes( input(t("Interactive monitoring dashboard? (y/N): ")) ) - add_ssh_config = self._is_yes( - input(t("Add each VM to ~/.ssh/config? (y/N): ")) + add_ssh_config = self._is_yes_default_yes( + input(t("Add each VM to ~/.ssh/config? (Y/n): ")) ) # 5) Sépare les VM à CRÉER des déjà existantes AVANT de proposer le # parallélisme : on connaît alors le vrai nombre à déployer (affiché # dans le prompt) et on peut numéroter chaque tâche. - pending = [] # (name, d, v, ram, disk, a) + pending = [] # (name, d, v, ram, disk, a, vcpus) deployed = [] - for i, (d, v, ram, disk, a) in enumerate(selected): + for i, (d, v, ram, disk, a, vcpus) in enumerate(selected): name = names[i] if self._qemu_domain_exists(name): - print(f"⏭ {name}: {t('already exists, skipped.')}") deployed.append(name) else: - pending.append((name, d, v, ram, disk, a)) + pending.append((name, d, v, ram, disk, a, vcpus)) n_jobs = len(pending) + # Collisions de noms : une VM déjà définie est ignorée (rien n'est + # écrasé), mais un qcow2 orphelin fera ÉCHOUER deploy_qemu, qui refuse + # d'écraser sans --force. On le dit avant, pas après l'attente. + if not self._qemu_confirm_collisions(deployed, pending): + print(t("Cancelled.")) + return + if not pending: + print(t("Nothing to create - every VM already exists.")) + return + # Nombre de déploiements en parallèle. Défaut = nombre de CPU de l'hôte # (borné par le nombre de VM). On affiche aussi le NB DE VM à déployer. default_par = min(n_jobs, os.cpu_count() or 4) or 1 @@ -4034,23 +4218,34 @@ class TODO: except ValueError: parallelism = default_par - # Confirmation puis déploiement (en parallèle). - ans = input(f"\n{t('Deploy these VMs now? (y/N): ')}") - if not self._is_yes(ans): + # 6) Récapitulatif final, puis confirmation. Toutes les réponses + # données jusqu'ici sont rassemblées ici : c'est le dernier point où + # une erreur de saisie se rattrape sans avoir rien créé. + self._qemu_print_recap( + pending, + deployed, + install_branch, + install_label, + install_prod, + ssh_key, + add_ssh_config, + parallelism, + ) + if not self._confirm_or_discard(t("Deploy these VMs now? (Y/n): ")): print(t("Cancelled.")) return # Jobs numérotés (k/N) : l'ID suit l'ORDRE de préparation, stable même # si les résultats reviennent dans le désordre (exécution parallèle). jobs = [] # (id, name, parts) - for k, (name, d, v, ram, disk, a) in enumerate(pending, 1): + for k, (name, d, v, ram, disk, a, vcpus) in enumerate(pending, 1): parts = self._qemu_build_deploy_parts( d, v, a, name, ram, - evcpus, + vcpus, disk, ssh_key, install_branch, diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 4f72ef1..f044b47 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -262,9 +262,9 @@ TRANSLATIONS = { "fr": "Ajouter cette VM à ~/.ssh/config ? (o/N, défaut : non) : ", "en": "Add this VM to ~/.ssh/config? (y/N, default: no): ", }, - "Add each VM to ~/.ssh/config? (y/N): ": { - "fr": "Ajouter chaque VM à ~/.ssh/config ? (o/N, défaut : non) : ", - "en": "Add each VM to ~/.ssh/config? (y/N, default: no): ", + "Add each VM to ~/.ssh/config? (Y/n): ": { + "fr": "Ajouter chaque VM à ~/.ssh/config ? (O/n, défaut : oui) : ", + "en": "Add each VM to ~/.ssh/config? (Y/n, default: yes): ", }, "Waiting for the VM IP (DHCP lease)...": { "fr": "Attente de l'IP de la VM (bail DHCP)...", @@ -1249,6 +1249,46 @@ TRANSLATIONS = { "fr": "Choix (1-4, défaut 1) :", "en": "Choice (1-4, default 1):", }, + "Choice (1-5, default 1):": { + "fr": "Choix (1-5, défaut 1) :", + "en": "Choice (1-5, default 1):", + }, + "Custom - set vCPU, RAM and disk": { + "fr": "Personnalisé — choisir vCPU, RAM et disque", + "en": "Custom — set vCPU, RAM and disk", + }, + "custom": { + "fr": "personnalisé", + "en": "custom", + }, + "varies": { + "fr": "varié", + "en": "varies", + }, + "vCPU per VM, blank = keep": { + "fr": "vCPU par VM, vide = garder", + "en": "vCPU per VM, blank = keep", + }, + "New vCPU count, blank = keep": { + "fr": "Nouveau nombre de vCPU, vide = garder", + "en": "New vCPU count, blank = keep", + }, + "Invalid vCPU count.": { + "fr": "Nombre de vCPU invalide.", + "en": "Invalid vCPU count.", + }, + "More vCPU than host cores": { + "fr": "Plus de vCPU que de cœurs sur l'hôte", + "en": "More vCPU than host cores", + }, + "> host cores": { + "fr": "> cœurs de l'hôte", + "en": "> host cores", + }, + "Total vCPU (all running):": { + "fr": "vCPU total (toutes démarrées) :", + "en": "Total vCPU (all running):", + }, "Deploy VM(s) (one or many)": { "fr": "🚀 Déployer une ou plusieurs VM", "en": "🚀 Deploy VM(s) (one or many)", @@ -2205,21 +2245,116 @@ TRANSLATIONS = { "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, défaut : non) : ", - "en": "Install ERPLibre into ~/git/erplibre on each VM? (y/N, default: no): ", + "Install ERPLibre into ~/git/erplibre on each VM? (Y/n): ": { + "fr": "Installer ERPLibre dans ~/git/erplibre sur chaque VM ? " + "(O/n, défaut : oui) : ", + "en": "Install ERPLibre into ~/git/erplibre on each VM? " + "(Y/n, default: yes): ", }, - "Deploy these VMs now? (y/N): ": { - "fr": "Déployer ces VM maintenant ? (o/N, défaut : non) : ", - "en": "Deploy these VMs now? (y/N, default: no): ", + "Deploy these VMs now? (Y/n): ": { + "fr": "Déployer ces VM maintenant ? (O/n, défaut : oui) : ", + "en": "Deploy these VMs now? (Y/n, default: yes): ", + }, + "Discard everything and start over? (y/N): ": { + "fr": "Tout abandonner et recommencer ? (o/N, défaut : non) : ", + "en": "Discard everything and start over? (y/N, default: no): ", + }, + "Final review before deployment": { + "fr": "Récapitulatif final avant déploiement", + "en": "Final review before deployment", + }, + "VMs to create:": { + "fr": "VM à créer :", + "en": "VMs to create:", + }, + "Existing, left untouched:": { + "fr": "Existantes, laissées intactes :", + "en": "Existing, left untouched:", + }, + "ERPLibre install:": { + "fr": "Installation ERPLibre :", + "en": "ERPLibre install:", + }, + "profile": { + "fr": "profil", + "en": "profile", + }, + "branch": { + "fr": "branche", + "en": "branch", + }, + "production (/opt, confined)": { + "fr": "production (/opt, confiné)", + "en": "production (/opt, confined)", + }, + "development (~/git)": { + "fr": "développement (~/git)", + "en": "development (~/git)", + }, + "no": { + "fr": "non", + "en": "no", + }, + "SSH key:": { + "fr": "Clé SSH :", + "en": "SSH key:", + }, + "~/.ssh/config:": { + "fr": "~/.ssh/config :", + "en": "~/.ssh/config:", + }, + "one entry per VM": { + "fr": "une entrée par VM", + "en": "one entry per VM", + }, + "untouched": { + "fr": "inchangé", + "en": "untouched", + }, + "Parallelism:": { + "fr": "Parallélisme :", + "en": "Parallelism:", + }, + "at a time": { + "fr": "à la fois", + "en": "at a time", + }, + "Nothing to create - every VM already exists.": { + "fr": "Rien à créer — toutes les VM existent déjà.", + "en": "Nothing to create — every VM already exists.", + }, + "Name collisions detected": { + "fr": "Collisions de noms détectées", + "en": "Name collisions detected", + }, + "VM already defined - SKIPPED, nothing overwritten": { + "fr": "VM déjà définie — IGNORÉE, rien ne sera écrasé", + "en": "VM already defined — SKIPPED, nothing overwritten", + }, + "disk present without VM - deployment will FAIL": { + "fr": "disque présent sans VM — le déploiement ÉCHOUERA", + "en": "disk present without VM - deployment will FAIL", + }, + "Remove it by hand, or rename the VM.": { + "fr": "L'effacer à la main, ou renommer la VM.", + "en": "Remove it by hand, or rename the VM.", + }, + "Continue despite these collisions? (y/N): ": { + "fr": "Continuer malgré ces collisions ? (o/N, défaut : non) : ", + "en": "Continue despite these collisions? (y/N, default: no): ", }, "Cancelled.": { "fr": "Annulé.", "en": "Cancelled.", }, - "already exists, skipped.": { - "fr": "existe déjà, ignorée.", - "en": "already exists, skipped.", + "Resolving VM IPs (parallel, emulated boot is slow)...": { + "fr": "Résolution des IP des VM (en parallèle, le démarrage émulé " + "est lent)...", + "en": "Resolving VM IPs (parallel, emulated boot is slow)...", + }, + "no IP": { + "fr": "pas d'IP", + "en": "no IP", }, "Cloning ERPLibre on each VM": { "fr": "Clonage d'ERPLibre sur chaque VM",