diff --git a/bin/devbase b/bin/devbase index 8b2db6e2..272a391c 100755 --- a/bin/devbase +++ b/bin/devbase @@ -36,12 +36,18 @@ env_var_keys() { # Environment setup export DOCKER_GID=$( [ "$(uname)" = "Darwin" ] && echo "0" || grep docker /etc/group | cut -d: -f3 ) export COMPOSE_PROJECT_NAME=$(basename "$PWD") -# devbase root の .env (AWS / BigQuery 等の devbase ツール用変数) を読み込む。 +# devbase root の非機密設定 (env) を読み込む。 +# +# 機密 (認証情報) はここでは読まない。暗号化された機密は Python 本体が必要に +# なった時点で復号し、値を必要とする処理へ環境変数として渡す (plan35 §4.4)。 +# シェルから読める場所に機密を置かないことが暗号化の前提なので、ここで +# `${DEVBASE_ROOT}/.env` を source すると意味が無くなる。 +# # project ディレクトリで実行された場合に Laravel ランタイム用 .env を bash で # source すると、CRLF 改行や `|` / `&` 等の特殊文字を含む値で syntax error に # なる。compose は同階層の .env を自動で読むため wrapper 側で project .env を # source する必要は無い。 -[ -f "${DEVBASE_ROOT}/.env" ] && set -a && source "${DEVBASE_ROOT}/.env" && set +a +[ -f "${DEVBASE_ROOT}/env" ] && set -a && source "${DEVBASE_ROOT}/env" && set +a # 呼び出し元 (初期 CWD) の env で定義された変数キーを記録しておく。 # project 切替 (maybe_cd_project) 時に「呼び出し元プロジェクトにしか無い変数」を @@ -61,6 +67,15 @@ export DEVBASE_ROOT # Function definitions # =================================================================== +# Docker Compose の変数展開が機密を必要とする場合があるため、compose の呼び出しは +# Python 経由で機密を注入して実行する (plan35 §4.4 / §11.2)。復号結果は子プロセスの +# 環境変数としてだけ渡り、ファイルには書き出されない。 +compose_with_secrets() { + ensure_uv + PYTHONPATH="${DEVBASE_ROOT}/lib:$PYTHONPATH" \ + uv run --project "$DEVBASE_ROOT" python -m devbase.cli env exec -- "$@" +} + cmd_build() { echo "=== Building devbase images ===" @@ -154,7 +169,7 @@ cmd_build() { echo "" echo "[2/2] Building project image without cache..." - if docker compose build "${DEV_SERVICE_NAME:-dev}" --no-cache "$@"; then + if compose_with_secrets docker compose build "${DEV_SERVICE_NAME:-dev}" --no-cache "$@"; then echo "" echo "✓ All images built successfully" else @@ -176,7 +191,7 @@ cmd_build() { echo "" echo "[2/2] Building project image..." - if docker compose build "${DEV_SERVICE_NAME:-dev}" "$@"; then + if compose_with_secrets docker compose build "${DEV_SERVICE_NAME:-dev}" "$@"; then echo "" echo "✓ All images built successfully" else @@ -209,7 +224,7 @@ cmd_build() { echo "" echo "[2/2] Building project image..." - if docker compose build "${DEV_SERVICE_NAME:-dev}" "$@"; then + if compose_with_secrets docker compose build "${DEV_SERVICE_NAME:-dev}" "$@"; then echo "" echo "✓ All images built successfully" else diff --git a/docs/user/cli-reference/03-env.md b/docs/user/cli-reference/03-env.md index fe62ee83..75e4b871 100644 --- a/docs/user/cli-reference/03-env.md +++ b/docs/user/cli-reference/03-env.md @@ -152,6 +152,78 @@ devbase env keygen --force > **鍵のバックアップは必須です。** この鍵を失うと、暗号化した機密は誰にも復号できません(devbase 側にも復旧手段はありません)。生成後に表示される鍵ファイルを、パスワード管理ツールなど端末とは別の場所へ必ず複製してください。鍵は全ワークスペース共通のため、`--force` で作り直すと他のワークスペースで暗号化した機密も復号できなくなります。 +## `devbase env encrypt` + +平文で保存されている設定を、暗号化ストア (`$DEVBASE_ROOT/secrets/`) へ移します。事前に `devbase env keygen` で鍵を作っておく必要があります。 + +``` +devbase env encrypt [--project NAME]... [--dry-run] [-y|--yes] +``` + +| オプション | 説明 | +|-----------|------| +| `--project NAME` | 対象を指定プロジェクトだけに絞る(繰り返し指定可)。指定すると共通設定は対象外になります | +| `--dry-run` | 変更内容と構成ファイルの差分を表示するだけで、何も書き換えません | +| `-y`, `--yes` | 確認プロンプトを省略 | + +実行すると次の 3 つが行われます。 + +1. 平文の設定を暗号化して `secrets/` 配下へ保存する +2. **暗号化した内容を読み戻して元と一致することを確認**してから、元の平文を `backups/env-encrypt/<日時>/` へ退避する +3. 各プロジェクトの `compose.yml` から機密ファイルの参照をコメントアウトする(元の行はコメントとして残るため、`decrypt` で復元できます) + +```bash +# 何が変わるかを先に確認する +devbase env encrypt --dry-run + +# 共通設定とすべてのプロジェクトを暗号化する +devbase env encrypt + +# 特定プロジェクトだけを暗号化する +devbase env encrypt --project web +``` + +> 退避した平文は**自動では消しません**。内容を確認したうえで、案内された `backups/env-encrypt/<日時>/` を削除してください。削除するまでは端末上に平文の認証情報が残ったままです。 + +> 退避先は毎回新しく作られます。同じ秒に再実行して名前が衝突した場合は `<日時>-2`, `<日時>-3` … と別のディレクトリになり、**過去の退避物を上書きすることはありません**。 + +## `devbase env decrypt` + +暗号化された設定を平文へ戻します。`encrypt` と対になる退避コマンドです。 + +``` +devbase env decrypt [--project NAME]... [--dry-run] [-y|--yes] +``` + +オプションは `encrypt` と同じです。`compose.yml` のコメントアウトも元に戻るため、暗号化前の状態へそのまま復帰します。機密ファイルは `KEY=VALUE` の一覧へ畳まず原文のバイト列のまま暗号化しているので、コメント・空行・`export KEY=...` 表記・値のクォートもそのまま戻ります。 + +> 原文が保たれるのは**値を書き換えるまで**です。暗号化した状態で `devbase env set` などを実行すると、内容は `KEY=VALUE` を昇順に並べた書式へ正規化され、コメントは残りません(平文だけを使っていた頃と同じ挙動です)。 + +```bash +devbase env decrypt --dry-run +devbase env decrypt +``` + +## `devbase env exec` + +復号した機密を環境変数として渡した状態で、任意のコマンドを実行します。値はその子プロセスの環境変数としてのみ渡り、ファイルには書き出されません。 + +``` +devbase env exec -- CMD [ARGS...] +``` + +起動ラッパーは共通の機密ファイルを読み込まないため、ホスト側で機密を必要とする処理(Docker Compose の変数展開など)はこのコマンドを通します。devbase 自身の `devbase build` も内部でこれを使っています。 + +```bash +# コンテナに渡る値を確認する +devbase env exec -- printenv ANTHROPIC_API_KEY + +# 機密を必要とする compose 操作を手で実行する +devbase env exec -- docker compose config +``` + +> `devbase env exec -- printenv` のように値を表示するコマンドは、画面共有や端末ログに認証情報がそのまま残ります。実行する場面に注意してください。 + ## `devbase env export` 複数プロジェクトの `.env` 群を暗号化したまま 1 つのバンドルにまとめて書き出します。 diff --git a/docs/user/cli-reference/README.md b/docs/user/cli-reference/README.md index 50343e49..3fc0e734 100644 --- a/docs/user/cli-reference/README.md +++ b/docs/user/cli-reference/README.md @@ -6,7 +6,7 @@ devbase の全コマンドの構文、オプション、使用例をまとめた |---------|------| | [トップレベルコマンド](01-toplevel.md) | `init` / `status` / `bin/rc` | | [project グループ](02-project.md) | コンテナのライフサイクル管理・一覧(`up` / `down` / `login` / `ps` / `logs` / `scale` / `build` / `rebuild` / `list`)と非推奨の `container` グループ | -| [env グループ](03-env.md) | 環境変数の管理(`init` / `sync` / `list` / `set` / `get` / `delete` / `edit` / `project` / `keygen` / `export` / `import`) | +| [env グループ](03-env.md) | 環境変数の管理(`init` / `sync` / `list` / `set` / `get` / `delete` / `edit` / `project` / `keygen` / `encrypt` / `decrypt` / `exec` / `export` / `import`) | | [plugin グループ](04-plugin.md) | プラグインの管理(`list` / `install` / `uninstall` / `update` / `info` / `sync` / `migrate` / `repo *`) | | [snapshot グループ](05-snapshot.md) | スナップショットの管理(`create` / `list` / `restore` / `copy` / `delete` / `rotate`) | @@ -26,7 +26,9 @@ graph TD D --> D3["login [index]"] D --> D4["build [image] / rebuild [name]"] D --> D2["list [--no-interactive]"] - E --> E1[init / sync / list / set / get / delete / edit / project / export / import] + E --> E1[init / sync / list / set / get / delete / edit / project] + E --> E2[keygen / encrypt / decrypt / exec] + E --> E3[export / import] F --> F1[list / install / uninstall / update / info / sync / migrate] F --> F2[repo add / repo remove / repo list / repo refresh] G --> G1[create / list / restore / copy / delete / rotate] diff --git a/etc/_devbase b/etc/_devbase index d6ae39e5..ed5db95a 100644 --- a/etc/_devbase +++ b/etc/_devbase @@ -106,6 +106,9 @@ _devbase() { 'export:Export .env files as an encrypted bundle (age)' 'import:Import .env bundle (age decrypt + merge)' 'keygen:Generate the devbase age key used by the secret store' + 'exec:Run a command with the decrypted secrets in its environment' + 'encrypt:Move plaintext settings into the encrypted store' + 'decrypt:Move encrypted settings back to plaintext' ) plugin_subcommands=( @@ -288,6 +291,15 @@ _devbase() { '--backup-dir[Override backup directory]:dir:_files -/' \ '--keep-last[Keep only the last N backup directories]:n:' ;; + exec) + _arguments '*:command:_command_names -e' + ;; + encrypt|decrypt) + _arguments \ + '*--project[Limit to the specified project (repeatable)]:name:' \ + '--dry-run[Show what would change without writing]' \ + '--yes[Skip the confirmation prompt]' '-y[Skip the confirmation prompt]' + ;; keygen) _arguments \ '--force[Overwrite an existing key]' \ diff --git a/etc/devbase-completion.bash b/etc/devbase-completion.bash index e204ac3a..9a77285d 100644 --- a/etc/devbase-completion.bash +++ b/etc/devbase-completion.bash @@ -35,7 +35,7 @@ _devbase_completions() { # project / container は同じサブコマンド群 (container は非推奨だが補完は維持)。 local project_subcommands="up down ps login logs scale build rebuild list" local container_subcommands="up down ps login logs scale build rebuild" - local env_subcommands="init sync list set get delete edit project export import keygen" + local env_subcommands="init sync list set get delete edit project export import keygen exec encrypt decrypt" local plugin_subcommands="list install uninstall update info sync repo" local repo_subcommands="add remove list refresh" local snapshot_subcommands="create list restore copy delete rotate" diff --git a/issues/plan35.md b/issues/plan35.md index c58c42cb..5991f828 100644 --- a/issues/plan35.md +++ b/issues/plan35.md @@ -230,6 +230,7 @@ chmod 600 ~/.config/devbase/age/keys.txt - **コンテナ環境の可視性**: コンテナの詳細情報を参照する権限があれば、注入済みの環境変数は読める。devbase は開発コンテナに Docker の制御ソケットを渡す構成を既定に含むため、**コンテナ内から他コンテナの環境変数も参照できる** - **構成の展開結果**: 構成の確認コマンドは変数名だけの列挙を実際の値へ解決して表示する - **利用者権限を得た攻撃者**: 既定の鍵保管では鍵も同時に読める +- **同名キーの由来分離**: 生成する構成はサービスごとに「元々参照していた由来(共通/プロジェクト)のキーだけ」を列挙するが、同じキーが共通機密とプロジェクト機密の両方にある場合、値は devbase 自身の環境変数から解決されるため 1 つ(プロジェクト側が優先)に定まる。結果として共通側だけを参照していたサービスにも合成後の値が渡る。サービスごとに異なる値を渡すには生成ファイルへ値を書き込むしかなく、「生成物に機密の値を残さない」という本方針の前提と矛盾するため受け入れる 実行時の露出を下げる手段(コンテナの秘密情報機能によるファイル渡し、クラウドの一時認証、認証エージェントの転送)は、本方針の範囲外として将来の課題に置く。 @@ -255,9 +256,18 @@ chmod 600 ~/.config/devbase/age/keys.txt ## 10. 未確認事項・残リスク -- **値を持たない変数名の列挙の挙動**: 実行プロセス側で変数が未設定だった場合に、Docker Compose の版によって警告のみか失敗かが分かれる可能性がある。実装前に対象版で確認する -- **ホスト側処理の機密依存範囲**: 起動ラッパーが読み込んだ環境変数に依存する処理を全件は洗い出せていない。段階 3 の着手時に、機密を必要とする処理の一覧化を先に行う -- **コンテナ台数を増やした構成での上書き順序**: 台数拡張時に生成される構成ファイルと、機密を渡す上書き構成の適用順序は未検証 +- ~~**値を持たない変数名の列挙の挙動**~~: 確認済み。Docker Compose v5.1.4 では、実行プロセス側で未設定の変数は失敗ではなく空 (`null`) として扱われ、その変数はコンテナへ渡らない。 + + ```console + $ DEFINED_VAR=hello docker compose config + environment: + DEFINED_VAR: hello + UNDEFINED_VAR: null + ``` + + 同時に、構成の確認コマンドが設定済みの値をそのまま表示することも確認できた (§7「守れないもの」に挙げた挙動)。 +- ~~**ホスト側処理の機密依存範囲**~~: 洗い出し済み。§11.2 を参照 +- ~~**コンテナ台数を増やした構成での上書き順序**~~: 別ファイルの上書きを重ねる方式をやめ、台数拡張時に生成する構成そのものへ変数名の列挙を書き込む方式にした。適用順序の問題自体が発生しない - **復号の実行回数**: 現在は devbase の実行ごとに設定ファイルを読み込んでいる。復号を毎回行う場合の所要時間は未計測であり、体感が悪ければ実行単位での保持を検討する - **バックアップ機能との関係**: バックアップ取得がボリューム内の機密を平文で保存するかは未確認 diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 17a37d3b..e6547d58 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -6,6 +6,7 @@ import sys from importlib import import_module from pathlib import Path +from typing import Optional from devbase.errors import DevbaseError from devbase.log import get_logger, setup @@ -53,7 +54,7 @@ ('project',): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build', 'rebuild', 'list'], ('container', 'ct'): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build', 'rebuild'], ('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project', 'keygen', - 'export', 'import'], + 'exec', 'encrypt', 'decrypt', 'export', 'import'], ('plugin', 'pl'): ['list', 'install', 'uninstall', 'update', 'info', 'sync', 'repo', 'migrate'], ('snapshot', 'ss'): ['create', 'list', 'restore', 'copy', 'delete', 'rotate'], } @@ -67,6 +68,15 @@ # `import` 追加で `i` が `init` / `import` の両方にマッチして ambiguous に # なるため、既存ショートカット (`devbase env i` → `init`) を維持する。 'i': 'init', + # `exec` 追加で `ex` が `exec` / `export` の両方にマッチするため、 + # 既存ショートカット (`devbase env ex` → `export`) を維持する。 + # `exec` は `exe` 以降で一意に決まる。 + 'ex': 'export', + # `decrypt` 追加で `d` / `de` が `delete` とも一致するため、既存 + # ショートカット (`devbase env d` → `delete`) を維持する。 + # `decrypt` は `dec` 以降で一意に決まる。 + 'd': 'delete', + 'de': 'delete', }, } @@ -294,6 +304,24 @@ def _add_env_parser(subparsers): # 説明中の環境変数名は devbase.env.agekeys.KEY_FILE_ENV と対。agekeys は pyrage を # 引き込むため、parser 構築時に import せず文字列で持つ (暗号機能を使わない # コマンドまで pyrage のロード失敗に巻き込まないため)。 + env_exec = env_sub.add_parser( + 'exec', + help='Run a command with the decrypted secrets in its environment') + env_exec.add_argument('argv', nargs=argparse.REMAINDER, + metavar='-- CMD [ARGS...]', + help='Command to run (prefix with -- to pass flags)') + + for name, action in (('encrypt', 'Move plaintext settings into the encrypted store'), + ('decrypt', 'Move encrypted settings back to plaintext')): + sub = env_sub.add_parser(name, help=action) + sub.add_argument('--project', action='append', default=[], + metavar='NAME', dest='projects', + help='Limit to the specified project (repeatable)') + sub.add_argument('--dry-run', action='store_true', + help='Show what would change without writing') + sub.add_argument('--yes', '-y', action='store_true', dest='assume_yes', + help='Skip the confirmation prompt') + env_keygen = env_sub.add_parser( 'keygen', help='Generate the devbase age key used by the secret store ' @@ -628,6 +656,8 @@ def main(): cmd = args.command + _load_secret_env(cmd, getattr(args, 'subcommand', None)) + try: return _dispatch(cmd, args) except DevbaseError as e: @@ -635,6 +665,53 @@ def main(): return 1 +# 機密の注入を行わないコマンド。鍵の生成や暗号化・復号は「まだ鍵が無い」 +# 「復号できない」状態でこそ実行されるため、注入を試みると本来の操作の前に +# 落ちてしまう。 +# +# `env` のように注入が要るサブコマンド (`env list` など) と要らないサブコマンド +# が同居するグループがあるため、``(コマンド, サブコマンド)`` の組で持つ。 +# サブコマンドが ``None`` の項目は「そのコマンド全体をスキップする」意味。 +_NO_SECRET_INJECTION = frozenset({ + ('init', None), + ('env', 'keygen'), + ('env', 'encrypt'), + ('env', 'decrypt'), +}) + + +def _skip_secret_injection(cmd: str, subcommand: Optional[str]) -> bool: + return ((cmd, None) in _NO_SECRET_INJECTION + or (cmd, subcommand) in _NO_SECRET_INJECTION) + + +def _load_secret_env(cmd: str, subcommand: Optional[str] = None) -> None: + """機密を復号して自プロセスの環境変数へ載せる。 + + 起動ラッパーは共通の機密ファイルを読み込まなくなった (plan35 §4.4)。 + 従来はラッパーが全コマンドに対して値を環境変数として渡していたため、 + 同じ範囲を Python 側で肩代わりする。ここで載せておけば、エディタ起動や + Docker Compose の変数展開など、値を必要とする処理が従来どおり動く。 + + 復号に失敗しても停止しない。鍵が未整備でも `env keygen` や `--help` は + 使えるべきで、値が本当に要る操作 (コンテナ起動など) は各コマンド側で + 改めて必須として読み込む。 + """ + if _skip_secret_injection(cmd, subcommand): + return + root = os.environ.get('DEVBASE_ROOT') + if not root: + return + try: + from devbase.env import runtime as _runtime + + _runtime.inject(Path(root), _runtime.current_project_name(Path(root))) + except DevbaseError as e: + logger.debug("機密を読み込めませんでした: %s", e) + except Exception as e: # noqa: BLE001 - 通常コマンドを暗号化都合で倒さない + logger.debug("機密の読み込みで想定外のエラー: %s", e) + + # DEVBASE_ROOT 必須コマンドの定義: cmd -> (module, function, args を渡すか)。 # 起動コストを抑えるため import は dispatch 時に遅延させる (従来の関数内 import と同等)。 _ROOT_COMMANDS = { diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 8c7c65e3..68c5b624 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -35,8 +35,68 @@ # 共通ヘルパー # --------------------------------------------------------------------------- +def _devbase_root() -> Optional[Path]: + root = os.environ.get('DEVBASE_ROOT') + return Path(root) if root else None + + +def _inject_secrets(*, required: bool): + """機密を復号して自プロセスの環境変数へ載せ、載せた内容を返す。 + + ``docker compose`` は自分を起動したプロセスの環境変数から値を解決するため、 + Compose を呼ぶ前にここを通す。生成する構成には変数名しか書かないので、 + 暗号文も平文ファイルも Compose には渡らない (plan35 §4.3)。 + + 戻り値を変数名の一覧ではなく :class:`~devbase.env.runtime.SecretEnv` に + しているのは、構成生成側が**由来 (共通 / プロジェクト) ごとの内訳**を必要 + とするため。サービスが元々参照していなかった由来の機密まで渡さないための + 材料になる。 + + ``required=False`` の経路 (down / ps / logs など) では、鍵が無い・復号に + 失敗したというだけでコンテナを止められなくなるのは困るため、警告に留めて + 続行する。値が要るのは主に起動時の変数展開であり、停止や状態確認には + 要らない。 + + 載せ直す前に :func:`~devbase.env.runtime.clear_injected` を通すのは、 + プロジェクト切替の残留対策。``cli._load_secret_env`` は dispatch の**前**に + 「現在地のプロジェクト」の機密を載せるが、TUI や + ``python -m devbase.cli project up `` の直接起動ではその後 + ``_resolve_project_name`` が対象プロジェクトへ切り替わる。ここは切替・chdir + の**後**に呼ばれるので、載せ直しで上書きできる。ただし**上書きだけでは + 足りない**: 切替先に同名のキーが無い機密 (切替元プロジェクト固有のもの) は + 上書きされず残り、Compose や子プロセスへ引き継がれてしまうため、先に + 取り除く。非機密設定 (``env``) 側の ``_CALLER_ENV_KEYS`` / + :func:`_resolve_project_name` と同じ扱いを機密にも与えることになる。 + """ + from devbase.env import runtime as _runtime + from devbase.errors import DevbaseError + + root = _devbase_root() + if root is None: + return _runtime.SecretEnv() + _runtime.clear_injected() + try: + return _runtime.inject(root, _runtime.current_project_name(root)) + except DevbaseError as e: + if required: + raise + logger.warning("機密を読み込めませんでした (続行します): %s", e) + return _runtime.SecretEnv() + + +def _generate_compose_for(scale: int, secrets) -> Path: + """機密の内訳を渡してスケール構成を生成する""" + return generate_scaled_compose( + scale, + secret_env_names=secrets.names, + global_env_names=secrets.global_names, + project_env_names=secrets.project_names, + ) + + def _compose_run(subcommand: str, *extra_args: str) -> int: """docker compose コマンドを実行する共通関数""" + _inject_secrets(required=False) cmd = ['docker', 'compose'] if _SCALE_COMPOSE_FILE.exists(): cmd.extend(['-f', str(_SCALE_COMPOSE_FILE)]) @@ -290,6 +350,12 @@ def _resolve_project_name(project_name: str) -> bool: if not already_there: caller_env_keys = _env_var_keys(Path('env')) os.chdir(target) + # ``PWD`` も併せて切り替える。機密の解決 ( + # :func:`devbase.env.runtime.current_project_name`) は wrapper の cd を前提に + # ``os.environ['PWD']`` を先に見るため、os.chdir だけだと切替前の PWD が残り、 + # 切替先ではなく呼び出し元プロジェクトの機密を読んでしまう + # (TUI の ``_run_in_project`` が PWD を差し替えているのと同じ理由)。 + os.environ['PWD'] = str(target) target_env_keys = _env_var_keys(Path('env')) for key in caller_env_keys - target_env_keys: os.environ.pop(key, None) @@ -555,7 +621,7 @@ def cmd_up(project_name: str = None, scale: int = None, docker_compose_down() logger.info("[3/6] Generating scaled compose file...") - override_file = generate_scaled_compose(scale) + override_file = _generate_compose_for(scale, _inject_secrets(required=True)) logger.info("Generated: %s", override_file) logger.info("[4/6] Starting containers...") @@ -594,6 +660,7 @@ def cmd_up(project_name: str = None, scale: int = None, def cmd_down() -> int: """Stop and remove containers""" + _inject_secrets(required=False) compose_file = _SCALE_COMPOSE_FILE if _SCALE_COMPOSE_FILE.exists() else None docker_compose_down(compose_file=compose_file) @@ -615,6 +682,7 @@ def cmd_down() -> int: def cmd_login(index: str = '1') -> int: """Login to container""" + _inject_secrets(required=False) dev_service = get_dev_service_name() if _SCALE_COMPOSE_FILE.exists(): @@ -687,7 +755,8 @@ def cmd_scale(new_scale: int, project_name: str = None) -> int: ensure_network('devbase_net') logger.info("[3/5] Generating scaled compose file...") - override_file = generate_scaled_compose(new_scale) + override_file = _generate_compose_for( + new_scale, _inject_secrets(required=True)) logger.info("Generated: %s", override_file) logger.info("[4/5] Starting new containers (%d..%d)...", current_scale + 1, new_scale) @@ -871,13 +940,27 @@ def _ensure_env_files() -> bool: return False devbase_root_env = devbase_root / '.env' - if project_env.exists() and devbase_root_env.exists(): + # 機密が暗号化されていれば平文の .env は存在しない。ファイルの有無ではなく + # 秘密ストアに設定があるかで判定しないと、移行済みの環境で毎回 env init が + # 走ってしまう。 + from devbase.env import runtime as _runtime + from devbase.env.secret_store import SecretRef, SecretStore + + store = SecretStore(devbase_root) + has_global = store.exists(SecretRef.for_global()) + + project_name = _runtime.current_project_name(devbase_root) + has_project = project_env.exists() + if not has_project and project_name: + has_project = store.exists(SecretRef.for_project(project_name)) + + if has_project and has_global: return True missing_files = [] - if not project_env.exists(): + if not has_project: missing_files.append("project .env") - if not devbase_root_env.exists(): + if not has_global: missing_files.append(f"devbase root .env ({devbase_root_env})") logger.info("Missing: %s", ', '.join(missing_files)) @@ -886,7 +969,7 @@ def _ensure_env_files() -> bool: success = True child_env = {**os.environ, 'PYTHONPATH': str(devbase_root / 'lib')} - if not devbase_root_env.exists(): + if not has_global: logger.info("Creating devbase root .env...") try: result = subprocess.run( @@ -902,7 +985,7 @@ def _ensure_env_files() -> bool: logger.error("Running env init for devbase root: %s", e) success = False - if not project_env.exists(): + if not has_project: logger.info("Creating project .env...") try: project_env.touch() diff --git a/lib/devbase/commands/env.py b/lib/devbase/commands/env.py index e66fcffd..23719afc 100644 --- a/lib/devbase/commands/env.py +++ b/lib/devbase/commands/env.py @@ -39,52 +39,13 @@ def _global_env(devbase_root: Path): def _current_project_name(devbase_root: Path, cwd: Optional[Path] = None) -> Optional[str]: - """CWD が ``projects/`` 配下ならプロジェクト名を返す。 - - ``projects//sub/dir`` のような下位ディレクトリから実行された場合も - ```` を返す。保存先はプロジェクトの直下に固定したい (コンテナ構成が - 参照するのはそこであり、実行時の CWD ではない) ため、末尾ではなく先頭の - パス要素を採用する。 - - 判定は論理パス → 物理パスの順に 2 段で行う。両方が要るのは: - - - ``.resolve()`` だけだと、プラグイン経由で ``projects/`` が - シンボリックリンクになっているプロジェクト配下で実行したときに - リンク先の実体を指してしまい、``projects/`` の外と判定される。 - - 論理パスだけだと、リンク先の実体パスで入ったときに ``projects/`` 配下と - 判定できない。 - - ``PWD`` 由来のパスはシェルがシンボリックリンクを保った論理パスなので、 - まず ``resolve()`` せずそのまま突き合わせる。 - - 2 段で使う正規化が違うのは、それぞれ守りたい性質が違うため: - - - 論理パス側は ``os.path.abspath`` (= ``normpath``) で ``..`` を **文字列として** - 畳む。シンボリックリンクを解いてしまうと上記の症状が戻るので解かない。一方 - ``..`` を畳まないと ``projects/web/../../outside`` のような - ``projects/`` の外を指すパスが ``relative_to`` を通ってしまい、プロジェクト外 - からの ``--project`` が ``web`` の設定を書き換える。``..`` を textual に畳む - のはシェルの ``cd`` / ``PWD`` の意味論そのものなので、論理パス扱いと矛盾しない。 - - 物理パス側は ``.resolve()`` でリンクも ``..`` も実体まで解く。こちらは - 「実体パスで入られた場合」を拾うためのフォールバックなので、リンクを - 保つ理由が無い。 - """ - current = Path(cwd) if cwd is not None else Path(os.environ.get('PWD', os.getcwd())) - projects_dir = Path(devbase_root) / 'projects' + """CWD からプロジェクト名を解決する (実体は :mod:`devbase.env.runtime`)。 - def to_logical(path: Path) -> Path: - """シンボリックリンクは解かず、絶対パス化と ``..`` の畳み込みだけ行う""" - return Path(os.path.abspath(path)) + 同じ判定をコンテナ起動側 (機密の合成) でも使うため、実装は 1 箇所に置く。 + """ + from devbase.env import runtime as _runtime - for to_path in (to_logical, Path.resolve): - try: - relative = to_path(current).relative_to(to_path(projects_dir)) - except (ValueError, OSError): - continue - parts = relative.parts - if parts: - return parts[0] - return None + return _runtime.current_project_name(devbase_root, cwd) def _project_env(devbase_root: Path, cwd: Optional[Path] = None): @@ -140,6 +101,18 @@ def cmd_env(devbase_root: Path, args) -> int: 'project': lambda: cmd_env_project(devbase_root), 'export': lambda: cmd_env_export(devbase_root, args), 'import': lambda: cmd_env_import(devbase_root, args), + 'exec': lambda: cmd_env_exec(devbase_root, + list(getattr(args, 'argv', []) or [])), + 'encrypt': lambda: _migrate(args).cmd_env_encrypt( + devbase_root, + dry_run=getattr(args, 'dry_run', False), + assume_yes=getattr(args, 'assume_yes', False), + projects=list(getattr(args, 'projects', []) or []) or None), + 'decrypt': lambda: _migrate(args).cmd_env_decrypt( + devbase_root, + dry_run=getattr(args, 'dry_run', False), + assume_yes=getattr(args, 'assume_yes', False), + projects=list(getattr(args, 'projects', []) or []) or None), 'keygen': lambda: cmd_env_keygen(devbase_root, force=getattr(args, 'force', False), assume_yes=getattr(args, 'assume_yes', False)), @@ -153,6 +126,44 @@ def cmd_env(devbase_root: Path, args) -> int: return 1 +def _migrate(_args=None): + """移行コマンドの実装モジュール (import を遅延させる)""" + from devbase.commands import env_migrate + + return env_migrate + + +def cmd_env_exec(devbase_root: Path, argv) -> int: + """機密を環境変数として渡した状態でコマンドを実行する。 + + 起動ラッパーは共通の機密ファイルを読み込まなくなったため、ホスト側で動く + 処理のうち値を必要とするもの (Docker Compose の変数展開など) は、この + コマンドを通して実行する (plan35 §4.4)。復号結果は子プロセスの環境変数 + としてのみ渡り、ファイルには書き出さない。 + """ + from devbase.env import runtime as _runtime + + # argparse.REMAINDER は区切りの `--` も残すため、先頭のものだけ取り除く。 + # 2 つ目以降はコマンド自身への引数なのでそのまま渡す。 + if argv and argv[0] == '--': + argv = argv[1:] + + if not argv: + logger.error("実行するコマンドを指定してください: devbase env exec -- CMD [ARGS...]") + return 1 + + env = _runtime.child_env(devbase_root, + _runtime.current_project_name(devbase_root)) + try: + return subprocess.run(argv, env=env).returncode + except FileNotFoundError: + logger.error("コマンドが見つかりません: %s", argv[0]) + return 127 + except OSError as e: + logger.error("コマンドを実行できませんでした (%s): %s", argv[0], e) + return 1 + + def cmd_env_init(devbase_root: Path, reset: bool = False) -> int: """全体環境の初期セットアップ(対話式)""" env_file = _global_env(devbase_root) diff --git a/lib/devbase/commands/env_migrate.py b/lib/devbase/commands/env_migrate.py new file mode 100644 index 00000000..0e56310f --- /dev/null +++ b/lib/devbase/commands/env_migrate.py @@ -0,0 +1,682 @@ +"""平文と暗号化構成のあいだを往復する移行コマンド + +``devbase env encrypt`` は平文の設定を暗号化ストアへ移し、``devbase env decrypt`` +は平文へ戻す。どちらも以下を守る (plan35 §9): + +- **無言で消さない**: 元の平文はバックアップへ退避し、削除は利用者に委ねる。 + 退避先は排他的に作り、既存のバックアップへは決して書き込まない + (:func:`_create_backup_dir`) +- **原文のまま往復させる**: 機密は ``KEY=VALUE`` の辞書へ畳まず、ファイルの + バイト列のまま暗号化する。``decrypt`` するとコメント・空行・``export`` + 表記まで含めて暗号化前のファイルへ戻る。ただし原文が保たれるのは値を + 書き換えるまでで、``devbase env set`` などで更新すると内容は ``EnvFile`` + の書式へ正規化される (平文だけを使っていた頃と同じ挙動) +- **読み戻せることを確認してから消す**: 暗号化した直後に復号し、元の内容と + 一致した対象だけ平文を退避する。鍵の設定を間違えたまま平文を失うと復旧できない +- **構成ファイルの変更は差分を見せてから行う**: 利用者が独自に編集した + ``compose.yml`` を黙って書き換えない +- **中途半端な状態で終わらない**: 移行は「機密ファイルの移動」と + ``compose.yml`` の書き換えが噛み合って初めて意味を持つ。どちらか片方だけ + 済んだ状態は「構成ファイルが存在しないファイルを参照する」壊れた設定になる + ため、実行した操作ごとに取り消し手続きを積み、どこで失敗しても逆順に + 巻き戻してから ``1`` を返す (:class:`_Rollback`) +""" + +from __future__ import annotations + +import os +import shutil +import stat +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Callable, Dict, List, Optional, Sequence, Set, Tuple + +from devbase.env import agekeys, compose_migrate, io_common +from devbase.env.secret_store import ( + MODE_AGE, + MODE_PLAINTEXT, + SecretRef, + SecretStore, +) +from devbase.env.store import safe_input +from devbase.errors import DevbaseError +from devbase.log import get_logger + +logger = get_logger(__name__) + + +class MigrationError(DevbaseError): + """移行を中止して巻き戻すべき失敗""" + + +@dataclass +class Target: + """移行対象の 1 参照""" + + ref: SecretRef + values: Dict[str, str] = field(default_factory=dict) + + @property + def label(self) -> str: + return self.ref.label() + + +def _timestamp() -> str: + return datetime.now().strftime('%Y%m%d%H%M%S') + + +def _project_names(devbase_root: Path) -> List[str]: + projects_dir = Path(devbase_root) / 'projects' + if not projects_dir.is_dir(): + return [] + return sorted(p.name for p in projects_dir.iterdir() if p.is_dir()) + + +def _select_refs(devbase_root: Path, store: SecretStore, wanted_mode: str, + projects: Optional[Sequence[str]]) -> List[SecretRef]: + """指定された保存形式で存在する参照を集める。 + + ``projects`` を指定した場合は共通設定を対象から外す。「このプロジェクトだけ」 + と言われたのに全体に効く共通設定まで動かすと、取り消しの利かない操作を + 利用者の意図より広く実行してしまう。 + """ + refs: List[SecretRef] = [] + if not projects and store.mode(SecretRef.for_global()) == wanted_mode: + refs.append(SecretRef.for_global()) + + names = list(projects) if projects else _project_names(devbase_root) + for name in names: + ref = SecretRef.for_project(name) + if store.mode(ref) == wanted_mode: + refs.append(ref) + return refs + + +def _affected_projects(refs: Sequence[SecretRef]) -> List[str]: + return [ref.name for ref in refs if ref.kind == 'project' and ref.name] + + +def _confirm(prompt: str, assume_yes: bool) -> bool: + if assume_yes: + return True + return safe_input(prompt) == 'yes' + + +# --------------------------------------------------------------------------- +# 巻き戻し +# --------------------------------------------------------------------------- + +class _Rollback: + """実行した操作の取り消し手続きを積み、失敗時に逆順で実行する。 + + 移行は複数の破壊的な操作 (暗号化・平文の退避・構成ファイルの書き換え・ + 暗号文の削除) が連なる。「全部検証してから全部実行する」とフェーズを + 分けるだけでは、実行フェーズの途中で失敗したぶんが中間状態として残る。 + そこで **操作を 1 つ実行するたびにその取り消し手続きを積み**、どこで + 失敗しても :meth:`unwind` で逆順に巻き戻せるようにする。 + """ + + def __init__(self) -> None: + self._undo: List[Tuple[str, Callable[[], None]]] = [] + + def push(self, description: str, undo: Callable[[], None]) -> None: + """実行済みの操作に対する取り消し手続きを積む。 + + Args: + description: 取り消しが何をするか (巻き戻しに失敗したときに + 「何が残っているか」として利用者へ見せる) + undo: 取り消し手続き + """ + self._undo.append((description, undo)) + + def unwind(self) -> None: + """積んだ取り消し手続きを逆順に実行する。 + + 後の操作は前の操作を前提にしているため、必ず逆順で戻す。巻き戻しの + 途中で失敗しても残りは試みるが、**握り潰さずに何が残っているかを + 具体的に列挙する**。ここで黙ると、利用者は壊れた状態に気付けない。 + """ + failures: List[str] = [] + for description, undo in reversed(self._undo): + try: + undo() + except Exception as e: # 1 つ失敗しても残りの巻き戻しは続ける + failures.append(f" - {description}: {e}") + self._undo.clear() + if failures: + logger.error( + "巻き戻しに失敗しました。次の操作が完了しておらず、" + "手動での復旧が必要です:\n%s", "\n".join(failures)) + + +# --------------------------------------------------------------------------- +# encrypt +# --------------------------------------------------------------------------- + +def cmd_env_encrypt(devbase_root: Path, *, dry_run: bool = False, + assume_yes: bool = False, + projects: Optional[Sequence[str]] = None) -> int: + """平文の設定を暗号化ストアへ移す""" + root = Path(devbase_root) + store = SecretStore(root) + + try: + recipients = agekeys.resolve_recipients(root) + except DevbaseError as e: + logger.error("%s", e) + return 1 + + refs = _select_refs(root, store, MODE_PLAINTEXT, projects) + if not refs: + print("暗号化する平文の設定はありません") + return 0 + + print("\n=== 暗号化する設定 ===") + for ref in refs: + print(f" {ref.label():<24} {store.plaintext.path(ref)}" + f" → {store.age.path(ref)}") + print(f"\n受信者 ({len(recipients)} 件):") + for spec in recipients: + print(f" {spec}") + + # 構成ファイルを読めない / 自動では直せない機密参照がある場合はここで中止する。 + # 平文にはまだ触れていないので、返すだけで元の状態が保たれる。 + try: + compose_changes = _plan_compose_changes(root, refs) + except MigrationError as e: + logger.error("暗号化を中止しました: %s", e) + return 1 + if compose_changes: + print("\n=== コンテナ構成の変更 ===") + for path, (_, _, patch) in compose_changes.items(): + print(f"\n--- {path}") + print(patch, end='' if patch.endswith('\n') else '\n') + + if dry_run: + print("\n(--dry-run のため変更していません)") + return 0 + + print("\n" + "=" * 60) + print("暗号化すると、この鍵を失った時点で設定は復旧できなくなります。") + print(f" 鍵ファイル: {agekeys.key_file_path()}") + print(" 鍵のバックアップを取ってから続行してください。") + print("=" * 60) + if not _confirm("続行しますか? (yes と入力): ", assume_yes): + print("中止しました") + return 1 + + rollback = _Rollback() + try: + # 1. 全対象を暗号化して読み戻せることを確認する (平文にはまだ触れない) + # 2. バックアップ先を排他的に作り、平文をそこへ移す + # 3. compose.yml を書き換える + # 平文を消すのは「全対象の暗号文が読み戻せた」と分かってからにする。 + _encrypt_and_verify(store, refs, rollback) + backup_dir = _create_backup_dir( + root / 'backups' / 'env-encrypt' / _timestamp()) + # 中身を戻したあとに空のバックアップ先だけ残ると「まだ退避された + # ものがある」と誤解させるので、作ったディレクトリも巻き戻しで畳む。 + rollback.push( + f"空になったバックアップ先 {backup_dir} を削除する", + lambda d=backup_dir: _prune_empty_dirs(d, d)) + moved = _move_plaintext_to_backup(store, refs, backup_dir, rollback) + _apply_compose_changes(compose_changes, rollback) + except (DevbaseError, OSError) as e: + logger.error("暗号化を中止し、変更を巻き戻します: %s", e) + rollback.unwind() + return 1 + + print("\n=== 完了 ===") + print("元の平文は次の場所へ退避しました。内容を確認したうえで削除してください:") + for path in moved: + print(f" {path}") + print("\n削除する場合:") + print(f" rm -rf {backup_dir}") + return 0 + + +def _encrypt_and_verify(store: SecretStore, refs: Sequence[SecretRef], + rollback: _Rollback) -> None: + """全対象を暗号化し、読み戻して元の内容と一致することを確認する。 + + ここでは平文に一切触れない。鍵の指定を誤ったまま平文を失うと、誰にも + 復号できないファイルだけが残るため、「読み戻せた」ことを全対象について + 確かめてから次のフェーズへ進む。途中で失敗しても、この実行で作った + 暗号文を消せば元の状態に戻る。 + + 運ぶのは辞書ではなく **平文ファイルの生バイト列** である。``KEY=VALUE`` の + 辞書へ畳むとコメント・空行・``export KEY=...`` 表記・値のクォートが落ち、 + ``decrypt`` しても暗号化前の状態には戻らない。バイト列のまま暗号化し、 + バイト列のまま読み戻して一致を確かめる。 + + なお原文が保たれるのは **値を書き換えるまで** である。``devbase env set`` + などで値を更新すると辞書経由の ``save`` が走り、内容は ``EnvFile`` の書式へ + 正規化される。平文しか無かった頃と同じ挙動であり、暗号化しても変わらない。 + """ + for ref in refs: + original = store.plaintext.load_bytes(ref) + store.age.save_bytes(ref, original) + # 対象は MODE_PLAINTEXT で選んである = この .age はこの実行で作った + # ものだけ。巻き戻しで既存の暗号文を巻き添えにする心配はない。 + rollback.push( + f"{ref.label()}の暗号文 {store.age.path(ref)} を削除する", + lambda r=ref: store.age.remove(r)) + + restored = store.age.load_bytes(ref) + if restored != original: + raise MigrationError( + f"{ref.label()}の暗号化結果が元の内容と一致しません") + logger.info("%s を暗号化しました: %s", ref.label(), store.age.path(ref)) + + +#: バックアップ先の名前が衝突したときに試す一意な suffix の上限。 +#: ここまでぶつかるのは「同じ秒に何十回も移行している」か「先回りして名前を +#: 作られている」異常事態なので、無限に別名を探し続けずに中止して知らせる。 +_BACKUP_DIR_MAX_ATTEMPTS = 100 + + +def _create_backup_dir(preferred: Path) -> Path: + """バックアップ先を **排他的に** 作成し、実際に作れたパスを返す。 + + ディレクトリ名は秒単位の日時なので、同じ秒に 2 回移行すると衝突しうる。 + 既存のディレクトリへそのまま書くと :func:`shutil.move` が同名の + ``global.env`` やプロジェクトの env を上書きし、「削除しないはずの過去の + 平文」を失う。そこで ``exist_ok=False`` で作り、既にあれば ``-2`` ``-3`` … + と一意な名前へ寄せて、**既存のディレクトリへは決して書き込まない**。 + + Raises: + MigrationError: 上限まで試しても空きが見つからない場合、または + ディレクトリを作成できない場合 (どちらも平文にはまだ触れていない + 段階なので、返せば元の状態が保たれる) + """ + # 親階層 (backups/env-encrypt) はスナップショットなど他機能とも共有するので + # 権限は既定のまま。退避先そのものは平文の機密が置かれるため 0700 で作る。 + try: + preferred.parent.mkdir(parents=True, exist_ok=True) + except OSError as e: + raise MigrationError( + f"バックアップ先を作成できませんでした ({preferred.parent}): {e}") from e + + for attempt in range(1, _BACKUP_DIR_MAX_ATTEMPTS + 1): + candidate = (preferred if attempt == 1 + else preferred.with_name(f'{preferred.name}-{attempt}')) + try: + candidate.mkdir(mode=0o700, exist_ok=False) + except FileExistsError: + continue + except OSError as e: + raise MigrationError( + f"バックアップ先を作成できませんでした ({candidate}): {e}") from e + return candidate + raise MigrationError( + f"バックアップ先 {preferred} が既にあり、" + f"{_BACKUP_DIR_MAX_ATTEMPTS} 回試しても空いている名前が見つかりません" + "でした。既存のバックアップを片付けてから再実行してください") + + +def _move_plaintext_to_backup(store: SecretStore, refs: Sequence[SecretRef], + backup_dir: Path, + rollback: _Rollback) -> List[Path]: + """全対象の平文をバックアップへ移す (取り消し: 元の場所へ戻す)""" + moved: List[Path] = [] + for ref in refs: + source = store.plaintext.path(ref) + dest = _move_to_backup(source, ref, backup_dir) + rollback.push( + f"{ref.label()}の平文を {source} へ戻す", + lambda s=source, d=dest: _move_back(d, s, backup_dir)) + moved.append(dest) + return moved + + +def _move_to_backup(source: Path, ref: SecretRef, backup_dir: Path) -> Path: + """平文ファイルをバックアップへ移す (コピーではなく移動)。 + + ``backup_dir`` は :func:`_create_backup_dir` がこの実行のために排他的に + 作ったディレクトリで、既存のバックアップとは決して重ならない。その内側の + ``projects/`` は複数の対象で共有するので ``exist_ok=True`` で掘る + (対象ごとにファイル名が一意なため、ここで上書きは起こらない)。 + """ + if ref.kind == 'global': + dest = backup_dir / 'global.env' + else: + dest = backup_dir / 'projects' / f'{ref.name}.env' + dest.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + shutil.move(str(source), str(dest)) + return dest + + +def _move_back(dest: Path, source: Path, backup_dir: Path) -> None: + """バックアップへ移した平文を元の場所へ戻す""" + shutil.move(str(dest), str(source)) + _prune_empty_dirs(dest.parent, backup_dir) + + +def _prune_empty_dirs(start: Path, stop: Path) -> None: + """``start`` から ``stop`` まで、空になったディレクトリを畳む。 + + 中身を戻したのに空のバックアップディレクトリだけ残ると「まだ退避された + ものがある」と誤解させる。見た目の掃除でしかないので、消せなくても + 巻き戻しの失敗としては扱わない (中身は既に元の場所へ戻っている)。 + """ + current = start + while True: + try: + os.rmdir(current) + except OSError: + return + if current == stop or current == current.parent: + return + current = current.parent + + +# --------------------------------------------------------------------------- +# decrypt +# --------------------------------------------------------------------------- + +def cmd_env_decrypt(devbase_root: Path, *, dry_run: bool = False, + assume_yes: bool = False, + projects: Optional[Sequence[str]] = None) -> int: + """暗号化された設定を平文へ戻す""" + root = Path(devbase_root) + store = SecretStore(root) + + refs = _select_refs(root, store, MODE_AGE, projects) + if not refs: + print("平文へ戻す暗号化済みの設定はありません") + return 0 + + print("\n=== 平文へ戻す設定 ===") + for ref in refs: + print(f" {ref.label():<24} {store.age.path(ref)}" + f" → {store.plaintext.path(ref)}") + + try: + compose_changes = _plan_compose_changes(root, refs, restore=True) + except MigrationError as e: + logger.error("復号を中止しました: %s", e) + return 1 + if compose_changes: + print("\n=== コンテナ構成の変更 ===") + for path, (_, _, patch) in compose_changes.items(): + print(f"\n--- {path}") + print(patch, end='' if patch.endswith('\n') else '\n') + + if dry_run: + print("\n(--dry-run のため変更していません)") + return 0 + + print("\n平文に戻すと、ディスク上に認証情報がそのまま置かれた状態になります。") + if not _confirm("続行しますか? (yes と入力): ", assume_yes): + print("中止しました") + return 1 + + rollback = _Rollback() + try: + # 1. 全対象の暗号文を読み込み、復号できることを確認する + # 2. 平文を書き出す + # 3. compose.yml を復元する + # 4. 最後に暗号文を削除する + # + # 破壊的な削除を最後に置くのは、途中で失敗したときに失うものを最小に + # するため。2 や 3 で失敗しても暗号文はまだディスク上にあり、巻き戻しは + # 「書いた平文を消す」だけで済む。逆に先に消してしまうと、以降の失敗の + # 巻き戻しがメモリ上の内容頼みになり、復旧の余地が狭くなる。 + loaded = _load_encrypted(store, refs) + _write_plaintext(store, loaded, rollback) + _apply_compose_changes(compose_changes, rollback) + _remove_encrypted(store, loaded, rollback) + except (DevbaseError, OSError) as e: + logger.error("復号を中止し、変更を巻き戻します: %s", e) + rollback.unwind() + return 1 + + print("\n=== 完了 ===") + return 0 + + +def _load_encrypted(store: SecretStore, refs: Sequence[SecretRef], + ) -> List[Tuple[SecretRef, bytes, bytes]]: + """全対象の暗号文を読み込み、復号できることを確認する。 + + Returns: + ``(参照, 復号した平文のバイト列, 暗号文のバイト列)`` の並び + + 平文は辞書ではなくバイト列で控える。``encrypt`` が原文をそのまま暗号化して + いるので、そのまま書き戻せばコメント・空行・``export`` 表記まで含めて + 暗号化前のファイルへ戻る。 + + 暗号文の生バイト列も控える。最後に削除した ``.age`` を、巻き戻しでそのまま + 書き戻せるようにするため (再暗号化すると内容が同じでもバイト列は変わり、 + 「元に戻した」と言い切れなくなる)。 + """ + loaded: List[Tuple[SecretRef, bytes, bytes]] = [] + for ref in refs: + path = store.age.path(ref) + try: + blob = path.read_bytes() + except OSError as e: + raise MigrationError(f"暗号文を読み込めませんでした ({path}): {e}") from e + loaded.append((ref, store.age.load_bytes(ref), blob)) + return loaded + + +def _write_plaintext(store: SecretStore, + loaded: Sequence[Tuple[SecretRef, bytes, bytes]], + rollback: _Rollback) -> None: + """全対象の平文を書き出す (取り消し: 書いた平文を削除)""" + for ref, plain, _ in loaded: + store.plaintext.save_bytes(ref, plain) + # 対象は MODE_AGE で選んである = この平文はこの実行で作ったものだけ。 + rollback.push( + f"{ref.label()}の平文 {store.plaintext.path(ref)} を削除する", + lambda r=ref: store.plaintext.remove(r)) + logger.info("%s を平文へ戻しました: %s", ref.label(), + store.plaintext.path(ref)) + + +def _remove_encrypted(store: SecretStore, + loaded: Sequence[Tuple[SecretRef, bytes, bytes]], + rollback: _Rollback) -> None: + """暗号文を削除する (取り消し: 控えた生バイト列で復元)""" + for ref, _, blob in loaded: + path = store.age.path(ref) + store.age.remove(ref) + rollback.push( + f"{ref.label()}の暗号文 {path} を復元する", + lambda p=path, b=blob: io_common.write_secure_bytes_atomic(p, b)) + + +# --------------------------------------------------------------------------- +# コンテナ構成の書き換え +# --------------------------------------------------------------------------- + +def _compose_targets(path: Path, *, has_global: bool, + project_names: Sequence[str]) -> Set[str]: + """この ``compose.yml`` で触ってよい参照の種別を決める。 + + 暗号化 (無効化) と復号 (復元) で同じ判定を使う。「一部だけ復号したのに + 全マーカーを戻す」と、まだ暗号化されたままの共通設定への参照まで有効に + なり、存在しないファイルを指したまま Compose が起動に失敗する。 + """ + wanted: Set[str] = set() + if has_global: + wanted.add(compose_migrate.TARGET_GLOBAL) + if path.parent.name in project_names: + wanted.add(compose_migrate.TARGET_PROJECT) + return wanted + + +def _plan_compose_changes(devbase_root: Path, refs: Sequence[SecretRef], + *, restore: bool = False): + """``compose.yml`` の書き換え内容を組み立てる (書き込みはしない)。 + + Returns: + ``{パス: (書き換え前のテキスト, 書き換え後のテキスト, 差分)}`` + + 書き換え前のテキストも返すのは、適用後に別の操作が失敗したとき、 + 差分を計算したのと同じ内容へ書き戻して巻き戻せるようにするため。 + + 暗号化側では、書き換え内容を確定したあとに **YAML としてパースし直して** + 機密参照が残っていないことを確かめる (:func:`_verify_secrets_are_unreferenced`)。 + 行ベースの走査が未知の記法を取りこぼしても、ここで必ず中止に落ちる。 + + Raises: + MigrationError: 構成ファイルを読めない場合、自動では書き換えられない + 機密参照が残っている場合、または書き換え後も機密参照が残っている + ことを事後検証が見つけた場合 (いずれも「平文だけ退避されて構成は + 存在しないファイルを指したまま」という壊れた結果になる) + """ + root = Path(devbase_root) + has_global = any(ref.kind == 'global' for ref in refs) + project_names = _affected_projects(refs) + + # 共通の機密を暗号化する場合、その参照は全プロジェクトの構成に現れるため、 + # 対象プロジェクトだけでなく全プロジェクトを見る必要がある。 + targets = _project_names(root) if has_global else project_names + changes = {} + + for path in compose_migrate.compose_files(root, targets): + try: + # `read_text` は改行を LF へ揃えて読む (universal newlines) ため、 + # CRLF の compose.yml を書き戻すとファイル全体の改行コードが + # 変わってしまう。書き換えた行以外は 1 バイトも動かさない。 + before = path.read_bytes().decode('utf-8') + except (OSError, UnicodeDecodeError) as e: + # 読めないファイルを飛ばして続けると、機密の参照が残ったまま平文 + # だけが退避され、コマンドは成功を返す。壊れた構成に気付けるのは + # 次の起動時になるため、ここで移行全体を中止する。 + raise MigrationError( + f"構成ファイルを読めませんでした ({path}): {e}") from e + + # 行単位では書き換えられない記法 (インライン配列・続きの行を持つ + # long syntax など) は対象から漏れる。黙って漏らすと壊れた構成の + # まま起動して初めて気付くため、どのファイルの何行目かを警告しておく。 + compose_migrate.warn_unsupported_env_file(before, path) + + wanted = _compose_targets(path, has_global=has_global, + project_names=project_names) + if not restore: + # 扱えない記法のうち **機密を指しているもの** は警告では済まない。 + # 平文を退避したあとも参照が有効なまま残り、Compose が存在しない + # ファイルを読もうとして起動できなくなる。手で直してから再実行して + # もらう (機密と無関係なものは移行に影響しないので警告のみ)。 + # + # 復元 (decrypt) 側では止めない。平文が戻る以上その参照は有効に + # なるうえ、ここで失敗させると壊れた状態からの復帰手段まで + # 塞いでしまう。 + blocking = compose_migrate.secret_unsupported_env_file_lines( + before, wanted) + if blocking: + detail = '\n'.join(f" {path}:{number}: {line}" + for number, line in blocking) + raise MigrationError( + "自動で書き換えられない env_file の記法が機密ファイルを" + "参照しています。次の行を `env_file:` の下に `- ...` を" + "並べる書き方へ手で直してから再実行してください:\n" + f"{detail}") + if restore: + after, touched = compose_migrate.enable(before, wanted) + else: + after, touched = compose_migrate.disable(before, wanted) + # 行ベースの走査が終わったところで、書き換えた結果を YAML として + # 読み直し、機密参照が本当に消えたことを確かめる。走査は記法の + # 判別に頼っている以上いつでも取りこぼしうるので、記法に依らない + # この検証を最後の砦として必ず通す (compose_migrate 冒頭 + # 「二段構えの保証」)。差分が出なかったファイルも対象にする。 + _verify_secrets_are_unreferenced(path, after, wanted) + + if touched and after != before: + changes[path] = (before, after, + compose_migrate.diff(before, after, path)) + + return changes + + +def _verify_secrets_are_unreferenced(path: Path, after: str, + wanted: Set[str]) -> None: + """書き換え後の ``compose.yml`` に機密参照が残っていないことを確かめる。 + + ``compose_migrate`` の書き換えは行ベースなので、YAML の記法が想定から + 外れると (``env_file: >-`` のようなブロックスカラーなど) 参照を取りこぼす。 + 取りこぼしたまま進むと「平文だけ退避され、構成は存在しないファイルを指した + まま」でコマンドが成功してしまう。**記法の判別に依らない事後検証**をここに + 置き、取りこぼしを必ず移行の中止へ落とす。 + + 復元 (decrypt) 側では行わない。平文が戻る以上その参照は有効で正しく、 + 残っていることが期待される状態だからである。 + + Raises: + MigrationError: 参照が残っている場合、または YAML として読めない場合 + """ + try: + remaining = compose_migrate.remaining_secret_env_file_refs( + after, wanted) + except compose_migrate.ComposeParseError as e: + # 検証できない = 参照が残っていないと言い切れない。読み取り失敗と + # 同じ扱いで中止する (「たぶん大丈夫」で平文を消してはいけない)。 + raise MigrationError( + f"構成ファイルを読めませんでした ({path}): {e}") from e + + if remaining: + detail = '\n'.join(f" {path}: サービス {service} の env_file: {ref}" + for service, ref in remaining) + raise MigrationError( + "暗号化で消える機密ファイルへの env_file 参照を自動で外せません" + "でした。次の参照を手で削除するか、`env_file:` の下に `- ...` を " + "1 行ずつ並べる書き方へ直してから再実行してください:\n" + f"{detail}") + + +#: 既存の ``compose.yml`` の権限を読めなかったときに使う既定値。 +#: 機密ではないので ``0600`` ではなく「誰でも読める」側に倒す。 +_COMPOSE_FALLBACK_MODE = 0o644 + + +def _compose_file_mode(path: Path) -> int: + """既存の ``compose.yml`` の権限をそのまま返す。 + + 書き込みに使う :func:`io_common.write_secure_bytes_atomic` は機密ファイル + 向けに既定が ``0600`` になっている。``compose.yml`` は機密ではなく、他の + 利用者や CI から読めることを前提に置かれているため、**既存の権限を勝手に + 狭めない**よう元の mode を引き継ぐ。 + """ + try: + return stat.S_IMODE(path.stat().st_mode) + except OSError: + return _COMPOSE_FALLBACK_MODE + + +def _write_compose(path: Path, text: str, mode: int) -> None: + """``compose.yml`` を **原子的に** 差し替える。 + + ``Path.write_text`` は既存ファイルを truncate してから書くため、途中で + ``OSError`` が起きると部分的な ``compose.yml`` が残る。しかもこの書き込みは + 取り消し手続きを積む前に走るので、壊れた内容を巻き戻せない。一時ファイル + → ``os.replace`` の方式なら、途中で失敗しても元の内容がそのまま残る。 + """ + io_common.write_secure_bytes_atomic(path, text.encode('utf-8'), mode=mode) + + +def _apply_compose_changes(changes, rollback: _Rollback) -> None: + """計画した書き換えを適用する (取り消し: 元のテキストを書き戻す)。 + + 1 つでも書けなければ例外で呼び出し元へ返す。ここでログだけ出して次の + ファイルへ進むと、機密の移動・削除が済んだあとでもコマンドが成功扱いに + なり、構成ファイルが存在しないファイルを参照したまま残ってしまう。 + 書けたぶんの取り消しは既に積んであるので、呼び出し元が巻き戻せる。 + """ + for path, (before, after, _) in changes.items(): + # 元の権限は書き込み前に控える。差し替え後に読むと、こちらが付けた + # 権限を「元の権限」と取り違える。 + mode = _compose_file_mode(path) + try: + _write_compose(path, after, mode) + except OSError as e: + raise MigrationError( + f"構成ファイルを更新できませんでした ({path}): {e}") from e + rollback.push( + f"{path} を元の内容へ書き戻す", + lambda p=path, t=before, m=mode: _write_compose(p, t, m)) + logger.info("構成ファイルを更新しました: %s", path) diff --git a/lib/devbase/env/compose_migrate.py b/lib/devbase/env/compose_migrate.py new file mode 100644 index 00000000..abfc81c2 --- /dev/null +++ b/lib/devbase/env/compose_migrate.py @@ -0,0 +1,901 @@ +"""プロジェクト構成ファイルから機密ファイルの参照を外す / 戻す + +各プロジェクトの ``compose.yml`` は共通設定とプロジェクト設定を ``env_file`` で +直接参照している。機密を暗号化すると、そのファイルは平文としては存在しなくなる +ため、参照を残したままでは Docker Compose が起動時に失敗する (plan35 §2.2)。 + +書き換えは **行単位のコメントアウト** で行い、元の行をそのまま残す: + + env_file: + # devbase(PLAN35) 機密は環境変数で注入: - ${DEVBASE_ROOT}/.env + - env + +こうする理由は 2 つある。1 つは、YAML として読み書きし直すと利用者が自分で書いた +コメントや整形が失われること。もう 1 つは、平文へ戻す操作 (``devbase env decrypt``) +で**元の行を機械的に復元できる**こと。行を削除してしまうと、どの位置に何を書き戻せば +よいか分からなくなる。 + +この走査が扱う範囲 (契約) +------------------------- + +行単位で書き換える以上、YAML の記法すべてを扱えるわけではない。**何を扱い、何を +扱わないか**をここに明示する。走査を直すときはこの契約と突き合わせること。 + +1. 書き換える記法 — 1 行がちょうど 1 エントリに対応するもの:: + + env_file: + - .env + - "${DEVBASE_ROOT}/.env" + - path: .env # long syntax でも 1 行で閉じているもの + + env_file: .env # 値が単一文字列。キー行ごと無効化する + env_file: "${DEVBASE_ROOT}/.env" + + 行ごとコメントアウトしても他の指定を巻き込まないため、無効化も復元も機械的に + できる。行末コメント・前後の空行・利用者のコメント行が混ざっていてもよい。 + 単一文字列の形はエントリが 1 つしかないので、``env_file:`` の行そのものを + コメントアウトする (シーケンスの全エントリを落としたときと同じ扱い)。 + +2. 移行を中止する記法 — 1 行に閉じていない、または 1 行に複数の指定が同居する + もの:: + + env_file: [ "${DEVBASE_ROOT}/.env", .env ] + env_file: >- # 続きの行に値を持つブロックスカラー + .env + env_file: + - path: .env + required: false # 続きの行を持つ long syntax + - { path: .env } # フロー記法のマッピング + + 行ごとコメントアウトすると無関係な指定まで巻き添えにする (あるいは 1 エントリ + の一部だけが残って YAML が壊れる)。**機密を指している場合は移行を止め**、 + 利用者に手で直してもらう (:func:`secret_unsupported_env_file_lines`)。 + ``env_file:`` の値がシーケンスでない場合 (下がマッピングになっている等) も + ここに含める。Compose の仕様上は不正な書き方だが、参照を見落とすよりは中止・ + 警告する方が安全なため。 + +3. 触らない記法 — 機密と無関係な参照:: + + env_file: + - config/app.env + + env_file: config/app.env + + 移行で消えるファイルではないので書き換える必要がない。ただし 2. の記法で + 書かれている場合は、機密でなくても警告する + (:func:`warn_unsupported_env_file`)。 + +**不変条件**: 扱えない記法に当たったときに黙って通してはいけない。機密を指して +いれば中止 (2.)、指していなければ警告に落ちる。黙って見逃すと、平文を退避した +あとも参照だけが残り、次の起動で初めて壊れていることに気付くことになる。 + +二段構えの保証 (行ベースの走査 + 事後検証) +------------------------------------------ + +上の契約は「行を見て記法を判別できる」ことに依存している。YAML の記法は多く、 +判別を 1 つ取りこぼすたびに同じ穴が空く。例えば ``env_file: >-`` のブロック +スカラーは先頭行に参照先が書かれておらず、行だけを見ても機密を指しているか +分からない。**記法ごとに穴を塞ぎ続ける限り、この種の見落としは無くならない**。 + +そこで記法の判別に頼らない**事後検証**を最後の砦として置く: + +1. 行ベースの走査は「うまく書き換えられれば書き換える」(契約 1.)。扱えないと + *分かった* ものは早い段階で中止・警告する + (契約 2., :func:`secret_unsupported_env_file_lines`) +2. 書き換えたあとのテキストを **YAML としてパースし**、機密参照が本当に残って + いないことを確かめる (:func:`remaining_secret_env_file_refs`)。残っていれば + 移行そのものを中止し、手で直してもらう + +1. が取りこぼしても 2. が必ず捕まえるので、**未知の記法でも「平文だけ退避されて +参照が残る」結果にはならない**。1. を直す意味は「分かりやすいエラーを早い段階で +出す」ことであって、不変条件そのものを支えているのは 2. である。 +""" + +from __future__ import annotations + +import difflib +import re +from pathlib import Path +from typing import (Any, Dict, Iterable, List, NamedTuple, Optional, Sequence, + Set, Tuple) + +import yaml + +from devbase.errors import DevbaseError +from devbase.log import get_logger + +logger = get_logger(__name__) + + +class ComposeParseError(DevbaseError): + """``compose.yml`` を YAML として読めない + + 事後検証 (:func:`remaining_secret_env_file_refs`) は「機密参照が残って + いないこと」をパース結果で確かめる。パースできなければ確かめようがない + ため、「参照が無い」と読み替えて先へ進んではいけない。呼び出し側は + 構成ファイルの読み取り失敗と同じ扱いで移行を中止する。 + """ + +#: コメントアウトした行に付ける目印。復元時はこれを取り除くだけで元に戻る。 +DISABLED_MARK = '# devbase(PLAN35) 機密は環境変数で注入: ' + +#: 共通の機密ファイルを指す ``env_file`` エントリ +GLOBAL_ENTRIES = ('${DEVBASE_ROOT}/.env', '$DEVBASE_ROOT/.env') + +#: プロジェクトの機密ファイルを指す ``env_file`` エントリ +PROJECT_ENTRIES = ('.env', './.env') + +TARGET_GLOBAL = 'global' +TARGET_PROJECT = 'project' + +_ENV_FILE_KEY_RE = re.compile(r'^(\s*)env_file:\s*(#.*)?$') +_LIST_ITEM_RE = re.compile(r'^(\s*)-\s*(.*?)\s*$') + +#: ``env_file:`` の後ろに値が続く書き方 (インライン配列・単一文字列)。 +#: このうち単一文字列は 1 行で完結するため書き換えの対象にできる +#: (:func:`_inline_scalar_ref`)。それ以外は検出して警告・中止に回す。 +_ENV_FILE_INLINE_RE = re.compile(r'^\s*env_file:\s*(?!#)(\S.*)$') + +#: 「1 行で完結する単一文字列」とはみなせない値の先頭文字。フロー記法 +#: (``[`` ``{``) は 1 行に複数の指定が同居し、ブロックスカラー (``|`` ``>``) や +#: アンカー・別名・タグ (``&`` ``*`` ``!``) は続きの行を持ちうる。どちらも行ごと +#: コメントアウトすると無関係な指定を巻き込む / YAML が壊れるため、契約 2. 側へ +#: 回して中止・警告に落とす。 +_UNSAFE_SCALAR_HEADS = ('[', '{', '|', '>', '&', '*', '!', '?', '%', '@', '`', + ',', '-') + +#: long syntax (``- path: .env``) の ``path`` キー。Compose はエントリを +#: マッピングでも書けるため、文字列としてだけ見ると参照を取りこぼす。 +_LONG_SYNTAX_PATH_RE = re.compile(r"""^(?:path|'path'|"path")\s*:\s*(.*)$""") + +#: フロー記法 (``{ path: .env, required: false }``) から ``path`` の値だけを拾う。 +#: 書き換えの対象にはしないが、「機密を指しているか」の判定には要る。 +_FLOW_PATH_RE = re.compile( + r"""(?:^|[\[{,]\s*)(?:path|'path'|"path")\s*:\s*([^,}\]]*)""") + +#: ``services:`` セクションの開始行 +_SERVICES_KEY_RE = re.compile(r'^(\s*)services:\s*(#.*)?$') + +#: サービス名の行 (`` dev:`` / `` "db":`` / `` db: # コメント``) +_SERVICE_KEY_RE = re.compile( + r"""^\s*(?:"([^"]*)"|'([^']*)'|([^\s#:][^:]*)):\s*(#.*)?$""") + + +class _Entry(NamedTuple): + """``env_file`` ブロックの 1 エントリ + + Attributes: + index: エントリが始まる行の位置。 + refs: そのエントリが指しうる参照先。続きの行に書かれた ``path`` も + 含める。移行を止めるべきかの判定に使う。 + disabled: すでにコメントアウトされているか。 + supported: **行単位で無効化・復元できるか** (モジュール冒頭の契約 1.)。 + 偽なら書き換えず、警告か中止のどちらかに落とす。 + """ + + index: int + refs: Tuple[str, ...] + disabled: bool + supported: bool + + +def _indent_of(line: str) -> int: + return len(line) - len(line.lstrip(' ')) + + +def _split_eol(line: str) -> Tuple[str, str]: + """行を ``(中身, 行末)`` に分ける。 + + ``rstrip('\\n')`` で行末を落として ``'\\n'`` を付け直すと、CRLF の行が + LF になってしまう。暗号化 → 復号の往復で元の ``compose.yml`` に戻らず、 + 書き換えた行だけ改行コードが混ざる。元の行末をそのまま付け直せるよう + ここで分けておく。 + """ + for eol in ('\r\n', '\n', '\r'): + if line.endswith(eol): + return line[:-len(eol)], eol + return line, '' + + +def _strip_quotes(value: str) -> str: + if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): + value = value[1:-1] + return value.strip() + + +def _entry_value(raw: str) -> str: + """``- "${DEVBASE_ROOT}/.env" # comment`` から参照先だけを取り出す""" + return _strip_quotes(raw.split('#', 1)[0].strip()) + + +def _long_syntax_ref(text: str) -> Optional[str]: + """``path: .env`` から参照先を取り出す (long syntax でなければ None)""" + match = _LONG_SYNTAX_PATH_RE.match(text.strip()) + if not match: + return None + return _strip_quotes(match.group(1).split('#', 1)[0].strip()) + + +def _flow_map_refs(text: str) -> List[str]: + """フロー記法の中の ``path`` の値をすべて拾う。 + + ``- { path: .env, required: false }`` や ``env_file: [{path: .env}]`` は + 書き換えの対象にしない (契約 2.) が、機密を指しているなら移行を止める + 必要があるため、判定に使う参照先だけは取り出す。 + """ + return [_strip_quotes(value.strip()) + for value in _FLOW_PATH_RE.findall(text) + if value.strip()] + + +def _inline_scalar_ref(raw: str) -> Optional[str]: + """``env_file: .env`` の値が **1 行で完結する単一文字列** なら参照先を返す。 + + この形はエントリが 1 つしかなく、``env_file:`` の行ごとコメントアウトしても + 他の指定を巻き込まない。だから中止 (契約 2.) ではなく書き換えの対象にできる + (契約 1.)。1 行で安全に判断できない値 — フロー記法・ブロックスカラー・ + 閉じていないクォート — は ``None`` を返し、従来どおり中止・警告へ回す。 + + Args: + raw: ``env_file:`` の後ろに続く部分 (行末コメントを含みうる) + + Returns: + 参照先の文字列。単一文字列として扱えない場合は ``None``。 + """ + value = raw.strip() + if value[:1] in ('"', "'"): + # クォートされた値は閉じ引用符まで見る。`"a # b"` のように値の中へ + # `#` が入る場合、先にコメントで切ると参照先を取り違える。 + quote = value[0] + end = value.find(quote, 1) + if end < 0: + return None # 閉じていない = 続きの行を持つ可能性がある + rest = value[end + 1:].strip() + if rest and not rest.startswith('#'): + return None # 引用符の後ろに別の指定が続く + return value[1:end] + value = value.split('#', 1)[0].strip() + if not value or value[0] in _UNSAFE_SCALAR_HEADS: + return None + return value + + +def _inline_entries(raw: str) -> List[str]: + """``env_file:`` の後ろに直接書かれた値から参照の一覧を取り出す。 + + ``[ "${DEVBASE_ROOT}/.env", .env ]`` のようなインライン配列と、 + ``.env`` のような単一文字列の両方を受ける。「機密を指しているかどうか」の + 判定に使う (単一文字列は書き換えもできるが、その判定は + :func:`_inline_scalar_ref` が行う)。 + """ + value = raw.split('#', 1)[0].strip() + if value.startswith('['): + inner = value[1:] + if inner.endswith(']'): + inner = inner[:-1] + parts = inner.split(',') + else: + parts = [value] + found = [item for item in (_strip_quotes(part.strip()) for part in parts) + if item] + # 要素がフロー記法のマッピングだと上の分割では参照先にならない + # (``{path: .env}`` がそのまま 1 要素になる)。``path`` の値も足しておく。 + found.extend(_flow_map_refs(value)) + return found + + +def _list_item_refs(body: str) -> Tuple[Tuple[str, ...], bool]: + """リスト項目の中身から ``(参照先, 行単位で扱えるか)`` を返す。 + + ``- .env`` のような文字列と ``- path: .env`` の long syntax はどちらも + 1 行で閉じているので書き換えられる。フロー記法のマッピングだけは 1 行に + 複数の指定が同居するため対象外にする (契約 2.)。 + """ + value = body.split('#', 1)[0].strip() + if value.startswith('{') or value.startswith('['): + return tuple(_flow_map_refs(value)), False + ref = _long_syntax_ref(value) + if ref is not None: + return (ref,), True + return (_strip_quotes(value),), True + + +def _service_name(line: str) -> Optional[str]: + """サービス名の行から **YAML と同じ姿の** 名前を取り出す。 + + ``"db":`` のようにクォートされたキーも有効な YAML で、PyYAML は ``db`` を + 返す。引用符込みで記録すると、パース済みのサービス名と照合する生成側 + (``devbase.volume.compose``) と一致せず、そのサービスへ機密が渡らない。 + ここで引用符を外して揃える (二重引用符の中のバックスラッシュ表記までは + 解釈しない。構成ファイルのサービス名には現れないため)。 + """ + match = _SERVICE_KEY_RE.match(line) + if not match: + return None + double, single, bare = match.group(1), match.group(2), match.group(3) + if double is not None: + return double + if single is not None: + return single.replace("''", "'") + return bare.strip() + + +def _target_of(value: str) -> Optional[str]: + """``env_file`` の 1 エントリが**どちらの機密**を指しているかを返す。 + + 「機密かどうか」だけでなく由来 (共通 / プロジェクト) まで返すのは、機密の + 渡し先を決める側 (``devbase.volume.compose``) が「そのサービスが元々 + 受け取っていた由来のキーだけ」を列挙できるようにするため。真偽値だけでは + 共通設定しか読んでいなかったサービスにプロジェクト固有の機密まで渡って + しまう。 + """ + if value in GLOBAL_ENTRIES: + return TARGET_GLOBAL + if value in PROJECT_ENTRIES: + return TARGET_PROJECT + return None + + +def _is_target(value: str, targets: Set[str]) -> bool: + return _target_of(value) in targets + + +def is_secret_entry(value: str, + targets: Iterable[str] = (TARGET_GLOBAL, TARGET_PROJECT) + ) -> bool: + """``env_file`` の 1 エントリが「暗号化移行で消える既知の機密参照」かを返す。 + + 判定そのものは :func:`_is_target` と同じだが、あちらは private なので、 + 構成生成側 (``devbase.volume.compose``) から同じ基準で判定するための公開窓口 + として置く。判定を 1 箇所に集めておかないと、移行が外す参照と生成が落とす + 参照がずれる。 + """ + if not isinstance(value, str): + return False + return _is_target(value.strip(), set(targets)) + + +def _is_disabled(line: str) -> bool: + return line.lstrip(' ').startswith(DISABLED_MARK) + + +def _disable_line(content: str) -> str: + """行末は含めずに受け取り、目印を付けた姿を返す。 + + 末尾の空白まで含めてそのまま残すのは、復元したときに元のバイト列へ戻す + ため (行末は :func:`_split_eol` で別に持ち回る)。 + """ + indent = ' ' * _indent_of(content) + return f"{indent}{DISABLED_MARK}{content.lstrip(' ')}" + + +def _enable_line(content: str) -> str: + indent = ' ' * _indent_of(content) + return f"{indent}{content.lstrip(' ')[len(DISABLED_MARK):]}" + + +def _source_line(line: str) -> str: + """無効化されているかに関わらず、その行の「YAML としての姿」を返す。 + + 復元側はキー行もエントリ行もコメントアウトされている場合があるため、 + インデントや記法の判定は目印を外した姿に対して行う必要がある。 + """ + return _enable_line(line) if _is_disabled(line) else line + + +def _is_skippable(line: str) -> bool: + """空行、または利用者が書いた単独のコメント行かを返す。 + + どちらも YAML としての構造を持たないので、走査の途中で出てきても + ブロックの終わりとみなしてはいけない。ここで打ち切ると、**コメント行より + 後ろに書かれた機密参照が無効化されないまま残り**、平文を退避したあとに + Compose が存在しないファイルを読もうとして起動できなくなる。 + + 無効化済みの行 (:data:`DISABLED_MARK` 付き) も見た目はコメント行だが、 + 中身はエントリなので読み飛ばしてはいけない。判定の順序を間違えると + ``enable`` が何も復元できなくなるため、先に :func:`_is_disabled` で除く。 + """ + stripped = line.strip() + if not stripped: + return True + if _is_disabled(stripped): + return False + return stripped.startswith('#') + + +def _with_continuation(entry: _Entry, source: str) -> _Entry: + """続きの行を持つエントリに「行単位では扱えない」印を付ける。 + + ``- path: .env`` の下に ``required: false`` が続く形は、``- path:`` の行だけ + コメントアウトすると ``required: false`` が宙に浮いて YAML が壊れる。行を + またぐ範囲を安全に無効化・復元する術がないので、書き換えの対象から外して + 中止・警告へ回す (契約 2.)。続きの行に書かれた ``path`` も控えておかないと、 + ``-`` の行に参照が現れない書き方で機密を見落とす。 + """ + refs = entry.refs + ref = _long_syntax_ref(source) + if ref: + refs = refs + (ref,) + return entry._replace(refs=refs, supported=False) + + +def _scan_env_file_block(lines: Sequence[str], key_index: int, key_indent: int + ) -> Tuple[List[_Entry], int]: + """``env_file:`` ブロックのエントリを集め、ブロックの終端を返す。 + + ``disable`` と ``enable`` は向きが逆なだけで「どこからどこまでがブロックで、 + どの行がエントリか」の判定は同じである。二重に持つと片方だけ直したときに + 無効化と復元がずれるため、走査はここ 1 箇所に集める。扱えない記法の検出も + 同じ走査に相乗りさせる (:func:`_unsupported_entries`)。 + + Args: + key_index: ``env_file:`` キー行の位置。 + key_indent: キー行のインデント (目印を外した姿で数えたもの)。 + + Returns: + ``([エントリ], ブロック終端の行の位置)`` + """ + entries: List[_Entry] = [] + index = key_index + 1 + item_indent: Optional[int] = None + while index < len(lines): + raw = _split_eol(lines[index])[0] + if _is_skippable(raw): + index += 1 + continue + # 無効化済みの行も「YAML としての姿」に戻してインデントと記法を見る + source = _source_line(raw) + if _indent_of(source) <= key_indent: + break + item = _LIST_ITEM_RE.match(source) + if item is None: + # `- ` で始まらないのにブロックの中にある行。long syntax の続き + # (`required: false` など) か、そもそもシーケンスでない値である。 + # どちらも行単位では扱えないので、直前のエントリに印を付けて先へ + # 進む。ここで走査を打ち切る方が危険で、後ろに並ぶエントリを丸ごと + # 取りこぼし、機密の参照が有効なまま残ってしまう。 + if item_indent is None: + # `env_file:` の直下がシーケンスでない。ブロック全体を 1 つの + # 扱えないエントリとみなし、キーより深い行はすべて続きとして + # 束ねる。 + entries.append(_Entry(index, (), False, False)) + item_indent = key_indent + entries[-1] = _with_continuation(entries[-1], source) + index += 1 + continue + item_indent = _indent_of(source) + refs, supported = _list_item_refs(item.group(2)) + entries.append(_Entry(index, refs, _is_disabled(raw), supported)) + index += 1 + return entries, index + + +def disable(text: str, targets: Iterable[str] = (TARGET_GLOBAL, TARGET_PROJECT) + ) -> Tuple[str, List[str]]: + """機密ファイルを指す ``env_file`` エントリをコメントアウトする。 + + 書き換えるのは契約 1. の記法だけで、扱えない記法には触れない。触れない分は + :func:`secret_unsupported_env_file_lines` が中止の理由として拾う。 + + Returns: + ``(書き換え後のテキスト, 無効化した参照の一覧)`` + """ + wanted = set(targets) + lines = text.splitlines(keepends=True) + disabled: List[str] = [] + + index = 0 + while index < len(lines): + content, eol = _split_eol(lines[index]) + + # `env_file: .env` のように値が単一文字列で 1 行に収まっている形は、 + # その行がそのまま 1 エントリなのでキー行ごと落とす (契約 1.) + inline = _ENV_FILE_INLINE_RE.match(content) + if inline: + ref = _inline_scalar_ref(inline.group(1)) + if ref is not None and _is_target(ref, wanted): + lines[index] = _disable_line(content) + eol + disabled.append(ref) + index += 1 + continue + + match = _ENV_FILE_KEY_RE.match(content) + if not match: + index += 1 + continue + + key_index = index + key_indent = len(match.group(1)) + touched_here = False + active_entries = 0 + + entries, block_end = _scan_env_file_block(lines, key_index, key_indent) + for entry in entries: + if entry.disabled: + # すでに無効化されている。有効なエントリとしても数えない + continue + if entry.supported and _is_target(entry.refs[0], wanted): + entry_content, entry_eol = _split_eol(lines[entry.index]) + lines[entry.index] = _disable_line(entry_content) + entry_eol + disabled.append(entry.refs[0]) + touched_here = True + else: + active_entries += 1 + + # 全エントリを落とすと `env_file:` だけが残り、Compose が + # 「env_file は文字列かリスト」で失敗する。キー行ごと無効化する。 + if touched_here and active_entries == 0: + lines[key_index] = _disable_line(content) + eol + + index = block_end + + return ''.join(lines), disabled + + +def enable(text: str, targets: Iterable[str] = (TARGET_GLOBAL, TARGET_PROJECT) + ) -> Tuple[str, List[str]]: + """``disable`` が付けた目印を外し、元の行へ戻す。 + + ``disable`` と同じく **種別を絞れる**。一部のプロジェクトだけを復号した + ときに全マーカーを戻すと、まだ暗号化されたままの共通設定 + (``${DEVBASE_ROOT}/.env``) の参照まで有効になり、存在しないファイルを + 指したまま Compose の起動が失敗する。 + + Returns: + ``(書き換え後のテキスト, 復元した行の一覧)`` + """ + wanted = set(targets) + lines = text.splitlines(keepends=True) + restored: List[str] = [] + + index = 0 + while index < len(lines): + content, eol = _split_eol(lines[index]) + # キー行そのものが無効化されている場合があるため、目印を外した姿で判定する + source = _source_line(content) + + # 単一文字列の形は行ごと無効化されている。同じ条件で戻す (契約 1.) + inline = _ENV_FILE_INLINE_RE.match(source) + if inline: + if _is_disabled(content): + ref = _inline_scalar_ref(inline.group(1)) + if ref is not None and _is_target(ref, wanted): + lines[index] = _enable_line(content) + eol + restored.append(lines[index].strip()) + index += 1 + continue + + match = _ENV_FILE_KEY_RE.match(source) + if not match: + index += 1 + continue + + key_index = index + key_disabled = _is_disabled(content) + key_indent = _indent_of(_source_line(content)) + active_entries = 0 + + entries, block_end = _scan_env_file_block(lines, key_index, key_indent) + for entry in entries: + if not entry.disabled: + active_entries += 1 + continue + if entry.supported and _is_target(entry.refs[0], wanted): + entry_content, entry_eol = _split_eol(lines[entry.index]) + lines[entry.index] = _enable_line(entry_content) + entry_eol + restored.append(lines[entry.index].strip()) + active_entries += 1 + + # キー行は「有効なエントリが 1 つも残らない」場合に無効化されている。 + # 逆向きも同じ条件で判断し、エントリが戻ったときにだけ復元する。 + # まだ全エントリが無効なまま `env_file:` を戻すと Compose が失敗する。 + if key_disabled and active_entries > 0: + lines[key_index] = _enable_line(content) + eol + restored.append(lines[key_index].strip()) + + index = block_end + + return ''.join(lines), restored + + +def _unsupported_entries(text: str) -> List[Tuple[int, str, Tuple[str, ...]]]: + """行単位では扱えない ``env_file`` の記述を、指しうる参照つきで列挙する。 + + 契約 2. に当たるものをすべて集める。参照先まで返すのは、呼び出し側が + 「警告で済ませてよい行」と「移行を止めるべき行」を区別できるようにするため。 + + Returns: + ``[(1 始まりの行番号, 行の内容, その記述が指しうる参照)]`` + """ + lines = text.splitlines() + found: List[Tuple[int, str, Tuple[str, ...]]] = [] + + index = 0 + while index < len(lines): + stripped = lines[index].rstrip() + if not _is_disabled(stripped): + inline = _ENV_FILE_INLINE_RE.match(stripped) + if inline: + # `env_file:` の後ろに値が続く書き方。単一文字列は行ごと + # 無効化できる (契約 1.) ので挙げない。フロー記法など 1 行で + # 安全に判断できないものだけを中止・警告の対象にする + if _inline_scalar_ref(inline.group(1)) is None: + found.append((index + 1, stripped.strip(), + tuple(_inline_entries(inline.group(1))))) + index += 1 + continue + + source = _source_line(stripped) + match = _ENV_FILE_KEY_RE.match(source) + if not match: + index += 1 + continue + + # ブロックの中に潜む扱えない記法 (続きの行を持つ long syntax など) は + # 行を単独で見ても分からない。無効化と同じ走査で拾う。 + entries, block_end = _scan_env_file_block( + lines, index, len(match.group(1))) + for entry in entries: + if not entry.supported: + found.append((entry.index + 1, + lines[entry.index].strip(), entry.refs)) + index = block_end + + return found + + +def unsupported_env_file_lines(text: str) -> List[Tuple[int, str]]: + """行単位では扱えない ``env_file`` 記法を列挙する (契約 2.)。 + + Returns: + ``[(1 始まりの行番号, 行の内容)]`` + """ + return [(number, line) for number, line, _ in _unsupported_entries(text)] + + +def warn_unsupported_env_file(text: str, path: Optional[Path] = None + ) -> List[Tuple[int, str]]: + """扱えない ``env_file`` 記法を見つけたら警告する。 + + 移行の対象から外れることを黙っていると、利用者は「移行できた」と思った + まま起動して初めて壊れていることに気付く。どのファイルの何行目を手で + 直せばよいかまで示す。 + """ + found = unsupported_env_file_lines(text) + for number, line in found: + logger.warning( + "%s:%d の env_file は行単位では自動で書き換えられない記法です" + " (対応しているのは `env_file:` の下に `- ...` を 1 行ずつ並べる" + "書き方だけです)。手動で書き換えてください: %s", + path if path is not None else '', number, line) + return found + + +def secret_unsupported_env_file_lines( + text: str, + targets: Iterable[str] = (TARGET_GLOBAL, TARGET_PROJECT) +) -> List[Tuple[int, str]]: + """**機密ファイルを指している**扱えない記法の行だけを列挙する。 + + :func:`unsupported_env_file_lines` は契約 2. に当たるものをすべて返すが、 + そのうち ``env_file: config/app.env`` のように機密と無関係なものは移行に + 影響しない (書き換える必要が無い)。一方 ``env_file: [.env]`` や + ``- path: .env`` + ``required: false`` のように機密を指しているものは、平文を + 退避したあとも参照が有効なまま残り、Compose が存在しないファイルを読もうと + して起動できなくなる。**警告で流すのではなく移行を止める**必要があるため、 + その 2 つをここで区別する。 + + Returns: + ``[(1 始まりの行番号, 行の内容)]`` + """ + wanted = set(targets) + return [(number, line) for number, line, refs in _unsupported_entries(text) + if any(_is_target(ref, wanted) for ref in refs)] + + +def _parsed_env_file_refs(value: Any) -> List[str]: + """パース済みの ``env_file`` の値から参照文字列を平坦化して取り出す。 + + Compose の ``env_file`` は 3 通りの姿を取る。どれか 1 つでも見落とすと + 事後検証がその形を素通りさせてしまうため、すべてここで畳む:: + + env_file: .env # 文字列 + env_file: [.env, config/app.env] # 文字列のリスト + env_file: + - path: .env # long syntax (dict) のリスト + required: false + + 文字列にならない値 (数値や入れ子など Compose としては不正なもの) は + 参照として扱えないので落とす。落としたものが機密を指していることは + ありえない (機密参照は必ず文字列で書かれる)。 + """ + entries = value if isinstance(value, list) else [value] + refs: List[str] = [] + for entry in entries: + if isinstance(entry, dict): + entry = entry.get('path') + if isinstance(entry, str): + refs.append(entry.strip()) + return refs + + +def remaining_secret_env_file_refs( + text: str, + targets: Iterable[str] = (TARGET_GLOBAL, TARGET_PROJECT) +) -> List[Tuple[str, str]]: + """**YAML としてパースし**、有効なままの機密参照を列挙する (事後検証)。 + + モジュール冒頭「二段構えの保証」の 2. にあたる最後の砦。行ベースの走査 + (:func:`disable`) が書き換えを終えたテキストを渡すと、記法の判別に一切 + 頼らずに「機密を指す ``env_file`` が有効なまま残っていないか」を確かめ + られる。無効化した行は YAML のコメントなので、パーサからは最初から + 見えない = 残っていれば**走査が取りこぼした**ということになる。 + + 差分が出なかった (何も書き換えなかった) ``compose.yml`` にも掛ける。 + 走査が何も見つけられなかったファイルこそ取りこぼしの疑いが濃く、素通り + させると平文だけ退避された壊れた構成が残る。 + + Args: + text: 検証するテキスト (書き換え後のもの) + targets: 消える機密の種別。復号しない種別の参照は残っていて当然 + なので対象から外す。 + + Returns: + ``[(サービス名, 残っている参照)]``。空なら参照は残っていない。 + + Raises: + ComposeParseError: YAML として読めない場合 + """ + wanted = set(targets) + try: + document = yaml.safe_load(text) + except yaml.YAMLError as e: + raise ComposeParseError(f"YAML として読めません: {e}") from e + + if not isinstance(document, dict): + return [] + services = document.get('services') + if not isinstance(services, dict): + return [] + + found: List[Tuple[str, str]] = [] + for name, config in services.items(): + if not isinstance(config, dict) or 'env_file' not in config: + continue + for ref in _parsed_env_file_refs(config['env_file']): + if _is_target(ref, wanted): + found.append((str(name), ref)) + return found + + +def services_with_secret_env_file( + text: str, + targets: Iterable[str] = (TARGET_GLOBAL, TARGET_PROJECT) +) -> Dict[str, Set[str]]: + """機密ファイルを参照している (していた) サービスを **生テキスト** から集める。 + + 移行後の ``compose.yml`` では機密の ``env_file`` 参照がコメントアウトされ、 + YAML としてパースすると見えなくなる。パース結果だけを見ると「元々その参照 + から機密を受け取っていたサービス」(例: DB パスワードを読む ``db``) を + 取りこぼし、機密が渡らないまま起動して失敗する。そこで生テキストを走査し、 + **有効なエントリとコメントアウトされたエントリの両方**を拾う。 + + 行単位で書き換えられない記法 (契約 2.) も対象に含める。移行は止まるが、 + 利用者が手で直したあとも同じ判定が使えるようにするため。 + + Returns: + ``{サービス名: 参照していた種別の集合}``。種別は ``TARGET_GLOBAL`` / + ``TARGET_PROJECT``。単なるサービス名の集合ではなく種別まで返すのは、 + 機密を渡す側が**元々受け取っていた由来のキーだけ**へ絞れるようにする + ため。共通設定だけを読んでいたサービスにプロジェクト固有のトークンまで + 渡すのは、元の構成より機密の範囲を広げてしまう。 + """ + wanted = set(targets) + found: Dict[str, Set[str]] = {} + + def record(service: str, value: str) -> None: + target = _target_of(value) + if target in wanted: + found.setdefault(service, set()).add(target) + + services_indent: Optional[int] = None + service_indent: Optional[int] = None + current: Optional[str] = None + env_file_indent: Optional[int] = None + + for raw_line in text.splitlines(): + stripped = raw_line.rstrip() + # 空行と利用者のコメント行は構造を持たない。ここで env_file ブロックを + # 打ち切ると、その後ろのエントリを取りこぼして機密が渡らなくなる + if _is_skippable(stripped): + continue + # コメントアウト済みの行も「YAML としての姿」に戻して判定する + line = _source_line(stripped) + indent = _indent_of(line) + + if services_indent is None: + match = _SERVICES_KEY_RE.match(line) + if match: + services_indent = len(match.group(1)) + service_indent = None + current = None + env_file_indent = None + continue + + if indent <= services_indent: + # services: セクションを抜けた (volumes: / networks: など) + services_indent = None + service_indent = None + current = None + env_file_indent = None + match = _SERVICES_KEY_RE.match(line) + if match: + services_indent = len(match.group(1)) + continue + + if service_indent is None: + service_indent = indent + + if indent <= service_indent: + # クォート付きのキーも YAML と同じ姿へ揃える。引用符込みで記録すると + # パース済みのサービス名と照合できず、機密が渡らない + current = _service_name(line) + env_file_indent = None + continue + + if current is None: + continue + + if env_file_indent is not None and indent > env_file_indent: + item = _LIST_ITEM_RE.match(line) + if item: + for ref in _list_item_refs(item.group(2))[0]: + record(current, ref) + continue + # `- ` で始まらない行は long syntax の続き (`path:` / `required:`)。 + # ブロックを抜けたことにすると後続のエントリを取りこぼす + ref = _long_syntax_ref(line) + if ref is not None: + record(current, ref) + continue + env_file_indent = None + + if _ENV_FILE_KEY_RE.match(line): + env_file_indent = indent + continue + + inline = _ENV_FILE_INLINE_RE.match(line) + if inline: + for value in _inline_entries(inline.group(1)): + record(current, value) + + return found + + +def find_secret_entries(text: str, + targets: Iterable[str] = (TARGET_GLOBAL, TARGET_PROJECT) + ) -> List[str]: + """有効なままの機密ファイル参照を列挙する (書き換えはしない)""" + _, found = disable(text, targets) + return found + + +def diff(before: str, after: str, path: Path) -> str: + """利用者へ提示するための差分を作る""" + return ''.join(difflib.unified_diff( + before.splitlines(keepends=True), + after.splitlines(keepends=True), + fromfile=f'{path} (現在)', + tofile=f'{path} (変更後)', + )) + + +def compose_files(devbase_root: Path, projects: Sequence[str]) -> List[Path]: + """対象プロジェクトの ``compose.yml`` のうち実在するものを返す""" + root = Path(devbase_root) + found = [] + for name in projects: + path = root / 'projects' / name / 'compose.yml' + if path.is_file(): + found.append(path) + return found diff --git a/lib/devbase/env/runtime.py b/lib/devbase/env/runtime.py new file mode 100644 index 00000000..a8a94e6d --- /dev/null +++ b/lib/devbase/env/runtime.py @@ -0,0 +1,272 @@ +"""実行時に機密をメモリ上で合成し、子プロセスへ渡す + +暗号化した機密は、恒久的な平文ファイルを介さずにコンテナへ届ける必要がある +(plan35 §4.2)。本モジュールは復号結果をプロセス内で合成し、 + + - ``docker compose`` を起動する devbase 自身の環境変数へ載せる + - コンテナへ渡すべき**変数名の一覧**を返す + +の 2 つを提供する。値を持たない変数名の列挙を構成ファイルに書けば、Docker +Compose は自分を起動したプロセスの環境変数からその値を解決する。結果として +暗号文も平文ファイルも Compose には渡らない。 +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from devbase.env.secret_store import SecretRef, SecretStore +from devbase.env.store import EnvFile +from devbase.log import get_logger + +logger = get_logger(__name__) + + +# --------------------------------------------------------------------------- +# プロジェクトの特定 +# --------------------------------------------------------------------------- + +def current_project_name(devbase_root: Path, cwd: Optional[Path] = None) -> Optional[str]: + """CWD が ``projects/`` 配下ならプロジェクト名を返す。 + + ``projects//sub/dir`` のような下位ディレクトリから実行された場合も + ```` を返す。保存先はプロジェクトの直下に固定したい (コンテナ構成が + 参照するのはそこであり、実行時の CWD ではない) ため、末尾ではなく先頭の + パス要素を採用する。 + + 判定は論理パス → 物理パスの順に 2 段で行う。両方が要るのは: + + - ``.resolve()`` だけだと、プラグイン経由で ``projects/`` が + シンボリックリンクになっているプロジェクト配下で実行したときに + リンク先の実体を指してしまい、``projects/`` の外と判定される。 + - 論理パスだけだと、リンク先の実体パスで入ったときに ``projects/`` 配下と + 判定できない。 + + ``PWD`` 由来のパスはシェルがシンボリックリンクを保った論理パスなので、 + まず ``resolve()`` せずそのまま突き合わせる。 + + 2 段で使う正規化が違うのは、それぞれ守りたい性質が違うため: + + - 論理パス側は ``os.path.abspath`` (= ``normpath``) で ``..`` を **文字列として** + 畳む。シンボリックリンクを解いてしまうと上記の症状が戻るので解かない。一方 + ``..`` を畳まないと ``projects/web/../../outside`` のような + ``projects/`` の外を指すパスが ``relative_to`` を通ってしまい、プロジェクト外 + からの ``--project`` が ``web`` の設定を書き換える。``..`` を textual に畳む + のはシェルの ``cd`` / ``PWD`` の意味論そのものなので、論理パス扱いと矛盾しない。 + - 物理パス側は ``.resolve()`` でリンクも ``..`` も実体まで解く。こちらは + 「実体パスで入られた場合」を拾うためのフォールバックなので、リンクを + 保つ理由が無い。 + """ + current = Path(cwd) if cwd is not None else Path(os.environ.get('PWD', os.getcwd())) + projects_dir = Path(devbase_root) / 'projects' + + def to_logical(path: Path) -> Path: + """シンボリックリンクは解かず、絶対パス化と ``..`` の畳み込みだけ行う""" + return Path(os.path.abspath(path)) + + for to_path in (to_logical, Path.resolve): + try: + relative = to_path(current).relative_to(to_path(projects_dir)) + except (ValueError, OSError): + continue + parts = relative.parts + if parts: + return parts[0] + return None + + +# --------------------------------------------------------------------------- +# 機密の合成 +# --------------------------------------------------------------------------- + +@dataclass +class SecretEnv: + """合成した機密と、コンテナへ渡すべき変数名 + + 変数名を**由来 (共通 / プロジェクト) ごとに分けて**持つ。構成生成側は、 + サービスが元々 ``env_file`` で参照していた由来のキーだけを列挙する必要が + あり、全キーをまとめた一覧しか無いと、共通設定だけを読んでいたサービスへ + プロジェクト固有のトークンまで渡ってしまうため (plan35 §4.3)。 + """ + + values: Dict[str, str] = field(default_factory=dict) + #: 共通機密 (``$DEVBASE_ROOT/.env``) 由来のキー + global_names: List[str] = field(default_factory=list) + #: プロジェクト機密 (``projects//.env``) 由来のキー + project_names: List[str] = field(default_factory=list) + + @property + def names(self) -> List[str]: + """コンテナの構成へ列挙する変数名の全体 (共通 → プロジェクトの順) + + 由来を問わず全件が要る場面 (dev サービス、注入した件数のログ) 向けの + 従来どおりの一覧。重複は先に現れた側の位置で 1 件に畳む。 + """ + return list(dict.fromkeys([*self.global_names, *self.project_names])) + + def __bool__(self) -> bool: + return bool(self.global_names or self.project_names) + + +def _project_env_overrides(devbase_root: Path, project: str) -> Dict[str, str]: + """プロジェクトの非機密設定 (``projects//env``) による上書き値。 + + 値そのものはファイルから読まず、既に環境変数へ載っているものだけを採用する。 + ``env`` は ``WORK_DIR=/work/$GIT_REPO`` のように同一ファイル内の変数を参照 + するため、起動ラッパー (または ``_load_project_env``) が展開した後の値が + 正しく、ここで生の行を読み直すと未展開の文字列を掴んでしまう。 + """ + path = Path(devbase_root) / 'projects' / project / 'env' + if not path.is_file(): + return {} + try: + raw = path.read_bytes() + except OSError as e: + logger.warning("プロジェクト設定を読めませんでした (%s): %s", path, e) + return {} + try: + keys = EnvFile.parse_bytes(raw).keys() + except UnicodeDecodeError as e: + logger.warning("プロジェクト設定を UTF-8 として読めませんでした (%s): %s", path, e) + return {} + return {key: os.environ[key] for key in keys if key in os.environ} + + +def resolve(devbase_root: Path, project: Optional[str] = None, + *, store: Optional[SecretStore] = None) -> SecretEnv: + """機密を合成して返す。 + + 重ね順は従来の ``env_file`` の並びを踏襲する: + 共通の機密 → プロジェクトの非機密設定 → プロジェクトの機密。 + + コンテナへ列挙するのは共通機密とプロジェクト機密のキーだけで、非機密設定は + 構成ファイルが ``env_file`` として直接読むため列挙しない。ただし両方に同じ + キーがある場合は、列挙した変数の**値**として非機密設定側を採用する。 + ``environment`` は ``env_file`` より優先されるため、こうしないと + 「プロジェクト設定が共通設定を上書きする」という従来の関係が反転する。 + """ + root = Path(devbase_root) + store = store if store is not None else SecretStore(root) + + global_secrets = store.load(SecretRef.for_global()) + global_names = list(global_secrets) + project_names: List[str] = [] + + merged: Dict[str, str] = dict(global_secrets) + + if project: + merged.update(_project_env_overrides(root, project)) + project_secrets = store.load(SecretRef.for_project(project)) + merged.update(project_secrets) + project_names = list(project_secrets) + + resolved = SecretEnv(global_names=global_names, project_names=project_names) + resolved.values = { + name: merged[name] for name in resolved.names if name in merged + } + return resolved + + +#: この実行で :func:`inject` が載せた履歴。 +#: +#: 値は ``(対象の環境マッピング, {変数名: 載せる**前**の値 (未設定なら None)})``。 +#: +#: 「載せた変数名」だけでなく元の値まで控えるのは、解除時に利用者がシェルで +#: 設定していた同名の変数まで消さないため。元々あった変数は元の値へ戻し、 +#: 元々無かった変数だけを削除する。 +#: +#: さらに**対象マッピングごとに**分けて持つ。:func:`inject` / :func:`clear_injected` +#: は ``environ`` 引数で ``os.environ`` 以外のマッピングを渡され得る (テストや、 +#: 将来「子プロセス用の辞書へ載せて後で戻す」ような呼び出し) ため。履歴が全体で +#: 1 つしか無いと、``inject(..., environ=A)`` の後に ``clear_injected(environ=B)`` +#: を呼んだとき、A に対して記録した内容で B を書き換えてしまい (誤って B の値を +#: 「復元」し)、かつ A には機密が載ったまま残る。 +#: +#: ``dict`` は hashable ではないのでキーには ``id()`` を使うが、対象そのものへの +#: 参照も一緒に保持する。参照を持つ限り対象オブジェクトは生存し続けるので、 +#: 解放済みアドレスの ``id`` が別のマッピングへ再利用されて履歴が誤爆すること +#: がない。解除した時点でその対象の履歴ごと捨てる。 +_injected_originals: Dict[int, Tuple[Any, Dict[str, Optional[str]]]] = {} + + +def _history_for(target) -> Dict[str, Optional[str]]: + """対象マッピングに紐づく注入履歴を返す (無ければ作る)""" + _, originals = _injected_originals.setdefault(id(target), (target, {})) + return originals + + +def clear_injected(environ=None) -> List[str]: + """この実行で載せた機密を取り除き、注入前の状態へ戻す。 + + プロジェクトを切り替える経路 (TUI や ``project up `` の直接起動) では、 + 切替元プロジェクトの機密を載せた後に切替先の機密を載せ直すことになる。この + とき**単に上書きするだけでは足りない**: 切替先に同名のキーが無ければ、切替元 + 固有の機密が ``os.environ`` に残ったまま Compose や子プロセスへ引き継がれて + しまうため。載せ直す前にここを通して、切替元の値を確実に落とす。 + + 非機密設定 (``env``) について起動ラッパーの ``_CALLER_ENV_KEYS`` や + :func:`devbase.commands.container._resolve_project_name` が行っている + 「呼び出し元固有のキーを unset してから対象を読む」のと同じ性質を、機密に + ついても満たすための関数。 + + 自分が **その対象マッピングへ** 載せたキーだけを対象にする。利用者がシェルで + 設定していた同名の変数は注入前の値へ戻すので、消えることはない。他の + マッピングへの注入は、ここでは一切触らない。 + + Returns: + 取り除いた (または元へ戻した) 変数名の一覧 + """ + target = environ if environ is not None else os.environ + entry = _injected_originals.pop(id(target), None) + if entry is None: + return [] + _, originals = entry + cleared = list(originals) + for name, original in originals.items(): + if original is None: + target.pop(name, None) + else: + target[name] = original + if cleared: + logger.debug("機密 %d 件を環境変数から取り除きました", len(cleared)) + return cleared + + +def inject(devbase_root: Path, project: Optional[str] = None, + *, environ=None, store: Optional[SecretStore] = None) -> SecretEnv: + """合成した機密を環境変数へ載せ、載せた内容を返す。 + + ``docker compose`` は devbase 自身の環境変数から値を解決するため、Compose を + 起動する前にここを通す。 + + 載せた変数名と注入前の値を **載せた対象マッピングごとに** 記録し、 + :func:`clear_injected` で元へ戻せるようにする。プロジェクト切替時に切替元の + 機密を落とすために必要 (詳細は :func:`clear_injected` の説明を参照)。 + """ + resolved = resolve(devbase_root, project, store=store) + target = environ if environ is not None else os.environ + if resolved.values: + # 履歴は対象マッピングごとに持つ (理由は _injected_originals の説明を参照)。 + # 載せるものが無いときは記録も作らない (空の履歴が対象への参照を抱え込む + # のを避ける)。 + originals = _history_for(target) + for name in resolved.values: + # 既に記録済みなら上書きしない。記録したいのは「devbase が最初に載せる + # 前の値」であって、前回の注入で載せた機密ではないため。 + if name not in originals: + originals[name] = target.get(name) + target.update(resolved.values) + if resolved.names: + logger.debug("機密 %d 件を環境変数へ載せました", len(resolved.names)) + return resolved + + +def child_env(devbase_root: Path, project: Optional[str] = None, + *, base=None, store: Optional[SecretStore] = None) -> Dict[str, str]: + """機密を載せた子プロセス用の環境変数辞書を作る (``os.environ`` は変えない)""" + env = dict(base if base is not None else os.environ) + env.update(resolve(devbase_root, project, store=store).values) + return env diff --git a/lib/devbase/env/secret_store.py b/lib/devbase/env/secret_store.py index 7de534e8..5c720d09 100644 --- a/lib/devbase/env/secret_store.py +++ b/lib/devbase/env/secret_store.py @@ -16,6 +16,19 @@ どちらを使うかは**ファイルの存在で自動判定**する。暗号化ファイルがあればそれを使い、 無ければ平文を使う。同じ参照に対して両方が存在する状態は、どちらが正なのか判断できない ため明示的なエラーにして利用者に解消させる (plan35 §9)。 + +読み書きの経路は 2 つある: + +- ``load`` / ``save``: ``KEY=VALUE`` の辞書として扱う。``devbase env set`` など + 「値を書き換える」操作はこちらを使う +- ``load_bytes`` / ``save_bytes``: **原文のバイト列をそのまま** 扱う。 + ``devbase env encrypt`` / ``decrypt`` の移行はこちらを使い、コメント・空行・ + ``export KEY=...`` のような表記を保ったまま往復させる + +このため「**値を書き換えるまでは原文が保たれ、書き換えると正規化される**」という +性質になる。``env set`` で 1 つでも値を更新すると ``EnvFile.dump_bytes`` の書式 +(キーの昇順・コメントの消失) へ揃うが、これは平文しか無かった頃と同じ挙動であり、 +暗号化したからといって変わるものではない。 """ from __future__ import annotations @@ -88,6 +101,8 @@ def path(self, ref: SecretRef) -> Path: ... def exists(self, ref: SecretRef) -> bool: ... def load(self, ref: SecretRef) -> Dict[str, str]: ... def save(self, ref: SecretRef, data: Dict[str, str]) -> Path: ... + def load_bytes(self, ref: SecretRef) -> bytes: ... + def save_bytes(self, ref: SecretRef, data: bytes) -> Path: ... def remove(self, ref: SecretRef) -> bool: ... @@ -107,31 +122,45 @@ def path(self, ref: SecretRef) -> Path: def exists(self, ref: SecretRef) -> bool: return self.path(ref).is_file() - def load(self, ref: SecretRef) -> Dict[str, str]: + def load_bytes(self, ref: SecretRef) -> bytes: + """``.env`` の中身を **原文のバイト列のまま** 返す (不在なら空)""" path = self.path(ref) if not path.is_file(): - return {} + return b'' try: - return EnvFile.parse_bytes(path.read_bytes()) + return path.read_bytes() except OSError as e: raise SecretStoreError(f"読み込みに失敗しました ({path}): {e}") from e - except UnicodeDecodeError as e: - raise SecretStoreError( - f"{path} を UTF-8 として読めませんでした: {e}\n" - "暗号化済みファイルを平文として読もうとしていないか確認してください" - ) from e - def save(self, ref: SecretRef, data: Dict[str, str]) -> Path: + def save_bytes(self, ref: SecretRef, data: bytes) -> Path: + """バイト列を **加工せずそのまま** ``.env`` へ書き出す""" path = self.path(ref) try: # 平文とはいえ機密の入れ物なので、暗号化側と同じく atomic に差し替える。 # 直接 O_TRUNC すると書き込み途中の失敗で旧値も新値も失った空ファイルが # 残り、その状態で暗号化すると中身の無い機密を保存してしまう。 - _io_common.write_secure_bytes_atomic(path, EnvFile.dump_bytes(data)) + _io_common.write_secure_bytes_atomic(path, data) except OSError as e: raise SecretStoreError(f"書き込みに失敗しました ({path}): {e}") from e return path + def load(self, ref: SecretRef) -> Dict[str, str]: + try: + return EnvFile.parse_bytes(self.load_bytes(ref)) + except UnicodeDecodeError as e: + raise SecretStoreError( + f"{self.path(ref)} を UTF-8 として読めませんでした: {e}\n" + "暗号化済みファイルを平文として読もうとしていないか確認してください" + ) from e + + def save(self, ref: SecretRef, data: Dict[str, str]) -> Path: + """辞書を ``EnvFile`` の書式へ整形して保存する。 + + コメント・空行・``export`` 表記は辞書に載らないため、この経路を通ると + 内容が正規化される。原文を保ちたい移行系は :meth:`save_bytes` を使う。 + """ + return self.save_bytes(ref, EnvFile.dump_bytes(data)) + def remove(self, ref: SecretRef) -> bool: path = self.path(ref) if not path.exists(): @@ -188,37 +217,31 @@ def exists(self, ref: SecretRef) -> bool: # -- 読み書き ----------------------------------------------------------- - def load(self, ref: SecretRef) -> Dict[str, str]: + def load_bytes(self, ref: SecretRef) -> bytes: + """復号した **生バイト列** を返す (不在なら空)。 + + 中身を ``KEY=VALUE`` として解釈しないので、コメント・空行・``export`` + 表記を含む原文をそのまま取り出せる。 + """ path = self.path(ref) if not path.is_file(): - return {} + return b'' try: blob = path.read_bytes() except OSError as e: raise SecretStoreError(f"読み込みに失敗しました ({path}): {e}") from e try: - plain = _cipher.decrypt(blob, identities=self.identities()) + return _cipher.decrypt(blob, identities=self.identities()) except _cipher.CipherError as e: raise SecretStoreError( f"{ref.label()}の機密を復号できませんでした ({path}): {e}" ) from e - try: - return EnvFile.parse_bytes(plain) - except UnicodeDecodeError as e: - # 復号は成功したのに中身が UTF-8 でない = 元々 .env ではない - # バイナリを暗号化していた、というケース。PlaintextBackend.load と - # 同じく SecretStoreError へ包み、呼び出し側が扱う例外を 1 種類に保つ。 - raise SecretStoreError( - f"{ref.label()}の機密を復号しましたが、UTF-8 として読めませんでした " - f"({path}): {e}\n" - "KEY=VALUE 形式以外のファイルを暗号化していないか確認してください" - ) from e - def save(self, ref: SecretRef, data: Dict[str, str]) -> Path: + def save_bytes(self, ref: SecretRef, data: bytes) -> Path: + """バイト列を **加工せずそのまま** 暗号化して保存する""" path = self.path(ref) try: - blob = _cipher.encrypt(EnvFile.dump_bytes(data), - recipients=self.recipients()) + blob = _cipher.encrypt(data, recipients=self.recipients()) except _cipher.CipherError as e: raise SecretStoreError( f"{ref.label()}の機密を暗号化できませんでした: {e}" @@ -232,6 +255,27 @@ def save(self, ref: SecretRef, data: Dict[str, str]) -> Path: raise SecretStoreError(f"書き込みに失敗しました ({path}): {e}") from e return path + def load(self, ref: SecretRef) -> Dict[str, str]: + try: + return EnvFile.parse_bytes(self.load_bytes(ref)) + except UnicodeDecodeError as e: + # 復号は成功したのに中身が UTF-8 でない = 元々 .env ではない + # バイナリを暗号化していた、というケース。PlaintextBackend.load と + # 同じく SecretStoreError へ包み、呼び出し側が扱う例外を 1 種類に保つ。 + raise SecretStoreError( + f"{ref.label()}の機密を復号しましたが、UTF-8 として読めませんでした " + f"({self.path(ref)}): {e}\n" + "KEY=VALUE 形式以外のファイルを暗号化していないか確認してください" + ) from e + + def save(self, ref: SecretRef, data: Dict[str, str]) -> Path: + """辞書を ``EnvFile`` の書式へ整形して暗号化する。 + + ``PlaintextBackend.save`` と同じく、この経路を通ると内容が正規化される。 + 原文を保ちたい移行系は :meth:`save_bytes` を使う。 + """ + return self.save_bytes(ref, EnvFile.dump_bytes(data)) + def remove(self, ref: SecretRef) -> bool: path = self.path(ref) if not path.exists(): @@ -305,9 +349,21 @@ def save(self, ref: SecretRef, data: Dict[str, str]) -> Path: まだ何も無い参照は平文に落とす。暗号化へ移すのは ``devbase env encrypt`` の役目であり、``set`` や ``sync`` が暗黙に形式を変えるべきではない。 + + 辞書を経由するため、保存した時点で内容は ``EnvFile`` の書式へ正規化される + (コメント・空行・``export`` 表記は残らない)。原文のまま運びたい場合は + :meth:`save_bytes` を使う。 """ return self.backend_for(ref).save(ref, data) + def load_bytes(self, ref: SecretRef) -> bytes: + """保存形式を問わず、中身を **原文のバイト列のまま** 返す""" + return self.backend_for(ref).load_bytes(ref) + + def save_bytes(self, ref: SecretRef, data: bytes) -> Path: + """既存の保存形式を維持したまま、バイト列を **そのまま** 保存する""" + return self.backend_for(ref).save_bytes(ref, data) + def project_names(self) -> List[str]: """暗号化済みの機密を持つプロジェクト名を返す""" base = self.root / SECRETS_DIRNAME / 'projects' diff --git a/lib/devbase/volume/compose.py b/lib/devbase/volume/compose.py index e4d5cacd..4b8a219f 100644 --- a/lib/devbase/volume/compose.py +++ b/lib/devbase/volume/compose.py @@ -4,12 +4,18 @@ import os import yaml from pathlib import Path -from typing import Any, Dict, Optional +from typing import ( + Any, Dict, Iterable, List, Mapping, Optional, Sequence, Set, +) +from devbase.env import compose_migrate from devbase.errors import DockerError +from devbase.log import get_logger from .manager import get_work_volume_for_index, get_ai_volume_for_index +logger = get_logger(__name__) + # 旧 /home/ubuntu マウントは非推奨のため scale 生成時に除去する _DEPRECATED_TARGET = '/home/ubuntu' @@ -141,8 +147,121 @@ def _load_compose_config(compose_file: Path) -> dict: raise DockerError(f"Failed to parse compose file: {e}") +def _mask_secret_environment( + service: dict, secret_env_names: Sequence[str], +) -> None: + """機密キーだけを「値なしの参照」へ置き換える (それ以外の値は残す)。 + + 以前は ``environment`` を丸ごと落としていたが、それでは元の ``compose.yml`` + が持つ**非機密の固定値や機能フラグ**まで消え、スケールした途端に生成コンテナ + の挙動が変わってしまう。生成ファイルに残してはいけないのは機密の値だけなので、 + ``secret_env_names`` に挙がったキーに限って値を落とし、devbase 自身の環境変数 + から解決させる書き方へ置き換える (plan35 §4.3)。 + + 元の記法は尊重する。map 形式なら値を ``None`` にした map (Compose は ``KEY:`` + を「実行プロセスの環境変数から解決」と解釈する)、list 形式なら裸のキー名を + 並べた list として出力する。 + """ + # 重複を除きつつ、指定された順序は保つ + secrets = list(dict.fromkeys(secret_env_names)) + secret_set = set(secrets) + existing = service.get('environment') + + if existing is None: + # 元から environment が無ければ、機密が無い限り作らない + if secrets: + service['environment'] = list(secrets) + return + + if isinstance(existing, dict): + masked = { + key: (None if key in secret_set else value) + for key, value in existing.items() + } + for name in secrets: + masked.setdefault(name, None) + service['environment'] = masked + return + + if isinstance(existing, list): + masked_list = [] + listed = set() + for item in existing: + if not isinstance(item, str): + masked_list.append(item) + continue + name = item.split('=', 1)[0].strip() + listed.add(name) + # 機密キーは `KEY=value` でも `KEY` でも、値なし参照に揃える + masked_list.append(name if name in secret_set else item) + masked_list.extend(name for name in secrets if name not in listed) + service['environment'] = masked_list + return + + # map / list 以外は Compose が受け付けない書き方。手掛かりを残しつつ、 + # 機密が渡らない事故を避けるため名前の列挙で置き換える。 + logger.warning( + "environment の形式 (%s) を解釈できないため、機密の変数名の列挙で" + "置き換えます", type(existing).__name__) + service['environment'] = list(secrets) + + +class _SecretNames: + """機密の変数名を**由来別**に保持し、参照種別に応じた部分集合を切り出す。 + + 共通機密 (``$DEVBASE_ROOT/.env``) 由来とプロジェクト機密 + (``projects//.env``) 由来を分けて持つのは、サービスごとに「元々 + ``env_file`` で参照していた由来のキーだけ」を列挙するため。全件をまとめて + 渡すと、共通設定だけを読んでいた ``db`` のようなサービスにプロジェクト固有 + のトークンまで届き、元の構成より機密の範囲が広がってしまう。 + + 由来の内訳が分からない場合 (``global_names`` / ``project_names`` が + ``None``) は、全キーが両方の由来を持つものとして扱う。従来どおりの動作へ + 落ちるだけで、渡し先が狭まって起動できなくなる事故は起こさない。 + + 既知の限界: 同じキーが共通機密とプロジェクト機密の**両方**にある場合、 + Compose は値を devbase 自身の環境変数から解決するため、実際に渡る値は + 合成後の 1 つ (プロジェクト側が優先) に決まる。したがって共通側だけを参照 + していたサービスにもプロジェクト側の値が渡る。サービスごとに違う値を渡す + には生成ファイルへ値を書き込むしかなく、それは「生成物に機密の値を残さない」 + という本方式の前提と矛盾するため受け入れる (plan35 §7)。 + """ + + def __init__( + self, + all_names: Sequence[str] = (), + global_names: Optional[Sequence[str]] = None, + project_names: Optional[Sequence[str]] = None, + ) -> None: + split_known = global_names is not None or project_names is not None + globals_ = list(global_names or ()) + projects = list(project_names or ()) + # 重複を除きつつ、呼び出し側が渡した順序は保つ + self.all: List[str] = list(dict.fromkeys( + [*all_names, *globals_, *projects])) + if split_known: + self._by_target = { + compose_migrate.TARGET_GLOBAL: set(globals_), + compose_migrate.TARGET_PROJECT: set(projects), + } + else: + everything = set(self.all) + self._by_target = { + compose_migrate.TARGET_GLOBAL: everything, + compose_migrate.TARGET_PROJECT: everything, + } + + def for_targets(self, targets: Iterable[str]) -> List[str]: + """指定の参照種別に由来するキーだけを、全体と同じ順序で返す""" + allowed: Set[str] = set() + for target in targets: + allowed |= self._by_target.get(target, set()) + return [name for name in self.all if name in allowed] + + def _build_dev_instance( dev_service: dict, dev_service_name: str, index: int, + secret_env_names: Sequence[str] = (), ) -> dict: """Build the service definition for one scaled dev instance (dev-).""" service = copy.deepcopy(dev_service) @@ -152,8 +271,7 @@ def _build_dev_instance( # setdefault keeps an explicit `init: false` if the project set one. service.setdefault('init', True) - # Remove environment section (use env_file instead to avoid exposing secrets) - service.pop('environment', None) + _mask_secret_environment(service, secret_env_names) # Update volume mounts for /persistent/ai and /work ai_volume = get_ai_volume_for_index(index) @@ -167,9 +285,17 @@ def _build_dev_instance( 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, ) -> dict: - """Build the services section: non-dev services + dev-1..dev-N instances.""" + """Build the services section: non-dev services + dev-1..dev-N instances. + + ``secret_services`` は「サービス名 → 元々参照していた機密の種別 + (``TARGET_GLOBAL`` / ``TARGET_PROJECT``)」の対応。 + """ scaled_services = {} + secret_names = secret_names if secret_names is not None else _SecretNames() + receivers = dict(secret_services or {}) # Copy non-dev services (mysql, valkey, etc.) — rewriting any # `depends_on: ` reference to the scaled instances (dev-1..N) so @@ -182,20 +308,134 @@ def _build_scaled_services( # Insert tini as PID 1 so orphaned children are reaped (no zombies). # setdefault keeps an explicit `init: false` if the project set one. copied.setdefault('init', True) + # 元々機密ファイルを env_file で参照していたサービスにだけ機密を渡す。 + # 参照が外れたあと dev だけに渡すと、DB パスワードを読んでいた db の + # ような非 dev サービスが値を受け取れず起動に失敗する。逆に参照を + # 持たないサービスへ注入すると、元の構成に無い変数を勝手に増やす。 + # + # さらに、渡すのは**そのサービスが参照していた由来のキーだけ**に絞る。 + # 共通設定 (${DEVBASE_ROOT}/.env) だけを読んでいたサービスへプロジェクト + # 固有のトークンまで列挙するのは、元の構成に無かった機密を渡すことに + # なり、範囲の拡大にあたる。 + targets = receivers.get(service_name) + if targets: + _mask_secret_environment(copied, secret_names.for_targets(targets)) scaled_services[service_name] = copied - # Generate a service for each instance + # dev サービスは従来どおり全件 (共通 + プロジェクト) を対象にする。 + # devbase 自身が機密を注入する前提のサービスであり、env_file を書いていない + # 構成でも両方の機密を必要とする。 for i in range(1, scale + 1): scaled_services[f'{dev_service_name}-{i}'] = _build_dev_instance( - dev_service, dev_service_name, i, + dev_service, dev_service_name, i, secret_names.all, ) return scaled_services +def _env_file_ref(entry: Any) -> Optional[str]: + """``env_file`` の 1 エントリから参照先の文字列を取り出す (短縮形 / dict 形)""" + if isinstance(entry, dict): + entry = entry.get('path') + return entry if isinstance(entry, str) else None + + +def _resolve_env_file_path(entry: Any, base_dir: Path) -> Optional[Path]: + """``env_file`` の 1 エントリを実パスへ解決する (解釈できなければ None)""" + entry = _env_file_ref(entry) + if entry is None: + return None + expanded = os.path.expandvars(entry) + if '$' in expanded: + # 未定義の変数が残っている = ここでは存在判定できない。触らずに残す。 + return None + path = Path(expanded) + return path if path.is_absolute() else base_dir / path + + +def _drop_missing_env_files(service: dict, base_dir: Path, service_name: str) -> None: + """暗号化移行で消える機密ファイルへの ``env_file`` 参照のうち、実在しないものを落とす。 + + 機密を暗号化すると、それまで参照していた平文ファイルは無くなる。参照を + 残したままだと Docker Compose が起動時に落ちるため、生成する構成からは + 外す。値は環境変数として別途注入されるので失われない。 + + 落とす対象を :func:`compose_migrate.is_secret_entry` が真を返す既知の参照に + **限る**のが要点。実在しない参照を無条件に落とすと、利用者のタイプミスや + 未配置の必須設定まで黙って成功扱いになり、本来 Compose が起動時に知らせて + くれる構成の不備を隠してしまう。機密以外の欠落はそのまま残し、Compose に + エラーを出させる。 + + 移行コマンドが ``compose.yml`` を書き換え済みなら、ここに来る時点で該当 + エントリは無い。手で書いた構成や書き換え前の状態に対する保険として働く。 + """ + entries = service.get('env_file') + if entries is None: + return + if not isinstance(entries, list): + entries = [entries] + + kept = [] + for entry in entries: + ref = _env_file_ref(entry) + resolved = _resolve_env_file_path(entry, base_dir) + if (resolved is not None and not resolved.exists() + and ref is not None and compose_migrate.is_secret_entry(ref)): + logger.info( + "%s: 実在しない機密の env_file 参照を除きました (%s)。" + "機密は環境変数として渡されます", service_name, resolved) + continue + kept.append(entry) + + if kept: + service['env_file'] = kept + else: + service.pop('env_file', None) + + +def _services_receiving_secrets( + compose_file: Path, dev_service_name: str, +) -> Dict[str, Set[str]]: + """機密を渡すべきサービスと、その**参照種別**を決める。 + + 判定は :func:`compose_migrate.services_with_secret_env_file` に任せ、 + **パース済みの YAML ではなく生テキスト**を渡す。移行後の ``compose.yml`` + では機密の ``env_file`` 参照がコメントアウトされ、YAML からは消えている + ため、パース結果だけでは「元々その参照から機密を受け取っていたサービス」 + を復元できない。生テキストなら有効な参照とコメントアウトされた参照の + 両方を拾える。 + + 返すのがサービス名の集合ではなく種別つきの対応なのは、共通設定だけを + 参照していたサービスへプロジェクト固有の機密まで渡さないため。 + + dev サービスは常に両方の種別を持つものとして含める。devbase 自身が機密を + 注入する前提のサービスで、``env_file`` を書いていない構成でも機密は渡す + 必要があるため。 + + 生テキストを読めない場合は dev サービスだけ (全件) にフォールバックする。 + 判定に失敗したことを理由に全サービスへ機密を撒くと、必要のないコンテナに + まで認証情報を渡すことになる。 + """ + both = {compose_migrate.TARGET_GLOBAL, compose_migrate.TARGET_PROJECT} + receivers: Dict[str, Set[str]] = {dev_service_name: set(both)} + try: + text = compose_file.read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError) as e: + logger.warning( + "%s を読めなかったため、機密は %s サービスにのみ渡します: %s", + compose_file, dev_service_name, e) + return receivers + for name, targets in compose_migrate.services_with_secret_env_file(text).items(): + receivers.setdefault(name, set()).update(targets) + return receivers + + def generate_scaled_compose( scale: int, compose_file: Path = None, dev_service_name: str = None, + secret_env_names: Sequence[str] = (), + global_env_names: Optional[Sequence[str]] = None, + project_env_names: Optional[Sequence[str]] = None, ) -> Path: """ Generate scaled docker-compose file with per-instance volumes @@ -204,6 +444,13 @@ def generate_scaled_compose( scale: Number of container instances compose_file: Source compose file path (default: compose.yml) dev_service_name: Name of the development service to scale (default: from DEV_SERVICE_NAME env or 'dev') + secret_env_names: コンテナへ列挙する機密の変数名 (全件) + global_env_names: そのうち共通機密 (``$DEVBASE_ROOT/.env``) 由来のキー + project_env_names: そのうちプロジェクト機密由来のキー + + 非 dev サービスへは、そのサービスが元々 ``env_file`` で参照していた由来の + キーだけを列挙する。由来の内訳が渡されない場合 (両方 ``None``) は全キーを + 両方の由来とみなす。 Returns: Path to generated .docker-compose.scale.yml @@ -217,13 +464,23 @@ def generate_scaled_compose( # Extract dev service (configurable via DEV_SERVICE_NAME) services = config.get('services', {}) + base_dir = compose_file.resolve().parent + for service_name, service_config in services.items(): + if isinstance(service_config, dict): + _drop_missing_env_files(service_config, base_dir, service_name) dev_service = services.get(dev_service_name) if not dev_service: raise DockerError(f"No '{dev_service_name}' service found in compose file") + secret_services = _services_receiving_secrets(compose_file, dev_service_name) + secret_names = _SecretNames( + secret_env_names, global_env_names, project_env_names) + scaled_config = { 'services': _build_scaled_services( services, dev_service, dev_service_name, scale, + secret_names=secret_names, + secret_services=secret_services, ), 'volumes': _build_volumes_section(config, scale), 'networks': _build_networks_section(config), diff --git a/tests/cli/test_project_name_resolution.py b/tests/cli/test_project_name_resolution.py index d8d376d7..f6022b89 100644 --- a/tests/cli/test_project_name_resolution.py +++ b/tests/cli/test_project_name_resolution.py @@ -204,6 +204,44 @@ def test_resolve_clears_caller_only_env_keys(fake_root, monkeypatch): assert os.environ["COMPOSE_PROJECT_NAME"] == "other" +def test_switching_projects_drops_caller_only_secrets(fake_root, monkeypatch): + """切替経路 (`_resolve_project_name` → `_inject_secrets`) で機密が残留しない。 + + codex 指摘の回帰テスト。`cli._load_secret_env` は dispatch 前に現在地 + (呼び出し元) の機密を載せるため、`project up ` の直接起動では切替元 + 固有の機密が os.environ に残り Compose や子プロセスへ引き継がれてしまう。 + 切替後の載せ直しでこれが落ちること、共通の機密は残ることを固定する。 + """ + from devbase.env import runtime + + monkeypatch.setattr(runtime, "_injected_originals", {}) + for k in ("CALLER_TOKEN", "OTHER_TOKEN", "SHARED_SECRET"): + monkeypatch.delenv(k, raising=False) + + # 平文の機密 (移行前と同じ配置) を用意する。鍵が無くても同じ経路を通る。 + (fake_root / ".env").write_text("SHARED_SECRET=common\n") + caller = fake_root / "projects" / "caller" + caller.mkdir() + (caller / ".env").write_text("CALLER_TOKEN=caller_only\n") + other = fake_root / "projects" / "other" + other.mkdir() + (other / ".env").write_text("OTHER_TOKEN=other_only\n") + + # 呼び出し元プロジェクト内で起動した状況 (cli._load_secret_env 相当)。 + monkeypatch.chdir(caller) + monkeypatch.setenv("PWD", str(caller)) + container._inject_secrets(required=False) + assert os.environ["CALLER_TOKEN"] == "caller_only" + + assert container._resolve_project_name("other") is True + container._inject_secrets(required=False) + + # 切替元固有の機密は残らない / 切替先の機密が載る / 共通の機密は残る + assert "CALLER_TOKEN" not in os.environ + assert os.environ["OTHER_TOKEN"] == "other_only" + assert os.environ["SHARED_SECRET"] == "common" + + def test_load_project_env_diverges_from_shell_source(tmp_path, monkeypatch): """shell ``source`` との仕様乖離を固定する回帰テスト (docstring の note 対応)。 diff --git a/tests/cli/test_secret_injection.py b/tests/cli/test_secret_injection.py new file mode 100644 index 00000000..5da67b26 --- /dev/null +++ b/tests/cli/test_secret_injection.py @@ -0,0 +1,60 @@ +"""機密の注入をスキップするコマンドの判定 + +鍵の生成や暗号化・復号は「まだ鍵が無い」「復号できない」状態でこそ実行される。 +グループ (`env`) 単位ではなくサブコマンドまで見ないと、`env keygen` などでも +注入が走ってしまう。 +""" + +from __future__ import annotations + +import pytest + +from devbase import cli + + +@pytest.fixture +def calls(tmp_path, monkeypatch): + """`runtime.inject` の呼び出し回数を数える""" + from devbase.env import runtime + + recorded = [] + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) + monkeypatch.setattr(runtime, 'current_project_name', lambda root: None) + monkeypatch.setattr(runtime, 'inject', + lambda root, project: recorded.append((root, project))) + return recorded + + +@pytest.mark.parametrize('subcommand', ['keygen', 'encrypt', 'decrypt']) +def test_env_key_and_migration_subcommands_skip_injection(calls, subcommand): + cli._load_secret_env('env', subcommand) + assert calls == [] + + +@pytest.mark.parametrize('subcommand', ['list', 'set', 'get', 'edit', 'sync', + 'export', 'import']) +def test_other_env_subcommands_still_inject(calls, subcommand): + cli._load_secret_env('env', subcommand) + assert len(calls) == 1 + + +def test_init_skips_injection_regardless_of_subcommand(calls): + cli._load_secret_env('init', None) + assert calls == [] + + +def test_unrelated_commands_inject(calls): + cli._load_secret_env('project', 'up') + assert len(calls) == 1 + + +def test_env_without_a_subcommand_injects(calls): + """`devbase env` 単体 (ヘルプ表示) はグループ丸ごとの除外にはしない""" + cli._load_secret_env('env', None) + assert len(calls) == 1 + + +def test_injection_is_skipped_before_devbase_root_is_read(monkeypatch): + """DEVBASE_ROOT が無くても判定自体は成立する (例外を出さない)""" + monkeypatch.delenv('DEVBASE_ROOT', raising=False) + cli._load_secret_env('env', 'keygen') diff --git a/tests/cli/test_wrapper_secrets.py b/tests/cli/test_wrapper_secrets.py new file mode 100644 index 00000000..857fd93f --- /dev/null +++ b/tests/cli/test_wrapper_secrets.py @@ -0,0 +1,57 @@ +"""起動ラッパーが機密ファイルを読まないことの回帰テスト + +暗号化の前提は「シェルから読める場所に機密を置かない」こと。ラッパーが +``$DEVBASE_ROOT/.env`` を ``source`` に戻ると、暗号化していても起動のたびに +平文が必要になり、方針そのものが崩れる (plan35 §4.4)。 +""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +WRAPPER = REPO_ROOT / 'bin' / 'devbase' + + +def wrapper_lines(): + return [ + line for line in WRAPPER.read_text(encoding='utf-8').splitlines() + if line.strip() and not line.lstrip().startswith('#') + ] + + +def test_wrapper_does_not_source_the_global_secret_file(): + sourced = [ + line for line in wrapper_lines() + if re.search(r'source\s+"?\$\{?DEVBASE_ROOT\}?/\.env', line) + ] + assert sourced == [], ( + "起動ラッパーが共通の機密ファイルを source しています: " + repr(sourced)) + + +def test_wrapper_sources_the_non_secret_settings(): + sourced = [ + line for line in wrapper_lines() + if re.search(r'source\s+"?\$\{?DEVBASE_ROOT\}?/env"?', line) + ] + assert len(sourced) == 1, sourced + + +def test_compose_build_goes_through_the_secret_injection(): + """`docker compose build` は機密を必要としうるので env exec 経由で呼ぶ""" + lines = wrapper_lines() + direct = [line for line in lines + if 'docker compose build' in line + and 'compose_with_secrets' not in line] + assert direct == [], ("機密注入を経ずに compose build を呼んでいます: " + + repr(direct)) + + wrapped = [line for line in lines if 'compose_with_secrets docker compose build' in line] + assert len(wrapped) == 3, wrapped + + +def test_secret_injection_helper_uses_env_exec(): + text = WRAPPER.read_text(encoding='utf-8') + assert 'compose_with_secrets()' in text + assert 'devbase.cli env exec --' in text diff --git a/tests/commands/test_env_migrate.py b/tests/commands/test_env_migrate.py new file mode 100644 index 00000000..c135ea4e --- /dev/null +++ b/tests/commands/test_env_migrate.py @@ -0,0 +1,861 @@ +"""env encrypt / decrypt: 平文と暗号化構成の往復""" + +from __future__ import annotations + +import logging +import os +import stat +from pathlib import Path + +import pytest + +from devbase.commands import env_migrate +from devbase.env.secret_store import SecretRef, SecretStore + + +GLOBAL = SecretRef.for_global() +WEB = SecretRef.for_project('web') +API = SecretRef.for_project('api') + +COMPOSE = """services: + dev: + image: alpine + env_file: + - ${DEVBASE_ROOT}/.env + - env + - .env +""" + +#: 辞書へ畳むと落ちる要素を全部入れた ``.env`` (コメント・空行・``export`` +#: 表記・クォート・キーの並び順) +RAW_ENV = b"""# devbase \xe3\x81\xae\xe5\x85\xb1\xe9\x80\x9a\xe8\xa8\xad\xe5\xae\x9a +ZZZ_LAST=1 + +ANTHROPIC_API_KEY=sk-1 +export EDITOR=vim +QUOTED="a b c" # \xe6\x9c\xab\xe5\xb0\xbe\xe3\x82\xb3\xe3\x83\xa1\xe3\x83\xb3\xe3\x83\x88 +""" + + +@pytest.fixture +def root(tmp_path, monkeypatch): + from devbase.env import agekeys + + (tmp_path / 'projects' / 'web').mkdir(parents=True) + (tmp_path / 'projects' / 'web' / 'compose.yml').write_text(COMPOSE) + monkeypatch.setenv(agekeys.KEY_FILE_ENV, str(tmp_path / 'age' / 'keys.txt')) + monkeypatch.setenv('PWD', str(tmp_path)) + monkeypatch.chdir(tmp_path) + return tmp_path + + +@pytest.fixture +def with_key(root): + from devbase.env import agekeys + + agekeys.generate_key_file() + return root + + +def seed_plaintext(root): + store = SecretStore(root) + store.plaintext.save(GLOBAL, {'ANTHROPIC_API_KEY': 'sk-1'}) + store.plaintext.save(WEB, {'DB_PASSWORD': 'pw'}) + return store + + +@pytest.fixture +def two_projects(with_key): + """複数対象の途中失敗を見るための構成 (対象は global → api → web の順)""" + (with_key / 'projects' / 'api').mkdir(parents=True) + (with_key / 'projects' / 'api' / 'compose.yml').write_text(COMPOSE) + store = seed_plaintext(with_key) + store.plaintext.save(API, {'API_TOKEN': 'tk'}) + return with_key + + +def compose_texts(root): + return {name: (root / 'projects' / name / 'compose.yml').read_text() + for name in ('api', 'web')} + + +def age_files(root): + return sorted(p.name for p in root.glob('secrets/**/*.age')) + + +def plaintext_files(root): + paths = [root / '.env'] + paths += [root / 'projects' / name / '.env' for name in ('api', 'web')] + return sorted(str(p) for p in paths if p.exists()) + + +# --------------------------------------------------------------------------- +# encrypt +# --------------------------------------------------------------------------- + +def test_encrypt_requires_a_key(root, capsys): + seed_plaintext(root) + + assert env_migrate.cmd_env_encrypt(root, assume_yes=True) == 1 + assert (root / '.env').exists() # 平文はそのまま + + +def test_encrypt_reports_nothing_to_do(with_key, capsys): + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + assert '暗号化する平文の設定はありません' in capsys.readouterr().out + + +def test_encrypt_moves_plaintext_into_the_store(with_key): + seed_plaintext(with_key) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + + store = SecretStore(with_key) + assert store.is_encrypted(GLOBAL) + assert store.is_encrypted(WEB) + assert store.load(GLOBAL) == {'ANTHROPIC_API_KEY': 'sk-1'} + assert store.load(WEB) == {'DB_PASSWORD': 'pw'} + assert not (with_key / '.env').exists() + assert not (with_key / 'projects' / 'web' / '.env').exists() + + +def test_encrypt_keeps_the_plaintext_in_backups(with_key, capsys): + seed_plaintext(with_key) + + env_migrate.cmd_env_encrypt(with_key, assume_yes=True) + + backups = list((with_key / 'backups' / 'env-encrypt').iterdir()) + assert len(backups) == 1 + assert (backups[0] / 'global.env').read_text().strip() == 'ANTHROPIC_API_KEY=sk-1' + assert (backups[0] / 'projects' / 'web.env').exists() + # 消すのは利用者の判断。場所を案内する + assert '退避しました' in capsys.readouterr().out + + +def test_encrypt_never_overwrites_an_existing_backup(with_key, monkeypatch): + """退避先の名前が衝突しても、過去に退避した平文を上書きしない""" + seed_plaintext(with_key) + monkeypatch.setattr(env_migrate, '_timestamp', lambda: '20240101000000') + + existing = with_key / 'backups' / 'env-encrypt' / '20240101000000' + (existing / 'projects').mkdir(parents=True) + (existing / 'global.env').write_text('OLD_GLOBAL=1\n') + (existing / 'projects' / 'web.env').write_text('OLD_WEB=1\n') + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + + # 過去のバックアップは 1 バイトも動いていない + assert (existing / 'global.env').read_text() == 'OLD_GLOBAL=1\n' + assert (existing / 'projects' / 'web.env').read_text() == 'OLD_WEB=1\n' + # 今回のぶんは一意な suffix を付けた別ディレクトリへ入る + fresh = with_key / 'backups' / 'env-encrypt' / '20240101000000-2' + assert 'ANTHROPIC_API_KEY=sk-1' in (fresh / 'global.env').read_text() + assert 'DB_PASSWORD=pw' in (fresh / 'projects' / 'web.env').read_text() + + +def test_encrypt_aborts_when_no_backup_name_is_free(with_key, monkeypatch): + """一意な退避先を作れないなら、平文に触れないまま中止する""" + seed_plaintext(with_key) + monkeypatch.setattr(env_migrate, '_timestamp', lambda: '20240101000000') + monkeypatch.setattr(env_migrate, '_BACKUP_DIR_MAX_ATTEMPTS', 2) + + base = with_key / 'backups' / 'env-encrypt' + for name in ('20240101000000', '20240101000000-2'): + (base / name).mkdir(parents=True) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 1 + + # 平文はそのまま。作りかけの暗号文も退避物も残らない + assert (with_key / '.env').exists() + assert (with_key / 'projects' / 'web' / '.env').exists() + assert age_files(with_key) == [] + assert list(base.glob('**/*.env')) == [] + + +def test_encrypt_rewrites_the_compose_file(with_key): + from devbase.env import compose_migrate + + seed_plaintext(with_key) + + env_migrate.cmd_env_encrypt(with_key, assume_yes=True) + + text = (with_key / 'projects' / 'web' / 'compose.yml').read_text() + assert f'{compose_migrate.DISABLED_MARK}- ${{DEVBASE_ROOT}}/.env' in text + assert f'{compose_migrate.DISABLED_MARK}- .env' in text + assert ' - env\n' in text + + +def test_encrypt_dry_run_changes_nothing(with_key, capsys): + seed_plaintext(with_key) + before = (with_key / 'projects' / 'web' / 'compose.yml').read_text() + + assert env_migrate.cmd_env_encrypt(with_key, dry_run=True) == 0 + + assert (with_key / '.env').exists() + assert not (with_key / 'secrets' / 'global.env.age').exists() + assert (with_key / 'projects' / 'web' / 'compose.yml').read_text() == before + assert '--dry-run' in capsys.readouterr().out + + +def test_encrypt_shows_the_compose_diff(with_key, capsys): + seed_plaintext(with_key) + + env_migrate.cmd_env_encrypt(with_key, dry_run=True) + + out = capsys.readouterr().out + assert 'コンテナ構成の変更' in out + assert '- - ${DEVBASE_ROOT}/.env' in out + + +def test_encrypt_can_target_one_project(with_key): + seed_plaintext(with_key) + + env_migrate.cmd_env_encrypt(with_key, assume_yes=True, projects=['web']) + + store = SecretStore(with_key) + assert store.is_encrypted(WEB) + # 共通設定は対象外なので平文のまま + assert not store.is_encrypted(GLOBAL) + + +def test_encrypt_aborts_without_confirmation(with_key, monkeypatch): + seed_plaintext(with_key) + monkeypatch.setattr(env_migrate, 'safe_input', lambda prompt: 'no') + + assert env_migrate.cmd_env_encrypt(with_key) == 1 + assert (with_key / '.env').exists() + assert not (with_key / 'secrets' / 'global.env.age').exists() + + +def test_encrypt_keeps_plaintext_when_the_result_cannot_be_read_back(with_key, + monkeypatch): + """読み戻せない暗号文のために平文を失わない""" + seed_plaintext(with_key) + + from devbase.env.secret_store import AgeBackend, SecretStoreError + + def broken_load(self, ref): + raise SecretStoreError('復号できません') + + monkeypatch.setattr(AgeBackend, 'load_bytes', broken_load) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 1 + assert (with_key / '.env').exists() + assert not (with_key / 'secrets' / 'global.env.age').exists() + + +def test_encrypt_keeps_plaintext_when_the_result_differs(with_key, monkeypatch): + seed_plaintext(with_key) + + from devbase.env.secret_store import AgeBackend + + monkeypatch.setattr(AgeBackend, 'load_bytes', lambda self, ref: b'WRONG=x\n') + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 1 + assert (with_key / '.env').exists() + + +# --------------------------------------------------------------------------- +# encrypt: 途中で失敗したときの巻き戻し +# --------------------------------------------------------------------------- + +def test_encrypt_rolls_back_when_a_later_target_fails(two_projects, monkeypatch): + """後続対象の失敗で「先行対象だけ移行済み」の中間状態を残さない""" + root = two_projects + before = compose_texts(root) + + from devbase.env.secret_store import AgeBackend, SecretStoreError + + original_save = AgeBackend.save_bytes + + def fail_on_web(self, ref, data): + if ref == WEB: + raise SecretStoreError('暗号化できません') + return original_save(self, ref, data) + + monkeypatch.setattr(AgeBackend, 'save_bytes', fail_on_web) + + assert env_migrate.cmd_env_encrypt(root, assume_yes=True) == 1 + + # 先行対象の平文は元の場所のまま。暗号文も compose.yml も動いていない + assert plaintext_files(root) == sorted([ + str(root / '.env'), + str(root / 'projects' / 'api' / '.env'), + str(root / 'projects' / 'web' / '.env'), + ]) + assert age_files(root) == [] + assert compose_texts(root) == before + assert list(root.glob('backups/**/*.env')) == [] + + +def test_encrypt_rolls_back_when_the_compose_write_fails(two_projects, + monkeypatch): + """構成ファイルを書けなければ、機密の移動ごと巻き戻して失敗を返す""" + root = two_projects + before = compose_texts(root) + + original_write = env_migrate._write_compose + + def fail_on_web_compose(path, text, mode): + # api → web の順に書くので、api だけ書けた状態から巻き戻すことになる + if path.name == 'compose.yml' and path.parent.name == 'web': + raise OSError('読み取り専用ファイルシステムです') + return original_write(path, text, mode) + + monkeypatch.setattr(env_migrate, '_write_compose', fail_on_web_compose) + + assert env_migrate.cmd_env_encrypt(root, assume_yes=True) == 1 + + assert plaintext_files(root) == sorted([ + str(root / '.env'), + str(root / 'projects' / 'api' / '.env'), + str(root / 'projects' / 'web' / '.env'), + ]) + assert age_files(root) == [] + assert list(root.glob('backups/**/*.env')) == [] + # 先に書けてしまった api の compose.yml も元へ戻る + assert compose_texts(root) == before + + +# --------------------------------------------------------------------------- +# 構成ファイルの書き込みは原子的か / 権限を保つか +# --------------------------------------------------------------------------- + +def test_compose_is_not_left_partially_written(two_projects, monkeypatch): + """途中で失敗しても、壊れかけの compose.yml をディスクに残さない""" + root = two_projects + before = compose_texts(root) + + original_replace = os.replace + + def fail_on_web_compose(src, dst, **kwargs): + dst_path = Path(dst) + if dst_path.name == 'compose.yml' and dst_path.parent.name == 'web': + raise OSError('デバイスに空き領域がありません') + return original_replace(src, dst, **kwargs) + + monkeypatch.setattr(os, 'replace', fail_on_web_compose) + + assert env_migrate.cmd_env_encrypt(root, assume_yes=True) == 1 + + # 元の内容がそのまま残っている (truncate された痕跡が無い) + assert compose_texts(root) == before + # 一時ファイルも掃除されている + assert list((root / 'projects' / 'web').glob('.compose.yml.*')) == [] + + +def test_compose_permissions_are_preserved(with_key): + """compose.yml は機密ではない。原子的書き込みの既定 0600 へ落とさない""" + compose = with_key / 'projects' / 'web' / 'compose.yml' + compose.chmod(0o644) + seed_plaintext(with_key) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + + assert stat.S_IMODE(compose.stat().st_mode) == 0o644 + + +# --------------------------------------------------------------------------- +# decrypt +# --------------------------------------------------------------------------- + +def test_decrypt_reports_nothing_to_do(with_key, capsys): + assert env_migrate.cmd_env_decrypt(with_key, assume_yes=True) == 0 + assert '平文へ戻す暗号化済みの設定はありません' in capsys.readouterr().out + + +def test_round_trip_restores_everything(with_key): + seed_plaintext(with_key) + compose = with_key / 'projects' / 'web' / 'compose.yml' + before_compose = compose.read_text() + + env_migrate.cmd_env_encrypt(with_key, assume_yes=True) + assert env_migrate.cmd_env_decrypt(with_key, assume_yes=True) == 0 + + store = SecretStore(with_key) + assert store.mode(GLOBAL) == 'plaintext' + assert store.load(GLOBAL) == {'ANTHROPIC_API_KEY': 'sk-1'} + assert store.load(WEB) == {'DB_PASSWORD': 'pw'} + assert compose.read_text() == before_compose + assert not (with_key / 'secrets' / 'global.env.age').exists() + + +def test_round_trip_preserves_the_original_bytes(with_key): + """コメント・空行・``export`` 表記・クォートまでバイト単位で元へ戻る""" + seed_plaintext(with_key) + env = with_key / '.env' + env.write_bytes(RAW_ENV) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + assert not env.exists() + + assert env_migrate.cmd_env_decrypt(with_key, assume_yes=True) == 0 + assert env.read_bytes() == RAW_ENV + + +def test_env_set_normalizes_the_encrypted_content(with_key): + """暗号化済みでも値の更新は従来どおり効く。 + + 原文が保たれるのは「書き換えるまで」で、``env set`` が走ると平文だけを + 使っていた頃と同じく ``EnvFile`` の書式へ正規化される。 + """ + from devbase.commands import env as env_cmd + + seed_plaintext(with_key) + (with_key / '.env').write_bytes(RAW_ENV) + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + + assert env_cmd.cmd_env_set(with_key, 'ANTHROPIC_API_KEY=sk-2') == 0 + + store = SecretStore(with_key) + assert store.is_encrypted(GLOBAL) + assert store.load(GLOBAL)['ANTHROPIC_API_KEY'] == 'sk-2' + + assert env_migrate.cmd_env_decrypt(with_key, assume_yes=True) == 0 + after = (with_key / '.env').read_bytes() + assert b'ANTHROPIC_API_KEY=sk-2\n' in after + assert '# devbase の共通設定'.encode('utf-8') not in after + + +def test_decrypt_dry_run_changes_nothing(with_key): + seed_plaintext(with_key) + env_migrate.cmd_env_encrypt(with_key, assume_yes=True) + + assert env_migrate.cmd_env_decrypt(with_key, dry_run=True) == 0 + + assert SecretStore(with_key).is_encrypted(GLOBAL) + assert not (with_key / '.env').exists() + + +def test_decrypt_aborts_without_confirmation(with_key, monkeypatch): + seed_plaintext(with_key) + env_migrate.cmd_env_encrypt(with_key, assume_yes=True) + monkeypatch.setattr(env_migrate, 'safe_input', lambda prompt: '') + + assert env_migrate.cmd_env_decrypt(with_key) == 1 + assert SecretStore(with_key).is_encrypted(GLOBAL) + + +# --------------------------------------------------------------------------- +# decrypt: 途中で失敗したときの巻き戻し +# --------------------------------------------------------------------------- + +def test_decrypt_rolls_back_when_a_later_target_fails(two_projects, monkeypatch): + """後続対象を復号できないなら、先行対象の暗号文も消さない""" + root = two_projects + assert env_migrate.cmd_env_encrypt(root, assume_yes=True) == 0 + encrypted_compose = compose_texts(root) + + from devbase.env.secret_store import AgeBackend, SecretStoreError + + original_load = AgeBackend.load_bytes + + def fail_on_web(self, ref): + if ref == WEB: + raise SecretStoreError('復号できません') + return original_load(self, ref) + + monkeypatch.setattr(AgeBackend, 'load_bytes', fail_on_web) + + assert env_migrate.cmd_env_decrypt(root, assume_yes=True) == 1 + + assert age_files(root) == ['api.env.age', 'global.env.age', 'web.env.age'] + assert plaintext_files(root) == [] + assert compose_texts(root) == encrypted_compose + + +def test_decrypt_rolls_back_when_removing_the_ciphertext_fails(two_projects, + monkeypatch): + """削除は最後。失敗しても控えたバイト列から暗号文を復元して元へ戻す""" + root = two_projects + assert env_migrate.cmd_env_encrypt(root, assume_yes=True) == 0 + encrypted_compose = compose_texts(root) + + from devbase.env.secret_store import AgeBackend, SecretStoreError + + original_remove = AgeBackend.remove + + def fail_on_web(self, ref): + if ref == WEB: + raise SecretStoreError('削除できません') + return original_remove(self, ref) + + monkeypatch.setattr(AgeBackend, 'remove', fail_on_web) + + assert env_migrate.cmd_env_decrypt(root, assume_yes=True) == 1 + + assert age_files(root) == ['api.env.age', 'global.env.age', 'web.env.age'] + assert plaintext_files(root) == [] + assert compose_texts(root) == encrypted_compose + # 復元した暗号文はそのまま復号できる + store = SecretStore(root) + assert store.load(GLOBAL) == {'ANTHROPIC_API_KEY': 'sk-1'} + assert store.load(API) == {'API_TOKEN': 'tk'} + + +def test_decrypt_of_one_project_leaves_the_global_reference_disabled(two_projects): + """部分復号で、まだ暗号化されたままの共通設定の参照まで戻さない""" + root = two_projects + assert env_migrate.cmd_env_encrypt(root, assume_yes=True) == 0 + + assert env_migrate.cmd_env_decrypt(root, assume_yes=True, + projects=['web']) == 0 + + store = SecretStore(root) + assert not store.is_encrypted(WEB) + assert store.is_encrypted(GLOBAL) + + from devbase.env import compose_migrate as cm + + web = (root / 'projects' / 'web' / 'compose.yml').read_text() + # プロジェクト側だけが戻り、共通設定の参照は無効のまま + assert ' - .env\n' in web + assert f'{cm.DISABLED_MARK}- ${{DEVBASE_ROOT}}/.env' in web + # 対象外のプロジェクトの構成には手を触れない + api = (root / 'projects' / 'api' / 'compose.yml').read_text() + assert f'{cm.DISABLED_MARK}- .env' in api + + +def test_decrypt_of_everything_after_a_partial_decrypt_restores_the_original( + two_projects): + root = two_projects + before = compose_texts(root) + assert env_migrate.cmd_env_encrypt(root, assume_yes=True) == 0 + + assert env_migrate.cmd_env_decrypt(root, assume_yes=True, + projects=['web']) == 0 + assert env_migrate.cmd_env_decrypt(root, assume_yes=True) == 0 + + assert compose_texts(root) == before + + +def test_inline_env_file_is_warned_about(with_key, caplog): + """自動で書き換えられない記法は黙って見逃さない""" + compose = with_key / 'projects' / 'web' / 'compose.yml' + compose.write_text("""services: + dev: + env_file: [ "${DEVBASE_ROOT}/.env", .env ] +""") + seed_plaintext(with_key) + + with caplog.at_level(logging.WARNING, + logger='devbase.env.compose_migrate'): + env_migrate.cmd_env_encrypt(with_key, dry_run=True) + + messages = [r.getMessage() for r in caplog.records] + assert any('compose.yml:3' in m and 'env_file' in m for m in messages) + + +def test_inline_secret_env_file_aborts_the_migration(with_key, capsys): + """機密を指すインライン記法は警告では済まない (参照が有効なまま残る)""" + compose = with_key / 'projects' / 'web' / 'compose.yml' + compose.write_text("""services: + dev: + env_file: [ "${DEVBASE_ROOT}/.env", .env ] +""") + before = compose.read_text() + seed_plaintext(with_key) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 1 + + # 何も動いていない: 平文も暗号文も構成ファイルもそのまま + assert (with_key / '.env').exists() + assert (with_key / 'projects' / 'web' / '.env').exists() + assert age_files(with_key) == [] + assert list(with_key.glob('backups/**/*.env')) == [] + assert compose.read_text() == before + + +def test_inline_env_file_without_secrets_only_warns(with_key, caplog): + """機密と無関係なインライン記法は移行に影響しない。警告だけで続行する""" + compose = with_key / 'projects' / 'web' / 'compose.yml' + compose.write_text("""services: + dev: + env_file: [ config/app.env ] + worker: + env_file: + - ${DEVBASE_ROOT}/.env + - env +""") + seed_plaintext(with_key) + + with caplog.at_level(logging.WARNING, + logger='devbase.env.compose_migrate'): + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + + from devbase.env import compose_migrate as cm + + messages = [r.getMessage() for r in caplog.records] + assert any('compose.yml:3' in m for m in messages) + # ブロックシーケンスで書かれた機密参照はいつも通り無効化される + assert f'{cm.DISABLED_MARK}- ${{DEVBASE_ROOT}}/.env' in compose.read_text() + + +def test_scalar_env_file_is_migrated_instead_of_aborting(with_key): + """単一文字列で書かれた機密参照は中止せず、行ごと無効化して往復する""" + from devbase.env import compose_migrate as cm + + compose = with_key / 'projects' / 'web' / 'compose.yml' + original = """services: + dev: + image: alpine + env_file: ${DEVBASE_ROOT}/.env + db: + image: alpine + env_file: .env + batch: + image: alpine + env_file: config/app.env +""" + compose.write_text(original) + seed_plaintext(with_key) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + + after = compose.read_text() + assert f' {cm.DISABLED_MARK}env_file: ${{DEVBASE_ROOT}}/.env\n' in after + assert f' {cm.DISABLED_MARK}env_file: .env\n' in after + # 機密と無関係な参照は残す + assert ' env_file: config/app.env\n' in after + + assert env_migrate.cmd_env_decrypt(with_key, assume_yes=True) == 0 + assert compose.read_text() == original + + +def test_scalar_env_file_keeps_crlf_line_endings(with_key): + """CRLF の compose.yml でも単一文字列の往復でバイト単位に戻る""" + compose = with_key / 'projects' / 'web' / 'compose.yml' + original = """services: + dev: + image: alpine + env_file: ${DEVBASE_ROOT}/.env +""".replace('\n', '\r\n').encode('utf-8') + compose.write_bytes(original) + seed_plaintext(with_key) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + assert b'\r\n' in compose.read_bytes() + assert env_migrate.cmd_env_decrypt(with_key, assume_yes=True) == 0 + assert compose.read_bytes() == original + + +def test_scalar_env_file_reaches_the_service_that_read_it(with_key): + """無効化したあとも、そのサービスへ機密を渡す先として拾えている""" + from devbase.env import compose_migrate as cm + + compose = with_key / 'projects' / 'web' / 'compose.yml' + compose.write_text("""services: + dev: + image: alpine + env_file: ${DEVBASE_ROOT}/.env + db: + image: alpine + env_file: .env +""") + seed_plaintext(with_key) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + + assert cm.services_with_secret_env_file(compose.read_text()) == { + 'dev': {cm.TARGET_GLOBAL}, + 'db': {cm.TARGET_PROJECT}, + } + + +def test_multi_line_long_syntax_secret_aborts_the_migration(with_key): + """続きの行を持つ long syntax は行単位で外せない。移行ごと止める""" + compose = with_key / 'projects' / 'web' / 'compose.yml' + compose.write_text("""services: + dev: + env_file: + - path: ${DEVBASE_ROOT}/.env + required: false +""") + before = compose.read_text() + seed_plaintext(with_key) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 1 + + # 何も動いていない: 平文も暗号文も構成ファイルもそのまま + assert (with_key / '.env').exists() + assert age_files(with_key) == [] + assert compose.read_text() == before + + +# --------------------------------------------------------------------------- +# 事後検証: 行ベースの走査が取りこぼしても移行を止める +# --------------------------------------------------------------------------- + +def test_block_scalar_secret_env_file_aborts_the_migration(with_key, caplog): + """`env_file: >-` は先頭行に参照先が無い。行ベースの走査では外せない + + 走査が何も書き換えられず差分ゼロで素通りしかけるところを、書き換え後の + テキストを YAML としてパースする事後検証が捕まえる。 + """ + compose = with_key / 'projects' / 'web' / 'compose.yml' + compose.write_text("""services: + dev: + image: alpine + env_file: >- + .env +""") + before = compose.read_text() + seed_plaintext(with_key) + + with caplog.at_level(logging.ERROR, logger='devbase.commands.env_migrate'): + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 1 + + # 何も動いていない: 平文も暗号文も構成ファイルもそのまま + assert (with_key / '.env').exists() + assert (with_key / 'projects' / 'web' / '.env').exists() + assert age_files(with_key) == [] + assert list(with_key.glob('backups/**/*.env')) == [] + assert compose.read_text() == before + # どのファイルのどのサービスに何が残っているのかまで示す + message = '\n'.join(r.getMessage() for r in caplog.records) + assert 'サービス dev の env_file: .env' in message + assert str(compose) in message + + +def test_aliased_long_syntax_secret_aborts_the_migration(with_key): + """long syntax の dict を別名で参照する形も事後検証が平坦化して見つける""" + compose = with_key / 'projects' / 'web' / 'compose.yml' + compose.write_text("""x-secret: &secret + path: ${DEVBASE_ROOT}/.env + +services: + dev: + image: alpine + env_file: + - *secret +""") + before = compose.read_text() + seed_plaintext(with_key) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 1 + + assert (with_key / '.env').exists() + assert age_files(with_key) == [] + assert compose.read_text() == before + + +def test_broken_yaml_compose_aborts_the_migration(with_key): + """YAML として読めなければ「参照が残っていない」と言い切れない""" + compose = with_key / 'projects' / 'web' / 'compose.yml' + compose.write_text("""services: + dev: + image: alpine + labels: [unclosed +""") + seed_plaintext(with_key) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 1 + + assert (with_key / '.env').exists() + assert (with_key / 'projects' / 'web' / '.env').exists() + assert age_files(with_key) == [] + assert list(with_key.glob('backups/**/*.env')) == [] + + +def test_encrypt_leaves_no_secret_env_file_in_the_parsed_result(with_key): + """全部外せたケースは従来どおり成功し、パースしても機密参照が残らない""" + from devbase.env import compose_migrate as cm + + compose = with_key / 'projects' / 'web' / 'compose.yml' + compose.write_text("""services: + dev: + image: alpine + env_file: + - ${DEVBASE_ROOT}/.env + - env + db: + image: alpine + env_file: .env + batch: + image: alpine + env_file: + - path: .env + - config/app.env +""") + seed_plaintext(with_key) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + + after = compose.read_text() + assert cm.remaining_secret_env_file_refs(after) == [] + # 機密と無関係な参照は残したまま + assert ' - env\n' in after + assert ' - config/app.env\n' in after + + +def test_broken_yaml_does_not_block_the_decrypt(with_key): + """復号は壊れた状態からの復帰手段。事後検証で塞いではいけない""" + compose = with_key / 'projects' / 'web' / 'compose.yml' + seed_plaintext(with_key) + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + + compose.write_text(compose.read_text() + ' labels: [unclosed\n') + + assert env_migrate.cmd_env_decrypt(with_key, assume_yes=True) == 0 + assert (with_key / '.env').exists() + + +def test_crlf_compose_keeps_its_line_endings(with_key): + """CRLF の compose.yml を LF へ潰さない (往復でバイト単位に戻る)""" + compose = with_key / 'projects' / 'web' / 'compose.yml' + original = COMPOSE.replace('\n', '\r\n').encode('utf-8') + compose.write_bytes(original) + seed_plaintext(with_key) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 0 + + encrypted = compose.read_bytes() + # 書き換えた行も含めて LF 単独の行は生まれない + assert b'\n' not in encrypted.replace(b'\r\n', b'') + assert b'# devbase(PLAN35)' in encrypted + + assert env_migrate.cmd_env_decrypt(with_key, assume_yes=True) == 0 + assert compose.read_bytes() == original + + +def test_unreadable_compose_aborts_the_migration(with_key, monkeypatch): + """読めない構成ファイルを飛ばすと、機密だけ退避されて参照が残る""" + seed_plaintext(with_key) + + # 構成ファイルは改行コードを保つために read_bytes で読む + original_read = Path.read_bytes + + def fail_on_compose(self, *args, **kwargs): + if self.name == 'compose.yml': + raise OSError('アクセスが拒否されました') + return original_read(self, *args, **kwargs) + + monkeypatch.setattr(Path, 'read_bytes', fail_on_compose) + + assert env_migrate.cmd_env_encrypt(with_key, assume_yes=True) == 1 + + # 平文は退避されず、暗号文も作られていない + assert (with_key / '.env').exists() + assert (with_key / 'projects' / 'web' / '.env').exists() + assert age_files(with_key) == [] + assert list(with_key.glob('backups/**/*.env')) == [] + + +def test_unreadable_compose_aborts_the_decrypt(two_projects, monkeypatch): + root = two_projects + assert env_migrate.cmd_env_encrypt(root, assume_yes=True) == 0 + + original_read = Path.read_bytes + + def fail_on_compose(self, *args, **kwargs): + if self.name == 'compose.yml' and self.parent.name == 'web': + raise OSError('アクセスが拒否されました') + return original_read(self, *args, **kwargs) + + monkeypatch.setattr(Path, 'read_bytes', fail_on_compose) + + assert env_migrate.cmd_env_decrypt(root, assume_yes=True) == 1 + + # 暗号文はそのまま。平文も書かれていない + assert age_files(root) == ['api.env.age', 'global.env.age', 'web.env.age'] + assert plaintext_files(root) == [] diff --git a/tests/env/test_compose_migrate.py b/tests/env/test_compose_migrate.py new file mode 100644 index 00000000..ac3830e5 --- /dev/null +++ b/tests/env/test_compose_migrate.py @@ -0,0 +1,1136 @@ +"""compose_migrate.py: 構成ファイルの機密参照を外す / 戻す""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import pytest +import yaml + +from devbase.env import compose_migrate as cm + + +BASIC = """services: + + dev: + image: carmo:latest + env_file: + - ${DEVBASE_ROOT}/.env + - env + - .env + command: tail -f /dev/null +""" + + +def test_disable_comments_out_secret_entries_only(): + after, touched = cm.disable(BASIC) + + assert '- env\n' in after + assert f'{cm.DISABLED_MARK}- ${{DEVBASE_ROOT}}/.env' in after + assert f'{cm.DISABLED_MARK}- .env' in after + assert touched == ['${DEVBASE_ROOT}/.env', '.env'] + + +def test_disable_keeps_indentation(): + after, _ = cm.disable(BASIC) + line = next(l for l in after.splitlines() if cm.DISABLED_MARK in l) + assert line.startswith(' #') + + +def test_round_trip_restores_the_original_text(): + disabled, _ = cm.disable(BASIC) + restored, touched = cm.enable(disabled) + + assert restored == BASIC + assert len(touched) == 2 + + +def test_disable_is_idempotent(): + once, _ = cm.disable(BASIC) + twice, touched = cm.disable(once) + + assert twice == once + assert touched == [] + + +def test_only_the_requested_targets_are_disabled(): + after, touched = cm.disable(BASIC, {cm.TARGET_GLOBAL}) + + assert touched == ['${DEVBASE_ROOT}/.env'] + assert ' - .env\n' in after + + +def test_project_only_leaves_the_global_entry(): + after, touched = cm.disable(BASIC, {cm.TARGET_PROJECT}) + + assert touched == ['.env'] + assert ' - ${DEVBASE_ROOT}/.env\n' in after + + +def test_env_file_key_is_disabled_when_no_entry_remains(): + """全エントリを落とすと `env_file:` だけが残り Compose が失敗するため""" + text = """services: + dev: + env_file: + - ${DEVBASE_ROOT}/.env + image: x +""" + after, _ = cm.disable(text) + + assert f'{cm.DISABLED_MARK}env_file:' in after + assert cm.enable(after)[0] == text + + +def test_other_env_files_keep_the_key_active(): + after, _ = cm.disable(BASIC) + assert ' env_file:\n' in after + + +def test_user_comments_are_preserved(): + text = """services: + dev: + env_file: + # 共通設定 + - ${DEVBASE_ROOT}/.env + - env # プロジェクト設定 + image: x +""" + after, touched = cm.disable(text) + + assert ' # 共通設定\n' in after + assert ' - env # プロジェクト設定\n' in after + # コメント行で走査が止まると、その後ろの機密参照が無効化されないまま残る。 + # 「往復で元に戻る」だけでは何も書き換えられなかった場合と区別できない。 + assert touched == ['${DEVBASE_ROOT}/.env'] + assert f'{cm.DISABLED_MARK}- ${{DEVBASE_ROOT}}/.env' in after + assert cm.enable(after)[0] == text + + +def test_quoted_entries_are_recognised(): + text = """services: + dev: + env_file: + - "${DEVBASE_ROOT}/.env" + - env +""" + after, touched = cm.disable(text) + + assert touched == ['${DEVBASE_ROOT}/.env'] + assert cm.enable(after)[0] == text + + +def test_bare_dollar_form_is_recognised(): + text = """services: + dev: + env_file: + - $DEVBASE_ROOT/.env + - env +""" + _, touched = cm.disable(text) + assert touched == ['$DEVBASE_ROOT/.env'] + + +def test_unrelated_env_files_are_left_alone(): + text = """services: + dev: + env_file: + - config/app.env + - env +""" + after, touched = cm.disable(text) + + assert touched == [] + assert after == text + + +def test_multiple_services_are_handled(): + text = """services: + dev: + env_file: + - ${DEVBASE_ROOT}/.env + - env + worker: + env_file: + - ${DEVBASE_ROOT}/.env + - env +""" + after, touched = cm.disable(text) + + assert len(touched) == 2 + assert after.count(cm.DISABLED_MARK) == 2 + assert cm.enable(after)[0] == text + + +# --------------------------------------------------------------------------- +# enable: 種別を絞った復元 (部分復号) +# --------------------------------------------------------------------------- + +def test_enable_restores_only_the_requested_targets(): + """共通設定が暗号化されたままなら、その参照は戻してはいけない""" + disabled, _ = cm.disable(BASIC) + + after, restored = cm.enable(disabled, {cm.TARGET_PROJECT}) + + assert ' - .env\n' in after + assert restored == ['- .env'] + assert f'{cm.DISABLED_MARK}- ${{DEVBASE_ROOT}}/.env' in after + + +def test_enable_is_the_inverse_of_disable_per_target(): + disabled, _ = cm.disable(BASIC) + partial, _ = cm.enable(disabled, {cm.TARGET_PROJECT}) + full, _ = cm.enable(partial, {cm.TARGET_GLOBAL}) + + assert full == BASIC + + +def test_enable_leaves_the_key_disabled_while_entries_stay_disabled(): + """エントリを戻さないのに `env_file:` だけ戻すと Compose が失敗する""" + text = """services: + dev: + env_file: + - ${DEVBASE_ROOT}/.env + image: x +""" + disabled, _ = cm.disable(text) + + after, restored = cm.enable(disabled, {cm.TARGET_PROJECT}) + + assert after == disabled + assert restored == [] + + +def test_enable_restores_the_key_together_with_the_last_entry(): + text = """services: + dev: + env_file: + - ${DEVBASE_ROOT}/.env + image: x +""" + disabled, _ = cm.disable(text) + + after, restored = cm.enable(disabled, {cm.TARGET_GLOBAL}) + + assert after == text + assert restored == ['- ${DEVBASE_ROOT}/.env', 'env_file:'] + + +# --------------------------------------------------------------------------- +# 空行を含むリスト +# --------------------------------------------------------------------------- + +BLANK_IN_LIST = """services: + dev: + env_file: + - ${DEVBASE_ROOT}/.env + + - .env + image: x +""" + + +def test_blank_lines_inside_the_list_do_not_stop_the_scan(): + after, touched = cm.disable(BLANK_IN_LIST) + + assert touched == ['${DEVBASE_ROOT}/.env', '.env'] + assert f'{cm.DISABLED_MARK}- .env' in after + + +def test_blank_lines_do_not_make_the_key_look_used(): + """空行で走査が止まると「有効なエントリ 0 件」と誤判定してキーを落とす""" + text = """services: + dev: + env_file: + - ${DEVBASE_ROOT}/.env + + - env + image: x +""" + after, _ = cm.disable(text) + + assert ' env_file:\n' in after + assert f'{cm.DISABLED_MARK}env_file:' not in after + + +def test_blank_lines_round_trip(): + disabled, _ = cm.disable(BLANK_IN_LIST) + assert cm.enable(disabled)[0] == BLANK_IN_LIST + + +def test_blank_line_does_not_leak_into_the_next_block(): + text = """services: + dev: + env_file: + - .env + + worker: + image: x +""" + after, touched = cm.disable(text) + + assert touched == ['.env'] + assert ' worker:\n' in after + assert cm.enable(after)[0] == text + + +# --------------------------------------------------------------------------- +# コメント行を含むリスト +# --------------------------------------------------------------------------- + +COMMENT_IN_LIST = """services: + dev: + env_file: + - env + # 機密はここから + - ${DEVBASE_ROOT}/.env + - .env + image: x +""" + + +def test_comment_lines_inside_the_list_do_not_stop_the_scan(): + """コメント行で打ち切ると、その後ろの機密参照が有効なまま残ってしまう""" + after, touched = cm.disable(COMMENT_IN_LIST) + + assert touched == ['${DEVBASE_ROOT}/.env', '.env'] + assert f'{cm.DISABLED_MARK}- ${{DEVBASE_ROOT}}/.env' in after + assert f'{cm.DISABLED_MARK}- .env' in after + assert ' # 機密はここから\n' in after + assert ' - env\n' in after + + +def test_comment_lines_round_trip(): + disabled, _ = cm.disable(COMMENT_IN_LIST) + restored, touched = cm.enable(disabled) + + assert restored == COMMENT_IN_LIST + assert len(touched) == 2 + + +def test_comment_lines_do_not_make_the_key_look_used(): + """コメント行の後ろに有効なエントリが残るなら `env_file:` は落とせない""" + text = """services: + dev: + env_file: + - ${DEVBASE_ROOT}/.env + # プロジェクト設定 + - env + image: x +""" + after, _ = cm.disable(text) + + assert ' env_file:\n' in after + assert f'{cm.DISABLED_MARK}env_file:' not in after + + +def test_comments_and_blank_lines_mixed_do_not_stop_the_scan(): + text = """services: + dev: + env_file: + + # 共通設定 + - ${DEVBASE_ROOT}/.env + + # プロジェクト設定 + - .env + image: x +""" + after, touched = cm.disable(text) + + assert touched == ['${DEVBASE_ROOT}/.env', '.env'] + # 有効なエントリが 1 つも残らないのでキー行も無効化される + assert f'{cm.DISABLED_MARK}env_file:' in after + assert cm.enable(after)[0] == text + + +def test_comment_does_not_leak_into_the_next_block(): + """ブロックの外のコメントを読み飛ばしても、次のサービスは壊さない""" + text = """services: + dev: + env_file: + - .env + + # ここから worker + worker: + env_file: + - ${DEVBASE_ROOT}/.env +""" + after, touched = cm.disable(text) + + assert touched == ['.env', '${DEVBASE_ROOT}/.env'] + assert ' # ここから worker\n' in after + assert ' worker:\n' in after + assert cm.enable(after)[0] == text + + +# --------------------------------------------------------------------------- +# 対応していない記法 +# --------------------------------------------------------------------------- + +INLINE = """services: + dev: + env_file: [ "${DEVBASE_ROOT}/.env", .env ] + worker: + env_file: .env + batch: + env_file: + - ${DEVBASE_ROOT}/.env + - env +""" + + +def test_inline_notation_is_reported(): + """フロー記法は挙がる。単一文字列は書き換えられるので挙がらない""" + found = cm.unsupported_env_file_lines(INLINE) + + assert [number for number, _ in found] == [3] + assert found[0][1] == 'env_file: [ "${DEVBASE_ROOT}/.env", .env ]' + + +def test_block_sequence_alone_reports_nothing(): + assert cm.unsupported_env_file_lines(BASIC) == [] + + +def test_env_file_key_with_a_trailing_comment_is_not_reported(): + text = """services: + dev: + env_file: # 共通設定 + - env +""" + assert cm.unsupported_env_file_lines(text) == [] + + +def test_warn_unsupported_env_file_names_the_file_and_line(caplog): + with caplog.at_level(logging.WARNING, logger='devbase.env.compose_migrate'): + cm.warn_unsupported_env_file(INLINE, Path('projects/web/compose.yml')) + + messages = [r.getMessage() for r in caplog.records] + assert len(messages) == 1 + assert 'projects/web/compose.yml:3' in messages[0] + assert 'env_file: [ "${DEVBASE_ROOT}/.env", .env ]' in messages[0] + + +def test_inline_notation_does_not_break_the_block_sequence(): + """対象外の記法が混ざっていても、扱える書き方は従来どおり処理する""" + after, touched = cm.disable(INLINE) + + # フロー記法 (3 行目) は残り、単一文字列とブロックシーケンスは無効化される + assert touched == ['.env', '${DEVBASE_ROOT}/.env'] + assert ' env_file: [ "${DEVBASE_ROOT}/.env", .env ]\n' in after + assert ' - env\n' in after + assert cm.enable(after)[0] == INLINE + + +def test_secret_inline_lines_are_separated_from_harmless_ones(): + """機密を指すインライン記法だけが「移行を止める理由」になる""" + text = """services: + dev: + env_file: config/app.env + worker: + env_file: [ "${DEVBASE_ROOT}/.env", config/app.env ] + batch: + env_file: .env # プロジェクト設定 +""" + # 単一文字列 (3 行目・7 行目) は書き換えられるので挙がらない + assert [n for n, _ in cm.unsupported_env_file_lines(text)] == [5] + # 機密を指すフロー記法だけが移行を止める + assert [n for n, _ in cm.secret_unsupported_env_file_lines(text)] == [5] + + +def test_secret_inline_lines_respect_the_requested_targets(): + """プロジェクトだけを暗号化するなら、共通設定のインライン記法は止めない""" + text = """services: + dev: + env_file: [ "${DEVBASE_ROOT}/.env" ] +""" + assert cm.secret_unsupported_env_file_lines(text, {cm.TARGET_PROJECT}) == [] + assert len(cm.secret_unsupported_env_file_lines(text, {cm.TARGET_GLOBAL})) == 1 + + +def test_disabled_inline_lines_are_not_reported_again(): + """コメントアウト済みの行を再び「止める理由」に数えない""" + text = f"""services: + dev: + {cm.DISABLED_MARK}env_file: .env +""" + assert cm.secret_unsupported_env_file_lines(text) == [] + + +# --------------------------------------------------------------------------- +# 単一文字列の env_file (契約 1.: 1 行で完結するので行ごと無効化できる) +# --------------------------------------------------------------------------- + +SCALAR = """services: + dev: + image: alpine + env_file: ${DEVBASE_ROOT}/.env + worker: + image: alpine + env_file: ".env" + batch: + image: alpine + env_file: config/app.env +""" + + +def test_scalar_env_file_is_disabled_and_restored(): + """単一文字列の機密参照は行ごと無効化し、復元で元のテキストに戻る""" + after, touched = cm.disable(SCALAR) + + assert touched == ['${DEVBASE_ROOT}/.env', '.env'] + assert f' {cm.DISABLED_MARK}env_file: ${{DEVBASE_ROOT}}/.env\n' in after + assert f' {cm.DISABLED_MARK}env_file: ".env"\n' in after + # 機密と無関係な単一文字列は触らない + assert ' env_file: config/app.env\n' in after + # コメントアウトした行は YAML としては消えている + assert 'env_file' not in yaml.safe_load(after)['services']['dev'] + + assert cm.enable(after)[0] == SCALAR + + +def test_scalar_env_file_round_trips_with_crlf(): + """CRLF でも往復でバイト単位に戻る""" + original = SCALAR.replace('\n', '\r\n') + + after, touched = cm.disable(original) + + assert touched == ['${DEVBASE_ROOT}/.env', '.env'] + assert '\n' not in after.replace('\r\n', '') + assert cm.enable(after)[0] == original + + +def test_scalar_env_file_with_a_trailing_comment_round_trips(): + """行末コメントや余分な空白があってもそのまま戻る""" + text = """services: + dev: + env_file: .env # プロジェクト設定 +""" + after, touched = cm.disable(text) + + assert touched == ['.env'] + assert cm.enable(after)[0] == text + + +def test_scalar_env_file_without_secrets_is_untouched(): + """機密を指さない単一文字列は無効化も警告も中止もしない""" + text = """services: + dev: + env_file: config/app.env +""" + after, touched = cm.disable(text) + + assert touched == [] + assert after == text + assert cm.unsupported_env_file_lines(text) == [] + assert cm.secret_unsupported_env_file_lines(text) == [] + + +def test_scalar_env_file_respects_the_requested_targets(): + """一部だけ暗号化するときは、その種別の単一文字列だけを無効化する""" + after, touched = cm.disable(SCALAR, {cm.TARGET_PROJECT}) + + assert touched == ['.env'] + assert ' env_file: ${DEVBASE_ROOT}/.env\n' in after + # 共通設定が暗号化されたままなら、その行は戻さない + restored, names = cm.enable(after, {cm.TARGET_GLOBAL}) + assert names == [] + assert restored == after + assert cm.enable(after, {cm.TARGET_PROJECT})[0] == SCALAR + + +def test_scalar_env_file_is_not_reported_as_unsupported(): + """単一文字列は契約 1. に入ったので、中止の理由にはならない""" + assert cm.unsupported_env_file_lines(SCALAR) == [] + assert cm.secret_unsupported_env_file_lines(SCALAR) == [] + + +def test_scalar_env_file_disable_is_idempotent(): + once, _ = cm.disable(SCALAR) + twice, touched = cm.disable(once) + + assert touched == [] + assert twice == once + + +def test_services_with_secret_env_file_reads_scalar_notation(): + """単一文字列でも「どの種別を参照していたか」を拾う""" + assert cm.services_with_secret_env_file(SCALAR) == { + 'dev': {cm.TARGET_GLOBAL}, + 'worker': {cm.TARGET_PROJECT}, + } + + +def test_services_with_secret_env_file_sees_disabled_scalar_notation(): + """無効化したあとも参照元のサービスを見失わない""" + after, _ = cm.disable(SCALAR) + + assert cm.services_with_secret_env_file(after) == { + 'dev': {cm.TARGET_GLOBAL}, + 'worker': {cm.TARGET_PROJECT}, + } + + +def test_flow_sequence_is_still_unsupported(): + """1 行で安全に判断できない記法は従来どおり中止の対象のまま""" + text = """services: + dev: + env_file: [ .env ] + worker: + env_file: { path: .env } +""" + after, touched = cm.disable(text) + + assert touched == [] + assert after == text + assert [n for n, _ in cm.secret_unsupported_env_file_lines(text)] == [3, 5] + + +def test_block_scalar_env_file_is_still_unsupported(): + """続きの行に値を持つブロックスカラーは単一文字列として扱わない""" + text = """services: + dev: + env_file: >- + .env +""" + after, touched = cm.disable(text) + + assert touched == [] + assert after == text + assert [n for n, _ in cm.unsupported_env_file_lines(text)] == [3] + + +def test_unclosed_quote_env_file_is_still_unsupported(): + """クォートが閉じていない値は 1 行で判断できない。中止側へ回す""" + text = """services: + dev: + env_file: ".env +""" + after, touched = cm.disable(text) + + assert touched == [] + assert after == text + assert [n for n, _ in cm.unsupported_env_file_lines(text)] == [3] + + +# --------------------------------------------------------------------------- +# 機密参照を持つサービスの列挙 (生成側が機密を渡す先を決めるのに使う) +# --------------------------------------------------------------------------- + +MULTI_SERVICE = """services: + + dev: + image: alpine + env_file: + - ${DEVBASE_ROOT}/.env + - env + volumes: + - x:/work + db: + image: mysql + env_file: + - .env + cache: + image: redis + env_file: + - config/app.env + worker: + env_file: [ ".env" ] +volumes: + x: {} +networks: + net: + driver: bridge +""" + + +def test_services_with_secret_env_file_lists_only_the_referencing_ones(): + """参照していたサービスと、その参照種別 (共通 / プロジェクト) を返す""" + assert cm.services_with_secret_env_file(MULTI_SERVICE) == { + 'dev': {cm.TARGET_GLOBAL}, + 'db': {cm.TARGET_PROJECT}, + 'worker': {cm.TARGET_PROJECT}, + } + + +def test_services_with_secret_env_file_reports_both_targets(): + """両方を参照するサービスは両方の種別を持つ""" + text = """services: + dev: + env_file: + - ${DEVBASE_ROOT}/.env + - env + - .env +""" + assert cm.services_with_secret_env_file(text) == { + 'dev': {cm.TARGET_GLOBAL, cm.TARGET_PROJECT}} + + +def test_services_with_secret_env_file_sees_disabled_entries(): + """移行後は参照がコメントアウトされる。種別まで含めて同じ結果を返す必要がある""" + disabled, _ = cm.disable(MULTI_SERVICE) + + assert cm.services_with_secret_env_file(disabled) == { + 'dev': {cm.TARGET_GLOBAL}, + 'db': {cm.TARGET_PROJECT}, + 'worker': {cm.TARGET_PROJECT}, + } + + +def test_services_with_secret_env_file_ignores_other_sections(): + """`volumes:` などの `- .env` らしき行をサービス扱いしない""" + text = """services: + dev: + volumes: + - ./.env:/etc/x +volumes: + data: {} +""" + assert cm.services_with_secret_env_file(text) == {} + + +def test_services_with_secret_env_file_sees_past_comment_lines(): + """コメント行で走査が止まると、その後ろの参照を持つサービスを取りこぼす""" + text = """services: + dev: + env_file: + - env + # 機密はここから + - ${DEVBASE_ROOT}/.env + + # プロジェクト設定 + - .env + # ここから db + db: + env_file: + # プロジェクト設定 + - .env +""" + assert cm.services_with_secret_env_file(text) == { + 'dev': {cm.TARGET_GLOBAL, cm.TARGET_PROJECT}, + 'db': {cm.TARGET_PROJECT}, + } + + +def test_services_with_secret_env_file_sees_past_comments_after_disable(): + """移行後も同じ結果でなければ、機密が渡らないまま起動して失敗する""" + text = """services: + db: + env_file: + # プロジェクト設定 + - .env + - env +""" + disabled, touched = cm.disable(text) + + assert touched == ['.env'] + assert cm.services_with_secret_env_file(disabled) == { + 'db': {cm.TARGET_PROJECT}} + + +def test_services_with_secret_env_file_respects_targets(): + assert cm.services_with_secret_env_file( + MULTI_SERVICE, {cm.TARGET_GLOBAL}) == {'dev': {cm.TARGET_GLOBAL}} + assert cm.services_with_secret_env_file( + MULTI_SERVICE, {cm.TARGET_PROJECT}) == { + 'db': {cm.TARGET_PROJECT}, 'worker': {cm.TARGET_PROJECT}} + + +# --------------------------------------------------------------------------- +# long syntax (`- path: .env`) +# --------------------------------------------------------------------------- + +LONG_SYNTAX = """services: + dev: + env_file: + - path: ${DEVBASE_ROOT}/.env + - path: env + - .env +""" + + +def test_single_line_long_syntax_is_disabled_and_restored(): + """1 行で閉じている long syntax は通常のエントリと同じように扱える""" + after, touched = cm.disable(LONG_SYNTAX) + + assert touched == ['${DEVBASE_ROOT}/.env', '.env'] + assert f'{cm.DISABLED_MARK}- path: ${{DEVBASE_ROOT}}/.env' in after + # 機密と無関係な long syntax は触らない + assert ' - path: env\n' in after + assert cm.enable(after)[0] == LONG_SYNTAX + + +def test_single_line_long_syntax_is_not_reported_as_unsupported(): + assert cm.unsupported_env_file_lines(LONG_SYNTAX) == [] + + +def test_quoted_long_syntax_is_recognised(): + text = """services: + dev: + env_file: + - "path": ".env" # プロジェクト設定 + - env +""" + after, touched = cm.disable(text) + + assert touched == ['.env'] + assert cm.enable(after)[0] == text + + +MULTI_LINE_LONG_SYNTAX = """services: + dev: + env_file: + - path: .env + required: false + - env +""" + + +def test_multi_line_long_syntax_is_reported_and_blocks_the_migration(): + """`required: false` が続く形は行単位で無効化できない (契約 2.)""" + assert cm.unsupported_env_file_lines(MULTI_LINE_LONG_SYNTAX) == [ + (4, '- path: .env')] + assert [n for n, _ in + cm.secret_unsupported_env_file_lines(MULTI_LINE_LONG_SYNTAX)] == [4] + + +def test_multi_line_long_syntax_is_left_untouched(): + """行だけ落とすと `required: false` が宙に浮いて YAML が壊れる""" + after, touched = cm.disable(MULTI_LINE_LONG_SYNTAX) + + assert touched == [] + assert after == MULTI_LINE_LONG_SYNTAX + + +def test_entries_after_a_multi_line_entry_are_still_scanned(): + """続きの行で走査を打ち切ると、後ろの機密参照が有効なまま残る""" + text = """services: + dev: + env_file: + - path: config/app.env + required: false + - ${DEVBASE_ROOT}/.env +""" + after, touched = cm.disable(text) + + assert touched == ['${DEVBASE_ROOT}/.env'] + assert cm.enable(after)[0] == text + # 機密と無関係な long syntax は警告だけで、移行は止めない + assert [n for n, _ in cm.unsupported_env_file_lines(text)] == [4] + assert cm.secret_unsupported_env_file_lines(text) == [] + + +def test_multi_line_entry_without_a_path_on_the_dash_line_is_still_seen(): + """`-` の行に参照が現れない書き方でも機密を見落とさない""" + text = """services: + dev: + env_file: + - + path: .env + required: false +""" + assert len(cm.secret_unsupported_env_file_lines(text)) == 1 + assert cm.disable(text)[0] == text + + +def test_flow_mapping_entry_blocks_the_migration(): + """1 行に複数の指定が同居するフロー記法は書き換えの対象外 (契約 2.)""" + text = """services: + dev: + env_file: + - { path: .env, required: false } +""" + assert cm.unsupported_env_file_lines(text) == [ + (4, '- { path: .env, required: false }')] + assert len(cm.secret_unsupported_env_file_lines(text)) == 1 + assert cm.disable(text)[0] == text + + +def test_env_file_that_is_not_a_sequence_is_not_passed_silently(): + """シーケンスでない値 (Compose としては不正) も黙って通さない""" + text = """services: + dev: + env_file: + path: .env + required: false +""" + assert [n for n, _ in cm.unsupported_env_file_lines(text)] == [4] + assert len(cm.secret_unsupported_env_file_lines(text)) == 1 + assert cm.disable(text)[0] == text + + +def test_inline_flow_mapping_is_seen_as_a_secret_reference(): + text = """services: + dev: + env_file: [ { path: .env } ] +""" + assert len(cm.secret_unsupported_env_file_lines(text)) == 1 + + +def test_services_with_secret_env_file_reads_long_syntax(): + text = """services: + db: + env_file: + - path: .env + cache: + env_file: + - path: ${DEVBASE_ROOT}/.env + required: false + none: + env_file: + - path: config/app.env +""" + assert cm.services_with_secret_env_file(text) == { + 'db': {cm.TARGET_PROJECT}, + 'cache': {cm.TARGET_GLOBAL}, + } + + +# --------------------------------------------------------------------------- +# クォートされたサービス名 +# --------------------------------------------------------------------------- + +QUOTED_SERVICES = """services: + "db": + image: mysql + env_file: + - .env + 'cache': + image: redis + env_file: + - ${DEVBASE_ROOT}/.env +""" + + +def test_quoted_service_names_match_the_parsed_ones(): + """PyYAML は `"db":` を `db` と読む。引用符込みで記録すると照合できない""" + parsed = set(yaml.safe_load(QUOTED_SERVICES)['services']) + found = cm.services_with_secret_env_file(QUOTED_SERVICES) + + assert set(found) <= parsed + assert found == { + 'db': {cm.TARGET_PROJECT}, + 'cache': {cm.TARGET_GLOBAL}, + } + + +def test_quoted_service_names_survive_the_migration(): + """移行でコメントアウトされたあとも同じサービス名で拾えること""" + disabled, _ = cm.disable(QUOTED_SERVICES) + + assert cm.services_with_secret_env_file(disabled) == { + 'db': {cm.TARGET_PROJECT}, + 'cache': {cm.TARGET_GLOBAL}, + } + + +# --------------------------------------------------------------------------- +# 改行コード (CRLF / 混在) +# --------------------------------------------------------------------------- + +def test_crlf_round_trip_is_byte_identical(): + """行末を LF へ潰すと、往復しても元の compose.yml に戻らない""" + text = BASIC.replace('\n', '\r\n') + + disabled, touched = cm.disable(text) + + assert touched == ['${DEVBASE_ROOT}/.env', '.env'] + assert f'{cm.DISABLED_MARK}- .env\r\n' in disabled + # LF 単独の行が紛れ込んでいない + assert '\n' not in disabled.replace('\r\n', '') + assert cm.enable(disabled)[0] == text + + +def test_crlf_key_line_round_trip(): + """キー行ごと無効化する場合も行末を保つ""" + text = ('services:\r\n dev:\r\n env_file:\r\n' + ' - ${DEVBASE_ROOT}/.env\r\n image: x\r\n') + + disabled, _ = cm.disable(text) + + assert f'{cm.DISABLED_MARK}env_file:\r\n' in disabled + assert cm.enable(disabled)[0] == text + + +def test_mixed_line_endings_are_preserved(): + """混在していても、書き換えた行の行末だけをそのまま引き継ぐ""" + text = ('services:\n dev:\r\n env_file:\n' + ' - ${DEVBASE_ROOT}/.env\r\n - .env\n - env\r\n') + + disabled, touched = cm.disable(text) + + assert touched == ['${DEVBASE_ROOT}/.env', '.env'] + assert f'{cm.DISABLED_MARK}- ${{DEVBASE_ROOT}}/.env\r\n' in disabled + assert f'{cm.DISABLED_MARK}- .env\n' in disabled + assert cm.enable(disabled)[0] == text + + +def test_a_file_without_a_trailing_newline_round_trips(): + text = 'services:\n dev:\n env_file:\n - .env' + + disabled, touched = cm.disable(text) + + assert touched == ['.env'] + assert not disabled.endswith('\n') + assert cm.enable(disabled)[0] == text + + +# --------------------------------------------------------------------------- +# 末尾スペース / 行末コメントを伴うエントリ +# --------------------------------------------------------------------------- + +def test_entries_with_trailing_comments_and_spaces_are_disabled(): + """`- ${DEVBASE_ROOT}/.env # 共通設定` のような行も取りこぼさない""" + text = """services: + dev: + env_file: + - ${DEVBASE_ROOT}/.env # 共通設定 + - ".env" # プロジェクト設定 + - env +""" + after, touched = cm.disable(text) + + assert touched == ['${DEVBASE_ROOT}/.env', '.env'] + assert f'{cm.DISABLED_MARK}- ${{DEVBASE_ROOT}}/.env # 共通設定' in after + # 行末コメントごと元の姿へ戻る + assert cm.enable(after)[0] == text + + +def test_services_with_secret_env_file_handles_trailing_comments(): + text = """services: + db: + env_file: + - .env # プロジェクト設定 +""" + assert cm.services_with_secret_env_file(text) == { + 'db': {cm.TARGET_PROJECT}} + + +# --------------------------------------------------------------------------- +# 事後検証: 書き換え後のテキストを YAML としてパースして確かめる +# --------------------------------------------------------------------------- + +def test_remaining_refs_is_empty_after_a_successful_disable(): + """行ベースの走査で全部外せたケースは、事後検証も素通りする""" + after, _ = cm.disable(BASIC) + + assert cm.remaining_secret_env_file_refs(after) == [] + + +def test_remaining_refs_ignores_files_without_secrets(): + text = """services: + dev: + env_file: + - config/app.env +""" + assert cm.remaining_secret_env_file_refs(text) == [] + + +def test_remaining_refs_finds_a_block_scalar_the_line_scan_misses(): + """`env_file: >-` は先頭行に参照先が無い。行ベースでは取りこぼす""" + text = """services: + dev: + env_file: >- + .env +""" + # 行ベースの走査は何も書き換えられていない (取りこぼしている) + assert cm.disable(text)[1] == [] + assert cm.remaining_secret_env_file_refs(text) == [('dev', '.env')] + + +def test_remaining_refs_flattens_long_syntax_dicts(): + text = """services: + dev: + env_file: + - path: ${DEVBASE_ROOT}/.env + required: false + - path: config/app.env +""" + assert cm.remaining_secret_env_file_refs(text) == [ + ('dev', '${DEVBASE_ROOT}/.env')] + + +def test_remaining_refs_accepts_a_plain_string_value(): + text = """services: + db: + env_file: .env +""" + assert cm.remaining_secret_env_file_refs(text) == [('db', '.env')] + + +def test_remaining_refs_reports_every_service(): + text = """services: + dev: + env_file: ${DEVBASE_ROOT}/.env + db: + env_file: + - .env +""" + assert cm.remaining_secret_env_file_refs(text) == [ + ('dev', '${DEVBASE_ROOT}/.env'), ('db', '.env')] + + +def test_remaining_refs_honours_the_target_filter(): + """復号しない種別の参照は残っていて当然。検証の対象から外す""" + text = """services: + dev: + env_file: + - ${DEVBASE_ROOT}/.env + - .env +""" + assert cm.remaining_secret_env_file_refs(text, [cm.TARGET_PROJECT]) == [ + ('dev', '.env')] + + +def test_remaining_refs_does_not_see_disabled_lines(): + """無効化した行は YAML のコメント = パーサからは見えない""" + after, _ = cm.disable(BASIC) + + assert cm.DISABLED_MARK in after + assert cm.remaining_secret_env_file_refs(after) == [] + + +def test_remaining_refs_tolerates_files_without_services(): + assert cm.remaining_secret_env_file_refs('') == [] + assert cm.remaining_secret_env_file_refs('volumes:\n data:\n') == [] + assert cm.remaining_secret_env_file_refs('services:\n') == [] + + +def test_remaining_refs_ignores_non_string_entries(): + """Compose としては不正な値。機密参照ではないので検証は素通りさせる""" + text = """services: + dev: + env_file: + - 123 + - [] +""" + assert cm.remaining_secret_env_file_refs(text) == [] + + +def test_remaining_refs_raises_on_broken_yaml(): + """検証できない = 参照が無いと言い切れない。黙って通してはいけない""" + with pytest.raises(cm.ComposeParseError): + cm.remaining_secret_env_file_refs('services:\n dev:\n - [oops\n') + + +def test_find_secret_entries_does_not_modify(): + found = cm.find_secret_entries(BASIC) + assert found == ['${DEVBASE_ROOT}/.env', '.env'] + + +def test_diff_mentions_both_sides(): + after, _ = cm.disable(BASIC) + patch = cm.diff(BASIC, after, Path('compose.yml')) + + assert '(現在)' in patch and '(変更後)' in patch + assert '- - ${DEVBASE_ROOT}/.env' in patch + + +def test_compose_files_lists_only_existing(tmp_path): + (tmp_path / 'projects' / 'web').mkdir(parents=True) + (tmp_path / 'projects' / 'web' / 'compose.yml').write_text(BASIC) + (tmp_path / 'projects' / 'api').mkdir() + + found = cm.compose_files(tmp_path, ['web', 'api', 'missing']) + + assert found == [tmp_path / 'projects' / 'web' / 'compose.yml'] diff --git a/tests/env/test_runtime.py b/tests/env/test_runtime.py new file mode 100644 index 00000000..7dffd530 --- /dev/null +++ b/tests/env/test_runtime.py @@ -0,0 +1,331 @@ +"""runtime.py: 機密の合成とコンテナへ渡す変数名""" + +from __future__ import annotations + +import os + +import pyrage +import pytest + +from devbase.env import runtime +from devbase.env.secret_store import SecretRef, SecretStore + + +@pytest.fixture +def root(tmp_path): + (tmp_path / 'projects' / 'web').mkdir(parents=True) + return tmp_path + + +@pytest.fixture +def store(root, tmp_path): + identity = pyrage.x25519.Identity.generate() + key = tmp_path / 'id.key' + key.write_text(str(identity)) + return SecretStore(root, recipients=[str(identity.to_public())], + identities=[str(key)]) + + +@pytest.fixture(autouse=True) +def _isolate_injection_state(monkeypatch): + """注入記録 (モジュールレベル) をテストごとに独立させる""" + monkeypatch.setattr(runtime, '_injected_originals', {}) + + +GLOBAL = SecretRef.for_global() +WEB = SecretRef.for_project('web') +API = SecretRef.for_project('api') + + +# --------------------------------------------------------------------------- +# 重ね順 +# --------------------------------------------------------------------------- + +def test_global_secrets_are_listed_for_the_container(root, store): + store.age.save(GLOBAL, {'ANTHROPIC_API_KEY': 'sk-1'}) + + resolved = runtime.resolve(root, None, store=store) + + assert resolved.values == {'ANTHROPIC_API_KEY': 'sk-1'} + assert resolved.names == ['ANTHROPIC_API_KEY'] + + +def test_project_secrets_override_global(root, store): + store.age.save(GLOBAL, {'TOKEN': 'global', 'ONLY_GLOBAL': 'g'}) + store.age.save(WEB, {'TOKEN': 'project'}) + + resolved = runtime.resolve(root, 'web', store=store) + + assert resolved.values['TOKEN'] == 'project' + assert resolved.values['ONLY_GLOBAL'] == 'g' + assert sorted(resolved.names) == ['ONLY_GLOBAL', 'TOKEN'] + + +def test_names_are_kept_per_origin(root, store): + """由来ごとに分けて持つ (構成生成側がサービスごとに絞り込むため)""" + store.age.save(GLOBAL, {'TOKEN': 'global', 'ONLY_GLOBAL': 'g'}) + store.age.save(WEB, {'TOKEN': 'project', 'ONLY_PROJECT': 'p'}) + + resolved = runtime.resolve(root, 'web', store=store) + + assert sorted(resolved.global_names) == ['ONLY_GLOBAL', 'TOKEN'] + assert sorted(resolved.project_names) == ['ONLY_PROJECT', 'TOKEN'] + # 両方にあるキーは全体としては 1 件に畳む + assert sorted(resolved.names) == ['ONLY_GLOBAL', 'ONLY_PROJECT', 'TOKEN'] + + +def test_no_secrets_is_falsy(root, store): + resolved = runtime.resolve(root, None, store=store) + + assert not resolved + assert resolved.names == [] + + +def test_project_env_overrides_global_for_the_same_key(root, store, monkeypatch): + """非機密設定が共通設定を上書きする従来の関係を保つ""" + (root / 'projects' / 'web' / 'env').write_text('AWS_DEFAULT_REGION=us-east-1\n') + monkeypatch.setenv('AWS_DEFAULT_REGION', 'us-east-1') + store.age.save(GLOBAL, {'AWS_DEFAULT_REGION': 'ap-northeast-1'}) + + resolved = runtime.resolve(root, 'web', store=store) + + assert resolved.values['AWS_DEFAULT_REGION'] == 'us-east-1' + + +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') + store.age.save(GLOBAL, {'TOKEN': 't'}) + + resolved = runtime.resolve(root, 'web', store=store) + + assert resolved.names == ['TOKEN'] + assert 'GIT_REPO' 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'}) + + resolved = runtime.resolve(root, 'web', store=store) + + assert resolved.values['WORK_DIR'] == '/work/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'}) + + resolved = runtime.resolve(root, 'web', store=store) + + assert resolved.values['WORK_DIR'] == '/work/global' + + +def test_resolve_without_any_secrets_is_empty(root, store): + resolved = runtime.resolve(root, 'web', store=store) + assert resolved.values == {} + assert resolved.names == [] + assert not resolved + + +def test_plaintext_secrets_are_resolved_too(root, store): + """移行前 (平文のまま) でも同じ経路で読める""" + store.plaintext.save(GLOBAL, {'TOKEN': 'plain'}) + + resolved = runtime.resolve(root, None, store=store) + + assert resolved.values == {'TOKEN': 'plain'} + + +# --------------------------------------------------------------------------- +# 注入 +# --------------------------------------------------------------------------- + +def test_inject_puts_values_into_the_given_environ(root, store): + store.age.save(GLOBAL, {'TOKEN': 'sk-1'}) + environ = {} + + resolved = runtime.inject(root, None, environ=environ, store=store) + + assert environ == {'TOKEN': 'sk-1'} + assert resolved.names == ['TOKEN'] + + +# --------------------------------------------------------------------------- +# 注入の解除 (プロジェクト切替時の残留対策) +# --------------------------------------------------------------------------- + +def test_switching_projects_drops_the_source_only_secret(root, store): + """切替元にしか無い機密は、切替先の機密を載せ直すと消える。 + + 単に上書きするだけでは、切替先に同名キーが無い機密が残ってしまう。 + """ + (root / 'projects' / 'api').mkdir() + store.age.save(GLOBAL, {'SHARED': 'common'}) + store.age.save(WEB, {'WEB_ONLY': 'w'}) + store.age.save(API, {'API_ONLY': 'a'}) + environ = {} + + runtime.inject(root, 'web', environ=environ, store=store) + assert environ['WEB_ONLY'] == 'w' + + runtime.clear_injected(environ) + runtime.inject(root, 'api', environ=environ, store=store) + + # 切替元固有の機密は残らない + assert 'WEB_ONLY' not in environ + assert environ['API_ONLY'] == 'a' + # 共通の機密は切替後も残る + assert environ['SHARED'] == 'common' + + +def test_clear_injected_restores_the_users_own_value(root, store): + """利用者がシェルで設定していた同名の変数は消さず元の値へ戻す""" + store.age.save(GLOBAL, {'TOKEN': 'from-secret'}) + environ = {'TOKEN': 'from-shell', 'PATH': '/bin'} + + runtime.inject(root, None, environ=environ, store=store) + assert environ['TOKEN'] == 'from-secret' + + cleared = runtime.clear_injected(environ) + + assert environ['TOKEN'] == 'from-shell' + assert environ['PATH'] == '/bin' + assert cleared == ['TOKEN'] + + +def test_clear_injected_removes_keys_that_did_not_exist(root, store): + store.age.save(GLOBAL, {'TOKEN': 'from-secret'}) + environ = {} + + runtime.inject(root, None, environ=environ, store=store) + runtime.clear_injected(environ) + + assert environ == {} + + +def test_repeated_injection_keeps_the_original_value(root, store): + """載せ直しても記録するのは「最初に載せる前の値」""" + store.age.save(GLOBAL, {'TOKEN': 'from-secret'}) + environ = {'TOKEN': 'from-shell'} + + runtime.inject(root, None, environ=environ, store=store) + runtime.inject(root, None, environ=environ, store=store) + runtime.clear_injected(environ) + + assert environ['TOKEN'] == 'from-shell' + + +def test_clear_injected_without_injection_is_noop(root): + environ = {'TOKEN': 'from-shell'} + + assert runtime.clear_injected(environ) == [] + assert environ == {'TOKEN': 'from-shell'} + + +def test_clear_injected_only_touches_the_given_mapping(root, store): + """履歴は注入先ごとに持つ (別のマッピングを巻き込まない) + + 履歴が全体で 1 つしか無いと、A へ注入した記録で B を「復元」してしまい、 + B の値が壊れるうえ A には機密が残る。 + """ + store.age.save(GLOBAL, {'TOKEN': 'from-secret'}) + a = {'TOKEN': 'a-shell'} + b = {'TOKEN': 'b-shell'} + + runtime.inject(root, None, environ=a, store=store) + runtime.inject(root, None, environ=b, store=store) + + assert runtime.clear_injected(a) == ['TOKEN'] + + # A だけが元へ戻り、B は注入したままで壊れない + assert a == {'TOKEN': 'a-shell'} + assert b == {'TOKEN': 'from-secret'} + + # B の履歴は残っているので、後から解除すれば B も元へ戻る + assert runtime.clear_injected(b) == ['TOKEN'] + assert b == {'TOKEN': 'b-shell'} + + +def test_clearing_one_mapping_keeps_secrets_out_of_the_other(root, store): + """A の解除が B の機密を消し残さない (逆に A には機密を残さない)""" + store.age.save(GLOBAL, {'ONLY_SECRET': 's'}) + a = {} + b = {} + + runtime.inject(root, None, environ=a, store=store) + runtime.inject(root, None, environ=b, store=store) + runtime.clear_injected(b) + + assert b == {} + assert a == {'ONLY_SECRET': 's'} + + runtime.clear_injected(a) + assert a == {} + + +def test_inject_and_clear_default_to_os_environ(root, store, monkeypatch): + """既定の対象は従来どおり os.environ""" + monkeypatch.delenv('TOKEN', raising=False) + store.age.save(GLOBAL, {'TOKEN': 'from-secret'}) + other = {'TOKEN': 'other'} + + runtime.inject(root, None, store=store) + assert os.environ['TOKEN'] == 'from-secret' + + # 別マッピングへの注入は os.environ の履歴に混ざらない + runtime.inject(root, None, environ=other, store=store) + + assert runtime.clear_injected() == ['TOKEN'] + assert 'TOKEN' not in os.environ + assert other == {'TOKEN': 'from-secret'} + + +def test_child_env_does_not_touch_os_environ(root, store, monkeypatch): + monkeypatch.delenv('TOKEN', raising=False) + store.age.save(GLOBAL, {'TOKEN': 'sk-1'}) + + env = runtime.child_env(root, None, store=store) + + assert env['TOKEN'] == 'sk-1' + assert 'TOKEN' not in os.environ + + +def test_child_env_keeps_the_existing_environment(root, store): + store.age.save(GLOBAL, {'TOKEN': 'sk-1'}) + + env = runtime.child_env(root, None, base={'PATH': '/bin'}, store=store) + + assert env['PATH'] == '/bin' + assert env['TOKEN'] == 'sk-1' + + +# --------------------------------------------------------------------------- +# プロジェクトの特定 +# --------------------------------------------------------------------------- + +def test_current_project_name_from_a_subdirectory(root): + sub = root / 'projects' / 'web' / 'src' + sub.mkdir() + assert runtime.current_project_name(root, sub) == 'web' + + +def test_current_project_name_outside_projects(root): + assert runtime.current_project_name(root, root) is None + + +def test_current_project_name_rejects_paths_escaping_projects(root): + escaped = root / 'projects' / 'web' / '..' / '..' / 'outside' + assert runtime.current_project_name(root, escaped) is None + + +def test_current_project_name_follows_a_symlinked_project(root, tmp_path): + target = tmp_path / 'linked-target' + target.mkdir() + (root / 'projects' / 'linked').symlink_to(target) + + assert runtime.current_project_name(root, root / 'projects' / 'linked') == 'linked' diff --git a/tests/env/test_secret_store.py b/tests/env/test_secret_store.py index fd13c86d..00dddc81 100644 --- a/tests/env/test_secret_store.py +++ b/tests/env/test_secret_store.py @@ -99,6 +99,32 @@ def test_load_of_missing_file_is_empty(store): assert store.plaintext.load(GLOBAL) == {} +RAW = b'# comment\n\nexport EDITOR=vim\nQUOTED="a b"\n' + + +def test_bytes_roundtrip_keeps_the_original_content(store): + """バイト列経路はコメント・空行・``export`` 表記をそのまま往復させる""" + for backend in (store.age, store.plaintext): + backend.save_bytes(GLOBAL, RAW) + assert backend.load_bytes(GLOBAL) == RAW + backend.remove(GLOBAL) + + +def test_load_bytes_of_missing_file_is_empty(store): + assert store.age.load_bytes(GLOBAL) == b'' + assert store.plaintext.load_bytes(GLOBAL) == b'' + + +def test_dict_save_normalizes_what_bytes_save_preserved(store): + """辞書経由で保存し直すと従来どおり正規化される (原文は残らない)""" + store.plaintext.save_bytes(GLOBAL, RAW) + values = store.plaintext.load(GLOBAL) + store.plaintext.save(GLOBAL, values) + + assert b'# comment' not in store.plaintext.load_bytes(GLOBAL) + assert store.plaintext.load(GLOBAL) == values + + def test_age_load_with_wrong_identity_raises(tmp_path, keypair): public, _ = keypair other = tmp_path / 'other.key' diff --git a/tests/volume/test_compose_secret_env.py b/tests/volume/test_compose_secret_env.py new file mode 100644 index 00000000..82894c46 --- /dev/null +++ b/tests/volume/test_compose_secret_env.py @@ -0,0 +1,459 @@ +"""生成する構成ファイルへの機密の渡し方 (変数名のみの列挙)""" + +from __future__ import annotations + +import pytest +import yaml + +from devbase.volume.compose import generate_scaled_compose + + +COMPOSE = """services: + dev: + image: alpine + env_file: + - ${DEVBASE_ROOT}/.env + - env + environment: + FEATURE_FLAG: enabled + DB_PASSWORD: has-a-value + volumes: + - x:/work + db: + image: mysql +volumes: + x: {} +""" + +COMPOSE_LIST_ENV = """services: + dev: + image: alpine + environment: + - FEATURE_FLAG=enabled + - DB_PASSWORD=has-a-value + - PASSTHROUGH + volumes: + - x:/work +volumes: + x: {} +""" + +COMPOSE_NO_ENV = """services: + dev: + image: alpine + volumes: + - x:/work +volumes: + x: {} +""" + + +@pytest.fixture +def project(tmp_path, monkeypatch): + (tmp_path / 'compose.yml').write_text(COMPOSE) + (tmp_path / 'env').write_text('GIT_REPO=web\n') + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path / 'root')) + (tmp_path / 'root').mkdir() + monkeypatch.chdir(tmp_path) + return tmp_path + + +@pytest.fixture +def project_factory(tmp_path, monkeypatch): + """任意の compose.yml でプロジェクトを組み立てる""" + def build(compose_text): + (tmp_path / 'compose.yml').write_text(compose_text) + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path / 'root')) + (tmp_path / 'root').mkdir(exist_ok=True) + monkeypatch.chdir(tmp_path) + return tmp_path + return build + + +def generated(path): + return yaml.safe_load((path / '.docker-compose.scale.yml').read_text()) + + +def test_secret_names_are_listed_without_values(project): + generate_scaled_compose(1, secret_env_names=['ANTHROPIC_API_KEY', 'DB_PASSWORD']) + + # 元が map 形式なら map のまま。機密キーだけ値なし参照 (None) になる + assert generated(project)['services']['dev-1']['environment'] == { + 'FEATURE_FLAG': 'enabled', + 'DB_PASSWORD': None, + 'ANTHROPIC_API_KEY': None, + } + + +def test_non_secret_environment_is_preserved_in_list_form(project_factory): + path = project_factory(COMPOSE_LIST_ENV) + + generate_scaled_compose(1, secret_env_names=['DB_PASSWORD', 'ANTHROPIC_API_KEY']) + + # 元が list 形式なら list のまま。機密キーは裸のキー名へ落とす + assert generated(path)['services']['dev-1']['environment'] == [ + 'FEATURE_FLAG=enabled', + 'DB_PASSWORD', + 'PASSTHROUGH', + 'ANTHROPIC_API_KEY', + ] + assert 'has-a-value' not in (path / '.docker-compose.scale.yml').read_text() + + +def test_generated_file_contains_no_secret_values(project): + generate_scaled_compose(1, secret_env_names=['ANTHROPIC_API_KEY', 'DB_PASSWORD']) + + text = (project / '.docker-compose.scale.yml').read_text() + assert 'ANTHROPIC_API_KEY' in text + # 機密キーの値は生成物に残さない。非機密の固定値はそのまま残す + assert 'has-a-value' not in text + assert 'enabled' in text + + +def test_every_instance_gets_the_names(project): + generate_scaled_compose(3, secret_env_names=['TOKEN']) + + config = generated(project) + for index in (1, 2, 3): + assert config['services'][f'dev-{index}']['environment']['TOKEN'] is None + + +def test_no_environment_section_without_secrets(project_factory): + path = project_factory(COMPOSE_NO_ENV) + + generate_scaled_compose(1, secret_env_names=[]) + + assert 'environment' not in generated(path)['services']['dev-1'] + + +def test_names_are_listed_when_original_has_no_environment(project_factory): + path = project_factory(COMPOSE_NO_ENV) + + generate_scaled_compose(1, secret_env_names=['ANTHROPIC_API_KEY', 'TOKEN']) + + assert generated(path)['services']['dev-1']['environment'] == [ + 'ANTHROPIC_API_KEY', 'TOKEN'] + + +def test_missing_env_file_entries_are_dropped(project): + """暗号化で平文が無くなった参照を残すと Compose が起動時に落ちる""" + generate_scaled_compose(1, secret_env_names=['TOKEN']) + + config = generated(project) + assert config['services']['dev-1']['env_file'] == ['env'] + + +def test_missing_non_secret_env_file_entries_are_kept(project_factory): + """機密以外の欠落は隠さない (タイプミスや未配置を Compose に知らせる)""" + path = project_factory("""services: + dev: + image: alpine + env_file: + - ${DEVBASE_ROOT}/.env + - config/app.env + - .env +""") + + generate_scaled_compose(1, secret_env_names=['TOKEN']) + + # 既知の機密参照 (${DEVBASE_ROOT}/.env, .env) だけが落ち、残りは残る + assert generated(path)['services']['dev-1']['env_file'] == ['config/app.env'] + + +def test_existing_env_file_entries_are_kept(project): + (project / 'root' / '.env').write_text('TOKEN=x\n') + + generate_scaled_compose(1, secret_env_names=['TOKEN']) + + config = generated(project) + assert config['services']['dev-1']['env_file'] == [ + '${DEVBASE_ROOT}/.env', 'env'] + + +def test_env_file_key_is_removed_when_nothing_remains(tmp_path, monkeypatch): + (tmp_path / 'compose.yml').write_text("""services: + dev: + image: alpine + env_file: + - ${DEVBASE_ROOT}/.env +""") + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path / 'root')) + (tmp_path / 'root').mkdir() + monkeypatch.chdir(tmp_path) + + generate_scaled_compose(1, secret_env_names=['TOKEN']) + + config = yaml.safe_load((tmp_path / '.docker-compose.scale.yml').read_text()) + assert 'env_file' not in config['services']['dev-1'] + + +def test_unresolvable_env_file_entries_are_left_alone(tmp_path, monkeypatch): + """未定義の変数を含む参照は存在判定できないので触らない""" + (tmp_path / 'compose.yml').write_text("""services: + dev: + image: alpine + env_file: + - ${SOME_UNDEFINED_ROOT}/.env +""") + monkeypatch.delenv('SOME_UNDEFINED_ROOT', raising=False) + monkeypatch.chdir(tmp_path) + + generate_scaled_compose(1) + + config = yaml.safe_load((tmp_path / '.docker-compose.scale.yml').read_text()) + assert config['services']['dev-1']['env_file'] == ['${SOME_UNDEFINED_ROOT}/.env'] + + +def test_non_dev_services_are_untouched(project): + """機密ファイルを参照していないサービスには余計な変数を注入しない""" + generate_scaled_compose(1, secret_env_names=['TOKEN']) + + config = generated(project) + assert 'environment' not in config['services']['db'] + + +# --------------------------------------------------------------------------- +# 元々機密ファイルを参照していた非 dev サービスへの受け渡し +# --------------------------------------------------------------------------- + +COMPOSE_DB_WITH_SECRET = """services: + dev: + image: alpine + volumes: + - x:/work + db: + image: mysql + env_file: + - ${DEVBASE_ROOT}/.env + environment: + MYSQL_DATABASE: app + cache: + image: redis + env_file: + - config/app.env +volumes: + x: {} +""" + + +def test_non_dev_service_with_a_secret_reference_gets_the_names(project_factory): + """DB パスワードを env_file から受け取っていたサービスに機密を渡す""" + path = project_factory(COMPOSE_DB_WITH_SECRET) + + generate_scaled_compose(1, secret_env_names=['DB_PASSWORD']) + + config = generated(path) + # 非機密の値は残したまま、機密は値なし参照として列挙される + assert config['services']['db']['environment'] == { + 'MYSQL_DATABASE': 'app', + 'DB_PASSWORD': None, + } + # 機密を参照していないサービスには注入しない + assert 'environment' not in config['services']['cache'] + + +def test_commented_out_references_still_receive_the_secrets(project_factory): + """移行後は参照がコメントアウトされる。YAML から消えても渡し先は変えない""" + from devbase.env import compose_migrate + + disabled, _ = compose_migrate.disable(COMPOSE_DB_WITH_SECRET) + path = project_factory(disabled) + + generate_scaled_compose(1, secret_env_names=['DB_PASSWORD']) + + config = generated(path) + assert config['services']['db']['environment']['DB_PASSWORD'] is None + assert 'environment' not in config['services']['cache'] + + +# --------------------------------------------------------------------------- +# 由来 (共通 / プロジェクト) ごとの絞り込み +# --------------------------------------------------------------------------- + +COMPOSE_MIXED_ORIGINS = """services: + dev: + image: alpine + env_file: + - ${DEVBASE_ROOT}/.env + - env + - .env + volumes: + - x:/work + global_only: + image: mysql + env_file: + - ${DEVBASE_ROOT}/.env + project_only: + image: redis + env_file: + - .env + both: + image: nginx + env_file: + - ${DEVBASE_ROOT}/.env + - .env + none: + image: busybox +volumes: + x: {} +""" + +ORIGINS = dict( + secret_env_names=['SHARED_KEY', 'PROJECT_TOKEN'], + global_env_names=['SHARED_KEY'], + project_env_names=['PROJECT_TOKEN'], +) + + +def _env_names(service_config): + """map / list どちらの記法でも、列挙された変数名を集合で返す""" + environment = service_config.get('environment') + if isinstance(environment, dict): + return set(environment) + return {item.split('=', 1)[0] for item in (environment or [])} + + +def test_global_only_service_gets_only_global_names(project_factory): + """共通の .env だけを読んでいたサービスにプロジェクト固有の機密は渡さない""" + path = project_factory(COMPOSE_MIXED_ORIGINS) + + generate_scaled_compose(1, **ORIGINS) + + assert _env_names(generated(path)['services']['global_only']) == {'SHARED_KEY'} + + +def test_project_only_service_gets_only_project_names(project_factory): + path = project_factory(COMPOSE_MIXED_ORIGINS) + + generate_scaled_compose(1, **ORIGINS) + + assert _env_names(generated(path)['services']['project_only']) == { + 'PROJECT_TOKEN'} + + +def test_service_referencing_both_gets_every_name(project_factory): + path = project_factory(COMPOSE_MIXED_ORIGINS) + + generate_scaled_compose(1, **ORIGINS) + + config = generated(path) + assert _env_names(config['services']['both']) == { + 'SHARED_KEY', 'PROJECT_TOKEN'} + # dev は従来どおり全件 (env_file を書いていない構成でも両方が要る) + assert _env_names(config['services']['dev-1']) == { + 'SHARED_KEY', 'PROJECT_TOKEN'} + # 機密を参照していないサービスには何も注入しない + assert 'environment' not in config['services']['none'] + + +def test_origins_are_respected_after_migration(project_factory): + """移行で参照がコメントアウトされたあとも由来ごとの絞り込みを保つ""" + from devbase.env import compose_migrate + + disabled, _ = compose_migrate.disable(COMPOSE_MIXED_ORIGINS) + path = project_factory(disabled) + + generate_scaled_compose(1, **ORIGINS) + + config = generated(path) + assert _env_names(config['services']['global_only']) == {'SHARED_KEY'} + assert _env_names(config['services']['project_only']) == {'PROJECT_TOKEN'} + + +def test_without_the_split_every_receiver_gets_every_name(project_factory): + """由来の内訳が渡されない場合は従来どおり全件 (渡し漏れで壊さない)""" + path = project_factory(COMPOSE_MIXED_ORIGINS) + + generate_scaled_compose(1, secret_env_names=['SHARED_KEY', 'PROJECT_TOKEN']) + + config = generated(path) + assert _env_names(config['services']['global_only']) == { + 'SHARED_KEY', 'PROJECT_TOKEN'} + + +def test_unreadable_compose_falls_back_to_dev_only(project_factory, monkeypatch): + """生テキストを読めない場合は dev だけ・全件へフォールバックする""" + from pathlib import Path as _Path + + path = project_factory(COMPOSE_MIXED_ORIGINS) + + original = _Path.read_text + + def fail_on_compose(self, *args, **kwargs): + if self.name == 'compose.yml': + raise OSError('boom') + return original(self, *args, **kwargs) + + monkeypatch.setattr(_Path, 'read_text', fail_on_compose) + + generate_scaled_compose(1, **ORIGINS) + + config = generated(path) + assert _env_names(config['services']['dev-1']) == { + 'SHARED_KEY', 'PROJECT_TOKEN'} + for name in ('global_only', 'project_only', 'both', 'none'): + assert 'environment' not in config['services'][name] + + +# --------------------------------------------------------------------------- +# クォートされたサービス名 / long syntax の env_file +# --------------------------------------------------------------------------- + +COMPOSE_QUOTED_SERVICE = """services: + dev: + image: alpine + volumes: + - x:/work + "db": + image: mysql + env_file: + - ${DEVBASE_ROOT}/.env + 'cache': + image: redis + env_file: + - .env +volumes: + x: {} +""" + + +def test_quoted_service_names_receive_their_secrets(project_factory): + """`"db":` は PyYAML では `db`。引用符込みで拾うと機密が渡らない""" + path = project_factory(COMPOSE_QUOTED_SERVICE) + + generate_scaled_compose(1, **ORIGINS) + + config = generated(path) + # 生成後もサービス名はパース済みの姿 (引用符なし) + assert {'db', 'cache'} <= set(config['services']) + assert _env_names(config['services']['db']) == {'SHARED_KEY'} + assert _env_names(config['services']['cache']) == {'PROJECT_TOKEN'} + + +COMPOSE_LONG_SYNTAX = """services: + dev: + image: alpine + volumes: + - x:/work + db: + image: mysql + env_file: + - path: ${DEVBASE_ROOT}/.env + cache: + image: redis + env_file: + - path: config/app.env +volumes: + x: {} +""" + + +def test_long_syntax_reference_receives_its_secrets(project_factory): + """long syntax (`- path: ...`) の参照も由来つきで拾う""" + path = project_factory(COMPOSE_LONG_SYNTAX) + + generate_scaled_compose(1, **ORIGINS) + + config = generated(path) + assert _env_names(config['services']['db']) == {'SHARED_KEY'} + assert 'environment' not in config['services']['cache']