- Go 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
Some checks failed
CI / check (push) Has been cancelled
Removing a machine by tearing its Link down touches every layer: the Link, the Machine, the machine's own resources, the ClusterMachine and its holders, the last-chance wipe, the provider's deprovision and the etcd audit. Each was proved on its own; nothing proved that an operator who does what omnictl does ends with a working cluster and no machine left behind. The compat tests now drive it with omni/client's calls over the API: - a worker removed as `omnictl cluster machine delete --force` does is not reset, only wiped at the last, its node is deleted, the cluster stays ready, and its MachineSetNode, KernelArgs and MachineInstallDiskConfig go in Omni's order, read from what stays while a finalizer of the test holds each step; - a control plane that lost its power is removed with nothing called on it; the member it leaves in etcd is removed by the audit after its minute, and until then holds back the next removal instead of wedging it; with two control planes, etcd has lost its quorum, and the removal still goes through where Omni's quorum check would refuse, the node cleanup being refused by the cluster's API and letting the machine go all the same; - a cluster with a dead control plane stays deleting, and tearing that machine's Link down finishes the same deletion; the template deletion that destroys disconnected machines also ends when the other control planes keep the cluster's load balancer healthy; - deleting the MachineRequest of a cluster machine resets the machine first, and the Link is torn down and the provider asked to deprovision only after its ClusterMachine is gone. The harness gains what the tests need and no more: a machine that lost its power, whose dials reach a listener that never answers and whose peer stops shaking hands; a kube-apiserver that answers 503; machines created by a provider inside a cluster; a cluster deletion the test performs itself; and machines that do not report their stage once their Link is not running, as the event sink drops such events, which had made a snapshot the Link's teardown destroyed come back and held the Machine. The compat run is given thirty minutes, as it is longer than go test's ten. Refs #33 |
||
| .forgejo/workflows | ||
| client | ||
| cmd | ||
| docs | ||
| internal | ||
| test/compat | ||
| .gitignore | ||
| .golangci.yml | ||
| AGENTS.md | ||
| buf.gen.yaml | ||
| buf.yaml | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| Makefile | ||
| README.md | ||
Batuta
Система управления кластерами ALT Orchestra: одна точка управления множеством кластеров, обнаружение и учёт машин, декларативная конфигурация, жизненный цикл кластера от создания до удаления.
Реализованы сборка и CI, генерация protobuf, ядро состояния COSI
с маршрутизатором по namespace, два бэкенда хранения (встроенный etcd
по умолчанию, bolt как альтернатива для одного узла), сжатие и шифрование
ресурсов на диске, регистрация типов из siderolabs/omni/client, объявление
namespace'ов и ResourceDefinitions, чтение конфигурации сервера, gRPC API
поверх COSI-состояния, публикация адреса фабрики образов и её
PXE-фронтенда в FeaturesConfig, откуда их читает SDK провайдера,
сервис management.ManagementService, зарегистрированный и под именем
batuta.management.v1.ManagementService, — CreateSchematicFromRaw,
пересылающий схематик провайдера на фабрику образов, и
GetInstallationMediaURL, отвечающий Unimplemented, после чего URL
носителя строит фолбэк SDK по FeaturesConfig, — канал управления машинами batutalink — сервис
provision с допуском по join-токену и токену узла, устройство WireGuard,
получающее пиров из ресурсов Link, монитор, записывающий в них состояние
соединения, и приём событий и журнала ядра машин в отдельный файл SQLite,
— зеркало ресурсов Talos подключённых машин и выдача
им токенов узла, контроллерный рантайм с контроллерами BackendStatus,
MachineController, LinkStatusController, MachineStatusController,
NodeTokenStatusController и InfraMachineRegistrationController,
пользовательские типы кластера — Cluster, MachineSet,
MachineSetNode, ConfigPatch, MachineClass и
MachineInstallDiskConfig, а также NodeForceDestroyRequest, — писаные
через API и проверяемые по правилам Omni, и ExtensionsConfiguration
с KernelArgs, любая запись которых отклоняется (#119), распределение
машин набора из MachineClass по меткам (класс с auto_provision
отклоняется, #143),
каталог версий Talos из конфигурации (talosVersions),
вывод из кластера его UUID, версии конфигурации, секретов,
TalosConfig и служебного Kubeconfig, ClusterMachine из узлов набора,
выбор патчей конфигурации каждой машины кластера,
превращение запросов патчей провайдера в ConfigPatch (#81),
порт балансировщика каждого кластера (LoadBalancerConfig) и сам
балансировщик с его статусом (LoadBalancerStatus),
выбор установочного диска каждой машины (MachineInstallDiskStatus),
образ установки каждой машины (ClusterMachineTalosVersion,
MachineConfigGenOptions),
документы SideroLink каждой машины (MachineJoinConfig),
машинная конфигурация каждой машины кластера (ClusterMachineConfig),
её применение к машине по слотам обновления (ClusterMachineConfigStatus,
MachineSetConfigStatus), идентичность машины кластера
(ClusterMachineIdentity), бутстрап etcd кластера
(ClusterBootstrapStatus), сброс машины при её исключении из кластера
(выход control plane из etcd, Reset, подтверждение по maintenance-режиму),
последняя попытка стереть диск удаляемой машины,
удаление узла Kubernetes выведенной машины, очерёдность и пределы удаления
машин набора по delete_strategy с воротами балансировщика control plane,
проверка кворума etcd перед выходом control plane из кластера,
аудит членов etcd, которых не учитывает ни одна машина кластера
(EtcdAuditResult), состояние каждой машины кластера
(ClusterMachineStatus), адреса control plane (ClusterEndpoint) и то,
что сообщает Kubernetes API кластера (KubernetesStatus),
статусы набора и кластера, состояние etcd и control plane кластера
(ControlPlaneStatus),
ClusterTeardownController и MachineSetTeardownController,
разбирающие кластер и набор по частям в порядке Omni, и
internal/clusters.Destroyer, уничтожающий экземпляр такого типа, когда
тот уходит в удаление без владельца и финализаторов, и
batuta serve — собранный процесс, поднимающий всё перечисленное.
Архитектуры машины в MachineStatus (#75), меток
образа, засева меток машины из тегов платформы, схемы образа, собранного
не image factory, получения raw схемы из image factory для образа без
манифеста, initial_state и переименования расширений (#76),
остальных операций над машинами через Talos API — перезагрузки, сброса по
запросу, выключения, обновления (#32), учёта upgrade_strategy набора
(#144), удаления записи об узле в службе обнаружения (#154), непрерывного
аудита узлов (#155), установки Talos 1.13 и новее из maintenance-режима
(#132), обновления установленной машины (#133), прокси рабочих нагрузок
(ExposedService, #145), остальных RPC Management API (#34), аутентификации
(#35) и веб-интерфейсов (#36) нет.
Порядок работ — в трекере.
Свойства
Свойства архитектуры (раздел «Решения и основания» в документе архитектуры). У каждого указано, чем оно подтверждено, у неподтверждённых — задача трекера.
- Машины подключаются сами через исходящий WireGuard-туннель — узел за NAT не требует маршрутизируемого адреса и не сканируется. (реализовано: сервис provision и устройство WireGuard; подключение настоящей Talos-машины не проверено, #31)
- Идентификатор машины — UUID из SMBIOS, не IP-адрес:
LinkиMachineименуются UUID машины,LinkStatus— производно от него; адрес, с которого машина пришла, меняется при переподключении и ключом не служит. - Состояние — COSI: версии, владельцы, финализаторы, двухфазное удаление, подписка на изменения. (реализовано, проверено конформанс-сьютом)
- Связь с Talos — по gRPC через
pkg/machinery;talosctlкак подпроцесс не используется. (реализовано чтение ресурсов машин — зеркало Talos — в maintenance-режиме и с учётными данными кластера настроенной машины, применение конфигурации машин кластера и бутстрап etcd; перезагрузки, сброса, выключения и обновления нет, #32) - Совместимость со сторонними infra-провайдерами Omni:
omni-infra-provider-proxmoxи другие подключаются без правок. (реализовано: объявленные провайдеры, путь join-токенов v2, провайдерские токены, проверки запроса, привязка машины к запросу обоими способами, реапер осиротевших запросов — провереноtest/compatрантаймом SDKomni/clientзафиксированной версии;SysVersion— чтением клиентом состояния того же SDK и правилом разбора версии из SDK v1.9.x–v1.10.x, повторённым в тесте: рантайм этих версийtest/compatне запускает; статус каждого известного провайдера и его основание — omni-compat.md, раздел 15; статичные провайдеры не поддерживаются, #84; с настоящей инфраструктурой не проверено, #31)
Состав
| Компонент | Назначение | Реализация |
|---|---|---|
cmd/batuta |
сервер: serve --config — состояние, gRPC API, контроллеры, канал управления машинами; version — версия сборки |
есть |
cmd/batutactl |
CLI: get/list/watch состояния COSI по gRPC |
есть |
internal/batutalink |
канал управления: provision, WireGuard, приём событий и журнала ядра | есть |
internal/machinestore |
события и журнал ядра машин в файле SQLite, вне COSI | есть |
internal/infraprovider |
infra-провайдеры из конфигурации: InfraProvider, ProviderJoinConfig, SiderolinkAPIConfig |
есть |
internal/talos |
доступ к Talos API машин — в maintenance-режиме и с учётными данными их кластера, зеркало их ресурсов, выдача токенов узла, применение конфигурации машин, сбор их идентичности, бутстрап etcd кластеров, сброс и стирание удаляемых машин | есть |
internal/balancer |
балансировщик control plane каждого кластера: слушатель на адресе сервера в туннеле и LoadBalancerStatus |
есть |
ui/core |
TS-библиотека интерфейса, без привязки к оболочке | нет, #36 |
ui/app |
самостоятельный веб-интерфейс | нет, #36 |
ui/headlamp |
плагин Headlamp поверх ui/core |
нет, #36 |
cmd/batuta serve поднимает состояние и, с включённым туннелем, хранилище
событий и журнала машин, регистрирует ResourceDefinitions типов, которыми
владеют контроллеры и писатели batutalink, management, infraprovider
и, с включённым туннелем, talos, и типов infra-провайдеров, которые
пишут извне (docs/omni-compat.md §8.4), засевает в состояние параметры из конфигурации, запускает
контроллеры BackendStatus, MachineController (Link и запросы
провайдеров → Machine), LinkStatusController (Link → LinkStatus),
MachineStatusController (Machine, MachineStatusSnapshot и зеркало
Talos → MachineStatus), NodeTokenStatusController
и InfraMachineRegistrationController (Link провайдера и его запрос →
InfraMachineRegistration),
канал управления машинами и, с включённым туннелем, зеркало Talos, затем
открывает порт provision, с включённым туннелем — порты приёма
событий и журнала ядра внутри туннеля, и порт API. На порту API аутентификации нет
(#35), поэтому публичный bind без явного согласия отклоняется — см.
«Безопасность» ниже.
Сборка и проверка
Go 1.26.
make build # cmd/batuta, cmd/batutactl → bin/
make test # go test ./...
make lint # go vet + golangci-lint, без замечаний
make generate # регенерация из proto; CI проверяет отсутствие дрейфа
Запуск
bin/batuta serve --config <путь> читает конфигурационный документ,
поднимает состояние и открывает:
| Протокол | Ключ конфигурации, умолчание | Что |
|---|---|---|
| TCP | api.bindAddress, 127.0.0.1:8080 |
состояние COSI по gRPC и /metrics |
| TCP | siderolink.bindAddress, 0.0.0.0:8090 |
сервис provision (sidero.link.ProvisionService), цель siderolink.api= в kernel args машин |
| UDP | wireguard.port, 50180 |
устройство WireGuard, на всех интерфейсах |
| TCP | не настраивается, [fdae:41e4:649b:9303::1]:8091 |
приём событий машин (events.EventSinkService, gRPC без TLS); только с wireguard.enabled, только с адресов внутри туннеля |
| TCP | не настраивается, [fdae:41e4:649b:9303::1]:8092 |
приём журнала ядра машин, объект JSON на строку; только с wireguard.enabled, только с адресов внутри туннеля |
На порту API два протокола на одном сокете (internal/server, cmux по
SETTINGS-фрейму): состояние COSI по gRPC (cosi.resource.State — тот же
контракт, что использует немодифицированный infra-провайдер,
docs/omni-compat.md §3) и /metrics по HTTP (Prometheus).
bin/batuta version печатает версию сборки (internal/version) и ничего
не поднимает.
Минимальный рабочий конфигурационный документ:
machineApiAdvertisedUrl: grpc://batuta.example.org:8090
wireguard:
advertisedEndpoint: batuta.example.org:50180
state:
kind: boltdb
boltdb:
path: /var/lib/batuta/state.db
key:
env: BATUTA_STATE_KEY
state.key называет источник ключа шифрования состояния на диске
(internal/state.KeyProvider) — ровно один из двух, оба сразу или ни
одного отклоняются при запуске:
env— имя переменной окружения с base64-encoded 32-байтным ключом (internal/state.EnvKeyProvider);credential— имя systemd-credential,LoadCredentialEncrypted(internal/state.CredentialKeyProvider).
export BATUTA_STATE_KEY=$(head -c 32 /dev/urandom | base64)
bin/batuta serve --config batuta.yaml
state.kind: etcd (по умолчанию, вместо boltdb из примера) поднимает
встроенный etcd вместо одного файла — описание полей обоих бэкендов
в internal/config.StateConfig. Полный пример документа и умолчания —
в docs/architecture.md §12.
Infra-провайдеры объявляются в конфигурации — у API нет аутентификации, через которую провайдера регистрирует администратор Omni:
infraProviders:
- id: proxmox
id — идентификатор, с которым запущен провайдер: метка DNS-1123, без
повторов. При каждом старте для объявленного провайдера пишутся его
InfraProvider и ProviderJoinConfig с секретом, который создаётся
один раз и хранится в состоянии. Провайдер, убранный из списка, при
старте удаляется вместе с его ресурсами; если у него остались
MachineRequest, старт завершается ошибкой, называющей провайдера
и число запросов. Смена секрета — убрать провайдера без запросов,
запустить Batuta и объявить его снова; освобождение провайдера, который
больше не запустится, — docs/architecture.md §12.
Провайдер на SDK выбирает при старте путь v2: читает свой
ProviderJoinConfig и SiderolinkAPIConfig и подписывает токены машин
секретом провайдера (docs/omni-compat.md §9). Машина, join-токен
которой называет провайдера не из списка, получает отказ при
регистрации, с токеном версии 1 тоже: провайдер, работающий с Batuta
по легаси-пути, должен быть объявлен здесь, иначе его машины
не регистрируются.
Версии Talos, которые можно указать при создании кластера, тоже объявляются в конфигурации:
talosVersions:
- version: "11.0"
talos: "1.12.10"
kubernetes: ["1.34.1"]
deprecated: false
version — версия, которую несёт образ ALT Orchestra или которой назван
апстримный релиз Talos, без ведущего v (заданный с v — принимается,
v отбрасывается), без повторов в списке. talos — версия Talos внутри
этого образа; у образа ALT Orchestra она не совпадает с версией самого
образа (11.0 несёт Talos 1.12.10) — секреты и конфигурация машины
собираются по ней, а не по version. Пустое значение — то же, что
version: апстримный релиз, у которого они совпадают, может это поле
не указывать. kubernetes — совместимые версии Kubernetes, непустой
список. При каждом старте для каждой записи пишется TalosVersion
(ID — version, аннотация batuta.altlinux.org/talos-version — talos),
а версия, убранная из списка, при следующем старте удаляется
(internal/talosversions). Пустой или отсутствующий список допустим,
но тогда создать кластер нечем: подстановки версии из фабрики образов
нет (alt-orchestra/image-factory#13).
Канал управления машинами
Машина подключается к порту provision, предъявляет join-токен и, если он
ей выдан, токен узла, получает адрес внутри туннеля
(fdae:41e4:649b:9303::/64, сервер — fdae:41e4:649b:9303::1) и
записывается ресурсом Link с ID, равным её UUID. Устройство WireGuard
берёт пиров из сохранённых Link, поэтому после перезапуска процесса оно
настраивается заново по тем же ресурсам. Монитор раз в 10 секунд читает
статистику пиров и записывает в Link поля connected и last_endpoint;
пир считается отключённым, если рукопожатия не было дольше
wireguard.PeerDownInterval (275 секунд).
Сбой любого из циклов туннеля — работы устройства WireGuard, передачи ему
пиров из Link (batutalink.LinkPeers), монитора
(batutalink.LinkMonitor), индекса Link по адресу в туннеле
(batutalink.LinkIndex), приёма событий или журнала ядра, удаления
устаревших записей хранилища машин, зеркала Talos, выдачи токенов узла,
балансировщика control plane — завершает процесс с кодом 1. Так
задумано: устройство не принимает повторно событие пира, которое не смогло
применить, а перезапущенный процесс настраивает новое устройство по всем
сохранённым Link заново. Поэтому batuta serve запускается под
супервизором, который перезапускает процесс, — например, systemd
с Restart=on-failure.
-
machineApiAdvertisedUrl— обязателен. Это адрес, который машины набирают для provision: он становитсяapi_endpointвConnectionParams, из которого провайдер собирает kernel args. Значение —grpc://хост:портс явным портом; иное отклоняется при запуске. -
wireguard.advertisedEndpoint— обязателен. Этохост:порт, который сервис provision называет каждой допущенной машине для WireGuard. -
Объявленный адрес без хоста отклоняется всегда. Адрес, до которого не дотянется ни одна машина (loopback,
0.0.0.0/::, link-local), отклоняется, если соответствующий слушатель сам слушает не только loopback; для WireGuard это так всегда, поскольку устройство принимает только порт. Этот отказ снимаетsiderolink.allowLocalAdvertised: true— например, для запуска на одной машине с адресами127.0.0.1. Имя хоста не разрешается. Подробности —docs/architecture.md§12. -
wireguard.enabled— по умолчаниюtrue: процесс создаёт устройство WireGuard (интерфейсsiderolink), и для этого ему нужнаCAP_NET_ADMIN(например,AmbientCapabilities=CAP_NET_ADMINв unit-файле systemd). Устройство, которое не удалось создать, прерывает запуск, и ничего не обслуживается. -
wireguard.enabled: false— отладочный режим: сервис provision обслуживается, машины допускаются и записываются вLink(повторная регистрация машины под новым ключом без совпадающего токена получает отказ: без устройства ни один ключ не молчит, иLinkсохраняет первый ключ), но устройства WireGuard и монитора нет, порт UDP не открывается, порты приёма событий и журнала ядра и хранилище машин — тоже, зеркала Talos нет. Допущенной в этом режиме машине называются адрес и ключ, на которые не отвечает никакое устройство: туннель не поднимется,connectedостаётсяfalse. -
siderolink.initialJoinToken— join-токен, который предъявляет новая машина. Пустое значение: при первом запуске на данном состоянии токен генерируется и дальше сохраняется. Заданное значение — простой токен без префикса версии, не длиннее 1024 байт, без пробелов по краям; оно становится токеном по умолчанию, а прежние токены остаются действительными. Отзыва join-токенов нет (#66), поэтому смена значения в конфигурации не обезвреживает токен, который утёк. -
imageFactory.baseUrl— адрес фабрики образов, по умолчаниюhttps://factory.altlinux.space;imageFactory.pxeBaseUrl— адрес её PXE-фронтенда, по умолчанию тот же хост с префиксомpxe.. Оба — абсолютныйhttp://илиhttps://URL с хостом, без пользовательской информации, запроса или фрагмента; конечные/отбрасываются.imageFactory.baseUrlс хостом-IP-литералом без явногоimageFactory.pxeBaseUrlотклоняется:pxe.перед IP-адресом — не хост. -
imageFactory.installerImage— шаблон Gotext/templateссылки на образ установщика, по которой машина ставит Talos, над полямиHost(хостimageFactory.baseUrl),Platform,SecureBoot,Schematic(идентификатор схематика машины) иVersion(версия Talos кластера как она предложена, например11.0у образа ALT Orchestra и1.12.10у upstream, безv). Шаблон, который не разбирается или ссылается не только на эти пять полей (.Host,$.Host, без цепочки после поля), отклоняется при загрузке. Пустое значение — правило Omni:<хост>/<платформа>-installer[-secureboot]/<схематик>:v<версия>, если контракт Talos — 1.10 или новее, и<хост>/installer[-secureboot]/<схематик>:v<версия>до него; контракт определяется версией Talos за предложенной версией (talosVersions[].talos), а не самой предложенной версией. Фабрикаfactory.altlinux.spaceназывает установщики иначе, пока не отдаёт upstream-имена (#98, alt-orchestra/image-factory#13), поэтому для неё:imageFactory: installerImage: "{{.Host}}/alt-orchestra-installer/{{.Schematic}}:v{{.Version}}"Ссылка строится там, где генерируется машинная конфигурация (
ClusterMachineConfig);MachineConfigGenOptionsнесёт для неё факты машины. -
loadBalancer.portRange— диапазон портов, из которого каждому кластеру выделяется порт балансировщика нагрузки,<первый>-<последний>, обе границы включительно, по умолчанию10000-35000. Формат, границы вне1-65535и перевёрнутый диапазон отклоняются при загрузке. Порт записывается вLoadBalancerConfigкластера и сохраняется; адресhttps://[<адрес сервера в туннеле>]:<порт>— адрес, который Omni ставит точкой входа Kubernetes API в конфигурации машин; так её ставит и Batuta. На этом порту работает балансировщик кластера (см. ниже).
При каждом запуске в состояние засеваются идентичность сервера (ключ
WireGuard, сохраняемый между запусками), join-токены, ConnectionParams:
kernel args для машин (siderolink.api= с join-токеном в параметре
jointoken, адреса приёма событий 8091 и логов 8092 внутри туннеля),
и FeaturesConfig: адрес фабрики образов и её PXE-фронтенда, которые
читает фолбэк провайдера при разрешении URL носителя установки
(docs/omni-compat.md §10). При каждом запуске заново пишется и
SysVersion — его читает при старте провайдер на omni/client
v1.9.x–v1.10.x и завершается без него (docs/omni-compat.md §8).
ConnectionParams и FeaturesConfig, изменённые вручную, при следующем
запуске перезаписываются конфигурацией целиком; SysVersion живёт
в памяти и не переживает перезапуск сам по себе. Запрос
с wireguard_over_grpc получает Unimplemented.
Токен узла выдаёт сервер: с включённым туннелем каждой подключённой
машине с Talos 1.6 или новее он записывается в META по Talos API
(в maintenance-режиме или с учётными данными кластера, по состоянию машины)
и сохраняется с ключом WireGuard, которому выдан.
Токен, предъявленный машиной, не записывается никогда. Машина, предъявившая
сохранённый токен под новым ключом, доказала, что токен пережил
перезагрузку: её Link получает отметку
siderolink.omni.sidero.dev/force-valid-node-unique-token, и дальше она
допускается только с этим токеном. Без отметки регистрация без токена или
с другим токеном под новым ключом допускается, только если ключ, который
держит Link, молчит — без рукопожатия WireGuard по данным устройства —
nodeTokens.reclaimAfter (по умолчанию 10m, не меньше 4m35s); такой
перехват оставляет на Link аннотацию
batuta.altlinux.org/reclaimed-without-node-token, и машина получает новый
токен. Машина, для которой токен не сохранён, защищена тем же правилом:
её регистрация под новым ключом допускается, только если прежний ключ
её Link молчит nodeTokens.reclaimAfter. nodeTokens.strict: true отказывает любой регистрации
с несовпадающим токеном. Состояние токена машины показывает
NodeUniqueTokenStatuses.omni.sidero.dev. Правила допуска и пределы —
в разделе «Безопасность» и docs/architecture.md §13.
События и журнал ядра машин
С включённым туннелем процесс принимает от машин на адресе сервера
в туннеле fdae:41e4:649b:9303::1:
- на порту
8091— события (events.EventSinkService, gRPC без TLS), цель kernel argtalos.events.sink=; - на порту
8092— журнал ядра, объект JSON на строку, цель kernel argtalos.logging.kernel=.
Оба порта открываются после устройства WireGuard: адрес сервера
появляется на интерфейсе вместе с устройством, и привязка к нему раньше
не удаётся. Отправитель определяется только по адресу источника: адрес
в туннеле принадлежит одному Link, ID которого — UUID машины; поля
сообщения машину не называют. Событие с адреса, которого нет ни у одного
Link, отклоняется (PermissionDenied), соединение журнала с такого
адреса закрывается до чтения. Адрес вне туннеля не принадлежит ни одному
Link и отклоняется так же. Этой проверкой получает отказ и сосед в том же
L2-сегменте: Linux следует модели слабого хоста и принимает пакет на адрес
сервера в туннеле, пришедший на любой интерфейс, так что порты 8091
и 8092 достижимы с его стороны и в обход туннеля. Событие подтверждается после записи
в хранилище; на ошибку записи ответ — Internal, и машина присылает то же
событие снова. Строку журнала, которую не удалось записать, приём пишет
повторно, а следующую строку не читает, пока запись не удастся. Одна машина
держит не больше 4 соединений журнала одновременно; соединение сверх этого
и соединение, в котором строка длиннее 64 КиБ, закрываются.
MachineStatusEvent машины становится полем machine_status её ресурса
MachineStatusSnapshot, из которого MachineStatusController берёт
maintenance в MachineStatus.
Машина, у которой задан HTTPS_PROXY, а NO_PROXY не включает
fdae:41e4:649b:9303::/64, отправляет события через прокси. Адрес
источника тогда не принадлежит ни одному Link, событие отклоняется,
и машина повторяет отправку без конца.
Принятое хранится в файле SQLite (internal/machinestore), вне состояния
COSI:
machineStore.path— путь к файлу, по умолчанию/var/lib/batuta/machines.sqlite; путь с символом?отклоняется;machineStore.retention— сколько запись хранится после получения, по умолчанию168h, только положительное значение. Устаревшие записи удаляются раз вretention/10, но не реже раза в час.
Хранится каждое событие с непустым ID и содержимым — ID, type_url
и содержимое, как получены; событие без них подтверждается и не хранится.
Каждая строка журнала хранится такой, какой получена, кроме значений
join-токена и URL конфигурации.
Ядро печатает свою командную строку, а в ней
siderolink.api=…?jointoken=<токен> и, возможно, talos.config=<URL>,
в котором бывают учётные данные, поэтому перед записью каждое
jointoken=<значение> в строке заменяется на jointoken=REDACTED,
а каждое talos.config=<значение> — на talos.config=REDACTED.
Значение кончается перед &, кавычкой, обратной косой чертой или
пробельным символом.
Повторная доставка события новой записи не создаёт; повторная доставка
строки — тоже, если в строке есть и seq, и clock. Файл и его
спутники -wal и -shm создаются с правами 0600; шифрования на диске
у хранилища нет (#69). Объём записей одной машины ограничен только
возрастом записей (#72). Сохранённые строки журнала через API не отдаются:
RPC журнала машины в Management API нет (#34).
Зеркало Talos
С включённым туннелем процесс подключается к Talos API каждой подключённой
машины — порт 50000/tcp на её адресе в туннеле (management_address
её Machine; к адресу вне ULA SideroLink — нет) — как к машине
в maintenance-режиме (TLS без проверки сертификата сервера, без
клиентского сертификата) или с учётными данными кластера, как ниже.
Одна сессия на машину читает её ресурсы Talos
фиксированного списка типов и копирует их в namespace talos-mirror,
с ID <UUID машины>/<ID в Talos>
и меткой batuta.altlinux.org/machine=<UUID>. Отличий от Talos два:
в копии LinkStatus удалены ключи WireGuard (см. «Безопасность»),
а у машины, которая не регистрирует тип runtime.Version, в зеркале
всё же есть runtime.Version — составленный из ответа вызова Version:
тег версии и пустое имя. Копии отключившейся машины
остаются такими, какими были при отключении; копии удалённой Machine
удаляются.
Какие из типов машина регистрирует, показывают маркеры — копии её
ResourceDefinitions в namespace talos-mirror-types. Сессия, прерванная
ошибкой, повторяется через паузу от 1 секунды до минуты. Пока стадия машины
в MachineStatusSnapshot — MAINTENANCE, а также у машины вне кластера
и у машины, кластер которой не имеет TalosConfig, сессия подключается как
к машине в maintenance-режиме; иначе — с клиентским сертификатом os:admin
из TalosConfig кластера, а сертификат машины проверяется по OS CA оттуда
же. Итог последнего чтения — last_error и метка invalid-state
в MachineStatus: метка означает, что машина отвергла учётные данные,
потому что работает со своим PKI.
Из зеркала MachineStatus получает версию Talos, оборудование, кроме
архитектуры (#75), сеть, метаданные платформы, состояние безопасности,
командную строку ядра, диагностику, схему образа и метки; значения
jointoken= и talos.config= в командной строке ядра и в манифесте схемы
заменены на REDACTED. Части MachineStatus, перечисленные в #76,
из зеркала не заполняются. Подробности — docs/architecture.md §7.1.
Определения зеркальных типов называют talos-mirror namespace'ом
по умолчанию, поэтому копии читаются по одному имени типа:
bin/batutactl list HostnameStatuses.net.talos.dev
bin/batutactl list ResourceDefinitions.meta.cosi.dev --namespace talos-mirror-types
Балансировщик control plane
Узел достигает Kubernetes API кластера только через Batuta: его пир
SideroLink допускает один адрес — адрес сервера в туннеле, и именно он
стоит в cluster.controlPlane.endpoint машинной конфигурации
(LoadBalancerConfig.siderolink_endpoint). Для каждого кластера, у
которого есть LoadBalancerConfig, internal/balancer слушает
[<адрес сервера в туннеле>]:<bind_port> — на адресе туннеля, как порты
8091 и 8092, а не на 0.0.0.0, как Omni, и принимает только
адреса источника из туннеля, — и проксирует TCP
на адреса LoadBalancerConfig.endpoints: адреса управления control plane
с портом 6443. Порт у каждого кластера свой, из loadBalancer.portRange.
Слушатель открыт, пока ClusterStatus.has_connected_control_planes истинно;
иначе он закрыт вместе с проксируемыми соединениями. Каждый адрес
проверяется TCP-соединением раз в 20 секунд и сразу при смене
LoadBalancerConfig; новое соединение получает здоровый адрес по кругу,
а если выбранный отказал — следующий здоровый. Без здорового адреса
слушатель остаётся, а принятые соединения закрываются.
LoadBalancerStatus (ID — кластер, через API только читается) говорит
stopped и healthy; он сверяется с хранимым каждые 10 секунд и при
изменении и пишется, только когда отличается. Он уничтожается вместе
с LoadBalancerConfig. Если порт занят и слушатель не открывается,
в журнал пишется строка с кластером и адресом, кластер остаётся
stopped, остальные обслуживаются как прежде, а попытка повторяется
с паузой от 1 секунды до 1 минуты. Без туннеля (wireguard.enabled: false)
адреса для привязки нет: слушателей нет, LoadBalancerStatus пишется
как stopped. Подробности — docs/architecture.md §12 и
docs/omni-compat.md.
Проверка без Talos-машины
Порт provision принимает любого клиента, сгенерированного из
siderolink/api/siderolink: запрос Provision с node_uuid,
node_public_key и join-токеном из ConnectionParams возвращает адрес
машины в туннеле, ключ и адрес сервера, а в состоянии появляются Link
и Machine с этим UUID и LinkStatus, ссылающийся на Link:
bin/batutactl get ConnectionParams.omni.sidero.dev siderolink-config
bin/batutactl get Links.omni.sidero.dev <uuid>
bin/batutactl get Machines.omni.sidero.dev <uuid>
batutactl
bin/batutactl читает то же состояние по gRPC:
bin/batutactl list BackendStatuses.batuta.altlinux.org --endpoint http://127.0.0.1:8080
bin/batutactl get BackendStatuses.batuta.altlinux.org backend -o json
Адрес API — только флагом --endpoint (по умолчанию http://127.0.0.1:8080);
файла конфигурации у клиента нет: учётных данных нет (#35, см.
docs/omni-compat.md §4), а заводить второй документ в довесок
к серверному ради одного loopback-адреса незачем. Неизвестный тип
ресурса выводит список зарегистрированных на сервере типов.
Oxide и SakuraCloud завершаются при старте без
OMNI_SERVICE_ACCOUNT_KEY (docs/omni-compat.md §15). Администратора
у Batuta нет — учётную запись службы выдаёт batutactl для уже
объявленного провайдера (infraProviders в конфигурации сервера, выше):
bin/batutactl infraprovider key oxide --endpoint http://127.0.0.1:8080
Команда отказывает без ключа и без вывода, если идентификатор — не метка
DNS-1123, срок --ttl короче секунды или длиннее 2³²−1 секунд, провайдер
не объявлен или сервер недостижим; идентификатор и срок проверяются до
обращения к серверу. На успехе она печатает в stdout две строки,
OMNI_ENDPOINT и OMNI_SERVICE_ACCOUNT_KEY, за ними — открытую часть
ключа в ASCII-armor, а в stderr предупреждает, что подпись Batuta
не проверяет (#35) и что провайдер перестаёт подписывать вызовы
по истечении срока действия ключа (--ttl в целых секундах, дробная
часть отбрасывается; по умолчанию год): ключ переиздаётся и провайдер
перезапускается. --advertised-endpoint задаёт значение OMNI_ENDPOINT,
если провайдер обращается к Batuta не по тому же адресу, что и сама
команда (--endpoint).
Первые две строки идут провайдеру как переменные окружения: Oxide
и SakuraCloud читают OMNI_ENDPOINT и OMNI_SERVICE_ACCOUNT_KEY
из окружения, поэтому переменные экспортируются (set -a). У Oxide
идентификатор задаёт PROVIDER_ID, по умолчанию oxide, а доступ
к Oxide — обязательные OXIDE_HOST и OXIDE_TOKEN:
set -a
eval "$(bin/batutactl infraprovider key oxide --endpoint http://127.0.0.1:8080 | head -n2)"
set +a
OXIDE_HOST=https://oxide.example.org OXIDE_TOKEN=oxide-token-... omni-infra-provider-oxide
SakuraCloud берёт идентификатор из флага --id, по умолчанию sakura;
остальные его настройки — доступ к Sakura Cloud, --config-file —
Batuta не касаются:
set -a
eval "$(bin/batutactl infraprovider key sakura --endpoint http://127.0.0.1:8080 | head -n2)"
set +a
omni-infra-provider-sakuracloud --id sakura
talemu тоже требует этот ключ, но его собственный запуск против Batuta не проверен (#87) — ключ снимает только отказ при старте.
Метрики
/metrics на порту API отдаёт:
batuta_controller_crashes_total,batuta_controller_wakeups_total,batuta_controller_reads_total,batuta_controller_writes_total— по контроллерам;batuta_resources— число ресурсов по namespace;grpc_server_*— число и длительность вызовов gRPC на портах API, provision и приёма событий;batuta_grpc_panics_total— паники обработчиков gRPC. Паника в обработчике на любом из трёх gRPC-портов перехватывается: вызов получаетInternal, процесс продолжает работу, счётчик растёт на единицу;batuta_node_token_admissions_total{verdict},batuta_node_token_refusals_total{reason},batuta_node_token_assignments_total{kind},batuta_node_token_persistence_marks_total— допуски, отказы, выдачи токенов узла и отметки постоянства; метки не несут ни токена, ни UUID, ни ключа.
Безопасность
Порт API не аутентифицирует вызовы (#35): кто до него дотянулся, читает
и пишет состояние. Поэтому публичный (не loopback) api.bindAddress
отклоняется при запуске; разрешает его только api.allowPublicBind: true
(docs/architecture.md §13).
Через API не выдаются и не принимаются ни на чтение, ни на запись
Configs (приватный ключ WireGuard сервера), JoinTokens,
DefaultJoinTokens, NodeUniqueTokens и ImageFactoryAuths (учётные
данные фабрики образов, которых Batuta не хранит); ConnectionParams,
InfraProviders, ProviderJoinConfigs, SiderolinkAPIConfigs, Links,
MachineStatusSnapshots, FeaturesConfigs и SysVersions через
API только читаются. Только читаются
и выходы контроллеров — Machines, LinkStatuses, MachineStatuses,
NodeUniqueTokenStatuses, BackendStatuses,
InfraMachineRegistrations: их набор берётся из реестра
контроллеров, поэтому выход всякого контроллера, запущенного процессом,
через API не записывается. Зеркало Talos через API только читается —
и копии в talos-mirror, и маркеры в talos-mirror-types; копии
KernelCmdlines и ExtensionStatuses не выдаются вовсе: командная строка
ядра и манифест схемы несут join-токен и могут нести URL конфигурации
с учётными данными. Копии совпадают с ресурсами машины, кроме ключей
WireGuard: в копии LinkStatus удалены PrivateKey интерфейса
и PresharedKey каждого пира. Отказ — PermissionDenied с текстом
not available over the API. MachineRequests через API читаются
и пишутся, но запись принимается только после проверки содержимого
запроса (docs/omni-compat.md §8.5); отказ — InvalidArgument.
ConnectionParams несёт join-токен по умолчанию: без него провайдер на
легаси-пути не соберёт kernel args машины. Так же через API читается
секрет каждого провайдера — join_token его ProviderJoinConfig: с ним
выпускаются токены этого провайдера с любым ID запроса
(docs/architecture.md §13). Поэтому api.allowPublicBind: true открывает
всякому, кто дотянется до порта, join-токен по умолчанию и секрет каждого
провайдера — а с ними право ввести машину, — чтение ConnectionParams,
InfraProviders, ProviderJoinConfigs, SiderolinkAPIConfigs, Links,
MachineStatusSnapshots, FeaturesConfigs, SysVersions, выходов
контроллеров (в их числе InfraMachineRegistrations), зеркала Talos
(кроме KernelCmdlines и ExtensionStatuses), ResourceDefinitions
и Namespaces, чтение и запись всех прочих типов — MachineRequests
с проверкой содержимого — и вызов
CreateSchematicFromRaw — POST на imageFactory.baseUrl с телом
по выбору вызывающего, который фабрика видит пришедшим от Batuta.
Командная строка ядра и манифест схемы попадают и в MachineStatus,
который через API читается: в kernel_cmdline, schematic.raw
и schematic.kernel_args. Там каждое значение jointoken=
и talos.config= заменено на REDACTED, а raw, в тексте которого после
замены остаётся часть такого значения, пуст.
Join-токен разрешает только вступление: зная его и UUID, машиной может назваться кто угодно. Вторая половина — токен узла (см. «Канал управления машинами»). Его пределы:
- машина с Talos ниже 1.6, машина, настроенная вне Batuta, со своим PKI,
и машина, настроенная с сертификатами кластера, но не входящая ни в один
кластер Batuta или входящая в кластер без
TalosConfig, токена не получают: её живой ключ не заменяется, но каждая её перезагрузка допускается только черезnodeTokens.reclaimAfterот последнего рукопожатия прежнего ключа, и после такого молчания её UUID может занять любой держатель join-токена; ниnodeTokens.strict, ни отметка постоянства к такой машине не применяются; - машина infra-провайдера без
Machine— не привязанная к запросу, в конфликте привязки или безInfraProviderStatusсвоего провайдера (docs/architecture.md§7.2) — токена не получает: выдача работает поMachine. Её, как и машину выше, защищает только молчание ключа: послеnodeTokens.reclaimAfterеё UUID может занять любой держатель join-токена; - после понижения Talos ниже 1.6 машина допускается только перехватом, а с отметкой постоянства — не допускается до сброса токена, которого нет (#79);
- диск, клонированный после выдачи, несёт тот же токен, и
Linkпереходит от клона к клону (#80); - машина без постоянной META (ISO) после перезагрузки возвращается через
nodeTokens.reclaimAfter; - перехват машины, не доказавшей постоянства, необратим до сброса токена, которого нет (#79);
- токен узла читает не только сервер: Talos отдаёт ключ META
0x10ролиReader, так что на сконфигурированной машине любой держатель сертификатаos:readerкластера читает токен узла и join-токен и может занять UUID машины, в том числе при живом ключе; в maintenance-режиме токен может прочитать тот, кто способен достучаться до туннельного адреса машины (#79); - машину, отвергающую выбранные учётные данные, сервер вызывает снова примерно раз в минуту.
Документация
- AGENTS.md — правила работы в репозитории: коммиты, язык, проверки, инварианты
- Архитектура — решения, состав, потоки данных, развёртывание, безопасность
- Контракт совместимости с infra-провайдерами Omni — ресурсы, протокол, последовательность provision, чеклист совместимости
Лицензия
Серверная часть — AGPL-3.0-or-later: каждый файл исходного кода
несёт строку SPDX-License-Identifier. Модуль client/ —
определения API, которые используют другие, — под MPL-2.0,
с уведомлением из Exhibit A в каждом файле, кроме файлов *_vtproto.pb.go,
которые генератор пишет без заголовка: их покрывает client/LICENSE.
Версии, опубликованные под MPL-2.0, остаются под ней.
Кто изменил Batuta и даёт пользователям работать с изменённой версией по сети, обязан предложить им исходный код этой версии (AGPL §13). Запущенный сервер не указывает на свой исходный код, и места для такого предложения в нём нет (#112).
Зависимости под MPL-2.0, в том числе siderolabs/omni/client,
cosi-project/runtime, cosi-project/state-etcd, siderolabs/siderolink
и talos/pkg/machinery, сохраняют её (docs/architecture.md, раздел 4).
Серверная часть Omni (siderolabs/omni, корень репозитория) распространяется
под BUSL 1.1 и не заимствуется ни в каком объёме.