Как мы писали Kubernetes-клиент, который старается не врать

С чего всё началось, и почему мне до сих пор немного неловко
Я устал от Lens. Не от какой-то одной его черты — просто от накопившегося раздражения, помноженного на то, что в какой-то момент за него начали просить деньги.
Дальше был обычный круг по альтернативам. k9s прекрасен, но это TUI: он живёт в терминале и разговаривает горячими клавишами, а мне хотелось окно, куда можно ткнуть мышью и показать коллеге через демонстрацию экрана. Headlamp — ближайший по духу и честно бесплатный, но на тот момент он показался мне слишком молодым и слишком «про расширения»: базовые вещи приходилось достраивать плагинами. Octant к тому времени уже был архивирован. Остальное — либо веб-морды, которые надо где-то поднимать и куда-то пускать, либо kubectl с алиасами, что в общем-то и есть исходная точка.
Ни один из них не врал мне меньше, чем Lens. А это, как выяснилось позже, и было настоящей претензией — просто я тогда ещё не умел её сформулировать.
А теперь честная часть, ради которой стоит дочитать абзац. Разозлившись на платный клиент, я сел писать свой платный клиент. Закрытый репозиторий, код лицензирования, приём платежей, версии от 1.0.0 до 1.7.12 — двадцать пять тегов, которые видел только я.
В апреле я это закрыл. Схлопнул историю в один коммит, выпилил весь платёжный код, снёс все двадцать пять тегов и выложил под MIT. Не потому, что прозрел — просто пользователей было ноль, а поддерживать биллинг ради нуля пользователей глупо. Ирония дошла до меня чуть позже, чем следовало бы.
С тех пор вышло семь релизов, а на середине пути ко мне присоединился друг — и переписал столько, что об этом стоит рассказать отдельно.
Правило, из которого выросло всё остальное
Формулировка простая: приложение не должно утверждать того, чего не может подтвердить.
Звучит как лозунг, но из него следуют совершенно конкретные баги — те, которые есть почти во всех клиентах, включая тот, что был у нас самих.
Под Running может лежать мёртвый контейнер. .status.phase у пода — это Running, даже когда контейнер внутри крутится в CrashLoopBackOff. Клиент честно показывает поле — и честно врёт. Лечится тем, что статус надо не читать, а выводить: kubectl делает это в функции printPod, и мы её портировали в resources/types/pod_display.rs, вместе с подсчётом readiness ровно так, как считает kubectl get pod (сайдкары там считаются не так, как кажется).

Тот же под в списке — Init:CrashLoopBackOff, а не Running. Init-контейнеры доезжают до фронта последовательностью, а не множеством: видно, что migrate — второй из четырёх.
Service с исправным селектором может не отдавать трафик. Здоровые поды, живой селектор, зелёная строчка в списке — и ноль запросов, потому что в targetPort опечатка в имени порта. Пока readiness выводится логикой, это невидимо. Стоит начать читать EndpointSlices — то, что кластер реально публикует, — и расхождение вылезает само.

Цепочка не просто рисуется, а называет место обрыва. Внизу — та самая фраза: «which is why every list page in the app draws this as healthy».
Истёкший токен выглядит как пустой кластер. Это мой любимый и самый неприятный. Токены GKE живут около часа, никто их здесь не продлевает, а список, вернувшийся с 401, рисовал своё пустое состояние. То есть приложение с уверенным видом сообщало: подов у вас нет. Ни одного. На всех экранах сразу.
Это худший вид отказа. Ошибка выглядит как ошибка, а вот исправно работающий интерфейс, показывающий неправду, не выглядит никак. Теперь 401 — отдельная ошибка, а не отсутствующий результат: список читает ошибку своего запроса, и отказавшая сессия заменяет страницу экраном, который называет кластер, цитирует API-сервер и предлагает войти заново.
Логи открываются там, где ответ
Забегу вперёд: это уже не моя работа, а того самого друга, про которого ниже. Но пользуюсь я этим чаще всего остального, так что пусть стоит здесь, рядом с правилом, из которого оно выросло.
Если под застрял в Init:CrashLoopBackOff, открывать логи «текущего контейнера» бессмысленно: текущий — это тот, который ещё не стартовал, и он не написал ни строки.
Поэтому вьюер сам выбирает упавший init-контейнер, сам переключается на прошлый запуск и говорит, что сделал именно это.

«Opened on migrate alone — the pod is stuck in init, so nothing after it has written a line. These are the lines of the run that failed, not of the current one.» Четыре строки лога, и в них ответ: миграция упала на несуществующей таблице.
Разница между «показать логи» и «показать те логи, ради которых ты сюда пришёл» — примерно четыре клика и одно переключение контекста. Каждый раз.
Поллинг, который стоил 895 запросов в минуту
Первая версия жила на refetchInterval — 45 руками написанных интервалов по всему фронту. Из них ровно два проверяли, смотрит ли кто-нибудь на экран, которому они принадлежат.
Простаивающее приложение давало 895 запросов в минуту к API-серверу. Сейчас — 205, минус 77%.
Чинилось в два приёма. Сначала списки переехали с поллинга на kube::runtime::watcher — 16 страниц, все через один WatchManager. Потом появился хук useLiveQuery, который принимает не число, а «скорость»: refresh: "resourceList". Интервал он выводит сам — из того, видна ли поверхность, в фокусе ли окно, перестал ли меняться ответ и жив ли watch.
И главное — правило линтера, которое запрещает писать refetchInterval где-либо, кроме самого хука:
selector: "Property[key.name=/^refetchInterval(InBackground)?$/], ...",
message: "Do not set refetchInterval. Use useLiveQuery({ refresh: '<rate>' }) — ...",
Мысль тут не в экономии запросов, а в том, что число, написанное руками, не умеет ничего из перечисленного и не научится потом. Запрет на число — это способ сделать так, чтобы правильное поведение наследовалось само, а не держалось на памяти того, кто пишет следующий экран.
Заодно всплыло, что бэкенд слал по одному Tauri-событию на строку лога, и болтливый под в сотню строк в секунду ронял UI в заметный джанк. Появился флаш раз в 50 мс:
/// Flush interval. 50ms keeps perceived latency low (~one frame at
/// 60fps) while collapsing a chatty pod into a handful of events.
const FLUSH_INTERVAL: Duration = Duration::from_millis(50);
Запомните эту константу, она ещё выстрелит.
Гонка, которую я ловил трижды
Отдельная история, стоившая мне больше всего времени. У Tauri-событий нет реплея. Если бэкенд начал слать байты раньше, чем фронт успел зарезолвить listen(), эти байты просто исчезают.
Первый раз я поймал это на интерактивной аутентификации: модалка OIDC рендерилась пустой, потому что первый промпт улетал в пустоту. Второй раз — на логах. Третий — на watch-событиях.
Лечение одинаковое во всех трёх местах: бэкенд не начинает работу сразу, а блокируется на oneshot::Receiver, и фронт отпускает его отдельной командой *_subscribed — но только после того, как все listen() зарезолвились. Плюс страховочный таймаут на 60 секунд, если фронт не отзовётся.
Из той же серии — RAII-гарды на каждый долгоживущий спавн. Раньше запись в мапе состояния удалялась в конце функции, и любая паника оставляла зомби. Теперь Drop срабатывает на любом выходе, включая раскрутку паники.
Ещё одна оттуда же: пайпы вместо PTY. Инструменты вроде kubelogin зовут getpass/term.ReadPassword, а те проверяют isatty(stdin) и через пайп промпт не печатают вообще. Никакой ошибки — просто тишина. Пришлось переписать exec-адаптер на portable-pty с настоящей парой PTY. Тест на это гоняет sh -c 'tty' и проверяет, что в выводе /dev/ttys…, а не not a tty.
Потом пришёл друг и переписал половину
Где-то здесь в проект пришёл belliel — и дальнейшее лучше показать цифрами, потому что словами звучит неправдоподобно.
Один pull request: 231 коммит, +105 522 / −27 946. Все гейты зелёные на каждом из 231. Второй, через два дня, — ещё +9991.
Что он принёс: платформу интеграций (cert-manager, Traefik, ingress-nginx, Istio, Argo CD, Flux, Prometheus, Loki, k3s, minikube, Karpenter и CRD трёх облаков), карту маршрутизации, цепочку трафика, которая называет место обрыва, дизайн-систему на role-токенах с линт-гвардами против дрейфа, поиск по кластеру, мультинеймспейсный скоуп и то самое правило про «не утверждать» — оно его.
Интеграция стоит одной папки и одной строки, а линт-правило держит шов: снаружи src/integrations/ никто не имеет права назвать вендора по имени — только спросить фасет. Появилось это правило не из любви к архитектуре, а потому что cert-manager однажды приехал в проект дважды, в двух местах, — и ничто не отказало второму.

Страница цепочки выдачи. У каждого сертификата — не «ошибка», а конкретный объект, на котором всё встало, и предложение, объясняющее почему.
Дальше — про разделение труда, потому что оно вышло чётким, и я его посчитал через git blame по текущему дереву.
Бэкенд — 22 553 строки мои против 14 743 его. По подсистемам:
Подсистема | Я | Друг |
|---|---|---|
| 2750 | 36 |
| 1359 | 237 |
| 283 | 2 |
| 840 | 635 |
| 706 | 505 |
| 537 | 245 |
| 2526 | 4188 |
| 0 | 2079 |
| 0 | 1361 |
Фронтенд — наоборот: 93 653 строки его против моих 28 029.
Получилось так: я собрал машину, он собрал то, что видит пользователь. Аутентификация, терминал, watch, логи, метрики, состояние, мост событий — моё. Интеграции, маршрутизация, поиск, вывод правдивых статусов и три четверти интерфейса — его.
А теперь та константа, про которую я просил запомнить. Главный перф-выигрыш его последнего релиза звучит так: всплеск на 1000 объектов — это 5 событий и один рендер вместо 1000 и 1000. Достигнут он тем, что watch-события поехали через тот самый пятидесятимиллисекундный флаш из лог-стримера. Он не стал писать свой батчер — переиспользовал мой.
Мне это нравится больше, чем любая цифра в таблице выше. Хорошая инфраструктура — это когда следующий человек делает свою фичу быстрее, чем если бы писал с нуля, и даже не обязан спрашивать разрешения.
Ещё одна деталь про его подход, которую стоит украсть. В последнем PR есть раздел «нашлось во втором проходе»: он перечитывает собственную ветку враждебно, специально ища, что сломал. Так нашлись две худшие вещи в ветке — table-fixed резолвит width:100% как max(100%, сумма ширин), из-за чего список подов оказался на 378 px шире окна с уехавшими за экран кнопками; и слияние обзора рисовало каждую кластерную проблему по разу на каждый выбранный неймспейс. Обе починены до мержа.
Первый же релиз показал, что год сборок был не подписан
Дальше — история про то, как я выкатил релиз, скачал собственный dmg и получил от macOS:
«Rubick» is damaged and can’t be opened. You should move it to the Trash.
Файл был целый. Просто все сборки — начиная с самой первой публичной — несли только ad-hoc-подпись, которую ставит линкер:
Signature=adhoc
TeamIdentifier=not set
Sealed Resources=none
Тонкость, которую стоит знать заранее: на неподписанное приложение macOS ругается привычным «не удалось проверить разработчика» и даёт открыть через контекстное меню. А на ad-hoc-подписанное с карантином — говорит «damaged» и предлагает только корзину. Это одно и то же по сути, но выглядит как испорченная закачка, и пользователь её просто удалит.
Дальше начались открытия:
Developer ID нельзя выпустить через API. App Store Connect API умеет всё — кроме этого. На оба типа сертификата приходит Permission denied: This operation can only be performed by the Account Holder. Только браузер, только руками.
Нотаризация залипает. Не падает — именно висит. Из четырёх заявок за два дня две ушли в Accepted за минуты, а две остались в In Progress; одна из них висела пятнадцать часов при том, что статус-страница Apple рапортовала «инцидентов нет». Джоба на GitHub, которая её ждала, упёрлась в дефолтный шестичасовой потолок и умерла, сожгнув шесть часов раннера впустую.
Лечится не умом, а timeout-minutes: 90 — чтобы зависание становилось быстрым и понятным отказом.
Что до самой сборки — Apple к ней претензий не имела вообще:
"status": "Accepted",
"statusSummary": "Ready for distribution",
"issues": null
Отдельно советую проверять результат не глазами, а воспроизведением: я вешаю на скачанную копию атрибут карантина ровно так, как это делает браузер, и спрашиваю Gatekeeper.
xattr -w com.apple.quarantine "0081;$(printf %x $(date +%s));Safari;" Rubick.app
spctl -a -vvv Rubick.app
# → accepted, source=Notarized Developer ID
xcrun stapler validate Rubick.app
# → The validate action worked!
Степлер тут не формальность: без пристёпленного тикета на машине без интернета Gatekeeper всё равно ругнётся, потому что спросить Apple ему будет негде.
И последнее, что я оттуда вынес. Идентификатор бандла com.k8s-gui.app мы не трогали, хотя продукт по дороге переименовался в Rubick. Сменишь его — и каждая установленная копия отвалится от апдейтера, а рядом встанет вторая. Имя в меню поменять можно, идентификатор — нет.
Что мы намеренно не сделали
Раздел, который в проектах обычно опускают, а зря.
Оценку стоимости. Committed use, sustained use, спот, договорные цены — всё это делает число неверным чаще, чем верным. А неправильное число про деньги отравляет доверие ко всем остальным числам на экране.
Граф топологии всего кластера. Маршрутизация — это цепочка в фиксированном порядке, а не произвольный граф. Force-directed клубок выглядит как инсайт и не отвечает ни на один вопрос.
Редактирование маршрутов и перевыпуск сертификатов. Читать их хорошо — это одна фича, писать — совсем другая, с другим радиусом поражения. Лимит ACME — пять неудач в час, и кнопка, которая их сжигает, кладёт кластер в худший из моментов.
Облачный третий тир — потолки пулов, здоровье балансировщиков. Для этого нужен живой GKE/EKS/AKS, чтобы сверяться. Писать вслепую — значит показывать поля, которых мы ни разу не видели в ответе API.
Что в итоге

Стартовый экран отсортирован по тому, насколько всё плохо, а не по алфавиту. Внизу — события с причинами, а не со словом «Warning».
38 810 строк Rust и 121 807 TypeScript. 344 теста в cargo и 1577 в vitest. Семь релизов с 24 апреля, последний — 4.0.0.
Приложение бесплатное, без аккаунта и телеметрии. Собирается под macOS (обе архитектуры), Windows и Linux.
Про лицензию — решение свежее, буквально этих дней. Всё по 3.1.0 включительно выходило под MIT и остаётся под MIT навсегда: этот грант не отзывается. Начиная с 4.0.0 — GPLv3.
Причина, надеюсь, из статьи уже читается. Lens смог стать тем, что меня разозлило, ровно потому, что был разрешительно лицензирован: открытая база, проприетарный слой сверху, деньги. Оставлять ту же дверь открытой в проекте, который вырос из этого раздражения, оказалось глупо.
Форкать и продавать по-прежнему можно — только с исходниками. А запуск приложения распространением не является, так что поставить его себе, хоть на все машины в компании, не обязывает вообще ни к чему.
Код: https://github.com/Dudude-bit/rubick Скачать: https://github.com/Dudude-bit/rubick/releases/latest
Если у вас есть кластер и десять минут — поставьте и скажите, где оно врёт. Это единственный баг-репорт, который мы считаем срочным.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.