[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:
Mathieu Benoit 2026-08-24 05:38:28 -04:00
parent e8ab56a0df
commit 3d8e750af2
9 changed files with 373 additions and 14 deletions

View file

@ -291,6 +291,53 @@ def plan_rows(vms, domains, extra_disk_gb=0, orphelin=None):
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:
"""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."""
@ -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
— et reste ouvert si elle échoue, puisque c'est ce qu'on veut lire."""
import shlex
import subprocess
shlex_quote = shlex.quote
import threading
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; }
"""
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_all", t("Copy all logs")),
("q", "quit", t("Quit")),
@ -587,6 +641,10 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True):
def __init__(self):
super().__init__()
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._t0 = time.time()
self._slots = threading.Semaphore(max(1, parallelism))
@ -609,7 +667,8 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True):
with Vertical():
yield Static("", id="summary")
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 Footer()
@ -634,27 +693,65 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True):
def _job() -> None:
with self._slots:
t0 = time.time()
lignes = []
try:
res = subprocess.run(
parts, capture_output=True, text=True
# « Popen » et LECTURE LIGNE À LIGNE, pas
# « 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:
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._finish, jid, name, rc, out, time.time() - t0
)
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):
self._out[name] = out
results.append((name, rc, out, secs))
self._done += 1
log = self.query_one(f"#log_{slug(name)}", RichLog)
for line in out.strip().splitlines():
log.write(line)
if rc == 0:
self._reussies.append(name)
# 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)
mark = "✅" if rc == 0 else "❌"
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
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) --------------------- #
def _copy(self, text, what):
payload, cut = clip_payload(text)

View file

@ -28,6 +28,8 @@ from script.todo.deploy_form_lib import (
FREE,
RES_FIELDS,
SELECT_TO_FIELD,
branch_default,
branch_order,
build_vms,
disk_note,
entry_key,
@ -126,7 +128,12 @@ def run_proxmox_form(ctx, run_app: bool = True):
catalog = ctx["catalog"]
noms_pris = ctx["names"]
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 []
stockages = ctx.get("storages") or []
ponts = ctx.get("bridges") or []
@ -309,7 +316,9 @@ def run_proxmox_form(ctx, run_app: bool = True):
)
yield Select(
[(b, b) for b in branches],
value=branches[0],
value=branch_default(
branches, ctx.get("branch_current")
),
allow_blank=False,
id="f_branch",
)

View file

@ -825,6 +825,8 @@ class ProxmoxMenuMixin:
"internal_bridge": (pve.INTERNAL_BRIDGE, pve.INTERNAL_CIDR),
"build_command": build_command,
"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(),
# 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

View file

@ -1254,6 +1254,8 @@ class QemuDeployMixin:
"native": native,
"domains": self._qemu_list_domains(),
"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(),
# Les systèmes qui IMPOSENT ce qu'on installe dessus. Sans cette
# table, le formulaire posait ERPLibre + Odoo 18 sur une VM

View file

@ -41,6 +41,8 @@ from script.todo.deploy_form_lib import ( # noqa: F401
SELECT_TO_FIELD,
apply_overrides,
apply_profile,
branch_default,
branch_order,
build_spec,
build_vms,
clean_hostname,
@ -116,7 +118,9 @@ def run_deploy_form(ctx, run_app: bool = True):
no_erplibre = {
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
free_ram = ctx.get("free_ram") 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(
[(b, b) for b in branches],
value=(
"develop" if "develop" in branches else branches[0]
value=branch_default(
branches, ctx.get("branch_current")
),
allow_blank=False,
id="f_branch",

View file

@ -2304,6 +2304,24 @@ class QemuManageMixin:
pass
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):
"""Branches distantes d'ERPLibre, triées. Vide si le réseau manque.

View file

@ -3986,6 +3986,14 @@ TRANSLATIONS = {
"fr": "Fichiers orphelins :",
"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
# étaient traduites : le rapport de qualité de migration sortait
# « 42 modules · 812 models · 1 204 vues ».

View file

@ -52,6 +52,58 @@ class TestRessourcesLibres(unittest.TestCase):
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):
"""La demande du plan ne dit pas si ça rentre : il faut la place à côté."""

View 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)