Успех без свидетеля
Выкатывал узел на тестовое окружение. Скрипт отработал и закончил бодро:
== probe https://…/health
/v1/client-error -> 401: the new build is live
dev is on sha-eeca165.
Пошёл проверять руками — узел на старом образе. Ни одного признака этого в выводе.
Дальше — три причины, и они складывались одна на другую, как в учебнике.
Причина первая: проба, отличавшая не то
Проба спрашивала, есть ли на узле маршрут /v1/client-error. Логика в комментарии рядом честная: «этот маршрут есть только в выкатываемой сборке, значит 404 — старый образ ещё жив».
Это было верно в день, когда писалось. Потом маршрут доехал до предыдущего образа, и с тех пор проба отличала новую сборку от какой-то древней — то есть ни от чего.
Мне нравится этот класс ошибок: код не менялся, комментарий не врал, а утверждение стало ложным само, потому что мир под ним сдвинулся. Тест на «новизну по признаку» живёт ровно до момента, когда признак перестаёт быть новым.
Причина вторая: инструмент, глотающий ошибку по проекту
Внутри скрипт зовёт мастер раскатки, который ходит по машинам пула. В нём:
except Exception as e: # one box's failure must not abort the run
print(f" · {b['id']}: ERROR {e}")
Намерение правильное. Пул есть пул: одна упавшая машина не повод бросать остальные. Но дальше процесс завершался нулём — и вызывающая сторона узнавала об ошибке ровно ничего.
Получилось разделение труда наоборот: мастер знает про ошибку и молчит наружу, скрипт наружу говорит и про ошибку не знает.
Чинится это не отменой замысла, а его достройкой: неудачи считаются, остальные машины обрабатываются, а в конце процесс выходит с ненулевым кодом. Пять строк.
Причина третья: я сам
Между этими двумя причинами лежала третья, моя. Когда скрипт написал «live», я это прочитал — и пошёл проверять руками только потому, что за пять минут до того правил ровно ту ручку, чьё поведение хотел увидеть. Не будь у меня личного интереса к этой ручке, запись «дев на новой сборке» осталась бы в голове фактом.
Вот против этого и нужен свидетель, который не я.
Чем это заменилось
Требование к пробе сформулировалось само: она должна спрашивать у системы то, что знает только она сама, и чего не знает спрашивающий.
Первым делом захотелось зашить коммит в образ. Отверг: сборка переиспользует образ по содержимому — одинаковые байты не пересобираются, а получают ещё один тег. Зашей туда коммит, и каждый коммит начнёт давать новые байты; я бы чинил проверку, ломая экономию сборки.
Взял другое: тег, с которым запущен контейнер. Он приезжает в окружение процесса, процесс отдаёт его в /health, скрипт сравнивает с тем, что катит. Ключевое свойство — контейнер держит окружение с момента старта. Раскатка, упавшая до пересоздания контейнера, оставляет старый тег, и проба видит именно это:
FAIL: на узле sha-0000000, а не sha-eeca165 — раскатка не состоялась
Проверил тремя случаями: живой узел на старом образе (поля нет вовсе), подставной узел с чужим тегом, подставной с верным. Красное, красное, зелёное.
А починку мастера пришлось проверять отдельно и не сразу: первый эксперимент вышел пустым. Я снял счёт неудач, запустил скрипт целиком — он всё равно упал, и я чуть не записал это в успех. Упал он на новой пробе, а не на мастере: две починки закрывают один и тот же случай, и через скрипт их не различить. Пришлось звать мастер напрямую: без счёта неудач — код 0 на упавшей машине, с ним — 1.
Что унести к себе
Проба на «новизну по признаку» протухает молча. «Этого маршрута/поля/ключа в старой версии не было» верно один раз. Спрашивайте не признак, а идентичность: пусть система сама назовёт, кто она, и сравнивайте с ожидаемым.
Глотать ошибку и возвращать ноль — разные решения. Первое часто правильно, второе почти никогда. Если ваш инструмент по замыслу продолжает после сбоя, он тем более обязан сказать об этом кодом возврата — именно потому, что вывод никто не читает.
Проверяя починку, проверяйте её в одиночку. Две правки, закрывающие один случай, дают зелёное по любой из них, и вы не узнаете, какая работает. Это ровно то же, обо что я споткнулся неделю назад с кодом возврата, только этажом выше.