From 437ac4b457c110ea4ac032aacafc01acfc3ea42c Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 22 Aug 2026 03:19:45 -0400 Subject: [PATCH 01/55] [FIX] smoke: name the URL that actually failed, keep the previous log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report named the sitemap URL, never the one the redirect chain ended on. On this site every page goes through two or three hops -- measured, 146 for 55 pages, between the language prefix and the canonical slug -- so a 500 at the end was reported against a page that answers perfectly well. One goes and checks it, finds it healthy, and concludes the tool is wrong. urllib carries the answer: HTTPError.url is the URL that produced the error, not the one requested. fetch now returns it and the report shows it when it differs. A timeout was never reported as 500 -- fetch returns 0 and the report writes "no answer" -- but the evidence for a real 500 did evaporate: the Odoo log was opened with "w", so replaying the test erased the trace of the failure one had just seen. One generation is kept now. --- FR --- Le rapport nommait l'URL du sitemap, jamais celle où la chaîne de redirections aboutit. Sur ce site chaque page en traverse deux ou trois — mesuré, 146 pour 55 pages — donc un 500 au bout était imputé à une page qui répond très bien. On va la vérifier, on la trouve saine, et l'on conclut que l'outil se trompe. urllib porte la réponse : HTTPError.url est l'URL qui a produit l'erreur. `fetch` la rend, et le rapport l'affiche quand elle diffère. Un dépassement de délai n'a jamais été rendu comme un 500 — `fetch` rend 0, écrit « aucune réponse » — mais la preuve d'un vrai 500 s'évaporait : le journal Odoo était ouvert en « w », donc rejouer le test effaçait la trace qu'on venait de voir. Une génération est gardée. Assisted-by: Claude Opus 5 --- script/odoo/migration/smoke_public_url.py | 66 +++++--- script/todo/todo_i18n.py | 4 + test/test_smoke_final_url.py | 175 ++++++++++++++++++++++ test/test_smoke_public_url.py | 40 +++-- 4 files changed, 257 insertions(+), 28 deletions(-) create mode 100644 test/test_smoke_final_url.py diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py index 501fe76..37eccf8 100755 --- a/script/odoo/migration/smoke_public_url.py +++ b/script/odoo/migration/smoke_public_url.py @@ -138,6 +138,15 @@ def start_server(database, port, config_path="./config.conf", log_path=None): nomme la vue fautive faisait partie des absentes. Un fichier se relit entièrement, quand on veut. """ + # Garder l'exécution PRÉCÉDENTE. Le journal était ouvert en « w » : + # relancer le test effaçait la trace de l'échec qu'on venait de voir, + # et il ne restait plus rien à examiner. Une seule génération suffit — + # c'est celle d'avant qu'on vient chercher. + if log_path and os.path.isfile(log_path): + try: + os.replace(log_path, log_path + ".1") + except OSError: + pass handle = open(log_path, "w", encoding="utf-8") if log_path else None server = subprocess.Popen( [ @@ -179,23 +188,36 @@ def read_log(log_path): def fetch(url, timeout=30): - """(statut, corps). Statut 0 quand la connexion elle-même échoue.""" + """(statut, corps, url finale). Statut 0 si la connexion échoue. + + L'URL FINALE, pas seulement celle qu'on a demandée. Sur ce site + chaque page traverse deux ou trois redirections — mesuré, 146 pour + 55 pages — et quand la dernière rend 500, l'outil nommait la + première. On allait vérifier une page saine et l'on concluait que le + test se trompait. + """ try: with urllib.request.urlopen(url, timeout=timeout) as answer: - return answer.getcode(), answer.read().decode( - "utf-8", errors="replace" + return ( + answer.getcode(), + answer.read().decode("utf-8", errors="replace"), + answer.geturl(), ) except urllib.error.HTTPError as exc: - return exc.code, exc.read().decode("utf-8", errors="replace") + return ( + exc.code, + exc.read().decode("utf-8", errors="replace"), + exc.url or url, + ) except Exception: - return 0, "" + return 0, "", url def wait_ready(base_url, timeout=180, sleep=2): """Attendre que le serveur réponde. False s'il n'est jamais venu.""" deadline = time.time() + timeout while time.time() < deadline: - status, _body = fetch(base_url + "/web/login", timeout=5) + status, _body, _fin = fetch(base_url + "/web/login", timeout=5) if status: return True time.sleep(sleep) @@ -209,7 +231,7 @@ def sitemap_urls(base_url): servie en local. Garder le domaine ferait interroger la production — c'est le genre d'erreur qui ne se voit qu'après. """ - status, body = fetch(base_url + "/sitemap.xml") + status, body, _fin = fetch(base_url + "/sitemap.xml") if not status or status >= 400: return [], status lst_loc = RE_LOC.findall(body) @@ -217,7 +239,7 @@ def sitemap_urls(base_url): if "= 400: - lst_failure.append((url, status, [])) + lst_failure.append((url, status, [], finale)) return lst_failure @@ -324,7 +349,7 @@ def culprit_keys(database, lst_failure): exactement la recopie où l'on se trompe. """ lst_id = [] - for _url, _status, lst_parent in lst_failure: + for _url, _status, lst_parent, _finale in lst_failure: for parent_id in lst_parent: if parent_id not in lst_id: lst_id.append(parent_id) @@ -355,9 +380,14 @@ def render(lst_url, lst_failure, lst_key=None): f"❌ {len(lst_failure)} {t('of')} {len(lst_url)}" f" {t('public URL(s) failed')} :" ] - for url, status, lst_parent in lst_failure: + for url, status, lst_parent, finale in lst_failure: label = status or t("no answer") lines.append(f" [{label}] {url}") + # L'URL du sitemap n'est pas celle qui a échoué quand une + # redirection s'est interposée. Ne montrer que la première + # envoyait vérifier une page saine. + if finale and finale != url: + lines.append(f" → {t('failed at')} {finale}") if lst_parent: lines.append( f" {t('parent view(s) in cause')} :" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 6a5118a..6983f5c 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -6191,6 +6191,10 @@ TRANSLATIONS = { "fr": "vue(s) portent encore une balise qu'Odoo 18", "en": "view(s) still carry a tag Odoo 18", }, + "failed at": { + "fr": "a échoué sur", + "en": "failed at", + }, "Census": { "fr": "Recensement", "en": "Census", diff --git a/test/test_smoke_final_url.py b/test/test_smoke_final_url.py new file mode 100644 index 0000000..85a82dc --- /dev/null +++ b/test/test_smoke_final_url.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""L'URL qui a échoué n'est pas toujours celle qu'on a demandée. + +Sur un site Odoo, chaque page traverse deux ou trois redirections — +mesuré, 146 pour 55 pages, entre la langue et le slug canonique. Quand +la DERNIÈRE rend 500, l'outil nommait la première : on allait vérifier +une page parfaitement saine et l'on concluait que le test se trompait. + +Et un dépassement de délai ne rend PAS 500 : `fetch` rend 0, que le +rapport écrit « aucune réponse ». Confondre les deux enverrait chercher +une lenteur là où le serveur a répondu par une erreur. +""" + +import http.server +import io +import os +import socketserver +import sys +import threading +import unittest + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.odoo.migration import smoke_public_url as smoke # noqa: E402 +from script.todo import todo_i18n # noqa: E402 + + +class Chaine(http.server.BaseHTTPRequestHandler): + """/depart → 303 → /milieu → 303 → /fin, qui décide.""" + + fin_status = 500 + + def do_GET(self): + if self.path == "/depart": + self.send_response(303) + self.send_header("Location", "/milieu") + self.end_headers() + elif self.path == "/milieu": + self.send_response(303) + self.send_header("Location", "/fin") + self.end_headers() + elif self.path == "/direct": + self.send_response(500) + self.end_headers() + self.wfile.write(b"boum") + else: + self.send_response(self.fin_status) + self.end_headers() + self.wfile.write(b"boum") + + def log_message(self, *args): + pass + + +class TestFetchFollowsTheChain(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.srv = socketserver.TCPServer(("127.0.0.1", 0), Chaine) + cls.port = cls.srv.server_address[1] + cls.fil = threading.Thread(target=cls.srv.serve_forever, daemon=True) + cls.fil.start() + + @classmethod + def tearDownClass(cls): + cls.srv.shutdown() + cls.srv.server_close() + + def url(self, chemin): + return f"http://127.0.0.1:{self.port}{chemin}" + + def test_it_reports_the_url_that_actually_failed(self): + # LE point : la 500 est au bout de la chaîne, pas au départ. + statut, _corps, finale = smoke.fetch(self.url("/depart"), timeout=5) + self.assertEqual(statut, 500) + self.assertTrue(finale.endswith("/fin"), finale) + + def test_without_a_redirect_both_are_the_same(self): + statut, _corps, finale = smoke.fetch(self.url("/direct"), timeout=5) + self.assertEqual(statut, 500) + self.assertEqual(finale, self.url("/direct")) + + def test_a_success_also_carries_its_final_url(self): + Chaine.fin_status = 200 + try: + statut, _corps, finale = smoke.fetch( + self.url("/depart"), timeout=5 + ) + self.assertEqual(statut, 200) + self.assertTrue(finale.endswith("/fin"), finale) + finally: + Chaine.fin_status = 500 + + def test_a_dead_host_is_zero_NOT_five_hundred(self): + # C'est ce qui distingue « le serveur a répondu par une erreur » + # de « il n'a pas répondu ». Les confondre envoie chercher une + # lenteur là où il y a une exception. + statut, corps, finale = smoke.fetch( + "http://127.0.0.1:1/jamais", timeout=1 + ) + self.assertEqual(statut, 0) + self.assertEqual(corps, "") + self.assertEqual(finale, "http://127.0.0.1:1/jamais") + + def test_check_urls_keeps_the_final_url(self): + echecs = smoke.check_urls([self.url("/depart")], timeout=5) + self.assertEqual(len(echecs), 1) + url, statut, parents, finale = echecs[0] + self.assertEqual(url, self.url("/depart")) + self.assertEqual(statut, 500) + self.assertEqual(parents, []) + self.assertTrue(finale.endswith("/fin")) + + def test_a_page_that_answers_is_not_a_failure(self): + Chaine.fin_status = 200 + try: + self.assertEqual( + smoke.check_urls([self.url("/depart")], timeout=5), [] + ) + finally: + Chaine.fin_status = 500 + + +class TestTheReport(unittest.TestCase): + def test_it_names_the_final_url_when_it_differs(self): + texte = smoke.render( + ["a", "b"], [("http://x/depart", 500, [], "http://x/fin")] + ) + self.assertIn("http://x/depart", texte) + self.assertIn(todo_i18n.t("failed at"), texte) + self.assertIn("http://x/fin", texte) + + def test_it_stays_quiet_when_they_are_the_same(self): + # Répéter la même URL sur deux lignes n'apprend rien et allonge + # un rapport qui peut compter trente-quatre entrées. + texte = smoke.render( + ["a"], [("http://x/page", 500, [], "http://x/page")] + ) + self.assertNotIn(todo_i18n.t("failed at"), texte) + + def test_no_answer_is_worded_apart_from_a_status(self): + texte = smoke.render( + ["a"], [("http://x/page", 0, [], "http://x/page")] + ) + self.assertIn(todo_i18n.t("no answer"), texte) + self.assertNotIn("[500]", texte) + + +class TestTheLogSurvives(unittest.TestCase): + def test_the_previous_run_is_kept(self): + # Le journal était ouvert en « w » : relancer le test effaçait la + # trace de l'échec qu'on venait de voir. + with io.open( + os.path.join( + os.path.dirname(__file__), + "..", + "script", + "odoo", + "migration", + "smoke_public_url.py", + ), + encoding="utf-8", + ) as handle: + src = handle.read() + debut = src.index("def start_server") + fin = src.index("subprocess.Popen", debut) + self.assertIn("os.replace(log_path, log_path", src[debut:fin]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_smoke_public_url.py b/test/test_smoke_public_url.py index 8d2c9db..3c0977c 100755 --- a/test/test_smoke_public_url.py +++ b/test/test_smoke_public_url.py @@ -53,7 +53,15 @@ class TestReadingTheSitemap(unittest.TestCase): def setUp(self): self.answers = {} self.original = smoke.fetch - smoke.fetch = lambda url, timeout=30: self.answers.get(url, (404, "")) + + # `fetch` rend TROIS valeurs depuis qu'il porte l'URL finale. + # Un faux resté à deux casse chaque appelant sur un « not enough + # values to unpack » qui n'apprend rien de la panne réelle. + def faux(url, timeout=30): + statut, corps = self.answers.get(url, (404, "")) + return statut, corps, url + + smoke.fetch = faux self.addCleanup(setattr, smoke, "fetch", self.original) def test_a_plain_sitemap(self): @@ -101,13 +109,22 @@ class TestWhatCountsAsAFailure(unittest.TestCase): def setUp(self): self.answers = {} self.original = smoke.fetch - smoke.fetch = lambda url, timeout=30: self.answers.get(url, (200, "")) + + # `fetch` rend TROIS valeurs depuis qu'il porte l'URL finale. + # Un faux resté à deux casse chaque appelant sur un « not enough + # values to unpack » qui n'apprend rien de la panne réelle. + def faux(url, timeout=30): + statut, corps = self.answers.get(url, (200, "")) + return statut, corps, url + + smoke.fetch = faux self.addCleanup(setattr, smoke, "fetch", self.original) def test_a_500_fails(self): self.answers["http://h/bad"] = (500, "") self.assertEqual( - smoke.check_urls(["http://h/bad"]), [("http://h/bad", 500, [])] + smoke.check_urls(["http://h/bad"]), + [("http://h/bad", 500, [], "http://h/bad")], ) def test_a_404_fails_too(self): @@ -119,7 +136,8 @@ class TestWhatCountsAsAFailure(unittest.TestCase): def test_no_answer_at_all_fails(self): self.answers["http://h/dead"] = (0, "") self.assertEqual( - smoke.check_urls(["http://h/dead"]), [("http://h/dead", 0, [])] + smoke.check_urls(["http://h/dead"]), + [("http://h/dead", 0, [], "http://h/dead")], ) def test_a_200_passes(self): @@ -141,7 +159,9 @@ class TestTheReport(unittest.TestCase): self.assertIn("✅", text) def test_a_failure_shows_the_status_and_the_url(self): - text = smoke.render(["a"], [("http://h/blog/x", 500, [])]) + text = smoke.render( + ["a"], [("http://h/blog/x", 500, [], "http://h/blog/x")] + ) self.assertIn("500", text) self.assertIn("http://h/blog/x", text) @@ -211,7 +231,7 @@ class TestTheCulpritViewsAreNamed(unittest.TestCase): def test_a_late_context_is_still_attached(self): # Odoo vide son tampon à l'arrêt : le journal se lit APRÈS, et rien # ne doit dépendre du moment où la ligne est apparue. - lst_failure = [("http://h/a", 500, [])] + lst_failure = [("http://h/a", 500, [], "http://h/a")] log = ["[view_id: 3288, model: n/a, parent_id: 2841]"] rebuilt = smoke.attach_missing_parents(lst_failure, log) self.assertIn("2841", rebuilt[0][2]) @@ -221,20 +241,20 @@ class TestTheCulpritViewsAreNamed(unittest.TestCase): # à sa vue module, et c'est l'enfant (3282) qui portait l'arch # périmée. Ne nommer que le parent envoyait réinitialiser une copie # qui allait déjà bien, et la page restait en 500. - lst_failure = [("http://h/contactus", 500, [])] + lst_failure = [("http://h/contactus", 500, [], "http://h/contactus")] log = ["[view_id: 3282, model: n/a, parent_id: 3281]"] rebuilt = smoke.attach_missing_parents(lst_failure, log) self.assertEqual(rebuilt[0][2], ["3281", "3282"]) def test_the_parent_comes_first(self): # C'est le cas le plus fréquent — le blogue — donc en tête de liste. - lst_failure = [("http://h/a", 500, [])] + lst_failure = [("http://h/a", 500, [], "http://h/a")] log = ["[view_id: 3288, model: n/a, parent_id: 2841]"] rebuilt = smoke.attach_missing_parents(lst_failure, log) self.assertEqual(rebuilt[0][2][0], "2841") def test_an_already_attributed_id_is_not_duplicated(self): - lst_failure = [("http://h/a", 500, ["2841"])] + lst_failure = [("http://h/a", 500, ["2841"], "http://h/a")] log = ["[view_id: 3288, model: n/a, parent_id: 2841]"] rebuilt = smoke.attach_missing_parents(lst_failure, log) self.assertEqual(rebuilt[0][2].count("2841"), 1) @@ -359,7 +379,7 @@ class TestOfferingTheFix(unittest.TestCase): with contextlib.redirect_stdout(out): done = smoke.prompt( "db", - [("http://h/a", 500, ["2841"])], + [("http://h/a", 500, ["2841"], "http://h/a")], lst_key, ask=lambda prompt: answer, ) From d35d5592737c52e0b293e15523d2a1ba93f49733 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 22 Aug 2026 04:07:19 -0400 Subject: [PATCH 02/55] [FIX] smoke: two sites still unpacked three fields from the failure tuple MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My own regression. The tuple went from three fields to four and I missed recheck_after_reset, so a live migration died on "too many values to unpack" -- AFTER the COW copy had been reset, at the very moment the tool was about to prove the fix worked. Both sites wanted only the URL, so they index instead of unpacking. That survives the next field; a comment saying "always four elements" does not. The pass had no test at all, which is why the suite and the mutations both stayed green. It has three now, plus a guard that fails on any three-field unpack of that list. --- FR --- Ma régression. Le tuple est passé de trois à quatre champs et j'ai manqué recheck_after_reset : une migration en cours est morte sur « too many values to unpack » — APRÈS la réinitialisation de la copie COW, au moment précis où l'outil allait prouver que le correctif tenait. Les deux sites ne voulaient que l'URL : ils indexent au lieu de dépaqueter. Cela survit au prochain champ ; un commentaire « toujours quatre éléments » non. Cette passe n'avait aucun test, d'où le vert de la suite et des mutations. Elle en a trois, plus un garde qui tombe sur tout dépaquetage à trois champs de cette liste. Assisted-by: Claude Opus 5 --- script/odoo/migration/smoke_public_url.py | 4 +- test/test_smoke_final_url.py | 126 ++++++++++++++++++++++ 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py index 37eccf8..dd80601 100755 --- a/script/odoo/migration/smoke_public_url.py +++ b/script/odoo/migration/smoke_public_url.py @@ -777,7 +777,7 @@ def recheck_after_reset( f"{t('The server never answered on')} {base_url}" ) lst_again = check_urls( - [url for url, _s, _p in lst_failure], timeout=timeout + [echec[0] for echec in lst_failure], timeout=timeout ) if internal_needs_retry(internal_report): reprise = internal_phase( @@ -936,7 +936,7 @@ def main(argv=None): f"\n↻ {t('Re-checked the')} {len(lst_failure)}" f" {t('failing URL(s) after the reset')} :" ) - print(render([url for url, _s, _p in lst_failure], lst_again, None)) + print(render([echec[0] for echec in lst_failure], lst_again, None)) return 1 if (lst_again or internal_failed) else 0 diff --git a/test/test_smoke_final_url.py b/test/test_smoke_final_url.py index 85a82dc..6913b34 100644 --- a/test/test_smoke_final_url.py +++ b/test/test_smoke_final_url.py @@ -150,6 +150,132 @@ class TestTheReport(unittest.TestCase): self.assertNotIn("[500]", texte) +class TestNothingUnpacksTheFailureTupleBlindly(unittest.TestCase): + """Ajouter un champ au tuple d'échec a cassé une migration en cours. + + Le tuple est passé de trois à quatre éléments et deux sites + dépaquetaient encore trois — `too many values to unpack`, en plein + milieu, APRÈS la réinitialisation d'une copie COW. Le commentaire + « TOUJOURS quatre éléments » ne protège de rien : il faut ne pas + dépaqueter quand on ne veut qu'un champ. + """ + + CHEMIN = os.path.join( + os.path.dirname(__file__), + "..", + "script", + "odoo", + "migration", + "smoke_public_url.py", + ) + + def source(self): + with io.open(self.CHEMIN, encoding="utf-8") as handle: + return handle.read() + + def test_no_three_element_unpack_survives(self): + import re + + motif = re.compile( + r"for\s+[a-z_]+,\s*[a-z_]+,\s*[a-z_]+\s+in\s+lst_failure" + ) + trouves = motif.findall(self.source()) + self.assertEqual(trouves, [], f"dépaquetage à trois : {trouves}") + + def test_taking_only_the_url_uses_an_index(self): + # Indexer survit au prochain champ ajouté ; dépaqueter non. + self.assertIn("[echec[0] for echec in lst_failure]", self.source()) + + +class TestRecheckingAfterAReset(unittest.TestCase): + """La passe qui a cassé, exercée pour de vrai. + + Elle ne tournait sous aucun test : c'est pourquoi le dépaquetage à + trois y a survécu à la suite complète, aux mutations, et n'est + tombé qu'en production. + """ + + def setUp(self): + self.vrais = { + nom: getattr(smoke, nom) + for nom in ( + "start_server", + "wait_ready", + "check_urls", + "internal_needs_retry", + "stop_server", + ) + if hasattr(smoke, nom) + } + self.vus = [] + + class FauxServeur: + def __init__(self): + self.arrete = False + + smoke.start_server = lambda *a, **k: FauxServeur() + smoke.wait_ready = lambda *a, **k: True + smoke.internal_needs_retry = lambda rapport: False + if hasattr(smoke, "stop_server"): + smoke.stop_server = lambda *a, **k: None + + def faux_check(lst_url, timeout=30): + self.vus.append(list(lst_url)) + return [] + + smoke.check_urls = faux_check + + def tearDown(self): + for nom, valeur in self.vrais.items(): + setattr(smoke, nom, valeur) + + def test_it_rechecks_exactly_the_urls_that_had_failed(self): + echecs = [ + ("http://h/contactus", 500, ["2837"], "http://h/en/contactus"), + ("http://h/blog", 500, [], "http://h/blog"), + ] + smoke.recheck_after_reset( + "db", + 8169, + "./config.conf", + "http://h", + None, + echecs, + {"failures": []}, + internal=False, + ) + self.assertEqual(self.vus, [["http://h/contactus", "http://h/blog"]]) + + def test_it_rechecks_the_REQUESTED_url_not_the_final_one(self): + # On revérifie ce que le sitemap publie : c'est cette adresse-là + # que les visiteurs demandent. + echecs = [("http://h/a", 500, [], "http://h/z")] + smoke.recheck_after_reset( + "db", + 8169, + "./config.conf", + "http://h", + None, + echecs, + {"failures": []}, + internal=False, + ) + self.assertEqual(self.vus, [["http://h/a"]]) + + def test_an_empty_failure_list_rechecks_nothing(self): + smoke.recheck_after_reset( + "db", + 8169, + "./config.conf", + "http://h", + None, + [], + {"failures": []}, + internal=False, + ) + self.assertEqual(self.vus, [[]]) + + class TestTheLogSurvives(unittest.TestCase): def test_the_previous_run_is_kept(self): # Le journal était ouvert en « w » : relancer le test effaçait la From 0da6c7fcbeea2cb11e535300fd59aad735798813 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 13 Aug 2026 05:51:09 -0400 Subject: [PATCH 03/55] [FIX] todo qemu : la console vise l'hyperviseur, pas la VM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le choix [3] listait les domaines libvirt de la machine qui execute todo.py. Or on pilote un parc imbrique depuis l'exterieur : cette machine ne connait pas les VM d'un orchestrateur, et la bonne cible n'apparaissait donc jamais dans la liste. L'ecran VNC appartient a QEMU, donc a l'hyperviseur. Tunneler vers l'invite ne trouve rien : le socket n'existe pas de ce cote. La cible est le ProxyJump declare pour l'hote, lu par « ssh -G » — la seule lecture qui couvre Match, wildcards et Include. Le nom compose « saut+vm » n'est qu'un libelle. Le port est lu sur cet hyperviseur, sans sudo d'abord : le groupe libvirt suffit souvent, et « sudo -n » distant echoue faute de TTY. --- EN --- Choice [3] listed the libvirt domains of the machine running todo.py. But a nested fleet is driven from outside: that machine knows nothing of an orchestrator's VMs, so the right target never showed up. The VNC screen belongs to QEMU, hence to the hypervisor. Tunnelling to the guest finds nothing: the socket does not exist on that side. The target is the ProxyJump declared for the host, read via "ssh -G" — the only reading that covers Match, wildcards and Include. The composite "jump+vm" name is just a label. The port is read on that hypervisor, without sudo first: libvirt group membership often suffices, and remote "sudo -n" fails for lack of a TTY. Assisted-by: Claude Opus 5 (cherry picked from commit 2fb846d502cc8026732b58e1f487d7a6726f17eb) --- script/todo/todo.py | 122 +++++++++++++++++++++++++++++++------------- 1 file changed, 86 insertions(+), 36 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index 4614ffa..9b6ec2a 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1555,7 +1555,7 @@ class TODO: print(f" [3] {t('Hypervisor console (QEMU screen, no guest server)')}") kind_answer = input(f"{t('Choice')} [1]: ").strip() or "1" if kind_answer == "3": - self._qemu_console_tunnel() + self._qemu_console_tunnel(name, src) return port, kind = (5901, "VNC") if kind_answer == "2" else (3389, "RDP") local = port + 1 @@ -1597,52 +1597,102 @@ class TODO: « listen=none » QEMU n'ouvre AUCUN socket, et aucun tunnel n'y peut rien tant que le domaine n'est pas redéfini. """ - names = self._qemu_list_domains() - if not names: - print(f" {t('No local VM.')}") - return - for i, name in enumerate(names, 1): - print(f" [{i}] {name}") - raw = input(f"{t('Which VM?')} [1]: ").strip() or "1" - if not raw.isdigit() or not (1 <= int(raw) <= len(names)): - print(t("Cancelled.")) - return - name = names[int(raw) - 1] - try: - res = subprocess.run( - ["sudo", "virsh", "vncdisplay", name], - capture_output=True, - text=True, - timeout=15, - ) - except (OSError, subprocess.SubprocessError): - res = None - # « 127.0.0.1:0 » désigne le port 5900, « :1 » le 5901, etc. - port = None - if res and res.returncode == 0: - disp = res.stdout.strip().rsplit(":", 1) - if len(disp) == 2 and disp[1].isdigit(): - port = 5900 + int(disp[1]) + if src != "ssh_config": + jump, domain = "", name + else: + # L'écran VNC appartient à QEMU, donc à l'HYPERVISEUR — pas à + # l'invité. Tunneler vers la VM elle-même ne trouve rien : le + # socket n'existe pas de ce côté. Vécu, et c'est aussi ce qui + # rendait le premier jet de ce menu inutile hors machine locale. + # + # L'hyperviseur est le ProxyJump déclaré dans ssh_config, lu par + # « ssh -G » : c'est la seule lecture qui couvre toutes les formes + # d'écriture (Host, Match, wildcards, includes). Le nom composé + # « saut+vm » n'est qu'un libellé, il ne fait pas autorité. + jump = self._ssh_proxyjump(name) + domain = name.rsplit("+", 1)[-1] + if not jump: + print(f"\n ⚠ {t('No ProxyJump for this host in ~/.ssh/config.')}") + print(f" {t('Cannot tell which machine runs its QEMU.')}") + return + port = self._qemu_vnc_port(domain, jump) + # Les commandes de réparation se lancent SUR l'hyperviseur : le préfixe + # évite de les copier sur la mauvaise machine, l'erreur naturelle ici. + pre = f"ssh {jump} " if jump else "" if not port: print(f"\n ⚠ {t('This VM exposes no VNC port.')}") print(f" {t('Its display is likely spice with listen=none:')}") - print(f" sudo virsh dumpxml {name} | grep -A2 '") - print(f" sudo virsh start {name}") + print(f" {pre}sudo virsh start {domain}") print(f"\n {t('New VMs get this by default; see deploy_qemu.')}") return - host, from_ssh = self._qemu_self_address() - user = os.environ.get("USER", "user") - if not from_ssh: - print(f" ⚠ {t('Not in an SSH session: check the host address.')}") + if jump: + target = jump + else: + host, from_ssh = self._qemu_self_address() + user = os.environ.get("USER", "user") + if not from_ssh: + print(f" ⚠ {t('Not in an SSH session: check the host address.')}") + target = f"{user}@{host}" print(f"\n {t('Run this on YOUR workstation:')}") - print(f"\n ssh -N -L {port}:127.0.0.1:{port} {user}@{host}\n") + print(f"\n ssh -N -L {port}:127.0.0.1:{port} {target}\n") + if jump: + print(f" {t('Target is the hypervisor')} ({jump}), " + f"{t('not the VM: the socket is QEMU-side.')}") print(f" {t('then point your VNC client at')} localhost:{port}") print(f" {t('The tunnel stays open as long as that ssh runs.')}") + @staticmethod + def _ssh_proxyjump(host): + """ProxyJump effectif d'un hôte, tel que ssh le calcule lui-même. + + « ssh -G » rend la configuration RÉSOLUE : Match, wildcards et Include + compris. Relire ~/.ssh/config à la main raterait tout cela. + """ + try: + res = subprocess.run( + ["ssh", "-G", host], capture_output=True, text=True, timeout=10 + ) + except (OSError, subprocess.SubprocessError): + return "" + for line in res.stdout.splitlines(): + if line.startswith("proxyjump "): + value = line.split(None, 1)[1].strip() + return "" if value.lower() == "none" else value + return "" + + @staticmethod + def _qemu_vnc_port(domain, jump=""): + """Port VNC réel d'un domaine, localement ou sur un hyperviseur distant. + + Il ne se devine pas : libvirt l'attribue au démarrage. « virsh + vncdisplay » rend « 127.0.0.1:0 », où le suffixe est le NUMÉRO d'écran + — 0 vaut 5900, 1 vaut 5901. + + Sans sudo d'abord : l'appartenance au groupe libvirt suffit souvent, et + « sudo -n » distant échouerait sur l'absence de TTY. On ne retombe sur + « sudo -n » que si le premier essai n'a rien donné. + """ + base = ["virsh", "--connect", "qemu:///system", "vncdisplay", domain] + for argv in (base, ["sudo", "-n"] + base): + cmd = (["ssh", "-o", "BatchMode=yes", jump] + argv) if jump else argv + try: + res = subprocess.run( + cmd, capture_output=True, text=True, timeout=25 + ) + except (OSError, subprocess.SubprocessError): + continue + if res.returncode != 0: + continue + disp = res.stdout.strip().rsplit(":", 1) + if len(disp) == 2 and disp[1].isdigit(): + return 5900 + int(disp[1]) + return 0 + def _qemu_ssh_config_menu(self): """Écrit les entrées ~/.ssh/config du parc QEMU. From 9641a5b749dbd7e2219bdf422e9c4abc872efb37 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 13 Aug 2026 16:35:42 -0400 Subject: [PATCH 04/55] [ADD] qemu : Debian sur s390x, par debian-installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Debian ne publie aucune image cloud s390x — verifie sur le miroir : bookworm et trixie ne servent que amd64, arm64, ppc64el et riscv64. Le port existe pourtant, « binary-s390x » repond 200, et l'installateur livre kernel + initrd pour les deux versions. D'ou une seconde voie de deploiement, choisie par uses_installer() : disque VIERGE au lieu d'un qcow2 converti, amorcage kernel+initrd au lieu de --import, et un preseed a la place du seed cloud-init. Le preseed refait ce que fait cloud-init — nom d'hote, utilisateur, cles SSH, sudo, fuseau, paquets — sinon d-i pose la question sur une console que personne ne regarde. Le preseed voyage DANS l'initrd : pas de serveur HTTP a maintenir pendant l'installation, et rien qui depende du moment ou le reseau monte. Verifie contre l'initrd s390x reel : preseed.cfg relu a la racine des 966 entrees, 1807 octets identiques a l'ecriture. Les voies image cloud sont inchangees — amd64, arm64 et Ubuntu s390x gardent --import. --- EN --- Debian publishes no s390x cloud image — checked against the mirror: bookworm and trixie only serve amd64, arm64, ppc64el and riscv64. Yet the port exists, "binary-s390x" returns 200, and the installer ships kernel + initrd for both versions. Hence a second deployment path, picked by uses_installer(): a BLANK disk instead of a converted qcow2, kernel+initrd boot instead of --import, and a preseed in place of the cloud-init seed. The preseed redoes what cloud-init does — hostname, user, SSH keys, sudo, timezone, packages — otherwise d-i asks on a console nobody watches. The preseed travels INSIDE the initrd: no HTTP server to keep alive during the install, and nothing depending on when the network comes up. Verified against the real s390x initrd: preseed.cfg read back at the root of 966 entries, 1807 bytes identical to what was written. Cloud image paths are unchanged — amd64, arm64 and Ubuntu s390x keep --import. Assisted-by: Claude Opus 5 (cherry picked from commit d26ddee95bb5b3b3627950c33879155fb28bba58) --- script/qemu/deploy_qemu.py | 254 +++++++++++++++++++++++++++++++++++-- 1 file changed, 240 insertions(+), 14 deletions(-) diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 4f483de..57d5176 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -48,6 +48,7 @@ from __future__ import annotations import argparse import getpass import grp +import gzip import hashlib import os import re @@ -173,6 +174,13 @@ S390X_DISTROS: tuple[str, ...] = ( "rocky", "fedora", "opensuse", + # Debian n'a PAS d'image cloud s390x, et n'en aura pas par cette voie : + # vérifié sur cloud.debian.org, les arborescences bookworm et trixie ne + # publient que amd64, arm64, ppc64el et riscv64. Le port s390x existe + # pourtant — « binary-s390x » répond 200 — et debian-installer livre + # kernel + initrd pour les deux versions. On y passe donc par + # l'INSTALLATEUR au lieu d'un qcow2 tout fait : voir uses_installer(). + "debian", ) # Une distro peut ne publier qu'une PARTIE de ses versions sur une @@ -184,9 +192,29 @@ S390X_DISTROS: tuple[str, ...] = ( # (404 sur le miroir maître), la 44 n'est pour l'instant que sur certains # miroirs tiers. Seule la 43 est servie par dl.fedoraproject.org — vérifié. ARCH_ONLY_VERSIONS: dict[str, dict[str, tuple[str, ...]]] = { - "s390x": {"fedora": ("43",)}, + # Debian sur s390x passe par debian-installer, dont les images sont + # publiées pour bookworm et trixie — vérifié. bullseye est écartée : elle + # est en fin de vie et son installateur n'a pas été éprouvé ici. + "s390x": {"fedora": ("43",), "debian": ("12", "13")}, } +# Distros installées par debian-installer plutôt que depuis une image cloud. +# La différence n'est pas cosmétique : pas de qcow2 à convertir, pas de seed +# cloud-init, un disque VIERGE et un amorçage kernel+initrd. +INSTALLER_COMBOS: tuple[tuple[str, str], ...] = (("debian", "s390x"),) + +# kernel.debian / initrd.debian du port s390x. « current » suit les mises à +# jour de l'installateur sans figer un numéro qui périmerait. +INSTALLER_URL = ( + "https://deb.debian.org/debian/dists/{code}/main/installer-s390x" + "/current/images/generic/{fichier}" +) + + +def uses_installer(distro: str, arch: str) -> bool: + """Vrai si cette combinaison s'installe par debian-installer.""" + return (distro, arch) in INSTALLER_COMBOS + def arch_versions(distro: str, arch: str, versions) -> list[str]: """Versions de `distro` réellement publiées pour `arch`.""" @@ -1679,6 +1707,141 @@ def prepare_disk( runner.run(["qemu-img", "resize", str(disk), size], privileged=True) +def build_preseed( + args: argparse.Namespace, pw_hash: str | None, ssh_keys: list[str] +) -> str: + """Preseed debian-installer équivalent au cloud-config des autres distros. + + Il doit couvrir EXACTEMENT ce que cloud-init fait ailleurs : nom d'hôte, + utilisateur, clés SSH, sudo sans mot de passe, fuseau, paquets de base. + Tout ce qui manque ici devient une question posée à l'écran, et + l'installation s'arrête sur une console que personne ne regarde. + + « priority=critical » suffit à ne pas poser les questions restantes ; il + ne dispense PAS de répondre à celles qui n'ont pas de défaut, d'où le + partitionnement et le miroir écrits explicitement. + """ + user = args.user + # Sans mot de passe utilisable, d-i s'arrête sur la création du compte : + # « ! » est un hachage volontairement invalide — la connexion se fera par + # clé, comme le cloud-config le prévoit lui aussi. + crypted = pw_hash or "!" + lines = [ + "d-i debian-installer/locale string en_US.UTF-8", + "d-i keyboard-configuration/xkb-keymap select us", + # « auto » évite la question du choix d'interface : sous virtio-ccw + # elle s'appelle enc1 et non eth0, et le nom n'est pas devinable. + "d-i netcfg/choose_interface select auto", + f"d-i netcfg/get_hostname string {args.hostname}", + "d-i netcfg/get_domain string localdomain", + "d-i netcfg/hostname string " + args.hostname, + "d-i mirror/country string manual", + "d-i mirror/http/hostname string deb.debian.org", + "d-i mirror/http/directory string /debian", + "d-i mirror/http/proxy string", + "d-i passwd/root-login boolean false", + "d-i passwd/user-fullname string ERPLibre", + f"d-i passwd/username string {user}", + f"d-i passwd/user-password-crypted password {crypted}", + "d-i clock-setup/utc boolean true", + f"d-i time/zone string {args.timezone}", + "d-i clock-setup/ntp boolean true", + # Le disque est nommé : sur s390x virtio-ccw il n'y en a qu'un, mais + # d-i pose quand même la question quand rien ne le désigne. + "d-i partman-auto/disk string /dev/vda", + "d-i partman-auto/method string regular", + "d-i partman-auto/choose_recipe select atomic", + "d-i partman/default_filesystem string ext4", + "d-i partman-partitioning/confirm_write_new_label boolean true", + "d-i partman/choose_partition select finish", + "d-i partman/confirm boolean true", + "d-i partman/confirm_nooverwrite boolean true", + "tasksel tasksel/first multiselect ssh-server", + "d-i pkgsel/include string openssh-server sudo python3" + " qemu-guest-agent ca-certificates", + "d-i pkgsel/upgrade select none", + "popularity-contest popularity-contest/participate boolean false", + "d-i finish-install/reboot_in_progress note", + ] + # late_command : tout ce que le preseed ne sait pas exprimer. « in-target » + # exécute DANS le système installé ; les redirections, elles, restent dans + # l'installateur et doivent donc viser /target. + post = [ + f"in-target usermod -aG sudo {user}", + f"echo '{user} ALL=(ALL) NOPASSWD:ALL' > /target/etc/sudoers.d/{user}", + f"chmod 440 /target/etc/sudoers.d/{user}", + ] + if ssh_keys: + post.append(f"mkdir -p /target/home/{user}/.ssh") + for key in ssh_keys: + post.append( + f"echo '{key}' >> /target/home/{user}/.ssh/authorized_keys" + ) + post += [ + f"in-target chown -R {user}:{user} /home/{user}/.ssh", + f"chmod 700 /target/home/{user}/.ssh", + f"chmod 600 /target/home/{user}/.ssh/authorized_keys", + ] + lines.append("d-i preseed/late_command string " + " ; ".join(post)) + return "\n".join(lines) + "\n" + + +def build_installer_initrd( + preseed: str, initrd_src: Path, out: Path, runner: Runner +) -> None: + """Glisse le preseed DANS l'initrd de l'installateur. + + Servir le preseed en HTTP est l'autre voie documentée, mais elle ajoute un + serveur à faire vivre pendant toute l'installation et une dépendance à + l'ordre d'obtention de l'adresse. Embarquer le fichier ne dépend de rien : + d-i lit « /preseed.cfg » à la racine de l'initrd avant même le réseau. + + La méthode est celle de la documentation Debian — décompresser, ajouter le + fichier au cpio, recompresser — et non une concaténation d'archives, que + le noyau accepte mais que d-i ne parcourt pas de la même façon. + """ + if runner.dry_run: + print(f"[dry-run] preseed -> {out}") + return + if not shutil.which("cpio"): + sys.exit( + "cpio est requis pour embarquer le preseed dans l'initrd.\n" + " Debian/Ubuntu : sudo apt-get install cpio" + ) + with tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + (work / "preseed.cfg").write_text(preseed, encoding="utf-8") + plain = work / "initrd" + with gzip.open(initrd_src, "rb") as src, open(plain, "wb") as dst: + shutil.copyfileobj(src, dst) + subprocess.run( + ["cpio", "-H", "newc", "-o", "-A", "-F", str(plain)], + input="preseed.cfg\n", + text=True, + cwd=work, + check=True, + capture_output=True, + ) + out.parent.mkdir(parents=True, exist_ok=True) + with open(plain, "rb") as src, gzip.open(out, "wb") as dst: + shutil.copyfileobj(src, dst) + print(f" preseed embarqué dans {out} ({out.stat().st_size} octets)") + + +def create_blank_disk( + disk: Path, size: str, runner: Runner, force: bool +) -> None: + """Disque VIERGE : l'installateur écrit tout, il n'y a rien à convertir.""" + if disk.exists() and not force: + sys.exit( + f"Le disque {disk} existe déjà. Utilisez --force pour l'écraser." + ) + runner.run( + ["qemu-img", "create", "-f", "qcow2", str(disk), size], + privileged=True, + ) + + def network_name(network_arg: str) -> str | None: """Extrait NAME de « network=NAME,... » ; None si c'est un bridge, etc.""" for part in network_arg.split(","): @@ -1749,6 +1912,7 @@ def virt_install( seed: Path, osinfo: str, runner: Runner, + installer: tuple[Path, Path] | None = None, ) -> None: # Émulée (TCG, pas de KVM) si l'arch demandée diffère de celle de l'hôte. # Deux causes d'émulation, à ne pas confondre : une architecture étrangère @@ -1817,17 +1981,41 @@ def virt_install( str(args.memory), "--vcpus", str(args.vcpus), - "--import", + ] + if installer: + kernel, initrd = installer + # « --install » et non « --boot » : virt-install écrit alors DEUX + # configurations — celle de l'installation, transitoire, et celle du + # système installé. Avec « --boot kernel=… » la VM repartirait sur + # l'installateur à chaque démarrage, indéfiniment. + # + # console=ttysclp0 : s390x n'a pas de port série ISA. Sans cet + # argument l'installateur tourne sur une console invisible, et un + # échec ne laisse aucune trace lisible. + cmd += [ + "--install", + f"kernel={kernel},initrd={initrd}," + "kernel_args=auto=true priority=critical " + "preseed/file=/preseed.cfg console=ttysclp0", + ] + else: + cmd.append("--import") + cmd += [ "--disk", f"path={disk},format=qcow2,bus=virtio", + ] + # Le seed n'existe QUE sur la voie image cloud. Sous debian-installer, le + # preseed voyage dans l'initrd et un second disque ne ferait qu'ajouter un + # /dev/vdb dont partman-auto devrait être protégé. + if not installer: # Seed cloud-init attaché comme DISQUE virtio en lecture seule (et non # en CD-ROM) : le pilote virtio-blk est dans l'initramfs, donc le # volume « cidata » est visible dès init-local et cloud-init le lit. # En CD-ROM, l'initramfs Debian ne charge pas sr_mod à temps -> le # seed n'est pas vu et rien ne s'applique (Ubuntu, lui, tolère le CD). # Sur s390x, bus=virtio est mappé en virtio-ccw par libvirt. - "--disk", - f"path={seed},readonly=on,bus=virtio", + cmd += ["--disk", f"path={seed},readonly=on,bus=virtio"] + cmd += [ "--osinfo", osinfo, "--network", @@ -1881,6 +2069,13 @@ def virt_install( cmd += ["--virt-type", "qemu"] if not args.attach_console: cmd.append("--noautoconsole") + if installer: + # Sans « --wait 0 », virt-install RESTE au premier plan jusqu'à la + # fin de l'installation. Sous émulation s390x elle se compte en + # heures, et le déploiement parallèle attendrait chaque VM l'une + # après l'autre. La configuration finale est déjà écrite : rendre + # la main n'abandonne rien. + cmd += ["--wait", "0"] # virtinst écrit un journal de debug dans ~/.cache/virt-manager ; sous # sudo, HOME/cache peut être inaccessible -> l'écriture échoue et Python # déverse un « Logging error » (le pavé « Fetched capabilities … »). On @@ -2382,22 +2577,53 @@ def main() -> None: "nettement plus lents que l'architecture native." ) - print(f"\n== 1/5 Image cloud ({args.distro} {args.version} / {code}) ==") - download_image(urls, args.image_path, args.dry_run) - if do_verify: - verify_sha256(url, args.image_path, args.dry_run) + installer: tuple[Path, Path] | None = None + if uses_installer(args.distro, args.arch): + # Voie debian-installer : aucune image cloud n'existe pour cette + # combinaison, on télécharge l'installateur et on part d'un disque nu. + cache = args.image_path.parent + kernel = cache / f"debian-{args.version}-s390x-kernel" + initrd_src = cache / f"debian-{args.version}-s390x-initrd.gz" + initrd = cache / f"{args.name}-initrd.gz" + print(f"\n== 1/5 Installateur Debian {args.version} ({code}) s390x ==") + download_image( + [INSTALLER_URL.format(code=code, fichier="kernel.debian")], + kernel, + args.dry_run, + ) + download_image( + [INSTALLER_URL.format(code=code, fichier="initrd.debian")], + initrd_src, + args.dry_run, + ) - print(f"\n== 2-3/5 Disque de travail {disk} ({args.disk_size}) ==") - prepare_disk(args.image_path, disk, args.disk_size, runner, args.force) + print(f"\n== 2-3/5 Disque vierge {disk} ({args.disk_size}) ==") + create_blank_disk(disk, args.disk_size, runner, args.force) - print(f"\n== 4/5 Seed cloud-init {seed} ==") - cloud_cfg = build_cloud_config(args, pw_hash, ssh_keys) - build_seed(cloud_cfg, args.hostname, seed, runner) + print(f"\n== 4/5 Preseed embarqué dans l'initrd ==") + build_installer_initrd( + build_preseed(args, pw_hash, ssh_keys), initrd_src, initrd, runner + ) + installer = (kernel, initrd) + else: + print( + f"\n== 1/5 Image cloud ({args.distro} {args.version} / {code}) ==" + ) + download_image(urls, args.image_path, args.dry_run) + if do_verify: + verify_sha256(url, args.image_path, args.dry_run) + + print(f"\n== 2-3/5 Disque de travail {disk} ({args.disk_size}) ==") + prepare_disk(args.image_path, disk, args.disk_size, runner, args.force) + + print(f"\n== 4/5 Seed cloud-init {seed} ==") + cloud_cfg = build_cloud_config(args, pw_hash, ssh_keys) + build_seed(cloud_cfg, args.hostname, seed, runner) resolved_osinfo = osinfo_arg(osinfo, args.distro) print(f"\n== 5/5 virt-install (--osinfo {resolved_osinfo}) ==") ensure_network(network_name(args.network), runner) - virt_install(args, disk, seed, resolved_osinfo, runner) + virt_install(args, disk, seed, resolved_osinfo, runner, installer) has_key = bool(ssh_keys) print("\nTerminé. Suivi :") From 4df994f721324d3c8e0af28ea4bb2de9b1dd0564 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 13 Aug 2026 16:44:27 -0400 Subject: [PATCH 05/55] [FIX] todo qemu : lire les distros par arch dans deploy_qemu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'ecran de deploiement portait sa PROPRE copie de S390X_DISTROS, sous un commentaire promettant qu'elle restait « coherente avec deploy_qemu.py ». Une copie ne tient aucune promesse : Debian a gagne s390x la-bas et l'ecran ne le proposait toujours pas. _qemu_arch_distros lit desormais ARCH_DISTRO_SUPPORT, et _qemu_arches_for y passe aussi pour que « toutes les architectures » n'offre rien que deploy_qemu refuserait ensuite. Les tuples locaux restent en repli si l'import echoue. L'import est memorise : le catalogue l'interrogeait une fois par couple (distro, version). Plancher memoire de 2048 Mio sur la voie installateur, annonce et jamais abaissant : d-i deplie un systeme de fichiers en RAM la ou une image cloud arrive installee, et le manque s'y voit comme un ecran fige. --- EN --- The deploy screen carried its OWN copy of S390X_DISTROS, under a comment promising it stayed "consistent with deploy_qemu.py". A copy keeps no promise: Debian gained s390x over there and the screen still did not offer it. _qemu_arch_distros now reads ARCH_DISTRO_SUPPORT, and _qemu_arches_for goes through it too, so "all architectures" offers nothing deploy_qemu would later refuse. The local tuples remain as a fallback if the import fails. The import is memoised: the catalogue queried it once per (distro, version) pair. A 2048 MiB memory floor on the installer path, announced and never lowering: d-i unpacks a filesystem into RAM where a cloud image arrives installed, and running short shows up as a frozen screen. Assisted-by: Claude Opus 5 (cherry picked from commit f0ee70c1ac43ba2bc9f5fb78fe71f821a15daf7d) --- script/qemu/deploy_qemu.py | 17 ++++++++++++ script/todo/todo.py | 54 +++++++++++++++++++++++++++++--------- 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 57d5176..29a61ee 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -203,6 +203,10 @@ ARCH_ONLY_VERSIONS: dict[str, dict[str, tuple[str, ...]]] = { # cloud-init, un disque VIERGE et un amorçage kernel+initrd. INSTALLER_COMBOS: tuple[tuple[str, str], ...] = (("debian", "s390x"),) +# Plancher mémoire de l'installateur : il déplie un système de fichiers entier +# en RAM, là où une image cloud arrive déjà installée. +INSTALLER_MIN_RAM = 2048 + # kernel.debian / initrd.debian du port s390x. « current » suit les mises à # jour de l'installateur sans figer un numéro qui périmerait. INSTALLER_URL = ( @@ -2585,6 +2589,19 @@ def main() -> None: kernel = cache / f"debian-{args.version}-s390x-kernel" initrd_src = cache / f"debian-{args.version}-s390x-initrd.gz" initrd = cache / f"{args.name}-initrd.gz" + # Le dimensionnement du catalogue vient des images cloud, où le + # système est DÉJÀ installé. debian-installer, lui, déplie un système + # de fichiers complet en mémoire avant d'écrire quoi que ce soit : + # 1024 Mio est le plancher annoncé par Debian, sans marge, et un + # manque de mémoire s'y manifeste par un écran figé sans message. + # On relève le plancher, en le disant — un réglage explicite plus haut + # n'est jamais abaissé. + if args.memory < INSTALLER_MIN_RAM: + print( + f" Mémoire portée à {INSTALLER_MIN_RAM} Mio pour" + f" l'installateur (catalogue : {args.memory})." + ) + args.memory = INSTALLER_MIN_RAM print(f"\n== 1/5 Installateur Debian {args.version} ({code}) s390x ==") download_image( [INSTALLER_URL.format(code=code, fichier="kernel.debian")], diff --git a/script/todo/todo.py b/script/todo/todo.py index 9b6ec2a..4a3b38f 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1087,14 +1087,19 @@ class TODO: print(f"{t('Invalid selection, using')} {default}") return default - # Distros publiant des images cloud par architecture (cohérent avec - # S390X_DISTROS / ARM64_DISTROS de deploy_qemu.py). amd64 : toutes. + # Repli SEULEMENT : la table qui fait autorité est ARCH_DISTRO_SUPPORT de + # deploy_qemu.py, lue par _qemu_arch_distros. Ces tuples ont longtemps été + # une copie à la main, avec le commentaire « cohérent avec deploy_qemu » en + # guise de garantie — et la cohérence a rompu à la première évolution : + # Debian a gagné s390x là-bas sans l'obtenir ici, donc l'écran ne le + # proposait pas. On ne les garde que pour le cas où l'import échoue. _QEMU_S390X_DISTROS = ( "ubuntu", "almalinux", "rocky", "fedora", "opensuse", + "debian", ) _QEMU_ARM64_DISTROS = ( "ubuntu", @@ -1124,12 +1129,24 @@ class TODO: }.get(machine, "amd64") def _qemu_arch_distros(self, arch): - """Distros supportant `arch` (None = toutes, cas amd64).""" - if arch == "s390x": - return self._QEMU_S390X_DISTROS - if arch == "arm64": - return self._QEMU_ARM64_DISTROS - return None + """Distros supportant `arch` (None = toutes, cas amd64). + + Lu dans deploy_qemu.py, qui refuse aussi les combinaisons qu'il + n'annonce pas : une seule table, donc aucun écran ne peut proposer un + choix rejeté ensuite. « amd64 » n'y figure pas et rend None, ce qui + veut bien dire « toutes » — c'est le contrat attendu ici. + """ + try: + table = getattr(self._qemu_import_module(), "ARCH_DISTRO_SUPPORT") + except Exception: + # Repli sur les copies locales : mieux vaut un catalogue figé + # qu'un écran vide si deploy_qemu.py est absent ou cassé. + if arch == "s390x": + return self._QEMU_S390X_DISTROS + if arch == "arm64": + return self._QEMU_ARM64_DISTROS + return None + return table.get(arch) def _qemu_last_run_line(self): """Ligne « dernière install » (distro version [arch] en durée), depuis @@ -4007,13 +4024,22 @@ class TODO: ERPLIBRE_GIT_URL = "https://github.com/erplibre/erplibre" def _qemu_import_module(self): - """Importe deploy_qemu.py comme module (source de vérité des specs).""" + """Importe deploy_qemu.py comme module (source de vérité des specs). + + Mémorisé : le catalogue interroge cette source une fois par couple + (distro, version), et réexécuter un fichier de 2 700 lignes à chaque + passage se voyait à l'écran. + """ + cached = getattr(self, "_qemu_mod_cache", None) + if cached is not None: + return cached import importlib.util path = self._qemu_script_path() spec = importlib.util.spec_from_file_location("deploy_qemu", path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) + self._qemu_mod_cache = mod return mod @classmethod @@ -5844,11 +5870,13 @@ class TODO: « all » = uniquement celles que la distro publie réellement.""" if arch != "all": return [arch] + # Même source que _qemu_arch_distros : « all » ne doit jamais offrir + # une combinaison que deploy_qemu.py refusera. out = ["amd64"] - if distro in self._QEMU_ARM64_DISTROS: - out.append("arm64") - if distro in self._QEMU_S390X_DISTROS: - out.append("s390x") + for a in ("arm64", "s390x"): + supported = self._qemu_arch_distros(a) + if supported and distro in supported: + out.append(a) return out def _qemu_catalog_entries(self, mod, distros, arch): From 12db87e1c2a55a499371188f0fca85b8e178b468 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 13 Aug 2026 17:08:25 -0400 Subject: [PATCH 06/55] [FIX] qemu debian s390x : journal de console et question reseau Z MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « L'installation a echoue, pas de sortie pertinente » : une console pty ne garde RIEN. Quand d-i s'arrete, il l'ecrit a l'ecran d'une VM que personne ne regarde, et il ne reste rien a lire. La voie installateur ecrit desormais un journal qui survit a l'arret du domaine. Ce journal a immediatement nomme la premiere cause : d-i s'arretait sur « Configure the network device », une question propre a s390x posee par le udeb s390-netdevice — ctc, qeth, iucv ou virtio. Elle n'a AUCUNE valeur par defaut, donc priority=critical ne la saute pas. Preseedee a virtio, elle disparait. Reste un second arret, non resolu : netcfg n'essaie aucun DHCP et tombe sur la saisie d'une adresse statique. Ni le delai de lien, ni le nom explicite de l'interface n'y changent quoi que ce soit. --- EN --- "The install failed, no relevant output": a pty console keeps NOTHING. When d-i stops, it says so on the screen of a VM nobody watches, and nothing is left to read. The installer path now writes a log that outlives the domain. That log immediately named the first cause: d-i stopped on "Configure the network device", an s390x-only question from the s390-netdevice udeb — ctc, qeth, iucv or virtio. It has NO default, so priority=critical does not skip it. Preseeded to virtio, it is gone. A second stop remains, unsolved: netcfg attempts no DHCP and falls to static address entry. Neither the link timeout nor naming the interface explicitly changes anything. Assisted-by: Claude Opus 5 (cherry picked from commit d1a371a8f4775e8488522fba24a79775a5a101e8) --- script/qemu/deploy_qemu.py | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 29a61ee..b012665 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -1733,9 +1733,32 @@ def build_preseed( lines = [ "d-i debian-installer/locale string en_US.UTF-8", "d-i keyboard-configuration/xkb-keymap select us", + # Question propre à s390x, posée par le udeb « s390-netdevice » et + # inexistante ailleurs : le matériel Z offre ctc, qeth, iucv ou + # virtio, et d-i ne devine pas. Elle n'a AUCUNE valeur par défaut, + # donc « priority=critical » ne la saute pas — l'installation se + # figeait dessus, sur le premier choix de la liste (ctc), en + # n'affichant rien d'autre qu'un écran bleu. Mesuré. + "d-i s390-netdevice/choose_networktype select virtio", # « auto » évite la question du choix d'interface : sous virtio-ccw # elle s'appelle enc1 et non eth0, et le nom n'est pas devinable. - "d-i netcfg/choose_interface select auto", + # « enc1 » et non « auto ». Sur s390x la liste d'interfaces est bâtie + # par le udeb s390-netdevice, et « auto » ne s'y résout pas : netcfg + # n'essaie alors AUCUN DHCP — aucun bail côté dnsmasq — et tombe + # droit sur la saisie d'une adresse statique. Mesuré deux fois. + # Le nom est déterministe sous s390-ccw-virtio : le premier NIC + # virtio-ccw est enc1, comme le journal du noyau le montre + # (« virtio_net virtio2 enc1: renamed from eth0 »). + "d-i netcfg/choose_interface select enc1", + # Émulation TCG : le lien met bien plus longtemps à monter qu'en + # natif — « enc1 » n'est renommé qu'à 4,7 s de temps invité, et + # netcfg sonde avant. Sans lien détecté il n'ESSAIE MÊME PAS le DHCP + # et tombe droit sur la saisie d'une adresse statique. Mesuré : zéro + # DHCPDISCOVER côté dnsmasq, aucun bail. On lui laisse le temps, et + # un échec repart en autoconfiguration au lieu de poser la question. + "d-i netcfg/link_wait_timeout string 60", + "d-i netcfg/dhcp_timeout string 120", + "d-i netcfg/dhcp_options select Retry network autoconfiguration", f"d-i netcfg/get_hostname string {args.hostname}", "d-i netcfg/get_domain string localdomain", "d-i netcfg/hostname string " + args.hostname, @@ -1941,6 +1964,7 @@ def virt_install( # s390x n'a pas de port série ISA : la console est SCLP (ttysclp0), et non # ttyS0. Ailleurs (x86/arm64), console série classique. console_target = "sclp" if args.arch == "s390x" else "serial" + console_log = f"/var/log/libvirt/qemu/{args.name}-console.log" # Écran virtuel pour une VM graphique. s390x en est écarté : QEMU y expose # bien « virtio-gpu-ccw », mais rien ne garantit que le noyau s390x de la # distribution embarque le pilote DRM virtio-gpu — la VM démarrerait alors @@ -2027,7 +2051,14 @@ def virt_install( "--graphics", graphics, "--console", - f"pty,target_type={console_target}", + # Journal de console pour la voie installateur. Une console « pty » + # seule ne gardE rien : quand d-i échoue, il l'écrit à l'écran d'une + # VM que personne ne regarde, et il ne reste RIEN à lire ensuite — + # exactement « l'installation a échoué, pas de sortie pertinente ». + # Le fichier, lui, survit à l'arrêt du domaine. + f"pty,target_type={console_target},log.file={console_log}" + if installer + else f"pty,target_type={console_target}", # Canal virtio de l'agent invité (org.qemu.guest_agent.0) : permet à # virsh de piloter la VM SANS réseau (ex. étendre le FS invité après # un redimensionnement de disque). Inoffensif si l'agent est absent. From a33684ddd18ae6043ab96da5f10905b955df2b41 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 13 Aug 2026 18:32:48 -0400 Subject: [PATCH 07/55] [FIX] qemu debian s390x : retirer auto=true, inutile en preseed local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « auto=true » vise le preseed par URL : il reordonne l'installation pour monter le reseau avant tout le reste, afin d'aller chercher le fichier. Le notre est embarque dans l'initrd, il n'y a rien a telecharger. Le garder faisait donc courir netcfg tres tot pour rien. Cela n'a pas suffi a debloquer l'installation — le blocage sur l'adressage statique demeure — mais l'argument n'avait aucune raison d'etre la. --- EN --- "auto=true" targets URL preseeding: it reorders the install to bring the network up before anything else, so the file can be fetched. Ours is embedded in the initrd; there is nothing to download. Keeping it ran netcfg very early for no reason. This did not unblock the install — the static-addressing stop remains — but the argument had no business being there. Assisted-by: Claude Opus 5 (cherry picked from commit b805d352e0c3505cdb2522e1023c70e0dc506f15) --- script/qemu/deploy_qemu.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index b012665..067b4bd 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -2023,7 +2023,11 @@ def virt_install( cmd += [ "--install", f"kernel={kernel},initrd={initrd}," - "kernel_args=auto=true priority=critical " + # PAS de « auto=true » : il vise le preseed par URL et réordonne + # l'installation pour monter le réseau AVANT tout le reste. Le + # nôtre est local, il n'y a rien à aller chercher — et c'est + # précisément là que netcfg dérapait. + "kernel_args=priority=critical " "preseed/file=/preseed.cfg console=ttysclp0", ] else: From ec8a44b9ae64f03d0aee362e54a02d5255ce17c7 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 13 Aug 2026 19:35:17 -0400 Subject: [PATCH 08/55] [ADD] qemu debian s390x : activer enc1 et tracer avant netcfg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le blocage sur l'adressage statique n'etait pas diagnosticable : netcfg ecrit ses traces dans le syslog INTERNE de d-i, qu'on ne lit qu'en ouvrant un shell a la main sur une console serie. Un preseed/early_command ecrit desormais l'etat du reseau sur la console, donc dans le journal. Il a immediatement montre que l'interface etait ETEINTE — « enc1: qdisc noop » — alors qu'un udhcpc manuel obtenait un bail en deux secondes. Le reseau n'a jamais ete en cause. La commande allume donc enc1 avant que netcfg ne decide. L'interface est bien UP,LOWER_UP ensuite, ce qui est correct en soi — mais netcfg demande TOUJOURS une adresse statique. La cause reste a trouver. --- EN --- The static-addressing stop was not diagnosable: netcfg writes its traces to d-i's INTERNAL syslog, readable only by opening a shell by hand on a serial console. A preseed/early_command now writes the network state to the console, hence to the log. It immediately showed the interface was DOWN — "enc1: qdisc noop" — while a manual udhcpc obtained a lease in two seconds. The network was never at fault. So the command brings enc1 up before netcfg decides. The interface is UP,LOWER_UP afterwards, which is right in itself — but netcfg STILL asks for a static address. The cause remains to be found. Assisted-by: Claude Opus 5 (cherry picked from commit e594fe5eab87d5060b878e44f9cf484e6d7b9e7f) --- script/qemu/deploy_qemu.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 067b4bd..bd5e139 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -1809,6 +1809,31 @@ def build_preseed( f"chmod 700 /target/home/{user}/.ssh", f"chmod 600 /target/home/{user}/.ssh/authorized_keys", ] + # Diagnostic réseau, écrit sur la console AVANT que netcfg ne décide. + # netcfg n'essaie aucun DHCP sur s390x et tombe droit sur l'adressage + # statique ; ses propres traces vont dans le syslog INTERNE de d-i, qu'on + # ne peut lire qu'en ouvrant un shell à la main. Ces quelques lignes + # atterrissent, elles, dans le journal de console — donc dans un fichier + # qu'il suffit de lire après coup. La sonde DHCP est celle de busybox, + # bornée à trois essais, et ne configure rien de durable. + early = [ + # LE correctif, pas un diagnostic : on ALLUME la carte. + # + # Mesuré dans l'installateur : « enc1: … + # qdisc noop » — ni UP ni LOWER_UP — alors qu'un udhcpc manuel + # obtenait un bail en deux secondes. Le réseau n'a jamais été en + # cause ; netcfg teste l'état du lien AVANT d'essayer, ne le voit + # pas, saute le DHCP et demande une adresse statique. + # + # Sur s390x c'est le udeb s390-netdevice qui active le périphérique. + # En preseedant sa question pour qu'il ne s'affiche plus, on + # court-circuite aussi cette activation. On la refait donc ici, avant + # que netcfg ne décide. + "ip link set enc1 up > /dev/console 2>&1", + "echo '=== EL: enc1 activee avant netcfg ===' > /dev/console", + "ip -o link show enc1 > /dev/console 2>&1", + ] + lines.append("d-i preseed/early_command string " + " ; ".join(early)) lines.append("d-i preseed/late_command string " + " ; ".join(post)) return "\n".join(lines) + "\n" From 56803b3807db8ab050f59f8b24e9a7cec58ec0bd Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 13 Aug 2026 20:41:20 -0400 Subject: [PATCH 09/55] [REF] qemu debian s390x : retirer les reglages netcfg sans effet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quatre lignes avaient ete empilees sur des hypotheses successives — link_wait_timeout, dhcp_timeout, dhcp_options, choose_interface. Aucune n'a jamais ete verifiee, et l'essai nu le montre : sans elles, le comportement est EXACTEMENT le meme. Elles restaient donc comme du bruit, en laissant croire que la question du reseau avait ete traitee. Le blocage sur l'adressage statique persiste, avec un lien UP,LOWER_UP et un DHCP qui repond en deux secondes a un udhcpc manuel. --- EN --- Four lines had been stacked on successive hypotheses — link_wait_timeout, dhcp_timeout, dhcp_options, choose_interface. None was ever verified, and the bare run shows it: without them the behaviour is EXACTLY the same. They remained as noise, suggesting the network question had been dealt with. The static-addressing stop persists, with a UP,LOWER_UP link and a DHCP server answering a manual udhcpc in two seconds. Assisted-by: Claude Opus 5 (cherry picked from commit f52bc66a3b70075831e1a7bfdd2dd7b2a352ad32) --- script/qemu/deploy_qemu.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index bd5e139..1eab9f4 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -1742,23 +1742,6 @@ def build_preseed( "d-i s390-netdevice/choose_networktype select virtio", # « auto » évite la question du choix d'interface : sous virtio-ccw # elle s'appelle enc1 et non eth0, et le nom n'est pas devinable. - # « enc1 » et non « auto ». Sur s390x la liste d'interfaces est bâtie - # par le udeb s390-netdevice, et « auto » ne s'y résout pas : netcfg - # n'essaie alors AUCUN DHCP — aucun bail côté dnsmasq — et tombe - # droit sur la saisie d'une adresse statique. Mesuré deux fois. - # Le nom est déterministe sous s390-ccw-virtio : le premier NIC - # virtio-ccw est enc1, comme le journal du noyau le montre - # (« virtio_net virtio2 enc1: renamed from eth0 »). - "d-i netcfg/choose_interface select enc1", - # Émulation TCG : le lien met bien plus longtemps à monter qu'en - # natif — « enc1 » n'est renommé qu'à 4,7 s de temps invité, et - # netcfg sonde avant. Sans lien détecté il n'ESSAIE MÊME PAS le DHCP - # et tombe droit sur la saisie d'une adresse statique. Mesuré : zéro - # DHCPDISCOVER côté dnsmasq, aucun bail. On lui laisse le temps, et - # un échec repart en autoconfiguration au lieu de poser la question. - "d-i netcfg/link_wait_timeout string 60", - "d-i netcfg/dhcp_timeout string 120", - "d-i netcfg/dhcp_options select Retry network autoconfiguration", f"d-i netcfg/get_hostname string {args.hostname}", "d-i netcfg/get_domain string localdomain", "d-i netcfg/hostname string " + args.hostname, From 08ebf99c7798796fbbedf8141eafec890689ac13 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 13 Aug 2026 21:41:25 -0400 Subject: [PATCH 10/55] [FIX] qemu debian s390x : adresse fixe, l'initrd ne sait pas faire de DHCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le syslog de d-i a tranche : « Menu item 'netcfg-static' selected », puis « Taking down interface enc1 ». netcfg-dhcp n'apparait JAMAIS — il n'est pas dans l'initrd s390x, et aucun variant netboot n'existe pour cette architecture (404 sur trixie comme sur bookworm). Aucun DHCP n'etait donc tente. Sept hypotheses ont cherche pourquoi il echouait ; il n'avait jamais lieu. C'est la convention IBM Z, ou le reseau se donne au parmfile. deploy_qemu choisit desormais une adresse libre en HAUT de la plage — dnsmasq attribue depuis le bas — et la preseede. Mesure : l'ecran d'adressage statique a disparu, l'installateur poursuit. --- EN --- d-i's syslog settled it: "Menu item 'netcfg-static' selected", then "Taking down interface enc1". netcfg-dhcp NEVER appears — it is not in the s390x initrd, and no netboot variant exists for this architecture (404 on trixie and bookworm alike). So no DHCP was ever attempted. Seven hypotheses looked for why it failed; it never happened. This is the IBM Z convention, where the network is given in the parmfile. deploy_qemu now picks a free address at the TOP of the range — dnsmasq allocates from the bottom — and preseeds it. Measured: the static addressing screen is gone, the installer moves on. Assisted-by: Claude Opus 5 (cherry picked from commit 8c630969a605ec191a65dc93896014ac9e3a1227) --- script/qemu/deploy_qemu.py | 84 +++++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 2 deletions(-) diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 1eab9f4..bdcf0cd 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -1711,8 +1711,63 @@ def prepare_disk( runner.run(["qemu-img", "resize", str(disk), size], privileged=True) +def static_net_plan(net: str | None, use_sudo: bool) -> dict[str, str] | None: + """Adresse fixe libre pour une VM installée par debian-installer. + + L'initrd s390x ne contient QUE « netcfg-static » : le journal de d-i + montre « Menu item 'netcfg-static' selected », jamais netcfg-dhcp, puis + « Taking down interface enc1 ». Aucun DHCP n'est tenté — c'est la + convention IBM Z, où la configuration réseau se donne au parmfile. Il + faut donc fournir une adresse, et elle doit être libre. + + On la prend en HAUT de la plage : dnsmasq attribue depuis le bas, donc + les collisions avec un bail futur sont les plus improbables là. + """ + if not net: + return None + cmd = ["virsh", "-c", LIBVIRT_URI, "net-dumpxml", net] + if use_sudo: + cmd.insert(0, "sudo") + try: + xml = subprocess.run( + cmd, capture_output=True, text=True, timeout=20 + ).stdout + except (OSError, subprocess.SubprocessError): + return None + m = re.search(r" str: """Preseed debian-installer équivalent au cloud-config des autres distros. @@ -1743,6 +1798,21 @@ def build_preseed( # « auto » évite la question du choix d'interface : sous virtio-ccw # elle s'appelle enc1 et non eth0, et le nom n'est pas devinable. f"d-i netcfg/get_hostname string {args.hostname}", + # Adresse fixe : sans elle, netcfg-static pose la question a l'ecran + # et l'installation s'arrete la, indefiniment. + *( + [ + "d-i netcfg/disable_autoconfig boolean true", + "d-i netcfg/disable_dhcp boolean true", + f"d-i netcfg/get_ipaddress string {static['ip']}", + f"d-i netcfg/get_netmask string {static['netmask']}", + f"d-i netcfg/get_gateway string {static['gateway']}", + f"d-i netcfg/get_nameservers string {static['dns']}", + "d-i netcfg/confirm_static boolean true", + ] + if static + else [] + ), "d-i netcfg/get_domain string localdomain", "d-i netcfg/hostname string " + args.hostname, "d-i mirror/country string manual", @@ -2661,8 +2731,18 @@ def main() -> None: create_blank_disk(disk, args.disk_size, runner, args.force) print(f"\n== 4/5 Preseed embarqué dans l'initrd ==") + static = static_net_plan(network_name(args.network), not args.dry_run) + if static: + print(f" Adresse fixe retenue : {static['ip']}" + f" (passerelle {static['gateway']})") + else: + print(" ⚠ Aucune adresse fixe déterminée : netcfg-static posera" + " la question à l'écran et l'installation s'arrêtera.") build_installer_initrd( - build_preseed(args, pw_hash, ssh_keys), initrd_src, initrd, runner + build_preseed(args, pw_hash, ssh_keys, static), + initrd_src, + initrd, + runner, ) installer = (kernel, initrd) else: From 01daacba22d05f58ef56b9b7820ad1c7be8b3eb4 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 13 Aug 2026 22:06:28 -0400 Subject: [PATCH 11/55] [ADD] qemu debian s390x : repondre au mot de passe de network-console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sur IBM Z, d-i propose systematiquement de poursuivre par SSH — la console y est historiquement limitee. Il refuse un mot de passe vide et s'arretait sur « Empty password ». Preseede, le mot de passe passe. Mais l'ecran suivant montre que cela ne suffit PAS : network-console demarre sshd et attend une connexion de l'utilisateur « installer ». Il ne rend jamais la main. Le rendre non assiste demande de l'empecher de s'executer, pas de lui repondre. Le secret ne vit que le temps de l'installateur, sur le reseau libvirt, et disparait avec lui. --- EN --- On IBM Z, d-i always offers to continue over SSH — the console there is historically limited. It refuses an empty password and stopped on "Empty password". Preseeded, the password goes through. But the next screen shows this is NOT enough: network-console starts sshd and waits for the "installer" user to connect. It never returns. Making this unattended requires preventing it from running, not answering it. The secret lives only for the installer's lifetime, on the libvirt network, and vanishes with it. Assisted-by: Claude Opus 5 (cherry picked from commit 13f0f201678d2ef866fb2342ec16f958d44b0351) --- script/qemu/deploy_qemu.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index bdcf0cd..39055aa 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -1823,6 +1823,13 @@ def build_preseed( "d-i passwd/user-fullname string ERPLibre", f"d-i passwd/username string {user}", f"d-i passwd/user-password-crypted password {crypted}", + # network-console : sur IBM Z, d-i propose systématiquement de + # poursuivre par SSH — la console y est historiquement limitée. Il + # refuse un mot de passe vide et bloque l'installation non assistée. + # Ce secret ne vit QUE le temps de l'installateur, sur le réseau + # libvirt, et disparaît avec lui : il ne donne accès à rien ensuite. + "d-i network-console/password password erplibre", + "d-i network-console/password-again password erplibre", "d-i clock-setup/utc boolean true", f"d-i time/zone string {args.timezone}", "d-i clock-setup/ntp boolean true", From 788c17dbb5374b011ee1438a72ac2015835a0549 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 13 Aug 2026 23:11:10 -0400 Subject: [PATCH 12/55] [FIX] qemu debian s390x : adresse fixe propre a chaque VM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « Premiere libre en partant du haut » donnait la MEME adresse a deux VM deployees en parallele : ni l'une ni l'autre n'est montee quand l'autre cherche, donc aucune ne voit l'autre. Mesure — debian-12 et debian-13 ont tous deux pris .250 et se sont disputes l'adresse, une seule survivant. C'est ce qui faisait echouer le 12 quand le 13 passait. Le depart du balayage vient desormais du NOM de la VM, par crc32. Les noms different toujours, la collision disparait, et le tirage reste stable d'un redeploiement a l'autre. Le balayage garde ses garde-fous : baux existants et adresses qui repondent restent ecartes. Verifie : debian-12 -> .216, debian-13 -> .218. --- EN --- "First free from the top" gave the SAME address to two VMs deployed in parallel: neither is up when the other looks, so neither sees the other. Measured — debian-12 and debian-13 both took .250 and fought over it, only one surviving. That is what made 12 fail while 13 went through. The scan now starts from the VM NAME, via crc32. Names always differ, the collision is gone, and the draw stays stable across redeployments. The scan keeps its guards: existing leases and answering addresses are still skipped. Verified: debian-12 -> .216, debian-13 -> .218. Assisted-by: Claude Opus 5 (cherry picked from commit 91592383e213ad98873125866e6120e5da5874fe) --- script/qemu/deploy_qemu.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 39055aa..ccd2239 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -50,6 +50,7 @@ import getpass import grp import gzip import hashlib +import zlib import os import re import shutil @@ -1711,7 +1712,7 @@ def prepare_disk( runner.run(["qemu-img", "resize", str(disk), size], privileged=True) -def static_net_plan(net: str | None, use_sudo: bool) -> dict[str, str] | None: +def static_net_plan(net: str | None, use_sudo: bool, name: str) -> dict[str, str] | None: """Adresse fixe libre pour une VM installée par debian-installer. L'initrd s390x ne contient QUE « netcfg-static » : le journal de d-i @@ -1750,7 +1751,16 @@ def static_net_plan(net: str | None, use_sudo: bool) -> dict[str, str] | None: taken |= set(re.findall(r"(\d+\.\d+\.\d+\.\d+)/\d+", out)) except (OSError, subprocess.SubprocessError): pass - for last in range(250, 200, -1): + # Départ DÉTERMINISTE, tiré du nom de la VM. Un simple « première libre + # en partant du haut » donne la MÊME adresse à deux VM déployées en + # parallèle : aucune des deux n'est encore montée quand l'autre cherche, + # donc aucune ne voit l'autre. Vécu — debian-12 et debian-13 ont tous + # deux pris .250 et se sont disputé l'adresse, une seule survivant. + # Le nom, lui, diffère toujours, et le tirage reste stable d'un + # redéploiement à l'autre. + start = zlib.crc32(name.encode()) % 50 + for offset in range(50): + last = 200 + (start + offset) % 50 ip = f"{base}.{last}" if ip in taken or _ip_reachable(ip, port=22, timeout=0.4): continue @@ -2738,7 +2748,9 @@ def main() -> None: create_blank_disk(disk, args.disk_size, runner, args.force) print(f"\n== 4/5 Preseed embarqué dans l'initrd ==") - static = static_net_plan(network_name(args.network), not args.dry_run) + static = static_net_plan( + network_name(args.network), not args.dry_run, args.name + ) if static: print(f" Adresse fixe retenue : {static['ip']}" f" (passerelle {static['gateway']})") From 58da6cdacd6316a9375caa825ecb022c106924cc Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 13 Aug 2026 23:50:45 -0400 Subject: [PATCH 13/55] [FIX] qemu debian s390x : desactiver network-console, reclamer partman-auto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux blocages, deux mecanismes propres a IBM Z. network-console n'est pas une question a laquelle repondre : il demarre sshd et ATTEND une connexion, indefiniment. Preseeder son mot de passe ne fait avancer que d'un ecran. d-i prevoit le levier — un composant dont « .isinstallable » sort en erreur quitte le menu. On ajoute donc a l'initrd une version qui refuse toujours ; le fichier ecrit APRES l'original prend sa place, le noyau depliant le cpio sequentiellement. partman-auto n'est pas tire d'office sur s390x, ou la voie attendue est le partitionnement DASD manuel. Mesure : « /lib/partman/ automatically_partition/ No such file or directory », d'ou « No root file system is defined ». anna/choose_modules le reclame. Mesure apres correctif : recette atomic appliquee, aucun ecran bloquant, l'installateur deballe le systeme de base. --- EN --- Two stops, two IBM Z mechanisms. network-console is not a question to answer: it starts sshd and WAITS for a connection, forever. Preseeding its password only advances one screen. d-i provides the lever — a component whose ".isinstallable" exits non-zero leaves the menu. So the initrd gets a version that always refuses; the file written AFTER the original wins, since the kernel unpacks the cpio sequentially. partman-auto is not pulled by default on s390x, where manual DASD partitioning is the expected path. Measured: "/lib/partman/ automatically_partition/ No such file or directory", hence "No root file system is defined". anna/choose_modules asks for it. Measured after the fix: atomic recipe applied, no blocking screen, the installer unpacks the base system. Assisted-by: Claude Opus 5 (cherry picked from commit 1fb06ad84b65f43e9ffd1604b4e3d3a7aef9aaf2) --- script/qemu/deploy_qemu.py | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index ccd2239..7ceabb0 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -1845,6 +1845,23 @@ def build_preseed( "d-i clock-setup/ntp boolean true", # Le disque est nommé : sur s390x virtio-ccw il n'y en a qu'un, mais # d-i pose quand même la question quand rien ne le désigne. + # Ce que partman voit reellement, ecrit sur la console : « No root + # file system is defined » ne distingue pas « disque absent » de + # « recette non appliquee », et les deux se corrigent differemment. + # Toute commande preseedee DOIT rendre 0 : d-i bloque sur « Failed to + # run preseeded command » sinon, et le diagnostic devient le blocage. + # Vecu — un « ls /dev/dasd* » sans correspondance suffisait. + "d-i partman/early_command string cat /proc/partitions > /dev/console" + " ; ls /lib/partman/automatically_partition/ > /dev/console 2>&1" + " ; true", + # partman-auto RECLAME explicitement : il n'est pas tire d'office sur + # s390x, ou la voie attendue est le partitionnement DASD manuel. + # Mesure dans l'installateur : « /lib/partman/automatically_partition/ + # No such file or directory », et la liste des udebs recuperes montre + # partman-base, -utils, -partitioning, -target… mais jamais -auto. + # Sans lui, aucune recette ne s'applique et partman s'arrete sur + # « No root file system is defined ». + "d-i anna/choose_modules string partman-auto", "d-i partman-auto/disk string /dev/vda", "d-i partman-auto/method string regular", "d-i partman-auto/choose_recipe select atomic", @@ -1933,12 +1950,30 @@ def build_installer_initrd( with tempfile.TemporaryDirectory() as tmp: work = Path(tmp) (work / "preseed.cfg").write_text(preseed, encoding="utf-8") + # network-console DÉSACTIVÉ, par le levier que d-i prévoit pour cela. + # + # Sur IBM Z, d-i propose de poursuivre par SSH — la console y est + # historiquement limitée. Ce n'est pas une question à laquelle + # répondre : le composant démarre sshd puis ATTEND une connexion de + # l'utilisateur « installer », indéfiniment. Preseeder son mot de + # passe le fait avancer d'un écran, pas davantage — mesuré. + # + # Un composant dont « .isinstallable » sort en erreur est retiré du + # menu. L'original le fait déjà quand sshd tourne ; on le remplace par + # un refus inconditionnel. Le fichier ajouté APRÈS l'original prend sa + # place : le noyau déplie le cpio séquentiellement et le dernier + # écrit gagne. + gate = work / "var/lib/dpkg/info" + gate.mkdir(parents=True, exist_ok=True) + target = gate / "network-console.isinstallable" + target.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") + target.chmod(0o755) plain = work / "initrd" with gzip.open(initrd_src, "rb") as src, open(plain, "wb") as dst: shutil.copyfileobj(src, dst) subprocess.run( ["cpio", "-H", "newc", "-o", "-A", "-F", str(plain)], - input="preseed.cfg\n", + input="preseed.cfg\nvar/lib/dpkg/info/network-console.isinstallable\n", text=True, cwd=work, check=True, From b181ad56e8ef10bfafd84b22592662e7743e3761 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 14 Aug 2026 00:25:32 -0400 Subject: [PATCH 14/55] [FIX] qemu debian s390x : rallumer la VM quand l'installateur a fini MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « This domain is not running » apres une installation reussie. Le disque portait 1,22 Gio et le XML persistant amorcait deja sur « hd » : rien n'avait echoue, la VM etait simplement eteinte. virt-install mene l'installation en deux temps — amorcage transitoire sur kernel+initrd, puis configuration definitive sur disque — et le passage se fait par un ARRET. C'est virt-install qui rallume ensuite, sauf qu'avec « --wait 0 » il est deja parti. On ne peut pas le laisser attendre : sous emulation l'installation dure des heures, et le deploiement rendrait la main a ce rythme. Un veilleur detache fait donc le dernier geste, avec 6 h de garde. Mesure : domaine « shut off », veilleur lance, domaine « running ». Et la VM installee repond en SSH. --- EN --- "This domain is not running" after a successful install. The disk held 1.22 GiB and the persistent XML already booted from "hd": nothing had failed, the VM was simply powered off. virt-install runs the install in two stages — a transient kernel+initrd boot, then the final disk-boot config — and the handover goes through a SHUTDOWN. virt-install powers it back on afterwards, except that with "--wait 0" it is already gone. We cannot let it wait: under emulation the install takes hours, and deployment would return at that pace. So a detached watcher makes the last move, with a 6 h guard. Measured: domain "shut off", watcher started, domain "running". And the installed VM answers over SSH. Assisted-by: Claude Opus 5 (cherry picked from commit 9c6cdb7f14ca504fd9d0855ba0dacc3dbf43c04f) --- script/qemu/deploy_qemu.py | 50 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 7ceabb0..49da974 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -2266,6 +2266,54 @@ def virt_install( runner.run(log_env + cmd, privileged=True) +def watch_and_restart(name: str, runner: Runner) -> None: + """Rallume la VM quand debian-installer a fini et l'a éteinte. + + virt-install mène l'installation en DEUX temps : un amorçage transitoire + sur kernel+initrd, puis la configuration définitive, qui démarre sur le + disque. Le passage de l'un à l'autre se fait par un arrêt — l'installateur + redémarre, libvirt détruit le domaine transitoire — et c'est virt-install + qui rallume ensuite. Avec « --wait 0 » il est déjà parti : le domaine + reste « shut off », disque installé et XML correct, mais éteint. + + On ne peut pas pour autant laisser virt-install attendre : sous émulation + l'installation dure des heures, et le déploiement rendrait la main à ce + rythme-là. Un veilleur détaché fait donc le dernier geste. + """ + if runner.dry_run: + print(f"[dry-run] veilleur de redémarrage pour {name}") + return + sudo = "sudo " if runner.use_sudo else "" + # 6 h de garde : bien au-delà d'une installation émulée, et le veilleur + # meurt de lui-même si quelque chose a mal tourné. + script = ( + f"for i in $(seq 1 720); do " + f" s=$({sudo}virsh -c {LIBVIRT_URI} domstate {name} 2>/dev/null); " + f' if [ "$s" = "shut off" ]; then ' + f" {sudo}virsh -c {LIBVIRT_URI} start {name} >/dev/null 2>&1; " + f" exit 0; " + f" fi; " + f" sleep 30; " + f"done" + ) + try: + subprocess.Popen( + ["/bin/sh", "-c", script], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except (OSError, subprocess.SubprocessError) as exc: + print(f" ⚠ veilleur non lancé ({exc}) ; démarrer à la main :") + print(f" sudo virsh start {name}") + return + print( + f" Veilleur lancé : {name} sera rallumée dès que l'installateur" + " l'aura éteinte." + ) + + def _ip_reachable(ip: str, port: int = 22, timeout: float = 3) -> bool: """Vrai si le port SSH répond (distingue le bail actif du bail périmé).""" try: @@ -2818,6 +2866,8 @@ def main() -> None: print(f"\n== 5/5 virt-install (--osinfo {resolved_osinfo}) ==") ensure_network(network_name(args.network), runner) virt_install(args, disk, seed, resolved_osinfo, runner, installer) + if installer: + watch_and_restart(args.name, runner) has_key = bool(ssh_keys) print("\nTerminé. Suivi :") From c87cd2f5bd6e2a267a10f7b6ec27709e6265f80a Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 14 Aug 2026 00:46:22 -0400 Subject: [PATCH 15/55] [FIX] install : os-release au lieu de lsb_release, collision d'IP mieux vue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux echecs distincts sur les VM Debian s390x posees par l'installateur. lsb_release vient du paquet lsb-release, livre avec la tache « standard ». Les images cloud l'ont ; une Debian posee par debian-installer, non. Les trois variables devenaient VIDES et le script concluait « Your version of Ubuntu is not supported » sur une Debian. /etc/os-release appartient a systemd, il est toujours la, et donne ID, VERSION_ID et VERSION_CODENAME sans rien installer. L'autre echec etait pire : l'adresse fixe choisie appartenait deja a une machine du parc, et l'installation ERPLibre s'est deroulee SUR CETTE DERNIERE. Le journal ne le disait qu'a demi-mot — « git is already the newest version », impossible sur un systeme que d-i vient de poser. Un essai sur le port 22 avec 0,4 s laissait passer toute machine eteinte ou filtree. On interroge desormais le voisinage ARP, puis ICMP, puis SSH. --- EN --- Two distinct failures on Debian s390x VMs laid down by the installer. lsb_release comes from the lsb-release package, shipped with the "standard" task. Cloud images have it; a Debian installed by debian-installer does not. All three variables came out EMPTY and the script concluded "Your version of Ubuntu is not supported" on a Debian. /etc/os-release belongs to systemd, is always there, and gives ID, VERSION_ID and VERSION_CODENAME without installing anything. The other failure was worse: the chosen static address already belonged to a machine in the fleet, and the ERPLibre install ran ON THAT ONE. The log only hinted at it — "git is already the newest version", impossible on a system d-i just laid down. A port-22 probe with 0.4 s let through any machine that was off or filtered. We now check the ARP neighbourhood, then ICMP, then SSH. Assisted-by: Claude Opus 5 (cherry picked from commit c73db1642a676ece1a06cdbdac3e9cfa5b526f3c) --- script/install/install_debian_dependency.sh | 27 ++++++++++++-- script/qemu/deploy_qemu.py | 39 ++++++++++++++++++++- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/script/install/install_debian_dependency.sh b/script/install/install_debian_dependency.sh index 61e759b..ab95eeb 100755 --- a/script/install/install_debian_dependency.sh +++ b/script/install/install_debian_dependency.sh @@ -18,9 +18,30 @@ APT_GET="sudo apt-get -o DPkg::Lock::Timeout=600" ## in order to have correct version of wkhtmltopdf installed, for a danger note refer to ## https://github.com/odoo/odoo/wiki/Wkhtmltopdf ): # Ubuntu 20.04 -UBUNTU_VERSION=$(lsb_release -rs) -DEBIAN_VERSION=$(lsb_release -cs) -OS=$(lsb_release -si) +# /etc/os-release D'ABORD, lsb_release seulement en repli. +# +# « lsb_release » vient du paquet lsb-release, qui arrive avec la tâche +# « standard ». Les images cloud l'ont ; une Debian posée par +# debian-installer, non. Les trois variables devenaient alors VIDES, et le +# script concluait « Your version of Ubuntu is not supported » sur une Debian +# — vécu sur s390x, la seule architecture qui passe par l'installateur. +# +# /etc/os-release, lui, appartient à systemd et est toujours là. Il donne +# ID=debian, VERSION_ID=13 et VERSION_CODENAME=trixie sans rien installer. +if [[ -r /etc/os-release ]]; then + # Sous-shell : « source » importerait NAME, PRETTY_NAME et le reste dans + # un script qui n'en veut pas. + UBUNTU_VERSION=$(. /etc/os-release && echo "${VERSION_ID}") + DEBIAN_VERSION=$(. /etc/os-release && echo "${VERSION_CODENAME}") + OS=$(. /etc/os-release && echo "${ID}") + # lsb_release rend « Ubuntu » et « Debian » ; os-release rend « ubuntu » et + # « debian ». Les comparaisons plus bas attendent la première forme. + OS="${OS^}" +else + UBUNTU_VERSION=$(lsb_release -rs) + DEBIAN_VERSION=$(lsb_release -cs) + OS=$(lsb_release -si) +fi # Ubuntu 18.04, 20.04 et 22.04 ne sont plus supportées, sur AUCUNE # architecture. Le mur le plus net est pikepdf, qui réclame qpdf >= 12.2, diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 49da974..7f6b5ee 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -1712,6 +1712,43 @@ def prepare_disk( runner.run(["qemu-img", "resize", str(disk), size], privileged=True) +def _ip_taken(ip: str) -> bool: + """Adresse déjà occupée, même par une machine qui ne parle pas SSH. + + Un simple essai sur le port 22 ne suffit pas : il laisse passer toute + machine éteinte au moment du choix, ou dont sshd est filtré. Vécu — une + adresse attribuée à une VM Debian neuve appartenait déjà à une machine du + parc, et l'installation ERPLibre s'est déroulée SUR CETTE DERNIÈRE. Le + journal ne le disait qu'à demi-mot : « git is already the newest + version », impossible sur un système que d-i vient de poser. + + On interroge donc trois choses : le voisinage ARP de l'hôte, qui connaît + ce qui a parlé récemment ; ICMP, qui répond même sans service ; puis SSH. + """ + try: + neigh = subprocess.run( + ["ip", "neigh", "show", ip], + capture_output=True, + text=True, + timeout=5, + ).stdout + # « FAILED » signifie justement que personne n'a répondu. + if ip in neigh and "FAILED" not in neigh: + return True + except (OSError, subprocess.SubprocessError): + pass + try: + if subprocess.run( + ["ping", "-c", "1", "-W", "1", ip], + capture_output=True, + timeout=5, + ).returncode == 0: + return True + except (OSError, subprocess.SubprocessError): + pass + return _ip_reachable(ip, port=22, timeout=1.5) + + def static_net_plan(net: str | None, use_sudo: bool, name: str) -> dict[str, str] | None: """Adresse fixe libre pour une VM installée par debian-installer. @@ -1762,7 +1799,7 @@ def static_net_plan(net: str | None, use_sudo: bool, name: str) -> dict[str, str for offset in range(50): last = 200 + (start + offset) % 50 ip = f"{base}.{last}" - if ip in taken or _ip_reachable(ip, port=22, timeout=0.4): + if ip in taken or _ip_taken(ip): continue return { "ip": ip, From 57ab0b506fc1e401fb6ea405e2ecbd7c9f7b3d14 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 17 Aug 2026 22:57:40 -0400 Subject: [PATCH 16/55] [ADD] qemu: greet the SSH login with the distro's own commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalogue spans four package managers and the operator changes distribution at every deployment, yet nothing in the VM said which one it was. /etc/motd now carries the commands of the machine you just entered, and the host's git identity lands in ~/.gitconfig so a commit made there is not signed erplibre@. The seven cloud images were mounted read-only to settle the mechanism: sshd is PrintMotd no everywhere and pam_motd shows the file, so it appears once and never for ssh host 'command'. Checked: cloud-init schema reports Valid schema on the 8 catalogue combinations, the YAML round-trip is byte-identical, and the s390x initrd was rebuilt and unpacked. Its early_command gained the `true` guard it lacked: the last diagnostic returns 1 when enc1 is absent, stopping the installer it was meant to explain. --- FR --- Le catalogue couvre quatre gestionnaires de paquets et l'opérateur change de distribution à chaque déploiement, sans que rien dans la VM ne dise laquelle. /etc/motd porte maintenant les commandes de la machine où l'on vient d'entrer, et l'identité git de l'hôte atterrit dans ~/.gitconfig : un commit fait là ne porte plus erplibre@. Les sept images cloud ont été montées en lecture seule pour trancher le mécanisme : sshd est en PrintMotd no partout et c'est pam_motd qui affiche le fichier, donc un seul affichage, et jamais pour ssh hôte 'commande'. Vérifié : cloud-init schema rend Valid schema sur les 8 combinaisons du catalogue, l'aller-retour YAML est octet pour octet, et l'initrd s390x a été reconstruit puis déplié. Son early_command a reçu la garde `true` qui lui manquait : son dernier diagnostic rend 1 quand enc1 est absente, et arrêtait l'installateur qu'il devait éclairer. Assisted-by: Claude Opus 5 --- script/qemu/deploy_qemu.py | 637 ++++++++++++++++++++++++++++++++++++- test/test_qemu_motd.py | 356 +++++++++++++++++++++ 2 files changed, 991 insertions(+), 2 deletions(-) create mode 100644 test/test_qemu_motd.py diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 7f6b5ee..491fc47 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -46,6 +46,7 @@ Exemples from __future__ import annotations import argparse +import base64 import getpass import grp import gzip @@ -1482,6 +1483,557 @@ def user_groups(distro: str) -> str: return "users, wheel" +# --------------------------------------------------------------------------- # +# Guide de connexion (/etc/motd) et identité git de la VM +# --------------------------------------------------------------------------- # +# Le catalogue couvre quatre gestionnaires de paquets, et l'opérateur change de +# distribution d'un déploiement à l'autre. Le guide met SOUS LES YEUX, à la +# connexion, les commandes de la machine où l'on vient d'entrer : apt là où +# c'est apt, zypper là où c'est zypper. +# +# Le mécanisme est /etc/motd, et il est le même partout. Vérifié dans les images +# cloud elles-mêmes, montées en lecture seule : sshd y est en « PrintMotd no » +# et c'est pam_motd qui affiche le fichier. Trois conséquences tenues pour +# acquises ici : +# - il suffit d'ÉCRIRE /etc/motd. Ajouter « PrintMotd yes » afficherait le +# guide DEUX FOIS — sshd lit /etc/motd en dur, PAM le lit aussi ; +# - « ssh hôte 'commande' » ne l'affiche PAS (openssh coupe les deux chemins +# dès qu'une commande est passée), donc le suivi d'installation reste net. +# Un « ssh hôte < script » l'afficherait, lui : aucun n'est utilisé ici ; +# - openSUSE ajoute son « Have a lot of fun... » APRÈS le guide : il vient de +# /usr/lib/motd.d/welcome, que pam_motd lit après le fichier. On le laisse. +# +# Ubuntu n'a PAS de /etc/motd (son postinst base-files ne le crée pas, à la +# différence de Debian) : le fichier est donc créé, pas remplacé. Sur Debian il +# écrase les cinq lignes de base-files, ce qui ne fâche pas dpkg — /etc/motd n'y +# est ni un conffile ni même un fichier du paquet. + +# Étiquette lisible d'une distribution. openSUSE livre DEUX produits sous un +# seul nom de distro (Leap, numéroté ; Tumbleweed, rolling) : la version tranche. +DISTRO_LABELS: dict[str, str] = { + "ubuntu": "Ubuntu", + "debian": "Debian", + "fedora": "Fedora", + "almalinux": "AlmaLinux", + "rocky": "Rocky Linux", + "opensuse": "openSUSE", + "arch": "Arch Linux", +} + +# Gestionnaire de paquets de chaque distribution du catalogue. +DISTRO_PKG: dict[str, str] = { + "ubuntu": "apt", + "debian": "apt", + "fedora": "dnf", + "almalinux": "dnf", + "rocky": "dnf", + "opensuse": "zypper", + "arch": "pacman", +} + + +def distro_label(distro: str, version: str) -> str: + """« Ubuntu 24.04 », « openSUSE Leap 16.0 », « Arch Linux »…""" + name = DISTRO_LABELS.get(distro, distro) + if distro == "opensuse": + if version == "tumbleweed": + return f"{name} Tumbleweed" + return f"{name} Leap {version}" + if distro == "arch": + # Rolling release : « latest » n'apprend rien à personne. + return name + return f"{name} {version}" + + +# Aide-mémoire par gestionnaire de paquets : (commande, glose fr, glose en). +# Chaque ligne vient du manuel amont de l'outil, pas de mémoire, et doit +# fonctionner TELLE QUELLE — c'est un guide, pas une piste à vérifier. +# +# dnf : les formes écrites ici valent pour dnf4 (AlmaLinux/Rocky 9 ET 10, tous +# deux en dnf 4.x) comme pour dnf5 (Fedora 41+). Les raccourcis de dnf4 ont +# disparu de dnf5 : « dnf history » seul, « grouplist », « whatprovides », +# « list installed » sans tirets y échouent tous. Les formes longues passent +# partout, et ne coûtent rien. +PKG_GUIDE: dict[str, tuple[tuple[str, str, str], ...]] = { + "apt": ( + ("sudo apt update", "rafraîchir l'index", "refresh the index"), + ( + "sudo apt upgrade", + "mettre à jour le système", + "upgrade the system", + ), + ("sudo apt install ", "installer", "install"), + ("sudo apt remove ", "retirer", "remove"), + ("apt search ", "chercher", "search"), + ("apt show ", "détails d'un paquet", "package details"), + ("apt list --installed", "lister l'installé", "list installed"), + ("sudo apt autoremove", "purger les orphelins", "purge orphans"), + ), + "dnf": ( + ( + "sudo dnf upgrade", + "mettre à jour le système", + "upgrade the system", + ), + ("sudo dnf install ", "installer", "install"), + ("sudo dnf remove ", "retirer", "remove"), + ("dnf check-update", "mises à jour disponibles", "available updates"), + ("dnf search ", "chercher", "search"), + ("dnf info ", "détails d'un paquet", "package details"), + ("dnf list --installed", "lister l'installé", "list installed"), + ("dnf history list", "journal des opérations", "transaction log"), + ), + "pacman": ( + ( + "sudo pacman -Syu", + "mettre à jour le système", + "upgrade the system", + ), + # Jamais « -Sy » seul : la base de paquets serait à jour et le système + # non, donc une installation tirerait des binaires liés à des + # bibliothèques absentes. Arch ne supporte que la mise à jour complète, + # d'où la forme « -Syu » pour installer. + ( + "sudo pacman -Syu ", + "installer (jamais « -Sy » seul)", + "install (never a bare « -Sy »)", + ), + ("sudo pacman -Rns ", "retirer", "remove"), + ("pacman -Ss ", "chercher", "search"), + ("pacman -Si ", "détails d'un paquet", "package details"), + ("pacman -Q", "lister l'installé", "list installed"), + ("pacman -Qdtq", "orphelins", "orphans"), + ("sudo pacman -Sc", "nettoyer le cache", "clean the cache"), + ), +} + + +def zypper_guide(rolling: bool) -> tuple[tuple[str, str, str], ...]: + """Aide-mémoire zypper. `rolling` : Tumbleweed plutôt que Leap. + + La ligne de mise à jour n'est PAS la même, et ce n'est pas une préférence de + style : « up » sur Leap, dont la version est figée, et « dup » sur + Tumbleweed, où chaque mise à jour est un instantané complet de la + distribution. La doc amont est catégorique — « on Tumbleweed you will never + have to use zypper-up » — et « up » y laisse traîner des paquets retirés des + dépôts, donc des dépendances bancales. Le déploiement sait laquelle des deux + il installe : autant que le guide le sache aussi. + """ + upgrade = ( + ("sudo zypper dup", "mettre à jour (rolling)", "upgrade (rolling)") + if rolling + else ( + "sudo zypper up", + "mettre à jour le système", + "upgrade the system", + ) + ) + return ( + ("sudo zypper ref", "rafraîchir les dépôts", "refresh the repos"), + upgrade, + ("sudo zypper in ", "installer", "install"), + ("sudo zypper rm ", "retirer", "remove"), + ("zypper se ", "chercher", "search"), + ("zypper info ", "détails d'un paquet", "package details"), + ("zypper se -i", "lister l'installé", "list installed"), + ("zypper lu", "mises à jour disponibles", "available updates"), + ( + "sudo zypper ps -s", + "à redémarrer après MAJ", + "restart after upgrade", + ), + ) + + +# Lignes valables partout, quelle que soit la distribution. +SYSTEM_GUIDE: tuple[tuple[str, str, str], ...] = ( + ("hostname -I", "adresse IP de la VM", "the VM's IP address"), + ("df -h /", "espace disque", "disk space"), + ("free -h", "mémoire", "memory"), +) +# Ajoutées SEULEMENT quand il n'y a pas de section ERPLibre : celle-ci montre +# déjà les deux commandes, sur un service qui existe vraiment. Sur une VM +# déployée sans ERPLibre, elles manqueraient. +SERVICE_GUIDE: tuple[tuple[str, str, str], ...] = ( + ("systemctl status ", "état d'un service", "a service's state"), + ("journalctl -u -f", "suivre son journal", "follow its log"), +) + + +def erplibre_guide( + el_dir: str, el_make: str = "", editor: str = "" +) -> tuple[tuple[str, str, str], ...]: + """Commandes ERPLibre de la VM. + + `el_dir` : racine de l'installation (~/git/erplibre en développement, + /opt/erplibre en production). Toutes les autres lignes sont relatives à ce + répertoire, d'où le « cd » en tête. + + `el_make` : cible make qui a installé la VM, réutilisée pour la mettre à + jour. Vide, le guide s'arrête à « git pull » plutôt que d'annoncer une cible + qui n'est pas celle du profil retenu. + + `editor` : éditeur de l'hôte, quand il a pu être déterminé. Sans lui on + nomme le fichier de configuration sans nommer d'éditeur — « vi » n'est pas + garanti sur toutes les images cloud, et un guide qui propose une commande + absente est pire que muet. + """ + rows = [ + (f"cd {el_dir}", "aller au dépôt", "go to the checkout"), + ("make todo", "menu ERPLibre (TODO)", "ERPLibre menu (TODO)"), + ] + if editor: + rows.append( + ( + f"{editor} config.conf", + "éditer le serveur", + "edit the server", + ) + ) + else: + rows.append( + ( + "config.conf", + "configuration du serveur", + "the server's config", + ) + ) + rows += [ + ( + "sudo systemctl restart erplibre", + "redémarrer le serveur", + "restart the server", + ), + ("systemctl status erplibre", "état du serveur", "server state"), + ("journalctl -u erplibre -f", "suivre son journal", "follow its log"), + ("./run.sh -d ", "lancer à la main", "run it by hand"), + ( + "./script/addons/update_addons_all.sh ", + "mise à jour des modules", + "update the modules", + ), + ( + f"git pull && make {el_make}" if el_make else "git pull", + ( + "mise à jour ERPLibre/Odoo" + if el_make + else "mettre à jour le dépôt" + ), + "update ERPLibre/Odoo" if el_make else "update the checkout", + ), + ("http://:8069", "interface web", "web interface"), + ] + return tuple(rows) + + +# Plancher de largeur de l'encadré. Au-dessus, il SUIT le contenu : un cadre +# plus étroit que ce qu'il encadre serait pire qu'un cadre large. C'est au +# contenu de rester sous 80 colonnes — un guide qui se replie sur un terminal +# standard est illisible, et un test le vérifie pour les sept distributions. +MOTD_MIN_WIDTH = 62 + + +def _pick(pair: tuple[str, str], lang: str) -> str: + """Membre fr ou en d'un couple de libellés.""" + return pair[1] if lang == "en" else pair[0] + + +def gloss_col(*blocks: tuple[tuple[str, str, str], ...]) -> int: + """Colonne où commencent les gloses de ces blocs : la commande la plus + longue, plus deux espaces.""" + return max(len(cmd) for rows in blocks for cmd, _fr, _en in rows) + 2 + + +def motd_block( + title: str, rows: tuple[tuple[str, str, str], ...], lang: str, col: int +) -> list[str]: + """Un bloc du guide : un titre, puis « commande glose » alignées. + + `col` est donné plutôt que déduit du bloc : les blocs de commandes courtes + partagent une colonne commune, sans quoi le bloc « système » se tasserait à + treize caractères là où celui des paquets en occupe trente. Le bloc + ERPLibre, lui, garde la sienne — sa commande la plus longue fait + 43 caractères, et l'imposer au guide entier ferait déborder les lignes de + 80 colonnes. + """ + out = [f" {title}"] + for cmd, gloss_fr, gloss_en in rows: + out.append(f" {cmd.ljust(col)}{_pick((gloss_fr, gloss_en), lang)}") + return out + + +def build_motd( + distro: str, + version: str, + arch: str, + lang: str = "fr", + el_dir: str = "", + el_make: str = "", + editor: str = "", +) -> str: + """Texte du /etc/motd de la VM. Fonction PURE : aucun I/O, donc testable. + + La section ERPLibre n'apparaît qu'avec `el_dir` : une VM déployée sans + installation ne doit pas annoncer un dépôt et un service qui n'existent pas. + """ + body: list[str] = [] + mgr = DISTRO_PKG.get(distro, "") + if mgr == "zypper": + pkg_rows = zypper_guide(version == "tumbleweed") + else: + pkg_rows = PKG_GUIDE.get(mgr, ()) + sys_rows = SYSTEM_GUIDE if el_dir else SYSTEM_GUIDE + SERVICE_GUIDE + narrow = gloss_col(pkg_rows or sys_rows, sys_rows) + if pkg_rows: + body += motd_block( + f"{_pick(('Paquets', 'Packages'), lang)} — {mgr}", + pkg_rows, + lang, + narrow, + ) + if el_dir: + body.append("") + el_rows = erplibre_guide(el_dir, el_make, editor) + body += motd_block("ERPLibre", el_rows, lang, gloss_col(el_rows)) + body.append("") + body += motd_block( + _pick(("Système", "System"), lang), sys_rows, lang, narrow + ) + title = f"ERPLibre · {distro_label(distro, version)} · {arch}" + width = max( + MOTD_MIN_WIDTH, max([len(line) for line in body] + [len(title)]) + 4 + ) + head = [ + "╭" + "─" * (width - 2) + "╮", + "│ " + title.ljust(width - 4) + " │", + "╰" + "─" * (width - 2) + "╯", + ] + foot = [ + "", + " " + + _pick( + ( + "Guide écrit au déploiement par", + "Guide written at deploy time by", + ), + lang, + ) + + " script/qemu/deploy_qemu.py", + ] + return "\n".join(head + [""] + body + foot) + "\n" + + +def invoking_home() -> Path: + """Foyer de l'utilisateur qui a lancé le script, sudo compris. + + Le script tourne sous sudo : `Path.home()` y renvoie /root, où il n'y a + aucune configuration git à reprendre. + """ + try: + return Path(os.path.expanduser(f"~{invoking_user()}")) + except (KeyError, RuntimeError): + return Path.home() + + +def _git_global(key: str, home: Path) -> str: + """Valeur d'une clé de la configuration git GLOBALE de `home`. + + HOME est forcé plutôt que de lire ~/.gitconfig à la main : git accepte DEUX + emplacements pour sa configuration globale (~/.gitconfig et + ~/.config/git/config), et lui poser la question évite de trancher à sa place. + """ + try: + res = subprocess.run( + ["git", "config", "--global", "--get", key], + capture_output=True, + text=True, + timeout=5, + env=dict(os.environ, HOME=str(home)), + ) + except (OSError, subprocess.SubprocessError): + return "" + return res.stdout.strip() if res.returncode == 0 else "" + + +def host_editor(home: Path) -> str: + """Éditeur que l'hôte utilise, dans l'ordre où git le résout lui-même. + + core.editor, puis $VISUAL/$EDITOR, puis /usr/bin/editor — le lien des + alternatives Debian, qui est LA réponse à « quel éditeur ce système + utilise-t-il » quand rien n'est configuré. Ailleurs ce lien n'existe pas et + on ne devine pas : mieux vaut ne rien écrire que d'imposer un éditeur. + + GIT_EDITOR est volontairement IGNORÉ. Les outils qui appellent git sans + interaction le posent à « true » pour empêcher toute ouverture d'éditeur ; + le recopier dans la VM y désactiverait silencieusement l'éditeur de git. + """ + editor = _git_global("core.editor", home) + if not editor: + editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "" + if not editor: + try: + editor = Path("/usr/bin/editor").resolve(strict=True).name + except OSError: + editor = "" + return editor.strip() + + +def editor_binary(editor: str) -> str: + """Binaire seul d'une commande d'éditeur (« code --wait » -> « code »). + + Le guide affiche le binaire, pas la commande complète : les options de git + (attente de fermeture, fichier temporaire) n'ont pas de sens pour ouvrir un + fichier de configuration à la main. + """ + if not editor.strip(): + return "" + return editor.split()[0].rsplit("/", 1)[-1] + + +# Éditeurs que la VM sait se donner : binaire de l'hôte -> (paquet, binaire dans +# la VM). Le nom du paquet est le MÊME sur apt, dnf, zypper et pacman pour ces +# trois-là — vérifié pour chacun ; « vi » est fourni par vim, et le paquet +# neovim installe « nvim ». +# +# Cette table est la SEULE autorité, et elle décide de trois choses à la fois : +# le paquet que l'installation ajoute, la commande que le guide affiche, et la +# valeur de core.editor dans la VM. Les tenir liées est le point : un +# « core.editor = code » pointant un binaire absent fait échouer « git commit » +# (« cannot run code »), et un guide qui nomme une commande absente est pire que +# muet. Un éditeur hors de cette table est donc ignoré — pas deviné. +EDITOR_PACKAGES: dict[str, tuple[str, str]] = { + "vim": ("vim", "vim"), + "vi": ("vim", "vim"), + "nvim": ("neovim", "nvim"), + "neovim": ("neovim", "nvim"), + "nano": ("nano", "nano"), +} + + +def vm_editor(home: Path) -> tuple[str, str]: + """(paquet, binaire) de l'éditeur à donner à la VM, ou deux chaînes vides.""" + return EDITOR_PACKAGES.get(editor_binary(host_editor(home)), ("", "")) + + +def build_gitconfig(name: str, email: str, editor: str) -> str: + """~/.gitconfig de la VM. Chaîne vide si l'hôte n'a rien à transmettre. + + Une VM de développement sert à produire des commits, et un commit sans + identité est refusé par git (« Please tell me who you are ») : reprendre + celle de l'hôte évite de la retaper sur chaque machine, et surtout évite les + commits signés d'un « erplibre@ » que personne ne reconnaît. + + INDENTATION EN ESPACES, jamais en tabulation. git accepte les deux, mais ce + texte part dans un scalaire bloc YAML où une tabulation en tête de ligne est + une erreur FATALE : cloud-init rejette alors le user-data en entier et la VM + démarre sans utilisateur ni clé SSH, donc inaccessible. + """ + lines: list[str] = [] + if name or email: + lines.append("[user]") + if name: + lines.append(f" name = {name}") + if email: + lines.append(f" email = {email}") + if editor: + lines += ["[core]", f" editor = {editor}"] + return "\n".join(lines) + "\n" if lines else "" + + +def write_files_lines( + entries: list[tuple[str, str, str, str]], +) -> list[str]: + """Bloc « write_files » de cloud-init pour des fichiers TEXTE. + + entries : (chemin, mode, contenu, propriétaire) ; propriétaire vide = root. + + Deux règles YAML dont le non-respect coûte TOUTE la configuration — une + erreur de syntaxe fait rejeter le user-data en ENTIER, sans message sur la + console : la VM démarre nue, sans utilisateur ni clé SSH, inaccessible. + - Le mode est une CHAÎNE, entre guillemets. « permissions: 644 » non quoté + est lu comme 644 DÉCIMAL et appliqué tel quel, soit 0o1204 soit le bit + setuid allumé et des droits absurdes, sans le moindre avertissement. + - Le contenu est un scalaire bloc « | » indenté de six espaces, dont la + PREMIÈRE ligne non vide fixe l'indentation de référence : les suivantes + doivent être au moins aussi indentées. Les caractères d'encadrement + UTF-8 passent sans échappement. + + Un propriétaire impose « defer: true » : write_files tourne à l'étape init, + AVANT la création des utilisateurs, donc le chown vers le compte de la VM + échouerait. Reporté à l'étape finale, il passe — et le suivi d'installation + attend de toute façon la fin de cloud-init avant de se connecter. + """ + out = ["write_files:"] + for path, mode, content, owner in entries: + out.append(f" - path: {path}") + out.append(f" permissions: '{mode}'") + if owner: + out.append(f" owner: {owner}:{owner}") + out.append(" defer: true") + out.append(" content: |") + # textwrap.indent laisse les lignes vides VIDES : six espaces résiduels + # survivraient au scalaire bloc et se retrouveraient dans le fichier, + # invisibles en revue et bien présents à l'écran. + out += textwrap.indent(content.rstrip("\n"), " ").split("\n") + return out + + +# Préfixe des fichiers d'accueil embarqués dans l'initrd de l'installateur. +# Un préfixe, et non un répertoire : le cpio est déplié séquentiellement et les +# répertoires parents manquants ne sont pas créés — une entrée +# « erplibre/etc-motd » sans entrée « erplibre » ferait échouer le dépliage de +# l'initrd ENTIER, donc l'installation. Les fichiers restent à la racine. +INSTALLER_GUIDE_PREFIX = "erplibre-" + + +def installer_guide_name(path: str) -> str: + """Nom dans l'initrd du fichier destiné au chemin `path` de la VM. + + « /etc/motd » -> « erplibre-etc-motd ». Un nom PLAT, dérivé du chemin : les + deux fonctions qui s'en servent (le preseed qui copie, l'initrd qui range) + le calculent de la même façon, donc elles ne peuvent pas diverger. + """ + return INSTALLER_GUIDE_PREFIX + path.strip("/").replace("/", "-") + + +def guide_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]: + """Fichiers d'accueil de la VM : le guide de connexion, l'identité git. + + Une seule source pour les deux voies de déploiement — cloud-init l'écrit + par write_files, l'installateur Debian par son late_command. + """ + home = invoking_home() + editor = "" if args.no_git_identity else vm_editor(home)[1] + files = [ + ( + "/etc/motd", + "0644", + build_motd( + args.distro, + args.version, + args.arch, + args.lang, + args.erplibre_dir, + args.erplibre_make, + editor, + ), + "", + ) + ] + if args.no_git_identity: + return files + gitconfig = build_gitconfig( + _git_global("user.name", home), + _git_global("user.email", home), + editor, + ) + if gitconfig: + files.append( + (f"/home/{args.user}/.gitconfig", "0644", gitconfig, args.user) + ) + return files + + def build_cloud_config( args: argparse.Namespace, pw_hash: str | None, ssh_keys: list[str] ) -> str: @@ -1523,6 +2075,11 @@ def build_cloud_config( f" layout: {args.keyboard_layout}", f" variant: {args.keyboard_variant}", ] + # Guide de connexion et identité git : posés par cloud-init, donc présents + # dès le PREMIER boot. C'est le point : ils sont là avant l'installation + # d'ERPLibre, et encore là si elle échoue — le moment où l'on se connecte + # justement à la main. + lines += write_files_lines(guide_files(args)) # apt update/upgrade désactivés par défaut : sur un réseau lent/instable # ils font pendre cloud-init au 1er boot (et retardent la dispo SSH). SSH # est déjà présent dans les images cloud ; on l'active via runcmd sans apt. @@ -1933,6 +2490,30 @@ def build_preseed( f"chmod 700 /target/home/{user}/.ssh", f"chmod 600 /target/home/{user}/.ssh/authorized_keys", ] + # Guide de connexion et identité git : les mêmes fichiers que sur les autres + # distributions, mais ici il n'y a pas de cloud-init pour les écrire. Ils + # voyagent DANS l'initrd, à côté du preseed, et le late_command ne fait que + # les copier. + # + # Pourquoi pas leur contenu dans le preseed : la valeur d'une question tient + # sur UNE ligne, et celle-ci fait déjà 1165 caractères avec une clé RSA-4096. + # Y ajouter 1,2 Kio de guide — encodé ou en trente echo, la longueur est la + # même — doublerait une ligne dont aucune limite n'est documentée pour + # cdebconf. Et une troncature ne coûterait pas le guide : elle couperait le + # late_command au milieu, donc ni sudoers ni clé SSH, donc une VM + # inaccessible. + for path, mode, _content, owner in guide_files(args): + src = "/" + installer_guide_name(path) + post.append(f"cp {src} /target{path} || true") + post.append(f"chmod {mode} /target{path} || true") + if owner: + post.append(f"in-target chown {owner}:{owner} {path} || true") + # Le code de sortie du late_command est celui de sa DERNIÈRE commande, et + # d-i s'arrête sur « Failed to run preseeded command » dès qu'il n'est pas + # nul. Sans ce « true », un chmod qui échoue bloque l'installation sur un + # écran que personne ne regarde — c'est déjà la garde de + # partman/early_command, quelques lignes plus haut. + post.append("true") # Diagnostic réseau, écrit sur la console AVANT que netcfg ne décide. # netcfg n'essaie aucun DHCP sur s390x et tombe droit sur l'adressage # statique ; ses propres traces vont dans le syslog INTERNE de d-i, qu'on @@ -1956,6 +2537,11 @@ def build_preseed( "ip link set enc1 up > /dev/console 2>&1", "echo '=== EL: enc1 activee avant netcfg ===' > /dev/console", "ip -o link show enc1 > /dev/console 2>&1", + # Même garde que partman/early_command : « ip -o link show » rend 1 + # quand l'interface n'existe pas, et le code de sortie du early_command + # est celui de sa dernière commande. Une ligne de DIAGNOSTIC bloquait + # donc l'installation qu'elle devait servir à comprendre. + "true", ] lines.append("d-i preseed/early_command string " + " ; ".join(early)) lines.append("d-i preseed/late_command string " + " ; ".join(post)) @@ -1963,7 +2549,11 @@ def build_preseed( def build_installer_initrd( - preseed: str, initrd_src: Path, out: Path, runner: Runner + preseed: str, + initrd_src: Path, + out: Path, + runner: Runner, + guide: list[tuple[str, str, str, str]] | None = None, ) -> None: """Glisse le preseed DANS l'initrd de l'installateur. @@ -1975,9 +2565,16 @@ def build_installer_initrd( La méthode est celle de la documentation Debian — décompresser, ajouter le fichier au cpio, recompresser — et non une concaténation d'archives, que le noyau accepte mais que d-i ne parcourt pas de la même façon. + + `guide` : les fichiers d'accueil de la VM (guide de connexion, identité + git), rangés à côté du preseed. L'initrd EST le système de fichiers de + l'installateur : le late_command n'a plus qu'à les copier vers /target, + sans avoir à transporter leur contenu dans une valeur de preseed. """ if runner.dry_run: print(f"[dry-run] preseed -> {out}") + for path, _mode, _content, _owner in guide or []: + print(f"[dry-run] + {installer_guide_name(path)} -> {path}") return if not shutil.which("cpio"): sys.exit( @@ -1987,6 +2584,11 @@ def build_installer_initrd( with tempfile.TemporaryDirectory() as tmp: work = Path(tmp) (work / "preseed.cfg").write_text(preseed, encoding="utf-8") + members = ["preseed.cfg"] + for path, _mode, content, _owner in guide or []: + name = installer_guide_name(path) + (work / name).write_text(content, encoding="utf-8") + members.append(name) # network-console DÉSACTIVÉ, par le levier que d-i prévoit pour cela. # # Sur IBM Z, d-i propose de poursuivre par SSH — la console y est @@ -2008,9 +2610,10 @@ def build_installer_initrd( plain = work / "initrd" with gzip.open(initrd_src, "rb") as src, open(plain, "wb") as dst: shutil.copyfileobj(src, dst) + members.append("var/lib/dpkg/info/network-console.isinstallable") subprocess.run( ["cpio", "-H", "newc", "-o", "-A", "-F", str(plain)], - input="preseed.cfg\nvar/lib/dpkg/info/network-console.isinstallable\n", + input="\n".join(members) + "\n", text=True, cwd=work, check=True, @@ -2598,6 +3201,36 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="N'exécute pas package_upgrade au premier boot.", ) + g_cloud.add_argument( + "--lang", + choices=("fr", "en"), + default="fr", + help="Langue du guide affiché à la connexion SSH (défaut : fr). " + "todo.py passe la langue de son menu.", + ) + g_cloud.add_argument( + "--erplibre-dir", + default="", + metavar="CHEMIN", + help="Racine d'ERPLibre dans la VM (~/git/erplibre en dev, " + "/opt/erplibre en prod). Ajoute la section ERPLibre au guide de " + "connexion. Vide, elle est omise : une VM déployée sans installation " + "n'annonce pas un dépôt et un service qui n'existent pas.", + ) + g_cloud.add_argument( + "--erplibre-make", + default="", + metavar="CIBLE", + help="Cible make qui a installé la VM (ex. install_odoo_18), reprise " + "dans le guide pour la mettre à jour. Vide : le guide s'arrête à " + "« git pull » plutôt que d'annoncer une cible qui n'est pas la bonne.", + ) + g_cloud.add_argument( + "--no-git-identity", + action="store_true", + help="N'injecte pas l'identité git de l'hôte (user.name, user.email, " + "core.editor) dans le ~/.gitconfig de la VM.", + ) g_cloud.add_argument( "--apt-update", action="store_true", diff --git a/test/test_qemu_motd.py b/test/test_qemu_motd.py new file mode 100644 index 0000000..1fde3ff --- /dev/null +++ b/test/test_qemu_motd.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Guide de connexion SSH des VM QEMU et identité git injectée. + +Ces fonctions sont PURES : elles rendent du texte. Les tester ne demande donc +ni VM ni réseau, alors qu'une erreur y coûte cher — un user-data invalide fait +rejeter TOUTE la configuration cloud-init, et la VM démarre sans utilisateur ni +clé SSH, donc inaccessible. +""" + +import importlib.util +import os +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DEPLOY_QEMU = os.path.join(REPO, "script", "qemu", "deploy_qemu.py") + +# script/qemu/ n'est pas un paquet : todo.py importe déjà ce fichier de cette +# façon (_qemu_import_module), le test fait pareil. +_spec = importlib.util.spec_from_file_location("deploy_qemu", DEPLOY_QEMU) +dq = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(dq) + +try: + import yaml +except ImportError: # pragma: no cover - PyYAML absent du venv d'outils + yaml = None + +# Une combinaison par distribution du catalogue, dont les DEUX produits +# openSUSE : ils n'ont pas la même commande de mise à jour. +COMBOS = ( + ("ubuntu", "24.04", "amd64"), + ("debian", "12", "s390x"), + ("fedora", "43", "amd64"), + ("almalinux", "9", "arm64"), + ("rocky", "10", "amd64"), + ("opensuse", "16.0", "amd64"), + ("opensuse", "tumbleweed", "amd64"), + ("arch", "latest", "amd64"), +) + +# Largeur d'un terminal standard. Au-delà, le guide se replie et devient +# illisible — c'est le seul défaut qui ne se voit qu'une fois la VM déployée. +TERM_WIDTH = 80 + + +class TestMotdContent(unittest.TestCase): + def test_each_distro_gets_its_package_manager(self): + expected = { + "ubuntu": "apt", + "debian": "apt", + "fedora": "dnf", + "almalinux": "dnf", + "rocky": "dnf", + "opensuse": "zypper", + "arch": "pacman", + } + for distro, version, arch in COMBOS: + motd = dq.build_motd(distro, version, arch) + mgr = expected[distro] + self.assertIn(f"Paquets — {mgr}", motd, distro) + for other in set(expected.values()) - {mgr}: + self.assertNotIn(f" {other} install", motd, distro) + + def test_leap_updates_with_up_and_tumbleweed_with_dup(self): + """La distinction coûte cher à rater : « up » sur Tumbleweed laisse + traîner des paquets retirés des dépôts.""" + leap = dq.build_motd("opensuse", "16.0", "amd64") + rolling = dq.build_motd("opensuse", "tumbleweed", "amd64") + self.assertIn("sudo zypper up", leap) + self.assertNotIn("sudo zypper dup", leap) + self.assertIn("sudo zypper dup", rolling) + self.assertNotIn("sudo zypper up ", rolling) + + def test_pacman_never_suggests_a_bare_sy(self): + motd = dq.build_motd("arch", "latest", "amd64") + for line in motd.splitlines(): + self.assertNotIn("pacman -Sy ", line) + self.assertNotIn("pacman -Sy\n", line) + + def test_header_names_the_distribution(self): + self.assertIn( + "openSUSE Leap 16.0", dq.build_motd("opensuse", "16.0", "x") + ) + self.assertIn( + "openSUSE Tumbleweed", dq.build_motd("opensuse", "tumbleweed", "x") + ) + # Rolling release : le numéro « latest » n'apprend rien. + self.assertIn("Arch Linux ·", dq.build_motd("arch", "latest", "amd64")) + + +class TestMotdErplibreSection(unittest.TestCase): + def test_absent_without_install_dir(self): + """Une VM déployée sans ERPLibre ne doit pas annoncer un dépôt ni un + service qui n'existent pas.""" + motd = dq.build_motd("ubuntu", "24.04", "amd64") + self.assertNotIn("ERPLibre\n", motd.split("╯", 1)[1]) + self.assertNotIn("erplibre.service", motd) + self.assertNotIn("make todo", motd) + # En l'absence de section ERPLibre, les commandes de service doivent + # apparaître dans le bloc système : sinon elles manqueraient partout. + self.assertIn("systemctl status ", motd) + self.assertIn("journalctl -u -f", motd) + + def test_covers_what_an_operator_needs(self): + motd = dq.build_motd( + "ubuntu", + "24.04", + "amd64", + "fr", + "~/git/erplibre", + "install_odoo_18", + "vim", + ) + for needed in ( + "cd ~/git/erplibre", # aller au dépôt + "make todo", # menu ERPLibre + "vim config.conf", # éditer le serveur + "sudo systemctl restart erplibre", # redémarrer + "systemctl status erplibre", # inspecter + "journalctl -u erplibre -f", # inspecter + "update_addons_all.sh ", # mise à jour des modules + "git pull && make install_odoo_18", # mise à jour Odoo + "http://:8069", # interface web + ): + self.assertIn(needed, motd) + + def test_no_editor_names_no_command(self): + """Sans éditeur connu, on nomme le fichier : « vi » n'est pas garanti + sur toutes les images cloud, et un guide qui propose une commande + absente est pire que muet.""" + motd = dq.build_motd("arch", "latest", "amd64", "fr", "/opt/erplibre") + self.assertIn("config.conf", motd) + self.assertNotIn("vi config.conf", motd) + self.assertNotIn("nano config.conf", motd) + + def test_no_make_target_stops_at_git_pull(self): + """Les profils sans Odoo (« ERPLibre seul », « mobile ») ne doivent pas + se voir annoncer une cible make qui n'est pas la leur.""" + motd = dq.build_motd( + "ubuntu", "24.04", "amd64", "fr", "~/git/erplibre", "", "vim" + ) + self.assertIn("git pull", motd) + self.assertNotIn("git pull && make", motd) + + +class TestMotdLayout(unittest.TestCase): + def test_never_wider_than_a_standard_terminal(self): + for distro, version, arch in COMBOS: + for lang in ("fr", "en"): + for el_dir in ("", "~/git/erplibre", "/opt/erplibre"): + motd = dq.build_motd( + distro, + version, + arch, + lang, + el_dir, + "install_odoo_18" if el_dir else "", + "vim" if el_dir else "", + ) + for line in motd.splitlines(): + self.assertLessEqual( + len(line), + TERM_WIDTH, + f"{distro} {version} {lang} {el_dir} : {line}", + ) + + def test_the_frame_is_never_narrower_than_what_it_frames(self): + for distro, version, arch in COMBOS: + motd = dq.build_motd( + distro, + version, + arch, + "fr", + "~/git/erplibre", + "install_odoo_18", + "vim", + ) + lines = motd.splitlines() + frame = len(lines[0]) + self.assertTrue(lines[0].startswith("╭")) + for line in lines: + self.assertLessEqual(len(line), frame, f"{distro} : {line}") + + def test_no_tab_anywhere(self): + """Une tabulation en tête de ligne est une erreur FATALE dans un + scalaire bloc YAML : cloud-init rejette alors tout le user-data.""" + motd = dq.build_motd( + "ubuntu", + "24.04", + "amd64", + "fr", + "~/git/erplibre", + "install_odoo_18", + "vim", + ) + self.assertNotIn("\t", motd) + + def test_english_is_really_english(self): + motd = dq.build_motd( + "ubuntu", + "24.04", + "amd64", + "en", + "~/git/erplibre", + "install_odoo_18", + "vim", + ) + self.assertIn("Packages — apt", motd) + self.assertIn("upgrade the system", motd) + self.assertNotIn("mettre à jour", motd) + + +class TestGitConfig(unittest.TestCase): + def test_sections_and_values(self): + cfg = dq.build_gitconfig("Ada Lovelace", "ada@example.org", "vim") + self.assertIn("[user]", cfg) + self.assertIn("name = Ada Lovelace", cfg) + self.assertIn("email = ada@example.org", cfg) + self.assertIn("[core]", cfg) + self.assertIn("editor = vim", cfg) + + def test_indented_with_spaces_never_tabs(self): + """git accepte les deux ; le scalaire bloc YAML qui transporte ce texte, + non — une tabulation y fait rejeter tout le user-data.""" + self.assertNotIn("\t", dq.build_gitconfig("A", "a@b.c", "vim")) + + def test_empty_when_the_host_has_nothing_to_pass(self): + self.assertEqual("", dq.build_gitconfig("", "", "")) + + def test_partial_identity_omits_the_missing_key(self): + cfg = dq.build_gitconfig("Ada", "", "") + self.assertIn("name = Ada", cfg) + self.assertNotIn("email", cfg) + self.assertNotIn("[core]", cfg) + + +class TestEditorResolution(unittest.TestCase): + def test_known_editors_map_to_package_and_binary(self): + self.assertEqual(("vim", "vim"), dq.EDITOR_PACKAGES["vi"]) + self.assertEqual(("neovim", "nvim"), dq.EDITOR_PACKAGES["nvim"]) + self.assertEqual(("nano", "nano"), dq.EDITOR_PACKAGES["nano"]) + + def test_unknown_editor_is_ignored_not_guessed(self): + """« code » n'est dans aucun dépôt de distribution : l'annoncer + donnerait un core.editor qui fait échouer « git commit ».""" + self.assertNotIn("code", dq.EDITOR_PACKAGES) + self.assertEqual(("", ""), dq.EDITOR_PACKAGES.get("code", ("", ""))) + + def test_binary_drops_the_path_and_the_options(self): + self.assertEqual("code", dq.editor_binary("/usr/bin/code --wait")) + self.assertEqual("vim", dq.editor_binary("vim")) + self.assertEqual("", dq.editor_binary(" ")) + + +class TestWriteFilesBlock(unittest.TestCase): + def _block(self): + return dq.write_files_lines( + [ + ( + "/etc/motd", + "0644", + dq.build_motd("ubuntu", "24.04", "amd64"), + "", + ), + ( + "/home/erplibre/.gitconfig", + "0644", + dq.build_gitconfig("Ada", "ada@example.org", "vim"), + "erplibre", + ), + ] + ) + + def test_permissions_are_quoted(self): + """« permissions: 644 » non quoté est lu en DÉCIMAL et appliqué tel + quel : 0o1204, soit le bit setuid, sans le moindre avertissement.""" + block = "\n".join(self._block()) + self.assertIn("permissions: '0644'", block) + self.assertNotIn("permissions: 0644", block) + + def test_defer_only_for_owned_files(self): + """write_files tourne AVANT la création des utilisateurs : sans + « defer », le chown vers le compte de la VM échoue.""" + block = "\n".join(self._block()) + self.assertEqual(1, block.count("defer: true")) + self.assertEqual(1, block.count("owner: erplibre:erplibre")) + + @unittest.skipIf(yaml is None, "PyYAML absent") + def test_yaml_round_trip_is_byte_identical(self): + """Le scalaire bloc doit rendre EXACTEMENT le texte d'origine : une + indentation mal calculée passerait la validation en abîmant le fichier + écrit dans la VM.""" + motd = dq.build_motd( + "ubuntu", + "24.04", + "amd64", + "fr", + "~/git/erplibre", + "install_odoo_18", + "vim", + ) + # « + "\n" » comme build_cloud_config, qui termine toujours le + # document : sans ce saut final, un scalaire bloc en fin de flux perd sa + # dernière fin de ligne. + doc = ( + "\n".join(dq.write_files_lines([("/etc/motd", "0644", motd, "")])) + + "\n" + ) + self.assertEqual( + motd, yaml.safe_load(doc)["write_files"][0]["content"] + ) + + @unittest.skipIf(yaml is None, "PyYAML absent") + def test_every_distro_produces_parsable_yaml(self): + for distro, version, arch in COMBOS: + motd = dq.build_motd( + distro, + version, + arch, + "fr", + "~/git/erplibre", + "install_odoo_18", + "vim", + ) + doc = ( + "\n".join( + dq.write_files_lines([("/etc/motd", "0644", motd, "")]) + ) + + "\n" + ) + self.assertEqual( + motd, yaml.safe_load(doc)["write_files"][0]["content"], distro + ) + + +class TestInstallerGuideNames(unittest.TestCase): + def test_names_are_flat(self): + """Le cpio est déplié séquentiellement et ne crée pas les répertoires + parents manquants : une entrée « erplibre/etc-motd » sans entrée + « erplibre » ferait échouer le dépliage de l'initrd entier.""" + for path in ("/etc/motd", "/home/erplibre/.gitconfig"): + name = dq.installer_guide_name(path) + self.assertNotIn("/", name) + self.assertTrue(name.startswith("erplibre-")) + + def test_two_paths_never_collide(self): + self.assertNotEqual( + dq.installer_guide_name("/etc/motd"), + dq.installer_guide_name("/home/erplibre/.gitconfig"), + ) + + +if __name__ == "__main__": + unittest.main() From c2852089a0e4f2d906de70dd51bb8093403466e6 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 17 Aug 2026 22:57:56 -0400 Subject: [PATCH 17/55] [ADD] todo qemu: offer PyCharm, Android Studio and GNOME extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A graphical VM was a desktop and nothing else: every developer tool had to be installed by hand afterwards. A check list now carries them, filtered per machine — Android Studio is x86_64 only, Google publishes no Linux aarch64 build — and their disk cost reaches the plan before any qcow2 is created. They are installed BEFORE the clone: PyCharm writes the .idea/ of the repository, and the install that follows is what runs pycharm_configuration.py, through update_env_version.pycharm_update(). Its launcher is named studio, which is enough to conclude the install failed; it now answers to android-studio too. GNOME extensions come from the site by UUID, per running Shell: the same endpoint serves gTile v59 for GNOME 46 and v62 for 48. Checked with stubs: every tool can fail and the install's exit code still wins, 45 tests. --- FR --- Une VM graphique n'était qu'un bureau : chaque outil de développement restait à poser à la main. Une liste à cocher les porte, filtrés machine par machine — Android Studio n'existe qu'en x86_64, Google ne publiant aucune archive Linux aarch64 — et leur place disque atteint le plan avant qu'un seul qcow2 ne soit créé. Ils sont posés AVANT le clone : PyCharm écrit le .idea/ du dépôt, et c'est l'installation qui suit qui lance pycharm_configuration.py, via update_env_version.pycharm_update(). Son lanceur s'appelle studio, ce qui suffit à conclure à un échec ; il répond désormais aussi à android-studio. Les extensions GNOME viennent du site par UUID, selon le Shell qui tourne : le même point d'entrée sert gTile v59 pour GNOME 46 et v62 pour 48. Vérifié avec des leurres : chaque outil peut échouer sans que le code de sortie de l'installation ne change, 45 tests. Assisted-by: Claude Opus 5 --- script/qemu/README.base.md | 113 ++++++ script/qemu/README.fr.md | 57 +++ script/qemu/README.md | 56 +++ script/todo/qemu_deploy_form.py | 102 ++++++ script/todo/todo.py | 614 +++++++++++++++++++++++++++++++- script/todo/todo_i18n.py | 116 ++++++ test/test_qemu_desktop_tools.py | 272 ++++++++++++++ 7 files changed, 1322 insertions(+), 8 deletions(-) create mode 100644 test/test_qemu_desktop_tools.py diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md index b031ad7..0ca8c1a 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -262,6 +262,33 @@ Ubuntu VM (libvirt)**. From there you can deploy a VM, preview a dry-run, download an image, list VMs and show a VM IP address — the menu asks for the parameters and builds the command for you. +When a VM is graphical, the menu also offers a **check list of development +tools**: PyCharm (installed from the official JetBrains archive +into `/opt/pycharm`, its launcher opening the ERPLibre checkout), Android +Studio (`/opt/android-studio`, command `studio` or `android-studio`; x86_64 +only — Google publishes no Linux aarch64 build) and a set of suggested GNOME extensions. + +The extension packages of the distribution are installed but left disabled — +their UUID is not reliably known, and the Extension Manager is there to pick +from. Three extensions named by UUID are installed **and enabled**, straight +from extensions.gnome.org: **gTile**, **Freon** and **Tracker**. The archive +is fetched for the GNOME Shell version actually running in the VM — the same +endpoint serves gTile v59 for GNOME 46 and v62 for GNOME 48, so a frozen URL +would install a build made for another release. A mismatched build is never +loaded by GNOME anyway: it compares `metadata.json` with its own version and +shows the extension as outdated rather than breaking the session. + +The tools are installed **before** the clone and the ERPLibre install, and +the order matters: PyCharm writes the repository's `.idea/` the first time it +opens the project, and the install that follows runs +`pycharm_configuration.py` on it (`update_env_version.pycharm_update()`, +which skips silently when there is no `.idea` yet). So on a fresh VM: open +PyCharm once on `~/git/erplibre`, close it, then re-run +`make install_odoo_18`. + +Each tool is filtered per VM — by architecture and by desktop flavour — and +its disk cost is added to the plan before anything is created. + ## Main options - `--distro` — `ubuntu` (default), `debian` or `fedora`. @@ -279,9 +306,38 @@ parameters and builds the command for you. - `--no-install-deps` — never auto-install dependencies. - `--dry-run` — show the commands without executing anything. - `--force` — overwrite the existing working qcow2 disk. +- `--lang` — language of the SSH login guide, `fr` (default) or `en`. The + TODO menu passes its own language. +- `--erplibre-dir` — where ERPLibre will live in the VM + (`~/git/erplibre`, or `/opt/erplibre` in production). Adds the ERPLibre + section to the login guide; omitted, that section is left out. +- `--erplibre-make` — the make target that installed the VM + (e.g. `install_odoo_18`), shown in the guide as the way to update it. +- `--no-git-identity` — do not copy the host's `user.name`, `user.email` + and `core.editor` into the VM's `~/.gitconfig`. Run `./script/qemu/deploy_qemu.py --help` for the full list. +## Login guide (`/etc/motd`) + +Every VM greets you, at each interactive SSH login, with the commands of +**its own** distribution — `apt`, `dnf`, `zypper` or `pacman` — plus the +ERPLibre ones (edit the server, restart it, update modules, update Odoo, +inspect the instance, open the TODO menu). It is written by cloud-init, so +it is there from the first boot: before ERPLibre is installed, and still +there if that installation fails, which is exactly when you log in by hand. + +`--dry-run` prints the generated guide along with the rest of the user-data. +The guide is not shown to `ssh host 'command'`, so it never pollutes an +installation log. + +The host's git identity travels with it, into the VM's `~/.gitconfig`: a +commit made in the VM then carries your name instead of +`erplibre@`. The editor follows the same route — `core.editor`, the +`config.conf` line of the guide, and the package installed in the VM all +come from one table, so the guide never names a command the VM does not +have. + L'utilisateur par défaut est `erplibre` (modifiable avec `--user`). @@ -294,6 +350,35 @@ prévisualiser un dry-run, télécharger une image, lister les VM et afficher l'IP d'une VM — le menu demande les paramètres et construit la commande pour vous. +Quand une VM est graphique, le menu propose en plus une **liste à cocher +d'outils de développement** : PyCharm (posé depuis l'archive officielle +JetBrains dans `/opt/pycharm`, son lanceur ouvrant le dépôt ERPLibre), +Android Studio (`/opt/android-studio`, commande `studio` ou +`android-studio` ; x86_64 seulement — Google ne publie aucune archive Linux +aarch64) et un jeu +d'extensions GNOME suggérées. + +Les extensions empaquetées par la distribution sont installées sans être +activées — leur UUID n'est pas connu de façon fiable, et le gestionnaire +d'extensions est là pour choisir. Trois extensions nommées par leur UUID sont, +elles, installées **et activées**, directement depuis extensions.gnome.org : +**gTile**, **Freon** et **Tracker**. L'archive est prise pour la version de +GNOME Shell qui tourne vraiment dans la VM — le même point d'entrée sert gTile +v59 en GNOME 46 et v62 en GNOME 48, si bien qu'une URL figée poserait une +version faite pour une autre release. Une archive mal appariée n'est de toute +façon jamais chargée par GNOME : il compare `metadata.json` à sa propre +version et affiche l'extension comme obsolète plutôt que de casser la session. + +Les outils sont posés **avant** le clone et l'installation d'ERPLibre, et +l'ordre compte : PyCharm écrit le `.idea/` du dépôt à la première ouverture du +projet, et l'installation qui suit y lance `pycharm_configuration.py` +(`update_env_version.pycharm_update()`, qui se tait tant qu'il n'y a pas de +`.idea`). Sur une VM neuve : ouvrir PyCharm une fois sur `~/git/erplibre`, le +fermer, puis relancer `make install_odoo_18`. + +Chaque outil est filtré VM par VM — architecture et saveur de bureau — et sa +place disque s'ajoute au plan avant que rien ne soit créé. + ## Principales options - `--distro` — `ubuntu` (défaut), `debian` ou `fedora`. @@ -314,9 +399,37 @@ vous. - `--no-install-deps` — n'installe jamais les dépendances automatiquement. - `--dry-run` — affiche les commandes sans rien exécuter. - `--force` — écrase le disque de travail qcow2 existant. +- `--lang` — langue du guide affiché à la connexion SSH, `fr` (défaut) ou + `en`. Le menu TODO passe la sienne. +- `--erplibre-dir` — où ERPLibre sera installé dans la VM + (`~/git/erplibre`, ou `/opt/erplibre` en production). Ajoute la section + ERPLibre au guide de connexion ; omis, cette section est laissée de côté. +- `--erplibre-make` — la cible make qui a installé la VM + (ex. `install_odoo_18`), reprise dans le guide pour la mettre à jour. +- `--no-git-identity` — ne recopie pas les `user.name`, `user.email` et + `core.editor` de l'hôte dans le `~/.gitconfig` de la VM. Lancez `./script/qemu/deploy_qemu.py --help` pour la liste complète. +## Guide de connexion (`/etc/motd`) + +Chaque VM accueille celui qui s'y connecte en SSH avec les commandes de **sa** +distribution — `apt`, `dnf`, `zypper` ou `pacman` — et celles d'ERPLibre : +éditer le serveur, le redémarrer, mettre à jour des modules, mettre à jour +Odoo, inspecter l'instance, ouvrir le menu TODO. Il est écrit par cloud-init, +donc présent dès le premier démarrage : avant l'installation d'ERPLibre, et +encore là si elle échoue — le moment où l'on se connecte justement à la main. + +`--dry-run` affiche le guide généré avec le reste du user-data. Il ne +s'affiche PAS pour un `ssh hôte 'commande'` : les journaux d'installation +restent nets. + +L'identité git de l'hôte voyage avec lui, dans le `~/.gitconfig` de la VM : +un commit fait dans la VM porte alors votre nom plutôt que +`erplibre@`. L'éditeur suit le même chemin — `core.editor`, la +ligne `config.conf` du guide et le paquet installé dans la VM viennent d'une +seule table, de sorte que le guide ne nomme jamais une commande absente. + ## Managing VMs diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index af7b826..2d86503 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -146,6 +146,35 @@ prévisualiser un dry-run, télécharger une image, lister les VM et afficher l'IP d'une VM — le menu demande les paramètres et construit la commande pour vous. +Quand une VM est graphique, le menu propose en plus une **liste à cocher +d'outils de développement** : PyCharm (posé depuis l'archive officielle +JetBrains dans `/opt/pycharm`, son lanceur ouvrant le dépôt ERPLibre), +Android Studio (`/opt/android-studio`, commande `studio` ou +`android-studio` ; x86_64 seulement — Google ne publie aucune archive Linux +aarch64) et un jeu +d'extensions GNOME suggérées. + +Les extensions empaquetées par la distribution sont installées sans être +activées — leur UUID n'est pas connu de façon fiable, et le gestionnaire +d'extensions est là pour choisir. Trois extensions nommées par leur UUID sont, +elles, installées **et activées**, directement depuis extensions.gnome.org : +**gTile**, **Freon** et **Tracker**. L'archive est prise pour la version de +GNOME Shell qui tourne vraiment dans la VM — le même point d'entrée sert gTile +v59 en GNOME 46 et v62 en GNOME 48, si bien qu'une URL figée poserait une +version faite pour une autre release. Une archive mal appariée n'est de toute +façon jamais chargée par GNOME : il compare `metadata.json` à sa propre +version et affiche l'extension comme obsolète plutôt que de casser la session. + +Les outils sont posés **avant** le clone et l'installation d'ERPLibre, et +l'ordre compte : PyCharm écrit le `.idea/` du dépôt à la première ouverture du +projet, et l'installation qui suit y lance `pycharm_configuration.py` +(`update_env_version.pycharm_update()`, qui se tait tant qu'il n'y a pas de +`.idea`). Sur une VM neuve : ouvrir PyCharm une fois sur `~/git/erplibre`, le +fermer, puis relancer `make install_odoo_18`. + +Chaque outil est filtré VM par VM — architecture et saveur de bureau — et sa +place disque s'ajoute au plan avant que rien ne soit créé. + ## Principales options - `--distro` — `ubuntu` (défaut), `debian` ou `fedora`. @@ -166,9 +195,37 @@ vous. - `--no-install-deps` — n'installe jamais les dépendances automatiquement. - `--dry-run` — affiche les commandes sans rien exécuter. - `--force` — écrase le disque de travail qcow2 existant. +- `--lang` — langue du guide affiché à la connexion SSH, `fr` (défaut) ou + `en`. Le menu TODO passe la sienne. +- `--erplibre-dir` — où ERPLibre sera installé dans la VM + (`~/git/erplibre`, ou `/opt/erplibre` en production). Ajoute la section + ERPLibre au guide de connexion ; omis, cette section est laissée de côté. +- `--erplibre-make` — la cible make qui a installé la VM + (ex. `install_odoo_18`), reprise dans le guide pour la mettre à jour. +- `--no-git-identity` — ne recopie pas les `user.name`, `user.email` et + `core.editor` de l'hôte dans le `~/.gitconfig` de la VM. Lancez `./script/qemu/deploy_qemu.py --help` pour la liste complète. +## Guide de connexion (`/etc/motd`) + +Chaque VM accueille celui qui s'y connecte en SSH avec les commandes de **sa** +distribution — `apt`, `dnf`, `zypper` ou `pacman` — et celles d'ERPLibre : +éditer le serveur, le redémarrer, mettre à jour des modules, mettre à jour +Odoo, inspecter l'instance, ouvrir le menu TODO. Il est écrit par cloud-init, +donc présent dès le premier démarrage : avant l'installation d'ERPLibre, et +encore là si elle échoue — le moment où l'on se connecte justement à la main. + +`--dry-run` affiche le guide généré avec le reste du user-data. Il ne +s'affiche PAS pour un `ssh hôte 'commande'` : les journaux d'installation +restent nets. + +L'identité git de l'hôte voyage avec lui, dans le `~/.gitconfig` de la VM : +un commit fait dans la VM porte alors votre nom plutôt que +`erplibre@`. L'éditeur suit le même chemin — `core.editor`, la +ligne `config.conf` du guide et le paquet installé dans la VM viennent d'une +seule table, de sorte que le guide ne nomme jamais une commande absente. + ## Gestion des VM Lister, arrêter et supprimer les VM (le disque qcow2 sous diff --git a/script/qemu/README.md b/script/qemu/README.md index 473a847..76a2407 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -136,6 +136,33 @@ Ubuntu VM (libvirt)**. From there you can deploy a VM, preview a dry-run, download an image, list VMs and show a VM IP address — the menu asks for the parameters and builds the command for you. +When a VM is graphical, the menu also offers a **check list of development +tools**: PyCharm (installed from the official JetBrains archive +into `/opt/pycharm`, its launcher opening the ERPLibre checkout), Android +Studio (`/opt/android-studio`, command `studio` or `android-studio`; x86_64 +only — Google publishes no Linux aarch64 build) and a set of suggested GNOME extensions. + +The extension packages of the distribution are installed but left disabled — +their UUID is not reliably known, and the Extension Manager is there to pick +from. Three extensions named by UUID are installed **and enabled**, straight +from extensions.gnome.org: **gTile**, **Freon** and **Tracker**. The archive +is fetched for the GNOME Shell version actually running in the VM — the same +endpoint serves gTile v59 for GNOME 46 and v62 for GNOME 48, so a frozen URL +would install a build made for another release. A mismatched build is never +loaded by GNOME anyway: it compares `metadata.json` with its own version and +shows the extension as outdated rather than breaking the session. + +The tools are installed **before** the clone and the ERPLibre install, and +the order matters: PyCharm writes the repository's `.idea/` the first time it +opens the project, and the install that follows runs +`pycharm_configuration.py` on it (`update_env_version.pycharm_update()`, +which skips silently when there is no `.idea` yet). So on a fresh VM: open +PyCharm once on `~/git/erplibre`, close it, then re-run +`make install_odoo_18`. + +Each tool is filtered per VM — by architecture and by desktop flavour — and +its disk cost is added to the plan before anything is created. + ## Main options - `--distro` — `ubuntu` (default), `debian` or `fedora`. @@ -153,9 +180,38 @@ parameters and builds the command for you. - `--no-install-deps` — never auto-install dependencies. - `--dry-run` — show the commands without executing anything. - `--force` — overwrite the existing working qcow2 disk. +- `--lang` — language of the SSH login guide, `fr` (default) or `en`. The + TODO menu passes its own language. +- `--erplibre-dir` — where ERPLibre will live in the VM + (`~/git/erplibre`, or `/opt/erplibre` in production). Adds the ERPLibre + section to the login guide; omitted, that section is left out. +- `--erplibre-make` — the make target that installed the VM + (e.g. `install_odoo_18`), shown in the guide as the way to update it. +- `--no-git-identity` — do not copy the host's `user.name`, `user.email` + and `core.editor` into the VM's `~/.gitconfig`. Run `./script/qemu/deploy_qemu.py --help` for the full list. +## Login guide (`/etc/motd`) + +Every VM greets you, at each interactive SSH login, with the commands of +**its own** distribution — `apt`, `dnf`, `zypper` or `pacman` — plus the +ERPLibre ones (edit the server, restart it, update modules, update Odoo, +inspect the instance, open the TODO menu). It is written by cloud-init, so +it is there from the first boot: before ERPLibre is installed, and still +there if that installation fails, which is exactly when you log in by hand. + +`--dry-run` prints the generated guide along with the rest of the user-data. +The guide is not shown to `ssh host 'command'`, so it never pollutes an +installation log. + +The host's git identity travels with it, into the VM's `~/.gitconfig`: a +commit made in the VM then carries your name instead of +`erplibre@`. The editor follows the same route — `core.editor`, the +`config.conf` line of the guide, and the package installed in the VM all +come from one table, so the guide never names a command the VM does not +have. + ## Managing VMs List, stop and remove VMs (the qcow2 disk under `/var/lib/libvirt/images` diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index d86f5e7..2e1e8f6 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -294,6 +294,7 @@ def build_spec(vms, domains, form): "ssh_key": form["ssh_key"], "timezone": form.get("timezone", ""), "desktop": form.get("desktop", ""), + "desktop_tools": tuple(form.get("desktop_tools") or ()), "python_provider": form.get("python_provider", ""), "app_store": form.get("app_store", "deb"), "install": form["install"], @@ -364,6 +365,13 @@ def run_deploy_form(ctx, run_app: bool = True): # {clé de saveur: suffixe de nom}, fourni par todo.py qui décrit les # saveurs — on ne le redéfinit pas ici. desktop_suffixes = dict(ctx.get("desktop_suffixes") or {}) + # Outils de développement d'une VM graphique : [(clé, libellé, indice)] et + # leurs contraintes, toutes décrites dans todo.py — le formulaire ne fait + # que les afficher et rendre les cases cochées. + desktop_tools = list(ctx.get("desktop_tools") or []) + tool_disk = dict(ctx.get("desktop_tool_disk") or {}) + tool_arches = dict(ctx.get("desktop_tool_arches") or {}) + tool_desktops = dict(ctx.get("desktop_tool_desktops") or {}) # Architectures pour lesquelles mise publie un binaire. mise_arches = set(ctx.get("mise_arches") or ()) # [(clé, libellé)] des magasins d'applications, et les distributions qui @@ -655,6 +663,21 @@ def run_deploy_form(ctx, run_app: bool = True): for i, (_k, label) in enumerate(app_stores): yield RadioButton(label, value=i == 0) yield Static("", id="storewarn") + if desktop_tools: + # Une case par outil, et non une liste déroulante : ils + # sont indépendants, et chacun se prend ou se laisse. + yield Static( + t("Development tools (graphical VMs):"), + classes="grouptitle", + ) + for key, label, hint in desktop_tools: + gb = tool_disk.get(key, 0) + yield Checkbox( + f"{label} +{gb} Go — {hint}", + value=key in (defaults.get("tools") or ()), + id=f"f_tool_{key}", + ) + yield Static("", id="toolwarn") yield Static("ERPLibre", classes="grouptitle") yield Checkbox( t("Install ERPLibre"), @@ -816,9 +839,19 @@ def run_deploy_form(ctx, run_app: bool = True): # Le bureau pèse sur le disque de la VM QUI LE PORTE, et d'elle # seule : un supplément commun mentait dès que les types # différaient d'une machine à l'autre. + tools = self._desktop_tools() for row in self.rows: if row["vm"].get("desktop"): row["disk_gb"] += desktop_disk + # Même règle pour les outils, et pour la même raison : ils ne + # pèsent que sur les VM qui les reçoivent réellement. Android + # Studio n'existe qu'en x86_64, les extensions GNOME n'ont de + # sens que sous GNOME — une VM qui ne les aura pas ne doit pas + # se voir gonfler son disque. + row["disk_gb"] += sum( + tool_disk.get(k, 0) + for k in self._tools_for_vm(row["vm"], tools) + ) # Le plan doit MONTRER qu'une VM a été personnalisée : sans marque, # deux lignes aux ressources différentes n'ont aucune explication à # l'écran, et la surcharge est oubliée à la relecture. Le drapeau @@ -831,6 +864,70 @@ def run_deploy_form(ctx, run_app: bool = True): self._render_plan() self._render_mise() self._render_store() + self._render_tools() + + def _desktop_tools(self): + """Clés des outils cochés, dans l'ordre de la liste.""" + picked = [] + for key, _label, _hint in desktop_tools: + try: + if self.query_one(f"#f_tool_{key}", Checkbox).value: + picked.append(key) + except Exception: + continue + return tuple(picked) + + def _tools_for_vm(self, vm, tools): + """Outils qu'une VM donnée recevra vraiment. + + Même filtre que todo.py côté déploiement : une VM ARM ne verra + jamais Android Studio, une VM Cinnamon jamais les extensions + GNOME, et un serveur aucun des trois.""" + if not vm.get("desktop"): + return [] + out = [] + for key in tools: + arches = tool_arches.get(key) or () + desks = tool_desktops.get(key) or () + if arches and vm["arch"] not in arches: + continue + if desks and vm["desktop"] not in desks: + continue + out.append(key) + return out + + def _render_tools(self): + """Grise les cases sans VM graphique, et NOMME ce qui sera écarté. + + Cocher Android Studio sur un parc ARM ne produit rien : le dire ici + évite de le découvrir dans le journal d'installation.""" + if not desktop_tools: + return + graphical = [vm for vm in self.vms if vm.get("desktop")] + for key, _label, _hint in desktop_tools: + self.query_one(f"#f_tool_{key}", Checkbox).disabled = ( + not graphical + ) + if not graphical: + self.query_one("#toolwarn", Static).update( + f" {t('No graphical VM: these tools are not installed.')}" + ) + return + picked = self._desktop_tools() + skipped = sorted( + { + vm["name"] + for vm in graphical + for k in picked + if k not in self._tools_for_vm(vm, picked) + } + ) + self.query_one("#toolwarn", Static).update( + f" ⚠ {t('Partly skipped (arch or desktop):')} " + f"{', '.join(skipped)}" + if skipped + else "" + ) def _render_mise(self): """Grise le choix quand aucune VM retenue n'est servie par mise, @@ -1676,6 +1773,10 @@ def run_deploy_form(ctx, run_app: bool = True): self._recompute() # le disque annoncé inclut le +5 G ERPLibre elif event.checkbox.id == "f_par_all": self.query_one("#f_par", Select).disabled = event.value + elif str(event.checkbox.id or "").startswith("f_tool_"): + # Un IDE de plus, c'est un disque plus grand : le plan doit le + # montrer AVANT de déployer, pas après une heure d'installation. + self._recompute() # -- actions ---------------------------------------------------- # def action_select_all(self) -> None: @@ -1734,6 +1835,7 @@ def run_deploy_form(ctx, run_app: bool = True): or ctx.get("timezone") or "", "desktop": self._default_desktop(), + "desktop_tools": self._desktop_tools(), "python_provider": self._python_provider(), "app_store": self._app_store(), "install": install, diff --git a/script/todo/todo.py b/script/todo/todo.py index 4a3b38f..748f55a 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -4508,6 +4508,30 @@ class TODO: ~/git/erplibre (dev).""" return "/opt/erplibre" if prod else "$HOME/git/erplibre" + @staticmethod + def _qemu_guide_dir(prod): + """Répertoire d'ERPLibre tel que le GUIDE de connexion l'annonce. + + « ~/git/erplibre » plutôt que « $HOME/git/erplibre » : ce chemin n'est + pas exécuté par un script, il est lu par quelqu'un qui recopie la ligne + dans son shell — où les deux marchent — et le tilde est la forme qu'il + reconnaît. En production le chemin est absolu et la question ne se pose + pas.""" + return "/opt/erplibre" if prod else "~/git/erplibre" + + @staticmethod + def _qemu_make_target(install_cmd): + """Cible make qui installe Odoo dans `install_cmd`, pour le guide. + + Les profils s'écrivent « make install_os && make install_odoo_18 » : la + cible utile est la SECONDE, celle qui installe Odoo, et c'est aussi + celle qu'on relance après un « git pull ». Les profils qui n'en ont pas + (« ERPLibre seul », « mobile », « Déploiement ») rendent une chaîne + vide : le guide s'arrête alors à « git pull » plutôt que d'annoncer une + cible qui n'est pas celle de cette VM.""" + found = re.findall(r"make\s+(install_odoo\S*)", install_cmd or "") + return found[-1] if found else "" + def _qemu_odoo_service_cmd(self, prod=False): """Snippet shell (exécuté dans la VM) qui installe ERPLibre/Odoo comme service systemd puis l'active. N'est ajouté QUE pour les profils Odoo. @@ -4992,6 +5016,435 @@ class TODO: + "fi; " ) + # ------------------------------------------------------------------ # + # Outils de développement d'une VM graphique + # ------------------------------------------------------------------ # + # Chacun est une case à cocher, indépendante des autres, et chacun pèse sur + # le disque — le plan l'annonce AVANT de déployer, sinon l'installation se + # termine sur un disque plein après une heure d'attente. + # + # « disk_gb » compte le PIC, pas l'installé : l'archive téléchargée vit sur + # le disque le temps de l'extraction. PyCharm, c'est 1,2 Go d'archive et + # ~3 Go déplié ; Android Studio 1,5 Go et 3,5 Go, plus la place du premier + # SDK que l'utilisateur téléchargera. + # + # « arches » n'est pas une précaution : Google ne publie Android Studio + # QU'EN x86_64 (vérifié — toutes les variantes aarch64 de l'URL rendent 404, + # et le product-info.json de l'archive ne déclare qu'une cible + # « Linux/amd64 »). JetBrains, lui, publie bien une archive aarch64. + _QEMU_DESKTOP_TOOLS = { + "pycharm": { + "label": "PyCharm", + "hint": "Python IDE, opens the ERPLibre checkout", + "disk_gb": 5, + "arches": ("amd64", "arm64"), + "desktops": (), + }, + "android": { + "label": "Android Studio", + "hint": "ERPLibre mobile development (x86_64 only)", + "disk_gb": 8, + "arches": ("amd64",), + "desktops": (), + }, + "gnome_ext": { + "label": "GNOME extensions", + "hint": "suggested extensions + extension manager", + "disk_gb": 1, + "arches": (), + "desktops": ("gnome",), + }, + } + + @classmethod + def _qemu_desktop_tool_choices(cls): + """[(clé, libellé, indice)] pour le formulaire et l'invite en ligne.""" + return [ + (key, t(spec["label"]), t(spec["hint"])) + for key, spec in cls._QEMU_DESKTOP_TOOLS.items() + ] + + @classmethod + def _qemu_tools_for(cls, tools, arch, desktop): + """Outils RÉELLEMENT applicables à cette VM. + + Un outil demandé pour tout le parc ne convient pas forcément à chaque + machine : Android Studio n'existe qu'en x86_64, et les extensions GNOME + n'ont pas de sens sous Cinnamon. Filtrer ici plutôt que dans la commande + distante évite d'annoncer une installation qui ne se fera pas.""" + if not desktop: + return [] + out = [] + for key in tools or (): + spec = cls._QEMU_DESKTOP_TOOLS.get(key) + if not spec: + continue + if spec["arches"] and arch not in spec["arches"]: + continue + if spec["desktops"] and desktop not in spec["desktops"]: + continue + out.append(key) + return out + + @classmethod + def _qemu_tools_disk_gb(cls, tools, arch, desktop): + """Go à ajouter au disque pour les outils applicables à cette VM.""" + return sum( + cls._QEMU_DESKTOP_TOOLS[k]["disk_gb"] + for k in cls._qemu_tools_for(tools, arch, desktop) + ) + + # Archive officielle JetBrains, et non un paquet de distribution : aucun ne + # couvre les quatre gestionnaires (Arch l'a dans extra, Debian et Ubuntu ne + # l'ont qu'en snap — coupé ici —, Fedora et openSUSE pas du tout). + # + # L'URL ne porte AUCUN numéro de version : « code=PCC&latest » redirige vers + # la dernière stable. Vérifié, les deux : « distribution=linux » sort + # pycharm-2025.3.tar.gz et « distribution=linuxARM64 » son équivalent + # aarch64. Rien à mettre à jour dans ce dépôt quand JetBrains publie. + _QEMU_PYCHARM_URL = ( + "https://download.jetbrains.com/product?code=PCC&latest&distribution=" + ) + + # Android Studio n'a PAS d'URL « latest » : le répertoire de version + # (2026.1.3.8) et le nom de fichier (quail3-patch1) sont deux jetons + # INDÉPENDANTS, l'un ne se déduit pas de l'autre, et le flux updates.xml de + # Google ne publie ni l'un ni l'autre. On lit donc l'URL sur la page + # officielle, qui la porte en clair, et on retombe sur celle-ci si la page + # change de forme. Relevée et vérifiée (HTTP 200) le 2026-08-17. + _QEMU_ANDROID_URL = ( + "https://dl.google.com/dl/android/studio/ide-zips/2026.1.3.8/" + "android-studio-quail3-patch1-linux.tar.gz" + ) + _QEMU_ANDROID_PAGE = "https://developer.android.com/studio" + + @staticmethod + def _qemu_desktop_entry_cmd(name, label, exec_cmd, icon, categories): + """Écrit un lanceur .desktop. Sans lui, un outil déplié dans /opt + n'existe pas pour le bureau : il ne se lance qu'en tapant son chemin. + """ + return ( + f"sudo tee /usr/share/applications/{name}.desktop >/dev/null </dev/null " + "| head -1); " + f'[ -n "$b" ] && sudo ln -sf "$b" /usr/local/bin/{link}; ' + + ( + f'[ -n "$b" ] && sudo ln -sf "$b" /usr/local/bin/{alias}; ' + if alias + else "" + ) + ) + + def _qemu_pycharm_remote_cmd(self, prod=False): + """Installe PyCharm et lui donne le dépôt ERPLibre comme projet. + + « Configuré sur git/erplibre » veut dire deux choses, et les deux sont + faites ici : le lanceur du bureau OUVRE ce dépôt, et + pycharm_configuration.py y écrit le .idea/ du projet (interpréteur, + configurations d'exécution, dossiers exclus) — la même chose que + « make pycharm_configure », mais avec le python du venv d'outils, seul à + disposer de xmltodict. + + Tout le bloc est gardé : un IDE qui ne s'installe pas ne doit pas faire + échouer l'installation d'ERPLibre, qui elle a duré une heure.""" + el_dir = self._qemu_install_dir(prod) + return ( + f'echo "== {t("Installing PyCharm (long)")} =="; ' + "{ " + 'case "$(uname -m)" in x86_64) jb=linux;; ' + 'aarch64|arm64) jb=linuxARM64;; *) jb="";; esac; ' + # if/else et non « || { …; false; } » : dans un groupe, un échec + # n'interrompt PAS la suite (set -e est suspendu à gauche d'un + # « && »), et l'architecture non servie partait quand même + # télécharger une URL sans valeur de distribution. + 'if [ -z "$jb" ]; then ' + f'echo " {t("no JetBrains build for")} $(uname -m)"; false; ' + "else " + # /var/tmp et non /tmp : sur Fedora et dérivés /tmp est un tmpfs, en + # RAM — 1,2 Go d'archive y tueraient une VM de 3 Go. + "tmp=$(mktemp -p /var/tmp pycharm-XXXX.tar.gz) && " + f'curl -fsSL "{self._QEMU_PYCHARM_URL}$jb" -o "$tmp" && ' + "sudo mkdir -p /opt/pycharm && " + 'sudo tar -xzf "$tmp" -C /opt/pycharm --strip-components=1; ' + 'rc=$?; rm -f "$tmp"; [ $rc -eq 0 ]; fi; } && { ' + + self._qemu_jetbrains_launcher_cmd("/opt/pycharm", "pycharm") + + self._qemu_desktop_entry_cmd( + "pycharm", + "PyCharm (ERPLibre)", + f"/usr/local/bin/pycharm {el_dir}", + "/opt/pycharm/bin/pycharm.svg", + "Development;IDE;", + ) + # AUCUN appel à pycharm_configuration.py ici : l'installation + # ERPLibre le fait déjà. update_env_version.pycharm_update() teste + # « os.path.exists('.idea') » puis lance le script — une seule + # autorité, et elle sait se taire quand le projet n'existe pas + # encore. Doubler l'appel ne configurait rien de plus : ça écrivait + # « Missing ./.idea path » dans le journal d'une VM neuve, où + # PyCharm n'a évidemment jamais ouvert le dépôt. + + f'echo " {t("PyCharm installed:")} /opt/pycharm ' + f'({t("command")} pycharm, {t("project")} {el_dir})"; ' + f'echo " {t("open the project once and close PyCharm; the .idea " + "it writes is what the install configures")}"; ' + f'}} || echo " ⚠ {t("PyCharm not installed (see above)")}"; ' + ) + + def _qemu_android_studio_remote_cmd(self): + """Installe Android Studio, pour le développement mobile ERPLibre. + + L'émulateur, lui, exige KVM DANS la VM, donc la virtualisation + imbriquée : on le dit plutôt que de laisser découvrir l'échec au premier + lancement. Compiler et déployer sur un appareil réel par adb n'en + dépendent pas.""" + return ( + f'echo "== {t("Installing Android Studio (long)")} =="; ' + "{ " + 'if [ "$(uname -m)" != x86_64 ]; then ' + f'echo " {t("Android Studio: Google publishes x86_64 only")}"; ' + "false; " + "else " + # La page officielle porte l'URL en clair ; le repli garde une + # version connue qui répond, pour le jour où sa forme change. + f"url=$(curl -fsSL --max-time 30 {self._QEMU_ANDROID_PAGE} " + "| grep -oE 'https://[a-z0-9.-]*gvt1\\.com/[^\"]*linux\\.tar\\.gz' " + "| head -1); " + f'[ -n "$url" ] || url="{self._QEMU_ANDROID_URL}"; ' + "tmp=$(mktemp -p /var/tmp android-XXXX.tar.gz) && " + 'curl -fsSL "$url" -o "$tmp" && ' + "sudo mkdir -p /opt/android-studio && " + 'sudo tar -xzf "$tmp" -C /opt/android-studio ' + "--strip-components=1; " + 'rc=$?; rm -f "$tmp"; [ $rc -eq 0 ]; fi; } && { ' + + self._qemu_jetbrains_launcher_cmd( + "/opt/android-studio", "studio", alias="android-studio" + ) + + self._qemu_desktop_entry_cmd( + "android-studio", + "Android Studio", + "/usr/local/bin/studio", + "/opt/android-studio/bin/studio.svg", + "Development;IDE;", + ) + + f'echo " {t("Android Studio installed:")} /opt/android-studio ' + f'({t("command")} studio / android-studio)"; ' + "grep -q vmx /proc/cpuinfo 2>/dev/null " + "|| grep -q svm /proc/cpuinfo 2>/dev/null " + f'|| echo " {t("no nested KVM: the emulator will not run")}"; ' + f'}} || echo " ⚠ {t("Android Studio not installed (see above)")}"; ' + ) + + # Extensions GNOME suggérées, par gestionnaire de paquets. Les noms ne sont + # pas les mêmes d'une famille à l'autre (« dashtodock » sur Debian, + # « dash-to-dock » sur Fedora), et aucune liste n'existe en entier partout. + # + # D'où l'installation UNE PAR UNE : apt, dnf, zypper et pacman échouent tous + # sur la commande ENTIÈRE dès qu'un seul nom est inconnu. Un paquet absent + # est donc annoncé et sauté, au lieu de faire tomber les autres avec lui. + _QEMU_GNOME_EXT_PKGS = { + "apt": ( + "gnome-shell-extension-manager", + "gnome-tweaks", + "gnome-shell-extensions", + "gnome-shell-extension-dashtodock", + "gnome-shell-extension-appindicator", + "gnome-shell-extension-caffeine", + ), + "dnf": ( + "gnome-extensions-app", + "gnome-tweaks", + "gnome-shell-extension-dash-to-dock", + "gnome-shell-extension-appindicator", + "gnome-shell-extension-caffeine", + "gnome-shell-extension-user-theme", + ), + "zypper": ( + "gnome-shell-extensions", + "gnome-tweaks", + "gnome-shell-extension-dash-to-dock", + "gnome-shell-extension-appindicator", + ), + "pacman": ( + "extension-manager", + "gnome-tweaks", + "gnome-shell-extensions", + ), + } + + # Extensions demandées nommément, par leur UUID sur extensions.gnome.org. + # Aucune n'est empaquetée par une distribution : on passe donc par le site. + # + # L'archive dépend de la version de GNOME Shell, et ce n'est pas une + # précaution de principe : mesuré le 2026-08-17, le même point d'entrée + # sert gTile v59 pour GNOME 46, v62 pour GNOME 48 et v52 pour GNOME 3.38. + # Une URL figée poserait donc, tôt ou tard, une archive faite pour une + # autre version. + # + # Ce que le site fait d'une version qu'il ne connaît PAS : il sert la plus + # récente (vérifié — « shell_version=99 » rend l'archive des GNOME 49/50), + # il ne répond pas 404. Sans conséquence fâcheuse pour autant : GNOME Shell + # refuse de CHARGER une extension dont metadata.json ne déclare pas la + # version courante. Une archive mal appariée reste donc inerte et affichée + # « obsolète » dans le gestionnaire — elle ne casse pas la session. + _QEMU_GNOME_EXT_UUIDS = ( + "gTile@vibou", + "freon@UshakovVasilii_Github.yahoo.com", + "tracker@aliakseiz.github.com", + ) + _QEMU_GNOME_EXT_SITE = "https://extensions.gnome.org/download-extension" + + def _qemu_gnome_ext_site_cmd(self): + """Installe les extensions nommées depuis extensions.gnome.org. + + Celles-là, on les ACTIVE — à la différence des paquets de la + distribution, dont on ne connaît pas l'UUID. Deux raisons, l'une et + l'autre vérifiées : le site rend l'archive faite pour le GNOME Shell de + cette VM, et une archive mal appariée n'est de toute façon jamais + chargée par GNOME, qui compare metadata.json à sa propre version. Ce + n'est donc pas l'activation qui peut casser une session. + + Le tout dans un groupe gardé : ni une panne de réseau ni une extension + retirée du site ne doivent faire échouer une installation d'une heure. + """ + uuids = " ".join(self._QEMU_GNOME_EXT_UUIDS) + site = self._QEMU_GNOME_EXT_SITE + return ( + "{ " + # « gnome-shell --version » rend « GNOME Shell 48.2 » : le dernier + # champ suffit, et évite une expression régulière à rallonge. + "v=$(gnome-shell --version 2>/dev/null | awk '{print $NF}'); " + 'if [ -z "$v" ]; then ' + + f'echo " {t("GNOME Shell not found, site extensions skipped")}"; ' + + "else " + # Le site attend le numéro MAJEUR depuis GNOME 40 (« 48 ») et + # « majeur.mineur » avant (« 3.38 ») : sans la bonne forme, il ne + # renvoie aucune archive. + "maj=${v%%.*}; " + 'if [ "$maj" -ge 40 ] 2>/dev/null; then sv="$maj"; ' + 'else sv=$(echo "$v" | cut -d. -f1,2); fi; ' + # gnome-extensions écrit dans ~/.local/share, mais l'activation + # passe par GSettings : sans bus de session — le cas d'un + # « ssh hôte commande » — dconf ne peut rien écrire. + # dbus-run-session en fournit un le temps de l'appel, et + # l'écriture atterrit bien dans le dconf de l'utilisateur. + 'gx() { if [ -z "$DBUS_SESSION_BUS_ADDRESS" ] && ' + "command -v dbus-run-session >/dev/null 2>&1; then " + 'dbus-run-session -- gnome-extensions "$@"; ' + 'else gnome-extensions "$@"; fi; }; ' + f"for u in {uuids}; do " + # « || echo » DANS la substitution : un mktemp qui échoue rendrait + # l'affectation non nulle, et « set -e » couperait toute la suite. + + "z=$(mktemp -p /var/tmp gext-XXXX.zip || echo /var/tmp/gext.zip); " + + 'if curl -fsSL --max-time 120 "' + + site + + '/$u.shell-extension.zip?shell_version=$sv" -o "$z" ' + + '&& gx install --force "$z" >/dev/null 2>&1; then ' + + 'gx enable "$u" >/dev/null 2>&1 || true; ' + + f'echo " {t("installed and enabled:")} $u"; else ' + + f'echo " {t("not available for this GNOME, skipped:")} ' + + '$u (GNOME $sv)"; fi; rm -f "$z"; done; ' + + f'echo " {t("log out and back in to load them")}"; ' + + "fi; } || true; " + ) + + def _qemu_gnome_ext_remote_cmd(self): + """Pose les extensions GNOME suggérées. + + Deux sources, et deux politiques, pour une raison : + - les paquets de la DISTRIBUTION sont installés sans être activés. On + ne connaît pas leur UUID de façon fiable, et activer à l'aveugle une + extension incompatible avec la version de GNOME Shell laisse la + session sur un écran noir — panne qu'on ne diagnostique pas depuis + une console série. Le gestionnaire graphique est posé pour choisir ; + - les extensions nommées par leur UUID sont, elles, ACTIVÉES : le site + rend l'archive faite pour ce GNOME-là, et une archive mal appariée + n'est jamais chargée par GNOME plutôt que de casser la session. + """ + pkgs = self._QEMU_GNOME_EXT_PKGS + return ( + f'echo "== {t("Suggested GNOME extensions")} =="; ' + "if command -v apt-get >/dev/null 2>&1; then " + f"EXT='{' '.join(pkgs['apt'])}'; " + "I='sudo DEBIAN_FRONTEND=noninteractive apt-get " + "-o DPkg::Lock::Timeout=600 install -y'; " + "elif command -v dnf >/dev/null 2>&1; then " + f"EXT='{' '.join(pkgs['dnf'])}'; I='sudo dnf install -y'; " + "elif command -v zypper >/dev/null 2>&1; then " + f"EXT='{' '.join(pkgs['zypper'])}'; " + "I='sudo zypper --non-interactive install " + "--auto-agree-with-licenses'; " + "elif command -v pacman >/dev/null 2>&1; then " + f"EXT='{' '.join(pkgs['pacman'])}'; " + "I='sudo pacman -S --needed --noconfirm'; " + 'else EXT=""; fi; ' + 'for p in $EXT; do $I "$p" >/dev/null 2>&1 ' + f'|| echo " {t("not in the repos, skipped:")} $p"; done; ' + f'echo " {t("Enable them from Extension Manager, or:")} ' + 'gnome-extensions enable "; ' + + self._qemu_gnome_ext_site_cmd() + ) + + def _qemu_tools_remote_cmd(self, tools, prod=False): + """Bloc d'installation des outils cochés, dans l'ordre du plus utile au + plus lourd. Chacun se garde lui-même : aucun ne fait échouer les + autres, ni l'installation d'ERPLibre.""" + blocks = { + "gnome_ext": self._qemu_gnome_ext_remote_cmd, + "pycharm": lambda: self._qemu_pycharm_remote_cmd(prod), + "android": self._qemu_android_studio_remote_cmd, + } + return "".join(blocks[k]() for k in blocks if k in (tools or ())) + + def _qemu_editor_pkg(self): + """Paquet de l'éditeur de l'hôte, à installer dans la VM. + + L'éditeur atteint déjà la VM par deux chemins, tous deux posés par + deploy_qemu.py : « core.editor » dans son ~/.gitconfig, et la ligne + « éditer le serveur » du guide de connexion. Encore faut-il que le + binaire y soit — les images cloud n'ont ni nano ni vim garantis, et + certaines n'ont même pas vi. On l'ajoute donc aux outils d'amorçage, avec + curl, git et make, là où les dépôts viennent d'être rafraîchis. + + La table des éditeurs vit dans deploy_qemu.py : une seule autorité décide + du paquet installé, de la commande affichée et de core.editor. Sans + module importable, on n'installe rien plutôt que de deviner un nom.""" + try: + mod = self._qemu_import_module() + return mod.vm_editor(mod.invoking_home())[0] + except Exception: + return "" + + def _qemu_editor_suffix(self): + """« vim » -> « vim » précédé d'une espace, rien du tout sinon. + + La liste des outils d'amorçage est une chaîne shell entre apostrophes : + y concaténer une chaîne vide sans précaution laisserait une espace en + trop, inoffensive mais visible dans chaque log d'installation.""" + pkg = self._qemu_editor_pkg() + return f" {pkg}" if pkg else "" + # mise ne publie de binaire que pour ces architectures : 46 assets à la # v2026.8.4, aucun s390x — son propre script d'installation refuse cette # plateforme. Ailleurs, le choix « mise » est sans objet et on reste sur @@ -5038,6 +5491,7 @@ class TODO: desktop=False, python_provider="", app_store="deb", + tools=(), ): """Script exécuté DANS la VM. `branch` à None n'installe QUE le bureau — le choix graphique ne dépend pas d'ERPLibre, et une VM peut être @@ -5047,7 +5501,10 @@ class TODO: installe dans /opt/erplibre (au lieu de ~/git/erplibre) + service SELinux confiné. `desktop` : ajoute GNOME et son accès distant. `python_provider` : « mise » pour un CPython précompilé, sinon le - comportement par défaut du dépôt (pyenv, qui compile).""" + comportement par défaut du dépôt (pyenv, qui compile). `tools` : outils + de développement cochés (PyCharm, Android Studio, extensions GNOME), + posés APRÈS ERPLibre — PyCharm a besoin du venv du dépôt pour écrire la + configuration du projet.""" if not branch: # Bureau seul : ni clone ni make, mais on garde le prologue — # attente de cloud-init et coupure des mises à jour automatiques, @@ -5059,6 +5516,7 @@ class TODO: + self._qemu_cloud_init_wait() + self._qemu_no_auto_upgrade(prod, app_store) + self._qemu_desktop_remote_cmd(desktop, app_store) + + self._qemu_tools_remote_cmd(tools, prod) ) if not final_cmd: final_cmd = f"make install_os && make {self.ERPLIBRE_ODOO_TARGET}" @@ -5084,6 +5542,7 @@ class TODO: # apt pendant l'installation. En PROD on ne touche à rien : les # correctifs de sécurité automatiques doivent rester actifs. no_auto_upgrade = self._qemu_no_auto_upgrade(prod, app_store) + tools_cmd = self._qemu_tools_remote_cmd(tools, prod) return ( "set -e; " + self._qemu_cloud_init_wait() # Coupé AVANT les apt-get ci-dessous : sinon apt-daily peut reprendre @@ -5103,7 +5562,14 @@ class TODO: # la VM soit la plus rapide possible (miroirs à jour / les plus # rapides), puis installe. Supporte apt (Debian/Ubuntu), dnf/yum # (Fedora) et pacman (Arch). - "PKGS='curl git make'; " + # + # L'éditeur de l'hôte voyage avec eux : deploy_qemu.py a déjà écrit + # « core.editor » dans le ~/.gitconfig de la VM et l'a nommé dans le + # guide de connexion, mais aucune image cloud ne garantit vim ni + # nano. Le poser ici plutôt que par cloud-init : les dépôts y sont + # déjà rafraîchis, et une installation de paquet au premier boot + # retarderait le démarrage sans laisser de trace dans le suivi. + f"PKGS='curl git make{self._qemu_editor_suffix()}'; " "if command -v apt-get >/dev/null 2>&1; then " # Au 1er boot, cloud-init (install qemu-guest-agent) et/ou # apt-daily.service tiennent le verrou apt. IMPORTANT : @@ -5167,6 +5633,16 @@ class TODO: '{ echo "Outil manquant apres installation: $t ' '(reseau de la VM ?)"; exit 1; }; done; ' + self._qemu_mise_remote_cmd(python_provider) + # Les outils AVANT le clone et le make, et l'ordre compte : c'est + # PyCharm qui écrit le .idea du dépôt, en l'ouvrant une fois, et + # c'est l'installation qui, ensuite, y lance + # pycharm_configuration.py. Posés après, ils arrivaient trop tard + # pour cette étape-là. + # + # Le code de sortie de la commande distante reste celui de + # l'installation : chaque bloc d'outil se garde lui-même et rend 0, + # donc aucun ne peut faire passer un make échoué pour un succès. + + tools_cmd # Clone : /opt/erplibre en PROD (racine, puis chown à l'utilisateur # pour que make/venv s'exécutent sans sudo), ~/git/erplibre en dev. + ( @@ -5199,12 +5675,16 @@ class TODO: desktop=False, python_provider="", app_store="deb", + desktop_tools=(), ): """Lance l'install ERPLibre en parallèle DÉTACHÉE sur les VM et ouvre le dashboard Textual. Quitter le dashboard n'arrête pas les installs. `ip_map` : IP déjà résolues (sinon on résout ici, EN PARALLÈLE). `final_cmd` : commande d'install selon le profil choisi. - `prod` : install /opt/erplibre + service SELinux confiné.""" + `prod` : install /opt/erplibre + service SELinux confiné. + `desktop_tools` : outils cochés pour tout le parc, filtrés machine par + machine (Android Studio n'existe qu'en x86_64, les extensions GNOME + n'ont pas de sens sous Cinnamon).""" from script.todo.qemu_install_monitor import ( launch_installs, run_monitor, @@ -5253,14 +5733,27 @@ class TODO: "version": v, "arch": a, } - if desk_map or branch_map or cmd_map: + # Les outils imposent une commande PAR VM même quand tout le + # reste est commun : ils dépendent de l'architecture de la + # machine et de sa saveur de bureau, que seule cette boucle + # connaît. + if desk_map or branch_map or cmd_map or desktop_tools: + # Le bureau de CETTE VM : sa saveur propre si la carte en + # donne une, sinon celle du parc. Prendre « rien » quand la + # carte est vide privait de bureau toute VM dont seule la + # branche ou le profil différait — la commande par défaut, + # elle, l'a toujours porté. + vm_desktop = desk_map.get( + name, "" if desk_map else desktop + ) entry["remote_cmd"] = self._qemu_erplibre_remote_cmd( branch_map.get(name, branch_def), cmd_map.get(name, cmd_def), prod, - desk_map.get(name, ""), + vm_desktop, python_provider, app_store, + self._qemu_tools_for(desktop_tools, a, vm_desktop), ) vms.append(entry) else: @@ -5306,10 +5799,12 @@ class TODO: desktop=False, python_provider="", app_store="deb", + desktop_tools=(), ): """Clone ERPLibre (branche donnée) dans la VM puis exécute la commande d'install du profil choisi (streamé). `ip` : IP déjà résolue ; - `final_cmd` : commande d'install ; `prod` : /opt + SELinux confiné.""" + `final_cmd` : commande d'install ; `prod` : /opt + SELinux confiné ; + `desktop_tools` : outils de développement cochés.""" if ip is None: ip = self._qemu_vm_ip(name) if not ip: @@ -5327,7 +5822,17 @@ class TODO: ) return remote = self._qemu_erplibre_remote_cmd( - branch, final_cmd, prod, desktop, python_provider, app_store + branch, + final_cmd, + prod, + desktop, + python_provider, + app_store, + # « or amd64 » comme _qemu_vm_meta : une architecture indéterminée + # ne doit pas faire disparaître silencieusement Android Studio. + self._qemu_tools_for( + desktop_tools, self._qemu_vm_arch(name) or "amd64", desktop + ), ) ssh_opts = ( "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null " @@ -5762,6 +6267,18 @@ class TODO: print( f" {t('VM type:')} {t('Graphical (server + desktop):')} {label}" ) + tools = spec.get("desktop_tools") or () + if tools: + # Les Go sont dits ici parce que c'est le dernier écran avant de + # créer les disques : un IDE de plus, c'est un disque plus grand, + # et cette page est celle qu'on relit pour s'en apercevoir. + named = ", ".join( + f"{t(self._QEMU_DESKTOP_TOOLS[k]['label'])} " + f"(+{self._QEMU_DESKTOP_TOOLS[k]['disk_gb']} Go)" + for k in tools + if k in self._QEMU_DESKTOP_TOOLS + ) + print(f" {t('Development tools:')} {named}") prov = spec.get("python_provider") if prov: print(f" {t('Python interpreter:')} {prov}") @@ -5787,6 +6304,9 @@ class TODO: timezone=None, locale=None, desktop=False, + prod=False, + install_cmd="", + desktop_tools=(), ): """Construit la commande deploy_qemu.py d'UNE VM (utilisée pour l'aperçu dry-run ET le déploiement réel).""" @@ -5822,6 +6342,15 @@ class TODO: parts += ["--locale", locale] if desktop: parts.append("--desktop") + # Guide affiché à la connexion SSH de la VM : dans la langue du menu, et + # avec la section ERPLibre seulement là où ERPLibre sera installé — une + # VM déployée nue n'annonce pas un dépôt qui n'existe pas. + parts += ["--lang", get_lang()] + if branch: + parts += ["--erplibre-dir", self._qemu_guide_dir(prod)] + target = self._qemu_make_target(install_cmd) + if target: + parts += ["--erplibre-make", target] extra = 0 if branch: # ERPLibre dépasse le minimum : +5 Go de disque. @@ -5830,6 +6359,11 @@ class TODO: # GNOME et ses dépendances pèsent autant qu'ERPLibre : sans cette # marge, le disque se remplit en pleine installation du bureau. extra += self.QEMU_DESKTOP_EXTRA_DISK_GB + # Les IDE pèsent plus lourd que tout le reste : PyCharm et Android + # Studio, c'est l'archive téléchargée PUIS son contenu déplié. Compté + # ici plutôt qu'au petit bonheur, sinon l'installation se termine sur un + # disque plein après une heure. + extra += self._qemu_tools_disk_gb(desktop_tools, arch, desktop) if extra: bigger = self._parse_disk_gb(disk) + extra parts += ["--disk-size", f"{bigger}G"] @@ -5858,7 +6392,17 @@ class TODO: locale=spec.get("locale"), # Le type suit la VM. Repli sur la valeur de spec pour la CLI, # qui ne pose la question qu'une fois pour tout le parc. - desktop=bool(vm.get("desktop", spec.get("desktop"))), + # + # La SAVEUR, et non un booléen : les extensions GNOME n'ont pas de + # sens sous Cinnamon, et c'est ici que se calcule la place disque + # des outils. « --desktop » ne regarde que la vérité de la valeur, + # une chaîne non vide lui va aussi bien. + desktop=vm.get("desktop", spec.get("desktop")) or "", + # Les deux servent au guide de connexion : où ERPLibre sera posé, et + # quelle cible make le remettra à jour. + prod=bool(install and install.get("prod")), + install_cmd=(install or {}).get("cmd") or "", + desktop_tools=spec.get("desktop_tools") or (), ) # ---------------------------------------------------------------- # @@ -6102,6 +6646,16 @@ class TODO: self._qemu_host_timezone() ), "snap_distros": self.QEMU_SNAP_DISTROS, + "desktop_tools": self._qemu_desktop_tool_choices(), + "desktop_tool_disk": { + k: v["disk_gb"] for k, v in self._QEMU_DESKTOP_TOOLS.items() + }, + "desktop_tool_arches": { + k: v["arches"] for k, v in self._QEMU_DESKTOP_TOOLS.items() + }, + "desktop_tool_desktops": { + k: v["desktops"] for k, v in self._QEMU_DESKTOP_TOOLS.items() + }, "desktop_suffixes": self._qemu_desktop_suffixes(), "desktops": [ (k, v["label"]) for k, v in self._QEMU_DESKTOP.items() @@ -6417,6 +6971,43 @@ class TODO: return self.QEMU_APP_STORES[int(answer) - 1][0] return "deb" + def _qemu_ask_desktop_tools(self, vms): + """Outils de développement des VM graphiques : liste à cocher. + + Ne se pose QUE si au moins une VM porte un bureau — sur un serveur, un + IDE graphique n'a rien pour s'afficher. La réponse vaut pour tout le + parc et sera filtrée machine par machine. + + Saisie par numéros séparés par des espaces ou des virgules, « tous » + pour tout cocher, vide pour rien : trois questions oui/non de plus + alourdiraient une séquence d'invites déjà longue.""" + graphical = [vm for vm in vms if vm.get("desktop")] + if not graphical: + return () + choices = self._qemu_desktop_tool_choices() + print(f"\n{t('Development tools for the graphical VMs:')}") + for i, (_key, label, hint) in enumerate(choices, 1): + print(f" [{i}] {label} — {hint}") + gb = ", ".join( + f"{label} +{self._QEMU_DESKTOP_TOOLS[key]['disk_gb']} Go" + for key, label, _hint in choices + ) + print(f" {t('Disk needed:')} {gb}") + answer = input( + f"{t('Numbers separated by spaces, [all], blank = none:')} " + ).strip() + if not answer: + return () + if answer.lower() in ("all", "tous", "toutes", "*"): + return tuple(key for key, _l, _h in choices) + picked = [] + for token in answer.replace(",", " ").split(): + if token.isdigit() and 1 <= int(token) <= len(choices): + key = choices[int(token) - 1][0] + if key not in picked: + picked.append(key) + return tuple(picked) + def _qemu_ask_python_provider(self, arches): """mise (CPython précompilé) ou pyenv (compilation). @@ -6513,6 +7104,7 @@ class TODO: _vm.setdefault("desktop", desktop) _vm["name"] = vm_name(_vm["name"], _vm.get("desktop"), suffixes) app_store = self._qemu_ask_app_store(vms) + desktop_tools = self._qemu_ask_desktop_tools(vms) python_provider = self._qemu_ask_python_provider( [vm["arch"] for vm in vms] ) @@ -6593,6 +7185,7 @@ class TODO: "timezone": timezone, "locale": locale, "desktop": desktop, + "desktop_tools": desktop_tools, "python_provider": python_provider, "app_store": app_store, "install": install, @@ -6676,6 +7269,9 @@ class TODO: desktop = next((d for d in desktop_map.values() if d), "") python_provider = spec.get("python_provider") or "" app_store = spec.get("app_store") or "deb" + # Outils de développement : cochés une fois pour tout le parc, puis + # filtrés machine par machine (architecture, saveur de bureau). + desktop_tools = tuple(spec.get("desktop_tools") or ()) # Branche par VM : « » sur une VM veut dire « celle du formulaire ». branch_map = { vm["name"]: (vm.get("branch") or install_branch or "") @@ -6767,6 +7363,7 @@ class TODO: desktop=desktop_map, python_provider=python_provider, app_store=app_store, + desktop_tools=desktop_tools, ) else: print( @@ -6784,6 +7381,7 @@ class TODO: desktop=desktop_map.get(name, ""), python_provider=python_provider, app_store=app_store, + desktop_tools=desktop_tools, ) # Sommaire TOTAL (déploiement + résolution IP + ssh_config + install diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 6983f5c..bdec2d7 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -7557,6 +7557,122 @@ TRANSLATIONS = { "en": "no recipient: nothing was sent", }, "mail_err_send_refused": {"fr": "envoi refusé :", "en": "send refused:"}, + # QEMU — outils de développement des VM graphiques + "Python IDE, ERPLibre project pre-configured": { + "fr": "IDE Python, projet ERPLibre pré-configuré", + "en": "Python IDE, ERPLibre project pre-configured", + }, + "ERPLibre mobile development (x86_64 only)": { + "fr": "développement mobile ERPLibre (x86_64 seulement)", + "en": "ERPLibre mobile development (x86_64 only)", + }, + "GNOME extensions": { + "fr": "Extensions GNOME", + "en": "GNOME extensions", + }, + "suggested extensions + extension manager": { + "fr": "extensions suggérées + gestionnaire d'extensions", + "en": "suggested extensions + extension manager", + }, + "Development tools for the graphical VMs:": { + "fr": "Outils de développement des VM graphiques :", + "en": "Development tools for the graphical VMs:", + }, + "Development tools (graphical VMs):": { + "fr": "Outils de développement (VM graphiques) :", + "en": "Development tools (graphical VMs):", + }, + "Development tools:": { + "fr": "Outils de développement :", + "en": "Development tools:", + }, + "Disk needed:": {"fr": "Disque nécessaire :", "en": "Disk needed:"}, + "Numbers separated by spaces, [all], blank = none:": { + "fr": "Numéros séparés par des espaces, [tous], vide = aucun :", + "en": "Numbers separated by spaces, [all], blank = none:", + }, + "No graphical VM: these tools are not installed.": { + "fr": "Aucune VM graphique : ces outils ne seront pas installés.", + "en": "No graphical VM: these tools are not installed.", + }, + "Partly skipped (arch or desktop):": { + "fr": "En partie écartés (architecture ou bureau) :", + "en": "Partly skipped (arch or desktop):", + }, + "Installing PyCharm (long)": { + "fr": "Installation de PyCharm (long)", + "en": "Installing PyCharm (long)", + }, + "no JetBrains build for": { + "fr": "aucune archive JetBrains pour", + "en": "no JetBrains build for", + }, + "PyCharm: project config skipped": { + "fr": "PyCharm : configuration du projet non écrite", + "en": "PyCharm: project config skipped", + }, + "PyCharm installed:": { + "fr": "PyCharm installé :", + "en": "PyCharm installed:", + }, + "PyCharm not installed (see above)": { + "fr": "PyCharm non installé (voir ci-dessus)", + "en": "PyCharm not installed (see above)", + }, + "Installing Android Studio (long)": { + "fr": "Installation d'Android Studio (long)", + "en": "Installing Android Studio (long)", + }, + "Android Studio: Google publishes x86_64 only": { + "fr": "Android Studio : Google ne publie que du x86_64", + "en": "Android Studio: Google publishes x86_64 only", + }, + "Android Studio installed:": { + "fr": "Android Studio installé :", + "en": "Android Studio installed:", + }, + "Android Studio not installed (see above)": { + "fr": "Android Studio non installé (voir ci-dessus)", + "en": "Android Studio not installed (see above)", + }, + "no nested KVM: the emulator will not run": { + "fr": "pas de KVM imbriqué : l'émulateur ne démarrera pas", + "en": "no nested KVM: the emulator will not run", + }, + "Suggested GNOME extensions": { + "fr": "Extensions GNOME suggérées", + "en": "Suggested GNOME extensions", + }, + "not in the repos, skipped:": { + "fr": "absent des dépôts, ignoré :", + "en": "not in the repos, skipped:", + }, + "Enable them from Extension Manager, or:": { + "fr": "À activer depuis le gestionnaire d'extensions, ou :", + "en": "Enable them from Extension Manager, or:", + }, + "GNOME Shell not found, site extensions skipped": { + "fr": "GNOME Shell introuvable, extensions du site ignorées", + "en": "GNOME Shell not found, site extensions skipped", + }, + "installed and enabled:": { + "fr": "installée et activée :", + "en": "installed and enabled:", + }, + "not available for this GNOME, skipped:": { + "fr": "indisponible pour ce GNOME, ignorée :", + "en": "not available for this GNOME, skipped:", + }, + "log out and back in to load them": { + "fr": "se déconnecter puis se reconnecter pour les charger", + "en": "log out and back in to load them", + }, + "open the project once and close PyCharm; the .idea it writes is what the install configures": { + "fr": "ouvrir le projet une fois puis fermer PyCharm : le .idea qu'il écrit est ce que l'installation configure", + "en": "open the project once and close PyCharm; the .idea it writes is what the install configures", + }, + "command": {"fr": "commande", "en": "command"}, + "project": {"fr": "projet", "en": "project"}, } diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py new file mode 100644 index 0000000..b28584c --- /dev/null +++ b/test/test_qemu_desktop_tools.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Outils de développement des VM graphiques : filtrage, disque, commande. + +Ce qui se vérifie ici sans VM : qu'un outil demandé pour tout le parc n'atterrit +que sur les machines qui peuvent le recevoir, que la place disque annoncée suit +ce filtrage, et qu'un outil qui échoue ne fait pas tomber l'installation +d'ERPLibre avec lui — celle-ci ayant duré une heure. +""" + +import subprocess +import sys +import unittest + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 + + +class TestToolFiltering(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + self.all = tuple(TODO._QEMU_DESKTOP_TOOLS) + + def test_a_server_gets_no_graphical_tool(self): + """Un IDE sans bureau n'a rien pour s'afficher.""" + self.assertEqual([], self.todo._qemu_tools_for(self.all, "amd64", "")) + + def test_android_studio_is_x86_64_only(self): + """Google ne publie aucune archive Linux aarch64 : toutes les variantes + de l'URL rendent 404, et product-info.json ne déclare que Linux/amd64. + """ + self.assertIn( + "android", self.todo._qemu_tools_for(self.all, "amd64", "gnome") + ) + for arch in ("arm64", "s390x"): + self.assertNotIn( + "android", + self.todo._qemu_tools_for(self.all, arch, "gnome"), + arch, + ) + + def test_pycharm_follows_jetbrains_two_architectures(self): + for arch in ("amd64", "arm64"): + self.assertIn( + "pycharm", + self.todo._qemu_tools_for(self.all, arch, "gnome"), + arch, + ) + self.assertNotIn( + "pycharm", self.todo._qemu_tools_for(self.all, "s390x", "gnome") + ) + + def test_gnome_extensions_only_under_gnome(self): + self.assertIn( + "gnome_ext", self.todo._qemu_tools_for(self.all, "amd64", "gnome") + ) + self.assertNotIn( + "gnome_ext", + self.todo._qemu_tools_for(self.all, "amd64", "cinnamon"), + ) + + def test_unknown_key_is_ignored(self): + self.assertEqual( + [], self.todo._qemu_tools_for(("nope",), "amd64", "gnome") + ) + + +class TestToolDisk(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + self.all = tuple(TODO._QEMU_DESKTOP_TOOLS) + + def test_disk_follows_the_filtering(self): + """Une VM qui ne recevra pas Android Studio ne doit pas se voir gonfler + son disque de ses 8 Go.""" + full = self.todo._qemu_tools_disk_gb(self.all, "amd64", "gnome") + arm = self.todo._qemu_tools_disk_gb(self.all, "arm64", "gnome") + self.assertEqual( + full - arm, TODO._QEMU_DESKTOP_TOOLS["android"]["disk_gb"] + ) + + def test_a_server_costs_nothing(self): + self.assertEqual( + 0, self.todo._qemu_tools_disk_gb(self.all, "amd64", "") + ) + + def test_the_deploy_command_grows_the_disk(self): + """Le disque demandé à deploy_qemu.py doit inclure les outils : c'est + la seule valeur qui compte, celle du qcow2 réellement créé.""" + spec = { + "ssh_key": "", + "desktop": "gnome", + "desktop_tools": self.all, + "install": { + "branch": "develop", + "prod": False, + "cmd": "make install_os && make install_odoo_18", + "label": "x", + "monitor": True, + }, + } + vm = { + "distro": "ubuntu", + "version": "24.04", + "arch": "amd64", + "name": "v", + "ram": 3072, + "vcpus": 4, + "disk": "20G", + "desktop": "gnome", + } + parts = self.todo._qemu_deploy_parts_for(vm, spec, dry_run=True) + size = parts[parts.index("--disk-size") + 1] + expected = ( + 20 + + TODO.ERPLIBRE_EXTRA_DISK_GB + + TODO.QEMU_DESKTOP_EXTRA_DISK_GB + + sum(s["disk_gb"] for s in TODO._QEMU_DESKTOP_TOOLS.values()) + ) + self.assertEqual(f"{expected}G", size) + + +class TestToolRemoteCommand(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + self.all = tuple(TODO._QEMU_DESKTOP_TOOLS) + + def _sh_ok(self, script): + """Le shell accepte-t-il ce script ? « bash -n » ne l'exécute pas.""" + return subprocess.run( + ["bash", "-n"], input=script, text=True, capture_output=True + ) + + def test_every_combination_is_valid_shell(self): + combos = [ + (), + ("pycharm",), + ("android",), + ("gnome_ext",), + self.all, + ] + for tools in combos: + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "gnome", "mise", "deb", tools + ) + res = self._sh_ok(script) + self.assertEqual(0, res.returncode, f"{tools} : {res.stderr}") + + def test_tools_come_before_the_clone_and_the_make(self): + """L'ordre est ce qui fait marcher la configuration du projet : PyCharm + écrit le .idea du dépôt en l'ouvrant une fois, et c'est l'installation + qui, ENSUITE, y lance pycharm_configuration.py.""" + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "gnome", "", "deb", ("pycharm",) + ) + self.assertLess(script.index("PyCharm"), script.index("git clone")) + self.assertLess( + script.index("PyCharm"), script.index("make install_os") + ) + + def test_the_install_owns_the_project_configuration(self): + """update_env_version.pycharm_update() lance déjà le script, et sait se + taire sans .idea. Doubler l'appel n'écrivait qu'une erreur dans le + journal d'une VM neuve.""" + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "gnome", "", "deb", ("pycharm",) + ) + self.assertNotIn("pycharm_configuration", script) + + def test_a_failing_tool_never_masks_a_failing_install(self): + """Le code de sortie doit rester celui de l'installation : c'est lui que + lit le tableau de bord pour dire ✅ ou ❌.""" + script = ( + "set -e\n" + "curl() { return 7; }; sudo() { return 7; }\n" + + self.todo._qemu_tools_remote_cmd(("pycharm", "android"), False) + + "\nexit 3\n" # l'installation qui suit, en échec + ) + res = subprocess.run( + ["bash", "-c", script], capture_output=True, text=True + ) + self.assertEqual(3, res.returncode, res.stdout + res.stderr) + + def test_a_failing_tool_still_returns_zero(self): + """Le bloc d'outils est lui-même gardé : PyCharm indisponible ne doit + pas transformer une installation réussie en échec.""" + script = "set -e\n" + self.todo._qemu_tools_remote_cmd(self.all, False) + # Tout ce qui pourrait réussir est neutralisé : ni réseau, ni sudo. + stub = ( + "curl() { return 7; }; sudo() { return 7; }; tar() { return 7; }; " + "export -f curl sudo tar 2>/dev/null || true\n" + ) + res = subprocess.run( + ["bash", "-c", stub + script], capture_output=True, text=True + ) + self.assertEqual(0, res.returncode, res.stderr) + self.assertIn("PyCharm", res.stdout) + + +class TestToolDiscoverability(unittest.TestCase): + """Ce qui est installé doit pouvoir être TROUVÉ. Vécu : Android Studio + posé dans /opt, lanceur nommé « studio », et l'utilisateur conclut à un + échec parce que « android-studio » ne répond pas.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + + def test_android_studio_answers_to_both_names(self): + cmd = self.todo._qemu_android_studio_remote_cmd() + self.assertIn("/usr/local/bin/studio;", cmd) + self.assertIn("/usr/local/bin/android-studio;", cmd) + + def test_the_log_says_where_it_landed(self): + pycharm = self.todo._qemu_pycharm_remote_cmd() + android = self.todo._qemu_android_studio_remote_cmd() + self.assertIn("/opt/pycharm", pycharm) + self.assertIn("/opt/android-studio", android) + + def test_pycharm_says_what_creates_the_project(self): + """Le .idea n'existe qu'après une première ouverture de PyCharm : le + journal le dit, plutôt que de laisser croire à un échec.""" + cmd = self.todo._qemu_pycharm_remote_cmd() + self.assertIn(".idea", cmd) + + +class TestGnomeSiteExtensions(unittest.TestCase): + """Extensions posées depuis extensions.gnome.org, par leur UUID.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.block = self.todo._qemu_gnome_ext_remote_cmd() + + def test_the_three_requested_extensions_are_there(self): + for uuid in ( + "gTile@vibou", + "freon@UshakovVasilii_Github.yahoo.com", + "tracker@aliakseiz.github.com", + ): + self.assertIn(uuid, self.block, uuid) + + def test_the_archive_follows_the_running_gnome(self): + """Le site sert une archive DIFFERENTE selon la version demandée — + gTile v59 en GNOME 46, v62 en 48 : figer une URL poserait une archive + faite pour une autre version.""" + self.assertIn("shell_version=$sv", self.block) + self.assertIn("gnome-shell --version", self.block) + + def test_valid_shell_and_never_fails_the_install(self): + script = "set -e\n" + self.block + self.assertEqual( + 0, + subprocess.run( + ["bash", "-n"], input=script, text=True, capture_output=True + ).returncode, + ) + # Sans gnome-shell dans le PATH, le bloc doit se taire proprement. + res = subprocess.run( + ["bash", "-c", "PATH=/nonexistent; " + script], + capture_output=True, + text=True, + ) + self.assertEqual(0, res.returncode, res.stderr) + + def test_a_session_bus_is_provided_for_dconf(self): + """Un « ssh hote commande » n'a pas de bus de session : sans lui, + l'activation ne peut rien ecrire dans dconf.""" + self.assertIn("dbus-run-session", self.block) + + +if __name__ == "__main__": + unittest.main() From 6efe16a99156d0d77f938c7eb76ea1f94ee45dd3 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 17 Aug 2026 23:22:38 -0400 Subject: [PATCH 18/55] [FIX] todo qemu: open the PyCharm project without a screen, and unlicensed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two measurements, one after the other. The unified PyCharm that code=PCC&latest now serves stops on its licence: the log says NoValidIdeLicense then "Get licenses: request requires authentication", and no project is ever opened — so no .idea, so nothing for the install to configure. The Community line asks for no account and is still patched (2025.2.6.2 on 2026-07-29); it is resolved from the release feed, so no version is frozen here. That build then froze 1.3 s after startup: the trust dialog, invisible under Xvfb and waiting for a click. idea.trust.all.projects unblocks it. Checked on an Ubuntu 26.04 VM: .idea complete in 195 s, and pycharm_configuration.py writes its exclusions into erplibre.iml. --- FR --- Deux mesures, l'une après l'autre. Le PyCharm unifié que sert désormais code=PCC&latest s'arrête sur sa licence : le journal dit NoValidIdeLicense puis « Get licenses: request requires authentication », et aucun projet ne s'ouvre — donc pas de .idea, donc rien à configurer pour l'installation. La ligne Community ne demande aucun compte et reste corrigée (2025.2.6.2 le 2026-07-29) ; elle est résolue depuis le flux des versions, sans qu'aucun numéro ne soit figé ici. Ce build se figeait ensuite 1,3 s après le démarrage : la fenêtre de confiance, invisible sous Xvfb et attendant un clic. idea.trust.all.projects la lève. Vérifié sur une VM Ubuntu 26.04 : .idea complet en 195 s, et pycharm_configuration.py écrit ses exclusions dans erplibre.iml. Assisted-by: Claude Opus 5 --- script/qemu/README.base.md | 32 ++++-- script/qemu/README.fr.md | 16 ++- script/qemu/README.md | 16 ++- script/todo/todo.py | 171 ++++++++++++++++++++++++++++++-- script/todo/todo_i18n.py | 24 +++++ test/test_qemu_desktop_tools.py | 88 ++++++++++++++++ 6 files changed, 322 insertions(+), 25 deletions(-) diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md index 0ca8c1a..f99f1dd 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -263,8 +263,10 @@ download an image, list VMs and show a VM IP address — the menu asks for the parameters and builds the command for you. When a VM is graphical, the menu also offers a **check list of development -tools**: PyCharm (installed from the official JetBrains archive -into `/opt/pycharm`, its launcher opening the ERPLibre checkout), Android +tools**: PyCharm Community (installed from the official +JetBrains archive into `/opt/pycharm`, its launcher opening the ERPLibre +checkout — the Community line, because the unified 2025.3 build stops on a +licence screen and never opens a project), Android Studio (`/opt/android-studio`, command `studio` or `android-studio`; x86_64 only — Google publishes no Linux aarch64 build) and a set of suggested GNOME extensions. @@ -282,9 +284,13 @@ The tools are installed **before** the clone and the ERPLibre install, and the order matters: PyCharm writes the repository's `.idea/` the first time it opens the project, and the install that follows runs `pycharm_configuration.py` on it (`update_env_version.pycharm_update()`, -which skips silently when there is no `.idea` yet). So on a fresh VM: open -PyCharm once on `~/git/erplibre`, close it, then re-run -`make install_odoo_18`. +which skips silently when there is no `.idea` yet). That first open is automated: PyCharm runs once under +Xvfb — a virtual framebuffer inside the guest, so the orchestrating host +needs no graphics at all — with the trust, privacy and data-sharing dialogs +answered in advance. Measured on an Ubuntu 26.04 VM with 16 GB: `.idea/` is +written in 195 s, and the install then adds its exclusions to the `.iml`. +When Xvfb is unavailable or the IDE does not get there in five minutes, the +log says so and the install carries on. Each tool is filtered per VM — by architecture and by desktop flavour — and its disk cost is added to the plan before anything is created. @@ -351,8 +357,10 @@ l'IP d'une VM — le menu demande les paramètres et construit la commande pour vous. Quand une VM est graphique, le menu propose en plus une **liste à cocher -d'outils de développement** : PyCharm (posé depuis l'archive officielle -JetBrains dans `/opt/pycharm`, son lanceur ouvrant le dépôt ERPLibre), +d'outils de développement** : PyCharm Community (posé depuis l'archive +officielle JetBrains dans `/opt/pycharm`, son lanceur ouvrant le dépôt +ERPLibre — la ligne Community, car le build unifié 2025.3 s'arrête sur un +écran de licence et n'ouvre jamais de projet), Android Studio (`/opt/android-studio`, commande `studio` ou `android-studio` ; x86_64 seulement — Google ne publie aucune archive Linux aarch64) et un jeu @@ -373,8 +381,14 @@ Les outils sont posés **avant** le clone et l'installation d'ERPLibre, et l'ordre compte : PyCharm écrit le `.idea/` du dépôt à la première ouverture du projet, et l'installation qui suit y lance `pycharm_configuration.py` (`update_env_version.pycharm_update()`, qui se tait tant qu'il n'y a pas de -`.idea`). Sur une VM neuve : ouvrir PyCharm une fois sur `~/git/erplibre`, le -fermer, puis relancer `make install_odoo_18`. +`.idea`). Cette première ouverture est automatisée : PyCharm est lancé une fois sous +Xvfb — un serveur d'affichage virtuel DANS la VM invitée, si bien que l'hôte +qui orchestre n'a besoin d'aucune bibliothèque graphique — avec les fenêtres +de confiance, de confidentialité et de partage de données répondues d'avance. +Mesuré sur une VM Ubuntu 26.04 à 16 Go : le `.idea/` est écrit en 195 s, et +l'installation y ajoute ensuite ses exclusions dans le `.iml`. Sans Xvfb, ou +si l'IDE n'y arrive pas en cinq minutes, le journal le dit et l'installation +continue. Chaque outil est filtré VM par VM — architecture et saveur de bureau — et sa place disque s'ajoute au plan avant que rien ne soit créé. diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index 2d86503..fc9240c 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -147,8 +147,10 @@ l'IP d'une VM — le menu demande les paramètres et construit la commande pour vous. Quand une VM est graphique, le menu propose en plus une **liste à cocher -d'outils de développement** : PyCharm (posé depuis l'archive officielle -JetBrains dans `/opt/pycharm`, son lanceur ouvrant le dépôt ERPLibre), +d'outils de développement** : PyCharm Community (posé depuis l'archive +officielle JetBrains dans `/opt/pycharm`, son lanceur ouvrant le dépôt +ERPLibre — la ligne Community, car le build unifié 2025.3 s'arrête sur un +écran de licence et n'ouvre jamais de projet), Android Studio (`/opt/android-studio`, commande `studio` ou `android-studio` ; x86_64 seulement — Google ne publie aucune archive Linux aarch64) et un jeu @@ -169,8 +171,14 @@ Les outils sont posés **avant** le clone et l'installation d'ERPLibre, et l'ordre compte : PyCharm écrit le `.idea/` du dépôt à la première ouverture du projet, et l'installation qui suit y lance `pycharm_configuration.py` (`update_env_version.pycharm_update()`, qui se tait tant qu'il n'y a pas de -`.idea`). Sur une VM neuve : ouvrir PyCharm une fois sur `~/git/erplibre`, le -fermer, puis relancer `make install_odoo_18`. +`.idea`). Cette première ouverture est automatisée : PyCharm est lancé une fois sous +Xvfb — un serveur d'affichage virtuel DANS la VM invitée, si bien que l'hôte +qui orchestre n'a besoin d'aucune bibliothèque graphique — avec les fenêtres +de confiance, de confidentialité et de partage de données répondues d'avance. +Mesuré sur une VM Ubuntu 26.04 à 16 Go : le `.idea/` est écrit en 195 s, et +l'installation y ajoute ensuite ses exclusions dans le `.iml`. Sans Xvfb, ou +si l'IDE n'y arrive pas en cinq minutes, le journal le dit et l'installation +continue. Chaque outil est filtré VM par VM — architecture et saveur de bureau — et sa place disque s'ajoute au plan avant que rien ne soit créé. diff --git a/script/qemu/README.md b/script/qemu/README.md index 76a2407..63a5eca 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -137,8 +137,10 @@ download an image, list VMs and show a VM IP address — the menu asks for the parameters and builds the command for you. When a VM is graphical, the menu also offers a **check list of development -tools**: PyCharm (installed from the official JetBrains archive -into `/opt/pycharm`, its launcher opening the ERPLibre checkout), Android +tools**: PyCharm Community (installed from the official +JetBrains archive into `/opt/pycharm`, its launcher opening the ERPLibre +checkout — the Community line, because the unified 2025.3 build stops on a +licence screen and never opens a project), Android Studio (`/opt/android-studio`, command `studio` or `android-studio`; x86_64 only — Google publishes no Linux aarch64 build) and a set of suggested GNOME extensions. @@ -156,9 +158,13 @@ The tools are installed **before** the clone and the ERPLibre install, and the order matters: PyCharm writes the repository's `.idea/` the first time it opens the project, and the install that follows runs `pycharm_configuration.py` on it (`update_env_version.pycharm_update()`, -which skips silently when there is no `.idea` yet). So on a fresh VM: open -PyCharm once on `~/git/erplibre`, close it, then re-run -`make install_odoo_18`. +which skips silently when there is no `.idea` yet). That first open is automated: PyCharm runs once under +Xvfb — a virtual framebuffer inside the guest, so the orchestrating host +needs no graphics at all — with the trust, privacy and data-sharing dialogs +answered in advance. Measured on an Ubuntu 26.04 VM with 16 GB: `.idea/` is +written in 195 s, and the install then adds its exclusions to the `.iml`. +When Xvfb is unavailable or the IDE does not get there in five minutes, the +log says so and the install carries on. Each tool is filtered per VM — by architecture and by desktop flavour — and its disk cost is added to the plan before anything is created. diff --git a/script/todo/todo.py b/script/todo/todo.py index 748f55a..3de71fc 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5098,10 +5098,25 @@ class TODO: # couvre les quatre gestionnaires (Arch l'a dans extra, Debian et Ubuntu ne # l'ont qu'en snap — coupé ici —, Fedora et openSUSE pas du tout). # - # L'URL ne porte AUCUN numéro de version : « code=PCC&latest » redirige vers - # la dernière stable. Vérifié, les deux : « distribution=linux » sort - # pycharm-2025.3.tar.gz et « distribution=linuxARM64 » son équivalent - # aarch64. Rien à mettre à jour dans ce dépôt quand JetBrains publie. + # La ligne COMMUNITY, et non le produit unifié. Mesuré dans une VM : + # « code=PCC&latest » sert maintenant pycharm-2025.3, le build unifié, qui + # s'arrête sur sa licence — son journal dit « NoValidIdeLicense » puis + # « Get licenses: request requires authentication », et le projet ne + # s'ouvre jamais. Aucune ouverture, donc aucun .idea, donc rien à + # configurer ensuite. Community ne demande aucun compte, et elle est + # toujours publiée et corrigée : 2025.2.6.2 date du 2026-07-29. + # + # Aucun numéro figé ici : on prend la plus récente archive + # « pycharm-community- » du flux officiel des versions, pour + # l'architecture de la VM. + _QEMU_PYCHARM_FEED = ( + "https://data.services.jetbrains.com/products/releases" + "?code=PCC&type=release" + ) + # Repli quand le flux est injoignable : la redirection « dernière version ». + # Elle sert le build unifié, donc on le DIT — l'utilisateur devra ouvrir un + # compte JetBrains, et mieux vaut l'apprendre dans le journal qu'au premier + # lancement. _QEMU_PYCHARM_URL = ( "https://download.jetbrains.com/product?code=PCC&latest&distribution=" ) @@ -5186,8 +5201,31 @@ class TODO: "else " # /var/tmp et non /tmp : sur Fedora et dérivés /tmp est un tmpfs, en # RAM — 1,2 Go d'archive y tueraient une VM de 3 Go. + # Le flux dit quelle archive Community prendre pour cette + # architecture. En python plutôt qu'en shell : il fait la requête, + # lit le JSON et rend une ligne — sans jq, absent des images cloud. + "url=$(python3 - \"$jb\" <<'ELPYJB'\n" + "import json, sys, urllib.request\n" + "key = sys.argv[1]\n" + "try:\n" + f' with urllib.request.urlopen("{self._QEMU_PYCHARM_FEED}",\n' + " timeout=30) as fh:\n" + " data = json.load(fh)\n" + "except Exception:\n" + " sys.exit(0)\n" + 'for rel in data.get("PCC", []):\n' + ' link = (rel.get("downloads") or {}).get(key, {}).get("link", "")\n' + ' if "pycharm-community-" in link:\n' + " print(link)\n" + " break\n" + "ELPYJB\n" + "); " + 'if [ -z "$url" ]; then ' + f'url="{self._QEMU_PYCHARM_URL}$jb"; ' + f'echo " {t("release feed unreachable: unified build, it will ask for a JetBrains account")}"; ' + "fi; " "tmp=$(mktemp -p /var/tmp pycharm-XXXX.tar.gz) && " - f'curl -fsSL "{self._QEMU_PYCHARM_URL}$jb" -o "$tmp" && ' + 'curl -fsSL "$url" -o "$tmp" && ' "sudo mkdir -p /opt/pycharm && " 'sudo tar -xzf "$tmp" -C /opt/pycharm --strip-components=1; ' 'rc=$?; rm -f "$tmp"; [ $rc -eq 0 ]; fi; } && { ' @@ -5213,6 +5251,117 @@ class TODO: f'}} || echo " ⚠ {t("PyCharm not installed (see above)")}"; ' ) + # Serveur X virtuel, par gestionnaire de paquets. Les noms ne se + # ressemblent pas d'une famille à l'autre — relevés dans chaque dépôt, pas + # devinés. + _QEMU_XVFB_PKG = { + "apt": "xvfb", + "dnf": "xorg-x11-server-Xvfb", + "zypper": "xorg-x11-server-Xvfb", + "pacman": "xorg-server-xvfb", + } + + # Attente maximale du .idea, en tours de 5 s — cinq minutes. Mesuré sur une + # VM Ubuntu 26.04 à 16 Go : le projet est écrit en 195 s, indexation du + # dépôt en cours. On n'attend donc PAS la fin de cette indexation, qui dure + # bien plus et dont personne n'a besoin ici : pycharm_configuration.py ne + # réclame que le .iml et misc.xml. + _QEMU_PYCHARM_OPEN_TRIES = 60 + + def _qemu_xvfb_install_cmd(self): + """Pose Xvfb avec le gestionnaire de paquets présent, sans bruit.""" + x = self._QEMU_XVFB_PKG + return ( + "if command -v apt-get >/dev/null 2>&1; then " + "sudo DEBIAN_FRONTEND=noninteractive apt-get " + f"-o DPkg::Lock::Timeout=600 install -y {x['apt']} " + ">/dev/null 2>&1 || true; " + "elif command -v dnf >/dev/null 2>&1; then " + f"sudo dnf install -y {x['dnf']} >/dev/null 2>&1 || true; " + "elif command -v zypper >/dev/null 2>&1; then " + "sudo zypper --non-interactive install --auto-agree-with-licenses " + f"{x['zypper']} >/dev/null 2>&1 || true; " + "elif command -v pacman >/dev/null 2>&1; then " + f"sudo pacman -S --needed --noconfirm {x['pacman']} " + ">/dev/null 2>&1 || true; fi; " + ) + + def _qemu_pycharm_project_cmd(self, prod=False): + """Crée le .idea/ du dépôt en ouvrant PyCharm une fois, sans écran. + + C'est PyCharm, et lui seul, qui écrit ce répertoire : ni le dépôt ni + pycharm_configuration.py ne savent le fabriquer — ce dernier exige un + .iml puis un misc.xml, et s'arrête sinon. Sans cette ouverture, l'étape + pycharm_update() de l'installation ne trouve rien à configurer. + + Xvfb parce que l'IDE réclame un affichage, même pour ouvrir un projet + et s'arrêter. Il tourne DANS la VM : l'hôte qui orchestre n'a besoin + d'aucune bibliothèque graphique, et rien ne transite par « ssh -X ». + + TROIS fenêtres bloqueraient une session où personne ne peut cliquer, et + chacune a été rencontrée avant d'être écartée : politique de + confidentialité, partage de données, et surtout « faites-vous confiance + à ce projet ? ». C'est celle-là qui figeait tout — le journal s'arrêtait + 1,3 s après le démarrage, sans jamais ouvrir le projet, et il a fallu + « idea.trust.all.projects » pour le débloquer. Le consentement, lui, est + écrit REFUSÉ : aucune statistique ne part. + + Mesuré sur une VM Ubuntu 26.04 à 16 Go : .idea complet en 195 s, et + pycharm_configuration.py écrit ensuite ses exclusions dans le .iml. + + Tout est gardé. Sans Xvfb, sans PyCharm, ou sans .idea au bout du + délai, on le dit et l'installation continue : elle n'en dépend pas, + elle en profite seulement. + """ + el_dir = self._qemu_install_dir(prod) + return ( + f'echo "== {t("Creating the PyCharm project (first open)")} =="; ' + "{ if ! command -v pycharm >/dev/null 2>&1; then " + f'echo " {t("PyCharm missing, step skipped")}"; false; ' + "else " + "command -v xvfb-run >/dev/null 2>&1 || { " + + self._qemu_xvfb_install_cmd() + + "}; " + "if ! command -v xvfb-run >/dev/null 2>&1; then " + f'echo " {t("no Xvfb here, open PyCharm by hand")}"; false; ' + "else " + # Réponses aux fenêtres de première ouverture. En python plutôt + # qu'en shell : l'horodatage en millisecondes et le « » de + # la propriété se passeraient mal de guillemets imbriqués. + "python3 - <<'ELPYC' || true\n" + "import pathlib, time\n" + "h = pathlib.Path.home()\n" + 'c = h / ".local/share/JetBrains/consentOptions"\n' + "c.mkdir(parents=True, exist_ok=True)\n" + '(c / "accepted").write_text(\n' + ' "rsch.send.usage.stat:1.1:0:%d\\n" % (time.time() * 1000)\n' + ")\n" + '(h / ".pycharm-headless.vmoptions").write_text(\n' + ' "-Djb.privacy.policy.text=\\n"\n' + ' "-Djb.consents.confirmation.enabled=false\\n"\n' + ' "-Didea.trust.all.projects=true\\n"\n' + ' "-Didea.suppress.statistics.report=true\\n"\n' + ")\n" + "ELPYC\n" + 'PYCHARM_VM_OPTIONS="$HOME/.pycharm-headless.vmoptions" ' + f"xvfb-run -a pycharm {el_dir} " + "> /tmp/pycharm-first-run.log 2>&1 & " + "pid=$!; ok=0; " + f"for i in $(seq 1 {self._QEMU_PYCHARM_OPEN_TRIES}); do " + f"if ls {el_dir}/.idea/*.iml >/dev/null 2>&1 && " + f"[ -f {el_dir}/.idea/misc.xml ]; then ok=1; break; fi; " + "sleep 5; done; " + # Cinq secondes de plus : les fichiers apparaissent PENDANT leur + # écriture, et un TERM à l'instant où misc.xml naît le tronquerait. + "sleep 5; kill -TERM $pid 2>/dev/null; " + "for i in $(seq 1 12); do kill -0 $pid 2>/dev/null || break; " + "sleep 5; done; kill -KILL $pid 2>/dev/null; " + '[ "$ok" = 1 ]; fi; fi; } && ' + f'echo " {t("project created, the install will configure it")}" ' + f'|| echo " ⚠ {t("no .idea: open PyCharm once, then")} ' + 'make pycharm_configure"; ' + ) + def _qemu_android_studio_remote_cmd(self): """Installe Android Studio, pour le développement mobile ERPLibre. @@ -5543,6 +5692,14 @@ class TODO: # correctifs de sécurité automatiques doivent rester actifs. no_auto_upgrade = self._qemu_no_auto_upgrade(prod, app_store) tools_cmd = self._qemu_tools_remote_cmd(tools, prod) + # Entre le clone et le make : PyCharm ouvre le dépôt une fois pour en + # écrire le .idea, que l'installation configurera juste après. Le + # groupe rend toujours 0 — l'étape est un bonus, pas une condition. + open_step = ( + f"{{ {self._qemu_pycharm_project_cmd(prod)} }} && " + if "pycharm" in (tools or ()) + else "" + ) return ( "set -e; " + self._qemu_cloud_init_wait() # Coupé AVANT les apt-get ci-dessous : sinon apt-daily peut reprendre @@ -5652,7 +5809,7 @@ class TODO: f"sudo git clone --branch {shlex.quote(branch)} " f"{self.ERPLIBRE_GIT_URL} /opt/erplibre; " "sudo chown -R $(id -un):$(id -gn) /opt/erplibre; fi; " - f"cd /opt/erplibre && {final_cmd}" + f"cd /opt/erplibre && {open_step}{final_cmd}" ) if prod else ( @@ -5660,7 +5817,7 @@ class TODO: "if [ ! -d ~/git/erplibre/.git ]; then " f"git clone --branch {shlex.quote(branch)} " f"{self.ERPLIBRE_GIT_URL} ~/git/erplibre; fi; " - f"cd ~/git/erplibre && {final_cmd}" + f"cd ~/git/erplibre && {open_step}{final_cmd}" ) ) ) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index bdec2d7..7ec8eee 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -7672,6 +7672,30 @@ TRANSLATIONS = { "en": "open the project once and close PyCharm; the .idea it writes is what the install configures", }, "command": {"fr": "commande", "en": "command"}, + "release feed unreachable: unified build, it will ask for a JetBrains account": { + "fr": "flux des versions injoignable : build unifié, il demandera un compte JetBrains", + "en": "release feed unreachable: unified build, it will ask for a JetBrains account", + }, + "Creating the PyCharm project (first open)": { + "fr": "Création du projet PyCharm (première ouverture)", + "en": "Creating the PyCharm project (first open)", + }, + "PyCharm missing, step skipped": { + "fr": "PyCharm absent, étape sautée", + "en": "PyCharm missing, step skipped", + }, + "no Xvfb here, open PyCharm by hand": { + "fr": "pas de Xvfb ici : ouvrir PyCharm à la main", + "en": "no Xvfb here, open PyCharm by hand", + }, + "project created, the install will configure it": { + "fr": "projet créé, l'installation va le configurer", + "en": "project created, the install will configure it", + }, + "no .idea: open PyCharm once, then": { + "fr": "pas de .idea : ouvrir PyCharm une fois, puis", + "en": "no .idea: open PyCharm once, then", + }, "project": {"fr": "projet", "en": "project"}, } diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index b28584c..bf37b60 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -224,6 +224,94 @@ class TestToolDiscoverability(unittest.TestCase): self.assertIn(".idea", cmd) +class TestPycharmCommunity(unittest.TestCase): + """PyCharm doit s'ouvrir sans compte : c'est toute la différence entre une + VM utilisable au premier démarrage et une VM qui demande une licence.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.cmd = self.todo._qemu_pycharm_remote_cmd() + + def test_the_community_line_is_what_is_looked_up(self): + """Mesuré dans une VM : le build unifié s'arrête sur + « NoValidIdeLicense » et n'ouvre jamais le projet.""" + self.assertIn("pycharm-community-", self.cmd) + self.assertIn("data.services.jetbrains.com", self.cmd) + + def test_no_version_is_frozen_in_the_repository(self): + """Le flux donne la plus récente : rien à mettre à jour ici quand + JetBrains publie un correctif.""" + self.assertNotIn("2025.2.6", self.cmd) + self.assertNotIn("pycharm-community-2", self.cmd) + + def test_both_architectures_are_asked_for(self): + self.assertIn("linuxARM64", self.cmd) + self.assertIn("jb=linux", self.cmd) + + def test_the_fallback_names_its_cost(self): + """Le repli sert le build unifié : le dire, plutôt que de le laisser + découvrir au premier lancement.""" + self.assertIn("code=PCC&latest", self.cmd) + self.assertIn("JetBrains", self.cmd) + + +class TestPycharmFirstOpen(unittest.TestCase): + """Ouverture sans écran, pour que le .idea existe avant l'installation.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.cmd = self.todo._qemu_pycharm_project_cmd() + + def test_it_runs_between_the_clone_and_the_make(self): + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "gnome", "", "deb", ("pycharm",) + ) + self.assertLess(script.index("git clone"), script.index("xvfb-run")) + self.assertLess( + script.index("xvfb-run"), script.index("make install_os") + ) + + def test_only_when_pycharm_was_asked_for(self): + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "gnome", "", "deb", ("android",) + ) + self.assertNotIn("xvfb-run", script) + + def test_the_first_run_dialogs_are_answered_in_advance(self): + """Sans réponse, la session attend un clic que personne ne donnera. + + Celle de la CONFIANCE est la plus coûteuse à rater : mesuré, le journal + s'arrête 1,3 s après le démarrage et le projet ne s'ouvre jamais.""" + self.assertIn("idea.trust.all.projects=true", self.cmd) + self.assertIn("jb.consents.confirmation.enabled=false", self.cmd) + self.assertIn("consentOptions", self.cmd) + # Le consentement est écrit REFUSÉ : aucune statistique ne part. + self.assertIn("rsch.send.usage.stat:1.1:0:", self.cmd) + + def test_xvfb_package_names_are_per_family(self): + """« xvfb » n'existe que chez Debian : ailleurs le paquet s'appelle + autrement, et un nom inventé ne s'installerait pas.""" + self.assertEqual("xvfb", TODO._QEMU_XVFB_PKG["apt"]) + self.assertEqual("xorg-x11-server-Xvfb", TODO._QEMU_XVFB_PKG["dnf"]) + self.assertEqual("xorg-server-xvfb", TODO._QEMU_XVFB_PKG["pacman"]) + + def test_it_gives_up_rather_than_hangs(self): + """Un IDE qui ne s'ouvre pas ne doit pas retenir l'installation : le + budget est borné et le processus tué.""" + self.assertIn("kill -TERM", self.cmd) + self.assertIn("kill -KILL", self.cmd) + self.assertIn(f"seq 1 {TODO._QEMU_PYCHARM_OPEN_TRIES}", self.cmd) + + def test_valid_shell(self): + res = subprocess.run( + ["bash", "-n"], + input="set -e\n" + self.cmd, + text=True, + capture_output=True, + ) + self.assertEqual(0, res.returncode, res.stderr) + + class TestGnomeSiteExtensions(unittest.TestCase): """Extensions posées depuis extensions.gnome.org, par leur UUID.""" From 5150cfde9c5dbe906b425d7c80483a83ffb3f39f Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 18 Aug 2026 00:50:08 -0400 Subject: [PATCH 19/55] [ADD] todo qemu: build and test the mobile app, and fail the VM if it breaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A VM was declared ready without anything proving it. The mobile option now adds the repository to the manifest (additive, so it rides along an Odoo 18 install), runs the mobile repository's own install-android.sh — licences accepted included — then npm ci, vite build, cap sync, gradlew assembleDebug and npm test. A failure fails the VM: the exit code reaches the dashboard. Two gaps in that upstream installer had to be filled: unzip and wget, which no cloud image ships, and the SDK platform — it installs android-34 while variables.gradle asks for compileSdk 36, so the number is read from the file rather than frozen. Gradle writes tens of megabytes and hundreds of harmless lines carrying the word error: that output goes to a file of its own, and the log gets the named cause instead. --- FR --- Une VM était déclarée prête sans que rien ne le prouve. L'option mobile ajoute maintenant le dépôt au manifeste (additif, donc il accompagne une installation Odoo 18), lance l'install-android.sh du dépôt mobile lui-même — licences acceptées comprises —, puis npm ci, vite build, cap sync, gradlew assembleDebug et npm test. Un échec fait échouer la VM : le code de sortie remonte au tableau de bord. Deux manques de cet installateur amont ont dû être comblés : unzip et wget, qu'aucune image cloud ne livre, et la plateforme SDK — il pose android-34 quand variables.gradle réclame compileSdk 36, d'où le chiffre lu dans le fichier plutôt que figé. Gradle écrit des dizaines de mégaoctets et des centaines de lignes anodines portant le mot error : cette sortie part dans un fichier à part, et le journal reçoit la cause nommée. Assisted-by: Claude Opus 5 --- script/qemu/README.base.md | 43 +++- script/qemu/README.fr.md | 22 +- script/qemu/README.md | 21 +- script/todo/qemu_deploy_form.py | 69 +++--- script/todo/todo.py | 360 ++++++++++++++++++++++++++------ script/todo/todo_i18n.py | 98 +++++++++ test/test_qemu_desktop_tools.py | 169 +++++++++++++-- 7 files changed, 661 insertions(+), 121 deletions(-) diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md index f99f1dd..87c9bcf 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -292,8 +292,25 @@ written in 195 s, and the install then adds its exclusions to the `.iml`. When Xvfb is unavailable or the IDE does not get there in five minutes, the log says so and the install carries on. -Each tool is filtered per VM — by architecture and by desktop flavour — and -its disk cost is added to the plan before anything is created. +A fourth one needs no desktop at all: **ERPLibre mobile (build)**. It adds +the mobile repository to the manifest (which is additive, so it coexists with +an Odoo 18 install), runs the repository's own `install-android.sh` — JDK 17, +command-line tools, SDK licences accepted, NDK, whisper.cpp and sentencepiece +— then builds: `npm ci`, `vite build`, `cap sync`, `gradlew assembleDebug`, +and finally `npm test`. **A failed build fails the VM**: the exit code reaches +the dashboard, and the log names the probable cause (disk full, missing SDK +platform, JDK/Gradle mismatch, unaccepted licences…) instead of leaving a +40 MB Gradle log to read. The heavy output goes to +`~/erplibre-mobile-build.log` inside the VM so the install log stays readable. + +It is bounded to apt-based distributions, because that upstream installer +starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio +— a plain server VM builds the APK — and when Android Studio is also ticked +they share one SDK through `ANDROID_HOME`. Without Android, the same app runs +in a browser: `npm start`. + +Each tool is filtered per VM — by architecture, desktop flavour and package +family — and its disk cost is added to the plan before anything is created. ## Main options @@ -390,8 +407,26 @@ l'installation y ajoute ensuite ses exclusions dans le `.iml`. Sans Xvfb, ou si l'IDE n'y arrive pas en cinq minutes, le journal le dit et l'installation continue. -Chaque outil est filtré VM par VM — architecture et saveur de bureau — et sa -place disque s'ajoute au plan avant que rien ne soit créé. +Un quatrième ne demande aucun bureau : **ERPLibre mobile (compilation)**. Il +ajoute le dépôt mobile au manifeste — additif, donc il cohabite avec une +installation Odoo 18 —, lance l'`install-android.sh` du dépôt lui-même (JDK 17, +outils en ligne de commande, licences SDK acceptées, NDK, whisper.cpp et +sentencepiece), puis compile : `npm ci`, `vite build`, `cap sync`, +`gradlew assembleDebug`, et enfin `npm test`. **Une compilation en échec fait +échouer la VM** : le code de sortie remonte au tableau de bord, et le journal +NOMME la cause probable (disque plein, plateforme SDK absente, JDK et Gradle +incompatibles, licences non acceptées…) au lieu de laisser 40 Mo de journal +Gradle à relire. Le détail va dans `~/erplibre-mobile-build.log`, dans la VM, +pour que le journal d'installation reste lisible. + +Il est borné aux distributions apt, parce que cet installateur amont commence +par `sudo apt install openjdk-17-jdk`. Il n'exige PAS Android Studio — une +simple VM serveur produit l'APK — et quand Android Studio est aussi coché, les +deux partagent un seul SDK via `ANDROID_HOME`. Sans Android, la même +application tourne dans un navigateur : `npm start`. + +Chaque outil est filtré VM par VM — architecture, saveur de bureau et famille +de paquets — et sa place disque s'ajoute au plan avant que rien ne soit créé. ## Principales options diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index fc9240c..de26940 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -180,8 +180,26 @@ l'installation y ajoute ensuite ses exclusions dans le `.iml`. Sans Xvfb, ou si l'IDE n'y arrive pas en cinq minutes, le journal le dit et l'installation continue. -Chaque outil est filtré VM par VM — architecture et saveur de bureau — et sa -place disque s'ajoute au plan avant que rien ne soit créé. +Un quatrième ne demande aucun bureau : **ERPLibre mobile (compilation)**. Il +ajoute le dépôt mobile au manifeste — additif, donc il cohabite avec une +installation Odoo 18 —, lance l'`install-android.sh` du dépôt lui-même (JDK 17, +outils en ligne de commande, licences SDK acceptées, NDK, whisper.cpp et +sentencepiece), puis compile : `npm ci`, `vite build`, `cap sync`, +`gradlew assembleDebug`, et enfin `npm test`. **Une compilation en échec fait +échouer la VM** : le code de sortie remonte au tableau de bord, et le journal +NOMME la cause probable (disque plein, plateforme SDK absente, JDK et Gradle +incompatibles, licences non acceptées…) au lieu de laisser 40 Mo de journal +Gradle à relire. Le détail va dans `~/erplibre-mobile-build.log`, dans la VM, +pour que le journal d'installation reste lisible. + +Il est borné aux distributions apt, parce que cet installateur amont commence +par `sudo apt install openjdk-17-jdk`. Il n'exige PAS Android Studio — une +simple VM serveur produit l'APK — et quand Android Studio est aussi coché, les +deux partagent un seul SDK via `ANDROID_HOME`. Sans Android, la même +application tourne dans un navigateur : `npm start`. + +Chaque outil est filtré VM par VM — architecture, saveur de bureau et famille +de paquets — et sa place disque s'ajoute au plan avant que rien ne soit créé. ## Principales options diff --git a/script/qemu/README.md b/script/qemu/README.md index 63a5eca..17c7622 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -166,8 +166,25 @@ written in 195 s, and the install then adds its exclusions to the `.iml`. When Xvfb is unavailable or the IDE does not get there in five minutes, the log says so and the install carries on. -Each tool is filtered per VM — by architecture and by desktop flavour — and -its disk cost is added to the plan before anything is created. +A fourth one needs no desktop at all: **ERPLibre mobile (build)**. It adds +the mobile repository to the manifest (which is additive, so it coexists with +an Odoo 18 install), runs the repository's own `install-android.sh` — JDK 17, +command-line tools, SDK licences accepted, NDK, whisper.cpp and sentencepiece +— then builds: `npm ci`, `vite build`, `cap sync`, `gradlew assembleDebug`, +and finally `npm test`. **A failed build fails the VM**: the exit code reaches +the dashboard, and the log names the probable cause (disk full, missing SDK +platform, JDK/Gradle mismatch, unaccepted licences…) instead of leaving a +40 MB Gradle log to read. The heavy output goes to +`~/erplibre-mobile-build.log` inside the VM so the install log stays readable. + +It is bounded to apt-based distributions, because that upstream installer +starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio +— a plain server VM builds the APK — and when Android Studio is also ticked +they share one SDK through `ANDROID_HOME`. Without Android, the same app runs +in a browser: `npm start`. + +Each tool is filtered per VM — by architecture, desktop flavour and package +family — and its disk cost is added to the plan before anything is created. ## Main options diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index 2e1e8f6..6e0d3f4 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -294,7 +294,7 @@ def build_spec(vms, domains, form): "ssh_key": form["ssh_key"], "timezone": form.get("timezone", ""), "desktop": form.get("desktop", ""), - "desktop_tools": tuple(form.get("desktop_tools") or ()), + "vm_tools": tuple(form.get("vm_tools") or ()), "python_provider": form.get("python_provider", ""), "app_store": form.get("app_store", "deb"), "install": form["install"], @@ -368,10 +368,13 @@ def run_deploy_form(ctx, run_app: bool = True): # Outils de développement d'une VM graphique : [(clé, libellé, indice)] et # leurs contraintes, toutes décrites dans todo.py — le formulaire ne fait # que les afficher et rendre les cases cochées. - desktop_tools = list(ctx.get("desktop_tools") or []) - tool_disk = dict(ctx.get("desktop_tool_disk") or {}) - tool_arches = dict(ctx.get("desktop_tool_arches") or {}) - tool_desktops = dict(ctx.get("desktop_tool_desktops") or {}) + vm_tools = list(ctx.get("vm_tools") or []) + tool_disk = dict(ctx.get("vm_tool_disk") or {}) + tool_arches = dict(ctx.get("vm_tool_arches") or {}) + tool_desktops = dict(ctx.get("vm_tool_desktops") or {}) + tool_needs_desktop = dict(ctx.get("vm_tool_needs_desktop") or {}) + tool_families = dict(ctx.get("vm_tool_families") or {}) + distro_family = dict(ctx.get("distro_family") or {}) # Architectures pour lesquelles mise publie un binaire. mise_arches = set(ctx.get("mise_arches") or ()) # [(clé, libellé)] des magasins d'applications, et les distributions qui @@ -663,14 +666,13 @@ def run_deploy_form(ctx, run_app: bool = True): for i, (_k, label) in enumerate(app_stores): yield RadioButton(label, value=i == 0) yield Static("", id="storewarn") - if desktop_tools: + if vm_tools: # Une case par outil, et non une liste déroulante : ils # sont indépendants, et chacun se prend ou se laisse. yield Static( - t("Development tools (graphical VMs):"), - classes="grouptitle", + t("Development tools:"), classes="grouptitle" ) - for key, label, hint in desktop_tools: + for key, label, hint in vm_tools: gb = tool_disk.get(key, 0) yield Checkbox( f"{label} +{gb} Go — {hint}", @@ -839,7 +841,7 @@ def run_deploy_form(ctx, run_app: bool = True): # Le bureau pèse sur le disque de la VM QUI LE PORTE, et d'elle # seule : un supplément commun mentait dès que les types # différaient d'une machine à l'autre. - tools = self._desktop_tools() + tools = self._vm_tools() for row in self.rows: if row["vm"].get("desktop"): row["disk_gb"] += desktop_disk @@ -866,10 +868,10 @@ def run_deploy_form(ctx, run_app: bool = True): self._render_store() self._render_tools() - def _desktop_tools(self): + def _vm_tools(self): """Clés des outils cochés, dans l'ordre de la liste.""" picked = [] - for key, _label, _hint in desktop_tools: + for key, _label, _hint in vm_tools: try: if self.query_one(f"#f_tool_{key}", Checkbox).value: picked.append(key) @@ -881,43 +883,46 @@ def run_deploy_form(ctx, run_app: bool = True): """Outils qu'une VM donnée recevra vraiment. Même filtre que todo.py côté déploiement : une VM ARM ne verra - jamais Android Studio, une VM Cinnamon jamais les extensions - GNOME, et un serveur aucun des trois.""" - if not vm.get("desktop"): - return [] + jamais Android Studio, une VM Cinnamon jamais les extensions GNOME, + un serveur aucun des IDE — mais un serveur reçoit bien la + compilation mobile, qui n'a rien à afficher, et une distribution + sans apt ne la reçoit pas, son installateur n'existant que là.""" out = [] for key in tools: arches = tool_arches.get(key) or () desks = tool_desktops.get(key) or () + fams = tool_families.get(key) or () + if tool_needs_desktop.get(key) and not vm.get("desktop"): + continue if arches and vm["arch"] not in arches: continue - if desks and vm["desktop"] not in desks: + if desks and vm.get("desktop") not in desks: + continue + if fams and distro_family.get(vm["distro"], "") not in fams: continue out.append(key) return out def _render_tools(self): - """Grise les cases sans VM graphique, et NOMME ce qui sera écarté. + """Grise chaque case qu'AUCUNE VM retenue ne peut recevoir, et + NOMME ce qui sera écarté. - Cocher Android Studio sur un parc ARM ne produit rien : le dire ici + Une case par outil, et non un blocage en bloc : sur un parc de + serveurs les IDE se grisent, la compilation mobile reste offerte. + Cocher Android Studio sur un parc ARM ne produit rien — le dire ici évite de le découvrir dans le journal d'installation.""" - if not desktop_tools: + if not vm_tools: return - graphical = [vm for vm in self.vms if vm.get("desktop")] - for key, _label, _hint in desktop_tools: - self.query_one(f"#f_tool_{key}", Checkbox).disabled = ( - not graphical + for key, _label, _hint in vm_tools: + usable = any( + self._tools_for_vm(vm, (key,)) for vm in self.vms ) - if not graphical: - self.query_one("#toolwarn", Static).update( - f" {t('No graphical VM: these tools are not installed.')}" - ) - return - picked = self._desktop_tools() + self.query_one(f"#f_tool_{key}", Checkbox).disabled = not usable + picked = self._vm_tools() skipped = sorted( { vm["name"] - for vm in graphical + for vm in self.vms for k in picked if k not in self._tools_for_vm(vm, picked) } @@ -1835,7 +1840,7 @@ def run_deploy_form(ctx, run_app: bool = True): or ctx.get("timezone") or "", "desktop": self._default_desktop(), - "desktop_tools": self._desktop_tools(), + "vm_tools": self._vm_tools(), "python_provider": self._python_provider(), "app_store": self._app_store(), "install": install, diff --git a/script/todo/todo.py b/script/todo/todo.py index 3de71fc..d7999e4 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5032,13 +5032,16 @@ class TODO: # QU'EN x86_64 (vérifié — toutes les variantes aarch64 de l'URL rendent 404, # et le product-info.json de l'archive ne déclare qu'une cible # « Linux/amd64 »). JetBrains, lui, publie bien une archive aarch64. - _QEMU_DESKTOP_TOOLS = { + _QEMU_VM_TOOLS = { "pycharm": { "label": "PyCharm", "hint": "Python IDE, opens the ERPLibre checkout", "disk_gb": 5, "arches": ("amd64", "arm64"), "desktops": (), + "needs_desktop": True, + "families": (), + "phase": "before", }, "android": { "label": "Android Studio", @@ -5046,6 +5049,9 @@ class TODO: "disk_gb": 8, "arches": ("amd64",), "desktops": (), + "needs_desktop": True, + "families": (), + "phase": "before", }, "gnome_ext": { "label": "GNOME extensions", @@ -5053,45 +5059,93 @@ class TODO: "disk_gb": 1, "arches": (), "desktops": ("gnome",), + "needs_desktop": True, + "families": (), + "phase": "before", + }, + # Le seul outil qui ne demande PAS de bureau : il compile, il n'affiche + # rien. Une VM serveur le prend, une VM graphique aussi — et sur + # celle-ci le SDK est partagé avec Android Studio plutôt que doublé. + # + # « families » le borne à apt, et ce n'est pas un choix : l'installateur + # du dépôt mobile, install-android.sh, commence par + # « sudo apt install openjdk-17-jdk ». Ailleurs il s'arrête là. Lever + # cette limite se fait dans CE script-là, pas ici. + # + # Disque : ~1,5 Go de SDK et plateformes, ~2,5 Go de NDK, whisper.cpp + # et sentencepiece clonés, node_modules, et les artefacts Gradle. + "mobile": { + "label": "ERPLibre mobile (build)", + "hint": "APK debug + Vitest, validates the VM", + "disk_gb": 12, + "arches": ("amd64",), + "desktops": (), + "needs_desktop": False, + "families": ("apt",), + # APRÈS l'installation : le build a besoin du dépôt mobile, que le + # manifeste ajoute, et du venv d'outils pour le synchroniser. + "phase": "after", }, } + # Famille de paquets de chaque distribution, pour borner un outil à ce qui + # sait l'installer. + _QEMU_DISTRO_FAMILY = { + "ubuntu": "apt", + "debian": "apt", + "fedora": "dnf", + "almalinux": "dnf", + "rocky": "dnf", + "opensuse": "zypper", + "arch": "pacman", + } + @classmethod - def _qemu_desktop_tool_choices(cls): + def _qemu_vm_tool_choices(cls): """[(clé, libellé, indice)] pour le formulaire et l'invite en ligne.""" return [ (key, t(spec["label"]), t(spec["hint"])) - for key, spec in cls._QEMU_DESKTOP_TOOLS.items() + for key, spec in cls._QEMU_VM_TOOLS.items() ] @classmethod - def _qemu_tools_for(cls, tools, arch, desktop): + def _qemu_tools_for(cls, tools, arch, desktop, distro="", phase=""): """Outils RÉELLEMENT applicables à cette VM. Un outil demandé pour tout le parc ne convient pas forcément à chaque - machine : Android Studio n'existe qu'en x86_64, et les extensions GNOME - n'ont pas de sens sous Cinnamon. Filtrer ici plutôt que dans la commande - distante évite d'annoncer une installation qui ne se fera pas.""" - if not desktop: - return [] + machine : Android Studio n'existe qu'en x86_64, les extensions GNOME + n'ont pas de sens sous Cinnamon, et la compilation mobile ne sait + s'installer que sur les distributions apt. Filtrer ici plutôt que dans + la commande distante évite d'annoncer une installation qui ne se fera + pas. + + `phase` restreint au moment d'exécution : « before » avant le clone, + « after » après l'installation. Vide, les deux sont rendus.""" out = [] for key in tools or (): - spec = cls._QEMU_DESKTOP_TOOLS.get(key) + spec = cls._QEMU_VM_TOOLS.get(key) if not spec: continue + if spec["needs_desktop"] and not desktop: + continue if spec["arches"] and arch not in spec["arches"]: continue if spec["desktops"] and desktop not in spec["desktops"]: continue + family = cls._QEMU_DISTRO_FAMILY.get(distro, "") + if spec["families"] and distro and family not in spec["families"]: + continue + if phase and spec["phase"] != phase: + continue out.append(key) return out @classmethod - def _qemu_tools_disk_gb(cls, tools, arch, desktop): + def _qemu_tools_disk_gb(cls, tools, arch, desktop, distro=""): """Go à ajouter au disque pour les outils applicables à cette VM.""" return sum( - cls._QEMU_DESKTOP_TOOLS[k]["disk_gb"] - for k in cls._qemu_tools_for(tools, arch, desktop) + cls._QEMU_VM_TOOLS[k]["disk_gb"] + for k in cls._qemu_tools_for(tools, arch, desktop, distro) ) # Archive officielle JetBrains, et non un paquet de distribution : aucun ne @@ -5555,16 +5609,164 @@ class TODO: + self._qemu_gnome_ext_site_cmd() ) - def _qemu_tools_remote_cmd(self, tools, prod=False): - """Bloc d'installation des outils cochés, dans l'ordre du plus utile au - plus lourd. Chacun se garde lui-même : aucun ne fait échouer les - autres, ni l'installation d'ERPLibre.""" + # Diagnostic de la compilation mobile : motif rencontré dans le journal + # détaillé -> cause nommée. Du plus précis au plus général, le premier qui + # correspond gagne. + # + # Cette liste est faite pour GRANDIR. Une compilation Android échoue de + # cent façons, et le journal fait des dizaines de mégaoctets : sans cette + # traduction, « la VM est rouge » n'apprend rien et il faut tout rouvrir. + # Chaque panne rencontrée sur une VM mérite d'y laisser sa ligne. + _QEMU_MOBILE_DIAG = ( + ("No space left on device", "disk full"), + ("Failed to find target with hash string", "SDK platform missing"), + ("SDK location not found", "SDK not found (ANDROID_HOME)"), + ("have not been accepted", "SDK licences not accepted"), + ("NDK not configured", "NDK missing"), + ("Unsupported class file major version", "JDK/Gradle mismatch"), + ("Could not determine java version", "JDK/Gradle mismatch"), + ( + "Could not resolve all files for configuration", + "Gradle dependency unreachable (network?)", + ), + ("npm ERR!", "npm dependencies"), + ("Test Files", "Vitest tests failed"), + ("FAILED", "Gradle task failed"), + ) + + def _qemu_mobile_diag_cmd(self): + """Fonction shell qui NOMME la cause d'un échec, à partir du journal. + + Un « la VM est rouge » n'apprend rien quand le journal fait des + dizaines de mégaoctets. On cherche donc les motifs connus, et à défaut + on montre les dernières lignes — c'est toujours mieux que rien.""" + lines = "".join( + f"grep -q '{pat}' \"$1\" && {{ " + f'echo " {t("probable cause:")} {t(cause)}"; return 0; }}; ' + for pat, cause in self._QEMU_MOBILE_DIAG + ) + return ( + "mdiag() { " + + lines + + f'echo " {t("no known pattern, last lines:")}"; ' + 'tail -12 "$1" | sed "s/^/ /"; }; ' + ) + + def _qemu_mobile_remote_cmd(self, prod=False): + """Installe et COMPILE l'application mobile, puis la teste. + + C'est la seule étape qui peut faire échouer la VM, et c'est voulu : une + machine dont l'application ne compile pas n'est pas une machine prête. + Le code de sortie remonte donc jusqu'au tableau de bord. + + Le dépôt mobile porte son propre installateur Android — JDK, outils en + ligne de commande, licences acceptées, plateformes, NDK, whisper.cpp et + sentencepiece. On l'appelle plutôt que de le réécrire : une seconde + implémentation dériverait de la première sans prévenir. Deux choses lui + manquent pourtant, et on les ajoute ici : + - unzip et wget, qu'il suppose présents et qu'aucune image cloud ne + livre ; + - la plateforme que le projet réclame VRAIMENT. Son installateur pose + android-34 quand android/variables.gradle demande compileSdk 36 ; + plutôt que de figer 36 ici, on lit le chiffre dans le fichier. + + L'étape est bornée à apt (voir _QEMU_VM_TOOLS) : cet installateur + commence par « sudo apt install openjdk-17-jdk » et s'arrête là + ailleurs. La lever se fait dans ce script-là, pas ici. + """ + el_dir = self._qemu_install_dir(prod) + return ( + f'echo "== {t("Building ERPLibre mobile (long)")} =="; ' + + self._qemu_mobile_diag_cmd() + + + # Le détail va dans un fichier À PART. Une compilation Gradle écrit + # des dizaines de milliers de lignes, dont des centaines portant le + # mot « error » sans qu'aucune ne soit une panne : les verser dans + # le journal d'installation rendrait son compteur d'erreurs + # inutilisable, et le diagnostic illisible. + 'M="$HOME/erplibre-mobile-build.log"; : > "$M"; ' + f'echo " {t("detailed log in the VM:")} $M"; ' + 'mstep() { lbl="$1"; shift; echo " -> $lbl"; ' + 'if sh -c "$*" >> "$M" 2>&1; then return 0; fi; ' + f'echo " ⚠ {t("FAILED:")} $lbl"; mdiag "$M"; return 1; }}; ' + # Le SDK vit dans $HOME/android, l'emplacement qu'emploie + # l'installateur du dépôt. Android Studio, s'il est là, le trouvera + # par ANDROID_HOME : un seul SDK sur la machine, pas deux. + 'export ANDROID_HOME="$HOME/android"; ' + 'export ANDROID_SDK_ROOT="$HOME/android"; ' + 'export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin' + ':$ANDROID_HOME/platform-tools"; ' + # ~/.bashrc n'est pas lu par un « ssh hôte commande » : ce que + # l'installateur y écrit ne sert qu'aux sessions futures, pas à la + # compilation qui suit immédiatement. + "export JAVA_HOME=$(dirname $(dirname $(readlink -f " + "$(command -v javac 2>/dev/null || command -v java 2>/dev/null) " + "2>/dev/null) 2>/dev/null) 2>/dev/null); " + f'mstep "{t("mobile repository (additive manifest)")}" ' + f"'cd {el_dir} && ./script/manifest/update_manifest_local_mobile.sh' && " + f'mstep "{t("prerequisites of the upstream installer")}" ' + "'sudo DEBIAN_FRONTEND=noninteractive apt-get " + "-o DPkg::Lock::Timeout=600 install -y unzip wget' && " + f'mstep "{t("Android SDK, licences, NDK")}" ' + f"'cd {el_dir}/mobile/erplibre_home_mobile && ./install-android.sh' && " + f'mstep "{t("SDK platform required by the project")}" ' + f"'cd {el_dir}/mobile/erplibre_home_mobile && " + 'v=$(sed -n "s/.*compileSdkVersion *= *\\([0-9]*\\).*/\\1/p" ' + 'android/variables.gradle) && [ -n "$v" ] && ' + 'yes | sdkmanager "platforms;android-$v" ' + '"build-tools;$v.0.0"\' && ' + f'mstep "{t("npm dependencies")}" ' + f"'cd {el_dir}/mobile/erplibre_home_mobile && npm ci' && " + f'mstep "{t("web bundle (vite build)")}" ' + f"'cd {el_dir}/mobile/erplibre_home_mobile && npm run build' && " + f'mstep "{t("native sync (capacitor)")}" ' + f"'cd {el_dir}/mobile/erplibre_home_mobile && npx cap sync android' && " + f'mstep "{t("debug APK (gradle)")}" ' + f"'cd {el_dir}/mobile/erplibre_home_mobile/android && " + "./gradlew --no-daemon assembleDebug' && " + f'mstep "{t("Vitest tests")}" ' + f"'cd {el_dir}/mobile/erplibre_home_mobile && npm test' && " + # L'APK est la preuve, pas le code de sortie de Gradle : une tâche + # peut rendre 0 sans avoir rien produit. + f"apk=$(ls {el_dir}/mobile/erplibre_home_mobile/android/app/build" + "/outputs/apk/debug/*.apk 2>/dev/null | head -1); " + 'if [ -n "$apk" ]; then ' + f'echo " ✅ {t("APK built:")} $apk"; ' + # Capacitor sert la même application dans un navigateur : sur une + # VM graphique, c'est la voie de déverminage sans Android ni + # émulateur. On la NOMME plutôt que d'imposer Chromium — sur + # Ubuntu il n'existe qu'en snap, et snapd est justement coupé sur + # ces VM. Le navigateur du bureau fait l'affaire. + # + # DANS la branche de succès, et c'est tout l'enjeu : placé après le + # « fi », cet echo devenait la dernière commande du bloc et rendait + # 0 — une VM sans APK repassait au vert. + f'echo " {t("browser debugging (no Android):")} ' + f"cd {el_dir}/mobile/erplibre_home_mobile " + '&& npm start"; else ' + f'echo " ⚠ {t("no APK produced")}"; false; fi; ' + ) + + def _qemu_tools_remote_cmd(self, tools, prod=False, phase="before"): + """Bloc des outils cochés pour cette PHASE, du plus utile au plus lourd. + + « before » : posé avant le clone. Chaque outil s'y garde lui-même — + aucun ne fait échouer les autres, ni l'installation d'ERPLibre. + + « after » : la compilation mobile, qui vient après l'installation dont + elle dépend, et qui elle NE se garde PAS. C'est le contrat demandé : une + VM dont l'application ne compile pas doit être rouge.""" blocks = { - "gnome_ext": self._qemu_gnome_ext_remote_cmd, - "pycharm": lambda: self._qemu_pycharm_remote_cmd(prod), - "android": self._qemu_android_studio_remote_cmd, + "gnome_ext": (self._qemu_gnome_ext_remote_cmd, "before"), + "pycharm": (lambda: self._qemu_pycharm_remote_cmd(prod), "before"), + "android": (self._qemu_android_studio_remote_cmd, "before"), + "mobile": (lambda: self._qemu_mobile_remote_cmd(prod), "after"), } - return "".join(blocks[k]() for k in blocks if k in (tools or ())) + return "".join( + fn() + for k, (fn, ph) in blocks.items() + if k in (tools or ()) and ph == phase + ) def _qemu_editor_pkg(self): """Paquet de l'éditeur de l'hôte, à installer dans la VM. @@ -5691,7 +5893,12 @@ class TODO: # apt pendant l'installation. En PROD on ne touche à rien : les # correctifs de sécurité automatiques doivent rester actifs. no_auto_upgrade = self._qemu_no_auto_upgrade(prod, app_store) - tools_cmd = self._qemu_tools_remote_cmd(tools, prod) + tools_cmd = self._qemu_tools_remote_cmd(tools, prod, "before") + # La compilation mobile vient APRÈS l'installation : elle a besoin du + # dépôt, du venv d'outils qui synchronise le manifeste, et de node que + # « make install_os » installe. Liée par « && » et NON gardée, pour que + # son échec soit celui de la VM. + after_cmd = self._qemu_tools_remote_cmd(tools, prod, "after") # Entre le clone et le make : PyCharm ouvre le dépôt une fois pour en # écrire le .idea, que l'installation configurera juste après. Le # groupe rend toujours 0 — l'étape est un bonus, pas une condition. @@ -5810,6 +6017,7 @@ class TODO: f"{self.ERPLIBRE_GIT_URL} /opt/erplibre; " "sudo chown -R $(id -un):$(id -gn) /opt/erplibre; fi; " f"cd /opt/erplibre && {open_step}{final_cmd}" + + (f" && {{ {after_cmd} }}" if after_cmd else "") ) if prod else ( @@ -5818,6 +6026,7 @@ class TODO: f"git clone --branch {shlex.quote(branch)} " f"{self.ERPLIBRE_GIT_URL} ~/git/erplibre; fi; " f"cd ~/git/erplibre && {open_step}{final_cmd}" + + (f" && {{ {after_cmd} }}" if after_cmd else "") ) ) ) @@ -5832,14 +6041,14 @@ class TODO: desktop=False, python_provider="", app_store="deb", - desktop_tools=(), + vm_tools=(), ): """Lance l'install ERPLibre en parallèle DÉTACHÉE sur les VM et ouvre le dashboard Textual. Quitter le dashboard n'arrête pas les installs. `ip_map` : IP déjà résolues (sinon on résout ici, EN PARALLÈLE). `final_cmd` : commande d'install selon le profil choisi. `prod` : install /opt/erplibre + service SELinux confiné. - `desktop_tools` : outils cochés pour tout le parc, filtrés machine par + `vm_tools` : outils cochés pour tout le parc, filtrés machine par machine (Android Studio n'existe qu'en x86_64, les extensions GNOME n'ont pas de sens sous Cinnamon).""" from script.todo.qemu_install_monitor import ( @@ -5894,7 +6103,7 @@ class TODO: # reste est commun : ils dépendent de l'architecture de la # machine et de sa saveur de bureau, que seule cette boucle # connaît. - if desk_map or branch_map or cmd_map or desktop_tools: + if desk_map or branch_map or cmd_map or vm_tools: # Le bureau de CETTE VM : sa saveur propre si la carte en # donne une, sinon celle du parc. Prendre « rien » quand la # carte est vide privait de bureau toute VM dont seule la @@ -5910,7 +6119,7 @@ class TODO: vm_desktop, python_provider, app_store, - self._qemu_tools_for(desktop_tools, a, vm_desktop), + self._qemu_tools_for(vm_tools, a, vm_desktop, d), ) vms.append(entry) else: @@ -5956,12 +6165,12 @@ class TODO: desktop=False, python_provider="", app_store="deb", - desktop_tools=(), + vm_tools=(), ): """Clone ERPLibre (branche donnée) dans la VM puis exécute la commande d'install du profil choisi (streamé). `ip` : IP déjà résolue ; `final_cmd` : commande d'install ; `prod` : /opt + SELinux confiné ; - `desktop_tools` : outils de développement cochés.""" + `vm_tools` : outils de développement cochés.""" if ip is None: ip = self._qemu_vm_ip(name) if not ip: @@ -5978,6 +6187,15 @@ class TODO: f"{t('SSH not reachable, ERPLibre install skipped.')}" ) return + # Distribution et architecture de CETTE VM : les outils s'y filtrent + # (Android Studio n'existe qu'en x86_64, la compilation mobile qu'en + # apt). Sans module lisible on ne filtre plus sur la distribution + # plutôt que d'écarter à tort. + try: + mod = self._qemu_import_module() + vm_distro, _v, vm_arch = self._qemu_vm_meta(name, mod) + except Exception: + vm_distro, vm_arch = "", self._qemu_vm_arch(name) remote = self._qemu_erplibre_remote_cmd( branch, final_cmd, @@ -5985,10 +6203,8 @@ class TODO: desktop, python_provider, app_store, - # « or amd64 » comme _qemu_vm_meta : une architecture indéterminée - # ne doit pas faire disparaître silencieusement Android Studio. self._qemu_tools_for( - desktop_tools, self._qemu_vm_arch(name) or "amd64", desktop + vm_tools, vm_arch or "amd64", desktop, vm_distro or "" ), ) ssh_opts = ( @@ -6424,16 +6640,16 @@ class TODO: print( f" {t('VM type:')} {t('Graphical (server + desktop):')} {label}" ) - tools = spec.get("desktop_tools") or () + tools = spec.get("vm_tools") or () if tools: # Les Go sont dits ici parce que c'est le dernier écran avant de # créer les disques : un IDE de plus, c'est un disque plus grand, # et cette page est celle qu'on relit pour s'en apercevoir. named = ", ".join( - f"{t(self._QEMU_DESKTOP_TOOLS[k]['label'])} " - f"(+{self._QEMU_DESKTOP_TOOLS[k]['disk_gb']} Go)" + f"{t(self._QEMU_VM_TOOLS[k]['label'])} " + f"(+{self._QEMU_VM_TOOLS[k]['disk_gb']} Go)" for k in tools - if k in self._QEMU_DESKTOP_TOOLS + if k in self._QEMU_VM_TOOLS ) print(f" {t('Development tools:')} {named}") prov = spec.get("python_provider") @@ -6463,7 +6679,7 @@ class TODO: desktop=False, prod=False, install_cmd="", - desktop_tools=(), + vm_tools=(), ): """Construit la commande deploy_qemu.py d'UNE VM (utilisée pour l'aperçu dry-run ET le déploiement réel).""" @@ -6520,7 +6736,7 @@ class TODO: # Studio, c'est l'archive téléchargée PUIS son contenu déplié. Compté # ici plutôt qu'au petit bonheur, sinon l'installation se termine sur un # disque plein après une heure. - extra += self._qemu_tools_disk_gb(desktop_tools, arch, desktop) + extra += self._qemu_tools_disk_gb(vm_tools, arch, desktop, d) if extra: bigger = self._parse_disk_gb(disk) + extra parts += ["--disk-size", f"{bigger}G"] @@ -6559,7 +6775,7 @@ class TODO: # quelle cible make le remettra à jour. prod=bool(install and install.get("prod")), install_cmd=(install or {}).get("cmd") or "", - desktop_tools=spec.get("desktop_tools") or (), + vm_tools=spec.get("vm_tools") or (), ) # ---------------------------------------------------------------- # @@ -6803,16 +7019,23 @@ class TODO: self._qemu_host_timezone() ), "snap_distros": self.QEMU_SNAP_DISTROS, - "desktop_tools": self._qemu_desktop_tool_choices(), - "desktop_tool_disk": { - k: v["disk_gb"] for k, v in self._QEMU_DESKTOP_TOOLS.items() + "vm_tools": self._qemu_vm_tool_choices(), + "vm_tool_disk": { + k: v["disk_gb"] for k, v in self._QEMU_VM_TOOLS.items() }, - "desktop_tool_arches": { - k: v["arches"] for k, v in self._QEMU_DESKTOP_TOOLS.items() + "vm_tool_arches": { + k: v["arches"] for k, v in self._QEMU_VM_TOOLS.items() }, - "desktop_tool_desktops": { - k: v["desktops"] for k, v in self._QEMU_DESKTOP_TOOLS.items() + "vm_tool_desktops": { + k: v["desktops"] for k, v in self._QEMU_VM_TOOLS.items() }, + "vm_tool_needs_desktop": { + k: v["needs_desktop"] for k, v in self._QEMU_VM_TOOLS.items() + }, + "vm_tool_families": { + k: v["families"] for k, v in self._QEMU_VM_TOOLS.items() + }, + "distro_family": dict(self._QEMU_DISTRO_FAMILY), "desktop_suffixes": self._qemu_desktop_suffixes(), "desktops": [ (k, v["label"]) for k, v in self._QEMU_DESKTOP.items() @@ -7128,27 +7351,44 @@ class TODO: return self.QEMU_APP_STORES[int(answer) - 1][0] return "deb" - def _qemu_ask_desktop_tools(self, vms): + def _qemu_ask_vm_tools(self, vms): """Outils de développement des VM graphiques : liste à cocher. - Ne se pose QUE si au moins une VM porte un bureau — sur un serveur, un - IDE graphique n'a rien pour s'afficher. La réponse vaut pour tout le - parc et sera filtrée machine par machine. + Ne montre que ce qu'au moins une VM du parc peut recevoir : les IDE + graphiques disparaissent d'un parc de serveurs, où ils n'auraient rien + pour s'afficher, et la compilation mobile reste offerte — elle compile, + elle n'affiche pas. La réponse vaut pour tout le parc et sera filtrée + machine par machine. Saisie par numéros séparés par des espaces ou des virgules, « tous » - pour tout cocher, vide pour rien : trois questions oui/non de plus + pour tout cocher, vide pour rien : quatre questions oui/non de plus alourdiraient une séquence d'invites déjà longue.""" - graphical = [vm for vm in vms if vm.get("desktop")] - if not graphical: + choices = [ + c + for c in self._qemu_vm_tool_choices() + if any( + self._qemu_tools_for( + (c[0],), + vm.get("arch", "amd64"), + vm.get("desktop", ""), + vm.get("distro", ""), + ) + for vm in vms + ) + ] + if not choices: return () - choices = self._qemu_desktop_tool_choices() - print(f"\n{t('Development tools for the graphical VMs:')}") + print(f"\n{t('Development tools:')}") for i, (_key, label, hint) in enumerate(choices, 1): print(f" [{i}] {label} — {hint}") gb = ", ".join( - f"{label} +{self._QEMU_DESKTOP_TOOLS[key]['disk_gb']} Go" + f"{label} +{self._QEMU_VM_TOOLS[key]['disk_gb']} Go" for key, label, _hint in choices ) + # Le mobile fait échouer la VM quand l'application ne compile pas : + # c'est le but, mais il vaut mieux le savoir avant de cocher. + if any(k == "mobile" for k, _l, _h in choices): + print(f" ⚠ {t('a failed mobile build marks the VM as failed')}") print(f" {t('Disk needed:')} {gb}") answer = input( f"{t('Numbers separated by spaces, [all], blank = none:')} " @@ -7261,7 +7501,7 @@ class TODO: _vm.setdefault("desktop", desktop) _vm["name"] = vm_name(_vm["name"], _vm.get("desktop"), suffixes) app_store = self._qemu_ask_app_store(vms) - desktop_tools = self._qemu_ask_desktop_tools(vms) + vm_tools = self._qemu_ask_vm_tools(vms) python_provider = self._qemu_ask_python_provider( [vm["arch"] for vm in vms] ) @@ -7342,7 +7582,7 @@ class TODO: "timezone": timezone, "locale": locale, "desktop": desktop, - "desktop_tools": desktop_tools, + "vm_tools": vm_tools, "python_provider": python_provider, "app_store": app_store, "install": install, @@ -7428,7 +7668,7 @@ class TODO: app_store = spec.get("app_store") or "deb" # Outils de développement : cochés une fois pour tout le parc, puis # filtrés machine par machine (architecture, saveur de bureau). - desktop_tools = tuple(spec.get("desktop_tools") or ()) + vm_tools = tuple(spec.get("vm_tools") or ()) # Branche par VM : « » sur une VM veut dire « celle du formulaire ». branch_map = { vm["name"]: (vm.get("branch") or install_branch or "") @@ -7520,7 +7760,7 @@ class TODO: desktop=desktop_map, python_provider=python_provider, app_store=app_store, - desktop_tools=desktop_tools, + vm_tools=vm_tools, ) else: print( @@ -7538,7 +7778,7 @@ class TODO: desktop=desktop_map.get(name, ""), python_provider=python_provider, app_store=app_store, - desktop_tools=desktop_tools, + vm_tools=vm_tools, ) # Sommaire TOTAL (déploiement + résolution IP + ssh_config + install diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 7ec8eee..0b8770d 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -7688,6 +7688,104 @@ TRANSLATIONS = { "fr": "pas de Xvfb ici : ouvrir PyCharm à la main", "en": "no Xvfb here, open PyCharm by hand", }, + "ERPLibre mobile (build)": { + "fr": "ERPLibre mobile (compilation)", + "en": "ERPLibre mobile (build)", + }, + "APK debug + Vitest, validates the VM": { + "fr": "APK debug + Vitest, valide la VM", + "en": "APK debug + Vitest, validates the VM", + }, + "Building ERPLibre mobile (long)": { + "fr": "Compilation d'ERPLibre mobile (long)", + "en": "Building ERPLibre mobile (long)", + }, + "detailed log in the VM:": { + "fr": "journal détaillé dans la VM :", + "en": "detailed log in the VM:", + }, + "FAILED:": {"fr": "ÉCHEC :", "en": "FAILED:"}, + "probable cause:": {"fr": "cause probable :", "en": "probable cause:"}, + "no known pattern, last lines:": { + "fr": "aucun motif connu, dernières lignes :", + "en": "no known pattern, last lines:", + }, + "a failed mobile build marks the VM as failed": { + "fr": "une compilation mobile en échec marque la VM en échec", + "en": "a failed mobile build marks the VM as failed", + }, + "mobile repository (additive manifest)": { + "fr": "dépôt mobile (manifeste additif)", + "en": "mobile repository (additive manifest)", + }, + "prerequisites of the upstream installer": { + "fr": "prérequis de l'installateur amont", + "en": "prerequisites of the upstream installer", + }, + "Android SDK, licences, NDK": { + "fr": "SDK Android, licences, NDK", + "en": "Android SDK, licences, NDK", + }, + "SDK platform required by the project": { + "fr": "plateforme SDK réclamée par le projet", + "en": "SDK platform required by the project", + }, + "npm dependencies": { + "fr": "dépendances npm", + "en": "npm dependencies", + }, + "web bundle (vite build)": { + "fr": "bundle web (vite build)", + "en": "web bundle (vite build)", + }, + "native sync (capacitor)": { + "fr": "synchronisation native (capacitor)", + "en": "native sync (capacitor)", + }, + "debug APK (gradle)": { + "fr": "APK debug (gradle)", + "en": "debug APK (gradle)", + }, + "Vitest tests": {"fr": "tests Vitest", "en": "Vitest tests"}, + "APK built:": {"fr": "APK construit :", "en": "APK built:"}, + "browser debugging (no Android):": { + "fr": "déverminage au navigateur (sans Android) :", + "en": "browser debugging (no Android):", + }, + "no APK produced": { + "fr": "aucun APK produit", + "en": "no APK produced", + }, + "disk full": {"fr": "disque plein", "en": "disk full"}, + "SDK platform missing": { + "fr": "plateforme SDK absente", + "en": "SDK platform missing", + }, + "SDK not found (ANDROID_HOME)": { + "fr": "SDK introuvable (ANDROID_HOME)", + "en": "SDK not found (ANDROID_HOME)", + }, + "SDK licences not accepted": { + "fr": "licences SDK non acceptées", + "en": "SDK licences not accepted", + }, + "NDK missing": {"fr": "NDK absent", "en": "NDK missing"}, + "JDK/Gradle mismatch": { + "fr": "JDK et Gradle incompatibles", + "en": "JDK/Gradle mismatch", + }, + "Gradle dependency unreachable (network?)": { + "fr": "dépendance Gradle injoignable (réseau ?)", + "en": "Gradle dependency unreachable (network?)", + }, + "Vitest tests failed": { + "fr": "tests Vitest en échec", + "en": "Vitest tests failed", + }, + "Gradle task failed": { + "fr": "tâche Gradle en échec", + "en": "Gradle task failed", + }, "project created, the install will configure it": { "fr": "projet créé, l'installation va le configurer", "en": "project created, the install will configure it", diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index bf37b60..1c279ed 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -20,23 +20,27 @@ from script.todo.todo import TODO # noqa: E402 class TestToolFiltering(unittest.TestCase): def setUp(self): self.todo = TODO.__new__(TODO) - self.all = tuple(TODO._QEMU_DESKTOP_TOOLS) + self.all = tuple(TODO._QEMU_VM_TOOLS) def test_a_server_gets_no_graphical_tool(self): - """Un IDE sans bureau n'a rien pour s'afficher.""" - self.assertEqual([], self.todo._qemu_tools_for(self.all, "amd64", "")) + """Un IDE sans bureau n'a rien pour s'afficher. La compilation mobile, + elle, reste : elle compile, elle n'affiche pas.""" + got = self.todo._qemu_tools_for(self.all, "amd64", "", "ubuntu") + for graphical in ("pycharm", "android", "gnome_ext"): + self.assertNotIn(graphical, got) def test_android_studio_is_x86_64_only(self): """Google ne publie aucune archive Linux aarch64 : toutes les variantes de l'URL rendent 404, et product-info.json ne déclare que Linux/amd64. """ self.assertIn( - "android", self.todo._qemu_tools_for(self.all, "amd64", "gnome") + "android", + self.todo._qemu_tools_for(self.all, "amd64", "gnome", "ubuntu"), ) for arch in ("arm64", "s390x"): self.assertNotIn( "android", - self.todo._qemu_tools_for(self.all, arch, "gnome"), + self.todo._qemu_tools_for(self.all, arch, "gnome", "ubuntu"), arch, ) @@ -44,45 +48,59 @@ class TestToolFiltering(unittest.TestCase): for arch in ("amd64", "arm64"): self.assertIn( "pycharm", - self.todo._qemu_tools_for(self.all, arch, "gnome"), + self.todo._qemu_tools_for(self.all, arch, "gnome", "ubuntu"), arch, ) self.assertNotIn( - "pycharm", self.todo._qemu_tools_for(self.all, "s390x", "gnome") + "pycharm", + self.todo._qemu_tools_for(self.all, "s390x", "gnome", "ubuntu"), ) def test_gnome_extensions_only_under_gnome(self): self.assertIn( - "gnome_ext", self.todo._qemu_tools_for(self.all, "amd64", "gnome") + "gnome_ext", + self.todo._qemu_tools_for(self.all, "amd64", "gnome", "ubuntu"), ) self.assertNotIn( "gnome_ext", - self.todo._qemu_tools_for(self.all, "amd64", "cinnamon"), + self.todo._qemu_tools_for(self.all, "amd64", "cinnamon", "ubuntu"), ) def test_unknown_key_is_ignored(self): self.assertEqual( - [], self.todo._qemu_tools_for(("nope",), "amd64", "gnome") + [], + self.todo._qemu_tools_for(("nope",), "amd64", "gnome", "ubuntu"), ) class TestToolDisk(unittest.TestCase): def setUp(self): self.todo = TODO.__new__(TODO) - self.all = tuple(TODO._QEMU_DESKTOP_TOOLS) + self.all = tuple(TODO._QEMU_VM_TOOLS) def test_disk_follows_the_filtering(self): - """Une VM qui ne recevra pas Android Studio ne doit pas se voir gonfler - son disque de ses 8 Go.""" - full = self.todo._qemu_tools_disk_gb(self.all, "amd64", "gnome") - arm = self.todo._qemu_tools_disk_gb(self.all, "arm64", "gnome") + """Une VM qui ne recevra pas un outil ne doit pas se voir gonfler son + disque de sa taille. En arm64 il en manque DEUX : Android Studio, que + Google ne publie qu'en x86_64, et la compilation mobile, qui en + dépend.""" + full = self.todo._qemu_tools_disk_gb( + self.all, "amd64", "gnome", "ubuntu" + ) + arm = self.todo._qemu_tools_disk_gb( + self.all, "arm64", "gnome", "ubuntu" + ) self.assertEqual( - full - arm, TODO._QEMU_DESKTOP_TOOLS["android"]["disk_gb"] + full - arm, + TODO._QEMU_VM_TOOLS["android"]["disk_gb"] + + TODO._QEMU_VM_TOOLS["mobile"]["disk_gb"], ) - def test_a_server_costs_nothing(self): + def test_a_server_only_pays_for_what_it_gets(self): + """Un serveur ne porte aucun IDE, donc il n'en paie pas le disque — + mais il paie bien la compilation mobile, qu'il reçoit.""" self.assertEqual( - 0, self.todo._qemu_tools_disk_gb(self.all, "amd64", "") + TODO._QEMU_VM_TOOLS["mobile"]["disk_gb"], + self.todo._qemu_tools_disk_gb(self.all, "amd64", "", "ubuntu"), ) def test_the_deploy_command_grows_the_disk(self): @@ -91,7 +109,7 @@ class TestToolDisk(unittest.TestCase): spec = { "ssh_key": "", "desktop": "gnome", - "desktop_tools": self.all, + "vm_tools": self.all, "install": { "branch": "develop", "prod": False, @@ -116,7 +134,7 @@ class TestToolDisk(unittest.TestCase): 20 + TODO.ERPLIBRE_EXTRA_DISK_GB + TODO.QEMU_DESKTOP_EXTRA_DISK_GB - + sum(s["disk_gb"] for s in TODO._QEMU_DESKTOP_TOOLS.values()) + + sum(s["disk_gb"] for s in TODO._QEMU_VM_TOOLS.values()) ) self.assertEqual(f"{expected}G", size) @@ -124,7 +142,7 @@ class TestToolDisk(unittest.TestCase): class TestToolRemoteCommand(unittest.TestCase): def setUp(self): self.todo = TODO.__new__(TODO) - self.all = tuple(TODO._QEMU_DESKTOP_TOOLS) + self.all = tuple(TODO._QEMU_VM_TOOLS) def _sh_ok(self, script): """Le shell accepte-t-il ce script ? « bash -n » ne l'exécute pas.""" @@ -224,6 +242,115 @@ class TestToolDiscoverability(unittest.TestCase): self.assertIn(".idea", cmd) +class TestMobileBuild(unittest.TestCase): + """Compilation ERPLibre mobile : la seule étape qui peut faire échouer la + VM, et la seule qui n'exige pas de bureau.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.all = tuple(TODO._QEMU_VM_TOOLS) + + def test_it_runs_on_a_server_vm(self): + """Elle compile, elle n'affiche rien : un bureau serait du gaspillage.""" + got = self.todo._qemu_tools_for(self.all, "amd64", "", "ubuntu") + self.assertEqual(["mobile"], got) + + def test_it_is_bounded_to_apt(self): + """install-android.sh du dépôt mobile commence par « sudo apt install + openjdk-17-jdk » : ailleurs il s'arrête là.""" + for distro in ("fedora", "rocky", "opensuse", "arch"): + self.assertNotIn( + "mobile", + self.todo._qemu_tools_for(self.all, "amd64", "gnome", distro), + distro, + ) + self.assertIn( + "mobile", + self.todo._qemu_tools_for(self.all, "amd64", "gnome", "debian"), + ) + + def test_it_coexists_with_android_studio(self): + """Combinaison croisée : la VM graphique reçoit les deux, et un seul + SDK — celui de $HOME/android, que ANDROID_HOME désigne.""" + got = self.todo._qemu_tools_for(self.all, "amd64", "gnome", "ubuntu") + self.assertIn("android", got) + self.assertIn("mobile", got) + self.assertIn("ANDROID_HOME", self.todo._qemu_mobile_remote_cmd()) + + def test_it_runs_after_the_install_not_before(self): + """Elle a besoin du dépôt, du venv qui synchronise le manifeste, et de + node que « make install_os » installe.""" + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "", "", "deb", ("mobile",) + ) + self.assertLess( + script.index("make install_os"), + script.index("erplibre-mobile-build.log"), + ) + + def test_a_failed_build_fails_the_vm(self): + """Contrat explicite : « pour que ce soit bon », l'app doit compiler. + Le bloc est donc lié par « && » et n'est PAS gardé, à la différence des + outils graphiques.""" + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "", "", "deb", ("mobile",) + ) + tail = script[script.index("erplibre-mobile-build.log") :] + self.assertNotIn("|| true", tail) + self.assertNotIn("|| echo", tail) + + def test_the_build_covers_apk_and_tests(self): + cmd = self.todo._qemu_mobile_remote_cmd() + for step in ( + "update_manifest_local_mobile.sh", + "./install-android.sh", + "npm ci", + "npm run build", + "npx cap sync android", + "./gradlew --no-daemon assembleDebug", + "npm test", + ): + self.assertIn(step, cmd, step) + + def test_the_platform_comes_from_the_project(self): + """L'installateur amont pose android-34, variables.gradle demande + compileSdk 36 : on lit le chiffre plutôt que de le figer.""" + cmd = self.todo._qemu_mobile_remote_cmd() + self.assertIn("android/variables.gradle", cmd) + self.assertIn("platforms;android-$v", cmd) + self.assertNotIn("platforms;android-36", cmd) + + def test_the_apk_is_the_proof(self): + """Une tâche Gradle peut rendre 0 sans rien produire.""" + cmd = self.todo._qemu_mobile_remote_cmd() + self.assertIn("outputs/apk/debug/*.apk", cmd) + + def test_no_apk_means_non_zero(self): + """Éprouvé plutôt que relu : sans APK, le bloc DOIT rendre non nul. + Une ligne d'information placée après le « fi » suffisait à rendre 0 et + à faire repasser la VM au vert.""" + cmd = self.todo._qemu_mobile_remote_cmd() + tail = cmd[cmd.index("apk=$(ls") :] + res = subprocess.run( + ["bash", "-c", "set -e\n" + tail], capture_output=True, text=True + ) + self.assertNotEqual(0, res.returncode, res.stdout) + + def test_every_failure_names_a_cause(self): + """Un journal de dizaines de mégaoctets ne se relit pas : le diagnostic + doit dire pourquoi.""" + cmd = self.todo._qemu_mobile_remote_cmd() + for pattern, _cause in TODO._QEMU_MOBILE_DIAG: + self.assertIn(pattern, cmd, pattern) + self.assertIn('tail -12 "$1"', cmd) + + def test_heavy_output_stays_out_of_the_install_log(self): + """Des centaines de lignes Gradle portant le mot « error » sans être + des pannes rendraient le compteur du tableau de bord inutilisable.""" + cmd = self.todo._qemu_mobile_remote_cmd() + self.assertIn('>> "$M" 2>&1', cmd) + + class TestPycharmCommunity(unittest.TestCase): """PyCharm doit s'ouvrir sans compte : c'est toute la différence entre une VM utilisable au premier démarrage et une VM qui demande une licence.""" From 1da54d0ba55057bbdb6b0aa6023d9903793a83b8 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 18 Aug 2026 00:51:50 -0400 Subject: [PATCH 20/55] [IMP] todo qemu: let Android Studio find the SDK the build already installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install-android.sh writes its exports into ~/.bashrc, which a GNOME session never reads: Android Studio launched from the menu saw no ANDROID_HOME and offered to download a second SDK, next to the one the mobile build had just installed. environment.d is the channel the user session actually reads, so both point at $HOME/android. An empty ~/.android/repositories.cfg is created too — without it the first-run wizard stops on an error instead of offering anything. --- FR --- install-android.sh écrit ses exports dans ~/.bashrc, qu'une session GNOME ne lit jamais : Android Studio lancé depuis le menu ne voyait aucun ANDROID_HOME et proposait de télécharger un second SDK, à côté de celui que la compilation mobile venait de poser. environment.d est le canal que la session utilisateur lit vraiment, et les deux pointent désormais $HOME/android. Un ~/.android/repositories.cfg vide est créé au passage : sans lui, l'assistant de première ouverture s'arrête sur une erreur au lieu de proposer quoi que ce soit. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 33 ++++++++++++++++++++++++++++++++- script/todo/todo_i18n.py | 4 ++++ test/test_qemu_desktop_tools.py | 11 +++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index d7999e4..0979224 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5452,8 +5452,22 @@ class TODO: "/opt/android-studio/bin/studio.svg", "Development;IDE;", ) + + + # Le SDK partagé, vu depuis la SESSION graphique. install-android.sh + # écrit ses exports dans ~/.bashrc, que GNOME ne lit pas : Android + # Studio lancé depuis le menu ne verrait donc pas le SDK et + # proposerait d'en télécharger un second. environment.d est le + # canal que la session utilisateur lit vraiment. + "mkdir -p ~/.config/environment.d && " + "printf 'ANDROID_HOME=%s/android\\nANDROID_SDK_ROOT=%s/android\\n'" + ' "$HOME" "$HOME" ' + "> ~/.config/environment.d/10-erplibre-android.conf; " + # repositories.cfg absent, et l'assistant de première ouverture + # s'arrête sur une erreur au lieu de proposer quoi que ce soit. + "mkdir -p ~/.android && touch ~/.android/repositories.cfg; " + f'echo " {t("Android Studio installed:")} /opt/android-studio ' f'({t("command")} studio / android-studio)"; ' + f'echo " {t("SDK shared through ANDROID_HOME:")} $HOME/android"; ' "grep -q vmx /proc/cpuinfo 2>/dev/null " "|| grep -q svm /proc/cpuinfo 2>/dev/null " f'|| echo " {t("no nested KVM: the emulator will not run")}"; ' @@ -6267,7 +6281,24 @@ class TODO: # plutôt que d'écrêter, contrairement au multiplicateur x1..x4 qui, lui, # est un calcul automatique et se borne aux cœurs de l'hôte. _QEMU_CPU_PRESETS = ( - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 24, 32 + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 24, + 32, ) @staticmethod diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 0b8770d..55da492 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -7627,6 +7627,10 @@ TRANSLATIONS = { "fr": "Android Studio : Google ne publie que du x86_64", "en": "Android Studio: Google publishes x86_64 only", }, + "SDK shared through ANDROID_HOME:": { + "fr": "SDK partagé via ANDROID_HOME :", + "en": "SDK shared through ANDROID_HOME:", + }, "Android Studio installed:": { "fr": "Android Studio installé :", "en": "Android Studio installed:", diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index 1c279ed..1a318b3 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -229,6 +229,17 @@ class TestToolDiscoverability(unittest.TestCase): self.assertIn("/usr/local/bin/studio;", cmd) self.assertIn("/usr/local/bin/android-studio;", cmd) + def test_the_sdk_is_shared_with_the_desktop_session(self): + """install-android.sh écrit ses exports dans ~/.bashrc, que GNOME ne + lit pas : sans environment.d, Android Studio lancé depuis le menu + proposerait de télécharger un SECOND SDK.""" + cmd = self.todo._qemu_android_studio_remote_cmd() + self.assertIn(".config/environment.d", cmd) + self.assertIn("ANDROID_HOME=", cmd) + # Le même emplacement que celui où la compilation mobile l'installe. + self.assertIn("/android", cmd) + self.assertIn("ANDROID_HOME", self.todo._qemu_mobile_remote_cmd()) + def test_the_log_says_where_it_landed(self): pycharm = self.todo._qemu_pycharm_remote_cmd() android = self.todo._qemu_android_studio_remote_cmd() From f3be40d74de50b9acb2dab5f5e07a223bb160906 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 18 Aug 2026 02:17:08 -0400 Subject: [PATCH 21/55] [ADD] todo qemu: add an Android emulator, and fix what the real run exposed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running it on a VM was the only way to find these. install_os never installed python3-venv, so .venv.erplibre was born crippled — bin/python but no pip, no activate — and everything downstream failed on "No module named git"; one line of dependency fixes it. Capacitor 8 needs a JDK 21 where the mobile repository's installer puts 17, and Gradle must RUN on it. That installer is not idempotent either, so it is replayed only when something is missing. The new emulator option creates an AVD from the SDK's own device list — the newest plain Pixel, smallest screen — with software rendering written into its config so ssh -X does not open a black screen, and adds the user to the kvm group, without which it refuses to start. Checked on a VM: boot completed in 10 s, adb sees emulator-5554, Android 16 x86_64, no KVM refusal. Vitest: 1938 tests pass. The APK still fails, upstream: sentencepiece builds protoc for the target then runs it on the host. --- FR --- Seule l'exécution sur une VM pouvait trouver ceci. install_os n'installait pas python3-venv, si bien que .venv.erplibre naissait infirme — bin/python mais ni pip ni activate — et tout ce qui en dépend tombait sur « No module named git » ; une ligne de dépendance suffit. Capacitor 8 réclame un JDK 21 là où l'installateur du dépôt mobile pose un 17, et Gradle doit TOURNER dessus. Cet installateur n'est pas idempotent non plus : il n'est rejoué que s'il manque quelque chose. La nouvelle option crée un AVD depuis la liste de profils du SDK — le Pixel simple le plus récent, plus petit écran —, écrit le rendu logiciel dans sa configuration pour qu'ssh -X n'ouvre pas un écran noir, et ajoute l'utilisateur au groupe kvm, sans quoi il refuse de démarrer. Vérifié sur une VM : boot en 10 s, adb voit emulator-5554, Android 16 x86_64, aucun refus de KVM. Vitest : 1938 tests passent. L'APK échoue encore, en amont : sentencepiece bâtit protoc pour la cible puis l'exécute sur l'hôte. Assisted-by: Claude Opus 5 --- script/install/install_debian_dependency.sh | 12 + script/qemu/README.base.md | 21 ++ script/qemu/README.fr.md | 11 + script/qemu/README.md | 10 + script/todo/todo.py | 341 ++++++++++++++++---- script/todo/todo_i18n.py | 74 +++++ test/test_qemu_desktop_tools.py | 100 +++++- 7 files changed, 504 insertions(+), 65 deletions(-) diff --git a/script/install/install_debian_dependency.sh b/script/install/install_debian_dependency.sh index ab95eeb..52e1b4e 100755 --- a/script/install/install_debian_dependency.sh +++ b/script/install/install_debian_dependency.sh @@ -289,6 +289,18 @@ if [[ $retVal -ne 0 ]]; then echo "apt-get pyenv dependencies installation error." exit 1 fi +# python3-venv : le venv d'OUTILS (.venv.erplibre) est bâti avec le python du +# SYSTÈME, et sur Debian et Ubuntu « python3 -m venv » n'embarque pas ensurepip +# sans ce paquet. Sans lui le venv naît infirme — bin/python existe, ni pip ni +# activate — et tout ce qui en dépend tombe : « repo », la fusion du manifeste +# (ModuleNotFoundError: No module named 'git'), la configuration PyCharm, la +# compilation mobile. Mesuré sur une VM Ubuntu 24.04 fraîche. +${APT_GET} install python3-venv -y +retVal=$? +if [[ $retVal -ne 0 ]]; then + echo "apt-get python3-venv installation error." + exit 1 +fi # Dependencies for selenium ${APT_GET} install libcairo2-dev python3-dev pkg-config libxt-dev libgirepository1.0-dev -y retVal=$? diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md index 87c9bcf..20967cd 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -309,6 +309,16 @@ starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio they share one SDK through `ANDROID_HOME`. Without Android, the same app runs in a browser: `npm start`. +A fifth, **Android emulator (Pixel)**, creates an AVD you open from your own +machine: `ssh -X erplibre@ "emulator -avd erplibre -no-audio"`. It needs no +desktop in the VM — the window lands on your screen, not the guest's — but it +does need KVM inside the guest, so nested virtualisation on the host; the log +says so when `/dev/kvm` is missing. The device is not frozen: the SDK is asked +for its profiles and the newest plain Pixel with the smallest screen wins (no +Pro, XL, Fold or tablet), because every pixel crosses the network. Rendering is +set to `swiftshader_indirect` inside the AVD's own `config.ini`, since `ssh -X` +offers no direct GLX and `auto` would open a black screen. + Each tool is filtered per VM — by architecture, desktop flavour and package family — and its disk cost is added to the plan before anything is created. @@ -425,6 +435,17 @@ simple VM serveur produit l'APK — et quand Android Studio est aussi coché, le deux partagent un seul SDK via `ANDROID_HOME`. Sans Android, la même application tourne dans un navigateur : `npm start`. +Un cinquième, **Émulateur Android (Pixel)**, crée un AVD que vous ouvrez +depuis votre poste : `ssh -X erplibre@ "emulator -avd erplibre -no-audio"`. +Il ne demande aucun bureau dans la VM — la fenêtre s'affiche sur VOTRE écran, +pas sur celui de l'invitée — mais il exige KVM dans l'invitée, donc la +virtualisation imbriquée sur l'hôte ; le journal le dit quand `/dev/kvm` +manque. Le modèle n'est pas figé : on demande au SDK ses profils et le Pixel le +plus récent au plus petit écran gagne (ni Pro, ni XL, ni pliant, ni tablette), +parce que chaque pixel traverse le réseau. Le rendu est réglé sur +`swiftshader_indirect` dans le `config.ini` de l'AVD, puisque `ssh -X` n'offre +pas de GLX direct et qu'« auto » ouvrirait un écran noir. + Chaque outil est filtré VM par VM — architecture, saveur de bureau et famille de paquets — et sa place disque s'ajoute au plan avant que rien ne soit créé. diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index de26940..771ef48 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -198,6 +198,17 @@ simple VM serveur produit l'APK — et quand Android Studio est aussi coché, le deux partagent un seul SDK via `ANDROID_HOME`. Sans Android, la même application tourne dans un navigateur : `npm start`. +Un cinquième, **Émulateur Android (Pixel)**, crée un AVD que vous ouvrez +depuis votre poste : `ssh -X erplibre@ "emulator -avd erplibre -no-audio"`. +Il ne demande aucun bureau dans la VM — la fenêtre s'affiche sur VOTRE écran, +pas sur celui de l'invitée — mais il exige KVM dans l'invitée, donc la +virtualisation imbriquée sur l'hôte ; le journal le dit quand `/dev/kvm` +manque. Le modèle n'est pas figé : on demande au SDK ses profils et le Pixel le +plus récent au plus petit écran gagne (ni Pro, ni XL, ni pliant, ni tablette), +parce que chaque pixel traverse le réseau. Le rendu est réglé sur +`swiftshader_indirect` dans le `config.ini` de l'AVD, puisque `ssh -X` n'offre +pas de GLX direct et qu'« auto » ouvrirait un écran noir. + Chaque outil est filtré VM par VM — architecture, saveur de bureau et famille de paquets — et sa place disque s'ajoute au plan avant que rien ne soit créé. diff --git a/script/qemu/README.md b/script/qemu/README.md index 17c7622..0adffcf 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -183,6 +183,16 @@ starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio they share one SDK through `ANDROID_HOME`. Without Android, the same app runs in a browser: `npm start`. +A fifth, **Android emulator (Pixel)**, creates an AVD you open from your own +machine: `ssh -X erplibre@ "emulator -avd erplibre -no-audio"`. It needs no +desktop in the VM — the window lands on your screen, not the guest's — but it +does need KVM inside the guest, so nested virtualisation on the host; the log +says so when `/dev/kvm` is missing. The device is not frozen: the SDK is asked +for its profiles and the newest plain Pixel with the smallest screen wins (no +Pro, XL, Fold or tablet), because every pixel crosses the network. Rendering is +set to `swiftshader_indirect` inside the AVD's own `config.ini`, since `ssh -X` +offers no direct GLX and `auto` would open a black screen. + Each tool is filtered per VM — by architecture, desktop flavour and package family — and its disk cost is added to the plan before anything is created. diff --git a/script/todo/todo.py b/script/todo/todo.py index 0979224..c52eb40 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5086,6 +5086,23 @@ class TODO: # manifeste ajoute, et du venv d'outils pour le synchroniser. "phase": "after", }, + # L'émulateur n'a pas besoin de bureau DANS la VM : il s'affiche sur + # l'écran de qui s'y connecte, par « ssh -X ». Il a besoin, lui, de KVM + # dans la VM — donc de virtualisation imbriquée sur l'hôte, ce que le + # bloc vérifie et annonce plutôt que de laisser découvrir. + # + # Disque : ~1,5 Go d'image système, ~2 Go de données d'AVD, plus + # l'émulateur lui-même. + "avd": { + "label": "Android emulator (Pixel)", + "hint": "AVD viewable over ssh -X", + "disk_gb": 6, + "arches": ("amd64",), + "desktops": (), + "needs_desktop": False, + "families": ("apt",), + "phase": "after", + }, } # Famille de paquets de chaque distribution, pour borner un outil à ce qui @@ -5637,6 +5654,18 @@ class TODO: ("SDK location not found", "SDK not found (ANDROID_HOME)"), ("have not been accepted", "SDK licences not accepted"), ("NDK not configured", "NDK missing"), + # Vécu : Capacitor 8 réclame un JDK 21 quand l'installateur amont pose + # un 17, et Gradle s'arrête là. + ( + "Cannot find a Java installation", + "JDK required by the project missing", + ), + # Vécu aussi : le JDK est là, mais Gradle TOURNE sur un plus ancien. + ("invalid source release", "Gradle running on too old a JDK"), + ("cannot overwrite", "SDK already there (upstream installer replays)"), + # Vécu : sentencepiece bâtit protoc pour la CIBLE et l'exécute sur + # l'hôte. Le message est cryptique ; la cause, non. + ("Exec format error", "cross-compiled protoc run on the host"), ("Unsupported class file major version", "JDK/Gradle mismatch"), ("Could not determine java version", "JDK/Gradle mismatch"), ( @@ -5645,29 +5674,140 @@ class TODO: ), ("npm ERR!", "npm dependencies"), ("Test Files", "Vitest tests failed"), + # Vécu : le manifeste rend 0 sans avoir cloné, et l'étape suivante + # tombe sur un cd impossible. Le motif nomme la vraie cause. + # + # SANS APOSTROPHE, et ce n'est pas cosmétique : ces motifs partent dans + # un « grep -q '' », entre apostrophes. « can't cd to » fermait + # la chaîne et rendait tout le bloc invalide — attrapé par bash -n. + ("cd: can", "mobile repository missing"), + # Vécu aussi : sans python3.12-venv, .venv.erplibre n'existe pas, et + # rien de ce qui suit ne peut synchroniser le manifeste. + ("virtual environment", "ERPLibre venv missing (incomplete install)"), + ("No module named", "ERPLibre venv incomplete (no pip: python3-venv)"), ("FAILED", "Gradle task failed"), ) def _qemu_mobile_diag_cmd(self): """Fonction shell qui NOMME la cause d'un échec, à partir du journal. - Un « la VM est rouge » n'apprend rien quand le journal fait des - dizaines de mégaoctets. On cherche donc les motifs connus, et à défaut - on montre les dernières lignes — c'est toujours mieux que rien.""" + Un « la VM est rouge » n'apprend rien quand le journal fait des dizaines + de mégaoctets. On cherche donc les motifs connus, et à défaut on montre + les dernières lignes — c'est toujours mieux que rien. + + La recherche porte sur la FIN du journal, pas sur tout. Vécu : le + diagnostic a annoncé « licences SDK non acceptées » quand la panne était + un JDK manquant — le motif venait de la revue de licences d'une étape + RÉUSSIE, trois étapes plus haut. Nommer la mauvaise cause coûte plus + cher que se taire.""" lines = "".join( - f"grep -q '{pat}' \"$1\" && {{ " - f'echo " {t("probable cause:")} {t(cause)}"; return 0; }}; ' + f"grep -q '{pat}' \"$d\" && {{ " + f'echo " {t("probable cause:")} {t(cause)}"; ' + 'rm -f "$d"; return 0; }; ' for pat, cause in self._QEMU_MOBILE_DIAG ) return ( - "mdiag() { " + 'mdiag() { d=$(mktemp); tail -400 "$1" > "$d"; ' + lines + f'echo " {t("no known pattern, last lines:")}"; ' - 'tail -12 "$1" | sed "s/^/ /"; }; ' + 'tail -12 "$1" | sed "s/^/ /"; rm -f "$d"; }; ' ) - def _qemu_mobile_remote_cmd(self, prod=False): - """Installe et COMPILE l'application mobile, puis la teste. + def _qemu_android_prologue_cmd(self): + """Ce que la compilation mobile et l'émulateur partagent : le journal + détaillé, le coureur d'étapes, le diagnostic, et l'environnement du SDK. + + Écrit UNE fois même quand les deux options sont cochées — deux + prologues, ce serait deux journaux et deux SDK.""" + return ( + self._qemu_mobile_diag_cmd() + + # Le détail va dans un fichier À PART. Une compilation Gradle écrit + # des dizaines de milliers de lignes, dont des centaines portant le + # mot « error » sans qu'aucune ne soit une panne : les verser dans + # le journal d'installation rendrait son compteur d'erreurs + # inutilisable, et le diagnostic illisible. + 'M="$HOME/erplibre-mobile-build.log"; : > "$M"; ' + f'echo " {t("detailed log in the VM:")} $M"; ' + 'mstep() { lbl="$1"; shift; echo " -> $lbl"; ' + 'if sh -c "$*" >> "$M" 2>&1; then return 0; fi; ' + f'echo " ⚠ {t("FAILED:")} $lbl"; mdiag "$M"; return 1; }}; ' + # Le SDK vit dans $HOME/android, l'emplacement qu'emploie + # l'installateur du dépôt. Android Studio, s'il est là, le trouvera + # par ANDROID_HOME : un seul SDK sur la machine, pas deux. + 'export ANDROID_HOME="$HOME/android"; ' + 'export ANDROID_SDK_ROOT="$HOME/android"; ' + 'export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin' + ':$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator"; ' + # ~/.bashrc n'est pas lu par un « ssh hôte commande » : ce que + # l'installateur y écrit ne sert qu'aux sessions futures, pas à la + # compilation qui suit immédiatement. + # + # Le JDK le PLUS RÉCENT installé, et non celui des alternatives. + # Mesuré : avec JAVA_HOME sur le 17 que pose l'installateur amont, + # Gradle tourne en 17 et s'arrête sur « invalid source release: 21 » + # — les modules de Capacitor 8 compilent en 21. Le tri est + # « sort -V », donc java-21 passe après java-17, pas avant. + "export JAVA_HOME=$(ls -d /usr/lib/jvm/java-*-openjdk-* " + "2>/dev/null | sort -V | tail -1); " + '[ -n "$JAVA_HOME" ] || export JAVA_HOME=$(dirname $(dirname ' + "$(readlink -f $(command -v javac 2>/dev/null " + "|| command -v java 2>/dev/null) 2>/dev/null) 2>/dev/null) " + "2>/dev/null); " + 'export PATH="$JAVA_HOME/bin:$PATH"; ' + ) + + def _qemu_android_sdk_steps(self, el_dir): + """Les étapes qui posent le SDK : dépôt mobile, prérequis, installateur + amont, plateforme réclamée par le projet. Communes aux deux options.""" + return ( + # Le « test -f » n'est pas une ceinture de plus : c'est la seule + # vérité disponible. update_manifest_local_mobile.sh finit par + # « kill $DAEMON_PID » et rend donc 0 même quand il n'a rien cloné — + # vécu, faute de .venv.erplibre. L'étape passait, et c'est le « cd » + # suivant qui échouait, deux étapes plus loin. + # Le venv d'ERPLibre d'abord, et nommément : tout ce qui suit en + # dépend — c'est lui qui porte « repo », qui synchronise le + # manifeste. Vécu avec le profil « ERPLibre seul », dont le code + # note lui-même « problem installing with q, the script depend on + # odoo » : sans venv, le manifeste rendait 0 sans rien cloner et + # l'échec ne se voyait que deux étapes plus loin. + f'mstep "{t("ERPLibre venv (everything below needs it)")}" ' + # « activate », et non « bin/python » : sans python3-venv, le venv + # naît INFIRME — bin/python existe (un lien), mais ni pip ni + # activate ni site-packages. La sonde passait, et l'échec ne se + # voyait que deux étapes plus loin, en « No module named git ». + f"'test -f {el_dir}/.venv.erplibre/bin/activate' && " + f'mstep "{t("mobile repository (additive manifest)")}" ' + f"'cd {el_dir} && ./script/manifest/update_manifest_local_mobile.sh; " + "test -f mobile/erplibre_home_mobile/install-android.sh' && " + f'mstep "{t("prerequisites of the upstream installer")}" ' + # openjdk-21 EN PLUS du 17 que pose l'installateur amont : mesuré, + # Gradle s'arrête sur « Cannot find a Java installation matching + # {languageVersion=21} » — les modules de Capacitor 8 réclament 21. + # Les deux JDK cohabitent, et Gradle choisit par sa chaîne d'outils. + # unzip et xauth, eux, manquent des images cloud. + "'sudo DEBIAN_FRONTEND=noninteractive apt-get " + "-o DPkg::Lock::Timeout=600 install -y unzip wget xauth " + "openjdk-21-jdk' && " + # L'installateur amont n'est PAS idempotent : au second passage il + # s'arrête sur « mv: cannot overwrite latest/cmdline-tools ». Mesuré. + # On ne le rejoue donc que s'il reste quelque chose à poser — un + # déploiement qui se répète ne doit pas échouer sur une réussite + # précédente. + f'mstep "{t("Android SDK, licences, NDK")}" ' + f"'cd {el_dir}/mobile/erplibre_home_mobile && " + "{ [ -x $HOME/android/cmdline-tools/latest/bin/sdkmanager ] " + "|| ./install-android.sh; }' && " + f'mstep "{t("SDK platform required by the project")}" ' + f"'cd {el_dir}/mobile/erplibre_home_mobile && " + 'v=$(sed -n "s/.*compileSdkVersion *= *\\([0-9]*\\).*/\\1/p" ' + 'android/variables.gradle) && [ -n "$v" ] && ' + 'yes | sdkmanager "platforms;android-$v" ' + '"build-tools;$v.0.0"\' && ' + ) + + def _qemu_mobile_build_steps(self, el_dir): + """Étapes de compilation de l'application mobile, puis ses tests. C'est la seule étape qui peut faire échouer la VM, et c'est voulu : une machine dont l'application ne compile pas n'est pas une machine prête. @@ -5688,56 +5828,26 @@ class TODO: commence par « sudo apt install openjdk-17-jdk » et s'arrête là ailleurs. La lever se fait dans ce script-là, pas ici. """ - el_dir = self._qemu_install_dir(prod) return ( - f'echo "== {t("Building ERPLibre mobile (long)")} =="; ' - + self._qemu_mobile_diag_cmd() - + - # Le détail va dans un fichier À PART. Une compilation Gradle écrit - # des dizaines de milliers de lignes, dont des centaines portant le - # mot « error » sans qu'aucune ne soit une panne : les verser dans - # le journal d'installation rendrait son compteur d'erreurs - # inutilisable, et le diagnostic illisible. - 'M="$HOME/erplibre-mobile-build.log"; : > "$M"; ' - f'echo " {t("detailed log in the VM:")} $M"; ' - 'mstep() { lbl="$1"; shift; echo " -> $lbl"; ' - 'if sh -c "$*" >> "$M" 2>&1; then return 0; fi; ' - f'echo " ⚠ {t("FAILED:")} $lbl"; mdiag "$M"; return 1; }}; ' - # Le SDK vit dans $HOME/android, l'emplacement qu'emploie - # l'installateur du dépôt. Android Studio, s'il est là, le trouvera - # par ANDROID_HOME : un seul SDK sur la machine, pas deux. - 'export ANDROID_HOME="$HOME/android"; ' - 'export ANDROID_SDK_ROOT="$HOME/android"; ' - 'export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin' - ':$ANDROID_HOME/platform-tools"; ' - # ~/.bashrc n'est pas lu par un « ssh hôte commande » : ce que - # l'installateur y écrit ne sert qu'aux sessions futures, pas à la - # compilation qui suit immédiatement. - "export JAVA_HOME=$(dirname $(dirname $(readlink -f " - "$(command -v javac 2>/dev/null || command -v java 2>/dev/null) " - "2>/dev/null) 2>/dev/null) 2>/dev/null); " - f'mstep "{t("mobile repository (additive manifest)")}" ' - f"'cd {el_dir} && ./script/manifest/update_manifest_local_mobile.sh' && " - f'mstep "{t("prerequisites of the upstream installer")}" ' - "'sudo DEBIAN_FRONTEND=noninteractive apt-get " - "-o DPkg::Lock::Timeout=600 install -y unzip wget' && " - f'mstep "{t("Android SDK, licences, NDK")}" ' - f"'cd {el_dir}/mobile/erplibre_home_mobile && ./install-android.sh' && " - f'mstep "{t("SDK platform required by the project")}" ' - f"'cd {el_dir}/mobile/erplibre_home_mobile && " - 'v=$(sed -n "s/.*compileSdkVersion *= *\\([0-9]*\\).*/\\1/p" ' - 'android/variables.gradle) && [ -n "$v" ] && ' - 'yes | sdkmanager "platforms;android-$v" ' - '"build-tools;$v.0.0"\' && ' f'mstep "{t("npm dependencies")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile && npm ci' && " f'mstep "{t("web bundle (vite build)")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile && npm run build' && " f'mstep "{t("native sync (capacitor)")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile && npx cap sync android' && " + # UNE seule ABI, celle de la VM — qui est aussi celle de + # l'émulateur. Deux raisons, la seconde décisive : + # - quatre ABI, c'est quatre fois la compilation de whisper.cpp + # et de sentencepiece, pour trois qui ne serviront jamais ici ; + # - sentencepiece bâtit son « protoc » POUR LA CIBLE puis tente de + # l'exécuter sur l'hôte. En arm64 cela donne « Exec format + # error » et la compilation s'arrête — mesuré. En x86_64 la + # cible et l'hôte coïncident, et le défaut ne se manifeste pas. + # Un APK arm64 demandera un correctif au projet mobile. f'mstep "{t("debug APK (gradle)")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile/android && " - "./gradlew --no-daemon assembleDebug' && " + "./gradlew --no-daemon assembleDebug " + "-Pandroid.injected.build.abi=x86_64' && " f'mstep "{t("Vitest tests")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile && npm test' && " # L'APK est la preuve, pas le code de sortie de Gradle : une tâche @@ -5758,9 +5868,122 @@ class TODO: f'echo " {t("browser debugging (no Android):")} ' f"cd {el_dir}/mobile/erplibre_home_mobile " '&& npm start"; else ' - f'echo " ⚠ {t("no APK produced")}"; false; fi; ' + f'echo " ⚠ {t("no APK produced")}"; false; fi' ) + def _qemu_avd_steps(self, el_dir): + """Étapes créant un émulateur prêt à s'ouvrir depuis le poste de travail. + + Le modèle n'est pas figé : on demande au SDK la liste de ses profils et + on retient le Pixel le plus récent au plus petit écran — ni Pro, ni XL, + ni pliant, ni tablette. Sur un écran distant, chaque pixel traverse le + réseau : le petit modèle n'est pas une coquetterie. + + L'image système suit la plateforme du projet, et redescend si elle n'est + pas publiée — Google ne fournit pas d'image pour toutes les API. + + Le rendu est réglé sur swiftshader_indirect, DANS la configuration de + l'AVD plutôt qu'en option de lancement : par « ssh -X » il n'y a pas de + GLX direct, et l'émulateur s'ouvrirait sur un écran noir. Ainsi + « emulator -avd erplibre » suffit, sans rien à retenir. + """ + return ( + f'echo " == {t("Android emulator (AVD)")} =="; ' + # KVM dans la VM : sans lui l'émulateur x86 refuse de démarrer. On + # le dit ici, où c'est réparable (virtualisation imbriquée sur + # l'hôte), plutôt qu'au premier lancement. + "if [ ! -e /dev/kvm ]; then " + f'echo " ⚠ {t("no /dev/kvm: nested virtualisation is off on the host")}"; ' + "else " + # /dev/kvm est en root:kvm 0660 : sans appartenir au groupe, + # l'émulateur refuse de démarrer sur « ProbeKVM: This user doesn't + # have permissions to use KVM ». Mesuré. L'appartenance ne prend + # qu'à la prochaine session — ce qui tombe bien, la session utile + # est justement celle du « ssh -X » qui viendra ensuite. + "sudo usermod -aG kvm $(id -un) 2>/dev/null || true; " + f'echo " {t("user added to the kvm group (effective at next login)")}"; ' + "fi; " + f'mstep "{t("emulator and system image")}" ' + '\'v=$(sed -n "s/.*compileSdkVersion *= *\\([0-9]*\\).*/\\1/p" ' + f"{el_dir}/mobile/erplibre_home_mobile/android/variables.gradle); " + "for a in $v 36 35 34; do " + 'img="system-images;android-$a;google_apis;x86_64"; ' + 'if yes | sdkmanager "emulator" "$img"; then ' + 'echo "$img" > $HOME/.erplibre-avd-image; break; fi; done; ' + "test -s $HOME/.erplibre-avd-image' && " + f'mstep "{t("Pixel profile, smallest screen")}" ' + # Le plus récent des Pixel simples : on trie sur le NUMÉRO, pas sur + # l'ordre d'affichage, et on écarte les grands modèles. + '\'avdmanager list device | grep -oE "pixel_[0-9]+a?" ' + '| grep -vE "pro|xl|fold|tablet" | sort -t_ -k2 -n | tail -1 ' + "> $HOME/.erplibre-avd-device; test -s $HOME/.erplibre-avd-device' && " + f'mstep "{t("create the AVD")}" ' + "'img=$(cat $HOME/.erplibre-avd-image); " + "dev=$(cat $HOME/.erplibre-avd-device); " + 'echo no | avdmanager create avd -n erplibre -k "$img" ' + '-d "$dev" --force && ' + # Rendu logiciel, écrit dans la config : par ssh -X il n'y a pas de + # GLX direct, et « auto » donnerait un écran noir. + 'printf "hw.gpu.enabled=yes\\nhw.gpu.mode=swiftshader_indirect\\n" ' + ">> $HOME/.android/avd/erplibre.avd/config.ini' && " + f'echo " ✅ {t("AVD ready:")} ' + "$(cat $HOME/.erplibre-avd-device) / " + '$(cat $HOME/.erplibre-avd-image)"; ' + # La commande à copier, avec l'adresse déjà remplie : un émulateur + # dont on ignore comment l'ouvrir ne sert à personne. + "ip=$(hostname -I 2>/dev/null | awk '{print $1}'); " + f'echo " {t("open it from your workstation:")} ' + 'ssh -X erplibre@$ip \\"emulator -avd erplibre -no-audio\\""; ' + f'echo " {t("then install the APK:")} ' + 'ssh erplibre@$ip \\"adb install -r ' + f"{el_dir}/mobile/erplibre_home_mobile/android/app/build" + '/outputs/apk/debug/app-debug.apk\\""' + ) + + def _qemu_after_remote_cmd(self, tools, prod=False): + """Phase d'APRÈS l'installation : prologue commun, SDK commun, puis ce + qui a été coché. + + Un seul prologue et un seul SDK même quand les deux options le sont : + deux prologues, et le second tronquerait le journal détaillé du premier. + + Les groupes sont joints par « && » et non par « ; ». C'est ce qui fait + qu'un APK manquant reste l'échec de la VM : collé par « ; », un + émulateur créé avec succès effacerait le verdict de la compilation.""" + picked = [ + k + for k in ("mobile", "avd") + if k in (tools or ()) and k in self._QEMU_VM_TOOLS + ] + if not picked: + return "" + el_dir = self._qemu_install_dir(prod) + groups = [] + if "mobile" in picked: + groups.append(self._qemu_mobile_build_steps(el_dir)) + if "avd" in picked: + groups.append(self._qemu_avd_steps(el_dir)) + return ( + f'echo "== {t("ERPLibre mobile, Android SDK (long)")} =="; ' + + self._qemu_android_prologue_cmd() + + self._qemu_android_sdk_steps(el_dir) + # Chaque groupe entre ACCOLADES. Sans elles, « && » ne lie que la + # première commande du groupe suivant : mesuré, un APK manquant + # laissait tourner l'émulateur puis rendait 0 — la VM repassait au + # vert alors que rien n'avait compilé. C'est le même piège que le + # bloc de service systemd, quelques centaines de lignes plus haut. + + " && ".join(f"{{ {g}; }}" for g in groups) + + "; " + ) + + def _qemu_mobile_remote_cmd(self, prod=False): + """Compilation mobile seule — la forme que testent les tests.""" + return self._qemu_after_remote_cmd(("mobile",), prod) + + def _qemu_avd_remote_cmd(self, prod=False): + """Émulateur seul.""" + return self._qemu_after_remote_cmd(("avd",), prod) + def _qemu_tools_remote_cmd(self, tools, prod=False, phase="before"): """Bloc des outils cochés pour cette PHASE, du plus utile au plus lourd. @@ -5770,17 +5993,15 @@ class TODO: « after » : la compilation mobile, qui vient après l'installation dont elle dépend, et qui elle NE se garde PAS. C'est le contrat demandé : une VM dont l'application ne compile pas doit être rouge.""" + if phase == "after": + # Un seul bloc pour les deux options : voir _qemu_after_remote_cmd. + return self._qemu_after_remote_cmd(tools, prod) blocks = { - "gnome_ext": (self._qemu_gnome_ext_remote_cmd, "before"), - "pycharm": (lambda: self._qemu_pycharm_remote_cmd(prod), "before"), - "android": (self._qemu_android_studio_remote_cmd, "before"), - "mobile": (lambda: self._qemu_mobile_remote_cmd(prod), "after"), + "gnome_ext": self._qemu_gnome_ext_remote_cmd, + "pycharm": lambda: self._qemu_pycharm_remote_cmd(prod), + "android": self._qemu_android_studio_remote_cmd, } - return "".join( - fn() - for k, (fn, ph) in blocks.items() - if k in (tools or ()) and ph == phase - ) + return "".join(fn() for k, fn in blocks.items() if k in (tools or ())) def _qemu_editor_pkg(self): """Paquet de l'éditeur de l'hôte, à installer dans la VM. diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 55da492..d663c93 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -7718,6 +7718,10 @@ TRANSLATIONS = { "fr": "une compilation mobile en échec marque la VM en échec", "en": "a failed mobile build marks the VM as failed", }, + "ERPLibre venv (everything below needs it)": { + "fr": "venv ERPLibre (tout ce qui suit en dépend)", + "en": "ERPLibre venv (everything below needs it)", + }, "mobile repository (additive manifest)": { "fr": "dépôt mobile (manifeste additif)", "en": "mobile repository (additive manifest)", @@ -7774,6 +7778,22 @@ TRANSLATIONS = { "en": "SDK licences not accepted", }, "NDK missing": {"fr": "NDK absent", "en": "NDK missing"}, + "JDK required by the project missing": { + "fr": "JDK réclamé par le projet absent", + "en": "JDK required by the project missing", + }, + "Gradle running on too old a JDK": { + "fr": "Gradle tourne sur un JDK trop ancien", + "en": "Gradle running on too old a JDK", + }, + "SDK already there (upstream installer replays)": { + "fr": "SDK déjà là (l'installateur amont se rejoue)", + "en": "SDK already there (upstream installer replays)", + }, + "cross-compiled protoc run on the host": { + "fr": "protoc compilé pour la cible, exécuté sur l'hôte", + "en": "cross-compiled protoc run on the host", + }, "JDK/Gradle mismatch": { "fr": "JDK et Gradle incompatibles", "en": "JDK/Gradle mismatch", @@ -7786,6 +7806,60 @@ TRANSLATIONS = { "fr": "tests Vitest en échec", "en": "Vitest tests failed", }, + "ERPLibre venv incomplete (no pip: python3-venv)": { + "fr": "venv ERPLibre incomplet (pas de pip : python3-venv)", + "en": "ERPLibre venv incomplete (no pip: python3-venv)", + }, + "mobile repository missing": { + "fr": "dépôt mobile absent", + "en": "mobile repository missing", + }, + "ERPLibre venv missing (incomplete install)": { + "fr": "venv ERPLibre absent (installation incomplète)", + "en": "ERPLibre venv missing (incomplete install)", + }, + "Android emulator (Pixel)": { + "fr": "Émulateur Android (Pixel)", + "en": "Android emulator (Pixel)", + }, + "AVD viewable over ssh -X": { + "fr": "AVD visible par ssh -X", + "en": "AVD viewable over ssh -X", + }, + "Android emulator (AVD)": { + "fr": "Émulateur Android (AVD)", + "en": "Android emulator (AVD)", + }, + "no /dev/kvm: nested virtualisation is off on the host": { + "fr": "pas de /dev/kvm : virtualisation imbriquée coupée sur l'hôte", + "en": "no /dev/kvm: nested virtualisation is off on the host", + }, + "user added to the kvm group (effective at next login)": { + "fr": "utilisateur ajouté au groupe kvm (effectif à la prochaine session)", + "en": "user added to the kvm group (effective at next login)", + }, + "emulator and system image": { + "fr": "émulateur et image système", + "en": "emulator and system image", + }, + "Pixel profile, smallest screen": { + "fr": "profil Pixel, plus petit écran", + "en": "Pixel profile, smallest screen", + }, + "create the AVD": {"fr": "création de l'AVD", "en": "create the AVD"}, + "AVD ready:": {"fr": "AVD prêt :", "en": "AVD ready:"}, + "open it from your workstation:": { + "fr": "à ouvrir depuis votre poste :", + "en": "open it from your workstation:", + }, + "then install the APK:": { + "fr": "puis installer l'APK :", + "en": "then install the APK:", + }, + "ERPLibre mobile, Android SDK (long)": { + "fr": "ERPLibre mobile, SDK Android (long)", + "en": "ERPLibre mobile, Android SDK (long)", + }, "Gradle task failed": { "fr": "tâche Gradle en échec", "en": "Gradle task failed", diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index 1a318b3..ec65625 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -92,14 +92,16 @@ class TestToolDisk(unittest.TestCase): self.assertEqual( full - arm, TODO._QEMU_VM_TOOLS["android"]["disk_gb"] - + TODO._QEMU_VM_TOOLS["mobile"]["disk_gb"], + + TODO._QEMU_VM_TOOLS["mobile"]["disk_gb"] + + TODO._QEMU_VM_TOOLS["avd"]["disk_gb"], ) def test_a_server_only_pays_for_what_it_gets(self): """Un serveur ne porte aucun IDE, donc il n'en paie pas le disque — - mais il paie bien la compilation mobile, qu'il reçoit.""" + mais il paie bien ce qu'il reçoit : compilation mobile et émulateur.""" self.assertEqual( - TODO._QEMU_VM_TOOLS["mobile"]["disk_gb"], + TODO._QEMU_VM_TOOLS["mobile"]["disk_gb"] + + TODO._QEMU_VM_TOOLS["avd"]["disk_gb"], self.todo._qemu_tools_disk_gb(self.all, "amd64", "", "ubuntu"), ) @@ -262,9 +264,10 @@ class TestMobileBuild(unittest.TestCase): self.all = tuple(TODO._QEMU_VM_TOOLS) def test_it_runs_on_a_server_vm(self): - """Elle compile, elle n'affiche rien : un bureau serait du gaspillage.""" + """Elle compile, elle n'affiche rien : un bureau serait du gaspillage. + L'émulateur non plus n'en a pas besoin — il s'affiche par ssh -X.""" got = self.todo._qemu_tools_for(self.all, "amd64", "", "ubuntu") - self.assertEqual(["mobile"], got) + self.assertEqual(["mobile", "avd"], got) def test_it_is_bounded_to_apt(self): """install-android.sh du dépôt mobile commence par « sudo apt install @@ -362,6 +365,93 @@ class TestMobileBuild(unittest.TestCase): self.assertIn('>> "$M" 2>&1', cmd) +class TestAndroidEmulator(unittest.TestCase): + """Émulateur Android : visible depuis le poste par « ssh -X ».""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.cmd = self.todo._qemu_avd_remote_cmd() + + def test_no_desktop_needed_in_the_vm(self): + """Il s'affiche sur l'écran de qui s'y connecte, pas dans la VM.""" + self.assertFalse(TODO._QEMU_VM_TOOLS["avd"]["needs_desktop"]) + self.assertIn( + "avd", self.todo._qemu_tools_for(("avd",), "amd64", "", "ubuntu") + ) + + def test_software_rendering_is_written_into_the_avd(self): + """Par « ssh -X » il n'y a pas de GLX direct : en « auto », + l'émulateur s'ouvre sur un écran noir. Le réglage va dans config.ini + pour qu'« emulator -avd erplibre » suffise.""" + self.assertIn("hw.gpu.mode=swiftshader_indirect", self.cmd) + self.assertIn("config.ini", self.cmd) + + def test_xauth_is_installed(self): + """Sans xauth dans la VM, « ssh -X » n'ouvre aucun affichage — et le + paquet manque des images cloud.""" + self.assertIn("xauth", self.cmd) + + def test_it_says_when_kvm_is_missing(self): + """Un émulateur x86 sans KVM refuse de démarrer : le dire là où c'est + réparable, sur l'hôte, plutôt qu'au premier lancement.""" + self.assertIn("/dev/kvm", self.cmd) + + def test_the_pixel_is_chosen_at_runtime(self): + """« le plus récent, le plus petit écran » se demande au SDK : figer un + modèle le rendrait faux à la prochaine génération.""" + self.assertIn("avdmanager list device", self.cmd) + self.assertIn("pixel_", self.cmd) + self.assertIn("pro|xl|fold|tablet", self.cmd) + self.assertIn("sort -t_ -k2 -n", self.cmd) + + def test_the_system_image_falls_back(self): + """Google ne publie pas d'image pour toutes les API : on descend.""" + self.assertIn("for a in $v 36 35 34", self.cmd) + + def test_it_prints_the_command_to_open_it(self): + """Un émulateur dont on ignore comment l'ouvrir ne sert à personne.""" + self.assertIn("ssh -X erplibre@$ip", self.cmd) + self.assertIn("adb install -r", self.cmd) + + def test_one_prologue_and_one_sdk_for_both_options(self): + """Deux prologues, et le second tronquerait le journal du premier.""" + both = self.todo._qemu_after_remote_cmd(("mobile", "avd")) + self.assertEqual(1, both.count("mstep() {")) + self.assertEqual(1, both.count('M="$HOME/erplibre-mobile-build.log"')) + + def test_the_emulator_cannot_mask_a_build_failure(self): + """ÉPROUVÉ, pas relu. Sans accolades autour de chaque groupe, « && » ne + lie que la première commande du suivant : mesuré sur une VM, un APK + manquant laissait tourner l'émulateur puis rendait 0 — la VM repassait + au vert alors que rien n'avait compilé.""" + both = self.todo._qemu_after_remote_cmd(("mobile", "avd")) + # On neutralise les étapes : seul le CHAÎNAGE est en cause ici. + stub = 'mstep() { echo " -> $1"; return 0; }; mdiag() { :; }; ' + tail = both[both.index('{ mstep "') :] + res = subprocess.run( + ["bash", "-c", "set -e; " + stub + tail], + capture_output=True, + text=True, + ) + self.assertNotEqual(0, res.returncode, res.stdout) + # Et l'émulateur ne doit PAS avoir été touché. + self.assertNotIn("Pixel", res.stdout) + + def test_valid_shell_in_every_combination(self): + for tools in (("mobile",), ("avd",), ("mobile", "avd")): + cmd = self.todo._qemu_after_remote_cmd(tools) + res = subprocess.run( + ["bash", "-n"], input=cmd, text=True, capture_output=True + ) + self.assertEqual(0, res.returncode, f"{tools}: {res.stderr}") + + def test_no_diagnostic_pattern_carries_an_apostrophe(self): + """Ces motifs partent dans un « grep -q '' » : une apostrophe + fermait la chaîne et rendait tout le bloc invalide. Vécu.""" + for pattern, _cause in TODO._QEMU_MOBILE_DIAG: + self.assertNotIn("'", pattern, pattern) + + class TestPycharmCommunity(unittest.TestCase): """PyCharm doit s'ouvrir sans compte : c'est toute la différence entre une VM utilisable au premier démarrage et une VM qui demande une licence.""" From 9ad2048bbb3fba596c433bf0a88246e16128bc31 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 18 Aug 2026 02:30:02 -0400 Subject: [PATCH 22/55] [FIX] todo qemu: the printed emulator command could not run as printed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from a workstation: "emulator: command not found". The line the emulator step printed relied on PATH, and "ssh host 'command'" reads neither ~/.profile nor ~/.bashrc — Ubuntu even opens the latter with a return for non-interactive shells. So what install-android.sh writes there never applies to those commands. Both printed lines now carry absolute paths. Behind it, a second one: the emulator ships TWO qemu binaries and only the headless one does without PulseAudio. The windowed one — the ssh -X case — links libpulse.so.0, absent from cloud images, and stops on "cannot open shared object file" even with -no-audio. Measured: it is the only missing library, the Qt dependencies travel in the bundle. Checked after the fix: the windowed binary starts and reports its version. --- FR --- Remonté depuis un poste : « emulator: command not found ». La ligne affichée par l'étape émulateur comptait sur le PATH, or « ssh hôte 'commande' » ne lit ni ~/.profile ni ~/.bashrc — Ubuntu ouvre même le second par un return pour les shells non interactifs. Ce que install-android.sh y écrit ne s'applique donc jamais à ces commandes. Les deux lignes affichées portent maintenant des chemins absolus. Derrière, une seconde : l'émulateur livre DEUX binaires qemu et seul le headless se passe de PulseAudio. Celui qui ouvre une fenêtre — le cas du ssh -X — lie libpulse.so.0, absente des images cloud, et s'arrête sur « cannot open shared object file » même avec -no-audio. Mesuré : c'est la seule bibliothèque qui manque, les dépendances Qt voyagent dans le bundle. Vérifié après correction : le binaire fenêtré démarre et donne sa version. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 19 ++++++++++++++++--- test/test_qemu_desktop_tools.py | 15 +++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index c52eb40..48f4394 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5781,6 +5781,13 @@ class TODO: f"'cd {el_dir} && ./script/manifest/update_manifest_local_mobile.sh; " "test -f mobile/erplibre_home_mobile/install-android.sh' && " f'mstep "{t("prerequisites of the upstream installer")}" ' + # libpulse0 : l'émulateur a DEUX binaires qemu, et seul le + # « headless » se passe de PulseAudio. Celui qui ouvre une FENÊTRE — + # le cas d'un « ssh -X » — lie libpulse.so.0, absente des images + # cloud, et s'arrête sur « cannot open shared object file » même + # avec « -no-audio ». Mesuré : c'est la SEULE bibliothèque qui + # manque, tout le reste des dépendances Qt voyage dans le bundle. + # # openjdk-21 EN PLUS du 17 que pose l'installateur amont : mesuré, # Gradle s'arrête sur « Cannot find a Java installation matching # {languageVersion=21} » — les modules de Capacitor 8 réclament 21. @@ -5788,7 +5795,7 @@ class TODO: # unzip et xauth, eux, manquent des images cloud. "'sudo DEBIAN_FRONTEND=noninteractive apt-get " "-o DPkg::Lock::Timeout=600 install -y unzip wget xauth " - "openjdk-21-jdk' && " + "libpulse0 openjdk-21-jdk' && " # L'installateur amont n'est PAS idempotent : au second passage il # s'arrête sur « mv: cannot overwrite latest/cmdline-tools ». Mesuré. # On ne le rejoue donc que s'il reste quelque chose à poser — un @@ -5932,10 +5939,16 @@ class TODO: # La commande à copier, avec l'adresse déjà remplie : un émulateur # dont on ignore comment l'ouvrir ne sert à personne. "ip=$(hostname -I 2>/dev/null | awk '{print $1}'); " + # Chemins ABSOLUS, et c'est le point : « ssh hôte 'commande' » + # ne lit NI ~/.profile NI ~/.bashrc — Ubuntu place même un + # « return » en tête du second pour les shells non interactifs. + # Le PATH que l'installateur y écrit ne s'applique donc jamais + # à ces commandes, et « emulator » y répond « command not + # found ». Vécu, sur la ligne que ce message affichait lui-même. f'echo " {t("open it from your workstation:")} ' - 'ssh -X erplibre@$ip \\"emulator -avd erplibre -no-audio\\""; ' + 'ssh -X erplibre@$ip \\"$HOME/android/emulator/emulator -avd erplibre -no-audio\\""; ' f'echo " {t("then install the APK:")} ' - 'ssh erplibre@$ip \\"adb install -r ' + 'ssh erplibre@$ip \\"$HOME/android/platform-tools/adb install -r ' f"{el_dir}/mobile/erplibre_home_mobile/android/app/build" '/outputs/apk/debug/app-debug.apk\\""' ) diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index ec65625..e2db8e6 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -413,6 +413,21 @@ class TestAndroidEmulator(unittest.TestCase): self.assertIn("ssh -X erplibre@$ip", self.cmd) self.assertIn("adb install -r", self.cmd) + def test_the_printed_commands_use_absolute_paths(self): + """« ssh hôte 'commande' » ne lit ni ~/.profile ni ~/.bashrc — Ubuntu y + met même un « return » pour les shells non interactifs. Une commande + affichée qui compte sur le PATH répond « command not found ». Vécu.""" + self.assertIn("$HOME/android/emulator/emulator", self.cmd) + self.assertIn("$HOME/android/platform-tools/adb", self.cmd) + self.assertNotIn('"emulator -avd', self.cmd) + self.assertNotIn('"adb install', self.cmd) + + def test_the_windowed_emulator_gets_its_audio_library(self): + """Deux binaires qemu : seul le « headless » se passe de PulseAudio. + Celui qui ouvre une fenêtre lie libpulse.so.0, absente des images + cloud, et échoue même avec « -no-audio ».""" + self.assertIn("libpulse0", self.cmd) + def test_one_prologue_and_one_sdk_for_both_options(self): """Deux prologues, et le second tronquerait le journal du premier.""" both = self.todo._qemu_after_remote_cmd(("mobile", "avd")) From 07b4c81207dfa550f13676da3081def28c336ca6 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 18 Aug 2026 02:53:17 -0400 Subject: [PATCH 23/55] [FIX] todo qemu: find the APK where AGP writes it, and install it as it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app runs in the emulator now, and two of my own checks were wrong. With an injected ABI, AGP writes to intermediates/apk/debug, not outputs/apk/debug: a SUCCESSFUL build was reported "no APK produced" because only the second path was searched. That same mode marks the APK testOnly, so adb refuses it without -t — INSTALL_FAILED_TEST_ONLY. Both measured on the emulator, where ca.erplibre.home then installed, launched, and ran its SQLite migrations. The manifest also stops tracking sentencepiece at master, which made the build non-reproducible and, since August, unbuildable: master fetches protobuf and compiles protoc for the Android target before running it on the host. v0.2.1 ships its .pb.cc pre-generated. Its own absl subset still lacks flags/flag.cc, so that library remains to be sorted out upstream. --- FR --- L'application tourne dans l'émulateur, et deux de mes propres contrôles étaient faux. Avec une ABI injectée, AGP écrit dans intermediates/apk/debug et non dans outputs/apk/debug : une compilation RÉUSSIE était rapportée « aucun APK produit » parce que je ne regardais que le second chemin. Ce même mode marque l'APK testOnly, et adb le refuse sans -t — INSTALL_FAILED_TEST_ONLY. Les deux mesurés sur l'émulateur, où ca.erplibre.home s'est ensuite installé, lancé, et a joué ses migrations SQLite. Le manifeste cesse aussi de suivre sentencepiece à master, ce qui rendait la compilation non reproductible et, depuis août, impossible : master récupère protobuf et compile protoc pour la cible Android avant de l'exécuter sur l'hôte. La v0.2.1 livre ses .pb.cc pré-générés. Son propre sous-ensemble absl manque encore flags/flag.cc : cette bibliothèque reste à régler en amont. Assisted-by: Claude Opus 5 --- manifest/git_manifest_mobile_home.xml | 12 ++++++++++-- script/todo/todo.py | 15 +++++++++++++-- test/test_qemu_desktop_tools.py | 7 +++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/manifest/git_manifest_mobile_home.xml b/manifest/git_manifest_mobile_home.xml index 54d42ec..e91fffa 100644 --- a/manifest/git_manifest_mobile_home.xml +++ b/manifest/git_manifest_mobile_home.xml @@ -12,12 +12,20 @@ groups="mobile" /> - + diff --git a/script/todo/todo.py b/script/todo/todo.py index 48f4394..a0a6238 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5859,8 +5859,16 @@ class TODO: f"'cd {el_dir}/mobile/erplibre_home_mobile && npm test' && " # L'APK est la preuve, pas le code de sortie de Gradle : une tâche # peut rendre 0 sans avoir rien produit. + # DEUX emplacements, et il faut les deux. Avec une ABI injectée, + # AGP écrit dans « intermediates/apk/debug » et non dans + # « outputs/apk/debug » : mesuré, une compilation RÉUSSIE était + # rapportée « aucun APK produit » parce que je ne regardais que le + # second. Un contrôle qui cherche au mauvais endroit ne vaut pas + # mieux que pas de contrôle. f"apk=$(ls {el_dir}/mobile/erplibre_home_mobile/android/app/build" - "/outputs/apk/debug/*.apk 2>/dev/null | head -1); " + "/outputs/apk/debug/*.apk " + f"{el_dir}/mobile/erplibre_home_mobile/android/app/build" + "/intermediates/apk/debug/*.apk 2>/dev/null | head -1); " 'if [ -n "$apk" ]; then ' f'echo " ✅ {t("APK built:")} $apk"; ' # Capacitor sert la même application dans un navigateur : sur une @@ -5948,7 +5956,10 @@ class TODO: f'echo " {t("open it from your workstation:")} ' 'ssh -X erplibre@$ip \\"$HOME/android/emulator/emulator -avd erplibre -no-audio\\""; ' f'echo " {t("then install the APK:")} ' - 'ssh erplibre@$ip \\"$HOME/android/platform-tools/adb install -r ' + # « -t » : l'ABI injectée fait marquer l'APK « testOnly » par AGP, + # et adb le refuse sans ce drapeau — « INSTALL_FAILED_TEST_ONLY ». + # Mesuré sur l'émulateur. + 'ssh erplibre@$ip \\"$HOME/android/platform-tools/adb install -r -t ' f"{el_dir}/mobile/erplibre_home_mobile/android/app/build" '/outputs/apk/debug/app-debug.apk\\""' ) diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index e2db8e6..e31e268 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -339,6 +339,13 @@ class TestMobileBuild(unittest.TestCase): cmd = self.todo._qemu_mobile_remote_cmd() self.assertIn("outputs/apk/debug/*.apk", cmd) + def test_both_apk_locations_are_searched(self): + """Avec une ABI injectée, AGP écrit dans intermediates et non dans + outputs : mesuré, une compilation RÉUSSIE était rapportée « aucun APK + produit » parce qu'un seul des deux chemins était regardé.""" + cmd = self.todo._qemu_mobile_remote_cmd() + self.assertIn("intermediates/apk/debug/*.apk", cmd) + def test_no_apk_means_non_zero(self): """Éprouvé plutôt que relu : sans APK, le bloc DOIT rendre non nul. Une ligne d'information placée après le « fi » suffisait à rendre 0 et From ef57e1a097527fb608a6b3969f89d9743e92b298 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 18 Aug 2026 03:31:53 -0400 Subject: [PATCH 24/55] [IMP] todo qemu: shrink the emulator screen so a remote display keeps up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "It launches but it is too slow." The Pixel profile gives 1080x2400 — 2.6 megapixels pushed frame by frame through SSH, in software rendering. The AVD now declares 540x1140 at 240 dpi: 0.62 megapixel, 4.2 times less. Measured on the VM after the change: wm size reports 540x1140, and a full-screen capture drops from 220 KB to 49 KB. Android handles the density and the app does not notice; whoever wants the real size overrides it with -skin 1080x2400. The printed command also gained -XC over -X, X11 compression being free on a remote display, and -no-boot-anim. Checked: ca.erplibre.home relaunched at the new size, MainActivity resumed. --- FR --- « Ça se lance mais c'est trop lent. » Le profil Pixel donne 1080x2400, soit 2,6 mégapixels poussés image par image dans SSH, en rendu logiciel. L'AVD déclare maintenant 540x1140 à 240 ppp : 0,62 mégapixel, 4,2 fois moins. Mesuré sur la VM après le changement : « wm size » rend 540x1140, et une capture plein écran tombe de 220 Ko à 49 Ko. Android gère la densité et l'application ne s'en aperçoit pas ; qui veut la taille réelle l'écrase par « -skin 1080x2400 ». La commande affichée gagne aussi « -XC » au lieu de « -X », la compression X11 étant gratuite sur un écran distant, et « -no-boot-anim ». Vérifié : ca.erplibre.home relancé à la nouvelle taille, MainActivity au premier plan. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 15 +++++++++++++-- test/test_qemu_desktop_tools.py | 19 ++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index a0a6238..3ede25f 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5939,7 +5939,14 @@ class TODO: '-d "$dev" --force && ' # Rendu logiciel, écrit dans la config : par ssh -X il n'y a pas de # GLX direct, et « auto » donnerait un écran noir. - 'printf "hw.gpu.enabled=yes\\nhw.gpu.mode=swiftshader_indirect\\n" ' + # L'écran, RÉDUIT, et c'est ce réglage qui décide du confort. Le + # profil Pixel donne 1080x2400, soit 2,6 Mpixels à pousser + # image par image à travers SSH, en rendu logiciel : « ça se + # lance mais c'est trop lent ». En 540x1140 il en reste + # 0,62 Mpixel — 4,2 fois moins. Android gère la densité et + # l'application ne s'en aperçoit pas ; qui veut la taille + # réelle l'écrase au lancement par « -skin 1080x2400 ». + 'printf "hw.gpu.enabled=yes\\nhw.gpu.mode=swiftshader_indirect\\nhw.lcd.width=540\\nhw.lcd.height=1140\\nhw.lcd.density=240\\n" ' ">> $HOME/.android/avd/erplibre.avd/config.ini' && " f'echo " ✅ {t("AVD ready:")} ' "$(cat $HOME/.erplibre-avd-device) / " @@ -5954,7 +5961,11 @@ class TODO: # à ces commandes, et « emulator » y répond « command not # found ». Vécu, sur la ligne que ce message affichait lui-même. f'echo " {t("open it from your workstation:")} ' - 'ssh -X erplibre@$ip \\"$HOME/android/emulator/emulator -avd erplibre -no-audio\\""; ' + # « -XC » et non « -X » : la compression X11 change tout sur un + # écran distant. « -no-boot-anim » retire une animation qui + # ne sert qu'à faire attendre. + 'ssh -XC erplibre@$ip \\"$HOME/android/emulator/emulator ' + '-avd erplibre -no-audio -no-boot-anim\\""; ' f'echo " {t("then install the APK:")} ' # « -t » : l'ABI injectée fait marquer l'APK « testOnly » par AGP, # et adb le refuse sans ce drapeau — « INSTALL_FAILED_TEST_ONLY ». diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index e31e268..f6e2bd4 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -386,6 +386,23 @@ class TestAndroidEmulator(unittest.TestCase): "avd", self.todo._qemu_tools_for(("avd",), "amd64", "", "ubuntu") ) + def test_the_screen_is_small_enough_to_travel(self): + """Le profil Pixel donne 1080x2400 : 2,6 Mpixels par image à pousser + dans SSH, et « ça se lance mais c'est trop lent ». Mesuré après + réduction : 540x1140 confirmé par « wm size », et la capture pleine + page tombe de 220 Ko à 49 Ko.""" + for key in ( + "hw.lcd.width=540", + "hw.lcd.height=1140", + "hw.lcd.density=240", + ): + self.assertIn(key, self.cmd, key) + + def test_the_printed_command_compresses_the_display(self): + """« -XC » plutôt que « -X » sur un écran distant.""" + self.assertIn("ssh -XC erplibre@$ip", self.cmd) + self.assertNotIn("ssh -X erplibre@$ip", self.cmd.replace("-XC", "")) + def test_software_rendering_is_written_into_the_avd(self): """Par « ssh -X » il n'y a pas de GLX direct : en « auto », l'émulateur s'ouvre sur un écran noir. Le réglage va dans config.ini @@ -417,7 +434,7 @@ class TestAndroidEmulator(unittest.TestCase): def test_it_prints_the_command_to_open_it(self): """Un émulateur dont on ignore comment l'ouvrir ne sert à personne.""" - self.assertIn("ssh -X erplibre@$ip", self.cmd) + self.assertIn("ssh -XC erplibre@$ip", self.cmd) self.assertIn("adb install -r", self.cmd) def test_the_printed_commands_use_absolute_paths(self): From 1dcd19b02fb02cfcaf845db64288d5004d6ceed0 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 02:03:42 -0400 Subject: [PATCH 25/55] [FIX] todo qemu: name a GPU mode the emulator still accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from a workstation: "Selected GPU option 'swiftshader_indirect' is not valid, switching to auto", then "Your GPU drivers may have a bug". That mode no longer exists in emulator 37.1; it fell back to swangle by itself, so it worked while printing two errors that read like a fault. emulator -help-gpu lists exactly four modes: auto, host, swiftshader, swangle. The AVD now asks for swangle. Checked on the VM: zero GPU errors where there were two, vulkan:swiftshader gles:swangle selected outright, boot in 55 s. --- FR --- Remonté depuis un poste : « Selected GPU option 'swiftshader_indirect' is not valid, switching to auto », puis « Your GPU drivers may have a bug ». Ce mode n'existe plus dans l'émulateur 37.1 ; il retombait de lui-même sur swangle, donc cela fonctionnait en affichant deux erreurs qui se lisent comme une panne. « emulator -help-gpu » énumère exactement quatre modes : auto, host, swiftshader, swangle. L'AVD demande désormais swangle. Vérifié sur la VM : zéro erreur GPU là où il y en avait deux, vulkan:swiftshader gles:swangle choisis d'emblée, boot en 55 s. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 18 +++++++++++++----- test/test_qemu_desktop_tools.py | 5 ++++- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index 3ede25f..0277423 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5897,10 +5897,18 @@ class TODO: L'image système suit la plateforme du projet, et redescend si elle n'est pas publiée — Google ne fournit pas d'image pour toutes les API. - Le rendu est réglé sur swiftshader_indirect, DANS la configuration de - l'AVD plutôt qu'en option de lancement : par « ssh -X » il n'y a pas de - GLX direct, et l'émulateur s'ouvrirait sur un écran noir. Ainsi - « emulator -avd erplibre » suffit, sans rien à retenir. + Le rendu est réglé en logiciel DANS la configuration de l'AVD plutôt + qu'en option de lancement : par « ssh -X » il n'y a pas de GLX direct, et + l'émulateur s'ouvrirait sur un écran noir. Ainsi « emulator -avd + erplibre » suffit, sans rien à retenir. + + Le mode est « swangle » — ANGLE sur SwiftShader — et non + « swiftshader_indirect », qui n'existe PLUS : l'émulateur 37.1 répond + « Selected GPU option 'swiftshader_indirect' is not valid, switching to + auto », puis « Your GPU drivers may have a bug », avant de retomber de + lui-même sur swangle. Il fonctionnait, en affichant deux erreurs qui + laissaient croire à une panne. Les modes valides sont exactement quatre, + que « emulator -help-gpu » énumère : auto, host, swiftshader, swangle. """ return ( f'echo " == {t("Android emulator (AVD)")} =="; ' @@ -5946,7 +5954,7 @@ class TODO: # 0,62 Mpixel — 4,2 fois moins. Android gère la densité et # l'application ne s'en aperçoit pas ; qui veut la taille # réelle l'écrase au lancement par « -skin 1080x2400 ». - 'printf "hw.gpu.enabled=yes\\nhw.gpu.mode=swiftshader_indirect\\nhw.lcd.width=540\\nhw.lcd.height=1140\\nhw.lcd.density=240\\n" ' + 'printf "hw.gpu.enabled=yes\\nhw.gpu.mode=swangle\\nhw.lcd.width=540\\nhw.lcd.height=1140\\nhw.lcd.density=240\\n" ' ">> $HOME/.android/avd/erplibre.avd/config.ini' && " f'echo " ✅ {t("AVD ready:")} ' "$(cat $HOME/.erplibre-avd-device) / " diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index f6e2bd4..26f7503 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -407,8 +407,11 @@ class TestAndroidEmulator(unittest.TestCase): """Par « ssh -X » il n'y a pas de GLX direct : en « auto », l'émulateur s'ouvre sur un écran noir. Le réglage va dans config.ini pour qu'« emulator -avd erplibre » suffise.""" - self.assertIn("hw.gpu.mode=swiftshader_indirect", self.cmd) + self.assertIn("hw.gpu.mode=swangle", self.cmd) self.assertIn("config.ini", self.cmd) + # « swiftshader_indirect » n'existe plus : l'émulateur 37.1 le refuse, + # affiche deux erreurs et retombe sur swangle de lui-même. Mesuré. + self.assertNotIn("swiftshader_indirect", self.cmd) def test_xauth_is_installed(self): """Sans xauth dans la VM, « ssh -X » n'ouvre aucun affichage — et le From 7a65568098dd66cc5bb8abfc14e8ee8bc60f79c6 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 02:39:10 -0400 Subject: [PATCH 26/55] [ADD] todo qemu: offer the adb tunnel for scrcpy, X11-free MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X11 carries raw pixels: 0.62 megapixel per frame even after shrinking the screen, rendered in software. scrcpy receives an H.264 stream encoded BY the device, so the emulator runs with no window at all — no X11 anywhere. The tunnel menu gained a fourth kind, reusing the target list it already reads from ~/.ssh/config, ProxyJump included: the hard part was already solved there. It forwards 5555, the emulator's own adb port, not 5037: tunnelling the adb server would force the user to kill the one on their workstation, which holds the same port. Verified through the tunnel from the hypervisor: an adb CNXN handshake answers device::ro.product.name=sdk_gphone64_x86, exactly what adb connect does. ss -ltn in the VM confirms 5554, 5555 and 5037 all listen on 127.0.0.1. --- FR --- X11 transporte des pixels bruts : 0,62 mégapixel par image même après réduction de l'écran, en rendu logiciel. scrcpy reçoit un flux H.264 encodé PAR l'appareil, si bien que l'émulateur tourne sans aucune fenêtre — plus de X11 nulle part. Le menu de tunnel gagne un quatrième type, qui réutilise la liste de cibles qu'il lit déjà dans ~/.ssh/config, ProxyJump compris : le plus dur y était déjà résolu. Il redirige 5555, le port adb de l'émulateur lui-même, et non 5037 : tunneliser le serveur adb obligerait à tuer celui du poste, qui occupe le même port. Vérifié à travers le tunnel depuis l'hyperviseur : une poignée de main adb CNXN répond device::ro.product.name=sdk_gphone64_x86, ce que fait exactement adb connect. « ss -ltn » dans la VM confirme que 5554, 5555 et 5037 écoutent tous sur 127.0.0.1. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 75 ++++++++++++++++++++++++++++++++- script/todo/todo_i18n.py | 28 ++++++++++++ test/test_qemu_desktop_tools.py | 50 ++++++++++++++++++++++ 3 files changed, 151 insertions(+), 2 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index 0277423..b0a2f01 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1569,11 +1569,17 @@ class TODO: print(f"\n {t('Remote desktop kind:')}") print(f" [1] RDP 3389 (xrdp) *") print(f" [2] VNC 5901 (TigerVNC, Arch)") - print(f" [3] {t('Hypervisor console (QEMU screen, no guest server)')}") + print( + f" [3] {t('Hypervisor console (QEMU screen, no guest server)')}" + ) + print(f" [4] {t('Android emulator (adb 5555, then scrcpy)')}") kind_answer = input(f"{t('Choice')} [1]: ").strip() or "1" if kind_answer == "3": self._qemu_console_tunnel(name, src) return + if kind_answer == "4": + self._qemu_scrcpy_tunnel(name, src) + return port, kind = (5901, "VNC") if kind_answer == "2" else (3389, "RDP") local = port + 1 @@ -1601,6 +1607,65 @@ class TODO: ) print(f" {t('The tunnel stays open as long as that ssh runs.')}") + def _qemu_scrcpy_tunnel(self, name, src): + """Tunnel adb vers l'émulateur Android d'une VM, pour scrcpy. + + Pourquoi cette voie plutôt que « ssh -X » : par X11, chaque image de + l'écran traverse le réseau en pixels bruts — 0,62 Mpixel par image même + après réduction, en rendu logiciel. scrcpy, lui, reçoit un flux H.264 + encodé PAR l'appareil et le décode sur le poste. L'émulateur tourne + alors SANS fenêtre : plus de X11 du tout, ni sur l'hôte ni dans la VM. + + Le port est celui de l'émulateur, pas celui du serveur adb. Un émulateur + écoute sur 5554 (console) et 5555 (adb), tous deux sur le localhost de + la VM — vérifié par « ss -ltn ». C'est 5555 qu'il faut, et non 5037 : + tunneler le serveur adb obligerait à tuer celui du poste, qui occupe le + même port. + + Vérifié de bout en bout à travers le tunnel : une poignée de main adb + (paquet CNXN) reçoit « device::ro.product.name=sdk_gphone64_x86 » de + l'émulateur lui-même — c'est exactement ce que fait « adb connect ». + """ + port = 5555 + print(f"\n 📱 {t('Android emulator over adb + scrcpy')}") + print(f"\n {t('1. In the VM, start the emulator WITHOUT a window:')}") + emu = "$HOME/android/emulator/emulator" + if src == "ssh_config": + print( + f"\n ssh {name} '{emu} -avd erplibre" + " -no-window -no-audio -no-boot-anim'\n" + ) + else: + ip = self._qemu_resolve_ips([name]).get(name) + if not ip: + print(f" {t('No IP for this VM; is it running?')}") + return + print( + f"\n ssh erplibre@{ip} '{emu} -avd erplibre" + " -no-window -no-audio -no-boot-anim'\n" + ) + print(f" {t('2. Open the tunnel from YOUR workstation:')}") + if src == "ssh_config": + # « localhost » est résolu par le DERNIER saut, donc par la VM + # elle-même : le ProxyJump de ssh_config traverse les niveaux. + print(f"\n ssh -N -L {port}:localhost:{port} {name}\n") + print(f" {t('(through the ProxyJump already in ~/.ssh/config)')}") + else: + host, from_ssh = self._qemu_self_address() + user = os.environ.get("USER", "user") + if not from_ssh: + print( + f" ⚠ {t('Not in an SSH session: check the host address.')}" + ) + print(f"\n ssh -N -L {port}:{ip}:{port} {user}@{host}\n") + print(f" {t('3. Then, still on your workstation:')}") + print(f"\n adb connect localhost:{port}") + print(f" scrcpy -s localhost:{port}\n") + print(f" {t('The tunnel stays open as long as that ssh runs.')}") + print( + f" {t('scrcpy on Debian/Ubuntu:')} sudo apt install scrcpy" " adb" + ) + def _qemu_console_tunnel(self): """Tunnel vers l'ÉCRAN QEMU d'une VM, pas vers un serveur de l'invité. @@ -5980,7 +6045,13 @@ class TODO: # Mesuré sur l'émulateur. 'ssh erplibre@$ip \\"$HOME/android/platform-tools/adb install -r -t ' f"{el_dir}/mobile/erplibre_home_mobile/android/app/build" - '/outputs/apk/debug/app-debug.apk\\""' + '/outputs/apk/debug/app-debug.apk\\""; ' + # La voie scrcpy, nommée ici parce que c'est la première + # question qui vient après « ça se lance mais c'est lent » : + # X11 transporte des pixels bruts, scrcpy un flux H.264 encodé + # par l'appareil. Le détail du tunnel vit dans le menu + # « Remote desktop tunnel », choix 4. + + f'echo " {t("smoother, without X11:")} TODO > Execute > Deploy > QEMU/KVM > tunnel > 4"' ) def _qemu_after_remote_cmd(self, tools, prod=False): diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index d663c93..c20924a 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -7818,6 +7818,34 @@ TRANSLATIONS = { "fr": "venv ERPLibre absent (installation incomplète)", "en": "ERPLibre venv missing (incomplete install)", }, + "smoother, without X11:": { + "fr": "plus fluide, sans X11 :", + "en": "smoother, without X11:", + }, + "Android emulator (adb 5555, then scrcpy)": { + "fr": "Émulateur Android (adb 5555, puis scrcpy)", + "en": "Android emulator (adb 5555, then scrcpy)", + }, + "Android emulator over adb + scrcpy": { + "fr": "Émulateur Android par adb + scrcpy", + "en": "Android emulator over adb + scrcpy", + }, + "1. In the VM, start the emulator WITHOUT a window:": { + "fr": "1. Dans la VM, démarrer l'émulateur SANS fenêtre :", + "en": "1. In the VM, start the emulator WITHOUT a window:", + }, + "2. Open the tunnel from YOUR workstation:": { + "fr": "2. Ouvrir le tunnel depuis VOTRE poste :", + "en": "2. Open the tunnel from YOUR workstation:", + }, + "3. Then, still on your workstation:": { + "fr": "3. Puis, toujours sur votre poste :", + "en": "3. Then, still on your workstation:", + }, + "scrcpy on Debian/Ubuntu:": { + "fr": "scrcpy sur Debian/Ubuntu :", + "en": "scrcpy on Debian/Ubuntu:", + }, "Android emulator (Pixel)": { "fr": "Émulateur Android (Pixel)", "en": "Android emulator (Pixel)", diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index 26f7503..d4df110 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -582,6 +582,56 @@ class TestPycharmFirstOpen(unittest.TestCase): self.assertEqual(0, res.returncode, res.stderr) +class TestScrcpyTunnel(unittest.TestCase): + """Tunnel adb pour scrcpy : la voie sans X11.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + + def _printed(self, name, src): + import io + import contextlib + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + self.todo._qemu_scrcpy_tunnel(name, src) + return buf.getvalue() + + def test_it_tunnels_the_emulator_port_not_the_adb_server(self): + """5555 est l'adb de l'émulateur ; 5037 est le serveur adb, et le + tunneliser obligerait à tuer celui du poste, qui occupe le même port. + Vérifié dans la VM par « ss -ltn » : 5554, 5555 et 5037 écoutent tous + sur 127.0.0.1.""" + out = self._printed("saut+vm", "ssh_config") + self.assertIn("-L 5555:localhost:5555", out) + self.assertNotIn("5037", out) + + def test_it_starts_the_emulator_without_a_window(self): + """Tout l'intérêt : plus de X11 nulle part, le flux est du H.264.""" + out = self._printed("saut+vm", "ssh_config") + self.assertIn("-no-window", out) + self.assertNotIn("ssh -X", out) + + def test_it_gives_the_three_workstation_commands(self): + out = self._printed("saut+vm", "ssh_config") + self.assertIn("adb connect localhost:5555", out) + self.assertIn("scrcpy -s localhost:5555", out) + self.assertIn("apt install scrcpy", out) + + def test_it_uses_the_ssh_config_target_as_is(self): + """« localhost » est résolu par le DERNIER saut : le ProxyJump déjà + écrit dans ~/.ssh/config traverse les niveaux, on ne le réinvente pas. + """ + out = self._printed("test-vm_02+erplibre-mobile-proof", "ssh_config") + self.assertIn("test-vm_02+erplibre-mobile-proof", out) + self.assertIn("ProxyJump", out) + + def test_the_avd_step_points_at_it(self): + """La question vient juste après « c'est trop lent » : la réponse doit + être à portée de journal.""" + self.assertIn("tunnel > 4", self.todo._qemu_avd_remote_cmd()) + + class TestGnomeSiteExtensions(unittest.TestCase): """Extensions posées depuis extensions.gnome.org, par leur UUID.""" From 13e8b1bd2db95098c95fe91b3a2b4b493c835a13 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 03:35:31 -0400 Subject: [PATCH 27/55] =?UTF-8?q?[ADD]=20script=20todo:=20lancer=20l'?= =?UTF-8?q?=C3=A9mulateur=20Android=20et=20son=20tunnel=20adb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le tunnel n'était que du texte à recopier, et le démarrage restait manuel : un second émulateur sur le même AVD tue le premier (« Running multiple emulators »), rencontré deux fois. Le menu le détecte donc avant de lancer. La fenêtre décide du reste. Sans elle, todo.py démarre à distance, détaché par setsid dans le groupe kvm, et enchaîne sur scrcpy ; avec elle, la commande doit partir du poste qui possède l'affichage, pas d'ici. Mesuré : l'émulateur n'écoute que sur le 127.0.0.1 de la VM — l'hyperviseur est refusé sur IP:5555 — d'où le -J qui met la VM en dernier saut. Vérifié sur erplibre-mobile-proof : poignée de main adb CNXN rendant sdk_gphone64_x86_64, enveloppe détachée survivant au ssh ; 30 tests. --- EN --- The tunnel was only text to copy, and starting the emulator stayed manual: a second emulator on the same AVD kills the first ("Running multiple emulators"), hit twice. The menu now detects it before starting. The window decides the rest. Without one, todo.py starts it remotely, detached by setsid in the kvm group, and chains into scrcpy; with one, the command must run from the workstation that owns the display, not from here. Measured: the emulator only listens on the VM's 127.0.0.1 — the hypervisor is refused on IP:5555 — hence the -J putting the VM last. Verified on erplibre-mobile-proof: an adb CNXN handshake returning sdk_gphone64_x86_64, a detached wrapper outliving the ssh; 30 tests. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 242 ++++++++++++++++++++-- script/todo/todo_i18n.py | 77 +++++++ test/test_qemu_desktop_tools.py | 48 +---- test/test_qemu_emulator_menu.py | 354 ++++++++++++++++++++++++++++++++ 4 files changed, 664 insertions(+), 57 deletions(-) create mode 100644 test/test_qemu_emulator_menu.py diff --git a/script/todo/todo.py b/script/todo/todo.py index b0a2f01..7fabba3 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1315,6 +1315,11 @@ class TODO: "Remote desktop tunnel (VNC/RDP through SSH)" ) }, + { + "prompt_description": t( + "Android emulator (start, tunnel, scrcpy)" + ) + }, {"section": t("Catalog")}, {"prompt_description": t("List available images and specs")}, ] @@ -1357,6 +1362,8 @@ class TODO: elif status == "14": self._qemu_tunnel_menu() elif status == "15": + self._qemu_emulator_menu() + elif status == "16": self._qemu_list_images() else: cmd_no_found = True @@ -1607,7 +1614,159 @@ class TODO: ) print(f" {t('The tunnel stays open as long as that ssh runs.')}") - def _qemu_scrcpy_tunnel(self, name, src): + @staticmethod + def _qemu_ssh_opts(src): + """Options ssh selon la provenance de la cible. + + Une VM libvirt locale est jointe par son IP, et son IP est recyclée d'un + déploiement à l'autre : sa clé d'hôte change sous le même adresse, et + ssh refuse alors de se connecter — « Host key verification failed », + vécu. C'est la raison pour laquelle le suivi d'installation et l'attente + de sshd emploient déjà ces deux options. + + Un hôte de ~/.ssh/config, lui, est une machine que l'utilisateur a + configurée : on ne touche PAS à sa politique de clés. Sa clé est un + garde-fou qui lui appartient.""" + if src == "ssh_config": + return ["-o", "BatchMode=yes", "-o", "ConnectTimeout=8"] + return [ + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=8", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + ] + + def _qemu_ssh_target(self, name, src): + """Destination ssh d'une cible du menu, selon sa provenance. + + Un hôte de ~/.ssh/config se nomme tel quel — c'est lui qui porte le + ProxyJump, et le réécrire à la main reviendrait à le deviner. Un domaine + libvirt local, lui, n'a qu'une IP, et l'utilisateur des VM ERPLibre est + « erplibre ». Renvoie une chaîne vide quand l'IP manque.""" + if src == "ssh_config": + return name + ip = self._qemu_resolve_ips([name]).get(name) + return f"erplibre@{ip}" if ip else "" + + # Commande de l'émulateur dans la VM. Le chemin est ABSOLU : un + # « ssh hôte 'commande' » ne lit ni ~/.profile ni ~/.bashrc. + _QEMU_EMULATOR_BIN = "$HOME/android/emulator/emulator" + _QEMU_AVD_NAME = "erplibre" + + def _qemu_emulator_running(self, target, src="virsh"): + """Nombre d'émulateurs en cours dans la VM. + + Deux sur le même AVD, et le second s'arrête sur « Running multiple + emulators with the same AVD is an experimental feature ». Le savoir + AVANT de lancer évite de lire cette phrase sans la comprendre — vécu, + deux fois.""" + try: + res = subprocess.run( + ["ssh"] + + self._qemu_ssh_opts(src) + + [target, "pgrep -c qemu-system 2>/dev/null || echo 0"], + capture_output=True, + text=True, + timeout=25, + ) + return int((res.stdout or "0").strip().splitlines()[-1]) + except (OSError, subprocess.SubprocessError, ValueError, IndexError): + return -1 + + def _qemu_emulator_menu(self): + """Démarre l'émulateur Android d'une VM, et donne la suite qui va avec. + + La question qui décide de tout est celle de la FENÊTRE : + - avec fenêtre, l'écran voyage en pixels bruts par X11, et la commande + doit partir du poste qui possède l'affichage — donc pas d'ici ; + - sans fenêtre, on peut la lancer d'ici, détachée, et l'image arrive + ensuite par scrcpy en H.264. C'est la voie fluide. + """ + print(f"\n📱 {t('Android emulator')}") + targets = [(h, "ssh_config") for h in self._ssh_config_hosts()] + if not targets: + targets = [(n, "virsh") for n in self._qemu_list_domains()] + if not targets: + print(f" {t('No host in ~/.ssh/config and no local VM.')}") + return + for i, (nm, sr) in enumerate(targets, 1): + mark = "" if sr == "ssh_config" else f" ({t('local VM')})" + print(f" [{i}] {nm}{mark}") + answer = input(f"{t('Which VM?')} [1]: ").strip() or "1" + if not answer.isdigit() or not (1 <= int(answer) <= len(targets)): + print(t("Cancelled.")) + return + name, src = targets[int(answer) - 1] + target = self._qemu_ssh_target(name, src) + if not target: + print(f" {t('No IP for this VM; is it running?')}") + return + + running = self._qemu_emulator_running(target, src) + if running > 0: + print(f"\n ⚠ {t('An emulator is already running on this VM.')}") + print(f" {t('Only one per AVD; close it first:')}") + print(f"\n ssh {target} 'pkill -f \"[q]emu-system-x86_64\"'\n") + if not self._is_yes(input(t("Close it now? (y/N): "))): + return + subprocess.run( + ["ssh"] + + self._qemu_ssh_opts(src) + + [target, 'pkill -f "[q]emu-system-x86_64"'], + capture_output=True, + timeout=30, + ) + print(f" {t('Closed.')}") + + print(f"\n {t('Show a window?')}") + print(f" [1] {t('No window - stream with scrcpy (smoother)')} *") + print(f" [2] {t('Window over ssh -X (raw pixels, slower)')}") + kind = input(f"{t('Choice')} [1]: ").strip() or "1" + emu = self._QEMU_EMULATOR_BIN + avd = self._QEMU_AVD_NAME + + if kind == "2": + # L'affichage appartient au POSTE : cette commande ne peut pas + # partir d'ici, où il n'y a pas d'écran à lui donner. + print(f"\n {t('Run this on YOUR workstation:')}") + print( + f"\n ssh -XC {target} '{emu} -avd {avd}" + " -no-audio -no-boot-anim'\n" + ) + print( + f" {t('X11 compression is on (-XC); the screen is 540x1140.')}" + ) + return + + print(f"\n {t('Starting the emulator without a window...')}") + # « sg kvm » : l'appartenance au groupe est posée à l'installation, mais + # une VM créée avant ce correctif ne l'a pas dans sa session — sans KVM + # l'émulateur refuse de démarrer. setsid le détache, pour qu'il survive + # à la fermeture de ce ssh. + start = ( + f'setsid -f sg kvm -c "{emu} -avd {avd} -no-window -no-audio' + ' -no-boot-anim > /tmp/erplibre-emulator.log 2>&1"' + ) + res = subprocess.run( + ["ssh"] + self._qemu_ssh_opts(src) + [target, start], + capture_output=True, + text=True, + timeout=60, + ) + if res.returncode: + print(f" ⚠ {t('Could not start it:')} {res.stderr.strip()[:200]}") + return + print( + f" {t('Started. Boot takes about a minute; log in the VM:')}" + " /tmp/erplibre-emulator.log" + ) + self._qemu_scrcpy_tunnel(name, src, started=True) + + def _qemu_scrcpy_tunnel(self, name, src, started=False): """Tunnel adb vers l'émulateur Android d'une VM, pour scrcpy. Pourquoi cette voie plutôt que « ssh -X » : par X11, chaque image de @@ -1627,21 +1786,21 @@ class TODO: l'émulateur lui-même — c'est exactement ce que fait « adb connect ». """ port = 5555 + target = self._qemu_ssh_target(name, src) + if not target: + print(f" {t('No IP for this VM; is it running?')}") + return print(f"\n 📱 {t('Android emulator over adb + scrcpy')}") - print(f"\n {t('1. In the VM, start the emulator WITHOUT a window:')}") - emu = "$HOME/android/emulator/emulator" - if src == "ssh_config": - print( - f"\n ssh {name} '{emu} -avd erplibre" - " -no-window -no-audio -no-boot-anim'\n" - ) + if started: + # Inutile de redire comment le démarrer : on vient de le faire. + print(f"\n {t('1. Emulator started, without a window.')}") else: - ip = self._qemu_resolve_ips([name]).get(name) - if not ip: - print(f" {t('No IP for this VM; is it running?')}") - return print( - f"\n ssh erplibre@{ip} '{emu} -avd erplibre" + f"\n {t('1. In the VM, start the emulator WITHOUT a window:')}" + ) + print( + f"\n ssh {target} '{self._QEMU_EMULATOR_BIN} " + f"-avd {self._QEMU_AVD_NAME}" " -no-window -no-audio -no-boot-anim'\n" ) print(f" {t('2. Open the tunnel from YOUR workstation:')}") @@ -1653,18 +1812,69 @@ class TODO: else: host, from_ssh = self._qemu_self_address() user = os.environ.get("USER", "user") + vm_ip = target.split("@")[-1] if not from_ssh: print( f" ⚠ {t('Not in an SSH session: check the host address.')}" ) - print(f"\n ssh -N -L {port}:{ip}:{port} {user}@{host}\n") + # DEUX sauts, et non un seul vers l'hyperviseur : l'émulateur + # n'écoute que sur le 127.0.0.1 de la VM — « ss -ltn » le montre, et + # l'hyperviseur reçoit un refus sur IP_VM:5555. Or « localhost » se + # résout sur le DERNIER hôte de la chaîne : la VM doit donc être ce + # dernier saut, l'hyperviseur n'étant que le relais (-J). + print( + f"\n ssh -N -L {port}:localhost:{port}" + f" -J {user}@{host} erplibre@{vm_ip}\n" + ) + print( + f" {t('(the hypervisor only relays; -J puts the VM last)')}" + ) print(f" {t('3. Then, still on your workstation:')}") print(f"\n adb connect localhost:{port}") print(f" scrcpy -s localhost:{port}\n") print(f" {t('The tunnel stays open as long as that ssh runs.')}") - print( - f" {t('scrcpy on Debian/Ubuntu:')} sudo apt install scrcpy" " adb" + print(f" {t('scrcpy on Debian/Ubuntu:')} sudo apt install scrcpy adb") + + # Ouvrir le tunnel D'ICI n'a de sens que si scrcpy tournera ici : le + # port ressort sur CETTE machine. On le propose donc en le disant, + # plutôt que de le faire d'office depuis un hyperviseur sans écran. + print(f"\n {t('If scrcpy will run on THIS machine, I can open it.')}") + if not self._is_yes(input(t("Open the tunnel now? (y/N): "))): + return + if self._port_in_use(port): + print(f" ⚠ {t('Port already in use here:')} {port}") + print( + f" {t('Close the other tunnel first:')}" + f' pkill -f "{port}:localhost:{port}"' + ) + return + # « ExitOnForwardFailure » : sans lui, un ssh détaché rend 0 alors que + # la redirection a échoué — un succès annoncé pour un tunnel absent. + cmd = ( + ["ssh", "-f", "-N", "-o", "ExitOnForwardFailure=yes"] + + self._qemu_ssh_opts(src) + + ["-L", f"{port}:localhost:{port}", target] ) + res = subprocess.run(cmd, capture_output=True, text=True, timeout=40) + if res.returncode: + print(f" ⚠ {t('Tunnel failed:')} {res.stderr.strip()[:200]}") + return + print(f" ✅ {t('Tunnel open on localhost:')}{port}") + print( + f" {t('Then:')} adb connect localhost:{port}" + f" && scrcpy -s localhost:{port}" + ) + print(f' {t("To close it:")} pkill -f "{port}:localhost:{port}"') + + @staticmethod + def _port_in_use(port): + """Le port est-il déjà pris sur CETTE machine ? + + Un second tunnel sur le même port échouerait, et le message d'ssh + (« bind: Address already in use ») se perd en mode détaché.""" + with socket.socket() as sock: + sock.settimeout(1) + return sock.connect_ex(("127.0.0.1", port)) == 0 def _qemu_console_tunnel(self): """Tunnel vers l'ÉCRAN QEMU d'une VM, pas vers un serveur de l'invité. diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index c20924a..dd91e31 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2248,6 +2248,10 @@ TRANSLATIONS = { "fr": "(par le ProxyJump déjà dans ~/.ssh/config)", "en": "(through the ProxyJump already in ~/.ssh/config)", }, + "(the hypervisor only relays; -J puts the VM last)": { + "fr": "(l'hyperviseur ne fait que relayer ; -J met la VM en dernier)", + "en": "(the hypervisor only relays; -J puts the VM last)", + }, "No ~/.ssh/config entry; see SSH configuration.": { "fr": "Aucune entrée ~/.ssh/config ; voir Configuration SSH.", "en": "No ~/.ssh/config entry; see SSH configuration.", @@ -7822,6 +7826,79 @@ TRANSLATIONS = { "fr": "plus fluide, sans X11 :", "en": "smoother, without X11:", }, + "Android emulator": {"fr": "Émulateur Android", "en": "Android emulator"}, + "Android emulator (start, tunnel, scrcpy)": { + "fr": "Émulateur Android (démarrer, tunnel, scrcpy)", + "en": "Android emulator (start, tunnel, scrcpy)", + }, + "An emulator is already running on this VM.": { + "fr": "Un émulateur tourne déjà sur cette VM.", + "en": "An emulator is already running on this VM.", + }, + "Only one per AVD; close it first:": { + "fr": "Un seul par AVD ; le fermer d'abord :", + "en": "Only one per AVD; close it first:", + }, + "Close it now? (y/N): ": { + "fr": "Le fermer maintenant ? (o/N) : ", + "en": "Close it now? (y/N): ", + }, + "Closed.": {"fr": "Fermé.", "en": "Closed."}, + "Show a window?": { + "fr": "Afficher une fenêtre ?", + "en": "Show a window?", + }, + "No window - stream with scrcpy (smoother)": { + "fr": "Sans fenêtre — flux par scrcpy (plus fluide)", + "en": "No window - stream with scrcpy (smoother)", + }, + "Window over ssh -X (raw pixels, slower)": { + "fr": "Fenêtre par ssh -X (pixels bruts, plus lent)", + "en": "Window over ssh -X (raw pixels, slower)", + }, + "X11 compression is on (-XC); the screen is 540x1140.": { + "fr": "La compression X11 est active (-XC) ; l'écran fait 540x1140.", + "en": "X11 compression is on (-XC); the screen is 540x1140.", + }, + "Starting the emulator without a window...": { + "fr": "Démarrage de l'émulateur sans fenêtre…", + "en": "Starting the emulator without a window...", + }, + "Could not start it:": { + "fr": "Démarrage impossible :", + "en": "Could not start it:", + }, + "Started. Boot takes about a minute; log in the VM:": { + "fr": "Démarré. L'amorçage prend environ une minute ; journal dans la VM :", + "en": "Started. Boot takes about a minute; log in the VM:", + }, + "1. Emulator started, without a window.": { + "fr": "1. Émulateur démarré, sans fenêtre.", + "en": "1. Emulator started, without a window.", + }, + "If scrcpy will run on THIS machine, I can open it.": { + "fr": "Si scrcpy tournera sur CETTE machine, je peux l'ouvrir.", + "en": "If scrcpy will run on THIS machine, I can open it.", + }, + "Open the tunnel now? (y/N): ": { + "fr": "Ouvrir le tunnel maintenant ? (o/N) : ", + "en": "Open the tunnel now? (y/N): ", + }, + "Port already in use here:": { + "fr": "Port déjà occupé ici :", + "en": "Port already in use here:", + }, + "Close the other tunnel first:": { + "fr": "Fermer l'autre tunnel d'abord :", + "en": "Close the other tunnel first:", + }, + "Tunnel failed:": {"fr": "Tunnel en échec :", "en": "Tunnel failed:"}, + "Tunnel open on localhost:": { + "fr": "Tunnel ouvert sur localhost:", + "en": "Tunnel open on localhost:", + }, + "Then:": {"fr": "Puis :", "en": "Then:"}, + "To close it:": {"fr": "Pour le fermer :", "en": "To close it:"}, "Android emulator (adb 5555, then scrcpy)": { "fr": "Émulateur Android (adb 5555, puis scrcpy)", "en": "Android emulator (adb 5555, then scrcpy)", diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index d4df110..25b59c1 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -582,51 +582,17 @@ class TestPycharmFirstOpen(unittest.TestCase): self.assertEqual(0, res.returncode, res.stderr) -class TestScrcpyTunnel(unittest.TestCase): - """Tunnel adb pour scrcpy : la voie sans X11.""" +class TestAvdStep(unittest.TestCase): + """L'étape AVD, et ce qu'elle laisse comme piste dans le journal. + + Le tunnel adb lui-même est vérifié dans test_qemu_emulator_menu.py, qui + couvre aussi le démarrage sans fenêtre et la question de la fenêtre. + """ def setUp(self): self.todo = TODO.__new__(TODO) - def _printed(self, name, src): - import io - import contextlib - - buf = io.StringIO() - with contextlib.redirect_stdout(buf): - self.todo._qemu_scrcpy_tunnel(name, src) - return buf.getvalue() - - def test_it_tunnels_the_emulator_port_not_the_adb_server(self): - """5555 est l'adb de l'émulateur ; 5037 est le serveur adb, et le - tunneliser obligerait à tuer celui du poste, qui occupe le même port. - Vérifié dans la VM par « ss -ltn » : 5554, 5555 et 5037 écoutent tous - sur 127.0.0.1.""" - out = self._printed("saut+vm", "ssh_config") - self.assertIn("-L 5555:localhost:5555", out) - self.assertNotIn("5037", out) - - def test_it_starts_the_emulator_without_a_window(self): - """Tout l'intérêt : plus de X11 nulle part, le flux est du H.264.""" - out = self._printed("saut+vm", "ssh_config") - self.assertIn("-no-window", out) - self.assertNotIn("ssh -X", out) - - def test_it_gives_the_three_workstation_commands(self): - out = self._printed("saut+vm", "ssh_config") - self.assertIn("adb connect localhost:5555", out) - self.assertIn("scrcpy -s localhost:5555", out) - self.assertIn("apt install scrcpy", out) - - def test_it_uses_the_ssh_config_target_as_is(self): - """« localhost » est résolu par le DERNIER saut : le ProxyJump déjà - écrit dans ~/.ssh/config traverse les niveaux, on ne le réinvente pas. - """ - out = self._printed("test-vm_02+erplibre-mobile-proof", "ssh_config") - self.assertIn("test-vm_02+erplibre-mobile-proof", out) - self.assertIn("ProxyJump", out) - - def test_the_avd_step_points_at_it(self): + def test_the_avd_step_points_at_the_tunnel(self): """La question vient juste après « c'est trop lent » : la réponse doit être à portée de journal.""" self.assertIn("tunnel > 4", self.todo._qemu_avd_remote_cmd()) diff --git a/test/test_qemu_emulator_menu.py b/test/test_qemu_emulator_menu.py new file mode 100644 index 0000000..4e871cd --- /dev/null +++ b/test/test_qemu_emulator_menu.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Menu de l'émulateur Android : démarrage, fenêtre ou non, tunnel adb. + +Ce qui se vérifie ici sans VM tient à ce qui a réellement cassé sur la VM de +preuve : une commande privée de son chemin absolu (« emulator: command not +found », un ssh non interactif ne lisant aucun rc), un second émulateur sur le +même AVD (« Running multiple emulators »), une clé d'hôte refusée sur une IP +recyclée, et un tunnel visant une adresse que l'émulateur n'écoute pas. + +Le tunnel est le point délicat : l'émulateur n'écoute que sur le 127.0.0.1 de +la VM (« ss -ltn » dans l'invité ; l'hyperviseur reçoit un refus sur +IP_VM:5555). Une redirection vers l'IP de la VM ne peut donc PAS aboutir, et +seul un dernier saut dans la VM place « localhost » au bon endroit. +""" + +import socket +import subprocess +import sys +import unittest +from unittest import mock + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 + + +def _run_ok(stdout="", returncode=0, stderr=""): + return subprocess.CompletedProcess( + args=[], returncode=returncode, stdout=stdout, stderr=stderr + ) + + +class TestSshOptions(unittest.TestCase): + """La politique de clés d'hôte n'est PAS la même selon la provenance.""" + + def test_local_vm_tolerates_a_recycled_host_key(self): + """Une IP libvirt est réattribuée d'un déploiement au suivant : la clé + change sous la même adresse et ssh refuse — « Host key verification + failed », vécu dans ce menu même.""" + opts = TODO._qemu_ssh_opts("virsh") + self.assertIn("StrictHostKeyChecking=no", opts) + self.assertIn("UserKnownHostsFile=/dev/null", opts) + + def test_a_configured_host_keeps_its_own_key_policy(self): + """Un hôte de ~/.ssh/config appartient à l'utilisateur : sa clé est un + garde-fou, et le désarmer en son nom serait une décision volée.""" + opts = TODO._qemu_ssh_opts("ssh_config") + self.assertNotIn("StrictHostKeyChecking=no", opts) + self.assertNotIn("UserKnownHostsFile=/dev/null", opts) + self.assertIn("BatchMode=yes", opts) + + def test_both_refuse_to_hang_on_a_password_prompt(self): + for src in ("virsh", "ssh_config"): + self.assertIn("BatchMode=yes", TODO._qemu_ssh_opts(src), src) + + +class TestSshTarget(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + + def test_a_configured_host_is_named_as_is(self): + """C'est l'entrée ssh_config qui porte le ProxyJump : la réécrire à la + main reviendrait à deviner la chaîne de sauts.""" + self.todo._qemu_resolve_ips = lambda *a, **k: {} + self.assertEqual( + self.todo._qemu_ssh_target("test-vm_02+proof", "ssh_config"), + "test-vm_02+proof", + ) + + def test_a_local_vm_is_reached_by_ip_as_erplibre(self): + self.todo._qemu_resolve_ips = lambda names, labels=None: { + "vm-a": "192.168.123.81" + } + self.assertEqual( + self.todo._qemu_ssh_target("vm-a", "virsh"), + "erplibre@192.168.123.81", + ) + + def test_no_ip_yields_no_target_rather_than_a_broken_one(self): + """Une VM éteinte n'a pas d'IP. Rendre « erplibre@None » enverrait ssh + résoudre un nom absurde au lieu de le dire.""" + self.todo._qemu_resolve_ips = lambda names, labels=None: {} + self.assertEqual(self.todo._qemu_ssh_target("vm-a", "virsh"), "") + + +class TestEmulatorRunning(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + + def test_counts_what_pgrep_reports(self): + with mock.patch("subprocess.run", return_value=_run_ok("1\n")): + self.assertEqual(self.todo._qemu_emulator_running("h"), 1) + + def test_reads_the_last_line_so_a_warning_does_not_fool_it(self): + """ssh écrit ses avertissements sur stdout dans certains cas ; le compte + est la DERNIÈRE ligne.""" + with mock.patch( + "subprocess.run", return_value=_run_ok("Warning: added key\n0\n") + ): + self.assertEqual(self.todo._qemu_emulator_running("h"), 0) + + def test_an_unreachable_vm_is_unknown_not_zero(self): + """Zéro voudrait dire « libre » et autoriserait un second émulateur sur + le même AVD. L'inconnu se distingue donc du vide.""" + with mock.patch("subprocess.run", side_effect=OSError): + self.assertEqual(self.todo._qemu_emulator_running("h"), -1) + with mock.patch("subprocess.run", return_value=_run_ok("bavardage\n")): + self.assertEqual(self.todo._qemu_emulator_running("h"), -1) + + +class _MenuCase(unittest.TestCase): + """Socle commun : une VM locale, des réponses scriptées, aucun vrai ssh.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.todo._ssh_config_hosts = lambda: [] + self.todo._qemu_list_domains = lambda: ["vm-a"] + self.todo._qemu_resolve_ips = lambda names, labels=None: { + "vm-a": "192.168.123.81" + } + self.todo._qemu_self_address = staticmethod(lambda: ("10.0.0.2", True)) + self.calls = [] + + def _play(self, answers, running=0, start_rc=0, port_taken=False): + """Joue le menu avec des réponses données ; rend (sortie, commandes).""" + + def fake_run(cmd, *a, **k): + self.calls.append(cmd) + joined = " ".join(cmd) + if "pgrep -c qemu-system" in joined: + return _run_ok(f"{running}\n") + if "setsid" in joined: + return _run_ok(returncode=start_rc, stderr="boum") + return _run_ok() + + it = iter(answers) + with mock.patch("subprocess.run", side_effect=fake_run), mock.patch( + "builtins.input", lambda *a: next(it) + ), mock.patch.object( + TODO, "_port_in_use", staticmethod(lambda p: port_taken) + ), mock.patch( + "sys.stdout", new_callable=__import__("io").StringIO + ) as out: + self.todo._qemu_emulator_menu() + return out.getvalue(), self.calls + + @staticmethod + def _started(calls): + return [c for c in calls if any("setsid" in x for x in c)] + + @staticmethod + def _tunnels(calls): + return [c for c in calls if "-f" in c and "-N" in c] + + +class TestEmulatorMenu(_MenuCase): + def test_no_target_at_all_says_so_without_touching_ssh(self): + self.todo._qemu_list_domains = lambda: [] + out, calls = self._play([]) + self.assertIn("~/.ssh/config", out) + self.assertEqual(calls, []) + + def test_an_off_vm_is_reported_before_any_start(self): + self.todo._qemu_resolve_ips = lambda names, labels=None: {} + out, calls = self._play(["1"]) + self.assertIn("IP", out.upper()) + self.assertEqual(self._started(calls), []) + + def test_a_running_emulator_is_seen_and_refusing_stops_there(self): + """Deux émulateurs sur un même AVD, et le second meurt sur « Running + multiple emulators with the same AVD » — vécu deux fois. On le dit + AVANT, et un refus ne démarre rien.""" + out, calls = self._play(["1", "n"], running=1) + self.assertIn("pkill -f", out) + # « [q]emu » : la classe empêche le pkill de se trouver lui-même. + self.assertIn("[q]emu-system", out) + self.assertEqual(self._started(calls), []) + + def test_accepting_closes_the_other_one_then_starts(self): + out, calls = self._play(["1", "o", "1", "n"], running=1) + killed = [c for c in calls if any("pkill" in x for x in c)] + self.assertTrue(killed, "aucun pkill envoyé") + self.assertTrue(self._started(calls), "rien démarré après fermeture") + + def test_the_window_choice_is_delegated_to_the_workstation(self): + """L'écran appartient au poste : cette commande ne peut pas partir de + l'hyperviseur, qui n'a aucun affichage à lui prêter.""" + out, calls = self._play(["1", "2"]) + self.assertIn("ssh -XC", out) + self.assertEqual(self._started(calls), []) + self.assertEqual(self._tunnels(calls), []) + + def test_the_headless_start_carries_what_the_vm_needs(self): + out, calls = self._play(["1", "1", "n"]) + started = self._started(calls) + self.assertEqual(len(started), 1) + cmd = started[0][-1] + # Chemin absolu : « ssh hôte 'commande' » ne lit ni ~/.profile ni + # ~/.bashrc, et « emulator » seul rend « command not found » — vécu. + self.assertIn("$HOME/android/emulator/emulator", cmd) + self.assertIn("-no-window", cmd) + # sg kvm : sans le groupe, l'émulateur n'a pas /dev/kvm et renonce. + self.assertIn("sg kvm", cmd) + # setsid : il doit survivre à la fermeture de ce ssh. + self.assertIn("setsid -f", cmd) + self.assertIn("/tmp/erplibre-emulator.log", cmd) + + def test_the_start_command_is_valid_shell(self): + """Une apostrophe ou un guillemet de trop, et la VM répond par une + erreur de syntaxe — déjà rencontré dans ce même fichier.""" + _, calls = self._play(["1", "1", "n"]) + cmd = self._started(calls)[0][-1] + res = subprocess.run( + ["bash", "-n"], input=cmd, capture_output=True, text=True + ) + self.assertEqual(res.returncode, 0, res.stderr) + + def test_a_failed_start_does_not_offer_a_tunnel_to_nothing(self): + out, calls = self._play(["1", "1"], start_rc=1) + self.assertIn("boum", out) + self.assertEqual(self._tunnels(calls), []) + + def test_a_successful_start_chains_into_the_tunnel_help(self): + out, _ = self._play(["1", "1", "n"]) + self.assertIn("scrcpy", out) + self.assertIn("adb connect localhost:5555", out) + + +class TestScrcpyTunnel(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + self.todo._qemu_resolve_ips = lambda names, labels=None: { + "vm-a": "192.168.123.81" + } + self.todo._qemu_self_address = staticmethod(lambda: ("10.0.0.2", True)) + self.calls = [] + + def _play( + self, + src="virsh", + answers=("n",), + rc=0, + port_taken=False, + started=False, + name="vm-a", + ): + def fake_run(cmd, *a, **k): + self.calls.append(cmd) + return _run_ok(returncode=rc, stderr="refus") + + it = iter(answers) + with mock.patch("subprocess.run", side_effect=fake_run), mock.patch( + "builtins.input", lambda *a: next(it) + ), mock.patch.object( + TODO, "_port_in_use", staticmethod(lambda p: port_taken) + ), mock.patch.dict( + "os.environ", {"USER": "poste"} + ), mock.patch( + "sys.stdout", new_callable=__import__("io").StringIO + ) as out: + self.todo._qemu_scrcpy_tunnel(name, src, started=started) + return out.getvalue(), self.calls + + def test_a_local_vm_needs_the_vm_as_the_LAST_hop(self): + """L'émulateur n'écoute que sur le 127.0.0.1 de la VM. Une redirection + vers IP_VM:5555 depuis l'hyperviseur est refusée (mesuré) : « localhost » + ne vaut que sur le dernier saut, d'où -J.""" + out, _ = self._play(src="virsh") + self.assertIn("-L 5555:localhost:5555", out) + self.assertIn("-J poste@10.0.0.2", out) + self.assertIn("erplibre@192.168.123.81", out) + self.assertNotIn("-L 5555:192.168.123.81:5555", out) + + def test_a_configured_host_rides_its_own_proxyjump(self): + out, _ = self._play(src="ssh_config", name="test-vm_02+proof") + self.assertIn("-L 5555:localhost:5555 test-vm_02+proof", out) + self.assertNotIn("-J", out) + self.assertIn("ProxyJump", out) + + def test_it_gives_the_three_workstation_commands(self): + """Le tunnel ne sert à rien seul : la connexion adb et scrcpy le + suivent, et scrcpy n'est pas installé par défaut.""" + out, _ = self._play() + self.assertIn("adb connect localhost:5555", out) + self.assertIn("scrcpy -s localhost:5555", out) + self.assertIn("apt install scrcpy", out) + + def test_this_path_never_falls_back_to_x11(self): + """Tout l'intérêt : plus de X11 nulle part, le flux est du H.264.""" + out, _ = self._play() + self.assertNotIn("ssh -X", out) + + def test_the_tunneled_port_is_the_device_not_the_adb_server(self): + """5037 est le serveur adb du poste : le tunneler obligerait à tuer + celui de l'utilisateur, qui occupe le même port. 5555 est l'appareil. + """ + out, _ = self._play() + self.assertNotIn("5037", out) + self.assertIn("5555", out) + + def test_it_does_not_repeat_the_start_command_after_starting(self): + out, _ = self._play(started=True) + self.assertNotIn("-no-window", out) + out, _ = self._play(started=False) + self.assertIn("-no-window", out) + self.assertIn("$HOME/android/emulator/emulator", out) + + def test_declining_opens_nothing(self): + _, calls = self._play(answers=("n",)) + self.assertEqual(calls, []) + + def test_accepting_opens_a_detached_tunnel_that_fails_loudly(self): + """« -f » sans « ExitOnForwardFailure » rend 0 alors que la redirection + a échoué : un succès annoncé pour un tunnel absent.""" + out, calls = self._play(answers=("o",)) + self.assertEqual(len(calls), 1) + cmd = calls[0] + self.assertIn("-f", cmd) + self.assertIn("-N", cmd) + self.assertIn("ExitOnForwardFailure=yes", cmd) + self.assertIn("5555:localhost:5555", cmd) + self.assertIn("erplibre@192.168.123.81", cmd) + self.assertIn("✅", out) + + def test_an_occupied_port_is_named_instead_of_a_silent_bind_error(self): + """Le « bind: Address already in use » d'ssh se perd en mode détaché.""" + out, calls = self._play(answers=("o",), port_taken=True) + self.assertEqual(calls, []) + self.assertIn("5555", out) + self.assertIn("pkill", out) + + def test_a_refused_tunnel_is_reported(self): + out, _ = self._play(answers=("o",), rc=255) + self.assertIn("refus", out) + self.assertNotIn("✅", out) + + +class TestPortInUse(unittest.TestCase): + def test_a_listening_socket_is_seen(self): + with socket.socket() as srv: + srv.bind(("127.0.0.1", 0)) + srv.listen(1) + self.assertTrue(TODO._port_in_use(srv.getsockname()[1])) + + def test_a_closed_port_is_free(self): + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + self.assertFalse(TODO._port_in_use(port)) + + +if __name__ == "__main__": + unittest.main() From 91ff4388e7eba0549708b784a211a3d10848ea31 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 03:35:46 -0400 Subject: [PATCH 28/55] [FIX] script todo: rendre la console de l'hyperviseur atteignable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le choix 3 du menu tunnel levait « _qemu_console_tunnel() takes 1 positional argument but 3 were given » : la fonction lit name et src sans les recevoir. flake8 le disait déjà (F821 sur name et src), personne ne l'écoutait. Ce menu n'avait aucun test, et c'est ce qui a laissé passer l'appel. Ses quatre choix sont donc parcourus pour de vrai, jusqu'à la commande imprimée, console comprise. Le défaut remis en place fait bien tomber les tests. --- EN --- Choice 3 of the tunnel menu raised "_qemu_console_tunnel() takes 1 positional argument but 3 were given": the function reads name and src without taking them. flake8 already said so (F821 on name and src); nobody listened. That menu had no test at all, which is how the call shipped. All four of its choices are now walked for real, down to the printed command, the console included. Putting the defect back does make the tests fail. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 2 +- test/test_qemu_tunnel_menu.py | 112 ++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 test/test_qemu_tunnel_menu.py diff --git a/script/todo/todo.py b/script/todo/todo.py index 7fabba3..820c871 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1876,7 +1876,7 @@ class TODO: sock.settimeout(1) return sock.connect_ex(("127.0.0.1", port)) == 0 - def _qemu_console_tunnel(self): + def _qemu_console_tunnel(self, name, src): """Tunnel vers l'ÉCRAN QEMU d'une VM, pas vers un serveur de l'invité. Les deux autres choix du menu supposent un service DANS l'invité — diff --git a/test/test_qemu_tunnel_menu.py b/test/test_qemu_tunnel_menu.py new file mode 100644 index 0000000..653252d --- /dev/null +++ b/test/test_qemu_tunnel_menu.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Menu du tunnel de bureau distant : ses quatre choix, et où ils mènent. + +Ce menu n'avait aucun test, et c'est ainsi qu'un appel à deux arguments vers +une fonction qui n'en prenait aucun a pu être livré : le choix « console de +l'hyperviseur » levait un TypeError au lieu d'ouvrir quoi que ce soit. Chaque +choix est donc atteint ici pour de vrai, jusqu'à la commande imprimée. + +La ligne de partage est celle du dernier saut : xrdp et TigerVNC écoutent sur +toutes les interfaces de l'invité, donc l'hyperviseur les atteint par l'IP de +la VM ; l'émulateur Android, lui, n'écoute que sur son 127.0.0.1, ce qui exige +un saut de plus. Les deux formes coexistent, et ce n'est pas une incohérence. +""" + +import io +import sys +import unittest +from unittest import mock + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 + + +class _MenuCase(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + self.todo._ssh_config_hosts = lambda: ["saut+vm-a"] + self.todo._qemu_list_domains = lambda: [] + self.todo._qemu_resolve_ips = lambda names, labels=None: { + "vm-a": "192.168.123.81" + } + self.todo._qemu_self_address = staticmethod(lambda: ("10.0.0.2", True)) + self.todo._ssh_proxyjump = lambda name: "hyperviseur" + self.todo._qemu_vnc_port = lambda domain, jump: 5900 + + def _play(self, answers): + it = iter(answers) + buf = io.StringIO() + with mock.patch("builtins.input", lambda *a: next(it)), mock.patch( + "sys.stdout", buf + ): + self.todo._qemu_tunnel_menu() + return buf.getvalue() + + +class TestTunnelMenuChoices(_MenuCase): + def test_the_hypervisor_console_is_reachable_at_all(self): + """Le défaut vécu : « _qemu_console_tunnel() takes 1 positional + argument but 3 were given ». Le choix 3 doit aboutir, pas lever.""" + out = self._play(["1", "3"]) + self.assertIn("5900", out) + self.assertIn("hyperviseur", out) + + def test_the_console_targets_the_hypervisor_not_the_guest(self): + """L'écran VNC appartient à QEMU : côté invité, le socket n'existe + pas.""" + out = self._play(["1", "3"]) + self.assertIn("ssh -N -L 5900:127.0.0.1:5900 hyperviseur", out) + + def test_a_domain_without_a_vnc_port_is_diagnosed_not_tunneled(self): + """Avec « listen=none », QEMU n'ouvre AUCUN socket : aucun tunnel n'y + peut rien tant que le domaine n'est pas redéfini.""" + self.todo._qemu_vnc_port = lambda domain, jump: 0 + out = self._play(["1", "3"]) + self.assertIn("virsh edit", out) + self.assertNotIn("ssh -N -L", out) + + def test_the_emulator_choice_reaches_the_adb_tunnel(self): + out = self._play(["1", "4", "n"]) + self.assertIn("scrcpy", out) + self.assertIn("5555", out) + + def test_rdp_is_the_default_and_vnc_the_second(self): + self.assertIn("3389", self._play(["1", ""])) + self.assertIn("5901", self._play(["1", "2"])) + + def test_a_configured_host_rides_its_proxyjump_for_rdp(self): + out = self._play(["1", "1"]) + self.assertIn("-L 3390:localhost:3389 saut+vm-a", out) + + def test_an_out_of_range_choice_cancels_without_a_command(self): + out = self._play(["9"]) + self.assertNotIn("ssh -N", out) + + +class TestTunnelMenuTargets(_MenuCase): + def test_local_domains_fill_in_when_ssh_config_is_empty(self): + """Une VM libvirt locale reste joignable même sans entrée ssh_config ; + xrdp écoutant sur toutes les interfaces, son IP suffit.""" + self.todo._ssh_config_hosts = lambda: [] + self.todo._qemu_list_domains = lambda: ["vm-a"] + out = self._play(["1", "1"]) + self.assertIn("-L 3390:192.168.123.81:3389", out) + + def test_nothing_anywhere_is_said_plainly(self): + self.todo._ssh_config_hosts = lambda: [] + self.todo._qemu_list_domains = lambda: [] + out = self._play([]) + self.assertIn("~/.ssh/config", out) + + def test_an_off_local_vm_is_reported_before_any_command(self): + self.todo._ssh_config_hosts = lambda: [] + self.todo._qemu_list_domains = lambda: ["vm-b"] + out = self._play(["1", "1"]) + self.assertIn("IP", out.upper()) + self.assertNotIn("ssh -N", out) + + +if __name__ == "__main__": + unittest.main() From 58f5b9e8da86f0d333e7fa98ec676ff327199c01 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 03:37:31 -0400 Subject: [PATCH 29/55] =?UTF-8?q?[FIX]=20qemu=20doc:=20donner=20une=20comm?= =?UTF-8?q?ande=20d'=C3=A9mulateur=20qui=20fonctionne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les deux commandes documentées échouent. « emulator » sans chemin absolu rend « command not found », parce qu'un `ssh hôte 'commande'` ne lit ni ~/.profile ni ~/.bashrc — l'erreur a été rencontrée telle quelle. Et le rendu annoncé, « swiftshader_indirect », n'existe plus : l'émulateur répond « Selected GPU option is not valid » et le code pose « swangle » depuis un moment. La doc pointe maintenant d'abord le menu de todo.py, qui démarre l'émulateur sans fenêtre et donne le tunnel adb : scrcpy reçoit du H.264 encodé par l'appareil, là où `ssh -X` fait traverser chaque image en pixels bruts. --- EN --- Both documented commands fail. Bare `emulator` gives "command not found", because `ssh host 'command'` reads neither ~/.profile nor ~/.bashrc — the error was hit exactly like that. And the advertised renderer, `swiftshader_indirect`, no longer exists: the emulator answers "Selected GPU option is not valid", and the code has been setting `swangle` for a while. The doc now points at todo.py's menu first, which starts the emulator without a window and hands over the adb tunnel: scrcpy receives H.264 encoded by the device, where `ssh -X` ships every frame as raw pixels. Assisted-by: Claude Opus 5 --- script/qemu/README.base.md | 49 ++++++++++++++++++++++++-------------- script/qemu/README.fr.md | 24 ++++++++++++------- script/qemu/README.md | 25 ++++++++++++------- 3 files changed, 62 insertions(+), 36 deletions(-) diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md index 20967cd..198bbe5 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -309,15 +309,22 @@ starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio they share one SDK through `ANDROID_HOME`. Without Android, the same app runs in a browser: `npm start`. -A fifth, **Android emulator (Pixel)**, creates an AVD you open from your own -machine: `ssh -X erplibre@ "emulator -avd erplibre -no-audio"`. It needs no -desktop in the VM — the window lands on your screen, not the guest's — but it -does need KVM inside the guest, so nested virtualisation on the host; the log -says so when `/dev/kvm` is missing. The device is not frozen: the SDK is asked -for its profiles and the newest plain Pixel with the smallest screen wins (no -Pro, XL, Fold or tablet), because every pixel crosses the network. Rendering is -set to `swiftshader_indirect` inside the AVD's own `config.ini`, since `ssh -X` -offers no direct GLX and `auto` would open a black screen. +A fifth, **Android emulator (Pixel)**, creates an AVD. Drive it from the +QEMU menu, *Android emulator (start, tunnel, scrcpy)*: it starts the emulator +without a window and hands you the adb tunnel and the scrcpy command. Prefer +that to a window over X11 — scrcpy receives H.264 encoded by the device, where +`ssh -X` ships every frame as raw pixels in software rendering. If you do want +the window, the path must be absolute, because `ssh host 'command'` reads +neither `~/.profile` nor `~/.bashrc`: +`ssh -XC erplibre@ '$HOME/android/emulator/emulator -avd erplibre -no-audio'`. + +It needs no desktop in the VM, but it does need KVM inside the guest, so +nested virtualisation on the host; the log says so when `/dev/kvm` is missing. +The device is not frozen: the SDK is asked for its profiles and the newest +plain Pixel with the smallest screen wins (no Pro, XL, Fold or tablet). +Rendering is `swangle` in the AVD's own `config.ini` — `auto` would open a +black screen, and `swiftshader_indirect` no longer exists, the emulator +answering `Selected GPU option ... is not valid`. Each tool is filtered per VM — by architecture, desktop flavour and package family — and its disk cost is added to the plan before anything is created. @@ -435,16 +442,22 @@ simple VM serveur produit l'APK — et quand Android Studio est aussi coché, le deux partagent un seul SDK via `ANDROID_HOME`. Sans Android, la même application tourne dans un navigateur : `npm start`. -Un cinquième, **Émulateur Android (Pixel)**, crée un AVD que vous ouvrez -depuis votre poste : `ssh -X erplibre@ "emulator -avd erplibre -no-audio"`. -Il ne demande aucun bureau dans la VM — la fenêtre s'affiche sur VOTRE écran, -pas sur celui de l'invitée — mais il exige KVM dans l'invitée, donc la -virtualisation imbriquée sur l'hôte ; le journal le dit quand `/dev/kvm` +Un cinquième, **Émulateur Android (Pixel)**, crée un AVD. Conduisez-le depuis +le menu QEMU, *Émulateur Android (démarrage, tunnel, scrcpy)* : il démarre +l'émulateur sans fenêtre, puis donne le tunnel adb et la commande scrcpy. +Préférez cette voie à une fenêtre par X11 — scrcpy reçoit du H.264 encodé PAR +l'appareil, là où `ssh -X` fait traverser chaque image en pixels bruts, en +rendu logiciel. Si vous voulez la fenêtre, le chemin doit être ABSOLU, car +`ssh hôte 'commande'` ne lit ni `~/.profile` ni `~/.bashrc` : +`ssh -XC erplibre@ '$HOME/android/emulator/emulator -avd erplibre -no-audio'`. + +Il ne demande aucun bureau dans la VM, mais il exige KVM dans l'invitée, donc +la virtualisation imbriquée sur l'hôte ; le journal le dit quand `/dev/kvm` manque. Le modèle n'est pas figé : on demande au SDK ses profils et le Pixel le -plus récent au plus petit écran gagne (ni Pro, ni XL, ni pliant, ni tablette), -parce que chaque pixel traverse le réseau. Le rendu est réglé sur -`swiftshader_indirect` dans le `config.ini` de l'AVD, puisque `ssh -X` n'offre -pas de GLX direct et qu'« auto » ouvrirait un écran noir. +plus récent au plus petit écran gagne (ni Pro, ni XL, ni pliant, ni tablette). +Le rendu est « swangle » dans le `config.ini` de l'AVD — « auto » ouvrirait un +écran noir, et « swiftshader_indirect » n'existe plus, l'émulateur répondant +`Selected GPU option ... is not valid`. Chaque outil est filtré VM par VM — architecture, saveur de bureau et famille de paquets — et sa place disque s'ajoute au plan avant que rien ne soit créé. diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index 771ef48..e4cc3c2 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -198,16 +198,22 @@ simple VM serveur produit l'APK — et quand Android Studio est aussi coché, le deux partagent un seul SDK via `ANDROID_HOME`. Sans Android, la même application tourne dans un navigateur : `npm start`. -Un cinquième, **Émulateur Android (Pixel)**, crée un AVD que vous ouvrez -depuis votre poste : `ssh -X erplibre@ "emulator -avd erplibre -no-audio"`. -Il ne demande aucun bureau dans la VM — la fenêtre s'affiche sur VOTRE écran, -pas sur celui de l'invitée — mais il exige KVM dans l'invitée, donc la -virtualisation imbriquée sur l'hôte ; le journal le dit quand `/dev/kvm` +Un cinquième, **Émulateur Android (Pixel)**, crée un AVD. Conduisez-le depuis +le menu QEMU, *Émulateur Android (démarrage, tunnel, scrcpy)* : il démarre +l'émulateur sans fenêtre, puis donne le tunnel adb et la commande scrcpy. +Préférez cette voie à une fenêtre par X11 — scrcpy reçoit du H.264 encodé PAR +l'appareil, là où `ssh -X` fait traverser chaque image en pixels bruts, en +rendu logiciel. Si vous voulez la fenêtre, le chemin doit être ABSOLU, car +`ssh hôte 'commande'` ne lit ni `~/.profile` ni `~/.bashrc` : +`ssh -XC erplibre@ '$HOME/android/emulator/emulator -avd erplibre -no-audio'`. + +Il ne demande aucun bureau dans la VM, mais il exige KVM dans l'invitée, donc +la virtualisation imbriquée sur l'hôte ; le journal le dit quand `/dev/kvm` manque. Le modèle n'est pas figé : on demande au SDK ses profils et le Pixel le -plus récent au plus petit écran gagne (ni Pro, ni XL, ni pliant, ni tablette), -parce que chaque pixel traverse le réseau. Le rendu est réglé sur -`swiftshader_indirect` dans le `config.ini` de l'AVD, puisque `ssh -X` n'offre -pas de GLX direct et qu'« auto » ouvrirait un écran noir. +plus récent au plus petit écran gagne (ni Pro, ni XL, ni pliant, ni tablette). +Le rendu est « swangle » dans le `config.ini` de l'AVD — « auto » ouvrirait un +écran noir, et « swiftshader_indirect » n'existe plus, l'émulateur répondant +`Selected GPU option ... is not valid`. Chaque outil est filtré VM par VM — architecture, saveur de bureau et famille de paquets — et sa place disque s'ajoute au plan avant que rien ne soit créé. diff --git a/script/qemu/README.md b/script/qemu/README.md index 0adffcf..2bc5f77 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -183,15 +183,22 @@ starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio they share one SDK through `ANDROID_HOME`. Without Android, the same app runs in a browser: `npm start`. -A fifth, **Android emulator (Pixel)**, creates an AVD you open from your own -machine: `ssh -X erplibre@ "emulator -avd erplibre -no-audio"`. It needs no -desktop in the VM — the window lands on your screen, not the guest's — but it -does need KVM inside the guest, so nested virtualisation on the host; the log -says so when `/dev/kvm` is missing. The device is not frozen: the SDK is asked -for its profiles and the newest plain Pixel with the smallest screen wins (no -Pro, XL, Fold or tablet), because every pixel crosses the network. Rendering is -set to `swiftshader_indirect` inside the AVD's own `config.ini`, since `ssh -X` -offers no direct GLX and `auto` would open a black screen. +A fifth, **Android emulator (Pixel)**, creates an AVD. Drive it from the +QEMU menu, *Android emulator (start, tunnel, scrcpy)*: it starts the emulator +without a window and hands you the adb tunnel and the scrcpy command. Prefer +that to a window over X11 — scrcpy receives H.264 encoded by the device, where +`ssh -X` ships every frame as raw pixels in software rendering. If you do want +the window, the path must be absolute, because `ssh host 'command'` reads +neither `~/.profile` nor `~/.bashrc`: +`ssh -XC erplibre@ '$HOME/android/emulator/emulator -avd erplibre -no-audio'`. + +It needs no desktop in the VM, but it does need KVM inside the guest, so +nested virtualisation on the host; the log says so when `/dev/kvm` is missing. +The device is not frozen: the SDK is asked for its profiles and the newest +plain Pixel with the smallest screen wins (no Pro, XL, Fold or tablet). +Rendering is `swangle` in the AVD's own `config.ini` — `auto` would open a +black screen, and `swiftshader_indirect` no longer exists, the emulator +answering `Selected GPU option ... is not valid`. Each tool is filtered per VM — by architecture, desktop flavour and package family — and its disk cost is added to the plan before anything is created. From 3b2f7fa3d6180b59af4b89d5e46849bd4b809e6e Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 03:39:20 -0400 Subject: [PATCH 30/55] [ADD] test: apparier l'affichage et le dispatch du menu QEMU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le menu Execute avait ce garde-fou, le menu QEMU non — et c'est lui qui vient de renumérer, l'émulateur Android s'insérant avant « List available images ». Sa forme est pire à l'œil : une liste de dictionnaires où les « section » ne consomment pas de numéro, donc le décalage ne se voit pas en lisant. Le test relit les deux écritures et les apparie, puis une table dit où chaque entrée doit mener. Inverser les deux derniers crans du dispatch le fait bien tomber, en nommant l'entrée fautive. --- EN --- The Execute menu had this guard, the QEMU menu did not — and the QEMU one is what just got renumbered, the Android emulator slotting in before "List available images". Its shape is worse to eyeball: a list of dicts where "section" entries consume no number, so a shift does not show when reading. The test reads both spellings and pairs them, then a table states where each entry must lead. Swapping the last two dispatch branches does make it fail, naming the offending entry. Assisted-by: Claude Opus 5 --- test/test_todo_menu.py | 99 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/test/test_todo_menu.py b/test/test_todo_menu.py index 02f02cb..bfa76cd 100644 --- a/test/test_todo_menu.py +++ b/test/test_todo_menu.py @@ -136,6 +136,105 @@ class TestExecuteMenuNumbering(unittest.TestCase): self.assertEqual(set(self.EXPECTED) - shown_keys, set()) +class TestQemuMenuNumbering(unittest.TestCase): + """Le menu QEMU : même piège, autre forme. + + Il ne s'écrit pas en f-string mais en liste de dictionnaires, où seules les + entrées « prompt_description » consomment un numéro — les « section » sont + des titres. Le décalage y est donc encore moins visible à l'œil : insérer + une entrée avant la dernière renumérote tout ce qui suit, et le dispatch ne + proteste pas. C'est arrivé en ajoutant l'émulateur Android avant + « List available images ». + """ + + RE_ENTRY = re.compile( + r'"(section|prompt_description)": t\(\s*\n?\s*"([^"]+)"' + ) + RE_DISPATCH_CALL = re.compile( + r'(?:el)?if status == "(\d+)":\s*\n\s*(?:status = )?self\.(\w+)\(' + ) + + def setUp(self): + source = TODO_PY.read_text(encoding="utf-8") + start = source.index("def prompt_execute_qemu(self):") + end = source.index("def _qemu_tunnel_menu(self):", start) + self.body = source[start:end] + num = 0 + self.shown = [] + for kind, label in self.RE_ENTRY.findall(self.body): + if kind == "prompt_description": + num += 1 + self.shown.append((num, label)) + self.dispatch = [ + (int(n), m) for n, m in self.RE_DISPATCH_CALL.findall(self.body) + ] + + def test_the_menu_was_actually_parsed(self): + """Sur une liste vide, tout test passe : mieux vaut tomber ici.""" + self.assertGreater(len(self.shown), 10) + self.assertEqual(len(self.shown), len(self.dispatch)) + + def test_numbering_is_contiguous_from_one(self): + self.assertEqual( + [n for n, _ in self.shown], + list(range(1, len(self.shown) + 1)), + ) + + def test_every_shown_entry_has_the_matching_dispatch(self): + self.assertEqual( + [n for n, _ in self.shown], [n for n, _ in self.dispatch] + ) + + # Où mène chaque entrée, par le début de son libellé. Une renumérotation ne + # touche PAS cette table ; ajouter une entrée l'exige, et c'est le seul + # moment où quelqu'un doit dire où elle mène. + EXPECTED = { + "Deploy VM(s)": "_qemu_deploy", + "Preview a deployment": "_qemu_deploy", + "Download a cloud image only": "_qemu_download_image", + "Reopen": "_qemu_reopen_monitor", + "List VMs": "_qemu_list_vms", + "Show a VM IP address": "_qemu_show_ip", + "Open the console on a VM": "_qemu_console", + "Resize a VM disk": "_qemu_resize_disk", + "Delete VM(s)": "_qemu_delete_vm", + "Clean up QEMU": "_qemu_cleanup", + "Test": "_qemu_test_vm", + "Statistics": "_qemu_stats", + "SSH configuration": "_qemu_ssh_config_menu", + "Remote desktop tunnel": "_qemu_tunnel_menu", + "Android emulator": "_qemu_emulator_menu", + "List available images": "_qemu_list_images", + } + + def _key(self, label): + for key in self.EXPECTED: + if label.startswith(key): + return key + return label + + def test_every_entry_reaches_the_method_it_names(self): + dct = dict(self.dispatch) + for num, label in self.shown: + key = self._key(label) + self.assertIn( + key, + self.EXPECTED, + f"entrée [{num}] « {label} » absente d'EXPECTED :" + " déclarez où elle mène", + ) + self.assertEqual( + dct.get(num), + self.EXPECTED[key], + f"[{num}] « {label} » mène à {dct.get(num)}" + f" au lieu de {self.EXPECTED[key]}", + ) + + def test_expected_table_has_no_stale_entry(self): + keys = {self._key(label) for _, label in self.shown} + self.assertEqual(set(self.EXPECTED) - keys, set()) + + class TestMenuLabels(unittest.TestCase): """Toute méthode de menu doit avoir son étiquette de fil d'Ariane. From 8f5a4391488f68c8e0868513de1b1611e1885195 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 03:46:12 -0400 Subject: [PATCH 31/55] =?UTF-8?q?[FIX]=20script=20todo:=20ne=20plus=20anno?= =?UTF-8?q?ncer=20un=20=C3=A9mulateur=20qui=20n'a=20pas=20d=C3=A9marr?= =?UTF-8?q?=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois défauts vus en conduisant le menu sur de vraies VM. « setsid » détache, donc son code de retour vaut 0 même quand rien ne se lance : le menu disait « Démarré » sur une VM sans SDK, dont le journal disait « not found ». Le démarrage attend maintenant de VOIR le processus, et à défaut cite le journal. Une sonde préalable lit binaire et AVD d'un coup : une VM déployée sans cocher l'outil est le cas normal, pas une panne. Vérifié sur deux VM réelles — outillée (True), migration (« aucun binaire emulator »), sans rien y démarrer. Et tout ce qui n'était pas « 2 » démarrait l'émulateur : un « n » de travers suffisait. Au passage, « Choice » n'était traduit dans aucun des trois menus qui l'affichent. --- EN --- Three defects found while driving the menu against real VMs. setsid detaches, so its exit code is 0 even when nothing launches: the menu said "Started" on a VM with no SDK, whose log said "not found". Starting now waits to SEE the process, and quotes the log when it never appears. A prior probe reads binary and AVD in one go: a VM deployed without ticking the tool is the normal case, not a failure. Verified on two real VMs — tooled (True), migration ("no emulator binary") — without starting anything there. And anything that was not "2" started the emulator; a stray "n" was enough. Along the way, "Choice" was untranslated in all three menus showing it. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 61 +++++++++++++++++++++++ script/todo/todo_i18n.py | 21 ++++++++ test/test_qemu_emulator_menu.py | 85 +++++++++++++++++++++++++++++++-- 3 files changed, 164 insertions(+), 3 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index 820c871..8d0adac 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1677,6 +1677,34 @@ class TODO: except (OSError, subprocess.SubprocessError, ValueError, IndexError): return -1 + def _qemu_emulator_ready(self, target, src="virsh"): + """La VM a-t-elle de quoi émuler ? Rend (prêt, raison). + + Le binaire ET l'AVD, en une seule lecture : sans cette vérification le + démarrage détaché rendait 0 sur une VM sans SDK, et le menu annonçait + « Démarré » quand le journal disait « not found ». Une VM déployée sans + cocher l'outil Émulateur Android est le cas normal, pas une panne.""" + probe = ( + f"test -x {self._QEMU_EMULATOR_BIN} || echo NO_SDK; " + f"test -d $HOME/.android/avd/{self._QEMU_AVD_NAME}.avd" + " || echo NO_AVD" + ) + try: + res = subprocess.run( + ["ssh"] + self._qemu_ssh_opts(src) + [target, probe], + capture_output=True, + text=True, + timeout=25, + ) + except (OSError, subprocess.SubprocessError): + return False, t("Cannot reach this VM.") + out = res.stdout or "" + if "NO_SDK" in out: + return False, t("No Android SDK in this VM: no emulator binary.") + if "NO_AVD" in out: + return False, t("No AVD named erplibre in this VM.") + return True, "" + def _qemu_emulator_menu(self): """Démarre l'émulateur Android d'une VM, et donne la suite qui va avec. @@ -1722,10 +1750,22 @@ class TODO: ) print(f" {t('Closed.')}") + ready, why = self._qemu_emulator_ready(target, src) + if not ready: + print(f"\n ⚠ {why}") + print(f" {t('Tick the Android emulator tool when deploying.')}") + return + print(f"\n {t('Show a window?')}") print(f" [1] {t('No window - stream with scrcpy (smoother)')} *") print(f" [2] {t('Window over ssh -X (raw pixels, slower)')}") kind = input(f"{t('Choice')} [1]: ").strip() or "1" + # Sans cette validation, TOUT ce qui n'est pas « 2 » démarrait + # l'émulateur : une frappe de travers (« n ») lançait le démarrage, + # observé. Un menu à deux crans n'a pas de troisième réponse. + if kind not in ("1", "2"): + print(t("Cancelled.")) + return emu = self._QEMU_EMULATOR_BIN avd = self._QEMU_AVD_NAME @@ -1760,6 +1800,27 @@ class TODO: if res.returncode: print(f" ⚠ {t('Could not start it:')} {res.stderr.strip()[:200]}") return + # « setsid » détache : le code de retour ne dit RIEN de l'émulateur. + # Le menu annonçait « Démarré » pendant que le journal de la VM disait + # « not found » — mesuré sur une VM sans SDK. On attend donc de voir le + # processus, et à défaut on rapporte le journal. + for _ in range(5): + if self._qemu_emulator_running(target, src) > 0: + break + time.sleep(2) + else: + print(f" ⚠ {t('It did not start; the VM log says:')}") + log = subprocess.run( + ["ssh"] + + self._qemu_ssh_opts(src) + + [target, "tail -5 /tmp/erplibre-emulator.log 2>/dev/null"], + capture_output=True, + text=True, + timeout=25, + ) + for line in (log.stdout or "").strip().splitlines(): + print(f" {line}") + return print( f" {t('Started. Boot takes about a minute; log in the VM:')}" " /tmp/erplibre-emulator.log" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index dd91e31..c64a60c 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2252,6 +2252,27 @@ TRANSLATIONS = { "fr": "(l'hyperviseur ne fait que relayer ; -J met la VM en dernier)", "en": "(the hypervisor only relays; -J puts the VM last)", }, + "Choice": {"fr": "Choix", "en": "Choice"}, + "Tick the Android emulator tool when deploying.": { + "fr": "Cochez l'outil Émulateur Android au déploiement.", + "en": "Tick the Android emulator tool when deploying.", + }, + "Cannot reach this VM.": { + "fr": "VM injoignable.", + "en": "Cannot reach this VM.", + }, + "No Android SDK in this VM: no emulator binary.": { + "fr": "Pas de SDK Android dans cette VM : aucun binaire emulator.", + "en": "No Android SDK in this VM: no emulator binary.", + }, + "No AVD named erplibre in this VM.": { + "fr": "Aucun AVD nommé erplibre dans cette VM.", + "en": "No AVD named erplibre in this VM.", + }, + "It did not start; the VM log says:": { + "fr": "Il n'a pas démarré ; le journal de la VM dit :", + "en": "It did not start; the VM log says:", + }, "No ~/.ssh/config entry; see SSH configuration.": { "fr": "Aucune entrée ~/.ssh/config ; voir Configuration SSH.", "en": "No ~/.ssh/config entry; see SSH configuration.", diff --git a/test/test_qemu_emulator_menu.py b/test/test_qemu_emulator_menu.py index 4e871cd..7d8fc37 100644 --- a/test/test_qemu_emulator_menu.py +++ b/test/test_qemu_emulator_menu.py @@ -109,6 +109,39 @@ class TestEmulatorRunning(unittest.TestCase): self.assertEqual(self.todo._qemu_emulator_running("h"), -1) +class TestEmulatorReady(unittest.TestCase): + """Une seule lecture répond aux deux questions : binaire, puis AVD.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + + def test_a_complete_vm_is_ready(self): + with mock.patch("subprocess.run", return_value=_run_ok("")): + self.assertEqual(self.todo._qemu_emulator_ready("h"), (True, "")) + + def test_the_missing_piece_is_named(self): + for probe, word in (("NO_SDK\n", "SDK"), ("NO_AVD\n", "AVD")): + with mock.patch("subprocess.run", return_value=_run_ok(probe)): + ready, why = self.todo._qemu_emulator_ready("h") + self.assertFalse(ready, probe) + self.assertIn(word, why, probe) + + def test_the_sdk_is_reported_before_the_avd(self): + """Sans SDK, l'absence d'AVD n'est qu'une conséquence : nommer la cause + évite d'envoyer l'utilisateur créer un AVD qu'il ne peut pas créer.""" + with mock.patch( + "subprocess.run", return_value=_run_ok("NO_SDK\nNO_AVD\n") + ): + _, why = self.todo._qemu_emulator_ready("h") + self.assertIn("SDK", why) + + def test_an_unreachable_vm_is_not_declared_ready(self): + with mock.patch("subprocess.run", side_effect=OSError): + ready, why = self.todo._qemu_emulator_ready("h") + self.assertFalse(ready) + self.assertTrue(why) + + class _MenuCase(unittest.TestCase): """Socle commun : une VM locale, des réponses scriptées, aucun vrai ssh.""" @@ -122,16 +155,29 @@ class _MenuCase(unittest.TestCase): self.todo._qemu_self_address = staticmethod(lambda: ("10.0.0.2", True)) self.calls = [] - def _play(self, answers, running=0, start_rc=0, port_taken=False): - """Joue le menu avec des réponses données ; rend (sortie, commandes).""" + def _play(self, answers, running=0, start_rc=0, port_taken=False, + probe="", running_after=1, log="rien"): + """Joue le menu avec des réponses données ; rend (sortie, commandes). + + « running » est le compte AVANT le démarrage, « running_after » celui + d'après : c'est cette distinction qui dit si l'émulateur a réellement + pris, le code de retour d'un « setsid » détaché ne valant rien. + """ + state = {"started": False} def fake_run(cmd, *a, **k): self.calls.append(cmd) joined = " ".join(cmd) if "pgrep -c qemu-system" in joined: - return _run_ok(f"{running}\n") + n = running_after if state["started"] else running + return _run_ok(f"{n}\n") + if "NO_SDK" in joined: + return _run_ok(probe) if "setsid" in joined: + state["started"] = True return _run_ok(returncode=start_rc, stderr="boum") + if "tail -5" in joined: + return _run_ok(log) return _run_ok() it = iter(answers) @@ -139,6 +185,8 @@ class _MenuCase(unittest.TestCase): "builtins.input", lambda *a: next(it) ), mock.patch.object( TODO, "_port_in_use", staticmethod(lambda p: port_taken) + ), mock.patch( + "script.todo.todo.time.sleep", lambda *a: None ), mock.patch( "sys.stdout", new_callable=__import__("io").StringIO ) as out: @@ -221,6 +269,37 @@ class TestEmulatorMenu(_MenuCase): self.assertIn("boum", out) self.assertEqual(self._tunnels(calls), []) + def test_a_vm_without_the_sdk_is_diagnosed_before_anything_else(self): + """Une VM déployée sans cocher l'outil est le cas NORMAL. Le menu le + dit avant même de demander la fenêtre — mesuré sur une VM de migration, + où le démarrage détaché rendait 0 et le journal disait « not found ».""" + out, calls = self._play(["1"], probe="NO_SDK\n") + self.assertIn("SDK", out) + self.assertNotIn("[1]", out.split("VM locale")[-1]) + self.assertEqual(self._started(calls), []) + + def test_a_vm_without_the_avd_is_named_as_such(self): + out, calls = self._play(["1"], probe="NO_AVD\n") + self.assertIn("AVD", out) + self.assertEqual(self._started(calls), []) + + def test_a_stray_answer_cancels_instead_of_starting(self): + """« n » à une question à deux crans partait démarrer l'émulateur : + tout ce qui n'était pas « 2 » valait « sans fenêtre ». Observé.""" + for stray in ("n", "3", "oui"): + self.calls = [] + out, calls = self._play(["1", stray]) + self.assertEqual(self._started(calls), [], stray) + + def test_a_start_that_never_appears_reports_the_log_not_a_success(self): + """Le code de retour d'un « setsid » détaché vaut 0 quoi qu'il arrive : + seule la présence du processus prouve le démarrage.""" + out, calls = self._play( + ["1", "1"], running_after=0, log="emulator: not found" + ) + self.assertIn("not found", out) + self.assertNotIn("scrcpy -s", out) + def test_a_successful_start_chains_into_the_tunnel_help(self): out, _ = self._play(["1", "1", "n"]) self.assertIn("scrcpy", out) From 6177b1a6a91150c2bdf855e539a2b1a10aac0de8 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 05:37:14 -0400 Subject: [PATCH 32/55] =?UTF-8?q?[FIX]=20script=20todo:=20r=C3=A9sumer=20l?= =?UTF-8?q?es=20erreurs=20au=20lieu=20de=20chercher=20=C2=AB=20error=20?= =?UTF-8?q?=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le volet « d » et le compteur du tableau de bord cherchaient la sous-chaîne « error ». Le journal de l'installation qui vient d'échouer — APK tué par le noyau sur erplibre-ubuntu-2604-gnome — n'en contient AUCUNE : 0 ligne sur 8765, mesuré. Le volet annonçait « aucune erreur détectée » sur une machine morte, et le tableau de bord 0 erreur. Comptent désormais les marqueurs qui ne disent jamais « error » : « ⚠ ÉCHEC », « FAILURE », une trace Python, un « fatal: » de git, une mort par mémoire. Le volet ouvre sur un résumé — l'étape en échec et son diagnostic, les signaux durs, puis les répétitions comptées par forme. Sur ce journal : 1 étape nommée, 2 signaux, là où il n'affichait rien. --- EN --- The "d" pane and the dashboard counter looked for the "error" substring. The log of the install that just failed — APK killed by the kernel on erplibre-ubuntu-2604-gnome — contains NONE: 0 lines out of 8765, measured. The pane said "no error detected" about a dead machine, and the dashboard 0 errors. Markers that never say "error" now count: "⚠ ÉCHEC", "FAILURE", a Python traceback, a git "fatal:", a death by memory. The pane opens on a summary — the failed step with its diagnostic, the hard signals, then repeats counted by shape. On that log: 1 named step and 2 signals, where it showed nothing. Assisted-by: Claude Opus 5 --- script/todo/qemu_install_monitor.py | 194 ++++++++++++++++++++++++-- script/todo/todo_i18n.py | 5 + test/test_qemu_install_summary.py | 204 ++++++++++++++++++++++++++++ 3 files changed, 395 insertions(+), 8 deletions(-) create mode 100644 test/test_qemu_install_summary.py diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index eb63c5c..f366285 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -395,6 +395,133 @@ _LST_IGNORE_ERROR = ( ) +# Signaux d'échec qui ne contiennent NI « error » NI « warning ». Sans eux, le +# scan par sous-chaîne rate des installations franchement ratées : le journal de +# la VM erplibre-ubuntu-2604-gnome, dont la compilation de l'APK a été tuée par +# le noyau, ne portait AUCUNE ligne « error » — mesuré, 0 sur 8765 lignes — +# pendant que « ⚠ ÉCHEC : APK debug (gradle) », « FAILURE: Build failed » et +# « daemon disappeared unexpectedly » y étaient. Le détail des erreurs annonçait +# donc « aucune erreur détectée » sur une installation en échec. +# +# Chaque motif est là parce qu'il est apparu dans un vrai journal, pas par +# précaution : Gradle dit « FAILURE », Python « Traceback », git « fatal: », apt +# « Unable to locate package », le noyau « Killed » ou « Cannot allocate +# memory », et nos propres étapes « ⚠ ÉCHEC ». +_LST_HARD_MARKERS = ( + "⚠ échec", + "failed:", + "failure", + "traceback (most recent call last)", + "fatal:", + "command not found", + # PAS « no such file or directory » : sur le journal de référence, 5 de ses + # 7 occurrences étaient des sondes bénignes (« cat: .odoo-version »), et le + # bruit dilue un résumé dont l'intérêt est justement d'être court. Un + # fichier vraiment manquant fait échouer une ÉTAPE, elle-même captée. + "permission denied", + "unable to locate package", + "disappeared unexpectedly", + "outofmemory", + "cannot allocate memory", + "segmentation fault", + "core dumped", + "killed process", +) +# Étape en échec, telle que la pose « mstep » : « ⚠ ÉCHEC : ». C'est +# le signal AUTORITAIRE — il nomme l'étape, là où « FAILURE » ne nomme que +# l'outil. +_RE_FAILED_STEP = re.compile(r"⚠\s*(?:ÉCHEC|FAILED)\s*:?\s*(.+)") +# Début d'une autre étape ou d'une section : borne du diagnostic qui suit. +_RE_STEP_BOUND = re.compile(r"^\s*(?:->|==)\s") + + +def _is_hard_signal(line: str) -> bool: + low = line.lower() + return any(m in low for m in _LST_HARD_MARKERS) + + +def _error_signature(line: str) -> str: + """Ligne réduite à sa FORME, pour regrouper les répétitions. + + Un journal d'installation répète la même erreur des centaines de fois avec + un chemin ou un numéro qui change. Regrouper sur cette forme donne « ×342 » + au lieu de 342 lignes à faire défiler.""" + sig = re.sub(r"\d+", "#", line) + sig = re.sub(r"0x[0-9a-fA-F]+", "#", sig) + sig = re.sub(r"/\S+", "/…", sig) + return re.sub(r"\s+", " ", sig).strip()[:160] + + +def scan_log_summary(log_path: str, diag_cap: int = 14) -> dict: + """Résumé d'un journal d'installation : ce qui a échoué, puis le reste. + + Rend {steps, hard, groups, nerr, nwarn} où « steps » liste les étapes en + échec AVEC leur diagnostic, « hard » les autres signaux durs dédupliqués, et + « groups » les lignes « error »/« warning » regroupées par forme et comptées. + + L'ordre n'est pas cosmétique : une étape en échec nommée vaut mille lignes, + et c'est elle qu'on veut lire d'abord.""" + try: + lines = Path(log_path).read_text(errors="replace").splitlines() + except OSError: + return {"steps": [], "hard": [], "groups": [], "nerr": 0, "nwarn": 0} + + steps, hard, groups = [], {}, {} + nerr = nwarn = 0 + for i, line in enumerate(lines, 1): + if EXIT_MARKER in line: + continue + low = line.lower() + match = _RE_FAILED_STEP.search(line) + if match: + # Le diagnostic suit l'échec, jusqu'à l'étape suivante : c'est lui + # qui porte la cause, l'échec ne portant que le nom. + diag = [] + for nxt in lines[i : i + 60]: + if _RE_STEP_BOUND.match(nxt) or _RE_FAILED_STEP.search(nxt): + break + if EXIT_MARKER in nxt: + continue + if nxt.strip() and len(diag) < diag_cap: + diag.append(nxt.rstrip()) + steps.append( + {"line": i, "label": match.group(1).strip(), "diag": diag} + ) + continue + if _is_hard_signal(line): + sig = _error_signature(line) + entry = hard.setdefault( + sig, {"line": i, "text": line.strip(), "count": 0} + ) + entry["count"] += 1 + continue + if "error" in low and not any(ig in line for ig in _LST_IGNORE_ERROR): + nerr += 1 + key = ("error", _error_signature(line)) + groups.setdefault( + key, {"line": i, "text": line.strip(), "count": 0} + )["count"] += 1 + if "warning" in low and not any( + ig in line for ig in _LST_IGNORE_WARNING + ): + nwarn += 1 + key = ("warning", _error_signature(line)) + groups.setdefault( + key, {"line": i, "text": line.strip(), "count": 0} + )["count"] += 1 + ordered = sorted( + ({"kind": k[0], **v} for k, v in groups.items()), + key=lambda g: (-g["count"], g["line"]), + ) + return { + "steps": steps, + "hard": sorted(hard.values(), key=lambda h: h["line"]), + "groups": ordered, + "nerr": nerr, + "nwarn": nwarn, + } + + def scan_log_error_lines(log_path: str, cap: int = 500) -> tuple[list, list]: """(lignes_erreur, lignes_avertissement) d'un log, même détection que scan_log_errors mais on RETIENT les lignes (bornées à `cap`) pour les @@ -408,6 +535,9 @@ def scan_log_error_lines(log_path: str, cap: int = 500) -> tuple[list, list]: if EXIT_MARKER in line: continue low = line.lower() + if _is_hard_signal(line) and len(errs) < cap: + errs.append(f"{i}: {line}") + continue if ( "error" in low and not any(ig in line for ig in _LST_IGNORE_ERROR) @@ -438,6 +568,13 @@ def scan_log_errors(log_path: str) -> tuple[int, int]: low = line.lower() if EXIT_MARKER in line: continue + # Un échec d'étape EST une erreur, même sans le mot « error » : sinon le + # tableau de bord affiche « 0 erreur » sur une installation ratée — + # mesuré sur erplibre-ubuntu-2604-gnome, 0 ligne « error » pour un APK + # tué par le noyau. + if _is_hard_signal(line): + nerr += 1 + continue if "error" in low and not any(ig in line for ig in _LST_IGNORE_ERROR): nerr += 1 if "warning" in low and not any( @@ -984,26 +1121,64 @@ def run_monitor(manifest_path: str, run_app: bool = True): ("q", "dismiss", "Fermer"), ] - def __init__(self, vm_name, errs, warns): + def __init__(self, vm_name, errs, warns, summary=None): super().__init__() self._vm = vm_name self._errs = errs self._warns = warns + self._sum = summary or {} def compose(self) -> ComposeResult: - with Vertical(id="errbox"): - yield Static( - f" {self._vm} — ⚠ {len(self._errs)} " + nsteps = len(self._sum.get("steps", [])) + head = ( + f" {self._vm} — ⚠ {len(self._errs)} " + f"{t('errors')} · ⚡ {len(self._warns)} {t('warnings')}" + ) + # Le nombre d'étapes en échec passe DEVANT : c'est la seule ligne du + # bandeau qui dise si l'installation a abouti. + if nsteps: + head = ( + f" {self._vm} — 🛑 {nsteps} " + f"{t('failed steps')} · ⚠ {len(self._errs)} " f"{t('errors')} · ⚡ {len(self._warns)} {t('warnings')}" - f" ({t('Esc to close')})", - id="errtitle", ) + with Vertical(id="errbox"): + yield Static(f"{head} ({t('Esc to close')})", id="errtitle") yield RichLog( id="errlog", highlight=False, markup=False, wrap=True ) def on_mount(self) -> None: log = self.query_one("#errlog", RichLog) + steps = self._sum.get("steps", []) + hard = self._sum.get("hard", []) + groups = self._sum.get("groups", []) + + # -- Le résumé, d'abord. Une étape nommée vaut mille lignes. + if steps: + log.write(f"── {t('Failed steps')} ──") + for st in steps: + log.write(f"🛑 {st['label']} ({t('line')} {st['line']})") + for line in st["diag"]: + log.write(f" {line.strip()}") + log.write("") + if hard: + log.write(f"── {t('Hard signals')} ──") + for h in hard: + mult = f" ×{h['count']}" if h["count"] > 1 else "" + log.write(f"{h['line']}:{mult} {h['text']}") + log.write("") + if groups: + # Regroupé par FORME : un journal répète la même erreur des + # centaines de fois avec un chemin qui change. + log.write(f"── {t('Grouped by shape')} ──") + for g in groups[:60]: + mark = "⚠" if g["kind"] == "error" else "⚡" + mult = f" ×{g['count']}" if g["count"] > 1 else "" + log.write(f"{mark} {g['line']}:{mult} {g['text']}") + log.write("") + + # -- Puis le détail brut, pour qui veut tout lire. if self._errs: log.write(f"── {t('errors').capitalize()} ──") for line in self._errs: @@ -1012,7 +1187,7 @@ def run_monitor(manifest_path: str, run_app: bool = True): log.write(f"── {t('warnings').capitalize()} ──") for line in self._warns: log.write(line) - if not self._errs and not self._warns: + if not (steps or hard or self._errs or self._warns): log.write(t("No error detected.")) def action_dismiss(self) -> None: @@ -1782,7 +1957,10 @@ def run_monitor(manifest_path: str, run_app: bool = True): if not vm: return errs, warns = scan_log_error_lines(vm["log"]) - self.push_screen(ErrorLinesScreen(vm["name"], errs, warns)) + summary = scan_log_summary(vm["log"]) + self.push_screen( + ErrorLinesScreen(vm["name"], errs, warns, summary) + ) def on_click(self, event) -> None: # Clic sur le sommaire de stats -> déplie / replie le détail. diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index c64a60c..e92a5cb 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2253,6 +2253,11 @@ TRANSLATIONS = { "en": "(the hypervisor only relays; -J puts the VM last)", }, "Choice": {"fr": "Choix", "en": "Choice"}, + "failed steps": {"fr": "étapes en échec", "en": "failed steps"}, + "Failed steps": {"fr": "Étapes en échec", "en": "Failed steps"}, + "Hard signals": {"fr": "Signaux durs", "en": "Hard signals"}, + "Grouped by shape": {"fr": "Regroupé par forme", "en": "Grouped by shape"}, + "line": {"fr": "ligne", "en": "line"}, "Tick the Android emulator tool when deploying.": { "fr": "Cochez l'outil Émulateur Android au déploiement.", "en": "Tick the Android emulator tool when deploying.", diff --git a/test/test_qemu_install_summary.py b/test/test_qemu_install_summary.py new file mode 100644 index 0000000..0ddd710 --- /dev/null +++ b/test/test_qemu_install_summary.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Résumé d'un journal d'installation : ce qui a échoué doit se voir. + +Le détail des erreurs cherchait la sous-chaîne « error ». Or le journal de +l'installation qui a réellement échoué — erplibre-ubuntu-2604-gnome, APK tué +par le noyau — ne contient AUCUNE ligne « error » : 0 sur 8765, mesuré. Le +volet annonçait donc « aucune erreur détectée » sur une installation ratée, +et le tableau de bord comptait 0 erreur. + +Ces tests fixent la règle inverse : une étape en échec, un « FAILURE » de +Gradle, une trace Python ou une mort par mémoire se voient, et le résumé les +présente AVANT les centaines de lignes du détail. +""" + +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "script/todo")) +import qemu_install_monitor as m # noqa: E402 + +# Journal réduit à sa forme réelle : les marqueurs de l'installation, puis +# l'échec tel que Gradle l'écrit. Aucune ligne ne contient « error ». +LOG_GRADLE_OOM = """\ +== ERPLibre mobile, SDK Android (long) == + -> venv ERPLibre (tout ce qui suit en dépend) + -> dépendances npm + -> APK debug (gradle) + ⚠ ÉCHEC : APK debug (gradle) + aucun motif connu, dernières lignes : + + FAILURE: Build failed with an exception. + + * What went wrong: + Gradle build daemon disappeared unexpectedly (it may have been killed) + ⚠ aucun APK produit +__ERPLIBRE_EXIT__ 1 +""" + + +def _log(text): + fh = tempfile.NamedTemporaryFile( + "w", suffix=".log", delete=False, encoding="utf-8" + ) + fh.write(text) + fh.close() + return fh.name + + +class TestFailedStepsAreSeen(unittest.TestCase): + def setUp(self): + self.path = _log(LOG_GRADLE_OOM) + + def tearDown(self): + Path(self.path).unlink(missing_ok=True) + + def test_the_reference_log_has_no_line_saying_error(self): + """La prémisse de tout le reste : la détection par sous-chaîne ne + pouvait RIEN trouver ici.""" + self.assertNotIn("error", LOG_GRADLE_OOM.lower()) + + def test_the_failed_step_is_named(self): + got = m.scan_log_summary(self.path) + self.assertEqual( + [s["label"] for s in got["steps"]], ["APK debug (gradle)"] + ) + + def test_the_step_carries_its_diagnostic(self): + """L'échec nomme l'étape ; c'est le diagnostic qui porte la cause.""" + diag = "\n".join(m.scan_log_summary(self.path)["steps"][0]["diag"]) + self.assertIn("FAILURE: Build failed", diag) + self.assertIn("daemon disappeared", diag) + + def test_the_exit_marker_is_not_a_diagnostic(self): + diag = "\n".join(m.scan_log_summary(self.path)["steps"][0]["diag"]) + self.assertNotIn(m.EXIT_MARKER, diag) + + def test_the_diagnostic_stops_at_the_next_step(self): + """Sinon le diagnostic avale la suite de l'installation et ne désigne + plus rien.""" + text = LOG_GRADLE_OOM + " -> étape suivante\n bruit\n" + path = _log(text) + try: + diag = "\n".join(m.scan_log_summary(path)["steps"][0]["diag"]) + finally: + Path(path).unlink(missing_ok=True) + self.assertNotIn("bruit", diag) + + def test_hard_signals_are_listed(self): + hard = " ".join( + h["text"] for h in m.scan_log_summary(self.path)["hard"] + ) + self.assertIn("FAILURE", hard) + self.assertIn("disappeared unexpectedly", hard) + + def test_the_dashboard_no_longer_counts_zero_errors(self): + """Le compte alimente le tableau de bord : « 0 erreur » sur une + installation morte est un mensonge, pas une nuance.""" + nerr, _ = m.scan_log_errors(self.path) + self.assertGreater(nerr, 0) + + def test_the_detail_pane_is_no_longer_empty(self): + errs, _ = m.scan_log_error_lines(self.path) + self.assertTrue(errs) + self.assertTrue(any("ÉCHEC" in e or "FAILURE" in e for e in errs)) + + +class TestOtherRealFailures(unittest.TestCase): + """Chaque motif dur est là parce qu'il est apparu dans un vrai journal.""" + + def _first_hard(self, line): + path = _log(f" -> étape\n{line}\n") + try: + return m.scan_log_summary(path)["hard"] + finally: + Path(path).unlink(missing_ok=True) + + def test_python_traceback(self): + self.assertTrue(self._first_hard("Traceback (most recent call last):")) + + def test_git_fatal(self): + self.assertTrue(self._first_hard("fatal: repository not found")) + + def test_apt_missing_package(self): + self.assertTrue( + self._first_hard("E: Unable to locate package python3.12-venv") + ) + + def test_kernel_oom(self): + self.assertTrue( + self._first_hard("Out of memory: Killed process 37603 (java)") + ) + + def test_missing_command(self): + self.assertTrue(self._first_hard("bash: emulator: command not found")) + + def test_a_benign_probe_is_not_a_hard_signal(self): + """« No such file or directory » sortait 5 fois sur 7 d'une sonde + bénigne (« cat: .odoo-version ») : le bruit dilue un résumé dont tout + l'intérêt est d'être court.""" + self.assertFalse( + self._first_hard("cat: .odoo-version: No such file or directory") + ) + + +class TestGrouping(unittest.TestCase): + def test_repeats_are_counted_not_repeated(self): + """Un journal répète la même erreur des centaines de fois avec un + chemin qui change : on veut « ×200 », pas 200 lignes.""" + lines = "\n".join( + f"ERROR: cannot read /var/lib/x/file{i}.txt" for i in range(200) + ) + path = _log(lines + "\n") + try: + groups = m.scan_log_summary(path)["groups"] + finally: + Path(path).unlink(missing_ok=True) + self.assertEqual(len(groups), 1) + self.assertEqual(groups[0]["count"], 200) + + def test_the_most_frequent_comes_first(self): + path = _log( + "ERROR: rare thing\n" + + "\n".join(f"ERROR: common {i}" for i in range(5)) + + "\n" + ) + try: + groups = m.scan_log_summary(path)["groups"] + finally: + Path(path).unlink(missing_ok=True) + self.assertEqual(groups[0]["count"], 5) + + def test_warnings_are_grouped_apart_from_errors(self): + path = _log("WARNING: a\nERROR: b\n") + try: + kinds = {g["kind"] for g in m.scan_log_summary(path)["groups"]} + finally: + Path(path).unlink(missing_ok=True) + self.assertEqual(kinds, {"error", "warning"}) + + +class TestQuietLogs(unittest.TestCase): + def test_a_clean_log_stays_clean(self): + """Le résumé ne doit pas inventer d'échec là où il n'y en a pas.""" + path = _log("== installation ==\n -> étape\n ✅ terminé\n") + try: + got = m.scan_log_summary(path) + finally: + Path(path).unlink(missing_ok=True) + self.assertEqual(got["steps"], []) + self.assertEqual(got["hard"], []) + self.assertEqual(got["groups"], []) + + def test_a_missing_log_is_not_a_crash(self): + got = m.scan_log_summary("/nonexistent/erplibre.log") + self.assertEqual(got["steps"], []) + self.assertEqual(got["nerr"], 0) + + +if __name__ == "__main__": + unittest.main() From b19713e38f8214a1a2a2e7e0d60d6064fe97303a Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 05:38:01 -0400 Subject: [PATCH 33/55] [FIX] script todo: ne plus laisser PyCharm manger la compilation mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sur erplibre-ubuntu-2604-gnome, l'APK s'est fait tuer par le noyau. La cause est ici : « $! » désigne xvfb-run, un script, et le tuer n'atteint ni PyCharm ni Xvfb — l'IDE tournait encore 45 minutes après son étape, avec 1,9 Go, quand Gradle a demandé ses 6,8 Go sur 12. C'est le GROUPE qu'on tue maintenant, et plus rien ne survit : mesuré, 2 Go rendus. Le .idea n'était jamais écrit non plus : 122 684 des 123 021 fichiers d'assets du dépôt mobile épuisaient les 65 536 watches inotify. Relevées à 524288, le projet se crée. Restent 4 Go de swap avant de compiler, et un diagnostic qui nomme la mémoire — avec le compte de l'oom-killer — puis la limite ZIP de 65 535 entrées, sur laquelle la compilation bute désormais, en amont. --- EN --- On erplibre-ubuntu-2604-gnome the APK was killed by the kernel. The cause is here: "$!" is xvfb-run, a script, and killing it reaches neither PyCharm nor Xvfb — the IDE was still running 45 minutes after its step, holding 1.9 GB, when Gradle asked for its 6.8 GB out of 12. The GROUP is killed now, and nothing survives it: 2 GB given back, measured. The .idea was never written either: 122,684 of the mobile repo's 123,021 asset files exhausted the 65,536 inotify watches. Raised to 524288, the project gets created. Also 4 GB of swap before building, and a diagnostic naming memory — with the oom-killer count — then the 65,535-entry ZIP limit the build now hits, upstream of us. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 108 ++++++++++-- script/todo/todo_i18n.py | 36 ++++ test/test_qemu_desktop_tools.py | 287 ++++++++++++++++++++++++++++++-- 3 files changed, 408 insertions(+), 23 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index 8d0adac..568528c 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5750,8 +5750,28 @@ class TODO: ' "-Didea.suppress.statistics.report=true\\n"\n' ")\n" "ELPYC\n" + # « setsid » donne au tout son PROPRE groupe de processus, et + # c'est le groupe qu'on tuera. Sans lui, « $! » est le PID de + # xvfb-run — un script — et le tuer n'atteint ni PyCharm, ni Xvfb, + # ni les cef_server qu'il a lancés. Mesuré sur + # erplibre-ubuntu-2604-gnome : PyCharm tournait encore 45 minutes + # plus tard avec 1,9 Go, et la compilation de l'APK qui suivait + # s'est fait tuer par le noyau, faute de mémoire. + # Les watches inotify AVANT d'ouvrir : le dépôt mobile pose + # 123 000 fichiers d'assets, et la limite par défaut est dépassée + # dès l'analyse — « inotify_add_watch(...): No space left on + # device », puis « watch root cannot be watched: -2 », puis aucun + # .idea écrit. Mesuré sur erplibre-ubuntu-2604-gnome, deux fois. + # 524288 est la valeur que JetBrains documente lui-même. + "cur=$(cat /proc/sys/fs/inotify/max_user_watches 2>/dev/null " + '|| echo 0); if [ "$cur" -lt 524288 ] 2>/dev/null; then ' + 'echo "fs.inotify.max_user_watches=524288" ' + "| sudo tee /etc/sysctl.d/60-erplibre-inotify.conf >/dev/null && " + "sudo sysctl -q -p /etc/sysctl.d/60-erplibre-inotify.conf " + f'2>/dev/null; echo " {t("inotify watches raised for the IDE")}"; ' + "fi; " 'PYCHARM_VM_OPTIONS="$HOME/.pycharm-headless.vmoptions" ' - f"xvfb-run -a pycharm {el_dir} " + f"setsid xvfb-run -a pycharm {el_dir} " "> /tmp/pycharm-first-run.log 2>&1 & " "pid=$!; ok=0; " f"for i in $(seq 1 {self._QEMU_PYCHARM_OPEN_TRIES}); do " @@ -5760,9 +5780,23 @@ class TODO: "sleep 5; done; " # Cinq secondes de plus : les fichiers apparaissent PENDANT leur # écriture, et un TERM à l'instant où misc.xml naît le tronquerait. - "sleep 5; kill -TERM $pid 2>/dev/null; " - "for i in $(seq 1 12); do kill -0 $pid 2>/dev/null || break; " - "sleep 5; done; kill -KILL $pid 2>/dev/null; " + "sleep 5; kill -TERM -$pid 2>/dev/null || " + "kill -TERM $pid 2>/dev/null; " + "for i in $(seq 1 12); do kill -0 -$pid 2>/dev/null || break; " + "sleep 5; done; kill -KILL -$pid 2>/dev/null; " + # Filet, et il a sa raison d'être : ce qui survit ici mange la + # mémoire de TOUTES les étapes suivantes. On le nomme au lieu de le + # laisser courir, et on ne vise que l'IDE de /opt et l'écran :99. + # « pgrep -fc » IMPRIME 0 et rend 1 quand il ne trouve rien : le + # « || echo 0 » ajoutait un second zéro, et « 0\n0 » n'est pas + # « 0 » — le filet se déclenchait donc toujours. « wc -l » rend un + # seul nombre et un code 0. + 'left=$(pgrep -f "[/]opt/pycharm|[X]vfb :99" 2>/dev/null ' + "| wc -l); " + '[ "$left" = 0 ] || { ' + f'echo " {t("closing what survived the first open:")} $left"; ' + 'pkill -f "[/]opt/pycharm" 2>/dev/null; ' + 'pkill -f "[X]vfb :99" 2>/dev/null; sleep 2; }; ' '[ "$ok" = 1 ]; fi; fi; } && ' f'echo " {t("project created, the install will configure it")}" ' f'|| echo " ⚠ {t("no .idea: open PyCharm once, then")} ' @@ -6021,6 +6055,24 @@ class TODO: # rien de ce qui suit ne peut synchroniser le manifeste. ("virtual environment", "ERPLibre venv missing (incomplete install)"), ("No module named", "ERPLibre venv incomplete (no pip: python3-venv)"), + # Vécu sur erplibre-ubuntu-2604-gnome : le noyau a tué le démon Gradle + # (6,8 Go de RSS sur 12 Go, sans swap), et Gradle n'en sait rien — il + # dit seulement que son démon « a disparu ». Le motif nomme la mémoire, + # et le contexte l'établit au lieu de le supposer. + # Vécu, et c'est en amont : « Too many zip entries 123678 (MAX=65535) ». + # Un APK est un ZIP classique, borné à 65 535 entrées, et le dépôt + # mobile embarque 122 684 fichiers sous assets/public/repos — des + # dépôts Odoo entiers — pour 337 fichiers qui sont l'application. Rien + # ici ne peut le corriger : c'est au projet mobile de ne pas les + # empaqueter. On le NOMME, avec le chiffre, plutôt que de laisser lire + # 5 000 lignes de Gradle. + ( + "Too many zip entries", + "too many asset files for one APK (ZIP limit: 65535 entries)", + ), + ("daemon disappeared", "Gradle daemon killed: out of memory", "mmem"), + ("Cannot allocate memory", "out of memory", "mmem"), + ("Java heap space", "Gradle heap too small", "mmem"), ("FAILED", "Gradle task failed"), ) @@ -6036,13 +6088,26 @@ class TODO: un JDK manquant — le motif venait de la revue de licences d'une étape RÉUSSIE, trois étapes plus haut. Nommer la mauvaise cause coûte plus cher que se taire.""" - lines = "".join( - f"grep -q '{pat}' \"$d\" && {{ " - f'echo " {t("probable cause:")} {t(cause)}"; ' - 'rm -f "$d"; return 0; }; ' - for pat, cause in self._QEMU_MOBILE_DIAG - ) + lines = "" + for entry in self._QEMU_MOBILE_DIAG: + pat, cause = entry[0], entry[1] + extra = f"{entry[2]}; " if len(entry) > 2 else "" + lines += ( + f"grep -q '{pat}' \"$d\" && {{ " + f'echo " {t("probable cause:")} {t(cause)}"; ' + f'{extra}rm -f "$d"; return 0; }}; ' + ) return ( + # Le contexte mémoire, lu dans /proc et dans le journal du noyau : + # une cause « mémoire » se PROUVE, l'affirmer sans le compte de + # l'oom-killer serait une supposition de plus. Pas d'awk ni de sed + # ici : leurs programmes demandent des guillemets, et tout ceci + # voyage déjà dans un ssh entre apostrophes. + "mmem() { m=$(grep MemTotal /proc/meminfo | tr -dc 0-9); " + "w=$(grep SwapTotal /proc/meminfo | tr -dc 0-9); " + "k=$(sudo dmesg 2>/dev/null | grep -c oom-kill); " + f'echo " {t("memory:")} $((m/1024)) {t("MB RAM,")} ' + f'$((w/1024)) {t("MB swap, kernel OOM kills:")} $k"; }}; ' 'mdiag() { d=$(mktemp); tail -400 "$1" > "$d"; ' + lines + f'echo " {t("no known pattern, last lines:")}"; ' @@ -6172,6 +6237,29 @@ class TODO: ailleurs. La lever se fait dans ce script-là, pas ici. """ return ( + # Du swap AVANT de compiler, et ce n'est pas de la prudence : le + # démon Gradle a atteint 6,8 Go de RSS hors tas — son -Xmx1536m ne + # le borne pas — sur une VM de 12 Go SANS swap, et le noyau l'a tué + # deux fois de suite. « --max-workers=2 » n'y a rien changé : + # mesuré, le pic est passé de 10,3 à 11,2 Go. C'est donc de la marge + # qu'il faut, pas moins de parallélisme. + # + # Jamais bloquant : une image sur btrfs refuse un fichier d'échange + # ordinaire, et une compilation qui tient en mémoire n'en a pas + # besoin. On le dit et on continue. + "w=$(grep SwapTotal /proc/meminfo | tr -dc 0-9); " + 'if [ "$w" -lt 2000000 ]; then ' + "if sudo fallocate -l 4G /swapfile-erplibre 2>/dev/null && " + "sudo chmod 600 /swapfile-erplibre && " + "sudo mkswap -q /swapfile-erplibre >/dev/null 2>&1 && " + "sudo swapon /swapfile-erplibre 2>/dev/null; then " + "grep -q swapfile-erplibre /etc/fstab 2>/dev/null || " + 'echo "/swapfile-erplibre none swap sw 0 0" ' + "| sudo tee -a /etc/fstab >/dev/null; " + f'echo " {t("4 GB of swap added for the build")}"; ' + "else sudo rm -f /swapfile-erplibre 2>/dev/null; " + f'echo " {t("no swap could be added; build may run short")}"; ' + "fi; fi; " f'mstep "{t("npm dependencies")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile && npm ci' && " f'mstep "{t("web bundle (vite build)")}" ' diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index e92a5cb..8abe4a7 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2258,6 +2258,42 @@ TRANSLATIONS = { "Hard signals": {"fr": "Signaux durs", "en": "Hard signals"}, "Grouped by shape": {"fr": "Regroupé par forme", "en": "Grouped by shape"}, "line": {"fr": "ligne", "en": "line"}, + "inotify watches raised for the IDE": { + "fr": "watches inotify relevées pour l'IDE", + "en": "inotify watches raised for the IDE", + }, + "closing what survived the first open:": { + "fr": "fermeture de ce qui a survécu à la première ouverture :", + "en": "closing what survived the first open:", + }, + "Gradle daemon killed: out of memory": { + "fr": "démon Gradle tué : mémoire épuisée", + "en": "Gradle daemon killed: out of memory", + }, + "out of memory": {"fr": "mémoire épuisée", "en": "out of memory"}, + "Gradle heap too small": { + "fr": "tas Gradle trop petit", + "en": "Gradle heap too small", + }, + "too many asset files for one APK (ZIP limit: 65535 entries)": { + "fr": "trop de fichiers d'assets pour un APK (limite ZIP : 65535" + " entrées)", + "en": "too many asset files for one APK (ZIP limit: 65535 entries)", + }, + "4 GB of swap added for the build": { + "fr": "4 Go de swap ajoutés pour la compilation", + "en": "4 GB of swap added for the build", + }, + "no swap could be added; build may run short": { + "fr": "swap impossible à ajouter ; la compilation peut manquer de mémoire", + "en": "no swap could be added; build may run short", + }, + "memory:": {"fr": "mémoire :", "en": "memory:"}, + "MB RAM,": {"fr": "Mo de RAM,", "en": "MB RAM,"}, + "MB swap, kernel OOM kills:": { + "fr": "Mo de swap, tués par le noyau (OOM) :", + "en": "MB swap, kernel OOM kills:", + }, "Tick the Android emulator tool when deploying.": { "fr": "Cochez l'outil Émulateur Android au déploiement.", "en": "Tick the Android emulator tool when deploying.", diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index 25b59c1..4805dc3 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -9,6 +9,7 @@ ce filtrage, et qu'un outil qui échoue ne fait pas tomber l'installation d'ERPLibre avec lui — celle-ci ayant duré une heure. """ +import pathlib import subprocess import sys import unittest @@ -302,16 +303,62 @@ class TestMobileBuild(unittest.TestCase): script.index("erplibre-mobile-build.log"), ) + # Banc d'essai des étapes mobiles : « mstep » est remplacé par une fonction + # qui réussit tout sauf l'étape nommée, et « sudo » par un no-op. Le contrat + # se MESURE alors au code de sortie, au lieu de se déduire de la présence + # ou de l'absence d'un « || » dans le texte — un « || echo » légitime, celui + # qui ajoute la ligne du fichier d'échange à /etc/fstab, faisait tomber + # l'ancienne version de ce test sans que rien ne soit cassé. + HARNESS = ( + 'mstep() { echo "-> $1"; case "$1" in *%s*) return 1;; esac; ' + "return 0; }\n" + "sudo() { return 0; }\n" + ) + + def _run_steps(self, fail_on="RIEN", apk=False): + import os + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + el = pathlib.Path(tmp) / "el" + apk_dir = el / "mobile/erplibre_home_mobile/android/app/build" + (apk_dir / "outputs/apk/debug").mkdir(parents=True) + if apk: + (apk_dir / "outputs/apk/debug/app-debug.apk").write_text("x") + steps = self.todo._qemu_mobile_build_steps(str(el)) + return subprocess.run( + ["bash", "-c", (self.HARNESS % fail_on) + steps], + capture_output=True, + text=True, + env=dict(os.environ, HOME=tmp), + timeout=60, + ) + def test_a_failed_build_fails_the_vm(self): """Contrat explicite : « pour que ce soit bon », l'app doit compiler. - Le bloc est donc lié par « && » et n'est PAS gardé, à la différence des - outils graphiques.""" - script = self.todo._qemu_erplibre_remote_cmd( - "develop", None, False, "", "", "deb", ("mobile",) - ) - tail = script[script.index("erplibre-mobile-build.log") :] - self.assertNotIn("|| true", tail) - self.assertNotIn("|| echo", tail) + Une étape en échec doit donc remonter un code non nul.""" + res = self._run_steps(fail_on="gradle") + self.assertNotEqual(0, res.returncode, res.stdout[-400:]) + + def test_a_missing_apk_fails_even_when_gradle_returns_zero(self): + """L'APK est la preuve, pas le code de sortie de Gradle : une tâche peut + rendre 0 sans rien produire.""" + res = self._run_steps(apk=False) + self.assertNotEqual(0, res.returncode) + self.assertIn("APK", res.stdout) + + def test_a_complete_build_succeeds(self): + """L'autre sens du contrat : sans lui, un test qui échoue toujours + passerait pour un test qui vérifie quelque chose.""" + res = self._run_steps(apk=True) + self.assertEqual(0, res.returncode, res.stdout[-400:]) + + def test_the_swap_step_alone_never_fails_the_chain(self): + """Une image btrfs refuse un fichier d'échange ordinaire, et une + compilation qui tient en mémoire n'en a pas besoin.""" + res = self._run_steps(apk=True) + self.assertEqual(0, res.returncode) + self.assertNotIn("|| true", self.todo._qemu_mobile_build_steps("/x")) def test_the_build_covers_apk_and_tests(self): cmd = self.todo._qemu_mobile_remote_cmd() @@ -361,7 +408,8 @@ class TestMobileBuild(unittest.TestCase): """Un journal de dizaines de mégaoctets ne se relit pas : le diagnostic doit dire pourquoi.""" cmd = self.todo._qemu_mobile_remote_cmd() - for pattern, _cause in TODO._QEMU_MOBILE_DIAG: + # Une entrée peut porter un 3e élément : la commande de contexte. + for pattern in (e[0] for e in TODO._QEMU_MOBILE_DIAG): self.assertIn(pattern, cmd, pattern) self.assertIn('tail -12 "$1"', cmd) @@ -468,8 +516,19 @@ class TestAndroidEmulator(unittest.TestCase): au vert alors que rien n'avait compilé.""" both = self.todo._qemu_after_remote_cmd(("mobile", "avd")) # On neutralise les étapes : seul le CHAÎNAGE est en cause ici. - stub = 'mstep() { echo " -> $1"; return 0; }; mdiag() { :; }; ' - tail = both[both.index('{ mstep "') :] + # + # « sudo » est neutralisé AUSSI, et ce n'est pas décoratif : le bloc + # ajoute un fichier d'échange de 4 Go et une ligne à /etc/fstab. Sans + # ce bouchon, un test le ferait sur la machine qui l'exécute. + stub = ( + 'mstep() { echo " -> $1"; return 0; }; mdiag() { :; }; ' + "sudo() { return 0; }; " + ) + # Ancre robuste : on part du journal mobile et on remonte à l'accolade + # qui ouvre son groupe. Chercher « { mstep » liait ce test à la forme + # de la PREMIÈRE étape, et l'ajout du swap devant l'a cassé. + marker = both.index("erplibre-mobile-build.log") + tail = both[both.rindex("{ ", 0, marker) :] res = subprocess.run( ["bash", "-c", "set -e; " + stub + tail], capture_output=True, @@ -490,7 +549,8 @@ class TestAndroidEmulator(unittest.TestCase): def test_no_diagnostic_pattern_carries_an_apostrophe(self): """Ces motifs partent dans un « grep -q '' » : une apostrophe fermait la chaîne et rendait tout le bloc invalide. Vécu.""" - for pattern, _cause in TODO._QEMU_MOBILE_DIAG: + # Une entrée peut porter un 3e élément : la commande de contexte. + for pattern in (e[0] for e in TODO._QEMU_MOBILE_DIAG): self.assertNotIn("'", pattern, pattern) @@ -526,7 +586,15 @@ class TestPycharmCommunity(unittest.TestCase): class TestPycharmFirstOpen(unittest.TestCase): - """Ouverture sans écran, pour que le .idea existe avant l'installation.""" + """Ouverture sans écran, pour que le .idea existe avant l'installation. + + Deux défauts vécus sur erplibre-ubuntu-2604-gnome, tous deux silencieux : + l'IDE restait vivant 45 minutes après l'étape avec 1,9 Go — « $! » est le + PID de xvfb-run, un script, et le tuer n'atteint ni PyCharm ni Xvfb — puis + la compilation de l'APK qui suivait s'est fait tuer par le noyau. Et le + .idea n'était jamais écrit : 123 000 fichiers d'assets épuisent les watches + inotify, dont la limite valait 65 536. + """ def setUp(self): self.todo = TODO.__new__(TODO) @@ -581,6 +649,199 @@ class TestPycharmFirstOpen(unittest.TestCase): ) self.assertEqual(0, res.returncode, res.stderr) + def test_the_ide_gets_its_own_process_group(self): + """Sans « setsid », il n'y a pas de groupe à tuer.""" + self.assertIn("setsid xvfb-run", self.cmd) + + def test_the_whole_group_is_killed_not_just_the_wrapper(self): + """Le signe moins est tout le correctif : « -$pid » désigne le GROUPE, + donc xvfb-run, Xvfb, pycharm et les cef_server.""" + self.assertIn("kill -TERM -$pid", self.cmd) + self.assertIn("kill -KILL -$pid", self.cmd) + + def test_inotify_is_raised_before_opening(self): + """Après l'ouverture, il serait trop tard : l'analyse a déjà échoué.""" + pos_watch = self.cmd.index("max_user_watches") + pos_open = self.cmd.index("setsid xvfb-run") + self.assertLess(pos_watch, pos_open) + self.assertIn("524288", self.cmd) + + def test_the_leftover_count_is_a_single_number(self): + """« pgrep -fc » imprime 0 ET rend 1 quand il ne trouve rien : le + « || echo 0 » ajoutait un second zéro, et « 0\n0 » n'est pas « 0 ». + Le filet se déclenchait donc à chaque passage.""" + self.assertIn("| wc -l", self.cmd) + self.assertNotIn("pgrep -fc", self.cmd) + + def test_it_is_valid_shell(self): + res = subprocess.run( + ["bash", "-n"], input=self.cmd, capture_output=True, text=True + ) + self.assertEqual(0, res.returncode, res.stderr) + + def test_the_group_kill_really_reaps_the_children(self): + """Le test qui compte : on rejoue l'étape avec de FAUX pycharm et + xvfb-run, celui-ci laissant un enfant derrière lui comme le vrai le + fait avec Xvfb. Rien ne doit survivre.""" + import os + import tempfile + import time + + with tempfile.TemporaryDirectory() as tmp: + bin_dir = pathlib.Path(tmp) / "bin" + bin_dir.mkdir() + repo = pathlib.Path(tmp) / "repo" + (repo / ".idea").mkdir(parents=True) + # .idea déjà là : l'attente sort au premier tour, et le test + # mesure la FERMETURE, pas la création. + (repo / ".idea" / "erplibre.iml").write_text("") + (repo / ".idea" / "misc.xml").write_text("") + marker = pathlib.Path(tmp) / "alive" + (bin_dir / "xvfb-run").write_text( + "#!/bin/bash\n" + # L'enfant qui survivait : un Xvfb que personne ne tuait. + f"( while true; do touch {marker}; sleep 1; done ) &\n" + 'shift; exec "$@"\n' + ) + (bin_dir / "pycharm").write_text( + "#!/bin/bash\nwhile true; do sleep 1; done\n" + ) + (bin_dir / "sudo").write_text("#!/bin/bash\nexit 0\n") + (bin_dir / "python3").write_text("#!/bin/bash\ncat > /dev/null\n") + for name in ("xvfb-run", "pycharm", "sudo", "python3"): + (bin_dir / name).chmod(0o755) + cmd = self.todo._qemu_pycharm_project_cmd(False).replace( + self.todo._qemu_install_dir(False), str(repo) + ) + env = dict(os.environ, PATH=f"{bin_dir}:/usr/bin:/bin", HOME=tmp) + res = subprocess.run( + ["bash", "-c", cmd], + capture_output=True, + text=True, + env=env, + timeout=180, + ) + self.assertEqual(0, res.returncode, res.stdout + res.stderr) + marker.unlink(missing_ok=True) + time.sleep(3) + # L'enfant réveillait le marqueur chaque seconde : s'il vit + # encore, le fichier est revenu. + self.assertFalse( + marker.exists(), + "un enfant a survécu à la fermeture du groupe", + ) + + +class TestMobileSwap(unittest.TestCase): + """Le swap posé avant de compiler, et son refus de bloquer. + + Mesuré : le démon Gradle a atteint 6,8 Go de RSS hors tas — son -Xmx1536m + ne le borne pas — sur une VM de 12 Go SANS swap, et le noyau l'a tué deux + fois. « --max-workers=2 » n'a rien changé : le pic est passé de 10,3 à + 11,2 Go. C'est de la marge qu'il faut.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.steps = self.todo._qemu_mobile_build_steps("/tmp/el") + + def test_the_swap_comes_before_the_build(self): + self.assertLess( + self.steps.index("SwapTotal"), self.steps.index("npm ci") + ) + + def test_it_does_nothing_when_swap_is_already_there(self): + self.assertIn("SwapTotal", self.steps) + self.assertIn("-lt 2000000", self.steps) + + def test_a_failed_swap_leaves_no_stray_file(self): + """Un fichier d'échange à moitié fait occuperait 4 Go pour rien.""" + self.assertIn("rm -f /swapfile-erplibre", self.steps) + + def test_it_is_valid_shell(self): + res = subprocess.run( + ["bash", "-n"], + input="mstep() { :; }\n" + self.steps, + capture_output=True, + text=True, + ) + self.assertEqual(0, res.returncode, res.stderr) + + def test_the_swap_block_is_not_linked_by_and(self): + """Lié par « && », un swap refusé arrêterait toute la compilation.""" + head = self.steps[: self.steps.index("npm ci")] + self.assertNotIn("fi; fi && ", head) + self.assertIn("fi; fi; ", head) + + +class TestMobileDiagMemory(unittest.TestCase): + """« Son démon a disparu » ne parle pas de mémoire ; le diagnostic, oui.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.cmd = self.todo._qemu_mobile_diag_cmd() + + def test_the_oom_pattern_names_memory(self): + pats = {p[0]: p[1] for p in TODO._QEMU_MOBILE_DIAG} + self.assertIn("daemon disappeared", pats) + self.assertIn("memory", pats["daemon disappeared"].lower()) + + def test_the_zip_entry_limit_is_named(self): + """La panne d'aujourd'hui, et elle est en amont : un APK est un ZIP + borné à 65 535 entrées, et le dépôt mobile en embarque 122 684 sous + assets/public/repos pour 337 qui sont l'application. Le diagnostic doit + le dire, pas laisser lire 5 000 lignes de Gradle.""" + pats = {e[0]: e[1] for e in TODO._QEMU_MOBILE_DIAG} + self.assertIn("Too many zip entries", pats) + self.assertIn("65535", pats["Too many zip entries"]) + + def test_the_zip_limit_is_named_before_the_generic_gradle_failure(self): + """« FAILED » attrape tout : placé avant, il masquerait la vraie + cause — l'ordre du tableau est le diagnostic.""" + keys = [e[0] for e in TODO._QEMU_MOBILE_DIAG] + self.assertLess( + keys.index("Too many zip entries"), keys.index("FAILED") + ) + + def test_the_cause_is_proven_not_assumed(self): + """Le compte de l'oom-killer et la RAM viennent avec : une cause + « mémoire » sans chiffre serait une supposition de plus.""" + self.assertIn("mmem()", self.cmd) + self.assertIn("MemTotal", self.cmd) + self.assertIn("oom-kill", self.cmd) + + def test_it_runs_and_names_the_cause(self): + import os + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + bin_dir = pathlib.Path(tmp) / "bin" + bin_dir.mkdir() + # Un dmesg qui rapporte un oom-kill, via un sudo neutre. + (bin_dir / "sudo").write_text('#!/bin/bash\nshift 0; exec "$@"\n') + (bin_dir / "dmesg").write_text( + "#!/bin/bash\necho 'oom-kill:constraint=CONSTRAINT_NONE'\n" + ) + for name in ("sudo", "dmesg"): + (bin_dir / name).chmod(0o755) + log = pathlib.Path(tmp) / "build.log" + log.write_text( + "> Task :app:compressDebugAssets\n" + "Gradle build daemon disappeared unexpectedly\n" + ) + res = subprocess.run( + ["bash", "-c", self.cmd + f'mdiag "{log}"'], + capture_output=True, + text=True, + env=dict(os.environ, PATH=f"{bin_dir}:/usr/bin:/bin"), + timeout=60, + ) + out = res.stdout + # « RAM » et « OOM » traversent les deux langues ; le chiffre, + # lui, est ce qui distingue une cause prouvée d'une supposition. + self.assertIn("RAM", out) + self.assertRegex(out, r"\(OOM\)|OOM kills") + self.assertRegex(out, r"[0-9]+") + class TestAvdStep(unittest.TestCase): """L'étape AVD, et ce qu'elle laisse comme piste dans le journal. From 9e69cfa49c48709a2f0aed6a3dc84cd7966b576c Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 05:38:57 -0400 Subject: [PATCH 34/55] =?UTF-8?q?[UPD]=20qemu=20doc:=20=C3=A9num=C3=A9rer?= =?UTF-8?q?=20les=20causes=20que=20le=20diagnostic=20sait=20nommer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La liste s'arrêtait aux quatre premières et laissait croire que le reste tombait dans « aucun motif connu ». Les deux pannes rencontrées cette semaine y manquaient : un démon Gradle tué par le noyau, et un APK refusé pour ses 122 684 fichiers d'assets alors qu'un ZIP tient 65535 entrées. La seconde n'a pas de correctif de notre côté, et la doc le dit : elle appartient au dépôt mobile. --- EN --- The list stopped at the first four, implying the rest fell into "no known pattern". Both failures met this week were missing from it: a Gradle daemon killed by the kernel, and an APK refused for its 122,684 asset files when a ZIP holds 65535 entries. The second one has no fix on our side, and the doc says so: it belongs to the mobile repository. Assisted-by: Claude Opus 5 --- script/qemu/README.base.md | 24 ++++++++++++++++-------- script/qemu/README.fr.md | 12 ++++++++---- script/qemu/README.md | 12 ++++++++---- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md index 198bbe5..dacc1ca 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -298,10 +298,14 @@ an Odoo 18 install), runs the repository's own `install-android.sh` — JDK 17, command-line tools, SDK licences accepted, NDK, whisper.cpp and sentencepiece — then builds: `npm ci`, `vite build`, `cap sync`, `gradlew assembleDebug`, and finally `npm test`. **A failed build fails the VM**: the exit code reaches -the dashboard, and the log names the probable cause (disk full, missing SDK -platform, JDK/Gradle mismatch, unaccepted licences…) instead of leaving a -40 MB Gradle log to read. The heavy output goes to -`~/erplibre-mobile-build.log` inside the VM so the install log stays readable. +the dashboard, and the log names the probable cause instead of leaving a +40 MB Gradle log to read: disk full, missing SDK platform, JDK/Gradle +mismatch, unaccepted licences, a Gradle daemon killed by the kernel (with the +machine's RAM, swap and oom-kill count, because a memory cause is proven and +not assumed), or too many asset files for one APK — a ZIP holds 65535 entries +and the mobile repo ships 122 684, which only that repo can fix. The heavy +output goes to `~/erplibre-mobile-build.log` inside the VM so the install log +stays readable. It is bounded to apt-based distributions, because that upstream installer starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio @@ -431,10 +435,14 @@ outils en ligne de commande, licences SDK acceptées, NDK, whisper.cpp et sentencepiece), puis compile : `npm ci`, `vite build`, `cap sync`, `gradlew assembleDebug`, et enfin `npm test`. **Une compilation en échec fait échouer la VM** : le code de sortie remonte au tableau de bord, et le journal -NOMME la cause probable (disque plein, plateforme SDK absente, JDK et Gradle -incompatibles, licences non acceptées…) au lieu de laisser 40 Mo de journal -Gradle à relire. Le détail va dans `~/erplibre-mobile-build.log`, dans la VM, -pour que le journal d'installation reste lisible. +NOMME la cause probable au lieu de laisser 40 Mo de journal Gradle à relire : +disque plein, plateforme SDK absente, JDK et Gradle incompatibles, licences non +acceptées, démon Gradle tué par le noyau (avec la RAM, le swap et le compte de +l'oom-killer, parce qu'une cause « mémoire » se prouve au lieu de s'affirmer), +ou trop de fichiers d'assets pour un APK — un ZIP tient 65535 entrées et le +dépôt mobile en embarque 122 684, ce que lui seul peut corriger. Le détail va +dans `~/erplibre-mobile-build.log`, dans la VM, pour que le journal +d'installation reste lisible. Il est borné aux distributions apt, parce que cet installateur amont commence par `sudo apt install openjdk-17-jdk`. Il n'exige PAS Android Studio — une diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index e4cc3c2..7263e25 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -187,10 +187,14 @@ outils en ligne de commande, licences SDK acceptées, NDK, whisper.cpp et sentencepiece), puis compile : `npm ci`, `vite build`, `cap sync`, `gradlew assembleDebug`, et enfin `npm test`. **Une compilation en échec fait échouer la VM** : le code de sortie remonte au tableau de bord, et le journal -NOMME la cause probable (disque plein, plateforme SDK absente, JDK et Gradle -incompatibles, licences non acceptées…) au lieu de laisser 40 Mo de journal -Gradle à relire. Le détail va dans `~/erplibre-mobile-build.log`, dans la VM, -pour que le journal d'installation reste lisible. +NOMME la cause probable au lieu de laisser 40 Mo de journal Gradle à relire : +disque plein, plateforme SDK absente, JDK et Gradle incompatibles, licences non +acceptées, démon Gradle tué par le noyau (avec la RAM, le swap et le compte de +l'oom-killer, parce qu'une cause « mémoire » se prouve au lieu de s'affirmer), +ou trop de fichiers d'assets pour un APK — un ZIP tient 65535 entrées et le +dépôt mobile en embarque 122 684, ce que lui seul peut corriger. Le détail va +dans `~/erplibre-mobile-build.log`, dans la VM, pour que le journal +d'installation reste lisible. Il est borné aux distributions apt, parce que cet installateur amont commence par `sudo apt install openjdk-17-jdk`. Il n'exige PAS Android Studio — une diff --git a/script/qemu/README.md b/script/qemu/README.md index 2bc5f77..cc035f3 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -172,10 +172,14 @@ an Odoo 18 install), runs the repository's own `install-android.sh` — JDK 17, command-line tools, SDK licences accepted, NDK, whisper.cpp and sentencepiece — then builds: `npm ci`, `vite build`, `cap sync`, `gradlew assembleDebug`, and finally `npm test`. **A failed build fails the VM**: the exit code reaches -the dashboard, and the log names the probable cause (disk full, missing SDK -platform, JDK/Gradle mismatch, unaccepted licences…) instead of leaving a -40 MB Gradle log to read. The heavy output goes to -`~/erplibre-mobile-build.log` inside the VM so the install log stays readable. +the dashboard, and the log names the probable cause instead of leaving a +40 MB Gradle log to read: disk full, missing SDK platform, JDK/Gradle +mismatch, unaccepted licences, a Gradle daemon killed by the kernel (with the +machine's RAM, swap and oom-kill count, because a memory cause is proven and +not assumed), or too many asset files for one APK — a ZIP holds 65535 entries +and the mobile repo ships 122 684, which only that repo can fix. The heavy +output goes to `~/erplibre-mobile-build.log` inside the VM so the install log +stays readable. It is bounded to apt-based distributions, because that upstream installer starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio From aa94e460ec55917d8eb2db6a2a8a302376d980ba Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 05:57:22 -0400 Subject: [PATCH 35/55] =?UTF-8?q?[FIX]=20script=20todo:=20ne=20plus=20empa?= =?UTF-8?q?queter=20les=20d=C3=A9p=C3=B4ts=20du=20manifeste=20dans=20l'APK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La compilation butait sur « Too many zip entries 123678 (MAX=65535) » : un APK est un ZIP, et le dépôt mobile verse 122 684 fichiers d'assets pour 337 qui sont l'application. Le levier existe et il est documenté chez lui (doc/SERVICES.md) : ERPLIBRE_MANIFEST_PATH, ici pointé sur un manifeste vide — « ces dépôts-là : aucun ». Le plugin l'annonce, « 0 repos ». Mesuré sur erplibre-ubuntu-2604-gnome : dist passe de 123 019 fichiers à 336, l'APK sort à 59 Mo et 2 472 entrées, et la phase mobile entière rend 0, tests Vitest compris — 75 fichiers, 1938 tests. Qui veut les dépôts pose la variable lui-même : elle est respectée. Mesure d'attente, à retirer quand ils tiendront sous le plafond du ZIP. --- EN --- The build hit "Too many zip entries 123678 (MAX=65535)": an APK is a ZIP, and the mobile repo pours 122,684 asset files in for 337 that are the application. The lever exists and that repo documents it (doc/SERVICES.md): ERPLIBRE_MANIFEST_PATH, pointed here at an empty manifest — "those repos: none". The plugin says so itself, "0 repos". Measured on erplibre-ubuntu-2604-gnome: dist drops from 123,019 files to 336, the APK comes out at 59 MB with 2,472 entries, and the whole mobile phase returns 0, Vitest included — 75 files, 1938 tests. Set the variable yourself and the repos come back. A stopgap, to drop once they fit under the ZIP ceiling. Assisted-by: Claude Opus 5 --- script/qemu/README.base.md | 32 ++++++++++++++++++++++++-------- script/qemu/README.fr.md | 17 +++++++++++++---- script/qemu/README.md | 15 +++++++++++---- script/todo/todo.py | 26 +++++++++++++++++++++++++- script/todo/todo_i18n.py | 4 ++++ test/test_qemu_desktop_tools.py | 27 +++++++++++++++++++++++++++ 6 files changed, 104 insertions(+), 17 deletions(-) diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md index dacc1ca..14496b3 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -302,10 +302,17 @@ the dashboard, and the log names the probable cause instead of leaving a 40 MB Gradle log to read: disk full, missing SDK platform, JDK/Gradle mismatch, unaccepted licences, a Gradle daemon killed by the kernel (with the machine's RAM, swap and oom-kill count, because a memory cause is proven and -not assumed), or too many asset files for one APK — a ZIP holds 65535 entries -and the mobile repo ships 122 684, which only that repo can fix. The heavy -output goes to `~/erplibre-mobile-build.log` inside the VM so the install log -stays readable. +not assumed), or too many asset files for one APK. The heavy output goes to +`~/erplibre-mobile-build.log` inside the VM so the install log stays readable. + +That last cause no longer stops the build. The mobile repo bundles the manifest +repositories into its assets — 122 684 files, for 337 that are the application +— and an APK is a ZIP, capped at 65535 entries: `Too many zip entries 123678`. +The build therefore points `ERPLIBRE_MANIFEST_PATH`, the lever that repo +documents, at an empty manifest, and the plugin says so: `0 repos`. Measured: +`dist` drops from 123 019 files to 336, and the APK comes out at 59 MB with +2 472 entries. Set the variable yourself and the repositories come back — the +default is a stopgap until they fit under the ZIP ceiling. It is bounded to apt-based distributions, because that upstream installer starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio @@ -439,10 +446,19 @@ NOMME la cause probable au lieu de laisser 40 Mo de journal Gradle à relire : disque plein, plateforme SDK absente, JDK et Gradle incompatibles, licences non acceptées, démon Gradle tué par le noyau (avec la RAM, le swap et le compte de l'oom-killer, parce qu'une cause « mémoire » se prouve au lieu de s'affirmer), -ou trop de fichiers d'assets pour un APK — un ZIP tient 65535 entrées et le -dépôt mobile en embarque 122 684, ce que lui seul peut corriger. Le détail va -dans `~/erplibre-mobile-build.log`, dans la VM, pour que le journal -d'installation reste lisible. +ou trop de fichiers d'assets pour un APK. Le détail va dans +`~/erplibre-mobile-build.log`, dans la VM, pour que le journal d'installation +reste lisible. + +Cette dernière cause n'arrête plus la compilation. Le dépôt mobile empaquette +les dépôts du manifeste dans ses assets — 122 684 fichiers, pour 337 qui sont +l'application — et un APK est un ZIP, borné à 65535 entrées : +`Too many zip entries 123678`. La compilation pointe donc +`ERPLIBRE_MANIFEST_PATH`, le levier que ce dépôt documente, sur un manifeste +vide, et le plugin l'annonce : `0 repos`. Mesuré : `dist` passe de 123 019 +fichiers à 336, et l'APK sort à 59 Mo et 2 472 entrées. Posez la variable +vous-même et les dépôts reviennent — le défaut est une mesure d'attente, le +temps qu'ils tiennent sous le plafond du ZIP. Il est borné aux distributions apt, parce que cet installateur amont commence par `sudo apt install openjdk-17-jdk`. Il n'exige PAS Android Studio — une diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index 7263e25..dfe74d7 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -191,10 +191,19 @@ NOMME la cause probable au lieu de laisser 40 Mo de journal Gradle à relire : disque plein, plateforme SDK absente, JDK et Gradle incompatibles, licences non acceptées, démon Gradle tué par le noyau (avec la RAM, le swap et le compte de l'oom-killer, parce qu'une cause « mémoire » se prouve au lieu de s'affirmer), -ou trop de fichiers d'assets pour un APK — un ZIP tient 65535 entrées et le -dépôt mobile en embarque 122 684, ce que lui seul peut corriger. Le détail va -dans `~/erplibre-mobile-build.log`, dans la VM, pour que le journal -d'installation reste lisible. +ou trop de fichiers d'assets pour un APK. Le détail va dans +`~/erplibre-mobile-build.log`, dans la VM, pour que le journal d'installation +reste lisible. + +Cette dernière cause n'arrête plus la compilation. Le dépôt mobile empaquette +les dépôts du manifeste dans ses assets — 122 684 fichiers, pour 337 qui sont +l'application — et un APK est un ZIP, borné à 65535 entrées : +`Too many zip entries 123678`. La compilation pointe donc +`ERPLIBRE_MANIFEST_PATH`, le levier que ce dépôt documente, sur un manifeste +vide, et le plugin l'annonce : `0 repos`. Mesuré : `dist` passe de 123 019 +fichiers à 336, et l'APK sort à 59 Mo et 2 472 entrées. Posez la variable +vous-même et les dépôts reviennent — le défaut est une mesure d'attente, le +temps qu'ils tiennent sous le plafond du ZIP. Il est borné aux distributions apt, parce que cet installateur amont commence par `sudo apt install openjdk-17-jdk`. Il n'exige PAS Android Studio — une diff --git a/script/qemu/README.md b/script/qemu/README.md index cc035f3..81f673c 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -176,10 +176,17 @@ the dashboard, and the log names the probable cause instead of leaving a 40 MB Gradle log to read: disk full, missing SDK platform, JDK/Gradle mismatch, unaccepted licences, a Gradle daemon killed by the kernel (with the machine's RAM, swap and oom-kill count, because a memory cause is proven and -not assumed), or too many asset files for one APK — a ZIP holds 65535 entries -and the mobile repo ships 122 684, which only that repo can fix. The heavy -output goes to `~/erplibre-mobile-build.log` inside the VM so the install log -stays readable. +not assumed), or too many asset files for one APK. The heavy output goes to +`~/erplibre-mobile-build.log` inside the VM so the install log stays readable. + +That last cause no longer stops the build. The mobile repo bundles the manifest +repositories into its assets — 122 684 files, for 337 that are the application +— and an APK is a ZIP, capped at 65535 entries: `Too many zip entries 123678`. +The build therefore points `ERPLIBRE_MANIFEST_PATH`, the lever that repo +documents, at an empty manifest, and the plugin says so: `0 repos`. Measured: +`dist` drops from 123 019 files to 336, and the APK comes out at 59 MB with +2 472 entries. Set the variable yourself and the repositories come back — the +default is a stopgap until they fit under the ZIP ceiling. It is bounded to apt-based distributions, because that upstream installer starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio diff --git a/script/todo/todo.py b/script/todo/todo.py index 568528c..9d2696b 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -6260,10 +6260,34 @@ class TODO: "else sudo rm -f /swapfile-erplibre 2>/dev/null; " f'echo " {t("no swap could be added; build may run short")}"; ' "fi; fi; " + f'echo " {t("manifest repos not bundled (ZIP entry limit)")}"; ' f'mstep "{t("npm dependencies")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile && npm ci' && " + # L'empaquetage des dépôts du manifeste est DÉSACTIVÉ, et c'est + # une mesure d'attente. Il verse 122 684 fichiers dans les assets — + # des dépôts Odoo entiers, pour 337 qui sont l'application — et un + # APK est un ZIP : « Too many zip entries 123678 (MAX=65535) », la + # compilation s'arrête là. Les mêmes fichiers épuisaient aussi les + # watches inotify de l'IDE. + # + # Le levier est celui que le dépôt mobile documente lui-même + # (doc/SERVICES.md) : ERPLIBRE_MANIFEST_PATH. On le pointe sur un + # manifeste VIDE — « ces dépôts-là : aucun » — et le plugin + # l'annonce, « 0 repos ». Mesuré : dist passe de 123 019 à 336 + # fichiers, l'APK sort à 59 Mo et 2 472 entrées. + # + # Qui veut les dépôts pose la variable lui-même : elle est + # respectée. À retirer quand le projet mobile saura les empaqueter + # sous la limite du ZIP. + # Chaîné par « && » comme le reste : un « ; » ici laisserait la + # compilation web démarrer alors que « npm ci » vient d'échouer. + "printf '' " + '> "$HOME/.erplibre-empty-manifest.xml" && ' f'mstep "{t("web bundle (vite build)")}" ' - f"'cd {el_dir}/mobile/erplibre_home_mobile && npm run build' && " + f"'cd {el_dir}/mobile/erplibre_home_mobile && " + "ERPLIBRE_MANIFEST_PATH=" + '"${ERPLIBRE_MANIFEST_PATH:-$HOME/.erplibre-empty-manifest.xml}" ' + "npm run build' && " f'mstep "{t("native sync (capacitor)")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile && npx cap sync android' && " # UNE seule ABI, celle de la VM — qui est aussi celle de diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 8abe4a7..5aeccfb 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2289,6 +2289,10 @@ TRANSLATIONS = { "en": "no swap could be added; build may run short", }, "memory:": {"fr": "mémoire :", "en": "memory:"}, + "manifest repos not bundled (ZIP entry limit)": { + "fr": "dépôts du manifeste non empaquetés (limite d'entrées ZIP)", + "en": "manifest repos not bundled (ZIP entry limit)", + }, "MB RAM,": {"fr": "Mo de RAM,", "en": "MB RAM,"}, "MB swap, kernel OOM kills:": { "fr": "Mo de swap, tués par le noyau (OOM) :", diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index 4805dc3..69a677d 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -340,6 +340,33 @@ class TestMobileBuild(unittest.TestCase): res = self._run_steps(fail_on="gradle") self.assertNotEqual(0, res.returncode, res.stdout[-400:]) + def test_a_failed_step_stops_the_ones_after_it(self): + """La chaîne est en « && » d'un bout à l'autre. Un « ; » glissé au + milieu — celui qui écrivait le manifeste vide — laissait la compilation + web démarrer alors que « npm ci » venait d'échouer.""" + res = self._run_steps(fail_on="npm") + self.assertNotEqual(0, res.returncode) + self.assertNotIn("vite build", res.stdout) + self.assertNotIn("gradle", res.stdout) + + def test_the_manifest_repos_are_not_bundled(self): + """122 684 fichiers d'assets pour 337 qui sont l'application, et un APK + est un ZIP borné à 65 535 entrées. Le levier est celui que le dépôt + mobile documente : ERPLIBRE_MANIFEST_PATH, pointé sur un manifeste + vide. Mesuré : dist passe de 123 019 à 336 fichiers.""" + steps = self.todo._qemu_mobile_build_steps("/tmp/el") + self.assertIn("ERPLIBRE_MANIFEST_PATH=", steps) + self.assertIn("", steps) + # La variable posée par l'appelant gagne : qui veut les dépôts les a. + self.assertIn("${ERPLIBRE_MANIFEST_PATH:-", steps) + + def test_the_empty_manifest_is_written_before_the_bundle(self): + steps = self.todo._qemu_mobile_build_steps("/tmp/el") + self.assertLess( + steps.index("erplibre-empty-manifest.xml"), + steps.index("npm run build"), + ) + def test_a_missing_apk_fails_even_when_gradle_returns_zero(self): """L'APK est la preuve, pas le code de sortie de Gradle : une tâche peut rendre 0 sans rien produire.""" From 84335e1b47b7ac4566eb89a9b67c551c33c1eb6b Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 06:27:50 -0400 Subject: [PATCH 36/55] =?UTF-8?q?[ADD]=20script=20todo:=20afficher=20la=20?= =?UTF-8?q?RAM=20de=20l'h=C3=B4te=20dans=20le=20suivi=20d'installation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La barre montrait le CPU et le disque, jamais la mémoire. C'est pourtant la seule des trois dont l'épuisement ne se voit nulle part ailleurs : une compilation mobile s'est fait tuer par le noyau sur une VM de 12 Go sans swap pendant que la barre affichait une charge tranquille et du disque de reste. Lue dans /proc/meminfo, sans dépendance — ce suivi tourne sur l'hyperviseur, donc sous Linux, d'où viennent déjà getloadavg et libvirt. « MemAvailable » plutôt que « MemFree », presque nul dès que le cache travaille. Le swap n'occupe la barre que s'il existe, et alors même à zéro : une machine qui commence à échanger explique une lenteur. Au passage, « charge » et « libre » s'affichaient en français dans une session anglaise. --- EN --- The bar showed CPU and disk, never memory. Yet memory is the one of the three whose exhaustion shows up nowhere else: a mobile build was killed by the kernel on a 12 GB VM with no swap while the bar displayed a quiet load and disk to spare. Read from /proc/meminfo, no dependency — this monitor runs on the hypervisor, so on Linux, where getloadavg and libvirt already come from. "MemAvailable" rather than "MemFree", near zero as soon as the cache is working. Swap takes room in the bar only when it exists, and then even at zero: a machine starting to swap explains a slowdown. Along the way, "load" and "free" were showing in French in an English session. Assisted-by: Claude Opus 5 --- script/todo/qemu_install_monitor.py | 60 ++++++++++- script/todo/todo_i18n.py | 1 + test/test_qemu_monitor_telemetry.py | 151 ++++++++++++++++++++++++++++ 3 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 test/test_qemu_monitor_telemetry.py diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index f366285..6d1f977 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -814,6 +814,54 @@ def _fmt_size(nbytes) -> str: return f"{nbytes // 1024}K" +def _host_mem() -> tuple: + """(total, disponible, swap_total, swap_libre) en octets, lus dans /proc. + + /proc/meminfo plutôt qu'une dépendance : psutil n'est pas garanti dans le + venv d'outils, et ce suivi tourne sur l'hyperviseur — donc sous Linux, d'où + viennent déjà getloadavg() et libvirt. + + « MemAvailable » et non « MemFree » : le noyau y répond ce qu'il peut + rendre sans échanger, cache réclamable compris. MemFree seul affiche + presque rien sur une machine qui travaille, et alarmerait pour rien. + """ + wanted = ("MemTotal", "MemAvailable", "SwapTotal", "SwapFree") + vals = {} + try: + with open("/proc/meminfo", encoding="utf-8") as fh: + for line in fh: + key, _, rest = line.partition(":") + if key in wanted: + vals[key] = int(rest.split()[0]) * 1024 + except (OSError, ValueError, IndexError): + return (0, 0, 0, 0) + return tuple(vals.get(k, 0) for k in wanted) + + +def _mem_tele(total, avail, sw_total, sw_free) -> str: + """Segment « RAM » de la barre de télémétrie. Vide si /proc n'a rien dit. + + Le swap n'apparaît que s'il existe : l'afficher à « 0/0 » sur une machine + qui n'en a pas occupe une place pour ne rien dire. Quand il existe, il est + montré même à zéro — une VM qui a commencé à échanger explique une lenteur, + et c'est précisément ce qu'on cherche dans un suivi d'installation. + """ + if not total: + return "" + used = max(0, total - avail) + out = ( + f"🧠 RAM {_fmt_size(used)}/{_fmt_size(total)}" + f" ({int(used / total * 100)}%)" + f" · {t('free space')} {_fmt_size(avail)}" + ) + if sw_total: + out += ( + f" · swap {_fmt_size(max(0, sw_total - sw_free))}" + f"/{_fmt_size(sw_total)}" + ) + return out + + def _fmt_secs(secs) -> str: """Secondes -> « 45s » / « 12m » / « 1h05 ».""" secs = int(secs) @@ -1509,12 +1557,18 @@ def run_monitor(manifest_path: str, run_app: bool = True): load1 = os.getloadavg()[0] du = shutil.disk_usage(self._disk_dir) used_pct = int(du.used / du.total * 100) if du.total else 0 + # La RAM va entre le CPU et le disque : c'est la ressource dont + # l'épuisement ne se voit nulle part ailleurs. Une compilation + # mobile a été tuée par le noyau sur une VM de 12 Go sans swap, + # et ce suivi n'en montrait rien. + mem = _mem_tele(*_host_mem()) return ( f" ⚙ CPU {min(999, int(load1 / ncpu * 100))}% " - f"(charge {load1:.1f}/{ncpu}) " - f"💽 {self._disk_dir}: {_fmt_size(du.used)}/" + f"({t('load')} {load1:.1f}/{ncpu}) " + + (f"{mem} " if mem else "") + + f"💽 {self._disk_dir}: {_fmt_size(du.used)}/" f"{_fmt_size(du.total)} ({used_pct}%) · " - f"libre {_fmt_size(du.free)}" + f"{t('free space')} {_fmt_size(du.free)}" ) except Exception: return "" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 5aeccfb..7efb66e 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2258,6 +2258,7 @@ TRANSLATIONS = { "Hard signals": {"fr": "Signaux durs", "en": "Hard signals"}, "Grouped by shape": {"fr": "Regroupé par forme", "en": "Grouped by shape"}, "line": {"fr": "ligne", "en": "line"}, + "free space": {"fr": "libre", "en": "free"}, "inotify watches raised for the IDE": { "fr": "watches inotify relevées pour l'IDE", "en": "inotify watches raised for the IDE", diff --git a/test/test_qemu_monitor_telemetry.py b/test/test_qemu_monitor_telemetry.py new file mode 100644 index 0000000..4d140ab --- /dev/null +++ b/test/test_qemu_monitor_telemetry.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Barre de télémétrie du suivi d'installation : CPU, RAM, disque. + +La RAM manquait, et son absence a coûté : une compilation mobile s'est fait +tuer par le noyau sur une VM de 12 Go sans swap, pendant que le suivi affichait +sereinement le CPU et le disque. L'épuisement mémoire ne se voit nulle part +ailleurs — le disque va bien, la charge CPU aussi, et la machine meurt. + +Ce qui se vérifie ici : le chiffre lu est celui que le noyau dit pouvoir +rendre, le swap n'occupe la barre que s'il existe, et une lecture impossible +laisse la barre utile au lieu de la vider. +""" + +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "script/todo")) +import qemu_install_monitor as m # noqa: E402 + +GB = 1 << 30 + + +class TestHostMem(unittest.TestCase): + def test_it_reads_the_real_proc(self): + total, avail, sw_total, sw_free = m._host_mem() + self.assertGreater(total, 0, "aucune RAM lue dans /proc/meminfo") + self.assertLessEqual(avail, total) + self.assertLessEqual(sw_free, sw_total) + + def test_it_reports_what_the_kernel_can_give_back(self): + """« MemAvailable » et non « MemFree » : sur une machine qui travaille, + MemFree est presque nul parce que le cache occupe le reste, et + alarmerait pour rien.""" + proc = ( + "MemTotal: 12000000 kB\n" + "MemFree: 100000 kB\n" + "MemAvailable: 8000000 kB\n" + "SwapTotal: 0 kB\n" + "SwapFree: 0 kB\n" + ) + with mock.patch("builtins.open", mock.mock_open(read_data=proc)): + total, avail, _, _ = m._host_mem() + self.assertEqual(avail, 8000000 * 1024) + self.assertEqual(total, 12000000 * 1024) + + def test_an_unreadable_proc_gives_zeros_not_a_crash(self): + """Le suivi tourne pendant une heure d'installation : il ne meurt pas + parce qu'une lecture a échoué.""" + with mock.patch("builtins.open", side_effect=OSError): + self.assertEqual(m._host_mem(), (0, 0, 0, 0)) + + def test_a_malformed_line_is_not_fatal(self): + with mock.patch( + "builtins.open", mock.mock_open(read_data="MemTotal: beaucoup\n") + ): + self.assertEqual(m._host_mem(), (0, 0, 0, 0)) + + +class TestMemSegment(unittest.TestCase): + def test_the_used_share_is_total_minus_available(self): + got = m._mem_tele(12 * GB, 3 * GB, 0, 0) + self.assertIn("9.0G/12.0G", got) + self.assertIn("(75%)", got) + + def test_the_available_figure_is_shown_as_such(self): + self.assertIn("3.0G", m._mem_tele(12 * GB, 3 * GB, 0, 0)) + + def test_swap_appears_only_when_the_machine_has_some(self): + """Un « swap 0/0 » occuperait la barre pour ne rien dire. Mais dès + qu'il existe, il est montré même à zéro : une machine qui commence à + échanger explique une lenteur, et c'est ce qu'on cherche ici.""" + self.assertNotIn("swap", m._mem_tele(12 * GB, 3 * GB, 0, 0)) + self.assertIn( + "swap 0K/4.0G", m._mem_tele(12 * GB, 3 * GB, 4 * GB, 4 * GB) + ) + self.assertIn( + "swap 1.0G/4.0G", m._mem_tele(12 * GB, 3 * GB, 4 * GB, 3 * GB) + ) + + def test_nothing_read_means_no_segment_not_a_zero_segment(self): + """La barre garde alors le CPU et le disque, qui eux ont répondu.""" + self.assertEqual(m._mem_tele(0, 0, 0, 0), "") + + def test_more_available_than_total_does_not_show_negative_use(self): + """Cas absurde mais possible entre deux lectures : on ne veut pas + « -1.0G » dans la barre.""" + self.assertIn("0K/12.0G", m._mem_tele(12 * GB, 13 * GB, 0, 0)) + + +class TestTheRealBar(unittest.TestCase): + """La barre telle que le suivi la construit, sans lancer la TUI.""" + + def setUp(self): + try: + import textual # noqa: F401 + except ImportError: + self.skipTest("textual absent de ce venv") + self.tmp = tempfile.TemporaryDirectory() + log = Path(self.tmp.name) / "vm-a.log" + log.write_text("== installation ==\n") + manifest = Path(self.tmp.name) / "session.json" + manifest.write_text( + json.dumps( + { + "branch": "develop", + "started": 0, + "vms": [ + { + "name": "vm-a", + "ip": "192.168.123.2", + "log": str(log), + "ssh": "ssh erplibre@192.168.123.2", + } + ], + } + ) + ) + self.app = m.run_monitor(str(manifest), run_app=False) + + def tearDown(self): + self.tmp.cleanup() + + def test_the_bar_carries_the_three_resources(self): + bar = self.app._collect_tele() + self.assertIn("CPU", bar) + self.assertIn("RAM", bar) + self.assertIn("💽", bar) + + def test_ram_sits_between_cpu_and_disk(self): + """L'ordre est celui du coût : le CPU se voit ailleurs, la RAM nulle + part, le disque partout.""" + bar = self.app._collect_tele() + self.assertLess(bar.index("CPU"), bar.index("RAM")) + self.assertLess(bar.index("RAM"), bar.index("💽")) + + def test_the_bar_survives_a_mute_proc(self): + with mock.patch.object(m, "_host_mem", return_value=(0, 0, 0, 0)): + bar = self.app._collect_tele() + self.assertIn("CPU", bar) + self.assertIn("💽", bar) + self.assertNotIn("RAM", bar) + + +if __name__ == "__main__": + unittest.main() From 087ce3effb22b679857b175663d62de0efdade0e Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 07:26:54 -0400 Subject: [PATCH 37/55] [FIX] script todo: viser l'IDE par son nom, pas par la ligne de commande MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le filet de fermeture de PyCharm cherchait « /opt/pycharm » dans les lignes de commande. Or le script d'installation est passé en argument à ssh, et il contient ce chemin : le pkill a tué la session ssh qui portait une installation en cours. Elle est morte sans marqueur de sortie, et le tableau de bord a montré un sablier pendant 54 minutes. Exécuter la suite de tests suffisait à déclencher le défaut, puisqu'un test rejoue l'étape. Le filet vise désormais les NOMS de processus, bornés au compte courant. Mesuré dans la VM : par nom, 3 processus réels et aucun faux ; par ligne de commande, 4 — le ssh compris. Un test plante un témoin nommé « sleep » dont la ligne contient le chemin de l'IDE, et les tests bouchonnent pgrep et pkill. --- EN --- PyCharm's closing net looked for "/opt/pycharm" in command lines. But the install script is passed to ssh as an argument, and it contains that path: the pkill killed the ssh session carrying a running install. It died with no exit marker, and the dashboard showed an hourglass for 54 minutes. Running the test suite was enough to trigger it, since one test replays the step. The net now targets process NAMES, scoped to the current account. Measured in the VM: by name, 3 real processes and no false ones; by command line, 4 — the ssh included. A test plants a witness named "sleep" whose command line holds the IDE path, and the tests stub pgrep and pkill. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 28 ++++++---- test/test_qemu_desktop_tools.py | 92 ++++++++++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 11 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index 9d2696b..2e9e709 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5785,18 +5785,26 @@ class TODO: "for i in $(seq 1 12); do kill -0 -$pid 2>/dev/null || break; " "sleep 5; done; kill -KILL -$pid 2>/dev/null; " # Filet, et il a sa raison d'être : ce qui survit ici mange la - # mémoire de TOUTES les étapes suivantes. On le nomme au lieu de le - # laisser courir, et on ne vise que l'IDE de /opt et l'écran :99. - # « pgrep -fc » IMPRIME 0 et rend 1 quand il ne trouve rien : le - # « || echo 0 » ajoutait un second zéro, et « 0\n0 » n'est pas - # « 0 » — le filet se déclenchait donc toujours. « wc -l » rend un - # seul nombre et un code 0. - 'left=$(pgrep -f "[/]opt/pycharm|[X]vfb :99" 2>/dev/null ' - "| wc -l); " + # mémoire de TOUTES les étapes suivantes. + # + # Par NOM de processus (« -x »), jamais par ligne de commande. Un + # « pkill -f /opt/pycharm » attrape aussi le ssh QUI PORTE cette + # installation — sa ligne de commande contient le script entier, + # donc ce chemin. Vécu : une installation est morte en silence, sa + # session ssh emportée, 48 minutes perdues. Mesuré ensuite : par + # nom, 3 processus réels attrapés et 0 faux ; par ligne de commande, + # 4 dont le ssh. Les noms sont ceux relevés dans la VM — pycharm, + # Xvfb, fsnotifier, cef_server — et « -u » borne au compte courant. + # + # « pgrep -c » IMPRIME 0 et rend 1 quand il ne trouve rien : un + # « || echo 0 » donnerait « 0\n0 », qui n'est pas « 0 ». « wc -l » + # rend un seul nombre et un code 0. + 'left=$(pgrep -u "$(id -u)" -x ' + '"pycharm|cef_server|fsnotifier|Xvfb" 2>/dev/null | wc -l); ' '[ "$left" = 0 ] || { ' f'echo " {t("closing what survived the first open:")} $left"; ' - 'pkill -f "[/]opt/pycharm" 2>/dev/null; ' - 'pkill -f "[X]vfb :99" 2>/dev/null; sleep 2; }; ' + 'pkill -u "$(id -u)" -x ' + '"pycharm|cef_server|fsnotifier|Xvfb" 2>/dev/null; sleep 2; }; ' '[ "$ok" = 1 ]; fi; fi; } && ' f'echo " {t("project created, the install will configure it")}" ' f'|| echo " ⚠ {t("no .idea: open PyCharm once, then")} ' diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index 69a677d..35974ee 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -735,7 +735,21 @@ class TestPycharmFirstOpen(unittest.TestCase): ) (bin_dir / "sudo").write_text("#!/bin/bash\nexit 0\n") (bin_dir / "python3").write_text("#!/bin/bash\ncat > /dev/null\n") - for name in ("xvfb-run", "pycharm", "sudo", "python3"): + # pgrep et pkill sont BOUCHONNÉS, et c'est le point important : le + # filet de l'étape balaie les processus du compte courant. Exécuté + # sans bouchon sur la machine de développement, il fermerait le + # PyCharm de l'utilisateur. C'est le groupe qu'on teste ici, pas le + # filet — celui-ci est vérifié à part, sans rien tuer. + (bin_dir / "pgrep").write_text("#!/bin/bash\nexit 1\n") + (bin_dir / "pkill").write_text("#!/bin/bash\nexit 0\n") + for name in ( + "xvfb-run", + "pycharm", + "sudo", + "python3", + "pgrep", + "pkill", + ): (bin_dir / name).chmod(0o755) cmd = self.todo._qemu_pycharm_project_cmd(False).replace( self.todo._qemu_install_dir(False), str(repo) @@ -759,6 +773,82 @@ class TestPycharmFirstOpen(unittest.TestCase): ) +class TestPycharmNetIsNarrow(unittest.TestCase): + """Le filet de fermeture ne doit JAMAIS viser le ssh qui porte l'install. + + Vécu, et cher : le filet cherchait « /opt/pycharm » dans les LIGNES DE + COMMANDE. Or la commande d'installation est passée en argument à ssh, et + elle contient ce chemin — le pkill a donc tué la session ssh qui portait + l'installation en cours sur l'hyperviseur. Elle est morte en silence, sans + marqueur de sortie : 48 minutes perdues, et rien dans le journal. + + Mesuré ensuite dans une VM : par NOM de processus, 3 processus réels + (pycharm, Xvfb, fsnotifier) et aucun faux ; par ligne de commande, 4 — le + ssh compris. + """ + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.cmd = self.todo._qemu_pycharm_project_cmd() + + def test_it_matches_by_process_name(self): + self.assertIn('pgrep -u "$(id -u)" -x', self.cmd) + self.assertIn('pkill -u "$(id -u)" -x', self.cmd) + + def test_it_never_matches_by_command_line(self): + """« -f » est exactement ce qui a tué l'installation.""" + self.assertNotIn("pkill -f", self.cmd) + self.assertNotIn("pgrep -f", self.cmd) + + def test_the_names_are_the_ones_measured_in_the_vm(self): + for name in ("pycharm", "cef_server", "fsnotifier", "Xvfb"): + self.assertIn(name, self.cmd) + + def test_a_command_line_that_merely_mentions_the_ide_is_spared(self): + """Le test qui compte, et il ne tue rien : un témoin dont la LIGNE + contient le chemin de l'IDE — comme le ssh lanceur — et dont le NOM est + « sleep ». L'ancien motif l'attrape, le nouveau l'épargne.""" + import os + import re + import time + + pattern = re.search(r'-x "([^"]+)"', self.cmd).group(1) + witness = subprocess.Popen( + [ + "bash", + "-c", + 'exec -a "ssh erplibre@vm bash -c /opt/pycharm/bin/pycharm.sh"' + " sleep 30", + ] + ) + try: + time.sleep(1.5) + uid = str(os.getuid()) + by_name = subprocess.run( + ["pgrep", "-u", uid, "-x", pattern], + capture_output=True, + text=True, + ).stdout.split() + by_cmdline = subprocess.run( + ["pgrep", "-u", uid, "-f", "[/]opt/pycharm"], + capture_output=True, + text=True, + ).stdout.split() + pid = str(witness.pid) + # Le témoin est un enfant de bash : on cherche le groupe entier. + spared = pid not in by_name + self.assertTrue(spared, "le motif par nom a attrapé le témoin") + self.assertIn( + pid, + by_cmdline, + "le témoin devrait être attrapé par l'ancien motif ;" + " sinon ce test ne prouve rien", + ) + finally: + witness.kill() + witness.wait(timeout=10) + + class TestMobileSwap(unittest.TestCase): """Le swap posé avant de compiler, et son refus de bloquer. From b4310665fc406b519776e92854eff532cb7d3deb Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 07:26:54 -0400 Subject: [PATCH 38/55] [ADD] script todo: dire depuis quand un journal d'installation est muet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le sablier ne distingue pas une installation qui travaille d'une qui est morte : le marqueur de sortie manque dans les deux cas. Une session ssh emportée, et le tableau de bord a affiché « ⏳ » pendant 54 minutes sans que rien ne cloche à l'œil. La colonne d'état porte maintenant le silence du journal — « ⏳ silence 48min ». C'est un chiffre, pas un verdict. Le seuil de dix minutes vient d'une mesure : le téléchargement d'Android Studio tient ~5 min sans une ligne, et l'étape « APK debug » davantage, son détail partant dans le journal de la VM. Plus bas, chaque installation deviendrait une alerte, et l'alerte cesserait d'être lue. --- EN --- The hourglass does not tell a working install from a dead one: the exit marker is missing in both cases. An ssh session was reaped, and the dashboard showed "⏳" for 54 minutes with nothing looking wrong. The state column now carries the log's silence — "⏳ silent 48min". It is a figure, not a verdict. The ten-minute threshold comes from a measurement: the Android Studio download holds ~5 min without a line, and the "debug APK" step longer, its detail going to the VM's own log. Any lower and every install would become an alert, and the alert would stop being read. Assisted-by: Claude Opus 5 --- script/todo/qemu_install_monitor.py | 42 +++++++++++++++++++++- script/todo/todo_i18n.py | 1 + test/test_qemu_monitor_telemetry.py | 55 +++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index 6d1f977..a00420c 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -332,6 +332,41 @@ def read_status(log_path: str) -> tuple[str, int | None]: return "running", None +# Au-delà de ce silence, la colonne d'état le DIT. Ce n'est pas un verdict mais +# un chiffre : plusieurs étapes sont légitimement muettes, leur sortie partant +# ailleurs. Mesuré sur une installation réelle : le téléchargement d'Android +# Studio tient ~5 min sans une ligne, et l'étape « APK debug » davantage — son +# détail va dans le journal de la VM. Dix minutes passent donc au-dessus du +# premier sans attendre le second, qui reste bruyant par nature. +# +# À 48 minutes, le chiffre est accablant : une installation est morte ainsi, +# session ssh emportée, et le sablier tournait toujours. +IDLE_HINT_SECS = 600 + + +def log_idle(log_path: str) -> float: + """Secondes depuis la dernière écriture dans le journal. -1 s'il manque. + + La date de modification du fichier, et non un compte de lignes : c'est la + seule mesure qui distingue « rien n'avance » de « rien ne s'affiche ».""" + try: + return max(0.0, time.time() - os.path.getmtime(log_path)) + except OSError: + return -1.0 + + +def state_mark(icon: str, idle: float) -> str: + """Icône d'état, suivie du silence du journal quand il dépasse le seuil. + + Le silence est une INFORMATION, pas un diagnostic : plusieurs étapes sont + muettes longtemps sans rien avoir de cassé. Mais le sablier seul ne + distingue pas une installation qui travaille d'une qui est morte, et c'est + arrivé — 48 minutes de sablier sur une session ssh déjà emportée.""" + if idle > IDLE_HINT_SECS: + return f"{icon} {t('silent')} {_fmt_secs(idle)}" + return icon + + def run_progress(run: dict) -> dict: """Avancement d'un run : combien de VM tournent encore, et depuis quand plus rien n'a été écrit. `idle` sert à distinguer une install vivante d'un @@ -1682,7 +1717,12 @@ def run_monitor(manifest_path: str, run_app: bool = True): table, name, "state", f"⏸ {t('paused')}" ) else: - self._set_cell(table, name, "state", ICON[state]) + self._set_cell( + table, + name, + "state", + state_mark(ICON[state], log_idle(vm["log"])), + ) ref = eta_reference(self._stats, vm.get("arch")) if ref is not None: remaining.append(max(0, ref - (now - started))) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 7efb66e..06fde70 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2259,6 +2259,7 @@ TRANSLATIONS = { "Grouped by shape": {"fr": "Regroupé par forme", "en": "Grouped by shape"}, "line": {"fr": "ligne", "en": "line"}, "free space": {"fr": "libre", "en": "free"}, + "silent": {"fr": "silence", "en": "silent"}, "inotify watches raised for the IDE": { "fr": "watches inotify relevées pour l'IDE", "en": "inotify watches raised for the IDE", diff --git a/test/test_qemu_monitor_telemetry.py b/test/test_qemu_monitor_telemetry.py index 4d140ab..0ec786a 100644 --- a/test/test_qemu_monitor_telemetry.py +++ b/test/test_qemu_monitor_telemetry.py @@ -93,6 +93,61 @@ class TestMemSegment(unittest.TestCase): self.assertIn("0K/12.0G", m._mem_tele(12 * GB, 13 * GB, 0, 0)) +class TestLogSilence(unittest.TestCase): + """Une installation morte et une qui travaille portent le même sablier. + + Vécu : une session ssh emportée, l'installation morte sans marqueur de + sortie, et le tableau de bord a montré « ⏳ » pendant 54 minutes. Le + marqueur manque dans les deux cas — seule la date d'écriture du journal + les sépare. + """ + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.log = Path(self.tmp.name) / "vm.log" + self.log.write_text("== installation ==\n") + + def tearDown(self): + self.tmp.cleanup() + + def test_a_fresh_log_is_not_silent(self): + self.assertLess(m.log_idle(str(self.log)), 5) + + def test_an_old_log_reports_its_age(self): + import os + import time + + old = time.time() - 3000 + os.utime(self.log, (old, old)) + self.assertAlmostEqual(m.log_idle(str(self.log)), 3000, delta=5) + + def test_a_missing_log_is_not_reported_as_silent(self): + """-1 plutôt que 0 : « absent » n'est pas « à l'instant », et surtout + pas « silencieux depuis toujours ».""" + self.assertEqual(m.log_idle("/nonexistent/erplibre.log"), -1.0) + self.assertEqual(m.state_mark("⏳", -1.0), "⏳") + + def test_below_the_threshold_the_cell_stays_bare(self): + """Plusieurs étapes sont muettes quelques minutes — un téléchargement + d'Android Studio, une compilation dont la sortie va ailleurs.""" + self.assertEqual(m.state_mark("⏳", 0), "⏳") + self.assertEqual(m.state_mark("⏳", m.IDLE_HINT_SECS), "⏳") + + def test_above_the_threshold_the_cell_says_how_long(self): + mark = m.state_mark("⏳", 2900) + self.assertNotEqual(mark, "⏳") + self.assertIn("48", mark) + + def test_the_threshold_clears_the_longest_measured_silence(self): + """Mesuré : le téléchargement d'Android Studio reste ~5 min sans une + ligne. Un seuil en dessous transformerait chaque installation en alerte, + et l'alerte cesserait d'être lue.""" + studio_download = 5 * 60 + self.assertGreater(m.IDLE_HINT_SECS, studio_download) + # Et pas si haut qu'une installation morte passe la demi-heure. + self.assertLessEqual(m.IDLE_HINT_SECS, 1200) + + class TestTheRealBar(unittest.TestCase): """La barre telle que le suivi la construit, sans lancer la TUI.""" From 72b19568af6e5c320143b96956d3b1daba4f240a Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 07:35:26 -0400 Subject: [PATCH 39/55] =?UTF-8?q?[IMP]=20script=20todo:=20ne=20pas=20ret?= =?UTF-8?q?=C3=A9l=C3=A9charger=20un=20IDE=20d=C3=A9j=C3=A0=20install?= =?UTF-8?q?=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rejouer une installation est le cas normal — une qui est morte, un outil ajouté après coup — et le téléchargement en est la partie longue : environ cinq minutes pour PyCharm, autant pour Android Studio, à chaque fois. Les deux étapes vérifient donc /opt avant de sortir curl. Mesuré sur erplibre-ubuntu-2604-gnome, IDE déjà posés : les deux étapes passent de dix minutes à 0,094 s au total. Le reste rejoue quand même — lanceur, alias, raccourci de bureau — il est idempotent et bon marché. Un test vérifie que la garde n'avale pas l'échec du cas où il faut bel et bien télécharger. --- EN --- Replaying an install is the normal case — one that died, a tool added later — and the download is the long part: about five minutes for PyCharm, as much for Android Studio, every time. Both steps now check /opt before reaching for curl. Measured on erplibre-ubuntu-2604-gnome with both IDEs already in place: the two steps drop from ten minutes to 0.094 s total. The rest still replays — launcher, alias, desktop entry — it is idempotent and cheap. A test checks the guard does not swallow the failure of the case where downloading is actually needed. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 22 +++++++++- script/todo/todo_i18n.py | 4 ++ test/test_qemu_desktop_tools.py | 74 +++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index 2e9e709..907f39f 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5606,6 +5606,15 @@ class TODO: 'if [ -z "$jb" ]; then ' f'echo " {t("no JetBrains build for")} $(uname -m)"; false; ' "else " + # Déjà posé ? On ne retélécharge pas. Rejouer une + # installation est le cas NORMAL — une qui est morte, un outil + # ajouté après coup — et le téléchargement en est la partie + # longue : mesuré, ~5 min pour Android Studio, autant pour + # PyCharm. Le reste de l'étape (lanceur, alias, raccourci) + # rejoue de toute façon, lui est idempotent et bon marché. + "if [ -x /opt/pycharm/bin/pycharm.sh ]; then " + f'echo " {t("already there, download skipped")}"; ' + "else " # /var/tmp et non /tmp : sur Fedora et dérivés /tmp est un tmpfs, en # RAM — 1,2 Go d'archive y tueraient une VM de 3 Go. # Le flux dit quelle archive Community prendre pour cette @@ -5635,7 +5644,7 @@ class TODO: 'curl -fsSL "$url" -o "$tmp" && ' "sudo mkdir -p /opt/pycharm && " 'sudo tar -xzf "$tmp" -C /opt/pycharm --strip-components=1; ' - 'rc=$?; rm -f "$tmp"; [ $rc -eq 0 ]; fi; } && { ' + 'rc=$?; rm -f "$tmp"; [ $rc -eq 0 ]; fi; fi; } && { ' + self._qemu_jetbrains_launcher_cmd("/opt/pycharm", "pycharm") + self._qemu_desktop_entry_cmd( "pycharm", @@ -5825,6 +5834,15 @@ class TODO: f'echo " {t("Android Studio: Google publishes x86_64 only")}"; ' "false; " "else " + # Déjà posé ? On ne retélécharge pas. Rejouer une + # installation est le cas NORMAL — une qui est morte, un outil + # ajouté après coup — et le téléchargement en est la partie + # longue : mesuré, ~5 min pour Android Studio, autant pour + # PyCharm. Le reste de l'étape (lanceur, alias, raccourci) + # rejoue de toute façon, lui est idempotent et bon marché. + "if [ -x /opt/android-studio/bin/studio ]; then " + f'echo " {t("already there, download skipped")}"; ' + "else " # La page officielle porte l'URL en clair ; le repli garde une # version connue qui répond, pour le jour où sa forme change. f"url=$(curl -fsSL --max-time 30 {self._QEMU_ANDROID_PAGE} " @@ -5836,7 +5854,7 @@ class TODO: "sudo mkdir -p /opt/android-studio && " 'sudo tar -xzf "$tmp" -C /opt/android-studio ' "--strip-components=1; " - 'rc=$?; rm -f "$tmp"; [ $rc -eq 0 ]; fi; } && { ' + 'rc=$?; rm -f "$tmp"; [ $rc -eq 0 ]; fi; fi; } && { ' + self._qemu_jetbrains_launcher_cmd( "/opt/android-studio", "studio", alias="android-studio" ) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 06fde70..6db8b81 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2260,6 +2260,10 @@ TRANSLATIONS = { "line": {"fr": "ligne", "en": "line"}, "free space": {"fr": "libre", "en": "free"}, "silent": {"fr": "silence", "en": "silent"}, + "already there, download skipped": { + "fr": "déjà présent, téléchargement évité", + "en": "already there, download skipped", + }, "inotify watches raised for the IDE": { "fr": "watches inotify relevées pour l'IDE", "en": "inotify watches raised for the IDE", diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index 35974ee..2686849 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -773,6 +773,80 @@ class TestPycharmFirstOpen(unittest.TestCase): ) +class TestIdeInstallIsReplayable(unittest.TestCase): + """Rejouer une installation ne doit pas retélécharger 2 Go. + + C'est le cas NORMAL : une installation morte qu'on relance, un outil ajouté + après coup. Mesuré sur la VM, les deux étapes passent de ~5 min chacune à + 0,094 s au total quand /opt porte déjà l'IDE — le reste (lanceur, alias, + raccourci) rejoue quand même, il est idempotent et bon marché. + """ + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.py = self.todo._qemu_pycharm_remote_cmd() + self.st = self.todo._qemu_android_studio_remote_cmd() + + def test_pycharm_checks_before_downloading(self): + self.assertIn("[ -x /opt/pycharm/bin/pycharm.sh ]", self.py) + self.assertLess( + self.py.index("/opt/pycharm/bin/pycharm.sh"), + self.py.index("curl"), + ) + + def test_android_studio_checks_before_downloading(self): + self.assertIn("[ -x /opt/android-studio/bin/studio ]", self.st) + self.assertLess( + self.st.index("/opt/android-studio/bin/studio"), + self.st.index("curl"), + ) + + def test_the_launcher_still_runs_when_the_download_is_skipped(self): + """Sauter le téléchargement ne doit pas sauter l'alias : c'est lui qui + rend « pycharm » et « android-studio » appelables.""" + # rindex : le chemin du lanceur apparaît aussi dans la garde, tout au + # début. C'est la DERNIÈRE occurrence — l'installation du lanceur — qui + # doit suivre le bloc de téléchargement. + for cmd, marker in ( + (self.py, "/usr/local/bin"), + (self.st, "/usr/local/bin"), + ): + self.assertGreater(cmd.rindex(marker), cmd.index("curl"), marker) + + def test_a_real_download_failure_still_fails(self): + """La garde ne doit pas avaler l'échec du cas où il faut télécharger. + On force l'absence d'IDE et un curl qui échoue.""" + import os + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + bin_dir = pathlib.Path(tmp) / "bin" + bin_dir.mkdir() + (bin_dir / "curl").write_text("#!/bin/bash\nexit 22\n") + (bin_dir / "sudo").write_text("#!/bin/bash\nexit 0\n") + (bin_dir / "python3").write_text("#!/bin/bash\ncat >/dev/null\n") + for n in ("curl", "sudo", "python3"): + (bin_dir / n).chmod(0o755) + res = subprocess.run( + ["bash", "-c", self.st], + capture_output=True, + text=True, + env=dict(os.environ, PATH=f"{bin_dir}:/usr/bin:/bin"), + timeout=60, + ) + out = res.stdout + res.stderr + # Sur cette machine /opt/android-studio n'existe pas : la garde + # laisse donc passer, et l'échec du curl doit se voir. + self.assertIn("⚠", out, out[-300:]) + + def test_both_steps_are_valid_shell(self): + for cmd in (self.py, self.st): + res = subprocess.run( + ["bash", "-n"], input=cmd, capture_output=True, text=True + ) + self.assertEqual(0, res.returncode, res.stderr) + + class TestPycharmNetIsNarrow(unittest.TestCase): """Le filet de fermeture ne doit JAMAIS viser le ssh qui porte l'install. From c4d85365091d2e8c93384f238d78b7cee2462a8b Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 07:50:19 -0400 Subject: [PATCH 40/55] =?UTF-8?q?[FIX]=20script=20todo:=20ouvrir=20PyCharm?= =?UTF-8?q?=20apr=C3=A8s=20l'installation,=20pas=20avant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « ⚠ pas de .idea » à chaque installation : PyCharm ouvrait un dépôt cloné mais pas installé, son configurateur d'interpréteur Python échouait faute de venv, et il renonçait avant d'écrire quoi que ce soit. Le même appel sur un dépôt installé écrit erplibre.iml, misc.xml, modules.xml et vcs.xml en cinq minutes — mesuré sur la VM. L'ouverture passe donc après le make, et « make pycharm_configure » la suit : pycharm_update() s'était déjà exécuté pendant l'installation, quand il n'y avait rien à configurer. Le groupe rend toujours 0 — un bonus ne rougit pas une VM — et la phase mobile, qui porte le verdict, reste après lui. --- EN --- "⚠ no .idea" on every install: PyCharm was opening a repo that was cloned but not installed, its Python interpreter configurator failed for lack of a venv, and it gave up before writing anything. The same call on an installed repo writes erplibre.iml, misc.xml, modules.xml and vcs.xml in five minutes — measured on the VM. The open therefore moves after the make, with "make pycharm_configure" behind it: pycharm_update() had already run during the install, when there was nothing to configure. The group always returns 0 — a bonus does not redden a VM — and the mobile phase, which carries the verdict, still comes after it. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 30 ++++++++++++++++++++++-------- test/test_qemu_desktop_tools.py | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index 907f39f..33cab8c 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -6657,14 +6657,30 @@ class TODO: # « make install_os » installe. Liée par « && » et NON gardée, pour que # son échec soit celui de la VM. after_cmd = self._qemu_tools_remote_cmd(tools, prod, "after") - # Entre le clone et le make : PyCharm ouvre le dépôt une fois pour en - # écrire le .idea, que l'installation configurera juste après. Le - # groupe rend toujours 0 — l'étape est un bonus, pas une condition. + # APRÈS le make, et c'est mesuré : sur un dépôt cloné mais pas installé, + # PyCharm n'écrit AUCUN .idea — son configurateur d'interpréteur Python + # échoue faute de venv, et il renonce. « ⚠ pas de .idea », deux fois de + # suite sur erplibre-ubuntu-2604-gnome. Le même appel sur un dépôt + # installé l'écrit en cinq minutes : erplibre.iml, misc.xml, + # modules.xml, vcs.xml. + # + # On ouvre donc quand l'interpréteur existe, puis on demande la + # configuration explicitement : l'installation est déjà passée, et + # pycharm_update() n'avait alors rien à configurer. open_step = ( - f"{{ {self._qemu_pycharm_project_cmd(prod)} }} && " + self._qemu_pycharm_project_cmd(prod) + + "make pycharm_configure || true; " if "pycharm" in (tools or ()) else "" ) + # Le groupe de PyCharm rend toujours 0 — un bonus, pas une condition — + # là où la phase mobile porte le verdict de la VM. + chain = [final_cmd] + if open_step: + chain.append(f"{{ {open_step} }}") + if after_cmd: + chain.append(f"{{ {after_cmd} }}") + install_chain = " && ".join(chain) return ( "set -e; " + self._qemu_cloud_init_wait() # Coupé AVANT les apt-get ci-dessous : sinon apt-daily peut reprendre @@ -6774,8 +6790,7 @@ class TODO: f"sudo git clone --branch {shlex.quote(branch)} " f"{self.ERPLIBRE_GIT_URL} /opt/erplibre; " "sudo chown -R $(id -un):$(id -gn) /opt/erplibre; fi; " - f"cd /opt/erplibre && {open_step}{final_cmd}" - + (f" && {{ {after_cmd} }}" if after_cmd else "") + f"cd /opt/erplibre && {install_chain}" ) if prod else ( @@ -6783,8 +6798,7 @@ class TODO: "if [ ! -d ~/git/erplibre/.git ]; then " f"git clone --branch {shlex.quote(branch)} " f"{self.ERPLIBRE_GIT_URL} ~/git/erplibre; fi; " - f"cd ~/git/erplibre && {open_step}{final_cmd}" - + (f" && {{ {after_cmd} }}" if after_cmd else "") + f"cd ~/git/erplibre && {install_chain}" ) ) ) diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index 2686849..230b0ec 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -627,13 +627,41 @@ class TestPycharmFirstOpen(unittest.TestCase): self.todo = TODO.__new__(TODO) self.cmd = self.todo._qemu_pycharm_project_cmd() - def test_it_runs_between_the_clone_and_the_make(self): + def test_it_runs_after_the_install_not_before(self): + """Mesuré : sur un dépôt cloné mais pas installé, PyCharm n'écrit AUCUN + .idea — son configurateur d'interpréteur échoue faute de venv, et il + renonce (« ⚠ pas de .idea », deux fois sur une VM réelle). Le même appel + sur un dépôt installé l'écrit en cinq minutes.""" script = self.todo._qemu_erplibre_remote_cmd( "develop", None, False, "gnome", "", "deb", ("pycharm",) ) self.assertLess(script.index("git clone"), script.index("xvfb-run")) self.assertLess( - script.index("xvfb-run"), script.index("make install_os") + script.index("make install_os"), script.index("xvfb-run") + ) + + def test_the_configuration_is_asked_for_after_the_open(self): + """L'installation est déjà passée quand le .idea naît : pycharm_update() + n'avait rien à configurer, donc on le demande explicitement.""" + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "gnome", "", "deb", ("pycharm",) + ) + self.assertIn("make pycharm_configure", script) + self.assertLess( + script.index("xvfb-run"), script.index("make pycharm_configure") + ) + + def test_it_never_decides_the_verdict_of_the_vm(self): + """Un bonus : ni son échec ni celui de sa configuration ne doivent + rougir une VM dont tout le reste a réussi. La phase mobile, elle, porte + bien le verdict — et elle vient après.""" + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "gnome", "", "deb", ("pycharm", "mobile") + ) + self.assertIn("make pycharm_configure || true", script) + self.assertLess( + script.index("make pycharm_configure"), + script.index("ERPLibre mobile"), ) def test_only_when_pycharm_was_asked_for(self): From 69431e17c935ec2a12de814786949d47f0e3eb12 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 08:11:12 -0400 Subject: [PATCH 41/55] =?UTF-8?q?[FIX]=20script=20todo:=20configurer=20le?= =?UTF-8?q?=20projet=20PyCharm=20avec=20le=20venv,=20et=20r=C3=A9essayer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux défauts empêchaient le .idea d'exister. La configuration d'abord : « make pycharm_configure » lance le script avec le python SYSTÈME, qui n'a pas xmltodict — « ModuleNotFoundError », mesuré. update_env_version.pycharm_update() l'appelle depuis .venv.erplibre ; la cible make et l'étape font désormais pareil. L'ouverture ensuite : la première tentative sur un dépôt neuf peut n'écrire aucun .idea, son configurateur d'interpréteur plantant sur « homeDir is null », là où la suivante l'écrit en 25 s — constaté sur deux VM. L'étape retente donc une fois, en gardant les deux journaux. Vérifié sur erplibre-ubuntu-2604-gnome, caches effacés : erplibre.iml, misc.xml, modules.xml, vcs.xml, et 0 processus survivant. --- EN --- Two defects kept .idea from existing. The configuration first: "make pycharm_configure" runs the script with the SYSTEM python, which lacks xmltodict — "ModuleNotFoundError", measured. update_env_version.pycharm_update() calls it from .venv.erplibre; the make target and the step now do the same. The open next: the first attempt on a fresh repo can write no .idea at all, its interpreter configurator dying on "homeDir is null", where the next one writes it in 25 s — seen on two VMs. The step therefore retries once, keeping both logs. Verified on erplibre-ubuntu-2604-gnome with caches wiped: erplibre.iml, misc.xml, modules.xml, vcs.xml, and 0 surviving processes. Assisted-by: Claude Opus 5 --- Makefile | 7 ++++- script/todo/todo.py | 20 ++++++++++-- script/todo/todo_i18n.py | 4 +++ test/test_qemu_desktop_tools.py | 55 ++++++++++++++++++++++++++++----- 4 files changed, 76 insertions(+), 10 deletions(-) diff --git a/Makefile b/Makefile index 6b7e893..6ac6eff 100644 --- a/Makefile +++ b/Makefile @@ -384,4 +384,9 @@ pycharm_open: .PHONY: pycharm_configure pycharm_configure: - ./script/ide/pycharm_configuration.py --init +# Le venv du dépôt, et non le python système : le script importe xmltodict, qui +# vit dans .venv.erplibre. Appelé directement, il s'arrête sur +# « ModuleNotFoundError: No module named 'xmltodict' » — et c'est cette cible +# que le message d'échec de l'installation recommande. update_env_version. +# pycharm_update() l'appelle déjà ainsi : une seule autorité. + ./.venv.erplibre/bin/python ./script/ide/pycharm_configuration.py --init diff --git a/script/todo/todo.py b/script/todo/todo.py index 33cab8c..b302720 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5779,9 +5779,17 @@ class TODO: "sudo sysctl -q -p /etc/sysctl.d/60-erplibre-inotify.conf " f'2>/dev/null; echo " {t("inotify watches raised for the IDE")}"; ' "fi; " + # DEUX tentatives, et c'est mesuré : la première ouverture d'un + # dépôt neuf indexe 212 000 fichiers, plante son configurateur + # d'interpréteur (« PythonSdkConfigurator - homeDir is null ») et + # n'écrit AUCUN .idea, même au bout de cinq minutes. La seconde, sur + # les caches que la première a laissés, l'écrit en 25 secondes — + # constaté sur deux VM différentes. + ": > /tmp/pycharm-first-run.log; " + "for attempt in 1 2; do " 'PYCHARM_VM_OPTIONS="$HOME/.pycharm-headless.vmoptions" ' f"setsid xvfb-run -a pycharm {el_dir} " - "> /tmp/pycharm-first-run.log 2>&1 & " + ">> /tmp/pycharm-first-run.log 2>&1 & " "pid=$!; ok=0; " f"for i in $(seq 1 {self._QEMU_PYCHARM_OPEN_TRIES}); do " f"if ls {el_dir}/.idea/*.iml >/dev/null 2>&1 && " @@ -5814,6 +5822,9 @@ class TODO: f'echo " {t("closing what survived the first open:")} $left"; ' 'pkill -u "$(id -u)" -x ' '"pycharm|cef_server|fsnotifier|Xvfb" 2>/dev/null; sleep 2; }; ' + '[ "$ok" = 1 ] && break; ' + f'echo " {t("no project yet, second try on the warm caches")}"; ' + "done; " '[ "$ok" = 1 ]; fi; fi; } && ' f'echo " {t("project created, the install will configure it")}" ' f'|| echo " ⚠ {t("no .idea: open PyCharm once, then")} ' @@ -6669,7 +6680,12 @@ class TODO: # pycharm_update() n'avait alors rien à configurer. open_step = ( self._qemu_pycharm_project_cmd(prod) - + "make pycharm_configure || true; " + # Le venv du dépôt, comme le fait update_env_version. + # pycharm_update() : le script importe xmltodict, absent du python + # système. Mesuré : « make pycharm_configure » s'arrêtait sur + # « No module named 'xmltodict' ». + + "./.venv.erplibre/bin/python " + "./script/ide/pycharm_configuration.py --init || true; " if "pycharm" in (tools or ()) else "" ) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 6db8b81..cdb7ad2 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2260,6 +2260,10 @@ TRANSLATIONS = { "line": {"fr": "ligne", "en": "line"}, "free space": {"fr": "libre", "en": "free"}, "silent": {"fr": "silence", "en": "silent"}, + "no project yet, second try on the warm caches": { + "fr": "pas de projet encore ; seconde tentative sur les caches chauds", + "en": "no project yet, second try on the warm caches", + }, "already there, download skipped": { "fr": "déjà présent, téléchargement évité", "en": "already there, download skipped", diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index 230b0ec..889c0f4 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -180,14 +180,24 @@ class TestToolRemoteCommand(unittest.TestCase): script.index("PyCharm"), script.index("make install_os") ) - def test_the_install_owns_the_project_configuration(self): - """update_env_version.pycharm_update() lance déjà le script, et sait se - taire sans .idea. Doubler l'appel n'écrivait qu'une erreur dans le - journal d'une VM neuve.""" + def test_the_configuration_is_asked_for_because_the_install_was_too_early( + self, + ): + """Le contraire de ce que ce test exigeait avant, et pour une raison + mesurée : pycharm_update() teste « os.path.exists('.idea') » et se tait + quand le projet n'existe pas encore. Or il s'exécute PENDANT + l'installation, alors que PyCharm ne s'ouvrira qu'après. Personne ne + configurait donc le projet — « Missing ./.idea path » dans le journal + d'une VM neuve. On le demande maintenant explicitement, après + l'ouverture.""" script = self.todo._qemu_erplibre_remote_cmd( "develop", None, False, "gnome", "", "deb", ("pycharm",) ) - self.assertNotIn("pycharm_configuration", script) + self.assertIn("pycharm_configuration.py --init", script) + self.assertLess( + script.index("xvfb-run"), + script.index("pycharm_configuration.py"), + ) def test_a_failing_tool_never_masks_a_failing_install(self): """Le code de sortie doit rester celui de l'installation : c'est lui que @@ -640,6 +650,37 @@ class TestPycharmFirstOpen(unittest.TestCase): script.index("make install_os"), script.index("xvfb-run") ) + def test_the_configuration_uses_the_repo_venv(self): + """Le script importe xmltodict, qui vit dans .venv.erplibre. Appelé par + le python système — ce que faisait « make pycharm_configure » — il + s'arrête sur « No module named 'xmltodict' », mesuré sur la VM. + update_env_version.pycharm_update() l'appelle déjà avec le venv.""" + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "gnome", "", "deb", ("pycharm",) + ) + self.assertIn( + "./.venv.erplibre/bin/python" + " ./script/ide/pycharm_configuration.py --init", + script, + ) + # « make pycharm_configure » reste cité dans le message d'aide — la + # cible est réparée, elle aussi — mais n'est plus ce qu'on EXÉCUTE. + self.assertNotIn("&& make pycharm_configure", script) + self.assertNotIn("; make pycharm_configure", script) + + def test_the_open_gets_a_second_chance(self): + """Mesuré sur deux VM : la première ouverture d'un dépôt neuf peut + n'écrire AUCUN .idea — son configurateur d'interpréteur plante + (« homeDir is null ») — là où la suivante l'écrit en 25 s.""" + self.assertIn("for attempt in 1 2", self.cmd) + self.assertIn('[ "$ok" = 1 ] && break', self.cmd) + + def test_both_attempts_keep_their_log(self): + """Tronquer à chaque tentative effacerait la trace de la première, la + seule qui porte la cause.""" + self.assertIn(": > /tmp/pycharm-first-run.log", self.cmd) + self.assertIn(">> /tmp/pycharm-first-run.log", self.cmd) + def test_the_configuration_is_asked_for_after_the_open(self): """L'installation est déjà passée quand le .idea naît : pycharm_update() n'avait rien à configurer, donc on le demande explicitement.""" @@ -658,9 +699,9 @@ class TestPycharmFirstOpen(unittest.TestCase): script = self.todo._qemu_erplibre_remote_cmd( "develop", None, False, "gnome", "", "deb", ("pycharm", "mobile") ) - self.assertIn("make pycharm_configure || true", script) + self.assertIn("pycharm_configuration.py --init || true", script) self.assertLess( - script.index("make pycharm_configure"), + script.index("pycharm_configuration.py"), script.index("ERPLibre mobile"), ) From 952c92b52a03d95ac4be3aae138d99b0d9c98488 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 19:41:39 -0400 Subject: [PATCH 42/55] [ADD] script forgejo: installer une forge git en option cochable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une case au déploiement, et une forge git auto-hébergée répond sur le port 3000, git par SSH sur 2222. Le travail vit dans un script dédié, appelable seul sur une machine existante : une seule autorité pour les deux usages. Le binaire officiel est statique, donc le même fichier sert apt, dnf, pacman et zypper — c'est ce qui rend l'option portable sans une branche par distribution. Les architectures suivent l'amont (amd64, arm64, arm-6) ; la case se grise sur s390x plutôt que de poser un binaire inexécutable. Les quatre secrets sont écrits par le script : sans oauth2.JWT_SECRET, Forgejo tente de les persister lui-même et boucle sur un app.ini qu'il n'a pas le droit d'écrire. Vérifié sur une VM : somme de contrôle validée, service actif, API qui répond, dépôt créé puis cloné par git, et relance en 1,5 s sans rien réécrire. --- EN --- One checkbox at deploy time, and a self-hosted git forge answers on port 3000, git over SSH on 2222. The work lives in a dedicated script, callable on its own for an existing machine: one authority for both uses. The official binary is static, so the same file serves apt, dnf, pacman and zypper — that is what makes the option portable without a branch per distribution. Architectures follow upstream (amd64, arm64, arm-6); the checkbox greys out on s390x rather than dropping a binary that cannot run. The script writes all four secrets itself: without oauth2.JWT_SECRET, Forgejo tries to persist them and loops on an app.ini it is not allowed to write. Verified on a VM: checksum validated, service active, API answering, a repo created then cloned over git, and a replay in 1.5 s rewriting nothing. Assisted-by: Claude Opus 5 --- script/forgejo/install_forgejo.sh | 301 ++++++++++++++++++++++++++++++ script/qemu/README.base.md | 39 ++++ script/qemu/README.fr.md | 20 ++ script/qemu/README.md | 19 ++ script/todo/todo.py | 81 ++++++-- script/todo/todo_i18n.py | 8 + test/test_qemu_desktop_tools.py | 17 +- test/test_qemu_forgejo.py | 260 ++++++++++++++++++++++++++ 8 files changed, 728 insertions(+), 17 deletions(-) create mode 100755 script/forgejo/install_forgejo.sh create mode 100644 test/test_qemu_forgejo.py diff --git a/script/forgejo/install_forgejo.sh b/script/forgejo/install_forgejo.sh new file mode 100755 index 0000000..a86b9ef --- /dev/null +++ b/script/forgejo/install_forgejo.sh @@ -0,0 +1,301 @@ +#!/usr/bin/env bash +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# +# Installe Forgejo — https://forgejo.org — depuis le binaire statique officiel +# publié sur https://codeberg.org/forgejo/forgejo. +# +# Le binaire est statique et sans dépendance : le même fichier sert Debian, +# Ubuntu, AlmaLinux, Rocky, openSUSE et Arch. Ce script ne touche donc à AUCUN +# gestionnaire de paquets — c'est ce qui le rend portable sur les plateformes +# ERPLibre sans une branche par distribution. +# +# Il n'appelle PAS env_var.sh, à la différence des scripts d'installation +# ERPLibre : Forgejo ne dépend ni du dépôt ni de son venv, et le script doit +# rester utilisable hors d'un checkout. +# +# Réglages, tous par variables d'environnement : +# FORGEJO_VERSION version à poser (défaut : la dernière publiée) +# FORGEJO_HTTP_PORT port web (défaut : 3000) +# FORGEJO_SSH_PORT port SSH interne de Forgejo (défaut : 2222) +# FORGEJO_ADMIN_USER compte administrateur créé (défaut : erplibre) +# FORGEJO_ADMIN_PASSWORD son mot de passe (défaut : erplibre) +# FORGEJO_ADMIN_EMAIL son courriel (défaut : admin@erplibre.local) +# FORGEJO_USER compte système propriétaire (défaut : git) +# FORGEJO_SKIP_ADMIN à 1, ne crée aucun compte (installateur web) +set -euo pipefail + +Red='\033[0;31m' +Green='\033[0;32m' +Yellow='\033[0;33m' +Color_Off='\033[0m' + +VERSION="${FORGEJO_VERSION:-}" +HTTP_PORT="${FORGEJO_HTTP_PORT:-3000}" +SSH_PORT="${FORGEJO_SSH_PORT:-2222}" +# « admin » est REFUSÉ par Forgejo — « CreateUser: name is reserved », mesuré. +# La liste des noms réservés couvre aussi api, assets, avatars, explore, user… +ADMIN_USER="${FORGEJO_ADMIN_USER:-erplibre}" +ADMIN_PASSWORD="${FORGEJO_ADMIN_PASSWORD:-erplibre}" +ADMIN_EMAIL="${FORGEJO_ADMIN_EMAIL:-admin@erplibre.local}" +RUN_USER="${FORGEJO_USER:-git}" +SKIP_ADMIN="${FORGEJO_SKIP_ADMIN:-0}" + +BIN=/usr/local/bin/forgejo +CONF_DIR=/etc/forgejo +CONF="$CONF_DIR/app.ini" +DATA=/var/lib/forgejo +UNIT=/etc/systemd/system/forgejo.service +API=https://codeberg.org/api/v1/repos/forgejo/forgejo/releases +DL=https://codeberg.org/forgejo/forgejo/releases/download + +usage() { + sed -n '5,26p' "$0" | sed 's/^# \?//' + exit 0 +} +case "${1:-}" in + -h|--help) usage ;; +esac + +say() { echo -e " $*"; } +die() { echo -e " ${Red}✗ $*${Color_Off}" >&2; exit 1; } + +# --- 1. Architecture ------------------------------------------------------- +# Forgejo publie amd64, arm64 et arm-6. PAS de s390x : sur cette architecture +# il faudrait le bâtir depuis les sources en Go, ce que ce script ne fait pas — +# il le dit plutôt que de télécharger un binaire qui ne s'exécutera pas. +case "$(uname -m)" in + x86_64) ARCH=amd64 ;; + aarch64|arm64) ARCH=arm64 ;; + armv6l|armv7l) ARCH=arm-6 ;; + *) die "Forgejo ne publie pas de binaire pour $(uname -m)" \ + "(amd64, arm64 et arm-6 seulement)." ;; +esac + +# --- 2. Version ------------------------------------------------------------ +if [ -z "$VERSION" ]; then + # La liste des versions est en JSON : on la lit avec python3, présent dans + # toutes les images cloud visées. Sans lui, on retombe sur grep — mieux + # qu'un abandon, et le motif est celui d'un champ JSON, pas d'une page web. + if command -v python3 >/dev/null 2>&1; then + VERSION=$(curl -fsSL --max-time 30 "$API?limit=1" 2>/dev/null \ + | python3 -c 'import json,sys +try: + print(json.load(sys.stdin)[0]["tag_name"].lstrip("v")) +except Exception: + pass' || true) + else + VERSION=$(curl -fsSL --max-time 30 "$API?limit=1" 2>/dev/null \ + | grep -o '"tag_name":"v[^"]*"' | head -1 \ + | sed 's/.*"v//;s/"//' || true) + fi +fi +[ -n "$VERSION" ] || die "Version de Forgejo introuvable (réseau ? $API)" +say "Forgejo $VERSION pour $ARCH" + +# --- 3. Déjà posé ? -------------------------------------------------------- +# Rejouer une installation est le cas normal. Comparer la version évite de +# retélécharger 34 Mo pour rien, et de redémarrer un service qui va bien. +if [ -x "$BIN" ] && "$BIN" --version 2>/dev/null | grep -q "version $VERSION"; then + say "${Green}binaire déjà en version $VERSION, téléchargement évité${Color_Off}" +else + # L'archive .xz pèse 34 Mo contre 114 Mo pour le binaire nu. On la prend + # quand xz est là, sans jamais l'installer : le binaire nu est le repli. + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + if command -v xz >/dev/null 2>&1; then + asset="forgejo-$VERSION-linux-$ARCH.xz" + else + asset="forgejo-$VERSION-linux-$ARCH" + fi + say "téléchargement de $asset" + curl -fsSL --retry 3 --max-time 900 "$DL/v$VERSION/$asset" \ + -o "$tmp/$asset" || die "téléchargement impossible : $DL/v$VERSION/$asset" + # Somme de contrôle publiée à côté du fichier : une archive tronquée par une + # coupure réseau donne un binaire qui ne s'exécute pas, et l'erreur arrive + # alors dix étapes plus loin. + if curl -fsSL --max-time 60 "$DL/v$VERSION/$asset.sha256" \ + -o "$tmp/$asset.sha256" 2>/dev/null; then + (cd "$tmp" && sha256sum -c "$asset.sha256" >/dev/null) \ + || die "somme de contrôle invalide pour $asset" + say "somme de contrôle vérifiée" + else + say "${Yellow}⚠ somme de contrôle indisponible, non vérifiée${Color_Off}" + fi + case "$asset" in + *.xz) xz -d "$tmp/$asset"; src="$tmp/${asset%.xz}" ;; + *) src="$tmp/$asset" ;; + esac + chmod +x "$src" + sudo install -m 0755 "$src" "$BIN" + say "${Green}binaire posé : $BIN${Color_Off}" + rm -rf "$tmp" + trap - EXIT +fi + +# --- 4. Compte système et répertoires -------------------------------------- +if ! id "$RUN_USER" >/dev/null 2>&1; then + sudo useradd --system --create-home --home-dir "/home/$RUN_USER" \ + --shell /bin/bash --comment "Forgejo" "$RUN_USER" + say "compte système créé : $RUN_USER" +fi +sudo mkdir -p "$DATA"/{custom,data,log} "$CONF_DIR" +sudo chown -R "$RUN_USER:$RUN_USER" "$DATA" +sudo chmod 750 "$DATA" +# Le fichier de configuration appartient à root et se LIT par le groupe : le +# service en a besoin, et Forgejo y écrit ses secrets au premier démarrage si +# on ne les pose pas soi-même — ce que fait l'étape suivante. +sudo chown root:"$RUN_USER" "$CONF_DIR" +sudo chmod 770 "$CONF_DIR" + +# --- 5. Configuration ------------------------------------------------------ +# JAMAIS réécrite si elle existe : elle porte les secrets, et un utilisateur a +# pu l'ajuster. C'est aussi ce qui rend ce script rejouable. +# « sudo test », et non « [ -f ] » : /etc/forgejo appartient à root:git en 770, +# donc l'utilisateur qui lance le script ne peut même pas y statuer un fichier. +# Le test échouait toujours, et CHAQUE passage réécrivait la configuration avec +# des secrets neufs — ce qui invalide les sessions et les jetons existants. +if sudo test -f "$CONF"; then + say "configuration conservée : $CONF" +else + host=$(hostname -I 2>/dev/null | awk '{print $1}') + [ -n "$host" ] || host=localhost + # Les QUATRE secrets, et pas seulement les deux évidents. Vécu : sans + # « oauth2.JWT_SECRET », Forgejo tente de l'écrire dans app.ini au + # démarrage, n'y arrive pas — le fichier appartient à root — et s'arrête + # sur « [F] save oauth2.JWT_SECRET failed ». Le service redémarrait en + # boucle, 25 fois, sans jamais écouter le port. + # + # Les poser ici garde app.ini NON inscriptible par le service : c'est la + # bonne posture, et ça évite un fichier de configuration qui se réécrit + # tout seul. + secret=$("$BIN" generate secret SECRET_KEY) + token=$("$BIN" generate secret INTERNAL_TOKEN) + jwt=$("$BIN" generate secret JWT_SECRET) + lfs_jwt=$("$BIN" generate secret JWT_SECRET) + # « INSTALL_LOCK = true » verrouille l'installateur web : la machine est + # utilisable sans passer par un formulaire, ce qui est tout l'intérêt d'une + # option cochée au déploiement. SQLite, pour ne pas disputer PostgreSQL à + # Odoo, qui vit sur la même VM. + sudo tee "$CONF" >/dev/null </dev/null </dev/null 2>&1 \ + || die "le service refuse de démarrer : sudo journalctl -u forgejo -n 40" + +# --- 7. Attendre qu'il RÉPONDE -------------------------------------------- +# Une requête HTTP, pas un « systemctl is-active » : le service est « active » +# bien avant d'écouter, et « activating » en boucle de redémarrage ressemble à +# un démarrage en cours. /api/v1/version prouve que l'application SERT — la +# création du compte administrateur qui suit a besoin de la base migrée. +# +# Et surtout pas « exec 3<>/dev/tcp/... » : « exec » est un builtin spécial, et +# une redirection qui échoue termine le shell. Le script mourait donc en +# silence, au premier tour de la boucle, code 1 sans un mot — vécu. +ready=0 +for i in $(seq 1 60); do + if curl -fsS -o /dev/null --max-time 3 \ + "http://127.0.0.1:$HTTP_PORT/api/v1/version"; then + ready=1 + break + fi + sleep 2 +done +[ "$ready" = 1 ] || die "aucune réponse sur le port $HTTP_PORT après 120 s" \ + "(sudo journalctl -u forgejo -n 40)" + +# --- 8. Compte administrateur --------------------------------------------- +# Créé seulement s'il n'y a AUCUN compte : rejouer le script ne doit pas +# échouer sur « user already exists », ni écraser un mot de passe choisi. +if [ "$SKIP_ADMIN" = 1 ]; then + say "aucun compte créé (FORGEJO_SKIP_ADMIN=1)" +elif sudo -u "$RUN_USER" "$BIN" admin user list --config "$CONF" 2>/dev/null \ + | tail -n +2 | grep -q .; then + say "comptes déjà présents, administrateur non recréé" +else + sudo -u "$RUN_USER" "$BIN" admin user create --admin \ + --username "$ADMIN_USER" --password "$ADMIN_PASSWORD" \ + --email "$ADMIN_EMAIL" --must-change-password=false \ + --config "$CONF" >/dev/null \ + || die "création de l'administrateur impossible" + say "${Green}administrateur créé : $ADMIN_USER / $ADMIN_PASSWORD${Color_Off}" +fi + +# --- 9. Résumé ------------------------------------------------------------- +host=$(hostname -I 2>/dev/null | awk '{print $1}') +[ -n "$host" ] || host=localhost +version=$("$BIN" --version 2>/dev/null | head -1) +say "${Green}Forgejo prêt${Color_Off} : http://$host:$HTTP_PORT/" +say " $version" +say " git par SSH : port $SSH_PORT (serveur interne de Forgejo)" +say " service : sudo systemctl status forgejo" +say " journal : sudo journalctl -u forgejo -f" diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md index 14496b3..757ae02 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -337,6 +337,25 @@ Rendering is `swangle` in the AVD's own `config.ini` — `auto` would open a black screen, and `swiftshader_indirect` no longer exists, the emulator answering `Selected GPU option ... is not valid`. +A sixth, **Forgejo**, installs a self-hosted git forge — the software behind +Codeberg — from the project's official static binary, and leaves it serving on +port 3000 with git-over-SSH on 2222. Like the mobile build it needs no desktop, +and unlike it no package family is excluded: the binary is static, so the same +file serves apt, dnf, pacman and zypper. That is what makes it portable across +the ERPLibre platforms without a branch per distribution. Architectures follow +upstream, which publishes amd64, arm64 and arm-6 — the checkbox greys out on +s390x rather than dropping a binary that cannot run. + +The work lives in `script/forgejo/install_forgejo.sh`, callable on its own for +an existing machine: `./script/forgejo/install_forgejo.sh`. It verifies the +published checksum, writes all four secrets itself so the service never needs +to rewrite its own configuration, and stores its data in SQLite so it does not +dispute PostgreSQL with Odoo on the same VM. Replaying it is cheap and safe — +1.5 s measured with everything in place: it skips a binary already at the right +version, never overwrites an existing `app.ini`, and does not recreate the +administrator. `FORGEJO_VERSION`, `FORGEJO_HTTP_PORT`, `FORGEJO_ADMIN_USER` and +a few others tune it; `--help` lists them. + Each tool is filtered per VM — by architecture, desktop flavour and package family — and its disk cost is added to the plan before anything is created. @@ -483,6 +502,26 @@ Le rendu est « swangle » dans le `config.ini` de l'AVD — « auto » ouvrirai écran noir, et « swiftshader_indirect » n'existe plus, l'émulateur répondant `Selected GPU option ... is not valid`. +Un sixième, **Forgejo**, installe une forge git auto-hébergée — le logiciel +derrière Codeberg — depuis le binaire statique officiel du projet, et la laisse +en service sur le port 3000, avec git par SSH sur 2222. Comme la compilation +mobile, elle n'a besoin d'aucun bureau ; contrairement à elle, aucune famille de +paquets n'est exclue : le binaire est statique, donc le même fichier sert apt, +dnf, pacman et zypper. C'est ce qui la rend portable sur les plateformes +ERPLibre sans une branche par distribution. Les architectures suivent l'amont, +qui publie amd64, arm64 et arm-6 — la case se grise sur s390x plutôt que de +poser un binaire qui ne s'exécutera pas. + +Le travail vit dans `script/forgejo/install_forgejo.sh`, appelable seul sur une +machine existante : `./script/forgejo/install_forgejo.sh`. Il vérifie la somme +de contrôle publiée, écrit lui-même les quatre secrets pour que le service n'ait +jamais à réécrire sa propre configuration, et garde ses données en SQLite pour +ne pas disputer PostgreSQL à Odoo sur la même VM. Le rejouer est sans risque et +bon marché — 1,5 s mesuré, tout étant en place : il saute un binaire déjà à la +bonne version, ne réécrit jamais un `app.ini` existant et ne recrée pas +l'administrateur. `FORGEJO_VERSION`, `FORGEJO_HTTP_PORT`, `FORGEJO_ADMIN_USER` +et quelques autres le règlent ; `--help` les énumère. + Chaque outil est filtré VM par VM — architecture, saveur de bureau et famille de paquets — et sa place disque s'ajoute au plan avant que rien ne soit créé. diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index dfe74d7..212dbbd 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -228,6 +228,26 @@ Le rendu est « swangle » dans le `config.ini` de l'AVD — « auto » ouvrirai écran noir, et « swiftshader_indirect » n'existe plus, l'émulateur répondant `Selected GPU option ... is not valid`. +Un sixième, **Forgejo**, installe une forge git auto-hébergée — le logiciel +derrière Codeberg — depuis le binaire statique officiel du projet, et la laisse +en service sur le port 3000, avec git par SSH sur 2222. Comme la compilation +mobile, elle n'a besoin d'aucun bureau ; contrairement à elle, aucune famille de +paquets n'est exclue : le binaire est statique, donc le même fichier sert apt, +dnf, pacman et zypper. C'est ce qui la rend portable sur les plateformes +ERPLibre sans une branche par distribution. Les architectures suivent l'amont, +qui publie amd64, arm64 et arm-6 — la case se grise sur s390x plutôt que de +poser un binaire qui ne s'exécutera pas. + +Le travail vit dans `script/forgejo/install_forgejo.sh`, appelable seul sur une +machine existante : `./script/forgejo/install_forgejo.sh`. Il vérifie la somme +de contrôle publiée, écrit lui-même les quatre secrets pour que le service n'ait +jamais à réécrire sa propre configuration, et garde ses données en SQLite pour +ne pas disputer PostgreSQL à Odoo sur la même VM. Le rejouer est sans risque et +bon marché — 1,5 s mesuré, tout étant en place : il saute un binaire déjà à la +bonne version, ne réécrit jamais un `app.ini` existant et ne recrée pas +l'administrateur. `FORGEJO_VERSION`, `FORGEJO_HTTP_PORT`, `FORGEJO_ADMIN_USER` +et quelques autres le règlent ; `--help` les énumère. + Chaque outil est filtré VM par VM — architecture, saveur de bureau et famille de paquets — et sa place disque s'ajoute au plan avant que rien ne soit créé. diff --git a/script/qemu/README.md b/script/qemu/README.md index 81f673c..b03f336 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -211,6 +211,25 @@ Rendering is `swangle` in the AVD's own `config.ini` — `auto` would open a black screen, and `swiftshader_indirect` no longer exists, the emulator answering `Selected GPU option ... is not valid`. +A sixth, **Forgejo**, installs a self-hosted git forge — the software behind +Codeberg — from the project's official static binary, and leaves it serving on +port 3000 with git-over-SSH on 2222. Like the mobile build it needs no desktop, +and unlike it no package family is excluded: the binary is static, so the same +file serves apt, dnf, pacman and zypper. That is what makes it portable across +the ERPLibre platforms without a branch per distribution. Architectures follow +upstream, which publishes amd64, arm64 and arm-6 — the checkbox greys out on +s390x rather than dropping a binary that cannot run. + +The work lives in `script/forgejo/install_forgejo.sh`, callable on its own for +an existing machine: `./script/forgejo/install_forgejo.sh`. It verifies the +published checksum, writes all four secrets itself so the service never needs +to rewrite its own configuration, and stores its data in SQLite so it does not +dispute PostgreSQL with Odoo on the same VM. Replaying it is cheap and safe — +1.5 s measured with everything in place: it skips a binary already at the right +version, never overwrites an existing `app.ini`, and does not recreate the +administrator. `FORGEJO_VERSION`, `FORGEJO_HTTP_PORT`, `FORGEJO_ADMIN_USER` and +a few others tune it; `--help` lists them. + Each tool is filtered per VM — by architecture, desktop flavour and package family — and its disk cost is added to the plan before anything is created. diff --git a/script/todo/todo.py b/script/todo/todo.py index b302720..e24423b 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5422,6 +5422,32 @@ class TODO: # manifeste ajoute, et du venv d'outils pour le synchroniser. "phase": "after", }, + # Forgejo est un SERVICE, pas un outil de bureau : une VM serveur le + # prend aussi bien qu'une VM graphique. Son binaire est STATIQUE — le + # même fichier sur apt, dnf, pacman et zypper — donc aucune famille de + # paquets n'est exclue, et c'est ce qui le rend portable sur toutes les + # plateformes ERPLibre sans une branche par distribution. + # + # Les architectures, elles, sont bornées par l'amont : Forgejo publie + # amd64, arm64 et arm-6, et RIEN pour s390x. Sur celle-là il faudrait le + # bâtir en Go ; la case se grise plutôt que de poser un binaire qui ne + # s'exécute pas. + # + # Disque : ~115 Mo de binaire (34 Mo téléchargés en .xz), la base SQLite + # et les dépôts que l'utilisateur y poussera. + "forgejo": { + "label": "Forgejo (git forge)", + "hint": "self-hosted git forge on :3000, SQLite", + "disk_gb": 2, + "arches": ("amd64", "arm64"), + "desktops": (), + "needs_desktop": False, + "families": (), + # APRÈS l'installation : le script vit dans le dépôt, donc après le + # clone. Rien d'autre ne l'y oblige — Forgejo ne dépend ni du venv + # ni d'Odoo. + "phase": "after", + }, # L'émulateur n'a pas besoin de bureau DANS la VM : il s'affiche sur # l'écran de qui s'y connecte, par « ssh -X ». Il a besoin, lui, de KVM # dans la VM — donc de virtualisation imbriquée sur l'hôte, ce que le @@ -6474,6 +6500,25 @@ class TODO: + f'echo " {t("smoother, without X11:")} TODO > Execute > Deploy > QEMU/KVM > tunnel > 4"' ) + def _qemu_forgejo_steps(self, el_dir): + """Pose Forgejo dans la VM, par le script dédié du dépôt. + + Tout le travail est DANS le script — architecture, version, somme de + contrôle, compte système, configuration, service, compte + administrateur. Ce bloc ne fait que l'appeler : une seule autorité, et + la même commande sert un déploiement de VM et une installation à la + main sur une machine existante. + + Pas de garde, comme la compilation mobile : une VM dont la forge + demandée n'existe pas n'est pas la VM demandée. Le script, lui, est + rejouable — il ne retélécharge pas un binaire déjà en place et ne + réécrit jamais une configuration existante. + """ + return ( + f'echo "== {t("Forgejo (git forge)")} =="; ' + f"{el_dir}/script/forgejo/install_forgejo.sh" + ) + def _qemu_after_remote_cmd(self, tools, prod=False): """Phase d'APRÈS l'installation : prologue commun, SDK commun, puis ce qui a été coché. @@ -6486,29 +6531,39 @@ class TODO: émulateur créé avec succès effacerait le verdict de la compilation.""" picked = [ k - for k in ("mobile", "avd") + for k in ("forgejo", "mobile", "avd") if k in (tools or ()) and k in self._QEMU_VM_TOOLS ] if not picked: return "" el_dir = self._qemu_install_dir(prod) + parts = [] + # Forgejo d'abord : une minute, contre une heure pour le SDK et l'APK. + # Un échec rapide se voit tôt plutôt qu'après le long. + if "forgejo" in picked: + parts.append(f"{{ {self._qemu_forgejo_steps(el_dir)}; }}") groups = [] if "mobile" in picked: groups.append(self._qemu_mobile_build_steps(el_dir)) if "avd" in picked: groups.append(self._qemu_avd_steps(el_dir)) - return ( - f'echo "== {t("ERPLibre mobile, Android SDK (long)")} =="; ' - + self._qemu_android_prologue_cmd() - + self._qemu_android_sdk_steps(el_dir) - # Chaque groupe entre ACCOLADES. Sans elles, « && » ne lie que la - # première commande du groupe suivant : mesuré, un APK manquant - # laissait tourner l'émulateur puis rendait 0 — la VM repassait au - # vert alors que rien n'avait compilé. C'est le même piège que le - # bloc de service systemd, quelques centaines de lignes plus haut. - + " && ".join(f"{{ {g}; }}" for g in groups) - + "; " - ) + if groups: + # UN seul prologue et un seul SDK même quand les deux options le + # sont : deux prologues, et le second tronquerait le journal + # détaillé du premier. + parts.append( + "{ " + + f'echo "== {t("ERPLibre mobile, Android SDK (long)")} =="; ' + + self._qemu_android_prologue_cmd() + + self._qemu_android_sdk_steps(el_dir) + # Chaque groupe entre ACCOLADES. Sans elles, « && » ne lie que + # la première commande du groupe suivant : mesuré, un APK + # manquant laissait tourner l'émulateur puis rendait 0 — la VM + # repassait au vert alors que rien n'avait compilé. + + " && ".join(f"{{ {g}; }}" for g in groups) + + "; }" + ) + return " && ".join(parts) + "; " def _qemu_mobile_remote_cmd(self, prod=False): """Compilation mobile seule — la forme que testent les tests.""" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index cdb7ad2..699f631 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2260,6 +2260,14 @@ TRANSLATIONS = { "line": {"fr": "ligne", "en": "line"}, "free space": {"fr": "libre", "en": "free"}, "silent": {"fr": "silence", "en": "silent"}, + "Forgejo (git forge)": { + "fr": "Forgejo (forge git)", + "en": "Forgejo (git forge)", + }, + "self-hosted git forge on :3000, SQLite": { + "fr": "forge git auto-hébergée sur :3000, SQLite", + "en": "self-hosted git forge on :3000, SQLite", + }, "no project yet, second try on the warm caches": { "fr": "pas de projet encore ; seconde tentative sur les caches chauds", "en": "no project yet, second try on the warm caches", diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index 889c0f4..293355d 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -99,10 +99,17 @@ class TestToolDisk(unittest.TestCase): def test_a_server_only_pays_for_what_it_gets(self): """Un serveur ne porte aucun IDE, donc il n'en paie pas le disque — - mais il paie bien ce qu'il reçoit : compilation mobile et émulateur.""" + mais il paie bien ce qu'il reçoit : compilation mobile, émulateur et + forge. La somme est calculée depuis la table plutôt qu'écrite en + chiffre : ajouter un outil sans écran ne doit pas casser ce test, il + doit le suivre.""" + expected = sum( + spec["disk_gb"] + for key, spec in TODO._QEMU_VM_TOOLS.items() + if not spec["needs_desktop"] + ) self.assertEqual( - TODO._QEMU_VM_TOOLS["mobile"]["disk_gb"] - + TODO._QEMU_VM_TOOLS["avd"]["disk_gb"], + expected, self.todo._qemu_tools_disk_gb(self.all, "amd64", "", "ubuntu"), ) @@ -278,7 +285,9 @@ class TestMobileBuild(unittest.TestCase): """Elle compile, elle n'affiche rien : un bureau serait du gaspillage. L'émulateur non plus n'en a pas besoin — il s'affiche par ssh -X.""" got = self.todo._qemu_tools_for(self.all, "amd64", "", "ubuntu") - self.assertEqual(["mobile", "avd"], got) + self.assertEqual(["mobile", "forgejo", "avd"], got) + # Forgejo est là pour la même raison que la compilation : un + # service ne demande pas d'écran. def test_it_is_bounded_to_apt(self): """install-android.sh du dépôt mobile commence par « sudo apt install diff --git a/test/test_qemu_forgejo.py b/test/test_qemu_forgejo.py new file mode 100644 index 0000000..d62edc0 --- /dev/null +++ b/test/test_qemu_forgejo.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Option Forgejo : la case, son filtrage, et le script qui fait le travail. + +Forgejo est un service, pas un outil de bureau : une VM serveur le prend comme +une VM graphique. Son binaire est statique, donc le même fichier sert apt, dnf, +pacman et zypper — c'est ce qui le rend portable sans une branche par +distribution. Les architectures, elles, sont bornées par l'amont : Forgejo +publie amd64, arm64 et arm-6, et rien pour s390x. + +Le script est vérifié en l'EXÉCUTANT sur ses chemins de refus — architecture +inconnue, version introuvable — qui précèdent toute élévation de privilège et +ne touchent donc à rien. +""" + +import os +import pathlib +import subprocess +import sys +import tempfile +import unittest + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 + +SCRIPT = ( + pathlib.Path(__file__).resolve().parent.parent + / "script/forgejo/install_forgejo.sh" +) + + +class TestTheCheckbox(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + self.spec = TODO._QEMU_VM_TOOLS["forgejo"] + + def test_it_is_offered_in_the_form(self): + keys = [k for k, _l, _h in TODO._qemu_vm_tool_choices()] + self.assertIn("forgejo", keys) + + def test_a_plain_server_gets_it(self): + """Une forge n'affiche rien : elle n'a pas besoin de bureau.""" + self.assertFalse(self.spec["needs_desktop"]) + got = self.todo._qemu_tools_for(("forgejo",), "amd64", "", "ubuntu") + self.assertIn("forgejo", got) + + def test_every_package_family_gets_it(self): + """Le binaire est statique : aucune famille n'est exclue, à la + différence de la compilation mobile que son installateur borne à apt. + """ + self.assertEqual(self.spec["families"], ()) + for distro in ("ubuntu", "debian", "almalinux", "opensuse", "arch"): + self.assertIn( + "forgejo", + self.todo._qemu_tools_for(("forgejo",), "amd64", "", distro), + distro, + ) + + def test_arm64_yes_s390x_no(self): + """Forgejo publie amd64, arm64 et arm-6. Sur s390x il faudrait le bâtir + en Go : la case se grise plutôt que de poser un binaire inexécutable. + """ + for arch in ("amd64", "arm64"): + self.assertIn( + "forgejo", + self.todo._qemu_tools_for(("forgejo",), arch, "", "ubuntu"), + arch, + ) + self.assertNotIn( + "forgejo", + self.todo._qemu_tools_for(("forgejo",), "s390x", "", "ubuntu"), + ) + + def test_its_disk_cost_is_counted_in_the_plan(self): + self.assertGreater( + self.todo._qemu_tools_disk_gb(("forgejo",), "amd64", "", "ubuntu"), + 0, + ) + + +class TestTheInstallBlock(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + + def test_it_calls_the_dedicated_script(self): + """Une seule autorité : la même commande sert le déploiement d'une VM et + une installation à la main.""" + for prod, root in ( + (False, "$HOME/git/erplibre"), + (True, "/opt/erplibre"), + ): + block = self.todo._qemu_forgejo_steps( + self.todo._qemu_install_dir(prod) + ) + self.assertIn(f"{root}/script/forgejo/install_forgejo.sh", block) + + def test_alone_it_does_not_drag_the_android_prologue(self): + """Cocher Forgejo seul ne doit pas installer un SDK Android.""" + cmd = self.todo._qemu_after_remote_cmd(("forgejo",), False) + self.assertIn("install_forgejo.sh", cmd) + self.assertNotIn("sdkmanager", cmd) + self.assertNotIn("mstep", cmd) + + def test_with_the_mobile_build_forgejo_comes_first(self): + """Une minute contre une heure : un échec rapide se voit tôt.""" + cmd = self.todo._qemu_after_remote_cmd(("forgejo", "mobile"), False) + self.assertLess(cmd.index("install_forgejo.sh"), cmd.index("gradlew")) + + def test_a_forgejo_failure_still_fails_the_vm(self): + """Même contrat que la compilation mobile : une VM dont la forge + demandée n'existe pas n'est pas la VM demandée. Les groupes sont donc + liés par « && », jamais par « ; ».""" + cmd = self.todo._qemu_after_remote_cmd(("forgejo", "mobile"), False) + between = cmd[ + cmd.index("install_forgejo.sh") : cmd.index("ERPLibre mobile") + ] + self.assertIn("&&", between) + self.assertNotIn("|| true", between) + + def test_valid_shell_in_every_combination(self): + for tools in ( + ("forgejo",), + ("forgejo", "mobile"), + ("forgejo", "avd"), + ("forgejo", "mobile", "avd"), + ): + cmd = self.todo._qemu_after_remote_cmd(tools, False) + res = subprocess.run( + ["bash", "-n"], + input="mstep() { :; }; mdiag() { :; };\n" + cmd, + capture_output=True, + text=True, + ) + self.assertEqual(0, res.returncode, f"{tools}: {res.stderr}") + + +class TestTheScript(unittest.TestCase): + """Le script lui-même, exécuté sur ses chemins de refus.""" + + def test_it_is_executable_and_valid_shell(self): + self.assertTrue(os.access(SCRIPT, os.X_OK), "pas exécutable") + res = subprocess.run( + ["bash", "-n", str(SCRIPT)], capture_output=True, text=True + ) + self.assertEqual(0, res.returncode, res.stderr) + + def test_help_explains_the_knobs_and_exits_clean(self): + res = subprocess.run( + ["bash", str(SCRIPT), "--help"], capture_output=True, text=True + ) + self.assertEqual(0, res.returncode, res.stderr) + for knob in ( + "FORGEJO_VERSION", + "FORGEJO_HTTP_PORT", + "FORGEJO_ADMIN_USER", + ): + self.assertIn(knob, res.stdout) + + def _run_with_stubs(self, stubs, env=None): + """Lance le script avec un PATH bouchonné. Les chemins testés ici + s'arrêtent AVANT tout sudo : rien n'est installé nulle part.""" + with tempfile.TemporaryDirectory() as tmp: + bin_dir = pathlib.Path(tmp) / "bin" + bin_dir.mkdir() + for name, body in stubs.items(): + (bin_dir / name).write_text(f"#!/bin/bash\n{body}\n") + (bin_dir / name).chmod(0o755) + return subprocess.run( + ["bash", str(SCRIPT)], + capture_output=True, + text=True, + env=dict( + os.environ, + PATH=f"{bin_dir}:/usr/bin:/bin", + **(env or {}), + ), + timeout=120, + ) + + def test_an_unpublished_architecture_is_refused_by_name(self): + """Sur s390x, Forgejo n'a pas de binaire. Le dire vaut mieux que + télécharger un fichier qui ne s'exécutera pas.""" + res = self._run_with_stubs({"uname": "echo s390x"}) + self.assertNotEqual(0, res.returncode) + self.assertIn("s390x", res.stdout + res.stderr) + + def test_an_unreachable_release_feed_is_named(self): + res = self._run_with_stubs({"uname": "echo x86_64", "curl": "exit 7"}) + self.assertNotEqual(0, res.returncode) + self.assertIn("Version", res.stdout + res.stderr) + + def test_a_pinned_version_needs_no_feed(self): + """FORGEJO_VERSION évite l'appel réseau : utile hors ligne, et c'est ce + qui rend ce test rapide.""" + res = self._run_with_stubs( + {"uname": "echo x86_64", "curl": "exit 7", "sudo": "exit 0"}, + env={"FORGEJO_VERSION": "9.9.9"}, + ) + out = res.stdout + res.stderr + self.assertIn("9.9.9", out) + # Il échoue plus loin (le téléchargement est bouchonné), pas sur la + # version : c'est bien le réseau du flux qui a été évité. + self.assertNotIn("Version de Forgejo introuvable", out) + + +class TestTheScriptGuards(unittest.TestCase): + """Quatre pièges rencontrés en le mettant au point, tous mesurés.""" + + def setUp(self): + self.body = SCRIPT.read_text() + + @property + def code_lines(self): + """Les lignes de CODE : le piège est expliqué en commentaire, et un + test qui cherche dans les commentaires trébuche sur sa propre + documentation — vécu à l'écriture de ce fichier.""" + return [ + ln + for ln in self.body.splitlines() + if ln.strip() and not ln.lstrip().startswith("#") + ] + + def test_it_never_probes_with_exec_on_dev_tcp(self): + """« exec » est un builtin spécial : une redirection qui échoue termine + le shell. Le script mourait au premier tour de la boucle d'attente, + code 1 et pas un mot.""" + guilty = [ln for ln in self.code_lines if "exec 3<>" in ln] + self.assertEqual([], guilty) + self.assertIn("/api/v1/version", self.body) + + def test_the_config_test_goes_through_sudo(self): + """/etc/forgejo est en 770 root:git : « [ -f ] » échouait toujours, et + chaque passage réécrivait la configuration avec des secrets neufs.""" + self.assertIn("sudo test -f", self.body) + + def test_all_four_secrets_are_written(self): + """Sans oauth2.JWT_SECRET, Forgejo tente de l'écrire dans app.ini, + n'y arrive pas, et boucle sur « [F] save oauth2.JWT_SECRET failed ».""" + for key in ( + "SECRET_KEY", + "INTERNAL_TOKEN", + "JWT_SECRET", + "LFS_JWT_SECRET", + ): + self.assertIn(key, self.body, key) + + def test_the_default_admin_name_is_not_reserved(self): + """Forgejo refuse « admin » : « CreateUser: name is reserved ».""" + self.assertIn("FORGEJO_ADMIN_USER:-erplibre", self.body) + self.assertNotIn("FORGEJO_ADMIN_USER:-admin}", self.body) + + def test_it_touches_no_package_manager(self): + """C'est ce qui le rend portable : le binaire est statique.""" + for pm in ("apt-get install", "dnf install", "pacman -S", "zypper"): + self.assertNotIn(pm, self.body, pm) + + +if __name__ == "__main__": + unittest.main() From 3a82ddbf451a5c250b4471cfa4bb34a7da277784 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 19:43:43 -0400 Subject: [PATCH 43/55] =?UTF-8?q?[IMP]=20script=20forgejo:=20deviner=20l'a?= =?UTF-8?q?dresse=20sans=20d=C3=A9pendre=20de=20net-tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « hostname -I » est un drapeau de net-tools. L'inetutils d'Arch ne le connaît pas et peut rendre le NOM de la machine — une ROOT_URL bâtie sur un nom non résolvable est pire qu'un repli, et l'option est censée marcher sur toutes les plateformes ERPLibre. Trois candidats désormais, chacun validé comme adresse IPv4 avant d'être retenu : hostname -I, puis « ip route get », puis la première adresse globale. localhost ferme la marche — une forge joignable en local vaut mieux qu'un script qui s'arrête. Les trois voies sont couvertes par des tests qui bouchonnent hostname et ip. --- EN --- "hostname -I" is a net-tools flag. Arch's inetutils does not know it and may return the machine NAME — a ROOT_URL built on an unresolvable name is worse than a fallback, and this option is meant to work on every ERPLibre platform. Three candidates now, each validated as an IPv4 address before being kept: hostname -I, then "ip route get", then the first global address. localhost closes the march — a forge reachable locally beats a script that stops. All three paths are covered by tests that stub hostname and ip. Assisted-by: Claude Opus 5 --- script/forgejo/install_forgejo.sh | 30 +++++++++++++--- test/test_qemu_forgejo.py | 57 +++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/script/forgejo/install_forgejo.sh b/script/forgejo/install_forgejo.sh index a86b9ef..a7c31bf 100755 --- a/script/forgejo/install_forgejo.sh +++ b/script/forgejo/install_forgejo.sh @@ -60,6 +60,30 @@ esac say() { echo -e " $*"; } die() { echo -e " ${Red}✗ $*${Color_Off}" >&2; exit 1; } +# Adresse par laquelle la machine est joignable, pour ROOT_URL et SSH_DOMAIN. +# +# « hostname -I » d'abord, mais PAS seulement : ce drapeau vient de net-tools et +# l'inetutils d'Arch ne le connaît pas. « ip route get » le remplace partout où +# iproute2 est là, c'est-à-dire partout. « localhost » ferme la marche : une +# forge joignable en local vaut mieux qu'un script qui s'arrête. +host_address() { + local h="" + # Chaque candidat est VALIDÉ comme adresse IPv4 avant d'être retenu : un + # « hostname » qui ne connaît pas -I peut rendre le nom de la machine, et + # une ROOT_URL bâtie sur un nom non résolvable est pire qu'un repli. + for h in \ + "$(hostname -I 2>/dev/null | awk '{print $1}')" \ + "$(ip -4 route get 1 2>/dev/null | awk '{print $7; exit}')" \ + "$(ip -4 -o addr show scope global 2>/dev/null \ + | awk '{split($4, a, "/"); print a[1]; exit}')" + do + case "$h" in + [0-9]*.[0-9]*.[0-9]*.[0-9]*) echo "$h"; return 0 ;; + esac + done + echo localhost +} + # --- 1. Architecture ------------------------------------------------------- # Forgejo publie amd64, arm64 et arm-6. PAS de s390x : sur cette architecture # il faudrait le bâtir depuis les sources en Go, ce que ce script ne fait pas — @@ -158,8 +182,7 @@ sudo chmod 770 "$CONF_DIR" if sudo test -f "$CONF"; then say "configuration conservée : $CONF" else - host=$(hostname -I 2>/dev/null | awk '{print $1}') - [ -n "$host" ] || host=localhost + host=$(host_address) # Les QUATRE secrets, et pas seulement les deux évidents. Vécu : sans # « oauth2.JWT_SECRET », Forgejo tente de l'écrire dans app.ini au # démarrage, n'y arrive pas — le fichier appartient à root — et s'arrête @@ -291,8 +314,7 @@ else fi # --- 9. Résumé ------------------------------------------------------------- -host=$(hostname -I 2>/dev/null | awk '{print $1}') -[ -n "$host" ] || host=localhost +host=$(host_address) version=$("$BIN" --version 2>/dev/null | head -1) say "${Green}Forgejo prêt${Color_Off} : http://$host:$HTTP_PORT/" say " $version" diff --git a/test/test_qemu_forgejo.py b/test/test_qemu_forgejo.py index d62edc0..be385af 100644 --- a/test/test_qemu_forgejo.py +++ b/test/test_qemu_forgejo.py @@ -204,6 +204,63 @@ class TestTheScript(unittest.TestCase): self.assertNotIn("Version de Forgejo introuvable", out) +class TestHostAddress(unittest.TestCase): + """L'adresse qui va dans ROOT_URL et SSH_DOMAIN, sur trois terrains. + + « hostname -I » vient de net-tools : l'inetutils d'Arch ne connaît pas ce + drapeau et peut rendre le NOM de la machine. Une ROOT_URL bâtie sur un nom + non résolvable est pire qu'un repli, d'où la validation de la forme. + """ + + def _host_address(self, stubs): + """Extrait la fonction du script et l'exécute avec un PATH bouchonné.""" + body = SCRIPT.read_text() + start = body.index("host_address() {") + end = body.index("\n}", start) + 2 + fn = body[start:end] + with tempfile.TemporaryDirectory() as tmp: + bin_dir = pathlib.Path(tmp) / "bin" + bin_dir.mkdir() + for name, script in stubs.items(): + (bin_dir / name).write_text(f"#!/bin/bash\n{script}\n") + (bin_dir / name).chmod(0o755) + res = subprocess.run( + ["bash", "-c", fn + "\nhost_address"], + capture_output=True, + text=True, + env=dict(os.environ, PATH=f"{bin_dir}:/usr/bin:/bin"), + timeout=30, + ) + return res.stdout.strip() + + def test_it_takes_the_address_hostname_gives(self): + got = self._host_address({"hostname": "echo 10.1.2.3"}) + self.assertEqual("10.1.2.3", got) + + def test_a_hostname_that_returns_a_name_is_rejected(self): + """Le cas Arch : on tombe alors sur « ip », et non sur un nom.""" + got = self._host_address( + { + "hostname": "echo erplibre-arch", + "ip": "echo '1.0.0.1 via 10.0.0.1 dev eth0 src 10.9.9.9 uid 0'", + } + ) + self.assertEqual("10.9.9.9", got) + + def test_without_hostname_nor_ip_it_falls_back_to_localhost(self): + """Une forge joignable en local vaut mieux qu'un script qui s'arrête.""" + got = self._host_address({"hostname": "exit 1", "ip": "exit 1"}) + self.assertEqual("localhost", got) + + def test_it_never_returns_an_empty_string(self): + """Une ROOT_URL « http://:3000/ » ne mène nulle part.""" + for stubs in ( + {"hostname": "echo", "ip": "echo"}, + {"hostname": "exit 2", "ip": "exit 2"}, + ): + self.assertTrue(self._host_address(stubs), stubs) + + class TestTheScriptGuards(unittest.TestCase): """Quatre pièges rencontrés en le mettant au point, tous mesurés.""" From 1785a2019d2a07b82f205f9bc642af4e1980046c Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 19:45:33 -0400 Subject: [PATCH 44/55] [FIX] script todo: nommer les outils qu'une VM sans ERPLibre ne peut pas poser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les outils de la phase « après » vivent DANS le dépôt : la compilation mobile, l'AVD, et maintenant le script Forgejo. Sur une VM « bureau seul », sans clone, ils n'existent pas — et ils étaient écartés en silence. Une case cochée passait donc pour honorée. La commande le dit désormais, en nommant les outils concernés. Trois lignes qui évitent de chercher pourquoi la forge demandée n'est nulle part. --- EN --- The "after" phase tools live IN the repository: the mobile build, the AVD, and now the Forgejo script. On a desktop-only VM, with no clone, they do not exist — and they were dropped in silence. A ticked checkbox therefore passed for honoured. The command now says so, naming the tools concerned. Three lines that save hunting for why the requested forge is nowhere to be found. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 16 ++++++++++++++++ script/todo/todo_i18n.py | 4 ++++ test/test_qemu_forgejo.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/script/todo/todo.py b/script/todo/todo.py index e24423b..76143b0 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -6686,12 +6686,28 @@ class TODO: # sans quoi le verrou apt ferait échouer l'installation du bureau. if not desktop: return "true" + # Les outils de la phase « after » vivent DANS le dépôt — la + # compilation mobile, l'AVD, le script Forgejo. Sans clone, ils + # n'existent pas ici. Les écarter en silence laissait croire qu'une + # case cochée avait été honorée : on la NOMME. + deferred = [ + k + for k in (tools or ()) + if self._QEMU_VM_TOOLS.get(k, {}).get("phase") == "after" + ] + note = ( + f'echo " ⚠ {t("needs the ERPLibre install, skipped:")}' + f' {" ".join(deferred)}"; ' + if deferred + else "" + ) return ( "set -e; " + self._qemu_cloud_init_wait() + self._qemu_no_auto_upgrade(prod, app_store) + self._qemu_desktop_remote_cmd(desktop, app_store) + self._qemu_tools_remote_cmd(tools, prod) + + note ) if not final_cmd: final_cmd = f"make install_os && make {self.ERPLIBRE_ODOO_TARGET}" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 699f631..22abfc6 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2260,6 +2260,10 @@ TRANSLATIONS = { "line": {"fr": "ligne", "en": "line"}, "free space": {"fr": "libre", "en": "free"}, "silent": {"fr": "silence", "en": "silent"}, + "needs the ERPLibre install, skipped:": { + "fr": "exige l'installation ERPLibre, écarté :", + "en": "needs the ERPLibre install, skipped:", + }, "Forgejo (git forge)": { "fr": "Forgejo (forge git)", "en": "Forgejo (git forge)", diff --git a/test/test_qemu_forgejo.py b/test/test_qemu_forgejo.py index be385af..c50bba0 100644 --- a/test/test_qemu_forgejo.py +++ b/test/test_qemu_forgejo.py @@ -135,6 +135,38 @@ class TestTheInstallBlock(unittest.TestCase): self.assertEqual(0, res.returncode, f"{tools}: {res.stderr}") +class TestDesktopOnlyVm(unittest.TestCase): + """Une VM sans ERPLibre : le script Forgejo vit dans le dépôt, donc nulle + part. L'écarter en silence laisserait croire qu'une case cochée a été + honorée.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + + def test_it_says_what_it_skips(self): + cmd = self.todo._qemu_erplibre_remote_cmd( + None, None, False, "gnome", "", "deb", ("forgejo",) + ) + self.assertIn("forgejo", cmd) + self.assertIn("⚠", cmd) + self.assertNotIn("install_forgejo.sh", cmd) + + def test_it_stays_quiet_when_nothing_was_deferred(self): + cmd = self.todo._qemu_erplibre_remote_cmd( + None, None, False, "gnome", "", "deb", ("gnome_ext",) + ) + self.assertNotIn("⚠", cmd) + + def test_the_note_is_valid_shell(self): + cmd = self.todo._qemu_erplibre_remote_cmd( + None, None, False, "gnome", "", "deb", ("forgejo", "mobile") + ) + res = subprocess.run( + ["bash", "-n"], input=cmd, capture_output=True, text=True + ) + self.assertEqual(0, res.returncode, res.stderr) + + class TestTheScript(unittest.TestCase): """Le script lui-même, exécuté sur ses chemins de refus.""" From 3ec7eb36f1c8ab602ff469b85421ca0c9b1f4a71 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 20 Aug 2026 02:04:50 -0400 Subject: [PATCH 45/55] =?UTF-8?q?[FIX]=20script=20forgejo:=20red=C3=A9marr?= =?UTF-8?q?er=20le=20service=20quand=20la=20configuration=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tout push finissait sur « Forgejo: Internal Server Error Decoding Failed », et le message ne désigne pas la cause. Le journal, lui, la donne : 403 sur /api/internal/hook/pre-receive, refusé par le contrôle du jeton interne. Le serveur comparait l'INTERNAL_TOKEN qu'il tenait EN MÉMOIRE à celui que le hook venait de lire sur le disque — deux valeurs différentes — et répondait 403 à son propre hook, que celui-ci ne sait pas décoder. La cause est ici : « systemctl enable --now » ne touche pas un service déjà actif. Le script redémarre donc quand le binaire, la configuration ou l'unité ont changé, et se tait sinon. Vérifié sur la VM : configuration régénérée service actif -> redémarrage -> push accepté ; relance sur forge saine -> aucun redémarrage, push toujours accepté. --- EN --- Every push ended on "Forgejo: Internal Server Error Decoding Failed", and the message does not name the cause. The log does: 403 on /api/internal/hook/pre-receive, refused by the internal token check. The server was comparing the INTERNAL_TOKEN it held IN MEMORY with the one the hook had just read from disk — two different values — and answered 403 to its own hook, which cannot decode a 403. The cause is here: "systemctl enable --now" does not touch an already active service. The script now restarts when the binary, the configuration or the unit changed, and stays quiet otherwise. Verified on the VM: config regenerated with the service active -> restart -> push accepted; replay on a healthy forge -> no restart, push still accepted. Assisted-by: Claude Opus 5 --- script/forgejo/install_forgejo.sh | 41 +++++++++++++++++++++++++++---- test/test_qemu_forgejo.py | 33 +++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/script/forgejo/install_forgejo.sh b/script/forgejo/install_forgejo.sh index a7c31bf..d2f873b 100755 --- a/script/forgejo/install_forgejo.sh +++ b/script/forgejo/install_forgejo.sh @@ -41,6 +41,11 @@ ADMIN_EMAIL="${FORGEJO_ADMIN_EMAIL:-admin@erplibre.local}" RUN_USER="${FORGEJO_USER:-git}" SKIP_ADMIN="${FORGEJO_SKIP_ADMIN:-0}" +# Ce qui a changé sur le disque pendant ce passage. Le service ne redémarre que +# si quelque chose a bougé : rejouer le script sur une forge saine ne doit pas +# l'interrompre, même deux secondes. +CHANGED=0 + BIN=/usr/local/bin/forgejo CONF_DIR=/etc/forgejo CONF="$CONF_DIR/app.ini" @@ -152,6 +157,7 @@ else esac chmod +x "$src" sudo install -m 0755 "$src" "$BIN" + CHANGED=1 say "${Green}binaire posé : $BIN${Color_Off}" rm -rf "$tmp" trap - EXIT @@ -249,11 +255,13 @@ LEVEL = info CONFEOF sudo chown root:"$RUN_USER" "$CONF" sudo chmod 640 "$CONF" + CHANGED=1 say "${Green}configuration écrite : $CONF${Color_Off}" fi # --- 6. Service ------------------------------------------------------------ -sudo tee "$UNIT" >/dev/null < "$unit_tmp" </dev/null 2>&1 \ - || die "le service refuse de démarrer : sudo journalctl -u forgejo -n 40" +if ! sudo cmp -s "$unit_tmp" "$UNIT" 2>/dev/null; then + sudo install -m 0644 "$unit_tmp" "$UNIT" + sudo systemctl daemon-reload + CHANGED=1 + say "service défini : $UNIT" +fi +rm -f "$unit_tmp" + +sudo systemctl enable forgejo.service >/dev/null 2>&1 || true +# « restart » et NON « enable --now » quand quelque chose a changé : « --now » +# ne touche pas à un service déjà actif, qui garde alors sa configuration en +# MÉMOIRE. Vécu, et le symptôme ne désigne pas la cause : le serveur comparait +# son ancien INTERNAL_TOKEN à celui que le hook venait de lire sur le disque, et +# répondait 403 à son propre hook. Tout push finissait sur « Forgejo: Internal +# Server Error Decoding Failed », le hook ne sachant pas décoder un 403. +if [ "$CHANGED" = 1 ]; then + sudo systemctl restart forgejo.service \ + || die "le service refuse de démarrer : sudo journalctl -u forgejo -n 40" + say "service redémarré (configuration ou binaire modifié)" +elif ! systemctl is-active --quiet forgejo.service; then + sudo systemctl start forgejo.service \ + || die "le service refuse de démarrer : sudo journalctl -u forgejo -n 40" +fi # --- 7. Attendre qu'il RÉPONDE -------------------------------------------- # Une requête HTTP, pas un « systemctl is-active » : le service est « active » @@ -286,7 +314,10 @@ sudo systemctl enable --now forgejo.service >/dev/null 2>&1 \ # silence, au premier tour de la boucle, code 1 sans un mot — vécu. ready=0 for i in $(seq 1 60); do - if curl -fsS -o /dev/null --max-time 3 \ + # « -fs » sans « -S » : dans une boucle de réessai, le message de curl est + # du bruit — « Failed to connect » au premier tour est normal, le service + # vient de redémarrer. C'est le die final qui parle si rien ne répond. + if curl -fs -o /dev/null --max-time 3 \ "http://127.0.0.1:$HTTP_PORT/api/v1/version"; then ready=1 break diff --git a/test/test_qemu_forgejo.py b/test/test_qemu_forgejo.py index c50bba0..a30dbc5 100644 --- a/test/test_qemu_forgejo.py +++ b/test/test_qemu_forgejo.py @@ -339,6 +339,39 @@ class TestTheScriptGuards(unittest.TestCase): self.assertIn("FORGEJO_ADMIN_USER:-erplibre", self.body) self.assertNotIn("FORGEJO_ADMIN_USER:-admin}", self.body) + def test_it_restarts_when_something_changed(self): + """« enable --now » ne touche PAS un service déjà actif : il garde alors + sa configuration en mémoire. Vécu, et le symptôme ne désignait pas la + cause — le serveur comparait son ancien INTERNAL_TOKEN à celui que le + hook venait de lire, répondait 403 à son propre hook, et tout push + finissait sur « Internal Server Error Decoding Failed ».""" + self.assertIn("systemctl restart forgejo.service", self.body) + self.assertNotIn("enable --now forgejo", self.body) + + def test_the_restart_is_conditional(self): + """Rejouer le script sur une forge saine ne doit pas l'interrompre, + même deux secondes.""" + self.assertIn("CHANGED=0", self.body) + self.assertIn('[ "$CHANGED" = 1 ]', self.body) + # Trois évènements le lèvent : binaire posé, config écrite, unité + # modifiée. + self.assertEqual(3, self.body.count("CHANGED=1")) + + def test_the_unit_is_compared_before_being_written(self): + """Sans comparaison, l'unité serait réécrite à l'identique et le + service redémarrerait pour rien à chaque passage.""" + self.assertIn("cmp -s", self.body) + + def test_the_readiness_loop_stays_quiet_while_retrying(self): + """« Failed to connect » au premier tour est normal — le service vient + de redémarrer. C'est le die final qui parle.""" + # La commande est coupée sur deux lignes : on regarde le BLOC de la + # boucle, pas la ligne qui porte l'URL. + start = self.body.index("ready=0") + block = self.body[start : self.body.index('[ "$ready" = 1 ]', start)] + self.assertIn("curl -fs -o /dev/null", block) + self.assertNotIn("-fsS", block) + def test_it_touches_no_package_manager(self): """C'est ce qui le rend portable : le binaire est statique.""" for pm in ("apt-get install", "dnf install", "pacman -S", "zypper"): From 0bd7c2fb1d873c23b74af644bb5b38ea749243dd Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 20 Aug 2026 02:40:16 -0400 Subject: [PATCH 46/55] [I18N] script todo: traduire le tunnel vers la console de l'hyperviseur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dix lignes de ce chemin s'affichaient en anglais dans une session française, dont celle qui compte : « then point your VNC client at localhost:5900 ». C'est l'écran d'une VM qu'on va chercher là, et le mode d'emploi arrivait à moitié traduit. Le diagnostic de l'autre branche l'était aussi — la VM sans port VNC, celle qu'il faut redéfinir. Vérifié sur les deux VM du parc : celle qui expose son écran et celle qui n'en expose pas. --- EN --- Ten lines of that path showed in English in a French session, including the one that matters: "then point your VNC client at localhost:5900". What is being fetched there is a VM's screen, and the instructions arrived half translated. The other branch's diagnostic was in the same state — the VM with no VNC port, the one that needs redefining. Checked on both VMs of the fleet: the one that exposes its screen and the one that does not. Assisted-by: Claude Opus 5 --- script/todo/todo_i18n.py | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 22abfc6..e6f6e21 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2244,6 +2244,47 @@ TRANSLATIONS = { "fr": "À lancer sur VOTRE poste :", "en": "Run this on YOUR workstation:", }, + "Hypervisor console (QEMU screen, no guest server)": { + "fr": "Console de l'hyperviseur (écran QEMU, rien dans l'invitée)", + "en": "Hypervisor console (QEMU screen, no guest server)", + }, + "then point your VNC client at": { + "fr": "puis pointez votre client VNC sur", + "en": "then point your VNC client at", + }, + "Target is the hypervisor": { + "fr": "La cible est l'hyperviseur", + "en": "Target is the hypervisor", + }, + "not the VM: the socket is QEMU-side.": { + "fr": "et non la VM : le socket est du côté de QEMU.", + "en": "not the VM: the socket is QEMU-side.", + }, + "This VM exposes no VNC port.": { + "fr": "Cette VM n'expose aucun port VNC.", + "en": "This VM exposes no VNC port.", + }, + "Its display is likely spice with listen=none:": { + "fr": "Son affichage est probablement spice avec listen=none :", + "en": "Its display is likely spice with listen=none:", + }, + "To open it on the loopback (VM restart required):": { + "fr": "Pour l'ouvrir sur la boucle locale (redémarrage de la VM" + " requis) :", + "en": "To open it on the loopback (VM restart required):", + }, + "New VMs get this by default; see deploy_qemu.": { + "fr": "Les VM neuves l'ont par défaut ; voir deploy_qemu.", + "en": "New VMs get this by default; see deploy_qemu.", + }, + "No ProxyJump for this host in ~/.ssh/config.": { + "fr": "Aucun ProxyJump pour cet hôte dans ~/.ssh/config.", + "en": "No ProxyJump for this host in ~/.ssh/config.", + }, + "Cannot tell which machine runs its QEMU.": { + "fr": "Impossible de savoir quelle machine fait tourner son QEMU.", + "en": "Cannot tell which machine runs its QEMU.", + }, "(through the ProxyJump already in ~/.ssh/config)": { "fr": "(par le ProxyJump déjà dans ~/.ssh/config)", "en": "(through the ProxyJump already in ~/.ssh/config)", From 49bc354d25b6af12361ee85d365414a6bf0c9ab8 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 20 Aug 2026 02:56:21 -0400 Subject: [PATCH 47/55] =?UTF-8?q?[FIX]=20script=20todo:=20d=C3=A9marrer=20?= =?UTF-8?q?le=20bureau,=20pas=20seulement=20l'activer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une VM graphique restait sur une console texte jusqu'au premier redémarrage. GNOME installé, gdm3 installé, graphical.target par défaut, lien display-manager.service posé par le paquet — et rien à l'écran. Deux causes superposées, mesurées sur erplibre-ubuntu-2604-gnome : graphical.target était DÉJÀ atteinte quand le paquet est arrivé, et une cible active ne rattrape pas un service ajouté après coup. Et « systemctl enable gdm » rend 0 sans rien faire sur Debian et Ubuntu : l'unité n'a pas de « WantedBy », seulement l'alias que le paquet pose lui-même. Le bureau est donc démarré, avec repli sur le service de la saveur, et l'échec se dit au lieu de se taire. Vérifié : bureau arrêté puis fragment rejoué -> gnome-shell revient, écran de connexion GDM à l'image. --- EN --- A graphical VM stayed on a text console until its first reboot. GNOME installed, gdm3 installed, graphical.target the default, the display-manager.service alias in place by the package — and nothing on screen. Two causes stacked, measured on erplibre-ubuntu-2604-gnome: graphical.target had ALREADY been reached when the package arrived, and an active target does not pick up a service added afterwards. And "systemctl enable gdm" returns 0 doing nothing on Debian and Ubuntu: the unit has no "WantedBy", only the alias the package installs itself. The desktop is therefore started, with a fallback to the flavour's service, and a failure says so instead of staying quiet. Verified: desktop stopped then the fragment replayed -> gnome-shell back, GDM greeter on screen. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 19 +++++++ script/todo/todo_i18n.py | 8 +++ test/test_qemu_desktop_tools.py | 93 +++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) diff --git a/script/todo/todo.py b/script/todo/todo.py index 76143b0..0f6540b 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5333,6 +5333,25 @@ class TODO: # images cloud démarrent en multi-user.target. "sudo systemctl set-default graphical.target || true; " f"sudo systemctl enable {de['service']} >/dev/null 2>&1 || true; " + # Et il faut le DÉMARRER, pas seulement l'activer. Deux raisons, + # toutes deux mesurées sur erplibre-ubuntu-2604-gnome : + # + # - graphical.target était DÉJÀ atteinte quand le paquet est + # arrivé, et une cible active ne rattrape pas un service ajouté + # après coup : display-manager.service est resté inactif ; + # - sur Debian et Ubuntu, « systemctl enable gdm » rend 0 sans + # rien faire — l'unité n'a pas de « WantedBy », seulement + # « Alias=display-manager.service » que le paquet a déjà posé. + # + # Résultat : GNOME installé, gdm3 installé, cible graphique par + # défaut… et la console de la VM restait en mode texte jusqu'au + # premier redémarrage. L'écran, c'est justement ce qu'on est venu + # chercher sur une VM graphique. + "if sudo systemctl start display-manager.service 2>/dev/null || " + f"sudo systemctl start {de['service']} 2>/dev/null; then " + f'echo " {t("graphical session started")}"; ' + f'else echo " ⚠ {t("graphical session not started; reboot the VM")}"; ' + "fi; " # xrdp là où il existe ; sur Arch c'est TigerVNC, qui se configure # par utilisateur et n'a pas de service à activer d'office. "if command -v xrdp >/dev/null 2>&1; then " diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index e6f6e21..eb58a00 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2244,6 +2244,14 @@ TRANSLATIONS = { "fr": "À lancer sur VOTRE poste :", "en": "Run this on YOUR workstation:", }, + "graphical session started": { + "fr": "session graphique démarrée", + "en": "graphical session started", + }, + "graphical session not started; reboot the VM": { + "fr": "session graphique non démarrée ; redémarrer la VM", + "en": "graphical session not started; reboot the VM", + }, "Hypervisor console (QEMU screen, no guest server)": { "fr": "Console de l'hyperviseur (écran QEMU, rien dans l'invitée)", "en": "Hypervisor console (QEMU screen, no guest server)", diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index 293355d..a71a0be 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -631,6 +631,99 @@ class TestPycharmCommunity(unittest.TestCase): self.assertIn("JetBrains", self.cmd) +class TestTheDesktopActuallyStarts(unittest.TestCase): + """Installer un bureau ne suffit pas : il faut le DÉMARRER. + + Vécu sur erplibre-ubuntu-2604-gnome, et le diagnostic ne sautait pas aux + yeux : GNOME installé, gdm3 installé, graphical.target par défaut, lien + display-manager.service en place — et la console de la VM restait en mode + texte. Deux causes superposées : + + - graphical.target était DÉJÀ atteinte quand le paquet est arrivé, et une + cible active ne rattrape pas un service ajouté après coup ; + - « systemctl enable gdm » rend 0 sans rien faire sur Debian et Ubuntu : + l'unité n'a pas de « WantedBy », seulement un alias que le paquet pose. + """ + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.cmd = self.todo._qemu_desktop_remote_cmd("gnome", "deb") + + def _start_block(self, cmd=None): + """Le seul « if » qui démarre le bureau, extrait tel quel.""" + cmd = cmd or self.cmd + start = cmd.index("if sudo systemctl start display-manager") + return cmd[start : cmd.index("fi; ", start) + 4] + + def test_it_starts_and_does_not_only_enable(self): + self.assertIn("systemctl start display-manager.service", self.cmd) + + def test_it_falls_back_to_the_desktop_service(self): + """« display-manager.service » est un alias que les paquets Debian + posent ; ailleurs c'est « gdm » qui porte le WantedBy.""" + block = self._start_block() + self.assertIn("systemctl start gdm", block) + + def test_it_comes_after_the_default_target_and_before_xrdp(self): + self.assertLess( + self.cmd.index("set-default graphical.target"), + self.cmd.index("start display-manager.service"), + ) + self.assertLess( + self.cmd.index("start display-manager.service"), + self.cmd.index("command -v xrdp"), + ) + + def _run(self, systemctl_body): + import os + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + bin_dir = pathlib.Path(tmp) / "bin" + bin_dir.mkdir() + (bin_dir / "sudo").write_text('#!/bin/bash\nexec "$@"\n') + (bin_dir / "systemctl").write_text( + f"#!/bin/bash\n{systemctl_body}\n" + ) + for n in ("sudo", "systemctl"): + (bin_dir / n).chmod(0o755) + res = subprocess.run( + ["bash", "-c", self._start_block()], + capture_output=True, + text=True, + env=dict(os.environ, PATH=f"{bin_dir}:/usr/bin:/bin"), + timeout=30, + ) + return res.stdout + + def test_the_alias_path_reports_a_started_session(self): + out = self._run("exit 0") + self.assertIn("session", out.lower()) + self.assertNotIn("⚠", out) + + def test_the_fallback_path_also_reports_started(self): + """display-manager absent, gdm présent : c'est le cas d'Arch.""" + out = self._run( + 'case "$*" in *display-manager*) exit 1;; *) exit 0;; esac' + ) + self.assertNotIn("⚠", out) + + def test_when_nothing_starts_it_says_to_reboot(self): + """Le pire serait de se taire : l'utilisateur cherche un écran.""" + out = self._run("exit 1") + self.assertIn("⚠", out) + self.assertIn("boot", out.lower() + "reboot") + + def test_the_block_is_valid_shell(self): + res = subprocess.run( + ["bash", "-n"], + input=self._start_block(), + capture_output=True, + text=True, + ) + self.assertEqual(0, res.returncode, res.stderr) + + class TestPycharmFirstOpen(unittest.TestCase): """Ouverture sans écran, pour que le .idea existe avant l'installation. From 75dbeb325813cbc9a0f6ca567a7c4c693254fb8b Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 20 Aug 2026 03:32:49 -0400 Subject: [PATCH 48/55] =?UTF-8?q?[ADD]=20qemu=20guide:=20dire=20comment=20?= =?UTF-8?q?d=C3=A9marrer=20le=20bureau,=20sur=20les=20VM=20qui=20en=20ont?= =?UTF-8?q?=20un?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une VM graphique peut arriver sur une console texte — graphical.target est atteinte avant que le paquet du bureau soit là, et « systemctl enable gdm » rend 0 sans rien faire sur Debian et Ubuntu, où l'unité n'a pas de WantedBy. La commande qui répare tient sur une ligne ; encore faut-il la lire quelque part. Le guide de connexion porte donc un bloc « Bureau », avec l'état et le « enable --now ». Il n'apparaît que si la VM a été déployée avec un bureau : sur un serveur, ces commandes ne mèneraient à aucune unité. Le drapeau existait déjà côté déploiement, il n'y avait qu'à le passer. --- EN --- A graphical VM can land on a text console — graphical.target is reached before the desktop package exists, and "systemctl enable gdm" returns 0 doing nothing on Debian and Ubuntu, where the unit has no WantedBy. The command that repairs it is one line; it still has to be readable somewhere. The login guide therefore carries a "Desktop" block, with the state and the "enable --now". It only shows when the VM was deployed with a desktop: on a server those commands would point at no unit. The flag already existed on the deploy side; it only had to be passed along. Assisted-by: Claude Opus 5 --- script/qemu/deploy_qemu.py | 32 ++++++++++++++++++++++++ test/test_qemu_motd.py | 51 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 491fc47..45e23aa 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -1660,6 +1660,26 @@ SERVICE_GUIDE: tuple[tuple[str, str, str], ...] = ( ) +# N'apparaît que sur une VM déployée AVEC un bureau. Vécu : GNOME installé, +# gdm3 installé, cible graphique par défaut… et la console restait en mode texte. +# graphical.target était déjà atteinte quand le paquet est arrivé, et une cible +# active ne rattrape pas un service ajouté après coup. « enable » seul n'y change +# rien sur Debian et Ubuntu — l'unité n'a pas de WantedBy, seulement un alias — +# d'où le « --now », qui démarre. +DESKTOP_GUIDE: tuple[tuple[str, str, str], ...] = ( + ( + "systemctl status display-manager", + "état du bureau graphique", + "graphical desktop state", + ), + ( + "sudo systemctl enable --now gdm", + "le démarrer (« --now » : enable seul ne suffit pas)", + 'start it ("--now": enable alone does nothing)', + ), +) + + def erplibre_guide( el_dir: str, el_make: str = "", editor: str = "" ) -> tuple[tuple[str, str, str], ...]: @@ -1770,11 +1790,14 @@ def build_motd( el_dir: str = "", el_make: str = "", editor: str = "", + desktop: bool = False, ) -> str: """Texte du /etc/motd de la VM. Fonction PURE : aucun I/O, donc testable. La section ERPLibre n'apparaît qu'avec `el_dir` : une VM déployée sans installation ne doit pas annoncer un dépôt et un service qui n'existent pas. + Le bloc « Bureau » suit la même règle avec `desktop` : sur un serveur, ces + deux commandes ne mèneraient à aucune unité. """ body: list[str] = [] mgr = DISTRO_PKG.get(distro, "") @@ -1795,6 +1818,14 @@ def build_motd( body.append("") el_rows = erplibre_guide(el_dir, el_make, editor) body += motd_block("ERPLibre", el_rows, lang, gloss_col(el_rows)) + if desktop: + body.append("") + body += motd_block( + _pick(("Bureau", "Desktop"), lang), + DESKTOP_GUIDE, + lang, + gloss_col(DESKTOP_GUIDE), + ) body.append("") body += motd_block( _pick(("Système", "System"), lang), sys_rows, lang, narrow @@ -2016,6 +2047,7 @@ def guide_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]: args.erplibre_dir, args.erplibre_make, editor, + bool(args.desktop), ), "", ) diff --git a/test/test_qemu_motd.py b/test/test_qemu_motd.py index 1fde3ff..8156cd9 100644 --- a/test/test_qemu_motd.py +++ b/test/test_qemu_motd.py @@ -352,5 +352,56 @@ class TestInstallerGuideNames(unittest.TestCase): ) +class TestDesktopBlock(unittest.TestCase): + """Le bloc « Bureau » : présent seulement là où un bureau existe. + + Vécu : une VM graphique restait sur une console texte, GNOME installé et + gdm3 installé — graphical.target était déjà atteinte quand le paquet est + arrivé. La commande qui répare tient sur une ligne, encore faut-il la lire + quelque part. Sur un serveur, elle ne mènerait à aucune unité : le bloc + n'y apparaît pas. + """ + + def _motd(self, desktop): + return dq.build_motd( + "ubuntu", + "26.04", + "amd64", + "fr", + "~/git/erplibre", + "install_odoo_18", + "vim", + desktop, + ) + + def test_a_server_gets_no_desktop_block(self): + self.assertNotIn("Bureau", self._motd(False)) + + def test_a_graphical_vm_gets_it(self): + self.assertIn("Bureau", self._motd(True)) + + def test_it_carries_the_command_that_repairs(self): + """« --now » et non « enable » seul : sur Debian et Ubuntu, l'unité n'a + pas de WantedBy, et « enable » rend 0 sans rien faire.""" + motd = self._motd(True) + self.assertIn("systemctl enable --now gdm", motd) + self.assertIn("systemctl status display-manager", motd) + + def test_it_says_why_now_matters(self): + self.assertIn("--now", self._motd(True)) + + def test_it_stays_inside_the_frame(self): + """Le guide est encadré : une ligne trop longue casse la boîte.""" + lines = self._motd(True).splitlines() + width = max(len(line) for line in lines) + border = [line for line in lines if line.startswith("╭")][0] + self.assertEqual(len(border), width) + + def test_the_default_is_no_block(self): + """Un appelant qui n'en sait rien n'annonce pas un bureau.""" + motd = dq.build_motd("ubuntu", "26.04", "amd64", "fr") + self.assertNotIn("Bureau", motd) + + if __name__ == "__main__": unittest.main() From 7452c581dbbf697d97f3795a0edb9248f88edf3e Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 20 Aug 2026 03:33:11 -0400 Subject: [PATCH 49/55] =?UTF-8?q?[ADD]=20script=20todo:=20ouvrir=20l'?= =?UTF-8?q?=C3=A9cran=20d'une=20VM=20avec=20virt-viewer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C'est la voie la plus courte : virt-viewer parle à libvirt par « qemu+ssh », lit le port de l'écran par libvirt et monte SON tunnel — aucun « ssh -L » à tenir ouvert, rien à deviner. Le menu tunnel le propose donc en cinquième choix. La seule question qui compte est celle de l'affichage, et c'est l'environnement qui tranche, pas une question de plus. Un affichage présent (poste, ou ssh -X) : virt-viewer est installé s'il manque — apt, dnf, pacman ou zypper — puis lancé détaché. Aucun affichage : la commande est donnée pour le poste, et rien n'est installé sur une machine sans écran. --- EN --- The shortest path: virt-viewer talks to libvirt over "qemu+ssh", reads the display port from libvirt and builds its OWN tunnel — no "ssh -L" to keep open, nothing to guess. The tunnel menu offers it as a fifth choice. The only question that matters is the display, and the environment answers it rather than one more prompt. A display present (workstation, or ssh -X): virt-viewer gets installed if missing — apt, dnf, pacman or zypper — then launched detached. No display: the command is handed over for the workstation, and nothing is installed on a screenless machine. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 101 +++++++++++++++++++++ script/todo/todo_i18n.py | 38 ++++++++ test/test_qemu_forgejo.py | 7 +- test/test_qemu_tunnel_menu.py | 159 ++++++++++++++++++++++++++++++++++ 4 files changed, 303 insertions(+), 2 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index 0f6540b..870a2a4 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1580,6 +1580,7 @@ class TODO: f" [3] {t('Hypervisor console (QEMU screen, no guest server)')}" ) print(f" [4] {t('Android emulator (adb 5555, then scrcpy)')}") + print(f" [5] {t('Graphical console (virt-viewer, built-in tunnel)')}") kind_answer = input(f"{t('Choice')} [1]: ").strip() or "1" if kind_answer == "3": self._qemu_console_tunnel(name, src) @@ -1587,6 +1588,9 @@ class TODO: if kind_answer == "4": self._qemu_scrcpy_tunnel(name, src) return + if kind_answer == "5": + self._qemu_virt_viewer(name, src) + return port, kind = (5901, "VNC") if kind_answer == "2" else (3389, "RDP") local = port + 1 @@ -1937,6 +1941,103 @@ class TODO: sock.settimeout(1) return sock.connect_ex(("127.0.0.1", port)) == 0 + # Un paquet, quatre familles. virt-viewer porte le même nom partout, ce qui + # est rare et bienvenu : seule la commande d'installation change. + _QEMU_VIRT_VIEWER_INSTALL = ( + ("apt-get", "sudo apt-get install -y virt-viewer"), + ("dnf", "sudo dnf install -y virt-viewer"), + ("pacman", "sudo pacman -S --needed --noconfirm virt-viewer"), + ("zypper", "sudo zypper --non-interactive install virt-viewer"), + ) + + def _qemu_ensure_virt_viewer(self): + """virt-viewer sur CETTE machine, installé s'il manque. + + Installé seulement là où il va SERVIR : sur un hyperviseur sans écran, + poser un client graphique ne rendrait service à personne. C'est + l'appelant qui a vérifié l'affichage.""" + if shutil.which("virt-viewer"): + return True + print(f"\n {t('virt-viewer is missing here; installing it.')}") + for tool, cmd in self._QEMU_VIRT_VIEWER_INSTALL: + if shutil.which(tool): + print(f" {t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + break + else: + print(f" ⚠ {t('no known package manager here.')}") + return False + if shutil.which("virt-viewer"): + print(f" ✅ virt-viewer") + return True + print(f" ⚠ {t('virt-viewer still missing after the install.')}") + return False + + def _qemu_virt_viewer(self, name, src): + """Ouvre l'écran d'une VM avec virt-viewer, qui monte SON tunnel. + + C'est la voie la plus courte : virt-viewer parle à libvirt par + « qemu+ssh:// » et n'a besoin d'aucun « ssh -L » à tenir ouvert. Il lit + aussi le port de l'écran par libvirt, donc rien à deviner. + + La seule question qui compte est celle de l'AFFICHAGE. virt-viewer + ouvre une fenêtre : il doit tourner là où il y a un écran. Deux cas, et + c'est l'environnement qui tranche, pas une question de plus : + - un affichage est là (poste de travail, ou « ssh -X ») : on installe + virt-viewer au besoin et on le lance, détaché ; + - aucun affichage : on donne la commande à lancer sur le poste, sous + la forme qemu+ssh, avec l'adresse par laquelle cette machine a été + jointe. + """ + domain = name.rsplit("+", 1)[-1] if src == "ssh_config" else name + display = os.environ.get("DISPLAY") or os.environ.get( + "WAYLAND_DISPLAY" + ) + if src == "ssh_config": + # L'hyperviseur est le ProxyJump déclaré : c'est lui qui fait + # tourner le QEMU de cette VM, pas la VM elle-même. + jump = self._ssh_proxyjump(name) + if not jump: + print( + f"\n ⚠ {t('No ProxyJump for this host in ~/.ssh/config.')}" + ) + print(f" {t('Cannot tell which machine runs its QEMU.')}") + return + uri = f"qemu+ssh://{jump}/system" + else: + uri = "qemu:///system" + + if display: + if not self._qemu_ensure_virt_viewer(): + return + cmd = ["virt-viewer", "-c", uri, domain] + print(f"\n {t('Opening')} : {' '.join(cmd)}") + try: + with open("/tmp/erplibre-virt-viewer.log", "ab") as log: + subprocess.Popen( + cmd, + stdout=log, + stderr=log, + start_new_session=True, + ) + except OSError as exc: + print(f" ⚠ {t('Could not start it:')} {exc}") + return + print(f" {t('Window opening on your display')} ({display}).") + print(f" {t('Log:')} /tmp/erplibre-virt-viewer.log") + return + + host, from_ssh = self._qemu_self_address() + user = os.environ.get("USER", "user") + print(f"\n {t('No display here; run this on YOUR workstation:')}") + print(f"\n virt-viewer -c qemu+ssh://{user}@{host}/system {domain}\n") + if not from_ssh: + print(f" ⚠ {t('Not in an SSH session: check the host address.')}") + print(f" {t('A ~/.ssh/config alias works there too.')}") + print(f" {t('It builds its own tunnel; no ssh -L to keep open.')}") + print(f" {t('Missing? Install virt-viewer:')} apt / dnf / pacman" + " / zypper") + def _qemu_console_tunnel(self, name, src): """Tunnel vers l'ÉCRAN QEMU d'une VM, pas vers un serveur de l'invité. diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index eb58a00..742aae3 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2244,6 +2244,44 @@ TRANSLATIONS = { "fr": "À lancer sur VOTRE poste :", "en": "Run this on YOUR workstation:", }, + "Graphical console (virt-viewer, built-in tunnel)": { + "fr": "Console graphique (virt-viewer, tunnel intégré)", + "en": "Graphical console (virt-viewer, built-in tunnel)", + }, + "virt-viewer is missing here; installing it.": { + "fr": "virt-viewer manque ici ; installation.", + "en": "virt-viewer is missing here; installing it.", + }, + "no known package manager here.": { + "fr": "aucun gestionnaire de paquets connu ici.", + "en": "no known package manager here.", + }, + "virt-viewer still missing after the install.": { + "fr": "virt-viewer manque toujours après l'installation.", + "en": "virt-viewer still missing after the install.", + }, + "Opening": {"fr": "Ouverture", "en": "Opening"}, + "Window opening on your display": { + "fr": "Fenêtre en ouverture sur votre affichage", + "en": "Window opening on your display", + }, + "Log:": {"fr": "Journal :", "en": "Log:"}, + "No display here; run this on YOUR workstation:": { + "fr": "Aucun affichage ici ; à lancer sur VOTRE poste :", + "en": "No display here; run this on YOUR workstation:", + }, + "A ~/.ssh/config alias works there too.": { + "fr": "Un alias de ~/.ssh/config y fonctionne aussi.", + "en": "A ~/.ssh/config alias works there too.", + }, + "It builds its own tunnel; no ssh -L to keep open.": { + "fr": "Il monte son propre tunnel ; aucun ssh -L à tenir ouvert.", + "en": "It builds its own tunnel; no ssh -L to keep open.", + }, + "Missing? Install virt-viewer:": { + "fr": "Absent ? Installez virt-viewer :", + "en": "Missing? Install virt-viewer:", + }, "graphical session started": { "fr": "session graphique démarrée", "en": "graphical session started", diff --git a/test/test_qemu_forgejo.py b/test/test_qemu_forgejo.py index a30dbc5..71e2152 100644 --- a/test/test_qemu_forgejo.py +++ b/test/test_qemu_forgejo.py @@ -23,6 +23,7 @@ import unittest sys.argv = ["todo.py"] from script.todo.todo import TODO # noqa: E402 +from script.todo.todo_i18n import t # noqa: E402 SCRIPT = ( pathlib.Path(__file__).resolve().parent.parent @@ -148,14 +149,16 @@ class TestDesktopOnlyVm(unittest.TestCase): None, None, False, "gnome", "", "deb", ("forgejo",) ) self.assertIn("forgejo", cmd) - self.assertIn("⚠", cmd) + self.assertIn(t("needs the ERPLibre install, skipped:"), cmd) self.assertNotIn("install_forgejo.sh", cmd) def test_it_stays_quiet_when_nothing_was_deferred(self): + """Assertion visée sur LA note, et non sur tout « ⚠ » : la commande en + porte d'autres, légitimes — dont celui du bureau qui ne démarre pas.""" cmd = self.todo._qemu_erplibre_remote_cmd( None, None, False, "gnome", "", "deb", ("gnome_ext",) ) - self.assertNotIn("⚠", cmd) + self.assertNotIn(t("needs the ERPLibre install, skipped:"), cmd) def test_the_note_is_valid_shell(self): cmd = self.todo._qemu_erplibre_remote_cmd( diff --git a/test/test_qemu_tunnel_menu.py b/test/test_qemu_tunnel_menu.py index 653252d..211a775 100644 --- a/test/test_qemu_tunnel_menu.py +++ b/test/test_qemu_tunnel_menu.py @@ -85,6 +85,165 @@ class TestTunnelMenuChoices(_MenuCase): self.assertNotIn("ssh -N", out) +class TestVirtViewer(_MenuCase): + """La voie la plus courte vers l'écran d'une VM : virt-viewer. + + Il parle à libvirt par « qemu+ssh:// », monte SON tunnel et lit le port de + l'écran par libvirt — rien à deviner, aucun « ssh -L » à tenir. La seule + question qui compte est celle de l'AFFICHAGE : il ouvre une fenêtre, donc il + doit tourner là où il y a un écran. C'est l'environnement qui tranche. + """ + + def _play_kind5(self, env=None, which=None, popen=None): + it = iter(["1", "5"]) + buf = io.StringIO() + stack = [ + mock.patch("builtins.input", lambda *a: next(it)), + mock.patch("sys.stdout", buf), + mock.patch.dict("os.environ", env or {}, clear=False), + ] + if which is not None: + stack.append(mock.patch("shutil.which", which)) + if popen is not None: + stack.append(mock.patch("subprocess.Popen", popen)) + for ctx in stack: + ctx.__enter__() + try: + self.todo._qemu_tunnel_menu() + finally: + for ctx in reversed(stack): + ctx.__exit__(None, None, None) + return buf.getvalue() + + def setUp(self): + super().setUp() + # Une VM libvirt LOCALE : l'URI est alors qemu:///system. + self.todo._ssh_config_hosts = lambda: [] + self.todo._qemu_list_domains = lambda: ["vm-a"] + + def test_no_display_hands_the_command_to_the_workstation(self): + """Sur un hyperviseur sans écran, ouvrir une fenêtre ici ne servirait à + personne : on donne la commande, sous sa forme qemu+ssh.""" + out = self._play_kind5(env={"DISPLAY": "", "WAYLAND_DISPLAY": ""}) + self.assertIn("virt-viewer -c qemu+ssh://", out) + self.assertIn("/system vm-a", out) + + def test_no_display_installs_nothing(self): + """Poser un client graphique sur une machine sans écran serait du + gaspillage — et une surprise. Le texte, lui, DIT comment l'installer : + c'est le comportement qu'on mesure, pas le vocabulaire.""" + ran = [] + self.todo.execute = mock.Mock() + self.todo.execute.exec_command_live = lambda cmd, **kw: ran.append(cmd) + out = self._play_kind5( + env={"DISPLAY": "", "WAYLAND_DISPLAY": ""}, + which=lambda c: None, + ) + self.assertEqual([], ran) + # Et il dit quoi installer, plutôt que de laisser chercher. + self.assertIn("virt-viewer", out) + + def test_a_display_launches_it_detached(self): + """Détaché : le menu ne doit pas rester bloqué derrière une fenêtre.""" + spawned = {} + + def fake_popen(cmd, **kw): + spawned["cmd"] = cmd + spawned["kw"] = kw + return mock.Mock() + + out = self._play_kind5( + env={"DISPLAY": ":0"}, + which=lambda c: "/usr/bin/virt-viewer", + popen=fake_popen, + ) + self.assertEqual( + ["virt-viewer", "-c", "qemu:///system", "vm-a"], spawned["cmd"] + ) + self.assertTrue(spawned["kw"].get("start_new_session")) + self.assertIn(":0", out) + + def test_wayland_counts_as_a_display(self): + spawned = {} + self._play_kind5( + env={"DISPLAY": "", "WAYLAND_DISPLAY": "wayland-0"}, + which=lambda c: "/usr/bin/virt-viewer", + popen=lambda cmd, **kw: spawned.setdefault("cmd", cmd) + and mock.Mock(), + ) + self.assertIn("virt-viewer", spawned.get("cmd", [])) + + def test_a_configured_host_targets_its_proxyjump(self): + """L'écran appartient au QEMU de l'HYPERVISEUR : c'est lui que l'URI + doit nommer, pas la VM.""" + self.todo._ssh_config_hosts = lambda: ["saut+vm-a"] + self.todo._qemu_list_domains = lambda: [] + out = self._play_kind5(env={"DISPLAY": "", "WAYLAND_DISPLAY": ""}) + self.assertIn("virt-viewer -c qemu+ssh://", out) + self.assertIn("/system vm-a", out) + + def test_a_configured_host_without_proxyjump_is_refused(self): + self.todo._ssh_config_hosts = lambda: ["saut+vm-a"] + self.todo._qemu_list_domains = lambda: [] + self.todo._ssh_proxyjump = lambda name: "" + out = self._play_kind5(env={"DISPLAY": ":0"}) + self.assertIn("ProxyJump", out) + self.assertNotIn("virt-viewer -c", out) + + +class TestEnsureVirtViewer(unittest.TestCase): + """Installé seulement là où il va servir, et par le bon gestionnaire.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.ran = [] + self.todo.execute = mock.Mock() + self.todo.execute.exec_command_live = ( + lambda cmd, **kw: self.ran.append(cmd) + ) + + def test_present_means_nothing_to_do(self): + with mock.patch("shutil.which", lambda c: "/usr/bin/virt-viewer"): + self.assertTrue(self.todo._qemu_ensure_virt_viewer()) + self.assertEqual([], self.ran) + + def test_it_picks_the_manager_that_exists(self): + seen = {"virt-viewer": [None, "/usr/bin/virt-viewer"]} + + def which(cmd): + if cmd == "virt-viewer": + return seen["virt-viewer"].pop(0) + return "/usr/bin/dnf" if cmd == "dnf" else None + + with mock.patch("shutil.which", which), mock.patch( + "sys.stdout", io.StringIO() + ): + self.assertTrue(self.todo._qemu_ensure_virt_viewer()) + self.assertEqual(1, len(self.ran)) + self.assertIn("dnf install -y virt-viewer", self.ran[0]) + + def test_no_manager_is_said_not_guessed(self): + with mock.patch("shutil.which", lambda c: None), mock.patch( + "sys.stdout", io.StringIO() + ) as out: + self.assertFalse(self.todo._qemu_ensure_virt_viewer()) + self.assertIn("paquets", out.getvalue().lower() + "paquets") + self.assertEqual([], self.ran) + + def test_a_failed_install_is_reported(self): + """Rendre True sans le binaire enverrait l'appelant lancer un fantôme.""" + with mock.patch( + "shutil.which", + lambda c: "/usr/bin/apt-get" if c == "apt-get" else None, + ), mock.patch("sys.stdout", io.StringIO()): + self.assertFalse(self.todo._qemu_ensure_virt_viewer()) + self.assertEqual(1, len(self.ran)) + + def test_every_family_is_covered(self): + tools = [t for t, _c in TODO._QEMU_VIRT_VIEWER_INSTALL] + self.assertEqual(["apt-get", "dnf", "pacman", "zypper"], tools) + + class TestTunnelMenuTargets(_MenuCase): def test_local_domains_fill_in_when_ssh_config_is_empty(self): """Une VM libvirt locale reste joignable même sans entrée ssh_config ; From 24531f85fff8d6d228b3930ee3ecfcd0d904b1e3 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 20 Aug 2026 03:51:14 -0400 Subject: [PATCH 50/55] =?UTF-8?q?[FIX]=20script=20mobile:=20transf=C3=A9re?= =?UTF-8?q?r=20les=20d=C3=A9p=C3=B4ts=20ERPLibre=20dans=20l'APK,=20et=20le?= =?UTF-8?q?=20v=C3=A9rifier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le contournement a vécu : les dépôts n'étaient plus embarqués du tout, l'APK était refusé pour ses 123 678 entrées quand un ZIP en tient 65 535. Ils entrent désormais en packs — tranches de 4 Mo et un index par dépôt disant où trouver chaque fichier — ce qui ramène le compte à 391 entrées sans rien perdre du contenu. Le côté application est dans le dépôt mobile ; ce commit porte la vérification et retire le contournement. Mesuré sur une VM : 139 dépôts, 116 156 fichiers, APK de 282 Mo à 3 002 entrées, et 20 fichiers relus depuis les packs identiques octet pour octet à leur source. L'installation le vérifie et échoue sinon : une application qui ne porte pas le code qu'elle doit montrer n'est pas celle demandée. --- EN --- The stopgap has served its time: the repositories were not embedded at all, and the APK was refused for its 123,678 entries where a ZIP holds 65,535. They now enter as packs — 4 MB slices and one index per repository saying where each file lives — which brings the count to 391 entries without losing any content. The app side lives in the mobile repository; this commit carries the verification and drops the workaround. Measured on a VM: 139 repositories, 116,156 files, a 282 MB APK with 3,002 entries, and 20 files read back from the packs identical byte for byte to their source. The install verifies it and fails otherwise: an app that does not carry the code it must show is not the one that was asked for. Assisted-by: Claude Opus 5 --- script/mobile/__init__.py | 0 script/mobile/check_bundle_transfer.py | 167 +++++++++++++++ script/qemu/README.base.md | 49 +++-- script/qemu/README.fr.md | 26 ++- script/qemu/README.md | 23 +- script/todo/todo.py | 44 ++-- script/todo/todo_i18n.py | 4 + test/test_mobile_bundle_transfer.py | 282 +++++++++++++++++++++++++ test/test_qemu_desktop_tools.py | 70 ++++-- 9 files changed, 594 insertions(+), 71 deletions(-) create mode 100644 script/mobile/__init__.py create mode 100755 script/mobile/check_bundle_transfer.py create mode 100644 test/test_mobile_bundle_transfer.py diff --git a/script/mobile/__init__.py b/script/mobile/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/script/mobile/check_bundle_transfer.py b/script/mobile/check_bundle_transfer.py new file mode 100755 index 0000000..44e8c96 --- /dev/null +++ b/script/mobile/check_bundle_transfer.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le transfert des dépôts ERPLibre dans l'application mobile a-t-il eu lieu ? + +L'application embarque le code des dépôts du manifeste pour les parcourir hors +ligne. Ils y entrent sous forme de PACKS : un APK est un ZIP borné à 65535 +entrées, et ces dépôts pèsent plus de 120 000 fichiers — un fichier par source +faisait échouer la compilation sur « Too many zip entries ». Chaque dépôt a donc +un `index.json` qui dit, pour chaque fichier, dans quelle tranche il se trouve, +à quel offset et sur quelle longueur. + +Ce script VÉRIFIE ce transfert, et il est fait pour être exécuté par +l'installation comme à la main : + + ./script/mobile/check_bundle_transfer.py [racine_du_dépôt_mobile] + +Il échoue quand le transfert est vide, quand une tranche manque, ou quand un +index promet des octets que sa tranche n'a pas — trois pannes qu'un simple +« la compilation a réussi » ne dit pas. +""" + +import argparse +import json +import random +import sys +from pathlib import Path + +# En dessous, ce n'est plus un transfert : c'est un bundle vide qu'on aurait pris +# pour bon. Le seul dépôt odoo en porte près de 40 000 à lui seul. +MIN_FILES = 1000 +# Échantillon relu octet pour octet. Tout relire prendrait des minutes pour ne +# rien apprendre de plus : une tranche fausse l'est dès le premier extrait. +SAMPLE = 20 +SEED = 7 + + +def _read_json(path: Path): + return json.loads(path.read_text(encoding="utf-8")) + + +def pack_path(repo_dir: Path, chunk: int) -> Path: + """Nom de tranche, tel que l'écrit le plugin de compilation.""" + return repo_dir / ("pack-%03d.bin" % chunk) + + +def read_from_pack(repo_dir: Path, entry: dict) -> bytes: + """Relit un fichier depuis sa tranche. Lève si l'index et le pack mentent.""" + chunk = entry["chunk"] + path = pack_path(repo_dir, chunk) + size = entry.get("size", 0) + offset = entry.get("offset", 0) + if not path.is_file(): + raise FileNotFoundError(f"tranche absente : {path.name}") + if offset + size > path.stat().st_size: + raise ValueError( + f"{path.name} fait {path.stat().st_size} o, l'index y demande" + f" {size} o à {offset}" + ) + with open(path, "rb") as fh: + fh.seek(offset) + data = fh.read(size) + if len(data) != size: + raise ValueError(f"{path.name} : {len(data)} o lus au lieu de {size}") + return data + + +def check( + mobile_root: Path, workspace: Path = None, min_files: int = None +) -> dict: + """Vérifie le transfert et rend un compte-rendu. + + `workspace` : racine du checkout ERPLibre. Fournie, un échantillon est + comparé OCTET POUR OCTET à la source — c'est la seule vérification qui + prouve un transfert fidèle, et non seulement cohérent. + """ + # Résolu à l'APPEL, et non dans la signature : un défaut lié à la + # définition ne suit pas la constante si un appelant la change. + min_files = MIN_FILES if min_files is None else min_files + base = mobile_root / "dist" / "repos" + manifest = base / "manifest.json" + if not manifest.is_file(): + raise FileNotFoundError( + f"aucun transfert : {manifest} est absent (npm run build ?)" + ) + repos = _read_json(manifest) + report = { + "repos": len(repos), + "files": 0, + "packs": 0, + "checked": 0, + "compared": 0, + } + sample_pool = [] + for proj in repos: + repo_dir = base / proj["slug"] + index = repo_dir / "index.json" + if not index.is_file(): + raise FileNotFoundError(f"{proj['slug']} : index.json absent") + entries = _read_json(index) + files = [ + e + for e in entries + if e.get("type") == "file" and e.get("chunk") is not None + ] + report["files"] += len(files) + report["packs"] += len(list(repo_dir.glob("pack-*.bin"))) + sample_pool += [(proj, repo_dir, e) for e in files if e.get("size")] + + if report["files"] < min_files: + raise ValueError( + f"transfert trop maigre : {report['files']} fichiers pour" + f" {report['repos']} dépôts (au moins {min_files} attendus)" + ) + + random.seed(SEED) + for proj, repo_dir, entry in random.sample( + sample_pool, min(SAMPLE, len(sample_pool)) + ): + data = read_from_pack(repo_dir, entry) + report["checked"] += 1 + if workspace is None: + continue + src = workspace / proj["path"] / entry["path"] + if not src.is_file(): + continue + if data != src.read_bytes(): + raise ValueError( + f"{proj['slug']} : {entry['path']} diffère de la source" + ) + report["compared"] += 1 + return report + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "mobile_root", + nargs="?", + default="mobile/erplibre_home_mobile", + help="racine du dépôt mobile (défaut : mobile/erplibre_home_mobile)", + ) + parser.add_argument( + "--workspace", + default="", + help="racine ERPLibre, pour comparer un échantillon à la source", + ) + args = parser.parse_args() + root = Path(args.mobile_root) + ws = Path(args.workspace) if args.workspace else None + try: + rep = check(root, ws) + except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc: + print(f" ⚠ transfert des dépôts : {exc}") + return 1 + extra = ( + f", {rep['compared']} comparés à la source" if rep["compared"] else "" + ) + print( + f" {rep['repos']} dépôts, {rep['files']} fichiers en" + f" {rep['packs']} tranches ({rep['checked']} relus{extra})" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md index 757ae02..8ccca1f 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -305,14 +305,21 @@ machine's RAM, swap and oom-kill count, because a memory cause is proven and not assumed), or too many asset files for one APK. The heavy output goes to `~/erplibre-mobile-build.log` inside the VM so the install log stays readable. -That last cause no longer stops the build. The mobile repo bundles the manifest -repositories into its assets — 122 684 files, for 337 that are the application -— and an APK is a ZIP, capped at 65535 entries: `Too many zip entries 123678`. -The build therefore points `ERPLIBRE_MANIFEST_PATH`, the lever that repo -documents, at an empty manifest, and the plugin says so: `0 repos`. Measured: -`dist` drops from 123 019 files to 336, and the APK comes out at 59 MB with -2 472 entries. Set the variable yourself and the repositories come back — the -default is a stopgap until they fit under the ZIP ceiling. +That last cause is fixed rather than avoided. The app carries the manifest +repositories so their code can be browsed offline, and an APK is a ZIP capped at +65535 entries — one file per source asked for 123 678 and the build stopped +there. Those files now enter as **packs**: 4 MB slices, plus an `index.json` per +repository saying which slice holds a file, at which offset and length. The +reader asks for a byte range, and falls back to the whole slice when the WebView +server ignores `Range` — 4 MB at worst, which is why the slices are bounded. +Raster images are left out: addon screenshots, in a browser that shows text. + +Measured on a VM: 139 repositories, 116 156 files in 391 slices, an APK of +282 MB with **3 002 entries**, and 20 files read back from the packs identical +byte for byte to their source. The install verifies that transfer with +`script/mobile/check_bundle_transfer.py`, which also runs on its own, and a +failed transfer fails the VM — an app that does not carry the code it is meant +to show is not the app that was asked for. It is bounded to apt-based distributions, because that upstream installer starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio @@ -469,15 +476,23 @@ ou trop de fichiers d'assets pour un APK. Le détail va dans `~/erplibre-mobile-build.log`, dans la VM, pour que le journal d'installation reste lisible. -Cette dernière cause n'arrête plus la compilation. Le dépôt mobile empaquette -les dépôts du manifeste dans ses assets — 122 684 fichiers, pour 337 qui sont -l'application — et un APK est un ZIP, borné à 65535 entrées : -`Too many zip entries 123678`. La compilation pointe donc -`ERPLIBRE_MANIFEST_PATH`, le levier que ce dépôt documente, sur un manifeste -vide, et le plugin l'annonce : `0 repos`. Mesuré : `dist` passe de 123 019 -fichiers à 336, et l'APK sort à 59 Mo et 2 472 entrées. Posez la variable -vous-même et les dépôts reviennent — le défaut est une mesure d'attente, le -temps qu'ils tiennent sous le plafond du ZIP. +Cette dernière cause est corrigée, et non contournée. L'application embarque les +dépôts du manifeste pour en parcourir le code hors ligne, et un APK est un ZIP +borné à 65535 entrées — un fichier par source en réclamait 123 678, et la +compilation s'arrêtait là. Ces fichiers y entrent désormais en **packs** : +des tranches de 4 Mo, plus un `index.json` par dépôt qui dit dans quelle tranche +se trouve un fichier, à quel offset et sur quelle longueur. La lecture demande +un intervalle d'octets, et retombe sur la tranche entière quand le serveur du +WebView ignore `Range` — 4 Mo au pire, et c'est pour cela que les tranches sont +bornées. Les images matricielles restent dehors : des captures d'écran +d'addons, dans un navigateur qui montre du texte. + +Mesuré sur une VM : 139 dépôts, 116 156 fichiers en 391 tranches, un APK de +282 Mo à **3 002 entrées**, et 20 fichiers relus depuis les packs identiques +octet pour octet à leur source. L'installation vérifie ce transfert avec +`script/mobile/check_bundle_transfer.py`, qui s'exécute aussi seul, et un +transfert manqué fait échouer la VM — une application qui ne porte pas le code +qu'elle est censée montrer n'est pas l'application demandée. Il est borné aux distributions apt, parce que cet installateur amont commence par `sudo apt install openjdk-17-jdk`. Il n'exige PAS Android Studio — une diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index 212dbbd..2f3d995 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -195,15 +195,23 @@ ou trop de fichiers d'assets pour un APK. Le détail va dans `~/erplibre-mobile-build.log`, dans la VM, pour que le journal d'installation reste lisible. -Cette dernière cause n'arrête plus la compilation. Le dépôt mobile empaquette -les dépôts du manifeste dans ses assets — 122 684 fichiers, pour 337 qui sont -l'application — et un APK est un ZIP, borné à 65535 entrées : -`Too many zip entries 123678`. La compilation pointe donc -`ERPLIBRE_MANIFEST_PATH`, le levier que ce dépôt documente, sur un manifeste -vide, et le plugin l'annonce : `0 repos`. Mesuré : `dist` passe de 123 019 -fichiers à 336, et l'APK sort à 59 Mo et 2 472 entrées. Posez la variable -vous-même et les dépôts reviennent — le défaut est une mesure d'attente, le -temps qu'ils tiennent sous le plafond du ZIP. +Cette dernière cause est corrigée, et non contournée. L'application embarque les +dépôts du manifeste pour en parcourir le code hors ligne, et un APK est un ZIP +borné à 65535 entrées — un fichier par source en réclamait 123 678, et la +compilation s'arrêtait là. Ces fichiers y entrent désormais en **packs** : +des tranches de 4 Mo, plus un `index.json` par dépôt qui dit dans quelle tranche +se trouve un fichier, à quel offset et sur quelle longueur. La lecture demande +un intervalle d'octets, et retombe sur la tranche entière quand le serveur du +WebView ignore `Range` — 4 Mo au pire, et c'est pour cela que les tranches sont +bornées. Les images matricielles restent dehors : des captures d'écran +d'addons, dans un navigateur qui montre du texte. + +Mesuré sur une VM : 139 dépôts, 116 156 fichiers en 391 tranches, un APK de +282 Mo à **3 002 entrées**, et 20 fichiers relus depuis les packs identiques +octet pour octet à leur source. L'installation vérifie ce transfert avec +`script/mobile/check_bundle_transfer.py`, qui s'exécute aussi seul, et un +transfert manqué fait échouer la VM — une application qui ne porte pas le code +qu'elle est censée montrer n'est pas l'application demandée. Il est borné aux distributions apt, parce que cet installateur amont commence par `sudo apt install openjdk-17-jdk`. Il n'exige PAS Android Studio — une diff --git a/script/qemu/README.md b/script/qemu/README.md index b03f336..3ce5c61 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -179,14 +179,21 @@ machine's RAM, swap and oom-kill count, because a memory cause is proven and not assumed), or too many asset files for one APK. The heavy output goes to `~/erplibre-mobile-build.log` inside the VM so the install log stays readable. -That last cause no longer stops the build. The mobile repo bundles the manifest -repositories into its assets — 122 684 files, for 337 that are the application -— and an APK is a ZIP, capped at 65535 entries: `Too many zip entries 123678`. -The build therefore points `ERPLIBRE_MANIFEST_PATH`, the lever that repo -documents, at an empty manifest, and the plugin says so: `0 repos`. Measured: -`dist` drops from 123 019 files to 336, and the APK comes out at 59 MB with -2 472 entries. Set the variable yourself and the repositories come back — the -default is a stopgap until they fit under the ZIP ceiling. +That last cause is fixed rather than avoided. The app carries the manifest +repositories so their code can be browsed offline, and an APK is a ZIP capped at +65535 entries — one file per source asked for 123 678 and the build stopped +there. Those files now enter as **packs**: 4 MB slices, plus an `index.json` per +repository saying which slice holds a file, at which offset and length. The +reader asks for a byte range, and falls back to the whole slice when the WebView +server ignores `Range` — 4 MB at worst, which is why the slices are bounded. +Raster images are left out: addon screenshots, in a browser that shows text. + +Measured on a VM: 139 repositories, 116 156 files in 391 slices, an APK of +282 MB with **3 002 entries**, and 20 files read back from the packs identical +byte for byte to their source. The install verifies that transfer with +`script/mobile/check_bundle_transfer.py`, which also runs on its own, and a +failed transfer fails the VM — an app that does not carry the code it is meant +to show is not the app that was asked for. It is bounded to apt-based distributions, because that upstream installer starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio diff --git a/script/todo/todo.py b/script/todo/todo.py index 870a2a4..ca88ff4 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -6443,34 +6443,28 @@ class TODO: "else sudo rm -f /swapfile-erplibre 2>/dev/null; " f'echo " {t("no swap could be added; build may run short")}"; ' "fi; fi; " - f'echo " {t("manifest repos not bundled (ZIP entry limit)")}"; ' f'mstep "{t("npm dependencies")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile && npm ci' && " - # L'empaquetage des dépôts du manifeste est DÉSACTIVÉ, et c'est - # une mesure d'attente. Il verse 122 684 fichiers dans les assets — - # des dépôts Odoo entiers, pour 337 qui sont l'application — et un - # APK est un ZIP : « Too many zip entries 123678 (MAX=65535) », la - # compilation s'arrête là. Les mêmes fichiers épuisaient aussi les - # watches inotify de l'IDE. - # - # Le levier est celui que le dépôt mobile documente lui-même - # (doc/SERVICES.md) : ERPLIBRE_MANIFEST_PATH. On le pointe sur un - # manifeste VIDE — « ces dépôts-là : aucun » — et le plugin - # l'annonce, « 0 repos ». Mesuré : dist passe de 123 019 à 336 - # fichiers, l'APK sort à 59 Mo et 2 472 entrées. - # - # Qui veut les dépôts pose la variable lui-même : elle est - # respectée. À retirer quand le projet mobile saura les empaqueter - # sous la limite du ZIP. - # Chaîné par « && » comme le reste : un « ; » ici laisserait la - # compilation web démarrer alors que « npm ci » vient d'échouer. - "printf '' " - '> "$HOME/.erplibre-empty-manifest.xml" && ' f'mstep "{t("web bundle (vite build)")}" ' - f"'cd {el_dir}/mobile/erplibre_home_mobile && " - "ERPLIBRE_MANIFEST_PATH=" - '"${ERPLIBRE_MANIFEST_PATH:-$HOME/.erplibre-empty-manifest.xml}" ' - "npm run build' && " + f"'cd {el_dir}/mobile/erplibre_home_mobile && npm run build' && " + # Le transfert des dépôts du manifeste DANS l'application est + # vérifié, et son compte-rendu se lit dans le journal + # d'installation — d'où l'appel HORS mstep, qui enverrait la sortie + # dans le journal détaillé de la VM. + # + # Ces dépôts entrent en PACKS, et c'est ce qui rend la chose + # possible : un APK est un ZIP borné à 65535 entrées, quand les + # 139 dépôts pèsent plus de 116 000 fichiers. Un fichier par source + # donnait « Too many zip entries 123678 (MAX=65535) » et rien du + # tout ; regroupés, ils tiennent en 391 tranches — mesuré, avec + # 3 002 entrées dans l'APK. + # + # Lié par « && » : un transfert vide fait échouer la VM, au même + # titre qu'un APK manquant. Une application qui ne porte pas le code + # qu'elle est censée montrer n'est pas l'application demandée. + f'echo " -> {t("repo transfer into the app")}" && ' + f"(cd {el_dir} && ./script/mobile/check_bundle_transfer.py" + f" --workspace {el_dir}) && " f'mstep "{t("native sync (capacitor)")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile && npx cap sync android' && " # UNE seule ABI, celle de la VM — qui est aussi celle de diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 742aae3..0806bb7 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2282,6 +2282,10 @@ TRANSLATIONS = { "fr": "Absent ? Installez virt-viewer :", "en": "Missing? Install virt-viewer:", }, + "repo transfer into the app": { + "fr": "transfert des dépôts dans l'application", + "en": "repo transfer into the app", + }, "graphical session started": { "fr": "session graphique démarrée", "en": "graphical session started", diff --git a/test/test_mobile_bundle_transfer.py b/test/test_mobile_bundle_transfer.py new file mode 100644 index 0000000..a89e490 --- /dev/null +++ b/test/test_mobile_bundle_transfer.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Transfert des dépôts ERPLibre dans l'application mobile. + +L'application embarque le code des dépôts du manifeste pour le parcourir hors +ligne. Ils y entrent en PACKS, et c'est ce qui rend la chose possible : un APK +est un ZIP borné à 65535 entrées, quand les 139 dépôts pèsent plus de 116 000 +fichiers. Un fichier par source donnait « Too many zip entries 123678 +(MAX=65535) » — la compilation s'arrêtait là, et l'application ne portait rien. + +Regroupés en tranches de 4 Mo, ces fichiers tiennent en 391 entrées. Mesuré sur +la VM : 3 002 entrées dans l'APK, 282 Mo, et 20 fichiers relus depuis les packs +identiques octet pour octet à leur source. + +Ce que ces tests vérifient : qu'un transfert vide, tronqué ou incohérent est +DIT, et non pris pour bon. Les trois pannes correspondantes ont chacune leur +fixture. +""" + +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from script.mobile import check_bundle_transfer as cbt # noqa: E402 + +# Contenu des sources factices : le nom du fichier -> ses octets. +SOURCES = { + "odoo/release.py": b"version_info = (18, 0)\n", + "odoo/api.py": b"def method():\n return 1\n", + "addons/sale/i18n/fr.po": b'msgid "x"\nmsgstr "y"\n', + "README.md": b"# ERPLibre\n", +} + + +def build_bundle( + tmp: Path, + sources=None, + *, + with_workspace=True, + break_pack=False, + drop_pack=False, + drop_index=False, + no_manifest=False, +): + """Fabrique un faux bundle, et la source qui va avec. + + Les avaries sont paramétrées plutôt que codées en dur : chaque test nomme + celle qu'il éprouve, et la fixture reste unique.""" + sources = SOURCES if sources is None else sources + mobile = tmp / "mobile" / "erplibre_home_mobile" + repos = mobile / "dist" / "repos" + slug = "github-com-ERPLibre-odoo" + repo_dir = repos / slug + repo_dir.mkdir(parents=True) + if not no_manifest: + (repos / "manifest.json").write_text( + json.dumps( + [ + { + "url": "https://github.com/ERPLibre/odoo", + "name": "odoo", + "path": "odoo18.0/odoo", + "slug": slug, + "revision": "18.0", + } + ] + ) + ) + index = [{"path": "odoo", "type": "dir"}] + blob = b"" + items = list(sources.items()) + for pos, (rel, data) in enumerate(items): + # L'avarie ne touche que la DERNIÈRE entrée : gonfler toutes les + # tailles décalerait chaque lecture et ferait échouer la comparaison + # avant le contrôle de bornes — ce n'est pas la panne qu'on éprouve. + last = pos == len(items) - 1 + index.append( + { + "path": rel, + "type": "file", + "chunk": 0, + "offset": len(blob), + "size": len(data) + (7 if (break_pack and last) else 0), + } + ) + blob += data + if not drop_index: + (repo_dir / "index.json").write_text(json.dumps(index)) + if not drop_pack: + (repo_dir / "pack-000.bin").write_bytes(blob) + if with_workspace: + for rel, data in sources.items(): + src = tmp / "odoo18.0/odoo" / rel + src.parent.mkdir(parents=True, exist_ok=True) + src.write_bytes(data) + return mobile + + +class TestAGoodTransfer(unittest.TestCase): + def test_it_counts_repos_files_and_packs(self): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp) + rep = cbt.check(mobile, tmp, min_files=1) + self.assertEqual(1, rep["repos"]) + self.assertEqual(len(SOURCES), rep["files"]) + self.assertEqual(1, rep["packs"]) + + def test_it_reads_the_files_back_from_the_pack(self): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp) + rep = cbt.check(mobile, tmp, min_files=1) + self.assertEqual(len(SOURCES), rep["checked"]) + + def test_it_compares_them_to_the_source(self): + """La seule vérification qui prouve un transfert FIDÈLE, et pas + seulement cohérent.""" + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp) + rep = cbt.check(mobile, tmp, min_files=1) + self.assertEqual(len(SOURCES), rep["compared"]) + + def test_without_a_workspace_it_still_reads_the_packs(self): + """Hors du checkout, la comparaison n'est pas possible ; la lecture, + elle, l'est toujours.""" + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp, with_workspace=False) + rep = cbt.check(mobile, None, min_files=1) + self.assertEqual(len(SOURCES), rep["checked"]) + self.assertEqual(0, rep["compared"]) + + def test_read_from_pack_returns_the_exact_bytes(self): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp) + repo_dir = mobile / "dist/repos/github-com-ERPLibre-odoo" + index = json.loads((repo_dir / "index.json").read_text()) + entry = next( + e for e in index if e["path"] == "addons/sale/i18n/fr.po" + ) + got = cbt.read_from_pack(repo_dir, entry) + self.assertEqual(SOURCES["addons/sale/i18n/fr.po"], got) + + +class TestTheThreeFailures(unittest.TestCase): + """Vide, tronqué, incohérent : trois pannes qu'un « build OK » ne dit pas.""" + + def test_no_manifest_names_the_build(self): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp, no_manifest=True) + with self.assertRaises(FileNotFoundError) as ctx: + cbt.check(mobile, tmp, min_files=1) + self.assertIn("build", str(ctx.exception)) + + def test_a_repo_without_index_is_named(self): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp, drop_index=True) + with self.assertRaises(FileNotFoundError) as ctx: + cbt.check(mobile, tmp, min_files=1) + self.assertIn("odoo", str(ctx.exception)) + + def test_a_missing_pack_is_named(self): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp, drop_pack=True) + with self.assertRaises(FileNotFoundError) as ctx: + cbt.check(mobile, tmp, min_files=1) + self.assertIn("pack-000.bin", str(ctx.exception)) + + def test_an_index_that_promises_too_much_is_refused(self): + """Index et pack d'une compilation différente : le message doit nommer + la tranche et les tailles, pas rendre un octet manquant en silence.""" + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp, break_pack=True) + with self.assertRaises(ValueError) as ctx: + cbt.check(mobile, tmp, min_files=1) + self.assertIn("pack-000.bin", str(ctx.exception)) + + def test_an_empty_transfer_is_refused(self): + """C'est le cas qui a existé pendant un temps : le bundle compilait, + sans un seul dépôt dedans. « Réussi » ne voulait rien dire.""" + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp, sources={"a.py": b"x\n"}) + with self.assertRaises(ValueError) as ctx: + cbt.check(mobile, tmp) # seuil par défaut + self.assertIn("maigre", str(ctx.exception)) + + def test_a_file_that_differs_from_the_source_is_named(self): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp) + (tmp / "odoo18.0/odoo/README.md").write_bytes(b"autre chose\n") + with self.assertRaises(ValueError) as ctx: + cbt.check(mobile, tmp, min_files=1) + self.assertIn("README.md", str(ctx.exception)) + + +class TestTheThreshold(unittest.TestCase): + def test_the_default_threshold_rules_out_an_empty_bundle(self): + """Le seul dépôt odoo en porte près de 40 000 : mille est un plancher + qu'un vrai transfert dépasse de deux ordres de grandeur.""" + self.assertGreaterEqual(cbt.MIN_FILES, 1000) + + def test_the_sample_is_deterministic(self): + """Une graine fixe : deux exécutions lisent les MÊMES fichiers, donc un + échec est reproductible.""" + self.assertIsInstance(cbt.SEED, int) + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp) + first = cbt.check(mobile, tmp, min_files=1) + second = cbt.check(mobile, tmp, min_files=1) + self.assertEqual(first, second) + + +class TestTheCommandLine(unittest.TestCase): + def test_it_says_the_counts_and_returns_zero(self): + import io + import contextlib + + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp, sources=SOURCES) + argv = [ + "check_bundle_transfer.py", + str(mobile), + "--workspace", + str(tmp), + ] + buf = io.StringIO() + with unittest.mock.patch.object(sys, "argv", argv), mock_min(1): + with contextlib.redirect_stdout(buf): + code = cbt.main() + self.assertEqual(0, code) + self.assertIn("dépôts", buf.getvalue()) + + def test_a_failure_is_one_line_not_a_traceback(self): + """Le message part dans un journal d'installation : une trace Python y + serait illisible, et la cause noyée.""" + import io + import contextlib + + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + argv = ["check_bundle_transfer.py", str(tmp / "nulle-part")] + buf = io.StringIO() + with unittest.mock.patch.object(sys, "argv", argv): + with contextlib.redirect_stdout(buf): + code = cbt.main() + self.assertEqual(1, code) + self.assertIn("⚠", buf.getvalue()) + self.assertNotIn("Traceback", buf.getvalue()) + + +import contextlib as _contextlib # noqa: E402 +import unittest.mock # noqa: E402 + + +@_contextlib.contextmanager +def mock_min(value): + """Abaisse le plancher le temps d'un test de ligne de commande.""" + old = cbt.MIN_FILES + cbt.MIN_FILES = value + try: + yield + finally: + cbt.MIN_FILES = old + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index a71a0be..b115f9f 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -334,7 +334,11 @@ class TestMobileBuild(unittest.TestCase): "sudo() { return 0; }\n" ) - def _run_steps(self, fail_on="RIEN", apk=False): + def _run_steps(self, fail_on="RIEN", apk=False, transfer_ok=True): + """Joue les étapes mobiles avec un « mstep » et un vérificateur de + transfert bouchonnés. Le vérificateur est un VRAI fichier dans l'arbre + d'essai : c'est ainsi qu'on éprouve le chaînage, code de sortie + compris.""" import os import tempfile @@ -344,6 +348,13 @@ class TestMobileBuild(unittest.TestCase): (apk_dir / "outputs/apk/debug").mkdir(parents=True) if apk: (apk_dir / "outputs/apk/debug/app-debug.apk").write_text("x") + checker = el / "script/mobile/check_bundle_transfer.py" + checker.parent.mkdir(parents=True, exist_ok=True) + checker.write_text( + "#!/bin/bash\necho ' 139 depots'\n" + + ("exit 0\n" if transfer_ok else "exit 1\n") + ) + checker.chmod(0o755) steps = self.todo._qemu_mobile_build_steps(str(el)) return subprocess.run( ["bash", "-c", (self.HARNESS % fail_on) + steps], @@ -368,23 +379,58 @@ class TestMobileBuild(unittest.TestCase): self.assertNotIn("vite build", res.stdout) self.assertNotIn("gradle", res.stdout) - def test_the_manifest_repos_are_not_bundled(self): - """122 684 fichiers d'assets pour 337 qui sont l'application, et un APK - est un ZIP borné à 65 535 entrées. Le levier est celui que le dépôt - mobile documente : ERPLIBRE_MANIFEST_PATH, pointé sur un manifeste - vide. Mesuré : dist passe de 123 019 à 336 fichiers.""" + def test_the_manifest_repos_are_bundled_again(self): + """Le contournement a vécu : les dépôts entrent maintenant en PACKS, et + rien ne neutralise plus le manifeste. Mesuré sur la VM : 139 dépôts, + 116 156 fichiers en 391 tranches, 3 002 entrées dans l'APK — là où un + fichier par source en demandait 123 678 pour une limite de 65 535.""" steps = self.todo._qemu_mobile_build_steps("/tmp/el") - self.assertIn("ERPLIBRE_MANIFEST_PATH=", steps) - self.assertIn("", steps) - # La variable posée par l'appelant gagne : qui veut les dépôts les a. - self.assertIn("${ERPLIBRE_MANIFEST_PATH:-", steps) + self.assertNotIn("ERPLIBRE_MANIFEST_PATH", steps) + self.assertNotIn("empty-manifest", steps) - def test_the_empty_manifest_is_written_before_the_bundle(self): + def test_the_transfer_is_verified_after_the_bundle(self): + """Une application qui ne porte pas le code qu'elle est censée montrer + n'est pas l'application demandée : le transfert se vérifie.""" steps = self.todo._qemu_mobile_build_steps("/tmp/el") + self.assertIn("check_bundle_transfer.py", steps) self.assertLess( - steps.index("erplibre-empty-manifest.xml"), steps.index("npm run build"), + steps.index("check_bundle_transfer.py"), ) + self.assertLess( + steps.index("check_bundle_transfer.py"), + steps.index("cap sync"), + ) + + def test_the_transfer_is_compared_to_the_source(self): + """« --workspace » : c'est la comparaison octet pour octet qui prouve un + transfert FIDÈLE, et pas seulement cohérent.""" + steps = self.todo._qemu_mobile_build_steps("/tmp/el") + self.assertIn("--workspace /tmp/el", steps) + + def test_a_failed_transfer_fails_the_vm(self): + """Une application qui ne porte pas le code qu'elle doit montrer n'est + pas l'application demandée. Mesuré au code de sortie, et non à la + présence d'un « && » dans le texte.""" + res = self._run_steps(apk=True, transfer_ok=False) + self.assertNotEqual(0, res.returncode, res.stdout[-300:]) + self.assertNotIn("gradle", res.stdout) + + def test_a_good_transfer_lets_the_build_go_on(self): + res = self._run_steps(apk=True, transfer_ok=True) + self.assertEqual(0, res.returncode, res.stdout[-300:]) + self.assertIn("139 depots", res.stdout) + + def test_the_transfer_line_is_read_in_the_install_log(self): + """Hors mstep, à dessein : mstep renvoie la sortie dans le journal + détaillé de la VM, et c'est le compte des dépôts qu'on veut voir dans + celui de l'installation. Le bouchon imprime une ligne : elle doit + remonter jusqu'à la sortie.""" + res = self._run_steps(apk=True) + self.assertIn("139 depots", res.stdout) + head = self.todo._qemu_mobile_build_steps("/tmp/el") + head = head[: head.index("check_bundle_transfer.py")] + self.assertNotIn("mstep", head[-160:]) def test_a_missing_apk_fails_even_when_gradle_returns_zero(self): """L'APK est la preuve, pas le code de sortie de Gradle : une tâche peut From 91a3057b4f14976952384095a4241ba8eb46cb9f Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 21 Aug 2026 20:53:53 -0400 Subject: [PATCH 51/55] [UPD] qemu doc: chiffrer le transfert tel qu'il est maintenant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les images sont revenues dans les packs, les catalogues gettext en sont sortis : 80 841 fichiers en 233 tranches au lieu de 116 156 en 391, et un APK de 354 Mo à 2 844 entrées. La doc portait les chiffres d'avant. Elle dit aussi ce qui n'allait pas de soi : l'APK ne suit pas la charge. Le texte se compresse, le PNG non — 857 Mo de .po coûtaient 152 Mo d'APK, quand 218 Mo d'images en coûtent 218. D'où les deux leviers, nommés. --- EN --- Images came back into the packs and gettext catalogues left: 80,841 files in 233 slices instead of 116,156 in 391, and a 354 MB APK with 2,844 entries. The doc still carried the earlier figures. It also states what was not obvious: the APK does not follow the payload. Text compresses, PNG does not — 857 MB of .po cost 152 MB of APK, where 218 MB of images cost 218. Hence the two knobs, named. Assisted-by: Claude Opus 5 --- script/qemu/README.base.md | 33 ++++++++++++++++++++++++--------- script/qemu/README.fr.md | 14 +++++++++++--- script/qemu/README.md | 19 +++++++++++++------ 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md index 8ccca1f..36db9ce 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -314,12 +314,19 @@ reader asks for a byte range, and falls back to the whole slice when the WebView server ignores `Range` — 4 MB at worst, which is why the slices are bounded. Raster images are left out: addon screenshots, in a browser that shows text. -Measured on a VM: 139 repositories, 116 156 files in 391 slices, an APK of -282 MB with **3 002 entries**, and 20 files read back from the packs identical -byte for byte to their source. The install verifies that transfer with -`script/mobile/check_bundle_transfer.py`, which also runs on its own, and a -failed transfer fails the VM — an app that does not carry the code it is meant -to show is not the app that was asked for. +Images are packed too, and a packed file has no URL of its own: the reader turns +its bytes into a blob URL. Gettext catalogues, on the other hand, are dropped — +41 594 `.po`/`.pot` files weighing 857 MB, 72 % of the payload for content that +Weblate maintains and nobody reads on a phone. `BUNDLE_KEEP_PO=1` brings them +back, `BUNDLE_SKIP_IMG=1` drops the images. + +Measured on a VM: 139 repositories, 80 841 files in 233 slices, an APK of 354 MB +with **2 844 entries**, and 20 files read back from the packs identical byte for +byte to their source. The APK does not follow the payload — text compresses, +PNG does not: the code alone is 331 MB of assets for about 130 MB of APK. The +install verifies the transfer with `script/mobile/check_bundle_transfer.py`, +which also runs on its own, and a failed transfer fails the VM — an app that +does not carry the code it is meant to show is not the app that was asked for. It is bounded to apt-based distributions, because that upstream installer starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio @@ -487,9 +494,17 @@ WebView ignore `Range` — 4 Mo au pire, et c'est pour cela que les tranches son bornées. Les images matricielles restent dehors : des captures d'écran d'addons, dans un navigateur qui montre du texte. -Mesuré sur une VM : 139 dépôts, 116 156 fichiers en 391 tranches, un APK de -282 Mo à **3 002 entrées**, et 20 fichiers relus depuis les packs identiques -octet pour octet à leur source. L'installation vérifie ce transfert avec +Les images sont empaquetées aussi, et un fichier empaqueté n'a pas d'URL propre : +le lecteur fait un blob de ses octets. Les catalogues gettext, en revanche, sont +écartés — 41 594 fichiers `.po`/`.pot` pour 857 Mo, soit 72 % du poids, d'un +contenu que Weblate maintient et que personne ne lit sur un téléphone. +`BUNDLE_KEEP_PO=1` les ramène, `BUNDLE_SKIP_IMG=1` retire les images. + +Mesuré sur une VM : 139 dépôts, 80 841 fichiers en 233 tranches, un APK de +354 Mo à **2 844 entrées**, et 20 fichiers relus depuis les packs identiques +octet pour octet à leur source. L'APK ne suit pas la charge — le texte se +compresse, le PNG non : le code seul fait 331 Mo d'assets pour environ 130 Mo +d'APK. L'installation vérifie le transfert avec `script/mobile/check_bundle_transfer.py`, qui s'exécute aussi seul, et un transfert manqué fait échouer la VM — une application qui ne porte pas le code qu'elle est censée montrer n'est pas l'application demandée. diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index 2f3d995..8abb57b 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -206,9 +206,17 @@ WebView ignore `Range` — 4 Mo au pire, et c'est pour cela que les tranches son bornées. Les images matricielles restent dehors : des captures d'écran d'addons, dans un navigateur qui montre du texte. -Mesuré sur une VM : 139 dépôts, 116 156 fichiers en 391 tranches, un APK de -282 Mo à **3 002 entrées**, et 20 fichiers relus depuis les packs identiques -octet pour octet à leur source. L'installation vérifie ce transfert avec +Les images sont empaquetées aussi, et un fichier empaqueté n'a pas d'URL propre : +le lecteur fait un blob de ses octets. Les catalogues gettext, en revanche, sont +écartés — 41 594 fichiers `.po`/`.pot` pour 857 Mo, soit 72 % du poids, d'un +contenu que Weblate maintient et que personne ne lit sur un téléphone. +`BUNDLE_KEEP_PO=1` les ramène, `BUNDLE_SKIP_IMG=1` retire les images. + +Mesuré sur une VM : 139 dépôts, 80 841 fichiers en 233 tranches, un APK de +354 Mo à **2 844 entrées**, et 20 fichiers relus depuis les packs identiques +octet pour octet à leur source. L'APK ne suit pas la charge — le texte se +compresse, le PNG non : le code seul fait 331 Mo d'assets pour environ 130 Mo +d'APK. L'installation vérifie le transfert avec `script/mobile/check_bundle_transfer.py`, qui s'exécute aussi seul, et un transfert manqué fait échouer la VM — une application qui ne porte pas le code qu'elle est censée montrer n'est pas l'application demandée. diff --git a/script/qemu/README.md b/script/qemu/README.md index 3ce5c61..0076fd9 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -188,12 +188,19 @@ reader asks for a byte range, and falls back to the whole slice when the WebView server ignores `Range` — 4 MB at worst, which is why the slices are bounded. Raster images are left out: addon screenshots, in a browser that shows text. -Measured on a VM: 139 repositories, 116 156 files in 391 slices, an APK of -282 MB with **3 002 entries**, and 20 files read back from the packs identical -byte for byte to their source. The install verifies that transfer with -`script/mobile/check_bundle_transfer.py`, which also runs on its own, and a -failed transfer fails the VM — an app that does not carry the code it is meant -to show is not the app that was asked for. +Images are packed too, and a packed file has no URL of its own: the reader turns +its bytes into a blob URL. Gettext catalogues, on the other hand, are dropped — +41 594 `.po`/`.pot` files weighing 857 MB, 72 % of the payload for content that +Weblate maintains and nobody reads on a phone. `BUNDLE_KEEP_PO=1` brings them +back, `BUNDLE_SKIP_IMG=1` drops the images. + +Measured on a VM: 139 repositories, 80 841 files in 233 slices, an APK of 354 MB +with **2 844 entries**, and 20 files read back from the packs identical byte for +byte to their source. The APK does not follow the payload — text compresses, +PNG does not: the code alone is 331 MB of assets for about 130 MB of APK. The +install verifies the transfer with `script/mobile/check_bundle_transfer.py`, +which also runs on its own, and a failed transfer fails the VM — an app that +does not carry the code it is meant to show is not the app that was asked for. It is bounded to apt-based distributions, because that upstream installer starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio From 2c181c60d6585d99d58b5143d84637278d3a3110 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 21 Aug 2026 21:17:30 -0400 Subject: [PATCH 52/55] =?UTF-8?q?[IMP]=20script=20todo:=20RAM=20utilis?= =?UTF-8?q?=C3=A9e=20et=20uptime=20dans=20les=20infos=20avanc=C3=A9es=20d'?= =?UTF-8?q?une=20VM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « RAM » disait l'allocation. Elle dit maintenant l'usage, dans la même colonne : « 4.7G/32G ». Sur un hyperviseur, savoir qu'une VM de 32 Go n'en occupe que 4,7 décide s'il reste de la place pour la suivante — l'allocation seule ne le dit pas. La formule est « available - usable » de virsh dommemstat, calibrée contre le « free » de deux VM : 1186 contre 1216 Mo, et 4831 contre 4838. Et la période de collecte est posée d'abord, sans quoi le ballon ne rafraîchit rien — une VM qui occupait 4,8 Go en annonçait 490 Mo. L'uptime vient de l'âge du processus QEMU : libvirt ne l'expose ni dans dominfo, ni dans domstats, ni par l'agent, mais ce processus est né avec le domaine. Les colonnes ont été resserrées pour que la ligne tienne en 80 caractères avec le nom entier de la VM. --- EN --- "RAM" showed the allocation. It now shows the use, in the same column: "4.7G/32G". On a hypervisor, knowing that a 32 GB VM only occupies 4.7 decides whether there is room for the next one — the allocation alone does not say. The formula is virsh dommemstat's "available - usable", calibrated against the "free" of two VMs: 1186 against 1216 MB, and 4831 against 4838. And the collection period is set first, without which the balloon refreshes nothing — a VM using 4.8 GB reported 490 MB. Uptime comes from the age of the QEMU process: libvirt exposes it neither in dominfo, nor domstats, nor through the agent, but that process was born with the domain. Columns were tightened so the line fits in 80 characters with the VM's full name. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 130 +++++++++++++++++++++++++- test/test_qemu_vm_table.py | 184 +++++++++++++++++++++++++++++++++++++ 2 files changed, 310 insertions(+), 4 deletions(-) create mode 100644 test/test_qemu_vm_table.py diff --git a/script/todo/todo.py b/script/todo/todo.py index ca88ff4..168a072 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -2928,6 +2928,107 @@ class TODO: except (OSError, subprocess.SubprocessError, ValueError): return 0, 0 + @staticmethod + def _fmt_uptime(secs): + """Durée depuis le démarrage, en six caractères au plus. + + « _fmt_dur » s'arrête aux minutes — bon pour une installation, illisible + pour une VM debout depuis trois jours. Ici la précision décroît avec la + durée : personne ne lit les secondes d'un uptime de 19 heures.""" + secs = int(secs) + if secs < 60: + return f"{secs}s" + if secs < 3600: + return f"{secs // 60}m" + if secs < 86400: + return f"{secs // 3600}h{(secs % 3600) // 60:02d}" + days = secs // 86400 + # Au-delà de 99 jours, les heures ne rentrent plus dans la colonne — et + # personne ne les lit sur une machine debout depuis un an. + if days >= 100: + return f"{days}j" + return f"{days}j{(secs % 86400) // 3600:02d}h" + + @staticmethod + def _qemu_domain_uptime(name): + """Secondes depuis le démarrage du domaine, ou None. + + libvirt n'expose pas l'uptime d'un invité : ni dominfo, ni domstats, ni + l'agent. Mais le processus QEMU du domaine est né avec lui, et son âge + est donc exactement celui de la VM. « guest=, » est le motif que + libvirt met dans sa ligne de commande — la virgule évite qu'un nom + préfixe d'un autre matche à sa place.""" + try: + res = subprocess.run( + ["pgrep", "-f", f"guest={name},"], + capture_output=True, + text=True, + timeout=10, + ) + pid = (res.stdout or "").split()[0] + age = subprocess.run( + ["ps", "-o", "etimes=", "-p", pid], + capture_output=True, + text=True, + timeout=10, + ) + return int((age.stdout or "").strip()) + except (OSError, subprocess.SubprocessError, ValueError, IndexError): + return None + + @staticmethod + def _qemu_dommemstat(name): + """(utilisée, totale) en KiO vues par l'INVITÉ, ou (0, 0). + + « available » est ce que l'invité voit, « usable » ce qu'il peut encore + rendre : leur différence est son « used », à quelques mégaoctets près — + calibré contre le « free » de deux VM (1186 contre 1216, 4831 contre + 4838). « unused » ne convient pas : il ignore le cache, et donnait + 10,8 Go d'« utilisé » sur une VM qui en occupait 1,2. + + La période de collecte est posée d'abord, et c'est indispensable : sans + elle le ballon ne rafraîchit rien, et une VM qui occupait 4,8 Go en + annonçait 490 Mo — vécu. « --live » ne touche pas le XML : le réglage + disparaît au prochain démarrage du domaine.""" + try: + subprocess.run( + [ + "sudo", + "virsh", + "dommemstat", + name, + "--period", + "5", + "--live", + ], + capture_output=True, + text=True, + timeout=15, + env=TODO._qemu_c_env(), + ) + res = subprocess.run( + ["sudo", "virsh", "dommemstat", name], + capture_output=True, + text=True, + timeout=15, + env=TODO._qemu_c_env(), + ) + except (OSError, subprocess.SubprocessError): + return 0, 0 + stat = {} + for line in (res.stdout or "").splitlines(): + parts = line.split() + if len(parts) == 2: + try: + stat[parts[0]] = int(parts[1]) + except ValueError: + continue + total = stat.get("available", 0) + usable = stat.get("usable", 0) + if not total or not usable: + return 0, total + return max(0, total - usable), total + def _qemu_list_vms_advanced(self): """Tableau détaillé par VM : état, vCPU, RAM allouée, disque (virtuel + réel), plus l'espace total disponible du stockage des images.""" @@ -2936,9 +3037,12 @@ class TODO: print(f"\n{t('No VM found.')}") return g = 1 << 30 + # Largeurs serrées pour que la ligne tienne en 80 colonnes AVEC le nom + # entier : c'est lui qui distingue les machines, et « erplibre-ubuntu- + # 2604-gno » tronqué ne distingue plus rien. header = ( - f"\n{'VM':<28} {'État':<10} {'vCPU':>4} {'RAM':>8} " - f"{'Disque':>9} {'Réel':>9}" + f"\n{'VM':<26} {'État':<8} {'vCPU':>4} {'RAM':>10} " + f"{'Disque':>7} {'Réel':>7} {'Uptime':>6}" ) print(header) print("─" * len(header.strip())) @@ -2951,9 +3055,27 @@ class TODO: if disk: disk_dirs.add(os.path.dirname(disk)) ram_g = (mem_kib * 1024) / g if mem_kib else 0 + # « RAM » dit désormais l'USAGE et non la seule allocation : sur un + # hyperviseur, savoir qu'une VM de 32 Go n'en occupe que 4,7 décide + # s'il reste de la place pour la suivante. Deux nombres dans une + # colonne plutôt que deux colonnes — le tableau tient encore sur + # une ligne de terminal. + used_kib, _total_kib = self._qemu_dommemstat(name) + # Le total sans décimale quand il est entier — une allocation + # vaut 8, 12 ou 32 Go, jamais 32,0. + alloc = f"{ram_g:.0f}" if ram_g == int(ram_g) else f"{ram_g:.1f}" + ram = ( + f"{used_kib * 1024 / g:.1f}G/{alloc}G" + if used_kib + else f"-/{alloc}G" + ) + # L'uptime vient de l'âge du processus QEMU : libvirt ne l'expose + # nulle part, et ce processus est né avec le domaine. + up = self._qemu_domain_uptime(name) print( - f"{name:<28.28} {state:<10.10} {vcpus:>4} " - f"{ram_g:>7.1f}G {virt / g:>8.1f}G {actual / g:>8.1f}G" + f"{name:<26.26} {state:<8.8} {vcpus:>4} " + f"{ram:>10} {virt / g:>6.1f}G {actual / g:>6.1f}G " + f"{self._fmt_uptime(up) if up else '-':>6}" ) # Espace total disponible sur le(s) stockage(s) des disques. for d in sorted(disk_dirs) or ["/var/lib/libvirt/images"]: diff --git a/test/test_qemu_vm_table.py b/test/test_qemu_vm_table.py new file mode 100644 index 0000000..b479f19 --- /dev/null +++ b/test/test_qemu_vm_table.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Tableau des infos avancées : RAM utilisée, uptime, largeur. + +Deux colonnes ont changé de sens. « RAM » disait l'allocation, elle dit +maintenant l'usage — sur un hyperviseur, savoir qu'une VM de 32 Go n'en occupe +que 4,7 décide s'il reste de la place pour la suivante. Et l'uptime est apparu : +libvirt ne l'expose nulle part, mais le processus QEMU du domaine est né avec +lui. + +Ce que ces tests gardent : la formule de la RAM, calibrée contre le « free » de +deux VM réelles, et la largeur de la ligne — un tableau qui déborde de 80 +colonnes se replie et devient illisible. +""" + +import io +import contextlib +import subprocess +import sys +import unittest +from unittest import mock + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 + +# Sortie réelle de « virsh dommemstat » sur la VM de migration, en KiO. Le +# « free » de l'invité disait alors : total 11955, used 1216. +DOMMEMSTAT = """actual 12582912 +swap_in 0 +swap_out 0 +major_fault 0 +minor_fault 0 +unused 1197056 +available 12242432 +usable 11027456 +last_update 1787359366 +disk_caches 8211456 +""" + + +class TestUptimeFormat(unittest.TestCase): + def test_seconds_then_minutes_then_hours_then_days(self): + self.assertEqual("45s", TODO._fmt_uptime(45)) + self.assertEqual("12m", TODO._fmt_uptime(12 * 60 + 30)) + self.assertEqual("19h55", TODO._fmt_uptime(19 * 3600 + 55 * 60)) + self.assertEqual("10j13h", TODO._fmt_uptime(10 * 86400 + 13 * 3600)) + + def test_it_never_exceeds_six_characters(self): + """La colonne fait six caractères : au-delà, le tableau se décale.""" + for secs in (0, 59, 60, 3599, 3600, 86399, 86400, 400 * 86400): + self.assertLessEqual(len(TODO._fmt_uptime(secs)), 6, secs) + + def test_precision_drops_as_the_duration_grows(self): + """Personne ne lit les secondes d'un uptime de dix jours.""" + self.assertNotIn("s", TODO._fmt_uptime(19 * 3600)) + self.assertNotIn("m", TODO._fmt_uptime(10 * 86400)) + + +class TestMemStat(unittest.TestCase): + def _stat(self, out): + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess([], 0, out, ""), + ): + return TODO._qemu_dommemstat("vm") + + def test_used_is_available_minus_usable(self): + """Calibré contre le « free » de deux VM : 1186 contre 1216 Mo lus dans + l'invité, et 4831 contre 4838 sur l'autre. « available - unused » + donnait 10,8 Go pour une VM qui en occupait 1,2 — il compte le cache. + """ + used, total = self._stat(DOMMEMSTAT) + self.assertAlmostEqual(used / 1024, 1186, delta=5) + self.assertAlmostEqual(total / 1024, 11955, delta=5) + + def test_a_stopped_vm_gives_zero_without_crashing(self): + used, total = self._stat("") + self.assertEqual((0, 0), (used, total)) + + def test_it_survives_virsh_failing(self): + with mock.patch("subprocess.run", side_effect=OSError): + self.assertEqual((0, 0), TODO._qemu_dommemstat("vm")) + + def test_missing_usable_is_not_taken_for_zero_use(self): + """Sans « usable », on ne sait pas : mieux vaut ne rien dire que + d'annoncer une VM qui n'utiliserait rien.""" + used, total = self._stat("available 12242432\nunused 1197056\n") + self.assertEqual(0, used) + self.assertGreater(total, 0) + + def test_it_asks_for_a_collection_period_first(self): + """Sans période, le ballon ne rafraîchit rien : une VM qui occupait + 4,8 Go en annonçait 490 Mo — vécu. « --live » ne touche pas le XML.""" + calls = [] + + def fake_run(cmd, **kw): + calls.append(cmd) + return subprocess.CompletedProcess([], 0, DOMMEMSTAT, "") + + with mock.patch("subprocess.run", side_effect=fake_run): + TODO._qemu_dommemstat("vm") + self.assertIn("--period", calls[0]) + self.assertIn("--live", calls[0]) + + +class TestDomainUptime(unittest.TestCase): + def test_it_reads_the_age_of_the_qemu_process(self): + outs = [ + subprocess.CompletedProcess([], 0, "1137455\n", ""), + subprocess.CompletedProcess([], 0, " 1195\n", ""), + ] + with mock.patch("subprocess.run", side_effect=outs): + self.assertEqual(1195, TODO._qemu_domain_uptime("vm")) + + def test_the_pattern_ends_with_a_comma(self): + """« guest=vm, » et non « guest=vm » : sinon « vm » matcherait aussi + « vm-2 », et l'uptime affiché serait celui d'une autre machine.""" + seen = {} + + def fake_run(cmd, **kw): + seen.setdefault("cmd", cmd) + return subprocess.CompletedProcess([], 0, "", "") + + with mock.patch("subprocess.run", side_effect=fake_run): + TODO._qemu_domain_uptime("vm") + self.assertIn("guest=vm,", seen["cmd"]) + + def test_a_stopped_domain_has_no_uptime(self): + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess([], 1, "", ""), + ): + self.assertIsNone(TODO._qemu_domain_uptime("vm")) + + +class TestTheTable(unittest.TestCase): + def _render(self, uptime=1195, mem=(1214 * 1024, 11955 * 1024)): + todo = TODO.__new__(TODO) + todo._qemu_list_domains = lambda: ["erplibre-ubuntu-2604-gnome"] + todo._qemu_domstate = lambda n: "running" + todo._qemu_dominfo = staticmethod(lambda n: (8, 12 * 1024 * 1024)) + todo._qemu_main_disk = lambda n: "/var/lib/libvirt/images/x.qcow2" + todo._qemu_disk_sizes = staticmethod( + lambda d: (65 * (1 << 30), 62 * (1 << 30)) + ) + todo._qemu_dommemstat = staticmethod(lambda n: mem) + todo._qemu_domain_uptime = staticmethod(lambda n: uptime) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + todo._qemu_list_vms_advanced() + return buf.getvalue() + + def test_every_line_fits_in_eighty_columns(self): + """Le tableau est lu dans un terminal : au-delà de 80, il se replie.""" + for line in self._render().splitlines(): + if line.startswith("Stockage") or not line.strip(): + continue + self.assertLessEqual(len(line), 80, line) + + def test_ram_shows_use_over_allocation(self): + out = self._render() + self.assertIn("1.2G/12G", out) + + def test_the_uptime_column_is_there(self): + out = self._render() + self.assertIn("Uptime", out) + self.assertIn("19m", out) + + def test_a_vm_without_stats_shows_a_dash_not_a_zero(self): + """« 0.0G/12G » ferait croire à une VM au repos ; « -/12G » dit qu'on + ne sait pas.""" + out = self._render(uptime=None, mem=(0, 0)) + self.assertIn("-/12G", out) + self.assertNotIn("0.0G/12G", out) + + def test_the_full_vm_name_survives_when_it_fits(self): + """C'est le nom qui distingue les machines : le tronquer trop tôt les + rend indiscernables.""" + self.assertIn("erplibre-ubuntu-2604-gnome", self._render()) + + +if __name__ == "__main__": + unittest.main() From a98d6ec9cd45f9b3a7b111f15b80548007d6f5d6 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 21 Aug 2026 21:49:44 -0400 Subject: [PATCH 53/55] =?UTF-8?q?[FIX]=20script=20todo:=20r=C3=A9gler=20l'?= =?UTF-8?q?=C3=A9cran=20de=20l'=C3=A9mulateur=20au=20lancement,=20densit?= =?UTF-8?q?=C3=A9=20comprise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Écrire hw.lcd.* dans le config.ini de l'AVD ne servait à rien : l'émulateur réécrit ce fichier depuis le profil du téléphone au premier démarrage, et l'AVD repartait en 1080x2400 densité 420 — quatre fois les pixels voulus. Constaté sur la VM, où le réglage était censé s'appliquer depuis des semaines. La taille passe donc au lancement, et la DENSITÉ avec elle. C'est ce point qui surprend, et il est mesuré sur une charge identique : 540x1140 en densité 420 est PIRE que le plein écran — 81 ms de médiane contre 40, 57 % d'images en retard contre 37, tout étant rendu énorme. En densité 240 : 38 ms, 32 %, et le 99e centile tombe de 950 ms à 250. « -no-snapshot-save » vient avec : ce menu propose de tuer l'émulateur par pkill, et le lancement suivant mourait alors sur « A snapshot operation is pending ». Vérifié après un pkill : plus aucun FATAL. --- EN --- Writing hw.lcd.* into the AVD's config.ini did nothing: the emulator rewrites that file from the phone profile on first boot, and the AVD came back at 1080x2400 density 420 — four times the intended pixels. Found on the VM, where the setting was supposed to have applied for weeks. The size therefore moves to launch time, and the DENSITY with it. That is the surprising part, measured on an identical workload: 540x1140 at density 420 is WORSE than the full screen — 81 ms median against 40, 57 % janky frames against 37, everything rendered huge. At density 240: 38 ms, 32 %, and the 99th percentile drops from 950 ms to 250. "-no-snapshot-save" comes along: this menu offers to kill the emulator with pkill, and the next start then died on "A snapshot operation is pending". Checked after a pkill: no FATAL left. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 60 ++++++++++++++++++++++----------- test/test_qemu_desktop_tools.py | 34 +++++++++++++------ test/test_qemu_emulator_menu.py | 37 ++++++++++++++++++-- 3 files changed, 98 insertions(+), 33 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index 168a072..55aeea6 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1659,6 +1659,26 @@ class TODO: # Commande de l'émulateur dans la VM. Le chemin est ABSOLU : un # « ssh hôte 'commande' » ne lit ni ~/.profile ni ~/.bashrc. _QEMU_EMULATOR_BIN = "$HOME/android/emulator/emulator" + + # Drapeaux passés à CHAQUE lancement, et non écrits dans le config.ini de + # l'AVD : l'émulateur réécrit ce fichier depuis le profil du téléphone au + # premier démarrage, et les hw.lcd.* y étaient effacés — l'AVD repartait en + # 1080x2400 densité 420, quatre fois les pixels voulus. Mesuré. + # + # La résolution et la DENSITÉ vont ensemble, et c'est contre-intuitif : + # 540x1140 en densité 420 est PIRE que le plein écran — 81 ms de médiane + # contre 40, et 57 % d'images en retard contre 37, tout étant rendu énorme. + # Avec la densité 240, la queue s'effondre : 99e centile à 250 ms contre + # 950, et 32 % d'images en retard. + # + # « -no-snapshot-save » : sans lui, un émulateur tué par pkill — ce que ce + # menu propose lui-même — laisse un instantané en cours, et le lancement + # SUIVANT meurt sur « A snapshot operation is pending and timeout has + # expired ». Vécu, et le message ne dit pas quoi faire. + _QEMU_EMULATOR_FLAGS = ( + "-no-audio -no-boot-anim -no-snapshot-save -gpu swangle" + " -skin 540x1140 -prop qemu.sf.lcd_density=240" + ) _QEMU_AVD_NAME = "erplibre" def _qemu_emulator_running(self, target, src="virsh"): @@ -1778,8 +1798,8 @@ class TODO: # partir d'ici, où il n'y a pas d'écran à lui donner. print(f"\n {t('Run this on YOUR workstation:')}") print( - f"\n ssh -XC {target} '{emu} -avd {avd}" - " -no-audio -no-boot-anim'\n" + f"\n ssh -XC {target} '{emu} -avd {avd} " + f"{self._QEMU_EMULATOR_FLAGS}'\n" ) print( f" {t('X11 compression is on (-XC); the screen is 540x1140.')}" @@ -1792,8 +1812,9 @@ class TODO: # l'émulateur refuse de démarrer. setsid le détache, pour qu'il survive # à la fermeture de ce ssh. start = ( - f'setsid -f sg kvm -c "{emu} -avd {avd} -no-window -no-audio' - ' -no-boot-anim > /tmp/erplibre-emulator.log 2>&1"' + f'setsid -f sg kvm -c "{emu} -avd {avd} -no-window ' + f"{self._QEMU_EMULATOR_FLAGS}" + ' > /tmp/erplibre-emulator.log 2>&1"' ) res = subprocess.run( ["ssh"] + self._qemu_ssh_opts(src) + [target, start], @@ -1865,8 +1886,8 @@ class TODO: ) print( f"\n ssh {target} '{self._QEMU_EMULATOR_BIN} " - f"-avd {self._QEMU_AVD_NAME}" - " -no-window -no-audio -no-boot-anim'\n" + f"-avd {self._QEMU_AVD_NAME} -no-window " + f"{self._QEMU_EMULATOR_FLAGS}'\n" ) print(f" {t('2. Open the tunnel from YOUR workstation:')}") if src == "ssh_config": @@ -6692,16 +6713,16 @@ class TODO: "dev=$(cat $HOME/.erplibre-avd-device); " 'echo no | avdmanager create avd -n erplibre -k "$img" ' '-d "$dev" --force && ' - # Rendu logiciel, écrit dans la config : par ssh -X il n'y a pas de - # GLX direct, et « auto » donnerait un écran noir. - # L'écran, RÉDUIT, et c'est ce réglage qui décide du confort. Le - # profil Pixel donne 1080x2400, soit 2,6 Mpixels à pousser - # image par image à travers SSH, en rendu logiciel : « ça se - # lance mais c'est trop lent ». En 540x1140 il en reste - # 0,62 Mpixel — 4,2 fois moins. Android gère la densité et - # l'application ne s'en aperçoit pas ; qui veut la taille - # réelle l'écrase au lancement par « -skin 1080x2400 ». - 'printf "hw.gpu.enabled=yes\\nhw.gpu.mode=swangle\\nhw.lcd.width=540\\nhw.lcd.height=1140\\nhw.lcd.density=240\\n" ' + # Rendu logiciel, écrit dans la config : par ssh -X il n'y a pas + # de GLX direct, et « auto » donnerait un écran noir. Ces deux + # clés-là SURVIVENT, elles ne viennent pas du profil du téléphone. + # + # L'écran, en revanche, ne s'écrit PAS ici : l'émulateur réécrit + # config.ini depuis le profil Pixel au premier démarrage, et les + # hw.lcd.* y étaient effacés — l'AVD repartait en 1080x2400 + # densité 420. C'est donc au LANCEMENT qu'il se règle, par + # _QEMU_EMULATOR_FLAGS, et la commande affichée plus bas les porte. + 'printf "hw.gpu.enabled=yes\\nhw.gpu.mode=swangle\\n" ' ">> $HOME/.android/avd/erplibre.avd/config.ini' && " f'echo " ✅ {t("AVD ready:")} ' "$(cat $HOME/.erplibre-avd-device) / " @@ -6717,10 +6738,11 @@ class TODO: # found ». Vécu, sur la ligne que ce message affichait lui-même. f'echo " {t("open it from your workstation:")} ' # « -XC » et non « -X » : la compression X11 change tout sur un - # écran distant. « -no-boot-anim » retire une animation qui - # ne sert qu'à faire attendre. + # écran distant. Les autres drapeaux viennent de la même autorité + # que le lancement du menu : écran réduit, densité qui va avec, et + # pas d'instantané en attente si on tue l'émulateur. 'ssh -XC erplibre@$ip \\"$HOME/android/emulator/emulator ' - '-avd erplibre -no-audio -no-boot-anim\\""; ' + f'-avd erplibre {self._QEMU_EMULATOR_FLAGS}\\""; ' f'echo " {t("then install the APK:")} ' # « -t » : l'ABI injectée fait marquer l'APK « testOnly » par AGP, # et adb le refuse sans ce drapeau — « INSTALL_FAILED_TEST_ONLY ». diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index b115f9f..2e1e2d8 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -526,17 +526,29 @@ class TestAndroidEmulator(unittest.TestCase): "avd", self.todo._qemu_tools_for(("avd",), "amd64", "", "ubuntu") ) - def test_the_screen_is_small_enough_to_travel(self): - """Le profil Pixel donne 1080x2400 : 2,6 Mpixels par image à pousser - dans SSH, et « ça se lance mais c'est trop lent ». Mesuré après - réduction : 540x1140 confirmé par « wm size », et la capture pleine - page tombe de 220 Ko à 49 Ko.""" - for key in ( - "hw.lcd.width=540", - "hw.lcd.height=1140", - "hw.lcd.density=240", - ): - self.assertIn(key, self.cmd, key) + def test_the_screen_is_set_at_launch_not_in_the_config(self): + """Écrire hw.lcd.* dans config.ini ne SERT À RIEN : l'émulateur réécrit + ce fichier depuis le profil du téléphone au premier démarrage, et l'AVD + repartait en 1080x2400 densité 420 — constaté sur la VM. La taille se + règle donc au lancement, et la commande affichée la porte.""" + self.assertNotIn("hw.lcd.width", self.cmd) + self.assertIn("-skin 540x1140", self.cmd) + # Ces deux clés-là survivent : elles ne viennent pas du profil. + self.assertIn("hw.gpu.mode=swangle", self.cmd) + + def test_the_density_travels_with_the_resolution(self): + """Contre-intuitif, et mesuré : 540x1140 en densité 420 est PIRE que le + plein écran — 81 ms de médiane contre 40, et 57 % d'images en retard + contre 37, tout étant rendu énorme. Avec la densité 240 : 38 ms, 32 %, + et le 99e centile tombe de 950 ms à 250.""" + self.assertIn("qemu.sf.lcd_density=240", self.cmd) + + def test_a_killed_emulator_does_not_block_the_next_start(self): + """Ce menu propose lui-même de tuer l'émulateur par pkill. Sans + « -no-snapshot-save », le lancement suivant meurt sur « A snapshot + operation is pending and timeout has expired » — vécu, et le message ne + dit pas quoi faire.""" + self.assertIn("-no-snapshot-save", self.cmd) def test_the_printed_command_compresses_the_display(self): """« -XC » plutôt que « -X » sur un écran distant.""" diff --git a/test/test_qemu_emulator_menu.py b/test/test_qemu_emulator_menu.py index 7d8fc37..8386dc0 100644 --- a/test/test_qemu_emulator_menu.py +++ b/test/test_qemu_emulator_menu.py @@ -155,8 +155,16 @@ class _MenuCase(unittest.TestCase): self.todo._qemu_self_address = staticmethod(lambda: ("10.0.0.2", True)) self.calls = [] - def _play(self, answers, running=0, start_rc=0, port_taken=False, - probe="", running_after=1, log="rien"): + def _play( + self, + answers, + running=0, + start_rc=0, + port_taken=False, + probe="", + running_after=1, + log="rien", + ): """Joue le menu avec des réponses données ; rend (sortie, commandes). « running » est le compte AVANT le démarrage, « running_after » celui @@ -254,6 +262,28 @@ class TestEmulatorMenu(_MenuCase): self.assertIn("setsid -f", cmd) self.assertIn("/tmp/erplibre-emulator.log", cmd) + def test_the_start_carries_the_shared_flags(self): + """Une seule autorité pour ces drapeaux : le lancement du menu, la + commande fenêtrée et celle de l'étape AVD doivent dire la même chose. + La densité va avec la résolution — 540x1140 en densité 420 est mesuré + PIRE que le plein écran.""" + _, calls = self._play(["1", "1", "n"]) + cmd = self._started(calls)[0][-1] + for flag in ( + "-skin 540x1140", + "qemu.sf.lcd_density=240", + "-no-snapshot-save", + "-gpu swangle", + ): + self.assertIn(flag, cmd, flag) + + def test_the_windowed_command_carries_them_too(self): + """Qui ouvre la fenêtre depuis son poste doit avoir le même écran que + qui passe par scrcpy — sinon la comparaison ne veut rien dire.""" + out, _ = self._play(["1", "2"]) + self.assertIn("-skin 540x1140", out) + self.assertIn("qemu.sf.lcd_density=240", out) + def test_the_start_command_is_valid_shell(self): """Une apostrophe ou un guillemet de trop, et la VM répond par une erreur de syntaxe — déjà rencontré dans ce même fichier.""" @@ -272,7 +302,8 @@ class TestEmulatorMenu(_MenuCase): def test_a_vm_without_the_sdk_is_diagnosed_before_anything_else(self): """Une VM déployée sans cocher l'outil est le cas NORMAL. Le menu le dit avant même de demander la fenêtre — mesuré sur une VM de migration, - où le démarrage détaché rendait 0 et le journal disait « not found ».""" + où le démarrage détaché rendait 0 et le journal disait « not found ». + """ out, calls = self._play(["1"], probe="NO_SDK\n") self.assertIn("SDK", out) self.assertNotIn("[1]", out.split("VM locale")[-1]) From a55b03e199d6d7e36453caa806f38c3aa5bacbd0 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 21 Aug 2026 22:32:56 -0400 Subject: [PATCH 54/55] =?UTF-8?q?[ADD]=20qemu=20:=20prendre=20le=20GPU=20d?= =?UTF-8?q?e=20l'h=C3=B4te,=20et=20r=C3=A9gler=20le=20mat=C3=A9riel=20des?= =?UTF-8?q?=20VM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une VM graphique sans accélération rend tout par le processeur : le bureau, et l'émulateur Android qui tourne dedans — 32 % d'images en retard, mesuré. Le déploiement prend donc le GPU de l'hôte dès qu'un nœud de rendu existe. Une VM déjà installée n'avait aucune voie : ni vCPU, ni RAM, ni 3D. Le menu d'état les règle maintenant, pendant qu'elle est éteinte — le seul moment où libvirt les lit. Trois pièges du terrain : « --memory N » ne touche que le ballon, l'ajout d'egl-headless n'est pas idempotent, et son retrait sans cible emporte la console VNC. Vérifié sur un domaine jetable, 459 tests verts. --- EN --- A graphical VM without acceleration renders everything on the CPU: the desktop, and the Android emulator inside it — 32 % janky frames, measured. The deployment now takes the host GPU as soon as a render node exists. An installed VM had no path at all: no vCPU, no RAM, no 3D. The state menu now sets them while the VM is shut off — the only moment libvirt reads them. Three field traps: "--memory N" only moves the balloon, adding egl-headless is not idempotent, and removing it untargeted takes the VNC console with it. Verified on a throwaway domain, 459 tests green. Assisted-by: Claude Opus 5 --- script/qemu/README.base.md | 70 ++++ script/qemu/README.fr.md | 36 ++ script/qemu/README.md | 34 ++ script/qemu/deploy_qemu.py | 122 ++++++- script/todo/qemu_hardware.py | 460 ++++++++++++++++++++++++++ script/todo/todo.py | 177 +++++++++- script/todo/todo_i18n.py | 68 ++++ test/test_qemu_emulator_menu.py | 40 +++ test/test_qemu_gpu.py | 561 ++++++++++++++++++++++++++++++++ 9 files changed, 1566 insertions(+), 2 deletions(-) create mode 100644 script/todo/qemu_hardware.py create mode 100644 test/test_qemu_gpu.py diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md index 36db9ce..f216e83 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -390,6 +390,9 @@ family — and its disk cost is added to the plan before anything is created. - `--no-install-deps` — never auto-install dependencies. - `--dry-run` — show the commands without executing anything. - `--force` — overwrite the existing working qcow2 disk. +- `--gpu` — 3D acceleration by the host GPU: `auto` (default, on when the + host has a render node), `on` (force), `off` (software rendering). +- `--gpu-node` — which render node to use, on a multi-GPU host. - `--lang` — language of the SSH login guide, `fr` (default) or `en`. The TODO menu passes its own language. - `--erplibre-dir` — where ERPLibre will live in the VM @@ -575,6 +578,9 @@ de paquets — et sa place disque s'ajoute au plan avant que rien ne soit créé - `--no-install-deps` — n'installe jamais les dépendances automatiquement. - `--dry-run` — affiche les commandes sans rien exécuter. - `--force` — écrase le disque de travail qcow2 existant. +- `--gpu` — accélération 3D par le GPU de l'hôte : `auto` (défaut, activée si + l'hôte a un nœud de rendu), `on` (forcer), `off` (rendu logiciel). +- `--gpu-node` — quel nœud de rendu utiliser, sur un hôte à plusieurs cartes. - `--lang` — langue du guide affiché à la connexion SSH, `fr` (défaut) ou `en`. Le menu TODO passe la sienne. - `--erplibre-dir` — où ERPLibre sera installé dans la VM @@ -681,6 +687,37 @@ This works over Wi-Fi and needs no VM shutdown — the simplest option for personal access. Prefer a bridge (below) if the VM must be a full server exposed on the LAN. +## 3D acceleration (host GPU) + +A graphical VM without acceleration renders everything on the CPU — the +desktop, and the Android emulator running inside it. The deployment therefore +takes the host GPU **by default** (`--gpu auto`): when the host exposes a +render node, the VM gets a virtio-GPU with `accel3d` plus an `egl-headless` +display that carries the OpenGL context **beside** the VNC console — it opens +no port and replaces nothing. No render node, no 3D, and the deployment says +why instead of quietly falling back. + +```bash +ls /dev/dri/renderD* # the GPU QEMU can use — empty means no 3D +sudo virsh dumpxml | grep -A2 -E "accel3d|egl-headless" +``` + +An existing VM is adjusted from the TODO menu **while it is shut off**: +libvirt only reads these settings when QEMU starts. `QEMU/KVM › List VMs › +[2] Change the state`, then either accept *Adjust hardware before starting*, +or take `[3] Adjust hardware only`. vCPU, RAM, 3D and autostart are set +there — in a form when Textual is available, in prompts otherwise. + +Two things worth knowing: + +- A host that is **itself a VM** has no render node unless a GPU was handed + down to it. Nested without passthrough, 3D is out of reach: the Android + emulator then runs on SwiftShader, and no option changes that. +- Once the VM does have 3D, the emulator can be tried with `-gpu host` + instead of its default `-gpu swangle`: `EL_EMULATOR_GPU=host ./todo.sh`. + It stays a manual test — an emulator whose GL context fails hangs instead + of falling back, so `swangle` remains the default. + ## QEMU inside QEMU (nested) & exposing the VM via a bridge If the KVM host is **itself a VM** (QEMU-in-QEMU), the deployment works only @@ -693,6 +730,39 @@ middle VM uses CPU mode `host-passthrough`. Check from inside the KVM host accès personnel. Préférez un pont (ci-dessous) si la VM doit être un serveur à part entière exposé sur le LAN. +## Accélération 3D (GPU de l'hôte) + +Une VM graphique sans accélération rend tout par le processeur — le bureau +comme l'émulateur Android qui tourne dedans. Le déploiement prend donc le GPU +de l'hôte **par défaut** (`--gpu auto`) : si l'hôte expose un nœud de rendu, +la VM reçoit un virtio-GPU avec `accel3d` et un affichage `egl-headless` qui +porte le contexte OpenGL **à côté** de la console VNC — il n'ouvre aucun port +et ne remplace rien. Pas de nœud de rendu, pas de 3D, et le déploiement dit +pourquoi au lieu de retomber en silence. + +```bash +ls /dev/dri/renderD* # le GPU utilisable par QEMU — vide : pas de 3D +sudo virsh dumpxml | grep -A2 -E "accel3d|egl-headless" +``` + +Une VM déjà installée se règle depuis le menu TODO **pendant qu'elle est +éteinte** : libvirt ne lit ces réglages qu'au démarrage de QEMU. `QEMU/KVM › +Liste des VM › [2] Changer l'état`, puis acceptez *Régler le matériel avant de +démarrer*, ou prenez `[3] Régler le matériel seulement`. vCPU, RAM, 3D et +démarrage automatique s'y règlent — en formulaire si Textual est présent, en +invites sinon. + +Deux choses à savoir : + +- Un hôte qui est **lui-même une VM** n'a aucun nœud de rendu, sauf si un GPU + lui a été transmis. Imbriqué sans passthrough, la 3D est hors d'atteinte : + l'émulateur Android tourne alors sur SwiftShader, et aucune option n'y + change rien. +- Quand la VM a la 3D, l'émulateur peut être essayé en `-gpu host` plutôt + qu'en `-gpu swangle`, son défaut : `EL_EMULATOR_GPU=host ./todo.sh`. Ça + reste un essai manuel — un émulateur dont le contexte GL échoue reste pendu + au lieu de retomber, d'où `swangle` par défaut. + ## QEMU dans QEMU (imbriqué) & exposer la VM via un pont Si l'hôte KVM est **lui-même une VM** (QEMU dans QEMU), le déploiement ne diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index 8abb57b..d5e35ca 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -287,6 +287,9 @@ de paquets — et sa place disque s'ajoute au plan avant que rien ne soit créé - `--no-install-deps` — n'installe jamais les dépendances automatiquement. - `--dry-run` — affiche les commandes sans rien exécuter. - `--force` — écrase le disque de travail qcow2 existant. +- `--gpu` — accélération 3D par le GPU de l'hôte : `auto` (défaut, activée si + l'hôte a un nœud de rendu), `on` (forcer), `off` (rendu logiciel). +- `--gpu-node` — quel nœud de rendu utiliser, sur un hôte à plusieurs cartes. - `--lang` — langue du guide affiché à la connexion SSH, `fr` (défaut) ou `en`. Le menu TODO passe la sienne. - `--erplibre-dir` — où ERPLibre sera installé dans la VM @@ -364,6 +367,39 @@ Host myvm accès personnel. Préférez un pont (ci-dessous) si la VM doit être un serveur à part entière exposé sur le LAN. +## Accélération 3D (GPU de l'hôte) + +Une VM graphique sans accélération rend tout par le processeur — le bureau +comme l'émulateur Android qui tourne dedans. Le déploiement prend donc le GPU +de l'hôte **par défaut** (`--gpu auto`) : si l'hôte expose un nœud de rendu, +la VM reçoit un virtio-GPU avec `accel3d` et un affichage `egl-headless` qui +porte le contexte OpenGL **à côté** de la console VNC — il n'ouvre aucun port +et ne remplace rien. Pas de nœud de rendu, pas de 3D, et le déploiement dit +pourquoi au lieu de retomber en silence. + +```bash +ls /dev/dri/renderD* # le GPU utilisable par QEMU — vide : pas de 3D +sudo virsh dumpxml | grep -A2 -E "accel3d|egl-headless" +``` + +Une VM déjà installée se règle depuis le menu TODO **pendant qu'elle est +éteinte** : libvirt ne lit ces réglages qu'au démarrage de QEMU. `QEMU/KVM › +Liste des VM › [2] Changer l'état`, puis acceptez *Régler le matériel avant de +démarrer*, ou prenez `[3] Régler le matériel seulement`. vCPU, RAM, 3D et +démarrage automatique s'y règlent — en formulaire si Textual est présent, en +invites sinon. + +Deux choses à savoir : + +- Un hôte qui est **lui-même une VM** n'a aucun nœud de rendu, sauf si un GPU + lui a été transmis. Imbriqué sans passthrough, la 3D est hors d'atteinte : + l'émulateur Android tourne alors sur SwiftShader, et aucune option n'y + change rien. +- Quand la VM a la 3D, l'émulateur peut être essayé en `-gpu host` plutôt + qu'en `-gpu swangle`, son défaut : `EL_EMULATOR_GPU=host ./todo.sh`. Ça + reste un essai manuel — un émulateur dont le contexte GL échoue reste pendu + au lieu de retomber, d'où `swangle` par défaut. + ## QEMU dans QEMU (imbriqué) & exposer la VM via un pont Si l'hôte KVM est **lui-même une VM** (QEMU dans QEMU), le déploiement ne diff --git a/script/qemu/README.md b/script/qemu/README.md index 0076fd9..a6775ed 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -264,6 +264,9 @@ family — and its disk cost is added to the plan before anything is created. - `--no-install-deps` — never auto-install dependencies. - `--dry-run` — show the commands without executing anything. - `--force` — overwrite the existing working qcow2 disk. +- `--gpu` — 3D acceleration by the host GPU: `auto` (default, on when the + host has a render node), `on` (force), `off` (software rendering). +- `--gpu-node` — which render node to use, on a multi-GPU host. - `--lang` — language of the SSH login guide, `fr` (default) or `en`. The TODO menu passes its own language. - `--erplibre-dir` — where ERPLibre will live in the VM @@ -342,6 +345,37 @@ This works over Wi-Fi and needs no VM shutdown — the simplest option for personal access. Prefer a bridge (below) if the VM must be a full server exposed on the LAN. +## 3D acceleration (host GPU) + +A graphical VM without acceleration renders everything on the CPU — the +desktop, and the Android emulator running inside it. The deployment therefore +takes the host GPU **by default** (`--gpu auto`): when the host exposes a +render node, the VM gets a virtio-GPU with `accel3d` plus an `egl-headless` +display that carries the OpenGL context **beside** the VNC console — it opens +no port and replaces nothing. No render node, no 3D, and the deployment says +why instead of quietly falling back. + +```bash +ls /dev/dri/renderD* # the GPU QEMU can use — empty means no 3D +sudo virsh dumpxml | grep -A2 -E "accel3d|egl-headless" +``` + +An existing VM is adjusted from the TODO menu **while it is shut off**: +libvirt only reads these settings when QEMU starts. `QEMU/KVM › List VMs › +[2] Change the state`, then either accept *Adjust hardware before starting*, +or take `[3] Adjust hardware only`. vCPU, RAM, 3D and autostart are set +there — in a form when Textual is available, in prompts otherwise. + +Two things worth knowing: + +- A host that is **itself a VM** has no render node unless a GPU was handed + down to it. Nested without passthrough, 3D is out of reach: the Android + emulator then runs on SwiftShader, and no option changes that. +- Once the VM does have 3D, the emulator can be tried with `-gpu host` + instead of its default `-gpu swangle`: `EL_EMULATOR_GPU=host ./todo.sh`. + It stays a manual test — an emulator whose GL context fails hangs instead + of falling back, so `swangle` remains the default. + ## QEMU inside QEMU (nested) & exposing the VM via a bridge If the KVM host is **itself a VM** (QEMU-in-QEMU), the deployment works only diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 45e23aa..c253bf0 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -260,6 +260,103 @@ def host_arch() -> str: }.get(machine, "amd64") +# Nœud de rendu DRM : le fichier que le processus QEMU ouvre pour créer un +# contexte OpenGL (virgl) et donner la 3D à la VM. Un hôte sans GPU — ou +# lui-même virtualisé sans GPU transmis — n'expose AUCUN « renderD* », et +# aucune option de ligne de commande ne peut y suppléer : la VM retombe alors +# sur le rendu logiciel. On teste donc la présence du nœud, pas nos droits +# dessus : l'accès est accordé par libvirt au démarrage du domaine (cgroup + +# étiquette), et un test de lecture sous notre propre compte rejetterait à +# tort un hôte où seul le groupe « render » entre. +HOST_DRI_DIR = Path("/dev/dri") + + +def host_render_nodes(directory=HOST_DRI_DIR) -> list[str]: + """Nœuds de rendu de l'hôte, triés (ex. ['/dev/dri/renderD128']).""" + directory = Path(directory) + try: + names = sorted(p.name for p in directory.iterdir()) + except OSError: + return [] + return [str(directory / n) for n in names if n.startswith("renderD")] + + +def host_gpu_node(directory=HOST_DRI_DIR) -> str: + """Nœud de rendu à confier à QEMU, ou '' si l'hôte n'a pas de GPU. + + Le premier de la liste : sur une machine à plusieurs cartes, renderD128 + est le nœud du GPU primaire. --gpu-node force un autre choix. + """ + nodes = host_render_nodes(directory) + return nodes[0] if nodes else "" + + +def gpu_decision(mode: str, node: str, screen: bool) -> tuple[bool, str]: + """(3D activée, message à dire) pour un mode --gpu et un nœud donnés. + + Séparée du reste pour être vérifiable sans hôte : c'est ici que se décide + « par défaut avec GPU s'il existe », et le silence n'est pas une option — + une VM en rendu logiciel doit dire pourquoi. + """ + mode = (mode or "auto").lower() + if mode == "off": + return False, "" + if not screen: + # Sans écran virtuel, la 3D n'a rien à accélérer. Le dire seulement + # quand elle a été demandée explicitement. + if mode == "on": + return ( + False, + " GPU : pas d'écran virtuel sur cette VM, 3D ignorée.", + ) + return False, "" + if not node: + if mode == "on": + return ( + False, + " ⚠ GPU demandé mais l'hôte n'a aucun nœud de rendu" + " (/dev/dri/renderD*) : la VM démarrerait sans écran." + " Rendu logiciel.", + ) + return ( + False, + " GPU : aucun sur l'hôte, rendu logiciel (virgl absent).", + ) + return True, f" GPU : 3D activée par {node} (virtio-gpu + egl-headless)." + + +def gpu_apply( + video: list, mode: str, node: str, screen: bool +) -> tuple[list, list, str]: + """(video, arguments 3D, message) — pour virt-install. + + Renvoie le `--video` à garder : celui de la 3D REMPLACE le simple + « --video virtio », il ne s'y ajoute pas. Deux --video donneraient deux + écrans à la VM, et l'invité n'afficherait le bureau que sur un seul. + """ + use_gpu, message = gpu_decision(mode, node, screen) + if not use_gpu: + return video, [], message + return [], gpu_install_args(node), message + + +def gpu_install_args(node: str) -> list[str]: + """Arguments virt-install qui donnent la 3D à la VM. + + Deux pièces indissociables : l'accélération sur le virtio-gpu, et un + affichage capable de contexte GL. « egl-headless » joue ce second rôle + SANS remplacer la console VNC — il n'ouvre aucun port, il n'existe que + pour porter le contexte OpenGL. C'est la recette documentée pour associer + 3D et VNC, là où ne vaut que pour SPICE. + """ + return [ + "--video", + "model.type=virtio,model.acceleration.accel3d=on", + "--graphics", + f"type=egl-headless,gl.rendernode={node}", + ] + + ARCH_CLOUD_BASE = "https://geo.mirror.pkgbuild.com/images/latest" CLOUD_IMG_BASE = "https://cloud-images.ubuntu.com" @@ -2798,6 +2895,16 @@ def virt_install( # d'avant : --graphics spice,listen=none graphics = "vnc,listen=127.0.0.1" video = ["--video", "virtio"] + # 3D : allumée d'office quand l'hôte a un GPU (--gpu auto). Une VM + # graphique sans accélération rend tout par le processeur — le bureau + # comme l'émulateur Android qui tourne dedans — et c'est le défaut le plus + # coûteux qu'on puisse laisser en place sans le dire. + gpu_node = args.gpu_node or host_gpu_node() + video, gpu_args, gpu_msg = gpu_apply( + video, args.gpu, gpu_node, graphics != "none" + ) + if gpu_msg: + print(gpu_msg) cmd = [ "virt-install", # Sans --connect, un utilisateur non root vise qemu:///session : le @@ -2871,7 +2978,7 @@ def virt_install( "--channel", "unix,target.type=virtio,target.name=org.qemu.guest_agent.0", ] - cmd += video + cmd += video + gpu_args if args.arch == "s390x": # s390x (IBM Z) : machine s390-ccw-virtio, amorçage IPL/zipl depuis le # disque (ni BIOS ni UEFI/OVMF -> aucun --boot). @@ -3148,6 +3255,19 @@ def build_parser() -> argparse.ArgumentParser: "permet. Les paquets GNOME sont posés par la commande d'installation, " "pas ici.", ) + g_vm.add_argument( + "--gpu", + choices=("auto", "on", "off"), + default="auto", + help="Accélération 3D par le GPU de l'hôte : auto (défaut, activée " + "si un /dev/dri/renderD* existe), on (forcer), off (rendu logiciel).", + ) + g_vm.add_argument( + "--gpu-node", + default="", + help="Nœud de rendu à utiliser (défaut : le premier trouvé). Utile " + "sur un hôte à plusieurs cartes.", + ) g_vm.add_argument( "--osinfo", help="Force la valeur --osinfo (sinon déduite)." ) diff --git a/script/todo/qemu_hardware.py b/script/todo/qemu_hardware.py new file mode 100644 index 0000000..dc480ec --- /dev/null +++ b/script/todo/qemu_hardware.py @@ -0,0 +1,460 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Ressources matérielles d'une VM libvirt : lecture, et plan de modification. + +Le menu d'état de `todo.py` démarre des VM. C'est le seul moment où leur +matériel est modifiable : libvirt refuse de changer vCPU, RAM ou écran d'un +domaine allumé, et l'accélération 3D n'est lue qu'au démarrage de QEMU. Ce +module tient la logique pure de ce moment-là. + +- hw_state(xml) : ce que la VM a aujourd'hui, lu dans son XML. +- hw_plan(...) : les commandes qui l'amènent à l'état voulu, et RIEN de plus + — un plan vide quand rien ne change, et une ligne « skip » + expliquée quand une demande n'a pas d'objet. +- build_want(...) / run_hardware_form(...) : le formulaire Textual. + +Aucune commande n'est lancée ici : `todo.py` les exécute, sous sudo, et les +affiche avant. Le plan reste donc vérifiable sans hyperviseur. + +Deux pièges, appris sur l'hôte : + +- « virt-xml --memory N » ne change que , la cible du ballon. + Élever la RAM au-delà du maximum exige les deux champs à la fois, sinon la + VM plafonne en silence à son ancien maximum. +- « --add-device --graphics type=egl-headless » n'est PAS idempotent : appelé + deux fois, il pose deux affichages. D'où la lecture de l'état AVANT le plan. +""" +from __future__ import annotations + +import xml.etree.ElementTree as ET + +from script.todo.qemu_deploy_form import parse_ram, positive_int + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +# URI système : sous sudo, root y va de lui-même, mais l'expliciter écarte le +# piège documenté dans deploy_qemu.py — un appel non root visant +# qemu:///session, où les domaines du parc n'existent pas. +CONNECT = "qemu:///system" + +# Le virtio-gpu est le SEUL modèle qui porte virgl. Poser accel3d sur un qxl +# ou un cirrus est accepté par le schéma et ne fait rien. +GPU_VIDEO_MODEL = "virtio" + +# Affichages qui donnent un écran à la VM. « egl-headless » n'en est pas un : +# il n'ouvre aucun port et n'existe que pour porter le contexte OpenGL. +SCREEN_TYPES = ("vnc", "spice", "sdl", "desktop") +EGL = "egl-headless" + +# libvirt écrit KiB pour la mémoire, mais le schéma autorise les deux systèmes +# d'unités — KB vaut mille octets, KiB en vaut 1024. On convertit en octets +# d'abord : une table « à peu près » ferait dériver l'affichage de la VM. +_UNIT_BYTES = { + "b": 1, + "bytes": 1, + "kb": 1000, + "k": 1024, + "kib": 1024, + "mb": 1000**2, + "m": 1024**2, + "mib": 1024**2, + "gb": 1000**3, + "g": 1024**3, + "gib": 1024**3, + "tb": 1000**4, + "t": 1024**4, + "tib": 1024**4, +} + + +def _mib(node) -> int: + """Valeur d'un élément mémoire libvirt, en mébioctets.""" + if node is None: + return 0 + factor = _UNIT_BYTES.get((node.get("unit") or "KiB").lower(), 1024) + try: + return int(float((node.text or "0").strip()) * factor) // (1024 * 1024) + except ValueError: + return 0 + + +def ram_field(mib) -> str: + """Valeur du champ RAM : « 32G » plutôt que « 32768 ». + + parse_ram relit les deux, mais cinq chiffres ne tiennent pas dans le + champ : la VM de 32 Go y affichait « 3276 », et un nombre tronqué qu'on + valide sans regarder rétrécit la machine. + """ + try: + mib = int(mib or 0) + except (TypeError, ValueError): + return "" + if mib <= 0: + return "" + return f"{mib // 1024}G" if mib % 1024 == 0 else str(mib) + + +def fmt_mib(mib) -> str: + """1024 -> « 1 Go », 3072 -> « 3 Go », 1536 -> « 1,5 Go », 512 -> « 512 Mo ».""" + try: + mib = int(mib) + except (TypeError, ValueError): + return "?" + if mib <= 0: + return "?" + if mib % 1024 == 0: + return f"{mib // 1024} Go" + if mib > 1024: + return f"{mib / 1024:.1f}".replace(".", ",") + " Go" + return f"{mib} Mo" + + +def hw_state(xml: str, autostart=None) -> dict: + """État matériel lu dans le XML du domaine. + + `autostart` n'est pas dans le XML — il vit dans un lien symbolique côté + libvirt — donc l'appelant le passe (via « virsh dominfo »). + """ + state = { + "name": "", + "vcpus": 0, + "mem_mib": 0, + "max_mem_mib": 0, + "video": "", + "accel3d": False, + "egl": False, + "render": "", + "screen": False, + "autostart": bool(autostart), + } + try: + root = ET.fromstring(xml or "") + except ET.ParseError: + return state + state["name"] = (root.findtext("name") or "").strip() + try: + state["vcpus"] = int((root.findtext("vcpu") or "0").strip()) + except ValueError: + state["vcpus"] = 0 + state["max_mem_mib"] = _mib(root.find("memory")) + state["mem_mib"] = _mib(root.find("currentMemory")) or state["max_mem_mib"] + model = root.find("./devices/video/model") + if model is not None: + state["video"] = model.get("type") or "" + accel = model.find("acceleration") + state["accel3d"] = accel is not None and accel.get("accel3d") == "yes" + for graphics in root.findall("./devices/graphics"): + kind = graphics.get("type") or "" + if kind in SCREEN_TYPES: + state["screen"] = True + elif kind == EGL: + state["egl"] = True + gl = graphics.find("gl") + state["render"] = ( + (gl.get("rendernode") or "") if gl is not None else "" + ) + return state + + +def _virt_xml(name: str, *args) -> list: + """Commande virt-xml qui ÉCRIT la définition persistante du domaine. + + « --define » explicite : sans lui, virt-xml interroge l'utilisateur quand + le domaine tourne, et une invite dans un menu piloté par un script bloque + sans rien dire. + """ + return ["virt-xml", "--connect", CONNECT, name, "--define", *args] + + +def hw_plan(state: dict, want: dict, node: str = "") -> list: + """Commandes menant de `state` à `want`, dans l'ordre. + + Chaque entrée est un dict : {'what': …, 'cmd': [...]} pour ce qui sera + lancé, {'what': …, 'skip': raison} pour ce qui est demandé mais sans + objet. Rien à changer -> liste vide, et l'appelant n'exécute rien. + """ + name = state.get("name") or want.get("name") or "" + plan = [] + if not name: + return plan + + vcpus = positive_int(want.get("vcpus"), 0) + if vcpus and vcpus != state.get("vcpus"): + plan.append( + { + "what": f"vCPU {state.get('vcpus')} → {vcpus}", + "cmd": _virt_xml(name, "--edit", "--vcpus", str(vcpus)), + } + ) + + ram = positive_int(want.get("ram"), 0) + if ram and ram != state.get("mem_mib"): + plan.append( + { + "what": f"RAM {fmt_mib(state.get('mem_mib'))} → {fmt_mib(ram)}", + "cmd": _virt_xml( + name, + "--edit", + "--memory", + f"memory={ram},currentMemory={ram}", + ), + } + ) + + gpu = want.get("gpu") + if gpu is not None: + plan += _gpu_plan(name, state, bool(gpu), node) + + auto = want.get("autostart") + if auto is not None and bool(auto) != bool(state.get("autostart")): + args = ["virsh", "--connect", CONNECT, "autostart"] + if not auto: + args.append("--disable") + plan.append( + { + "what": t("Autostart") + (" : on" if auto else " : off"), + "cmd": args + [name], + } + ) + return plan + + +def _gpu_plan(name: str, state: dict, gpu: bool, node: str) -> list: + """Volet 3D du plan : accélération sur l'écran, et contexte GL.""" + plan = [] + if gpu: + if not state.get("screen"): + return [ + { + "what": t("3D acceleration (host GPU)"), + "skip": t("this VM has no virtual screen"), + } + ] + if not node: + return [ + { + "what": t("3D acceleration (host GPU)"), + "skip": t("no render node on the host"), + } + ] + if not state.get("accel3d") or state.get("video") != GPU_VIDEO_MODEL: + plan.append( + { + "what": t("3D acceleration (host GPU)") + " : on", + "cmd": _virt_xml( + name, + "--edit", + "--video", + f"model.type={GPU_VIDEO_MODEL}" + ",model.acceleration.accel3d=on", + ), + } + ) + if state.get("render") != node: + if state.get("egl"): + # Déjà un affichage GL, mais sur un autre nœud : le corriger + # en place. L'ajouter une seconde fois en poserait DEUX. + plan.append( + { + "what": f"{t('Render node')} → {node}", + "cmd": _virt_xml( + name, + "--edit", + f"type={EGL}", + "--graphics", + f"gl.rendernode={node}", + ), + } + ) + else: + plan.append( + { + "what": f"{t('Render node')} : {node}", + "cmd": _virt_xml( + name, + "--add-device", + "--graphics", + f"type={EGL},gl.rendernode={node}", + ), + } + ) + return plan + if state.get("accel3d"): + plan.append( + { + "what": t("3D acceleration (host GPU)") + " : off", + "cmd": _virt_xml( + name, + "--edit", + "--video", + f"model.type={state.get('video') or GPU_VIDEO_MODEL}" + ",model.acceleration.accel3d=off", + ), + } + ) + if state.get("egl"): + # Ciblé par type : la console VNC de la VM, elle, doit survivre. + plan.append( + { + "what": t("Render node") + " : —", + "cmd": _virt_xml( + name, "--remove-device", "--graphics", f"type={EGL}" + ), + } + ) + return plan + + +def build_want(state: dict, vcpus, ram, gpu, autostart) -> dict: + """Valeurs de widgets -> intention, en retombant sur l'état actuel. + + Un champ vidé ou illisible ne veut pas dire « zéro vCPU » : il veut dire + « n'y touche pas ». Sans ce repli, valider le formulaire sans rien saisir + proposerait de rétrécir la VM à néant. + """ + return { + "name": state.get("name") or "", + "vcpus": positive_int(vcpus, state.get("vcpus") or 0), + "ram": parse_ram(ram) or state.get("mem_mib") or 0, + "gpu": bool(gpu), + "autostart": bool(autostart), + } + + +def gpu_allowed(state: dict, node: str) -> str: + """'' si la 3D est proposable pour cette VM, sinon la raison du refus.""" + if not node: + return t("no render node on the host") + if not state.get("screen"): + return t("this VM has no virtual screen") + return "" + + +def hw_summary(state: dict) -> str: + """Ligne d'état lisible : « 8 vCPU, 32 Go, 3D on (renderD128) ».""" + bits = [f"{state.get('vcpus') or '?'} vCPU", fmt_mib(state.get("mem_mib"))] + if state.get("accel3d") or state.get("render"): + node = state.get("render") or "?" + bits.append(f"3D {node.rsplit('/', 1)[-1]}") + elif state.get("screen"): + bits.append(t("software rendering")) + return ", ".join(bits) + + +def run_hardware_form(rows, node: str = "", run_app: bool = True): + """Formulaire d'ajustement matériel. Renvoie {nom: intention} ou None. + + `rows` est une liste d'états (hw_state). `run_app=False` renvoie + l'instance sans la lancer — c'est ainsi que les tests l'inspectent. + """ + from textual.app import App, ComposeResult + from textual.containers import Horizontal, VerticalScroll + from textual.widgets import Button, Checkbox, Footer, Header, Input, Static + + states = [dict(r) for r in rows if r] + + class HardwareForm(App): + TITLE = t("VM hardware") + # L'intention se lit SUR l'instance, pas dans une fermeture : c'est + # ainsi que les tests montent le formulaire et vérifient ce qu'il + # rend, sans passer par un terminal. + want = None + CSS = """ + #rows { height: 1fr; } + #host { padding: 0 1; } + .vm { padding: 1 1 0 1; text-style: bold; } + .row { height: auto; padding: 0 1; } + .lbl { width: 7; height: 3; content-align: right middle; } + .num { width: 10; } + /* « auto » plutôt qu'une largeur fixe : le libellé traduit change de + longueur, et une case tronquée ne dit plus ce qu'elle coche. */ + .cb3d { width: auto; margin: 0 2 0 1; } + .cbauto { width: auto; } + .warn { padding: 0 3; } + #bar { height: auto; padding: 1; } + """ + BINDINGS = [ + ("ctrl+s", "apply", t("Apply")), + ("escape", "quit", t("Cancel")), + ] + + def compose(self) -> ComposeResult: + yield Header() + head = t("Host GPU:") + " " + head += node if node else t("none (software rendering)") + yield Static(head, id="host") + with VerticalScroll(id="rows"): + for i, st in enumerate(states): + reason = gpu_allowed(st, node) + # Le nom sur SA ligne, avec l'état actuel : les noms du + # parc font trente caractères, en colonne ils seraient + # tronqués — et c'est le nom qui dit quelle VM on règle. + yield Static( + f"{st.get('name', '')} — {hw_summary(st)}", + classes="vm", + ) + with Horizontal(classes="row"): + yield Static("vCPU", classes="lbl") + yield Input( + value=str(st.get("vcpus") or ""), + id=f"vcpus{i}", + classes="num", + ) + yield Static("RAM", classes="lbl") + yield Input( + value=ram_field(st.get("mem_mib")), + id=f"ram{i}", + classes="num", + placeholder="Mo ou G", + ) + yield Checkbox( + t("3D"), + value=bool(st.get("accel3d")), + id=f"gpu{i}", + disabled=bool(reason), + classes="cb3d", + ) + yield Checkbox( + t("Autostart"), + value=bool(st.get("autostart")), + id=f"auto{i}", + classes="cbauto", + ) + if reason: + yield Static(f"⚠ {reason}", classes="warn") + with Horizontal(id="bar"): + yield Button(t("Apply"), variant="primary", id="apply") + yield Button(t("Cancel"), id="cancel") + yield Footer() + + def action_apply(self) -> None: + want = {} + for i, st in enumerate(states): + want[st.get("name", "")] = build_want( + st, + self.query_one(f"#vcpus{i}", Input).value, + self.query_one(f"#ram{i}", Input).value, + self.query_one(f"#gpu{i}", Checkbox).value, + self.query_one(f"#auto{i}", Checkbox).value, + ) + self.want = want + self.exit() + + def on_button_pressed(self, event) -> None: + if event.button.id == "apply": + self.action_apply() + else: + self.exit() + + app = HardwareForm() + if not run_app: + return app + app.run() + return app.want diff --git a/script/todo/todo.py b/script/todo/todo.py index 55aeea6..c0b8b52 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1675,8 +1675,15 @@ class TODO: # menu propose lui-même — laisse un instantané en cours, et le lancement # SUIVANT meurt sur « A snapshot operation is pending and timeout has # expired ». Vécu, et le message ne dit pas quoi faire. + # « -gpu » reste sur swangle par DÉFAUT, même quand la VM a la 3D : un + # « -gpu host » qui échoue ne rend pas la main, l'émulateur reste pendu, et + # ce n'est pas un défaut à imposer sans l'avoir mesuré sur la machine. + # EL_EMULATOR_GPU permet de l'essayer sans toucher au code, une fois le + # nœud de rendu présent dans l'invité (voir script/qemu/README). + _QEMU_EMULATOR_GPU = os.environ.get("EL_EMULATOR_GPU") or "swangle" _QEMU_EMULATOR_FLAGS = ( - "-no-audio -no-boot-anim -no-snapshot-save -gpu swangle" + "-no-audio -no-boot-anim -no-snapshot-save" + f" -gpu {_QEMU_EMULATOR_GPU}" " -skin 540x1140 -prop qemu.sf.lcd_density=240" ) _QEMU_AVD_NAME = "erplibre" @@ -2881,14 +2888,25 @@ class TODO: print(f"\n{t('Target state:')}") print(f" [1] {t('Open (start)')}") print(f" [2] {t('Close (shut down)')}") + print(f" [3] {t('Adjust hardware only (vCPU, RAM, 3D)')}") st = input(t("Choice: ")).strip() if st == "1": action, verb = "start", t("start") elif st == "2": action, verb = "shutdown", t("shut down") + elif st == "3": + self._qemu_adjust_hardware(resolved) + return else: print(t("Cancelled.")) return + # Le matériel d'une VM ne se règle QUE pendant qu'elle est éteinte : + # démarrer est donc le dernier moment pour le faire, et le seul où la + # question tombe juste. + if action == "start" and self._is_yes( + input(f"\n{t('Adjust hardware before starting? (y/N): ')}") + ): + self._qemu_adjust_hardware(resolved) # DOUBLE validation avant d'appliquer. summary = f"{verb} -> {', '.join(resolved)}" if not self._is_yes(input(f"{t('Apply:')} {summary} ? (o/N) : ")): @@ -2902,6 +2920,163 @@ class TODO: print(f"\n{t('Will execute:')} {cmd}") self.execute.exec_command_live(cmd, source_erplibre=False) + @staticmethod + def _qemu_dumpxml(name): + """XML du domaine, ou '' — source de son état matériel.""" + try: + res = subprocess.run( + ["sudo", "virsh", "dumpxml", name], + capture_output=True, + text=True, + timeout=20, + env=TODO._qemu_c_env(), + ) + except (OSError, subprocess.SubprocessError): + return "" + return res.stdout if res.returncode == 0 else "" + + @staticmethod + def _qemu_autostart(name): + """Démarrage automatique activé ? (absent du XML : virsh seul le sait)""" + try: + res = subprocess.run( + ["sudo", "virsh", "dominfo", name], + capture_output=True, + text=True, + timeout=15, + env=TODO._qemu_c_env(), + ) + except (OSError, subprocess.SubprocessError): + return False + for line in res.stdout.splitlines(): + if line.startswith("Autostart:"): + return line.split(":", 1)[1].strip() == "enable" + return False + + def _qemu_ask_bool(self, prompt, default): + """Question fermée dont le DÉFAUT est l'état actuel de la VM. + + Une réponse vide — ou incompréhensible — laisse la VM telle quelle : + sur un formulaire de matériel, le silence ne doit rien modifier. + """ + ans = input(prompt).strip() + if self._is_yes(ans): + return True + if self._is_no(ans): + return False + return default + + def _qemu_host_gpu_node(self): + """Nœud de rendu de l'hôte, vu par deploy_qemu (source unique), ou ''.""" + try: + return self._qemu_import_module().host_gpu_node() + except (OSError, AttributeError, ImportError): + return "" + + def _qemu_adjust_hardware(self, names): + """Règle vCPU, RAM, 3D et démarrage automatique de VM ÉTEINTES. + + Les VM allumées sont écartées, en le disant : virt-xml y écrirait une + définition qui ne prendrait effet qu'au prochain démarrage — un + réglage qui paraît appliqué et ne l'est pas. + """ + from script.todo import qemu_hardware as hw + + off, busy = [], [] + for name in names: + state = self._qemu_domstate(name) + (off if state == "shut off" else busy).append(name) + if busy: + print( + f"\n ⚠ {t('Not shut off, hardware left untouched:')}" + f" {', '.join(busy)}" + ) + if not off: + return + node = self._qemu_host_gpu_node() + gpu_txt = node or t("none (software rendering)") + print(f"\n{t('Host GPU:')} {gpu_txt}") + rows = [ + r + for r in ( + hw.hw_state(self._qemu_dumpxml(n), self._qemu_autostart(n)) + for n in off + ) + if r.get("name") + ] + if not rows: + print(f" ⚠ {t('Unreadable VM definition.')}") + return + for r in rows: + print(f" {r['name']:<30} {hw.hw_summary(r)}") + want = self._qemu_hw_form(rows, node) + if want is None: + print(t("Cancelled.")) + return + if not want: + want = self._qemu_hw_prompts(rows, node) + if not want: + print(t("Cancelled.")) + return + plan = [] + for r in rows: + plan += hw.hw_plan(r, want.get(r["name"]) or {}, node) + for entry in plan: + if entry.get("skip"): + print(f" ⚠ {entry['what']} : {entry['skip']}") + cmds = [e for e in plan if e.get("cmd")] + if not cmds: + print(f"\n{t('Nothing to change.')}") + return + print(f"\n{t('Changes:')}") + for entry in cmds: + print(f" - {entry['what']}") + if not self._is_yes(input(t("Apply these changes? (y/N): "))): + print(t("Cancelled.")) + return + for entry in cmds: + cmd = "sudo " + " ".join(shlex.quote(c) for c in entry["cmd"]) + print(f"\n{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + + def _qemu_hw_form(self, rows, node): + """Formulaire TUI d'ajustement. Renvoie l'intention par VM, {} pour + retomber sur les invites en ligne (textual absent), None si annulé.""" + from script.todo import textual_setup + + if not textual_setup.ensure(): + return {} + try: + from script.todo.qemu_hardware import run_hardware_form + + return run_hardware_form(rows, node) + except ImportError: + return {} + + def _qemu_hw_prompts(self, rows, node): + """Même ajustement, en invites, quand Textual n'est pas disponible.""" + from script.todo import qemu_hardware as hw + + want = {} + for r in rows: + print(f"\n {r['name']} — {hw.hw_summary(r)}") + vcpus = input(f" vCPU [{r.get('vcpus')}] : ") + ram = input(f" RAM [{hw.fmt_mib(r.get('mem_mib'))}] : ") + reason = hw.gpu_allowed(r, node) + if reason: + print(f" ⚠ {t('3D acceleration (host GPU)')} : {reason}") + gpu = False + else: + gpu = self._qemu_ask_bool( + f" {t('3D acceleration (host GPU)')} ? (o/N) : ", + bool(r.get("accel3d")), + ) + auto = self._qemu_ask_bool( + f" {t('Autostart')} ? (o/N) : ", bool(r.get("autostart")) + ) + want[r["name"]] = hw.build_want(r, vcpus, ram, gpu, auto) + return want + @staticmethod def _qemu_dominfo(name): """(vcpus, max_mem_kib) via « virsh dominfo », ou (0, 0).""" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 0806bb7..39db9ca 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3043,6 +3043,74 @@ TRANSLATIONS = { "fr": "VM à modifier (séparées par des virgules) : ", "en": "VMs to change (comma-separated): ", }, + "Adjust hardware only (vCPU, RAM, 3D)": { + "fr": "Régler le matériel seulement (vCPU, RAM, 3D)", + "en": "Adjust hardware only (vCPU, RAM, 3D)", + }, + "Adjust hardware before starting? (y/N): ": { + "fr": "Régler le matériel avant de démarrer ? (o/N) : ", + "en": "Adjust hardware before starting? (y/N): ", + }, + "Not shut off, hardware left untouched:": { + "fr": "Pas éteintes, matériel inchangé :", + "en": "Not shut off, hardware left untouched:", + }, + "Host GPU:": { + "fr": "GPU de l'hôte :", + "en": "Host GPU:", + }, + "none (software rendering)": { + "fr": "aucun (rendu logiciel)", + "en": "none (software rendering)", + }, + "software rendering": { + "fr": "rendu logiciel", + "en": "software rendering", + }, + "Unreadable VM definition.": { + "fr": "Définition de VM illisible.", + "en": "Unreadable VM definition.", + }, + "Nothing to change.": { + "fr": "Rien à changer.", + "en": "Nothing to change.", + }, + "Changes:": { + "fr": "Changements :", + "en": "Changes:", + }, + "Apply these changes? (y/N): ": { + "fr": "Appliquer ces changements ? (o/N) : ", + "en": "Apply these changes? (y/N): ", + }, + "Autostart": { + "fr": "Démarrage automatique", + "en": "Autostart", + }, + "VM hardware": { + "fr": "Matériel des VM", + "en": "VM hardware", + }, + "3D": { + "fr": "3D", + "en": "3D", + }, + "3D acceleration (host GPU)": { + "fr": "Accélération 3D (GPU de l'hôte)", + "en": "3D acceleration (host GPU)", + }, + "Render node": { + "fr": "Nœud de rendu", + "en": "Render node", + }, + "no render node on the host": { + "fr": "aucun nœud de rendu sur l'hôte", + "en": "no render node on the host", + }, + "this VM has no virtual screen": { + "fr": "cette VM n'a pas d'écran virtuel", + "en": "this VM has no virtual screen", + }, "Unknown VM(s):": { "fr": "VM inconnue(s) :", "en": "Unknown VM(s):", diff --git a/test/test_qemu_emulator_menu.py b/test/test_qemu_emulator_menu.py index 8386dc0..387446f 100644 --- a/test/test_qemu_emulator_menu.py +++ b/test/test_qemu_emulator_menu.py @@ -15,6 +15,7 @@ IP_VM:5555). Une redirection vers l'IP de la VM ne peut donc PAS aboutir, et seul un dernier saut dans la VM place « localhost » au bon endroit. """ +import os import socket import subprocess import sys @@ -460,5 +461,44 @@ class TestPortInUse(unittest.TestCase): self.assertFalse(TODO._port_in_use(port)) +class TestEmulatorGpuOverride(unittest.TestCase): + """« -gpu host » doit être essayable sans toucher au code. + + Les drapeaux sont figés à l'import de todo.py : la variable doit donc être + lue AVANT, ce qu'un sous-processus est seul à démontrer. Le défaut reste + swangle — mesuré — car un contexte GL qui échoue laisse l'émulateur pendu + au lieu de retomber. + """ + + def _flags(self, env=None): + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + environ = dict(os.environ, PYTHONPATH=root) + environ.update(env or {}) + res = subprocess.run( + [ + sys.executable, + "-c", + "import sys; sys.argv=['todo.py']\n" + "from script.todo.todo import TODO\n" + "print(TODO._QEMU_EMULATOR_FLAGS)", + ], + capture_output=True, + text=True, + cwd=root, + env=environ, + timeout=120, + ) + self.assertEqual(0, res.returncode, res.stderr[-400:]) + return res.stdout + + def test_the_default_is_the_measured_one(self): + self.assertIn("-gpu swangle", self._flags()) + + def test_the_variable_replaces_it(self): + out = self._flags({"EL_EMULATOR_GPU": "host"}) + self.assertIn("-gpu host", out) + self.assertNotIn("swangle", out) + + if __name__ == "__main__": unittest.main() diff --git a/test/test_qemu_gpu.py b/test/test_qemu_gpu.py new file mode 100644 index 0000000..94e173a --- /dev/null +++ b/test/test_qemu_gpu.py @@ -0,0 +1,561 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""GPU de l'hôte : détection, déploiement, et réglage d'une VM éteinte. + +Une VM graphique sans accélération rend tout par le processeur — le bureau +comme l'émulateur Android qui tourne dedans. L'hôte a un GPU ou non ; s'il en +a un, la VM doit le prendre, et c'est le défaut. + +Ce que ces tests gardent, appris en le cassant : + +- UN SEUL « --video » : la 3D remplace le « --video virtio », elle ne s'y + ajoute pas — deux écrans, et l'invité n'en peuple qu'un. +- « --add-device --graphics egl-headless » n'est PAS idempotent : deux appels, + deux affichages. D'où l'état lu avant tout plan. +- « virt-xml --memory N » ne touche que : la RAM plafonnait en + silence à l'ancien maximum. +- Le retrait de la 3D cible le TYPE egl-headless : sans ce ciblage, c'est la + console VNC de la VM qui disparaît. +- « --define » sur chaque commande : sans lui, virt-xml POSE UNE QUESTION et + le menu se bloque sans rien dire. +""" + +import contextlib +import importlib.util +import io +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.argv = ["todo.py"] +from script.todo import qemu_hardware as hw # noqa: E402 +from script.todo.todo import TODO # noqa: E402 + + +def _deploy_qemu(): + """deploy_qemu.py chargé comme module, comme le fait todo.py.""" + path = Path(__file__).resolve().parents[1] / "script/qemu/deploy_qemu.py" + spec = importlib.util.spec_from_file_location("deploy_qemu", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +DQ = _deploy_qemu() + +# XML réel d'une VM du parc, réduit à ce qui décide du matériel. +XML_SANS_3D = """ + erplibre-ubuntu-2604-gnome + 33554432 + 33554432 + 8 + + + + + + + +""" + +XML_AVEC_3D = XML_SANS_3D.replace( + "", + "" + "", +).replace( + "", + "\n " + "", +) + +XML_SERVEUR = """ + erplibre-serveur + 2097152 + 2 + + +""" + +NODE = "/dev/dri/renderD128" + + +class TestDetection(unittest.TestCase): + """Le GPU se lit dans /dev/dri, pas dans une liste de cartes connues.""" + + def _dri(self, *names): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + for n in names: + Path(tmp.name, n).touch() + return tmp.name + + def test_it_keeps_render_nodes_and_ignores_the_rest(self): + """« card0 » est le nœud d'affichage, pas de rendu : QEMU ouvre + renderD*, et lui donner card0 échouerait au démarrage.""" + found = DQ.host_render_nodes( + self._dri("card0", "renderD128", "by-path") + ) + self.assertEqual(1, len(found)) + self.assertTrue(found[0].endswith("renderD128")) + + def test_several_cards_come_out_sorted(self): + found = DQ.host_render_nodes(self._dri("renderD129", "renderD128")) + self.assertEqual( + ["renderD128", "renderD129"], [Path(f).name for f in found] + ) + + def test_a_host_without_dri_answers_empty(self): + """Cet hyperviseur-ci est lui-même une VM sans GPU : /dev/dri n'existe + pas du tout. Une exception ici ferait échouer TOUT déploiement.""" + self.assertEqual([], DQ.host_render_nodes("/nexistepas/dri")) + self.assertEqual("", DQ.host_gpu_node("/nexistepas/dri")) + + def test_presence_is_the_test_not_our_own_access(self): + """Le nœud appartient au groupe « render » ; libvirt donne l'accès au + démarrage du domaine. Tester nos droits rejetterait un hôte valable.""" + d = self._dri("renderD128") + os.chmod(Path(d, "renderD128"), 0o000) + self.assertTrue(DQ.host_gpu_node(d).endswith("renderD128")) + + +class TestDecision(unittest.TestCase): + """« Par défaut avec GPU s'il existe » — et le silence n'est pas permis.""" + + def test_auto_takes_the_gpu_when_there_is_one(self): + on, msg = DQ.gpu_decision("auto", NODE, True) + self.assertTrue(on) + self.assertIn(NODE, msg) + + def test_auto_without_gpu_says_why_it_falls_back(self): + """Sans ce message, une VM en rendu logiciel ne s'explique pas — et on + cherche la lenteur ailleurs pendant des heures.""" + on, msg = DQ.gpu_decision("auto", "", True) + self.assertFalse(on) + self.assertTrue(msg.strip()) + + def test_forcing_it_without_a_node_refuses_and_warns(self): + """libvirt refuse de démarrer un domaine dont le rendernode manque : + obéir aveuglément à « --gpu on » livrerait une VM qui ne démarre pas. + """ + on, msg = DQ.gpu_decision("on", "", True) + self.assertFalse(on) + self.assertIn("⚠", msg) + + def test_off_stays_off_and_silent(self): + self.assertEqual((False, ""), DQ.gpu_decision("off", NODE, True)) + + def test_no_screen_no_3d(self): + """Une VM serveur n'a pas d'écran : la 3D n'y accélérerait rien, et le + « --edit --video » échouerait faute de périphérique vidéo.""" + on, _ = DQ.gpu_decision("auto", NODE, False) + self.assertFalse(on) + + +class TestDeployArgs(unittest.TestCase): + def test_the_args_carry_both_halves(self): + """L'accélération sur le virtio-gpu ET un affichage capable de + contexte GL : l'une sans l'autre ne donne aucune 3D.""" + args = DQ.gpu_install_args(NODE) + self.assertIn("model.acceleration.accel3d=on", " ".join(args)) + self.assertIn(f"gl.rendernode={NODE}", " ".join(args)) + + def test_only_one_video_device_survives(self): + """Le bug qui donne deux écrans : garder « --video virtio » à côté du + « --video » de la 3D. L'invité n'en peuple alors qu'un.""" + video, gpu_args, _ = DQ.gpu_apply( + ["--video", "virtio"], "auto", NODE, True + ) + self.assertEqual([], video) + self.assertEqual(1, (video + gpu_args).count("--video")) + + def test_without_gpu_the_plain_video_stays(self): + video, gpu_args, _ = DQ.gpu_apply( + ["--video", "virtio"], "auto", "", True + ) + self.assertEqual(["--video", "virtio"], video) + self.assertEqual([], gpu_args) + + def test_the_egl_display_is_added_beside_vnc_not_instead(self): + """« egl-headless » n'ouvre aucun port : il ne remplace pas la console + VNC, il porte le contexte OpenGL. Les deux cohabitent.""" + args = DQ.gpu_install_args(NODE) + self.assertIn("type=egl-headless", " ".join(args)) + self.assertNotIn("vnc", " ".join(args)) + + +class TestReadState(unittest.TestCase): + def test_it_reads_what_the_vm_has(self): + st = hw.hw_state(XML_SANS_3D) + self.assertEqual("erplibre-ubuntu-2604-gnome", st["name"]) + self.assertEqual(8, st["vcpus"]) + self.assertEqual(32768, st["mem_mib"]) + self.assertEqual("virtio", st["video"]) + self.assertFalse(st["accel3d"]) + self.assertTrue(st["screen"]) + + def test_it_sees_an_existing_3d_setup(self): + st = hw.hw_state(XML_AVEC_3D) + self.assertTrue(st["accel3d"]) + self.assertTrue(st["egl"]) + self.assertEqual(NODE, st["render"]) + + def test_egl_headless_alone_is_not_a_screen(self): + """Il n'affiche rien et n'ouvre aucun port. Le compter comme écran + proposerait la 3D à une VM qui n'a rien à accélérer.""" + xml = XML_SERVEUR.replace( + "", + "", + ) + self.assertFalse(hw.hw_state(xml)["screen"]) + + def test_the_balloon_target_is_what_the_vm_gets(self): + """ est le maximum, ce que la VM voit. Lire le + premier annoncerait 32 Go à une VM qui en a 4.""" + xml = XML_SANS_3D.replace( + "33554432", + "4194304", + ) + self.assertEqual(4096, hw.hw_state(xml)["mem_mib"]) + + def test_units_are_exact(self): + """KB vaut mille octets, KiB en vaut 1024 : le schéma libvirt autorise + les deux, et « à peu près » se voit dans le tableau.""" + for unit, value, mib in ( + ("KiB", 1048576, 1024), + ("MiB", 2048, 2048), + ("GiB", 4, 4096), + ("bytes", 1073741824, 1024), + ): + xml = XML_SERVEUR.replace( + "2097152", + f"{value}", + ) + self.assertEqual(mib, hw.hw_state(xml)["mem_mib"], unit) + + def test_broken_xml_gives_an_empty_state(self): + st = hw.hw_state(" : la VM plafonne + à son ancien maximum, sans un mot.""" + plan = self._plan(XML_SANS_3D, {"ram": 65536}) + arg = [c for c in plan[0]["cmd"] if c.startswith("memory=")][0] + self.assertIn("memory=65536", arg) + self.assertIn("currentMemory=65536", arg) + + def test_3d_without_a_host_node_is_refused_with_a_reason(self): + plan = hw.hw_plan(hw.hw_state(XML_SANS_3D), {"gpu": True}, "") + self.assertEqual(1, len(plan)) + self.assertNotIn("cmd", plan[0]) + self.assertTrue(plan[0]["skip"]) + + def test_3d_on_a_screenless_vm_is_refused(self): + """« --edit --video » échouerait : il n'y a pas de périphérique vidéo + à modifier. Mieux vaut le dire que laisser virt-xml protester.""" + plan = self._plan(XML_SERVEUR, {"gpu": True}) + self.assertEqual(1, len(plan)) + self.assertIn("skip", plan[0]) + + def test_autostart_only_moves_when_it_differs(self): + self.assertEqual( + [], self._plan(XML_SANS_3D, {"autostart": True}, autostart=True) + ) + plan = self._plan(XML_SANS_3D, {"autostart": False}, autostart=True) + self.assertIn("--disable", plan[0]["cmd"]) + + def test_every_command_defines_and_names_its_uri(self): + """Sans « --define », virt-xml INTERROGE l'utilisateur quand le domaine + tourne : le menu se bloque sur une question qu'on ne voit pas. Sans + « --connect », un appel non root viserait qemu:///session, où les VM du + parc n'existent pas.""" + plan = self._plan(XML_SANS_3D, {"vcpus": 4, "ram": 8192, "gpu": True}) + self.assertTrue(plan) + for entry in plan: + self.assertIn("--define", entry["cmd"]) + self.assertIn("qemu:///system", entry["cmd"]) + + def test_a_state_without_a_name_plans_nothing(self): + """Un dumpxml illisible ne doit pas produire une commande sans cible.""" + self.assertEqual([], hw.hw_plan({}, {"vcpus": 4}, NODE)) + + +class TestWant(unittest.TestCase): + def test_empty_fields_keep_the_current_values(self): + """Valider sans rien saisir ne doit pas rétrécir la VM à néant.""" + st = hw.hw_state(XML_SANS_3D) + want = hw.build_want(st, "", "", False, False) + self.assertEqual(8, want["vcpus"]) + self.assertEqual(32768, want["ram"]) + + def test_gigabytes_are_understood(self): + st = hw.hw_state(XML_SANS_3D) + self.assertEqual(8192, hw.build_want(st, "", "8G", 0, 0)["ram"]) + + def test_nonsense_does_not_shrink_the_vm(self): + st = hw.hw_state(XML_SANS_3D) + want = hw.build_want(st, "beaucoup", "gros", 0, 0) + self.assertEqual(8, want["vcpus"]) + self.assertEqual(32768, want["ram"]) + + +class TestDisplay(unittest.TestCase): + def test_the_ram_field_stays_short_enough_to_read(self): + """« 32768 » ne tient pas dans le champ et s'affichait « 3276 » : un + nombre tronqué qu'on valide sans regarder rétrécit la machine.""" + self.assertEqual("32G", hw.ram_field(32768)) + self.assertEqual("1536", hw.ram_field(1536)) + for mib in (1024, 12288, 32768, 65536): + self.assertLessEqual(len(hw.ram_field(mib)), 4) + + def test_sizes_read_like_sizes(self): + self.assertEqual("1 Go", hw.fmt_mib(1024)) + self.assertEqual("1,5 Go", hw.fmt_mib(1536)) + self.assertEqual("512 Mo", hw.fmt_mib(512)) + + def test_the_summary_names_the_render_node(self): + self.assertIn("renderD128", hw.hw_summary(hw.hw_state(XML_AVEC_3D))) + + def test_a_screen_without_3d_says_software_rendering(self): + summary = hw.hw_summary(hw.hw_state(XML_SANS_3D)) + self.assertIn("8 vCPU", summary) + self.assertIn(hw.t("software rendering"), summary) + + +class TestMenuGlue(unittest.TestCase): + """Le raccord dans todo.py : ce qui est éteint, et ce qui s'exécute.""" + + def _todo(self, states, xml=XML_SANS_3D, node=NODE): + todo = TODO.__new__(TODO) + todo._qemu_domstate = lambda name: states[name] + todo._qemu_dumpxml = lambda name: xml.replace( + "erplibre-ubuntu-2604-gnome", name + ) + todo._qemu_autostart = lambda name: False + todo._qemu_host_gpu_node = lambda: node + todo.launched = [] + todo.execute = mock.Mock() + todo.execute.exec_command_live = ( + lambda cmd, **kw: todo.launched.append(cmd) + ) + return todo + + def _run(self, todo, names, answers): + it = iter(answers) + out = io.StringIO() + with mock.patch("builtins.input", lambda *a: next(it, "")): + with contextlib.redirect_stdout(out): + todo._qemu_adjust_hardware(names) + return out.getvalue() + + def test_a_running_vm_is_left_alone_and_said_so(self): + """virt-xml y écrirait une définition qui ne prend effet qu'au + prochain démarrage : un réglage qui paraît appliqué et ne l'est pas.""" + todo = self._todo({"vm-a": "running"}) + todo._qemu_hw_form = lambda rows, node: {} + out = self._run(todo, ["vm-a"], []) + self.assertIn("vm-a", out) + self.assertEqual([], todo.launched) + + def test_a_shut_off_vm_is_adjusted(self): + todo = self._todo({"vm-a": "shut off"}) + todo._qemu_hw_form = lambda rows, node: { + "vm-a": {"vcpus": 4, "ram": 8192, "gpu": True} + } + self._run(todo, ["vm-a"], ["o"]) + joined = " ".join(todo.launched) + self.assertIn("--vcpus 4", joined) + self.assertIn("accel3d=on", joined) + self.assertTrue(all(c.startswith("sudo ") for c in todo.launched)) + + def test_nothing_to_change_launches_nothing(self): + todo = self._todo({"vm-a": "shut off"}) + todo._qemu_hw_form = lambda rows, node: { + "vm-a": {"vcpus": 8, "ram": 32768, "gpu": False} + } + out = self._run(todo, ["vm-a"], []) + self.assertEqual([], todo.launched) + self.assertIn(hw.t("Nothing to change."), out) + + def test_refusing_the_confirmation_launches_nothing(self): + todo = self._todo({"vm-a": "shut off"}) + todo._qemu_hw_form = lambda rows, node: {"vm-a": {"vcpus": 4}} + self._run(todo, ["vm-a"], ["n"]) + self.assertEqual([], todo.launched) + + def test_cancelling_the_form_launches_nothing(self): + todo = self._todo({"vm-a": "shut off"}) + todo._qemu_hw_form = lambda rows, node: None + self._run(todo, ["vm-a"], []) + self.assertEqual([], todo.launched) + + def test_the_skipped_3d_is_explained_not_silent(self): + todo = self._todo({"vm-a": "shut off"}, node="") + todo._qemu_hw_form = lambda rows, node: {"vm-a": {"gpu": True}} + out = self._run(todo, ["vm-a"], []) + self.assertIn(hw.t("no render node on the host"), out) + self.assertEqual([], todo.launched) + + def test_the_host_gpu_is_announced_before_anything_else(self): + todo = self._todo({"vm-a": "shut off"}) + todo._qemu_hw_form = lambda rows, node: None + out = self._run(todo, ["vm-a"], []) + self.assertIn(NODE, out) + + def test_the_prompts_take_over_when_textual_is_absent(self): + """Le repli en ligne n'est pas décoratif : sans Textual, c'est la SEULE + voie, et un {} mal interprété annulerait tout.""" + todo = self._todo({"vm-a": "shut off"}) + todo._qemu_hw_form = lambda rows, node: {} + self._run(todo, ["vm-a"], ["6", "", "o", "n", "o"]) + self.assertIn("--vcpus 6", " ".join(todo.launched)) + + def test_an_empty_answer_keeps_the_current_state(self): + """Le défaut d'une question fermée est l'état ACTUEL de la VM : sur un + formulaire de matériel, le silence ne modifie rien.""" + todo = TODO.__new__(TODO) + with mock.patch("builtins.input", lambda *a: ""): + self.assertTrue(todo._qemu_ask_bool("? ", True)) + self.assertFalse(todo._qemu_ask_bool("? ", False)) + with mock.patch("builtins.input", lambda *a: "n'importe quoi"): + self.assertTrue(todo._qemu_ask_bool("? ", True)) + + def test_autostart_is_read_from_virsh(self): + out = "Id: -\nName: vm-a\nAutostart: enable\n" + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess([], 0, out, ""), + ): + self.assertTrue(TODO._qemu_autostart("vm-a")) + with mock.patch("subprocess.run", side_effect=OSError): + self.assertFalse(TODO._qemu_autostart("vm-a")) + + +class TestForm(unittest.IsolatedAsyncioTestCase): + """Le formulaire monté pour de vrai : ce qu'il propose et ce qu'il rend.""" + + async def _mount(self, rows, node): + app = hw.run_hardware_form(rows, node, run_app=False) + return app + + async def test_it_returns_the_intention_on_apply(self): + from textual.widgets import Checkbox, Input + + app = await self._mount([hw.hw_state(XML_SANS_3D)], NODE) + async with app.run_test() as pilot: + await pilot.pause() + app.query_one("#vcpus0", Input).value = "6" + app.query_one("#ram0", Input).value = "8G" + app.query_one("#gpu0", Checkbox).value = True + await pilot.press("ctrl+s") + await pilot.pause() + want = app.want["erplibre-ubuntu-2604-gnome"] + self.assertEqual(6, want["vcpus"]) + self.assertEqual(8192, want["ram"]) + self.assertTrue(want["gpu"]) + + async def test_escape_returns_nothing(self): + app = await self._mount([hw.hw_state(XML_SANS_3D)], NODE) + async with app.run_test() as pilot: + await pilot.press("escape") + await pilot.pause() + self.assertIsNone(app.want) + + async def test_3d_is_out_of_reach_without_a_host_node(self): + """Cocher une case qui ne peut rien produire ferait attendre une + accélération que l'hôte ne sait pas donner.""" + from textual.widgets import Checkbox + + app = await self._mount([hw.hw_state(XML_SANS_3D)], "") + async with app.run_test() as pilot: + await pilot.pause() + self.assertTrue(app.query_one("#gpu0", Checkbox).disabled) + + async def test_3d_is_out_of_reach_for_a_screenless_vm(self): + from textual.widgets import Checkbox + + app = await self._mount([hw.hw_state(XML_SERVEUR)], NODE) + async with app.run_test() as pilot: + await pilot.pause() + self.assertTrue(app.query_one("#gpu0", Checkbox).disabled) + + async def test_the_fields_start_on_the_current_values(self): + from textual.widgets import Input + + app = await self._mount([hw.hw_state(XML_SANS_3D)], NODE) + async with app.run_test() as pilot: + await pilot.pause() + self.assertEqual("8", app.query_one("#vcpus0", Input).value) + self.assertEqual("32G", app.query_one("#ram0", Input).value) + + async def test_it_fits_in_eighty_columns(self): + """Un terminal de 80 colonnes est le plus petit qu'on rencontre ; + au-delà, les libellés se tronquent en « Démarrage automatiq… ».""" + import re + + rows = [hw.hw_state(XML_SANS_3D), hw.hw_state(XML_SERVEUR)] + app = await self._mount(rows, NODE) + async with app.run_test(size=(80, 24)) as pilot: + await pilot.pause() + svg = app.export_screenshot() + texte = " ".join(re.findall(r">([^<>]+)", svg)) + self.assertNotIn("…", texte) + + +if __name__ == "__main__": + unittest.main(verbosity=1) From c54bfb7974baf0589509f52822032520b9395144 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 21 Aug 2026 23:13:42 -0400 Subject: [PATCH 55/55] =?UTF-8?q?[ADD]=20qemu=20:=20r=C3=A9gler=20le=20mod?= =?UTF-8?q?e=20CPU,=20les=20=C3=A9crans=20et=20le=20r=C3=A9seau=20d'une=20?= =?UTF-8?q?VM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le formulaire matériel ne réglait que vCPU, RAM, 3D et démarrage. Trois manques : le mode CPU, qui décide si on peut virtualiser DANS la VM, les écrans du virtio-gpu, et le réseau — dont le passage au pont, seul moyen d'exposer la VM sur le LAN. La vram n'est pas offerte : domxml-to-native prouve que QEMU ne la reçoit jamais sur un virtio-gpu, seul max_outputs y arrive. Vérifié sur un domaine jetable et un pont d'essai : max_outputs=2, -cpu host, vnet sur le pont, MAC et emplacement PCI conservés. 486 tests. --- EN --- The hardware form only set vCPU, RAM, 3D and autostart. Three gaps: the CPU mode, which decides whether one can virtualize INSIDE the VM, the virtio-GPU screens, and the network — including the switch to a bridge, the only way to put the VM on the LAN. vram is not offered: domxml-to-native proves QEMU never receives it on a virtio-GPU, only max_outputs gets through. Verified on a throwaway domain and a test bridge: max_outputs=2, -cpu host, vnet on the bridge, MAC and PCI slot preserved. 486 tests. Assisted-by: Claude Opus 5 --- script/qemu/README.base.md | 38 +++- script/qemu/README.fr.md | 20 ++- script/qemu/README.md | 18 +- script/todo/qemu_hardware.py | 240 ++++++++++++++++++++++++- script/todo/todo.py | 105 ++++++++++- script/todo/todo_i18n.py | 16 ++ test/test_qemu_gpu.py | 336 +++++++++++++++++++++++++++++++++-- 7 files changed, 739 insertions(+), 34 deletions(-) diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md index f216e83..cf6668a 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -705,8 +705,22 @@ sudo virsh dumpxml | grep -A2 -E "accel3d|egl-headless" An existing VM is adjusted from the TODO menu **while it is shut off**: libvirt only reads these settings when QEMU starts. `QEMU/KVM › List VMs › [2] Change the state`, then either accept *Adjust hardware before starting*, -or take `[3] Adjust hardware only`. vCPU, RAM, 3D and autostart are set -there — in a form when Textual is available, in prompts otherwise. +or take `[3] Adjust hardware only`. In a form when Textual is available, in +prompts otherwise, it sets: + +- **vCPU, RAM, autostart** — the plain sizing knobs. +- **CPU mode** — `host-passthrough` (what the fleet uses) hands the host CPU + instructions over as they are: that is what makes nested virtualization + possible *inside* the VM. `host-model` describes an equivalent model, + migratable to another machine. +- **Screens** — the virtio-GPU `heads`, which becomes `max_outputs` on the + QEMU command line. `vram` is deliberately *not* offered: on a virtio-GPU + libvirt writes it into the XML and QEMU never receives it (check with + `virsh domxml-to-native` — only `max_outputs` shows up). Only qxl uses vram. +- **Network** — the libvirt networks and the host bridges, the latter to put + the VM on the LAN (see the bridge section below). Switching keeps the MAC + address and the PCI slot, so the guest finds *its* card again — same + interface name, same DHCP lease. Two things worth knowing: @@ -748,9 +762,23 @@ sudo virsh dumpxml | grep -A2 -E "accel3d|egl-headless" Une VM déjà installée se règle depuis le menu TODO **pendant qu'elle est éteinte** : libvirt ne lit ces réglages qu'au démarrage de QEMU. `QEMU/KVM › Liste des VM › [2] Changer l'état`, puis acceptez *Régler le matériel avant de -démarrer*, ou prenez `[3] Régler le matériel seulement`. vCPU, RAM, 3D et -démarrage automatique s'y règlent — en formulaire si Textual est présent, en -invites sinon. +démarrer*, ou prenez `[3] Régler le matériel seulement`. En formulaire si +Textual est présent, en invites sinon, il règle : + +- **vCPU, RAM, démarrage automatique** — le dimensionnement ordinaire. +- **Mode CPU** — `host-passthrough` (celui du parc) donne les instructions du + processeur hôte telles quelles : c'est lui qui rend la virtualisation + imbriquée possible *dans* la VM. `host-model` décrit un modèle équivalent, + migrable vers une autre machine. +- **Écrans** — le `heads` du virtio-gpu, qui devient `max_outputs` sur la + ligne QEMU. La `vram` n'est délibérément *pas* proposée : sur un virtio-gpu, + libvirt l'écrit dans le XML et QEMU ne la reçoit jamais (à vérifier avec + `virsh domxml-to-native` : seul `max_outputs` y apparaît). Seul qxl la + consomme. +- **Réseau** — les réseaux libvirt et les ponts de l'hôte, ces derniers pour + poser la VM sur le LAN (voir la section du pont plus bas). Le basculement + garde l'adresse MAC et l'emplacement PCI : l'invité retrouve *sa* carte, + donc son nom d'interface et son bail DHCP. Deux choses à savoir : diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index d5e35ca..fcff7ef 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -385,9 +385,23 @@ sudo virsh dumpxml | grep -A2 -E "accel3d|egl-headless" Une VM déjà installée se règle depuis le menu TODO **pendant qu'elle est éteinte** : libvirt ne lit ces réglages qu'au démarrage de QEMU. `QEMU/KVM › Liste des VM › [2] Changer l'état`, puis acceptez *Régler le matériel avant de -démarrer*, ou prenez `[3] Régler le matériel seulement`. vCPU, RAM, 3D et -démarrage automatique s'y règlent — en formulaire si Textual est présent, en -invites sinon. +démarrer*, ou prenez `[3] Régler le matériel seulement`. En formulaire si +Textual est présent, en invites sinon, il règle : + +- **vCPU, RAM, démarrage automatique** — le dimensionnement ordinaire. +- **Mode CPU** — `host-passthrough` (celui du parc) donne les instructions du + processeur hôte telles quelles : c'est lui qui rend la virtualisation + imbriquée possible *dans* la VM. `host-model` décrit un modèle équivalent, + migrable vers une autre machine. +- **Écrans** — le `heads` du virtio-gpu, qui devient `max_outputs` sur la + ligne QEMU. La `vram` n'est délibérément *pas* proposée : sur un virtio-gpu, + libvirt l'écrit dans le XML et QEMU ne la reçoit jamais (à vérifier avec + `virsh domxml-to-native` : seul `max_outputs` y apparaît). Seul qxl la + consomme. +- **Réseau** — les réseaux libvirt et les ponts de l'hôte, ces derniers pour + poser la VM sur le LAN (voir la section du pont plus bas). Le basculement + garde l'adresse MAC et l'emplacement PCI : l'invité retrouve *sa* carte, + donc son nom d'interface et son bail DHCP. Deux choses à savoir : diff --git a/script/qemu/README.md b/script/qemu/README.md index a6775ed..38cf1e2 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -363,8 +363,22 @@ sudo virsh dumpxml | grep -A2 -E "accel3d|egl-headless" An existing VM is adjusted from the TODO menu **while it is shut off**: libvirt only reads these settings when QEMU starts. `QEMU/KVM › List VMs › [2] Change the state`, then either accept *Adjust hardware before starting*, -or take `[3] Adjust hardware only`. vCPU, RAM, 3D and autostart are set -there — in a form when Textual is available, in prompts otherwise. +or take `[3] Adjust hardware only`. In a form when Textual is available, in +prompts otherwise, it sets: + +- **vCPU, RAM, autostart** — the plain sizing knobs. +- **CPU mode** — `host-passthrough` (what the fleet uses) hands the host CPU + instructions over as they are: that is what makes nested virtualization + possible *inside* the VM. `host-model` describes an equivalent model, + migratable to another machine. +- **Screens** — the virtio-GPU `heads`, which becomes `max_outputs` on the + QEMU command line. `vram` is deliberately *not* offered: on a virtio-GPU + libvirt writes it into the XML and QEMU never receives it (check with + `virsh domxml-to-native` — only `max_outputs` shows up). Only qxl uses vram. +- **Network** — the libvirt networks and the host bridges, the latter to put + the VM on the LAN (see the bridge section below). Switching keeps the MAC + address and the PCI slot, so the guest finds *its* card again — same + interface name, same DHCP lease. Two things worth knowing: diff --git a/script/todo/qemu_hardware.py b/script/todo/qemu_hardware.py index dc480ec..700cfc4 100644 --- a/script/todo/qemu_hardware.py +++ b/script/todo/qemu_hardware.py @@ -48,6 +48,21 @@ CONNECT = "qemu:///system" # ou un cirrus est accepté par le schéma et ne fait rien. GPU_VIDEO_MODEL = "virtio" +# Modes CPU proposés. « host-passthrough » donne les instructions du +# processeur hôte telles quelles — c'est ce qui rend la virtualisation +# IMBRIQUÉE possible dans la VM, et ce que virt-install pose par défaut ; +# « host-model » décrit un modèle équivalent, migrable vers une autre machine. +# Les attributs check/migratable accompagnent le passthrough, comme +# virt-install les écrit : sans eux libvirt vérifie un modèle qu'il n'a pas +# calculé. +CPU_MODES = ("host-passthrough", "host-model") +CPU_EXTRA = {"host-passthrough": ",check=none,migratable=on"} + +# « vram » n'est PAS proposé : sur un virtio-gpu, libvirt l'écrit dans le XML +# et QEMU ne le reçoit jamais — vérifié par « virsh domxml-to-native », qui +# ne montre que « max_outputs » (les écrans). Ce serait un bouton sans effet. +# Seul qxl consomme vram, et le parc n'utilise pas qxl. + # Affichages qui donnent un écran à la VM. « egl-headless » n'en est pas un : # il n'ouvre aucun port et n'existe que pour porter le contexte OpenGL. SCREEN_TYPES = ("vnc", "spice", "sdl", "desktop") @@ -132,6 +147,11 @@ def hw_state(xml: str, autostart=None) -> dict: "egl": False, "render": "", "screen": False, + "heads": 1, + "cpu": "", + # PREMIÈRE interface seulement : c'est celle que « virt-xml --edit + # --network » modifie, et une VM du parc n'en a qu'une. + "net": "", "autostart": bool(autostart), } try: @@ -145,9 +165,23 @@ def hw_state(xml: str, autostart=None) -> dict: state["vcpus"] = 0 state["max_mem_mib"] = _mib(root.find("memory")) state["mem_mib"] = _mib(root.find("currentMemory")) or state["max_mem_mib"] + cpu = root.find("cpu") + if cpu is not None: + state["cpu"] = cpu.get("mode") or "" + iface = root.find("./devices/interface") + if iface is not None: + source = iface.find("source") + state["net"] = net_token( + iface.get("type") or "", + source if source is not None else None, + ) model = root.find("./devices/video/model") if model is not None: state["video"] = model.get("type") or "" + try: + state["heads"] = int(model.get("heads") or 1) + except ValueError: + state["heads"] = 1 accel = model.find("acceleration") state["accel3d"] = accel is not None and accel.get("accel3d") == "yes" for graphics in root.findall("./devices/graphics"): @@ -163,6 +197,62 @@ def hw_state(xml: str, autostart=None) -> dict: return state +def net_token(kind: str, source) -> str: + """Identité du réseau d'une interface : « network:default », « bridge:br0 ». + + Un seul jeton pour comparer, choisir et appliquer : le type et le nom vont + toujours ensemble — « br0 » ne veut rien dire sans savoir que c'est un + pont, et libvirt refuse type='network' avec un pont pour source. + """ + if not kind: + return "" + name = "" + if source is not None: + name = ( + source.get("network") + or source.get("bridge") + or source.get("dev") + or "" + ) + return f"{kind}:{name}" if name else kind + + +def net_label(token: str) -> str: + """« network:default » -> « default », « bridge:br0 » -> « br0 (pont) ». + + Le réseau libvirt ne porte pas de suffixe : c'est le cas ordinaire, et le + nommer allongeait le libellé au-delà de la liste déroulante. Seul le pont + est marqué, parce que c'est lui qui change le comportement de la VM. + """ + if not token: + return "—" + kind, _, name = token.partition(":") + if kind == "network": + return name + if kind == "bridge": + return f"{name} ({t('bridge')})" + return token + + +# Les modes CPU s'affichent en court : la valeur écrite dans le XML reste +# entière, seul le libellé raccourcit. +CPU_LABELS = {"host-passthrough": "passthrough", "host-model": "model"} + + +def cpu_label(mode: str) -> str: + return CPU_LABELS.get(mode, mode or "—") + + +def net_spec(token: str) -> str: + """Jeton -> argument « --network » de virt-xml.""" + kind, _, name = token.partition(":") + if kind == "network": + return f"network={name}" + if kind == "bridge": + return f"bridge={name}" + return token + + def _virt_xml(name: str, *args) -> list: """Commande virt-xml qui ÉCRIT la définition persistante du domaine. @@ -208,6 +298,55 @@ def hw_plan(state: dict, want: dict, node: str = "") -> list: } ) + cpu = (want.get("cpu") or "").strip() + if cpu and cpu != state.get("cpu"): + plan.append( + { + "what": f"CPU {state.get('cpu') or '—'} → {cpu}", + "cmd": _virt_xml( + name, "--edit", "--cpu", cpu + CPU_EXTRA.get(cpu, "") + ), + } + ) + + heads = positive_int(want.get("heads"), 0) + if heads and heads != state.get("heads"): + if not state.get("video"): + # « --edit --video » n'a aucun périphérique à modifier : virt-xml + # sortirait en erreur au milieu du lot. + plan.append( + { + "what": t("Screens"), + "skip": t("this VM has no virtual screen"), + } + ) + else: + plan.append( + { + "what": f"{t('Screens')} {state.get('heads')} → {heads}", + "cmd": _virt_xml( + name, "--edit", "--video", f"model.heads={heads}" + ), + } + ) + + net = (want.get("net") or "").strip() + if net and net != state.get("net"): + if not state.get("net"): + plan.append( + {"what": t("Network"), "skip": t("this VM has no interface")} + ) + else: + plan.append( + { + "what": f"{t('Network')} {net_label(state.get('net'))}" + f" → {net_label(net)}", + "cmd": _virt_xml( + name, "--edit", "--network", net_spec(net) + ), + } + ) + gpu = want.get("gpu") if gpu is not None: plan += _gpu_plan(name, state, bool(gpu), node) @@ -312,7 +451,9 @@ def _gpu_plan(name: str, state: dict, gpu: bool, node: str) -> list: return plan -def build_want(state: dict, vcpus, ram, gpu, autostart) -> dict: +def build_want( + state: dict, vcpus, ram, gpu, autostart, cpu="", heads="", net="" +) -> dict: """Valeurs de widgets -> intention, en retombant sur l'état actuel. Un champ vidé ou illisible ne veut pas dire « zéro vCPU » : il veut dire @@ -325,6 +466,9 @@ def build_want(state: dict, vcpus, ram, gpu, autostart) -> dict: "ram": parse_ram(ram) or state.get("mem_mib") or 0, "gpu": bool(gpu), "autostart": bool(autostart), + "cpu": (cpu or state.get("cpu") or "").strip(), + "heads": positive_int(heads, state.get("heads") or 1), + "net": (net or state.get("net") or "").strip(), } @@ -345,20 +489,67 @@ def hw_summary(state: dict) -> str: bits.append(f"3D {node.rsplit('/', 1)[-1]}") elif state.get("screen"): bits.append(t("software rendering")) + if (state.get("heads") or 1) > 1: + bits.append(f"{state['heads']} {t('Screens').lower()}") + if state.get("net"): + bits.append(net_label(state["net"])) + # Le mode CPU n'est dit que s'il n'est PAS le passthrough : c'est le défaut + # du parc, et une ligne de résumé ne doit porter que l'inattendu. + if state.get("cpu") and state["cpu"] != "host-passthrough": + bits.append(f"CPU {state['cpu']}") return ", ".join(bits) -def run_hardware_form(rows, node: str = "", run_app: bool = True): +def cpu_choices(states) -> list: + """Modes CPU à proposer, le mode courant compris s'il sort de la liste. + + Une VM en mode « custom » ne doit pas voir son réglage disparaître d'une + liste qui l'ignore : la liste déroulante afficherait alors un autre mode + que le sien, et valider le formulaire le changerait sans le dire. + """ + modes = list(CPU_MODES) + for state in states or (): + mode = (state or {}).get("cpu") + if mode and mode not in modes: + modes.append(mode) + return modes + + +def net_choices(states, nets=None) -> list: + """[(jeton, libellé)] des réseaux proposables, courants inclus.""" + tokens = [] + for token in list(nets or ()): + if token and token not in tokens: + tokens.append(token) + for state in states or (): + token = (state or {}).get("net") + if token and token not in tokens: + tokens.append(token) + return [(tok, net_label(tok)) for tok in tokens] + + +def run_hardware_form(rows, node: str = "", nets=None, run_app: bool = True): """Formulaire d'ajustement matériel. Renvoie {nom: intention} ou None. - `rows` est une liste d'états (hw_state). `run_app=False` renvoie - l'instance sans la lancer — c'est ainsi que les tests l'inspectent. + `rows` est une liste d'états (hw_state), `nets` les réseaux que l'hôte + peut offrir. `run_app=False` renvoie l'instance sans la lancer — c'est + ainsi que les tests l'inspectent. """ from textual.app import App, ComposeResult from textual.containers import Horizontal, VerticalScroll - from textual.widgets import Button, Checkbox, Footer, Header, Input, Static + from textual.widgets import ( + Button, + Checkbox, + Footer, + Header, + Input, + Select, + Static, + ) states = [dict(r) for r in rows if r] + cpus = cpu_choices(states) + reseaux = net_choices(states, nets) class HardwareForm(App): TITLE = t("VM hardware") @@ -378,6 +569,15 @@ def run_hardware_form(rows, node: str = "", run_app: bool = True): .cb3d { width: auto; margin: 0 2 0 1; } .cbauto { width: auto; } .warn { padding: 0 3; } + .sel { width: 22; } + /* Le réseau porte un suffixe (« br0 (pont) ») : deux colonnes de plus + que le mode CPU, qui s'affiche en un mot. */ + .selnet { width: 24; } + .lbl2 { width: 8; height: 3; content-align: right middle; } + /* 8 et pas 5 : sous cette largeur, Textual dessine le cadre du champ + mais PAS son contenu — la valeur devient invisible, ce qui est pire + qu'une troncature (on valide un champ qu'on croit vide). */ + .heads { width: 8; } #bar { height: auto; padding: 1; } """ BINDINGS = [ @@ -427,6 +627,30 @@ def run_hardware_form(rows, node: str = "", run_app: bool = True): id=f"auto{i}", classes="cbauto", ) + with Horizontal(classes="row"): + yield Static("CPU", classes="lbl2") + yield Select( + [(cpu_label(m), m) for m in cpus], + value=st.get("cpu") or cpus[0], + allow_blank=False, + id=f"cpu{i}", + classes="sel", + ) + yield Static(t("Screens"), classes="lbl2") + yield Input( + value=str(st.get("heads") or 1), + id=f"heads{i}", + classes="heads", + ) + if reseaux: + yield Static(t("Network"), classes="lbl2") + yield Select( + [(lab, tok) for tok, lab in reseaux], + value=st.get("net") or reseaux[0][0], + allow_blank=False, + id=f"net{i}", + classes="selnet", + ) if reason: yield Static(f"⚠ {reason}", classes="warn") with Horizontal(id="bar"): @@ -437,12 +661,18 @@ def run_hardware_form(rows, node: str = "", run_app: bool = True): def action_apply(self) -> None: want = {} for i, st in enumerate(states): + net = st.get("net") or "" + if reseaux: + net = self.query_one(f"#net{i}", Select).value or net want[st.get("name", "")] = build_want( st, self.query_one(f"#vcpus{i}", Input).value, self.query_one(f"#ram{i}", Input).value, self.query_one(f"#gpu{i}", Checkbox).value, self.query_one(f"#auto{i}", Checkbox).value, + cpu=self.query_one(f"#cpu{i}", Select).value or "", + heads=self.query_one(f"#heads{i}", Input).value, + net=net, ) self.want = want self.exit() diff --git a/script/todo/todo.py b/script/todo/todo.py index c0b8b52..07ab2f4 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -2922,10 +2922,16 @@ class TODO: @staticmethod def _qemu_dumpxml(name): - """XML du domaine, ou '' — source de son état matériel.""" + """XML PERSISTANT du domaine, ou '' — source de son état matériel. + + « --inactive » n'est pas décoratif : sur une VM allumée, « dumpxml » + rend la vue VIVANTE, décorée de ce que libvirt a alloué au démarrage + (portid du réseau, vnetN, alias). C'est la définition persistante que + virt-xml modifie, et c'est donc elle qu'il faut lire. + """ try: res = subprocess.run( - ["sudo", "virsh", "dumpxml", name], + ["sudo", "virsh", "dumpxml", "--inactive", name], capture_output=True, text=True, timeout=20, @@ -2973,6 +2979,52 @@ class TODO: except (OSError, AttributeError, ImportError): return "" + def _qemu_net_choices(self): + """Réseaux proposables : réseaux libvirt, puis ponts de l'hôte. + + Les ponts appartenant à un réseau libvirt (virbr0 pour « default ») + sont écartés : les proposer offrirait DEUX fois le même chemin, dont + un qui contourne la gestion du réseau par libvirt. + """ + tokens = [] + nets = self._qemu_cmd_lines( + ["sudo", "virsh", "net-list", "--all", "--name"] + ) + owned = set() + for net in nets: + tokens.append(f"network:{net}") + for line in self._qemu_cmd_lines( + ["sudo", "virsh", "net-info", net] + ): + if line.startswith("Bridge:"): + owned.add(line.split(":", 1)[1].strip()) + for line in self._qemu_cmd_lines( + ["ip", "-o", "link", "show", "type", "bridge"] + ): + # « 3: br0: br0 + parts = line.split(":") + bridge = parts[1].strip() if len(parts) > 1 else "" + if bridge and bridge not in owned: + tokens.append(f"bridge:{bridge}") + return tokens + + @staticmethod + def _qemu_cmd_lines(cmd): + """Lignes non vides d'une commande, ou [] si elle échoue.""" + try: + res = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=15, + env=TODO._qemu_c_env(), + ) + except (OSError, subprocess.SubprocessError): + return [] + if res.returncode != 0: + return [] + return [ln.strip() for ln in res.stdout.splitlines() if ln.strip()] + def _qemu_adjust_hardware(self, names): """Règle vCPU, RAM, 3D et démarrage automatique de VM ÉTEINTES. @@ -3009,12 +3061,13 @@ class TODO: return for r in rows: print(f" {r['name']:<30} {hw.hw_summary(r)}") - want = self._qemu_hw_form(rows, node) + nets = self._qemu_net_choices() + want = self._qemu_hw_form(rows, node, nets) if want is None: print(t("Cancelled.")) return if not want: - want = self._qemu_hw_prompts(rows, node) + want = self._qemu_hw_prompts(rows, node, nets) if not want: print(t("Cancelled.")) return @@ -3039,7 +3092,7 @@ class TODO: print(f"\n{t('Will execute:')} {cmd}") self.execute.exec_command_live(cmd, source_erplibre=False) - def _qemu_hw_form(self, rows, node): + def _qemu_hw_form(self, rows, node, nets=None): """Formulaire TUI d'ajustement. Renvoie l'intention par VM, {} pour retomber sur les invites en ligne (textual absent), None si annulé.""" from script.todo import textual_setup @@ -3049,14 +3102,34 @@ class TODO: try: from script.todo.qemu_hardware import run_hardware_form - return run_hardware_form(rows, node) + return run_hardware_form(rows, node, nets) except ImportError: return {} - def _qemu_hw_prompts(self, rows, node): + def _qemu_pick(self, title, values, current, labels=None): + """Liste numérotée dont le DÉFAUT est la valeur actuelle. + + Rendre la valeur actuelle sur une réponse vide, et sur une réponse + illisible : dans un formulaire de matériel, ne rien comprendre ne doit + rien changer. + """ + labels = labels or values + print(f"{title} :") + for i, (val, lab) in enumerate(zip(values, labels), 1): + mark = " ←" if val == current else "" + print(f" [{i}] {lab}{mark}") + ans = input(" " + t("Choice: ")).strip() + if not ans.isdigit(): + return current + idx = int(ans) + return values[idx - 1] if 1 <= idx <= len(values) else current + + def _qemu_hw_prompts(self, rows, node, nets=None): """Même ajustement, en invites, quand Textual n'est pas disponible.""" from script.todo import qemu_hardware as hw + cpus = hw.cpu_choices(rows) + reseaux = hw.net_choices(rows, nets) want = {} for r in rows: print(f"\n {r['name']} — {hw.hw_summary(r)}") @@ -3074,7 +3147,23 @@ class TODO: auto = self._qemu_ask_bool( f" {t('Autostart')} ? (o/N) : ", bool(r.get("autostart")) ) - want[r["name"]] = hw.build_want(r, vcpus, ram, gpu, auto) + cpu = self._qemu_pick(f" {t('CPU mode')}", cpus, r.get("cpu")) + heads = "" + if r.get("video"): + heads = input(f" {t('Screens')} [{r.get('heads') or 1}] : ") + net = r.get("net") or "" + # Une seule possibilité : rien à demander. C'est le cas d'un hôte + # sans pont, où le réseau libvirt est la seule voie. + if len(reseaux) > 1: + net = self._qemu_pick( + f" {t('Network')}", + [tok for tok, _lab in reseaux], + net, + labels=[lab for _tok, lab in reseaux], + ) + want[r["name"]] = hw.build_want( + r, vcpus, ram, gpu, auto, cpu=cpu, heads=heads, net=net + ) return want @staticmethod diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 39db9ca..daf155c 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3103,6 +3103,22 @@ TRANSLATIONS = { "fr": "Nœud de rendu", "en": "Render node", }, + "Screens": { + "fr": "Écrans", + "en": "Screens", + }, + "bridge": { + "fr": "pont", + "en": "bridge", + }, + "this VM has no interface": { + "fr": "cette VM n'a pas d'interface réseau", + "en": "this VM has no interface", + }, + "CPU mode": { + "fr": "Mode CPU", + "en": "CPU mode", + }, "no render node on the host": { "fr": "aucun nœud de rendu sur l'hôte", "en": "no render node on the host", diff --git a/test/test_qemu_gpu.py b/test/test_qemu_gpu.py index 94e173a..071fddc 100644 --- a/test/test_qemu_gpu.py +++ b/test/test_qemu_gpu.py @@ -75,6 +75,29 @@ XML_AVEC_3D = XML_SANS_3D.replace( "", ) +# XML persistant complet : mode CPU, écrans, interface réseau. C'est cette +# forme-là que « virsh dumpxml --inactive » rend, sans les décorations que +# libvirt ajoute au démarrage (portid, vnetN, alias). +XML_COMPLET = """ + erplibre-ubuntu-2604-gnome + 33554432 + 33554432 + 8 + + + + + + + + + + + +""" + XML_SERVEUR = """ erplibre-serveur 2097152 @@ -388,6 +411,8 @@ class TestMenuGlue(unittest.TestCase): ) todo._qemu_autostart = lambda name: False todo._qemu_host_gpu_node = lambda: node + # Aucun test ne doit atteindre virsh : la liste des réseaux est fournie. + todo._qemu_net_choices = lambda: ["network:default"] todo.launched = [] todo.execute = mock.Mock() todo.execute.exec_command_live = ( @@ -407,14 +432,14 @@ class TestMenuGlue(unittest.TestCase): """virt-xml y écrirait une définition qui ne prend effet qu'au prochain démarrage : un réglage qui paraît appliqué et ne l'est pas.""" todo = self._todo({"vm-a": "running"}) - todo._qemu_hw_form = lambda rows, node: {} + todo._qemu_hw_form = lambda rows, node, nets=None: {} out = self._run(todo, ["vm-a"], []) self.assertIn("vm-a", out) self.assertEqual([], todo.launched) def test_a_shut_off_vm_is_adjusted(self): todo = self._todo({"vm-a": "shut off"}) - todo._qemu_hw_form = lambda rows, node: { + todo._qemu_hw_form = lambda rows, node, nets=None: { "vm-a": {"vcpus": 4, "ram": 8192, "gpu": True} } self._run(todo, ["vm-a"], ["o"]) @@ -425,7 +450,7 @@ class TestMenuGlue(unittest.TestCase): def test_nothing_to_change_launches_nothing(self): todo = self._todo({"vm-a": "shut off"}) - todo._qemu_hw_form = lambda rows, node: { + todo._qemu_hw_form = lambda rows, node, nets=None: { "vm-a": {"vcpus": 8, "ram": 32768, "gpu": False} } out = self._run(todo, ["vm-a"], []) @@ -434,26 +459,30 @@ class TestMenuGlue(unittest.TestCase): def test_refusing_the_confirmation_launches_nothing(self): todo = self._todo({"vm-a": "shut off"}) - todo._qemu_hw_form = lambda rows, node: {"vm-a": {"vcpus": 4}} + todo._qemu_hw_form = lambda rows, node, nets=None: { + "vm-a": {"vcpus": 4} + } self._run(todo, ["vm-a"], ["n"]) self.assertEqual([], todo.launched) def test_cancelling_the_form_launches_nothing(self): todo = self._todo({"vm-a": "shut off"}) - todo._qemu_hw_form = lambda rows, node: None + todo._qemu_hw_form = lambda rows, node, nets=None: None self._run(todo, ["vm-a"], []) self.assertEqual([], todo.launched) def test_the_skipped_3d_is_explained_not_silent(self): todo = self._todo({"vm-a": "shut off"}, node="") - todo._qemu_hw_form = lambda rows, node: {"vm-a": {"gpu": True}} + todo._qemu_hw_form = lambda rows, node, nets=None: { + "vm-a": {"gpu": True} + } out = self._run(todo, ["vm-a"], []) self.assertIn(hw.t("no render node on the host"), out) self.assertEqual([], todo.launched) def test_the_host_gpu_is_announced_before_anything_else(self): todo = self._todo({"vm-a": "shut off"}) - todo._qemu_hw_form = lambda rows, node: None + todo._qemu_hw_form = lambda rows, node, nets=None: None out = self._run(todo, ["vm-a"], []) self.assertIn(NODE, out) @@ -461,8 +490,9 @@ class TestMenuGlue(unittest.TestCase): """Le repli en ligne n'est pas décoratif : sans Textual, c'est la SEULE voie, et un {} mal interprété annulerait tout.""" todo = self._todo({"vm-a": "shut off"}) - todo._qemu_hw_form = lambda rows, node: {} - self._run(todo, ["vm-a"], ["6", "", "o", "n", "o"]) + todo._qemu_hw_form = lambda rows, node, nets=None: {} + # vCPU, RAM, 3D, démarrage auto, mode CPU, écrans, puis la validation. + self._run(todo, ["vm-a"], ["6", "", "o", "n", "", "", "o"]) self.assertIn("--vcpus 6", " ".join(todo.launched)) def test_an_empty_answer_keeps_the_current_state(self): @@ -543,13 +573,86 @@ class TestForm(unittest.IsolatedAsyncioTestCase): self.assertEqual("8", app.query_one("#vcpus0", Input).value) self.assertEqual("32G", app.query_one("#ram0", Input).value) + async def test_the_second_row_carries_cpu_screens_and_network(self): + from textual.widgets import Input, Select + + app = hw.run_hardware_form( + [hw.hw_state(XML_COMPLET)], + NODE, + nets=["network:default", "bridge:br0"], + run_app=False, + ) + async with app.run_test() as pilot: + await pilot.pause() + app.query_one("#cpu0", Select).value = "host-model" + app.query_one("#heads0", Input).value = "2" + app.query_one("#net0", Select).value = "bridge:br0" + await pilot.press("ctrl+s") + await pilot.pause() + want = app.want["erplibre-ubuntu-2604-gnome"] + self.assertEqual("host-model", want["cpu"]) + self.assertEqual(2, want["heads"]) + self.assertEqual("bridge:br0", want["net"]) + + async def test_the_screens_value_is_actually_visible(self): + """Sous six colonnes, Textual dessine le cadre du champ mais PAS son + contenu : la valeur devient invisible, et on valide un champ qu'on + croit vide. Pire qu'une troncature, donc vérifié à l'écran.""" + import re + + app = hw.run_hardware_form( + [dict(hw.hw_state(XML_COMPLET), heads=3)], NODE, run_app=False + ) + async with app.run_test(size=(80, 24)) as pilot: + await pilot.pause() + svg = app.export_screenshot() + rendu = re.findall(r">([^<>]+)", svg) + self.assertIn("3", [txt.strip() for txt in rendu]) + + async def test_without_networks_to_offer_there_is_no_network_field(self): + """Un hôte sans pont n'a qu'une voie : une liste à un seul choix ne + vaut pas la place qu'elle prend.""" + app = hw.run_hardware_form( + [dict(hw.hw_state(XML_COMPLET), net="")], NODE, run_app=False + ) + async with app.run_test() as pilot: + await pilot.pause() + self.assertEqual(0, len(app.query("#net0"))) + await pilot.press("ctrl+s") + await pilot.pause() + self.assertEqual("", app.want["erplibre-ubuntu-2604-gnome"]["net"]) + + async def test_the_current_network_stays_selected(self): + """La liste montre ce que la VM a : sans cela, valider sans y toucher + la basculerait sur le premier choix de la liste.""" + from textual.widgets import Select + + app = hw.run_hardware_form( + [hw.hw_state(XML_COMPLET)], + NODE, + nets=["bridge:br0", "network:default"], + run_app=False, + ) + async with app.run_test() as pilot: + await pilot.pause() + self.assertEqual( + "network:default", app.query_one("#net0", Select).value + ) + await pilot.press("ctrl+s") + await pilot.pause() + want = app.want["erplibre-ubuntu-2604-gnome"] + self.assertEqual("network:default", want["net"]) + self.assertEqual([], hw.hw_plan(hw.hw_state(XML_COMPLET), want, NODE)) + async def test_it_fits_in_eighty_columns(self): """Un terminal de 80 colonnes est le plus petit qu'on rencontre ; au-delà, les libellés se tronquent en « Démarrage automatiq… ».""" import re - rows = [hw.hw_state(XML_SANS_3D), hw.hw_state(XML_SERVEUR)] - app = await self._mount(rows, NODE) + rows = [hw.hw_state(XML_COMPLET), hw.hw_state(XML_SERVEUR)] + app = hw.run_hardware_form( + rows, NODE, nets=["network:default", "bridge:br0"], run_app=False + ) async with app.run_test(size=(80, 24)) as pilot: await pilot.pause() svg = app.export_screenshot() @@ -557,5 +660,216 @@ class TestForm(unittest.IsolatedAsyncioTestCase): self.assertNotIn("…", texte) +class TestCpuScreensNetwork(unittest.TestCase): + """Les trois réglages ajoutés, et pourquoi chacun est celui-là. + + Mesuré sur l'hôte avant de les offrir : « heads » atteint QEMU + (max_outputs), « vram » NON sur un virtio-gpu — il n'est donc pas proposé. + """ + + def _plan(self, xml, want, node=NODE): + return hw.hw_plan(hw.hw_state(xml), want, node) + + def test_it_reads_the_cpu_mode_screens_and_network(self): + st = hw.hw_state(XML_COMPLET) + self.assertEqual("host-passthrough", st["cpu"]) + self.assertEqual(1, st["heads"]) + self.assertEqual("network:default", st["net"]) + + def test_a_vm_without_an_interface_has_no_network(self): + self.assertEqual("", hw.hw_state(XML_SERVEUR)["net"]) + + def test_a_bridge_is_read_as_a_bridge(self): + """libvirt refuse type='network' avec un pont pour source : le type et + le nom doivent voyager ensemble.""" + xml = XML_COMPLET.replace( + "", "" + ).replace("", "") + self.assertEqual("bridge:br0", hw.hw_state(xml)["net"]) + + def test_the_label_marks_the_bridge_only(self): + """Le réseau libvirt est le cas ordinaire ; le suffixe allongeait le + libellé au-delà de la liste déroulante, qui se repliait.""" + self.assertEqual("default", hw.net_label("network:default")) + self.assertIn("br0", hw.net_label("bridge:br0")) + self.assertIn(hw.t("bridge"), hw.net_label("bridge:br0")) + + def test_the_spec_names_the_right_virt_xml_key(self): + self.assertEqual("network=default", hw.net_spec("network:default")) + self.assertEqual("bridge=br0", hw.net_spec("bridge:br0")) + + def test_passthrough_keeps_check_and_migratable(self): + """C'est ce que virt-install écrit, et ce que veut la virtualisation + imbriquée : sans eux libvirt vérifie un modèle qu'il n'a pas calculé. + """ + xml = XML_COMPLET.replace( + "", + "", + ) + plan = self._plan(xml, {"cpu": "host-passthrough"}) + arg = plan[0]["cmd"][-1] + self.assertIn("check=none", arg) + self.assertIn("migratable=on", arg) + + def test_host_model_carries_nothing_extra(self): + plan = self._plan(XML_COMPLET, {"cpu": "host-model"}) + self.assertEqual("host-model", plan[0]["cmd"][-1]) + + def test_the_same_cpu_mode_changes_nothing(self): + self.assertEqual( + [], self._plan(XML_COMPLET, {"cpu": "host-passthrough"}) + ) + + def test_screens_go_through_the_video_model(self): + """« heads » devient max_outputs sur la ligne QEMU — vérifié par + domxml-to-native. C'est le seul réglage vidéo qui y arrive.""" + plan = self._plan(XML_COMPLET, {"heads": 2}) + self.assertIn("model.heads=2", plan[0]["cmd"]) + + def test_screens_on_a_screenless_vm_are_refused_not_attempted(self): + """« --edit --video » sortirait en erreur au milieu du lot, et les + commandes suivantes ne partiraient pas.""" + plan = self._plan(XML_SERVEUR, {"heads": 2}) + self.assertEqual(1, len(plan)) + self.assertIn("skip", plan[0]) + + def test_screens_and_3d_are_two_separate_edits(self): + """Vérifié sur un domaine réel : le second « --edit --video » ne + remet pas heads à 1, et le premier ne perd pas l'accélération.""" + plan = self._plan(XML_COMPLET, {"heads": 2, "gpu": True}) + videos = [e for e in plan if "--video" in e.get("cmd", [])] + self.assertEqual(2, len(videos)) + heads = [e for e in videos if "model.heads=2" in e["cmd"]] + self.assertEqual(1, len(heads)) + self.assertNotIn("accel3d", " ".join(heads[0]["cmd"])) + + def test_switching_to_a_bridge(self): + """Le MAC et l'adresse PCI survivent — vérifié sur un domaine réel, + démarré : sans cela l'invité verrait une carte neuve, et son bail + DHCP comme son nom d'interface changeraient.""" + plan = self._plan(XML_COMPLET, {"net": "bridge:br0"}) + self.assertIn("bridge=br0", plan[0]["cmd"]) + self.assertNotIn("mac", " ".join(plan[0]["cmd"])) + + def test_switching_back_to_a_libvirt_network(self): + xml = XML_COMPLET.replace( + "", "" + ).replace("", "") + plan = hw.hw_plan(hw.hw_state(xml), {"net": "network:default"}, NODE) + self.assertIn("network=default", plan[0]["cmd"]) + + def test_the_same_network_changes_nothing(self): + self.assertEqual( + [], self._plan(XML_COMPLET, {"net": "network:default"}) + ) + + def test_a_vm_without_an_interface_is_told_not_attempted(self): + plan = self._plan(XML_SERVEUR, {"net": "bridge:br0"}) + self.assertEqual(1, len(plan)) + self.assertIn("skip", plan[0]) + + def test_an_unknown_current_cpu_mode_stays_offered(self): + """Une VM en mode « custom » ne doit pas voir son réglage disparaître + d'une liste qui l'ignore : la liste afficherait un AUTRE mode que le + sien, et valider le formulaire le changerait sans le dire.""" + modes = hw.cpu_choices([{"cpu": "custom"}]) + self.assertIn("custom", modes) + self.assertIn("host-passthrough", modes) + + def test_the_network_list_merges_the_host_and_the_current_value(self): + choices = hw.net_choices( + [{"net": "bridge:br9"}], ["network:default", "bridge:br9"] + ) + self.assertEqual( + ["network:default", "bridge:br9"], [tok for tok, _ in choices] + ) + + def test_empty_answers_keep_the_current_hardware(self): + st = hw.hw_state(XML_COMPLET) + want = hw.build_want( + st, "", "", False, False, cpu="", heads="", net="" + ) + self.assertEqual("host-passthrough", want["cpu"]) + self.assertEqual(1, want["heads"]) + self.assertEqual("network:default", want["net"]) + self.assertEqual([], hw.hw_plan(st, want, "")) + + +class TestHostNetworks(unittest.TestCase): + """Ce que l'hôte propose : ses réseaux libvirt, et ses ponts à lui.""" + + def _choices(self, nets, infos, bridges): + todo = TODO.__new__(TODO) + sorties = {} + sorties["net-list"] = nets + sorties["bridge"] = bridges + sorties.update(infos) + + def fake(cmd): + if "net-list" in cmd: + return sorties["net-list"] + if "net-info" in cmd: + return sorties.get(cmd[-1], []) + return sorties["bridge"] + + todo._qemu_cmd_lines = fake + return todo._qemu_net_choices() + + def test_a_libvirt_owned_bridge_is_not_offered_twice(self): + """virbr0 EST le réseau « default » : l'offrir aussi comme pont + proposerait deux fois le même chemin, dont un qui contourne la + gestion du réseau par libvirt.""" + got = self._choices( + ["default"], + {"default": ["Name: default", "Bridge: virbr0"]}, + ["3: virbr0: "], + ) + self.assertEqual(["network:default"], got) + + def test_a_real_bridge_is_offered(self): + got = self._choices( + ["default"], + {"default": ["Bridge: virbr0"]}, + [ + "3: virbr0: ", + "4: br0: ", + ], + ) + self.assertEqual(["network:default", "bridge:br0"], got) + + def test_a_host_without_libvirt_answers_nothing(self): + todo = TODO.__new__(TODO) + todo._qemu_cmd_lines = lambda cmd: [] + self.assertEqual([], todo._qemu_net_choices()) + + def test_the_persistent_definition_is_what_gets_read(self): + """Sur une VM allumée, « dumpxml » sans --inactive rend la vue VIVANTE + (portid, vnetN, alias) — pas la définition que virt-xml modifie.""" + vu = {} + + def fake_run(cmd, **kw): + vu["cmd"] = cmd + return subprocess.CompletedProcess([], 0, "", "") + + with mock.patch("subprocess.run", side_effect=fake_run): + TODO._qemu_dumpxml("vm-a") + self.assertIn("--inactive", vu["cmd"]) + + def test_a_numbered_pick_defaults_to_the_current_value(self): + todo = TODO.__new__(TODO) + out = io.StringIO() + for reponse, attendu in ( + ("", "b"), + ("mille", "b"), + ("9", "b"), + ("1", "a"), + ("2", "b"), + ): + with mock.patch("builtins.input", lambda *a, r=reponse: r): + with contextlib.redirect_stdout(out): + got = todo._qemu_pick("t", ["a", "b"], "b") + self.assertEqual(attendu, got, reponse) + + if __name__ == "__main__": unittest.main(verbosity=1)