Blog ·
Un null passé à travers le tamis
Chaque conteneur réel annonçait que sa couverture de sauvegarde restait indéterminée. La config se lisait bien. Le fil disait null, le schéma disait absent.
Par Provibr
Nous construisions l’onglet matériel des conteneurs quand une vérification navigateur a produit une phrase qu’elle n’aurait jamais dû produire : la configuration de ce conteneur n’a pas pu être lue, donc ce que contient une sauvegarde n’a pas été établi.
La configuration avait bien été lue. Elle se trouvait dans la réponse, complète, avec tous ses points de montage. Ce qui avait échoué, c’était notre propre lecture, une couche plus loin, et le message accusait le mauvais côté du fil.
Remonter la piste a pris du temps, parce qu’il n’y avait rien à remonter. Aucune exception, aucune ligne de journal, aucun test en échec. Juste une phrase calme sur un écran, disant qu’une chose n’avait pas été mesurée.
Une clé absente et un null ne disent pas la même chose
L’agent construit les lignes de points de montage en Rust avec serde_json::json!, et l’un des champs est un Option<String> : le chemin à l’intérieur du conteneur, qui vient du mp=/data de la configuration.
Un Option qui vaut None s’y sérialise en null. Pas en clé absente. C’est le comportement correct et documenté, et c’est ce que tout lecteur de cette macro attendrait s’il y réfléchissait une seconde, ce que personne n’avait fait.
De l’autre côté, le schéma disait .optional(), et dans zod un optional nu signifie que la clé peut être absente. Il ne signifie pas que la valeur peut être null. Ce sont deux affirmations différentes au sujet du JSON, et nous avions écrit l’une en envoyant l’autre.
Le champ qui a fait partir cela sur absolument tous les conteneurs est celui qui est structurellement toujours vide. Toute configuration de conteneur comporte une ligne rootfs, et un rootfs n’a par définition aucun mp=, puisqu’il est monté à la racine. La toute première ligne de chaque observation venue de chaque conteneur réel portait donc "mountPath": null.
Un seul champ fautif et il n’y a plus aucune donnée
Si la défaillance s’était limitée à ce champ, cela aurait été un défaut cosmétique. Elle ne l’était pas, et la raison tient à la forme de la lecture plutôt qu’à la forme du bogue.
Nous validons l’observation entière comme un seul objet. Un safeParse sur un objet échoue en bloc ; il n’y a pas de résultat partiel. Un seul champ refusé signifiait donc aucune liste de montages, aucune classification de couverture et aucun aperçu de ce qu’une restauration remplacerait. Tout ce qui se trouve en aval ne voyait rien, et tout ce qui se trouve en aval est écrit pour être honnête au sujet du rien.
Cette honnêteté est délibérée, et c’est la raison pour laquelle le produit ne vous dit pas qu’une sauvegarde est complète alors qu’il n’a rien regardé. Une configuration de conteneur dont le rootfs n’est pas lisible est signalée comme inconnue plutôt que complète, précisément pour qu’une mesure vide ne puisse jamais être présentée comme une bonne mesure.
Ici, le garde-fou a fonctionné exactement comme prévu et a produit une phrase qui était, dans ses propres termes, vraie : rien n’avait été établi. Le mensonge se trouvait un niveau plus haut, dans l’affirmation sur le pourquoi.
Rien n’était rouge, et la raison met mal à l’aise
Cela est parti avec la ronde qui a introduit la couverture de sauvegarde des conteneurs, et les données de test de trois rondes sont passées sur le même code sans que rien ne devienne rouge.
La raison, c’est que ces données ont été écrites à la main. Pour exercer le tableau de couverture sans un vrai Proxmox, on écrit une ligne qui ressemble à ce que l’agent envoie, et ce que l’on écrit quand on veut dire celle-ci n’a pas de chemin, c’est omettre la clé. Ce qui se lit sans erreur. Trois rondes de données de test ont donc encodé l’hypothèse du schéma plutôt que le comportement de l’agent, et toutes étaient vertes.
Aucun hyperviseur réel n’est dans la boucle pour corriger cela. Le Proxmox auquel nous avons accès est la machine de production de quelqu’un, et aucun de nos agents n’y va. La seule description du fil contre laquelle les deux côtés auraient pu être vérifiés était donc la source Rust, et rien ne la lisait.
Le garde-fou lit le producteur au lieu de le recopier
La réparation tient en un champ. La partie intéressante, c’est le test qui l’accompagne.
Il ouvre lxc.rs, trouve la fonction qui construit les lignes, extrait les noms de clés du bloc json!, puis affirme deux choses. D’abord, que l’ensemble des clés envoyées par l’agent est exactement l’ensemble des clés connues du schéma, de sorte qu’aucun des deux côtés ne peut gagner un champ tout seul. Ensuite, et c’est celle qui compte, qu’une ligne dont tous les champs optionnels valent null passe à travers le tamis.
Ce second test construit sa ligne à partir des clés qu’il vient de lire, et non d’une liste écrite dans le fichier de test. Un nouveau champ optionnel ajouté avec un .optional() nu est rouge le jour même où il est ajouté, sans que personne ait à se souvenir de cet article.
Il y a un troisième test pour l’autre sens : une ligne venue d’un agent plus ancien, qui ne porte pas du tout les champs récents, se lit toujours. Les deux formes sont réelles, elles sont toutes les deux en base, et un correctif qui n’aurait accepté que la nouvelle aurait cassé les anciennes lignes tout en ayant l’air correct.
Le correctif lui-même est un preprocess qui envoie null sur undefined, plutôt qu’un .nullable(). C’est un choix délibéré au sujet du type dérivé : avec nullable, le type devient string | null | undefined et chaque lecteur gagne un troisième état qui ne lui dit rien. Avec preprocess, le type reste ce qu’il était, les quatre lecteurs gardent le code qu’ils avaient déjà, et la tolérance sur le fil s’exprime à exactement un endroit.
Deux erreurs dans la mesure, pas dans le produit
Établir que la réparation fonctionnait a produit deux faux verts de son cru, et c’est la moitié la plus transposable de cette ronde.
Le premier : un schéma écrit avec .strict() a refusé en silence nos propres données de test, et l’écran est devenu vide plutôt que faux. Un écran vide pendant une réparation ressemble exactement à un écran qui n’a pas fini de charger.
Le second est pire, et c’est un piège connu chez nous dans lequel nous sommes tombés quand même. Le tableau des sauvegardes vit dans un panneau qui est toujours monté dans le document, parce que c’est ainsi que le retour de focus d’une boîte de dialogue est rendu possible. Une vérification qui lit ce tableau avec un sélecteur le lit donc, qu’il soit ouvert ou non. Notre affirmation selon laquelle deux onglets disaient la même chose passait sur deux tableaux tous les deux vides et tous les deux invisibles.
Les deux ont été fermés de la même façon : exiger que la boîte de dialogue soit réellement ouverte, et poser une borne inférieure sur le nombre de lignes avant de les comparer. Une comparaison de rien avec rien n’est pas une comparaison.
Ce que nous noterions
- Une clé absente et un
nullsont deux affirmations différentes, et les sérialiseurs JSON choisissent à votre place. - Un schéma qui n’accepte qu’une clé manquante n’a jamais lu son producteur.
- La validation d’un objet entier transforme un champ fautif en absence totale de données, ce qui est la bonne conception et une mauvaise défaillance à déboguer.
- Le message d’erreur le plus dangereux est un message calme. Le nôtre était honnête sur le résultat et faux sur la cause.
- Des données de test écrites à la main encodent vos hypothèses, y compris celle sur laquelle vous êtes sur le point de vous tromper.
- Laissez le garde-fou lire le producteur. Une liste de clés recopiée dans un test dérive ; une liste de clés extraite de la source ne le peut pas.
- Testez explicitement la ligne entièrement à null, parce que c’est la ligne que votre vrai producteur envoie en premier.
- Acceptez aussi l’ancienne forme. Des lignes écrites par des agents plus anciens sont encore en base.
- Tolérez sur le fil, pas dans le type. Élargissez à la frontière et laissez les lecteurs tranquilles.
- Un panneau toujours monté rend une vérification du DOM verte et vide de sens. Affirmez qu’il est ouvert et qu’il a des lignes.
Pour être clair : le défaut portait sur la couverture de tous les conteneurs réels depuis la ronde qui a livré les sauvegardes de conteneurs, trouvé par une vérification navigateur et non par une suite, réparé avec un seul preprocess et gardé par un test qui lit la source Rust ; la ronde où il a été trouvé a fait passer le module de tests des conteneurs de trente-cinq tests à trente-neuf, avec musl et le formatage sans reproche, et s’est terminée avec les paquets partagés à 5347 verts, la passerelle à 1467, les outils bouchons à 489 et la suite web à 4593, aucun échec nulle part, et cinquante vérifications navigateur vertes dans deux langues sans aucune constatation d’accessibilité. Non prouvé : rien de tout cela n’a tourné contre un vrai Proxmox, la forme du fil est donc établie d’après la source plutôt que d’après une réponse réelle, et la chaîne en fonctionnement a été exercée contre notre propre bouchon.
Issu du travail de plateforme sur Provibr. Ce à quoi sert le tableau de couverture est dans Ce qu’une sauvegarde ne contient pas, la ronde qui a lu pour la première fois une configuration de conteneur est dans Réinstaller un conteneur, c’est le remplacer, et une autre vérification qui était verte parce qu’elle n’arrivait jamais est dans Vert parce qu’il ne pouvait pas y parvenir.