No description
  • Haskell 98.3%
  • Nix 1.7%
Find a file
Alexandr_Croitor f66e0465ac
All checks were successful
CI / linux (push) Successful in 1m19s
feat: add virtualization module
2026-06-28 19:10:04 +03:00
.forgejo/workflows fix: no --all-systems 2026-06-06 17:45:03 +03:00
app chore: strip file I/O from frogos-dsl-compile 2026-06-28 14:18:57 +03:00
src feat: add virtualization module 2026-06-28 19:10:04 +03:00
test feat: add virtualization module 2026-06-28 19:10:04 +03:00
.gitignore test: add QuickCheck property suite with shrinking 2026-06-17 16:12:23 +03:00
.stan.toml chore(stan): suppress STAN-0206 via .stan.toml; drop --no-default from AGENTS.md 2026-06-21 15:24:02 +03:00
AGENTS.md feat: state-mutating actions carry priority 2026-06-22 22:02:00 +03:00
cabal.project choire: update IR 2026-06-10 17:39:49 +03:00
CHANGELOG.md feat: add virtualization module 2026-06-28 19:10:04 +03:00
CLAUDE.md choire: add AGENTS for coding 2026-06-01 13:47:42 +03:00
CODESTYLE.md refactor(module): unify policy derivation via policiesFromPortSpecs 2026-06-21 16:05:38 +03:00
DSL.cabal feat: add virtualization module 2026-06-28 19:10:04 +03:00
flake.lock feat: add virtualization module 2026-06-28 19:10:04 +03:00
flake.nix refactor: extract IR types to frogos-ir package 2026-06-10 00:36:19 +03:00
LICENSE Initial commit 2026-05-27 17:21:51 +03:00
README.md refactor(module): replace hardcodedModuleName with moduleDomainName 2026-06-21 15:39:29 +03:00

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-документа, не меняя схему существующих областей.

Валидация

Валидация выполняется на двух уровнях:

  1. Типы Haskell запрещают структурно невозможные конструкции во время компиляции. Например, условие нельзя использовать как действие, а стратегия наблюдения должна применяться только к наблюдаемому условию.
  2. Smart constructors проверяют имена, диапазоны, polling-интервалы и непустые network-списки. configuration возвращает первую ошибку как Either DomainError BuildState.
  3. Чистый валидатор проверяет дубликаты и конфликты после построения конфигурации. 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 принимают явный приоритет (0100). Когда два профиля конфликтуют на одном модуле, побеждает больший приоритет; равные приоритеты — ошибка валидации. 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 закрытые: движок знает все варианты заранее, открытых точек расширения нет. Это позволяет исчерпывающе обрабатывать каждый тип узла без вырожденных случаев.