diff --git a/CHANGELOG.md b/CHANGELOG.md index df027965..b9525693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,15 @@ **これまでに既存の世代の名前へ `--full` を打って失敗したことがあり、その後に同じ世代へ差分が積まれていれば、 その世代の差分は失敗の前の変更を欠いていることがあります。** 世代のファイルからは見分けられないため、 心当たりがあれば新しい世代を作ってください(`devbase snapshot create`)。 +- **既存の世代へ差分を積む `devbase snapshot create --name <名前>` と `devbase up` の自動スナップショットが失敗しても、 + 世代の `snapshot.snar` と差分を打つ前のまま残すようにしました(#466)。** これまでは失敗した回に `snapshot.snar` だけが + 進み、その後の差分が失敗の前の変更を含まず、復元でそのファイルが無いか古い中身になっていました。書く途中で止まった + 作りかけの `incr-NNN.tar.zst` も残り、復元がそのアーカイブで止まることがありました。今は失敗の後の差分が失敗の前の + 変更も含みます。tar が読み取りで止まった(終了コード 2 以上)控えも失敗として扱います。失敗したときの知らせ方 + (`create` は終了コード 1、`devbase up` は警告 1 行で起動を続ける)は変わりません。差分の作成は `snapshot.snar.bak` を作らなくなりました。 + **これまでに差分の作成が失敗したことのある世代は、その後の差分が失敗の前の変更を欠いていることがあります。** + 世代のファイルからは見分けられないため、心当たりがあれば新しい世代を作ってください(`devbase snapshot create`)。 + 作りかけの差分で復元が止まる世代は、`devbase snapshot restore <名前> --point <その前の番号>` でその前の時点へ戻せます。 - **プロジェクトの `env` に `DEV_SERVICE_NAME=` と空で書いたとき、開発サービス名を `dev` として扱うようにしました(#425)。** これまで `devbase build` は `dev` を建てる一方、`up`・`login`・`open`・`post-start`・`scale`・`--expires` の判定・ token の配布は空の名前のサービスを探して開発サービスを見失っていました。未設定や空でない値のプロジェクトは変わりません。 diff --git a/docs/specifications/snapshot-exclude.md b/docs/specifications/snapshot-exclude.md index 283eb9b2..510846a8 100644 --- a/docs/specifications/snapshot-exclude.md +++ b/docs/specifications/snapshot-exclude.md @@ -47,7 +47,7 @@ | 世代の記録 | 同 `_recorded_excludes` | 世代の `meta.yml` の `exclude` を読む。無い・リストでない・文字列でない要素を含むなら `None` | | 比較 | 同 `_excludes_differ`・`_incremental_block_reason` | 世代の記録が今の一覧と違うかを決める。差分を積めない理由(対象ボリューム → 控えの起点 → 除外の一覧の順)の最後に当てる | | 控えに使う一覧 | 同 `_excludes_for`・`_latest_recorded_excludes` | 設定を読めたら今の一覧、読めなければ記録の一覧を返す(下の「読めない設定の間」) | -| tar のコマンド | 同 `backup_command(archive, volumes, excludes, snar)` | 除外の引数を `-C` とメンバーより前に置く。`snar` は既定で `snapshot.snar`、世代の作り直しは `snapshot.snar.new` | +| tar のコマンド | 同 `backup_command(archive, volumes, excludes, snar)` | 除外の引数を `-C` とメンバーより前に置く。`snar` は既定で `snapshot.snar`、世代の作り直しと既存の世代への差分の作成は `snapshot.snar.new`(#466) | | 作成 | 同 `_create_full`・`_create_incremental` | `_excludes_for` の一覧で控える。full は `meta.yml` に `exclude` を書き、差分は書き換えない | 世代に関わる判断(記録・比較・積み先)は `manager.py` に置き、`exclude.py` は世代の状態に依らない処理だけを diff --git a/docs/specifications/snapshot-series.md b/docs/specifications/snapshot-series.md index 104705f1..91ec114b 100644 --- a/docs/specifications/snapshot-series.md +++ b/docs/specifications/snapshot-series.md @@ -69,6 +69,7 @@ | 保持 | 同 `rotate(keep, max_total)` と、副作用の無い `_rotation_plan` | 削除候補を決め、場所を検証してから消す | | 世代の作り直し | 同 `_create_full` / `_has_previous_files` / `checked_backup_command` / `_install_rebuilt_full` / `_replace_snap_meta` / `_discard_rebuild_temps`、`restore`(印の検査)。仮の名前は定数 `FULL_ARCHIVE_TMP`・`SNAR_FILE_TMP`・`META_FILE_TMP`、印は `REBUILDING_KEY` | 前のファイルを持つ世代へ full を作るとき、仮の名前へ控えてから決まった順で入れ替える。作り直しの途中の印を持つ世代の復元を止める(下の「世代の作り直し」) | | 世代の場所の検証 | 同 `_safe_snap_dir` | 名前・シンボリックリンク・`backups/` の外を `SnapshotError` で止める | +| 差分の作成 | 同 `_create_incremental` / `checked_backup_command` / `_install_incremental` / `_discard_incremental_temps`。仮の名前は定数 `INCR_ARCHIVE_TMP`・`SNAR_FILE_TMP` | 既存の世代へ差分を積むとき、世代の snar の写しと仮の名前の差分へ控え、成功してから差分 → snar の順に改名で置く(下の「差分の作成」) | | 自動スナップショット | `lib/devbase/commands/container.py` の `_auto_snapshot` / `_should_skip_by_interval` | 最小間隔を系列で判定し、判定の結果に従って `create` と `rotate()` を呼ぶ | | 自動ローテーション | 同 `cmd_down` | `mgr.rotate()` を既定の引数で呼ぶ | | 手動ローテーション | `lib/devbase/cli.py`(`--keep` / `--max-total`)、`lib/devbase/commands/snapshot.py` の `cmd_snapshot` / `_snapshot_rotate` | 引数を `rotate` へ渡す。`max_total` は `getattr(args, 'max_total', None)` で受ける | @@ -168,6 +169,35 @@ classDiagram からの変更」として次の差分に入るだけである。代償として、共通ボリュームの変化は系列ごとに別々に 控えられる。 +### 差分の作成 + +既存の世代へ差分を積むとき(`create --name <名前>` の `--full` なし・`devbase up` の自動スナップショット)、`_create_incremental` は +組・控えの起点・除外の一覧の検査を通った後に次の順で進む。 + +1. 前の中断の残りの仮のファイル(`incr.tar.zst.new`・`snapshot.snar.new`)を消す。snar の有無の判定より前に消し、snar の無い世代の + 作り直しにも持ち込まない +2. `snapshot.snar` が無ければ full へ切り替える(「世代の作り直し」) +3. ホストで `snapshot.snar` を `snapshot.snar.new` へ写し、`checked_backup_command(backup_command('incr.tar.zst.new', …, snar='snapshot.snar.new'))` + を docker で打つ。tar は世代の `snapshot.snar` に触らない。失敗の定義は作り直しと同じ(tar が 2 以上、または zstd が 0 以外。tar の 1 は通す) +4. 成功したら `incr.tar.zst.new` → `incr-NNN.tar.zst`、`snapshot.snar.new` → `snapshot.snar` の順に改名する。番号は今までどおり + `incr-*.tar.zst` の本数から決める +5. `meta.yml`(`type: incremental`・`files` に足す・`incremental_count`)と `snapshot.yml` のエントリを更新する + +写し・控え・改名のどこで失敗しても、仮のファイルを消して `SnapshotError` を返す(「エラー処理」)。差分の作成は `snapshot.snar.bak` を作らない。 +前の devbase が作った `snapshot.snar.bak` には触らず、世代の作り直しが消す。 + +常に成り立つ条件: + +- 控えのコマンドか snar の写しが失敗したら、世代のファイルの一覧と各ファイルのバイト列は打つ前と同じで、`snapshot.yml` も変わらない +- `snapshot.snar` は、その状態を作った控えの差分が `incr-NNN.tar.zst` として置かれた後でだけ進む。どこで止まっても世代は + (a) snar も差分の列も打つ前と同じ、(b) 新しい差分が置かれ snar は打つ前と同じ、(c) 新しい差分が置かれ snar が進んだ、のどれかである。 + 「snar が進み新しい差分が無い」にはならない +- (b) で止まった世代へ次の差分を作ると、打つ前の snar から控えるため (b) の差分の分を重ねて含み、失敗した回の後の変更を欠かない。 + 復元は (b) の差分とその後の差分を番号の順に当てて、最後の差分の時点になる。(b) の `meta.yml` の `files` と `incremental_count` は + 置かれた差分を数えないが、`files` を読む処理は無く、`incremental_count` は次の差分の成功で本数どおりに戻る +- 仮のファイルの名前は `incr-*.tar.zst` の glob・`is_archive_file`・`_has_previous_files` に当たらず、番号の決め方・最小間隔・復元の差分の列に入らない +- 成功した差分の作成の結果(アーカイブの一覧・`snapshot.snar`・`meta.yml`・`snapshot.yml` のエントリ)は、この規則の前と同じである + ### 最小間隔 `_auto_snapshot` は `last_snapshot_time(mgr.volumes)` で、起動するグループの系列の世代だけを見る。 @@ -324,7 +354,8 @@ zstd は既にある `full.tar.zst` を上書きしない。仮の名前と空 - 控えのコマンドが失敗したら(手順 2 の定義: tar が 2 以上、または zstd が 0 以外)、仮の名前のファイルを消して `SnapshotError` を投げ直す。世代の既存のファイルは 1 バイトも変わらず、ファイルは増えない。`snapshot.yml` も変わらない - 作り直しに成功した世代は、`full.tar.zst` と `snapshot.snar` が同じ 1 回の控えから作られ、`incr-*.tar.zst`・ - `snapshot.snar.bak`・仮の名前のファイルを持たない + `snapshot.snar.bak`・作り直しの仮の名前のファイル(`full.tar.zst.new`・`snapshot.snar.new`・`meta.yml.new`)を持たない。 + 前の中断で残った差分の作成の `incr.tar.zst.new` は作り直しが消さず、次の差分の作成が消す - 入れ替え(手順 3)の途中で止まった世代は `snapshot.snar` を持たない。次の `create`(名前の指定・自動 スナップショット)は snar の無い世代の経路で作り直す - 入れ替えの途中で止まった世代は、次のどちらかである @@ -363,6 +394,7 @@ zstd は既にある `full.tar.zst` を上書きしない。仮の名前と空 | 各世代の `meta.yml` の `rebuilding` | 世代の作り直しの入れ替えの途中だけ `true`。持つ世代は `restore` が止める。作り直しを終えた世代と新しい世代は持たない | | 各世代の `meta.yml` の `exclude` | full を作ったときの除外の一覧(並べ替え済み)。差分では書き換えない。積む直前の検証(順 4a と `_create_incremental`)でだけ読む | | アーカイブの mtime | 最小間隔の判定 | +| 各世代の `incr.tar.zst.new`・`snapshot.snar.new` | 差分の作成の間だけある仮のファイル。成功でも失敗でも残さず、プロセスが止められたときだけ残って次の差分の作成が控えの前に消す。アーカイブとして数えない | | 設定 | 値 | | --- | --- | @@ -404,6 +436,9 @@ zstd は既にある `full.tar.zst` を上書きしない。仮の名前と空 | 世代の作り直しの入れ替えが失敗 | 仮の名前のファイルを消して `SnapshotError`(`世代 '{名前}' の作り直しを終えられませんでした: {理由}。もう一度 devbase snapshot create --name {名前} --full を打ってください`)→ CLI は終了コード 1 | | `meta.yml` に `rebuilding: true` を持つ世代の `restore` | 復元前バックアップを作らず、ボリュームを消さずに `SnapshotError`(`世代 '{名前}' は作り直しの途中で止まっているため復元できません。devbase snapshot create --name {名前} --full を打って作り直してください`)→ CLI は終了コード 1 | | 仮の名前のファイルを消せない | 警告 1 行で続ける。次の作り直しが控えの前に消す | +| 差分の作成の控えのコマンドが失敗(tar の終了コードが 2 以上、または zstd が 0 以外。`checked_backup_command`) | 差分の作成の仮のファイルを消して `SnapshotError`(`Dockerでのtar操作に失敗しました: {stderr}`)→ `create` は終了コード 1、`devbase up` は警告 1 行で起動を続ける。世代と `snapshot.yml` は打つ前のまま | +| 差分の作成の snar の写し・差分の改名・snar の改名が失敗(`OSError`) | 差分の作成の仮のファイルを消して `SnapshotError`(`世代 '{名前}' に差分を作れませんでした: {理由}`)→ 同上。snar の改名で止まったときだけ新しい差分が置かれ、snar は打つ前のまま | +| 差分の作成の仮のファイルを消せない | 警告 1 行(`差分の作成の仮のファイル {パス} を消せませんでした: {理由}`)で続ける。次の差分の作成が控えの前に消す | ## 運用 @@ -419,9 +454,13 @@ zstd は既にある `full.tar.zst` を上書きしない。仮の名前と空 - 世代の作り直しが入る前の devbase で、既にある世代の名前へ `--full` を打って失敗し、その後に同じ世代へ差分を 積んだことがあれば、その世代の差分は失敗の前の変更を欠き得る(失敗した回に `snapshot.snar` だけが進んでいた)。 ファイルの形からは見分けられないため、見つける処理は持たない。案内は `CHANGELOG.md` にあり、新しい世代を作り直す -- 差分の作成(`_create_incremental`)で控えのコマンドが失敗すると、`snapshot.snar` は進んだまま残る(#466)。 - 作り直しの経路と違い、差分の経路は前の状態へ戻さない。新しい世代へ直接控える full と差分の控えのコマンドは - `checked_backup_command` で包まず、tar の失敗を zstd の成功が隠し得る +- 差分の作成(`_create_incremental`)は、控え・写し・改名のどこで失敗しても `snapshot.snar` を進めたまま残さず、 + 作りかけの差分も残さない(「差分の作成」)。失敗の後の差分は失敗の前の変更も含む +- 差分の作成の失敗の扱いが入る前の devbase で差分の作成が失敗した世代は、その後の差分が失敗の前の変更を欠き得る + (失敗した回に `snapshot.snar` だけが進んでいた)。作りかけの `incr-NNN.tar.zst` が残った世代は、復元がそのアーカイブで止まる。 + どちらもファイルの形からは見分けられないため、見つける処理は持たない。作りかけの差分で復元が止まる世代は + `devbase snapshot restore <名前> --point <その前の番号>` でその前の時点へ戻せる。欠けを避けるには新しい世代を作り直す +- 新しい世代へ直接控える full の控えのコマンドは `checked_backup_command` で包まず、tar の失敗を zstd の成功が隠し得る ## テスト観点 @@ -481,7 +520,8 @@ CLI と TUI: - 実の GNU tar と zstd で、`a`・`b` の full と `incr-001` を持つ世代へ `c` を足して `create(name='g', full=True)` を打つと、 `full.tar.zst` の中身の一覧に `a`・`b`・`c` がすべて入ること - その世代を復元すると `a`・`b`・`c` が戻り、`a`・`b` の中身が作り直した時点の中身であること(前の `incr-001` の中身へ戻らないこと) -- 作り直した後の世代が `full.tar.zst`・`snapshot.snar`・`meta.yml` だけを持ち(`incr-*`・`snapshot.snar.bak`・仮の名前のファイルが無い)、 +- 作り直した後の世代が、前の中断で残った差分の作成の `incr.tar.zst.new` を除いて `full.tar.zst`・`snapshot.snar`・`meta.yml` だけを持ち + (`incr-*`・`snapshot.snar.bak`・作り直しの仮の名前のファイルが無い)、 `meta.yml` が `type: full`・`files: [full.tar.zst]`・`incremental_count: 0`・作り直した時点の除外の一覧を持ち、印を持たないこと - `snapshot.yml` のその世代のエントリが 1 件のまま、`incremental_count` が 0 で、`created_at` が作り直す前と同じであること - 控えのコマンドが前の snar を受け取らず仮の名前へ書き、tar の終了コード(`PIPESTATUS`)も見ること @@ -516,6 +556,23 @@ CLI と TUI: 交互に `devbase up` し、それぞれの系列の最新の世代に `incr-001` が積まれ、世代の数が変わらないことを `devbase snapshot list` で見る。 +差分の作成(`tests/snapshot/test_incremental_failure.py`): + +- full と差分 0 本・2 本の世代へ差分を作り、控えのコマンドが失敗する(tar が 2・zstd が 1・書く途中で止まる 141 と 153。 + 失敗の前に仮の名前のファイルを書く形)と `SnapshotError` になり、世代のファイルの一覧と各ファイルのバイト列と + `snapshot.yml` が打つ前と同じであること。tar が 1・zstd が 0 の控えは成功として `incr-(N+1).tar.zst` と進んだ snar が残ること +- snar の写しの失敗で、docker を呼ばずに `SnapshotError`(`世代 '{名前}' に差分を作れませんでした: {理由}`)になり、世代が打つ前と同じであること +- 差分の改名で止めると世代が打つ前と同じで、snar の改名で止めると新しい差分が置かれ snar が打つ前のままであること。 + その後の差分が次の番号で作られ、snar が進むこと +- 仮の名前(`incr.tar.zst.new`・`snapshot.snar.new`)が `incr-*.tar.zst` の glob・`is_archive_file`・`_has_previous_files` に当たらないこと。 + 前の中断で残った仮のファイルが控えの前に消え、最小間隔の判定と復元の差分の列に入らないこと +- 成功した差分の作成の後の世代が `full.tar.zst`・`incr-*.tar.zst`・`snapshot.snar`・`meta.yml` を持ち、`snapshot.snar.bak` を作らないこと。 + 前からある `snapshot.snar.bak` には触らないこと。控えに渡す仮の snar が世代の snar の写しから始まること +- 差分の作成の失敗(控え・写し・改名)で、`create --name` が終了コード 1、`devbase up` の自動スナップショットが警告 1 行で続き、世代が変わらないこと +- 実の GNU tar と zstd で、`c` を足して控えを失敗させ(tar が 2・zstd が 1 のそれぞれ)、`e` を足して次の差分を作ると、 + `incr-002.tar.zst` の中身が `ai/c` と `ai/e` の両方であること +- 実の GNU tar と zstd で、snar の改名で止めた世代へ追加・書き換え・削除の後に次の差分を積んで復元すると、復元先が元のツリーと同じであること + ## 関連リンク - [スナップショットガイド](../user/snapshot-guide.md) diff --git a/lib/devbase/snapshot/manager.py b/lib/devbase/snapshot/manager.py index 805a0d1a..030efd2c 100644 --- a/lib/devbase/snapshot/manager.py +++ b/lib/devbase/snapshot/manager.py @@ -58,6 +58,10 @@ # 作り直しの途中で full と meta.yml の組が合わない間だけ meta.yml に立てる印。 # 印のある世代は復元しない。 REBUILDING_KEY = 'rebuilding' +# 既存の世代へ差分を積むときの差分の仮の名前 (#466)。状態ファイルは作り直しと同じ +# ``SNAR_FILE_TMP`` へ写して控え、成功してから差分 → 状態ファイルの順に改名で置く。 +# 番号を含めず、``is_archive_file`` と ``INCR_ARCHIVE_GLOB`` に当たらない名前にする。 +INCR_ARCHIVE_TMP = 'incr.tar.zst.new' # コンテナ内のマウント先。作成時は対象ボリュームを /source の下へ読み取り専用で、 # 復元時は /target の下へ書き込み可で並べる。世代ディレクトリは /backup。 @@ -1030,12 +1034,12 @@ def backup_command(archive: str, volumes: dict, excludes=(), snar: str = SNAR_FI @staticmethod def checked_backup_command(command: str) -> str: - """``backup_command`` の終了コードに tar の失敗も載せる (世代の作り直し用)。 + """``backup_command`` の終了コードに tar の失敗も載せる (世代の作り直しと差分の作成用)。 パイプの終了コードは zstd のものなので、tar が読み取りで止まっても成功に見える。 - 作り直しはこの後で前の full と差分を消すため、tar の致命的な失敗 (2 以上) と - zstd の失敗のどちらでも止める。tar の 1 (読む間にファイルが変わった) は、 - 新しい世代へ直接控えるときと同じく通す。 + 作り直しはこの後で前の full と差分を消し、差分の作成はこの後で差分と snar を + 世代へ置くため、tar の致命的な失敗 (2 以上) と zstd の失敗のどちらでも止める。 + tar の 1 (読む間にファイルが変わった) は、新しい世代へ直接控えるときと同じく通す。 """ return ( f"{command}; s=(\"${{PIPESTATUS[@]}}\"); " @@ -1242,6 +1246,27 @@ def _discard_rebuild_temps(snap_dir: Path) -> None: logger.warning("作り直しの仮のファイル %s を消せませんでした: %s", snap_dir / tmp, e) + @staticmethod + def _install_incremental(snap_dir: Path, incr_name: str) -> None: + """仮の名前で控えた差分と snar を世代へ置く (#466 決定 2)。 + + 差分 → snar の順に改名する。間で止まっても世代は「新しい差分が置かれ snar は + 打つ前と同じ」になり、「snar が進み新しい差分が無い」にはならない。 + 途中の失敗は ``OSError`` のまま外へ出す。 + """ + os.replace(snap_dir / INCR_ARCHIVE_TMP, snap_dir / incr_name) + os.replace(snap_dir / SNAR_FILE_TMP, snap_dir / SNAR_FILE) + + @staticmethod + def _discard_incremental_temps(snap_dir: Path) -> None: + """差分の作成の仮のファイルを消す。消せなければ警告して続ける (#466 決定 6)。""" + for tmp in (INCR_ARCHIVE_TMP, SNAR_FILE_TMP): + try: + (snap_dir / tmp).unlink(missing_ok=True) + except OSError as e: + logger.warning("差分の作成の仮のファイル %s を消せませんでした: %s", + snap_dir / tmp, e) + def _create_incremental(self, name: str, snap_dir: Path) -> None: """差分バックアップを作成""" reason, recorded = self._incremental_block_reason(snap_dir) @@ -1274,6 +1299,9 @@ def _create_incremental(self, name: str, snap_dir: Path) -> None: # 設定を読めない間は記録の一覧で積む (記録と同じなら今の一覧と同じ) excludes = self._excludes_for(snap_dir, self.volumes) + # 前の中断の残りは、snar の有無の判定より前に消す (#466 決定 4) + self._discard_incremental_temps(snap_dir) + snar_file = snap_dir / SNAR_FILE if not snar_file.exists(): # snarファイルがなければフルバックアップにフォールバック @@ -1288,12 +1316,22 @@ def _create_incremental(self, name: str, snap_dir: Path) -> None: logger.info("差分バックアップを作成中: %s/%s", name, incr_name) - # 差分の作成が途中で失敗しても snar を戻せるよう、先に控えを取る。 - self._run_docker_tar( - snap_dir, 'backup', - f"cp {ARCHIVE_MOUNT}/{SNAR_FILE} {ARCHIVE_MOUNT}/{SNAR_FILE}.bak && " - + self.backup_command(incr_name, self.volumes, excludes) - ) + # 世代の snar を tar に渡さず、写した仮の snar と仮の名前の差分へ控える。控えが + # 失敗しても世代の本来の名前のファイルは 1 バイトも変わらない (#466 決定 1・3) + try: + shutil.copyfile(snar_file, snap_dir / SNAR_FILE_TMP) + self._run_docker_tar( + snap_dir, 'backup', + self.checked_backup_command(self.backup_command( + INCR_ARCHIVE_TMP, self.volumes, excludes, snar=SNAR_FILE_TMP)), + ) + self._install_incremental(snap_dir, incr_name) + except SnapshotError: + self._discard_incremental_temps(snap_dir) + raise + except OSError as e: + self._discard_incremental_temps(snap_dir) + raise SnapshotError(f"世代 '{name}' に差分を作れませんでした: {e}") from e # meta.yml を更新 snap_meta = self._load_snap_meta(snap_dir) diff --git a/tests/snapshot/test_archive_layout.py b/tests/snapshot/test_archive_layout.py index 064f9197..f4622fec 100644 --- a/tests/snapshot/test_archive_layout.py +++ b/tests/snapshot/test_archive_layout.py @@ -18,8 +18,10 @@ BACKUP_ROOT, FULL_ARCHIVE, INCR_ARCHIVE_GLOB, + INCR_ARCHIVE_TMP, RESTORE_ROOT, SNAR_FILE, + SNAR_FILE_TMP, SnapshotManager, incr_archive_name, incr_archive_number, @@ -153,10 +155,10 @@ def test_create_and_restore_go_through_the_helpers(tmp_path: Path): backup_cmds = [c["command"] for c in mgr.calls if c["mode"] == "backup"] assert backup_cmds[0] == SnapshotManager.backup_command( FULL_ARCHIVE, mgr.volumes, mgr.excludes) - assert backup_cmds[1].endswith( - SnapshotManager.backup_command(incr_archive_name(1), mgr.volumes, mgr.excludes)) - assert backup_cmds[1].startswith( - f"cp {ARCHIVE_MOUNT}/{SNAR_FILE} {ARCHIVE_MOUNT}/{SNAR_FILE}.bak && ") + # 差分は写した仮の snar と仮の名前の差分へ控え、成功してから改名で置く (#466) + assert backup_cmds[1] == SnapshotManager.checked_backup_command( + SnapshotManager.backup_command( + INCR_ARCHIVE_TMP, mgr.volumes, mgr.excludes, snar=SNAR_FILE_TMP)) mgr.calls.clear() mgr.restore("snap1") diff --git a/tests/snapshot/test_incremental_failure.py b/tests/snapshot/test_incremental_failure.py new file mode 100644 index 00000000..151254a4 --- /dev/null +++ b/tests/snapshot/test_incremental_failure.py @@ -0,0 +1,441 @@ +"""差分の作成の失敗 (#466) + +既存の世代へ差分を積むとき、世代の snar を写した仮の snar と仮の名前の差分へ控え、成功してから +差分 → snar の順に改名で置く。控え・写し・改名のどこで失敗しても、世代は「打つ前のまま」 +「新しい差分が置かれ snar は打つ前のまま」「新しい差分が置かれ snar が進んだ」のどれかで残る。 +``DEVBASE_ROOT`` を使わず ``tmp_path`` に閉じ、``_run_docker_tar`` を差し替えて Docker を起動しない。 +実の GNU tar と zstd を使うテストは、どちらかが無ければ飛ぶ。 +""" + +from __future__ import annotations + +import logging +import os +import re +import shutil +import subprocess +import types +from pathlib import Path + +import pytest +import yaml + +from devbase.commands import container +from devbase.commands.snapshot import cmd_snapshot +from devbase.errors import SnapshotCommandError, SnapshotError +from devbase.snapshot import manager as manager_mod +from devbase.snapshot.manager import ( + ARCHIVE_MOUNT, + FULL_ARCHIVE, + INCR_ARCHIVE_GLOB, + INCR_ARCHIVE_TMP, + SNAR_FILE, + SNAR_FILE_TMP, + SnapshotManager, + incr_archive_name, + is_archive_file, +) + +from .test_backup_real_tar import LocalTarManager, _has_gnu_tar, _members + +_ARCHIVE_RE = re.compile(r"-o /backup/([^\s;]+)") +_SNAR_RE = re.compile(r"--listed-incremental=/backup/([^\s;]+)") + +real_tar = pytest.mark.skipif( + not (_has_gnu_tar() and shutil.which("zstd")), reason="GNU tar と zstd が要る") + + +@pytest.fixture(autouse=True) +def _group_env(monkeypatch): + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "acme") + + +class FakeManager(SnapshotManager): + """``docker run`` を実行せず、控えのコマンドが書くはずのファイルを回ごとに違う中身で置く。 + + ``exit_codes`` に (tar, zstd) の終了コードを入れると、仮のファイルを書いた後に控えの + パイプをその終了コードの組に差し替えて bash で打ち、失敗なら ``SnapshotCommandError`` にする。 + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.calls: list = [] + self.runs = 0 + self.exit_codes = (0, 0) + + def _run_docker_tar(self, snap_dir, mode, command, volumes=None): + self.calls.append({"mode": mode, "command": command}) + if mode != "backup": + return None + self.runs += 1 + (snap_dir / _ARCHIVE_RE.search(command).group(1)).write_text(f"archive-{self.runs}") + (snap_dir / _SNAR_RE.search(command).group(1)).write_text(f"snar-{self.runs}") + tar, zstd = self.exit_codes + # 控えのパイプだけを差し替える。前の形 (``cp … .bak && `` を前に付ける) でも同じに打つ + pipeline = re.sub(r"^(?:cp \S+ \S+ && )?tar .*? \| zstd [^;]*", + f"(exit {tar}) | (exit {zstd})", command) + result = subprocess.run(["bash", "-c", pipeline], capture_output=True, text=True) + if result.returncode != 0: + raise SnapshotCommandError( + f"Dockerでのtar操作に失敗しました: tar={tar} zstd={zstd}", + stderr=f"tar={tar} zstd={zstd}") + return None + + +def _snap_dir(root: Path) -> Path: + return root / "backups" / "g" + + +def _snapshot(snap_dir: Path) -> dict: + return {p.name: p.read_bytes() for p in snap_dir.iterdir()} + + +def _state(root: Path) -> bytes: + return (root / "backups" / "snapshot.yml").read_bytes() + + +def _meta(snap_dir: Path) -> dict: + return yaml.safe_load((snap_dir / "meta.yml").read_text()) + + +def _with_incrementals(root: Path, count: int) -> FakeManager: + """世代 ``g`` を full と ``count`` 本の差分で作る。""" + mgr = FakeManager(root) + mgr.create(name="g") + for _ in range(count): + mgr.create(name="g") + mgr.calls.clear() + return mgr + + +def _fail_replace_to(monkeypatch, name: str) -> None: + """``os.replace`` のうち、``name`` へ改名する 1 回を失敗させる。""" + real = os.replace + + def fake(src, dst): + if Path(dst).name == name: + raise OSError("injected") + return real(src, dst) + + monkeypatch.setattr(manager_mod.os, "replace", fake) + + +# --------------------------------------------------------------------------- +# 控えの失敗 (受け入れ条件 1・3・I1) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("count", [0, 2]) +@pytest.mark.parametrize("exit_codes", [(2, 0), (0, 1), (141, 153)]) +def test_failed_backup_keeps_the_generation_byte_for_byte(tmp_path, count, exit_codes): + mgr = _with_incrementals(tmp_path, count) + snap_dir = _snap_dir(tmp_path) + before = _snapshot(snap_dir) + state_before = _state(tmp_path) + + mgr.exit_codes = exit_codes + with pytest.raises(SnapshotError, match=f"tar={exit_codes[0]} zstd={exit_codes[1]}"): + mgr.create(name="g") + + assert _snapshot(snap_dir) == before + assert _state(tmp_path) == state_before + + +def test_tar_exit_1_is_still_a_success(tmp_path): + """tar の 1 (読む間にファイルが変わった) は今と同じく通す。""" + mgr = _with_incrementals(tmp_path, 1) + snap_dir = _snap_dir(tmp_path) + mgr.exit_codes = (1, 0) + mgr.create(name="g") + assert (snap_dir / incr_archive_name(2)).read_text() == f"archive-{mgr.runs}" + assert (snap_dir / SNAR_FILE).read_text() == f"snar-{mgr.runs}" + + +def test_failed_snar_copy_keeps_the_generation(tmp_path, monkeypatch): + mgr = _with_incrementals(tmp_path, 1) + snap_dir = _snap_dir(tmp_path) + before = _snapshot(snap_dir) + state_before = _state(tmp_path) + + def fail(*args, **kwargs): + raise OSError("injected") + + monkeypatch.setattr(manager_mod.shutil, "copyfile", fail) + with pytest.raises(SnapshotError, match="世代 'g' に差分を作れませんでした: injected"): + mgr.create(name="g") + + assert mgr.calls == [] + assert _snapshot(snap_dir) == before + assert _state(tmp_path) == state_before + + +# --------------------------------------------------------------------------- +# 置く途中の失敗 (受け入れ条件 4・I2) +# --------------------------------------------------------------------------- + +def test_failed_incremental_rename_leaves_the_generation_as_before(tmp_path, monkeypatch): + """(a): 差分の改名で止まると、snar も差分の列も打つ前のまま。""" + mgr = _with_incrementals(tmp_path, 1) + snap_dir = _snap_dir(tmp_path) + before = _snapshot(snap_dir) + state_before = _state(tmp_path) + + _fail_replace_to(monkeypatch, incr_archive_name(2)) + with pytest.raises(SnapshotError, match="差分を作れませんでした"): + mgr.create(name="g") + + assert _snapshot(snap_dir) == before + assert _state(tmp_path) == state_before + + +def test_failed_snar_rename_leaves_the_new_incremental_and_the_old_snar(tmp_path, monkeypatch): + """(b): snar の改名で止まると、新しい差分が置かれ snar は打つ前のまま。""" + mgr = _with_incrementals(tmp_path, 1) + snap_dir = _snap_dir(tmp_path) + before = _snapshot(snap_dir) + + _fail_replace_to(monkeypatch, SNAR_FILE) + with pytest.raises(SnapshotError, match="差分を作れませんでした"): + mgr.create(name="g") + + after = _snapshot(snap_dir) + assert set(after) == set(before) | {incr_archive_name(2)} + assert after[incr_archive_name(2)] == f"archive-{mgr.runs}".encode() + assert {k: v for k, v in after.items() if k != incr_archive_name(2)} == before + + +def test_next_incremental_after_a_stop_gets_the_next_number(tmp_path, monkeypatch): + """(b) の世代へ次の差分を作ると、番号は重ならず snar が進む (受け入れ条件 5 のファイルの側)。""" + mgr = _with_incrementals(tmp_path, 1) + snap_dir = _snap_dir(tmp_path) + with monkeypatch.context() as m: + _fail_replace_to(m, SNAR_FILE) + with pytest.raises(SnapshotError): + mgr.create(name="g") + + mgr.create(name="g") + assert sorted(p.name for p in snap_dir.glob(INCR_ARCHIVE_GLOB)) == [ + incr_archive_name(1), incr_archive_name(2), incr_archive_name(3)] + assert (snap_dir / incr_archive_name(3)).read_text() == f"archive-{mgr.runs}" + assert (snap_dir / SNAR_FILE).read_text() == f"snar-{mgr.runs}" + assert not (snap_dir / INCR_ARCHIVE_TMP).exists() + assert not (snap_dir / SNAR_FILE_TMP).exists() + + +# --------------------------------------------------------------------------- +# 仮の名前のファイル (受け入れ条件 6・I3) +# --------------------------------------------------------------------------- + +def test_temp_names_are_not_archives(tmp_path): + for tmp in (INCR_ARCHIVE_TMP, SNAR_FILE_TMP): + assert not is_archive_file(tmp) + (tmp_path / tmp).write_text("stale") + assert not list(tmp_path.glob(INCR_ARCHIVE_GLOB)) + assert not SnapshotManager._has_previous_files(tmp_path) + + +def test_leftover_temps_are_discarded_before_the_backup(tmp_path): + mgr = _with_incrementals(tmp_path, 1) + snap_dir = _snap_dir(tmp_path) + for tmp in (INCR_ARCHIVE_TMP, SNAR_FILE_TMP): + (snap_dir / tmp).write_text("stale") + future = (snap_dir / incr_archive_name(1)).stat().st_mtime + 1000 + os.utime(snap_dir / INCR_ARCHIVE_TMP, (future, future)) + assert mgr.last_snapshot_time().timestamp() < future + + seen = {} + original = FakeManager._run_docker_tar + + def spy(self, snap_dir_, mode, command, volumes=None): + if mode == "backup": + seen["temps"] = {t for t in (INCR_ARCHIVE_TMP, SNAR_FILE_TMP) + if (snap_dir_ / t).exists() + and (snap_dir_ / t).read_bytes() == b"stale"} + return original(self, snap_dir_, mode, command, volumes) + + mgr._run_docker_tar = spy.__get__(mgr) + mgr.create(name="g") + assert seen["temps"] == set() + assert sorted(p.name for p in snap_dir.glob(INCR_ARCHIVE_GLOB)) == [ + incr_archive_name(1), incr_archive_name(2)] + + +def test_restore_does_not_pick_up_leftover_temps(tmp_path): + mgr = _with_incrementals(tmp_path, 1) + (_snap_dir(tmp_path) / INCR_ARCHIVE_TMP).write_text("stale") + mgr.restore("g") + restored = [c["command"] for c in mgr.calls if c["mode"] == "restore"] + assert not any(INCR_ARCHIVE_TMP in c for c in restored) + assert any(incr_archive_name(1) in c for c in restored) + + +# --------------------------------------------------------------------------- +# 成功の結果は今と同じ (受け入れ条件 7・I4) +# --------------------------------------------------------------------------- + +def test_successful_incremental_leaves_the_same_files_without_bak(tmp_path): + mgr = _with_incrementals(tmp_path, 0) + snap_dir = _snap_dir(tmp_path) + mgr.create(name="g") + assert {p.name for p in snap_dir.iterdir()} == { + FULL_ARCHIVE, incr_archive_name(1), SNAR_FILE, "meta.yml"} + assert (snap_dir / SNAR_FILE).read_text() == f"snar-{mgr.runs}" + meta = _meta(snap_dir) + assert meta["type"] == "incremental" + assert meta["files"] == [FULL_ARCHIVE, incr_archive_name(1)] + assert meta["incremental_count"] == 1 + [entry] = yaml.safe_load(_state(tmp_path))["snapshots"] + assert entry["incremental_count"] == 1 + + +def test_existing_bak_is_left_alone(tmp_path): + mgr = _with_incrementals(tmp_path, 1) + snap_dir = _snap_dir(tmp_path) + (snap_dir / f"{SNAR_FILE}.bak").write_text("old bak") + mgr.create(name="g") + assert (snap_dir / f"{SNAR_FILE}.bak").read_text() == "old bak" + + +def test_backup_command_reads_the_copied_snar(tmp_path): + """控えに渡す仮の snar は、世代の snar の写しから始まる。""" + mgr = _with_incrementals(tmp_path, 1) + snap_dir = _snap_dir(tmp_path) + expected = (snap_dir / SNAR_FILE).read_bytes() + seen = {} + + def spy(self, snap_dir_, mode, command, volumes=None): + seen["snar"] = (snap_dir_ / SNAR_FILE_TMP).read_bytes() + assert f"--listed-incremental={ARCHIVE_MOUNT}/{SNAR_FILE_TMP} " in command + return FakeManager._run_docker_tar(self, snap_dir_, mode, command, volumes) + + mgr._run_docker_tar = spy.__get__(mgr) + mgr.create(name="g") + assert seen["snar"] == expected + + +# --------------------------------------------------------------------------- +# 利用者への知らせ方は今と同じ (受け入れ条件 8・I5) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("stop", ["backup", "copy", "rename"]) +def test_cli_exits_1_when_the_incremental_fails(tmp_path, monkeypatch, stop): + mgr = _with_incrementals(tmp_path, 1) + if stop == "backup": + mgr.exit_codes = (2, 0) + elif stop == "copy": + monkeypatch.setattr(manager_mod.shutil, "copyfile", + lambda *a, **k: (_ for _ in ()).throw(OSError("injected"))) + else: + _fail_replace_to(monkeypatch, SNAR_FILE) + monkeypatch.setattr("devbase.commands.snapshot.SnapshotManager", lambda *a, **k: mgr) + args = types.SimpleNamespace(subcommand="create", name="g", full=False, group="acme") + assert cmd_snapshot(tmp_path, args) == 1 + + +def test_auto_snapshot_warns_once_and_keeps_the_generation(tmp_path, monkeypatch, caplog): + _with_incrementals(tmp_path, 1) + snap_dir = _snap_dir(tmp_path) + before = _snapshot(snap_dir) + monkeypatch.setenv("DEVBASE_ROOT", str(tmp_path)) + monkeypatch.setenv("DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES", "0") + + def fail(self, snap_dir_, mode, command, volumes=None): + if mode == "backup": + (snap_dir_ / INCR_ARCHIVE_TMP).write_text("partial") + (snap_dir_ / SNAR_FILE_TMP).write_text("advanced") + raise SnapshotCommandError("Dockerでのtar操作に失敗しました: boom", stderr="boom") + + monkeypatch.setattr(SnapshotManager, "_run_docker_tar", fail) + with caplog.at_level(logging.INFO, logger="devbase"): + container._auto_snapshot() + + warnings = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "デプロイは続行します" in warnings[0] + assert any("差分更新中: g" in r.getMessage() for r in caplog.records) + assert _snapshot(snap_dir) == before + + +# --------------------------------------------------------------------------- +# 実の tar と zstd (受け入れ条件 2・5) +# --------------------------------------------------------------------------- + +VOLUMES = ("ai", "group") + + +class FailingTarManager(LocalTarManager): + """``fail`` に ``"tar"``・``"zstd"`` を入れると、次の控えをその側で失敗させる。 + + ``"tar"`` は無いメンバーを足して tar を 2 で止める (zstd は 0)。``"zstd"`` は zstd の代わりに + パイプを読み切って 1 で終わる (tar は snar を進め終える。実測 R3 の形)。 + """ + + fail = None + + def _run_docker_tar(self, snap_dir, mode, command, volumes=None): + if mode == "backup" and self.fail == "tar": + command = command.replace(" ai group |", " ai group missing |") + elif mode == "backup" and self.fail == "zstd": + command = re.sub(r"zstd -1 -T0 -o \S+", "{ cat >/dev/null; exit 1; }", command) + self.fail = None + return super()._run_docker_tar(snap_dir, mode, command, volumes) + + +def _real_setup(tmp_path: Path) -> tuple: + source, target = tmp_path / "source", tmp_path / "target" + for sub in VOLUMES: + (source / sub).mkdir(parents=True) + (target / sub).mkdir(parents=True) + ai = source / "ai" + (ai / "a").write_text("a1") + (ai / "b").write_text("b1") + mgr = FailingTarManager(tmp_path, source, target) + mgr.create(name="g") + (ai / "b").write_text("b2 changed") + mgr.create(name="g") + return mgr, ai + + +def _tree(root: Path) -> dict: + return {str(p.relative_to(root)): p.read_bytes() + for p in sorted(root.rglob("*")) if p.is_file()} + + +@real_tar +@pytest.mark.parametrize("fail", ["tar", "zstd"]) +def test_next_incremental_holds_changes_from_before_the_failure(tmp_path, fail): + """受け入れ条件 1・2: 失敗の前に足した ``c`` と後に足した ``e`` の両方が次の差分に入る。""" + mgr, ai = _real_setup(tmp_path) + snap_dir = _snap_dir(tmp_path) + (ai / "c").write_text("c1") + before = _snapshot(snap_dir) + + mgr.fail = fail + with pytest.raises(SnapshotError): + mgr.create(name="g") + assert _snapshot(snap_dir) == before + + (ai / "e").write_text("e1") + mgr.create(name="g") + assert _members(snap_dir / incr_archive_name(2)) == ["ai/c", "ai/e"] + + +@real_tar +def test_restore_after_a_stop_between_renames_has_the_latest_tree(tmp_path, monkeypatch): + """受け入れ条件 5: (b) の世代へ次の差分を積んで復元すると、最後の差分の時点になる。""" + mgr, ai = _real_setup(tmp_path) + snap_dir = _snap_dir(tmp_path) + (ai / "f").write_text("f1") + (ai / "a").unlink() + with monkeypatch.context() as m: + _fail_replace_to(m, SNAR_FILE) + with pytest.raises(SnapshotError): + mgr.create(name="g") + assert _members(snap_dir / incr_archive_name(2)) == ["ai/f"] + + (ai / "h").write_text("h1") + (ai / "f").write_text("f2 rewritten") + mgr.create(name="g") + assert _members(snap_dir / incr_archive_name(3)) == ["ai/f", "ai/h"] + + mgr.restore("g") + assert _tree(tmp_path / "target") == _tree(tmp_path / "source") diff --git a/tests/snapshot/test_rebuild_generation.py b/tests/snapshot/test_rebuild_generation.py index 63f87e12..c9f3de26 100644 --- a/tests/snapshot/test_rebuild_generation.py +++ b/tests/snapshot/test_rebuild_generation.py @@ -151,7 +151,8 @@ def test_incremental_after_rebuild_starts_from_one(tmp_path): mgr.create(name="g") snap_dir = _backups(tmp_path) / "g" assert _meta(snap_dir)["files"] == [FULL_ARCHIVE, "incr-001.tar.zst"] - assert f"--listed-incremental=/backup/{SNAR_FILE} " in mgr.calls[-1]["command"] + # 差分は世代の snar を写した仮の snar へ積む (#466) + assert f"--listed-incremental=/backup/{SNAR_FILE_TMP} " in mgr.calls[-1]["command"] # ---------------------------------------------------------------------------