[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:
parent
0da6c7fcbe
commit
9641a5b749
1 changed files with 240 additions and 14 deletions
|
|
@ -48,6 +48,7 @@ from __future__ import annotations
|
||||||
import argparse
|
import argparse
|
||||||
import getpass
|
import getpass
|
||||||
import grp
|
import grp
|
||||||
|
import gzip
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
@ -173,6 +174,13 @@ S390X_DISTROS: tuple[str, ...] = (
|
||||||
"rocky",
|
"rocky",
|
||||||
"fedora",
|
"fedora",
|
||||||
"opensuse",
|
"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
|
# 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
|
# (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é.
|
# miroirs tiers. Seule la 43 est servie par dl.fedoraproject.org — vérifié.
|
||||||
ARCH_ONLY_VERSIONS: dict[str, dict[str, tuple[str, ...]]] = {
|
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]:
|
def arch_versions(distro: str, arch: str, versions) -> list[str]:
|
||||||
"""Versions de `distro` réellement publiées pour `arch`."""
|
"""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)
|
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:
|
def network_name(network_arg: str) -> str | None:
|
||||||
"""Extrait NAME de « network=NAME,... » ; None si c'est un bridge, etc."""
|
"""Extrait NAME de « network=NAME,... » ; None si c'est un bridge, etc."""
|
||||||
for part in network_arg.split(","):
|
for part in network_arg.split(","):
|
||||||
|
|
@ -1749,6 +1912,7 @@ def virt_install(
|
||||||
seed: Path,
|
seed: Path,
|
||||||
osinfo: str,
|
osinfo: str,
|
||||||
runner: Runner,
|
runner: Runner,
|
||||||
|
installer: tuple[Path, Path] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
# Émulée (TCG, pas de KVM) si l'arch demandée diffère de celle de l'hôte.
|
# É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
|
# Deux causes d'émulation, à ne pas confondre : une architecture étrangère
|
||||||
|
|
@ -1817,17 +1981,41 @@ def virt_install(
|
||||||
str(args.memory),
|
str(args.memory),
|
||||||
"--vcpus",
|
"--vcpus",
|
||||||
str(args.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",
|
"--disk",
|
||||||
f"path={disk},format=qcow2,bus=virtio",
|
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
|
# 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
|
# 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.
|
# 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
|
# 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).
|
# 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.
|
# Sur s390x, bus=virtio est mappé en virtio-ccw par libvirt.
|
||||||
"--disk",
|
cmd += ["--disk", f"path={seed},readonly=on,bus=virtio"]
|
||||||
f"path={seed},readonly=on,bus=virtio",
|
cmd += [
|
||||||
"--osinfo",
|
"--osinfo",
|
||||||
osinfo,
|
osinfo,
|
||||||
"--network",
|
"--network",
|
||||||
|
|
@ -1881,6 +2069,13 @@ def virt_install(
|
||||||
cmd += ["--virt-type", "qemu"]
|
cmd += ["--virt-type", "qemu"]
|
||||||
if not args.attach_console:
|
if not args.attach_console:
|
||||||
cmd.append("--noautoconsole")
|
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
|
# virtinst écrit un journal de debug dans ~/.cache/virt-manager ; sous
|
||||||
# sudo, HOME/cache peut être inaccessible -> l'écriture échoue et Python
|
# sudo, HOME/cache peut être inaccessible -> l'écriture échoue et Python
|
||||||
# déverse un « Logging error » (le pavé « Fetched capabilities … »). On
|
# déverse un « Logging error » (le pavé « Fetched capabilities … »). On
|
||||||
|
|
@ -2382,22 +2577,53 @@ def main() -> None:
|
||||||
"nettement plus lents que l'architecture native."
|
"nettement plus lents que l'architecture native."
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"\n== 1/5 Image cloud ({args.distro} {args.version} / {code}) ==")
|
installer: tuple[Path, Path] | None = None
|
||||||
download_image(urls, args.image_path, args.dry_run)
|
if uses_installer(args.distro, args.arch):
|
||||||
if do_verify:
|
# Voie debian-installer : aucune image cloud n'existe pour cette
|
||||||
verify_sha256(url, args.image_path, args.dry_run)
|
# 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}) ==")
|
print(f"\n== 2-3/5 Disque vierge {disk} ({args.disk_size}) ==")
|
||||||
prepare_disk(args.image_path, disk, args.disk_size, runner, args.force)
|
create_blank_disk(disk, args.disk_size, runner, args.force)
|
||||||
|
|
||||||
print(f"\n== 4/5 Seed cloud-init {seed} ==")
|
print(f"\n== 4/5 Preseed embarqué dans l'initrd ==")
|
||||||
cloud_cfg = build_cloud_config(args, pw_hash, ssh_keys)
|
build_installer_initrd(
|
||||||
build_seed(cloud_cfg, args.hostname, seed, runner)
|
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)
|
resolved_osinfo = osinfo_arg(osinfo, args.distro)
|
||||||
print(f"\n== 5/5 virt-install (--osinfo {resolved_osinfo}) ==")
|
print(f"\n== 5/5 virt-install (--osinfo {resolved_osinfo}) ==")
|
||||||
ensure_network(network_name(args.network), runner)
|
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)
|
has_key = bool(ssh_keys)
|
||||||
print("\nTerminé. Suivi :")
|
print("\nTerminé. Suivi :")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue