From 5610440c196f23a2d2d0ae0f7d6f4975bda860e9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 24 Aug 2026 06:54:32 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20clone=20=E3=81=A7=E3=81=8D=E3=81=AA?= =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=9F=E3=83=AA=E3=83=9D=E3=82=B8=E3=83=88?= =?UTF-8?q?=E3=83=AA=E3=82=92=20up=20=E3=81=A8=20workspace=20=E3=81=B8?= =?UTF-8?q?=E5=8F=8D=E6=98=A0=E3=81=99=E3=82=8B=20(PLAN37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 複数リポジトリ構成で一部のリポジトリに権限が無い場合、clone の失敗は entrypoint が warning に留めてコンテナ起動を続ける。この方針自体は維持したうえで、失敗の見せ方を直す。 - `devbase up` が ready 待ちの後に `/work` の実体を確認し、`project.yml` に書いたのに 無いリポジトリを clone URL 付きで警告する。揃っているときは何も出さない。 ログを grep せず実体を見るのは、ログが再起動をまたいで積み上がり「いつの失敗か」を 判別できないため。 - multi-root ワークスペースに clone できたリポジトリだけを載せる。ホストは folder ごとに 直列化した `DEVBASE_WORKSPACE_FOLDERS` を渡し、entrypoint は存在する dir の JSON だけを 連結する。シェルで JSON をエスケープしないので dir に引用符が入っても壊れない。 - `DEVBASE_WORKSPACE_B64` は残し、新 wire format を知らない古いイメージでは従来どおり 全フォルダ入りの workspace が書かれるようにする (silent に機能を失わせない)。 entrypoint の変更を反映するにはイメージの再ビルドが要る。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wqvdj79BJRWxhUMR9D9kts --- CHANGELOG.md | 17 +++ containers/base/entrypoint.sh | 64 ++++++++- docs/developer/architecture.md | 2 +- docs/user/project-yml.md | 20 +++ issues/PLAN37_clone-failure-visibility.md | 167 ++++++++++++++++++++++ lib/devbase/commands/container.py | 46 ++++++ lib/devbase/project/runtime.py | 40 +++++- tests/commands/test_up_missing_repos.py | 111 ++++++++++++++ tests/containers/test_entrypoint_repos.py | 99 +++++++++++++ tests/project/test_runtime.py | 45 ++++++ 10 files changed, 600 insertions(+), 11 deletions(-) create mode 100644 issues/PLAN37_clone-failure-visibility.md create mode 100644 tests/commands/test_up_missing_repos.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 28f8c19..ad2f048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ ## [Unreleased] ### Added +- **clone できなかったリポジトリを `devbase up` が知らせる**ようにしました。複数リポジトリ構成で + 一部のリポジトリに権限が無い (または名前が違う) 場合、これまでは entrypoint の警告が + `docker logs` にしか出ず、`up` の画面は成功したように見えていました。コンテナ起動後に + `/work` の実体を確認し、`project.yml` に書いたのに無いリポジトリを clone URL 付きで + 警告します。すべて揃っているときの出力は変わりません。clone の失敗が `up` を失敗させない + 点も従来どおりです。 + +### Changed +- **multi-root ワークスペースに、clone できたリポジトリだけを載せる**ようにしました。これまでは + `project.yml` の内容をそのまま書き出していたため、clone に失敗したリポジトリが VS Code の + エクスプローラに「開けないフォルダ」として並んでいました。 + +> **Note:** ワークスペースの変更は `entrypoint.sh` の変更を含むため、反映には +> `devbase container build` (必要に応じて `--no-cache`) によるイメージの再ビルドが要ります。 +> 再ビルドしていないイメージでは、これまでどおり全フォルダを載せたワークスペースが +> 書き出されます (機能が黙って失われることはありません)。 + - **`plugin.yml` の `requires.devbase` をインストール時に検証**するようにしました。要件を 満たさない Plugin は `devbase plugin install` が中止します。これまでは値を読むだけで 比較しておらず、`project.yml` 形式の Plugin を 2.x へ入れられてしまい、`devbase up` の diff --git a/containers/base/entrypoint.sh b/containers/base/entrypoint.sh index 3c2190a..442b13e 100644 --- a/containers/base/entrypoint.sh +++ b/containers/base/entrypoint.sh @@ -15,7 +15,12 @@ set -e # lib/devbase/project/config.py の encode_repo_plan) # DEVBASE_PRIMARY_DIR : 起動後に cd する /work 配下のディレクトリ名 # DEVBASE_WORKSPACE : 書き出す *.code-workspace の絶対パス (複数 repo 時) -# DEVBASE_WORKSPACE_B64 : その中身 (base64 JSON) +# DEVBASE_WORKSPACE_FOLDERS +# : base64 の行区切りレコード。1 行 = と folder オブジェクトの +# JSON を US (0x1f) で並べたもの。clone できた dir の行だけを +# 連結して workspace にする (PLAN37) +# DEVBASE_WORKSPACE_B64 : 完成済みの workspace (base64 JSON)。DEVBASE_WORKSPACE_FOLDERS を +# 渡さない古いホスト向けの fallback # # 関数定義だけを読み込みたいテストからは # `DEVBASE_ENTRYPOINT_LIB_ONLY=1 . entrypoint.sh` で source する。 @@ -91,14 +96,63 @@ EOF # 複数 repo をまとめて開くための *.code-workspace を書き出す。 # -# 中身はホスト側で組み立てて base64 で渡ってくるので、ここでは復号して置くだけ。 -# JSON の組み立て (エスケープ) をシェルでやらない分、壊れにくい。 +# clone できなかった repo のフォルダを載せると、VS Code のエクスプローラに開けない +# フォルダが並ぶ (PLAN37)。そこで DEVBASE_WORKSPACE_FOLDERS の各行を見て、 +# / が実在する行の JSON だけを連結する。 +# +# 各 folder の JSON はホスト側が直列化済みなので、ここで組み立てるのは外枠 ( +# `{"folders": [` … `]}`) とカンマだけ。dir に " や \ が入っていてもシェルで +# エスケープを考えずに済む。 devbase_write_workspace() { + local work_root="${1:-/work}" [ -n "${DEVBASE_WORKSPACE:-}" ] || return 0 - [ -n "${DEVBASE_WORKSPACE_B64:-}" ] || return 0 local dest="$DEVBASE_WORKSPACE" mkdir -p "$(dirname "$dest")" + + if [ -z "${DEVBASE_WORKSPACE_FOLDERS:-}" ]; then + # 旧ホスト (PLAN37 前) から起動された場合。完成品をそのまま置く。 + devbase_write_workspace_verbatim "$dest" + return 0 + fi + + local records dir folder extra first=1 + if ! records="$(printf '%s' "$DEVBASE_WORKSPACE_FOLDERS" | base64 -d 2>/dev/null)"; then + echo "Warning: Failed to decode DEVBASE_WORKSPACE_FOLDERS" + devbase_write_workspace_verbatim "$dest" + return 0 + fi + + { + printf '{\n "folders": [\n' + while IFS=$'\x1f' read -r dir folder extra; do + [ -n "$dir$folder$extra" ] || continue + if [ -z "$dir" ] || [ -z "$folder" ] || [ -n "$extra" ]; then + echo "Warning: Ignoring malformed workspace folder record" >&2 + continue + fi + if [ ! -d "$work_root/$dir" ]; then + echo "Warning: Skipping workspace folder (not cloned): $dir" >&2 + continue + fi + [ "$first" = "1" ] || printf ',\n' + printf ' %s' "$folder" + first=0 + done < "$dest.tmp" + + mv "$dest.tmp" "$dest" + echo "Workspace file written: $dest" +} + +# ホストが組み立て済みの workspace (DEVBASE_WORKSPACE_B64) をそのまま書き出す。 +devbase_write_workspace_verbatim() { + local dest="$1" + [ -n "${DEVBASE_WORKSPACE_B64:-}" ] || return 0 + if printf '%s' "$DEVBASE_WORKSPACE_B64" | base64 -d > "$dest.tmp" 2>/dev/null; then mv "$dest.tmp" "$dest" echo "Workspace file written: $dest" @@ -392,7 +446,7 @@ echo "AI agent settings symlinks setup completed" # 個々の失敗はコンテナ起動を止めない (関数内で warning 扱い)。 DEVBASE_WORK_ROOT="${DEVBASE_WORK_ROOT:-/work}" devbase_clone_repos "$DEVBASE_WORK_ROOT" -devbase_write_workspace +devbase_write_workspace "$DEVBASE_WORK_ROOT" devbase_enter_primary_dir "$DEVBASE_WORK_ROOT" # Signal that entrypoint setup is complete diff --git a/docs/developer/architecture.md b/docs/developer/architecture.md index c3af02e..5bb5f27 100644 --- a/docs/developer/architecture.md +++ b/docs/developer/architecture.md @@ -148,7 +148,7 @@ YAML の解釈をホスト側の Python に閉じ込めることで、コンテ | モジュール | 役割 | |-----------|------| | `config.py` | `project.yml` の読み込み・`defaults` 継承・検証・正規化 (`ProjectConfig` / `RepoSpec`)。clone プランの符号化 (`encode_repo_plan`) | -| `runtime.py` | コンテナへ渡す環境変数の組み立て (`DEVBASE_REPOS` / `DEVBASE_PRIMARY_DIR` / `DEVBASE_WORKSPACE*`)、multi-root ワークスペース JSON の生成、`scale` の読み書き | +| `runtime.py` | コンテナへ渡す環境変数の組み立て (`DEVBASE_REPOS` / `DEVBASE_PRIMARY_DIR` / `DEVBASE_WORKSPACE*`)、multi-root ワークスペース JSON の生成 (`DEVBASE_WORKSPACE_FOLDERS` は folder ごとに直列化した形。entrypoint が clone できた repo だけを選べるようにするため)、`scale` の読み書き | | `migrate.py` | 旧 `env` 形式 (`GIT_USER` / `GIT_REPO` 等) から `project.yml` への変換 | ```mermaid diff --git a/docs/user/project-yml.md b/docs/user/project-yml.md index 0dbfad4..a72edf2 100644 --- a/docs/user/project-yml.md +++ b/docs/user/project-yml.md @@ -97,6 +97,26 @@ clone URL は `https:////.git` で組み立てられます。 - `repos[]` の `host` / `owner` / `repo` / `dir` / `branch` に空白・制御文字が混ざっている - `dir` が `/work` 直下から外れている(`../` や入れ子のパス、`.` / `..`) +## clone できないリポジトリがあるとき + +primary には権限があるがサブリポジトリには権限がない、という構成は起こりえます。この場合 +**権限のあるリポジトリだけが clone され、コンテナは通常どおり起動します**。1 本 clone できない +だけで開発環境ごと止めても、他のリポジトリでの作業まで巻き添えになるためです。 + +| 起きること | 挙動 | +|---|---| +| clone の失敗 | 警告を出して次のリポジトリへ進む。`devbase up` は成功で終わる (終了コード 0) | +| `devbase up` の出力 | `/work` に無いリポジトリを clone URL 付きで一覧表示する。揃っていれば何も出さない | +| multi-root ワークスペース | clone できたリポジトリだけが `folders` に載る。開けないフォルダは並ばない | +| primary が clone できなかった | 警告を出し、ログイン直後のカレントは `/work` になる | + +clone は**コンテナ起動のたびに試行される**ので、後から権限が付与されれば次の `devbase up` で +取り込まれます。`project.yml` を直す必要はありません。 + +権限が無い場合も存在しない場合も、GitHub は private リポジトリに対して同じ +`Repository not found` (404) を返します。警告からは区別できないため、リポジトリ名の +打ち間違いも同じ見え方になります。詳細は `devbase project logs ` で確認してください。 + ## `env` との使い分け | 書く場所 | 内容 | 例 | diff --git a/issues/PLAN37_clone-failure-visibility.md b/issues/PLAN37_clone-failure-visibility.md new file mode 100644 index 0000000..cba264a --- /dev/null +++ b/issues/PLAN37_clone-failure-visibility.md @@ -0,0 +1,167 @@ +# PLAN37: clone に失敗したリポジトリを `devbase up` と workspace に正しく反映する + +## 関連リンク + +- 前提となる設計: `issues/PLAN32_multi-repo-project.md`(複数リポジトリ clone と multi-root workspace) +- 実装: `containers/base/entrypoint.sh`、`lib/devbase/project/runtime.py`、`lib/devbase/commands/container.py` +- 発端: multi-repo 構成で「primary には権限があるがサブリポジトリには権限がない」ケースの挙動調査 + +## モード + +`standard` — 既存の振る舞い(clone 失敗はコンテナ起動を止めない)は変えず、その結果の**見せ方**を足す。 +公開コマンドは増えず、`project.yml` スキーマも変わらない。コンテナへ渡す内部 wire format を 1 つ追加する。 + +## 目的と非目的 + +達成したい状態: + +- `devbase up` の出力だけで、**どのリポジトリが `/work` に無いか**が分かる。`docker logs` を掘らなくてよい。 +- 生成される `*.code-workspace` に、**存在しないフォルダが並ばない**。 + +やらないこと: + +- clone 失敗で `devbase up` を失敗させること。1 本落ちただけで開発環境ごと止めない方針(PLAN32)は維持する。 +- 権限エラーと typo の区別。GitHub は権限のない private リポジトリにも `Repository not found` (404) を返すため、 + クライアント側では判別できない。表示は「`/work` に無い」という事実に留める。 +- clone のリトライ・認証まわりの改善。entrypoint は起動のたびに clone を試すので、権限付与後は次回 `up` で解決する。 + +## 前提 + +調査で確認した現状(2026-08-24、実機 `nyle-dx-dev-1` と実イメージの `/entrypoint.sh` で確認): + +- 前提 1: `devbase_clone_repos` は個々の clone / checkout / init.sh の失敗を warning に留めて次の repo へ進む。 + 回帰テストは `tests/containers/test_entrypoint_repos.py:131` にある。primary が落ちても起動は続く(同 `:271`)。 +- 前提 2: 失敗はハングしない。コンテナは `Tty=false` で、GitHub は権限のない private リポジトリにも 404 を返すため、 + git は認証プロンプトへ落ちずに 0.4 秒で `exit 128` する。`~/.git-credentials` も消えない(401 ではないので reject が走らない)。 +- 前提 3: `cmd_up` は `docker compose up -d` → ready 待ち → editor 起動の順で進み、entrypoint の標準出力を一切見ない。 + そのため clone 失敗があっても `=== Deploy completed successfully ===` で終わる。 +- 前提 4: workspace の JSON はホスト側 (`build_workspace_document`) が `project.yml` から静的に組み立て、 + `DEVBASE_WORKSPACE_B64` で渡している。clone の成否は反映されない。 +- 前提 5: entrypoint の変更は base イメージの再ビルドが要る(`devbase container build`)。ホストだけ更新した状態でも + **今までどおり動く**必要がある。 + +## 受け入れ条件 + +- [x] AC1: `project.yml` に 2 件書き、片方が clone できない構成で `devbase up` すると、標準出力に + 「`/work` に無いリポジトリ」の一覧(dir と clone URL)と、詳細の確認先が出る。 + 検証: 稼働中の実コンテナ `nyle-dx-dev-1` に対して `_report_missing_repos` を実行(docker のモックなし)。 + `no-such-repo-xyz123` が clone URL 付きで警告に出た。 +- [x] AC2: AC1 の状況でも `devbase up` の終了コードは 0 で、成功したリポジトリでは通常どおり作業できる。 + 検証: 報告は `logger.warning` のみで戻り値を持たず、例外も投げない(`tests/commands/test_up_missing_repos.py`)。 +- [x] AC3: 全リポジトリが揃っているときは、`up` の出力は従来と変わらない。 + 検証: 実 `nyle-dx` の `project.yml`(nyle-dx + ideabase、どちらも `/work` に有る)で出力なし。 +- [x] AC4: clone に失敗したリポジトリは `*.code-workspace` の `folders` に含まれない。 + 検証: 実イメージ内の bash で新 `devbase_write_workspace` を実行し、`no-such-repo-xyz123` だけが + 落ちた JSON を確認。 +- [x] AC5: 落としたフォルダは entrypoint のログに warning として残る。 + 検証: 同上(`Warning: Skipping workspace folder (not cloned): no-such-repo-xyz123`)。 +- [x] AC6: 旧イメージ(新 wire format を知らない entrypoint)に新しいホストから `up` しても、workspace は + 従来どおり全フォルダ入りで書き出される。 + 検証: イメージに焼かれている**実物の** `/entrypoint.sh` に新旧両方の環境変数を渡し、 + `DEVBASE_WORKSPACE_B64` 経由で 3 フォルダすべてが書き出されることを確認。 +- [x] AC7: `pytest` が通る(1445 passed)。 + +## 代替案と採否 + +| 案 | 内容 | 採否 | 理由 | +|---|---|---|---| +| clone 結果をコンテナのログから grep してホストで表示 | `docker logs` を `Warning: Failed to clone` で検索 | 不採用 | ログはコンテナ再起動をまたいで積み上がり、いつの失敗か特定できない。「今 `/work` に有るか」を直接見る方が真実に近い | +| **`/work` の実体を見て不足を報告する** | ready 待ちの後に `ls -A1 /work` を 1 回実行し、`project.yml` の dir 集合と突き合わせる | **採用** | 冪等で、既存 clone を引き継いだ場合も正しい。exec は instance あたり 1 回で済む | +| dir ごとに `test -d` を exec | repo 件数 × instance 回の exec | 不採用 | 遅く、dir 名をシェルへ渡すためのクォートが増える。`ls` の 1 回で足りる | +| workspace を entrypoint 側で JSON パースして絞る | `jq` / `python3` で folders をフィルタ | 不採用 | base 非継承イメージ (lfm 等) に依存を増やす。PLAN32 が避けた方針をそのまま踏襲する | +| **folder ごとに直列化した JSON をホストから渡し、entrypoint は存在するものだけ連結する** | `DEVBASE_WORKSPACE_FOLDERS` = `` の行 | **採用** | `dir` に `"` や `\` が入ってもホスト側の `json.dumps` が処理済み。entrypoint は連結するだけで JSON パーサが要らない | +| `DEVBASE_WORKSPACE_B64` を置き換える | 旧変数を削除する | 不採用 | ホストだけ更新してイメージが古い間、workspace が黙って書かれなくなる。旧変数は fallback として残す | + +## ドメイン用語 + +| 用語 | 意味 | +|---|---| +| clone プラン | `project.yml` を正規化した内部表現。`DEVBASE_REPOS` でコンテナへ渡る(PLAN32) | +| workspace フォルダレコード | 本 PLAN で追加する wire format。`` を 1 行とする LF 区切り、全体を base64 | +| 欠落リポジトリ | `project.yml` に書かれているのに `/work/` が存在しないリポジトリ | + +## 不変条件 + +- clone の失敗はコンテナ起動を止めない(PLAN32 から継続)。 +- ホスト側だけが `project.yml` と wire format の変換を行う。entrypoint は JSON を組み立てない。 +- 新しい警告は**異常時のみ**出す。全リポジトリが揃っているときの出力は変えない。 + +## wire format + +追加する環境変数(`DEVBASE_WORKSPACE` / `DEVBASE_WORKSPACE_B64` は現状のまま残す): + +``` +DEVBASE_WORKSPACE_FOLDERS : base64。復号すると 1 行 1 フォルダの LF 区切り。 + 1 行 = + 例: nyle-dx\x1f{"name": "nyle-dx", "path": "/work/nyle-dx"} +``` + +`dir` は空白・制御文字を含まないことが `project.yml` のローダで保証済みなので、US / LF がフィールドを割ることはない。 +JSON 側も `json.dumps` が制御文字をエスケープするため、1 行に収まる。 + +## 修正対象 + +- `lib/devbase/project/runtime.py` — `container_env` に `DEVBASE_WORKSPACE_FOLDERS` を追加 +- `containers/base/entrypoint.sh` — `devbase_write_workspace` が存在するフォルダだけを書き出す +- `lib/devbase/commands/container.py` — ready 待ちの後に欠落リポジトリを報告する +- `tests/project/test_runtime.py` / `tests/containers/test_entrypoint_repos.py` / `tests/commands/` — 回帰テスト +- `docs/developer/architecture.md`、`docs/user/project-yml.md`、`CHANGELOG.md` — wire format と挙動の記述 + +## タスク分解 + +### Task 1: workspace フォルダレコードの生成(ホスト) + +- `container_env` が repo 2 件以上のとき `DEVBASE_WORKSPACE_FOLDERS` を追加する。`build_workspace_document` の + folders と同じ順序(primary 先頭)を使い、1 フォルダ = 1 レコードへ直列化する。 +- 単体テスト: レコード数・順序・`dir` とのペア・repo 1 件のときは付かないこと。 + +### Task 2: 存在するフォルダだけを書き出す(entrypoint) + +- `devbase_write_workspace ` にして、`DEVBASE_WORKSPACE_FOLDERS` があればレコードを読み、 + `/` が存在する行だけを `{"folders": [...]}` へ連結する。 +- 落とした行は `Warning: Skipping workspace folder (not cloned): ` として出す。 +- `DEVBASE_WORKSPACE_FOLDERS` が無ければ従来どおり `DEVBASE_WORKSPACE_B64` をそのまま書き出す(旧ホスト互換)。 +- 単体テスト: 欠落フォルダの除外、全滅時 (`folders: []`)、fallback、warning の出力。 + +### Task 3: 欠落リポジトリの報告(ホスト) + +- `cmd_up` の ready 待ちの直後に、instance ごとに `docker compose exec -T dev- ls -A1 /work` を実行し、 + `project.yml` の dir 集合との差を求める。 +- 欠落があれば `logger.warning` で dir と clone URL、`docker logs` の確認先を出す。欠落が無ければ何も出さない。 +- exec 自体が失敗した場合は黙って諦める(`up` を倒さない)。 +- 単体テスト: 欠落あり / 無し / exec 失敗の 3 経路。 + +### Task 4: ドキュメントと CHANGELOG + +- `docs/developer/architecture.md` の `runtime.py` の説明に新 wire format を追記。 +- `docs/user/project-yml.md` に「権限が無いリポジトリがあるとどうなるか」を追記。 +- `CHANGELOG.md` に、entrypoint の変更を反映するには `devbase container build` が要る旨を明記。 + +## 影響範囲 + +| 対象 | 影響 | +|---|---| +| 既存プロジェクト(repo 1 件) | なし。`DEVBASE_WORKSPACE_FOLDERS` は 2 件以上でしか付かない | +| 既存プロジェクト(repo 2 件以上・全て clone 成功) | なし。workspace の内容も出力も変わらない | +| 旧イメージ + 新ホスト | workspace は fallback 経路で従来どおり。欠落リポジトリの報告はホスト側なので効く | +| 新イメージ + 旧ホスト | `DEVBASE_WORKSPACE_FOLDERS` が無く fallback へ落ちるだけ | + +## リスクと対処 + +| リスク | 対処 | +|---|---| +| `/work` は複数プロジェクトで共有されるため、別プロジェクトが同名 dir を作っていると「有る」と判定される | dir 名の衝突は共有ボリュームの既存の性質。報告は「`/work` に無い」だけを述べ、所有権は主張しない | +| `ls -A1 /work` が巨大になる | 出力は名前だけで、共有ボリュームの実績でも数十件。1 instance につき 1 回に留める | +| entrypoint のシェルで JSON を組み立てる | 組み立てるのは `{"folders": [` と `]}` の外枠と `,` だけ。値はホストが直列化済み | + +## 切り戻し手順 + +1. `lib/devbase/commands/container.py` の報告呼び出しを外す(警告が消えるだけ)。 +2. workspace を元に戻す場合は `container_env` から `DEVBASE_WORKSPACE_FOLDERS` を落とす。entrypoint は + fallback で `DEVBASE_WORKSPACE_B64` を使うため、イメージを戻さなくても旧挙動に戻る。 + +## 完了の定義 + +- 受け入れ条件 AC1〜AC7 を満たす。 +- `pytest` が通り、新規テストが Task 1〜3 の各経路を押さえている。 +- ドキュメントと CHANGELOG が更新されている。 diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 197df26..3f7b86f 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -20,6 +20,7 @@ get_dev_service_name, ) from devbase.utils.docker import ( + docker_compose, docker_compose_down, docker_compose_up, wait_for_containers_ready, @@ -633,6 +634,47 @@ def _maybe_open_editor(project_name: str, open_flag: Optional[bool], logger.warning("エディタの自動オープンに失敗しましたがデプロイは成功しています: %s", e) +def _report_missing_repos(config, scale: int, dev_service_name: str, + project_name: str, + compose_file: Optional[Path] = None) -> None: + """``project.yml`` に書いたのに ``/work`` へ無いリポジトリを警告する (PLAN37)。 + + clone の失敗は entrypoint 側で warning に留めてコンテナ起動を続ける + (``containers/base/entrypoint.sh`` の ``devbase_clone_repos``)。その warning は + ``docker logs`` にしか出ないため、``up`` の画面だけを見ていると「成功した」と + 読めてしまう。ここで ``/work`` の実体を見て不足を伝える。 + + ログを grep せず実体を見るのは、ログがコンテナ再起動をまたいで積み上がり + 「いつの失敗か」を判別できないため。「今 ``/work`` に有るか」の方が真実に近い。 + + 問い合わせ自体の失敗 (コンテナが既に落ちている等) では何も言わない。``up`` は + ここまでで成功しており、付随情報のために倒す価値はない。 + """ + for index in range(1, scale + 1): + service = f"{dev_service_name}-{index}" + try: + result = docker_compose( + ['exec', '-T', service, 'ls', '-A1', '/work'], + compose_file=compose_file, check=True, + capture_output=True, silent_error=True) + except (subprocess.CalledProcessError, OSError): + continue + + # dir には空白・制御文字が入らない (project.yml のローダが弾く) ので + # ls の 1 行 = 1 エントリ名として扱える。 + present = set(result.stdout.split()) + missing = [repo for repo in config.repos if repo.dir not in present] + if not missing: + continue + + logger.warning("Repositories missing in /work of %s (clone may have failed):", + service) + for repo in missing: + logger.warning(" - %s (%s)", repo.dir, repo.url) + logger.warning(" Details: devbase project logs %s | grep Warning", + project_name) + + def cmd_up(project_name: str = None, scale: int = None, open_editor: Optional[bool] = None, open_index: Optional[int] = None) -> int: @@ -703,6 +745,10 @@ def cmd_up(project_name: str = None, scale: int = None, timeout=60 ) + # clone できなかった repo があれば伝える (揃っていれば何も出さない)。 + _report_missing_repos(config, scale, dev_service_name, project_name, + compose_file=override_file) + # Run project-specific deploy script for each scaled instance deploy_script = Path('./deploy') if deploy_script.exists() and deploy_script.is_file(): diff --git a/lib/devbase/project/runtime.py b/lib/devbase/project/runtime.py index 62270db..21968ec 100644 --- a/lib/devbase/project/runtime.py +++ b/lib/devbase/project/runtime.py @@ -37,9 +37,35 @@ def build_workspace_document(config: ProjectConfig) -> Dict[str, Any]: primary repo を先頭に置く。エディタのエクスプローラは並び順どおりに出るため、 作業の起点になる repo が一番上に来る方が探しやすい。 """ - repos = sorted(config.repos, key=lambda repo: not repo.primary) - return {"folders": [{"name": repo.dir, "path": f"/work/{repo.dir}"} - for repo in repos]} + return {"folders": [_workspace_folder(repo) for repo in _workspace_repos(config)]} + + +def encode_workspace_folders(config: ProjectConfig) -> str: + r"""workspace の folder を 1 行 1 件へ直列化する (PLAN37 の wire format)。 + + ```` の行を LF で連ね、全体を base64 化する。 + entrypoint は ```` で clone の成否を確かめ、生き残った行の JSON だけを + 連結して workspace を書き出す。**ホストが JSON を直列化しておくことが要点**で、 + ``dir`` に ``"`` や ``\`` が入っていてもシェル側でエスケープを考えずに済む。 + + ``dir`` は ``project.yml`` のローダが空白・制御文字を弾いているため US / LF が + フィールドを割ることはなく、JSON 側も ``json.dumps`` が制御文字を + エスケープするので 1 行に収まる。 + """ + lines = [] + for repo in _workspace_repos(config): + folder = json.dumps(_workspace_folder(repo), ensure_ascii=False) + lines.append(f"{repo.dir}\x1f{folder}\n") + return base64.b64encode("".join(lines).encode()).decode() + + +def _workspace_repos(config: ProjectConfig): + """workspace へ並べる順 (primary が先頭)。""" + return sorted(config.repos, key=lambda repo: not repo.primary) + + +def _workspace_folder(repo) -> Dict[str, str]: + return {"name": repo.dir, "path": f"/work/{repo.dir}"} def container_env(config: ProjectConfig, project_name: str) -> Dict[str, str]: @@ -47,8 +73,11 @@ def container_env(config: ProjectConfig, project_name: str) -> Dict[str, str]: - ``DEVBASE_REPOS``: clone プラン (base64) - ``DEVBASE_PRIMARY_DIR``: 起動後に ``cd`` する ``/work`` 配下のディレクトリ名 - - ``DEVBASE_WORKSPACE`` / ``DEVBASE_WORKSPACE_B64``: repo が 2 件以上のときだけ。 - 1 件のときは従来どおりフォルダを開かせたいので付けない。 + - ``DEVBASE_WORKSPACE`` / ``DEVBASE_WORKSPACE_B64`` / ``DEVBASE_WORKSPACE_FOLDERS``: + repo が 2 件以上のときだけ。1 件のときは従来どおりフォルダを開かせたいので付けない。 + entrypoint は ``DEVBASE_WORKSPACE_FOLDERS`` から clone できた repo だけを選んで + 書き出す (PLAN37)。``DEVBASE_WORKSPACE_B64`` は**この変数を知らない古いイメージ**の + ための完成品で、新しいホスト + 古いイメージでも workspace が消えないよう残している。 値は base64 と検証済みの名前だけなので、``$`` や改行を含まず compose の 変数展開に食われない。 @@ -62,6 +91,7 @@ def container_env(config: ProjectConfig, project_name: str) -> Dict[str, str]: ensure_ascii=False, indent=2) env["DEVBASE_WORKSPACE"] = workspace_path(project_name) env["DEVBASE_WORKSPACE_B64"] = base64.b64encode(document.encode()).decode() + env["DEVBASE_WORKSPACE_FOLDERS"] = encode_workspace_folders(config) return env diff --git a/tests/commands/test_up_missing_repos.py b/tests/commands/test_up_missing_repos.py new file mode 100644 index 0000000..56508aa --- /dev/null +++ b/tests/commands/test_up_missing_repos.py @@ -0,0 +1,111 @@ +"""`devbase up` が clone できなかったリポジトリを伝える (PLAN37)。 + +clone の失敗は entrypoint 側で warning に留まりコンテナ起動は続く。その warning は +`docker logs` にしか出ないため、`up` の画面だけでは「揃っている」と読めてしまう。 +ここでは `/work` の実体を見て不足を報告する経路を固定する。 +""" + +from __future__ import annotations + +import logging +import subprocess + +import pytest + +from devbase.commands import container +from devbase.project.config import parse_project_config + + +def config_of(*repos): + return parse_project_config( + {"version": 1, "defaults": {"owner": "volareinc"}, + "repos": [{"repo": r} for r in repos]}, + source="project.yml") + + +@pytest.fixture +def work_listing(monkeypatch): + """`docker compose exec ... ls -A1 /work` の応答を差し替える。""" + calls: list = [] + + def install(responses): + def fake(command, **kwargs): + calls.append(command) + service = command[2] + reply = responses[service] + if isinstance(reply, Exception): + raise reply + return subprocess.CompletedProcess(command, 0, stdout=reply, stderr="") + + monkeypatch.setattr(container, 'docker_compose', fake) + return calls + + return install + + +def warnings_of(caplog): + return [r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING] + + +def test_missing_repositories_are_named_with_their_clone_url(work_listing, caplog): + work_listing({"dev-1": "carmo\n"}) + + with caplog.at_level(logging.WARNING): + container._report_missing_repos(config_of("carmo", "carmo-batch"), + scale=1, dev_service_name="dev", + project_name="carmo") + + messages = "\n".join(warnings_of(caplog)) + assert "carmo-batch" in messages + assert "https://github.com/volareinc/carmo-batch.git" in messages + + +def test_nothing_is_reported_when_every_repository_is_present(work_listing, caplog): + """揃っているときの出力は従来どおり (正常時にノイズを増やさない)。""" + work_listing({"dev-1": "carmo\ncarmo-batch\n"}) + + with caplog.at_level(logging.WARNING): + container._report_missing_repos(config_of("carmo", "carmo-batch"), + scale=1, dev_service_name="dev", + project_name="carmo") + + assert warnings_of(caplog) == [] + + +def test_other_directories_in_the_shared_work_volume_are_ignored(work_listing, caplog): + """/work は他プロジェクトと共有される。関係ないディレクトリは判定に使わない。""" + work_listing({"dev-1": "carmo\ncarmo-batch\nuttaro-system\n.pnpm-store\n"}) + + with caplog.at_level(logging.WARNING): + container._report_missing_repos(config_of("carmo", "carmo-batch"), + scale=1, dev_service_name="dev", + project_name="carmo") + + assert warnings_of(caplog) == [] + + +def test_every_instance_is_checked(work_listing, caplog): + """scale>1 では instance ごとに /work ボリュームが別なので全部見る。""" + calls = work_listing({"dev-1": "carmo\ncarmo-batch\n", "dev-2": "carmo\n"}) + + with caplog.at_level(logging.WARNING): + container._report_missing_repos(config_of("carmo", "carmo-batch"), + scale=2, dev_service_name="dev", + project_name="carmo") + + assert [c[2] for c in calls] == ["dev-1", "dev-2"] + messages = "\n".join(warnings_of(caplog)) + assert "dev-2" in messages + assert "dev-1" not in messages + + +def test_a_failed_lookup_stays_silent(work_listing, caplog): + """問い合わせが失敗しても up は成功済み。付随情報のために騒がない。""" + work_listing({"dev-1": subprocess.CalledProcessError(1, "docker")}) + + with caplog.at_level(logging.WARNING): + container._report_missing_repos(config_of("carmo", "carmo-batch"), + scale=1, dev_service_name="dev", + project_name="carmo") + + assert warnings_of(caplog) == [] diff --git a/tests/containers/test_entrypoint_repos.py b/tests/containers/test_entrypoint_repos.py index 30a6373..47424ef 100644 --- a/tests/containers/test_entrypoint_repos.py +++ b/tests/containers/test_entrypoint_repos.py @@ -254,6 +254,105 @@ def test_workspace_is_skipped_when_not_configured(tmp_path, work): assert list(work.iterdir()) == [] +# --------------------------------------------------------------------------- +# workspace: clone できなかったフォルダを載せない (PLAN37) +# --------------------------------------------------------------------------- + +def encode_folders(*dirs) -> str: + """host 側 (``encode_workspace_folders``) と同じ wire format を組み立てる。""" + text = "".join( + f'{d}\x1f{json.dumps({"name": d, "path": f"/work/{d}"})}\n' for d in dirs) + return base64.b64encode(text.encode()).decode() + + +def write_workspace(work: Path, env: dict, tmp_path: Path): + dest = work / "sample.code-workspace" + result = run_entrypoint_fn(f'devbase_write_workspace "{work}"', + {"DEVBASE_WORKSPACE": str(dest), **env}, tmp_path) + return result, dest + + +def test_workspace_lists_only_the_repositories_that_exist(tmp_path, work): + """clone に失敗した repo を載せると VS Code に開けないフォルダが並ぶため落とす。""" + (work / "app").mkdir() + + result, dest = write_workspace( + work, {"DEVBASE_WORKSPACE_FOLDERS": encode_folders("app", "no-perm")}, tmp_path) + + assert result.returncode == 0, result.stderr + assert json.loads(dest.read_text()) == { + "folders": [{"name": "app", "path": "/work/app"}]} + + +def test_a_skipped_workspace_folder_is_reported(tmp_path, work): + """何が消えたのか後から分かるよう warning に残す。""" + (work / "app").mkdir() + + result, _ = write_workspace( + work, {"DEVBASE_WORKSPACE_FOLDERS": encode_folders("app", "no-perm")}, tmp_path) + + assert "no-perm" in result.stdout + result.stderr + assert "Warning" in result.stdout + result.stderr + + +def test_workspace_keeps_the_declared_order(tmp_path, work): + for name in ("app", "docs", "infra"): + (work / name).mkdir() + + result, dest = write_workspace( + work, {"DEVBASE_WORKSPACE_FOLDERS": encode_folders("app", "docs", "infra")}, + tmp_path) + + assert result.returncode == 0, result.stderr + assert [f["name"] for f in json.loads(dest.read_text())["folders"]] == [ + "app", "docs", "infra"] + + +def test_workspace_is_still_valid_json_when_nothing_was_cloned(tmp_path, work): + """全滅しても壊れた JSON を残さない (古い workspace が残るより空の方が読める)。""" + result, dest = write_workspace( + work, {"DEVBASE_WORKSPACE_FOLDERS": encode_folders("no-perm")}, tmp_path) + + assert result.returncode == 0, result.stderr + assert json.loads(dest.read_text()) == {"folders": []} + + +def test_workspace_handles_special_characters_in_the_folder_name(tmp_path, work): + """folder の JSON はホストが直列化済み。シェルは連結するだけで壊さない。""" + (work / 'we"ird').mkdir() + + result, dest = write_workspace( + work, {"DEVBASE_WORKSPACE_FOLDERS": encode_folders('we"ird')}, tmp_path) + + assert result.returncode == 0, result.stderr + assert json.loads(dest.read_text()) == { + "folders": [{"name": 'we"ird', "path": '/work/we"ird'}]} + + +def test_workspace_falls_back_to_the_prebuilt_document(tmp_path, work): + """PLAN37 を知らない古いホストから起動されても workspace を消さない。""" + document = {"folders": [{"name": "app", "path": "/work/app"}]} + encoded = base64.b64encode(json.dumps(document).encode()).decode() + + result, dest = write_workspace(work, {"DEVBASE_WORKSPACE_B64": encoded}, tmp_path) + + assert result.returncode == 0, result.stderr + assert json.loads(dest.read_text()) == document + + +def test_a_broken_folder_record_does_not_fail_startup(tmp_path, work): + (work / "app").mkdir() + broken = base64.b64encode(b"app\x1f{\"name\": \"app\", \"path\": \"/work/app\"}\nnoseparator\n").decode() + + result, dest = write_workspace( + work, {"DEVBASE_WORKSPACE_FOLDERS": broken}, tmp_path) + + assert result.returncode == 0, result.stderr + assert json.loads(dest.read_text()) == { + "folders": [{"name": "app", "path": "/work/app"}]} + assert "Warning" in result.stdout + result.stderr + + # --------------------------------------------------------------------------- # primary への cd # --------------------------------------------------------------------------- diff --git a/tests/project/test_runtime.py b/tests/project/test_runtime.py index da8588c..8cef8d0 100644 --- a/tests/project/test_runtime.py +++ b/tests/project/test_runtime.py @@ -12,6 +12,7 @@ from devbase.project.runtime import ( build_workspace_document, container_env, + encode_workspace_folders, hook_env, read_scale, workspace_path, @@ -59,6 +60,50 @@ def test_single_repo_projects_open_a_plain_folder(): assert "DEVBASE_WORKSPACE" not in env assert "DEVBASE_WORKSPACE_B64" not in env + assert "DEVBASE_WORKSPACE_FOLDERS" not in env + + +# --------------------------------------------------------------------------- +# workspace の folder レコード (PLAN37) +# --------------------------------------------------------------------------- + +def decode_folder_records(encoded: str): + """```` の行を (dir, folder) の列へ戻す。""" + text = base64.b64decode(encoded).decode() + assert text.endswith("\n"), "行区切りは末尾にも付ける契約" + return [(line.split("\x1f")[0], json.loads(line.split("\x1f")[1])) + for line in text.splitlines() if line] + + +def test_workspace_folders_pair_each_dir_with_its_serialized_folder(): + """entrypoint が dir で存在確認できるよう、dir と folder JSON が組で並ぶ。""" + env = container_env(config_of("carmo", "carmo-batch"), project_name="carmo") + + assert decode_folder_records(env["DEVBASE_WORKSPACE_FOLDERS"]) == [ + ("carmo", {"name": "carmo", "path": "/work/carmo"}), + ("carmo-batch", {"name": "carmo-batch", "path": "/work/carmo-batch"}), + ] + + +def test_workspace_folders_follow_the_document_order(): + """primary 先頭の並びは workspace 本体と揃える (エクスプローラの並び)。""" + config = config_of("carmo-doc", {"repo": "carmo", "primary": True}) + + records = decode_folder_records(encode_workspace_folders(config)) + + assert [dir_ for dir_, _ in records] == ["carmo", "carmo-doc"] + assert [folder for _, folder in records] == build_workspace_document(config)["folders"] + + +def test_workspace_folder_records_stay_on_one_line_with_special_characters(): + """dir に引用符が入っても、直列化はホスト側で済ませてあるので行が割れない。""" + config = config_of({"repo": "carmo", "dir": 'we"ird'}, "carmo-batch") + + text = base64.b64decode(encode_workspace_folders(config)).decode() + + assert len(text.splitlines()) == 2 + assert decode_folder_records(encode_workspace_folders(config))[0] == ( + 'we"ird', {"name": 'we"ird', "path": '/work/we"ird'}) def test_workspace_path_is_derived_from_the_project_name(): From 1194e547976399e53340553523c3a21e5ec057e8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 24 Aug 2026 06:59:29 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs(PLAN37):=20=E5=8F=97=E3=81=91=E5=85=A5?= =?UTF-8?q?=E3=82=8C=E6=9D=A1=E4=BB=B6=E3=82=92=E5=AE=9F=E6=A9=9F=E6=A4=9C?= =?UTF-8?q?=E8=A8=BC=E3=81=AE=E7=B5=90=E6=9E=9C=E3=81=B8=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit イメージを再ビルドしたうえで、権限のないリポジトリを一時的に足した nyle-dx で devbase up を実行し、AC1〜AC5 を実機で確認した結果に差し替える。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wqvdj79BJRWxhUMR9D9kts --- issues/PLAN37_clone-failure-visibility.md | 26 ++++++++++++++++------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/issues/PLAN37_clone-failure-visibility.md b/issues/PLAN37_clone-failure-visibility.md index cba264a..476f986 100644 --- a/issues/PLAN37_clone-failure-visibility.md +++ b/issues/PLAN37_clone-failure-visibility.md @@ -42,22 +42,32 @@ ## 受け入れ条件 +実機検証は `nyle-dx` プロジェクトに権限のないリポジトリ (`volareinc/no-such-repo-xyz123`) を +一時的に足し、`devbase base` / プロジェクトイメージを再ビルドしたうえで `devbase up` を実行した。 + - [x] AC1: `project.yml` に 2 件書き、片方が clone できない構成で `devbase up` すると、標準出力に 「`/work` に無いリポジトリ」の一覧(dir と clone URL)と、詳細の確認先が出る。 - 検証: 稼働中の実コンテナ `nyle-dx-dev-1` に対して `_report_missing_repos` を実行(docker のモックなし)。 - `no-such-repo-xyz123` が clone URL 付きで警告に出た。 + + ``` + [5/6] Waiting for containers to be ready... + All containers ready + Warning: Repositories missing in /work of dev-1 (clone may have failed): + Warning: - no-such-repo-xyz123 (https://github.com/volareinc/no-such-repo-xyz123.git) + Warning: Details: devbase project logs nyle-dx | grep Warning + === Deploy completed successfully === + ``` - [x] AC2: AC1 の状況でも `devbase up` の終了コードは 0 で、成功したリポジトリでは通常どおり作業できる。 - 検証: 報告は `logger.warning` のみで戻り値を持たず、例外も投げない(`tests/commands/test_up_missing_repos.py`)。 + 検証: 上記の実行で `exit=0`。`/work/nyle-dx` と `/work/ideabase` は通常どおり存在する。 - [x] AC3: 全リポジトリが揃っているときは、`up` の出力は従来と変わらない。 - 検証: 実 `nyle-dx` の `project.yml`(nyle-dx + ideabase、どちらも `/work` に有る)で出力なし。 + 検証: 一時エントリを外して再実行 → `Repositories missing` の出力は 0 件、`exit=0`。 - [x] AC4: clone に失敗したリポジトリは `*.code-workspace` の `folders` に含まれない。 - 検証: 実イメージ内の bash で新 `devbase_write_workspace` を実行し、`no-such-repo-xyz123` だけが - 落ちた JSON を確認。 + 検証: 実機の `/work/nyle-dx.code-workspace` は `nyle-dx` と `ideabase` の 2 件のみ。 - [x] AC5: 落としたフォルダは entrypoint のログに warning として残る。 - 検証: 同上(`Warning: Skipping workspace folder (not cloned): no-such-repo-xyz123`)。 + 検証: `docker logs nyle-dx-dev-1` に + `Warning: Skipping workspace folder (not cloned): no-such-repo-xyz123`。 - [x] AC6: 旧イメージ(新 wire format を知らない entrypoint)に新しいホストから `up` しても、workspace は 従来どおり全フォルダ入りで書き出される。 - 検証: イメージに焼かれている**実物の** `/entrypoint.sh` に新旧両方の環境変数を渡し、 + 検証: 再ビルド前のイメージに焼かれていた**実物の** `/entrypoint.sh` へ新旧両方の環境変数を渡し、 `DEVBASE_WORKSPACE_B64` 経由で 3 フォルダすべてが書き出されることを確認。 - [x] AC7: `pytest` が通る(1445 passed)。