diff --git a/docs/08-orchestrator-spec.md b/docs/08-orchestrator-spec.md index c097469..4da10cb 100644 --- a/docs/08-orchestrator-spec.md +++ b/docs/08-orchestrator-spec.md @@ -166,12 +166,47 @@ machine token или пароль пира, а сообщение уходит поверх незавершённой установки. Разрешение не подразумевается: молчаливое согласие на произвольный partial marker и позволяло «чинить» чужое состояние. +### Запись маркера долговечна и имеет ровно одного владельца + +`install` и `reconfigure` пишут маркер через один и тот же +`lib/installStateWriter.ts`. Раньше писателей было два, с разными гарантиями: +`install` перезаписывал файл на месте, `reconfigure` подставлял его атомарно. +Слабейшая гарантия досталась команде, которая этот файл создаёт. + +Перезапись на месте укорачивает файл до нуля и только потом наполняет. Любой +отказ между этими моментами — потеря питания, `kill -9`, `ENOSPC` — оставляет на +сервере половину документа: + +```json +{ + "product": "hy2xs", + "release_line": +``` + +Такой маркер не разбирается: `reconfigure`/`repair` видят его как отсутствующий, +а clean-host — как присутствующий, причём хост к этому моменту уже изменён. + +Атомарности при этом недостаточно, нужна **долговечность**. Порядок записи: + +```text +1. запись во временный файл в том же каталоге +2. права и владелец ← до подстановки: иначе есть окно, + в котором файл виден с чужими правами +3. fsync временного файла ← данные на носителе, а не в page cache +4. rename ← атомарная подстановка +5. fsync каталога ← сама запись каталога о новом имени +``` + +Без шагов 3 и 5 `rename()` даёт атомарность видимости, но после внезапной +перезагрузки ext4 штатно отдаёт по этому пути нулевой файл или отсутствие файла. +Для метаданных восстановления это неприемлемо. + ## Ownership и rollback Операция ведёт учёт того, к чему она **могла прикоснуться**: ```text -stateWritten +stateTouched depsTouched filesystemTouched uiTouched @@ -189,12 +224,18 @@ servicesStarted хост уже изменён, хотя шаг не закончился. Поэтому **каждый флаг взводится перед мутирующим вызовом**, а не после него. -`stateWritten` — полноценный участник классификации. `install-state.json` +`stateTouched` — полноценный участник классификации. `install-state.json` пишется сразу после успешного preflight, до `installDeps`; пока он в классификации не учитывался, падение `apt-get` объявлялось «на сервере ничего не изменено», rollback пропускался, а маркер оставался на хосте и ломал следующую установку по clean-host контракту. +Флаг называется `touched`, а не `written`, и это не косметика. Запись маркера — +три операции (`mkdir`, `write`, `chown`), и отказ последней оставляет файл на +диске. Пока флаг взводился **после** успешной записи, такой отказ давал +классификацию `fatal_pre_apply` — «на сервере ничего не изменено» — при уже +существующем `/var/lib/hy2xs/install-state.json`. + Классификация отказа строится **по этим флагам и фазе**, а не по тексту сообщения об ошибке. Ранее классификация шла по подстрокам, из-за чего preflight-ошибка со словом `nftables` приводила к откату чужого firewall. @@ -202,13 +243,49 @@ preflight-ошибка со словом `nftables` приводила к отк Инварианты rollback: - `fatal_pre_apply` по определению означает «ничего не применялось». Попасть в - него нельзя ни при одном взведённом флаге, включая `stateWritten`. В этом + него нельзя ни при одном взведённом флаге, включая `stateTouched`. В этом случае system rollback не выполняется, `install-state.json` не пишется, diagnostics-бандл не собирается (его сбор сам создал бы каталоги в `/var/log/hy2xs`). - `systemctl stop/disable` выполняется **только если текущая операция сама развернула эти unit-файлы**. +### После операционного отказа откат выполняется целиком + +Порядок в обработчике ошибки один и тот же в `install` и `reconfigure`: + +```text +запись состояния отказа → best effort +сбор диагностики → best effort +откат → обязателен +``` + +Обе первые операции пишут на диск (`/var/lib/hy2xs`, `/var/log/hy2xs`), то есть +падают ровно на заполненном диске и read-only ФС — там, где откат нужнее всего. +Пока хотя бы одна из них стояла обычным `await`, её собственный отказ уносил +управление наружу, и восстановление не выполнялось вовсе: применённый firewall и +развёрнутые сервисы оставались на сервере. Для диагностики это было закрыто +раньше, для записи состояния — нет. + +Второй инвариант — **стадии отката независимы**: + +| Команда | Стадии | +| --- | --- | +| `install` | firewall → stop services → disable services → reset failed services | +| `reconfigure` | firewall → restore configuration | + +Каждая стадия — это `systemctl`, `cp`, `rm -rf` или `nft`, то есть каждая умеет +упасть сама. Пока они стояли цепочкой `await`, отказ первой отменял все +следующие. В `reconfigure` это означало сервер одновременно с применённым +сломанным firewall **и** без восстановленных из `/etc/hy2xs/backups` конфигов — +то есть худший сценарий отказа лишался обеих половин восстановления сразу. + +Стадии выполняются последовательно и в объявленном порядке; независимость +означает «отказ не прерывает остальные», а не «выполняется как попало». +Отказавшие стадии перечисляются в журнале, а наружу пробрасывается **исходная** +ошибка операции: проблема внутри отката — это дополнительная информация о том, +что осталось не восстановленным, а не замена диагноза. + ## Инвариант публичного endpoint `preflight` проверяет, что публичный endpoint ведёт **на этот сервер**. Так как diff --git a/docs/11-testing-and-acceptance.md b/docs/11-testing-and-acceptance.md index 8595047..99f89b8 100644 --- a/docs/11-testing-and-acceptance.md +++ b/docs/11-testing-and-acceptance.md @@ -153,7 +153,9 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh - классификация отказа зависит от ownership-флагов и фазы, а **не** от текста ошибки; - `fatal_pre_apply` недостижим ни при одном взведённом флаге, включая - `stateWritten`: записанный `install-state.json` уже делает хост изменённым; + `stateTouched`: записанный `install-state.json` уже делает хост изменённым; +- частично выполненная запись маркера (отказ на `chown` после успешного `write`) + тоже даёт post-apply: флаг взводится **до** записи, а не после неё; - начатая (не обязательно завершённая) установка пакетов уже даёт `fatal_post_apply` — регрессия на сценарий «PHASE 0 прошла, apt-get упал, установщик заявил, что ничего не тронул»; @@ -188,6 +190,47 @@ HYSTERIA_BIN=/usr/local/bin/hysteria ./tools/test/e2e-hysteria.sh - записываемый маркер всегда несёт идентификацию поколения; - незавершённая установка подсказывает `repair --allow-partial-state`. +## A5a. Обязательный откат (unit) + +`orchestrator/test/rollback-mandatory.test.ts` — поведение механизма проверяется +настоящим внедрением отказа в стадию, проводка команд к нему — разбором +исходника (поднять systemd и nftables в этой среде нельзя): + +- при отказе первой стадии отката выполняются **все** последующие; +- отказавшие стадии перечисляются по именам и в порядке объявления; +- откат не бросает даже при отказе всех стадий: наружу обязана уйти исходная + ошибка операции, а не проблема внутри восстановления; +- не-`Error` причина (брошенная строка) не роняет откат; +- `persistFailureState` не пробрасывает отказ записи наружу — это и был P0: + падение записи маркера отменяло откат целиком; +- в обработчике ошибки `install` и `reconfigure` не осталось незащищённой записи + состояния (`advanceInstallState` / `markPhase` голым `await`); +- откат в обеих командах идёт через `runRollbackStages`, а не цепочкой `await`; +- внутри `rollbackCurrentState` ни одна команда не обрывает следующие: отказ + `systemctl daemon-reload` отменял перезапуск сервисов строкой ниже, то есть + восстановленные unit-файлы так и не применялись. + +## A5b. Долговечная запись маркера (unit) + +`orchestrator/test/atomic-write.test.ts`: + +- содержимое заменяется целиком, а не дописывается поверх прежнего; +- при отказе записи по целевому пути остаётся **прежний полный** документ; +- временный файл не выживает ни при успехе, ни при отказе подстановки; +- права выставляются точно, независимо от umask (`0600`, `0644`); +- `ensureDir` приводит права **существующего** каталога к объявленным: `mkdir` + этого не делает, поэтому «создать» и «права такие, как объявлено» — два + разных действия; +- и запись, и создание каталога проходят через read-only guard; +- `persistInstallState` под guard'ом отказывает: единственный писатель маркера + обязан идти через guarded-примитивы, иначе PHASE 0 смогла бы создать + `/var/lib/hy2xs`, и «read-only» перестало бы быть правдой ровно для того + файла, по которому clean-host принимает решение. + +Наличие самих `fsync` проверяется приёмкой сборки по исходнику: из +пользовательского процесса их не наблюдать, а без них `rename()` даёт +атомарность видимости без долговечности. + ## A6. Редактирование секретов (unit) `orchestrator/test/redaction.test.ts`: @@ -727,6 +770,48 @@ runtime-пакета выполнял `install.sh`, эти пути не при порт, не ответил резолвер) объявлялся `fatal_pre_apply` — «на сервере ничего не изменено» — при уже созданном каталоге оркестратора. +## D1b. Откат при невозможности записать состояние отказа (fault injection) + +Проверяется на чистом хосте. Это доказательство того, что телеметрия состояния +больше не стоит перед восстановлением. + +Подготовка: `/var/lib/hy2xs` делается недоступным для записи именно к моменту +обработки ошибки. Практичнее всего смонтировать поверх него крошечный `tmpfs` +и заполнить его до отказа: + +```bash +mount -t tmpfs -o size=16k tmpfs /var/lib/hy2xs +dd if=/dev/zero of=/var/lib/hy2xs/filler bs=1k count=64 2>/dev/null || true +``` + +Сценарий: + +1. установка доходит **дальше** шага firewall (то есть `firewallTouched` + взведён, правила применены); +2. следующий шаг ломается искусственно; +3. запись `phase: failed` в маркер падает по `ENOSPC`; +4. в журнале есть `failed to persist failure state, continuing with the + mandatory rollback`; +5. **откат всё равно выполняется**: `rollbackFirewallNow` снимает применённые + правила, `/etc/nftables.conf` возвращается к прежнему состоянию, а + развёрнутые этой операцией юниты останавливаются и выключаются; +6. SSH остаётся доступным; +7. в журнале перечислены отказавшие стадии отката, если они были, и наружу + ушла **исходная** ошибка операции, а не `ENOSPC`. + +До исправления шаги 4–6 давали противоположный результат: бросок из записи +состояния уносил управление наружу, и сервер оставался с применённым firewall +неудавшейся установки. + +Тот же сценарий повторяется для `reconfigure`, где цена выше: там откат +дополнительно возвращает конфиги из `/etc/hy2xs/backups`, и оба восстановления +отменялись разом. + +Дополнительно проверяется независимость стадий: если сделать неработоспособной +первую стадию (например, удалить `/run/hy2xs/rollback/` между применением +firewall и отказом), восстановление конфигов и остановка сервисов обязаны +выполниться всё равно. + ## D1a. Проход установки не спотыкается о собственный маркер Проверяется на чистом хосте, обычной успешной установкой. @@ -836,7 +921,7 @@ hy2xs-orchestrator doctor 5. `post-install.env` отражает фактическое deploy-состояние 6. оркестратор зафиксирован как Bun/TypeScript stack и поставляется как готовый install-артефакт 7. оркестратор не требует standalone update / rollback / uninstall subcommands -8. bounded rollback в install/reconfigure корректно отрабатывает failure-сценарии firewall/systemd/config/smoke +8. bounded rollback в install/reconfigure корректно отрабатывает failure-сценарии firewall/systemd/config/smoke, и ни один его собственный отказ не отменяет остальные стадии 9. Telegram/access layer не требуется для прохождения install acceptance 10. отсутствует production path для port hopping 11. UI не запускается от root @@ -879,3 +964,6 @@ hy2xs-orchestrator doctor 48. локальные SVG-иконки собираются спрайтом из репозитория, без `vite-plugin-svg-icons` 49. каждая иконка задаёт систему координат: `viewBox` либо пара `width`/`height` 50. страница конфига Hysteria не содержит элементов управления, которые ничего не сохраняют +51. невозможность записать состояние отказа не отменяет откат: восстановление выполняется, в журнале остаётся отметка о неудавшейся записи +52. `install-state.json` пишется одним писателем, атомарно и с `fsync` файла и каталога: после потери питания на диске лежит либо прежний полный документ, либо новый полный +53. ownership-флаг маркера установки взводится **до** записи, поэтому отказ на `chown` не даёт `fatal_pre_apply` при уже созданном файле diff --git a/docs/13-production-runbook.md b/docs/13-production-runbook.md index 544ffcb..4cf56b7 100644 --- a/docs/13-production-runbook.md +++ b/docs/13-production-runbook.md @@ -70,6 +70,21 @@ sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env - rollback guard не должен отменяться до успешного smoke; - для recovery использовать вывод оркестратора и перезапускать apply только после устранения root-cause. +Откат после операционного отказа выполняется целиком и сам по себе не может +быть отменён: ни неудачной записью состояния в `/var/lib/hy2xs`, ни отказом +одной из своих стадий. Поэтому в журнале нужно читать две разные вещи: + +| Строка в журнале | Что она означает | +| --- | --- | +| `failed to persist failure state, continuing with the mandatory rollback` | маркер не обновился (обычно заполненный диск), но восстановление выполнено; после освобождения места запустить `doctor` | +| `rollback stage "<имя>" failed, continuing with the remaining stages` | конкретная половина восстановления не отработала; остальные выполнены | +| `rollback finished with N failed stage(s); manual recovery may be required` | итог: перечисленные стадии требуют ручной проверки | +| `rollback completed: N stage(s) succeeded` | восстановление отработало полностью | + +Наружу оркестратор всегда пробрасывает **исходную** ошибку операции, а не +проблему внутри отката: последняя — это информация о том, что осталось не +восстановленным, а не причина отказа. + ## 9. Reconfigure flow ```bash diff --git a/orchestrator/src/commands/install.ts b/orchestrator/src/commands/install.ts index a915ffb..97ed268 100644 --- a/orchestrator/src/commands/install.ts +++ b/orchestrator/src/commands/install.ts @@ -1,13 +1,11 @@ import type { InstallContext, InstallOptions } from "../types/context"; -import { fileExists, readText, writeText, writeTextAtomic } from "../lib/fs"; +import { fileExists, readText, writeTextAtomic } from "../lib/fs"; import { runMutatingVisible } from "../lib/process"; import { info, setOperationContext, step, stepDone } from "../lib/log"; import { readPackageValue } from "../lib/packageMeta"; -import { - INSTALL_STATE_PATH, - REPAIR_HINT, - buildInstallStateRecord -} from "../lib/installState"; +import { REPAIR_HINT, buildInstallStateRecord } from "../lib/installState"; +import { persistInstallState } from "../lib/installStateWriter"; +import { persistFailureState, runRollbackStages, type RollbackStage } from "../lib/rollback"; import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env"; import { ORCHESTRATOR_INSTALL_DIR, @@ -62,8 +60,18 @@ type InstallPhase = * "firewall", — недопустимо. */ type OperationOwnership = { - /** install-state.json уже создан: сам по себе делает хост изменённым. */ - stateWritten: boolean; + /** + * К /var/lib/hy2xs уже могли прикоснуться: сам по себе созданный + * install-state.json делает хост изменённым. + * + * Флаг называется touched, а не written, по той же причине, что и остальные. + * Запись маркера — это три операции (`mkdir`, `write`, `chown`), и отказ + * последней оставляет файл на диске. Пока флаг взводился ПОСЛЕ успешной + * записи, такой отказ давал классификацию `fatal_pre_apply` — «на сервере + * ничего не изменено», — хотя /var/lib/hy2xs/install-state.json уже + * существовал и следующая чистая установка опознавала его как чужую. + */ + stateTouched: boolean; /** * Раскладка самого оркестратора и runtime-пакета (/usr/local/lib/hy2xs, * symlink в /usr/local/bin). Раньше эти пути создавал install.sh, и они @@ -92,7 +100,7 @@ type FailureKind = function newOwnership(): OperationOwnership { return { - stateWritten: false, + stateTouched: false, bootstrapTouched: false, depsTouched: false, filesystemTouched: false, @@ -145,9 +153,7 @@ async function writeInstallState( repairHint: phase === "installed" ? undefined : REPAIR_HINT }); - await runMutatingVisible`install -d -m 0755 -o root -g root /var/lib/hy2xs`; - await writeText(INSTALL_STATE_PATH, `${JSON.stringify(record, null, 2)}\n`, 0o644); - await runMutatingVisible`chown root:root ${INSTALL_STATE_PATH}`; + await persistInstallState(record); } async function advanceInstallState( @@ -156,15 +162,18 @@ async function advanceInstallState( phase: InstallPhase, lastError = "" ): Promise { + // Флаг взводится ПЕРЕД записью, а не после неё: см. комментарий к + // stateTouched. Частично выполненная запись маркера — это уже изменение + // хоста, и классификация обязана исходить из «сюда мы могли влезть». + ownership.stateTouched = true; await writeInstallState(context, phase, lastError); - ownership.stateWritten = true; } /** * Классификация опирается на то, к чему операция уже могла прикоснуться. * `fatal_pre_apply` по определению означает «на сервере ничего не изменено», * поэтому в него нельзя попасть после ЛЮБОГО взведённого флага — включая - * `stateWritten`: записанный /var/lib/hy2xs/install-state.json это уже + * `stateTouched`: записанный /var/lib/hy2xs/install-state.json это уже * изменение хоста, которое переживёт неудачную установку. * * Порядок веток — от самой поздней стадии к самой ранней: она точнее @@ -190,7 +199,7 @@ export function classifyFailure(ownership: OperationOwnership, phase: InstallPha ownership.filesystemTouched || ownership.depsTouched || ownership.bootstrapTouched || - ownership.stateWritten + ownership.stateTouched ) { return "fatal_post_apply"; } @@ -201,6 +210,12 @@ export function classifyFailure(ownership: OperationOwnership, phase: InstallPha * Инвариант: сервисы останавливаются и выключаются ТОЛЬКО если их развернула * текущая операция. Иначе неудачный запуск установщика на чужом сервере * положил бы работающий сервис. + * + * Второй инвариант — стадии независимы. Снятие firewall и остановка сервисов + * чинят разные половины неудачной установки, и отказ первой не имеет права + * отменить вторую: `rollbackFirewallNow` выполняет `systemctl`, `cp` и + * `rm -rf`, то есть умеет упасть сам, а без остановки сервисов на хосте + * остаётся включённый в автозапуск hysteria-server от неудавшейся установки. */ async function rollbackFailedInstall( context: InstallContext, @@ -217,18 +232,43 @@ async function rollbackFailedInstall( // которого install-state сохраняется с repair_hint. Снести оркестратор при // откате означало бы лишить оператора инструмента починки. Полная зачистка — // это осознанное отдельное действие, tools/legacy/purge-v0.sh. + const stages: RollbackStage[] = []; + if (ownership.firewallTouched) { - await rollbackFirewallNow(context); + stages.push({ + name: "firewall", + run: async () => { + await rollbackFirewallNow(context); + } + }); } - if (!ownership.unitsTouched) { + if (ownership.unitsTouched) { + stages.push( + { + name: "stop services", + run: async () => { + await runMutatingVisible`systemctl stop hysteria-server hy2xs-admin || true`; + } + }, + { + name: "disable services", + run: async () => { + await runMutatingVisible`systemctl disable hysteria-server hy2xs-admin || true`; + } + }, + { + name: "reset failed services", + run: async () => { + await runMutatingVisible`systemctl reset-failed hysteria-server hy2xs-admin || true`; + } + } + ); + } else { info("rollback: systemd units were not deployed by this operation, leaving services untouched"); - return; } - await runMutatingVisible`systemctl stop hysteria-server hy2xs-admin || true`; - await runMutatingVisible`systemctl disable hysteria-server hy2xs-admin || true`; - await runMutatingVisible`systemctl reset-failed hysteria-server hy2xs-admin || true`; + await runRollbackStages(stages); } export async function install(options: InstallOptions): Promise { @@ -373,11 +413,21 @@ export async function install(options: InstallOptions): Promise { throw error; } - await advanceInstallState( - context, - ownership, - failureKind === "smoke_readiness_timeout" ? "smoke_failed" : "failed", - `${failureKind}: ${message}` + // Запись состояния отказа — best effort, откат — обязателен. + // + // На заполненном диске или read-only ФС падает и она: `mkdir`, запись файла + // и `chown` в /var/lib/hy2xs. Пока она стояла обычным await, её собственный + // отказ уносил управление из обработчика наружу, и до rollbackFailedInstall + // дело не доходило вовсе — то есть применённый firewall и развёрнутые + // сервисы оставались на сервере ровно в том сценарии, ради которого откат и + // существует. Это тот же класс, что и с диагностикой ниже. + await persistFailureState(() => + advanceInstallState( + context, + ownership, + failureKind === "smoke_readiness_timeout" ? "smoke_failed" : "failed", + `${failureKind}: ${message}` + ) ); // Диагностика — best effort, откат — обязателен. diff --git a/orchestrator/src/commands/reconfigure.ts b/orchestrator/src/commands/reconfigure.ts index 1b53b4d..fc1046e 100644 --- a/orchestrator/src/commands/reconfigure.ts +++ b/orchestrator/src/commands/reconfigure.ts @@ -8,6 +8,8 @@ import { buildInstallStateRecord, type InstallStateRecord } from "../lib/installState"; +import { persistInstallState } from "../lib/installStateWriter"; +import { persistFailureState, runRollbackStages, type RollbackStage } from "../lib/rollback"; import { parseRuntimeEnv, renderRuntimeEnv } from "../config/env"; import { preflight } from "../steps/preflight"; import { generateConfig } from "../steps/config"; @@ -105,11 +107,11 @@ async function markPhase(context: ReconfigureContext, phase: ReconfigurePhase, l repairHint: phase === "installed" ? undefined : REPAIR_HINT }); - await writeTextAtomic(INSTALL_STATE_PATH, `${JSON.stringify(record, null, 2)}\n`, { - mode: 0o644, - owner: "root", - group: "root" - }); + // Запись идёт тем же путём, что и в install: у маркера установки ровно один + // владелец записи, и гарантии у него не зависят от того, какая команда его + // обновляет. Каталог при этом обеспечивается здесь так же, как при установке: + // repair обязан работать и на хосте, где /var/lib/hy2xs потеряли вручную. + await persistInstallState(record); } async function backupCurrentState(): Promise { @@ -139,7 +141,10 @@ async function rollbackCurrentState(): Promise { await runMutatingVisible`if [ -f /etc/hy2xs/backups/hy2xs.nft.existed ]; then cp -a /etc/hy2xs/backups/hy2xs.nft.bak /etc/nftables.d/hy2xs.nft 2>/dev/null || true; else rm -f /etc/nftables.d/hy2xs.nft; fi`; await runMutatingVisible`nft -f /etc/nftables.conf >/dev/null 2>&1 || true`; - await runMutatingVisible`systemctl daemon-reload`; + // `|| true` здесь по той же причине, что и у остальных команд отката: без + // него отказ daemon-reload отменял бы перезапуск сервисов строкой ниже, то + // есть восстановленные из backups unit-файлы так и не были бы применены. + await runMutatingVisible`systemctl daemon-reload || true`; await runMutatingVisible`systemctl restart hysteria-server hy2xs-admin || true`; } @@ -284,7 +289,15 @@ export async function reconfigure(options: ReconfigureOptions): Promise { } catch (error) { info("reconfigure failed, rollback in progress"); const message = error instanceof Error ? error.message : String(error); - await markPhase(context, classifyReconfigureFailure(ownership), message); + + // Запись состояния отказа — best effort, откат — обязателен. + // + // markPhase пишет в /var/lib/hy2xs и падает ровно там, где откат нужнее + // всего: заполненный диск, read-only ФС, ошибка ввода-вывода. Пока она + // стояла обычным await, её отказ уносил управление наружу мимо обоих + // восстановлений — и снятия firewall, и возврата конфигов из + // /etc/hy2xs/backups. + await persistFailureState(() => markPhase(context, classifyReconfigureFailure(ownership), message)); // Диагностика — best effort, откат — обязателен. // @@ -301,10 +314,30 @@ export async function reconfigure(options: ReconfigureOptions): Promise { info(`diagnostics collection failed, continuing with rollback: ${diagnosticsMessage}`); } + // Стадии отката независимы. Снятие firewall и возврат конфигов чинят разные + // половины неудачного прохода, и обе выполняются через `systemctl`, `cp` и + // `nft`, то есть каждая умеет упасть сама. Пока они стояли цепочкой + // `await`, отказ первой отменял вторую: сервер оставался и с применённым + // сломанным firewall, и со сломанными конфигами одновременно. + const stages: RollbackStage[] = []; + if (ownership.firewallTouched) { - await rollbackFirewallNow(context); + stages.push({ + name: "firewall", + run: async () => { + await rollbackFirewallNow(context); + } + }); } - await rollbackCurrentState(); + + stages.push({ + name: "restore configuration", + run: async () => { + await rollbackCurrentState(); + } + }); + + await runRollbackStages(stages); throw error; } } diff --git a/orchestrator/src/lib/fs.ts b/orchestrator/src/lib/fs.ts index 3384f00..672fc29 100644 --- a/orchestrator/src/lib/fs.ts +++ b/orchestrator/src/lib/fs.ts @@ -1,4 +1,5 @@ -import { stat } from "node:fs/promises"; +import { chmod, mkdir, open, rename, stat, unlink } from "node:fs/promises"; +import { dirname } from "node:path"; import { assertMutationAllowed } from "./guard"; async function statSafe(path: string): Promise { @@ -44,44 +45,132 @@ export async function writeText(path: string, data: string, mode?: number): Prom } } +/** + * Смена владельца по ИМЕНИ пользователя и группы. + * + * Здесь остаётся внешний `chown`, а не `fs.chown`: последний принимает только + * числовые uid/gid, то есть потребовал бы собственного разбора /etc/passwd и + * /etc/group. Имена — часть контракта установки (`root:root`, + * `hysteria:hy2xs-admin`), и разрешать их обязана система. + */ +function chownByName(target: string, owner: string, group: string): void { + const result = Bun.spawnSync(["chown", `${owner}:${group}`, target], { + stdout: "pipe", + stderr: "pipe" + }); + if (!result.success) { + throw new Error(`chown failed for ${target}: ${result.stderr.toString()}`); + } +} + +/** + * Каталог с гарантированными правами и владельцем. + * + * Существует ровно ради install-state: `mkdir -p` не меняет права уже + * существующего каталога, а режим при создании ещё и маскируется umask, поэтому + * «создать, если нет» и «права такие, как объявлено» — два разных действия. + * + * Проходит через guard: PHASE 0 не имеет права создать даже пустой каталог. + */ +export async function ensureDir( + path: string, + options: { + mode: number; + owner?: string; + group?: string; + } +): Promise { + assertMutationAllowed(`ensureDir(${path})`); + await mkdir(path, { recursive: true, mode: options.mode }); + await chmod(path, options.mode); + if (options.owner && options.group) { + chownByName(path, options.owner, options.group); + } +} + +/** + * Запись файла, которая переживает потерю питания. + * + * Атомарность и долговечность — РАЗНЫЕ свойства, и раньше здесь было только + * первое. `Bun.write` + `mv` даёт атомарность видимости: читатель видит либо + * старое содержимое, либо новое, никогда половину. Но данные к моменту + * `rename()` живут в page cache, а сам `rename()` — в незасинхронизированном + * каталоге. После внезапной перезагрузки ext4 штатно отдаёт по этому пути + * нулевой файл или отсутствие файла вовсе. + * + * Для install-state.json это принципиально: он и есть метаданные + * восстановления. Оператор читает его первым, а `repair --allow-partial-state` + * принимает решения по его содержимому. Обрезанный до нуля маркер означает + * потерю единственного описания того, что установка успела сделать с сервером. + * + * Поэтому порядок здесь такой и никакой другой: + * + * 1. запись во временный файл рядом с целью (тот же каталог — rename обязан + * остаться внутри одной файловой системы); + * 2. права и владелец — ДО подстановки, иначе существует окно, в котором файл + * уже виден по целевому пути с чужими правами; + * 3. fsync временного файла — данные и метаданные на носителе; + * 4. rename — атомарная подстановка; + * 5. fsync каталога — сама запись каталога о новом имени. + * + * Шаг 5 на win32 пропускается: открыть каталог как файл там нельзя. Оркестратор + * на Windows не выполняется (assertPlatform требует Debian), а тесты обязаны + * идти и на машине разработчика. + */ export async function writeTextAtomic( path: string, data: string, options: { mode: number; - owner: string; - group: string; + owner?: string; + group?: string; } ): Promise { assertMutationAllowed(`writeTextAtomic(${path})`); - const dir = path.replace(/\/[^/]+$/, "") || "."; - const base = path.split("/").pop() || "tmp"; + const dir = dirname(path); + const base = path.split(/[/\\]/).pop() || "tmp"; const tmp = `${dir}/.${base}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`; - await Bun.write(tmp, data); + let renamed = false; + try { + // "wx" — отказ, если файл уже существует: имя случайное, и совпадение + // означало бы чужой файл, а не наш прошлый заход. + const handle = await open(tmp, "wx", options.mode); + try { + await handle.writeFile(data, "utf8"); + // Режим при open маскируется umask, поэтому объявленные права + // выставляются явно — по дескриптору, а не по имени. + await handle.chmod(options.mode); + if (options.owner && options.group) { + chownByName(tmp, options.owner, options.group); + } + await handle.sync(); + } finally { + await handle.close(); + } - const chmodResult = Bun.spawnSync(["chmod", options.mode.toString(8), tmp], { - stdout: "pipe", - stderr: "pipe" - }); - if (!chmodResult.success) { - throw new Error(`chmod failed for ${tmp}: ${chmodResult.stderr.toString()}`); + await rename(tmp, path); + renamed = true; + } finally { + if (!renamed) { + // Временный файл не имеет права пережить неудачную запись: каталог + // install-state читается диагностикой и purge как набор наших файлов. + await unlink(tmp).catch(() => undefined); + } } - const chownResult = Bun.spawnSync(["chown", `${options.owner}:${options.group}`, tmp], { - stdout: "pipe", - stderr: "pipe" - }); - if (!chownResult.success) { - throw new Error(`chown failed for ${tmp}: ${chownResult.stderr.toString()}`); - } + await syncDirectory(dir); +} - const mvResult = Bun.spawnSync(["mv", "-f", tmp, path], { - stdout: "pipe", - stderr: "pipe" - }); - if (!mvResult.success) { - throw new Error(`atomic rename failed for ${path}: ${mvResult.stderr.toString()}`); +async function syncDirectory(dir: string): Promise { + if (process.platform === "win32") { + return; + } + const handle = await open(dir, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); } } diff --git a/orchestrator/src/lib/installState.ts b/orchestrator/src/lib/installState.ts index 39a09ad..f5d5552 100644 --- a/orchestrator/src/lib/installState.ts +++ b/orchestrator/src/lib/installState.ts @@ -9,7 +9,9 @@ import { HY2XS_CONFIG_SCHEMA_VERSION, HY2XS_RELEASE_LINE } from "../config/profi * Без них reconfigure/repair не отличают v1 от произвольного старого маркера. */ -export const INSTALL_STATE_PATH = "/var/lib/hy2xs/install-state.json"; +export const INSTALL_STATE_DIR = "/var/lib/hy2xs"; + +export const INSTALL_STATE_PATH = `${INSTALL_STATE_DIR}/install-state.json`; export const HY2XS_PRODUCT_ID = "hy2xs"; diff --git a/orchestrator/src/lib/installStateWriter.ts b/orchestrator/src/lib/installStateWriter.ts new file mode 100644 index 0000000..854ddc5 --- /dev/null +++ b/orchestrator/src/lib/installStateWriter.ts @@ -0,0 +1,35 @@ +import { ensureDir, writeTextAtomic } from "./fs"; +import { INSTALL_STATE_DIR, INSTALL_STATE_PATH, type InstallStateRecord } from "./installState"; + +/** + * Единственный способ записать маркер установки на диск. + * + * Раньше их было два. `install` писал через `writeText` — обычная перезапись + * файла на месте, — а `reconfigure` через `writeTextAtomic`. Один и тот же файл, + * две разные гарантии, причём слабейшая досталась команде, которая создаёт этот + * файл впервые и после которой он и становится метаданными восстановления. + * + * Расхождение стоило дороже, чем выглядит. Перезапись на месте укорачивает файл + * до нуля и только потом наполняет: любой отказ между этими моментами — потеря + * питания, kill -9, ENOSPC — оставляет на сервере половину JSON: + * + * { + * "product": "hy2xs", + * "release_line": + * + * Такой маркер не разбирается, поэтому reconfigure/repair видят его как + * отсутствующий, а clean-host — как присутствующий. Установка при этом уже + * изменила хост. + * + * Модуль отделён от installState.ts намеренно: тот остаётся чистым (структура + * записи и проверка поколения) и разбирается юнит-тестами без файловой системы + * и подпроцессов. + */ +export async function persistInstallState(record: InstallStateRecord): Promise { + await ensureDir(INSTALL_STATE_DIR, { mode: 0o755, owner: "root", group: "root" }); + await writeTextAtomic(INSTALL_STATE_PATH, `${JSON.stringify(record, null, 2)}\n`, { + mode: 0o644, + owner: "root", + group: "root" + }); +} diff --git a/orchestrator/src/lib/rollback.ts b/orchestrator/src/lib/rollback.ts new file mode 100644 index 0000000..f3918d1 --- /dev/null +++ b/orchestrator/src/lib/rollback.ts @@ -0,0 +1,101 @@ +import { info } from "./log"; + +/** + * Откат неудачной операции. + * + * Инвариант, ради которого существует модуль: + * + * после операционного отказа откат выполняется ЦЕЛИКОМ, и ни один его шаг + * не может отменить остальные. + * + * Что было. Откат был написан обычной цепочкой `await`: + * + * if (ownership.firewallTouched) { + * await rollbackFirewallNow(context); + * } + * await rollbackCurrentState(); + * + * Каждый шаг отката — это `systemctl`, `cp`, `rm -rf` и `nft` через + * `runMutatingVisible`, который бросает на ненулевом коде возврата. То есть + * отказ ПЕРВОГО шага отменял все последующие. В reconfigure это означало + * буквально: сервер остаётся с применённым сломанным firewall И без + * восстановленных из /etc/hy2xs/backups конфигов — то есть худший сценарий + * отказа лишался обеих половин восстановления сразу. + * + * Это тот же класс ошибки, который уже закрыт для диагностики («диагностика — + * best effort, откат — обязателен»), просто применённый на уровень ниже: внутри + * самого отката шаги тоже не имеют права зависеть друг от друга. + * + * Порядок при этом сохраняется: шаги идут последовательно и в объявленном + * порядке. Независимость означает «отказ не прерывает», а не «выполняется + * как попало». + */ +export type RollbackStage = { + /** Имя для журнала: оператор читает его первым при разборе неудачи. */ + name: string; + run: () => Promise; +}; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Выполняет ВСЕ стадии отката и возвращает список отказавших. + * + * Возврат, а не бросок: вызывающий обязан после отката пробросить ИСХОДНУЮ + * ошибку операции. Ошибка внутри отката — это дополнительная информация о том, + * что именно осталось не восстановленным, а не замена причины отказа. + */ +export async function runRollbackStages(stages: readonly RollbackStage[]): Promise { + const failures: string[] = []; + + for (const stage of stages) { + try { + await stage.run(); + } catch (error) { + const message = errorMessage(error); + failures.push(`${stage.name}: ${message}`); + info(`rollback stage "${stage.name}" failed, continuing with the remaining stages: ${message}`); + } + } + + if (failures.length === 0) { + info(`rollback completed: ${stages.length} stage(s) succeeded`); + return failures; + } + + info(`rollback finished with ${failures.length} failed stage(s); manual recovery may be required:`); + for (const failure of failures) { + info(` - ${failure}`); + } + return failures; +} + +/** + * Запись состояния отказа — best effort, ровно как сбор диагностики. + * + * Что было. И `install`, и `reconfigure` в обработчике ошибки первым делом + * писали в маркер фазу отказа обычным `await`, и только потом откатывались. + * Запись этого файла — это `mkdir`, `write` и `chown` в /var/lib/hy2xs, то есть + * она умеет упасть сама: заполненный диск, ФС в read-only, ошибка ввода-вывода. + * + * И падала она ровно в тех сценариях, ради которых откат и существует. Дальше + * бросок из обработчика уносил управление наружу, и обязательное + * восстановление — снятие применённого firewall, остановка развёрнутых + * сервисов, возврат конфигов — не выполнялось вовсе. + * + * То есть НЕОБЯЗАТЕЛЬНАЯ телеметрия состояния стояла перед ОБЯЗАТЕЛЬНЫМ + * восстановлением и умела его отменить. Состояние отказа полезно оператору, но + * оно описывает сервер, а откат его чинит; при выборе между «записать, что всё + * плохо» и «сделать, чтобы стало хорошо» продукт обязан выбирать второе. + */ +export async function persistFailureState(write: () => Promise): Promise { + try { + await write(); + } catch (error) { + info( + `failed to persist failure state, continuing with the mandatory rollback: ${errorMessage(error)}` + ); + } +} diff --git a/orchestrator/test/atomic-write.test.ts b/orchestrator/test/atomic-write.test.ts new file mode 100644 index 0000000..d2243a6 --- /dev/null +++ b/orchestrator/test/atomic-write.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ensureDir, writeTextAtomic } from "../src/lib/fs"; +import { disableReadOnlyGuard, enableReadOnlyGuard } from "../src/lib/guard"; +import { persistInstallState } from "../src/lib/installStateWriter"; +import { buildInstallStateRecord } from "../src/lib/installState"; + +/** + * install-state.json — это метаданные восстановления, и записывать их нужно так, + * чтобы после отказа на сервере лежал ЛИБО прежний полный документ, ЛИБО новый + * полный документ. + * + * Что было. install писал маркер через `writeText` — обычную перезапись на + * месте. Она укорачивает файл до нуля и только потом наполняет, поэтому отказ + * между этими моментами оставлял половину JSON. reconfigure писал тот же файл + * атомарно, то есть у одного файла было две разные гарантии в зависимости от + * того, какая команда его обновляла. + * + * Проверяется здесь именно наблюдаемое поведение: временный файл не выживает, + * целевой никогда не остаётся обрезанным, права выставлены до подстановки. + * Сам fsync проверить из пользовательского процесса нельзя — его наличие + * закрепляет приёмка по исходнику. + */ + +const onPosix = process.platform !== "win32"; + +let dir = ""; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "hy2xs-atomic-")); +}); + +afterEach(async () => { + disableReadOnlyGuard(); + await rm(dir, { recursive: true, force: true }); +}); + +async function leftovers(): Promise { + return (await readdir(dir)).filter((name) => name.includes(".tmp-")); +} + +describe("writeTextAtomic", () => { + test("создаёт файл с объявленным содержимым", async () => { + const target = join(dir, "install-state.json"); + await writeTextAtomic(target, '{"product":"hy2xs"}\n', { mode: 0o644 }); + + expect(await readFile(target, "utf8")).toBe('{"product":"hy2xs"}\n'); + }); + + test("не оставляет временных файлов после успешной записи", async () => { + await writeTextAtomic(join(dir, "state.json"), "{}\n", { mode: 0o644 }); + expect(await leftovers()).toEqual([]); + }); + + test("заменяет прежнее содержимое целиком, а не дописывает", async () => { + const target = join(dir, "state.json"); + await writeFile(target, "старое длинное содержимое, которое должно исчезнуть целиком"); + await writeTextAtomic(target, "{}\n", { mode: 0o644 }); + + expect(await readFile(target, "utf8")).toBe("{}\n"); + }); + + // Ключевое отличие от перезаписи на месте: пока новая запись не удалась, + // по целевому пути лежит ПРЕЖНИЙ полный документ, а не его обрезок. + test("при отказе записи прежний файл остаётся нетронутым", async () => { + const target = join(dir, "state.json"); + const previous = '{"product":"hy2xs","phase":"installed"}\n'; + await writeFile(target, previous); + + // Каталог назначения исчезает между вызовами: создать временный файл негде, + // то есть запись падает на самом раннем шаге. + const missing = join(dir, "gone", "state.json"); + await expect(writeTextAtomic(missing, "{}\n", { mode: 0o644 })).rejects.toThrow(); + + expect(await readFile(target, "utf8")).toBe(previous); + }); + + test("отказ подстановки не оставляет временный файл", async () => { + // Цель — каталог: rename файла поверх непустого каталога не проходит. + const target = join(dir, "state.json"); + await mkdir(target); + await mkdir(join(target, "occupied")); + + await expect(writeTextAtomic(target, "{}\n", { mode: 0o644 })).rejects.toThrow(); + expect(await leftovers()).toEqual([]); + }); + + test.skipIf(!onPosix)("права выставляются точно, независимо от umask", async () => { + const target = join(dir, "state.json"); + await writeTextAtomic(target, "{}\n", { mode: 0o600 }); + + expect(((await stat(target)).mode & 0o777).toString(8)).toBe("600"); + }); + + test.skipIf(!onPosix)("режим 0644 маркера установки сохраняется", async () => { + const target = join(dir, "install-state.json"); + await writeTextAtomic(target, "{}\n", { mode: 0o644 }); + + expect(((await stat(target)).mode & 0o777).toString(8)).toBe("644"); + }); + + test("запись проходит через read-only guard", async () => { + enableReadOnlyGuard("test phase"); + await expect(writeTextAtomic(join(dir, "state.json"), "{}\n", { mode: 0o644 })).rejects.toThrow( + /read-only guard violation/ + ); + }); +}); + +describe("ensureDir", () => { + test("создаёт недостающие уровни", async () => { + const target = join(dir, "var", "lib", "hy2xs"); + await ensureDir(target, { mode: 0o755 }); + + expect((await stat(target)).isDirectory()).toBe(true); + }); + + test("повторный вызов не является ошибкой", async () => { + const target = join(dir, "hy2xs"); + await ensureDir(target, { mode: 0o755 }); + await ensureDir(target, { mode: 0o755 }); + + expect((await stat(target)).isDirectory()).toBe(true); + }); + + // mkdir не меняет права уже существующего каталога, поэтому «создать» и + // «права такие, как объявлено» — два разных действия. + test.skipIf(!onPosix)("права приводятся к объявленным у существующего каталога", async () => { + const target = join(dir, "hy2xs"); + await mkdir(target, { mode: 0o700 }); + await ensureDir(target, { mode: 0o755 }); + + expect(((await stat(target)).mode & 0o777).toString(8)).toBe("755"); + }); + + test("создание каталога проходит через read-only guard", async () => { + enableReadOnlyGuard("the read-only install preflight (PHASE 0)"); + await expect(ensureDir(join(dir, "hy2xs"), { mode: 0o755 })).rejects.toThrow( + /read-only guard violation.*PHASE 0/s + ); + }); +}); + +describe("persistInstallState", () => { + // Единственный писатель маркера обязан идти через guarded-примитивы: иначе + // PHASE 0 смогла бы создать /var/lib/hy2xs, и «read-only» перестало бы быть + // правдой ровно для того файла, по которому clean-host принимает решение. + test("под read-only guard запись маркера невозможна", async () => { + enableReadOnlyGuard("the read-only install preflight (PHASE 0)"); + const record = buildInstallStateRecord({ + productVersion: "1.0.0", + buildId: "b", + opId: "op", + startedAt: "2026-08-30T00:00:00.000Z", + phase: "preflight_ok", + installed: false, + ownedPaths: [] + }); + + await expect(persistInstallState(record)).rejects.toThrow(/read-only guard violation/); + }); +}); diff --git a/orchestrator/test/install-boundary.test.ts b/orchestrator/test/install-boundary.test.ts index f591daa..39d40cb 100644 --- a/orchestrator/test/install-boundary.test.ts +++ b/orchestrator/test/install-boundary.test.ts @@ -15,7 +15,7 @@ type Ownership = Parameters[0]; function ownership(overrides: Partial = {}): Ownership { return { - stateWritten: false, + stateTouched: false, bootstrapTouched: false, depsTouched: false, filesystemTouched: false, @@ -109,8 +109,21 @@ describe("классификация отказа установки", () => { // объявлялось «на сервере ничего не изменено», rollback пропускался, а // /var/lib/hy2xs/install-state.json оставался на хосте и ломал следующую // установку по clean-host контракту. - test("записанный install-state сам по себе делает отказ post-apply", () => { - expect(classifyFailure(ownership({ stateWritten: true }), "preflight_ok")).toBe("fatal_post_apply"); + test("тронутый install-state сам по себе делает отказ post-apply", () => { + expect(classifyFailure(ownership({ stateTouched: true }), "preflight_ok")).toBe("fatal_post_apply"); + }); + + // Вторая половина той же регрессии. Флаг назывался stateWritten и взводился + // ПОСЛЕ успешной записи, а запись маркера — три операции (mkdir, write, + // chown). Отказ на chown оставлял /var/lib/hy2xs/install-state.json на диске + // при невзведённом флаге, то есть давал «на сервере ничего не изменено» с + // уже существующим маркером установки. + test("частично выполненная запись маркера уже post-apply", () => { + // Ровно то состояние, которое оставляет упавший на chown advanceInstallState: + // флаг взведён, а фаза ещё preflight_ok. + expect(classifyFailure(ownership({ stateTouched: true }), "preflight_ok")).not.toBe( + "fatal_pre_apply" + ); }); // Регрессия: раскладку оркестратора и runtime-пакета выполнял install.sh, @@ -122,13 +135,13 @@ describe("классификация отказа установки", () => { "fatal_post_apply" ); expect( - classifyFailure(ownership({ stateWritten: true, bootstrapTouched: true }), "bootstrap_installed") + classifyFailure(ownership({ stateTouched: true, bootstrapTouched: true }), "bootstrap_installed") ).toBe("fatal_post_apply"); }); test("падение installDeps после записи состояния — post-apply", () => { expect( - classifyFailure(ownership({ stateWritten: true, depsTouched: true }), "preflight_ok") + classifyFailure(ownership({ stateTouched: true, depsTouched: true }), "preflight_ok") ).toBe("fatal_post_apply"); }); diff --git a/orchestrator/test/rollback-mandatory.test.ts b/orchestrator/test/rollback-mandatory.test.ts new file mode 100644 index 0000000..03f34da --- /dev/null +++ b/orchestrator/test/rollback-mandatory.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { persistFailureState, runRollbackStages } from "../src/lib/rollback"; + +/** + * Откат после операционного отказа обязан выполниться ЦЕЛИКОМ. + * + * Здесь закрепляются два дефекта одного класса, оба найдены в failure path + * install и reconfigure: + * + * 1. запись состояния отказа стояла перед откатом обычным `await`, поэтому её + * собственное падение (заполненный диск, read-only ФС) отменяло откат + * целиком; + * 2. сам откат был цепочкой `await`, поэтому падение первой стадии отменяло + * все последующие. В reconfigure это означало сервер одновременно и с + * применённым сломанным firewall, и без восстановленных конфигов. + * + * Механизм проверяется поведением — настоящим внедрением отказа в стадию. + * Проводка команд к этому механизму проверяется разбором исходника: поднять + * настоящие systemd-юниты и nftables в этой среде нельзя, а утверждение при + * этом остаётся точным. + */ + +// Пути считаются от файла теста, а не от cwd: `bun test` запускается и из корня +// репозитория (сборка), и из orchestrator/ (разработчик). +function source(relativeToSrc: string): string { + return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8"); +} + +describe("стадии отката независимы", () => { + test("все стадии выполняются, когда все успешны", async () => { + const executed: string[] = []; + const failures = await runRollbackStages([ + { name: "firewall", run: async () => void executed.push("firewall") }, + { name: "services", run: async () => void executed.push("services") } + ]); + + expect(executed).toEqual(["firewall", "services"]); + expect(failures).toEqual([]); + }); + + // Ключевой инвариант патча: упавшая стадия не отменяет остальные. + test("падение первой стадии не отменяет последующие", async () => { + const executed: string[] = []; + const failures = await runRollbackStages([ + { + name: "firewall", + run: async () => { + executed.push("firewall"); + throw new Error("nft: command failed"); + } + }, + { name: "restore configuration", run: async () => void executed.push("restore") }, + { name: "stop services", run: async () => void executed.push("stop") } + ]); + + expect(executed).toEqual(["firewall", "restore", "stop"]); + expect(failures).toHaveLength(1); + expect(failures[0]).toContain("firewall"); + expect(failures[0]).toContain("nft: command failed"); + }); + + test("падение каждой стадии учитывается отдельно и в порядке объявления", async () => { + const failures = await runRollbackStages([ + { + name: "firewall", + run: async () => { + throw new Error("first"); + } + }, + { name: "healthy", run: async () => undefined }, + { + name: "restore configuration", + run: async () => { + throw new Error("second"); + } + } + ]); + + expect(failures).toHaveLength(2); + expect(failures[0]).toStartWith("firewall: "); + expect(failures[1]).toStartWith("restore configuration: "); + }); + + // Причиной отказа операции остаётся исходная ошибка: откат сообщает о своих + // проблемах возвратом, а не броском, иначе бы он подменил собой диагноз. + test("откат не бросает даже при отказе всех стадий", async () => { + const failures = await runRollbackStages([ + { + name: "a", + run: async () => { + throw new Error("boom"); + } + }, + { + name: "b", + run: async () => { + throw new Error("boom"); + } + } + ]); + expect(failures).toHaveLength(2); + }); + + test("не-Error причина не роняет откат", async () => { + const failures = await runRollbackStages([ + { + name: "weird", + run: async () => { + throw "строковая ошибка"; + } + } + ]); + expect(failures[0]).toContain("строковая ошибка"); + }); + + test("пустой набор стадий допустим", async () => { + expect(await runRollbackStages([])).toEqual([]); + }); +}); + +describe("состояние отказа пишется best effort", () => { + test("успешная запись выполняется", async () => { + let written = false; + await persistFailureState(async () => { + written = true; + }); + expect(written).toBe(true); + }); + + // Регрессия: это и был P0. Падение записи маркера уносило управление наружу + // мимо обязательного отката. + test("падение записи не пробрасывается наружу", async () => { + await expect( + persistFailureState(async () => { + throw new Error("ENOSPC: no space left on device"); + }) + ).resolves.toBeUndefined(); + }); +}); + +describe("install: откат обязателен после операционного отказа", () => { + const installSource = source("commands/install.ts"); + + test("запись состояния отказа обёрнута в persistFailureState", () => { + expect(installSource).toContain("await persistFailureState(() =>"); + const guarded = installSource.indexOf("await persistFailureState(() =>"); + const advance = installSource.indexOf("advanceInstallState(", guarded); + // Обёрнута именно запись маркера, а не что-то другое рядом. + expect(advance).toBeGreaterThan(guarded); + expect(advance - guarded).toBeLessThan(80); + }); + + test("состояние отказа пишется раньше отката, но не может его отменить", () => { + const guarded = installSource.indexOf("await persistFailureState("); + const rollback = installSource.indexOf("await rollbackFailedInstall("); + expect(guarded).toBeGreaterThan(-1); + expect(rollback).toBeGreaterThan(guarded); + }); + + // В обработчике не должно остаться ни одной незащищённой записи состояния: + // единственный голый advanceInstallState в catch и был дефектом. + test("в обработчике ошибки нет незащищённой записи маркера", () => { + const catchAt = installSource.indexOf("} catch (error) {"); + expect(catchAt).toBeGreaterThan(-1); + const handler = installSource.slice(catchAt); + expect(handler).not.toContain("await advanceInstallState("); + }); + + test("откат идёт через независимые стадии", () => { + expect(installSource).toContain("await runRollbackStages(stages)"); + // Прямых await-вызовов отката в теле rollbackFailedInstall быть не должно: + // именно они и образовывали отменяемую цепочку. + const start = installSource.indexOf("async function rollbackFailedInstall"); + const body = installSource.slice(start, installSource.indexOf("export async function install")); + expect(body).toContain("await rollbackFirewallNow(context)"); + // Вызов существует только внутри стадии. + const firewallAt = body.indexOf("await rollbackFirewallNow(context)"); + const stageAt = body.lastIndexOf('name: "firewall"', firewallAt); + expect(stageAt).toBeGreaterThan(-1); + }); + + test("остановка сервисов остаётся отдельными стадиями", () => { + for (const stage of ["stop services", "disable services", "reset failed services"]) { + expect(installSource).toContain(`name: "${stage}"`); + } + }); + + test("чужие сервисы по-прежнему не трогаются", () => { + expect(installSource).toContain( + "systemd units were not deployed by this operation, leaving services untouched" + ); + }); +}); + +describe("reconfigure: откат обязателен после операционного отказа", () => { + const reconfigureSource = source("commands/reconfigure.ts"); + + test("запись состояния отказа обёрнута в persistFailureState", () => { + expect(reconfigureSource).toContain("await persistFailureState(() => markPhase(context"); + }); + + test("в обработчике ошибки нет незащищённого markPhase", () => { + const catchAt = reconfigureSource.indexOf("} catch (error) {"); + expect(catchAt).toBeGreaterThan(-1); + const handler = reconfigureSource.slice(catchAt); + expect(handler).not.toContain("await markPhase("); + }); + + test("firewall и восстановление конфигов — независимые стадии", () => { + expect(reconfigureSource).toContain('name: "firewall"'); + expect(reconfigureSource).toContain('name: "restore configuration"'); + expect(reconfigureSource).toContain("await runRollbackStages(stages)"); + }); + + // Регрессия: отказ rollbackFirewallNow отменял rollbackCurrentState целиком. + test("порядок сохранён: сначала firewall, затем конфиги", () => { + const firewall = reconfigureSource.indexOf("await rollbackFirewallNow(context)"); + const restore = reconfigureSource.indexOf("await rollbackCurrentState()"); + const stages = reconfigureSource.indexOf("await runRollbackStages(stages)"); + expect(firewall).toBeGreaterThan(-1); + expect(firewall).toBeLessThan(restore); + expect(restore).toBeLessThan(stages); + }); + + // Внутри самого восстановления конфигов дефект был тот же: единственная + // команда без `|| true` отменяла перезапуск сервисов строкой ниже. + test("ни одна команда восстановления конфигов не обрывает следующие", () => { + const start = reconfigureSource.indexOf("async function rollbackCurrentState"); + const body = reconfigureSource.slice(start, reconfigureSource.indexOf("async function readInstallState")); + const offenders = body + .split(/\r?\n/) + .filter((line) => line.includes("runMutatingVisible`")) + .filter((line) => !line.includes("|| true") && !line.includes("; fi`")); + expect(offenders, `команды отката без защиты: ${offenders.join("; ")}`).toEqual([]); + }); +}); diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index c61b76b..60bfd5a 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -307,24 +307,44 @@ run_clean_install_acceptance() { grep -q 'systemd units were not deployed by this operation' orchestrator/src/commands/install.ts \ || fail "acceptance: rollback must never stop services it did not deploy" - log_step "Acceptance: a written install-state already makes the failure post-apply" - # Регрессия: classifyFailure не учитывал stateWritten, поэтому падение + log_step "Acceptance: a touched install-state already makes the failure post-apply" + # Регрессия: classifyFailure не учитывал маркер установки, поэтому падение # apt-get объявлялось «на сервере ничего не изменено», rollback пропускался, # а install-state.json оставался на хосте и ломал следующую установку. - grep -q 'ownership.stateWritten' orchestrator/src/commands/install.ts \ - || fail "acceptance: classifyFailure must account for a written install-state" + grep -q 'ownership.stateTouched' orchestrator/src/commands/install.ts \ + || fail "acceptance: classifyFailure must account for a touched install-state" + ! grep -q 'ownership.stateWritten' orchestrator/src/commands/install.ts \ + || fail "acceptance: stateWritten вернулся; флаг обязан называться stateTouched и взводиться до записи" "$BUN_BIN" -e ' const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8"); const body = source.slice(source.indexOf("export function classifyFailure")); const preApply = body.indexOf("return \"fatal_pre_apply\""); - const stateWritten = body.indexOf("ownership.stateWritten"); - if (preApply < 0 || stateWritten < 0) { + const stateTouched = body.indexOf("ownership.stateTouched"); + if (preApply < 0 || stateTouched < 0) { throw new Error("could not locate classifyFailure branches"); } - if (stateWritten > preApply) { - throw new Error("stateWritten is checked after the fatal_pre_apply fallback"); + if (stateTouched > preApply) { + throw new Error("stateTouched is checked after the fatal_pre_apply fallback"); } - ' || fail "acceptance: fatal_pre_apply must be unreachable once install-state was written" + ' || fail "acceptance: fatal_pre_apply must be unreachable once install-state was touched" + + log_step "Acceptance: the install-state flag is raised before the write, like every other one" + # Запись маркера — это mkdir, write и chown. Отказ последней оставляет файл на + # диске, поэтому флаг обязан отвечать на вопрос «сюда мы могли влезть», а не + # «запись удалась». + "$BUN_BIN" -e ' + const source = require("node:fs").readFileSync("orchestrator/src/commands/install.ts", "utf8"); + const start = source.indexOf("async function advanceInstallState"); + if (start < 0) throw new Error("advanceInstallState is missing"); + // Границей тела служит следующее объявление верхнего уровня. + const rest = source.slice(start + 1); + const end = rest.search(/\n(export )?(async )?function /); + const body = end < 0 ? rest : rest.slice(0, end); + const flag = body.indexOf("ownership.stateTouched = true"); + const write = body.indexOf("await writeInstallState("); + if (flag < 0 || write < 0) throw new Error("could not locate the flag and the write"); + if (flag > write) throw new Error("stateTouched is raised after writeInstallState"); + ' || fail "acceptance: the install-state ownership flag must be raised before the write" log_step "Acceptance: mutating ownership flags are raised before the step, not after" # Флаг «шаг завершился» отвечает не на тот вопрос: apt-get умеет изменить @@ -791,6 +811,125 @@ run_single_owner_acceptance() { } ' || fail "acceptance: a diagnostics failure must never cancel the rollback" + log_step "Acceptance: persisting the failure state never blocks the rollback" + # Тот же класс, что и «диагностика не отменяет откат», но уровнем раньше. + # Запись маркера отказа — это mkdir/write/chown в /var/lib/hy2xs, то есть она + # падает ровно на заполненном диске и read-only ФС — там, где откат нужнее + # всего. Пока она стояла обычным await, её отказ уносил управление наружу + # мимо снятия firewall и остановки развёрнутых сервисов. + [ -f orchestrator/src/lib/rollback.ts ] \ + || fail "acceptance: модуль обязательного отката отсутствует" + local rollback_command + for rollback_command in orchestrator/src/commands/install.ts orchestrator/src/commands/reconfigure.ts; do + grep -q 'persistFailureState(' "$rollback_command" \ + || fail "acceptance: запись состояния отказа в $rollback_command не помечена как best effort" + done + "$BUN_BIN" -e ' + const fs = require("node:fs"); + for (const [file, write] of [ + ["orchestrator/src/commands/install.ts", "await advanceInstallState("], + ["orchestrator/src/commands/reconfigure.ts", "await markPhase("] + ]) { + const source = fs.readFileSync(file, "utf8"); + const handler = source.slice(source.indexOf("} catch (error) {")); + if (handler.length === 0) throw new Error("не найден обработчик ошибки в " + file); + if (handler.includes(write)) { + throw new Error("незащищённая запись состояния отказа в обработчике " + file); + } + const guarded = handler.indexOf("await persistFailureState("); + if (guarded < 0) throw new Error("состояние отказа не обёрнуто в " + file); + } + ' || fail "acceptance: отказ записи состояния обязан продолжать откат, а не отменять его" + + log_step "Acceptance: rollback stages are independent, not a cancellable chain" + # Каждая стадия отката — systemctl/cp/rm/nft, то есть умеет упасть сама. + # Цепочка `await` означала, что отказ первой отменяет все следующие: в + # reconfigure сервер оставался и с применённым сломанным firewall, и без + # восстановленных из /etc/hy2xs/backups конфигов одновременно. + grep -q 'export async function runRollbackStages' orchestrator/src/lib/rollback.ts \ + || fail "acceptance: у отката нет механизма независимых стадий" + for rollback_command in orchestrator/src/commands/install.ts orchestrator/src/commands/reconfigure.ts; do + grep -q 'await runRollbackStages(stages)' "$rollback_command" \ + || fail "acceptance: откат в $rollback_command снова выполняется отменяемой цепочкой" + done + # Внутри восстановления конфигов дефект был тот же: единственная команда без + # `|| true` отменяла перезапуск сервисов строкой ниже. + "$BUN_BIN" -e ' + const source = require("node:fs").readFileSync("orchestrator/src/commands/reconfigure.ts", "utf8"); + const start = source.indexOf("async function rollbackCurrentState"); + if (start < 0) throw new Error("rollbackCurrentState отсутствует"); + const rest = source.slice(start + 1); + const end = rest.search(/\n(export )?(async )?function /); + const body = end < 0 ? rest : rest.slice(0, end); + const offenders = body.split(/\r?\n/) + .filter((line) => line.includes("runMutatingVisible`")) + .filter((line) => !line.includes("|| true") && !line.includes("; fi`")); + if (offenders.length) { + throw new Error("команды восстановления конфигов обрывают следующие:\n" + offenders.join("\n")); + } + ' || fail "acceptance: ни одна команда отката не имеет права отменить остальные" + + log_step "Acceptance: the install-state marker has exactly one durable writer" + # Раньше writeText в install и writeTextAtomic в reconfigure давали одному + # файлу две разные гарантии, причём слабейшую — команде, которая его создаёт. + [ -f orchestrator/src/lib/installStateWriter.ts ] \ + || fail "acceptance: модуль записи маркера установки отсутствует" + for rollback_command in orchestrator/src/commands/install.ts orchestrator/src/commands/reconfigure.ts; do + grep -q 'await persistInstallState(record)' "$rollback_command" \ + || fail "acceptance: $rollback_command пишет маркер установки мимо единственного писателя" + ! grep -q 'writeText(INSTALL_STATE_PATH' "$rollback_command" \ + || fail "acceptance: неатомарная перезапись маркера установки вернулась в $rollback_command" + done + # Долговечность, а не только атомарность: rename без fsync после потери + # питания штатно отдаёт нулевой файл, а маркер — это метаданные восстановления. + grep -q 'await handle.sync()' orchestrator/src/lib/fs.ts \ + || fail "acceptance: временный файл подставляется без fsync данных" + grep -q 'async function syncDirectory' orchestrator/src/lib/fs.ts \ + || fail "acceptance: каталог не синхронизируется после подстановки" + # Владелец обязан выставляться ДО подстановки: иначе существует окно, в + # котором файл уже виден по целевому пути с чужими правами. + "$BUN_BIN" -e ' + const source = require("node:fs").readFileSync("orchestrator/src/lib/fs.ts", "utf8"); + const start = source.indexOf("export async function writeTextAtomic"); + if (start < 0) throw new Error("writeTextAtomic отсутствует"); + const body = source.slice(start); + const chown = body.indexOf("chownByName(tmp"); + const sync = body.indexOf("await handle.sync()"); + const rename = body.indexOf("await rename(tmp, path)"); + if (chown < 0 || sync < 0 || rename < 0) throw new Error("не найдены шаги атомарной записи"); + if (!(chown < sync && sync < rename)) { + throw new Error("порядок обязан быть: права/владелец -> fsync -> rename"); + } + ' || fail "acceptance: атомарная запись обязана выставлять владельца и синхронизировать до подстановки" + # Каждый production-вызов обязан объявлять владельца явно: параметр + # необязателен только ради тестов, которые пишут во временный каталог. + "$BUN_BIN" -e ' + const fs = require("node:fs"); + const path = require("node:path"); + const offenders = []; + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { walk(full); continue; } + if (!entry.name.endsWith(".ts")) continue; + const source = fs.readFileSync(full, "utf8"); + // Ищутся ВЫЗОВЫ, а не объявление: у самой функции параметр owner + // необязателен, потому что тесты пишут во временный каталог, где + // выставить root:root нельзя. + let at = source.indexOf("await writeTextAtomic("); + while (at >= 0) { + const call = source.slice(at, at + 400); + if (!call.includes("owner:")) offenders.push(full); + at = source.indexOf("await writeTextAtomic(", at + 1); + } + } + }; + walk("orchestrator/src"); + if (offenders.length) { + throw new Error("вызовы без владельца: " + [...new Set(offenders)].join(", ")); + } + ' || fail "acceptance: production-запись обязана объявлять владельца файла" + log_step "Acceptance: reconfigure classifies by ownership, not by message text" ! grep -qF '.test(message)' orchestrator/src/commands/reconfigure.ts \ || fail "acceptance: reconfigure must not classify failures by matching the error text"