[ADD] qemu : Debian sur s390x, par debian-installer

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)
This commit is contained in:
Mathieu Benoit 2026-08-13 16:35:42 -04:00
parent 0da6c7fcbe
commit 9641a5b749

View file

@ -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 :")