Blog ·
Un null que se coló por el tamiz
Todo contenedor real informaba de que su cobertura de copia no se podía establecer. La configuración se leía bien. El cable decía null y el esquema, ausente.
Por Provibr
Estábamos construyendo la pestaña de hardware para contenedores cuando una comprobación de navegador produjo una frase que nunca debería haber producido: la configuración de este contenedor no se ha podido leer, así que lo que contiene una copia de seguridad no ha quedado establecido.
La configuración sí se había leído. Estaba en la respuesta, completa, con todos los puntos de montaje dentro. Lo que había fallado era nuestro propio análisis de ella, una capa más adelante, y el mensaje culpaba al lado equivocado del cable.
Reconstruir el camino hacia atrás llevó su tiempo, porque no había nada desde donde reconstruirlo. Ninguna excepción, ninguna línea de registro, ninguna prueba en rojo. Solo una frase serena en una pantalla diciendo que algo no se había medido.
Una clave ausente y un null no son lo mismo
El agente construye las filas de puntos de montaje en Rust con serde_json::json!, y uno de los campos es un Option<String>: la ruta dentro del contenedor, que viene de mp=/data en la configuración.
Un Option que vale None se serializa ahí como null. No como una clave ausente. Ese es el comportamiento correcto y documentado, y es lo que esperaría cualquiera que leyese esa macro si se parase a pensarlo un segundo, cosa que nadie había hecho.
Al otro lado, el esquema decía .optional(), y en zod un opcional a secas significa que la clave puede estar ausente. No significa que el valor pueda ser null. Son dos afirmaciones distintas sobre JSON, y habíamos escrito una mientras enviábamos la otra.
El campo que hacía saltar esto en todos y cada uno de los contenedores es el que por estructura está siempre vacío. Toda configuración de contenedor tiene una línea rootfs, y un rootfs por definición no tiene mp=, porque está montado en la raíz. Así que la primerísima fila de cada observación de cada contenedor real llevaba "mountPath": null.
Un solo campo malo significa ningún dato en absoluto
Si el fallo se hubiera quedado en ese campo habría sido un defecto cosmético. No lo fue, y la razón está en la forma del análisis y no en la forma del error.
Validamos la observación entera como un solo objeto. Un safeParse sobre un objeto falla como un todo; no hay resultado parcial. Así que un campo rechazado significaba ninguna lista de montajes, ninguna clasificación de cobertura y ninguna vista previa de lo que una restauración reemplazaría. Todo lo que viene después no veía nada, y todo lo que viene después está escrito para ser honesto sobre la nada.
Esa honestidad es deliberada y es la razón por la que el producto no dice que una copia está completa cuando no ha mirado. Una configuración de contenedor sin un rootfs legible se informa como desconocida y no como completa, precisamente para que una medición vacía no pueda disfrazarse nunca de medición buena.
Aquí la guarda funcionó exactamente como estaba diseñada y produjo una frase que, en sus propios términos, era cierta: nada había quedado establecido. La mentira estaba un nivel más arriba, en la afirmación sobre el porqué.
Nada estaba en rojo, y la razón es incómoda
Esto se publicó con la ronda que introdujo la cobertura de copia para contenedores, y los datos de siembra de tres rondas pasaron por el mismo código sin que nada se pusiera en rojo.
La razón es que las siembras estaban escritas a mano. Para ejercitar la tabla de cobertura sin un Proxmox real se escribe una fila que se parece a lo que envía el agente, y lo que uno escribe cuando quiere decir este no tiene ruta es dejar la clave fuera. Cosa que se analiza sin problema. Tres rondas de datos de prueba codificaron por tanto la suposición del esquema y no el comportamiento del agente, y todas ellas estaban en verde.
No hay ningún hipervisor real en el circuito que corrija eso. El Proxmox al que tenemos acceso es la máquina de producción de alguien, y ningún agente nuestro entra ahí. Así que la única descripción del cable contra la que se podían haber contrastado los dos lados era el código fuente en Rust, y nadie lo estaba leyendo.
La guarda lee al productor en lugar de copiarlo
La reparación es un campo. La parte interesante es la prueba que está a su lado.
Abre lxc.rs, encuentra la función que construye las filas, extrae los nombres de las claves del bloque json! y después afirma dos cosas. Primero, que el conjunto de claves que envía el agente es exactamente el conjunto de claves que el esquema conoce, de modo que ninguno de los dos lados puede crecer un campo por su cuenta. Segundo, y esta es la que importa, que una fila en la que todos los campos opcionales valen null pasa por el tamiz.
Esa segunda prueba construye su fila a partir de las claves que acaba de leer, y no de una lista escrita en el fichero de prueba. Un campo opcional nuevo añadido con un .optional() a secas se pone en rojo el día en que se añade, sin que nadie tenga que acordarse de este artículo.
Hay una tercera prueba para la dirección contraria: una fila de un agente más antiguo, que no lleva en absoluto los campos más nuevos, se sigue analizando. Las dos formas son reales, las dos están en la base de datos, y una corrección que solo aceptara la nueva habría roto las filas antiguas con toda la apariencia de ser correcta.
La corrección en sí es un preprocess que convierte null en undefined, en lugar de un .nullable(). Es una elección deliberada sobre el tipo derivado: con nullable el tipo pasa a ser string | null | undefined y todo lector gana un tercer estado que no significa nada para él. Con preprocess el tipo se queda como estaba, los cuatro lectores conservan el código que ya tenían, y la tolerancia del cable queda expresada en un único sitio.
Dos errores en la medición, no en el producto
Establecer que la reparación funcionaba produjo dos verdes falsos propios, y son la mitad más aprovechable de esta ronda.
El primero: un esquema escrito con .strict() rechazó en silencio nuestra propia siembra, y la pantalla se quedó vacía en lugar de equivocada. Una pantalla vacía durante una reparación se parece exactamente a una pantalla que todavía no ha cargado.
El segundo es peor, y es una trampa conocida aquí en la que caímos igualmente. La tabla de copias vive en un panel que está siempre montado en el documento, porque así es como se consigue que funcione la devolución del foco de un diálogo. De modo que una comprobación que lee la tabla con un selector la lee esté abierta o no. Nuestra afirmación de que dos pestañas decían lo mismo se cumplía sobre dos tablas que estaban las dos vacías y las dos invisibles.
Las dos se cerraron de la misma manera: exigir que el diálogo esté realmente abierto y poner una cota inferior al número de filas antes de compararlas. Una comparación de nada con nada no es una comparación.
Lo que dejaríamos por escrito
- Una clave ausente y un
nullson afirmaciones distintas, y los serializadores de JSON eligen por uno. - Un esquema que solo acepta una clave ausente no ha leído nunca a su productor.
- Validar el objeto entero convierte un campo malo en ningún dato, que es el diseño correcto y un fallo malo de depurar.
- El mensaje de error más peligroso es el sereno. El nuestro era honesto sobre el resultado y erróneo sobre la causa.
- Los datos de prueba escritos a mano codifican las suposiciones propias, incluida aquella en la que se está a punto de errar.
- Deje que la guarda lea al productor. Una lista de claves copiada dentro de una prueba se desvía; una lista de claves extraída de la fuente no puede.
- Pruebe de forma explícita la fila con todo a null, porque esa es la fila que el productor real envía primero.
- Acepte también la forma antigua. Las filas escritas por agentes más antiguos siguen en la base de datos.
- Tolere en el cable, no en el tipo. Ensanche en la frontera y deje en paz a los lectores.
- Un panel siempre montado deja una comprobación del DOM verde sobre el vacío. Afirme que está abierto y que tiene filas.
Para que quede claro qué es esto: el defecto estaba en la cobertura de todos los contenedores reales desde la ronda que publicó las copias de contenedor, encontrado por una comprobación de navegador y no por ningún conjunto de pruebas, reparado con un solo preprocess y protegido por una prueba que lee el código fuente en Rust; la ronda en la que se encontró llevó el módulo de pruebas de contenedores de treinta y cinco pruebas a treinta y nueve, con musl y el formato limpios, y terminó con los paquetes compartidos en 5347 en verde, la pasarela en 1467, las herramientas del agente simulado en 489 y el conjunto de pruebas web en 4593, sin fallos en ninguna parte, y cincuenta comprobaciones de navegador en verde en dos idiomas sin hallazgos de accesibilidad. Sin demostrar: nada de esto se ejecutó contra un Proxmox real, así que la forma del cable queda establecida a partir de la fuente y no de una respuesta viva, y la cadena en funcionamiento se ejercitó contra nuestro propio simulador.
Del trabajo de plataforma en Provibr. Para qué sirve la tabla de cobertura está en lo que una copia de seguridad no contiene, la ronda que leyó por primera vez la configuración de un contenedor está en reinstalar un contenedor es reemplazarlo, y otra comprobación que estaba en verde porque nunca llegó a su destino está en en verde porque no podía llegar hasta allí.