import { createHash } from 'node:crypto'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function paragraph(text) { return '
' + text + '
'; } function heading(text) { return '' + escapeHtml(String(code).trim()) + '';
}
function figure(src, alt, caption) {
return 'pg_restore. Документация PostgreSQL 12 отдельно описывает SQL dump, файловую копию и continuous archiving: это не три названия одной операции, а разные подходы с разными допущениями. В материале не смешиваем их. Берём только учебный logical dump, не делаем вывод о полном кластере и не выдаём проверку двух схем за готовность к point-in-time recovery. Такая граница нужна, чтобы runbook не стал набором команд без предмета восстановления.'),
figure('/assets/editorial/2020/backup-recovery-contract-2020.svg', 'Вертикальная схема backup contract: сначала фиксируется scope двух учебных схем, затем создаётся archive, рядом записывается manifest с checksum, после чего restore допускается только в изолированный кандидат и завершается структурными проверками', 'Путь идёт не от «файл существует» к «всё надёжно», а от явно записанного scope к отдельному доказательству restore. Hash охраняет байты archive, а проверки после restore отвечают за смысл результата.'),
heading('До команды называем scope и цену пропуска'),
paragraph('Симптом неполного backup обычно начинается не с повреждённого файла, а с неоговорённой границы. Владелец ожидает роли, таблицы, вложения или соседнюю схему, а выбранная команда сохраняет только часть базы. Причина в том, что удобная выборка --schema и особенно --table не превращает выбранный объект в самостоятельную систему. PostgreSQL предупреждает: dump отдельной схемы или таблицы может не включить зависимости, нужные для восстановления в чистой базе. Проверка здесь не догадка по размеру файла, а записанный список включений и исключений до запуска.'),
dataTable(
'Учебный scope backup contract: что фиксируется до создания archive',
['Поле', 'Учебное значение', 'Какой риск снимает', 'Что не доказывает'],
[
['Логическая единица', 'training_catalog', 'не путает один database dump с cluster backup', 'наличие ролей и tablespaces'],
['Включено', 'schema:catalog, schema:reference', 'даёт проверяемый список ожидаемых объектов', 'что внешние файлы вернутся сами'],
['Исключено', 'cluster roles, tablespaces, external files', 'не прячет заведомо отсутствующие области', 'что исключения допустимы для конкретной задачи'],
['Формат', 'pg_dump custom (-Fc)', 'связывает archive с маршрутом через pg_restore', 'совместимость любой версии без отдельной проверки'],
['Цель drill', 'изолированный учебный кандидат', 'не даёт restore писать в источник', 'фактическое время восстановления'],
],
),
paragraph('В этой таблице сознательно нет обещанного числа минут. Плановое окно можно записать отдельно как вопрос к следующей проверке: когда началось восстановление, когда archive стал читаемым, когда прошли смысловые запросы. Пока эти точки не сняты на конкретном стенде, называть их RTO было бы подменой измерения. Аналогично, дата archive сама по себе не даёт RPO: она не объясняет, какие изменения между копиями допустимо потерять и входит ли в scope журнал изменений.'),
heading('Manifest кладём рядом с archive, а не в память команды'),
paragraph('Имя файла обычно содержит дату, но дата не заменяет контракт. Рядом с archive нужен небольшой manifest: версия его формы, идентификатор учебной копии, алгоритм checksum, scope, формат, список ожидаемых relations и критерии учебного restore. Manifest не содержит пароль, URL или access key. Он не делает копию криптографически защищённой и не назначает retention; его роль уже: не дать спустя неделю гадать, что именно предполагалось восстановить и каким результатом считается успех.'),
codeBlock(manifestExample),
paragraph('Checksum здесь проверяет конкретные байты. Если archive был обрезан при передаче, подменён другим файлом или повреждён после записи, hash из manifest не совпадёт и drill остановится до pg_restore. Но совпавший hash не означает, что схема подходит приложению, нужные роли существуют или семантическая проверка пройдёт. Это важная граница: checksum — gate перед restore, не сертификат работоспособности. Именно поэтому manifest хранит и технический digest, и независимый список смысловых checks.'),
heading('Минимальная учебная последовательность'),
paragraph('Команды ниже показывают форму маршрута, а не запускаются этой статьёй. Имя training_catalog вымышленное; пароль, host и строка подключения намеренно отсутствуют. Перед практическим применением команда должна выбрать способ аутентификации вне текста, проверить права и зафиксировать версию клиента рядом с manifest. Для учебной копии полезно сначала получить archive и его оглавление, а уже затем разрешать восстановление в отдельной цели.'),
codeBlock(createArchiveCommands),
paragraph('Список из pg_restore --list не заменяет восстановление, но он быстро ловит другой класс ошибок: archive другого формата, отсутствующий ожидаемый объект или не тот файл под правильным именем. Если оглавление не похоже на manifest, следующий шаг не «попробовать всё равно», а остановиться и выяснить, где расходятся scope, команда и опубликованный артефакт. Так диагностика остаётся короткой: симптом — список не совпал; причина — не тот archive или неполный scope; проверка — manifest плюс list; действие — не запускать restore.'),
heading('Restore должен быть безопасной пробой, а не риском для источника'),
paragraph('Самая опасная ошибка в runbook — команда, которую можно случайно выполнить против источника. Поэтому учебный маршрут заранее запрещает перезапись исходной базы, не использует --clean как «исправление» и требует отдельного кандидата. В нём нет реальных данных: набор relations и счётчиков синтетический. Даже успешный restore кандидата не даёт разрешения заменить источник; он только создаёт evidence, что конкретный archive и конкретный путь были проверены в оговорённой границе.'),
codeBlock(restoreCommands),
paragraph('После команды важнее не её exit code, а результат проверок. Для учебного набора это существование двух relations и их ожидаемые маленькие счётчики. В прикладном проекте к ним добавятся версия схемы, критичный reference-набор, чтение одной записи без персональных данных или проверка migration state. Проверка не должна выполнять опасную бизнес-операцию и не должна зависеть от внешнего сервиса, если его нет в scope. Иначе drill превращается в нереплицируемый мини-инцидент.'),
heading('Нумерованный маршрут для первого drill'),
orderedList([
'Сформулировать один вопрос восстановления: какой набор объектов нужен и чего в нём точно нет. Записать includes, excludes и владельца следующей проверки в manifest.',
'Создать учебный logical archive выбранным инструментом и сразу сохранить его имя, формат, размер и вычисленный SHA-256 рядом с manifest.',
'Сверить checksum и вывести оглавление archive. При расхождении остановить маршрут до restore: файл с неверными байтами нельзя «проверить дальше».',
'Подготовить изолированный учебный кандидат. Явно подтвердить, что он не является источником и что runbook не содержит команд удаления исходных данных.',
'Выполнить restore только в кандидате и собрать структурные checks: ожидаемые relations, версия схемы и безопасные счётчики или иные заранее оговорённые признаки.',
'Записать verdict вместе с тем, что не проверялось: cluster roles, tablespaces, внешние файлы, реальные сроки и любой production recovery. По этому списку планировать следующий drill, а не закрывать тему зелёным статусом.',
]),
heading('Что считать отрицательным результатом'),
paragraph('Отрицательный результат drill не означает, что команда «сломала backup». Он показывает место, где контракт пока не выдерживает проверку. Hash не совпал — нельзя доверять артефакту. Archive читается, но в нём нет relation из manifest — неверен scope или выбран другой файл. Restore завершается, но счётчик не совпадает — недостаточен критерий или фактически сохранён не тот набор. Цель оказалась не изолированной — останавливаемся ещё до команды. У каждого случая одно действие: сохранить наблюдение, поправить manifest или runbook и повторить учебную пробу после изменения.'),
paragraph(trainingNotice),
heading('Граница этой практики'),
paragraph('Этот текст не назначает retention, не выбирает хранилище, не проверяет encryption, не запускает PostgreSQL и не измеряет время. Он также не утверждает, что custom dump подходит любой базе или что один logical archive покрывает роли, tablespaces и внешние вложения. Его результат — более честная точка старта: backup становится набором байтов с описанным scope, а restore — отдельной, безопасной и повторяемой проверкой. Когда появятся реальные требования и разрешённый стенд, этот учебный runbook можно расширять измерениями, но не заменять ими текущие доказательства.'),
],
[postgresBackup, pgDump, pgRestore, sha256Standard],
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2020-10-mechanism-backup-recovery',
title: 'Контракт резервной копии: scope, manifest и проверяемый restore',
categories: ['Данные', 'Надёжность', 'Архитектура'],
cover: '/assets/editorial/2020/backup-recovery-restore-path-2020.svg',
excerpt: 'Checksum подтверждает байты, но не смысл. Разбираем границы logical backup, поля manifest и последовательность restore, которую можно проверить без реальной аварии.',
readingMinutes: 15,
},
[
paragraph('Симптом сложнее, чем «архив отсутствует»: файл найден, checksum совпал, а восстановление всё равно останавливается на роли, зависимости или неожиданно пустой таблице. Цена ошибки в том, что команда принимает целостность одного файла за готовность всей системы. В момент сбоя это рождает ложный выбор: либо давить restore в неизвестной цели, либо вручную собирать недостающие части, не понимая, какая из них является источником истины.'),
paragraph('Для октября 2020 года достаточно более приземлённой модели. Не строим SRE-платформу и не называем учебные отметки реальными RPO/RTO. Вместо этого формируем backup contract: одна заявленная область, один archive, один manifest, один безопасный путь restore и набор маленьких доказательств. Contract не отменяет потребность в политиках хранения, правах и резервировании, но он делает видимой границу между «команда выполнилась» и «мы проверили, что этот результат можно использовать по назначению».'),
heading('Четыре утверждения, которые нельзя склеивать'),
paragraph('Слово «копия готова» обычно скрывает сразу несколько разных утверждений. Первое: процесс прочитал согласованный срез данных. Второе: получившийся файл сохранился и не изменился. Третье: инструмент способен разобрать его формат. Четвёртое: после restore целевой набор объектов отвечает ожидаемому контракту. Ошибка возникает, когда успешная проверка второго шага автоматически засчитывается как четвёртый. Hash умеет сравнить байты; он не знает, что в архиве отсутствует роль, что выбранная схема зависит от другой схемы или что приложение ждёт внешний файл.'),
dataTable(
'Разные доказательства в backup/restore contract',
['Доказательство', 'Что оно подтверждает', 'Что не подтверждает', 'Следующее действие'],
[
['Успешный pg_dump', 'инструмент завершил выбранный logical dump', 'полноту cluster-wide объектов и будущий restore', 'записать scope, stderr и формат в manifest'],
['SHA-256 совпал', 'проверяемые байты archive не изменились', 'семантику данных и совместимость цели', 'прочитать archive и подготовить isolated candidate'],
['pg_restore --list', 'archive читается и содержит перечисленные элементы', 'что все зависимости смогут работать после restore', 'сверить list с manifest, а не только с именем файла'],
['Restore в кандидате', 'выбранный инструмент смог развернуть archive в отдельной цели', 'что приложение готово обслуживать запросы', 'выполнить структурные и безопасные смысловые checks'],
['Checks после restore', 'заявленный минимум objects и данных получен', 'что покрыт любой disaster-сценарий', 'зафиксировать пробелы и спланировать следующий drill'],
],
),
paragraph('Таблица полезна именно тем, что в каждой строке оставляет ограничение. Если check не умеет ответить на вопрос, его не нужно нагружать чужой ролью. Например, hash ценен, потому что он останавливает работу с изменённым archive до restore. А row count ценен, потому что ловит пустой или неожиданно урезанный учебный набор. Они не конкурируют; они стоят на разных границах и должны оставаться отдельными в runbook и в журнале проверки.'),
heading('Scope начинается с того, что не попало в archive'),
paragraph('PostgreSQL 12 прямо разделяет области. pg_dump делает dump одной базы, а cluster-wide объекты, такие как роли и tablespaces, требуют отдельного рассмотрения через pg_dumpall. Это не мелкая деталь командной строки: если restore нуждается в роли, а manifest её не упомянул, archive одной базы не становится «почти полным» от хорошего настроения. В учебном примере роли и tablespaces явно исключены, поэтому drill не может нечаянно зачесть их как восстановленные.'),
paragraph('Выбор схемы тоже имеет цену. Параметр --schema удобен, когда цель действительно ограничена одной областью, но документация предупреждает, что зависимости выбранной схемы могут не попасть в чистую цель. Поэтому перед первым drill полезнее начать не с оптимизации маленького dump, а с вопроса: сможет ли target существовать без объектов вне списка? Если ответ не доказан, include-list должен расшириться либо manifest должен зафиксировать внешнюю предпосылку. «Мы всегда так делали» не является ни зависимостью, ни проверкой.'),
figure('/assets/editorial/2020/backup-recovery-restore-path-2020.svg', 'Вертикальная схема restore path: учебный scope становится logical archive, checksum сравнивает байты с manifest, оглавление archive сверяется с ожидаемыми relations, затем isolated candidate получает restore и проходит структурные checks; каждая граница может остановить маршрут', 'Restore не начинается с самой тяжёлой команды. Сначала contract исключает неверный scope и изменённые байты; после команды остаются отдельные проверки структуры и данных.'),
heading('Manifest — это договор между созданием и восстановлением'),
paragraph('Manifest не обязан быть большим каталогом всего хранилища. Для одной учебной копии достаточно стабильной формы: версия manifest, backup ID, file и format, размер и SHA-256, includes/excludes, список expected relations, безопасные checks и правило цели. Важно, чтобы поле называло наблюдаемое значение, а не эмоцию. Вместо "complete": true лучше хранить конкретное scope.includes и restoreChecks.rows. Тогда следующий читатель может повторить проверку или оспорить её границу, не пытаясь расшифровать одно булево поле.'),
codeBlock(manifestExample),
paragraph('Два свойства manifest особенно легко перепутать. artifact.sha256 относится к уже записанному набору байтов; если bytes изменились, restore не должен продолжаться. restoreChecks относятся к тому, что появилось после развертывания; они не вычисляются из hash и не могут быть подставлены до restore. Хранить их в одном документе удобно, но смысл у них разный. Первое — gate целостности, второе — ожидаемое наблюдение о результате. Разделение делает диагностику быстрее и не даёт чинить не ту причину.'),
heading('Checksum не превращает файл в проверенный backup'),
paragraph('SHA-256 нужен, когда причина симптома может находиться между созданием archive и его чтением: частично записанный файл, неверная передача, путаница имени или повреждение хранения. При несовпадении hash действие простое и безопасное — archive нельзя использовать, надо воспроизвести получение доверенного артефакта. Но checksum не видит SQL-содержимое как бизнес-правило. Два одинаково целых файла могут быть одинаково непригодны, если оба созданы с узким scope или оба не содержат нужной внешней части.'),
paragraph('Поэтому fixture ниже намеренно проверяет две разные поломки. Изменение хотя бы одного байта меняет digest и запрещает попытку restore. Сужение includes с двух учебных схем до одной оставляет bytes нетронутыми, но нарушает contract и тоже останавливает маршрут. Это не эмуляция PostgreSQL и не тест backup-инфраструктуры. Это детерминированная модель, которая проверяет, что наш код и статья не называют «checksum совпал» полным ответом.'),
codeBlock([
'import { verifyFixture } from "./scripts/upgrade-2020-10.mjs";',
'',
'const result = verifyFixture();',
'console.log(result.checks);',
'// Все проверки true только для исходных учебных bytes и полного scope.',
'// Фикстура не открывает файл, сеть или PostgreSQL.',
].join('\n')),
heading('Restore path: сначала остановки, затем доказательства'),
paragraph('После manifest путь должен быть линейным. На входе есть archive и versioned contract. Первый gate сравнивает checksum. Второй читает содержание archive и сопоставляет его со scope. Третий создаёт изолированного кандидата. Четвёртый запускает restore выбранным инструментом. Пятый читает результат безопасными запросами. Сложность здесь не в количестве шагов, а в том, что каждый хранит свою причину остановки. Если archive не читается, не нужно обсуждать row count; если target не изолирован, не нужно запускать команду «чтобы посмотреть».'),
paragraph('Для non-plain форматов PostgreSQL использует pg_restore; custom и directory archives позволяют просматривать и выбирать элементы. Это полезно как диагностический инструмент, но выборочная реконструкция не должна случайно стать новым scope. Если в manifest записано «две схемы вместе», а команда restore выбирает только одну relation ради скорости, результат уже не проверяет исходный contract. Оптимизация допустима только после того, как новый scope и его checks явно записаны отдельной версией manifest.'),
heading('Смысловые checks должны быть безопасными'),
paragraph('После restore нельзя ограничиваться тем, что процесс вернул код 0, но и не нужно включать опасную бизнес-операцию. Для учебной базы достаточно трёх слоёв: relation существует; схема имеет ожидаемую версию или маркер; маленький набор синтетических строк даёт ожидаемый счётчик. В рабочем проекте можно добавить проверку reference-данных или чтение агрегата без персональных значений. Ключевое условие: check не должен менять источник, писать во внешнюю систему и зависеть от ресурса, которого archive не заявлял.'),
dataTable(
'Учебные checks после restore и их границы',
['Проверка', 'Симптом, который ловит', 'Причина, которую отделяет', 'Безопасное действие'],
[
['Relation существует', 'archive развернулся, но нужной таблицы нет', 'ошибка scope или другой archive', 'сверить manifest и список archive'],
['Версия схемы', 'таблица есть, но форма не та', 'несовместимый format или не тот релиз схемы', 'остановить ввод данных и уточнить инструмент/версию'],
['Синтетический row count', 'объекты созданы, но набор пуст или урезан', 'неполный dump либо неверный критерий', 'сравнить с manifest, не с памятью оператора'],
['Флаг isolated target', 'команда направлена не туда', 'ошибка runbook до restore', 'не выполнять restore, создать отдельного кандидата'],
],
),
heading('Нумерованный маршрут диагностики'),
orderedList([
'Прочитать manifest и назвать требуемый scope. Если он не содержит нужный объект или внешнюю предпосылку, не пытаться компенсировать это командой restore.',
'Сравнить SHA-256 archive с manifest. Любое расхождение означает остановку до инструмента восстановления.',
'Построить list archive и сверить ожидаемые relations и format. Несовпадение — повод разбирать creation path, а не менять target.',
'Подтвердить isolated candidate и запрет перезаписи источника. Если цель не доказана, drill считается не начавшимся.',
'Выполнить restore в кандидате выбранной версией инструмента и сохранить stderr как evidence, не трактуя его отсутствие как полный успех.',
'Запустить безопасные structural and semantic checks, записать verdict и отдельно перечислить области вне scope: роли, tablespaces, файлы, сроки и настоящий disaster recovery.',
]),
heading('Где заканчивается контракт'),
paragraph('Backup contract не заменяет политику хранения, доступы к хранилищу, криптографическую защиту, мониторинг или план переключения. Он не даёт право называть практику устойчивой, пока нет конкретного разрешённого стенда и измерений. Но контракт полезен раньше этих зрелых слоёв: он делает видимыми inputs, ожидаемый output и границу доказательства. Для автора уровня М3 это честный следующий шаг — не «мы готовы к любой аварии», а «мы умеем проверить один путь и знаем, что ещё не включили».'),
paragraph(trainingNotice),
],
[postgresBackup, pgDump, pgRestore, pgDumpall, sha256Standard],
);
const fieldArticle = createRevision(
{
slug: 'editorial-2020-10-field-backup-recovery',
title: 'Разбор учебного restore drill: от архива к доказательству',
categories: ['Данные', 'Надёжность', 'Разбор'],
cover: '/assets/editorial/2020/backup-recovery-diagnosis-2020.svg',
excerpt: 'Разбираем учебный маршрут, в котором archive есть, но verdict появляется только после manifest, checksum, isolated target и безопасных checks.',
readingMinutes: 14,
},
[
paragraph('Симптом в разборе простой: команда видит свежий archive, но не может ответить, что произойдёт после команды restore. Неизвестны scope, ожидаемые объекты, допустимая цель и проверка результата. Цена не в том, что «не хватает документации»; в реальном сбое такой пробел превращает восстановление в эксперимент над источником. Оператор выбирает файл по дате, запускает знакомую команду и получает либо неполный набор, либо ещё одну неизвестную переменную вместо доказательства.'),
paragraph('Ниже не production-инцидент и не отчёт о достигнутом RPO/RTO. Это синтетический разбор учебной пробы: две логические схемы, один custom archive, manifest с SHA-256 и изолированный кандидат. Цель — показать, как runbook превращает расплывчатый вопрос «backup есть?» в последовательность «симптом → причина → проверка → действие». После такой пробы можно честно перечислить непроверенные границы и назначить следующую проверку, не притворяясь, что уже проведён disaster recovery.'),
heading('Собираем пакет доказательств до первой команды'),
paragraph('Для drill нужны не только archive и пароль к базе — пароль в этом материале намеренно отсутствует. Нужны четыре безопасных артефакта: manifest, сам archive, checksum и лист ожидаемых checks. Manifest связывает файл с scope; checksum связывает manifest с конкретными bytes; список checks связывает restore с наблюдаемым результатом. Если начать с команды без этих связей, любая ошибка будет выглядеть одинаково: «не получилось». Тогда нельзя отличить повреждённый файл от неверной цели или разумно спорить, была ли нужная таблица вообще включена.'),
paragraph('Учебный manifest не хранит реальных имён окружений и не выдаёт секреты. Он использует вымышленные labels catalog и reference, а excludes прямо называют cluster roles, tablespaces и external files. Это важнее красивого JSON: исключённая область должна быть видна до drill, чтобы позже никто не искал её в archive. PostgreSQL 12 разделяет dump одной базы и cluster-wide объекты; если роли нужны будущей цели, это отдельная часть плана, а не скрытый побочный эффект pg_dump.'),
figure('/assets/editorial/2020/backup-recovery-diagnosis-2020.svg', 'Вертикальная схема диагностики учебного restore drill: для каждого симптома от отсутствующего scope до несовпавшего checksum и пустой relation показаны причина, точная проверка и безопасное действие; путь не содержит операций над источником', 'Схема помогает не перескакивать к restore. Сначала проверяем contract и байты, затем безопасность цели, затем структуру и синтетические данные результата.'),
heading('Учебная шкала не равна измеренному RPO или RTO'),
paragraph('В runbook полезно записывать последовательность событий, но нельзя подменять их историей о измеренном времени. Отметка restore_started нужна, чтобы в следующем разрешённом drill измерить интервал до semantic_checks_passed. До этого она не становится RTO. Дата archive помогает задать вопрос о максимально допустимой потере данных, но не становится RPO без требований к данным, частоты копий и понимания журналов изменений. В таблице ниже слова «вопрос» намеренны: они удерживают автора от ложной цифры.'),
dataTable(
'Что учебный drill фиксирует, а что оставляет вопросом',
['Наблюдение', 'Что можно записать сейчас', 'Какой вопрос остаётся', 'Следующее действие'],
[
['Дата и ID archive', 'какой учебный артефакт участвовал в пробе', 'какая потеря данных допустима между копиями', 'согласовать требования данных до следующего drill'],
['Начало и конец restore', 'две отметки учебного маршрута', 'сколько времени допустимо для конкретного сервиса', 'измерить на разрешённой цели, не называть интервал RTO заранее'],
['Checksum', 'bytes совпали или нет', 'достаточен ли scope для прикладного восстановления', 'сверить includes/excludes и checks'],
['Structural checks', 'две relations и синтетические счётчики', 'готово ли всё приложение и внешние зависимости', 'расширить contract или зафиксировать исключение'],
['Isolated target', 'источник не был частью restore', 'подходит ли цель для будущего сценария', 'описать требования к отдельному стенду'],
],
),
paragraph('Такая таблица делает разбор полезнее отчёта «успешно». Она показывает, что было фактически наблюдаемо, и не превращает план в факт. Если будущая команда измерит длительность, ей всё равно придётся указать условия: размер данных, версия инструмента, параллелизм, доступность цели, набор checks. Без условий одно число не переносится на следующий restore. Здесь автор 2020 года формирует дисциплину фиксации, а не заявляет опыт управления программой надёжности.'),
heading('Один synthetic trace вместо легенды о катастрофе'),
paragraph('Вместо реального лога используется короткий учебный trace. Он показывает порядок решений: сначала читают scope, затем сравнивают digest, потом смотрят list archive, восстанавливают только в отдельного кандидата и только после этого запускают semantic checks. У каждой строки есть значение для диагностики. Если trace заканчивается после hash, это не «почти успешный restore»: это означает, что evidence о результате ещё не получен.'),
codeBlock(drillEventExample),
paragraph('Этот пример намеренно не содержит host, username, credentials, реальных timestamps или описания пользовательских данных. В настоящий runbook можно добавить минимальный correlation ID и место хранения evidence, но не следует логировать сам archive, содержимое dump или секретные connection strings. Для учебного случая хватает backup ID, версии manifest, результата gate и причины остановки. Это делает заметки сравнимыми между drills и не превращает журнал в ещё одну копию чувствительных данных.'),
heading('Диагностика начинается с вопроса о scope'),
paragraph('Представим первый симптом: archive существует, но restore list не содержит reference.codes. Самая вероятная причина не в checksum: hash может идеально совпадать с теми байтами, которые изначально были сохранены неполно. Проверка — сравнить list archive с scope.includes и restoreChecks.relations из manifest. Действие — остановить restore, открыть creation command и решить, должен ли scope расшириться либо relation была внешней зависимостью, которую нужно описать отдельно. Повторный запуск restore в надежде, что relation «появится», не добавляет доказательств.'),
paragraph('Второй симптом: list выглядит правильно, но checksum не совпал. Причина находится между записью manifest и текущим файлом: другой archive под тем же именем, неполная передача, повреждение или ошибка выбора. Проверка однозначна — повторно вычислить SHA-256 над фактически используемыми bytes. Действие тоже однозначно — не восстанавливать этот файл. Нужно получить доверенный artifact или создать новую учебную копию, а затем обновить manifest только вместе с ним. Подмена hash в JSON «чтобы пройти gate» уничтожает сам смысл контроля.'),
heading('После checksum всё ещё нельзя трогать источник'),
paragraph('Третий симптом: bytes и list прошли, но оператор собирается восстановить «туда, где видно данные». Причина — runbook не делает безопасную цель явным входом. Проверка должна быть предельно скучной: имя кандидата и запрет перезаписи источника зафиксированы до команды. Действие — создать отдельного учебного кандидата и повторить проверку цели. Даже маленький drill не оправдывает запуск с destructive flags ради удобства. Если под рукой нет безопасной цели, честный verdict — «restore не проверен», а не «backup успешен».'),
paragraph('Четвёртый симптом: restore завершился, но relation пуста или schema version не та. Причина может быть в scope, версии инструмента, неполном содержимом или в слишком слабом критерии. Проверка состоит из заранее записанных read-only запросов против кандидата и сопоставления с manifest. Действие — сохранить фактический verdict, не маскировать его ручной вставкой строк и не менять expected count после факта. Если критерий был неверным, обновляют contract, создают новый артефакт и повторяют пробу как новую версию, а не переписывают историю старой.'),
heading('Фикстура проверяет контракт, а не инфраструктуру'),
paragraph('В revision-модуле есть небольшая in-memory fixture. Она строит synthetic manifest для двух relations, вычисляет SHA-256 фиксированных bytes и проверяет восемь условий: digest совпал, scope полон, safety запрещает overwrite источника, relations и rows совпали, изменение bytes отклонено, а суженный scope отклонён. У fixture нет файла, сети, PostgreSQL, cron, облачного хранилища или настоящего restore. Поэтому её можно запускать как проверку логики статьи, но нельзя прикладывать вместо реального drill.'),
codeBlock([
'# Запуск учебной fixture из revision-модуля:',
'node web/scripts/upgrade-2020-10.mjs --verify-fixture',
'',
'# Ожидаем не скорость, а логические результаты:',
'# checksumMatches=true, scopeMatches=true, expectedRowsPresent=true',
'# alteredBytesRejected=true, narrowedScopeRejected=true, sourceWasUntouched=true',
].join('\n')),
heading('Нумерованный маршрут разбора'),
orderedList([
'Назвать backup ID и прочитать manifest целиком: format, includes, excludes, checksum и checks. Не выбирать archive только по дате в имени.',
'Сверить scope с вопросом восстановления. Если нужная область не заявлена, зафиксировать это как design gap, а не как сбой pg_restore.',
'Вычислить checksum фактического archive и сравнить с manifest. При несовпадении остановить маршрут до попытки чтения или restore.',
'Получить list archive и сравнить его с ожидаемыми relations. Расхождение отделяет проблему creation path от проблемы target.',
'Подтвердить isolated candidate и запрет действий над источником. Отсутствие безопасной цели — корректная причина не запускать drill.',
'Восстановить archive в кандидате, выполнить read-only structural and semantic checks, сохранить результат и список непокрытых областей.',
'Сделать из найденного пробела конкретную правку manifest, команды или checks и назначить новый учебный drill. Не превращать один запуск в заявление о готовности к любой аварии.',
]),
heading('Как выглядит честный итог'),
paragraph('Честный verdict после учебного drill может быть положительным и всё равно ограниченным: «вымышленный custom archive с двумя схемами совпал с manifest, развернулся в isolated candidate, прошёл два структурных checks; роли, tablespaces, внешние файлы, реальные требования к потере данных и сроки не проверялись». Такой текст полезнее громкого «резервное копирование настроено». Он сохраняет путь, по которому другой инженер сможет повторить проверку, и оставляет список того, что ещё нужно обсудить до любого production recovery.'),
paragraph('Документация PostgreSQL помогает не размывать эту границу: logical dump одной базы, archive format и восстановление через pg_restore — конкретные механизмы, а не общая метафора «бэкапа». NIST-определённый SHA-256 даёт проверку bytes, но не заменяет semantic check. Соединяя их в manifest и drill, мы не обещаем невозможного; мы получаем один контролируемый путь, который можно сделать лучше после следующего измерения.'),
paragraph(trainingNotice),
],
[postgresBackup, pgDump, pgRestore, pgDumpall, sha256Standard],
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
const isMainModule = process.argv[1]
&& resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMainModule) {
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions));
} else if (process.argv.includes('--verify-fixture')) {
process.stdout.write(JSON.stringify(verifyFixture()) + '\n');
} else {
process.stderr.write('Usage: node web/scripts/upgrade-2020-10.mjs --print-revisions | --verify-fixture\n');
}
}