[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
|
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):
|
def plan_totals(rows):
|
||||||
"""Totaux des VM RÉELLEMENT créées (les existantes ne consomment rien de
|
"""Totaux des VM RÉELLEMENT créées (les existantes ne consomment rien de
|
||||||
neuf) : (nb, vcpus, ram_mo, disque_go)."""
|
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 os
|
||||||
|
import re
|
||||||
|
|
||||||
from script.todo.deploy_form_lib import (
|
from script.todo.deploy_form_lib import (
|
||||||
CSS_BASE,
|
CSS_BASE,
|
||||||
|
|
@ -28,7 +29,9 @@ from script.todo.deploy_form_lib import (
|
||||||
RES_FIELDS,
|
RES_FIELDS,
|
||||||
SELECT_TO_FIELD,
|
SELECT_TO_FIELD,
|
||||||
build_vms,
|
build_vms,
|
||||||
|
disk_note,
|
||||||
entry_key,
|
entry_key,
|
||||||
|
gib,
|
||||||
plan_rows,
|
plan_rows,
|
||||||
plan_totals,
|
plan_totals,
|
||||||
res_row_widgets,
|
res_row_widgets,
|
||||||
|
|
@ -466,14 +469,18 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
||||||
pass
|
pass
|
||||||
n, cpu, ram, disque = plan_totals(self.rows)
|
n, cpu, ram, disque = plan_totals(self.rows)
|
||||||
libre = ctx.get("free_ram") or 0
|
libre = ctx.get("free_ram") or 0
|
||||||
alerte = (
|
# La place du stockage CHOISI : elle change avec la liste, donc
|
||||||
f" ⚠ {t('more RAM than the host has free')}"
|
# elle se relit à chaque rendu plutôt qu'une fois au montage.
|
||||||
if libre and ram > libre
|
place = gib((ctx.get("storage_avail") or {}).get(self._storage()))
|
||||||
else ""
|
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(
|
self.query_one("#totals", Static).update(
|
||||||
f" {n} {t('VM')} {cpu} vCPU {ram} Mo RAM "
|
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('storage')} {self._storage() or '?'}"
|
||||||
f" {t('bridge')} {self._bridge() or '?'}{alerte}"
|
f" {t('bridge')} {self._bridge() or '?'}{alerte}"
|
||||||
)
|
)
|
||||||
|
|
@ -537,9 +544,15 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
||||||
self._refresh_after()
|
self._refresh_after()
|
||||||
|
|
||||||
def on_select_changed(self, event) -> None:
|
def on_select_changed(self, event) -> None:
|
||||||
if self._syncing or not self._is_current(event.select):
|
if self._syncing:
|
||||||
return
|
return
|
||||||
ident = event.select.id or ""
|
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"):
|
if ident in ("f_storage", "f_bridge"):
|
||||||
self._refresh_after()
|
self._refresh_after()
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -142,9 +142,7 @@ class ProxmoxMenuMixin:
|
||||||
def _pve_host_from_ssh_config(self):
|
def _pve_host_from_ssh_config(self):
|
||||||
"""Un alias de ~/.ssh/config : il porte déjà utilisateur, port et
|
"""Un alias de ~/.ssh/config : il porte déjà utilisateur, port et
|
||||||
ProxyJump — rien à redemander, et le rebond traverse."""
|
ProxyJump — rien à redemander, et le rebond traverse."""
|
||||||
entrees = self._ssh_config_entries(
|
entrees = self._ssh_config_entries(os.path.expanduser("~/.ssh/config"))
|
||||||
os.path.expanduser("~/.ssh/config")
|
|
||||||
)
|
|
||||||
if not entrees:
|
if not entrees:
|
||||||
print(f"\n{t('No SSH hosts found in ~/.ssh/config')}")
|
print(f"\n{t('No SSH hosts found in ~/.ssh/config')}")
|
||||||
return None
|
return None
|
||||||
|
|
@ -205,7 +203,9 @@ class ProxmoxMenuMixin:
|
||||||
chemin = os.path.expanduser("~/.ssh/known_hosts")
|
chemin = os.path.expanduser("~/.ssh/known_hosts")
|
||||||
os.makedirs(os.path.dirname(chemin), exist_ok=True)
|
os.makedirs(os.path.dirname(chemin), exist_ok=True)
|
||||||
with open(chemin, "a", encoding="utf-8") as fh:
|
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())
|
lignes = len(res.stdout.strip().splitlines())
|
||||||
print(f" ✓ {lignes} {t('key(s) recorded in ~/.ssh/known_hosts')}")
|
print(f" ✓ {lignes} {t('key(s) recorded in ~/.ssh/known_hosts')}")
|
||||||
return True
|
return True
|
||||||
|
|
@ -233,7 +233,9 @@ class ProxmoxMenuMixin:
|
||||||
print(f" ✗ {t('Not a Proxmox host (or unreachable):')}")
|
print(f" ✗ {t('Not a Proxmox host (or unreachable):')}")
|
||||||
premiere = (out or "").strip().splitlines()
|
premiere = (out or "").strip().splitlines()
|
||||||
print(f" {premiere[0] if premiere else t('no answer')}")
|
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
|
return None
|
||||||
# « qm » exige les privilèges. La voie « VM QEMU locale » donne
|
# « 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
|
# 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":
|
if qui.strip() != "0":
|
||||||
code, _o = pve.run(host, "sudo -n true", timeout=20)
|
code, _o = pve.run(host, "sudo -n true", timeout=20)
|
||||||
if code:
|
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.')}")
|
print(f" → {t('Connect as root@, or allow NOPASSWD sudo.')}")
|
||||||
return None
|
return None
|
||||||
prefixe = "sudo "
|
prefixe = "sudo "
|
||||||
|
|
@ -295,9 +299,7 @@ class ProxmoxMenuMixin:
|
||||||
return [] if multiple else None
|
return [] if multiple else None
|
||||||
print(f"\n{titre or t('VMs on this host:')}")
|
print(f"\n{titre or t('VMs on this host:')}")
|
||||||
for i, vm in enumerate(vms, 1):
|
for i, vm in enumerate(vms, 1):
|
||||||
print(
|
print(f" [{i}] {vm['vmid']:<6} {vm['name']:<28} {vm['status']}")
|
||||||
f" [{i}] {vm['vmid']:<6} {vm['name']:<28} {vm['status']}"
|
|
||||||
)
|
|
||||||
if multiple:
|
if multiple:
|
||||||
print(f" [all] {t('select all')}")
|
print(f" [all] {t('select all')}")
|
||||||
brut = input(t("Selection (number): ")).strip()
|
brut = input(t("Selection (number): ")).strip()
|
||||||
|
|
@ -390,9 +392,7 @@ class ProxmoxMenuMixin:
|
||||||
if not vm:
|
if not vm:
|
||||||
return
|
return
|
||||||
print(f"\n ⚠ {t('Proxmox can only GROW a disk, never shrink it.')}")
|
print(f"\n ⚠ {t('Proxmox can only GROW a disk, never shrink it.')}")
|
||||||
taille = input(
|
taille = input(t("Size (+10G to add, 40G for a target): ")).strip()
|
||||||
t("Size (+10G to add, 40G for a target): ")
|
|
||||||
).strip()
|
|
||||||
if not re.match(r"^\+?\d+[MGT]$", taille):
|
if not re.match(r"^\+?\d+[MGT]$", taille):
|
||||||
print(t("Invalid selection!"))
|
print(t("Invalid selection!"))
|
||||||
return
|
return
|
||||||
|
|
@ -485,7 +485,9 @@ class ProxmoxMenuMixin:
|
||||||
"""Recopie la clé publique SUR l'hôte : « qm set --sshkeys » attend un
|
"""Recopie la clé publique SUR l'hôte : « qm set --sshkeys » attend un
|
||||||
FICHIER là-bas, pas une clé en ligne."""
|
FICHIER là-bas, pas une clé en ligne."""
|
||||||
try:
|
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()
|
cle = fh.read().strip()
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
print(f" ⚠ {t('SSH key unreadable:')} {exc}")
|
print(f" ⚠ {t('SSH key unreadable:')} {exc}")
|
||||||
|
|
@ -525,11 +527,11 @@ class ProxmoxMenuMixin:
|
||||||
print(" address <ip-de-l-hôte>/24")
|
print(" address <ip-de-l-hôte>/24")
|
||||||
print(" gateway <passerelle>")
|
print(" gateway <passerelle>")
|
||||||
print(" bridge-ports <interface>")
|
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 ""
|
return ""
|
||||||
_c, sortie = self._pve_show(
|
_c, sortie = self._pve_show("ip -o -4 route show default", quiet=True)
|
||||||
"ip -o -4 route show default", quiet=True
|
|
||||||
)
|
|
||||||
uplink = ""
|
uplink = ""
|
||||||
parts = (sortie or "").split()
|
parts = (sortie or "").split()
|
||||||
if "dev" in parts:
|
if "dev" in parts:
|
||||||
|
|
@ -629,6 +631,12 @@ class ProxmoxMenuMixin:
|
||||||
"next_vmid": pve.next_vmid(vms),
|
"next_vmid": pve.next_vmid(vms),
|
||||||
"storages": [s["name"] for s in stockages if s.get("actif")],
|
"storages": [s["name"] for s in stockages if s.get("actif")],
|
||||||
"storage": pve.pick_storage(stockages),
|
"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,
|
"bridges": ponts,
|
||||||
"bridge": pve.pick_bridge(ponts),
|
"bridge": pve.pick_bridge(ponts),
|
||||||
"ipconfig": ipconfig,
|
"ipconfig": ipconfig,
|
||||||
|
|
@ -797,7 +805,9 @@ class ProxmoxMenuMixin:
|
||||||
print(f"\n {t('Waiting for the VM address…')} {vm['name']}")
|
print(f"\n {t('Waiting for the VM address…')} {vm['name']}")
|
||||||
ip = self._pve_guest_ip(vm["vmid"])
|
ip = self._pve_guest_ip(vm["vmid"])
|
||||||
if not ip:
|
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
|
continue
|
||||||
print(f" ✓ {vm['name']} : {ip}")
|
print(f" ✓ {vm['name']} : {ip}")
|
||||||
if spec.get("add_ssh_config"):
|
if spec.get("add_ssh_config"):
|
||||||
|
|
@ -839,9 +849,10 @@ class ProxmoxMenuMixin:
|
||||||
distro = self._qemu_prompt_distro()
|
distro = self._qemu_prompt_distro()
|
||||||
version = self._qemu_prompt_version(distro)
|
version = self._qemu_prompt_version(distro)
|
||||||
arch = "amd64"
|
arch = "amd64"
|
||||||
nom = input(
|
nom = (
|
||||||
t("VM name (default: erplibre-<distro>): ")
|
input(t("VM name (default: erplibre-<distro>): ")).strip()
|
||||||
).strip() or f"erplibre-{distro}"
|
or f"erplibre-{distro}"
|
||||||
|
)
|
||||||
memoire = (
|
memoire = (
|
||||||
self._qemu_ask_ram(t("RAM in MB, blank = 4096"), 4096) or 4096
|
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.
|
# l'afficher avant de l'avoir choisi ne pouvait pas marcher.
|
||||||
vmid = pve.next_vmid(self._pve_vms())
|
vmid = pve.next_vmid(self._pve_vms())
|
||||||
ipconfig = pve.ipconfig_for(infos_ponts.get(pont, {}), vmid)
|
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('bridge')} : {pont}")
|
||||||
print(f" {t('address')} {ipconfig}")
|
print(f" {t('address')} {ipconfig}")
|
||||||
print(f" VMID : {vmid}")
|
print(f" VMID : {vmid}")
|
||||||
|
|
@ -989,7 +1002,9 @@ class ProxmoxMenuMixin:
|
||||||
identity_file=cle,
|
identity_file=cle,
|
||||||
proxy_jump=host["target"],
|
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):
|
def _pve_test_vm(self):
|
||||||
"""Ouvre Odoo (:8069) d'une VM Proxmox dans un navigateur en ligne.
|
"""Ouvre Odoo (:8069) d'une VM Proxmox dans un navigateur en ligne.
|
||||||
|
|
@ -1007,7 +1022,9 @@ class ProxmoxMenuMixin:
|
||||||
return
|
return
|
||||||
if not self._qemu_ip_reachable(ip, port=8069, timeout=3):
|
if not self._qemu_ip_reachable(ip, port=8069, timeout=3):
|
||||||
print(f"\n ⚠ {ip}:8069 {t('unreachable from here.')}")
|
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
|
return
|
||||||
navigateur = self._qemu_choose_cli_browser()
|
navigateur = self._qemu_choose_cli_browser()
|
||||||
if not navigateur:
|
if not navigateur:
|
||||||
|
|
@ -1032,7 +1049,9 @@ class ProxmoxMenuMixin:
|
||||||
"sshkey_path": "/root/.ssh/erplibre-deploy.pub",
|
"sshkey_path": "/root/.ssh/erplibre-deploy.pub",
|
||||||
}
|
}
|
||||||
print(f"\n── {t('Example: demo-vm, Debian 13, on a Proxmox host')} ──")
|
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):
|
for cmd in pve.create_cmds(101, spec):
|
||||||
print(f" {cmd}")
|
print(f" {cmd}")
|
||||||
|
|
||||||
|
|
@ -1091,8 +1110,16 @@ class ProxmoxMenuMixin:
|
||||||
"SSH configuration (~/.ssh/config, ProxyJump)"
|
"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")},
|
{"section": t("Catalog")},
|
||||||
{"prompt_description": t("List available images and their specs")},
|
{"prompt_description": t("List available images and their specs")},
|
||||||
{"prompt_description": t("Proxmox - example sequence (dry-run)")},
|
{"prompt_description": t("Proxmox - example sequence (dry-run)")},
|
||||||
|
|
|
||||||
|
|
@ -1215,6 +1215,7 @@ class QemuDeployMixin:
|
||||||
catalog[a] = entries
|
catalog[a] = entries
|
||||||
|
|
||||||
print(f"\n{t('Loading (VM list, branches)...')}")
|
print(f"\n{t('Loading (VM list, branches)...')}")
|
||||||
|
disque_libre, disque_total = self._host_disk_gb()
|
||||||
return {
|
return {
|
||||||
"catalog": catalog,
|
"catalog": catalog,
|
||||||
"arches": arches,
|
"arches": arches,
|
||||||
|
|
@ -1226,6 +1227,11 @@ class QemuDeployMixin:
|
||||||
"timezone": self._qemu_host_timezone(),
|
"timezone": self._qemu_host_timezone(),
|
||||||
"host_cpu": os.cpu_count() or 2,
|
"host_cpu": os.cpu_count() or 2,
|
||||||
"free_ram": self._host_free_ram_mb(),
|
"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,
|
"base_vcpus": self._QEMU_BASE_VCPUS,
|
||||||
"cpu_presets": self._QEMU_CPU_PRESETS,
|
"cpu_presets": self._QEMU_CPU_PRESETS,
|
||||||
"ram_presets": self._QEMU_RAM_PRESETS,
|
"ram_presets": self._QEMU_RAM_PRESETS,
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,11 @@ from script.todo.deploy_form_lib import ( # noqa: F401
|
||||||
clip_payload,
|
clip_payload,
|
||||||
copy_name,
|
copy_name,
|
||||||
disk_gb,
|
disk_gb,
|
||||||
|
disk_note,
|
||||||
entry_key,
|
entry_key,
|
||||||
expand_copies,
|
expand_copies,
|
||||||
fmt_dur,
|
fmt_dur,
|
||||||
|
gib,
|
||||||
parse_disk,
|
parse_disk,
|
||||||
parse_ram,
|
parse_ram,
|
||||||
plan_rows,
|
plan_rows,
|
||||||
|
|
@ -107,6 +109,8 @@ def run_deploy_form(ctx, run_app: bool = True):
|
||||||
branches = ctx.get("branches") or ["master"]
|
branches = ctx.get("branches") or ["master"]
|
||||||
host_cpu = ctx.get("host_cpu") or 2
|
host_cpu = ctx.get("host_cpu") or 2
|
||||||
free_ram = ctx.get("free_ram") or 0
|
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
|
base_vcpus = ctx.get("base_vcpus") or 2
|
||||||
extra_disk = ctx.get("extra_disk_gb") or 0
|
extra_disk = ctx.get("extra_disk_gb") or 0
|
||||||
desktop_disk = ctx.get("desktop_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
|
return
|
||||||
n, cpus, ram, disk = plan_totals(self.rows)
|
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:
|
if free_ram and ram > free_ram:
|
||||||
warn = f" ⚠ {t('> host free RAM')}"
|
alertes.append(t("> host free RAM"))
|
||||||
elif cpus > host_cpu:
|
if free_disk and disk > free_disk:
|
||||||
warn = f" ⚠ {t('> host cores')} ({host_cpu})"
|
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)
|
dupes = len({vm["name"] for vm in self.vms}) != len(self.vms)
|
||||||
dup_txt = (
|
dup_txt = (
|
||||||
f"\n ⚠ {t('Duplicate names detected; keeping as entered.')}"
|
f"\n ⚠ {t('Duplicate names detected; keeping as entered.')}"
|
||||||
|
|
@ -942,7 +953,8 @@ def run_deploy_form(ctx, run_app: bool = True):
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
self.query_one("#totals", Static).update(
|
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}"
|
f"{skip_txt}{warn}{dup_txt}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1514,6 +1514,28 @@ class TODO(
|
||||||
invites « défaut oui » où tout sauf « non » vaut oui."""
|
invites « défaut oui » où tout sauf « non » vaut oui."""
|
||||||
return ans.strip().lower() in ("n", "no", "non")
|
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
|
@staticmethod
|
||||||
def _host_free_ram_mb():
|
def _host_free_ram_mb():
|
||||||
"""RAM disponible de l'hôte en Mo (MemAvailable), 0 si inconnu."""
|
"""RAM disponible de l'hôte en Mo (MemAvailable), 0 si inconnu."""
|
||||||
|
|
|
||||||
|
|
@ -1253,6 +1253,14 @@ TRANSLATIONS = {
|
||||||
"fr": "> RAM libre de l'hôte",
|
"fr": "> RAM libre de l'hôte",
|
||||||
"en": "> host free RAM",
|
"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": {
|
"total RAM": {
|
||||||
"fr": "RAM totale",
|
"fr": "RAM totale",
|
||||||
"en": "total RAM",
|
"en": "total RAM",
|
||||||
|
|
@ -3359,6 +3367,10 @@ TRANSLATIONS = {
|
||||||
"fr": "Aucun pont sur l'hôte.",
|
"fr": "Aucun pont sur l'hôte.",
|
||||||
"en": "No bridge on the host.",
|
"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": {
|
"more RAM than the host has free": {
|
||||||
"fr": "plus de RAM que l'hôte n'en a de libre",
|
"fr": "plus de RAM que l'hôte n'en a de libre",
|
||||||
"en": "more RAM than the host has free",
|
"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):
|
def test_the_address_is_derived_from_the_vmid(self):
|
||||||
rows = [rangee("a"), rangee("b")]
|
rows = [rangee("a"), rangee("b")]
|
||||||
assign_vmids(
|
assign_vmids(rows, [], 100, lambda v: f"ip=10.10.10.{50 + v % 200}/24")
|
||||||
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[0]["vm"]["ipconfig"], "ip=10.10.10.150/24")
|
||||||
self.assertEqual(rows[1]["vm"]["ipconfig"], "ip=10.10.10.151/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",
|
"ssh_key": "/home/x/.ssh/id_ed25519.pub",
|
||||||
"start": True,
|
"start": True,
|
||||||
"add_ssh_config": True,
|
"add_ssh_config": True,
|
||||||
"install": {"branch": "develop", "label": "Odoo 18", "cmd": "make"},
|
"install": {
|
||||||
|
"branch": "develop",
|
||||||
|
"label": "Odoo 18",
|
||||||
|
"cmd": "make",
|
||||||
|
},
|
||||||
"monitor": True,
|
"monitor": True,
|
||||||
"parallelism": 2,
|
"parallelism": 2,
|
||||||
}
|
}
|
||||||
|
|
@ -141,7 +143,11 @@ def contexte():
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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",
|
"node": "pve1",
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"amd64": [
|
"amd64": [
|
||||||
|
|
@ -158,6 +164,10 @@ def contexte():
|
||||||
"next_vmid": 102,
|
"next_vmid": 102,
|
||||||
"storages": ["local-lvm", "local"],
|
"storages": ["local-lvm", "local"],
|
||||||
"storage": "local-lvm",
|
"storage": "local-lvm",
|
||||||
|
"storage_avail": {
|
||||||
|
"local-lvm": 90 * (1 << 30),
|
||||||
|
"local": 12 * (1 << 30),
|
||||||
|
},
|
||||||
"bridges": ["vmbr0"],
|
"bridges": ["vmbr0"],
|
||||||
"bridge": "vmbr0",
|
"bridge": "vmbr0",
|
||||||
"ipconfig": lambda pont, vmid: f"ip=10.10.10.{50 + vmid % 200}/24",
|
"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 pilote.pause()
|
await pilote.pause()
|
||||||
await gestes(app, pilote)
|
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
|
resultat["app"] = app
|
||||||
|
|
||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
@ -256,9 +274,7 @@ class TestEcran(unittest.TestCase):
|
||||||
|
|
||||||
app = self._rendu(gestes)
|
app = self._rendu(gestes)
|
||||||
self.assertEqual(len(app.rows), 4)
|
self.assertEqual(len(app.rows), 4)
|
||||||
vmids = [
|
vmids = [r["vm"]["vmid"] for r in app.rows if r["state"] != "exists"]
|
||||||
r["vm"]["vmid"] for r in app.rows if r["state"] != "exists"
|
|
||||||
]
|
|
||||||
self.assertEqual(len(vmids), len(set(vmids)))
|
self.assertEqual(len(vmids), len(set(vmids)))
|
||||||
|
|
||||||
def test_deploying_yields_a_spec_the_engine_can_run(self):
|
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.assertIn("ip=", vm["ipconfig"])
|
||||||
self.assertEqual(spec["install"]["branch"], "develop")
|
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):
|
def test_text_prompts_are_not_a_cancellation(self):
|
||||||
# {} n'est pas None : l'appelant distingue « annulé » de
|
# {} n'est pas None : l'appelant distingue « annulé » de
|
||||||
# « pose-moi les questions à l'ancienne ».
|
# « 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)
|
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):
|
class TestEtatDuNom(unittest.TestCase):
|
||||||
"""Un nom déjà pris, un disque resté seul : deux gravités différentes."""
|
"""Un nom déjà pris, un disque resté seul : deux gravités différentes."""
|
||||||
|
|
||||||
def test_a_defined_vm_is_skipped(self):
|
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.assertEqual(etat, "exists")
|
||||||
self.assertTrue(note)
|
self.assertTrue(note)
|
||||||
|
|
||||||
|
|
@ -105,7 +132,9 @@ def entree(distro, version, arch="amd64"):
|
||||||
|
|
||||||
class TestSocleDuPlan(unittest.TestCase):
|
class TestSocleDuPlan(unittest.TestCase):
|
||||||
def setUp(self):
|
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 = [
|
self.app.rows = [
|
||||||
{
|
{
|
||||||
"vm": {
|
"vm": {
|
||||||
|
|
@ -190,7 +219,9 @@ def membres(chemin, classe=None):
|
||||||
arbre = ast.parse(pathlib.Path(chemin).read_text(encoding="utf-8"))
|
arbre = ast.parse(pathlib.Path(chemin).read_text(encoding="utf-8"))
|
||||||
trouves = set()
|
trouves = set()
|
||||||
for n in ast.walk(arbre):
|
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 |= {
|
trouves |= {
|
||||||
m.name for m in n.body if isinstance(m, ast.FunctionDef)
|
m.name for m in n.body if isinstance(m, ast.FunctionDef)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue