diff --git a/docs/developer/contributing.md b/docs/developer/contributing.md index cdc0dc9a..75b21daa 100644 --- a/docs/developer/contributing.md +++ b/docs/developer/contributing.md @@ -241,7 +241,16 @@ PR には以下の情報を記載する。 ### 現状のテスト方針 -devbase は現時点では**手動テスト中心**で運用している。変更時は以下の手順で動作確認を行う。 +`tests/` の pytest と、Docker や実環境が要る範囲の手動テストを併用する。手元では +`uv run --locked pytest tests/ -q` で全体を回し、実機の挙動は以下の手順で確認する。 + +### CI が実行するもの + +`main` 宛ての Pull Request と `main` への push で `.github/workflows/ci.yml` が走り、 +`compileall`(Python 3.10 / 3.11 / 3.12)・`ruff check --select=E9,F63,F7,F82 lib`・ +`bin/` と `install.sh` の ShellCheck・`uv sync --locked` の後の `pytest tests/` +(Python 3.10 / 3.13)を実行する。CI に `DEVBASE_ROOT` と Docker は無いため、テストは +自前の一時ディレクトリを `DEVBASE_ROOT` に向け、実機を要するものは理由を添えて skip する。 ### 手動テストの手順 diff --git a/docs/specifications/cli-argument-resolution.md b/docs/specifications/cli-argument-resolution.md new file mode 100644 index 00000000..5adf34df --- /dev/null +++ b/docs/specifications/cli-argument-resolution.md @@ -0,0 +1,349 @@ +# 位置引数の解決(プロジェクト名・イメージ名) + +## 概要 + +`devbase <コマンド> <値>` の `<値>` を、プロジェクト名として解釈するか、そのコマンド固有の意味 +(イメージ名・`login` の番号・`scale` の台数)のまま下流へ渡すかを決める規則。判定は起動ラッパー +`bin/devbase`(name 解決)と、ラッパーを経ない直接起動に備える Python 側の検証の 2 か所にあり、 +どちらも同じ名前の形を使う。 + +| 入口 | 名前の位置 | 解決するもの | +| --- | --- | --- | +| `devbase `(ショートカット) | 2 番目 | `up` `down` `ps` `scale` `login` `build` `rebuild` `open` | +| `devbase project ` | 3 番目 | `up` `down` `ps` `logs` `scale` `rebuild` `open` | +| `devbase container …` / `ct`(非推奨) | — | 解決しない(`[name]` を受け付けない) | +| `python -m devbase.cli project ` | 3 番目 | Python 側のフォールバック(`_resolve_project_name`) | + +名前として解釈した値は、ラッパーが `$DEVBASE_ROOT/projects/` へ `cd` して引数から取り除く。 +`devbase` は PATH 上の実行ファイルとして子プロセスで起動するため、この `cd` が呼び出し元シェルの +作業ディレクトリを変えることはない。 + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 名前の形 | 親ディレクトリの直下の 1 つの名前として受け付ける形。`[A-Za-z0-9][A-Za-z0-9._-]*` の全体一致 | +| name 解決 | 位置引数を名前として解釈し、`projects/` へ `cd` して引数から取り除く処理 | +| ラッパー | `bin/devbase`(bash 実装の入口。Python 実装のコマンドへ `run_python` で振り分ける) | +| ショートカット | `devbase up` のように `project` を省いたトップレベルの同義語(`cli.SHORTCUTS` と shell の `_NAME_RESOLVABLE_SHORTCUTS`) | +| 単体ビルド | `$DEVBASE_ROOT/containers/` を `devbase-:latest` として 1 つだけ作るビルド | + +## 構成要素 + +| 要素 | 置き場所 | 責務 | +| --- | --- | --- | +| 名前の形の規則 | `lib/devbase/utils/names.py` | `SINGLE_SEGMENT_NAME_PATTERN` と `is_single_segment_name(value)`。`re` だけに依存し副作用を持たない | +| 名前の形の規則(shell) | `bin/devbase` | `_SINGLE_SEGMENT_NAME_RE` と `is_single_segment_name`。Python と同じ正規表現を文字列で持つ | +| name 解決 | `bin/devbase` | `maybe_cd_project`。形・実在の順に見て、通れば `cd` と `COMPOSE_PROJECT_NAME` / `env` の再読み込み | +| 解決の対象の一覧 | `bin/devbase` | `_PROJECT_NAME_SUBCOMMANDS`(`project` の対象)と `_NAME_RESOLVABLE_SHORTCUTS`(トップレベルの対象) | +| `build` の使い方 | `bin/devbase` | `build_usage`。トップレベル `build` の `-h` / `--help` で出す | +| 解決の順序 | `bin/devbase` の name 解決の `case` | `build` のヘルプ → `project` → `build` → その他のショートカット | +| 名前の検証(切替) | `lib/devbase/commands/container.py` | `_resolve_project_name`。ラッパーを経ない直接起動のフォールバック | +| 名前の検証(注入) | `lib/devbase/cli.py` | `_named_lifecycle_project`。dispatch 前の機密の注入で使うプロジェクト名 | +| 名前の検証(イメージ) | `lib/devbase/commands/container.py` | `_build_single_image`。`containers/` へ連結する前の検証 | +| 引数の受け口 | `lib/devbase/cli.py` | `_add_project_parser`(`name` positional を持つサブコマンド)、`SHORTCUTS`、`GROUP_ALIASES` | + +型(クラス)は持たない。モジュール関数の並びで構成する。 + +```mermaid +graph TD + subgraph 入口のシェル + W[name 解決の case] --> UB[build_usage] + W --> M[maybe_cd_project] + W --> SS[is_single_segment_name
shell] + M --> SS + end + subgraph Python + C[cli.main] --> NL[_named_lifecycle_project] + C --> RP[_resolve_project_name] + C --> BI[_build_single_image] + NL --> PS[utils/names
is_single_segment_name] + RP --> PS + BI --> PS + end + W -->|run_python| C +``` + +`cli.main` と `_resolve_project_name` の間には `_dispatch_lifecycle` と `_enter_project` がある。 + +## 仕様 + +### 名前の形 + +| 項目 | 内容 | +| --- | --- | +| 規則 | `[A-Za-z0-9][A-Za-z0-9._-]*` に**全体が**一致する。先頭が英数字なので `.`・`..`・`-x`・空文字は当たらない。`/`・`\`・空白・非 ASCII は含められない | +| Python | `devbase.utils.names.is_single_segment_name(value)`。`re.fullmatch` で見るため、末尾の改行も不一致になる | +| shell | `_SINGLE_SEGMENT_NAME_RE='^[A-Za-z0-9][A-Za-z0-9._-]*$'`。比較は関数の中で `local LC_ALL=C` にして行う | +| 使う場所 | shell: `maybe_cd_project`、`build` の衝突の判定。Python: `_resolve_project_name`、`_named_lifecycle_project`、`_build_single_image` | + +プロジェクト名とイメージ名は同じ規則を使う。どちらも「決まった親ディレクトリ(`projects/` / +`containers/`)の直下の 1 つの名前」を表すためである。`/` と `..` だけを弾く拒否リストは採らない +(`\`・空白・制御文字・非 ASCII のように見落とした文字がそのまま通る)。 + +shell が Python を呼ばずに同じ正規表現を文字列で持つのは、name 解決のたびに `uv run` の起動が +1 回増えるためである。2 か所の一致は同期テストで保つ。 + +`[A-Za-z]` の範囲は C ライブラリの正規表現ではロケールによって ASCII 以外を含みうるため、shell 側は +`LC_ALL=C` を関数の中に閉じて比べる。`[[:alnum:]]` はロケールに依存し、Python の定義と字面で +比べられなくなるため使わない。 + +### 形に合わない値の扱い + +ラッパーは形に合わない値を**名前として扱わない**(`cd` も引数からの除去もしない)。止めずに +そのまま下流へ渡す。同じ位置引数が名前以外の意味(`login` の番号、`build` のイメージ、`scale` の +台数)も持つためである。ラッパーは名前かどうかだけを決め、止めるのは意味を知る下流に任せる。 + +### トップレベル `build` の引数の解釈 + +上から順に見て、最初に当たった行で決まる。判定に使うのは `$2` だけである +(`build --no-cache bi-tools` のように位置引数が 2 番目に無いときは name 解決を通らず、 +`build)` の分岐がイメージとして拾う)。 + +| 条件 | 解釈 | 行き先 | cd | +| --- | --- | --- | --- | +| `build` より後ろのどこかに `-h` か `--help` がある | 使い方 | `build_usage` → 終了コード 0 | しない | +| `$2` が名前の形に合い `containers/$2` が実在する | イメージ `$2` | Python の `project build $2 …`。`projects/$2` も実在すれば stderr に知らせを 1 行出す | しない | +| `$2` が名前の形に合い `projects/$2` が実在する | プロジェクト `$2` | `projects/$2` へ `cd` し `$2` を取り除いて shell の `cmd_build` | する | +| 上のどれでもない | 既存の `build)` 分岐 | 位置引数があれば Python の単体ビルド、無ければ shell の `cmd_build` | しない | + +`containers/` があれば `projects/` の有無によらず name 解決を通さない。`build ` は +イメージを明示した指定であり、プロジェクトのビルドは通常そのディレクトリで引数なしに行うためで +ある。逆にすると `containers/` をトップレベルからビルドする手段が無いまま残る。判定は +`maybe_cd_project` より前に置く(後だと `cd` と `env` の読み込みが先に起き、戻す手段が無い)。 +名前の形を先に見るのは、`containers/../x` のような値でディレクトリの実在を確かめないためである。 + +### 衝突の知らせ + +| 項目 | 内容 | +| --- | --- | +| 条件 | トップレベル `build ` で、`` が名前の形に合い、`containers/` と `projects/` がどちらもディレクトリとして実在する | +| 出力 | stderr に 1 行。1 回の起動で 1 回だけ | +| 文言 | `Note: '' is also a project (projects/); building image containers/. To build the project, run 'devbase build' in $DEVBASE_ROOT/projects/`(`$DEVBASE_ROOT` は展開した値) | +| 終了コード | 知らせは終了コードに影響しない。単体ビルドの結果がそのまま返る | + +標準出力ではなく stderr へ出すのは、単体ビルドの出力をパイプで読む側の邪魔をしないためである。 + +### `devbase build --help` / `-h` + +| 項目 | 内容 | +| --- | --- | +| 名前 | `devbase build -h` / `devbase build --help`。前方一致(`devbase b --help`)も同じ | +| 入力 | `build` より後ろの引数のどこかにある `-h` または `--help`。`--context --help` / `--context -h` も使い方として扱う。`--context=--help` / `--context=-h` は使い方にせず下流へ渡す(argparse の値不足で終了コード 2 になる) | +| 出力 | 標準出力に下の使い方。終了コード 0 | +| 起こさないこと | `cmd_build`・`compose_with_secrets`・`run_python` を呼ばない。`cd` せず、切り替え先のプロジェクト(`projects/`)の `env` を読まない | +| 対象外 | `devbase project build --help`(argparse の `--help`)は変わらない。`-` の付かない `help` という語は名前として扱う | + +```text +Usage: devbase build [ | ] [options] + +Build devbase images. + (no argument) build the images of the current project (base image first) + build the project in $DEVBASE_ROOT/projects/ + build $DEVBASE_ROOT/containers/ alone as devbase-:latest + (when both containers/ and projects/ exist, is an image; + to build the project, run 'devbase build' in its directory) + +Options: + --no-cache rebuild the base and project images without cache + --project-no-cache rebuild only the project image without cache (base uses cache) + --expires[=DAYS] rebuild without cache only if the image is older than DAYS days (default 7) + --context NAME run docker against the docker context NAME + -h, --help show this help +``` + +使い方をラッパーが出すのは、トップレベル `build` が shell の `cmd_build` と Python の +`project build` に振り分けられ、受け付ける引数が両者で違う(`--project-no-cache` は shell にだけ +ある)ためである。判定を name 解決より前に置くのは、`build carmo --help` で `projects/carmo` への +`cd` とその `env` の読み込みを起こさないためである。起動時の `$DEVBASE_ROOT/env` と実行時の +ディレクトリの `env` の読み込みは、コマンド名の解決より前に全コマンド共通で起きる。 + +### `container` / `ct` グループ + +`container` / `ct` は name 解決の対象外である。parser が `[name]` を持たないため、 +`devbase container up carmo` は `projects/carmo` が実在しても argparse の usage エラー +(`unrecognized arguments: carmo`、終了コード 2)になる。`container scale N` は +`new_scale` の型エラーで同じく 2 である。名前なしの `devbase container up` は今までどおり実行時の +ディレクトリのプロジェクトで動き、非推奨の警告を出す。 + +ラッパーだけが名前を取り除く形では「実在するときだけ受け付ける」動きになり、受け付けるかどうかが +利用者の打った語ではなく `projects/` の中身で変わる。`container` は非推奨で、名前の指定は +`project ` が持つため、ラッパーを parser に合わせた。 + +### Python 側の名前の検証 + +3 つの入口が `projects/` または `containers/` へ名前を連結する前に同じ規則で弾く。 + +| 入口 | 形に合わないときの振る舞い | +| --- | --- | +| `container._resolve_project_name(name)` | `プロジェクト名に使えない形です: ''(英数字で始まり、英数字・'.'・'-'・'_' だけからなる名前)` を error ログへ出して `False` を返す。`chdir`・`env` の読み込み・候補の提示をしない。呼び出し元(`_dispatch_lifecycle`)は 1 を返す | +| `cli._named_lifecycle_project(root, cmd, sub, name)` | `projects/` の実在を見る前に `None` を返す。`store_for` / `ref_group` を呼ばず、`projects/` の外の `env` の宣言も読まない | +| `container._build_single_image(image)` | `Invalid image name: …(must be a single directory name under containers/: …)` を error ログへ出して 1 を返す | + +`_resolve_project_name` は、ラッパーが起動前に `cd` 済みでないとき(`python -m devbase.cli` の +直接起動、`_ensure_env_files` などラッパーを経ない経路)のフォールバックである。ラッパー経由なら +同一パス判定で `chdir` は no-op になる。 + +### 失敗の形 + +| 状況 | 終了コード | 出力 | +| --- | --- | --- | +| ラッパー経由で形に合わない名前(`up ../etc` など) | 下流の結果 | ラッパーは何も出さない。引数はそのまま Python へ渡る | +| `project ` / トップレベル `up` などで Python が形に合わない `name` を受け取った | 1 | `プロジェクト名に使えない形です: …`(error)。候補の一覧は出さない | +| 形に合うが `projects/` が無い | 1 | 従来どおり利用可能なプロジェクト候補を添えたエラー | +| `scale <形に合わない値>`(値が 1 つだけ) | 2 | argparse の `new_scale` の型エラー | +| `login <形に合わない値>` | 下流の結果 | 番号として `docker compose` へ渡る。`cd` しない | +| `build <形に合わない値>` | 1 | `_build_single_image` の `Invalid image name` | +| `container ` / `ct `(`up` `down` `ps` `logs` `rebuild` `open`) | 2 | argparse の `unrecognized arguments: ` | +| `container scale [N]` | 2 | argparse の `new_scale` の型エラー | + +### 解決の順序 + +```mermaid +graph TD + A[コマンド名を前方一致で解決] --> H{build で
-h か --help を含む} + H -->|はい| U[build_usage
終了コード 0] + H -->|いいえ| G{コマンド} + G -->|project| P3{sub が名前を取る} + P3 -->|はい| M3[maybe_cd_project に 3 番目] + P3 -->|いいえ| T[素通し] + G -->|container か ct| T + G -->|build| C{2 番目が形に合い
containers にある} + C -->|はい| N[projects にもあれば
stderr に 1 行] + N --> T + C -->|いいえ| M2[maybe_cd_project に 2 番目] + G -->|他のショートカット| M2 + G -->|それ以外| T + M2 --> V{形に合い
projects にある} + M3 --> V + V -->|はい| CD[cd と env の読み込み
名前を取り除く] + V -->|いいえ| T + CD --> D[dispatch] + T --> D +``` + +「他のショートカット」は `_NAME_RESOLVABLE_SHORTCUTS` から `build` を除いた 7 つ +(`up` `down` `ps` `scale` `login` `rebuild` `open`)である。 + +### 常に成り立つ条件 + +- 名前として `projects/` へ連結される値は、必ず名前の形に合う 1 セグメントである。したがって + 位置引数に `..` や `/` を混ぜて `$DEVBASE_ROOT/projects/` の外を指すこと + (パストラバーサル)はできず、name 解決と Python 側の検証はそのような値で `cd` も `env` の + 読み込みもしない。**保証するのはここまでで、`projects/` の実体がどこにあるかは含まない** + (下の「リンクの先は対象外」) +- `containers/` への連結(実在の確認と単体ビルド)も同じ規則を通る +- ラッパーが名前として解釈した値だけが引数から取り除かれる。解釈しなかった値は 1 つも欠けずに + 下流へ渡る +- shell と Python の正規表現は同じ文字列である(同期テストが一致を見る) + +### リンクの先は対象外 + +上の保証は**位置引数の形**についてのもので、`$DEVBASE_ROOT/projects/` が指す先までは +縛らない。`projects/` はプラグインの同期が張るシンボリックリンクであることが多く、実体は +リポジトリの管理外(`repos/` 配下など、`.gitignore` で除外された場所)にある。 + +``` +$ ls -l $DEVBASE_ROOT/projects +lrwxr-xr-x adminer -> ../repos/github.com--devbasex--devbase-samples/adminer/projects/adminer +lrwxr-xr-x carmo -> ../repos/github.com--volareinc--devbase-ext/carmo-web/projects/carmo +``` + +`maybe_cd_project` の `cd "$target"` も Python 側の `os.chdir` もリンクを辿るため、対象が +リンクなら実体のディレクトリへ移動し、そこの `env` を `source` する。これは登録済みの +プロジェクトを扱うための**意図した動き**で、この仕様は変えていない。つまり、 + +| 事柄 | 保証 | +| --- | --- | +| 位置引数に `..` `/` `.` を含めて `projects/` の外を指す | 拒む(名前の形で弾く) | +| `projects/` が登録済みのシンボリックリンクで、実体が `projects/` の外にある | 拒まない。実体へ `cd` し、そこの `env` を読む | + +リンクを張れるのは `$DEVBASE_ROOT/projects/` へ書ける者だけで、その者はもともと +任意の `env` をそこへ置ける。したがってリンクを辿ることで新たに広がる権限は無い。 + +### 残る衝突 + +トップレベルの `devbase login ` / `devbase scale ` は、値が実在するプロジェクト名と +一致すると名前として解釈される(`projects/2` がある状態の `devbase login 2` は番号 2 ではなく +プロジェクト `2` への操作になる)。数字だけのプロジェクト名は通常作られないため衝突は偶発に +限られる。 + +名前の解決は現在地を見ない。`maybe_cd_project` が見るのは `$DEVBASE_ROOT/projects/<値>` の実在 +だけなので、対象プロジェクトのディレクトリの中で打っても回避できない。`projects/web` の中で +`devbase login 2` と打つと `projects/2` へ切り替わり、`2` は引数から取り除かれて index は既定の +1 になる。`devbase scale 2` も同じく `projects/2` へ切り替わり、`new_scale` が無くなって usage +error になる。回避するには、名前の解決を通らない形か、名前を明示した形を使う。 + +| 衝突する形 | 回避する形 | 理由 | +| --- | --- | --- | +| `devbase login 2` | `devbase project login 2` | `project login` は `[name]` を取らないため `_PROJECT_NAME_SUBCOMMANDS` に含まれず、`2` は index のまま下流へ渡る(対象はカレントプロジェクト) | +| `devbase scale 2` | `devbase project scale 2` | `` が名前として取り除かれ、残る `2` が `new_scale` になる。`devbase project scale 2` は依然 `projects/2` へ切り替わるので、名前は省略しない | + +## データ・設定 + +`maybe_cd_project` が名前として解釈したとき、ラッパーは対象ディレクトリで次を行う。 + +| 対象 | 内容 | +| --- | --- | +| 作業ディレクトリ | `$DEVBASE_ROOT/projects/` へ `cd`(子プロセスの中なので呼び出し元シェルには及ばない) | +| `COMPOSE_PROJECT_NAME` | `` を export | +| 呼び出し元の `env` 固有のキー | 起動時に記録したキーを unset してから対象の `env` を `source` する(呼び出し元の値の残留を防ぐ) | +| 対象の `env` | `set -a` の下で `source ./env`(`.env` は読まない) | + +Python 側の `_resolve_project_name` は同じ結果になるよう、`chdir` に加えて `os.environ['PWD']` と +`COMPOSE_PROJECT_NAME` を更新し、対象の `env` を `os.environ` へ反映する。 + +`projects/` と `containers/` の位置はどちらも `$DEVBASE_ROOT` の直下で、`DEVBASE_ROOT` が未設定の +ときは Python 側の名前の検証が「`DEVBASE_ROOT` が未設定のため解決できません」で 1 を返す。 + +## 運用 + +- 名前の形に合わないプロジェクト(`_` で始まる名前など)は、名前の指定(CLI の `[name]` と + `devbase list` の一覧)から操作できない。そのディレクトリの中で名前なしに打てば動く +- 名前の検証はリポジトリの中で 1 つに寄せていない。`env/bundle.py` の `is_valid_project_name` + (先頭の `_` を許す。`env` の export / import の書庫の中の名前)、`env/secret_store.py` の + `_validate_project_name`(機密の保存先のファイル名)、`snapshot/manager.py` の `_VALID_NAME_RE` + (スナップショットの名前)はそれぞれ別の用途と互換性を持つ。寄せると受け付ける名前が変わる + 範囲が広がるため、位置引数の解決はこの仕様の規則だけを使う +- shell 側は macOS 既定の bash 3.2 で動くこと。`[[ =~ ]]` の右辺は変数で渡す(引用した右辺は + 文字列として比べられる)。連想配列・`${var,,}`・`mapfile` を使わない +- `cli.py` でサブコマンドを足し引きしたら、`bin/devbase` の `_PROJECT_NAME_SUBCOMMANDS` / + `_NAME_RESOLVABLE_SHORTCUTS` も合わせる(両側にコメントの対がある) + +## テスト観点 + +- 名前の形が実在のプロジェクト名(`carmo`・`github_work_time`・`carmo-ai`)を通し、`../etc`・ + `a/b`・`.`・`..`・空・`-x`・`café` を弾くこと(`tests/utils/test_names.py`) +- shell の `_SINGLE_SEGMENT_NAME_RE` が Python の `SINGLE_SEGMENT_NAME_PATTERN` と一致すること + (`tests/cli/test_project_name_resolution.py` の同期テスト) +- 形に合わない名前で、トップレベルの 7 コマンドと `project` の 7 サブコマンドが `projects/` の外へ + `cd` せず、そこの `env` を読まないこと。形に合う実在の名前は `cd` して引数から取り除かれること + (`tests/cli/test_project_name_resolution.py`) +- ラッパーを経ない `python -m devbase.cli project up ../etc` が `chdir` せず、使えない形である旨を + 出して 1 で終わること(同上) +- dispatch 前の注入(`_named_lifecycle_project`)が形に合わない名前で `projects/` の外の `env` を + 読まず `None` を返すこと(`tests/cli/test_secret_injection.py`) +- `build ` で `containers/` と `projects/` が両方あるときイメージが勝ち、知らせが stderr に + ちょうど 1 行出ること。片方だけのときは従来どおりで知らせが出ないこと + (`tests/cli/test_build_image_argument.py`) +- `build --help` / `-h`(`build --help` を含む)が終了コード 0 で使い方を出し、ビルドを + 起こさず `cd` も `env` の読み込みもしないこと。使い方に `--no-cache`・`--project-no-cache`・ + `--expires[=DAYS]`・`--context NAME`・`` が載ること。`--context --help` は使い方、 + `--context=--help` は下流へ渡ること(同上) +- `container` / `ct` の 7 サブコマンドが名前を取り除かず、parser が `SystemExit(2)` になること。 + 名前なしの `container up` は実行時のディレクトリで動き非推奨の警告を出すこと + (`tests/cli/test_project_name_resolution.py`、`tests/cli/test_project_dispatch.py`) +- ラッパーの振る舞いは `tests/cli/conftest.py` の `exec_wrapper` で確かめる。`bin/devbase` を + 一時ディレクトリへ複製して実プロセスで起動し、`maybe_cd_project` や `cmd_build` は差し替えず、 + 外へ出る呼び出しが通る `uv` だけを `PATH` の先頭で差し替える(複製した位置から `DEVBASE_ROOT` + が決まるので、実環境の `DEVBASE_ROOT` を継承しない) +- macOS の `/bin/bash`(3.2)で `tests/cli` を流すことと `shellcheck --severity=error bin/devbase` + は手元で行う(CI の bash は Linux 版) + +## 関連リンク + +- [CLI リファレンス: project](../user/cli-reference/02-project.md) +- [CLI リファレンス: 一覧](../user/cli-reference/README.md) +- [アーキテクチャ: `build` の振り分け](../developer/architecture.md) +- [エディタの窓の開き直し(`devbase open`)](editor-open.md) +- 実装 PR: devbasex/devbase#207(#146・#142・#196・#200) diff --git a/docs/specifications/editor-open.md b/docs/specifications/editor-open.md index 0823fd8a..70d132ca 100644 --- a/docs/specifications/editor-open.md +++ b/docs/specifications/editor-open.md @@ -177,4 +177,5 @@ graph TD - [CLI リファレンス: `devbase project open`](../user/cli-reference/02-project.md#devbase-project-open) - [環境変数ガイド: エディタ自動オープン](../user/environment-variables.md) - [別ホストの Docker への dev コンテナ起動(docker context)](remote-docker-context.md) +- [位置引数の解決(プロジェクト名・イメージ名)](cli-argument-resolution.md) - 課題: devbasex/devbase#197。設計 PR: #198、実装 PR: #199 diff --git a/docs/specifications/secret-backend.md b/docs/specifications/secret-backend.md index b925ca8f..de5ba177 100644 --- a/docs/specifications/secret-backend.md +++ b/docs/specifications/secret-backend.md @@ -76,14 +76,14 @@ Infisical で個人単位の機密を守るには利用者ごとに project を | OpenBao adapter | `lib/devbase/env/openbao.py` | AppRole 認証、参照ごとの取得、版を指定した丸ごとの書き込み、失敗の種類の判定 | | ブートストラップ | `lib/devbase/env/bootstrap.py` | 接続資格情報を登録簿を経由せず age で直接読み書きする | | キャッシュ | `lib/devbase/env/cache.py` | 参照ごとの控えの書き込み・読み出し・破棄・全消去 | -| 機密の合成 | `lib/devbase/env/runtime.py` | 4 層の機密を対象のプロジェクトのグループで重ねてコンテナへ渡す。`SecretStore` をライフサイクル操作 1 回の間持ち回る(`store_for` / `release_store`) | +| 機密の合成 | `lib/devbase/env/runtime.py` | 4 層の機密を対象のプロジェクトのグループで重ねてコンテナへ渡す。読み取りの直後に `DEVBASE_ACCOUNT_GROUP` を外して警告する(`_without_account_group` / `_warned_account_group_refs`)。`SecretStore` をライフサイクル操作 1 回の間持ち回る(`store_for` / `release_store`) | | dispatch 前の注入 | `lib/devbase/cli.py` | `_load_secret_env`。注入を行わないコマンドと、`version: 2` で注入に使うプロジェクトを決める。`--group` / `--layout` / `--group-alias` / `--exclude-project` の引数 | | 同期済みハッシュの控え | `lib/devbase/env/sources.py` | `SourcesManager` と `sources_path`。`version: 2` では置き場のグループごとに控えを分ける | | コンテナへの token の配送 | `lib/devbase/env/container_token.py` | 受け取った token を `docker exec` の stdin で各コンテナの `~/.vault-token` へ書く。token の取得と届け先の解決は持たない | | `up` / `scale` の前処理と後処理 | `lib/devbase/commands/container.py` | `version: 2` でボリュームと機密のグループの食い違いを起動前に検査する(`_check_group_consistency`)。`_ensure_env_files` の子プロセスの `env init` へグループを渡す。backend が `openbao` のとき dev サービスへ `BAO_ADDR` を足し、起動後に token を書く | | base イメージ | `containers/base/Dockerfile` | OpenBao CLI `bao` を `checksums.txt` で検証して `/usr/local/bin` へ置く | | `env backend` コマンド | `lib/devbase/commands/env_backend.py` | `status` / `use` / `test` / `migrate` | -| `env` コマンド | `lib/devbase/commands/env.py` | `--user` と `--group` の受け取り、`-p` とプロジェクトのグループの照合、`edit` の分岐、一覧の保存形式表示、`env token` | +| `env` コマンド | `lib/devbase/commands/env.py` | `--user` と `--group` の受け取り、`-p` とプロジェクトのグループの照合、`edit` の分岐、一覧の保存形式表示、`env token`。`env set` は `DEVBASE_ACCOUNT_GROUP` を置き場を開く前に拒む | | `rekey` / `doctor` | `lib/devbase/commands/env_ops.py` | 手元の age 暗号文すべての再暗号化、backend 設定と権限と Git の除外の点検 | | `encrypt` / `decrypt` | `lib/devbase/commands/env_migrate.py` | age ストアと平文の間の移動(backend の向きと突き合わせる) | | `export` | `lib/devbase/env/bundle.py` | 機密をバンドルへ集める。`version: 2` では対象のグループと同じ置き場のプロジェクトだけを集める | @@ -177,6 +177,64 @@ flowchart LR 従来と同じである。`version: 2` では 4 つの機密の層はいずれも、起動するプロジェクトの グループ(`SecretStore.ref_group(project)`)の参照である。 +4 つの機密の層(1・2・4・5)から `DEVBASE_ACCOUNT_GROUP` は外す。詳細は次の節にある。 + +### 機密の置き場の `DEVBASE_ACCOUNT_GROUP` + +`runtime.resolve()` は、4 つの置き場から読んだ内容を合成に使う前に `DEVBASE_ACCOUNT_GROUP` を +除く(`_without_account_group`)。backend と版によらず、`version: 1` でも `age` / `plaintext` でも +同じである。 + +| 対象 | 結果 | +| --- | --- | +| `SecretEnv.values` | `DEVBASE_ACCOUNT_GROUP` を含まない。`projects//env` が同じキーを宣言していても含まない(重ね順 3 は名前の一覧に無いキーの値を採らないため) | +| `SecretEnv.global_names` / `project_names` / `names` | 含まない。したがって dev コンテナの `environment` にも列挙されない | +| `runtime.inject` / `child_env` | どちらも `resolve` の結果だけを載せるため、プロセスの環境変数と子プロセスの `DEVBASE_ACCOUNT_GROUP` を書き換えない。シェルか `env` ファイル由来の値はそのまま残る | +| 置き場への要求 | 変わらない(4 参照を 1 回ずつ `load`) | +| 比べ方 | キー名の完全一致。`export DEVBASE_ACCOUNT_GROUP` のような接頭辞付きのキーは別の名前の変数で、グループに効かないため対象外 | + +外す場所を読み取りの直後の 1 か所にするのは、`inject`・`child_env`・コンテナへ列挙する変数名が +どれも `resolve` の結果から作られるためである。ここで外せば、3 つの経路と重ね順の全層に同じ規則が +当たり、経路を足しても漏れない。置き場の値を載せると、プロセスの環境変数を読む +`volume.manager.resolve_account_group()` が置き場の値でボリュームのグループを変えてしまう。 + +置き場に値が残っていた場合は、その置き場(`SecretRef`)について 1 プロセスで 1 回だけ警告を出す。 + +```text +機密の置き場({参照の表示})にある DEVBASE_ACCOUNT_GROUP は使いません。アカウントグループは env +ファイル(projects//env・$DEVBASE_ROOT/env)で決まります。消すには: devbase env delete +DEVBASE_ACCOUNT_GROUP{付ける引数}{実行場所} +``` + +(実際は `logger.warning` の 1 行。上は紙面の都合で折り返している。) + +| 置き場 | 参照の表示(`SecretRef.label()`) | 付ける引数 | 実行場所 | +| --- | --- | --- | --- | +| チーム共通 | `グローバル` | なし | なし | +| 個人共通 | `個人のグローバル` | ` --user` | なし | +| プロジェクトのチーム | `プロジェクト 'web'` | ` -p` | `(projects/web で実行)` | +| プロジェクトの個人 | `個人のプロジェクト 'web'` | ` -p --user` | `(projects/web で実行)` | + +- 参照がグループを持つとき(`version: 2`)は、表示の末尾に `(グループ <名前>)` が付き、引数の + 末尾に ` --group <読み替える前の名前>` が加わる。省くと `env delete` は実行した場所のグループを + 宛先にし、警告を出した置き場と違う置き場を指しうる +- 値そのものは出さない。空の値でもキーがあれば出す +- 重複を抑える集合はモジュールの変数で、`SecretStore` ではなくプロセスが持つ。ストアは + `release_store` で捨てられる(`up` の中の `env init` の後など)ため、ストアに持たせると + 1 回の `up` で警告が 2 回出る。TUI は 1 プロセスで操作を続けるので、同じ置き場の警告は最初に + 注入したときの 1 回だけになる +- 置き場の値は自動では消さない。置き場はチームと共有する場所でもあり、コマンドの副作用で + 書き換えると書いた本人の知らないうちに他の端末にも及ぶ。消すかどうかは利用者が決める + +`devbase env set DEVBASE_ACCOUNT_GROUP=VALUE` は置き場を開く前に拒む。 + +| 項目 | 内容 | +| --- | --- | +| 条件 | `KEY` が前後の空白を除いて `DEVBASE_ACCOUNT_GROUP` と一致する。`-p` / `--user` / `--group` の有無によらない | +| 出力 | 終了コード 1。error ログ `DEVBASE_ACCOUNT_GROUP は機密の置き場へは書けません(置き場の値はアカウントグループの決定に使われません)。projects//env か $DEVBASE_ROOT/env に書いてください` | +| 置き場への作用 | `_open_target_env` より前に返すため、書き込み・ファイルの作成・書き込みのための読み出し(`fresh`)も、`--group` の名前の検証も起きない。dispatch 前の注入による読み取りは他のコマンドと同じく起きうる | +| 対象外 | `env import` / `env edit` は拒まない(複数のキーをまとめて扱い、1 キーのために全体を止めると他のキーの作業まで止まる)。書かれた値は上の警告で知らせる | + ### アカウントグループごとの置き場(`version: 2`) `backend.yml` の版はレイアウトと 1 対 1 で、`version: 1` は `flat`、`version: 2` は `group` である。 @@ -739,6 +797,8 @@ flowchart TD ある - 参照のグループは非機密の `env` ファイルだけから決まり、機密の置き場の値とプロセスの環境変数は 使わない。レイアウトと合わないグループの参照は、サーバへ要求する前に拒む +- `DEVBASE_ACCOUNT_GROUP` は、どの backend・どの版でも機密の合成に現れない。devbase のプロセスの + 環境変数にも、子プロセスにも、dev コンテナの `environment` にも、置き場の値が載ることはない - `version: 2` の `up` / `scale` は、ボリュームと機密のグループが食い違ったまま副作用を起こさない ## データ・設定 @@ -862,7 +922,9 @@ cache: だけになる。企業ごとのグループの機密を、別グループのプロジェクトへ届けずに済み、サーバは グループ単位のポリシーで読み書きを絞れる - グループを非機密の `env` ファイルから決めるため、機密の置き場に書いた値で読む置き場は - 変わらない。ボリュームのグループとの食い違いは `up` / `scale` が起動前に止める + 変わらない。ボリュームのグループも同じで、置き場の `DEVBASE_ACCOUNT_GROUP` は合成から外れ、 + プロセスの環境変数にもコンテナにも載らない。置き場へ書ける者が、それを読む端末の + ボリュームの宛先を変えることはできない ## 運用 @@ -893,9 +955,11 @@ cache: 書き、`env backend status` で確かめる - `up` / `scale` がグループの食い違いで止まったら、起動したいグループに合わせて、プロジェクトの `env` に `DEVBASE_ACCOUNT_GROUP` を書くか、シェルの環境変数を外す -- 機密の置き場に `DEVBASE_ACCOUNT_GROUP` を書くと、注入でプロセスの環境変数へ載ってボリュームの - グループを変えうる(プロジェクトの `env` が宣言していれば上書きされる)。`version: 2` ではこの - 場合も食い違いの検査で止まる。注入の対象から外すかは #185 で扱う。置き場には書かない +- 機密の置き場に `DEVBASE_ACCOUNT_GROUP` を書いても使われない。`devbase env set` は拒み、既に + 置き場にある値は合成から外れて置き場ごとに 1 回警告が出る。警告が出たら、添えられた + `devbase env delete DEVBASE_ACCOUNT_GROUP …` で消し、グループは `projects//env` か + `$DEVBASE_ROOT/env` で宣言する。`version: 1` の端末で置き場の値によってグループを切り替えて + いた場合、ボリュームのグループは `env` ファイルの宣言(無ければ `default`)へ戻る ## テスト観点 @@ -912,6 +976,15 @@ cache: と結果不明の書き込みでの破棄・控えから読んだ参照の書き戻し拒否・無効化・原子性・本文の 途中切れ(`tests/env/test_cache.py`) - 4 層の重ね順とファイル backend での不変(`tests/env/test_runtime.py`) +- 4 つの置き場の `DEVBASE_ACCOUNT_GROUP` が `inject` / `child_env` / `resolve` の名前と値のどれにも + 現れず、プロセスの環境変数(シェル由来・`projects//env` 由来)を書き換えないこと、置き場 + ごとに 1 回だけ消し方を添えた警告が出て値を出さないこと、グループを持つ参照では `--group` が + 付くこと、`release_store` や別のストアをまたいでも警告が増えないこと(`tests/env/test_runtime.py`) +- 置き場に `DEVBASE_ACCOUNT_GROUP` があっても、注入の後の `up` のグループの食い違いの検査が + 止まらないこと(`tests/commands/test_container_up_order.py`) +- `env set DEVBASE_ACCOUNT_GROUP=…` が `-p` / `--user` / `--group` の組み合わせによらず 1 で終わり、 + サーバへ書き込みの要求を出さず `.env` も作らないこと。前後に空白があっても拒み、他のキーは + 今までどおり書けること(`tests/commands/test_env_account_group.py`) - `status` / `use`、`--cache` / `--no-cache` の引き継ぎ、argv に `secret_id` を取る経路が 無いこと(`tests/commands/test_env_backend.py`) - `--user` の宛先、`list` / `get` の順序、ファイル backend での拒否、`edit` / `init --reset`、 @@ -989,10 +1062,10 @@ cache: - [CLI リファレンス: env](../user/cli-reference/03-env.md) - 発端の依頼: `issues/security-key.md` - 実装 PR: devbasex/devbase#171(Infisical 版 #167 を置き換え)、#177(`up` の往復、#168)、 - #178(コンテナの `bao`、#169)、#184(アカウントグループごとの置き場、#182。設計は #183) + #178(コンテナの `bao`、#169)、#184(アカウントグループごとの置き場、#182。設計は #183)、 + #206(機密の置き場に書いた `DEVBASE_ACCOUNT_GROUP` を注入しない、#185。設計は #205) - Infisical から OpenBao への切り替えの経緯: devbasex/devbase#166 - サーバのポリシーをグループ単位に絞る課題: 運用側のリポジトリ(carmo-cdk#363) -- 範囲外として起票した課題: #185(機密の置き場に書いた `DEVBASE_ACCOUNT_GROUP` の注入) - [環境変数ガイド: アカウントグループ](../user/environment-variables.md#アカウントグループ-devbase_account_group) - [OpenBao: KV v2 API](https://openbao.org/api-docs/secret/kv/kv-v2/) - [OpenBao: AppRole auth](https://openbao.org/docs/auth/approle/) diff --git a/docs/user/cli-reference/02-project.md b/docs/user/cli-reference/02-project.md index fae87514..126e7f81 100644 --- a/docs/user/cli-reference/02-project.md +++ b/docs/user/cli-reference/02-project.md @@ -21,8 +21,11 @@ cd $DEVBASE_ROOT/projects/adminer && devbase project up - `` は `$DEVBASE_ROOT/projects/` 配下のプロジェクト名(`devbase project list` で確認可能) - 名前として受け付ける形は、英数字で始まり英数字・`.`・`-`・`_` だけからなる文字列です (`carmo`、`github_work_time`、`carmo-ai`、`carmo.takemi`)。`../etc` や `a/b` のように - 形に合わない値は名前として扱わず、`projects/` の外のディレクトリへ移動することはありません。 + 形に合わない値は名前として扱わず、`..` や `/` で `projects/` の外を指すことはできません。 `[name]` を取るコマンドに渡すと、プロジェクト名に使えない形である旨を出して終了コード 1 になります + (なお `projects/` 自体がシンボリックリンクの場合は、その実体のディレクトリへ移動して + そこの `env` を読みます。プラグインの同期が張るリンクがこれにあたり、実体は `repos/` 配下など + `projects/` の外にあります) - 存在しない名前を指定するとエラーになり、利用可能なプロジェクト候補が表示されます - 名前解決はラッパー (`bin/devbase`) が対象ディレクトリへ `cd` してから実行します。 これにより `build`(シェル実装)を含む全操作が名前指定で成立します @@ -40,7 +43,17 @@ cd $DEVBASE_ROOT/projects/adminer && devbase project up > **衝突注意:** トップレベルの `devbase login ` / `devbase scale ` は、値が実在する > プロジェクト名と一致すると名前として解釈されます(`projects/2` が存在する状態の `devbase login 2` > は index=2 ではなく project `2` への操作になります)。数字だけのプロジェクト名は通常作られないため -> 衝突は偶発に限られますが、該当する場合は対象プロジェクトのディレクトリ内で実行してください。 +> 衝突は偶発に限られます。 +> +> 名前の解決は現在地を見ません(`$DEVBASE_ROOT/projects/<値>` が実在するかだけを見ます)。 +> そのため対象プロジェクトのディレクトリ内で実行しても回避できません(`projects/web` の中で +> `devbase login 2` と打つと `projects/2` へ切り替わり、`2` が取り除かれて index は既定の 1 に +> なります)。該当する場合は次のように打ってください。 +> +> - `devbase project login 2` — `project login` は `[name]` を取らないため名前解決の対象外で、 +> `2` は index のままカレントプロジェクトへ渡ります +> - `devbase project scale 2` — `` が名前として取り除かれ、残る `2` が `new_scale` +> になります(`devbase project scale 2` は `projects/2` へ切り替わるため、名前は省略しません) ## `--context NAME`(共通オプション) diff --git a/issues/PLAN60_ci-pytest.md b/issues/old/PLAN60_ci-pytest.md similarity index 100% rename from issues/PLAN60_ci-pytest.md rename to issues/old/PLAN60_ci-pytest.md diff --git a/issues/PLAN61_name-resolution-design.md b/issues/old/PLAN61_name-resolution-design.md similarity index 100% rename from issues/PLAN61_name-resolution-design.md rename to issues/old/PLAN61_name-resolution-design.md diff --git a/issues/PLAN61_name-resolution.md b/issues/old/PLAN61_name-resolution.md similarity index 100% rename from issues/PLAN61_name-resolution.md rename to issues/old/PLAN61_name-resolution.md diff --git a/issues/PLAN62_inject-account-group-design.md b/issues/old/PLAN62_inject-account-group-design.md similarity index 100% rename from issues/PLAN62_inject-account-group-design.md rename to issues/old/PLAN62_inject-account-group-design.md diff --git a/issues/PLAN62_inject-account-group.md b/issues/old/PLAN62_inject-account-group.md similarity index 100% rename from issues/PLAN62_inject-account-group.md rename to issues/old/PLAN62_inject-account-group.md