- Haskell 98.3%
- Nix 1.7%
|
|
||
|---|---|---|
| .forgejo/workflows | ||
| app | ||
| src | ||
| test | ||
| .gitignore | ||
| .stan.toml | ||
| AGENTS.md | ||
| cabal.project | ||
| CHANGELOG.md | ||
| CLAUDE.md | ||
| CODESTYLE.md | ||
| DSL.cabal | ||
| flake.lock | ||
| flake.nix | ||
| LICENSE | ||
| README.md | ||
DSL
Проект описывает configuration.frog, использующий Haskell в качестве EDSL
операционной системы FrogOS.
Haskell выбран не ради экзотики — его типовая система позволяет встроить часть верификации в сам язык: структурно невалидная конфигурация не компилируется. Синтаксис намеренно минималистичен: порог вникания соизмерим с Nix.
Импорты
configuration.frog использует обычные явные импорты Haskell. Пользователь
подключает только необходимые DSL-модули и функции; библиотека не внедряет
скрытый prelude и не добавляет импорты автоматически:
import DSL (configuration, profile, when, systemPackages)
import Condition (poll, processRunning, via)
import Power (PowerProfile (Performance), setPowerProfile)
import Module (disable, nginx)
import Module.Nginx (nginxModule)
import Port (port, withFallback)
Отдельной системы DSL-импортов в первой версии нет. Разбиение конфигурации на файлы выполняется обычными Haskell-модулями с явными импортами.
Зачем Haskell
Haskell выбран не ради экзотики — его типовая система позволяет встроить часть верификации в сам язык: структурно невалидная конфигурация не компилируется. Синтаксис намеренно минималистичен: порог вникания соизмерим с Nix.
via — стратегия наблюдения — применяется только к Condition. Применение к
Action — ошибка компиляции: у действия нет семантики подписки.
-- ok: наблюдение за процессом через polling
when (processRunning "steam" `via` poll 500) $ ...
-- ошибка типов: Builder scope () не наблюдается
disable nginx `via` poll 500
-- ошибка типов: when ожидает Either DomainError Condition, получает Builder scope ()
when (disable nginx) $ ...
IR
DSL описывает намерения пользователя, а не план выполнения. Код на Haskell компилируется в декларативный IR, который затем читает движок FrogOS (Planner + Executor).
Доменные smart constructors проверяют значения во время построения DSL. Семантический валидатор проверяет целостность документа и отсутствие конфликтов между узлами. Движок проверяет динамическое окружение: доступность ресурсов и состояние системы в момент применения. Он также выбирает порядок применения и интерпретирует fallback в рамках уже проверенных намерений. Недоступность ресурса во время применения не считается статическим конфликтом конфигурации.
IR организован в области намерений: profiles, modules и другие будущие
разделы. Каждая область содержит узлы трёх типов:
Condition— boolean-триггер. Отвечает на вопрос "когда это актуально". Пример:when (processRunning "steam").Policy— правило выбора или ограничение с вариантами. Не исполняет действие напрямую, не принимает решение, а задаёт ограничения и возможные действия для движка. Пример:fallback,allowPorts.Action— желаемое изменение системы, которое может быть исполнено движком. Пример:disable nginx,setPowerProfile Performance.
Все типы могут использоваться совместно в любой области. Система не ограничивает администратора в описании намерений — пока реализация возможна.
Новые области намерений (например network, security) добавляются как новые
ключи в корень IR-документа, не меняя схему существующих областей.
Валидация
Валидация выполняется на двух уровнях:
- Типы Haskell запрещают структурно невозможные конструкции во время компиляции. Например, условие нельзя использовать как действие, а стратегия наблюдения должна применяться только к наблюдаемому условию.
- Smart constructors проверяют имена, диапазоны, polling-интервалы и непустые
network-списки.
configurationвозвращает первую ошибку какEither DomainError BuildState. - Чистый валидатор проверяет дубликаты и конфликты после построения
конфигурации.
validateвозвращаетEither ValidationError ValidatedIR.
ProfileBuilder и ConfigBuilder разделены. Условие можно задать только в
profile через when; модули объявляются на уровне configuration и не
поддерживают условий. IRDocument извлекается через getIRDocument только после
успешной семантической валидации.
Архитектура
IR использует закрытые корневые типы Condition, Policy и Action. Каждый
корневой тип объединяет узлы из отдельных доменных модулей:
data Action
= ModuleAction Module.Action
| PowerAction Power.Action
data Policy
= NetworkPolicy Network.Policy
data Condition
= ProcessCondition Process.Condition
| SystemCondition System.Condition
| And Condition Condition
| Not Condition
Домены изолируют собственные типы и правила проверки. Корневые модули объединяют их в один документ и выполняют проверки конфликтов между доменами.
Пакет frogos-ir (отдельный репозиторий) содержит IR-типы и JSON-кодирование:
IR -- корневые суммы Action, Condition, Policy
Types -- IRDocument, ProfileSection, ModuleMap, IRVersion
ObserveStrategy -- ObserveStrategy, IntervalMs
Domain.Error -- DomainError
Domain.Process -- ProcessName, AppName, ProcessRunning, AppRunning
Domain.System -- CpuLoad, BatteryBelow, BatteryAbove
Domain.Power -- PowerProfile, SetPowerProfile
Domain.Network -- Port, AllowPorts, Fallback
Domain.Package -- PackageName
Domain.Module -- ModuleRef, ModuleDomain, EnableModule, DisableModule, RestartModule
Domain.Module.Nginx -- NginxConfig
Domain.Module.Nginx.VirtualHost -- NginxVirtualHost
Domain.Module.PostgreSQL -- PostgreSQLConfig
Domain.Module.Forgejo -- ForgejoConfig
DSL-пакет (этот репозиторий) предоставляет публичный API конфигурации:
DSL -- configuration, profile, when, systemPackages, servicePackages, BodyBuilder; re-exports validate
Builder -- Builder, ProfileBuilder, BodyBuilder, ConfigBuilder
Types -- BuildState
Condition -- appRunning, processRunning, cpuLoad, batteryBelow, batteryAbove, not, (<&&>), poll, via, ObserveStrategy
Module -- enable, enableWithPriority, disable, disableWithPriority, restart, nginx, postgresql, forgejo
Module.Nginx -- NginxBuilder, nginxModule, enable, virtualHost
Module.Nginx.VirtualHost -- NginxVHostBuilder, httpPort, httpsPort, domain, proxyPass
Module.PostgreSQL -- PostgreSQLBuilder, postgresqlModule, enable, port, dataDir, maxConnections
Module.Forgejo -- ForgejoBuilder, forgejoModule, enable, httpPort, sshPort, domain
Port -- PortSpec, port, withFallback
Power -- setPowerProfile, PowerProfile (..)
Network -- portResource, allowPorts, fallback
Validate -- validate, ValidatedIR, getIRDocument, ValidationError, SectionKind, Location, LocalError
Compile -- compile, compileResult
Diagnostics -- renderDomainError, renderValidationError
Построение конфигурации проходит последовательно:
DSL
→ BuildState
→ smart constructors доменных значений
→ глобальный валидатор конфликтов
→ IR
→ JSON
Конструктор проверенного IR не входит в публичный API. Создать IR можно
только через validate, поэтому сериализатор никогда не получает непроверенную
конфигурацию.
Новый домен добавляется явно: новый доменный модуль подключается к закрытым корневым типам, регистрирует доменный валидатор и JSON-кодирование. Если его узлы взаимодействуют с существующими доменами, добавляются глобальные правила конфликтов. Существующие доменные типы при этом менять не требуется.
Граница расширяемости
Пользователь конфигурации может создавать именованные композиции встроенных
Condition, Policy и Action, но не новые примитивные узлы. Например,
gaming — пользовательское имя для композиции processRunning и cpuLoad, а
не новый тип условия.
Новый примитив добавляется как возможность платформы. Его реализация требует согласованных изменений в DSL, доменном валидаторе, схеме IR и движке FrogOS, который интерпретирует узел. Неизвестный тип узла считается несовместимой версией IR. Пользовательские плагины и произвольные примитивы не входят в первую версию.
Условия (Condition)
Condition — это предикат над наблюдаемым состоянием системы. Пользователь
задаёт имена и комбинирует встроенные условия. gaming не встроен — это просто
имя композиции:
gaming :: Either DomainError Condition
gaming = processRunning "steam" <&&> cpuLoad 0.7
batteryLow :: Either DomainError Condition
batteryLow = batteryBelow 20
Конструкторы условий возвращают Either DomainError Condition — ошибки
проверки значений (пустое имя, недопустимый порог) видны в момент построения.
Булевы операторы (<&&>) и not сохраняют тип Either DomainError Condition,
поэтому ошибка в любом листе поднимается наружу.
Доступны два вида условий на процессы: appRunning соответствует FrogOS-приложению
по имени приложения; processRunning — низкоуровневый escape hatch, соответствующий
процессу по точному имени в таблице процессов.
Композиция условий через аппликатив — все источники наблюдения известны статически, что позволяет движку оптимизировать подписки заранее.
Стратегии наблюдения (via)
Каждое условие декларирует не только что наблюдать, но и как:
processRunning "steam" `via` poll 500 -- polling каждые 500ms
cpuLoad 0.7 `via` poll 1000 -- polling каждую секунду
Стратегия наблюдения — это hint для планировщика событий в движке. Движок подписывается на источники лениво: по мере вычисления цепочки условий.
via применяется только к Condition. Применение к Builder scope () — ошибка
компиляции: у действия нет семантики подписки.
Когда via применяется к составному условию (And, Not), стратегия рекурсивно
устанавливается на всех листьях. Если хотя бы один лист уже имеет стратегию,
выражение возвращает Left StrategyConflict; существующая стратегия не
перезаписывается.
-- ошибка типов: Builder scope () не наблюдается
disable nginx `via` poll 500
-- ошибка типов: when ожидает Either DomainError Condition, получает Builder scope ()
when (disable nginx) $ ...
Профили и модули
Пример DSL — профиль с условием, действиями и объявление модуля:
configuration $ do
nginxModule (pure ())
profile "gaming" $
when (processRunning "steam" `via` poll 500) $ do
disable nginx
setPowerProfile Performance
Пример DSL — объявление серверного стека через модульные билдеры:
configuration $ do
nginxModule $ do
enable True
virtualHost "example.com" $ do
httpPort (80 `withFallback` [800])
httpsPort (443 `withFallback` [80])
postgresqlModule $ do
enable True
port (port 5432)
dataDir "/var/lib/postgresql/data"
maxConnections 100
forgejoModule $ do
enable True
httpPort (port 3000)
sshPort (port 2222)
domain "git.example.com"
Профиль хранит одно опциональное условие, список политик, список действий и
список пакетов. Модули объявляются на уровне configuration и описывают статическую
конфигурацию сервиса: порты, директории, виртуальные хосты. Условие в модуле не
поддерживается — when возвращает ProfileBuilder, а не ConfigBuilder.
Объявление модулей и синглтоны
Каждый модуль — синглтон: nginxModule, postgresqlModule, forgejoModule можно
объявить только по одному разу в конфигурации. Повторное объявление одного и того
же модуля даёт DuplicateModuleName.
Действия enable и disable в профилях ссылаются на модуль через ModuleRef
(например, nginx, postgresql, forgejo). Каждый модуль, на который нацелено
такое действие, должен быть объявлен через соответствующий конструктор. Необъявленная
цель даёт UndeclaredModuleTarget. Как в NixOS: не объявлено — не управляется.
enableWithPriority и disableWithPriority принимают явный приоритет (0–100).
Когда два профиля конфликтуют на одном модуле, побеждает больший приоритет;
равные приоритеты — ошибка валидации. enable и disable без суффикса используют
приоритет 100.
Пакеты
systemPackages объявляет пакеты, устанавливаемые глобально для всей конфигурации.
servicePackages объявляет пакеты, доступные только пока активен соответствующий
профиль:
configuration $ do
systemPackages ["steam"]
profile "gaming" (servicePackages ["wine"])
Политики (Policy)
Policy представляет декларативное ограничение или пространство допустимых
решений. Многие Policy можно выразить через набор Condition + Action, но не
все: fallback несёт собственную семантику выбора — "предпочти первый вариант,
при недоступности выбери из остальных" — которую Condition и Action сами по
себе не выражают. Policy сохраняется как отдельный тип, чтобы Executor мог
принимать решение самостоятельно, зная намерение целиком.
Цена Policy — меньшая гибкость: автор DSL не обязан описывать все причины условной ошибки и все точные ветки решения, но конкретную интерпретацию выбирает Executor.
Сетевые политики (allowPorts, fallback) автоматически выводятся модульными
билдерами из заданных портов. Явно добавить политику в профиль или тело when
можно через модуль Network.
IR-документ
В IR все области живут в одном документе с намерениями:
{
"version": 6,
"modules": {
"nginx": [
{
"name": "nginx",
"enable": true
}
]
},
"profiles": [
{
"name": "gaming",
"condition": {
"type": "process_running",
"name": "steam",
"observe": {
"type": "poll",
"interval_ms": 500
}
},
"actions": [
{
"type": "module_disable",
"module": { "domain": "nginx", "name": "nginx" },
"priority": 100
},
{ "type": "power_profile", "value": "performance" }
],
"policies": []
}
]
}
Такой IR сериализуемый, валидируемый и контролируемо расширяемый разработчиками платформы: новые условия, политики и действия добавляются в доменные типы, а новые домены явно подключаются к закрытым корневым типам без изменения схем существующих доменов.
Пайплайн и хранение
DSL → Compile → IR (JSON) → Planner (Rust) → Plan (JSON) → Executor
Compile.compile принимает Either DomainError BuildState,
прогоняет через validate и выводит компактный JSON на stdout; при ошибке
выводит сообщение в stderr и завершается с ненулевым кодом.
compileResult — чистое ядро для тестов и библиотечных потребителей.
Planner — FFI-библиотека на Rust. Граница между DSL-компилятором и Planner'ом пересекается через JSON: IR сериализуется перед передачей. Plan также передаётся как JSON.
Каждое поколение конфигурации содержит три артефакта:
intent.json— сериализованный IR, переданный Planner'у.plan.json— дерево изменений, построенное Planner'ом. Версионируется черезIRVersion.log— лог исполнения Executor'а.
Это даёт четыре свойства:
- Diff бесплатно. Разница между поколениями видна через
diff gen-41/intent.json gen-42/intent.json. - Текущее поколение. Симлинк
current/указывает на активное поколение. - Rollback через Planner. Откат читает
intent.jsonнужного поколения и передаёт его Planner'у для пересчёта плана. - Независимость формата хранения. Схема
intent.jsonможет меняться независимо от внутреннего представления IR.
Plan — дерево разницы между текущим состоянием системы и ожидаемым, построенное Planner'ом. Executor исполняет план последовательно, сохраняя в памяти исходное состояние только тех ресурсов, которых касается план.
Часть Condition и Policy (в том числе fallback) может быть разрешена
только в момент исполнения — например, доступность порта неизвестна до попытки
его занять. Такие узлы передаются Executor'у вместе с планом: Executor сам
определяет нужное действие на основе наблюдаемого состояния и при необходимости
использует сохранённое исходное состояние для отката.
IRVersion
IR содержит явную версию (IRVersion) — инкрементальный счётчик. При
несовпадении версии движок отказывается читать документ и откатывает состояние.
Расширение набора узлов (новые Condition, Policy, Action) всегда сопровождается
сменой IRVersion.
Суммы Condition, Policy и Action закрытые: движок знает все варианты
заранее, открытых точек расширения нет. Это позволяет исчерпывающе обрабатывать
каждый тип узла без вырожденных случаев.