[FIX] deploy : la branche du dépôt, et la sortie pendant qu'elle arrive
Trois défauts rapportés sur un déploiement Proxmox. La branche proposée était « dependabot/pip/aiobotocore-3.1.3 » : « git ls-remote » rend la liste par ordre alphabétique, et l'écran en prenait la première. Il propose maintenant la branche du DÉPÔT — celle qu'on a sous les yeux — puis develop, puis master ; la liste met ces trois-là en tête et relègue les branches de robot à la fin. Les deux écrans partagent la règle. La vue de progression n'affichait la sortie qu'à la FIN du travail : « subprocess.run » ne rend rien avant. Sur Proxmox, une VM demande le téléchargement de 325 Mio puis l'import du disque — plusieurs minutes de bloc vide. Elle lit maintenant ligne à ligne. Et « s » y ouvre un ssh sur la VM créée, par son nom, donc par l'entrée ~/.ssh/config qui porte le rebond. --- EN --- Three defects reported on a Proxmox deployment. The branch offered was "dependabot/pip/aiobotocore-3.1.3": "git ls-remote" returns the list in alphabetical order and the screen took its first entry. It now offers the CHECKOUT's branch — the one in front of you — then develop, then master; the list puts those three first and pushes robot branches to the end. Both screens share the rule. The progress view only showed output at the END of a job: "subprocess.run" returns nothing before that. On Proxmox a VM needs a 325 MiB download then a disk import — minutes of an empty block. It now reads line by line. And "s" opens an ssh to the created VM, by name, hence through the ~/.ssh/config entry that carries the jump. Assisted-by: Claude Opus 5
This commit is contained in:
parent
e8ab56a0df
commit
3d8e750af2
9 changed files with 373 additions and 14 deletions
|
|
@ -291,6 +291,53 @@ def plan_rows(vms, domains, extra_disk_gb=0, orphelin=None):
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
# Les branches qu'on veut voir en premier. « ls-remote » les rend par ordre
|
||||||
|
# alphabétique : la liste commençait donc par « dependabot/pip/aiobotocore »,
|
||||||
|
# et c'est CELLE-LÀ qui était proposée par défaut — rapporté.
|
||||||
|
BRANCHES_EN_TETE = ("develop", "master")
|
||||||
|
|
||||||
|
|
||||||
|
def branch_default(branches, courante=""):
|
||||||
|
"""La branche à proposer : celle du DÉPÔT d'abord.
|
||||||
|
|
||||||
|
Déployer avec la branche qu'on a sous les yeux est le cas courant ; à
|
||||||
|
défaut « develop », puis « master ». Le premier de la liste alphabétique
|
||||||
|
n'a aucune raison d'être un bon défaut, et c'était une branche de
|
||||||
|
dependabot.
|
||||||
|
"""
|
||||||
|
liste = list(branches or ())
|
||||||
|
if not liste:
|
||||||
|
return ""
|
||||||
|
for candidate in (courante,) + BRANCHES_EN_TETE:
|
||||||
|
if candidate and candidate in liste:
|
||||||
|
return candidate
|
||||||
|
return liste[0]
|
||||||
|
|
||||||
|
|
||||||
|
def branch_order(branches, courante=""):
|
||||||
|
"""La liste réordonnée : la branche du dépôt, develop, master, puis le
|
||||||
|
reste, et les branches de robot à la fin.
|
||||||
|
|
||||||
|
Une liste de soixante entrées qui commence par six « dependabot/pip/… »
|
||||||
|
demande de dérouler pour trouver ce qu'on cherche.
|
||||||
|
"""
|
||||||
|
liste = list(branches or ())
|
||||||
|
# Dédoublonnée : la branche du dépôt EST souvent « develop », et la voir
|
||||||
|
# deux fois de suite ferait douter de la liste.
|
||||||
|
tete, vues = [], set()
|
||||||
|
for b in (courante,) + BRANCHES_EN_TETE:
|
||||||
|
if b and b in liste and b not in vues:
|
||||||
|
tete.append(b)
|
||||||
|
vues.add(b)
|
||||||
|
milieu, robots = [], []
|
||||||
|
for b in liste:
|
||||||
|
if b in vues:
|
||||||
|
continue
|
||||||
|
vues.add(b)
|
||||||
|
(robots if b.startswith("dependabot/") else milieu).append(b)
|
||||||
|
return tete + milieu + robots
|
||||||
|
|
||||||
|
|
||||||
def gib(nbytes) -> int:
|
def gib(nbytes) -> int:
|
||||||
"""Octets -> Gio entiers. Le plan compte en Go partout ailleurs : mêler
|
"""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."""
|
des unités sur la même ligne de totaux la rendrait illisible."""
|
||||||
|
|
@ -550,7 +597,10 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True):
|
||||||
|
|
||||||
Un bloc reste DÉPLIÉ tant que la VM tourne, se replie dès qu'elle réussit
|
Un bloc reste DÉPLIÉ tant que la VM tourne, se replie dès qu'elle réussit
|
||||||
— et reste ouvert si elle échoue, puisque c'est ce qu'on veut lire."""
|
— et reste ouvert si elle échoue, puisque c'est ce qu'on veut lire."""
|
||||||
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
shlex_quote = shlex.quote
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
|
|
@ -579,6 +629,10 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True):
|
||||||
#hint { height: auto; color: $text-muted; padding: 0 1; }
|
#hint { height: auto; color: $text-muted; padding: 0 1; }
|
||||||
"""
|
"""
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
|
# « s » comme dans le tableau de bord : une VM qui vient d'être
|
||||||
|
# créée est souvent joignable tout de suite, et on veut y entrer
|
||||||
|
# sans quitter l'écran. Rapporté comme manquant.
|
||||||
|
("s", "ssh", t("SSH")),
|
||||||
("c", "copy_current", t("Copy log")),
|
("c", "copy_current", t("Copy log")),
|
||||||
("C", "copy_all", t("Copy all logs")),
|
("C", "copy_all", t("Copy all logs")),
|
||||||
("q", "quit", t("Quit")),
|
("q", "quit", t("Quit")),
|
||||||
|
|
@ -587,6 +641,10 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._out = {name: "" for _jid, name, _p in jobs}
|
self._out = {name: "" for _jid, name, _p in jobs}
|
||||||
|
# Ce qui a DÉJÀ été écrit à l'écran, pour ne pas le doubler.
|
||||||
|
self._ecrites = {}
|
||||||
|
# Les VM créées sans erreur : celles où « s » peut entrer.
|
||||||
|
self._reussies = []
|
||||||
self._done = 0
|
self._done = 0
|
||||||
self._t0 = time.time()
|
self._t0 = time.time()
|
||||||
self._slots = threading.Semaphore(max(1, parallelism))
|
self._slots = threading.Semaphore(max(1, parallelism))
|
||||||
|
|
@ -609,7 +667,8 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True):
|
||||||
with Vertical():
|
with Vertical():
|
||||||
yield Static("", id="summary")
|
yield Static("", id="summary")
|
||||||
yield Static(
|
yield Static(
|
||||||
f" {t('c copy log · C copy all · q quit')}", id="hint"
|
f" {t('s ssh · c copy log · C copy all · q quit')}",
|
||||||
|
id="hint",
|
||||||
)
|
)
|
||||||
yield Button(t("Copy all logs"), id="copyall")
|
yield Button(t("Copy all logs"), id="copyall")
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
@ -634,27 +693,65 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True):
|
||||||
def _job() -> None:
|
def _job() -> None:
|
||||||
with self._slots:
|
with self._slots:
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
|
lignes = []
|
||||||
try:
|
try:
|
||||||
res = subprocess.run(
|
# « Popen » et LECTURE LIGNE À LIGNE, pas
|
||||||
parts, capture_output=True, text=True
|
# « subprocess.run » : celui-ci ne rend la sortie
|
||||||
|
# qu'à la fin du travail, et le bloc restait vide
|
||||||
|
# pendant tout le déploiement — rapporté. Sur Proxmox,
|
||||||
|
# une VM demande le téléchargement d'une image de
|
||||||
|
# 325 Mio puis l'import du disque : plusieurs minutes
|
||||||
|
# de silence, et rien à l'écran.
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
parts,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
bufsize=1,
|
||||||
)
|
)
|
||||||
rc = res.returncode
|
|
||||||
out = (res.stdout or "") + (res.stderr or "")
|
|
||||||
except (OSError, subprocess.SubprocessError) as exc:
|
except (OSError, subprocess.SubprocessError) as exc:
|
||||||
rc, out = 1, str(exc)
|
self.call_from_thread(
|
||||||
|
self._finish,
|
||||||
|
jid,
|
||||||
|
name,
|
||||||
|
1,
|
||||||
|
str(exc),
|
||||||
|
time.time() - t0,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
for ligne in proc.stdout:
|
||||||
|
ligne = ligne.rstrip("\n")
|
||||||
|
lignes.append(ligne)
|
||||||
|
self.call_from_thread(self._ecrire, name, ligne)
|
||||||
|
rc = proc.wait()
|
||||||
|
out = "\n".join(lignes)
|
||||||
self.call_from_thread(
|
self.call_from_thread(
|
||||||
self._finish, jid, name, rc, out, time.time() - t0
|
self._finish, jid, name, rc, out, time.time() - t0
|
||||||
)
|
)
|
||||||
|
|
||||||
self.run_worker(_job, thread=True, group="deploy", exclusive=False)
|
self.run_worker(_job, thread=True, group="deploy", exclusive=False)
|
||||||
|
|
||||||
|
def _ecrire(self, name, ligne):
|
||||||
|
"""Une ligne, dès qu'elle arrive. Appelée depuis le fil."""
|
||||||
|
self._ecrites[name] = True
|
||||||
|
try:
|
||||||
|
self.query_one(f"#log_{slug(name)}", RichLog).write(ligne)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _finish(self, jid, name, rc, out, secs):
|
def _finish(self, jid, name, rc, out, secs):
|
||||||
self._out[name] = out
|
self._out[name] = out
|
||||||
results.append((name, rc, out, secs))
|
results.append((name, rc, out, secs))
|
||||||
self._done += 1
|
self._done += 1
|
||||||
log = self.query_one(f"#log_{slug(name)}", RichLog)
|
if rc == 0:
|
||||||
for line in out.strip().splitlines():
|
self._reussies.append(name)
|
||||||
log.write(line)
|
# La sortie est déjà à l'écran, ligne par ligne : la réécrire
|
||||||
|
# la doublerait. Seul un échec avant même le démarrage du
|
||||||
|
# processus n'a rien écrit.
|
||||||
|
if not self._ecrites.get(name):
|
||||||
|
log = self.query_one(f"#log_{slug(name)}", RichLog)
|
||||||
|
for line in out.strip().splitlines():
|
||||||
|
log.write(line)
|
||||||
block = self.query_one(f"#{slug(name)}", Collapsible)
|
block = self.query_one(f"#{slug(name)}", Collapsible)
|
||||||
mark = "✅" if rc == 0 else "❌"
|
mark = "✅" if rc == 0 else "❌"
|
||||||
block.title = f"{mark} [{jid}] {name} · {fmt_dur(secs)}" + (
|
block.title = f"{mark} [{jid}] {name} · {fmt_dur(secs)}" + (
|
||||||
|
|
@ -665,6 +762,27 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True):
|
||||||
block.collapsed = rc == 0
|
block.collapsed = rc == 0
|
||||||
self._refresh_summary()
|
self._refresh_summary()
|
||||||
|
|
||||||
|
def action_ssh(self) -> None:
|
||||||
|
"""Entre dans la VM créée, sans quitter l'écran.
|
||||||
|
|
||||||
|
Par son NOM et non par son adresse : c'est l'entrée
|
||||||
|
~/.ssh/config que le déploiement vient d'écrire qui sait comment
|
||||||
|
l'atteindre — et pour une VM posée sur un hôte Proxmox, elle
|
||||||
|
porte le rebond, seul chemin vers son réseau interne.
|
||||||
|
|
||||||
|
« suspend() » rend le terminal : ssh a besoin du clavier, et
|
||||||
|
Textual le tient encore.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
if not self._reussies:
|
||||||
|
self.notify(t("No VM created yet."), severity="warning")
|
||||||
|
return
|
||||||
|
# La dernière créée : c'est celle qu'on regarde.
|
||||||
|
nom = self._reussies[-1]
|
||||||
|
with self.suspend():
|
||||||
|
os.system(f"ssh {shlex_quote(nom)} || true")
|
||||||
|
|
||||||
# -- presse-papiers (OSC 52 : traverse SSH) --------------------- #
|
# -- presse-papiers (OSC 52 : traverse SSH) --------------------- #
|
||||||
def _copy(self, text, what):
|
def _copy(self, text, what):
|
||||||
payload, cut = clip_payload(text)
|
payload, cut = clip_payload(text)
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ from script.todo.deploy_form_lib import (
|
||||||
FREE,
|
FREE,
|
||||||
RES_FIELDS,
|
RES_FIELDS,
|
||||||
SELECT_TO_FIELD,
|
SELECT_TO_FIELD,
|
||||||
|
branch_default,
|
||||||
|
branch_order,
|
||||||
build_vms,
|
build_vms,
|
||||||
disk_note,
|
disk_note,
|
||||||
entry_key,
|
entry_key,
|
||||||
|
|
@ -126,7 +128,12 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
||||||
catalog = ctx["catalog"]
|
catalog = ctx["catalog"]
|
||||||
noms_pris = ctx["names"]
|
noms_pris = ctx["names"]
|
||||||
vmids_pris = ctx["vmids"]
|
vmids_pris = ctx["vmids"]
|
||||||
branches = ctx.get("branches") or ["master"]
|
# Ordre et défaut partagés avec le formulaire QEMU/KVM : la liste vient
|
||||||
|
# de « git ls-remote », alphabétique, et commençait donc par une branche
|
||||||
|
# de dependabot — proposée par défaut. Rapporté.
|
||||||
|
branches = branch_order(
|
||||||
|
ctx.get("branches") or ["master"], ctx.get("branch_current")
|
||||||
|
)
|
||||||
profiles = ctx.get("install_profiles") or []
|
profiles = ctx.get("install_profiles") or []
|
||||||
stockages = ctx.get("storages") or []
|
stockages = ctx.get("storages") or []
|
||||||
ponts = ctx.get("bridges") or []
|
ponts = ctx.get("bridges") or []
|
||||||
|
|
@ -309,7 +316,9 @@ def run_proxmox_form(ctx, run_app: bool = True):
|
||||||
)
|
)
|
||||||
yield Select(
|
yield Select(
|
||||||
[(b, b) for b in branches],
|
[(b, b) for b in branches],
|
||||||
value=branches[0],
|
value=branch_default(
|
||||||
|
branches, ctx.get("branch_current")
|
||||||
|
),
|
||||||
allow_blank=False,
|
allow_blank=False,
|
||||||
id="f_branch",
|
id="f_branch",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -825,6 +825,8 @@ class ProxmoxMenuMixin:
|
||||||
"internal_bridge": (pve.INTERNAL_BRIDGE, pve.INTERNAL_CIDR),
|
"internal_bridge": (pve.INTERNAL_BRIDGE, pve.INTERNAL_CIDR),
|
||||||
"build_command": build_command,
|
"build_command": build_command,
|
||||||
"branches": self._qemu_branch_list() or ["master"],
|
"branches": self._qemu_branch_list() or ["master"],
|
||||||
|
# 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(),
|
"install_profiles": self._qemu_install_profiles(),
|
||||||
# Même règle qu'en QEMU/KVM : un système peut IMPOSER ce qu'on
|
# 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
|
# installe dessus. Un Proxmox imbriqué recevait sinon ERPLibre et
|
||||||
|
|
|
||||||
|
|
@ -1254,6 +1254,8 @@ class QemuDeployMixin:
|
||||||
"native": native,
|
"native": native,
|
||||||
"domains": self._qemu_list_domains(),
|
"domains": self._qemu_list_domains(),
|
||||||
"branches": self._qemu_branch_list() or ["master"],
|
"branches": self._qemu_branch_list() or ["master"],
|
||||||
|
# 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(),
|
"install_profiles": self._qemu_install_profiles(),
|
||||||
# Les systèmes qui IMPOSENT ce qu'on installe dessus. Sans cette
|
# Les systèmes qui IMPOSENT ce qu'on installe dessus. Sans cette
|
||||||
# table, le formulaire posait ERPLibre + Odoo 18 sur une VM
|
# table, le formulaire posait ERPLibre + Odoo 18 sur une VM
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ from script.todo.deploy_form_lib import ( # noqa: F401
|
||||||
SELECT_TO_FIELD,
|
SELECT_TO_FIELD,
|
||||||
apply_overrides,
|
apply_overrides,
|
||||||
apply_profile,
|
apply_profile,
|
||||||
|
branch_default,
|
||||||
|
branch_order,
|
||||||
build_spec,
|
build_spec,
|
||||||
build_vms,
|
build_vms,
|
||||||
clean_hostname,
|
clean_hostname,
|
||||||
|
|
@ -116,7 +118,9 @@ def run_deploy_form(ctx, run_app: bool = True):
|
||||||
no_erplibre = {
|
no_erplibre = {
|
||||||
impose[1].strip() for impose in distro_profiles.values() if impose
|
impose[1].strip() for impose in distro_profiles.values() if impose
|
||||||
}
|
}
|
||||||
branches = ctx.get("branches") or ["master"]
|
branches = branch_order(
|
||||||
|
ctx.get("branches") or ["master"], ctx.get("branch_current")
|
||||||
|
)
|
||||||
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
|
free_disk = ctx.get("free_disk") or 0
|
||||||
|
|
@ -332,8 +336,8 @@ def run_deploy_form(ctx, run_app: bool = True):
|
||||||
)
|
)
|
||||||
yield Select(
|
yield Select(
|
||||||
[(b, b) for b in branches],
|
[(b, b) for b in branches],
|
||||||
value=(
|
value=branch_default(
|
||||||
"develop" if "develop" in branches else branches[0]
|
branches, ctx.get("branch_current")
|
||||||
),
|
),
|
||||||
allow_blank=False,
|
allow_blank=False,
|
||||||
id="f_branch",
|
id="f_branch",
|
||||||
|
|
|
||||||
|
|
@ -2304,6 +2304,24 @@ class QemuManageMixin:
|
||||||
pass
|
pass
|
||||||
return None, None, arch
|
return None, None, arch
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _qemu_repo_branch():
|
||||||
|
"""Branche du dépôt COURANT, ou '' — le défaut des formulaires.
|
||||||
|
|
||||||
|
On déploie le plus souvent ce qu'on a sous les yeux ; le premier nom
|
||||||
|
de la liste alphabétique, lui, n'a aucune raison d'être bon."""
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return ""
|
||||||
|
nom = (res.stdout or "").strip()
|
||||||
|
return "" if res.returncode or nom == "HEAD" else nom
|
||||||
|
|
||||||
def _qemu_branch_list(self):
|
def _qemu_branch_list(self):
|
||||||
"""Branches distantes d'ERPLibre, triées. Vide si le réseau manque.
|
"""Branches distantes d'ERPLibre, triées. Vide si le réseau manque.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3986,6 +3986,14 @@ TRANSLATIONS = {
|
||||||
"fr": "Fichiers orphelins :",
|
"fr": "Fichiers orphelins :",
|
||||||
"en": "Orphan files:",
|
"en": "Orphan files:",
|
||||||
},
|
},
|
||||||
|
"s ssh · c copy log · C copy all · q quit": {
|
||||||
|
"fr": "s ssh · c copier le log · C copier tout · q quitter",
|
||||||
|
"en": "s ssh · c copy log · C copy all · q quit",
|
||||||
|
},
|
||||||
|
"No VM created yet.": {
|
||||||
|
"fr": "Aucune VM créée pour l'instant.",
|
||||||
|
"en": "No VM created yet.",
|
||||||
|
},
|
||||||
# « models » manquait alors que ses trois voisines de la même ligne
|
# « models » manquait alors que ses trois voisines de la même ligne
|
||||||
# étaient traduites : le rapport de qualité de migration sortait
|
# étaient traduites : le rapport de qualité de migration sortait
|
||||||
# « 42 modules · 812 models · 1 204 vues ».
|
# « 42 modules · 812 models · 1 204 vues ».
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,58 @@ class TestRessourcesLibres(unittest.TestCase):
|
||||||
self.assertEqual(lib.res_value("8", "", 2), 8)
|
self.assertEqual(lib.res_value("8", "", 2), 8)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLesBranches(unittest.TestCase):
|
||||||
|
"""« git ls-remote » rend les branches par ordre alphabétique.
|
||||||
|
|
||||||
|
Rapporté : le formulaire Proxmox proposait « dependabot/pip/aiobotocore-
|
||||||
|
3.1.3 » — la première de la liste. Le bon défaut est la branche du DÉPÔT
|
||||||
|
qu'on a sous les yeux, puis develop, puis master.
|
||||||
|
"""
|
||||||
|
|
||||||
|
BRANCHES = [
|
||||||
|
"dependabot/pip/aiobotocore-3.1.3",
|
||||||
|
"dependabot/pip/boto3-1.42.49",
|
||||||
|
"develop",
|
||||||
|
"dev_mobile",
|
||||||
|
"master",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_the_checkout_branch_wins(self):
|
||||||
|
self.assertEqual(
|
||||||
|
lib.branch_default(self.BRANCHES, "dev_mobile"), "dev_mobile"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_without_it_develop_then_master(self):
|
||||||
|
self.assertEqual(lib.branch_default(self.BRANCHES, ""), "develop")
|
||||||
|
sans_develop = [b for b in self.BRANCHES if b != "develop"]
|
||||||
|
self.assertEqual(lib.branch_default(sans_develop, ""), "master")
|
||||||
|
|
||||||
|
def test_a_branch_that_no_longer_exists_is_ignored(self):
|
||||||
|
# On déploie une branche qui existe, pas celle qu'on avait localement.
|
||||||
|
self.assertEqual(
|
||||||
|
lib.branch_default(self.BRANCHES, "partie-en-fumée"), "develop"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_last_resort_is_the_first_but_never_by_default(self):
|
||||||
|
self.assertEqual(lib.branch_default(["zzz"], ""), "zzz")
|
||||||
|
self.assertEqual(lib.branch_default([], "develop"), "")
|
||||||
|
|
||||||
|
def test_the_list_puts_the_useful_ones_first(self):
|
||||||
|
ordre = lib.branch_order(self.BRANCHES, "dev_mobile")
|
||||||
|
self.assertEqual(ordre[:3], ["dev_mobile", "develop", "master"])
|
||||||
|
|
||||||
|
def test_the_robots_go_last(self):
|
||||||
|
ordre = lib.branch_order(self.BRANCHES, "develop")
|
||||||
|
self.assertTrue(all(b.startswith("dependabot/") for b in ordre[-2:]))
|
||||||
|
|
||||||
|
def test_nothing_is_lost_nor_duplicated(self):
|
||||||
|
# La branche du dépôt EST souvent « develop » : la voir deux fois
|
||||||
|
# ferait douter de la liste.
|
||||||
|
ordre = lib.branch_order(self.BRANCHES, "develop")
|
||||||
|
self.assertEqual(sorted(ordre), sorted(self.BRANCHES))
|
||||||
|
self.assertEqual(len(ordre), len(set(ordre)))
|
||||||
|
|
||||||
|
|
||||||
class TestPlaceDisque(unittest.TestCase):
|
class TestPlaceDisque(unittest.TestCase):
|
||||||
"""La demande du plan ne dit pas si ça rentre : il faut la place à côté."""
|
"""La demande du plan ne dit pas si ça rentre : il faut la place à côté."""
|
||||||
|
|
||||||
|
|
|
||||||
146
test/test_todo_deploy_progress.py
Normal file
146
test/test_todo_deploy_progress.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
|
"""La vue de progression d'un déploiement : en direct, et on peut y entrer.
|
||||||
|
|
||||||
|
Rapporté sur un déploiement Proxmox : « les logs ne sont pas live, ils sont
|
||||||
|
apparus à la toute fin » et « il manque les boutons comme s pour se connecter
|
||||||
|
en ssh ». Les deux étaient vrais de cette vue — elle exécutait chaque travail
|
||||||
|
avec `subprocess.run`, qui ne rend sa sortie qu'à la fin, et ses touches se
|
||||||
|
limitaient à copier et quitter.
|
||||||
|
|
||||||
|
Une VM sur Proxmox demande le téléchargement d'une image de 325 Mio puis
|
||||||
|
l'import de son disque : plusieurs minutes d'un bloc vide.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.argv = ["todo.py"]
|
||||||
|
from script.todo.deploy_form_lib import run_deploy_progress # noqa: E402
|
||||||
|
|
||||||
|
try:
|
||||||
|
import textual # noqa: F401
|
||||||
|
|
||||||
|
TEXTUAL = True
|
||||||
|
except Exception: # pragma: no cover - dépend de l'environnement
|
||||||
|
TEXTUAL = False
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(TEXTUAL, "Textual absent")
|
||||||
|
class TestLeFlux(unittest.TestCase):
|
||||||
|
def test_the_lines_appear_while_the_job_runs(self):
|
||||||
|
"""Le cœur du rapport : compté PENDANT, pas après."""
|
||||||
|
jobs = [
|
||||||
|
(
|
||||||
|
"1",
|
||||||
|
"vm-flux",
|
||||||
|
[
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
"for i in $(seq 1 6); do echo ligne $i; sleep 1; done",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
vu = []
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
from textual.widgets import RichLog
|
||||||
|
|
||||||
|
app = run_deploy_progress(jobs, 1, run_app=False)
|
||||||
|
async with app.run_test(size=(120, 30)) as pilote:
|
||||||
|
await pilote.pause()
|
||||||
|
journal = app.query_one(RichLog)
|
||||||
|
for _ in range(3):
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
await pilote.pause()
|
||||||
|
vu.append(len(journal.lines))
|
||||||
|
vu.append(len(app._reussies))
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
# Au moins une ligne AVANT la fin, et de plus en plus.
|
||||||
|
self.assertGreater(vu[0], 0, "rien à l'écran pendant le travail")
|
||||||
|
self.assertGreater(vu[2], vu[0], "l'affichage n'avance pas")
|
||||||
|
|
||||||
|
def test_the_output_is_not_written_twice(self):
|
||||||
|
# `_finish` réécrivait tout : la sortie apparaîtrait en double.
|
||||||
|
jobs = [("1", "vm-court", ["bash", "-c", "echo une; echo deux"])]
|
||||||
|
vu = {}
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
from textual.widgets import RichLog
|
||||||
|
|
||||||
|
app = run_deploy_progress(jobs, 1, run_app=False)
|
||||||
|
async with app.run_test(size=(120, 30)) as pilote:
|
||||||
|
await pilote.pause()
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
await pilote.pause()
|
||||||
|
vu["lignes"] = len(app.query_one(RichLog).lines)
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
self.assertEqual(vu["lignes"], 2)
|
||||||
|
|
||||||
|
def test_a_command_that_cannot_start_still_says_so(self):
|
||||||
|
# Le processus n'a rien écrit : c'est le seul cas où `_finish` doit
|
||||||
|
# poser la sortie lui-même.
|
||||||
|
jobs = [("1", "vm-absente", ["/n/existe/pas/du/tout"])]
|
||||||
|
vu = {}
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
from textual.widgets import RichLog
|
||||||
|
|
||||||
|
app = run_deploy_progress(jobs, 1, run_app=False)
|
||||||
|
async with app.run_test(size=(120, 30)) as pilote:
|
||||||
|
await pilote.pause()
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
await pilote.pause()
|
||||||
|
vu["lignes"] = len(app.query_one(RichLog).lines)
|
||||||
|
vu["resultats"] = list(app._reussies)
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
self.assertGreater(vu["lignes"], 0, "l'échec ne dit rien")
|
||||||
|
self.assertEqual(vu["resultats"], [])
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(TEXTUAL, "Textual absent")
|
||||||
|
class TestLaToucheSsh(unittest.TestCase):
|
||||||
|
def test_the_key_is_offered(self):
|
||||||
|
app = run_deploy_progress([("1", "vm", ["true"])], 1, run_app=False)
|
||||||
|
self.assertIn("s", [b[0] for b in type(app).BINDINGS])
|
||||||
|
|
||||||
|
def test_it_targets_the_vm_that_was_created(self):
|
||||||
|
jobs = [("1", "vm-creee", ["bash", "-c", "echo ok"])]
|
||||||
|
vu = {}
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
app = run_deploy_progress(jobs, 1, run_app=False)
|
||||||
|
async with app.run_test(size=(120, 30)) as pilote:
|
||||||
|
await pilote.pause()
|
||||||
|
await asyncio.sleep(1.2)
|
||||||
|
await pilote.pause()
|
||||||
|
vu["reussies"] = list(app._reussies)
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
# Par son NOM : c'est l'entrée ~/.ssh/config qui sait l'atteindre, et
|
||||||
|
# pour une VM Proxmox elle porte le rebond.
|
||||||
|
self.assertEqual(vu["reussies"], ["vm-creee"])
|
||||||
|
|
||||||
|
def test_a_failed_job_is_not_offered(self):
|
||||||
|
jobs = [("1", "vm-ratee", ["false"])]
|
||||||
|
vu = {}
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
app = run_deploy_progress(jobs, 1, run_app=False)
|
||||||
|
async with app.run_test(size=(120, 30)) as pilote:
|
||||||
|
await pilote.pause()
|
||||||
|
await asyncio.sleep(1.2)
|
||||||
|
await pilote.pause()
|
||||||
|
vu["reussies"] = list(app._reussies)
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
self.assertEqual(vu["reussies"], [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
Loading…
Reference in a new issue