[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:
parent
2462798f4e
commit
0551d807ac
7 changed files with 336 additions and 141 deletions
|
|
@ -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
|
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
|
# Ce que l'écran lit du contexte. Une seule liste, parce que les deux
|
||||||
# formulaires doivent en recevoir autant : c'est en fournissant un
|
# formulaires doivent en recevoir autant : c'est en fournissant un
|
||||||
# sous-ensemble que l'écran Proxmox avait perdu la moitié des réglages.
|
# 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
|
`render_extras()` après chaque recalcul et `extras_values()` au moment de
|
||||||
bâtir sa spec."""
|
bâtir sa spec."""
|
||||||
|
|
||||||
def extras_init(self, ctx) -> None:
|
def extras_init(self, ctx, branches=(), profiles=()) -> None:
|
||||||
"""À appeler dans `__init__`, avant tout `compose`."""
|
"""À 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 = 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
|
# Les widgets
|
||||||
|
|
@ -310,6 +323,165 @@ class ExtrasMixin:
|
||||||
def _extras_disk_gb(self, vm, tools) -> int:
|
def _extras_disk_gb(self, vm, tools) -> int:
|
||||||
return extras_disk_gb(vm, tools, self._extras)
|
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
|
# Redessiner
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,14 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
||||||
+ """
|
+ """
|
||||||
SelectionList { height: 10; border: solid $panel; }
|
SelectionList { height: 10; border: solid $panel; }
|
||||||
RadioSet { height: auto; layout: horizontal; }
|
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.vmbranch { width: 34; }
|
||||||
|
.vmrow Select.vmprof { width: 40; }
|
||||||
#hostline { height: 1; color: $accent; padding: 0 1; }
|
#hostline { height: 1; color: $accent; padding: 0 1; }
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
@ -210,7 +217,7 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
||||||
self._syncing = False
|
self._syncing = False
|
||||||
# La liste des ponts GRANDIT : l'écran sait en créer un.
|
# La liste des ponts GRANDIT : l'écran sait en créer un.
|
||||||
self._ponts = list(ponts)
|
self._ponts = list(ponts)
|
||||||
self.extras_init(ctx)
|
self.extras_init(ctx, branches, profiles)
|
||||||
|
|
||||||
# ---------------------------------------------------------------- #
|
# ---------------------------------------------------------------- #
|
||||||
# L'écran
|
# L'écran
|
||||||
|
|
@ -594,6 +601,11 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
||||||
if item.get("instance")
|
if item.get("instance")
|
||||||
else Static("", classes="vmcopy")
|
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",
|
classes="vmrow",
|
||||||
)
|
)
|
||||||
cartes.append(
|
cartes.append(
|
||||||
|
|
@ -739,6 +751,14 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
||||||
if ident in ("f_storage", "f_bridge"):
|
if ident in ("f_storage", "f_bridge"):
|
||||||
self._refresh_after()
|
self._refresh_after()
|
||||||
return
|
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
|
# Réglage commun : « libre… » révèle la saisie, une valeur
|
||||||
# s'applique à toutes les VM non figées.
|
# s'applique à toutes les VM non figées.
|
||||||
champ = SELECT_TO_FIELD.get(ident)
|
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.
|
# Réglage d'UNE rangée.
|
||||||
if ident.startswith("v") and "_" in ident:
|
if ident.startswith("v") and "_" in ident:
|
||||||
rang, champ = ident[1:].split("_", 1)
|
rang, champ = ident[1:].split("_", 1)
|
||||||
if not rang.isdigit() or champ not in RES_FIELDS:
|
if not rang.isdigit():
|
||||||
return
|
return
|
||||||
index = int(rang)
|
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:
|
if event.value is FREE:
|
||||||
self._row_free(index, champ, True)
|
self._row_free(index, champ, True)
|
||||||
self._set_override(
|
self._set_override(
|
||||||
|
|
|
||||||
|
|
@ -1427,6 +1427,21 @@ class ProxmoxMenuMixin:
|
||||||
}
|
}
|
||||||
finale = cartes if self._qemu_per_vm(cartes, commun) else commun
|
finale = cartes if self._qemu_per_vm(cartes, commun) else commun
|
||||||
branche = (install or {}).get("branch") or ""
|
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:
|
if suivi:
|
||||||
# Rien à installer ? La commande distante regarde alors la VM
|
# Rien à installer ? La commande distante regarde alors la VM
|
||||||
# ARRIVER (cloud-init, puis relevé système) : c'est ce que le
|
# 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
|
# Les réglages du système invité, qui n'atteignaient pas la
|
||||||
# commande distante : la VM naissait serveur nu, sans outils.
|
# commande distante : la VM naissait serveur nu, sans outils.
|
||||||
prod=bool(spec.get("prod")),
|
prod=bool(spec.get("prod")),
|
||||||
desktop=spec.get("desktop") or "",
|
desktop=bureau,
|
||||||
python_provider=spec.get("python_provider") or "",
|
python_provider=spec.get("python_provider") or "",
|
||||||
app_store=spec.get("app_store") or "deb",
|
app_store=spec.get("app_store") or "deb",
|
||||||
vm_tools=spec.get("vm_tools") or (),
|
vm_tools=spec.get("vm_tools") or (),
|
||||||
|
|
@ -1477,16 +1492,17 @@ class ProxmoxMenuMixin:
|
||||||
return resultat
|
return resultat
|
||||||
# Sans suivi mais avec quelque chose à installer : en série, sortie à
|
# Sans suivi mais avec quelque chose à installer : en série, sortie à
|
||||||
# l'écran. C'est le pendant exact de la voie QEMU/KVM.
|
# 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:
|
for vm in joignables:
|
||||||
self._qemu_install_erplibre_vm(
|
self._qemu_install_erplibre_vm(
|
||||||
vm["name"],
|
vm["name"],
|
||||||
cle_locale,
|
cle_locale,
|
||||||
branche,
|
branches_vm.get(vm["name"], ""),
|
||||||
alias.get(vm["name"], vm["name"]),
|
alias.get(vm["name"], vm["name"]),
|
||||||
vm.get("install_cmd") or commun,
|
vm.get("install_cmd") or commun,
|
||||||
bool(spec.get("prod")),
|
bool(spec.get("prod")),
|
||||||
desktop=spec.get("desktop") or "",
|
desktop=bureaux.get(vm["name"], ""),
|
||||||
python_provider=spec.get("python_provider") or "",
|
python_provider=spec.get("python_provider") or "",
|
||||||
app_store=spec.get("app_store") or "deb",
|
app_store=spec.get("app_store") or "deb",
|
||||||
vm_tools=spec.get("vm_tools") or (),
|
vm_tools=spec.get("vm_tools") or (),
|
||||||
|
|
|
||||||
|
|
@ -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é
|
# Le socle commun aux deux formulaires (QEMU/KVM et Proxmox VE). Réexporté
|
||||||
# tel quel : les appelants historiques importent encore ces noms ICI.
|
# 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
|
from script.todo.deploy_form_plan import ( # noqa: F401
|
||||||
PlanMixin,
|
PlanMixin,
|
||||||
preview_screen,
|
preview_screen,
|
||||||
|
|
@ -128,8 +128,6 @@ def run_deploy_form(ctx, run_app: bool = True):
|
||||||
total_disk = ctx.get("total_disk") or 0
|
total_disk = ctx.get("total_disk") or 0
|
||||||
base_vcpus = ctx.get("base_vcpus") or 2
|
base_vcpus = ctx.get("base_vcpus") or 2
|
||||||
extra_disk = ctx.get("extra_disk_gb") or 0
|
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
|
# {clé de saveur: suffixe de nom}, fourni par todo.py qui décrit les
|
||||||
# saveurs — on ne le redéfinit pas ici.
|
# saveurs — on ne le redéfinit pas ici.
|
||||||
desktop_suffixes = dict(ctx.get("desktop_suffixes") or {})
|
desktop_suffixes = dict(ctx.get("desktop_suffixes") or {})
|
||||||
|
|
@ -137,9 +135,6 @@ def run_deploy_form(ctx, run_app: bool = True):
|
||||||
result = {"spec": None}
|
result = {"spec": None}
|
||||||
|
|
||||||
AUTO = "__auto__"
|
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):
|
def entry_label(e):
|
||||||
star = " *" if e.get("default") else ""
|
star = " *" if e.get("default") else ""
|
||||||
|
|
@ -194,7 +189,7 @@ def run_deploy_form(ctx, run_app: bool = True):
|
||||||
self._syncing = False
|
self._syncing = False
|
||||||
# Jeu de VM actuellement monté dans le panneau droit.
|
# Jeu de VM actuellement monté dans le panneau droit.
|
||||||
self._shown_ids = ()
|
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
|
# 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
|
# 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
|
# 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_plan()
|
||||||
self.render_extras()
|
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 ---------------- #
|
# -- 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:
|
def _mount_rows(self) -> None:
|
||||||
"""(Re)construit le panneau droit.
|
"""(Re)construit le panneau droit.
|
||||||
|
|
||||||
|
|
@ -610,40 +564,7 @@ def run_deploy_form(ctx, run_app: bool = True):
|
||||||
if item.get("instance")
|
if item.get("instance")
|
||||||
else Static("", classes="vmcopy")
|
else Static("", classes="vmcopy")
|
||||||
),
|
),
|
||||||
Select(
|
*self.install_row_widgets(i, SELECT_NULL),
|
||||||
[(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",
|
|
||||||
),
|
|
||||||
classes="vmrow",
|
classes="vmrow",
|
||||||
)
|
)
|
||||||
widgets.append(
|
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
|
# profil donne déjà n'enregistre pas de surcharge. La VM
|
||||||
# suivra donc le profil s'il change — ce qui est aussi le plus
|
# suivra donc le profil s'il change — ce qui est aussi le plus
|
||||||
# attendu quand on n'a rien changé de visible.
|
# attendu quand on n'a rien changé de visible.
|
||||||
vm_now = self.rows[index]["vm"]
|
if self.extras_on_row_select(event, index, field):
|
||||||
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()
|
|
||||||
return
|
return
|
||||||
if field == "branch":
|
# Ne restent ici que les RESSOURCES : elles n'ont pas de
|
||||||
# « la branche du formulaire » n'est pas une surcharge :
|
# défaut à comparer, mais une saisie libre à révéler.
|
||||||
# la VM doit suivre si on la change en haut.
|
if event.value is FREE:
|
||||||
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:
|
|
||||||
self._row_free(index, field, True)
|
self._row_free(index, field, True)
|
||||||
self._set_override(
|
self._set_override(
|
||||||
index, field, self._read_row_free(index, field)
|
index, field, self._read_row_free(index, field)
|
||||||
)
|
)
|
||||||
elif event.value is not SELECT_NULL:
|
elif event.value is not SELECT_NULL:
|
||||||
|
if self._row_echo(index, field, event.value):
|
||||||
|
return
|
||||||
self._row_free(index, field, False)
|
self._row_free(index, field, False)
|
||||||
self._set_override(index, field, event.value)
|
self._set_override(index, field, event.value)
|
||||||
self._recompute()
|
self._recompute()
|
||||||
|
|
|
||||||
|
|
@ -4351,6 +4351,10 @@ TRANSLATIONS = {
|
||||||
"fr": "Clonage d'ERPLibre sur chaque VM",
|
"fr": "Clonage d'ERPLibre sur chaque VM",
|
||||||
"en": "Cloning ERPLibre on each VM",
|
"en": "Cloning ERPLibre on each VM",
|
||||||
},
|
},
|
||||||
|
"per VM": {
|
||||||
|
"fr": "par VM",
|
||||||
|
"en": "per VM",
|
||||||
|
},
|
||||||
"Installing ERPLibre on each VM": {
|
"Installing ERPLibre on each VM": {
|
||||||
"fr": "Installation d'ERPLibre sur chaque VM",
|
"fr": "Installation d'ERPLibre sur chaque VM",
|
||||||
"en": "Installing ERPLibre on each VM",
|
"en": "Installing ERPLibre on each VM",
|
||||||
|
|
|
||||||
|
|
@ -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):
|
class TestLEcranDUneVmProxmox(unittest.TestCase):
|
||||||
"""« Console de l'hyperviseur » conseillait des commandes virsh sur une
|
"""« Console de l'hyperviseur » conseillait des commandes virsh sur une
|
||||||
machine qui n'a pas libvirt.
|
machine qui n'a pas libvirt.
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ def releve(fabrique, ctx, gestes=None):
|
||||||
vu["ids"] = {w.id for w in app.query("#fields *") if w.id}
|
vu["ids"] = {w.id for w in app.query("#fields *") if w.id}
|
||||||
vu["spec"] = app._form_values()
|
vu["spec"] = app._form_values()
|
||||||
vu["noms"] = [r["vm"]["name"] for r in app.rows]
|
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]
|
vu["disques"] = [r["disk_gb"] for r in app.rows]
|
||||||
|
|
||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
@ -163,6 +164,17 @@ class TestLesDeuxEcrans(unittest.TestCase):
|
||||||
vide.extras_init({})
|
vide.extras_init({})
|
||||||
return vide
|
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):
|
def test_both_screens_bind_the_same_catalog_shortcuts(self):
|
||||||
# « Versions principales » (F7) manquait à l'écran Proxmox, qui
|
# « Versions principales » (F7) manquait à l'écran Proxmox, qui
|
||||||
# affiche pourtant le même catalogue, drapeau « default » compris.
|
# affiche pourtant le même catalogue, drapeau « default » compris.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue