fix(swap): activate the previous swapfile in place when it cannot be moved back [P0]

The rollback still had a path that ended with two intact swapfiles on disk and neither of
them running.

With the original already off and moved to .previous, a failed rename back returned
immediately - "the host may currently have no swap" - without trying to activate anything.
Both candidates were sitting right there: the old swapfile under its .previous name, and
the replacement that the earlier fix had preserved. That return threw away the whole point
of preserving them.

The rename back was never the requirement. The kernel activates a swap area by path and
does not care that the path ends in .previous, so the old swapfile is brought up where it
lies. The rollback now tracks both files by where they currently are rather than by a flag,
and works down the candidates: original at the managed path, original at .previous,
replacement wherever it ended up. Only when none of them activates does it report that the
host has no managed swap - and then that statement is true.

Two consequences fall out of tracking locations:

- A replacement that could not be moved aside is still on the managed path, so the restore
  no longer renames the original over it. That rename would have unlinked one of the two
  files this rollback exists to keep.
- When the original ends up serving from .previous the replacement is not released and
  nothing is deleted. The host has swap, so nothing is urgent, but the managed path does not
  hold what the ownership record describes - that is an operator's call, and the error names
  the location of both files and the commands that finish it.

Three tests: the rename back fails and the previous swapfile is activated in place; it fails
and the previous swapfile will not activate either, so the replacement takes over; a live
replacement that cannot be released is left in place and nothing else is attempted. The
last one needed a swapoff failure, which the fake could not express before.

fix(daemon): let the docker events subsystem return to healthy [P1]

The outer loop recorded an error every time the events stream ended, and nothing ever
recorded a success for it. One Docker restart therefore pinned a failure in status
permanently while the replacement stream was running normally. The reconnect now clears it
and logs the recovery once.

The per-subsystem transition logic moved onto LastActionState as record_ok/record_err, so it
can be tested without the /run writes that set_ok/set_err perform. Clearing the last error
also clears its timestamp - otherwise status showed an empty error next to the time it
stopped being true.

fix(swap): drop transition leftovers once the managed swap is confirmed live [P1]

Leftovers were only cleared on the way *into* a resize. A process killed after the
replacement went live left .previous on disk indefinitely: the next run finds a valid,
active, correctly owned swapfile at the managed path, takes the fast path, and never looks.
A swapfile is commonly sized after RAM, so that is not a rounding error. They are now
dropped right after the managed swap is confirmed active, and only when inactive - an active
leftover means the kernel is still swapping to it, which is the interrupted-rollback case
that needs an operator.

This also replaces two tests that asserted nothing about production code: both rebuilt the
active-leftover check inline instead of calling it.

Docs: the resize transition and its three degraded outcomes are now written down in
IMPLEMENTATION_SPEC rather than living only in the code, the reconcile order gained the
cleanup step, per-subsystem error tracking is described, and the acceptance checklist gained
the fault cases to exercise on Debian - each one keyed to the exact wording the error must
carry.

cargo fmt --check, clippy -D warnings, cargo test --locked --workspace --release: 199 + 4
passed.
This commit is contained in:
2026-08-07 06:05:59 +05:00
parent 61dcea2d25
commit 383e67605a
4 changed files with 441 additions and 96 deletions
+7
View File
@@ -73,6 +73,8 @@
- [ ] Если контейнер временно отсутствует, daemon логирует ошибку reconcile и продолжает работу.
- [ ] На старте daemon сбой `sysctl_tuning` или `swap` завершает процесс, а сбой traffic/firewall только логируется — сервис остаётся живым.
- [ ] После обрыва потока `docker events` он перезапускается автоматически (проверяется, например, `systemctl restart docker`).
- [ ] После восстановления потока `docker events` ошибка подсистемы снимается: в `events.log` появляется `docker_events=recovered`, а `last_action.state` не остаётся с этой ошибкой навсегда.
- [ ] Успешный reconcile одной подсистемы не гасит незакрытую ошибку другой: при постоянно падающем `traffic` успешный `firewall` не очищает `last_error` и не пишет `firewall=recovered`.
- [ ] Параллельный запуск `apply-limits` во время работы daemon не приводит к гонке: второй процесс ждёт блокировку до 10 секунд и затем сообщает `lock busy timeout`.
- [ ] После `kill -9` блокировка освобождается ядром автоматически; оставшийся lock-файл не мешает следующему захвату `/run/awg2-harness/traffic.lock`.
@@ -145,7 +147,12 @@
- [ ] Если `swapoff` второго чужого swap падает, ранее отключённые чужие swap возвращаются в активное состояние.
- [ ] При откате resize swap возвращается с **прежним** priority, а не с новым (проверяется заведомо неприменимым `swap.priority`).
- [ ] Kill процесса в середине resize: следующий запуск с активным `.new` или `.previous` даёт понятную ошибку с командой `swapoff`, а не бесконечно падает на удалении активного файла.
- [ ] Kill процесса **после** активации нового swapfile: следующий `ensure-swap` проходит быстрым путём и удаляет неактивный `.previous`; активный `.previous` при этом не трогается.
- [ ] После отката в `/proc/swaps` присутствует ровно исходный swap; `.new` отключён и удалён.
- [ ] Откат при недоступном `rename` обратно (например, основной путь занят каталогом): исходный swapfile активируется прямо по пути `.previous`, в `/proc/swaps` есть запись, ошибка содержит `is serving from` и путь обоих файлов.
- [ ] Откат при неподнимающемся исходном swapfile: активируется отложенная замена, ошибка содержит `is left active so the host is not without swap`; ни один из файлов не удалён.
- [ ] Откат при активной замене, которую не удаётся отключить: файлы не перемещаются, ошибка содержит `is left in place and serving`, замена продолжает обслуживать.
- [ ] Формулировка «хост без управляемого swap» появляется только тогда, когда в `/proc/swaps` действительно нет ни одной managed-записи.
- [ ] Нечитаемый managed sysctl-файл даёт в `status` причину `managed sysctl file is unreadable`, а не «файл отсутствует».
- [ ] При `reboot.enabled = false` файл `/run/awg2-harness/reboot.plan` удалён, `status` не показывает устаревший план.
- [ ] После принятия запроса на перезагрузку `reboot.plan` удалён.