revise October 2020 backup articles
Build and deploy / deploy (push) Successful in 13s

This commit is contained in:
2026-07-31 11:49:22 +03:00
parent 6c96a2a338
commit 2ffa7a40eb
7 changed files with 857 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Производство редакционных партий # Производство редакционных партий
На 31 июля 2026 года строгий аудит проходит 97 из 358 созданных материалов. Остальные 261 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. На 31 июля 2026 года строгий аудит проходит 100 из 358 созданных материалов. Остальные 258 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия ## Одна партия
+164
View File
@@ -0,0 +1,164 @@
# Октябрь 2020 — тройное ревью автономного пакета П32 «Резервное копирование и восстановление»
Статус: **принят независимым редактором в выпусковой набор**. Этот документ
не переписывает базовый архив. В объектах revision нет полей <code>date</code>
и <code>author</code>: слой публикации сохраняет стабильные метаданные исходных
статей.
Проверенные revision:
- <code>editorial-2020-10-practice-backup-recovery</code>;
- <code>editorial-2020-10-mechanism-backup-recovery</code>;
- <code>editorial-2020-10-field-backup-recovery</code>.
Модуль экспортирует ровно три revision. Режим <code>--print-revisions</code>
пишет только JSON; режим <code>--verify-fixture</code> запускает
детерминированную in-memory модель. Она не создаёт archive, не открывает
PostgreSQL и не восстанавливает реальные данные.
## Проход 1. Факты, механизм и ограничения — пройдено
| Утверждение или решение | Первичный / официальный источник | Проверенная граница |
| --- | --- | --- |
| PostgreSQL различает SQL dump, файловую копию и continuous archiving | [PostgreSQL 12: Backup and Restore](https://www.postgresql.org/docs/12/backup.html) | Пакет разбирает только учебный logical dump. Он не называет его PITR, полной копией кластера или disaster recovery. |
| <code>pg_dump</code> создаёт dump одной базы; custom archive читается <code>pg_restore</code>; выбор схемы может не включить нужные зависимости | [PostgreSQL 12: pg_dump](https://www.postgresql.org/docs/12/app-pgdump.html) | Scope записывает includes и excludes, а проверка list archive предшествует restore. Отдельная схема не объявлена самодостаточной без доказательства. |
| Cluster-wide объекты, включая роли и tablespaces, требуют отдельного рассмотрения | [PostgreSQL 12: pg_dumpall](https://www.postgresql.org/docs/12/app-pg-dumpall.html) | Учебный manifest явно исключает роли и tablespaces. Их отсутствие не маскируется зелёным статусом одного database dump. |
| Non-plain archive восстанавливается и диагностируется через <code>pg_restore</code> | [PostgreSQL 12: pg_restore](https://www.postgresql.org/docs/12/app-pgrestore.html) | В статье list archive — отдельный gate. Успешный list не подменяет structural или semantic checks после restore. |
| SHA-256 сравнивает bytes артефакта, а не прикладную пригодность восстановленного набора | [NIST FIPS 180-4: Secure Hash Standard](https://csrc.nist.gov/pubs/fips/180-4/final) | Hash — обязательная проверка до restore, но не сертификат полноты scope, ролей, внешних файлов или поведения приложения. |
Технический разбор повторно сверил эти границы с кодом и текстом:
- backup отделён от проверенного restore: archive, digest, archive list,
isolated candidate и evidence остаются отдельными шагами;
- manifest содержит scope, format, SHA-256, ожидаемые relations и safe
checks, но не содержит URL, access key, пароля или реальных данных;
- fixture отклоняет изменённые bytes и искусственно суженный scope, подтверждает
две relations, синтетические row count и запрет перезаписи источника;
- custom dump не выдан за копию cluster-wide объектов; роли, tablespaces и
external files честно остаются за границей учебного маршрута;
- строки о RPO/RTO сформулированы как вопросы и будущие измерения. В пакете
нет вымышленного срока восстановления, production disaster recovery или
заявления о зрелой SRE-программе.
Реальный запуск <code>--verify-fixture</code> вернул девять истинных проверок:
<code>checksumMatches</code>, <code>byteCountMatches</code>, <code>scopeMatches</code>,
<code>safetyMatches</code>, <code>expectedRelationsPresent</code>,
<code>expectedRowsPresent</code>, <code>alteredBytesRejected</code>,
<code>narrowedScopeRejected</code> и <code>sourceWasUntouched</code>. Фикстура
использует фиксированный 85-byte учебный buffer и synthetic manifest; это
проверка контракта текста, не интеграционный test СУБД.
Вердикт прохода: **пройден**. Источники поддерживают сказанное, а механика не
выходит за проверенные границы.
## Проход 2. Редактура, голос М3 и объём — пройдено
| Revision | Проблема и цена в первых двух абзацах | Практический путь | Тон и ограничение |
| --- | --- | --- | --- |
| Практика | Файл появляется по расписанию, но никто не знает, что вернётся из него; цена — неполный restore, риск затронуть источник и потеря времени в сбое | Scope → custom archive → manifest/hash → isolated candidate → relations и safe counts | Автор формирует первый runbook и не называет учебную пробу реальным RPO/RTO или production recovery. |
| Механизм | Digest совпал, но restore может упасть на роли, зависимости или пустой relation; цена — принять целостность файла за готовность системы | Разделить evidence capture, bytes, format и semantics; зафиксировать их в manifest и gates | М3 связывает данные, delivery и безопасные проверки, но не изображает платформенную SRE-практику. |
| Полевой разбор | Archive есть, а scope, target и критерий успеха не названы; цена — эксперимент над источником в момент сбоя | Собрать evidence packet, разобрать четыре симптома и пройти drill по нумерованному маршруту | Synthetic trace показывает метод, а не реальный инцидент, среду или достигнутые показатели. |
- Draft gate измерил основной текст без разделов источников: практика —
**10 835** знаков, механизм — **10 734**, полевой разбор — **10 778**.
Все три текста находятся в требуемом диапазоне 5 000–15 000 знаков.
- В первых двух абзацах каждого материала названы конкретные симптом и цена.
Дальше сохраняется рабочая последовательность «симптом → причина → проверка
→ действие», а не общая речь о важности backup.
- В каждом материале есть как минимум пять смысловых разделов, figure с
содержательным <code>alt</code>/<code>figcaption</code>, таблица с
<code>caption</code>/<code>thead</code>, воспроизводимый manifest/code или
fixture, нумерованный маршрут и не менее двух официальных источников.
- Язык короткий и технический: archive, scope, checksum, candidate и checks
каждый раз связаны с конкретной операцией или критерием. Нет реальных
данных, имён окружений, access key, URL, passwords, статуса production
или фиктивных чисел RPO/RTO.
Вердикт прохода: **пройден**. Статьи соответствуют М3 / октябрю 2020 года:
автор уже документирует recovery drill, но не присваивает себе несуществующую
операционную зрелость.
## Проход 3. Визуал и выпуск автономного пакета — пройдено в заданных границах
- <code>backup-recovery-contract-2020.svg</code> показывает, почему scope,
archive, manifest, isolated restore и evidence являются разными стадиями.
Последняя подпись прямо разделяет byte-level checksum и смысловые checks.
- <code>backup-recovery-restore-path-2020.svg</code> показывает шесть
последовательных gate: scope, checksum, archive list, candidate, restore и
evidence. У каждого есть отдельное условие остановки.
- <code>backup-recovery-diagnosis-2020.svg</code> связывает четыре симптома с
причиной, проверкой и безопасным действием, не добавляя destructive
команды к источнику.
- У всех SVG есть <code>title</code>, <code>desc</code>,
<code>role="img"</code>, вертикальный viewBox, контрастные карточки и
короткие подписи. В них нет JavaScript, <code>foreignObject</code>, внешних
URL, raster data URI или реальных данных.
- Каждая схема отрендерена локально через Sharp при ширине **375 px** и
просмотрена независимо. Первичный preflight нашёл одну длинную нижнюю
подпись в схеме диагностики; она была разбита на две строки, SVG
отрендерен повторно. Финальные версии не имеют визуального обрезания,
наложений или горизонтального overflow.
### Фактические команды и результаты
<pre><code>cd web
node --check scripts/upgrade-2020-10.mjs
npm run audit:draft -- scripts/upgrade-2020-10.mjs
node scripts/upgrade-2020-10.mjs --verify-fixture
xmllint --noout \
public/assets/editorial/2020/backup-recovery-contract-2020.svg \
public/assets/editorial/2020/backup-recovery-restore-path-2020.svg \
public/assets/editorial/2020/backup-recovery-diagnosis-2020.svg</code></pre>
| Проверка | Реальный результат |
| --- | --- |
| <code>node --check</code> | код завершения 0 |
| Draft audit | PASS: 10 835 / 10 734 / 10 778 знаков тела |
| In-memory fixture | все девять логических checks вернули <code>true</code>; hash учебного buffer — <code>ef4e31fd…90d419a</code> |
| XML | все три SVG валидны, код завершения 0 |
| 375 px visual preflight | выполнен локальным Sharp-рендером; одна длинная подпись исправлена и финальные изображения просмотрены повторно |
| Scope/self-review | revision не меняют <code>date</code>/<code>author</code>; registry, <code>articles.json</code>, standard, очередь, package config и Git не менялись |
Не запускались PostgreSQL, <code>pg_dump</code>, <code>pg_restore</code>,
cron, object storage, browser, CI, production build, deployment, внешний
стенд или assistive technology. Sharp-проверка 375 px проверяет компоновку
локального SVG, но не подменяет browser-review или проверку скринридером.
Учебные команды и fixture не являются доказательством реального disaster
recovery.
## Итог
Статус: **тройное ревью пройдено; П32 принят к отдельной публикации**.
Созданы только пять файлов, разрешённых задачей:
1. <code>web/scripts/upgrade-2020-10.mjs</code>;
2. <code>editorial/reviews/2020-10-draft.md</code>;
3. <code>web/public/assets/editorial/2020/backup-recovery-contract-2020.svg</code>;
4. <code>web/public/assets/editorial/2020/backup-recovery-restore-path-2020.svg</code>;
5. <code>web/public/assets/editorial/2020/backup-recovery-diagnosis-2020.svg</code>.
Автономный авторский пакет не менял registry, <code>articles.json</code>,
стандарт, очередь, package configuration или Git.
## Независимая интеграционная приёмка
Основной редактор 31 июля 2026 года подключил три revision к
<code>web/data/editorial-revisions.mjs</code>, не меняя базовый
<code>articles.json</code>, даты или автора архивных записей. В registry стало
91 revision. Отдельно выполнены:
| Проверка после интеграции | Реальный результат |
| --- | --- |
| Строгий audit трёх slug | PASS: 10 835 / 10 734 / 10 778 знаков; у каждой статьи есть figure, table и code examples |
| Production build | PASS: Next.js собрал 374 статические страницы |
| Независимый mobile visual review | PASS: основной редактор повторно просмотрел три SVG после Sharp-рендера в 375 px; clipping, overlap и overflow не обнаружены |
Первичные PostgreSQL 12-документы сверены независимо: они действительно
разделяют SQL dump, файловую копию и continuous archiving; <code>pg_dump</code>
работает с одной базой, а cluster-wide объекты требуют отдельного рассмотрения.
Ни этот отчёт, ни интеграция не утверждают запуск PostgreSQL, реальный
backup/restore, browser или assistive technology.
Выпусковой вердикт: **ACCEPT**. Commit и push выполняются отдельной
публикационной операцией; Git остаётся источником её фактической записи.
+2
View File
@@ -28,6 +28,7 @@ import { revisions as june2020Revisions } from '../scripts/upgrade-2020-06.mjs';
import { revisions as july2020Revisions } from '../scripts/upgrade-2020-07.mjs'; import { revisions as july2020Revisions } from '../scripts/upgrade-2020-07.mjs';
import { revisions as august2020Revisions } from '../scripts/upgrade-2020-08.mjs'; import { revisions as august2020Revisions } from '../scripts/upgrade-2020-08.mjs';
import { revisions as september2020Revisions } from '../scripts/upgrade-2020-09.mjs'; import { revisions as september2020Revisions } from '../scripts/upgrade-2020-09.mjs';
import { revisions as october2020Revisions } from '../scripts/upgrade-2020-10.mjs';
// This layer replaces archived source entries without losing their stable slug and date. // This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [ export const editorialRevisions = [
@@ -61,4 +62,5 @@ export const editorialRevisions = [
...july2020Revisions, ...july2020Revisions,
...august2020Revisions, ...august2020Revisions,
...september2020Revisions, ...september2020Revisions,
...october2020Revisions,
]; ];
@@ -0,0 +1,64 @@
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="1240" viewBox="0 0 720 1240" role="img" aria-labelledby="title desc">
<title id="title">Контракт учебной резервной копии и восстановления</title>
<desc id="desc">Вертикальная схема из пяти шагов: scope, archive, manifest с checksum, изолированный restore и доказательство результата. Hash проверяет байты, а проверки после restore — смысл результата.</desc>
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="6" refY="6" orient="auto">
<path d="M1 1 L11 6 L1 11 Z" fill="#3566c8"/>
</marker>
<style>
.title { fill: #14213d; font: 700 34px system-ui, -apple-system, sans-serif; }
.subtitle { fill: #43536f; font: 400 21px system-ui, -apple-system, sans-serif; }
.step { fill: #3566c8; font: 700 22px system-ui, -apple-system, sans-serif; }
.label { fill: #14213d; font: 700 26px system-ui, -apple-system, sans-serif; }
.body { fill: #34435c; font: 400 22px system-ui, -apple-system, sans-serif; }
.note { fill: #51627d; font: 400 20px system-ui, -apple-system, sans-serif; }
</style>
</defs>
<rect width="720" height="1240" fill="#f7f9fc"/>
<rect x="40" y="36" width="640" height="140" rx="24" fill="#e9f0ff"/>
<text x="72" y="88" class="title">Backup contract</text>
<text x="72" y="124" class="title">начинается со scope</text>
<text x="72" y="154" class="subtitle">Учебный путь: archive не равен проверенному restore</text>
<line x1="360" y1="344" x2="360" y2="386" stroke="#3566c8" stroke-width="6" marker-end="url(#arrow)"/>
<line x1="360" y1="524" x2="360" y2="566" stroke="#3566c8" stroke-width="6" marker-end="url(#arrow)"/>
<line x1="360" y1="724" x2="360" y2="766" stroke="#3566c8" stroke-width="6" marker-end="url(#arrow)"/>
<line x1="360" y1="934" x2="360" y2="976" stroke="#3566c8" stroke-width="6" marker-end="url(#arrow)"/>
<rect x="48" y="220" width="624" height="124" rx="20" fill="#ffffff" stroke="#8ba8e8" stroke-width="3"/>
<circle cx="94" cy="282" r="25" fill="#3566c8"/>
<text x="87" y="290" fill="#ffffff" font-family="system-ui, sans-serif" font-size="23" font-weight="700">1</text>
<text x="140" y="271" class="label">Scope</text>
<text x="140" y="304" class="body">catalog + reference</text>
<text x="140" y="330" class="note">Роли, tablespaces и внешние файлы названы отдельно</text>
<rect x="48" y="400" width="624" height="124" rx="20" fill="#ffffff" stroke="#8ba8e8" stroke-width="3"/>
<circle cx="94" cy="462" r="25" fill="#3566c8"/>
<text x="87" y="470" fill="#ffffff" font-family="system-ui, sans-serif" font-size="23" font-weight="700">2</text>
<text x="140" y="451" class="label">Archive</text>
<text x="140" y="484" class="body">Логический dump в известном формате</text>
<text x="140" y="510" class="note">Имя файла не заменяет описание содержимого</text>
<rect x="48" y="580" width="624" height="144" rx="20" fill="#ffffff" stroke="#8ba8e8" stroke-width="3"/>
<circle cx="94" cy="652" r="25" fill="#3566c8"/>
<text x="87" y="660" fill="#ffffff" font-family="system-ui, sans-serif" font-size="23" font-weight="700">3</text>
<text x="140" y="635" class="label">Manifest + checksum</text>
<text x="140" y="668" class="body">Hash сверяет bytes archive</text>
<text x="140" y="696" class="note">Checks заранее называют ожидаемый результат restore</text>
<rect x="48" y="780" width="624" height="154" rx="20" fill="#ffffff" stroke="#8ba8e8" stroke-width="3"/>
<circle cx="94" cy="857" r="25" fill="#3566c8"/>
<text x="87" y="865" fill="#ffffff" font-family="system-ui, sans-serif" font-size="23" font-weight="700">4</text>
<text x="140" y="840" class="label">Isolated restore</text>
<text x="140" y="873" class="body">Только учебный кандидат</text>
<text x="140" y="902" class="note">Источник не перезаписывается и не участвует в пробе</text>
<rect x="48" y="990" width="624" height="154" rx="20" fill="#ffffff" stroke="#5aa37b" stroke-width="3"/>
<circle cx="94" cy="1067" r="25" fill="#26734d"/>
<text x="87" y="1075" fill="#ffffff" font-family="system-ui, sans-serif" font-size="23" font-weight="700">5</text>
<text x="140" y="1050" class="label">Evidence</text>
<text x="140" y="1083" class="body">Relations, schema marker, safe counts</text>
<text x="140" y="1112" class="note">Verdict фиксирует также непокрытые области</text>
<text x="48" y="1194" class="note">Checksum подтверждает bytes. Restore checks подтверждают смысл.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

@@ -0,0 +1,57 @@
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="1510" viewBox="0 0 720 1510" role="img" aria-labelledby="title desc">
<title id="title">Диагностика учебного restore drill</title>
<desc id="desc">Четыре вертикальные карточки связывают симптомы восстановления с причиной, проверкой и безопасным действием: неизвестный scope, несовпавший checksum, не изолированная цель и неверный результат после restore.</desc>
<defs>
<style>
.title { fill: #162e2c; font: 700 34px system-ui, -apple-system, sans-serif; }
.subtitle { fill: #46605c; font: 400 21px system-ui, -apple-system, sans-serif; }
.number { fill: #ffffff; font: 700 24px system-ui, -apple-system, sans-serif; }
.label { fill: #162e2c; font: 700 24px system-ui, -apple-system, sans-serif; }
.body { fill: #35514c; font: 400 21px system-ui, -apple-system, sans-serif; }
.action { fill: #186043; font: 700 21px system-ui, -apple-system, sans-serif; }
.footer { fill: #506a65; font: 400 20px system-ui, -apple-system, sans-serif; }
</style>
</defs>
<rect width="720" height="1510" fill="#f6faf8"/>
<rect x="40" y="36" width="640" height="142" rx="24" fill="#dff2ea"/>
<text x="72" y="88" class="title">Диагностика drill</text>
<text x="72" y="124" class="title">до и после restore</text>
<text x="72" y="154" class="subtitle">Симптом не лечим следующей командой без проверки</text>
<rect x="48" y="220" width="624" height="250" rx="22" fill="#ffffff" stroke="#81bca3" stroke-width="3"/>
<circle cx="94" cy="268" r="26" fill="#26734d"/>
<text x="86" y="276" class="number">1</text>
<text x="140" y="266" class="label">Scope не назван</text>
<text x="140" y="302" class="body">Причина: archive выбран по имени,</text>
<text x="140" y="330" class="body">а не по требуемому набору объектов.</text>
<text x="140" y="370" class="body">Проверка: includes / excludes и archive list.</text>
<text x="140" y="418" class="action">Действие: остановить restore, уточнить contract.</text>
<rect x="48" y="505" width="624" height="250" rx="22" fill="#ffffff" stroke="#81bca3" stroke-width="3"/>
<circle cx="94" cy="553" r="26" fill="#26734d"/>
<text x="86" y="561" class="number">2</text>
<text x="140" y="551" class="label">Checksum не совпал</text>
<text x="140" y="587" class="body">Причина: другие или изменённые bytes.</text>
<text x="140" y="627" class="body">Проверка: SHA-256 фактического archive.</text>
<text x="140" y="675" class="action">Действие: не использовать файл, получить доверенный.</text>
<rect x="48" y="790" width="624" height="250" rx="22" fill="#ffffff" stroke="#81bca3" stroke-width="3"/>
<circle cx="94" cy="838" r="26" fill="#26734d"/>
<text x="86" y="846" class="number">3</text>
<text x="140" y="836" class="label">Target не изолирован</text>
<text x="140" y="872" class="body">Причина: runbook не назвал безопасную цель.</text>
<text x="140" y="912" class="body">Проверка: source overwrite запрещён до команды.</text>
<text x="140" y="960" class="action">Действие: создать candidate; иначе drill не начинать.</text>
<rect x="48" y="1075" width="624" height="270" rx="22" fill="#ffffff" stroke="#81bca3" stroke-width="3"/>
<circle cx="94" cy="1123" r="26" fill="#26734d"/>
<text x="86" y="1131" class="number">4</text>
<text x="140" y="1121" class="label">Restore прошёл, checks нет</text>
<text x="140" y="1157" class="body">Причина: exit code приняли за evidence.</text>
<text x="140" y="1197" class="body">Проверка: relation, schema marker, safe count.</text>
<text x="140" y="1245" class="action">Действие: зафиксировать verdict и пробелы scope.</text>
<rect x="48" y="1372" width="624" height="98" rx="18" fill="#e8f5ef"/>
<text x="78" y="1415" class="footer">RPO/RTO требуют отдельного</text>
<text x="78" y="1444" class="footer">разрешённого учебного drill.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

@@ -0,0 +1,68 @@
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="1280" viewBox="0 0 720 1280" role="img" aria-labelledby="title desc">
<title id="title">Путь учебного восстановления из archive</title>
<desc id="desc">Вертикальный путь из шести этапов: scope, archive, checksum, список archive, изолированный кандидат, restore и смысловые проверки. У каждого этапа есть собственная причина остановить путь.</desc>
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="6" refY="6" orient="auto">
<path d="M1 1 L11 6 L1 11 Z" fill="#8b5b18"/>
</marker>
<style>
.title { fill: #27223a; font: 700 34px system-ui, -apple-system, sans-serif; }
.subtitle { fill: #5f5870; font: 400 21px system-ui, -apple-system, sans-serif; }
.tag { fill: #8b5b18; font: 700 21px system-ui, -apple-system, sans-serif; }
.label { fill: #27223a; font: 700 26px system-ui, -apple-system, sans-serif; }
.body { fill: #4e485a; font: 400 22px system-ui, -apple-system, sans-serif; }
.note { fill: #675f73; font: 400 20px system-ui, -apple-system, sans-serif; }
</style>
</defs>
<rect width="720" height="1280" fill="#fbfaf7"/>
<rect x="40" y="36" width="640" height="142" rx="24" fill="#fff2d8"/>
<text x="72" y="88" class="title">Restore path</text>
<text x="72" y="124" class="title">с остановками по пути</text>
<text x="72" y="154" class="subtitle">Каждый gate исключает один класс неверного действия</text>
<line x1="116" y1="247" x2="116" y2="1105" stroke="#d8b26f" stroke-width="8" stroke-linecap="round"/>
<circle cx="116" cy="248" r="30" fill="#8b5b18"/>
<text x="106" y="256" fill="#ffffff" font-family="system-ui, sans-serif" font-size="22" font-weight="700">1</text>
<rect x="170" y="194" width="500" height="110" rx="18" fill="#ffffff" stroke="#d8b26f" stroke-width="3"/>
<text x="204" y="235" class="label">Прочитать scope</text>
<text x="204" y="268" class="body">Что включено и что исключено?</text>
<text x="204" y="292" class="note">Нет scope — нет разрешения на restore</text>
<circle cx="116" cy="420" r="30" fill="#8b5b18"/>
<text x="106" y="428" fill="#ffffff" font-family="system-ui, sans-serif" font-size="22" font-weight="700">2</text>
<rect x="170" y="366" width="500" height="110" rx="18" fill="#ffffff" stroke="#d8b26f" stroke-width="3"/>
<text x="204" y="407" class="label">Сверить checksum</text>
<text x="204" y="440" class="body">Manifest и фактические bytes</text>
<text x="204" y="464" class="note">Не совпали — archive не используем</text>
<circle cx="116" cy="592" r="30" fill="#8b5b18"/>
<text x="106" y="600" fill="#ffffff" font-family="system-ui, sans-serif" font-size="22" font-weight="700">3</text>
<rect x="170" y="538" width="500" height="110" rx="18" fill="#ffffff" stroke="#d8b26f" stroke-width="3"/>
<text x="204" y="579" class="label">Прочитать archive list</text>
<text x="204" y="612" class="body">Expected relations присутствуют?</text>
<text x="204" y="636" class="note">Нет — исправляем creation path</text>
<circle cx="116" cy="764" r="30" fill="#8b5b18"/>
<text x="106" y="772" fill="#ffffff" font-family="system-ui, sans-serif" font-size="22" font-weight="700">4</text>
<rect x="170" y="710" width="500" height="110" rx="18" fill="#ffffff" stroke="#d8b26f" stroke-width="3"/>
<text x="204" y="751" class="label">Подтвердить candidate</text>
<text x="204" y="784" class="body">Изолирован и не является источником</text>
<text x="204" y="808" class="note">Нет — drill корректно останавливается</text>
<circle cx="116" cy="936" r="30" fill="#8b5b18"/>
<text x="106" y="944" fill="#ffffff" font-family="system-ui, sans-serif" font-size="22" font-weight="700">5</text>
<rect x="170" y="882" width="500" height="110" rx="18" fill="#ffffff" stroke="#d8b26f" stroke-width="3"/>
<text x="204" y="923" class="label">Выполнить restore</text>
<text x="204" y="956" class="body">Только в этот candidate</text>
<text x="204" y="980" class="note">Exit code не становится финальным verdict</text>
<circle cx="116" cy="1108" r="30" fill="#26734d"/>
<text x="106" y="1116" fill="#ffffff" font-family="system-ui, sans-serif" font-size="22" font-weight="700">6</text>
<rect x="170" y="1054" width="500" height="118" rx="18" fill="#ffffff" stroke="#69af87" stroke-width="3"/>
<text x="204" y="1095" class="label">Собрать evidence</text>
<text x="204" y="1128" class="body">Relations, marker, safe counts</text>
<text x="204" y="1152" class="note">И отдельно: что этот drill не проверял</text>
<text x="48" y="1230" class="tag">Правило: не перескакивать через gate ради «быстрого» restore.</text>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

+501
View File
@@ -0,0 +1,501 @@
import { createHash } from 'node:crypto';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(code) {
return '<pre><code>' + escapeHtml(String(code).trim()) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function dataTable(caption, headers, rows) {
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table><caption>' + escapeHtml(caption) + '</caption>' + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function plainText(content) {
return content
.replace(/<[^>]+>/g, ' ')
.replace(/&(?:quot|amp|lt|gt|#039);/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function bodyText(content) {
return plainText(
content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''),
);
}
function createRevision(meta, bodyParts, sources) {
if (sources.length < 2) {
throw new Error(meta.slug + ': нужно минимум два первичных или официальных источника');
}
const contentHtml = bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources);
const length = bodyText(contentHtml).length;
if (length < 5000 || length > 15000) {
throw new Error(meta.slug + ': основной текст вне 5 000–15 000 знаков: ' + length);
}
return {
...meta,
contentHtml,
};
}
const postgresBackup = {
title: 'PostgreSQL 12: Backup and Restore',
url: 'https://www.postgresql.org/docs/12/backup.html',
note: 'разделяет SQL dump, копирование на уровне файловой системы и continuous archiving; у способов разные предпосылки и границы восстановления',
};
const pgDump = {
title: 'PostgreSQL 12: pg_dump',
url: 'https://www.postgresql.org/docs/12/app-pgdump.html',
note: 'документирует согласованный логический dump, форматы custom и directory, а также ограничение выборки схемы или таблицы без зависимостей',
};
const pgRestore = {
title: 'PostgreSQL 12: pg_restore',
url: 'https://www.postgresql.org/docs/12/app-pgrestore.html',
note: 'описывает восстановление non-plain archive, просмотр содержания и параметры, которые нужно сверять с версией выбранного инструмента',
};
const pgDumpall = {
title: 'PostgreSQL 12: pg_dumpall',
url: 'https://www.postgresql.org/docs/12/app-pg-dumpall.html',
note: 'показывает отдельную область cluster-wide объектов, включая роли; один dump базы не следует выдавать за копию всего кластера',
};
const sha256Standard = {
title: 'NIST FIPS 180-4: Secure Hash Standard',
url: 'https://csrc.nist.gov/pubs/fips/180-4/final',
note: 'задаёт семейство SHA-2; hash сверяет байты артефакта, но сам по себе не доказывает пригодность результата к восстановлению',
};
const trainingNotice = 'Имена, данные, timestamps, размеры, команды и результаты ниже учебные. Здесь нет реальной базы, access key, production-окружения или отчёта о disaster recovery.';
const manifestExample = [
'{',
' "manifestVersion": 1,',
' "backupId": "training-backup-2020-10-a",',
' "artifact": {',
' "file": "training-catalog-2020-10.dump",',
' "format": "pg_dump custom (-Fc)",',
' "sha256": "здесь хранится вычисленный hash",',
' },',
' "scope": {',
' "includes": ["schema:catalog", "schema:reference"],',
' "excludes": ["cluster roles", "tablespaces", "external files"],',
' },',
' "restoreChecks": {',
' "relations": ["catalog.items", "reference.codes"],',
' "rows": { "catalog.items": 3, "reference.codes": 2 },',
' },',
' "safety": "restore only into an isolated training candidate"',
'}',
].join('\n');
const createArchiveCommands = [
'# Учебная последовательность: команды не выполнялись этой статьёй.',
'# training_catalog — вымышленное имя логической базы, не адрес окружения.',
'pg_dump --format=custom --file=training-catalog-2020-10.dump training_catalog',
'shasum -a 256 training-catalog-2020-10.dump',
'pg_restore --list training-catalog-2020-10.dump',
'# Полученные hash и список объектов записывают в manifest рядом с архивом.',
].join('\n');
const restoreCommands = [
'# Только изолированный учебный кандидат; источник не перезаписывается.',
'createdb training_restore_candidate',
'pg_restore --dbname=training_restore_candidate training-catalog-2020-10.dump',
'psql --dbname=training_restore_candidate --command="SELECT count(*) FROM catalog.items;"',
'psql --dbname=training_restore_candidate --command="SELECT count(*) FROM reference.codes;"',
'# Результат сравнивают с manifest, а не с ожиданием «команда завершилась без текста».',
].join('\n');
const drillEventExample = [
'{"step":"scope_read","result":"catalog + reference; cluster roles excluded","kind":"training"}',
'{"step":"artifact_hash","result":"matches manifest","kind":"training"}',
'{"step":"archive_list","result":"expected relations listed","kind":"training"}',
'{"step":"restore_candidate","result":"completed without source overwrite","kind":"training"}',
'{"step":"semantic_check","result":"2 relations and expected row counts","kind":"training"}',
'{"step":"verdict","result":"evidence recorded; next drill still required","kind":"training"}',
].join('\n');
const fixtureBytes = Buffer.from(
'training archive v1\\nrelation catalog.items rows 3\\nrelation reference.codes rows 2\\n',
'utf8',
);
function sha256(bytes) {
return createHash('sha256').update(bytes).digest('hex');
}
function sameMembers(left, right) {
const leftSet = new Set(left);
const rightSet = new Set(right);
return leftSet.size === left.length
&& rightSet.size === right.length
&& leftSet.size === rightSet.size
&& [...leftSet].every((value) => rightSet.has(value));
}
export function createTrainingManifest() {
return {
manifestVersion: 1,
backupId: 'training-backup-2020-10-a',
artifact: {
file: 'training-catalog-2020-10.dump',
format: 'pg_dump custom (-Fc)',
bytes: fixtureBytes.length,
sha256: sha256(fixtureBytes),
},
scope: {
includes: ['schema:catalog', 'schema:reference'],
excludes: ['cluster roles', 'tablespaces', 'external files'],
},
restoreChecks: {
relations: ['catalog.items', 'reference.codes'],
rows: {
'catalog.items': 3,
'reference.codes': 2,
},
},
safety: {
target: 'isolated training candidate',
sourceOverwriteAllowed: false,
},
};
}
export function inspectTrainingBackup(manifest, bytes) {
const requiredScope = ['schema:catalog', 'schema:reference'];
const checksumMatches = manifest.artifact.sha256 === sha256(bytes);
const byteCountMatches = manifest.artifact.bytes === bytes.length;
const scopeMatches = sameMembers(manifest.scope.includes, requiredScope)
&& manifest.scope.excludes.includes('cluster roles')
&& manifest.scope.excludes.includes('tablespaces');
const safetyMatches = manifest.safety.target === 'isolated training candidate'
&& manifest.safety.sourceOverwriteAllowed === false;
return {
checksumMatches,
byteCountMatches,
scopeMatches,
safetyMatches,
canAttemptRestore: checksumMatches && byteCountMatches && scopeMatches && safetyMatches,
};
}
export function runBackupRestoreFixture() {
const manifest = createTrainingManifest();
const inspection = inspectTrainingBackup(manifest, fixtureBytes);
const simulatedRestore = {
relations: [
{ name: 'catalog.items', rows: 3 },
{ name: 'reference.codes', rows: 2 },
],
targetWasIsolated: true,
sourceWasUntouched: true,
};
const rowsByRelation = Object.fromEntries(
simulatedRestore.relations.map((relation) => [relation.name, relation.rows]),
);
const expectedRelationsPresent = sameMembers(
simulatedRestore.relations.map((relation) => relation.name),
manifest.restoreChecks.relations,
);
const expectedRowsPresent = Object.entries(manifest.restoreChecks.rows).every(
([name, rows]) => rowsByRelation[name] === rows,
);
const alteredBytes = Buffer.concat([fixtureBytes, Buffer.from('altered', 'utf8')]);
const alteredRejected = inspectTrainingBackup(manifest, alteredBytes).checksumMatches === false;
const narrowedManifest = {
...manifest,
scope: {
...manifest.scope,
includes: ['schema:catalog'],
},
};
const narrowedScopeRejected = inspectTrainingBackup(narrowedManifest, fixtureBytes).scopeMatches === false;
return {
fixture: 'Deterministic in-memory teaching model; it does not create an archive, connect to PostgreSQL, or restore data.',
manifest,
inspection,
simulatedRestore,
checks: {
checksumMatches: inspection.checksumMatches,
byteCountMatches: inspection.byteCountMatches,
scopeMatches: inspection.scopeMatches,
safetyMatches: inspection.safetyMatches,
expectedRelationsPresent,
expectedRowsPresent,
alteredBytesRejected: alteredRejected,
narrowedScopeRejected,
sourceWasUntouched: simulatedRestore.sourceWasUntouched,
},
};
}
export function verifyFixture() {
const fixture = runBackupRestoreFixture();
const passed = Object.values(fixture.checks).every(Boolean) && fixture.inspection.canAttemptRestore;
if (!passed) {
throw new Error('backup/restore fixture did not preserve the documented training contract');
}
return fixture;
}
const practiceArticle = createRevision(
{
slug: 'editorial-2020-10-practice-backup-recovery',
title: 'Резервная копия — это ещё не восстановление: учебный restore drill',
categories: ['Данные', 'Надёжность', 'Практика'],
cover: '/assets/editorial/2020/backup-recovery-contract-2020.svg',
excerpt: 'Архив в хранилище отвечает только на вопрос о записи байтов. Фиксируем scope, manifest и безопасный учебный restore, чтобы проверять готовность не по имени файла.',
readingMinutes: 14,
},
[
paragraph('Симптом выглядит успокаивающе: по расписанию появляется файл архива, задача заканчивается без ошибки, а в таблице есть зелёная отметка. Но при первом вопросе «что именно вернётся из этой копии?» обычно выясняется, что никто не открывал archive, не сверял его состав и не поднимал отдельного кандидата на restore. Цена такой уверенности — не только потеря времени в сбое. Можно восстановить неполный набор данных, затронуть исходную базу неверной командой или обнаружить отсутствующую зависимость тогда, когда выбирать уже некогда.'),
paragraph('В октябре 2020 года я бы не называл это зрелой SRE-программой и не рисовал реальные RPO или RTO. Ниже — учебный restore drill для вымышленной логической базы с двумя схемами. Его задача скромнее: отделить факт создания архива от доказательства, что известный набор байтов можно развернуть в изолированном кандидате и проверить заранее выбранными запросами. Это runbook, который ещё надо измерить и привязать к конкретному проекту, а не обещание recovery в production.'),
heading('Backup и restore отвечают на разные вопросы'),
paragraph('Backup отвечает на вопрос «какие байты и с каким scope мы сохранили в этот момент». Restore отвечает на другой: «получится ли из этих байтов собрать нужный набор объектов в безопасной цели и доказать это проверками». Между ними лежат формат archive, версия инструмента, список объектов, права, внешние файлы и само место, куда идёт восстановление. Поэтому строка «backup completed» — полезное событие, но не финальный verdict. Она сообщает о завершении одной команды, а не о работоспособности будущего маршрута.'),
paragraph('Для логической базы PostgreSQL удобен формат custom, который затем читает <code>pg_restore</code>. Документация 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 обычно начинается не с повреждённого файла, а с неоговорённой границы. Владелец ожидает роли, таблицы, вложения или соседнюю схему, а выбранная команда сохраняет только часть базы. Причина в том, что удобная выборка <code>--schema</code> и особенно <code>--table</code> не превращает выбранный объект в самостоятельную систему. PostgreSQL предупреждает: dump отдельной схемы или таблицы может не включить зависимости, нужные для восстановления в чистой базе. Проверка здесь не догадка по размеру файла, а записанный список включений и исключений до запуска.'),
dataTable(
'Учебный scope backup contract: что фиксируется до создания archive',
['Поле', 'Учебное значение', 'Какой риск снимает', 'Что не доказывает'],
[
['Логическая единица', '<code>training_catalog</code>', 'не путает один database dump с cluster backup', 'наличие ролей и tablespaces'],
['Включено', '<code>schema:catalog</code>, <code>schema:reference</code>', 'даёт проверяемый список ожидаемых объектов', 'что внешние файлы вернутся сами'],
['Исключено', 'cluster roles, tablespaces, external files', 'не прячет заведомо отсутствующие области', 'что исключения допустимы для конкретной задачи'],
['Формат', '<code>pg_dump custom (-Fc)</code>', 'связывает archive с маршрутом через <code>pg_restore</code>', 'совместимость любой версии без отдельной проверки'],
['Цель 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 остановится до <code>pg_restore</code>. Но совпавший hash не означает, что схема подходит приложению, нужные роли существуют или семантическая проверка пройдёт. Это важная граница: checksum — gate перед restore, не сертификат работоспособности. Именно поэтому manifest хранит и технический digest, и независимый список смысловых checks.'),
heading('Минимальная учебная последовательность'),
paragraph('Команды ниже показывают форму маршрута, а не запускаются этой статьёй. Имя <code>training_catalog</code> вымышленное; пароль, host и строка подключения намеренно отсутствуют. Перед практическим применением команда должна выбрать способ аутентификации вне текста, проверить права и зафиксировать версию клиента рядом с manifest. Для учебной копии полезно сначала получить archive и его оглавление, а уже затем разрешать восстановление в отдельной цели.'),
codeBlock(createArchiveCommands),
paragraph('Список из <code>pg_restore --list</code> не заменяет восстановление, но он быстро ловит другой класс ошибок: archive другого формата, отсутствующий ожидаемый объект или не тот файл под правильным именем. Если оглавление не похоже на manifest, следующий шаг не «попробовать всё равно», а остановиться и выяснить, где расходятся scope, команда и опубликованный артефакт. Так диагностика остаётся короткой: симптом — список не совпал; причина — не тот archive или неполный scope; проверка — manifest плюс list; действие — не запускать restore.'),
heading('Restore должен быть безопасной пробой, а не риском для источника'),
paragraph('Самая опасная ошибка в runbook — команда, которую можно случайно выполнить против источника. Поэтому учебный маршрут заранее запрещает перезапись исходной базы, не использует <code>--clean</code> как «исправление» и требует отдельного кандидата. В нём нет реальных данных: набор 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',
['Доказательство', 'Что оно подтверждает', 'Что не подтверждает', 'Следующее действие'],
[
['Успешный <code>pg_dump</code>', 'инструмент завершил выбранный logical dump', 'полноту cluster-wide объектов и будущий restore', 'записать scope, stderr и формат в manifest'],
['SHA-256 совпал', 'проверяемые байты archive не изменились', 'семантику данных и совместимость цели', 'прочитать archive и подготовить isolated candidate'],
['<code>pg_restore --list</code>', '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 прямо разделяет области. <code>pg_dump</code> делает dump одной базы, а cluster-wide объекты, такие как роли и tablespaces, требуют отдельного рассмотрения через <code>pg_dumpall</code>. Это не мелкая деталь командной строки: если restore нуждается в роли, а manifest её не упомянул, archive одной базы не становится «почти полным» от хорошего настроения. В учебном примере роли и tablespaces явно исключены, поэтому drill не может нечаянно зачесть их как восстановленные.'),
paragraph('Выбор схемы тоже имеет цену. Параметр <code>--schema</code> удобен, когда цель действительно ограничена одной областью, но документация предупреждает, что зависимости выбранной схемы могут не попасть в чистую цель. Поэтому перед первым 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 и правило цели. Важно, чтобы поле называло наблюдаемое значение, а не эмоцию. Вместо <code>"complete": true</code> лучше хранить конкретное <code>scope.includes</code> и <code>restoreChecks.rows</code>. Тогда следующий читатель может повторить проверку или оспорить её границу, не пытаясь расшифровать одно булево поле.'),
codeBlock(manifestExample),
paragraph('Два свойства manifest особенно легко перепутать. <code>artifact.sha256</code> относится к уже записанному набору байтов; если bytes изменились, restore не должен продолжаться. <code>restoreChecks</code> относятся к тому, что появилось после развертывания; они не вычисляются из 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 использует <code>pg_restore</code>; 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 <code>catalog</code> и <code>reference</code>, а excludes прямо называют cluster roles, tablespaces и external files. Это важнее красивого JSON: исключённая область должна быть видна до drill, чтобы позже никто не искал её в archive. PostgreSQL 12 разделяет dump одной базы и cluster-wide объекты; если роли нужны будущей цели, это отдельная часть плана, а не скрытый побочный эффект <code>pg_dump</code>.'),
figure('/assets/editorial/2020/backup-recovery-diagnosis-2020.svg', 'Вертикальная схема диагностики учебного restore drill: для каждого симптома от отсутствующего scope до несовпавшего checksum и пустой relation показаны причина, точная проверка и безопасное действие; путь не содержит операций над источником', 'Схема помогает не перескакивать к restore. Сначала проверяем contract и байты, затем безопасность цели, затем структуру и синтетические данные результата.'),
heading('Учебная шкала не равна измеренному RPO или RTO'),
paragraph('В runbook полезно записывать последовательность событий, но нельзя подменять их историей о измеренном времени. Отметка <code>restore_started</code> нужна, чтобы в следующем разрешённом drill измерить интервал до <code>semantic_checks_passed</code>. До этого она не становится 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 не содержит <code>reference.codes</code>. Самая вероятная причина не в checksum: hash может идеально совпадать с теми байтами, которые изначально были сохранены неполно. Проверка — сравнить list archive с <code>scope.includes</code> и <code>restoreChecks.relations</code> из 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, а не как сбой <code>pg_restore</code>.',
'Вычислить 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 и восстановление через <code>pg_restore</code> — конкретные механизмы, а не общая метафора «бэкапа». 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');
}
}