Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@
`project` / `container` の配下(`logs`・`profile up|down|list` を含む)、`env exec`・`env token` で、`-` を打って補完すると
`--context` が候補に出ます(`devbase up web -<TAB>` のようにプロジェクト名の後でも出ます)。値(docker context の名前)は補完しません。
`-` で始まらない語の候補は変わりません。
- **スナップショットが、作り直せる一時ファイル・ログ・取り直せる取得物を控えないようにしました(#368)。**
既定で `.codex/logs_2.sqlite`(`-wal`・`-shm` を含む)・`.codex/.tmp`・`.codex/cache`・`.claude/plugins/cache`・
`.claude/plugins/marketplaces`・`.serena/logs`・`.gemini/tmp/*/logs.json`・`.gemini/tmp/*/logs` を、フル・差分・
復元前バックアップから外します。会話の記録(`.gemini/tmp/<プロジェクト>/chats/` を含む)・入れたプラグインの一覧と
データ・`.serena/language_servers` はこれまでどおり控えます。外したパスは復元後のボリュームに無く、各 CLI が作り直します。
`backups/exclude.yml` の `exclude` にボリュームの中の相対のパスを並べると、除外を足せます。
世代の `meta.yml` に作ったときの除外の一覧(`exclude`)を残し、今と違う除外の一覧の世代へは差分を積みません。
**更新後の最初の `devbase up` の自動スナップショットは、系列ごとに新しい世代(フル)を作ります。**
除外の一覧が違う世代を `devbase snapshot create --name` で指定すると、差分を作らずに終了コード 1 で止まります。
既存の世代はそのまま一覧・復元できます。

### Fixed
- **プロジェクトの `env` に `DEV_SERVICE_NAME=` と空で書いたとき、開発サービス名を `dev` として扱うようにしました(#425)。**
Expand Down
14 changes: 12 additions & 2 deletions docs/specifications/snapshot-series.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ classDiagram
| 2 | 系列の最新の世代が `_safe_snap_dir` を通らない(不正な名前・シンボリックリンク・`backups/` の外) | `None` | WARNING `{系列} の最新の世代 '{名前}' は扱えないため、新しい世代を作成します: {理由}` |
| 3 | 最新の世代のディレクトリがあり、その `meta.yml` の組が `self.volumes` と違う | `None` | INFO `世代 {名前} の meta.yml の対象ボリューム ({組}) が {系列} と一致しないため、新しい世代を作成します` |
| 4 | 最新の世代のディレクトリがあり、その `meta.yml` の `archive_root` が `members` でない | `None` | INFO `世代 {名前} は控えの起点が古い形 (/source) のため、新しい世代を作成します` |
| 4a | 最新の世代のディレクトリがあり、その `meta.yml` の `exclude` が無い(除外を持たない世代)か、`backups/exclude.yml` を読めて今の除外の一覧と集合として違う | `None` | INFO `世代 {名前} は今と違う除外の一覧で作られているため、新しい世代を作成します` |
| 5 | 最新の世代の `incremental_count` が上限以上 | `None` | INFO `世代 {名前}({系列})の差分が上限 ({上限}) に達したため、新しい世代を作成します` |
| 6 | それ以外 | 最新の世代の名前 | 出さない |

Expand All @@ -151,6 +152,12 @@ classDiagram
止める。旧レイアウト(ルートへ直接マウント)の `full` は起点が `.` のままなので `archive_root` を書かない
- 名前を明示して組の違う世代へ差分を作ろうとすると、`_create_incremental` が理由を示して
`SnapshotError` で止める(旧レイアウトの世代へは差分を積まない)
- 順 4a は除外の一覧(既定の除外と `backups/exclude.yml`。#368)の違いを見る。1 つの世代は full を
作ったときの一覧で差分も積む。違う一覧の差分を積むと、外したパスが full の時点の中身で復元されるためである。
`backups/exclude.yml` を読めない間は、記録を持つ世代を違うと判定せず順 5 へ進み、その記録の一覧で積む
(読めない設定で新しい世代ができ、ローテーションで古い世代が消えるのを防ぐ)。名前を明示して除外の一覧の
違う世代(記録の無い世代を含む)へ差分を作ろうとすると、`_create_incremental` が snar に触る前に
`SnapshotError` で止める。比べる順は組 → `archive_root` → 除外の一覧で、snar 無しのフォールバックより前に置く

**間に別グループの起動を挟んでも、系列の最新の世代へ積む。** `snapshot.snar` は世代ごとにあり、
系列が同じならアーカイブのレイアウト(`/source/ai` と `/source/group`)もマウントするボリュームも
Expand Down Expand Up @@ -244,7 +251,8 @@ classDiagram

## データ・設定

**形は変えていない。** 移行は無く、変更前の devbase も同じファイルを読める。
**形は変えていない。** 移行は無く、変更前の devbase も同じファイルを読める。各世代の `meta.yml` に
足した `exclude`(#368)は、変更前の devbase が読み捨てる。

| 保存先 | 系列の規則での扱い |
| --- | --- |
Expand All @@ -253,6 +261,7 @@ classDiagram
| 同 `snapshots[].incremental_count` | 差分の上限の判定 |
| 同 `max_generations` | `rotate` が消したときに `keep` を書く。読む側は無い |
| 各世代の `meta.yml` の `volumes` | 系列の判定には使わない。積む直前の検証(`auto_snapshot_target` の順 3 と `_create_incremental`)でだけ読む |
| 各世代の `meta.yml` の `exclude` | full を作ったときの除外の一覧(並べ替え済み)。差分では書き換えない。積む直前の検証(順 4a と `_create_incremental`)でだけ読む |
| アーカイブの mtime | 最小間隔の判定 |

| 設定 | 値 |
Expand Down Expand Up @@ -357,7 +366,8 @@ CLI と TUI:
- `cmd_down` の `rotate()` の失敗で終了コード 0(`tests/commands/test_container_down_snapshot.py`)

復元のテスト(`tests/snapshot/test_restore_incremental.py`、`test_manager_volumes.py` の復元)は
変えずに通ること。
変えずに通ること。ただし `test_restore_incremental.py` の実の Docker で入れ替える場所は、既定の除外
(#368)に入らない `data/cache` に置く。

実機で確かめる観点: 2 つのグループのプロジェクトを `DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES=0` で
交互に `devbase up` し、それぞれの系列の最新の世代に `incr-001` が積まれ、世代の数が変わらないことを
Expand Down
7 changes: 7 additions & 0 deletions docs/user/cli-reference/05-snapshot.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ devbase snapshot create [--name NAME] [--full] [--group NAME]
で止まります。`--group default` も終了コード 2 です(旧既定のボリュームの系列には新しい世代を
作りません。一覧・復元・コピー・削除・ローテーションはできます)。

既にある世代の名前を `--full` なしで指定すると、その世代へ差分を積みます。その世代が今と違う除外の一覧で
作られている(除外の一覧を記録していない世代を含む)ときは、差分を作らずに終了コード 1 で止まります。

控えには、既定の除外(作り直せる一時ファイル・ログ・取り直せる取得物)と `backups/exclude.yml` に
書いた除外が入りません。一覧と書き方は
[スナップショットガイドの「控えから外すもの」](../snapshot-guide.md#控えから外すもの) にあります。

```bash
# 自動命名でフルスナップショット(差分を積むのは devbase up の自動スナップショット)
devbase snapshot create
Expand Down
68 changes: 65 additions & 3 deletions docs/user/snapshot-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ graph LR
style D fill:#e8f4e8
```

- **フルバックアップ**: 対象ボリューム 2 本の全体をアーカイブ(アーカイブ内では `ai/` と `group/` に分かれます)
- **フルバックアップ**: 対象ボリューム 2 本の全体([控えから外すもの](#控えから外すもの)を除く)をアーカイブ(アーカイブ内では `ai/` と `group/` に分かれます)
- **差分バックアップ**: 前回からの変更分のみをアーカイブ
- **圧縮**: zstd `-1 -T0`(圧縮レベル 1、全 CPU コア使用)で高速圧縮

Expand All @@ -52,6 +52,61 @@ graph LR

プロジェクトのコンテナを起動せずにバックアップ・復元を実行できるため、ダウンタイムが発生しません。

## 控えから外すもの

作り直せる一時ファイル・ログ・取り直せる取得物は、スナップショットに入れません。フルバックアップ・
差分バックアップ・復元前バックアップのどれにも同じ除外が効きます。外したパスは復元後のボリュームに無く、
各 CLI が必要なときに作り直すか取り直します。

### 既定の除外

devbase が持つ除外の一覧です。共通ボリュームとグループのボリュームの両方に当てます。端末に無いパスは
何も起きません(警告も出ません)。

| パス(ボリューム内) | 中身 |
|---|---|
| `.gemini/tmp/*/logs.json`・`.gemini/tmp/*/logs` | Gemini CLI の作業用のログ |
| `.codex/logs_2.sqlite`(`-wal`・`-shm` を含む) | Codex の動作ログ |
| `.codex/.tmp`・`.codex/cache` | Codex の一時ファイル・キャッシュ |
| `.claude/plugins/cache`・`.claude/plugins/marketplaces` | Claude Code のプラグインとマーケットプレイスの取得物 |
| `.serena/logs` | Serena のログ |

次のものは外さず、これまでどおり控えて復元で戻します。

- 会話の記録(`.claude/projects`・`.codex/sessions`・`.codex/thread_history_1.sqlite`・
`.codex/state_5.sqlite`・`.gemini/tmp/<プロジェクト>/chats/`)
- 入れたプラグインの一覧とプラグインのデータ(`.claude/plugins/installed_plugins.json`・
`.claude/plugins/known_marketplaces.json`・`.claude/plugins/data/`)
- `.serena/language_servers`

### 除外を足す

`${DEVBASE_ROOT}/backups/exclude.yml` に、ボリュームの中の相対のパスを並べると、既定の除外に足せます。
devbase はこのファイルを作らず、書き換えません。既定の除外を外すことはできません。

```yaml
# スナップショットの控えから外すパス(ボリュームの中の相対のパス)
exclude:
- .cache/pip
- work/*/node_modules
```

- 書いたパスとその下を外します。パスはボリュームの頭(共通ボリュームとグループのボリュームのそれぞれ)
からだけ当たり、ボリュームの下の深い場所にある同じ名前には当たりません
- `*` は 1 つの要素(`/` で区切った 1 段)の中の任意の文字列に当たります。`?`・`[`・`]`・`\` は使えません
- 絶対パス・`..` を含むパス・空の値・文字列でない値は採らず、値ごとに警告を 1 行出して、残りで控えます
- ファイルが無い・`exclude` が無い・空のリストのときは、既定の除外だけで控えます

**除外の一覧を変えると、次の自動スナップショットは差分を積まずに新しい世代を作ります。** 1 つの世代は
同じ除外の一覧で控えるためです。系列ごとの保持数(3)を超えると、最も古い世代がローテーションで消えます。
既定の除外を持つ版へ更新した後の最初の自動スナップショットも、系列ごとに新しい世代を作ります。

`exclude.yml` を YAML として読めない・最上位が辞書でない・`exclude` がリストでないときは、警告を 1 行出し、
今の世代の除外の一覧のまま控えます(差分も、差分の上限で作る新しい世代も同じ一覧です)。読めない設定を
理由に新しい世代は作りません。除外の一覧を記録していない世代しか無いときは、既定の除外だけで控えます。

世代ごとに、作ったときの除外の一覧が `meta.yml` の `exclude` に残ります。

## 世代管理

### 設定パラメータ
Expand Down Expand Up @@ -128,7 +183,8 @@ flowchart TD
```

起動時の出力には、扱った系列のグループ名が出ます。新しい世代を作るときは、その理由
(系列に世代が無い / 差分が上限に達した)を 1 行出します。
(系列に世代が無い / 差分が上限に達した / 系列の最新の世代が今と違う除外の一覧で作られている)を 1 行出します。
除外の一覧が違う世代へは、差分の数に関わらず差分を積みません([控えから外すもの](#控えから外すもの))。

```text
[0/6] スナップショットを差分更新中: 20260920-212546 (グループ initech)
Expand Down Expand Up @@ -157,6 +213,7 @@ flowchart TD
```
backups/
├── snapshot.yml # スナップショット全体のメタデータ
├── exclude.yml # 利用者が足す除外(任意。利用者が書く)
├── 20260220-103000/ # タイムスタンプ名の世代
│ ├── meta.yml # 世代のメタデータ
│ ├── full.tar.zst # フルバックアップ
Expand All @@ -172,7 +229,8 @@ backups/
| ファイル | 内容 |
|---------|------|
| `snapshot.yml` | 全世代のインデックス情報(対象ボリューム名を含む)|
| `meta.yml` | 世代ごとの作成日時、バックアップポイント数、サイズ、**対象ボリューム**等 |
| `meta.yml` | 世代ごとの作成日時、バックアップポイント数、サイズ、**対象ボリューム**、**除外の一覧**(`exclude`)等 |
| `exclude.yml` | 利用者が既定の除外に足すパス([除外を足す](#除外を足す)) |
| `full.tar.zst` | フルバックアップアーカイブ |
| `incr-NNN.tar.zst` | 差分バックアップアーカイブ(NNN は連番) |

Expand Down Expand Up @@ -204,6 +262,10 @@ devbase snapshot create --name before-upgrade

指定した名前でスナップショットを作成します。重要な変更の前に手動で作成する場合に便利です。

既にある世代の名前を指定すると、その世代へ差分を積みます。その世代が今と違う除外の一覧で作られて
いる(除外の一覧を記録していない世代を含む)ときは、差分を作らずに理由を示して終了コード 1 で止まります。
世代のアーカイブと `snapshot.snar` は変わりません。新しい世代を作成してください(`devbase snapshot create`)。

#### フルバックアップの強制作成

```bash
Expand Down
124 changes: 124 additions & 0 deletions lib/devbase/snapshot/exclude.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""スナップショットの控えから外すパス (除外の一覧) の組み立て (#368)

既定の除外 (``DEFAULT_EXCLUDES``) と、利用者が ``backups/exclude.yml`` に書いた除外を
合わせて除外の一覧にし、GNU tar の引数へ変える。世代の状態に依らず、Docker も
``meta.yml`` も使わない。世代に関わる判断 (記録・比較・積み先) は ``manager.py`` が持つ。

除外のパターンはボリュームの中の相対のパスで、``*`` は 1 つの要素の中の任意の文字列に
当たる。当たったパスとその下を控えない。各ボリュームの頭 (``ai/``・``group/``・旧レイアウトの
``./``) からだけ当たり、ボリュームの下の深い場所の同じ名前には当たらない。
"""

from __future__ import annotations

import shlex
from pathlib import Path

import yaml

from devbase.log import get_logger

logger = get_logger(__name__)

# 利用者の除外の設定。devbase のルートの ``backups/`` に 1 つだけ置き、devbase は読むだけ。
USER_CONFIG = 'exclude.yml'

# 既定の除外。作り直せる一時ファイル・ログ・取り直せる取得物だけを挙げる。
# 会話の記録 (``.claude/projects``・``.codex/sessions``・``.gemini/tmp/*/chats`` など)・
# プラグインの状態のファイル (``.claude/plugins/installed_plugins.json`` など)・
# ``.serena/language_servers`` には当てない。
DEFAULT_EXCLUDES: tuple = (
'.gemini/tmp/*/logs.json',
'.gemini/tmp/*/logs',
'.codex/logs_2.sqlite',
'.codex/logs_2.sqlite-wal',
'.codex/logs_2.sqlite-shm',
'.codex/.tmp',
'.codex/cache',
'.claude/plugins/cache',
'.claude/plugins/marketplaces',
'.serena/logs',
)

# ``*`` 以外のワイルドカードとエスケープの文字。除外のパターンは持たない。
_FORBIDDEN_CHARS = ('?', '[', ']', '\\')


def normalize(value) -> str:
"""除外のパターンを正規化して返す。使えない値は理由つきの ``ValueError``。"""
if not isinstance(value, str):
raise ValueError('文字列ではありません')
text = value.strip()
if not text:
raise ValueError('空です')
if text.startswith('/'):
raise ValueError('絶対パスです')
if any(ch in text for ch in _FORBIDDEN_CHARS):
raise ValueError('使えない文字を含みます (ワイルドカードは * だけ)')
parts = text.split('/')
if '..' in parts:
raise ValueError('ボリュームの外を指します (.. を含みます)')
parts = [p for p in parts if p not in ('', '.')]
if not parts:
raise ValueError('空です')
return '/'.join(parts)


def _load_user_excludes(backups_dir: Path) -> tuple:
"""利用者の除外を読む。``(パターンのリスト, 設定を読めたか)`` を返す。"""
path = backups_dir / USER_CONFIG
if not path.exists():
return [], True
try:
with open(path, encoding='utf-8') as f:
data = yaml.safe_load(f)
if data is None:
data = {}
if not isinstance(data, dict):
raise ValueError('最上位が辞書ではありません')
values = data.get('exclude')
if values is None:
values = []
if not isinstance(values, list):
raise ValueError('exclude がリストではありません')
except (OSError, UnicodeDecodeError, yaml.YAMLError, ValueError) as e:
logger.warning(
"backups/%s を読めないため、今の世代の除外の一覧のまま控えます"
"(記録の無い世代だけなら既定の除外だけで作ります): %s", USER_CONFIG, e)
return [], False

patterns = []
for value in values:
try:
patterns.append(normalize(value))
except ValueError as e:
logger.warning("backups/%s の除外 %r を採りません: %s", USER_CONFIG, value, e)
return patterns, True


def resolve_excludes(backups_dir: Path) -> tuple:
"""既定の除外と利用者の除外を合わせる。

Returns:
``(除外の一覧, 設定を読めたか)``。一覧は重複を除いて並べ替えたタプル。
設定ファイルが無いときは読めたとみなす。
"""
user, readable = _load_user_excludes(backups_dir)
return tuple(sorted(set(DEFAULT_EXCLUDES) | set(user))), readable


def tar_exclude_args(excludes, members) -> str:
"""除外の一覧から GNU tar の引数を組み立てる。

``members`` は控えるメンバー名 (``ai``・``group``。旧レイアウトは ``.``)。各パターンに
メンバー名を頭に付け、``--anchored --no-wildcards-match-slash`` で頭からだけ・1 つの要素の
中だけに当てる。これらのオプションはメンバーより前に置かないと効かない。
一覧が空なら空文字を返す。
"""
if not excludes:
return ''
args = ['--anchored', '--no-wildcards-match-slash']
for member in members:
for pattern in excludes:
args.append(shlex.quote(f'--exclude={member}/{pattern}'))
return ' '.join(args)
Loading
Loading