0057. Служба агента резервных копий в установщиках и настройка vault
Статус: accepted. Owner: Ian Panaev. Дата: 2026-10-02. Продолжает ADR 0056 (агент B2) и дополняет ADR 0031, ADR 0050, ADR 0055.
Контекст
Агент arkvory-backup agent (ADR 0056) работает под службой ОС, но установщики регистрировали только API и worker: оператор создавал службу вручную, без автозапуска, восстановления и обновления вместе с релизом. Путь vault задаётся только агенту (ARKVORY_BACKUP_VAULT), а его подготовка (права учётной записи службы, sandbox systemd, bind mount Compose) требовала ручных шагов с риском ошибиться в правах на каталог с хешами паролей и всем содержимым.
Ограничения существующих установок:
root/launcher.mjsкопируется один раз при установке и содержит таблицу ролей; launcher 0.2.x знает только api, worker и migrate.- Обновление выполняет код установленной (старой) версии. Обновление с 0.2.x меняет схему (24 → 26), поэтому идёт через
upgradeбез автоматического отката.
Попутно найдено: arkvory configure --tls-* брал installation lock второй раз внутри уже взятого и всегда завершался «Installation is locked»; кроме того, новый runtime.json создавался с владельцем root:root и режимом 0640, и после перезапуска служба на Linux теряла право его читать.
Решение
Роль backup. В таблице ролей launcher три службы: api, worker и backup (apps/backup/dist/main.js agent), а также migrate. Единственная операторская команда через launcher — vault-init <абсолютный каталог>: CLI vault init того же релиза с окружением установки, поэтому CLI сам повторяет проверку отделения vault от storage. Точка входа получает argv, как собственный исполняемый файл. Неожиданный выход службы, как у API и worker, даёт код 1.
| Supervisor | Служба | Доступ к vault |
|---|---|---|
| systemd | arkvory-backup: User arkvory, тот же sandbox (ProtectSystem=strict, ProtectHome, PrivateTmp), Restart=always 10 с, TimeoutStopSec=120 | Без ReadWritePaths: storage только для чтения. deploy/backup-vault-linux.sh пишет drop-in arkvory-backup.service.d/arkvory-vault.conf с ReadWritePaths="-<vault>", ставит владельца arkvory:arkvory и режим 0700. Повторная регистрация drop-in не трогает |
| WinSW | Arkvorybackup: LocalService, Automatic (Delayed), stoptimeout 120 с, restart через 10 с, resetfailure 1 ч, журналы 20 MiB × 5, как worker | deploy/backup-vault-windows.ps1: icacls /inheritance:r /grant:r SYSTEM F, Administrators F и NT AUTHORITY\LOCAL SERVICE (S-1-5-19) M с наследованием (OI)(CI) для новых файлов |
| Compose | backup: общий runtime-шаблон (read_only, cap_drop ALL, no-new-privileges, unless-stopped, stop_grace_period 120 с), без портов | config/compose.vault.yml: long syntax bind, create_host_path: false, цель /srv/arkvory-vault. Установщик добавляет -f только к релизу, объявляющему сервис backup. Владельца (uid 1000) выставляет одноразовый vault-owner (profile maintenance, root, без сети), как initialize; на Windows ещё ACL |
Контейнер backup монтирует только storage:ro и runtime.json: без ключей, health-токена и mailbox обновлений, зависит только от database.
На Windows используется та же учётная запись, что у API и worker. Service SID (NT SERVICE\Arkvorybackup) WinSW 2.12 не настраивает. Vault защищён не хуже данных и runtime.json, которые LocalService уже читает. UNC и SMB отклоняются: LocalService входит в сеть анонимно.
Compose поддерживается. Корневая ФС контейнера доступна только для чтения, а агент без vault.json ничего не пишет. Поэтому отсутствующий mount не превращается в запись в другое место. Несуществующий каталог не даёт контейнеру стартовать: в статусе это agent_offline. На Docker Desktop (bind mount 9P) проверены запись от uid 1000, rename, fsync файла и каталога и statfs; полная копия проходит в gate.
Ограничения Compose:
- Том NAS, смонтированный после старта контейнера, не виден до перезапуска backup.
- SELinux relabel не выполняется, как и для остальных bind mount профиля.
- В rootless Podman uid 1000 контейнера соответствует subuid хоста.
- Ручные команды Compose с настроенным vault должны включать
-f config/compose.vault.yml. Иначеupпересоздаст backup без vault, и агент сообщитvault_unavailable.
Жизненный цикл.
stop: backup → worker → api. Агент прерывает фазу какinterrupted, задание возвращается в очередь.start: api и worker, затем backup. Ошибка запуска агента — предупреждение. Исключение — неподтверждённый timeout, который сохраняет lock. Readiness, как прежде, означает три ответа API и работающий worker.- После старта при install, finish-install и repair, update, recover, upgrade и updates-connect выполняется ограниченное (около 90 с) ожидание
GET /api/v1/backup/status. Запрос идёт с bootstrap-ключом черезlocalRequest/localTargetи ждётagent.online; при неудаче — WARN без отката. Ожидание стоит внеapplyUpdate, поэтому агент не влияет на откат API. - Совместимость:
stopвключает backup, если служба зарегистрирована (native) или текущий релиз её объявляет (Compose).startзапускает агента, только если релиз поставляет роль (apps/backup/dist/agent.jsили сервис вcompose.yml). - Принятие роли (adopt). Установка, созданная релизом без роли, получает её при первом update или upgrade кодом новой версии (в окне остановки), при
configure --backup-vaultилиupdates-connect. Регистрируются только службы (register-linux.sh … services,register-windows.ps1 -ServicesOnly): владельцы и ACL дерева выставлены при создании установки, а их повторная установка обходит весь каталог данных (chown -R, распространение наследуемых ACL) и на терабайтах не укладывается в окно обновления. Install и repair по-прежнему выполняют полную регистрацию. Вместе со службами копируется новыйroot/launcher.mjs. Launcher заменяется атомарно и только при отличии содержимого; на Windows затем повторяется выдача прав управляемой БД (NetworkService) на новый файл. Новый launcher совместим с ролями старых релизов. - Код 0.2.x роль не регистрирует:
manage.mjs update/upgradeи планировщик запускают старый код. После такого обновления нуженarkvory updates-connectилиconfigure --backup-vault. Пакеты deb, rpm и exe применяют обновление кодом нового пакета. - Удаление и repair удаляют или регистрируют службу backup вместе с остальными. Vault вне корня и drop-in остаются нетронутыми.
arkvory configure --root R --backup-vault <dir> [--init-vault] и --backup-vault-off.
Проверка до любых изменений:
- путь абсолютный (на Windows — с буквой диска, не UNC);
- без символов, которые нельзя передать в unit, Compose и icacls: кавычки
"и',$,%, обратный апостроф, управляющие символы, на POSIX ещё\; - каталог существует и не является корнем ФС;
- после realpath (symlink, junction, 8.3) он не внутри корня установки и
ARKVORY_DATA_DIRи не содержит их (в Compose — только корень); - на systemd он не в /home, /root, /run/user, /tmp, /var/tmp (ProtectHome, PrivateTmp);
- проба записи: файл создаётся и удаляется. В Compose отказ в доступе здесь не ошибка: каталог получает пользователь контейнера, и пишет он.
Без
vault.jsonнужны--init-vaultи пустой каталог. Существующий vault заново не инициализируется.Содержимое (
vault.json, пустота) читается на хосте. В Compose vault принадлежит пользователю контейнера (uid 1000, 0700), и учётная запись, запустившая configure без root, может не видеть его содержимого. Тогда его читает одноразовый контейнер того же релиза:compose run --rm --no-deps -T backup vault-inspect /srv/arkvory-vaultпечатает одну JSON-строку{vaultId, empty}. Для этого на время запуска пишется override с bind mount; владелец каталога не меняется, override затем восстанавливается. Так же определяется ID послеvault init.Роль принимается (adopt), vault открывается (drop-in, ACL или override и
vault-owner). Затем при необходимости выполняетсяvault init: native —node releases/<v>/deploy/launcher.mjs <root> vault-init <dir>, Compose —compose run --rm --no-deps backup vault-init /srv/arkvory-vault. После него vault открывается повторно, чтобы файлы init принадлежали учётной записи службы.В
runtime.jsonзаписываетсяARKVORY_BACKUP_VAULT: в native — канонический путь хоста, в Compose —/srv/arkvory-vault. Файл заменяется с сохранением режима, владельца и группы.Перезапускается только backup. До 150 с команда ждёт:
agent.online,vault.configured,vault.availableиvault.id, равный ID изvault.json. Heartbeat прежнего агента не засчитывается. Bootstrap-ключ с правомbackup.readобязателен: отказ 401 или 403 — немедленная ошибка и откат.--backup-vault-offудаляет переменную, закрывает доступ (drop-in или override) и ждёт агента online без vault. Права на сам vault не отзываются.При ошибке восстанавливаются
runtime.jsonи прежний доступ, агент перезапускается, если уже перезапускался.vault.json, созданный этой попыткой, остаётся: это пустой vault, который повтор использует. При неподтверждённом завершении команды отката нет, lock сохраняется.
Один вызов configure меняет одно: HTTPS, vault или политику обновлений. Перед HTTPS и vault проверяется журнал обновления (checkJournal). В configure --tls-* убран вложенный lock; runtime.json заменяется replaceText с сохранением режима, владельца и группы.
Альтернативы
- Оставить регистрацию оператору: нет автозапуска, восстановления и обновления вместе с релизом.
- Включить агента в readiness: сбой vault или NAS блокировал бы обновления и откатывал API.
- Писать vault в основной unit (
ReadWritePaths): repair переписывал бы unit без знания vault. Drop-in живёт отдельно. - Service SID на Windows: строже, но требует SidType unrestricted вне WinSW и не проверяется локально. Оставлено на будущее.
- Отказать Compose: агент в контейнере вечно сообщал бы
vault_not_configured, хотя bind mount проверяемо безопасен. - Подстановка
${VAR:-…}вcompose.yml: для ненастроенного vault нужен фиктивный каталог. Override подключается только при настроенном vault.
Последствия
- Новые установки сразу запускают агента. Без vault он online и сообщает
vault_not_configured. - Первое обновление старой установки кодом новой версии регистрирует службу агента без пересчёта прав дерева; окно обновления от объёма данных не зависит.
configureперезапускает агента: текущая копия прерывается (interrupted) и повторяется.configureпринимает vault, когда агент сообщил его доступным. Первый heartbeat после перезапуска включает пробу записи агентом (ADR 0056), поэтому vault только для чтения отклоняется сразу, а не первой копией.
Проверка
Unit:
tests/backup-setup.test.mjs:- успех с существующим vault и ID из heartbeat; init только без
vault.json; - отказы до изменений: относительный или отсутствующий путь, файл, корень ФС, внутри root или storage, junction внутрь root, опасные символы, непустой каталог;
- откат при неподтверждённом vault и при сбое до перезапуска;
- выключение, отказ bootstrap-ключа, sandbox systemd.
- успех с существующим vault и ID из heartbeat; init только без
tests/backup-service.test.mjs: таблица ролей и процесс launcher, сервис Compose и override, признак роли в релизе, сохранение режима при замене, предупреждение вместо отказа; рольvault-inspectконтейнера, разбор её вывода после строк Compose, чтение недоступного хосту vault через контейнер без смены владельца.tests/deployment-process.test.mjs: захват stdout команды с пределом размера.tests/tls-setup.test.mjs.
Gates:
deployment:vault-initчерез launcher собранного релиза, синтаксис скриптов служб.deployment-services: регистрация, восстановление автозапуска регистрацией только служб и полной, три падения и stop для api, worker и backup; на Linux регистрация только служб не меняет владельца файлов данных, полная — меняет; drop-in открывает vault; storage для агента только для чтения.native-install:- служба backup активна и включена;
configure --backup-vault … --init-vault, агент сообщает vault;- копия опубликованного blob через
POST /api/v1/backup/runs; - crash recovery агента; repair сохраняет vault; удаление не трогает vault; RPM запускает backup.
deployment-containers: контейнер backup под restart policy, configure с bind mount и копия, восстановление после падения, update с тем же vault.