Перейти к основному содержимому

ADR-0001 · YAML configuration with env interpolation

Статус: accepted v2.0 (2026-04-19; v1.0 — 2026-04-17) · Полный нормативный текст

Зачем нужна единая спецификация конфига

Приложения на dagstack пишутся на разных языках (Python, TypeScript, Go) и используют разные библиотеки конфига по умолчанию (python-dotenv, dotenv, viper). Результат: оператор видит разную модель конфигурации в каждом приложении, перенос знаний между ними невозможен, разделение dev/staging/production делается ad-hoc.

Опыт соседних экосистем решает похожие задачи одинаково: YAML + env-интерполяция + деплой-слои + типизированный доступ через нативные модели языка:

  • @backstage/config (Spotify) — YAML + env + deep merge + hot-reload.
  • Spring Bootapplication.yml + profiles + @ConfigurationProperties.
  • HashiCorp Viper — multi-format + env-переопределения + struct-unmarshal.

ADR-0001 фиксирует эту модель как контракт, не зависящий от языка для dagstack-экосистемы — формат передачи и поверхность API одинаковы в Python / TypeScript / Go.

Семь ключевых решений

1. YAML как первичный формат передачи

YAML 1.2 (совместимый с YAML 1.1 парсерами) — один и тот же файл консистентно читается во всех реализациях. JSON допускается как эквивалент (YAML 1.2 — надмножество JSON), для сред без YAML-парсера.

2. Синтаксис env-интерполяции

${VAR} → значение VAR; если не задана — ConfigError(env_unresolved)
${VAR:-default} → значение VAR; если не задана или пустая — literal "default"

Семантика:

  • Интерполяция при загрузке файла, до слияния и до приведения типов.
  • Интерполированное значение всегда — строка. Типизация — в типизированном методе доступа.
  • Escape $$$.
  • Значение по умолчанию — литеральная строка; вложенные ${...} в значениях по умолчанию не интерполируются.

3. Слои конфига и правила слияния

Три слоя в порядке приоритета:

  1. app-config.yaml — base (коммитится).
  2. app-config.local.yaml — переопределения разработчика (gitignored).
  3. app-config.${DAGSTACK_ENV}.yaml — специфичные для окружения (коммитятся).

DAGSTACK_ENV, не APP_ENV / NODE_ENV — env с пространством имён, чтобы избежать коллизий в мульти-фреймворк-развёртываниях.

Стратегия слияния:

  • Объекты (maps) — рекурсивное глубокое слияние.
  • Массивы — атомарная замена, не конкатенация.

Составные профили (в стиле Spring Boot prod,us-east) — отложены в Phase 2.

4. API доступа к конфигу

Абстрактный псевдокод контракта (реализации воплощают идиоматично):

Config.load(path): Config # обёртка над loadFrom([YamlFileSource(path)])
Config.load(paths[]): Config
Config.loadFrom(sources[]): Config

config.has(path): boolean
config.get(path): Value
config.getString(path, default?): string
config.getInt(path, default?): int
config.getNumber(path, default?): float
config.getBool(path, default?): boolean
config.getList(path): Value[]

config.getSection(path, schema): TypedObject
  • path — через точку (database.host, plugins[0], labels.kubernetes\.io/zone).
  • getSection использует нативную schema: pydantic / zod / struct-tags.
  • Правило изоляции: компонент читает только свою секцию.

Naming convention — по идиоматике языка. ADR задаёт семантику, конкретный case биндинг выбирает по native style:

  • Pythonsnake_case: config.get_string, config.get_int, config.get_section, config.on_section_change, Config.load_from.
  • TypeScriptcamelCase: config.getString, config.getInt, config.getSection, config.onSectionChange, Config.loadFrom.
  • GoPascalCase (public API): cfg.GetString, cfg.GetInt, cfg.GetSection, cfg.OnSectionChange, config.LoadFrom. Для default- семантики Go добавляет суффикс Default (cfg.GetIntDefault(path, 10)) вместо default-параметра, чтобы не нарушать однозначность сигнатур.

Выбор sync/async — зависит от реализации. Python — sync (Phase 1); TypeScript — async (Promise<Config> из Config.load); Go — sync с context.Context первым аргументом и возвратом error. Реализация фиксирует выбор в ADR конкретного языка и conformance runner.

5. Модель ошибок

ConfigError {
path: string # путь ошибки через точку
reason: ConfigErrorReason # enum, фиксирован в _meta/error_reasons.yaml
details: string # human-readable message
source_id?: string # какой ConfigSource, если применимо
}

ConfigErrorReason ∈ {
missing, type_mismatch, env_unresolved, validation_failed,
parse_error, source_unavailable, reload_rejected
}

Идиоматическая реализация: Python raise, TypeScript throw, Go return (value, error), Rust Result<T, ConfigError>, Java throw. Имя типа по соглашению, поля обязательны.

6. Абстракция ConfigSource

Любой источник реализует минимальный интерфейс:

id: string
load(): ConfigTree
watch?(callback): Subscription # опционально
close?(): void # опционально

Встроенные источники Phase 1: YamlFileSource, JsonFileSource, in-memory источник (InMemorySource в Python/TypeScript, DictSource в Go — историческое расхождение имени, семантика идентична).

Адаптеры Phase 2+ в дорожной карте: EtcdSource, ConsulSource, VaultSource, HttpSource, SqlSource, KubernetesSource.

7. Подписки / реактивная перезагрузка

config.onSectionChange(path, schema, callback) → Subscription {
unsubscribe(): void # идемпотентно
active: boolean
inactive_reason: string | null
path: string
}

Поведение:

  • Если ни один источник не поддерживает watch → подписка принимается, но active=false с inactive_reason = "subscription_without_watch".
  • Атомарный откат на уровне всего дерева: если любая подписанная секция валидируется неудачно — вся операция перезагрузки откатывается, ни один подписчик не уведомляется.
  • Отписка идемпотентна; callback не вызывается после unsubscribe даже для уже запущенных циклов перезагрузки.
  • Callbacks fire-and-forget (sync или async — зависит от реализации).

Это — ключевая защита от скрытых багов: приложение одинаково работает на источниках с watch и без (только active меняется).

Пример конфига с тремя слоями

app-config.yaml
database:
host: "${DB_HOST:-localhost}"
port: "${DB_PORT:-5432}"
name: "orders"
user: "${DB_USER}"
password: "${DB_PASSWORD}"
pool_size: 20

cache:
url: "${REDIS_URL:-redis://localhost:6379/0}"
ttl_min: 15

api:
port: 8080
request_timeout_s: 30
app-config.local.yaml (gitignored)
database:
pool_size: 5 # меньше соединений локально
api:
request_timeout_s: 120 # медленный отладчик
app-config.production.yaml
database:
host: "prod-db.internal.example.com"
pool_size: 100
cache:
url: "redis://prod-cache.internal.example.com:6379/0"
main.py
from dagstack.config import Config

config = Config.load("app-config.yaml")
# DAGSTACK_ENV=production → итоговый конфиг:
# database.host = "prod-db.internal.example.com" (из production)
# database.pool_size = 5 (из local; production не перекрыл)
# database.port = 5432 (из base)
# cache.url = "redis://prod-cache.internal.example.com:6379/0"
# api.request_timeout_s = 120 (из local)

Последствия

Положительные:

  • Оператор видит одну модель конфига независимо от языка приложения.
  • Секреты стандартно передаются через env-интерполяцию — не коммитятся в git.
  • Hot-reload поддержан без ломающих изменений API при переключении источника (YamlFileSource → EtcdSource — код плагина не меняется).
  • Атомарный откат защищает от частичного применения невалидных конфигов.

Компромиссы:

  • Ограничение на вложенные значения по умолчанию (${VAR:-${OTHER}} не работает) — упрощает парсер, но иногда требует двухэтапного подхода в shell-скрипте.
  • Стратегия полной замены массивов — файл-переопределение всегда указывает массив целиком; нет синтаксиса «добавить элемент».
  • Выбор sync/async на стороне реализации — добавляет документационное разночтение между реализациями («в Python это async, в Go sync») — смягчается отдельным ADR-дополнением для каждого языка.

Что запрещено этим ADR:

  • Реализация не может ввести свою парадигму слияния (deep-merge для массивов, shallow для вложенных объектов) — нормативно фиксируется глубокое слияние для объектов, замена для массивов.
  • ConfigError без path / reason / details — обязательные поля.
  • Тихий резервный возврат пустой строки при отсутствии env-переменной без значения по умолчанию — ошибка, плагин не стартует.

Связанные ADR и спеки

  • plugin-system-spec ADR-0006 § discovery — discover использует config для plugin_dirs.
  • logger-spec — читает секцию logging через config.
  • tenancy-spec — читает секцию tenancy.

Нормативный источник

Полный текст с формальными правилами канонического JSON (подмножество RFC 8785), артефактами спецификации, дорожной картой адаптеров: config-spec/adr/0001-yaml-configuration.md.