diff --git a/CHANGELOG.md b/CHANGELOG.md index 25d540df..c4fc58db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,15 @@ 既存の世代はそのまま一覧・復元できます。 ### Fixed +- **既にある世代の名前で `devbase snapshot create --name <名前> --full` を打つと、その世代を今のボリュームの全体で + 作り直すようにしました(#458)。** これまでは `full.tar.zst` を作り直せずに終了コード 1 で止まり、そのとき + `snapshot.snar` だけが進んでいました。作り直した世代は前のフルと差分を持たず、その世代から前の時点へは戻せません。 + 前の時点を残したいときは別の名前で作るか、先に `devbase snapshot copy` で写してください。控えに失敗したときは + 世代が打つ前のまま残ります。入れ替えの途中で止まった世代は `restore` がボリュームを消す前に止まり、同じ名前で + もう一度 `--full` を打つと作り直せます。 + **これまでに既存の世代の名前へ `--full` を打って失敗したことがあり、その後に同じ世代へ差分が積まれていれば、 + その世代の差分は失敗の前の変更を欠いていることがあります。** 世代のファイルからは見分けられないため、 + 心当たりがあれば新しい世代を作ってください(`devbase snapshot create`)。 - **プロジェクトの `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 d7f60b6a..283eb9b2 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)` | 除外の引数を `-C` とメンバーより前に置く | +| tar のコマンド | 同 `backup_command(archive, volumes, excludes, snar)` | 除外の引数を `-C` とメンバーより前に置く。`snar` は既定で `snapshot.snar`、世代の作り直しは `snapshot.snar.new` | | 作成 | 同 `_create_full`・`_create_incremental` | `_excludes_for` の一覧で控える。full は `meta.yml` に `exclude` を書き、差分は書き換えない | 世代に関わる判断(記録・比較・積み先)は `manager.py` に置き、`exclude.py` は世代の状態に依らない処理だけを @@ -58,7 +58,7 @@ classDiagram class SnapshotManager { +excludes: tuple~str~ +exclude_config_readable: bool - +backup_command(archive, volumes, excludes)$ str + +backup_command(archive, volumes, excludes, snar)$ str +auto_snapshot_target(max_incrementals) str|None -_exclude_resolution: tuple -_create_full(name, snap_dir, volumes) @@ -261,8 +261,9 @@ exclude: Claude Code は最初のセッションが始まるまで `claude plugin list` がプラグインを `cache-miss` と出し、最初の セッションでマーケットプレイスとプラグインを取り直して `installed_plugins.json` の一覧のまま読み込む。 Codex は消した `logs_2.sqlite` と `cache` を作り直す -- 既存の世代の名前へ `--full` を打つと full が変更分だけになる不具合は #458 にある。除外の一覧が違う世代への - 案内は「新しい世代を作成してください (devbase snapshot create)」とし、`--name` に既存の名前を勧めない +- 既存の世代の名前へ `--full` を打つと、その世代を作り直した時点の除外の一覧で作り直す(世代の作り直し。 + [スナップショットの系列](snapshot-series.md#世代の作り直し))。前の差分は消え、前の時点へは戻せない。除外の一覧が + 違う世代への案内は「新しい世代を作成してください (devbase snapshot create)」とし、`--name` に既存の名前を勧めない ## テスト観点 diff --git a/docs/specifications/snapshot-series.md b/docs/specifications/snapshot-series.md index 0b9eb5fa..6e8b5810 100644 --- a/docs/specifications/snapshot-series.md +++ b/docs/specifications/snapshot-series.md @@ -56,6 +56,7 @@ | 系列の最新の世代 | 系列の中で `created_at` が最も新しい世代。同じ値なら `snapshot.yml` で後ろのもの | | 全体の上限 | 系列をまたいで数えた世代の数の上限(`max_total`) | | 復元前バックアップ | `restore` が書き戻す前に自動で作るフルの世代 `pre-restore-<時刻>`。復元する世代の対象ボリュームの組を控える。実行時の `DEVBASE_ACCOUNT_GROUP` には依らない。控えに失敗したら警告を出してそのディレクトリを消し、控え無しで復元を続ける | +| 世代の作り直し | 既にある世代のディレクトリへ、前の `snapshot.snar` を引き継がずに新しい full を作り、前の差分と状態のファイルを除くこと。`--full` を既存の名前へ打ったとき・snar の無い世代への差分の指定・同じ秒に重なった復元前バックアップで起きる | ## 構成要素 @@ -65,6 +66,7 @@ | 積み先の判定 | 同 `auto_snapshot_target` | 積み先の世代の名前か、新しい世代を作るべきこと(`None`)を返し、理由をログへ出す | | 最小間隔の元 | 同 `last_snapshot_time(volumes=None)` | 渡された組の系列の世代のアーカイブの mtime の最大を返す | | 保持 | 同 `rotate(keep, max_total)` と、副作用の無い `_rotation_plan` | 削除候補を決め、場所を検証してから消す | +| 世代の作り直し | 同 `_create_full` / `_install_rebuilt_full` / `_discard_rebuild_temps` | 前のファイルを持つ世代へ full を作るとき、仮の名前へ控えてから決まった順で入れ替える(下の「世代の作り直し」) | | 世代の場所の検証 | 同 `_safe_snap_dir` | 名前・シンボリックリンク・`backups/` の外を `SnapshotError` で止める | | 自動スナップショット | `lib/devbase/commands/container.py` の `_auto_snapshot` / `_should_skip_by_interval` | 最小間隔を系列で判定し、判定の結果に従って `create` と `rotate()` を呼ぶ | | 自動ローテーション | 同 `cmd_down` | `mgr.rotate()` を既定の引数で呼ぶ | @@ -249,6 +251,45 @@ classDiagram (`name` が無い・上の 3 つ・場所にディレクトリ以外がある)で不正なエントリを判定し、中を読まずに警告して 一覧から外す。`snapshot.yml` は書き換えない +### 世代の作り直し + +`_create_full` は、世代に前のファイル(`full.tar.zst`・`snapshot.snar`・`incr-*.tar.zst` のどれか)があれば +世代の作り直しとして控える。無ければ本来の名前(`full.tar.zst`・`snapshot.snar`)へ直接控え、最後に +`meta.yml` を書く(新しい世代の控えのコマンドと `meta.yml` はこの規則の前と同じ)。 + +作り直しの手順: + +1. 前の中断の残りの仮の名前のファイル(`full.tar.zst.new`・`snapshot.snar.new`・`meta.yml.new`)を消す +2. 前の `snapshot.snar` を tar に渡さず、空から始まる `snapshot.snar.new` と `full.tar.zst.new` へ控える + (tar はレベル 0 でボリュームの全体を控える)。除外の一覧は新しい世代と同じ規則(`_excludes_for`)で決める +3. 控えが成功したら `_install_rebuilt_full` が次の順に 1 つずつ入れ替える。改名は同じディレクトリの中の + `os.replace` で、ファイルを写さない + 1. `snapshot.snar.bak` と `snapshot.snar` を消す + 2. `incr-*.tar.zst` を番号の大きい順に消す + 3. `meta.yml` を新しい控えの内容に `rebuilding: true` を足した形へ置き換える + 4. `full.tar.zst.new` を `full.tar.zst` へ改名する + 5. `meta.yml` を新しい控えの内容(印無し)へ置き換える + 6. `snapshot.snar.new` を `snapshot.snar` へ改名する +4. INFO を 1 行出す。`snapshot.yml` のエントリは `create` / `_backup_before_restore` が今の規則で更新する + (`incremental_count` と `updated_at`。`created_at` は変えない) + +`meta.yml` の置き換えは `meta.yml.new` へ書いてから改名し、途中まで書いた `meta.yml` を残さない。 +作り直した `meta.yml` は `type: full`・`files: [full.tar.zst]`・`incremental_count: 0`・作り直した時点の +`volumes`・`exclude`・`created_at` を持つ。 + +常に成り立つ条件: + +- 控えのコマンドが失敗したら、仮の名前のファイルを消して `SnapshotError` を投げ直す。世代の既存のファイルは + 1 バイトも変わらず、ファイルは増えない +- 入れ替え(手順 3)の途中で止まった世代は `snapshot.snar` を持たない。次の `create`(名前の指定・自動 + スナップショット)は snar の無い世代の経路で作り直す +- 入れ替えの途中で止まった世代は、次のどちらかである + - `meta.yml` に印が無く、`full.tar.zst` と `meta.yml` が同じ 1 回の控えのもので、前の full から番号の + 小さい順に続く差分とともにある時点へ戻せる + - `meta.yml` が `rebuilding: true` を持つ。`restore` はこの世代を、復元前バックアップとボリュームの消去の前に + `SnapshotError` で止める +- 作り直した後は、その世代から前の時点へ戻す手段は無い + ## データ・設定 **形は変えていない。** 移行は無く、変更前の devbase も同じファイルを読める。各世代の `meta.yml` に @@ -261,6 +302,7 @@ classDiagram | 同 `snapshots[].incremental_count` | 差分の上限の判定 | | 同 `max_generations` | `rotate` が消したときに `keep` を書く。読む側は無い | | 各世代の `meta.yml` の `volumes` | 系列の判定には使わない。積む直前の検証(`auto_snapshot_target` の順 3 と `_create_incremental`)でだけ読む | +| 各世代の `meta.yml` の `rebuilding` | 世代の作り直しの入れ替えの途中だけ `true`。持つ世代は `restore` が止める。作り直しを終えた世代と新しい世代は持たない | | 各世代の `meta.yml` の `exclude` | full を作ったときの除外の一覧(並べ替え済み)。差分では書き換えない。積む直前の検証(順 4a と `_create_incremental`)でだけ読む | | アーカイブの mtime | 最小間隔の判定 | @@ -282,6 +324,7 @@ classDiagram | 全体の上限で消した | INFO | `ローテーション: 全体の上限 {max_total} 世代を超えたため、{系列} の {名前} を削除しました` | | 全体の上限を満たせない | WARNING | `全体の上限 {max_total} 世代を超えていますが、各グループの最新の世代は消さないため {残り} 世代を残します` | | 場所が不正なエントリ | WARNING | `snapshot.yml の世代 '{名前}' は場所が不正なため、ディレクトリを消さずに一覧からだけ外します: {理由}` | +| 世代を作り直した | INFO | `世代 {名前} を今のボリュームの全体で作り直しました(前の差分 {本数} 本を除きました)` | | `snapshot rotate` で消す候補が無い | INFO | `ローテーション不要です`(`_snapshot_rotate`。消した数も外した数も 0 のときだけ) | | `snapshot rotate` で一覧から外しただけ | INFO | `ローテーション: 場所が不正な {数} 世代を一覧から外しました(削除した世代はありません)`(`_snapshot_rotate`) | | `snapshot rotate` で世代を消し、一覧からも外した | INFO | `ローテーション: 場所が不正な {数} 世代を一覧から外しました`(`_snapshot_rotate`。`rotate` の削除の INFO の後) | @@ -299,6 +342,10 @@ classDiagram | `rotate` の `keep` / `max_total` が 1 未満 | `SnapshotError`。何も消さず `snapshot.yml` も書かない | | `rotate` の候補が `_safe_snap_dir` を通らない | ディレクトリを消さず、一覧から外して警告。他の候補の削除は続ける | | `create` / `restore` / `copy` / `delete` の世代が `_safe_snap_dir` を通らない | `SnapshotError` → CLI は終了コード 1 | +| 世代の作り直しの控えのコマンドが失敗 | 仮の名前のファイルを消して `SnapshotError`(`Dockerでのtar操作に失敗しました: {stderr}`)→ CLI は終了コード 1。世代は打つ前のまま | +| 世代の作り直しの入れ替えが失敗 | 仮の名前のファイルを消して `SnapshotError`(`世代 '{名前}' の作り直しを終えられませんでした: {理由}。もう一度 devbase snapshot create --name {名前} --full を打ってください`)→ CLI は終了コード 1 | +| `meta.yml` に `rebuilding: true` を持つ世代の `restore` | 復元前バックアップを作らず、ボリュームを消さずに `SnapshotError`(`世代 '{名前}' は作り直しの途中で止まっているため復元できません。devbase snapshot create --name {名前} --full を打って作り直してください`)→ CLI は終了コード 1 | +| 仮の名前のファイルを消せない | 警告 1 行で続ける。次の作り直しが控えの前に消す | ## 運用 @@ -365,6 +412,20 @@ CLI と TUI: - TUI の問いの文言と、`max_total` を渡さないこと(`tests/cli/tui/test_actions_snapshot.py`) - `cmd_down` の `rotate()` の失敗で終了コード 0(`tests/commands/test_container_down_snapshot.py`) +世代の作り直し(`tests/snapshot/test_rebuild_generation.py`、`tests/snapshot/test_backup_real_tar.py`): + +- 差分を持つ世代へ `--full` を打つと、`full.tar.zst`・`snapshot.snar`・`meta.yml` だけが残り、`meta.yml` が + full の形で今の除外の一覧を持ち、`snapshot.yml` のエントリが 1 件のまま `created_at` を変えないこと。 + 控えのコマンドが前の snar を受け取らないこと +- 控えのコマンドの失敗で世代のファイルのバイト列と `snapshot.yml` が変わらないこと。入れ替えの各段で + 止めると `SnapshotError` になり、snar と仮の名前のファイルが残らず、次の差分の指定が作り直すこと +- 別のボリュームの組で作り直して止めた世代は、印があれば `restore` が docker を 1 回も呼ばずに止まり、 + 印が無ければ `full.tar.zst` と `meta.yml` の組が合うこと。差分が番号の大きい順に消えること +- snar の無い世代への差分の指定と、同じ秒の復元前バックアップが作り直しになること。新しい世代の控えの + コマンドがこの規則の前と同じであること +- 実の GNU tar と zstd で、作り直した full がボリュームの全体を持ち、復元で全ファイルが作り直した時点の + 中身で戻り、その後の差分が後の変更だけを持つこと(GNU tar と zstd が無ければ飛ぶ) + 復元のテスト(`tests/snapshot/test_restore_incremental.py`、`test_manager_volumes.py` の復元)は 変えずに通ること。ただし `test_restore_incremental.py` の実の Docker で入れ替える場所は、既定の除外 (#368)に入らない `data/cache` に置く。 diff --git a/docs/user/cli-reference/05-snapshot.md b/docs/user/cli-reference/05-snapshot.md index 47c125e4..ead4a1cf 100644 --- a/docs/user/cli-reference/05-snapshot.md +++ b/docs/user/cli-reference/05-snapshot.md @@ -25,6 +25,12 @@ devbase snapshot create [--name NAME] [--full] [--group NAME] 既にある世代の名前を `--full` なしで指定すると、その世代へ差分を積みます。その世代が今と違う除外の一覧で 作られている(除外の一覧を記録していない世代を含む)ときは、差分を作らずに終了コード 1 で止まります。 +既にある世代の名前を `--full` 付きで指定すると、その世代を今のボリュームの全体で作り直します。前の +フルと差分は消え、その世代から前の時点へは戻せません。前の時点を残したいときは、別の名前で作るか、 +先に `devbase snapshot copy` で写してください。作り直しに失敗したときは終了コード 1 で止まり、控えの +コマンドが失敗した場合は世代が打つ前のまま残ります。入れ替えの途中で止まった世代は、もう一度同じ名前で +`--full` を打つと作り直せます(それまで復元は止まることがあります)。 + 控えには、既定の除外(作り直せる一時ファイル・ログ・取り直せる取得物)と `backups/exclude.yml` に 書いた除外が入りません。一覧と書き方は [スナップショットガイドの「控えから外すもの」](../snapshot-guide.md#控えから外すもの) にあります。 diff --git a/docs/user/snapshot-guide.md b/docs/user/snapshot-guide.md index 7c195c7e..0089d74b 100644 --- a/docs/user/snapshot-guide.md +++ b/docs/user/snapshot-guide.md @@ -279,6 +279,16 @@ devbase snapshot create --full devbase snapshot create --name before-migration --full ``` +既にある世代の名前を `--full` 付きで指定すると、その世代を今のボリュームの全体で作り直します。 +前のフルと差分は消え、その世代から前の時点へは戻せません。前の時点を残したいときは、別の名前で作るか、 +先に `devbase snapshot copy <名前> <新しい名前>` で写してください。 + +- 控えのコマンドが失敗したときは終了コード 1 で止まり、世代は打つ前のまま残ります +- 控えの後の入れ替えで止まったときも終了コード 1 で止まり、同じ名前でもう一度 `--full` を打つよう案内します。 + 入れ替えの途中で止まった世代を `restore` すると、ボリュームを消す前に「作り直しの途中で止まっているため + 復元できません」と出て止まります +- 作り直したときは「世代 <名前> を今のボリュームの全体で作り直しました(前の差分 N 本を除きました)」と出ます + ### スナップショットの一覧 ```bash diff --git a/lib/devbase/snapshot/manager.py b/lib/devbase/snapshot/manager.py index b293bc32..bec925d9 100644 --- a/lib/devbase/snapshot/manager.py +++ b/lib/devbase/snapshot/manager.py @@ -1,5 +1,6 @@ """スナップショット管理のコアロジック""" +import os import re import shlex import shutil @@ -47,6 +48,17 @@ INCR_ARCHIVE_GLOB = 'incr-*.tar.zst' _INCR_ARCHIVE_RE = re.compile(r'^incr-(\d+)\.tar\.zst$') +# 世代の作り直し (既にある世代へ full を作る) で使う仮の名前 (#458)。新しい控えをここへ +# 書いてから改名で入れ替え、控えが失敗しても前のファイルに触らない。``is_archive_file`` と +# ``INCR_ARCHIVE_GLOB`` に当たらない名前にする。 +FULL_ARCHIVE_TMP = f'{FULL_ARCHIVE}.new' +SNAR_FILE_TMP = f'{SNAR_FILE}.new' +SNAP_META_FILE = 'meta.yml' +META_FILE_TMP = f'{SNAP_META_FILE}.new' +# 作り直しの途中で full と meta.yml の組が合わない間だけ meta.yml に立てる印。 +# 印のある世代は復元しない。 +REBUILDING_KEY = 'rebuilding' + # コンテナ内のマウント先。作成時は対象ボリュームを /source の下へ読み取り専用で、 # 復元時は /target の下へ書き込み可で並べる。世代ディレクトリは /backup。 BACKUP_ROOT = '/source' @@ -493,6 +505,12 @@ def restore(self, name: str, point: int | None = None) -> None: Noneなら全差分を適用。 """ snap_dir = self._restore_source(name, point) + if self._load_snap_meta(snap_dir).get(REBUILDING_KEY): + # full と meta.yml の組が合わない。復元前バックアップも消去も行わない (#458) + raise SnapshotError( + f"世代 '{name}' は作り直しの途中で止まっているため復元できません。" + f"devbase snapshot create --name {name} --full を打って作り直してください" + ) # 控える組を決めるため、組は控えより先に読む。検証を通らない世代では # 控えも復元も行わない。 @@ -980,11 +998,12 @@ def _ensure_snapshot_image(self) -> str: return ensure_snapshot_image(self.devbase_root) @staticmethod - def backup_command(archive: str, volumes: dict, excludes=()) -> str: + def backup_command(archive: str, volumes: dict, excludes=(), snar: str = SNAR_FILE) -> str: """対象を 1 本のアーカイブへ書き出すコマンドを組み立てる。 - full も差分も同じ形で、``snapshot.snar`` に前回の状態を積み上げる。 - 差分は ``archive`` に ``incr-NNN.tar.zst`` を渡す。 + full も差分も同じ形で、``snar`` (既定 ``snapshot.snar``) に前回の状態を積み上げる。 + 差分は ``archive`` に ``incr-NNN.tar.zst`` を渡す。世代の作り直しは、空から始まる + 仮の名前の ``snar`` を渡して全体 (レベル 0) を控える (#458)。 起点は各ボリュームのマウント先 (``ai`` / ``group``) で、``/source`` そのものではない。 ``/source`` はコンテナを起動するたびに作られ inode が変わるため、GNU tar が毎回 @@ -998,7 +1017,7 @@ def backup_command(archive: str, volumes: dict, excludes=()) -> str: exclude_args = tar_exclude_args(excludes, member_list) exclude_part = f"{exclude_args} " if exclude_args else '' return ( - f"tar --listed-incremental={ARCHIVE_MOUNT}/{SNAR_FILE} {exclude_part}" + f"tar --listed-incremental={ARCHIVE_MOUNT}/{snar} {exclude_part}" f"-cf - -C {BACKUP_ROOT} {members} | zstd -1 -T0 -o {ARCHIVE_MOUNT}/{archive}" ) @@ -1096,6 +1115,9 @@ def _create_full(self, name: str, snap_dir: Path, volumes: Optional[dict] = None) -> None: """フルバックアップを作成する。 + 世代に前のファイル (full・snar・差分のどれか) があれば、世代の作り直しとして + 仮の名前へ控えてから入れ替える (#458)。無ければ本来の名前へ直接控える。 + Args: volumes: 控える組。省けば作成時の対象 (``self.volumes``) """ @@ -1103,11 +1125,41 @@ def _create_full(self, name: str, snap_dir: Path, volumes = self.volumes excludes = self._excludes_for(snap_dir, volumes) logger.info("フルバックアップを作成中: %s", name) - self._run_docker_tar( - snap_dir, 'backup', self.backup_command(FULL_ARCHIVE, volumes, excludes), volumes, - ) + if not self._has_previous_files(snap_dir): + self._run_docker_tar( + snap_dir, 'backup', self.backup_command(FULL_ARCHIVE, volumes, excludes), volumes, + ) + self._save_snap_meta(snap_dir, self._full_meta(name, volumes, excludes)) + return + + # 前の中断の残りを消してから、前の snar を引き継がずに仮の名前で控える。 + # 控えが失敗しても前のファイルは 1 バイトも変わらない (決定 1) + self._discard_rebuild_temps(snap_dir) + try: + self._run_docker_tar( + snap_dir, 'backup', + self.backup_command(FULL_ARCHIVE_TMP, volumes, excludes, snar=SNAR_FILE_TMP), + volumes, + ) + except SnapshotError: + self._discard_rebuild_temps(snap_dir) + raise - # meta.yml を作成 + try: + removed = self._install_rebuilt_full( + snap_dir, self._full_meta(name, volumes, excludes)) + except (OSError, SnapshotError) as e: + self._discard_rebuild_temps(snap_dir) + raise SnapshotError( + f"世代 '{name}' の作り直しを終えられませんでした: {e}。" + f"もう一度 devbase snapshot create --name {name} --full を打ってください" + ) from e + logger.info("世代 %s を今のボリュームの全体で作り直しました(前の差分 %d 本を除きました)", + name, removed) + + @staticmethod + def _full_meta(name: str, volumes: dict, excludes) -> dict: + """full を作った直後の ``meta.yml`` の内容を組む。""" meta = { 'name': name, 'created_at': datetime.now().isoformat(), @@ -1121,7 +1173,53 @@ def _create_full(self, name: str, snap_dir: Path, if '' not in volumes: # 旧レイアウト (ルートへ直接マウント) の起点は ``.`` のままなので書かない meta['archive_root'] = ARCHIVE_ROOT_MEMBERS - self._save_snap_meta(snap_dir, meta) + return meta + + @staticmethod + def _has_previous_files(snap_dir: Path) -> bool: + """世代が前の控え (full・snar・差分のどれか) を持つか。持てば作り直しになる。""" + return ((snap_dir / FULL_ARCHIVE).exists() or (snap_dir / SNAR_FILE).exists() + or any(snap_dir.glob(INCR_ARCHIVE_GLOB))) + + def _install_rebuilt_full(self, snap_dir: Path, meta: dict) -> int: + """仮の名前で控えた full を世代へ入れ替え、除いた差分の本数を返す (#458・決定 2)。 + + 状態ファイル → 差分 (番号の大きい順) → 印付きの meta.yml → full → 印無しの + meta.yml → 状態ファイルの順に 1 つずつ行う。どこで止まっても世代は snar を持たず、 + 「印が無く full と meta.yml が同じ控えの組」か「印があり復元しない組」のどちらかになる。 + 途中の失敗は ``OSError`` のまま外へ出す。 + """ + for stale in (f'{SNAR_FILE}.bak', SNAR_FILE): + (snap_dir / stale).unlink(missing_ok=True) + + incrs = sorted(snap_dir.glob(INCR_ARCHIVE_GLOB), + key=lambda p: (incr_archive_number(p.name) or 0, p.name), + reverse=True) + for incr in incrs: + incr.unlink() + + self._replace_snap_meta(snap_dir, {**meta, REBUILDING_KEY: True}) + os.replace(snap_dir / FULL_ARCHIVE_TMP, snap_dir / FULL_ARCHIVE) + self._replace_snap_meta(snap_dir, meta) + os.replace(snap_dir / SNAR_FILE_TMP, snap_dir / SNAR_FILE) + return len(incrs) + + def _replace_snap_meta(self, snap_dir: Path, meta: dict) -> None: + """``meta.yml`` を仮の名前へ書いてから改名で置き換える (途中まで書いた形を残さない)。""" + tmp = snap_dir / META_FILE_TMP + with open(tmp, 'w') as f: + yaml.dump(meta, f, default_flow_style=False, allow_unicode=True) + os.replace(tmp, snap_dir / SNAP_META_FILE) + + @staticmethod + def _discard_rebuild_temps(snap_dir: Path) -> None: + """作り直しの仮の名前のファイルを消す。消せなければ警告して続ける。""" + for tmp in (FULL_ARCHIVE_TMP, SNAR_FILE_TMP, META_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: """差分バックアップを作成""" diff --git a/tests/snapshot/fake_tar.py b/tests/snapshot/fake_tar.py new file mode 100644 index 00000000..cfe4f052 --- /dev/null +++ b/tests/snapshot/fake_tar.py @@ -0,0 +1,20 @@ +"""Docker を差し替えたテストで、控えのコマンドが書くはずのファイルを置く。 + +控えのコマンドが書くのは ``-o /backup/<アーカイブ>`` と ``--listed-incremental=/backup/<状態>`` の +2 本である。世代の作り直しは仮の名前 (``full.tar.zst.new``・``snapshot.snar.new``) へ書く (#458) +ため、名前はコマンドから拾う。 +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_ARCHIVE_RE = re.compile(r"-o /backup/(\S+)") +_SNAR_RE = re.compile(r"--listed-incremental=/backup/(\S+)") + + +def write_backup_outputs(snap_dir: Path, command: str) -> None: + """``command`` が書くアーカイブと状態ファイルを ``snap_dir`` へ置く。""" + (snap_dir / _ARCHIVE_RE.search(command).group(1)).write_text("archive") + (snap_dir / _SNAR_RE.search(command).group(1)).write_text("snar") diff --git a/tests/snapshot/test_archive_layout.py b/tests/snapshot/test_archive_layout.py index 69e9287d..064f9197 100644 --- a/tests/snapshot/test_archive_layout.py +++ b/tests/snapshot/test_archive_layout.py @@ -26,6 +26,8 @@ is_archive_file, ) +from .fake_tar import write_backup_outputs + @pytest.fixture(autouse=True) def _group_env(monkeypatch): @@ -42,9 +44,7 @@ def __init__(self, *args, **kwargs): def _run_docker_tar(self, snap_dir, mode, command, volumes=None): self.calls.append({"mode": mode, "command": command}) if mode == "backup": - archive = re.search(r"-o /backup/(\S+)", command).group(1) - (snap_dir / archive).write_text("archive") - (snap_dir / SNAR_FILE).write_text("snar") + write_backup_outputs(snap_dir, command) # --------------------------------------------------------------------------- diff --git a/tests/snapshot/test_auto_snapshot_series.py b/tests/snapshot/test_auto_snapshot_series.py index effa9024..c1e7d47f 100644 --- a/tests/snapshot/test_auto_snapshot_series.py +++ b/tests/snapshot/test_auto_snapshot_series.py @@ -8,7 +8,6 @@ import logging import os -import re import time import pytest @@ -17,6 +16,7 @@ from devbase.commands import container from devbase.snapshot.manager import SnapshotManager +from .fake_tar import write_backup_outputs from .test_manager_series import names, write_state @@ -27,9 +27,7 @@ def root(tmp_path, monkeypatch): monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "acme") # up が宣言から置いた後 (#315) def fake(self, snap_dir, mode, command, volumes=None): if mode == "backup": - archive = re.search(r"/backup/(full\.tar\.zst|incr-\d+\.tar\.zst)", command) - (snap_dir / archive.group(1)).write_text("archive") - (snap_dir / "snapshot.snar").write_text("snar") + write_backup_outputs(snap_dir, command) monkeypatch.setattr(SnapshotManager, "_run_docker_tar", fake) return tmp_path diff --git a/tests/snapshot/test_backup_real_tar.py b/tests/snapshot/test_backup_real_tar.py index 047f93d4..e0befea0 100644 --- a/tests/snapshot/test_backup_real_tar.py +++ b/tests/snapshot/test_backup_real_tar.py @@ -7,6 +7,7 @@ from __future__ import annotations +import re import shutil import subprocess from pathlib import Path @@ -199,3 +200,75 @@ def test_missing_excluded_paths_do_not_fail_or_warn(tmp_path): result = subprocess.run(["bash", "-c", command], capture_output=True, text=True) assert result.returncode == 0 assert "tar:" not in result.stderr + + +# --------------------------------------------------------------------------- +# 世代の作り直し (#458) +# --------------------------------------------------------------------------- + +_MOUNT_RE = re.compile(r"/(backup|source|target)(?=[/ ;]|$)") + + +class LocalTarManager(SnapshotManager): + """控え・復元のコマンドを Docker でなく手元の bash で走らせる。 + + マウント先 (``/backup``・``/source``・``/target``) は 1 回の置き換えで手元のディレクトリへ写す。 + """ + + def __init__(self, root: Path, source: Path, target: Path): + super().__init__(root, group="acme") + self.source = source + self.target = target + + def _run_docker_tar(self, snap_dir, mode, command, volumes=None): + paths = {"backup": str(snap_dir), "source": str(self.source), + "target": str(self.target)} + command = _MOUNT_RE.sub(lambda m: paths[m.group(1)], command) + return subprocess.run(["bash", "-c", command], check=True, + capture_output=True, text=True) + + +def _rebuild_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 = LocalTarManager(tmp_path, source, target) + mgr.create(name="g") + (ai / "a").write_text("a2 changed") + mgr.create(name="g") + assert _members(tmp_path / "backups" / "g" / incr_archive_name(1)) == ["ai/a"] + (ai / "c").write_text("c1") + (ai / "b").write_text("b3 rebuilt") + mgr.create(name="g", full=True) + return mgr, ai + + +def test_rebuilt_full_holds_the_whole_volume(tmp_path): + """受け入れ条件 1""" + _rebuild_setup(tmp_path) + snap_dir = tmp_path / "backups" / "g" + assert {"ai/a", "ai/b", "ai/c"} <= set(_members(snap_dir / FULL_ARCHIVE)) + assert not list(snap_dir.glob("incr-*")) + + +def test_restore_after_rebuild_brings_back_every_file(tmp_path): + """受け入れ条件 2""" + mgr, _ = _rebuild_setup(tmp_path) + mgr.restore("g") + restored = tmp_path / "target" / "ai" + assert (restored / "a").read_text() == "a2 changed" + assert (restored / "b").read_text() == "b3 rebuilt" + assert (restored / "c").read_text() == "c1" + + +def test_incremental_after_rebuild_holds_only_later_changes(tmp_path): + """受け入れ条件 7""" + mgr, ai = _rebuild_setup(tmp_path) + (ai / "d").write_text("d1") + mgr.create(name="g") + snap_dir = tmp_path / "backups" / "g" + assert _members(snap_dir / incr_archive_name(1)) == ["ai/d"] diff --git a/tests/snapshot/test_exclude_generations.py b/tests/snapshot/test_exclude_generations.py index 515ad44d..7c9fb673 100644 --- a/tests/snapshot/test_exclude_generations.py +++ b/tests/snapshot/test_exclude_generations.py @@ -8,7 +8,6 @@ from __future__ import annotations import logging -import re import shlex import types @@ -20,6 +19,7 @@ from devbase.snapshot.exclude import DEFAULT_EXCLUDES, USER_CONFIG from devbase.snapshot.manager import SNAR_FILE, SnapshotManager +from .fake_tar import write_backup_outputs from .test_manager_series import vols, write_state DEFAULTS = sorted(DEFAULT_EXCLUDES) @@ -41,9 +41,7 @@ def __init__(self, *args, **kwargs): def _run_docker_tar(self, snap_dir, mode, command, volumes=None): self.calls.append({"mode": mode, "command": command}) if mode == "backup": - archive = re.search(r"-o /backup/(\S+)", command).group(1) - (snap_dir / archive).write_text("archive") - (snap_dir / SNAR_FILE).write_text("snar") + write_backup_outputs(snap_dir, command) def backup_excludes(self) -> list: """記録した控えのコマンドごとに、``--exclude=`` の値の並びを返す。""" diff --git a/tests/snapshot/test_manager_series.py b/tests/snapshot/test_manager_series.py index 6fda54b3..7dc83345 100644 --- a/tests/snapshot/test_manager_series.py +++ b/tests/snapshot/test_manager_series.py @@ -9,7 +9,6 @@ import logging import os -import re import time import types from datetime import datetime @@ -23,6 +22,8 @@ from devbase.snapshot.exclude import DEFAULT_EXCLUDES from devbase.snapshot.manager import RotateResult, SnapshotManager +from .fake_tar import write_backup_outputs + @pytest.fixture(autouse=True) def _clean_group_env(monkeypatch): @@ -39,8 +40,7 @@ def __init__(self, *args, **kwargs): def _run_docker_tar(self, snap_dir, mode, command, volumes=None): self.calls.append({"mode": mode, "command": command}) if mode == "backup": - (snap_dir / "full.tar.zst").write_text("archive") - (snap_dir / "snapshot.snar").write_text("snar") + write_backup_outputs(snap_dir, command) def vols(group: str) -> dict: @@ -217,9 +217,7 @@ class ArchiveRecordingManager(RecordingManager): def _run_docker_tar(self, snap_dir, mode, command, volumes=None): self.calls.append({"mode": mode, "command": command}) if mode == "backup": - archive = re.search(r"/backup/(full\.tar\.zst|incr-\d+\.tar\.zst)", command) - (snap_dir / archive.group(1)).write_text("archive") - (snap_dir / "snapshot.snar").write_text("snar") + write_backup_outputs(snap_dir, command) def _entry(root: Path, name: str) -> dict: diff --git a/tests/snapshot/test_manager_volumes.py b/tests/snapshot/test_manager_volumes.py index 2da2cf62..a3ab2a3e 100644 --- a/tests/snapshot/test_manager_volumes.py +++ b/tests/snapshot/test_manager_volumes.py @@ -15,6 +15,8 @@ from devbase.errors import SnapshotError from devbase.snapshot.manager import SnapshotManager +from .fake_tar import write_backup_outputs + @pytest.fixture(autouse=True) def _clean_group_env(monkeypatch): @@ -42,8 +44,7 @@ def _run_docker_tar(self, snap_dir, mode, command, volumes=None): }) # フルバックアップの実体が無いと restore が止まるので、印だけ作る if mode == "backup": - (snap_dir / "full.tar.zst").write_text("archive") - (snap_dir / "snapshot.snar").write_text("snar") + write_backup_outputs(snap_dir, command) # --------------------------------------------------------------------------- diff --git a/tests/snapshot/test_pre_restore_backup.py b/tests/snapshot/test_pre_restore_backup.py index 64358931..b64aa26a 100644 --- a/tests/snapshot/test_pre_restore_backup.py +++ b/tests/snapshot/test_pre_restore_backup.py @@ -15,6 +15,8 @@ from devbase.errors import SnapshotCommandError, SnapshotError from devbase.snapshot.manager import SnapshotManager +from .fake_tar import write_backup_outputs + INITECH_VOLUMES = {"ai": "devbase_home_ubuntu", "group": "devbase_home_initech"} REAL_ERROR_STDERR = "tar: ./ai/x: Cannot write: No space left on device\n" @@ -46,8 +48,7 @@ def _run_docker_tar(self, snap_dir, mode, command, volumes=None): raise SnapshotCommandError( f"Dockerでのtar操作に失敗しました: {REAL_ERROR_STDERR}", stderr=REAL_ERROR_STDERR) - (snap_dir / "full.tar.zst").write_text("archive") - (snap_dir / "snapshot.snar").write_text("snar") + write_backup_outputs(snap_dir, command) return None if self._fail_restore: raise SnapshotCommandError( diff --git a/tests/snapshot/test_rebuild_generation.py b/tests/snapshot/test_rebuild_generation.py new file mode 100644 index 00000000..59aef9a3 --- /dev/null +++ b/tests/snapshot/test_rebuild_generation.py @@ -0,0 +1,359 @@ +"""世代の作り直し (#458) + +既にある世代へ full を作るとき (``create --name <既存> --full``・snar の無い世代への差分の指定・ +同じ秒の復元前バックアップ)、前の snar を引き継がずに仮の名前で控えてから入れ替える。 +``DEVBASE_ROOT`` を使わず ``tmp_path`` に閉じ、``_run_docker_tar`` を差し替えて Docker を起動しない。 +""" + +from __future__ import annotations + +import logging +import os +import types +from datetime import datetime +from pathlib import Path + +import pytest +import yaml + +from devbase.commands.snapshot import cmd_snapshot +from devbase.errors import SnapshotCommandError, SnapshotError +from devbase.snapshot import manager as manager_mod +from devbase.snapshot.exclude import DEFAULT_EXCLUDES +from devbase.snapshot.manager import ( + FULL_ARCHIVE, + FULL_ARCHIVE_TMP, + META_FILE_TMP, + REBUILDING_KEY, + SNAR_FILE, + SNAR_FILE_TMP, + SnapshotManager, +) + +from .fake_tar import write_backup_outputs + +DEFAULTS = sorted(DEFAULT_EXCLUDES) +REBUILT = "rebuilt" + + +@pytest.fixture(autouse=True) +def _group_env(monkeypatch): + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "acme") + + +class FakeManager(SnapshotManager): + """``docker run`` を実行せず、コマンドを記録して書くはずのファイルを置く。 + + ``fail_backup`` が真なら控えのコマンドを失敗させる (仮の名前のファイルを書いた後に)。 + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.calls: list = [] + self.fail_backup = False + self.content = "archive" + + def _run_docker_tar(self, snap_dir, mode, command, volumes=None): + self.calls.append({"mode": mode, "command": command, "snap_dir": snap_dir}) + if mode != "backup": + return None + write_backup_outputs(snap_dir, command) + if self.fail_backup: + raise SnapshotCommandError("Dockerでのtar操作に失敗しました: boom", stderr="boom") + if FULL_ARCHIVE_TMP in command: + (snap_dir / FULL_ARCHIVE_TMP).write_text(REBUILT) + return None + + +def _backups(root: Path) -> Path: + return root / "backups" + + +def _listing(snap_dir: Path) -> set: + return {p.name for p in snap_dir.iterdir()} + + +def _snapshot(snap_dir: Path) -> dict: + return {p.name: p.read_bytes() for p in snap_dir.iterdir()} + + +def _meta(snap_dir: Path) -> dict: + return yaml.safe_load((snap_dir / "meta.yml").read_text()) + + +def _entries(root: Path) -> list: + return yaml.safe_load((_backups(root) / "snapshot.yml").read_text())["snapshots"] + + +def _with_incrementals(root: Path, count: int = 1) -> FakeManager: + """世代 ``g`` を full と ``count`` 本の差分で作る (``snapshot.snar.bak`` も持つ)。""" + mgr = FakeManager(root) + mgr.create(name="g") + for _ in range(count): + snap_dir = _backups(root) / "g" + (snap_dir / f"{SNAR_FILE}.bak").write_text("bak") + mgr.create(name="g") + mgr.calls.clear() + return mgr + + +# --------------------------------------------------------------------------- +# 作り直しの成功 (受け入れ条件 3・4・I1・INFO) +# --------------------------------------------------------------------------- + +def test_rebuild_leaves_only_the_new_full(tmp_path, caplog): + mgr = _with_incrementals(tmp_path, 2) + snap_dir = _backups(tmp_path) / "g" + created_at = _entries(tmp_path)[0]["created_at"] + + with caplog.at_level(logging.INFO, logger="devbase"): + mgr.create(name="g", full=True) + + assert _listing(snap_dir) == {FULL_ARCHIVE, SNAR_FILE, "meta.yml"} + assert (snap_dir / FULL_ARCHIVE).read_text() == REBUILT + meta = _meta(snap_dir) + assert meta["type"] == "full" + assert meta["files"] == [FULL_ARCHIVE] + assert meta["incremental_count"] == 0 + assert meta["exclude"] == DEFAULTS + assert REBUILDING_KEY not in meta + + entries = _entries(tmp_path) + assert [e["name"] for e in entries] == ["g"] + assert entries[0]["incremental_count"] == 0 + assert entries[0]["created_at"] == created_at + + # 前の snar を tar に渡さず、仮の名前へ控える + [call] = mgr.calls + assert f"--listed-incremental=/backup/{SNAR_FILE_TMP} " in call["command"] + assert call["command"].endswith(f"-o /backup/{FULL_ARCHIVE_TMP}") + + rebuilt = [r.getMessage() for r in caplog.records + if r.levelno == logging.INFO and "作り直しました" in r.getMessage()] + assert len(rebuilt) == 1 + assert "g" in rebuilt[0] and "2 本" in rebuilt[0] + + +def test_rebuild_uses_the_current_excludes(tmp_path): + mgr = _with_incrementals(tmp_path) + (_backups(tmp_path) / "exclude.yml").write_text("exclude:\n - .cache/pip\n") + mgr = FakeManager(tmp_path) + mgr.create(name="g", full=True) + assert _meta(_backups(tmp_path) / "g")["exclude"] == sorted(set(DEFAULTS) | {".cache/pip"}) + + +def test_incremental_after_rebuild_starts_from_one(tmp_path): + """受け入れ条件 7 (ファイルの側。中身は実の tar のテスト)""" + mgr = _with_incrementals(tmp_path) + mgr.create(name="g", full=True) + 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"] + + +# --------------------------------------------------------------------------- +# 新しい世代は今と同じ (受け入れ条件 9・I5) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("full", [False, True]) +def test_new_generation_is_written_directly(tmp_path, caplog, full): + mgr = FakeManager(tmp_path) + with caplog.at_level(logging.INFO, logger="devbase"): + mgr.create(name="g", full=full) + [call] = mgr.calls + assert call["command"] == SnapshotManager.backup_command( + FULL_ARCHIVE, mgr.volumes, mgr.excludes) + assert _listing(_backups(tmp_path) / "g") == {FULL_ARCHIVE, SNAR_FILE, "meta.yml"} + assert "作り直しました" not in caplog.text + + +# --------------------------------------------------------------------------- +# snar の無い世代への差分の指定 (受け入れ条件 8) +# --------------------------------------------------------------------------- + +def test_incremental_without_snar_rebuilds_and_drops_incrementals(tmp_path): + mgr = _with_incrementals(tmp_path, 2) + snap_dir = _backups(tmp_path) / "g" + (snap_dir / SNAR_FILE).unlink() + mgr.create(name="g") + assert _listing(snap_dir) == {FULL_ARCHIVE, SNAR_FILE, "meta.yml"} + assert _meta(snap_dir)["files"] == [FULL_ARCHIVE] + + +# --------------------------------------------------------------------------- +# 控えの失敗 (受け入れ条件 5・I2) +# --------------------------------------------------------------------------- + +def test_failed_backup_keeps_the_generation_byte_for_byte(tmp_path): + mgr = _with_incrementals(tmp_path) + snap_dir = _backups(tmp_path) / "g" + (snap_dir / f"{SNAR_FILE}.bak").unlink() + before = _snapshot(snap_dir) + assert set(before) == {FULL_ARCHIVE, "incr-001.tar.zst", SNAR_FILE, "meta.yml"} + state_before = (_backups(tmp_path) / "snapshot.yml").read_bytes() + + mgr.fail_backup = True + with pytest.raises(SnapshotError): + mgr.create(name="g", full=True) + + assert _snapshot(snap_dir) == before + assert (_backups(tmp_path) / "snapshot.yml").read_bytes() == state_before + + +def test_leftover_temps_are_discarded_before_the_backup(tmp_path): + mgr = _with_incrementals(tmp_path) + snap_dir = _backups(tmp_path) / "g" + for tmp in (FULL_ARCHIVE_TMP, SNAR_FILE_TMP, META_FILE_TMP): + (snap_dir / tmp).write_text("stale") + seen = {} + + original = FakeManager._run_docker_tar + + def spy(self, snap_dir_, mode, command, volumes=None): + seen["temps"] = {t for t in (FULL_ARCHIVE_TMP, SNAR_FILE_TMP, META_FILE_TMP) + if (snap_dir_ / t).exists()} + return original(self, snap_dir_, mode, command, volumes) + + mgr._run_docker_tar = spy.__get__(mgr) + mgr.create(name="g", full=True) + assert seen["temps"] == set() + + +def test_cli_exits_1_when_the_backup_fails(tmp_path, monkeypatch): + mgr = _with_incrementals(tmp_path) + mgr.fail_backup = True + monkeypatch.setattr("devbase.commands.snapshot.SnapshotManager", lambda *a, **k: mgr) + args = types.SimpleNamespace(subcommand="create", name="g", full=True, group="acme") + assert cmd_snapshot(tmp_path, args) == 1 + + +# --------------------------------------------------------------------------- +# 入れ替えの途中の失敗 (受け入れ条件 6・I3) +# --------------------------------------------------------------------------- + +def _fail_replace_at(monkeypatch, nth: int) -> None: + """``os.replace`` の ``nth`` 回目 (1 始まり) を失敗させる。 + + 入れ替えの順は 1 印付きの meta.yml・2 full・3 印無しの meta.yml・4 状態ファイル。 + """ + real = os.replace + count = {"n": 0} + + def fake(src, dst): + count["n"] += 1 + if count["n"] == nth: + raise OSError("injected") + return real(src, dst) + + monkeypatch.setattr(manager_mod.os, "replace", fake) + + +def _fail_incr_unlink(monkeypatch) -> None: + real = Path.unlink + + def fake(self, missing_ok=False): + if self.name.startswith("incr-"): + raise OSError("injected") + return real(self, missing_ok=missing_ok) + + monkeypatch.setattr(Path, "unlink", fake) + + +def test_incrementals_are_dropped_from_the_highest_number(tmp_path, monkeypatch): + """止まっても、残る差分は前の full から番号の小さい順に続く。""" + mgr = _with_incrementals(tmp_path, 3) + snap_dir = _backups(tmp_path) / "g" + real = Path.unlink + count = {"n": 0} + + def fake(self, missing_ok=False): + if self.name.startswith("incr-"): + count["n"] += 1 + if count["n"] == 2: + raise OSError("injected") + return real(self, missing_ok=missing_ok) + + with monkeypatch.context() as m: + m.setattr(Path, "unlink", fake) + with pytest.raises(SnapshotError): + mgr.create(name="g", full=True) + assert sorted(p.name for p in snap_dir.glob("incr-*")) == [ + "incr-001.tar.zst", "incr-002.tar.zst"] + + +@pytest.mark.parametrize("stop", ["incr", 1, 2, 3, 4]) +def test_failed_install_reports_and_next_create_rebuilds(tmp_path, monkeypatch, stop): + mgr = _with_incrementals(tmp_path) + snap_dir = _backups(tmp_path) / "g" + with monkeypatch.context() as m: + if stop == "incr": + _fail_incr_unlink(m) + else: + _fail_replace_at(m, stop) + with pytest.raises(SnapshotError, match="作り直しを終えられませんでした"): + mgr.create(name="g", full=True) + + names = _listing(snap_dir) + assert SNAR_FILE not in names + assert f"{SNAR_FILE}.bak" not in names + assert not names & {FULL_ARCHIVE_TMP, SNAR_FILE_TMP, META_FILE_TMP} + + # 次の差分の指定は snar 無しの経路で作り直し、印を外す + mgr.create(name="g") + assert _listing(snap_dir) == {FULL_ARCHIVE, SNAR_FILE, "meta.yml"} + assert REBUILDING_KEY not in _meta(snap_dir) + assert _meta(snap_dir)["files"] == [FULL_ARCHIVE] + + +@pytest.mark.parametrize("stop,marked", [("incr", False), (1, False), (2, True), + (3, True), (4, False)]) +def test_marked_generation_is_not_restored(tmp_path, monkeypatch, stop, marked): + """I3: 別のボリュームの組で作り直して止めると、印のある世代は消去の前に止まる。""" + _with_incrementals(tmp_path) + snap_dir = _backups(tmp_path) / "g" + other = FakeManager(tmp_path, group="other") + with monkeypatch.context() as m: + if stop == "incr": + _fail_incr_unlink(m) + else: + _fail_replace_at(m, stop) + with pytest.raises(SnapshotError): + other.create(name="g", full=True) + + meta = _meta(snap_dir) + assert bool(meta.get(REBUILDING_KEY)) is marked + rebuilt = (snap_dir / FULL_ARCHIVE).read_text() == REBUILT + # 印が無ければ full と meta.yml の組が合う + if not marked: + assert rebuilt is (meta["volumes"]["group"] == "devbase_home_other") + + other.calls.clear() + if marked: + with pytest.raises(SnapshotError, match="作り直しの途中で止まっている"): + other.restore("g") + assert other.calls == [] + else: + other.restore("g") + restores = [c for c in other.calls if c["mode"] == "restore"] + assert restores + + +# --------------------------------------------------------------------------- +# 同じ秒の復元前バックアップ (F3) +# --------------------------------------------------------------------------- + +def test_pre_restore_backup_rebuilds_an_existing_generation(tmp_path, monkeypatch): + class FixedNow(datetime): + @classmethod + def now(cls, tz=None): + return datetime(2026, 10, 12, 12, 0, 0) + + monkeypatch.setattr(manager_mod, "datetime", FixedNow) + mgr = FakeManager(tmp_path) + name = "pre-restore-20261012-120000" + mgr.create(name=name) + (_backups(tmp_path) / name / "incr-001.tar.zst").write_text("old") + + assert mgr._backup_before_restore(mgr.volumes) == name + assert _listing(_backups(tmp_path) / name) == {FULL_ARCHIVE, SNAR_FILE, "meta.yml"}