Система управления кластерами ALT Orchestra. Бэкенд на Go поверх COSI, связь с Talos по gRPC, совместимость с infra-провайдерами Omni.
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Alexey Shabalin 051a5ba763
Some checks failed
CI / check (push) Has been cancelled
Remove machines through their Links end to end
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
2026-10-09 16:39:35 +03:00
.forgejo/workflows Relicense Batuta under AGPL-3.0-or-later 2026-09-30 21:58:20 +03:00
client Reach configured machines with the cluster's credentials 2026-10-02 12:38:35 +03:00
cmd Relicense Batuta under AGPL-3.0-or-later 2026-09-30 21:58:20 +03:00
docs Remove machines through their Links end to end 2026-10-09 16:39:35 +03:00
internal Say the node deletion is unconfirmed, not unanswered 2026-10-09 16:39:35 +03:00
test/compat Remove machines through their Links end to end 2026-10-09 16:39:35 +03:00
.gitignore Test the infra provider SDK against Batuta 2026-09-25 17:18:51 +03:00
.golangci.yml Relicense Batuta under AGPL-3.0-or-later 2026-09-30 21:58:20 +03:00
AGENTS.md Remove machines through their Links end to end 2026-10-09 16:39:35 +03:00
buf.gen.yaml Relicense Batuta under AGPL-3.0-or-later 2026-09-30 21:58:20 +03:00
buf.yaml Relicense Batuta under AGPL-3.0-or-later 2026-09-30 21:58:20 +03:00
go.mod Report each cluster's Kubernetes status 2026-10-03 14:23:37 +03:00
go.sum Store machine events and kernel logs in SQLite 2026-09-24 23:31:57 +03:00
LICENSE Relicense Batuta under AGPL-3.0-or-later 2026-09-30 21:58:20 +03:00
Makefile Remove machines through their Links end to end 2026-10-09 16:39:35 +03:00
README.md Remove etcd members no machine accounts for 2026-10-09 16:39:35 +03:00

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 рантаймом SDK omni/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 — шаблон Go text/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 arg talos.events.sink=;
  • на порту 8092 — журнал ядра, объект JSON на строку, цель kernel arg talos.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);
  • машину, отвергающую выбранные учётные данные, сервер вызывает снова примерно раз в минуту.

Документация

Лицензия

Серверная часть — 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 и не заимствуется ни в каком объёме.