From a0ad0192f568cf2429870b68a4d7afd0b2f89020 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 23 Aug 2026 00:20:50 +0900 Subject: [PATCH 1/8] =?UTF-8?q?docs:=20PLAN32=20=E3=82=92=E5=BE=8C?= =?UTF-8?q?=E6=96=B9=E4=BA=92=E6=8F=9B=E3=81=AA=E3=81=97=E3=83=BB=E8=A4=87?= =?UTF-8?q?=E6=95=B0=E3=83=AA=E3=83=9D=E3=82=B8=E3=83=88=E3=83=AA=E6=A7=8B?= =?UTF-8?q?=E6=88=90=E3=81=B8=E5=85=A8=E9=9D=A2=E6=94=B9=E8=A8=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issue i32 の「後方互換性配慮必要なし / 把握している plugin リポジトリを全て 新方式へ」に合わせ、旧 plan の後方互換前提を廃して project.yml を唯一の正と する設計に改めた。pilot は trygroup 2 repo と uttaro 3 repo (host 混在)。 Co-Authored-By: Claude Opus 5 (1M context) --- issues/PLAN32_multi-repo-project.md | 416 ++++++++++++++++++---------- 1 file changed, 268 insertions(+), 148 deletions(-) diff --git a/issues/PLAN32_multi-repo-project.md b/issues/PLAN32_multi-repo-project.md index 32d5581..8f4cbd4 100644 --- a/issues/PLAN32_multi-repo-project.md +++ b/issues/PLAN32_multi-repo-project.md @@ -1,180 +1,300 @@ # PLAN32: 1 project = 1 container = 複数リポジトリ構成への変更 -> 元 issue: `issues/i32.md` -> 種別: 構成変更 (multi-PR) / base branch: `main` / release branch: `release/PLAN32` +## 関連リンク + +- 元 issue: `issues/i32.md` +- 参考: `docs/plugin-dev/repo-backed-projects.md` (pre-up populate パターン。今回の対象外) +- 参考: `docs/user/environment-variables.md`, `docs/plugin-dev/quickstart.md` + +## モード + +`architecture` — プロジェクト設定の公開インタフェース (`projects//env` の `GIT_USER`/`GIT_REPO`) を +**後方互換なしで** `project.yml` へ置き換え、host CLI・entrypoint・editor・plugin リポジトリ 3 本 (136 project) +の複数モジュールにまたがるため。 + +## 目的と非目的 + +達成したい状態: + +- 1 つの dev コンテナで**複数リポジトリ**を `/work` 配下へ clone し、横断作業できる。 +- リポジトリ指定は配列を素直に表現できる **YAML (`projects//project.yml`)** で行う。 +- `CONTAINER_SCALE` / `WORK_DIR` / `DEVBASE_OPEN_EDITOR` といった **devbase 自身の設定**も YAML 側へ集約し、 + `env` は「コンテナへ渡す環境変数」だけを持つ役割に純化する。 +- VS Code は複数リポジトリを 1 ウィンドウで開く **multi-root workspace** で開く。 + +やらないこと: + +- **後方互換の維持はしない** (issue 明記)。旧 `GIT_USER`/`GIT_REPO` 経路は削除し、 + 移行漏れは黙って動かすのではなく**明示的なエラー**にする。 +- `pre-up` populate パターン (`docs/plugin-dev/repo-backed-projects.md`) の再設計。今回は触らない。 +- 複数リポジトリ間の依存解決・同時 push などのワークフロー支援。clone と workspace 生成までが範囲。 +- `volareinc/nyle-dx` の取り込み (ユーザー判断により後回し)。 + +## 前提 + +- 前提 1: 移行対象は把握済み plugin リポジトリ 3 本。`repos/` 配下で `projects/*/env` は **136 件** + (`volareinc/devbase-ext` 122 / `takemi-ohama/devbase-ext` 8 / `devbasex/devbase-samples` 6)。 + キー分布は `WORK_DIR`/`GIT_USER`/`GIT_REPO`/`DEVBASE_OPEN_EDITOR`/`CONTAINER_SCALE` が全 136 件、 + `ENABLE_SSH` 2 件、`GIT_HOST` 1 件、`AWS_CONFIG_BASE64` 1 件 → 機械変換で移行できる。 +- 前提 2: entrypoint は base image 由来の 1 本のみ (`containers/base/entrypoint.sh`)。lfm も + `COPY --from=devbase-base` で同一ファイルを使う。変更は base 再ビルドで全イメージへ届く。 +- 前提 3: `projects//compose.yml` は `env_file: - env` を持ち、`env` が実在しないと compose が落ちる + (`_drop_missing_env_files` は機密由来の参照しか落とさない)。→ 移行後も `env` ファイルは残す。 +- 前提 4: 題材 (pilot) は `takemi-ohama/devbase-ext` の 2 プロジェクト。 + - `project-trygroup-prd` ← `project-trygroup-prd` + `project-trygroup-prd-customer` (同一 owner `KK-Generation` / 同一イメージ `containers/trygroup`) + - `uttarov2` ← `uttarov2` (**gitlab.com** / `uttaro_dev`) + `uttarov2-doc` + `uttarov2migration` (**github.com** / `uttaro-dev2`) + - 後者は **repo ごとに host が違う**ため、per-repo `host` の検証題材になる。統合後のイメージは `containers/php85` に寄せる。 + +## 受け入れ条件 + +- [ ] AC1: `project.yml` に 2 件以上の repo を書いたプロジェクトで `devbase up` すると、コンテナ内 `/work/` に + **全 repo が clone** され、primary repo の dir に `cd` した状態でログインできる。 + 検証: `devbase build --no-cache` → `up` → `devbase login` で `pwd` と `ls /work`。 +- [ ] AC2: repo ごとに `host` を変えられる (github.com と gitlab.com の混在)。 + 検証: pilot `uttarov2` (gitlab 1 + github 2) の clone 結果。 +- [ ] AC3: `branch` 指定があれば clone 後にそのブランチがチェックアウトされる。検証: コンテナ内 `git -C rev-parse --abbrev-ref HEAD`。 +- [ ] AC4: repo が 2 件以上のとき、`devbase up --open` は multi-root workspace (`/work/.code-workspace`) を開き、 + 全 repo フォルダが VS Code のエクスプローラに並ぶ。1 件のときは従来どおり `/work/` フォルダを開く。 + 検証: 生成された `.code-workspace` の内容 + 実機の VS Code。 +- [ ] AC5: `scale` / `open_editor` を `project.yml` から読む。`devbase scale N` は `project.yml` を書き換える。 + 検証: 単体テスト + `devbase scale 2` 実行後の `project.yml` diff。 +- [ ] AC6: `project.yml` が無い / スキーマ不正のプロジェクトは、`up` が**明示エラー**で停止し、移行方法を案内する + (旧 `GIT_USER`/`GIT_REPO` へ暗黙フォールバックしない)。検証: 単体テスト + 未移行プロジェクトでの `up`。 +- [ ] AC7: 1 repo の clone に失敗しても他 repo の clone は継続する (fail-soft)。検証: 存在しない repo を含む `project.yml` で `up`。 +- [ ] AC8: 把握済み plugin リポジトリ 3 本の **全 136 project** が `project.yml` を持ち、`env` から + `GIT_*`/`WORK_DIR`/`CONTAINER_SCALE`/`DEVBASE_OPEN_EDITOR` が除かれている。検証: 変換コマンドの `--dry-run` 出力と PR diff。 +- [ ] AC9: pilot 2 プロジェクトが統合後の構成 (2 repo / 3 repo) で実際に起動し、旧 5 プロジェクトのうち統合された + 3 つ (`project-trygroup-prd-customer` / `uttarov2-doc` / `uttarov2migration`) のディレクトリが削除されている。 +- [ ] AC10: devbase 本体 / plugin repo 各 PR の `/ndf:cross-review` が APPROVE になる (issue の完了条件)。 + +## 代替案と採否 + +| 案 | 内容 | 採否 | 理由 | +| --- | --- | --- | --- | +| 設定ファイル名 `project.yml` (repos + devbase 設定を集約) | `repos` に加え `scale` / `open_editor` / `work_dir` を持つ | **採用** | issue の「`CONTAINER_SCALE` は yaml が相応しい」に沿う。設定の置き場所が 1 つに決まり「どっちに書くか」の迷いが消える | +| 設定ファイル名 `repos.yml` (repo 定義のみ) | scale 等は `env` に残す | 不採用 | devbase 設定とコンテナ環境変数が `env` に同居したままで、issue の振り分け要求を半分しか満たさない | +| repo リストの transport: **base64 TSV** を `DEVBASE_REPOS` で渡す | `url\tdir\tbranch\tinit` の行を base64 化 | **採用** | entrypoint 側が `base64 -d` + `while read` だけで解釈でき、jq/python への依存を増やさない (lfm など base 非継承イメージでも安全)。base64 なので compose の `$` 展開・改行事故も起きない | +| transport: base64 JSON + jq | entrypoint で `jq` パース | 不採用 | `jq` は base image にはあるが lfm 系の派生イメージで保証できない。TSV で足りる | +| transport: `project.yml` を bind mount して entrypoint で解釈 | コンテナ内で YAML を読む | 不採用 | project ディレクトリは現在マウントしていない。マウント経路の追加とコンテナ内 YAML パーサ依存の 2 つを同時に増やす | +| workspace ファイル: **host で JSON を組み立て base64 で渡し entrypoint が書き出す** | `DEVBASE_WORKSPACE_B64` | **採用** | 生成ロジックを Python 側 (テスト可能) に置ける。entrypoint は `base64 -d > file` の 1 行 | +| workspace ファイル: entrypoint で shell 組み立て | printf で JSON を書く | 不採用 | テストできない場所にエスケープ処理を置くことになる | +| 移行: 変換コマンドを実装して機械適用 | `devbase project migrate-config` | **採用** | 136 件を手で書くのは誤りが混じる。冪等 + `--dry-run` で diff をレビューできる | +| 移行: 手作業 | — | 不採用 | 件数が多く、レビューでの見落としリスクが高い | + +## ドメイン用語 + +| 用語 | 意味 | +| --- | --- | +| project | `projects//` 1 ディレクトリ = 1 compose プロジェクト = 1 dev コンテナ (群) | +| repo | project が `/work` 配下へ clone する git リポジトリ。今回から**複数** | +| primary repo | ログイン時の `cd` 先、および repo 1 件時にエディタが開くフォルダ。既定は `repos` の先頭 | +| clone プラン | `project.yml` を正規化した内部表現。base64 TSV で `DEVBASE_REPOS` としてコンテナへ渡る | +| plugin repo | project 定義を配布するリポジトリ (`volareinc/devbase-ext` 等)。`projects/*` はここへの symlink | + +## 不変条件 + +- clone プランの `dir` はプロジェクト内で一意 (同じ `/work/` を 2 repo が奪い合わない)。 +- primary repo はちょうど 1 件。 +- `DEVBASE_REPOS` に機密を含めない (URL / dir / branch のみ。認証は既存の git 資格情報機構)。 +- `project.yml` は人間が編集する正であり、`DEVBASE_REPOS` は内部 wire format。両者の変換は Python 側だけが行う。 + +## 互換性 + +| 対象 | 変更 | 互換性の扱い | +| --- | --- | --- | +| `projects//env` の `GIT_USER`/`GIT_REPO`/`GIT_HOST`/`WORK_DIR` | 削除 | **破壊的**。`project.yml` へ移行。旧キーが残っていても無視し、`project.yml` 不在なら `up` はエラー | +| `projects//env` の `CONTAINER_SCALE`/`DEVBASE_OPEN_EDITOR` | `project.yml` の `scale`/`open_editor` へ移動 | **破壊的**。グローバル `.env` の `DEVBASE_OPEN_EDITOR` は既定値として残す | +| `projects//env` (ファイル自体) | 残す | `ENABLE_SSH` 等のコンテナ環境変数用。空でも compose が参照するため削除しない | +| `containers/base/entrypoint.sh` | 単一 clone → 複数 clone | **base image 再ビルドが必要**。`devbase build --no-cache` | +| `devbase scale N` | 書き込み先が `env` → `project.yml` | コマンド名・引数は不変 | +| plugin repo の project 定義 | 全 136 件へ `project.yml` 追加 | devbase 本体と plugin repo の**同時切り替え (flag day)**。移行漏れは AC6 のエラーで即座に分かる | + +## スキーマ (`projects//project.yml`) -## 1. 背景と目的 +```yaml +version: 1 # 必須。スキーマ版 +scale: 1 # 任意 (既定 2)。旧 CONTAINER_SCALE +open_editor: true # 任意 (未指定ならグローバル .env の DEVBASE_OPEN_EDITOR) +work_dir: /work/carmo # 任意。既定は primary repo の /work/ -現在の devbase は **1 プロジェクト = 1 コンテナ = 1 リポジトリ** を原則とする構成になっている。 +defaults: # 任意。repos の各要素へ継承させる既定値 + host: github.com # 既定 github.com + owner: volareinc -- プロジェクトごとの repo 指定は `projects//env` の `GIT_USER` / `GIT_REPO` (単一ペア) で行う。 -- コンテナ起動時、`containers/base/entrypoint.sh` が `https://$GIT_HOST/$GIT_USER/$GIT_REPO.git` を **1 本だけ** `/work` に clone し、`cd` する。 -- VS Code は `WORK_DIR=/work/$GIT_REPO` (単一 repo) を開く。 +repos: + - repo: carmo # 必須 + primary: true # 任意。未指定なら先頭要素が primary + - repo: carmo-batch # host/owner は defaults を継承 + - repo: uttarov2 + host: gitlab.com # 個別上書き (pilot uttarov2 の実例) + owner: uttaro_dev + dir: system # 任意。/work 配下の clone 先名 (既定 repo 名) + branch: develop # 任意。clone 後に checkout + init: false # 任意 (既定 true)。clone 後の ./init.sh 実行有無 +``` -これを **1 プロジェクト = 1 コンテナ = 複数リポジトリ** に拡張したい。あわせて、repo 指定を env の文字列変数で表現するのは配列表現力に限界があるため、**YAML (`projects//repos.yml`) を新たな正とする**。 +正規化とバリデーション: -### 解決したい課題 -1. 1 つの開発コンテナで複数 repo (例: `carmo` 本体 + `carmo-batch` + `carmo-cdk`) を同時にチェックアウトして横断作業したい。 -2. repo ごとの host / owner / branch / clone 先ディレクトリ / init 実行有無 を宣言的に、増減しやすい形で管理したい。 -3. 既存の単一 repo プロジェクト (env `GIT_USER`/`GIT_REPO` ベース) を壊さず移行できること。 +- `owner` / `repo` 必須。`host` 既定 `github.com`、`dir` 既定 `repo`、`init` 既定 `true`。 +- `dir` 重複はエラー。`primary: true` が 2 件以上はエラー。`repos` が空はエラー。 +- 未知キーはエラー (typo を黙って無視しない)。 -## 2. 現状アーキテクチャ (調査結果) +wire format (`DEVBASE_REPOS`, base64 TSV / 1 行 1 repo, タブ区切り): -| レイヤ | ファイル | 役割 | 単一 repo 前提の箇所 | -|---|---|---|---| -| プロジェクト設定 | `projects//env` | `GIT_USER`/`GIT_REPO`/`WORK_DIR` を定義 | 単一ペアのみ | -| プロジェクト compose | `projects//compose.yml` | `env_file: [root .env, env, .env]` で dev サービスへ注入 | — | -| 起動フロー (host) | `lib/devbase/commands/container.py` | `_load_project_env` (env 解析+`$VAR`展開), `_run_pre_up_hook` (`./pre-up`), `generate_scaled_compose` | env は単一 repo キーのみ想定 | -| clone (container) | `containers/base/entrypoint.sh:265-289` | `GIT_USER`+`GIT_REPO` を 1 本 clone → `init.sh` → `cd` | **中核**: 単一 clone/cd | -| エディタ起動 | `lib/devbase/editor/opener.py:resolve_workdir` | `WORK_DIR` or `/work/$GIT_REPO` を開く | 単一フォルダのみ | -| scale 生成 | `lib/devbase/volume/compose.py` | dev を N 台へ複製、`/work` を named volume 化 | repo 数と直交 (影響小) | +``` +https://github.com/volareinc/carmo.gitcarmomain1 +https://gitlab.com/uttaro_dev/uttarov2.gitsystem0 +``` -観測: 認証情報は既に **base64 env blob** (`GCP_CREDENTIALS_BASE64__*` 等) としてコンテナへ渡す実装パターンが確立している。repo リストの transport にも同じ手法が使える。 +列: `url`, `dir`, `branch` (空可), `init` (`1`/`0`)。primary は別変数 `DEVBASE_PRIMARY_DIR` で渡す +(TSV の列を増やさず、entrypoint の `cd` 先判定を単純に保つ)。 + +## 修正対象 -## 3. 設計方針 +devbase 本体: -### 3.1 YAML スキーマ (新規 `projects//repos.yml`) +- 新規 `lib/devbase/project/__init__.py`, `lib/devbase/project/config.py` (ローダ・正規化・検証・wire format) +- 新規 `lib/devbase/project/migrate.py` (env → project.yml 変換) +- `lib/devbase/utils/config.py` (`get_container_scale` の取得元) +- `lib/devbase/commands/container.py` (`cmd_up` 配線 / `cmd_scale` の書き込み先 / `_maybe_open_editor`) +- `lib/devbase/volume/compose.py` (dev サービスへ `DEVBASE_REPOS` 等を注入) +- `lib/devbase/editor/opener.py` (`resolve_workdir` / `resolve_workspace` の入力を project 設定へ) +- `lib/devbase/commands/project.py` + `lib/devbase/cli.py` (`project migrate-config` サブコマンド) +- `containers/base/entrypoint.sh` (複数 clone + workspace 書き出し) +- `docs/user/environment-variables.md`, `docs/user/cli-reference/02-project.md`, `docs/user/container-operations.md`, + `docs/plugin-dev/quickstart.md`, `docs/plugin-dev/compose-yml-guidelines.md`, `docs/developer/architecture.md`, `CHANGELOG.md` +- `tests/project/`, `tests/editor/test_opener.py`, `tests/volume/`, `tests/cli/` -```yaml -# 任意: 各 repo のデフォルト値 (DRY 用) -defaults: - host: github.com - owner: volareinc +plugin リポジトリ (別 PR): -repos: - - repo: carmo # 必須。clone 先 dir 名 (default: repo 名) - primary: true # 任意: cd 先 & エディタ既定フォルダ (未指定なら先頭要素) - branch: main # 任意: clone 後に checkout - - repo: carmo-batch # host/owner は defaults を継承 - - repo: carmo-cdk - owner: volareinc # defaults を個別上書き可 - dir: cdk # 任意: /work 配下の clone 先 dir 名を明示指定 - init: false # 任意: clone 後の init.sh 実行有無 (default: true) -``` +- `takemi-ohama/devbase-ext`: pilot 統合 2 件 + 残り project の機械移行 (8 project) +- `volareinc/devbase-ext`: 122 project の機械移行 +- `devbasex/devbase-samples`: 6 project の機械移行 -- **正規化ルール**: `host` default `github.com`、`dir` default = `repo`、`init` default `true`、`primary` 未指定なら先頭 repo。 -- **バリデーション**: `owner`/`repo` 必須、`dir` 重複禁止、`primary` は最大 1 件。 +## タスク分解 -### 3.2 config → container の transport (推奨: host 正規化 → env blob) +各 Task = 1 PR。base branch は `release/PLAN32` (devbase 本体)。 -**YAML を人間向けの正とし、コンテナへは正規化済みの「clone プラン」を base64 env blob で渡す**ハイブリッド構成を採用する。 +### Task 1: `project.yml` ローダと wire format -``` -[人間] repos.yml (YAML, 表現力) - │ devbase up (host / Python) - ▼ -[PR1 loader] parse + validate + 正規化 - │ → clone プラン (JSON) を base64 化 - ▼ -DEVBASE_REPOS 環境変数 (compose 経由でコンテナへ) - │ devbase up → docker compose - ▼ -[PR2 entrypoint] DEVBASE_REPOS を decode → repo ごとに clone/checkout/init → primary へ cd -``` +- **対象ファイル:** `lib/devbase/project/config.py`, `tests/project/test_config.py` +- **変更内容:** `ProjectConfig` / `RepoSpec` dataclass、YAML 読み込み・`defaults` 継承・正規化・検証、 + `encode_repo_plan()` / `decode_repo_plan()` (base64 TSV)。`project.yml` 不在・不正時は移行手順を含む + `ConfigError` を送出。この PR では**呼び出し元を差し替えない** (挙動変更なし)。 +- **満たす受け入れ条件:** AC6 の一部 (エラー文言)、AC2/AC3 のデータ表現 +- **進め方:** テスト駆動。正常系 (defaults 継承 / dir 明示 / host 混在 / primary 指定) と + 異常系 (owner 欠落 / dir 重複 / primary 複数 / 未知キー / repos 空 / ファイル不在) を先に書く。 -採用理由: -- パース/バリデーションを **テスト可能な Python** 側に集約でき、entrypoint (bash) を単純に保てる。 -- コンテナ内に YAML パーサ (yq / pyyaml) を新規依存として持ち込まずに済む (既存の base64 env パターンと一致)。 -- env は内部 wire format にすぎず、**人間が触る正は YAML** という issue の要求を満たす。 +### Task 2: host 側配線 (`up` / `scale` / editor) -> 代替案 (不採用): `repos.yml` をコンテナへ bind mount し entrypoint 内で `python -c` パース。/work は named volume でありプロジェクト dir は未マウントのため mount 経路の追加が必要で、transport が複雑化する。将来 in-container で再 clone したいニーズが出た場合に再検討する。 +- **対象ファイル:** `lib/devbase/commands/container.py`, `lib/devbase/volume/compose.py`, + `lib/devbase/utils/config.py`, `lib/devbase/editor/opener.py`, `tests/volume/`, `tests/editor/`, `tests/commands/` +- **変更内容:** `cmd_up` で `project.yml` を読み、生成 compose の dev サービスへ `DEVBASE_REPOS` / + `DEVBASE_PRIMARY_DIR` / `DEVBASE_WORKSPACE_B64` を注入。`get_container_scale` は `project.yml` の `scale` を、 + `cmd_scale` は `project.yml` を書き換える。`resolve_workdir` は primary repo、repo 2 件以上なら + `DEVBASE_WORKSPACE` に `/work/.code-workspace` を立てる。旧 `GIT_*`/`WORK_DIR` 参照は削除。 +- **満たす受け入れ条件:** AC4 (host 側)、AC5、AC6 +- **進め方:** テスト駆動。生成 compose に期待の env が載ること / `scale` 書き換えの冪等性 / 未移行時のエラーを先に書く。 -### 3.3 後方互換 +### Task 3: entrypoint の複数 clone と workspace 書き出し -- `repos.yml` が **無い**プロジェクトは、従来どおり env の `GIT_USER`/`GIT_REPO` から **単一要素の clone プラン**を loader が合成する。既存 40+ プロジェクトは無変更で動作する。 -- `repos.yml` が **有る**場合は env の `GIT_USER`/`GIT_REPO` を無視 (YAML 優先)。両方あるときは warning を出す。 -- entrypoint は `DEVBASE_REPOS` があればそれを、無ければ従来の `GIT_USER`/`GIT_REPO` 分岐 (現行コード) をそのまま使う二段構え。→ entrypoint 単体でも後方互換。 +- **対象ファイル:** `containers/base/entrypoint.sh`, `tests/containers/test_entrypoint_repos.py` (bash を直接実行する形) +- **変更内容:** `DEVBASE_REPOS` を `base64 -d` して 1 行ずつ clone → `branch` があれば checkout → + `init=1` なら `./init.sh` → 最後に `DEVBASE_PRIMARY_DIR` へ `cd`。clone 失敗は warning で継続。 + `DEVBASE_WORKSPACE_B64` があれば `DEVBASE_WORKSPACE` のパスへ書き出す。旧 `GIT_USER`/`GIT_REPO` 分岐は削除。 +- **満たす受け入れ条件:** AC1、AC2、AC3、AC7、AC4 (書き出し側) +- **進め方:** shell 関数を抽出し、ホスト側 pytest から `bash -c` で呼ぶ形で先にテストを書く + (clone は `file://` のローカル bare repo を使う)。**base image 再ビルド必須**を PR body に明記 + ([[entrypoint-change-needs-rebuild]])。 -### 3.4 エディタ (複数 repo のワークスペース) +### Task 4: 移行コマンド `devbase project migrate-config` -- `primary` repo を `resolve_workdir` の既定フォルダにする (単一 repo 時と同じ挙動)。 -- 複数 repo 時は `.code-workspace` (multi-root) をコンテナ内 `/work` に生成し、全 repo フォルダを 1 ウィンドウで開けるようにする。`DEVBASE_WORKSPACE` (既存機構, `resolve_workspace`) 経由で開く。 +- **対象ファイル:** `lib/devbase/project/migrate.py`, `lib/devbase/commands/project.py`, `lib/devbase/cli.py`, `tests/project/test_migrate.py` +- **変更内容:** `projects/*/env` (または指定ディレクトリ配下) を走査し、`GIT_USER`/`GIT_REPO`/`GIT_HOST`/ + `WORK_DIR`/`CONTAINER_SCALE`/`DEVBASE_OPEN_EDITOR` から `project.yml` を生成、`env` からは該当キーを除去。 + `--dry-run` で diff 表示、冪等 (再実行しても差分なし)。symlink 先 (plugin repo の実体) を書き換えることを明示。 +- **満たす受け入れ条件:** AC8 +- **進め方:** テスト駆動。実 env のパターン (GIT_HOST 有無 / ENABLE_SSH 残留 / 既に移行済み) を fixture 化。 -## 4. PR 分割計画 +### Task 5: ドキュメントと CHANGELOG -| PR # | branch 名 | 概要 | 依存 | 並行可否 | -|---|---|---|---|---| -| 1 | `feature/PLAN32-config-loader` | `repos.yml` スキーマ定義 + Python loader (`lib/devbase/repos/config.py`): parse・validate・正規化・env 合成フォールバック + 単体テスト。**挙動変更なしの純ライブラリ** | なし | ○ | -| 2 | `feature/PLAN32-up-transport` | `devbase up` で loader を呼び `DEVBASE_REPOS` (base64 clone プラン) をコンテナ環境へ注入。`container.py`/compose 生成への配線 | PR1 | × (PR1 の loader API 確定後) | -| 3 | `feature/PLAN32-entrypoint` | `entrypoint.sh` を複数 repo clone ループへ拡張 (`DEVBASE_REPOS` decode → clone/checkout/init → primary cd)。`GIT_USER`/`GIT_REPO` 後方互換分岐を保持。**要 base image 再ビルド** | PR1 (clone プラン形式の契約のみ) | ○ (PR2 と mock 契約で並行可) | -| 4 | `feature/PLAN32-editor` | 複数 repo の `.code-workspace` 生成 + `resolve_workdir`/opener の primary 対応 + 単体テスト | PR1 | ○ (mock で先行可) | -| 5 | `feature/PLAN32-migrate-docs` | env→`repos.yml` 変換ヘルパ + README/docs 更新 + サンプルプロジェクト (`repos.yml` 例) + CHANGELOG | PR1〜4 | × (最後に統合) | +- **対象ファイル:** `docs/` 各所, `CHANGELOG.md`, `issues/PLAN32_multi-repo-project.md` +- **変更内容:** `project.yml` スキーマ、複数 repo 構成の手順、移行コマンドの使い方、破壊的変更の告知。 +- **満たす受け入れ条件:** AC8 の周辺 (手順の再現性) +- **進め方:** テスト駆動の対象外 (文書)。 + +### Task 6: plugin repo 移行 — `takemi-ohama/devbase-ext` (pilot 含む) + +- **対象ファイル:** `personal/projects/*`, `bplus/projects/*` +- **変更内容:** Task 4 のコマンドで 8 project を移行。加えて pilot 統合: + `project-trygroup-prd` へ customer repo を追加し `project-trygroup-prd-customer/` を削除。 + `uttarov2` へ doc/migration repo を追加し `uttarov2-doc/` `uttarov2migration/` を削除 (イメージは php85 に統一)。 +- **満たす受け入れ条件:** AC1〜AC4, AC9 +- **進め方:** 変換 → 手で pilot を統合 → 実機で `build --no-cache` + `up` 検証。 + +### Task 7: plugin repo 移行 — `volareinc/devbase-ext` (122) / `devbasex/devbase-samples` (6) + +- **対象ファイル:** 各 repo の `*/projects/*/env` と新規 `project.yml` +- **変更内容:** Task 4 のコマンドによる機械変換のみ (統合はしない)。 +- **満たす受け入れ条件:** AC8 +- **進め方:** `--dry-run` の全件 diff をレビュー → 適用 → 代表 project で `up` 確認。 + +## PR 分割計画 + +devbase 本体 (`volareinc/devbase` 相当。ここでは `devbase` repo): + +| PR # | branch 名 | 対応 Task | 概要 | 依存 | 並行可否 | +|---|---|---|---|---|---| +| 1 | `feature/PLAN32-config-loader` | Task 1 | `project.yml` ローダ・正規化・検証・wire format (純ライブラリ、挙動変更なし) | なし | ○ | +| 2 | `feature/PLAN32-host-wiring` | Task 2 | `up`/`scale`/editor の配線を `project.yml` へ切替、`DEVBASE_REPOS` 注入 | PR1 | × (PR1 merge 後) | +| 3 | `feature/PLAN32-entrypoint` | Task 3 | entrypoint 複数 clone + workspace 書き出し (**base image 再ビルド必須**) | PR1 (wire format の契約のみ) | ○ (PR2 と並行可) | +| 4 | `feature/PLAN32-migrate-cmd` | Task 4 | `devbase project migrate-config` (env → project.yml 変換) | PR1 | ○ | +| 5 | `feature/PLAN32-docs` | Task 5 | docs / CHANGELOG | PR1〜4 | × (最後に統合) | ``` release branch: release/PLAN32 base branch: main ``` -依存グラフ: PR1 が全ての土台。PR2/PR3/PR4 は PR1 の **clone プラン JSON 形式の契約**さえ固定すれば並行開発可 (PR3 は entrypoint 側、PR2 は host 側で同じ契約の両端)。PR5 は結合・ドキュメントで最後。 - -## 5. PR ごとの実装詳細 - -### PR1: config loader (foundation) -- 新規 `lib/devbase/repos/__init__.py`, `lib/devbase/repos/config.py`。 -- API 案: - - `load_repo_plan(project_dir: Path, environ: Mapping) -> list[RepoSpec]` - - `repos.yml` があれば YAML を読み、`defaults` 継承 → 正規化 → validate。 - - 無ければ env の `GIT_USER`/`GIT_REPO`/`GIT_HOST` から単一 `RepoSpec` を合成。両方あれば warning。 - - `RepoSpec` = `{host, owner, repo, dir, branch, init, primary}` (dataclass)。 - - `encode_repo_plan(specs) -> str` (JSON→base64) / `decode_repo_plan(str)` は PR2/PR3 の契約テストで共有。 -- clone プラン JSON 契約 (PR2 が生成 / PR3 が消費) を **このPRで確定**し docstring に明記: - ```json - [{"url":"https://github.com/volareinc/carmo.git","dir":"carmo","branch":"main","init":true,"primary":true}, ...] - ``` -- テスト: 正常系 (defaults 継承 / dir 明示 / primary 指定)、異常系 (owner 欠落 / dir 重複 / primary 複数)、env フォールバック、YAML+env 併存 warning。 - -### PR2: up transport (host wiring) -- `container.py:cmd_up` (および必要なら `generate_scaled_compose` 前処理) で `load_repo_plan` → `encode_repo_plan` → `DEVBASE_REPOS` を **生成 compose の dev サービス environment もしくは補助 env_file** へ注入。 - - secret 露出回避のため既存方針 (`environment` を除去し `env_file` 優先) と整合させる。base64 blob を書き出す一時 env ファイル方式が安全。 -- 単一 repo (env フォールバック) でも同じ `DEVBASE_REPOS` を注入し、経路を一本化。 -- テスト: プロジェクト固定で `DEVBASE_REPOS` が期待 JSON を base64 で持つこと。 - -### PR3: entrypoint 複数 clone (container) -- `entrypoint.sh:265-289` を置換: - - `DEVBASE_REPOS` があれば base64 decode → 各要素で `git clone ` → `branch` 指定時 `git -C checkout` → `init: true` なら `(cd && [ -f init.sh ] && ./init.sh)` → `primary` の dir へ最後に `cd`。 - - `DEVBASE_REPOS` 無し時は現行の `GIT_USER`/`GIT_REPO` 単一 clone を維持 (後方互換)。 - - clone 失敗は現行同様 warning で継続 (fail-soft)。 - - decode/iterate は bash + `base64 -d` + 小さな Python one-liner (base image に uv/python 有) で JSON→行変換。新規 apt 依存を増やさない。 -- **base image 再ビルドが必要** ([[entrypoint-change-needs-rebuild]]): 検証は `devbase build --no-cache` 必須。`devbase up` だけでは反映されない点を PR body / テスト手順に明記。 - -### PR4: editor 複数 repo -- `opener.py`: `resolve_workdir` は primary repo を返す。複数 repo 時は `/work/.code-workspace` (全 repo フォルダを含む multi-root JSON) を生成し `DEVBASE_WORKSPACE` を設定 → `resolve_workspace` 経由で開く。 -- 生成タイミング: entrypoint (コンテナ内 `/work` 実体を見て生成) が素直。PR3 の clone 後段に組み込むか、opener 側で attach 時生成するかを PR4 冒頭で確定。 -- テスト: single repo→従来フォルダ、multi repo→workspace パス解決。 - -### PR5: migration + docs -- `env`→`repos.yml` 変換ヘルパ (既存プロジェクトの `GIT_USER`/`GIT_REPO` を読み `repos.yml` を生成、`--dry-run` 付き)。一括移行は任意 (後方互換があるため強制しない)。 -- `README.md` / `docs/` に複数 repo 構成の手順・スキーマ・移行方法を追記。 -- サンプル: `projects/` にマルチ repo の `repos.yml` 例、または `docs/examples/`。 -- `CHANGELOG.md` 追記。 - -## 6. テスト / 検証計画 - -### 単体 (各個別 PR) -- PR1: loader の正常/異常/フォールバック (pytest)。 -- PR2: `DEVBASE_REPOS` 注入内容の検証。 -- PR4: workspace パス解決。 - -### 結合 (release PR / Step 7 相当) -- [ ] `repos.yml` (2〜3 repo) を持つ検証用プロジェクトで `devbase build --no-cache` → `up` → コンテナ内 `/work` に全 repo が clone され、primary に cd していること。 -- [ ] `repos.yml` 内 `branch` 指定が反映されること。 -- [ ] 既存 env-only プロジェクト (`GIT_USER`/`GIT_REPO`) が無変更で従来どおり単一 clone されること (後方互換の回帰確認)。 -- [ ] `DEVBASE_OPEN_EDITOR=1` で複数 repo が multi-root workspace として開くこと。 -- [ ] clone 失敗時 (存在しない repo) に fail-soft で他 repo が継続 clone されること。 - -## 7. リスク / 留意点 - -| リスク | 対策 | -|---|---| -| entrypoint 変更が `up` では反映されず古い挙動が残る | [[entrypoint-change-needs-rebuild]]。PR3/結合テストで `build --no-cache` 必須を明記 | -| base64 blob に repo URL 以上の機密は含めない | URL/dir/branch のみ。認証は既存の git credentials 機構を流用 | -| YAML+env 併存時の優先順位の混乱 | YAML 優先 + warning。docs に明記 | -| 既存 40+ プロジェクトへの回帰 | 後方互換フォールバックを PR1 で担保し、結合テストに env-only 回帰を含める | -| `dir` 衝突 / primary 複数 | PR1 の validate で早期 fail | - -## 8. 次のアクション - -本 plan は **作成フェーズ**の成果物。実装に進む場合は `/ndf:issue-plan-strategy` の実行フェーズ (Step 3〜) に従い: -1. `release/PLAN32` ブランチ + release Draft PR を先行作成。 -2. PR1〜5 の個別 Draft PR を作成 (PR1 を最優先で着手)。 -3. PR1 完了・merge 後に PR2/PR3/PR4 を worktree 並行開発、PR5 を最後に統合。 +plugin リポジトリ (別リポジトリのため release ブランチは使わず単体 PR): + +| repo | branch 名 | 対応 Task | 概要 | 依存 | +|---|---|---|---|---| +| `takemi-ohama/devbase-ext` | `feature/PLAN32-project-yml` | Task 6 | 8 project の移行 + pilot 統合 2 件 | 本体 PR1〜4 | +| `volareinc/devbase-ext` | `feature/PLAN32-project-yml` | Task 7 | 122 project の機械移行 | 本体 PR1〜4 | +| `devbasex/devbase-samples` | `feature/PLAN32-project-yml` | Task 7 | 6 project の機械移行 | 本体 PR1〜4 | + +plugin repo の PR は本体 release PR の merge 直後に merge する (flag day)。 + +## 影響範囲 + +- 全 project の起動経路 (`devbase up` / `list` / TUI)。移行前の project は起動できなくなる (意図した破壊的変更)。 +- base image を使う全コンテナ (再ビルドが必要)。 +- `devbase scale` / `devbase env init` (エディタ設定の置き場所)。 +- ドキュメント全般 (`GIT_USER`/`GIT_REPO` を前提にした記述)。 + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| devbase 本体と plugin repo の切り替えタイミングのずれで起動不能になる | AC6 の明示エラーで原因が即分かるようにする。plugin repo 側 PR は本体 release PR の merge 直後に merge する。移行コマンドは本体 merge 前でも `--dry-run` で確認可能 | +| entrypoint 変更が `up` では反映されない | [[entrypoint-change-needs-rebuild]]。Task 3 / 結合検証で `devbase build --no-cache` を必須手順として PR body に明記 | +| 136 件の機械変換でキーの取りこぼし (ENABLE_SSH 等) | 変換対象キーを allowlist で限定し、それ以外は `env` に残す。`--dry-run` の全件 diff をレビュー | +| pilot 統合でイメージ差 (php / php85) による退行 | 統合先を php85 に統一し、doc 側のツール要件を統合後に実機確認 | +| gitlab.com repo の clone 認証が github と別経路 | pilot `uttarov2` で実機検証 (AC2)。失敗時は fail-soft (AC7) で他 repo は継続 | +| 生成 compose に repo URL が載る | URL は機密ではない。`DEVBASE_REPOS` に機密を入れない不変条件をレビュー観点に含める | + +## 切り戻し手順 + +- devbase 本体: `release/PLAN32` の revert で旧 entrypoint / 旧 `GIT_*` 経路へ戻る。base image の再ビルドが必要。 +- plugin repo: 各 PR の revert で `env` が復元される (`project.yml` は削除)。データ移行は無く、生成物は + コンテナ内 `/work` の clone だけなので、切り戻し後も再 clone で復旧できる。 +- pilot 統合 (project ディレクトリ削除) は revert で復元されるが、統合後に作った `/work` ボリュームは + `devbase down` + ボリューム削除で作り直す。 + +## 完了の定義 + +- [ ] AC1〜AC10 をすべて満たし、条件ごとに検証手段と結果が対応している +- [ ] `uv run pytest` が green +- [ ] devbase 本体 release PR と plugin repo 各 PR の `/ndf:cross-review` が APPROVE +- [ ] `docs/` と `CHANGELOG.md` が新方式のみを説明している (旧方式の記述が残っていない) From 9f8c810f1e059e1a8c569fd8f36ee6fbfa1ff04a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Sun, 23 Aug 2026 01:00:12 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20PLAN32-entrypoint=20entrypoint=20?= =?UTF-8?q?=E3=81=AE=E8=A4=87=E6=95=B0=E3=83=AA=E3=83=9D=E3=82=B8=E3=83=88?= =?UTF-8?q?=E3=83=AA=20clone=20=E3=81=A8=20workspace=20=E7=94=9F=E6=88=90?= =?UTF-8?q?=20(#106)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: PLAN32-entrypoint Draft PR 作成 * feat(entrypoint): 複数リポジトリの clone と workspace 書き出しに対応 PLAN32 Task 3。GIT_USER/GIT_REPO による単一 clone を廃し、ホストが渡す clone プラン (DEVBASE_REPOS = base64 TSV) を 1 行ずつ処理して /work 配下へ 複数リポジトリを clone する。branch 指定の checkout、init.sh の実行可否、 primary ディレクトリへの cd、複数 repo 用 workspace ファイルの書き出しを それぞれ関数に分けた。 clone / checkout / init.sh の失敗は warning に留めて次のリポジトリへ進む。 1 つ落ちただけで起動できないと、他リポジトリでの作業まで止まるため。 TSV の分解に `IFS=$'\t' read` は使っていない。タブは IFS の空白扱いで連続 する区切りが 1 つに畳まれ、branch 未指定の行で init がずれるため、パラメータ 展開で 1 フィールドずつ切り出している。 関数定義だけを source できるようにし (DEVBASE_ENTRYPOINT_LIB_ONLY)、 ローカルの bare リポジトリを clone 元にした単体テストを追加した。 Co-Authored-By: Claude Opus 5 (1M context) * fix(entrypoint): checkout は clone 直後のみ + 失敗時は当該 repo を打ち切る - checkout 失敗時に continue し、意図しない branch で init.sh が走らないようにする - 既存 clone には checkout しない。コンテナ再起動のたびにユーザの作業ブランチから 設定 branch へ引き戻される問題を回避する - テストの PATH ハードコードをやめ、実行環境を引き継ぐ (DEVBASE_*/GIT_* のみ除去) Co-Authored-By: Claude Opus 5 (1M context) * fix(entrypoint): clone プランの区切りを US (0x1f) にして空フィールドを保てるようにする タブ区切りは IFS の空白扱いで連続する区切りが 1 つに畳まれるため、branch 未指定の行で init の値がずれる。非空白の US (0x1f) にすると bash の自然な 読み方 (IFS='' read -r ...) がそのまま正しく動くので、パラメータ展開に よる手動分解をやめて read に戻した。あわせて列数・init 値の検証を入れ、 壊れた行は警告に留めて次の行へ進む。 符号化側 (lib/devbase/project/config.py) と同じ契約に揃えている。 Co-Authored-By: Claude Opus 5 (1M context) * docs(PLAN32): clone プランの wire format 契約を US (0x1f) 区切りへ統一 plan と PR 本文が base64 TSV を契約としていた一方、entrypoint と符号化側 (encode_repo_plan) は US (0x1f) 区切りへ移行済みで、契約と実装が食い違っていた。 タブは IFS の空白として扱われ連続する区切りが 1 つに畳まれるため、branch 未指定 (空フィールド) の行で init の値がずれる。非空白の US なら IFS=$'\x1f' read が そのまま 4 列として読める。実装は既に正しいので、契約側 (plan) を US へ揃える。 - issues/PLAN32_multi-repo-project.md: スキーマ節の wire format を US 区切り (行区切り LF / 末尾 LF あり) の記述へ更新。代替案表にタブ区切りを不採用案として 理由付きで追加 - containers/base/entrypoint.sh: 行区切りと末尾 LF、符号化側の所在をコメントに明記 - tests/containers/test_entrypoint_repos.py: 実物の encode_repo_plan の出力を entrypoint に通す結合テストを追加 (config.py は Task 1 の別 PR なので importorskip で未導入ブランチでは skip) Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- containers/base/entrypoint.sh | 159 +++++++++-- issues/PLAN32_multi-repo-project.md | 25 +- tests/containers/__init__.py | 0 tests/containers/test_entrypoint_repos.py | 331 ++++++++++++++++++++++ 4 files changed, 481 insertions(+), 34 deletions(-) create mode 100644 tests/containers/__init__.py create mode 100644 tests/containers/test_entrypoint_repos.py diff --git a/containers/base/entrypoint.sh b/containers/base/entrypoint.sh index 77db99e..3c2190a 100644 --- a/containers/base/entrypoint.sh +++ b/containers/base/entrypoint.sh @@ -2,6 +2,133 @@ set -e +# =================================================================== +# PLAN32: 複数リポジトリの clone / workspace 生成 +# =================================================================== +# ホスト側 (devbase up) が projects//project.yml を正規化し、clone プランを +# base64 のレコード列 (DEVBASE_REPOS) としてコンテナへ渡す。ここでは 1 行ずつ読んで clone +# するだけなので、コンテナイメージへ YAML/JSON パーサ依存を増やさずに済む。 +# +# DEVBASE_REPOS : base64 の行区切りレコード。1 行 = url / dir / branch / init を +# US (0x1f) 区切りで並べたもの。branch は空可、init は 1/0。 +# 行区切りは LF で末尾にも LF が付く (符号化側の契約: +# lib/devbase/project/config.py の encode_repo_plan) +# DEVBASE_PRIMARY_DIR : 起動後に cd する /work 配下のディレクトリ名 +# DEVBASE_WORKSPACE : 書き出す *.code-workspace の絶対パス (複数 repo 時) +# DEVBASE_WORKSPACE_B64 : その中身 (base64 JSON) +# +# 関数定義だけを読み込みたいテストからは +# `DEVBASE_ENTRYPOINT_LIB_ONLY=1 . entrypoint.sh` で source する。 + +# clone プランを復号して 1 行 1 repo で出力する (未設定なら何も出さない)。 +devbase_repo_plan_lines() { + [ -n "${DEVBASE_REPOS:-}" ] || return 0 + printf '%s' "$DEVBASE_REPOS" | base64 -d +} + +# clone プランの各リポジトリを / へ clone する。 +# +# 個々の失敗 (clone / checkout / init.sh) は warning に留めて次の repo へ進む。 +# 1 つ落ちただけでコンテナが起動しないと、他リポジトリでの作業まで止まるため。 +devbase_clone_repos() { + local work_root="${1:-/work}" + local plan url dir branch init target cloned + + if ! plan="$(devbase_repo_plan_lines 2>/dev/null)"; then + echo "Warning: Failed to decode DEVBASE_REPOS (skipping repository setup)" + return 0 + fi + if [ -z "$plan" ]; then + echo "No repositories configured (DEVBASE_REPOS is empty)" + return 0 + fi + + mkdir -p "$work_root" + local extra index=0 + # フィールド区切りは US (0x1f)。タブだと IFS の空白扱いで連続する区切りが 1 つに + # 畳まれ、branch 未指定 (空フィールド) の行で init の値がずれる。 + while IFS=$'\x1f' read -r url dir branch init extra; do + # 末尾の空行 (符号化側が付ける末尾改行) は読み飛ばす + [ -n "$url$dir$branch$init$extra" ] || continue + index=$((index + 1)) + if [ -z "$url" ] || [ -z "$dir" ] || [ -n "$extra" ] || + { [ "$init" != "1" ] && [ "$init" != "0" ]; }; then + echo "Warning: Ignoring malformed clone plan entry (line $index)" + continue + fi + target="$work_root/$dir" + + cloned=0 + if [ -d "$target/.git" ]; then + echo "Repository already exists: $dir" + else + echo "Cloning repository: $url -> $target" + if ! git clone "$url" "$target"; then + echo "Warning: Failed to clone repository: $url" + continue + fi + cloned=1 + fi + + # checkout は clone 直後だけ。既存 clone に対して毎回実行すると、コンテナ内で + # 作業ブランチへ切り替えたユーザが再起動のたびに引き戻されてしまう。 + # 失敗したら意図しない branch で init.sh を走らせないよう この repo は打ち切る。 + if [ "$cloned" = "1" ] && [ -n "$branch" ]; then + if ! git -C "$target" checkout "$branch"; then + echo "Warning: Failed to checkout branch '$branch' in $dir (skipping)" + continue + fi + fi + + if [ "$init" = "1" ] && [ -f "$target/init.sh" ]; then + echo "Running init.sh in $dir" + (cd "$target" && ./init.sh) || echo "Warning: init.sh failed in $dir" + fi + done < "$dest.tmp" 2>/dev/null; then + mv "$dest.tmp" "$dest" + echo "Workspace file written: $dest" + else + rm -f "$dest.tmp" + echo "Warning: Failed to write workspace file: $dest" + fi +} + +# primary リポジトリのディレクトリへ移動する (ログイン直後の作業場所)。 +devbase_enter_primary_dir() { + local work_root="${1:-/work}" + local target="$work_root/${DEVBASE_PRIMARY_DIR:-}" + + if [ -z "${DEVBASE_PRIMARY_DIR:-}" ]; then + return 0 + fi + if [ -d "$target" ]; then + cd "$target" + echo "Current directory: $(pwd)" + else + echo "Warning: Primary directory does not exist: $target" + fi +} + +# テストは関数定義だけを使う (source 時のみ有効な return で以降を読み飛ばす)。 +if [ -n "${DEVBASE_ENTRYPOINT_LIB_ONLY:-}" ]; then + return 0 2>/dev/null || exit 0 +fi + # Setup authentication credentials from environment variables USERNAME="${USERNAME:-ubuntu}" @@ -261,32 +388,12 @@ done echo "AI agent settings symlinks setup completed" # ======================================== -# Git operations (optional, don't fail if they error) -if [ -n "$GIT_USER" ] && [ -n "$GIT_REPO" ]; then - # Clone repository only if it doesn't exist - GIT_HOST="${GIT_HOST:-github.com}" - if [ ! -d "$GIT_REPO" ]; then - echo "Cloning repository: $GIT_HOST/$GIT_USER/$GIT_REPO" - git clone "https://$GIT_HOST/$GIT_USER/$GIT_REPO.git" || echo "Warning: Failed to clone repository" - else - echo "Repository already exists: $GIT_REPO" - fi - # Run init.sh from cloned repository root if it exists - [ -f "$GIT_REPO/init.sh" ] && (cd "$GIT_REPO" && ./init.sh) || true -fi - -# Move to repository directory if it exists -echo "Current directory before cd: $(pwd)" -if [ -n "$GIT_REPO" ]; then - echo "GIT_REPO=$GIT_REPO" - if [ -d "$GIT_REPO" ]; then - echo "Directory $GIT_REPO exists, changing to it" - cd "$GIT_REPO" - echo "Current directory after cd: $(pwd)" - else - echo "Directory $GIT_REPO does not exist in $(pwd)" - fi -fi +# Repository setup (PLAN32: 1 project = 複数リポジトリ) +# 個々の失敗はコンテナ起動を止めない (関数内で warning 扱い)。 +DEVBASE_WORK_ROOT="${DEVBASE_WORK_ROOT:-/work}" +devbase_clone_repos "$DEVBASE_WORK_ROOT" +devbase_write_workspace +devbase_enter_primary_dir "$DEVBASE_WORK_ROOT" # Signal that entrypoint setup is complete touch /tmp/entrypoint-ready diff --git a/issues/PLAN32_multi-repo-project.md b/issues/PLAN32_multi-repo-project.md index 8f4cbd4..5f04ebf 100644 --- a/issues/PLAN32_multi-repo-project.md +++ b/issues/PLAN32_multi-repo-project.md @@ -73,8 +73,9 @@ | --- | --- | --- | --- | | 設定ファイル名 `project.yml` (repos + devbase 設定を集約) | `repos` に加え `scale` / `open_editor` / `work_dir` を持つ | **採用** | issue の「`CONTAINER_SCALE` は yaml が相応しい」に沿う。設定の置き場所が 1 つに決まり「どっちに書くか」の迷いが消える | | 設定ファイル名 `repos.yml` (repo 定義のみ) | scale 等は `env` に残す | 不採用 | devbase 設定とコンテナ環境変数が `env` に同居したままで、issue の振り分け要求を半分しか満たさない | -| repo リストの transport: **base64 TSV** を `DEVBASE_REPOS` で渡す | `url\tdir\tbranch\tinit` の行を base64 化 | **採用** | entrypoint 側が `base64 -d` + `while read` だけで解釈でき、jq/python への依存を増やさない (lfm など base 非継承イメージでも安全)。base64 なので compose の `$` 展開・改行事故も起きない | -| transport: base64 JSON + jq | entrypoint で `jq` パース | 不採用 | `jq` は base image にはあるが lfm 系の派生イメージで保証できない。TSV で足りる | +| repo リストの transport: **base64 の US 区切りレコード列** を `DEVBASE_REPOS` で渡す | `urldirbranchinit` の行 (`` = 0x1f) を base64 化 | **採用** | entrypoint 側が `base64 -d` + `IFS=$'\x1f' read` だけで解釈でき、jq/python への依存を増やさない (lfm など base 非継承イメージでも安全)。base64 なので compose の `$` 展開・改行事故も起きない | +| transport: base64 JSON + jq | entrypoint で `jq` パース | 不採用 | `jq` は base image にはあるが lfm 系の派生イメージで保証できない。US 区切りの平テキストで足りる | +| transport: 区切り文字にタブ (当初案) | `url\tdir\tbranch\tinit` の行を base64 化 | 不採用 | タブは IFS の**空白扱い**で連続する区切りが 1 つに畳まれる。`branch` 未指定 (空フィールド) の行で `init` の値がずれるため、`IFS=$'\t' read` で素直に 4 列として読めない。非空白の US (0x1f) なら空フィールドがそのまま残る | | transport: `project.yml` を bind mount して entrypoint で解釈 | コンテナ内で YAML を読む | 不採用 | project ディレクトリは現在マウントしていない。マウント経路の追加とコンテナ内 YAML パーサ依存の 2 つを同時に増やす | | workspace ファイル: **host で JSON を組み立て base64 で渡し entrypoint が書き出す** | `DEVBASE_WORKSPACE_B64` | **採用** | 生成ロジックを Python 側 (テスト可能) に置ける。entrypoint は `base64 -d > file` の 1 行 | | workspace ファイル: entrypoint で shell 組み立て | printf で JSON を書く | 不採用 | テストできない場所にエスケープ処理を置くことになる | @@ -88,7 +89,7 @@ | project | `projects//` 1 ディレクトリ = 1 compose プロジェクト = 1 dev コンテナ (群) | | repo | project が `/work` 配下へ clone する git リポジトリ。今回から**複数** | | primary repo | ログイン時の `cd` 先、および repo 1 件時にエディタが開くフォルダ。既定は `repos` の先頭 | -| clone プラン | `project.yml` を正規化した内部表現。base64 TSV で `DEVBASE_REPOS` としてコンテナへ渡る | +| clone プラン | `project.yml` を正規化した内部表現。base64 の US 区切りレコード列として `DEVBASE_REPOS` でコンテナへ渡る | | plugin repo | project 定義を配布するリポジトリ (`volareinc/devbase-ext` 等)。`projects/*` はここへの symlink | ## 不変条件 @@ -139,15 +140,23 @@ repos: - `dir` 重複はエラー。`primary: true` が 2 件以上はエラー。`repos` が空はエラー。 - 未知キーはエラー (typo を黙って無視しない)。 -wire format (`DEVBASE_REPOS`, base64 TSV / 1 行 1 repo, タブ区切り): +wire format (`DEVBASE_REPOS`, base64 / 1 行 1 repo, US (0x1f) 区切り): ``` -https://github.com/volareinc/carmo.gitcarmomain1 -https://gitlab.com/uttaro_dev/uttarov2.gitsystem0 +https://github.com/volareinc/carmo.gitcarmomain1 +https://gitlab.com/uttaro_dev/uttarov2.gitsystem0 ``` +- フィールド区切りは **US = unit separator (0x1f)**。非空白なので `IFS=$'\x1f' read -r url dir branch init` + がそのまま 4 列として読め、`branch` 未指定 (空フィールド) の行でも `init` の値がずれない。 + タブだと IFS の空白扱いで連続する区切りが 1 つに畳まれるため採用しない。 +- 行区切りは LF。**末尾にも LF を付ける**。`while read` は EOF 直前の改行なし行を読み捨てる実装が + あるため、末尾 LF が無いと最後の行 (repo 1 件ならその唯一の行) が丸ごと落ちる。 +- 各フィールドは符号化側で検証済み。空白・制御文字 (US / LF を含む) は通さないので、 + エスケープ規則は持たない。 + 列: `url`, `dir`, `branch` (空可), `init` (`1`/`0`)。primary は別変数 `DEVBASE_PRIMARY_DIR` で渡す -(TSV の列を増やさず、entrypoint の `cd` 先判定を単純に保つ)。 +(列を増やさず、entrypoint の `cd` 先判定を単純に保つ)。 ## 修正対象 @@ -179,7 +188,7 @@ plugin リポジトリ (別 PR): - **対象ファイル:** `lib/devbase/project/config.py`, `tests/project/test_config.py` - **変更内容:** `ProjectConfig` / `RepoSpec` dataclass、YAML 読み込み・`defaults` 継承・正規化・検証、 - `encode_repo_plan()` / `decode_repo_plan()` (base64 TSV)。`project.yml` 不在・不正時は移行手順を含む + `encode_repo_plan()` / `decode_repo_plan()` (base64 / US 区切り)。`project.yml` 不在・不正時は移行手順を含む `ConfigError` を送出。この PR では**呼び出し元を差し替えない** (挙動変更なし)。 - **満たす受け入れ条件:** AC6 の一部 (エラー文言)、AC2/AC3 のデータ表現 - **進め方:** テスト駆動。正常系 (defaults 継承 / dir 明示 / host 混在 / primary 指定) と diff --git a/tests/containers/__init__.py b/tests/containers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/containers/test_entrypoint_repos.py b/tests/containers/test_entrypoint_repos.py new file mode 100644 index 0000000..30a6373 --- /dev/null +++ b/tests/containers/test_entrypoint_repos.py @@ -0,0 +1,331 @@ +"""entrypoint.sh の複数リポジトリ clone と workspace 書き出し (PLAN32) + +``containers/base/entrypoint.sh`` を ``DEVBASE_ENTRYPOINT_LIB_ONLY=1`` で source し、 +関数だけを読み込んで bash から直接呼び出す。clone 元にはローカルの bare リポジトリ +(``file://``) を使うため、ネットワークにも Docker にも依存しない。 +""" + +from __future__ import annotations + +import base64 +import json +import os +import subprocess +from pathlib import Path + +import pytest + +ENTRYPOINT = Path(__file__).resolve().parents[2] / "containers" / "base" / "entrypoint.sh" + + +def encode_plan(rows) -> str: + """host 側が渡す wire format をテスト側で組み立てる。 + + フィールド区切りは US (0x1f)、行区切りは LF で末尾にも LF を付ける + (``lib/devbase/project/config.py`` の ``encode_repo_plan`` と同じ契約)。 + ``rows`` は ``(url, dir, branch, init)`` のタプル列。init は ``"1"`` / ``"0"``。 + """ + text = "".join("\x1f".join(row) + "\n" for row in rows) + return base64.b64encode(text.encode()).decode() + + +def run_entrypoint_fn(script: str, env: dict, cwd: Path) -> subprocess.CompletedProcess: + """entrypoint.sh の関数だけを読み込んで ``script`` を実行する。 + + ``PATH`` を固定すると Homebrew の git しか無い環境で落ちるため、実行環境を + 引き継ぐ。ただし呼び出し側の DEVBASE_* / GIT_* が紛れ込むとテストの前提が + 崩れるので、そこだけ落としてから ``env`` を重ねる。 + """ + base = {k: v for k, v in os.environ.items() + if not k.startswith(("DEVBASE_", "GIT_"))} + full = f'set -e\nDEVBASE_ENTRYPOINT_LIB_ONLY=1 . "{ENTRYPOINT}"\n{script}\n' + return subprocess.run( + ["bash", "-c", full], cwd=cwd, env={**base, **env}, + capture_output=True, text=True, + ) + + +def current_branch(repo: Path) -> str: + return subprocess.run(["git", "-C", str(repo), "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, text=True).stdout.strip() + + +def make_origin(tmp_path: Path, name: str, *, branches=(), files=None) -> str: + """clone 元の bare リポジトリを作り ``file://`` URL を返す。""" + work = tmp_path / "origins" / name + work.mkdir(parents=True) + git = ["git", "-C", str(work)] + subprocess.run(["git", "init", "-q", "-b", "main", str(work)], check=True) + subprocess.run([*git, "config", "user.email", "t@example.com"], check=True) + subprocess.run([*git, "config", "user.name", "t"], check=True) + for rel, content in (files or {"README.md": name}).items(): + path = work / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + if rel.endswith(".sh"): + path.chmod(0o755) + subprocess.run([*git, "add", "-A"], check=True) + subprocess.run([*git, "commit", "-qm", "init"], check=True) + for branch in branches: + subprocess.run([*git, "branch", branch], check=True) + + bare = tmp_path / "origins" / f"{name}.git" + subprocess.run(["git", "clone", "-q", "--bare", str(work), str(bare)], check=True) + return f"file://{bare}" + + +@pytest.fixture +def work(tmp_path: Path) -> Path: + d = tmp_path / "work" + d.mkdir() + return d + + +# --------------------------------------------------------------------------- +# clone +# --------------------------------------------------------------------------- + +def test_clones_every_repository_in_the_plan(tmp_path, work): + plan = encode_plan([ + (make_origin(tmp_path, "app"), "app", "", "0"), + (make_origin(tmp_path, "docs"), "docs", "", "0"), + (make_origin(tmp_path, "infra"), "cdk", "", "0"), + ]) + + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"DEVBASE_REPOS": plan}, tmp_path) + + assert result.returncode == 0, result.stderr + assert (work / "app" / "README.md").read_text() == "app" + assert (work / "docs" / "README.md").read_text() == "docs" + assert (work / "cdk" / "README.md").read_text() == "infra" + + +def test_checks_out_the_requested_branch(tmp_path, work): + plan = encode_plan([ + (make_origin(tmp_path, "app", branches=("develop",)), "app", "develop", "0"), + ]) + + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"DEVBASE_REPOS": plan}, tmp_path) + + assert result.returncode == 0, result.stderr + assert current_branch(work / "app") == "develop" + + +def test_runs_init_script_only_when_enabled(tmp_path, work): + files = {"README.md": "app", "init.sh": "#!/bin/bash\ntouch ./init-was-run\n"} + plan = encode_plan([ + (make_origin(tmp_path, "with-init", files=files), "with-init", "", "1"), + (make_origin(tmp_path, "no-init", files=files), "no-init", "", "0"), + ]) + + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"DEVBASE_REPOS": plan}, tmp_path) + + assert result.returncode == 0, result.stderr + assert (work / "with-init" / "init-was-run").exists() + assert not (work / "no-init" / "init-was-run").exists() + + +def test_a_failing_clone_does_not_stop_the_others(tmp_path, work): + plan = encode_plan([ + (f"file://{tmp_path}/does-not-exist.git", "missing", "", "0"), + (make_origin(tmp_path, "app"), "app", "", "0"), + ]) + + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"DEVBASE_REPOS": plan}, tmp_path) + + assert result.returncode == 0, result.stderr + assert (work / "app" / "README.md").exists() + assert not (work / "missing").exists() + assert "Warning" in result.stdout + result.stderr + + +def test_a_failing_checkout_does_not_stop_the_others(tmp_path, work): + plan = encode_plan([ + (make_origin(tmp_path, "app"), "app", "no-such-branch", "0"), + (make_origin(tmp_path, "docs"), "docs", "", "0"), + ]) + + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"DEVBASE_REPOS": plan}, tmp_path) + + assert result.returncode == 0, result.stderr + assert (work / "docs" / "README.md").exists() + + +def test_a_failing_checkout_skips_the_init_script(tmp_path, work): + """checkout に失敗した repo は打ち切る (意図しない branch で init.sh を走らせない)。""" + files = {"README.md": "app", "init.sh": "#!/bin/bash\ntouch ./init-was-run\n"} + plan = encode_plan([ + (make_origin(tmp_path, "app", files=files), "app", "no-such-branch", "1"), + ]) + + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"DEVBASE_REPOS": plan}, tmp_path) + + assert result.returncode == 0, result.stderr + assert not (work / "app" / "init-was-run").exists() + + +def test_existing_clone_keeps_the_branch_the_user_switched_to(tmp_path, work): + """再起動のたびに設定 branch へ引き戻さない (checkout は clone 直後のみ)。""" + url = make_origin(tmp_path, "app", branches=("develop", "feature-A")) + plan = encode_plan([(url, "app", "develop", "0")]) + run_entrypoint_fn(f'devbase_clone_repos "{work}"', {"DEVBASE_REPOS": plan}, tmp_path) + assert current_branch(work / "app") == "develop" + subprocess.run(["git", "-C", str(work / "app"), "checkout", "-q", "feature-A"], check=True) + + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"DEVBASE_REPOS": plan}, tmp_path) + + assert result.returncode == 0, result.stderr + assert current_branch(work / "app") == "feature-A" + + +def test_existing_clone_is_kept(tmp_path, work): + url = make_origin(tmp_path, "app") + plan = encode_plan([(url, "app", "", "0")]) + run_entrypoint_fn(f'devbase_clone_repos "{work}"', {"DEVBASE_REPOS": plan}, tmp_path) + (work / "app" / "local-change.txt").write_text("keep me") + + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"DEVBASE_REPOS": plan}, tmp_path) + + assert result.returncode == 0, result.stderr + assert (work / "app" / "local-change.txt").read_text() == "keep me" + + +def test_no_plan_is_not_an_error(tmp_path, work): + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', {}, tmp_path) + + assert result.returncode == 0, result.stderr + assert list(work.iterdir()) == [] + + +def test_entry_with_missing_fields_is_skipped(tmp_path, work): + """列数が合わない行は clone せず警告に留め、正しい行の処理は続ける""" + good = make_origin(tmp_path, "app") + broken = "\x1f".join([f"file://{tmp_path}/x.git", "x"]) # branch / init が無い + plan = base64.b64encode( + (broken + "\n" + "\x1f".join([good, "app", "", "0"]) + "\n").encode()).decode() + + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"DEVBASE_REPOS": plan}, tmp_path) + + assert result.returncode == 0, result.stderr + assert "Warning" in result.stdout + result.stderr + assert not (work / "x").exists() + assert (work / "app" / "README.md").exists() + + +def test_broken_plan_is_reported_without_failing_startup(tmp_path, work): + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"DEVBASE_REPOS": "not-base64!!"}, tmp_path) + + assert result.returncode == 0, result.stderr + assert "Warning" in result.stdout + result.stderr + + +# --------------------------------------------------------------------------- +# workspace +# --------------------------------------------------------------------------- + +def test_writes_the_workspace_file(tmp_path, work): + document = {"folders": [{"path": "/work/app"}, {"path": "/work/docs"}]} + encoded = base64.b64encode(json.dumps(document).encode()).decode() + dest = work / "sample.code-workspace" + + result = run_entrypoint_fn("devbase_write_workspace", { + "DEVBASE_WORKSPACE": str(dest), + "DEVBASE_WORKSPACE_B64": encoded, + }, tmp_path) + + assert result.returncode == 0, result.stderr + assert json.loads(dest.read_text()) == document + + +def test_workspace_is_skipped_when_not_configured(tmp_path, work): + result = run_entrypoint_fn("devbase_write_workspace", {}, tmp_path) + + assert result.returncode == 0, result.stderr + assert list(work.iterdir()) == [] + + +# --------------------------------------------------------------------------- +# primary への cd +# --------------------------------------------------------------------------- + +def test_primary_dir_is_the_landing_directory(tmp_path, work): + (work / "app").mkdir() + + result = run_entrypoint_fn(f'devbase_enter_primary_dir "{work}"; pwd', + {"DEVBASE_PRIMARY_DIR": "app"}, tmp_path) + + assert result.returncode == 0, result.stderr + assert result.stdout.strip().endswith("/work/app") + + +def test_missing_primary_dir_does_not_fail_startup(tmp_path, work): + result = run_entrypoint_fn(f'devbase_enter_primary_dir "{work}"', + {"DEVBASE_PRIMARY_DIR": "app"}, tmp_path) + + assert result.returncode == 0, result.stderr + assert "Warning" in result.stdout + result.stderr + + +def test_old_git_repo_env_is_no_longer_honoured(tmp_path, work): + """PLAN32 は後方互換を持たない。GIT_USER/GIT_REPO では clone しない。""" + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"GIT_USER": "volareinc", "GIT_REPO": "carmo"}, tmp_path) + + assert result.returncode == 0, result.stderr + assert list(work.iterdir()) == [] + + +# --------------------------------------------------------------------------- +# 符号化側との結合 +# --------------------------------------------------------------------------- + +def test_the_real_encoder_output_is_consumed_as_is(tmp_path, work): + """host 側の ``encode_repo_plan`` の出力を entrypoint がそのまま読めること。 + + wire format (US 区切り / 末尾 LF) の契約は符号化側と entrypoint の 2 箇所に + 分かれているため、テスト用の ``encode_plan`` だけを見ていると片側だけ変わった + ときに気付けない。実物の producer を通した plan で clone まで確認する。 + + ``lib/devbase/project/config.py`` は別 PR (Task 1) で入るので、まだ無い + ブランチでは skip する。 + """ + config = pytest.importorskip( + "devbase.project.config", + reason="lib/devbase/project/config.py はまだこのブランチに無い (Task 1)") + + repos = [ + config.RepoSpec(host="github.com", owner="o", repo="app", + dir="app", branch=None, init=False, primary=True), + config.RepoSpec(host="github.com", owner="o", repo="docs", + dir="docs", branch="develop", init=False, primary=False), + ] + # url はローカルの bare リポジトリへ差し替える (ネットワークに触らない) + urls = { + "app": make_origin(tmp_path, "app"), + "docs": make_origin(tmp_path, "docs", branches=("develop",)), + } + plan = config.encode_repo_plan(repos) + decoded = base64.b64decode(plan).decode() + for spec in repos: + decoded = decoded.replace(spec.url, urls[spec.dir]) + plan = base64.b64encode(decoded.encode()).decode() + + result = run_entrypoint_fn(f'devbase_clone_repos "{work}"', + {"DEVBASE_REPOS": plan}, tmp_path) + + assert result.returncode == 0, result.stderr + assert "malformed" not in result.stdout + result.stderr + # branch 未指定の repo が畳まれず、後続の repo もずれずに読めていること + assert (work / "app" / "README.md").exists() + assert (work / "docs" / "README.md").exists() + assert current_branch(work / "docs") == "develop" From 27d2a5c23cf9801b11ae0a81c30382770d5c0332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Sun, 23 Aug 2026 01:03:16 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20PLAN32-config-loader=20project.yml?= =?UTF-8?q?=20=E3=83=AD=E3=83=BC=E3=83=80=E3=81=A8=20wire=20format=20(#104?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: PLAN32-config-loader Draft PR 作成 * feat(project): project.yml ローダと clone プランの wire format を追加 PLAN32 Task 1。projects//project.yml を読み、defaults 継承・既定値 補完・検証を経て正規化した ProjectConfig を返すライブラリを追加した。 コンテナへは base64 TSV の clone プランとして渡す契約 (encode/decode) も ここで確定させる。呼び出し元の差し替えは後続 PR で行うため、この PR 単体 では挙動は変わらない。 未知キー・dir 重複・primary 複数・空白混入は黙って通さずエラーにする。 project.yml が無い場合も旧 env 形式へフォールバックせず、移行コマンドを 案内するエラーにした (PLAN32 は後方互換を持たないため、移行漏れを検出 できる必要がある)。 Co-Authored-By: Claude Opus 5 (1M context) * fix(project): YAML の型検証を厳密化し設定ファイルを UTF-8 で読む - version: YAML の `true` が Python では `1` と等価なため bool を明示的に拒否 - defaults: `or {}` をやめ、null/未指定のみ空マッピングへ正規化 (`defaults: []` / `false` が黙って受理され型検証が素通りするのを防ぐ) - project.yml の読み込みに encoding="utf-8" を明示し、UnicodeDecodeError は 「UTF-8 で保存してください」と案内する ConfigError に変換 - テストの write_text にも encoding="utf-8" を明示 + 上記の回帰テストを追加 Co-Authored-By: Claude Opus 5 (1M context) * fix(project): version の型判定を厳密化し wire format の init 列を検証する - version は `type(...) is int` で判定。YAML の `1.0` は float だが `1.0 == 1` を満たすため、これまで schema version 1 として受理されていた - decode_repo_plan で init 列が `1`/`0` 以外なら ConfigError。壊れた値や将来の 未知値を「init しない」として黙って通さない - 非 UTF-8 (cp932) の project.yml が UTF-8 保存の案内を出すことをテストで固定 Co-Authored-By: Claude Opus 5 (1M context) * fix(project): wire format の区切りを US にし末尾 LF を付けて bash 契約を固定 タブ区切りは bash の既定 IFS と同じ空白類のため、`IFS=$'\t' read -r url dir branch init` では連続区切りが 1 つに畳まれ、branch 未指定 (空フィールド) の行で init の値が branch にずれ込んでいた。区切りを US (\x1f) に変えると空白類ではない ため空フィールドが保持され、bash 側の素直な読み方がそのまま正しく動く。 あわせて符号化結果に末尾 LF を付ける。`while read` は EOF 直前の改行なし行を 読み捨てるため、末尾 LF が無いと repo 1 件構成で唯一の行が丸ごと落ちていた。 契約 (区切りは \x1f / 行区切りは LF で末尾にもあり / 各フィールドは検証済みで 空白・制御文字を含まない) を docstring と PLAN32 の wire format 節に明記し、 実際の bash を `bash -c` で起動して読ませる回帰テストを追加した (Docker 不要)。 また YAML が int として読む `repo: 123` に「必須です」と出て紛らわしかったため、 未指定と型不一致でエラーメッセージを分けた。 Co-Authored-By: Claude Opus 5 (1M context) * fix(project): 空文字と非空白の制御文字を正しく弾き分ける - 省略可能な branch に `branch: ""` を書くと「必須です」と返り矛盾していたため、 未指定 (None) / 型不一致 / 空文字 の 3 つをそれぞれのメッセージで書き分ける - `isspace()` だけでは NUL・BEL・DEL やゼロ幅空白がすり抜け、encode_repo_plan の 「制御文字を一切含まない」という契約を満たせていなかったので、印字できない文字も まとめて弾くようにした Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- issues/PLAN32_multi-repo-project.md | 9 + lib/devbase/project/__init__.py | 10 + lib/devbase/project/config.py | 407 ++++++++++++++++++++++ tests/project/__init__.py | 0 tests/project/test_config.py | 516 ++++++++++++++++++++++++++++ 5 files changed, 942 insertions(+) create mode 100644 lib/devbase/project/__init__.py create mode 100644 lib/devbase/project/config.py create mode 100644 tests/project/__init__.py create mode 100644 tests/project/test_config.py diff --git a/issues/PLAN32_multi-repo-project.md b/issues/PLAN32_multi-repo-project.md index 5f04ebf..9d8a5ce 100644 --- a/issues/PLAN32_multi-repo-project.md +++ b/issues/PLAN32_multi-repo-project.md @@ -158,6 +158,15 @@ https://gitlab.com/uttaro_dev/uttarov2.gitsystem0 列: `url`, `dir`, `branch` (空可), `init` (`1`/`0`)。primary は別変数 `DEVBASE_PRIMARY_DIR` で渡す (列を増やさず、entrypoint の `cd` 先判定を単純に保つ)。 +entrypoint 側の読み方 (`containers/base/entrypoint.sh`): + +```bash +printf '%s' "$DEVBASE_REPOS" | base64 -d | + while IFS=$'\x1f' read -r url dir branch init; do + ... + done +``` + ## 修正対象 devbase 本体: diff --git a/lib/devbase/project/__init__.py b/lib/devbase/project/__init__.py new file mode 100644 index 0000000..bc07db6 --- /dev/null +++ b/lib/devbase/project/__init__.py @@ -0,0 +1,10 @@ +"""プロジェクト設定 (``projects//project.yml``) の読み込み。""" + +from .config import ( # noqa: F401 + ProjectConfig, + RepoSpec, + decode_repo_plan, + encode_repo_plan, + load_project_config, + parse_project_config, +) diff --git a/lib/devbase/project/config.py b/lib/devbase/project/config.py new file mode 100644 index 0000000..2c4023a --- /dev/null +++ b/lib/devbase/project/config.py @@ -0,0 +1,407 @@ +"""``projects//project.yml`` の読み込み・正規化・検証 (PLAN32)。 + +1 プロジェクト = 1 コンテナ = **複数リポジトリ**構成の設定ファイルを扱う。 +人間が編集する正は YAML であり、コンテナへは正規化した「clone プラン」を +base64 テキスト (:func:`encode_repo_plan`) にして渡す。YAML の解釈をホスト側の +Python に閉じ込めることで、entrypoint (bash) は ``base64 -d`` と ``while read`` +だけで済み、コンテナイメージへ YAML パーサ依存を持ち込まずに済む。 + +スキーマ:: + + version: 1 # 必須 + scale: 1 # 任意。旧 CONTAINER_SCALE + open_editor: true # 任意。旧 DEVBASE_OPEN_EDITOR + work_dir: /work/carmo # 任意。既定は primary repo の /work/ + defaults: # 任意。repos の各要素へ継承させる既定値 + host: github.com + owner: volareinc + repos: + - repo: carmo # 必須 + primary: true # 任意。未指定なら先頭要素が primary + - repo: carmo-batch + dir: batch # 任意。/work 配下の clone 先名 (既定 repo 名) + branch: develop # 任意。clone 後に checkout + init: false # 任意 (既定 true)。clone 後の ./init.sh 実行有無 + +旧方式 (``env`` の ``GIT_USER`` / ``GIT_REPO``) への後方互換は持たない。 +``project.yml`` が無いプロジェクトは移行手順を案内して :class:`ConfigError` +を送出する (黙って単一 repo として動かすと、移行漏れが検出できないため)。 +""" + +from __future__ import annotations + +import base64 +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any, Iterable, Mapping, Optional, Sequence, Tuple + +import yaml + +from devbase.errors import ConfigError + +#: 設定ファイル名 (プロジェクトディレクトリ直下) +PROJECT_CONFIG_FILENAME = "project.yml" + +#: 対応するスキーマ版 +SUPPORTED_VERSION = 1 + +_TOP_LEVEL_KEYS = frozenset( + {"version", "scale", "open_editor", "work_dir", "defaults", "repos"}) +_REPO_KEYS = frozenset({"host", "owner", "repo", "dir", "branch", "init", "primary"}) +#: ``defaults`` に書けるのは repo ごとに異なるとは限らない項目だけ。 +#: ``dir`` / ``primary`` は repo 固有 (継承すると必ず重複・複数 primary になる)。 +_DEFAULTS_KEYS = frozenset({"host", "owner", "branch", "init"}) + +_DEFAULT_HOST = "github.com" + +#: wire format のフィールド区切り。US (unit separator, ``\x1f``) を使う。 +#: タブは bash の既定 ``IFS`` と同じ空白類に分類され、``IFS=$'\t' read`` では +#: 連続する区切りが 1 つに畳まれてしまうため、空フィールド (branch 未指定) が +#: 消えて以降の列がずれる。US は空白類ではないので空フィールドが保持される。 +_WIRE_FIELD_SEPARATOR = "\x1f" + + +@dataclass(frozen=True) +class RepoSpec: + """正規化済みの 1 リポジトリ分の clone 指定。""" + + host: str + owner: str + repo: str + dir: str + branch: Optional[str] + init: bool + primary: bool + + @property + def url(self) -> str: + """clone 先 URL。認証は既存の git 資格情報機構に委ねる (URL に含めない)。""" + return f"https://{self.host}/{self.owner}/{self.repo}.git" + + +@dataclass(frozen=True) +class RepoPlanEntry: + """wire format を復号した 1 行分 (entrypoint が受け取る情報と同じ)。""" + + url: str + dir: str + branch: Optional[str] + init: bool + + +@dataclass(frozen=True) +class ProjectConfig: + """``project.yml`` 1 ファイル分の正規化済み設定。""" + + version: int + repos: Tuple[RepoSpec, ...] + scale: Optional[int] = None + open_editor: Optional[bool] = None + work_dir: Optional[str] = None + + @property + def primary(self) -> RepoSpec: + """``cd`` 先・エディタの既定フォルダになる repo (常にちょうど 1 件)。""" + return next(repo for repo in self.repos if repo.primary) + + def resolved_work_dir(self) -> str: + """コンテナ内で開く既定フォルダ。明示指定が無ければ primary repo の dir。""" + return self.work_dir or f"/work/{self.primary.dir}" + + +# --------------------------------------------------------------------------- +# 読み込み +# --------------------------------------------------------------------------- + +def config_path(project_dir: Path) -> Path: + """プロジェクトディレクトリ内の ``project.yml`` のパス。""" + return Path(project_dir) / PROJECT_CONFIG_FILENAME + + +def load_project_config(project_dir: Path) -> ProjectConfig: + """``/project.yml`` を読み込む。 + + Raises: + ConfigError: ファイルが無い / YAML が壊れている / スキーマ違反。 + 旧 ``env`` 形式へのフォールバックはしない (PLAN32 は後方互換なし)。 + """ + path = config_path(project_dir) + if not path.is_file(): + raise ConfigError( + f"{path} がありません。PLAN32 以降、プロジェクトのリポジトリ構成は " + f"{PROJECT_CONFIG_FILENAME} で指定します。" + "旧 env 形式 (GIT_USER / GIT_REPO) からの移行は " + "`devbase project migrate-config` を実行してください。" + ) + + try: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + except UnicodeDecodeError as e: + raise ConfigError( + f"{path} を UTF-8 として読めません ({e})。" + f"{PROJECT_CONFIG_FILENAME} は UTF-8 で保存してください。") from e + except OSError as e: + raise ConfigError(f"{path} を読み込めません: {e}") from e + except yaml.YAMLError as e: + raise ConfigError(f"{path} の YAML を解釈できません: {e}") from e + + if raw is None: + raise ConfigError(f"{path} が空です。version と repos が必要です。") + if not isinstance(raw, Mapping): + raise ConfigError(f"{path} の最上位はマッピングである必要があります。") + + return parse_project_config(raw, source=str(path)) + + +def parse_project_config(data: Mapping[str, Any], source: str) -> ProjectConfig: + """読み込み済みのマッピングを正規化・検証する (I/O を伴わない)。 + + Args: + data: YAML を読み込んだマッピング + source: エラーメッセージに出す出所 (ファイルパス等) + """ + _reject_unknown_keys(data, _TOP_LEVEL_KEYS, source, "最上位") + + version = data.get("version") + # YAML では ``true`` が ``1``、``1.0`` が float として読まれ、どちらも + # ``== 1`` を満たしてしまう。整数のスキーマ版という契約を保つため、値の + # 一致だけでなく型そのものを厳密に見る (``type(...) is int`` は bool を + # 部分型として受理しない)。 + if type(version) is not int or version != SUPPORTED_VERSION: + raise ConfigError( + f"{source}: version は {SUPPORTED_VERSION} である必要があります " + f"(現在: {version!r})") + + defaults = data.get("defaults") + if defaults is None: + defaults = {} + if not isinstance(defaults, Mapping): + raise ConfigError(f"{source}: defaults はマッピングである必要があります。") + _reject_unknown_keys(defaults, _DEFAULTS_KEYS, source, "defaults") + + raw_repos = data.get("repos") + if not isinstance(raw_repos, Sequence) or isinstance(raw_repos, (str, bytes)): + raise ConfigError(f"{source}: repos はリストである必要があります。") + if not raw_repos: + raise ConfigError(f"{source}: repos が空です。1 件以上指定してください。") + + repos = [_parse_repo(entry, defaults, source, i) + for i, entry in enumerate(raw_repos)] + _validate_dirs(repos, source) + repos = _assign_primary(repos, source) + + return ProjectConfig( + version=SUPPORTED_VERSION, + repos=tuple(repos), + scale=_parse_scale(data.get("scale"), source), + open_editor=_parse_open_editor(data.get("open_editor"), source), + work_dir=_parse_work_dir(data.get("work_dir"), source), + ) + + +# --------------------------------------------------------------------------- +# wire format (entrypoint との契約) +# --------------------------------------------------------------------------- + +def encode_repo_plan(repos: Iterable[RepoSpec]) -> str: + """clone プランを base64 テキストへ符号化する。 + + entrypoint (bash) との契約: + + - 1 行 1 repo で ``urldirbranchinit``。```` は unit separator + (``\x1f``)、``init`` は ``1``/``0``、``branch`` 未指定は**空フィールド**。 + - フィールド区切りが空白類 (タブ) ではないため、``IFS=$'\x1f' read -r url + dir branch init`` で空フィールドが畳まれず、素直に 4 列として読める。 + - 行区切りは LF。**末尾にも LF を付ける**。``while read`` は EOF 直前の + 改行なし行を読み捨てる実装があるため、末尾 LF が無いと最後の行 (repo が + 1 件ならその唯一の行) が丸ごと落ちる。 + - 各フィールドは :func:`_require_token` で検証済みで、空白・制御文字 + (タブ・改行・US を含む) を一切含まない。よって区切り文字とフィールド値が + 衝突することはなく、エスケープも不要。 + + base64 にするのは、compose の変数展開 (``$``) や改行を含む値で構成ファイルが + 壊れないようにするため。primary は列に含めず ``DEVBASE_PRIMARY_DIR`` で別に + 渡す (entrypoint の ``cd`` 先判定を単純に保つ)。 + + 典型的な consumer:: + + printf '%s' "$DEVBASE_REPOS" | base64 -d | + while IFS=$'\x1f' read -r url dir branch init; do + ... + done + """ + lines = [ + _WIRE_FIELD_SEPARATOR.join( + [repo.url, repo.dir, repo.branch or "", "1" if repo.init else "0"]) + for repo in repos + ] + text = "".join(f"{line}\n" for line in lines) + return base64.b64encode(text.encode()).decode() + + +def decode_repo_plan(encoded: str) -> Tuple[RepoPlanEntry, ...]: + """:func:`encode_repo_plan` の逆変換 (契約テストと診断用)。 + + 末尾 LF や空行は無視するので、末尾 LF の有無は round trip に影響しない。 + """ + try: + text = base64.b64decode(encoded, validate=True).decode() + except (ValueError, UnicodeDecodeError) as e: + raise ConfigError(f"clone プランを復号できません: {e}") from e + + entries = [] + for line in text.splitlines(): + if not line: + continue + fields = line.split(_WIRE_FIELD_SEPARATOR) + if len(fields) != 4: + raise ConfigError(f"clone プランの列数が不正です: {line!r}") + url, directory, branch, init = fields + # init 列は wire format 上 ``1``/``0`` だけ。それ以外を False へ丸めると + # 壊れた値や将来の未知値が「init しない」として黙って通ってしまう。 + if init not in ("0", "1"): + raise ConfigError( + f"clone プランの init 列は 1 か 0 である必要があります: {line!r}") + entries.append(RepoPlanEntry( + url=url, dir=directory, branch=branch or None, init=init == "1")) + return tuple(entries) + + +# --------------------------------------------------------------------------- +# 内部: 検証 +# --------------------------------------------------------------------------- + +def _reject_unknown_keys(data: Mapping[str, Any], allowed: frozenset, + source: str, where: str) -> None: + """未知キーは黙って無視せずエラーにする (typo が設定漏れとして表れないように)。""" + unknown = sorted(str(key) for key in data if key not in allowed) + if unknown: + raise ConfigError( + f"{source}: {where}に未知のキーがあります: {', '.join(unknown)} " + f"(使えるキー: {', '.join(sorted(allowed))})") + + +def _parse_repo(entry: Any, defaults: Mapping[str, Any], source: str, + index: int) -> RepoSpec: + where = f"repos[{index}]" + if not isinstance(entry, Mapping): + raise ConfigError(f"{source}: {where} はマッピングである必要があります。") + _reject_unknown_keys(entry, _REPO_KEYS, source, where) + + merged = {**defaults, **entry} + + repo = _require_token(merged.get("repo"), "repo", source, where, + allow_slash=False) + owner = _require_token(merged.get("owner"), "owner", source, where, + allow_slash=True) + host = _require_token(merged.get("host", _DEFAULT_HOST), "host", source, where, + allow_slash=False) + + directory = merged.get("dir", repo) + directory = _require_token(directory, "dir", source, where, allow_slash=False) + if directory in (".", ".."): + raise ConfigError( + f"{source}: {where} の dir は /work 直下の名前である必要があります " + f"({directory!r})") + + branch = merged.get("branch") + if branch is not None: + branch = _require_token(branch, "branch", source, where, allow_slash=True) + + init = merged.get("init", True) + if not isinstance(init, bool): + raise ConfigError(f"{source}: {where} の init は真偽値です ({init!r})") + + primary = entry.get("primary", False) + if not isinstance(primary, bool): + raise ConfigError(f"{source}: {where} の primary は真偽値です ({primary!r})") + + return RepoSpec(host=host, owner=owner, repo=repo, dir=directory, + branch=branch, init=init, primary=primary) + + +def _require_token(value: Any, field: str, source: str, where: str, + allow_slash: bool) -> str: + """URL 組み立てと wire format を壊さない文字列であることを確かめる。 + + 空白・タブ・改行・制御文字は wire format (行区切り・US 区切り) を壊し、``/`` は + ``https:////.git`` の構造や ``/work/`` の階層を + 壊すため、項目ごとに許可を分ける (gitlab のサブグループやブランチ名の + ``feature/x`` は ``/`` を含むため許可する)。 + """ + # YAML は ``repo: 123`` を int として読むため、「未指定」「型が違う」「空」を + # 同じ "必須です" で片づけると「指定したのに必須と言われる」ことになる。 + # 特に branch のような省略可能なフィールドでは ``branch: ""`` に対して + # 「必須です」と返るのが矛盾して見える。 + if value is None: + raise ConfigError(f"{source}: {where} の {field} は必須です") + if not isinstance(value, str): + raise ConfigError( + f"{source}: {where} の {field} は文字列で指定してください " + f"({value!r})") + if not value: + raise ConfigError( + f"{source}: {where} の {field} に空文字は指定できません") + # ``isspace()`` だけでは NUL・DEL のような非空白の制御文字やゼロ幅空白が + # すり抜ける。URL 組み立て・``/work/``・wire format のいずれにとっても + # 害なので「印字できない文字」をまとめて弾く (通常の空白は印字可能なので + # ``isspace()`` 側で拾う)。 + if any(c.isspace() or not c.isprintable() for c in value): + raise ConfigError( + f"{source}: {where} の {field} に空白文字・制御文字は使えません " + f"({value!r})") + if not allow_slash and "/" in value: + raise ConfigError( + f"{source}: {where} の {field} に / は使えません ({value!r})") + if allow_slash and (value.startswith("/") or value.endswith("/")): + raise ConfigError( + f"{source}: {where} の {field} は / で始まる・終わることはできません " + f"({value!r})") + return value + + +def _validate_dirs(repos: Sequence[RepoSpec], source: str) -> None: + """同じ ``/work/`` を 2 つの repo が奪い合わないこと。""" + seen = set() + for repo in repos: + if repo.dir in seen: + raise ConfigError( + f"{source}: clone 先の dir が重複しています: {repo.dir!r}") + seen.add(repo.dir) + + +def _assign_primary(repos: Sequence[RepoSpec], source: str) -> list: + """primary をちょうど 1 件に確定する (未指定なら先頭)。""" + explicit = [repo for repo in repos if repo.primary] + if len(explicit) > 1: + names = ", ".join(repo.dir for repo in explicit) + raise ConfigError( + f"{source}: primary: true は 1 件だけ指定できます ({names})") + if explicit: + return list(repos) + first, *rest = repos + return [replace(first, primary=True), *rest] + + +def _parse_scale(value: Any, source: str) -> Optional[int]: + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise ConfigError(f"{source}: scale は 1 以上の整数です ({value!r})") + return value + + +def _parse_open_editor(value: Any, source: str) -> Optional[bool]: + if value is None: + return None + if not isinstance(value, bool): + raise ConfigError(f"{source}: open_editor は真偽値です ({value!r})") + return value + + +def _parse_work_dir(value: Any, source: str) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + raise ConfigError(f"{source}: work_dir は文字列です ({value!r})") + return value.strip() diff --git a/tests/project/__init__.py b/tests/project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/project/test_config.py b/tests/project/test_config.py new file mode 100644 index 0000000..7d99ff6 --- /dev/null +++ b/tests/project/test_config.py @@ -0,0 +1,516 @@ +"""project.yml の読み込み・正規化・検証と clone プランの wire format""" + +from __future__ import annotations + +import base64 +import shutil +import subprocess + +import pytest + +from devbase.errors import ConfigError +from devbase.project.config import ( + decode_repo_plan, + encode_repo_plan, + load_project_config, + parse_project_config, +) + + +def write_project_yml(tmp_path, text: str): + (tmp_path / "project.yml").write_text(text, encoding="utf-8") + return tmp_path + + +# --------------------------------------------------------------------------- +# 正常系 +# --------------------------------------------------------------------------- + +def test_single_repo_defaults(): + """最小構成: host は github.com、dir は repo 名、init は有効、先頭が primary""" + config = parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + (repo,) = config.repos + assert repo.host == "github.com" + assert repo.owner == "volareinc" + assert repo.repo == "carmo" + assert repo.dir == "carmo" + assert repo.branch is None + assert repo.init is True + assert repo.primary is True + assert repo.url == "https://github.com/volareinc/carmo.git" + assert config.primary is repo + + +def test_defaults_are_inherited_and_overridable(): + config = parse_project_config({ + "version": 1, + "defaults": {"host": "github.com", "owner": "uttaro-dev2"}, + "repos": [ + {"repo": "uttarov2", "host": "gitlab.com", "owner": "uttaro_dev", "dir": "system"}, + {"repo": "uttarov2-doc"}, + {"repo": "uttarov2migration", "branch": "develop", "init": False}, + ], + }, source="project.yml") + + system, doc, migration = config.repos + assert system.url == "https://gitlab.com/uttaro_dev/uttarov2.git" + assert system.dir == "system" + assert doc.url == "https://github.com/uttaro-dev2/uttarov2-doc.git" + assert doc.dir == "uttarov2-doc" + assert migration.branch == "develop" + assert migration.init is False + + +def test_primary_can_be_chosen_explicitly(): + config = parse_project_config({ + "version": 1, + "defaults": {"owner": "volareinc"}, + "repos": [{"repo": "carmo-doc"}, {"repo": "carmo", "primary": True}], + }, source="project.yml") + + assert config.primary.repo == "carmo" + assert [r.primary for r in config.repos] == [False, True] + + +def test_optional_settings_are_read(): + config = parse_project_config({ + "version": 1, + "scale": 3, + "open_editor": False, + "work_dir": "/work/carmo/app", + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + assert config.scale == 3 + assert config.open_editor is False + assert config.work_dir == "/work/carmo/app" + + +def test_optional_settings_default_to_none(): + """未指定の設定は None。既定値の解釈は呼び出し側 (env / 既定値) に委ねる""" + config = parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + assert config.scale is None + assert config.open_editor is None + assert config.work_dir is None + + +def test_work_dir_defaults_to_primary_repo_dir(): + config = parse_project_config({ + "version": 1, + "defaults": {"owner": "volareinc"}, + "repos": [{"repo": "carmo-doc"}, {"repo": "carmo", "primary": True}], + }, source="project.yml") + + assert config.resolved_work_dir() == "/work/carmo" + + +def test_resolved_work_dir_prefers_explicit_value(): + config = parse_project_config({ + "version": 1, + "work_dir": "/work/carmo/app", + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + assert config.resolved_work_dir() == "/work/carmo/app" + + +def test_load_project_config_reads_file(tmp_path): + write_project_yml(tmp_path, """ +version: 1 +scale: 1 +defaults: + owner: KK-Generation +repos: + - repo: project-trygroup-prd + - repo: project-trygroup-prd-customer +""") + + config = load_project_config(tmp_path) + + assert config.scale == 1 + assert [r.dir for r in config.repos] == [ + "project-trygroup-prd", "project-trygroup-prd-customer"] + + +# --------------------------------------------------------------------------- +# 異常系 (後方互換は無いので、曖昧な設定は黙って通さない) +# --------------------------------------------------------------------------- + +def test_missing_file_is_an_error_with_migration_hint(tmp_path): + (tmp_path / "env").write_text("GIT_USER=volareinc\nGIT_REPO=carmo\n") + + with pytest.raises(ConfigError) as excinfo: + load_project_config(tmp_path) + + message = str(excinfo.value) + assert "project.yml" in message + assert "migrate-config" in message + + +def test_missing_owner_is_an_error(): + with pytest.raises(ConfigError, match="owner"): + parse_project_config({"version": 1, "repos": [{"repo": "carmo"}]}, + source="project.yml") + + +def test_missing_repo_is_an_error(): + with pytest.raises(ConfigError, match="repo"): + parse_project_config({"version": 1, "repos": [{"owner": "volareinc"}]}, + source="project.yml") + + +def test_duplicated_dir_is_an_error(): + with pytest.raises(ConfigError, match="dir"): + parse_project_config({ + "version": 1, + "defaults": {"owner": "volareinc"}, + "repos": [{"repo": "carmo"}, {"repo": "carmo-batch", "dir": "carmo"}], + }, source="project.yml") + + +def test_multiple_primary_is_an_error(): + with pytest.raises(ConfigError, match="primary"): + parse_project_config({ + "version": 1, + "defaults": {"owner": "volareinc"}, + "repos": [{"repo": "carmo", "primary": True}, + {"repo": "carmo-batch", "primary": True}], + }, source="project.yml") + + +def test_empty_repos_is_an_error(): + with pytest.raises(ConfigError, match="repos"): + parse_project_config({"version": 1, "repos": []}, source="project.yml") + + +def test_unknown_key_is_an_error(): + """typo を黙って無視しない""" + with pytest.raises(ConfigError, match="brunch"): + parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo", "brunch": "main"}], + }, source="project.yml") + + +def test_unknown_top_level_key_is_an_error(): + with pytest.raises(ConfigError, match="container_scale"): + parse_project_config({ + "version": 1, + "container_scale": 2, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + +def test_unsupported_version_is_an_error(): + with pytest.raises(ConfigError, match="version"): + parse_project_config({ + "version": 2, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + +def test_missing_version_is_an_error(): + with pytest.raises(ConfigError, match="version"): + parse_project_config({ + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + +@pytest.mark.parametrize("bad_version", [True, False, 1.0, "1"]) +def test_non_integer_version_is_an_error(bad_version): + """YAML の ``true`` / ``1.0`` は ``== 1`` を満たすため型で明示的に弾く""" + with pytest.raises(ConfigError, match="version"): + parse_project_config({ + "version": bad_version, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + +@pytest.mark.parametrize("bad_defaults", [[], False, 0, "host", ["host"]]) +def test_non_mapping_defaults_is_an_error(bad_defaults): + """falsy な非マッピングを空マッピング扱いで黙って受理しない""" + with pytest.raises(ConfigError, match="defaults"): + parse_project_config({ + "version": 1, + "defaults": bad_defaults, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + +def test_null_defaults_is_treated_as_empty(): + """``defaults:`` と書いただけ (null) は未指定と同じ扱い""" + config = parse_project_config({ + "version": 1, + "defaults": None, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + assert config.repos[0].host == "github.com" + + +@pytest.mark.parametrize("bad_dir", ["../escape", "nested/dir", ".", "..", "/abs"]) +def test_dir_must_stay_directly_under_work(bad_dir): + """/work の外へ抜ける dir を許すと clone 先が予測できなくなる""" + with pytest.raises(ConfigError, match="dir"): + parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo", "dir": bad_dir}], + }, source="project.yml") + + +@pytest.mark.parametrize("field,value", [ + ("owner", "vola reinc"), + ("repo", "car\tmo"), + ("host", "github.com/extra"), + ("branch", "main\nrm -rf"), + ("repo", "car\x1fmo"), # US — wire format の列区切りそのもの +]) +def test_fields_reject_whitespace_and_separators(field, value): + """wire format (US 区切り・LF 行区切り) と URL 組み立てを壊す値を弾く""" + spec = {"owner": "volareinc", "repo": "carmo"} + spec[field] = value + with pytest.raises(ConfigError, match=field): + parse_project_config({"version": 1, "repos": [spec]}, source="project.yml") + + +@pytest.mark.parametrize("value", [ + "car\x00mo", # NUL — bash の read / git のどちらにとっても異物 + "car\x07mo", # BEL + "car\x7fmo", # DEL + "car\u200bmo", # ゼロ幅空白 — 目視できないまま URL に混ざる +]) +def test_fields_reject_non_whitespace_control_characters(value): + """``isspace()`` ではすり抜ける制御文字・ゼロ幅空白も弾く + + :func:`encode_repo_plan` の docstring が「制御文字を一切含まない」と + 宣言している以上、空白判定だけでは契約を満たせない。 + """ + with pytest.raises(ConfigError, match="repo"): + parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": value}], + }, source="project.yml") + + +def test_scale_must_be_a_positive_integer(): + with pytest.raises(ConfigError, match="scale"): + parse_project_config({ + "version": 1, "scale": 0, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + +def test_open_editor_must_be_boolean(): + with pytest.raises(ConfigError, match="open_editor"): + parse_project_config({ + "version": 1, "open_editor": "yes", + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + +def test_broken_yaml_is_an_error(tmp_path): + write_project_yml(tmp_path, "version: 1\nrepos: [") + + with pytest.raises(ConfigError, match="project.yml"): + load_project_config(tmp_path) + + +def test_non_utf8_file_is_an_error_with_encoding_hint(tmp_path): + """Shift-JIS 等で保存されたファイルは UTF-8 での保存を案内する""" + (tmp_path / "project.yml").write_bytes( + "version: 1 # 日本語コメント\n".encode("cp932")) + + with pytest.raises(ConfigError, match="UTF-8"): + load_project_config(tmp_path) + + +def test_yaml_root_must_be_a_mapping(tmp_path): + write_project_yml(tmp_path, "- repo: carmo") + + with pytest.raises(ConfigError, match="project.yml"): + load_project_config(tmp_path) + + +# --------------------------------------------------------------------------- +# wire format (entrypoint との契約) +# --------------------------------------------------------------------------- + +#: wire format のフィールド区切り (unit separator)。 +US = "\x1f" + +#: entrypoint (bash) が想定する読み取り方をそのまま再現する consumer。 +#: 素朴な ``while read`` で 4 列が欠けずに読めることを、実際の bash で確かめる。 +_BASH_CONSUMER = r""" +set -eu +printf '%s' "$PLAN" | base64 -d | + while IFS=$'\x1f' read -r url dir branch init; do + printf '[%s][%s][%s][%s]\n' "$url" "$dir" "$branch" "$init" + done +""" + + +def run_bash_consumer(encoded: str) -> list: + """符号化済み clone プランを bash の ``while read`` で読ませて行を返す。""" + result = subprocess.run( + ["bash", "-c", _BASH_CONSUMER], + env={"PLAN": encoded, "PATH": "/usr/bin:/bin:/usr/local/bin"}, + capture_output=True, text=True, check=True, + ) + return result.stdout.splitlines() + + +requires_bash = pytest.mark.skipif( + shutil.which("bash") is None, reason="bash が無い環境ではスキップ") + + +def test_encode_repo_plan_is_base64_unit_separated(): + config = parse_project_config({ + "version": 1, + "defaults": {"owner": "uttaro-dev2"}, + "repos": [ + {"repo": "uttarov2", "host": "gitlab.com", "owner": "uttaro_dev", "dir": "system"}, + {"repo": "uttarov2-doc", "branch": "develop", "init": False}, + ], + }, source="project.yml") + + encoded = encode_repo_plan(config.repos) + decoded = base64.b64decode(encoded).decode() + + assert decoded == ( + f"https://gitlab.com/uttaro_dev/uttarov2.git{US}system{US}{US}1\n" + f"https://github.com/uttaro-dev2/uttarov2-doc.git{US}uttarov2-doc" + f"{US}develop{US}0\n" + ) + + +def test_encoded_plan_ends_with_newline(): + """末尾 LF が無いと素朴な ``while read`` consumer が最後の行を落とす""" + config = parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + decoded = base64.b64decode(encode_repo_plan(config.repos)).decode() + + assert decoded.endswith("\n") + + +@requires_bash +def test_bash_consumer_reads_every_column_including_empty_branch(): + """branch 未指定 (空フィールド) でも init が branch にずれ込まないこと + + 区切りをタブにすると bash の IFS 空白扱いで連続区切りが 1 つに畳まれ、 + ``1`` が branch に入って init が空になる。US (\x1f) なら空フィールドが残る。 + """ + config = parse_project_config({ + "version": 1, + "defaults": {"owner": "volareinc"}, + "repos": [ + {"repo": "carmo"}, + {"repo": "carmo-batch", "branch": "develop", "init": False}, + ], + }, source="project.yml") + + lines = run_bash_consumer(encode_repo_plan(config.repos)) + + assert lines == [ + "[https://github.com/volareinc/carmo.git][carmo][][1]", + "[https://github.com/volareinc/carmo-batch.git][carmo-batch][develop][0]", + ] + + +@requires_bash +def test_bash_consumer_does_not_drop_the_last_line_for_a_single_repo(): + """末尾 LF が無いと 1 repo 構成では唯一の行がループ本体に入らない""" + config = parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + lines = run_bash_consumer(encode_repo_plan(config.repos)) + + assert lines == ["[https://github.com/volareinc/carmo.git][carmo][][1]"] + + +def test_repo_plan_round_trips(): + config = parse_project_config({ + "version": 1, + "defaults": {"owner": "volareinc"}, + "repos": [{"repo": "carmo"}, {"repo": "carmo-batch", "branch": "main"}], + }, source="project.yml") + + restored = decode_repo_plan(encode_repo_plan(config.repos)) + + assert [(e.url, e.dir, e.branch, e.init) for e in restored] == [ + ("https://github.com/volareinc/carmo.git", "carmo", None, True), + ("https://github.com/volareinc/carmo-batch.git", "carmo-batch", "main", True), + ] + + +@pytest.mark.parametrize("bad_init", ["", "2", "true", "0 "]) +def test_decode_rejects_init_column_outside_one_and_zero(bad_init): + """壊れた値・将来の未知値を「init しない」として黙って通さない""" + line = f"https://github.com/volareinc/carmo.git{US}carmo{US}{US}{bad_init}" + encoded = base64.b64encode(line.encode()).decode() + + with pytest.raises(ConfigError, match="init"): + decode_repo_plan(encoded) + + +def test_numeric_repo_name_reports_a_type_error_not_a_missing_field(): + """YAML が int として読む ``repo: 123`` に「必須です」と言わない + + 「指定したのに必須と言われる」を避けるため、未指定と型不一致を書き分ける。 + """ + with pytest.raises(ConfigError, match="repo は文字列で指定してください"): + parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": 123}], + }, source="project.yml") + + +def test_unspecified_repo_reports_a_missing_field(): + with pytest.raises(ConfigError, match="repo は必須です"): + parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": None}], + }, source="project.yml") + + +def test_empty_repo_reports_an_empty_value_not_a_missing_field(): + """``repo: ""`` は「指定はされている」ので「必須です」とは言わない""" + with pytest.raises(ConfigError, match="repo に空文字は指定できません"): + parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": ""}], + }, source="project.yml") + + +def test_empty_optional_branch_is_rejected_as_empty_not_as_missing(): + """branch は省略可能なので ``branch: ""`` に「必須です」と返すと矛盾する""" + with pytest.raises(ConfigError, match="branch に空文字は指定できません"): + parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo", "branch": ""}], + }, source="project.yml") + + +def test_encoded_plan_has_no_shell_or_compose_hazards(): + """compose の変数展開・改行で壊れないこと (base64 なので英数と = のみ)""" + config = parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + }, source="project.yml") + + encoded = encode_repo_plan(config.repos) + + assert encoded.strip() == encoded + assert all(c.isalnum() or c in "+/=" for c in encoded) From e3a6e362e3ecc0006bc2a73e3ee9ed397a3221fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Sun, 23 Aug 2026 01:35:24 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20PLAN32-host-wiring=20up=20/=20scale?= =?UTF-8?q?=20/=20editor=20=E3=81=AE=E9=85=8D=E7=B7=9A=E3=82=92=20project.?= =?UTF-8?q?yml=20=E3=81=B8=E5=88=87=E6=9B=BF=20(#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(up): project.yml を起動・スケール・エディタの唯一の設定源にする PLAN32 Task 2。devbase up がプロジェクトの project.yml を読み、正規化した clone プランを生成 compose の dev サービスへ環境変数として載せる。これで entrypoint が複数リポジトリを clone できる。 - lib/devbase/project/runtime.py を追加: コンテナへ渡す環境変数の組み立て (DEVBASE_REPOS / DEVBASE_PRIMARY_DIR / DEVBASE_WORKSPACE*)、workspace JSON の 生成、scale の読み書き。workspace の JSON はホスト側で組み立てて base64 で 渡すため、シェルでのエスケープが要らずテストもできる - scale の取得元と devbase scale の書き込み先を env の CONTAINER_SCALE から project.yml の scale へ移した。書き込みは行単位の置換にしてコメントと並び順を 保ち、書いた結果を読み直して壊れていれば元へ戻す - エディタは repo が 1 件なら primary のフォルダ、2 件以上なら entrypoint が 書き出した multi-root workspace を開く。自動オープンの有効判定は project.yml の open_editor > グローバル .env の DEVBASE_OPEN_EDITOR の順 - 旧 GIT_REPO / WORK_DIR / CONTAINER_SCALE を読む経路を削除した。project.yml が 無いプロジェクトは移行手順を案内するエラーで停止する (後方互換なし) Co-Authored-By: Claude Opus 5 (1M context) * fix(scale): write_scale が scale 行の行内コメントを消さないようにする `^scale:.*$` の一括置換で `scale: 1 # 並列数` の行内コメントごと 消えており、「コメントを保持する」という関数の契約に反していた。 値部分と行内コメントを別々に捕まえ、値だけを差し替える。 Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- lib/devbase/commands/container.py | 121 ++++++--------- lib/devbase/editor/opener.py | 35 +++-- lib/devbase/env/runtime.py | 4 +- lib/devbase/project/runtime.py | 142 ++++++++++++++++++ lib/devbase/utils/config.py | 18 --- lib/devbase/volume/compose.py | 40 +++++ tests/cli/test_project_dispatch.py | 34 +++-- tests/cli/test_project_name_resolution.py | 20 +-- tests/commands/test_container_up_order.py | 10 +- tests/editor/test_opener.py | 64 ++++++-- tests/env/test_runtime.py | 22 +-- tests/project/test_runtime.py | 148 +++++++++++++++++++ tests/volume/test_compose_dev_environment.py | 113 ++++++++++++++ tests/volume/test_compose_secret_env.py | 2 +- 14 files changed, 615 insertions(+), 158 deletions(-) create mode 100644 lib/devbase/project/runtime.py create mode 100644 tests/project/test_runtime.py create mode 100644 tests/volume/test_compose_dev_environment.py diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index b34ec6f..cfd760b 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -25,7 +25,8 @@ wait_for_containers_ready, ensure_network ) -from devbase.utils.config import get_project_name, get_container_scale +from devbase.utils.config import get_project_name +from devbase.project import runtime as project_runtime logger = get_logger(__name__) @@ -85,13 +86,19 @@ def _inject_secrets(*, required: bool): return _runtime.SecretEnv() -def _generate_compose_for(scale: int, secrets) -> Path: - """機密の内訳を渡してスケール構成を生成する""" +def _generate_compose_for(scale: int, secrets, dev_environment=None) -> Path: + """機密の内訳と devbase 由来の環境変数を渡してスケール構成を生成する。 + + ``dev_environment`` は ``project.yml`` から作った clone プラン等 + (:func:`devbase.project.runtime.container_env`)。dev サービスへ載せることで、 + entrypoint がコンテナ内で複数リポジトリを clone できる。 + """ return generate_scaled_compose( scale, secret_env_names=secrets.names, global_env_names=secrets.global_names, project_env_names=secrets.project_names, + dev_environment=dev_environment, ) @@ -276,18 +283,17 @@ def _load_project_env(env_file: Path) -> None: wrapper (bin/devbase) は cd 後に ``source ./env`` で env を読み込むため、 Python フォールバック経路でも同じ KEY=VALUE を ``os.environ`` に載せて - 変数欠落 (例: project 固有の ``CONTAINER_SCALE``) を防ぐ。 + 変数欠落 (例: project 固有の ``ENABLE_SSH``) を防ぐ。 env は環境変数定義のみを想定したファイル (bin/devbase 冒頭コメント参照) の ため、ここでは ``export`` 接頭辞付き / 無しの単純な ``KEY=VALUE`` 行のみを 解釈する。``#`` コメント・空行は無視し、値の前後のクォートは除去する。 変数参照 (``$VAR`` / ``${VAR}``) は shell ``source ./env`` (wrapper 経路) と - 同様に展開する。実 env が ``WORK_DIR=/work/$GIT_REPO`` のように同一ファイル内で - 先に定義した変数を参照しており、展開しないと TUI (``list``) 経路でワークスペース - パスが ``$GIT_REPO`` 等の未展開文字列のまま VS Code で開いてしまうため - (行は file 順に ``os.environ`` へ載せるので、参照時には先行行の値が解決済み)。 - 単一引用符 ``'...'`` の値は shell 同様リテラル扱いで展開しない。 + 同様に展開する。``FOO=$BAR/baz`` のように同一ファイル内で先に定義した変数を + 参照する書き方を wrapper 経路と揃えるため (行は file 順に ``os.environ`` へ + 載せるので、参照時には先行行の値が解決済み)。単一引用符 ``'...'`` の値は + shell 同様リテラル扱いで展開しない。 .. note:: shell ``source`` との仕様乖離について @@ -323,10 +329,9 @@ def _load_project_env(env_file: Path) -> None: if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): single_quoted = value[0] == "'" value = value[1:-1] - # shell `source ./env` 相当の変数展開 ($VAR / ${VAR}) を行う。実 env は - # `WORK_DIR=/work/$GIT_REPO` のように同一ファイル内で先に定義した変数を - # 参照しており (行順に os.environ へ載せるため参照時には解決済み)、展開 - # しないと TUI (list) 経路でワークスペースパスが未展開のまま開いてしまう。 + # shell `source ./env` 相当の変数展開 ($VAR / ${VAR}) を行う。同一ファイル内で + # 先に定義した変数を参照する書き方 (`FOO=$BAR/baz`) を wrapper 経路と揃える + # ため (行順に os.environ へ載せるため参照時には解決済み)。 # 単一引用符はリテラル ($BAR を展開しない) という shell 規則に合わせ、 # `'...'` の場合のみ展開しない。展開は _expand_env_vars に委ね、`$VAR` / # `${VAR}` のみ展開し (未定義は空文字 = shell source 準拠)、`\$` はリテラル @@ -563,11 +568,15 @@ def _resolve_open_index(open_index: Optional[int], scale: int) -> int: def _maybe_open_editor(project_name: str, open_flag: Optional[bool], open_index: Optional[int], scale: int, - compose_file=None) -> None: + config, compose_file=None) -> None: """`up` 完了後に dev コンテナへ接続したエディタを開く ([6/6])。 - 有効判定は ``open_flag`` (CLI ``--open``/``--no-open``) が優先、None なら env - ``DEVBASE_OPEN_EDITOR``。エディタ起動の成否は ``up`` の戻り値に影響させない。 + 有効判定は ``open_flag`` (CLI ``--open``/``--no-open``) が優先、None なら + ``project.yml`` の ``open_editor``、それも無ければ env ``DEVBASE_OPEN_EDITOR``。 + エディタ起動の成否は ``up`` の戻り値に影響させない。 + + 開く対象は ``config`` (``project.yml``) から決める。repo が 1 件なら primary の + フォルダ、2 件以上なら entrypoint が書き出した ``*.code-workspace``。 ``open_index`` は起動済みインスタンス範囲 ``1..scale`` 内である必要がある。 0・負数・``scale`` 超過は存在しないコンテナ URI になり原因不明な起動失敗を招くため、 @@ -579,7 +588,8 @@ def _maybe_open_editor(project_name: str, open_flag: Optional[bool], """ from devbase.editor import opener - enabled = open_flag if open_flag is not None else opener.is_open_enabled() + enabled = (open_flag if open_flag is not None + else opener.is_open_enabled(config=config)) if not enabled: return @@ -591,13 +601,18 @@ def _maybe_open_editor(project_name: str, open_flag: Optional[bool], compose_file = _SCALE_COMPOSE_FILE dev_service_name = get_dev_service_name() - workdir = opener.resolve_workdir(os.environ, project_name) + workdir = config.resolved_work_dir() + # repo が 2 件以上なら multi-root workspace を開く (entrypoint が同じパスへ + # ファイルを書き出している)。1 件なら従来どおりフォルダを開く。 + workspace = (project_runtime.workspace_path(project_name) + if len(config.repos) > 1 else None) logger.info("[6/6] Opening editor attached to the dev container...") try: opener.open_editor( project_name=project_name, dev_service_name=dev_service_name, workdir=workdir, + workspace=workspace, index=open_index, compose_file=compose_file, ) @@ -612,8 +627,11 @@ def cmd_up(project_name: str = None, scale: int = None, if project_name is None: project_name = get_project_name() + # project.yml が唯一の正 (PLAN32)。読めなければ移行手順を案内して止まる。 + config = project_runtime.current_project_config() + if scale is None: - scale = get_container_scale() + scale = config.scale if config.scale is not None else project_runtime.DEFAULT_SCALE dev_service_name = get_dev_service_name() @@ -653,7 +671,9 @@ def cmd_up(project_name: str = None, scale: int = None, # にしないため。 with _previous_scale_compose() as down_compose_file: logger.info("[2/6] Generating scaled compose file...") - override_file = _generate_compose_for(scale, _inject_secrets(required=True)) + override_file = _generate_compose_for( + scale, _inject_secrets(required=True), + dev_environment=project_runtime.container_env(config, project_name)) logger.info("Generated: %s", override_file) logger.info("[3/6] Stopping existing containers...") @@ -676,7 +696,7 @@ def cmd_up(project_name: str = None, scale: int = None, _run_deploy_script_for_instances(deploy_script, range(1, scale + 1)) _maybe_open_editor(project_name, open_editor, open_index, scale, - compose_file=override_file) + config, compose_file=override_file) logger.info("=== Deploy completed successfully ===") return 0 @@ -763,8 +783,10 @@ def cmd_scale(new_scale: int, project_name: str = None) -> int: if project_name is None: project_name = get_project_name() + config = project_runtime.current_project_config() dev_service_name = get_dev_service_name() - current_scale = _get_current_scale() + current_scale = (config.scale if config.scale is not None + else project_runtime.DEFAULT_SCALE) logger.info("Scaling project '%s' from %d to %d containers (dev service: %s)", project_name, current_scale, new_scale, dev_service_name) @@ -779,9 +801,9 @@ def cmd_scale(new_scale: int, project_name: str = None) -> int: return 1 try: - logger.info("[1/5] Updating env file: CONTAINER_SCALE=%d -> %d...", current_scale, new_scale) - if not _update_scale_in_env(new_scale): - return 1 + 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) @@ -791,7 +813,8 @@ def cmd_scale(new_scale: int, project_name: str = None) -> int: logger.info("[3/5] Generating scaled compose file...") override_file = _generate_compose_for( - new_scale, _inject_secrets(required=True)) + new_scale, _inject_secrets(required=True), + dev_environment=project_runtime.container_env(config, project_name)) logger.info("Generated: %s", override_file) logger.info("[4/5] Starting new containers (%d..%d)...", current_scale + 1, new_scale) @@ -1392,49 +1415,3 @@ def _mark_pulled(image_name: str) -> None: marker.touch() except OSError as e: logger.warning("Could not write pull marker for '%s': %s", image_name, e) - - -def _update_scale_in_env(new_scale: int) -> bool: - """Update CONTAINER_SCALE value in env file""" - env_file = Path('./env') - - if not env_file.exists(): - logger.error("env file not found: %s", env_file) - return False - - def _is_scale_line(line: str) -> bool: - return line.strip().startswith('CONTAINER_SCALE=') - - try: - lines = env_file.read_text().splitlines(keepends=True) - new_lines = [ - f'CONTAINER_SCALE={new_scale}\n' if _is_scale_line(line) else line - for line in lines - ] - if not any(map(_is_scale_line, lines)): - new_lines.append(f'\n# Added by devbase scale command\nCONTAINER_SCALE={new_scale}\n') - env_file.write_text(''.join(new_lines)) - return True - - except Exception as e: - logger.error("Updating env file: %s", e) - return False - - -def _get_current_scale() -> int: - """Get current CONTAINER_SCALE from env file""" - env_file = Path('./env') - - if not env_file.exists(): - return 0 - - try: - with open(env_file, 'r') as f: - for line in f: - if line.strip().startswith('CONTAINER_SCALE='): - value = line.split('=', 1)[1].strip() - return int(value) - except Exception: - pass - - return 0 diff --git a/lib/devbase/editor/opener.py b/lib/devbase/editor/opener.py index 6cfa776..0a9f7d9 100644 --- a/lib/devbase/editor/opener.py +++ b/lib/devbase/editor/opener.py @@ -253,8 +253,15 @@ def detect_context(environ=None, isatty: Optional[bool] = None, ) -def is_open_enabled(environ=None) -> bool: - """``DEVBASE_OPEN_EDITOR`` env が真かどうか (未設定は False)。""" +def is_open_enabled(environ=None, config=None) -> bool: + """エディタを自動で開くかどうか。 + + プロジェクト設定 (``project.yml`` の ``open_editor``) が指定されていればそれを + 採る。未指定なら env ``DEVBASE_OPEN_EDITOR`` (グローバル ``.env`` の既定値) + を見る。どちらも無ければ開かない。 + """ + if config is not None and config.open_editor is not None: + return config.open_editor env = os.environ if environ is None else environ value = env.get("DEVBASE_OPEN_EDITOR") if value is None: @@ -425,23 +432,17 @@ def resolve_container_name(dev_service_name: str, project_name: str, index: int return f"{project_name}-{dev_service_name}-{index}" -def resolve_workdir(environ=None, project_name: Optional[str] = None) -> str: - """コンテナ内で開くワークスペースパス (``/work/$GIT_REPO``) を返す。""" - env = os.environ if environ is None else environ - workdir = env.get("WORK_DIR") - if workdir: - return workdir - repo = env.get("GIT_REPO") or project_name - return f"/work/{repo}" if repo else "/work" - - def resolve_workspace(environ=None) -> Optional[str]: """開く VS Code ワークスペースファイル (``*.code-workspace``) のコンテナ内パス。 ``DEVBASE_WORKSPACE`` env にコンテナ内の絶対パス (例 ``/home/ubuntu/share/work/uttarov2-doc.workspace``) が指定されていればそれを返す。 - 未設定・空文字なら None を返し、呼び出し側 (:func:`open_editor`) は従来どおり - :func:`resolve_workdir` のフォルダを ``--folder-uri`` で開く。 + 未設定・空文字なら None を返し、呼び出し側 (:func:`open_editor`) はフォルダを + ``--folder-uri`` で開く。 + + 複数リポジトリのプロジェクトでは ``devbase up`` が ``project.yml`` から + workspace パスを決めて :func:`open_editor` の ``workspace`` 引数で直接渡す。 + この env はそれを手動で上書きしたい場合の口として残している。 ワークスペースファイルはコンテナ内に実在するパスを指す前提 (attach 先は コンテナ authority のため)。``/home/ubuntu/share`` 等の共有マウント配下に置けば @@ -622,6 +623,7 @@ def _launch(cmd: list, env: dict) -> None: def open_editor(*, project_name: str, dev_service_name: str, workdir: str, + workspace: Optional[str] = None, index: int = 1, compose_file=None, environ=None, isatty: Optional[bool] = None, system: Optional[str] = None, @@ -633,7 +635,8 @@ def open_editor(*, project_name: str, dev_service_name: str, workdir: str, 握り潰して warning にし、``up`` 本体を絶対に失敗させない。``isatty`` / ``system`` / ``ipc_alive`` は :func:`detect_context` への差し替え口 (テスト用)。 ``compose_file`` は実コンテナ名問い合わせ時に起動と同じ override compose を - ``-f`` で渡すため。 + ``-f`` で渡すため。``workspace`` は複数リポジトリ構成で開く + ``*.code-workspace`` のコンテナ内パス (未指定なら env ``DEVBASE_WORKSPACE``)。 """ env = os.environ if environ is None else environ ctx = detect_context(env, isatty=isatty, system=system, ipc_alive=ipc_alive) @@ -681,7 +684,7 @@ def open_editor(*, project_name: str, dev_service_name: str, workdir: str, # DEVBASE_WORKSPACE があれば *.code-workspace をワークスペースとして開く。VS Code は # `--file-uri` に渡したパスが .code-workspace 拡張子なら multi-root ワークスペースとして # 開くため、フォルダを開く `--folder-uri` と URI ターゲット・フラグの両方を切り替える。 - workspace = resolve_workspace(env) + workspace = workspace or resolve_workspace(env) open_target = workspace or workdir uri_flag = "--file-uri" if workspace else "--folder-uri" uri = build_attach_uri(container, open_target, diff --git a/lib/devbase/env/runtime.py b/lib/devbase/env/runtime.py index a8a94e6..429ace5 100644 --- a/lib/devbase/env/runtime.py +++ b/lib/devbase/env/runtime.py @@ -115,8 +115,8 @@ def _project_env_overrides(devbase_root: Path, project: str) -> Dict[str, str]: """プロジェクトの非機密設定 (``projects//env``) による上書き値。 値そのものはファイルから読まず、既に環境変数へ載っているものだけを採用する。 - ``env`` は ``WORK_DIR=/work/$GIT_REPO`` のように同一ファイル内の変数を参照 - するため、起動ラッパー (または ``_load_project_env``) が展開した後の値が + ``env`` は ``APP_ROOT=$APP_HOME/app`` のように同一ファイル内の変数を参照 + できるため、起動ラッパー (または ``_load_project_env``) が展開した後の値が 正しく、ここで生の行を読み直すと未展開の文字列を掴んでしまう。 """ path = Path(devbase_root) / 'projects' / project / 'env' diff --git a/lib/devbase/project/runtime.py b/lib/devbase/project/runtime.py new file mode 100644 index 0000000..ac97007 --- /dev/null +++ b/lib/devbase/project/runtime.py @@ -0,0 +1,142 @@ +"""``project.yml`` をコンテナ・エディタが使う形へ変換する層 (PLAN32)。 + +:mod:`devbase.project.config` が「読んで検証する」までを担い、ここは +「コンテナへ何を渡すか」「エディタに何を開かせるか」「``scale`` をどう書き戻すか」 +という実行時の関心を持つ。 +""" + +from __future__ import annotations + +import base64 +import json +import re +from pathlib import Path +from typing import Any, Dict, Mapping + +from devbase.errors import ConfigError +from devbase.project.config import ( + PROJECT_CONFIG_FILENAME, + ProjectConfig, + config_path, + encode_repo_plan, + load_project_config, +) + +#: ``scale`` 未指定時のコンテナ数 (従来の ``CONTAINER_SCALE`` 既定値と同じ) +DEFAULT_SCALE = 2 + + +def workspace_path(project_name: str) -> str: + """複数 repo をまとめて開く workspace ファイルのコンテナ内パス。""" + return f"/work/{project_name}.code-workspace" + + +def build_workspace_document(config: ProjectConfig) -> Dict[str, Any]: + """VS Code の multi-root workspace ファイル (JSON) の中身を組み立てる。 + + 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]} + + +def container_env(config: ProjectConfig, project_name: str) -> Dict[str, str]: + """dev コンテナへ渡す環境変数を組み立てる。 + + - ``DEVBASE_REPOS``: clone プラン (base64) + - ``DEVBASE_PRIMARY_DIR``: 起動後に ``cd`` する ``/work`` 配下のディレクトリ名 + - ``DEVBASE_WORKSPACE`` / ``DEVBASE_WORKSPACE_B64``: repo が 2 件以上のときだけ。 + 1 件のときは従来どおりフォルダを開かせたいので付けない。 + + 値は base64 と検証済みの名前だけなので、``$`` や改行を含まず compose の + 変数展開に食われない。 + """ + env = { + "DEVBASE_REPOS": encode_repo_plan(config.repos), + "DEVBASE_PRIMARY_DIR": config.primary.dir, + } + if len(config.repos) > 1: + document = json.dumps(build_workspace_document(config), + ensure_ascii=False, indent=2) + env["DEVBASE_WORKSPACE"] = workspace_path(project_name) + env["DEVBASE_WORKSPACE_B64"] = base64.b64encode(document.encode()).decode() + return env + + +# --------------------------------------------------------------------------- +# scale (旧 CONTAINER_SCALE) +# --------------------------------------------------------------------------- + +#: ``scale: 1 # 並列数`` の値部分と行内コメントを別々に捕まえる。 +#: 値だけを差し替えてコメントをそのまま残すため。 +_SCALE_LINE = re.compile(r'^scale:(?P[^#\n]*)(?P#[^\n]*)?$', re.M) +_VERSION_LINE = re.compile(r'^version:.*$', re.M) + + +def _rewrite_scale_line(match: "re.Match[str]", scale: int) -> str: + """``scale`` 行の値だけを差し替え、行内コメントは元の間隔ごと残す。""" + comment = match.group("comment") + if not comment: + return f"scale: {scale}" + value = match.group("value") + gap = value[len(value.rstrip()):] or " " + return f"scale: {scale}{gap}{comment}" + + +def read_scale(project_dir: Path) -> int: + """``project.yml`` の ``scale`` (未指定なら既定値)。""" + config = load_project_config(project_dir) + return config.scale if config.scale is not None else DEFAULT_SCALE + + +def write_scale(project_dir: Path, scale: int) -> None: + """``project.yml`` の ``scale`` を書き換える (無ければ ``version`` の直後へ追加)。 + + YAML を読み直して書き戻すとコメントと並び順が失われるため、行単位で置き換える。 + 書き換えた結果は読み直して検証し、壊れていれば元へ戻す。 + """ + if scale < 1: + raise ConfigError(f"scale は 1 以上の整数です ({scale!r})") + + path = config_path(project_dir) + original = path.read_text(encoding="utf-8") + + if _SCALE_LINE.search(original): + updated = _SCALE_LINE.sub( + lambda m: _rewrite_scale_line(m, scale), original, count=1) + elif _VERSION_LINE.search(original): + updated = _VERSION_LINE.sub( + lambda m: f"{m.group(0)}\nscale: {scale}", original, count=1) + else: + raise ConfigError( + f"{path}: version 行が見つからないため scale を書き込めません") + + path.write_text(updated, encoding="utf-8") + try: + load_project_config(project_dir) + except ConfigError: + path.write_text(original, encoding="utf-8") + raise + + +def current_project_config(project_dir: Path = None) -> ProjectConfig: + """カレントプロジェクト (既定は CWD) の設定を読む。 + + wrapper (``bin/devbase``) と ``_resolve_project_name`` が対象プロジェクトへ + cd 済みである前提。見つからなければ移行手順を含むエラーになる。 + """ + return load_project_config(Path(project_dir or Path.cwd())) + + +__all__ = [ + "DEFAULT_SCALE", + "PROJECT_CONFIG_FILENAME", + "build_workspace_document", + "container_env", + "current_project_config", + "read_scale", + "workspace_path", + "write_scale", +] diff --git a/lib/devbase/utils/config.py b/lib/devbase/utils/config.py index 50640f6..14429bd 100644 --- a/lib/devbase/utils/config.py +++ b/lib/devbase/utils/config.py @@ -4,7 +4,6 @@ from pathlib import Path from typing import Optional -from devbase.errors import ConfigError def get_project_name() -> str: @@ -22,23 +21,6 @@ def get_project_name() -> str: return Path.cwd().name -def get_container_scale() -> int: - """ - Get container scale from environment - - Returns: - Number of containers (default: 2) - """ - scale_str = os.environ.get('CONTAINER_SCALE', '2') - try: - scale = int(scale_str) - if scale < 1: - raise ConfigError("CONTAINER_SCALE must be >= 1") - return scale - except ValueError as e: - raise ConfigError(f"Invalid CONTAINER_SCALE value '{scale_str}': {e}") - - def get_devbase_root() -> Optional[Path]: """ Get devbase root directory from environment diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index 4b8a219..2b9203d 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -259,9 +259,40 @@ def for_targets(self, targets: Iterable[str]) -> List[str]: return [name for name in self.all if name in allowed] +def _apply_dev_environment(service: dict, extra: Mapping[str, str]) -> None: + """dev サービスへ devbase 由来の環境変数を載せる (PLAN32: clone プラン等)。 + + ``environment`` は辞書形と ``KEY=VALUE`` のリスト形の両方が使われるため、 + 元の形を保ったまま追記する。同名キーは devbase 側の値で上書きする + (clone プランはプロジェクト設定から毎回生成される正のため)。 + """ + if not extra: + return + + existing = service.get('environment') + if isinstance(existing, dict): + existing.update(extra) + return + if isinstance(existing, list): + names = set(extra) + kept = [entry for entry in existing + if not (isinstance(entry, str) + and entry.split('=', 1)[0] in names)] + service['environment'] = kept + [f"{k}={v}" for k, v in extra.items()] + return + if existing is None: + service['environment'] = dict(extra) + return + + logger.warning( + "environment の形式 (%s) を解釈できないため、devbase の環境変数を " + "追記できませんでした", type(existing).__name__) + + def _build_dev_instance( dev_service: dict, dev_service_name: str, index: int, secret_env_names: Sequence[str] = (), + dev_environment: Optional[Mapping[str, str]] = None, ) -> dict: """Build the service definition for one scaled dev instance (dev-).""" service = copy.deepcopy(dev_service) @@ -272,6 +303,9 @@ def _build_dev_instance( service.setdefault('init', True) _mask_secret_environment(service, secret_env_names) + # 機密の伏せ字化のあとに載せる。devbase 由来の値 (clone プラン等) は機密では + # なく、そのままコンテナへ渡す必要があるため。 + _apply_dev_environment(service, dev_environment or {}) # Update volume mounts for /persistent/ai and /work ai_volume = get_ai_volume_for_index(index) @@ -287,6 +321,7 @@ def _build_scaled_services( services: dict, dev_service: dict, dev_service_name: str, scale: int, secret_names: Optional[_SecretNames] = None, secret_services: Optional[Mapping[str, Set[str]]] = None, + dev_environment: Optional[Mapping[str, str]] = None, ) -> dict: """Build the services section: non-dev services + dev-1..dev-N instances. @@ -328,6 +363,7 @@ def _build_scaled_services( for i in range(1, scale + 1): scaled_services[f'{dev_service_name}-{i}'] = _build_dev_instance( dev_service, dev_service_name, i, secret_names.all, + dev_environment=dev_environment, ) return scaled_services @@ -436,6 +472,7 @@ def generate_scaled_compose( secret_env_names: Sequence[str] = (), global_env_names: Optional[Sequence[str]] = None, project_env_names: Optional[Sequence[str]] = None, + dev_environment: Optional[Mapping[str, str]] = None, ) -> Path: """ Generate scaled docker-compose file with per-instance volumes @@ -447,6 +484,8 @@ def generate_scaled_compose( secret_env_names: コンテナへ列挙する機密の変数名 (全件) global_env_names: そのうち共通機密 (``$DEVBASE_ROOT/.env``) 由来のキー project_env_names: そのうちプロジェクト機密由来のキー + dev_environment: dev サービスへ載せる devbase 由来の環境変数 + (PLAN32 の clone プラン ``DEVBASE_REPOS`` 等。機密ではない) 非 dev サービスへは、そのサービスが元々 ``env_file`` で参照していた由来の キーだけを列挙する。由来の内訳が渡されない場合 (両方 ``None``) は全キーを @@ -481,6 +520,7 @@ def generate_scaled_compose( services, dev_service, dev_service_name, scale, secret_names=secret_names, secret_services=secret_services, + dev_environment=dev_environment, ), 'volumes': _build_volumes_section(config, scale), 'networks': _build_networks_section(config), diff --git a/tests/cli/test_project_dispatch.py b/tests/cli/test_project_dispatch.py index 679e791..b53e406 100644 --- a/tests/cli/test_project_dispatch.py +++ b/tests/cli/test_project_dispatch.py @@ -353,15 +353,25 @@ def test_lifecycle_propagates_open_args_to_cmd_up(monkeypatch): assert captured == {'open_editor': True, 'open_index': 2} +def _project_config(*repos): + """_maybe_open_editor に渡すプロジェクト設定 (既定は単一 repo)。""" + from devbase.project.config import parse_project_config + return parse_project_config({ + "version": 1, + "defaults": {"owner": "volareinc"}, + "repos": [{"repo": r} for r in (repos or ("carmo",))], + }, source="project.yml") + + def test_maybe_open_editor_disabled_by_default(monkeypatch): """open_flag=None かつ env 未設定なら open_editor を呼ばない。""" from devbase.commands import container from devbase.editor import opener - monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None: False) + monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None, config=None: False) called = [] monkeypatch.setattr(opener, 'open_editor', lambda **kw: called.append(kw) or 'launch') - container._maybe_open_editor('carmo', None, None, 1) + container._maybe_open_editor('carmo', None, None, 1, _project_config()) assert called == [] @@ -369,12 +379,12 @@ def test_maybe_open_editor_flag_overrides_env(monkeypatch): """open_flag=True なら env が False でも開く。""" from devbase.commands import container from devbase.editor import opener - monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None: False) + monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None, config=None: False) called = [] monkeypatch.setattr(opener, 'open_editor', lambda **kw: called.append(kw) or 'launch') monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') - container._maybe_open_editor('carmo', True, 1, 1) + container._maybe_open_editor('carmo', True, 1, 1, _project_config()) assert len(called) == 1 assert called[0]['project_name'] == 'carmo' @@ -383,14 +393,14 @@ def test_maybe_open_editor_failure_does_not_raise(monkeypatch): """open_editor が例外でも _maybe_open_editor は伝播させない (up を倒さない)。""" from devbase.commands import container from devbase.editor import opener - monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None: True) + monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None, config=None: True) monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') def boom(**kw): raise RuntimeError("x") monkeypatch.setattr(opener, 'open_editor', boom) - container._maybe_open_editor('carmo', None, None, 1) # 例外が出なければ OK + container._maybe_open_editor('carmo', None, None, 1, _project_config()) # 例外が出なければ OK @pytest.mark.parametrize('bad_index', [0, -1, 3]) @@ -398,12 +408,12 @@ def test_maybe_open_editor_out_of_range_index_falls_back(monkeypatch, bad_index) """0・負数・scale 超過の index は既定 (1) へフォールバックする (scale=2)。""" from devbase.commands import container from devbase.editor import opener - monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None: True) + monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None, config=None: True) monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') called = [] monkeypatch.setattr(opener, 'open_editor', lambda **kw: called.append(kw) or 'launch') - container._maybe_open_editor('carmo', True, bad_index, 2) + container._maybe_open_editor('carmo', True, bad_index, 2, _project_config()) assert len(called) == 1 assert called[0]['index'] == 1 @@ -412,12 +422,12 @@ def test_maybe_open_editor_valid_index_within_scale(monkeypatch): """範囲内 (1..scale) の index はそのまま使われる。""" from devbase.commands import container from devbase.editor import opener - monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None: True) + monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None, config=None: True) monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') called = [] monkeypatch.setattr(opener, 'open_editor', lambda **kw: called.append(kw) or 'launch') - container._maybe_open_editor('carmo', True, 2, 3) + container._maybe_open_editor('carmo', True, 2, 3, _project_config()) assert called[0]['index'] == 2 @@ -425,11 +435,11 @@ def test_maybe_open_editor_forwards_compose_file(monkeypatch): """compose_file 引数が open_editor まで伝播する (実コンテナ名問い合わせ用)。""" from devbase.commands import container from devbase.editor import opener - monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None: True) + monkeypatch.setattr(opener, 'is_open_enabled', lambda environ=None, config=None: True) monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') called = [] monkeypatch.setattr(opener, 'open_editor', lambda **kw: called.append(kw) or 'launch') - container._maybe_open_editor('carmo', True, 1, 1, + container._maybe_open_editor('carmo', True, 1, 1, _project_config(), compose_file='override.yml') assert called[0]['compose_file'] == 'override.yml' diff --git a/tests/cli/test_project_name_resolution.py b/tests/cli/test_project_name_resolution.py index f6022b8..5523c3a 100644 --- a/tests/cli/test_project_name_resolution.py +++ b/tests/cli/test_project_name_resolution.py @@ -267,26 +267,26 @@ def test_load_project_env_diverges_from_shell_source(tmp_path, monkeypatch): def test_load_project_env_expands_variable_references(tmp_path, monkeypatch): """``$VAR`` / ``${VAR}`` を shell ``source`` 同様に展開する回帰テスト。 - 実 env の ``WORK_DIR=/work/$GIT_REPO`` (同一ファイル内で先に定義した変数を参照) + env は ``APP_ROOT=/srv/$APP_NAME`` のように同一ファイル内で先に定義した変数を参照 が TUI (``list``) 経路で未展開のまま VS Code に渡る不具合の回帰防止。 単一引用符値はリテラル扱いで展開しないことも併せて pin する。 """ - for k in ("GIT_REPO", "WORK_DIR", "WORK_DIR_BRACE", "SINGLE_Q"): + for k in ("APP_NAME", "APP_ROOT", "APP_ROOT_BRACE", "SINGLE_Q"): monkeypatch.delenv(k, raising=False) env_path = tmp_path / "env" env_path.write_text( - "GIT_REPO=adminer\n" - "WORK_DIR=/work/$GIT_REPO\n" # 行順に解決済みの GIT_REPO を展開 - "WORK_DIR_BRACE=/work/${GIT_REPO}\n" # ${VAR} 形式も展開 - "SINGLE_Q='/work/$GIT_REPO'\n" # 単一引用符はリテラル + "APP_NAME=adminer\n" + "APP_ROOT=/srv/$APP_NAME\n" # 行順に解決済みの APP_NAME を展開 + "APP_ROOT_BRACE=/srv/${APP_NAME}\n" # ${VAR} 形式も展開 + "SINGLE_Q='/srv/$APP_NAME'\n" # 単一引用符はリテラル ) container._load_project_env(env_path) - assert os.environ["GIT_REPO"] == "adminer" - assert os.environ["WORK_DIR"] == "/work/adminer" - assert os.environ["WORK_DIR_BRACE"] == "/work/adminer" - assert os.environ["SINGLE_Q"] == "/work/$GIT_REPO" + assert os.environ["APP_NAME"] == "adminer" + assert os.environ["APP_ROOT"] == "/srv/adminer" + assert os.environ["APP_ROOT_BRACE"] == "/srv/adminer" + assert os.environ["SINGLE_Q"] == "/srv/$APP_NAME" def test_load_project_env_escaped_dollar_and_undefined(tmp_path, monkeypatch): diff --git a/tests/commands/test_container_up_order.py b/tests/commands/test_container_up_order.py index a96c850..fac04d3 100644 --- a/tests/commands/test_container_up_order.py +++ b/tests/commands/test_container_up_order.py @@ -27,10 +27,12 @@ def up_harness(tmp_path, monkeypatch): """cmd_up の外部作用をすべてスタブ化し、呼び出し順を記録する。""" monkeypatch.chdir(tmp_path) + # PLAN32: cmd_up は project.yml を唯一の正として読む + (tmp_path / 'project.yml').write_text( + "version: 1\nscale: 1\nrepos:\n - owner: volareinc\n repo: carmo\n") calls: list = [] monkeypatch.setattr(container, 'get_project_name', lambda: 'proj') - monkeypatch.setattr(container, 'get_container_scale', lambda: 1) monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') monkeypatch.setattr(container, '_ensure_env_files', lambda: True) monkeypatch.setattr(container, '_run_pre_up_hook', lambda: True) @@ -56,7 +58,7 @@ def test_generate_precedes_down_and_uses_previous_compose(up_harness, monkeypatc calls = up_harness container._SCALE_COMPOSE_FILE.write_text(OLD_COMPOSE) - def fake_generate(scale, secrets): + def fake_generate(scale, secrets, dev_environment=None): calls.append(('generate', scale)) container._SCALE_COMPOSE_FILE.write_text(NEW_COMPOSE) return container._SCALE_COMPOSE_FILE @@ -95,7 +97,7 @@ def test_generation_failure_restores_previous_compose(up_harness, monkeypatch): calls = up_harness container._SCALE_COMPOSE_FILE.write_text(OLD_COMPOSE) - def half_written(scale, secrets): + def half_written(scale, secrets, dev_environment=None): container._SCALE_COMPOSE_FILE.write_text('services:\n dev-1:') raise DevbaseError('compose.yml が壊れています') @@ -114,7 +116,7 @@ def test_first_run_without_previous_compose(up_harness, monkeypatch): calls = up_harness assert not container._SCALE_COMPOSE_FILE.exists() - def fake_generate(scale, secrets): + def fake_generate(scale, secrets, dev_environment=None): container._SCALE_COMPOSE_FILE.write_text(NEW_COMPOSE) return container._SCALE_COMPOSE_FILE diff --git a/tests/editor/test_opener.py b/tests/editor/test_opener.py index 74bf58c..772274c 100644 --- a/tests/editor/test_opener.py +++ b/tests/editor/test_opener.py @@ -11,6 +11,7 @@ import pytest from devbase.editor import opener +from devbase.project.config import parse_project_config @dataclass @@ -296,6 +297,23 @@ def test_is_open_enabled(value, expected): assert opener.is_open_enabled(env) is expected +@pytest.mark.parametrize("open_editor,env_value,expected", [ + (True, "0", True), # project.yml が env の既定を上書きする + (False, "1", False), + (None, "1", True), # project.yml 未指定なら env (グローバル既定) に従う + (None, None, False), +]) +def test_is_open_enabled_prefers_the_project_config(open_editor, env_value, expected): + config = parse_project_config({ + "version": 1, + "repos": [{"owner": "volareinc", "repo": "carmo"}], + **({} if open_editor is None else {"open_editor": open_editor}), + }, source="project.yml") + env = {} if env_value is None else {"DEVBASE_OPEN_EDITOR": env_value} + + assert opener.is_open_enabled(env, config=config) is expected + + # --------------------------------------------------------------------------- # resolve_editor_cmd # --------------------------------------------------------------------------- @@ -602,18 +620,6 @@ def test_parse_compose_ps_name_empty_and_invalid(): assert opener._parse_compose_ps_name("[]") is None -def test_resolve_workdir_prefers_work_dir_env(): - assert opener.resolve_workdir({"WORK_DIR": "/work/x"}, "y") == "/work/x" - - -def test_resolve_workdir_from_git_repo(): - assert opener.resolve_workdir({"GIT_REPO": "myrepo"}, None) == "/work/myrepo" - - -def test_resolve_workdir_fallback_project_name(): - assert opener.resolve_workdir({}, "proj") == "/work/proj" - - def test_resolve_workspace_none_when_unset(): assert opener.resolve_workspace({}) is None @@ -628,6 +634,40 @@ def test_resolve_workspace_returns_path(): "/home/ubuntu/share/work/uttarov2-doc.workspace" +def test_open_editor_opens_the_given_workspace_as_a_file(monkeypatch, tmp_path): + """複数 repo 構成では workspace ファイルを --file-uri で開く""" + calls = [] + monkeypatch.setattr(opener, "resolve_editor_cmd", lambda env=None: ["code"]) + monkeypatch.setattr(opener, "resolve_container_name", + lambda *a, **kw: "carmo-dev-1") + + action = opener.open_editor( + project_name="carmo", dev_service_name="dev", workdir="/work/carmo", + workspace="/work/carmo.code-workspace", + environ={"TERM": "xterm"}, isatty=True, system="Linux", + launcher=lambda cmd, env: calls.append(cmd), + ) + + assert action == "launch" + assert calls[0][1] == "--file-uri" + assert "carmo.code-workspace" in calls[0][2] + + +def test_open_editor_opens_the_folder_without_a_workspace(monkeypatch): + calls = [] + monkeypatch.setattr(opener, "resolve_editor_cmd", lambda env=None: ["code"]) + monkeypatch.setattr(opener, "resolve_container_name", + lambda *a, **kw: "carmo-dev-1") + + opener.open_editor( + project_name="carmo", dev_service_name="dev", workdir="/work/carmo", + environ={"TERM": "xterm"}, isatty=True, system="Linux", + launcher=lambda cmd, env: calls.append(cmd), + ) + + assert calls[0][1] == "--folder-uri" + + # --------------------------------------------------------------------------- # decide_action (§2.4 マトリクス全分岐) # --------------------------------------------------------------------------- diff --git a/tests/env/test_runtime.py b/tests/env/test_runtime.py index 7dffd53..17c5258 100644 --- a/tests/env/test_runtime.py +++ b/tests/env/test_runtime.py @@ -94,35 +94,35 @@ def test_project_env_overrides_global_for_the_same_key(root, store, monkeypatch) def test_project_env_only_keys_are_not_listed(root, store, monkeypatch): """非機密設定は env_file が直接読むので変数名を列挙しない""" - (root / 'projects' / 'web' / 'env').write_text('GIT_REPO=web\n') - monkeypatch.setenv('GIT_REPO', 'web') + (root / 'projects' / 'web' / 'env').write_text('APP_NAME=web\n') + monkeypatch.setenv('APP_NAME', 'web') store.age.save(GLOBAL, {'TOKEN': 't'}) resolved = runtime.resolve(root, 'web', store=store) assert resolved.names == ['TOKEN'] - assert 'GIT_REPO' not in resolved.values + assert 'APP_NAME' not in resolved.values def test_project_env_value_comes_from_the_environment(root, store, monkeypatch): """展開済みの値を採用する (生の行を読み直さない)""" - (root / 'projects' / 'web' / 'env').write_text('WORK_DIR=/work/$GIT_REPO\n') - monkeypatch.setenv('WORK_DIR', '/work/web') - store.age.save(GLOBAL, {'WORK_DIR': '/work/unset'}) + (root / 'projects' / 'web' / 'env').write_text('APP_ROOT=/srv/$APP_NAME\n') + monkeypatch.setenv('APP_ROOT', '/srv/web') + store.age.save(GLOBAL, {'APP_ROOT': '/srv/unset'}) resolved = runtime.resolve(root, 'web', store=store) - assert resolved.values['WORK_DIR'] == '/work/web' + assert resolved.values['APP_ROOT'] == '/srv/web' def test_project_env_is_ignored_when_not_in_the_environment(root, store, monkeypatch): - monkeypatch.delenv('WORK_DIR', raising=False) - (root / 'projects' / 'web' / 'env').write_text('WORK_DIR=/work/$GIT_REPO\n') - store.age.save(GLOBAL, {'WORK_DIR': '/work/global'}) + monkeypatch.delenv('APP_ROOT', raising=False) + (root / 'projects' / 'web' / 'env').write_text('APP_ROOT=/srv/$APP_NAME\n') + store.age.save(GLOBAL, {'APP_ROOT': '/srv/global'}) resolved = runtime.resolve(root, 'web', store=store) - assert resolved.values['WORK_DIR'] == '/work/global' + assert resolved.values['APP_ROOT'] == '/srv/global' def test_resolve_without_any_secrets_is_empty(root, store): diff --git a/tests/project/test_runtime.py b/tests/project/test_runtime.py new file mode 100644 index 0000000..d08f8af --- /dev/null +++ b/tests/project/test_runtime.py @@ -0,0 +1,148 @@ +"""project.yml をコンテナ・エディタへ渡す形へ変換する層 (PLAN32 Task 2)""" + +from __future__ import annotations + +import base64 +import json + +import pytest + +from devbase.errors import ConfigError +from devbase.project.config import decode_repo_plan, parse_project_config +from devbase.project.runtime import ( + build_workspace_document, + container_env, + read_scale, + workspace_path, + write_scale, +) + + +def config_of(*repos, **top): + return parse_project_config( + {"version": 1, "defaults": {"owner": "volareinc"}, + "repos": [dict(repo=r) if isinstance(r, str) else r for r in repos], + **top}, + source="project.yml") + + +# --------------------------------------------------------------------------- +# コンテナへ渡す環境変数 +# --------------------------------------------------------------------------- + +def test_container_env_carries_the_clone_plan_and_primary_dir(): + env = container_env(config_of("carmo", "carmo-batch"), project_name="carmo") + + entries = decode_repo_plan(env["DEVBASE_REPOS"]) + assert [(e.url, e.dir) for e in entries] == [ + ("https://github.com/volareinc/carmo.git", "carmo"), + ("https://github.com/volareinc/carmo-batch.git", "carmo-batch"), + ] + assert env["DEVBASE_PRIMARY_DIR"] == "carmo" + + +def test_multi_repo_projects_get_a_workspace_file(): + env = container_env(config_of("carmo", "carmo-batch"), project_name="carmo") + + assert env["DEVBASE_WORKSPACE"] == "/work/carmo.code-workspace" + document = json.loads(base64.b64decode(env["DEVBASE_WORKSPACE_B64"]).decode()) + assert document["folders"] == [ + {"name": "carmo", "path": "/work/carmo"}, + {"name": "carmo-batch", "path": "/work/carmo-batch"}, + ] + + +def test_single_repo_projects_open_a_plain_folder(): + """repo が 1 件なら従来どおりフォルダを開く (workspace ファイルを作らない)""" + env = container_env(config_of("carmo"), project_name="carmo") + + assert "DEVBASE_WORKSPACE" not in env + assert "DEVBASE_WORKSPACE_B64" not in env + + +def test_workspace_path_is_derived_from_the_project_name(): + assert workspace_path("carmo") == "/work/carmo.code-workspace" + + +def test_workspace_document_lists_the_primary_repo_first(): + config = config_of("carmo-doc", {"repo": "carmo", "primary": True}) + + document = build_workspace_document(config) + + assert [f["name"] for f in document["folders"]] == ["carmo", "carmo-doc"] + + +def test_container_env_values_are_safe_for_compose(): + """base64 と単純な名前だけなので、compose の変数展開に食われない""" + env = container_env(config_of("carmo", "carmo-batch"), project_name="carmo") + + assert all("$" not in value and "\n" not in value for value in env.values()) + + +# --------------------------------------------------------------------------- +# scale の読み書き (旧 CONTAINER_SCALE) +# --------------------------------------------------------------------------- + +def test_read_scale_uses_the_project_config(tmp_path): + (tmp_path / "project.yml").write_text( + "version: 1\nscale: 3\nrepos:\n - owner: volareinc\n repo: carmo\n") + + assert read_scale(tmp_path) == 3 + + +def test_read_scale_falls_back_to_the_default(tmp_path): + (tmp_path / "project.yml").write_text( + "version: 1\nrepos:\n - owner: volareinc\n repo: carmo\n") + + assert read_scale(tmp_path) == 2 + + +def test_read_scale_reports_a_missing_config(tmp_path): + with pytest.raises(ConfigError, match="project.yml"): + read_scale(tmp_path) + + +def test_write_scale_updates_the_existing_key_and_keeps_comments(tmp_path): + (tmp_path / "project.yml").write_text( + "version: 1\n# 並行開発用のコンテナ数\nscale: 1\nrepos:\n" + " - owner: volareinc\n repo: carmo\n") + + write_scale(tmp_path, 4) + + text = (tmp_path / "project.yml").read_text() + assert "scale: 4" in text + assert "# 並行開発用のコンテナ数" in text + assert read_scale(tmp_path) == 4 + + +def test_write_scale_keeps_an_inline_comment(tmp_path): + (tmp_path / "project.yml").write_text( + "version: 1\nscale: 1 # 並列数\nrepos:\n" + " - owner: volareinc\n repo: carmo\n") + + write_scale(tmp_path, 4) + + # 値だけが差し替わり、行内コメントと間隔がそのまま残ること + assert (tmp_path / "project.yml").read_text().splitlines()[1] == ( + "scale: 4 # 並列数") + assert read_scale(tmp_path) == 4 + + +def test_write_scale_adds_the_key_when_absent(tmp_path): + (tmp_path / "project.yml").write_text( + "version: 1\nrepos:\n - owner: volareinc\n repo: carmo\n") + + write_scale(tmp_path, 2) + + assert read_scale(tmp_path) == 2 + # repos の配下ではなく最上位に書かれること + assert (tmp_path / "project.yml").read_text().splitlines()[1] == "scale: 2" + + +def test_write_scale_rejects_a_broken_result(tmp_path): + (tmp_path / "project.yml").write_text( + "version: 1\nrepos:\n - owner: volareinc\n repo: carmo\n") + + with pytest.raises(ConfigError, match="scale"): + write_scale(tmp_path, 0) + assert "scale" not in (tmp_path / "project.yml").read_text() diff --git a/tests/volume/test_compose_dev_environment.py b/tests/volume/test_compose_dev_environment.py new file mode 100644 index 0000000..e220718 --- /dev/null +++ b/tests/volume/test_compose_dev_environment.py @@ -0,0 +1,113 @@ +"""dev サービスへの追加環境変数の注入 (PLAN32: clone プランの受け渡し)""" + +from __future__ import annotations + +import pytest +import yaml + +from devbase.volume.compose import generate_scaled_compose + + +COMPOSE_DICT_ENV = """services: + dev: + image: alpine + environment: + FEATURE_FLAG: enabled + volumes: + - x:/work + db: + image: mysql +volumes: + x: {} +""" + +COMPOSE_LIST_ENV = """services: + dev: + image: alpine + environment: + - FEATURE_FLAG=enabled + volumes: + - x:/work +volumes: + x: {} +""" + +COMPOSE_NO_ENV = """services: + dev: + image: alpine + volumes: + - x:/work +volumes: + x: {} +""" + +REPO_ENV = {"DEVBASE_REPOS": "cGxhbg==", "DEVBASE_PRIMARY_DIR": "carmo"} + + +@pytest.fixture +def project(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + return tmp_path + + +def generated(project): + return yaml.safe_load((project / ".docker-compose.scale.yml").read_text()) + + +def env_of(service) -> dict: + environment = service.get("environment") + if isinstance(environment, dict): + return environment + return dict(entry.split("=", 1) for entry in environment) + + +def test_dev_instances_receive_the_extra_environment(project): + (project / "compose.yml").write_text(COMPOSE_DICT_ENV) + + generate_scaled_compose(2, dev_environment=REPO_ENV) + + services = generated(project)["services"] + for name in ("dev-1", "dev-2"): + assert env_of(services[name])["DEVBASE_REPOS"] == "cGxhbg==" + assert env_of(services[name])["DEVBASE_PRIMARY_DIR"] == "carmo" + # 元からある値は残す + assert env_of(services[name])["FEATURE_FLAG"] == "enabled" + + +def test_non_dev_services_do_not_receive_it(project): + """clone するのは dev コンテナだけ。他サービスへ余計な変数を増やさない""" + (project / "compose.yml").write_text(COMPOSE_DICT_ENV) + + generate_scaled_compose(1, dev_environment=REPO_ENV) + + db = generated(project)["services"]["db"] + assert "DEVBASE_REPOS" not in (db.get("environment") or {}) + + +def test_list_form_environment_is_supported(project): + (project / "compose.yml").write_text(COMPOSE_LIST_ENV) + + generate_scaled_compose(1, dev_environment=REPO_ENV) + + dev = generated(project)["services"]["dev-1"] + assert env_of(dev) == { + "FEATURE_FLAG": "enabled", + "DEVBASE_REPOS": "cGxhbg==", + "DEVBASE_PRIMARY_DIR": "carmo", + } + + +def test_environment_section_is_created_when_absent(project): + (project / "compose.yml").write_text(COMPOSE_NO_ENV) + + generate_scaled_compose(1, dev_environment=REPO_ENV) + + assert env_of(generated(project)["services"]["dev-1"]) == REPO_ENV + + +def test_without_extra_environment_nothing_is_added(project): + (project / "compose.yml").write_text(COMPOSE_NO_ENV) + + generate_scaled_compose(1) + + assert "environment" not in generated(project)["services"]["dev-1"] diff --git a/tests/volume/test_compose_secret_env.py b/tests/volume/test_compose_secret_env.py index 82894c4..723dd75 100644 --- a/tests/volume/test_compose_secret_env.py +++ b/tests/volume/test_compose_secret_env.py @@ -51,7 +51,7 @@ @pytest.fixture def project(tmp_path, monkeypatch): (tmp_path / 'compose.yml').write_text(COMPOSE) - (tmp_path / 'env').write_text('GIT_REPO=web\n') + (tmp_path / 'env').write_text('APP_NAME=web\n') monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path / 'root')) (tmp_path / 'root').mkdir() monkeypatch.chdir(tmp_path) From cd2225760f7f1641f4c672b239e3cfb72bab6a29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Sun, 23 Aug 2026 01:50:08 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20PLAN32-migrate-cmd=20env=20?= =?UTF-8?q?=E3=81=8B=E3=82=89=20project.yml=20=E3=81=B8=E3=81=AE=E5=A4=89?= =?UTF-8?q?=E6=8F=9B=E3=82=B3=E3=83=9E=E3=83=B3=E3=83=89=20(#107)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(project): env から project.yml への変換コマンドを追加 PLAN32 Task 4。devbase project migrate-config で旧 env 形式 (GIT_USER / GIT_REPO / GIT_HOST / WORK_DIR / CONTAINER_SCALE / DEVBASE_OPEN_EDITOR) を project.yml へ機械的に変換する。配布中のプロジェクト 定義は 3 つの plugin リポジトリに 136 件あり、手で書き換えると取りこぼしが 混じるため。 - 変換対象キーは allowlist で限定し、それ以外 (ENABLE_SSH 等) は env に残す - 既存の project.yml は上書きしない。手で複数 repo 構成へ整えたものを壊さず、 env の旧キー掃除だけ行うので何度実行しても同じ状態に収束する - 旧キーを説明していた直前のコメント行も一緒に落とす。キーだけ消すと何を 説明しているか分からない行が残るため - 全部消えて空になった env にはファイルの役割を書いた雛形を残す (compose が env_file で参照するためファイル自体は消せない) - 生成した YAML はローダで検証してから書き出す - --dry-run で生成内容を確認でき、--projects-dir で devbase へリンクしていない plugin リポジトリ内の projects も直接変換できる Co-Authored-By: Claude Opus 5 (1M context) * fix(project): migrate-config を atomic 書き込みと失敗隔離で堅牢化 cross-review round 1 (codex / gemini) の指摘対応。 - 既存 project.yml が壊れている場合は load_project_config で検出し、env を 一切変更せず failed として返す。旧キーは唯一の復旧元であり、設定を読めない 状態で掃除すると構成が完全に失われるため - project.yml / env を同一ディレクトリの一時ファイルへ書いて os.replace する atomic write に変更。ディスクフルや中断で truncate されると「壊れた project.yml + 旧キーの無い env」から復旧できなくなる。symlink 自体を 置き換えないよう realpath 解決し、既存ファイルのパーミッションを引き継ぐ - migrate_project を薄いガードで包み、OSError / UnicodeDecodeError / yaml.YAMLError / ConfigError を failed の MigrationResult に畳む。136 件の 一括移行で 1 件の I/O・デコード失敗が全体を止めないようにする - _load_yaml は yaml.YAMLError を ConfigError にラップする。env に GIT_REPO="carmo のような閉じられていない引用符があると生成 YAML が壊れ、 その 1 件で一括移行がクラッシュしていた Co-Authored-By: Claude Opus 5 (1M context) * fix(project): migrate-config で文字列値の YAML 暗黙型変換を防ぐ GIT_REPO=123 / GIT_REPO=on のように旧 env では有効な文字列が、生成した project.yml では YAML 1.1 の暗黙タグで int / bool / date として読まれ、 ローダの「文字列で指定してください」に当たって移行が失敗していた。 host / owner / repo / work_dir は yaml.safe_dump にスカラー出力を任せ、 引用が必要な値だけを引用する。carmo-web のような通常の値は素のままなので、 既に移行済みのファイルと生成物の見た目は変わらない。 併せて、GIT_REPO="carmo のように env 側の引用符が閉じていない値は、 引用符込みのリポジトリ名として通ってしまわないよう malformed な env として failed に倒す。 Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- lib/devbase/cli.py | 20 ++ lib/devbase/commands/project.py | 54 +++ lib/devbase/project/migrate.py | 326 ++++++++++++++++++ tests/commands/test_project_migrate_config.py | 84 +++++ tests/project/test_migrate.py | 284 +++++++++++++++ 5 files changed, 768 insertions(+) create mode 100644 lib/devbase/project/migrate.py create mode 100644 tests/commands/test_project_migrate_config.py create mode 100644 tests/project/test_migrate.py diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 063e2a5..1371120 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -242,6 +242,21 @@ def _add_project_parser(subparsers): # 取らない (wrapper の _PROJECT_NAME_SUBCOMMANDS にも含めない)。 _add_list_subparser(pj_sub) + # `migrate-config` は旧 env 形式から project.yml への変換 (PLAN32)。lifecycle + # ではないため wrapper の _PROJECT_NAME_SUBCOMMANDS には含めない。 + pj_migrate = pj_sub.add_parser( + 'migrate-config', + help='Convert legacy env (GIT_USER/GIT_REPO/...) into project.yml') + pj_migrate.add_argument('names', nargs='*', metavar='NAME', + help='Limit to the given projects (default: all)') + pj_migrate.add_argument('--dry-run', action='store_true', + help='Show what would change without writing') + # plugin リポジトリには devbase へリンクしていない projects/ もある + # (`repos///projects`)。一括移行のため直接指定できるようにする。 + pj_migrate.add_argument('--projects-dir', metavar='DIR', default=None, + help='Directory holding the projects ' + '(default: $DEVBASE_ROOT/projects)') + def _add_list_subparser(sub): """`list` サブコマンドを登録する (project list / top-level list 共通)。 @@ -762,6 +777,11 @@ def _dispatch(cmd, args): devbase_root = _require_devbase_root() from devbase.commands.project import cmd_project_list return cmd_project_list(devbase_root, args) + # `project migrate-config` も lifecycle ではなく projects/ 全体の変換。 + if getattr(args, 'subcommand', None) == 'migrate-config': + devbase_root = _require_devbase_root() + from devbase.commands.project import cmd_project_migrate_config + return cmd_project_migrate_config(devbase_root, args) from devbase.commands.container import cmd_project return cmd_project(args) diff --git a/lib/devbase/commands/project.py b/lib/devbase/commands/project.py index 37af37c..c7d6c1d 100644 --- a/lib/devbase/commands/project.py +++ b/lib/devbase/commands/project.py @@ -13,6 +13,7 @@ from __future__ import annotations import os +import sys from pathlib import Path from devbase.log import get_logger @@ -169,3 +170,56 @@ def cmd_project_list(devbase_root: Path, args) -> int: from devbase.tui import run as tui_run return tui_run(Path(devbase_root), args) + + +def cmd_project_migrate_config(devbase_root: Path, args) -> int: + """`devbase project migrate-config [name...] [--dry-run]` (PLAN32)。 + + 旧 ``env`` 形式 (``GIT_USER`` / ``GIT_REPO`` 等) を ``project.yml`` へ変換する。 + ``projects/`` は plugin リポジトリへの symlink なので、書き換わるのは + plugin リポジトリ側の実体ファイルになる。どのパスを触ったかを必ず表示する。 + + ``--projects-dir`` で別のディレクトリを指定できる。plugin リポジトリには + devbase へリンクしていない projects (``repos///projects``) も + あり、それらを一括で移行するため。 + """ + from devbase.project.migrate import migrate_project, migrate_projects + + override = getattr(args, 'projects_dir', None) + projects_dir = Path(override) if override else Path(devbase_root) / 'projects' + if not projects_dir.is_dir(): + print(f"projects ディレクトリがありません: {projects_dir}", file=sys.stderr) + return 1 + + names = list(getattr(args, 'names', None) or []) + dry_run = bool(getattr(args, 'dry_run', False)) + + if names: + missing = [name for name in names if not (projects_dir / name).is_dir()] + if missing: + print(f"プロジェクトが見つかりません: {', '.join(missing)}", + file=sys.stderr) + return 1 + results = [migrate_project(projects_dir / name, dry_run=dry_run) + for name in names] + else: + results = migrate_projects(projects_dir, dry_run=dry_run) + + if dry_run: + print("=== dry-run: ファイルは書き換えません ===") + + counts = {} + for result in results: + counts[result.status] = counts.get(result.status, 0) + 1 + detail = f" — {result.reason}" if result.reason else "" + print(f"[{result.status}] {result.name} ({result.path}){detail}") + if dry_run and result.project_yml: + print(_indent(result.project_yml)) + + print("--- " + " / ".join(f"{status}={count}" + for status, count in sorted(counts.items()))) + return 1 if counts.get('failed') else 0 + + +def _indent(text: str, prefix: str = ' ') -> str: + return ''.join(prefix + line + '\n' for line in text.splitlines()) diff --git a/lib/devbase/project/migrate.py b/lib/devbase/project/migrate.py new file mode 100644 index 0000000..b29b303 --- /dev/null +++ b/lib/devbase/project/migrate.py @@ -0,0 +1,326 @@ +"""旧 ``env`` 形式から ``project.yml`` への移行 (PLAN32)。 + +PLAN32 で ``GIT_USER`` / ``GIT_REPO`` / ``GIT_HOST`` / ``WORK_DIR`` / +``CONTAINER_SCALE`` / ``DEVBASE_OPEN_EDITOR`` は ``project.yml`` へ移り、``env`` は +「コンテナへ渡す環境変数」だけを持つ。配布中のプロジェクト定義は 3 つの plugin +リポジトリに 136 件あり、手で書き換えると取りこぼしが混じるため機械的に変換する。 + +``env`` から読むキーは allowlist で限定し、それ以外 (``ENABLE_SSH`` 等) は +``env`` にそのまま残す。既に ``project.yml`` があるプロジェクトは**上書きしない** +(手で複数 repo 構成へ整えたものを壊さないため)。``env`` の旧キー掃除だけは行う +ので、何度実行しても同じ状態に収束する。 +""" + +from __future__ import annotations + +import os +import re +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Tuple + +import yaml + +from devbase.errors import ConfigError +from devbase.log import get_logger +from devbase.project.config import ( + PROJECT_CONFIG_FILENAME, + load_project_config, + parse_project_config, +) + +logger = get_logger(__name__) + +#: ``project.yml`` へ移すキー (これ以外は env に残す) +MIGRATED_KEYS: Tuple[str, ...] = ( + "GIT_HOST", "GIT_USER", "GIT_REPO", "WORK_DIR", + "CONTAINER_SCALE", "DEVBASE_OPEN_EDITOR", +) + +#: ``project.yml`` へ**文字列スカラー**として書き出すキー (残りは数値・真偽値) +_STRING_KEYS: Tuple[str, ...] = ("GIT_HOST", "GIT_USER", "GIT_REPO", "WORK_DIR") + +_DEFAULT_HOST = "github.com" +_TRUTHY = {"1", "true", "yes", "on"} + +#: 旧キーを全部落として空になった env に残す説明 (compose が参照するのでファイルは消せない) +_EMPTY_ENV_TEXT = ( + "# コンテナへ渡す環境変数を書く。\n" + "# devbase 自身の設定 (リポジトリ・scale・エディタ) は project.yml にある。\n" +) + +_ASSIGNMENT = re.compile(r'^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$') +_VAR_REF = re.compile(r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)') + + +@dataclass +class MigrationResult: + """1 プロジェクト分の移行結果 (``--dry-run`` でも同じものを返す)。""" + + name: str + path: Path + status: str # migrated / already / skipped / failed + reason: str = "" + project_yml: str = "" + env: str = "" + changed_env: bool = False + + +def migrate_project(project_dir: Path, dry_run: bool = False) -> MigrationResult: + """1 プロジェクトを ``project.yml`` 方式へ移行する。 + + 136 件を 1 回で回すため、**1 件の失敗で全体を止めない**。不正な UTF-8・権限 + エラー・書き込み失敗といった想定内の例外はここで ``failed`` の + :class:`MigrationResult` に畳み、残りのプロジェクトの移行と集計を続行する。 + """ + project_dir = Path(project_dir) + try: + return _migrate_project(project_dir.resolve(), dry_run=dry_run) + except (OSError, UnicodeDecodeError, yaml.YAMLError, ConfigError) as e: + logger.warning("migrate-config: %s の移行に失敗しました: %s", project_dir, e) + return MigrationResult(project_dir.name, project_dir, "failed", reason=str(e)) + + +def _migrate_project(project_dir: Path, dry_run: bool) -> MigrationResult: + """:func:`migrate_project` の本体 (例外はそのまま送出し、呼び出し側で畳む)。""" + name = project_dir.name + env_path = project_dir / "env" + config_path = project_dir / PROJECT_CONFIG_FILENAME + + if not env_path.is_file(): + return MigrationResult(name, project_dir, "skipped", + reason=f"env ファイルがありません ({env_path})") + + env_text = env_path.read_text(encoding="utf-8") + values = _parse_env(env_text) + new_env_text = _strip_migrated_keys(env_text) + env_changed = new_env_text != env_text + + if config_path.is_file(): + # 既存 project.yml が壊れている / 読めない場合は env を触らない。旧キーは + # 唯一の復旧元なので、設定を読めない状態で掃除すると構成が完全に消える。 + try: + load_project_config(project_dir) + except ConfigError as e: + return MigrationResult( + name, project_dir, "failed", + reason=(f"既存 {PROJECT_CONFIG_FILENAME} を読めないため env を" + f"変更しませんでした: {e}")) + if env_changed and not dry_run: + _atomic_write(env_path, new_env_text) + return MigrationResult( + name, project_dir, "already", + reason=f"{PROJECT_CONFIG_FILENAME} が既にあります (上書きしません)", + env=new_env_text, changed_env=env_changed) + + missing = [key for key in ("GIT_USER", "GIT_REPO") if not values.get(key)] + if missing: + return MigrationResult( + name, project_dir, "skipped", + reason=f"env に {' / '.join(missing)} がないため変換できません") + + # host / owner / repo / 作業ディレクトリに引用符が残るのは、``GIT_REPO="carmo`` + # のように env 側の引用符が閉じていない場合だけ。YAML では正しく引用して + # 書けてしまう (= 引用符込みのリポジトリ名として通ってしまう) ため、 + # 生成前に malformed な env として弾く。 + quoted = [key for key in _STRING_KEYS + if any(c in values.get(key, "") for c in "\"'")] + if quoted: + return MigrationResult( + name, project_dir, "failed", + reason=(f"env の {' / '.join(quoted)} の引用符が閉じていません " + "(値に引用符が残っています)")) + + document = _build_project_yml(values) + try: + parse_project_config(_load_yaml(document, config_path), + source=str(config_path)) + except ConfigError as e: + return MigrationResult(name, project_dir, "failed", + reason=str(e), project_yml=document) + + if not dry_run: + # project.yml を完全に永続化してから env を掃除する。逆順や非 atomic な + # 書き込みだと、中断・ディスクフル時に「壊れた project.yml + 旧キーの無い + # env」が残り、再実行しても復旧できなくなる。 + _atomic_write(config_path, document) + if env_changed: + _atomic_write(env_path, new_env_text) + + return MigrationResult(name, project_dir, "migrated", + project_yml=document, env=new_env_text, + changed_env=env_changed) + + +def migrate_projects(projects_dir: Path, dry_run: bool = False) -> List[MigrationResult]: + """``projects/`` 配下の全プロジェクトを移行する (名前順)。 + + ``projects/`` は plugin リポジトリへの symlink であることが多い。 + :func:`migrate_project` が実体パスへ解決するため、書き換わるのは plugin + リポジトリ側のファイルになる (そこが定義の正であるため意図どおり)。 + """ + projects_dir = Path(projects_dir) + entries = sorted( + (entry for entry in projects_dir.iterdir() if entry.is_dir()), + key=lambda entry: entry.name) + return [migrate_project(entry, dry_run=dry_run) for entry in entries] + + +# --------------------------------------------------------------------------- +# 内部 +# --------------------------------------------------------------------------- + +def _yaml_scalar(value: str) -> str: + """文字列を YAML の暗黙型変換に食われないスカラーとして書き出す。 + + ``GIT_REPO=123`` / ``GIT_REPO=on`` / ``GIT_REPO=2026-08-22`` は env では + ただの文字列だが、素で埋め込むと YAML 1.1 の暗黙タグで int / bool / date に + なり、ローダの「文字列で指定してください」で移行が失敗する。 + ``yaml.safe_dump`` に判断を任せることで、引用が要る値だけが引用され、 + ``carmo-web`` のような通常の値は素のまま (= 生成物の見た目は変わらない)。 + """ + return yaml.safe_dump( + value, default_flow_style=True, width=10 ** 6, allow_unicode=True, + ).strip().removesuffix("...").strip() + + +def _load_yaml(text: str, source: Path) -> dict: + """生成した YAML を読み戻す。壊れていたら :class:`ConfigError` に揃える。 + + ``env`` に ``CONTAINER_SCALE="1`` のような閉じられていない引用符があると、値が + そのまま YAML へ流れて ``yaml.YAMLError`` になる。ローダと同じ + :class:`ConfigError` に変換して、その 1 件だけを ``failed`` に倒す。 + """ + try: + data = yaml.safe_load(text) + except yaml.YAMLError as e: + raise ConfigError(f"{source} 用に生成した YAML を解釈できません: {e}") from e + if not isinstance(data, dict): + raise ConfigError( + f"{source} 用に生成した YAML がマッピングになりません " + "(env の値に YAML の構文が混ざっている可能性があります)") + return data + + +def _atomic_write(path: Path, text: str) -> None: + """同一ディレクトリの一時ファイルへ書いてから ``os.replace`` で差し替える。 + + 直接 ``write_text`` すると、ディスクフルや中断でファイルが truncate された + まま残りうる。移行では ``project.yml`` が壊れたまま ``env`` の旧キーだけが + 消えると復旧元が無くなるため、読み手からは常に旧内容か新内容のどちらかしか + 見えない atomic な差し替えにする。 + """ + path = Path(os.path.realpath(path)) # symlink 自体を置き換えない + mode = path.stat().st_mode & 0o777 if path.exists() else 0o644 + fd, tmp_name = tempfile.mkstemp( + dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp") + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(tmp_path, mode) + os.replace(tmp_path, path) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise + + +def _parse_env(text: str) -> Dict[str, str]: + """``KEY=VALUE`` を読み、``$VAR`` は先行行の値で展開する (wrapper と同じ規則)。""" + values: Dict[str, str] = {} + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + m = _ASSIGNMENT.match(line) + if not m: + continue + key, raw = m.group(1), m.group(2).strip() + literal = len(raw) >= 2 and raw[0] == raw[-1] == "'" + if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in ("'", '"'): + raw = raw[1:-1] + if not literal: + raw = _VAR_REF.sub( + lambda m: values.get(m.group(1) or m.group(2), ""), raw) + values[key] = raw + return values + + +def _strip_migrated_keys(text: str) -> str: + """移行したキーの行を落とす (直前に付いていた説明コメントも一緒に落とす)。 + + キーを消してコメントだけ残ると、何を説明しているのか分からない行になるため。 + 全部消えて空になった場合は、``env`` の役割を書いた雛形を残す (compose が + ``env_file`` で参照するのでファイル自体は消せない)。 + """ + kept: List[str] = [] + pending_comments: List[str] = [] + + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("#"): + pending_comments.append(line) + continue + if not stripped: + kept.extend(pending_comments) + pending_comments = [] + kept.append(line) + continue + + m = _ASSIGNMENT.match(line) + if m and m.group(1) in MIGRATED_KEYS: + pending_comments = [] # 直前の説明コメントごと落とす + continue + kept.extend(pending_comments) + pending_comments = [] + kept.append(line) + + kept.extend(pending_comments) + body = "\n".join(kept).strip("\n") + if not body.strip(): + return _EMPTY_ENV_TEXT + return body + "\n" + + +def _build_project_yml(values: Dict[str, str]) -> str: + """``env`` の値から ``project.yml`` のテキストを組み立てる。 + + ``yaml.dump`` ではなく手で組み立てるのは、キーの並び順とコメントを人が読む + 順序で固定したいため (この結果を人が編集して repo を足していく)。 + """ + repo = values["GIT_REPO"] + owner = values["GIT_USER"] + host = values.get("GIT_HOST") or _DEFAULT_HOST + lines = [ + "# devbase プロジェクト設定 (PLAN32)", + "# リポジトリを増やすときは repos に要素を足す。", + "version: 1", + ] + + scale = values.get("CONTAINER_SCALE") + if scale: + lines.append(f"scale: {scale}") + + open_editor = values.get("DEVBASE_OPEN_EDITOR") + if open_editor: + enabled = open_editor.strip().lower() in _TRUTHY + lines.append(f"open_editor: {'true' if enabled else 'false'}") + + work_dir = values.get("WORK_DIR") + if work_dir and work_dir != f"/work/{repo}": + lines.append(f"work_dir: {_yaml_scalar(work_dir)}") + + lines.append("repos:") + if host != _DEFAULT_HOST: + lines.append(f" - host: {_yaml_scalar(host)}") + lines.append(f" owner: {_yaml_scalar(owner)}") + else: + lines.append(f" - owner: {_yaml_scalar(owner)}") + lines.append(f" repo: {_yaml_scalar(repo)}") + return "\n".join(lines) + "\n" + + +__all__ = ["MIGRATED_KEYS", "MigrationResult", "migrate_project", "migrate_projects"] diff --git a/tests/commands/test_project_migrate_config.py b/tests/commands/test_project_migrate_config.py new file mode 100644 index 0000000..c7125eb --- /dev/null +++ b/tests/commands/test_project_migrate_config.py @@ -0,0 +1,84 @@ +"""`devbase project migrate-config` (PLAN32 Task 4)""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from devbase.commands.project import cmd_project_migrate_config + +LEGACY_ENV = ("GIT_USER=volareinc\nGIT_REPO=carmo\nWORK_DIR=/work/$GIT_REPO\n" + "CONTAINER_SCALE=1\nDEVBASE_OPEN_EDITOR=1\n") + + +@pytest.fixture +def devbase_root(tmp_path): + projects = tmp_path / "projects" + projects.mkdir() + for name in ("carmo", "adminer"): + directory = projects / name + directory.mkdir() + (directory / "env").write_text(LEGACY_ENV, encoding="utf-8") + return tmp_path + + +def args(**kw): + return SimpleNamespace(**{"dry_run": False, "names": [], "projects_dir": None, **kw}) + + +def test_migrates_every_project(devbase_root, capsys): + assert cmd_project_migrate_config(devbase_root, args()) == 0 + + for name in ("carmo", "adminer"): + assert (devbase_root / "projects" / name / "project.yml").is_file() + out = capsys.readouterr().out + assert "migrated" in out + + +def test_dry_run_writes_nothing(devbase_root, capsys): + assert cmd_project_migrate_config(devbase_root, args(dry_run=True)) == 0 + + assert not (devbase_root / "projects" / "carmo" / "project.yml").exists() + # 生成される内容を確認できること + assert "version: 1" in capsys.readouterr().out + + +def test_named_projects_only(devbase_root): + cmd_project_migrate_config(devbase_root, args(names=["carmo"])) + + assert (devbase_root / "projects" / "carmo" / "project.yml").is_file() + assert not (devbase_root / "projects" / "adminer" / "project.yml").exists() + + +def test_unknown_project_is_an_error(devbase_root, capsys): + assert cmd_project_migrate_config(devbase_root, args(names=["nope"])) == 1 + assert "nope" in capsys.readouterr().err + + +def test_failed_conversion_is_reported_as_an_error(devbase_root, capsys): + (devbase_root / "projects" / "carmo" / "env").write_text( + "GIT_USER=vol areinc\nGIT_REPO=carmo\n", encoding="utf-8") + + assert cmd_project_migrate_config(devbase_root, args()) == 1 + assert "failed" in capsys.readouterr().out.lower() + + +def test_projects_dir_override(tmp_path, devbase_root): + """plugin リポジトリ内の未リンク projects も直接変換できる""" + plugin_projects = tmp_path / "plugin" / "projects" + (plugin_projects / "appliv").mkdir(parents=True) + (plugin_projects / "appliv" / "env").write_text(LEGACY_ENV, encoding="utf-8") + + assert cmd_project_migrate_config( + devbase_root, args(projects_dir=str(plugin_projects))) == 0 + + assert (plugin_projects / "appliv" / "project.yml").is_file() + assert not (devbase_root / "projects" / "carmo" / "project.yml").exists() + + +def test_missing_projects_dir_is_an_error(devbase_root, tmp_path, capsys): + assert cmd_project_migrate_config( + devbase_root, args(projects_dir=str(tmp_path / "nope"))) == 1 + assert "projects" in capsys.readouterr().err diff --git a/tests/project/test_migrate.py b/tests/project/test_migrate.py new file mode 100644 index 0000000..3718356 --- /dev/null +++ b/tests/project/test_migrate.py @@ -0,0 +1,284 @@ +"""env → project.yml の移行 (PLAN32 Task 4)""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from devbase.project.config import load_project_config +from devbase.project.migrate import migrate_project, migrate_projects + +LEGACY_ENV = """GIT_USER=volareinc +GIT_REPO=carmo +WORK_DIR=/work/$GIT_REPO +CONTAINER_SCALE=1 +# up/list 完了後に dev コンテナへ接続した VS Code を自動で開く (PLAN31_3) +DEVBASE_OPEN_EDITOR=1 +""" + + +def project(tmp_path: Path, name: str = "carmo", env: str = LEGACY_ENV) -> Path: + directory = tmp_path / name + directory.mkdir() + (directory / "env").write_text(env, encoding="utf-8") + return directory + + +def test_creates_project_yml_from_env(tmp_path): + directory = project(tmp_path) + + result = migrate_project(directory) + + assert result.status == "migrated" + config = load_project_config(directory) + assert [(r.host, r.owner, r.repo, r.dir) for r in config.repos] == [ + ("github.com", "volareinc", "carmo", "carmo")] + assert config.scale == 1 + assert config.open_editor is True + assert config.work_dir is None # 既定 (/work/) と同じなら書かない + + +def test_removes_migrated_keys_from_env(tmp_path): + directory = project(tmp_path, env=LEGACY_ENV + "ENABLE_SSH=true\n") + + migrate_project(directory) + + env_text = (directory / "env").read_text(encoding="utf-8") + assert "ENABLE_SSH=true" in env_text + for key in ("GIT_USER", "GIT_REPO", "WORK_DIR", "CONTAINER_SCALE", + "DEVBASE_OPEN_EDITOR"): + assert key not in env_text + + +def test_drops_the_comment_that_documented_a_removed_key(tmp_path): + directory = project(tmp_path) + + migrate_project(directory) + + assert "PLAN31_3" not in (directory / "env").read_text(encoding="utf-8") + + +def test_keeps_the_env_file_even_when_it_becomes_empty(tmp_path): + """compose が env_file で参照するため、空になってもファイルは残す""" + directory = project(tmp_path) + + migrate_project(directory) + + env_file = directory / "env" + assert env_file.is_file() + assert env_file.read_text(encoding="utf-8").strip().startswith("#") + + +def test_non_default_host_and_work_dir_are_kept(tmp_path): + directory = project(tmp_path, env=( + "GIT_USER=uttaro_dev\nGIT_REPO=uttarov2\nGIT_HOST=gitlab.com\n" + "WORK_DIR=/work/$GIT_REPO/src\nCONTAINER_SCALE=1\n")) + + migrate_project(directory) + + config = load_project_config(directory) + assert config.repos[0].host == "gitlab.com" + assert config.work_dir == "/work/uttarov2/src" + + +def test_open_editor_off_is_preserved(tmp_path): + directory = project(tmp_path, env=( + "GIT_USER=volareinc\nGIT_REPO=carmo\nDEVBASE_OPEN_EDITOR=0\n")) + + migrate_project(directory) + + assert load_project_config(directory).open_editor is False + + +def test_absent_optional_keys_are_not_written(tmp_path): + directory = project(tmp_path, env="GIT_USER=volareinc\nGIT_REPO=carmo\n") + + migrate_project(directory) + + config = load_project_config(directory) + assert config.scale is None + assert config.open_editor is None + + +def test_dry_run_changes_nothing(tmp_path): + directory = project(tmp_path) + + result = migrate_project(directory, dry_run=True) + + assert result.status == "migrated" + assert result.project_yml.startswith("#") or "version: 1" in result.project_yml + assert not (directory / "project.yml").exists() + assert (directory / "env").read_text(encoding="utf-8") == LEGACY_ENV + + +def test_running_twice_is_a_no_op(tmp_path): + directory = project(tmp_path) + migrate_project(directory) + first = (directory / "project.yml").read_text(encoding="utf-8") + + result = migrate_project(directory) + + assert result.status == "already" + assert (directory / "project.yml").read_text(encoding="utf-8") == first + + +def test_existing_project_yml_is_never_overwritten(tmp_path): + """手で整えた設定 (複数 repo 等) を移行が壊さない""" + directory = project(tmp_path) + (directory / "project.yml").write_text( + "version: 1\nrepos:\n - owner: volareinc\n repo: carmo\n" + " - owner: volareinc\n repo: carmo-batch\n", encoding="utf-8") + + result = migrate_project(directory) + + assert result.status == "already" + assert len(load_project_config(directory).repos) == 2 + # env に残っていた旧キーは掃除される + assert "GIT_REPO" not in (directory / "env").read_text(encoding="utf-8") + + +def test_project_without_repo_keys_is_skipped(tmp_path): + directory = project(tmp_path, env="ENABLE_SSH=true\n") + + result = migrate_project(directory) + + assert result.status == "skipped" + assert "GIT_USER" in result.reason + assert not (directory / "project.yml").exists() + + +def test_missing_env_file_is_skipped(tmp_path): + directory = tmp_path / "carmo" + directory.mkdir() + + result = migrate_project(directory) + + assert result.status == "skipped" + + +def test_generated_config_is_validated(tmp_path): + """検証に通らない値 (空白混じり等) は書き出さずに失敗として報告する""" + directory = project(tmp_path, env="GIT_USER=vol areinc\nGIT_REPO=carmo\n") + + result = migrate_project(directory) + + assert result.status == "failed" + assert not (directory / "project.yml").exists() + + +def test_migrate_projects_walks_every_project(tmp_path): + projects = tmp_path / "projects" + projects.mkdir() + for name in ("a", "b"): + project(projects, name) + (projects / "c").mkdir() # env なし → skipped + + results = migrate_projects(projects) + + assert {r.name: r.status for r in results} == { + "a": "migrated", "b": "migrated", "c": "skipped"} + assert load_project_config(projects / "a").repos[0].repo == "carmo" + + +def test_migrate_projects_follows_symlinks_to_the_plugin_repo(tmp_path): + """projects/ は plugin repo への symlink。実体側を書き換える""" + plugin_repo = tmp_path / "plugin-repo" + plugin_repo.mkdir() + real = project(plugin_repo) + projects = tmp_path / "projects" + projects.mkdir() + (projects / "carmo").symlink_to(real, target_is_directory=True) + + results = migrate_projects(projects) + + assert [r.status for r in results] == ["migrated"] + assert (real / "project.yml").is_file() + assert not (projects / "carmo" / "project.yml").is_symlink() + + +def test_string_values_that_look_like_yaml_scalars_stay_strings(tmp_path): + """``GIT_REPO=123`` のように YAML の暗黙型に当たる値も文字列として移行する""" + directory = project( + tmp_path, + env="GIT_HOST=on\nGIT_USER=123\nGIT_REPO=2026\nWORK_DIR=/work/no\n") + + result = migrate_project(directory) + + assert result.status == "migrated" + config = load_project_config(directory) + assert [(r.host, r.owner, r.repo) for r in config.repos] == [ + ("on", "123", "2026")] + assert config.work_dir == "/work/no" + + +def test_plain_values_are_written_without_quotes(tmp_path): + """引用が要らない値は素のまま書く (既に移行済みのファイルと同じ見た目)""" + directory = project(tmp_path) + + document = migrate_project(directory, dry_run=True).project_yml + + assert " - owner: volareinc\n" in document + assert " repo: carmo\n" in document + + +def test_broken_yaml_from_env_fails_only_that_project(tmp_path): + """閉じられていない引用符の env は、その 1 件だけ failed になり一括移行は止まらない""" + projects = tmp_path / "projects" + projects.mkdir() + project(projects, "broken", env='GIT_USER=volareinc\nGIT_REPO="carmo\n') + project(projects, "sound") + + results = migrate_projects(projects) + + assert {r.name: r.status for r in results} == { + "broken": "failed", "sound": "migrated"} + assert not (projects / "broken" / "project.yml").exists() + # 変換できなかった側の env は旧キーを保持する (復旧元を残す) + assert "GIT_REPO" in (projects / "broken" / "env").read_text(encoding="utf-8") + + +def test_unreadable_env_is_reported_as_failed(tmp_path): + """不正な UTF-8 の env が例外のまま伝播して残りの移行を止めない""" + projects = tmp_path / "projects" + projects.mkdir() + bad = projects / "bad" + bad.mkdir() + (bad / "env").write_bytes(b"GIT_USER=vol\xffareinc\nGIT_REPO=carmo\n") + project(projects, "sound") + + results = migrate_projects(projects) + + assert {r.name: r.status for r in results} == { + "bad": "failed", "sound": "migrated"} + + +def test_broken_existing_project_yml_keeps_env_untouched(tmp_path): + """既存 project.yml が壊れているとき env の旧キー (復旧元) は消さない""" + directory = project(tmp_path) + (directory / "project.yml").write_text( + "version: 1\nrepos:\n - owner: volareinc\n", encoding="utf-8") + + result = migrate_project(directory) + + assert result.status == "failed" + assert "GIT_REPO=carmo" in (directory / "env").read_text(encoding="utf-8") + + +def test_writes_are_atomic(tmp_path, monkeypatch): + """書き込み中に落ちても既存ファイルが truncate されない""" + directory = project(tmp_path) + original = (directory / "env").read_text(encoding="utf-8") + + def boom(*args, **kwargs): + raise OSError("No space left on device") + + monkeypatch.setattr("devbase.project.migrate.os.replace", boom) + + result = migrate_project(directory) + + assert result.status == "failed" + assert not (directory / "project.yml").exists() + assert (directory / "env").read_text(encoding="utf-8") == original + # 一時ファイルは後始末される + assert sorted(p.name for p in directory.iterdir()) == ["env"] From ff35563513d45ad22424acecbbebc12639c3d22f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Sun, 23 Aug 2026 02:26:14 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20PLAN32-docs=20=E3=83=89=E3=82=AD?= =?UTF-8?q?=E3=83=A5=E3=83=A1=E3=83=B3=E3=83=88=E3=81=A8=20CHANGELOG=20(#1?= =?UTF-8?q?08)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: project.yml 方式のドキュメントを整備する PLAN32 Task 5。複数リポジトリ構成と project.yml への移行に合わせて利用者向け ドキュメントを更新した。 - docs/user/project-yml.md を新設: スキーマ、複数リポジトリの例、検証される 内容、env との使い分け、旧 env からの移行表、コンテナへの渡り方 - 環境変数ガイド / コンテナ操作ガイド / CLI リファレンス / プラグイン開発の 各所から旧キー (GIT_USER / GIT_REPO / WORK_DIR / CONTAINER_SCALE) の説明を 外し、project.yml とその参照へ置き換え - CLI リファレンスに devbase project migrate-config を追記 - アーキテクチャ解説に project/ モジュールの節を追加 - CHANGELOG に破壊的変更と再ビルドの注意を追記 過去リリース分の CHANGELOG 記述は履歴なので書き換えていない。 Co-Authored-By: Claude Opus 5 (1M context) * docs: レビュー指摘対応 — アンカー切れ修正と env ファイル必須の明記 - repo-backed-projects.md: 見出し変更で切れた `#スケール前提-container_scale1` 参照 2 箇所を `#スケール前提-scale-1` に修正 - quickstart.md: `env` はファイル自体が必須(中身は任意)と見出しから分かるよう変更し、 最小構成のディレクトリツリーへ `project.yml` を追加 - project-yml.md: `env` ファイルが必須である旨を同じ表現に揃える Co-Authored-By: Claude Opus 5 (1M context) * docs: DEVBASE_OPEN_EDITOR は env init の収集対象であることを明記 `devbase env init` の editor コレクター (lib/devbase/env/collectors/editor.py) が `DEVBASE_OPEN_EDITOR` を対話収集し、対話の既定は `1` (有効) である。 「収集対象外」「既定: OFF」という記述は実装と矛盾していたため、収集対象で あることと、OFF に倒れるのはキー自体が未設定のときだけである旨へ修正した。 Co-Authored-By: Claude Opus 5 (1M context) * docs: init.sh の実行頻度と DEVBASE_WORKSPACE の有効範囲を実装に合わせる - project.yml リファレンス: `init` は clone 直後だけでなくコンテナ起動の たびに `./init.sh` を実行することを明記し、`branch` (clone 直後のみ) との タイミング差の表と冪等性の注意を追加 - 環境変数: `DEVBASE_WORKSPACE` が効くのはリポジトリ 1 件の構成だけで、 2 件以上では自動生成した workspace を直接開くため上書きできない旨を明記 - container-operations / plugin-dev quickstart の関連記述も同じ挙動へ揃える Co-Authored-By: Claude Opus 5 (1M context) * docs: 読み込み順序の表からも env の旧用途を外す env の役割を「コンテナへ渡す環境変数」と書き換えたのに、直上の表だけ 「リポジトリ名・コンテナ数等」と旧仕様のままで矛盾していた。 Co-Authored-By: Claude Opus 5 (1M context) * docs: work_dir の有効範囲と検証対象フィールドを正確にする - work_dir はリポジトリ 1 件のときだけ効く (2 件以上では自動生成の multi-root ワークスペースが開かれる) ことを明記 - 空白・制御文字の検証は repos[] の各項目が対象で、work_dir は前後の 空白を落とすだけなので、検証範囲を実装に合わせて限定した Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- CHANGELOG.md | 25 ++++ README.md | 1 + docs/README.md | 3 + docs/developer/architecture.md | 20 +++ docs/plugin-dev/compose-yml-guidelines.md | 28 ++-- docs/plugin-dev/quickstart.md | 73 ++++++++--- docs/plugin-dev/repo-backed-projects.md | 40 +++--- docs/user/cli-reference/02-project.md | 34 ++++- docs/user/container-operations.md | 17 ++- docs/user/environment-variables.md | 26 ++-- docs/user/project-yml.md | 152 ++++++++++++++++++++++ 11 files changed, 350 insertions(+), 69 deletions(-) create mode 100644 docs/user/project-yml.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d401fd..c8080fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,32 @@ ## [Unreleased] +### Changed +- **1 プロジェクト = 1 コンテナ = 複数リポジトリ**に対応しました。プロジェクトが開発対象と + するリポジトリは `projects//project.yml` の `repos` 配列で指定し、すべてが同じ + コンテナの `/work` 配下へ clone されます。リポジトリごとに Git ホスト・オーナー・ + ブランチ・clone 先ディレクトリ名・`init.sh` の実行有無を指定できます。関連する複数 + リポジトリ (本体・ドキュメント・インフラ等) を 1 つの開発環境で横断的に扱えます。 + リポジトリが 2 件以上あるときは、全リポジトリを含む multi-root ワークスペース + `/work/<プロジェクト名>.code-workspace` を生成してエディタで開きます。 + 詳細は [project.yml リファレンス](docs/user/project-yml.md) を参照してください。 +- **プロジェクト設定を `env` から `project.yml` へ移しました (破壊的変更)。** + `GIT_USER` / `GIT_REPO` / `GIT_HOST` / `WORK_DIR` / `CONTAINER_SCALE` / + `DEVBASE_OPEN_EDITOR` は `project.yml` の `repos` / `work_dir` / `scale` / + `open_editor` に移行しました。`env` は「コンテナへ渡す環境変数」だけを持ちます。 + 配列を表現できない環境変数では複数リポジトリを書けないためです。 + `project.yml` を持たないプロジェクトは `devbase up` が移行手順を案内して停止します + (旧形式へ暗黙にフォールバックすると、移行漏れを検出できないため)。 + `devbase project scale N` の書き込み先も `project.yml` の `scale` になります。 + +> **Note:** リポジトリの clone は `entrypoint.sh` で行われ、これはイメージに焼き込まれます。 +> 適用には `devbase build --no-cache` によるベースイメージの再ビルドが必要です。 + ### Added +- **`devbase project migrate-config`** を追加しました。旧 `env` 形式のプロジェクト定義を + `project.yml` へ機械的に変換します。`--dry-run` で変換結果を確認でき、既存の + `project.yml` は上書きしないため何度実行しても同じ状態に収束します。 + 変換対象は上記 6 キーのみで、`ENABLE_SSH` などそれ以外は `env` に残ります。 - **tmux 内では `VSCODE_IPC_HOOK_CLI` が古くても VS Code を自動で開く**ようにしました。 tmux サーバーはセッション作成時の環境変数を保持し続けますが、`update-environment` に 登録した変数は attach のたびに更新されるため、**ペインのシェルは古い値・tmux の diff --git a/README.md b/README.md index c995ce0..d0d486f 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,7 @@ devbaseのコマンドは4つのグループにまとめられています。 | [はじめに](docs/user/getting-started.md) | 前提条件、初回セットアップ、日常ワークフロー | | [CLIリファレンス](docs/user/cli-reference/README.md) | 全コマンドの構文・オプション・使用例(コマンドグループ別) | | [プラグインレジストリ](docs/user/plugin-registries.md) | 公開・社内レジストリの一覧と追加方法 | +| [project.yml リファレンス](docs/user/project-yml.md) | プロジェクト設定(複数リポジトリ・scale・エディタ) | | [環境変数ガイド](docs/user/environment-variables.md) | 3レベル構造、コレクター、ソース同期 | | [環境変数の export/import ガイド](docs/user/env-export-import.md) | バンドル形式・age 暗号化・S3 連携・merge/replace の運用 | | [コンテナ操作ガイド](docs/user/container-operations.md) | ライフサイクル、並行開発、ボリューム構造 | diff --git a/docs/README.md b/docs/README.md index b108925..2cf4ce5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,6 +45,7 @@ graph TD | [はじめに](user/getting-started.md) | 前提条件、初回セットアップ、日常ワークフロー | | [CLI リファレンス](user/cli-reference/README.md) | 全コマンドの構文・オプション・使用例 | | [プラグインレジストリ](user/plugin-registries.md) | 公開・社内レジストリの一覧と追加方法 | +| [project.yml リファレンス](user/project-yml.md) | プロジェクト設定(複数リポジトリ・scale・エディタ) | | [環境変数ガイド](user/environment-variables.md) | 3レベル構造、コレクター、ソース同期 | | [環境変数の暗号化](user/env-encryption.md) | 認証情報を暗号化して保存する / 鍵とチーム共有 / 平文へ戻す | | [コンテナ操作ガイド](user/container-operations.md) | ライフサイクル、並行開発、ボリューム構造 | @@ -101,6 +102,7 @@ docs/ │ │ ├── 04-plugin.md ← plugin グループ │ │ └── 05-snapshot.md ← snapshot グループ │ ├── plugin-registries.md ← プラグインレジストリ +│ ├── project-yml.md ← project.yml リファレンス │ ├── environment-variables.md ← 環境変数ガイド │ ├── env-encryption.md ← 環境変数の暗号化 │ ├── container-operations.md ← コンテナ操作ガイド @@ -128,6 +130,7 @@ docs/ | 環境変数を設定する | [環境変数ガイド](user/environment-variables.md#環境変数の操作) | | 認証情報を暗号化して保存する | [環境変数の暗号化](user/env-encryption.md) | | 複数コンテナで並行開発する | [コンテナ操作ガイド](user/container-operations.md#並行開発) | +| 1 つのコンテナで複数リポジトリを扱う | [project.yml リファレンス](user/project-yml.md#複数リポジトリ) | | データをバックアップ・復元する | [スナップショットガイド](user/snapshot-guide.md) | | エラーが発生した | [トラブルシューティング](user/troubleshooting.md) | | 旧 Orca 接続から移行する | [Orca 削除の移行ガイド](user/orca-removal-migration.md) | diff --git a/docs/developer/architecture.md b/docs/developer/architecture.md index 4e1ef27..963f3bb 100644 --- a/docs/developer/architecture.md +++ b/docs/developer/architecture.md @@ -139,6 +139,26 @@ flowchart LR `snapshot/manager.py` の `SnapshotManager` クラスが全機能を提供する。差分バックアップとフルバックアップに対応し、zstd 圧縮を使用する。 +### project/ -- プロジェクト設定 (project.yml) + +`projects//project.yml` を読み、コンテナとエディタが使える形へ変換する層。 +YAML の解釈をホスト側の Python に閉じ込めることで、コンテナ側 (`entrypoint.sh`) は +復号して 1 行ずつ clone するだけで済み、イメージへ YAML パーサ依存を持ち込まない。 + +| モジュール | 役割 | +|-----------|------| +| `config.py` | `project.yml` の読み込み・`defaults` 継承・検証・正規化 (`ProjectConfig` / `RepoSpec`)。clone プランの符号化 (`encode_repo_plan`) | +| `runtime.py` | コンテナへ渡す環境変数の組み立て (`DEVBASE_REPOS` / `DEVBASE_PRIMARY_DIR` / `DEVBASE_WORKSPACE*`)、multi-root ワークスペース JSON の生成、`scale` の読み書き | +| `migrate.py` | 旧 `env` 形式 (`GIT_USER` / `GIT_REPO` 等) から `project.yml` への変換 | + +```mermaid +flowchart LR + Y["project.yml"] --> C["config.py
検証・正規化"] + C --> R["runtime.py
環境変数へ"] + R --> Compose[".docker-compose.scale.yml
dev サービス"] + Compose --> E["entrypoint.sh
復号して clone"] +``` + ### その他 | ディレクトリ | 役割 | diff --git a/docs/plugin-dev/compose-yml-guidelines.md b/docs/plugin-dev/compose-yml-guidelines.md index 977b92a..4aa7065 100644 --- a/docs/plugin-dev/compose-yml-guidelines.md +++ b/docs/plugin-dev/compose-yml-guidelines.md @@ -71,10 +71,9 @@ flowchart TB G1["共通APIキー"] G2["共有設定値"] end - subgraph level2["レベル2: プロジェクト設定(env)"] - P1["GIT_USER / GIT_REPO"] - P2["WORK_DIR"] - P3["CONTAINER_SCALE"] + subgraph level2["レベル2: プロジェクト環境変数(env)"] + P1["ENABLE_SSH など"] + P2["アプリが読む変数"] end subgraph level3["レベル3: プロジェクト機密(.env)"] S1["プロジェクト固有のAPIキー"] @@ -90,7 +89,7 @@ flowchart TB | レベル | ファイル | Git管理 | 用途 | 例 | |--------|---------|---------|------|-----| | 1 | `${DEVBASE_ROOT}/.env` | No | 全プロジェクト共通の設定 | 共通APIキー | -| 2 | `env` | Yes | プロジェクト固有の設定 | `GIT_REPO`, `CONTAINER_SCALE` | +| 2 | `env` | Yes | コンテナへ渡すプロジェクト固有の環境変数 | `ENABLE_SSH` | | 3 | `.env` | No | プロジェクト固有の機密 | DB接続文字列、秘密鍵 | --- @@ -204,9 +203,9 @@ ports: 例: `CONTAINER_INDEX=1` の場合は `13000:3000`、`CONTAINER_INDEX=2` の場合は `23000:3000` -### 5.3 CONTAINER_SCALE の設定 +### 5.3 コンテナ数(scale)の設定 -`env` ファイルで `CONTAINER_SCALE` を設定すると、`devbase up` 実行時に `.docker-compose.scale.yml` が自動生成され、指定された数のコンテナが起動します。 +`project.yml` の `scale` を設定すると、`devbase up` 実行時に `.docker-compose.scale.yml` が自動生成され、指定された数のコンテナが起動します(既定: 2)。詳細は [project.yml リファレンス](../user/project-yml.md) を参照してください。 --- @@ -241,15 +240,18 @@ networks: external: true ``` -対応する `env` ファイル: +対応する `project.yml`: -```bash -GIT_USER=your-org -GIT_REPO=my-webapp -WORK_DIR=/work/$GIT_REPO -CONTAINER_SCALE=1 +```yaml +version: 1 +scale: 1 +repos: + - owner: your-org + repo: my-webapp ``` +`env` にはコンテナへ渡す環境変数だけを書きます(無くてもファイル自体は残します。`env_file` が参照するため)。 + ### 6.2 データベースを含む構成 基本構成に `db` サービスを追加し、`depends_on` で依存関係を定義します。 diff --git a/docs/plugin-dev/quickstart.md b/docs/plugin-dev/quickstart.md index e607daa..52c102a 100644 --- a/docs/plugin-dev/quickstart.md +++ b/docs/plugin-dev/quickstart.md @@ -53,7 +53,8 @@ my-plugin/ └── projects/ └── my-project/ ├── compose.yml - └── env + ├── project.yml + └── env # 中身は任意だが、ファイルは必須 ``` ### 2.2 compose.yml の作成 @@ -93,26 +94,60 @@ networks: > **補足:** compose.yml の記述ルール詳細は [compose.yml ガイドライン](compose-yml-guidelines.md) を参照してください。 -### 2.3 env ファイルの作成 +### 2.3 project.yml の作成 -`projects/my-project/env` を作成します。このファイルはGit管理対象です。 +`projects/my-project/project.yml` を作成します。このファイルはGit管理対象で、**プロジェクト設定の正**です。 -```bash -GIT_USER=your-github-user -GIT_REPO=my-repo -WORK_DIR=/work/$GIT_REPO -CONTAINER_SCALE=1 -# GitLab等GitHub以外のホストを使う場合: -# GIT_HOST=gitlab.com +```yaml +version: 1 +scale: 1 +repos: + - owner: your-github-user + repo: my-repo ``` -| 変数 | 説明 | +複数のリポジトリを 1 つのコンテナへチェックアウトできます。 + +```yaml +version: 1 +scale: 1 +defaults: + owner: your-github-user +repos: + - repo: my-app # 先頭が primary(ログイン直後の作業ディレクトリ) + - repo: my-app-docs + - repo: my-app-infra + host: gitlab.com # リポジトリごとに Git ホストを変えられる + owner: another-org + dir: infra # /work 配下の clone 先名(既定: repo 名) + branch: develop # clone 後にチェックアウトするブランチ + init: false # 起動のたびの ./init.sh 実行を無効化する +``` + +主なキーは以下のとおりです。全項目は [project.yml リファレンス](../user/project-yml.md) を参照してください。 + +| キー | 説明 | |------|------| -| `GIT_USER` | Gitホストのユーザー名またはOrganization名 | -| `GIT_REPO` | リポジトリ名 | -| `GIT_HOST` | Gitホスト名(デフォルト: `github.com`)。GitLabの場合は `gitlab.com` を指定 | -| `WORK_DIR` | コンテナ内の作業ディレクトリ | -| `CONTAINER_SCALE` | 起動するコンテナ数(デフォルト: 2) | +| `version` | スキーマ版。現在は `1` | +| `repos[].owner` / `repos[].repo` | Git ホストのユーザー名(Organization 名)とリポジトリ名 | +| `repos[].host` | Git ホスト名(既定: `github.com`)。GitLab なら `gitlab.com` | +| `repos[].dir` / `branch` / `init` / `primary` | clone 先ディレクトリ名 / チェックアウトするブランチ(clone 直後のみ) / `init.sh` の実行有無(起動のたびに実行。冪等に書くこと) / 既定の作業リポジトリ | +| `scale` | 起動するコンテナ数(既定: 2) | +| `open_editor` | `devbase up` 後に VS Code を自動で開くか | + +### 2.3.1 env ファイル(ファイルは必須・中身は任意) + +`projects/my-project/env` には、**コンテナへ渡す環境変数**だけを書きます(`ENABLE_SSH` など)。devbase 自身の設定は `project.yml` にあります。 + +```bash +ENABLE_SSH=true +``` + +2.2 の `compose.yml` が `env_file: - env` で参照するため、**ファイルは必ず作成してください**(実在しないと `devbase up` が compose の起動時に失敗します)。渡したい環境変数が無ければ空ファイルで構いません。 + +```bash +touch projects/my-project/env +``` ### 2.4 .env ファイル(任意) @@ -137,12 +172,10 @@ MY_SECRET_API_KEY=sk-xxxxxxxxxxxx # projects/my-project/pre-up set -e -# env から GIT_USER / GIT_REPO を取得 -source ./env - # build context に使うリポジトリが無ければ clone +# (pre-up はホスト側で動くフックなので、clone 先も URL もここに直接書く) if [ ! -d "./repo" ]; then - git clone "https://github.com/${GIT_USER}/${GIT_REPO}.git" repo + git clone "https://github.com/your-github-user/my-repo.git" repo fi ``` diff --git a/docs/plugin-dev/repo-backed-projects.md b/docs/plugin-dev/repo-backed-projects.md index ae1c61a..6a979db 100644 --- a/docs/plugin-dev/repo-backed-projects.md +++ b/docs/plugin-dev/repo-backed-projects.md @@ -4,7 +4,7 @@ リファレンス実装は Laravel Sail ベースの `carmo-system-console` プラグインです。**このリポジトリには含まれません** — 社内向けの private プラグインレジストリで配布されており、`devbase plugin install` 後に `projects/carmo-system-console/`(`projects/` は `.gitignore` 対象)へ展開されます。アクセス権が無い場合でも、本書のコード断片と [チェックリスト](#7-チェックリスト新規に-repo-連携プロジェクトを作るとき) だけでパターンを再現できます。 -> **前提:** ライフサイクルフック自体の基本は [プラグイン開発クイックスタート](quickstart.md#25-ライフサイクルフック任意) を、共有ボリュームや `CONTAINER_SCALE` の一般論は [compose.yml ガイドライン](compose-yml-guidelines.md) と [コンテナ操作ガイド](../user/container-operations.md#並行開発) を参照してください。本書はそれらを組み合わせた「repo 連携」パターンに絞って説明します。 +> **前提:** ライフサイクルフック自体の基本は [プラグイン開発クイックスタート](quickstart.md#25-ライフサイクルフック任意) を、共有ボリュームや `scale` の一般論は [compose.yml ガイドライン](compose-yml-guidelines.md) と [コンテナ操作ガイド](../user/container-operations.md#並行開発) を参照してください。本書はそれらを組み合わせた「repo 連携」パターンに絞って説明します。 --- @@ -28,7 +28,7 @@ graph TD S["リモート git リポジトリ
(volareinc/app 等)"] -->|"pre-up ① clone/pull"| R["ホスト ./repo
(app のビルドコンテキスト)"] S3["S3
env/<env>.env"] -->|"pre-up ② 取得"| E["ホスト ./.env
(compose 変数展開用)"] - R -->|"pre-up ③ populate"| V["共有 work ボリューム
/work/<GIT_REPO>"] + R -->|"pre-up ③ populate"| V["共有 work ボリューム
/work/<リポジトリ名>"] E -->|"pre-up ④ 配置"| V V --> A["app コンテナ /work"] V --> N["nginx コンテナ /work:ro"] @@ -64,9 +64,9 @@ volumes: > **Note:** `pre-up` は子プロセスのため `export DEVBASE_WORK_VOLUME` しても後続の `docker compose up` へは伝播しません。`compose.yml` 側は `${DEVBASE_WORK_VOLUME:-devbase_work_${DEVBASE_INSTANCE_INDEX:-1}}` のフォールバック式で解決し、加えて `pre-up` が同じ値を `.env` に書き出すことで整合を取ります。 -### スケール前提: `CONTAINER_SCALE=1` +### スケール前提: `scale: 1` -**このパターンは scale=1(1 プロジェクト = 1 work ボリューム)を前提としています。** devbase の既定は `CONTAINER_SCALE=2` なので、プロジェクトの `env` に `CONTAINER_SCALE=1` を明示してください。 +**このパターンは scale=1(1 プロジェクト = 1 work ボリューム)を前提としています。** devbase の既定は `2` なので、プロジェクトの [`project.yml`](../user/project-yml.md) に `scale: 1` を明示してください。 現行実装では、scale>1 にすると「全コンテナが同一のソースツリーを共有する」という本パターンの前提が次の 2 点で崩れます。 @@ -78,7 +78,7 @@ volumes: - **`DEVBASE_WORK_VOLUME` で名前を分ける。** scale 生成は dev サービスの `/work` を `devbase_work_`(scale=1 なら常に `devbase_work_1`)へ無条件に差し替えます(`compose.yml` に `/work` マウントを書いていなくても追加されます)。`DEVBASE_WORK_VOLUME` が効くのは app / nginx など非 dev サービスだけなので、既定名以外を指定すると dev だけが別ボリュームを見る分裂状態になります。 - **プロジェクトディレクトリごと複製する。** work ボリュームは `COMPOSE_PROJECT_NAME` の接頭辞が付かない[グローバルな external ボリューム](#クリーンに作り直す再-populate)なので、複製先も同じ `devbase_work_1` を共有します。分離になりません。 -同一リポジトリを同時に複数環境で動かす必要がある場合は、Docker ホスト(`docker context`)そのものを分けてください。なお **別リポジトリ**の repo 連携プロジェクト同士は、populate 先が `/work/` とサブディレクトリで分かれるため、同じ work ボリュームを共有したまま共存できます。 +同一リポジトリを同時に複数環境で動かす必要がある場合は、Docker ホスト(`docker context`)そのものを分けてください。なお **別リポジトリ**の repo 連携プロジェクト同士は、populate 先が `/work/<リポジトリ名>` とサブディレクトリで分かれるため、同じ work ボリュームを共有したまま共存できます。 --- @@ -90,8 +90,8 @@ volumes: |---|------|------| | ① | `repo/` の clone / pull | 無ければ `git clone`、あれば `git pull --ff-only`(app ビルドコンテキストの最新化) | | ② | `.env` の取得 | S3 等から取得してホスト `./.env` に配置(`docker compose` の変数展開前に必要) | -| ③ | work ボリュームへ populate | `repo/` の内容を `/work/` へコピー | -| ④ | `.env` を work ボリュームへ配置 | Laravel 等のランタイムが `/work//.env` を参照するため | +| ③ | work ボリュームへ populate | `repo/` の内容を `/work/<リポジトリ名>` へコピー | +| ④ | `.env` を work ボリュームへ配置 | Laravel 等のランタイムが `/work/<リポジトリ名>/.env` を参照するため | ② を `deploy`(`up` 後フック)ではなく `pre-up` で行うのは、`compose.yml` の `MYSQL_DATABASE: ${DB_DATABASE:-...}` のような変数展開が `docker compose` パース時(= MySQL コンテナ初回起動前)に `.env` を要求するためです。`deploy` 段階では間に合わず、DB がデフォルト名で初期化されてしまいます。 @@ -101,7 +101,7 @@ volumes: **このパターンの肝は「初回だけ populate し、2 回目以降はコンテナ側に触れない」ことです。** -`pre-up` は work ボリューム上に `/work//.git` が存在するかどうかで populate 済みを判定し、済みの場合は ②③④ をスキップします。 +`pre-up` は work ボリューム上に `/work/<リポジトリ名>/.git` が存在するかどうかで populate 済みを判定し、済みの場合は ②③④ をスキップします。 | # | 処理 | 未populate(初回) | populate 済み(2回目以降) | |---|------|:---:|:---:| @@ -130,20 +130,20 @@ populate 済み以降、更新経路は次のように分かれます。 | 対象 | 場所 | 更新方法 | |------|------|---------| | ビルドコンテキスト | ホスト `./repo` | `pre-up` が毎回 `git pull`(自動) | -| 実行時ソース | work ボリューム `/work/` | **コンテナ内で手動 `git pull`** | -| 実行時 `.env` | work ボリューム `/work//.env` | コンテナ内で手動編集 | +| 実行時ソース | work ボリューム `/work/<リポジトリ名>` | **コンテナ内で手動 `git pull`** | +| 実行時 `.env` | work ボリューム `/work/<リポジトリ名>/.env` | コンテナ内で手動編集 | ```bash # 実行時ソースの更新(dev コンテナ内) -cd /work/ +cd /work/<リポジトリ名> git pull origin main ``` ### クリーンに作り直す(再 populate) -`.env` やソースを S3 / `repo/` の内容からやり直したい場合は、populate 済み判定に使われる `/work/` を消して、次回 `up` で populate を再実行させます。 +`.env` やソースを S3 / `repo/` の内容からやり直したい場合は、populate 済み判定に使われる `/work/<リポジトリ名>` を消して、次回 `up` で populate を再実行させます。 -> **Warning:** work ボリューム(既定 `devbase_work_1`)は `COMPOSE_PROJECT_NAME` の接頭辞が付かない **グローバルな external ボリューム**で、同じインスタンス index を使う **すべての devbase プロジェクトが共有**します。`docker volume rm` でボリュームごと消すと、停止中の別プロジェクトのソースや生成物まで巻き添えで失われます。プロジェクトの分離単位はボリュームではなく `/work/` サブディレクトリなので、**通常はサブディレクトリだけを削除**してください。 +> **Warning:** work ボリューム(既定 `devbase_work_1`)は `COMPOSE_PROJECT_NAME` の接頭辞が付かない **グローバルな external ボリューム**で、同じインスタンス index を使う **すべての devbase プロジェクトが共有**します。`docker volume rm` でボリュームごと消すと、停止中の別プロジェクトのソースや生成物まで巻き添えで失われます。プロジェクトの分離単位はボリュームではなく `/work/<リポジトリ名>` サブディレクトリなので、**通常はサブディレクトリだけを削除**してください。 **推奨: このプロジェクトのサブディレクトリだけを削除する** @@ -153,8 +153,8 @@ devbase down # 何が入っているか(=他プロジェクトが同居していないか)を確認 docker run --rm -v devbase_work_1:/work alpine ls -la /work -# このプロジェクトのソースだけを削除( は env の値) -docker run --rm -v devbase_work_1:/work alpine rm -rf /work/ +# このプロジェクトのソースだけを削除(<リポジトリ名> は project.yml の repos[].dir) +docker run --rm -v devbase_work_1:/work alpine rm -rf /work/<リポジトリ名> devbase up # pre-up が ②③④ を再実行 ``` @@ -182,8 +182,8 @@ devbase up |------|------|------| | `DEVBASE_REPO_PULL` | `1` | `0` にすると ①(`repo/` の `git pull`)を抑止。オフラインや意図的にビルドコンテキストを固定したいとき | | `DEVBASE_ENV_OVERWRITE` | `backup` | 未 populate 時の既存ホスト `.env` の扱い。`backup`(`.env.bak.` に退避して上書き)/ `skip`(既存があれば S3 取得しない)/ `force`(退避せず上書き) | -| `DEVBASE_WORK_VOLUME` | `devbase_work_` | `compose.yml` が参照する共有 work ボリューム名の明示指定。未指定なら `DEVBASE_INSTANCE_INDEX` から解決。ただし効くのは **app / nginx など非 dev サービスだけ**で、dev サービスの `/work` は scale 生成時に `devbase_work_` へ無条件に差し替えられます。dev から実行時ソースを触る本パターンでは **既定名のまま**にしてください([スケール前提](#スケール前提-container_scale1) を参照) | -| `DEVBASE_INSTANCE_INDEX` | `1` | work ボリューム名のインデックス。**devbase 本体が渡すのは `deploy` フックに対してのみ**で、`pre-up` や `docker compose` のプロセス環境には渡りません。`compose.yml` の `${DEVBASE_INSTANCE_INDEX:-1}` は `.env` に書かれた値、無ければ `1` に解決されます([スケール前提](#スケール前提-container_scale1) を参照) | +| `DEVBASE_WORK_VOLUME` | `devbase_work_` | `compose.yml` が参照する共有 work ボリューム名の明示指定。未指定なら `DEVBASE_INSTANCE_INDEX` から解決。ただし効くのは **app / nginx など非 dev サービスだけ**で、dev サービスの `/work` は scale 生成時に `devbase_work_` へ無条件に差し替えられます。dev から実行時ソースを触る本パターンでは **既定名のまま**にしてください([スケール前提](#スケール前提-scale-1) を参照) | +| `DEVBASE_INSTANCE_INDEX` | `1` | work ボリューム名のインデックス。**devbase 本体が渡すのは `deploy` フックに対してのみ**で、`pre-up` や `docker compose` のプロセス環境には渡りません。`compose.yml` の `${DEVBASE_INSTANCE_INDEX:-1}` は `.env` に書かれた値、無ければ `1` に解決されます([スケール前提](#スケール前提-scale-1) を参照) | > **Note:** `.env` の環境選択(例: `s3://.../env/local.env` の `local` 部分)など、S3 パスやプロファイルはプロジェクト固有の変数(例: `CARMO_ENV`)で制御することがあります。プロジェクトの `pre-up` 冒頭コメントを参照してください。 @@ -191,12 +191,12 @@ devbase up ## 7. チェックリスト(新規に repo 連携プロジェクトを作るとき) -- [ ] `env` に `GIT_USER` / `GIT_REPO` を定義した -- [ ] `env` に `CONTAINER_SCALE=1` を明記した(既定は `2`。[スケール前提](#スケール前提-container_scale1) を参照) +- [ ] `project.yml` に `repos`(`owner` / `repo`)を定義した +- [ ] `project.yml` に `scale: 1` を明記した(既定は `2`。[スケール前提](#スケール前提-scale-1) を参照) - [ ] `compose.yml` で work ボリュームを `external: true` + `name: ${DEVBASE_WORK_VOLUME:-devbase_work_${DEVBASE_INSTANCE_INDEX:-1}}` で宣言した - [ ] app サービスの `build.context` をホスト `./repo` にした - [ ] `pre-up` で ①clone/pull → ②`.env`取得 → ③populate → ④`.env`配置 を実装した -- [ ] `pre-up` が `/work//.git` の有無で populate 済みを判定し、②③④ をスキップする +- [ ] `pre-up` が `/work/<リポジトリ名>/.git` の有無で populate 済みを判定し、②③④ をスキップする - [ ] populate 時の owner を `1000:1000`(コンテナ内ユーザー)に設定した - [ ] `storage/` / `vendor/` / `node_modules/` 等、初回のみ生成され上書きしたくないパスの扱いを決めた - [ ] README にソース・`.env` の更新運用(手動 pull / 再 populate)を記載した diff --git a/docs/user/cli-reference/02-project.md b/docs/user/cli-reference/02-project.md index 1f2ae9e..505edf8 100644 --- a/docs/user/cli-reference/02-project.md +++ b/docs/user/cli-reference/02-project.md @@ -52,7 +52,8 @@ devbase up [name] - 起動時にスナップショットを自動作成(新世代 or 差分追加) - 直近のスナップショット取得から既定 60 分以内のときはスキップします - 間隔は `DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES` 環境変数で上書き可能(既定 60、`0` で無効化=毎回取得、不正値は警告して既定値) -- `CONTAINER_SCALE` の値に基づいてコンテナ数を決定 +- `project.yml` の `scale` に基づいてコンテナ数を決定(既定: 2) +- `project.yml` の `repos` を clone プランへ正規化してコンテナへ渡す(コンテナ内で `/work` 配下へ clone される) - イメージの自動準備(`devbase up` は `devbase rebuild`=`devbase build --expires=7` 相当を実行): - `build:` 定義あり、イメージ未存在 → `devbase build` を自動実行 - `build:` 定義あり、イメージ存在 → プロジェクトイメージの作成日で再ビルドの要否を判定: @@ -157,6 +158,37 @@ devbase project scale 3 devbase project scale adminer 3 ``` +新しい値は `project.yml` の `scale` に書き戻されるため、次回の `devbase up` にも引き継がれます。 + +## `devbase project migrate-config` + +旧 `env` 形式(`GIT_USER` / `GIT_REPO` / `GIT_HOST` / `WORK_DIR` / `CONTAINER_SCALE` / +`DEVBASE_OPEN_EDITOR`)のプロジェクト定義を [`project.yml`](../project-yml.md) へ変換します。 + +``` +devbase project migrate-config [NAME ...] [--dry-run] [--projects-dir DIR] +``` + +| パラメータ | 必須 | 説明 | +|-----------|------|------| +| `NAME` | いいえ | 対象プロジェクト名(省略時は `projects/` 配下すべて) | +| `--dry-run` | いいえ | 生成される `project.yml` を表示するだけで書き換えない | +| `--projects-dir` | いいえ | 対象ディレクトリ(既定: `$DEVBASE_ROOT/projects`)。devbase へリンクしていないプラグインリポジトリ内の `projects/` を直接変換する場合に使う | + +```bash +# まず変換結果を確認する +devbase project migrate-config --dry-run + +# 変換を適用する +devbase project migrate-config +``` + +- 変換対象は上記キーのみで、`ENABLE_SSH` などそれ以外は `env` に残ります +- 既存の `project.yml` は上書きしません(手で複数リポジトリ構成へ整えたものを壊さないため)。 + `env` に残った旧キーの掃除だけを行うため、何度実行しても同じ状態になります +- `projects/` はプラグインリポジトリへのシンボリックリンクです。書き換わるのはリンク先の + 実体(=定義の正)で、出力には実際に触れたパスが表示されます + ## `devbase project build` コンテナイメージをビルドします。キャッシュの扱いは 3 モードあります。 diff --git a/docs/user/container-operations.md b/docs/user/container-operations.md index efe84d0..b140ec5 100644 --- a/docs/user/container-operations.md +++ b/docs/user/container-operations.md @@ -69,11 +69,14 @@ devbase は複数のコンテナを同時に起動し、並行開発を行うこ ### コンテナ数の設定 -プロジェクトの `env` ファイルで `CONTAINER_SCALE` を設定します。デフォルト値は `2` です。 - -```bash -# env ファイルで設定 -CONTAINER_SCALE=2 +プロジェクトの [`project.yml`](project-yml.md) で `scale` を設定します。デフォルト値は `2` です。 + +```yaml +version: 1 +scale: 2 +repos: + - owner: your-org + repo: my-repo ``` ### 動的スケーリング @@ -180,7 +183,7 @@ AI CLI ツールの設定や認証情報は、コンテナを再生成しても - `/persistent/ai` は全コンテナ共通の `devbase_home_ubuntu` ボリュームなので、**どのコンテナからも同じ実体**を参照します(例: `~/share` は全コンテナで共有)。 - symlink **対象外**のホーム配下ファイル(シェル履歴など)はコンテナ層に置かれ、再生成で失われます。永続化したいものは `/persistent/ai` 配下(= 上記 symlink 先)か `/work` に置いてください。 -- `share` 配下に置いた VS Code ワークスペースファイルは `DEVBASE_WORKSPACE` で開けます([環境変数](environment-variables.md) 参照)。 +- `share` 配下に置いた VS Code ワークスペースファイルは `DEVBASE_WORKSPACE` で開けます(リポジトリ 1 件の構成のみ。[環境変数](environment-variables.md) 参照)。 > **Note:** symlink 対象は entrypoint にビルド時 `COPY` で焼き込まれます。エントリを増減した場合は > イメージの再ビルドが必要です(`devbase up` 単体では反映されない場合があります。[CLI リファレンス: project グループ](cli-reference/02-project.md#devbase-project-up) の `devbase project up` の注記参照)。 @@ -295,7 +298,7 @@ devbase status ## ベストプラクティス 1. **プロジェクト固有のファイルは `/work` に配置する** -- `/persistent/ai`(`~/.claude` 等の symlink 先・`~/share`)は全コンテナで共有されるため -2. **`CONTAINER_SCALE` は必要最小限に設定する** -- リソース消費を抑制 +2. **`scale` は必要最小限に設定する** -- リソース消費を抑制 3. **作業終了後は `devbase down` を実行する** -- 自動ローテーションでディスク容量を管理 4. **`devbase ps` で状態を確認してからログインする** -- 異常終了したコンテナへのログイン試行を避ける 5. **イメージのビルドは初回と更新時のみ** -- 変更がない場合はキャッシュが利用される diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index e0cd444..cbe4a04 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -21,7 +21,7 @@ graph TD | 優先度 | レベル | ファイル | 用途 | Git 管理 | |-------|-------|---------|------|---------| | 1(低) | グローバル | `devbase/.env` | 共通 API キー・認証情報 | gitignore | -| 2 | プロジェクト設定 | `projects/*/env` | リポジトリ名・コンテナ数等 | 管理対象 | +| 2 | プロジェクト環境変数 | `projects/*/env` | コンテナへ渡すプロジェクト固有の環境変数(`ENABLE_SSH` 等) | 管理対象 | | 3(高) | プロジェクト機密 | `projects/*/.env` | プロジェクト固有の API キー | gitignore | > **Note:** 同じキーが複数のレベルに存在する場合、優先度が高いレベルの値が使用されます。例えば、グローバルの `AWS_PROFILE` をプロジェクトの `.env` で上書きすることで、プロジェクトごとに異なる AWS プロファイルを使用できます。 @@ -34,14 +34,19 @@ graph TD **プロジェクト設定 `env`(`projects/*/env`)** -プロジェクト固有の設定値を格納します。Git 管理対象のため、機密情報は含めないでください。 +コンテナへ渡すプロジェクト固有の環境変数を格納します。Git 管理対象のため、機密情報は含めないでください。 ```bash # env ファイルの例 -REPO_NAME=my-project -CONTAINER_SCALE=2 +ENABLE_SSH=true ``` +devbase 自身の設定(どのリポジトリを clone するか、コンテナ数、エディタの自動オープン)は +環境変数ではなく [`projects/*/project.yml`](project-yml.md) に書きます。`env` に +`GIT_USER` / `GIT_REPO` / `WORK_DIR` / `CONTAINER_SCALE` を書いても効果はありません。 + +`compose.yml` が `env_file: - env` で参照するため、中身が無くてもファイル自体は残してください。 + **プロジェクト機密 `.env`(`projects/*/.env`)** プロジェクト固有の機密情報を格納します。gitignore 対象のため、チームメンバーは個別に設定する必要があります。 @@ -140,15 +145,20 @@ devbase はホストマシンの認証情報を自動収集し、コンテナ内 ## `devbase up` 後のエディタ自動オープン -`devbase up` 完了後、dev コンテナへ接続した VS Code を自動で開けます(VS Code の「Attach to Running Container」を CLI から起動)。既定では `/work/$GIT_REPO` フォルダを開きます(`--folder-uri`)。`DEVBASE_WORKSPACE` を指定するとフォルダの代わりに `*.code-workspace` ワークスペースファイルを開きます(`--file-uri`)。 +`devbase up` 完了後、dev コンテナへ接続した VS Code を自動で開けます(VS Code の「Attach to Running Container」を CLI から起動)。 + +開く対象は [`project.yml`](project-yml.md) から決まります。 + +- リポジトリが 1 件: primary リポジトリのフォルダ(`--folder-uri`) +- リポジトリが 2 件以上: 全リポジトリを含む multi-root ワークスペース `/work/<プロジェクト名>.code-workspace`(`--file-uri`) -これらは `devbase env init` の収集対象外で、プロジェクトの `env` か `$DEVBASE_ROOT/.env` に手書きする devbase 動作設定です。 +自動オープンの有無は `project.yml` の `open_editor` が最優先で、未指定なら以下の env に従います。このうち `DEVBASE_OPEN_EDITOR` だけは `devbase env init` の editor コレクターが対話収集し(対話の既定は `1` = 有効)、`$DEVBASE_ROOT/.env` に書き込まれます。残りは収集対象外で、`$DEVBASE_ROOT/.env` かプロジェクトの `env` に手書きする devbase 動作設定です。 | キー | 説明 | |------|------| -| `DEVBASE_OPEN_EDITOR` | 真(`1`/`true`/`yes`/`on`)で `up` 後にエディタを開く(既定: OFF) | +| `DEVBASE_OPEN_EDITOR` | 真(`1`/`true`/`yes`/`on`)で `up` 後にエディタを開く。`devbase env init` の対話既定は `1`(有効)なので、init 済みの環境では通常 ON。キー自体が未設定のときのみ OFF に倒れる。`project.yml` の `open_editor` が指定されていればそちらが優先 | | `DEVBASE_EDITOR` | 起動コマンド(既定: `code`)。`cursor` / `code-insiders` 等も可 | -| `DEVBASE_WORKSPACE` | 開く `*.code-workspace` ファイルの**コンテナ内絶対パス**(例 `/home/ubuntu/share/work/uttarov2-doc.workspace`)。指定時はフォルダではなくワークスペースを開く。未設定なら従来どおり `/work/$GIT_REPO` フォルダ。`~/share`(= 全コンテナ共有ボリューム `/persistent/ai/share` への symlink)配下に置けば全コンテナで共用可 | +| `DEVBASE_WORKSPACE` | 開く `*.code-workspace` ファイルの**コンテナ内絶対パス**を明示指定する(例 `/home/ubuntu/share/work/uttarov2-doc.workspace`)。**効くのはリポジトリ 1 件の構成だけ**です。2 件以上の構成では `devbase up` が自動生成した `/work/<プロジェクト名>.code-workspace` を直接開くため、この env を設定しても上書きできません。`~/share`(= 全コンテナ共有ボリューム `/persistent/ai/share` への symlink)配下に置けば全コンテナで共用可 | | `DEVBASE_OPEN_INDEX` | scale 時に開く dev インスタンス番号(既定: `1`) | | `DEVBASE_EDITOR_SSH_HOST` | Remote-SSH 跨ホスト構成での ssh-remote ホスト名(例 `mac2`)。**通常は `~/.vscode-server` から自動検出**され不要。検出が外れる場合のみ明示。下記「跨ホスト」参照 | | `DEVBASE_EDITOR_DOCKER_CONTEXT` | 跨ホスト時に ssh 先で使う docker context(既定: ホストの `docker context show`) | diff --git a/docs/user/project-yml.md b/docs/user/project-yml.md new file mode 100644 index 0000000..0fdb27c --- /dev/null +++ b/docs/user/project-yml.md @@ -0,0 +1,152 @@ +# project.yml リファレンス + +`projects//project.yml` は、1 つのプロジェクト(= 1 つの dev コンテナ群)が +**どのリポジトリを開発対象にするか**と、devbase 自身のふるまいを定義するファイルです。 +Git 管理対象で、プロジェクト設定の正です。 + +1 プロジェクトに複数のリポジトリを登録でき、すべてが同じコンテナの `/work` 配下へ +clone されます。関連する複数リポジトリ(本体・ドキュメント・インフラなど)を 1 つの +開発環境で横断的に扱うための仕組みです。 + +## 最小構成 + +```yaml +version: 1 +repos: + - owner: volareinc + repo: carmo +``` + +`devbase up` すると、コンテナ内の `/work/carmo` にリポジトリが clone され、 +ログイン直後の作業ディレクトリもそこになります。 + +## 複数リポジトリ + +```yaml +version: 1 +scale: 1 +open_editor: true + +defaults: + owner: uttaro-dev2 + +repos: + - repo: uttarov2 # 先頭が primary + host: gitlab.com # リポジトリごとにホストを変えられる + owner: uttaro_dev + dir: system # /work/system へ clone する + - repo: uttarov2-doc + - repo: uttarov2migration + branch: develop + init: false +``` + +リポジトリが 2 件以上あるとき、`devbase up` は全リポジトリを含む +**multi-root ワークスペース** `/work/<プロジェクト名>.code-workspace` を生成し、 +エディタはそれを開きます(1 件のときは primary リポジトリのフォルダを開きます)。 + +## キー一覧 + +### 最上位 + +| キー | 必須 | 既定値 | 説明 | +|------|------|--------|------| +| `version` | はい | -- | スキーマ版。現在は `1` | +| `repos` | はい | -- | clone するリポジトリの配列(1 件以上) | +| `defaults` | いいえ | -- | `repos` の各要素へ継承させる既定値(`host` / `owner` / `branch` / `init`) | +| `scale` | いいえ | `2` | 起動するコンテナ数。`devbase project scale N` はこの値を書き換える | +| `open_editor` | いいえ | -- | `devbase up` 後に VS Code を自動で開くか。未指定なら env `DEVBASE_OPEN_EDITOR` に従う | +| `work_dir` | いいえ | primary の `/work/` | エディタが開く既定フォルダを明示指定する。**効くのはリポジトリが 1 件のときだけ**で、2 件以上のときは自動生成の multi-root ワークスペースが開かれる | + +### `repos[]` + +| キー | 必須 | 既定値 | 説明 | +|------|------|--------|------| +| `owner` | はい | `defaults.owner` | Git ホストのユーザー名または Organization 名 | +| `repo` | はい | -- | リポジトリ名 | +| `host` | いいえ | `github.com` | Git ホスト名(例 `gitlab.com`) | +| `dir` | いいえ | `repo` と同じ | `/work` 直下の clone 先ディレクトリ名 | +| `branch` | いいえ | リポジトリの既定ブランチ | clone 直後にチェックアウトするブランチ | +| `init` | いいえ | `true` | リポジトリ直下の `./init.sh` を実行するか。clone 直後だけでなく**コンテナ起動のたび**(既存 clone があっても)実行されます | +| `primary` | いいえ | 先頭要素が `true` | ログイン直後の作業ディレクトリになるリポジトリ(1 件だけ指定可) | + +clone URL は `https:////.git` で組み立てられます。認証は +コンテナに渡された既存の Git 資格情報の仕組みに委ねます(`project.yml` に +資格情報は書きません)。 + +`branch` と `init` は実行タイミングが異なります。 + +| キー | 実行タイミング | 理由 | +|------|--------------|------| +| `branch` | **clone 直後の 1 回だけ** | 既存 clone にも毎回適用すると、コンテナ内で作業ブランチへ切り替えた状態が再起動のたびに引き戻されるため | +| `init` | **コンテナ起動のたび**(既存 clone があっても毎回) | 依存パッケージの再取得など、コンテナ再生成後にも必要な処理を置く場所のため | + +`init.sh` は毎回走るので、**何度実行しても同じ結果になる(冪等な)内容にしてください**。 +`git clone` や追記のような繰り返すと壊れる処理を書く場合は、スクリプト側で実行済みかを +判定してください。実行が重い・1 回だけでよい場合は `init: false` にして手動実行に切り替えます。 +なお `init.sh` の失敗は警告に留まり、他リポジトリの処理とコンテナ起動は続行されます。 + +## 検証されること + +設定ミスを黙って無視せず、`devbase up` の時点でエラーにします。 + +- `owner` / `repo` が無い、`repos` が空 +- `dir` の重複(同じ `/work/` を 2 つのリポジトリが奪い合う) +- `primary: true` が 2 件以上 +- 未知のキー(`brunch: main` のような打ち間違いが「書いたのに効かない」形で表れないため) +- `repos[]` の `host` / `owner` / `repo` / `dir` / `branch` に空白・制御文字が混ざっている +- `dir` が `/work` 直下から外れている(`../` や入れ子のパス、`.` / `..`) + +## `env` との使い分け + +| 書く場所 | 内容 | 例 | +|---------|------|-----| +| `project.yml` | devbase 自身の設定 | リポジトリ、コンテナ数、エディタの自動オープン | +| `env` | コンテナへ渡す環境変数 | `ENABLE_SSH`、アプリが読む設定値 | +| `.env` | プロジェクト固有の機密 | API キー、DB 接続情報 | + +`compose.yml` が `env_file: - env` で参照するため、`env` は**ファイル自体が必須**です。 +渡したい環境変数が無ければ空ファイルで構いませんが、削除すると `devbase up` が +compose の起動時に失敗します。 + +## 旧 `env` 形式からの移行 + +`GIT_USER` / `GIT_REPO` / `GIT_HOST` / `WORK_DIR` / `CONTAINER_SCALE` / +`DEVBASE_OPEN_EDITOR` を `env` に書く旧形式は廃止されました。`project.yml` の無い +プロジェクトは `devbase up` が移行手順を案内して停止します。 + +変換は [`devbase project migrate-config`](cli-reference/02-project.md#devbase-project-migrate-config) +で行います。 + +```bash +devbase project migrate-config --dry-run # 変換結果を確認 +devbase project migrate-config # 適用 +``` + +| 旧 `env` のキー | 移行先 | +|----------------|--------| +| `GIT_USER` | `repos[].owner` | +| `GIT_REPO` | `repos[].repo` | +| `GIT_HOST` | `repos[].host` | +| `WORK_DIR` | `work_dir`(既定値と同じ場合は書きません) | +| `CONTAINER_SCALE` | `scale` | +| `DEVBASE_OPEN_EDITOR` | `open_editor` | + +## コンテナへの渡り方 + +`project.yml` を編集する人が意識する必要はありませんが、仕組みを知っておくと +トラブルシュートに役立ちます。 + +```mermaid +flowchart LR + Y["project.yml
(人が編集する正)"] -->|"devbase up がホスト側で正規化"| P["clone プラン
DEVBASE_REPOS (base64)"] + P -->|"compose の dev サービスへ"| C["コンテナ"] + C -->|"entrypoint が復号して clone"| W["/work/<dir> ×N"] +``` + +YAML の解釈はホスト側の Python に閉じています。コンテナ側は復号して 1 行ずつ +clone するだけなので、イメージへ YAML パーサを持ち込みません。 + +> **Note:** `entrypoint.sh` はイメージに焼き込まれます。devbase 本体を更新して +> clone のふるまいが変わった場合は、`devbase build --no-cache` でベースイメージを +> 再ビルドしないと反映されません。 From 0c53b3c5c60e9d620f20a36227dcb40d67013d95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Sun, 23 Aug 2026 02:33:20 +0900 Subject: [PATCH 7/8] =?UTF-8?q?chore:=20PLAN32=20=E3=81=AE=E7=A0=B4?= =?UTF-8?q?=E5=A3=8A=E7=9A=84=E5=A4=89=E6=9B=B4=E3=81=AB=E5=90=88=E3=82=8F?= =?UTF-8?q?=E3=81=9B=E3=81=A6=203.0.0=20=E3=81=B8=E4=B8=8A=E3=81=92?= =?UTF-8?q?=E3=82=8B=20(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: project.yml 方式のドキュメントを整備する PLAN32 Task 5。複数リポジトリ構成と project.yml への移行に合わせて利用者向け ドキュメントを更新した。 - docs/user/project-yml.md を新設: スキーマ、複数リポジトリの例、検証される 内容、env との使い分け、旧 env からの移行表、コンテナへの渡り方 - 環境変数ガイド / コンテナ操作ガイド / CLI リファレンス / プラグイン開発の 各所から旧キー (GIT_USER / GIT_REPO / WORK_DIR / CONTAINER_SCALE) の説明を 外し、project.yml とその参照へ置き換え - CLI リファレンスに devbase project migrate-config を追記 - アーキテクチャ解説に project/ モジュールの節を追加 - CHANGELOG に破壊的変更と再ビルドの注意を追記 過去リリース分の CHANGELOG 記述は履歴なので書き換えていない。 Co-Authored-By: Claude Opus 5 (1M context) * docs: レビュー指摘対応 — アンカー切れ修正と env ファイル必須の明記 - repo-backed-projects.md: 見出し変更で切れた `#スケール前提-container_scale1` 参照 2 箇所を `#スケール前提-scale-1` に修正 - quickstart.md: `env` はファイル自体が必須(中身は任意)と見出しから分かるよう変更し、 最小構成のディレクトリツリーへ `project.yml` を追加 - project-yml.md: `env` ファイルが必須である旨を同じ表現に揃える Co-Authored-By: Claude Opus 5 (1M context) * docs: DEVBASE_OPEN_EDITOR は env init の収集対象であることを明記 `devbase env init` の editor コレクター (lib/devbase/env/collectors/editor.py) が `DEVBASE_OPEN_EDITOR` を対話収集し、対話の既定は `1` (有効) である。 「収集対象外」「既定: OFF」という記述は実装と矛盾していたため、収集対象で あることと、OFF に倒れるのはキー自体が未設定のときだけである旨へ修正した。 Co-Authored-By: Claude Opus 5 (1M context) * chore: プロジェクト設定の破壊的変更に合わせて 3.0.0 へ上げる project.yml への移行はプロジェクト定義の互換性を壊すため、SemVer の major を上げる。プラグイン側は requires.devbase を ">=3.0.0" に更新して 非対応の devbase へインストールされないようにする必要がある (各 plugin リポジトリの PR で対応)。 CHANGELOG の [Unreleased] を [3.0.0] として確定し、ドキュメント中の バージョン表記も揃えた。 Co-Authored-By: Claude Opus 5 (1M context) * fix: 実行時版数・uv.lock・CHANGELOG 参照リンクを 3.0.0 へ同期 pyproject.toml だけを 3.0.0 にしたため、実行時の版数表示とロックファイルが 2.2.0 のまま取り残されていた。 - lib/devbase/__init__.py の __version__ を 3.0.0 へ (--version / status / export manifest の実体) - lib/devbase/cli.py, lib/devbase/commands/status.py の ImportError fallback も 同じ値へ同期 - uv.lock のローカルパッケージ版数を 3.0.0 へ (uv lock --check が通る状態に戻す) - CHANGELOG.md に [3.0.0] のリンク定義を追加し、[Unreleased] の比較開始点を v3.0.0 へ変更 - docs/user/env-export-import.md の manifest サンプルの devbase_version を 3.0.0 へ docs/user/plugin-registries.md の「devbase v2.2.0 以降」は当該仕様が導入された 版を指す履歴記述のため据え置く。 Co-Authored-By: Claude Opus 5 (1M context) * docs: init.sh の実行頻度と DEVBASE_WORKSPACE の有効範囲を実装に合わせる - project.yml リファレンス: `init` は clone 直後だけでなくコンテナ起動の たびに `./init.sh` を実行することを明記し、`branch` (clone 直後のみ) との タイミング差の表と冪等性の注意を追加 - 環境変数: `DEVBASE_WORKSPACE` が効くのはリポジトリ 1 件の構成だけで、 2 件以上では自動生成した workspace を直接開くため上書きできない旨を明記 - container-operations / plugin-dev quickstart の関連記述も同じ挙動へ揃える Co-Authored-By: Claude Opus 5 (1M context) * docs: 読み込み順序の表からも env の旧用途を外す env の役割を「コンテナへ渡す環境変数」と書き換えたのに、直上の表だけ 「リポジトリ名・コンテナ数等」と旧仕様のままで矛盾していた。 Co-Authored-By: Claude Opus 5 (1M context) * docs: plugin.yml の requires.devbase を実装どおりに文書化する CHANGELOG が 3.0.0 で requires.devbase: ">=3.0.0" への更新を求めているのに、 plugin.yml リファレンスに requires の記述が無く、プラグイン作者が何を書けばよいか たどれない状態だった (gemini round 2 の major 指摘)。 追記にあたって実装 (lib/devbase/plugin/syncer.py load_plugin_info) を確認したところ、 リファレンスとクイックスタートが載せていた plugins[] 配列 + projects[] 列挙の構造は どこからも読まれておらず、実際は plugin.yml が name / version / description / priority / requires.devbase を持つフラット形式で、プロジェクトは projects/ 配下の ディレクトリから自動検出される。旧構造のまま requires だけ足すと誤った位置を案内する ため、スキーマ記述を実装に合わせて訂正した上で追記している。 - plugin-yml-reference.md: 基本構造・フィールド一覧・使用例をフラット形式へ訂正。 requires (devbase 本体の最低バージョン。project.yml 形式は 3.0.0 以降でのみ読めるため ">=3.0.0" を指定する) と priority の詳細節を追加。プロジェクト自動検出を明記。 複数 Plugin を 1 リポジトリで配る場合は registry.yml を使う旨へ差し替え。 実在しないエラーメッセージを並べていたバリデーション表を実際の PluginError へ訂正。 - quickstart.md 1.2: plugin.yml サンプルをフラット形式 + requires.devbase へ更新。 検証: uv run pytest 1371 passed / 記載サンプルを load_plugin_info に通して requires_devbase='>=3.0.0' が取れることを確認。 * docs: plugin.yml の name/version 記述を実装の挙動へ揃える - name: 強制されない命名規則を「バリデーションルール」と書いていたため 「命名規則(推奨)」へ改め、省略時はディレクトリ名になることを追記 - version: サンプルの引用符の有無を基本構造の例と統一 --------- Co-authored-by: Claude Opus 5 (1M context) --- CHANGELOG.md | 8 +- docs/developer/architecture.md | 2 +- docs/plugin-dev/plugin-yml-reference.md | 219 +++++++++++++----------- docs/plugin-dev/quickstart.md | 25 +-- docs/user/env-export-import.md | 2 +- lib/devbase/__init__.py | 2 +- lib/devbase/cli.py | 2 +- lib/devbase/commands/status.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 10 files changed, 144 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8080fc..b131b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ## [Unreleased] +## [3.0.0] - 2026-08-23 + +プロジェクト設定を `project.yml` へ移行する破壊的変更を含みます。プラグイン側の +プロジェクト定義も本バージョンに合わせた更新が必要です (`requires.devbase: ">=3.0.0"`)。 + ### Changed - **1 プロジェクト = 1 コンテナ = 複数リポジトリ**に対応しました。プロジェクトが開発対象と するリポジトリは `projects//project.yml` の `repos` 配列で指定し、すべてが同じ @@ -223,5 +228,6 @@ OSS 化に伴う初回リリース。devbase は本バージョンより `devbas ### Removed - 「公式レジストリ」固定の概念を廃止。各レジストリは対等な扱いとなる。 -[Unreleased]: https://github.com/devbasex/devbase/compare/v2.2.0...HEAD +[Unreleased]: https://github.com/devbasex/devbase/compare/v3.0.0...HEAD +[3.0.0]: https://github.com/devbasex/devbase/compare/v2.2.0...v3.0.0 [2.2.0]: https://github.com/devbasex/devbase/releases/tag/v2.2.0 diff --git a/docs/developer/architecture.md b/docs/developer/architecture.md index 963f3bb..c3af02e 100644 --- a/docs/developer/architecture.md +++ b/docs/developer/architecture.md @@ -1,6 +1,6 @@ # devbase アーキテクチャ概要 -devbase v2.2.0 のアーキテクチャ設計と内部構造について説明する。 +devbase v3.0.0 のアーキテクチャ設計と内部構造について説明する。 ## 全体構成 diff --git a/docs/plugin-dev/plugin-yml-reference.md b/docs/plugin-dev/plugin-yml-reference.md index 7e70c94..88b52fd 100644 --- a/docs/plugin-dev/plugin-yml-reference.md +++ b/docs/plugin-dev/plugin-yml-reference.md @@ -1,7 +1,7 @@ # plugin.yml リファレンス -`plugin.yml` はPluginリポジトリのルートに配置する設定ファイルです。 -Pluginのメタ情報とプロジェクト一覧を定義します。 +`plugin.yml` は Plugin ディレクトリのルートに配置する設定ファイルです。 +Plugin のメタ情報(名前・バージョン・必要な devbase 本体のバージョンなど)を定義します。 --- @@ -10,10 +10,13 @@ Pluginのメタ情報とプロジェクト一覧を定義します。 ```mermaid flowchart TB subgraph repo["Pluginリポジトリ"] - PY["plugin.yml"] - subgraph projects["projects/"] - P1["my-project-a/"] - P2["my-project-b/"] + RY["registry.yml
(リポジトリが持つPlugin一覧)"] + subgraph plugin["my-plugin/"] + PY["plugin.yml
(Pluginのメタ情報)"] + subgraph projects["projects/"] + P1["my-project-a/"] + P2["my-project-b/"] + end end end subgraph devbase["devbaseルート"] @@ -26,7 +29,7 @@ flowchart TB S2["my-project-b → plugins/my-plugin/projects/my-project-b"] end end - PY -->|"devbase plugin install"| PS + RY -->|"devbase plugin install"| PS repo -->|clone| IP IP -->|symlink| S1 IP -->|symlink| S2 @@ -37,45 +40,29 @@ flowchart TB ## 基本構造 ```yaml -plugins: - - name: my-plugin - version: 1.0.0 - description: "プラグインの説明" - projects: - - name: my-project-a - description: "プロジェクトAの説明" - path: projects/my-project-a - - name: my-project-b - description: "プロジェクトBの説明" - path: projects/my-project-b +name: my-plugin +version: "1.0.0" +description: "プラグインの説明" +requires: + devbase: ">=3.0.0" +priority: 0 ``` +Plugin が提供するプロジェクトは `projects/` 配下のディレクトリから**自動的に検出**されます。 +`plugin.yml` にプロジェクトを列挙する必要はありません。 + --- ## フィールド一覧 -### トップレベル - -| フィールド | 型 | 必須 | 説明 | -|-----------|-----|------|------| -| `plugins` | array | Yes | Plugin定義のリスト | - -### Plugin定義 (`plugins[*]`) - -| フィールド | 型 | 必須 | 説明 | -|-----------|-----|------|------| -| `name` | string | Yes | Plugin名 | -| `version` | string | Yes | セマンティックバージョン | -| `description` | string | No | Pluginの説明 | -| `projects` | array | Yes | プロジェクト定義のリスト | - -### プロジェクト定義 (`plugins[*].projects[*]`) - -| フィールド | 型 | 必須 | 説明 | -|-----------|-----|------|------| -| `name` | string | Yes | プロジェクト名 | -| `description` | string | No | プロジェクトの説明 | -| `path` | string | Yes | リポジトリルートからの相対パス | +| フィールド | 型 | 必須 | 既定値 | 説明 | +|-----------|-----|------|--------|------| +| `name` | string | Yes | ディレクトリ名 | Plugin名 | +| `version` | string | No | `0.1.0` | セマンティックバージョン | +| `description` | string | No | `""` | Pluginの説明 | +| `requires` | map | No | なし | 動作要件。現在は `devbase` キーのみ | +| `requires.devbase` | string | No | なし | 必要な devbase 本体の最低バージョン(例: `">=3.0.0"`) | +| `priority` | int | No | `0` | プロジェクト名が他Pluginと衝突したときの優先度。大きいほうが `projects/` を取る | --- @@ -83,9 +70,10 @@ plugins: ### `name`(Plugin名) -Pluginを一意に識別する名前です。 +Pluginを一意に識別する名前です。省略するとディレクトリ名が使われますが、 +`registry.yml` の `plugins[*].name` と食い違うと追跡しにくいため明示してください。 -**バリデーションルール:** +**命名規則(推奨):** - 使用可能文字: 英小文字、数字、ハイフン(`a-z`, `0-9`, `-`) - 先頭はアルファベット @@ -110,8 +98,8 @@ name: a # 2文字未満 **フォーマット:** `MAJOR.MINOR.PATCH` ```yaml -version: 1.0.0 -version: 2.3.1 +version: "1.0.0" +version: "2.3.1" ``` | 要素 | 意味 | インクリメントするとき | @@ -122,41 +110,68 @@ version: 2.3.1 ### `description` -Pluginまたはプロジェクトの説明文です。 +Pluginの説明文です。 `devbase plugin list` で一覧表示されるため、簡潔に記述してください。 ```yaml description: "EC事業部のマイクロサービス群" ``` -### `projects` +### `requires` -Pluginに含まれるプロジェクトの一覧です。 -1つのPluginに複数のプロジェクトを含めることができます。 +この Plugin が動作するために必要な devbase 側の条件を書きます。 +現在使えるキーは `devbase`(本体の最低バージョン)だけです。 -### `projects[*].name`(プロジェクト名) +```yaml +requires: + devbase: ">=3.0.0" +``` -プロジェクトを一意に識別する名前です。 -インストール時に `projects//` としてシンボリックリンクが作成されます。 +| 値 | 意味 | +|----|------| +| `">=3.0.0"` | devbase 3.0.0 以上が必要 | +| 省略 | バージョン要件なし(どの版でも導入を試みる) | -**バリデーションルール:** +**devbase 3.0.0 以降のPluginは `">=3.0.0"` を指定してください。** +プロジェクト設定を `projects//project.yml` で記述する形式は devbase 3.0.0 で導入されたもので、 +2.x 系の devbase は `project.yml` を読めません。要件を書かないまま 2.x へ導入されると、 +インストールは成功するのに `devbase up` の段階で初めて失敗します。 -- Plugin名と同様の命名規則 -- devbase全体で一意であること(他のPluginのプロジェクト名と重複不可) +> `requires.devbase` を上げるのは、**Plugin が `project.yml` 形式へ移行したタイミング**です。 +> 本体の版数と一緒に自動では上がりません。 -### `projects[*].path` +### `priority` -リポジトリルートからの相対パスで、プロジェクトディレクトリの位置を指定します。 +同じ名前のプロジェクトを複数のPluginが提供したときに、どちらが `projects/` の +シンボリックリンクを取るかを決める整数です(既定 `0`、大きいほうが勝ち)。 +負けた側は `projects/.--` の形でリンクされ、どちらも利用できます。 ```yaml -path: projects/my-project +priority: 10 ``` -**注意事項:** +### プロジェクトの検出 + +`plugin.yml` にプロジェクト一覧は書きません。Plugin ディレクトリ直下の `projects/` にある +ディレクトリ(`.` で始まるものを除く)がそのままプロジェクトとして扱われ、インストール時に +devbase ルートの `projects//` へシンボリックリンクが作成されます。 + +``` +my-plugin/ +├── plugin.yml +└── projects/ + ├── my-project-a/ -> projects/my-project-a として公開される + │ ├── compose.yml + │ ├── project.yml + │ └── env + └── my-project-b/ + ├── compose.yml + ├── project.yml + └── env +``` -- パスの先頭に `/` を付けない(相対パスで記述する) -- 指定したディレクトリに `compose.yml` が存在すること -- 慣例として `projects/` ディレクトリ配下に配置する +各プロジェクトディレクトリの中身は +[クイックスタート](quickstart.md) と [project.yml リファレンス](../user/project-yml.md) を参照してください。 --- @@ -167,14 +182,11 @@ path: projects/my-project 最もシンプルな構成です。 ```yaml -plugins: - - name: my-api - version: 1.0.0 - description: "APIサーバー開発環境" - projects: - - name: my-api - description: "APIサーバー" - path: projects/my-api +name: my-api +version: "1.0.0" +description: "APIサーバー開発環境" +requires: + devbase: ">=3.0.0" ``` ディレクトリ構造: @@ -185,6 +197,7 @@ my-api/ └── projects/ └── my-api/ ├── compose.yml + ├── project.yml └── env ``` @@ -193,20 +206,11 @@ my-api/ 関連するプロジェクトをまとめて管理する場合に使います。 ```yaml -plugins: - - name: ecommerce - version: 2.1.0 - description: "ECサイト開発環境一式" - projects: - - name: ec-frontend - description: "フロントエンド(Next.js)" - path: projects/ec-frontend - - name: ec-backend - description: "バックエンドAPI(Go)" - path: projects/ec-backend - - name: ec-admin - description: "管理画面(Laravel)" - path: projects/ec-admin +name: ecommerce +version: "2.1.0" +description: "ECサイト開発環境一式" +requires: + devbase: ">=3.0.0" ``` ディレクトリ構造: @@ -217,37 +221,47 @@ ecommerce/ └── projects/ ├── ec-frontend/ │ ├── compose.yml + │ ├── project.yml │ └── env ├── ec-backend/ │ ├── compose.yml + │ ├── project.yml │ └── env └── ec-admin/ ├── compose.yml + ├── project.yml └── env ``` ### 1リポジトリに複数Pluginを含む場合 -`plugins` 配列に複数のPlugin定義を記述します。 -チーム横断で1つのリポジトリを共有する場合に使えます。 +`plugin.yml` は Plugin ごとに 1 ファイルです。1 つのリポジトリで複数の Plugin を配布する場合は、 +Plugin ごとにディレクトリを分けてそれぞれに `plugin.yml` を置き、リポジトリルートの +`registry.yml` に一覧を記述します。 + +``` +my-registry/ +├── registry.yml +├── team-alpha/ +│ ├── plugin.yml +│ └── projects/alpha-service/ +└── team-beta/ + ├── plugin.yml + └── projects/beta-service/ +``` + +`registry.yml`: ```yaml +name: my-registry +description: "社内レジストリ" plugins: - name: team-alpha - version: 1.0.0 + path: team-alpha description: "Alphaチームのプロジェクト" - projects: - - name: alpha-service - description: "Alphaチームのサービス" - path: projects/alpha-service - - name: team-beta - version: 1.2.0 + path: team-beta description: "Betaチームのプロジェクト" - projects: - - name: beta-service - description: "Betaチームのサービス" - path: projects/beta-service ``` --- @@ -258,7 +272,7 @@ devbaseには似た名前の2つのファイルがあります。混同しない | 項目 | plugin.yml | plugins.yml | |------|-----------|-------------| -| 配置場所 | Pluginリポジトリのルート | devbaseルートディレクトリ | +| 配置場所 | Pluginディレクトリのルート | devbaseルートディレクトリ | | 管理者 | Plugin開発者 | devbase(自動管理) | | 用途 | Pluginの定義・メタ情報 | インストール済みPluginのレジストリ | | Git管理 | Plugin側のリポジトリで管理 | devbase側のリポジトリで管理 | @@ -290,17 +304,16 @@ plugins: ## バリデーション -`plugin.yml` は `devbase plugin install` 実行時に自動的にバリデーションされます。 +`devbase plugin install` はリポジトリを clone したあと `registry.yml` と `plugin.yml` を読み込みます。 ### よくあるエラーと対処 | エラーメッセージ | 原因 | 対処 | |----------------|------|------| -| `Invalid plugin name` | 命名規則違反 | 英小文字・数字・ハイフンのみ使用 | -| `Invalid version format` | SemVer形式でない | `MAJOR.MINOR.PATCH` 形式に修正 | -| `Project directory not found` | pathが不正 | ディレクトリの存在を確認 | -| `compose.yml not found` | compose.ymlが未配置 | 指定ディレクトリに配置 | -| `Duplicate project name` | プロジェクト名が重複 | 一意な名前に変更 | +| `Failed to parse .../plugin.yml` | YAML の構文エラー | インデント・引用符を確認 | +| `No registry.yml found in repository` | リポジトリルートに `registry.yml` が無い | リポジトリルートに配置する | +| `Plugin '' not found in ` | `registry.yml` に該当 Plugin の記載が無い | `plugins[*].name` を確認 | +| `Plugin directory not found: ` | `registry.yml` の `path` が実在しない | `path` とディレクトリ名を突き合わせる | --- diff --git a/docs/plugin-dev/quickstart.md b/docs/plugin-dev/quickstart.md index 52c102a..db45c53 100644 --- a/docs/plugin-dev/quickstart.md +++ b/docs/plugin-dev/quickstart.md @@ -6,7 +6,7 @@ devbase用のPluginを作成し、公開するまでの手順を解説します ## 前提条件 -- devbase 2.2.0 以上がインストール済み +- devbase 3.0.0 以上がインストール済み - Git がインストール済み - Docker / Docker Compose が利用可能 @@ -23,20 +23,23 @@ git init ### 1.2 plugin.yml の配置 -リポジトリのルートに `plugin.yml` を作成します。 -このファイルはPluginのメタ情報とプロジェクト一覧を定義します。 +リポジトリのルート(Plugin ディレクトリのルート)に `plugin.yml` を作成します。 +このファイルは Plugin のメタ情報を定義します。 ```yaml -plugins: - - name: my-plugin - version: 1.0.0 - description: "サンプルプラグイン" - projects: - - name: my-project - description: "サンプルプロジェクト" - path: projects/my-project +name: my-plugin +version: "1.0.0" +description: "サンプルプラグイン" +requires: + devbase: ">=3.0.0" +priority: 0 ``` +**ポイント:** + +- プロジェクトは `projects/` 配下のディレクトリから自動的に検出されます。`plugin.yml` に一覧を書く必要はありません。 +- `requires.devbase` には **この Plugin が動作する devbase 本体の最低バージョン**を書きます。`project.yml` 形式のプロジェクト定義は devbase 3.0.0 以降でしか読めないため、`project.yml` を持つ Plugin(= 本手順で作るもの)は必ず `">=3.0.0"` を指定してください。 + > **補足:** `plugin.yml` のフォーマット詳細は [plugin.yml リファレンス](plugin-yml-reference.md) を参照してください。 --- diff --git a/docs/user/env-export-import.md b/docs/user/env-export-import.md index 8eb9ce2..5997562 100644 --- a/docs/user/env-export-import.md +++ b/docs/user/env-export-import.md @@ -74,7 +74,7 @@ env/projects//.env ```yaml version: 1 created_at: '2026-05-21T10:00:00+09:00' -devbase_version: 2.2.0 +devbase_version: 3.0.0 files: - path: env/global.env sha256: <64 文字 hex> diff --git a/lib/devbase/__init__.py b/lib/devbase/__init__.py index 24ca405..9eaa5dd 100644 --- a/lib/devbase/__init__.py +++ b/lib/devbase/__init__.py @@ -1,4 +1,4 @@ """devbase - Docker-based Development Environment Manager""" -__version__ = "2.2.0" +__version__ = "3.0.0" __author__ = "devbase team" diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 1371120..747b8d1 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -14,7 +14,7 @@ try: from . import __version__ except ImportError: - __version__ = "2.2.0" + __version__ = "3.0.0" logger = get_logger("devbase.cli") diff --git a/lib/devbase/commands/status.py b/lib/devbase/commands/status.py index 55d8bb2..65d104e 100644 --- a/lib/devbase/commands/status.py +++ b/lib/devbase/commands/status.py @@ -10,7 +10,7 @@ try: from devbase import __version__ except ImportError: - __version__ = "2.2.0" + __version__ = "3.0.0" logger = get_logger(__name__) diff --git a/pyproject.toml b/pyproject.toml index 7a03a1e..8af0bd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "devbase" -version = "2.2.0" +version = "3.0.0" description = "Docker-based Development Environment Manager" requires-python = ">=3.10" dependencies = [ diff --git a/uv.lock b/uv.lock index 3e2a11d..b33e944 100644 --- a/uv.lock +++ b/uv.lock @@ -41,7 +41,7 @@ wheels = [ [[package]] name = "devbase" -version = "2.2.0" +version = "3.0.0" source = { virtual = "." } dependencies = [ { name = "boto3" }, From 317c4c92891f4607caee3afc28a7cab5a6eaa46d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=B5=9C=E6=AF=85=E7=BE=8E?= Date: Sun, 23 Aug 2026 02:35:01 +0900 Subject: [PATCH 8/8] =?UTF-8?q?feat:=20pre-up=20/=20deploy=20=E3=83=95?= =?UTF-8?q?=E3=83=83=E3=82=AF=E3=81=B8=20clone=20=E5=85=88=E3=81=A8?= =?UTF-8?q?=E3=83=AA=E3=83=9D=E3=82=B8=E3=83=88=E3=83=AA=20URL=20=E3=82=92?= =?UTF-8?q?=E6=B8=A1=E3=81=99=20(#110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: project.yml 方式のドキュメントを整備する PLAN32 Task 5。複数リポジトリ構成と project.yml への移行に合わせて利用者向け ドキュメントを更新した。 - docs/user/project-yml.md を新設: スキーマ、複数リポジトリの例、検証される 内容、env との使い分け、旧 env からの移行表、コンテナへの渡り方 - 環境変数ガイド / コンテナ操作ガイド / CLI リファレンス / プラグイン開発の 各所から旧キー (GIT_USER / GIT_REPO / WORK_DIR / CONTAINER_SCALE) の説明を 外し、project.yml とその参照へ置き換え - CLI リファレンスに devbase project migrate-config を追記 - アーキテクチャ解説に project/ モジュールの節を追加 - CHANGELOG に破壊的変更と再ビルドの注意を追記 過去リリース分の CHANGELOG 記述は履歴なので書き換えていない。 Co-Authored-By: Claude Opus 5 (1M context) * docs: レビュー指摘対応 — アンカー切れ修正と env ファイル必須の明記 - repo-backed-projects.md: 見出し変更で切れた `#スケール前提-container_scale1` 参照 2 箇所を `#スケール前提-scale-1` に修正 - quickstart.md: `env` はファイル自体が必須(中身は任意)と見出しから分かるよう変更し、 最小構成のディレクトリツリーへ `project.yml` を追加 - project-yml.md: `env` ファイルが必須である旨を同じ表現に揃える Co-Authored-By: Claude Opus 5 (1M context) * docs: DEVBASE_OPEN_EDITOR は env init の収集対象であることを明記 `devbase env init` の editor コレクター (lib/devbase/env/collectors/editor.py) が `DEVBASE_OPEN_EDITOR` を対話収集し、対話の既定は `1` (有効) である。 「収集対象外」「既定: OFF」という記述は実装と矛盾していたため、収集対象で あることと、OFF に倒れるのはキー自体が未設定のときだけである旨へ修正した。 Co-Authored-By: Claude Opus 5 (1M context) * docs: init.sh の実行頻度と DEVBASE_WORKSPACE の有効範囲を実装に合わせる - project.yml リファレンス: `init` は clone 直後だけでなくコンテナ起動の たびに `./init.sh` を実行することを明記し、`branch` (clone 直後のみ) との タイミング差の表と冪等性の注意を追加 - 環境変数: `DEVBASE_WORKSPACE` が効くのはリポジトリ 1 件の構成だけで、 2 件以上では自動生成した workspace を直接開くため上書きできない旨を明記 - container-operations / plugin-dev quickstart の関連記述も同じ挙動へ揃える Co-Authored-By: Claude Opus 5 (1M context) * feat(hooks): pre-up / deploy へ clone 先とリポジトリ URL を渡す PLAN32 で GIT_REPO / WORK_DIR が env から project.yml へ移った結果、それらを `source ./env` で読んでいたライフサイクルフックが値を取れなくなる。実際に plugin リポジトリの pre-up / deploy が壊れることを結合検証で確認した。 フックはホスト側で動くため、devbase 側から明示的に環境変数として渡す: - DEVBASE_PRIMARY_DIR: primary repo の /work 配下ディレクトリ名 - DEVBASE_PRIMARY_URL: primary repo の clone URL - DEVBASE_WORK_DIR: コンテナ内の既定の作業ディレクトリ - DEVBASE_REPO_DIRS: 全 repo のディレクトリ名 (空白区切り、宣言順) env を経由せず devbase が渡すことで、複数リポジトリ構成でもフックが clone 先を 一意に知れる。渡した値は子プロセス限定で、親の os.environ は汚さない。 Co-Authored-By: Claude Opus 5 (1M context) * docs: 読み込み順序の表からも env の旧用途を外す env の役割を「コンテナへ渡す環境変数」と書き換えたのに、直上の表だけ 「リポジトリ名・コンテナ数等」と旧仕様のままで矛盾していた。 Co-Authored-By: Claude Opus 5 (1M context) * docs: フックへ渡る環境変数を公式ガイドへ反映 pre-up / deploy へ渡す DEVBASE_PRIMARY_DIR / DEVBASE_PRIMARY_URL / DEVBASE_WORK_DIR / DEVBASE_REPO_DIRS は公開契約だが、ガイドは移行前の GIT_REPO / WORK_DIR を案内したままだった。フック作者がこの契約を使って 移行できないため、次を追記する。 - quickstart: 「フックへ渡る環境変数」表 (pre-up / deploy の別と DEVBASE_INSTANCE_INDEX が pre-up に渡らないことを含む) と、 DEVBASE_PRIMARY_URL を使う pre-up サンプル - repo-backed-projects: clone 先 / URL の受け取り方と pre-up 骨子、 関連環境変数節への追記、チェックリスト項目 - project.yml リファレンス: project.yml の各キーとの対応表 - CHANGELOG: Unreleased / Added へ本機能を記載 Co-Authored-By: Claude Opus 5 (1M context) * test: フック環境変数のテストが実行環境に影響されないようにする 親プロセスへ漏れないことを確かめるテストは、実行環境に同名の変数が居ると 偽陰性/偽陽性になる。事前に unset して初期状態を固定した。 Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- CHANGELOG.md | 9 +++ docs/plugin-dev/quickstart.md | 32 ++++++++- docs/plugin-dev/repo-backed-projects.md | 46 +++++++++++- docs/user/project-yml.md | 18 +++++ lib/devbase/commands/container.py | 31 +++++--- lib/devbase/project/runtime.py | 22 ++++++ tests/commands/test_container_up_order.py | 2 +- tests/commands/test_hook_env.py | 87 +++++++++++++++++++++++ tests/project/test_runtime.py | 25 +++++++ 9 files changed, 257 insertions(+), 15 deletions(-) create mode 100644 tests/commands/test_hook_env.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b131b03..58ab1e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,15 @@ > 適用には `devbase build --no-cache` によるベースイメージの再ビルドが必要です。 ### Added +- **ライフサイクルフックへ `project.yml` の値を環境変数で渡す**ようにしました。`pre-up` / + `deploy` に `DEVBASE_PRIMARY_DIR` (primary の clone 先ディレクトリ名) / + `DEVBASE_PRIMARY_URL` (primary の clone URL) / `DEVBASE_WORK_DIR` (コンテナ内の既定の + 作業ディレクトリ) / `DEVBASE_REPO_DIRS` (全リポジトリのディレクトリ名・宣言順の空白 + 区切り) が渡ります。フックはホスト側で動くため `env` を読み込めず、`GIT_REPO` / + `WORK_DIR` の `project.yml` 移行によって `source ./env` に依存していたフックが値を + 取れなくなるためです。値は子プロセス限定で、親プロセスの環境は汚しません。 + 詳細は [フックへ渡る環境変数](docs/plugin-dev/quickstart.md#フックへ渡る環境変数) + を参照してください。 - **`devbase project migrate-config`** を追加しました。旧 `env` 形式のプロジェクト定義を `project.yml` へ機械的に変換します。`--dry-run` で変換結果を確認でき、既存の `project.yml` は上書きしないため何度実行しても同じ状態に収束します。 diff --git a/docs/plugin-dev/quickstart.md b/docs/plugin-dev/quickstart.md index db45c53..aec5443 100644 --- a/docs/plugin-dev/quickstart.md +++ b/docs/plugin-dev/quickstart.md @@ -170,19 +170,45 @@ MY_SECRET_API_KEY=sk-xxxxxxxxxxxx | `pre-up` | `devbase up` 開始直後(`docker compose up` の前) | `build.context` 用ソースリポジトリの clone、設定ファイルの生成など、イメージビルド前に完了させたい準備 | | `deploy` | コンテナ起動完了後、各スケールインスタンスごとに実行 | S3 からの `.env` 取得、コンテナ起動後に必要な外部リソースの初期化など | +#### フックへ渡る環境変数 + +フックは**ホスト側**で動くため、コンテナへ渡る `env` / `.env` は読み込まれません。フックが必要とする `project.yml` の値は、devbase が環境変数として明示的に渡します。 + +| 変数 | 内容 | `pre-up` | `deploy` | +|------|------|:---:|:---:| +| `DEVBASE_PRIMARY_DIR` | primary リポジトリの `/work` 配下ディレクトリ名(`repos[].dir`。未指定ならリポジトリ名) | ✓ | ✓ | +| `DEVBASE_PRIMARY_URL` | primary リポジトリの clone URL(`https:////.git`) | ✓ | ✓ | +| `DEVBASE_WORK_DIR` | コンテナ内の既定の作業ディレクトリ(`work_dir`。未指定なら `/work/$DEVBASE_PRIMARY_DIR`) | ✓ | ✓ | +| `DEVBASE_REPO_DIRS` | 全リポジトリのディレクトリ名を `project.yml` の宣言順に空白区切りで並べたもの | ✓ | ✓ | +| `DEVBASE_INSTANCE_INDEX` | 実行対象のインスタンス番号(1 始まり)。`pre-up` はインスタンスごとに実行されないため渡りません | -- | ✓ | + +primary は `repos` の先頭(または `primary: true` を付けた 1 件)で、常にちょうど 1 件です。primary 以外も含めて全リポジトリを回したい場合は `DEVBASE_REPO_DIRS` を使います。 + +```bash +for dir in $DEVBASE_REPO_DIRS; do + echo "populate /work/$dir" +done +``` + +> **Note:** これらは**子プロセスにだけ**渡ります。フック内で `export` しても、後続の `docker compose up` や別プロジェクトの実行へは伝播しません。 + +#### `pre-up` の例 + ```bash #!/bin/bash # projects/my-project/pre-up set -e # build context に使うリポジトリが無ければ clone -# (pre-up はホスト側で動くフックなので、clone 先も URL もここに直接書く) +# (clone 先も URL も devbase が project.yml から渡してくれる) if [ ! -d "./repo" ]; then - git clone "https://github.com/your-github-user/my-repo.git" repo + git clone "$DEVBASE_PRIMARY_URL" repo fi + +echo "コンテナ内の作業ディレクトリ: $DEVBASE_WORK_DIR" # 例: /work/my-repo ``` -> **Note:** どちらのフックも `bash` で実行されます。`chmod +x` で実行可能ビットを立てておいてください。`pre-up` が非ゼロ終了すると `devbase up` は中断します。`deploy` は各インスタンスに対して `DEVBASE_INSTANCE_INDEX` を環境変数として渡しますが、失敗してもデプロイは続行されます。 +> **Note:** どちらのフックも `bash` で実行されます。`chmod +x` で実行可能ビットを立てておいてください。`pre-up` が非ゼロ終了すると `devbase up` は中断します。`deploy` は失敗してもデプロイは続行されます。 > **応用:** 外部リポジトリを共有 work ボリュームへ取り込み、app / nginx / db など複数コンテナで動かすプロジェクトでは、`pre-up` で clone/pull と work ボリュームへの populate を行い、2 回目以降はコンテナ側を上書きしないよう冪等にスキップするのが定石です。詳細は [repo 連携プロジェクトと pre-up populate パターン](repo-backed-projects.md) を参照してください。 diff --git a/docs/plugin-dev/repo-backed-projects.md b/docs/plugin-dev/repo-backed-projects.md index 6a979db..f30c6a5 100644 --- a/docs/plugin-dev/repo-backed-projects.md +++ b/docs/plugin-dev/repo-backed-projects.md @@ -88,10 +88,40 @@ volumes: | # | 処理 | 内容 | |---|------|------| -| ① | `repo/` の clone / pull | 無ければ `git clone`、あれば `git pull --ff-only`(app ビルドコンテキストの最新化) | +| ① | `repo/` の clone / pull | 無ければ `git clone "$DEVBASE_PRIMARY_URL"`、あれば `git pull --ff-only`(app ビルドコンテキストの最新化) | | ② | `.env` の取得 | S3 等から取得してホスト `./.env` に配置(`docker compose` の変数展開前に必要) | -| ③ | work ボリュームへ populate | `repo/` の内容を `/work/<リポジトリ名>` へコピー | -| ④ | `.env` を work ボリュームへ配置 | Laravel 等のランタイムが `/work/<リポジトリ名>/.env` を参照するため | +| ③ | work ボリュームへ populate | `repo/` の内容を `/work/$DEVBASE_PRIMARY_DIR` へコピー | +| ④ | `.env` を work ボリュームへ配置 | Laravel 等のランタイムが `/work/$DEVBASE_PRIMARY_DIR/.env` を参照するため | + +### clone 先と URL の受け取り方 + +`pre-up` はホスト側で動くフックなので、コンテナへ渡る `env` は読み込まれません。populate 先のディレクトリ名と clone URL は、devbase が `project.yml` から解決して環境変数で渡します。 + +```bash +#!/bin/bash +# projects//pre-up +set -e + +REPO_DIR="$DEVBASE_PRIMARY_DIR" # 例: carmo-system-console +WORK_VOLUME="${DEVBASE_WORK_VOLUME:-devbase_work_1}" + +# ① ビルドコンテキストの clone / pull +if [ ! -d "./repo/.git" ]; then + git clone "$DEVBASE_PRIMARY_URL" repo +elif [ "${DEVBASE_REPO_PULL:-1}" = "1" ]; then + git -C repo pull --ff-only +fi + +# populate 済み判定は work ボリューム上の /work/$REPO_DIR/.git で行う +if docker run --rm -v "$WORK_VOLUME:/work" alpine test -d "/work/$REPO_DIR/.git"; then + echo "populate 済みのためスキップ" + exit 0 +fi + +# ②③④ (.env 取得 / populate / .env 配置) は /work/$REPO_DIR を対象に行う +``` + +変数の一覧と `deploy` への渡り方は [クイックスタート「フックへ渡る環境変数」](quickstart.md#フックへ渡る環境変数) を参照してください。`project.yml` に複数リポジトリを書いている場合は、`DEVBASE_REPO_DIRS`(宣言順・空白区切り)で全 clone 先を回せます。 ② を `deploy`(`up` 後フック)ではなく `pre-up` で行うのは、`compose.yml` の `MYSQL_DATABASE: ${DB_DATABASE:-...}` のような変数展開が `docker compose` パース時(= MySQL コンテナ初回起動前)に `.env` を要求するためです。`deploy` 段階では間に合わず、DB がデフォルト名で初期化されてしまいます。 @@ -185,6 +215,15 @@ devbase up | `DEVBASE_WORK_VOLUME` | `devbase_work_` | `compose.yml` が参照する共有 work ボリューム名の明示指定。未指定なら `DEVBASE_INSTANCE_INDEX` から解決。ただし効くのは **app / nginx など非 dev サービスだけ**で、dev サービスの `/work` は scale 生成時に `devbase_work_` へ無条件に差し替えられます。dev から実行時ソースを触る本パターンでは **既定名のまま**にしてください([スケール前提](#スケール前提-scale-1) を参照) | | `DEVBASE_INSTANCE_INDEX` | `1` | work ボリューム名のインデックス。**devbase 本体が渡すのは `deploy` フックに対してのみ**で、`pre-up` や `docker compose` のプロセス環境には渡りません。`compose.yml` の `${DEVBASE_INSTANCE_INDEX:-1}` は `.env` に書かれた値、無ければ `1` に解決されます([スケール前提](#スケール前提-scale-1) を参照) | +上記はプロジェクト側が設定する変数です。これに対し、次の 4 つは **devbase が `project.yml` から解決して `pre-up` / `deploy` の両方へ渡す**読み取り専用の値です(詳細は [クイックスタート「フックへ渡る環境変数」](quickstart.md#フックへ渡る環境変数))。 + +| 変数 | 内容 | +|------|------| +| `DEVBASE_PRIMARY_DIR` | primary リポジトリの `/work` 配下ディレクトリ名(populate 先) | +| `DEVBASE_PRIMARY_URL` | primary リポジトリの clone URL | +| `DEVBASE_WORK_DIR` | コンテナ内の既定の作業ディレクトリ | +| `DEVBASE_REPO_DIRS` | 全リポジトリのディレクトリ名(宣言順・空白区切り) | + > **Note:** `.env` の環境選択(例: `s3://.../env/local.env` の `local` 部分)など、S3 パスやプロファイルはプロジェクト固有の変数(例: `CARMO_ENV`)で制御することがあります。プロジェクトの `pre-up` 冒頭コメントを参照してください。 --- @@ -196,6 +235,7 @@ devbase up - [ ] `compose.yml` で work ボリュームを `external: true` + `name: ${DEVBASE_WORK_VOLUME:-devbase_work_${DEVBASE_INSTANCE_INDEX:-1}}` で宣言した - [ ] app サービスの `build.context` をホスト `./repo` にした - [ ] `pre-up` で ①clone/pull → ②`.env`取得 → ③populate → ④`.env`配置 を実装した +- [ ] `pre-up` が clone 先・URL を `DEVBASE_PRIMARY_DIR` / `DEVBASE_PRIMARY_URL` から受け取っている(`source ./env` でも値を直書きでもなく) - [ ] `pre-up` が `/work/<リポジトリ名>/.git` の有無で populate 済みを判定し、②③④ をスキップする - [ ] populate 時の owner を `1000:1000`(コンテナ内ユーザー)に設定した - [ ] `storage/` / `vendor/` / `node_modules/` 等、初回のみ生成され上書きしたくないパスの扱いを決めた diff --git a/docs/user/project-yml.md b/docs/user/project-yml.md index 0fdb27c..0dbfad4 100644 --- a/docs/user/project-yml.md +++ b/docs/user/project-yml.md @@ -147,6 +147,24 @@ flowchart LR YAML の解釈はホスト側の Python に閉じています。コンテナ側は復号して 1 行ずつ clone するだけなので、イメージへ YAML パーサを持ち込みません。 +## ライフサイクルフックへの渡り方 + +プロジェクトに `pre-up` / `deploy` フックを置いている場合、それらは**ホスト側**で +動くため `env` を読み込めません。フックがよく必要とする値は、devbase が +`project.yml` から解決して環境変数として渡します。 + +| 環境変数 | `project.yml` の由来 | +|---------|---------------------| +| `DEVBASE_PRIMARY_DIR` | primary リポジトリの `repos[].dir`(未指定ならリポジトリ名) | +| `DEVBASE_PRIMARY_URL` | primary リポジトリの `host` / `owner` / `repo` から組み立てた clone URL | +| `DEVBASE_WORK_DIR` | `work_dir`(未指定なら `/work/`) | +| `DEVBASE_REPO_DIRS` | `repos[].dir` を宣言順に空白区切りで並べたもの | + +旧 `env` 形式のフックが `source ./env` で読んでいた `GIT_REPO` / `WORK_DIR` は、 +それぞれ `DEVBASE_PRIMARY_DIR` / `DEVBASE_WORK_DIR` に置き換えてください。詳細は +[プラグイン開発クイックスタート「フックへ渡る環境変数」](../plugin-dev/quickstart.md#フックへ渡る環境変数) +を参照してください。 + > **Note:** `entrypoint.sh` はイメージに焼き込まれます。devbase 本体を更新して > clone のふるまいが変わった場合は、`devbase build --no-cache` でベースイメージを > 再ビルドしないと反映されません。 diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index cfd760b..197df26 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -146,11 +146,17 @@ def _compose_run(subcommand: str, *extra_args: str) -> int: return subprocess.run(cmd).returncode -def _run_deploy_script_for_instances(deploy_script: Path, indices) -> None: - """デプロイスクリプトをスケールされた各インスタンスに対して実行する""" +def _run_deploy_script_for_instances(deploy_script: Path, indices, + config=None) -> None: + """デプロイスクリプトをスケールされた各インスタンスに対して実行する。 + + ``config`` (``project.yml``) を渡すと、clone 先やリポジトリ URL をフックへ + 環境変数で伝える (:func:`devbase.project.runtime.hook_env`)。 + """ + hook_vars = project_runtime.hook_env(config) if config is not None else {} for i in indices: logger.info("[Bonus] Running deploy script for instance %d...", i) - env = {**os.environ, 'DEVBASE_INSTANCE_INDEX': str(i)} + env = {**os.environ, **hook_vars, 'DEVBASE_INSTANCE_INDEX': str(i)} try: subprocess.run(['bash', str(deploy_script)], check=True, env=env) logger.info("Deploy script completed for instance %d", i) @@ -158,12 +164,17 @@ def _run_deploy_script_for_instances(deploy_script: Path, indices) -> None: logger.warning("Deploy script failed for instance %d (exit code %d)", i, e.returncode) -def _run_pre_up_hook() -> bool: +def _run_pre_up_hook(config=None) -> bool: """`./pre-up` フックがあればコンテナ起動前に実行する。 ビルドコンテキスト用のリポジトリ clone など、`docker compose up` より前に 完了しておく必要のある準備処理をプロジェクト側で記述するためのフック。 + ``config`` (``project.yml``) を渡すと、clone 先やリポジトリ URL をフックへ + 環境変数で伝える (:func:`devbase.project.runtime.hook_env`)。フックは以前 + ``source ./env`` で ``GIT_REPO`` / ``WORK_DIR`` を読んでいたが、これらは + ``project.yml`` へ移ったため devbase 側から明示的に渡す。 + Returns: True: フックが存在しなかった、または成功した False: フックが失敗した(呼び出し側で `cmd_up` を中断する) @@ -173,8 +184,10 @@ def _run_pre_up_hook() -> bool: return True logger.info("Running pre-up hook: %s", pre_up_script) + hook_vars = project_runtime.hook_env(config) if config is not None else {} try: - subprocess.run(['bash', str(pre_up_script)], check=True, env=os.environ.copy()) + subprocess.run(['bash', str(pre_up_script)], check=True, + env={**os.environ, **hook_vars}) return True except subprocess.CalledProcessError as e: logger.error("pre-up hook failed (exit code %d)", e.returncode) @@ -644,7 +657,7 @@ def cmd_up(project_name: str = None, scale: int = None, return 1 # Pre-step: Run ./pre-up hook (e.g. clone source repos used as build contexts) - if not _run_pre_up_hook(): + if not _run_pre_up_hook(config): return 1 # Pre-check 2: Ensure container images exist @@ -693,7 +706,8 @@ def cmd_up(project_name: str = None, scale: int = None, # Run project-specific deploy script for each scaled instance deploy_script = Path('./deploy') if deploy_script.exists() and deploy_script.is_file(): - _run_deploy_script_for_instances(deploy_script, range(1, scale + 1)) + _run_deploy_script_for_instances(deploy_script, range(1, scale + 1), + config) _maybe_open_editor(project_name, open_editor, open_index, scale, config, compose_file=override_file) @@ -840,7 +854,8 @@ def cmd_scale(new_scale: int, project_name: str = None) -> int: # Run project-specific deploy script for newly added instances deploy_script = Path('./deploy') if deploy_script.exists() and deploy_script.is_file(): - _run_deploy_script_for_instances(deploy_script, range(current_scale + 1, new_scale + 1)) + _run_deploy_script_for_instances( + deploy_script, range(current_scale + 1, new_scale + 1), config) logger.info("=== Scale completed successfully ===") logger.info("Container scale: %d -> %d", current_scale, new_scale) diff --git a/lib/devbase/project/runtime.py b/lib/devbase/project/runtime.py index ac97007..62270db 100644 --- a/lib/devbase/project/runtime.py +++ b/lib/devbase/project/runtime.py @@ -65,6 +65,27 @@ def container_env(config: ProjectConfig, project_name: str) -> Dict[str, str]: return env +def hook_env(config: ProjectConfig) -> Dict[str, str]: + """``pre-up`` / ``deploy`` フックへ渡す環境変数を組み立てる。 + + フックはホスト側で動き、clone 先のパスやリポジトリ URL を必要とすることが + ある (共有ボリュームへの populate、Laravel の ``.env`` 配置など)。以前は + ``env`` の ``GIT_REPO`` / ``WORK_DIR`` を ``source ./env`` で読んでいたが、 + それらは ``project.yml`` へ移ったため devbase 側から明示的に渡す。 + + - ``DEVBASE_PRIMARY_DIR`` : primary repo の ``/work`` 配下ディレクトリ名 + - ``DEVBASE_PRIMARY_URL`` : primary repo の clone URL + - ``DEVBASE_WORK_DIR`` : コンテナ内の既定の作業ディレクトリ + - ``DEVBASE_REPO_DIRS`` : 全 repo のディレクトリ名 (空白区切り、宣言順) + """ + return { + "DEVBASE_PRIMARY_DIR": config.primary.dir, + "DEVBASE_PRIMARY_URL": config.primary.url, + "DEVBASE_WORK_DIR": config.resolved_work_dir(), + "DEVBASE_REPO_DIRS": " ".join(repo.dir for repo in config.repos), + } + + # --------------------------------------------------------------------------- # scale (旧 CONTAINER_SCALE) # --------------------------------------------------------------------------- @@ -136,6 +157,7 @@ def current_project_config(project_dir: Path = None) -> ProjectConfig: "build_workspace_document", "container_env", "current_project_config", + "hook_env", "read_scale", "workspace_path", "write_scale", diff --git a/tests/commands/test_container_up_order.py b/tests/commands/test_container_up_order.py index fac04d3..f5df914 100644 --- a/tests/commands/test_container_up_order.py +++ b/tests/commands/test_container_up_order.py @@ -35,7 +35,7 @@ def up_harness(tmp_path, monkeypatch): monkeypatch.setattr(container, 'get_project_name', lambda: 'proj') monkeypatch.setattr(container, 'get_dev_service_name', lambda: 'dev') monkeypatch.setattr(container, '_ensure_env_files', lambda: True) - monkeypatch.setattr(container, '_run_pre_up_hook', lambda: True) + monkeypatch.setattr(container, '_run_pre_up_hook', lambda config=None: True) monkeypatch.setattr(container, '_ensure_images', lambda: True) monkeypatch.setattr(container, '_auto_snapshot', lambda: None) monkeypatch.setattr(container, 'ensure_volumes', lambda *a, **k: None) diff --git a/tests/commands/test_hook_env.py b/tests/commands/test_hook_env.py new file mode 100644 index 0000000..120e519 --- /dev/null +++ b/tests/commands/test_hook_env.py @@ -0,0 +1,87 @@ +"""ライフサイクルフックへ渡す環境変数 (PLAN32) + +``pre-up`` / ``deploy`` はホスト側で動き、clone 先のパスを必要とすることがある。 +旧構成では ``source ./env`` で ``GIT_REPO`` / ``WORK_DIR`` を読んでいたが、これらは +``project.yml`` へ移ったため devbase 側から明示的に渡す。 +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from devbase.commands import container +from devbase.project.config import parse_project_config + +DUMP_SCRIPT = '''#!/bin/bash +{ + echo "DEVBASE_PRIMARY_DIR=$DEVBASE_PRIMARY_DIR" + echo "DEVBASE_PRIMARY_URL=$DEVBASE_PRIMARY_URL" + echo "DEVBASE_WORK_DIR=$DEVBASE_WORK_DIR" + echo "DEVBASE_REPO_DIRS=$DEVBASE_REPO_DIRS" + echo "DEVBASE_INSTANCE_INDEX=$DEVBASE_INSTANCE_INDEX" +} > dump.txt +''' + + +@pytest.fixture +def config(): + return parse_project_config({ + "version": 1, + "defaults": {"owner": "volareinc"}, + "repos": [{"repo": "carmo"}, {"repo": "carmo-batch"}], + }, source="project.yml") + + +def dumped(tmp_path: Path) -> dict: + lines = (tmp_path / "dump.txt").read_text().splitlines() + return dict(line.split("=", 1) for line in lines) + + +def test_pre_up_hook_receives_the_repo_layout(tmp_path, monkeypatch, config): + monkeypatch.chdir(tmp_path) + (tmp_path / "pre-up").write_text(DUMP_SCRIPT) + + assert container._run_pre_up_hook(config) is True + + values = dumped(tmp_path) + assert values["DEVBASE_PRIMARY_DIR"] == "carmo" + assert values["DEVBASE_PRIMARY_URL"] == "https://github.com/volareinc/carmo.git" + assert values["DEVBASE_WORK_DIR"] == "/work/carmo" + assert values["DEVBASE_REPO_DIRS"] == "carmo carmo-batch" + + +def test_deploy_hook_receives_the_repo_layout_and_index(tmp_path, monkeypatch, config): + monkeypatch.chdir(tmp_path) + deploy = tmp_path / "deploy" + deploy.write_text(DUMP_SCRIPT) + + container._run_deploy_script_for_instances(deploy, [2], config) + + values = dumped(tmp_path) + assert values["DEVBASE_WORK_DIR"] == "/work/carmo" + assert values["DEVBASE_INSTANCE_INDEX"] == "2" + + +def test_hooks_still_run_without_a_config(tmp_path, monkeypatch): + """設定を渡さない呼び出し (旧 scale 経路) でもフック自体は動く""" + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("DEVBASE_WORK_DIR", raising=False) + (tmp_path / "pre-up").write_text(DUMP_SCRIPT) + + assert container._run_pre_up_hook() is True + + assert dumped(tmp_path)["DEVBASE_WORK_DIR"] == "" + + +def test_hook_env_does_not_leak_into_the_parent_process(tmp_path, monkeypatch, config): + monkeypatch.chdir(tmp_path) + # 実行環境に同名の変数が居ても判定がぶれないよう、事前状態を固定する + monkeypatch.delenv("DEVBASE_WORK_DIR", raising=False) + (tmp_path / "pre-up").write_text(DUMP_SCRIPT) + + container._run_pre_up_hook(config) + + assert "DEVBASE_WORK_DIR" not in os.environ diff --git a/tests/project/test_runtime.py b/tests/project/test_runtime.py index d08f8af..da8588c 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, + hook_env, read_scale, workspace_path, write_scale, @@ -146,3 +147,27 @@ def test_write_scale_rejects_a_broken_result(tmp_path): with pytest.raises(ConfigError, match="scale"): write_scale(tmp_path, 0) assert "scale" not in (tmp_path / "project.yml").read_text() + + +# --------------------------------------------------------------------------- +# ライフサイクルフックへ渡す環境変数 +# --------------------------------------------------------------------------- + +def test_hook_env_exposes_the_primary_repo_and_work_dir(): + """`pre-up` / `deploy` は clone 先を知る必要がある (旧 WORK_DIR / GIT_REPO の代替)""" + env = hook_env(config_of("carmo", "carmo-batch")) + + assert env["DEVBASE_PRIMARY_DIR"] == "carmo" + assert env["DEVBASE_PRIMARY_URL"] == "https://github.com/volareinc/carmo.git" + assert env["DEVBASE_WORK_DIR"] == "/work/carmo" + assert env["DEVBASE_REPO_DIRS"] == "carmo carmo-batch" + + +def test_hook_env_follows_explicit_work_dir_and_primary(): + config = config_of({"repo": "carmo-doc"}, {"repo": "carmo", "primary": True}, + work_dir="/work/carmo/app") + + env = hook_env(config) + + assert env["DEVBASE_PRIMARY_DIR"] == "carmo" + assert env["DEVBASE_WORK_DIR"] == "/work/carmo/app"