[ADD] todo : dire la place qui reste sous le plan de déploiement
La ligne de totaux annonçait « ~126 G » sans dire sur quoi : la demande seule ne dit pas si ça rentre, et on l'apprenait au déploiement. Elle dit maintenant la demande, ce qui reste et la capacité — « ~126 G / 20 G libres sur 270 G » — et prévient dès que le plan dépasse. Les trois limites (RAM, disque, cœurs) s'affichent ensemble : n'en montrer qu'une cachait les autres. Sur Proxmox, la place vient du stockage choisi, que « pvesm status » donnait déjà. Deux défauts trouvés en le faisant : la marque de génération, exigée de tous les widgets, faisait taire chaque réglage commun de l'écran Proxmox, et « [x1] » disparaissait, lu comme une balise Rich. --- EN --- The totals line said "~126 G" without saying out of what: the demand alone does not tell whether it fits, and one found out at deploy time. It now says the demand, what is left and the capacity — "~126 G / 20 G free of 270 G" — and warns as soon as the plan exceeds it. The three limits (RAM, disk, cores) show together: showing only one hid the others. On Proxmox the room comes from the chosen storage, which "pvesm status" already gave. Two defects found on the way: the generation mark, required of every widget, silenced each common setting on the Proxmox screen, and "[x1]" vanished, read as a Rich tag. Assisted-by: Claude Opus 5
This commit is contained in:
parent
ccedba3411
commit
562ad1c873
10 changed files with 401 additions and 50 deletions
|
|
@ -291,6 +291,31 @@ def plan_rows(vms, domains, extra_disk_gb=0, orphelin=None):
|
|||
return rows
|
||||
|
||||
|
||||
def gib(nbytes) -> int:
|
||||
"""Octets -> Gio entiers. Le plan compte en Go partout ailleurs : mêler
|
||||
des unités sur la même ligne de totaux la rendrait illisible."""
|
||||
try:
|
||||
return int(nbytes) // (1 << 30)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def disk_note(plan_gb, free_gb, total_gb=0) -> str:
|
||||
"""« ~50 G / 20 G libres sur 270 G » — la demande, le reste, la capacité.
|
||||
|
||||
La demande seule ne dit pas si ça rentre : c'est le rapprochement qui
|
||||
décide, et c'est pourquoi la place s'affiche là même où la demande était
|
||||
déjà écrite. Les deux nombres ne sont pas redondants — 20 Go libres sur
|
||||
270 se lit autrement que 20 sur 24. Sans mesure (0), on n'invente rien :
|
||||
la demande s'affiche seule.
|
||||
"""
|
||||
if not free_gb:
|
||||
return f"~{plan_gb} G"
|
||||
if not total_gb:
|
||||
return f"~{plan_gb} G / {free_gb} G {t('free')}"
|
||||
return f"~{plan_gb} G / {free_gb} G {t('free of')} {total_gb} G"
|
||||
|
||||
|
||||
def plan_totals(rows):
|
||||
"""Totaux des VM RÉELLEMENT créées (les existantes ne consomment rien de
|
||||
neuf) : (nb, vcpus, ram_mo, disque_go)."""
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ Le formulaire ne touche à rien : il rend une spec. C'est l'appelant
|
|||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from script.todo.deploy_form_lib import (
|
||||
CSS_BASE,
|
||||
|
|
@ -28,7 +29,9 @@ from script.todo.deploy_form_lib import (
|
|||
RES_FIELDS,
|
||||
SELECT_TO_FIELD,
|
||||
build_vms,
|
||||
disk_note,
|
||||
entry_key,
|
||||
gib,
|
||||
plan_rows,
|
||||
plan_totals,
|
||||
res_row_widgets,
|
||||
|
|
@ -466,14 +469,18 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
|||
pass
|
||||
n, cpu, ram, disque = plan_totals(self.rows)
|
||||
libre = ctx.get("free_ram") or 0
|
||||
alerte = (
|
||||
f" ⚠ {t('more RAM than the host has free')}"
|
||||
if libre and ram > libre
|
||||
else ""
|
||||
)
|
||||
# La place du stockage CHOISI : elle change avec la liste, donc
|
||||
# elle se relit à chaque rendu plutôt qu'une fois au montage.
|
||||
place = gib((ctx.get("storage_avail") or {}).get(self._storage()))
|
||||
alertes = []
|
||||
if libre and ram > libre:
|
||||
alertes.append(t("more RAM than the host has free"))
|
||||
if place and disque > place:
|
||||
alertes.append(t("more disk than the storage has free"))
|
||||
alerte = f" ⚠ {' · '.join(alertes)}" if alertes else ""
|
||||
self.query_one("#totals", Static).update(
|
||||
f" {n} {t('VM')} {cpu} vCPU {ram} Mo RAM "
|
||||
f"{disque} Go [{res_label(self.profile)}]"
|
||||
f"{disk_note(disque, place)} {res_label(self.profile)}"
|
||||
f" {t('storage')} {self._storage() or '?'}"
|
||||
f" {t('bridge')} {self._bridge() or '?'}{alerte}"
|
||||
)
|
||||
|
|
@ -537,9 +544,15 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
|||
self._refresh_after()
|
||||
|
||||
def on_select_changed(self, event) -> None:
|
||||
if self._syncing or not self._is_current(event.select):
|
||||
if self._syncing:
|
||||
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 à
|
||||
# ignorer chaque réglage commun — mesuré, ni le stockage, ni la
|
||||
# RAM générale n'atteignaient le plan.
|
||||
if re.match(r"v\d+_", ident) and not self._is_current(event.select):
|
||||
return
|
||||
if ident in ("f_storage", "f_bridge"):
|
||||
self._refresh_after()
|
||||
return
|
||||
|
|
|
|||
|
|
@ -142,9 +142,7 @@ class ProxmoxMenuMixin:
|
|||
def _pve_host_from_ssh_config(self):
|
||||
"""Un alias de ~/.ssh/config : il porte déjà utilisateur, port et
|
||||
ProxyJump — rien à redemander, et le rebond traverse."""
|
||||
entrees = self._ssh_config_entries(
|
||||
os.path.expanduser("~/.ssh/config")
|
||||
)
|
||||
entrees = self._ssh_config_entries(os.path.expanduser("~/.ssh/config"))
|
||||
if not entrees:
|
||||
print(f"\n{t('No SSH hosts found in ~/.ssh/config')}")
|
||||
return None
|
||||
|
|
@ -205,7 +203,9 @@ class ProxmoxMenuMixin:
|
|||
chemin = os.path.expanduser("~/.ssh/known_hosts")
|
||||
os.makedirs(os.path.dirname(chemin), exist_ok=True)
|
||||
with open(chemin, "a", encoding="utf-8") as fh:
|
||||
fh.write(res.stdout if res.stdout.endswith("\n") else res.stdout + "\n")
|
||||
fh.write(
|
||||
res.stdout if res.stdout.endswith("\n") else res.stdout + "\n"
|
||||
)
|
||||
lignes = len(res.stdout.strip().splitlines())
|
||||
print(f" ✓ {lignes} {t('key(s) recorded in ~/.ssh/known_hosts')}")
|
||||
return True
|
||||
|
|
@ -233,7 +233,9 @@ class ProxmoxMenuMixin:
|
|||
print(f" ✗ {t('Not a Proxmox host (or unreachable):')}")
|
||||
premiere = (out or "").strip().splitlines()
|
||||
print(f" {premiere[0] if premiere else t('no answer')}")
|
||||
print(f" → {t('Check the address, the SSH access and pveversion.')}")
|
||||
print(
|
||||
f" → {t('Check the address, the SSH access and pveversion.')}"
|
||||
)
|
||||
return None
|
||||
# « qm » exige les privilèges. La voie « VM QEMU locale » donne
|
||||
# l'accès d'erplibre, pas de root : il faut donc sudo, et il faut le
|
||||
|
|
@ -244,7 +246,9 @@ class ProxmoxMenuMixin:
|
|||
if qui.strip() != "0":
|
||||
code, _o = pve.run(host, "sudo -n true", timeout=20)
|
||||
if code:
|
||||
print(f" ✗ {t('qm needs root: no root, and sudo asks for a password.')}")
|
||||
print(
|
||||
f" ✗ {t('qm needs root: no root, and sudo asks for a password.')}"
|
||||
)
|
||||
print(f" → {t('Connect as root@, or allow NOPASSWD sudo.')}")
|
||||
return None
|
||||
prefixe = "sudo "
|
||||
|
|
@ -295,9 +299,7 @@ class ProxmoxMenuMixin:
|
|||
return [] if multiple else None
|
||||
print(f"\n{titre or t('VMs on this host:')}")
|
||||
for i, vm in enumerate(vms, 1):
|
||||
print(
|
||||
f" [{i}] {vm['vmid']:<6} {vm['name']:<28} {vm['status']}"
|
||||
)
|
||||
print(f" [{i}] {vm['vmid']:<6} {vm['name']:<28} {vm['status']}")
|
||||
if multiple:
|
||||
print(f" [all] {t('select all')}")
|
||||
brut = input(t("Selection (number): ")).strip()
|
||||
|
|
@ -390,9 +392,7 @@ class ProxmoxMenuMixin:
|
|||
if not vm:
|
||||
return
|
||||
print(f"\n ⚠ {t('Proxmox can only GROW a disk, never shrink it.')}")
|
||||
taille = input(
|
||||
t("Size (+10G to add, 40G for a target): ")
|
||||
).strip()
|
||||
taille = input(t("Size (+10G to add, 40G for a target): ")).strip()
|
||||
if not re.match(r"^\+?\d+[MGT]$", taille):
|
||||
print(t("Invalid selection!"))
|
||||
return
|
||||
|
|
@ -485,7 +485,9 @@ class ProxmoxMenuMixin:
|
|||
"""Recopie la clé publique SUR l'hôte : « qm set --sshkeys » attend un
|
||||
FICHIER là-bas, pas une clé en ligne."""
|
||||
try:
|
||||
with open(os.path.expanduser(chemin_local), encoding="utf-8") as fh:
|
||||
with open(
|
||||
os.path.expanduser(chemin_local), encoding="utf-8"
|
||||
) as fh:
|
||||
cle = fh.read().strip()
|
||||
except OSError as exc:
|
||||
print(f" ⚠ {t('SSH key unreadable:')} {exc}")
|
||||
|
|
@ -525,11 +527,11 @@ class ProxmoxMenuMixin:
|
|||
print(" address <ip-de-l-hôte>/24")
|
||||
print(" gateway <passerelle>")
|
||||
print(" bridge-ports <interface>")
|
||||
print(f" ⚠ {t('This moves the host address: do it from a console.')}")
|
||||
print(
|
||||
f" ⚠ {t('This moves the host address: do it from a console.')}"
|
||||
)
|
||||
return ""
|
||||
_c, sortie = self._pve_show(
|
||||
"ip -o -4 route show default", quiet=True
|
||||
)
|
||||
_c, sortie = self._pve_show("ip -o -4 route show default", quiet=True)
|
||||
uplink = ""
|
||||
parts = (sortie or "").split()
|
||||
if "dev" in parts:
|
||||
|
|
@ -629,6 +631,12 @@ class ProxmoxMenuMixin:
|
|||
"next_vmid": pve.next_vmid(vms),
|
||||
"storages": [s["name"] for s in stockages if s.get("actif")],
|
||||
"storage": pve.pick_storage(stockages),
|
||||
# La place libre par stockage, en octets : « pvesm status » la
|
||||
# donne dans la même sortie, donc l'écran peut dire si le plan
|
||||
# rentre sans un aller-retour de plus vers l'hôte.
|
||||
"storage_avail": {
|
||||
s["name"]: s.get("avail") or 0 for s in stockages
|
||||
},
|
||||
"bridges": ponts,
|
||||
"bridge": pve.pick_bridge(ponts),
|
||||
"ipconfig": ipconfig,
|
||||
|
|
@ -797,7 +805,9 @@ class ProxmoxMenuMixin:
|
|||
print(f"\n {t('Waiting for the VM address…')} {vm['name']}")
|
||||
ip = self._pve_guest_ip(vm["vmid"])
|
||||
if not ip:
|
||||
print(f" ⚠ {vm['name']} : {t('No address yet. Try [6] later.')}")
|
||||
print(
|
||||
f" ⚠ {vm['name']} : {t('No address yet. Try [6] later.')}"
|
||||
)
|
||||
continue
|
||||
print(f" ✓ {vm['name']} : {ip}")
|
||||
if spec.get("add_ssh_config"):
|
||||
|
|
@ -839,9 +849,10 @@ class ProxmoxMenuMixin:
|
|||
distro = self._qemu_prompt_distro()
|
||||
version = self._qemu_prompt_version(distro)
|
||||
arch = "amd64"
|
||||
nom = input(
|
||||
t("VM name (default: erplibre-<distro>): ")
|
||||
).strip() or f"erplibre-{distro}"
|
||||
nom = (
|
||||
input(t("VM name (default: erplibre-<distro>): ")).strip()
|
||||
or f"erplibre-{distro}"
|
||||
)
|
||||
memoire = (
|
||||
self._qemu_ask_ram(t("RAM in MB, blank = 4096"), 4096) or 4096
|
||||
)
|
||||
|
|
@ -884,7 +895,9 @@ class ProxmoxMenuMixin:
|
|||
# l'afficher avant de l'avoir choisi ne pouvait pas marcher.
|
||||
vmid = pve.next_vmid(self._pve_vms())
|
||||
ipconfig = pve.ipconfig_for(infos_ponts.get(pont, {}), vmid)
|
||||
print(f"\n {t('storage')} : {stockage} ({len(stockages)} {t('offered')})")
|
||||
print(
|
||||
f"\n {t('storage')} : {stockage} ({len(stockages)} {t('offered')})"
|
||||
)
|
||||
print(f" {t('bridge')} : {pont}")
|
||||
print(f" {t('address')} {ipconfig}")
|
||||
print(f" VMID : {vmid}")
|
||||
|
|
@ -989,7 +1002,9 @@ class ProxmoxMenuMixin:
|
|||
identity_file=cle,
|
||||
proxy_jump=host["target"],
|
||||
)
|
||||
print(f" ✓ ssh {vm['name']} ({ip} {t('through')} {host['target']})")
|
||||
print(
|
||||
f" ✓ ssh {vm['name']} ({ip} {t('through')} {host['target']})"
|
||||
)
|
||||
|
||||
def _pve_test_vm(self):
|
||||
"""Ouvre Odoo (:8069) d'une VM Proxmox dans un navigateur en ligne.
|
||||
|
|
@ -1007,7 +1022,9 @@ class ProxmoxMenuMixin:
|
|||
return
|
||||
if not self._qemu_ip_reachable(ip, port=8069, timeout=3):
|
||||
print(f"\n ⚠ {ip}:8069 {t('unreachable from here.')}")
|
||||
print(f" → {t('Use [13] to add a ProxyJump entry, then a tunnel.')}")
|
||||
print(
|
||||
f" → {t('Use [13] to add a ProxyJump entry, then a tunnel.')}"
|
||||
)
|
||||
return
|
||||
navigateur = self._qemu_choose_cli_browser()
|
||||
if not navigateur:
|
||||
|
|
@ -1032,7 +1049,9 @@ class ProxmoxMenuMixin:
|
|||
"sshkey_path": "/root/.ssh/erplibre-deploy.pub",
|
||||
}
|
||||
print(f"\n── {t('Example: demo-vm, Debian 13, on a Proxmox host')} ──")
|
||||
print(f" {pve.image_fetch_cmd('https://…/debian-13.qcow2', spec['image'])}")
|
||||
print(
|
||||
f" {pve.image_fetch_cmd('https://…/debian-13.qcow2', spec['image'])}"
|
||||
)
|
||||
for cmd in pve.create_cmds(101, spec):
|
||||
print(f" {cmd}")
|
||||
|
||||
|
|
@ -1091,8 +1110,16 @@ class ProxmoxMenuMixin:
|
|||
"SSH configuration (~/.ssh/config, ProxyJump)"
|
||||
)
|
||||
},
|
||||
{"prompt_description": t("Remote desktop tunnel (VNC/RDP over SSH)")},
|
||||
{"prompt_description": t("Android emulator (start, tunnel, scrcpy)")},
|
||||
{
|
||||
"prompt_description": t(
|
||||
"Remote desktop tunnel (VNC/RDP over SSH)"
|
||||
)
|
||||
},
|
||||
{
|
||||
"prompt_description": t(
|
||||
"Android emulator (start, tunnel, scrcpy)"
|
||||
)
|
||||
},
|
||||
{"section": t("Catalog")},
|
||||
{"prompt_description": t("List available images and their specs")},
|
||||
{"prompt_description": t("Proxmox - example sequence (dry-run)")},
|
||||
|
|
|
|||
|
|
@ -1215,6 +1215,7 @@ class QemuDeployMixin:
|
|||
catalog[a] = entries
|
||||
|
||||
print(f"\n{t('Loading (VM list, branches)...')}")
|
||||
disque_libre, disque_total = self._host_disk_gb()
|
||||
return {
|
||||
"catalog": catalog,
|
||||
"arches": arches,
|
||||
|
|
@ -1226,6 +1227,11 @@ class QemuDeployMixin:
|
|||
"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
|
||||
# ICI, comme le reste : une lecture disque pendant que Textual
|
||||
# affiche n'a pas sa place.
|
||||
"free_disk": disque_libre,
|
||||
"total_disk": disque_total,
|
||||
"base_vcpus": self._QEMU_BASE_VCPUS,
|
||||
"cpu_presets": self._QEMU_CPU_PRESETS,
|
||||
"ram_presets": self._QEMU_RAM_PRESETS,
|
||||
|
|
|
|||
|
|
@ -47,9 +47,11 @@ from script.todo.deploy_form_lib import ( # noqa: F401
|
|||
clip_payload,
|
||||
copy_name,
|
||||
disk_gb,
|
||||
disk_note,
|
||||
entry_key,
|
||||
expand_copies,
|
||||
fmt_dur,
|
||||
gib,
|
||||
parse_disk,
|
||||
parse_ram,
|
||||
plan_rows,
|
||||
|
|
@ -107,6 +109,8 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
branches = ctx.get("branches") or ["master"]
|
||||
host_cpu = ctx.get("host_cpu") or 2
|
||||
free_ram = ctx.get("free_ram") or 0
|
||||
free_disk = ctx.get("free_disk") or 0
|
||||
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
|
||||
|
|
@ -919,11 +923,18 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
)
|
||||
return
|
||||
n, cpus, ram, disk = plan_totals(self.rows)
|
||||
warn = ""
|
||||
# Une liste et non un seul avertissement : la RAM, les cœurs et le
|
||||
# disque sont trois limites distinctes, et n'en montrer qu'une
|
||||
# cachait les autres — on corrigeait la première pour découvrir la
|
||||
# suivante au déploiement.
|
||||
alertes = []
|
||||
if free_ram and ram > free_ram:
|
||||
warn = f" ⚠ {t('> host free RAM')}"
|
||||
elif cpus > host_cpu:
|
||||
warn = f" ⚠ {t('> host cores')} ({host_cpu})"
|
||||
alertes.append(t("> host free RAM"))
|
||||
if free_disk and disk > free_disk:
|
||||
alertes.append(t("> host free disk"))
|
||||
if cpus > host_cpu:
|
||||
alertes.append(f"{t('> host cores')} ({host_cpu})")
|
||||
warn = f" ⚠ {' · '.join(alertes)}" if alertes else ""
|
||||
dupes = len({vm["name"] for vm in self.vms}) != len(self.vms)
|
||||
dup_txt = (
|
||||
f"\n ⚠ {t('Duplicate names detected; keeping as entered.')}"
|
||||
|
|
@ -942,7 +953,8 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
else ""
|
||||
)
|
||||
self.query_one("#totals", Static).update(
|
||||
f" {n} {t('VMs')} · {cpus} vCPU · {ram} Mo · ~{disk} G"
|
||||
f" {n} {t('VMs')} · {cpus} vCPU · {ram} Mo · "
|
||||
f"{disk_note(disk, free_disk, total_disk)}"
|
||||
f"{skip_txt}{warn}{dup_txt}"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1514,6 +1514,28 @@ class TODO(
|
|||
invites « défaut oui » où tout sauf « non » vaut oui."""
|
||||
return ans.strip().lower() in ("n", "no", "non")
|
||||
|
||||
@staticmethod
|
||||
def _host_disk_gb(path="/var/lib/libvirt/images"):
|
||||
"""(libre, total) en Go du système de fichiers qui portera les disques.
|
||||
|
||||
On remonte vers le premier parent qui existe : le répertoire d'images
|
||||
n'est créé qu'au premier déploiement, et « /var/lib/libvirt » ou « / »
|
||||
répondent de la même partition dans la quasi-totalité des cas. (0, 0)
|
||||
si rien ne répond — la place libre s'affiche alors comme inconnue
|
||||
plutôt qu'inventée.
|
||||
"""
|
||||
chemin = path
|
||||
while chemin and not os.path.isdir(chemin):
|
||||
parent = os.path.dirname(chemin)
|
||||
if parent == chemin:
|
||||
break
|
||||
chemin = parent
|
||||
try:
|
||||
usage = shutil.disk_usage(chemin or "/")
|
||||
except OSError:
|
||||
return 0, 0
|
||||
return usage.free // (1 << 30), usage.total // (1 << 30)
|
||||
|
||||
@staticmethod
|
||||
def _host_free_ram_mb():
|
||||
"""RAM disponible de l'hôte en Mo (MemAvailable), 0 si inconnu."""
|
||||
|
|
|
|||
|
|
@ -1253,6 +1253,14 @@ TRANSLATIONS = {
|
|||
"fr": "> RAM libre de l'hôte",
|
||||
"en": "> host free RAM",
|
||||
},
|
||||
"> host free disk": {
|
||||
"fr": "> disque libre de l'hôte",
|
||||
"en": "> host free disk",
|
||||
},
|
||||
"free of": {
|
||||
"fr": "libres sur",
|
||||
"en": "free of",
|
||||
},
|
||||
"total RAM": {
|
||||
"fr": "RAM totale",
|
||||
"en": "total RAM",
|
||||
|
|
@ -3359,6 +3367,10 @@ TRANSLATIONS = {
|
|||
"fr": "Aucun pont sur l'hôte.",
|
||||
"en": "No bridge on the host.",
|
||||
},
|
||||
"more disk than the storage has free": {
|
||||
"fr": "plus de disque que le stockage n'en a de libre",
|
||||
"en": "more disk than the storage has free",
|
||||
},
|
||||
"more RAM than the host has free": {
|
||||
"fr": "plus de RAM que l'hôte n'en a de libre",
|
||||
"en": "more RAM than the host has free",
|
||||
|
|
|
|||
|
|
@ -78,9 +78,7 @@ class TestVmid(unittest.TestCase):
|
|||
|
||||
def test_the_address_is_derived_from_the_vmid(self):
|
||||
rows = [rangee("a"), rangee("b")]
|
||||
assign_vmids(
|
||||
rows, [], 100, lambda v: f"ip=10.10.10.{50 + v % 200}/24"
|
||||
)
|
||||
assign_vmids(rows, [], 100, lambda v: f"ip=10.10.10.{50 + v % 200}/24")
|
||||
self.assertEqual(rows[0]["vm"]["ipconfig"], "ip=10.10.10.150/24")
|
||||
self.assertEqual(rows[1]["vm"]["ipconfig"], "ip=10.10.10.151/24")
|
||||
|
||||
|
|
@ -100,7 +98,11 @@ class TestSpec(unittest.TestCase):
|
|||
"ssh_key": "/home/x/.ssh/id_ed25519.pub",
|
||||
"start": True,
|
||||
"add_ssh_config": True,
|
||||
"install": {"branch": "develop", "label": "Odoo 18", "cmd": "make"},
|
||||
"install": {
|
||||
"branch": "develop",
|
||||
"label": "Odoo 18",
|
||||
"cmd": "make",
|
||||
},
|
||||
"monitor": True,
|
||||
"parallelism": 2,
|
||||
}
|
||||
|
|
@ -141,7 +143,11 @@ def contexte():
|
|||
}
|
||||
|
||||
return {
|
||||
"host": {"target": "erplibre@10.0.0.5", "sudo": "sudo ", "label": "pve"},
|
||||
"host": {
|
||||
"target": "erplibre@10.0.0.5",
|
||||
"sudo": "sudo ",
|
||||
"label": "pve",
|
||||
},
|
||||
"node": "pve1",
|
||||
"catalog": {
|
||||
"amd64": [
|
||||
|
|
@ -158,6 +164,10 @@ def contexte():
|
|||
"next_vmid": 102,
|
||||
"storages": ["local-lvm", "local"],
|
||||
"storage": "local-lvm",
|
||||
"storage_avail": {
|
||||
"local-lvm": 90 * (1 << 30),
|
||||
"local": 12 * (1 << 30),
|
||||
},
|
||||
"bridges": ["vmbr0"],
|
||||
"bridge": "vmbr0",
|
||||
"ipconfig": lambda pont, vmid: f"ip=10.10.10.{50 + vmid % 200}/24",
|
||||
|
|
@ -195,6 +205,14 @@ class TestEcran(unittest.TestCase):
|
|||
await pilote.pause()
|
||||
await pilote.pause()
|
||||
await gestes(app, pilote)
|
||||
# Relevé AVANT la sortie du contexte : `run_test` démonte
|
||||
# l'écran, et « #totals » n'existe plus après.
|
||||
from textual.widgets import Static
|
||||
|
||||
widget = app.query_one("#totals", Static)
|
||||
app.ligne_totaux = str(
|
||||
getattr(widget, "_content", "") or widget.render()
|
||||
)
|
||||
resultat["app"] = app
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
|
@ -256,9 +274,7 @@ class TestEcran(unittest.TestCase):
|
|||
|
||||
app = self._rendu(gestes)
|
||||
self.assertEqual(len(app.rows), 4)
|
||||
vmids = [
|
||||
r["vm"]["vmid"] for r in app.rows if r["state"] != "exists"
|
||||
]
|
||||
vmids = [r["vm"]["vmid"] for r in app.rows if r["state"] != "exists"]
|
||||
self.assertEqual(len(vmids), len(set(vmids)))
|
||||
|
||||
def test_deploying_yields_a_spec_the_engine_can_run(self):
|
||||
|
|
@ -276,6 +292,64 @@ class TestEcran(unittest.TestCase):
|
|||
self.assertIn("ip=", vm["ipconfig"])
|
||||
self.assertEqual(spec["install"]["branch"], "develop")
|
||||
|
||||
def _totaux(self, app):
|
||||
return app.ligne_totaux
|
||||
|
||||
def test_the_totals_line_shows_the_room_left_on_the_storage(self):
|
||||
async def rien(app, pilote):
|
||||
pass
|
||||
|
||||
ligne = self._totaux(self._rendu(rien))
|
||||
# « pvesm status » donne déjà la place : la demande du plan s'affiche
|
||||
# donc à côté d'elle, sans un aller-retour de plus vers l'hôte.
|
||||
self.assertIn("/ 90 G", ligne)
|
||||
self.assertIn("local-lvm", ligne)
|
||||
|
||||
def test_changing_the_storage_changes_the_room(self):
|
||||
# La marque de génération ne vaut que pour les widgets de RANGÉE :
|
||||
# l'exiger des widgets globaux faisait taire tous les réglages
|
||||
# communs, stockage compris.
|
||||
async def gestes(app, pilote):
|
||||
from textual.widgets import Select
|
||||
|
||||
app.query_one("#f_storage", Select).value = "local"
|
||||
await pilote.pause()
|
||||
await pilote.pause()
|
||||
|
||||
ligne = self._totaux(self._rendu(gestes))
|
||||
self.assertIn("/ 12 G", ligne)
|
||||
self.assertIn("local", ligne)
|
||||
|
||||
def test_a_plan_bigger_than_the_storage_is_flagged(self):
|
||||
async def gestes(app, pilote):
|
||||
from textual.widgets import Select
|
||||
|
||||
app.query_one("#f_storage", Select).value = "local"
|
||||
await pilote.pause()
|
||||
await pilote.pause()
|
||||
|
||||
self.assertIn("⚠", self._totaux(self._rendu(gestes)))
|
||||
|
||||
def test_a_common_setting_reaches_every_vm(self):
|
||||
async def gestes(app, pilote):
|
||||
# « value = True » sur le bouton : action_next_button() ne
|
||||
# déplace que la surbrillance et n'émet aucun message.
|
||||
list(app.query("#f_profile RadioButton"))[2].value = True
|
||||
await pilote.pause()
|
||||
await pilote.pause()
|
||||
|
||||
app = self._rendu(gestes)
|
||||
self.assertEqual(app.profile, "3")
|
||||
self.assertTrue(all(r["vm"]["ram"] == 6144 for r in app.rows))
|
||||
|
||||
def test_the_resource_label_survives_the_markup(self):
|
||||
# « [x1] » se faisait manger : Static lit le balisage Rich, et une
|
||||
# balise inconnue disparaît avec son contenu.
|
||||
async def rien(app, pilote):
|
||||
pass
|
||||
|
||||
self.assertIn("x1", self._totaux(self._rendu(rien)))
|
||||
|
||||
def test_text_prompts_are_not_a_cancellation(self):
|
||||
# {} n'est pas None : l'appelant distingue « annulé » de
|
||||
# « pose-moi les questions à l'ancienne ».
|
||||
|
|
|
|||
129
test/test_qemu_deploy_form_disk.py
Normal file
129
test/test_qemu_deploy_form_disk.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
"""La ligne de totaux du formulaire QEMU/KVM : la place, pas seulement la
|
||||
demande.
|
||||
|
||||
La ligne disait « ~126 G » sans dire sur quoi : on découvrait au déploiement
|
||||
qu'il ne restait pas la place. Elle dit maintenant la demande, ce qui reste et
|
||||
la capacité — et elle prévient quand ça ne rentre pas, pour les trois limites
|
||||
(RAM, disque, cœurs) au lieu d'une seule à la fois.
|
||||
|
||||
La sonde disque est vérifiée à part : elle doit tomber sur une partition qui
|
||||
existe même quand le répertoire d'images n'a jamais été créé.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestSondeDisque(unittest.TestCase):
|
||||
def test_it_measures_the_filesystem_that_will_hold_the_disks(self):
|
||||
libre, total = TODO._host_disk_gb()
|
||||
self.assertGreater(total, 0)
|
||||
self.assertLessEqual(libre, total)
|
||||
|
||||
def test_a_missing_directory_falls_back_to_a_parent(self):
|
||||
# /var/lib/libvirt/images n'existe qu'après le premier déploiement :
|
||||
# sans repli, la place s'afficherait comme inconnue sur une machine
|
||||
# neuve, là où elle est justement la plus utile.
|
||||
libre, total = TODO._host_disk_gb("/n/existe/pas/du/tout")
|
||||
self.assertGreater(total, 0)
|
||||
|
||||
def test_it_never_raises(self):
|
||||
self.assertEqual(len(TODO._host_disk_gb("")), 2)
|
||||
|
||||
|
||||
def contexte():
|
||||
"""Contexte minimal du formulaire, avec des mesures CHOISIES : la ligne
|
||||
doit se lire pareil quelle que soit la machine qui lance le test."""
|
||||
todo = TODO.__new__(TODO)
|
||||
mod = todo._qemu_import_module()
|
||||
todo._qemu_list_domains = lambda: []
|
||||
todo._qemu_branch_list = lambda: ["develop", "master"]
|
||||
ctx = todo._qemu_form_context(mod)
|
||||
ctx["free_disk"] = 500
|
||||
ctx["total_disk"] = 900
|
||||
ctx["free_ram"] = 64000
|
||||
ctx["host_cpu"] = 64
|
||||
return ctx
|
||||
|
||||
|
||||
@unittest.skipUnless(TEXTUAL, "Textual absent")
|
||||
class TestLigneDeTotaux(unittest.TestCase):
|
||||
def _ligne(self, combien, ctx=None):
|
||||
from script.todo.qemu_deploy_form import run_deploy_form
|
||||
|
||||
ctx = ctx or contexte()
|
||||
vu = {}
|
||||
|
||||
async def scenario():
|
||||
from textual.widgets import SelectionList, Static
|
||||
|
||||
app = run_deploy_form(ctx, run_app=False)
|
||||
async with app.run_test(size=(200, 50)) as pilote:
|
||||
await pilote.pause()
|
||||
liste = app.query_one(SelectionList)
|
||||
for i in range(combien):
|
||||
liste.select(liste.get_option_at_index(i).value)
|
||||
await pilote.pause()
|
||||
await pilote.pause()
|
||||
widget = app.query_one("#totals", Static)
|
||||
vu["ligne"] = str(
|
||||
getattr(widget, "_content", "") or widget.render()
|
||||
)
|
||||
vu["vms"] = len(app.vms)
|
||||
|
||||
asyncio.run(scenario())
|
||||
return vu
|
||||
|
||||
def test_nothing_ticked_says_how_to_fill_the_list(self):
|
||||
# Un total à zéro n'apprend rien.
|
||||
self.assertNotIn("~", self._ligne(0)["ligne"])
|
||||
|
||||
def test_it_shows_the_demand_the_room_and_the_capacity(self):
|
||||
ligne = self._ligne(2)["ligne"]
|
||||
self.assertIn("~", ligne)
|
||||
self.assertIn("500 G", ligne)
|
||||
self.assertIn("900 G", ligne)
|
||||
|
||||
def test_a_plan_bigger_than_the_room_is_flagged(self):
|
||||
ctx = contexte()
|
||||
ctx["free_disk"] = 1
|
||||
ctx["total_disk"] = 900
|
||||
self.assertIn("⚠", self._ligne(2, ctx)["ligne"])
|
||||
|
||||
def test_the_three_limits_are_reported_together(self):
|
||||
# N'en montrer qu'une cachait les autres : on corrigeait la première
|
||||
# pour découvrir la suivante au déploiement.
|
||||
ctx = contexte()
|
||||
ctx["free_disk"] = 1
|
||||
ctx["free_ram"] = 1
|
||||
ctx["host_cpu"] = 1
|
||||
ligne = self._ligne(2, ctx)["ligne"]
|
||||
self.assertEqual(ligne.count("⚠"), 1)
|
||||
for morceau in ("RAM", "disque", "cœurs"):
|
||||
self.assertIn(morceau, ligne, ligne)
|
||||
|
||||
def test_an_unknown_measure_shows_no_room_at_all(self):
|
||||
ctx = contexte()
|
||||
ctx["free_disk"] = 0
|
||||
ctx["total_disk"] = 0
|
||||
ligne = self._ligne(2, ctx)["ligne"]
|
||||
self.assertIn("~", ligne)
|
||||
self.assertNotIn("/", ligne)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
|
@ -52,11 +52,38 @@ class TestRessourcesLibres(unittest.TestCase):
|
|||
self.assertEqual(lib.res_value("8", "", 2), 8)
|
||||
|
||||
|
||||
class TestPlaceDisque(unittest.TestCase):
|
||||
"""La demande du plan ne dit pas si ça rentre : il faut la place à côté."""
|
||||
|
||||
def test_it_shows_the_demand_the_room_and_the_capacity(self):
|
||||
self.assertEqual(
|
||||
lib.disk_note(50, 20, 270), "~50 G / 20 G libres sur 270 G"
|
||||
)
|
||||
|
||||
def test_without_a_capacity_it_still_shows_the_room(self):
|
||||
self.assertEqual(lib.disk_note(50, 20), "~50 G / 20 G libres")
|
||||
|
||||
def test_without_a_measure_it_invents_nothing(self):
|
||||
# Une place inconnue ne doit pas se lire comme « 0 Go libres ».
|
||||
self.assertEqual(lib.disk_note(50, 0, 0), "~50 G")
|
||||
self.assertEqual(lib.disk_note(50, 0, 270), "~50 G")
|
||||
|
||||
def test_bytes_become_whole_gibibytes(self):
|
||||
self.assertEqual(lib.gib(90 * (1 << 30)), 90)
|
||||
self.assertEqual(lib.gib(0), 0)
|
||||
# Une mesure absente ou illisible vaut zéro, pas une exception : la
|
||||
# ligne de totaux doit s'afficher même quand l'hôte n'a rien répondu.
|
||||
self.assertEqual(lib.gib(None), 0)
|
||||
self.assertEqual(lib.gib("x"), 0)
|
||||
|
||||
|
||||
class TestEtatDuNom(unittest.TestCase):
|
||||
"""Un nom déjà pris, un disque resté seul : deux gravités différentes."""
|
||||
|
||||
def test_a_defined_vm_is_skipped(self):
|
||||
etat, note = lib.vm_status("erplibre-debian-13", ["erplibre-debian-13"])
|
||||
etat, note = lib.vm_status(
|
||||
"erplibre-debian-13", ["erplibre-debian-13"]
|
||||
)
|
||||
self.assertEqual(etat, "exists")
|
||||
self.assertTrue(note)
|
||||
|
||||
|
|
@ -105,7 +132,9 @@ def entree(distro, version, arch="amd64"):
|
|||
|
||||
class TestSocleDuPlan(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.app = FauxPlan([entree("debian", "13"), entree("ubuntu", "26.04")])
|
||||
self.app = FauxPlan(
|
||||
[entree("debian", "13"), entree("ubuntu", "26.04")]
|
||||
)
|
||||
self.app.rows = [
|
||||
{
|
||||
"vm": {
|
||||
|
|
@ -190,7 +219,9 @@ def membres(chemin, classe=None):
|
|||
arbre = ast.parse(pathlib.Path(chemin).read_text(encoding="utf-8"))
|
||||
trouves = set()
|
||||
for n in ast.walk(arbre):
|
||||
if isinstance(n, ast.ClassDef) and (classe is None or n.name == classe):
|
||||
if isinstance(n, ast.ClassDef) and (
|
||||
classe is None or n.name == classe
|
||||
):
|
||||
trouves |= {
|
||||
m.name for m in n.body if isinstance(m, ast.FunctionDef)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue