Эффективное управление парком qf
Документ описывает рекомендованную модель управления крупным парком: что держать в IaC, как таргетировать политики, как заводить хосты zero-touch.
TL;DR
Terraform (стационар) Runtime (динамика)
├─ qf_object_group ─────┐ ├─ bulk-токен (label_template = role/env)
└─ qf_policy │ └─ хост энроллится → сам штампует лейблы
(selector по лейблам) │ → label-selector политики применяются авто
▼
ничего на хост вручную не вешаем
Главное: хост таргетируется лейблами, а лейблы приезжают из enrollment-токена. Политики селектят по лейблам напрямую — host-group для этого не обязателен. Это и есть zero-touch: ни одного per-host ручного шага.
Что реально делает каждый слой (по коду)
| Объект | Где живёт | Чем управляется | Назначение |
|---|---|---|---|
qf_object_group | Terraform | провайдер | переиспользуемые IP/port/host-сеты |
qf_policy (+ selector) | Terraform | провайдер | правила + кого они касаются (по лейблам) |
| bootstrap-токен | REST/UI | не в Terraform | несёт label_template → штампует лейблы на хост при энролле |
| host-group | REST/UI | не в Terraform | бандл общих лейблов + удобство каскада |
| членство в группе | REST/UI/API | host_group_members, явное | какие хосты входят в группу |
Два факта, которые меняют схему:
- Токен → лейблы всегда; в группу — опционально.
label_templateтокена применяется к хосту при первом подключении. По умолчанию токен не трогает host-group. Но bulk-токен с полемtarget_group_idавто-добавляет хост в указанную группу при энролле (feature 383, см. group-first флоу ниже). - Членство в группе — явное. Хост попадает в группу либо руками через
POST /host-groups/{id}/members(UI/API), либо авто черезtarget_group_idтокена. Селектора группы по лейблам нет: сменишь лейбл хоста — он не будет автоматически добавлен в группу.
Матчинг политики идёт по эффективным лейблам = лейблы групп (по приоритету, меньше число — выше) ⊕ собственные лейблы хоста (хостовые перетирают групповые).
Рекомендованный поток (label-first)
Шаг 1. Стационар в Terraform
Object-group'ы и политики — как код, версионируются, ревьюятся в PR.
resource "qf_object_group" "mgmt_net" {
name = "mgmt-net"
type = "ipset"
spec = jsonencode({ cidrs = ["10.0.0.0/16"] })
}
resource "qf_policy" "web" {
name = "web-ingress"
priority = 100
# таргет по ЛЕЙБЛАМ — никакой привязки к IP/хостам
selector = jsonencode({ matchLabels = { role = "web", env = "prod" } })
rule {
priority = 10
action = "allow"
match = jsonencode({ protocol = "tcp", dst_ports = ["443"] })
}
}
selector — это AND по всем matchLabels. Хост получит политику, только если у
него (эффективно) есть оба лейбла role=web И env=prod.
Шаг 2. Bulk-токены под когорты
Один токен на роль/окружение, label_template = те же лейблы, по которым селектят
политики. Через UI (Tokens) или REST:
POST /tokens
{ "type": "bulk",
"label_template": { "role": "web", "env": "prod" },
"max_uses": 500, "ttl_seconds": 2592000 }
Шаг 3. Энролл хостов
agent.conf из двух полей (QF_ENDPOINT + QF_ENROLL_TOKEN), раскатывается
Ansible-ролью. При первом подключении хост:
- штампует на себя
role=web, env=prodиз токена, - авто-детектит интерфейс,
- получает client-cert,
- CP резолвит label-селекторы → политика
webприменяется сразу, без участия оператора.
Добавили 300 web-хостов — раздали тот же токен, политики применяются автоматически.
Альтернативный поток (group-first)
Когда удобнее рассуждать группами, а не отдельными лейблами: политики привязаны к
host-group, а токен добавляет хост в группу при энролле (target_group_id,
feature 383). Хост наследует лейблы группы → политики группы применяются сразу.
Тоже zero-touch, но точка таргетинга — группа, не label_template.
Terraform (стационар) Runtime (динамика)
├─ qf_object_group └─ bulk-токен { target_group_id = <grp> }
└─ qf_policy (selector = anyOf └─ хост энроллится
[ _groupId блок группы ]) → авто-join в группу
▲ → наследует лейблы группы
│ руками: создать группу, → политики группы применяются авто
└─ слинковать политику на неё
Шаг 1. Стационар в Terraform
Object-group'ы и политики — как раньше. Но selector политики таргетит группу
(anyOf-блок с _groupId), а не голые лейблы:
resource "qf_policy" "web" {
name = "web-ingress"
priority = 100
selector = jsonencode({
anyOf = [{ matchLabels = { role = "web" }, _groupId = "<group-uuid>" }]
})
rule {
priority = 10
action = "allow"
match = jsonencode({ protocol = "tcp", dst_ports = ["443"] })
}
}
Шаг 2. Руками создать группы, слинковать политики
Группа = бандл лейблов ({role: web}). В UI: Policy → Assign host group (даёт
тот самый anyOf-блок с _groupId), либо через Terraform-selector выше. Группа с
пустым набором лейблов к политике не привязывается — задай лейблы.
Шаг 3. Bulk-токен с target_group_id
POST /tokens
{ "type": "bulk",
"target_group_id": "<group-uuid>",
"max_uses": 500, "ttl_seconds": 2592000 }
label_template можно оставить пустым — лейблы приедут из группы через
эффективные лейблы. (FK ON DELETE SET NULL: при удалении группы токен сохраняется,
поле обнуляется, авто-join выключается.)
Шаг 4. Энролл
Хост подключается → CP делает best-effort AddHostToGroup → живое членство
(наследует лейблы группы, каскад при смене лейблов группы) → политики группы
применяются сразу. agent.conf всё те же 2 поля.
Live-validated (2026-06-30): re-enroll qt1 токеном с target_group_id и пустым
label_template → qt1 авто-вошёл в k8s-servers, effective_labels={role:k8s-server}
при собственных {}, log-политика группы применилась.
Label-first vs group-first — что брать
| label-first | group-first | |
|---|---|---|
| Таргет политики | голые лейблы (matchLabels) | группа (anyOf + _groupId) |
| Токен несёт | label_template | target_group_id |
| Смена таргета когорты | правишь лейблы токена | двигаешь хосты между группами / лейблы группы |
| Общий бандл лейблов в 1 месте | нет | да (лейблы группы) |
| Кол-во ручных шагов | 0 | создать группу + линк (1 раз на когорту) |
Дефолт — label-first (меньше сущностей). group-first — когда нужен общий управляемый-в-одном-месте бандл лейблов или иерархия приоритетов.
Трейдофф: лейбл токена иммутабелен, bulk-relabel нет
Ключевое ограничение label-first: лейбл из токена задаётся один раз при энролле и после не двигается массово. Примитива «добавить лейбл X на N уже-заенролленных хостов» в CP нет. Что реально есть:
| Механизм | Массовый релейбл? |
|---|---|
PATCH /hosts/{id} | нет — один хост, full-replace map (read-merge-write вручную) + каскад |
| bulk-label endpoint | отсутствует |
re-enroll с новым label_template | тяжёлый: роль скипает при наличии сертов, нужен wipe pki/ + повторный энролл |
Три выхода, по убыванию:
- Держать мутируемое измерение в host-group, не в токене. Меняешь лейбл группы один раз → каскад пересчитывает всех членов. Это и есть смысл группы. label-first не имеет bulk-relabel намеренно — для этого группа.
- Скриптовый PATCH-loop (одноразовый сдвиг):
GET /hosts→ на каждый read-merge-writePATCH /hosts/{id}. Гонок нет (хосты независимы), но N запросов.for id in $(curl -s .../hosts | jq -r '.[].id'); docur=$(curl -s .../hosts/$id | jq '.labels')new=$(jq -n --argjson c "$cur" '$c + {tier:"gold"}')curl -X PATCH .../hosts/$id -d "{\"labels\":$new}"done - Re-enroll — наихудший вариант: роль не приспособлена (нужен wipe certs + повторный энролл). Применять ради одного лейбла не стоит.
Правило раскладки измерений:
| Измерение | Куда |
|---|---|
иммутабельное на всю жизнь хоста (role, env) | лейбл токена (label_template) |
будешь массово двигать (tier, maintenance, canary) | host-group (меняешь в одном месте) |
Т.е. штатная схема — гибрид: неизменное через токен, мутируемое через группу. Не клади в токен то, что придётся потом массово релейблить.
Когда нужны host-group'ы
Группы не нужны для таргетинга политик (это делают лейблы). Бери группу, когда:
- Общий бандл лейблов на много хостов, который хочешь менять в одном месте. Сменил лейбл группы → каскад пересчитал политики у всех членов разом.
- Иерархия приоритетов лейблов (группа задаёт дефолт, хост перетирает).
- OR в селекторе политики (см. ниже) — каждая группа = отдельный OR-блок.
Цена: членство явное — либо руками (POST /host-groups/{id}/members), либо
авто через target_group_id токена (group-first флоу). Без target_group_id
группы нарушают zero-touch. Поэтому: по умолчанию label-first, группы — точечно
под перечисленные случаи (а если group-first — то токеном с target_group_id).
AND vs OR в селекторе
- AND (дефолт): все лейблы в
matchLabelsодного блока.{role=web, env=prod}= «web И prod». - OR: только через
anyOf— несколько блоков, матч по любому.В UI{ "anyOf": [ {"matchLabels":{"role":"web"}}, {"matchLabels":{"role":"api"}} ] }anyOfсобирается неявно: добавляешь ≥2 блоков (несколько групп через «Add group», или хосты через «Assign host») → получаешь OR. Свободного OR по голым лейблам в одном поле UI нет — поле Host selector всегда AND-блок. - В Terraform пишешь
anyOfруками черезjsonencode.
Антипаттерны
- ❌ Таргетить политику на конкретные host-id / группы вместо лейблов. Теряешь декларативность: новый хост требует ручного действия. Лейбл из токена — авто.
- ❌ Считать, что обычный токен заведёт хост в group. Не заведёт — проставляет
только лейблы. В группу заводит только bulk-токен с
target_group_id(group-first флоу); иначе членство — отдельный явный шаг. - ❌ Грузить всё в один host-group и таргетить политики на группу. Работает, но возвращает ручное членство в каждый деплой. Лейблы из токена этого не требуют.
- ❌ default-deny / match-all deny. Модель fail-open: сегментация — это явные
denyна сервис-порты при default-allow, не глобальный запрет.
Object-group'ы: типы и семантика
Переиспользуемые сеты, которые CP резолвит в бандл на этапе компиляции:
| Тип | spec | Во что резолвится |
|---|---|---|
ipset | { cidrs = [...] } | CIDR/IP; при >8 инлайн-записях становятся LPM-trie BPF-map |
portset | { ports = [...] } | порты + диапазоны |
hostset | { selector = {...} } | динамически — в IP хостов, матчащихся селектором (реальный IP attached-интерфейса каждого хоста) |
Поведение:
- Правка spec object-group'ы → каскад на все хосты, где эта OG используется.
- Удаление референсной object-group → HTTP 409 (FK
ON DELETE RESTRICT). - Orphan (никем не референсится) — чистится GC; референсные защищены.
- Валидация spec на create/update: битый CIDR / порт / оператор → HTTP 400.
Разделение ответственности
| Слой | Кто ведёт | Канал | Частота изменений |
|---|---|---|---|
| object-groups, политики, селекторы | платформа/сеть | Terraform (PR-ревью) | редко, стационар |
| токены (label_template когорт) | платформа | REST/UI, один раз на роль | редко |
| энролл хостов | автоматизация (Ansible) | agent.conf 2 поля | часто, zero-touch |
| host-group'ы (если нужны) | платформа | REST/UI/API | редко |
Стационар — в git через Terraform. Динамика парка — через лейблы токенов. Оператор не трогает отдельные хосты в штатном потоке.