[ADD] qemu arch : yay et bash-completion sur l'invité, guide au MOTD

Une image cloud Arch est nue : ni bash-completion, ni accès à l'AUR. Les
deux arrivent avec l'amorçage, sur la seule branche pacman. yay-bin
plutôt que yay, dont le paquet source compile Go pour le même outil ; la
construction reste sous l'utilisateur de la VM, makepkg refusant root. Le
« || true » qui ferme le bloc porte : le groupe est le dernier membre de
sa liste « || », donc set -e s'y applique et un sudo en échec emporterait
l'installation entière. Le guide de connexion n'annonce yay que si une
installation a eu lieu.

Vérifié : 10 tests, dont « bash -n » sur la commande distante entière et
la survie du bloc sous set -e avec un PATH vide.

--- EN ---

An Arch cloud image is bare: no bash-completion, no AUR access. Both come
with the bootstrap, on the pacman branch alone. yay-bin rather than yay,
whose source package compiles Go for the same tool; the build stays under
the VM user, makepkg refusing root. The « || true » closing the block
carries weight: the group is the last member of its « || » list, so set -e
applies inside it and one failing sudo would take the whole install down.
The login guide announces yay only when an install ran.

Checked: 10 tests, among them « bash -n » over the whole remote command
and the block surviving set -e with an empty PATH.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-02 08:04:05 -04:00
parent 736ab3b676
commit 29b12c8ca1
5 changed files with 220 additions and 3 deletions

View file

@ -1759,6 +1759,19 @@ PKG_GUIDE: dict[str, tuple[tuple[str, str, str], ...]] = {
}
# Assistant AUR posé sur l'invité Arch par l'amorçage d'installation. Les
# formes viennent du manuel de yay : il reprend les options de pacman, sauf
# « -Yc » qui lui est propre. Jamais sous sudo — yay appelle sudo lui-même
# pour la seule étape qui en a besoin, et le lancer en root fait échouer
# makepkg, qui refuse de construire sous cet utilisateur.
AUR_GUIDE: tuple[tuple[str, str, str], ...] = (
("yay -Syu", "mettre à jour dépôts + AUR", "upgrade repos and AUR"),
("yay -S <paquet>", "installer depuis l'AUR", "install from the AUR"),
("yay -Ss <motif>", "chercher dans l'AUR", "search the AUR"),
("yay -Yc", "retirer les orphelins", "remove orphans"),
)
def zypper_guide(rolling: bool) -> tuple[tuple[str, str, str], ...]:
"""Aide-mémoire zypper. `rolling` : Tumbleweed plutôt que Leap.
@ -1965,6 +1978,12 @@ def build_motd(
lang,
narrow,
)
# yay arrive avec l'amorçage d'installation, pas avec l'image : une VM
# déployée sans installation n'annonce donc pas une commande absente.
# C'est la règle du bloc ERPLibre ci-dessous, appliquée au même signal.
if mgr == "pacman" and el_dir:
body.append("")
body += motd_block("AUR — yay", AUR_GUIDE, lang, narrow)
if el_dir:
body.append("")
el_rows = erplibre_guide(el_dir, el_make, editor)

View file

@ -186,8 +186,12 @@ class QemuDeployMixin:
"{ sudo dnf clean all; sudo dnf install -y --refresh $PKGS; }; "
"elif command -v pacman >/dev/null 2>&1; then "
+ self._qemu_pacman_prepare_cmd()
+ "sudo pacman -S --needed --noconfirm $PKGS; "
"elif command -v zypper >/dev/null 2>&1; then "
# bash-completion n'est PAS dans une image cloud Arch, là où les
# images Debian et Fedora l'embarquent : sans lui, la tabulation
# ne complète que les noms de fichiers, pas les sous-commandes.
+ "sudo pacman -S --needed --noconfirm $PKGS bash-completion; "
+ self._qemu_yay_install_cmd()
+ "elif command -v zypper >/dev/null 2>&1; then "
# openSUSE : « --non-interactive » vaut le -y des autres, et
# « --auto-agree-with-licenses », qui va APRÈS « install »,
# évite un blocage sur une licence à accepter.

View file

@ -584,6 +584,41 @@ class QemuInstallMixin:
"fi; "
)
@staticmethod
def _qemu_yay_install_cmd():
"""Pose yay, l'assistant AUR, sur un invité Arch.
« yay-bin » et non « yay » : le paquet source compile son propre Go,
ce qui coûte plusieurs minutes et le compilateur avec ; le binaire
précompilé donne le même outil.
makepkg REFUSE de tourner en root et sort en erreur ; le clonage et la
construction restent donc sous l'utilisateur de la VM, qui appelle
sudo pour la seule installation finale. Le NOPASSWD posé par
cloud-init rend ce sudo silencieux.
yay est un bonus, pas une condition : le bloc se termine par « true »
pour qu'un AUR injoignable ne fasse pas échouer, sous « set -e », une
installation par ailleurs complète.
"""
return (
"command -v yay >/dev/null 2>&1 || { "
"sudo pacman -S --needed --noconfirm base-devel git && "
"yd=$(mktemp -d) && "
"git clone --depth 1 https://aur.archlinux.org/yay-bin.git "
'"$yd" && ( cd "$yd" && makepkg -si --noconfirm ); '
# « rm -rf » sur une variable vide rend 0 en silence sous -f : le
# nettoyage n'a donc pas besoin de savoir si le clonage a eu lieu.
'rm -rf "$yd"; '
# « || true » ferme le groupe ENTIER, et il porte. Le groupe est
# le DERNIER membre de la liste « || », donc set -e s'y applique
# et le premier sudo en échec emporterait toute l'installation.
# Un membre de plus l'y suspend, et rend le bloc inoffensif.
"} || true; "
"command -v yay >/dev/null 2>&1 "
'&& echo " yay installé" || echo " ⚠ yay non installé"; '
)
def _qemu_zypper_mirror_cmd(self):
"""Réécrit l'hôte des dépôts zypper vers un miroir plus proche."""
mirrors = " ".join(self._QEMU_ZYPPER_MIRRORS)

154
test/test_qemu_arch_yay.py Normal file
View file

@ -0,0 +1,154 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""L'invité Arch : yay et bash-completion, puis leur trace dans l'accueil.
Une image cloud Arch est nue : ni bash-completion, ni accès à l'AUR. Les deux
s'ajoutent à l'amorçage d'installation, sur la seule branche pacman.
Ce que ces tests gardent :
- makepkg REFUSE de construire en root : la construction ne passe jamais par
sudo, et un « sudo makepkg » repasserait le test au rouge.
- yay est un bonus : sous « set -e », son échec ne doit pas emporter une
installation par ailleurs complète.
- L'accueil de session n'annonce yay que sur une VM qui l'aura vraiment —
la règle déjà tenue par le bloc ERPLibre, appliquée au même signal.
"""
import importlib.util
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
sys.argv = ["todo.py"]
from script.todo.todo import TODO # noqa: E402
RACINE = Path(__file__).resolve().parents[1]
def _deploy_qemu():
"""deploy_qemu.py chargé comme module, comme le fait todo.py."""
path = RACINE / "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()
class ArchGuestBootstrap(unittest.TestCase):
def setUp(self):
self.todo = TODO.__new__(TODO)
self.cmd = self.todo._qemu_erplibre_remote_cmd(
"develop", None, False, False, "", "deb", ()
)
def test_bash_completion_rides_the_pacman_branch(self):
self.assertIn("bash-completion", self.cmd)
# Sur la branche pacman et nulle part ailleurs : les images Debian et
# Fedora l'embarquent déjà, l'ajouter là serait du bruit.
pacman = self.cmd.index("command -v pacman")
zypper = self.cmd.index("command -v zypper", pacman)
self.assertIn("bash-completion", self.cmd[pacman:zypper])
def test_yay_is_installed_from_the_prebuilt_package(self):
"""yay-bin plutôt que yay : le paquet source compile Go, et le
compilateur avec, pour le même outil."""
self.assertIn("aur.archlinux.org/yay-bin.git", self.cmd)
def test_makepkg_never_runs_under_sudo(self):
"""makepkg sort en erreur sous root : « running makepkg as root is
not allowed ». La construction reste sous l'utilisateur de la VM."""
yay = self.todo._qemu_yay_install_cmd()
self.assertIn("makepkg -si --noconfirm", yay)
self.assertNotIn("sudo makepkg", yay)
def test_a_failing_aur_does_not_break_the_chain(self):
"""Sous « set -e », un groupe qui échoue arrête tout. Le bloc doit
rendre 0 même sans réseau, sans quoi une VM par ailleurs installée
serait comptée en échec."""
yay = self.todo._qemu_yay_install_cmd()
script = "set -e\nPATH=/nonexistent\n" + yay + "\necho SURVECU"
with tempfile.NamedTemporaryFile(
"w", suffix=".sh", delete=False
) as fh:
fh.write(script)
chemin = fh.name
res = subprocess.run(
["bash", chemin], capture_output=True, text=True, timeout=60
)
self.assertEqual(res.returncode, 0, res.stderr)
self.assertIn("SURVECU", res.stdout)
def test_the_whole_remote_command_is_valid_shell(self):
"""Une erreur de syntaxe ne se verrait qu'une fois la VM déployée."""
res = subprocess.run(
["bash", "-n"],
input=self.cmd,
capture_output=True,
text=True,
timeout=60,
)
self.assertEqual(res.returncode, 0, res.stderr)
class AurInTheMotd(unittest.TestCase):
def test_arch_with_an_install_announces_yay(self):
motd = DQ.build_motd(
"arch", "latest", "amd64", "fr", "~/git/erplibre", "install", "vi"
)
self.assertIn("AUR — yay", motd)
self.assertIn("yay -Syu", motd)
def test_arch_without_an_install_announces_nothing(self):
motd = DQ.build_motd("arch", "latest", "amd64", "fr")
self.assertNotIn("yay", motd)
def test_another_distro_never_gets_the_block(self):
for distro, version in (
("ubuntu", "24.04"),
("debian", "13"),
("fedora", "43"),
("opensuse", "tumbleweed"),
):
with self.subTest(distro=distro):
motd = DQ.build_motd(
distro,
version,
"amd64",
"fr",
"~/git/erplibre",
"install",
"vi",
)
self.assertNotIn("yay", motd)
def test_the_block_is_translated(self):
motd = DQ.build_motd(
"arch", "latest", "amd64", "en", "~/git/erplibre", "install", "vi"
)
self.assertIn("search the AUR", motd)
self.assertNotIn("chercher dans l'AUR", motd)
def test_the_block_stays_under_a_standard_terminal(self):
for lang in ("fr", "en"):
motd = DQ.build_motd(
"arch",
"latest",
"amd64",
lang,
"~/git/erplibre",
"install",
"vi",
)
for line in motd.splitlines():
self.assertLessEqual(len(line), 80, line)
if __name__ == "__main__":
unittest.main()

View file

@ -182,7 +182,12 @@ class TestToolRemoteCommand(unittest.TestCase):
script = self.todo._qemu_erplibre_remote_cmd(
"develop", None, False, "gnome", "", "deb", ("pycharm",)
)
self.assertLess(script.index("PyCharm"), script.index("git clone"))
# « git clone --branch » et non « git clone » : l'amorçage Arch en
# pose un autre, celui de yay-bin, et il arrive plus tôt. Seul le
# clone du dépôt, qui seul porte une branche, est en jeu ici.
self.assertLess(
script.index("PyCharm"), script.index("git clone --branch")
)
self.assertLess(
script.index("PyCharm"), script.index("make install_os")
)