Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
e965f1d
feat(PLAN68): スナップショットの世代をアカウントグループごとの系列で持つ
takemi-ohama Sep 24, 2026
74c1ad8
test: characterize snapshot command and auto-snapshot branches
takemi-ohama Sep 24, 2026
63f7331
Test: characterization — lib/devbase/commands/container.py#_auto_snap…
takemi-ohama Sep 24, 2026
391a526
Test: characterization — snapshot restore args, auto-snapshot future …
takemi-ohama Sep 24, 2026
5fc8239
Refactor: extract snapshot interval and series rules
takemi-ohama Sep 24, 2026
c2d75e0
Refactor: consolidate_duplication / extract_method — snapshot archive…
takemi-ohama Sep 24, 2026
5ed1d61
Refactor: consolidate_duplication — lib/devbase/snapshot/manager.py#S…
takemi-ohama Sep 24, 2026
346f49c
Refactor: introduce_named_constant — lib/devbase/snapshot/manager.py#…
takemi-ohama Sep 24, 2026
2b63b89
Refactor: consolidate_duplication — lib/devbase/snapshot/manager.py#S…
takemi-ohama Sep 24, 2026
ae15233
Refactor: remove_dead_code — SnapshotManager.should_start_new_generation
takemi-ohama Sep 24, 2026
a81af47
Revert: 範囲外の構造改善を PR から外す (PLAN68)
takemi-ohama Sep 24, 2026
b58f9ee
fix(snapshot): 引用符なしの created_at を文字列に揃えて比べる (PLAN68)
takemi-ohama Sep 24, 2026
f557461
fix(snapshot): 系列の最新が扱えない世代なら新しい世代を作る (PLAN68)
takemi-ohama Sep 24, 2026
2b06856
docs(PLAN68): スナップショットの系列の確定仕様を docs/specifications へ移す (#248)
takemi-ohama Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
346 changes: 346 additions & 0 deletions docs/specifications/snapshot-series.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions docs/user/cli-reference/02-project.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 される)
Expand Down
14 changes: 11 additions & 3 deletions docs/user/cli-reference/05-snapshot.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,20 @@ devbase snapshot delete <name>

## `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)で動きます。
4 changes: 2 additions & 2 deletions docs/user/container-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) を参照してください。

Expand Down
115 changes: 82 additions & 33 deletions docs/user/snapshot-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<group>`)ごとの**系列**に分かれます。アカウントグループ 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` を超えた古い世代と、全体の上限を超えた古い世代は自動的に削除される

## 自動実行

Expand All @@ -91,22 +115,37 @@ graph TD

```mermaid
flowchart TD
A[devbase up 実行] --> B{現世代の差分バックアップが<br/>DEFAULT_MAX_INCREMENTALS 回以上?}
B -->|はい| C[新世代のフルバックアップを作成]
B -->|いいえ| D[現世代に差分バックアップを追加]
C --> E[コンテナを起動]
D --> E
A[devbase up 実行] --> S{起動するグループの系列に<br/>世代がある?}
S -->|いいえ| C[新世代のフルバックアップを作成]
S -->|はい| B{系列の最新の世代の差分バックアップが<br/>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 を<br/>超えた古い世代を削除]
C --> T{全体の世代数 > 全体の上限?}
T -->|はい| D[系列の最新ではない世代のうち<br/>最も古いものを削除]
D --> T
T -->|いいえ| E[完了]
```

## バックアップデータ構造
Expand Down Expand Up @@ -190,9 +229,11 @@ before-upgrade 2026-02-21 14:00:00 1 2.1GB devbase_ho
### 対象ボリュームが変わったとき

アカウントグループを切り替えたり、分離前の環境から更新したりすると、対象ボリュームの構成が
変わります。このとき devbase は**新しい世代を作ります**。旧世代の差分状態ファイル
(`snapshot.snar`)は別のレイアウトを記録しているため、そこへ差分を積むと全ファイルが
移動したものとして扱われ、差分が壊れるからです。世代を分けることで旧世代はそのまま復元できます。
変わります。devbase は、切り替えた先のグループの系列([系列](#系列))の最新の世代へ差分を
積みます。**新しい世代を作るのは、その系列にまだ世代が無いときだけです。** 構成の違う世代へは
差分を積みません。差分状態ファイル(`snapshot.snar`)は別のレイアウトを記録しているため、
そこへ差分を積むと全ファイルが移動したものとして扱われ、差分が壊れるからです。
系列を分けることで、どの世代もそのまま復元できます。

構成の違う世代を明示的に指定して差分を作ろうとした場合は、理由を示して中断します。

Expand Down Expand Up @@ -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)で動きます。

## 運用のベストプラクティス

Expand All @@ -357,9 +407,8 @@ devbase snapshot rotate --keep 5
du -sh projects/<project>/backups/
```

5. **ローテーションの保持数はプロジェクトに合わせて調整する**
5. **長期保持したい世代は `backups/` の外へ複製する**

```bash
# 長期保持が必要な場合
devbase snapshot rotate --keep 7
```
`devbase snapshot rotate --keep` の指定はその 1 回だけに効き、次の `devbase up` / `devbase down`
の自動ローテーションで既定の数まで削除されます。`snapshot copy` で作った世代もローテーションの
対象です。長く残したい世代は、世代のディレクトリを `backups/` の外へ複製してください。
4 changes: 3 additions & 1 deletion docs/user/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,9 +358,11 @@ devbase snapshot list
# バックアップディレクトリのサイズ確認
du -sh ${DEVBASE_ROOT}/backups/

# 不要な世代を削除(2世代のみ保持)
# 不要な世代を削除(グループごとに 2 世代、全体で 6 世代まで保持。この 1 回だけに効く)
devbase snapshot rotate --keep 2

# 各グループの最新の世代はローテーションで消えない。使わないグループの世代は個別に削除する

# 個別のスナップショットを削除
devbase snapshot delete <name>
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 本文に書く |

### 修正対象

Expand Down
6 changes: 5 additions & 1 deletion lib/devbase/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading