From 688efdea2165e06ff95be7f2989c21f152986165 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 05:39:56 +0900 Subject: [PATCH 01/15] =?UTF-8?q?chore:=20v3.7.0=20=E3=81=AE=20release=20?= =?UTF-8?q?=E3=83=96=E3=83=A9=E3=83=B3=E3=83=81=E3=82=92=E9=96=8B=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) From 5ad056baab2e2355de69417af1983350e7ab4534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Tue, 22 Sep 2026 21:19:43 +0900 Subject: [PATCH 02/15] =?UTF-8?q?docs(PLAN63):=20base=20=E3=82=A4=E3=83=A1?= =?UTF-8?q?=E3=83=BC=E3=82=B8=E3=81=AE=E6=97=A5=E6=9C=AC=E8=AA=9E=E3=81=AE?= =?UTF-8?q?=E6=8F=8F=E7=94=BB=E3=81=A8=E3=80=81=E6=96=87=E6=9B=B8=E3=82=92?= =?UTF-8?q?=E6=89=B1=E3=81=86=E8=BB=BD=E9=87=8F=E3=81=AE=E9=81=93=E5=85=B7?= =?UTF-8?q?=E3=81=AE=E8=A6=81=E6=B1=82=E4=BB=95=E6=A7=98=E3=81=A8=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=20(#161,=20#160)=20(#221)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(PLAN63): base イメージの日本語の描画と、文書を扱う軽量の道具の要求仕様と設計 (#161, #160) base コンテナで sans-serif が中国語フェイス(WenQuanYi Zen Hei)へ解決される問題と、 Office 文書・PDF を扱う軽量の道具が無い問題を、1 本の設計にまとめた。 どちらも containers/base/Dockerfile の同じ apt / COPY の区画を触るため 1 本にした。 実装は別のブランチで行う。この Pull Request は設計文書だけを載せる。 Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN63): 受け入れ条件 6 の表を 8 通りへ揃え、wqy-zenhei の扱いを実態へ直す レビュー指摘 2 件への対応。 - 受け入れ条件 6 の表に総称ファミリ 4 つ × 言語 2 つの 8 行が揃っていなかった。 `sans:lang=zh-cn` / `sans:lang=ko` / `monospace:lang=ko` を足し、欧文を名指しした 行にも `Arial:lang=ko` を足して 12 行にした(major) - 設計文書の「解決先の表」にも同じ 4 行を足し、`serif:lang=ko` の変更前が `(未測定)` のままだったのを実測値 `WenQuanYi Zen Hei` へ直した - テスト設計の受け入れ条件 6 の行を「8 つ」から「12 行(総称ファミリ 4 つ × 言語 2 つの 8 行と、欧文を名指しした 3 行、中国語の書体を名指しした 1 行)」へ改めた - 「変えないもの」の `fonts-wqy-zenhei` の行が Dockerfile の実態と合っていなかった。 導入の行は無く `npx playwright install --with-deps chromium` が依存として入れる、 と書き直した。決定 6 の検査の観点も `apt-get remove` / `apt-get purge` / `dpkg -r` が無いことの確認へ具体化した(minor) いずれも手元の devbase-base:latest(arm64)での実測にもとづく。 Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN63): 切り戻し手順に派生イメージとコンテナの建て直しを足し、`` の数を 9 に直す cross-review round 2 (codex) の 2 件へ対応した。 - 切り戻し手順を 4 段(revert / base の再ビルド / 派生イメージの建て直し / 稼働中コンテナの作り直し)へ書き直した。base のタグを戻しても、派生イメージは `FROM devbase-base:latest` を自分のビルドの時点で焼き込むため建て直すまで古い層を持つ。 「影響」の表の「利用者の操作」の行にも同じことを書いた - `fonts-local.conf` の `` は、未導入の書体の受け皿 1 つと総称ファミリ 4 つ × 言語 2 つの 8 つで合計 9 つである。テスト設計の検査条件と本文の説明の両方を 9 つへ直した Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN63): 解決先の表の漏れ・参照先・キャッシュの説明を直す round 3 のレビュー指摘 3 件へ対応する。 - 解決先の表へ `Times New Roman:lang=zh-cn` の行を足す(実測済みの値。 `Arial:lang=ko` は既にあった) - 決定 2 の中の参照先を「決定 4」から「決定 8」へ直す。`sans-serif` の prefer を日本語へ向けるのは決定 8 である - 決定 4 のキャッシュの説明を Docker の仕様へ合わせる。`RUN` のキャッシュが 効いている間はその中身が実行されないため、取得先の内容が変わっても キャッシュは無効にならない Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN63): 受け入れ条件 13 の合否のラインを測り方の違いに耐える形へ直す 受け入れ条件 13 は合否のラインを「約 24 MB」とし、検証を `docker images` の差と定めていた。しかし 24 MB は稼働中のコンテナでの `du` の差(apt の リストとキャッシュを含む)で、`docker images` が出す層単位の値とは測り方が 違う。そのままでは正しい実装でも不合格になりうる。 合否のラインを「7.09GB に対して +0.5% 未満(40 MB 以下)」へ改め、 24 MB をラインに使わない理由を条件の中に書いた。設計文書の 「未確認のまま残ること」にも、このラインが測り方の違いを吸収するための ものである旨を追記した。 Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN63): コンテナの作り直しの案内を devbase down → up へ統一する devbase rebuild は devbase build --expires=7 のシノニム (lib/devbase/commands/container.py の cmd_rebuild) で、イメージのビルドしか 行わず稼働中のコンテナを作り直さない。期限内ならビルドそのものを飛ばすため、 切り戻しにも適用にも使えない。 「影響」の表の「利用者の操作」と「切り戻し手順」の 4 番目の両方で、稼働中の コンテナの作り直しを devbase down → devbase up に統一し、devbase rebuild が ここでは使えない理由を書き添えた。 Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN63): 受け入れ条件へ IPAPGothic と fc-match sans を足し、テスト設計に fc-match -s を書く (#221) round 6 のレビュー指摘 3 件への対応。 - 受け入れ条件 6 の表へ `IPAPGothic`(名指し)の行を足した。イメージに実在する 日本語の書体がそのまま残ることを固定する。受け入れ条件 4 は「イメージに無い 書体名が JP へ落ちる」条件なので、`WenQuanYi Zen Hei`(名指し)と同じ性質の この行は 6 の表へ置いた - 受け入れ条件 1 へ `fc-match sans`(`lang` なし)を足した。`fonts-local.conf` は `sans` の `` を持つのに、`lang` を付けない照合が固定されていなかった - テスト設計の受け入れ条件 1〜4 の行へ、`fc-match -s sans-serif:lang=ja` の 1 件目も 確かめることを明記した。あわせて受け入れ条件 6 の行数を 12 から 13 へ直した Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN63): 受け入れ条件 5 から Calibri / Cambria を外し 11 へ移す 受け入れ条件 5 の見出しは「欧文が壊れない」だが、`Calibri` と `Cambria` は 変更前に `WenQuanYi Zen Hei`(中国語のフェイス)へ落ちており、`Arial` などの 「変更の前後で変わらない」ものとは性質が違う。同じ枠に並べると基準を誤読させる。 - 受け入れ条件 5 は変わらない 3 つ(`Arial` / `Times New Roman` / `Courier New`)に絞る - 受け入れ条件 11 を「欧文の metric 互換が直る」とし、現状がどちらも `WenQuanYi Zen Hei` であることと、直す手段(`fonts-crosextra-carlito` / `fonts-crosextra-caladea`)を書く - 設計文書のテスト設計の表の `5・11` の行を、上の切り分けに合わせる 受け入れ条件の番号は 1〜16 のまま変えていない。 Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN63): 決定 2 の表がどの状態を測っているかを列の見出しと前置きで示す 「解決先の表」の ko の「変更前」と、決定 2 の表の ko の「規則なし」は 食い違って見えるが、測っている状態が違う。前者は /etc/fonts/local.conf が無い今の base イメージそのまま(WenQuanYi Zen Hei)、後者は local.conf は置いたうえで zh-cn / ko の だけを書かなかった状態 (Noto Sans CJK JP / Noto Serif CJK JP)である。どちらも 2026-09-22 に arm64 で実測した値なので、値は変えない。 読み分けられるように、決定 2 の表の列の見出しを「zh-cn / ko の規則なし」 へ改め、表の直前に 3 つの列がどれも local.conf を置いた状態であることと、 local.conf を置く前の値は「解決先の表」の「変更前」にあることを書いた。 決定 2 の末尾の段落の言い回しも同じ語に揃えた。 Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- issues/PLAN63_base-image-rendering-design.md | 425 +++++++++++++++++++ issues/PLAN63_base-image-rendering.md | 266 ++++++++++++ 2 files changed, 691 insertions(+) create mode 100644 issues/PLAN63_base-image-rendering-design.md create mode 100644 issues/PLAN63_base-image-rendering.md diff --git a/issues/PLAN63_base-image-rendering-design.md b/issues/PLAN63_base-image-rendering-design.md new file mode 100644 index 00000000..e5acd3b8 --- /dev/null +++ b/issues/PLAN63_base-image-rendering-design.md @@ -0,0 +1,425 @@ +# PLAN63: base イメージの日本語の描画と、文書を扱う軽量の道具 の設計 + +要求と受け入れ条件は [PLAN63_base-image-rendering.md](PLAN63_base-image-rendering.md) にある。 +この文書は「どう作るか」だけを扱う。 + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 総称ファミリ(`sans-serif` / `sans` / `serif` / `monospace`)と、イメージに無い書体名の指定を、Noto CJK の JP フェイスへ向ける | base とその派生イメージで文字を描くすべての道具(Chromium / Playwright / PDF の生成 / 画像の生成) | +| F2 | 中国語・韓国語を明示した指定を、その言語の、同じ様式のフェイスへ向ける | 同上 | +| F3 | PDF を画像にする・調べる、OOXML を壊さずに読み書きする、欧文の字幅を正しく測る道具を base へ入れる | `document-skills` を使う利用者 | +| F4 | 上の 3 つを回帰テストで固定し、利用者向け文書と CHANGELOG を合わせる | devbase の開発者・利用者 | + +## 解決の経路 + +fontconfig は `/etc/fonts/fonts.conf` から始まり、そこにある `` 1 行で `conf.d` を +**ファイル名の番号順**に読む。`local.conf` は `conf.d/51-local.conf` 経由で読まれる。 + +```mermaid +graph TD + FC["/etc/fonts/fonts.conf
include conf.d の 1 行だけ"] --> D[conf.d を番号順に読む] + D --> C30["30-metric-aliases.conf
Arial→Liberation Sans
Calibri→Carlito / Cambria→Caladea"] + C30 --> C50["50-user.conf
→ ~/.config/fontconfig/fonts.conf"] + C50 --> C51["51-local.conf
→ /etc/fonts/local.conf
★ 置き場所はここ"] + C51 --> C64["64-wqy-zenhei.conf
sans-serif の prefer に WenQuanYi Zen Hei"] + C64 --> C65["65-nonlatin.conf
sans-serif の prefer 一覧にも WenQuanYi Zen Hei"] + C65 --> C70["70-fonts-noto-cjk.conf"] +``` + +**`` は、一致した総称ファミリの直前へ挿入する(prepend)。** そのため +**最も早く読まれた `` が先頭に残る。** 効くのは順序が後だからではなく、先だからである。 +`51` は `64` / `65` より先なので、`local.conf` の指定が勝つ。 + +## 実測(2026-09-22 / arm64 の `devbase-base:latest`) + +Ubuntu 26.04 / fontconfig 2.17.1 / イメージのサイズ 7.09GB。稼働中のコンテナへ設定と +パッケージを入れて測った(イメージは建て直していない)。 + +### 解決先の表(この設計が固定するもの) + +| 指定 | 変更前 | 変更後 | +| --- | --- | --- | +| `sans-serif` | WenQuanYi Zen Hei | **Noto Sans CJK JP** | +| `sans-serif:lang=ja` | WenQuanYi Zen Hei | **Noto Sans CJK JP** | +| `sans` | WenQuanYi Zen Hei | **Noto Sans CJK JP** | +| `serif` | WenQuanYi Zen Hei | **Noto Serif CJK JP** | +| `monospace` | WenQuanYi Zen Hei Mono | **Noto Sans Mono CJK JP** | +| `Noto Sans JP` | WenQuanYi Zen Hei | **Noto Sans CJK JP** | +| `Meiryo` / `Yu Gothic` / `MS PGothic` | WenQuanYi Zen Hei | **Noto Sans CJK JP** | +| `Zen Kaku Gothic New`(未導入) | WenQuanYi Zen Hei | **Noto Sans CJK JP** | +| `Arial` | Liberation Sans | Liberation Sans | +| `Times New Roman` | Liberation Serif | Liberation Serif | +| `Courier New` | Liberation Mono | Liberation Mono | +| `Calibri` | WenQuanYi Zen Hei | **Carlito**(パッケージの追加による) | +| `Cambria` | WenQuanYi Zen Hei | **Caladea**(同上) | +| `sans-serif:lang=zh-cn` | Noto Sans CJK SC | Noto Sans CJK SC | +| `sans:lang=zh-cn` | Noto Sans CJK SC | Noto Sans CJK SC | +| `serif:lang=zh-cn` | Noto Serif CJK SC | Noto Serif CJK SC | +| `monospace:lang=zh-cn` | Noto Sans Mono CJK SC | Noto Sans Mono CJK SC | +| `sans-serif:lang=ko` | WenQuanYi Zen Hei | **Noto Sans CJK KR** | +| `sans:lang=ko` | WenQuanYi Zen Hei | **Noto Sans CJK KR** | +| `serif:lang=ko` | WenQuanYi Zen Hei | **Noto Serif CJK KR** | +| `monospace:lang=ko` | WenQuanYi Zen Hei Mono | **Noto Sans Mono CJK KR** | +| `Arial:lang=zh-cn` | Liberation Sans | Liberation Sans | +| `Times New Roman:lang=zh-cn` | Liberation Serif | Liberation Serif | +| `Arial:lang=ko` | Liberation Sans | Liberation Sans | +| `WenQuanYi Zen Hei`(名指し) | WenQuanYi Zen Hei | WenQuanYi Zen Hei | +| `IPAPGothic`(名指し) | IPAPGothic | IPAPGothic | + +`fc-match -s sans-serif:lang=ja` の並びも変わる。 + +| | 1 番目 | 2 番目 | 3 番目 | +| --- | --- | --- | --- | +| 変更前 | WenQuanYi Zen Hei | IPAPGothic | Loma(タイ語) | +| 変更後 | **Noto Sans CJK JP** | WenQuanYi Zen Hei | IPAPGothic | + +### パッケージの追加の実測 + +`apt-get install --no-install-recommends` で 6 つを指定した結果である。 + +| 項目 | 値 | +| --- | --- | +| 指定するパッケージ | 6 | +| 依存を含めて新規に入るパッケージ | **24** | +| ディスクの増分 | **約 24 MB**(apt のリストとキャッシュを含む測り方。7.09GB に対して +0.34%) | +| 使えるようになるコマンド | `pdftoppm` / `pdfinfo` / `pdffonts` / `pdftocairo` | +| 使えるようになるモジュール | `PIL` 12.1.1 / `defusedxml` 0.7.1 / `lxml` 6.0.2 | +| 入らないもの | `soffice` / `libreoffice` / `pip` / `pip3` | + +## 構成要素 + +| 要素 | 変更 | 責務 | +| --- | --- | --- | +| `containers/base/fonts-local.conf`(新設) | 足す | 総称ファミリの先頭を日本語フェイスにする。未導入の書体の受け皿を置く。中国語・韓国語を明示した指定を守る。**先頭に、置き場所を動かせない理由をコメントで持つ** | +| `containers/base/Dockerfile` の 1 つ目の `RUN` の `apt-get install` | 変える | `fonts-noto-cjk` の行の隣へ 6 パッケージを足す | +| `containers/base/Dockerfile` の末尾の `COPY` 群 | 変える | `COPY --chmod=0644 fonts-local.conf /etc/fonts/local.conf` を足し、直後に `RUN sudo fc-cache -f` を置く | +| `tests/containers/test_base_dockerfile_fonts.py`(新設) | 足す | Docker を起動せずに、Dockerfile と `fonts-local.conf` の**形**を固定する | +| `tests/containers/test_base_image_font_matching.py`(新設) | 足す | 建てたイメージの中で `fc-match` の**解決先の表**を固定する。イメージが無い / 古いときは skip | +| `docs/user/container-operations.md` | 変える | base の説明に、日本語のフェイスと文書の道具の行を足す | +| `CHANGELOG.md` | 変える | `[Unreleased]` に `### Added`(6 パッケージ)と `### Fixed`(日本語が中国語フォントで描画される)。**イメージを建て直すまで反映されない**ことを添える | + +次のものは変えない。 + +- `fonts-wqy-zenhei` を削除しないこと(要求の前提 2)。Dockerfile に導入の行は無く、 + `npx playwright install --with-deps chromium` が依存として入れる +- `npx playwright install --with-deps chromium` とその後のクリーンアップ(前提 5、#220) +- 派生イメージの Dockerfile(すべて `FROM devbase-base:latest`) +- `containers/lfm`(base 由来ではない) + +## 入出力の契約 + +### `containers/base/fonts-local.conf` + +先頭のコメントは**受け入れ条件 8 が要求する内容**(置き場所を動かせない理由と、その実測)を持つ。 + +```xml + + + + + + sans-serifNoto Sans CJK JP + sansNoto Sans CJK JP + serifNoto Serif CJK JP + monospaceNoto Sans Mono CJK JP + + + + Noto Sans CJK JP + + + + + sans-serif + zh-cn + Noto Sans CJK SC + + + +``` + +規則は 4 つの `` と 9 つの `` で構成する。`` の内訳は、未導入の書体の +受け皿が 1 つと、**総称ファミリ 4 つ × 言語 2 つ = 8 つ**である。 + +| 総称ファミリ | `lang=zh-cn` で前置する | `lang=ko` で前置する | +| --- | --- | --- | +| `sans-serif` | `Noto Sans CJK SC` | `Noto Sans CJK KR` | +| `sans` | `Noto Sans CJK SC` | `Noto Sans CJK KR` | +| `serif` | `Noto Serif CJK SC` | `Noto Serif CJK KR` | +| `monospace` | `Noto Sans Mono CJK SC` | `Noto Sans Mono CJK KR` | + +これらのフェイスは `fonts-noto-cjk` / `fonts-noto-cjk-extra` に既にあり、追加の導入は要らない +(`fc-list : family` で JP / SC / KR の Sans・Serif・Sans Mono の 9 つの存在を確認済み)。 + +### `containers/base/Dockerfile` の差分の形 + +**1 つ目の `RUN`(最初の `apt-get install`)**: `fonts-noto-cjk fonts-noto-cjk-extra` の行の隣へ +足す。理由は決定 4。 + +```dockerfile + libnss3 libxrandr2 libxss1 \ + fonts-noto-cjk fonts-noto-cjk-extra \ + fonts-crosextra-carlito fonts-crosextra-caladea \ + poppler-utils python3-pil python3-defusedxml python3-lxml; \ +``` + +**末尾の `COPY` 群**(`ai-cli-aliases.sh` / `tmux.conf` と同じ区画、`USER ubuntu` より後): + +```dockerfile +COPY --chmod=0644 fonts-local.conf /etc/fonts/local.conf +RUN sudo fc-cache -f +``` + +`COPY` の所有者は `USER` の指定によらず root になるため、既存の `tmux.conf` と同じ形でよい。 +`fc-cache` は root で走らせる必要があるので `sudo` を付ける(同じ区画の +`sudo install -d` / `sudo ln -sf` と同じ流儀。`ubuntu` は NOPASSWD の sudo を持つ)。 + +## 処理の流れ + +ビルドの層と、この変更が触る位置。 + +```mermaid +graph TD + L1["RUN 1: apt(locales / git / fonts-noto-cjk …)
+ docker / terraform / gh / node / chromium
★ ここへ 6 パッケージを足す"] --> L2["RUN: ユーザーとグループ"] + L2 --> L3["RUN: aws / gcloud / uv / npm globals"] + L3 --> L4["RUN: bao"] + L4 --> L5["RUN(ubuntu): claude / agy / kiro
npx playwright install --with-deps chromium
→ ここで fonts-wqy-zenhei が入る
→ 直後に sudo rm -rf ~/.cache"] + L5 --> L6["COPY 群: ai-cli-aliases.sh / tmux.conf / entrypoint.sh
★ ここへ fonts-local.conf の COPY と fc-cache -f を足す"] +``` + +**`fc-cache -f` は `L5` より後でなければならない。** `L5` の `--with-deps` が +`fonts-wqy-zenhei` / `fonts-ipafont-gothic` / `fonts-liberation` などを入れ、その直後に +`~/.cache` を消す。`L5` より前で走らせると、後から入った書体を知らないキャッシュが残る。 + +## 決定の記録 + +### 決定 1: 置き場所は `/etc/fonts/local.conf` から動かさない + +`conf.d/99-*.conf` へ置くと効かない。同じ内容で実測した(2026-09-22、arm64)。 + +| 置き場所 | `fc-match sans-serif` | `fc-match sans-serif:lang=ja` | +| --- | --- | --- | +| `/etc/fonts/local.conf` | **Noto Sans CJK JP** | **Noto Sans CJK JP** | +| `/etc/fonts/conf.d/99-devbase-fonts.conf` | WenQuanYi Zen Hei | WenQuanYi Zen Hei | + +理由は「解決の経路」に書いたとおりで、`` が prepend であり、**最も早く読まれた +`` が先頭に残る**ためである。`51-local.conf` は `64` / `65` より先に読まれる。 + +**この実測では、もう 1 つ分かったことがある。** `99-` へ置いた場合でも +`` の規則(zh-cn / ko の前置)は効いていた。効かないのは +`` だけである。**規則の種類によって順序への依存が違う**ため、 +「99 でも一部は効く」ことを知らないと、部分的に直ったのを見て置き場所の問題を見落とす。 + +この理由は `containers/base/fonts-local.conf` の先頭のコメントにも同じ内容で残す(受け入れ条件 8)。 + +**`conf.d/00-*.conf` のように小さい番号を使う案は採らない。** 動きはするが、 +`local.conf` は fontconfig が「システムの局所的な設定」のために用意した場所で、番号の +付け替えで順序を取りに行くのは他のパッケージの番号と競合する。 + +### 決定 2: 中国語・韓国語の規則は、総称ファミリを名指ししたときだけ効かせる + +**#161 の本文にある形(`lang` だけを見て前置する)は採らない。実測で壊れるものが見つかった。** + +3 つの変種を同じイメージの中で比べた(2026-09-22、arm64)。 + +**3 つの列はどれも `/etc/fonts/local.conf` を置いた状態である。** 違うのは zh-cn / ko の +`` の書き方だけで、4 つの `` と受け皿はどの列にもある。**`local.conf` を置く +前の値(今の base イメージそのまま)は、この表ではなく「解決先の表」の「変更前」の列にある。** +`local.conf` が無ければ `sans-serif` 自体が `WenQuanYi Zen Hei` なので、`lang=ko` も +`WenQuanYi Zen Hei` になる。`` を置いてはじめて、ko の指定が日本語のフェイスへ +引き寄せられる ── それがこの表の 1 列目である。 + +| 指定 | zh-cn / ko の規則なし | #161 の形(`lang` だけ) | この設計(`family` + `lang`) | +| --- | --- | --- | --- | +| `sans-serif:lang=zh-cn` | Noto Sans CJK SC | Noto Sans CJK SC | Noto Sans CJK SC | +| `serif:lang=zh-cn` | Noto Serif CJK SC | **Noto Sans CJK SC** ← 様式が崩れる | Noto Serif CJK SC | +| `monospace:lang=zh-cn` | Noto Sans Mono CJK SC | **Noto Sans CJK SC** ← 等幅でなくなる | Noto Sans Mono CJK SC | +| `sans-serif:lang=ko` | **Noto Sans CJK JP** ← 韓国語が日本語の字形になる | Noto Sans CJK KR | Noto Sans CJK KR | +| `serif:lang=ko` | **Noto Serif CJK JP** ← 同上 | **Noto Sans CJK KR** ← 様式が崩れる | Noto Serif CJK KR | +| `Arial:lang=zh-cn` | Liberation Sans | **Noto Sans CJK SC** ← 欧文が奪われる | Liberation Sans | +| `Times New Roman:lang=zh-cn` | Liberation Serif | **Noto Sans CJK SC** ← 同上 | Liberation Serif | + +**`binding="strong"` の前置は、名指しの書体よりも強い。** そのため `lang` だけを条件にすると、 +`lang=zh-cn` が付いたすべてのパターン ── `Arial` や `Times New Roman` を名指しした欧文の +指定を含む ── から、指定した書体を奪う。Chromium は中国語のページで `lang=zh-cn` を +載せるため、これは絵に描いた話ではない。 + +**zh-cn / ko の規則を 1 つも置かない案も採らない。** `zh-cn` は OS 既定の +`65-nonlatin.conf` と `70-fonts-noto-cjk.conf` が正しく扱うので規則なしでも合うが、 +**`ko` は合わない**(決定 8 の `sans-serif` の prefer が JP を先頭にするため、韓国語が +日本語の字形になる。これは `local.conf` を置いたことで初めて起きる)。 +`ko` だけ書くと非対称で、なぜ `zh-cn` が無いのかが後から読めない。8 つ並べて対称にする。 + +### 決定 3: 未導入の書体の受け皿は、弱い結合の `append` 1 つで足りる + +`` は、すべてのパターンの末尾へ +`Noto Sans CJK JP` を足す。**弱い結合なので、実在する指定を妨げない。** `Arial` は +`30-metric-aliases.conf`(スロット 30、このファイルより先)の強い結合で Liberation Sans へ +向くため、受け皿は末尾に付くだけで結果を変えない。実測で確認した(`Arial` / `Times New Roman` / +`Courier New` / `Calibri` / `Cambria` のいずれも変わらない)。 + +条件(``)を付けて「未導入のときだけ」足す形は採らない。fontconfig に「その書体が +実在するか」を問う `` は無く、弱い結合がまさにその働きをする。 + +### 決定 4: 6 パッケージは、1 つ目の `RUN` の最初の `apt-get install` の一覧へ足す + +Dockerfile の 1 つ目の `RUN` は `apt-get install` を 2 回呼ぶ。1 回目は Ubuntu の標準の +アーカイブから(`locales` / `git` / `fonts-noto-cjk` など)、2 回目は足したリポジトリから +(`docker-ce` / `terraform` / `gh` / `nodejs` / `chromium-browser`)。 + +**2 つの一覧はどちらも同じ `RUN` の中にあり、キャッシュの鍵は 1 つである。** どちらへ書いても +キャッシュの効き方は同じになる。6 つはすべて標準のアーカイブにあり外部のリポジトリを要さない +ので、1 回目の一覧へ、既にあるフォントの行の隣へ置く。 + +**新しい `RUN` を立てる案は採らない。** 立てれば、この 6 つを後から足し引きしても 1 つ目の +巨大な層のキャッシュは無効にならない。しかし代償が 3 つある。 + +| 代償 | 内容 | +| --- | --- | +| 層が 1 つ増える | base は既に層が多い。派生イメージ 7 つがこの上に積む | +| `apt-get update` をもう 1 回走らせる | 1 つ目の `RUN` は最後に `rm -rf /var/lib/apt/lists/*` でリストを消す。新しい `RUN` はリストを取り直す必要がある | +| クリーンアップを書き写す | `apt-get clean` と `rm -rf` を 2 か所に持つことになる | + +**キャッシュが無効になる条件は、その `RUN` の命令の文字列が変わるか、親の層が変わるか、 +`--no-cache` を渡すかの 3 つである。** 取得先の中身が変わってもキャッシュは無効にならない +(`curl | bash` は、キャッシュが効いている間は実行されない)。 + +**この変更そのものが 1 つ目の `RUN` の文字列を変えるため、層を分けても分けなくても、この +変更では 1 度建て直される。** 層を分けて守れるのは「次にこの 6 つを足し引きしたとき」だけで、 +その頻度は低い。`devbase build base --no-cache` はどのみち全部を建て直す。層を増やして +守るほどの利得が無い。 + +### 決定 5: `COPY` と `fc-cache -f` は末尾の `COPY` 群へ置く + +**`fc-cache -f` は、Playwright が `--with-deps` でフォントを入れる `RUN` より後でなければ +ならない。** その `RUN` は `fonts-wqy-zenhei` / `fonts-ipafont-gothic` / `fonts-liberation` などを +入れ、直後に `~/.cache` を消す。 + +末尾へ置くことには、キャッシュの上の利点もある。`fonts-local.conf` を書き換えたとき、 +無効になるのは末尾の数層だけで、巨大な `RUN` は建て直されない。 + +**フォントのパッケージを入れる 1 つ目の `RUN` の中で `fc-cache` を走らせる案は採らない。** +Playwright が後から入れるフォントを知らないキャッシュが残り、しかも `fonts-local.conf` を +1 文字直すたびにイメージ全体が建て直しになる。 + +なお `fc-cache` は `local.conf` の内容を反映するためではない(設定は照合のたびに読まれる)。 +走らせるのは、`~/.cache` を消した後に `/var/cache/fontconfig` を作り直し、コンテナの初回起動 +時のキャッシュ生成を避けるためである。 + +### 決定 6: 回帰テストは 2 段にする。形は Docker なしで、解決先は Docker ありで固定する + +**Dockerfile の文字列検査だけでは足りない。** 固定したいのは「`COPY` の行があること」では +なく「`fc-match sans-serif` が日本語を返すこと」で、後者は文字列からは分からない。決定 2 の +ような壊れ方(`lang` の条件が広すぎて欧文を奪う)は、Dockerfile を読んでも見えない。 + +| テスト | Docker | 何を固定するか | +| --- | --- | --- | +| `tests/containers/test_base_dockerfile_fonts.py` | 不要 | 6 パッケージが一覧にあること。`COPY` の宛先が `/etc/fonts/local.conf` であり `conf.d/` ではないこと。`fc-cache -f` が `COPY` より後にあること。`fonts-local.conf` が整形式の XML で、4 つの `` と 9 つの ``(受け皿 1 つと、総称ファミリ 4 つ × 言語 2 つの 8 つ)を持つこと。先頭のコメントが置き場所の理由(`51-local.conf` / `conf.d` / `99`)に触れていること。`libreoffice` / `soffice` / `pip` を入れていないこと。Dockerfile に `fonts-wqy-zenhei` を対象とする `apt-get remove` / `apt-get purge` / `dpkg -r` が無いこと | +| `tests/containers/test_base_image_font_matching.py` | 要る | 「解決先の表」の各行。1 回の `docker run` で全部の `fc-match` を採り、行ごとに突き合わせる | + +**Docker が要るテストは、`tests/snapshot/test_restore_incremental.py` の先例に合わせる。** +そこは fixture の中で `shutil.which('docker')` → `docker info` → `docker image inspect` の 3 段を +見て `pytest.skip` する(marker は使わない。`pyproject.toml` に marker の登録も `addopts` も無い)。 + +**それに 1 段足す。イメージの中に `/etc/fonts/local.conf` が無ければ skip する。** 理由は、 +この変更より前に建てた `devbase-base:latest` を持っている人が全員 `pytest tests/` で赤くなる +のを避けるためである。skip の文言に `devbase build base --no-cache` を書き、建て直せば検査が +効くようにする。**古いイメージを「失敗」として知らせる案は採らない。** 正しい作業ツリーで +テストが赤くなり、赤の意味が「壊れている」と「イメージが古い」で混ざる。 + +**このテストは CI では動かない。** `.github/workflows/ci.yml` にイメージを建てるジョブは 1 つも +無く、runner に `devbase-base:latest` は無いので skip になる。さらに +**`release/v3.7.0` を base にした Pull Request では検査ジョブが 1 件も動かない** +(`on.pull_request.branches` が `main` だけ。#216)。**`gh pr checks` の `no checks reported` と +`mergeStateStatus: CLEAN` は「通った」ことを意味しない。** そのため、解決先の表の証跡は +手元で採って Pull Request 本文へ貼る。 + +**環境変数(`DEVBASE_IMAGE_TESTS=1` など)で明示的に有効化する案は採らない。** 既存の +Docker を使うテストが環境変数を要求しておらず、流儀が 2 つに割れる。イメージの中に +`/etc/fonts/local.conf` があるかどうかは、「この検査が意味を持つ状態か」をそのまま表す。 + +### 決定 7: 設定は Dockerfile のヒアドキュメントではなく、独立したファイルにする + +`containers/base/` のビルドコンテキストはこのディレクトリそのものなので、ファイルを置けば +`COPY` で届く(`ai-cli-aliases.sh` / `tmux.conf` と同じ形)。独立したファイルにすると、 +XML として検査でき、差分が読め、置き場所の理由のコメントを長く書ける。#161 が求めている +「ファイル先頭のコメント」も、ヒアドキュメントの中では Dockerfile のコメントと混ざる。 + +### 決定 8: 日本語を既定にし、言語を明示しない中国語は日本語の字形で描く + +base の利用者は日本語話者で、扱う文書も日本語が多い。`lang` を伴わない `sans-serif` は +どちらかの言語を選ばざるをえず、**「日本語の文書が中国語の字形で描かれる」を裏返して +「言語を明示しない中国語の文書が日本語の字形で描かれる」にする。** + +裏返す先が小さいことは実測で確かめてある。中国語を明示した指定(`lang=zh-cn`)と、中国語の +書体を名指しした指定(`WenQuanYi Zen Hei`)はどちらも変わらない。Chromium は +`` のページで `lang` を載せる。 + +### 決定 9: 利用者の上書きの口は塞がない + +`/etc/fonts/conf.d/50-user.conf` が `~/.config/fontconfig/fonts.conf` を読む。スロット 50 は +`51-local.conf` より**先**なので、利用者が自分の `fonts.conf` で別の `` を書けば +そちらが勝つ。実測で確認した(利用者側で `sans-serif` を `WenQuanYi Zen Hei` に戻せた)。 + +この性質はこの設計が作るものではなく、`/etc/fonts/local.conf` を選んだことの帰結である。 +`conf.d/00-*.conf` を使っていたら、利用者の設定より先に読まれて上書きを塞いでいた。 + +## テスト設計 + +| 受け入れ条件 | 何で確かめるか | +| --- | --- | +| 1・2・3・4 | `test_base_image_font_matching.py` の解決先の表(JP の行)。受け入れ条件 2 は `fc-match` の 1 件だけでなく、**`fc-match -s sans-serif:lang=ja` の 1 件目が `Noto Sans CJK JP` であること**も確かめる(同じ `docker run` の中で `fc-match -s` の出力の先頭を採る)。Docker が無い / イメージが古いときは skip | +| 5・11 | 同じ表の欧文の行。5 は変更の前後で変わらない 3 つ(`Arial` / `Times New Roman` / `Courier New`)、11 は変更で直る 2 つ(`Calibri` → Carlito / `Cambria` → Caladea。**現状はどちらも `WenQuanYi Zen Hei`**) | +| 6 | 同じ表の `lang` を明示した行と、書体を名指しした行の 13 行(総称ファミリ 4 つ × 言語 2 つの 8 行、欧文を名指しした 3 行、実在する書体を名指しした 2 行(`WenQuanYi Zen Hei` / `IPAPGothic`))。**決定 2 の壊れ方を捕まえるのはこの行である** | +| 7 | `test_base_dockerfile_fonts.py`: `COPY` の宛先が `/etc/fonts/local.conf` であること、`conf.d/` を宛先にする `COPY` が無いこと、`fc-cache -f` が 1 度だけで `COPY` より後にあること | +| 8 | `test_base_dockerfile_fonts.py`: `fonts-local.conf` の先頭のコメントが `51-local.conf` と `conf.d` と `99` に触れていること。設計文書の側は目視 | +| 9・10・12 | `test_base_image_font_matching.py` と同じ `docker run` の中で、`command -v` と `python3 -c "import …"` の結果も採る | +| 13 | ビルドの前後の `docker images`。手で測って Pull Request 本文へ貼る | +| 14 | `uv run --locked pytest tests/ -q` | +| 15 | `devbase build base --no-cache` | +| 16 | 派生イメージを 1 つ建て直して `fc-match sans-serif` を見る。手で確かめて Pull Request 本文へ貼る | + +`test_base_image_font_matching.py` は `docker run` を**セッションで 1 回**に抑える +(`scope="session"` の fixture が 1 つのスクリプトを走らせ、結果を辞書にして返す)。 +`fc-match` を 20 回別々に `docker run` すると、コンテナの起動だけで数十秒かかる。 + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| LibreOffice と Chromium での実際の描画 | どちらも base に無いため実機未検証。確かめたのは `fc-match` の水準まで。LibreOffice は fontconfig とは別の照合も持つため、解決先が `fc-match` と一致しないことがある(#161 の由来になった `volareinc/nyle-dx` PR #5 では `NotoSansCJKsc` が埋め込まれていた)。`containers/docs`(#219)を作るときに、そこで確かめる | +| amd64 での再現 | 手元は arm64 のみ。amd64 では `google-chrome-stable` が追加で入るため、`--with-deps` が入れるフォントの顔ぶれが違いうる。`fc-match` の表が同じになるかは、amd64 の端末で建てるまで分からない | +| `containers/lfm` | `FROM nvidia/cuda:...` で base 由来ではなく、`fonts-noto-cjk` を自前で入れている(`containers/lfm/Dockerfile:23`)。同じ問題を抱えるかは未調査。抱えていれば別途起票する | +| イメージの増分の測り方 | 24 MB は稼働中のコンテナでの `du` の差で、apt のリストとキャッシュを含む。層としての増分は、実装の持ち場で `devbase build base --no-cache` の前後の `docker images` で測り直す。受け入れ条件 13 の合否のラインは、測り方の違いを吸収できるよう +0.5% 未満(40 MB 以下)にしてある。 | +| 利用者への周知 | 建て直すまで反映されないため、CHANGELOG に `devbase build base --no-cache` が要ることを書く。既に建てた人がいつ建て直すかは devbase の側から決められない | diff --git a/issues/PLAN63_base-image-rendering.md b/issues/PLAN63_base-image-rendering.md new file mode 100644 index 00000000..9d751978 --- /dev/null +++ b/issues/PLAN63_base-image-rendering.md @@ -0,0 +1,266 @@ +# PLAN63: base イメージの日本語の描画と、文書を扱う軽量の道具 + +対象 issue: devbasex/devbase#161, devbasex/devbase#160 + +- ワークフローモード: `standard` + - 根拠: base イメージの本番の振る舞い(総称ファミリの解決先と、同梱するパッケージ)を変える。 + base はすべての派生イメージとプロジェクトの土台で、変更は再ビルドした全員に届く +- 2 件を 1 本にする理由: どちらも `containers/base/Dockerfile` の同じ apt / COPY の区画を触る。 + 分けると同じ箇所で競合し、レビューした差分と入る差分が変わる。#160 の本文も + 「#161 を先に入れるか、1 本の PR にまとめる」と書いている +- ベースブランチ: `release/v3.7.0`(release Pull Request は #212) + +## 目的 + +- **base コンテナで日本語を描いたとき、日本語のフェイスで描かれる。** 総称ファミリ + (`sans-serif` / `serif` / `monospace`)と、日本語環境でよく指定される書体名 + (`Meiryo` / `Yu Gothic` / `MS PGothic` / `Noto Sans JP`)と、イメージに無い書体名のすべてが、 + Noto CJK の JP フェイスへ解決される +- **欧文と、中国語・韓国語を明示した指定は壊さない。** `Arial` / `Times New Roman` / `Courier New` + は metric 互換のまま、`lang=zh-cn` / `lang=ko` はそれぞれの言語のフェイスのままにする +- **PDF を画像にする・調べる、OOXML を壊さずに読み書きする、欧文の字幅を正しく測る**が、 + base だけで(派生イメージも外部のサービスも使わずに)できる + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わらない(CLI の引数・環境変数・コマンドは増減しない) | +| データ | 変わらない | +| 既存の振る舞い | **変わる。** base コンテナの中で描かれる文字のフェイスが変わる。イメージを建て直すまでは変わらない。CHANGELOG では #161 を Fixed、#160 を Added に書く | +| イメージのサイズ | +約 24 MB(7.09GB に対して +0.34%)。`fonts-local.conf` 自体は 0 | +| 利用者の操作 | **`devbase build base --no-cache` が要る。`devbase up` だけでは反映されない。** 派生イメージ(`containers/general` など)を使っているプロジェクトは、その派生イメージも建て直す。稼働中のコンテナは `devbase down` → `devbase up` で作り直す | + +## 前提 + +- **前提 1: base の既定は日本語にする。** 利用者は日本語話者で、扱う文書も日本語が多い。 + 中国語・韓国語は `lang` を明示したときだけそのフェイスを保つ形にする(言語を明示しない + 中国語の文書は日本語の字形で描かれる。これは意図した振る舞いで、変更前の「日本語の文書が + 中国語の字形で描かれる」を裏返したものである) +- **前提 2: `fonts-wqy-zenhei` は削除しない。** #161 が実測付きで結論している。削除しても + OS 既定の `65-nonlatin.conf` が `sans-serif` の prefer 一覧に `WenQuanYi Zen Hei` を含むため + 日本語にはならず(タイ語の Loma か IPAPGothic へ落ちる)、中国語のページを豆腐にするだけになる +- **前提 3: LibreOffice は base に入れない。** #160 の決定。展開 372〜459 MB は base の規律に + 見合わない +- **前提 4: `containers/docs` は新設しない。** #160 の表題の範囲は base への追加であり、 + 派生イメージの新設はそれを超える。#219 として起票済み(派生イメージ・使い捨てコンテナ・ + Google Slides の 3 つの比べ方と、LibreOffice の実測表を引き写してある) +- **前提 5: Playwright のブラウザと arm64 の Chromium には手を付けない。** #161 の「決めること」は + #220 として起票済み。`playwright-kit` が実行時に `uv sync` + `playwright install chromium` を + 自前で行う設計であることを確認した(`devbasex/ai-plugins` の + `plugins/playwright-kit/skills/playwright-kit-ops/templates/run.sh` ほか) +- **前提 6: CI はこの変更を検査しない。** `.github/workflows/ci.yml` にイメージを建てるジョブは + 1 つも無く、`on.pull_request.branches` は `main` だけである。**`release/v3.7.0` を base にした + Pull Request では検査ジョブが 1 件も動かない**(#216)。そのため、イメージの中でしか確かめ + られない受け入れ条件の証跡は、**手元で建てたイメージから採って Pull Request 本文へ載せる** +- **前提 7: `ENV LANG` は設定しない。** 言語のヒントに頼らず、総称ファミリの解決先そのものを + 日本語にする(`fc-match sans-serif` は `lang` を明示しなくても日本語になる)。`LANG` を + `ja_JP.UTF-8` にすると、コンテナの中のすべてのコマンドの出力・ソート順・日付の書式が変わり、 + 影響がフォントの外へ出る +- **前提 8: 追加するのは 6 パッケージだけで、`pip` は足さない。** Python パッケージが要るときは + 既にある `uv` / `uvx` で賄う(`markitdown` は `uvx` のままにする。焼き込むと展開で 200 MB 前後) + +## 対象範囲 + +含む: + +- `containers/base/fonts-local.conf`(新設)と、それを `/etc/fonts/local.conf` へ置く `COPY`、 + および `fc-cache -f` の実行(#161) +- `containers/base/Dockerfile` への 6 パッケージの追加(#160) + (`poppler-utils` / `python3-pil` / `python3-defusedxml` / `python3-lxml` / + `fonts-crosextra-carlito` / `fonts-crosextra-caladea`) +- 回帰テスト(`tests/containers/`) +- 利用者向け文書と CHANGELOG + +含まない: + +- LibreOffice の追加(前提 3) +- `containers/docs` の新設(前提 4、#219) +- Playwright のブラウザの導入方法と arm64 の Chromium(前提 5、#220) +- `fonts-wqy-zenhei` の削除(前提 2) +- `ENV LANG` の設定(前提 7) +- `pip` の追加(前提 8) +- 派生イメージ(`containers/general` / `go` / `php` / `php85` / `bi-tools` / `latex` / `trygroup`)の + Dockerfile の変更。いずれも `FROM devbase-base:latest` なので、base を建て直せば自動的に効く +- `containers/lfm` の変更。`FROM nvidia/cuda:...` で base 由来ではなく、`fonts-noto-cjk` を + 自前で入れている。同じ問題を抱えるかは未調査(下の「未確認のまま残ること」) +- 新しい型・永続データ・画面の追加(そのためクラス図・ER 図・画面遷移図を作らない) + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | base イメージの構成物は `containers/base/` の直下に置く。ビルドコンテキストはこのディレクトリそのもの(`lib/devbase/commands/container.py` の `_build_single_image` が `str(image_dir)` を渡す)なので、`COPY` したいファイルはここに置けば届く | +| コーディング規約 | `containers/base/Dockerfile` の既存の書き方に合わせる。`COPY --chmod=` で権限を明示し、なぜそうするかを直前のコメントに書く | +| テスト戦略 | `tests/containers/` の既存の流儀に合わせる。既定は Docker に依存しない検査(Dockerfile とスクリプトの文字列・関数の呼び出し)。Docker が要る検査は `tests/snapshot/test_restore_incremental.py` の先例(fixture の中で `shutil.which` → `docker info` → `docker image inspect` の 3 段を見て `pytest.skip`)に合わせる | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 手元で全体テスト、`devbase build base --no-cache` と解決先の表の採取 | +| 確認してから行う | 前提 1(日本語を既定にする)と受け入れ条件 6 の表の確定(設計 Pull Request の承認で確かめる) | +| 行わない | LibreOffice の追加、`containers/docs` の新設、`fonts-wqy-zenhei` の削除、`ENV LANG` の設定、Playwright まわりの変更 | + +## 実装計画 + +設計は [PLAN63_base-image-rendering-design.md](PLAN63_base-image-rendering-design.md)。 +**タスクへの分解は実装の持ち場で `/ndf:implementation-plan` が行う。** ここでは触る対象だけを挙げる。 + +### 修正対象 + +- `containers/base/fonts-local.conf`(新設) +- `containers/base/Dockerfile` +- `tests/containers/test_base_dockerfile_fonts.py`(新設)、 + `tests/containers/test_base_image_font_matching.py`(新設。Docker が要る) +- `docs/user/container-operations.md`(base の説明に文書の道具とフォントの行を足す) +- `CHANGELOG.md` + +### 切り戻し手順 + +- データの移行は無い。変わるのはイメージの中身だけである +- 戻すには 4 つが要る。 + 1. ブランチの revert + 2. `devbase build base --no-cache` + 3. **base から派生したイメージの建て直し**(`containers/general` / `go` / `php` / `php85` / + `bi-tools` / `latex` / `trygroup` のうち使っているもの)。派生イメージは + `FROM devbase-base:latest` を自分のビルドの時点で焼き込むため、base のタグを戻しても + 建て直すまで古い層を持つ + 4. **稼働中のコンテナの作り直し**(`devbase down` → `devbase up`)。既に起動している + コンテナは、イメージを建て直しただけでは入れ替わらない。**`devbase rebuild` はここでは + 使えない。** `devbase build --expires=7` のシノニム(`lib/devbase/commands/container.py` + の `cmd_rebuild`)で、イメージのビルドしか行わずコンテナを作り直さないうえ、期限内なら + ビルドそのものを飛ばす +- **建て直すまで戻らない。** 同じことが適用の側にも当たる(受け入れ条件 16) + +## 受け入れ条件 + +**実測の基準**: 以下の「現状」はすべて、2026-09-22 に手元の `devbase-base:latest` +(Ubuntu 26.04 / arm64 / fontconfig 2.17.1 / 7.09GB)で採った。イメージを建て直さずに +確かめられるものは、稼働中のコンテナへ設定とパッケージを入れて確かめてある。 + +### フォントの解決先(#161) + +- [ ] 1. 建てた base イメージの中で `fc-match sans-serif` が `Noto Sans CJK JP` を返す + (現状: `WenQuanYi Zen Hei`)。`fc-match sans` も同じ(現状: `WenQuanYi Zen Hei`) +- [ ] 2. `fc-match sans-serif:lang=ja` も `Noto Sans CJK JP` を返す(現状: `WenQuanYi Zen Hei`)。 + `fc-match -s sans-serif:lang=ja` の 1 件目も `Noto Sans CJK JP` になる + (現状: `WenQuanYi Zen Hei` → `IPAPGothic` → `Loma` の順) +- [ ] 3. `fc-match serif` が `Noto Serif CJK JP`、`fc-match monospace` が `Noto Sans Mono CJK JP` を返す + (現状: `WenQuanYi Zen Hei` / `WenQuanYi Zen Hei Mono`) +- [ ] 4. `Noto Sans JP` / `Meiryo` / `Yu Gothic` / `MS PGothic` と、イメージに無い書体名 + (`Zen Kaku Gothic New`)が、いずれも `Noto Sans CJK JP` を返す(現状: すべて `WenQuanYi Zen Hei`) +- [ ] 5. **欧文が壊れない**(変更の前後で変わらない)。`Arial` → `Liberation Sans`、 + `Times New Roman` → `Liberation Serif`、`Courier New` → `Liberation Mono`。 + **`Calibri` と `Cambria` はここに含めない。** この 2 つは変更前に + `WenQuanYi Zen Hei`(中国語のフェイス)へ落ちており、「変わらない」ではなく + 「直る」ものである。受け入れ条件 11 で固定する +- [ ] 6. **他言語が壊れない。`lang` を明示した指定は、その言語の、しかも同じ様式(sans / serif / + 等幅)のフェイスを返す。** + + | 指定 | 返すもの | + | --- | --- | + | `sans-serif:lang=zh-cn` | `Noto Sans CJK SC` | + | `sans:lang=zh-cn` | `Noto Sans CJK SC` | + | `serif:lang=zh-cn` | `Noto Serif CJK SC` | + | `monospace:lang=zh-cn` | `Noto Sans Mono CJK SC` | + | `sans-serif:lang=ko` | `Noto Sans CJK KR` | + | `sans:lang=ko` | `Noto Sans CJK KR` | + | `serif:lang=ko` | `Noto Serif CJK KR` | + | `monospace:lang=ko` | `Noto Sans Mono CJK KR` | + | `Arial:lang=zh-cn` | `Liberation Sans`(欧文の指定は言語で変わらない) | + | `Arial:lang=ko` | `Liberation Sans`(同上) | + | `Times New Roman:lang=zh-cn` | `Liberation Serif` | + | `WenQuanYi Zen Hei`(名指し) | `WenQuanYi Zen Hei`(残る) | + | `IPAPGothic`(名指し) | `IPAPGothic`(残る。イメージに実在する日本語の書体を奪わない) | + +- [ ] 7. 設定は `/etc/fonts/local.conf` に置かれ、`/etc/fonts/conf.d/` には 1 つも置かれない。 + ビルドの中で `fc-cache -f` が 1 度走る + 検証: `tests/containers/` の Dockerfile の文字列検査 +- [ ] 8. `/etc/fonts/local.conf` から動かさない理由(`conf.d/99-*.conf` では効かないこと、および + その実測)が、`containers/base/fonts-local.conf` の先頭のコメントと設計文書の + 「決定の記録」の**両方**にある + 検証: `tests/containers/` の文字列検査(コメントの有無)と、設計文書の目視 + +### 文書を扱う道具(#160) + +- [ ] 9. `pdftoppm` / `pdfinfo` / `pdffonts` / `pdftocairo` が `PATH` にある +- [ ] 10. `python3 -c "import PIL, defusedxml, lxml"` が終了コード 0 +- [ ] 11. **欧文の metric 互換が直る。** `fc-match Calibri` が `Carlito`、`fc-match Cambria` が + `Caladea` を返す(**現状はどちらも `WenQuanYi Zen Hei`**)。`fonts-crosextra-carlito` と + `fonts-crosextra-caladea` を足すことで直る +- [ ] 12. `soffice` / `libreoffice` / `pip` / `pip3` のいずれも `PATH` に無い(前提 3・8 のまま)。 + `uv` はある +- [ ] 13. 追加するのは 6 パッケージの指定だけで、依存を含めて新規に入るのは 24 パッケージ、 + **イメージの増分が 7.09GB に対して +0.5% 未満(40 MB 以下)**に収まる + 検証: `devbase build base --no-cache` の前後の `docker images` の差 + **合否のラインを「約 24 MB」にしない。** 24 MB は稼働中のコンテナでの `du` の差で、 + apt のリストとキャッシュを含む測り方である。`docker images` が出すのは層単位の値で、 + 層の重なりの分だけずれる。同じ数字を 2 つの測り方で比べると、正しい実装でも + 不合格になりうる + +### 退行しないこと + +- [ ] 14. `uv run --locked pytest tests/ -q` が終了コード 0(既存の `tests/containers/` の 8 ファイルを含む) +- [ ] 15. `devbase build base --no-cache` が arm64 で成功する。6 パッケージは Ubuntu 26.04 の + arm64 にすべて存在する(版は下の表) +- [ ] 16. base を建て直した後、派生イメージ(`containers/general` など)を建て直すと、同じ + 解決先になる(`FROM devbase-base:latest` のため)。1 つで確かめる + +| パッケージ | 版(2026-09-22 / arm64) | +| --- | --- | +| `poppler-utils` | 26.01.0-2ubuntu0.1 | +| `python3-pil` | 12.1.1-2ubuntu1.3 | +| `python3-defusedxml` | 0.7.1-3build1 | +| `python3-lxml` | 6.0.2-1build1 | +| `fonts-crosextra-carlito` | 20230309-2 | +| `fonts-crosextra-caladea` | 20200211-2 | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --locked pytest tests/ -q` | +| イメージの中の解決先 | `devbase build base --no-cache` の後、`docker run --rm --entrypoint /bin/bash devbase-base:latest -c 'fc-match ...'` の表(受け入れ条件 1〜6・9〜12)。出力を Pull Request 本文へ貼る | +| イメージのサイズ | ビルドの前後の `docker images` | +| CI | **動かない**(前提 6、#216)。`gh pr checks` の `no checks reported` と `mergeStateStatus: CLEAN` は「通った」ことを意味しない | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| LibreOffice と Chromium での実際の描画 | どちらも base に無いため実機未検証。確かめたのは `fc-match` の水準まで(#161 と同じ)。LibreOffice は fontconfig とは別の照合も持つ | +| `containers/lfm` | `FROM nvidia/cuda:...` で base 由来ではなく、`fonts-noto-cjk` を自前で入れている(`containers/lfm/Dockerfile:23`)。同じ問題を抱えるかは未調査。抱えていれば別途起票する | +| amd64 での再現 | 手元は arm64 のみ。amd64 では `google-chrome-stable` が追加で入るため、フォントの顔ぶれが違いうる | +## 依頼(原文) + +#161: + +> **base コンテナでは、日本語が中国語のフォントで描画される。** 総称ファミリの `sans-serif` そのものが中国語フォントへ解決される。 +> +> **影響は Office 文書の描画に限らない。** fontconfig は Chromium / Playwright のスクリーンショット、PDF の生成、画像の生成すべてが参照する。**日本語を含むページを撮ると、中国語の字形で写る。** +> +> `/etc/fonts/local.conf` を1つ置く。(…)Dockerfile へは `COPY --chmod=0644 fonts-local.conf /etc/fonts/local.conf` を足し、`fc-cache -f` を1度走らせる。**追加パッケージは要らない(サイズは 0)。** +> +> **`conf.d/99-*.conf` へ置くと効かない。** 同じ内容で実測した。 +> +> **`fonts-wqy-zenhei` を外す案は採らない。** rdepends が空なので削除自体はできるが、上の実測のとおり `65-nonlatin.conf` が残るため `sans-serif` はタイ語や IPAPGothic へ落ちるだけで、日本語にはならない。 + +#160: + +> **base コンテナに、Office 文書を画像へ描画する経路が1つも無い。**(…) +> +> **LibreOffice は base に入れない(決定)。** 展開 372〜459 MB は base の規律に見合わない。 +> +> ``` +> poppler-utils +> python3-pil python3-defusedxml python3-lxml +> fonts-crosextra-carlito fonts-crosextra-caladea +> ``` +> +> **展開 約24 MB / ダウンロード 約7 MB、7.09GB の base に対して +0.34%。** +> +> **`pip` は足さない。`uv` があるため、必要な Python パッケージはそちらで賄う。** +> +> #161 と本件は `containers/base/Dockerfile` の同じ apt / COPY の区画を触る。**#161 を先に入れるか、1 本の PR にまとめる。** + From 5c4cd82652a957c79972762cc0194eaed1f276f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Tue, 22 Sep 2026 21:19:51 +0900 Subject: [PATCH 03/15] =?UTF-8?q?=E8=A8=AD=E8=A8=88(PLAN64):=20=E6=A9=9F?= =?UTF-8?q?=E5=AF=86=E3=81=AE=E5=8F=82=E7=85=A7=E3=81=AE=E8=A6=8B=E5=87=BA?= =?UTF-8?q?=E3=81=97=E3=81=AB=E3=82=B0=E3=83=AB=E3=83=BC=E3=83=97=E3=81=AE?= =?UTF-8?q?=E8=AA=AD=E3=81=BF=E6=9B=BF=E3=81=88=E3=81=AE=E5=89=8D=E5=BE=8C?= =?UTF-8?q?=E3=82=92=E5=87=BA=E3=81=99=20(#188)=20(#223)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(PLAN64): 機密の参照の見出しにグループの読み替えの前後を出す設計 (#188) 要求と受け入れ条件 (issues/PLAN64_secret-label-group.md) と設計 (issues/PLAN64_secret-label-group-design.md) の 2 文書を足す。lib/ は変えない。 Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN64): レビューの指摘 3 件を反映 (#188) - 受け入れ条件 7 の例示を、実際に SecretRef.label() の既定を使う文言 (OpenBaoBackend._check_group / runtime の DEVBASE_ACCOUNT_GROUP 警告) へ差し替えた。 --group の不一致の文言 (_project_group_mismatch) は display_group を直接呼んでおり、 今も読み替えの前後を出す。対象範囲の「含まない」と決定 2 に明記した - 設計のテスト設計 7 の参照先を OpenBaoSettings.path_of から OpenBaoBackend._check_group へ直した - 影響の「公開インタフェース」に env backend migrate を足した Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN64): round 2 の指摘 2 件を反映 (#188) - 前提 2 を直した。config.openbao が None かどうかで backend の種類を判定しては ならない (backend: age でも openbao: 節が残れば None にならない)。表示の分岐は SecretStore.storage_group に任せる旨へ揃え、決定 3 の根拠にも足した - 受け入れ条件 5・6 の検証を、既存テストの通過から新規の出力テストへ変えた。 既存の assert は '=== グローバル' の前方一致で、見出しにグループが付いても通る ため「変わらないこと」を確かめられない Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN64): round 3 の指摘 2 件を反映 (#188) - 設計文書の末尾に残っていた不要なタグ を消した - 受け入れ条件 3 とテスト設計を、env backend migrate の 2 つの一覧の両方を 確かめる形へ直した。計画の一覧 (_MigrationPlan._heading) と --to age の 完了表示は別の関数が出すため、--dry-run の計画一覧だけでは決定 4 を 確かめられない Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN64): 決定 6 の仕様の書き換え指示を決定 2 の例外へ揃えた (#188) 「エラー文言とログを除く」と書くと、display_group を直接呼ぶ _project_group_mismatch の -p の拒否の文言 (default → nyle を出す既存の仕様と テスト) と矛盾する。除くものを「引数なしの SecretRef.label() で参照を表示する エラー文言・ログ」に限る形へ直し、機能一覧 F2 と曖昧語の具体化にも同じ分かれ目を 書いた。 Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN64): テスト設計 7 の検証対象を要求文書へ揃えた (#188) 受け入れ条件 7 は 2 つの文言 (OpenBaoBackend._check_group の例外と、 runtime.py の DEVBASE_ACCOUNT_GROUP の警告) を挙げているが、設計のテスト設計は 例外だけを書いていた。警告文でも → が出ないことを見る旨を足した。 Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- issues/PLAN64_secret-label-group-design.md | 323 +++++++++++++++++++++ issues/PLAN64_secret-label-group.md | 236 +++++++++++++++ 2 files changed, 559 insertions(+) create mode 100644 issues/PLAN64_secret-label-group-design.md create mode 100644 issues/PLAN64_secret-label-group.md diff --git a/issues/PLAN64_secret-label-group-design.md b/issues/PLAN64_secret-label-group-design.md new file mode 100644 index 00000000..6e17ec49 --- /dev/null +++ b/issues/PLAN64_secret-label-group-design.md @@ -0,0 +1,323 @@ +# PLAN64: 機密の参照の見出しにグループの読み替えを出す設計 + +要求と受け入れ条件は [PLAN64_secret-label-group.md](PLAN64_secret-label-group.md) にある。この文書は「どう作るか」だけを扱う。 + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 参照の見出しのグループ名が、読み替えの前と後(`default → nyle`)を出す | `env list` / `env backend test` / `env backend migrate` を打つ利用者 | +| F2 | **引数なしの `label()` で参照を表示する**エラー文言・警告・ログは、読み替える前の名前のままにする(`display_group` を直接呼ぶ文言は今も前後を出し、変えない) | 失敗の原因を読む利用者と、ログを読む開発者 | +| F3 | 参照の表示の文言を組み立てる場所を 1 つに保つ | devbase の開発者(新しい見出しを足す人) | +| F4 | 確定仕様が、どの文言がどちらの形になるかを重ならない形で述べる | 確定仕様の読み手 | + +## 構成要素 + +### コード + +| 要素 | 変更 | 責務 | +| --- | --- | --- | +| `SecretRef.label()`(`lib/devbase/env/secret_store.py`) | 変える | 参照の表示の文言を組み立てる唯一の場所。キーワード引数 `group_display` を足し、グループの括弧の中に入れる名前だけを外から受ける。省いたときは今までどおり `self.group`(読み替える前の名前)を入れる | +| `SecretStore.display_label(ref)`(同) | 足す | 見出し用の表示を作る唯一の口。`storage_group(ref.group)` で読み替えの要否を判定し、要るときだけ `OpenBaoSettings.display_group` の結果を `label(group_display=...)` へ渡す | +| `SecretStore.storage_group`(同) | 変えない | 「グループの表示に読み替えを出すか」の判定に使う。`backend` が `openbao` でない・設定が無い・`layout: flat`・グループが `None` のいずれでも `None` を返す | +| `OpenBaoSettings.display_group`(`lib/devbase/env/backend_config.py`) | 変えない | 読み替えの前と後の文字列(`default → nyle`)を作る | +| `cmd_env_list` の共通の節(`lib/devbase/commands/env.py`) | 変える | `env_file.ref.label()` を `store.display_label(env_file.ref)` にする | +| `_group_suffix`(同) | 変える | 引数に `store` を取り、`display_label` の差分を取る。文言は写さない | +| `cmd_env_backend_test`(`lib/devbase/commands/env_backend.py`) | 変える | 参照ごとの行の `ref.label()` を `store.display_label(ref)` にする | +| `cmd_env_backend_migrate` の完了表示(同) | 変える | 「サーバ上の機密はそのまま残っています」の一覧の `unit.server_ref.label()` を `display_label` にする | +| `_MigrationPlan._heading`(同) | 変える | 移行の計画の一覧の `unit.server_ref.label()` を `self.server_store.display_label(...)` にする | + +変えないものは次の 6 つである。 + +| 変えないもの | 理由 | +| --- | --- | +| `SecretRef` のフィールドと等価性 | 参照の等価性とキャッシュの鍵に入っている | +| `OpenBaoSettings.path_of` / `display_path` | 置き場のパスは今も読み替え後の名前を出している | +| `env backend status` の表示 | すでに `display_group` を使っている | +| `bundle.py` / `io_import.py` の「別の置き場のプロジェクト」の列挙 | 同上 | +| `display_label` を通らない 47 か所の `label()` の呼び出し | 決定 2・決定 5 | +| 一覧の桁幅(`:<24` / `:<28` / `:<40`) | 決定 7 | + +### 呼び出しの関係 + +図は `label()` を呼ぶ関係だけを描く。設定の読み込みと backend の選択は省く。 + +```mermaid +graph TD + subgraph 見出しを出す側 + L[cmd_env_list] + GS[_group_suffix] + BT[cmd_env_backend_test] + BM[cmd_env_backend_migrate
の完了表示] + MH[_MigrationPlan._heading] + end + subgraph 表示の口 + DL[SecretStore.display_label] + SG[SecretStore.storage_group] + DG[OpenBaoSettings.display_group] + end + subgraph 文言 + LB["SecretRef.label(group_display=…)"] + end + subgraph 誤りを伝える側 + E[エラー文言・警告・ログ・巻き戻しの説明
43 か所] + end + L --> DL + GS --> DL + BT --> DL + BM --> DL + MH --> DL + DL --> SG + DL --> DG + DL --> LB + E --> LB +``` + +`E` は `label()` を引数なしで呼ぶ。`DL` を経由しないことが F2 の実現方式である。 + +### 対象の数え方 + +`lib/` の `.label()` の出現は 52 件 / 10 ファイル(2026-09-22 に `grep -rn "\.label()" lib/` で実測。issue 本文と一致)。 +このうち 1 件(`lib/devbase/commands/env.py` の `_group_suffix` の docstring)は文字列の言及で、実行される呼び出しではない。 + +| 区分 | 件数 | 扱い | +| --- | ---: | --- | +| 見出し(読み替えのあるグループを持ちうる) | 5 | `display_label` へ寄せる | +| 見出し(参照が常にグループを持たない。`env encrypt` / `env decrypt` / `env rekey`) | 3 | 変えない | +| エラー文言・警告・ログ・巻き戻しの説明・使われていない委譲 | 43 | 変えない | +| docstring の言及 | 1 | 変えない | +| 合計 | 52 | | + +「変えない」は 47 件(3 + 43 + 1)で、内訳は上の図の `E`(43 件)、グループを持たない一覧(3 件)、docstring の言及(1 件)である。 + +## 配置 + +### システムの文脈 + +この変更が触る外部は無い。`devbase` はホストで動き、置き場のパス・サーバへの要求・キャッシュのファイルはいずれも変わらない。変わるのは端末の画面に出る文字列だけである。 + +```mermaid +graph LR + U[利用者の端末] --> D[devbase CLI] + D -->|変わらない| B[OpenBao サーバ] + D -->|変わらない| F[DEVBASE_ROOT のファイル] + D -->|変わる: 見出しの文字列| T[端末の画面] +``` + +### モジュールの置き場所 + +```text +lib/devbase/ +├── env/ +│ ├── secret_store.py # SecretRef.label に group_display / SecretStore.display_label を足す +│ └── backend_config.py # 変えない(display_group / storage_group をそのまま使う) +└── commands/ + ├── env.py # cmd_env_list の共通の節 / _group_suffix + └── env_backend.py # cmd_env_backend_test / cmd_env_backend_migrate の完了表示 / _MigrationPlan._heading +tests/ +├── env/test_secret_store_label.py # 新設。label の契約と display_label の分岐 +└── commands/test_env_group_label.py # 新設。読み替えのあるグループでのコマンドの出力 +docs/specifications/secret-backend.md # 2 か所の書き分けと list の見出しの例 +docs/user/env-backend.md # 見出しの例に読み替えのある場合を足す +docs/user/cli-reference/03-env.md # 同上 +CHANGELOG.md # Unreleased の Fixed +``` + +## 構造 + +```mermaid +classDiagram + class SecretRef { + +str kind + +Optional~str~ name + +str owner + +Optional~str~ group + +label(group_display) str + } + class SecretStore { + +config + +storage_group(group) Optional~str~ + +display_label(ref) str + } + class OpenBaoSettings { + +str layout + +Dict~str,str~ group_aliases + +storage_group(group) str + +display_group(group) str + } + class BackendConfig { + +str backend + +int version + } + SecretStore --> BackendConfig : config + BackendConfig --> OpenBaoSettings : openbao (Optional) + SecretStore ..> SecretRef : display_label(ref) + SecretRef <.. OpenBaoSettings : (依存しない) +``` + +`SecretRef` は `frozen=True` のままで、フィールドを足さない。`group` は等価性とキャッシュの鍵に入っているため、表示のための値をフィールドとして持たせない。 + +## 入出力の契約 + +### `SecretRef.label(*, group_display=None) -> str` + +| 引数 | 型 | 既定 | 意味 | +| --- | --- | --- | --- | +| `group_display` | `Optional[str]` | `None` | グループの括弧の中に入れる名前。`None` なら `self.group`(読み替える前の名前) | + +| 状況 | 返り値 | +| --- | --- | +| `self.group` が `None` | `グローバル` / `プロジェクト 'web'` / `個人のグローバル` / `個人のプロジェクト 'web'`(`group_display` を渡しても無視する) | +| `self.group` があり `group_display` が `None` | `グローバル(グループ default)` | +| `self.group` があり `group_display` が `default → nyle` | `グローバル(グループ default → nyle)` | + +**`group_display` は文字列で受け、`OpenBaoSettings` を受けない。** 参照が設定の型を知らない状態を保つためである。 + +### `SecretStore.display_label(ref) -> str` + +| 状況 | 返り値 | +| --- | --- | +| `storage_group(ref.group)` が `None`(`ref.group` が `None`、backend が `openbao` でない、設定が無い、`layout: flat`) | `ref.label()` と同じ文字列 | +| 読み替えが無い(`storage_group(ref.group) == ref.group`) | `ref.label()` と同じ文字列。`display_group` が `→` を付けないため | +| 読み替えがある | `ref.label(group_display='default → nyle')` | + +**判定を `storage_group` に任せるのは、`config.openbao` が `None` かどうかが backend の種類だけでは決まらないためである。** `backend: age` の設定に `openbao:` 節が残っていると、`config.openbao` は `None` にならない。判定しているのは `_openbao_from_dict`(`lib/devbase/env/backend_config.py`)である(2026-09-22 に確認)。`storage_group` は backend の種類・設定の有無・`layout` の 3 つをまとめて見る唯一の判定である。`ref_group` も同じものを使っている。 + +### 見出しの形 + +| コマンド | 行 | 読み替えが無いとき | 読み替えがあるとき | +| --- | --- | --- | --- | +| `env list` | 節の見出し | `=== グローバル(グループ kkg) (…) ===` | `=== グローバル(グループ default → nyle) (…) ===` | +| `env list` | 件数の行 | `グローバル(グループ kkg): 22変数` | `グローバル(グループ default → nyle): 22変数` | +| `env list` | プロジェクトの節 | `=== プロジェクト: web(グループ kkg) (…) ===` | `=== プロジェクト: web(グループ default → nyle) (…) ===` | +| `env backend test` | 参照ごとの行 | ` グローバル(グループ kkg) devbase/team/kkg/global 22 変数` | ` グローバル(グループ default → nyle) devbase/team/nyle/global 22 変数` | +| `env backend migrate` | 計画の一覧 | ` グローバル(グループ kkg) devbase/team/kkg/global 22 件: …` | ` グローバル(グループ default → nyle) devbase/team/nyle/global 22 件: …` | +| `env backend migrate --to age` | 完了後の一覧 | ` グローバル(グループ kkg) devbase/team/kkg/global` | ` グローバル(グループ default → nyle) devbase/team/nyle/global` | + +`version: 1` とファイル backend では、いずれの行にもグループが付かない(`ref.group` が `None`)。 + +### 失敗の形 + +`display_label` は例外を送出しない経路だけを通る。`storage_group` が `None` を返せば `label()` をそのまま返し、`None` 以外を返した時点でグループ名の検証は成功している。`display_group` はその直後に同じ `storage_group` を呼ぶため、新たに `BackendConfigError` になる場面が無い。終了コードを持たず、標準出力にも標準エラーにも自分では書かない。 + +## 処理の流れ + +```mermaid +sequenceDiagram + participant U as 利用者 + participant C as cmd_env_backend_test + participant S as SecretStore + participant O as OpenBaoSettings + participant R as SecretRef + + U->>C: devbase env backend test + C->>S: display_label(ref) + S->>O: storage_group(ref.group) + O-->>S: 'nyle'(読み替え後) + alt 読み替え後 ≠ 読み替え前 + S->>O: display_group(ref.group) + O-->>S: 'default → nyle' + S->>R: label(group_display='default → nyle') + else 同じ、または None + S->>R: label() + end + R-->>S: 'グローバル(グループ default → nyle)' + S-->>C: 同じ文字列 + C->>U: 見出しの行(隣に display_path) +``` + +エラー文言の経路はこの図に現れない。`label()` を引数なしで直接呼ぶためである。 + +## 非機能の実現方式 + +| 項目 | 条件 | 実現方式 | +| --- | --- | --- | +| 堅牢性 | 誤りを伝える文言の組み立てが、新たな例外を起こさない | `label()` を通して参照を出すエラー文言は、引数なしで呼ぶ。読み替えの解決(`storage_group` / `display_group`。名前の検証と予約語の検査で `BackendConfigError` を送出しうる)を新しく背負う文言を増やさない | +| セキュリティ | 見出しに機密の値が出ない | 変えるのはグループ名だけで、キーも値も触らない。グループ名は `backend.yml` の設定で、機密ではない | +| 互換性 | `version: 1` とファイル backend の出力が 1 文字も変わらない | `display_label` が `storage_group(ref.group) is None` で `ref.label()` を返す。`ref.group` はこれらの設定では常に `None`(`SecretStore.ref_group` の契約) | +| 可読性 | 一覧の桁が崩れない | 桁幅を変えない。読み替えの無い状態でも `プロジェクト 'carmo-ai'(グループ default)` は 31 文字で `:<28` を超えており(2026-09-22 に実測)、桁あふれはこの変更で始まるものではない | +| 性能 | 1 回の表示で増える処理 | `storage_group` は辞書の参照と正規表現の検証だけで、サーバへの要求もファイルの読み込みも増やさない。参照 1 件あたり最大 2 回呼ぶ | + +## 決定の記録 + +### 決定 1: グループの表示の責務を `label()` の契約へ寄せる(issue の `move_responsibility` を採る) + +`label()` の引数に `group_display` を足し、見出しの側は「括弧の中に入れる名前」だけを渡す。文言の組み立て(`(グループ …)` の括弧、`個人の` の接頭、チーム単位の文字列)は `label()` の中から出ない。`_group_suffix` が明記している従属関係(「文言は `SecretRef.label()` が持ち、ここでは写さずに差分だけを取り出す」)はそのまま保たれる。 + +見出しの側で `f'(グループ {display})'` を組み立てる案は採らない。同じ文言が 5 か所へ複製され、`label()` を直したときに見出しが追随しない。 + +### 決定 2: `label()` の既定の返り値は変えない + +読み替えの解決は `OpenBaoSettings.storage_group` を通り、グループ名の検証と予約語の検査で `BackendConfigError` を送出しうる。既定を読み替え後にすると、43 か所のエラー文言・警告・ログがこの解決に依存する。**誤りを伝える文言を組み立てる途中で新しい例外が起きる形**になり、元の失敗が利用者へ届かなくなる。この危険は仮想のものではない。`BackendConfigError` そのものの文言(`lib/devbase/env/backend_config.py` の `_group_of`)が `label()` を使っている。 + +`display_group` を直接呼んでいる文言(`lib/devbase/commands/env.py` の `_project_group_mismatch` など)は、この決定の対象ではない。設定が読める場所で組み立てており、今も読み替えの前後を出している。 + +既定を読み替え後にする案(52 か所すべてに及ぶ案)は採らない。上の理由に加えて、`SecretRef` が `frozen=True` の値であり、設定を持たないまま読み替えを解決する手段が無いためである。持たせるには全フィールドに設定への参照が要り、参照の等価性とキャッシュの鍵に影響する。 + +### 決定 3: 見出し用の表示を作る口を `SecretStore.display_label` の 1 つに置く + +見出しの側が `storage_group` と `display_group` と `label` を毎回組み合わせると、組み合わせ方の誤りが 5 か所で起こりうる。`config.openbao` が `None` の場合の見落としがこれにあたる。判定を 1 か所に閉じ、見出しの側は `store.display_label(ref)` だけを呼ぶ。 + +**`config.openbao` が `None` かどうかで backend の種類を判定しない。** `backend: age` に `openbao:` 節が残っていれば `None` にならないため、その判定は `backend: age` の端末をグループ別の置き場として扱いうる。受け入れ条件 6 のテストがこの取り違えをそのまま検査する。 + +`OpenBaoSettings` に口を置く案は採らない。`config.openbao` が `None` の場合と backend が `openbao` でない場合を、呼ぶ側が毎回確かめることになる。`SecretStore` は `storage_group` / `ref_group` / `same_storage_group` で既に同じ判定を引き受けている。 + +### 決定 4: `env backend migrate` の 2 つの一覧も直す + +`lib/devbase/commands/env_backend.py` には、参照の表示とサーバのパスを隣に並べる一覧がもう 2 つある。移行の計画の一覧と、`--to age` の完了後の「サーバ上の機密はそのまま残っています」の一覧である。直さなければ、同じ食い違い(`default` の隣に `nyle` のパス)がそこに残る。issue 本文はこの 2 か所を挙げていないため、受け入れ条件 3 を設計の時点で足した。 + +### 決定 5: `env encrypt` / `env decrypt` / `env rekey` の一覧は直さない + +これらが組む参照は `SecretRef.for_global()` / `SecretRef.for_project(name)` で、`group=` を渡さない。`ref.group` が常に `None` になる。参照を組んでいるのは `_select_refs`(`lib/devbase/commands/env_migrate.py`)と `_encrypted_refs`(`lib/devbase/commands/env_ops.py`)である(2026-09-22 に確認)。見出しにグループが出ないため、直しても出力が変わらない。 + +### 決定 6: 確定仕様は、どちらか一方を消さずに「文言の種類で分ける」形で解く + +`docs/specifications/secret-backend.md` の 2 つの記述は、どちらも正しい振る舞いを述べている。片方を消すと、残った側が対象外の文言まで巻き込む。 + +| 今の記述 | どう直すか | +| --- | --- | +| 「`label()` はグループがあれば `(グループ <名前>)` を後ろに付ける」 | 既定が読み替える前の名前であることを書く。`group_display` を渡すと前後になることを足す。エラー文言とログが既定を使う理由(読み替えの解決が失敗しうる)を添える | +| 「文言には読み替えの前と後を `default → nyle` の形で出す(`display_group`)」 | この規則を保ったまま、除くものを**引数なしの `SecretRef.label()` で参照を表示するエラー文言・ログ**に限ると書く。`display_group` を直接呼ぶ文言(`_project_group_mismatch` が組む `-p` の拒否の文言など)はエラーであっても前と後を出す | +| `list` の見出しの例(`with` の 1 つだけ) | 読み替えのある場合(`default → nyle`)を足す。`with` だけでは前後が一致して矛盾が表に出ない | + +### 決定 7: 一覧の桁幅を変えない + +`env backend test` の `:<28` は文字数で数え、全角文字の表示幅を数えない。読み替えの無い状態でも `プロジェクト 'carmo-ai'(グループ default)` は 31 文字で既に超えている。読み替えの表示で 7 文字増えるが、桁あふれの性質は変わらない。 + +幅を広げる案と、実際の最大長から幅を計算する案は採らない。どちらも読み替えの無いグループの出力を変え、受け入れ条件 5・6(変わらないこと)を崩す。桁揃えそのものの見直しは、この変更とは別の課題である。 + +## テスト設計 + +`DEVBASE_ROOT` は各テストが自分で差し替える既存の流儀に合わせる。使う道具と手本は次のとおりである。 + +| 何を | どこから | +| --- | --- | +| 設定の書き込み | `tests/conftest.py` の `configure_openbao`(`layout='group'`、`group_aliases={'default': 'nyle'}` を渡す) | +| `DEVBASE_ROOT` と作業ディレクトリ | `tests/conftest.py` の `openbao_root` fixture | +| 手本にする fixture | `tests/commands/test_env_user_axis.py` の `grouped`(すでに同じ読み替えを設定している) | + +**「変わらないこと」(受け入れ条件 5・6)は、既存テストだけでは確かめられない。** `env list` の見出しを固定している既存の assert は `'=== グローバル'` の前方一致で、見出しに `(グループ default → nyle)` が付いても通る(`tests/commands/test_env_user_axis.py:229-253`。2026-09-22 に確認)。読み替えの無い設定で`(グループ` が 1 つも出ないことを見る出力テストを新しく置く。 + +| 受け入れ条件 | 何で確かめるか | +| --- | --- | +| 1(`env backend test` の見出し) | `tests/commands/test_env_group_label.py` を新設。`layout='group'`・`group_aliases={'default': 'nyle'}` で `cmd_env_backend_test` を呼び、`capsys` の行に `グローバル(グループ default → nyle)` と `個人のグローバル(グループ default → nyle)` が出ることと、同じ行のパスが `…/team/nyle/global` であることを見る | +| 2(`env list` の見出し) | 同ファイル。`cmd_env_list` を `projects/web`(グループの宣言なし → `default`)で呼び、`=== グローバル(グループ default → nyle) (` と `=== プロジェクト: web(グループ default → nyle) (`、および件数の行 2 つを見る | +| 3(`env backend migrate` の 2 つの一覧) | 同ファイル。`cmd_env_backend_migrate(to='age', dry_run=True)` で**計画の一覧**の行に `グローバル(グループ default → nyle)` が出ることを見る。あわせて `dry_run=False`(偽サーバ)で `--to age` を通し、完了後の「サーバ上の機密はそのまま残っています」の一覧の行にも同じ見出しが出ることを見る。この 2 つは別の関数(`_MigrationPlan._heading` と `cmd_env_backend_migrate` の完了表示)が出すため、片方だけでは決定 4 を確かめられない | +| 4(読み替えの無いグループ) | 同ファイル。`projects/web` の `env` に `DEVBASE_ACCOUNT_GROUP=kkg` を書き、見出しが `(グループ kkg)` のままで `→` を含まないことを見る | +| 5(`version: 1`) | `tests/commands/test_env_group_label.py` に新規で 1 件。`configure_openbao(layout='flat')`(`version: 1`)で `cmd_env_list` と `cmd_env_backend_test` を呼び、**出力全体に `(グループ` が 1 つも出ない**ことを見る。既存の `tests/env/test_runtime.py`・`tests/env/test_groups.py` も変更なしで通す | +| 6(ファイル backend) | 同ファイルに新規で 1 件。`backend: age` に `openbao:` 節を残した設定で `cmd_env_list` を呼び、出力全体に `(グループ` が 1 つも出ないことを見る。既存の `tests/commands/test_env_user_axis.py` も変更なしで通す | +| 7(エラー文言) | `tests/env/test_secret_store_label.py` を新設。読み替えのあるグループの参照で `label()` を引数なしに呼ぶと `(グループ default)` になること、`label(group_display='default → nyle')` で前後が出ること、`group` が `None` の参照では `group_display` を渡しても無視されることを見る。あわせて、要求文書の受け入れ条件 7 が挙げる 2 つの文言に `→` が出ないことを見る。`layout: flat` でグループ付きの参照を拒む例外(`lib/devbase/env/openbao.py` の `OpenBaoBackend._check_group`)と、置き場の `DEVBASE_ACCOUNT_GROUP` を使わない旨の警告(`lib/devbase/env/runtime.py`)である | +| 8(`env backend status`) | 既存の `tests/commands/test_env_backend.py` を変更なしで通す | +| 9・10・11(文書) | 実装 Pull Request のレビューで読んで確かめる(自動の検査を置かない) | +| 12(退行) | `uv run pytest tests/ -q` の結果を実装 Pull Request の本文へ載せる | + +`display_label` の分岐(`storage_group` が `None` / 読み替えなし / 読み替えあり)は、受け入れ条件 1・4・5・6 のテストが 3 つとも通る。分岐だけの単体テストは `tests/env/test_secret_store_label.py` に 1 件置く。 + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 実機での見え方 | この端末の機密の置き場は本番の系のため、確認は読み取り(`env backend test` / `env list --keys-only` / `env backend status`)に限る。`env backend migrate` の一覧は `--dry-run` でも移行元の読み取りを伴うため、実機では確かめずテストだけで見る | +| CI | `release/v3.7.0` を base にした Pull Request では CI が 1 件も動かない(`.github/workflows/ci.yml` の対象が `main` だけ、#216)。手元の `uv run pytest tests/ -q` が唯一の証跡になる | +| `tests/conftest.py` の `DEVBASE_ROOT` の隔離 | #217(`release/v3.7.0` 未マージ)が入ると、新しいテストの `DEVBASE_ROOT` の扱いを揃え直す余地がある。この設計では既存の流儀のままにする | +| 桁揃えそのもの | 全角文字の表示幅を数えない桁揃えは、読み替えの有無に関わらず長い参照で崩れる。この変更では扱わない | diff --git a/issues/PLAN64_secret-label-group.md b/issues/PLAN64_secret-label-group.md new file mode 100644 index 00000000..11bf7e71 --- /dev/null +++ b/issues/PLAN64_secret-label-group.md @@ -0,0 +1,236 @@ +# PLAN64: 機密の参照の見出しに、グループの読み替えの前後を出す + +対象 issue: devbasex/devbase#188 + +- ワークフローモード: `standard` + - 根拠: 利用者が読む公開の出力の振る舞いを変える(`env backend test` と `env list` の見出し)。 + あわせて確定仕様 `docs/specifications/secret-backend.md` の自己矛盾を解く +- 参照: release Pull Request devbasex/devbase#212(`release/v3.7.0`) + +## 目的 + +`group_aliases` のある端末で、機密の参照の見出しに出るグループ名と、その隣に並ぶ置き場のパスが +同じグループを指していると読めるようにする。あわせて、確定仕様の「参照の表示」と「文言の表示」の +2 つの記述が食い違ったままにしない。 + +## 曖昧語の具体化 + +| 依頼文の語 | 具体化 | +| --- | --- | +| 「見出し」 | 利用者が正常系の一覧として読む行に限る。`env backend test` の参照ごとの行(`lib/devbase/commands/env_backend.py`)と、`env list` のチーム共通・個人共通・プロジェクトの節の `=== ... ===` と末尾の件数の行(`lib/devbase/commands/env.py`)。エラー文言・警告・ログは含まない | +| 「読み替えの前後を出す」 | `OpenBaoSettings.display_group(group)` の返り値をそのまま使う。読み替えがあれば `default → nyle`、無ければ `default`(`→` を付けない) | +| 「矛盾が解ける」 | 仕様書の 2 つの記述が、どの文言がどちらの形になるかを重ならない形で述べ、どちらを読んでも同じ結論になる。分かれ目は「引数なしの `SecretRef.label()` で参照を表示するか」で、`display_group` を直接呼ぶ文言はエラーであっても読み替えの前後を出す | +| 「変わらない」 | 出力の文字列がバイト単位で今と同じ | + +## 前提 + +- 前提 1: `version: 1` とファイル backend(`plaintext` / `age`)では `SecretRef.group` が常に `None` に + なる(`SecretStore.ref_group` の契約、確定仕様「参照のグループ」)。見出しにグループが付かない。 + したがって「見出しが変わらない」ことは、グループの有無で分岐する形にすれば構造として保てる +- 前提 2: **`config.openbao` が `None` かどうかで backend の種類を判定してはならない。** + `_openbao_from_dict`(`lib/devbase/env/backend_config.py`)は `backend: age` でも `openbao:` 節が + 残っていれば設定を返す。ファイル backend で `None` になるのは、節が無く `version: 1` のときだけである + (2026-09-22 に確認)。表示の分岐は `SecretStore.storage_group(ref.group)` に任せる。この 1 つが + backend の種類・設定の有無・`layout`・グループの有無をまとめて見て、当たらなければ `None` を返す +- 前提 3: `OpenBaoSettings.display_group` は `storage_group` を経由する。名前の検証と予約語の検査で + `BackendConfigError` を送出しうる。**誤りを伝える文言の組み立ての中では呼ばない。** 見出しは + 対応するパスの解決が成功した後に出る(`path_of` も `storage_group` を通る)。そのため + 見出しの経路で新たに失敗する場面は生じない +- 前提 4: 一覧の桁揃え(`env backend test` の `{...:<28}`)は文字数で数えており、全角文字の表示幅を + 数えない。読み替えの無い状態でも `プロジェクト 'carmo-ai'(グループ default)` は 31 文字で 28 を + 超えている(2026-09-22 に実測)。桁あふれは本 issue の変更で始まるものではない +- 前提 5: この端末の機密の置き場は本番の系である。実機での確認は読み取りに限る + (`env backend status` / `env backend test` / `env list --keys-only`)。`env set` / `migrate` / + backend の切り替えは行わない + +## 対象範囲 + +含む: + +- `SecretRef.label()` の契約(グループの表示をどう決めるか) +- `env backend test` の参照ごとの行の見出し +- `env backend migrate` の移行の計画の一覧と、`--to age` の完了後の「サーバ上の機密はそのまま残っています」の一覧 +- `env list` のチーム共通・個人共通・プロジェクトの節の見出しと末尾の件数の行 +- `docs/specifications/secret-backend.md` の 2 つの記述の書き分けと、`list` の見出しの例 +- 読み替えのあるグループを使う新しいテスト +- 利用者向け文書の見出しの例(`docs/user/env-backend.md`、`docs/user/cli-reference/03-env.md`)と CHANGELOG + +含まない: + +- エラー文言・警告・ログに出る `label()` の表示(前提 3 の理由で読み替え前の名前のまま) +- `env backend status` の表示(すでに `display_group` を使っており、変えない) +- `env encrypt` / `env decrypt` の一覧(`lib/devbase/commands/env_migrate.py`)と `env rekey` の一覧 + (`lib/devbase/commands/env_ops.py`)。参照を `group=` を渡さずに組んでいるため、`ref.group` が + 常に `None` になり、グループが見出しに出ない(`env_migrate.py:85-94`、`env_ops.py:56-63`。 + 2026-09-22 に確認) +- すでに `OpenBaoSettings.display_group` を直接呼んでいる文言。読み替えの前後を今も出しており、 + 変えない。`bundle.py` / `io_import.py` / `env_backend.py` の「別の置き場のプロジェクト」の列挙と、 + `lib/devbase/commands/env.py` の `_project_group_mismatch`(`--group` がプロジェクトのグループと + 違う置き場である旨の文言)がこれにあたる +- 一覧の桁幅(`{...:<28}` / `{...:<40}`)の変更(前提 4) +- `group_aliases` の設定方法・置き場のパスの組み立て・キャッシュの位置 +- `DEVBASE_ACCOUNT_GROUP` の警告文に付く ` --group <読み替える前の名前>` の引数 + (引数は読み替える前の名前でなければ通らないため、変えない) +- `tests/conftest.py` による `DEVBASE_ROOT` の隔離(別の Pull Request #217 が扱う) +- `lib/devbase/commands/env_migrate.py` の使われていない `Target.label`(#222 として起票。出力が変わらない) +- 新しい永続データ・画面の追加(そのため ER 図・テーブル定義・CRUD 図・画面遷移図を作らない) + +## 受け入れ条件 + +見出しの表示(#188): + +- [ ] 1. 前提: `backend: openbao` / `version: 2` / `group_aliases: {default: nyle}`、対象のグループが `default` + 操作: `devbase env backend test` を実行する + 結果: チーム共通の行の見出しが `グローバル(グループ default → nyle)` になる。個人共通は + `個人のグローバル(グループ default → nyle)`。隣に並ぶパスは今と同じ(`…/team/nyle/global`) + 検証: 新規テスト(`env backend test` の出力の行を読む) +- [ ] 2. 前提: 1 と同じ設定で、プロジェクト `web` のグループが `default` + 操作: `devbase env list` を `projects/web` で実行する + 結果: `=== グローバル(グループ default → nyle) (...) ===` と + `=== プロジェクト: web(グループ default → nyle) (...) ===` になり、末尾の件数の行 + (`グローバル(グループ default → nyle): N変数` / `プロジェクト(グループ default → nyle): N変数`)も同じ形になる + 検証: 新規テスト(`capsys` の出力を読む) +- [ ] 3. 前提: 1 と同じ設定 + 操作: `devbase env backend migrate --to age --dry-run` と、`--dry-run` なしの `--to age` を実行する + 結果: 移行の計画の一覧の見出しと、完了後の「サーバ上の機密はそのまま残っています」の一覧の見出しが、 + どちらも `グローバル(グループ default → nyle)` になる。この 2 つは別の関数 + (`_MigrationPlan._heading` と `cmd_env_backend_migrate` の完了表示)が出すため、両方を見る + 検証: 新規テスト 2 件 + (2026-09-22 追加。issue 本文は `test` と `list` だけを挙げるが、同じ一覧の形で + `label()` とサーバのパスを並べる箇所が `lib/devbase/commands/env_backend.py` に 2 つあり、 + 直さないと同じ食い違いが残るため) +- [ ] 4. 前提: 1 と同じ設定で、対象のグループが `kkg`(`group_aliases` に対応が無い) + 操作: `devbase env backend test` / `devbase env list` を実行する + 結果: 見出しは `グローバル(グループ kkg)` のまま。`→` は出ない + 検証: 新規テスト + +変わらないこと: + +- [ ] 5. `version: 1`(`layout: flat`)の `openbao` backend で、`env backend test` と `env list` の見出しに + グループが付かない(今と同じ文字列) + 検証: 新規テスト。`env list` の見出しが `=== グローバル (` で始まり、出力全体に `(グループ` が + 1 つも出ないことを見る。`env backend test` の参照ごとの行も同じ。あわせて既存テスト + (`tests/env/test_groups.py`・`tests/env/test_runtime.py`)が変更なしで通ること + (2026-09-22 変更。既存テストは `'=== グローバル'` の前方一致で見ており、見出しに + `(グループ default → nyle)` が付いても通ってしまうため、これだけでは条件を確かめられない) +- [ ] 6. ファイル backend(`plaintext` / `age`)の `env list` の見出しが今と同じ文字列 + 検証: 新規テスト。`openbao:` 節を残した `backend: age` の設定でも、見出しに `(グループ` が + 出ないことを見る(前提 2 の取り違えをそのまま検査する)。あわせて既存テスト + (`tests/commands/test_env_user_axis.py`)が変更なしで通ること + (2026-09-22 変更。理由は条件 5 と同じ) +- [ ] 7. `SecretRef.label()` を通して参照を出すエラー文言・警告・ログは、読み替えのあるグループでも + 読み替える**前**の名前のままである。例は `layout: flat` でグループ付きの参照を拒む例外 + (`lib/devbase/env/openbao.py` の `OpenBaoBackend._check_group`)と、置き場の + `DEVBASE_ACCOUNT_GROUP` を使わない旨の警告(`lib/devbase/env/runtime.py`)である + 検証: 新規テスト 1 件(読み替えのあるグループで、この 2 つの文言に `→` が出ないこと) +- [ ] 8. `env backend status` の `グループ: default → nyle` が今と同じ + 検証: 既存テストが変更なしで通ること + +仕様書(#188): + +- [ ] 9. `docs/specifications/secret-backend.md` の「`label()` はグループがあれば `(グループ <名前>)` を + 後ろに付ける」と「文言には読み替えの前と後を `default → nyle` の形で出す(`display_group`)」が、 + どの文言がどちらになるかを重ならない形で述べている + 検証: 設計 Pull Request と実装 Pull Request のレビュー(読んで確かめる) +- [ ] 10. 同文書の `list` の見出しの例に、読み替えのある場合(`=== グローバル(グループ default → nyle) (...) ===`)が + 加わっている + 検証: 同上 +- [ ] 11. 利用者向け文書(`docs/user/env-backend.md`、`docs/user/cli-reference/03-env.md`)の見出しの例に + 読み替えのある場合が加わっている + 検証: 同上 + +退行しないこと: + +- [ ] 12. 全体テスト(`uv run pytest tests/ -q`)が通る + 検証: 実装 Pull Request の本文に実行結果を載せる(`release/v3.7.0` を base にした Pull Request では + CI が 1 件も動かないため、手元の実行が唯一の証跡になる。#216) + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わる: `group_aliases` のあるグループでのみ、`env backend test` / `env list` / `env backend migrate`(計画の一覧と `--to age` の完了表示)の見出しのグループ名が `default` から `default → nyle` になる。CHANGELOG では Fixed に書く | +| データ | 変わらない。置き場のパス・キャッシュの位置・`backend.yml` の内容は同じ | +| 既存の振る舞い | `SecretRef.label()` の既定の返り値は変えない。エラー文言・警告・ログは今のまま | +| 確定仕様 | `docs/specifications/secret-backend.md` の 2 か所の記述を書き分け、例を 1 つ足す | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run pytest tests/ -q`(`release/v3.7.0` を base にする Pull Request では CI が動かないため手元で行い、結果を Pull Request 本文へ載せる) | +| 静的解析 | `ruff check --select=E9,F63,F7,F82 lib` | +| 手動確認 | この端末(`group_aliases: {default: nyle}`)で `devbase env backend test` と `devbase env list --keys-only` を打ち、見出しとパスが同じグループを指すことを見る。**読み取りのみ**(前提 5) | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | 参照の値と表示の文言は `lib/devbase/env/secret_store.py`、グループの読み替えの規則は `lib/devbase/env/backend_config.py`、コマンドの出力は `lib/devbase/commands/`。文言の組み立てはコマンド側へ写さない | +| コーディング規約 | 既存に合わせる(`ruff`)。`SecretRef` は `frozen=True` のまま | +| テスト戦略 | 表示の契約は単体テスト、コマンドの出力は `capsys` を使うコマンドのテスト。`DEVBASE_ROOT` は各テストが `monkeypatch.setenv` で自分で差し替える既存の流儀に合わせる(`tests/conftest.py` の autouse fixture による隔離は #217 が扱い、まだ `release/v3.7.0` に入っていない) | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 手元で全体テスト、`ruff` | +| 確認してから行う | `SecretRef.label()` の引数の追加(設計 Pull Request の承認で確かめる)、確定仕様の書き分け | +| 行わない | エラー文言・ログの表示の変更、桁幅の変更、実環境への書き込み | + +## 実装計画 + +設計は [PLAN64_secret-label-group-design.md](PLAN64_secret-label-group-design.md)。 + +## 依頼(原文) + +devbasex/devbase#188 より。 + +> ## 何が起きたか +> +> `version: 2`(`group_aliases: {default: nyle}`)の端末で `devbase env backend test` を打つと、見出しが読み替え前の名前だけになる。 +> +> ```text +> グローバル(グループ default) devbase/team/nyle/global 22 変数 +> ``` +> +> `env backend status` は `グループ: default → nyle` と両方を出すのに、`SecretRef.label()` を使う見出し(`env backend test`・`env list`)は `default` だけで、隣のパス(`nyle`)と食い違って見える。 +> +> 現象は v3.6.0 でも再現する。`SecretRef.label()`(`lib/devbase/env/secret_store.py:151`)は `self.group` をそのまま埋め込み、`storage_group` も `display_group` も呼ばない。 +> +> ## 仕様書が自己矛盾している +> +> `docs/specifications/secret-backend.md` に、相反する 2 つの記述がある。 +> +> | 箇所 | 記述 | +> | --- | --- | +> | 「`label()` はグループがあれば `(グループ <名前>)` を後ろに付ける」 | 読み替え前を出す | +> | 「文言には読み替えの前と後を `default → nyle` の形で出す(`display_group`)」 | 読み替え後も出す | +> +> `list` の見出しの例は `=== グローバル(グループ with) (...) ===` で固定されているが、`with` は読み替えの無いグループなので、この例では前後が一致してしまい矛盾が表に出ない。**コードを直す前に、どちらを採るかを仕様の側で決める必要がある。** +> +> ## 修正レイヤー +> +> **現象レイヤー**: `env backend test` の見出し(`lib/devbase/commands/env_backend.py:472`)と `env list` の見出し(`lib/devbase/commands/env.py` の `cmd_env_list` / `_group_suffix`)。 +> +> **修正レイヤー**: `SecretRef.label()`(`lib/devbase/env/secret_store.py:151`)が返すグループの表示。呼び出される側の契約であり、`env list` の `_group_suffix` は「文言は `SecretRef.label()` が持ち、ここでは写さずに差分だけを取り出す」と明記して label() に従っている。見出しを 1 か所ずつ直すと、この従属関係が崩れる。 +> +> `label()` は `SecretRef` にあり `OpenBaoSettings` を知らないため、読み替えの前後を出すには表示の側へ設定を渡す必要がある。`label()` のコメントは「チーム単位の文字列は変えない(誤りの伝達や桁揃えに埋め込まれている)」と断っているので、`label()` にグループの表示だけを差し替える引数を足すか、表示用のラッパを 1 つ置くかを設計で決める。 +> +> **採る手**: 移動(`move_responsibility`)。グループの表示の責務を、呼び出し側から `label()` の契約へ寄せる。 +> +> ### 波及の範囲 +> +> `label()` は `lib/` の中で **52 か所 / 10 ファイル**から呼ばれる。大半はエラー文言とログである。 +> +> **既定の振る舞いを変えると 52 か所すべてに及ぶ。** 見出しだけを変えるなら、差し替えるのは `commands/env_backend.py:472` と `commands/env.py` の `_group_suffix` の 2 か所で足りる。どちらにするかが設計の分かれ目である。 +> +> ## 期待すること +> +> 見出しのグループ名も `status` と同じく読み替えの前後を出す(`OpenBaoSettings.display_group` を使う)。`version: 1` とファイル backend の見出しは変えない。 +> +> ## 受け入れ条件 +> +> - `group_aliases` のあるグループで `env backend test` と `env list` の見出しが読み替えの前後を出す +> - `docs/specifications/secret-backend.md` の 2 つの記述の矛盾が解け、`list` の見出しの例に読み替えのある場合(`default → nyle`)が加わる +> - `version: 1` とファイル backend の見出しが変わらない From c3c0d3b0a69bf9dd76f804980399d1e14afb6af2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Tue, 22 Sep 2026 21:19:58 +0900 Subject: [PATCH 04/15] =?UTF-8?q?=E8=A8=AD=E8=A8=88(PLAN65):=20devbase=20s?= =?UTF-8?q?cale=20=E3=81=AE=20Compose=20=E5=91=BC=E3=81=B3=E5=87=BA?= =?UTF-8?q?=E3=81=97=E3=82=92=E5=85=B1=E9=80=9A=E7=B5=8C=E8=B7=AF=E3=81=B8?= =?UTF-8?q?=E5=AF=84=E3=81=9B=E3=80=81cmd=5Fscale=20=E3=81=AE=E6=AE=B5?= =?UTF-8?q?=E9=9A=8E=E3=82=92=E5=88=86=E3=81=91=E3=82=8B=20(#192)=20(#225)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(PLAN65): devbase scale の Compose 呼び出しを共通経路へ寄せる要求仕様と設計 (#192) cmd_scale が docker compose を共通経路 (utils/docker.py の docker_compose) を 通さずに呼ぶため、compose_env() が適用されない。確定仕様 docs/specifications/compose-profiles.md は「COMPOSE_PROFILES を端末や .env に 置いても devbase 経由の操作には効かない」と約束する一方で、同じ文書の中で cmd_scale をその対象から外している。 この食い違いを、約束の側を正として解く設計を置く。実測で cmd_login にも同じ穴が あることが分かったため、そちらも同じ Pull Request で塞ぐ決定にした。 実装は 2 本の Pull Request に分ける (振る舞いと仕様 / 構造)。順序は 仕様 → 共通経路へ寄せる → 現状固定テストを足す → 関数を分ける。 Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN65): 決定 10 を決定の記録の一覧へ載せる (#192) Pull Request 本文の「決めたこと」の節は設計文書の `## 決定の記録` の `###` 見出しから 作られるため、章へ切り出した決定 10 が一覧から漏れていた。章を指す見出しを置く。 Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN65): config 読み取りの統合を 1 本目の Pull Request へ移す (#192) 確定仕様の経路の表は _resolve_dev_service / _read_compose_services の 2 行を docker_compose の用途へ畳む。畳んだ表を 1 本目で入れて統合を 2 本目に置くと、 1 本目のマージの時点で「共通経路を通る」と書いた仕様と、直接 subprocess.run を 呼ぶ実装が食い違った版が残る。受け入れ条件 A-1(grep が 6 → 3)・A-5(棚卸しの 一致)・D-1 も 1 本目では満たせない。 分け目を「Compose の呼び出しを共通経路へ寄せる」と「cmd_scale の段階を分ける」に 改め、受け入れ条件とどちらの Pull Request が対応するかの表を足した。 Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- issues/PLAN65_scale-compose-path-design.md | 498 +++++++++++++++++++++ issues/PLAN65_scale-compose-path.md | 282 ++++++++++++ 2 files changed, 780 insertions(+) create mode 100644 issues/PLAN65_scale-compose-path-design.md create mode 100644 issues/PLAN65_scale-compose-path.md diff --git a/issues/PLAN65_scale-compose-path-design.md b/issues/PLAN65_scale-compose-path-design.md new file mode 100644 index 00000000..c380ea22 --- /dev/null +++ b/issues/PLAN65_scale-compose-path-design.md @@ -0,0 +1,498 @@ +# PLAN65: `devbase scale` の Compose 呼び出しを共通経路へ寄せ、`cmd_scale` の段階を分ける の設計 + +要求と受け入れ条件は `issues/PLAN65_scale-compose-path.md` にある。この文書は「どう作るか」 +だけを扱う。 + +対象 issue: devbasex/devbase#192 / base は `release/v3.7.0`(release Pull Request は #212) + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| 1 | `devbase scale` の起動が共通経路(`docker_compose`)を通り、子プロセスの `COMPOSE_PROFILES` が打ち消される | devbase の利用者 | +| 2 | `devbase scale` の起動の対象が既定のサービスに限られる | devbase の利用者 | +| 3 | `devbase login` の `exec` も共通経路の規則に従う | devbase の利用者 | +| 4 | `cmd_scale` の段階に名前が付き、`cmd_up` と同じ形で読める | devbase を保守する側 | +| 5 | `docker compose config --format json` を起動する箇所が 1 つになる | devbase を保守する側 | +| 6 | `devbase scale` の正常系の手順がテストで固定される | devbase を保守する側 | +| 7 | 確定仕様の「devbase 経由の操作には効かない」が例外を持たない | 仕様を読む側 | + +## 構成要素 + +| 要素 | 責務 | 変更 | +| --- | --- | --- | +| `docker_compose`(`utils/docker.py`) | `docker compose` を `env=compose_env()` で起動する唯一の共通経路 | 変えない(呼び出し元が増えるだけ) | +| `compose_env`(`utils/docker.py`) | 子プロセスの `COMPOSE_PROFILES` を `__devbase_none__` にする | 変えない | +| `default_services`(`commands/container.py`) | 生成物から `profiles:` を持たないサービス名を求める | 変えない(`cmd_scale` から呼ぶようになる) | +| `_compose_lines`(`commands/container.py`) | `config --services` / `--profiles` を読む。非 0 は `DevbaseError` | 変えない(`default_services` の下位) | +| `_compose_run`(`commands/container.py`) | `devbase ps` / `devbase logs` の起動 | 変えない(確定仕様の経路の表に並ぶため図に載せる) | +| `_ensure_images`(`commands/container.py`) | 起動前のイメージの確認 | 呼ぶ関数の名前だけ変わる(`_read_compose_services` → `_compose_config_services`) | +| `cmd_scale`(`commands/container.py`) | 前提の検査・段階の呼び出し・後処理・終了コードの決定 | **本体を 86 行から 40 行以下へ縮める** | +| `_check_scale_request`(新設) | `new_scale` の妥当性(1 未満・現在以下)の判定と案内のログ | **新設** | +| `_run_scale_pipeline`(新設) | `[1/5]`〜`[5/5]`。`project.yml` の書き換え・ボリューム・network・構成生成・既定のサービスの解決・`--no-recreate` の起動・ready 待ち | **新設**(`cmd_up` の `_run_deploy_pipeline` と対称) | +| `_compose_config_services`(新設) | `config --format json` の(終了コード, `services`)を返す唯一の関数 | **新設。`_read_compose_services` を置き換える** | +| `_resolve_dev_service` | dev サービス定義を返す。失敗は `None` | **本文を `_compose_config_services` の上へ載せ替える。名前と契約は変えない** | +| `cmd_login` | `docker compose exec - bash` | **`env=compose_env()` を渡す(1 行)** | +| `docs/specifications/compose-profiles.md` | 経路・コマンド列・運用・テスト観点の確定仕様 | **`scale` と `login` を対象に含める形へ書き換える** | +| `tests/commands/test_container_scale_order.py`(新設) | `devbase scale` の正常系の手順と子プロセスの環境の固定 | **新設** | +| `tests/utils/test_docker_profiles.py` | 経路ごとに `COMPOSE_PROFILES` の打ち消しを固定する | **棚卸しの一覧とコメントを更新する** | + +## 経路の図 + +図は実行時の呼び出しだけを描く。**上の表の要素のうち 4 つは図に現れない。** + +| 図に現れない要素 | 理由 | +| --- | --- | +| `_compose_run` | この束では触らず、確定仕様の経路の表に並ぶだけである | +| 確定仕様 1 本とテスト 2 本 | 実行時の呼び出しを持たない | + +`devbase scale` の経路: + +```mermaid +graph TD + SCALE[cmd_scale] --> CSR[_check_scale_request] + SCALE --> RSP[_run_scale_pipeline] + RSP --> DS[default_services] + RSP --> DC[docker_compose] + DS --> CL[_compose_lines] + CL --> CE[compose_env] + DC --> CE + CE --> COMPOSE[(docker compose)] +``` + +`config --format json` の読み取りと `devbase login` の経路: + +```mermaid +graph TD + RDS[_resolve_dev_service] --> CCS[_compose_config_services] + EI[_ensure_images] --> CCS + CCS --> DC2[docker_compose] + LOGIN[cmd_login] --> CE2[compose_env] + DC2 --> CE2 + CE2 --> COMPOSE2[(docker compose)] +``` + +## 構造 + +処理の順序を変える(1 つの関数の通しの流れを 2 つの関数へ分ける)ため、変更後の呼び出しの +並びを示す。型(クラス)は追加しない。モジュール関数の並びで構成する。 + +### `cmd_up` と `cmd_scale` の段階の対応(変更後) + +| 段階 | `cmd_up` 側 | 段階 | `cmd_scale` 側 | +| --- | --- | --- | --- | +| 前提の検査 | `_run_pre_up_checks`(group / env / pre-up / images) | 前提の検査 | `_check_group_consistency` と `_check_scale_request`(**新設**) | +| — | (`cmd_up` 本体で `_auto_snapshot`) | — | 行わない(`scale` は退避を取らない) | +| — | — | `[1/5]` | `project_runtime.write_scale` | +| `[1/6]` | `ensure_volumes` | `[2/5]` | `ensure_volumes` | +| `[1.5/6]` | `ensure_network` | `[2.5/5]` | `ensure_network` | +| `[2/6]` | `_build_scaled_override`(`_previous_scale_compose` の中) | `[3/5]` | `_build_scaled_override`(退避は取らない) | +| (2 と 3 の間) | `default_services(override_file)` | (3 と 4 の間) | `default_services(override_file)`(**新設**) | +| `[3/6]` | `docker_compose_down` | — | 行わない(既存を止めないのが `scale` の趣旨) | +| `[4/6]` | `docker_compose_up(services=...)` | `[4/5]` | `docker_compose(['up', '-d', '--no-recreate', *services])`(**変更**) | +| `[5/6]` | `wait_for_containers_ready` | `[5/5]` | `wait_for_containers_ready` | +| 後処理 | `_report_missing_repos` → `./deploy` → `_push_bao_token` → `_apply_window_titles` → `_maybe_open_editor` | 後処理 | `_push_bao_token(start=current+1)` → `./deploy`(範囲は `current+1..new`)。変えない | + +`[1/5]`〜`[5/5]` と `[2.5/5]` の文字列は変えない(決定 7)。後処理の順序(bao → deploy)も +`cmd_up` と逆のまま変えない。`cmd_scale` が `_report_missing_repos` / `_apply_window_titles` / +`_maybe_open_editor` を呼ばないことも変えない(範囲外。#224 として起票済み)。 + +## 入出力の契約: 組み立てるコマンド列 + +### `devbase scale` が組み立てるコマンド列 + +変更前: + +``` +docker compose -f <生成物> up -d --no-recreate +env: 呼び出し元の os.environ そのまま(COMPOSE_PROFILES は利用者と .env の値) +``` + +変更後: + +``` +docker compose -f <生成物> up -d --no-recreate <既定のサービス...> +env: compose_env()(COMPOSE_PROFILES=__devbase_none__、ほかは os.environ の複製) +``` + +`<生成物>` は `_build_scaled_override` が返したパス。`<既定のサービス...>` は +`default_services(<生成物>)` が返した一覧の全件で、並びは変えない。`--profile` は付けない。 + +### `devbase login` が組み立てるコマンド列 + +コマンド列は変えない。`env` だけを `compose_env()` にする。 + +``` +docker compose [-f <生成物>] exec <開発サービス名>- bash # 生成物がある場合 +docker compose exec --index= <開発サービス名> bash # 生成物が無い場合 +``` + +## 入出力の契約: 新設・変更する関数のシグネチャ + +```python +def _check_scale_request(new_scale: int, current_scale: int) -> bool: + """``new_scale`` を受け付けるかを判定し、受け付けないときは案内を出す。""" + + +def _run_scale_pipeline(project_name: str, new_scale: int, current_scale: int, + config, target: docker_context.DockerTarget, + dev_service_name: str) -> Optional[Path]: + """``[1/5]``〜``[5/5]`` の本体。生成した override compose のパスを返す。""" + + +def _compose_config_services() -> tuple[int, dict]: + """``docker compose config --format json`` の (終了コード, services) を返す。""" + + +def _resolve_dev_service() -> Optional[dict]: + """compose config から dev サービス定義を取得する。失敗時は None。""" +``` + +| 関数 | 契約 | +| --- | --- | +| `_check_scale_request` | `new_scale < 1` と `new_scale <= current_scale` の 2 つで `False` を返す。ログの文言と出し分け(error / warning + info 2 行)は変更前のまま | +| `_run_scale_pipeline` | 起動が 0 以外で終わったときだけ `None` を返す(`Failed to start new containers` はこの関数が出す)。それ以外の失敗は `DevbaseError` / `DockerError` のまま伝播する | +| `_compose_config_services` | 非 0 なら `services` は空の辞書。JSON として読めなければ `json.JSONDecodeError` を伝播する。`_read_compose_services` の契約と同じで、実行は `docker_compose` を通る | +| `_resolve_dev_service` | 名前・引数・戻り値の契約は変えない。終了コードが非 0 でも、JSON として読めなくても `None` を返す | + +`_read_compose_services` は削除する。唯一の呼び出し元 `_ensure_images`(`container.py:2019`)は +`_compose_config_services` を呼ぶ。`_resolve_dev_service` の名前を残すのは、 +`tests/cli/test_base_image_staleness.py` の 3 か所がこの名前を差し替えているためである。 + +## 確定仕様 `docs/specifications/compose-profiles.md` の書き換え + +| 節(変更前の行) | 何をするか | +| --- | --- | +| 構成要素の表(38-40 付近の `devbase up` の起動の行) | `devbase scale` の起動も `docker_compose` を通ることを書く | +| 有効なプロファイルの決め方・経路の表(74-81) | 行を 5 つにする。`docker_compose` の用途へ `scale` の `up -d --no-recreate` を足し、`_resolve_dev_service` / `_read_compose_services` の 2 行を `docker_compose` の用途へ畳み、`cmd_login` の `exec` の行を足す | +| 同じ節(83-84) | 「`cmd_scale` が直接呼ぶ …… この対象に含めない」を削り、「devbase が Compose を起動する経路はこの表の 5 つだけである」に置き換える | +| 組み立てるコマンド列の表(107-120) | `devbase scale` の起動の行(`up -d --no-recreate <既定のサービス...>`)と `devbase login` の行(`exec <開発サービス名>- bash`)を足す | +| `devbase up` と `devbase down` の節(122 以降) | `devbase scale` の段落を足す。生成物を作った直後に `default_services` を求め、停止の段を持たないこと、既に動いているプロファイルのサービスを止めないことを書く | +| 運用(386-389) | 「`devbase scale` はプロファイルのサービスを複製しない」に「起動の対象にも入れない。既に動いているプロファイルのサービスは止めない」を足す。388-389 の約束はそのまま残す(成り立つようになる) | +| テスト観点(441-445 付近) | `devbase scale` の行を足す(コマンド列・子プロセスの環境・手順の順序) | + +`docs/plugin-dev/compose-profiles.md:82` は変えない。共通経路へ寄せれば約束が成り立つため +である。 + +## 処理の流れ + +```mermaid +graph TD + A[devbase scale N] --> B{_check_group_consistency} + B -->|不一致| Z1[1 を返す] + B -->|一致| C{_check_scale_request} + C -->|受け付けない| Z2[1 を返す] + C -->|受け付ける| D["[1/5] write_scale"] + D --> E["[2/5] ensure_volumes
[2.5/5] ensure_network"] + E --> F["[3/5] _build_scaled_override"] + F --> G["default_services(生成物)"] + G --> H["[4/5] docker_compose
up -d --no-recreate 既定のサービス..."] + H -->|非 0| Z3["Failed to start new containers
1 を返す"] + H -->|0| I["[5/5] wait_for_containers_ready"] + I --> J["_push_bao_token(start=current+1)"] + J --> K["./deploy (current+1..N)"] + K --> L[0 を返す] +``` + +`[1/5]` から `[5/5]` までが `_run_scale_pipeline` の中にある。`_push_bao_token` 以降は +`cmd_scale` の本体に残る(決定 8)。 + +## 失敗の経路 + +失敗の経路は 4 つで、いずれも終了コード 1 になる。 + +| 失敗 | どこで | 見え方 | +| --- | --- | --- | +| グループの不一致 | `_check_group_consistency` | 変更前のまま。`project.yml` は書き換えない | +| `new_scale` が不適 | `_check_scale_request` | 変更前のまま。`project.yml` は書き換えない | +| 構成生成・既定のサービスの解決・ready 待ちの失敗 | `_run_scale_pipeline` の中で `DevbaseError` / `DockerError` | `cmd_scale` の `except DevbaseError` が `Scale failed: ...` を出す。`project.yml` は**既に書き換わっている**(変更前も同じ) | +| 起動が 0 以外 | `_run_scale_pipeline` が `None` を返す | `Failed to start new containers` を出して 1。変更前のまま | + +**既定のサービスの解決を足すと、失敗の経路が 1 つ増える。** `default_services` は +`_compose_lines` を通り、非 0 の終了を `DevbaseError` にする。`cmd_up` が同じ解決を既に +行っているため、`up` が通るプロジェクトでこの解決だけが失敗する経路は無い。 + +## 決定の記録 + +### 決定 1: `devbase scale` の Compose 呼び出しを共通経路へ寄せる(issue #192 の案 A) + +`docs/specifications/compose-profiles.md` の 2 つの記述のうち、**388-389 行目の約束を正とする。** +その約束は「`COMPOSE_PROFILES` を端末や `.env` に置いても devbase 経由の操作には効かない」で +ある。**83-84 行目の `cmd_scale` の除外は削る。** + +理由: + +- **約束の側が、より外に向いている。** 388-389 は「運用」の節にあり、利用者が読んで自分の + 端末と `.env` の扱いを決めるための文である。同じ約束は利用者向けの + `docs/plugin-dev/compose-profiles.md:82` にもある。83-84 は「有効なプロファイルの決め方」の + 節にある実装の内訳で、読者は devbase を保守する側である。**外向きの約束に例外を足すほうが、 + 内向きの内訳を 1 行足すより高くつく** +- **除外の理由が成り立っていない。** 83-84 は「プロファイルの入口ではないためである」と書くが、 + 打ち消しが要るのは入口だからではなく、**子プロセスへ利用者の値がそのまま渡るから**である。 + `cmd_scale` はサービスの指定も持たないため、プロファイルのサービスが起動の対象に入りうる。 + 同じ理由で `_resolve_dev_service` / `_read_compose_services`(読み取りだけで、入口ではない)も + 対象に入っている。除外の基準は既に一貫していない +- **仕様へ例外を書く形は、3 か所へ例外を足すことになる。** 確定仕様の 2 か所(経路の表・運用)と利用者向けの文書 1 か所に + 「ただし `devbase scale` は除く」を足すことになる。`devbase up` と `devbase scale` で + `COMPOSE_PROFILES` の効き方が違う状態を、覚える対象として利用者へ渡す +- **仕様へ例外を書く形では、構造の側の目的も達しない。** #192 は「関数を分割しても共通経路を + 通さなければ、同じ食い違いが残る」と書いている。この形を採ると `cmd_scale` だけが自前で + `subprocess.run` を + 持つ形が残り、`up` の起動と `scale` の起動が別の規則で動く状態が確定仕様として固定される + +採らなかった案: + +| 採らなかった形 | 内容 | 退けた理由 | +| --- | --- | --- | +| 仕様へ例外を書く(issue #192 の案 B) | 「devbase 経由の操作には効かない」を `scale` 除外込みへ書き直す | 上の 4 点。とくに、利用者向けの約束に例外が増える | +| `scale` にプロファイルの口を足す | `devbase scale` へ `--profile` を受ける引数を足し、プロファイルも複製できるようにする | 確定仕様 141・386-387 が「プロファイルのサービスは scale の対象にせず、複製されるのは開発サービスだけ」と決めている。この決定を変える要求は #192 に無い | +| 環境だけを渡す | `compose_env()` だけを渡し、起動の対象は明示しない | 決定 4 で退けた | + +### 決定 2: `cmd_login` の穴も同じ Pull Request で塞ぐ + +実測で、`compose_env()` を渡していない箇所は `cmd_scale`(1648)だけでなく +**`cmd_login`(1413)も**だった(要求仕様の実測の表の 4 と 5)。`cmd_login` も塞ぐ。 + +理由: + +- **決定 1 は「経路の表が devbase の Compose の起動を網羅している」ことを仕様として言い直す + 決定である。** 網羅していない状態のまま表を書き直すと、同じ食い違いを別の行で作る +- **観測できる振る舞いは変わらない。** `docker compose exec <サービス> bash` は既に動いている + コンテナを名指しする。開発サービスは `profiles:` を持たないため、`COMPOSE_PROFILES` の値で + 選ばれ方が変わらない。`exec` の中で動く `bash` の環境はコンテナ側から来る +- **棚卸しのコメントが実装と合っていない。** `tests/utils/test_docker_profiles.py:102` は + 「決定 7 の棚卸しの 4 か所」と書き、`cmd_scale` と `cmd_login` を数えていない。片方だけ直すと、 + 次に読む人が残りの 1 つを穴と気づけない + +採らなかった案: `cmd_login` を別の課題として起票し、この束では触らない。退けたのは、 +経路の表を書き直す作業がこの束にあるためである。**この 1 行は独立したコミットにする**ので、 +範囲外と判断されたら切り出せる。 + +### 決定 3: `docker_compose_up()` は拡張せず、`docker_compose()` を直接呼ぶ + +`cmd_scale` の起動を次の形にする。 + +```python +docker_compose(['up', '-d', '--no-recreate', *services], + compose_file=override_file, check=False) +``` + +理由: + +- **`docker_compose_up()` は `--no-recreate` を持たず、`check=True` 固定である。** + `no_recreate: bool = False` と `check` を足すと、引数が 2 つ増える。`utils/docker.py` の + 関数が `scale` の事情を知ることにもなる +- **`check=False` を保つ必要がある。** 変更前の `cmd_scale` は終了コードを見て + `Failed to start new containers` を出す。`docker_compose_up()` を使うと + `subprocess.CalledProcessError` が飛ぶ。これは `cmd_scale` の `except DevbaseError` を + 素通りし、traceback で落ちる。`cmd_up` は `except subprocess.CalledProcessError` も持つが、 + `cmd_scale` は持たない。**共通経路へ寄せる実装を素直に書くと、ここを踏む** +- `docker_compose()` は `env=compose_env()` を渡す唯一の共通経路であり、目的(決定 1)は + これを通すことで達する。`profile up` / `profile down` / `profile list` も + `docker_compose()` を直接呼ぶ(`container.py:1503` / `1531` / `1547`) + +採らなかった案: + +| 案 | 退けた理由 | +| --- | --- | +| `docker_compose_up()` に `no_recreate` と `check` を足す | 上の 1 つ目と 2 つ目。`tests/utils/test_docker_profiles.py:85-99` が固定している `docker_compose_up` の契約も広がる | +| `cmd_scale` に `except subprocess.CalledProcessError` を足して `docker_compose_up()` を使う | ログが `Failed to start new containers` から変わるか、2 か所で同じ文言を持つことになる。終了コードを見るほうが差分が小さい | + +### 決定 4: 起動の対象は `default_services(<生成物>)` で明示する + +`compose_env()` を渡すだけにせず、`up` と同じく既定のサービス名を全件並べる。 + +理由: + +- **確定仕様が `up` で明示する理由が、`scale` にそのまま当たる。** 確定仕様 136-138 行目は + 「起動の対象を明示するのは、打ち消し用のプロファイル名が効かない形でプロファイルが有効に + なっても、一覧に無いサービスを起動しないため」と書いている。`scale` の起動も同じ生成物に + 対する `up` である +- **`up` と `scale` で対象の決め方が違う状態を残さない。** #192 の「`scale` の手順を変えるときに + `cmd_up` と `cmd_scale` の片方だけを直す食い違いが起きやすい」は、まさにこの形である +- **プロファイルを持たないプロジェクトでは集合が変わらない。** `default_services` は + `profiles:` を持たないサービスの全件で、サービス名を付けない `up` の対象と同じである + (前提 6 / 受け入れ条件 B-5・E-1) + +採らなかった形(環境だけを渡す): `compose_env()` だけを渡し、サービス名は付けない。差分は最小で、 +失敗の経路も増えない。退けたのは上の 1 つ目と 2 つ目で、とくに「`up` と `scale` の規則が違う」 +状態が残ることを避けた。**増える失敗の経路は「処理の流れ」の節で見積もっており、`up` が通る +プロジェクトでは踏まない。** + +### 決定 5: 失敗の扱いとログの文言は変えない + +次の文言と、その出し分けを変えない。`cmd_scale` に `except subprocess.CalledProcessError` を +足さない。 + +``` +[1/5] ... [5/5] +Using --no-recreate to avoid restarting existing containers... +Failed to start new containers +Scale failed: %s +=== Scale completed successfully === +``` + +理由: 振る舞いの変更を「子プロセスの環境」と「起動の対象」の 2 点だけに絞る。 + +### 決定 6: `_previous_scale_compose()` は使わない + +`cmd_up` は生成に失敗したとき旧構成を書き戻すために `_previous_scale_compose()` を使うが、 +`cmd_scale` には入れない。理由: `scale` は既存のコンテナを止めない。止める段が無いので、旧構成で停止する必要が無い。 +入れると失敗したときに生成物だけが巻き戻り、`project.yml` の `scale` の値(`[1/5]` で既に +書き換わっている)と食い違う。 + +### 決定 7: 段階の番号の文字列は変えない + +`[1/5]`〜`[5/5]` と `[2.5/5]` をそのまま持つ。`default_services` の呼び出しには段階の番号を +付けない(`cmd_up` も `[2/6]` と `[3/6]` の間で番号を持たない)。理由: 番号を振り直すと、出力を読んでいる人にとっての差分が増える。`[2.5/5]` のような中途の +番号は `cmd_up` の `[1.5/6]` と同じ流儀で、この束で整えるものではない。 + +### 決定 8: 抽出は 2 つの関数に分け、`cmd_up` と対称にする + +`_check_scale_request`(前提の検査)と `_run_scale_pipeline`(`[1/5]`〜`[5/5]`)の 2 つにする。 +後処理(bao token・`./deploy`・完了のログ)は `cmd_scale` に残す。 + +理由: + +- **`cmd_up` が同じ形をしている。** `_run_pre_up_checks` と `_run_deploy_pipeline` があり、 + 後処理は `cmd_up` 本体にある。2 つのコマンドを並べて読めるようにするのが #192 の狙いである +- **`_check_group_consistency` は既に関数である。** 残る前提の検査は `new_scale < 1` と + `new_scale <= current_scale` の 2 つだけである。これを 1 つにまとめれば、`cmd_scale` の冒頭が + 「2 つの検査 → 段階 → 後処理」の 3 段に読める +- **後処理を出さない理由**: bao token と `./deploy` は `current_scale + 1` から + `new_scale` までの範囲を使う。`cmd_up` も同じものを本体に持つ。移すと 2 つのコマンドの形が + かえって離れる + +採らなかった案: + +| 案 | 退けた理由 | +| --- | --- | +| 段階ごとに 5 つの関数へ分ける | 1 行か 2 行の関数が並ぶ。`cmd_up` の形と離れ、順序の読み取りが `_run_scale_pipeline` 1 つを読むより難しくなる | +| `_run_deploy_pipeline` と `_run_scale_pipeline` を 1 つの関数へ統合し、引数で分岐させる | 停止の有無・退避の有無・`--no-recreate` の有無・段階の番号の 4 つで分岐する。分岐で分ける対象が 4 つあるものは 1 つの関数にしない | + +### 決定 9: config の読み取りは「下位の 1 関数 + 既存の名前を残した包み」に統合する + +`_compose_config_services()` を新設し、`_read_compose_services` を削除、`_resolve_dev_service` は +名前と契約を保ったまま本文を載せ替える。 + +理由: + +- **2 つの契約は違うので、1 つの関数に畳めない。** `_resolve_dev_service` は不正 JSON を + `None` に、`_read_compose_services` は `json.JSONDecodeError` の伝播にしている。どちらの + 呼び出し元も、その違いに合わせた失敗処理を持つ(`_build_resolved` は `if not dev_service:`、 + `_ensure_images` は外側の `except Exception`)。**引数で切り替える形にすると、呼び出し側が + 渡す値で失敗の形が変わる関数になる** +- **`_resolve_dev_service` の名前を残すのは、テストが差し替えているためである。** + `tests/cli/test_base_image_staleness.py:158 / 173 / 188` がこの名前を `monkeypatch.setattr` で + 差し替える。名前を変えると、この束の外の 3 つのテストを書き換えることになる +- **`_read_compose_services` の名前は残さない。** 呼び出し元が 1 つ(`_ensure_images`)で、 + 契約は `_compose_config_services` と同じである。同じ契約の名前を 2 つ持つと、次に読む人が + 違いを探す + +### 決定 10: 実装は 2 本の Pull Request に分ける + +内訳・触るファイル・依存の順序・採らなかった案は、次の章「実装の分け方」にある。 + +## 実装の分け方(決定 10) + +**分け目は「Compose の呼び出しを共通経路へ寄せる」と「`cmd_scale` の段階を分ける」である。** +確定仕様が約束する経路の一覧は、1 本目のマージの時点で実装と一致させる。 + +| # | 名前 | 内容 | 触るファイル | 依存 | +| --- | --- | --- | --- | --- | +| 1 | Compose の呼び出しを共通経路へ寄せる | 確定仕様の書き換え(決定 1)・`cmd_scale` の起動(決定 3・4)・`cmd_login` の 1 行(決定 2)・config 読み取りの統合(決定 9)・現状固定テストの新設・棚卸しのコメントと一覧の更新 | `docs/specifications/compose-profiles.md`、`lib/devbase/commands/container.py`(`cmd_scale` の `[4/5]` / `cmd_login` / `_resolve_dev_service` / `_read_compose_services` / `_ensure_images`)、`tests/commands/test_container_scale_order.py`(新設)、`tests/utils/test_docker_profiles.py` | 無し(base は `release/v3.7.0`) | +| 2 | `cmd_scale` の段階を分ける | 段階の抽出(決定 7・8)。振る舞いは変えない | `lib/devbase/commands/container.py`(`cmd_scale` のみ) | **Pull Request 1 の `:マージ` が要る** | + +依存の理由: 2 は `cmd_scale` の本体を関数へ割る。1 が書き換える `[4/5]` の行も動かすため、 +2 を先に出すとレビューした差分と入る差分が変わる。 + +**config 読み取りの統合(決定 9)を 1 本目へ入れる理由。** 確定仕様の経路の表は、 +`_resolve_dev_service` と `_read_compose_services` の 2 行を `docker_compose` の用途へ畳む。 +畳んだ表を 1 本目で入れて統合を 2 本目に置くと、**1 本目のマージの時点で仕様と実装が食い違った +版が残る。** 統合そのものは Compose の起動を共通経路へ寄せる変更で、#192 が指定した順序の +「共通経路へ寄せる」に入る。2 本目に残すのは「関数を分ける」だけである。 + +## 受け入れ条件とどちらの Pull Request が対応するか + +| 受け入れ条件 | Pull Request 1 | Pull Request 2 | +| --- | --- | --- | +| A-1(`compose_env()` を渡さない起動が 0 件。`grep` が 6 → 3) | **満たす** | 変わらない | +| A-2 / A-3 / A-4(確定仕様と利用者向けの文書) | **満たす** | 変わらない | +| A-5(棚卸しのコメントと一覧が経路と一致) | **満たす** | 変わらない | +| B-1〜B-5(`devbase scale` の振る舞い) | **満たす** | 緑のまま通す | +| C-1 / C-2(正常系の手順の固定) | **満たす** | 緑のまま通す | +| C-3 / C-4 / E-1〜E-3(退行しないこと) | 満たす | **書き換えずに満たす** | +| D-1 / D-2(config 読み取りの統合と契約) | **満たす** | 変わらない | +| D-3 / D-4(`cmd_scale` の本体 40 行以下と段階の対応) | 満たさない | **満たす** | + +`grep -rn "'docker', 'compose'" lib/` は現状 6 件である。Pull Request 1 で消えるのは +`container.py` の 1648(`cmd_scale`)・1792・1970 の 3 件である。残るのは +`utils/docker.py:56` と `container.py:266` と `opener.py:396` で、Pull Request 2 はこの +件数を変えない。`cmd_login`(1413)は `_compose_base_args()` の戻り値を使うため、この +`grep` には現れない。 + +#192 が指定した順序(仕様 → 共通経路 → 現状固定テスト → 関数を分ける)は、この 2 本の中で +次のように並ぶ。 + +| 順序 | どこで | 備考 | +| --- | --- | --- | +| 1. 仕様 | Pull Request 1 の 1 つ目のコミット | 決定 1 を確定仕様へ書く。経路の表は畳んだ後の 5 行にする | +| 2. 共通経路へ寄せる | Pull Request 1 の 2 つ目のコミット | `cmd_scale` の起動・`cmd_login` の 1 行・config 読み取りの統合。新しいコマンド列と子プロセスの環境を固定するテストを先に書いて落とす(`tdd-cycle`)。**`cmd_scale` の本体の構造は触らない** | +| 3. 現状固定テストを足す | Pull Request 1 の 3 つ目のコミット | 正常系の手順(順序・範囲)を固定する。**構造を変える前に緑にする** | +| 4. 関数を分ける | Pull Request 2 | 3 のテストを書き換えずに緑のまま通す。書き換えが要るなら振る舞いが変わっている | + +**3 を 2 より先に置かない理由**: 2 はコマンド列と子プロセスの環境を意図して変える。先に +現状(`env` 無し・サービス名無し)を固定すると、2 でそのテストを書き換えることになり、 +「固定したものを自分で書き換えた」記録が残る。2 が変えるのは 1 行の呼び出しで、構造は +触らないため、固定が無くても差分を目で追える。**構造を変える 4 の前には、3 が緑で入っている。** + +採らなかった案: + +| 採らなかった形 | 退けた理由 | +| --- | --- | +| 1 本にまとめる | `cmd_scale` の本体を関数へ割る差分と、Compose の呼び出しを寄せる差分が同じ差分に混ざる。レビューで「この行はどちらの目的か」が読めない | +| 3 本に分ける(仕様 / 振る舞い / 構造) | 確定仕様の 1 節と実装の 1 行は同じ約束の裏表で、別々にマージすると仕様と実装が食い違う版が中間に残る | +| config 読み取りの統合を 2 本目に置く | 1 本目のマージの時点で、畳んだ経路の表と直接 `subprocess.run` を呼ぶ実装が食い違う。受け入れ条件 A-1・A-5・D-1 も 1 本目では満たせない | +| 現状固定テストだけを先に 1 本出す | 上の「3 を 2 より先に置かない理由」と同じ | + +## テスト設計 + +新設するのは `tests/commands/test_container_scale_order.py` の 1 ファイルである。既存の流儀に +合わせる。`container` モジュールの属性を `monkeypatch.setattr` で差し替え、共有の `calls` へ +追記させて最後に並びを比べる。`tests/commands/test_container_up_order.py:46-92` の +`up_harness` と同型である。コマンド列と子プロセスの `env` は +`tests/utils/test_docker_profiles.py:19-30` の `FakeRun` を流用して拾う。 + +**実 docker と実 `DEVBASE_ROOT` には触れない。** `DEVBASE_ROOT` は各テストが自分で +`monkeypatch.setenv` する(要求仕様の前提 4)。 + +| 受け入れ条件 | 何で確かめるか | +| --- | --- | +| A-1 | `grep -rn "'docker', 'compose'\|\"docker\", \"compose\"" lib/` が 3 件。残った 3 件を 1 つずつ辿る | +| A-2 / A-3 | `grep -n "cmd_scale" docs/specifications/compose-profiles.md` と、書き換えた 2 つの表の目視 | +| A-4 | `git diff --name-only` に `docs/plugin-dev/compose-profiles.md` が出ないこと | +| A-5 | `tests/utils/test_docker_profiles.py` の棚卸しの節に、変更後の経路(`_compose_run` / `_compose_lines` / `cmd_login` / `editor._query_container_name`)が並ぶ | +| B-1 | `monkeypatch.setenv('COMPOSE_PROFILES', 'web')` のうえで `cmd_scale(2)`。`FakeRun` が拾った `env['COMPOSE_PROFILES'] == docker.NO_PROFILE` | +| B-2 | 同じテストの後で `os.environ['COMPOSE_PROFILES'] == 'web'` | +| B-3 | `FakeRun` の `cmd` が `['docker', 'compose', '-f', <生成物>, 'up', '-d', '--no-recreate', 'dev-1', 'dev-2']`。`default_services` を差し替えて一覧を固定する | +| B-4 | `FakeRun(returncode=1)` で `cmd_scale(2) == 1`、`caplog` に `Failed to start new containers`、例外が外へ出ないこと | +| B-5 | `default_services` を実物にし、`_compose_lines` の標準出力を差し替えて、`profiles:` を持つサービスが一覧に出ないことを見る | +| C-1 | `calls` の並びが `['group', 'write_scale', 'volumes', 'network', 'generate', 'default_services', 'up', 'wait', 'bao', 'deploy']` | +| C-2 | `bao` の `start` が `current + 1`、`deploy` の `indices` が `range(current + 1, new + 1)` | +| C-3 | 既存の 4 か所を書き換えずに `pytest tests/commands/test_container_up_order.py tests/commands/test_container_context.py tests/cli/test_project_dispatch.py` | +| C-4 / E-1 | `pytest tests/` の全件を変更の前後で実行し、件数と結果を Pull Request 本文へ並べる | +| D-1 | `grep -rn "'config', '--format', 'json'" lib/` が 1 件 | +| D-2 | `_compose_config_services` と `_resolve_dev_service` の単体テスト 3 通り(終了コード非 0 / 不正 JSON / 正常)。不正 JSON で前者は `json.JSONDecodeError`、後者は `None` | +| D-3 | `cmd_scale` の `def` から次の `def` までの行数 | +| D-4 | この文書の段階の対応表と、`grep -n "/5\]" lib/devbase/commands/container.py` の出力を並べる | +| E-2 | 生成物に `profiles:` を持つサービスを置き、`up` のコマンド列にそのサービス名が出ないことを見る | +| E-3 | `scale` の `calls` に停止(`down` / `stop` / `rm`)が 1 件も出ないことを見る | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| `--no-recreate` とサービス名の明示の組み合わせの実 docker の挙動 | 確かめるには実環境のプロジェクトが要る(要求仕様の前提 2)。確定仕様 92-95 行目の「打ち消し用のプロファイル名を入れると、依存先としての起動が止まる」を前提にする | +| `COMPOSE_PROFILES` を置いたうえで `devbase scale` を打っている利用者の有無 | 分からない。いた場合、この変更でプロファイルのサービスが起動しなくなる。切り戻しは配布の版を戻すこと | +| `cmd_scale` が `_report_missing_repos` / `_apply_window_titles` を新しいインスタンスへ行わないこと | この束の対象外。**#224 として起票済み** | +| CI での検査 | `release/v3.7.0` を base にした Pull Request では検査ジョブが 1 件も動かない(#216)。証跡は手元で採って Pull Request 本文へ載せる | diff --git a/issues/PLAN65_scale-compose-path.md b/issues/PLAN65_scale-compose-path.md new file mode 100644 index 00000000..67278404 --- /dev/null +++ b/issues/PLAN65_scale-compose-path.md @@ -0,0 +1,282 @@ +# PLAN65: `devbase scale` の Compose 呼び出しを共通経路へ寄せ、`cmd_scale` の段階を分ける + +対象 issue: devbasex/devbase#192 + +- ワークフローモード: `standard` + - 根拠: `devbase scale` の本番の振る舞いを変える。変わるのは子プロセスへ渡る環境と、 + 起動の対象に入るサービスの集合である。確定仕様 `docs/specifications/compose-profiles.md` も + 変える。構造変更の側も本番の振る舞いの変更を伴うため、対象のテストが薄くても + `legacy-refactor` ではなく `standard` にする +- ベースブランチ: `release/v3.7.0`(release Pull Request は #212) +- 設計文書: `issues/PLAN65_scale-compose-path-design.md` + +## 目的 +- **確定仕様が約束している「`COMPOSE_PROFILES` を端末や `.env` に置いても devbase 経由の操作には + 効かない」が `devbase scale` でも成り立つ。** いまは成り立たない +- **`devbase up` と `devbase scale` が、同じ生成物に対して同じ規則で Compose を呼ぶ。** 起動の + 対象の決め方と子プロセスの環境が経路によって違わない +- **`cmd_scale` の段階に名前が付き、`cmd_up` と同じ形で読める。** `docker compose config + --format json` を読む経路が 1 つになる +- **`devbase scale` の正常系の手順が、テストで固定される。** いまは 1 つも無い + +## 実測: Compose を起動する経路(2026-09-22 / `release/v3.7.0` の先頭 688efde) +推測ではなく、この作業ツリーで採った値である。 +`os.execvp` 系は 0 件、`Popen` / `check_output` / `subprocess.call` で Compose を起動する箇所も +0 件だった。Compose の起動はすべて `subprocess.run` である。 + +``` +$ grep -rn "'docker', 'compose'\|\"docker\", \"compose\"" lib/ +lib/devbase/utils/docker.py:56: cmd = ['docker', 'compose'] +lib/devbase/commands/container.py:266: cmd = ['docker', 'compose'] # _compose_base_args +lib/devbase/commands/container.py:1648: ['docker', 'compose', '-f', str(override_file), 'up', '-d', '--no-recreate'], +lib/devbase/commands/container.py:1792: ['docker', 'compose', 'config', '--format', 'json'], +lib/devbase/commands/container.py:1970: ['docker', 'compose', 'config', '--format', 'json'], +lib/devbase/editor/opener.py:396: cmd = ["docker", "compose"] +``` + +**この grep だけでは数え足りない。** `_compose_base_args()`(266 行目)が返した配列を使う +呼び出し元は、行の上に `['docker', 'compose']` を持たない。呼び出し元まで辿ると、Compose を +起動する箇所は 8 か所で、**`compose_env()` を渡していないのは 2 か所**である。 + +| # | 場所 | 関数 | `compose_env()` | 起動するもの | +| --- | --- | --- | --- | --- | +| 1 | `utils/docker.py:64` | `docker_compose()` | 渡す | 共通経路そのもの | +| 2 | `container.py:281` | `_compose_run()` | 渡す | `ps` / `logs` | +| 3 | `container.py:291` | `_compose_lines()` | 渡す | `config --services` / `--profiles` | +| 4 | **`container.py:1413`** | **`cmd_login()`** | **渡さない** | `exec - bash` | +| 5 | **`container.py:1648`** | **`cmd_scale()`** | **渡さない** | `up -d --no-recreate` | +| 6 | `container.py:1792` | `_resolve_dev_service()` | 渡す | `config --format json` | +| 7 | `container.py:1970` | `_read_compose_services()` | 渡す | `config --format json` | +| 8 | `editor/opener.py:396` | `_query_container_name()` | 渡す | `ps --format json` | + +issue 本文は 5 だけを挙げているが、**4(`cmd_login`)も同じ穴である**。`tests/utils/test_docker_profiles.py:102` +のコメントも「決定 7 の棚卸しの 4 か所」と書いており、この 2 か所が棚卸しから漏れている。 +`cmd_login` の扱いは設計の決定 2 で決める。 + +## 実測: 現状固定テスト +``` +$ grep -rn "no-recreate" tests/ +(0 件) +$ grep -rn "no-recreate" lib/ +lib/devbase/commands/container.py:1645 +lib/devbase/commands/container.py:1648 +``` + +`cmd_scale` を呼ぶテストは 4 か所ある(`tests/cli/test_project_dispatch.py:366` は +`cmd_scale` を差し替える側で、本体は動かない)。 + +| 場所 | 何を固定しているか | `[4/5]` まで届くか | +| --- | --- | --- | +| `tests/commands/test_container_up_order.py:257` | グループ不一致で 1 を返す | 届かない | +| `tests/commands/test_container_up_order.py:290` | `_build_scaled_override` の例外で 1 を返す | 届かない | +| `tests/commands/test_container_context.py:254` | 接続先(`DOCKER_CONTEXT` / `DOCKER_GID`)の伝播。`@parametrize` で 2 ケース | **届く(唯一)** | +| `tests/cli/test_project_dispatch.py:362,366` | dispatch の引数の伝播(本体は差し替え) | 届かない | + +**正常系を通る 1 本(`test_container_context.py:254`)はあるが、固定しているのは接続先の +伝播だけである。** 起動のコマンド列・子プロセスの `COMPOSE_PROFILES`・`_push_bao_token` と +`./deploy` の順序と範囲は、どのテストも固定していない。issue 本文の「正常系の手順を固定する +テストは無い」は、この意味では成り立つ。 + +## 確定仕様の食い違い +`docs/specifications/compose-profiles.md` は同じ文書の中で相反する 2 つを書いている。 + +| 行 | 内容 | +| --- | --- | +| 83-84 | 「`cmd_scale` が直接呼ぶ `docker compose -f <生成物> up -d --no-recreate` はこの対象に含めない。プロファイルの入口ではないためである」 | +| 388-389 | 「`COMPOSE_PROFILES` を端末や `.env` に置いても devbase 経由の操作には効かない。素の `docker compose` には従来どおり効く」 | + +同じ約束は利用者向けの文書にもある(`docs/plugin-dev/compose-profiles.md:82`)。 + +## 影響 +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わらない(CLI の引数・環境変数・サブコマンドは増減しない) | +| データ | 変わらない(`project.yml` / 生成物 `.docker-compose.scale.yml` の形は変わらない) | +| 既存の振る舞い | **変わる。** `devbase scale` の子プロセスの `COMPOSE_PROFILES` が常に `__devbase_none__` になり、起動の対象が既定のサービスに限られる。端末または `.env` に `COMPOSE_PROFILES` を置いた人にとっては、`devbase scale` がプロファイルのサービスを起動しなくなる | +| 確定仕様 | **変わる。** `docs/specifications/compose-profiles.md` の経路の表・コマンド列の表・運用・テスト観点 | +| ログ | 変わらない(`[1/5]`〜`[5/5]` の文字列と `Failed to start new containers` を保つ) | +| 利用者の操作 | 追加の操作は要らない。配布された版を使えばそのまま効く | + +## 対象範囲 +含む: + +- `lib/devbase/commands/container.py` + - `cmd_scale` の `docker compose ... up -d --no-recreate` を共通経路(`utils/docker.py` の + `docker_compose()`)へ寄せる + - `cmd_scale` の段階(`[1/5]`〜`[5/5]`)を関数へ抽出する + - `_resolve_dev_service` と `_read_compose_services` の `docker compose config --format json` を + 1 つの関数へ統合し、共通経路へ寄せる + - `cmd_login` の `docker compose exec` へ `compose_env()` を渡す(設計の決定 2) +- `docs/specifications/compose-profiles.md` — 経路の表・組み立てるコマンド列の表・運用・テスト観点 +- `tests/commands/` — `devbase scale` の正常系の手順を固定するテスト(新規) +- `tests/utils/test_docker_profiles.py` — 「決定 7 の棚卸しの 4 か所」のコメントと、そこに並ぶ + 経路のテスト(棚卸しの中身が変わるため) + +含まない: + +- `cmd_up` / `_run_deploy_pipeline` / `cmd_down` / `cmd_profile_*` の振る舞い(読み替えの対象に + 入るだけで、コマンド列も順序も変えない) +- `docker_compose_up()` / `docker_compose_down()` / `compose_env()` のシグネチャの変更 +- `_compose_run`(`devbase ps` / `devbase logs`)と `editor/opener.py` の `_query_container_name`。 + どちらも既に `compose_env()` を渡しており、この束の対象ではない +- `devbase scale` へのプロファイルの指定の口(`scale --profile` のような引数)の追加 +- `pytest` の `DEVBASE_ROOT` の隔離(PR #217 の範囲) +- `cmd_scale` の前提の検査そのものの見直し(`new_scale <= current_scale` を警告で 1 にする + 今の判定は変えない) + +## 前提 +- **前提 1: 実害の観測は無い。** issue 本文のとおり、確定仕様の約束と実装が食い違っていることまでが + 分かっている。`COMPOSE_PROFILES` を置いたうえで `devbase scale` を打った事例の報告は無い。 + よって「壊れているものを直す」ではなく「約束と実装のどちらかへ揃える」変更である +- **前提 2: 実環境のプロジェクトで `devbase up` / `devbase scale` を実行して確かめない。** 実環境の + コンテナは本番の系である。確認はコマンド列を組み立てる関数の単体の水準(`subprocess.run` の + 差し替え)で行う。実 docker と実 `DEVBASE_ROOT` には触れない +- **前提 3: `release/v3.7.0` を base にした Pull Request では CI が 1 件も動かない。** + `.github/workflows/ci.yml` の `on.pull_request.branches` が `main` だけのためである。 + `gh pr checks` は `no checks reported` を返し、`mergeStateStatus` は `CLEAN` を返す(#216)。 + 検証は手元で行い、証跡を Pull Request 本文へ載せる +- **前提 4: pytest は実環境の `DEVBASE_ROOT` を継承する。** その隔離は別の束(PR #217、未マージ)が + 入れる。この束のテストは既存の流儀(各テストが自分で `monkeypatch.setenv`)に合わせる +- **前提 5: `docs/specifications/compose-profiles.md` は別の束(PR #213、未マージ)も触る。** + #213 の hunk は 193 行目付近の 1 行だけで、この束が触る節(74-84 / 107-120 / 386-390 / 445 付近) + とは重ならない。後からマージする側が競合を解く +- **前提 6: プロファイルを持たないプロジェクトの振る舞いは変わらない。** 確定仕様 141 行目の + 「プロファイルを持たないプロジェクトでは、`up` / `down` / `scale` が扱うコンテナの集合と順序は + 変わらない」を保つ + +## 受け入れ条件: 仕様と振る舞い(A・B) +### 仕様の食い違いの解消(A-1〜A-5) + +- [ ] **A-1: `lib/` の中で `compose_env()` を渡さずに `docker compose` を起動する箇所が 0 件になる。** + 上の実測の 8 か所を 1 件ずつ辿り、`compose_env()` を直接渡すか `docker_compose()` を経由する + ことを確かめる。現状は 2 件(`cmd_scale:1648` と `cmd_login:1413`)が満たしていない。 + `grep -rn "'docker', 'compose'\|\"docker\", \"compose\"" lib/` の件数は 6 → 3 に減る。 + 残るのは `utils/docker.py` の共通経路・`_compose_base_args`・`editor/opener.py` の 3 つである +- [ ] **A-2: `docs/specifications/compose-profiles.md` の中に、`cmd_scale` を共通経路の対象から + 外す記述が残っていない。** 現状 83-84 行目の「`cmd_scale` が直接呼ぶ …… この対象に含めない」が + 消え、経路の表(74-81 行目)に `scale` の起動の経路が載る +- [ ] **A-3: 「組み立てるコマンド列」の表に `devbase scale` の行がある。** 行は + `up -d --no-recreate <既定のサービス...>` と、子プロセスの `COMPOSE_PROFILES` が + `__devbase_none__` であることを示す +- [ ] **A-4: `docs/plugin-dev/compose-profiles.md:82` の約束に例外を足さずに済む。** + その約束は「端末と `.env` の `COMPOSE_PROFILES` は devbase 経由の操作に効かない」である。 + 足す必要が出たら、共通経路へ寄せる判断(設計の決定 1)を見直す +- [ ] **A-5: `tests/utils/test_docker_profiles.py` の「棚卸しの N か所」のコメントと、そこに + 並ぶテストが、変更後の経路の一覧と一致する。** いまのコメントは 4 か所と書き、 + `cmd_scale` と `cmd_login` を数えていない + +### `devbase scale` の振る舞い(B-1〜B-5) + +`subprocess.run` を差し替えた単体のテストで確かめる。実 docker には触れない。 + +- [ ] **B-1: `devbase scale` が起動に使う子プロセスの環境の `COMPOSE_PROFILES` が + `__devbase_none__` である。** 呼び出し側の `os.environ` に `COMPOSE_PROFILES=web` を置いた + 状態でも同じ値になる +- [ ] **B-2: 呼び出し側の `os.environ` は書き換わらない。** `cmd_scale` の前後で + `os.environ.get('COMPOSE_PROFILES')` が変わらない +- [ ] **B-3: 組み立てるコマンド列が + `docker compose -f <生成物> up -d --no-recreate <既定のサービス...>` である。** + `-f` に渡るのは `_build_scaled_override` が返したパスで、サービス名は + `default_services(<生成物>)` が返した一覧の全件、その並びのままである +- [ ] **B-4: 起動が 0 以外で終わったら、`Failed to start new containers` を error で出して 1 を返す。** + 例外は送出しない(`subprocess.CalledProcessError` が `cmd_scale` の外へ出ない) +- [ ] **B-5: プロファイルを持たない生成物では、起動の対象に入るサービスの集合が変更前と一致する。** + 比べるのは、`default_services` が返す一覧と、変更前にサービス名を付けずに起動したときの + 対象である。`config --services` の出力を差し替えて示す + +## 受け入れ条件: テスト・構造・退行(C・D・E) +### 正常系の手順の固定(C-1〜C-4) + +- [ ] **C-1: `devbase scale` の正常系を通すテストが存在し、次の順序を固定する。** + `grep -rn "no-recreate" tests/` が 1 件以上になる + + ``` + _check_group_consistency → write_scale → ensure_volumes → ensure_network + → _build_scaled_override → default_services → 起動 → wait_for_containers_ready + → _push_bao_token → ./deploy + ``` +- [ ] **C-2: `_push_bao_token` と `_run_deploy_script_for_instances` に渡る範囲が + `current_scale + 1` から `new_scale` までである。** 既にあるインスタンスを含めない +- [ ] **C-3: 既にある 4 か所の `cmd_scale` のテストが、書き換えずに通る。** + グループ不一致で 1、`_build_scaled_override` の例外で 1、context の伝播、dispatch の伝播 +- [ ] **C-4: `pytest tests/` の全件が、変更の前後で同じ結果になる**(前提 4 のとおり、実行時は + 各テストが自分で `monkeypatch.setenv` する既存の流儀に従う) + +### 構造(D-1〜D-4) + +- [ ] **D-1: `docker compose config --format json` を起動する箇所が 1 か所になる。** + `grep -rn "'config', '--format', 'json'" lib/` が 1 件(現状 2 件) +- [ ] **D-2: 統合した後も 2 つの呼び出し元の契約が変わらない。** + `_resolve_dev_service` は、終了コードが非 0 のときと JSON として読めないときに `None` を返す。 + `_read_compose_services` の契約を引き継ぐ側は、JSON として読めないときに + `json.JSONDecodeError` を伝播する +- [ ] **D-3: `cmd_scale` の本体が 40 行以下になる**(現状 86 行)。抽出した段階の関数が + `[1/5]`〜`[5/5]` のログ文字列をそのまま持つ +- [ ] **D-4: `cmd_scale` と `cmd_up` の段階の対応が、設計文書の表と一致する。** 段階の番号の + 文字列(`[2.5/5]` を含む)は変えない + +## 受け入れ条件: 退行しないこと(E) + +- [ ] **E-1: `devbase up` のコマンド列と順序が変わらない。** + `tests/commands/test_container_up_order.py` を書き換えずに通す +- [ ] **E-2: `devbase scale` はプロファイルのサービスを複製しない**(確定仕様 386-387 行目)。 + 生成物の `profiles:` は保たれたままで、`scale` の対象に入らない +- [ ] **E-3: 既に動いているプロファイルのサービスを `devbase scale` が停止しない。** + `--no-recreate` の起動で、対象に入らないサービスへは触れない(`up` と違い、`scale` は + 停止の段を持たない) + +## 検証手段 +| 条件 | 手段 | +| --- | --- | +| A-1 / D-1 | `grep -rn` の出力を Pull Request 本文へ貼る | +| A-2 / A-3 / A-4 | 変更後の `docs/specifications/compose-profiles.md` の該当の節と `grep -n "cmd_scale" docs/specifications/compose-profiles.md` | +| B-1〜B-5 / C-1〜C-2 | 新しいテスト `tests/commands/test_container_scale_order.py` の `pytest` | +| C-3 / C-4 / E-1 | `pytest tests/` の全件。変更前の結果と並べて Pull Request 本文へ載せる | +| D-2 | 統合した関数の単体のテスト(終了コード非 0 / 不正 JSON / 正常の 3 通り) | +| D-3 | `python - <<'EOF'` で `cmd_scale` の行数を数える、または差分の行数 | +| E-2 / E-3 | 生成物に `profiles:` を含むサービスを置いたテストで、起動の対象の一覧を検査 | + +CI は動かない(前提 3)。上の手段はすべて手元で実行し、証跡を Pull Request 本文へ載せる。 + +## 実装計画 + +設計文書の「実装の分け方」の章が、2 本の Pull Request の内訳・対象ファイル・依存の順序を +持つ。この文書は受け入れ条件の側だけを持つ。 + +**どの条件がどちらの Pull Request で満たされるかは、設計文書の「受け入れ条件とどちらの +Pull Request が対応するか」の表が決める。** A-1 の `grep` の件数(6 → 3)と A-5 の棚卸しの +一致は 1 本目で満たす。D-3・D-4(`cmd_scale` の本体 40 行以下と段階の対応)だけが 2 本目で +満たす条件である。 + +## 未確認のまま残ること +- **`--no-recreate` と明示したサービス名の組み合わせで、Compose が依存先をどう扱うか**の実測は + 行わない。確定仕様 92-95 行目に、打ち消し用のプロファイル名を入れれば依存先としての起動も + 止まると書いてある。それを前提にする。実 docker で確かめるには実環境のプロジェクトが要る + ため(前提 2)行わない +- **`COMPOSE_PROFILES` を置いたうえで `devbase scale` を打っている利用者がいるか**は分からない。 + いた場合、この変更でプロファイルのサービスが起動しなくなる。切り戻しは配布の版を戻すこと + (`devbase scale` に専用の退避の口は設けない) + +## 境界 +| 区分 | 内容 | +| --- | --- | +| 常に行う | 変更範囲の pytest の実行、既存テストの全件実行、`grep` による数え直し | +| 確認してから行う | 確定仕様の約束の書き換え(この文書の受け入れ条件がその確認である)、`utils/docker.py` の関数のシグネチャの変更 | +| 行わない | 実環境のプロジェクトでの `devbase up` / `devbase scale` の実行、`cmd_up` の振る舞いの変更、依頼範囲外のリファクタリング | + +## 前提とする取り決め +- ブランチ戦略と Pull Request の運用は `ndf-policies` に従う。base は `release/v3.7.0` +- 構造を変える変更は、振る舞いを変えないことをテストで守る(`refactoring`) +- コミットと Pull Request の本文の作法は `markdown-writing` に従う + +## 依頼(原文) +issue #192 の本文から、決めることと採る手・順序の指定を原文のまま写す。 + +> ## 決めること +> +> - `devbase scale` の Compose 呼び出しを共通経路へ寄せるか、それとも仕様の「devbase 経由の +> 操作には効かない」を `scale` 除外込みへ書き直すか。決め方によって、抽出した後の関数の境界が変わる + +> **採る手**: 統合(`consolidate_duplication`)。Compose の呼び出しを共通経路へ寄せてから、段階を抽出する。 +> +> **順序**: 仕様(`scale` を共通経路の対象にするか決める)→ 共通経路へ寄せる → 現状固定テストを足す → 関数を分ける。 From 33ee9efd8e0f54971b622b17fc070d6413f06b69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Tue, 22 Sep 2026 21:20:06 +0900 Subject: [PATCH 05/15] =?UTF-8?q?=E8=A8=AD=E8=A8=88(PLAN66):=20=E5=90=8D?= =?UTF-8?q?=E5=89=8D=E3=81=AE=E5=BD=A2=E3=81=AB=E5=90=88=E3=82=8F=E3=81=AA?= =?UTF-8?q?=E3=81=84=E3=83=97=E3=83=AD=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88?= =?UTF-8?q?=E3=82=92=E3=80=81=E4=BD=9C=E3=82=89=E3=82=8C=E3=81=9F=E6=99=82?= =?UTF-8?q?=E7=82=B9=E3=81=A7=E7=9F=A5=E3=82=89=E3=81=9B=E3=82=8B=20(#203)?= =?UTF-8?q?=20(#230)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(PLAN66): 名前の形に合わないプロジェクトを作られた時点で知らせる要求仕様と設計 (#203) - プラグインの同期と env import で名前の形を検査し、弾かずに警告に留める - スナップショットの名前の形を utils/names の述語へ寄せる - 実装は含まない(設計だけの Pull Request) Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN66): round 1 のレビュー指摘を反映する (#203) - 検査の位置を候補の集約から symlink を張る直前へ移す(同じ名前で 2 行出る・載らない名前にも出る) - 知らせの文を完了形にせず、dry-run や書き込み失敗と矛盾しない形にする - 決定 7 の共通化の範囲(ヒント文だけ)を明記する - 既存のテストファイルへの追加であることを検証欄に書く Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN66): round 2 のレビュー指摘を反映する (#203) - env import の知らせを保存先ごとの文にする(age・サーバ backend は projects/ に作らない) - 受け入れ条件へ age の保存先の条件を足す(番号を 1 つ繰り下げ) - 受け入れ条件 11 の拒否リストへ末尾の改行を足し、設計の 8 件と揃える Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN66): round 3 のレビュー指摘を反映する (#203) - age の保存先の受け入れ条件を backend を明示する流儀へ(平文へ落ちると条件を確かめられない) - 同期の知らせの案内を出所ごとに分ける(別名と実ディレクトリでは改名先が違う) - F2 と用語の定義を保存先の違いに合わせる - real_projects を sorted で走査する旨を明記する - 前提の番号の順序を昇順へ直す Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN66): F1 の文から、同期が作らない実ディレクトリを分けて書く (#203) Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN66): round 5 のレビュー指摘を反映する (#203) - 別名の案内を、名前の側と owner の側で分ける(_foo.valid-owner は改名で直る) - 分かれ目を is_single_segment_name(<名前>) と明記し、_warn_unusable_name へ base を渡す - 受け入れ条件 3 を 2 つの分岐で確かめる形にする(3 と 3-2) Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN66): 知らせを出す時点の記述を、計画を立てた直後へ揃える (#203) Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../PLAN66_project-name-validation-design.md | 429 ++++++++++++++++++ issues/PLAN66_project-name-validation.md | 326 +++++++++++++ 2 files changed, 755 insertions(+) create mode 100644 issues/PLAN66_project-name-validation-design.md create mode 100644 issues/PLAN66_project-name-validation.md diff --git a/issues/PLAN66_project-name-validation-design.md b/issues/PLAN66_project-name-validation-design.md new file mode 100644 index 00000000..fe65049d --- /dev/null +++ b/issues/PLAN66_project-name-validation-design.md @@ -0,0 +1,429 @@ +# PLAN66: 名前の形に合わないプロジェクトを、作られた時点で知らせる の設計 + +要求と受け入れ条件は [PLAN66_project-name-validation.md](PLAN66_project-name-validation.md) に +ある。この文書は「どう作るか」だけを扱う。 + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | プラグインの同期が、`projects/` に載せる名前(プラグインのプロジェクト・合成する別名)と、そこで見つけた実ディレクトリの名前の形を見て、合わないものを 1 行知らせる。symlink は今と同じく張る | `devbase plugin install` / `update` / `sync` を打つ利用者と、プラグインの作者 | +| F2 | `devbase env import` が名前の形に合わない名前を取り込むとき、**保存先に応じた内容で** 1 行知らせる(平文は `projects//`、age は `secrets/projects/.env.age`、サーバ backend はサーバ側)。import は今と同じく通す | `devbase env import` を打つ利用者(端末の移行) | +| F3 | スナップショットの名前の形を `utils/names` の述語へ寄せ、定数の二重持ちをやめる | devbase を保守する側 | + +## 構成要素 + +| 要素 | 変更 | 責務 | +| --- | --- | --- | +| `lib/devbase/utils/names.py` の `NAME_FORM_HINT`(新設) | 足す | 名前の形を説明する文の定数。`re` だけに依存し副作用を持たない module の契約(確定仕様「構成要素」)を保つため、**ログを出さない**。値は `container.py` の既存の文言と同じ「英数字で始まり、英数字・'.'・'-'・'_' だけからなる名前」 | +| `lib/devbase/plugin/syncer.py` の `_warn_unusable_name`(新設) | 足す | 1 つの名前が形に合わなければ `logger.warning` を 1 行出す。合えば何もしない。戻り値を持たない(呼び出し側の分岐に使わせない) | +| `lib/devbase/plugin/syncer.py` の `_link_loser_projects` | 変える | 合成した別名(`f"{proj_name}.{owner}"`)に `_warn_unusable_name` を呼ぶ。**元のプロジェクト名を `base` として渡す**(案内の分岐に使う)。symlink を張る条件は変えない | +| `lib/devbase/plugin/syncer.py` の `sync_projects` | 変える | 2 か所で `_warn_unusable_name` を呼ぶ。(1) `real_projects` を採取した直後に、その名前ごと(**`sorted(real_projects)` で走査する**。`real_projects` は `set` で、並べないと警告の順が実行ごとに変わる。既存の `sorted(project_candidates.items())` と同じ扱い)。(2) winner へ symlink を張る直前(`real_projects` のスキップより後)に、その名前 1 回 | +| `lib/devbase/env/_import_merge.py` の `project_name_of`(新設) | 足す | 1 つのメンバー名から、そのプロジェクト名を返す純粋な関数(当たらなければ `None`)。`_PROJECT_ENV_RE` を使う | +| `lib/devbase/env/io_import.py` の `import_bundle` | 変える | `_build_plans` が返した `plans` を回し、`plan.arcname` の名前が形に合わなければ 1 行知らせる。**文は `plan.target` と `plan.ref` から保存先を読んで選ぶ**(決定 4)。`--dry-run` の判定より前に置く | +| `lib/devbase/snapshot/manager.py` の `_VALID_NAME_RE` | 消す | 文字の規則を `utils/names` へ寄せる | +| `lib/devbase/snapshot/manager.py` の `_validate_name` | 変える | `is_single_segment_name(name)` で判定する。例外の型(`SnapshotError`)と文言は変えない。`not name` の明示ガードは述語が空を弾くので消す | +| `docs/specifications/cli-argument-resolution.md` の「運用」 | 変える | 下の「確定仕様の書き換え」の 2 つの箇条書き | +| `CHANGELOG.md` の `[Unreleased]` | 変える | Added に F1・F2、Changed に F3 | + +変えないものは次のとおりである。 + +| 変えないもの | 補足 | +| --- | --- | +| `discover_projects` | `.` 始まりの除外も含めて現状のまま(決定 8) | +| `_collect_project_candidates` | 候補の集約。ここでは検査しない(決定 2) | +| `sync_projects` が返す数 | 張った symlink の数(決定 1) | +| `_extract_owner`・`_make_relative_target` | 別名の合成の規則(#228 で起票した文書の食い違いも含む) | +| `_PROJECT_ENV_RE` のパターン | 書庫の中の名前の規則(決定 4) | +| `env/bundle.py`・`env/secret_store.py` | 確定仕様が別に保つと決めている規則 | +| `bin/devbase`・`cli.py`・`commands/container.py` | 下流の検証(決定 3) | + +## 構成要素の関係と文脈 + +### 構成要素の関係 + +```mermaid +graph TD + subgraph 名前の形の規則 + N[utils/names
is_single_segment_name
NAME_FORM_HINT] + end + subgraph プラグインの同期 + SP[sync_projects] --> CC[_collect_project_candidates] + SP --> LL[_link_loser_projects] + SP --> WU[_warn_unusable_name] + CC --> DP[discover_projects] + LL --> WU + LL --> EO[_extract_owner] + WU --> N + end + subgraph env の import + IB[import_bundle] --> BP[_build_plans] + IB --> PN[project_name_of] + IB --> N + end + subgraph スナップショット + VN[_validate_name] --> N + end +``` + +図に現れない要素は 4 つある。`NAME_FORM_HINT` は `N` に含めた。消す `_VALID_NAME_RE` は +呼び出しの辺を持たなくなる。`docs/specifications/cli-argument-resolution.md` と +`CHANGELOG.md` は文書で、呼び出しを持たない。 + +### システムの文脈 + +devbase はホストで動く。この変更が触る外部は `$DEVBASE_ROOT/projects/` の直下の名前と、 +標準エラーへ出る行だけである。docker daemon・ネットワーク・機密の置き場への呼び出しは +増えも減りもしない。 + +```mermaid +graph LR + U[利用者の端末] --> C[devbase CLI] + P[プラグインの repos/ クローン] -->|名前を読む| C + B[env の書庫 tar.gz] -->|名前を読む| C + C -->|symlink と実ディレクトリを作る| D[DEVBASE_ROOT/projects/] + C -->|警告 1 行| U +``` + +## 処理の流れ + +```mermaid +sequenceDiagram + participant U as 利用者 + participant S as sync_projects + participant C as _collect_project_candidates + participant W as _warn_unusable_name + participant L as _link_loser_projects + U->>S: devbase plugin sync + S->>S: real_projects を採取(実ディレクトリ) + loop sorted(real_projects) の名前ごと + S->>W: 名前の形を見る + W-->>U: 合わなければ警告 1 行 + end + S->>C: 候補を集める(検査しない) + loop 張る名前ごと(実ディレクトリのスキップより後) + S->>W: 名前の形を見る + W-->>U: 合わなければ警告 1 行 + S->>S: winner へ symlink を張る(無条件。今と同じ) + S->>L: 敗れた側の別名を張る + L->>W: 合成した名前の形を見る + W-->>U: 合わなければ警告 1 行 + end + S-->>U: 張った数を返す(今と同じ) +``` + +`import_bundle` の流れは 1 か所だけである。`_build_plans` の直後、`--dry-run` の判定より前に +`plans` を回し、形に合わない名前を知らせる。**`filter_members` の直後ではなく `plans` を見る** +のは、保存先が `plan.target` と `plan.ref` で決まるためである(決定 4)。**`--dry-run` でも +出る**のは、書き込む前に何が起きるかを知らせるためである。 + +## 入出力の契約: 新設・変更する関数 + +| 関数 | シグネチャ | 契約 | +| --- | --- | --- | +| `utils/names.NAME_FORM_HINT` | `str`(module の定数) | 名前の形を説明する文。末尾に句点を置かない(呼び出し側が文へ埋める) | +| `syncer._warn_unusable_name` | `(name: str, source: str, base: Optional[str] = None) -> None` | `is_single_segment_name(name)` が True なら何もしない。False なら `logger.warning` を 1 行。`source` は名前の出所(プラグイン名・別名・実ディレクトリ)で、**文に埋めるだけでなく、末尾の案内の選択にも使う**。`base` は別名のときだけ渡す元のプロジェクト名で、`is_single_segment_name(base)` の結果が別名の案内を 2 つに分ける(上の「警告の文」の出所の表) | +| `_import_merge.project_name_of` | `(arcname: str) -> Optional[str]` | 純粋。`_PROJECT_ENV_RE` に当たれば group(1)、当たらなければ `None`。例外を投げない(メンバーの妥当性は `filter_members` が既に見ている) | +| `snapshot.SnapshotManager._validate_name` | `(name: str) -> None`(変更なし) | `is_single_segment_name(name)` が False なら `SnapshotError`。文言は今と同じ | + +## 入出力の契約: 警告の文 + +```text +プロジェクト名として使えない形の名前が projects/ に載ります: '_foo'(出所: プラグイン p1)。 +この名前では、名前を指定した操作(devbase up _foo など)ができません(NAME_FORM_HINT)。 +projects/_foo の中で名前なしに打てば動きます。<出所ごとの案内> +``` + +**`<出所ごとの案内>` は出所で分かれる。** 名前を作っている場所が違うため、同じ案内が +当てはまらない。 + +| 出所 | 案内 | +| --- | --- | +| プラグインのプロジェクト(出所はプラグイン名) | プラグイン側の `projects/<名前>` を改名する | +| 別名(devbase が合成した `<名前>.`)で、`<名前>` の側が形に合わない | プラグイン側の `projects/<名前>` を改名する。**同じ実行で `<名前>` 自身の知らせも出る**(winner の symlink の分) | +| 別名で、`<名前>` は形に合う(= `` の側が原因) | `` は devbase が合成する部分である。`--link` なら元パスの basename、`repos/` 由来ならその置き場のディレクトリ名を変える。**プラグイン側の `projects/` を改名しても直らない** | +| `projects/` 直下の実ディレクトリ | その実ディレクトリ自身を改名する(プラグインは関係しない) | + +**別名のどちらが原因かは `is_single_segment_name(<名前>)` で分かれる。** False なら `<名前>` の側、 +True なら `` の側である(別名が形に合わないのに `<名前>` が合うなら、合わない文字は +`` にしかない)。**原因を断定しない文にはしない。** 片方だけを直せば済む利用者に、 +効く手段を否定して伝えることになる。 + +- **1 件 1 回。** 名前 1 つにつき 1 行だけ出す。**そのために、プラグインのプロジェクトの検査は + `sync_projects` が symlink を張る直前に置く**(決定 2)。候補の集約 + (`_collect_project_candidates`)に置くと、同じ名前を 2 つのプラグインが持つときに + プラグインの数だけ出て、実ディレクトリでスキップする名前にも出る +- **起きていないことを書かない。** 知らせは symlink を張る直前と、書き出しの計画を立てた + 直後(`_build_plans` の後)に出す。文は完了形にせず、`--dry-run` や後続の失敗で書き込みが + 起きなくても矛盾しない形にする +- **`verbose` に依存させない。** `sync_projects(verbose=False)` は数を数える用途で使われる + (`tests/plugin/test_repos_core.py` と `updater` の差分計算)。弾かない代わりに知らせるのが + 唯一の効果なので、黙る経路を作らない +- **import の文は保存先で 2 つに分かれる**(決定 4)。出所はどちらも「書庫」である + +| 保存先(`plan.target` / `plan.ref`) | 文 | +| --- | --- | +| `projects/<名前>/.env`(ファイル backend の平文) | この import が `projects/_foo/` を作ることと、`projects/_foo` の中で名前なしに打てば動くこと、`projects/_foo` を改名すれば直ることを書く | +| それ以外(age の `secrets/projects/<名前>.env.age`・サーバ backend) | 保存先をそのまま名指しし、**`projects/` には何も作られない**ことを書く。この名前でプロジェクトを作っても名前を指定した操作ができないことを添える。改名の案内は書かない(改名する対象が `projects/` に無い) | + +## 確定仕様の書き換え + +`docs/specifications/cli-argument-resolution.md` の「運用」の 1 つ目と 2 つ目の箇条書きを +書き換える。**他の節(「他のショートカット」など)は触らない**(#208 の調査で直す対象では +ないと確認済み)。 + +| 今の記述 | 書き換え後 | +| --- | --- | +| 名前の形に合わないプロジェクト(`_` で始まる名前など)は、名前の指定(CLI の `[name]` と `devbase list` の一覧)から操作できない。そのディレクトリの中で名前なしに打てば動く | 同じ内容に加えて、**名前の形に合わない名前が `projects/` に載った時点で知らせが出る**ことと、その 4 つの出所(プラグインの同期の symlink・同期が合成する別名・`env import` が作る実ディレクトリ・手で作った実ディレクトリ)を書く。**知らせは出すが弾かない**(同期と import は今と同じものを作る)ことを明記する | +| 名前の検証はリポジトリの中で 1 つに寄せていない。`env/bundle.py` の `is_valid_project_name`…、`env/secret_store.py` の `_validate_project_name`…、`snapshot/manager.py` の `_VALID_NAME_RE`(スナップショットの名前)はそれぞれ別の用途と互換性を持つ | 寄せていないのは `env/bundle.py` と `env/secret_store.py` の **2 つ**にする。`snapshot/manager.py` は `utils/names` の述語を共有する側へ移し、共有してよい理由(文字集合が同じで、受け付ける名前が広がらない)と、広がらないことを固定するテストの在り処を書く | + +## 決定の記録 + +### 決定 1: 名前の形を検査するが、弾かずに警告に留める + +プラグインの同期は、名前の形に合わない名前でも**今と同じく symlink を張る**。`env import` も +**今と同じく `projects//` を作る**。違いは `logger.warning` の 1 行だけである。 + +理由: + +- **弾くと、今ある唯一の使い方が消える。** 確定仕様の「運用」は「そのディレクトリの中で + 名前なしに打てば動く」と書いている。symlink を張らなければ、そのプロジェクトは + `$DEVBASE_ROOT/projects/` の下に現れない。`env/runtime.py:119` はプロジェクトを + `projects/` からの相対パスで決めるため、**プラグインのクローンの中へ cd しても + プロジェクトとして扱われない**。名前を指定できないだけの状態から、どこからも操作 + できない状態へ悪化する +- **利用者はプラグインの持ち主でないことがある。** 名前を直せるのはプラグインの作者だけで、 + 手元の `repos/` のディレクトリを改名すると `plugin update` が競合する。弾く実装は、 + 利用者に手段のない失敗を渡す +- **同期の全体を失敗させると被害が広がる。** `devbase plugin install` / `update` / `sync` は + 1 回で全プラグインを同期する。1 件の名前で失敗させると、同じプラグインの他のプロジェクトも + `projects/` から消える(同期は先に既存の symlink を全部消してから張り直す。 + `syncer.py:155-157`)。この端末では 133 件が一度に消える +- **「実在しないから安全」は弾く根拠にならない。** 実測では 3 リポジトリ・22 プラグイン・ + 133 件すべてが名前の形に合う。弾いても**今日は**誰も困らない。だが実在しないということは + 「弾いて得られる利益も今日は無い」ことでもあり、天秤は「失う手段」の側に傾く +- **知らせるだけで目的は達する。** 困るのは「`devbase list` に出るのに `devbase up` が + 通らない理由が分からない」ことである。載った時点で理由と回避手段を渡せば、その困りは消える + +採らなかった案: + +| 採らなかった形 | 内容 | 退けた理由 | +| --- | --- | --- | +| 同期の全体を失敗させる | 名前の形に合わない名前を見つけたら例外を投げ、`install` / `update` / `sync` を 0 以外で終わらせる | 1 件の名前でそのプラグインの全プロジェクトが `projects/` から消える。利用者に直す手段が無い | +| その 1 件だけ載せない(skip) | symlink を張らず、警告だけ出す | 名前なしに打つ手段まで失う(上の 1 点目)。`devbase list` から黙って消える | +| 名前を整えて載せる(sanitize) | `_foo` を `foo` などへ直して載せる | devbase が名前を発明することになる。既存の `foo` と衝突し、どちらが `projects/foo` を取るかが同期の順で決まる | +| 名前の形を広げて `_` を許す | `SINGLE_SEGMENT_NAME_PATTERN` の先頭に `_` を足す | 受け付ける名前を広げる変更で、確定仕様が「寄せると受け付ける名前が変わる範囲が広がる」ことを理由に退けた向きと同じ。#203 も規則の変更を求めていない | + +### 決定 2: 検査は `projects/` に名前を載せる直前に置き、列挙と集約には置かない + +検査を置くのは次の 3 か所である。`discover_projects` と `_collect_project_candidates` の +中には置かない。 + +| 置く場所 | 見る名前 | いつ | +| --- | --- | --- | +| `sync_projects` の winner の分岐 | プラグインのプロジェクト | `real_projects` のスキップより後、symlink を張る直前 | +| `_link_loser_projects` | 合成する別名 | 別名の symlink を張る直前 | +| `sync_projects` の `real_projects` | `projects/` 直下の実ディレクトリ | 採取した直後 | + +理由: + +- **`discover_projects` は「表示のための列挙」にも使われる。** `plugin/updater.py:26,54,121` が + 更新前後の差分を出すために呼ぶ。ここで警告を出すと、`projects/` に何も載せない場面で + 同じ行が何度も出る +- **候補の集約(`_collect_project_candidates`)も早すぎる。** 集約は + プラグイン 1 つにつき 1 回回るため、同じ名前を 2 つのプラグインが持つと**同じ名前で 2 行** + 出る。さらに集約は `real_projects` のスキップより前にあるため、**`projects/` に載らない + 名前にも警告が出る**(実ディレクトリが勝つ場合)。`sync_projects` の winner の分岐へ置くと、 + 実際に載る名前 1 つにつき 1 行になる +- **「載せる直前」は出所も持っている。** winner の分岐は `winner_plugin.name` を持つため、 + 警告の「出所」を落とさずに書ける +- **別名は devbase 自身が作る名前である。** `_extract_owner` は `--link` のプラグインで + 元パスの basename をそのまま返す。実測では、元パスが `/Users/x/my plugin` のとき + devbase が `carmo.my plugin` という名前の symlink を張った。プラグイン側の名前が + 正しくても、合成の結果が形に合わないことがある。**プラグインの名前だけを見る検査では + 拾えない** +- **実ディレクトリは devbase が作っていないが、同期が唯一それを列挙する場所である。** + 手で `mkdir projects/_foo` した場合と、`env import` が作った場合の両方が + `real_projects` に入る。同期のたびに知らせが出る + +採らなかった案: `discover_projects` の中で `.` 始まりと同じように除外する。決定 1 で +退けた「skip」と同じ形になるため採らない。 + +### 決定 3: 下流の検証は残す。責務は「移動」ではなく「前倒し」 + +下流の 3 つの検証は消さない。`bin/devbase` の `maybe_cd_project`、 +`cli._named_lifecycle_project`、`container._resolve_project_name` である。#203 が「移動 +(`move_responsibility`)」と書いているが、この設計では入口に検査を**足す**だけにする。 + +理由: + +- **下流が防いでいるのは別の入力である。** 下流へ来る値は `projects/` の一覧からではなく、 + 利用者が打った引数から来る。`devbase up ../etc` は `projects/` に載っていない値で、 + 入口の検査では防げない。PLAN61(#146)はこの検証をパストラバーサル防止として置いた +- **入口の検査は弾かない(決定 1)。** 弾かない検査へ責務を移すと、防ぐものが無くなる + +採らなかった案: 下流の検証を緩め、入口だけで守る。確定仕様の「テスト観点」が +`../etc`・`a/b`・`.`・`..` を弾くことを固定しており、この決定を覆す要求は #203 に無い。 + +### 決定 4: `env import` の知らせは保存先ごとに文を変え、書庫の名前の規則は変えない + +`devbase env import` は書庫の中の名前の規則(`env/bundle.py` の +`_VALID_PROJECT_NAME_RE`。先頭の `_` を許す)をそのまま使い、`_foo` を含む書庫を今と同じく +import する。**足すのは知らせだけである。** + +理由: + +- **実測で、これが `_foo` が生まれる実際の経路だった。** #203 の本文は「`syncer` が + `projects/` に名前を載せる唯一の入口」と書いている。隔離した root での実験では、 + `env import` が `projects/_foo/` を実ディレクトリとして作り、終了コード 0 で何も + 言わなかった。同期だけを直しても、名前の指定から操作できないプロジェクトは生まれる +- **弾けない。** 確定仕様は、書庫の中の名前が先頭の `_` を許すことを互換性のために + 保つと決めている。import で弾くと、過去に export した書庫が import できなくなる +- **知らせる相手が正しい。** 書庫を作った端末では `_foo` が動いていたとは限らない + (export 側の `_should_skip_project` は `_foo` を通す)。import した端末で初めて + 「名前で操作できないプロジェクトがある」状態になるため、import の時点が知らせる時点である + +**知らせる文は保存先で分かれる。** `io_import._build_plans` は書き出し先を +`store.path(ref)` で決める。**`projects/<名前>/.env` になるのはファイル backend の平文の +ときだけ**である。age の backend では `secrets/projects/<名前>.env.age`、サーバの backend では +`plan.ref` を立ててサーバへ書く。どちらも `projects/` には何も作らないため、 +「`projects/_foo/` を作ります」と書くと事実と違う。文は `plan.target` と `plan.ref` から +選ぶ(上の「警告の文」の表)。 + +**保存先が `projects/` の外でも知らせる。** その名前で機密を保存したこと自体は残り、後で +同じ名前のプロジェクトを作っても名前を指定した操作ができない。ただし改名の案内は出さない +(`projects/` に改名する対象が無い)。 + +採らなかった案: + +| 採らなかった形 | 退けた理由 | +| --- | --- | +| import では何もせず、次の `plugin sync` の知らせに任せる | `env import` の後に `plugin sync` を打つ決まりは無い。実ディレクトリの知らせは同期のたびに出るが、import の直後には出ない | +| 保存先を見ずに 1 つの文で済ませる | `projects/` に何も作らない保存先(age・サーバ backend)で、作ったと書くことになる | +| 保存先が `projects/` の外なら黙る | その名前の機密は残る。後で同じ名前のプロジェクトを作ったときに、名前を指定した操作ができない理由が分からない | +| import で弾く | 上の 2 点目。確定仕様の互換性の決定を覆す | +| `secret_store` の書き込み側(`secret_store.py:199`)でも知らせる | `env/secret_store.py` は G4 の束(#188)が触る。重なりを避ける。import の経路を通る名前は決定 4 の知らせで拾える(残る経路は「未確認のまま残ること」へ記録した) | + +### 決定 5: スナップショットの名前は `utils/names` の述語を共有する + +`snapshot/manager.py` の `_VALID_NAME_RE` を消し、`_validate_name` が +`is_single_segment_name` を呼ぶ。例外の型・文言・`_safe_snap_dir` の封じ込め +(`resolve()` + `startswith`)は変えない。 + +理由: + +- **確定仕様が「寄せない」とした理由が、この 2 つには当たらない。** 理由は「寄せると + 受け付ける名前が変わる範囲が広がる」ことだった。この 2 つは文字集合が同じで、 + 寄せても**広がらない** +- **狭まる 1 ケースは直す価値がある。** 実測では `_VALID_NAME_RE.match("abc\n")` が True、 + `is_single_segment_name("abc\n")` が False だった。`re.match` と `$` の組み合わせが + 末尾の改行の前でも一致するためである。末尾に改行を持つスナップショット名が通る状態は、 + 意図した仕様ではない +- **二重持ちは黙って育つ。** `_VALID_NAME_RE` には専用のテストが無い + (`grep -rn "無効なスナップショット名" tests` が 0 件)。片方だけが直る状態が続く + +採らなかった案: + +| 採らなかった形 | 退けた理由 | +| --- | --- | +| 寄せず、2 つが同じであることを固定するテストだけを足す | `bin/devbase` の同期テストと同じ形。shell と Python のように言語が違えば要るが、同じ言語で同じ module から import できる場所に 2 つ置く理由が無い | +| `SnapshotError` の文言も `NAME_FORM_HINT` へ寄せる | スナップショット名の説明(「英数字・ハイフン・アンダースコア・ドットのみ使用可能、先頭は英数字」)は利用者向けの既存の文で、変えると利用者の検索が当たらなくなる。文字の規則だけを共有する | +| 逆向きに寄せる(`utils/names` が `snapshot` の定数を使う) | `utils/names` は確定仕様が定めた規則の置き場で、`snapshot` は利用者の 1 機能である。依存の向きが逆になる | + +### 決定 6: 共有した述語が将来広がらないよう、スナップショット側で受理と拒否を固定する + +`tests/snapshot/test_manager_name.py` に、`_validate_name` の受理と拒否を固定するテストを +置く。通す名前は `ok-name`・`a.b`・`A_b`・`0abc` の 4 件である。弾く名前は `_foo`・`.x`・`-x`・ +空・`..`・`café`・`a/b`・`abc\n` の 8 件で、受け入れ条件 11 の列挙と同じ集合にする。 + +理由: 決定 5 で `utils/names` の述語を共有したため、**将来この述語を広げると +スナップショット名も黙って広がる**。#203 が求めるのと逆向きの変更(名前の形に `_` を許す)が +将来採られたとき、このテストが落ちてスナップショット名を広げるかどうかを改めて決められる。 + +採らなかった案: `utils/names` 側のテストだけで足りるとする。落ちる場所が +`tests/utils/test_names.py` になり、スナップショット名への影響が読み取れない。 + +### 決定 7: 知らせの文言の定数は `utils/names.py` に置き、ログはそこから出さない + +`NAME_FORM_HINT` を `utils/names.py` の定数として足す。`logger` はこの module へ持ち込まない。 + +**共通化するのは名前の形の説明(ヒント文)だけである。** 出所と対象の名前を含む文の +組み立ては、同期と import で別々に行う(文脈が違うため。上の「警告の文」)。 + +理由: 確定仕様の「構成要素」が `lib/devbase/utils/names.py` を「`re` だけに依存し副作用を +持たない」と定めている。ログの出力は副作用である。説明を 1 か所に置く要求と、副作用を +持たない要求は、定数だけを置いて呼び出し側が `logger.warning` を出すことで両立する。 + +採らなかった案: + +| 採らなかった形 | 退けた理由 | +| --- | --- | +| `utils/names.py` に `warn_if_unusable(name)` を置く | 上の契約を破る。確定仕様の書き換えが要る | +| 名前の形の説明(ヒント文)まで呼び出し側へ別々に書く | 同じ説明が 2 か所で別々に育つ。#229 で起票した既存の重複と同じ形を増やす | + +### 決定 8: `discover_projects` の `.` 始まりの黙った除外は変えない + +`.` で始まるディレクトリは今と同じく黙って除外し、名前の形の警告も出さない。 + +理由: `.` 始まりのディレクトリは `projects/` に載らないため、「名前の指定から操作できない +プロジェクト」を作らない。除外は意図された動きで、`.DS_Store` のような持ち込み物を +プロジェクトとして扱わないためにある。`plugin info` との食い違い(#226)は表示だけの問題で、 +この設計の対象ではない。 + +### 決定 9: 実装は 2 本の Pull Request に分ける + +1 本目が知らせ(F1・F2)、2 本目がスナップショットの寄せ(F3)である。 + +理由: 2 つは独立した振る舞いで、片方が戻されても他方は残せる。どちらも `docs/specifications/cli-argument-resolution.md` の「運用」を触る。**触る箇条書きは +別**で、1 本目が 1 つ目、2 本目が 2 つ目である。同じファイルのため、2 本目は 1 本目の +マージを待つ。 + +採らなかった案: 1 本にまとめる。スナップショット名が狭まる変更(利用者の入力を弾く向き)と、 +知らせを足す変更(何も弾かない)を 1 つの revert の単位に入れることになる。 + +## 実装の分け方(決定 9) + +| # | 名前 | 含むもの | 依存 | +| --- | --- | --- | --- | +| 1 | 知らせ(F1・F2) | `utils/names.py`(定数)・`plugin/syncer.py`・`env/_import_merge.py`・`env/io_import.py`・`tests/plugin/test_repos_core.py`・`tests/env/test_io_import.py`・`tests/cli/test_env_bundle_backend.py`・確定仕様の「運用」の 1 つ目・`CHANGELOG.md` | 無し | +| 2 | スナップショットの寄せ(F3) | `snapshot/manager.py`・`tests/snapshot/test_manager_name.py`(新設)・確定仕様の「運用」の 2 つ目・`CHANGELOG.md` | Pull Request 1 の**マージ**(同じファイルの別の箇条書き) | + +## テスト設計 + +| 受け入れ条件 | 何で確かめるか | +| --- | --- | +| 1(同期が張り、警告が 1 回出る) | `tests/plugin/test_repos_core.py` の既存の `TestSyncProjects` へテストを足す。`_make_repo_dir` で `projects: ["_foo", "ok-name"]` のプラグインを作り、`sync_projects(registry, verbose=False)` の戻り値が 2、両方の symlink が存在、`caplog` の WARNING に `_foo` が 1 件・`ok-name` が 0 件 | +| 2(合う名前では警告が出ない) | 同上。`projects: ["ok-name"]` で `caplog` の WARNING に名前の形の行が 0 件 | +| 3(別名の警告・`` の側) | 同上。既存の `test_link_plugin_collision_uses_source_basename` の形を借り、`--link` のプラグインの `source` を `/tmp/my plugin` にして、別名の symlink が張られ、`carmo.my plugin` の警告が 1 件。案内が `` の側を指すこと | +| 3-2(別名の警告・`<名前>` の側) | 同上で、衝突する名前を `_foo` にする。警告は 2 件(`_foo` と `_foo.`)で、別名の案内がプラグイン側の `projects/_foo` を指すこと | +| 4(実ディレクトリの警告) | 同上。`devbase_root / "projects" / "_foo"` を `mkdir` してから `sync_projects`。ディレクトリが残り、警告が 1 件 | +| 5(`.` 始まりは変わらない) | 同上。プラグインの `projects/.hidden` を作り、`projects/.hidden` が作られず、警告が 0 件 | +| 6(import が作り、警告が出る) | 既存の `tests/env/test_io_import.py` へテストを足す。`bundle.pack` で `env/projects/_foo/.env` と `env/projects/ok-name/.env` の書庫を作り、`import_bundle(tmp_path, ImportOptions(source=..., include_global=False, include_metadata=False))` が 0 を返し、両方のディレクトリができ、`caplog` に `_foo` の警告が 1 件 | +| 7(`--dry-run` でも警告) | 同上。`dry_run=True` で `projects/` に何も作られず、警告が 1 件 | +| 8(合う名前では警告が出ない) | 同上。`ok-name` だけの書庫で警告が 0 件 | +| 9(age の保存先では文が変わる) | 既存の `tests/cli/test_env_bundle_backend.py` へテストを足す。**`bc.save(root, bc.BackendConfig(backend='age'))` で backend を明示する**(既存の `test_import_into_an_explicit_age_backend_encrypts_new_references` と同じ形)。明示しないと保存先が無い参照は平文へ落ち、`projects/_foo/` ができてこの条件を確かめられない。`projects/_foo/` ができず `secrets/projects/_foo.env.age` が書かれ、警告が 1 件でその文に保存先が入り `projects/` を作るとは書かないこと | +| 10(末尾の改行を弾く) | `tests/snapshot/test_manager_name.py`(新設)。`SnapshotManager(tmp_path)._validate_name("abc\n")` が `SnapshotError`(`tmp_path` を渡す流儀は `tests/snapshot/test_auto_snapshot.py:74` と同じ) | +| 11(受理と拒否の固定) | 同上。`pytest.mark.parametrize` で受理 4 件・拒否 8 件(拒否は `abc\n` を含む。受け入れ条件 11 の列挙と同じ集合)。拒否の文言が `無効なスナップショット名` を含む | +| 12(定数が残っていない) | `grep -n "_VALID_NAME_RE" lib/devbase/snapshot/manager.py` を Pull Request 2 の本文の実測へ載せる | +| 13・14(確定仕様と CHANGELOG) | 実装 Pull Request の差分(レビューで見る) | +| 15・16(退行しない) | 既存の `TestSyncProjects`(7 件)・`tests/env/test_io_import.py`・`test_import_merge.py`・`test_store_roundtrip.py`・`tests/cli/test_env_bundle_backend.py` を**変更せずに**通す(足すテストは新しい関数として書く) | +| 17(全体) | `uv run --locked pytest -q tests/` を手元で実行し、結果を Pull Request 本文へ載せる(`release/v3.7.0` を base にすると CI が動かない。#216) | + +テストの流儀: + +- **`DEVBASE_ROOT` に依存しない。** 同期は `PluginRegistry(tmp_path)`、import は + `import_bundle(tmp_path, ...)` を使う。どちらも root を引数で受ける経路である。 + `tests/plugin/test_repos_core.py` の既存の fixture(`devbase_root` = `tmp_path`)が + この形である。pytest が実環境の `DEVBASE_ROOT` を継承する問題(#209 / PR #217)の + 影響を受けない +- **警告は `caplog` で見る。** 既存の `test_missing_plugin_dir_warns` は警告の中身を + 見ていないが、新しいテストは件数と名前を見る + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 他の端末のプラグイン | 名前の形に合わないプロジェクトが実在しないことは、この端末の 3 リポジトリ・22 プラグイン・133 件で確かめた値である。社内の private レジストリの他のプラグインや、他の利用者が `--link` で入れたものは数えられない。決定 1(弾かない)はこの不確かさに耐える | +| `secret_store` の直接の書き込み | `env/secret_store.py:199` は平文モードで `projects//.env` を書き、その名前は `_validate_project_name`(先頭の `_` を許す)だけを通る。`env import` を経る経路は決定 4 の知らせで拾えるが、他の呼び出し元から来た名前は拾えない。G4 の束(#188)が同じファイルを触るため、この設計では触らない | +| 警告が出る回数 | 同期は `install` / `update` / `sync` のたびに走るため、名前を直さない利用者には毎回同じ行が出る。抑止(1 日 1 回など)は持たない。うるさければ名前を直す動機になる、という前提を置いている | +| `logger.warning` の宛先 | `devbase.log` の設定(標準エラー・色)に従う。パイプへ流している利用者には見えることを実機で確かめていない(リリース後テストで見る) | diff --git a/issues/PLAN66_project-name-validation.md b/issues/PLAN66_project-name-validation.md new file mode 100644 index 00000000..dbb019a8 --- /dev/null +++ b/issues/PLAN66_project-name-validation.md @@ -0,0 +1,326 @@ +# PLAN66: 名前の形に合わないプロジェクトを、作られた時点で知らせる + +対象 issue: devbasex/devbase#203 + +- ワークフローモード: `standard` + - 根拠: プラグインの同期と `env import` に検査を足すのは本番の振る舞いの追加である。 + `devbase plugin install` / `update` / `sync` と `devbase env import` の出力が変わる +- release Pull Request: #212(base は `release/v3.7.0`) + +## 依頼(原文) + +issue #203 より: + +> ## 何を見つけたか +> +> プロジェクト名を検証する規則が、場所ごとに違います。 +> +> | 場所 | 規則 | 用途 | +> | --- | --- | --- | +> | `lib/devbase/env/bundle.py` の `is_valid_project_name`(`_VALID_PROJECT_NAME_RE`) | `^[A-Za-z0-9_][A-Za-z0-9_.\-]*$`。先頭に `_` を許す | env の export / import の書庫の中の名前 | +> | `lib/devbase/env/secret_store.py` の `_validate_project_name` | 空と、`Path(name).name` と一致しない値(区切り文字を含む)と、`.`・`..` だけを弾く | 機密の保存先のファイル名 | +> | `lib/devbase/utils/names.py` の `SINGLE_SEGMENT_NAME_PATTERN` | `[A-Za-z0-9][A-Za-z0-9._-]*`。先頭は英数字 | 名前の指定(`devbase up ` や TUI の一覧) | +> | `lib/devbase/snapshot/manager.py` の `_VALID_NAME_RE` | `^[a-zA-Z0-9][a-zA-Z0-9._-]*$` | スナップショットの名前 | +> +> 3 つ目と 4 つ目は文字集合が同じで、別々の定数として 2 重に持っています。 +> +> `_` 始まりのプロジェクトは、env の export / import はできますが、名前を指定した操作は +> できません。該当するプロジェクトは今のところ実在しません。 +> +> ## 寄せないことは確定仕様になっている +> +> (中略)したがって「3 つの規則を 1 つへ寄せるか」は決着済みです。残っているのは、 +> **名前の形に合わないプロジェクトが作られるのを防ぐかどうか**です。 +> +> ## 修正レイヤー +> +> **修正レイヤー**: 名前を作る側。`lib/devbase/plugin/syncer.py` の `discover_projects` / +> `sync_projects` が `projects/` に名前を載せる唯一の入口で、今は `.` 始まりを外すだけで +> 名前の形を検査していません。 +> +> (中略)検証の下流を揃えるのではなく、**生成の入口で弾くか警告するのが責務の場所**です。 +> 入口で防げば、4 つの規則が違うままでも、名前の指定から操作できないプロジェクトは +> 生まれません。 +> +> **採る手**: 移動(`move_responsibility`)。名前の形の責務を、使う側の検証から +> プラグインの同期へ移します。 +> +> ## 決めること +> +> - プラグインの同期(`discover_projects` / `sync_projects`)で名前の形を検査するか。 +> 弾くのか、警告に留めるのか +> - `utils/names.py` の `SINGLE_SEGMENT_NAME_PATTERN` と `snapshot/manager.py` の +> `_VALID_NAME_RE` は文字集合が同じである。この 2 つだけを 1 つへ寄せるか。確定仕様が +> 「寄せない」としたのは用途と互換性が違う 3 つについてで、この 2 つは同じ形をしている + +## 実測(本文の誤りを含む) + +この作業ツリー(`release/v3.7.0` の先頭 688efde)で数え直した。**本文の 2 つの記述が +事実と違う。** + +| # | 本文の記述 | 実測 | 手段 | +| --- | --- | --- | --- | +| 1 | 名前を検証する規則は「4 か所」 | プロジェクト名そのものは **6 か所**。`env/_import_merge.py` の `_PROJECT_ENV_RE`(書庫の中の `env/projects//.env` の名前)と、`bin/devbase` の `_SINGLE_SEGMENT_NAME_RE`(shell 版)と、`syncer.discover_projects` の `.` 始まりの除外が本文に無い。同じ文字集合の正規表現は他に `volume/manager.py` の `_GROUP_NAME_RE`(アカウントグループ名)がある | `grep -rn -E '\[A-Za-z0-9\]\[A-Za-z0-9\|\[a-zA-Z0-9\]\[a-zA-Z0-9' lib bin` ほか | +| 2 | `syncer` が `projects/` に名前を載せる**唯一の入口** | **違う。`devbase env import` が `projects//` を実ディレクトリとして作る。** 書庫に `env/projects/_foo/.env` が入っていると、隔離した root への import が終了コード 0 で `projects/_foo/` を作り、警告を 1 行も出さない | 下の「実験 1」 | +| 3 | 名前の形に合わないプロジェクトは「今のところ実在しない」 | **合っている。** 登録済み 3 リポジトリ・22 プラグインの `projects/` 直下 **133 件**すべてが名前の形に合う。`.` 始まり 0 件、先頭 `_` 0 件、ASCII 外 0 件。うち 3 件は未コミットの手元のディレクトリ(`predict_contract`・`car-pricing`・`carmo-screening`) | `plugins.yml` の 22 プラグインを辿って `re.fullmatch` で判定(読み取りのみ) | +| 4 | `SINGLE_SEGMENT_NAME_PATTERN` と `_VALID_NAME_RE` は「文字集合が同じ」 | 文字集合は同じだが**振る舞いが違う**。`_VALID_NAME_RE.match("abc\n")` は True(`re.match` + `$` は末尾の改行の前で一致する)、`is_single_segment_name("abc\n")` は False(`fullmatch`)。`env/bundle.py` の規則も同じ穴を持つ | 下の「実験 3」 | + +## 実験の記録 + +### 実験 1: `env import` が `projects/_foo/` を作る + +隔離した root で `import_bundle` を呼んだ。実環境の `DEVBASE_ROOT` は使っていない。 +書庫には `env/projects/_foo/.env` と `env/projects/ok-name/.env` を入れた。 + +``` +import rc = 0 +projects/ の中身: ['_foo', 'ok-name'] +_foo は実ディレクトリか: True symlink か: False +_foo/.env: A=1 +``` + +同じ root で名前の解決を呼ぶと、`devbase list` には出るが名前では操作できない。 + +``` +list_projects の名前: ['_foo', 'ok-name'] +プロジェクト名に使えない形です: '_foo'(英数字で始まり、英数字・'.'・'-'・'_' だけからなる名前) +_foo -> False +ok-name -> True +``` + +### 実験 2: 同期が作る別名も名前の形に合わないことがある + +`sync_projects` は名前の衝突に敗れた側へ `<プロジェクト名>.` の別名を張る。 +`owner` は `--link` のプラグインでは**元パスの basename そのまま**である +(`syncer._extract_owner`)。空白を含むパスから張ると、devbase 自身が名前の形に合わない +名前を作る。 + +``` +owner = 'my plugin' +生成される別名 = 'carmo.my plugin' 名前の形に合うか: False +作られた symlink の数: 1 +projects/ の中身: ['carmo.my plugin'] +repos 由来の owner = 'github.com--volareinc--devbase-ext' → 'carmo.github.com--volareinc--devbase-ext' 形に合うか: True +``` + +## 実験 3: 2 つの規則の振る舞いの差 + +``` +'_foo' single: False snapshot: False bundle: True +'sp ace' single: False snapshot: False bundle: False +'abc\n' single: False snapshot: True bundle: True ← 末尾の改行だけが食い違う +'abc\nx' single: False snapshot: False bundle: False +'a.b' single: True snapshot: True bundle: True +``` + +## 目的 + +- 名前の形に合わないプロジェクトが `projects/` に載った時点で、利用者がそれを知り、 + 何ができないか(名前を指定した操作)と何ができるか(そのディレクトリで名前なしに打つ)が + 分かる +- 名前の形の規則が 2 つの定数に分かれて別々に育たない + +## 前提 + +- 前提 1: **名前の形の規則そのものは変えない。** `[A-Za-z0-9][A-Za-z0-9._-]*` の全体一致 + (`docs/specifications/cli-argument-resolution.md` の「用語」)のまま。先頭の `_` を + 受け付けるようにはしない +- 前提 2: **`env/bundle.py` と `env/secret_store.py` の規則は寄せない。** 確定仕様の + 「運用」が用途と互換性の違いを理由に決めている。書庫の中の名前が先頭の `_` を許すことも + 変えない(過去に export した書庫を import できなくなる) +- 前提 3: **下流(名前の指定)の検証は消さない。** `../etc` のような値は `projects/` の + 一覧から来ず、利用者の打ち間違いとして直接渡る。入口の検査では防げない +- 前提 4: 名前の形に合わない名前を作る経路は 3 つ(プラグインの同期が張る symlink、同期が + 作る別名、`env import` が作る実ディレクトリ)と、手で作った実ディレクトリである。 + 手で作ったものは devbase が作っていないが、同期が既に列挙している(`real_projects`) +- 前提 5: 名前の形に合わないプロジェクトが実在しないことは、**この端末で確かめた値である** + (実測 3)。他の端末に実在しないことは確かめられない +- 前提 6: **`env import` が `projects//` を作るのは、機密の保存先がファイル backend の + 平文のときだけである。** 書き出し先は `io_import._build_plans` が `store.path(ref)` で + 決める。age の backend では `secrets/projects/.env.age`、サーバの backend では + サーバ側へ書く。知らせの文は保存先ごとに変える + +## 対象範囲 + +含む: + +- `lib/devbase/plugin/syncer.py`: 同期が `projects/` に載せる名前の形の検査と知らせ。 + 対象は `discover_projects` の結果・合成する別名・`projects/` 直下の実ディレクトリの 3 つ +- `lib/devbase/env/io_import.py` と `lib/devbase/env/_import_merge.py`: import が書き出す + 名前の知らせ。**保存先ごとに文を変える**(前提 6) +- `lib/devbase/utils/names.py`: 文言の定数を足す(述語は既にある) +- `lib/devbase/snapshot/manager.py`: `_VALID_NAME_RE` を `utils/names` の述語へ寄せる +- `docs/specifications/cli-argument-resolution.md` の「運用」の 2 つの箇条書き +- `CHANGELOG.md` + +含まない: + +- 名前の形の規則の変更(前提 1) +- `env/bundle.py`・`env/secret_store.py` の規則を寄せること(前提 2) +- 下流の 3 つの検証を消すこと・変えること(前提 3)。 + 対象は `bin/devbase` の `maybe_cd_project`、`cli._named_lifecycle_project`、 + `container._resolve_project_name` である +- `lib/devbase/commands/container.py` の既存の文言を新しい定数へ寄せること + (G5 の束が同じファイルを触る。次の束で寄せる → #229 で起票) +- `lib/devbase/plugin/info.py` が `.` 始まりを除外しない食い違い(→ #226 で起票) +- `env/bundle.py`・`env/_import_merge.py` が末尾の改行を通すこと(→ #227 で起票) +- `docs/plugin-dev/plugin-yml-reference.md` の別名の形の記述が実装と違うこと(→ #228 で起票) +- 名前の形に合わない名前を**弾く**こと(設計の決定 1 で退けた) +- 新しい型・永続データ・画面(そのためクラス図・ER 図・画面遷移図を作らない) + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 名前の形 | `[A-Za-z0-9][A-Za-z0-9._-]*` の全体一致(確定仕様の「用語」と同じ) | +| 名前を作る経路 | 名前を `projects/` の直下か機密の置き場に出現させる処理。同期の symlink・同期の別名・`env import` の書き出しの 3 つ(import の書き出し先は保存先で変わる。前提 6) | +| 別名 | 名前の衝突に敗れたプラグインのプロジェクトへ張る `<プロジェクト名>.` の symlink | +| 知らせ | `logger.warning` の 1 行。処理は止めない | + +## 受け入れ条件(同期) + +同期(プラグイン): + +- [ ] 1. 前提: プラグインの `projects/` に `_foo` と `ok-name` がある。 + 操作: `sync_projects(registry)` を呼ぶ(`devbase plugin sync` / `install` / `update` の経路)。 + 結果: 2 つの symlink が**どちらも張られ**、作られた数も今と同じ。`_foo` について警告が + 1 回だけ出る。警告は名前・名前を指定した操作ができないこと・そのディレクトリで名前なしに + 打てば動くことを含む。終了コードは 0。 + 検証: 既存の `tests/plugin/test_repos_core.py` の `TestSyncProjects` へテストを足す + (`caplog` で警告を見る)。 +- [ ] 2. 前提: プラグインの `projects/` の名前がすべて名前の形に合う。 + 操作: 同上。 + 結果: 名前の形についての警告が 1 行も出ない。 + 検証: 同上。 +- [ ] 3. 前提: 名前が衝突し、敗れた側が `--link` のプラグインである。元パスの basename は + `my plugin`(空白を含む)。元のプロジェクト名 `carmo` は名前の形に合う。 + 操作: 同上。 + 結果: 別名 `carmo.my plugin` の symlink は今と同じく張られる。その名前について警告が + 1 回出る。案内は `` の側(元パスの basename を変える)で、プラグイン側の + `projects/carmo` の改名を促さない。 + 検証: 同上。 +- [ ] 3-2. 前提: 3 と同じ衝突で、元のプロジェクト名が `_foo`(名前の形に合わない)。 + 操作: 同上。 + 結果: 警告は 2 行出る(`_foo` と `_foo.` の 2 つの名前について 1 行ずつ)。 + 別名の案内はプラグイン側の `projects/_foo` の改名で、`` の側を指さない。 + 検証: 同上。 +- [ ] 4. 前提: `projects/_foo` が実ディレクトリとして存在する(プラグイン由来ではない)。 + 操作: 同上。 + 結果: 実ディレクトリは今と同じく残る(symlink で上書きしない)。その名前について + 警告が 1 回出る。 + 検証: 同上。 +- [ ] 5. 前提: プラグインの `projects/` に `.hidden` がある。 + 操作: 同上。 + 結果: 今と同じく `projects/.hidden` は作られない。名前の形の警告も出ない + (`.` 始まりの除外は変えない)。 + 検証: 同上。 + +## 受け入れ条件(env import とスナップショット) + +`env import`: + +- [ ] 6. 前提: 書庫に `env/projects/_foo/.env` と `env/projects/ok-name/.env` が入っている。 + 操作: `devbase env import <書庫>`(`import_bundle`)を実行する。 + 結果: 今と同じく 2 つのディレクトリが作られ、終了コードは 0。`_foo` について警告が + 1 回出る。 + 検証: 既存の `tests/env/test_io_import.py` へテストを足す。 +- [ ] 7. 前提: 6 と同じ書庫。 + 操作: `--dry-run` を付けて実行する。 + 結果: 書き込みは起きない。`_foo` の警告は出る。 + 検証: 同上。 +- [ ] 8. 前提: 書庫の名前がすべて名前の形に合う。 + 操作: `devbase env import <書庫>` を実行する。 + 結果: 名前の形についての警告が 1 行も出ない。 + 検証: 同上。 +- [ ] 9. 前提: `backend_config` に `backend: age` を明示保存した root で、書庫に + `env/projects/_foo/.env` が入っている。 + 操作: `devbase env import <書庫>` を実行する。 + 結果: `projects/_foo/` は作られず、`secrets/projects/_foo.env.age` が書かれる(今と同じ)。 + 警告は 1 回出て、**実際の保存先を名指しし、`projects/` に作るとは書かない**。 + 検証: 既存の `tests/cli/test_env_bundle_backend.py` へテストを足す。 + backend の明示は `bc.save(root, bc.BackendConfig(backend='age'))` で行う。 + 既存の `test_import_into_an_explicit_age_backend_encrypts_new_references` と同じ形にする。 + +スナップショットの名前の規則: + +- [ ] 10. 操作: `SnapshotManager._validate_name` に `abc\n`(末尾に改行)を渡す。 + 結果: `SnapshotError` になる(今は通る)。 + 検証: `tests/snapshot/test_manager_name.py`(新設)。 +- [ ] 11. 操作: `_validate_name` に `ok-name`・`a.b`・`A_b`・`0abc` の 4 件を渡す。 + 結果: どれも例外にならない。`_foo`・`.x`・`-x`・空・`..`・`café`・`a/b`・`abc\n` の + 8 件は `SnapshotError` になる。文言(`無効なスナップショット名`)は変わらない。 + 検証: 同上。受理と拒否を固定するテストで、共有した述語を将来広げたときにここで落ちる。 +- [ ] 12. `lib/devbase/snapshot/manager.py` に `_VALID_NAME_RE` が残っていない。 + 検証: `grep -n "_VALID_NAME_RE" lib/devbase/snapshot/manager.py` が 0 件。 + +## 受け入れ条件(文書と退行しないこと) + +確定仕様と文書: + +- [ ] 13. `docs/specifications/cli-argument-resolution.md` の「運用」が次の 3 つを書いている。 + 検証はいずれも実装 Pull Request の差分で見る(設計 Pull Request には載せない)。 + - 名前の形に合わないプロジェクトが載った時点で知らせが出ること + - 寄せていない規則は `env/bundle.py` と `env/secret_store.py` の 2 つであること + - スナップショットの名前が `utils/names` の述語を共有すること +- [ ] 14. `CHANGELOG.md` の `[Unreleased]` に Added(知らせ)と Changed(スナップショットの + 名前が末尾の改行を受け付けなくなる)が載っている。 + 検証: 同上。 + +退行しないこと: + +- [ ] 15. 名前の形に合うプロジェクトだけのとき、`sync_projects` が返す数と `projects/` の + 中身が今と同じである。 + 検証: `tests/plugin/test_repos_core.py` の既存の `TestSyncProjects`(7 件)が変更なしで + 通る。 +- [ ] 16. 名前の形に合う書庫のとき、`env import` が書き出すファイルと終了コードが今と + 同じである。 + 検証: `tests/env/test_io_import.py`・`test_import_merge.py`・`test_store_roundtrip.py` の + 既存のテストが変更なしで通る。 +- [ ] 17. 全体テスト(`uv run --locked pytest -q tests/`)が通る。 + 検証: 実装 Pull Request で実行し、結果を本文へ載せる。 + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 運用・保守性 | 名前の形の判定は `utils/names.is_single_segment_name` だけを使い、新しい正規表現を作らない。知らせの文言は 1 か所の定数に置く | +| 移行性 | 既存の利用者に移行の作業を求めない(決定 1 で弾かないため)。名前の形に合わないプロジェクトを持つ利用者は、警告を見て名前を変えるかどうかを自分で決める | +| セキュリティ | 下流のパストラバーサル防止(前提 3)を弱めない。知らせに利用者の名前以外の情報を載せない | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わる(出力のみ): `devbase plugin install` / `update` / `sync` と `devbase env import` に警告が増えうる。終了コードと作られるものは変わらない。`devbase snapshot` の名前は末尾の改行を受け付けなくなる | +| データ | 変わらない(スキーマ・移行なし) | +| 既存の振る舞い | 同期と import の出力。スナップショット名の検証(末尾の改行 1 ケース) | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --locked pytest -q tests/` | +| 静的解析 | `ruff check --select=E9,F63,F7,F82 lib` | +| 手動確認 | 隔離した root でプラグインの同期と `env import` を実行し、警告の文と終了コードを見る(`DEVBASE_ROOT` を実環境へ向けない) | +| CI | **`release/v3.7.0` を base にした Pull Request では 1 件も動かない**(`.github/workflows/ci.yml` の対象が `main` だけ。#216)。検証は手元で行い、証跡を Pull Request 本文へ載せる | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | 名前の形の規則は `lib/devbase/utils/names.py` に 1 つ(確定仕様「構成要素」)。この module は `re` だけに依存し副作用を持たない(ログを足さない)。知らせは呼び出し側の logger が出す | +| コーディング規約 | 既存の logger(`devbase.plugin.syncer` / `devbase.env.io_import`)を使い、`logger.warning` は 1 件 1 行。文言は日本語 | +| テスト戦略 | 同期は `tests/plugin/test_repos_core.py` の既存の fixture(`devbase_root` = `tmp_path`、`registry`、`_make_repo_dir`)で単体。import は `tests/env/test_io_import.py` の流儀で、age の保存先は `tests/cli/test_env_bundle_backend.py` の流儀(`backend_config` に `backend: age` を明示保存する)。**pytest は実環境の `DEVBASE_ROOT` を継承する**(隔離は #217 が入れる)ため、どのテストも `DEVBASE_ROOT` に依存しない引数渡しの経路だけを使う | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 手元で全体テスト、`ruff` | +| 確認してから行う | 弾くか警告に留めるかの決定(設計 Pull Request のレビューで確かめる)、スナップショットの名前を狭めること | +| 行わない | 名前の形の規則の変更、下流の検証の削除、`env/bundle.py` と `secret_store.py` を寄せること | + +## 実装計画 + +設計は [PLAN66_project-name-validation-design.md](PLAN66_project-name-validation-design.md)。 +実装のタスクは設計の「実装の分け方」に従い、2 本の Pull Request に分ける。 From c75c1cc90f84bac286d99ba7f12276e2dbfa3f1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Tue, 22 Sep 2026 21:20:15 +0900 Subject: [PATCH 06/15] =?UTF-8?q?docs:=20[name]=20/=20--context=20?= =?UTF-8?q?=E3=82=92=E5=8F=96=E3=82=8B=E3=82=B5=E3=83=96=E3=82=B3=E3=83=9E?= =?UTF-8?q?=E3=83=B3=E3=83=89=E3=81=AE=E5=88=97=E6=8C=99=E3=82=92=E6=8F=83?= =?UTF-8?q?=E3=81=88=E3=80=81profiles=20=E3=81=AB=20requires.devbase=20?= =?UTF-8?q?=E3=81=AE=E6=89=8B=E9=A0=86=E3=82=92=E8=B6=B3=E3=81=99=20(#208,?= =?UTF-8?q?=20#195)=20(#213)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 文書の列挙を揃える作業の Draft Pull Request を開く Co-Authored-By: Claude Opus 5 (1M context) * docs: [name] / --context を取るサブコマンドの列挙を揃え、profiles に requires.devbase の手順を足す #208: argparse の集合を正本として、文書 8 か所の列挙に rebuild / open を足す。 - [name]: 02-project.md / cli-reference/README.md(mermaid・ショートカットの表)/ container-operations.md / specifications/compose-profiles.md - --context: 02-project.md / remote-docker-context.md の 2 か所(env token も)/ environment-variables.md - profile も [name] を取るが解決の経路が違うことを 1 行添える - cli-argument-resolution.md と DEVBASE_DOCKER_CONTEXT の行は正しいので触らない #195: profiles: を使う Plugin が plugin.yml の requires.devbase を ">=3.5.0" へ 上げる手順を plugin-dev/compose-profiles.md へ足し、上げる契機を 1 つに限定していた plugin-yml-reference.md 側を広げて相互リンクする。 Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- docs/plugin-dev/compose-profiles.md | 22 +++++++++++++++++++- docs/plugin-dev/plugin-yml-reference.md | 11 ++++++++-- docs/specifications/compose-profiles.md | 2 +- docs/specifications/remote-docker-context.md | 7 ++++--- docs/user/cli-reference/02-project.md | 11 ++++++++-- docs/user/cli-reference/README.md | 11 ++++++++-- docs/user/container-operations.md | 6 ++++-- docs/user/environment-variables.md | 4 ++-- 8 files changed, 59 insertions(+), 15 deletions(-) diff --git a/docs/plugin-dev/compose-profiles.md b/docs/plugin-dev/compose-profiles.md index 84b147ca..b531328f 100644 --- a/docs/plugin-dev/compose-profiles.md +++ b/docs/plugin-dev/compose-profiles.md @@ -7,7 +7,7 @@ dev のほかに app / db などのサービスを持つプロジェクトで、 | 項目 | 条件 | | --- | --- | | Docker Compose | **2.20.0 以上**。`depends_on` の `required` を使うため。動作を確かめたのは v5.1.4 | -| devbase | `devbase project profile` があるバージョン | +| devbase | **3.5.0 以上**。`devbase project profile` が入った版です(CHANGELOG の `[3.5.0]`)。Plugin として配るなら [`plugin.yml` の `requires.devbase` も上げます](#plugin-として配るなら-requiresdevbase-を-350-以上へ上げる) | ## 1. `compose.yml` の書き方 @@ -43,6 +43,26 @@ services: プロファイル名に `__devbase_none__` は使わないでください。devbase が「どのプロファイルも有効にしない」ために予約している名前です。 +### Plugin として配るなら `requires.devbase` を 3.5.0 以上へ上げる + +`profiles:` を使うプロジェクトを含む Plugin は、`plugin.yml` の `requires.devbase` を +`">=3.5.0"` へ上げてください。`compose.yml` を書き換えたのと同じ Pull Request で上げます。 + +```yaml +# /plugin.yml +requires: + devbase: ">=3.5.0" +``` + +3.5.0 未満の devbase では、`profiles:` を付けたサービスが `devbase up` の起動対象から外れたまま、 +後から起動する手段(`devbase project profile up`)もありません。テスト用サーバ群が黙って起動 +しない状態になります。 + +`requires.devbase` を上げておけば、新規の `devbase plugin install` はその場で拒否され、 +`devbase plugin update` では警告が出ます。仕組みは既にあるため、Plugin 側でやることは版数を +書くことだけです。書式(必ずクォートする)と検証の詳細は +[`plugin.yml` リファレンスの `requires`](plugin-yml-reference.md#requires) を参照してください。 + ## 2. コマンド ```bash diff --git a/docs/plugin-dev/plugin-yml-reference.md b/docs/plugin-dev/plugin-yml-reference.md index 94a39fd5..44f91aa0 100644 --- a/docs/plugin-dev/plugin-yml-reference.md +++ b/docs/plugin-dev/plugin-yml-reference.md @@ -169,8 +169,15 @@ WARNING プラグイン 'carmo-web' は devbase >=4.0.0 を要求しています devbase 本体を更新してください (この警告を止める場合は DEVBASE_IGNORE_PLUGIN_REQUIRES=1)。 ``` -> `requires.devbase` を上げるのは、**Plugin が `project.yml` 形式へ移行したタイミング**です。 -> 本体の版数と一緒に自動では上がりません。 +> **`requires.devbase` は本体の版数と一緒に自動では上がりません。** 上げるのは、**Plugin が +> devbase の新しい機能に依存しはじめたタイミング**です。契機は次のとおりです。 +> +> | 契機 | 上げる版数 | +> | --- | --- | +> | Plugin が `project.yml` 形式へ移行した | その形式を読める版 | +> | Plugin のプロジェクトが `compose.yml` に `profiles:` を使う | `">=3.5.0"`([テスト用サーバを後から起動・停止する](compose-profiles.md#plugin-として配るなら-requiresdevbase-を-350-以上へ上げる)) | +> +> どちらも、その機能を使い始めた Pull Request で一緒に上げます。 ### `priority` diff --git a/docs/specifications/compose-profiles.md b/docs/specifications/compose-profiles.md index b135ba5f..ed146e3f 100644 --- a/docs/specifications/compose-profiles.md +++ b/docs/specifications/compose-profiles.md @@ -193,7 +193,7 @@ subcommand より前に置く。`<サービス...>` はプロファイル X に `cli._dispatch` が `project profile list` を `project list`(プロジェクト一覧)へ流すためである - 前方一致の省略は `project p` / `container p` を従来どおり `ps` に解決し(`SUBCMD_PREFIX_PREFERENCES`)、 `project pr` は `profile` に解決する -- `bin/devbase` の `_PROJECT_NAME_SUBCOMMANDS`(`up down ps logs scale rebuild`)に `profile` は +- `bin/devbase` の `_PROJECT_NAME_SUBCOMMANDS`(`up down ps logs scale rebuild open`)に `profile` は 入れない。wrapper は 3 番目の引数をプロジェクト名として解決するが、`profile` ではそこに `up` / `down` / `list` が来るため、同名のプロジェクトが実在すると誤って移動する。名前の解決は Python 側の `_dispatch_lifecycle` が行う diff --git a/docs/specifications/remote-docker-context.md b/docs/specifications/remote-docker-context.md index d0f6a1e1..f428dfce 100644 --- a/docs/specifications/remote-docker-context.md +++ b/docs/specifications/remote-docker-context.md @@ -4,7 +4,7 @@ devbase は、プロジェクトごとの個人設定 `projects//project.local.yml` に docker context の 名前を書くと、そのプロジェクトの `up` / `down` / `ps` / `logs` / `login` / `scale` / `build` / -`rebuild` を別ホストの docker daemon へ向ける。compose クライアントと機密の復号は手元で行い、 +`rebuild` / `open` を別ホストの docker daemon へ向ける。compose クライアントと機密の復号は手元で行い、 daemon だけがリモートにある。リモートに要るのは docker CLI・dockerd・sshd で、devbase・ `projects/`・機密鍵をリモートへ複製しない。`devbase up` が開く VS Code は、attach URI の `settings.context` でそのホストのコンテナへ接続する。 @@ -40,8 +40,9 @@ daemon だけがリモートにある。リモートに要るのは docker CLI この段階では docker を呼ばない。 `--context` は `project` / `container` 配下の `up` / `down` / `ps` / `logs` / `login` / `scale` / -`build` / `rebuild`、トップレベルのショートカット `up` / `down` / `ps` / `login` / `scale` / -`build` / `rebuild`、および `env exec` が受け付ける。空文字と空白のみは終了コード 2 で拒む +`build` / `rebuild` / `open` / `profile up` / `profile down` / `profile list`、トップレベルの +ショートカット `up` / `down` / `ps` / `login` / `scale` / `build` / `rebuild` / `open`、および +`env exec` / `env token` が受け付ける。空文字と空白のみは終了コード 2 で拒む (Python の parser と `bin/devbase` の両方)。 ### リモート扱いの判定 diff --git a/docs/user/cli-reference/02-project.md b/docs/user/cli-reference/02-project.md index 126e7f81..9b1f44d5 100644 --- a/docs/user/cli-reference/02-project.md +++ b/docs/user/cli-reference/02-project.md @@ -6,7 +6,7 @@ ## プロジェクト名指定(CWD 非依存) -`up` / `down` / `ps` / `logs` / `scale` は省略可能な `[name]` 引数を取ります。`[name]` +`up` / `down` / `ps` / `logs` / `scale` / `rebuild` / `open` は省略可能な `[name]` 引数を取ります。`[name]` を指定すると、**現在のディレクトリに依存せず** `$DEVBASE_ROOT/projects/` を対象に 操作できます。 @@ -18,6 +18,13 @@ devbase project up adminer cd $DEVBASE_ROOT/projects/adminer && devbase project up ``` +> **`profile` も `[name]` を取りますが、解決の経路が違います。** `project profile up` / +> `profile down` / `profile list` は `[name]` を受け付けますが、`bin/devbase` の +> `_PROJECT_NAME_SUBCOMMANDS`(`up` / `down` / `ps` / `logs` / `scale` / `rebuild` / `open`) +> には入っていません。`profile` では 3 番目の引数に `up` / `down` / `list` が来るため、 +> ラッパーでは位置で名前を解決できないからです。名前の解決は Python 側の +> `_dispatch_lifecycle` が行います。 + - `` は `$DEVBASE_ROOT/projects/` 配下のプロジェクト名(`devbase project list` で確認可能) - 名前として受け付ける形は、英数字で始まり英数字・`.`・`-`・`_` だけからなる文字列です (`carmo`、`github_work_time`、`carmo-ai`、`carmo.takemi`)。`../etc` や `a/b` のように @@ -57,7 +64,7 @@ cd $DEVBASE_ROOT/projects/adminer && devbase project up ## `--context NAME`(共通オプション) -`up` / `down` / `ps` / `logs` / `login` / `scale` / `build` / `rebuild` / `profile`(`project` / +`up` / `down` / `ps` / `logs` / `login` / `scale` / `build` / `rebuild` / `open` / `profile`(`project` / `container` 配下と、トップレベルのショートカット)は `--context NAME` を受け付けます。 そのコマンドの `docker` / `docker compose` を、指定した docker context の daemon へ向けます。 diff --git a/docs/user/cli-reference/README.md b/docs/user/cli-reference/README.md index 5e88b450..0dfe1b74 100644 --- a/docs/user/cli-reference/README.md +++ b/docs/user/cli-reference/README.md @@ -5,7 +5,7 @@ devbase の全コマンドの構文、オプション、使用例をまとめた | ファイル | 内容 | |---------|------| | [トップレベルコマンド](01-toplevel.md) | `init` / `status` / `bin/rc` | -| [project グループ](02-project.md) | コンテナのライフサイクル管理・一覧(`up` / `down` / `login` / `ps` / `logs` / `scale` / `build` / `rebuild` / `list`)と非推奨の `container` グループ | +| [project グループ](02-project.md) | コンテナのライフサイクル管理・一覧(`up` / `down` / `login` / `ps` / `logs` / `scale` / `build` / `rebuild` / `open` / `profile` / `list`)と非推奨の `container` グループ | | [env グループ](03-env.md) | 環境変数の管理(`init` / `sync` / `list` / `set` / `get` / `delete` / `edit` / `project` / `keygen` / `encrypt` / `decrypt` / `exec` / `token` / `rekey` / `doctor` / `export` / `import`) | | [plugin グループ](04-plugin.md) | プラグインの管理(`list` / `install` / `uninstall` / `update` / `info` / `sync` / `migrate` / `repo *`) | | [snapshot グループ](05-snapshot.md) | スナップショットの管理(`create` / `list` / `restore` / `copy` / `delete` / `rotate`) | @@ -22,9 +22,10 @@ graph TD A --> E[env] A --> F[plugin / pl] A --> G[snapshot / ss] - D --> D1["up / down / ps / logs / scale [name]"] + D --> D1["up / down / ps / logs / scale / open [name]"] D --> D3["login [index]"] D --> D4["build [image] / rebuild [name]"] + D --> D5["profile up / down / list [name]"] D --> D2["list [--no-interactive]"] E --> E1[init / sync / list / set / get / delete / edit / project] E --> E2[keygen / encrypt / decrypt / exec / token / rekey / doctor] @@ -61,10 +62,16 @@ graph TD | `devbase ps [name]` | `devbase project ps [name]` | | `devbase scale [name] ` | `devbase project scale [name] ` | | `devbase rebuild [name]` | `devbase project rebuild [name]` | +| `devbase open [name]` | `devbase project open [name]` | | `devbase list` | `devbase project list` | > **Note:** `logs` はトップレベルシノニムを持ちません。`devbase project logs` を使用してください。 > +> **`profile` について:** `devbase project profile up|down|list` もトップレベルシノニムを持ちません。 +> `[name]` は受け付けますが、3 番目の引数に `up` / `down` / `list` が来るためラッパーでは位置で +> 解決できず、名前の解決は Python 側が行います。詳細は +> [project グループの「プロジェクト名指定」](02-project.md#プロジェクト名指定cwd-非依存)を参照してください。 +> > **※ `build` の転送先について:** `devbase build`(既定 / `--no-cache` / `--project-no-cache`)は他の > ショートカットのように `project` グループ(Python 実装)へ転送されるのではなく、`bin/devbase` の > シェル実装 `cmd_build` に直接委譲されます。base イメージの段階ビルド等を CWD で行う必要があるため diff --git a/docs/user/container-operations.md b/docs/user/container-operations.md index 2ad6018d..34c69a69 100644 --- a/docs/user/container-operations.md +++ b/docs/user/container-operations.md @@ -5,8 +5,10 @@ devbase のコンテナ管理機能について、ライフサイクル、並行 > **コマンド体系について:** コンテナ操作は `devbase project ` グループ(および > トップレベルショートカット `devbase up` 等)で行います。旧 `devbase container ` は > 非推奨となり、`project` へのエイリアスとして警告付きで当面動作します。`project` では -> `up` / `down` / `ps` / `logs` / `scale` に `[name]` を指定することで **任意のディレクトリ -> から** 対象プロジェクトを操作できます。プロジェクト一覧は `devbase project list` を参照 +> `up` / `down` / `ps` / `logs` / `scale` / `rebuild` / `open` に `[name]` を指定することで +> **任意のディレクトリから** 対象プロジェクトを操作できます。`profile up` / `profile down` / +> `profile list` も `[name]` を取りますが、ラッパーでは位置で解決できず Python 側が解決する +> 点が違います。プロジェクト一覧は `devbase project list` を参照 > してください。詳細は [CLI リファレンス: project グループ](cli-reference/02-project.md) を参照。 ## コンテナライフサイクル diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index 58f35cd8..8098bc57 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -368,8 +368,8 @@ DEVBASE_WINDOW_TITLE=0 `devbase up` は既定で **コマンドを実行した環境の Docker** にコンテナを立てます。 `projects//project.local.yml` に docker context の名前を書くと、そのプロジェクトの -`up` / `down` / `ps` / `logs` / `login` / `scale` / `build` / `rebuild` を**別ホストの daemon** -へ向けられます。用途は、CUDA が使える Windows(WSL2)の GPU、負荷分散のための 3 台目の PC、 +`up` / `down` / `ps` / `logs` / `login` / `scale` / `build` / `rebuild` / `open` を +**別ホストの daemon** へ向けられます。用途は、CUDA が使える Windows(WSL2)の GPU、負荷分散のための 3 台目の PC、 AWS EC2 の計算資源などです。 仕組みは「compose クライアントは手元、daemon はリモート」です。devbase・`projects/`・機密鍵を From b112584be817d5a2be41a9a210c79d00af616282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Tue, 22 Sep 2026 21:20:23 +0900 Subject: [PATCH 07/15] =?UTF-8?q?test:=20pytest=20=E3=81=AE=E3=82=BB?= =?UTF-8?q?=E3=83=83=E3=82=B7=E3=83=A7=E3=83=B3=E5=85=A8=E4=BD=93=E3=81=A7?= =?UTF-8?q?=20DEVBASE=5FROOT=20=E3=82=92=E9=9A=94=E9=9B=A2=E3=81=99?= =?UTF-8?q?=E3=82=8B=20(#209)=20(#217)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: #209 の作業ツリーを開く pytest のセッション全体で DEVBASE_ROOT を隔離する作業の口を開ける。 Co-Authored-By: Claude Opus 5 (1M context) * test: pytest のセッション全体で DEVBASE_ROOT を隔離する (#209) pytest は実行したシェルの環境を継承するため、tmp の root を作るだけで setenv しない fixture を使うテストが、自前の `monkeypatch.setenv('DEVBASE_ROOT', ...)` を忘れると 実環境の devbase の `projects/` と `secrets/backend.yml` を読む。 fixture 1 つに setenv を足しても同じ穴は他にも残るため、`tests/conftest.py` に autouse fixture `_isolate_devbase_root` を置き、テストごとの空の tmp の root へ固定する (#209 の案 B)。autouse は同じ scope の明示の fixture より先に立つので、既存の 32 か所の setenv は書き換えずに後勝ちで働き、未設定の分岐を試す道 (delenv) も残る。 Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- tests/conftest.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 40ba0348..9d5ac2cd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -319,6 +319,29 @@ def do_GET(self): truncate=state.truncate_get_body) +@pytest.fixture(autouse=True) +def _isolate_devbase_root(tmp_path_factory, monkeypatch): + """継承した ``DEVBASE_ROOT`` を、テストごとの空の tmp の root へ置き換える (#209) + + pytest は実行したシェルの環境をそのまま継承する。``DEVBASE_ROOT`` を持つシェル + (ホストの Mac) から走らせると、tmp の root を作るだけで setenv しない fixture + (``openbao_root`` や各所の ``root``) を使うテストが、実環境の ``projects/`` と + ``secrets/backend.yml`` を読む。dev コンテナの中は持たないため、環境で再現したり + しなかったりする。 + + fixture 1 つに setenv を足しても同じ穴は他にも残るため、セッション全体をここで塞ぐ。 + autouse の fixture は同じ scope の明示の fixture より先に立つので、テストの側の + ``monkeypatch.setenv('DEVBASE_ROOT', ...)`` は後勝ちでそのまま働く。未設定の分岐を + 試すテストは、その場で ``monkeypatch.delenv('DEVBASE_ROOT', raising=False)`` と書く。 + + 値を空にせず tmp のディレクトリを指すのは、設定済みを既定にするためである。未設定を + 既定にすると、設定済みの分岐を試す側が毎回 setenv を書くことになり、今と変わらない。 + テストごとに別のディレクトリにするのは、setenv を忘れたテストがここへ書いても隣の + テストへ漏らさないためである。 + """ + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path_factory.mktemp('devbase-root'))) + + @pytest.fixture(autouse=True) def _release_shared_secret_store(): """持ち回りの SecretStore (PLAN55) をテストごとに捨て、控えが隣のテストへ漏れないようにする""" From eaa9e5debca8f8dd388403f737dc08f0a0df3393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Tue, 22 Sep 2026 22:37:06 +0900 Subject: [PATCH 08/15] =?UTF-8?q?refactor(PLAN65):=20devbase=20scale=20?= =?UTF-8?q?=E3=81=A8=20login=20=E3=81=AE=20Compose=20=E5=91=BC=E3=81=B3?= =?UTF-8?q?=E5=87=BA=E3=81=97=E3=82=92=E5=85=B1=E9=80=9A=E7=B5=8C=E8=B7=AF?= =?UTF-8?q?=E3=81=B8=E5=AF=84=E3=81=9B=E3=82=8B=20(#192)=20(#231)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: #192 の実装 1 本目(Compose の呼び出しを共通経路へ寄せる)を始める Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN65): 実装 1 本目の計画を置く (#192) Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN65): devbase scale と login を Compose の共通経路の対象に含める (#192) 確定仕様の「COMPOSE_PROFILES は devbase 経由の操作には効かない」を例外なしの約束にする (設計の決定 1)。経路の表から cmd_scale の除外を削り、経路を 5 つに畳む。 Co-Authored-By: Claude Opus 5 (1M context) * fix(PLAN65): devbase scale の起動と config の読み取りを Compose の共通経路へ寄せる (#192) - cmd_scale の [4/5] を docker_compose(['up', '-d', '--no-recreate', *services], check=False) にし、 起動の対象を default_services(<生成物>) で明示する(設計の決定 1・3・4) - config --format json を読む関数を _compose_config_services の 1 つにし、docker_compose を通す。 _read_compose_services を削除し、_resolve_dev_service は名前と契約を保って載せ替える(決定 9) Co-Authored-By: Claude Opus 5 (1M context) * fix(PLAN65): devbase login の exec にも compose_env() を渡す (#192) 経路の表が devbase の Compose の起動を網羅するよう、cmd_login の 1 行を塞ぐ(設計の決定 2)。 棚卸しのコメントを変更後の 4 経路(_compose_run / _compose_lines / cmd_login / _query_container_name)へ書き直し、_compose_lines と cmd_login のテストを並べる。 Co-Authored-By: Claude Opus 5 (1M context) * test(PLAN65): devbase scale の正常系の手順と範囲を固定する (#192) cmd_scale の段階を分ける前の安全網。順序(グループの検査 → write_scale → ボリューム → network → 生成 → default_services → 起動 → ready 待ち → bao → ./deploy)、bao と ./deploy の 範囲(current + 1 から new まで)、停止を呼ばないこと、受け付けない scale の扱いを固定する。 Co-Authored-By: Claude Opus 5 (1M context) * Test: characterize scale deploy failure and default login Add current-behavior tests for continuing deployment after an instance failure and logging into instance 1 when arguments are omitted. Production code is unchanged. Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default * Test: characterization test for cmd_scale branch — lib/devbase/commands/container.py#cmd_scale cmd_scale に明示的な project_name が渡された場合の分岐を固定する現状固定テストを追加。 Item-Id: R1-003 Round: 1 Impl-Runtime: agy Impl-Model: default --------- Co-authored-by: Claude Opus 5 (1M context) --- docs/specifications/compose-profiles.md | 41 ++- issues/PLAN65_scale-compose-path-impl1.md | 184 ++++++++++ lib/devbase/commands/container.py | 45 ++- tests/cli/test_login_command.py | 30 +- tests/commands/test_container_scale_order.py | 349 +++++++++++++++++++ tests/utils/test_docker_profiles.py | 83 ++++- 6 files changed, 695 insertions(+), 37 deletions(-) create mode 100644 issues/PLAN65_scale-compose-path-impl1.md create mode 100644 tests/commands/test_container_scale_order.py diff --git a/docs/specifications/compose-profiles.md b/docs/specifications/compose-profiles.md index ed146e3f..aa99a9bd 100644 --- a/docs/specifications/compose-profiles.md +++ b/docs/specifications/compose-profiles.md @@ -36,6 +36,8 @@ dev のほかに app / db などのサービスを持つプロジェクトで、 | プロファイルの操作 | `lib/devbase/commands/container.py` | 共通の前段 `_profile_targets`、`cmd_profile_up` / `cmd_profile_down` / `cmd_profile_list`、`_dev_instance_indices`、`_running_services` / `_running_label` | | 振り分け | `lib/devbase/commands/container.py` | `_dispatch_lifecycle` の handlers の `profile` と `_dispatch_profile`。名前を指定したときの切替 `_enter_project`。`cmd_container` の非推奨の警告 | | `devbase up` の起動 | `lib/devbase/commands/container.py` | `_run_deploy_pipeline` が停止より前に `default_services` を求め、`docker_compose_up` へ渡す | +| `devbase scale` の起動 | `lib/devbase/commands/container.py` | `cmd_scale` が生成物を作った直後に `default_services` を求め、`docker_compose` へ `up -d --no-recreate <既定のサービス...>` を `check=False` で渡す | +| Compose 設定の読み取り | `lib/devbase/commands/container.py` | `_compose_config_services` が `docker_compose` で `config --format json` を読む唯一の関数。`_resolve_dev_service`(`build --expires` / `rebuild`)と `_ensure_images`(起動前のイメージ確認)がその上に載る | | フックの環境変数 | `lib/devbase/project/runtime.py`、`commands/container.py` | `active_profiles_env` / `hook_env(config, active_profiles=())`。`_hook_vars`、`_run_deploy_script_for_instances(..., active_profiles=()) -> bool`、`_run_pre_up_hook` | | 引数の受け口 | `lib/devbase/cli.py` | `_add_profile_subparser(sub, with_name=...)`、`SUBCMD_MAP` と `SUBCMD_PREFIX_PREFERENCES` | | 一覧の操作メニュー | `lib/devbase/tui/actions_project.py` | `_PROFILE_OPS` / `_profile_names` / `_running_ops` / `_op_profile`、`_BACK_TO_TOP_OPS` | @@ -73,15 +75,16 @@ devbase 経由の Compose では、有効なプロファイルを devbase が経 | 経路 | 場所 | 用途 | | --- | --- | --- | -| `docker_compose` | `utils/docker.py` | `up` / `down` / `profile up` / `profile down` / `profile list` の `ps` | +| `docker_compose` | `utils/docker.py` | `up` / `down` / `scale` の `up -d --no-recreate` / `profile up` / `profile down` / `profile list` の `ps`、`_compose_config_services` の `config --format json`(`build --expires` / `rebuild` と起動前のイメージ確認) | | `_compose_lines` | `commands/container.py` | `config --profiles` / `config --services`(プロファイルの解決) | | `_compose_run` | `commands/container.py` | `devbase ps` / `devbase logs` | -| `_resolve_dev_service` | `commands/container.py` | `build --expires` / `rebuild` が読む `config --format json` | -| `_read_compose_services` | `commands/container.py` | 起動前のイメージ確認が読む `config --format json` | +| `cmd_login` | `commands/container.py` | `devbase login` の `exec <開発サービス名>- bash` | | `_query_container_name` | `editor/opener.py` | エディタを開くときの `ps --format json` | -`cmd_scale` が直接呼ぶ `docker compose -f <生成物> up -d --no-recreate` はこの対象に含めない。 -プロファイルの入口ではないためである。 +devbase が Compose を起動する経路はこの表の 5 つだけである。`docker_compose` 以外の 4 つは +`subprocess.run` を直接呼び、`env=compose_env()` を自分で渡す。打ち消しが要るのはプロファイルの +入口だからではなく、子プロセスへ利用者の値がそのまま渡るからである。読み取りだけの経路も +`exec` も同じ扱いにする。 値の決め方には次の理由がある。 @@ -107,6 +110,7 @@ subcommand より前に置く。`<サービス...>` はプロファイル X に | 操作 | コマンド列(`docker compose -f <ファイル>` の後) | 子プロセスの `COMPOSE_PROFILES` | | --- | --- | --- | | `devbase up` の起動 | `up -d <既定のサービス...>`(`--profile` なし) | `__devbase_none__` | +| `devbase scale` の起動 | `up -d --no-recreate <既定のサービス...>`(`--profile` なし) | `__devbase_none__` | | `devbase down` / `devbase up` 冒頭の停止 | `--profile '*' down -t0` | `__devbase_none__` | | `profile up X` | `--profile X up -d --no-deps <サービス...>` | `__devbase_none__` | | `profile down X`(1 段目) | `--profile X stop <サービス...>` | `__devbase_none__` | @@ -115,6 +119,9 @@ subcommand より前に置く。`<サービス...>` はプロファイル X に | 既定のサービスの解決 | `config --services` | `__devbase_none__` | | プロファイル名の解決 | `config --profiles` | `__devbase_none__` | | プロファイル X の解決 | `--profile X config --services` | `__devbase_none__` | +| Compose 設定の読み取り | `config --format json`(`-f` なし) | `__devbase_none__` | +| `devbase login`(生成物あり) | `exec <開発サービス名>- bash` | `__devbase_none__` | +| `devbase login`(生成物なし) | `exec --index= <開発サービス名> bash`(`-f` なし) | `__devbase_none__` | `docker_compose_up(compose_file, detach=True, services=())` は `services` が空なら従来どおり サービス名を付けない。`docker_compose_down` は引数を増やさず、常に `--profile '*'` を付ける。 @@ -137,6 +144,16 @@ subcommand より前に置く。`<サービス...>` はプロファイル X に 指定のため、`.env` が別のプロファイルを有効にしても対象は狭まらない。`--profile '*'` を付けない と、プロファイルのサービスが動いたまま残り、network の削除にも失敗する。 +`devbase scale` は既存のコンテナを止めずにインスタンスを足す操作で、停止の段を持たない。 +`cmd_scale` は生成物を作った直後に `default_services(override_file)` を求め、 +`docker_compose(['up', '-d', '--no-recreate', *services], compose_file=override_file, check=False)` +で起動する。起動の対象を明示する理由は `up` と同じである。プロファイルのサービスは起動の対象に +入れず、既に動いているプロファイルのサービスは対象の外にあるため止めない。`check=False` で +終了コードを受け、0 以外なら `Failed to start new containers` を出して 1 を返す +(`docker_compose_up` は `check=True` 固定で、`subprocess.CalledProcessError` が `cmd_scale` の +`except DevbaseError` を素通りするため使わない)。`config --services` の失敗は `DevbaseError` として +`Scale failed: ...` で 1 になる。`project.yml` の `scale` はその時点で既に書き換わっている。 + プロファイルを持たないプロジェクトでは、`up` / `down` / `scale` が扱うコンテナの集合と順序は 変わらない。プロファイルのサービスは scale の対象にせず、複製されるのは開発サービスだけである。 @@ -384,7 +401,7 @@ TUI では、復元境界が機密の注入履歴も戻すため、別プロジ - `devbase up` はプロファイルのサービスも止め、既定のサービスだけを起動する。テスト用サーバを 使い続けるなら `up` の後に `profile up` をやり直す - `devbase down` はプロファイルのサービスも含めて削除する。`devbase scale` はプロファイルの - サービスを複製しない + サービスを複製せず、起動の対象にも入れない。既に動いているプロファイルのサービスは止めない - `COMPOSE_PROFILES` を端末や `.env` に置いても devbase 経由の操作には効かない。素の `docker compose` には従来どおり効く - プロファイル名に `__devbase_none__` を使わない @@ -398,13 +415,21 @@ TUI では、復元境界が機密の注入履歴も戻すため、別プロジ 検査する。実 docker と実 `DEVBASE_ROOT` には触れない。 - `compose_env` が `COMPOSE_PROFILES` を打ち消し用の名前にし(未設定でも入れる)、プロセスの環境を - 変えないこと。`docker_compose`・`ps` / `logs`・`config` を読む 2 経路・エディタの `ps` が - その環境を渡すこと。`down` が `--profile '*' down -t0` になり、`up` がサービス無しで従来の形、 + 変えないこと。`docker_compose`・`ps` / `logs`・`config --services`・`login` の `exec` + (生成物あり・なし)・エディタの `ps` がその環境を渡し、`config --format json` を読む 2 経路が + `docker_compose` を通ること。`_compose_config_services` が非 0 で空の `services`、読めない JSON で + `json.JSONDecodeError` を返し、`_resolve_dev_service` がどちらでも `None` を返すこと。`down` が `--profile '*' down -t0` になり、`up` がサービス無しで従来の形、 サービス有りで `--profile` 無しに名前を並べること(`tests/utils/test_docker_profiles.py`) - `devbase up` が生成物の既定のサービスを起動へ渡し、`default_services` が失敗したときは コンテナを止めないこと(`tests/commands/test_container_up_order.py`)。既存の `up` の harness は `default_services` を差し替える(`test_up_roundtrips.py` / `test_container_context.py` / `test_container_bao.py` / `tui/test_dispatch.py`) +- `devbase scale` が `up -d --no-recreate <既定のサービス...>` を打ち消し用の名前で起動し、呼び出し側の + `os.environ` を変えないこと。起動の非 0 で `Failed to start new containers` と 1 になり例外を + 出さないこと。プロファイルのサービスを起動の対象に入れないこと。正常系の順序 + (グループの検査 → `write_scale` → ボリューム → network → 生成 → `default_services` → 起動 → + ready 待ち → bao の token → `./deploy`)、bao と `./deploy` の範囲が `current + 1` から `new` まで + であること、停止を呼ばないこと(`tests/commands/test_container_scale_order.py`) - `default_services` が打ち消し用の名前で `config --services` を呼ぶこと、`profile_services` が 既定のサービスを差し引き、Compose が展開した名前を使い、プロファイルが無ければ空、解決の失敗で `DevbaseError` になること。生成物が無いときに Compose を呼ばずに 1、未知の名前で起動・停止を呼ばずに diff --git a/issues/PLAN65_scale-compose-path-impl1.md b/issues/PLAN65_scale-compose-path-impl1.md new file mode 100644 index 00000000..79f62917 --- /dev/null +++ b/issues/PLAN65_scale-compose-path-impl1.md @@ -0,0 +1,184 @@ +# PLAN65 実装 1 本目: Compose の呼び出しを共通経路へ寄せる + +## 関連リンク + +- 課題: devbasex/devbase#192 +- 要求と受け入れ条件: `issues/PLAN65_scale-compose-path.md` +- 設計: `issues/PLAN65_scale-compose-path-design.md`(設計 PR #225。案 A を承認済み) +- release PR: #212(base は `release/v3.7.0`) +- この計画が扱うのは設計の「実装の分け方」の **1 本目だけ**である。2 本目(`cmd_scale` の段階を分ける)は + この Pull Request のマージ後に別に出す + +## モード + +`standard`(`devbase scale` の子プロセスの環境と起動の対象が変わり、確定仕様も変える。要求の文書の判定のまま)。 + +## 目的と非目的 + +達成したい状態: + +- `lib/` の中で `compose_env()` を通さずに `docker compose` を起動する箇所が 0 件になる + (`cmd_scale` の `[4/5]` と `cmd_login` の 2 か所を塞ぐ) +- `devbase scale` の起動の対象が、`devbase up` と同じく `default_services(<生成物>)` で明示される +- `docker compose config --format json` を起動する関数が `_compose_config_services` の 1 つになる +- 確定仕様 `docs/specifications/compose-profiles.md` の経路の一覧が実装と一致し、 + 「devbase 経由の操作には効かない」が例外を持たない +- `devbase scale` の正常系の手順(順序と範囲)がテストで固定される + +やらないこと: + +- `cmd_scale` の段階の抽出(`_check_scale_request` / `_run_scale_pipeline` の新設)。2 本目の範囲で、 + 受け入れ条件 D-3・D-4 はこの Pull Request では満たさない +- `docker_compose_up()` / `docker_compose_down()` / `compose_env()` のシグネチャの変更(設計の決定 3) +- `cmd_scale` のログの文言・段階の番号・失敗の扱いの変更(決定 5・7) +- `_previous_scale_compose()` を `cmd_scale` へ入れること(決定 6) +- `docs/plugin-dev/compose-profiles.md` の変更(A-4) +- `cmd_scale` から `_report_missing_repos` / `_apply_window_titles` を呼ぶこと(#224 として起票済み) + +## 前提 + +- 前提 1: 実環境のプロジェクトで `devbase up` / `scale` / `login` を実行しない。確認はコマンド列を組み立てる + 水準と `subprocess.run` を差し替えた水準で行う +- 前提 2: `release/v3.7.0` を base にした Pull Request では CI が動かない(#216)。`uv run pytest` を手元で実行し、 + 結果と終了コードを Pull Request 本文の Test plan へ載せる +- 前提 3: `tests/conftest.py` の autouse fixture(#217)がテストごとに `DEVBASE_ROOT` を tmp へ向ける。 + 新しいテストもこれに乗る +- 前提 4: 変更前の全件の結果は `2863 passed`(`uv run pytest -q`、exit=0、2026-09-22、`b112584` + 空コミット) + +## 受け入れ条件(この Pull Request で満たすもの) + +設計文書の「受け入れ条件とどちらの Pull Request が対応するか」の表から、1 本目で満たすものを写す。 +番号は要求の文書のもの。 + +- [ ] A-1: `compose_env()` を渡さない起動が 0 件。`grep -rn "'docker', 'compose'\|\"docker\", \"compose\"" lib/` が 6 → 3 件 + (`utils/docker.py` の共通経路・`_compose_base_args`・`editor/opener.py`)。`cmd_login` は `_compose_base_args` 経由のため目視で辿る +- [ ] A-2: 確定仕様に `cmd_scale` を共通経路の対象から外す記述が無い。経路の表に `scale` の起動が載る +- [ ] A-3: 確定仕様の「組み立てるコマンド列」の表に `devbase scale` の行(`up -d --no-recreate <既定のサービス...>`、`__devbase_none__`)がある +- [ ] A-4: `docs/plugin-dev/compose-profiles.md` を変えない(`git diff --name-only` に出ない) +- [ ] A-5: `tests/utils/test_docker_profiles.py` の棚卸しのコメントと、そこに並ぶテストが変更後の経路 + (`_compose_run` / `_compose_lines` / `cmd_login` / `editor._query_container_name`)と一致する +- [ ] B-1: `COMPOSE_PROFILES=web` のうえで `cmd_scale(2)` の起動の子プロセスの `COMPOSE_PROFILES` が `__devbase_none__` +- [ ] B-2: `cmd_scale` の前後で `os.environ['COMPOSE_PROFILES']` が `web` のまま +- [ ] B-3: 起動のコマンド列が `['docker', 'compose', '-f', <生成物>, 'up', '-d', '--no-recreate', ]` +- [ ] B-4: 起動が非 0 なら `Failed to start new containers` を出して 1。例外は外へ出ない +- [ ] B-5: プロファイルを持たない生成物では、起動の対象が `config --services` の全件(サービス名を付けない `up` と同じ集合) +- [ ] C-1: 正常系の順序 `group → write_scale → volumes → network → generate → default_services → up → wait → bao → deploy` を固定するテストがある。`grep -rn "no-recreate" tests/` が 1 件以上 +- [ ] C-2: `_push_bao_token` の `start` が `current + 1`、`./deploy` の範囲が `range(current + 1, new + 1)` +- [ ] C-3: 既存の `cmd_scale` のテスト 4 か所を書き換えずに通す +- [ ] C-4: `uv run pytest` の全件が変更の前後で同じ結果(新設分だけ件数が増え、失敗 0) +- [ ] D-1: `grep -rn "'config', '--format', 'json'" lib/` が 1 件 +- [ ] D-2: `_compose_config_services` は非 0 で `(rc, {})`、不正 JSON で `json.JSONDecodeError`、正常で `(0, services)`。 + `_resolve_dev_service` は非 0 と不正 JSON で `None` +- [ ] E-1: `tests/commands/test_container_up_order.py` を書き換えずに通す +- [ ] E-2: `profiles:` を持つサービスが `scale` の起動のコマンド列に出ない +- [ ] E-3: `scale` の呼び出しに停止(`down` / `stop` / `rm`)が 1 件も無い + +## 代替案と採否 + +設計で決めたもの(決定 1〜4・9)はここで再検討しない。計画で決めたのはコミットの並べ方だけである。 + +| 案 | 内容 | 採否 | 理由 | +| --- | --- | --- | --- | +| A | 仕様 → 共通経路へ寄せる(新しい振る舞いのテストを先に書いて落とす)→ 現状固定テスト | **採用** | 設計文書の「#192 が指定した順序」の表のとおり。#192 の順序(仕様 → 共通経路 → 現状固定テスト)とも一致する | +| B | 現状固定テストを先に書いて緑を確かめ、そこから寄せる | 不採用 | 設計文書が「3 を 2 より先に置かない理由」で退けている。寄せる変更は `[4/5]` のコマンド列と `env` を**意図して**変えるため、現状(`env` 無し・サービス名無し)を先に固定すると 2 でそのテストを書き換えることになる。承認した設計から外れる | + +案 A の中での「先に失敗するテスト」の置き方: + +- 共通経路へ寄せるコミットでは、**新しい振る舞い**(B-1〜B-4、D-1/D-2、`cmd_login` の `env`)を表すテストを先に書き、 + 変更前の実装で落ちることを確かめてから寄せる(`tdd-cycle`) +- 現状固定テスト(C-1・C-2・E-3)は寄せた後の実装に対して書き、書いた時点で緑であることを確かめる。 + これが 2 本目(構造の変更)の前の安全網になる。2 本目の構造変更の前に緑で入っている、という設計の要件を満たす +- `[4/5]` 以外の手順(順序・範囲)は寄せるコミットで変わらないため、C-1 の順序のうち `default_services` の位置だけが + 寄せるコミットで新しく生まれる。これは B-3 のテストで先に固定する + +## 不変条件 + +- 呼び出し側の `os.environ` は書き換えない(`compose_env()` は複製を返す) +- `cmd_scale` のログの文字列(`[1/5]`〜`[5/5]`、`[2.5/5]`、`Using --no-recreate ...`、`Failed to start new containers`、 + `Scale failed: %s`、`=== Scale completed successfully ===`)は変えない +- `cmd_scale` は既存のコンテナを止めない(停止の段を持たない) + +## 互換性 + +| 対象 | 変更 | 互換性の扱い | +| --- | --- | --- | +| CLI(`devbase scale` / `devbase login`) | 引数は変えない | 変えない | +| `devbase scale` の起動 | 子プロセスの `COMPOSE_PROFILES` が常に `__devbase_none__`、対象が既定のサービス | **振る舞いが変わる**(承認済み)。端末や `.env` に `COMPOSE_PROFILES` を置いた人の `scale` はプロファイルのサービスを起動しなくなる。プロファイルを持たないプロジェクトでは対象の集合は変わらない | +| `devbase login` | `exec` の子プロセスの `COMPOSE_PROFILES` | 観測できる振る舞いは変わらない(開発サービスは `profiles:` を持たない) | +| モジュール関数 | `_read_compose_services` を削除し `_compose_config_services` を新設 | 呼び出し元は `_ensure_images` の 1 つ。`_resolve_dev_service` の名前と契約は保つ | + +## 修正対象 + +- `docs/specifications/compose-profiles.md` +- `lib/devbase/commands/container.py`(`cmd_scale` の `[4/5]` / `cmd_login` / `_resolve_dev_service` / `_read_compose_services` → `_compose_config_services` / `_ensure_images`) +- `tests/commands/test_container_scale_order.py`(新設) +- `tests/utils/test_docker_profiles.py` +- `issues/PLAN65_scale-compose-path-impl1.md`(この計画) + +## タスク分解 + +コミットは設計の順序表に合わせて 4 つにする(決定 2 のとおり `cmd_login` は独立したコミット)。 + +### Task 1: 確定仕様を書き換える(1 つ目のコミット) + +- **対象ファイル:** `docs/specifications/compose-profiles.md` +- **変更内容:** 設計の「確定仕様の書き換え」の表の 7 か所。構成要素の表に `scale` の起動、経路の表を 5 行 + (`docker_compose` / `_compose_lines` / `_compose_run` / `cmd_login` / `_query_container_name`)に畳み、 + `cmd_scale` の除外を「経路はこの表の 5 つだけ」に置き換え、コマンド列の表に `scale` と `login` の行、 + `up`/`down` の節に `scale` の段落、運用に「起動の対象にも入れない。動いているプロファイルのサービスは止めない」、 + テスト観点に `scale` の行 +- **満たす受け入れ条件:** A-2 / A-3 / A-4 +- **進め方:** ドキュメントのためテスト駆動は適用しない。`grep -n "cmd_scale"` と目視で確かめる + +### Task 2: `cmd_scale` の起動と config の読み取りを共通経路へ寄せる(2 つ目のコミット) + +- **対象ファイル:** `lib/devbase/commands/container.py`、`tests/commands/test_container_scale_order.py`(新設)、`tests/utils/test_docker_profiles.py` +- **変更内容:** + - `[4/5]` を `docker_compose(['up', '-d', '--no-recreate', *services], compose_file=override_file, check=False)` にする。 + `services = default_services(override_file)` は `[3/5]` の生成の直後に求める(番号は付けない。決定 7) + - `_compose_config_services()` を新設し `docker_compose(['config', '--format', 'json'], check=False, capture_output=True)` を通す。 + `_read_compose_services` を削除し、`_ensure_images` と `_resolve_dev_service` をその上に載せる + - 棚卸しの節のうち config の 2 経路のテストを `docker_compose` を通る経路として書き直す +- **満たす受け入れ条件:** A-1(`cmd_scale` 分)/ B-1〜B-5 / D-1 / D-2 / E-2 +- **進め方:** 失敗するテスト(B-1〜B-5・E-2・D-2 の `_compose_config_services`)を先に書き、変更前の実装で落ちることを確かめる → + 寄せる → 全件 + +### Task 3: `cmd_login` に `compose_env()` を渡す(3 つ目のコミット) + +- **対象ファイル:** `lib/devbase/commands/container.py`、`tests/utils/test_docker_profiles.py` +- **変更内容:** `subprocess.run(cmd, env=compose_env())` の 1 行。棚卸しのコメントを「`docker_compose` を通らずに直接呼ぶ経路 4 か所」 + (`_compose_run` / `_compose_lines` / `cmd_login` / `_query_container_name`)へ書き直し、`cmd_login`(生成物あり・なし)と `_compose_lines` のテストを並べる +- **満たす受け入れ条件:** A-1(`cmd_login` 分)/ A-5 +- **進め方:** `cmd_login` の `env` のテストを先に書いて落とす → 1 行 → 全件 + +### Task 4: `devbase scale` の正常系の手順を固定する(4 つ目のコミット) + +- **対象ファイル:** `tests/commands/test_container_scale_order.py` +- **変更内容:** `cmd_scale` の外部作用を差し替える harness(`test_container_up_order.py` の `up_harness` と同型)で、 + 順序(C-1)、bao と `./deploy` の範囲(C-2)、停止を呼ばないこと(E-3)を固定する +- **満たす受け入れ条件:** C-1 / C-2 / E-3 +- **進め方:** 現状固定テスト。書いた時点で緑であることを確かめる(落ちたら実装の振る舞いを読み直す。テストに合わせて実装を変えない) + +## 影響範囲 + +- `devbase scale`(起動の環境と対象)、`devbase login`(環境のみ)、`devbase build --expires` / `rebuild` / 起動前のイメージ確認(config の読み取りの経路。振る舞いは同じ) +- `tests/cli/test_base_image_staleness.py` は `_resolve_dev_service` の名前を差し替えるため、名前を保てば影響しない + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| `container.py` は 2000 行を超える 1 ファイルで、`cmd_scale` は 86 行の通しの関数 | 構造は 2 本目で分ける(設計の決定 10)。この Pull Request では `[4/5]` の数行と `default_services` の 1 行だけを触り、Task 4 の現状固定テストを 2 本目の前に入れる | +| `docker_compose_up()` を使うと `CalledProcessError` が `except DevbaseError` を素通りして traceback で落ちる | `docker_compose(..., check=False)` を直接呼ぶ(決定 3)。B-4 のテストで例外が外へ出ないことを固定する | +| `default_services` の解決の失敗という新しい失敗の経路 | `DevbaseError` として `Scale failed: ...` で 1 になる。`up` が同じ解決を既に行っているため、`up` が通るプロジェクトでは踏まない(設計の「処理の流れ」) | +| 既存の `test_container_context.py` の scale テストが `default_services` を差し替えていない harness で落ちる | harness は `default_services` を差し替え済み(`['dev-1']`)。C-3 のとおり書き換えずに通すことを確かめる | + +## 切り戻し手順 + +- この Pull Request の revert で戻る。データ(`project.yml` / 生成物)の形は変わらないため、移行は無い + +## 完了の定義 + +- [ ] 上の受け入れ条件(D-3・D-4 を除く)が、条件ごとに検証手段と結果で対応している +- [ ] `uv run pytest` の全件が exit=0 で、結果を Pull Request 本文の Test plan に載せた +- [ ] 4 つのコミットが設計の順序どおりに並び、Draft の Pull Request へ push した diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 43b2dc4e..2abd5046 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -1410,7 +1410,8 @@ def cmd_login(index: str = '1', context: Optional[str] = None) -> int: else: cmd.extend(['exec', f'--index={index}', dev_service, 'bash']) - return subprocess.run(cmd).returncode + # 経路の表の 1 つとして子プロセスの COMPOSE_PROFILES を打ち消す (PLAN65 決定 2) + return subprocess.run(cmd, env=compose_env()).returncode # --------------------------------------------------------------------------- @@ -1640,14 +1641,17 @@ def cmd_scale(new_scale: int, project_name: str = None, logger.info("[3/5] Generating scaled compose file...") override_file = _build_scaled_override(new_scale, config, project_name, target) logger.info("Generated: %s", override_file) + # up と同じく起動の対象を生成物の既定のサービスで明示する (PLAN65 決定 4) + services = default_services(override_file) logger.info("[4/5] Starting new containers (%d..%d)...", current_scale + 1, new_scale) logger.info("Using --no-recreate to avoid restarting existing containers...") - result = subprocess.run( - ['docker', 'compose', '-f', str(override_file), 'up', '-d', '--no-recreate'], - check=False - ) + # 共通経路を通し、子プロセスの COMPOSE_PROFILES を打ち消す (PLAN65 決定 1)。 + # docker_compose_up は check=True 固定で CalledProcessError が except DevbaseError を + # 素通りするため、check=False で終了コードを見る (決定 3) + result = docker_compose(['up', '-d', '--no-recreate', *services], + compose_file=override_file, check=False) if result.returncode != 0: logger.error("Failed to start new containers") @@ -1788,17 +1792,13 @@ def cmd_build(image: Optional[str] = None, no_cache: bool = False, def _resolve_dev_service() -> Optional[dict]: """compose config から dev サービス定義を取得する。失敗時は None。""" - result = subprocess.run( - ['docker', 'compose', 'config', '--format', 'json'], - capture_output=True, text=True, check=False, env=compose_env(), - ) - if result.returncode != 0: - return None try: - config = json.loads(result.stdout) + returncode, services = _compose_config_services() except json.JSONDecodeError: return None - return config.get('services', {}).get(get_dev_service_name(), {}) + if returncode != 0: + return None + return services.get(get_dev_service_name(), {}) def _build_resolved(expires: Optional[int], no_cache: bool) -> int: @@ -1964,15 +1964,14 @@ def _image_max_age_days() -> int: ) -def _read_compose_services() -> tuple[int, dict]: - """Compose 設定の終了コードと services を取得する。""" - result = subprocess.run( - ['docker', 'compose', 'config', '--format', 'json'], - capture_output=True, - text=True, - check=False, - env=compose_env(), - ) +def _compose_config_services() -> tuple[int, dict]: + """``docker compose config --format json`` の (終了コード, services) を返す。 + + ``config --format json`` を起動する唯一の関数 (PLAN65 決定 9)。非 0 なら services は空。 + JSON として読めなければ :class:`json.JSONDecodeError` を伝播する。 + """ + result = docker_compose(['config', '--format', 'json'], + check=False, capture_output=True) if result.returncode != 0: return result.returncode, {} config = json.loads(result.stdout) @@ -2016,7 +2015,7 @@ def _ensure_images() -> bool: dev_service_name = get_dev_service_name() try: - returncode, services = _read_compose_services() + returncode, services = _compose_config_services() if returncode != 0: logger.info("Unable to check image status") logger.info("Running 'devbase container build' to ensure images exist...") diff --git a/tests/cli/test_login_command.py b/tests/cli/test_login_command.py index a965ef5c..a462c350 100644 --- a/tests/cli/test_login_command.py +++ b/tests/cli/test_login_command.py @@ -24,7 +24,8 @@ def test_login_command(tmp_path, monkeypatch, scaled, expected): monkeypatch.setattr(container, 'get_dev_service_name', lambda: events.append(('service',)) or 'dev') - def run(cmd): + def run(cmd, **kwargs): + # 子プロセスの env (compose_env) は tests/utils/test_docker_profiles.py が固定する (PLAN65) events.append(('run', cmd)) return subprocess.CompletedProcess(cmd, 7) @@ -34,3 +35,30 @@ def run(cmd): assert events == [ ('context', 'remote'), ('secrets', False), ('service',), ('run', expected), ] + + +@pytest.mark.parametrize(('scaled', 'expected'), [ + (True, ['docker', 'compose', '-f', '.docker-compose.scale.yml', + 'exec', 'dev-1', 'bash']), + (False, ['docker', 'compose', 'exec', '--index=1', 'dev', 'bash']), +]) +def test_login_command_defaults(tmp_path, monkeypatch, scaled, expected): + """現状固定: 引数省略時は context=None でインスタンス 1 に接続する。""" + monkeypatch.chdir(tmp_path) + if scaled: + (tmp_path / '.docker-compose.scale.yml').write_text('services: {}\n') + contexts = [] + commands = [] + monkeypatch.setattr(container, '_apply_context', contexts.append) + monkeypatch.setattr(container, '_inject_secrets', lambda *, required: None) + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + + def run(cmd, **kwargs): + commands.append(cmd) + return subprocess.CompletedProcess(cmd, 7) + + monkeypatch.setattr(container.subprocess, 'run', run) + + assert container.cmd_login() == 7 + assert contexts == [None] + assert commands == [expected] diff --git a/tests/commands/test_container_scale_order.py b/tests/commands/test_container_scale_order.py new file mode 100644 index 00000000..5db9b7c1 --- /dev/null +++ b/tests/commands/test_container_scale_order.py @@ -0,0 +1,349 @@ +"""``devbase scale`` の Compose の呼び出しと正常系の手順 (PLAN65)。 + +``devbase scale`` の起動は ``devbase up`` と同じ共通経路 (``docker_compose``) を通り、子プロセスの +``COMPOSE_PROFILES`` を打ち消し用の名前にする。起動の対象は生成物の既定のサービスを明示する。 + +実 docker と実 ``DEVBASE_ROOT`` には触れない。``subprocess.run`` を差し替えて、組み立てた +コマンド列と子プロセスの環境を拾う。 +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +from devbase.commands import container +from devbase.errors import DevbaseError +from devbase.utils import docker +from devbase.utils import docker_context as dc + + +PROJECT_YML = "version: 1\nscale: 1\nrepos:\n - owner: volareinc\n repo: carmo\n" + +# 生成物のサービスとそのプロファイル。``config --services`` の応答はここから作る +NO_PROFILE_SERVICES = {'dev-1': [], 'dev-2': [], 'redis': []} +WITH_PROFILE_SERVICES = {'dev-1': [], 'dev-2': [], 'redis': [], 'testdb': ['test']} + + +class FakeCompose: + """``subprocess.run`` の代わり。呼び出しを記録し、``config --services`` に答える。 + + ``config --services`` は Compose と同じく、有効なプロファイル (``--profile`` と子プロセスの + ``COMPOSE_PROFILES``) に属するサービスと、プロファイルを持たないサービスを返す。 + """ + + def __init__(self, services: dict, calls: list, up_returncode: int = 0): + self.services = services + self.calls = calls + self.up_returncode = up_returncode + + def __call__(self, cmd, **kwargs): + cmd = list(cmd) + env = kwargs.get('env') + self.calls.append(('run', {'cmd': cmd, 'env': env})) + if 'config' in cmd and '--services' in cmd: + active = set() + for i, arg in enumerate(cmd): + if arg == '--profile': + active.add(cmd[i + 1]) + raw = (env if env is not None else os.environ).get('COMPOSE_PROFILES', '') + active.update(p for p in raw.split(',') if p) + names = [name for name, profiles in self.services.items() + if not profiles or active.intersection(profiles)] + return subprocess.CompletedProcess(cmd, 0, '\n'.join(names) + '\n', '') + returncode = self.up_returncode if 'up' in cmd else 0 + return subprocess.CompletedProcess(cmd, returncode, '', '') + + +@pytest.fixture +def scale_harness(tmp_path, monkeypatch): + """``cmd_scale`` の外部作用を差し替え、呼び出しの順序を ``calls`` へ記録する。""" + monkeypatch.chdir(tmp_path) + for name in ('DOCKER_CONTEXT', 'DOCKER_HOST', 'DEVBASE_DOCKER_CONTEXT'): + monkeypatch.delenv(name, raising=False) + dc.reset() + (tmp_path / 'project.yml').write_text(PROJECT_YML) + override = tmp_path / '.docker-compose.scale.yml' + calls: list = [] + + monkeypatch.setattr(container, 'get_project_name', lambda: 'proj') + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + monkeypatch.setattr(container, '_check_group_consistency', + lambda project=None: calls.append(('group', None)) or True) + monkeypatch.setattr(container, '_resolve_docker_target', lambda context=None: dc.DockerTarget( + context=None, source='none', remote=False, home=None, gid=None)) + + real_write_scale = container.project_runtime.write_scale + + def write_scale(project_dir, scale): + calls.append(('write_scale', scale)) + real_write_scale(project_dir, scale) + + monkeypatch.setattr(container.project_runtime, 'write_scale', write_scale) + monkeypatch.setattr(container, 'ensure_volumes', + lambda scale, project: calls.append(('volumes', scale))) + monkeypatch.setattr(container, 'ensure_network', + lambda name='devbase_net': calls.append(('network', name))) + + def build(scale, config, project_name, target): + calls.append(('generate', scale)) + override.write_text("services:\n dev-1: {}\n dev-2: {}\n") + return override + + monkeypatch.setattr(container, '_build_scaled_override', build) + + real_default_services = container.default_services + + def default_services(compose_file, environ=None): + calls.append(('default_services', Path(compose_file))) + return real_default_services(compose_file, environ) + + monkeypatch.setattr(container, 'default_services', default_services) + monkeypatch.setattr(container, 'wait_for_containers_ready', + lambda **k: calls.append(('wait', k))) + monkeypatch.setattr(container, '_push_bao_token', + lambda *a, **k: calls.append(('bao', {'args': a, **k}))) + real_deploy = container._run_deploy_script_for_instances + monkeypatch.setattr(container, '_run_deploy_script_for_instances', + lambda script, indices, config=None, active_profiles=(): + calls.append(('deploy', list(indices))) or True) + + fake = FakeCompose(NO_PROFILE_SERVICES, calls) + monkeypatch.setattr(subprocess, 'run', fake) + return {'calls': calls, 'override': override, 'fake': fake, 'root': tmp_path, + 'real_deploy': real_deploy} + + +def _runs(calls, *words): + return [c for name, c in calls if name == 'run' and all(w in c['cmd'] for w in words)] + + +def _up_calls(calls): + return [c for name, c in calls if name == 'run' and 'up' in c['cmd']] + + +# --------------------------------------------------------------------------- +# 起動の子プロセスの環境とコマンド列 (B-1〜B-5 / E-2) +# --------------------------------------------------------------------------- + +def test_scale_start_passes_reserved_profile_to_child(scale_harness, monkeypatch): + """B-1 / B-2: 利用者の COMPOSE_PROFILES は子プロセスで打ち消し、呼び出し側は変えない。""" + monkeypatch.setenv('COMPOSE_PROFILES', 'web') + + assert container.cmd_scale(2) == 0 + + up = _up_calls(scale_harness['calls']) + assert len(up) == 1 + assert up[0]['env'] is not None + assert up[0]['env']['COMPOSE_PROFILES'] == docker.NO_PROFILE + assert os.environ['COMPOSE_PROFILES'] == 'web' + + +def test_scale_start_names_default_services_of_generated_compose(scale_harness, monkeypatch): + """B-3: ``up -d --no-recreate`` に ``default_services(<生成物>)`` の全件を並びのまま渡す。""" + monkeypatch.setattr(container, 'default_services', + lambda compose_file, environ=None: ['dev-1', 'dev-2', 'redis']) + + assert container.cmd_scale(2) == 0 + + up = _up_calls(scale_harness['calls']) + assert [c['cmd'] for c in up] == [[ + 'docker', 'compose', '-f', str(scale_harness['override']), + 'up', '-d', '--no-recreate', 'dev-1', 'dev-2', 'redis']] + assert '--profile' not in up[0]['cmd'] + + +def test_scale_start_failure_returns_one_without_exception(scale_harness, caplog): + """B-4: 起動の非 0 は ``Failed to start new containers`` と 1。例外を外へ出さない。""" + scale_harness['fake'].up_returncode = 1 + + assert container.cmd_scale(2) == 1 + + assert 'Failed to start new containers' in caplog.text + names = [name for name, _ in scale_harness['calls']] + assert 'wait' not in names and 'bao' not in names and 'deploy' not in names + + +def test_scale_start_targets_every_service_without_profiles(scale_harness): + """B-5: プロファイルを持たない生成物では、対象は ``config --services`` の全件。""" + assert container.cmd_scale(2) == 0 + + up = _up_calls(scale_harness['calls']) + assert up[0]['cmd'][-3:] == ['dev-1', 'dev-2', 'redis'] + assert set(up[0]['cmd'][7:]) == set(NO_PROFILE_SERVICES) + + +def test_scale_start_leaves_out_profile_services(scale_harness, monkeypatch): + """E-2: 利用者が COMPOSE_PROFILES でプロファイルを有効にしても、起動の対象に入れない。""" + monkeypatch.setenv('COMPOSE_PROFILES', 'test') + scale_harness['fake'].services = WITH_PROFILE_SERVICES + + assert container.cmd_scale(2) == 0 + + up = _up_calls(scale_harness['calls']) + assert 'testdb' not in up[0]['cmd'] + assert up[0]['cmd'][7:] == ['dev-1', 'dev-2', 'redis'] + config = _runs(scale_harness['calls'], 'config', '--services') + assert config and all(c['env']['COMPOSE_PROFILES'] == docker.NO_PROFILE for c in config) + + +def test_scale_default_services_failure_is_a_scale_failure(scale_harness, monkeypatch, caplog): + """既定のサービスの解決の失敗は ``Scale failed`` で 1。起動しない。""" + def fail(compose_file, environ=None): + raise DevbaseError('config --services failed') + + monkeypatch.setattr(container, 'default_services', fail) + + assert container.cmd_scale(2) == 1 + + assert 'Scale failed: config --services failed' in caplog.text + assert _up_calls(scale_harness['calls']) == [] + + +# --------------------------------------------------------------------------- +# 正常系の手順の固定 (C-1 / C-2 / E-3)。cmd_scale の段階を分ける前の安全網 +# --------------------------------------------------------------------------- + +def _step(name, payload): + if name != 'run': + return name + cmd = payload['cmd'] + if 'config' in cmd: + return 'config' + if 'up' in cmd: + return 'up' + return 'run:' + ' '.join(cmd[2:]) + + +def test_scale_runs_the_steps_in_order(scale_harness): + """C-1: グループの検査 → write_scale → ボリューム → network → 生成 → 既定のサービス → 起動 + → ready 待ち → bao → ./deploy。""" + (scale_harness['root'] / 'deploy').write_text('#!/bin/sh\n') + + assert container.cmd_scale(3) == 0 + + steps = [_step(name, payload) for name, payload in scale_harness['calls']] + assert [s for s in steps if s != 'config'] == [ + 'group', 'write_scale', 'volumes', 'network', 'generate', 'default_services', + 'up', 'wait', 'bao', 'deploy'] + # 既定のサービスの解決 (config --services) は生成の後、起動の前に行う + assert steps.index('generate') < steps.index('config') < steps.index('up') + up = _up_calls(scale_harness['calls'])[0] + assert up['cmd'][4:7] == ['up', '-d', '--no-recreate'] + + +def test_scale_passes_the_generated_compose_and_new_scale(scale_harness): + """C-1: 各段に新しい scale と生成物が渡り、project.yml の scale が書き換わる。""" + assert container.cmd_scale(3) == 0 + + calls = dict((name, payload) for name, payload in scale_harness['calls'] if name != 'run') + override = scale_harness['override'] + assert calls['write_scale'] == 3 + assert calls['volumes'] == 3 + assert calls['network'] == 'devbase_net' + assert calls['generate'] == 3 + assert calls['default_services'] == override + assert calls['wait'] == {'container_prefix': 'dev', 'scale': 3, + 'compose_file': override, 'timeout': 60} + assert 'scale: 3' in (scale_harness['root'] / 'project.yml').read_text() + + +def test_scale_hooks_cover_only_the_new_instances(scale_harness): + """C-2: bao の token と ./deploy は current + 1 から new まで。既存のインスタンスを含めない。""" + (scale_harness['root'] / 'deploy').write_text('#!/bin/sh\n') + + assert container.cmd_scale(4) == 0 + + calls = dict((name, payload) for name, payload in scale_harness['calls'] if name != 'run') + assert calls['bao'] == {'args': ('proj', 4, 'dev'), + 'compose_file': scale_harness['override'], 'start': 2} + assert calls['deploy'] == [2, 3, 4] + + +def test_scale_skips_deploy_without_the_script(scale_harness): + """``./deploy`` が無ければ走らせない。bao の token は書く。""" + assert container.cmd_scale(2) == 0 + + names = [name for name, _ in scale_harness['calls']] + assert 'bao' in names and 'deploy' not in names + + +def test_scale_continues_after_deploy_failure_and_returns_zero(scale_harness, monkeypatch): + """現状固定: deploy の 2 が失敗しても 3 を実行し、scale 自体は 0 を返す。""" + (scale_harness['root'] / 'deploy').write_text('#!/bin/sh\n') + monkeypatch.setattr(container, '_run_deploy_script_for_instances', + scale_harness['real_deploy']) + deployed_indices = [] + + def run(cmd, **kwargs): + if cmd == ['bash', 'deploy']: + index = kwargs['env']['DEVBASE_INSTANCE_INDEX'] + deployed_indices.append(index) + if index == '2': + raise subprocess.CalledProcessError(1, cmd) + return subprocess.CompletedProcess(cmd, 0) + return scale_harness['fake'](cmd, **kwargs) + + monkeypatch.setattr(subprocess, 'run', run) + + assert container.cmd_scale(3) == 0 + assert deployed_indices == ['2', '3'] + + +def test_scale_never_stops_containers(scale_harness): + """E-3: scale は停止の段を持たない。down / stop / rm を呼ばない。""" + (scale_harness['root'] / 'deploy').write_text('#!/bin/sh\n') + + assert container.cmd_scale(3) == 0 + + runs = [payload['cmd'] for name, payload in scale_harness['calls'] if name == 'run'] + assert runs + for cmd in runs: + assert not {'down', 'stop', 'rm'} & set(cmd) + + +@pytest.mark.parametrize('new_scale', [0, 1]) +def test_scale_rejects_a_scale_not_above_current(scale_harness, new_scale): + """現状固定: 1 未満と現在以下は 1 を返し、project.yml を書き換えず、何も起動しない。""" + assert container.cmd_scale(new_scale) == 1 + + names = [name for name, _ in scale_harness['calls']] + assert names == ['group'] + assert (scale_harness['root'] / 'project.yml').read_text() == PROJECT_YML + + +def test_scale_passes_explicit_project_name(scale_harness, monkeypatch): + """現状固定: 明示的に指定された project_name が ensure_volumes, _build_scaled_override, _push_bao_token に渡る。""" + captured: dict = {} + + orig_volumes = container.ensure_volumes + + def fake_ensure_volumes(scale, project): + captured['volumes_project'] = project + return orig_volumes(scale, project) + + orig_build = container._build_scaled_override + + def fake_build(scale, config, project_name, target): + captured['build_project'] = project_name + return orig_build(scale, config, project_name, target) + + orig_push_bao = container._push_bao_token + + def fake_push_bao(project_name, *args, **kwargs): + captured['bao_project'] = project_name + return orig_push_bao(project_name, *args, **kwargs) + + monkeypatch.setattr(container, 'ensure_volumes', fake_ensure_volumes) + monkeypatch.setattr(container, '_build_scaled_override', fake_build) + monkeypatch.setattr(container, '_push_bao_token', fake_push_bao) + + assert container.cmd_scale(2, project_name='custom-proj') == 0 + + assert captured['volumes_project'] == 'custom-proj' + assert captured['build_project'] == 'custom-proj' + assert captured['bao_project'] == 'custom-proj' + diff --git a/tests/utils/test_docker_profiles.py b/tests/utils/test_docker_profiles.py index 02405093..4c1107fc 100644 --- a/tests/utils/test_docker_profiles.py +++ b/tests/utils/test_docker_profiles.py @@ -100,6 +100,12 @@ def test_up_names_the_given_services_without_profile(monkeypatch, fake_run): # --------------------------------------------------------------------------- # docker_compose を通らずに Compose を直接呼ぶ経路 (決定 7 の棚卸しの 4 か所) +# +# devbase が Compose を起動する経路は docker_compose とこの 4 つだけである (PLAN65 決定 1・2)。 +# _compose_run (ps / logs)・_compose_lines (config --services / --profiles)・ +# cmd_login (exec)・editor._query_container_name (ps --format json) は subprocess.run を +# 直接呼ぶため、env=compose_env() を自分で渡す。scale の起動と config --format json の +# 読み取りは docker_compose を通る (下の節と tests/commands/test_container_scale_order.py) # --------------------------------------------------------------------------- @pytest.fixture @@ -122,14 +128,34 @@ def test_ps_and_logs_pass_reserved_profile(container_run): assert [c['env']['COMPOSE_PROFILES'] for c in run.calls] == [docker.NO_PROFILE] * 2 -def test_compose_config_readers_pass_reserved_profile(container_run): + + +def test_compose_lines_pass_reserved_profile(container_run): container, run = container_run - container._resolve_dev_service() - container._read_compose_services() + container._compose_lines(Path('x.yml'), ['config', '--services']) + + assert run.calls[0]['cmd'] == ['docker', 'compose', '-f', 'x.yml', 'config', '--services'] + assert run.calls[0]['env']['COMPOSE_PROFILES'] == docker.NO_PROFILE - assert [c['cmd'][2] for c in run.calls] == ['config', 'config'] - assert [c['env']['COMPOSE_PROFILES'] for c in run.calls] == [docker.NO_PROFILE] * 2 + +@pytest.mark.parametrize('generated, expected_tail', [ + (True, ['exec', 'dev-2', 'bash']), + (False, ['exec', '--index=2', 'dev', 'bash']), +]) +def test_login_passes_reserved_profile(container_run, monkeypatch, generated, expected_tail): + container, run = container_run + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + if generated: + container._SCALE_COMPOSE_FILE.write_text('services: {}\n') + + container.cmd_login('2') + + assert run.calls[0]['cmd'][-len(expected_tail):] == expected_tail + assert run.calls[0]['env'] is not None + assert run.calls[0]['env']['COMPOSE_PROFILES'] == docker.NO_PROFILE + import os + assert os.environ['COMPOSE_PROFILES'] == 'test' def test_editor_container_name_query_passes_reserved_profile(monkeypatch): @@ -141,3 +167,50 @@ def test_editor_container_name_query_passes_reserved_profile(monkeypatch): assert run.calls[0]['cmd'][-4:] == ['ps', '--format', 'json', 'dev-1'] assert run.calls[0]['env']['COMPOSE_PROFILES'] == docker.NO_PROFILE + + +# --------------------------------------------------------------------------- +# config --format json の読み取り (PLAN65 決定 9)。docker_compose を通る唯一の関数に寄せる +# --------------------------------------------------------------------------- + +def test_compose_config_readers_pass_reserved_profile(container_run): + container, run = container_run + + container._resolve_dev_service() + container._compose_config_services() + + assert [c['cmd'] for c in run.calls] == [['docker', 'compose', 'config', '--format', 'json']] * 2 + assert [c['env']['COMPOSE_PROFILES'] for c in run.calls] == [docker.NO_PROFILE] * 2 + + +@pytest.mark.parametrize('returncode, stdout, expected', [ + (1, 'not json', (1, {})), + (0, '{"services": {"dev": {"image": "x"}}}', (0, {'dev': {'image': 'x'}})), +]) +def test_compose_config_services_contract(container_run, returncode, stdout, expected): + container, run = container_run + run.returncode, run.stdout = returncode, stdout + + assert container._compose_config_services() == expected + + +def test_compose_config_services_propagates_unreadable_json(container_run): + import json + container, run = container_run + run.stdout = 'not json' + + with pytest.raises(json.JSONDecodeError): + container._compose_config_services() + + +@pytest.mark.parametrize('returncode, stdout, expected', [ + (1, '{"services": {"dev": {"image": "x"}}}', None), + (0, 'not json', None), + (0, '{"services": {"dev": {"image": "x"}}}', {'image': 'x'}), +]) +def test_resolve_dev_service_contract(container_run, monkeypatch, returncode, stdout, expected): + container, run = container_run + monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') + run.returncode, run.stdout = returncode, stdout + + assert container._resolve_dev_service() == expected From ff48e8e1c913f12f06a84815f16e8ad8817234b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Tue, 22 Sep 2026 23:46:40 +0900 Subject: [PATCH 09/15] =?UTF-8?q?refactor(PLAN65):=20cmd=5Fscale=20?= =?UTF-8?q?=E3=81=AE=E6=AE=B5=E9=9A=8E=E3=82=92=E5=89=8D=E6=8F=90=E3=81=AE?= =?UTF-8?q?=E6=A4=9C=E6=9F=BB=E3=81=A8=E6=AE=B5=E9=9A=8E=E3=81=AE=E5=AE=9F?= =?UTF-8?q?=E8=A1=8C=E3=81=B8=E5=88=86=E3=81=91=E3=82=8B=20(#192)=20(#232)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: #192 の実装 2 本目(cmd_scale の段階を分ける)を始める Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN65): 実装 2 本目の計画を置く (#192) Co-Authored-By: Claude Opus 5 (1M context) * refactor(PLAN65): cmd_scale の前提の検査を _check_scale_request へ出す (#192) new_scale の 1 未満・現在以下の判定とログを関数へ移す(設計の決定 8)。 文言と出し分けは変えない。契約のテストを足し、既存の現状固定テストは書き換えない。 Co-Authored-By: Claude Opus 5 (1M context) * refactor(PLAN65): cmd_scale の [1/5]〜[5/5] を _run_scale_pipeline へ出す (#192) cmd_up の _run_deploy_pipeline と対称の段にする(設計の決定 8)。起動が 0 以外なら Failed to start new containers を出して None を返し、ほかの失敗は伝播する。後処理 (bao の token・./deploy・完了のログ)は cmd_scale の本体に残す。段階の番号とログの 文言は変えない(決定 7)。cmd_scale の本体は 92 行から 40 行になる(D-3)。 Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN65): 実装 2 本目の計画の受け入れ条件に結果を記す (#192) Co-Authored-By: Claude Opus 5 (1M context) * style(PLAN65): cmd_scale の空行と折り返しを元へ戻し、D-3 の行数の判断を記す (#192) 40 行に届かせるために削った空行・1 行へ詰めた文・短くしたコメントを、抽出前の書式へ戻した。 振る舞いは変えない。D-3 の意図(段階の命名と cmd_up との形の一致)は満たし、行数は 要求の数え方で 51 行になる。数字のために読みやすさを犠牲にしない判断を要求の文書と計画に記した。 Co-Authored-By: Claude Opus 5 (1M context) * Test: characterize cmd_scale error paths Preserve configuration and subprocess side effects on target resolution and readiness failures. Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default * Test: characterize cmd_scale default scale handling Fix current behavior when config.scale is omitted in project.yml. DEFAULT_SCALE is treated as current scale, rejecting scale 2 and deploying only instance 3 on scale 3. Item-Id: R1-002 Round: 1 Impl-Runtime: agy Impl-Model: default --------- Co-authored-by: Claude Opus 5 (1M context) --- issues/PLAN65_scale-compose-path-impl2.md | 143 +++++++++++++++++ issues/PLAN65_scale-compose-path.md | 6 + lib/devbase/commands/container.py | 114 ++++++++----- tests/commands/test_container_scale_order.py | 160 ++++++++++++++++++- 4 files changed, 379 insertions(+), 44 deletions(-) create mode 100644 issues/PLAN65_scale-compose-path-impl2.md diff --git a/issues/PLAN65_scale-compose-path-impl2.md b/issues/PLAN65_scale-compose-path-impl2.md new file mode 100644 index 00000000..d499b2da --- /dev/null +++ b/issues/PLAN65_scale-compose-path-impl2.md @@ -0,0 +1,143 @@ +# PLAN65 実装 2 本目: `cmd_scale` の段階を分ける + +## 関連リンク + +- 課題: devbasex/devbase#192(この Pull Request が閉じる) +- 要求と受け入れ条件: `issues/PLAN65_scale-compose-path.md` +- 設計: `issues/PLAN65_scale-compose-path-design.md`(設計 PR #225。承認済み) +- 1 本目: #231(`release/v3.7.0` へマージ済み。計画は `issues/PLAN65_scale-compose-path-impl1.md`) +- release PR: #212(base は `release/v3.7.0`) +- この計画が扱うのは設計の「実装の分け方」の **2 本目だけ**である + +## モード + +`standard`(本番の振る舞いを変えない構造変更で、対象に 1 本目で入れた現状固定テストが十分にある)。 + +## 目的と非目的 + +達成したい状態: + +- `cmd_scale` が「2 つの検査 → 段階 → 後処理」の 3 段に読め、`cmd_up`(`_run_pre_up_checks` → + `_run_deploy_pipeline` → 後処理)と並べて読める(設計の決定 8) + +やらないこと: + +- 段階ごとに 5 つの関数へ分けること(決定 8 が退けた案) +- `_run_deploy_pipeline` と `_run_scale_pipeline` の統合(決定 8 が退けた案) +- 後処理(`_push_bao_token`・`./deploy`・完了のログ)を関数へ出すこと(決定 8。`cmd_up` も本体に持つ) +- 段階の番号の文字列・ログの文言・失敗の扱い・後処理の順序の変更(決定 5・7) +- `cmd_scale` 以外の本番コードの変更(`_SCALE_COMPOSE_FILE` の有無の確認の重複などは範囲外) +- `cmd_scale` から `_report_missing_repos` / `_apply_window_titles` を呼ぶこと(#224) + +## 前提 + +- 前提 1: 実環境のプロジェクトで `devbase up` / `scale` / `login` を実行しない。確認はテスト(`subprocess.run` と + 段階の関数を差し替えた水準)で行う +- 前提 2: `release/v3.7.0` を base にした Pull Request では CI が動かない(#216)。`uv run --locked pytest tests/ -q` を + 手元で実行し、結果と終了コードを Pull Request 本文の Test plan へ載せる +- 前提 3: 変更前の全件は `2889 passed`、exit=0(この作業ツリーの起点 `eaa9e5d` + 空コミットで取り直した) +- 前提 4: 行数は `ast` で `cmd_scale` の `def` の行から関数の最後の行までを数える(`end_lineno - lineno + 1`)。 + あわせて設計の検証手段の「`def` から次の `def` まで」も並べて載せる + +## 受け入れ条件(この Pull Request で満たすもの) + +設計文書の「受け入れ条件とどちらの Pull Request が対応するか」の表から、2 本目で満たすものを写す。 + +- [ ] D-3: `cmd_scale` の本体が 40 行以下になる(起点で 92 行)。抽出した段階の関数(`_run_scale_pipeline`)が + `[1/5]`〜`[5/5]` のログ文字列をそのまま持つ + - 結果: 段階のログ文字列はすべて `_run_scale_pipeline` へ移った(満たす)。行数は `def`〜最後の行で 92 → 54、 + 要求の「86 行」と同じ数え方(シグネチャとドキュメント文字列を除く)で 51 行。空行・コメントを除けば 40 行。 + 一度は空行を削り 2 つの文を約 100 文字の 1 行へ詰めて 40 行に合わせたが、検査の持ち場で元の書式へ戻した。 + 意図(段階の命名と `cmd_up` との形の一致)は満たしており、数字のために読みやすさを犠牲にしない。 + 要求の文書の D-3 にこの判断を書き足した + - 検証: `ast` で行数を数える / `grep -n "/5\]" lib/devbase/commands/container.py` の 6 行がすべて `_run_scale_pipeline` の範囲にある +- [x] D-4: `cmd_scale` と `cmd_up` の段階の対応が設計文書の「段階の対応(変更後)」の表と一致する。段階の番号の + 文字列(`[2.5/5]` を含む)は変えない + - 検証: 設計の表と `grep -n "/5\]"` の出力を並べる。`_check_scale_request` と `_run_scale_pipeline` の契約(設計の + 「新設・変更する関数の契約」の表)を単体テストで固定する + +退行しないこと(1 本目で満たした条件を書き換えずに緑のまま通す): + +- [x] `tests/commands/test_container_scale_order.py` の既存のテスト(順序・範囲・停止しないこと・`./deploy` の失敗の後も + 続けること・`project_name` の明示・`cmd_login` の既定の引数ほか)を**書き換えずに**各コミットで通す — B-1〜B-5 / C-1 / C-2 / E-2 / E-3 +- [x] 既存の 4 か所の `cmd_scale` のテストを書き換えずに通す — C-3 +- [x] `uv run --locked pytest tests/ -q` の全件が変更の前後で同じ(足したテストの分だけ増える)— C-4 / E-1 + +## 代替案と採否 + +| 案 | 内容 | 採否 | 理由 | +| --- | --- | --- | --- | +| A | `_check_scale_request` と `_run_scale_pipeline` の 2 つを抽出し、後処理は本体に残す | 採用 | 設計の決定 8 | +| B | 段階ごとに 5 つの関数へ分ける | 不採用 | 決定 8 が退けた | +| C | `_run_deploy_pipeline` と統合して引数で分岐 | 不採用 | 決定 8 が退けた | +| D | 1 本目の cross-refactoring で 3 者が挙げた extract_method の形をそのまま使う | 参考のみ | 決定 8 が優先する。関数の名前・境界・シグネチャは設計の「新設・変更する関数のシグネチャ」に従う | + +## 不変条件 + +- `project.yml` の `scale` は、グループの不一致と `new_scale` の不適のときは書き換わらない +- 起動が 0 以外のとき `Failed to start new containers` を出して 1 を返す(例外にしない) +- 構成生成・既定のサービスの解決・ready 待ちの失敗は `DevbaseError` として `cmd_scale` の `except` が `Scale failed: ...` を出す +- `[1/5]` → `[2/5]` → `[2.5/5]` → `[3/5]` → `default_services` → `[4/5]` → `[5/5]` → bao → `./deploy` の順 + +## 互換性 + +| 対象 | 変更 | 互換性の扱い | +| --- | --- | --- | +| `cmd_scale` のシグネチャ・戻り値・ログ | 変えない | 構造だけを変える | +| 新設の 2 関数 | モジュール内の private 関数を足す | 公開インタフェースではない | + +## 修正対象 + +- `lib/devbase/commands/container.py`(`cmd_scale` と、その直前に置く新設の 2 関数) +- `tests/commands/test_container_scale_order.py`(新設の 2 関数の契約のテストを**追記**。既存のテストは書き換えない) + +## タスク分解 + +### Task 1: `_check_scale_request` を抽出する + +- **対象ファイル:** `lib/devbase/commands/container.py`、`tests/commands/test_container_scale_order.py` +- **変更内容:** `new_scale < 1`(error 1 行)と `new_scale <= current_scale`(warning + info 2 行)の判定を + `_check_scale_request(new_scale, current_scale) -> bool` へ移す。文言と出し分けは変えない +- **満たす受け入れ条件:** D-4(前提の検査の段)・D-3 の一部 +- **進め方:** 契約のテスト(1 未満で False と error、現在以下で False と warning + info、上回れば True でログ無し)を + 先に書き、関数が無いことで落ちるのを確かめてから抽出する。既存の現状固定テストが緑のままであることを確かめてコミット + +### Task 2: `_run_scale_pipeline` を抽出する + +- **対象ファイル:** 同上 +- **変更内容:** `[1/5]`〜`[5/5]`(`write_scale`・`ensure_volumes`・`ensure_network`・`_build_scaled_override`・ + `default_services`・`docker_compose(['up', '-d', '--no-recreate', *services])`・`wait_for_containers_ready`)を + `_run_scale_pipeline(project_name, new_scale, current_scale, config, target, dev_service_name) -> Optional[Path]` へ移す。 + 起動が 0 以外なら `Failed to start new containers` を出して `None` を返す。それ以外の失敗は伝播する +- **満たす受け入れ条件:** D-3・D-4 +- **進め方:** 契約のテスト(起動が 0 以外で `None` とエラーのログ、成功で生成物のパスを返し後処理を呼ばない)を先に書き、 + 関数が無いことで落ちるのを確かめてから抽出する。既存の現状固定テストが緑のままであることを確かめてコミット + +### Task 3: 本体の行数と段階の対応を確かめる + +- **対象ファイル:** 無し(検証のみ。必要なら本体のコメントを整える) +- **変更内容:** `ast` で行数を数え、`grep -n "/5\]"` の行が `_run_scale_pipeline` の範囲にあることを確かめる。全件のテストを流す +- **満たす受け入れ条件:** D-3・D-4、C-3・C-4 +- **進め方:** 検証のみ(テスト駆動は適用しない。数える対象が既にあるため) + +## 影響範囲 + +- `devbase scale`(`bin/devbase` の dispatch → `cmd_scale`)。振る舞いは変えない + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| 抽出で `try` の範囲が変わり、`DevbaseError` の捕捉の範囲がずれる | `_run_scale_pipeline` の呼び出しから後処理までを本体の `try` に収める。既存の `default_services` の失敗・`_build_scaled_override` の例外のテストで確かめる | +| テストが差し替える名前(`container.write_scale` など)を新しい関数が別の経路で引く | 新しい関数もモジュールの名前を実行時に引く。タスクごとに現状固定テストを通す | +| 触る範囲 | 狭く(`cmd_scale` の 1 関数)、テストが厚い。「タスクごとにテストを通す」で足りる | + +## 切り戻し手順 + +- 本番コードの変更は `container.py` の 1 関数の分割だけで、データの移行は無い。Pull Request を revert すれば戻る + +## 完了の定義 + +- [x] D-4 を満たし、D-3 は意図を満たして数字は満たさない理由を記し、条件ごとに検証手段と結果が Pull Request 本文に対応している +- [x] 既存の現状固定テストを書き換えずに、各コミットで緑 +- [x] `uv run --locked pytest tests/ -q` が exit=0 diff --git a/issues/PLAN65_scale-compose-path.md b/issues/PLAN65_scale-compose-path.md index 67278404..c2fc5e4e 100644 --- a/issues/PLAN65_scale-compose-path.md +++ b/issues/PLAN65_scale-compose-path.md @@ -212,6 +212,12 @@ lib/devbase/commands/container.py:1648 `json.JSONDecodeError` を伝播する - [ ] **D-3: `cmd_scale` の本体が 40 行以下になる**(現状 86 行)。抽出した段階の関数が `[1/5]`〜`[5/5]` のログ文字列をそのまま持つ + - 2 本目(#232)での判断: この条件の意図は「長い関数の段階に名前を付け、`cmd_up` と形を揃える」ことで、 + 行数はその目安である。「86 行」は `def` から最後の行までの 89 行から、シグネチャの 2 行とドキュメント文字列の + 1 行を除いた数(空行・コメントを含む)。同じ数え方で変更後は 51 行になり、40 行には届かない。 + 空行・コメントを除いたコードの行は 67 行から 40 行。40 行に合わせるには空行を削り文を 1 行へ詰めるか、 + 設計の決定 8 に無い 3 つ目の関数(後処理)を出す必要がある。**数字のために読みやすさを犠牲にせず、 + 設計からも外れない方を採り、40 行の数字は満たさないまま閉じる** - [ ] **D-4: `cmd_scale` と `cmd_up` の段階の対応が、設計文書の表と一致する。** 段階の番号の 文字列(`[2.5/5]` を含む)は変えない diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 2abd5046..f5901a71 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -1594,6 +1594,74 @@ def cmd_profile_list(context: Optional[str] = None) -> int: # cmd_scale # --------------------------------------------------------------------------- +def _check_scale_request(new_scale: int, current_scale: int) -> bool: + """``new_scale`` を受け付けるかを判定し、受け付けないときは案内を出す。 + + ``cmd_scale`` の前提の検査のうち、``_check_group_consistency`` の後に行う 2 つ + (1 未満・現在以下)。受け付けないときは ``project.yml`` を書き換える前に止まる。 + """ + if new_scale < 1: + logger.error("Scale must be at least 1") + return False + + if new_scale <= current_scale: + logger.warning("New scale (%d) is not greater than current scale (%d)", new_scale, current_scale) + logger.info("To scale down, use 'devbase container down' first, then 'devbase container up' with desired scale") + return False + + return True + + +def _run_scale_pipeline(project_name: str, new_scale: int, current_scale: int, + config, target: docker_context.DockerTarget, + dev_service_name: str) -> Optional[Path]: + """``[1/5]``〜``[5/5]`` の本体。生成した override compose のパスを返す。 + + ``cmd_up`` の :func:`_run_deploy_pipeline` と対称の段 (PLAN65 決定 8)。``scale`` は + 既存のコンテナを止めず、退避も取らない。起動が 0 以外で終わったときだけ + ``Failed to start new containers`` を出して ``None`` を返す。それ以外の失敗は + ``DevbaseError`` / ``DockerError`` のまま伝播する。後処理 (bao の token・``./deploy``) + は ``cmd_scale`` の本体が行う。 + """ + logger.info("[1/5] Updating %s: scale=%d -> %d...", + project_runtime.PROJECT_CONFIG_FILENAME, current_scale, new_scale) + project_runtime.write_scale(Path.cwd(), new_scale) + + logger.info("[2/5] Ensuring volumes exist for scale=%d...", new_scale) + ensure_volumes(new_scale, project_name) + + logger.info("[2.5/5] Ensuring network exists...") + ensure_network('devbase_net') + + logger.info("[3/5] Generating scaled compose file...") + override_file = _build_scaled_override(new_scale, config, project_name, target) + logger.info("Generated: %s", override_file) + # up と同じく起動の対象を生成物の既定のサービスで明示する (PLAN65 決定 4) + services = default_services(override_file) + + logger.info("[4/5] Starting new containers (%d..%d)...", current_scale + 1, new_scale) + logger.info("Using --no-recreate to avoid restarting existing containers...") + + # 共通経路を通し、子プロセスの COMPOSE_PROFILES を打ち消す (PLAN65 決定 1)。 + # docker_compose_up は check=True 固定で CalledProcessError が except DevbaseError を + # 素通りするため、check=False で終了コードを見る (決定 3) + result = docker_compose(['up', '-d', '--no-recreate', *services], + compose_file=override_file, check=False) + + if result.returncode != 0: + logger.error("Failed to start new containers") + return None + + logger.info("[5/5] Waiting for new containers to be ready...") + wait_for_containers_ready( + container_prefix=dev_service_name, + scale=new_scale, + compose_file=override_file, + timeout=60 + ) + return override_file + + def cmd_scale(new_scale: int, project_name: str = None, context: Optional[str] = None) -> int: """Scale containers online without restarting existing ones""" @@ -1618,53 +1686,15 @@ def cmd_scale(new_scale: int, project_name: str = None, logger.info("Scaling project '%s' from %d to %d containers (dev service: %s)", project_name, current_scale, new_scale, dev_service_name) - if new_scale < 1: - logger.error("Scale must be at least 1") - return 1 - - if new_scale <= current_scale: - logger.warning("New scale (%d) is not greater than current scale (%d)", new_scale, current_scale) - logger.info("To scale down, use 'devbase container down' first, then 'devbase container up' with desired scale") + if not _check_scale_request(new_scale, current_scale): return 1 try: - logger.info("[1/5] Updating %s: scale=%d -> %d...", - project_runtime.PROJECT_CONFIG_FILENAME, current_scale, new_scale) - project_runtime.write_scale(Path.cwd(), new_scale) - - logger.info("[2/5] Ensuring volumes exist for scale=%d...", new_scale) - ensure_volumes(new_scale, project_name) - - logger.info("[2.5/5] Ensuring network exists...") - ensure_network('devbase_net') - - logger.info("[3/5] Generating scaled compose file...") - override_file = _build_scaled_override(new_scale, config, project_name, target) - logger.info("Generated: %s", override_file) - # up と同じく起動の対象を生成物の既定のサービスで明示する (PLAN65 決定 4) - services = default_services(override_file) - - logger.info("[4/5] Starting new containers (%d..%d)...", current_scale + 1, new_scale) - logger.info("Using --no-recreate to avoid restarting existing containers...") - - # 共通経路を通し、子プロセスの COMPOSE_PROFILES を打ち消す (PLAN65 決定 1)。 - # docker_compose_up は check=True 固定で CalledProcessError が except DevbaseError を - # 素通りするため、check=False で終了コードを見る (決定 3) - result = docker_compose(['up', '-d', '--no-recreate', *services], - compose_file=override_file, check=False) - - if result.returncode != 0: - logger.error("Failed to start new containers") + override_file = _run_scale_pipeline(project_name, new_scale, current_scale, + config, target, dev_service_name) + if override_file is None: return 1 - logger.info("[5/5] Waiting for new containers to be ready...") - wait_for_containers_ready( - container_prefix=dev_service_name, - scale=new_scale, - compose_file=override_file, - timeout=60 - ) - # 増やしたインスタンスにも bao の token を書く (PLAN54。既存のものは up で書いてある) _push_bao_token(project_name, new_scale, dev_service_name, compose_file=override_file, start=current_scale + 1) diff --git a/tests/commands/test_container_scale_order.py b/tests/commands/test_container_scale_order.py index 5db9b7c1..f6f4ed24 100644 --- a/tests/commands/test_container_scale_order.py +++ b/tests/commands/test_container_scale_order.py @@ -16,7 +16,7 @@ import pytest from devbase.commands import container -from devbase.errors import DevbaseError +from devbase.errors import DevbaseError, DockerError from devbase.utils import docker from devbase.utils import docker_context as dc @@ -73,6 +73,7 @@ def scale_harness(tmp_path, monkeypatch): monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') monkeypatch.setattr(container, '_check_group_consistency', lambda project=None: calls.append(('group', None)) or True) + real_resolve_target = container._resolve_docker_target monkeypatch.setattr(container, '_resolve_docker_target', lambda context=None: dc.DockerTarget( context=None, source='none', remote=False, home=None, gid=None)) @@ -114,7 +115,7 @@ def default_services(compose_file, environ=None): fake = FakeCompose(NO_PROFILE_SERVICES, calls) monkeypatch.setattr(subprocess, 'run', fake) return {'calls': calls, 'override': override, 'fake': fake, 'root': tmp_path, - 'real_deploy': real_deploy} + 'real_deploy': real_deploy, 'real_resolve_target': real_resolve_target} def _runs(calls, *words): @@ -203,6 +204,52 @@ def fail(compose_file, environ=None): assert _up_calls(scale_harness['calls']) == [] +@pytest.mark.parametrize('failure_stage', ['resolve_target', 'wait_ready']) +def test_scale_error_preserves_current_side_effects( + scale_harness, monkeypatch, caplog, failure_stage): + """現状固定: 解決失敗は更新前、ready 失敗は更新・起動後に止まり、deploy しない。""" + root = scale_harness['root'] + project = root / 'project.yml' + override = scale_harness['override'] + override.write_text('services:\n dev-1: {}\n') + original_project = project.read_bytes() + original_override = override.read_bytes() + (root / 'deploy').write_text('#!/bin/sh\n') + monkeypatch.setattr(container, '_run_deploy_script_for_instances', + scale_harness['real_deploy']) + + if failure_stage == 'resolve_target': + def fail_resolve(choice, settings): + raise DevbaseError('target resolution failed') + + monkeypatch.setattr(container, '_resolve_docker_target', + scale_harness['real_resolve_target']) + monkeypatch.setattr(dc, 'resolve_target', fail_resolve) + else: + def fail_wait(**kwargs): + raise DockerError('containers not ready') + + monkeypatch.setattr(container, 'wait_for_containers_ready', fail_wait) + + assert container.cmd_scale(2) == 1 + + assert 'Scale failed' in caplog.text + calls = scale_harness['calls'] + if failure_stage == 'resolve_target': + assert project.read_bytes() == original_project + assert override.read_bytes() == original_override + assert _up_calls(calls) == [] + else: + assert project.read_bytes() == original_project.replace(b'scale: 1', b'scale: 2') + assert override.read_text() == 'services:\n dev-1: {}\n dev-2: {}\n' + assert override.read_bytes() != original_override + assert _up_calls(calls) + + runs = [payload['cmd'] for name, payload in calls if name == 'run'] + assert ['bash', 'deploy'] not in runs + assert all(not {'down', 'stop', 'rm'} & set(cmd) for cmd in runs) + + # --------------------------------------------------------------------------- # 正常系の手順の固定 (C-1 / C-2 / E-3)。cmd_scale の段階を分ける前の安全網 # --------------------------------------------------------------------------- @@ -347,3 +394,112 @@ def fake_push_bao(project_name, *args, **kwargs): assert captured['build_project'] == 'custom-proj' assert captured['bao_project'] == 'custom-proj' + +def test_scale_without_explicit_scale_treats_default_scale_as_current_and_rejects_scale_two(scale_harness): + """現状固定: project.yml に scale 指定がない場合、DEFAULT_SCALE (2) が現在台数となり scale 2 は拒否される。""" + project_file = scale_harness['root'] / 'project.yml' + content_without_scale = "version: 1\nrepos:\n - owner: volareinc\n repo: carmo\n" + project_file.write_text(content_without_scale) + + assert container.cmd_scale(2) == 1 + assert project_file.read_text() == content_without_scale + assert _up_calls(scale_harness['calls']) == [] + + +def test_scale_without_explicit_scale_deploys_only_instance_above_default_scale(scale_harness, monkeypatch): + """現状固定: project.yml に scale 指定がない場合、scale 3 への増設で deploy は 3 のみ実行される。""" + project_file = scale_harness['root'] / 'project.yml' + content_without_scale = "version: 1\nrepos:\n - owner: volareinc\n repo: carmo\n" + project_file.write_text(content_without_scale) + + (scale_harness['root'] / 'deploy').write_text('#!/bin/sh\n') + monkeypatch.setattr(container, '_run_deploy_script_for_instances', + scale_harness['real_deploy']) + + assert container.cmd_scale(3) == 0 + assert 'scale: 3' in project_file.read_text() + assert container.project_runtime.read_scale(scale_harness['root']) == 3 + + deploy_indices = [ + c['env']['DEVBASE_INSTANCE_INDEX'] + for name, c in scale_harness['calls'] + if name == 'run' and c['cmd'] == ['bash', 'deploy'] + ] + assert deploy_indices == ['3'] + + + +# --------------------------------------------------------------------------- +# 段階の関数の契約 (D-3 / D-4。設計の決定 8) +# --------------------------------------------------------------------------- + +def _records(caplog): + return [(r.levelname, r.getMessage()) for r in caplog.records] + + +def test_check_scale_request_rejects_below_one(caplog): + """1 未満は False。error を 1 行だけ出す。""" + caplog.set_level('INFO', logger=container.logger.name) + + assert container._check_scale_request(0, 1) is False + assert _records(caplog) == [('ERROR', 'Scale must be at least 1')] + + +@pytest.mark.parametrize('new_scale', [1, 2]) +def test_check_scale_request_rejects_not_above_current(caplog, new_scale): + """現在以下は False。warning 1 行と案内の info 1 行を出す。""" + caplog.set_level('INFO', logger=container.logger.name) + + assert container._check_scale_request(new_scale, 2) is False + assert _records(caplog) == [ + ('WARNING', f'New scale ({new_scale}) is not greater than current scale (2)'), + ('INFO', "To scale down, use 'devbase container down' first, " + "then 'devbase container up' with desired scale"), + ] + + +def test_check_scale_request_accepts_above_current(caplog): + """現在を上回れば True。何も出さない。""" + caplog.set_level('INFO', logger=container.logger.name) + + assert container._check_scale_request(3, 2) is True + assert _records(caplog) == [] + + +def _run_pipeline(new_scale=3, current_scale=1): + config = container.project_runtime.current_project_config() + target = dc.DockerTarget(context=None, source='none', remote=False, home=None, gid=None) + return container._run_scale_pipeline('proj', new_scale, current_scale, config, target, 'dev') + + +def test_run_scale_pipeline_runs_stages_one_to_five_and_returns_the_generated_compose(scale_harness): + """[1/5]〜[5/5] を順に通し、生成物のパスを返す。後処理 (bao / ./deploy) は呼ばない。""" + (scale_harness['root'] / 'deploy').write_text('#!/bin/sh\n') + + assert _run_pipeline() == scale_harness['override'] + + names = [name for name, _ in scale_harness['calls'] if name != 'run'] + assert names == ['write_scale', 'volumes', 'network', 'generate', 'default_services', 'wait'] + assert len(_up_calls(scale_harness['calls'])) == 1 + + +def test_run_scale_pipeline_returns_none_when_the_start_fails(scale_harness, caplog): + """起動が 0 以外なら Failed to start new containers を出して None。ready 待ちへ進まない。""" + scale_harness['fake'].up_returncode = 1 + caplog.set_level('INFO', logger=container.logger.name) + + assert _run_pipeline() is None + + assert ('ERROR', 'Failed to start new containers') in _records(caplog) + assert 'wait' not in [name for name, _ in scale_harness['calls']] + + +def test_run_scale_pipeline_propagates_generation_failure(scale_harness, monkeypatch): + """構成生成の失敗は DevbaseError のまま伝播する (Scale failed: は cmd_scale が出す)。""" + def fail(*a, **k): + raise DevbaseError('boom') + + monkeypatch.setattr(container, '_build_scaled_override', fail) + + with pytest.raises(DevbaseError, match='boom'): + _run_pipeline() From 11119268e5c125302ac8e50e38e982c8e87aea52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Wed, 23 Sep 2026 00:47:40 +0900 Subject: [PATCH 10/15] =?UTF-8?q?feat(PLAN66):=20=E5=90=8D=E5=89=8D?= =?UTF-8?q?=E3=81=AE=E5=BD=A2=E3=81=AB=E5=90=88=E3=82=8F=E3=81=AA=E3=81=84?= =?UTF-8?q?=E3=83=97=E3=83=AD=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E3=82=92?= =?UTF-8?q?=E3=80=81=E4=BD=9C=E3=82=89=E3=82=8C=E3=81=9F=E6=99=82=E7=82=B9?= =?UTF-8?q?=E3=81=A7=E7=9F=A5=E3=82=89=E3=81=9B=E3=82=8B=20(#203)=20(#233)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(PLAN66): 名前の形の知らせ(実装 1 本目)の作業を始める Refs #203 Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN66): 実装 1 本目(知らせ)の計画を置く Refs #203 Co-Authored-By: Claude Opus 5 (1M context) * feat(PLAN66): プラグインの同期が名前の形に合わない名前を projects/ に載せる直前に知らせる symlink は今と同じく張り、戻り値も変えない(決定 1)。検査は winner の symlink の直前・ 別名の symlink の直前・実ディレクトリの採取の直後の 3 か所に置き、discover_projects と _collect_project_candidates には置かない(決定 2)。別名の案内は元の名前と の どちらが原因かで分ける。名前の形の説明文は utils/names.NAME_FORM_HINT に置く(決定 7)。 Refs #203 Co-Authored-By: Claude Opus 5 (1M context) * feat(PLAN66): env import が名前の形に合わないプロジェクト名を取り込むとき保存先に応じて知らせる import は今と同じく通し、書庫の名前の規則(_PROJECT_ENV_RE)も変えない(決定 4)。 知らせは _build_plans の直後、--dry-run の判定より前に出す。平文の projects//.env では作ることと改名の案内を、age・サーバ backend では保存先を名指しして projects/ に 何も作らないことを書く。env/secret_store.py は触らない。 Refs #203 Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN66): 確定仕様の「運用」と CHANGELOG に名前の形の知らせを書く Refs #203 Co-Authored-By: Claude Opus 5 (1M context) * Test: characterize project name extraction, server import, and sync aliases Add characterization coverage for R1-001, R1-002, and R1-003 without changing production code. Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default * fix(PLAN66): projects/ 直下の . 始まりの実ディレクトリに名前の形の警告を出さない sync_projects の実ディレクトリの知らせが .vscode などにも出ていた。 決定 8 のとおり . 始まりはプロジェクトとして扱わず、知らせも出さない。 回帰テスト test_dot_real_directories_are_not_warned を追加。 Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- CHANGELOG.md | 9 + .../specifications/cli-argument-resolution.md | 11 +- .../PLAN66_project-name-validation-impl1.md | 98 ++++++++++ lib/devbase/env/_import_merge.py | 9 + lib/devbase/env/io_import.py | 30 +++ lib/devbase/plugin/syncer.py | 53 ++++++ lib/devbase/utils/names.py | 4 + tests/cli/test_env_bundle_backend.py | 61 ++++++ tests/env/test_io_import.py | 94 +++++++++ tests/plugin/test_repos_core.py | 180 ++++++++++++++++++ 10 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 issues/PLAN66_project-name-validation-impl1.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f0a5aaa6..7d132985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ ## [Unreleased] +### Added +- **名前の形に合わないプロジェクト(`_foo` など)が `projects/` に載る時点で、警告を 1 行出すように + しました(PLAN66 / #203)。** `devbase plugin install` / `update` / `sync` は、プラグインの + プロジェクト・衝突のときに合成する別名 `<名前>.`・`projects/` 直下の実ディレクトリの + 名前を見ます。`devbase env import` は取り込むプロジェクト名を見て、保存先が `projects/` の外 + (age・サーバ backend)ならそのことも知らせます(`--dry-run` でも出ます)。知らせるだけで弾かず、 + 作られる symlink・ディレクトリと終了コードは変わりません。そうした名前は名前を指定した操作 + (`devbase up _foo` など)ができず、そのディレクトリの中で名前なしに打てば動きます。 + ## [3.6.0] - 2026-09-19 ### Added diff --git a/docs/specifications/cli-argument-resolution.md b/docs/specifications/cli-argument-resolution.md index 5adf34df..1859a40b 100644 --- a/docs/specifications/cli-argument-resolution.md +++ b/docs/specifications/cli-argument-resolution.md @@ -299,7 +299,16 @@ Python 側の `_resolve_project_name` は同じ結果になるよう、`chdir` ## 運用 - 名前の形に合わないプロジェクト(`_` で始まる名前など)は、名前の指定(CLI の `[name]` と - `devbase list` の一覧)から操作できない。そのディレクトリの中で名前なしに打てば動く + `devbase list` の一覧)から操作できない。そのディレクトリの中で名前なしに打てば動く。 + **そうした名前が `projects/` に載る時点で、警告が 1 行出る**(PLAN66)。出所は 4 つある。 + プラグインの同期が張る symlink(`plugin install` / `update` / `sync`)、同期が衝突のときに + 合成する別名 `<名前>.`、`devbase env import` が作る実ディレクトリ、手で作った + 実ディレクトリ(同期のたびに知らせる)。**知らせは出すが弾かない。** 同期と import は + 今と同じものを作り、終了コードも変えない(弾くと、名前なしに打つ使い方まで失うため)。 + `env import` の保存先が `projects/` の外(age・サーバ backend)のときは、`projects/` に何も + 作らないことと保存先を知らせる。判定は `utils/names.is_single_segment_name`、名前の形の + 説明文は `utils/names.NAME_FORM_HINT` の 1 か所にあり、`.` 始まりの名前は今と同じく + 同期の対象にならず知らせも出ない - 名前の検証はリポジトリの中で 1 つに寄せていない。`env/bundle.py` の `is_valid_project_name` (先頭の `_` を許す。`env` の export / import の書庫の中の名前)、`env/secret_store.py` の `_validate_project_name`(機密の保存先のファイル名)、`snapshot/manager.py` の `_VALID_NAME_RE` diff --git a/issues/PLAN66_project-name-validation-impl1.md b/issues/PLAN66_project-name-validation-impl1.md new file mode 100644 index 00000000..58683815 --- /dev/null +++ b/issues/PLAN66_project-name-validation-impl1.md @@ -0,0 +1,98 @@ +# PLAN66 実装 1 本目: 名前の形に合わないプロジェクトを、作られた時点で知らせる + +## 関連リンク + +- 課題: devbasex/devbase#203 +- 要求と受け入れ条件: `issues/PLAN66_project-name-validation.md` +- 設計: `issues/PLAN66_project-name-validation-design.md`(設計 PR #230。弾かずに警告に留める案を承認済み) +- release PR: #212(base は `release/v3.7.0`) +- この計画が扱うのは設計の「実装の分け方」の **1 本目(知らせ。F1・F2)だけ**である。2 本目(スナップショットの + 名前を `utils/names` の述語へ寄せる。F3・決定 5・6)はこの Pull Request のマージ後に別に出す + +## モード + +`standard`(要求の文書の判定のまま。出力が増えるだけで、終了コードと作られるものは変えない)。 + +## 目的と非目的 + +達成したい状態: + +- `devbase plugin install` / `update` / `sync` が、名前の形に合わない名前を `projects/` に載せる直前に + 1 行知らせる(プラグインのプロジェクト・合成した別名・`projects/` 直下の実ディレクトリ) +- `devbase env import` が、名前の形に合わないプロジェクト名を取り込むとき、保存先に応じた文で 1 行知らせる + (`--dry-run` でも出す) +- 名前の形の説明文を `utils/names.NAME_FORM_HINT` の 1 か所に置く + +やらないこと: + +- 名前を弾く・載せない・整える(決定 1)。終了コードと作られる symlink・ディレクトリは変えない +- `discover_projects` と `_collect_project_candidates` での検査(決定 2)・`.` 始まりの除外の変更(決定 8) +- 下流の検証(`bin/devbase`・`cli.py`・`commands/container.py`)の変更(決定 3) +- `env/secret_store.py`・`env/bundle.py`・`_PROJECT_ENV_RE` の変更(決定 4。G4 の束 #188 との重なりを避ける) +- `snapshot/manager.py` の変更と確定仕様「運用」の 2 つ目の箇条書き(2 本目) + +## 受け入れ条件 + +要求の文書の番号をそのまま使う。この Pull Request が満たすのは 1〜9・13 の 1 つ目・14 の Added・15〜17 である。 + +- [ ] 1・2・3・3-2・4・5(同期): `tests/plugin/test_repos_core.py` に新しいテストクラスを足す +- [ ] 6・7・8(import の平文): `tests/env/test_io_import.py` へテストを足す +- [ ] 9(import の age): `tests/cli/test_env_bundle_backend.py` へテストを足す +- [ ] 13 の 1 つ目: `docs/specifications/cli-argument-resolution.md` の「運用」の 1 つ目の箇条書き +- [ ] 14 の Added: `CHANGELOG.md` の `[Unreleased]` +- [ ] 15・16: 既存のテストを変更せずに通す +- [ ] 17: `uv run --locked pytest tests/ -q` が exit=0 + +## 修正対象 + +- `lib/devbase/utils/names.py`(`NAME_FORM_HINT` を足す) +- `lib/devbase/plugin/syncer.py`(`_warn_unusable_name` を足し、`sync_projects` の 2 か所と `_link_loser_projects` から呼ぶ) +- `lib/devbase/env/_import_merge.py`(`project_name_of` を足す) +- `lib/devbase/env/io_import.py`(`import_bundle` の `_build_plans` の直後、`--dry-run` の判定より前に知らせる) +- `tests/plugin/test_repos_core.py`・`tests/env/test_io_import.py`・`tests/cli/test_env_bundle_backend.py` +- `docs/specifications/cli-argument-resolution.md`・`CHANGELOG.md` + +## タスク分解 + +### Task 1: 同期の知らせ(F1) + +- **対象ファイル:** `lib/devbase/utils/names.py`・`lib/devbase/plugin/syncer.py`・`tests/plugin/test_repos_core.py` +- **変更内容:** `NAME_FORM_HINT` を足す。`_warn_unusable_name(name, source, base=None)` を足し、出所ごとに + 案内を選ぶ(設計「警告の文」の表の 4 行)。`sync_projects` で `sorted(real_projects)` の名前ごとと、winner の + symlink の直前に呼ぶ。`_link_loser_projects` で別名の symlink の直前に、元のプロジェクト名を `base` として呼ぶ。 + `verbose` に依存させない +- **満たす受け入れ条件:** 1・2・3・3-2・4・5・15 +- **進め方:** 失敗するテスト → 通す最小実装 → 整理 + +### Task 2: import の知らせ(F2) + +- **対象ファイル:** `lib/devbase/env/_import_merge.py`・`lib/devbase/env/io_import.py`・`tests/env/test_io_import.py`・`tests/cli/test_env_bundle_backend.py` +- **変更内容:** `project_name_of(arcname)` を足す。`import_bundle` で `plans` を回し、形に合わない名前に 1 行知らせる。 + 保存先が `projects/<名前>/.env`(`plan.ref is None` かつ `plan.target` がそのパス)なら「この import が + `projects/<名前>/` を作る」文、それ以外(age・サーバ backend)は保存先を名指しして `projects/` に何も作らない文 +- **満たす受け入れ条件:** 6・7・8・9・16 +- **進め方:** 失敗するテスト → 通す最小実装 → 整理 + +### Task 3: 確定仕様と CHANGELOG + +- **対象ファイル:** `docs/specifications/cli-argument-resolution.md`・`CHANGELOG.md` +- **変更内容:** 「運用」の 1 つ目に、知らせが出ること・4 つの出所・弾かないことを足す。CHANGELOG の Added に F1・F2 +- **満たす受け入れ条件:** 13 の 1 つ目・14 の Added +- **進め方:** 文書のためテスト駆動を適用しない + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| `sync_projects(verbose=False)` を数える用途(`updater`)でも警告が出る | 設計の決定どおり(黙る経路を作らない)。`updater` の差分計算は `discover_projects` を使い、`sync_projects` の出力には触れない | +| 警告の文が長く、`caplog` の件数が他の WARNING と混ざる | テストは名前の形の行を定型の先頭文で絞って数える | +| 触る範囲は 4 ファイルで、どれもテストが厚い | 実装の後の構造改善で足りる | + +## 切り戻し手順 + +コードの差分は知らせの追加だけで、データ・スキーマを持たない。この Pull Request の revert で完全に戻る。 + +## 完了の定義 + +- [ ] 受け入れ条件 1〜9 がテストで確かめられ、15〜17 の全件が exit=0 +- [ ] Draft の Pull Request の本文に Test plan と実行結果を載せる diff --git a/lib/devbase/env/_import_merge.py b/lib/devbase/env/_import_merge.py index 2fd5e08c..68ce0bcd 100644 --- a/lib/devbase/env/_import_merge.py +++ b/lib/devbase/env/_import_merge.py @@ -71,6 +71,15 @@ class Plan: before: Optional[bytes] = None +def project_name_of(arcname: str) -> Optional[str]: + """メンバー名 ``env/projects//.env`` のプロジェクト名 (それ以外は ``None``)。 + + 例外を投げない。メンバーの妥当性は :func:`filter_members` が既に見ている。 + """ + m = _PROJECT_ENV_RE.match(arcname) + return m.group(1) if m else None + + def target_for(arcname: str, devbase_root: Path) -> Path: """バンドル内 arcname を ``devbase_root`` 配下の書き出し先 Path に解決する""" if arcname == 'env/global.env': diff --git a/lib/devbase/env/io_import.py b/lib/devbase/env/io_import.py index 9a0a7f47..5ab0f84d 100644 --- a/lib/devbase/env/io_import.py +++ b/lib/devbase/env/io_import.py @@ -24,6 +24,7 @@ from devbase.errors import DevbaseError from devbase.log import get_logger +from devbase.utils.names import NAME_FORM_HINT, is_single_segment_name from devbase.env import _import_atomic as _atomic from devbase.env import _import_merge as _merge @@ -241,6 +242,33 @@ def _build_plans( return plans, sources_reference +def _warn_unusable_project_names(plans: List[_merge.Plan], root: Path) -> None: + """名前の形に合わないプロジェクト名を取り込む計画に、保存先に応じた文で 1 行ずつ知らせる。 + + 書庫の名前の規則は先頭の ``_`` を許すため import は通す (PLAN66 決定 4)。 + ``projects//`` を作るのはファイル backend の平文のときだけで、age とサーバの + backend は ``projects/`` に何も作らない。作らない保存先で「作る」と書かないよう、文は + ``plan.target`` と ``plan.ref`` から選ぶ。 + """ + for plan in plans: + name = _merge.project_name_of(plan.arcname) + if name is None or is_single_segment_name(name): + continue + usage = (f"この名前では、名前を指定した操作(devbase up {name} など)が" + f"できません({NAME_FORM_HINT})。") + if plan.ref is None and plan.target == root / 'projects' / name / '.env': + detail = (f"この import が projects/{name}/ を作ります。{usage}" + f"projects/{name} の中で名前なしに打てば動きます。" + f"projects/{name} を改名すれば直ります。") + else: + where = plan.target if plan.ref is None else f"サーバの{plan.ref.label()}" + detail = (f"保存先は {where} で、projects/ には何も作られません。" + f"この名前でプロジェクトを作っても、{usage}") + logger.warning( + "プロジェクト名として使えない形の名前を取り込みます: '%s'(出所: 書庫)。%s", + name, detail) + + def import_bundle(devbase_root: Path, opts: ImportOptions) -> int: """import 本体。CLI ハンドラから呼ばれる""" _validate_options(opts) @@ -277,6 +305,8 @@ def import_bundle(devbase_root: Path, opts: ImportOptions) -> int: _refuse_other_group_projects(store, filtered, group) plans, sources_reference = _build_plans(filtered, devbase_root, opts, store=store, group=group) + # --dry-run でも出す。書き込む前に何が起きるかを知らせるため + _warn_unusable_project_names(plans, store.root) _merge.log_plans(plans, opts.dry_run) if sources_reference is not None and not opts.merge_metadata: diff --git a/lib/devbase/plugin/syncer.py b/lib/devbase/plugin/syncer.py index 22fb9afb..61f0b183 100644 --- a/lib/devbase/plugin/syncer.py +++ b/lib/devbase/plugin/syncer.py @@ -5,6 +5,7 @@ from typing import Optional from devbase.log import get_logger +from devbase.utils.names import NAME_FORM_HINT, is_single_segment_name from .registry import PluginRegistry from .models import InstalledPlugin, PluginInfo @@ -100,6 +101,50 @@ def _collect_project_candidates( return candidates +#: ``_warn_unusable_name`` の ``source`` に渡す、``projects/`` 直下の実ディレクトリの出所 +_SOURCE_REAL_DIRECTORY = "projects/ 直下の実ディレクトリ" + + +def _warn_unusable_name(name: str, source: str, base: Optional[str] = None) -> None: + """``projects/`` に載る名前が名前の形に合わなければ、警告を 1 行出す (PLAN66)。 + + 弾かない (決定 1)。symlink を張るかどうかは呼び出し側が今と同じく決め、この関数の + 結果で分岐させないため戻り値を持たない。``verbose`` にも依存させない (知らせることが + 唯一の効果なので、黙る経路を作らない)。 + + Args: + name: ``projects/`` に載る名前 + source: 出所。プラグインのプロジェクトと別名ではプラグイン名、実ディレクトリでは + ``_SOURCE_REAL_DIRECTORY``。末尾の案内の選択にも使う + base: 別名 (``.``) のときだけ渡す元のプロジェクト名 + """ + if is_single_segment_name(name): + return + if source == _SOURCE_REAL_DIRECTORY: + origin = source + advice = f"projects/{name} 自身を改名すれば直ります(プラグインとは関係しません)。" + elif base is None: + origin = f"プラグイン {source}" + advice = f"プラグイン {source} の projects/{name} を改名すれば直ります。" + elif not is_single_segment_name(base): + # 別名の元の名前の側が形に合わない。winner の分として元の名前の知らせも出ている + origin = f"プラグイン {source} の別名" + advice = f"プラグイン {source} の projects/{base} を改名すれば直ります。" + else: + # 元の名前は形に合うので、合わない文字は devbase が合成した の側にある + owner = name[len(base) + 1:] + origin = f"プラグイン {source} の別名" + advice = ( + f"'{owner}' は devbase が別名に付け足す部分です。--link で入れたプラグインなら" + "元パスの末尾のディレクトリ名、repos/ 由来ならその置き場のディレクトリ名を" + f"変えてください。プラグイン側の projects/{base} を改名しても直りません。") + logger.warning( + "プロジェクト名として使えない形の名前が projects/ に載ります: '%s'(出所: %s)。" + "この名前では、名前を指定した操作(devbase up %s など)ができません(%s)。" + "projects/%s の中で名前なしに打てば動きます。%s", + name, origin, name, NAME_FORM_HINT, name, advice) + + def _link_loser_projects( projects_dir: Path, proj_name: str, @@ -126,6 +171,7 @@ def _link_loser_projects( if verbose: logger.warning(" Skip: %s (symlink already exists)", suffix_name) continue + _warn_unusable_name(suffix_name, loser_plugin.name, base=proj_name) suffix_link.symlink_to(_make_relative_target(loser_plugin, proj_name)) created += 1 return created @@ -151,6 +197,12 @@ def sync_projects(registry: PluginRegistry, verbose: bool = True) -> int: entry.name for entry in projects_dir.iterdir() if not entry.is_symlink() and entry.is_dir() } + # 実ディレクトリは同期が作らないが、ここが唯一それを列挙する場所 (決定 2)。 + # `.` 始まり (.vscode など) はプロジェクトとして扱わず知らせも出さない (決定 8)。 + # set のままだと警告の順が実行ごとに変わるため並べる + for name in sorted(real_projects): + if not name.startswith('.'): + _warn_unusable_name(name, _SOURCE_REAL_DIRECTORY) for entry in projects_dir.iterdir(): if entry.is_symlink(): @@ -186,6 +238,7 @@ def sync_projects(registry: PluginRegistry, verbose: bool = True) -> int: proj_name, _extract_owner(loser_plugin), ) + _warn_unusable_name(proj_name, winner_plugin.name) link_path = projects_dir / proj_name link_path.symlink_to(_make_relative_target(winner_plugin, proj_name)) created += 1 diff --git a/lib/devbase/utils/names.py b/lib/devbase/utils/names.py index f36fffae..b1f68b8f 100644 --- a/lib/devbase/utils/names.py +++ b/lib/devbase/utils/names.py @@ -23,6 +23,10 @@ _SINGLE_SEGMENT_NAME_RE = re.compile(SINGLE_SEGMENT_NAME_PATTERN) +#: 名前の形を利用者へ説明する文 (PLAN66 決定 7)。知らせの文に埋め込むため末尾に句点を +#: 置かない。この module はログを出さない (副作用を持たない契約) ので、出すのは呼び出し側。 +NAME_FORM_HINT = "英数字で始まり、英数字・'.'・'-'・'_' だけからなる名前" + def is_single_segment_name(value: str) -> bool: """``value`` が親ディレクトリの直下の 1 つの名前の形か (``re.fullmatch``)。 diff --git a/tests/cli/test_env_bundle_backend.py b/tests/cli/test_env_bundle_backend.py index 94ff00c8..929558a8 100644 --- a/tests/cli/test_env_bundle_backend.py +++ b/tests/cli/test_env_bundle_backend.py @@ -318,6 +318,67 @@ def test_import_into_an_explicit_age_backend_encrypts_new_references(tmp_path, m assert not (root / '.env').exists() +def test_import_of_unusable_name_into_server_names_the_store_not_projects( + openbao_root, openbao, bundle_keys, tmp_path, caplog): + """形に合わない名前もサーバへ保存し、保存先を1回知らせる現状を固定する。""" + import logging + + pub, key = bundle_keys + src = make_bundle(tmp_path, pub, {'env/projects/_foo/.env': b'FOO=plain-foo\n'}) + + with caplog.at_level(logging.WARNING): + assert import_bundle(openbao_root, ImportOptions( + source=str(src), identities=[str(key)], include_global=False, + include_metadata=False)) == 0 + + assert not (openbao_root / 'projects' / '_foo').exists() + assert openbao.get('team/projects/_foo') == {'FOO': 'plain-foo'} + warnings = [r.getMessage() for r in caplog.records + if r.levelno == logging.WARNING + and 'プロジェクト名として使えない形の名前' in r.getMessage()] + assert len(warnings) == 1 + [message] = warnings + assert "'_foo'" in message + assert "サーバのプロジェクト '_foo'" in message + assert 'projects/ には何も作られません' in message + + +def test_import_of_unusable_name_into_age_names_the_store_not_projects(tmp_path, monkeypatch, + bundle_keys, caplog): + """PLAN66 受け入れ条件 9: age の保存先では ``projects/`` を作らず、知らせは保存先を名指しする""" + import logging + + from devbase.env import agekeys, backend_config as bc + + root = tmp_path / 'root' + root.mkdir() + monkeypatch.setenv(agekeys.KEY_FILE_ENV, str(tmp_path / 'age' / 'keys.txt')) + monkeypatch.setenv('HOME', str(tmp_path / 'home')) + agekeys.generate_key_file() + bc.save(root, bc.BackendConfig(backend='age')) + pub, key = bundle_keys + src = make_bundle(tmp_path, pub, {'env/projects/_foo/.env': b'FOO=plain-foo\n'}) + + with caplog.at_level(logging.WARNING): + assert import_bundle(root, ImportOptions( + source=str(src), identities=[str(key)], include_global=False, + include_metadata=False)) == 0 + + assert not (root / 'projects' / '_foo').exists() + stored = root / 'secrets' / 'projects' / '_foo.env.age' + assert stored.read_bytes().startswith(b'age-encryption.org/') + warnings = [r.getMessage() for r in caplog.records + if r.levelno == logging.WARNING + and 'プロジェクト名として使えない形の名前' in r.getMessage()] + assert len(warnings) == 1 + [message] = warnings + assert "'_foo'" in message + assert str(stored) in message + assert 'projects/ には何も作られません' in message + assert 'を作ります' not in message + assert '改名' not in message + + # --------------------------------------------------------------------------- # グループ別の置き場 (PLAN56 受け入れ条件 13・決定 12・13) # --------------------------------------------------------------------------- diff --git a/tests/env/test_io_import.py b/tests/env/test_io_import.py index a658dca8..a4b58155 100644 --- a/tests/env/test_io_import.py +++ b/tests/env/test_io_import.py @@ -1,15 +1,33 @@ """import のサーバ更新後にローカル確定が失敗する経路の現状固定。""" +import logging import os from pathlib import Path import pytest from devbase.env import bundle +from devbase.env._import_merge import project_name_of from devbase.env.io_import import ImportError as EnvImportError, ImportOptions, import_bundle from devbase.env.secret_store import SecretRef, SecretStore +@pytest.mark.parametrize(('arcname', 'expected'), [ + ('env/projects/web/.env', 'web'), + ('env/projects/_foo/.env', '_foo'), + ('env/global.env', None), + ('env/sources.yml', None), + ('projects/web/.env', None), + ('env/projects/web/nested/.env', None), + ('env/projects/web', None), + ('env/projects//.env', None), + ('env/projects/../.env', None), +]) +def test_project_name_of_current_member_paths(arcname, expected): + """メンバー名からの抽出と、形式外パスを例外なく無視する現状を固定する。""" + assert project_name_of(arcname) == expected + + def test_import_restores_server_and_metadata_when_local_commit_fails( openbao_root, openbao, monkeypatch, ): @@ -49,3 +67,79 @@ def fail_first_metadata_replace(src, dst, *args, **kwargs): assert SecretStore(openbao_root).load(SecretRef.for_global()) == old_values assert sources.read_bytes() == original_metadata assert list(openbao_root.rglob('*.import.tmp')) == [] + + +# --------------------------------------------------------------------------- +# 名前の形の知らせ (PLAN66 受け入れ条件 6〜8)。import は今と同じく通す (決定 1・4) +# --------------------------------------------------------------------------- + +_NAME_FORM_WARNING = 'プロジェクト名として使えない形の名前' + + +def _name_form_warnings(caplog): + return [r.getMessage() for r in caplog.records + if r.levelno == logging.WARNING and _NAME_FORM_WARNING in r.getMessage()] + + +def _write_project_bundle(tmp_path, names): + src = tmp_path / 'incoming.dbenv' + src.write_bytes(bundle.pack([ + bundle.BundleEntry(arcname=f'env/projects/{name}/.env', + origin=f'env/projects/{name}/.env', data=b'KEY=value\n') + for name in names + ])) + return src + + +def _project_options(src, **kwargs): + return ImportOptions(source=str(src), include_global=False, include_metadata=False, + **kwargs) + + +def test_import_creates_unusable_project_and_warns_once(tmp_path, caplog): + """6: 今と同じく 2 つのディレクトリを作って 0 で終わり、``_foo`` だけを 1 回知らせる""" + root = tmp_path / 'root' + root.mkdir() + src = _write_project_bundle(tmp_path, ['_foo', 'ok-name']) + + with caplog.at_level(logging.WARNING): + assert import_bundle(root, _project_options(src)) == 0 + + assert (root / 'projects' / '_foo' / '.env').read_bytes() == b'KEY=value\n' + assert (root / 'projects' / 'ok-name' / '.env').read_bytes() == b'KEY=value\n' + warnings = _name_form_warnings(caplog) + assert len(warnings) == 1 + [message] = warnings + assert "'_foo'" in message + assert 'ok-name' not in message + assert 'projects/_foo/ を作ります' in message + assert '名前なしに打てば動きます' in message + assert 'projects/_foo を改名' in message + + +def test_import_dry_run_warns_without_writing(tmp_path, caplog): + """7: ``--dry-run`` では書き込まないが、知らせは出す""" + root = tmp_path / 'root' + root.mkdir() + src = _write_project_bundle(tmp_path, ['_foo', 'ok-name']) + + with caplog.at_level(logging.WARNING): + assert import_bundle(root, _project_options(src, dry_run=True)) == 0 + + assert not (root / 'projects').exists() + warnings = _name_form_warnings(caplog) + assert len(warnings) == 1 + assert "'_foo'" in warnings[0] + + +def test_import_of_usable_names_does_not_warn(tmp_path, caplog): + """8: 名前の形に合う名前だけなら、名前の形の警告は 1 行も出ない""" + root = tmp_path / 'root' + root.mkdir() + src = _write_project_bundle(tmp_path, ['ok-name', 'carmo_ai']) + + with caplog.at_level(logging.WARNING): + assert import_bundle(root, _project_options(src)) == 0 + + assert (root / 'projects' / 'ok-name' / '.env').is_file() + assert _name_form_warnings(caplog) == [] diff --git a/tests/plugin/test_repos_core.py b/tests/plugin/test_repos_core.py index 558b81b0..58e92bf4 100644 --- a/tests/plugin/test_repos_core.py +++ b/tests/plugin/test_repos_core.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import os import subprocess import textwrap @@ -725,6 +726,185 @@ def test_link_plugin_collision_uses_source_basename(self, registry, devbase_root assert suffix_link.is_symlink() +# 名前の形の知らせ (PLAN66 受け入れ条件 1〜5)。弾かずに警告に留める (決定 1)。 +_NAME_FORM_WARNING = "プロジェクト名として使えない形の名前" + + +def _name_form_warnings(caplog) -> list[str]: + return [r.getMessage() for r in caplog.records + if r.levelno == logging.WARNING and _NAME_FORM_WARNING in r.getMessage()] + + +def _install_repo_plugin(registry, devbase_root, projects, priority=0, + owner_repo="testorg/testrepo", name="p1"): + url = f"https://github.com/{owner_repo}.git" + _make_repo_dir(devbase_root, owner_repo, [ + {"name": name, "path": name, "projects": projects, "priority": priority}, + ]) + _register_repo(registry, owner_repo, url, [{"name": name, "path": name}]) + registry.add(InstalledPlugin( + name=name, version="1.0.0", source=url, + installed_at=registry.now_iso(), + path=f"repos/github.com--{owner_repo.replace('/', '--')}/{name}", + )) + + +def _install_link_plugin(registry, devbase_root, source_dirname, projects, name="p2"): + """``--link`` で入れたプラグイン。別名の は元パスの basename になる""" + local_plugin = devbase_root / source_dirname / name + local_plugin.mkdir(parents=True) + (local_plugin / "plugin.yml").write_text(f"name: {name}\nversion: 1.0.0\npriority: 0\n") + for proj in projects: + (local_plugin / "projects" / proj).mkdir(parents=True) + plugins_dir = devbase_root / "plugins" + plugins_dir.mkdir(exist_ok=True) + (plugins_dir / name).symlink_to(local_plugin) + registry.add(InstalledPlugin( + name=name, version="1.0.0", source=str(devbase_root / source_dirname), + installed_at=registry.now_iso(), + path=f"plugins/{name}", + linked=True, + )) + + +class TestSyncProjectsNameForm: + def test_unusable_plugin_project_is_linked_and_warned_once( + self, registry, devbase_root, caplog): + """1: 張る数は今と同じで、形に合わない名前だけが 1 回知らされる""" + _install_repo_plugin(registry, devbase_root, ["_foo", "ok-name"]) + + with caplog.at_level(logging.WARNING): + count = sync_projects(registry, verbose=False) + + assert count == 2 + assert (devbase_root / "projects" / "_foo").is_symlink() + assert (devbase_root / "projects" / "ok-name").is_symlink() + warnings = _name_form_warnings(caplog) + assert len(warnings) == 1 + [message] = warnings + assert "'_foo'" in message + assert "ok-name" not in message + assert "名前を指定した操作" in message + assert "名前なしに打てば動きます" in message + assert "プラグイン p1" in message + assert "projects/_foo を改名" in message + + def test_usable_names_are_not_warned(self, registry, devbase_root, caplog): + """2: 名前の形に合う名前だけなら、名前の形の警告は 1 行も出ない""" + _install_repo_plugin(registry, devbase_root, ["ok-name", "carmo_ai", "a.b"]) + + with caplog.at_level(logging.WARNING): + count = sync_projects(registry, verbose=False) + + assert count == 3 + assert _name_form_warnings(caplog) == [] + + def test_alias_with_usable_base_and_owner_is_linked_without_warning( + self, registry, devbase_root, caplog): + """競合時に正常な名前の別名リンクを警告なしで作る現状を固定する。""" + _install_repo_plugin(registry, devbase_root, ["carmo"], priority=10) + _install_link_plugin(registry, devbase_root, "my-local-repo", ["carmo"]) + + with caplog.at_level(logging.WARNING): + count = sync_projects(registry, verbose=False) + + assert count == 2 + winner = devbase_root / "projects" / "carmo" + alias = devbase_root / "projects" / "carmo.my-local-repo" + assert winner.is_symlink() + assert alias.is_symlink() + assert winner.resolve() == ( + devbase_root / "repos" / "github.com--testorg--testrepo" + / "p1" / "projects" / "carmo") + assert alias.resolve() == devbase_root / "my-local-repo" / "p2" / "projects" / "carmo" + assert winner.is_dir() and alias.is_dir() + assert _name_form_warnings(caplog) == [] + + def test_alias_with_unusable_owner_points_at_the_owner( + self, registry, devbase_root, caplog): + """3: 別名の の側が原因なら、プラグイン側の改名を促さない""" + _install_repo_plugin(registry, devbase_root, ["carmo"], priority=10) + _install_link_plugin(registry, devbase_root, "my plugin", ["carmo"]) + + with caplog.at_level(logging.WARNING): + count = sync_projects(registry, verbose=False) + + assert count == 2 + assert (devbase_root / "projects" / "carmo.my plugin").is_symlink() + warnings = _name_form_warnings(caplog) + assert len(warnings) == 1 + [message] = warnings + assert "'carmo.my plugin'" in message + assert "'my plugin'" in message + assert "元パス" in message + assert "projects/carmo を改名しても直りません" in message + + def test_alias_with_unusable_base_points_at_the_plugin( + self, registry, devbase_root, caplog): + """3-2: 元の名前の側が原因なら、別名もプラグイン側の改名を案内する""" + _install_repo_plugin(registry, devbase_root, ["_foo"], priority=10) + _install_link_plugin(registry, devbase_root, "my-local-repo", ["_foo"]) + + with caplog.at_level(logging.WARNING): + count = sync_projects(registry, verbose=False) + + assert count == 2 + assert (devbase_root / "projects" / "_foo").is_symlink() + assert (devbase_root / "projects" / "_foo.my-local-repo").is_symlink() + warnings = _name_form_warnings(caplog) + assert len(warnings) == 2 + winner = [m for m in warnings if "'_foo'" in m] + alias = [m for m in warnings if "'_foo.my-local-repo'" in m] + assert len(winner) == 1 and len(alias) == 1 + assert "プラグイン p2 の projects/_foo を改名" in alias[0] + assert "元パス" not in alias[0] + + def test_unusable_real_directory_is_kept_and_warned_once( + self, registry, devbase_root, caplog): + """4: 実ディレクトリは残り、知らせは 1 回。同じ名前のプラグインの分は重ねない""" + _install_repo_plugin(registry, devbase_root, ["_foo", "ok-name"]) + (devbase_root / "projects" / "_foo").mkdir() + + with caplog.at_level(logging.WARNING): + count = sync_projects(registry, verbose=False) + + assert count == 1 + real = devbase_root / "projects" / "_foo" + assert real.is_dir() and not real.is_symlink() + warnings = _name_form_warnings(caplog) + assert len(warnings) == 1 + [message] = warnings + assert "'_foo'" in message + assert "実ディレクトリ" in message + assert "プラグイン p1" not in message + + def test_dot_directories_stay_excluded_without_warning( + self, registry, devbase_root, caplog): + """5: `.` 始まりは今と同じく載らず、名前の形の警告も出ない""" + _install_repo_plugin(registry, devbase_root, [".hidden", "ok-name"]) + + with caplog.at_level(logging.WARNING): + count = sync_projects(registry, verbose=False) + + assert count == 1 + assert not (devbase_root / "projects" / ".hidden").exists() + assert _name_form_warnings(caplog) == [] + + def test_dot_real_directories_are_not_warned( + self, registry, devbase_root, caplog): + """5-2: `projects/` 直下の `.` 始まりの実ディレクトリも警告しない (決定 8)""" + _install_repo_plugin(registry, devbase_root, ["ok-name"]) + (devbase_root / "projects" / ".vscode").mkdir(parents=True) + + with caplog.at_level(logging.WARNING): + count = sync_projects(registry, verbose=False) + + assert count == 1 + real = devbase_root / "projects" / ".vscode" + assert real.is_dir() and not real.is_symlink() + assert _name_form_warnings(caplog) == [] + + class TestExtractOwner: def test_repos_based(self): plugin = InstalledPlugin( From 987909310ca6f7244b7a607e2b0cee164435ba43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Wed, 23 Sep 2026 02:51:44 +0900 Subject: [PATCH 11/15] =?UTF-8?q?refactor(PLAN66):=20=E3=82=B9=E3=83=8A?= =?UTF-8?q?=E3=83=83=E3=83=97=E3=82=B7=E3=83=A7=E3=83=83=E3=83=88=E3=81=AE?= =?UTF-8?q?=E5=90=8D=E5=89=8D=E3=81=AE=E6=A4=9C=E8=A8=BC=E3=82=92=20utils/?= =?UTF-8?q?names=20=E3=81=AE=E8=BF=B0=E8=AA=9E=E3=81=B8=E5=AF=84=E3=81=9B?= =?UTF-8?q?=E3=82=8B=20(#203)=20(#235)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(PLAN66): スナップショットの名前の寄せ(実装 2 本目)の作業を始める Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN66): 実装 2 本目の計画を置いた Co-Authored-By: Claude Opus 5 (1M context) * refactor(PLAN66): スナップショットの名前の検証を utils/names の述語へ寄せる _VALID_NAME_RE を消し、_validate_name が is_single_segment_name を呼ぶ(決定 5)。 例外の型・文言・_safe_snap_dir の封じ込めは変えない。狭まるのは末尾の改行を持つ名前だけ。 tests/snapshot/test_manager_name.py で受理 4 件・拒否 8 件を固定する(決定 6)。 Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN66): 確定仕様の「運用」の 2 つ目と CHANGELOG の Changed を書いた Co-Authored-By: Claude Opus 5 (1M context) * Test: characterize invalid snapshot name rejection in create Fix current public-entry behavior before directory creation or Docker execution. Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default * Test: characterize invalid snapshot name rejection in delete Fix current public-entry behavior before directory deletion or rmtree execution. Item-Id: R1-002 Round: 1 Impl-Runtime: agy Impl-Model: default --------- Co-authored-by: Claude Opus 5 (1M context) --- CHANGELOG.md | 5 ++ .../specifications/cli-argument-resolution.md | 14 +-- .../PLAN66_project-name-validation-impl2.md | 90 +++++++++++++++++++ lib/devbase/snapshot/manager.py | 10 ++- tests/snapshot/test_manager_name.py | 57 ++++++++++++ 5 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 issues/PLAN66_project-name-validation-impl2.md create mode 100644 tests/snapshot/test_manager_name.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d132985..52e4a89c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ 作られる symlink・ディレクトリと終了コードは変わりません。そうした名前は名前を指定した操作 (`devbase up _foo` など)ができず、そのディレクトリの中で名前なしに打てば動きます。 +### Changed +- **スナップショットの名前が、末尾に改行を持つ値(`abc\n`)を受け付けなくなりました(PLAN66 / #203)。** + スナップショットの名前の検証を、位置引数のプロジェクト名と同じ規則(`utils/names`)へ寄せました。 + それ以外に受け付ける名前と、エラーの文言は変わりません。 + ## [3.6.0] - 2026-09-19 ### Added diff --git a/docs/specifications/cli-argument-resolution.md b/docs/specifications/cli-argument-resolution.md index 1859a40b..1bbf02d5 100644 --- a/docs/specifications/cli-argument-resolution.md +++ b/docs/specifications/cli-argument-resolution.md @@ -309,11 +309,15 @@ Python 側の `_resolve_project_name` は同じ結果になるよう、`chdir` 作らないことと保存先を知らせる。判定は `utils/names.is_single_segment_name`、名前の形の 説明文は `utils/names.NAME_FORM_HINT` の 1 か所にあり、`.` 始まりの名前は今と同じく 同期の対象にならず知らせも出ない -- 名前の検証はリポジトリの中で 1 つに寄せていない。`env/bundle.py` の `is_valid_project_name` - (先頭の `_` を許す。`env` の export / import の書庫の中の名前)、`env/secret_store.py` の - `_validate_project_name`(機密の保存先のファイル名)、`snapshot/manager.py` の `_VALID_NAME_RE` - (スナップショットの名前)はそれぞれ別の用途と互換性を持つ。寄せると受け付ける名前が変わる - 範囲が広がるため、位置引数の解決はこの仕様の規則だけを使う +- 名前の検証はリポジトリの中で 1 つに寄せきっていない。寄せていないのは `env/bundle.py` の + `is_valid_project_name`(先頭の `_` を許す。`env` の export / import の書庫の中の名前)と + `env/secret_store.py` の `_validate_project_name`(機密の保存先のファイル名)の **2 つ**で、 + それぞれ別の用途と互換性を持つ。寄せると受け付ける名前が変わる範囲が広がるため、位置引数の + 解決はこの仕様の規則だけを使う。`snapshot/manager.py` のスナップショットの名前 + (`SnapshotManager._validate_name`)は `utils/names.is_single_segment_name` を共有する + (PLAN66)。文字集合が同じで、寄せても受け付ける名前は広がらない(狭まるのは末尾の改行を + 持つ名前だけ)。例外の型と文言は変えていない。述語を将来広げるとスナップショットの名前も + 広がるため、受理と拒否を `tests/snapshot/test_manager_name.py` で固定している - shell 側は macOS 既定の bash 3.2 で動くこと。`[[ =~ ]]` の右辺は変数で渡す(引用した右辺は 文字列として比べられる)。連想配列・`${var,,}`・`mapfile` を使わない - `cli.py` でサブコマンドを足し引きしたら、`bin/devbase` の `_PROJECT_NAME_SUBCOMMANDS` / diff --git a/issues/PLAN66_project-name-validation-impl2.md b/issues/PLAN66_project-name-validation-impl2.md new file mode 100644 index 00000000..03d16596 --- /dev/null +++ b/issues/PLAN66_project-name-validation-impl2.md @@ -0,0 +1,90 @@ +# PLAN66 実装 2 本目: スナップショットの名前の検証を `utils/names` の述語へ寄せる + +## 関連リンク + +- 課題: devbasex/devbase#203(この Pull Request で閉じる) +- 要求と受け入れ条件: `issues/PLAN66_project-name-validation.md` +- 設計: `issues/PLAN66_project-name-validation-design.md`(設計 PR #230。決定 5・6) +- 実装 1 本目: #233(知らせ。F1・F2。`release/v3.7.0` へマージ済み) +- release PR: #212(base は `release/v3.7.0`) +- この計画が扱うのは設計の「実装の分け方」の **2 本目(スナップショットの寄せ。F3)だけ**である + +## モード + +`standard`(要求の文書の判定のまま。受け付ける名前が狭まるのは末尾の改行 1 ケースだけで、利用者が承認済み)。 + +## 目的と非目的 + +達成したい状態: + +- スナップショット名の文字の規則を `utils/names.is_single_segment_name` の 1 か所に置き、`_VALID_NAME_RE` の二重持ちをやめる +- 共有した述語が将来広がったとき、スナップショット側のテストで気づける + +やらないこと: + +- `SnapshotError` の型と文言(「英数字・ハイフン・アンダースコア・ドットのみ使用可能、先頭は英数字」)の変更。 + `NAME_FORM_HINT` へ寄せる案は採らない(決定 5 の採らなかった案) +- 逆向きの寄せ(`utils/names` が `snapshot` の定数を使う) +- `_safe_snap_dir` の封じ込め(`resolve()` + `startswith`)の変更 +- 末尾の改行以外で受け付ける名前を狭める変更 +- 確定仕様「運用」の 1 つ目の箇条書きと、CHANGELOG の Added の行(1 本目の範囲。書き換えない) + +## 受け入れ条件 + +要求の文書の番号をそのまま使う。この Pull Request が満たすのは 10〜12・13 の 2 つ目と 3 つ目・14 の Changed・17 である。 + +- [ ] 10: `_validate_name("abc\n")` が `SnapshotError`(`tests/snapshot/test_manager_name.py`) +- [ ] 11: 受理 4 件(`ok-name`・`a.b`・`A_b`・`0abc`)が例外にならず、拒否 8 件(`_foo`・`.x`・`-x`・空・`..`・`café`・`a/b`・`abc\n`)が + `SnapshotError` で文言 `無効なスナップショット名` を含む(同上) +- [ ] 12: `grep -n "_VALID_NAME_RE" lib/devbase/snapshot/manager.py` が 0 件 +- [ ] 13 の 2 つ目・3 つ目: `docs/specifications/cli-argument-resolution.md` の「運用」の 2 つ目の箇条書きが、寄せていない規則は + `env/bundle.py` と `env/secret_store.py` の 2 つであること、スナップショットの名前が `utils/names` の述語を共有すること + (共有してよい理由と、広がらないことを固定するテストの在り処)を書く +- [ ] 14 の Changed: `CHANGELOG.md` の `[Unreleased]` に Changed(スナップショットの名前が末尾の改行を受け付けなくなる)を足す +- [ ] 17: `uv run --locked pytest tests/ -q` が exit=0 + +## 修正対象 + +- `lib/devbase/snapshot/manager.py` +- `tests/snapshot/test_manager_name.py`(新設) +- `docs/specifications/cli-argument-resolution.md`(「運用」の 2 つ目の箇条書きだけ) +- `CHANGELOG.md`(`[Unreleased]` に Changed を足すだけ) + +## タスク分解 + +### Task 1: 受理と拒否を固定し、述語へ寄せる(F3) + +- **対象ファイル:** `tests/snapshot/test_manager_name.py`・`lib/devbase/snapshot/manager.py` +- **変更内容:** `pytest.mark.parametrize` で受理 4 件・拒否 8 件を固定するテストを先に書く(`abc\n` だけが今の実装で落ちる)。 + `_VALID_NAME_RE` を消し、`_validate_name` の判定を `not is_single_segment_name(name)` にする(空は述語が弾くため `not name` のガードは消す)。 + 例外の型・文言・`_safe_snap_dir` は変えない。`re` は他の正規表現が使うため import を残す +- **満たす受け入れ条件:** 10・11・12 +- **進め方:** 失敗するテスト → 通す最小実装 → 整理 + +### Task 2: 確定仕様と CHANGELOG + +- **対象ファイル:** `docs/specifications/cli-argument-resolution.md`・`CHANGELOG.md` +- **変更内容:** 「運用」の 2 つ目を設計の「確定仕様の書き換え」の表の 2 行目どおりに書き換える。CHANGELOG の `[Unreleased]` に `### Changed` を足す +- **満たす受け入れ条件:** 13 の 2 つ目・3 つ目・14 の Changed +- **進め方:** 文書のためテスト駆動を適用しない + +## 影響範囲 + +- `SnapshotManager` の名前を受ける入口(`create`・`restore`・`rename`・`delete` など `_safe_snap_dir` を通る経路)。 + 末尾に改行を持つ名前だけが新たに弾かれる + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| 受け付ける名前が末尾の改行以外でも変わる | 受理 4 件・拒否 8 件のテストで固定し、文字集合が同じ(`[A-Za-z0-9][A-Za-z0-9._-]*`)ことを差分で確かめる | +| 触る範囲は 1 関数で、テストを新設する | 実装の後の構造改善で足りる | + +## 切り戻し手順 + +データ・スキーマを持たない。この Pull Request の revert で完全に戻る。 + +## 完了の定義 + +- [ ] 受け入れ条件 10〜12 がテストと grep で確かめられ、17 が exit=0 +- [ ] Draft の Pull Request の本文に Test plan と実行結果を載せる diff --git a/lib/devbase/snapshot/manager.py b/lib/devbase/snapshot/manager.py index b3cf5742..ada680e6 100644 --- a/lib/devbase/snapshot/manager.py +++ b/lib/devbase/snapshot/manager.py @@ -12,6 +12,7 @@ from devbase.errors import DevbaseError, SnapshotCommandError, SnapshotError from devbase.log import get_logger +from devbase.utils.names import is_single_segment_name from devbase.volume.manager import ( HOME_UBUNTU_VOLUME, SHARED_VOLUME_PREFIX, @@ -35,7 +36,6 @@ DEFAULT_MAX_GENERATIONS = 3 DEFAULT_MAX_INCREMENTALS = 10 METADATA_FILE = 'snapshot.yml' -_VALID_NAME_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._-]*$') # GNU tar の incremental はディレクトリを (dev, ino) で追跡して rename を検出する。 # ディレクトリが削除され作り直されると **inode 番号が再利用される**ため、tar は無関係な @@ -146,8 +146,12 @@ def volumes(self) -> dict: @staticmethod def _validate_name(name: str) -> None: - """スナップショット名のバリデーション(パストラバーサル防止)""" - if not name or not _VALID_NAME_RE.match(name): + """スナップショット名のバリデーション(パストラバーサル防止) + + 文字の規則は位置引数のプロジェクト名と同じ ``is_single_segment_name`` を共有する + (PLAN66 決定 5)。受理と拒否は ``tests/snapshot/test_manager_name.py`` で固定している。 + """ + if not is_single_segment_name(name): raise SnapshotError( f"無効なスナップショット名: '{name}' " "(英数字・ハイフン・アンダースコア・ドットのみ使用可能、先頭は英数字)" diff --git a/tests/snapshot/test_manager_name.py b/tests/snapshot/test_manager_name.py new file mode 100644 index 00000000..4f0dce9e --- /dev/null +++ b/tests/snapshot/test_manager_name.py @@ -0,0 +1,57 @@ +"""スナップショット名の受理と拒否を固定する (PLAN66 決定 6)。 + +``SnapshotManager._validate_name`` は ``utils/names.is_single_segment_name`` を共有する +(決定 5)。述語を将来広げるとスナップショット名も黙って広がるため、ここで受理と拒否を +固定し、広げるときにスナップショット名をどうするかを改めて決められるようにする。 +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from devbase.errors import SnapshotError +from devbase.snapshot.manager import SnapshotManager + + +@pytest.mark.parametrize('name', ['ok-name', 'a.b', 'A_b', '0abc']) +def test_validate_name_accepts(tmp_path, name): + SnapshotManager(tmp_path)._validate_name(name) + + +@pytest.mark.parametrize( + 'name', + ['_foo', '.x', '-x', '', '..', 'café', 'a/b', 'abc\n'], +) +def test_validate_name_rejects(tmp_path, name): + with pytest.raises(SnapshotError, match='無効なスナップショット名'): + SnapshotManager(tmp_path)._validate_name(name) + + +def test_create_rejects_invalid_name_before_side_effects(tmp_path): + """公開入口で不正名を拒否し、副作用へ進まない現状を固定する。""" + manager = SnapshotManager(tmp_path) + + with patch('devbase.snapshot.manager.subprocess.run') as run: + with pytest.raises(SnapshotError, match='無効なスナップショット名'): + manager.create(name='../evil') + + run.assert_not_called() + + assert list((tmp_path / 'backups').iterdir()) == [] + assert not (tmp_path / 'evil').exists() + + +def test_delete_rejects_invalid_name_before_side_effects(tmp_path): + """公開入口で不正名を拒否し、副作用へ進まない現状を固定する。""" + manager = SnapshotManager(tmp_path) + + with patch('devbase.snapshot.manager.shutil.rmtree') as rmtree: + with pytest.raises(SnapshotError, match='無効なスナップショット名'): + manager.delete(name='_foo') + + rmtree.assert_not_called() + + assert list((tmp_path / 'backups').iterdir()) == [] + From fb969a4a7b12ef7285b1c5d184d465ffa5c79639 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Wed, 23 Sep 2026 04:04:29 +0900 Subject: [PATCH 12/15] =?UTF-8?q?fix(PLAN64):=20=E6=A9=9F=E5=AF=86?= =?UTF-8?q?=E3=81=AE=E5=8F=82=E7=85=A7=E3=81=AE=E8=A6=8B=E5=87=BA=E3=81=97?= =?UTF-8?q?=E3=81=AB=E3=82=B0=E3=83=AB=E3=83=BC=E3=83=97=E3=81=AE=E8=AA=AD?= =?UTF-8?q?=E3=81=BF=E6=9B=BF=E3=81=88=E3=81=AE=E5=89=8D=E5=BE=8C=E3=82=92?= =?UTF-8?q?=E5=87=BA=E3=81=99=20(#188)=20(#237)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(PLAN64): 実装の作業場所を用意する (#188) Co-Authored-By: Claude Opus 5 (1M context) * fix(PLAN64): 機密の参照の見出しにグループの読み替えの前後を出す (#188) SecretRef.label() にキーワード引数 group_display を足し、見出し用の表示を作る口を SecretStore.display_label(ref) の 1 つに置いた。読み替えの要否は storage_group が 決める (backend の種類・設定の有無・layout・グループの有無をまとめて見る唯一の判定)。 label() の既定の返り値は変えていない。読み替えの解決は BackendConfigError を送出 しうるため、43 か所のエラー文言・警告・ログを巻き込まない。 見出しの呼び出し 5 か所 (env list の節の見出しと件数の行・env backend test の参照 ごとの行・env backend migrate の計画の一覧と --to age の完了後の一覧) を display_label へ寄せた。読み替えの対応が無いグループ・version: 1・ファイル backend の出力と、 env backend status の表示は変わらない。 確定仕様の相反する 2 つの記述を、文言の種類で分ける形に書き分けた。 Co-Authored-By: Claude Opus 5 (1M context) * test: characterize backend probe and flat layout labels Add characterization tests for current-group probe headings, skipped project aliases, and grouped references on a flat layout. Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default * test: characterize project references in the migration plan and display_label Add characterization tests for the project heading in the migration plan listing and for project / personal-project references passed to SecretStore.display_label. Item-Id: R1-002 Item-Id: R1-005 Round: 1 Impl-Runtime: agy Impl-Model: default Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011Vu8hNTZKeDeXVLg8hYuK8 * test: characterize the migration plan listing towards openbao Fix the headings of the reverse direction (age -> openbao) of `env backend migrate --dry-run`, where the aliased group name and the destination path have to name the same group on one line. Item-Id: R1-003 Round: 1 Impl-Runtime: claude Impl-Model: claude-opus-5 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011Vu8hNTZKeDeXVLg8hYuK8 * Refactor: consolidate_duplication — lib/devbase/env/secret_store.py#SecretStore.mode SecretStore.mode が backend_for と同じ backend の選択・存在判定・衝突検出を 重複して持っていたため、backend_for の結果の exists で判定する形にまとめた。 あわせて extract_method — lib/devbase/commands/env_backend.py#_MigrationPlan.apply: 書き込み後の読み戻し検証ループを _verify_read_back へ抽出した (R2-002)。 Item-Id: R2-001 Round: 2 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Opus 5.5 (1M context) * Revert "Refactor: consolidate_duplication — lib/devbase/env/secret_store.py#SecretStore.mode" This reverts commit cd75e0e688033ae33f6a4a0fbce8101678952a4c. 構造改善の提案 R2-001 / R2-002 は、この Pull Request の差分の外 (`SecretStore.mode` と `_MigrationPlan.apply` の読み戻し検証) を指していた。 R2-001 は `mode` の分岐を `backend_for` へ寄せており、見出しの表示だけを変える この変更の範囲を越えて振る舞いに触れうる。範囲外として取り消す。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011Vu8hNTZKeDeXVLg8hYuK8 --------- Co-authored-by: Claude Opus 5 (1M context) --- CHANGELOG.md | 10 + docs/specifications/secret-backend.md | 23 ++- docs/user/cli-reference/03-env.md | 2 +- docs/user/env-backend.md | 3 + issues/PLAN64_secret-label-group-impl.md | 145 ++++++++++++++ lib/devbase/commands/env.py | 14 +- lib/devbase/commands/env_backend.py | 7 +- lib/devbase/env/secret_store.py | 32 ++- tests/commands/test_env_group_label.py | 244 +++++++++++++++++++++++ tests/env/test_secret_store_label.py | 168 ++++++++++++++++ 10 files changed, 633 insertions(+), 15 deletions(-) create mode 100644 issues/PLAN64_secret-label-group-impl.md create mode 100644 tests/commands/test_env_group_label.py create mode 100644 tests/env/test_secret_store_label.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 52e4a89c..fe578f6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,16 @@ スナップショットの名前の検証を、位置引数のプロジェクト名と同じ規則(`utils/names`)へ寄せました。 それ以外に受け付ける名前と、エラーの文言は変わりません。 +### Fixed +- **`group_aliases` のある置き場で、機密の参照の見出しがグループの読み替えの前と後を出すように + しました(PLAN64 / #188)。** `devbase env list` の節の見出しと件数の行、`devbase env backend test` + の参照ごとの行、`devbase env backend migrate` の移行の計画の一覧と `--to age` の完了後の一覧が、 + `グローバル(グループ default)` から `グローバル(グループ default → nyle)` になります。これまでは + 読み替える前の名前だけが出て、隣に並ぶパス(`devbase/team/nyle/global`)と食い違って見えていました。 + 読み替えの対応が無いグループ・`version: 1` ・ファイル backend(`plaintext` / `age`)の見出しと、 + エラー文言・警告・ログ・`devbase env backend status` の表示は変わりません。置き場のパス・サーバへの + 要求・キャッシュにも影響しません。 + ## [3.6.0] - 2026-09-19 ### Added diff --git a/docs/specifications/secret-backend.md b/docs/specifications/secret-backend.md index de5ba177..adbfa37c 100644 --- a/docs/specifications/secret-backend.md +++ b/docs/specifications/secret-backend.md @@ -282,7 +282,11 @@ DEVBASE_ACCOUNT_GROUP{付ける引数}{実行場所} `for_project` が `group=` を受けて同じ規則で検証する。グループは参照の等価性に入り、1 つの `SecretStore` の中でグループの違う参照の控え(取得した内容と版)を取り違えない。グループを `SecretStore` のインスタンスに持たせないのは、ストアがプロジェクトの切替をまたいで持ち回られる -ためである。`label()` はグループがあれば `(グループ <名前>)` を後ろに付ける。 +ためである。`label()` はグループがあれば `(グループ <名前>)` を後ろに付ける。**引数なしで呼ぶと +括弧に入るのは読み替える前の名前**で、`label(group_display=...)` を渡すと括弧の中だけがその文字列に +差し替わる。既定を読み替える前の名前にしているのは、読み替えの解決(`storage_group`)がグループ名の +検証と予約語の検査で `BackendConfigError` を送出しうるためである。誤りを伝える文言とログがこの解決を +背負うと、文言を組み立てる途中で新しい例外が起き、元の失敗が利用者へ届かなくなる。 `SecretStore.ref_group(project)` は、backend が `openbao` かつ `layout: group` のときだけ `declared_group(root, project)` を返し、それ以外は `None` を返す。`version: 1` とファイル @@ -302,7 +306,17 @@ backend では参照のグループが常に空で、参照の値・等価性・ (`team//…` が `version: 1` の `team/global` / `team/projects/` と重なるため)。 `global` を読み替え元にする対応は受け付ける。2 つのグループが同じ置き場かは読み替えた後の 名前で比べる(`SecretStore.same_storage_group`)。文言には読み替えの前と後を `default → nyle` -の形で出す(`display_group`)。 +の形で出す(`display_group`)。**除くのは、引数なしの `SecretRef.label()` で参照を表示する +エラー文言・警告・ログだけ**で、そこには読み替える前の名前が出る(前項)。`display_group` を直接 +呼ぶ文言は、エラーであっても前と後を出す(`--group` がプロジェクトのグループと違う置き場である旨の +文言など)。 + +正常系の一覧の見出しは `SecretStore.display_label(ref)` を通す。見出し用の表示を作る口はこれ 1 つで、 +読み替えの要否は `SecretStore.storage_group(ref.group)` が決める(`None` を返せば `label()` をそのまま +返す)。backend の種類を `config.openbao is None` では判定しない。`backend: age` の設定に `openbao:` 節が +残っていれば `None` にならないためである。`display_label` を通る見出しは、`env list` の節の見出しと +件数の行・`env backend test` の参照ごとの行・`env backend migrate` の移行の計画の一覧と `--to age` の +完了後の一覧の 5 か所である。 **`default` の読み替えを置き場の上だけで行う理由。** `DEVBASE_ACCOUNT_GROUP` の既定値を変えると ボリューム名 `devbase_home_default` が変わり、既存の認証と会話ログのボリュームを移すことになる。 @@ -337,7 +351,10 @@ backend では参照のグループが常に空で、参照の値・等価性・ グループが `with` なら `up` はそこを読まず、書けたように見えて使われない機密が残るためである。 文言でプロジェクトの `env` の `DEVBASE_ACCOUNT_GROUP` を直すよう案内する。`list` の見出しは `=== グローバル(グループ with) (...) ===` / `=== プロジェクト: web(グループ with) (...) ===` -の形になる(`version: 1` ではグループが付かない)。 +の形になる(`version: 1` ではグループが付かない)。`group_aliases` に対応のあるグループでは、 +読み替えの前と後が並んで `=== グローバル(グループ default → nyle) (...) ===` / +`=== プロジェクト: web(グループ default → nyle) (...) ===` になり、隣に並ぶパス +(`devbase/team/nyle/global`)と同じグループを指していると読める。 #### dispatch 前の注入 diff --git a/docs/user/cli-reference/03-env.md b/docs/user/cli-reference/03-env.md index 3b295cd0..8bf46b43 100644 --- a/docs/user/cli-reference/03-env.md +++ b/docs/user/cli-reference/03-env.md @@ -75,7 +75,7 @@ devbase env list [-g|-p] [-r] [-k] [--user] [--group NAME] | `-r` | 値も表示(デフォルトではキーのみ) | | `-k` | キー名でソート | | `--user` | 個人単位の置き場だけを表示(サーバ backend のみ) | -| `--group NAME` | 対象のグループを指定(グループ別の置き場のみ)。見出しにグループ名が付く(例: `=== グローバル(グループ kkg) ...`) | +| `--group NAME` | 対象のグループを指定(グループ別の置き場のみ)。見出しにグループ名が付く(例: `=== グローバル(グループ kkg) ...`。`group_aliases` で読み替えているグループは `=== グローバル(グループ default → nyle) ...`) | ```bash # グローバル変数のみ、値付きで表示 diff --git a/docs/user/env-backend.md b/docs/user/env-backend.md index 07b75bd7..34960026 100644 --- a/docs/user/env-backend.md +++ b/docs/user/env-backend.md @@ -369,6 +369,9 @@ devbase env set --user --group kkg KEY=value `DEVBASE_ACCOUNT_GROUP` を直してください。 `env list` の見出しにはグループが付きます(例: `=== グローバル(グループ kkg) (...) ===`)。 +`group_aliases` で読み替えているグループでは、読み替えの前と後が並びます +(例: `=== グローバル(グループ default → nyle) (...) ===`)。`env backend test` の一覧も同じ形で、 +見出しのグループ名と隣のパス(`devbase/team/nyle/global`)が同じグループを指します。 ### `init` / `sync` / `project` / `export` / `import` diff --git a/issues/PLAN64_secret-label-group-impl.md b/issues/PLAN64_secret-label-group-impl.md new file mode 100644 index 00000000..5dd9e97a --- /dev/null +++ b/issues/PLAN64_secret-label-group-impl.md @@ -0,0 +1,145 @@ +# PLAN64: 機密の参照の見出しにグループの読み替えを出す(実装) + +## 関連リンク + +- 課題: devbasex/devbase#188 +- 要求と受け入れ条件: [PLAN64_secret-label-group.md](PLAN64_secret-label-group.md) +- 設計: [PLAN64_secret-label-group-design.md](PLAN64_secret-label-group-design.md)(設計 Pull Request #223) +- まとまり: release Pull Request #212(`release/v3.7.0`) + +## モード + +`standard`。利用者が読む公開の出力の振る舞いを変え、確定仕様の自己矛盾を解くため。 + +## 目的と非目的 + +達成したい状態: + +- `group_aliases` のある端末で、見出しのグループ名(`default → nyle`)と隣のパス(`…/team/nyle/…`)が + 同じグループを指していると読める +- 参照の表示の文言を組み立てる場所が `SecretRef.label()` の 1 つに保たれる +- 確定仕様の 2 つの記述が、文言の種類で重ならない形に分かれる + +やらないこと: + +- エラー文言・警告・ログ・巻き戻しの説明に出る `label()` の表示(43 か所。引数なしのまま) +- `env backend status` の表示(すでに `display_group` を使っている) +- `env encrypt` / `env decrypt` / `env rekey` の一覧(`ref.group` が常に `None` で出力が変わらない) +- 一覧の桁幅(`:<24` / `:<28` / `:<40`)の変更 +- `SecretRef` のフィールドと等価性、置き場のパス・サーバへの要求・キャッシュ + +## 前提 + +- 前提 1: `version: 1` とファイル backend では `SecretRef.group` が常に `None`(`SecretStore.ref_group` の契約) +- 前提 2: `config.openbao` が `None` かどうかで backend の種類を判定してはならない。 + `backend: age` でも `openbao:` 節が残っていれば `None` にならない。判定は `SecretStore.storage_group` に任せる +- 前提 3: `OpenBaoSettings.display_group` は `storage_group` を経由し `BackendConfigError` を送出しうる。 + 誤りを伝える文言の組み立ての中では呼ばない +- 前提 4: この端末の機密の置き場は本番の系である。確認はテストと読み取りに限り、書き込みを行わない + +## 受け入れ条件 + +要求文書の 12 件をそのまま引き継ぐ。検証手段は設計の「テスト設計」の表に対応する。 + +- [ ] 1. `env backend test` の見出しが `グローバル(グループ default → nyle)` / `個人のグローバル(グループ default → nyle)` + になり、隣のパスは今と同じ — 新規テスト +- [ ] 2. `env list` の節の見出しと件数の行(計 4 行)が読み替えの前後を出す — 新規テスト +- [ ] 3. `env backend migrate` の計画の一覧と `--to age` の完了後の一覧が、どちらも読み替えの前後を出す — 新規テスト 2 件 +- [ ] 4. 読み替えの対応が無いグループ(`kkg`)では `(グループ kkg)` のままで `→` が出ない — 新規テスト +- [ ] 5. `version: 1`(`layout: flat`)で出力全体に `(グループ` が 1 つも出ない — 新規テスト。既存 + `tests/env/test_groups.py` / `tests/env/test_runtime.py` が変更なしで通る +- [ ] 6. `openbao:` 節を残した `backend: age` でも出力全体に `(グループ` が出ない — 新規テスト。既存 + `tests/commands/test_env_user_axis.py` が変更なしで通る +- [ ] 7. 引数なしの `label()` を通すエラー文言・警告に `→` が出ない — 新規テスト 1 件 +- [ ] 8. `env backend status` の表示が今と同じ — 既存 `tests/commands/test_env_backend.py` が変更なしで通る +- [ ] 9・10・11. 確定仕様と利用者向け文書 — Pull Request のレビューで読んで確かめる +- [ ] 12. `uv run --locked pytest tests/ -q` が通り、結果を Pull Request 本文へ載せる + +## 代替案と採否 + +設計の「決定の記録」が正本。ここでは採否だけを控える。 + +| 案 | 内容 | 採否 | 理由 | +| --- | --- | --- | --- | +| A | `label()` に `group_display` を足し、`SecretStore.display_label` を唯一の口にする | 採用 | 文言が 1 か所に残り、エラー文言が読み替えの解決を背負わない(決定 1・2・3) | +| B | `label()` の既定を読み替え後にする | 不採用 | 43 か所のエラー文言・警告・ログが `BackendConfigError` を背負う(決定 2) | +| C | 見出しの側で `f'(グループ {display})'` を組み立てる | 不採用 | 同じ文言が 5 か所へ複製される(決定 1) | +| D | `config.openbao is None` で backend の種類を判定する | 不採用 | `backend: age` に節が残ると `None` にならない(決定 3、前提 2) | + +## 修正対象 + +| ファイル | 変更 | +| --- | --- | +| `lib/devbase/env/secret_store.py` | `SecretRef.label()` に `group_display` を足す / `SecretStore.display_label` を足す | +| `lib/devbase/commands/env.py` | `cmd_env_list` の共通の節 / `_group_suffix`(`store` を受ける) | +| `lib/devbase/commands/env_backend.py` | `cmd_env_backend_test` / `cmd_env_backend_migrate` の完了表示 / `_MigrationPlan._heading` | +| `tests/env/test_secret_store_label.py` | 新設。`label()` の契約と `display_label` の分岐 | +| `tests/commands/test_env_group_label.py` | 新設。読み替えのあるグループでのコマンドの出力 | +| `docs/specifications/secret-backend.md` | 2 つの記述の書き分けと `list` の見出しの例 | +| `docs/user/env-backend.md` / `docs/user/cli-reference/03-env.md` | 見出しの例に読み替えのある場合を足す | +| `CHANGELOG.md` | Unreleased の Fixed | + +## タスク分解 + +### Task 1: 表示の口を作る + +- **対象ファイル:** `lib/devbase/env/secret_store.py`、`tests/env/test_secret_store_label.py` +- **変更内容:** `SecretRef.label(*, group_display=None)` と `SecretStore.display_label(ref)` を足す。 + `display_label` は `storage_group(ref.group)` が `None` か読み替え無しなら `ref.label()` を返し、 + 読み替えがあるときだけ `display_group` の結果を渡す +- **満たす受け入れ条件:** 7、および 1・2・3・4・5・6 の土台 +- **進め方:** `label(group_display=...)` と `display_label` の 3 分岐を先に失敗するテストで固定してから実装する + +### Task 2: `env backend test` と `env list` の見出しを寄せる + +- **対象ファイル:** `lib/devbase/commands/env_backend.py`、`lib/devbase/commands/env.py`、 + `tests/commands/test_env_group_label.py` +- **変更内容:** `cmd_env_backend_test` の参照ごとの行と `cmd_env_list` の共通の節を `store.display_label(ref)` に、 + `_group_suffix` を `store` を受ける形に変える +- **満たす受け入れ条件:** 1・2・4・5・6 +- **進め方:** 読み替えのあるグループの出力を先に失敗するテストで固定してから差し替える + +### Task 3: `env backend migrate` の 2 つの一覧を寄せる + +- **対象ファイル:** `lib/devbase/commands/env_backend.py`、`tests/commands/test_env_group_label.py` +- **変更内容:** `_MigrationPlan._heading` を `self.server_store.display_label(...)` に、完了後の一覧を + `server_store.display_label(...)` にする +- **満たす受け入れ条件:** 3 +- **進め方:** 計画の一覧(`--dry-run`)と完了後の一覧(偽サーバ)を別々のテストで固定してから差し替える + +### Task 4: 文書を直す + +- **対象ファイル:** `docs/specifications/secret-backend.md`、`docs/user/env-backend.md`、 + `docs/user/cli-reference/03-env.md`、`CHANGELOG.md` +- **変更内容:** 確定仕様の 2 つの記述を文言の種類で分け、`list` の見出しの例に読み替えのある場合を足す。 + 利用者向け文書の例にも足す。CHANGELOG の Unreleased の Fixed へ 1 行 +- **満たす受け入れ条件:** 9・10・11 +- **進め方:** テスト駆動を適用しない(文書のため)。レビューで読んで確かめる + +## 影響範囲 + +`group_aliases` のある端末の 6 つの行の文字列だけが変わる。置き場のパス・サーバへの要求・キャッシュ・ +`backend.yml` の内容は変わらない。読み替えの対応が無いグループ、`version: 1`、ファイル backend の +出力はバイト単位で今と同じになる。 + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| 既存テストが前方一致で見ており、見出しが変わっても通ってしまう | 「`(グループ` が 1 つも出ない」ことを見る出力テストを新しく置く(受け入れ条件 5・6) | +| `config.openbao` の `None` 判定に落ちる | 判定を `storage_group` の 1 か所に閉じ、`backend: age` に節を残した設定のテストで検査する | +| 実環境(本番の OpenBao)へ要求を出す | テストは隔離した `DEVBASE_ROOT` と偽の設定だけで行う。`env set` / `migrate` / backend の切り替えを実環境で行わない | +| `release/v3.7.0` を base にすると CI が動かない(#216) | 手元で `uv run --locked pytest tests/ -q` を走らせ、結果を Pull Request 本文へ載せる | + +触る対象(`secret_store.py` の `label` と `SecretStore`、2 つのコマンド)は責務が分かれており、 +先に構造を整える必要は無い。 + +## 切り戻し手順 + +コードの変更だけで、データの移行を伴わない。Pull Request を revert すれば元の文字列へ戻る。 + +## 完了の定義 + +- [ ] 受け入れ条件 1〜8 と 12 に、対応するテストの実行結果がある +- [ ] 9・10・11 は Pull Request のレビューで読んで確かめる +- [ ] `ruff check --select=E9,F63,F7,F82 lib` が通る diff --git a/lib/devbase/commands/env.py b/lib/devbase/commands/env.py index c32daa85..15a2642d 100644 --- a/lib/devbase/commands/env.py +++ b/lib/devbase/commands/env.py @@ -668,7 +668,7 @@ def cmd_env_list(devbase_root: Path, global_only: bool = False, if as_user and not env_file.file_exists(): continue all_vars = env_file.get_all() - label = env_file.ref.label() + label = store.display_label(env_file.ref) print(f"\n=== {label} ({env_file.path}{_mode_suffix(env_file)}) ===") _print_env_vars(all_vars, keys_only, reveal) @@ -680,7 +680,7 @@ def cmd_env_list(devbase_root: Path, global_only: bool = False, if proj_env is not None and proj_env.file_exists(): proj_vars = proj_env.get_all() label = '個人のプロジェクト' if as_user else 'プロジェクト' - suffix = _group_suffix(proj_env.ref) + suffix = _group_suffix(store, proj_env.ref) print(f"\n=== {label}: {proj_env.ref.name}{suffix} " f"({proj_env.path}{_mode_suffix(proj_env)}) ===") @@ -690,14 +690,16 @@ def cmd_env_list(devbase_root: Path, global_only: bool = False, return 0 -def _group_suffix(ref) -> str: - """見出しに付けるグループの表示 (``(グループ with)``)。グループの無い参照では空。 +def _group_suffix(store, ref) -> str: + """見出しに付けるグループの表示 (``(グループ default → nyle)``)。グループの無い参照では空。 - 文言は ``SecretRef.label()`` が持ち、ここでは写さずに差分だけを取り出す。 + 文言は ``SecretRef.label()`` が持ち、ここでは写さずに差分だけを取り出す。読み替えの + 有無は ``SecretStore.display_label`` が決める (PLAN64 決定 3)。グループを外した参照の + 表示は読み替えに左右されないため、差分はグループの部分だけになる。 """ from dataclasses import replace - return ref.label()[len(replace(ref, group=None).label()):] + return store.display_label(ref)[len(replace(ref, group=None).label()):] def _include_project_refs(devbase_root: Path, store, group: Optional[str], *, diff --git a/lib/devbase/commands/env_backend.py b/lib/devbase/commands/env_backend.py index 33886bd5..bc225f23 100644 --- a/lib/devbase/commands/env_backend.py +++ b/lib/devbase/commands/env_backend.py @@ -469,7 +469,7 @@ def cmd_env_backend_test(devbase_root: Path) -> int: print(f"対象のグループと違う置き場のプロジェクトは調べていません: {', '.join(skipped)}") print(f"読めた参照: {len(results)} 件") for ref, count in results: - print(f" {ref.label():<28} {backend.display_path(ref):<40} {count} 変数") + print(f" {store.display_label(ref):<28} {backend.display_path(ref):<40} {count} 変数") return 0 @@ -591,7 +591,8 @@ def cmd_env_backend_migrate(devbase_root: Path, *, to: Optional[str], print("サーバ上の機密はそのまま残っています (devbase は消しません):") print(f" 接続先: {server.url}") for unit, _ in plan.moves: - print(f" {unit.server_ref.label():<24} {server.display_path(unit.server_ref)}") + print(f" {server_store.display_label(unit.server_ref):<24} " + f"{server.display_path(unit.server_ref)}") plan.print_left_on_server() return 0 @@ -702,7 +703,7 @@ def prepare(self) -> None: def _heading(self, unit: _MoveUnit) -> str: """参照の見出し。グループ別の置き場ではサーバ上のパスを添える (値は出さない)""" - label = f"{unit.server_ref.label():<24}" + label = f"{self.server_store.display_label(unit.server_ref):<24}" if self._grouped: label += f" {self.server.display_path(unit.server_ref)}" return label diff --git a/lib/devbase/env/secret_store.py b/lib/devbase/env/secret_store.py index d0627a76..0b5dbd22 100644 --- a/lib/devbase/env/secret_store.py +++ b/lib/devbase/env/secret_store.py @@ -148,12 +148,23 @@ def for_project(name: str, *, owner: str = OWNER_TEAM, def is_user(self) -> bool: return self.owner == OWNER_USER - def label(self) -> str: + def label(self, *, group_display: Optional[str] = None) -> str: + """参照の表示。``group_display`` は括弧の中に入れる名前だけを差し替える。 + + 既定 (``None``) は ``self.group``、つまり読み替える**前**の名前である + (PLAN64 決定 2)。読み替えの解決は ``BackendConfigError`` を送出しうるため、 + 誤りを伝える文言・警告・ログは引数なしで呼び、解決を背負わない。読み替えの + 前後 (``default → nyle``) を出す見出しは + :meth:`SecretStore.display_label` を通る。 + """ # チーム単位の文字列は変えない。誤りの伝達や桁揃えに埋め込まれており、 # 変えると既存の表示とテストが一斉に動く。 base = 'グローバル' if self.kind == 'global' else f"プロジェクト '{self.name}'" text = f'個人の{base}' if self.is_user else base - return f'{text}(グループ {self.group})' if self.group else text + if not self.group: + return text + display = self.group if group_display is None else group_display + return f'{text}(グループ {display})' class SecretBackend(Protocol): @@ -457,6 +468,23 @@ def storage_group(self, group: Optional[str]) -> Optional[str]: return None return settings.storage_group(group) + def display_label(self, ref: SecretRef) -> str: + """見出しに出す参照の表示。読み替えがあればグループ名を前と後で出す (PLAN64)。 + + 見出し用の表示を作る唯一の口である (決定 3)。読み替えの要否は + :meth:`storage_group` が決める。backend の種類・設定の有無・``layout`` ・ + グループの有無をまとめて見る判定はこれだけで、``config.openbao`` が ``None`` か + どうかでは決まらない (``backend: age`` に ``openbao:`` 節が残っていれば + ``None`` にならない)。 + + 読み替えが無ければ :meth:`SecretRef.label` と同じ文字列を返すため、 + ``version: 1`` とファイル backend の出力は 1 文字も変わらない。 + """ + storage = self.storage_group(ref.group) + if storage is None or storage == ref.group: + return ref.label() + return ref.label(group_display=self.config.openbao.display_group(ref.group)) + def same_storage_group(self, a: Optional[str], b: Optional[str]) -> bool: """2 つのグループが同じ置き場へ写るか (``storage_group`` 同士の比較)。 diff --git a/tests/commands/test_env_group_label.py b/tests/commands/test_env_group_label.py new file mode 100644 index 00000000..5ff2f117 --- /dev/null +++ b/tests/commands/test_env_group_label.py @@ -0,0 +1,244 @@ +"""読み替えのあるグループでの見出しの表示 (PLAN64 / #188) + +`group_aliases` のある置き場では、見出しのグループ名が読み替えの前と後 +(`default → nyle`) になり、隣に並ぶパスと同じグループを指していると読める。 +読み替えの対応が無いグループ・`version: 1` ・ファイル backend の出力は変わらない。 +""" + +from __future__ import annotations + +import pytest + +from devbase.commands import env as env_cmd +from devbase.commands import env_backend +from devbase.env import backend_config as bc + + +ALIASES = {'default': 'nyle'} +BOTH = 'default → nyle' + + +def at(monkeypatch, root, rel=''): + monkeypatch.setenv('PWD', str(root / rel) if rel else str(root)) + + +@pytest.fixture +def aliased(openbao_root, openbao): + """``version: 2`` で ``default`` を ``nyle`` へ読み替える置き場 (``web`` は宣言なし)""" + from tests.conftest import configure_openbao + + configure_openbao(openbao_root, openbao, layout=bc.LAYOUT_GROUP, group_aliases=ALIASES) + return openbao_root + + +# --------------------------------------------------------------------------- +# 受け入れ条件 1: env backend test +# --------------------------------------------------------------------------- + +def test_backend_test_headings_show_both_names_next_to_the_path(aliased, openbao, monkeypatch, + capsys): + openbao.put('team/nyle/global', {'A': '1'}) + openbao.put('users/member01/nyle/global', {'B': '2'}) + at(monkeypatch, aliased, 'projects/web') + + assert env_backend.cmd_env_backend_test(aliased) == 0 + + lines = capsys.readouterr().out.splitlines() + for label, path in ((f'グローバル(グループ {BOTH})', 'devbase/team/nyle/global'), + (f'個人のグローバル(グループ {BOTH})', + 'devbase/users/member01/nyle/global'), + (f"プロジェクト 'web'(グループ {BOTH})", + 'devbase/team/nyle/projects/web')): + # 見出しとパスが同じ行に並び、同じグループを指していることを見る (#188) + row = [line for line in lines if line.startswith(f' {label} ')] + assert len(row) == 1, label + assert path in row[0] + + +def test_backend_test_only_reads_the_current_group_and_labels_skipped_projects( + aliased, openbao, monkeypatch, capsys): + """現状固定: with の参照を表示し、別の置き場の api は読み替え名で案内する。""" + (aliased / 'projects' / 'api').mkdir() + (aliased / 'projects' / 'web' / 'env').write_text('DEVBASE_ACCOUNT_GROUP=with\n') + openbao.put('team/nyle/global', {'A': '1'}) + openbao.put('team/nyle/projects/api', {'B': '2'}) + openbao.put('users/member01/nyle/projects/api', {'C': '3'}) + openbao.put('team/with/global', {'D': '4'}) + openbao.put('users/member01/with/global', {'E': '5'}) + openbao.put('team/with/projects/web', {'F': '6'}) + openbao.put('users/member01/with/projects/web', {'G': '7'}) + at(monkeypatch, aliased, 'projects/web') + + assert env_backend.cmd_env_backend_test(aliased) == 0 + + out = capsys.readouterr().out + skipped, read = out.split('読めた参照:', 1) + skipped_rows = [line for line in skipped.splitlines() if '調べていません' in line] + assert len(skipped_rows) == 1 + assert 'api' in skipped_rows[0] + assert 'nyle' in skipped_rows[0] + assert 'api' not in read + for label, path in ( + ('グローバル(グループ with)', 'devbase/team/with/global'), + ('個人のグローバル(グループ with)', 'devbase/users/member01/with/global'), + ("プロジェクト 'web'(グループ with)", 'devbase/team/with/projects/web'), + ("個人のプロジェクト 'web'(グループ with)", + 'devbase/users/member01/with/projects/web')): + rows = [line for line in read.splitlines() if line.startswith(f' {label} ')] + assert len(rows) == 1, label + assert path in rows[0] + + +# --------------------------------------------------------------------------- +# 受け入れ条件 2: env list +# --------------------------------------------------------------------------- + +def test_list_headings_and_counts_show_both_names(aliased, openbao, monkeypatch, capsys): + openbao.put('team/nyle/global', {'A': '1'}) + openbao.put('users/member01/nyle/global', {'B': '2'}) + openbao.put('team/nyle/projects/web', {'C': '3'}) + openbao.put('users/member01/nyle/projects/web', {'D': '4'}) + at(monkeypatch, aliased, 'projects/web') + + assert env_cmd.cmd_env_list(aliased, keys_only=True) == 0 + + out = capsys.readouterr().out + for heading in (f'=== グローバル(グループ {BOTH}) (', + f'=== 個人のグローバル(グループ {BOTH}) (', + f'=== プロジェクト: web(グループ {BOTH}) (', + f'=== 個人のプロジェクト: web(グループ {BOTH}) ('): + assert heading in out + for count in (f'グローバル(グループ {BOTH}): 1変数', + f'個人のグローバル(グループ {BOTH}): 1変数', + f'プロジェクト(グループ {BOTH}): 1変数', + f'個人のプロジェクト(グループ {BOTH}): 1変数'): + assert count in out + + +# --------------------------------------------------------------------------- +# 受け入れ条件 3: env backend migrate の 2 つの一覧 (決定 4) +# --------------------------------------------------------------------------- + +def test_migration_plan_listing_shows_both_names(aliased, openbao, capsys): + """移行の計画の一覧 (``_MigrationPlan._heading``)""" + openbao.put('team/nyle/global', {'A': '1'}) + + assert env_backend.cmd_env_backend_migrate(aliased, to='age', dry_run=True) == 0 + + out = capsys.readouterr().out + assert f'グローバル(グループ {BOTH})' in out + assert 'devbase/team/nyle/global' in out + + +def test_migration_plan_listing_shows_both_names_for_project(aliased, openbao, capsys): + """現状固定: 移行計画の一覧にプロジェクトの機密の見出しとサーバ上のパスを出す。""" + openbao.put('team/nyle/projects/web', {'B': '2'}) + + assert env_backend.cmd_env_backend_migrate(aliased, to='age', dry_run=True) == 0 + + out = capsys.readouterr().out + assert f"プロジェクト 'web'(グループ {BOTH})" in out + assert 'devbase/team/nyle/projects/web' in out + + +@pytest.fixture +def aliased_age(aliased): + """``aliased`` と同じ置き場のまま backend を age にし、age 側に機密を置く + + ``tests/commands/test_env_backend_migrate.py`` の ``grouped_age_root`` と同じ構成。 + """ + from devbase.env.secret_store import SecretRef, SecretStore + + store = SecretStore(aliased, config=bc.BackendConfig()) + store.age.save(SecretRef.for_global(), {'A': 'a-value'}) + store.age.save(SecretRef.for_project('web'), {'W': 'w-value'}) + bc.save(aliased, bc.BackendConfig(backend='age', openbao=bc.load(aliased).openbao, + version=2)) + return aliased + + +def test_migration_plan_listing_to_openbao_shows_both_names_next_to_the_path( + aliased_age, openbao, capsys): + """現状固定: 逆向き (age → openbao) の一覧も読み替えの前と後と移行先のパスを並べる。""" + assert env_backend.cmd_env_backend_migrate(aliased_age, to='openbao', assume_yes=True, + dry_run=True) == 0 + + out = capsys.readouterr().out + for label, path in ((f'グローバル(グループ {BOTH})', 'devbase/team/nyle/global'), + (f"プロジェクト 'web'(グループ {BOTH})", + 'devbase/team/nyle/projects/web')): + # 見出しと移行先のパスが同じ行に並ぶ (桁揃えの空白は見ない) + rows = [line for line in out.splitlines() if line.startswith(f' {label}')] + assert len(rows) == 1, label + assert path in rows[0] + # --dry-run なのでサーバへは書かず、機密の値も出さない + assert not any(r.kv_path for r in openbao.requests_of('POST')) + assert 'a-value' not in out and 'w-value' not in out + + +def test_completion_listing_after_migrating_to_age_shows_both_names(aliased, openbao, capsys): + """``--to age`` の完了後の「サーバ上の機密はそのまま残っています」の一覧""" + openbao.put('team/nyle/global', {'A': '1'}) + + assert env_backend.cmd_env_backend_migrate(aliased, to='age', assume_yes=True) == 0 + + out = capsys.readouterr().out + tail = out[out.index('サーバ上の機密はそのまま残っています'):] + assert f'グローバル(グループ {BOTH})' in tail + assert 'devbase/team/nyle/global' in tail + + +# --------------------------------------------------------------------------- +# 受け入れ条件 4: 読み替えの対応が無いグループ +# --------------------------------------------------------------------------- + +def test_a_group_without_an_alias_keeps_its_name(aliased, openbao, monkeypatch, capsys): + (aliased / 'projects' / 'web' / 'env').write_text('DEVBASE_ACCOUNT_GROUP=kkg\n') + openbao.put('team/kkg/global', {'A': '1'}) + at(monkeypatch, aliased, 'projects/web') + + assert env_cmd.cmd_env_list(aliased, keys_only=True) == 0 + assert env_backend.cmd_env_backend_test(aliased) == 0 + + out = capsys.readouterr().out + assert '(グループ kkg)' in out + assert '→' not in out + + +# --------------------------------------------------------------------------- +# 受け入れ条件 5・6: 変わらないこと +# --------------------------------------------------------------------------- + +def test_version_one_shows_no_group_at_all(openbao_root, openbao, monkeypatch, capsys): + """``version: 1`` (``layout: flat``) の見出しにはグループが付かない""" + openbao.put('team/global', {'A': '1'}) + at(monkeypatch, openbao_root, 'projects/web') + + assert env_cmd.cmd_env_list(openbao_root, keys_only=True) == 0 + assert env_backend.cmd_env_backend_test(openbao_root) == 0 + + out = capsys.readouterr().out + assert '=== グローバル (' in out + assert '(グループ' not in out + + +def test_a_file_backend_with_a_leftover_openbao_section_shows_no_group(openbao_root, openbao, + monkeypatch, capsys): + """決定 3: ``backend: age`` に ``openbao:`` 節が残っていてもグループは出ない + + ``config.openbao`` はこの設定では ``None`` にならない。backend の種類を + ``config.openbao is None`` で判定すると、この端末をグループ別の置き場として扱う。 + """ + from tests.conftest import configure_openbao + + settings = configure_openbao(openbao_root, openbao, layout=bc.LAYOUT_GROUP, + group_aliases=ALIASES).openbao + bc.save(openbao_root, bc.BackendConfig(backend='age', openbao=settings, version=2)) + assert bc.load(openbao_root).openbao is not None + at(monkeypatch, openbao_root, 'projects/web') + + assert env_cmd.cmd_env_list(openbao_root, keys_only=True) == 0 + + out = capsys.readouterr().out + assert '=== グローバル (' in out + assert '(グループ' not in out diff --git a/tests/env/test_secret_store_label.py b/tests/env/test_secret_store_label.py new file mode 100644 index 00000000..52925a04 --- /dev/null +++ b/tests/env/test_secret_store_label.py @@ -0,0 +1,168 @@ +"""参照の表示の契約と、見出し用の表示の口 (PLAN64) + +`SecretRef.label()` の既定は読み替える**前**のグループ名のままで、読み替えの前後 +(`default → nyle`) を出すのは `SecretStore.display_label` を通った見出しだけである +(設計の決定 1・2・3)。 +""" + +from __future__ import annotations + +import logging + +import pytest + +from devbase.env import backend_config as bc +from devbase.env.secret_store import SecretRef, SecretStore, SecretStoreError + + +ALIASES = {'default': 'nyle'} + + +def _settings(*, layout: str, aliases=None) -> bc.OpenBaoSettings: + return bc.OpenBaoSettings(url='http://127.0.0.1:8200', user='member01', layout=layout, + group_aliases=dict(aliases or {})) + + +def _store(tmp_path, *, backend: str, layout: str, aliases=None) -> SecretStore: + """設定を直接渡した店 (``secrets/backend.yml`` を読まない)""" + version = 2 if layout == bc.LAYOUT_GROUP else 1 + config = bc.BackendConfig(backend=backend, openbao=_settings(layout=layout, aliases=aliases), + version=version) + return SecretStore(tmp_path, config=config) + + +# --------------------------------------------------------------------------- +# SecretRef.label の契約 +# --------------------------------------------------------------------------- + +def test_label_without_arguments_keeps_the_group_before_the_alias(): + """決定 2: 引数なしの既定は読み替える前の名前""" + assert SecretRef.for_global(group='default').label() == 'グローバル(グループ default)' + + +@pytest.mark.parametrize('ref, expected', [ + (SecretRef.for_global(group='default'), 'グローバル(グループ default → nyle)'), + (SecretRef.for_global(owner='user', group='default'), + '個人のグローバル(グループ default → nyle)'), + (SecretRef.for_project('web', group='default'), + "プロジェクト 'web'(グループ default → nyle)"), + (SecretRef.for_project('web', owner='user', group='default'), + "個人のプロジェクト 'web'(グループ default → nyle)"), +]) +def test_label_puts_group_display_inside_the_parentheses(ref, expected): + """決定 1: 括弧の中に入れる名前だけを外から受け、文言は label が持つ""" + assert ref.label(group_display='default → nyle') == expected + + +@pytest.mark.parametrize('ref', [ + SecretRef.for_global(), + SecretRef.for_global(owner='user'), + SecretRef.for_project('web'), +]) +def test_label_ignores_group_display_when_the_reference_has_no_group(ref): + assert ref.label(group_display='default → nyle') == ref.label() + assert '(グループ' not in ref.label(group_display='default → nyle') + + +# --------------------------------------------------------------------------- +# SecretStore.display_label の分岐 +# --------------------------------------------------------------------------- + +def test_display_label_shows_both_names_when_the_group_is_aliased(tmp_path): + store = _store(tmp_path, backend='openbao', layout=bc.LAYOUT_GROUP, aliases=ALIASES) + + assert (store.display_label(SecretRef.for_global(group='default')) + == 'グローバル(グループ default → nyle)') + + +def test_display_label_shows_both_names_for_project_references(tmp_path): + """現状固定: プロジェクト参照および個人プロジェクト参照でも読み替え後のグループ名を表示する。""" + store = _store(tmp_path, backend='openbao', layout=bc.LAYOUT_GROUP, aliases=ALIASES) + + assert (store.display_label(SecretRef.for_project('web', group='default')) + == "プロジェクト 'web'(グループ default → nyle)") + assert (store.display_label(SecretRef.for_project('web', owner='user', group='default')) + == "個人のプロジェクト 'web'(グループ default → nyle)") + + +def test_display_label_keeps_the_name_when_the_group_has_no_alias(tmp_path): + """受け入れ条件 4: 対応が無いグループでは ``→`` を付けない""" + store = _store(tmp_path, backend='openbao', layout=bc.LAYOUT_GROUP, aliases=ALIASES) + ref = SecretRef.for_global(group='kkg') + + assert store.display_label(ref) == ref.label() == 'グローバル(グループ kkg)' + + +def test_display_label_of_a_reference_without_a_group_is_the_plain_label(tmp_path): + store = _store(tmp_path, backend='openbao', layout=bc.LAYOUT_GROUP, aliases=ALIASES) + + assert store.display_label(SecretRef.for_global()) == 'グローバル' + + +def test_display_label_ignores_a_leftover_openbao_section_on_a_file_backend(tmp_path): + """決定 3: backend の種類を ``config.openbao is None`` で判定してはならない + + ``backend: age`` に ``openbao:`` 節が残っていても ``config.openbao`` は ``None`` に + ならない。判定は ``storage_group`` (backend の種類・設定の有無・layout をまとめて見る) + に任せるため、グループの付いた参照を渡しても読み替えない。 + """ + store = _store(tmp_path, backend='age', layout=bc.LAYOUT_GROUP, aliases=ALIASES) + + assert store.display_label(SecretRef.for_global(group='default')) \ + == 'グローバル(グループ default)' + + +def test_display_label_does_not_map_on_a_flat_layout(tmp_path): + store = _store(tmp_path, backend='openbao', layout=bc.LAYOUT_FLAT) + + assert store.display_label(SecretRef.for_global()) == 'グローバル' + + +def test_display_label_keeps_a_grouped_reference_on_a_flat_layout(tmp_path): + """現状固定: flat の表示は参照に付いたグループをそのまま残す。""" + store = _store(tmp_path, backend='openbao', layout=bc.LAYOUT_FLAT) + + assert (store.display_label(SecretRef.for_global(group='default')) + == 'グローバル(グループ default)') + + +# --------------------------------------------------------------------------- +# 受け入れ条件 7: 誤りを伝える文言は読み替えない +# --------------------------------------------------------------------------- + +def test_the_flat_layout_refusal_names_the_group_before_the_alias(tmp_path): + """``OpenBaoBackend._check_group`` の文言は引数なしの ``label()`` を通す + + ``layout: flat`` に ``group_aliases`` を置いた設定は ``validate()`` が拒むが、ここで + 見るのは文言が読み替えの解決を背負わないことである (決定 2)。``label()`` の既定を + 読み替え後にすると、この文言が ``default → nyle`` になる。 + """ + from devbase.env.openbao import OpenBaoBackend + + store = _store(tmp_path, backend='openbao', layout=bc.LAYOUT_FLAT, aliases=ALIASES) + backend = OpenBaoBackend(store) + + with pytest.raises(SecretStoreError) as excinfo: + backend.path_of(SecretRef.for_global(group='default')) + + assert '(グループ default)' in str(excinfo.value) + assert '→' not in str(excinfo.value) + + +def test_the_account_group_warning_names_the_group_before_the_alias(openbao_root, openbao, + caplog): + """置き場の ``DEVBASE_ACCOUNT_GROUP`` を使わない旨の警告も読み替えない (PLAN62)""" + from devbase.env import keys, runtime + from tests.conftest import configure_openbao + + configure_openbao(openbao_root, openbao, layout=bc.LAYOUT_GROUP, group_aliases=ALIASES) + openbao.put('team/nyle/global', {keys.DEVBASE_ACCOUNT_GROUP: 'kkg', 'A': '1'}) + + with caplog.at_level(logging.WARNING): + runtime.resolve(openbao_root, None) + + messages = [r.getMessage() for r in caplog.records + if r.levelno == logging.WARNING and keys.DEVBASE_ACCOUNT_GROUP in r.getMessage()] + assert len(messages) == 1 + assert '機密の置き場(グローバル(グループ default))' in messages[0] + assert '→' not in messages[0] From 03459ce797edc665e2714235109e4919662523fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Wed, 23 Sep 2026 05:23:53 +0900 Subject: [PATCH 13/15] =?UTF-8?q?feat(PLAN63):=20base=20=E3=82=A4=E3=83=A1?= =?UTF-8?q?=E3=83=BC=E3=82=B8=E3=81=AE=E6=97=A5=E6=9C=AC=E8=AA=9E=E3=81=AE?= =?UTF-8?q?=E6=8F=8F=E7=94=BB=E3=82=92=E7=9B=B4=E3=81=97=E3=80=81=E6=96=87?= =?UTF-8?q?=E6=9B=B8=E3=82=92=E6=89=B1=E3=81=86=E8=BB=BD=E9=87=8F=E3=81=AE?= =?UTF-8?q?=E9=81=93=E5=85=B7=E3=82=92=E8=B6=B3=E3=81=99=20(#161,=20#160)?= =?UTF-8?q?=20(#238)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(PLAN63): base イメージの描画と文書の道具の実装の作業場所を用意する (#161, #160) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011Vu8hNTZKeDeXVLg8hYuK8 * feat(PLAN63): base イメージの日本語の描画を直し、文書を扱う軽量の道具を足す (#161, #160) `/etc/fonts/local.conf` を 1 つ置き、総称ファミリ (sans-serif / sans / serif / monospace) と、イメージに無い書体名を Noto CJK の JP フェイスへ向ける。中国語・ 韓国語の規則は総称ファミリを名指ししたときだけ効かせる (``)。 この test を省くと `Arial:lang=zh-cn` から Liberation Sans を奪い、様式も崩れる。 conf.d/99-*.conf へ置くと `` が効かない。local.conf は conf.d/51-local.conf 経由で読まれ、sans-serif を中国語フェイスへ向けている 64 / 65 より「先」になるためで、理由と実測は fonts-local.conf の先頭に残した。 あわせて 6 パッケージを 1 つ目の RUN の 1 回目の apt-get install へ足す。 poppler-utils / python3-pil / python3-defusedxml / python3-lxml と、欧文の metric 互換の fonts-crosextra-carlito / fonts-crosextra-caladea である。 LibreOffice と pip は入れず、fonts-wqy-zenhei も消さない。 回帰テストは 2 段。Dockerfile と fonts-local.conf の形は Docker なしで、 fc-match の解決先は建てたイメージの中で固定する。後者はイメージが無い・古い ときは skip する。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011Vu8hNTZKeDeXVLg8hYuK8 * fix(PLAN63): probe の実行中の異常を skip でなく失敗として知らせる (#161) docker run を包む except (SubprocessError, OSError) が TimeoutExpired まで pytest.skip にしていたため、Docker もイメージもある状態で probe が 300 秒で タイムアウトすると 39 件すべてが skip になり pytest が成功終了していた。 skip してよいのは Docker が使えない・イメージが無い (_docker_unavailable) と イメージが古い (STALE_IMAGE_EXIT) の 3 つだけなので、docker run の try/except を 外して例外のまま失敗させる。意図はコメントで残す。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011Vu8hNTZKeDeXVLg8hYuK8 * test(PLAN63): 新しい RUN の検査を RUN ブロック単位へ直す (#161) `RUN ` で始まる行だけを集めていたため、各要素は `RUN set -eux; \` のような 1 行目だけになり、パッケージ名が入る継続行を見ていなかった。 `poppler-utils` / `fonts-crosextra` が無いという 2 つの assert は常に真で、 「6 パッケージのために RUN を足していない」という意図を固定できていなかった。 `_run_blocks()` を足して Dockerfile を RUN ブロック (行継続を含む 1 命令分) へ 分け、`_first_run_block()` はその 1 つ目を返す形に寄せた。検査は 1 つ目以外の 各ブロックの全文に対して行う。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011Vu8hNTZKeDeXVLg8hYuK8 --------- Co-authored-by: Claude Opus 5 (1M context) --- CHANGELOG.md | 22 ++ containers/base/Dockerfile | 21 +- containers/base/fonts-local.conf | 101 +++++++ docs/user/container-operations.md | 42 ++- issues/PLAN63_base-image-rendering-impl.md | 180 +++++++++++++ .../containers/test_base_dockerfile_fonts.py | 254 ++++++++++++++++++ .../test_base_image_font_matching.py | 187 +++++++++++++ 7 files changed, 805 insertions(+), 2 deletions(-) create mode 100644 containers/base/fonts-local.conf create mode 100644 issues/PLAN63_base-image-rendering-impl.md create mode 100644 tests/containers/test_base_dockerfile_fonts.py create mode 100644 tests/containers/test_base_image_font_matching.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fe578f6f..2170a0e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ ## [Unreleased] ### Added +- **base イメージに、Office 文書・PDF を扱う軽量の道具を足しました(PLAN63 / #160)。** + `poppler-utils`(`pdftoppm` / `pdfinfo` / `pdffonts` / `pdftocairo`)、`python3-pil`、 + `python3-defusedxml`、`python3-lxml` で、PDF を画像にする・調べる、OOXML を壊さずに + 読み書きすることが base だけでできます。あわせて欧文の metric 互換の + `fonts-crosextra-carlito` / `fonts-crosextra-caladea` を入れ、`Calibri` / `Cambria` の + 指定が正しい字幅の書体(Carlito / Caladea)へ解決されるようにしました(これまでは + どちらも中国語フォントへ落ちていました)。**LibreOffice と `pip` は入れていません。** + Python パッケージが要るときは既にある `uv` / `uvx` を使ってください。 + **反映には `devbase build base --no-cache` が要ります。** - **名前の形に合わないプロジェクト(`_foo` など)が `projects/` に載る時点で、警告を 1 行出すように しました(PLAN66 / #203)。** `devbase plugin install` / `update` / `sync` は、プラグインの プロジェクト・衝突のときに合成する別名 `<名前>.`・`projects/` 直下の実ディレクトリの @@ -19,6 +28,19 @@ それ以外に受け付ける名前と、エラーの文言は変わりません。 ### Fixed +- **base コンテナで日本語が中国語のフォントで描画される問題を直しました(PLAN63 / #161)。** + 総称ファミリ(`sans-serif` / `sans` / `serif` / `monospace`)と、イメージに無い書体名 + (`Meiryo` / `Yu Gothic` / `MS PGothic` / `Noto Sans JP` など)が、Noto CJK の **JP** + フェイスへ解決されるようになります。これまでは `sans-serif` そのものが中国語フォント + (WenQuanYi Zen Hei)へ解決され、Chromium / Playwright のスクリーンショット・PDF の生成・ + 画像の生成のすべてが中国語の字形で写っていました。欧文(`Arial` / `Times New Roman` / + `Courier New`)は Liberation の metric 互換のままで、`lang=zh-cn` / `lang=ko` を明示した + 指定は、その言語の、しかも同じ様式(sans / serif / 等幅)のフェイスのままです。 + **言語を明示しない中国語は日本語の字形で描かれるようになります**(意図した変更です)。 + 設定は `/etc/fonts/local.conf` に置いており、個人の `~/.config/fontconfig/fonts.conf` で + 上書きできます。**反映には `devbase build base --no-cache` が要ります。** `devbase up` + だけでは変わらず、派生イメージ(`general` など)を使っているプロジェクトは、その派生 + イメージも建て直してください。 - **`group_aliases` のある置き場で、機密の参照の見出しがグループの読み替えの前と後を出すように しました(PLAN64 / #188)。** `devbase env list` の節の見出しと件数の行、`devbase env backend test` の参照ごとの行、`devbase env backend migrate` の移行の計画の一覧と `--to age` の完了後の一覧が、 diff --git a/containers/base/Dockerfile b/containers/base/Dockerfile index c874f796..f0d94798 100644 --- a/containers/base/Dockerfile +++ b/containers/base/Dockerfile @@ -19,7 +19,13 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ openssh-client \ curl ca-certificates gnupg lsb-release \ libnss3 libxrandr2 libxss1 \ - fonts-noto-cjk fonts-noto-cjk-extra; \ + fonts-noto-cjk fonts-noto-cjk-extra \ + # 欧文の metric 互換 (Calibri -> Carlito / Cambria -> Caladea)。 + # 30-metric-aliases.conf が既に対応を持っており、実体が無いと中国語フォントへ落ちる。 + fonts-crosextra-carlito fonts-crosextra-caladea \ + # 文書を扱う軽量の道具 (#160)。PDF を画像にする・調べる、OOXML を壊さずに読み書きする。 + # LibreOffice (展開 372〜459MB) と pip は入れない。Python パッケージは uv / uvx で賄う。 + poppler-utils python3-pil python3-defusedxml python3-lxml; \ # ロケール設定 locale-gen en_US.UTF-8; \ update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8; \ @@ -250,6 +256,19 @@ COPY --chmod=0755 tmux-clean /usr/local/bin/tmux-clean RUN sudo ln -sf tmux-first /usr/local/bin/tmux1 \ && sudo ln -sf tmux-clean /usr/local/bin/tmuxc +# フォントの既定。素の fontconfig は sans-serif を中国語フェイス (WenQuanYi Zen Hei) へ +# 向けるため、日本語を描くと中国語の字形で写る (#161)。/etc/fonts/local.conf は +# conf.d/51-local.conf 経由で読まれ、これを中国語へ向けている 64 / 65 より「先」になる。 +# conf.d/99-*.conf へ置くと が効かない。理由と実測は fonts-local.conf の +# 先頭のコメントにある。利用者は ~/.config/fontconfig/fonts.conf (スロット 50) で上書きできる。 +COPY --chmod=0644 fonts-local.conf /etc/fonts/local.conf + +# フォントのキャッシュを作り直す。上の Playwright の RUN が --with-deps で +# fonts-wqy-zenhei / fonts-ipafont-gothic / fonts-liberation を入れた「後」でなければ、 +# 後から入った書体を知らないキャッシュが残る。直後に ~/.cache を消しているため、 +# ここで /var/cache/fontconfig を作り直してコンテナ初回起動時の生成を避ける。 +RUN sudo fc-cache -f + # entrypoint と dind COPY --chmod=755 entrypoint.sh /entrypoint.sh COPY --chmod=755 dind /usr/local/bin/dind diff --git a/containers/base/fonts-local.conf b/containers/base/fonts-local.conf new file mode 100644 index 00000000..116ba6cc --- /dev/null +++ b/containers/base/fonts-local.conf @@ -0,0 +1,101 @@ + + + + + + sans-serifNoto Sans CJK JP + sansNoto Sans CJK JP + serifNoto Serif CJK JP + monospaceNoto Sans Mono CJK JP + + + + Noto Sans CJK JP + + + + + sans-serif + zh-cn + Noto Sans CJK SC + + + sans + zh-cn + Noto Sans CJK SC + + + serif + zh-cn + Noto Serif CJK SC + + + monospace + zh-cn + Noto Sans Mono CJK SC + + + + sans-serif + ko + Noto Sans CJK KR + + + sans + ko + Noto Sans CJK KR + + + serif + ko + Noto Serif CJK KR + + + monospace + ko + Noto Sans Mono CJK KR + + diff --git a/docs/user/container-operations.md b/docs/user/container-operations.md index 34c69a69..225ca964 100644 --- a/docs/user/container-operations.md +++ b/docs/user/container-operations.md @@ -363,7 +363,7 @@ graph TD | イメージ | ベース | 主な内容 | 用途 | |---------|-------|---------|------| -| **base** | Ubuntu 26.04 | Docker CLI、Python 3 | 最小限の開発環境 | +| **base** | Ubuntu 26.04 | Docker CLI、Python 3、日本語フォント、PDF / OOXML の道具 | 最小限の開発環境 | | **general** | base | AWS CLI、gcloud、Terraform、Node.js 20、AI CLI | 汎用開発環境 | | **php** | general | PHP 8.5、Composer、MySQL Shell | PHP 8.5 系 開発 | | **php85** | general | PHP 8.5、Composer、MySQL Shell | PHP 8.5 系 開発 | @@ -372,6 +372,46 @@ graph TD | **go** | base | Go 開発環境 | Go 開発 | | **snapshot** | Ubuntu 26.04 | zstd のみ(約 80MB) | スナップショット専用 | +### 文字の描画と、文書を扱う道具(base 以降) + +base イメージは、文字を描くときの既定を**日本語**にしています。総称ファミリ(`sans-serif` / +`sans` / `serif` / `monospace`)と、イメージに無い書体名(`Meiryo` / `Yu Gothic` / +`MS PGothic` / `Noto Sans JP` など)は、いずれも Noto CJK の **JP** フェイスへ解決されます。 +fontconfig は Chromium / Playwright のスクリーンショット、PDF の生成、画像の生成がすべて +参照するため、日本語を含むページを撮っても日本語の字形で写ります。 + +| 指定 | 解決先 | +|------|--------| +| `sans-serif` / `sans` | Noto Sans CJK JP | +| `serif` | Noto Serif CJK JP | +| `monospace` | Noto Sans Mono CJK JP | +| `Arial` / `Times New Roman` / `Courier New` | Liberation Sans / Serif / Mono(metric 互換) | +| `Calibri` / `Cambria` | Carlito / Caladea(metric 互換) | +| `sans-serif:lang=zh-cn` など、言語を明示した指定 | その言語の、同じ様式のフェイス(SC / KR) | + +> **言語を明示しない中国語は、日本語の字形で描かれます。** `lang` を伴わない `sans-serif` は +> どちらかの言語を選ばざるをえないためで、意図した振る舞いです。中国語・韓国語で描きたい +> ときは `lang=zh-cn` / `lang=ko` を明示するか、書体を名指ししてください。 + +設定は `/etc/fonts/local.conf`(`containers/base/fonts-local.conf`)にあります。個人の設定 +`~/.config/fontconfig/fonts.conf` はこれより先に読まれるため、コンテナの中で上書きできます。 + +文書を扱う道具も base に入っています。 + +| 道具 | 用途 | +|------|------| +| `pdftoppm` / `pdftocairo` | PDF を画像(PNG / JPEG / SVG)にする | +| `pdfinfo` / `pdffonts` | PDF のページ数・寸法・埋め込みフォントを調べる | +| `python3 -c "import PIL"` | 画像の読み書き・変換(Pillow) | +| `python3 -c "import lxml, defusedxml"` | OOXML(.docx / .xlsx / .pptx)を壊さずに読み書きする | + +> **LibreOffice と `pip` は入っていません。** LibreOffice は展開 372〜459MB で base の規律に +> 見合わないため入れていません。Python パッケージが要るときは `uv` / `uvx` を使ってください。 + +> **これらは `devbase build base --no-cache` で base を建て直すと反映されます。** +> `devbase up` だけでは反映されません。派生イメージ(`general` など)を使っている +> プロジェクトは、その派生イメージも建て直してください。 + ### AI CLI エイリアス general イメージ以降のコンテナ内では、以下の AI CLI ツールがエイリアスとして利用可能です。 diff --git a/issues/PLAN63_base-image-rendering-impl.md b/issues/PLAN63_base-image-rendering-impl.md new file mode 100644 index 00000000..9aef3496 --- /dev/null +++ b/issues/PLAN63_base-image-rendering-impl.md @@ -0,0 +1,180 @@ +# PLAN63: base イメージの日本語の描画と、文書を扱う軽量の道具 の実装 + +## 関連リンク + +- 対象 issue: devbasex/devbase#161, devbasex/devbase#160 +- 要求と受け入れ条件: [PLAN63_base-image-rendering.md](PLAN63_base-image-rendering.md) +- 設計: [PLAN63_base-image-rendering-design.md](PLAN63_base-image-rendering-design.md) +- 設計 Pull Request: #221(マージ済み) / release Pull Request: #212 +- 範囲外として起票済み: #219(`containers/docs` と LibreOffice)、#220(arm64 の Chromium) + +## モード + +`standard`。base イメージの本番の振る舞い(総称ファミリの解決先と同梱するパッケージ)を変え、 +すべての派生イメージとプロジェクトへ届くため(要求の「ワークフローモード」の根拠のとおり)。 + +## 目的と非目的 + +達成したい状態: + +- base コンテナで日本語を描いたとき、日本語のフェイス(Noto CJK の JP)で描かれる +- 欧文(`Arial` / `Times New Roman` / `Courier New`)と、中国語・韓国語を**明示した**指定は壊れない +- PDF を画像にする・調べる、OOXML を壊さずに読み書きする、欧文の字幅を正しく測ることが + base だけでできる + +やらないこと(今回はやらない、の意味で): + +- LibreOffice の追加(要求の前提 3) +- `containers/docs` の新設(前提 4 / #219) +- `fonts-wqy-zenhei` の削除(前提 2) +- `ENV LANG` の設定(前提 7) +- `pip` の追加(前提 8) +- Playwright のブラウザの導入方法と arm64 の Chromium(前提 5 / #220) +- 派生イメージ(`containers/general` ほか)と `containers/lfm` の Dockerfile の変更 + +## 前提 + +- 前提 1: 設定は `/etc/fonts/local.conf` に置く。`conf.d/99-*.conf` では `` が + 効かない(設計の決定 1 の実測) +- 前提 2: 中国語・韓国語の規則は**総称ファミリを名指ししたときだけ**効かせる。`lang` だけを + 条件にすると `Arial:lang=zh-cn` から Liberation Sans を奪う(設計の決定 2 の実測) +- 前提 3: `release/v3.7.0` を base にした Pull Request では CI が 1 件も動かない(#216)。 + 証跡は手元で採って Pull Request 本文へ載せる +- 前提 4: この変更は `devbase build base --no-cache` を建てるまで手元に反映されない + +## 受け入れ条件 + +要求の 16 件をそのまま引き継ぐ。ここでは検証手段の対応だけを書く。 + +- [ ] 1〜6(フォントの解決先)— `tests/containers/test_base_image_font_matching.py` の解決先の表 +- [ ] 7・8(置き場所と、動かせない理由のコメント)— `tests/containers/test_base_dockerfile_fonts.py` +- [ ] 9・10・12(道具の有無)— 同じ `docker run` の中の `command -v` と `python3 -c "import …"` +- [ ] 11(`Calibri` → Carlito / `Cambria` → Caladea)— 解決先の表の欧文の行 +- [ ] 13(イメージの増分が 40 MB 以下)— ビルドの前後の `docker images`。手で測る +- [ ] 14(`uv run --locked pytest tests/ -q` が 0) +- [ ] 15(`devbase build base --no-cache` が arm64 で成功する) +- [ ] 16(派生イメージ 1 つで同じ解決先になる)— 手で確かめる + +## 代替案と採否 + +設計の「決定の記録」(決定 1〜9)が持つ。ここでは写さない。 + +## 不変条件 + +- `fc-match Arial` / `Times New Roman` / `Courier New` は Liberation の 3 つのままである +- `fc-match <中国語・韓国語を明示した総称ファミリ>` は、その言語の、**同じ様式**のフェイスを返す +- `soffice` / `libreoffice` / `pip` / `pip3` は `PATH` に無い +- Dockerfile に `fonts-wqy-zenhei` を消す命令が無い + +## 互換性 + +| 対象 | 変更 | 互換性の扱い | +| --- | --- | --- | +| 公開インタフェース(CLI の引数・環境変数・コマンド) | 無し | 変えない | +| データ | 無し | 変えない | +| base コンテナの中で描かれる文字のフェイス | **変わる** | 建て直すまで変わらない。CHANGELOG で `devbase build base --no-cache` が要ることを知らせる | + +## 修正対象 + +- `containers/base/fonts-local.conf`(新設) +- `containers/base/Dockerfile`(1 つ目の `RUN` の 1 回目の `apt-get install` / 末尾の `COPY` 群) +- `tests/containers/test_base_dockerfile_fonts.py`(新設) +- `tests/containers/test_base_image_font_matching.py`(新設。Docker が要る) +- `docs/user/container-operations.md` +- `CHANGELOG.md` +- `issues/PLAN63_base-image-rendering-impl.md`(この文書) + +## タスク分解 + +### Task 1: フォントの設定ファイルと、その形の回帰テスト + +- **対象ファイル:** `containers/base/fonts-local.conf`(新設)、 + `tests/containers/test_base_dockerfile_fonts.py`(新設) +- **変更内容:** 4 つの ``(`sans-serif` / `sans` / `serif` / `monospace` → Noto CJK JP)、 + 未導入の書体の受け皿 1 つ(`append` / `binding="weak"`)、総称ファミリ 4 つ × 言語 2 つ = + 8 つの ``(`` と `` の両方を持つ)。 + 先頭のコメントに、`/etc/fonts/local.conf` から動かせない理由(`51-local.conf` のスロット、 + `conf.d` の番号順、`99` での実測)を残す +- **満たす受け入れ条件:** 8(コメント)と、1〜6 の土台 +- **進め方:** 先に `test_base_dockerfile_fonts.py` の XML の形の検査(整形式・`` 4 つ・ + `` 9 つ・`lang` の `` が `family` の `` を必ず持つ・コメントの語)を書いて + 落とし、`fonts-local.conf` を足して通す + +### Task 2: Dockerfile の 6 パッケージと `COPY` / `fc-cache` + +- **対象ファイル:** `containers/base/Dockerfile`、`tests/containers/test_base_dockerfile_fonts.py` +- **変更内容:** 1 つ目の `RUN` の**1 回目**の `apt-get install` の一覧へ + `fonts-crosextra-carlito fonts-crosextra-caladea poppler-utils python3-pil + python3-defusedxml python3-lxml` を足す(新しい `RUN` を立てない)。末尾の `COPY` 群 + (`tmux.conf` と同じ区画、`USER ubuntu` より後)へ + `COPY --chmod=0644 fonts-local.conf /etc/fonts/local.conf` と `RUN sudo fc-cache -f` を足す +- **満たす受け入れ条件:** 7、および 9〜13 の土台 +- **進め方:** 先に Dockerfile の文字列検査(6 つが 1 回目の一覧にあること、`COPY` の宛先が + `/etc/fonts/local.conf` で `conf.d/` を宛先にする `COPY` が無いこと、`fc-cache -f` が 1 度だけで + `COPY` より後かつ Playwright の `RUN` より後にあること、`libreoffice` / `soffice` / `pip` を + 入れていないこと、`fonts-wqy-zenhei` を消す命令が無いこと)を書いて落とし、Dockerfile を直す + +### Task 3: 建てたイメージの中の解決先を固定するテスト + +- **対象ファイル:** `tests/containers/test_base_image_font_matching.py`(新設) +- **変更内容:** `scope="session"` の fixture が 1 回の `docker run` で `fc-match` の全行・ + `fc-match -s sans-serif:lang=ja` の 1 件目・`command -v` の結果・`python3 -c "import …"` の + 終了コードをまとめて採り、辞書で返す。skip は 4 段 + (`shutil.which('docker')` → `docker info` → `docker image inspect devbase-base:latest` → + イメージの中に `/etc/fonts/local.conf` があるか)。skip の文言に + `devbase build base --no-cache` を書く +- **満たす受け入れ条件:** 1〜6・9・10・11・12 +- **進め方:** テストを書き、建て直す前は skip になることを確かめる。Task 4 でイメージを + 建て直した後に、実際に通ることを確かめる + +### Task 4: イメージを建て直して実測する + +- **対象ファイル:** 無し(証跡の採取) +- **変更内容:** `devbase build base --no-cache` を作業ツリーの `containers/base` で実行し、 + 前後の `docker images` を記録する。解決先の表の 26 行を `fc-match` で採り、Pull Request + 本文へ載せる。派生イメージ 1 つを建て直して同じ解決先になることを確かめる +- **満たす受け入れ条件:** 1〜6・9〜13・15・16 +- **進め方:** テスト駆動を適用しない(測定のため)。結果は Pull Request 本文の Test plan へ + +### Task 5: 利用者向け文書と CHANGELOG + +- **対象ファイル:** `docs/user/container-operations.md`、`CHANGELOG.md` +- **変更内容:** イメージの詳細の表の base の行へ、日本語のフェイスと文書の道具を足す。 + CHANGELOG の `[Unreleased]` に `### Added`(6 パッケージ)と `### Fixed`(日本語が中国語 + フォントで描画される)を書き、**イメージを建て直すまで反映されない**ことを添える +- **満たす受け入れ条件:** 要求の「対象範囲」の文書の行 +- **進め方:** テスト駆動を適用しない(文書のため) + +## 影響範囲 + +- base から派生するすべてのイメージ(`general` / `go` / `php` / `php85` / `bi-tools` / + `latex` / `trygroup`)。いずれも `FROM devbase-base:latest` のため、建て直せば効く +- `containers/lfm` は base 由来ではないため影響しない(要求の「未確認のまま残ること」) +- 稼働中のコンテナは、イメージを建て直しただけでは入れ替わらない + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| `lang` の条件が広すぎて欧文の指定から書体を奪う(設計の決定 2 の壊れ方) | Task 1 の XML の形の検査で、`lang` の `` が `family` の `` を必ず持つことを固定する。Task 3 で `Arial:lang=zh-cn` の行を実測で固定する | +| ビルドキャッシュが壊れた層を配り 0 バイトのファイルを作る | `--no-cache` を必ず付ける | +| 建て直す前は Docker のテストが赤くなる | skip の 4 段目(`/etc/fonts/local.conf` の有無)で skip にする | +| 1 つ目の `RUN` の文字列が変わり、巨大な層が建て直される | 設計の決定 4 のとおり避けない。`--no-cache` でどのみち全部建て直る | +| 触る対象の構造 | Dockerfile と `tests/containers/` は既に薄く、先に整える必要は無い | + +## 切り戻し手順 + +データの移行は無い。戻すには 4 つが要る(要求の「切り戻し手順」のとおり)。 + +1. ブランチの revert +2. `devbase build base --no-cache` +3. 使っている派生イメージの建て直し +4. 稼働中のコンテナの作り直し(`devbase down` → `devbase up`)。**`devbase rebuild` は使えない** + +## 完了の定義 + +- [ ] 受け入れ条件 16 件すべてに検証手段と結果が対応している +- [ ] `uv run --locked pytest tests/ -q` が `exit=0` +- [ ] `devbase build base --no-cache` が成功し、増分が 40 MB 以下 +- [ ] 解決先の表の 26 行が Pull Request 本文に載っている +- [ ] 実装を載せた Draft の Pull Request がある(#238) diff --git a/tests/containers/test_base_dockerfile_fonts.py b/tests/containers/test_base_dockerfile_fonts.py new file mode 100644 index 00000000..64a06ea7 --- /dev/null +++ b/tests/containers/test_base_dockerfile_fonts.py @@ -0,0 +1,254 @@ +"""base イメージの日本語の描画と、文書を扱う軽量の道具の「形」 (PLAN63 / #161, #160) + +Docker を起動せず、``containers/base/Dockerfile`` と ``containers/base/fonts-local.conf`` の +文字列と構造だけを固定する。実際の解決先 (``fc-match`` が何を返すか) は +``tests/containers/test_base_image_font_matching.py`` が建てたイメージの中で固定する。 + +ここで固定するのは次の 5 つである。 + +- 6 パッケージが **1 つ目の RUN の 1 回目の** ``apt-get install`` の一覧にある (設計の決定 4) +- ``COPY`` の宛先が ``/etc/fonts/local.conf`` であり ``conf.d/`` ではない (決定 1) +- ``fc-cache -f`` が 1 度だけ、``COPY`` より後、かつ Playwright の ``RUN`` より後にある (決定 5) +- ``fonts-local.conf`` が 4 つの ```` と 9 つの ```` を持ち、言語の規則が + 総称ファミリの ```` を必ず伴う (決定 2。この ```` を省くと欧文の指定を奪う) +- 入れないもの (LibreOffice / pip) と、消さないもの (``fonts-wqy-zenhei``) が守られている +""" + +from __future__ import annotations + +import re +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + +BASE_DIR = Path(__file__).resolve().parents[2] / "containers" / "base" +DOCKERFILE = BASE_DIR / "Dockerfile" +FONTS_CONF = BASE_DIR / "fonts-local.conf" + +# 設計「解決の経路」で決めた、総称ファミリの向き先 +GENERIC_TO_JP = { + "sans-serif": "Noto Sans CJK JP", + "sans": "Noto Sans CJK JP", + "serif": "Noto Serif CJK JP", + "monospace": "Noto Sans Mono CJK JP", +} +# 言語を明示したときの向き先。**様式 (sans / serif / 等幅) を保つ** +LANG_RULES = { + ("sans-serif", "zh-cn"): "Noto Sans CJK SC", + ("sans", "zh-cn"): "Noto Sans CJK SC", + ("serif", "zh-cn"): "Noto Serif CJK SC", + ("monospace", "zh-cn"): "Noto Sans Mono CJK SC", + ("sans-serif", "ko"): "Noto Sans CJK KR", + ("sans", "ko"): "Noto Sans CJK KR", + ("serif", "ko"): "Noto Serif CJK KR", + ("monospace", "ko"): "Noto Sans Mono CJK KR", +} +FALLBACK_FAMILY = "Noto Sans CJK JP" +NEW_PACKAGES = ( + "poppler-utils", + "python3-pil", + "python3-defusedxml", + "python3-lxml", + "fonts-crosextra-carlito", + "fonts-crosextra-caladea", +) + + +def _statements() -> str: + """コメント行を除いた Dockerfile の本文 (説明の注記に assertion が反応しないように)""" + return "\n".join( + line for line in DOCKERFILE.read_text().splitlines() + if not line.lstrip().startswith("#") + ) + + +def _run_blocks() -> list[str]: + """Dockerfile を RUN ブロック単位 (行継続を含む 1 命令分) に分ける + + ``RUN`` の本文は ``\\`` の行継続で複数行にまたがる。``RUN`` で始まる**行**だけを + 見ると、拾えるのは 1 行目 (``RUN set -eux; \\``) だけでパッケージ名は 1 つも + 入らない。命令ごとに継続行まで連結してから検査する。 + """ + lines = _statements().splitlines() + blocks: list[str] = [] + block: list[str] | None = None + for line in lines: + if block is None: + if not line.startswith("RUN "): + continue + block = [] + block.append(line) + if not line.rstrip().endswith("\\"): + blocks.append("\n".join(block)) + block = None + if block is not None: # 最終行が \ で終わっていても取りこぼさない + blocks.append("\n".join(block)) + assert blocks, "RUN が 1 つも見つからない" + return blocks + + +def _first_run_block() -> str: + """1 つ目の RUN の 1 命令分 (行継続を含む) を取り出す""" + return _run_blocks()[0] + + +def _first_apt_install(block: str) -> str: + """1 つ目の RUN の**1 回目**の apt-get install の一覧だけを取り出す + + 1 つ目の RUN は apt-get install を 2 回呼ぶ。1 回目は Ubuntu の標準のアーカイブから、 + 2 回目は後から足したリポジトリ (docker-ce / terraform / gh / nodejs) からである。 + """ + calls = [m.start() for m in re.finditer(r"apt-get install", block)] + assert len(calls) >= 2, "1 つ目の RUN に apt-get install が 2 回無い" + return block[calls[0]:calls[1]] + + +# --------------------------------------------------------------------------- +# Dockerfile: 6 パッケージ (#160) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("package", NEW_PACKAGES) +def test_the_six_packages_are_in_the_first_apt_install(package): + """6 つとも標準のアーカイブにあるので、1 回目の一覧へ置く (決定 4)""" + assert re.search(rf"(? が効かない""" + assert re.search( + r"^COPY --chmod=0644 fonts-local\.conf /etc/fonts/local\.conf$", + _statements(), + flags=re.MULTILINE, + ) + + +def test_nothing_is_copied_into_fonts_conf_d(): + assert not re.search(r"^COPY\b.*\s/etc/fonts/conf\.d/", _statements(), flags=re.MULTILINE) + + +def test_fc_cache_runs_once_and_after_the_copy(): + text = _statements() + assert len(re.findall(r"fc-cache -f", text)) == 1 + assert text.index("COPY --chmod=0644 fonts-local.conf") < text.index("fc-cache -f") + + +def test_fc_cache_runs_after_playwright_installs_its_fonts(): + """決定 5。--with-deps が後から入れる書体を知らないキャッシュを残さない""" + text = _statements() + assert text.index("npx playwright install") < text.index("fc-cache -f") + + +# --------------------------------------------------------------------------- +# fonts-local.conf の構造 +# --------------------------------------------------------------------------- + +def _root() -> ET.Element: + root = ET.fromstring(FONTS_CONF.read_text()) + assert root.tag == "fontconfig" + return root + + +def test_the_conf_is_well_formed_xml(): + _root() + + +def test_the_four_generic_families_are_aliased_to_the_jp_faces(): + aliases = _root().findall("alias") + got = {} + for alias in aliases: + family = alias.findtext("family") + preferred = [f.text for f in alias.findall("prefer/family")] + assert preferred, f"{family} の に が無い" + got[family] = preferred[0] + assert got == GENERIC_TO_JP + + +def test_there_are_nine_matches_one_fallback_and_eight_language_rules(): + matches = _root().findall("match") + assert len(matches) == 9 + fallbacks = [m for m in matches if not m.findall("test")] + assert len(fallbacks) == 1 + + +def test_the_fallback_is_a_weakly_bound_append(): + """決定 3。弱い結合なので、実在する指定 (Arial など) を妨げない""" + fallback = next(m for m in _root().findall("match") if not m.findall("test")) + assert fallback.get("target") == "pattern" + edits = fallback.findall("edit") + assert len(edits) == 1 + edit = edits[0] + assert edit.get("name") == "family" + assert edit.get("mode") == "append" + assert edit.get("binding") == "weak" + assert edit.findtext("string") == FALLBACK_FAMILY + + +def test_every_language_rule_also_tests_the_generic_family(): + """決定 2。 を省くと Arial:lang=zh-cn から Liberation Sans を奪う""" + got = {} + for match in _root().findall("match"): + tests = match.findall("test") + if not tests: + continue + assert match.get("target") == "pattern" + names = [t.get("name") for t in tests] + assert "family" in names, "言語の規則が総称ファミリの を持たない" + assert "lang" in names + family = next(t.findtext("string") for t in tests if t.get("name") == "family") + lang_test = next(t for t in tests if t.get("name") == "lang") + assert lang_test.get("compare") == "contains" + edits = match.findall("edit") + assert len(edits) == 1 + assert edits[0].get("name") == "family" + assert edits[0].get("mode") == "prepend" + assert edits[0].get("binding") == "strong" + got[(family, lang_test.findtext("string"))] = edits[0].findtext("string") + assert got == LANG_RULES + + +def test_the_leading_comment_explains_why_the_location_cannot_move(): + """受け入れ条件 8。置き場所を動かせない理由と、その実測を先頭のコメントに残す""" + text = FONTS_CONF.read_text() + head = text[:text.index("")] + for token in ("51-local.conf", "conf.d", "99", "/etc/fonts/local.conf", "#161"): + assert token in head, f"先頭のコメントに {token} が無い" diff --git a/tests/containers/test_base_image_font_matching.py b/tests/containers/test_base_image_font_matching.py new file mode 100644 index 00000000..a151afe5 --- /dev/null +++ b/tests/containers/test_base_image_font_matching.py @@ -0,0 +1,187 @@ +"""建てた base イメージの中の解決先 (PLAN63 / #161, #160) + +Dockerfile の文字列検査 (``test_base_dockerfile_fonts.py``) では足りない。固定したいのは +「``COPY`` の行があること」ではなく「``fc-match sans-serif`` が日本語を返すこと」で、後者は +文字列からは分からない。設計の決定 2 のような壊れ方 (``lang`` の条件が広すぎて欧文の指定から +書体を奪う) は、Dockerfile を読んでも見えない。 + +**Docker が無い / イメージが無い / イメージが古いときは skip する。** この変更より前に建てた +``devbase-base:latest`` を持っている人が ``pytest tests/`` で全員赤くなるのを避けるためで、 +古いイメージを「失敗」として知らせると、赤の意味が「壊れている」と「イメージが古い」で混ざる。 + +**``docker run`` はセッションで 1 回に抑える。** ``fc-match`` を 28 回別々に走らせると、 +コンテナの起動だけで数十秒かかる。 +""" + +from __future__ import annotations + +import shutil +import subprocess + +import pytest + +IMAGE = "devbase-base:latest" +BUILD_HINT = f"`devbase build base --no-cache` で {IMAGE} を建て直すと、この検査が効く" + +# イメージの中に /etc/fonts/local.conf が無い (= この変更より前のイメージ) ときの終了コード +STALE_IMAGE_EXIT = 90 + +# 設計「解決先の表」の「変更後」の列。26 行のうち Meiryo / Yu Gothic / MS PGothic の行を +# 3 つへ開いてある +EXPECTED_MATCHES = { + # 総称ファミリ (受け入れ条件 1・2・3) + "sans-serif": "Noto Sans CJK JP", + "sans-serif:lang=ja": "Noto Sans CJK JP", + "sans": "Noto Sans CJK JP", + "serif": "Noto Serif CJK JP", + "monospace": "Noto Sans Mono CJK JP", + # 日本語環境でよく指定される書体名と、イメージに無い書体名 (受け入れ条件 4) + "Noto Sans JP": "Noto Sans CJK JP", + "Meiryo": "Noto Sans CJK JP", + "Yu Gothic": "Noto Sans CJK JP", + "MS PGothic": "Noto Sans CJK JP", + "Zen Kaku Gothic New": "Noto Sans CJK JP", + # 欧文は壊れない (受け入れ条件 5) + "Arial": "Liberation Sans", + "Times New Roman": "Liberation Serif", + "Courier New": "Liberation Mono", + # 欧文の metric 互換が直る (受け入れ条件 11。変更前はどちらも WenQuanYi Zen Hei) + "Calibri": "Carlito", + "Cambria": "Caladea", + # 他言語は壊れない。様式 (sans / serif / 等幅) も保つ (受け入れ条件 6) + "sans-serif:lang=zh-cn": "Noto Sans CJK SC", + "sans:lang=zh-cn": "Noto Sans CJK SC", + "serif:lang=zh-cn": "Noto Serif CJK SC", + "monospace:lang=zh-cn": "Noto Sans Mono CJK SC", + "sans-serif:lang=ko": "Noto Sans CJK KR", + "sans:lang=ko": "Noto Sans CJK KR", + "serif:lang=ko": "Noto Serif CJK KR", + "monospace:lang=ko": "Noto Sans Mono CJK KR", + # 欧文の指定は言語で変わらない。決定 2 の壊れ方を捕まえるのはこの 3 行である + "Arial:lang=zh-cn": "Liberation Sans", + "Times New Roman:lang=zh-cn": "Liberation Serif", + "Arial:lang=ko": "Liberation Sans", + # 実在する書体を名指しした指定は奪わない + "WenQuanYi Zen Hei": "WenQuanYi Zen Hei", + "IPAPGothic": "IPAPGothic", +} + +# 受け入れ条件 9・12 +EXPECTED_COMMANDS = { + "pdftoppm": True, + "pdfinfo": True, + "pdffonts": True, + "pdftocairo": True, + "uv": True, + "soffice": False, + "libreoffice": False, + "pip": False, + "pip3": False, +} + +_PROBE = r""" +set -u +# この変更より前に建てたイメージなら、測らずに抜ける +test -f /etc/fonts/local.conf || exit {stale} + +# ~/.bashrc が入れている PATH の行と同じ状態にする。uv / claude / agy は +# $HOME/.local/bin にあり、bash -c は対話でも login でもないため .bashrc を読まない +export PATH="$HOME/.local/bin:$PATH" + +first_family() {{ + # fc-match の既定の出力は `: "" "