Skip to content

Commit 4de5ea4

Browse files
authored
Merge pull request #263 from devbasex/feature/plan68-snapshot-series
feat(PLAN68): スナップショットの世代をアカウントグループごとの系列で持つ (#248)
2 parents 72e862a + 2b06856 commit 4de5ea4

22 files changed

Lines changed: 1641 additions & 129 deletions

‎CHANGELOG.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,31 @@
1212
止まります。`lfm` / `snapshot` は base を継がないため入りません。
1313
**反映には `devbase build base --no-cache` と、使っている派生イメージの建て直しが要ります。**
1414

15+
### Changed
16+
- **スナップショットの世代を、アカウントグループ(対象ボリュームの組)ごとの系列で持つように
17+
しました(PLAN68 / #248)。** グループの違うプロジェクトを行き来しても、`devbase up` は
18+
起動したグループの系列の最新の世代へ差分を積み、フルバックアップを取り直しません。
19+
新しい世代を作るのは、その系列に世代が無いときと、差分が上限(10)に達したときだけです。
20+
自動スナップショットの最小間隔(`DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES`)も系列ごとに判定します。
21+
起動時の出力には扱った系列のグループ名が出て、「対象ボリュームの構成が変わったため」の行は
22+
出なくなりました。
23+
- **ローテーションは、グループごとに 3 世代を残し、全体で 9 世代を上限にします。** 全体の上限を
24+
超えると系列をまたいで古い世代から消しますが、**各系列の最新の世代は自動では消えません**
25+
(次の差分の積み先のため)。使わなくなったグループの世代が不要なら `devbase snapshot delete`
26+
で消してください。
27+
- **`devbase snapshot rotate --keep N` の N は「全体で残す数」から「グループごとに残す数」に
28+
なりました。** 全体の上限は新しい `--max-total M`(省略時は `N × 3`)で指定します。同じ `--keep` の
29+
値で残る世代の数は減りませんが、どの世代が残るかは変わることがあります。どちらの指定も
30+
その 1 回の実行だけに効き、`devbase up` / `down` の自動ローテーションは既定の数で動きます。
31+
32+
### Fixed
33+
- **ローテーションが、`backups/` の外を指す名前の世代を消さず、シンボリックリンクの世代で
34+
止まらなくなりました(PLAN68 / #248)。** `snapshot.yml` のそうしたエントリは、ディレクトリも
35+
リンク先も消さずに一覧から外し、警告を出します。
36+
- **`devbase snapshot create` / `restore` / `copy` / `delete` が、シンボリックリンクの世代を
37+
エラーで止めるようになりました。** これまで `delete` はリンク先(`backups/` の外や別の世代)の
38+
中身を消していました。
39+
1540
## [3.7.0] - 2026-09-23
1641

1742
### Added

‎docs/specifications/snapshot-series.md‎

Lines changed: 346 additions & 0 deletions
Large diffs are not rendered by default.

‎docs/user/cli-reference/02-project.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ devbase up [name] [--context NAME]
9494
- 自動スナップショットは作らない(控えたいボリュームがリモートにあるため)
9595
- `DOCKER_HOST` が設定されていれば警告して外す(docker は `DOCKER_HOST` を `DOCKER_CONTEXT` より優先するため)
9696

97-
- 起動時にスナップショットを自動作成(新世代 or 差分追加)
98-
- 直近のスナップショット取得から既定 60 分以内のときはスキップします
97+
- 起動時にスナップショットを自動作成(起動するアカウントグループの系列の最新の世代へ差分追加。系列に世代が無いか差分が上限に達していれば新世代)
98+
- 同じアカウントグループ(系列)の直近のスナップショット取得から既定 60 分以内のときはスキップします。別のグループのスナップショットの時刻は見ません
9999
- 間隔は `DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES` 環境変数で上書き可能(既定 60、`0` で無効化=毎回取得、不正値は警告して既定値)
100100
- `project.yml` の `scale` に基づいてコンテナ数を決定(既定: 2)
101101
- `project.yml` の `repos` を clone プランへ正規化してコンテナへ渡す(コンテナ内で `/work` 配下へ clone される)

‎docs/user/cli-reference/05-snapshot.md‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,20 @@ devbase snapshot delete <name>
6666

6767
## `devbase snapshot rotate`
6868

69-
古い世代のスナップショットを削除します。
69+
古い世代のスナップショットを削除します。世代はアカウントグループ(対象ボリュームの組)ごとの
70+
系列に分かれ、系列ごとに `--keep` 世代を残したうえで、全体の上限を超えた分を系列をまたいで
71+
古い順に削除します。各系列の最新の世代は削除しません。
7072

7173
```
72-
devbase snapshot rotate [--keep N]
74+
devbase snapshot rotate [--keep N] [--max-total M]
7375
```
7476

7577
| オプション | 説明 |
7678
|-----------|------|
77-
| `--keep N` | 保持する世代数(デフォルト: `3`) |
79+
| `--keep N` | アカウントグループ(系列)ごとに保持する世代数(デフォルト: `3`) |
80+
| `--max-total M` | すべてのグループを合わせて保持する世代数の上限(デフォルト: `N × 3`)。各グループの最新の世代は上限を超えても残す |
81+
82+
`N` と `M` は 1 以上です。0 以下はエラーで終了コード 1 になります。
83+
84+
**どちらの指定も、手動で実行したその 1 回だけに効きます。** 値は保存されず、`devbase up` /
85+
`devbase down` の自動ローテーションは既定(グループごとに 3・全体で 9)で動きます。

‎docs/user/container-operations.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ devbase down
6060

6161
| タイミング | 動作 | 条件 |
6262
|-----------|------|------|
63-
| `devbase up` | フルバックアップ or 差分追加 | 前回のフルバックアップからの経過日数で判定 |
64-
| `devbase down` | 古い世代のローテーション | `DEFAULT_MAX_GENERATIONS` を超えた世代を削除 |
63+
| `devbase up` | フルバックアップ or 差分追加 | 起動するアカウントグループの系列の最新の世代へ差分を追加。系列に世代が無いか差分が上限に達していればフルバックアップで新しい世代 |
64+
| `devbase down` | 古い世代のローテーション | グループ(系列)ごとに `DEFAULT_MAX_GENERATIONS`(3)を超えた世代と、全体の上限(9)を超えた古い世代を削除。各系列の最新の世代は残す |
6565

6666
詳細は [スナップショットガイド](snapshot-guide.md) を参照してください。
6767

‎docs/user/snapshot-guide.md‎

Lines changed: 82 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -57,31 +57,55 @@ graph LR
5757
| パラメータ | デフォルト値 | 説明 |
5858
|-----------|------------|------|
5959
| `DEFAULT_MAX_INCREMENTALS` | `10` | 1 世代あたりの最大差分バックアップ数 |
60-
| `DEFAULT_MAX_GENERATIONS` | `3` | 保持する最大世代数 |
60+
| `DEFAULT_MAX_GENERATIONS` | `3` | アカウントグループ(系列)ごとに保持する世代数 |
61+
| 全体の上限 | `DEFAULT_MAX_GENERATIONS × 3`(`9`) | すべてのグループを合わせて保持する世代数の上限 |
6162

62-
デフォルト設定では、差分バックアップが 10 回溜まるとフルバックアップが新たに作成され、最大 3 世代が保持されます。
63+
デフォルト設定では、差分バックアップが 10 回溜まるとフルバックアップが新たに作成され、グループごとに最大 3 世代、全体で最大 9 世代が保持されます。
64+
65+
### 系列
66+
67+
世代は、控える**ボリュームの組**(共通ボリューム `devbase_home_ubuntu` とグループの
68+
ボリューム `devbase_home_<group>`)ごとの**系列**に分かれます。アカウントグループ 1 つにつき
69+
系列が 1 つできます。アカウントグループ分離より前に作られた世代(共通ボリュームだけを控えたもの)は、
70+
まとめて 1 つの系列になります。
71+
72+
- 差分は、起動したグループの系列の**最新の世代**へ積みます。別のグループのプロジェクトを
73+
起動した後に戻ってきても、フルバックアップを取り直しません
74+
- 保持の数は系列ごとに数えます。グループを切り替えても、他のグループの世代が保持の枠から押し出されません
75+
- 全体の上限を超えると、系列をまたいで最も古い世代から削除します
76+
- **各系列の最新の世代は自動では削除されません。** 次の差分の積み先になるためです。使わなくなった
77+
グループや、アカウントグループ分離より前の系列の最新の世代も残ります。不要になったら
78+
`devbase snapshot delete <名前>` で削除してください。系列の数が全体の上限を超えているときは、
79+
上限を超えたまま残し、警告を出します
80+
81+
積み先・最小間隔・ローテーションの規則と世代の場所の検証の仕様は
82+
[スナップショットの世代の系列](../specifications/snapshot-series.md) にあります。
6383

6484
### 世代の概念
6585

6686
```mermaid
6787
graph TD
68-
subgraph 世代 1(最古)
69-
A1[full.tar.zst]
70-
A2[incr-001.tar.zst]
71-
A3[incr-002.tar.zst]
88+
subgraph 系列 default
89+
subgraph 世代 1(最古)
90+
A1[full.tar.zst]
91+
A2[incr-001.tar.zst]
92+
A3[incr-002.tar.zst]
93+
end
94+
subgraph 世代 2(系列の最新)
95+
B1[full.tar.zst]
96+
B2[incr-001.tar.zst]
97+
end
7298
end
73-
subgraph 世代 2
74-
B1[full.tar.zst]
75-
B2[incr-001.tar.zst]
76-
end
77-
subgraph 世代 3(最新)
78-
C1[full.tar.zst]
99+
subgraph 系列 with
100+
subgraph 世代 3(系列の最新)
101+
C1[full.tar.zst]
102+
end
79103
end
80104
```
81105

82106
- 1 つの世代は 1 つのフルバックアップと 0 個以上の差分バックアップで構成される
83-
- 差分バックアップが `DEFAULT_MAX_INCREMENTALS` 回に達すると新しい世代が開始される
84-
- `DEFAULT_MAX_GENERATIONS` を超えた古い世代は自動的に削除される
107+
- 系列の最新の世代の差分バックアップが `DEFAULT_MAX_INCREMENTALS` 回に達すると、その系列に新しい世代が開始される
108+
- 系列ごとに `DEFAULT_MAX_GENERATIONS` を超えた古い世代と、全体の上限を超えた古い世代は自動的に削除される
85109

86110
## 自動実行
87111

@@ -91,22 +115,37 @@ graph TD
91115

92116
```mermaid
93117
flowchart TD
94-
A[devbase up 実行] --> B{現世代の差分バックアップが<br/>DEFAULT_MAX_INCREMENTALS 回以上?}
95-
B -->|はい| C[新世代のフルバックアップを作成]
96-
B -->|いいえ| D[現世代に差分バックアップを追加]
97-
C --> E[コンテナを起動]
98-
D --> E
118+
A[devbase up 実行] --> S{起動するグループの系列に<br/>世代がある?}
119+
S -->|いいえ| C[新世代のフルバックアップを作成]
120+
S -->|はい| B{系列の最新の世代の差分バックアップが<br/>DEFAULT_MAX_INCREMENTALS 回以上?}
121+
B -->|はい| C
122+
B -->|いいえ| D[系列の最新の世代に差分バックアップを追加]
123+
C --> R[ローテーション]
124+
D --> R
125+
R --> E[コンテナを起動]
99126
```
100127

128+
起動時の出力には、扱った系列のグループ名が出ます。新しい世代を作るときは、その理由
129+
(系列に世代が無い / 差分が上限に達した)を 1 行出します。
130+
131+
```text
132+
[0/6] スナップショットを差分更新中: 20260920-212546 (グループ with)
133+
```
134+
135+
直近のスナップショットから `DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES`(既定 60 分)以内なら
136+
スナップショットを飛ばします。この間隔も系列ごとに判定するため、default のプロジェクトを
137+
起動した直後に with のプロジェクトを起動しても、with の系列は控えます。
138+
101139
### `devbase down` 時の動作
102140

103141
```mermaid
104142
flowchart TD
105143
A[devbase down 実行] --> B[コンテナを停止・削除]
106-
B --> C{世代数 > DEFAULT_MAX_GENERATIONS?}
107-
C -->|はい| D[最古の世代を削除]
108-
D --> C
109-
C -->|いいえ| E[完了]
144+
B --> C[系列ごとに DEFAULT_MAX_GENERATIONS を<br/>超えた古い世代を削除]
145+
C --> T{全体の世代数 > 全体の上限?}
146+
T -->|はい| D[系列の最新ではない世代のうち<br/>最も古いものを削除]
147+
D --> T
148+
T -->|いいえ| E[完了]
110149
```
111150

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

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

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

@@ -332,11 +373,20 @@ devbase snapshot delete 20260218-080000
332373
# デフォルトの保持数で実行
333374
devbase snapshot rotate
334375

335-
# 保持する世代数を指定
376+
# グループごとに保持する世代数を指定
336377
devbase snapshot rotate --keep 5
378+
379+
# 全体の上限も指定
380+
devbase snapshot rotate --keep 5 --max-total 10
337381
```
338382

339-
`--keep N` で指定した世代数より古い世代を削除します。名前付きスナップショット(`--name` で作成したもの)はローテーション対象外です。
383+
`--keep N` はアカウントグループ(系列)ごとに残す世代数です。各系列で古い世代から削除し、
384+
残りの合計が `--max-total M`(省略時は `N × 3`)を超えていれば、系列をまたいで古い世代から
385+
削除します。各系列の最新の世代は削除しません。名前付きスナップショット(`--name` で作成したもの)や
386+
`copy` で作った世代も、対象ボリュームの組でいずれかの系列に入り、同じ規則で削除の対象になります。
387+
388+
**`--keep` と `--max-total` は、その 1 回の実行だけに効きます。** 値は保存されず、
389+
`devbase up` / `devbase down` の自動ローテーションは既定(グループごとに 3・全体で 9)で動きます。
340390

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

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

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

362-
```bash
363-
# 長期保持が必要な場合
364-
devbase snapshot rotate --keep 7
365-
```
412+
`devbase snapshot rotate --keep` の指定はその 1 回だけに効き、次の `devbase up` / `devbase down`
413+
の自動ローテーションで既定の数まで削除されます。`snapshot copy` で作った世代もローテーションの
414+
対象です。長く残したい世代は、世代のディレクトリを `backups/` の外へ複製してください。

‎docs/user/troubleshooting.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,9 +358,11 @@ devbase snapshot list
358358
# バックアップディレクトリのサイズ確認
359359
du -sh ${DEVBASE_ROOT}/backups/
360360

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

364+
# 各グループの最新の世代はローテーションで消えない。使わないグループの世代は個別に削除する
365+
364366
# 個別のスナップショットを削除
365367
devbase snapshot delete <name>
366368
```
File renamed without changes.
Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,28 @@ default(差分 0、3.9 GB)で、合計は 42 GB である。
202202
## 実装計画
203203

204204
設計は [PLAN68_snapshot-series-design.md](PLAN68_snapshot-series-design.md)。
205-
**タスクへの分解は実装の持ち場で `/ndf:implementation-plan` が行う。**
205+
タスクは受け入れ条件の単位で分け、どれも「失敗するテスト → 通す最小実装 → 整理」で進める。
206+
207+
### タスク分解
208+
209+
| # | タスク | 対象ファイル | 変更内容 | 満たす受け入れ条件 |
210+
| --- | --- | --- | --- | --- |
211+
| 1 | 世代の場所の検証 | `snapshot/manager.py`、`tests/snapshot/test_manager_series.py` | `_safe_snap_dir` にシンボリックリンクの拒否と `Path.is_relative_to` の包含判定を入れる(決定 7) | 26(`_safe_snap_dir`)・27・28 |
212+
| 2 | 系列の解決と積み先 | 同上 | `series_key` / `series_label` / `_entry_volumes` / `series_latest` / `auto_snapshot_target` を足し、`should_start_new_generation` を包むだけにする | 1〜5 |
213+
| 3 | 系列ごとの最小間隔 | 同上 | `last_snapshot_time(volumes=None)` | 6・7 |
214+
| 4 | 系列ごとの保持と全体の上限 | 同上 | `rotate(keep, max_total)` を系列ごと + 全体の上限 + 各系列の最新を残す形へ。消す前に `_safe_snap_dir` で検証し、拒否されたエントリは一覧からだけ外す | 8〜14・17・22・25・26 |
215+
| 5 | `_auto_snapshot` の流れ | `commands/container.py`、`tests/snapshot/test_auto_snapshot_series.py` | 最小間隔を系列で判定し、`auto_snapshot_target` の結果で `create` を呼ぶ。ログに系列の名前を入れる | 1・2・6・7・16 |
216+
| 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 |
217+
| 7 | 文書と CHANGELOG | 文書 4 本、`CHANGELOG.md` | 設計の「文書の変更」の表のとおり | 19〜21 |
218+
| 8 | 全体の確認 | — | `uv run --locked pytest tests/ -q`、`ruff check --select=E9,F63,F7,F82 lib` | 23・24 |
219+
220+
### リスクと対処
221+
222+
| リスク | 対処 |
223+
| --- | --- |
224+
| `manager.py`(818 行)に規則が集まる | 系列の解決は小さな関数に分け、`rotate` の削除候補の計算を副作用の無い補助に切り出す。構造は保ち、タスクごとにテストを通す |
225+
| 既存テスト(`test_auto_snapshot.py` の `last_snapshot_time`、`test_manager_volumes.py`)の退行 | 引数の既定値で現行の振る舞いを保ち、タスクごとに `tests/snapshot/` を回す |
226+
| 実際の tar による、別グループを挟んだ差分の復元が未確認 | この持ち場ではコンテナを起動しない(並行する #253 の検査と重ねない)。検査の持ち場へ回し、Pull Request 本文に書く |
206227

207228
### 修正対象
208229

‎lib/devbase/cli.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -713,7 +713,11 @@ def _add_snapshot_parser(subparsers):
713713
s_delete.add_argument('name', help='Snapshot name')
714714

715715
s_rotate = ss_sub.add_parser('rotate', help='Rotate old snapshots')
716-
s_rotate.add_argument('--keep', type=int, default=3, help='Generations to keep')
716+
s_rotate.add_argument('--keep', type=int, default=3,
717+
help='Generations to keep per account group')
718+
s_rotate.add_argument('--max-total', type=int, default=None, metavar='M',
719+
help='Upper limit of generations across all groups '
720+
'(default: 3 x --keep)')
717721

718722

719723
def _add_shortcuts(subparsers):

0 commit comments

Comments
 (0)