[ADD] cache qemu : NixOS apprend l'autorité, et le hors ligne s'ouvre

NixOS était SOUSTRAIT du cache faute d'ancre de confiance par fichier, ce
qui lui fermait le hors ligne : le magasin est alors la seule source, et
l'exception ne laisse rien. Une déclaration arriverait trop tard, la
première reconstruction étant le premier téléchargement.
L'autorité est donc POINTÉE, consommateur par consommateur, et
l'environnement se perd à trois frontières : nix-daemon, activé par
socket, qu'un fragment sous /run/systemd/system atteint ; sudo, que
« env_keep » traverse — le nix de root parle droit au magasin local et
télécharge lui-même ; et la session ssh, ouverte une seconde avant que
cloud-init n'écrive le faisceau. Vérifié : installation complète, 0 refus.

--- EN ---

NixOS was EXEMPTED from the cache for want of a per-file trust anchor,
which closed offline deployment to it: the store is then the only source,
and an exemption leaves nothing. A declaration would come too late, the
first rebuild being the first download.
The authority is therefore POINTED AT, consumer by consumer, and the
environment is lost at three boundaries: nix-daemon, socket-activated,
reached by a drop-in under /run/systemd/system; sudo, crossed by
« env_keep » — root's nix talks straight to the local store and downloads
itself; and the ssh session, opened one second before cloud-init writes
the bundle. Checked: a complete install, 0 refusals.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-16 21:33:10 -04:00
parent 0105cf16e5
commit 7c860ff047
10 changed files with 674 additions and 32 deletions

View file

@ -217,6 +217,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `make` looks for bash instead of assuming `/bin/bash`. That path does not exist on NixOS, where the shell lives in the store, and make stopped before running any recipe — including the one that installs what creates that path. Elsewhere the resolved shell is the same one as before
- The locale and the keyboard a deployed VM is given now apply on Debian, where both silently failed. A locale is generated from `/etc/locale.gen` and nowhere else, so `update-locale` refused one that was not there and the VM stayed on C.UTF-8; the keyboard module ends on a `console-setup` the genericcloud image does not carry, so `/etc/default/keyboard` — the file localed and X read — is written directly instead. Every Debian deployment used to print `cloud-init: status: error`, and a word that always shows warns of nothing
- A guest with no per-file trust anchor is taken out of the download cache instead of being intercepted without one. Interception is transparent and covers the whole bridge, so a VM given no authority still fails every HTTPS download on « self-signed certificate in certificate chain » — and on a declarative system placing the authority comes too late, the first rebuild being the first download. On a Proxmox host it is the HOST that is exempted: a nested guest leaves masqueraded behind it and the bridge never sees its own address. Measured from inside the guest: code 000 and SSL verification 19, then 200 and 0. Without it the package manager fell back to building 564 derivations, whose sources failed for the same reason
- NixOS learns the download cache's authority WITHOUT a rebuild, so it is no longer taken out of the cache — which used to close offline deployment to it, the store being the only source there and an exempted VM having none. It has no per-file trust anchor and /etc is generated read-only, while a declaration would come too late, the first rebuild being the first download. Measured on an intercepted VM: a READ goes through the nix client and a session variable suffices, but REALISING a derivation goes through nix-daemon, which never sees it; a drop-in under /run/systemd/system — a tmpfs, writable where /etc is not — reaches it. The bundle concatenates the system authorities with the cache's, giving the cache's alone would stop trusting everything else. Checked with upstream CUT on a fresh VM: nix-shell realises from the store
- Odoo answers from outside a NixOS VM. It listened on 0.0.0.0:8069 and replied locally, but NixOS enables a firewall by default where none of the four other cloud images does: the host received nothing — not a refusal, silence until the timeout — and the monitor declared Odoo absent on a machine where it was running. Measured from the host: 000 after 12 s, then 303 in 9 ms
- ERPLibre runs as a service on NixOS. The install ended by writing a unit into `/etc/systemd/system`, generated from the store and mounted read-only: it returned 1 at its last step, after the clone, the venv and an Odoo start had all succeeded. The unit is now declared by the module; its interpreter comes from the store, `/bin` being an envfs FUSE mount that systemd does not see when it resolves the executable; and its PATH carries bash, whose absence stopped `run.sh` before Odoo
- What a declarative system must declare, and the four others receive free from their cloud image: xmlsec1, without which Odoo refuses to install auth_saml, a module of the addons path; parallel and shfmt, called by bare name; growpart, absent from the whole system while the disk grow is written « … || true » and returned 0 without growing anything; and the guest agent, which came from the image rather than the repository, its unit PATH lacking findmnt so that guest-exec died with 127 on its first line
@ -270,6 +271,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `make` cherche bash au lieu de présumer `/bin/bash`. Ce chemin n'existe pas sur NixOS, où le shell vit dans le store, et make s'arrêtait avant d'exécuter la moindre recette — y compris celle qui installe de quoi créer ce chemin. Ailleurs, le shell résolu est celui d'avant
- Le locale et le clavier qu'une VM déployée reçoit s'appliquent désormais sur Debian, où les deux échouaient en silence. Un locale se génère à partir de `/etc/locale.gen` et de nulle part ailleurs : `update-locale` refusait celui qui n'y était pas et la VM restait en C.UTF-8 ; le module clavier finit par un `console-setup` que l'image genericcloud ne porte pas, alors `/etc/default/keyboard` — le fichier que localed et X relisent — est écrit directement. Chaque déploiement Debian imprimait `cloud-init: status: error`, et un mot qui s'affiche toujours n'avertit plus de rien
- Un invité sans ancre de confiance par fichier est soustrait au cache de téléchargement plutôt qu'intercepté sans elle. Le détournement est transparent et vaut pour tout le pont : une VM à qui l'on ne donne pas l'autorité échoue quand même sur « self-signed certificate in certificate chain » — et sur un système déclaratif, poser l'autorité arrive trop tard, la première reconstruction étant le premier téléchargement. Sur un hôte Proxmox, c'est l'HÔTE qui est excepté : un invité imbriqué sort masqué derrière lui et le pont ne voit jamais sa propre adresse. Mesuré depuis l'invité : code 000 et vérification SSL 19, puis 200 et 0. Sans cela le gestionnaire de paquets se rabattait sur 564 dérivations à construire, dont les sources échouaient pour la même raison
- NixOS apprend l'autorité du cache de téléchargement SANS reconstruire, et n'en est donc plus soustrait — ce qui lui fermait le déploiement hors ligne, le magasin étant alors la seule source et une VM exceptée n'ayant plus rien. Il n'a pas d'ancre de confiance par fichier et /etc est généré en lecture seule, quand une déclaration arriverait trop tard, la première reconstruction étant le premier téléchargement. Mesuré sur une VM interceptée : une LECTURE passe par le client nix et se contente d'une variable de session, mais RÉALISER une dérivation passe par nix-daemon, qui ne la voit pas ; un fragment sous /run/systemd/system — un tmpfs, inscriptible là où /etc ne l'est pas — l'atteint. Le faisceau concatène les autorités du système et celle du cache, donner la seconde seule ferait cesser d'approuver tout le reste. Vérifié amont COUPÉ sur une VM neuve : nix-shell réalise depuis le magasin
- Odoo répond depuis l'extérieur d'une VM NixOS. Il écoutait sur 0.0.0.0:8069 et répondait en local, mais NixOS active un pare-feu par défaut là où aucune des quatre autres images cloud n'en active : l'hôte ne recevait rien — pas un refus, un silence jusqu'au délai — et le suivi déclarait Odoo absent sur une machine où il tournait. Mesuré depuis l'hôte : 000 après 12 s, puis 303 en 9 ms
- ERPLibre tourne comme service sur NixOS. L'installation finissait par écrire une unité dans `/etc/systemd/system`, généré depuis le store et monté en lecture seule : elle rendait 1 à sa dernière étape, après que le clone, le venv et un démarrage d'Odoo avaient tous réussi. L'unité est désormais déclarée par le module ; son interpréteur vient du store, `/bin` étant un montage FUSE d'envfs que systemd ne voit pas quand il résout l'exécutable ; et son PATH porte bash, dont l'absence arrêtait `run.sh` avant Odoo
- Ce qu'un système déclaratif doit déclarer, et que les quatre autres reçoivent gratuitement de leur image cloud : xmlsec1, sans lequel Odoo refuse d'installer auth_saml, module du chemin des addons ; parallel et shfmt, appelés par leur nom nu ; growpart, absent de tout le système alors que l'agrandissement du disque s'écrit « … || true » et rendait 0 sans rien agrandir ; et l'agent invité, qui venait de l'image et non du dépôt, le PATH de son unité manquant findmnt si bien que guest-exec mourait en 127 dès sa première ligne

View file

@ -121,6 +121,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `make` cherche bash au lieu de présumer `/bin/bash`. Ce chemin n'existe pas sur NixOS, où le shell vit dans le store, et make s'arrêtait avant d'exécuter la moindre recette — y compris celle qui installe de quoi créer ce chemin. Ailleurs, le shell résolu est celui d'avant
- Le locale et le clavier qu'une VM déployée reçoit s'appliquent désormais sur Debian, où les deux échouaient en silence. Un locale se génère à partir de `/etc/locale.gen` et de nulle part ailleurs : `update-locale` refusait celui qui n'y était pas et la VM restait en C.UTF-8 ; le module clavier finit par un `console-setup` que l'image genericcloud ne porte pas, alors `/etc/default/keyboard` — le fichier que localed et X relisent — est écrit directement. Chaque déploiement Debian imprimait `cloud-init: status: error`, et un mot qui s'affiche toujours n'avertit plus de rien
- Un invité sans ancre de confiance par fichier est soustrait au cache de téléchargement plutôt qu'intercepté sans elle. Le détournement est transparent et vaut pour tout le pont : une VM à qui l'on ne donne pas l'autorité échoue quand même sur « self-signed certificate in certificate chain » — et sur un système déclaratif, poser l'autorité arrive trop tard, la première reconstruction étant le premier téléchargement. Sur un hôte Proxmox, c'est l'HÔTE qui est excepté : un invité imbriqué sort masqué derrière lui et le pont ne voit jamais sa propre adresse. Mesuré depuis l'invité : code 000 et vérification SSL 19, puis 200 et 0. Sans cela le gestionnaire de paquets se rabattait sur 564 dérivations à construire, dont les sources échouaient pour la même raison
- NixOS apprend l'autorité du cache de téléchargement SANS reconstruire, et n'en est donc plus soustrait — ce qui lui fermait le déploiement hors ligne, le magasin étant alors la seule source et une VM exceptée n'ayant plus rien. Il n'a pas d'ancre de confiance par fichier et /etc est généré en lecture seule, quand une déclaration arriverait trop tard, la première reconstruction étant le premier téléchargement. Mesuré sur une VM interceptée : une LECTURE passe par le client nix et se contente d'une variable de session, mais RÉALISER une dérivation passe par nix-daemon, qui ne la voit pas ; un fragment sous /run/systemd/system — un tmpfs, inscriptible là où /etc ne l'est pas — l'atteint. Le faisceau concatène les autorités du système et celle du cache, donner la seconde seule ferait cesser d'approuver tout le reste. Vérifié amont COUPÉ sur une VM neuve : nix-shell réalise depuis le magasin
- Odoo répond depuis l'extérieur d'une VM NixOS. Il écoutait sur 0.0.0.0:8069 et répondait en local, mais NixOS active un pare-feu par défaut là où aucune des quatre autres images cloud n'en active : l'hôte ne recevait rien — pas un refus, un silence jusqu'au délai — et le suivi déclarait Odoo absent sur une machine où il tournait. Mesuré depuis l'hôte : 000 après 12 s, puis 303 en 9 ms
- ERPLibre tourne comme service sur NixOS. L'installation finissait par écrire une unité dans `/etc/systemd/system`, généré depuis le store et monté en lecture seule : elle rendait 1 à sa dernière étape, après que le clone, le venv et un démarrage d'Odoo avaient tous réussi. L'unité est désormais déclarée par le module ; son interpréteur vient du store, `/bin` étant un montage FUSE d'envfs que systemd ne voit pas quand il résout l'exécutable ; et son PATH porte bash, dont l'absence arrêtait `run.sh` avant Odoo
- Ce qu'un système déclaratif doit déclarer, et que les quatre autres reçoivent gratuitement de leur image cloud : xmlsec1, sans lequel Odoo refuse d'installer auth_saml, module du chemin des addons ; parallel et shfmt, appelés par leur nom nu ; growpart, absent de tout le système alors que l'agrandissement du disque s'écrit « … || true » et rendait 0 sans rien agrandir ; et l'agent invité, qui venait de l'image et non du dépôt, le PATH de son unité manquant findmnt si bien que guest-exec mourait en 127 dès sa première ligne

View file

@ -121,6 +121,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `make` looks for bash instead of assuming `/bin/bash`. That path does not exist on NixOS, where the shell lives in the store, and make stopped before running any recipe — including the one that installs what creates that path. Elsewhere the resolved shell is the same one as before
- The locale and the keyboard a deployed VM is given now apply on Debian, where both silently failed. A locale is generated from `/etc/locale.gen` and nowhere else, so `update-locale` refused one that was not there and the VM stayed on C.UTF-8; the keyboard module ends on a `console-setup` the genericcloud image does not carry, so `/etc/default/keyboard` — the file localed and X read — is written directly instead. Every Debian deployment used to print `cloud-init: status: error`, and a word that always shows warns of nothing
- A guest with no per-file trust anchor is taken out of the download cache instead of being intercepted without one. Interception is transparent and covers the whole bridge, so a VM given no authority still fails every HTTPS download on « self-signed certificate in certificate chain » — and on a declarative system placing the authority comes too late, the first rebuild being the first download. On a Proxmox host it is the HOST that is exempted: a nested guest leaves masqueraded behind it and the bridge never sees its own address. Measured from inside the guest: code 000 and SSL verification 19, then 200 and 0. Without it the package manager fell back to building 564 derivations, whose sources failed for the same reason
- NixOS learns the download cache's authority WITHOUT a rebuild, so it is no longer taken out of the cache — which used to close offline deployment to it, the store being the only source there and an exempted VM having none. It has no per-file trust anchor and /etc is generated read-only, while a declaration would come too late, the first rebuild being the first download. Measured on an intercepted VM: a READ goes through the nix client and a session variable suffices, but REALISING a derivation goes through nix-daemon, which never sees it; a drop-in under /run/systemd/system — a tmpfs, writable where /etc is not — reaches it. The bundle concatenates the system authorities with the cache's, giving the cache's alone would stop trusting everything else. Checked with upstream CUT on a fresh VM: nix-shell realises from the store
- Odoo answers from outside a NixOS VM. It listened on 0.0.0.0:8069 and replied locally, but NixOS enables a firewall by default where none of the four other cloud images does: the host received nothing — not a refusal, silence until the timeout — and the monitor declared Odoo absent on a machine where it was running. Measured from the host: 000 after 12 s, then 303 in 9 ms
- ERPLibre runs as a service on NixOS. The install ended by writing a unit into `/etc/systemd/system`, generated from the store and mounted read-only: it returned 1 at its last step, after the clone, the venv and an Odoo start had all succeeded. The unit is now declared by the module; its interpreter comes from the store, `/bin` being an envfs FUSE mount that systemd does not see when it resolves the executable; and its PATH carries bash, whose absence stopped `run.sh` before Odoo
- What a declarative system must declare, and the four others receive free from their cloud image: xmlsec1, without which Odoo refuses to install auth_saml, a module of the addons path; parallel and shfmt, called by bare name; growpart, absent from the whole system while the disk grow is written « … || true » and returned 0 without growing anything; and the guest agent, which came from the image rather than the repository, its unit PATH lacking findmnt so that guest-exec died with 127 on its first line

View file

@ -194,6 +194,24 @@
CPATH = "/run/current-system/sw/include";
LIBRARY_PATH = "/run/current-system/sw/lib";
PKG_CONFIG_PATH = "/run/current-system/sw/lib/pkgconfig";
} // lib.optionalAttrs ("@EL_CA_BUNDLE@" != "") {
# L'autorité du cache de téléchargement, pour TOUTE session d'après
# l'installation — un « git pull » échouerait sinon sur un certificat
# qu'il ne reconnaît pas, comme le clone avant elle.
#
# La commande d'installation porte ces mêmes variables elle-même : elle
# tourne AVANT la première reconstruction, et aucun chemin PAM n'est
# inscriptible d'ici là. Les deux moitiés se relaient.
#
# « optionalAttrs » et non une valeur de repli : sur une machine sans
# cache le faisceau n'existe pas, et y pointer SSL_CERT_FILE couperait
# TLS partout.
SSL_CERT_FILE = "@EL_CA_BUNDLE@";
CURL_CA_BUNDLE = "@EL_CA_BUNDLE@";
GIT_SSL_CAINFO = "@EL_CA_BUNDLE@";
REQUESTS_CA_BUNDLE = "@EL_CA_BUNDLE@";
NODE_EXTRA_CA_CERTS = "@EL_CA_BUNDLE@";
PIP_CERT = "@EL_CA_BUNDLE@";
};
# Les réglages régionaux demandés au déploiement.

View file

@ -30,6 +30,11 @@ lire_seed() {
sudo grep -m1 -E "^${1}: " "${EL_CLOUD_CFG}" 2>/dev/null |
cut -d" " -f2- | tr -d "\r" || true
}
# Le faisceau que cloud-init a bâti, s'il l'a fait : vide sur une machine
# sans cache, et le module n'y déclare alors aucune variable — y pointer
# couperait TLS partout.
EL_CA_BUNDLE=/var/lib/erplibre/ca-bundle.crt
[ -r "${EL_CA_BUNDLE}" ] || EL_CA_BUNDLE=""
EL_LOCALE=${EL_LOCALE:-$(lire_seed locale)}
EL_TZ=${EL_TZ:-$(lire_seed timezone)}
MODULE_SRC="conf/nixos/erplibre.nix"
@ -51,8 +56,10 @@ echo -e "\n---- Module ERPLibre pour NixOS ----"
# module déclare un rôle PostgreSQL, et un rôle porte un nom.
sed -e "s/@EL_USER@/${EL_USER}/g" -e "s#@EL_DIR@#${EL_DIR}#g" \
-e "s/@EL_LOCALE@/${EL_LOCALE}/g" -e "s#@EL_TZ@#${EL_TZ}#g" \
-e "s#@EL_CA_BUNDLE@#${EL_CA_BUNDLE}#g" \
"${MODULE_SRC}" | sudo tee "${MODULE_DST}" > /dev/null
echo " posé : ${MODULE_DST} (compte ${EL_USER}, dépôt ${EL_DIR})"
echo " autorité du cache : ${EL_CA_BUNDLE:-aucune}"
echo " régional : locale « ${EL_LOCALE:-non demandée} »," \
"fuseau « ${EL_TZ:-non demandé} »"

View file

@ -3212,6 +3212,74 @@ OFFLINE_BOOTCMD = [
" - systemctl stop --no-block systemd-time-wait-sync.service || true",
]
# NixOS n'a pas d'ancre de confiance PAR FICHIER : /etc est généré depuis le
# store et monté en lecture seule, donc la forme de CACHE_TRUST — un
# répertoire où déposer, une commande qui relit — n'y existe pas. Le
# téléchargeur n'est pas non plus le même : mesuré, une LECTURE passe par le
# client et honore une variable de session, mais RÉALISER une dérivation
# passe par nix-daemon, qui ne la voit pas. Seul un fragment systemd
# l'atteint.
#
# /run/systemd/system est un tmpfs, donc inscriptible quand /etc ne l'est
# pas : le fragment y vit, et il prend effet sans reconstruction — ce qui est
# tout l'enjeu, la première reconstruction étant elle-même le premier
# téléchargement.
#
# Le faisceau est la CONCATÉNATION de celui du système et de l'autorité :
# donner l'autorité seule ferait cesser d'approuver tout le reste.
NIX_CA_DIR = "/var/lib/erplibre"
NIX_CA_BUNDLE = f"{NIX_CA_DIR}/ca-bundle.crt"
NIX_CA_SYSTEME = "/etc/ssl/certs/ca-certificates.crt"
NIX_DROPIN = "/run/systemd/system/nix-daemon.service.d/10-erplibre-cache.conf"
# Les variables qui portent le faisceau aux consommateurs d'une SESSION.
#
# Elles sont la seule voie ici : les quatre familles impératives déposent
# l'autorité dans le magasin du système, que tout lit sans qu'on demande, et
# qui SURVIT à sudo puisque ce n'est pas un environnement. NixOS n'a pas ce
# magasin — /etc/ssl/certs est un lien vers le store — donc chaque
# consommateur doit être pointé, et sudo les efface tous.
NIX_CA_VARS = (
"SSL_CERT_FILE",
"CURL_CA_BUNDLE",
"GIT_SSL_CAINFO",
"REQUESTS_CA_BUNDLE",
"NODE_EXTRA_CA_CERTS",
"PIP_CERT",
)
def nix_trust_commands() -> list[str]:
"""Ce qui fait approuver l'autorité du cache par nix, sans reconstruire.
Mesuré sur une VM interceptée : sans cela « nix-shell -p hello » échoue
sur « self-signed certificate in certificate chain » ; avec, la dérivation
est réalisée depuis cache.nixos.org à travers le cache, et le magasin
garde ses .narinfo — ce qui rend le hors ligne possible ensuite.
"""
dossier = NIX_DROPIN.rsplit("/", 1)[0]
return [
f"mkdir -p {NIX_CA_DIR} {dossier}",
f"cat {NIX_CA_SYSTEME} {NIX_CA_DIR}/{CACHE_CERT_NAME}"
f" > {NIX_CA_BUNDLE}",
f"printf '[Service]\\nEnvironment=NIX_SSL_CERT_FILE=%s\\n"
f"Environment=CURL_CA_BUNDLE=%s\\n' {NIX_CA_BUNDLE} {NIX_CA_BUNDLE}"
f" > {NIX_DROPIN}",
"systemctl daemon-reload",
# Socket-activé : arrêter les deux fait reprendre l'environnement à
# la prochaine connexion.
"systemctl stop nix-daemon 2>/dev/null || true",
"systemctl restart nix-daemon.socket 2>/dev/null || true",
# ET sudo, sans quoi rien de ce que la commande exporte ne survit :
# « sudo nixos-rebuild switch » évalue la configuration en allant
# chercher des centaines de narinfo, et les demandait toutes sans
# l'autorité. Les autres familles n'en ont pas besoin pour la même
# raison qu'elles n'ont pas besoin des variables : leur magasin
# système n'est pas un environnement.
commande_sudoers(NIX_CA_VARS),
]
CACHE_CERT_NAME = "erplibre-cache.crt"
# Les familles à qui AUCUN des trois gestes de CACHE_TRUST ne s'applique.
@ -3227,7 +3295,11 @@ CACHE_CERT_NAME = "erplibre-cache.crt"
# donc en être SOUSTRAITE, faute de quoi elle ne télécharge plus rien — et le
# message qu'elle rendrait, « self-signed certificate in certificate chain »,
# ne dit rien d'une famille sans magasin.
CACHE_SANS_AUTORITE = frozenset({"nix"})
# Plus aucune famille n'y figure. « nix » y était tant qu'on ne savait pas
# lui donner l'autorité : il l'a désormais par un fragment systemd, mesuré.
# La table reste, et le repli avec elle — une famille future sans magasin
# retomberait dessus plutôt que d'être interceptée sans rien.
CACHE_SANS_AUTORITE: frozenset = frozenset()
def cache_sans_autorite(distro: str) -> bool:
@ -3435,7 +3507,7 @@ def cache_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]:
if getattr(args, "cache_bypass", False):
return []
famille = cache_family(args.distro)
if famille not in CACHE_TRUST:
if famille != "nix" and famille not in CACHE_TRUST:
return []
try:
with open(args.cache_ca, encoding="utf-8") as fh:
@ -3446,7 +3518,7 @@ def cache_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]:
return []
if "BEGIN CERTIFICATE" not in pem:
return []
anchors = CACHE_TRUST[famille][0]
anchors = NIX_CA_DIR if famille == "nix" else CACHE_TRUST[famille][0]
return [(f"{anchors}/{CACHE_CERT_NAME}", "0644", pem, "")]
@ -3467,7 +3539,15 @@ def cache_commands(args: argparse.Namespace) -> list[str]:
"""
if not cache_files(args):
return []
_, commande, faisceau = CACHE_TRUST[cache_family(args.distro)]
famille = cache_family(args.distro)
# NixOS ne relit pas un magasin : il n'en a pas de la forme attendue. Ses
# gestes bâtissent un faisceau, le donnent au démon qui télécharge, et
# font traverser sudo aux variables qui le désignent. Pas /etc/environment
# en revanche : son pam_env porte « readenv=0 » et son fichier de
# configuration est un lien vers le store.
if famille == "nix":
return nix_trust_commands()
_, commande, faisceau = CACHE_TRUST[famille]
commandes = [f"{commande} || true"]
for var in CACHE_ENV_VARS:
commandes.append(
@ -3506,14 +3586,44 @@ def commande_sudoers(variables, fichier: str = CACHE_SUDOERS) -> str:
répertoire sudoers.d — retire le temporaire et ne fait pas échouer la
commande. Une variable par ligne, sans guillemets : la commande passe
telle quelle dans un « runcmd » YAML comme dans un « sh -c » par ssh.
LE PROFIL DU SYSTÈME EST AJOUTÉ AU PATH. cloud-init ne donne à son
« runcmd » que le PATH de son propre service, et sur une distribution
déclarative celui-ci est une liste de chemins du store qui ne contient
pas sudo : visudo est alors introuvable, la vérification échoue, et le
garde-fou efface le fichier au lieu de le poser — sans rien dire, puisque
c'est précisément sa dégradation prévue. Le répertoire n'existe pas sur
les quatre familles impératives, où l'ajout ne change rien.
"""
dossier, nom = fichier.rsplit("/", 1)
tmp = f"{dossier}/.{nom}"
return (
f"sh -c 'for v in {' '.join(variables)};"
f"sh -c 'PATH=$PATH:/run/current-system/sw/bin;"
f" for v in {' '.join(variables)};"
f" do echo Defaults env_keep += $v; done > {tmp}"
f" && chmod 0440 {tmp} && visudo -cf {tmp} && mv {tmp} {fichier}"
f" || rm -f {tmp}'"
f' || (rm -f {tmp}; echo " ⚠ {t_sudoers_manque()}")\''
)
def t_sudoers_manque() -> str:
"""Ce que dit un sudoers non posé.
L'échec est VOLONTAIREMENT sans conséquence sur le déploiement — un
sudoers.d absent ne vaut pas de tout arrêter. Mais muet, il se paie plus
tard et ailleurs, sur un « self-signed certificate » que rien ne relie à
ce geste. La ligne nomme le seul effet qui compte, et ce qu'il faut
regarder.
TROIS CARACTÈRES INTERDITS, et chacun casse ailleurs. L'apostrophe ferme
la quote simple qui enveloppe toute la commande, et le shell distant
meurt sur « unexpected EOF ». « : » suivi d'une espace, une accolade ou un
crochet font lire au YAML de « runcmd » autre chose qu'un scalaire
simple. Le message ne les porte donc pas, et un test le vérifie.
"""
return (
"sudoers non posé — visudo introuvable, "
"sudo perdra le faisceau du cache"
)
@ -5721,9 +5831,24 @@ def main() -> None:
# l'autorité du cache : on l'en SOUSTRAIT plutôt que de la laisser buter
# sur un certificat inconnu à chaque téléchargement. Décidé ici et non
# demandé à l'opérateur — c'est le catalogue qui sait.
if cache_sans_autorite(getattr(args, "distro", "")) and not getattr(
args, "cache_bypass", False
):
#
# JAMAIS HORS LIGNE, et c'est la même raison que sur la voie Proxmox :
# l'amont est alors coupé et le magasin est la SEULE source. Soustraire
# la VM ne la ferait pas télécharger en direct — cela ne lui laisserait
# RIEN. Les deux issues échouent pour une distribution sans magasin de
# certificats : exceptée, elle n'a plus de source ; interceptée, elle bute
# sur un certificat qu'elle ne reconnaît pas. Le dire AVANT vaut mieux
# qu'une heure d'installation pour y arriver.
sans_magasin = cache_sans_autorite(getattr(args, "distro", ""))
if sans_magasin and getattr(args, "offline", False):
print(
f"\n ⚠ {args.distro} n'a pas de magasin de certificats, et"
" l'amont du cache est coupé."
"\n Aucune source ne lui reste : l'exception la priverait du"
" magasin, et sans elle"
"\n chaque téléchargement bute sur un certificat inconnu."
)
elif sans_magasin and not getattr(args, "cache_bypass", False):
args.cache_bypass = True
print(
f"\n {args.distro} n'a pas de magasin de certificats :"

View file

@ -428,6 +428,39 @@ class QemuInstallMixin:
# cloud de 40 G, l'oublier remplit le disque en pleine installation.
QEMU_DESKTOP_EXTRA_DISK_GB = 6
@staticmethod
def _qemu_ca_exports():
"""Donne l'autorité du cache à TOUT ce que la commande distante lance.
Les quatre familles impératives posent l'autorité dans le magasin du
système, et curl, git et le reste la trouvent seuls. NixOS n'a pas
d'ancre par fichier : l'autorité y est un faisceau sous un chemin
inscriptible, que rien ne consulte sans qu'on le dise — nix mis à
part, qui a son fragment de service.
PAS par /etc/environment : mesuré sur une VM, le pam_env de sshd porte
« readenv=0 » et son fichier de configuration est un lien vers le
store. Aucun chemin PAM n'est inscriptible avant la première
reconstruction, et c'est précisément avant elle que le clone a lieu.
La commande porte donc ses variables elle-même.
GARDÉ par l'existence du fichier : ailleurs il n'y en a pas, et
pointer SSL_CERT_FILE sur un fichier absent couperait TLS partout. Un
« if » et non un « && » : sous « set -e », une garde fausse en fin de
liste ET-OU est un cas limite qui dépend du shell.
"""
faisceau = "/var/lib/erplibre/ca-bundle.crt"
variables = (
"SSL_CERT_FILE",
"CURL_CA_BUNDLE",
"GIT_SSL_CAINFO",
"REQUESTS_CA_BUNDLE",
"NODE_EXTRA_CA_CERTS",
"PIP_CERT",
)
export = " ".join(f"{v}={faisceau}" for v in variables)
return f"if [ -r {faisceau} ]; then export {export}; fi; "
@staticmethod
def _qemu_cloud_init_wait():
"""Attend la fin de cloud-init, qui tient le verrou apt/dnf/pacman
@ -477,6 +510,14 @@ class QemuInstallMixin:
# Les variables du cache sont écrites par cloud-init PENDANT
# l'attente : cette session, ouverte avant, ne les a pas reçues.
+ cache_env_reload() + "; "
# ICI, et nulle part avant. Le faisceau que ces exports désignent
# est écrit par cloud-init lui aussi : mesuré sur une VM, la
# session ssh est acceptée une seconde avant qu'il existe, donc
# une garde évaluée en tête de commande est fausse et n'exporte
# rien. Tout ce qui suit perd alors l'autorité — le clone du dépôt
# échoue sur « self-signed certificate », quand nix, qui lit son
# fragment au moment de s'en servir, télécharge très bien.
+ QemuInstallMixin._qemu_ca_exports()
)
@staticmethod

View file

@ -35,6 +35,22 @@ SCRIPT = RACINE / "script/install/install_nixos_dependency.sh"
AIGUILLAGE = RACINE / "script/install/install_dev.sh"
def _deploy_qemu():
"""deploy_qemu.py chargé comme module, comme le fait todo.py."""
import importlib.util
import sys
sys.argv = ["todo.py"]
chemin = RACINE / "script/qemu/deploy_qemu.py"
spec = importlib.util.spec_from_file_location("deploy_qemu", chemin)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
DQ = _deploy_qemu()
class LAiguillage(unittest.TestCase):
def setUp(self):
self.src = AIGUILLAGE.read_text(encoding="utf-8")
@ -440,6 +456,126 @@ class LesReglagesRegionauxDemandes(unittest.TestCase):
self.assertIn("glibc-locales", self.src)
class LAutoriteAtteintToutCeQuiTelecharge(unittest.TestCase):
"""nix a son fragment de service ; curl, git et le reste n'ont rien.
Les quatre familles impératives posent l'autorité dans le magasin du
système et tout la trouve seul. Ici l'autorité est un faisceau sous un
chemin inscriptible, que rien ne consulte sans qu'on le dise — et le
clone du dépôt échouait donc sur « self-signed certificate in
certificate chain » alors que nix, lui, téléchargeait très bien.
Deux moitiés qui se relaient : la commande d'installation porte ses
variables elle-même, car elle tourne AVANT la première reconstruction et
aucun chemin PAM n'est inscriptible d'ici là — mesuré, le pam_env de
sshd porte « readenv=0 » et son fichier est un lien vers le store. Le
module prend le relais pour les sessions d'après.
"""
def setUp(self):
self.src = MODULE.read_text(encoding="utf-8")
self.script = SCRIPT.read_text(encoding="utf-8")
def _exports(self):
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
return TODO.__new__(TODO)._qemu_ca_exports()
def test_the_command_carries_them_itself(self):
exports = self._exports()
for var in ("SSL_CERT_FILE", "GIT_SSL_CAINFO", "CURL_CA_BUNDLE"):
with self.subTest(var=var):
self.assertIn(var, exports)
def test_nothing_is_exported_where_there_is_no_bundle(self):
"""Pointer SSL_CERT_FILE sur un fichier absent couperait TLS partout,
et les quatre autres familles n'ont pas ce faisceau."""
self.assertIn("if [ -r ", self._exports())
# Un « if » et non un « && » : sous « set -e », une garde fausse en
# fin de liste ET-OU est un cas limite qui dépend du shell.
self.assertNotIn("] && export", self._exports())
def test_the_module_takes_over_afterwards(self):
"""Sans cela un « git pull » plus tard échouerait comme le clone."""
self.assertIn("GIT_SSL_CAINFO", self.src)
self.assertIn("@EL_CA_BUNDLE@", self.src)
def test_a_machine_without_a_cache_declares_nothing(self):
"""« optionalAttrs » et non un défaut : le faisceau n'existe pas
partout, et l'installeur laisse le marqueur vide dans ce cas."""
self.assertIn('lib.optionalAttrs ("@EL_CA_BUNDLE@" != "")', self.src)
self.assertIn(
'[ -r "${EL_CA_BUNDLE}" ] || EL_CA_BUNDLE=""', self.script
)
class SudoNEffacePasLAutorite(unittest.TestCase):
"""L'installation reconstruit le système par « sudo », et sudo remet
l'environnement à zéro.
Le fragment de service donné au démon nix ne couvre pas ce cas : mesuré
sur une VM, « sudo nix store info » rend « Store URL: local » et un
NIX_REMOTE vide. Le nix de root parle DIRECTEMENT au magasin local, sans
jamais passer par le démon — c'est lui qui télécharge, avec
l'environnement que sudo lui laisse, et il n'en laisse aucun.
Le mode de défaillance : chaque objet que la reconstruction doit chercher
est demandé sans l'autorité, et le seul journal est une répétition de
« unable to download ... narinfo: SSL peer certificate ... was not OK ».
Le système n'est pas activé, et rien ne nomme la cause.
Le remède est celui des autres familles, « Defaults env_keep », et non un
second : NixOS lit bien /etc/sudoers.d — son /etc/sudoers porte
« #includedir », le répertoire existe, et visudo est dans le PATH de root.
"""
def _commandes(self):
import tempfile
with tempfile.NamedTemporaryFile("w", suffix=".crt") as fh:
fh.write("-----BEGIN CERTIFICATE-----\n")
fh.flush()
args = DQ.build_parser().parse_args(
["--distro", "nixos", "--hostname", "x", "--cache-ca", fh.name]
)
return DQ.cache_commands(args)
def test_the_variables_cross_sudo(self):
joint = " ".join(self._commandes())
self.assertIn(DQ.CACHE_SUDOERS, joint)
self.assertIn("env_keep", joint)
def test_every_variable_the_session_exports_is_kept(self):
"""Les deux listes sont lues ensemble ou pas du tout : une variable
exportée que sudo efface est exactement le défaut qu'on répare, et
une variable gardée que rien n'exporte ne garde rien."""
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
exportees = set(
re.findall(
r"(\w+)=/var/lib/erplibre/ca-bundle\.crt",
TODO.__new__(TODO)._qemu_ca_exports(),
)
)
self.assertTrue(exportees, "la commande n'exporte rien")
self.assertEqual(exportees, set(DQ.NIX_CA_VARS))
def test_the_rebuild_is_the_one_that_needs_it(self):
"""Le garde-fou pointe la ligne qui justifie tout le reste : si la
reconstruction cessait de passer par sudo, ce mécanisme n'aurait plus
de raison d'être, et personne ne s'en apercevrait."""
self.assertIn(
"sudo nixos-rebuild switch",
SCRIPT.read_text(encoding="utf-8"),
)
class LePortDOdooTraverseLePareFeu(unittest.TestCase):
"""NixOS active un pare-feu par défaut ; aucune des images cloud des
quatre autres distributions n'en active un.
@ -524,6 +660,70 @@ class LeMenuNEcritPasDansEtcSurNixos(unittest.TestCase):
self.assertIn("make install_os", cmd)
class LAutoriteEstExporteeApresQueCloudInitLAEcrite(unittest.TestCase):
"""L'ordre entre l'attente et les exports, qui n'est pas un détail.
Le faisceau est écrit par cloud-init, et la session ssh est ouverte
AVANT lui : mesuré sur une VM, ssh est accepté à 13:18:27 et le fichier
apparaît à 13:18:28. Une garde « if [ -r … ] » évaluée en tête de
commande est donc fausse, n'exporte rien, et tout ce qui suit dans ce
shell perd l'autorité — le clone du dépôt échoue sur « self-signed
certificate » alors que nix, qui lit son fragment au moment de s'en
servir, télécharge très bien. Le symptôme accuse le réseau ; la cause est
une seconde d'écart.
Le remède est STRUCTUREL : les exports vivent dans l'attente elle-même,
à côté de la relecture des variables que les autres familles y font déjà.
Trois commandes distantes sont bâties à des endroits différents, et
aucune ne peut plus les oublier ni les mettre trop tôt.
"""
def _todo(self):
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
return TODO.__new__(TODO)
def test_the_exports_live_inside_the_wait(self):
attente = self._todo()._qemu_cloud_init_wait()
self.assertIn("ca-bundle.crt", attente)
def test_they_come_after_the_unit_that_writes_the_bundle(self):
"""« cloud-init status --wait » rend la main trop tôt : c'est
l'attente de l'unité finale qui garantit le fichier écrit."""
attente = self._todo()._qemu_cloud_init_wait()
self.assertLess(
attente.index("cloud-final"), attente.index("ca-bundle.crt")
)
def test_every_remote_command_carries_them_exactly_once(self):
"""Les trois points de sortie — bureau seul, bureau et outils,
installation complète — passent tous par l'attente."""
todo = self._todo()
for cas in (
{"branch": None, "desktop": False},
{"branch": None, "desktop": True},
{"branch": "master", "desktop": False},
):
with self.subTest(**cas):
cmd = todo._qemu_erplibre_remote_cmd(**cas)
self.assertEqual(
cmd.count("if [ -r /var/lib/erplibre/ca-bundle.crt ]"),
1,
"les exports doivent y être, et une seule fois",
)
def test_no_caller_puts_them_back_in_front(self):
"""Un appelant qui les rajouterait en tête ramènerait le défaut sans
que rien d'autre ne change."""
source = (RACINE / "script/todo/qemu_deploy.py").read_text(
encoding="utf-8"
)
self.assertNotIn("_qemu_ca_exports()", source)
class LeServiceAttendQuOdooSoitLa(unittest.TestCase):
"""L'unité est déclarée par le module, donc démarrée par la
reconstruction — laquelle a lieu PENDANT « make install_os ».

View file

@ -318,9 +318,9 @@ class TestLesVariablesTraversentSudo(unittest.TestCase):
commande.index("mv /etc/sudoers.d/.essai /etc/sudoers.d/essai"),
)
self.assertIn("chmod 0440", commande)
self.assertTrue(
commande.rstrip("'").endswith("|| rm -f /etc/sudoers.d/.essai")
)
# Le temporaire part toujours, et l'échec se DIT : muet, il se paie
# plus tard sur un « self-signed certificate » que rien ne relie ici.
self.assertIn("|| (rm -f /etc/sudoers.d/.essai; echo ", commande)
def test_le_contenu_ecrit_est_une_ligne_par_variable(self):
"""Joué dans un vrai shell, visudo remplacé : c'est le fichier produit
@ -386,7 +386,16 @@ class TestLeHorsLigneNAttendPasLHeure(unittest.TestCase):
self.assertTrue(ligne.rstrip().endswith("|| true"))
def test_une_vm_en_ligne_garde_sa_synchronisation(self):
self.assertNotIn("bootcmd", self.config())
"""La levée n'a lieu QUE hors ligne.
« Aucun bootcmd » l'a longtemps dit, parce que la levée était le seul.
Le locale en pose un autre depuis — il génère la locale demandée avant
que le module de cloud-init ne l'applique —, et l'absence GLOBALE ne
prouve donc plus rien. C'est la levée nommément qui doit manquer, et
c'est elle que cette classe garde."""
cmd = self.config().get("bootcmd") or []
aplati = " ".join(str(c) for c in cmd)
self.assertNotIn("systemd-time-wait-sync", aplati)
class TestLaLangueDesMessagesDuCache(unittest.TestCase):
@ -471,20 +480,44 @@ class TestAucunSystemeNestOublie(unittest.TestCase):
apprendre l'autorité et qu'on laisse passer n'échoue pas à
l'installation du certificat — elle échoue sur CHAQUE téléchargement,
avec un message qui ne dit rien de la cause.
L'épreuve porte sur le COMPORTEMENT et non sur l'appartenance à une
table : « nix » ne pose pas l'autorité dans un répertoire d'ancres —
il n'en a pas — mais il la pose, par un fragment systemd. Compter les
entrées de CACHE_TRUST l'aurait déclaré manquant alors qu'il est
servi, et déclarerait manquante toute mécanique future.
"""
import tempfile
from script.qemu.deploy_qemu import (
CACHE_SANS_AUTORITE,
CACHE_TRUST,
DISTROS,
build_parser,
cache_commands,
cache_family,
)
familles = {cache_family(d) for d in DISTROS} - {""}
manquantes = sorted(familles - set(CACHE_TRUST) - CACHE_SANS_AUTORITE)
with tempfile.NamedTemporaryFile(
"w", suffix=".crt", delete=False
) as fh:
fh.write("-----BEGIN CERTIFICATE-----\nZXNzYWk=\n")
fh.write("-----END CERTIFICATE-----\n")
ca = fh.name
muettes = []
for distro in DISTROS:
if not cache_family(distro):
continue
if cache_family(distro) in CACHE_SANS_AUTORITE:
continue
args = build_parser().parse_args(
["--distro", distro, "--hostname", "x", "--cache-ca", ca]
)
if not cache_commands(args):
muettes.append(distro)
self.assertEqual(
manquantes,
muettes,
[],
f"familles sans commande de confiance : {manquantes}",
f"systèmes qui ne posent rien : {muettes}",
)
def test_une_famille_exemptee_nest_pas_aussi_dans_la_table(self):
@ -495,17 +528,20 @@ class TestAucunSystemeNestOublie(unittest.TestCase):
self.assertEqual(set(), set(CACHE_TRUST) & CACHE_SANS_AUTORITE)
def test_une_exemptee_est_bien_soustraite_au_detournement(self):
"""L'exemption ne vaut que si le déploiement la POSE : la déclarer et
laisser la VM sur le pont ne change rien à son sort."""
from script.qemu.deploy_qemu import cache_sans_autorite
def test_plus_aucun_systeme_du_catalogue_nest_soustrait(self):
"""L'exemption est le dernier recours, et plus personne n'y tombe.
self.assertTrue(cache_sans_autorite("nixos"))
for connue in ("debian", "ubuntu", "fedora", "arch", "opensuse"):
with self.subTest(distro=connue):
self.assertFalse(cache_sans_autorite(connue))
# Un système hors catalogue n'est pas « exempté » : il n'a pas de
# famille, et c'est une autre question que celle-ci.
NixOS y était tant qu'on ne savait pas lui donner l'autorité : /etc
est en lecture seule, et une déclaration arriverait après le premier
téléchargement. Il l'a désormais par un fragment systemd, ce qui le
fait ENTRER dans le cache au lieu de l'en soustraire — et sans cela le
hors ligne lui était fermé, le magasin étant alors la seule source.
"""
from script.qemu.deploy_qemu import DISTROS, cache_sans_autorite
for distro in DISTROS:
with self.subTest(distro=distro):
self.assertFalse(cache_sans_autorite(distro))
self.assertFalse(cache_sans_autorite("inconnue"))
def test_la_famille_vient_du_catalogue_et_nest_pas_recopiee(self):
@ -517,5 +553,208 @@ class TestAucunSystemeNestOublie(unittest.TestCase):
self.assertEqual(cache_family(d), attendue, d)
class LeHorsLigneNeSousTraitPasUneDistributionSansMagasin(unittest.TestCase):
"""Les deux ne se combinent pas, et c'est dit AVANT.
Hors ligne, l'amont est coupé et le magasin est la SEULE source.
Soustraire au cache une distribution sans magasin de certificats ne la
ferait pas télécharger en direct : cela ne lui laisserait RIEN. Et sans
l'exception, chaque téléchargement bute sur un certificat inconnu. Les
deux issues échouent — une heure plus tard, si personne ne le dit.
"""
SRC = (RACINE / "script/qemu/deploy_qemu.py").read_text(encoding="utf-8")
def _bloc(self):
i = self.SRC.index("sans_magasin = cache_sans_autorite(")
return self.SRC[i : i + 1400]
def test_offline_is_tested_before_the_fallback(self):
"""L'ordre EST la correction : l'ancien code posait l'exception sans
jamais regarder si l'amont était coupé."""
bloc = self._bloc()
self.assertLess(
bloc.index('getattr(args, "offline", False)'),
bloc.index("args.cache_bypass = True"),
)
def test_offline_never_exempts(self):
bloc = self._bloc()
avant = bloc[: bloc.index("args.cache_bypass = True")]
self.assertNotIn("args.cache_bypass = True", avant)
self.assertIn("elif sans_magasin", bloc)
def test_the_dead_end_is_named(self):
"""Un refus qui ne dit pas pourquoi renvoie chercher dans la VM."""
bloc = self._bloc()
self.assertIn("Aucune source", bloc)
class NixApprendLAutoriteSansReconstruire(unittest.TestCase):
"""NixOS n'a pas d'ancre de confiance par fichier, et une déclaration
arriverait après le premier téléchargement — la première reconstruction
EST ce téléchargement.
Mesuré sur une VM interceptée : une LECTURE passe par le client nix et se
contente d'une variable de session, mais RÉALISER une dérivation passe
par nix-daemon, qui ne la voit pas. Un fragment systemd l'atteint, et
/run/systemd/system est un tmpfs — inscriptible quand /etc ne l'est pas.
"""
def _args(self, distro="nixos"):
import tempfile
with tempfile.NamedTemporaryFile(
"w", suffix=".crt", delete=False
) as fh:
fh.write("-----BEGIN CERTIFICATE-----\nZXNzYWk=\n")
fh.write("-----END CERTIFICATE-----\n")
return build_parser().parse_args(
["--distro", distro, "--hostname", "x", "--cache-ca", fh.name]
)
def test_the_authority_lands_where_it_can_be_written(self):
"""/etc est généré depuis le store : y déposer échouerait."""
from script.qemu.deploy_qemu import NIX_CA_DIR, cache_files
chemins = [f[0] for f in cache_files(self._args())]
self.assertTrue(chemins)
for c in chemins:
with self.subTest(chemin=c):
self.assertTrue(c.startswith(NIX_CA_DIR))
self.assertFalse(c.startswith("/etc/"))
def test_the_bundle_keeps_the_system_authorities(self):
"""Donner la seule autorité du cache ferait cesser d'approuver tout
le reste : le faisceau est une CONCATÉNATION."""
from script.qemu.deploy_qemu import NIX_CA_SYSTEME, cache_commands
joint = " ".join(cache_commands(self._args()))
self.assertIn(NIX_CA_SYSTEME, joint)
self.assertIn("cat ", joint)
def test_the_daemon_is_reached_not_the_session(self):
"""La session suffit pour LIRE, jamais pour réaliser : c'est le démon
qui télécharge alors, et seul un fragment l'atteint."""
from script.qemu.deploy_qemu import NIX_DROPIN, cache_commands
joint = " ".join(cache_commands(self._args()))
self.assertIn(NIX_DROPIN, joint)
self.assertIn("NIX_SSL_CERT_FILE", joint)
self.assertIn("daemon-reload", joint)
def test_nothing_needs_a_rebuild(self):
"""Tout l'enjeu : la première reconstruction est elle-même le premier
téléchargement."""
joint = " ".join(cache_commands(self._args()))
self.assertNotIn("nixos-rebuild", joint)
def test_the_other_families_are_untouched(self):
"""Elles ont un répertoire d'ancres et une commande qui le relit."""
from script.qemu.deploy_qemu import NIX_DROPIN, cache_commands
for distro in ("debian", "ubuntu", "fedora", "arch", "opensuse"):
with self.subTest(distro=distro):
joint = " ".join(cache_commands(self._args(distro)))
self.assertNotIn(NIX_DROPIN, joint)
self.assertIn("/etc/environment", joint)
class LesGestesSurvivententAuTransport(unittest.TestCase):
"""Les commandes du cache voyagent par deux transports, et chacun a ses
caractères mortels.
Le premier est le « runcmd » de cloud-init, un scalaire simple de YAML :
un « : » suivi d'une espace, une accolade ou un crochet en tête y font
lire autre chose qu'une commande. Le second est « sh -c '…' », que la
moindre apostrophe referme — le shell distant meurt alors sur
« unexpected EOF », loin de la ligne fautive.
Aucun des deux ne se voit à la lecture du Python : la chaîne y est
correcte, et c'est sa TRAVERSÉE qui échoue. D'où ces épreuves, une par
transport, sur toutes les familles à la fois.
"""
def setUp(self):
import tempfile
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
def _familles(self):
"""Une distribution par famille, nix compris — il est le seul dont
les gestes ne viennent pas de CACHE_TRUST."""
return ("debian", "ubuntu", "fedora", "arch", "opensuse", "nixos")
def test_every_runcmd_line_survives_a_yaml_parse(self):
for distro in self._familles():
with self.subTest(distro=distro):
lignes = cache_runcmd(faux_args(Path(self.tmp.name), distro))
charge = yaml.safe_load("runcmd:\n" + "\n".join(lignes))
self.assertEqual(
charge["runcmd"],
[l.removeprefix(" - ") for l in lignes],
"le YAML ne rend pas la commande telle qu'elle est écrite",
)
def test_every_command_survives_a_real_shell(self):
"""« sh -n » lit la commande sans rien exécuter : une quote non
refermée s'y voit, un rm -rf ne s'y joue pas."""
import subprocess
for distro in self._familles():
for commande in cache_commands(
faux_args(Path(self.tmp.name), distro)
):
with self.subTest(distro=distro, debut=commande[:40]):
res = subprocess.run(
["sh", "-n", "-c", commande],
capture_output=True,
text=True,
)
self.assertEqual(res.returncode, 0, res.stderr)
class LeSudoersAtteintVisudoLaOuIlEst(unittest.TestCase):
"""Un système déclaratif ne met pas sudo dans le PATH de cloud-init.
Mesuré sur une VM : le « runcmd » hérite du PATH du service de
cloud-init, vingt-six chemins du magasin dont AUCUN ne porte sudo, et
/usr/bin/visudo n'existe pas non plus. La vérification échouait donc,
le garde-fou effaçait le fichier au lieu de le poser, et sudo continuait
de vider l'environnement — l'installation ne rendait plus qu'une
répétition de refus de certificat.
Le profil du système est le chemin que ces distributions garantissent ;
les quatre familles impératives ne l'ont pas, et l'ajout n'y change rien.
"""
PROFIL = "/run/current-system/sw/bin"
def test_the_system_profile_is_on_the_path(self):
self.assertIn(self.PROFIL, commande_sudoers(["A"]))
def test_the_path_is_extended_not_replaced(self):
"""Remplacer le PATH perdrait echo, chmod et mv, qui viennent du
magasin eux aussi et ne sont nulle part ailleurs."""
self.assertIn(f"PATH=$PATH:{self.PROFIL}", commande_sudoers(["A"]))
def test_a_failure_is_announced(self):
"""Muet, l'échec se paie plus tard sur un « self-signed certificate »
que rien ne relie à ce geste."""
self.assertIn("echo ", commande_sudoers(["A"]).split("||")[-1])
def test_the_message_carries_none_of_the_deadly_characters(self):
"""L'apostrophe referme la commande, et les trois autres font lire au
YAML autre chose qu'un scalaire simple."""
from script.qemu.deploy_qemu import t_sudoers_manque
message = t_sudoers_manque()
self.assertTrue(message)
for interdit in ("'", ": ", "{", "}", "[", "]"):
with self.subTest(interdit=interdit):
self.assertNotIn(interdit, message)
if __name__ == "__main__":
unittest.main()

View file

@ -223,12 +223,20 @@ class LAutoriteDuCacheDansLeSeedAssemble(unittest.TestCase):
doc["runcmd"],
)
def test_a_system_with_no_anchor_gets_nothing_and_keeps_the_rest(self):
"""Un système déclaratif n'a pas d'ancre où écrire. Il ne reçoit donc
pas l'autorité — et le reste du document lui parvient quand même."""
def test_a_declarative_system_gets_it_where_it_can_be_written(self):
"""Un système déclaratif n'a pas d'ancre PAR FICHIER, mais il reçoit
l'autorité quand même : sous un chemin inscriptible, /etc étant
généré depuis le store et monté en lecture seule.
Il l'a longtemps reçue nulle part, et c'est ce qui lui fermait le
hors ligne — le magasin est alors la seule source, et une VM
soustraite n'a plus rien."""
doc = yaml.safe_load(_cloud_config("nixos", self.ca))
chemins = [f["path"] for f in doc.get("write_files", [])]
self.assertFalse([c for c in chemins if DQ.CACHE_CERT_NAME in c])
autorite = [c for c in chemins if DQ.CACHE_CERT_NAME in c]
self.assertEqual(1, len(autorite), chemins)
self.assertFalse(autorite[0].startswith("/etc/"))
# Et le reste du document lui parvient toujours.
self.assertIn("users", doc)
self.assertEqual("ca", doc["keyboard"]["layout"])