diff --git a/CHANGELOG.md b/CHANGELOG.md index 375a35f1..51b12818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,31 @@ 止まります。`lfm` / `snapshot` は base を継がないため入りません。 **反映には `devbase build base --no-cache` と、使っている派生イメージの建て直しが要ります。** +### Changed +- **スナップショットの世代を、アカウントグループ(対象ボリュームの組)ごとの系列で持つように + しました(PLAN68 / #248)。** グループの違うプロジェクトを行き来しても、`devbase up` は + 起動したグループの系列の最新の世代へ差分を積み、フルバックアップを取り直しません。 + 新しい世代を作るのは、その系列に世代が無いときと、差分が上限(10)に達したときだけです。 + 自動スナップショットの最小間隔(`DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES`)も系列ごとに判定します。 + 起動時の出力には扱った系列のグループ名が出て、「対象ボリュームの構成が変わったため」の行は + 出なくなりました。 +- **ローテーションは、グループごとに 3 世代を残し、全体で 9 世代を上限にします。** 全体の上限を + 超えると系列をまたいで古い世代から消しますが、**各系列の最新の世代は自動では消えません** + (次の差分の積み先のため)。使わなくなったグループの世代が不要なら `devbase snapshot delete` + で消してください。 +- **`devbase snapshot rotate --keep N` の N は「全体で残す数」から「グループごとに残す数」に + なりました。** 全体の上限は新しい `--max-total M`(省略時は `N × 3`)で指定します。同じ `--keep` の + 値で残る世代の数は減りませんが、どの世代が残るかは変わることがあります。どちらの指定も + その 1 回の実行だけに効き、`devbase up` / `down` の自動ローテーションは既定の数で動きます。 + +### Fixed +- **ローテーションが、`backups/` の外を指す名前の世代を消さず、シンボリックリンクの世代で + 止まらなくなりました(PLAN68 / #248)。** `snapshot.yml` のそうしたエントリは、ディレクトリも + リンク先も消さずに一覧から外し、警告を出します。 +- **`devbase snapshot create` / `restore` / `copy` / `delete` が、シンボリックリンクの世代を + エラーで止めるようになりました。** これまで `delete` はリンク先(`backups/` の外や別の世代)の + 中身を消していました。 + ## [3.7.0] - 2026-09-23 ### Added diff --git a/docs/specifications/snapshot-series.md b/docs/specifications/snapshot-series.md new file mode 100644 index 00000000..cdfd6381 --- /dev/null +++ b/docs/specifications/snapshot-series.md @@ -0,0 +1,346 @@ +# スナップショットの世代の系列(アカウントグループごとの保持と差分の積み先) + +## 概要 + +スナップショットの世代は、控える**対象ボリュームの組**ごとの**系列**に分かれる。組は共通 +ボリューム `devbase_home_ubuntu` とグループのボリューム `devbase_home_` からなり、 +共通ボリュームは固定のため、系列はアカウントグループと 1 対 1 に対応する。 + +- `devbase up` の自動スナップショットは、起動するグループの系列の**最新の世代**へ差分を積む。 + 別のグループを起動してから戻っても、`full.tar.zst` を取り直さない +- 自動スナップショットの最小間隔(`DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES`)は系列ごとに判定する +- ローテーションは系列ごとに `keep` 世代を残し、全体の上限(既定 `keep × 3`)を超えた分を系列を + またいで古い順に消す。**各系列の最新の世代は消さない** +- ローテーションは消す前に世代の場所を検証し、`backups/` の外やシンボリックリンクの先を消さない + +系列はどこにも保存しない。`snapshot.yml` のエントリの `volumes` からその場で導く。 + +利用者向けの読み方は [スナップショットガイド: 系列](../user/snapshot-guide.md#系列) と +[CLI リファレンス: `devbase snapshot rotate`](../user/cli-reference/05-snapshot.md#devbase-snapshot-rotate) +にある。 + +## 対象範囲 + +- 自動スナップショットの積み先・新しい世代を作る条件・最小間隔の判定 +- `devbase up` / `devbase down` / `devbase snapshot rotate` / TUI のローテーションの規則 +- 世代の場所の検証(`_safe_snap_dir`)。`create` / `restore` / `copy` / `delete` / `rotate` が共有する +- ログの文言 + +含まない: + +- 世代を名前で区別すること。`--name` の世代・`copy` の世代・`pre-restore-*` も対象ボリュームの組で + 系列に入り、同じ規則で消える。名前付きの世代の保護は #256 で扱う +- 復元の対象・順序・失敗時の案内(変えていない)。復元前の自動バックアップが控えるボリュームは #255 +- `devbase snapshot list` / `devbase status` の表示。`status` の「最新」は `snapshot.yml` の最後の + エントリ(最も新しく**作られた**世代)で、直前に差分を積んだ世代とは一致しないことがある +- 保持をバイト数で制限すること +- リモート扱いの `up`(自動スナップショットを作らない。[別ホストの Docker への dev コンテナ起動](remote-docker-context.md#自動スナップショット)) + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 世代 | `backups/<名前>/` の 1 つ。`full.tar.zst` 1 つと、0 個以上の `incr-NNN.tar.zst` からなる | +| 対象ボリュームの組 | `snapshot.yml` のエントリの `volumes`(マウント名 → ボリューム名)。現行は `{ai: devbase_home_ubuntu, group: devbase_home_}` | +| 系列 | 対象ボリュームの組が同じ世代の集まり。`volumes` が無い・空・dict でないエントリ(PLAN39 より前の旧レイアウト)は `{'': devbase_home_ubuntu}` の組として 1 つの系列になる | +| 系列の最新の世代 | 系列の中で `created_at` が最も新しい世代。同じ値なら `snapshot.yml` で後ろのもの | +| 全体の上限 | 系列をまたいで数えた世代の数の上限(`max_total`) | + +## 構成要素 + +| 要素 | 置き場所 | 責務 | +| --- | --- | --- | +| 系列の解決 | `lib/devbase/snapshot/manager.py` の `SnapshotManager.series_key` / `series_label` / `_entry_volumes` / `_series_entries` / `series_latest` | エントリを系列に分け、系列の最新の世代を返す。系列の表示名を作る | +| 積み先の判定 | 同 `auto_snapshot_target` | 積み先の世代の名前か、新しい世代を作るべきこと(`None`)を返し、理由をログへ出す | +| 最小間隔の元 | 同 `last_snapshot_time(volumes=None)` | 渡された組の系列の世代のアーカイブの mtime の最大を返す | +| 保持 | 同 `rotate(keep, max_total)` と、副作用の無い `_rotation_plan` | 削除候補を決め、場所を検証してから消す | +| 世代の場所の検証 | 同 `_safe_snap_dir` | 名前・シンボリックリンク・`backups/` の外を `SnapshotError` で止める | +| 自動スナップショット | `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)` で受ける | +| TUI | `lib/devbase/tui/actions_snapshot.py` の `_op_rotate` | 「グループごとに保持する世代数 (--keep)」だけを問い、`max_total` は渡さない | + +世代の規則は `manager.py` に置き、`container.py` は判定の結果に従って作成を呼ぶだけにする。 + +```mermaid +classDiagram + class SnapshotManager { + +volumes: dict + +series_key(volumes)$ tuple + +series_label(volumes)$ str + +series_latest(volumes) dict|None + +auto_snapshot_target(max_incrementals) str|None + +last_snapshot_time(volumes) datetime|None + +rotate(keep, max_total) int + -_rotation_plan(snapshots, keep, max_total) list + -_safe_snap_dir(name) Path + -_series_entries(volumes) list + -_entry_volumes(entry)$ dict + -_entry_age(entry, index)$ tuple + } + class 世代のエントリ { + +name + +created_at + +updated_at + +incremental_count + +volumes + } + SnapshotManager ..> 世代のエントリ : snapshot.yml から読む +``` + +`系列` は型として持たない。`series_key(volumes)` が返すタプルを辞書のキーにして、その場で +エントリを束ねる。 + +系列の判定とローテーションはホストの Python の中だけで完結し、Docker を呼ばない。Docker を +呼ぶのは既存の `create` のアーカイブ作成だけで、この規則は呼ぶ回数(full か差分か)を決める。 + +## 仕様 + +### 系列の識別 + +- `series_key(volumes)` は `tuple(sorted((str(k), str(v)) ...))`。値は検証しない。キーとして比べる + だけで、マウントには使わないためである。マウントに使う値は従来どおり `snapshot_volumes` が + `meta.yml` から検証して返す +- 判定には `meta.yml` ではなく `snapshot.yml` を使う。ローテーションが全世代の `meta.yml` を読まずに + 済み、壊れた `meta.yml` の検証エラーでローテーション全体が止まらない +- `series_label(volumes)` は `group` があれば `グループ <名前>`(`devbase_home_` を外した名前)、 + 無ければ `旧レイアウト(共通ボリュームのみ)` +- 世代の新旧は `(created_at, snapshot.yml での位置)` で比べる(`_entry_age`)。`created_at` が無い + エントリは空文字として最も古い。引用符なしの日時は YAML が `datetime` で返すため、`isoformat()` の + 文字列に揃えて比べる + +**系列を保存しない理由**: エントリは PLAN39 以降すべて `volumes` を持ち、系列はそこから一意に決まる。 +別に保存すると `volumes` と食い違ったときの規則と既存の `snapshot.yml` の移行が要る。導けば移行が +無く、変更前の devbase へ戻しても同じ `snapshot.yml` を読める。 + +### 差分の積み先 + +`auto_snapshot_target(max_incrementals=10)` は次の順に判定する。対象は `self.volumes`(起動する +グループの組)の系列である。 + +| 順 | 条件 | 結果 | ログ | +| --- | --- | --- | --- | +| 1 | 系列に世代が無い | `None` | INFO `{系列} の世代がまだ無いため、新しい世代を作成します` | +| 2 | 系列の最新の世代が `_safe_snap_dir` を通らない(不正な名前・シンボリックリンク・`backups/` の外) | `None` | WARNING `{系列} の最新の世代 '{名前}' は扱えないため、新しい世代を作成します: {理由}` | +| 3 | 最新の世代のディレクトリがあり、その `meta.yml` の組が `self.volumes` と違う | `None` | INFO `世代 {名前} の meta.yml の対象ボリューム ({組}) が {系列} と一致しないため、新しい世代を作成します` | +| 4 | 最新の世代の `incremental_count` が上限以上 | `None` | INFO `世代 {名前}({系列})の差分が上限 ({上限}) に達したため、新しい世代を作成します` | +| 5 | それ以外 | 最新の世代の名前 | 出さない | + +- 順 2 は、通らない世代へ積もうとすると `create` が止まり、`rotate` は系列の最新を消さないため、 + 起動のたびに同じ失敗を繰り返すことを防ぐ。新しい世代を作れば、扱えない世代は最新でなくなり、 + 次のローテーションで一覧から外れる +- ディレクトリが無い世代の名前を返したときは、`create(name=...)` が新しいディレクトリとして + full を作る +- 名前を明示して組の違う世代へ差分を作ろうとすると、`_create_incremental` が理由を示して + `SnapshotError` で止める(旧レイアウトの世代へは差分を積まない) + +**間に別グループの起動を挟んでも、系列の最新の世代へ積む。** `snapshot.snar` は世代ごとにあり、 +系列が同じならアーカイブのレイアウト(`/source/ai` と `/source/group`)もマウントするボリュームも +同じで、snar の記録と今のツリーのパスが対応する。間に変わった共通ボリュームの中身は「前回の差分 +からの変更」として次の差分に入るだけである。代償として、共通ボリュームの変化は系列ごとに別々に +控えられる。 + +### 最小間隔 + +`_auto_snapshot` は `last_snapshot_time(mgr.volumes)` で、起動するグループの系列の世代だけを見る。 + +- 系列の世代(`snapshot.yml` のエントリ)のうち、名前が単一の要素で、シンボリックリンクでない + 実ディレクトリだけを走査する。`volumes` を省くと `backups/` の全ディレクトリを走査する +- 数えるのはアーカイブ(`full.tar.zst` / `incr-*.tar.zst`)の mtime だけで、`meta.yml` / + `snapshot.snar` / `*.bak` は数えない。作成に失敗しても残りうるためである +- `間隔 > 0` かつ `0 ≤ 経過 < 間隔` なら飛ばす。経過が負(mtime が未来)なら飛ばさない。 + `DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES=0` はどの系列でも飛ばさない + +**系列ごとにする理由**: 全体で判定すると、default を控えた直後に with を起動したとき、with の +系列は何時間も控えていなくても飛ばされる。系列ごとにして控える回数が増えても、2 回目以降は差分になる。 + +### 保持 + +`rotate(keep=3, max_total=None)`: + +1. `max_total` を省けば `keep × 3`。`keep < 1` か `max_total < 1` なら `SnapshotError`(何も消さない) +2. エントリを `series_key` で束ね、各系列を古い順に並べ、古い側から `len - keep` 件を削除候補にする + (理由: 系列ごとの保持) +3. 残りの総数が `max_total` を超える間、残りが 2 件以上の系列の最古の世代のうち最も古い 1 件を + 削除候補にする(理由: 全体の上限)。そうした系列が無ければ警告して打ち切る +4. 候補が無ければ 0 を返し、`snapshot.yml` を書かない +5. 候補ごとに `_safe_snap_dir` で検証し、通ればディレクトリを消す。通らなければディレクトリを + 消さず、一覧からだけ外して警告する +6. 残りを古い順に並べて `snapshot.yml` へ保存し、`max_generations` に `keep` を書く。 + 検証を通って消した(ディレクトリが既に無かったものを含む)数を返す。一覧から外しただけのエントリは数えない + +常に成り立つこと: + +- **各系列の最新の世代は `rotate` では消えない。** 次の差分の積み先であり、消すとそのグループの次の + 起動で full を取り直すことになる。使わなくなったグループや旧レイアウトの系列の最新の世代も残り、 + 不要なら `devbase snapshot delete` で消す +- ディスクに載る世代の数は、`rotate` の後に `max(max_total, 系列の数)` を超えない +- 旧レイアウトの系列は 1 つの系列として数え、他の系列の保持に影響しない + +**全体の上限を持つ理由**: 系列ごとの保持だけでは、使わなくなった系列が 3 世代ずつ残り続け、 +グループの数だけディスクの使用量が増える。既定を `keep` と独立した定数にしないのは、`--keep` を +増やした利用者の上限が黙って指定より少なくなるためである。バイト数で持たないのは、1 世代の大きさが +差分の数で 1 桁以上振れ、何世代残るかを予測できないためである。 + +`devbase up`(作成の後)と `devbase down` は `rotate()` を既定の引数(系列ごと 3・全体 9)で呼ぶ。 +手動の `--keep` / `--max-total` は保存せず、その 1 回だけに効く。4 グループ以上の端末では、 +自動のローテーションで各系列の世代が 3 未満になりうる。自動のローテーションの上限を変える手段は +持たない。 + +### `devbase snapshot rotate` + +| 項目 | 内容 | +| --- | --- | +| 形 | `devbase snapshot rotate [--keep N] [--max-total M]` | +| `--keep N` | 系列(グループ)ごとに残す数。既定 3 | +| `--max-total M` | 全体の上限。既定 `N × 3`。各グループの最新の世代は上限を超えても残す | +| 失敗 | `N < 1` / `M < 1` は `SnapshotError` → `cmd_snapshot` がエラーを出して終了コード 1 | +| TUI | `keep` だけを問い、`max_total` を渡さない。`cmd_snapshot` は `getattr` で受け、既定の `keep × 3` で動く | + +**`--keep` の意味を「全体で残す数」から「系列ごとに残す数」へ変えた。** 自動のローテーション +(系列ごと)と同じ語を同じ意味にするためである。同じ値で残る世代の**数**は変更前より減らないが、 +**どの世代が残るか**は変わりうる(全体の上限は、世代の多い系列の古い世代を、他の系列の最新の世代より +先に消す)。 + +### 世代の場所の検証 + +`_safe_snap_dir(name)` は次の順に判定し、通らなければ `SnapshotError` にする。 + +1. 名前が `is_single_segment_name` を満たさない(`../outside` など) +2. `backups/<名前>` がシンボリックリンク(リンク先が `backups/` の外・兄弟の `backups-outside/`・ + `backups/` の中の別の世代のどれでも) +3. 解決後のパスが `Path.is_relative_to(backups_dir.resolve())` でない + +- 包含は文字列の前方一致ではなく**パスの要素の単位**で比べる。前方一致では兄弟の + `backups-outside/` が `backups` で始まるため通ってしまう +- devbase はリンクの世代を作らない。リンクを通すと、リンク先がどこでも `delete` が実体を消しうる +- `backups/` 自体をリンクにした構成は、解決後の `backups/` と比べるため使える +- `create` / `restore` / `copy` / `delete` はこの検証で止まり、ボリュームへの書き込みもディレクトリの + 作成・削除も起こさない。`rotate` だけは止まらず、そのエントリを一覧から外して警告する。不正な + エントリ 1 つで `devbase down` のたびにローテーション全体が止まるのを避けるためである + +## データ・設定 + +**形は変えていない。** 移行は無く、変更前の devbase も同じファイルを読める。 + +| 保存先 | 系列の規則での扱い | +| --- | --- | +| `backups/snapshot.yml` の `snapshots[].volumes` | 系列の識別子の元 | +| 同 `snapshots[].created_at` | 世代の新旧。差分を積んでも変わらない(差分は `updated_at` と `incremental_count` を更新する) | +| 同 `snapshots[].incremental_count` | 差分の上限の判定 | +| 同 `max_generations` | `rotate` が消したときに `keep` を書く。読む側は無い | +| 各世代の `meta.yml` の `volumes` | 系列の判定には使わない。積む直前の検証(`auto_snapshot_target` の順 3 と `_create_incremental`)でだけ読む | +| アーカイブの mtime | 最小間隔の判定 | + +| 設定 | 値 | +| --- | --- | +| `DEFAULT_MAX_GENERATIONS` | 3(系列ごとの保持数の既定) | +| 全体の上限の既定 | `keep × 3`(既定 9) | +| `DEFAULT_MAX_INCREMENTALS` | 10(1 世代の差分の上限) | +| `DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES` | 既定 60。0 で無効。不正な値は既定へ戻す | + +### ログの文言 + +| 場面 | 水準 | 文言 | +| --- | --- | --- | +| 最小間隔で飛ばす | INFO | `[0/6] {系列} の直近のスナップショット ({時刻}) から{分}分以内のためスキップします` | +| 新しい世代を作る | INFO | `[0/6] 新しいスナップショット世代を作成中 ({系列})...`(直前に積み先の判定の理由の 1 行) | +| 差分を積む | INFO | `[0/6] スナップショットを差分更新中: {名前} ({系列})` | +| 系列ごとの保持で消した | INFO | `ローテーション: {系列} の {数} 世代を削除しました(グループごとに {keep} 世代保持)` | +| 全体の上限で消した | INFO | `ローテーション: 全体の上限 {max_total} 世代を超えたため、{系列} の {名前} を削除しました` | +| 全体の上限を満たせない | WARNING | `全体の上限 {max_total} 世代を超えていますが、各グループの最新の世代は消さないため {残り} 世代を残します` | +| 場所が不正なエントリ | WARNING | `snapshot.yml の世代 '{名前}' は場所が不正なため、ディレクトリを消さずに一覧からだけ外します: {理由}` | + +`{系列}` は `series_label` の値(例: `グループ default`)。グループの切替は新しい世代を作る理由では +ないため、切替を理由とする行は無い。文言に直前の世代を作ったプロジェクトは添えない。世代は +プロジェクトではなくボリュームの組に属し、同じグループの複数のプロジェクトが 1 つの世代へ積む。 + +## エラー処理 + +| 場面 | 扱い | +| --- | --- | +| `_auto_snapshot` の中の例外(グループ名の不正による `DevbaseError`、`SnapshotError` など) | 警告 1 行に変えて起動を続ける。`snapshot.yml` を作らない | +| `cmd_down` の `rotate()` の失敗 | 警告 1 行。コンテナの停止は済んでおり、終了コード 0 | +| `rotate` の `keep` / `max_total` が 1 未満 | `SnapshotError`。何も消さず `snapshot.yml` も書かない | +| `rotate` の候補が `_safe_snap_dir` を通らない | ディレクトリを消さず、一覧から外して警告。他の候補の削除は続ける | +| `create` / `restore` / `copy` / `delete` の世代が `_safe_snap_dir` を通らない | `SnapshotError` → CLI は終了コード 1 | + +## 運用 + +- 次の `devbase up` から新しい規則で動く。利用者の操作は要らない +- 使わなくなったグループや旧レイアウトの系列の最新の世代は自動では消えない。 + `devbase snapshot list` の「対象ボリューム」とサイズの列で見て、`devbase snapshot delete` で消す +- 長く残したい世代は `backups/` の外へ複製する。`copy` の世代も系列に入り、ローテーションの対象になる +- 切り戻し(この変更の前の devbase へ戻す)では、最初の `devbase down` で全体 3 世代の旧規則の + ローテーションが走り、系列ごとに残っていた世代が消える。`snapshot copy` の退避では守れないため、 + `backups/` の外へ複製してから戻す +- 別グループの起動を挟んで積んだ差分を実際の `devbase-snapshot` で復元し、最後の差分の時点の + 共通ボリュームに戻ることは、自動テストでは確かめていない(tar は差し替えている) + +## テスト観点 + +自動テストは `DEVBASE_ROOT` を `tmp_path` へ向け、`SnapshotManager._run_docker_tar` を差し替えて +Docker を起動しない。`snapshot.yml` を直接書くテストは、世代のディレクトリと `meta.yml` も書く。 + +積み先(`tests/snapshot/test_manager_series.py`、`tests/snapshot/test_manager_volumes.py`): + +- default の世代の後に with の世代を作っても、default の積み先は default の最新の世代であること +- 系列に世代が無いグループは `None` と理由の行。差分の上限は系列ごとに数え、他の系列の差分数を + 使わないこと +- 旧レイアウトの世代だけなら `None`。組の違う世代を名前で指定した差分は `SnapshotError` +- `meta.yml` の組が食い違う最新の世代では `None`。`created_at` の新旧(引用符なしの YAML の日時を含む) +- 系列の最新がシンボリックリンクか `../outside` のとき、`None` と WARNING 1 行。新しい世代を作った後は + それが積み先になり、`rotate` で扱えないエントリが一覧から外れ、外の中身が残ること + +最小間隔と `_auto_snapshot` の流れ(`tests/snapshot/test_auto_snapshot_series.py`): + +- グループを行き来した後の起動で `create(name=..., full=False)` が呼ばれ、世代の数が変わらないこと。 + 出力に切替を理由とする行が無く、`グループ default` を含むこと +- 10 分前の系列は飛ばし、2 時間前の系列は積むこと。間隔 0 なら飛ばさないこと。mtime が未来なら + 飛ばさないこと +- 新しい世代の作成の後の `rotate()` が、その系列の最古だけを消し、他の系列を残すこと +- 不正なグループ名では警告 1 行で、`snapshot.yml` を作らないこと + +保持(`tests/snapshot/test_manager_series.py`): + +- default 4・with 1 で default の最古 1 世代だけが消え、戻り値が 1 +- default と with を交互に 4 つずつで、各 3 世代が残ること +- 4 系列 × 3 世代(A1〜D3 の順)で A1・B1・C1 が消えること。A の 3 世代が最古のときは A の古い 2 と + B の最古が消え、A の最新が残ること +- 10 系列 × 1 世代で `rotate(keep=3, max_total=9)` が何も消さず、WARNING が 1 件 +- 旧レイアウト 3 世代と default 3 世代で何も消さないこと +- `keep=0` / `max_total=0` が `SnapshotError` で何も消さないこと +- 既存と同じ 3 エントリ(default 2・with 1)の `snapshot.yml` で 0 を返し、ファイルのバイト列が + 変わらないこと +- 系列ごとの削除と全体の上限の削除のログにグループ名があること + +場所の検証(`tests/snapshot/test_manager_series.py`): + +- `../outside` のエントリが削除の対象でも `backups/` の外が残り、エントリが外れ、WARNING 1 件 +- 兄弟の `backups-outside/` を指すリンク、`backups/` の中の最新の世代を指すリンクのどちらでも、 + リンク先の中身が残り、エントリが外れ、WARNING 1 件。`_safe_snap_dir('old')` が `SnapshotError` +- 同じリンクで `cmd_snapshot` の `delete` が終了コード 1 でリンク先が残ること。`restore` / `copy` / + `create(name=...)` が `SnapshotError` で、tar を呼ばず `backups/new` も作らないこと + +CLI と TUI: + +- `--keep` / `--max-total` / 別名の parse(`tests/cli/test_snapshot_rotate_args.py`) +- `cmd_snapshot` に `keep=2, max_total=2` を渡すと上限が効くこと。`max_total` を省くと上限が + `keep × 3` になること。0 以下で終了コード 1(`tests/snapshot/test_manager_series.py`) +- TUI の問いの文言と、`max_total` を渡さないこと(`tests/cli/tui/test_actions_snapshot.py`) +- `cmd_down` の `rotate()` の失敗で終了コード 0(`tests/commands/test_container_down_snapshot.py`) + +復元のテスト(`tests/snapshot/test_restore_incremental.py`、`test_manager_volumes.py` の復元)は +変えずに通ること。 + +実機で確かめる観点: 2 つのグループのプロジェクトを `DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES=0` で +交互に `devbase up` し、それぞれの系列の最新の世代に `incr-001` が積まれ、世代の数が変わらないことを +`devbase snapshot list` で見る。 + +## 関連リンク + +- [スナップショットガイド](../user/snapshot-guide.md) +- [CLI リファレンス: snapshot (ss) グループ](../user/cli-reference/05-snapshot.md) +- [コンテナ運用ガイド](../user/container-operations.md) +- [別ホストの Docker への dev コンテナ起動(docker context)](remote-docker-context.md) +- [位置引数の解決(プロジェクト名・イメージ名)](cli-argument-resolution.md)(スナップショットの名前の規則) diff --git a/docs/user/cli-reference/02-project.md b/docs/user/cli-reference/02-project.md index 9b1f44d5..8d250bbb 100644 --- a/docs/user/cli-reference/02-project.md +++ b/docs/user/cli-reference/02-project.md @@ -94,8 +94,8 @@ devbase up [name] [--context NAME] - 自動スナップショットは作らない(控えたいボリュームがリモートにあるため) - `DOCKER_HOST` が設定されていれば警告して外す(docker は `DOCKER_HOST` を `DOCKER_CONTEXT` より優先するため) -- 起動時にスナップショットを自動作成(新世代 or 差分追加) - - 直近のスナップショット取得から既定 60 分以内のときはスキップします +- 起動時にスナップショットを自動作成(起動するアカウントグループの系列の最新の世代へ差分追加。系列に世代が無いか差分が上限に達していれば新世代) + - 同じアカウントグループ(系列)の直近のスナップショット取得から既定 60 分以内のときはスキップします。別のグループのスナップショットの時刻は見ません - 間隔は `DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES` 環境変数で上書き可能(既定 60、`0` で無効化=毎回取得、不正値は警告して既定値) - `project.yml` の `scale` に基づいてコンテナ数を決定(既定: 2) - `project.yml` の `repos` を clone プランへ正規化してコンテナへ渡す(コンテナ内で `/work` 配下へ clone される) diff --git a/docs/user/cli-reference/05-snapshot.md b/docs/user/cli-reference/05-snapshot.md index d55ad16c..ce7c6765 100644 --- a/docs/user/cli-reference/05-snapshot.md +++ b/docs/user/cli-reference/05-snapshot.md @@ -66,12 +66,20 @@ devbase snapshot delete ## `devbase snapshot rotate` -古い世代のスナップショットを削除します。 +古い世代のスナップショットを削除します。世代はアカウントグループ(対象ボリュームの組)ごとの +系列に分かれ、系列ごとに `--keep` 世代を残したうえで、全体の上限を超えた分を系列をまたいで +古い順に削除します。各系列の最新の世代は削除しません。 ``` -devbase snapshot rotate [--keep N] +devbase snapshot rotate [--keep N] [--max-total M] ``` | オプション | 説明 | |-----------|------| -| `--keep N` | 保持する世代数(デフォルト: `3`) | +| `--keep N` | アカウントグループ(系列)ごとに保持する世代数(デフォルト: `3`) | +| `--max-total M` | すべてのグループを合わせて保持する世代数の上限(デフォルト: `N × 3`)。各グループの最新の世代は上限を超えても残す | + +`N` と `M` は 1 以上です。0 以下はエラーで終了コード 1 になります。 + +**どちらの指定も、手動で実行したその 1 回だけに効きます。** 値は保存されず、`devbase up` / +`devbase down` の自動ローテーションは既定(グループごとに 3・全体で 9)で動きます。 diff --git a/docs/user/container-operations.md b/docs/user/container-operations.md index 579d6a35..6ec3d315 100644 --- a/docs/user/container-operations.md +++ b/docs/user/container-operations.md @@ -60,8 +60,8 @@ devbase down | タイミング | 動作 | 条件 | |-----------|------|------| -| `devbase up` | フルバックアップ or 差分追加 | 前回のフルバックアップからの経過日数で判定 | -| `devbase down` | 古い世代のローテーション | `DEFAULT_MAX_GENERATIONS` を超えた世代を削除 | +| `devbase up` | フルバックアップ or 差分追加 | 起動するアカウントグループの系列の最新の世代へ差分を追加。系列に世代が無いか差分が上限に達していればフルバックアップで新しい世代 | +| `devbase down` | 古い世代のローテーション | グループ(系列)ごとに `DEFAULT_MAX_GENERATIONS`(3)を超えた世代と、全体の上限(9)を超えた古い世代を削除。各系列の最新の世代は残す | 詳細は [スナップショットガイド](snapshot-guide.md) を参照してください。 diff --git a/docs/user/snapshot-guide.md b/docs/user/snapshot-guide.md index f0f8cd7b..e0a507e1 100644 --- a/docs/user/snapshot-guide.md +++ b/docs/user/snapshot-guide.md @@ -57,31 +57,55 @@ graph LR | パラメータ | デフォルト値 | 説明 | |-----------|------------|------| | `DEFAULT_MAX_INCREMENTALS` | `10` | 1 世代あたりの最大差分バックアップ数 | -| `DEFAULT_MAX_GENERATIONS` | `3` | 保持する最大世代数 | +| `DEFAULT_MAX_GENERATIONS` | `3` | アカウントグループ(系列)ごとに保持する世代数 | +| 全体の上限 | `DEFAULT_MAX_GENERATIONS × 3`(`9`) | すべてのグループを合わせて保持する世代数の上限 | -デフォルト設定では、差分バックアップが 10 回溜まるとフルバックアップが新たに作成され、最大 3 世代が保持されます。 +デフォルト設定では、差分バックアップが 10 回溜まるとフルバックアップが新たに作成され、グループごとに最大 3 世代、全体で最大 9 世代が保持されます。 + +### 系列 + +世代は、控える**ボリュームの組**(共通ボリューム `devbase_home_ubuntu` とグループの +ボリューム `devbase_home_`)ごとの**系列**に分かれます。アカウントグループ 1 つにつき +系列が 1 つできます。アカウントグループ分離より前に作られた世代(共通ボリュームだけを控えたもの)は、 +まとめて 1 つの系列になります。 + +- 差分は、起動したグループの系列の**最新の世代**へ積みます。別のグループのプロジェクトを + 起動した後に戻ってきても、フルバックアップを取り直しません +- 保持の数は系列ごとに数えます。グループを切り替えても、他のグループの世代が保持の枠から押し出されません +- 全体の上限を超えると、系列をまたいで最も古い世代から削除します +- **各系列の最新の世代は自動では削除されません。** 次の差分の積み先になるためです。使わなくなった + グループや、アカウントグループ分離より前の系列の最新の世代も残ります。不要になったら + `devbase snapshot delete <名前>` で削除してください。系列の数が全体の上限を超えているときは、 + 上限を超えたまま残し、警告を出します + +積み先・最小間隔・ローテーションの規則と世代の場所の検証の仕様は +[スナップショットの世代の系列](../specifications/snapshot-series.md) にあります。 ### 世代の概念 ```mermaid graph TD - subgraph 世代 1(最古) - A1[full.tar.zst] - A2[incr-001.tar.zst] - A3[incr-002.tar.zst] + subgraph 系列 default + subgraph 世代 1(最古) + A1[full.tar.zst] + A2[incr-001.tar.zst] + A3[incr-002.tar.zst] + end + subgraph 世代 2(系列の最新) + B1[full.tar.zst] + B2[incr-001.tar.zst] + end end - subgraph 世代 2 - B1[full.tar.zst] - B2[incr-001.tar.zst] - end - subgraph 世代 3(最新) - C1[full.tar.zst] + subgraph 系列 with + subgraph 世代 3(系列の最新) + C1[full.tar.zst] + end end ``` - 1 つの世代は 1 つのフルバックアップと 0 個以上の差分バックアップで構成される -- 差分バックアップが `DEFAULT_MAX_INCREMENTALS` 回に達すると新しい世代が開始される -- `DEFAULT_MAX_GENERATIONS` を超えた古い世代は自動的に削除される +- 系列の最新の世代の差分バックアップが `DEFAULT_MAX_INCREMENTALS` 回に達すると、その系列に新しい世代が開始される +- 系列ごとに `DEFAULT_MAX_GENERATIONS` を超えた古い世代と、全体の上限を超えた古い世代は自動的に削除される ## 自動実行 @@ -91,22 +115,37 @@ graph TD ```mermaid flowchart TD - A[devbase up 実行] --> B{現世代の差分バックアップが
DEFAULT_MAX_INCREMENTALS 回以上?} - B -->|はい| C[新世代のフルバックアップを作成] - B -->|いいえ| D[現世代に差分バックアップを追加] - C --> E[コンテナを起動] - D --> E + A[devbase up 実行] --> S{起動するグループの系列に
世代がある?} + S -->|いいえ| C[新世代のフルバックアップを作成] + S -->|はい| B{系列の最新の世代の差分バックアップが
DEFAULT_MAX_INCREMENTALS 回以上?} + B -->|はい| C + B -->|いいえ| D[系列の最新の世代に差分バックアップを追加] + C --> R[ローテーション] + D --> R + R --> E[コンテナを起動] ``` +起動時の出力には、扱った系列のグループ名が出ます。新しい世代を作るときは、その理由 +(系列に世代が無い / 差分が上限に達した)を 1 行出します。 + +```text +[0/6] スナップショットを差分更新中: 20260920-212546 (グループ with) +``` + +直近のスナップショットから `DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES`(既定 60 分)以内なら +スナップショットを飛ばします。この間隔も系列ごとに判定するため、default のプロジェクトを +起動した直後に with のプロジェクトを起動しても、with の系列は控えます。 + ### `devbase down` 時の動作 ```mermaid flowchart TD A[devbase down 実行] --> B[コンテナを停止・削除] - B --> C{世代数 > DEFAULT_MAX_GENERATIONS?} - C -->|はい| D[最古の世代を削除] - D --> C - C -->|いいえ| E[完了] + B --> C[系列ごとに DEFAULT_MAX_GENERATIONS を
超えた古い世代を削除] + C --> T{全体の世代数 > 全体の上限?} + T -->|はい| D[系列の最新ではない世代のうち
最も古いものを削除] + D --> T + T -->|いいえ| E[完了] ``` ## バックアップデータ構造 @@ -190,9 +229,11 @@ before-upgrade 2026-02-21 14:00:00 1 2.1GB devbase_ho ### 対象ボリュームが変わったとき アカウントグループを切り替えたり、分離前の環境から更新したりすると、対象ボリュームの構成が -変わります。このとき devbase は**新しい世代を作ります**。旧世代の差分状態ファイル -(`snapshot.snar`)は別のレイアウトを記録しているため、そこへ差分を積むと全ファイルが -移動したものとして扱われ、差分が壊れるからです。世代を分けることで旧世代はそのまま復元できます。 +変わります。devbase は、切り替えた先のグループの系列([系列](#系列))の最新の世代へ差分を +積みます。**新しい世代を作るのは、その系列にまだ世代が無いときだけです。** 構成の違う世代へは +差分を積みません。差分状態ファイル(`snapshot.snar`)は別のレイアウトを記録しているため、 +そこへ差分を積むと全ファイルが移動したものとして扱われ、差分が壊れるからです。 +系列を分けることで、どの世代もそのまま復元できます。 構成の違う世代を明示的に指定して差分を作ろうとした場合は、理由を示して中断します。 @@ -332,11 +373,20 @@ devbase snapshot delete 20260218-080000 # デフォルトの保持数で実行 devbase snapshot rotate -# 保持する世代数を指定 +# グループごとに保持する世代数を指定 devbase snapshot rotate --keep 5 + +# 全体の上限も指定 +devbase snapshot rotate --keep 5 --max-total 10 ``` -`--keep N` で指定した世代数より古い世代を削除します。名前付きスナップショット(`--name` で作成したもの)はローテーション対象外です。 +`--keep N` はアカウントグループ(系列)ごとに残す世代数です。各系列で古い世代から削除し、 +残りの合計が `--max-total M`(省略時は `N × 3`)を超えていれば、系列をまたいで古い世代から +削除します。各系列の最新の世代は削除しません。名前付きスナップショット(`--name` で作成したもの)や +`copy` で作った世代も、対象ボリュームの組でいずれかの系列に入り、同じ規則で削除の対象になります。 + +**`--keep` と `--max-total` は、その 1 回の実行だけに効きます。** 値は保存されず、 +`devbase up` / `devbase down` の自動ローテーションは既定(グループごとに 3・全体で 9)で動きます。 ## 運用のベストプラクティス @@ -357,9 +407,8 @@ devbase snapshot rotate --keep 5 du -sh projects//backups/ ``` -5. **ローテーションの保持数はプロジェクトに合わせて調整する** +5. **長期保持したい世代は `backups/` の外へ複製する** - ```bash - # 長期保持が必要な場合 - devbase snapshot rotate --keep 7 - ``` + `devbase snapshot rotate --keep` の指定はその 1 回だけに効き、次の `devbase up` / `devbase down` + の自動ローテーションで既定の数まで削除されます。`snapshot copy` で作った世代もローテーションの + 対象です。長く残したい世代は、世代のディレクトリを `backups/` の外へ複製してください。 diff --git a/docs/user/troubleshooting.md b/docs/user/troubleshooting.md index 85c48837..d3260d15 100644 --- a/docs/user/troubleshooting.md +++ b/docs/user/troubleshooting.md @@ -358,9 +358,11 @@ devbase snapshot list # バックアップディレクトリのサイズ確認 du -sh ${DEVBASE_ROOT}/backups/ -# 不要な世代を削除(2世代のみ保持) +# 不要な世代を削除(グループごとに 2 世代、全体で 6 世代まで保持。この 1 回だけに効く) devbase snapshot rotate --keep 2 +# 各グループの最新の世代はローテーションで消えない。使わないグループの世代は個別に削除する + # 個別のスナップショットを削除 devbase snapshot delete ``` diff --git a/issues/PLAN68_snapshot-series-design.md b/issues/old/PLAN68_snapshot-series-design.md similarity index 100% rename from issues/PLAN68_snapshot-series-design.md rename to issues/old/PLAN68_snapshot-series-design.md diff --git a/issues/PLAN68_snapshot-series.md b/issues/old/PLAN68_snapshot-series.md similarity index 88% rename from issues/PLAN68_snapshot-series.md rename to issues/old/PLAN68_snapshot-series.md index 9fdf3237..af3eb977 100644 --- a/issues/PLAN68_snapshot-series.md +++ b/issues/old/PLAN68_snapshot-series.md @@ -202,7 +202,28 @@ default(差分 0、3.9 GB)で、合計は 42 GB である。 ## 実装計画 設計は [PLAN68_snapshot-series-design.md](PLAN68_snapshot-series-design.md)。 -**タスクへの分解は実装の持ち場で `/ndf:implementation-plan` が行う。** +タスクは受け入れ条件の単位で分け、どれも「失敗するテスト → 通す最小実装 → 整理」で進める。 + +### タスク分解 + +| # | タスク | 対象ファイル | 変更内容 | 満たす受け入れ条件 | +| --- | --- | --- | --- | --- | +| 1 | 世代の場所の検証 | `snapshot/manager.py`、`tests/snapshot/test_manager_series.py` | `_safe_snap_dir` にシンボリックリンクの拒否と `Path.is_relative_to` の包含判定を入れる(決定 7) | 26(`_safe_snap_dir`)・27・28 | +| 2 | 系列の解決と積み先 | 同上 | `series_key` / `series_label` / `_entry_volumes` / `series_latest` / `auto_snapshot_target` を足し、`should_start_new_generation` を包むだけにする | 1〜5 | +| 3 | 系列ごとの最小間隔 | 同上 | `last_snapshot_time(volumes=None)` | 6・7 | +| 4 | 系列ごとの保持と全体の上限 | 同上 | `rotate(keep, max_total)` を系列ごと + 全体の上限 + 各系列の最新を残す形へ。消す前に `_safe_snap_dir` で検証し、拒否されたエントリは一覧からだけ外す | 8〜14・17・22・25・26 | +| 5 | `_auto_snapshot` の流れ | `commands/container.py`、`tests/snapshot/test_auto_snapshot_series.py` | 最小間隔を系列で判定し、`auto_snapshot_target` の結果で `create` を呼ぶ。ログに系列の名前を入れる | 1・2・6・7・16 | +| 6 | CLI と TUI | `cli.py`、`commands/snapshot.py`、`tui/actions_snapshot.py`、`tests/cli/tui/test_actions_snapshot.py`、`tests/snapshot/test_manager_series.py` | `--max-total` の追加、`--keep` の help、振り分けの `getattr(args, 'max_total', None)`、TUI の問いの文言 | 14(CLI)・15・18 | +| 7 | 文書と CHANGELOG | 文書 4 本、`CHANGELOG.md` | 設計の「文書の変更」の表のとおり | 19〜21 | +| 8 | 全体の確認 | — | `uv run --locked pytest tests/ -q`、`ruff check --select=E9,F63,F7,F82 lib` | 23・24 | + +### リスクと対処 + +| リスク | 対処 | +| --- | --- | +| `manager.py`(818 行)に規則が集まる | 系列の解決は小さな関数に分け、`rotate` の削除候補の計算を副作用の無い補助に切り出す。構造は保ち、タスクごとにテストを通す | +| 既存テスト(`test_auto_snapshot.py` の `last_snapshot_time`、`test_manager_volumes.py`)の退行 | 引数の既定値で現行の振る舞いを保ち、タスクごとに `tests/snapshot/` を回す | +| 実際の tar による、別グループを挟んだ差分の復元が未確認 | この持ち場ではコンテナを起動しない(並行する #253 の検査と重ねない)。検査の持ち場へ回し、Pull Request 本文に書く | ### 修正対象 diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index e6c0c34f..288290e0 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -713,7 +713,11 @@ def _add_snapshot_parser(subparsers): s_delete.add_argument('name', help='Snapshot name') s_rotate = ss_sub.add_parser('rotate', help='Rotate old snapshots') - s_rotate.add_argument('--keep', type=int, default=3, help='Generations to keep') + s_rotate.add_argument('--keep', type=int, default=3, + help='Generations to keep per account group') + s_rotate.add_argument('--max-total', type=int, default=None, metavar='M', + help='Upper limit of generations across all groups ' + '(default: 3 x --keep)') def _add_shortcuts(subparsers): diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index f5901a71..60fb58e6 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -803,8 +803,29 @@ def _snapshot_min_interval_minutes() -> int: ) +def _should_skip_by_interval( + label: str, min_interval: int, last: Optional[datetime], +) -> bool: + """系列の直近の取得から最小間隔内なら、理由を記録してスキップする。""" + from datetime import datetime, timedelta, timezone + + if min_interval > 0 and last is not None: + # 経過時間が負 (last が未来) の場合はスキップしない。システム時計の + # ズレや他環境からのリストアで last が未来になると delta が負になり、 + # 常に閾値未満と判定されて無期限にスキップされてしまうため、 + # timedelta(0) <= delta の下限ガードを設ける。 + delta = datetime.now(timezone.utc) - last + if timedelta(0) <= delta < timedelta(minutes=min_interval): + logger.info( + "[0/6] %s の直近のスナップショット (%s) から%d分以内のためスキップします", + label, last.astimezone().strftime('%Y-%m-%d %H:%M:%S'), min_interval, + ) + return True + return False + + def _auto_snapshot(remote: bool = False) -> None: - """デプロイ前の自動スナップショット (差分世代数ベース世代管理)。 + """デプロイ前の自動スナップショット (系列ごとの差分世代数ベース世代管理。PLAN68)。 失敗してもデプロイは続行する (warning のみ)。DEVBASE_ROOT 未設定なら no-op。 リモート扱い (PLAN52 決定 12) では作らない。控えたいボリュームがリモートにあり、 @@ -820,31 +841,22 @@ def _auto_snapshot(remote: bool = False) -> None: if not devbase_root: return try: - from datetime import datetime, timedelta, timezone - from devbase.snapshot.manager import SnapshotManager mgr = SnapshotManager(Path(devbase_root)) + # 最小間隔・積み先はどちらも系列 (起動するグループのボリュームの組) の + # 単位で判定する (PLAN68 決定 2・4) + label = mgr.series_label(mgr.volumes) min_interval = _snapshot_min_interval_minutes() - last = mgr.last_snapshot_time() - if min_interval > 0 and last is not None: - # 経過時間が負 (last が未来) の場合はスキップしない。システム時計の - # ズレや他環境からのリストアで last が未来になると delta が負になり、 - # 常に閾値未満と判定されて無期限にスキップされてしまうため、 - # timedelta(0) <= delta の下限ガードを設ける。 - delta = datetime.now(timezone.utc) - last - if timedelta(0) <= delta < timedelta(minutes=min_interval): - logger.info( - "[0/6] 直近のスナップショット (%s) から%d分以内のためスキップします", - last.astimezone().strftime('%Y-%m-%d %H:%M:%S'), min_interval, - ) - return - if mgr.should_start_new_generation(): - logger.info("[0/6] 新しいスナップショット世代を作成中...") + last = mgr.last_snapshot_time(mgr.volumes) + if _should_skip_by_interval(label, min_interval, last): + return + target = mgr.auto_snapshot_target() + if target is None: + logger.info("[0/6] 新しいスナップショット世代を作成中 (%s)...", label) mgr.create() else: - latest = mgr.list()[-1]['name'] - logger.info("[0/6] スナップショットを差分更新中: %s", latest) - mgr.create(name=latest, full=False) + logger.info("[0/6] スナップショットを差分更新中: %s (%s)", target, label) + mgr.create(name=target, full=False) mgr.rotate() except Exception as e: logger.warning("スナップショットの自動作成に失敗しましたがデプロイは続行します: %s", e) diff --git a/lib/devbase/commands/snapshot.py b/lib/devbase/commands/snapshot.py index d24f208c..b0ce330e 100644 --- a/lib/devbase/commands/snapshot.py +++ b/lib/devbase/commands/snapshot.py @@ -36,7 +36,9 @@ def cmd_snapshot(devbase_root: Path, args) -> int: name=getattr(args, 'name', ''), new_name=getattr(args, 'new_name', '')), 'delete': lambda: _snapshot_delete(mgr, name=getattr(args, 'name', '')), - 'rotate': lambda: _snapshot_rotate(mgr, keep=getattr(args, 'keep', 3)), + # TUI の dispatch_group は keep だけを持つ引数を渡すため getattr で受ける + 'rotate': lambda: _snapshot_rotate(mgr, keep=getattr(args, 'keep', 3), + max_total=getattr(args, 'max_total', None)), } handler = handlers.get(subcmd) @@ -101,8 +103,8 @@ def _snapshot_delete(mgr, name='') -> int: return 0 -def _snapshot_rotate(mgr, keep=3) -> int: - deleted = mgr.rotate(keep=keep) +def _snapshot_rotate(mgr, keep=3, max_total=None) -> int: + deleted = mgr.rotate(keep=keep, max_total=max_total) if deleted == 0: logger.info("ローテーション不要です") return 0 diff --git a/lib/devbase/snapshot/manager.py b/lib/devbase/snapshot/manager.py index ada680e6..a2da809e 100644 --- a/lib/devbase/snapshot/manager.py +++ b/lib/devbase/snapshot/manager.py @@ -4,7 +4,7 @@ import shlex import shutil import subprocess -from datetime import datetime, timezone +from datetime import date, datetime, timezone from pathlib import Path from typing import Optional @@ -69,6 +69,10 @@ def rename_only_failure(stderr: str) -> Optional[list]: # 失敗した rename が大量に出うるので、余裕をもって分割する。 _CHECK_COMMAND_BUDGET = 60_000 +# ローテーションで世代を消す理由。系列ごとの保持数を超えた分か、全体の上限を超えた分か。 +_REASON_PER_SERIES = 'series' +_REASON_TOTAL = 'total' + def chunk_paths(paths: list, budget: int = _CHECK_COMMAND_BUDGET) -> list: """引用済みのパスを、1 コマンドの長さが budget を超えないように分ける。 @@ -158,10 +162,26 @@ def _validate_name(name: str) -> None: ) def _safe_snap_dir(self, name: str) -> Path: - """名前からスナップショットディレクトリを安全に解決する""" + """名前からスナップショットディレクトリを安全に解決する。 + + 次の 3 つを ``SnapshotError`` で止める (PLAN68 決定 7)。 + + - 名前が不正 (``../outside`` など) + - 世代がシンボリックリンク。devbase はリンクの世代を作らず、リンク先が + ``backups/`` の外でも中の別の世代でも、消すと実体を失う + - 解決後のパスが ``backups/`` の中に無い。文字列の前方一致ではなく + パスの要素の単位で比べる (兄弟の ``backups-outside/`` を通さないため) + + ``backups/`` 自体をリンクにした構成は、解決後の ``backups/`` と比べるため使える。 + """ self._validate_name(name) - snap_dir = (self.backups_dir / name).resolve() - if not str(snap_dir).startswith(str(self.backups_dir.resolve())): + raw = self.backups_dir / name + if raw.is_symlink(): + raise SnapshotError( + f"スナップショット '{name}' はシンボリックリンクのため扱えません: " + f"{raw} -> {raw.readlink()}") + snap_dir = raw.resolve() + if not snap_dir.is_relative_to(self.backups_dir.resolve()): raise SnapshotError(f"無効なスナップショットパス: '{name}'") return snap_dir @@ -208,9 +228,12 @@ def list(self) -> list[dict]: snap['size_bytes'] = 0 return snapshots - def last_snapshot_time(self) -> Optional[datetime]: + def last_snapshot_time(self, volumes: Optional[dict] = None) -> Optional[datetime]: """直近のスナップショット取得 (フル/差分) 日時を返す。 + ``volumes`` を渡すと、その組の系列に属する世代 (``snapshot.yml`` のエントリ) + のディレクトリだけを見る (PLAN68 決定 4)。省けば全ディレクトリを見る。 + 各スナップショットディレクトリ内のアーカイブ実体 (``full.tar.zst`` / ``incr-*.tar.zst``) の mtime のうち最新のものを採用する。 差分更新は既存ディレクトリ名を再利用するため (ディレクトリ名の日付は世代 @@ -226,9 +249,16 @@ def last_snapshot_time(self) -> Optional[datetime]: """ if not self.backups_dir.exists(): return None + if volumes is None: + snap_dirs = list(self.backups_dir.iterdir()) + else: + snap_dirs = [ + self.backups_dir / s['name'] for _, s in self._series_entries(volumes) + if is_single_segment_name(s['name']) + ] latest: Optional[float] = None - for snap_dir in self.backups_dir.iterdir(): - if not snap_dir.is_dir(): + for snap_dir in snap_dirs: + if snap_dir.is_symlink() or not snap_dir.is_dir(): continue for f in snap_dir.iterdir(): if not f.is_file(): @@ -448,69 +478,220 @@ def delete(self, name: str) -> None: self._save_metadata(meta) logger.info("削除完了: %s", name) - def rotate(self, keep: int = DEFAULT_MAX_GENERATIONS) -> int: - """古い世代を削除する。 + def rotate(self, keep: int = DEFAULT_MAX_GENERATIONS, + max_total: Optional[int] = None) -> int: + """古い世代を削除する (PLAN68 決定 1・6・7)。 + + 系列 (対象ボリュームの組) ごとに ``keep`` 世代を残し、残りの総数が + ``max_total`` (省けば ``keep × 3``) を超えたら、系列をまたいで最も古い + 世代から消す。**各系列の最新の世代は消さない** (次の差分の積み先のため)。 + 消す前に :meth:`_safe_snap_dir` で名前を検証し、拒否されたエントリは + ディレクトリを消さずに一覧からだけ外す。 + + Args: + keep: 系列ごとに残す世代の数 + max_total: 全体で残す世代の上限。省けば ``keep × 3`` Returns: 削除された世代数 + + Raises: + SnapshotError: ``keep`` か ``max_total`` が 1 未満の場合 (何も消さない) """ - meta = self._load_metadata() - snapshots = meta.get('snapshots', []) + if max_total is None: + max_total = keep * 3 + if keep < 1: + raise SnapshotError(f"--keep は 1 以上である必要があります: {keep}") + if max_total < 1: + raise SnapshotError(f"--max-total は 1 以上である必要があります: {max_total}") - if len(snapshots) <= keep: + meta = self._load_metadata() + snapshots = meta.get('snapshots', []) or [] + plan = self._rotation_plan(snapshots, keep, max_total) + if not plan: return 0 - # 古い順にソート(created_atベース) - snapshots.sort(key=lambda s: s.get('created_at', '')) - to_delete = snapshots[:-keep] - - deleted = 0 - for snap in to_delete: - snap_dir = self.backups_dir / snap['name'] + removed_ids = set() + deleted_ids = set() + for index, reason in plan: + snap = snapshots[index] + name = snap.get('name', '') + removed_ids.add(index) + try: + snap_dir = self._safe_snap_dir(name) + except SnapshotError as e: + logger.warning( + "snapshot.yml の世代 '%s' は場所が不正なため、ディレクトリを消さずに" + "一覧からだけ外します: %s", name, e) + continue if snap_dir.exists(): shutil.rmtree(snap_dir) - deleted += 1 + deleted_ids.add(index) + if reason == _REASON_TOTAL: + logger.info( + "ローテーション: 全体の上限 %d 世代を超えたため、%s の %s を削除しました", + max_total, self.series_label(self._entry_volumes(snap)), name) + + per_series: dict = {} + for index, reason in plan: + if reason == _REASON_PER_SERIES and index in deleted_ids: + label = self.series_label(self._entry_volumes(snapshots[index])) + per_series[label] = per_series.get(label, 0) + 1 + for label, count in per_series.items(): + logger.info( + "ローテーション: %s の %d 世代を削除しました(グループごとに %d 世代保持)", + label, count, keep) - meta['snapshots'] = snapshots[-keep:] + remaining = [s for i, s in enumerate(snapshots) if i not in removed_ids] + order = {id(s): i for i, s in enumerate(snapshots)} + remaining.sort(key=lambda s: self._entry_age(s, order[id(s)])) + meta['snapshots'] = remaining meta['max_generations'] = keep self._save_metadata(meta) + return len(deleted_ids) - if deleted: - logger.info("ローテーション: %d 世代を削除しました(%d 世代保持)", deleted, keep) - return deleted + def _rotation_plan(self, snapshots: list, keep: int, max_total: int) -> list: + """ローテーションで消すエントリを決める (副作用なし)。 - def should_start_new_generation( - self, max_incrementals: int = DEFAULT_MAX_INCREMENTALS, - ) -> bool: - """最新世代の差分バックアップ数が上限に達しているか判定する。 + Returns: + ``(snapshots の添字, 理由)`` の並び。理由は系列ごとの保持なら + ``_REASON_PER_SERIES``、全体の上限なら ``_REASON_TOTAL``。 + """ + groups: dict = {} + for index, snap in enumerate(snapshots): + key = self.series_key(self._entry_volumes(snap)) + groups.setdefault(key, []).append(index) + + def age(index: int) -> tuple: + return self._entry_age(snapshots[index], index) + + plan: list = [] + kept: dict = {} + for key, indexes in groups.items(): + indexes.sort(key=age) + excess = max(0, len(indexes) - keep) + plan.extend((i, _REASON_PER_SERIES) for i in indexes[:excess]) + kept[key] = indexes[excess:] + + total = sum(len(v) for v in kept.values()) + while total > max_total: + candidates = [v for v in kept.values() if len(v) >= 2] + if not candidates: + logger.warning( + "全体の上限 %d 世代を超えていますが、各グループの最新の世代は" + "消さないため %d 世代を残します", max_total, total) + break + oldest = min(candidates, key=lambda v: age(v[0])) + plan.append((oldest.pop(0), _REASON_TOTAL)) + total -= 1 + return plan - Args: - max_incrementals: 1世代あたりの最大差分バックアップ数 + # ------------------------------------------------------------------ + # 系列 (PLAN68) + # ------------------------------------------------------------------ + + @staticmethod + def _entry_age(entry: dict, index: int) -> tuple: + # created_at が同じなら snapshot.yml で前にあるものを古いとみなす。 + # 引用符なしの日時は YAML が datetime で返すため、文字列に揃えて比べる + created = entry.get('created_at') or '' + if isinstance(created, date): # datetime も date の派生 + created = created.isoformat() + return (str(created), index) + + @staticmethod + def _entry_volumes(entry: dict) -> dict: + """``snapshot.yml`` のエントリの対象ボリュームの組。 + + ``volumes`` が無い・空・dict でないエントリは PLAN39 より前の旧レイアウトで、 + 共通ボリューム 1 本の組とみなす。値は検証しない (系列のキーにするだけで、 + マウントには使わないため)。 + """ + volumes = entry.get('volumes') if isinstance(entry, dict) else None + if isinstance(volumes, dict) and volumes: + return dict(volumes) + return {'': HOME_UBUNTU_VOLUME} + + @staticmethod + def series_key(volumes: dict) -> tuple: + """系列の識別子。対象ボリュームの組を並べ替えたタプル。""" + return tuple(sorted((str(k), str(v)) for k, v in volumes.items())) + + @staticmethod + def series_label(volumes: dict) -> str: + """系列の表示名 (ログ用)。例: ``グループ default``。""" + group = volumes.get(GROUP_MOUNT) + if isinstance(group, str) and group: + if group.startswith(SHARED_VOLUME_PREFIX): + group = group[len(SHARED_VOLUME_PREFIX):] + return f"グループ {group}" + return "旧レイアウト(共通ボリュームのみ)" + + def _entries(self) -> list: + return [s for s in (self._load_metadata().get('snapshots') or []) + if isinstance(s, dict) and 'name' in s] + + def _series_entries(self, volumes: dict) -> "list[tuple[int, dict]]": + """対象ボリュームの系列に属するエントリを元の添字とともに返す。""" + key = self.series_key(volumes) + return [ + (index, snap) for index, snap in enumerate(self._entries()) + if self.series_key(self._entry_volumes(snap)) == key + ] + + def series_latest(self, volumes: Optional[dict] = None) -> Optional[dict]: + """系列の最新の世代のエントリ (``created_at`` が最大)。無ければ ``None``。 + + ``created_at`` が同じなら ``snapshot.yml`` で後ろのものを新しいとみなす。 + """ + latest = None + latest_age = None + for index, snap in self._series_entries(self.volumes if volumes is None else volumes): + age = self._entry_age(snap, index) + if latest_age is None or age > latest_age: + latest, latest_age = snap, age + return latest + + def auto_snapshot_target( + self, max_incrementals: int = DEFAULT_MAX_INCREMENTALS, + ) -> Optional[str]: + """自動スナップショットの積み先を返す (PLAN68 決定 2)。 Returns: - True: 新世代を作成すべき(スナップショットなし or 差分数が上限以上) - False: 既存世代に差分を追加すべき + 系列の最新の世代の名前。新しい世代を作るべきなら ``None`` + (その理由を INFO で 1 行出す。最新の世代がシンボリックリンクか + 不正な名前なら WARNING)。 """ - meta = self._load_metadata() - snapshots = meta.get('snapshots', []) - if not snapshots: - return True - latest = snapshots[-1] - - # 対象ボリュームの構成が変わったら新世代にする (PLAN39 の移行やグループ - # 切替)。旧世代の snar は別のレイアウトを記録しているので、そこへ差分を - # 積むと全ファイルが移動したものとして扱われ差分が壊れる。世代を分ければ - # 旧世代はそのまま復元できる。 - snap_dir = self.backups_dir / latest.get('name', '') - if snap_dir.is_dir() and self.snapshot_volumes(snap_dir) != self.volumes: - logger.info( - "対象ボリュームの構成が変わったため新しい世代を作成します " - "(旧: %s / 新: %s)", - ', '.join(self.snapshot_volumes(snap_dir).values()), - ', '.join(self.volumes.values())) - return True + label = self.series_label(self.volumes) + latest = self.series_latest() + if latest is None: + logger.info("%s の世代がまだ無いため、新しい世代を作成します", label) + return None - return latest.get('incremental_count', 0) >= max_incrementals + name = latest['name'] + try: + # create と同じ検証を先に通す。通らない世代へ積もうとすると、rotate が + # 系列の最新を消さないため、起動のたびに同じ失敗を繰り返す (決定 7) + snap_dir = self._safe_snap_dir(name) + except SnapshotError as e: + logger.warning( + "%s の最新の世代 '%s' は扱えないため、新しい世代を作成します: %s", + label, name, e) + return None + if snap_dir.is_dir(): + recorded = self.snapshot_volumes(snap_dir) + if recorded != self.volumes: + logger.info( + "世代 %s の meta.yml の対象ボリューム (%s) が %s と一致しないため、" + "新しい世代を作成します", name, ', '.join(recorded.values()), label) + return None + + if latest.get('incremental_count', 0) >= max_incrementals: + logger.info( + "世代 %s(%s)の差分が上限 (%d) に達したため、新しい世代を作成します", + name, label, max_incrementals) + return None + return name # ------------------------------------------------------------------ # Private helpers @@ -647,7 +828,7 @@ def _create_incremental(self, name: str, snap_dir: Path) -> None: """差分バックアップを作成""" recorded = self.snapshot_volumes(snap_dir) if recorded != self.volumes: - # 通常はここへ来ない (should_start_new_generation が新世代へ倒す)。 + # 通常はここへ来ない (auto_snapshot_target が新世代へ倒す)。 # 明示的に古い世代を指定されたときだけ到達する。黙って壊れた差分を # 積むより、理由を出して止める方がよい。 raise SnapshotError( diff --git a/lib/devbase/tui/actions_snapshot.py b/lib/devbase/tui/actions_snapshot.py index b863b807..1b3434bb 100644 --- a/lib/devbase/tui/actions_snapshot.py +++ b/lib/devbase/tui/actions_snapshot.py @@ -9,7 +9,7 @@ - restore: ``name``, ``point`` (None=全差分適用 / manager は 1 以上のみ受理) - copy: ``name``, ``new_name`` - delete: ``name`` -- rotate: ``keep`` (3) +- rotate: ``keep`` (3。グループごと。``max_total`` は渡さず既定の ``keep × 3``) 破壊的な restore / delete は実行前に確認する (plan 3.4)。restore は ``cmd_snapshot`` 側にも TTY 時の input() 確認が残るが、TUI の規約として @@ -143,8 +143,10 @@ def _op_delete(devbase_root: Path): def _op_rotate(devbase_root: Path): - # keep=0 は manager 実装上 no-op (空スライス) のため 1 以上を要求する。 - keep = flow.need(menu.integer("保持する世代数 (--keep)", default=3, min_value=1)) + # keep=0 は manager が SnapshotError で拒むため 1 以上を要求する。 + # 全体の上限 (--max-total) は問わず、既定 (keep × 3) で動かす (PLAN68)。 + keep = flow.need(menu.integer("グループごとに保持する世代数 (--keep)", + default=3, min_value=1)) return dispatch_group(cmd_snapshot, devbase_root, "rotate", keep=keep) diff --git a/tests/cli/test_snapshot_rotate_args.py b/tests/cli/test_snapshot_rotate_args.py new file mode 100644 index 00000000..a89ce301 --- /dev/null +++ b/tests/cli/test_snapshot_rotate_args.py @@ -0,0 +1,48 @@ +"""snapshot rotate の引数解析の現状固定テスト。""" + +from devbase.cli import _create_parser + + +def test_rotate_explicit_limits(): + args = _create_parser().parse_args( + ["snapshot", "rotate", "--keep", "2", "--max-total", "5"] + ) + + assert args.subcommand == "rotate" + assert args.keep == 2 + assert type(args.keep) is int + assert args.max_total == 5 + assert type(args.max_total) is int + + +def test_rotate_default_limits(): + args = _create_parser().parse_args(["snapshot", "rotate"]) + + assert args.subcommand == "rotate" + assert args.keep == 3 + assert args.max_total is None + + +def test_rotate_alias_max_total(): + args = _create_parser().parse_args(["ss", "rotate", "--max-total", "4"]) + + assert args.subcommand == "rotate" + assert args.max_total == 4 + assert type(args.max_total) is int + + +def test_restore_name_and_point(): + args = _create_parser().parse_args(["snapshot", "restore", "daily", "--point", "2"]) + + assert args.subcommand == "restore" + assert args.name == "daily" + assert args.point == 2 + assert type(args.point) is int + + +def test_restore_default_point(): + args = _create_parser().parse_args(["snapshot", "restore", "daily"]) + + assert args.subcommand == "restore" + assert args.name == "daily" + assert args.point is None diff --git a/tests/cli/tui/test_actions_snapshot.py b/tests/cli/tui/test_actions_snapshot.py index 0804a162..61c43240 100644 --- a/tests/cli/tui/test_actions_snapshot.py +++ b/tests/cli/tui/test_actions_snapshot.py @@ -44,7 +44,7 @@ def _capture_dispatch(monkeypatch): def _spy(devbase_root, args): captured["devbase_root"] = devbase_root captured["subcommand"] = args.subcommand - for k in ("name", "full", "point", "new_name", "keep"): + for k in ("name", "full", "point", "new_name", "keep", "max_total"): if hasattr(args, k): captured[k] = getattr(args, k) return 0 @@ -314,14 +314,17 @@ def test_run_operation_rotate_collects_keep(monkeypatch, tmp_path): seen = {} def fake_integer(message, *, default=None, min_value=None, max_value=None): - seen.update(default=default, min_value=min_value) + seen.update(message=message, default=default, min_value=min_value) return 5 monkeypatch.setattr(menu, "integer", fake_integer) assert actions_snapshot._run_operation(tmp_path, "rotate") == 0 assert captured["subcommand"] == "rotate" and captured["keep"] == 5 - # CLI 既定 (--keep 3) と同じ既定値を提示し、no-op な 0 以下は弾く。 - assert seen == {"default": 3, "min_value": 1} + # 全体の上限は問わない (既定の keep × 3 で動く。PLAN68)。 + assert "max_total" not in captured + # CLI 既定 (--keep 3) と同じ既定値を提示し、0 以下は弾く。問いはグループごと。 + assert seen == {"message": "グループごとに保持する世代数 (--keep)", + "default": 3, "min_value": 1} @pytest.mark.parametrize("int_ret", ["BACK", None]) diff --git a/tests/commands/test_container_down_snapshot.py b/tests/commands/test_container_down_snapshot.py new file mode 100644 index 00000000..f78f51a0 --- /dev/null +++ b/tests/commands/test_container_down_snapshot.py @@ -0,0 +1,39 @@ +"""``cmd_down`` の停止後のローテーションが失敗したときの現状固定テスト。 + +停止は疑似コンテナの状態を変えるだけにし、Docker と実データの ``backups/`` に触らない。 +""" + +from __future__ import annotations + +import pytest + +from devbase.commands import container +from devbase.env import runtime as secret_runtime +from devbase.snapshot.manager import SnapshotManager + + +@pytest.fixture +def stopped(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) + monkeypatch.setattr(container, '_apply_context', lambda context: None) + monkeypatch.setattr(container, '_inject_secrets', + lambda *, required: secret_runtime.SecretEnv()) + state = {'dev-1': 'running'} + + def fake_down(compose_file=None): + state['dev-1'] = 'stopped' + + monkeypatch.setattr(container, 'docker_compose_down', fake_down) + return state + + +def test_rotate_failure_after_stop_returns_zero(stopped, monkeypatch): + """現状固定: ローテーションの例外は吸収し、停止済みのまま 0 を返す。""" + def boom(self, *a, **k): + raise RuntimeError("rotate failed") + + monkeypatch.setattr(SnapshotManager, 'rotate', boom) + + assert container.cmd_down() == 0 + assert stopped == {'dev-1': 'stopped'} diff --git a/tests/commands/test_snapshot.py b/tests/commands/test_snapshot.py new file mode 100644 index 00000000..d4171ad2 --- /dev/null +++ b/tests/commands/test_snapshot.py @@ -0,0 +1,42 @@ +"""復元の確認分岐を、疑似ボリュームの最終状態で現状固定する。""" + +from types import SimpleNamespace + +import pytest + +from devbase.commands import snapshot + + +@pytest.mark.parametrize( + "is_tty,answer,expected_content", + [ + (True, "", "original"), + (True, "n", "original"), + (True, "y", "saved at point 2"), + (True, "YES", "saved at point 2"), + (False, None, "saved at point 2"), + ], +) +def test_restore_confirmation(tmp_path, monkeypatch, is_tty, answer, expected_content): + volume = {"content": "original"} + saved = {("daily", 2): "saved at point 2"} + + class FakeSnapshotManager: + def __init__(self, devbase_root): + pass + + def restore(self, name, point=None): + volume["content"] = saved[name, point] + + def respond(prompt): + if not is_tty: + pytest.fail("Non-TTY restore must not prompt for input") + return answer + + monkeypatch.setattr(snapshot, "SnapshotManager", FakeSnapshotManager) + monkeypatch.setattr(snapshot.sys, "stdin", SimpleNamespace(isatty=lambda: is_tty)) + monkeypatch.setattr("builtins.input", respond) + args = SimpleNamespace(subcommand="restore", name="daily", point=2) + + assert snapshot.cmd_snapshot(tmp_path, args) == 0 + assert volume["content"] == expected_content diff --git a/tests/commands/test_snapshot_dispatch.py b/tests/commands/test_snapshot_dispatch.py new file mode 100644 index 00000000..106352fd --- /dev/null +++ b/tests/commands/test_snapshot_dispatch.py @@ -0,0 +1,28 @@ +"""未指定・未知の snapshot サブコマンドの終了コードを現状固定する。""" + +from types import SimpleNamespace + +import pytest + +from devbase.commands.snapshot import cmd_snapshot + + +@pytest.mark.parametrize("subcommand", [None, "bogus"]) +def test_missing_or_unknown_subcommand_returns_error(tmp_path, subcommand): + args = SimpleNamespace(subcommand=subcommand) + + assert cmd_snapshot(tmp_path, args) == 1 + + +def test_handler_snapshot_error_returns_error(tmp_path, monkeypatch): + """handler が SnapshotError を送出すると、捕えて 1 を返す (未指定・未知とは別経路)。""" + from devbase.errors import SnapshotError + from devbase.snapshot.manager import SnapshotManager + + def boom(self, name, new_name): + raise SnapshotError("copy failed") + + monkeypatch.setattr(SnapshotManager, 'copy', boom) + args = SimpleNamespace(subcommand="copy", name="a", new_name="b") + + assert cmd_snapshot(tmp_path, args) == 1 diff --git a/tests/snapshot/test_auto_snapshot_series.py b/tests/snapshot/test_auto_snapshot_series.py new file mode 100644 index 00000000..a1d2d182 --- /dev/null +++ b/tests/snapshot/test_auto_snapshot_series.py @@ -0,0 +1,159 @@ +"""``devbase up`` の自動スナップショットを系列で扱う (PLAN68) + +``DEVBASE_ROOT`` を ``tmp_path`` に向け、``SnapshotManager._run_docker_tar`` を +差し替える。実データの ``backups/`` と Docker に触らない。 +""" + +from __future__ import annotations + +import logging +import os +import re +import time + +import pytest +import yaml + +from devbase.commands import container +from devbase.snapshot.manager import SnapshotManager + +from .test_manager_series import names, write_state + + +@pytest.fixture +def root(tmp_path, monkeypatch): + monkeypatch.setenv("DEVBASE_ROOT", str(tmp_path)) + monkeypatch.setenv("DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES", "0") + monkeypatch.delenv("DEVBASE_ACCOUNT_GROUP", raising=False) + 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") + + monkeypatch.setattr(SnapshotManager, "_run_docker_tar", fake) + return tmp_path + + +def _age(path, seconds): + t = time.time() - seconds + os.utime(path, (t, t)) + + +def test_returning_group_appends_to_its_generation(root, caplog): + """1・16: default → with → default で、default の世代へ incr を積む。""" + backups = write_state(root, [("A", "default", 0), ("B", "with", 0)]) + + with caplog.at_level(logging.INFO, logger="devbase"): + container._auto_snapshot() + + assert (backups / "A" / "incr-001.tar.zst").exists() + assert not (backups / "B" / "incr-001.tar.zst").exists() + assert names(root) == ["A", "B"] + messages = [r.getMessage() for r in caplog.records] + assert not any("構成が変わった" in m for m in messages) + assert any("差分更新中: A (グループ default)" in m for m in messages) + + +def test_group_without_generation_creates_new(root, monkeypatch, caplog): + """2・16: with の世代が無ければ full の世代を作り、理由を出す。""" + write_state(root, [("A", "default", 0)]) + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "with") + + with caplog.at_level(logging.INFO, logger="devbase"): + container._auto_snapshot() + + entries = yaml.safe_load((root / "backups" / "snapshot.yml").read_text())["snapshots"] + assert len(entries) == 2 + new = entries[-1] + assert new["volumes"]["group"] == "devbase_home_with" + assert (root / "backups" / new["name"] / "full.tar.zst").exists() + messages = [r.getMessage() for r in caplog.records] + assert any("グループ with の世代がまだ無いため" in m for m in messages) + assert any("新しいスナップショット世代を作成中 (グループ with)" in m for m in messages) + + +def test_min_interval_is_per_series(root, monkeypatch, caplog): + """6: default は 10 分前なら飛ばし、with は 2 時間前なので積む。""" + backups = write_state(root, [("W", "with", 0), ("D", "default", 0)]) + _age(backups / "D" / "full.tar.zst", 600) + _age(backups / "W" / "full.tar.zst", 7200) + monkeypatch.setenv("DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES", "60") + + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "with") + container._auto_snapshot() + assert (backups / "W" / "incr-001.tar.zst").exists() + + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "default") + with caplog.at_level(logging.INFO, logger="devbase"): + container._auto_snapshot() + assert not (backups / "D" / "incr-001.tar.zst").exists() + assert any("グループ default の直近のスナップショット" in r.getMessage() + and "スキップします" in r.getMessage() for r in caplog.records) + + +def test_zero_interval_never_skips(root): + """7: 間隔 0 なら 10 分前の系列でも積む。""" + backups = write_state(root, [("D", "default", 0)]) + _age(backups / "D" / "full.tar.zst", 600) + + container._auto_snapshot() + assert (backups / "D" / "incr-001.tar.zst").exists() + + +def test_future_last_snapshot_is_not_skipped(root, monkeypatch): + """現状固定: 直近の取得時刻が未来 (時計のずれ) なら、間隔内でも飛ばさず積む。""" + backups = write_state(root, [("D", "default", 0)]) + monkeypatch.setenv("DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES", "60") + _age(backups / "D" / "full.tar.zst", -600) + + container._auto_snapshot() + assert (backups / "D" / "incr-001.tar.zst").exists() + + +def test_new_generation_rotates_only_its_own_series(root, monkeypatch): + """現状固定: 作成の後の rotate() で、新世代を積んだ系列の最古だけが消える。 + + ``_auto_snapshot`` は「新世代の作成 → 既定の rotate()」を続けて呼ぶ。既定の + rotate() は系列ごとに ``max_generations`` (ここでは 3) 世代を残すため、default + 系列を 3 世代 (最新の差分数 10 で上限) と with 系列を 1 世代の状態から default + で呼ぶと、default は新世代が積まれて 4 世代 → 最古が 1 つ落ちて 3 世代に戻り、 + with の 1 世代はそのまま残る。作成とローテーションのつなぎ目を固定する。 + """ + backups = write_state(root, [ + ("D1", "default", 0), ("D2", "default", 0), ("D3", "default", 10), + ("W1", "with", 0)]) + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "default") + + container._auto_snapshot() + + entries = yaml.safe_load((backups / "snapshot.yml").read_text())["snapshots"] + # default 系列は 3 世代 (最古の D1 が落ち、新世代が 1 つ増えた)。 + default_series = [e["name"] for e in entries + if e.get("volumes", {}).get("group") == "devbase_home_default"] + with_series = [e["name"] for e in entries + if e.get("volumes", {}).get("group") == "devbase_home_with"] + assert len(default_series) == 3 + assert "D1" not in default_series + assert {"D2", "D3"} <= set(default_series) + new_names = set(default_series) - {"D2", "D3"} + assert len(new_names) == 1 # 新しく積まれた 1 世代 + assert with_series == ["W1"] # with の 1 世代はそのまま残る + + # 消えた最古 (D1) のディレクトリは無く、with (W1) のディレクトリは残る。 + assert not (backups / "D1").exists() + assert (backups / "W1" / "full.tar.zst").exists() + new_name = next(iter(new_names)) + assert (backups / new_name / "full.tar.zst").exists() + + +def test_invalid_group_warns_without_creating_metadata(root, monkeypatch, caplog): + """現状固定: ボリューム解決の失敗は警告に落とし、呼び出し元へ戻る。""" + monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "invalid/group") + + with caplog.at_level(logging.WARNING, logger="devbase"): + container._auto_snapshot() + + warnings = [record for record in caplog.records if record.levelno == logging.WARNING] + assert len(warnings) == 1 + assert not (root / "backups" / "snapshot.yml").exists() diff --git a/tests/snapshot/test_manager_series.py b/tests/snapshot/test_manager_series.py new file mode 100644 index 00000000..c7e0fcb7 --- /dev/null +++ b/tests/snapshot/test_manager_series.py @@ -0,0 +1,541 @@ +"""スナップショットの世代を、ボリュームの組ごとの系列で持つ (PLAN68) + +系列の解決・差分の積み先・系列ごとの最小間隔・系列ごとの保持と全体の上限、 +および世代の場所の検証 (決定 7) を固定する。Docker は起動しない +(``_run_docker_tar`` を差し替える。``test_manager_volumes.py`` と同じ流儀)。 +""" + +from __future__ import annotations + +import logging +import os +import re +import time +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 SnapshotError +from devbase.snapshot.manager import SnapshotManager + + +@pytest.fixture(autouse=True) +def _clean_group_env(monkeypatch): + monkeypatch.delenv("DEVBASE_ACCOUNT_GROUP", raising=False) + + +class RecordingManager(SnapshotManager): + """``docker run`` を実行せず、渡された引数だけを記録する。""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.calls: list[dict] = [] + + 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") + + +def vols(group: str) -> dict: + return {"ai": "devbase_home_ubuntu", "group": f"devbase_home_{group}"} + + +def write_state(root: Path, entries: list) -> Path: + """``snapshot.yml`` と各世代のディレクトリを直接書く。 + + entries は ``(名前, グループ | None, 差分数)`` の並び。``None`` は旧レイアウト + (``volumes`` を持たないエントリ)。並びの順に ``created_at`` を古い方から振る。 + """ + backups = root / "backups" + backups.mkdir(parents=True, exist_ok=True) + snaps = [] + for i, (name, group, incr) in enumerate(entries): + created = f"2026-09-01T00:{i:02d}:00" + snap_dir = backups / name + if not snap_dir.exists() and not snap_dir.is_symlink() and "/" not in name: + snap_dir.mkdir() + (snap_dir / "full.tar.zst").write_text("archive") + (snap_dir / "snapshot.snar").write_text("snar") + meta = {"name": name, "type": "full", "files": ["full.tar.zst"], + "incremental_count": incr} + if group is None: + meta["volume"] = "devbase_home_ubuntu" + else: + meta["volumes"] = vols(group) + (snap_dir / "meta.yml").write_text(yaml.safe_dump(meta)) + entry = {"name": name, "created_at": created, "updated_at": created, + "incremental_count": incr} + if group is not None: + entry["volumes"] = vols(group) + snaps.append(entry) + (backups / "snapshot.yml").write_text(yaml.safe_dump( + {"max_generations": 3, "snapshots": snaps})) + return backups + + +def names(root: Path) -> list: + data = yaml.safe_load((root / "backups" / "snapshot.yml").read_text()) + return [s["name"] for s in data["snapshots"]] + + +# --------------------------------------------------------------------------- +# 系列の解決 +# --------------------------------------------------------------------------- + +def test_series_label_names_the_group(): + assert SnapshotManager.series_label(vols("with")) == "グループ with" + assert SnapshotManager.series_label({"": "devbase_home_ubuntu"}) == \ + "旧レイアウト(共通ボリュームのみ)" + + +def test_series_key_ignores_order(): + a = {"ai": "devbase_home_ubuntu", "group": "devbase_home_x"} + b = {"group": "devbase_home_x", "ai": "devbase_home_ubuntu"} + assert SnapshotManager.series_key(a) == SnapshotManager.series_key(b) + + +# --------------------------------------------------------------------------- +# 差分の積み先 (受け入れ条件 1〜5) +# --------------------------------------------------------------------------- + +def test_returning_group_appends_to_its_latest_generation(tmp_path): + """1: default → with → default で、default の世代へ差分を積む。""" + RecordingManager(tmp_path, group="default").create(name="A") + RecordingManager(tmp_path, group="with").create(name="B") + + mgr = RecordingManager(tmp_path, group="default") + assert mgr.auto_snapshot_target() == "A" + assert (mgr.auto_snapshot_target() is None) is False + + +def test_group_without_generation_starts_a_new_one(tmp_path, caplog): + """2: 系列に世代が無ければ新しい世代にする。理由を出す。""" + write_state(tmp_path, [("A", "default", 0)]) + mgr = RecordingManager(tmp_path, group="with") + + with caplog.at_level(logging.INFO, logger="devbase"): + assert mgr.auto_snapshot_target() is None + assert any("グループ with の世代がまだ無いため" in r.getMessage() + for r in caplog.records) + + +def test_incremental_limit_is_counted_per_series(tmp_path, caplog): + """3: 差分の上限は系列ごと。他の系列の差分数を見ない。""" + write_state(tmp_path, [("W", "with", 0), ("D", "default", 10)]) + + with caplog.at_level(logging.INFO, logger="devbase"): + assert RecordingManager(tmp_path, group="default").auto_snapshot_target() is None + assert any("上限 (10) に達した" in r.getMessage() and "グループ default" in r.getMessage() + for r in caplog.records) + assert RecordingManager(tmp_path, group="with").auto_snapshot_target() == "W" + + +def test_legacy_generation_is_not_appended_to(tmp_path): + """4: 旧レイアウトの世代だけなら新しい世代にする。""" + write_state(tmp_path, [("old", None, 0)]) + assert RecordingManager(tmp_path).auto_snapshot_target() is None + + +def test_meta_mismatch_starts_a_new_generation(tmp_path, caplog): + """snapshot.yml と meta.yml の組が食い違えば積まない (判定の順 2)。""" + backups = write_state(tmp_path, [("D", "default", 0)]) + meta = yaml.safe_load((backups / "D" / "meta.yml").read_text()) + meta["volumes"] = vols("kkg") + (backups / "D" / "meta.yml").write_text(yaml.safe_dump(meta)) + + with caplog.at_level(logging.INFO, logger="devbase"): + assert RecordingManager(tmp_path, group="default").auto_snapshot_target() is None + assert any("meta.yml の対象ボリューム" in r.getMessage() for r in caplog.records) + + +def test_series_latest_uses_created_at(tmp_path): + write_state(tmp_path, [("D1", "default", 0), ("W", "with", 0), ("D2", "default", 0)]) + mgr = RecordingManager(tmp_path, group="default") + assert mgr.series_latest()["name"] == "D2" + assert mgr.series_latest(vols("with"))["name"] == "W" + assert mgr.series_latest(vols("kkg")) is None + + +def _unquote_created_at(backups: Path, *targets: str) -> None: + """指定した世代の ``created_at`` を引用符なしの YAML timestamp で書き直す。""" + path = backups / "snapshot.yml" + data = yaml.safe_load(path.read_text()) + for snap in data["snapshots"]: + if snap["name"] in targets: + snap["created_at"] = datetime.fromisoformat(snap["created_at"]) + path.write_text(yaml.safe_dump(data)) + + +def test_series_latest_accepts_yaml_timestamp(tmp_path): + """手で書いた snapshot.yml の引用符なしの日時が混ざっても比べられる。""" + backups = write_state(tmp_path, [("D1", "default", 0), ("D2", "default", 0)]) + _unquote_created_at(backups, "D1") + assert RecordingManager(tmp_path, group="default").series_latest()["name"] == "D2" + + +def test_rotate_accepts_yaml_timestamp(tmp_path): + backups = write_state(tmp_path, [ + ("D1", "default", 0), ("D2", "default", 0), ("D3", "default", 0), + ("D4", "default", 0)]) + _unquote_created_at(backups, "D2", "D4") + assert SnapshotManager(tmp_path).rotate() == 1 + assert names(tmp_path) == ["D2", "D3", "D4"] + + +class ArchiveRecordingManager(RecordingManager): + """書き込むアーカイブ名を command から拾う (full / incr-NNN を区別する)。""" + + 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") + + +def _entry(root: Path, name: str) -> dict: + data = yaml.safe_load((root / "backups" / "snapshot.yml").read_text()) + return next(s for s in data["snapshots"] if s["name"] == name) + + +def test_create_numbers_incrementals_in_order(tmp_path): + """現状固定: 2 本目以降の差分は incr-002 と番号を進め、差分数を両方の台帳へ書く。""" + mgr = ArchiveRecordingManager(tmp_path, group="default") + mgr.create(name="g") + mgr.create(name="g", full=False) + mgr.create(name="g", full=False) + + snap_dir = tmp_path / "backups" / "g" + assert sorted(p.name for p in snap_dir.glob("*.tar.zst")) == [ + "full.tar.zst", "incr-001.tar.zst", "incr-002.tar.zst"] + meta = yaml.safe_load((snap_dir / "meta.yml").read_text()) + assert meta["type"] == "incremental" + assert meta["incremental_count"] == 2 + assert meta["files"] == ["full.tar.zst", "incr-001.tar.zst", "incr-002.tar.zst"] + assert _entry(tmp_path, "g")["incremental_count"] == 2 + + +def test_create_without_snar_falls_back_to_full(tmp_path): + """現状固定: 既存世代に snapshot.snar が無ければ差分でなく full を作り直す。""" + mgr = ArchiveRecordingManager(tmp_path, group="default") + mgr.create(name="h") + mgr.create(name="h", full=False) + snap_dir = tmp_path / "backups" / "h" + (snap_dir / "snapshot.snar").unlink() + + mgr.create(name="h", full=False) + + assert "incr-002.tar.zst" not in [p.name for p in snap_dir.iterdir()] + assert "incr-002" not in mgr.calls[-1]["command"] + assert "/backup/full.tar.zst" in mgr.calls[-1]["command"] + meta = yaml.safe_load((snap_dir / "meta.yml").read_text()) + assert meta["type"] == "full" + assert meta["incremental_count"] == 0 + assert meta["files"] == ["full.tar.zst"] + assert _entry(tmp_path, "h")["incremental_count"] == 0 + + +# --------------------------------------------------------------------------- +# 系列ごとの最小間隔 (受け入れ条件 6) +# --------------------------------------------------------------------------- + +def test_last_snapshot_time_per_series(tmp_path): + backups = write_state(tmp_path, [("W", "with", 0), ("D", "default", 0)]) + now = time.time() + os.utime(backups / "D" / "full.tar.zst", (now - 600, now - 600)) + os.utime(backups / "W" / "full.tar.zst", (now - 7200, now - 7200)) + + mgr = SnapshotManager(tmp_path) + d = mgr.last_snapshot_time(vols("default")).timestamp() + w = mgr.last_snapshot_time(vols("with")).timestamp() + assert abs(d - (now - 600)) < 2 + assert abs(w - (now - 7200)) < 2 + assert mgr.last_snapshot_time(vols("kkg")) is None + # 省けば全体 (現行どおり) + assert abs(mgr.last_snapshot_time().timestamp() - (now - 600)) < 2 + + +# --------------------------------------------------------------------------- +# 保持 (受け入れ条件 8〜14・17・22) +# --------------------------------------------------------------------------- + +def test_rotate_keeps_per_series(tmp_path, caplog): + """8・17: default 4・with 1 で default の最古だけを消す。""" + backups = write_state(tmp_path, [ + ("D1", "default", 0), ("D2", "default", 0), ("W1", "with", 0), + ("D3", "default", 0), ("D4", "default", 0)]) + + with caplog.at_level(logging.INFO, logger="devbase"): + assert SnapshotManager(tmp_path).rotate() == 1 + assert names(tmp_path) == ["D2", "W1", "D3", "D4"] + assert not (backups / "D1").exists() + assert (backups / "W1").exists() + assert any("グループ default の 1 世代を削除しました" in r.getMessage() + for r in caplog.records) + + +def test_alternating_groups_keep_three_each(tmp_path): + """9: 交互に 4 つずつ作っても各 3 世代が残る。""" + entries = [] + for i in range(4): + entries += [(f"D{i}", "default", 0), (f"W{i}", "with", 0)] + write_state(tmp_path, entries) + + assert SnapshotManager(tmp_path).rotate() == 2 + assert names(tmp_path) == ["D1", "W1", "D2", "W2", "D3", "W3"] + + +def test_total_limit_removes_oldest_across_series(tmp_path, caplog): + """10・17: 4 系列 × 3 世代 (交互) で A1・B1・C1 を消す。""" + entries = [] + for i in (1, 2, 3): + for g in "abcd": + entries.append((f"{g.upper()}{i}", f"g{g}", 0)) + write_state(tmp_path, entries) + + with caplog.at_level(logging.INFO, logger="devbase"): + assert SnapshotManager(tmp_path).rotate() == 3 + remaining = names(tmp_path) + assert len(remaining) == 9 + assert not {"A1", "B1", "C1"} & set(remaining) + assert any("全体の上限 9 世代を超えたため、グループ ga の A1 を削除しました" + in r.getMessage() for r in caplog.records) + + +def test_total_limit_keeps_latest_of_each_series(tmp_path): + """11: A の 3 世代が最古でも、A の最新は残す。""" + entries = [] + for g in "abcd": + for i in (1, 2, 3): + entries.append((f"{g.upper()}{i}", f"g{g}", 0)) + write_state(tmp_path, entries) + + assert SnapshotManager(tmp_path).rotate() == 3 + remaining = names(tmp_path) + assert "A3" in remaining + assert not {"A1", "A2", "B1"} & set(remaining) + + +def test_total_limit_cannot_remove_series_latest(tmp_path, caplog): + """12: 10 系列 × 1 世代は消さず、警告を 1 行出す。""" + write_state(tmp_path, [(f"S{i}", f"g{i}x", 0) for i in range(10)]) + before = (tmp_path / "backups" / "snapshot.yml").read_bytes() + + with caplog.at_level(logging.INFO, logger="devbase"): + assert SnapshotManager(tmp_path).rotate(keep=3, max_total=9) == 0 + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "全体の上限 9 世代" in warnings[0].getMessage() + assert "10 世代を残します" in warnings[0].getMessage() + assert (tmp_path / "backups" / "snapshot.yml").read_bytes() == before + + +def test_legacy_series_is_counted_separately(tmp_path): + """13: 旧レイアウト 3 と default 3 は、どちらも消さない。""" + write_state(tmp_path, [("L1", None, 0), ("L2", None, 0), ("L3", None, 0), + ("D1", "default", 0), ("D2", "default", 0), ("D3", "default", 0)]) + assert SnapshotManager(tmp_path).rotate() == 0 + + +@pytest.mark.parametrize("kwargs", [{"keep": 0}, {"max_total": 0}, {"keep": -1}]) +def test_rotate_rejects_non_positive(tmp_path, kwargs): + """14: 0 以下は SnapshotError。何も消さない。""" + backups = write_state(tmp_path, [(f"D{i}", "default", 0) for i in range(5)]) + with pytest.raises(SnapshotError): + SnapshotManager(tmp_path).rotate(**kwargs) + assert all((backups / f"D{i}").exists() for i in range(5)) + + +@pytest.mark.parametrize("attrs", [{"keep": 0}, {"keep": 3, "max_total": 0}]) +def test_cli_rotate_rejects_non_positive(tmp_path, attrs): + write_state(tmp_path, [("D1", "default", 0)]) + ns = types.SimpleNamespace(subcommand="rotate", **attrs) + assert cmd_snapshot(tmp_path, ns) == 1 + + +def test_cli_rotate_passes_max_total(tmp_path): + """15: --max-total が manager へ渡る (既定の 6 なら 0 件になる)。""" + write_state(tmp_path, [("D1", "default", 0), ("W1", "with", 0), + ("D2", "default", 0), ("W2", "with", 0)]) + ns = types.SimpleNamespace(subcommand="rotate", keep=2, max_total=2) + assert cmd_snapshot(tmp_path, ns) == 0 + assert names(tmp_path) == ["D2", "W2"] + + +def test_cli_rotate_without_max_total_uses_keep_times_three(tmp_path, caplog): + """15・18: TUI と同じ keep だけの引数で動き、全体の上限は keep × 3。""" + write_state(tmp_path, [(f"S{i}", f"g{i}x", 0) for i in range(4)]) + ns = types.SimpleNamespace(subcommand="rotate", keep=1) + with caplog.at_level(logging.INFO, logger="devbase"): + assert cmd_snapshot(tmp_path, ns) == 0 + assert any("全体の上限 3 世代" in r.getMessage() for r in caplog.records + if r.levelno == logging.WARNING) + + +def test_existing_state_is_left_untouched(tmp_path): + """22: この端末と同じ 3 エントリで何も消さず、snapshot.yml を書かない。""" + backups = tmp_path / "backups" + backups.mkdir() + real = { + "max_generations": 3, + "snapshots": [ + {"name": "20260915-231738", "created_at": "2026-09-15T23:17:38.1", + "updated_at": "2026-09-22T10:00:00", "incremental_count": 9, + "volumes": vols("default")}, + {"name": "20260920-212546", "created_at": "2026-09-20T21:25:46.1", + "updated_at": "2026-09-20T21:25:46.1", "incremental_count": 0, + "volumes": vols("with")}, + {"name": "20260923-081407", "created_at": "2026-09-23T08:14:07.1", + "updated_at": "2026-09-23T08:14:07.1", "incremental_count": 0, + "volumes": vols("default")}, + ], + } + (backups / "snapshot.yml").write_text(yaml.safe_dump(real)) + before = (backups / "snapshot.yml").read_bytes() + + assert SnapshotManager(tmp_path).rotate() == 0 + assert (backups / "snapshot.yml").read_bytes() == before + + +# --------------------------------------------------------------------------- +# 世代の場所の検証 (受け入れ条件 25〜28、決定 7) +# --------------------------------------------------------------------------- + +def test_rotate_does_not_remove_outside_backups(tmp_path, caplog): + """25: ``../outside`` のエントリは一覧から外すだけで、外のディレクトリは残す。""" + outside = tmp_path / "outside" + outside.mkdir() + (outside / "keep.txt").write_text("keep") + write_state(tmp_path, [("../outside", "default", 0), ("D1", "default", 0), + ("D2", "default", 0), ("D3", "default", 0)]) + + with caplog.at_level(logging.INFO, logger="devbase"): + SnapshotManager(tmp_path).rotate() + assert (outside / "keep.txt").read_text() == "keep" + assert names(tmp_path) == ["D1", "D2", "D3"] + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 and "'../outside'" in warnings[0].getMessage() + + +def _link_outside(tmp_path: Path) -> Path: + outside = tmp_path / "backups-outside" + outside.mkdir() + (outside / "keep.txt").write_text("keep") + (tmp_path / "backups").mkdir(exist_ok=True) + (tmp_path / "backups" / "old").symlink_to(outside, target_is_directory=True) + return outside + + +def test_rotate_does_not_follow_symlink_outside(tmp_path, caplog): + """26: 兄弟の backups-outside/ を指すリンクの世代は、リンク先を消さない。""" + outside = _link_outside(tmp_path) + write_state(tmp_path, [("old", "default", 0), ("D1", "default", 0), + ("D2", "default", 0), ("D3", "default", 0)]) + + with caplog.at_level(logging.INFO, logger="devbase"): + SnapshotManager(tmp_path).rotate() + assert (outside / "keep.txt").read_text() == "keep" + assert names(tmp_path) == ["D1", "D2", "D3"] + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 and "'old'" in warnings[0].getMessage() + with pytest.raises(SnapshotError): + SnapshotManager(tmp_path)._safe_snap_dir("old") + + +def test_rotate_does_not_follow_symlink_inside(tmp_path, caplog): + """26: backups/ の中の系列の最新を指すリンクでも、その中身を消さない。""" + backups = write_state(tmp_path, [("D1", "default", 0), ("D2", "default", 0), + ("new", "default", 0)]) + (backups / "old").symlink_to(backups / "new", target_is_directory=True) + data = yaml.safe_load((backups / "snapshot.yml").read_text()) + data["snapshots"].insert(0, {"name": "old", "created_at": "2026-08-01T00:00:00", + "incremental_count": 0, "volumes": vols("default")}) + (backups / "snapshot.yml").write_text(yaml.safe_dump(data)) + + with caplog.at_level(logging.INFO, logger="devbase"): + SnapshotManager(tmp_path).rotate() + assert (backups / "new" / "full.tar.zst").read_text() == "archive" + assert names(tmp_path) == ["D1", "D2", "new"] + assert len([r for r in caplog.records if r.levelno == logging.WARNING]) == 1 + + +def test_safe_snap_dir_uses_path_components(tmp_path): + """決定 7: 兄弟の backups-outside/ は文字列の前方一致でも通さない。""" + _link_outside(tmp_path) + mgr = SnapshotManager(tmp_path) + with pytest.raises(SnapshotError): + mgr._safe_snap_dir("old") + + +def test_cli_delete_refuses_symlink(tmp_path): + """27: delete はリンクの世代を止め、リンク先を消さない。""" + outside = _link_outside(tmp_path) + write_state(tmp_path, [("old", "default", 0)]) + ns = types.SimpleNamespace(subcommand="delete", name="old") + assert cmd_snapshot(tmp_path, ns) == 1 + assert (outside / "keep.txt").read_text() == "keep" + + +@pytest.mark.parametrize("op", ["restore", "copy", "create"]) +def test_other_operations_refuse_symlink(tmp_path, op): + """28: restore / copy / create もリンクの世代で止まり、何も書かない。""" + outside = _link_outside(tmp_path) + write_state(tmp_path, [("old", "default", 0)]) + mgr = RecordingManager(tmp_path) + + with pytest.raises(SnapshotError): + if op == "restore": + mgr.restore("old") + elif op == "copy": + mgr.copy("old", "new") + else: + mgr.create(name="old") + assert mgr.calls == [] + assert not (tmp_path / "backups" / "new").exists() + assert sorted(p.name for p in outside.iterdir()) == ["keep.txt"] + + +def _bad_latest(tmp_path: Path, kind: str) -> tuple: + """系列 default の最新がリンクか ``../outside`` の状態を作る。(名前, 外の場所) を返す。""" + if kind == "symlink": + outside = _link_outside(tmp_path) + name = "old" + else: + outside = tmp_path / "outside" + outside.mkdir() + (outside / "keep.txt").write_text("keep") + name = "../outside" + write_state(tmp_path, [("D1", "default", 0), (name, "default", 0)]) + return name, outside + + +@pytest.mark.parametrize("kind", ["symlink", "traversal"]) +def test_bad_latest_starts_a_new_generation(tmp_path, caplog, kind): + """決定 7: 系列の最新が扱えない世代なら積まず、理由を WARNING で 1 行出す。""" + name, _ = _bad_latest(tmp_path, kind) + mgr = RecordingManager(tmp_path, group="default") + + with caplog.at_level(logging.INFO, logger="devbase"): + assert mgr.auto_snapshot_target() is None + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 and f"'{name}'" in warnings[0].getMessage() + + +@pytest.mark.parametrize("kind", ["symlink", "traversal"]) +def test_bad_latest_is_left_behind_and_rotated_out(tmp_path, kind): + """新しい世代を作れば、扱えない世代は最新でなくなり、rotate が一覧から外す。""" + name, outside = _bad_latest(tmp_path, kind) + mgr = RecordingManager(tmp_path, group="default") + + created = mgr.create(name=mgr.auto_snapshot_target()) + assert mgr.auto_snapshot_target() == created + + mgr.rotate(keep=1) + assert names(tmp_path) == [created] + assert sorted(p.name for p in outside.iterdir()) == ["keep.txt"] diff --git a/tests/snapshot/test_manager_volumes.py b/tests/snapshot/test_manager_volumes.py index f5699a6a..5b69dc80 100644 --- a/tests/snapshot/test_manager_volumes.py +++ b/tests/snapshot/test_manager_volumes.py @@ -200,7 +200,7 @@ def test_layout_change_starts_a_new_generation(root): _write_legacy_snapshot(root) mgr = RecordingManager(root) - assert mgr.should_start_new_generation() is True + assert (mgr.auto_snapshot_target() is None) is True def test_group_change_starts_a_new_generation(root, monkeypatch): @@ -210,14 +210,14 @@ def test_group_change_starts_a_new_generation(root, monkeypatch): monkeypatch.setenv("DEVBASE_ACCOUNT_GROUP", "kkg") other = RecordingManager(root) - assert other.should_start_new_generation() is True + assert (other.auto_snapshot_target() is None) is True def test_same_layout_keeps_appending_increments(root): mgr = RecordingManager(root) mgr.create(name="snap1") - assert mgr.should_start_new_generation() is False + assert (mgr.auto_snapshot_target() is None) is False def test_incremental_on_a_different_layout_is_refused(root):