Блог ·
Null, що прослизнув крізь сито
Кожен справжній контейнер повідомляв, що покриття його копії встановити не вдалося. Конфігурацію прочитано добре. Дріт казав null, а схема казала «відсутній».
Автор: Provibr
Ми будували вкладку обладнання для контейнерів, коли браузерна перевірка видала речення, якого вона видавати не мала: конфігурацію цього контейнера прочитати не вдалося, тож те, що містить резервна копія, не встановлено.
Конфігурацію було прочитано. Вона лежала у відповіді, повна, з кожною точкою монтування всередині. Не вдався наш власний розбір її, шаром далі, а повідомлення звинуватило не той бік дроту.
Іти звідти назад тривало довгенько, бо йти назад не було від чого. Ані винятку, ані рядка в журналі, ані тесту, що падає. Лише спокійне речення на екрані, яке казало, що річ не виміряно.
Відсутній ключ і null не є тим самим
Агент будує рядки точок монтування мовою Rust за допомогою serde_json::json!, і одним із полів є Option<String>: шлях усередині контейнера, що походить з mp=/data у конфігурації.
Option, який є None, серіалізується там як null. Не як відсутній ключ. Це є правильною й задокументованою поведінкою, і саме її чекав би будь-який читач того макроса, якби подумав про це бодай секунду, чого ніхто не зробив.
З іншого боку схема казала .optional(), а в zod голий optional означає, що ключ може бути відсутнім. Він не означає, що значення може бути null. Це є двома різними твердженнями про JSON, і ми написали одне, а надсилали інше.
Полем, через яке це спрацьовувало на кожному без винятку контейнері, є те, яке структурно завжди порожнє. У кожній конфігурації контейнера є рядок rootfs, а корінь за визначенням не має mp=, бо його змонтовано в корені. Тож найперший рядок кожного спостереження з кожного справжнього контейнера ніс "mountPath": null.
Одне погане поле означає жодних даних
Якби відмова обмежилася тим полем, вона була б косметичним дефектом. Вона ним не була, і причина криється у формі розбору, а не у формі помилки.
Ми перевіряємо ціле спостереження як один обʼєкт. safeParse на обʼєкті падає цілком; часткового результату не буває. Тож одне відхилене поле означало ані переліку монтувань, ані класифікації покриття, ані попереднього перегляду того, що замінило б відновлення. Усе, що стоїть нижче за течією, побачило ніщо, а все, що стоїть нижче за течією, написано так, щоб бути чесним щодо ніщо.
Ця чесність є навмисною, і саме через неї виріб не каже вам, що копія є повною, коли він не дивився. Конфігурацію контейнера без читної кореневої файлової системи подають як невідому, а не як повну, саме для того, щоб порожнє вимірювання ніколи не можна було вбрати в добре.
Тут сторожа спрацювала рівно так, як задумано, і видала речення, яке у власних термінах було правдивим: нічого не встановлено. Неправда була шаром вище, у твердженні про те, чому.
Ніщо не було червоним, і причина є незручною
Це поїхало разом із раундом, що ввів покриття копій для контейнерів, і посівні дані трьох раундів пройшли тим самим кодом, а ніщо не почервоніло.
Причина в тому, що посіви писали руками. Щоб випробувати таблицю покриття без справжнього Proxmox, ви пишете рядок, схожий на те, що надсилає агент, а те, що ви пишете, коли маєте на увазі у цього шляху немає, є пропуском ключа. Який розбирається. Тож тестові дані трьох раундів закодували припущення схеми, а не поведінку агента, і кожні з них були зеленими.
У цьому колі немає справжнього гіпервізора, який це виправив би. Proxmox, до якого ми маємо доступ, є чиєюсь виробничою машиною, і жоден наш агент на неї не потрапляє. Тож єдиним описом дроту, проти якого можна було б звірити обидва боки, було джерело мовою Rust, і його ніщо не читало.
Сторожа читає виробника, а не копіює його
Виправлення є одним полем. Цікавою частиною є тест поруч із ним.
Він відкриває lxc.rs, знаходить функцію, що будує рядки, дістає імена ключів із блоку json!, а тоді стверджує дві речі. Перше, що набір ключів, які надсилає агент, є рівно набором ключів, про які знає схема, тож жоден бік не може відростити поле поодинці. Друге, і саме воно має значення, що рядок, у якому кожне необовʼязкове поле є null, проходить крізь сито.
Той другий тест будує свій рядок із ключів, які щойно прочитав, а не зі списку, записаного у файлі тесту. Нове необовʼязкове поле, додане з голим .optional(), є червоним того дня, коли його додали, і нікому не треба памʼятати цю статтю.
Є третій тест на інший бік: рядок від старішого агента, який новіших полів не несе зовсім, усе одно розбирається. Обидві форми є справжніми, обидві лежать у базі даних, і виправлення, яке приймало б лише нову, зламало б старі рядки, маючи при цьому правильний вигляд.
Самим виправленням є preprocess, що відображає null на undefined, а не .nullable(). Це є навмисним вибором щодо похідного типу: із nullable тип стає string | null | undefined, і кожен читач здобуває третій стан, який для нього нічого не означає. Із preprocess тип лишається таким, яким був, четверо читачів тримають код, який уже мали, а терпимість дроту виражена рівно в одному місці.
Дві помилки у вимірюванні, а не у виробі
Встановлення того, що виправлення спрацювало, породило два власні хибні зелені, і саме вони є тією половиною цього раунду, яку можна перенести кудись іще.
Перше: схема, написана зі .strict(), мовчки відхилила наш власний посів, і екран став порожнім, а не хибним. Порожній екран під час ремонту виглядає рівно так само, як екран, що ще не завантажився.
Друге є гіршим, і воно є відомою тут пасткою, у яку ми однаково вступили. Таблиця копій живе в панелі, яку завжди змонтовано в документі, бо саме так змушують працювати повернення фокуса діалогом. Тож перевірка, що читає таблицю селектором запиту, читає її незалежно від того, відкрита вона чи ні. Наше твердження, що дві вкладки кажуть те саме, проходило на двох таблицях, обидві з яких були порожніми й обидві невидимими.
Обидва закрили однаково: вимагати, щоб діалог був справді відкритим, і покласти нижню межу на кількість рядків, перш ніж їх порівнювати. Порівняння ніщо з ніщо не є порівнянням.
Що ми б записали
- Відсутній ключ і
nullє різними твердженнями, а серіалізатори JSON обирають за вас. - Схема, що приймає лише відсутній ключ, ніколи не читала свого виробника.
- Перевірка цілого обʼєкта перетворює одне погане поле на жодних даних, що є правильним задумом і поганою відмовою для налагодження.
- Найнебезпечнішим повідомленням про помилку є спокійне. Наше було чесним щодо наслідку і хибним щодо причини.
- Тестові дані, написані руками, кодують ваші припущення, зокрема й те, у якому ви от-от помилитеся.
- Нехай сторожа читає виробника. Список ключів, скопійований у тест, дрейфує; список ключів, розібраний із джерела, не може.
- Випробовуйте рядок, де все є null, окремо, бо саме цей рядок ваш справжній виробник надсилає першим.
- Приймайте й стару форму. Рядки, написані старішими агентами, досі лежать у базі даних.
- Терпіть на дроті, а не в типі. Розширюйте на межі й не чіпайте читачів.
- Завжди змонтована панель робить перевірку DOM порожньо зеленою. Стверджуйте, що вона відкрита і що в ній є рядки.
Для ясності про те, чим це є: дефект був у покритті кожного справжнього контейнера від того раунду, який випустив копії контейнерів, знайдений браузерною перевіркою, а не якимось набором тестів, полагоджений одним preprocess і захищений тестом, що читає джерело мовою Rust; раунд, у якому його знайшли, посунув тестовий модуль контейнерів із тридцяти пʼяти тестів до тридцяти девʼяти, з чистими musl і форматуванням, і завершився спільними пакунками зеленими на 5347, шлюзом на 1467, інструментами-заглушками на 489 і набором вебу на 4593, без жодної невдачі будь-де, і пʼятдесятьма браузерними перевірками зеленими двома мовами без знахідок щодо доступності. Не доведено: ніщо з цього не запускали проти справжнього Proxmox, тож форму дроту встановлено з джерела, а не з живої відповіді, а ланцюг у дії випробувано проти нашої власної заглушки.
Із платформної роботи над Provibr. Для чого існує таблиця покриття, описано в Чого резервна копія не містить, раунд, що вперше прочитав конфігурацію контейнера, описано в Перевстановлення контейнера є заміною, а ще одна перевірка, яка була зеленою через те, що ніколи не доходила, описана в Зелений, бо не міг туди дістатися.