[REF] déploiement : un seul socle pour ce qui décrit le système invité
Type de VM, production, magasin d'applications, outils de développement, fuseau horaire, interpréteur Python : six réglages qui décrivent l'invité, pas la machine qui le porte. Ils valaient donc déjà sur Proxmox — l'écran n'en offrait que trois. Une VM créée là-bas naissait serveur nu, sans outils et en UTC, et rien ne le disait. La duplication était le mécanisme de la dérive : chaque correctif se posait sur un seul des deux écrans. Les six vivent maintenant dans un socle commun, widgets ET logique, et le contexte qui les nourrit est écrit une fois. L'écran QEMU/KVM perd 330 lignes sans qu'un widget ni une valeur de spec ne bouge — vérifié en montant l'ancien et le nouveau dans le même processus. Trois conséquences se sont propagées seules : le disque annoncé (bureau et outils compris) atteint enfin « qm resize », le parallélisme suit les cœurs de l'hôte au lieu d'un plafond de quatre, et le nom prend le suffixe du bureau — sans lui, une VM graphique et sa jumelle serveur se disputaient le même. Deux défauts nommés au passage. Le fuseau : « qm set » n'en pose pas, il part maintenant par ssh avant l'installation. Et l'architecture d'une VM Proxmox venait de « virsh », qui ne connaît que les domaines d'ici — une VM ARM prise pour x86_64 recevait Android Studio, que Google ne publie pas pour elle. Le test porte sur la PARITÉ, pas sur six comportements : ajouter un réglage à un seul écran le fait échouer. Il a d'abord échoué à se lancer — hors des préfixes du lanceur, douze tests n'ont jamais tourné et le total n'avait pas bougé. La règle de nommage est maintenant dans son en-tête. --- EN --- VM type, production, app store, development tools, timezone, Python interpreter: six settings that describe the guest, not the machine hosting it. They already applied to Proxmox — the screen offered three. A VM created there was born a bare server, no tools, in UTC, and nothing said so. Duplication was the mechanism of the drift: each fix landed on one screen only. The six now live in a shared foundation, widgets AND logic, and the context feeding them is written once. The QEMU/KVM screen loses 330 lines with no widget and no spec value moving — verified by mounting the old and the new in one process. Three consequences followed on their own: the announced disk (desktop and tools included) finally reaches "qm resize", parallelism follows the host's cores instead of a cap of four, and the name takes the desktop suffix — without it a graphical VM and its server twin fought over the same one. Two defects named along the way. The timezone: "qm set" sets none, it now goes over ssh before the install. And a Proxmox VM's architecture came from "virsh", which only knows local domains — an ARM VM taken for x86_64 got Android Studio, which Google does not publish for it. The test covers PARITY, not six behaviours: adding a setting to one screen alone fails it. It first failed to run at all — outside the runner's prefixes, twelve tests never ran and the total had not moved. The naming rule is now in its header. Assisted-by: Claude Opus 5
This commit is contained in:
parent
27a61152cb
commit
6b985e57ab
10 changed files with 1086 additions and 507 deletions
|
|
@ -15,6 +15,13 @@
|
|||
# l'annonce donc avant de commencer.
|
||||
#
|
||||
# ./script/test/run_unit_test.sh [fichiers...]
|
||||
#
|
||||
# NOMMER UN NOUVEAU FICHIER : sans argument, ce script ne prend que les
|
||||
# préfixes listés plus bas (test_qemu_, test_todo_, test_proxmox_…) — le reste
|
||||
# de test/ demande une base de données. Un fichier hors préfixe tombe donc dans
|
||||
# le même silence qu'un fichier absent : douze tests écrits, jamais lancés, et
|
||||
# un total qui n'a pas bougé. Choisir le préfixe de sa famille, ou l'ajouter
|
||||
# ici.
|
||||
set -uo pipefail
|
||||
|
||||
Red='\033[0;31m'
|
||||
|
|
|
|||
480
script/todo/deploy_form_extras.py
Normal file
480
script/todo/deploy_form_extras.py
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
"""Ce qu'on installe DANS la VM, et qui ne regarde pas l'hyperviseur.
|
||||
|
||||
Type de VM, production, magasin d'applications, outils de développement,
|
||||
fuseau horaire, interpréteur Python : six réglages qui décrivent le système
|
||||
invité, pas la machine qui le porte. Ils valent donc mot pour mot sur libvirt
|
||||
et sur Proxmox VE — et c'est exactement ce qui avait dérivé. L'écran QEMU/KVM
|
||||
les portait tous, l'écran Proxmox trois : une VM créée là-bas naissait sans
|
||||
bureau, sans outils et en UTC, sans que rien ne le dise.
|
||||
|
||||
La duplication était le mécanisme de la dérive, pas son symptôme : chaque
|
||||
correctif se posait sur un seul des deux écrans. Ce module met les six ici,
|
||||
une seule fois, widgets ET logique. Un formulaire hérite d'`ExtrasMixin`,
|
||||
appelle `extras_init(ctx)` puis pose les fragments de `compose_*` où sa mise
|
||||
en page les veut.
|
||||
|
||||
Le contrat côté formulaire, court exprès : l'attribut `vms` (les VM retenues)
|
||||
et une case `#f_install`. Rien d'autre — ce module ne connaît ni stockage, ni
|
||||
pont, ni domaine libvirt.
|
||||
|
||||
Chaque accès aux widgets est gardé : un formulaire n'est pas tenu de poser
|
||||
tous les fragments, et lire un widget absent ne doit pas casser l'écran.
|
||||
"""
|
||||
|
||||
from script.todo.deploy_form_lib import FREE, t
|
||||
|
||||
# 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.
|
||||
_TABLES = (
|
||||
("desktops", list, "desktops"),
|
||||
("desktop_disk", int, "desktop_disk_gb"),
|
||||
("app_stores", list, "app_stores"),
|
||||
("snap_distros", set, "snap_distros"),
|
||||
("timezones", list, "timezones"),
|
||||
("timezone", str, "timezone"),
|
||||
("vm_tools", list, "vm_tools"),
|
||||
("tool_disk", dict, "vm_tool_disk"),
|
||||
("tool_phases", dict, "vm_tool_phases"),
|
||||
("tool_arches", dict, "vm_tool_arches"),
|
||||
("tool_desktops", dict, "vm_tool_desktops"),
|
||||
("tool_needs_desktop", dict, "vm_tool_needs_desktop"),
|
||||
("tool_families", dict, "vm_tool_families"),
|
||||
("distro_family", dict, "distro_family"),
|
||||
("mise_arches", set, "mise_arches"),
|
||||
("defaults", dict, "defaults"),
|
||||
)
|
||||
|
||||
|
||||
def extras_tables(ctx) -> dict:
|
||||
"""Les tables lues du contexte, chacune ramenée à son type.
|
||||
|
||||
Ramenées, et non prises telles quelles : une clé absente rend un vide du
|
||||
bon type, donc les prédicats plus bas n'ont aucun cas particulier à
|
||||
porter. Un formulaire qui n'offre pas les outils passe simplement un
|
||||
contexte sans « vm_tools »."""
|
||||
ctx = ctx or {}
|
||||
return {nom: kind(ctx.get(cle) or kind()) for nom, kind, cle in _TABLES}
|
||||
|
||||
|
||||
def tools_for_vm(vm, tools, tab) -> list:
|
||||
"""Outils qu'une VM donnée recevra VRAIMENT.
|
||||
|
||||
Le même filtre que côté déploiement, et c'est le point : une VM ARM ne
|
||||
verra jamais Android Studio, une VM Cinnamon jamais les extensions GNOME,
|
||||
un serveur aucun des IDE — mais un serveur reçoit bien la compilation
|
||||
mobile, qui n'a rien à afficher, et une distribution sans apt ne la
|
||||
reçoit pas, son installateur n'existant que là.
|
||||
|
||||
Filtrer ici plutôt que dans la commande distante permet d'annoncer
|
||||
l'écart AVANT le déploiement, au lieu de le laisser découvrir dans un
|
||||
journal d'installation d'une heure."""
|
||||
out = []
|
||||
for key in tools or ():
|
||||
arches = tab["tool_arches"].get(key) or ()
|
||||
desks = tab["tool_desktops"].get(key) or ()
|
||||
fams = tab["tool_families"].get(key) or ()
|
||||
if tab["tool_needs_desktop"].get(key) and not vm.get("desktop"):
|
||||
continue
|
||||
if arches and vm.get("arch") not in arches:
|
||||
continue
|
||||
if desks and vm.get("desktop") not in desks:
|
||||
continue
|
||||
if fams and tab["distro_family"].get(vm.get("distro"), "") not in fams:
|
||||
continue
|
||||
out.append(key)
|
||||
return out
|
||||
|
||||
|
||||
def app_store_needed(vms, tab) -> bool:
|
||||
"""Le choix du magasin n'a de sens que pour une VM GRAPHIQUE sur une
|
||||
distribution qui livre snapd. Ailleurs, rien ne tire de snap."""
|
||||
return any(
|
||||
vm.get("desktop") and vm.get("distro") in tab["snap_distros"]
|
||||
for vm in vms or ()
|
||||
)
|
||||
|
||||
|
||||
def mise_usable(vms, tab) -> bool:
|
||||
"""Au moins une VM retenue tourne sur une architecture que mise sert."""
|
||||
return any(vm.get("arch") in tab["mise_arches"] for vm in vms or ())
|
||||
|
||||
|
||||
def extras_disk_gb(vm, tools, tab) -> int:
|
||||
"""Go que le bureau et les outils ajoutent au disque de CETTE VM.
|
||||
|
||||
De cette VM et d'elle seule : un supplément commun mentait dès que les
|
||||
types différaient d'une machine à l'autre, et gonflait le disque d'un
|
||||
serveur pour un bureau qu'il n'aurait pas."""
|
||||
gb = tab["desktop_disk"] if vm.get("desktop") else 0
|
||||
return gb + sum(
|
||||
tab["tool_disk"].get(k, 0) for k in tools_for_vm(vm, tools, tab)
|
||||
)
|
||||
|
||||
|
||||
class ExtrasMixin:
|
||||
"""Les six réglages du système invité, widgets et logique.
|
||||
|
||||
Le formulaire hôte pose les fragments `compose_*` où il veut, appelle
|
||||
`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`."""
|
||||
self._extras = extras_tables(ctx)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Les widgets
|
||||
# ------------------------------------------------------------------ #
|
||||
def compose_vm_type(self):
|
||||
"""Type de VM par défaut. Serveur d'abord : c'est ce que sert une
|
||||
image cloud, et un bureau ajoute une à deux heures sur une
|
||||
architecture émulée. Le plan annonce le surcoût disque."""
|
||||
from textual.widgets import RadioButton, RadioSet, Static
|
||||
|
||||
tab = self._extras
|
||||
if not tab["desktops"]:
|
||||
return
|
||||
yield Static(t("VM type (default):"), classes="grouptitle")
|
||||
with RadioSet(id="f_type"):
|
||||
yield RadioButton(
|
||||
t("Server (no graphical interface)"),
|
||||
value=not tab["defaults"].get("desktop", ""),
|
||||
)
|
||||
for key, label in tab["desktops"]:
|
||||
yield RadioButton(
|
||||
f"{t('Graphical (server + desktop):')} {label}",
|
||||
value=tab["defaults"].get("desktop", "") == key,
|
||||
)
|
||||
|
||||
def compose_install_extras(self):
|
||||
"""Ce qui pend à la case « installer » : production, magasin,
|
||||
outils. Posé DANS la section « Installation », après la branche et le
|
||||
profil."""
|
||||
from textual.widgets import Checkbox, RadioButton, RadioSet, Static
|
||||
|
||||
tab = self._extras
|
||||
yield Checkbox(
|
||||
t("Production (/opt, confined)"),
|
||||
value=tab["defaults"].get("prod", False),
|
||||
id="f_prod",
|
||||
)
|
||||
if tab["app_stores"]:
|
||||
yield Static(
|
||||
t("Application store:"), id="t_store", classes="grouptitle"
|
||||
)
|
||||
with RadioSet(id="f_store"):
|
||||
for i, (_k, label) in enumerate(tab["app_stores"]):
|
||||
yield RadioButton(label, value=i == 0)
|
||||
yield Static("", id="storewarn")
|
||||
if tab["vm_tools"]:
|
||||
# Une case par outil, et non une liste déroulante : ils sont
|
||||
# indépendants, et chacun se prend ou se laisse.
|
||||
yield Static(
|
||||
t("Development tools:"), id="t_tools", classes="grouptitle"
|
||||
)
|
||||
for key, label, hint in tab["vm_tools"]:
|
||||
gb = tab["tool_disk"].get(key, 0)
|
||||
yield Checkbox(
|
||||
f"{label} +{gb} Go — {hint}",
|
||||
value=key in (tab["defaults"].get("tools") or ()),
|
||||
id=f"f_tool_{key}",
|
||||
)
|
||||
yield Static("", id="toolwarn")
|
||||
|
||||
def compose_timezone(self):
|
||||
"""Une liste plutôt qu'une saisie : un nom IANA mal orthographié
|
||||
n'est pas refusé par cloud-init, il est IGNORÉ — la VM reste en UTC et
|
||||
on ne s'en aperçoit qu'aux horodatages. « libre… » garde la porte
|
||||
ouverte aux six cents autres fuseaux de la base."""
|
||||
from textual.widgets import Input, Select, Static
|
||||
|
||||
tab = self._extras
|
||||
if not tab["timezones"]:
|
||||
return
|
||||
null = getattr(Select, "NULL", Select.BLANK)
|
||||
yield Static(t("Timezone"), classes="grouptitle")
|
||||
yield Select(
|
||||
[(z, z) for z in tab["timezones"]] + [(t("free value…"), FREE)],
|
||||
value=(tab["timezones"][0] if tab["timezones"] else null),
|
||||
allow_blank=False,
|
||||
id="f_tz_sel",
|
||||
)
|
||||
yield Input(
|
||||
value=tab["timezone"],
|
||||
placeholder=t("Timezone for the VMs"),
|
||||
id="f_tz",
|
||||
classes="freeval",
|
||||
)
|
||||
|
||||
def compose_python(self):
|
||||
"""mise pose un CPython PRÉCOMPILÉ, pyenv le COMPILE. Grisé quand
|
||||
aucune des VM retenues n'est sur une architecture que mise sert."""
|
||||
from textual.widgets import RadioButton, RadioSet, Static
|
||||
|
||||
yield Static(
|
||||
t("Python interpreter:"), id="t_python", classes="grouptitle"
|
||||
)
|
||||
with RadioSet(id="f_python"):
|
||||
yield RadioButton(t("mise (precompiled, faster)"), value=True)
|
||||
yield RadioButton(t("pyenv (compiles from source)"))
|
||||
yield Static("", id="miswarn")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Lire les widgets
|
||||
# ------------------------------------------------------------------ #
|
||||
def _widget(self, selector):
|
||||
"""Le widget, ou None s'il n'est pas de cet écran."""
|
||||
try:
|
||||
return self.query_one(selector)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _default_desktop(self) -> str:
|
||||
"""« » pour un serveur, sinon la clé de la saveur choisie. Chaque
|
||||
rangée du plan peut s'en écarter."""
|
||||
desktops = self._extras["desktops"]
|
||||
widget = self._widget("#f_type")
|
||||
if widget is None:
|
||||
return ""
|
||||
index = widget.pressed_index
|
||||
if index is None or index < 1 or index > len(desktops):
|
||||
return ""
|
||||
return desktops[index - 1][0]
|
||||
|
||||
def _install_state(self):
|
||||
"""(une installation ?, quelque chose à installer ?).
|
||||
|
||||
Deux réponses et non une : sans installation mais avec un bureau, il
|
||||
se pose encore des paquets — le magasin d'applications et les outils
|
||||
de la phase « avant » gardent un effet."""
|
||||
widget = self._widget("#f_install")
|
||||
installe = True if widget is None else bool(widget.value)
|
||||
return installe, bool(installe or self._default_desktop())
|
||||
|
||||
def _vm_tools(self) -> tuple:
|
||||
"""Clés des outils cochés, dans l'ordre de la liste."""
|
||||
picked = []
|
||||
for key, _label, _hint in self._extras["vm_tools"]:
|
||||
widget = self._widget(f"#f_tool_{key}")
|
||||
if widget is not None and widget.value:
|
||||
picked.append(key)
|
||||
return tuple(picked)
|
||||
|
||||
def _tools_for_vm(self, vm, tools) -> list:
|
||||
return tools_for_vm(vm, tools, self._extras)
|
||||
|
||||
def _app_store_needed(self) -> bool:
|
||||
return app_store_needed(self.vms, self._extras)
|
||||
|
||||
def _app_store(self) -> str:
|
||||
"""Magasin retenu. Sans VM concernée, la réponse est « deb » : elle
|
||||
ne change rien, et laisser passer « snap » réactiverait snapd pour
|
||||
rien."""
|
||||
stores = self._extras["app_stores"]
|
||||
widget = self._widget("#f_store")
|
||||
if not stores or widget is None or not self._app_store_needed():
|
||||
return "deb"
|
||||
index = widget.pressed_index
|
||||
if index is None or not (0 <= index < len(stores)):
|
||||
return stores[0][0]
|
||||
return stores[index][0]
|
||||
|
||||
def _mise_usable(self) -> bool:
|
||||
return mise_usable(self.vms, self._extras)
|
||||
|
||||
def _python_provider(self) -> str:
|
||||
"""« mise », « pyenv », ou rien — c'est-à-dire « automatique ».
|
||||
|
||||
Rien, et surtout pas « pyenv », quand mise n'est servi par aucune
|
||||
architecture retenue. « mise est indisponible » ne veut pas dire
|
||||
« l'utilisateur exige pyenv » : la nuance décide de tout, puisqu'un
|
||||
choix EXPLICITE écarte le Python de la distribution. Sur s390x,
|
||||
renvoyer « pyenv » forçait la compilation de CPython — celle dont gcc
|
||||
15.2 ne revient pas."""
|
||||
widget = self._widget("#f_python")
|
||||
if widget is None or not self._mise_usable():
|
||||
return ""
|
||||
return "pyenv" if widget.pressed_index == 1 else "mise"
|
||||
|
||||
def _timezone(self) -> str:
|
||||
"""Le fuseau des VM. Un champ vidé retombe sur celui de l'hôte plutôt
|
||||
que sur rien : sans valeur, la VM démarrerait en UTC."""
|
||||
widget = self._widget("#f_tz")
|
||||
saisi = widget.value.strip() if widget is not None else ""
|
||||
return saisi or self._extras["timezone"]
|
||||
|
||||
def _extras_disk_gb(self, vm, tools) -> int:
|
||||
return extras_disk_gb(vm, tools, self._extras)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Redessiner
|
||||
# ------------------------------------------------------------------ #
|
||||
def _render_store(self) -> None:
|
||||
"""Grise le choix quand aucune VM ne le concerne, et dit pourquoi."""
|
||||
widget = self._widget("#f_store")
|
||||
if widget is None:
|
||||
return
|
||||
needed = self._app_store_needed()
|
||||
_installe, quelque_chose = self._install_state()
|
||||
widget.disabled = not (needed and quelque_chose)
|
||||
note = self._widget("#storewarn")
|
||||
if note is not None:
|
||||
note.update(
|
||||
""
|
||||
if needed
|
||||
else f" {t('No graphical VM on a snap-based distro.')}"
|
||||
)
|
||||
|
||||
def _render_tools(self) -> None:
|
||||
"""Grise chaque case qu'AUCUNE VM retenue ne peut recevoir, et NOMME
|
||||
ce qui sera écarté.
|
||||
|
||||
Une case par outil, et non un blocage en bloc : sur un parc de
|
||||
serveurs les IDE se grisent, la compilation mobile reste offerte.
|
||||
Cocher Android Studio sur un parc ARM ne produit rien — le dire ici
|
||||
évite de le découvrir dans le journal d'installation."""
|
||||
tab = self._extras
|
||||
if not tab["vm_tools"]:
|
||||
return
|
||||
installe, quelque_chose = self._install_state()
|
||||
for key, _label, _hint in tab["vm_tools"]:
|
||||
widget = self._widget(f"#f_tool_{key}")
|
||||
if widget is None:
|
||||
continue
|
||||
usable = any(self._tools_for_vm(vm, (key,)) for vm in self.vms)
|
||||
# Un outil de la phase « après » vit DANS le dépôt ERPLibre :
|
||||
# sans installation, il n'a rien où s'installer, bureau ou pas.
|
||||
offert = (
|
||||
installe
|
||||
if tab["tool_phases"].get(key) == "after"
|
||||
else quelque_chose
|
||||
)
|
||||
widget.disabled = not (usable and offert)
|
||||
picked = self._vm_tools()
|
||||
skipped = sorted(
|
||||
{
|
||||
vm["name"]
|
||||
for vm in self.vms
|
||||
for k in picked
|
||||
if k not in self._tools_for_vm(vm, picked)
|
||||
}
|
||||
)
|
||||
note = self._widget("#toolwarn")
|
||||
if note is not None:
|
||||
note.update(
|
||||
f" ⚠ {t('Partly skipped (arch or desktop):')} "
|
||||
f"{', '.join(skipped)}"
|
||||
if skipped
|
||||
else ""
|
||||
)
|
||||
|
||||
def _render_mise(self) -> None:
|
||||
"""Grise le choix quand aucune VM retenue n'est servie par mise, ou
|
||||
quand rien ne s'installe, et NOMME les architectures qui retomberont
|
||||
sur pyenv."""
|
||||
widget = self._widget("#f_python")
|
||||
if widget is None:
|
||||
return
|
||||
installe, _quelque_chose = self._install_state()
|
||||
widget.disabled = not (self._mise_usable() and installe)
|
||||
ecartees = sorted(
|
||||
{
|
||||
vm["arch"]
|
||||
for vm in self.vms
|
||||
if vm.get("arch") not in self._extras["mise_arches"]
|
||||
}
|
||||
)
|
||||
note = self._widget("#miswarn")
|
||||
if note is not None:
|
||||
note.update(
|
||||
f" ⚠ {t('mise has no binary for:')} "
|
||||
f"{', '.join(ecartees)} — {t('those VMs use pyenv')}"
|
||||
if ecartees
|
||||
else ""
|
||||
)
|
||||
|
||||
def render_extras(self) -> None:
|
||||
"""Les trois, dans l'ordre. Appelée à la fin de chaque recalcul."""
|
||||
self._render_mise()
|
||||
self._render_store()
|
||||
self._render_tools()
|
||||
|
||||
def _sync_install_deps(self) -> None:
|
||||
"""Grise ce que le choix d'installation rend SANS EFFET.
|
||||
|
||||
Trois états et non deux, parce que la commande distante en a trois :
|
||||
rien du tout, un bureau seul, ou une installation complète. Sans
|
||||
installation MAIS avec un bureau, le magasin d'applications et les
|
||||
outils de la phase « avant » servent encore — les griser mentirait
|
||||
autant que de laisser actif ce qui ne fait rien. La branche, le
|
||||
profil et l'interpréteur Python, eux, ne servent qu'à l'installation.
|
||||
|
||||
Le type de VM et le suivi ne sont jamais grisés : le premier est
|
||||
l'autre moitié de la décision, le second regarde la VM arriver même
|
||||
quand rien ne s'installe."""
|
||||
installe, quelque_chose = self._install_state()
|
||||
for cible, actif in (
|
||||
("#f_branch", installe),
|
||||
("#f_profile_install", installe),
|
||||
("#f_prod", quelque_chose),
|
||||
):
|
||||
widget = self._widget(cible)
|
||||
if widget is not None:
|
||||
widget.disabled = not actif
|
||||
# Le magasin, les outils et l'interpréteur Python ont leur PROPRE
|
||||
# raison de se griser (architecture, bureau, distribution) : ils
|
||||
# composent les deux dans « _render_* », qui a le dernier mot.
|
||||
# Le titre suit ses champs : une section entière se lit inactive d'un
|
||||
# coup d'œil, au lieu de se déduire de trois widgets ternes.
|
||||
for cible, actif in (
|
||||
("#t_store", quelque_chose),
|
||||
("#t_tools", quelque_chose),
|
||||
("#t_python", installe),
|
||||
):
|
||||
widget = self._widget(cible)
|
||||
if widget is not None:
|
||||
widget.set_class(not actif, "off")
|
||||
# Les rangées portent les mêmes choix, par VM.
|
||||
for i in range(len(getattr(self, "rows", ()))):
|
||||
for cible in (f"#v{i}_branch", f"#v{i}_prof"):
|
||||
widget = self._widget(cible)
|
||||
if widget is not None:
|
||||
widget.disabled = not installe
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Les messages, et la spec
|
||||
# ------------------------------------------------------------------ #
|
||||
def extras_on_select(self, event) -> bool:
|
||||
"""Traite le sélecteur de fuseau. Rend True quand c'est fait, pour
|
||||
que l'appelant s'arrête là.
|
||||
|
||||
« libre… » révèle la saisie ; un fuseau choisi la referme et y
|
||||
recopie le nom, seule valeur que lit `extras_values` — un seul
|
||||
endroit porte la réponse."""
|
||||
if getattr(event.select, "id", "") != "f_tz_sel":
|
||||
return False
|
||||
free = event.value is FREE
|
||||
field = self._widget("#f_tz")
|
||||
if field is None:
|
||||
return True
|
||||
field.display = free
|
||||
field.disabled = not free
|
||||
if free:
|
||||
field.focus()
|
||||
elif isinstance(event.value, str):
|
||||
field.value = event.value
|
||||
return True
|
||||
|
||||
def extras_values(self) -> dict:
|
||||
"""Le fragment de spec que ces réglages produisent."""
|
||||
return {
|
||||
"timezone": self._timezone(),
|
||||
"desktop": self._default_desktop(),
|
||||
"vm_tools": self._vm_tools(),
|
||||
"python_provider": self._python_provider(),
|
||||
"app_store": self._app_store(),
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ from script.todo.deploy_form_lib import (
|
|||
res_row_widgets,
|
||||
t,
|
||||
)
|
||||
from script.todo.deploy_form_extras import ExtrasMixin
|
||||
from script.todo.deploy_form_plan import PlanMixin, preview_screen
|
||||
|
||||
# Aucun disque orphelin à craindre : les disques d'un Proxmox distant vivent
|
||||
|
|
@ -100,6 +101,14 @@ def build_spec(vms, existants, form):
|
|||
"add_ssh_config": form["add_ssh_config"],
|
||||
"install": form["install"],
|
||||
"python_provider": form.get("python_provider") or "",
|
||||
# Ce qui décrit le SYSTÈME INVITÉ, et non l'hyperviseur : il valait
|
||||
# déjà sur libvirt, il vaut ici. Sans ces cinq clés, une VM créée sur
|
||||
# Proxmox naissait sans bureau, sans outils et en UTC.
|
||||
"timezone": form.get("timezone") or "",
|
||||
"desktop": form.get("desktop") or "",
|
||||
"vm_tools": tuple(form.get("vm_tools") or ()),
|
||||
"app_store": form.get("app_store") or "deb",
|
||||
"prod": bool((form.get("install") or {}).get("prod")),
|
||||
"monitor": form["monitor"],
|
||||
"parallelism": form["parallelism"],
|
||||
}
|
||||
|
|
@ -136,6 +145,8 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
|||
ctx.get("branches") or ["master"], ctx.get("branch_current")
|
||||
)
|
||||
profiles = ctx.get("install_profiles") or []
|
||||
# {clé de saveur: suffixe de nom} — décrit par todo.py, source unique.
|
||||
desktop_suffixes = dict(ctx.get("desktop_suffixes") or {})
|
||||
stockages = ctx.get("storages") or []
|
||||
ponts = ctx.get("bridges") or []
|
||||
# {système: (libellé, commande)} — ce qu'un système impose d'installer.
|
||||
|
|
@ -152,7 +163,7 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
|||
def entry_label(e):
|
||||
return f"{e['distro']} {e['version']} [{e['arch']}] {e['name']}"
|
||||
|
||||
class ProxmoxForm(PlanMixin, App):
|
||||
class ProxmoxForm(ExtrasMixin, PlanMixin, App):
|
||||
TITLE = t("Deploy one or more ERPLibre VMs on Proxmox VE!")
|
||||
BINDINGS = [
|
||||
("f5", "deploy", t("Deploy")),
|
||||
|
|
@ -192,6 +203,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)
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# L'écran
|
||||
|
|
@ -295,6 +307,7 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
|||
value=True,
|
||||
id="f_sshcfg",
|
||||
)
|
||||
yield from self.compose_vm_type()
|
||||
# La case commande TOUTE installation — ERPLibre, Odoo,
|
||||
# mais aussi l'hyperviseur Proxmox VE d'une VM imbriquée.
|
||||
# Nommée « ERPLibre », elle laissait croire qu'un système
|
||||
|
|
@ -323,22 +336,9 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
|||
allow_blank=False,
|
||||
id="f_branch",
|
||||
)
|
||||
# Le même choix qu'en QEMU/KVM : mise pose un CPython
|
||||
# PRÉCOMPILÉ, pyenv le COMPILE. Sans cette section,
|
||||
# l'écran envoyait toujours « automatique » — et comme
|
||||
# mise n'est jamais installé d'office, c'était pyenv, donc
|
||||
# une compilation de Python. Rapporté sur une VM Arch.
|
||||
yield Static(
|
||||
t("Python interpreter:"),
|
||||
id="t_python",
|
||||
classes="grouptitle",
|
||||
)
|
||||
with RadioSet(id="f_python"):
|
||||
yield RadioButton(
|
||||
t("mise (precompiled, faster)"), value=True
|
||||
)
|
||||
yield RadioButton(t("pyenv (compiles from source)"))
|
||||
yield Static("", id="miswarn")
|
||||
yield from self.compose_install_extras()
|
||||
yield from self.compose_timezone()
|
||||
yield from self.compose_python()
|
||||
# Hors de la section « Installation » : le suivi regarde la
|
||||
# VM ARRIVER, même quand rien ne s'installe. Rangé dedans,
|
||||
# il se serait grisé avec elle.
|
||||
|
|
@ -352,10 +352,21 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
|||
id="f_monitor",
|
||||
)
|
||||
yield Static(f" {t('Parallelism')}")
|
||||
# Cochée, la case donne une exécution PAR installation :
|
||||
# le plafond du nombre de CPU ne s'applique plus.
|
||||
# Décochée, le nombre reprend la main, et son défaut suit
|
||||
# l'HÔTE PROXMOX — l'écran restait figé à quatre choix et
|
||||
# en proposait un, quel que soit le nombre de cœurs.
|
||||
yield Checkbox(
|
||||
t("One run per install"),
|
||||
value=True,
|
||||
id="f_par_all",
|
||||
)
|
||||
yield Select(
|
||||
[(str(n), n) for n in (1, 2, 3, 4)],
|
||||
value=1,
|
||||
[(str(n), n) for n in range(1, ctx["host_cpu"] + 1)],
|
||||
value=ctx["host_cpu"],
|
||||
allow_blank=False,
|
||||
disabled=True,
|
||||
id="f_par",
|
||||
)
|
||||
with Vertical(id="right"):
|
||||
|
|
@ -458,6 +469,8 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
|||
ctx["host_cpu"],
|
||||
self.custom,
|
||||
self.overrides,
|
||||
self._default_desktop(),
|
||||
desktop_suffixes,
|
||||
)
|
||||
# Ce qu'un système IMPOSE d'installer, posé sur le MODÈLE : le
|
||||
# déploiement lit « install_cmd » VM par VM.
|
||||
|
|
@ -470,12 +483,17 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
|||
)
|
||||
# Le supplément d'ERPLibre ne vaut que pour les VM qui l'auront
|
||||
# vraiment : une VM Proxmox ne clonera pas le dépôt.
|
||||
if self.query_one("#f_install", Checkbox).value:
|
||||
commun = (self._install() or {}).get("cmd") or ""
|
||||
for row in self.rows:
|
||||
cmd_vm = row["vm"].get("install_cmd") or commun
|
||||
if cmd_vm.strip() not in no_erplibre:
|
||||
row["disk_gb"] += ctx.get("extra_disk_gb", 0)
|
||||
installe = self.query_one("#f_install", Checkbox).value
|
||||
commun = (self._install() or {}).get("cmd") or ""
|
||||
outils = self._vm_tools()
|
||||
for row in self.rows:
|
||||
cmd_vm = row["vm"].get("install_cmd") or commun
|
||||
if installe and cmd_vm.strip() not in no_erplibre:
|
||||
row["disk_gb"] += ctx.get("extra_disk_gb", 0)
|
||||
# Le bureau et les outils ne pèsent que sur les VM qui les
|
||||
# reçoivent RÉELLEMENT : Android Studio n'existe qu'en
|
||||
# x86_64, les extensions GNOME n'ont de sens que sous GNOME.
|
||||
row["disk_gb"] += self._extras_disk_gb(row["vm"], outils)
|
||||
for row, entry in zip(self.rows, entries):
|
||||
cle = entry_key(entry)
|
||||
row["custom"] = bool(self.overrides.get(cle))
|
||||
|
|
@ -489,7 +507,18 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
|||
),
|
||||
)
|
||||
self._render_plan()
|
||||
self._render_mise()
|
||||
self.render_extras()
|
||||
|
||||
def _auto_name(self, index):
|
||||
"""Le nom du catalogue, suffixé du bureau : ce que la VM
|
||||
reprendrait si on effaçait le sien."""
|
||||
from script.todo.deploy_form_lib import vm_name
|
||||
|
||||
return vm_name(
|
||||
self._plan_entries()[index]["name"],
|
||||
self.rows[index]["vm"].get("desktop"),
|
||||
desktop_suffixes,
|
||||
)
|
||||
|
||||
def _vmid_start(self):
|
||||
brut = self.query_one("#f_vmid", Input).value.strip()
|
||||
|
|
@ -634,6 +663,10 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
|||
self.arch = arches[event.index]
|
||||
self._reload_catalog()
|
||||
return
|
||||
if event.radio_set.id in ("f_type", "f_store"):
|
||||
# Le bureau pèse sur le disque et change le nom des VM.
|
||||
self._refresh_after(remonter=True)
|
||||
return
|
||||
if event.radio_set.id == "f_profile":
|
||||
choix = ("1", "2", "3", "4", "custom")[event.index]
|
||||
self.profile = choix
|
||||
|
|
@ -649,28 +682,17 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
|||
self._clear_overrides(tuple(RES_FIELDS))
|
||||
self._refresh_after(remonter=True)
|
||||
|
||||
def _sync_install_deps(self) -> None:
|
||||
"""Grise ce que la case rend sans effet : la branche et le profil.
|
||||
|
||||
Le suivi n'en fait pas partie — il regarde la VM arriver même
|
||||
quand rien ne s'installe."""
|
||||
installe = self.query_one("#f_install", Checkbox).value
|
||||
self._render_mise()
|
||||
for cible in ("#f_profile_install", "#f_branch"):
|
||||
try:
|
||||
self.query_one(cible).disabled = not installe
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.query_one("#t_install").set_class(not installe, "off")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_checkbox_changed(self, event) -> None:
|
||||
if event.checkbox.id == "f_install":
|
||||
# Le disque d'ERPLibre entre — ou sort — du total.
|
||||
self._sync_install_deps()
|
||||
self._refresh_after()
|
||||
elif event.checkbox.id == "f_par_all":
|
||||
self.query_one("#f_par", Select).disabled = event.value
|
||||
elif str(event.checkbox.id or "").startswith("f_tool_"):
|
||||
# Un IDE de plus, c'est un disque plus grand : le plan doit
|
||||
# le montrer AVANT de déployer, pas après une heure.
|
||||
self._refresh_after()
|
||||
|
||||
def on_input_changed(self, event) -> None:
|
||||
if event.input.id == "f_vmid":
|
||||
|
|
@ -693,6 +715,8 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
|||
def on_select_changed(self, event) -> None:
|
||||
if self._syncing:
|
||||
return
|
||||
if self.extras_on_select(event):
|
||||
return
|
||||
ident = event.select.id or ""
|
||||
# La marque de génération ne concerne QUE les widgets de rangée :
|
||||
# un widget global n'en porte pas. L'exiger de tous revenait à
|
||||
|
|
@ -767,53 +791,6 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
|||
# ---------------------------------------------------------------- #
|
||||
# Les actions
|
||||
# ---------------------------------------------------------------- #
|
||||
def _mise_usable(self):
|
||||
"""Au moins une VM retenue tourne sur une architecture servie par
|
||||
mise. Sinon le choix ne veut rien dire."""
|
||||
servies = ctx.get("mise_arches") or ()
|
||||
return any(vm["arch"] in servies for vm in self.vms)
|
||||
|
||||
def _python_provider(self):
|
||||
"""« mise », « pyenv », ou rien — c'est-à-dire « automatique ».
|
||||
|
||||
Rien, et surtout pas « pyenv », quand mise n'est servi par aucune
|
||||
architecture retenue : « mise indisponible » ne veut pas dire
|
||||
« l'utilisateur exige pyenv », et un choix EXPLICITE écarte le
|
||||
Python de la distribution."""
|
||||
if not self._mise_usable():
|
||||
return ""
|
||||
try:
|
||||
index = self.query_one("#f_python", RadioSet).pressed_index
|
||||
except Exception:
|
||||
return ""
|
||||
return "pyenv" if index == 1 else "mise"
|
||||
|
||||
def _render_mise(self):
|
||||
"""Grise le choix quand aucune VM retenue n'est servie par mise, ou
|
||||
quand rien ne s'installe, et NOMME les architectures qui
|
||||
retomberont sur pyenv."""
|
||||
servies = ctx.get("mise_arches") or ()
|
||||
try:
|
||||
installe = self.query_one("#f_install", Checkbox).value
|
||||
self.query_one("#f_python", RadioSet).disabled = not (
|
||||
self._mise_usable() and installe
|
||||
)
|
||||
ecartees = sorted(
|
||||
{
|
||||
vm["arch"]
|
||||
for vm in self.vms
|
||||
if vm["arch"] not in servies
|
||||
}
|
||||
)
|
||||
self.query_one("#miswarn", Static).update(
|
||||
f" ⚠ {t('mise has no binary for:')} "
|
||||
f"{', '.join(ecartees)} — {t('those VMs use pyenv')}"
|
||||
if ecartees
|
||||
else ""
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _install(self):
|
||||
if not self.query_one("#f_install", Checkbox).value:
|
||||
return None
|
||||
|
|
@ -825,6 +802,9 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
|||
)
|
||||
return {
|
||||
"branch": self.query_one("#f_branch", Select).value,
|
||||
# /opt et service confiné, comme en QEMU/KVM : le choix ne
|
||||
# regarde pas l'hyperviseur, il regarde le système invité.
|
||||
"prod": self.query_one("#f_prod", Checkbox).value,
|
||||
"label": label,
|
||||
"cmd": cmd,
|
||||
}
|
||||
|
|
@ -841,11 +821,18 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
|||
"start": self.query_one("#f_start", Checkbox).value,
|
||||
"add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value,
|
||||
"install": self._install(),
|
||||
**self.extras_values(),
|
||||
# Le suivi est demandé au NIVEAU DU DÉPLOIEMENT : une VM sans
|
||||
# ERPLibre se suit aussi (cloud-init, puis relevé système).
|
||||
"python_provider": self._python_provider(),
|
||||
"monitor": self.query_one("#f_monitor", Checkbox).value,
|
||||
"parallelism": self.query_one("#f_par", Select).value,
|
||||
# Une exécution par installation : le nombre de VM retenues
|
||||
# fait foi. Le déploiement le borne ensuite à ce même nombre,
|
||||
# donc une valeur haute ne crée aucun travailleur inutile.
|
||||
"parallelism": (
|
||||
max(1, len(self.vms))
|
||||
if self.query_one("#f_par_all", Checkbox).value
|
||||
else self.query_one("#f_par", Select).value
|
||||
),
|
||||
}
|
||||
|
||||
def action_deploy(self) -> None:
|
||||
|
|
|
|||
|
|
@ -833,12 +833,11 @@ class ProxmoxMenuMixin:
|
|||
# La branche du dépôt : c'est elle qu'on déploie le plus souvent.
|
||||
"branch_current": self._qemu_repo_branch(),
|
||||
"install_profiles": self._qemu_install_profiles(),
|
||||
# Les architectures pour lesquelles mise publie un CPython
|
||||
# précompilé : sans ce choix, l'écran Proxmox envoyait toujours
|
||||
# « automatique », donc pyenv, qui COMPILE Python (1 à 3 min sur
|
||||
# une machine récente, bien plus sous émulation) — rapporté sur
|
||||
# une VM Arch.
|
||||
"mise_arches": self.QEMU_MISE_ARCHES,
|
||||
# Type de VM, magasin d'applications, outils, fuseau,
|
||||
# interpréteur Python : les réglages du système INVITÉ, qui ne
|
||||
# regardent pas l'hyperviseur. Cet écran n'en portait que trois —
|
||||
# une VM créée ici naissait sans bureau, sans outils et en UTC.
|
||||
**self._qemu_guest_context(),
|
||||
# Même règle qu'en QEMU/KVM : un système peut IMPOSER ce qu'on
|
||||
# installe dessus. Un Proxmox imbriqué recevait sinon ERPLibre et
|
||||
# Odoo 18, comme l'écran d'à côté avant correction.
|
||||
|
|
@ -887,15 +886,30 @@ class ProxmoxMenuMixin:
|
|||
la demande initiale quand ERPLibre s'installe. Ici la marge se perdait
|
||||
entre l'écran et « qm resize ».
|
||||
"""
|
||||
from script.todo.deploy_form_extras import (
|
||||
extras_disk_gb,
|
||||
extras_tables,
|
||||
)
|
||||
|
||||
demande = vm.get("disk") or ""
|
||||
install = spec.get("install") or {}
|
||||
cmd = vm.get("install_cmd") or install.get("cmd") or ""
|
||||
if not self._qemu_installs_erplibre(install.get("branch"), cmd):
|
||||
return demande
|
||||
gigs = self._parse_disk_gb(demande)
|
||||
if not gigs:
|
||||
return demande
|
||||
return f"{gigs + self.ERPLIBRE_EXTRA_DISK_GB}G"
|
||||
install = spec.get("install") or {}
|
||||
cmd = vm.get("install_cmd") or install.get("cmd") or ""
|
||||
marge = 0
|
||||
if self._qemu_installs_erplibre(install.get("branch"), cmd):
|
||||
marge += self.ERPLIBRE_EXTRA_DISK_GB
|
||||
# Le bureau et les outils pèsent aussi, et sur la VM QUI LES REÇOIT :
|
||||
# une VM ARM n'aura pas Android Studio, un serveur aucun des IDE. Le
|
||||
# plan les additionne déjà à l'écran ; sans eux ici, la VM naissait
|
||||
# avec le disque d'un serveur nu et GNOME le remplissait.
|
||||
marge += extras_disk_gb(
|
||||
dict(vm, desktop=vm.get("desktop") or spec.get("desktop") or ""),
|
||||
spec.get("vm_tools") or (),
|
||||
extras_tables(self._qemu_guest_context()),
|
||||
)
|
||||
return f"{gigs + marge}G" if marge else demande
|
||||
|
||||
def _pve_vm_commands(self, mod, vm, spec):
|
||||
"""Les commandes de création d'UNE VM, dans l'ordre : l'image puis
|
||||
|
|
@ -1107,6 +1121,33 @@ class ProxmoxMenuMixin:
|
|||
fh.write("\n".join(entete) + "\n")
|
||||
return chemin
|
||||
|
||||
def _pve_set_timezone(self, cible, spec):
|
||||
"""Pose le fuseau DANS la VM, par ssh.
|
||||
|
||||
La voie libvirt le donne à cloud-init, qui écrit /etc/timezone au
|
||||
premier démarrage. « qm set » n'a pas d'équivalent : le cloud-init de
|
||||
Proxmox ne règle que l'utilisateur, la clé et le réseau. Une VM créée
|
||||
ici restait donc en UTC — et on ne s'en aperçoit qu'aux horodatages,
|
||||
parfois des jours plus tard.
|
||||
|
||||
AVANT l'installation, pour que le journal porte déjà la bonne heure.
|
||||
Un nom IANA, jamais un décalage : « UTC-5 » ne dit rien de l'heure
|
||||
d'été, et timedatectl le refuse.
|
||||
"""
|
||||
fuseau = (spec.get("timezone") or "").strip()
|
||||
if not fuseau:
|
||||
return False
|
||||
code, sortie = self._pve_ssh(
|
||||
cible, f"sudo timedatectl set-timezone {shlex.quote(fuseau)}"
|
||||
)
|
||||
if code:
|
||||
# Nommé et non tu : la VM reste en UTC, et c'est une surprise
|
||||
# qu'on veut avoir maintenant plutôt qu'au premier journal.
|
||||
print(f" ⚠ {t('timezone not set')} : {fuseau} ({code})")
|
||||
return False
|
||||
print(f" ✓ {t('Timezone')} : {fuseau}")
|
||||
return True
|
||||
|
||||
def _pve_write_guide(self, cible, vm, spec, mod):
|
||||
"""Pose le guide de connexion et l'identité git DANS la VM.
|
||||
|
||||
|
|
@ -1331,6 +1372,8 @@ class ProxmoxMenuMixin:
|
|||
# s'installe, et l'installation ne le touche pas.
|
||||
if vm["alias"] and mod_qemu:
|
||||
self._pve_write_guide(vm["alias"], vm, spec, mod_qemu)
|
||||
if vm["alias"]:
|
||||
self._pve_set_timezone(vm["alias"], spec)
|
||||
joignables.append(vm)
|
||||
install = spec.get("install")
|
||||
# Rendu à l'appelant pour son sommaire : lui seul sait ce qui a été
|
||||
|
|
@ -1383,8 +1426,24 @@ class ProxmoxMenuMixin:
|
|||
branche,
|
||||
{n: alias.get(n, n) for n in noms},
|
||||
finale,
|
||||
# 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 "",
|
||||
python_provider=spec.get("python_provider") or "",
|
||||
app_store=spec.get("app_store") or "deb",
|
||||
vm_tools=spec.get("vm_tools") or (),
|
||||
pve=cartes_pve,
|
||||
# Ce que sont ces VM, pris de la SPEC. Le suivi le demandait
|
||||
# à virsh, qui ne connaît que les domaines d'ici.
|
||||
meta={
|
||||
vm["name"]: (
|
||||
vm.get("distro"),
|
||||
vm.get("version"),
|
||||
vm.get("arch") or "amd64",
|
||||
)
|
||||
for vm in joignables
|
||||
},
|
||||
)
|
||||
return resultat
|
||||
# Sans suivi mais avec quelque chose à installer : en série, sortie à
|
||||
|
|
@ -1397,7 +1456,11 @@ class ProxmoxMenuMixin:
|
|||
branche,
|
||||
alias.get(vm["name"], vm["name"]),
|
||||
vm.get("install_cmd") or commun,
|
||||
False,
|
||||
bool(spec.get("prod")),
|
||||
desktop=spec.get("desktop") or "",
|
||||
python_provider=spec.get("python_provider") or "",
|
||||
app_store=spec.get("app_store") or "deb",
|
||||
vm_tools=spec.get("vm_tools") or (),
|
||||
)
|
||||
return resultat
|
||||
|
||||
|
|
@ -1564,6 +1627,10 @@ class ProxmoxMenuMixin:
|
|||
"install": install,
|
||||
"monitor": True,
|
||||
"python_provider": "",
|
||||
# La voie par questions ne demande pas le fuseau — l'écran le
|
||||
# fait. Sans ce défaut, elle laissait la VM en UTC, alors que la
|
||||
# voie libvirt reprend le fuseau de l'hôte depuis toujours.
|
||||
"timezone": self._qemu_host_timezone(),
|
||||
}
|
||||
joignables = self._pve_after_create(
|
||||
host, spec_finale, [nom], cle_locale
|
||||
|
|
|
|||
|
|
@ -271,6 +271,7 @@ class QemuDeployMixin:
|
|||
app_store="deb",
|
||||
vm_tools=(),
|
||||
pve=None,
|
||||
meta=None,
|
||||
):
|
||||
"""Lance l'install ERPLibre en parallèle DÉTACHÉE sur les VM et ouvre
|
||||
le dashboard Textual. Quitter le dashboard n'arrête pas les installs.
|
||||
|
|
@ -279,7 +280,10 @@ class QemuDeployMixin:
|
|||
`prod` : install /opt/erplibre + service SELinux confiné.
|
||||
`vm_tools` : outils cochés pour tout le parc, filtrés machine par
|
||||
machine (Android Studio n'existe qu'en x86_64, les extensions GNOME
|
||||
n'ont pas de sens sous Cinnamon)."""
|
||||
n'ont pas de sens sous Cinnamon).
|
||||
`meta` : {nom: (distro, version, arch)} quand l'appelant SAIT ce que
|
||||
sont ces VM. Sans elle, on le demande à virsh — juste ici, donc faux
|
||||
pour une VM qui vit sur un Proxmox distant."""
|
||||
from script.todo.qemu_install_monitor import (
|
||||
launch_installs,
|
||||
run_monitor,
|
||||
|
|
@ -316,7 +320,13 @@ class QemuDeployMixin:
|
|||
for name in names:
|
||||
ip = ip_map.get(name)
|
||||
if ip:
|
||||
d, v, a = (
|
||||
# Ce que l'appelant sait d'abord. Sinon virsh — mais virsh
|
||||
# ne connaît QUE les domaines d'ici : sur une VM posée sur un
|
||||
# Proxmox distant il ne répond rien, ou pire, il répond pour
|
||||
# un domaine local qui porte le même nom. L'architecture
|
||||
# décide des outils installés : une VM ARM prise pour x86_64
|
||||
# recevait Android Studio, qui n'existe pas pour elle.
|
||||
d, v, a = (meta or {}).get(name) or (
|
||||
self._qemu_vm_meta(name, mod)
|
||||
if mod
|
||||
else (None, None, None)
|
||||
|
|
@ -1266,7 +1276,6 @@ class QemuDeployMixin:
|
|||
if self._qemu_distro_profile(d)
|
||||
},
|
||||
"ssh_key": self._qemu_default_ssh_key(),
|
||||
"timezone": self._qemu_host_timezone(),
|
||||
"host_cpu": os.cpu_count() or 2,
|
||||
"free_ram": self._host_free_ram_mb(),
|
||||
# La place du système de fichiers qui portera les qcow2. Mesurée
|
||||
|
|
@ -1279,47 +1288,7 @@ class QemuDeployMixin:
|
|||
"ram_presets": self._QEMU_RAM_PRESETS,
|
||||
"disk_presets": self._QEMU_DISK_PRESETS,
|
||||
"extra_disk_gb": self.ERPLIBRE_EXTRA_DISK_GB,
|
||||
"desktop_disk_gb": self.QEMU_DESKTOP_EXTRA_DISK_GB,
|
||||
"mise_arches": self.QEMU_MISE_ARCHES,
|
||||
"app_stores": [(k, t(lbl)) for k, lbl in self.QEMU_APP_STORES],
|
||||
"timezones": self._qemu_timezone_choices(
|
||||
self._qemu_host_timezone()
|
||||
),
|
||||
"snap_distros": self.QEMU_SNAP_DISTROS,
|
||||
"vm_tools": self._qemu_vm_tool_choices(),
|
||||
"vm_tool_disk": {
|
||||
k: v["disk_gb"] for k, v in self._QEMU_VM_TOOLS.items()
|
||||
},
|
||||
"vm_tool_arches": {
|
||||
k: v["arches"] for k, v in self._QEMU_VM_TOOLS.items()
|
||||
},
|
||||
"vm_tool_desktops": {
|
||||
k: v["desktops"] for k, v in self._QEMU_VM_TOOLS.items()
|
||||
},
|
||||
# « after » = l'outil vit DANS le dépôt ERPLibre (compilation
|
||||
# mobile, AVD, script Forgejo) : sans installation, il n'existe
|
||||
# pas, et la commande distante le saute en le nommant.
|
||||
"vm_tool_phases": {
|
||||
k: v.get("phase", "before")
|
||||
for k, v in self._QEMU_VM_TOOLS.items()
|
||||
},
|
||||
"vm_tool_needs_desktop": {
|
||||
k: v["needs_desktop"] for k, v in self._QEMU_VM_TOOLS.items()
|
||||
},
|
||||
"vm_tool_families": {
|
||||
k: v["families"] for k, v in self._QEMU_VM_TOOLS.items()
|
||||
},
|
||||
"distro_family": dict(self._QEMU_DISTRO_FAMILY),
|
||||
"desktop_suffixes": self._qemu_desktop_suffixes(),
|
||||
"desktops": [
|
||||
(k, v["label"]) for k, v in self._QEMU_DESKTOP.items()
|
||||
],
|
||||
"defaults": {
|
||||
"install": True,
|
||||
"add_ssh_config": True,
|
||||
"monitor": True,
|
||||
"prod": False,
|
||||
},
|
||||
**self._qemu_guest_context(),
|
||||
# L'aperçu passe par le MÊME constructeur que le déploiement.
|
||||
"build_command": lambda vm, spec, dry: " ".join(
|
||||
shlex.quote(p)
|
||||
|
|
|
|||
|
|
@ -70,6 +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_plan import ( # noqa: F401
|
||||
PlanMixin,
|
||||
preview_screen,
|
||||
|
|
@ -127,33 +128,11 @@ 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
|
||||
desktop_disk = ctx.get("desktop_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 {})
|
||||
# Outils de développement d'une VM graphique : [(clé, libellé, indice)] et
|
||||
# leurs contraintes, toutes décrites dans todo.py — le formulaire ne fait
|
||||
# que les afficher et rendre les cases cochées.
|
||||
vm_tools = list(ctx.get("vm_tools") or [])
|
||||
tool_disk = dict(ctx.get("vm_tool_disk") or {})
|
||||
# « after » = l'outil vit DANS le dépôt ERPLibre : sans installation, il
|
||||
# n'a rien où s'installer, bureau ou pas.
|
||||
tool_phases = dict(ctx.get("vm_tool_phases") or {})
|
||||
tool_arches = dict(ctx.get("vm_tool_arches") or {})
|
||||
tool_desktops = dict(ctx.get("vm_tool_desktops") or {})
|
||||
tool_needs_desktop = dict(ctx.get("vm_tool_needs_desktop") or {})
|
||||
tool_families = dict(ctx.get("vm_tool_families") or {})
|
||||
distro_family = dict(ctx.get("distro_family") or {})
|
||||
# Architectures pour lesquelles mise publie un binaire.
|
||||
mise_arches = set(ctx.get("mise_arches") or ())
|
||||
# [(clé, libellé)] des magasins d'applications, et les distributions qui
|
||||
# livrent snapd — la question n'a de sens que pour celles-là, graphiques.
|
||||
app_stores = list(ctx.get("app_stores") or [])
|
||||
snap_distros = set(ctx.get("snap_distros") or ())
|
||||
# Fuseaux proposés, celui de l'hôte en tête (voir todo.py).
|
||||
timezones = list(ctx.get("timezones") or [])
|
||||
defaults = ctx.get("defaults") or {}
|
||||
result = {"spec": None}
|
||||
|
||||
|
|
@ -169,7 +148,7 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
f"RAM≥{e['ram']}Mo {e['disk']}"
|
||||
)
|
||||
|
||||
class DeployForm(PlanMixin, App):
|
||||
class DeployForm(ExtrasMixin, PlanMixin, App):
|
||||
# Le socle porte la mise en page et les modales ; ne reste ici que ce
|
||||
# qui nomme les widgets propres à QEMU/KVM.
|
||||
CSS = (
|
||||
|
|
@ -215,6 +194,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)
|
||||
# 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
|
||||
|
|
@ -304,20 +284,7 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
f"\n {t('A VM edited on the right (marked) keeps its own.')}",
|
||||
id="scopetarget",
|
||||
)
|
||||
# Serveur par défaut : c'est ce que sert une image cloud,
|
||||
# et GNOME ajoute une à deux heures sur une architecture
|
||||
# émulée. Le plan annonce le surcoût disque.
|
||||
yield Static(t("VM type (default):"), classes="grouptitle")
|
||||
with RadioSet(id="f_type"):
|
||||
yield RadioButton(
|
||||
t("Server (no graphical interface)"),
|
||||
value=not defaults.get("desktop", ""),
|
||||
)
|
||||
for key, label in desktops:
|
||||
yield RadioButton(
|
||||
f"{t('Graphical (server + desktop):')} {label}",
|
||||
value=defaults.get("desktop", "") == key,
|
||||
)
|
||||
yield from self.compose_vm_type()
|
||||
# La case commande TOUTE installation — ERPLibre, Odoo, mais
|
||||
# aussi l'hyperviseur Proxmox VE. Nommée « ERPLibre », elle
|
||||
# laissait croire qu'un système Proxmox s'installerait
|
||||
|
|
@ -348,56 +315,8 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
allow_blank=not profiles,
|
||||
id="f_profile_install",
|
||||
)
|
||||
yield Checkbox(
|
||||
t("Production (/opt, confined)"),
|
||||
value=defaults.get("prod", False),
|
||||
id="f_prod",
|
||||
)
|
||||
if app_stores:
|
||||
yield Static(
|
||||
t("Application store:"),
|
||||
id="t_store",
|
||||
classes="grouptitle",
|
||||
)
|
||||
with RadioSet(id="f_store"):
|
||||
for i, (_k, label) in enumerate(app_stores):
|
||||
yield RadioButton(label, value=i == 0)
|
||||
yield Static("", id="storewarn")
|
||||
if vm_tools:
|
||||
# Une case par outil, et non une liste déroulante : ils
|
||||
# sont indépendants, et chacun se prend ou se laisse.
|
||||
yield Static(
|
||||
t("Development tools:"),
|
||||
id="t_tools",
|
||||
classes="grouptitle",
|
||||
)
|
||||
for key, label, hint in vm_tools:
|
||||
gb = tool_disk.get(key, 0)
|
||||
yield Checkbox(
|
||||
f"{label} +{gb} Go — {hint}",
|
||||
value=key in (defaults.get("tools") or ()),
|
||||
id=f"f_tool_{key}",
|
||||
)
|
||||
yield Static("", id="toolwarn")
|
||||
yield Static(t("Timezone"), classes="grouptitle")
|
||||
# Une liste plutôt qu'une saisie : un nom IANA mal
|
||||
# orthographié n'est pas refusé par cloud-init, il est
|
||||
# IGNORÉ — la VM reste en UTC et on ne s'en aperçoit
|
||||
# qu'aux horodatages. « libre… » garde la porte ouverte
|
||||
# aux six cents autres fuseaux de la base.
|
||||
yield Select(
|
||||
[(z, z) for z in timezones]
|
||||
+ [(t("free value…"), FREE)],
|
||||
value=(timezones[0] if timezones else SELECT_NULL),
|
||||
allow_blank=False,
|
||||
id="f_tz_sel",
|
||||
)
|
||||
yield Input(
|
||||
value=ctx.get("timezone") or "",
|
||||
placeholder=t("Timezone for the VMs"),
|
||||
id="f_tz",
|
||||
classes="freeval",
|
||||
)
|
||||
yield from self.compose_install_extras()
|
||||
yield from self.compose_timezone()
|
||||
yield Static("SSH", classes="grouptitle")
|
||||
yield Input(
|
||||
value=ctx.get("ssh_key") or "",
|
||||
|
|
@ -409,20 +328,7 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
value=defaults.get("add_ssh_config", True),
|
||||
id="f_sshcfg",
|
||||
)
|
||||
# mise pose un CPython précompilé, pyenv le compile.
|
||||
# Grisé quand AUCUNE des VM retenues n'est sur une
|
||||
# architecture que mise sert.
|
||||
yield Static(
|
||||
t("Python interpreter:"),
|
||||
id="t_python",
|
||||
classes="grouptitle",
|
||||
)
|
||||
with RadioSet(id="f_python"):
|
||||
yield RadioButton(
|
||||
t("mise (precompiled, faster)"), value=True
|
||||
)
|
||||
yield RadioButton(t("pyenv (compiles from source)"))
|
||||
yield Static("", id="miswarn")
|
||||
yield from self.compose_python()
|
||||
# Hors de la section « Installation » : le suivi
|
||||
# regarde la VM ARRIVER, même quand rien ne s'installe.
|
||||
# Rangé dans cette section, il se serait grisé avec elle —
|
||||
|
|
@ -596,17 +502,10 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
)
|
||||
if installe and cmd_vm.strip() not in no_erplibre:
|
||||
row["disk_gb"] += extra_disk
|
||||
if row["vm"].get("desktop"):
|
||||
row["disk_gb"] += desktop_disk
|
||||
# Même règle pour les outils, et pour la même raison : ils ne
|
||||
# pèsent que sur les VM qui les reçoivent réellement. Android
|
||||
# Studio n'existe qu'en x86_64, les extensions GNOME n'ont de
|
||||
# sens que sous GNOME — une VM qui ne les aura pas ne doit pas
|
||||
# se voir gonfler son disque.
|
||||
row["disk_gb"] += sum(
|
||||
tool_disk.get(k, 0)
|
||||
for k in self._tools_for_vm(row["vm"], tools)
|
||||
)
|
||||
# Le bureau et les outils ne pèsent que sur les VM qui
|
||||
# les reçoivent RÉELLEMENT : Android Studio n'existe qu'en
|
||||
# x86_64, les extensions GNOME n'ont de sens que sous GNOME.
|
||||
row["disk_gb"] += self._extras_disk_gb(row["vm"], tools)
|
||||
# Le plan doit MONTRER qu'une VM a été personnalisée : sans marque,
|
||||
# deux lignes aux ressources différentes n'ont aucune explication à
|
||||
# l'écran, et la surcharge est oubliée à la relecture. Le drapeau
|
||||
|
|
@ -617,163 +516,7 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
row["custom"] = bool(self.overrides.get(key))
|
||||
row["locked"] = key in self.locked
|
||||
self._render_plan()
|
||||
self._render_mise()
|
||||
self._render_store()
|
||||
self._render_tools()
|
||||
|
||||
def _vm_tools(self):
|
||||
"""Clés des outils cochés, dans l'ordre de la liste."""
|
||||
picked = []
|
||||
for key, _label, _hint in vm_tools:
|
||||
try:
|
||||
if self.query_one(f"#f_tool_{key}", Checkbox).value:
|
||||
picked.append(key)
|
||||
except Exception:
|
||||
continue
|
||||
return tuple(picked)
|
||||
|
||||
def _tools_for_vm(self, vm, tools):
|
||||
"""Outils qu'une VM donnée recevra vraiment.
|
||||
|
||||
Même filtre que todo.py côté déploiement : une VM ARM ne verra
|
||||
jamais Android Studio, une VM Cinnamon jamais les extensions GNOME,
|
||||
un serveur aucun des IDE — mais un serveur reçoit bien la
|
||||
compilation mobile, qui n'a rien à afficher, et une distribution
|
||||
sans apt ne la reçoit pas, son installateur n'existant que là."""
|
||||
out = []
|
||||
for key in tools:
|
||||
arches = tool_arches.get(key) or ()
|
||||
desks = tool_desktops.get(key) or ()
|
||||
fams = tool_families.get(key) or ()
|
||||
if tool_needs_desktop.get(key) and not vm.get("desktop"):
|
||||
continue
|
||||
if arches and vm["arch"] not in arches:
|
||||
continue
|
||||
if desks and vm.get("desktop") not in desks:
|
||||
continue
|
||||
if fams and distro_family.get(vm["distro"], "") not in fams:
|
||||
continue
|
||||
out.append(key)
|
||||
return out
|
||||
|
||||
def _render_tools(self):
|
||||
"""Grise chaque case qu'AUCUNE VM retenue ne peut recevoir, et
|
||||
NOMME ce qui sera écarté.
|
||||
|
||||
Une case par outil, et non un blocage en bloc : sur un parc de
|
||||
serveurs les IDE se grisent, la compilation mobile reste offerte.
|
||||
Cocher Android Studio sur un parc ARM ne produit rien — le dire ici
|
||||
évite de le découvrir dans le journal d'installation."""
|
||||
if not vm_tools:
|
||||
return
|
||||
installe, quelque_chose = self._install_state()
|
||||
for key, _label, _hint in vm_tools:
|
||||
usable = any(self._tools_for_vm(vm, (key,)) for vm in self.vms)
|
||||
offert = (
|
||||
installe
|
||||
if tool_phases.get(key) == "after"
|
||||
else quelque_chose
|
||||
)
|
||||
self.query_one(f"#f_tool_{key}", Checkbox).disabled = not (
|
||||
usable and offert
|
||||
)
|
||||
picked = self._vm_tools()
|
||||
skipped = sorted(
|
||||
{
|
||||
vm["name"]
|
||||
for vm in self.vms
|
||||
for k in picked
|
||||
if k not in self._tools_for_vm(vm, picked)
|
||||
}
|
||||
)
|
||||
self.query_one("#toolwarn", Static).update(
|
||||
f" ⚠ {t('Partly skipped (arch or desktop):')} "
|
||||
f"{', '.join(skipped)}"
|
||||
if skipped
|
||||
else ""
|
||||
)
|
||||
|
||||
def _render_mise(self):
|
||||
"""Grise le choix quand aucune VM retenue n'est servie par mise,
|
||||
et nomme les architectures qui retomberont sur pyenv."""
|
||||
usable = self._mise_usable()
|
||||
installe, _quelque_chose = self._install_state()
|
||||
self.query_one("#f_python", RadioSet).disabled = not (
|
||||
usable and installe
|
||||
)
|
||||
skipped = sorted(
|
||||
{
|
||||
vm["arch"]
|
||||
for vm in self.vms
|
||||
if vm["arch"] not in mise_arches
|
||||
}
|
||||
)
|
||||
msg = ""
|
||||
if skipped:
|
||||
msg = (
|
||||
f" ⚠ {t('mise has no binary for:')} "
|
||||
f"{', '.join(skipped)} — {t('those VMs use pyenv')}"
|
||||
)
|
||||
self.query_one("#miswarn", Static).update(msg)
|
||||
|
||||
def _python_provider(self):
|
||||
"""« mise », « pyenv », ou rien — c'est-à-dire « automatique ».
|
||||
|
||||
Rien, et surtout pas « pyenv », quand mise n'est servi par aucune
|
||||
architecture retenue. « mise est indisponible » ne veut pas dire
|
||||
« l'utilisateur exige pyenv » : la nuance décide de tout, puisqu'un
|
||||
choix EXPLICITE écarte le Python de la distribution. Sur s390x,
|
||||
renvoyer « pyenv » forçait la compilation de CPython — celle dont
|
||||
gcc 15.2 ne revient pas."""
|
||||
if not self._mise_usable():
|
||||
return ""
|
||||
index = self.query_one("#f_python", RadioSet).pressed_index
|
||||
return "pyenv" if index == 1 else "mise"
|
||||
|
||||
def _app_store(self):
|
||||
"""Magasin retenu. Sans VM concernée, la réponse est « deb » :
|
||||
elle ne change rien, et laisser passer « snap » réactiverait snapd
|
||||
pour rien."""
|
||||
if not app_stores or not self._app_store_needed():
|
||||
return "deb"
|
||||
index = self.query_one("#f_store", RadioSet).pressed_index
|
||||
if index is None or not (0 <= index < len(app_stores)):
|
||||
return app_stores[0][0]
|
||||
return app_stores[index][0]
|
||||
|
||||
def _app_store_needed(self):
|
||||
"""Au moins une VM graphique sur une distribution qui livre snapd."""
|
||||
return any(
|
||||
vm.get("desktop") and vm["distro"] in snap_distros
|
||||
for vm in self.vms
|
||||
)
|
||||
|
||||
def _render_store(self):
|
||||
"""Grise le choix quand aucune VM ne le concerne, et dit pourquoi."""
|
||||
if not app_stores:
|
||||
return
|
||||
needed = self._app_store_needed()
|
||||
_installe, quelque_chose = self._install_state()
|
||||
self.query_one("#f_store", RadioSet).disabled = not (
|
||||
needed and quelque_chose
|
||||
)
|
||||
self.query_one("#storewarn", Static).update(
|
||||
""
|
||||
if needed
|
||||
else f" {t('No graphical VM on a snap-based distro.')}"
|
||||
)
|
||||
|
||||
def _install_state(self):
|
||||
"""(une installation ?, quelque chose à installer ?).
|
||||
|
||||
Deux réponses et non une : sans installation mais avec un bureau,
|
||||
il se pose encore des paquets — le magasin d'applications et les
|
||||
outils de la phase « avant » gardent un effet."""
|
||||
installe = self.query_one("#f_install", Checkbox).value
|
||||
return installe, bool(installe or self._default_desktop())
|
||||
|
||||
def _mise_usable(self):
|
||||
return any(vm["arch"] in mise_arches for vm in self.vms)
|
||||
self.render_extras()
|
||||
|
||||
def _profile_cmd(self):
|
||||
"""Commande du profil choisi en haut : le défaut de chaque VM."""
|
||||
|
|
@ -811,14 +554,6 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
value = self.query_one("#f_branch", Select).value
|
||||
return value if isinstance(value, str) else branches[0]
|
||||
|
||||
def _default_desktop(self):
|
||||
"""« » pour un serveur, sinon la clé de la saveur choisie."""
|
||||
"""Type de VM par défaut. Chaque rangée peut s'en écarter."""
|
||||
index = self.query_one("#f_type", RadioSet).pressed_index
|
||||
if index is None or index < 1 or index > len(desktops):
|
||||
return ""
|
||||
return desktops[index - 1][0]
|
||||
|
||||
# -- panneau droit : une rangée de widgets par VM ---------------- #
|
||||
def _type_options(self):
|
||||
return [(t("Server"), SERVER)] + [
|
||||
|
|
@ -1000,52 +735,6 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
pass
|
||||
self._sync_free_inputs()
|
||||
|
||||
def _sync_install_deps(self) -> None:
|
||||
"""Grise ce que le choix d'installation rend SANS EFFET.
|
||||
|
||||
Trois états et non deux, parce que la commande distante en a
|
||||
trois : rien du tout, un bureau seul, ou une installation
|
||||
complète. Sans installation MAIS avec un bureau, le magasin
|
||||
d'applications et les outils de la phase « avant » servent encore
|
||||
— les griser mentirait autant que de laisser actif ce qui ne fait
|
||||
rien. La branche, le profil et l'interpréteur Python, eux, ne
|
||||
servent qu'à l'installation.
|
||||
|
||||
Le type de VM et le suivi ne sont jamais grisés : le premier est
|
||||
l'autre moitié de la décision, le second regarde la VM arriver
|
||||
même quand rien ne s'installe."""
|
||||
installe, quelque_chose = self._install_state()
|
||||
for cible, actif in (
|
||||
("#f_branch", installe),
|
||||
("#f_profile_install", installe),
|
||||
("#f_prod", quelque_chose),
|
||||
):
|
||||
try:
|
||||
self.query_one(cible).disabled = not actif
|
||||
except Exception:
|
||||
pass
|
||||
# Le magasin, les outils et l'interpréteur Python ont leur PROPRE
|
||||
# raison de se griser (architecture, bureau, distribution) : ils
|
||||
# composent les deux dans « _render_* », qui a le dernier mot.
|
||||
# Le titre suit ses champs : une section entière se lit inactive
|
||||
# d'un coup d'œil, au lieu de se déduire de trois widgets ternes.
|
||||
for cible, actif in (
|
||||
("#t_store", quelque_chose),
|
||||
("#t_tools", quelque_chose),
|
||||
("#t_python", installe),
|
||||
):
|
||||
try:
|
||||
self.query_one(cible).set_class(not actif, "off")
|
||||
except Exception:
|
||||
pass
|
||||
# Les rangées portent les mêmes choix, par VM.
|
||||
for i in range(len(self.rows)):
|
||||
for cible in (f"#v{i}_branch", f"#v{i}_prof"):
|
||||
try:
|
||||
self.query_one(cible).disabled = not installe
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _render_plan(self):
|
||||
# Le JEU de VM a-t-il changé ? Si oui on remonte les widgets, sinon
|
||||
# on se contente des titres : remonter à chaque frappe volerait le
|
||||
|
|
@ -1231,18 +920,7 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
# ci-dessous sans rien recalculer, et les rangées restaient sur
|
||||
# l'ancienne version. Elles n'en gardent pas de copie — « » y
|
||||
# veut dire « celle du formulaire » — il suffit de redessiner.
|
||||
if event.select.id == "f_tz_sel":
|
||||
# « libre… » révèle la saisie ; un fuseau choisi la referme et
|
||||
# y recopie le nom, seule valeur que lisent _form_values et la
|
||||
# spec — un seul endroit porte la réponse.
|
||||
free = event.value is FREE
|
||||
field = self.query_one("#f_tz", Input)
|
||||
field.display = free
|
||||
field.disabled = not free
|
||||
if free:
|
||||
field.focus()
|
||||
elif isinstance(event.value, str):
|
||||
field.value = event.value
|
||||
if self.extras_on_select(event):
|
||||
return
|
||||
if event.select.id in ("f_branch", "f_profile_install"):
|
||||
self._clear_overrides(
|
||||
|
|
@ -1377,15 +1055,7 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
else f"x{self.profile}"
|
||||
),
|
||||
"ssh_key": os.path.expanduser(key) if key else "",
|
||||
# Un champ vidé retombe sur le fuseau de l'hôte plutôt que sur
|
||||
# rien : sans valeur, la VM démarrerait en UTC.
|
||||
"timezone": self.query_one("#f_tz", Input).value.strip()
|
||||
or ctx.get("timezone")
|
||||
or "",
|
||||
"desktop": self._default_desktop(),
|
||||
"vm_tools": self._vm_tools(),
|
||||
"python_provider": self._python_provider(),
|
||||
"app_store": self._app_store(),
|
||||
**self.extras_values(),
|
||||
"install": install,
|
||||
"add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value,
|
||||
# Une exécution par installation : le nombre de VM retenues
|
||||
|
|
|
|||
|
|
@ -828,6 +828,51 @@ class QemuInstallMixin:
|
|||
"arch": "pacman",
|
||||
}
|
||||
|
||||
def _qemu_guest_context(self):
|
||||
"""Ce que les DEUX écrans de déploiement doivent savoir du système
|
||||
invité : type de VM, magasin d'applications, outils, fuseaux, Python.
|
||||
|
||||
Une seule méthode et non deux blocs jumeaux dans les constructeurs de
|
||||
contexte : c'est en n'en remplissant qu'un que l'écran Proxmox avait
|
||||
perdu la moitié des réglages. Rien ici ne parle d'hyperviseur — c'est
|
||||
exactement ce qui rend le bloc commun."""
|
||||
outils = self._QEMU_VM_TOOLS
|
||||
return {
|
||||
"desktops": [
|
||||
(k, v["label"]) for k, v in self._QEMU_DESKTOP.items()
|
||||
],
|
||||
"desktop_suffixes": self._qemu_desktop_suffixes(),
|
||||
"desktop_disk_gb": self.QEMU_DESKTOP_EXTRA_DISK_GB,
|
||||
"app_stores": [(k, t(lbl)) for k, lbl in self.QEMU_APP_STORES],
|
||||
"snap_distros": self.QEMU_SNAP_DISTROS,
|
||||
"timezone": self._qemu_host_timezone(),
|
||||
"timezones": self._qemu_timezone_choices(
|
||||
self._qemu_host_timezone()
|
||||
),
|
||||
"mise_arches": self.QEMU_MISE_ARCHES,
|
||||
"vm_tools": self._qemu_vm_tool_choices(),
|
||||
"vm_tool_disk": {k: v["disk_gb"] for k, v in outils.items()},
|
||||
"vm_tool_arches": {k: v["arches"] for k, v in outils.items()},
|
||||
"vm_tool_desktops": {k: v["desktops"] for k, v in outils.items()},
|
||||
# « after » = l'outil vit DANS le dépôt ERPLibre (compilation
|
||||
# mobile, AVD, script Forgejo) : sans installation, il n'existe
|
||||
# pas, et la commande distante le saute en le nommant.
|
||||
"vm_tool_phases": {
|
||||
k: v.get("phase", "before") for k, v in outils.items()
|
||||
},
|
||||
"vm_tool_needs_desktop": {
|
||||
k: v["needs_desktop"] for k, v in outils.items()
|
||||
},
|
||||
"vm_tool_families": {k: v["families"] for k, v in outils.items()},
|
||||
"distro_family": dict(self._QEMU_DISTRO_FAMILY),
|
||||
"defaults": {
|
||||
"install": True,
|
||||
"add_ssh_config": True,
|
||||
"monitor": True,
|
||||
"prod": False,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _qemu_vm_tool_choices(cls):
|
||||
"""[(clé, libellé, indice)] pour le formulaire et l'invite en ligne."""
|
||||
|
|
|
|||
|
|
@ -4523,6 +4523,10 @@ TRANSLATIONS = {
|
|||
"fr": "Chemin de la clé publique SSH",
|
||||
"en": "SSH public key path",
|
||||
},
|
||||
"timezone not set": {
|
||||
"fr": "fuseau non posé",
|
||||
"en": "timezone not set",
|
||||
},
|
||||
"Timezone": {
|
||||
"fr": "Fuseau horaire",
|
||||
"en": "Timezone",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ actif ce qui ne fait rien.
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
|
@ -37,39 +36,63 @@ except Exception: # pragma: no cover - dépend de l'environnement
|
|||
|
||||
|
||||
class TestSaPlace(unittest.TestCase):
|
||||
"""Lu dans la source : l'ordre du panneau est une décision, pas un hasard."""
|
||||
"""L'ordre du panneau est une décision, pas un hasard.
|
||||
|
||||
Relevé sur l'ÉCRAN MONTÉ et non dans la source : depuis que les réglages
|
||||
du système invité viennent d'un socle partagé, le fichier ne contient
|
||||
plus qu'un « yield from » là où le test cherchait un identifiant. Il
|
||||
passait au vert sur un écran qu'il ne lisait plus — puis au rouge sans
|
||||
qu'aucun ordre ait bougé. Le DOM, lui, dit ce que l'utilisateur voit."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.src = FORM.read_text(encoding="utf-8")
|
||||
cls.ordre = ordre_du_panneau(contexte()) if TEXTUAL else []
|
||||
|
||||
def _rang(self, motif):
|
||||
m = re.search(re.escape(motif), self.src)
|
||||
self.assertIsNotNone(m, motif)
|
||||
return m.start()
|
||||
def _rang(self, ident):
|
||||
self.assertIn(ident, self.ordre, ident)
|
||||
return self.ordre.index(ident)
|
||||
|
||||
@unittest.skipUnless(TEXTUAL, "Textual absent")
|
||||
def test_the_install_section_sits_under_the_vm_type(self):
|
||||
self.assertLess(
|
||||
self._rang('with RadioSet(id="f_type")'),
|
||||
self._rang('id="t_install"'),
|
||||
)
|
||||
self.assertLess(self._rang("f_type"), self._rang("t_install"))
|
||||
|
||||
@unittest.skipUnless(TEXTUAL, "Textual absent")
|
||||
def test_and_before_the_sections_it_commands(self):
|
||||
# Magasin d'applications, outils : ils dépendent d'elle, donc ils
|
||||
# viennent après.
|
||||
for apres in ('id="t_store"', 'id="t_tools"'):
|
||||
self.assertLess(self._rang('id="t_install"'), self._rang(apres))
|
||||
for apres in ("t_store", "t_tools"):
|
||||
self.assertLess(self._rang("t_install"), self._rang(apres))
|
||||
|
||||
def test_the_checkbox_no_longer_claims_to_be_about_erplibre(self):
|
||||
self.assertIn('t("Install software in the VM")', self.src)
|
||||
self.assertNotIn('t("Install ERPLibre")', self.src)
|
||||
|
||||
@unittest.skipUnless(TEXTUAL, "Textual absent")
|
||||
def test_the_monitor_left_the_install_section(self):
|
||||
# Rangé dedans, il se serait grisé avec elle — et décocher ERPLibre
|
||||
# avait déjà fait disparaître le tableau de bord une fois.
|
||||
self.assertLess(
|
||||
self._rang('id="t_deploy"'), self._rang('id="f_monitor"')
|
||||
self.assertLess(self._rang("t_deploy"), self._rang("f_monitor"))
|
||||
|
||||
|
||||
def ordre_du_panneau(ctx, forme="qemu"):
|
||||
"""Les identifiants du panneau gauche, dans l'ordre où ils s'affichent."""
|
||||
from script.todo.proxmox_deploy_form import run_proxmox_form
|
||||
from script.todo.qemu_deploy_form import run_deploy_form
|
||||
|
||||
vu = []
|
||||
|
||||
async def scenario():
|
||||
app = (
|
||||
run_deploy_form(ctx, run_app=False)
|
||||
if forme == "qemu"
|
||||
else run_proxmox_form(ctx, run_app=False)
|
||||
)
|
||||
async with app.run_test(size=(200, 60)) as pilote:
|
||||
await pilote.pause()
|
||||
vu.extend(w.id for w in app.query("#fields *") if w.id)
|
||||
|
||||
asyncio.run(scenario())
|
||||
return vu
|
||||
|
||||
|
||||
def contexte():
|
||||
|
|
|
|||
327
test/test_todo_deploy_form_parity.py
Normal file
327
test/test_todo_deploy_form_parity.py
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
"""Les deux écrans de déploiement offrent les MÊMES réglages du système invité.
|
||||
|
||||
Type de VM, production, magasin d'applications, outils de développement,
|
||||
fuseau horaire, interpréteur Python : six réglages qui décrivent l'invité, pas
|
||||
la machine qui le porte. Ils valent donc mot pour mot sur libvirt et sur
|
||||
Proxmox VE.
|
||||
|
||||
C'est ce qui avait dérivé. L'écran QEMU/KVM les portait tous les six, l'écran
|
||||
Proxmox trois : une VM créée là-bas naissait serveur nu, sans outils, en UTC —
|
||||
et rien ne le disait. La duplication était le mécanisme de la dérive, pas son
|
||||
symptôme : chaque correctif se posait sur un seul des deux écrans.
|
||||
|
||||
Ce fichier teste donc la PARITÉ elle-même, et pas six comportements. Ajouter
|
||||
un réglage à un seul écran le fait échouer, quel que soit ce réglage."""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.argv = ["todo.py"]
|
||||
from script.todo.todo import TODO # noqa: E402
|
||||
|
||||
try:
|
||||
import textual # noqa: F401
|
||||
|
||||
TEXTUAL = True
|
||||
except Exception: # pragma: no cover - dépend de l'environnement
|
||||
TEXTUAL = False
|
||||
|
||||
# Ce que porte le socle partagé. Les identifiants, parce qu'ils sont le
|
||||
# contrat : c'est par eux que la spec est lue.
|
||||
REGLAGES = ("f_type", "f_prod", "f_store", "f_tools", "f_tz", "f_python")
|
||||
|
||||
CATALOGUE = [
|
||||
{
|
||||
"distro": "ubuntu",
|
||||
"version": "26.04",
|
||||
"arch": "amd64",
|
||||
"name": "erplibre-ubuntu-2604",
|
||||
"ram": 2048,
|
||||
"disk": "20G",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def todo_muet():
|
||||
todo = TODO.__new__(TODO)
|
||||
todo._qemu_list_domains = lambda: []
|
||||
todo._qemu_branch_list = lambda: ["develop", "master"]
|
||||
return todo
|
||||
|
||||
|
||||
def contexte_proxmox(todo):
|
||||
"""Le contexte de l'écran Proxmox, hôte simulé — aucune VM n'est créée."""
|
||||
return dict(
|
||||
host={"target": "pve", "label": "pve"},
|
||||
node="pve",
|
||||
arches=["amd64"],
|
||||
native="amd64",
|
||||
catalog={"amd64": list(CATALOGUE)},
|
||||
names=[],
|
||||
vmids=[],
|
||||
next_vmid=100,
|
||||
storages=["local-lvm"],
|
||||
storage="local-lvm",
|
||||
storage_avail={"local-lvm": 500 << 30},
|
||||
bridges=["vmbr0"],
|
||||
bridge="vmbr0",
|
||||
ipconfig=lambda _p, _v: "ip=dhcp",
|
||||
nameservers=["1.1.1.1"],
|
||||
branches=["develop", "master"],
|
||||
branch_current="develop",
|
||||
install_profiles=[
|
||||
("ERPLibre + Odoo 18", "make install_os && make install_odoo_18")
|
||||
],
|
||||
distro_profiles={},
|
||||
ssh_key="~/.ssh/id_ed25519.pub",
|
||||
cpu_presets=(1, 2, 4),
|
||||
ram_presets=(2048, 4096),
|
||||
disk_presets=(20, 40),
|
||||
base_vcpus=2,
|
||||
host_cpu=8,
|
||||
free_ram=16000,
|
||||
extra_disk_gb=5,
|
||||
**todo._qemu_guest_context(),
|
||||
)
|
||||
|
||||
|
||||
def releve(fabrique, ctx, gestes=None):
|
||||
"""Monte l'écran, choisit la première entrée, et relève ce qu'il porte."""
|
||||
vu = {}
|
||||
|
||||
async def scenario():
|
||||
from textual.widgets import SelectionList
|
||||
|
||||
app = fabrique(ctx, run_app=False)
|
||||
async with app.run_test(size=(220, 70)) as pilote:
|
||||
await pilote.pause()
|
||||
liste = app.query_one(SelectionList)
|
||||
liste.select(liste.get_option_at_index(0).value)
|
||||
await pilote.pause()
|
||||
await pilote.pause()
|
||||
if gestes:
|
||||
await gestes(app, pilote)
|
||||
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["disques"] = [r["disk_gb"] for r in app.rows]
|
||||
|
||||
asyncio.run(scenario())
|
||||
return vu
|
||||
|
||||
|
||||
@unittest.skipUnless(TEXTUAL, "Textual absent")
|
||||
class TestLesDeuxEcrans(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
from script.todo.proxmox_deploy_form import run_proxmox_form
|
||||
from script.todo.qemu_deploy_form import run_deploy_form
|
||||
|
||||
todo = todo_muet()
|
||||
mod = todo._qemu_import_module()
|
||||
cls.qemu = releve(run_deploy_form, todo._qemu_form_context(mod))
|
||||
cls.pve = releve(run_proxmox_form, contexte_proxmox(todo))
|
||||
|
||||
def _porte(self, vu, ident):
|
||||
"""Un réglage est là s'il a son widget — les outils sont une case par
|
||||
outil, donc un préfixe."""
|
||||
if ident == "f_tools":
|
||||
return any(i.startswith("f_tool_") for i in vu["ids"])
|
||||
return ident in vu["ids"]
|
||||
|
||||
def test_both_screens_offer_the_same_guest_settings(self):
|
||||
for ident in REGLAGES:
|
||||
with self.subTest(reglage=ident):
|
||||
self.assertTrue(self._porte(self.qemu, ident), "QEMU/KVM")
|
||||
self.assertTrue(self._porte(self.pve, ident), "Proxmox")
|
||||
|
||||
def test_both_specs_carry_the_same_guest_keys(self):
|
||||
from script.todo.deploy_form_extras import ExtrasMixin
|
||||
|
||||
attendues = set(ExtrasMixin.extras_values(self._faux()))
|
||||
for nom, vu in (("QEMU/KVM", self.qemu), ("Proxmox", self.pve)):
|
||||
with self.subTest(ecran=nom):
|
||||
self.assertTrue(attendues <= set(vu["spec"]), nom)
|
||||
|
||||
def _faux(self):
|
||||
"""Un porteur du socle sans écran : la liste des clés ne dépend pas
|
||||
des widgets, et c'est justement ce qu'on veut vérifier."""
|
||||
from script.todo.deploy_form_extras import ExtrasMixin
|
||||
|
||||
class Vide(ExtrasMixin):
|
||||
vms = ()
|
||||
rows = ()
|
||||
|
||||
def query_one(self, _s, *_a):
|
||||
raise LookupError
|
||||
|
||||
vide = Vide()
|
||||
vide.extras_init({})
|
||||
return vide
|
||||
|
||||
def test_parallelism_follows_the_host_on_both_screens(self):
|
||||
# L'écran Proxmox plafonnait à quatre choix et en proposait UN, quel
|
||||
# que soit le nombre de cœurs de l'hôte.
|
||||
for nom, vu in (("QEMU/KVM", self.qemu), ("Proxmox", self.pve)):
|
||||
with self.subTest(ecran=nom):
|
||||
self.assertIn("f_par_all", vu["ids"], nom)
|
||||
|
||||
|
||||
@unittest.skipUnless(TEXTUAL, "Textual absent")
|
||||
class TestCeQueLeBureauChange(unittest.TestCase):
|
||||
"""Choisir un bureau doit se voir sur les DEUX écrans, et de la même
|
||||
façon : le nom prend un suffixe, le disque grossit."""
|
||||
|
||||
def _bureau(self):
|
||||
async def gestes(app, pilote):
|
||||
from textual.widgets import Checkbox
|
||||
|
||||
list(app.query("#f_type RadioButton"))[1].value = True
|
||||
await pilote.pause()
|
||||
app.query_one("#f_tool_pycharm", Checkbox).value = True
|
||||
await pilote.pause()
|
||||
await pilote.pause()
|
||||
|
||||
return gestes
|
||||
|
||||
def test_the_name_gets_the_desktop_suffix(self):
|
||||
from script.todo.proxmox_deploy_form import run_proxmox_form
|
||||
|
||||
todo = todo_muet()
|
||||
vu = releve(
|
||||
run_proxmox_form, contexte_proxmox(todo), gestes=self._bureau()
|
||||
)
|
||||
# Sans suffixe, une VM graphique et sa jumelle serveur portent le même
|
||||
# nom : la seconde est signalée « existe déjà » et ignorée.
|
||||
self.assertTrue(vu["noms"][0].endswith("-gnome"), vu["noms"])
|
||||
|
||||
def test_the_disk_grows_by_the_desktop_and_the_tools(self):
|
||||
from script.todo.proxmox_deploy_form import run_proxmox_form
|
||||
|
||||
todo = todo_muet()
|
||||
ctx = contexte_proxmox(todo)
|
||||
nu = releve(run_proxmox_form, ctx)
|
||||
avec = releve(run_proxmox_form, ctx, gestes=self._bureau())
|
||||
attendu = ctx["desktop_disk_gb"] + ctx["vm_tool_disk"]["pycharm"]
|
||||
self.assertEqual(
|
||||
avec["disques"][0] - nu["disques"][0],
|
||||
attendu,
|
||||
"le plan doit annoncer le surcoût AVANT de déployer",
|
||||
)
|
||||
|
||||
def test_the_created_disk_matches_what_the_plan_announced(self):
|
||||
# C'est le pont entre l'écran et « qm resize » : le plan annonçait
|
||||
# 36 Go et la commande en demandait 20.
|
||||
todo = todo_muet()
|
||||
taille = todo._pve_disk_with_margin(
|
||||
{"disk": "20G", "arch": "amd64", "distro": "ubuntu"},
|
||||
{
|
||||
"install": {
|
||||
"branch": "develop",
|
||||
"cmd": "make install_os && make install_odoo_18",
|
||||
},
|
||||
"desktop": "gnome",
|
||||
"vm_tools": ("pycharm",),
|
||||
},
|
||||
)
|
||||
self.assertEqual(taille, "36G")
|
||||
|
||||
|
||||
class TestCeQueSontCesVm(unittest.TestCase):
|
||||
"""L'architecture d'une VM Proxmox venait de « virsh », qui ne connaît que
|
||||
les domaines d'ICI.
|
||||
|
||||
Elle décide des outils : une VM ARM prise pour x86_64 recevait Android
|
||||
Studio, que Google ne publie pas pour elle — l'installation s'arrête, une
|
||||
heure plus tard. Même famille que le « s » qui partait vers la machine
|
||||
interne : on jugeait sur le nom."""
|
||||
|
||||
def _outils(self, meta):
|
||||
vu = {}
|
||||
todo = TODO.__new__(TODO)
|
||||
todo._qemu_import_module = lambda: object()
|
||||
todo._qemu_resolve_ips = lambda noms: {n: "10.0.0.1" for n in noms}
|
||||
todo._qemu_vm_meta = lambda nom, mod: ("ubuntu", "26.04", "amd64")
|
||||
todo._qemu_erplibre_remote_cmd = lambda *a, **kw: ""
|
||||
|
||||
def capture(vms, branche, remote):
|
||||
vu["archs"] = [v["arch"] for v in vms]
|
||||
raise SystemExit
|
||||
|
||||
import script.todo.qemu_install_monitor as mon
|
||||
|
||||
vrai = mon.launch_installs
|
||||
mon.launch_installs = capture
|
||||
try:
|
||||
todo._qemu_install_erplibre_monitored(
|
||||
["vm-arm"], "develop", {"vm-arm": "10.0.0.1"}, meta=meta
|
||||
)
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
mon.launch_installs = vrai
|
||||
return vu.get("archs")
|
||||
|
||||
def test_the_caller_knows_better_than_virsh(self):
|
||||
self.assertEqual(
|
||||
self._outils({"vm-arm": ("ubuntu", "26.04", "arm64")}), ["arm64"]
|
||||
)
|
||||
|
||||
def test_without_it_virsh_still_answers(self):
|
||||
# La voie libvirt ne régresse pas : ses domaines sont bien ici.
|
||||
self.assertEqual(self._outils(None), ["amd64"])
|
||||
|
||||
|
||||
class TestLeFuseauDUneVmProxmox(unittest.TestCase):
|
||||
"""« qm set » ne pose pas de fuseau : le cloud-init de Proxmox ne règle
|
||||
que l'utilisateur, la clé et le réseau. Une VM créée là restait en UTC, et
|
||||
on ne s'en aperçoit qu'aux horodatages."""
|
||||
|
||||
def _pose(self, spec):
|
||||
vu = {}
|
||||
todo = TODO.__new__(TODO)
|
||||
todo._pve_ssh = lambda cible, cmd, **kw: (
|
||||
vu.setdefault("cmd", cmd),
|
||||
(0, ""),
|
||||
)[1]
|
||||
todo._pve_set_timezone("vm", spec)
|
||||
return vu.get("cmd", "")
|
||||
|
||||
def test_the_timezone_reaches_the_vm(self):
|
||||
self.assertIn(
|
||||
"sudo timedatectl set-timezone America/Montreal",
|
||||
self._pose({"timezone": "America/Montreal"}),
|
||||
)
|
||||
|
||||
def test_a_free_value_is_quoted(self):
|
||||
# La liste laisse la saisie libre : ce qui en sort part dans une
|
||||
# commande distante, donc il est cité.
|
||||
self.assertIn(
|
||||
"'a b; rm -rf /'", self._pose({"timezone": "a b; rm -rf /"})
|
||||
)
|
||||
|
||||
def test_no_timezone_means_no_command(self):
|
||||
self.assertEqual(self._pose({}), "")
|
||||
|
||||
def test_the_prompt_path_still_gets_one(self):
|
||||
# La voie par questions ne demande pas le fuseau ; sans défaut, elle
|
||||
# laissait la VM en UTC alors que la voie libvirt reprend celui de
|
||||
# l'hôte depuis toujours.
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
src = Path("script/todo/proxmox_menu.py").read_text(encoding="utf-8")
|
||||
bloc = src[src.index("def _pve_deploy_prompts") :]
|
||||
bloc = bloc[: bloc.index("_pve_after_create")]
|
||||
self.assertTrue(
|
||||
re.search(r'"timezone":\s*self\._qemu_host_timezone\(\)', bloc),
|
||||
"le spec des invites doit porter un fuseau",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Loading…
Reference in a new issue