fix(swap): make the /etc/fstab rewrite transactional [P0]
Two release blockers, both in the most destructive operation the package performs. 1. An unreadable /etc/fstab was treated as an empty one. let content = fs::read_to_string(fstab_path).unwrap_or_default(); PermissionDenied, an I/O error, a damaged filesystem and "file absent" all collapsed into an empty string. The rewrite then emitted a fstab containing only the managed swap line and installed it atomically and successfully, dropping the root and boot mounts. Consequence: a host that does not boot, guarded by one unwrap_or_default. The same masking was present in read_fstab_swap_entries, so an unreadable fstab was reported by status as "no swap entries", which reads as healthy drift-free state. 2. The backup was best-effort. let _ = fs::write(bak_path, &content); If it failed, the rewrite proceeded anyway. IMPLEMENTATION_SPEC and the acceptance checklist both asserted the backup exists. The scenario where the backup fails is exactly the scenario where it is needed. New module fstab.rs owns the operation: - read_required distinguishes NotFound from every other error and fails on both. A missing /etc/fstab on Debian is an anomaly, not a blank slate, so it is never created; - a blank candidate is refused outright; - the backup is mandatory and flushed before the temp file is created, so there is no window where the target is about to be replaced and no recovery copy exists; - two copies are kept: .awg2-harness.bak is the state before the current rewrite, .awg2-harness.original is written once with create_new and holds the host as it was before this package first touched it; - the temp file is a sibling of the target, created with create_new, sync_all'd, and inherits the original permissions; a stale temp from a killed run is removed explicitly rather than reused; - the candidate is validated with findmnt --verify --tab-file before the swap. The step is skipped when findmnt cannot be spawned; a rejection is fatal; - commit is an atomic rename followed by a directory fsync; - after the rename the target is re-read and compared to what was written. A mismatch restores the backup and fails. Also fixed: render_fstab_line interpolated the path raw. fstab fields are whitespace-separated, so a space or tab in swap.path would shift every following column. Paths now go through octal escaping (\040, \011, \012, \134). Ten tests, all portable - the "unreadable but present" case is exercised by pointing the reader at a directory, which fails as a non-NotFound error on every platform. Covered: unreadable target is an error rather than an empty file, missing target is an error, backup failure leaves the target byte-identical, blank candidate refused, unchanged candidate is a no-op that writes no backup, the pristine copy survives repeated rewrites, a stale temp does not block commit, and field escaping. Docs updated: IMPLEMENTATION_SPEC section 17 gained the full rewrite contract, the acceptance checklist gained seven checks including the two failure modes above. cargo test --workspace: 124 + 4 passed, ten new. clippy -D warnings clean.
This commit is contained in:
@@ -100,6 +100,12 @@
|
||||
- [ ] `ensure-swap` создаёт файл по пути `swap.path` (в примере `/swapfile`) размером `swap.size_mb` (в примере 2048 MB).
|
||||
- [ ] Права на swap-файл — `0600`.
|
||||
- [ ] Перед перезаписью `/etc/fstab` создана резервная копия `/etc/fstab.awg2-harness.bak`.
|
||||
- [ ] После первой правки существует `/etc/fstab.awg2-harness.original` и он **не** меняется при последующих запусках.
|
||||
- [ ] Нечитаемый `/etc/fstab` (например, `chmod 000`) приводит к явной ошибке, а не к перезаписи файла одной managed-строкой.
|
||||
- [ ] Отсутствующий `/etc/fstab` приводит к ошибке, а не к созданию нового файла.
|
||||
- [ ] Если резервную копию записать не удалось, `/etc/fstab` остаётся неизменным.
|
||||
- [ ] `status` при нечитаемом `/etc/fstab` показывает ошибку, а не «нет swap-записей».
|
||||
- [ ] Путь swap с пробелом в имени попадает в `/etc/fstab` экранированным (`\040`) и корректно монтируется.
|
||||
- [ ] Если до запуска был активен другой swap, после `ensure-swap` он выключен.
|
||||
- [ ] После `ensure-swap` активен ровно один swap: `/swapfile`.
|
||||
- [ ] Все старые swap entries удалены из `/etc/fstab`.
|
||||
|
||||
@@ -535,10 +535,27 @@ Production-путь перезагрузки — внешний бинарь/с
|
||||
- одна запись в `/etc/fstab` с настроенным приоритетом;
|
||||
- `vm.swappiness` из конфига применён в runtime и сохранён persistently.
|
||||
|
||||
При `swap.enabled = true` AWG2 Harness владеет состоянием swap на хосте эксклюзивно. Целевое состояние — ровно одна активная swap-запись: `swap.path`. Все прочие активные swap-устройства и файлы выключаются через `swapoff`. Все не-комментарные swap-записи в `/etc/fstab` удаляются и заменяются одной управляемой записью AWG2; перед перезаписью создаётся резервная копия `/etc/fstab.awg2-harness.bak`. Обычные swap-файлы, не равные настроенному `swap.path`, могут быть удалены только после успешного `swapoff`. Блочные устройства не удаляются никогда — только выключаются и убираются из fstab. Операция обязана быть идемпотентной.
|
||||
При `swap.enabled = true` AWG2 Harness владеет состоянием swap на хосте эксклюзивно. Целевое состояние — ровно одна активная swap-запись: `swap.path`. Все прочие активные swap-устройства и файлы выключаются через `swapoff`. Все не-комментарные swap-записи в `/etc/fstab` удаляются и заменяются одной управляемой записью AWG2. Блочные устройства не удаляются никогда — только выключаются и убираются из fstab. Операция обязана быть идемпотентной.
|
||||
|
||||
Создание файла: `fallocate`, при неудаче — fallback на `dd`.
|
||||
|
||||
### Перезапись `/etc/fstab`
|
||||
|
||||
Это самая разрушительная операция пакета, поэтому она транзакционная и вынесена в отдельный модуль `fstab.rs`. Контракт:
|
||||
|
||||
- ошибка чтения `/etc/fstab` **никогда** не интерпретируется как пустой файл. `PermissionDenied`, ошибка ввода-вывода и отсутствие файла различаются, и все три прекращают операцию. Отсутствие `/etc/fstab` на Debian-хосте считается аномалией, а не основанием создать его с нуля;
|
||||
- пустой или состоящий из пробелов кандидат отклоняется;
|
||||
- резервная копия обязательна. Если её не удалось записать, целевой файл не трогается вовсе — ситуация, в которой копия не пишется, и есть та, в которой она нужна;
|
||||
- копий две: `/etc/fstab.awg2-harness.bak` перезаписывается перед каждой правкой и содержит состояние до неё, а `/etc/fstab.awg2-harness.original` создаётся один раз через `create_new` и хранит содержимое до первого вмешательства пакета;
|
||||
- запись идёт во временный файл рядом с целевым, с `create_new`, `sync_all` и переносом прав с оригинала; устаревший временный файл от убитого процесса удаляется явно, а не переиспользуется;
|
||||
- кандидат проверяется через `findmnt --verify --tab-file` до подмены. Если `findmnt` недоступен, шаг пропускается; если он отработал и отверг файл — операция прекращается;
|
||||
- подмена — атомарный `rename`, после него каталог синхронизируется;
|
||||
- после `rename` содержимое перечитывается и сверяется с записанным. Расхождение приводит к восстановлению из резервной копии.
|
||||
|
||||
Путь в записи fstab экранируется: поля разделяются пробелами, поэтому пробел или табуляция в пути сдвинули бы все последующие колонки.
|
||||
|
||||
Чтение swap-записей для `status` использует ту же строгую функцию чтения. Маскировка ошибки здесь показала бы «в fstab нет swap-записей», то есть здоровое состояние без drift, на хосте, где `/etc/fstab` вообще не читается.
|
||||
|
||||
## 18. Тестирование
|
||||
|
||||
Модульные тесты:
|
||||
|
||||
Reference in New Issue
Block a user