From e0486c2e818f0ba09e8df24c5dad2899cc9aef8f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 13 Oct 2026 04:05:12 +0900 Subject: [PATCH 1/3] =?UTF-8?q?Docs:=20snapshot-series.md=20=E3=82=92?= =?UTF-8?q?=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specifications/snapshot-series.md | 87 +++++- issues/issue-466-design.md | 406 ------------------------- issues/issue-466-requirements.md | 239 --------------- 3 files changed, 85 insertions(+), 647 deletions(-) delete mode 100644 issues/issue-466-design.md delete mode 100644 issues/issue-466-requirements.md diff --git a/docs/specifications/snapshot-series.md b/docs/specifications/snapshot-series.md index 2b5f6911..ed17e483 100644 --- a/docs/specifications/snapshot-series.md +++ b/docs/specifications/snapshot-series.md @@ -69,7 +69,7 @@ | 保持 | 同 `rotate(keep, max_total)` と、副作用の無い `_rotation_plan` | 削除候補を決め、場所を検証してから消す | | 世代の作り直し | 同 `_create_full` / `_has_previous_files` / `checked_backup_command` / `_install_rebuilt_full` / `_replace_snap_meta` / `_discard_rebuild_temps`、`restore`(印の検査)。仮の名前は定数 `FULL_ARCHIVE_TMP`・`SNAR_FILE_TMP`・`META_FILE_TMP`、印は `REBUILDING_KEY` | 前のファイルを持つ世代へ full を作るとき、仮の名前へ控えてから決まった順で入れ替える。作り直しの途中の印を持つ世代の復元を止める(下の「世代の作り直し」) | | 世代の場所の検証 | 同 `_safe_snap_dir` | 名前・シンボリックリンク・`backups/` の外を `SnapshotError` で止める | -| 差分の作成 | 同 `_create_incremental` / `checked_backup_command` / `_install_incremental` / `_complete_pending_incremental` / `_discard_incremental_temps`。仮の名前は定数 `INCR_ARCHIVE_TMP`・`SNAR_FILE_TMP`、組の snar は `snar_pending_name`・`SNAR_PENDING_GLOB` | 既存の世代へ差分を積むとき、世代の snar の写しと仮の名前の差分へ控え、成功してから差分 → snar の順に改名で置く。置き終えなかった組は次の差分の作成が先に揃える(下の「差分の作成」) | +| 差分の作成 | 同 `_create_incremental` / `_reject_blocked_incremental` / `checked_backup_command` / `_install_incremental` / `_complete_pending_incremental` / `_discard_incremental_temps` / `_record_incremental`。仮のファイルの削除は作り直しと共有の `_discard_temps`。仮の名前は定数 `INCR_ARCHIVE_TMP`・`SNAR_FILE_TMP`、組の snar は `snar_pending_name`・`SNAR_PENDING_GLOB` | 既存の世代へ差分を積むとき、世代の snar の写しと仮の名前の差分へ控え、成功してから差分 → snar の順に改名で置く。置き終えなかった組は次の差分の作成が先に揃える(下の「差分の作成」) | | 自動スナップショット | `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)` で受ける | @@ -204,6 +204,79 @@ classDiagram - 仮のファイルの名前は `incr-*.tar.zst` の glob・`is_archive_file`・`_has_previous_files` に当たらず、番号の決め方・最小間隔・復元の差分の列に入らない - 成功した差分の作成の結果(アーカイブの一覧・`snapshot.snar`・`meta.yml`・`snapshot.yml` のエントリ)は、この規則の前と同じである +`_create_incremental` を呼ぶのは `create` だけで、`devbase up` の自動スナップショットは `create(name=…, full=False)` を通る。 +`create` は世代を書き終えてから `_update_global_metadata` で `snapshot.yml` を更新するため、差分の作成が例外で止まると一覧は更新されない。 + +```mermaid +sequenceDiagram + participant CR as create + participant CI as _create_incremental + participant G as backups/名前/ + participant D as docker の tar・zstd + participant IN as _install_incremental + CR->>CI: 差分を積む(名前) + CI->>CI: 組・控えの起点・除外の一覧の検査(_reject_blocked_incremental。止まれば SnapshotError) + CI->>G: 置き終えなかった組を揃える(_complete_pending_incremental。できなければ SnapshotError) + CI->>G: 前の中断の仮のファイルを消す(_discard_incremental_temps) + alt snapshot.snar が無い + CI->>CR: _create_full へ切り替える(世代の作り直し) + end + CI->>G: snapshot.snar を snapshot.snar.new へ写す + CI->>D: checked(tar --listed-incremental=snapshot.snar.new | zstd -o incr.tar.zst.new) + CI->>IN: 置く(NNN) + IN->>G: snapshot.snar.new → snapshot.snar.pending-NNN + IN->>G: incr.tar.zst.new → incr-NNN.tar.zst(失敗なら組の snar を消す) + IN->>G: snapshot.snar.pending-NNN → snapshot.snar + alt 写し・控え・改名のどれかが失敗 + CI->>G: 仮のファイルを消す(組の snar は残す) + CI-->>CR: SnapshotError + end + CI->>G: meta.yml を更新(_record_incremental) + CR->>CR: _update_global_metadata +``` + +差分の作成の間の世代の状態((a)〜(c) は上の常に成り立つ条件のもの): + +```mermaid +stateDiagram-v2 + [*] --> 打つ前: 検査を通り、組を揃え、仮のファイルを消した + 打つ前 --> 打つ前: 写しか控えが失敗し仮のファイルを消した(a) + 打つ前 --> 控えた: 写しと控えが成功 + 控えた --> 打つ前: snar の組の名前への改名か差分の改名が失敗し、組の snar と仮のファイルを消した(a) + 控えた --> 差分を置いた: 差分を改名した + 差分を置いた --> 差分を置いた: snar の改名が失敗し、組の snar が残った(b) + 差分を置いた --> snarを進めた: 組の snar を snapshot.snar へ改名した(c) + snarを進めた --> [*]: meta.yml と snapshot.yml を更新 +``` + +「打つ前」「控えた」の間、世代の本来の名前のファイルは 1 つも変わらない。描いていない遷移(「打つ前」から snar だけが進む、 +「控えた」から snar が先に進む)は起こらない。「snar を進めた」の後で `meta.yml` か `snapshot.yml` の書き込みに失敗したときの扱いは +この規則の外である。 + +**仮の名前へ控える理由**: tar は zstd の成否に関わらず `--listed-incremental` に渡した snar を書き換える。世代の `snapshot.snar` を渡さず +写した仮の snar を渡せば、控えが失敗してもプロセスが止められても世代の本来の名前のファイルは変わらない。`snapshot.snar.bak` から戻す形は、 +戻す操作そのものが失敗し得て、プロセスが止められたときは走らないため採らない。 + +**差分を先に置き、間の snar を組の名前で残す理由**: snar を先に進めると、間で止まった世代は「snar が進み新しい差分が無い」になり、 +次の差分が失敗の前の変更を欠く。差分を先に置いて止まった世代へ打つ前の snar から次の差分を控えると、置いた差分の分を重ねて含み、 +ディレクトリの改名が復元で当てられない(常に成り立つ条件の 3 つ目)。組の snar を残せば、次の差分の作成が先に組を揃えてから控えられる。 + +**snar をホストで写す理由**: 控えのコマンドが作り直しと同じ `checked_backup_command(backup_command(…))` の形のままになり、写しの失敗が +docker の前の `OSError` として分かれる。コンテナの中で `cp … &&` を前に付けると、`cp` の失敗が `PIPESTATUS` の判定を読みにくい経路で +偽にする。写すのは KB の桁の snar 1 本だけである。 + +**仮の名前を番号の無い固定の名前にする理由**: 前の中断の残りが名前 1 つを消すだけで片づき、番号が変わった後の残りを探す処理が要らない。 +状態ファイルの仮の名前は作り直しと同じ `snapshot.snar.new` を使い、どちらの経路も控えの前に消すため、互いの残りを取り違えない。 +名前を指定した `--full` の作り直しは `incr.tar.zst.new` を消さない。残っても世代のどの読み手にも当たらず、次の差分の作成が消す。 + +**`snapshot.snar.bak` を作らない理由**: 差分の作成が失敗で snar を変えなくなり、`.bak` から戻す場面が無い。作り続けると、読む処理の無い +ファイルが差分のたびに書き換わり、戻す手がかりと読み違える。前の devbase も `.bak` を読まないため、作らなくなっても戻した devbase で困らない。 + +**仮のファイルを消せないときに続け、改名の失敗を `SnapshotError` に包む理由**: 仮のファイルの削除は本来の名前のファイルに触らないため、 +消せなくても世代は (a)〜(c) のどれかのままである。消せない残りは次の控えの前に消し直し、それでも消せなければ zstd が既にある出力を拒んで +控えが失敗するため、残りが差分として使われない。`cmd_snapshot` は `SnapshotError` しか受けないため、`OSError` のまま出すと `create --name` が +終了コード 1 でなく異常終了になる。 + ### 最小間隔 `_auto_snapshot` は `last_snapshot_time(mgr.volumes)` で、起動するグループの系列の世代だけを見る。 @@ -360,7 +433,7 @@ zstd は既にある `full.tar.zst` を上書きしない。仮の名前と空 - 控えのコマンドが失敗したら(手順 2 の定義: tar が 2 以上、または zstd が 0 以外)、仮の名前のファイルを消して `SnapshotError` を投げ直す。世代の既存のファイルは 1 バイトも変わらず、ファイルは増えない。`snapshot.yml` も変わらない - 作り直しに成功した世代は、`full.tar.zst` と `snapshot.snar` が同じ 1 回の控えから作られ、`incr-*.tar.zst`・ - `snapshot.snar.bak`・作り直しの仮の名前のファイル(`full.tar.zst.new`・`snapshot.snar.new`・`meta.yml.new`)を持たない。 + `snapshot.snar.bak`・差分の組の snar(`snapshot.snar.pending-NNN`)・作り直しの仮の名前のファイル(`full.tar.zst.new`・`snapshot.snar.new`・`meta.yml.new`)を持たない。 前の中断で残った差分の作成の `incr.tar.zst.new` は作り直しが消さず、次の差分の作成が消す - 入れ替え(手順 3)の途中で止まった世代は `snapshot.snar` を持たない。次の `create`(名前の指定・自動 スナップショット)は snar の無い世代の経路で作り直す @@ -401,6 +474,7 @@ zstd は既にある `full.tar.zst` を上書きしない。仮の名前と空 | 各世代の `meta.yml` の `exclude` | full を作ったときの除外の一覧(並べ替え済み)。差分では書き換えない。積む直前の検証(順 4a と `_create_incremental`)でだけ読む | | アーカイブの mtime | 最小間隔の判定 | | 各世代の `incr.tar.zst.new`・`snapshot.snar.new` | 差分の作成の間だけある仮のファイル。成功でも失敗でも残さず、プロセスが止められたときだけ残って次の差分の作成が控えの前に消す。アーカイブとして数えない | +| 各世代の `snapshot.snar.pending-NNN` | 差分 `incr-NNN.tar.zst` と組の snar。差分を置く間だけあり、差分を置いた後の snar の改名で止まったときだけ残る。次の差分の作成が控えの前に `snapshot.snar` へ置くか消し、世代の作り直しが消す。アーカイブとして数えない | | 設定 | 値 | | --- | --- | @@ -421,6 +495,7 @@ zstd は既にある `full.tar.zst` を上書きしない。仮の名前と空 | 全体の上限を満たせない | WARNING | `全体の上限 {max_total} 世代を超えていますが、各グループの最新の世代は消さないため {残り} 世代を残します` | | 場所が不正なエントリ | WARNING | `snapshot.yml の世代 '{名前}' は場所が不正なため、ディレクトリを消さずに一覧からだけ外します: {理由}` | | 世代を作り直した | INFO | `世代 {名前} を今のボリュームの全体で作り直しました(前の差分 {本数} 本を除きました)` | +| 差分の作成が置き終えなかった組を揃えた | INFO | `前回置き終えなかった差分 {名前}/incr-NNN.tar.zst の状態ファイルを置きました`(`_complete_pending_incremental`) | | `snapshot rotate` で消す候補が無い | INFO | `ローテーション不要です`(`_snapshot_rotate`。消した数も外した数も 0 のときだけ) | | `snapshot rotate` で一覧から外しただけ | INFO | `ローテーション: 場所が不正な {数} 世代を一覧から外しました(削除した世代はありません)`(`_snapshot_rotate`) | | `snapshot rotate` で世代を消し、一覧からも外した | INFO | `ローテーション: 場所が不正な {数} 世代を一覧から外しました`(`_snapshot_rotate`。`rotate` の削除の INFO の後) | @@ -445,6 +520,7 @@ zstd は既にある `full.tar.zst` を上書きしない。仮の名前と空 | 差分の作成の控えのコマンドが失敗(tar の終了コードが 2 以上、または zstd が 0 以外。`checked_backup_command`) | 差分の作成の仮のファイルを消して `SnapshotError`(`Dockerでのtar操作に失敗しました: {stderr}`)→ `create` は終了コード 1、`devbase up` は警告 1 行で起動を続ける。世代と `snapshot.yml` は打つ前のまま | | 差分の作成の snar の写し・差分の改名・snar の改名が失敗(`OSError`) | 差分の作成の仮のファイルを消して `SnapshotError`(`世代 '{名前}' に差分を作れませんでした: {理由}`)→ 同上。snar の改名で止まったときだけ新しい差分が置かれ、snar は打つ前のまま | | 差分の作成の仮のファイルを消せない | 警告 1 行(`差分の作成の仮のファイル {パス} を消せませんでした: {理由}`)で続ける。次の差分の作成が控えの前に消す | +| 置き終えなかった組の snar を `snapshot.snar` へ置けない・消せない(`OSError`) | docker を呼ばずに `SnapshotError`(`世代 '{名前}' の前回の差分の状態ファイル snapshot.snar.pending-NNN を片付けられませんでした: {理由}`)→ `create` は終了コード 1、`devbase up` は警告 1 行で起動を続ける。古い snar から次の差分を作らない | ## 運用 @@ -580,6 +656,13 @@ CLI と TUI: - 実の GNU tar と zstd で、`c` を足して控えを失敗させ(tar が 2・zstd が 1 のそれぞれ)、`e` を足して次の差分を作ると、 `incr-002.tar.zst` の中身が `ai/c` と `ai/e` の両方であること - 実の GNU tar と zstd で、snar の改名で止めた世代へ追加・書き換え・削除の後に次の差分を積んで復元すると、復元先が元のツリーと同じであること +- 実の GNU tar と zstd で、snar の改名で止めた差分がディレクトリを A→B と改名し、次の差分が B→C と改名しても、復元先が C を持つこと +- snar の改名で止めた世代へ次の差分を作ると、控えが組の snar から始まり(打つ前の snar から重ねて控えない)、番号が重ならず、 + 終わった後に仮のファイルと組の snar が残らないこと +- 差分の改名で止めると組の snar が残らないこと。組の差分が置かれていない組の snar は使わずに消えること +- 組の snar を `snapshot.snar` へ置けなければ、docker を呼ばずに組の名前を含む `SnapshotError` で止まり、世代が変わらないこと +- 世代の作り直しが組の snar も消すこと。組の snar の名前が `incr-*.tar.zst` の glob・`is_archive_file`・`_has_previous_files` に当たらないこと +- 仮のファイルを消せないときに警告 1 行で続き、控えの失敗はその失敗の `SnapshotError` のまま返ること ## 関連リンク diff --git a/issues/issue-466-design.md b/issues/issue-466-design.md deleted file mode 100644 index 5426da58..00000000 --- a/issues/issue-466-design.md +++ /dev/null @@ -1,406 +0,0 @@ -# snapshot: 差分の作成が失敗すると snapshot.snar だけが進み、その後の差分が失敗の前の変更を落として復元でファイルを失う → 失敗しても snar と差分の列を打つ前のまま保ち、作りかけの差分を残さない(#466) - -## 目的 - -- **何が壊れているか**: 既存の世代へ差分を積むとき(`devbase snapshot create --name <名前>`・`devbase up` の自動スナップショット)、控えの - コマンドが失敗すると `snapshot.snar` だけが失敗した回の状態へ進む。zstd が書く途中で止まると、作りかけの `incr-NNN.tar.zst` も世代に残る -- **誰が困るか**: 差分の作成が一度失敗した世代(ディスクの不足・docker の失敗など)を復元する利用者。失敗の後の差分は失敗の前の変更を - 含まず、復元でファイルが無いか古い中身になる。作りかけの差分があると、復元はそのアーカイブで止まる -- **直すと何が成り立つか**: 失敗しても世代の `snapshot.snar` と差分の列は打つ前のままで、作りかけの差分は残らない。次の差分は失敗の前の - 変更も含み、復元は最後の差分の時点になる - -## 適用範囲 - -- **働く範囲**: devbase の CLI(`lib/devbase/snapshot/manager.py` の差分の経路)。配布先の利用者の `backups/` の世代に効く。プラグインのリポジトリと - `devbase-snapshot` イメージは触らない -- **プロジェクトごとに違うもの**: 無し(世代の形はプロジェクトに依らない) -- **当たるモード**: `standard` - -## あるべき姿の根拠 - -| 根拠 | 種類 | 何を示すか | -| --- | --- | --- | -| #466 の本文の実測 R1〜R5(`devbase-snapshot:latest`、2026-10-13) | 実測 | 今の控えの形では、zstd がすぐ失敗すると tar が snar を進め(R3・R4)、書く途中で失敗すると作りかけの差分が残って復元が止まり(R1・R2)、tar の致命的な失敗をパイプの終了コードが隠す(R5) | -| この設計の控えの形を同じイメージで打った記録(下の「実測 V1〜V7」) | 実測 | 写した仮の snar と仮の名前の差分へ控えると、R1・R3 と同じ失敗でも世代の snar と差分の列が打つ前のまま残り、次の差分が失敗の前の変更を含んで、復元で元のツリーと同じになる | -| `docs/specifications/snapshot-series.md` の「世代の作り直し」の常に成り立つ条件(控えのコマンドが失敗したら、世代の既存のファイルは 1 バイトも変わらない) | 既存の仕様 | 同じ世代の full の経路で既に約束している条件を、差分の経路へ揃える | - -要求と受け入れ条件は #466 の本文にある(コピーは `issues/issue-466-requirements.md`)。この文書は「どう作るか」だけを扱う。 - -### 実測 V1〜V7 - -2026-10-13、`devbase-snapshot:latest`(Id `b19bbbecb642`、GNU tar 1.35・zstd 1.5.7)を `docker run --rm` で、`/tmp` の作業用のディレクトリを -`/source/ai:ro`・`/source/group:ro`・`/backup` に付けて打った。利用者のボリュームと `devbase up` は使っていない。各回の差分は、ホストで -`snapshot.snar` を `snapshot.snar.new` へ写し、`checked_backup_command(backup_command('incr.tar.zst.new', …, snar='snapshot.snar.new'))` と同じ形を -docker で打ち、成功したらホストで `incr.tar.zst.new` → `incr-NNN.tar.zst`、`snapshot.snar.new` → `snapshot.snar` の順に改名した。失敗したら 2 つの仮の -ファイルを消した。 - -| # | 形 | 結果 | -| --- | --- | --- | -| V1 | full(`ai/a`・`group/g`)→ `incr-001`(`ai/b`)の後に 3MB の `ai/c` を足し、`ulimit -f 100` で zstd が書く途中に止まる形で打つ | `PIPESTATUS=141 153`、判定は失敗。仮のファイルを消した後の世代は `full.tar.zst incr-001.tar.zst snapshot.snar` で、snar は 120 バイト・sha の先頭 `cd657425b788` のまま(打つ前と同じ) | -| V3 | `ai/e` を足し、zstd がすぐ失敗する形(`incr.tar.zst.new` が既にある)で打つ | `PIPESTATUS=141 1`、判定は失敗。仮の snar は 120 バイト、世代の snar は打つ前と同じ(3MB の `ai/c` を含むため、tar は R3 と違い SIGPIPE で止まった) | -| V3' | V7 の後に小さいファイル `ai/k` を足し、V3 と同じ形で打つ | `PIPESTATUS=0 1`、判定は失敗。tar は仮の snar を 129 → 132 バイトに進めた(R3 と同じ振る舞い)が、世代の snar は打つ前と同じで、仮のファイルを消した後の一覧も打つ前と同じ | -| V5 | 存在しないメンバーを渡し、tar が致命的に失敗する形で打つ | `PIPESTATUS=2 0`、判定の終了コードは 1(失敗)。世代の snar は打つ前と同じ | -| V4 | V1・V3・V5 の後に次の差分を打つ | `incr-002.tar.zst` ができ、中身は `ai/ group/ ai/c ai/e`。失敗した回の前に足した `ai/c` と後に足した `ai/e` の両方を含む | -| V6 | full → `incr-001` → `incr-002` の順に展開する | 終了コード 0。復元先は `ai/a ai/b ai/c ai/e group/g`、`ai/c` は元とバイト列が同じ | -| V7 | `ai/f` を足して `ai/b` を消し、差分を `incr-003` として置いて snar を進めずに止める(受け入れ条件 4 の (b))。続けて `ai/h` を足し `ai/f` を書き換えて `incr-004` を打ち、full から `incr-004` まで展開する | `incr-004` の中身は `ai/ group/ ai/f ai/h`(`incr-003` の分を重ねて含む)。復元先は `ai/a ai/c ai/e ai/f ai/h group/g`、`ai/f` は書き換えた後の中身で、消した `ai/b` は無い。`diff -r` で元のツリーと同じ | - -## ドメインモデル - -### コンテキスト - -| コンテキスト | 何の語が 1 つの意味に決まるか | -| --- | --- | -| スナップショット(`snapshot`) | 世代・系列・世代の作り直し・復元前バックアップ。この変更は世代の中の「差分の作成」の手順だけを変える | - -1 つのコンテキストで閉じる。 - -### 集約 - -| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | -| --- | --- | --- | --- | --- | -| 世代 | `SnapshotManager._create_full`(作る・作り直す)と `_create_incremental`(差分を積む)。この変更で触るのは後者と、後者だけが呼ぶ新しい関数 | 世代のディレクトリ `backups/<名前>/` | — | `full.tar.zst`・`incr-NNN.tar.zst`・`snapshot.snar`・`meta.yml` | -| 世代の一覧 | `SnapshotManager._update_global_metadata` | `backups/snapshot.yml` | 世代のエントリ(`name` で識別) | `updated_at`・`incremental_count` | - -`create` は世代を書き終えてから一覧を更新する(今の順のまま)。差分の作成が例外で止まると、一覧は更新されない。 - -### 不変条件 - -| # | 集約 | 条件 | 破れたときの扱い | -| --- | --- | --- | --- | -| I1 | 世代 | 差分の控えのコマンドが失敗したら(tar が 2 以上、または zstd が 0 以外)、または控えの前の snar の写しに失敗したら、世代のファイルの一覧と各ファイルのバイト列は打つ前と同じで、`snapshot.yml` も変わらない | `SnapshotError`。差分の作成の仮のファイルを消してから返す。消せないときだけは仮のファイルが残る(決定 6) | -| I2 | 世代 | `snapshot.snar` は、その状態を作った控えの差分が `incr-NNN.tar.zst` として世代に置かれた後でだけ進む。どこで止まっても、世代は (a) snar も差分の列も打つ前と同じ、(b) 新しい差分が置かれ snar は打つ前と同じ、(c) 新しい差分が置かれ snar が進んだ、のどれかである | (b) で止まったら `SnapshotError`。次の差分は打つ前の snar から控え、(b) の差分の分を重ねて含む | -| I3 | 世代 | 差分の作成の仮のファイル(`incr.tar.zst.new`・`snapshot.snar.new`)は、`incr-*.tar.zst` の glob・`is_archive_file`・`_has_previous_files` のどれにも当たらない。前の中断で残っていれば、次の差分の作成の控えの前に消える | 当たれば番号の決め方・最小間隔・復元の列が仮のファイルを拾う。名前の定数のテストが落ちる | -| I4 | 世代 | 成功した差分の作成の結果(アーカイブの一覧・`snapshot.snar`・`meta.yml`・`snapshot.yml` のエントリ)は今と同じ。差分の作成は `snapshot.snar.bak` を作らない(既にあるものには触らない) | 退行。成功の経路の既存のテストが落ちる | -| I5 | 世代 | 差分の作成の失敗は、`create --name` では終了コード 1、`devbase up` の自動スナップショットでは警告 1 行で起動を続ける | 失敗が素の `OSError` のまま外へ出ると、`cmd_snapshot` が受けずに異常終了する。改名と写しの `OSError` は `SnapshotError` に包む | - -### ドメインイベント - -| # | イベント | 発生元 | 受け手 | -| --- | --- | --- | --- | -| E1 | 差分の作成を始めた(組・控えの起点・除外の一覧の検査を通った) | `create --name <名前>`(`--full` なし、既存の世代)・`_auto_snapshot` → `create(name)` | `_create_incremental` | -| E2 | 前の中断の残りの仮のファイルを消した | `_discard_incremental_temps` | `_create_incremental`(snar の有無の判定へ進む) | -| E3 | 写した仮の snar と仮の名前の差分へ控えた(tar と zstd が終わった) | `_create_incremental`(docker の中の tar・zstd) | `_install_incremental` | -| E4 | 差分を世代の `incr-NNN.tar.zst` として置いた | `_install_incremental` | `_install_incremental`(snar の改名へ進む) | -| E5 | 世代の `snapshot.snar` を差分の後の状態へ進めた | `_install_incremental` | `_create_incremental`(`meta.yml` の更新へ進む) | -| E6 | `meta.yml` と `snapshot.yml` を更新した | `_create_incremental`・`_update_global_metadata` | `restore`・`auto_snapshot_target`・`rotate`(読む側) | -| E7 | 失敗の後に次の差分を作った | 利用者の次の `create`・次の `devbase up` | `_create_incremental`(E1 から) | - -要求の E2 は「E1 の検査を通った」後に置く。この設計では snar の有無の判定(無ければ full へ切り替える)より前に消す(決定 4)。 - -### 用語 - -| 用語 | 意味 | 用語集への反映 | -| --- | --- | --- | -| 世代 | `backups/<名前>/` の 1 つ。`full.tar.zst` 1 つと、0 個以上の `incr-NNN.tar.zst` からなる | 変更なし | -| 系列の最新の世代 | 自動スナップショットが差分を積む世代 | 変更なし | -| 世代の作り直し | 既にある世代へ、前の snar を引き継がずに新しい full を作ること | 変更なし(この変更で手順を変えない) | -| 復元前バックアップ | `restore` が書き戻す前に自動で作るフルの世代 | 変更なし(#473 が触る) | - -## 機能一覧 - -| # | 機能 | 誰が使うか | -| --- | --- | --- | -| F1 | 既存の世代へ差分を積む(成功したときの結果は今と同じ) | `create --name <名前>` を打つ利用者と `devbase up` の自動スナップショット | -| F2 | 差分の作成が失敗しても、世代の snar と差分の列を打つ前のまま保ち、作りかけの差分を残さない | F1 の利用者 | -| F3 | 差分の作成の途中で止まった世代へ、次の差分を欠けなく積む | F1 の利用者 | -| F4 | 差分の作成の失敗を、`create` は終了コード 1、`devbase up` は警告 1 行で知らせる(今と同じ) | F1 の利用者 | -| F5 | この変更より前の devbase で傷んだ世代の扱いを確定仕様と `CHANGELOG.md` で案内する | 利用者 | - -## 構成要素 - -| 要素 | 責務 | -| --- | --- | -| `SnapshotManager._create_incremental`(変更) | 組・控えの起点・除外の一覧の検査(今のまま)の後に `_discard_incremental_temps` を呼ぶ。snar があれば、ホストで `snapshot.snar` を `snapshot.snar.new` へ写し、`checked_backup_command(backup_command(INCR_ARCHIVE_TMP, volumes, excludes, snar=SNAR_FILE_TMP))` を docker で打つ。成功したら `_install_incremental` で置き、`meta.yml` を今と同じ形で更新する。写し・控え・置くのどこで失敗しても、仮のファイルを消して `SnapshotError` を返す。`snapshot.snar.bak` を作る `cp` は外す | -| `SnapshotManager._install_incremental`(追加) | 控えの成功の後に、`incr.tar.zst.new` → `incr-NNN.tar.zst`、`snapshot.snar.new` → `snapshot.snar` の順に `os.replace` で改名する(決定 2)。途中の失敗は `OSError` のまま外へ出す | -| `SnapshotManager._discard_incremental_temps`(追加) | 差分の作成の仮のファイル(`incr.tar.zst.new`・`snapshot.snar.new`)を消す。控えの前(前の中断の残り)と失敗の後に呼ぶ。消せなければ警告して続ける | -| 定数 `INCR_ARCHIVE_TMP`(追加) | 差分の仮の名前 `incr.tar.zst.new`。状態ファイルの仮の名前は作り直しと同じ `SNAR_FILE_TMP`(`snapshot.snar.new`)を使う(決定 4) | -| `backup_command`・`checked_backup_command`(呼ぶだけ) | 形を変えない。`checked_backup_command` の docstring の「世代の作り直し用」を、差分の経路でも使う旨へ直す | -| `_create_full`・`_install_rebuilt_full`・`_discard_rebuild_temps`・`_backup_before_restore`・`restore`・`_apply_incrementals`・`create`・`_update_global_metadata`(変更なし) | 今のまま。`create` が `SnapshotError` を受けずに外へ出すため、失敗では `_update_global_metadata` に届かない | -| `cmd_snapshot`・`_auto_snapshot`(変更なし) | 今のまま `SnapshotError` を終了コード 1・警告 1 行に変える(I5) | -| `docs/specifications/snapshot-series.md` | 差分の作成の手順と失敗の扱い(下の「#473 との境界」の箇所) | -| `CHANGELOG.md` の `[Unreleased]` の `### Fixed` | 利用者に見える変化と、前の devbase で傷んだ世代の案内 | - -```mermaid -graph LR - subgraph CLI - C[snapshot create --name] - U[devbase up の自動スナップショット] - end - subgraph SnapshotManager - CR[create] - CI[_create_incremental] - DT[_discard_incremental_temps] - IN[_install_incremental] - BC[backup_command と checked_backup_command] - CF[_create_full] - UG[_update_global_metadata] - end - D[(docker の中の tar・zstd)] - G[(backups/名前/)] - Y[(backups/snapshot.yml)] - C --> CR - U --> CR - CR --> CI - CI --> DT - CI --> BC - CI -->|snar を写す| G - CI --> D - CI --> IN - CI -->|snar が無い| CF - DT --> G - D --> G - IN --> G - CR --> UG - UG --> Y -``` - -図は処理の要素だけを描き、定数・文書・`CHANGELOG.md` を含めない。`_create_incremental` を呼ぶのは `create` だけである -(`grep -n "_create_incremental" lib/` で `manager.py` の定義と `create` の 1 か所)。`_auto_snapshot` は `create(name=…, full=False)` を通る。 - -## #473 との境界 - -#473(復元前バックアップの名前の重なり。設計なし)は同じスプリントで `manager.py`・`snapshot-series.md`・テスト・`CHANGELOG.md` を触る。 -触る範囲を次のとおり分け、同じ行と隣り合う行を両方が書き換えないようにする。 - -| 置き場 | #466 が触る | #473 が触る | -| --- | --- | --- | -| `manager.py` の関数 | `_create_incremental`、追加する `_install_incremental`・`_discard_incremental_temps`、定数 `INCR_ARCHIVE_TMP`(作り直しの仮の名前の定数の下に足す)、`checked_backup_command` の docstring | `_backup_before_restore` と、名前を決める関数(足すなら `_backup_before_restore` の近くに置く) | -| `manager.py` で両方が触らない | `_create_full`・`_install_rebuilt_full`・`_discard_rebuild_temps`・`backup_command`・`restore`・`_apply_incrementals`・`create`・`_update_global_metadata` | 同左 | -| `snapshot-series.md` | 「構成要素」の表に「差分の作成」の行(「世代の場所の検証」の行の下)、「仕様」に `### 差分の作成` の節(「差分の積み先」の節の末尾の段落の後、「最小間隔」の前)、「データ・設定」の保存先の表の末尾(「アーカイブの mtime」の行の後)に仮のファイルの行、「エラー処理」の表の末尾に差分の経路の行、「運用」の #466 の行(今の 422〜424 行)の置き換え、「テスト観点」の末尾(「関連リンク」の前)に差分の作成の項、「世代の作り直し」の常に成り立つ条件の仮の名前のファイルの文言(今の 326〜327 行)と「テスト観点」の作り直しの項の残るファイルの文言(今の 484 行)を作り直しの仮の名前のファイルへ絞る(決定 4) | 「用語」の表の「復元前バックアップ」の行、「世代の作り直し」の場面の表の復元前バックアップの行、節の末尾の重なりの段落(今の 346〜348 行)、「テスト観点」の作り直しの項のうち重なった復元前バックアップの行(今の 498 行) | -| テスト | 新しいファイル `tests/snapshot/test_incremental_failure.py`、`test_archive_layout.py` の `test_create_and_restore_go_through_the_helpers` の差分の控えのコマンドの行 | `test_rebuild_generation.py` の `test_pre_restore_backup_rebuilds_an_existing_generation`(と #473 が足すテスト) | -| `CHANGELOG.md` の `[Unreleased]` | `### Fixed` に 1 項 | 同じく 1 項。並行してぶつかったら両方の記入を残す | -| `docs/glossary/glossary.json` | 触らない(新しい語を足さない) | 「復元前バックアップ」の意味を変えるなら触る | - -どちらかが境界を越えて触る必要が出たら、越える側の Pull Request の本文に書く。`snapshot-series.md` で #466 が足す節と行は、 -#473 が触る行から 10 行以上離れた所に置く(git の 3 方向のマージは、隣り合う行の変更をぶつかりとして扱うため)。 - -## 構造 - -触る型は `SnapshotManager` だけで、型の関係は変わらない。触るメソッドと定数を示す。 - -```mermaid -classDiagram - class SnapshotManager { - -_create_incremental(name, snap_dir) - -_install_incremental(snap_dir, incr_name) - -_discard_incremental_temps(snap_dir)$ - +checked_backup_command(command)$ str - +backup_command(archive, volumes, excludes, snar)$ str - } - class SnapshotError - class SnapshotCommandError - SnapshotCommandError --|> SnapshotError - SnapshotManager ..> SnapshotError : 写しと改名の失敗で投げる - SnapshotManager ..> SnapshotCommandError : 控えの失敗で投げる(今のまま) -``` - -## データ構造 - -`snapshot.yml` と `meta.yml` の形は変えない。世代のディレクトリに置くファイルの扱いだけが変わる。移行は無い。 - -| ファイル | 今(差分の作成の後) | この変更の後 | -| --- | --- | --- | -| `incr-NNN.tar.zst` | 控えのコマンドが直接書く。失敗で作りかけが残り得る | 控えに成功した後に仮の名前から改名して置く。失敗では作らない | -| `snapshot.snar` | tar が直接書き換える。失敗でも進み得る | 差分を置いた後に仮の名前から改名して進める。失敗では打つ前のまま | -| `snapshot.snar.bak` | 差分のたびに `cp` で作る(戻す処理は無い) | 作らない。前の devbase で作られたものは残る(作り直しが消す) | -| `incr.tar.zst.new`・`snapshot.snar.new` | 無い(`snapshot.snar.new` は作り直しの間だけ) | 差分の作成の間だけある。成功でも失敗でも残さない。プロセスが殺されたときだけ残り、次の差分の作成の控えの前に消える | -| `meta.yml` | 差分の成功で `type: incremental`・`files` に足す・`incremental_count` を更新 | 同じ | -| `snapshot.yml` のエントリ | 差分の成功で `updated_at`・`incremental_count` を更新 | 同じ | - -差分の番号は今のとおり `incr-*.tar.zst` の本数から決める。(b) の状態(差分を置いたが snar を進めずに止まった)では `meta.yml` の `files` -と `incremental_count` がその差分を数えない。`files` を読む処理は無く(`grep -rn "'files'" lib/devbase/snapshot` は full の `meta.yml` を組む行と差分の追記の行の 2 か所だけ)、 -`incremental_count` は次の差分の成功で本数どおりに戻る。 - -| 機能 | `incr-NNN` | `snapshot.snar` | 仮のファイル | `snapshot.snar.bak` | `meta.yml` | `snapshot.yml` | -| --- | --- | --- | --- | --- | --- | --- | -| F1(成功) | 作成(改名) | 更新(改名) | 作成 → 改名 | — | 更新 | 更新 | -| F2(写し・控えの失敗) | — | — | 作成 → 削除 | — | — | — | -| F2(差分の改名の失敗) | — | — | 削除 | — | — | — | -| F2(snar の改名の失敗) | 作成(改名) | — | 削除 | — | — | — | -| F3(前の中断の残り) | — | — | 削除(控えの前) | — | — | — | - -## 入出力の契約 - -`devbase snapshot create` の引数・出力・終了コードと、`devbase up` の出力は変えない。失敗したときの文言は次のとおり。 - -| 場面 | 例外 | 文言 | CLI | -| --- | --- | --- | --- | -| 控えのコマンドの失敗(tar が 2 以上、または zstd が 0 以外) | `SnapshotCommandError`(今のまま) | `Dockerでのtar操作に失敗しました: {stderr}` | `create` は終了コード 1、`up` は警告 1 行 | -| snar の写し・差分の改名・snar の改名の失敗(`OSError`) | `SnapshotError` | `世代 '{名前}' に差分を作れませんでした: {理由}` | 同上 | -| 仮のファイルを消せない | 例外にしない | WARNING `差分の作成の仮のファイル {パス} を消せませんでした: {理由}` | 続ける | - -`create` の終了コード 1 の前に出る行は、今のとおり `cmd_snapshot` の `スナップショット操作に失敗: {文言}` である。 - -## 処理の流れ - -```mermaid -sequenceDiagram - participant CR as create - participant CI as _create_incremental - participant G as backups/名前/ - participant D as docker の tar・zstd - participant IN as _install_incremental - CR->>CI: 差分を積む(名前) - CI->>CI: 組・控えの起点・除外の一覧の検査(今のまま。止まれば SnapshotError) - CI->>G: 仮のファイルを消す(_discard_incremental_temps) - alt snapshot.snar が無い - CI->>CR: _create_full へ切り替える(今のまま) - end - CI->>G: snapshot.snar を snapshot.snar.new へ写す - alt 写しに失敗(OSError) - CI->>G: 仮のファイルを消す - CI-->>CR: SnapshotError - end - CI->>D: checked(tar --listed-incremental=snapshot.snar.new | zstd -o incr.tar.zst.new) - alt tar が 2 以上、または zstd が 0 以外 - CI->>G: 仮のファイルを消す - CI-->>CR: SnapshotCommandError - end - CI->>IN: 置く(incr-NNN) - IN->>G: incr.tar.zst.new → incr-NNN.tar.zst - IN->>G: snapshot.snar.new → snapshot.snar - alt どちらかの改名に失敗(OSError) - CI->>G: 仮のファイルを消す - CI-->>CR: SnapshotError - end - CI->>G: meta.yml を更新(今のまま) - CR->>CR: _update_global_metadata(今のまま) -``` - -差分の作成の間の世代の状態: - -```mermaid -stateDiagram-v2 - [*] --> 打つ前: 検査を通り仮のファイルを消した - 打つ前 --> 控えた: 写しと控えが成功 - 打つ前 --> 打つ前: 写しか控えが失敗し仮のファイルを消した(a) - 控えた --> 差分を置いた: 差分を改名した - 控えた --> 打つ前: 差分の改名が失敗し仮のファイルを消した(a) - 差分を置いた --> snarを進めた: snar を改名した(c) - 差分を置いた --> 差分を置いた: snar の改名が失敗し仮の snar を消した(b) - snarを進めた --> [*]: meta.yml と snapshot.yml を更新 -``` - -「打つ前」「控えた」の間、世代の本来の名前のファイルは 1 つも変わらない(仮のファイルだけが増減する)。「snar を進めた」の後で -`meta.yml` か `snapshot.yml` の書き込みに失敗する扱いは今のまま(要求の対象範囲の外)。描いていない遷移 -(「打つ前」から snar だけが進む、「控えた」から snar が先に進む)は起こらない。 - -## 非機能の実現方式 - -| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | -| --- | --- | --- | --- | -| 性能・拡張性 | 差分の作成で写すのは `snapshot.snar`(KB の桁)だけで、full と差分のアーカイブを写さない。世代のディレクトリが一時に使うディスクは今と同じ(差分 1 本分) | ホストで写すのは `snapshot.snar` 1 本だけ。差分は仮の名前へ 1 本書き、同じディレクトリの中の `os.replace` で改名する(中身を写さない)。`snapshot.snar.bak` を作らなくなる分だけ今より小さい | テストで、控えの前後に世代のディレクトリに増えたファイルが仮のファイル 2 本だけであることを見る。実測 V1〜V7 の一覧 | -| 移行性 | 世代と `snapshot.yml` の形は変えない。この変更の前の devbase へ戻しても、この変更の後に作った世代を読める。仮の名前のファイルが残っていても、前の devbase の glob に当たらない | 世代のファイルの名前と中身の形を変えない。仮の名前は `incr.tar.zst.new`・`snapshot.snar.new` で、前の devbase の `incr-*.tar.zst` の glob・`is_archive_file` に当たらない | I3 のテスト。前の devbase の glob(`incr-*.tar.zst`)と正規表現に仮の名前を当てるテスト | -| 運用・保守性 | 失敗は `SnapshotError` の文言(`Dockerでのtar操作に失敗しました: {stderr}` の形)で、tar と zstd のどちらで止まったかを stderr から読める | 控えの失敗は今の `SnapshotCommandError` の文言のまま返す(tar・zstd の stderr を含む)。写しと改名の失敗は「入出力の契約」の文言で、`OSError` の理由を含む | 控えの失敗のテストで、文言に差し替えた stderr が入ることを見る | - -## 決定の記録 - -### 決定 1: 控えが失敗しても世代の snar を変えないため、写した仮の snar と仮の名前の差分へ控え、成功してから改名で置く - -tar は zstd の成否に関わらず `--listed-incremental` に渡した snar を書き換える(実測 R3)。世代の `snapshot.snar` を tar に渡さず、ホストで写した -`snapshot.snar.new` を渡せば、控えの間に世代の本来の名前のファイルは 1 つも変わらない。控えが失敗したときに戻す処理が要らず、プロセスが -殺されても世代は打つ前のまま残る。世代の作り直しが full の経路で同じ形を採っており、仮の名前・消す時点・失敗の定義を揃えられる。 - -要求の未決 1 のもう 1 つの案(今のとおり `snapshot.snar.bak` を作り、失敗したら `.bak` から snar を戻す)は採らない。戻す操作そのものが -失敗し得て、プロセスが殺されたときは戻す処理が走らず、snar が進んだまま残る。 - -根拠: Vision / Value 2(MVV 版 1) - -### 決定 2: 「snar が進み新しい差分が無い」世代を作らないため、差分を置いてから snar を進める - -改名は 1 本ずつしかできないため、2 本の間で止まる場面が残る。差分を先に置けば、間で止まった世代は「新しい差分が置かれ snar は打つ前と同じ」 -(受け入れ条件 4 の (b))になる。次の差分は打つ前の snar から控えるため、(b) の差分の分を重ねて含み、変更を欠かない(実測 V7。消したファイルも -復元で消える)。snar を先に進めると、間で止まった世代は「snar が進み差分が無い」になり、#466 と同じ欠け方をする。 - -(b) で止まったときに置いた差分を消して (a) へ戻す処理は足さない。消す操作も失敗し得て、(b) は消さなくても安全なためである。 - -根拠: Vision / Value 2(MVV 版 1) - -### 決定 3: 控えのコマンドの形を作り直しと同じに保つため、snar の写しはホストで取る - -`shutil.copyfile` で写してから docker を打つ。控えのコマンドは `checked_backup_command(backup_command(…))` そのままの形になり、テストが -組み立ての関数との一致で縛れる。写しの失敗は docker の前の `OSError` として分かれ、「入出力の契約」の文言で知らせる。写すのは KB の桁の -snar 1 本で、ホストの利用者が書ける世代のディレクトリの中で完結する(作り直しの仮のファイルの改名と同じ前提)。 - -今のようにコンテナの中で `cp … && ` を前に付ける形は採らない。`checked_backup_command` は直前のパイプの `PIPESTATUS` を読むため、`cp` が -失敗すると `PIPESTATUS` が `cp` の 1 要素になり、判定が `[ "" -eq 0 ]` の評価の誤りで偽になるという読みにくい経路で失敗を返す。 - -根拠: Value 2(MVV 版 1) - -### 決定 4: 前の中断の残りを 1 か所で確実に消すため、差分の仮の名前を固定の `incr.tar.zst.new` にし、状態ファイルの仮の名前を作り直しと共有する - -番号を含まない固定の名前にすれば、前の中断の残りは名前 1 つを消すだけで片づき、番号が変わった後の残りを glob で探す処理が要らない。 -`incr.tar.zst.new` は `incr-*.tar.zst` の glob(末尾が `.tar.zst`)にも、`is_archive_file` の正規表現(`incr-<数字>.tar.zst` の全体一致)にも -当たらない。状態ファイルの仮の名前は作り直しの `snapshot.snar.new` を使う。作り直しは控えの前に `snapshot.snar.new` を消すため(今の -`_discard_rebuild_temps`)、差分の作成の残りを空の状態ファイルと取り違えない。差分の作成も控えの前に消してから写すため、作り直しの残りを -引き継がない。 - -前の中断の残りは、snar の有無の判定(無ければ full へ切り替える)より前に消す。snar の無い世代の作り直しにも、差分の作成の残りを -持ち込まないためである。名前を指定した `--full` で作り直す経路(`create` → `_create_full`)は `incr.tar.zst.new` を消さない。作り直しの経路を -変えない前提(要求の前提 3)による。残っても世代のどの読み手にも当たらず、次の差分の作成が消す。`_discard_rebuild_temps` へ -`incr.tar.zst.new` を足さない。 - -このため、作り直しに成功した世代にも `incr.tar.zst.new` が残り得る。`snapshot-series.md` の「世代の作り直し」の常に成り立つ条件 -(今の 326〜327 行「…`snapshot.snar.bak`・仮の名前のファイルを持たない」)と、「テスト観点」の作り直しの項(今の 484 行「…`meta.yml` だけを持ち -(…仮の名前のファイルが無い)」)は、この残りを含めると偽になる。2 か所とも、持たないファイルを「作り直しの仮の名前のファイル(`full.tar.zst.new`・ -`snapshot.snar.new`・`meta.yml.new`)」へ絞り、前の中断で残った `incr.tar.zst.new` は作り直しが消さず次の差分の作成が消すと書き足す -(484 行は「`full.tar.zst`・`snapshot.snar`・`meta.yml` だけを持ち」を、前の中断の `incr.tar.zst.new` を除く形にする)。 -`snapshot.snar.new` は作り直しが控えの前に消すため、差分の作成の残りでも作り直しの後には残らず、絞った条件に入れたままでよい。 - -番号付きの仮の名前(`incr-NNN.tar.zst.new`)は採らない。前の中断の番号と今回の番号が違い得て、消すのに glob が要る。 - -根拠: Value 2(MVV 版 1) - -### 決定 5: 戻す手がかりに見えて使われないファイルを増やさないため、差分の作成は `snapshot.snar.bak` を作らない - -決定 1 で、差分の作成は世代の snar を失敗で変えなくなり、`.bak` から戻す場面が無くなる。作り続けると、読む処理の無いファイルが差分のたびに -書き換わり、利用者が戻す手がかりと読み違える。前の devbase で作られた `.bak` は消さない。差分の作成の仕事の外で、世代の作り直しが今のとおり消す。 -前の devbase も `.bak` を読まないため、作らなくなっても戻した devbase で困らない。 - -根拠: Value 2(MVV 版 1) - -### 決定 6: 受け入れ条件 4 の (a)〜(c) を外れないため、仮のファイルを消せないときは警告して続け、改名の失敗は `SnapshotError` に包む - -世代の本来の名前のファイルを変えるのは 2 回の `os.replace` だけで、それぞれは同じファイルシステムの中で一度に入れ替わる。仮のファイルの -削除は本来の名前のファイルに触らないため、消せなくても世代は (a)〜(c) のどれかのままである。消せない残りは控えの前に消え直し、それでも消せなければ -zstd が既にある出力を拒んで控えが失敗する(実測 V3 の形)ため、残りが差分として使われることは無い。作り直しの仮のファイルと同じ扱いにそろえる -(要求の未決 2)。 - -`OSError` をそのまま外へ出す案は採らない。`cmd_snapshot` は `SnapshotError` しか受けず、`create --name` が終了コード 1 でなく異常終了になる。 - -根拠: Value 2(MVV 版 1) - -## テスト設計 - -単体のテストは `_run_docker_tar` を差し替え、控えのコマンドが書く仮のファイルをコマンドから拾って置く(`tests/snapshot/fake_tar.py` の今の形)。 -実の GNU tar と zstd のテストは `test_backup_real_tar.py` の `LocalTarManager` を使い、どちらかが無ければ飛ぶ。 - -| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | -| --- | --- | --- | -| 受け入れ条件 1・I1 | full と差分 N 本(N = 0・2)の世代へ差分を作り、控えが失敗する(tar が 2・zstd が 1 のそれぞれ。失敗の前に仮のファイルを書く形)と `SnapshotError` になり、世代のファイルの一覧と各ファイルのバイト列と `snapshot.yml` が打つ前と同じ | 世代の `snapshot.snar` を tar に渡す(今の形)、失敗で仮のファイルを消さない、tar の 2 を通すと落ちる | -| 受け入れ条件 1・I1(写しの失敗) | snar の写しが `OSError` で失敗すると `SnapshotError` になり、docker を呼ばず、世代が打つ前と同じ | 写しの失敗を包まない、写しの前に docker を呼ぶと落ちる | -| 受け入れ条件 2 | 実の tar で、`c` を足して控えを失敗させた後に `e` を足して次の差分を作ると、`incr-(N+1).tar.zst` ができ、中身に `c` と `e` の両方がある | 今の形に戻すと `c` が無くて落ちる | -| 受け入れ条件 3 | tar が 1・zstd が 0 の控えは成功として扱い、`incr-(N+1).tar.zst` と進んだ snar が残る | 判定を「tar が 0 のときだけ成功」にすると落ちる | -| 受け入れ条件 4・I2 | 差分の改名・snar の改名のそれぞれで `OSError` を起こすと `SnapshotError` になり、世代は前者で (a)、後者で (b) になる。どちらでも「snar が進み新しい差分が無い」にならない | 改名の順を snar → 差分にすると後者で落ちる。仮のファイルを消さないと落ちる | -| 受け入れ条件 5 | 実の tar で (b) の世代へ次の差分を作ると成功し、full から最後の差分まで復元すると、(b) の前後の変更(追加・書き換え・削除)が最後の時点の中身になる | (b) の後の差分の番号が重なる、または (b) の差分を復元の列から落とすと落ちる | -| 受け入れ条件 6・I3 | 仮の名前の定数が `incr-*.tar.zst` の glob・`is_archive_file`・`_has_previous_files` に当たらない。前の中断の仮のファイルを置いた世代へ差分を作ると、控えの前に消え、`last_snapshot_time` と復元の差分の列に入らない | 仮の名前を `incr-NNN.tar.zst` の形にする、控えの前に消さないと落ちる | -| 受け入れ条件 7・I4 | 成功した差分の作成の後の世代のアーカイブの一覧・`snapshot.snar`・`meta.yml`・`snapshot.yml` のエントリが今と同じで、`snapshot.snar.bak` が無い。前からある `.bak` は残る | `.bak` を作り続ける、`meta.yml` の更新を変えると落ちる | -| 受け入れ条件 8・I5 | 差分の作成が失敗すると `cmd_snapshot` の `create --name` が終了コード 1、`_auto_snapshot` が警告 1 行で続ける。写しと改名の失敗も同じ | `OSError` を包まずに外へ出すと落ちる | -| 受け入れ条件 9 | 作り直し・新しい世代への full・復元前バックアップ・`restore` の既存のテストが変えずに通る。`test_create_and_restore_go_through_the_helpers` の差分の行は `checked_backup_command(backup_command(INCR_ARCHIVE_TMP, …, snar=SNAR_FILE_TMP))` との一致に書き換える | 作り直しの経路のコマンドや仮のファイルの扱いを変えると落ちる | -| 受け入れ条件 11 | 上の 1〜6 の行のテストは、`_create_incremental` を今の形(`cp … .bak && backup_command(incr-NNN)`、失敗で何も戻さない)に戻すと落ちる。実装で戻して落ちることを確かめ、PR に書く | — | -| 受け入れ条件 10 | `snapshot-series.md` の「運用」から #466 の既知の欠陥の行が消え、「エラー処理」に差分の経路の行があり、前の devbase の世代の案内(`--point <その前の番号>`)が「運用」と `CHANGELOG.md` にある | 文書の確認(レビュー) | -| 受け入れ条件 12 | 実装の控えのコマンドを `devbase-snapshot:latest` の `docker run --rm` と作業用のディレクトリで打ち、R1・R3 の形の失敗の後の snar のバイト列・ファイルの一覧・次の差分の中身を PR に記録する(設計の実測 V1〜V7 と同じ形を実装の関数の出力で打つ) | 手動確認 | - -## 設計の結果 - -| 課題 | 扱い | 取り込み先 | 触るファイル | -| --- | --- | --- | --- | -| #466 | 実装する | — | `lib/devbase/snapshot/manager.py`、`tests/snapshot/`、`docs/specifications/snapshot-series.md`、`CHANGELOG.md` | - -## 未確認のまま残ること - -| 項目 | 内容 | -| --- | --- | -| Linux の実機での改名と写し | コンテナが root で書いた `incr.tar.zst.new`・`snapshot.snar.new` をホストの利用者が改名・削除でき、root が書いた `snapshot.snar` をホストで写せること。実測は macOS の Docker Desktop だけで、作り直しの経路と同じく Linux の実機では確かめていない | -| 要求の未決 3 | 利用者のボリュームで tar が 2 以上を返す常の場面があるか(前提 1)。実装の担当が受け入れ条件 12 の記録で確かめ、見つけたら人へ戻す | -| 前の devbase で傷んだ世代 | snar が進んだ世代・作りかけの差分を持つ世代は、ファイルの形から見分けられない。見つける処理は持たず、`CHANGELOG.md` と確定仕様の「運用」で、新しい世代を作り直すことと `--point <その前の番号>` で戻すことを案内する | diff --git a/issues/issue-466-requirements.md b/issues/issue-466-requirements.md deleted file mode 100644 index 9005f968..00000000 --- a/issues/issue-466-requirements.md +++ /dev/null @@ -1,239 +0,0 @@ -# #466: 差分の作成が失敗すると snapshot.snar が進んだまま残り、次の差分が失敗の前の変更を含まず復元でファイルを失う - -正は課題の本文(#466)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 - -## 何を見つけたか - -既存の世代へ差分を積む `devbase snapshot create --name <名前>`(と `devbase up` の自動スナップショット)で、 -控えのコマンドが失敗すると、`snapshot.snar` が失敗した回の状態まで進んだまま残る。 - -- `_create_incremental` は `cp snapshot.snar snapshot.snar.bak && tar --listed-incremental=/backup/snapshot.snar ... | zstd -o incr-NNN.tar.zst` を打つ。 - tar は zstd の成否に関わらず snar を書き換える -- 失敗すると `SnapshotError` になるが、`snapshot.snar.bak` から snar を戻す処理は無い。次の差分の作成で `cp` が進んだ snar を - `.bak` へ上書きし、戻す手がかりも消える -- 次の差分は「失敗した回の時点」からの変更しか持たない。前の差分から失敗した回までに足した・変えたファイルは、どのアーカイブにも入らない - -同じ形(tar が snar を進めた後に zstd が失敗する)を `devbase-snapshot:latest`(GNU tar 1.35・zstd 1.5.7)で確かめた(2026-10-12、 -作業用のディレクトリ。利用者のボリュームは使っていない)。full を作った後に `ai/c` を足し、zstd が失敗する形で控えを打つと -snar が 76 → 80 バイトに進み、その後に積んだ差分の中身は `ai/` だけで `ai/c` が無かった(#458 の設計の実測。#458 は full の経路)。 - -あわせて、zstd が書き込みの途中で失敗した場合は作りかけの `incr-NNN.tar.zst` が世代に残り得る。番号は `incr-*` の本数から -決めるため次の差分は番号を進め、復元は残った作りかけを glob で拾って展開に失敗し得る(実機では確かめていない)。 - -## どこで見つけたか - -`lib/devbase/snapshot/manager.py` の `_create_incremental`(`snapshot.snar.bak` を作る行と、失敗時に何も戻さないこと)。 -#458(既存の世代へ `--full` を打つ経路)の設計で、同じ snar を進める形を調べていて見つけた。 - -## なぜこの変更の範囲外なのか - -#458 の対象範囲は既存の世代のディレクトリへ `full.tar.zst` を作る経路で、「既存の世代へ差分を積む経路(`--full` なしの名前の指定)の -判定の規則」は含まない。#458 の受け入れ条件 5(失敗しても世代のファイルを保つ)は full の経路だけを縛り、差分の経路の失敗の -扱いは受け入れ条件に無い。範囲を広げると受け入れ条件が変わるため、承認ゲート 1 の前に足さない。根拠: Value 2(MVV 版 1) - -## 直さないと何が起きるか - -差分の作成が一度失敗した世代(ディスクの不足・docker の失敗など)では、その後の差分が失敗の前の変更を含まない。その世代を復元すると、 -前の差分から失敗した回までに足した・変えたファイルが無いか古い中身になる。利用者は失敗の後に次の `devbase up` で差分が積めたのを見て -控えが続いていると思うため、復元するまで気づかない。`devbase up` の自動スナップショットは系列の最新の世代へ積むため、名前を指定しない -利用者にも起き得る。 - -## 起票者の推定 - -| # | 条件 | 推した出所 | この要求での扱い | -| --- | --- | --- | --- | -| S1 | 失敗した回の後に積んだ差分が、前の差分から失敗した回までの変更を含まない | 本文の実測は full の後の失敗で、差分の後の失敗は同じ tar の振る舞いからの推定 | 実測 R3・R4 で差分の後の失敗でも確かめた。受け入れ条件 1・2 | -| S2 | 作りかけの `incr-NNN.tar.zst` が残り、復元が失敗し得る | `zstd -o` が途中で失敗したときのファイルの扱いと `_apply_incrementals` の glob。実機では確かめていない | 実測 R1・R2 で確かめた。受け入れ条件 1・6 | - -## 由来 - -issue #458 - -## 依頼(原文) - -> 差分の作成が失敗すると snapshot.snar が進んだまま残り、次の差分が失敗の前の変更を含まず復元でファイルを失う - -(書き手: 起票者、日付: 2026-10-12。課題の題。本文の「何を見つけたか」〜「直さないと何が起きるか」も起票者の文で、#458 の設計の中で見つけた範囲外の課題として書いた) - -> #458 の確定仕様(docs/specifications/snapshot-series.md の「世代の作り直し」)の続き。差分の経路(_create_incremental)の失敗で snapshot.snar を戻すことと、作りかけの incr-NNN.tar.zst を残さないことを決める。同じスプリントの #473(復元前バックアップの名前の重なり。設計なし)と同じファイルを触るので、触る範囲の境界を設計に書く。実機の確かめは devbase-snapshot:latest の docker run --rm と作業用のディレクトリで行い、利用者のボリュームを使わない。 - -(書き手: AI の起動指示、日付: 2026-10-13。スプリント m8c の conductor が要求の工程を起動した指示。利用者の指示の原文ではない) - -利用者の指示の原文は無い。着手は、スプリントのマイルストーン 04 の順位(#466 が 1 位)による。 - -## 目的 - -- 差分の作成(`devbase snapshot create --name <名前>` と `devbase up` の自動スナップショットが既存の世代へ積む経路)が失敗しても、 - その世代の控えの連なりが欠けないようにする。失敗の後に積んだ差分を含めて復元したとき、最後に成功した差分の時点から後の - 変更が復元先に入っている状態にする -- 失敗した回の作りかけのアーカイブが世代に残らず、復元がそれを拾って止まらないようにする -- 確定仕様の「運用」が既知の欠陥として書いている差分の経路の扱い(`snapshot-series.md` の #466 の行)を、守られる条件へ置き換える - -## 実機で確かめたこと - -2026-10-13、`devbase-snapshot:latest`(Id `b19bbbecb642`、作成 2026-09-30)を `docker run --rm` で、`/tmp` の作業用のディレクトリを -`/source:ro` と `/backup` に付けて打った。利用者のボリュームは使っていない。控えのコマンドは今の `_create_incremental` と同じ形 -(`cp snapshot.snar snapshot.snar.bak && tar --listed-incremental=... | zstd -1 -T0 -o incr-NNN.tar.zst`)。 - -| # | 形 | 結果 | -| --- | --- | --- | -| R1 | full → `incr-001`(成功)→ 3MB のファイル `c` を足し、`incr-002` を zstd がファイルの大きさの上限で書く途中に失敗する形で打つ | `PIPESTATUS=141 153`。tar は SIGPIPE で止まり snar は進まなかった。102400 バイトの作りかけの `incr-002.tar.zst` が残った。docker の終了コードは 0(パイプの最後の終了コードを見ない形で打ったため。実装の経路では `SnapshotError` になる) | -| R2 | R1 の世代を full → `incr-001` → `incr-002` の順に展開する | `incr-002` で `zstd: premature end`・`tar: Unexpected EOF in archive`、`PIPESTATUS=1 2`。復元はこのアーカイブで止まる | -| R3 | R1 の作りかけを消し、ファイル `e` を足して、zstd がすぐ失敗する形(出力の名前が既にある)で差分を打つ | `PIPESTATUS=0 1`。tar は小さいデータをパイプへ書き終え、snar が 78 → 84 バイトに進んだ。`.bak` は前の snar のまま | -| R4 | R3 の後に次の差分を打ち、中身を一覧する | 中身は `ai/` だけで `ai/e` が無い。起票者の推定 S1 を差分の後の失敗でも確かめた | -| R5 | 存在しないメンバーを渡し、tar が致命的に失敗する形で打つ | `PIPESTATUS=2 0`、パイプ全体の終了コードは 0。今の差分の経路は、tar の失敗を成功として扱う | - -R5 の形は、今の控えのコマンドではメンバーがマウント先(必ずある)なので起きにくい。ただし tar が読み取りで止まったアーカイブは -R2 と同じく復元を止め得るため、「失敗」の定義に含める(前提 1)。 - -## 前提 - -- 前提 1: 差分の作成の「失敗」は、世代の作り直しの控えのコマンドと同じ定義にする。tar の終了コードが 2 以上、または zstd の - 終了コードが 0 以外。tar の 1(読む間にファイルが変わった)は今と同じく通す。`devbase-snapshot` は root で読み取り専用の - マウントを読むため、利用者のボリュームで tar が 2 以上を返すのは読み取りの致命的な失敗だけである、と置く。外れて自動 - スナップショットの差分が毎回失敗する形が見つかったら、設計か実装でこの前提を見直す -- 前提 2: 差分の作成が失敗したときの利用者から見える扱い(`create` は `SnapshotError` で終了コード 1、`devbase up` は警告 1 行で - 起動を続ける)は今と同じにする。失敗の代わりに full へ切り替える・新しい世代を作るといった振る舞いは足さない -- 前提 3: 世代の作り直しの経路(`_create_full`・`_install_rebuilt_full`・`_discard_rebuild_temps`)と、新しい世代へ直接控える - full の経路の控えのコマンドは変えない。新しい世代への full で tar の失敗を zstd の成功が隠し得ることは、この課題では扱わない - (失敗しても前の控えは無く、消える前のファイルが無いため) -- 前提 4: 世代のファイルの形(`full.tar.zst`・`incr-NNN.tar.zst`・`snapshot.snar`・`meta.yml`)と `snapshot.yml` の形は変えない。 - 差分の作成の途中だけに使う仮の名前のファイルを足してよいが、その名前は `incr-*.tar.zst` の glob・`is_archive_file`・ - `_has_previous_files` のどれにも当たらない形にする -- 前提 5: 実機の確かめは `devbase-snapshot:latest` の `docker run --rm` と作業用のディレクトリで行う。`devbase up` は - 作業用のプロジェクトでも打たない(利用者のボリュームが付く。AGENTS.md の落とし穴・P3)。ボリュームを丸ごと写さない - -## 対象範囲 - -含む: - -- `_create_incremental` の、snar の検査(組・控えの起点・除外の一覧・snar の有無)を通った後の控えから `meta.yml` の更新まで -- 差分の控えのコマンドの失敗の定義(前提 1) -- 失敗したとき・途中で止まったときに、`snapshot.snar` を打つ前の状態に保つ(または戻す)ことと、作りかけの差分を残さないこと -- 前の中断で残った差分の作成の仮の名前のファイルを、次の差分の作成の前に消すこと -- `docs/specifications/snapshot-series.md` の差分の経路に関わる記述(「運用」の #466 の行・「エラー処理」・「テスト観点」)と `CHANGELOG.md` - -含まない: - -- 世代の作り直し(`_create_full` の前のファイルがある経路)・新しい世代への full の控えのコマンド(前提 3) -- 復元前バックアップの名前の重なり(#473)。`_backup_before_restore` と確定仕様の「世代の作り直し」の場面の表の - 復元前バックアップの行・その下の段落・用語の表の「復元前バックアップ」は #473 が触る -- この変更より前の devbase で失敗した差分の作成が残した世代(snar が進んだ世代・作りかけの `incr-NNN.tar.zst` を持つ世代)を - 見つける処理や直す処理。ファイルの形から見分けられないため持たない。案内だけを書く(受け入れ条件 10) -- 復元(`restore`・`_apply_incrementals`)の振る舞い。作りかけを拾わない形は、作成の側で作りかけを残さないことで満たす -- `meta.yml` と `snapshot.yml` の書き込みの失敗の扱い(差分のアーカイブと snar が揃った後の失敗。今の扱いのまま) -- `snapshot.snar.bak` を読む処理を足すこと(作り続けるかは設計で決める。未決 1) - -## #473 との境界 - -同じスプリントの #473 は設計を作らず、`lib/devbase/snapshot/manager.py` と `docs/specifications/snapshot-series.md` を触る。 - -| 置き場 | #466 が触る | #473 が触る | -| --- | --- | --- | -| `manager.py` | `_create_incremental` と、そこから呼ぶ新しい関数。`backup_command` / `checked_backup_command` は呼ぶだけで形を変えない | `_backup_before_restore`(と名前を決める関数) | -| `manager.py` で両方が触らない | `_create_full`・`_install_rebuilt_full`・`_discard_rebuild_temps`・`restore`・`_apply_incrementals` | 同左 | -| `snapshot-series.md` | 「運用」の #466 の行、「エラー処理」の表に差分の経路の行、「テスト観点」に差分の経路の項 | 「世代の作り直し」の場面の表の復元前バックアップの行と、節の末尾の重なりの段落、用語の表の「復元前バックアップ」 | -| テスト | 差分の経路のテスト(新しいファイルか `test_archive_layout.py` の差分の項)。`cp ... .bak &&` を縛る `test_create_and_restore_go_through_the_helpers` の差分の行 | `test_rebuild_generation.py` の `test_pre_restore_backup_rebuilds_an_existing_generation` | -| `CHANGELOG.md` の `[Unreleased]` | `### Fixed` に 1 項 | 同じく 1 項。並行してぶつかったら両方の記入を残す | - -どちらかが境界を越えて触る必要が出たら、越える側の Pull Request の本文に書く。 - -## ドメインイベント - -| # | イベント | 引き金 | 失敗したとき | 順序の前提 | -| --- | --- | --- | --- | --- | -| E1 | 差分の作成を始めた | `create --name <名前>`(`--full` なし、既存の世代)・`devbase up` の自動スナップショットが系列の最新の世代を選んだ | 組・控えの起点・除外の一覧の検査で止まる(今のまま、snar に触らない) | — | -| E2 | 前の中断の残りを消した | E1 の検査を通った | 消せなければ警告して続ける(作り直しの仮のファイルと同じ扱い) | E1 | -| E3 | 差分を控えた(tar と zstd が終わった) | E2 | 前提 1 の失敗。作りかけを消し、snar を打つ前のまま `SnapshotError` | E2 | -| E4 | 差分を世代の `incr-NNN.tar.zst` として置いた | E3 の成功 | 作りかけを消し、snar を打つ前のまま `SnapshotError` | E3 | -| E5 | 世代の `snapshot.snar` を差分の後の状態へ進めた | E4 | E4 の差分は残り、snar は前のまま。次の差分は E4 の分を重ねて含む(欠けない) | E4(E4 より前に snar を進めない) | -| E6 | `meta.yml` と `snapshot.yml` を更新した | E5 | 今の扱いのまま(含まない) | E5 | -| E7 | 失敗の後に次の差分を作った | 次の `create`・`devbase up` | — | E3〜E5 のどこかで止まった後 | - -## 用語 - -| 用語 | 意味 | -| --- | --- | -| 世代 | 用語集のとおり。`backups/<名前>/` の 1 つ | -| 系列の最新の世代 | 用語集のとおり。自動スナップショットが差分を積む世代 | -| 世代の作り直し | 用語集のとおり。この課題では変えない | -| 復元前バックアップ | 用語集のとおり。この課題では変えない(#473) | - -## 受け入れ条件 - -- [ ] 1. 前提: full と差分 N 本(N ≥ 0)を持つ世代がある。操作: その世代へ差分を作り、控えのコマンドが前提 1 の定義で失敗する - (tar が 2 以上・zstd が 0 以外のそれぞれ)。結果: `SnapshotError` になり、世代の `snapshot.snar` は打つ前とバイト列が同じで、 - `full.tar.zst`・`incr-001`〜`incr-N`・`meta.yml` も打つ前とバイト列が同じ。世代のファイルの一覧は打つ前と同じで、 - `incr-(N+1).tar.zst` も仮の名前のファイルも残らない。`snapshot.yml` も変わらない -- [ ] 2. 1 の失敗の後、ファイルを足して同じ世代へ次の差分を作ると、その差分は `incr-(N+1).tar.zst` の番号で作られ、中身に - 失敗した回より前に足したファイルと後に足したファイルの両方を含む(実測 R3・R4 の形が直っている) -- [ ] 3. tar が 1(読む間にファイルが変わった)・zstd が 0 の差分は今と同じく成功として扱い、`incr-(N+1).tar.zst` と進んだ snar を残す -- [ ] 4. 差分の作成が、控えが終わった後の各段(差分を世代へ置く・snar を進める)で例外で止まっても、世代は次のどれかの状態で残る。 - (a) snar も差分の列も打つ前と同じ、(b) 新しい差分が置かれ snar は打つ前と同じ、(c) 新しい差分が置かれ snar が進んだ。 - 「snar が進み、新しい差分が無い」状態にはならない。テストは各段で失敗を起こしてファイルの一覧と snar のバイト列で確かめる -- [ ] 5. 4 の (b) の世代へ次の差分を作ると成功し、その差分は (b) で置かれた差分の中身を重ねて含んでよいが、失敗した回の後の - 変更を欠かない。復元は (b) の差分とその後の差分を番号の順に当てて、最後の差分の時点になる -- [ ] 6. 差分の作成の途中の仮の名前のファイル(使うなら)は、`incr-*.tar.zst` の glob・`is_archive_file`・`_has_previous_files` に - 当たらない。前の中断でそれが残った世代へ差分を作ると、控えの前に消され、最小間隔の判定(`last_snapshot_time`)と復元の - 差分の列に入らない -- [ ] 7. 成功した差分の作成の結果(世代のファイルの一覧のうちアーカイブ・`snapshot.snar`・`meta.yml` の中身、`snapshot.yml` の - エントリ)は今と同じ。`snapshot.snar.bak` の扱いは設計の決定に従い、変えたら確定仕様に書く -- [ ] 8. 差分の作成が失敗したとき、`devbase snapshot create --name <名前>` は終了コード 1、`devbase up` の自動スナップショットは - 警告 1 行で起動を続ける(今と同じ) -- [ ] 9. 世代の作り直し・新しい世代への full・復元前バックアップ・`restore` の既存のテストが、変えずに通る - (`test_create_and_restore_go_through_the_helpers` の差分の控えのコマンドの形を縛る行は、新しい形へ書き換えてよい) -- [ ] 10. `docs/specifications/snapshot-series.md` の「運用」の #466 の行を、差分の経路で守られる条件へ置き換え、「エラー処理」の表に - 差分の控えのコマンドの失敗の行を足す。この変更より前の devbase で差分の作成が失敗した世代は失敗の前の変更を欠き得ること、 - 作りかけの差分で復元が止まる世代は `devbase snapshot restore <名前> --point <その前の番号>` でその前の時点へ戻せることを - 「運用」に残す。`CHANGELOG.md` の `[Unreleased]` の `### Fixed` に、利用者に見える変化と同じ案内を書く -- [ ] 11. 1〜6 を縛るテストは、実装を今の形(`cp ... .bak && tar ... | zstd ...`、失敗時に何も戻さない)に戻すと落ちる -- [ ] 12. `devbase-snapshot:latest` の `docker run --rm` と作業用のディレクトリで、実装の控えのコマンドに対して R1・R3 の形の失敗を - 起こし、1・2 の結果(snar のバイト列・ファイルの一覧・次の差分の中身)を記録に残す。利用者のボリュームと `devbase up` は使わない - -## 非機能の条件 - -| 大項目 | 条件 | -| --- | --- | -| 性能・拡張性 | 差分の作成で写すのは `snapshot.snar`(KB の桁)だけで、full と差分のアーカイブを写さない。世代のディレクトリが一時に使うディスクは今と同じ(差分 1 本分) | -| 移行性 | 世代と `snapshot.yml` の形は変えない(前提 4)。この変更の前の devbase へ戻しても、この変更の後に作った世代を読める。仮の名前のファイルが残っていても、前の devbase の glob に当たらない | -| 運用・保守性 | 失敗は `SnapshotError` の文言(`Dockerでのtar操作に失敗しました: {stderr}` の形)で、tar と zstd のどちらで止まったかを stderr から読める | - -## 影響 - -| 対象 | 影響 | -| --- | --- | -| 公開インタフェース | 変わらない(CLI の引数・出力・終了コードは今と同じ) | -| データ | 形は変わらない。差分の作成の途中だけ仮の名前のファイルが世代に現れ得る | -| 既存の振る舞い | 差分の作成で tar が 2 以上を返すと、これまで成功だったものが失敗になる(前提 1)。失敗の後の世代の状態が変わる(snar を戻し、作りかけを消す) | - -## 検証手段 - -| 項目 | 手段 | -| --- | --- | -| テスト | `env -u DEVBASE_ROOT uv run --locked pytest tests/ -q` | -| 静的解析 | `uvx ruff check --select=E9,F63,F7,F82 lib`、`python3 .github/scripts/proper_term_check.py` | -| 手動確認(マージ前) | 受け入れ条件 12。`devbase-snapshot:latest` の `docker run --rm` と作業用のディレクトリで、実装の控えのコマンドに失敗を起こして結果を PR に残す(担当が打てる。利用者のボリュームと `devbase up` は使わない) | - -## 前提とする取り決め - -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | `AGENTS.md` の「このリポジトリ」。実装は `lib/devbase/snapshot/`、テストは `tests/snapshot/` | -| コーディング規約 | `AGENTS.md` の「書き方」・周りのコードの書き方。確認は「検査」の 3 つのコマンド | -| テスト戦略 | 単体: `_run_docker_tar` を差し替えて失敗を起こし、世代のファイルとバイト列を見る(`tests/snapshot/` の既存の形)。実機: 受け入れ条件 12 | - -## 境界 - -| 区分 | 内容 | -| --- | --- | -| 常に行う | `env -u DEVBASE_ROOT` での全体のテスト、lint、固有の語の検査。実機の記録は中立の名前で書く | -| 確認してから行う | 世代のファイルの形を変えること(前提 4 を外れる)、#473 の境界を越えて触ること(承認ゲート 2 で見る) | -| 行わない | `devbase up`(作業用のプロジェクトを含む)・利用者のボリュームを付けた `docker run`・ボリュームの丸写し。`devbase-snapshot` イメージと base イメージの変更(P1)。世代の作り直しの経路の変更 | - -## 未決 - -| 項目 | 誰が決めるか | 期限 | -| --- | --- | --- | -| 1. snar を守る方式(仮の名前の snar へ写して控え、成功したら差分 → snar の順に改名する/今の `.bak` から戻す)と、`snapshot.snar.bak` を作り続けるか | 設計(`design`) | 承認ゲート 1 | -| 2. snar を戻す・消すのに失敗したとき(ディスクの I/O の失敗)の扱い。受け入れ条件 4 の (a)〜(c) を外れない形を設計で示す | 設計 | 承認ゲート 1 | -| 3. 前提 1 が外れる形(利用者のボリュームで tar が 2 以上を返す常の場面)があるか | 実装の担当が実機の記録(受け入れ条件 12)で確かめ、見つけたら人へ戻す | 承認ゲート 2 | From 5f2924d0f6ccda162650a912a545e0c0122c18b3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 13 Oct 2026 04:06:31 +0900 Subject: [PATCH 2/3] =?UTF-8?q?Docs:=20derived-image-language-servers.md?= =?UTF-8?q?=20=E3=82=92=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=AB?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/glossary.md | 8 +- docs/glossary/glossary.json | 16 +- .../derived-image-language-servers.md | 210 ++++++++++++ issues/issue-236-design.md | 311 ------------------ issues/issue-236-requirements.md | 193 ----------- 5 files changed, 220 insertions(+), 518 deletions(-) create mode 100644 docs/specifications/derived-image-language-servers.md delete mode 100644 issues/issue-236-design.md delete mode 100644 issues/issue-236-requirements.md diff --git a/docs/glossary.md b/docs/glossary.md index 5d519e64..e2a1e228 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -202,10 +202,10 @@ base の設定が派生イメージへ届く道筋(containers/*/Dockerfile) | 直の親の読み方 | — | Dockerfile の 1 行から直の親イメージを読む規則。正規表現の値を lib/devbase/utils/dockerfile.py が正本として持ち、bin/devbase が同じ値を写す | — | — | `docs/specifications/base-image-chain-build.md` | | プロジェクトの Dockerfile | — | プロジェクトの compose の構成で、開発サービス名のサービスの build(context と dockerfile。文字列の形は context だけ)が指す Dockerfile。開発サービスが build を持たないときは無い | — | — | `docs/specifications/base-image-chain-build.md` | | Dockerfile の場所の決め方 | — | compose の構成からプロジェクトの Dockerfile のパスを決める規則。通常のビルドと --expires の判定が同じ規則を使う | — | — | `docs/specifications/base-image-chain-build.md` | -| 言語サーバ | — | エディタや AI の道具へ、定義・参照・診断を Language Server Protocol で返すプログラム。#236 では pyright-langserver・typescript-language-server・intelephense を general・php・php85 の派生イメージの言語サーバの置き場に置く | — | — | — | -| uv のキャッシュ | — | uv / uvx が取得したパッケージと作った環境を置く場所。置き場は UV_CACHE_DIR、既定は ~/.cache/uv。base の片付けが消し、コンテナの層にあるため作り直しでも消える。#236 では派生イメージで温めない | — | — | — | -| 言語サーバの置き場 | — | 派生イメージが言語サーバとその依存(typescript 5 を含む)を入れる /opt/language-servers。持ち主は root で利用者は書けない。PATH に出すのは bin の 3 つのコマンドだけ | — | — | — | -| 言語サーバの組 | — | 言語サーバの置き場へ入れる npm のパッケージの名前と厳密な版の 4 組。対象の派生イメージのすべてで同じ | — | — | — | +| 言語サーバ | — | エディタや AI の道具へ、定義・参照・診断を Language Server Protocol で返すプログラム。pyright-langserver・typescript-language-server・intelephense を general・php・php85 の派生イメージの言語サーバの置き場に置く | — | — | `docs/specifications/derived-image-language-servers.md` | +| uv のキャッシュ | — | uv / uvx が取得したパッケージと作った環境を置く場所。置き場は UV_CACHE_DIR、既定は ~/.cache/uv。base の片付けが消し、コンテナの層にあるため作り直しでも消える。派生イメージは温めない | — | — | `docs/specifications/derived-image-language-servers.md` | +| 言語サーバの置き場 | — | 派生イメージが言語サーバとその依存(typescript 5 を含む)を入れる /opt/language-servers。持ち主は root で利用者は書けない。PATH に出すのは bin の 3 つのコマンドだけ | — | — | `docs/specifications/derived-image-language-servers.md` | +| 言語サーバの組 | — | 言語サーバの置き場へ入れる npm のパッケージの名前と厳密な版の 4 組。対象の派生イメージのすべてで同じ | — | — | `docs/specifications/derived-image-language-servers.md` | ## コンテナの起動(`container-start`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index e40f2fc4..fcd68b7e 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -994,30 +994,26 @@ { "term": "言語サーバ", "context": "image-lineage", - "meaning": "エディタや AI の道具へ、定義・参照・診断を Language Server Protocol で返すプログラム。#236 では pyright-langserver・typescript-language-server・intelephense を general・php・php85 の派生イメージの言語サーバの置き場に置く", - "source": "", - "pending_source": "issues/issue-236-design.md" + "meaning": "エディタや AI の道具へ、定義・参照・診断を Language Server Protocol で返すプログラム。pyright-langserver・typescript-language-server・intelephense を general・php・php85 の派生イメージの言語サーバの置き場に置く", + "source": "docs/specifications/derived-image-language-servers.md" }, { "term": "uv のキャッシュ", "context": "image-lineage", - "meaning": "uv / uvx が取得したパッケージと作った環境を置く場所。置き場は UV_CACHE_DIR、既定は ~/.cache/uv。base の片付けが消し、コンテナの層にあるため作り直しでも消える。#236 では派生イメージで温めない", - "source": "", - "pending_source": "issues/issue-236-design.md" + "meaning": "uv / uvx が取得したパッケージと作った環境を置く場所。置き場は UV_CACHE_DIR、既定は ~/.cache/uv。base の片付けが消し、コンテナの層にあるため作り直しでも消える。派生イメージは温めない", + "source": "docs/specifications/derived-image-language-servers.md" }, { "term": "言語サーバの置き場", "context": "image-lineage", "meaning": "派生イメージが言語サーバとその依存(typescript 5 を含む)を入れる /opt/language-servers。持ち主は root で利用者は書けない。PATH に出すのは bin の 3 つのコマンドだけ", - "source": "", - "pending_source": "issues/issue-236-design.md" + "source": "docs/specifications/derived-image-language-servers.md" }, { "term": "言語サーバの組", "context": "image-lineage", "meaning": "言語サーバの置き場へ入れる npm のパッケージの名前と厳密な版の 4 組。対象の派生イメージのすべてで同じ", - "source": "", - "pending_source": "issues/issue-236-design.md" + "source": "docs/specifications/derived-image-language-servers.md" } ] } diff --git a/docs/specifications/derived-image-language-servers.md b/docs/specifications/derived-image-language-servers.md new file mode 100644 index 00000000..9698f1d1 --- /dev/null +++ b/docs/specifications/derived-image-language-servers.md @@ -0,0 +1,210 @@ +# 派生イメージの言語サーバ(Python・TypeScript・PHP) + +## 概要 + +`general`・`php`・`php85` の派生イメージは、Python・TypeScript・PHP の**言語サーバ**を持つ。これらのイメージから作った +コンテナでは、入った時点で 3 つのコマンドが PATH の上にあり、Claude Code の公式 LSP プラグインが `--stdio` で起動できる。 + +| コマンド | 言語 | パッケージ | +| --- | --- | --- | +| `pyright-langserver` | Python | `pyright` | +| `typescript-language-server` | TypeScript・JavaScript | `typescript-language-server`(同じ置き場の `typescript` 5 を使う) | +| `intelephense` | PHP | `intelephense` | + +- 言語サーバは base ではなく派生イメージに置く。base は重くしない([プロジェクト MVV](../../.ndf/mvv.md) の Value 1)。 + base の Dockerfile と `containers/base/` の下は言語サーバのために変えない +- 言語サーバは専用の**言語サーバの置き場** `/opt/language-servers` に root で入り、利用者の `npm i -g` の領域 + (base の npm のグローバル領域)とは分かれる。`tsc` は base と同じ版のままである +- 版は Dockerfile で厳密に固定し、3 つのイメージで同じ**言語サーバの組**にする。静的な検査がこれを縛る +- Serena の uv のキャッシュはイメージで温めない。Serena の MCP は初回の起動で `serena-agent` と自分の言語サーバを取得する + +プラグイン(公式 LSP プラグイン)の導入は利用者の `~/.claude` の設定であり、この仕様の外である。 + +## 用語 + +言語サーバ・言語サーバの置き場・言語サーバの組・uv のキャッシュ・派生イメージ・展開後の大きさの定義は +[用語集: イメージの継承(`image-lineage`)](../glossary.md#イメージの継承image-lineage) にある。 + +## 対象範囲 + +- `containers/general/Dockerfile`・`containers/php/Dockerfile`・`containers/php85/Dockerfile` の言語サーバを入れる `RUN` と `ENV PATH` +- その形を縛る静的な検査 `tests/containers/test_language_servers_dockerfile.py` + +含まない: + +- base(`containers/base/`)。base の `npm i -g` が入れる `typescript`(`tsc`)の版 +- ほかの派生イメージ(`browser`・`go`・`latex`・`bi-tools`・`trygroup`)。言語サーバを持たない +- Python・TypeScript・PHP のほかの言語(Go の `gopls` など) +- Serena(mcp-serena)の起動定義・言語サーバの取得。ai-plugins の領分である +- どの派生イメージを使うか。各プロジェクトの `compose.yml` の dev サービスの `build.context` が決める。言語サーバの有無を + 切り替える引数や環境変数は持たない + +## 構成要素 + +| 要素 | 置き場所 | 責務 | +| --- | --- | --- | +| 言語サーバの `RUN` | `containers/general/Dockerfile`・`containers/php/Dockerfile`・`containers/php85/Dockerfile` | `USER root` の区画で言語サーバの組を `npm install --prefix /opt/language-servers` で入れ、3 つのコマンドの symlink を置き場の `bin` に作り、`typescript-language-server --version` で入ったことを確かめ、npm のキャッシュ(`/root/.npm`)を消す。`php`・`php85` では Composer の `COPY` の後に置く | +| `ENV PATH` | 同上(`RUN` の直後) | `/opt/language-servers/bin` を PATH の先頭へ足す | +| `USER ubuntu` | 同上(`ENV PATH` の後) | 利用者へ戻す | +| 言語サーバの形の検査 | `tests/containers/test_language_servers_dockerfile.py` | Docker を起動せずに、3 つの Dockerfile の文字列から下の常に成り立つ条件 I1〜I7 を縛る。対象のイメージの名前(`IMAGES`)を検査の中に持つ。Dockerfile を命令に分けるのは `tests/containers/dockerfile_parse.py` の `parse` | + +型(クラス)は持たない。Dockerfile の命令と静的な検査だけで構成する。 + +```mermaid +graph TD + subgraph 派生イメージから作ったコンテナ + BIN["/opt/language-servers/bin(3 つのコマンドの symlink)"] + NM["/opt/language-servers/node_modules(typescript 5 を含む)"] + GL["npm のグローバル領域(base の tsc。変えない)"] + CC["Claude Code と公式 LSP プラグイン"] + SE["Serena の MCP(uvx)"] + UVC["uv のキャッシュ(~/.cache/uv。コンテナの層)"] + SLS["作業ツリーの .serena/language_servers"] + end + CC -->|"--stdio で起動"| BIN + BIN --> NM + SE -->|"serena-agent と pyright を取得"| UVC + SE -->|"TypeScript・PHP の言語サーバを取得"| SLS +``` + +Serena の MCP はイメージの言語サーバを使わないため、`SE` から `BIN` への線は無い(下の「Serena」)。 + +## 仕様 + +### 常に成り立つ条件 + +| # | 条件 | 破れたときの扱い | +| --- | --- | --- | +| I1 | 対象の 3 つの Dockerfile が、それぞれ 1 つの `RUN` で `npm install --prefix /opt/language-servers` により `pyright`・`typescript-language-server`・`typescript`・`intelephense` の 4 つを入れる | 形の検査が、欠けたパッケージを挙げて落ちる | +| I2 | 4 つのパッケージの版がすべて厳密な版(`<名前>@<数字>.<数字>.<数字>`)で、`typescript` の版が `5.` で始まる | 形の検査が、範囲・`latest`・版の無い指定・5 以外の `typescript` を挙げて落ちる | +| I3 | 3 つの Dockerfile の言語サーバの組(名前と版)が同じである | 形の検査が、食い違うイメージと組を挙げて落ちる | +| I4 | 言語サーバの `RUN` は `npm i -g` / `npm install -g`(`--global`)を使わない。置き場の `bin` に張る symlink は 3 つのコマンドだけで、それぞれ `/opt/language-servers/node_modules/.bin/<コマンド>` を指す | 形の検査が落ちる。建てたイメージの `tsc --version` が base の版でなくなる | +| I5 | `ENV PATH` が `/opt/language-servers/bin` を含み、`node_modules` を含まない | 形の検査が落ちる。非対話のシェル(`docker exec`)でコマンドが見つからない、または置き場の `tsc` が見える | +| I6 | 対象の 3 つの Dockerfile に `UV_CACHE_DIR` と `serena-agent` が無い | 形の検査が落ちる | +| I7 | 言語サーバの `RUN` の前の最後の `USER` が `root`、後の最後の `USER` が `ubuntu` で、`RUN` が `rm -rf /root/.npm` を含む | 形の検査が落ちる | + +base の `containers/base/` が言語サーバのために変わらないことは、検査ではなく変更の差分で見る。 + +### 置き場と PATH + +言語サーバは `/opt/language-servers` に root が入れる。持ち主は root で、利用者 `ubuntu` は読みと実行だけができる +(書けない)。版を上げるのはイメージの Dockerfile の変更だけで、利用者の `npm` では上がらない。 + +PATH に出すのは置き場の `bin` の 3 つのコマンドだけである。`node_modules/.bin` を PATH に出さないため、置き場の `tsc`・`tsserver` は +見えず、`tsc` は base の npm のグローバル領域のものに当たる。`ENV PATH` で足すため、ログインシェルでない非対話のシェルでも +3 つのコマンドが見つかる。 + +**専用の置き場にする理由**: `typescript-language-server` は TypeScript 5 を要るが、base のグローバル領域の `typescript` は版を固定せずに入る最新の版で、 +建てた時点(2026-10-13)では 7.0.2 で `lib/tsserver.js` を持たない。グローバル領域へ `typescript@5` を入れると派生イメージの `tsc` が 5 へ下がり、利用者や道具の更新が +同じ領域の `typescript` を上げると TypeScript 5 の解決が黙って崩れる。`node_modules/.bin` を PATH の末尾へ足す形は、`tsc` がどちらに +当たるかが PATH の並びだけで決まるため採らない。 + +**root で入れる理由**: 利用者が更新する領域(`bi-tools`・`trygroup` が `USER ubuntu` で `npm i -g` する `ubuntu:npm` の領域)に置くと、 +固定した版と動いている版が食い違っても検査で気づけない。 + +### TypeScript の解決 + +`typescript-language-server` は、実体のパス(`realpath`)の親を順にたどって最初に見つかる `node_modules/typescript` を使う。 +置き場に `typescript` 5 を一緒に入れるため、それは `/opt/language-servers/node_modules/typescript` で、`lib/tsserver.js` を持つ。 +ai-plugins のインストールチェック(mcp-serena の `check`)の `typescript_major_5` も同じたどり方で、この置き場に当たる。 + +### 対象のイメージ + +言語サーバを持つのは `general`・`php`・`php85` の 3 つで、3 つとも 3 つの言語をすべて持つ。 + +**3 つの言語をすべて入れる理由**: `general` は多くのプロジェクトが使う汎用の派生イメージで、どの言語のコードを扱うかをイメージの側から +決められない。PHP のプロジェクトは画面の側に TypeScript・JavaScript を持つことが多い。組を 1 つにすれば、言語の欠けが利用者の選んだ +イメージで変わらず、3 つの Dockerfile を同じ検査(I3)で揃えられる。 + +ほかの派生イメージには入れない。`go` は対象の外の言語を主にし、`latex` はコードを扱わず、`bi-tools`・`trygroup` は特定のプロジェクトの +ための派生イメージである。要るときは同じ `RUN` を写し、検査の `IMAGES` に名前を足す。 + +**同じ `RUN` を各 Dockerfile に書く理由**: 派生イメージは `containers/<名前>/` をビルドの文脈にして建つ(各プロジェクトの `compose.yml` の +`build.context`)。文脈の外のファイルは `COPY` できず、共通のスクリプトや `ARG` で版を渡す形は、プラグインのリポジトリの `compose.yml` を +変えないと成り立たない。組の一致は検査(I3)で縛る。 + +### Serena + +Serena の MCP(ai-plugins の mcp-serena)は `uvx --from serena-agent==<版>` で起動し、PATH の上の言語サーバを使わない。Python は `uvx` で +`pyright` を、TypeScript と PHP は作業ツリーの `.serena/language_servers/` へ自分の固定した版を取得する。 + +派生イメージは Serena の uv のキャッシュを温めない(`UV_CACHE_DIR` も `serena-agent` も持たない。I6)。**Serena の MCP の初回の起動 +(コンテナを作り直した後の最初の起動を含む)にはネットワークが要る。** これは言語サーバを持たない派生イメージと同じ振る舞いである。 + +**温めない理由**: `serena-agent` だけを温めても、Serena が言語を扱い始める時点の取得は残る。温めるには 1 つのイメージにつき約 131MB が要り +(キャッシュを削るとオフラインの起動が壊れる)、ai-plugins が版の固定を上げるたびに温めたキャッシュは外れて重さだけが残る。イメージが +Serena の版を持たなければ、ai-plugins の版の固定と揃える作業が生まれない。 + +## データ・設定 + +言語サーバの組(3 つの Dockerfile で同じ): + +| パッケージ | 版 | +| --- | --- | +| `pyright` | 1.1.414 | +| `typescript-language-server` | 6.0.2 | +| `typescript` | 5.9.3 | +| `intelephense` | 1.18.5 | + +`npm install` には `--omit=dev --no-audit --no-fund` を付ける。4 つのパッケージは JavaScript だけで、アーキテクチャごとの取得物を持たない。 +Node は base のものを使う。 + +展開後の大きさ(arm64・Docker Desktop の containerd のイメージストア・2026-10-13。言語サーバを入れる前と後の同じ Dockerfile を同じ base の上に +建て、コンテナで `du -sbx /` を測ったバイト数): + +| イメージ | 前 | 後 | 増分 | +| --- | ---: | ---: | ---: | +| `general` | 2,969,540,486 | 3,118,859,637 | 149,319,151 | +| `php` | 3,135,202,864 | 3,284,522,031 | 149,319,167 | +| `php85` | — | — | 149,319,167 | + +`php85` は増分だけを記録した。 + +1 つの派生イメージで増える展開後の大きさは 350,000,000 バイト以下に保つ。 + +## 運用 + +- 言語サーバは**イメージを建て直すまで届かない**。使っている派生イメージを `devbase build` で建て直し、稼働中のコンテナは作り直す。 + base を建て直す必要は無い +- 版を上げるときは、3 つの Dockerfile の言語サーバの組を同時に同じ版へ変える(I3)。`typescript` は 5 系に留める(I2)。 + `typescript-language-server` の新しい版が TypeScript 5 を外したら、組の全体を見直す +- 各プロジェクトの `compose.yml` は自分の名前のタグで `containers/general/` などを建てる。ビルドのキャッシュに当たれば層は共有される + (同じ Dockerfile の 2 つ目のタグはキャッシュだけで建った)。キャッシュ無しで建て直すとプロジェクトごとに約 149MB の層が別に残り得る +- 言語サーバのコマンドの有無と起動は、建てたイメージで `--entrypoint bash` を付けて確かめる。派生イメージは base の entrypoint を継ぎ、 + entrypoint は `DEVBASE_ACCOUNT_GROUP` が無いと止まる。`DEVBASE_ACCOUNT_GROUP` を渡して entrypoint を通すと、ボリュームや機密の用意へ進み + 実の環境に触れ得る。`devbase up` で起動したコンテナでは確かめない +- amd64 での建てと、利用者のコンテナの中で公式 LSP プラグインの診断が届くことは、自動テストでは確かめていない + +## テスト観点 + +`tests/containers/test_language_servers_dockerfile.py`(Docker を要さない。`general`・`php`・`php85` のそれぞれ): + +- I1: `--prefix /opt/language-servers` を持つ `RUN` が 1 つだけあり、`npm install` が入れるパッケージの名前が 4 つと一致すること +- I2: 4 つの版が `<数字>.<数字>.<数字>` に完全一致し、`typescript` が `5.` で始まること。版の無い指定は落ちること +- I3: 3 つのイメージの名前と版の組が同じであること +- I4: `RUN` に `npm i` / `npm install` の `-g`・`--global` が無く、`ln -s` の宛先が置き場の `bin` の 3 つのコマンドだけで、 + 元が `node_modules/.bin/<同じ名前>` であること +- I5: `ENV PATH=` に `/opt/language-servers/bin:` があり、`node_modules` が無いこと +- I6: 命令の本文に `UV_CACHE_DIR` と `serena-agent` が無いこと +- I7: `RUN` より前の最後の `USER` が `root`、後の最後の `USER` が `ubuntu` で、`RUN` に `rm -rf /root/.npm` があること + +建てたイメージで確かめること(手動。CI はイメージを建てない。手元の base の上に作業用のタグで建て、`docker run --rm --entrypoint bash` +で打つ。作業用のタグは記録を書いたら消す): + +- 非対話の bash の `command -v` が 3 つのコマンドの `/opt/language-servers/bin/` のパスを返すこと +- 3 つのコマンドを `--stdio` で起動して LSP の `initialize` を送ると、`result.capabilities` を持つ応答が 30 秒以内に返ること +- `typescript-language-server` の `realpath` の親をたどって最初に見つかる `node_modules/typescript/package.json` の版が `5.` で始まり、 + `lib/tsserver.js` があること +- ai-plugins の mcp-serena の `check`(ランタイムは Claude Code)で、Python・TypeScript・PHP の `binary` と `typescript_major_5` の欠けが出ないこと + (`plugin` の欠けは利用者の設定のため問わない) +- `tsc --version` が base と同じ版を返すこと。`ubuntu` から `/opt/language-servers` に書けないこと +- 変更の前後で展開後の大きさを測り、増分が 350,000,000 バイト以下であること。測った端末(アーキテクチャ)とイメージストアを記録すること +- amd64 でも同じこと(リリース後テスト)。利用者のコンテナの中の Claude Code で `.py` を編集し、公式 LSP プラグインの診断が届くこと + +## 関連リンク + +- [base イメージの中身の絞り込み(文書の除外と DinD の廃止)](base-image-contents.md)(派生イメージの一覧と文書の除外) +- [base イメージの文字の描画と、文書を扱う道具](base-image-rendering.md)(ブラウザの派生イメージ) +- [派生イメージの継承の連なりのビルド(直の親の読み方)](base-image-chain-build.md) +- [用語集: イメージの継承(`image-lineage`)](../glossary.md#イメージの継承image-lineage) +- [コンテナ操作ガイド](../user/container-operations.md) diff --git a/issues/issue-236-design.md b/issues/issue-236-design.md deleted file mode 100644 index f6609dfc..00000000 --- a/issues/issue-236-design.md +++ /dev/null @@ -1,311 +0,0 @@ -# 派生イメージ: コンテナに入っても Python・TypeScript・PHP の言語サーバが無く、Claude Code の診断が届かない → general・php・php85 の派生イメージに言語サーバを入れ、入った時点で診断が使える(#236) - -## 目的 - -- **何が壊れているか**: base とその派生イメージに言語サーバ(`pyright-langserver`・`typescript-language-server`・`intelephense`)が無い。base の `typescript` は 7.0.2 で `lib/tsserver.js` を持たず、`typescript-language-server` が要る 5 系も無い -- **誰が困るか**: コンテナの中で Claude Code の公式 LSP プラグインを使う利用者。プラグインを入れても言語サーバが見つからず、編集の後の診断が届かない。コンテナを作り直すたびに手で `npm i -g` し直すことになる -- **直すと何が成り立つか**: `general`・`php`・`php85` の派生イメージから作ったコンテナでは、入った時点で 3 つの言語サーバが PATH の上にあり、TypeScript 5 を解決する。base の Dockerfile は変わらず、`tsc` は base と同じ 7.0.2 のまま - -## 適用範囲 - -- **働く範囲**: このリポジトリの `containers/general`・`containers/php`・`containers/php85` の Dockerfile と、その静的な検査(`tests/containers/`)、`CHANGELOG.md`。base(`containers/base/`)とほかの派生イメージは変えない。ai-plugins とプラグインのリポジトリ(`projects/*`)は変えない -- **プロジェクトごとに違うもの**: どの派生イメージを使うかは、各プロジェクトの `compose.yml` の dev サービスの `build.context` で受ける(今のまま)。言語サーバの有無を切り替える引数や環境変数は足さない -- **当たるモード**: `standard` - -## あるべき姿の根拠 - -| 根拠 | 種類 | 何を示すか | -| --- | --- | --- | -| 「言語サーバは base ではなく派生イメージで入れる(P1 に当たらない形)。base の Dockerfile は変えない設計にする」 | 利用者の指示の原文(#236 の 2026-10-13 のコメントと起動指示) | 置き場は派生イメージで、base は変えない | -| 「base を重くしすぎず、重い道具は派生イメージに置く」 | 利用者の指示の原文(MVV 版 1 の Value 1) | 言語サーバの置き場は派生イメージ | -| 既存の `devbase-base:latest`(e10369aa02c4、arm64)の使い捨てのコンテナで、`/opt/language-servers` へ 4 つのパッケージ(`pyright@1.1.414`・`typescript-language-server@6.0.2`・`typescript@5.9.3`・`intelephense@1.18.5`)を root で入れると、`du -sbx /` が 2,948,038,543 → 3,095,975,297 バイト(増分 147,936,754 バイト) | 実測(この設計、2026-10-13、Docker Desktop の containerd のイメージストア) | 3 つの言語を 1 つのイメージに入れても、要求の上限 350,000,000 バイトに収まる | -| 同じコンテナで、3 つのコマンドがそれぞれ `--stdio` で LSP の `initialize` に `capabilities` を持つ応答を返した。`typescript-language-server` の実体から親をたどると `/opt/language-servers/node_modules/typescript`(5.9.3)に当たり、`lib/tsserver.js` があった。`tsc --version` は `Version 7.0.2` のまま | 実測(同上) | 専用の置き場の形で受け入れ条件 3・4・7 が成り立つ | -| 同じ形を `--user root` で `ENV NPM_CONFIG_PREFIX` が効いた状態で打っても、npm のグローバル領域(`/usr/local/share/npm-global`)の `typescript` は 7.0.2 のまま変わらず、`ubuntu` から置き場は書けなかった | 実測(同上) | `--prefix` の取り付けはグローバル領域に触れない | -| Serena 1.7.0(`serena-agent==1.7.0`)の `solidlsp` の実装: Python は `uvx --from pyright==1.1.403 pyright-langserver`、TypeScript は `typescript@5.9.3` と `typescript-language-server@5.1.3`、PHP は `intelephense@1.14.4` を、Serena の置き場(`language_servers/static`)へ自分で npm install する。PATH の上のコマンドは見ない(設定 `ls_path` を書いたときだけ上書きする) | 実測(使い捨てのコンテナで取得したパッケージのソース) | イメージに入れた言語サーバは Serena には使われない(未決 3 の答え) | -| ai-plugins の `docs/specifications/mcp-serena-language-servers.md` の決定 16: `SERENA_HOME` は `.serena`(cwd からの相対)で、worktree ごとに `.serena/language_servers/` を取得する | 外部の一次情報 | Serena が取る言語サーバの置き場は作業ツリーの下で、イメージの外 | -| 冷えた uv のキャッシュから `uvx --from serena-agent==1.7.0 serena --help` が 5 秒で終わった。温めた後の大きさは 130,841,053 バイト。`uv cache prune --ci` で 18,347,459 バイトに減るが、その後の `uvx --offline` は終了コード 1 | 実測(同上) | 温めるなら 131MB が要り、削って小さくする道は無い | - -要求と受け入れ条件は #236 の本文にある(コピーは `issues/issue-236-requirements.md`)。この文書は「どう作るか」だけを扱う。 -前提・受け入れ条件・ドメインイベント・未決の番号は、要求の番号をそのまま使う。 - -## ドメインモデル - -### コンテキスト - -| コンテキスト | 何の語が 1 つの意味に決まるか | -| --- | --- | -| イメージの継承(`image-lineage`) | 派生イメージ・言語サーバ・言語サーバの置き場・言語サーバの組・uv のキャッシュ | - -この変更は `image-lineage` の中に収まる。base イメージ(`base-image`)の語は変えない。ai-plugins の mcp-serena(対応表・インストールチェック)とは**順応者**の関係で、派生イメージは ai-plugins のインストールチェックが求める形(PATH の上のコマンド・`typescript_major_5` のたどり方)にそのまま合わせ、翻訳の層を持たない。Serena の起動定義(`.mcp.json`)にも順応し、版の固定を写さない(決定 4)。 - -### 集約 - -この変更の集約は Dockerfile の命令の組である。持ち主は、その命令を書き換えてよい Dockerfile を指す。 - -| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | -| --- | --- | --- | --- | --- | -| 派生イメージの言語サーバ | `containers/general/Dockerfile`・`containers/php/Dockerfile`・`containers/php85/Dockerfile` のそれぞれ | 言語サーバを入れる root の `RUN` | — | 言語サーバの組(パッケージ名と厳密な版の 4 組)・言語サーバの置き場のパス・PATH に出す 3 つのコマンド | -| base の npm のグローバル領域 | `containers/base/Dockerfile`(この変更では変えない) | base の `npm i -g` | — | `typescript` 7.0.2・`NPM_CONFIG_PREFIX` | - -3 つの Dockerfile は同じ値(言語サーバの組・置き場・コマンド)を別々に持つ。3 つが同じ値であることは不変条件 I3 が守る。派生イメージの言語サーバは base の npm のグローバル領域を書き換えない(I4)。 - -### 不変条件 - -| # | 集約 | 条件 | 破れたときの扱い | -| --- | --- | --- | --- | -| I1 | 派生イメージの言語サーバ | 対象の 3 つの Dockerfile が、`npm install --prefix /opt/language-servers` で `pyright`・`typescript-language-server`・`typescript`・`intelephense` の 4 つを入れる | Dockerfile の形の検査が、欠けたイメージとパッケージの名前を挙げて落ちる | -| I2 | 派生イメージの言語サーバ | 4 つのパッケージの版がすべて厳密な版(`<名前>@<数字>.<数字>.<数字>`)で書かれ、`typescript` の版が `5.` で始まる | Dockerfile の形の検査が、範囲・`latest`・版の無い指定と 5 以外の `typescript` を挙げて落ちる | -| I3 | 派生イメージの言語サーバ | 3 つの Dockerfile の言語サーバの組(名前と版)が同じである | Dockerfile の形の検査が、食い違うイメージと版を挙げて落ちる | -| I4 | 派生イメージの言語サーバ | 言語サーバを入れる `RUN` は `npm i -g` / `npm install -g` を使わず、置き場の `bin` に置くのは 3 つのコマンド(`pyright-langserver`・`typescript-language-server`・`intelephense`)の symlink だけで、`tsc` を PATH に出さない | Dockerfile の形の検査が落ちる。建てたイメージの `tsc --version` が 7.0.2 でなくなる | -| I5 | 派生イメージの言語サーバ | `ENV PATH` が `/opt/language-servers/bin` を含む | Dockerfile の形の検査が落ちる。非対話のシェル(`docker exec`)でコマンドが見つからない | -| I6 | 派生イメージの言語サーバ | 対象の 3 つの Dockerfile に `UV_CACHE_DIR` と `serena-agent` が無い(決定 4 で (b) を選んだ) | Dockerfile の形の検査が落ちる | -| I7 | 派生イメージの言語サーバ | 言語サーバを入れる `RUN` は root で打ち、`USER ubuntu` を後に戻す。npm のキャッシュ(`/root/.npm`)を残さない | Dockerfile の形の検査が落ちる | -| I8 | base の npm のグローバル領域 | `containers/base/` の下は変わらない | 実装の Pull Request の `git diff main -- containers/base/` が空でない(受け入れ条件 1。検査ではなく差分で見る) | - -### ドメインイベント - -| # | イベント | 発生元 | 受け手 | -| --- | --- | --- | --- | -| E1 | 派生イメージを建てるときに言語サーバを入れた | 対象の 3 つの Dockerfile の root の `RUN` | 言語サーバの置き場。E3 で作るコンテナ | -| E3 | 派生イメージからコンテナを作って起動した | `devbase up`(利用者が打つ) | E4・E5 | -| E4 | Claude Code の公式 LSP プラグインが PATH の上の言語サーバを起動した | 利用者が `.py`・`.ts`・`.php` を開いて編集した | 言語サーバの置き場の 3 つのコマンド | -| E5 | Serena の MCP が `uvx --from serena-agent==1.7.0` で起動した | Claude Code / Codex | uv のキャッシュ(初回は取得。コンテナの層にあり、作り直すと消える) | -| E6 | Serena が言語サーバを使い始めた | Serena が言語のコードを初めて扱った | Serena の置き場(作業ツリーの `.serena/language_servers/`)。イメージの言語サーバは使わない | -| E7 | ai-plugins が `serena-agent` の版の固定を上げた | ai-plugins の配布 | E5 の取得(イメージは版を持たないため、揃える作業は生まれない) | - -E2(Serena を建てるときに温める)は (a) のときのイベントで、(b) を選んだため起きない(決定 4)。欠番にした。 - -### 用語 - -| 用語 | 意味 | 用語集への反映 | -| --- | --- | --- | -| 言語サーバ | エディタや AI の道具へ、定義・参照・診断を Language Server Protocol で返すプログラム。#236 では `pyright-langserver`・`typescript-language-server`・`intelephense` を `general`・`php`・`php85` の派生イメージの言語サーバの置き場に置く | 意味の変更(`image-lineage`。置くイメージを名指す) | -| 言語サーバの置き場 | 派生イメージが言語サーバとその依存(`typescript` 5 を含む)を入れる `/opt/language-servers`。持ち主は root で利用者は書けない。PATH に出すのは `bin` の 3 つのコマンドだけ | 追加(`image-lineage`) | -| 言語サーバの組 | 言語サーバの置き場へ入れる npm のパッケージの名前と厳密な版の 4 組。対象の派生イメージのすべてで同じ | 追加(`image-lineage`) | -| uv のキャッシュ | uv / uvx が取得したパッケージと作った環境を置く場所。置き場は `UV_CACHE_DIR`、既定は `~/.cache/uv`。base の片付けが消し、コンテナの層にあるため作り直しでも消える。#236 では派生イメージで温めない | 意味の変更(`image-lineage`。作り直しで消えることと、温めないことを足す) | - -## 機能一覧 - -| # | 機能 | 誰が使うか | -| --- | --- | --- | -| F1 | `general`・`php`・`php85` の派生イメージから作ったコンテナで、Python・TypeScript・PHP の言語サーバが PATH の上にあり、`--stdio` で起動できる | コンテナの中で Claude Code の公式 LSP プラグインを使う利用者 | -| F2 | 同じコンテナで、`typescript-language-server` が TypeScript 5 を解決し、ai-plugins のインストールチェックが本体と TypeScript の版の欠けを出さない | 同上。`/mcp-serena:language-servers` を打つ利用者 | -| F3 | 同じコンテナの `tsc` は base と同じ 7.0.2 のままで、利用者の `npm i -g` の領域も変わらない | TypeScript を `tsc` で建てる利用者・道具 | -| F4 | 言語サーバの版はイメージで固定され、3 つの派生イメージで揃う | devbase の保守者 | - -## 構成要素 - -型(クラス)は持たない。Dockerfile の命令と静的な検査だけで構成する。 - -| 要素 | 置き場所 | 責務 | -| --- | --- | --- | -| general の言語サーバの `RUN` | `containers/general/Dockerfile` | `USER root` で言語サーバの組を置き場へ入れ、3 つのコマンドの symlink を `bin` に作り、npm のキャッシュを消す。`ENV PATH` に置き場の `bin` を足し、`USER ubuntu` へ戻す | -| php の言語サーバの `RUN` | `containers/php/Dockerfile` | 同上。既存の root の区画(Composer の `COPY` の後、`USER ubuntu` の前)に置く | -| php85 の言語サーバの `RUN` | `containers/php85/Dockerfile` | 同上 | -| 言語サーバの形の検査 | `tests/containers/test_language_servers_dockerfile.py`(新設) | I1〜I7 を Docker 無しで縛る。対象の 3 つのイメージの名前を検査の中に持つ。`dockerfile_parse.parse` を使う | -| CHANGELOG | `CHANGELOG.md` の `[Unreleased]` | 3 つの派生イメージに言語サーバが入ったこと、増える大きさ、建て直しで届くこと、Serena は今のまま初回に取得すること | -| 用語集 | `docs/glossary/glossary.json`・`docs/glossary.md` | 用語の表の反映(`glossary.py render` で作り直す) | - -```mermaid -graph TD - subgraph このリポジトリ - B["base の Dockerfile(変えない)"] - G["general の言語サーバの RUN"] - P["php の言語サーバの RUN"] - P85["php85 の言語サーバの RUN"] - T["言語サーバの形の検査"] - C["CHANGELOG"] - GL2["用語集"] - end - subgraph 外部 - NPM["npm のレジストリ"] - AIP["ai-plugins のインストールチェック"] - end - B -->|FROM| G - B -->|FROM| P - B -->|FROM| P85 - NPM -->|言語サーバの組| G - NPM -->|言語サーバの組| P - NPM -->|言語サーバの組| P85 - T -->|I1〜I7 を読む| G - T -->|I1〜I7 を読む| P - T -->|I1〜I7 を読む| P85 - AIP -->|PATH と typescript_major_5| G - C -.->|利用者へ知らせる| G - GL2 -.->|言語サーバの置き場・組の語| G -``` - -配置(どこで動くか): - -```mermaid -graph TD - subgraph 派生イメージから作ったコンテナ - BIN["/opt/language-servers/bin(3 つのコマンド)"] - NM["/opt/language-servers/node_modules(typescript 5.9.3 を含む)"] - GL["/usr/local/share/npm-global(base の tsc 7.0.2。変えない)"] - CC["Claude Code と公式 LSP プラグイン"] - SE["Serena の MCP(uvx)"] - UVC["uv のキャッシュ(~/.cache/uv。コンテナの層)"] - SLS["作業ツリーの .serena/language_servers"] - end - CC -->|"--stdio で起動"| BIN - BIN --> NM - SE -->|"serena-agent と pyright を取得"| UVC - SE -->|"TypeScript・PHP の言語サーバを npm install"| SLS -``` - -Serena はイメージの言語サーバを使わない。配置の図で `SE` から `BIN` への線が無いのはそのためである(決定 4)。 - -置き場所(変える範囲): - -```text -containers/ -├── general/Dockerfile # 言語サーバの RUN と ENV PATH を足す -├── php/Dockerfile # 同上 -└── php85/Dockerfile # 同上 -tests/containers/ -└── test_language_servers_dockerfile.py # 新設 -CHANGELOG.md -docs/glossary/glossary.json -docs/glossary.md -``` - -## 処理の流れ - -派生イメージを建てるとき(E1): - -```mermaid -sequenceDiagram - participant U as 利用者(devbase build) - participant D as 派生イメージの Dockerfile - participant N as npm のレジストリ - U->>D: 建てる(base は手元にある) - D->>D: USER root - D->>N: npm install --prefix /opt/language-servers(言語サーバの組) - N-->>D: 4 つのパッケージと依存 - alt 取得に失敗 - D-->>U: ビルドが止まる(前のイメージが残る) - end - D->>D: bin に 3 つのコマンドの symlink を作る - D->>D: /root/.npm を消す - D->>D: ENV PATH に /opt/language-servers/bin を足す - D->>D: USER ubuntu - D-->>U: 建てたイメージ -``` - -コンテナの中で使うとき(E3〜E6): - -```mermaid -sequenceDiagram - participant CC as Claude Code の公式 LSP プラグイン - participant LS as 言語サーバの置き場 - participant SE as Serena の MCP - participant NET as ネットワーク - CC->>LS: pyright-langserver / typescript-language-server / intelephense --stdio - LS-->>CC: initialize の応答と診断 - SE->>NET: uvx が serena-agent==1.7.0 を取得(uv のキャッシュが冷えているとき) - SE->>NET: 言語ごとに Serena の言語サーバを取得(作業ツリーで初回) - alt ネットワークが無い - SE-->>CC: Serena が起動しない(今と同じ) - end -``` - -## 非機能の実現方式 - -| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | -| --- | --- | --- | --- | -| 性能・拡張性 | 1 つの派生イメージで増える展開後の大きさは 350,000,000 バイト以下(受け入れ条件 12) | 言語サーバの組だけを置き場へ入れ、`--omit=dev` で開発用の依存を外し、npm のキャッシュを消す。uv のキャッシュは温めない(決定 4)。見込みは 147,936,754 バイト | 実装の Pull Request で、3 つのイメージから作ったコンテナの `du -sbx /` を変更の前と後で測り、端末(アーキテクチャ)とイメージストアを書く | -| 運用・保守性 | 言語サーバの版は厳密に固定し、検査で止める(受け入れ条件 6) | 4 つのパッケージを `@<数字>.<数字>.<数字>` で書き、3 つの Dockerfile で同じ組にする。置き場の持ち主は root で、利用者の `npm` では上がらない | 言語サーバの形の検査(I2・I3)と全体の pytest | -| システム環境 | arm64 と amd64 の両方で受け入れ条件 2〜5 が通る | 4 つのパッケージは JavaScript だけで、アーキテクチャごとの取得物を持たない。Node は base のものを使う | arm64 はマージ前の手動確認、amd64 はリリース後テスト | - -要求の運用・保守性の「ai-plugins が `serena-agent` の固定を上げたときの揃え方」(未決 2)は、イメージが版を持たないため揃える作業が生まれない(決定 4)。 - -## 決定の記録 - -### 決定 1: どのプロジェクトのコードでも同じ診断が使えるよう、general・php・php85 の 3 つに 3 つの言語をすべて入れる - -`general` は、この端末で dev サービスを建てる 37 のプロジェクトのうち 24 が使う汎用の派生イメージで、コードはプロジェクトのボリュームの中にあるため、どの言語が書かれているかを手元から数えられない(未確認のまま残ること)。PHP のプロジェクトは画面の側に TypeScript・JavaScript を持つことが多い。3 つの言語を入れても増分は 147,936,754 バイトで上限の半分に収まり、組を 1 つにすれば 3 つの Dockerfile を同じ検査(I3)で揃えられる。 - -前提 3 の最小の組(`general` に Python と TypeScript、`php`・`php85` に PHP)だけにする形は採らなかった。イメージごとに組が違うと、言語の欠けが利用者の選んだイメージで変わり、PHP のプロジェクトの TypeScript で診断が届かない。`latex`・`bi-tools`・`trygroup`・`browser`・`go` には入れない。`go` は前提 2 の外の言語を主にし、`latex` はコードを扱わず、`bi-tools`・`trygroup` は 1 つのプロジェクトのための派生イメージで、`browser` を使うプロジェクトはこの端末に無い。要るときは同じ `RUN` を写し、検査の対象の一覧に名前を足せば揃う。 - -根拠: Mission / Value 1(MVV 版 1) - -### 決定 2: base の tsc と利用者の npm のグローバル領域を変えないため、言語サーバを専用の置き場 /opt/language-servers に入れ、3 つのコマンドだけを PATH に出す - -`typescript-language-server` は TypeScript 5 を要るが、base の npm のグローバル領域の `typescript` は 7.0.2 である。`npm install --prefix /opt/language-servers` で 4 つのパッケージを入れると、`typescript-language-server` は同じ置き場の `typescript` 5.9.3 を解決し、ai-plugins の `typescript_major_5` のたどり方もそこに当たる(実測)。置き場の `bin` には 3 つのコマンドの symlink だけを置き、`ENV PATH` の先頭へ足す。`node_modules/.bin` を PATH に出さないので、置き場の `tsc`・`tsserver` は見えず、`tsc` は base の 7.0.2 のまま(受け入れ条件 7。CHANGELOG に `tsc` の版の変化は書かない)。 - -グローバル領域へ `npm i -g typescript@5 ...` で入れる形は採らなかった。派生イメージの `tsc` が 5 へ下がり、利用者や Codex の自動の更新が同じ領域の `typescript` を上げると TypeScript 5 の解決が黙って崩れる。`node_modules/.bin` を PATH の末尾へ足す形も採らなかった。`tsc` がどちらに当たるかが PATH の並びだけで決まり、並びを変える変更で崩れる。 - -根拠: Value 1(MVV 版 1) - -### 決定 3: 版の固定を利用者の更新で崩さないため、置き場は root の RUN で作り、利用者は書けない形にする - -言語サーバを入れる `RUN` は root で打ち、置き場の持ち主は root、`ubuntu` は読みと実行だけができる(実測で `test -w` が偽)。版を上げるのはイメージの Dockerfile の変更だけで、検査(I2・I3)がその版を縛る。 - -`bi-tools`・`trygroup` の書き方(`USER ubuntu` で `npm i -g` し、`ubuntu:npm` の領域に置く)に合わせる形は採らなかった。あの領域は利用者が更新するための場所で、版を固定する言語サーバを置くと、固定した版と動いている版が食い違っても検査で気づけない。 - -根拠: Value 1(MVV 版 1) - -### 決定 4: イメージと ai-plugins の版の固定を結び付けないため、Serena の uv のキャッシュは温めず (b) を採る。Serena の初回の起動にはネットワークが要る - -Serena 1.7.0 は PATH の上の言語サーバを使わない。Python は `uvx --from pyright==1.1.403` で、TypeScript と PHP は作業ツリーの `.serena/language_servers/` へ npm install して、自分の固定した版を取得する(あるべき姿の根拠の実測と ai-plugins の決定 16)。このため `serena-agent` だけを温めても、Serena が言語を扱い始める時点でネットワークが要ることは変わらない。温めるには 1 つのイメージにつき 130,841,053 バイトが要り(`prune --ci` で削るとオフラインの起動が壊れる)、ai-plugins が版の固定を上げるたびに温めたキャッシュは外れて重さだけが残る(E7)。冷えた状態からの取得は 5 秒で終わった。 - -**(b) では、Serena の MCP の初回の起動(コンテナを作り直した後の最初の起動を含む)にネットワークが要る。** これは今の base と同じ振る舞いで、この変更で悪くならない。派生イメージには `UV_CACHE_DIR` も `serena-agent` も足さない(I6、受け入れ条件 11)。 - -(a)(`UV_CACHE_DIR` を `~/.cache` の外に置いて `serena-agent==1.7.0` を温める)は採らなかった。得られるのは `serena-agent` の取得の 5 秒とオフラインでの MCP の起動だけで、言語を扱う時点の取得は残る。代わりに、版を ai-plugins と揃え続ける作業と、`uv` を使うすべての処理のキャッシュの置き場が変わる影響を負う。Serena に `ls_path` でイメージの言語サーバを使わせる形も採らなかった。設定は作業ツリーの `.serena/` にあり、ai-plugins の領分である(対象範囲の「含まない」)。 - -根拠: Value 1 / Value 3(MVV 版 1) - -### 決定 5: 3 つの Dockerfile の言語サーバの組を揃えるため、同じ RUN を各 Dockerfile に書き、検査で同じ組であることを縛る - -派生イメージは `containers/<名前>/` をビルドの文脈にして建つ(各プロジェクトの `compose.yml` の `build.context`)。文脈の外のファイルは `COPY` できないため、共通の取り付けのスクリプトを 1 つ置く形は、プロジェクトの `compose.yml` の文脈を変えないと成り立たない。各 Dockerfile に同じ `RUN` を書き、言語サーバの形の検査が 3 つの組(名前と版)の一致を縛る(I3)。 - -共通のスクリプトを `containers/` の下に置いて文脈を広げる形は採らなかった。プラグインのリポジトリの `compose.yml` を変えることになり、対象範囲の外である。`ARG` で版を外から渡す形も、渡す側が各プロジェクトの `compose.yml` になり同じ理由で採らなかった。 - -根拠: Value 1(MVV 版 1) - -### 決定 6: 受け入れ条件 2・3 の手動確認は、entrypoint を bash に替えて打つ - -派生イメージは base の entrypoint を継ぎ、entrypoint は `DEVBASE_ACCOUNT_GROUP` が無いと止まる(`docker run --rm devbase-base:latest bash -c ...` で実測)。受け入れ条件 2・3 の `docker run --rm <イメージ> bash -c '...'` は `--entrypoint bash` を付けて `-c '...'` を渡す形で打つ。`ENV PATH` は entrypoint に依らず効くため、「ログインシェルでない非対話のシェルで見える」という条件の意図は変わらない。`devbase up` で起動したコンテナでは確かめない(P3、AGENTS.md の落とし穴)。 - -entrypoint へ `DEVBASE_ACCOUNT_GROUP` を渡して通す形は採らなかった。entrypoint がボリュームや機密の用意へ進み、実の環境に触れ得る。 - -根拠: P3 / Value 3(MVV 版 1) - -## テスト設計 - -| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | -| --- | --- | --- | -| 受け入れ条件 1・I8 | 実装の Pull Request の `git diff main -- containers/base/` が空 | `containers/base/` の何かを変えると差分に出る(検査ではなく完了判定で見る) | -| I1 | 3 つの Dockerfile のそれぞれに、`npm install --prefix /opt/language-servers` で 4 つのパッケージを入れる `RUN` がある | 1 つのイメージから `intelephense` を外す、`--prefix` を別の置き場にすると落ちる | -| I2・受け入れ条件 6 | 4 つのパッケージの版が `<名前>@<数字>.<数字>.<数字>` で、`typescript` が `5.` で始まる | `typescript@^5`・`pyright@latest`・版の無い `intelephense`・`typescript@7.0.2` のどれでも落ちる | -| I3 | 3 つの Dockerfile の言語サーバの組が同じ | 1 つのイメージだけ `pyright` の版を上げると落ちる | -| I4・受け入れ条件 7 | 言語サーバの `RUN` に `npm i -g` / `npm install -g` が無く、`bin` へ張る symlink が 3 つのコマンドだけ。建てたイメージの `tsc --version` が `Version 7.0.2` | `-g` で入れる、`node_modules/.bin` を丸ごと PATH に出す、`tsc` の symlink を足すと落ちる(建てたイメージの版は手動確認) | -| I5・受け入れ条件 2 | `ENV PATH` が `/opt/language-servers/bin` を含む。建てたイメージで `--entrypoint bash` の `command -v` が 3 つのパスを返す(決定 6) | `ENV PATH` の行を消すと落ちる(建てたイメージは手動確認) | -| I6・受け入れ条件 11 | 3 つの Dockerfile に `UV_CACHE_DIR` と `serena-agent` が無い | `ENV UV_CACHE_DIR=...` か `uvx --from serena-agent==...` を足すと落ちる | -| I7 | 言語サーバの `RUN` が `USER root` の区画にあり、その後に `USER ubuntu` があり、`RUN` が `/root/.npm` を消す | `USER ubuntu` の戻しを消す、`rm -rf /root/.npm` を消すと落ちる | -| 受け入れ条件 3 | 建てたイメージで 3 つのコマンドを `--stdio` で起動し `initialize` を送ると、`result.capabilities` を持つ応答が 30 秒以内に返る | 手動確認(マージ前)。言語サーバの依存が欠けると応答が返らない | -| 受け入れ条件 4 | 建てたイメージで `typescript-language-server` の実体の親をたどった最初の `node_modules/typescript/package.json` が `5.` で始まり、`lib/tsserver.js` がある | 手動確認。グローバル領域に入れる形へ戻すと 7.0.2 に当たる | -| 受け入れ条件 5 | 建てたイメージで ai-plugins の `check`(Claude Code)が 3 つの言語の `binary` と `typescript_major_5` を出さない | 手動確認 | -| 受け入れ条件 8・9・10 | (a) を選ばなかったため対象外(決定 4) | — | -| 受け入れ条件 12 | 3 つのイメージの展開後の大きさを前と後で測り、増分が 350,000,000 バイト以下 | 手動確認。結果は実装の Pull Request の本文 | -| 受け入れ条件 13 | 実装の Pull Request の `git diff main -- containers/{latex,bi-tools,trygroup,browser,go}/` が空 | 完了判定で見る | - -手動確認(マージ前)は、手元の `devbase-base:latest` の上に 3 つの派生イメージを作業用のタグで建て、`docker run --rm --entrypoint bash` で打つ。base は建てない。`devbase up` は打たない。`php`・`php85` は `composer:latest` の取得がキーチェーンで止まり得る(AGENTS.md の落とし穴)。建てられなければ「判断が要る」で返す。作業用のタグは記録を書いたら消す。 - -## 設計の結果 - -| 課題 | 扱い | 取り込み先 | 触るファイル | -| --- | --- | --- | --- | -| #236 | 実装する | — | `containers/general/Dockerfile`、`containers/php/Dockerfile`、`containers/php85/Dockerfile`、`tests/containers/test_language_servers_dockerfile.py`、`CHANGELOG.md`、`docs/glossary/glossary.json`、`docs/glossary.md` | - -## 未確認のまま残ること - -| 項目 | 内容 | -| --- | --- | -| `general` を使うプロジェクトの言語 | コードがプロジェクトのボリュームの中にあり、手元から言語の分布を数えなかった(ボリュームを写さないため)。決定 1 は分布に依らず 3 つを入れる | -| 各プロジェクトが別のタグで建てる重さ | 各プロジェクトの `compose.yml` は自分の名前のタグで `containers/general/` などを建てる。ビルドのキャッシュに当たれば層は共有されるが、キャッシュ無しで建て直すとプロジェクトごとに約 148MB の層が別に残り得る。実装の手動確認で、2 つ目のタグの建てがキャッシュに当たるかを見る | -| php・php85 での増分 | 実測は base の上だけで行った。`php`・`php85` の上でも Node は base のものを使うため同じ増分を見込むが、実装の Pull Request で測る(受け入れ条件 12) | -| amd64 | 4 つのパッケージは JavaScript だけだが、amd64 では建てていない。リリース後テストで受け入れ条件 2〜5 を確かめる | -| Serena の MCP の起動の待ち | (b) では冷えたコンテナの最初の起動で `serena-agent` を取得する。この端末で 5 秒だったが、遅い回線で Claude Code の MCP の起動の待ちを超えるかは測っていない。今の base と同じ振る舞いで、この変更では悪くならない | -| 公式 LSP プラグインの診断 | コンテナの中で `.py` を編集して診断が届くことは、リリース後テストで利用者のコンテナで見る | diff --git a/issues/issue-236-requirements.md b/issues/issue-236-requirements.md deleted file mode 100644 index b1b2904a..00000000 --- a/issues/issue-236-requirements.md +++ /dev/null @@ -1,193 +0,0 @@ -# #236: 派生イメージに言語サーバを入れ、コンテナに入った時点でコードインテリジェンスが使えるようにする - -正は課題の本文(#236)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 - -## 依頼(原文) - -> 言語サーバは base ではなく派生イメージで入れる(P1 に当たらない)。どの派生イメージに何を入れるかと、Serena の `uv` のキャッシュの扱いは設計で決める。判断待ちを解き、マイルストーン 04 で着手する。 - -(書き手: 利用者、日付: 2026-10-13。課題のコメントに「2026-10-13 の棚卸しで利用者が決めた」として記録された決定) - -> 前倒しせず 04 に置く。m7c で base を削った直後のため、言語サーバを足すなら base ではなく派生イメージから検討する(MVV の Value 1。base へ入れるなら P1 の承認が要る)。 - -(書き手: 利用者、日付: 2026-10-03。課題のコメントに「2026-10-03 の棚卸しで利用者が決めたこと」として記録された決定) - -> 言語サーバは base ではなく派生イメージで入れる(P1 に当たらない形)。base の Dockerfile は変えない設計にする。どの派生イメージ(containers/ の下)に何を入れるか、Serena の uv のキャッシュ(案 (a)/(b))の扱い、増える大きさ(展開後の大きさ、用語集の語)を設計で決める。本文の「base に入れる」前提の受け入れ条件は派生イメージの形へ書き換える。 - -(書き手: AI の起動指示(利用者の決定を引用したもの)、日付: 2026-10-13) - -> base コンテナに **言語サーバを入れておき**、コンテナに入った時点でコードインテリジェンス(定義・参照・診断・シンボル単位の編集)がすぐ使えるようにする。Serena 本体の取得を前もって済ませておくかは「決めること」で決める。 - -(書き手: 起票者、日付: 2026-10-03 ごろ。課題の最初の本文。「base に入れる」は上の利用者の決定で派生イメージへ変わった。最初の本文の全文はこの課題の編集の履歴にある) - -## 目的 - -コンテナに入った時点で、Claude Code の診断(公式 LSP プラグイン)と Serena のシンボルの把握・編集が、Python・TypeScript・PHP のコードで使える状態にする。base は重くせず(MVV の Value 1)、言語サーバは派生イメージに置く。 - -背景(起票の時点の調べ。2026-10-03): - -- ai-plugins 側([devbasex/ai-plugins#818](https://github.com/devbasex/ai-plugins/issues/818)、`docs/specifications/mcp-serena-language-servers.md`)は、言語サーバの本体を利用者(マシン・コンテナ)が PATH の上に入れる前提にしている。ai-plugins の `check` は欠けを知らせるだけで入れない -- Serena の MCP は常に `uvx --from serena-agent==1.7.0 serena start-mcp-server ...` で起動する(ai-plugins の決定 4: 導入済みの Serena を優先する起動のラッパーは作らない)。`uv tool install` で PATH に置いた `serena` は使われず、効くのは `uvx` が読む uv のキャッシュだけである -- uv のキャッシュの既定の置き場は `~/.cache/uv` で、base のクリーンアップ(`containers/base/Dockerfile` のユーザー用ツールの RUN)が `~/.cache` を消す。`~/.cache` は永続化の対象でもないため、コンテナを作り直すと消える -- base の `npm i -g` が入れる `typescript` は latest の 7.0.2 で、`lib/tsserver.js` を持たない。`typescript-language-server` は typescript 5 系を要る - -## 調べたこと(2026-10-13、arm64、既存の `devbase-base:latest`(e10369aa02c4)で `docker run --rm` の使い捨てのコンテナ) - -| 項目 | 値 | -| --- | --- | -| base の Python / uv / `tsc` | Python 3.14.4 / uv 0.12.22 / `tsc` 7.0.2。グローバルの `typescript/lib/tsserver.js` は無い | -| npm の `typescript` の `latest` | 7.0.2(5 系の最新は 5.9.3) | -| `npm i` で入る版 | `pyright` 1.1.414、`typescript-language-server` 6.0.2、`typescript@5` 5.9.3、`intelephense` 1.18.5 | -| Python・TypeScript の言語サーバ(`pyright` + `typescript-language-server` + `typescript@5`)の大きさ | 45,519,343 バイト(依存を含む `node_modules`) | -| PHP の言語サーバ(`intelephense`)の大きさ | 126,043,484 バイト(依存を含む) | -| `uvx --from serena-agent==1.7.0 serena --help` で温めた uv のキャッシュ | 130,841,053 バイト。base の Python 3.14 で動き、uv が別の Python を取りに行かなかった。温めた後、`uvx --offline` で同じコマンドが終了コード 0 | -| `pyright-langserver --version` / `intelephense --version` | どちらも終了コード 1(`--version` を持たない。`--stdio` などの指定を求める)。`typescript-language-server --version` は 6.0.2 を返す | -| ai-plugins の `typescript_major_5` の調べ方 | `typescript-language-server` の実体のパスの親を順にたどり、最初に見つけた `node_modules/typescript/package.json` の版が 5 か。グローバルの prefix に入れると base の `typescript` 7.0.2 に当たる | - -この端末の `projects/*` が dev サービスを建てる派生イメージ(数だけ。名前は書かない): `general` 24、`php` 9、`php85` 1、`latex` 1、`bi-tools` 1、`trygroup` 1。`browser`・`go` を使うプロジェクトはこの端末に無い。 - -## 起票者の推定 - -| # | 条件 | 推した出所 | この要求での扱い | -| --- | --- | --- | --- | -| S1 | 対象の言語は少なくとも Python・TypeScript・PHP | 最初の本文の「決めること」(ai-plugins の対応表から選んだ) | 前提 2 | -| S2 | 言語サーバは ai-plugins の対応表(`languages.json`)と同じ導入方法(`npm install -g`)で入れる | 最初の本文の作業項目 | 採らない。入れ方(prefix・グローバルか)は設計で決める。受け入れ条件 3〜5 は PATH の上に見えることと起動することだけを求める | -| S3 | `tsc --version` が 5 系を返す(グローバルの `typescript` を 5 へ固定する) | 最初の本文の受け入れ条件 2・作業項目 | 採らない。base の `typescript` は base の Dockerfile にあり変えない。派生イメージで `tsc` の版が変わるかは設計で決め、受け入れ条件 7 で扱う | -| S4 | `pyright-langserver --version` / `intelephense --version` が通る | 最初の本文の受け入れ条件 1 | 採らない。2026-10-13 の測定で両方とも `--version` を持たない。受け入れ条件 4 の起動の確認へ置き換える | -| S5 | 版を固定する | 最初の本文の作業項目 | 受け入れ条件 6 | -| S6 | 起動の確認をコンテナの検査(`tests/containers/`)に足す | 最初の本文の作業項目 | 受け入れ条件 6(静的な検査)。建てたイメージでの起動はテストで常に走らせず、検証手段の手動確認(マージ前)にする | -| S7 | amd64 でも建てて確かめる | 最初の本文の受け入れ条件 | 非機能の条件のシステム環境と、検証手段の手動確認(リリース後テスト) | -| S8 | (a) を選んだ場合、uv のキャッシュの置き場は `ubuntu` が書ける持ち主にする | 最初の本文の作業項目 | 受け入れ条件 9 | -| S9 | Serena が PATH の上の言語サーバを使うか、`~/.serena/language_servers` へ取りに行くかを確かめる | 最初の本文の作業項目 | 未決 3 | -| S10 | `~/.serena` は `/persistent/ai` に永続化されるため Serena が自分で取る言語サーバは端末ごとに初回 1 回で済む | 最初の本文の「なぜ base に入れるか」 | 未決 3 で確かめ直す(ai-plugins の `.mcp.json` は `SERENA_HOME=.serena` の相対パスを渡しており、置き場が `~/.serena` でない可能性がある) | - -## 前提 - -- 前提 1: base の Dockerfile と `containers/base/` の下のファイルは変えない(利用者の決定。P1 に当てない) -- 前提 2: 対象の言語は Python・TypeScript・PHP の 3 つである。Go(`gopls`)など対応表のほかの言語は対象にしない -- 前提 3: 言語サーバを入れる派生イメージは、少なくとも `general` に Python と TypeScript、`php` と `php85` に PHP を含む。ほかの組み(`php` に Python・TypeScript も入れるか、`latex`・`bi-tools`・`trygroup`・`browser`・`go` に入れるか)は設計で決め、決定に理由を書く。設計がこの最小の組みから外すときも、決定に理由を書く -- 前提 4: Serena の扱いは設計で (a)(uv のキャッシュをイメージで温める)か (b)(言語サーバだけを入れ、Serena の取得は実行時の `uvx` に任せる)を選ぶ。どちらを選んでも受け入れ条件は下のとおりに分けて書く -- 前提 5: ai-plugins の Serena の版の固定は 2026-10-13 の時点で `serena-agent==1.7.0` である。(a) を選ぶ場合、イメージの版の固定はこの値に揃える -- 前提 6: 1 つの派生イメージで増える展開後の大きさは、2026-10-13 の測定(言語サーバの全部 約 172MB、uv のキャッシュ 約 131MB)から、350,000,000 バイト以下に収まる - -## 対象範囲 - -含む: - -- 派生イメージ(`containers/` の下の base 以外)の Dockerfile に、Python・TypeScript・PHP の言語サーバを足す -- `typescript-language-server` が typescript 5 系を解決するようにする -- Serena の uv のキャッシュの扱い((a)/(b))を決め、(a) ならイメージで温める -- 増える展開後の大きさの測定と記録 -- 版の固定と、それを止める静的な検査(`tests/containers/`) -- `CHANGELOG.md` の `[Unreleased]` - -含まない: - -- base の Dockerfile と `containers/base/` の変更(前提 1) -- base の `npm i -g` にある `typescript`(7.0.2)の扱い -- Go・Ruby・Rust など前提 2 の外の言語 -- ai-plugins 側の変更(`.mcp.json`・`languages.json`・`check` の直し)。要るなら ai-plugins へ別に起票する -- Claude Code の公式 LSP プラグインの導入(利用者の `~/.claude` の設定にあたる) -- プラグインのリポジトリ(`projects/*`)の `compose.yml` の変更(どの派生イメージを使うかは各プロジェクトが選ぶ) - -## ドメインイベント - -| # | イベント | 引き金 | 失敗したとき | 順序の前提 | -| --- | --- | --- | --- | --- | -| E1 | 派生イメージを建てるときに言語サーバを入れた | `devbase build` / `docker compose build` | 取得に失敗するとビルドが止まる(イメージは前のまま残る) | base のイメージが手元にある | -| E2 | (a) のとき、建てるときに `serena-agent` を uv のキャッシュへ温めた | E1 と同じ建て | 取得に失敗するとビルドが止まる | uv が base にある | -| E3 | 派生イメージからコンテナを作って起動した | `devbase up`(利用者が打つ) | 既存の起動の失敗と同じ | E1(と E2) | -| E4 | Claude Code の公式 LSP プラグインが PATH の上の言語サーバを起動した | 利用者が `.py` などを開いて編集した | 言語サーバが見つからないと診断が届かない(プラグインがそう知らせる) | E3、利用者がプラグインを入れている | -| E5 | Serena の MCP が `uvx --from serena-agent==1.7.0` で起動した | Claude Code / Codex が MCP を起動した | (a) でキャッシュに当たらない(版が違う・置き場を読めない)と取得に行き、ネットワークが無いと起動しない | E3。(a) なら E2 | -| E6 | Serena が言語サーバを使い始めた | Serena が言語のコードを初めて扱った | PATH の上を使うか自分で取得するかは未決 3。取得なら初回にネットワークが要る | E5 | -| E7 | ai-plugins が `serena-agent` の版の固定を上げた | ai-plugins の配布 | (a) の温めたキャッシュは外れ、E5 の起動で新しい版を取得する。ネットワークが無いと起動しない | — | - -E5・E7 の失敗の経路は受け入れ条件 10 と未決 2 で扱う。E4 の「利用者がプラグインを入れている」は対象範囲の「含まない」とした。 - -## 用語 - -| 用語 | 意味 | -| --- | --- | -| 派生イメージ | 用語集の語。`FROM devbase-base:latest` で base を継ぎ、道具を足すイメージ | -| 展開後の大きさ | 用語集の語。建てたイメージから作ったコンテナで `du -sbx /` を測ったバイト数 | -| 言語サーバ | エディタや AI の道具へ、定義・参照・診断を Language Server Protocol で返すプログラム。この課題では `pyright-langserver`・`typescript-language-server`・`intelephense` | -| uv のキャッシュ | `uv` / `uvx` が取得したパッケージと作った環境を置く場所。置き場は `UV_CACHE_DIR`、既定は `~/.cache/uv` | - -## 受け入れ条件 - -base を変えないこと: - -- [ ] 1. `git diff main -- containers/base/` が空である - -言語サーバ(設計の決定で対象にした派生イメージと言語の組みごとに確かめる。前提 3 の最小の組みを含む): - -- [ ] 2. 対象の派生イメージで `docker run --rm <イメージ> bash -c 'command -v <コマンド>'`(ログインシェルでない非対話のシェル)が、言語ごとのコマンド(`pyright-langserver`・`typescript-language-server`・`intelephense`)のパスを返す -- [ ] 3. 対象の派生イメージで、言語サーバのコマンドを `--stdio` で起動し LSP の `initialize` を送ると、`result.capabilities` を持つ応答が 30 秒以内に返る -- [ ] 4. 対象の派生イメージで、`typescript-language-server` の実体のパス(`realpath`)の親を順にたどって最初に見つかる `node_modules/typescript/package.json` の版が `5.` で始まり、同じ `typescript` の下に `lib/tsserver.js` がある -- [ ] 5. 対象の派生イメージで ai-plugins の `check`(`/mcp-serena:language-servers` のインストールチェック、ランタイムは Claude Code)を打つと、Python・TypeScript・PHP のうち対象にした言語について、`binary`(本体)と `typescript_major_5`(TypeScript の版)の欠けが出ない。`plugin`(公式 LSP プラグインの導入)の欠けは利用者の設定のため問わない -- [ ] 6. `tests/containers/` の静的な検査が、足した npm のパッケージ(と (a) のときの `serena-agent`)の版が厳密な版(`@5.9.3` の形。範囲や `latest` でない)で書かれていることを止め、`env -u DEVBASE_ROOT uv run --locked pytest tests/ -q` が通る -- [ ] 7. 対象の派生イメージの `tsc --version` が、設計の決定に書いた版を返す。base の `tsc`(7.0.2)から変わるなら、`CHANGELOG.md` の `[Unreleased]` にそのイメージと版を書く - -Serena の uv のキャッシュ: - -- [ ] 8. (a) を選んだ場合: 対象の派生イメージで `docker run --rm --network none <イメージ> bash -c 'uvx --from serena-agent==1.7.0 serena --help'` が終了コード 0 で終わる -- [ ] 9. (a) を選んだ場合: 対象の派生イメージで、uv のキャッシュの置き場(`UV_CACHE_DIR`)が `~/.cache` の外にあり、`ubuntu` で `test -w "$UV_CACHE_DIR"` が通る -- [ ] 10. (a) を選んだ場合: イメージで温める `serena-agent` の版が、ai-plugins の `plugins/mcp/mcp-serena/.mcp.json` の固定(前提 5)と同じ文字列であり、Dockerfile のその行のコメントが ai-plugins の `.mcp.json` を出所として名指す -- [ ] 11. (b) を選んだ場合: 設計の決定に (b) を選んだ理由と、初回の起動にネットワークが要ることが書かれ、派生イメージに `UV_CACHE_DIR` も `serena-agent` も足されていない - -大きさと退行: - -- [ ] 12. 実装の Pull Request の本文に、対象の各派生イメージの展開後の大きさを変更の前と後で記し、測った端末(アーキテクチャ)とイメージストアを書く。増えた分が 1 つのイメージにつき 350,000,000 バイト以下である(前提 6) -- [ ] 13. 対象にしなかった派生イメージの Dockerfile は変わっていない(`git diff main -- containers/<名前>/` が空) - -## 非機能の条件 - -| 大項目 | 条件 | -| --- | --- | -| 性能・拡張性 | 1 つの派生イメージで増える展開後の大きさは 350,000,000 バイト以下(受け入れ条件 12) | -| 運用・保守性 | 言語サーバと `serena-agent` の版は厳密に固定し、検査で止める(受け入れ条件 6)。ai-plugins が `serena-agent` の固定を上げたときの揃え方は未決 2 | -| システム環境 | arm64 と amd64 の両方で、受け入れ条件 2〜5 と (a) のときの 8 が通る(amd64 は手動確認) | - -## 影響 - -| 対象 | 影響 | -| --- | --- | -| 公開インタフェース | 変わらない。devbase の CLI と設定の形は変えない | -| データ | 無し。ボリュームの中身は変えない | -| 既存の振る舞い | 対象の派生イメージに言語サーバのコマンドが PATH の上に増える。設計次第で、そのイメージの `tsc` の版と、(a) なら `UV_CACHE_DIR`(`uv` を使うすべての処理のキャッシュの置き場)が変わる。利用者がイメージを建て直すまでは届かない | - -## 検証手段 - -| 項目 | 手段 | -| --- | --- | -| テスト | `env -u DEVBASE_ROOT uv run --locked pytest tests/ -q` | -| 静的解析 | `uvx ruff check --select=E9,F63,F7,F82 lib`、`python3 .github/scripts/proper_term_check.py`、ShellCheck(開発参加ガイドの「CI が実行するもの」) | -| 手動確認(マージ前) | 対象の派生イメージを手元の `devbase-base:latest` の上に建て、受け入れ条件 2〜5・7・8・9・12 を `docker run --rm` で確かめる。`devbase up` は打たない(作業用のプロジェクトでも実のボリュームが付く。AGENTS.md の落とし穴)。base は建てない。非対話の担当は `ubuntu`・`composer:latest` などの取得がキーチェーンで止まり得るため、建てられなかったら「判断が要る」で返す(AGENTS.md の落とし穴) | -| 手動確認 | amd64 の端末で対象の派生イメージを建て、受け入れ条件 2〜5・8 を確かめる。利用者のコンテナの中の Claude Code で `.py` を編集し、公式 LSP プラグインの診断が届くことを見る(リリース後テスト) | - -## 前提とする取り決め - -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | `AGENTS.md` の「このリポジトリ」。変更は `containers/<派生イメージ>/`・`tests/containers/`・`CHANGELOG.md`。`containers/base/` と `projects/*` には置かない | -| コーディング規約 | `AGENTS.md` の「書き方」と「検査」。Dockerfile は周りの派生イメージ(`bi-tools`・`trygroup` の `USER ubuntu` で `npm i -g` する書き方)に合わせる | -| テスト戦略 | Dockerfile の静的な検査(`tests/containers/` の既存の `dockerfile_parse.py` を使う形)で版の固定と base の不変を止める。建てたイメージでの起動は手動確認(マージ前)で確かめる | - -## 境界 - -| 区分 | 内容 | -| --- | --- | -| 常に行う | 全体のテスト・lint・固有の語の検査。展開後の大きさの記録。`CHANGELOG.md` の記入 | -| 確認してから行う | 対象の派生イメージの選び方と `tsc` の版の変化(承認ゲート 1・2 で人が見る) | -| 行わない | base の Dockerfile の変更(P1)。base を建てる。`devbase up`(P3)。ボリュームを丸ごと写す。ai-plugins の変更 | - -## 未決 - -| 項目 | 誰が決めるか | 期限 | -| --- | --- | --- | -| 1. どの派生イメージにどの言語サーバを入れるか(前提 3 の最小の組みの外)と、共通の入れ方(各 Dockerfile に書くか、共通の断片を持つか) | 設計(承認ゲート 1 で利用者が見る) | 設計 PR | -| 2. Serena の扱い (a)/(b)。(a) なら ai-plugins の版の固定が上がったときに揃える手順(検査で気づくか、手で揃えるか) | 設計(承認ゲート 1 で利用者が見る) | 設計 PR | -| 3. Serena が PATH の上の言語サーバを使うか、自分で取得するか。取得するなら置き場(`.mcp.json` の `SERENA_HOME=.serena` が指す先)と、作り直しで残るか | 設計の調べ | 設計 PR | -| 4. 派生イメージでの `typescript` 5 の入れ方(グローバルの prefix を 5 へ上書きして `tsc` も変えるか、言語サーバ用の別の場所に置いて `tsc` は 7 のまま残すか) | 設計(承認ゲート 1 で利用者が見る) | 設計 PR | - -🤖 Generated with [Claude Code](https://claude.com/claude-code) From 503ec89d9b35adf8c00371b27172032874ef3ca2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 13 Oct 2026 04:06:35 +0900 Subject: [PATCH 3/3] =?UTF-8?q?Docs:=20base=20=E3=81=AE=E4=B8=AD=E8=BA=AB?= =?UTF-8?q?=E3=81=AE=E4=BB=95=E6=A7=98=E3=81=8B=E3=82=89=E6=B4=BE=E7=94=9F?= =?UTF-8?q?=E3=82=A4=E3=83=A1=E3=83=BC=E3=82=B8=E3=81=AE=E8=A8=80=E8=AA=9E?= =?UTF-8?q?=E3=82=B5=E3=83=BC=E3=83=90=E3=81=AE=E4=BB=95=E6=A7=98=E3=81=B8?= =?UTF-8?q?=E3=81=A4=E3=81=AA=E3=81=90=EF=BC=88#236=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ad3gDUEH7d2yH8hCw1HEy2 --- docs/specifications/base-image-contents.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/specifications/base-image-contents.md b/docs/specifications/base-image-contents.md index 26c43ada..6d683584 100644 --- a/docs/specifications/base-image-contents.md +++ b/docs/specifications/base-image-contents.md @@ -161,6 +161,9 @@ base を継ぐ派生イメージは `browser` / `general` / `go` / `php` / `php8 いずれも `FROM devbase-base:latest` である。派生イメージは dpkg の設定(`/etc/dpkg/dpkg.cfg.d/`)を継ぐ ため、派生が自分で `apt-get install` するパッケージの文書も展開されない。 +言語サーバ(Python・TypeScript・PHP)は base に入れず、`general`・`php`・`php85` の派生イメージが持つ +([派生イメージの言語サーバ](derived-image-language-servers.md))。 + npm グループの GID は `ARG NPM_GID="2000"` で固定する。建て直しても、npm のグローバル領域とブラウザの 置き場の数値の所有者が変わらないようにするためである。 @@ -241,6 +244,7 @@ npm グループの GID は `ARG NPM_GID="2000"` で固定する。建て直し ## 関連リンク - [base イメージの文字の描画と、文書を扱う道具](base-image-rendering.md)(ブラウザ・追加の太さのフォント・`terraform`) +- [派生イメージの言語サーバ(Python・TypeScript・PHP)](derived-image-language-servers.md) - [base イメージの Bash の静的検査(shellcheck)](base-image-shellcheck.md) - [CI の検査](ci-checks.md) - [用語集: イメージの継承(`image-lineage`)](../glossary.md#イメージの継承image-lineage)