Как устроено
Устройство
Тонкий слой поверх restic: порты и адаптеры, два класса источников, путь данных.
Unruin — тонкий оркестратор поверх restic. Сам он данные не переносит и не шифрует: это делает restic. Unruin решает, что запускается, где, когда, с какими секретами, и записывает, что получилось.
Слои (предметная область, порты и адаптеры)
┌────────────────────────── cmd/unruin ──────────────────────────┐
│ validate · run · serve · prune │
└───────────────────────────────┬──────────────────────────────────┘
│ зовёт
┌───────────────────────────────▼──────────────────────────────────┐
ПРИЛОЖЕНИЕ │ internal/app │
│ Runner (одно задание) Daemon (цикл cron) BuildRepos / Plan │
└───────┬───────────────┬───────────────┬───────────────┬───────────┘
│ │ │ │
┌────────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼─────────┐
АДАПТЕРЫ │ secrets │ │ ssh │ │ restic │ │ store (SQLite) │
│ kr/env/file │ │ построение │ │ движок │ │ история │
│ resolver │ │ команд │ │ + адреса │ │ состояние cron │
└────────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬─────────┘
│ │ │ │
┌────────▼───────────────▼───────────────▼───────────────▼─────────┐
ПРЕДМЕТНАЯ │ internal/domain (чистая, без сторонних зависимостей) │
ОБЛАСТЬ │ SecretRef · Secret · перечисления · Rotation · Run · порты │
└──────────────────────────────────────────────────────────────────┘
internal/config разбор jobs.yaml → типизированная конфигурация (YAML живёт только здесь)
internal/source StreamProducer (ExecProducer) + сборка удалённых команд
internal/pipeline источник → копия → параллельная рассылка, свод статуса
internal/scheduler чистое решение по cron (on_missed / on_overlap)
Предметная область не импортирует ни библиотеку YAML, ни драйвер базы, ни restic. Каждый внешний инструмент стоит за портом (интерфейсом), который она объявляет, а реализуют его адаптеры. Именно поэтому опасную логику можно покрыть модульными тестами с заглушками.
Два класса источников
restic снимает копию либо со стандартного ввода (поток), либо с пути (папка, которую он читает локально). Это по-настоящему разные пути данных, и они описаны двумя портами:
ПОТОК (postgres / mysql / command) ПУТЬ (files / docker_volume)
─────────────────────────────────────────── ─────────────────────────────────────
цель: pg_dump / mysqldump / команда цель: restic backup <путь>
│ stdout по SSH (pipefail) │ пишет прямо в хранилище
▼ ▼
оркестратор: restic backup --stdin ──► основное (доступ только на дозапись; бинарник
(байты идут через конвейер, не на диск) restic доставлен и сверен по сумме)
Поток гонит байты через конвейер оркестратора и никогда не кладёт их на диск. Путь
запускает restic на самой цели, поэтому дедупликация по файлам сохраняется, а
байты идут напрямую с цели в хранилище. Перед запуском источника-пути Unruin
щупает цель на предмет restic и, если его там нет, сам доставляет свой
статический бинарник в ~/.cache/unruin/restic по SSH (с проверкой
архитектуры; когда restic уже есть, всё это незаметно). Итог: для источников-путей
на цель ставить нечего, а источники-потоки restic на цели вообще не трогают.
Отказаться — UNRUIN_NO_RESTIC_PUSH=1.
Несколько хранилищ = основная копия + restic copy
restic за один раз пишет в одно хранилище. «Отправить в MinIO и в Seafile
и на SFTP» — это не тройник, а одна копия в основное хранилище и затем
параллельные restic copy в каждое следующее (параметры нарезки согласованы
при создании, поэтому дедупликация сохраняется).
источник ──► [основное]
│ restic copy (параллельно, с ограничением)
┌────────┼────────┐
▼ ▼ ▼
[вторая ][третья ][четвёртая]
Статус запуска сводится так: не сделалась основная → failed; у основной код возврата 3 → partial; не дошло обязательное хранилище → failed; не дошло по возможности → partial; всё чисто → success.
Путь, от которого зависит безопасность
producer.Start ──► restic backup --stdin ──► ждём источник
│
┌──────────────────────────────────────┤
источник УПАЛ источник в порядке
(pg_dump умер на середине) │
→ запуск НЕУДАЧЕН код 0 → всё хорошо
→ забыть обрезанную точку код 3 → с оговорками
→ НЕ рассылать дальше │
→ параллельная рассылка копий
Код возврата источника — главный: точка восстановления, снятая с обрезанного
дампа, не имеет права засчитаться за успех. Это обеспечивает internal/pipeline, и
это доказано интеграционным тестом против настоящего restic.
Секреты
В конфигурации только ссылки (kr://имя#поле, env://ПЕРЕМЕННАЯ,
file:///путь), никогда значения. В момент запуска Resolver направляет каждую
ссылку по схеме: kr:// → встроенная связка ключей на AES-256-GCM (хранилище по
умолчанию, открывается UNRUIN_MASTER_PASSPHRASE; необязательное #поле
выбирает одно значение внутри имени), env:// → окружение (там живёт секрет
аварийного конверта, который обязан быть вне kr://, чтобы пережить потерю
Unruin), file:// → содержимое файла (секреты Docker или k8s). Разрешённое
значение — тип Secret, который при печати выводит [REDACTED].
Наименьшие права: по умолчанию один доступ access делает всё. Когда нужно
разделение, цели и restic на них получают доступ только на дозапись
(access), а уборка идёт отдельным привилегированным доступом (prune_access),
который живёт только у оркестратора и никогда не уезжает на цель. Взломанный VPS
может дописывать, но не может удалить историю копий.
Расписание и состояние
internal/scheduler.Decide — чистая функция: расписание cron, время прошлого
срабатывания, сейчас, политики, идёт ли запуск → выполнять или пропустить и какое
время срабатывания сохранить. Daemon тикает раз в интервал и проверяет каждое
задание с расписанием. Наложение отсекается и внутри процесса (список идущих,
занимается атомарно), и поверх перезапусков и процессов
(RunStore.ActiveRun, с проверкой pid владельца — чтобы осиротевшая запись
упавшего запуска не блокировала планирование навсегда). История запусков и время
последнего срабатывания по заданию лежат в SQLite (store, режим WAL, один
писатель).
Тот же цикл ведёт и уборку по расписанию: заданию с prune_schedule (общим
или своим) демон применяет правила хранения привилегированным доступом, в очереди
с копированием по метке занятости хранилища и со своим временем последнего
срабатывания. Так хранилища не растут бесконечно без присмотра. Предел времени
запуска (run_timeout, у задания — timeout, по умолчанию щедрый) убивает
зависший запуск и записывает его неудачным; итоговая запись в историю идёт на
отдельном контексте, поэтому сбой всё равно фиксируется. Живость SSH ловит цель,
умершую посреди копирования; по SIGTERM демон сначала перестаёт принимать новые
запуски и только потом доводит до конца идущие.
Оповещения
После каждого запуска Runner отправляет domain.Notification, если настроен
Notifier и задание подписано на такой исход (notify.on, по умолчанию
[failed, partial]). В первой версии есть один канал — вебхук
(internal/notify), и его JSON несёт сразу text, content и message, чтобы
одно и то же тело подошло ntfy, Slack, Discord и произвольной точке приёма.
Доставка по возможности: неудачное оповещение никогда не роняет копирование.
Панель (v1.1)
serve поднимает HTTP-сервер рядом с планировщиком:
Vue SPA (internal/web, go:embed) JSON API (internal/api)
───────────────────────────── ───────────────────────
таблица заданий со статусами ──GET──▶ /api/jobs (задания + последний запуск)
история запусков ──GET──▶ /api/jobs/{n}/runs
кнопка «Запустить сейчас» ─POST──▶ /api/jobs/{n}/run → Daemon.Trigger
завести / изменить / удалить POST/PUT/DELETE▶ /api/jobs[/{n}]
здоровье и плашка секретов ──GET──▶ /api/health
Панель ведёт всю конфигурацию — задания, серверы и хранилища — поверх чтения
и запуска. Каждое изменение перечитывает jobs.yaml с диска, проверяет результат,
прогоняет его туда-обратно (сериализация → разбор, чтобы API не мог записать
конфигурацию, которую следующий serve не загрузит), пишет атомарно (временный
файл и fsync → переименование → fsync каталога) и подменяет живую конфигурацию.
Отвергнутое изменение возвращает ошибки проверки и не трогает работающую
конфигурацию. Наружу не выходят только значения секретов: API принимает
ссылки (kr:// / env:// / file://), но не разрешённый секрет — хотя поле в
панели, работающее только на запись, умеет положить значение прямо в связку
ключей. Правка под флагом AllowEdit (включена на loopback или когда задан
UNRUIN_API_TOKEN; выключена под UNRUIN_READONLY).
Вход: три режима, и /api/authmode говорит, какой сейчас. none — loopback без
настроенного доступа; login — имя и пароль (UNRUIN_AUTH_PASSWORD), сессия в
подписанной куке; token — только ключ (UNRUIN_API_TOKEN), сверяемый за
постоянное время и уходящий заголовком Bearer из localStorage. serve
отказывается стартовать на адресе не-loopback без настроенного доступа. Плашка
предупреждает, когда мастер-фразы kr:// нет или она неверная.
SPA вшита в бинарник, и Vue вместе со шрифтами лежит рядом с ней — образ не ходит ни в какую сеть за интерфейсом. Запущенные из панели задания отвязываются от контекста HTTP-запроса и переживают ответ.
Живая проверка (validate --preflight)
Обычный validate смотрит, что конфигурация разбирается и ссылки разрешаются.
--preflight добавляет живые проверки через Runner.Preflight: каждый секрет
разрешается, до каждого сервера есть SSH (с записью ключа хоста при первом
знакомстве), каждое хранилище достижимо (restic cat config). Это превращает
«конфигурация разбирается» в «это действительно пойдёт»: опечатка в имени kr://
или лежащий хост падают на коммите, а не в три часа ночи.
Восстановление после аварии
Аварийный конверт — хранилище, чей ключ живёт офлайн (env://), вне kr://.
Проверенный лист в RESTORE.md разворачивает копию из него, имея
только jobs.yaml, этот офлайновый секрет и restic: ни оркестратора, ни внешнего
менеджера секретов для начала восстановления не нужно. Файл хранилища секретов
(secrets.kr) и его мастер-фразу тоже держите офлайн — теперь это главный корень
всех остальных ключей.