Stands Engine разворачивает инфраструктурный стенд из одного YAML-манифеста: поднимает серверы в Hetzner Cloud, готовит окружение через SSH, рендерит конфиги приложений и запускает контейнеры через Podman/systemd.
Главная идея Stands Engine - управлять инфраструктурой через модель стенда, а не через набор разрозненных слоев.
Этот README даёт общий обзор проекта и быстрый старт. Исчерпывающие руководства собраны в разделе документации:
- описание переиспользуемых приложений;
- полный контракт манифеста стенда;
- настройка и эксплуатация движка.
В классической схеме один запуск обычно распадается на несколько инструментов: Terraform/Pulumi создает ресурсы, Ansible или shell-скрипты доводят серверы до нужного состояния, Docker/Kubernetes-манифесты запускают приложения. Связи между этими слоями часто живут в CI, документации или договоренностях команды.
Stands Engine делает стенд основной единицей описания. В одном YAML-манифесте фиксируются серверы, профили железа, пользователи, приложения, роли, порты, шаблоны конфигурации, хуки и размещение инстансов по узлам. Дальше движок сам раскладывает эту модель на provision, post-provision и application layer.
createсоздает реальные облачные ресурсы. Перед запуском проверьте токены, выбранные типы серверов, сеть, S3 backend Pulumi и стоимость ресурсов у провайдера.
flowchart LR
deps["app/registry manifests\nfrom_dep_manifest"]
manifest["stand.yml\nстенд, узлы, приложения, размещение"]
model["Stands Engine\nмодель стенда"]
provision["provision\nPulumi + Hetzner Cloud"]
config["config rendering\nMako templates"]
post["post-provision\nSSH + cloud-init + runtime setup"]
apps["app launch\nPodman + systemd"]
hooks["post-start hooks"]
result["running stand\nnodes + app instances"]
deps --> manifest
manifest --> model
model --> provision
provision --> config
provision --> post
config --> apps
post --> apps
apps --> hooks
hooks --> result
Один YAML описывает стенд как связанную модель, а Stands Engine переводит ее в конкретные действия: создать серверы, настроить узлы, отрендерить конфиги, запустить приложения и выполнить хуки.
- Нужно описывать dev, test или demo окружение как один воспроизводимый стенд, а не как набор несвязанных IaC, shell и app-манифестов.
- Важно видеть в одном месте, какие серверы нужны стенду, какие приложения на них живут, какие роли они выполняют и какие порты открывают.
- Хочется переиспользовать описания приложений между стендами, меняя только параметры запуска, набор инстансов и размещение по узлам.
- Нужен легкий слой управления стендом поверх Pulumi, SSH и Podman без полноценной платформы оркестрации.
Сейчас проект заточен под Hetzner Cloud, Pulumi Automation API, S3 backend для состояния, RHEL семейство Linux и Podman как runtime приложений.
Stands Engine берет модель стенда и проводит ее через несколько слоев исполнения:
- model layer: читает YAML, раскрывает вложенные
from_dep_manifestи валидирует связи между стендом, профилями узлов, приложениями, ролями, реестрами и инстансами; - provision layer: создает серверы Hetzner Cloud через Pulumi и привязывает к ним сеть, SSH key, cloud-init и labels стенда;
- post-provision layer: подключается по SSH, настраивает пользователей, firewalld, Podman, user systemd и сеть
app-net; - application layer: логинится в container registry, скачивает образы, рендерит Mako-шаблоны, загружает конфиги, запускает systemd user units и проверяет порты;
- hook layer: рендерит и выполняет post-start сценарии инстансов, если они объявлены в модели.
- Python
>=3.14 uv- Pulumi CLI в
PATH - аккаунт Hetzner Cloud и существующая SSH key в Hetzner
- существующая Hetzner network, указанная в
node_profiles.*.network - S3-совместимое хранилище для Pulumi state
- доступ к container registry, если образы закрытые
Зависимости Python описаны в pyproject.toml.
uv syncПосле установки CLI доступен через uv run:
uv run stands-engine --help
uv run stands-engine \
--resource project-assets=demo/resources \
validate demo/stand/stand.yml
uv run stands-engine \
--resource project-assets=demo/resources \
create demo/stand/stand.ymlПрежний вариант python main.py ... остается совместимым.
OCI image содержит Python, зависимости проекта, Pulumi CLI и hcloud provider plugin. На машине оператора достаточно Docker или Podman; Podman на целевых серверах устанавливается самим движком и не связан с runtime, используемым для запуска Stands Engine.
Локальная сборка:
docker build -f Containerfile -t stands-engine:local .
# или
podman build -f Containerfile -t stands-engine:local .Для повседневного запуска используйте launcher. Он автоматически выберет Podman или Docker, примонтирует текущий каталог только для чтения и сохранит ключи, configsets и connection output в .stands-engine/:
./stands-engine \
--env-file common.env \
--env-file stands/devBack.env \
--resource project-assets=demo/resources \
create demo/stand/stand.yml
./stands-engine --env-file common.env --env-file stands/devBack.env destroy demo/stand/stand.yml--env-file можно повторять. Файлы применяются слева направо, поэтому значения
из более позднего файла переопределяют одноимённые значения из предыдущих. Это
позволяет хранить общие настройки отдельно от настроек конкретного стенда.
Каталоги из других репозиториев подключаются как именованные read-only resources. Один resource можно использовать для нескольких hooks, указывая подкаталоги относительно его корня:
./stands-engine \
--env-file devBack.env \
--resource project-assets=/home/user/projects/payment-service/deploy/assets \
create demo/stand/stand.ymlМанифест обращается к такому каталогу через переносимый URI
resource://project-assets/...; launcher сам заменяет host path на путь внутри
контейнера. --resource можно повторять. При прямом запуске Python CLI синтаксис
тот же, но каталог читается непосредственно с host filesystem:
project-assets/
├── mongo/migrations/*.json
└── redpanda/acl-map.sh
uv run stands-engine \
--resource project-assets=/home/user/projects/payment-service/deploy/assets \
create demo/stand/stand.ymlЯвный выбор runtime или опубликованного image:
./stands-engine \
--runtime docker \
--image registry.example.com/stands-engine:0.1.0 \
--env-file devBack.env \
--resource project-assets=demo/resources \
create demo/stand/stand.ymlPowerShell на Windows, macOS или Linux:
.\stands-engine.ps1 create .\demo\stand\stand.yml -EnvFile dev.env `
-Resource "project-assets=.\demo\resources"
.\stands-engine.ps1 destroy .\demo\stand\stand.yml -EnvFile dev.envНесколько файлов в PowerShell передаются массивом в том же порядке приоритета:
.\stands-engine.ps1 create .\demo\stand\stand.yml `
-EnvFile common.env,stands\dev.env `
-Resource "project-assets=.\demo\resources"Launcher переопределяет локальные абсолютные пути из env-файла контейнерными:
STAND__PATH_TO_KEY=/data/keys/id_ed25519
STAND__PATH_TO_CONFIGSET=/data/configsets
OUTPUT__FILE_PATH=/data/outputСам devBack.env, другие *.env, приватные ключи, .git и локальные результаты исключены из build context и не копируются в image.
docker run --rm \
--env-file devBack.env \
-e STAND__PATH_TO_KEY=/data/keys/id_ed25519 \
-e STAND__PATH_TO_CONFIGSET=/data/configsets \
-e OUTPUT__FILE_PATH=/data/output \
-v "$PWD:/workspace:ro" \
-v "$PWD/.stands-engine:/data" \
-v "$PWD/demo/resources:/resources/project-assets:ro" \
registry.example.com/stands-engine:0.1.0 \
--resource project-assets=/resources/project-assets \
create /workspace/demo/stand/stand.ymlВ CI передавайте секреты через защищенные переменные pipeline. Для воспроизводимого запуска используйте version tag или digest, а не изменяемый latest.
Настройки читаются из переменных окружения или файла .env. Вложенные секции задаются через __.
HCLOUD__TOKEN=
S3__ACCESS_KEY=
S3__SECRET_KEY=
S3__REGION=
S3__ENDPOINT=
S3__BUCKET=
STAND__USER=
STAND__PASSPHRASE=
STAND__PATH_TO_KEY=
STAND__PATH_TO_CONFIGSET=
OUTPUT__CONSOLE=true
OUTPUT__CONSOLE_SECRETS=false
OUTPUT__FILE=false
OUTPUT__FILE_PATH=Где:
HCLOUD__TOKEN- токен Hetzner Cloud API.S3__*- backend Pulumi state.STAND__USER- владелец стенда; используется в имени backend-префикса и каталога configset.STAND__PASSPHRASE- passphrase для Pulumi secrets provider.STAND__PATH_TO_KEY- путь к приватному ключу стенда. Если файла нет, Stands Engine создаст ключ и сохранит его туда.STAND__PATH_TO_CONFIGSET- локальный каталог для отрендеренных конфигов приложений и хуков.OUTPUT__CONSOLE- печатать итоговую NDJSON-запись с данными подключения после успешногоcreate; по умолчаниюtrue.OUTPUT__CONSOLE_SECRETS- показывать настоящие password и URL в консоли; по умолчанию они заменяются на***.OUTPUT__FILE- сохранять полный форматированный JSON с данными подключения в файл; по умолчаниюfalse.OUTPUT__FILE_PATH- каталог для итогового JSON. Обязателен, еслиOUTPUT__FILE=true.
Строковые секреты можно не хранить непосредственно в YAML. Вместо значения укажите тег !secret и логическое имя:
preferences:
admin_user: cool_admin
admin_pass: !secret redis-admin-passwordStands Engine преобразует имя в верхний регистр, заменяет дефисы на подчёркивания и добавляет префикс SECRET_. Для примера выше процесс должен получить переменную SECRET_REDIS_ADMIN_PASSWORD:
export SECRET_REDIS_ADMIN_PASSWORD='change-me'Имя после !secret должно соответствовать шаблону [A-Za-z][A-Za-z0-9_-]*. Тег можно использовать для скалярного значения в основном манифесте или любом файле, подключённом через from_dep_manifest; использовать его для YAML-ключей, списков или mappings нельзя. Подставленное значение всегда имеет строковый тип и затем проверяется обычным валидатором манифеста.
Перед create движок проверяет сразу все ссылки. Отсутствующая или пустая переменная завершает команду до сборки стенда и облачных операций; сообщение содержит только имена переменных и пути в манифесте, но не их значения. При destroy можно не передавать секреты из preferences и registries.*.username/password, поскольку они не нужны для удаления. Секреты в структурных полях стенда и узлов остаются обязательными.
set -a
source devBack.env
set +aМеханизм защищает секреты от хранения в исходных YAML, но не шифрует их после подстановки: отрендерованные configset-файлы по-прежнему могут содержать открытые значения.
Демо разделено на описание стенда в demo/stand и подключаемые данные в demo/resources. Стенд поднимает Redpanda, Kafka UI, Redis и MongoDB на трёх серверах и использует публичные Docker Hub образы.
set -a
source devBack.env
set +a
python main.py \
--resource project-assets=demo/resources \
create demo/stand/stand.ymlУдаление стенда:
python main.py destroy demo/stand/stand.ymlCLI сейчас намеренно небольшой:
python main.py [--resource NAME=PATH] <validate|create|destroy> <path_to_stand_manifest>Манифест описывает желаемое состояние стенда целиком. Это не отдельный Terraform-файл, не inventory для post-provision и не deployment-манифест приложения, а связанная модель: какие узлы нужны, какие приложения существуют, какие инстансы приложений запущены и где они размещены.
Минимальная форма стенда:
version: 1
stand:
project: demo
env: test
users:
sudo: av.rybin
app: userapp
ssh:
key_name_admin: AVRybin
node_profiles:
default:
location: hel1
type_serv: cpx32
image: rocky-10
network: network-p2p
from_dep_manifest: ./app-registry/registries.yml
apps:
redis:
from_dep_manifest: ./app-registry/redis/app.yml
preferences:
admin_user: cool_admin_ui
admin_pass: "12345678"
instances:
master-redis:
role: master-redis
cpu: 1000
ram: 2048
oom_priority: 100
agents:
apps: []
nodes:
master-server:
profile: default
apps:
- master-redisКлючевые блоки:
stand- имя проекта и окружения, пользователи на сервере, имя SSH key в Hetzner.node_profiles- ресурсные профили узлов: location, type, image, network и опциональноapp_runtime.registries- реестры образов, на которые ссылаются приложения. Обычно подключаются черезfrom_dep_manifest; в одном стенде можно объявить несколько registry, а приложение выбирает нужный черезimage.registry.apps- каталог приложений стенда: образы, роли, порты, шаблоны, инстансы и параметры запуска.agents.apps- необязательный список инстансов, которые нужно запустить на каждой ноде стенда.nodes- размещение инстансов по конкретным узлам стенда.
from_dep_manifest можно использовать на любом уровне YAML mapping. Подключенный файл раскрывается на месте ключа, а относительные пути шаблонов и хуков нормализуются относительно файла, где они объявлены.
agents.apps использует те же имена инстансов, что и nodes.<node>.apps, но размещает их сразу на всех нодах:
apps:
dozzle:
from_dep_manifest: ./app-registry/dozzle/app.yml
instances:
dozzle:
role: logs-viewer
cpu: 500
ram: 512
agents:
apps:
- dozzleНа этапе build движок преобразует такой инстанс в обычные инстансы с именами <instance>--<node>, например dozzle--master-server. Эти имена используются как instance.name, имена контейнеров, ключи контекста apps, labels и директории configset. Исходного общего имени в собранной модели нет.
Agent-инстанс нельзя одновременно перечислять в nodes.*.apps или использовать как connection_instance. Сгенерированные имена должны быть уникальны и не могут совпадать с явно объявленными инстансами. Пустой список agents.apps разрешен; весь раздел agents можно не указывать.
Описание приложения - переиспользуемый фрагмент модели стенда. В нем фиксируются образ, роли, порты и шаблоны конфигурации, а конкретный стенд выбирает инстансы, preferences, hooks и размещение по узлам.
Пример:
version: 1
name: redis
image:
registry: docker
path: library/redis
version: 7.4.0-alpine3.20
roles:
master-redis:
ports:
- number: 6379
protocol: tcp
zone: internal
templates:
pod:
path: redis-instance.yml.mako
dest: /home/userapp/redis.yml
owner: userapp
mode: "644"В стенде после этого остаются только параметры конкретного запуска: preferences, instances, hooks, ресурсы и размещение по nodes. Для каждого инстанса обязательны cpu в millicpu и ram в десятичных мегабайтах. Опциональный oom_priority задает Linux OOM score adjustment в диапазоне от -1000 до 1000.
В demo registry-файле local показывает пример приватного insecure registry с логином и паролем; текущие demo-приложения используют его для загрузки образов.
Приложение может объявить отдельный Mako-шаблон с данными для подключения. В app.yml хранится только путь к нему:
connection: connection.json.makoОтносительный путь вычисляется от каталога app.yml. В манифесте стенда необходимо выбрать инстанс, IP-адрес и роль которого получит шаблон:
apps:
redis:
from_dep_manifest: ./app-registry/redis/app.yml
connection_instance: master-redisПример connection.json.mako:
<%!
import json
%>{
"endpoint": ${json.dumps(node.private_ip)},
"port": 6379,
"credentials": {
"user": ${json.dumps(cluster.preferences.admin_user)},
"password": ${json.dumps(cluster.preferences.admin_pass)}
},
"url": ${json.dumps("redis://" + node.private_ip + ":6379")}
}Шаблон получает тот же контекст node, instance, role, cluster и apps, что и шаблоны запуска. Результатом должен быть один JSON-объект с непустыми endpoint, credentials.user, credentials.password и портом от 1 до 65535. Поле url необязательно; внутри credentials можно добавлять параметры приложения.
После успешного запуска всех приложений и hooks Stands Engine объединяет результаты по именам приложений и печатает одну компактную NDJSON-запись. Поле id_stand совпадает с именем каталога configset: <user>_<project>_<env>. Например:
{"id_stand":"owner_demo_test","redis":{"endpoint":"10.0.0.2","port":6379,"credentials":{"user":"admin","password":"***"},"url":"***"}}Консольный результат по умолчанию маскирует password и весь URL. Файловый результат использует ту же структуру, но записывается как форматированный обычный JSON, всегда содержит реальные значения и создаётся с правами 0600, поэтому должен храниться как секрет. Имя файла формируется как <user>_<project>_<env>.json внутри OUTPUT__FILE_PATH.
Успешный destroy печатает отдельную запись:
{"id_stand":"owner_demo_test","operation":"destroy","status":"success"}Во время validate/create/destroy stdout зарезервирован для NDJSON-результатов. Успешный validate печатает {"operation":"validate","status":"success"}. Диагностика preflight, Pulumi, PyInfra и сообщения об ошибках отправляются в stderr. Коды завершения: 0 — успех, 1 — ошибка конфигурации или выполнения, 2 — неверный CLI-вызов, 130 — прерывание Ctrl+C. При ошибке stdout остаётся пустым.
Mako-шаблоны получают контекст:
node- сервер текущего инстанса, включая IP-адреса после создания;instance- текущий инстанс приложения;role- роль инстанса и ее порты;cluster- приложение/кластер, образ и общие preferences;apps- все инстансы стенда, чтобы сервисы могли ссылаться друг на друга.
Если у инстанса указан hooks, путь должен вести в директорию с hook.sh.mako. Файлы с суффиксом .mako рендерятся, остальные копируются без изменений; затем всё дерево загружается на сервер и hook.sh запускается после старта контейнера.
Перед сборкой стенда валидатор проверяет:
- наличие
stand,registries,apps,node_profiles,nodes; - обязательные поля
stand.project,stand.env,stand.users.*,stand.ssh.key_name_admin; - что каждый registry имеет
url, аusernameиpasswordзаданы вместе; - что образ приложения ссылается на существующий registry;
- что
app.nameсовпадает с ключом приложения; - что приложение с
connectionуказывает принадлежащий емуconnection_instance; - что инстансы ссылаются на существующие роли;
- что для каждого инстанса заданы положительные целые
cpuиram, а опциональныйoom_priorityнаходится в диапазоне от-1000до1000; - что узлы ссылаются на существующие профили;
- что каждый обычный инстанс размещен ровно на одном сервере;
- что
agents.appsссылается на уникальные существующие инстансы, не размещенные явно по нодам, а генерируемые имена не конфликтуют с другими инстансами; - что agent-инстанс не используется как
connection_instance; - что все обязательные ссылки
!secretимеют непустые значения в окружении.
Ошибки печатаются с путем к проблемному месту, например manifest.apps.redis.instances.master-redis.role.
- main.py - CLI-точка входа.
- ManifestParser - parsing model: чтение YAML, раскрытие зависимых манифестов и проверка входного контракта.
- StandBuilder - build model: преобразование проверенного словаря в объект стенда.
- StandFramework - stand lifecycle: provision, render configset, configure runtime, launch apps.
- InfraBaseLib - provider и SSH primitives: Pulumi, Hetzner, cloud-init, upload operations.
- ShellCollect - post-provision и runtime-команды для настройки узлов и запуска приложений.
- App - доменные модели приложений, ролей, образов и шаблонов.
- demo - пример стенда и реестр приложений.
Проект находится в активной разработке. Формат манифеста и внутренние API еще могут меняться.
Ближайшие задачи:
- стабилизировать входной контракт модели стенда;
- аккуратнее описать кастомизацию серверных профилей;
- яснее описать границы provider/runtime слоев;
- вынести секреты из демо и улучшить модель их передачи;
- расширить документацию по шаблонам, хукам и переиспользованию приложений.
Проект распространяется по лицензии Apache License 2.0. Информация об авторских правах приведена в файле NOTICE.