[REF] déploiement : la branche, le profil et le type se choisissent par VM

Sur Proxmox on déploie le plus souvent un parc MIXTE — un hyperviseur
imbriqué à côté de VM ERPLibre. C'est exactement le cas où un réglage par
machine sert, et c'est le seul écran qui ne l'offrait pas : ses rangées
n'avaient ni branche, ni profil, ni type.

Les trois choix et leur gestionnaire — quatre-vingts lignes — rejoignent le
socle. Les dupliquer aurait remis en place le mécanisme de dérive qu'on vient
d'enlever. L'écran QEMU/KVM perd encore 130 lignes sans qu'un widget, un
modèle ou une spec ne bouge : ancien et nouveau montés dans le même
processus, mêmes rangées, mêmes valeurs après avoir changé une branche, un
type et un profil.

Le déploiement suit : il lisait la seule valeur commune alors que le plan
portait déjà le choix par rangée. Une seule VM qui s'écarte suffit à rendre
la carte nécessaire — « len(set) > 1 » ne l'aurait pas vu.

Un défaut trouvé par un test, pas à l'usage : l'écho du montage se
reconnaissait à sa commande, or quand la commande imposée par le système
n'est pas dans la liste proposée, la liste retombe au rang 0 — et l'écho de
ce rang 0 effaçait l'imposition. Un Proxmox imbriqué reprenait ERPLibre et
Odoo 18. L'écho se reconnaît maintenant au RANG affiché.

--- EN ---

On Proxmox you usually deploy a MIXED fleet — a nested hypervisor next to
ERPLibre VMs. That is exactly where a per-machine setting earns its keep, and
it was the only screen without one: its rows had no branch, no profile, no
type.

The three choices and their handler — eighty lines — move into the shared
foundation. Duplicating them would have restored the very drift mechanism we
just removed. The QEMU/KVM screen loses another 130 lines with no widget,
model or spec moving: old and new mounted in one process, same rows, same
values after changing a branch, a type and a profile.

The deployment follows: it read the single common value while the plan
already carried the per-row choice. One VM that differs is enough to require
the map — "len(set) > 1" would not have seen it.

One defect found by a test, not by use: the mount echo was recognised by its
command, yet when the command imposed by the guest OS is absent from the
offered list, the list falls back to index 0 — and that index-0 echo erased
the imposition. A nested Proxmox took ERPLibre and Odoo 18 back. The echo is
now recognised by the DISPLAYED index.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-24 23:31:13 -04:00
parent 2462798f4e
commit 0551d807ac
7 changed files with 336 additions and 141 deletions

View file

@ -26,6 +26,10 @@ tous les fragments, et lire un widget absent ne doit pas casser l'écran.
from script.todo.deploy_form_lib import FREE, t
# « Serveur » est un CHOIX, pas une absence de choix : lui donner « » le
# rendrait indistinguable de la sentinelle « rien de sélectionné ».
SERVER = "__server__"
# Ce que l'écran lit du contexte. Une seule liste, parce que les deux
# formulaires doivent en recevoir autant : c'est en fournissant un
# sous-ensemble que l'écran Proxmox avait perdu la moitié des réglages.
@ -122,9 +126,18 @@ class ExtrasMixin:
`render_extras()` après chaque recalcul et `extras_values()` au moment de
bâtir sa spec."""
def extras_init(self, ctx) -> None:
"""À appeler dans `__init__`, avant tout `compose`."""
def extras_init(self, ctx, branches=(), profiles=()) -> None:
"""À appeler dans `__init__`, avant tout `compose`.
`branches` et `profiles` viennent du formulaire et non du contexte :
chacun les ordonne à sa façon (branch_order) et l'écran les propose
dans cet ordre-là. Ce sont les mêmes listes que portent les rangées."""
self._extras = extras_tables(ctx)
self._extras["branches"] = list(branches)
self._extras["profiles"] = list(profiles)
self._extras["distro_profiles"] = dict(
ctx.get("distro_profiles") or {}
)
# ------------------------------------------------------------------ #
# Les widgets
@ -310,6 +323,165 @@ class ExtrasMixin:
def _extras_disk_gb(self, vm, tools) -> int:
return extras_disk_gb(vm, tools, self._extras)
# ------------------------------------------------------------------ #
# Une rangée du plan : branche, profil, type — par VM
# ------------------------------------------------------------------ #
def _branch(self) -> str:
"""Branche du formulaire : le défaut de chaque VM."""
widget = self._widget("#f_branch")
valeur = widget.value if widget is not None else None
branches = self._extras["branches"]
return valeur if isinstance(valeur, str) else (branches or [""])[0]
def _profile_cmd(self) -> str:
"""Commande du profil choisi en haut : le défaut de chaque VM."""
profiles = self._extras["profiles"]
widget = self._widget("#f_profile_install")
if not profiles or widget is None:
return ""
index = widget.value
return profiles[index if isinstance(index, int) else 0][1]
def _row_default_cmd(self, i) -> str:
"""Commande qu'une rangée prend d'elle-même : celle que son système
impose, sinon le choix commun d'en haut.
C'est le défaut CONTRE LEQUEL on compare une saisie : sur une VM
Proxmox, choisir Odoo 18 est une vraie surcharge même quand c'est
aussi la valeur commune."""
if i < len(self.rows):
impose = self._extras["distro_profiles"].get(
self.rows[i]["vm"]["distro"]
)
if impose:
return impose[1]
return self._profile_cmd()
def _row_profile_index(self, i) -> int:
"""Rang du profil que la rangée doit AFFICHER."""
cmd = ""
if i < len(self.rows):
cmd = self.rows[i]["vm"].get("install_cmd") or ""
cmd = cmd or self._row_default_cmd(i)
for k, (_lbl, c) in enumerate(self._extras["profiles"]):
if c == cmd:
return k
return 0
def _type_options(self):
return [(t("Server"), SERVER)] + [
(label, key) for key, label in self._extras["desktops"]
]
def install_row_widgets(self, i, null=None):
"""Les trois choix qu'une VM peut prendre à elle seule.
Ici et non dans un formulaire : ce sont les mêmes trois des deux
côtés, et ils manquaient à l'écran Proxmox — où l'on déploie pourtant
le plus souvent un parc MIXTE, un hyperviseur imbriqué à côté de VM
ERPLibre."""
from textual.widgets import Select, Static
tab = self._extras
if null is None:
null = getattr(Select, "NULL", Select.BLANK)
vm = self.rows[i]["vm"] if i < len(self.rows) else {}
widgets = [
Select(
[(b, b) for b in tab["branches"]],
classes="vmbranch",
# Repli sur la branche du FORMULAIRE, jamais sur branches[0] :
# les rangées sont remontées dès que le jeu de VM change (une
# entrée cochée, une copie ajoutée, un renommage), et elles
# retombaient alors toutes sur « develop » quel que soit le
# choix commun.
value=vm.get("branch") or self._branch(),
allow_blank=False,
id=f"v{i}_branch",
),
(
Select(
[(lbl, k) for k, (lbl, _c) in enumerate(tab["profiles"])],
value=self._row_profile_index(i),
allow_blank=False,
classes="vmprof",
id=f"v{i}_prof",
)
if tab["profiles"]
else Static("", classes="vmprof")
),
]
if tab["desktops"]:
widgets.append(
Select(
self._type_options(),
value=vm.get("desktop") or SERVER,
allow_blank=False,
id=f"v{i}_type",
)
)
return widgets
def extras_on_row_select(self, event, index, field, null=None) -> bool:
"""Traite branche / profil / type d'UNE rangée. True quand c'est fait.
Ce que ces trois partagent, et qui n'est pas évident : poser
« value= » au montage fait émettre un Changed que Textual délivre
APRÈS coup, et un verrou temporel ne l'attrape pas — mesuré, les
champs de chaque VM se retrouvaient surchargés dès l'affichage et le
profil x1..x4 devenait inopérant. On compare donc à ce que le modèle
dit DÉJÀ : une valeur identique n'est pas une saisie, c'est l'écho.
Cas limite assumé : choisir explicitement la valeur que le profil
donne déjà n'enregistre pas de surcharge. La VM suivra donc le profil
s'il change — ce qui est aussi le plus attendu quand on n'a rien
changé de visible."""
if field not in ("branch", "prof", "type"):
return False
vm_now = self.rows[index]["vm"]
if field == "prof":
# L'écho se reconnaît à ceci : la valeur est CELLE QU'ON VIENT
# D'AFFICHER. Comparer les commandes ne suffisait pas — quand la
# commande imposée par le système n'est pas dans la liste
# proposée, la liste retombe sur le rang 0, et l'écho de ce
# rang 0 effaçait l'imposition. Un Proxmox imbriqué reprenait
# ainsi ERPLibre et Odoo 18, le défaut qu'on venait de corriger.
if event.value == self._row_profile_index(index):
return True
label, cmd = self._extras["profiles"][event.value]
defaut = self._row_default_cmd(index)
if cmd == (vm_now.get("install_cmd") or defaut):
return True
meme = cmd == defaut
self._set_override(index, "install_cmd", "" if meme else cmd)
self._set_override(index, "install_label", "" if meme else label)
self._recompute()
return True
if field == "branch":
# « la branche du formulaire » n'est pas une surcharge : la VM
# doit suivre si on la change en haut.
if event.value == (vm_now.get("branch") or self._branch()):
return True
self._set_override(
index,
"branch",
"" if event.value == self._branch() else event.value,
)
self._recompute()
return True
nouveau = "" if event.value == SERVER else event.value
if nouveau == (vm_now.get("desktop") or ""):
return True
self._set_override(index, "desktop", nouveau)
# « Serveur » est un choix légitime, pas un retrait : on le note
# explicitement pour qu'il tienne face au défaut.
if event.value == SERVER:
cle = self._row_key(index)
if cle is not None:
self.overrides.setdefault(cle, {})["desktop"] = ""
self._recompute()
return True
# ------------------------------------------------------------------ #
# Redessiner
# ------------------------------------------------------------------ #

View file

@ -187,7 +187,14 @@ def run_proxmox_form(ctx, run_app: bool = True):
+ """
SelectionList { height: 10; border: solid $panel; }
RadioSet { height: auto; layout: horizontal; }
/* Ces deux règles portent « .vmrow Select » EN PLUS de leur
classe : « .vmrow Select » (une classe + un type) l'emporte sur
« .vmbranch » (une classe) par spécificité CSS. Écrites simplement,
elles étaient silencieusement écrasées. La branche porte des noms
longs (« 1.6.0 », « develop », « feature/xyz ») : trop étroite, la
liste les tronque et on ne sait plus ce qu'on a choisi. */
.vmrow Select.vmbranch { width: 34; }
.vmrow Select.vmprof { width: 40; }
#hostline { height: 1; color: $accent; padding: 0 1; }
"""
)
@ -210,7 +217,7 @@ def run_proxmox_form(ctx, run_app: bool = True):
self._syncing = False
# La liste des ponts GRANDIT : l'écran sait en créer un.
self._ponts = list(ponts)
self.extras_init(ctx)
self.extras_init(ctx, branches, profiles)
# ---------------------------------------------------------------- #
# L'écran
@ -594,6 +601,11 @@ def run_proxmox_form(ctx, run_app: bool = True):
if item.get("instance")
else Static("", classes="vmcopy")
),
# Branche, profil, type — par VM. On déploie ici le plus
# souvent un parc MIXTE : un hyperviseur Proxmox imbriqué
# à côté de VM ERPLibre, et l'écran n'offrait qu'un choix
# commun pour les deux.
*self.install_row_widgets(i, SELECT_NULL),
classes="vmrow",
)
cartes.append(
@ -739,6 +751,14 @@ def run_proxmox_form(ctx, run_app: bool = True):
if ident in ("f_storage", "f_bridge"):
self._refresh_after()
return
if ident in ("f_branch", "f_profile_install"):
# Un réglage commun reprend la main sur les VM non figées :
# c'est le sens même du mot « commun ».
self._clear_overrides(
("branch",) if ident == "f_branch" else ("install_cmd",)
)
self._refresh_after(remonter=True)
return
# Réglage commun : « libre… » révèle la saisie, une valeur
# s'applique à toutes les VM non figées.
champ = SELECT_TO_FIELD.get(ident)
@ -755,9 +775,16 @@ def run_proxmox_form(ctx, run_app: bool = True):
# Réglage d'UNE rangée.
if ident.startswith("v") and "_" in ident:
rang, champ = ident[1:].split("_", 1)
if not rang.isdigit() or champ not in RES_FIELDS:
if not rang.isdigit():
return
index = int(rang)
if index >= len(self.rows):
return
if self.extras_on_row_select(event, index, champ):
self._refresh_after()
return
if champ not in RES_FIELDS:
return
if event.value is FREE:
self._row_free(index, champ, True)
self._set_override(

View file

@ -1427,6 +1427,21 @@ class ProxmoxMenuMixin:
}
finale = cartes if self._qemu_per_vm(cartes, commun) else commun
branche = (install or {}).get("branch") or ""
# Même règle pour la branche et pour le type de VM : depuis que le
# plan les porte PAR RANGÉE, lire la seule valeur commune revenait à
# jeter le choix. Un parc mixte — un hyperviseur imbriqué à côté de VM
# ERPLibre — est justement ce qu'on déploie ici le plus souvent.
branches_vm = {
vm["name"]: (vm.get("branch") or branche) for vm in joignables
}
if self._qemu_per_vm(branches_vm, branche):
branche = branches_vm
bureau = spec.get("desktop") or ""
bureaux = {
vm["name"]: (vm.get("desktop") or bureau) for vm in joignables
}
if self._qemu_per_vm(bureaux, bureau):
bureau = bureaux
if suivi:
# Rien à installer ? La commande distante regarde alors la VM
# ARRIVER (cloud-init, puis relevé système) : c'est ce que le
@ -1458,7 +1473,7 @@ class ProxmoxMenuMixin:
# Les réglages du système invité, qui n'atteignaient pas la
# commande distante : la VM naissait serveur nu, sans outils.
prod=bool(spec.get("prod")),
desktop=spec.get("desktop") or "",
desktop=bureau,
python_provider=spec.get("python_provider") or "",
app_store=spec.get("app_store") or "deb",
vm_tools=spec.get("vm_tools") or (),
@ -1477,16 +1492,17 @@ class ProxmoxMenuMixin:
return resultat
# Sans suivi mais avec quelque chose à installer : en série, sortie à
# l'écran. C'est le pendant exact de la voie QEMU/KVM.
print(f"\n{t('Installing ERPLibre on each VM')} ({branche})…")
etiquette = branche if isinstance(branche, str) else t("per VM")
print(f"\n{t('Installing ERPLibre on each VM')} ({etiquette})…")
for vm in joignables:
self._qemu_install_erplibre_vm(
vm["name"],
cle_locale,
branche,
branches_vm.get(vm["name"], ""),
alias.get(vm["name"], vm["name"]),
vm.get("install_cmd") or commun,
bool(spec.get("prod")),
desktop=spec.get("desktop") or "",
desktop=bureaux.get(vm["name"], ""),
python_provider=spec.get("python_provider") or "",
app_store=spec.get("app_store") or "deb",
vm_tools=spec.get("vm_tools") or (),

View file

@ -70,7 +70,7 @@ from script.todo.deploy_form_lib import ( # noqa: F401
# Le socle commun aux deux formulaires (QEMU/KVM et Proxmox VE). Réexporté
# tel quel : les appelants historiques importent encore ces noms ICI.
from script.todo.deploy_form_extras import ExtrasMixin
from script.todo.deploy_form_extras import SERVER, ExtrasMixin
from script.todo.deploy_form_plan import ( # noqa: F401
PlanMixin,
preview_screen,
@ -128,8 +128,6 @@ def run_deploy_form(ctx, run_app: bool = True):
total_disk = ctx.get("total_disk") or 0
base_vcpus = ctx.get("base_vcpus") or 2
extra_disk = ctx.get("extra_disk_gb") or 0
# [(clé, libellé)] — la liste vient de todo.py, source unique.
desktops = list(ctx.get("desktops") or [])
# {clé de saveur: suffixe de nom}, fourni par todo.py qui décrit les
# saveurs — on ne le redéfinit pas ici.
desktop_suffixes = dict(ctx.get("desktop_suffixes") or {})
@ -137,9 +135,6 @@ def run_deploy_form(ctx, run_app: bool = True):
result = {"spec": None}
AUTO = "__auto__"
# « Serveur » est un CHOIX, pas une absence de choix : lui donner « » le
# rendrait indistinguable de la sentinelle « rien de sélectionné ».
SERVER = "__server__"
def entry_label(e):
star = " *" if e.get("default") else ""
@ -194,7 +189,7 @@ def run_deploy_form(ctx, run_app: bool = True):
self._syncing = False
# Jeu de VM actuellement monté dans le panneau droit.
self._shown_ids = ()
self.extras_init(ctx)
self.extras_init(ctx, branches, profiles)
# Génération du jeu de rangées monté. Les identifiants de widgets
# portent un RANG, et le rang se décale quand on coche ou décoche
# une entrée : un événement émis par un widget déjà détruit
@ -518,48 +513,7 @@ def run_deploy_form(ctx, run_app: bool = True):
self._render_plan()
self.render_extras()
def _profile_cmd(self):
"""Commande du profil choisi en haut : le défaut de chaque VM."""
if not profiles:
return ""
index = self.query_one("#f_profile_install", Select).value
return profiles[index if isinstance(index, int) else 0][1]
def _row_default_cmd(self, i):
"""Commande qu'une rangée prend d'elle-même : celle que son
système impose, sinon le choix commun d'en haut.
C'est le défaut CONTRE LEQUEL on compare une saisie : sur une VM
Proxmox, choisir Odoo 18 est une vraie surcharge même quand c'est
aussi la valeur commune."""
if i < len(self.rows):
impose = distro_profiles.get(self.rows[i]["vm"]["distro"])
if impose:
return impose[1]
return self._profile_cmd()
def _row_profile_index(self, i):
"""Rang du profil que la rangée doit AFFICHER."""
cmd = ""
if i < len(self.rows):
cmd = self.rows[i]["vm"].get("install_cmd") or ""
cmd = cmd or self._row_default_cmd(i)
for k, (_lbl, c) in enumerate(profiles):
if c == cmd:
return k
return 0
def _branch(self):
"""Branche du formulaire : le défaut de chaque VM."""
value = self.query_one("#f_branch", Select).value
return value if isinstance(value, str) else branches[0]
# -- panneau droit : une rangée de widgets par VM ---------------- #
def _type_options(self):
return [(t("Server"), SERVER)] + [
(label, key) for key, label in desktops
]
def _mount_rows(self) -> None:
"""(Re)construit le panneau droit.
@ -610,40 +564,7 @@ def run_deploy_form(ctx, run_app: bool = True):
if item.get("instance")
else Static("", classes="vmcopy")
),
Select(
[(b, b) for b in branches],
classes="vmbranch",
# Repli sur la branche du FORMULAIRE, jamais sur
# branches[0] : les rangées sont remontées dès que le
# jeu de VM change (une entrée cochée, une copie
# ajoutée, un renommage), et elles retombaient alors
# toutes sur « develop » quel que soit le choix commun.
value=(
self.rows[i]["vm"].get("branch")
if i < len(self.rows)
else ""
)
or self._branch(),
allow_blank=False,
id=f"v{i}_branch",
),
(
Select(
[(lbl, i) for i, (lbl, _c) in enumerate(profiles)],
value=self._row_profile_index(i),
allow_blank=False,
classes="vmprof",
id=f"v{i}_prof",
)
if profiles
else Static("", classes="vmprof")
),
Select(
self._type_options(),
value=vm.get("desktop") or SERVER,
allow_blank=False,
id=f"v{i}_type",
),
*self.install_row_widgets(i, SELECT_NULL),
classes="vmrow",
)
widgets.append(
@ -854,63 +775,18 @@ def run_deploy_form(ctx, run_app: bool = True):
# profil donne déjà n'enregistre pas de surcharge. La VM
# suivra donc le profil s'il change — ce qui est aussi le plus
# attendu quand on n'a rien changé de visible.
vm_now = self.rows[index]["vm"]
if field == "prof":
label, cmd = profiles[event.value]
if cmd == (
vm_now.get("install_cmd")
or self._row_default_cmd(index)
):
return
same = cmd == self._row_default_cmd(index)
self._set_override(
index, "install_cmd", "" if same else cmd
)
self._set_override(
index, "install_label", "" if same else label
)
self._recompute()
if self.extras_on_row_select(event, index, field):
return
if field == "branch":
# « la branche du formulaire » n'est pas une surcharge :
# la VM doit suivre si on la change en haut.
current = vm_now.get("branch") or self._branch()
if event.value == current:
return
self._set_override(
index,
"branch",
"" if event.value == self._branch() else event.value,
)
self._recompute()
return
if field == "type":
new_desk = "" if event.value == SERVER else event.value
if new_desk == (vm_now.get("desktop") or ""):
return
elif (
event.value is not FREE and event.value is not SELECT_NULL
):
if self._row_echo(index, field, event.value):
return
if field == "type":
self._set_override(
index,
"desktop",
"" if event.value == SERVER else event.value,
)
# « Serveur » est un choix légitime, pas un retrait : on le
# note explicitement pour qu'il tienne face au défaut.
if event.value == SERVER:
key = self._row_key(index)
if key is not None:
self.overrides.setdefault(key, {})["desktop"] = ""
elif event.value is FREE:
# Ne restent ici que les RESSOURCES : elles n'ont pas de
# défaut à comparer, mais une saisie libre à révéler.
if event.value is FREE:
self._row_free(index, field, True)
self._set_override(
index, field, self._read_row_free(index, field)
)
elif event.value is not SELECT_NULL:
if self._row_echo(index, field, event.value):
return
self._row_free(index, field, False)
self._set_override(index, field, event.value)
self._recompute()

View file

@ -4351,6 +4351,10 @@ TRANSLATIONS = {
"fr": "Clonage d'ERPLibre sur chaque VM",
"en": "Cloning ERPLibre on each VM",
},
"per VM": {
"fr": "par VM",
"en": "per VM",
},
"Installing ERPLibre on each VM": {
"fr": "Installation d'ERPLibre sur chaque VM",
"en": "Installing ERPLibre on each VM",

View file

@ -598,6 +598,94 @@ class TestDeuxVmDuMemeNom(unittest.TestCase):
)
class TestUnParcMixte(unittest.TestCase):
"""Le plan porte branche, profil et type PAR RANGÉE — le déploiement
lisait encore la seule valeur commune.
C'est le cas qu'on déploie le plus souvent sur un Proxmox : un
hyperviseur imbriqué à côté de VM ERPLibre. Une seule VM qui porte sa
propre valeur suffit à rendre la carte nécessaire — « len(set) > 1 » ne
l'aurait pas vu, et tout le parc serait retombé sur le commun."""
def _capture(self, vms):
import contextlib
import io
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
vu = {}
todo = TODO.__new__(TODO)
todo._write_ssh_config_entry = lambda *a, **k: None
todo._ssh_private_key = lambda k: None
todo._ssh_config_block = lambda nom: {}
todo._qemu_list_domains = lambda: []
todo._pve_guest_ip = lambda vmid, attente=120: ""
todo._pve_write_guide = lambda *a, **k: True
todo._pve_set_timezone = lambda *a, **k: True
todo._qemu_import_module = lambda: None
def prise(noms, branche, alias, finale, **kw):
vu.update(branche=branche, finale=finale, kw=kw)
todo._qemu_install_erplibre_monitored = prise
spec = {
"host": {"target": "pve1"},
"vms": vms,
"user": "erplibre",
"add_ssh_config": True,
"install": {
"branch": "develop",
"cmd": "make install_odoo_18",
"label": "X",
},
"monitor": True,
"desktop": "",
}
with contextlib.redirect_stdout(io.StringIO()):
todo._pve_after_create(
spec["host"], spec, [v["name"] for v in vms], ""
)
return vu
def _vm(self, nom, **extra):
base = {
"name": nom,
"vmid": 100,
"ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1",
"install_cmd": "",
}
base.update(extra)
return base
def test_a_single_vm_with_its_own_branch_forces_the_map(self):
vu = self._capture(
[
self._vm("vm-a", branch="master"),
self._vm("vm-b", vmid=101),
]
)
self.assertEqual(vu["branche"], {"vm-a": "master", "vm-b": "develop"})
def test_a_uniform_fleet_keeps_the_common_value(self):
vu = self._capture([self._vm("vm-a"), self._vm("vm-b", vmid=101)])
self.assertEqual(vu["branche"], "develop")
def test_a_per_vm_desktop_reaches_the_install(self):
vu = self._capture(
[
self._vm("vm-a", desktop="gnome"),
self._vm("vm-b", vmid=101),
]
)
self.assertEqual(vu["kw"]["desktop"], {"vm-a": "gnome", "vm-b": ""})
def test_a_uniform_fleet_keeps_the_common_desktop(self):
vu = self._capture([self._vm("vm-a"), self._vm("vm-b", vmid=101)])
self.assertEqual(vu["kw"]["desktop"], "")
class TestLEcranDUneVmProxmox(unittest.TestCase):
"""« Console de l'hyperviseur » conseillait des commandes virsh sur une
machine qui n'a pas libvirt.

View file

@ -108,6 +108,7 @@ def releve(fabrique, ctx, gestes=None):
vu["ids"] = {w.id for w in app.query("#fields *") if w.id}
vu["spec"] = app._form_values()
vu["noms"] = [r["vm"]["name"] for r in app.rows]
vu["rangee"] = [w.id for w in app.query("#plan *") if w.id]
vu["disques"] = [r["disk_gb"] for r in app.rows]
asyncio.run(scenario())
@ -163,6 +164,17 @@ class TestLesDeuxEcrans(unittest.TestCase):
vide.extras_init({})
return vide
def test_both_screens_offer_the_same_per_vm_choices(self):
"""Branche, profil, type : ce qu'une VM peut prendre à elle seule.
L'écran Proxmox n'en offrait aucun, alors qu'on y déploie le plus
souvent un parc MIXTE — un hyperviseur imbriqué à côté de VM
ERPLibre — c'est-à-dire le cas où ils servent le plus."""
for ident in ("v0_branch", "v0_prof", "v0_type"):
with self.subTest(reglage=ident):
self.assertIn(ident, self.qemu["rangee"], "QEMU/KVM")
self.assertIn(ident, self.pve["rangee"], "Proxmox")
def test_both_screens_bind_the_same_catalog_shortcuts(self):
# « Versions principales » (F7) manquait à l'écran Proxmox, qui
# affiche pourtant le même catalogue, drapeau « default » compris.