Close the holes in the backup: a book still in intake keeps its source, a rejected one with a directory is no hole, and the runbook stops inverting two symptoms
This commit is contained in:
parent
f113675f53
commit
3e8471683e
10 changed files with 427 additions and 95 deletions
|
|
@ -1,6 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
|
@ -69,11 +70,19 @@ func TestBackupSaysWhenItDoesNotKnowWhereToLook(t *testing.T) {
|
|||
// command for that is a one-word command for destroying the current state by mistake. The procedure
|
||||
// lives in deploy/README.md and inside the point's own manifest.
|
||||
func TestThereIsNoOneWordRestoreCommand(t *testing.T) {
|
||||
t.Setenv("TM_PLATFORM_DSN", "")
|
||||
// ⚠ WITH a database, and that is not decoration: with `TM_PLATFORM_DSN` unset the dispatcher
|
||||
// refuses at the DSN gate before it ever looks at the subcommand, so the test would pass over a
|
||||
// `restore` command that existed. Acceptance caught exactly that — the assertion below now
|
||||
// reaches the thing it is about.
|
||||
t.Setenv("TM_PLATFORM_DSN", freshDB(t))
|
||||
var out strings.Builder
|
||||
if err := run([]string{"restore"}, &out); err == nil {
|
||||
err := run([]string{"restore"}, &out)
|
||||
if err == nil {
|
||||
t.Fatal("a `restore` command exists; the runbook's deliberate absence has been undone")
|
||||
}
|
||||
if !errors.Is(err, errUsage) {
|
||||
t.Errorf("`restore` was refused for some reason other than being unknown: %v", err)
|
||||
}
|
||||
if !strings.Contains(usage, "backup") {
|
||||
t.Error("usage does not mention the backup command at all")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -285,6 +285,12 @@ install -m0644 mining-contrast.zh.txt /opt/textmachine/configs/mining-contrast.z
|
|||
/opt/textmachine/engine/<версия>/tmctl redrive --config /srv/textmachine/books/<book_id>/book.yaml --dry-run
|
||||
```
|
||||
|
||||
⚠ **ЭТА ПРОВЕРКА ОТКРЫВАЕТ ПРОЕКТ НА ЗАПИСЬ, и на книге с идущим прогоном её запускать нельзя.**
|
||||
`redrive` даже с `--dry-run` идёт пишущим путём (`pipeline.NewRunner`), а значит берёт эксклюзивный
|
||||
flock проекта и мигрирует файл при необходимости. На занятой книге это отказ класса 12 («ничего не
|
||||
произошло, подожди») — не порча, но и не проверка. Гоняйте на книге, по которой прогонов нет:
|
||||
`tmplatformctl books` покажет, у кого их нет.
|
||||
|
||||
⚠ **СУДИТЬ ПО СООБЩЕНИЮ, А НЕ ПО КОДУ ВЫХОДА — оба исхода дают `exit 10`.** Замерено 05.09 обеими
|
||||
ветками:
|
||||
```
|
||||
|
|
@ -297,15 +303,21 @@ install -m0644 mining-contrast.zh.txt /opt/textmachine/configs/mining-contrast.z
|
|||
до жалобы на ключи можно только миновав контраст. Ключей у `redrive` в SaaS-развёртывании и не будет:
|
||||
они едут `--keys-file`, а его движок принимает только у `translate`.
|
||||
|
||||
⚠ **И ЕЩЁ ОДНО СЛЕДСТВИЕ, КОТОРОЕ ВИДНО НЕ В ОТКАЗЕ, А В ДЕНЬГАХ.** Книжное слагаемое цены
|
||||
(`book_once_usd` в `manifest --json`) условно ровно на этой паре фактов — контур включён И
|
||||
`contrast_path` задан. Конфиг деплоя, где ключ ПОТЕРЯН (а не файл), проходит загрузку и отдаёт
|
||||
книжное слагаемое НУЛЁМ: платформа увидит книгу дешевле, чем она есть. То есть «забыл строчку в
|
||||
конфиге» и «положил файл не туда» дают РАЗНЫЕ симптомы — второй громкий (`exit 10`), первый тихий и
|
||||
денежный. ⚠ Само число — **граница, а не прогноз**: вход терминолога производится из черновиков,
|
||||
которых в момент публикации числа нет, поэтому движок честно называет лишь сумму бюджетов обеих
|
||||
стадий, которую не превысит. Для вопроса «какой потолок нужен книге» это и есть полезная цифра; как
|
||||
«сколько будет в счёте» её читать нельзя.
|
||||
⚠ **ДВЕ РАЗНЫЕ ОШИБКИ РАЗВЁРТЫВАНИЯ, И ГРОМКАЯ — НЕ ТА, О КОТОРОЙ ДУМАЕШЬ.** ⛔ Прежняя редакция
|
||||
этого абзаца утверждала обратное и была НЕВЕРНА: она называла тихой потерю КЛЮЧА и громкой потерю
|
||||
файла. Замер обеих веток (05.09, `tmctl manifest --json` на одной книге):
|
||||
|
||||
| что потеряно | код выхода | что видит оператор |
|
||||
|---|---|---|
|
||||
| **строчка `contrast_path` в конфиге** | **`10`** | конфиг НЕ ЗАГРУЖАЕТСЯ вовсе, отказ на ЛЮБОМ пути, включая `$0`-читающие |
|
||||
| **сам файл артефакта** | **`0`** | всё работает, манифест отдаётся; отказывает только ПИШУЩИЙ путь — каждый `translate` и резюм |
|
||||
|
||||
Причина в коде и она намеренная: пустой ключ судится ПРИ ЗАГРУЗКЕ
|
||||
(`backend/internal/config/pipeline.go`, греп `mining.contrast_path` is not set), а наличие ФАЙЛА —
|
||||
только на пишущем пути (`CheckMiningContrast`), потому что `LoadPipeline` лежит и на `$0`-поверхностях,
|
||||
и отказывать `status` из-за отсутствующего файла данных — ошибка класса D20.4.
|
||||
⇒ **Забытая строчка в конфиге ловится немедленно и всем; забытый файл — только первым платным
|
||||
прогоном.** Опасен второй, и именно поэтому раздел выше велит класть артефакт ПЕРЕД выкаткой.
|
||||
|
||||
⚠ **Порядок в первой выкатке: артефакт → шаблон книги → демон.** Книга, заведённая раньше файла, не
|
||||
ломается и ничего не теряет — отказ приходит на старте прогона, а не на интейке, — но пользователь
|
||||
|
|
@ -744,6 +756,12 @@ install -d -m0750 -o tmplatform -g tmplatform /var/backups/tmplatform
|
|||
потерей книжного канона молча.
|
||||
⚠ **Всё пропущенное ИМЕНУЕТСЯ в манифесте** (`books[].not_copied`) с причиной — чтобы через полгода
|
||||
читатель не выводил правила из того, что уцелело.
|
||||
⚠ **И ТОЧКА — НЕ МГНОВЕНИЕ ВРЕМЕНИ, а последовательность:** сперва снимается денежный реестр, потом
|
||||
книги одна за другой. На большой библиотеке между дампом и последней книгой проходят минуты, а то и
|
||||
десятки минут. `complete: true` означает «всё, что должно было попасть, попало», а НЕ «всё снято в
|
||||
один момент»: прогон, начавшийся после дампа, будет виден в книге и не виден в реестре. Для
|
||||
восстановления это безвредно — реконсилятор доводит расчёт по exit-маркеру и журналу, — но читать
|
||||
точку как согласованный снимок нельзя.
|
||||
|
||||
⚠ **Про последний — точнее, потому что первая редакция этого абзаца говорила «наши копии их
|
||||
замещают», и это НЕВЕРНО.** `<каталог книги>/backups/` — это ПРЕДРЕЙСОВЫЕ точки, которые движок
|
||||
|
|
@ -808,6 +826,16 @@ tmplatformctl backup --verify 20260905T120000Z --dir /var/backups/tmplatform
|
|||
# 1. Остановить приём: демон не должен писать в то, что восстанавливается.
|
||||
systemctl stop tmplatformd
|
||||
|
||||
# 1а. ⚠ И ЭТОГО МАЛО: ПРОГОНЫ ДЕМОНУ НЕ ДЕТИ. Каждый — транзиентный юнит в СОБСТВЕННОМ менеджере
|
||||
# пользователя `tmplatform` и переживает остановку контрол-плейна по построению (D39.106, §«Что
|
||||
# юнит закрывает содержательно» выше). Восстановление делают в худший день — именно тогда
|
||||
# вероятен ИДУЩИЙ оплаченный прогон, и шаг 4 подсунул бы файл под открытым дескриптором.
|
||||
# Спросить и, если есть, остановить — и только потом трогать каталоги:
|
||||
systemctl --user -M tmplatform@ list-units 'tm-run-*' --no-legend # что живо в ИХ менеджере
|
||||
systemctl --user -M tmplatform@ stop 'tm-run-<id>.service' # каждый, что нашёлся
|
||||
# ⚠ Останов прогона НЕ теряет его работу: `SIGTERM` = «дочти чанк и отпусти лок», а реконсилятор
|
||||
# закроет его штатно после подъёма демона. Терять нечего, ждать — до одного чанка.
|
||||
|
||||
# 2. Денежный реестр и вся библиотека — одним дампом.
|
||||
# ⚠ DSN в рекомендуемой раскладке лежит ФАЙЛОМ (`TM_PLATFORM_DSN_FILE=/etc/tmplatform/dsn`), а не
|
||||
# переменной в шелле оператора: в худший день `$TM_PLATFORM_DSN` пуст, и шаг падает первым.
|
||||
|
|
@ -827,7 +855,13 @@ rm -f /srv/textmachine/books/<book_id>/*.db-wal /srv/textmachine/books/<book_id>
|
|||
mv /srv/textmachine/books/<book_id>/project.db /srv/textmachine/books/<book_id>/<book_id>.db
|
||||
chown -R tmplatform:tmplatform /srv/textmachine/books/<book_id>
|
||||
|
||||
# 5. Поднять и проверить: баланс на месте, книга на месте, движок открывает её файл.
|
||||
# 5. ⚠ ОТОЗВАТЬ УЧЁТНЫЕ ДАННЫЕ, ЕСЛИ ОТЗЫВ СЛУЧИЛСЯ ПОСЛЕ СНЯТИЯ ТОЧКИ. Дамп несёт таблицу сессий
|
||||
# целиком, поэтому восстановление ВОСКРЕШАЕТ всё, что было живо в момент снятия — включая
|
||||
# браузерную сессию или bearer-токен, отозванные позже. Это не дефект бэкапа (копия обязана быть
|
||||
# копией), это шаг чек-листа:
|
||||
tmplatformctl revoke --user <id> # для каждого, кому отзывали доступ после штампа точки
|
||||
|
||||
# 6. Поднять и проверить: баланс на месте, книга на месте, движок открывает её файл.
|
||||
systemctl start tmplatformd
|
||||
tmplatformctl balance --user <id>
|
||||
/opt/textmachine/engine/<версия>/tmctl status --config /srv/textmachine/books/<book_id>/book.yaml
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
| PD-375 | bug | minor | `internal/runs/runs.go:354`=`if in.CeilingChapters < bounds.Min`, `internal/httpapi/v0_test.go:359`, `internal/runs/control_test.go` | **Верхнюю половину проверки `ceiling_chapters` не исполняет НИ ОДИН тест: снятие второго операнда `in.CeilingChapters > bounds.Max` проходит ВСЮ батарею.** Собственный доккомментарий называет проверку несущей — «число, решающее СКОЛЬКО ДЕНЕГ резервируется, не может быть выбрано вызывающим односторонне», — и канон требует того же от сервера (`max_chapters` уже прижат к остатку, «a client MUST NOT clamp it again»). Единственный тест, называющий `ErrCeilingOutOfBounds`, это таблица соответствия ошибки коду 409 в `httpapi/v0_test.go`, которая `Start` не зовёт, а самая тесная фикстура просит 10 глав у книги, где их 100. Код сегодня ВЕРЕН; дефект в том, что править эту строку можно безнаказанно. Замерено на живом стенде: чистая сборка отвечает `409 ceiling_unavailable/bounds_moved`, сборка с мутацией отдаёт 202 и открывает резервацию `24000000` микро на книге из ТРЁХ глав, а движку уходит `--ceiling-usd 24.200000`. Вес: рефутер сузил major → minor, потому что код верен и ни один сегодняшний клиент до вреда не доходит. Воспроизведение: `docs/p8-review/axis1-money/a1-mut5-ceiling-bound.sh`, готовый пин `docs/p8-review/axis1-money/r1_ceiling_bound_test.go.txt` | open | ревью-пак P8-REVIEW, ось 1 (посадка мутации + живая проба, подтверждено рефутером) |
|
||||
| PD-377 | doc | minor | `internal/pgstore/credits.go:162`=`The ceiling to hand the engine is the amount held`, `internal/runs/spawn.go` `bookCap`, `cmd/tmplatformctl/main.go` `balance` | **Доккомментарий `Hold` на входе в денежный путь неверен ОБЕИМИ половинами с миграции 00011.** Он обещает «the ceiling to hand the engine is the amount held; read it back with OpenReservations». Движку передаётся не сумма холда, а `run_attempts.ceiling_arg_micro_usd` = committed книги плюс прирост (`spawn.go` `bookCap`), и сама 00011 говорит это прямым текстом («It is NOT ceiling_micro_usd»); начиная со ВТОРОГО прогона книги числа расходятся тем сильнее, чем дороже книга — на стенде до 17 раз (`60000` холда против `1060000` в аргументе). Вторая половина не работает даже механически: `Reservation.Ceiling` — поле, которое только сканируется и не читается никем, единственный потребитель `OpenReservations` печатает `Amount`/`BookID`/`OpenedAt`/`EngineRunID`. ⚠ Рефутер снял два из трёх исходных якорей: доккомментарии в применённых миграциях `00007`/`00009` зона не правит после лендинга (у всех 25 файлов миграций ровно по одному коммиту), а исправление уже лежит в следующем файле того же каталога. Остаётся Go-доккомментарий. Воспроизведение: `docs/p8-review/axis1-money/a1-ceiling-column-doc.sql` | open | ревью-пак P8-REVIEW, ось 1 (живой стенд, сужено рефутером до одного якоря) |
|
||||
| PD-386 | bug | minor | `cmd/tmplatformd/runner.go:475`=`out := []sweepPass{{"runs", sweepBudget, s.runs.Sweep}}` и четыре следующих прохода того же `one()`, `cmd/tmplatformd/runner_test.go` | **Такт свипа последователен, и бюджеты пяти проходов СКЛАДЫВАЮТСЯ: сумма объявленных — 37 минут при `TM_PLATFORM_SWEEP_EVERY` 15 секунд.** Пак P8-FIX дал каждому проходу свой бюджет и закрыл «один проход съедает дедлайн другого», но проходы по-прежнему идут подряд в одной горутине: runs 2м, readmodel 10м, intake 21м, idempotency 2м, observe 2м. Ничто эту сумму не ограничивает и ничто её не пинит — у функции `sweep` нет ни одного теста (в пакете два теста, оба про другое), и посадка «фаза runs уходит из головы такта в хвост» пережила ПОЛНУЮ батарею. Замерено пробой на реальной функции: за 4 секунды при такте 100 мс фаза прогонов отработала 40 раз сама по себе и 9 раз позади прохода материализации. Следствия по оси: калибровки самого лечения заданы в проходах и минутах и молча растягиваются (`StalledAfter` 5 «неудач подряд» и бэкофф, про который комментарий обещает «about a quarter of an hour», превращаются в часы); обещание `Stop` «the reconciler re-issues the stop on its next pass» задерживается на ту же величину; телеметрия стоит ПОСЛЕДНЕЙ. ⚠ Рефутер сузил: 37 минут — сумма ОБЪЯВЛЕННЫХ бюджетов, а не достижимая длительность (idempotency это один индексированный DELETE, а проход интейка сам себя режет). Воспроизведение: `docs/p8-review/axis3-queue/probe_sweep_serialisation_test.go.txt` | open | ревью-пак P8-REVIEW, ось 3 (проба на реальной функции + посадка мутации, сужено рефутером) |
|
||||
| PD-387 | doc | minor | `deploy/README.md:580`=`сколько всего может занять один проход свипа`, `cmd/tmplatformd/runner.go` `one`, `internal/config/config.go` `SweepBudget` | **Рантбук называет `TM_PLATFORM_SWEEP_BUDGET` ручкой «одного прохода свипа» и не говорит, КАКОГО из пяти, — а два бюджета из пяти оператору недоступны вовсе.** Один такт прогоняет ПЯТЬ проходов подряд, и только три берут `sweepBudget`; проход материализации берёт `refreshSweepBudget` 10 минут, проход интейка — `intakeSweepBudget` = `jobs.JobTimeout` + `readmodel.MaterializeBudget` + минута = 21 минута, и обе константы оператору недоступны вовсе. Собственный доккомментарий кода при этом ТОЧЕН («SweepBudget is what ONE pass of the RUN sweep may take») — расходится именно операторская проза, и расходится в разделе «Застрявшая работа: что оператор делает, когда свип не справляется», то есть там, где по ней и будут действовать. Родня `PD-368`: та про то, что поднимать надо пару, эта про то, что ручка не покрывает такт ⚠ Арифметика 37 минут пере-проверена и держится: 3×2 + 10 + 21. Воспроизведение — `docs/p8-review/axis3-queue/probe_sweep_serialisation_test.go.txt` (последовательность тактов) плюс `sed -n '264,272p' deploy/README.md` | open | ревью-пак P8-REVIEW, ось 3 (находка рефутера) |
|
||||
| PD-387 | doc | minor | `deploy/README.md:592`=`сколько всего может занять один проход свипа`, `cmd/tmplatformd/runner.go` `one`, `internal/config/config.go` `SweepBudget` | **Рантбук называет `TM_PLATFORM_SWEEP_BUDGET` ручкой «одного прохода свипа» и не говорит, КАКОГО из пяти, — а два бюджета из пяти оператору недоступны вовсе.** Один такт прогоняет ПЯТЬ проходов подряд, и только три берут `sweepBudget`; проход материализации берёт `refreshSweepBudget` 10 минут, проход интейка — `intakeSweepBudget` = `jobs.JobTimeout` + `readmodel.MaterializeBudget` + минута = 21 минута, и обе константы оператору недоступны вовсе. Собственный доккомментарий кода при этом ТОЧЕН («SweepBudget is what ONE pass of the RUN sweep may take») — расходится именно операторская проза, и расходится в разделе «Застрявшая работа: что оператор делает, когда свип не справляется», то есть там, где по ней и будут действовать. Родня `PD-368`: та про то, что поднимать надо пару, эта про то, что ручка не покрывает такт ⚠ Арифметика 37 минут пере-проверена и держится: 3×2 + 10 + 21. Воспроизведение — `docs/p8-review/axis3-queue/probe_sweep_serialisation_test.go.txt` (последовательность тактов) плюс `sed -n '264,272p' deploy/README.md` | open | ревью-пак P8-REVIEW, ось 3 (находка рефутера) |
|
||||
| PD-389 | hardening | minor | `cmd/tmplatformd/runner.go:563`=`s.metrics.ObserveRunner(metrics.Runner{`, `cmd/tmplatformd/runner.go` `pass`, `internal/metrics/metrics_test.go` `TestTheRunnersStateIsExposedWithItsUnits` | **Шов телеметрии не покрыт НИЧЕМ, и это доказуемо без прогона батареи: `sweep` и `observe` — неэкспортируемые функции пакета `main`, то есть из другого пакета их не может вызвать ни один тест в принципе,** а единственный тест-файл каталога несёт два теста, оба про другое. Проверено тремя посадками, пережившими полную батарею: `StalledRuns: o.StalledRuns` в ноль (наблюдаемая половина закрытого BLOCKER `PD-346`), `errors.Is(err, context.DeadlineExceeded)` в false (`sweep_unfinished_total` больше не может вырасти — `PD-351` со стороны ВЫЗЫВАЮЩЕГО, куда пин `TestAPassThatRanOutOfTimeSaysSo` по построению не достаёт), и перестановка `queue_depth` с `live_runs`. Дыра шире шва: пин формы, на который ссылается `STACK_DECISIONS` §24, задаёт литерал `metrics.Runner` из ШЕСТИ полей из восьми — `StalledRuns` и `AbandonedSurfaces` в него не входят, поэтому мутация внутри самого `ObserveRunner` тоже выживает. Пере-проверено координатором пака независимо: снятие `m.stalledRuns.Set(...)` и снятие `m.abandonedSurfaces.Set(...)` по отдельности проходят ПОЛНУЮ батарею (18 пакетов), при том что снятие соседнего инкремента `sweepUnfinished` тем же пином ловится. То есть операторская ручка, построенная паком P8-FIX в ответ на `PD-169`, не пиньётся ничем. Воспроизведение: `docs/p8-review/mutations-full.log` и `docs/p8-review/axis4-metrics/60-mutations.sh` | open | ревью-пак P8-REVIEW, ось 4 (посадки финдера, рефутера и координатора) |
|
||||
| PD-390 | hardening | minor | `cmd/tmplatformd/runner.go:560`=`log.Warn("the control plane's own state could not be read", "err", err)`, `internal/pgstore/observe.go` `Observe`, `internal/metrics/metrics.go` `ObserveRunner` | **Гейджи замирают при отказе телеметрического чтения, и признака устаревания в экспозиции нет.** `STACK_DECISIONS` §24 объявляет «значения снимает СВИП, а не скрейп», но у снятого значения нет ни отметки свежести, ни счётчика неудач: `observe()` при ошибке пишет один WARN и возвращается, НЕ тронув ни одного гейджа, а Prometheus такой ряд устаревшим не помечает — цель жива, ряд на месте, значение старое. Живой замер: при сломанном чтении и одновременно вылеченном мире экспозиция продолжала утверждать «1 застрявший прогон, 1 карантин, 1 живой прогон, холд возрастом 1201 с», тогда как в базе застрявших было 0; при этом `sweep_duration_seconds_count` рос по всем четырём проходам, то есть все «жив ли свип» сигналы оставались зелёными. `Observe` — ОДИН стейтмент на все восемь чисел, поэтому любая его поломка гасит все гейджи разом, а сам `observe()` вызывается ВНЕ `pass()`, поэтому своего ряда в `sweep_duration_seconds` у него нет и его отказ там не виден. Обратное направление хуже: процесс, у которого чтение не удалось НИ РАЗУ, отдаёт нули как здоровье, и `/readyz` с `/healthz` при этом зелёные. ⚠ Вторая половина того же корня, найденная рефутером: инстанс-ЧИТАТЕЛЬ (пустой `TM_PLATFORM_ENGINE_BIN` — объявленная форма деплоя) не запускает свип вовсе, `observe()` не зовётся ни разу, а метрики созданы раньше и регистрируют все восемь гейджей безусловно, поэтому реплика уверенно отвечает `runs_stalled 0`, `queue_depth 0`, `oldest_open_hold_seconds 0` про контрол-плейн, который она не измеряет — и тут нет даже WARN-строки. Лечится дёшево: `*_last_success_timestamp_seconds` либо счётчик неудач наблюдения плюс проведение `observe` через тот же `pass`. Воспроизведение: `docs/p8-review/axis4-metrics/30-stale-gauges.sh`, `r1-boot-with-blind-telemetry.sh`, `r6-read-replica-zeroes.sh` | open | ревью-пак P8-REVIEW, ось 4 (живой замер, расширено рефутером) |
|
||||
| PD-392 | hardening | minor | `internal/metrics/metrics.go:91`=`Namespace: namespace, Name: "quarantined_attempts"`, `internal/metrics/metrics.go` `oldest_open_hold_seconds`, `cmd/tmplatformctl/runs.go` `listRuns` | **Две метрики без ручки — тот самый класс, за который `PD-169` стоял BLOCKER'ом, в двух других местах.** Help гейджа карантина сам называет цену («Such a run keeps going and keeps spending»), но команды, называющей строку за этим числом, нет: `grep -rn quarantine cmd/` не даёт ни одного хита, и в таблице `runs` колонки карантина тоже нет. У возраста холда ручка формально есть — `balance --user`, — но она требует идентификатор аккаунта, которого гейдж не даёт, а документированный случай самого гейджа («A hold outlives its run only when a settlement could not be made») — это холд ЗАКОНЧЕННОГО прогона, которого список не показывает по построению. Живая проба на состоянии, произведённом ШТАТНЫМ операторским сценарием (abandon застрявшего прогона): при `tm_platform_oldest_open_hold_seconds 10813` команды отвечают «no run is live», «no run is failing to reconcile» и «no book has been given up on», а единственный путь к строке — psql, то есть ровно то, что эти числа заводились заменить. Глобального списка открытых холдов в CLI нет. Воспроизведение: `docs/p8-review/axis4-metrics/50-gauges-without-a-handle.sh` и `r5-hold-without-a-handle.sh` ⚠ **Общий корень с `PD-385`, и там же он взвешен:** сужение ЭТОЙ строки опирается на операторскую поверхность, несостоятельность которой доказывает соседняя строка того же пака — круговое сужение разобрано в `PD-385`, поднятой до major ⚠ **ПАК P13 03.09: половина про КАРАНТИН закрыта в дереве, строка сужается до возраста холда.** `grep -rn quarantine cmd/` теперь даёт хиты: `tmplatformctl runs` печатает колонку QUARANTINE с причиной, `tmplatformctl run unquarantine --run <id>` снимает её (`PD-426`). Ручки для `oldest_open_hold_seconds` по-прежнему нет — этот остаток и держит строку открытой. Статус — акт лендинга | open | ревью-пак P8-REVIEW, ось 4 (живая проба, подтверждено рефутером) |
|
||||
|
|
@ -89,7 +89,7 @@
|
|||
| PD-298 | bug | info | `internal/pgstore/readmodel.go` `ListNotes`, `internal/pgstore/sink.go` `unitDone` | **Снятие флага с замечания дельта-чтение выразить не может.** Резолюция, пере-разрешённая как не-`flagged` (редрайв), обновляет строку и двигает `revision`, но дельта фильтруется предикатом `ur.flagged` — строка не возвращается, и клиент никогда не узнаёт, что замечание снято: оно остаётся на экране навсегда. Канон §AfterVersion: «A DELETION cannot be expressed this way», и требует одного из двух ответов — `resync_required` либо `400 version_too_old`; здесь не даётся ни один. ⚠ НЕ подтверждено, что движок вообще пере-издаёт `unit_done` для той же тройки (глава, юнит, волна) с `flagged=false` — комментарий `sink.go` это УТВЕРЖДАЕТ («a redrive re-attacks a flagged one»), но чтением движка не сверено. Порядок: сначала сверка у движка, потом либо счётчик замены для замечаний, либо строка «переход недостижим» ⚠ **ДИСПОЗИЦИЯ АКТА 5 (сверка с движком контрактной сессией 20.08): переход НЕДОСТИЖИМ и механизма не строим.** Движок объявляет вердикт юнита один раз на волну, его announce-once-леджер не пере-announce-ит, поэтому единственная пере-доставка — ТОТ ЖЕ вердикт. Инвариант записан в коде (`sink.go` `unitDone`): если что-то научится снимать флаг, фолд обязан выдать кадр — иначе счётчик разъедется со списком молча. Строка держится открытой этим долгом, а не живым дефектом | open | приёмка P7 → доработка 20.08 (сверка) |
|
||||
| PD-299 | standards | info | `internal/httpapi/conditional.go` `acceptsGzip` | **`Accept-Encoding: identity;q=0` не отвечает `406`.** Клиент, потребовавший ЛЮБОГО кодирования кроме identity, получает identity. Половина RFC 9110 §12.5.3, которую правка PD-268 не закрыла: gzip-сторона (именованное кодирование выигрывает у `*`, нулевой вес — отказ) закрыта и пиньётся, эта — нет. Достижимо только специально сконструированным клиентом; ни один генерённый по контракту клиент так не делает | open | доработка 20.08 (сверка находок против дерева) |
|
||||
| PD-368 | hardening | info | `internal/config/config.go`, `internal/runs/reconcile.go` `phaseBudget` | **Пара `TM_PLATFORM_SWEEP_BUDGET`/`TM_PLATFORM_RUN_BUDGET` не проверяется на когерентность на буте, и поднять ОДИН из них — тихий no-op:** фазе достаётся половина прохода, поэтому любое значение `RunBudget` от половины прохода и выше наблюдаемо неотличимо от дефолта. Ревью заходило сюда как в major («поднять бюджет = объявить здоровый прогон застрявшим») и **это опровергнуто исполнением**: поднятие ручки не меняет вообще ничего, вердикт при дефолтах существует и без оператора, а строгая проверка `RunBudget < SweepBudget/2` отвергла бы сами шиппящиеся дефолты (60 с против 60 с). Остаётся эргономика: поднимать надо `SweepBudget`, и об этом не сказано нигде, кроме доккоммента ⚠ **ПАК P8-REVIEW 24.08: остаток («поднимать надо `SweepBudget`, и об этом не сказано нигде, кроме доккомментария») УЖЕ ЗАКРЫТ, и закрыт тем же паком, чья приёмка эту строку написала.** `deploy/README.md` несёт таблицу обеих ручек и прямо под ней ⚠-абзац «Поднимать надо ПАРУ, а не одну… `RUN_BUDGET` выше половины `SWEEP_BUDGET` наблюдаемо ничего не меняет», со ссылкой на этот же номер. Верным остаётся только то, что когерентность пары не проверяется на буте. ⚠ Связанное, но ДРУГОЕ: сама ручка не покрывает такт свипа целиком — `PD-387` ⚠⚠ **Уточнение рефутера, меняющее диспозицию: ЗАКРЫВАТЬ строку ЦЕЛИКОМ нельзя, только сузить.** Абзац рантбука приехал коммитом `31f1f82` — ТЕМ ЖЕ, который внёс саму строку, то есть остаток родился уже закрытым. А проверки когерентности пары на буте по-прежнему нет: `internal/config/config.go:460-463` грузит обе ручки без сверки. Сузить до этого и оставить `open`. ⚠ И соседство: абзац стоит под строкой `deploy/README.md` (греп `деньги В СВОЕЙ транзакции`), которая сама предмет открытой `PD-387` | open | воркфлоу-ревью волны 2 (P8-FIX), находка опровергнута, остаток зафиксирован |
|
||||
| PD-373 | doc | info | `internal/readmodel/readmodel.go:64`=`const maxAttempts = 5`, `deploy/README.md:565`=`После пяти неудач` | **У числа попыток материализации ДВА носителя и ничего между ними.** Код держит `const maxAttempts = 5`, рантбук оператора пишет «После пяти неудач долг списывается». **Посажена мутация оркестратором вне списка автора:** `maxAttempts` 5 → 500000, батарея зелёная — пин `readmodel_test.go` ездит `for attempts := range maxAttempts`, то есть доказывает МЕХАНИЗМ при любом значении константы, что само по себе правильно. Незакрытым остаётся другое: подняли константу — рантбук молча начал лгать оператору о том, когда платформа сдаётся. ⚠ Тот же ход у `runs.StalledAfter` проверен и НАРУШЕНИЯ НЕ ДАЛ: там носитель ровно один (рантбук пишет «сколько неудач подряд» без числа), мутация тоже выжила и это законно. Лечение — либо гейт на второй носитель ровно той формы, что пак построил для `ContractVersion` (`internal/gates/contract_test.go` читает канон, а не копию числа), либо число уходит из прозы | open | приёмка P8-FIX (посадка мутации оркестратором №18) |
|
||||
| PD-373 | doc | info | `internal/readmodel/readmodel.go:64`=`const maxAttempts = 5`, `deploy/README.md:577`=`После пяти неудач` | **У числа попыток материализации ДВА носителя и ничего между ними.** Код держит `const maxAttempts = 5`, рантбук оператора пишет «После пяти неудач долг списывается». **Посажена мутация оркестратором вне списка автора:** `maxAttempts` 5 → 500000, батарея зелёная — пин `readmodel_test.go` ездит `for attempts := range maxAttempts`, то есть доказывает МЕХАНИЗМ при любом значении константы, что само по себе правильно. Незакрытым остаётся другое: подняли константу — рантбук молча начал лгать оператору о том, когда платформа сдаётся. ⚠ Тот же ход у `runs.StalledAfter` проверен и НАРУШЕНИЯ НЕ ДАЛ: там носитель ровно один (рантбук пишет «сколько неудач подряд» без числа), мутация тоже выжила и это законно. Лечение — либо гейт на второй носитель ровно той формы, что пак построил для `ContractVersion` (`internal/gates/contract_test.go` читает канон, а не копию числа), либо число уходит из прозы | open | приёмка P8-FIX (посадка мутации оркестратором №18) |
|
||||
| PD-374 | doc | info | `docs/STACK_DECISIONS.md` «Гейты батареи», `internal/runner/systemd_test.go` `systemdOrSkip`, `Makefile` цель `check` | **Рецепт объявляет у батареи ДВА гейта и ждёт «скипов 0» — а условий три, и третье не названо.** Кроме `TM_PLATFORM_TEST_DSN` и пары `TM_PLATFORM_TEST_ENGINE_BIN`/`_BOOK_TEMPLATE` есть `systemdOrSkip`: без ДОСТИЖИМОГО пользовательского менеджера systemd три теста `internal/runner` скипаются. Поймано пере-прогоном батареи при приёмке: на этом хосте `/run/user/1000` не существует (сессия logind не поднята), поэтому «скипов 0» недостижимо в принципе — `make check` дал 18 пакетов, exit 0, линтер 0 issues и **3 скипа**. Мимо: сама цель `check` печатает над списком скипов «set TM_PLATFORM_TEST_DSN», отправляя читателя к ручке, которая тут ни при чём. Отчёт пака честен и это подтверждает — он мерил отдельно и получил 0, что верно на хосте с живым менеджером. Лечение: назвать третий гейт в рецепте вместе с двумя и не обещать «скипов 0» без него; заодно сделать сообщение цели `check` не называющим одну переменную из трёх ⚠ **ПАК P8-REVIEW 24.08: половина лечения УЖЕ в дереве.** `docs/ENGINEERING_STANDARDS.md` §3 п.1 переписан и называет ТРИ условия поимённо, включая достижимый пользовательский менеджер systemd, со ссылкой на этот номер. Названные строкой носители не тронуты: `docs/STACK_DECISIONS.md` по-прежнему пишет «Гейты батареи — их ДВА» и «скипов 0», а сообщение цели `check` в `Makefile` называет одну переменную из трёх. Предложение пака: сузить до этих двух. ⚠ На ЭТОМ хосте все три условия выполнимы (`/run/user/1000` жив), поэтому пак снял базовую линию со скипами 0 — см. `docs/p8-review/battery-final.log` ⚠ **ПАК P13 03.09: половина `Makefile` вылечена в дереве.** Цель `check` над списком скипов печатает условия хоста через новую цель `make conditions`, и перечень ВЫВОДИТСЯ из тестовых исходников по ВЫЗОВУ, а не по имени в прозе: `os.Getenv("TM_PLATFORM_TEST_…")` даёт переменные, `exec.LookPath("…")` — бинари, которых требуют хелперы (`systemd-run`, `python3`, `make`), плюс своя проба `systemdOrSkip` (достижимый пользовательский менеджер systemd); отдельной строкой назван CREATEDB, который спрашивается только при создании скретч-базы и пробой не проверяется. Литерала с одной переменной больше нет. Гейт `internal/gates` `TestTheBatteryNamesEveryHostConditionItsTestsRead` держит цель к исходникам: краснеет, когда перечень перестаёт выводиться (литеральный список), когда колонка состояния печатает слово вместо ответа хоста и когда колонка «read by» приписывает условие пакету, который его не читает. ⚠ Новая переменная в любом тесте гейт НЕ краснит — цель и гейт выводят её из одних и тех же исходников, поэтому она появляется в обоих; краснеет именно ОТКАЗ от вывода. `STACK_DECISIONS` «Гейты батареи» уже называет четыре условия. Статус — акт лендинга | open | приёмка P8-FIX (пере-прогон батареи оркестратором №18) |
|
||||
| PD-6 | hardening | info | `internal/auth/csrf.go:51` | GET освобождён от CSRF (верно), но SSE-хендшейк — GET с амбиентной кукой: origin-чек хендшейка потока (STACK §5) не покрыт ничем. Закрыть при постройке SSE (P1) ⚠ **ПАК P8-REVIEW 24.08: условие закрытия НАСТУПИЛО, лекарство не приехало, а защита оказалась ТРАНЗИТИВНОЙ.** SSE построен (`internal/httpapi/v0.go` маршрут `/books/{bookId}/events`, `internal/httpapi/stream.go` `streamEvents`, коммит `9b23e8c`), origin-чека не появилось: GET освобождён в `internal/auth/csrf.go` `cookieUnsafe`, а `http.CrossOriginProtection` судит только unsafe-методы. Живая проба: хендшейк с `Origin: https://evil.example` и амбиентной кукой отвечает 200 и стримит, без куки 401, заголовок `X-TM-Client` не требуется (`docs/p8-review/sse-origin-probe.txt`). ⚠ Вес поднимать НЕ предлагается, и это измеренная поправка к предложению аудита: браузерный случай сегодня закрыт ТРЕМЯ механизмами, ни один из которых не является чеком хендшейка — кука `SameSite=Lax` не уходит на кросс-сайтовый подзапрос, префикс `__Host-` не отдаёт её соседнему поддомену, а CORS-слоя нет вовсе (`PD-96`), поэтому кросс-origin `EventSource` браузер странице не отдаст. Это ровно класс `PD-86`: свойство держится, и держат его посторонние механизмы, ни один из которых не запинен как защита хендшейка. Диспозиция — вопрос приёмке: чек хендшейка или явное принятие с записью трёх носителей | open | приёмка P0 (security-линза) |
|
||||
| PD-23 | hardening | info | `internal/pgstore/migrations/00001_identity.sql` | Журнал входов растёт без ретенции и чистится только каскадом при удалении аккаунта. Нужен свип по возрасту (год?) — вопрос политики, не кода ⚠ **ПРОВЕРЕНО ПАКОМ P8-REVIEW 24.08 И ПРОТИВ ДЕРЕВА ЛОЖНО: ретенция ПОСТРОЕНА и РАБОТАЕТ.** `internal/pgstore/identity.go` `DeleteOldLoginEvents` (`delete from login_events where at < $1`) зовётся из `cmd/tmplatformd/main.go` циклом `sweepLogins` каждые 15 минут с окном `loginJournalRetention = 180 * 24 * time.Hour`, и свип монтируется в ОБЕИХ ветках входа. Приехало коммитом `9b23e8c` (лендинг P7), строка не обновлялась с P1. Доказано не чтением: строка возрастом 200 суток исчезла на ближайшем тике, демон напечатал `"login sweep" events=1` (`docs/p8-review/pd23-result.txt`). Предложение пака: ЗАКРЫТЬ, срок 180 суток отметить как выбранную политику. Незапиненность самого механизма вынесена отдельной строкой `PD-383` | open | самопроверка P1 |
|
||||
|
|
@ -617,4 +617,4 @@
|
|||
| ID | Класс | Серьёзность | Где | Суть | Статус | Источник |
|
||||
|---|---|---|---|---|---|---|
|
||||
| PD-442 | bug | minor | `internal/pgstore/migrations/00002_readmodel.sql` (таблица `exports`), канон `14-api-contract/openapi.yaml` §Export | **В схеме с самой первой миграции читающей модели жила таблица `exports`, у которой НЕТ ни одного читателя и ни одного писателя, и её форма ПРОТИВОРЕЧИТ ратифицированному контракту.** Она несла `ready boolean` плюс `failed_reason text`, а канон объясняет прямо, почему так нельзя: «A state and not a boolean: a boolean merges three situations into "not ready" and a poll on it never ends» (§`Export.state`). То есть будущая дверь выдачи, честно построенная на существующей таблице, отдала бы поллинг, который не кончается — ровно тот дефект, который канон и запрещает. Обнаружено при постройке двери: `sqlc generate` отказался с `relation "exports" already exists`, и это единственный сторож, который у класса был. Проверка отсутствия потребителей: `grep -rn '\bexports\b' --include=*.go internal/ cmd/` до пака давал ОДИН хит и тот в комментарии (`internal/httpapi/capabilities.go`). ⚠ Мигрировать было нечего — ни строки ни разу не записывалось, — поэтому `00032_exports.sql` таблицу СНОСИТ и создаёт заново в форме канона; down-путь восстанавливает форму `00002` дословно, потому что откат обязан вернуть то, что выпущенная миграция оставила. Пин формы: `internal/pgstore/exports_test.go` (четыре состояния, партиальные индексы свипа, каскад с книгой) | fixed(дерево пака «закрыть цикл», 04.09) | пак «закрыть цикл» 04.09 (найдено постройкой двери выдачи) |
|
||||
| PD-447 | doc | minor | `docs/STACK_DECISIONS.md` §«Как поднять локально» и `deploy/README.md` шаг 3–4 (оба исправлены); носители дефолта — `internal/config/config.go:316`=`TM_PLATFORM_ADDR` и `cmd/tmplatformctl/seed.go:41`=`base URL of the running tmplatformd` | **Оба стендовых рецепта зоны принимали ОТВЕТ ПО АДРЕСУ за доказательство того, что отвечает СВОЙ процесс, и потому проходили при мёртвом собственном демоне.** Замерено исполнением 04.09: на машине разработки четвёртые сутки жил чужой `tmplatformd` на `127.0.0.1:8080` со своей базой; демон рецепта умирает в этой ситуации на `bind: address already in use` — молча, он запущен фоном, — а смоук следующей строкой получает `healthz=200` и `readyz=ready` ОТ ЧУЖОГО ПРОЦЕССА. ⚠ **Дороже смоука — шаг сида:** `tmplatformctl seed` дарит `$25` кредита и грузит книгу ЧЕРЕЗ ЖИВОЙ ИНТЕЙК, то есть по этому рецепту деньги и данные уезжают в чужой деплой. Второй адрес там же был написан отдельным литералом (`--url http://127.0.0.1:8080`), что и есть штатный способ разъехаться с `TM_PLATFORM_ADDR` незаметно. ⚠ **Три лечения на три РАЗНЫЕ половины, ни одно не заменяет другое:** явный `_ADDR` уводит с общего дефолта; проба по `pid` слушателя отвечает на вопрос, на который `200` не отвечает в принципе — ЧЕЙ это процесс; `--noproxy '*'` нужен потому, что при заданных `http_proxy` голый `curl` на `127.0.0.1` уходит во внешний прокси. Проба предъявлена в обе стороны: свой pid на своём порту — проходит, чужой демон против своего pid — отвергается. ⚠ **Дефолт `8080` в коде НЕ меняется, и это решение:** коллизия — `tmplatformd` против `tmplatformd`, любой другой номер даст ту же аварию на втором одновременном стенде, а цену смены заплатят все существующие деплои и доки. Чинится посылка, а не номер. **Вес minor, а не major, по радиусу:** пишет только дев-стенды, боевого пути этим рецептом нет, деньги — стендовый грант. | fixed(`adf5e53`) | найдено оркестратором №22 при независимой проверке отзыва причины смертей демона, 04.09; починено зоной |
|
||||
| PD-447 | doc | minor | `docs/STACK_DECISIONS.md` §«Как поднять локально» и `deploy/README.md` шаг 3–4 (оба исправлены); носители дефолта — `internal/config/config.go:317`=`TM_PLATFORM_ADDR` и `cmd/tmplatformctl/seed.go:41`=`base URL of the running tmplatformd` | **Оба стендовых рецепта зоны принимали ОТВЕТ ПО АДРЕСУ за доказательство того, что отвечает СВОЙ процесс, и потому проходили при мёртвом собственном демоне.** Замерено исполнением 04.09: на машине разработки четвёртые сутки жил чужой `tmplatformd` на `127.0.0.1:8080` со своей базой; демон рецепта умирает в этой ситуации на `bind: address already in use` — молча, он запущен фоном, — а смоук следующей строкой получает `healthz=200` и `readyz=ready` ОТ ЧУЖОГО ПРОЦЕССА. ⚠ **Дороже смоука — шаг сида:** `tmplatformctl seed` дарит `$25` кредита и грузит книгу ЧЕРЕЗ ЖИВОЙ ИНТЕЙК, то есть по этому рецепту деньги и данные уезжают в чужой деплой. Второй адрес там же был написан отдельным литералом (`--url http://127.0.0.1:8080`), что и есть штатный способ разъехаться с `TM_PLATFORM_ADDR` незаметно. ⚠ **Три лечения на три РАЗНЫЕ половины, ни одно не заменяет другое:** явный `_ADDR` уводит с общего дефолта; проба по `pid` слушателя отвечает на вопрос, на который `200` не отвечает в принципе — ЧЕЙ это процесс; `--noproxy '*'` нужен потому, что при заданных `http_proxy` голый `curl` на `127.0.0.1` уходит во внешний прокси. Проба предъявлена в обе стороны: свой pid на своём порту — проходит, чужой демон против своего pid — отвергается. ⚠ **Дефолт `8080` в коде НЕ меняется, и это решение:** коллизия — `tmplatformd` против `tmplatformd`, любой другой номер даст ту же аварию на втором одновременном стенде, а цену смены заплатят все существующие деплои и доки. Чинится посылка, а не номер. **Вес minor, а не major, по радиусу:** пишет только дев-стенды, боевого пути этим рецептом нет, деньги — стендовый грант. | fixed(`adf5e53`) | найдено оркестратором №22 при независимой проверке отзыва причины смертей демона, 04.09; починено зоной |
|
||||
|
|
|
|||
|
|
@ -8,6 +8,82 @@
|
|||
> тобой. Разбор — секция «Соседи». Плюс строка **283** (структура глав: не-CJK txt приезжает одним
|
||||
> полотном) — пак её не касался, и она гейтит бету для не-CJK и EPUB-входов.
|
||||
|
||||
### ⛔ ДОФИКС ПО ПРИЁМКЕ (05.09, после `7e2226a`) — пять major и восемь мелких
|
||||
|
||||
Приёмка: 20 находок, 5 снято скептиками, 15 устояло, из них ПЯТЬ major. **Каждую проверил сам,
|
||||
прежде чем чинить** — ни одна не принята со слов. Ни одна не опровергнута: все пять подтвердились.
|
||||
|
||||
**`PV2-02` · книга в интейке не попадала в точку НИ ОДНИМ файлом, а точка звалась полной.** Ветка
|
||||
«базы ещё нет» возвращалась ДО копирования файлов, так что не уезжали ни `source.*`, ни `book.yaml`,
|
||||
ни журнал. ⚠ И `parsing` — не пятиминутное окно: `not_configured`/`storage_unavailable` никогда не
|
||||
становятся терминальными, поэтому на хосте со сломанным шаблоном ВСЕ загрузки стоят там сколько
|
||||
угодно — и это ровно те файлы, у которых нет второй копии нигде. **Починено разделением: файлы
|
||||
копируются ВСЕГДА и безусловно, вопрос про базу задаётся отдельно; `Skipped` теперь значит «нет
|
||||
проектной базы», а не «нет ничего».** Пин `TestABookStillInIntakeStillGetsItsSourceIntoThePoint`.
|
||||
|
||||
**`PV2-03` · ложная посылка в дискриминаторе, ценой — навсегда красный алерт.** Я обосновал пропуск
|
||||
`rejected` тем, что «каталог уходит вместе с исходником». Дерево: каталог сносится ТОЛЬКО при
|
||||
`ReasonSourceUnreadable`; второй терминальный путь (бюджет попыток исчерпан, движок не запускается)
|
||||
файл ОСТАВЛЯЕТ. Такая книга проваливалась между ветвями → дыра → `complete=false` навсегда → гейдж
|
||||
`+Inf` навсегда, и защита `prune` «не выбрасывать самую свежую ПОЛНУЮ точку» становилась холостой:
|
||||
полных точек не существовало. **Починено; пин `TestARejectedBookThatKeptItsDirectoryIsNotAHole`
|
||||
проверяет и то, что гейдж снова ОТДАЁТ возраст.**
|
||||
|
||||
**`PV2-04` · рецепт восстановления гасил демона, но прогоны ему НЕ дети.** Мой же документ семьюстами
|
||||
строками выше объясняет, что прогон — транзиентный юнит в СОБСТВЕННОМ менеджере и переживает деплой
|
||||
по построению. Шага «спросить и остановить прогоны» не было, а восстановление делают в худший день —
|
||||
именно тогда вероятен идущий оплаченный прогон, и `mv` подсунул бы файл под открытым дескриптором.
|
||||
**Шаг 1а добавлен**, с оговоркой, что останов прогона работу не теряет.
|
||||
|
||||
**`PV2-01` · гард на управляющие символы получила ОДНА дверь из двух — и отчёт звал это починенным.**
|
||||
Я экспортировала `MaxTitle` ровно ради «одной границы на две двери», и тут же оставила интейк с одной
|
||||
только длиной: титул, который пользователь ВВЁЛ, уходил мимо фильтра, а `NUL` доезжал до Postgres
|
||||
(`invalid byte sequence for encoding "UTF8": 0x00`), U+2028 приземлялся в имени книги. Класс —
|
||||
«поправил читателя, оставил писателя» на двух писателях ОДНОЙ колонки. **Починено; интейк ЧИСТИТ (а не
|
||||
отказывает — тело мультипарта уже принято, ронять из-за одной руны десятки мегабайт нельзя), дверь
|
||||
переименования по-прежнему ОТКАЗЫВАЕТ с указателем.** Пин `TestBothTitleWritersAgreeAboutControlCharacters`
|
||||
держит обе половины, включая ту, что bidi-марки и ZWJ трогать нельзя.
|
||||
|
||||
**`F2`/`PV2-05` · абзац рантбука про «тихий денежный симптом» был ИНВЕРТИРОВАН, и это моя ошибка того
|
||||
самого класса, за который я ловила других.** Я написала оркестратору «симптом пере-проверен по коду с
|
||||
замером обеих веток». Пере-проверена была ветка ФАЙЛА; ветка КЛЮЧА написана, а не проверена. Замер
|
||||
(`tmctl manifest --json`, одна книга, обе ветки):
|
||||
|
||||
| что потеряно | код выхода | что видит оператор |
|
||||
|---|---|---|
|
||||
| строчка `contrast_path` | **10** | конфиг НЕ ЗАГРУЖАЕТСЯ, отказ на ЛЮБОМ пути, включая `$0` |
|
||||
| сам файл артефакта | **0** | всё работает; отказывает только ПИШУЩИЙ путь |
|
||||
|
||||
То есть громкий и тихий были поменяны местами. **Абзац заменён таблицей замера.** ⚠ Заодно снята
|
||||
ссылка на `book_once_usd`: в ЗАКОММИЧЕННОМ движке этого поля нет (`F1`) — проверено тем же прогоном.
|
||||
|
||||
**Предъявлено после дофикса:** батарея `MAKE-EXIT=0`, 20 пакетов, **скипов ноль**, линтер 0 issues,
|
||||
`counts.py --lint` 0 красных якорей в `platform/` (четыре уехали от МОИХ ЖЕ правок дофикса и
|
||||
пере-нацелены), `--check` литералы сходятся. Восстановление рехёрсено **в пятый раз, на дофикснутом
|
||||
коде и по ОБНОВЛЁННОМУ рецепту**: точка снята (1 книга, `complete=true`), база дропнута до нуля
|
||||
таблиц, каталог книги снесён, `pg_restore` + `cp -a` + снятие хвостов `-wal` + переименование — баланс
|
||||
$25, книга «Мастер Гу», движок открывает файл 2/2.
|
||||
⛔ **ЧЕГО В ЭТОЙ ПРОБЕ НЕТ, и я говорю это прямо: шаг 1а (остановить прогоны в ИХ менеджере) НЕ
|
||||
ИСПОЛНЕН.** На стенде нет пользователя `tmplatform`, и команда честно отвечает
|
||||
`Failed to connect to bus: Operation not permitted`. То есть исправление `PV2-04` — это ТЕКСТ рецепта,
|
||||
выведенный из кода (прогон — транзиентный юнит в своём менеджере, D39.106) и из моего же документа,
|
||||
а НЕ проверенный исполнением шаг. Проверить его может только боевой хост с настоящим `tmplatform`.
|
||||
Записываю в препятствия, а не в предъявленное.
|
||||
|
||||
**Мелкие, взятые все восемь:** `F1` (выше) · `F3` тест «нет команды `restore`» упирался в DSN-гейт и
|
||||
не увидел бы подкоманду — теперь гоняется С базой и требует именно `errUsage` · `F8` моя вставка
|
||||
разлучила ЧУЖОЙ доккомментарий с его функцией — тест переехал в конец файла · `F9` арифметика
|
||||
«четверо суток» → 84 ч / ≈3,25 суток, в трёх носителях · `F11` мой же рецепт проверки
|
||||
(`redrive --dry-run`) открывает проект на ЗАПИСЬ и берёт flock — оговорка добавлена · `PV2-06`
|
||||
`dumpArgs` терял из DSN всё кроме host/port/user/dbname, то есть `sslmode=verify-full` тихо
|
||||
становился `prefer` — **теперь пересылается ВЕСЬ DSN минус пароль**, пин требует `sslmode`,
|
||||
`sslrootcert`, `connect_timeout` и запрещает пароль в argv · `PV2-08` восстановление воскрешает
|
||||
отозванные учётные данные — шаг чек-листа · `PV2-09` точка НЕ мгновение времени (реестр снимается
|
||||
до книг) — оговорка.
|
||||
|
||||
**Снято скептиками, не чинила** (пять): `PATCH` на непатчабельные члены · «EPUB с новым `dc:title` не
|
||||
доехал» · `BACKUP_DIR` вне песочницы · «у токена нет своего отзыва» · односторонность `--verify`.
|
||||
|
||||
### Комплектность против заказа: пункт → носитель → проба
|
||||
|
||||
| Пункт пака | Носитель | Чем предъявлено |
|
||||
|
|
@ -278,7 +354,7 @@ tmctl build --config <книга> --format epub --out … --partial
|
|||
|
||||
**Где живут точки и как часто.** Каталог называет ОПЕРАТОР (`TM_PLATFORM_BACKUP_DIR`), дефолта нет и
|
||||
не будет: любой придуманный нами путь лёг бы на тот же диск, потерю которого механизм и должен
|
||||
пережить. Каждые `6h`, `14` поколений ≈ четверо суток истории — интервал, который переживает
|
||||
пережить. Каждые `6h`, `14` поколений = 84 ч истории (самая старая ЖИВАЯ точка — 13 интервалов, ≈ 3,25 суток) — интервал, который переживает
|
||||
обнаружение аварии после выходных. ⚠ Требование «другое устройство» служба проверить НЕ МОЖЕТ
|
||||
(bind-mount и симлинк обманут любую пробу) — записано половиной сделки оператора и названо на буте.
|
||||
|
||||
|
|
@ -3595,7 +3671,7 @@ migrate → повтор») можно строить; строка открыт
|
|||
приёмкой (мёртвая цитата текста ошибки схемы в рантбуке и в строке П-1; образец в `tmplatformctl`,
|
||||
зовущий голый `tmctl` из PATH), закрыты правками и проверяются грепом: `grep -rn 'expects vM'
|
||||
platform/deploy platform/BACKLOG.md` — ноль строк; рантбук несёт живой токен
|
||||
(`platform/deploy/README.md:335`=`schema_mismatch found=N expected=M`); образец несёт
|
||||
(`platform/deploy/README.md:347`=`schema_mismatch found=N expected=M`); образец несёт
|
||||
версионированный путь (`platform/cmd/tmplatformctl/runs.go:53`=`The VERSIONED path and never a bare`).
|
||||
|
||||
⚠ **Живым остаётся мнение оркестратора:** прогнать деплой-рантбук end-to-end на дев-стенде
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ import (
|
|||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
|
@ -75,8 +76,6 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"textmachine/platform/internal/books"
|
||||
"textmachine/platform/internal/pgstore"
|
||||
"textmachine/platform/internal/runner"
|
||||
|
|
@ -185,7 +184,10 @@ type BookEntry struct {
|
|||
// Files are everything copied out of the book's directory, the consistent project database
|
||||
// among them (ProjectDBName).
|
||||
Files []File `json:"files"`
|
||||
// Skipped says why this book carries no files. Empty on a book that was copied.
|
||||
// Skipped says why this book carries no PROJECT DATABASE in the point — NOT that it carries
|
||||
// nothing. A book whose intake has not finished has its source, its `book.yaml` and its journal in
|
||||
// here and no database, because no database exists yet; `Files` is what it does carry. Empty when
|
||||
// the database is present.
|
||||
Skipped string `json:"skipped,omitempty"`
|
||||
// NotCopied names what inside the directory was deliberately left out, with the reason. It is
|
||||
// recorded rather than assumed known: a restore point that quietly omits something is the object
|
||||
|
|
@ -405,6 +407,8 @@ func restoreNotes() []string {
|
|||
"Restore Postgres with: pg_restore --clean --if-exists --no-owner -d <dsn> " + DumpFile,
|
||||
"Restore a book by copying " + BooksDir + "/<book id>/ back to its workdir, then renaming " +
|
||||
ProjectDBName + " to the file that book's book.yaml names in `project_db` — absent that key, <book id>.db.",
|
||||
"A book directory with NO " + ProjectDBName + " is not an error: that book's intake had not finished, so no " +
|
||||
"project database existed yet. Restore its files as they are.",
|
||||
"The engine's own pre-run restore points (a book's backups/ subdirectory) are NOT in here and are NOT superseded by these: " +
|
||||
"they are a different guarantee (a rollback point taken BEFORE a paid run, on the same disk) and are left untouched.",
|
||||
"Built exports and run-state markers are NOT in here: both are rebuildable and short-lived by design.",
|
||||
|
|
@ -453,10 +457,25 @@ func (s *Service) dumpPostgres(ctx context.Context, staging string) (File, error
|
|||
// (config.Config.DSN). It travels in the child's environment instead, which is the channel
|
||||
// PostgreSQL's own tools document for it (PGPASSWORD) and which is scoped to the child.
|
||||
func dumpArgs(dsn, out string) (args []string, env []string, err error) {
|
||||
cfg, err := pgconn.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
// The DSN itself is NOT in the message: it carries a password.
|
||||
return nil, nil, fmt.Errorf("backup: the configured DSN cannot be parsed for pg_dump: %w", err)
|
||||
// ⚠ THE WHOLE CONNECTION STRING IS FORWARDED, MINUS THE PASSWORD — it is not rebuilt from parts.
|
||||
// An earlier version passed host/port/user/dbname as four flags and dropped everything else,
|
||||
// which silently included `sslmode`: a deployment whose DSN says `verify-full` would have had its
|
||||
// nightly dump go out under libpq's default `prefer`, i.e. TLS optional and the certificate never
|
||||
// checked. Nothing would have said so — pg_dump succeeds either way. The same loss applies to
|
||||
// `sslrootcert`, `connect_timeout`, `application_name` and anything else an operator put there.
|
||||
//
|
||||
// The password is the one part that may not travel: a connection string on the command line is
|
||||
// readable by every process on the host through /proc/<pid>/cmdline. It goes in the child's
|
||||
// environment instead, which is the channel PostgreSQL's own tools document for it.
|
||||
u, perr := url.Parse(dsn)
|
||||
if perr != nil || u.Scheme == "" {
|
||||
// A key/value DSN (`host=… port=…`) is legal for libpq and cannot be edited as a URL. Refused
|
||||
// rather than mangled: the alternative is a dump taken under settings nobody chose.
|
||||
return nil, nil, fmt.Errorf("backup: the configured DSN is not a URL this platform can hand to %s without rewriting it; use the postgres:// form", "pg_dump")
|
||||
}
|
||||
password, _ := u.User.Password()
|
||||
if u.User != nil {
|
||||
u.User = url.User(u.User.Username())
|
||||
}
|
||||
args = []string{
|
||||
"--format=custom",
|
||||
|
|
@ -464,12 +483,9 @@ func dumpArgs(dsn, out string) (args []string, env []string, err error) {
|
|||
// No prompt, ever: this runs unattended, and a tool that stops for a password is a pass that
|
||||
// hangs until its budget kills it.
|
||||
"--no-password",
|
||||
"--host=" + cfg.Host,
|
||||
"--port=" + fmt.Sprint(cfg.Port),
|
||||
"--username=" + cfg.User,
|
||||
"--dbname=" + cfg.Database,
|
||||
"--dbname=" + u.String(),
|
||||
}
|
||||
env = append(os.Environ(), "PGPASSWORD="+cfg.Password)
|
||||
env = append(os.Environ(), "PGPASSWORD="+password)
|
||||
return args, env, nil
|
||||
}
|
||||
|
||||
|
|
@ -481,27 +497,46 @@ func (s *Service) copyBook(ctx context.Context, staging string, b pgstore.BookDi
|
|||
return entry, nil
|
||||
}
|
||||
if _, err := os.Stat(b.Workdir); errors.Is(err, fs.ErrNotExist) {
|
||||
// ⚠ A MISSING DIRECTORY IS TWO DIFFERENT FACTS, told apart by the book's own status — the same
|
||||
// discriminator, and for the same reason, as the engine's refusal below.
|
||||
// ⚠ A MISSING DIRECTORY IS TWO DIFFERENT FACTS, told apart by the book's own status.
|
||||
//
|
||||
// A REJECTED upload takes its directory with it, so a row outliving one is ordinary and the
|
||||
// point stays complete. Any other status means the directory should be there and is not: a
|
||||
// books volume that did not mount, or a tree that moved while the absolute workdirs in
|
||||
// Postgres stayed where they were. Calling that an ordinary skip is how a deployment comes to
|
||||
// publish points holding only the ledger, all marked complete, while the age gauge stays green
|
||||
// and retention quietly evicts the last point that held any paid work.
|
||||
// A REJECTED intake may take its directory with it, so a row outliving one is ordinary. Any
|
||||
// other status means the directory should be there and is not: a books volume that did not
|
||||
// mount, or a tree that moved while the absolute workdirs in Postgres stayed where they were.
|
||||
// Calling that an ordinary skip is how a deployment comes to publish points holding only the
|
||||
// ledger, all marked complete, while the age gauge stays green.
|
||||
if b.Status == statusRejected {
|
||||
entry.Skipped = "the book's intake was rejected, so its directory went with its source"
|
||||
entry.Skipped = "the book's intake was rejected and its directory is gone"
|
||||
return entry, nil
|
||||
}
|
||||
return entry, fmt.Errorf("the book's directory %s is not there, and its status (%s) says it should be", b.Workdir, b.Status)
|
||||
} else if err != nil {
|
||||
return entry, fmt.Errorf("stat the book's directory: %w", err)
|
||||
}
|
||||
|
||||
dest := filepath.Join(staging, BooksDir, b.ID)
|
||||
if err := os.MkdirAll(dest, 0o750); err != nil {
|
||||
return entry, fmt.Errorf("create the book's directory in the restore point: %w", err)
|
||||
}
|
||||
|
||||
// ⚠ THE DIRECTORY'S FILES ARE COPIED FIRST AND UNCONDITIONALLY, BEFORE ANY QUESTION ABOUT THE
|
||||
// DATABASE. An earlier version answered "this book has no project database yet" by returning
|
||||
// immediately, and so took NOTHING — not the uploaded source, not `book.yaml`, not the journal —
|
||||
// while the point still called itself complete.
|
||||
//
|
||||
// That was wrong on this package's own terms: the source is the one file in the directory nobody
|
||||
// can be asked to supply again, and a book can sit in `parsing` indefinitely rather than for a
|
||||
// moment — the intake's `not_configured` and `storage_unavailable` never become terminal
|
||||
// (internal/books/parse.go), so a host with a broken book template holds every upload in that
|
||||
// state. Those uploads are precisely the ones with no other copy anywhere.
|
||||
files, err := s.copyBookFiles(staging, b.Workdir, dest, &entry.NotCopied)
|
||||
if err != nil {
|
||||
return entry, err
|
||||
}
|
||||
entry.Files = files
|
||||
|
||||
if s.Cfg.EngineBinary == "" {
|
||||
// NOT a skip: an instance with no engine cannot copy ANY book, so every book would be
|
||||
// "skipped" and the point would be published holding nothing but the ledger. Refused as a
|
||||
// fault of the deployment, which is what it is.
|
||||
// NOT a skip: an instance with no engine cannot copy ANY book's database, so every book would
|
||||
// be "skipped" and the point would claim to be a backup of a library it never opened.
|
||||
return entry, errors.New("this instance has no engine binary, so no consistent database copy can be asked for")
|
||||
}
|
||||
res, err := s.Engine.Backup(ctx, s.Cfg.EngineBinary, b.Workdir)
|
||||
|
|
@ -511,30 +546,28 @@ func (s *Service) copyBook(ctx context.Context, staging string, b pgstore.BookDi
|
|||
if !res.Exited || res.ExitCode != 0 {
|
||||
// ⚠ THE ENGINE'S REFUSAL IS TWO DIFFERENT FACTS AND THEY MUST NOT BE ONE SKIP.
|
||||
//
|
||||
// A book whose intake has not finished has no project database at all, and the engine says so
|
||||
// rather than failing — an ordinary state that leaves the point COMPLETE. Every other refusal
|
||||
// is a book that HAS paid work which this point does not contain, and calling that "skipped"
|
||||
// the same way is how a point comes to say `complete: true` over a hole.
|
||||
// A book whose intake has not finished — or was rejected — has no project database at all, and
|
||||
// the engine says so rather than failing: an ordinary state that leaves the point COMPLETE,
|
||||
// because the book's FILES are already in it and there is no paid work to lose. Every other
|
||||
// refusal is a book that HAS paid work which this point does not contain, and calling that
|
||||
// "skipped" the same way is how a point comes to say `complete: true` over a hole.
|
||||
//
|
||||
// The discriminator is the PLATFORM's own fact — the book's intake status — and never the
|
||||
// engine's exit code or its prose: this side cannot tell "no database yet" from "a broken
|
||||
// deployment" by a number, and reading it out of a message would be the same unversioned
|
||||
// coupling PD-449 already carries once.
|
||||
if notYetCut(b.Status) {
|
||||
entry.Skipped = fmt.Sprintf("the book's intake has not finished (%s), so it has no project database yet: %s",
|
||||
if notYetCut(b.Status) || b.Status == statusRejected {
|
||||
entry.Skipped = fmt.Sprintf("no project database yet (status %s), but the book's own files ARE in this point: %s",
|
||||
b.Status, res.Stderr)
|
||||
return entry, nil
|
||||
}
|
||||
return entry, fmt.Errorf("the engine made no copy of a book that should have one (exit %d): %s",
|
||||
res.ExitCode, res.Stderr)
|
||||
}
|
||||
dest := filepath.Join(staging, BooksDir, b.ID)
|
||||
if err := os.MkdirAll(dest, 0o750); err != nil {
|
||||
return entry, fmt.Errorf("create the book's directory in the restore point: %w", err)
|
||||
}
|
||||
// The consistent snapshot first, under the fixed name the manifest's notes tell an operator to
|
||||
// rename back. The engine's copy is then removed: it was made for this pass, and leaving it
|
||||
// would grow the book's own directory by a whole database every cycle.
|
||||
|
||||
// The consistent snapshot, under the fixed name the manifest's notes tell an operator to rename
|
||||
// back. The engine's copy is then removed: it was made for this pass, and leaving it would grow
|
||||
// the book's own directory by a whole database every cycle.
|
||||
f, err := copyInto(staging, res.Path, filepath.Join(dest, ProjectDBName))
|
||||
if err != nil {
|
||||
return entry, err
|
||||
|
|
@ -544,11 +577,6 @@ func (s *Service) copyBook(ctx context.Context, staging string, b pgstore.BookDi
|
|||
s.log().Warn("the engine's copy could not be removed after it was taken into the restore point",
|
||||
"path", res.Path, "err", err)
|
||||
}
|
||||
rest, err := s.copyBookFiles(staging, b.Workdir, dest, &entry.NotCopied)
|
||||
if err != nil {
|
||||
return entry, err
|
||||
}
|
||||
entry.Files = append(entry.Files, rest...)
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -741,6 +741,89 @@ func TestARunOfDegradedPointsCannotEvictTheLastCompleteOne(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// ⛔ PV2-02: A BOOK STILL IN INTAKE MUST GET ITS FILES INTO THE POINT. It has no project database
|
||||
// yet, and an earlier version answered that by taking NOTHING — not the uploaded source, not
|
||||
// `book.yaml`, not the journal — while the point still called itself complete.
|
||||
//
|
||||
// ⚠ `parsing` is not a five-minute window: the intake's `not_configured` and `storage_unavailable`
|
||||
// never become terminal (internal/books/parse.go), so a host with a broken book template holds every
|
||||
// upload in that state indefinitely. Those uploads are exactly the ones with no other copy anywhere,
|
||||
// and this package's own comment calls the source irreplaceable.
|
||||
//
|
||||
// Mutation caught: returning from copyBook before copyBookFiles on the not-yet-cut branch.
|
||||
func TestABookStillInIntakeStillGetsItsSourceIntoThePoint(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, status := range []string{"uploading", "parsing"} {
|
||||
b := aBook(t, root, "bk_"+status)
|
||||
b.Status = status
|
||||
svc, _ := service(t, []pgstore.BookDirectory{b}, &fakeEngine{refuse: true}, false)
|
||||
res, err := svc.Take(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", status, err)
|
||||
}
|
||||
if !res.Complete {
|
||||
t.Errorf("%s: a book that legitimately has no database made the point incomplete", status)
|
||||
}
|
||||
// THE POINT: its own files are here, whatever the database situation.
|
||||
for _, want := range []string{"source.txt", "book.yaml", "events.jsonl"} {
|
||||
p := filepath.Join(res.Path, BooksDir, b.ID, want)
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Errorf("%s: %s is not in the point — the user's own upload was dropped: %v", status, want, err)
|
||||
}
|
||||
}
|
||||
m, err := ReadManifest(res.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.Books[0].Files) == 0 {
|
||||
t.Errorf("%s: the manifest lists no files for a book whose directory was full", status)
|
||||
}
|
||||
if m.Books[0].Skipped == "" {
|
||||
t.Errorf("%s: the absent database is not explained", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ⛔ PV2-03: A REJECTED BOOK THAT KEPT ITS DIRECTORY IS NOT A HOLE. Only one of the two terminal
|
||||
// reject paths removes the directory — `ReasonSourceUnreadable` — while the other (the attempt budget
|
||||
// exhausted because the engine would not run) deliberately KEEPS the user's file
|
||||
// (internal/books/parse.go, `if reason == ReasonSourceUnreadable { s.removeDir(...) }`).
|
||||
//
|
||||
// Such a book has a directory and no database, which fell between the branches and made every point
|
||||
// incomplete FOREVER: the age gauge then reads +Inf permanently, the one alertable signal of real
|
||||
// backup loss is stuck red and worthless, and prune's "never evict the newest COMPLETE point" becomes
|
||||
// vacuous because no complete point exists. And the cause that produces such books — the engine not
|
||||
// running — is the same deployment incident.
|
||||
//
|
||||
// Mutation caught: dropping `|| b.Status == statusRejected` from the engine-refusal branch.
|
||||
func TestARejectedBookThatKeptItsDirectoryIsNotAHole(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
b := aBook(t, root, "bk_rejected_kept")
|
||||
b.Status = "rejected" // its directory is right there: the reject path kept the user's file
|
||||
svc, dest := service(t, []pgstore.BookDirectory{b}, &fakeEngine{refuse: true}, false)
|
||||
at := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
|
||||
svc.Now = func() time.Time { return at }
|
||||
res, err := svc.Take(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Complete {
|
||||
t.Fatal("a rejected book that kept its directory made the point incomplete — the gauge would stick at +Inf forever")
|
||||
}
|
||||
// The gauge must actually report an age, which is the half that matters operationally.
|
||||
age, has, err := svc.Age(at.Add(time.Hour))
|
||||
if err != nil || !has || age != time.Hour {
|
||||
t.Errorf("the age gauge has no complete point to report: age=%v has=%v err=%v", age, has, err)
|
||||
}
|
||||
// And the user's file the deployment deliberately kept is in the point.
|
||||
if _, err := os.Stat(filepath.Join(res.Path, BooksDir, b.ID, "source.txt")); err != nil {
|
||||
t.Errorf("the source the reject path kept was not backed up: %v", err)
|
||||
}
|
||||
if points, _ := List(dest); len(points) != 1 {
|
||||
t.Errorf("points: %v", points)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify is what makes the manifest a guarantee rather than a description: a copy that rotted on the
|
||||
// disk is indistinguishable from a good one by size alone, and the moment to find out is not the
|
||||
// moment you need it.
|
||||
|
|
@ -868,12 +951,29 @@ func TestThePasswordTravelsInTheEnvironmentAndNeverInTheArgv(t *testing.T) {
|
|||
t.Error("the password did not reach the child's environment, so the dump would prompt and hang")
|
||||
}
|
||||
joined := strings.Join(args, " ")
|
||||
for _, want := range []string{"--host=db.example", "--port=6432", "--username=alice", "--dbname=tm",
|
||||
"--format=custom", "--file=/tmp/out.dump", "--no-password"} {
|
||||
for _, want := range []string{"db.example", "6432", "alice", "/tm", "--format=custom",
|
||||
"--file=/tmp/out.dump", "--no-password"} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("missing %s in %q", want, joined)
|
||||
}
|
||||
}
|
||||
// ⛔ AND EVERY OTHER PARAMETER THE OPERATOR PUT IN THE DSN, `sslmode` above all. An earlier version
|
||||
// rebuilt the connection from host/port/user/dbname and dropped the rest, so a deployment whose
|
||||
// DSN says `verify-full` had its nightly dump go out under libpq's default `prefer` — TLS optional,
|
||||
// certificate unchecked — and nothing said so, because pg_dump succeeds either way.
|
||||
strict, _, err := dumpArgs("postgres://alice:s3cr3t@db.example:6432/tm?sslmode=verify-full&sslrootcert=/etc/ca.pem&connect_timeout=9", "/tmp/o")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sj := strings.Join(strict, " ")
|
||||
for _, want := range []string{"sslmode=verify-full", "sslrootcert=/etc/ca.pem", "connect_timeout=9"} {
|
||||
if !strings.Contains(sj, want) {
|
||||
t.Errorf("the DSN's %s did not reach pg_dump — the dump would run under libpq defaults: %q", want, sj)
|
||||
}
|
||||
}
|
||||
if strings.Contains(sj, "s3cr3t") {
|
||||
t.Errorf("forwarding the DSN carried the password onto the argv: %q", sj)
|
||||
}
|
||||
// A DSN that cannot be parsed must not put itself into the error: that is the same leak by
|
||||
// another door.
|
||||
_, _, err = dumpArgs("://not a dsn:hunter2@", "/tmp/x")
|
||||
|
|
|
|||
|
|
@ -438,11 +438,45 @@ func (s *Service) owns(dir string) bool {
|
|||
// parse overwrites it.
|
||||
func (in Intake) title() string {
|
||||
if t := strings.TrimSpace(in.Title); t != "" {
|
||||
// ⚠ THE SAME CLEANING THE DERIVED BRANCH GETS, and it was missing here — the defect the
|
||||
// exported MaxTitle above exists to prevent, one field over. A title the user TYPED went
|
||||
// through length bounding alone, so a control character reached Postgres, which cannot hold
|
||||
// U+0000 in a `text` column: measured on a live database as
|
||||
// `ERROR: invalid byte sequence for encoding "UTF8": 0x00`, while U+2028 landed and the book
|
||||
// lived with a line separator in its name. The rename door refuses both (httpapi, the PATCH
|
||||
// handler); a deployment where one writer of a column is stricter than the other is the class
|
||||
// this file's own comment calls "how the intake and the patch come to disagree".
|
||||
//
|
||||
// Cleaned rather than refused HERE, unlike at the rename door, and the difference is the
|
||||
// contract's: `BookIntake.title` is one field of a multipart upload whose body has already
|
||||
// been received, and failing the whole upload over a stray character would throw away tens of
|
||||
// megabytes the user has just sent. The rename door has nothing to throw away, so it refuses
|
||||
// and says which member was wrong.
|
||||
if t = withoutControls(t); t == "" {
|
||||
// Nothing legible was left, so this is the same as having named nothing at all.
|
||||
return titleFrom(in.Filename)
|
||||
}
|
||||
return boundedTitle(t)
|
||||
}
|
||||
return titleFrom(in.Filename)
|
||||
}
|
||||
|
||||
// withoutControls drops what may not appear in a display name: Unicode Cc (C0 and C1 — NUL, the
|
||||
// newline, the escape) and the two line separators U+2028/U+2029.
|
||||
//
|
||||
// ⚠ AND DELIBERATELY NOT THE FORMAT CATEGORY (Cf). U+200E/U+200F and ZWJ/ZWNJ are ordinary content in
|
||||
// Hebrew, Arabic, Devanagari and Persian, and dropping "everything invisible" would quietly mangle a
|
||||
// legitimate title in a language pair this repository does not contain yet — the generality invariant,
|
||||
// not a hypothetical. The engine's own inbound fence draws the line in the same place.
|
||||
func withoutControls(s string) string {
|
||||
return strings.TrimSpace(strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) || r == '\u2028' || r == '\u2029' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s))
|
||||
}
|
||||
|
||||
// titleFrom is the book's name derived from the file's own.
|
||||
func titleFrom(filename string) string {
|
||||
name := filepath.Base(filepath.FromSlash(filename))
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
|
|
@ -72,3 +73,52 @@ func TestTheIntakeCounterCountsTheWriteStreamAndNotCharacters(t *testing.T) {
|
|||
t.Errorf("a UTF-16 source counted %d, exactly its character count — that is not what this counter does and the note on `counter` would be wrong", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ⛔ BOTH WRITERS OF `books.title` MUST AGREE ABOUT WHAT MAY BE IN IT. This file's own `MaxTitle`
|
||||
// exists because "two copies of the number is how the intake and the patch come to disagree" — and
|
||||
// the intake then disagreed with the rename door about something worse than a number: a title the
|
||||
// user TYPED went through length bounding alone, so U+0000 reached Postgres, which cannot hold it in
|
||||
// a `text` column, and U+2028 landed in a book's name.
|
||||
//
|
||||
// Found by acceptance, not by this session, in the very place the session had argued the border must
|
||||
// be single — the class "fixed the reader, left the writer" applied to two writers of one column.
|
||||
//
|
||||
// Mutation caught: dropping the withoutControls call from Intake.title.
|
||||
func TestBothTitleWritersAgreeAboutControlCharacters(t *testing.T) {
|
||||
for name, bad := range map[string]string{
|
||||
"NUL": "\u0000",
|
||||
"newline": "\u000a",
|
||||
"escape": "\u001b",
|
||||
"line separator": "\u2028",
|
||||
"paragraph separator": "\u2029",
|
||||
} {
|
||||
got := (Intake{Title: "be" + bad + "fore", Filename: "fallback.txt"}).title()
|
||||
for _, r := range got {
|
||||
if unicode.IsControl(r) || r == 0x2028 || r == 0x2029 {
|
||||
t.Errorf("%s: the intake stored %q, carrying U+%04X — the rename door refuses this", name, got, r)
|
||||
}
|
||||
}
|
||||
if got != "before" {
|
||||
t.Errorf("%s: got %q, want the title with only the offending rune removed", name, got)
|
||||
}
|
||||
}
|
||||
|
||||
// A title that is NOTHING BUT control characters leaves nothing legible, so the book is named from
|
||||
// its file — the same answer as naming nothing at all.
|
||||
if got := (Intake{Title: "\u0000\u000a", Filename: "Мастер Гу.txt"}).title(); got != "Мастер Гу" {
|
||||
t.Errorf("a title of only control characters gave %q, want the name derived from the file", got)
|
||||
}
|
||||
|
||||
// ⚠ And the half that must NOT be cleaned: bidi marks and the zero-width joiners are ordinary
|
||||
// content in Hebrew, Arabic, Devanagari and Persian. Dropping "everything invisible" would mangle
|
||||
// a legitimate title in a pair this repository does not carry yet.
|
||||
for name, title := range map[string]string{
|
||||
"right-to-left mark": "\u200fdavar",
|
||||
"zero-width joiner": "ka\u200dsha",
|
||||
"soft hyphen": "a\u00adb",
|
||||
} {
|
||||
if got := (Intake{Title: title, Filename: "f.txt"}).title(); got != title {
|
||||
t.Errorf("%s: a legitimate title was altered: %q -> %q", name, title, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,7 +198,8 @@ type BackupConfig struct {
|
|||
Dir string
|
||||
// Every is how often a restore point is taken; Keep is how many survive.
|
||||
//
|
||||
// Six hours and fourteen points — four days of history — because the two costs pull opposite
|
||||
// Six hours and fourteen points — 84 hours of history, of which the oldest LIVE point is 13
|
||||
// intervals back, about 3.25 days — because the two costs pull opposite
|
||||
// ways and neither is free: each point costs a full `VACUUM INTO` per book plus a whole dump, and
|
||||
// each one kept costs that much disk. Four days is what covers a fault discovered after a
|
||||
// weekend, which is the interval that actually decides whether a copy is still there when
|
||||
|
|
|
|||
|
|
@ -23,39 +23,6 @@ func scrape(t *testing.T, m *Metrics) string {
|
|||
// Labels carry the route PATTERN and never the path. A raw path is a user's library in an operator's
|
||||
// index (PD-3) and an unbounded number of series besides — the label would be chosen by whoever
|
||||
// sends the request.
|
||||
// The gauge's whole argument for being alertable is that a deployment with NO complete restore point
|
||||
// reads +Inf rather than 0 or NaN: `> threshold` is FALSE for both of those, so an instance that has
|
||||
// never made a backup would sit under every alert ever written for this series. The doc comment names
|
||||
// that as the wrong answer; nothing checked it.
|
||||
func TestTheBackupAgeGaugeIsInfiniteWhenThereIsNoPoint(t *testing.T) {
|
||||
m := New()
|
||||
read := func() float64 {
|
||||
fams, err := m.registry.Gather()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range fams {
|
||||
if f.GetName() == "tm_platform_backup_age_seconds" {
|
||||
return f.GetMetric()[0].GetGauge().GetValue()
|
||||
}
|
||||
}
|
||||
t.Fatal("the gauge is not registered")
|
||||
return 0
|
||||
}
|
||||
// Before anything has observed: a fresh process must already say "no copy", not "zero seconds old".
|
||||
if v := read(); !math.IsInf(v, 1) {
|
||||
t.Errorf("a fresh registry reads %v; an alert on `> threshold` would never fire", v)
|
||||
}
|
||||
m.ObserveBackupAge(0, false)
|
||||
if v := read(); !math.IsInf(v, 1) {
|
||||
t.Errorf("no restore point reads %v, want +Inf", v)
|
||||
}
|
||||
m.ObserveBackupAge(90*time.Minute, true)
|
||||
if v := read(); v != 5400 {
|
||||
t.Errorf("age = %v seconds, want 5400 (the base unit is seconds, not minutes)", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestsAreCountedByRoutePatternAndNeverByPath(t *testing.T) {
|
||||
m := New()
|
||||
mux := http.NewServeMux()
|
||||
|
|
@ -179,3 +146,36 @@ func TestAServiceWithoutTelemetryStillWorks(t *testing.T) {
|
|||
t.Fatalf("status %d through a nil registry", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The gauge's whole argument for being alertable is that a deployment with NO complete restore point
|
||||
// reads +Inf rather than 0 or NaN: `> threshold` is FALSE for both of those, so an instance that has
|
||||
// never made a backup would sit under every alert ever written for this series. The doc comment names
|
||||
// that as the wrong answer; nothing checked it.
|
||||
func TestTheBackupAgeGaugeIsInfiniteWhenThereIsNoPoint(t *testing.T) {
|
||||
m := New()
|
||||
read := func() float64 {
|
||||
fams, err := m.registry.Gather()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range fams {
|
||||
if f.GetName() == "tm_platform_backup_age_seconds" {
|
||||
return f.GetMetric()[0].GetGauge().GetValue()
|
||||
}
|
||||
}
|
||||
t.Fatal("the gauge is not registered")
|
||||
return 0
|
||||
}
|
||||
// Before anything has observed: a fresh process must already say "no copy", not "zero seconds old".
|
||||
if v := read(); !math.IsInf(v, 1) {
|
||||
t.Errorf("a fresh registry reads %v; an alert on `> threshold` would never fire", v)
|
||||
}
|
||||
m.ObserveBackupAge(0, false)
|
||||
if v := read(); !math.IsInf(v, 1) {
|
||||
t.Errorf("no restore point reads %v, want +Inf", v)
|
||||
}
|
||||
m.ObserveBackupAge(90*time.Minute, true)
|
||||
if v := read(); v != 5400 {
|
||||
t.Errorf("age = %v seconds, want 5400 (the base unit is seconds, not minutes)", v)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue