feat: PLAN35-runtime 起動ラッパーとコンテナへの受け渡し - #93
Conversation
b720afd to
26dae89
Compare
起動ラッパーが共通の機密ファイルを source するのをやめ、機密は Python 本体が 必要になった時点で復号してメモリ上で合成する。コンテナへは変数名だけを列挙 した構成で渡すため、暗号文も平文ファイルも Docker Compose には渡らない。 - 起動ラッパーは非機密設定 ($DEVBASE_ROOT/env) だけを読む。シェルから読める 場所に機密を置かないことが暗号化の前提であり、ここで読むと意味が無くなる - ホスト側で機密を必要とする処理は実測で 2 系統だけだった。Docker Compose の 変数展開 (ローカル S3 互換サービスの資格情報) と、それを含むビルド呼び出し。 ビルドは devbase env exec 経由にして Python 側から環境変数を渡す - 生成する構成へは変数名のみを書き、値は devbase 自身の環境変数から解決させる。 台数拡張時に生成する構成そのものへ書き込むため、別ファイルの上書きを重ねる 必要がなく適用順序の問題が起きない - 重ね順は従来の env_file の並びを維持する。共通機密とプロジェクト機密のキーを 列挙しつつ、両方に同じキーがある場合は値として非機密設定側を採用する。 environment は env_file より優先されるため、こうしないと「プロジェクト設定が 共通設定を上書きする」関係が反転する - 実在しない env_file 参照は生成時に落とす。暗号化で平文が無くなった参照が 残っていると Compose が起動時に落ちるため 移行コマンド (encrypt / decrypt) を追加した。暗号化は「読み戻せることを確認して から平文を退避する」順序で行う。鍵の指定を誤ったまま平文を失うと、誰にも復号 できないファイルだけが残るため。退避した平文は自動では消さず、場所を案内する。 構成ファイルの書き換えは行のコメントアウトで行い、元の行をそのまま残す。YAML と して読み書きし直すと利用者のコメントや整形が失われること、および平文へ戻す操作で 元の行を機械的に復元できることの 2 点による。 コンテナ起動前の設定チェックは、ファイルの有無ではなく秘密ストアに設定があるかで 判定する。移行済みの環境で毎回 env init が走るのを避けるため。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 1 | codex | REQUEST_CHANGES
暗号化・復号・Compose 更新を、途中失敗で不整合を残さない一連の移行処理にしてください。
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 1 | gemini | REQUEST_CHANGES
移行コマンドにおいて、ファイル処理エラー発生時の不整合リスクがあります。
詳細についてはインラインコメントを参照してください。
encrypt / decrypt はどちらも対象ごとにループ内で破壊的な操作を実行していたため、 後続の対象で失敗すると先行対象だけ移行済みになり、しかも compose.yml の書き換えは 実行されず「構成ファイルが存在しないファイルを参照する」壊れた状態で終わっていた。 また _apply_compose_changes は書き込み失敗をログに出して次のファイルへ進むため、 機密の移動・削除が済んだあとでもコマンドが成功扱いになっていた。 実行した操作ごとに取り消し手続きを積み、どこで失敗しても逆順に巻き戻す _Rollback を 追加し、encrypt / decrypt の両方で共有する。 - encrypt: 全対象の暗号化と読み戻し検証 → 平文の退避 → compose.yml の書き換え - decrypt: 全対象の復号確認 (生バイト列も控える) → 平文の書き出し → compose.yml の復元 → 最後に暗号文を削除。破壊的な削除を最後に置くことで、 途中で失敗したときに失うものを最小にする - _apply_compose_changes は書き込み失敗を MigrationError で呼び出し元へ伝え、 書けたぶんは巻き戻す。巻き戻し自体が失敗したら何が残っているかを列挙する Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 2 | codex | REQUEST_CHANGES
部分復号時に、暗号化されたままの参照を Compose 設定へ戻さないよう復元対象を限定してください。
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 2 | gemini | REQUEST_CHANGES
機密設定を環境変数渡しに移行するアプローチおよび、元の設定をコメントアウトで残す移行ツールの設計は妥当です。しかし、compose_migrate.py で env_file ブロック内の空行を正しくスキップできず、compose.yml を破損させる問題が見つかりました。
インラインコメントで指摘した箇所の修正をお願いします。
PR #93 のレビュー指摘 4 件に対応する。 - compose_migrate.enable() に disable() と同じ targets 引数を持たせた。 `env decrypt --project` で一部だけ復号したとき、これまでは compose 内の 全マーカーを戻していたため、まだ暗号化されたままの共通設定 (${DEVBASE_ROOT}/.env) の参照まで有効になり Compose の起動が失敗していた。 無効化と復元で同じ判定 (_compose_targets) を使うようにして揃えた。 キー行 (`env_file:`) は有効なエントリが 1 つ以上戻ったときだけ復元する。 - disable() が env_file リスト内の空行で break していたのをスキップに変えた。 空行以降のエントリを無効化し損ねるうえ、「有効なエントリ 0 件」と誤判定して `env_file:` キー自体をコメントアウトし、起動失敗を招いていた。ブロックの 終端判定はインデントが受け持つため、空行で止める必要はない。 - 機密注入のスキップ判定を (コマンド, サブコマンド) の組に変えた。args.command にはトップレベルしか入らないため、鍵がまだ無い / 復号できない状態で実行される `env keygen` / `env encrypt` / `env decrypt` でも注入が走っていた。 - 行単位では扱えない env_file 記法 (インライン配列・単一文字列) を検出して ファイルと行番号つきで警告するようにした。移行の対象から漏れることを黙って いると、利用者は壊れた構成のまま起動して初めて気付く。対応範囲はモジュールの docstring にも明記した。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 3 | codex | REQUEST_CHANGES
スケール用 Compose 生成で既存の非機密設定を失わないよう、設定の選別処理を修正してください。
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 3 | gemini | REQUEST_CHANGES
一部のヘルパー処理が、移行による不在ファイルだけでなく利用者の設定ミスまで隠蔽してしまう懸念があります。詳細はインラインコメントを参照してください。
生成する構成ファイル (.docker-compose.scale.yml) の作り方を 2 点直した。 - environment を丸ごと落としていたため、元の compose.yml が持つ非機密の固定値や 機能フラグまで消え、スケールした途端に生成コンテナの挙動が変わっていた。 secret_env_names に挙がったキーだけを値なし参照へ置き換え、それ以外は値ごと 残すようにした。元の記法は尊重し、map なら値 None の map、list なら裸のキー名 として出力する。機密キーの値が生成ファイルに残らないことは従来どおり保証する。 - 実在しない env_file 参照を無条件に落としていたため、利用者のタイプミスや未配置 の必須設定まで黙って成功扱いになり、Compose が知らせてくれる構成不備を隠して いた。落とす対象を「暗号化移行で消える既知の機密参照」に限定し、判定は compose_migrate.is_secret_entry (新規の公開関数) に集約した。 既存テストのうち、environment を落とす前提だったものは新仕様に合わせて更新した。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 4 | codex | REQUEST_CHANGES
移行の事前検証と Compose 構成更新を失敗時にも一貫して巻き戻せるようにしてください。
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 4 | gemini | REQUEST_CHANGES
- コンテナ構成 (
compose.yml) を変換する際、非devサービスが機密ファイルを失ったまま補填されない問題があります。 compose.ymlのenv_fileにコメントや末尾スペースがある場合の判定漏れリスクがあります。
これらは暗号化移行後のコンテナ起動失敗に直結するため、修正を提案します。
暗号化移行が「機密だけ退避されて構成は壊れたまま成功する」経路を塞ぎ、
生成する構成で非 dev サービスに機密が渡らない問題を直す。
- compose.yml を読めない場合は警告してスキップせず MigrationError で移行
全体を中止する。飛ばして続けると平文だけが退避され、存在しないファイルを
指す参照が残ったままコマンドが成功してしまうため
- 機密ファイルを指すインライン記法 (`env_file: [.env]` / `env_file: .env`)
を検出したら移行を失敗させ、手で直してからの再実行を案内する。機密と
無関係なインライン記法は移行に影響しないので従来どおり警告のみ。判定は
compose_migrate.secret_inline_env_file_lines() に切り出した
- compose.yml の書き込みを write_secure_bytes_atomic へ差し替え、途中で
失敗しても部分的なファイルが残らないようにする。compose.yml は機密では
ないため、既存ファイルの権限を読み取って mode に渡し 0600 へ落とさない
- 元々機密ファイルを env_file で参照していた非 dev サービス (db など) にも
機密の変数名を列挙する。移行後は参照がコメントアウトされ YAML から消える
ため、compose.yml の生テキストを見る
compose_migrate.services_with_secret_env_file() で渡し先を決める。参照を
持たないサービスには従来どおり注入しない
- 末尾スペース / 行末コメント付きのエントリ (`- ${DEVBASE_ROOT}/.env # 共通設定`)
が正しく無効化・復元されることを示すテストを追加 (現行実装で処理済み)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 5 | codex | REQUEST_CHANGES
非 dev サービスへの機密注入は、各サービスが元々参照していた機密ストアの範囲に限定してください。
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 5 | gemini | APPROVE
修正提案はありません。ロールバック処理や environment / env_file の欠落防止など、エッジケースまで堅牢に実装されています。
これまでは「機密参照を持つか」の真偽だけで渡し先を決めていたため、共通の .env だけを参照していた db のようなサービスにも、プロジェクト専用のトークン まで全件が environment へ列挙されていた。元々受け取っていなかった機密が渡る のは機密範囲の拡大にあたるため、由来 (共通 / プロジェクト) 単位で絞り込む。 - compose_migrate.services_with_secret_env_file() の戻り値を 「サービス名 → 参照種別の集合 (TARGET_GLOBAL / TARGET_PROJECT)」へ変更。 コメントアウト済みの参照も従来どおり種別つきで数える - runtime.SecretEnv に global_names / project_names を持たせ、names は 両者を畳んだ全体を返すプロパティへ (呼び出し側の互換は維持) - generate_scaled_compose は非 dev サービスへ、そのサービスが参照していた 由来のキーだけを列挙する。dev は従来どおり全件 - 生テキストを読めない場合の「dev のみ・全件」フォールバックは維持 既知の限界として、同じキーが共通機密とプロジェクト機密の両方にある場合は Compose が実行プロセスの環境変数から 1 つの値しか解決できないため、共通側 だけを参照していたサービスにも合成後 (プロジェクト優先) の値が渡る。値を サービスごとに変えるには生成ファイルへ機密の値を書く必要があり、本 PR の 前提と矛盾するため受け入れる。コードコメントと plan35.md §7 に明記した。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 6 | codex | REQUEST_CHANGES
env_file の有効なブロック記法が移行処理から漏れ、暗号化後に Compose が起動不能になる経路を修正してください。
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 6 | gemini | REQUEST_CHANGES
`env_file:` 配下に利用者が書いた単独のコメント行があると `_LIST_ITEM_RE` に一致せず走査が打ち切られ、コメント行より後ろの機密参照が 無効化されないまま残っていた。その状態で平文を退避すると、Compose が存在 しないファイルを参照して起動できなくなる。 空行と同じく単独のコメント行も読み飛ばすようにし、ブロックの終端は インデントだけが決めるようにした。無効化済みの行 (DISABLED_MARK 付き) は 見た目がコメント行でも中身はエントリなので、コメント判定より先に除いて いる (順序を誤ると enable が何も復元できなくなる)。 disable / enable で重複していたブロック走査は `_scan_env_file_block` へ 括り出した。片方だけ直すと無効化と復元がずれるため。 `services_with_secret_env_file` も同じ理由で参照種別を取りこぼしていたので、 共通の `_is_skippable` で読み飛ばすようにした。 テストは、既存の test_user_comments_are_preserved が「何も書き換えられて いない」ために往復の一致だけで通っていた点を補強し (修正前にこのアサートが失敗することを確認済み)、コメント行の後ろの参照が 無効化されること・往復で元に戻ること・コメントと空行が混在する場合・ services_with_secret_env_file が種別を拾えることを追加した。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 7 | codex | REQUEST_CHANGES
Compose の有効な記法と元ファイルの表現を保持できるよう、生テキスト走査の境界条件を修正してください。
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 7 | gemini | APPROVE
変更内容、移行時のロールバック容易性、エッジケースの保護、およびテストの網羅性を確認しました。堅牢に実装されており、特に指摘すべき問題点はありません。
行ベースの走査に残っていた 3 つの穴を塞ぎ、あわせて「何を扱い、何を扱わないか」
をモジュールの契約として docstring に書き出した。
- long syntax (`- path: .env`) を参照として認識する。1 行で閉じているものは
従来どおり無効化・復元し、`required: false` などの続きの行を持つ形・フロー
記法・シーケンスでない値は書き換えず、機密を指していれば移行を中止する。
続きの行で走査を打ち切らないので、その後ろに並ぶ機密参照も取りこぼさない。
- サービス名を YAML と同じ姿へ正規化する。`"db":` を引用符込みで記録すると
パース済みの `db` と一致せず、そのサービスへ機密が渡らなかった。
- 各行の元の行末を保って書き換える。`rstrip('\n') + '\n'` で CRLF が LF に
変わり、encrypt → decrypt の往復で元の compose.yml に戻らなかった。移行
コマンド側も read_bytes で読み、改行コードを勝手に揃えないようにした。
インライン記法だけを対象にしていた中止判定は扱えない記法全体に広げ、名前を
secret_unsupported_env_file_lines へ変更した。扱えない記法は黙って通さず、
必ず中止か警告のどちらかに落ちる不変条件を docstring に明記している。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 8 | codex | APPROVE
修正が必要な指摘はありません。
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 8 | gemini | REQUEST_CHANGES
移行処理の途中でエラーが発生した際のロールバック設計、および単一文字列で指定された env_file が変換から漏れる問題について修正を提案します。
`env_file: .env` のように値が単一文字列で書かれた定義は、これまで「行単位では
扱えない記法」として扱い、機密を指す場合は移行ごと中止していた。利用者は手で
`- ...` の並びへ書き換えないと暗号化できず、実質的に使えない状態だった。
この形はエントリが 1 つしかなく 1 行で完結するため、`env_file:` の行そのものを
コメントアウトすれば安全に無効化でき、`enable` でも元のバイト列へ戻せる。
`_inline_scalar_ref` で「1 行で完結する単一文字列」だけを切り出し、disable /
enable / 中止判定 (`secret_unsupported_env_file_lines`) の 3 箇所で同じ判定を
使うようにした。
フロー記法 (`env_file: [ ... ]` / `{ path: ... }`)、ブロックスカラー、閉じていない
クォートなど 1 行で安全に判断できない記法は、従来どおり警告・中止のままにする。
機密を指さない単一文字列 (`env_file: config/app.env`) は触らない。モジュール
冒頭の「扱う記法 / 中止する記法 / 触らない記法」の契約も実装に合わせて更新した。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 9 | codex | REQUEST_CHANGES
対応外の env_file 記法で機密参照を見落とし、平文退避後も参照が残る経路を修正してください。
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 9 | gemini | APPROVE
構成ファイルの走査、機密の退避と復元のトランザクション管理、スケーリング時の変数注入など、全般にわたって細部まで安全に実装されています。ロールバック機構や YAML のコメント保持も極めて堅牢です。
特に指摘すべき問題はありません。
`env_file: >-` のようなブロックスカラーは先頭行に参照先が無く、行ベースの 走査では機密を指しているか判別できない。その結果 `env_file: >-` +次行 `.env` の構成は無効化も中止もされず、暗号化後も存在しない平文への参照が残ったまま コマンドが成功していた。モジュールが掲げる「扱えない記法は必ず中止か警告に 落ちる」という不変条件が破れている。 記法ごとに穴を塞ぐ対応では同種の見落としが出続けるため、記法の判別に依らない 事後検証を最後の砦として追加する: - `compose_migrate.remaining_secret_env_file_refs()` を追加。書き換え後の テキストを `yaml.safe_load` でパースし、各サービスの `env_file` に残った 機密参照を返す。文字列 / 文字列のリスト / long syntax の dict のいずれも 平坦化して拾う。無効化した行は YAML のコメントなのでパーサからは見えず、 残っていれば走査が取りこぼしたことを意味する - パースできない `compose.yml` は `ComposeParseError` を投げる。検証できない 以上「参照が無い」とは言い切れないため、読み取り失敗と同じ扱いで中止する - `env_migrate` の暗号化側で全 `compose.yml` に検証を掛け、残っていれば どのファイルのどのサービスにどの参照が残るかを示して `MigrationError` で 中止する。差分ゼロのファイルも対象にする (走査が何も見つけられなかった ファイルこそ取りこぼしの疑いが濃い) - 復号側では行わない。平文が戻る以上その参照は有効で正しく、ここで止めると 壊れた状態からの復帰手段を塞いでしまう 行ベースの走査は「うまく書き換えられれば書き換える、取りこぼしたら事後検証が 止める」という二段構えになる。既存の `secret_unsupported_env_file_lines()` はより早い段階で分かりやすいエラーを 出すための仕組みとして残す。設計意図はモジュール docstring とコード内 コメントに明記した。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 10 | codex | REQUEST_CHANGES
- バックアップ先の衝突を防ぎ、暗号化・復号の往復で平文原本を復元できるようにしてください。
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 10 | gemini | APPROVE
修正を要する問題点は見当たりません。
- バックアップ先の衝突で過去の平文を失わないようにする 秒単位の日時ディレクトリは既存のバックアップと衝突しうる。従来は `exist_ok=True` で掘っていたため、衝突すると `shutil.move` が同名の `global.env` / プロジェクトの env を上書きし、「削除しないはずの過去の 平文」を失っていた。`_create_backup_dir` を追加して `exist_ok=False` で 排他的に作成し、既にあれば `-2` `-3` … と一意な名前へ寄せる。上限 (100 回) まで空きが無ければ平文に触れないまま中止する。退避先には平文の 機密が置かれるため 0700 で作る (親は他機能と共有するので既定のまま)。 - 往復でコメント・空行・`export` 表記が失われないようにする 暗号化時に平文を辞書へ畳んでいたため、`decrypt` してもコメント・空行・ `export KEY=...` 表記・値のクォートが戻らず、案内している「暗号化前の 状態へそのまま復帰」を満たしていなかった。`SecretStore` / `PlaintextBackend` / `AgeBackend` に生バイト列を扱う `save_bytes` / `load_bytes` を追加し、移行は原文のバイト列のまま暗号化・復元する。 読み戻し検証も「暗号化 → 復号 → 元のバイト列と一致」で維持する。 辞書経由の `save` / `load` はそのまま残し、`env set` などで値を書き換えた ときに正規化されるのは平文だけを使っていた頃と同じ挙動として変えない (「値を書き換えるまでは原文が保たれ、書き換えると正規化される」)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 11 | codex | REQUEST_CHANGES
プロジェクト切替時に切替元の復号済み機密が残留しないよう、注入ライフサイクルを修正してください。
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 11 | gemini | APPROVE
変更内容を確認しました。バックアップ先の排他作成や、原文バイト列の維持による完全な復元性の確保など、移行コマンドとしての堅牢性が大きく向上していると評価します。
ロールバック機構の設計や各フェーズのエラーハンドリングにも漏れは見当たらず、安全にデプロイできる状態です。
cli._load_secret_env は dispatch の前に「現在地のプロジェクト」の機密を os.environ へ載せるが、TUI や `project up <other>` の直接起動ではその後に 対象プロジェクトへ切り替わる。切替先に同名キーが無い機密 (切替元固有の トークン等) は載せ直しでは上書きされず残り、Compose や子プロセスへ 引き継がれてしまう。 - runtime.inject が「載せた変数名とその注入前の値」を記録し、 runtime.clear_injected で注入前の状態へ戻せるようにした。自分が載せた キーだけを対象にし、利用者がシェルで設定していた同名の変数は元の値へ 戻すので消えない - container._inject_secrets は対象プロジェクトへ chdir した後に呼ばれる ため、載せ直しの前に clear_injected を通して切替元の機密を落とす - _resolve_project_name は os.chdir と併せて PWD も切り替える。機密の 解決 (runtime.current_project_name) は wrapper の cd を前提に PWD を 先に見るため、PWD が切替前のままだと切替先ではなく呼び出し元の機密を 読んでしまい、載せ直しが機能しないため (TUI の _run_in_project と同様) 非機密設定 (env) 側の _CALLER_ENV_KEYS / _resolve_project_name と同じ性質を 機密にも与えることになる。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 12 | codex | REQUEST_CHANGES
注入履歴と操作対象の環境マッピングの対応を保証してください。
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 12 | gemini | APPROVE
特に追加の修正が必要な箇所は見当たりませんでした。
注入履歴がモジュールレベルに 1 つしか無かったため、inject(environ=A) の後に clear_injected(environ=B) を呼ぶと、A に対して記録した内容で B を誤って 「復元」し、かつ A には機密が載ったまま残っていた。 履歴を「どの環境マッピングへ注入したか」と結び付け、clear_injected は同じ 対象に記録された履歴だけを解除して、その対象の履歴を破棄するようにした。 dict は hashable でないため id() をキーにするが、対象そのものへの参照も 一緒に保持し、id の再利用による誤爆を防ぐ。os.environ を既定対象とする 従来の使い勝手は変えていない。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 13 | codex | APPROVE
修正を要求する指摘はありません。
takemi-ohama
left a comment
There was a problem hiding this comment.
🤖 cross-review | round 13 | gemini | APPROVE
設計の妥当性、エラー時の巻き戻しの堅牢性、compose.yml 差分の事後検証など、移行・運用リスクを抑える仕組みが高い品質で実装されています。指摘事項はありません。
* docs: PLAN35 環境変数ファイル暗号化の方針と PR 分割計画 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * feat: PLAN35-secret-store 秘密ストアの抽象層と age 実装 (#91) * chore: PLAN35-secret-store Draft PR 作成 * feat: 機密の保存先を抽象化する秘密ストアと age 実装を追加 平文の .env に直接置かれていた機密の保存先を 1 箇所に閉じ込め、平文と age 暗号化を差し替え可能にする層を追加する。上位の設定操作からは load / save だけが見え、どちらの形式で保存されているかは意識しなくてよい。 - 保存先はファイルの存在で自動判定する。暗号化ファイルがあればそれを使い、 無ければ平文を使う。同じ参照に両方が存在する状態はどちらが正か判断 できないため、黙って一方を採用せず明示的に停止する - devbase 専用の age 鍵を扱う層を分ける。export / import が使う ~/.ssh の 鍵とは失効・保管・バックアップの扱いが異なるため流用しない - 公開鍵は鍵ファイル中のコメントではなく秘密鍵から都度導出する。コメントは 手で書き換えられるため、信じると受信者と実鍵が食い違ったまま暗号化される - 受信者リストは公開鍵しか含まないが 0600 で保護する。第三者が自分の鍵を 追記できると以後の暗号化がその相手にも復号可能になるため、機密性ではなく 改竄防止のために権限を絞る - プロジェクト名はそのままファイル名になるため、パス区切りを含む名前を拒否 して保存先ディレクトリの外へ書き出せないようにする devbase env keygen を新設し、鍵の生成と受信者リストへの登録、鍵を失うと 復旧できない旨のバックアップ喚起までを 1 コマンドで済ませる。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: age 鍵のローテーションを原子的にして旧鍵の消失を防ぐ `devbase env keygen --force` は、既存鍵を `O_TRUNC` で直接上書きし、その後で 受信者リストを更新していた。このため次の 2 つの経路で「旧鍵は失われたのに 新しい状態も揃っていない」復旧不能な状況が起こりえた。 - 鍵の書き込みが途中で失敗する (ディスク枯渇・強制終了など) と、旧鍵だけが 消えて既存の暗号文を誰も復号できなくなる - 鍵の差し替えに成功しても、その後の受信者リスト更新が失敗すると旧鍵は 戻らず、新公開鍵も受信者に載らない 対応: - `io_common.write_secure_bytes_atomic` を追加。同一ディレクトリの一時ファイルへ 0600 で書いて fsync し、`os.replace` で差し替えたうえでディレクトリも fsync する。失敗時は一時ファイルを掃除し、旧内容をそのまま残す。 - `agekeys.generate_key_file` / `save_recipients` をこの atomic 書き込みに変更。 受信者リストも欠けたまま残ると暗号化対象から一部の受信者が黙って外れるため。 - `cmd_env_keygen` は鍵と受信者リストの中身を事前に控え、鍵生成〜受信者リスト 更新のいずれかが失敗したら両方を元の状態へ書き戻してからエラーを返す。 複数ファイルにまたがる更新は個々の書き込みが atomic でも原子的にならないため。 テストは差し替え失敗時に旧鍵・旧受信者リストが無傷で残ること、権限が 0600 の ままであること、一時ファイルが残らないこと、`cmd_env_keygen` のロールバックを 確認する。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: 機密の保存を atomic 化し、keygen の生成先と受信者リストの契約を整理 秘密ストアと鍵生成まわりで「失うと復旧できないもの」を壊しうる 3 点を直す。 - secret_store の save を write_secure_bytes_atomic へ切り替え AgeBackend.save / PlaintextBackend.save が既存ファイルを直接 O_TRUNC して いたため、ディスク枯渇や中断で旧 ciphertext まで失われ機密を復旧できなかった。 一時ファイル → fsync → os.replace の順にして、途中で失敗しても旧内容が そのまま残り、書きかけの一時ファイルも掃除されるようにした。 - devbase env keygen の --key-file を廃止 既定外のパスへ鍵を生成できても agekeys.resolve_identities() はそこを探索 しないため、「生成した鍵で保存した機密を復号できない」状態を作れてしまった。 生成先を常に agekeys.key_file_path() に固定し、生成先と探索先が構造的に 一致する契約にする。場所を変えたい場合は DEVBASE_AGE_KEY_FILE を設定して から実行する (help と zsh 補完も追随)。 - keygen が secrets/recipients.txt を書くのをやめる 鍵はグローバルなのに受信者リストはワークスペースごとに存在するため、 keygen で書き込むと別ワークスペースへ古い公開鍵が取り残され、既に失われた 秘密鍵に対応する公開鍵で暗号化する事故が起きうる。resolve_recipients() は リストが無ければ鍵ファイルの公開鍵へフォールバックするので単独利用では 不要で、明示的に受信者を足す経路 (後続の rekey) だけが作る設計にした。 触るファイルが鍵 1 つになり、--force 時のロールバックも鍵ファイルだけに 単純化される (旧鍵保全そのものは維持)。 テストは os.replace の失敗注入で旧 ciphertext が無傷かつ一時ファイルが残らない ことを検証し、keygen 側は DEVBASE_AGE_KEY_FILE を差し替えて既定パスを tmp へ 向ける方式へ書き換えた。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: 既存ディレクトリの権限を壊さず、keygen --force を常に確認する - agekeys._ensure_private_dir: 既存ディレクトリを 0700 へ落とすのをやめ、 自分が新規作成した階層だけを chmod する。DEVBASE_AGE_KEY_FILE で /tmp などの 共有ディレクトリを指定されると、そこを 0700 にして他ユーザーやサービスの アクセスを壊してしまうため。既存側が緩い場合は変更せず警告ログに留める。 save_recipients も同じ関数を通るので secrets/ の扱いが一貫する。 - cmd_env_keygen: --force の確認条件から _has_encrypted_secrets を外し、既存鍵が あれば常に確認プロンプトを出す (--yes でのみスキップ)。鍵は全ワークスペース 共通なのに条件がカレント DEVBASE_ROOT の機密有無に依存しており、まだ機密の無い 別プロジェクトから --force すると無警告で鍵が消え、他プロジェクトの機密が 復旧不能になっていた。_has_encrypted_secrets は文言の強弱にのみ使う。 - AgeBackend.load: 復号結果が不正 UTF-8 のときの UnicodeDecodeError を SecretStoreError へ包み、PlaintextBackend.load と例外の種類を揃える。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: 読み取り不能な既存鍵を「元は無かった」と誤認して消さないようにする _snapshot_file が読み取り失敗時も None を返していたため、「ファイルが存在しなかった」 と「存在したが読めなかった」を区別できず、鍵生成が失敗して _restore_file が走ると 後者のケースで既存鍵を削除していた。権限を直せば回収できたはずの旧鍵まで失われる。 - _snapshot_file の戻り値を FileSnapshot(SnapshotStatus, data) の 3 状態にし、 ABSENT / CAPTURED / UNREADABLE を持ち回るようにした - _restore_file は UNREADABLE のとき削除も上書きもせず、警告を出して現状を残す - cmd_env_keygen は既存鍵が読めない時点で上書きを中止する (控えが取れていない以上 生成失敗時に巻き戻せず、成功すれば旧鍵が消えるため。回復可能な権限エラーが 大半なので鍵を守る方を選び、権限確認か手動退避を案内する) - スナップショットは確認プロンプトより前に取り、空振りの同意要求を避ける Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * refactor: keygen の手動ロールバックを廃し、原子性は io 層に一本化する add_recipient の呼び出しが無くなり、keygen が触るファイルは鍵 1 つだけになった。 その 1 回の書き込みは agekeys.generate_key_file → io_common.write_secure_bytes_atomic (一時ファイル + fsync + os.replace) が 原子性を担保しており、失敗しても既存の鍵は元のまま残る。その上に 「メモリへ退避して同じ内容を書き戻す」層を重ねてもリストア側が失敗しうるぶん 壊れ方の種類が増えるだけなので、_snapshot_file / _restore_file / FileSnapshot / SnapshotStatus を削除した。 一方「既存鍵が在るのに読めないときは上書きせず中止する」保護は原子性とは 別の目的 (権限を直せば回収できたかもしれない鍵を握り潰さない) なので残し、 os.access(path, os.R_OK) による単純な判定へ置き換えた。将来この層へ再び ロールバックを足さないよう、意図はコードコメントに残してある。 テストは snapshot/restore の実装依存テストを削除し、振る舞い (読めない鍵では中止して exit 1・生成失敗でも旧鍵は無傷) のテストへ整理した。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: env keygen を SUBCMD_MAP に登録し短縮形が解決されるようにする keygen は parser にのみ登録されており SUBCMD_MAP[('env',)] へ追加漏れが あったため、_expand_argv() の prefix 展開対象にならず `devbase env k` が invalid choice で失敗していた。他の env サブコマンドと同様に prefix 解決 されるよう SUBCMD_MAP に追加する。`k` で始まる既存サブコマンドは無いため ambiguous にはならず、SUBCMD_PREFIX_PREFERENCES の追加は不要。 再発防止として、SUBCMD_MAP['env'] が parser 登録済みサブコマンドを漏れなく 含むことを検証するテストと、`devbase env k` → `env keygen` の解決テストを 追加した。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: 鍵ディレクトリを作成時点から 0700 にして権限露出の隙を無くす mkdir(parents=True) で一括作成してから chmod する実装では、作成から chmod 完了までの間だけ umask 依存の緩い権限 (例 0755) が見える窓があり、その隙に 開かれた fd は後からの chmod では閉じられない。未存在の階層を親→子の順に Path.mkdir(mode=0o700) で 1 階層ずつ作り、最初から 0700 で作成する。 並行して他プロセスが同じ階層を作った場合は FileExistsError を握りつぶし、 既存ディレクトリとして権限を触らない (既存ディレクトリは所有者の管轄として chmod しないという既存方針と一貫させる)。0o700 は group/other ビットが無く umask の影響を受けない旨をコメントに残し、将来の chmod 追加を防ぐ。 テストは、umask 0 でも各階層が「作成した瞬間から」0700 であること、および 途中階層が並行作成 (FileExistsError) されても失敗せずその権限を変えないことを 追加で検証する。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: 鍵の初回生成を O_EXCL で排他化し TOCTOU による鍵消失を塞ぐ 初回生成を並行実行すると、両プロセスが「鍵なし」と判定した後どちらも書き込みへ 進み、後発が先発の鍵を無確認で上書きできた。先発鍵で暗号化した機密はその瞬間から 復号不能になる。 - agekeys.generate_key_file(force=False) を os.open(O_WRONLY|O_CREAT|O_EXCL, 0600) による直接排他生成に変更。判定と作成の隙間をカーネル側で不可分に閉じる。既存 ファイルが無い状況では守る旧内容も無いため atomic replace は不要で、むしろ os.replace は既存を無条件に置き換えるぶん危険だった。FileExistsError は従来の AgeKeyError (「鍵ファイルが既に存在します … --force」) へ変換する - force=True は従来どおり一時ファイル + fsync + os.replace を維持 (旧鍵を失わない) - cmd_env_keygen が無条件に force=True を渡すのをやめ、コマンドの --force をその まま渡す。事前チェック後に他プロセスが鍵を作っていた場合も上書きせず停止する - --force 同士の並行実行にはロックを入れない判断理由をコメントに残した Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: 復号時の identity 解決を候補ごとに行い移行互換性を保つ cipher.decrypt() は渡された identity をまとめて解決していたため、 agekeys.resolve_identities() が先頭に置く devbase 専用鍵が壊れている / 読めないだけでその場で例外になり、後続の ~/.ssh 既定鍵を試せなかった。 結果として「旧来 ~/.ssh の鍵で暗号化した暗号文も移行期間中は復号できる」 という意図が失われていた。 候補ごとに解決を試し、解決できなかった候補は理由を warning に残して 読み飛ばし、有効な候補だけで復号を続行するようにした。全候補が解決不能 だった場合のみ CipherError とし、各候補の失敗理由をメッセージに含めて 原因を切り分けられるようにしている。解決に成功したが鍵が一致しない場合の メッセージ、passphrase 経路、identity と passphrase の排他契約は不変。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: PLAN35-env-commands 設定操作コマンドの保存先切替 (#92) * feat: 設定操作コマンドの保存先を秘密ストアへ切り替える devbase env の各コマンドが .env のパスを直接組み立てていたのをやめ、秘密 ストア越しに読み書きするようにした。保存先が平文か暗号化かはストアが判定 するため、コマンド側も設定の収集処理も保存形式を意識しない。 - EnvFile と同じ操作性でストアを扱うビューを挟み、collectors を書き換えずに 済むようにした。収集処理まで暗号化を知る必要はない - 一覧表示では暗号化されている保存先にだけ印を付ける。どちらで保存されて いるかは利用者が知りたい情報だが、平文側に印を足すと既存の見た目が変わる - 暗号化された設定の編集は、復号結果を自分専用の 0700 ディレクトリへ 0600 で 書き、編集後に暗号化し直してから finally で必ず消す。エディタへ値を渡す 手段が他に無いため、ここだけは平文が一瞬ディスクに載る例外として扱う - 編集内容が変わっていなければ書き戻さない。同じ内容でも再暗号化すると ファイルが変わり、差分やバックアップに無用な更新が生まれるため - エディタが異常終了した場合と、編集結果が UTF-8 として読めない場合は保存 しない。壊れた内容で既存の設定を上書きしないため プロジェクト設定の参照先を CWD ではなくプロジェクト直下に固定した。 projects/<name>/sub で実行すると従来はその場所に .env を作っていたが、 コンテナ構成が読むのはプロジェクト直下であり、書いた設定が反映されない。 あわせて projects/ の外での `env set --project` は、どのプロジェクトを指すか 決められないため明示的に断るようにした。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * feat: env delete / edit に --project を追加 設定が暗号化 (.env.age) されると、利用者がエディタで直接開いて不要なキーを 消せなくなる。しかし delete / edit には --project が無く、env set --project で 誤って入れた変数を CLI から取り除く手段が (env.yml に定義が無い限り) 無かった。 - delete / edit に --project / -p を追加し、カレントのプロジェクト設定を対象にする - projects/<name> 配下でない場合は set --project と同じ理由・文言で明示的に断る (どのプロジェクトを指すか決められず、CWD に .env を作るとコンテナが読む先と ずれるため) - set / delete / edit で重複していた参照解決を _target_env() へ集約 - zsh / bash 補完と CLI リファレンスを追随 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: symlink されたプロジェクト配下で --project が効かない問題を修正 _current_project_name が current.resolve() で物理パスに正規化していたため、 プラグイン経由で projects/<name> がシンボリックリンクになっているプロジェクト 配下で実行すると、リンク先の実体を指して projects/ の外と判定されていた。 判定を論理パス (absolute) → 物理パス (resolve) の 2 段に変え、リンク経由の プロジェクトも、.. を含むパスや実体パスで入った場合も拾えるようにする。 symlink プロジェクトの解決テストを追加。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: --project のパス判定で .. を正規化し、機密の保存先を 0700 で掘る PR #92 レビュー指摘 3 件への対応。 - _current_project_name: 論理パス側を Path.absolute から os.path.abspath (= normpath) に変更。.. が畳まれないため projects/web/../../outside のような プロジェクト外のパスが projects/web 配下と誤判定され、プロジェクト外からの --project が web の設定を書き換えていた。.. の textual な畳み込みはシェルの cd / PWD の意味論と一致するので、シンボリックリンク対応とも両立する。物理パス側 (resolve) はリンク先の実体パスで入られた場合のフォールバックとして据え置き。 - io_common.write_secure_bytes{,_atomic}: 親ディレクトリを mkdir(parents=True) で掘ると umask 依存になり、secrets/ が 0755 で生まれうる。ファイルは 0600 でも ディレクトリが読めるとファイル名の一覧が漏れるため、agekeys._ensure_private_dir を io_common.ensure_private_dir へ移して両者で共有する。新規作成した階層だけを 作成時点から 0700 にする / 既存ディレクトリは chmod しない / 並行作成の FileExistsError を握るという既存の性質はそのまま維持し、既存ディレクトリが緩い ときの警告は warn_if_permissive で選択制にした (agekeys 経路のみ有効。 DEVBASE_ROOT 直下や export 先 CWD で毎回鳴ると本当の警告が埋もれるため)。 - docs: 前段の PR で追加された devbase env keygen のリファレンスが漏れていたので 追記。鍵の既定パスと DEVBASE_AGE_KEY_FILE、--force / --yes、鍵を失うと復旧 できないためバックアップが必須である旨を記載。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: PLAN35-runtime 起動ラッパーとコンテナへの受け渡し (#93) * feat: 機密を平文ファイルを介さずコンテナへ渡す経路へ移行する 起動ラッパーが共通の機密ファイルを source するのをやめ、機密は Python 本体が 必要になった時点で復号してメモリ上で合成する。コンテナへは変数名だけを列挙 した構成で渡すため、暗号文も平文ファイルも Docker Compose には渡らない。 - 起動ラッパーは非機密設定 ($DEVBASE_ROOT/env) だけを読む。シェルから読める 場所に機密を置かないことが暗号化の前提であり、ここで読むと意味が無くなる - ホスト側で機密を必要とする処理は実測で 2 系統だけだった。Docker Compose の 変数展開 (ローカル S3 互換サービスの資格情報) と、それを含むビルド呼び出し。 ビルドは devbase env exec 経由にして Python 側から環境変数を渡す - 生成する構成へは変数名のみを書き、値は devbase 自身の環境変数から解決させる。 台数拡張時に生成する構成そのものへ書き込むため、別ファイルの上書きを重ねる 必要がなく適用順序の問題が起きない - 重ね順は従来の env_file の並びを維持する。共通機密とプロジェクト機密のキーを 列挙しつつ、両方に同じキーがある場合は値として非機密設定側を採用する。 environment は env_file より優先されるため、こうしないと「プロジェクト設定が 共通設定を上書きする」関係が反転する - 実在しない env_file 参照は生成時に落とす。暗号化で平文が無くなった参照が 残っていると Compose が起動時に落ちるため 移行コマンド (encrypt / decrypt) を追加した。暗号化は「読み戻せることを確認して から平文を退避する」順序で行う。鍵の指定を誤ったまま平文を失うと、誰にも復号 できないファイルだけが残るため。退避した平文は自動では消さず、場所を案内する。 構成ファイルの書き換えは行のコメントアウトで行い、元の行をそのまま残す。YAML と して読み書きし直すと利用者のコメントや整形が失われること、および平文へ戻す操作で 元の行を機械的に復元できることの 2 点による。 コンテナ起動前の設定チェックは、ファイルの有無ではなく秘密ストアに設定があるかで 判定する。移行済みの環境で毎回 env init が走るのを避けるため。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: env encrypt/decrypt が途中で失敗しても中間状態を残さないようにする encrypt / decrypt はどちらも対象ごとにループ内で破壊的な操作を実行していたため、 後続の対象で失敗すると先行対象だけ移行済みになり、しかも compose.yml の書き換えは 実行されず「構成ファイルが存在しないファイルを参照する」壊れた状態で終わっていた。 また _apply_compose_changes は書き込み失敗をログに出して次のファイルへ進むため、 機密の移動・削除が済んだあとでもコマンドが成功扱いになっていた。 実行した操作ごとに取り消し手続きを積み、どこで失敗しても逆順に巻き戻す _Rollback を 追加し、encrypt / decrypt の両方で共有する。 - encrypt: 全対象の暗号化と読み戻し検証 → 平文の退避 → compose.yml の書き換え - decrypt: 全対象の復号確認 (生バイト列も控える) → 平文の書き出し → compose.yml の復元 → 最後に暗号文を削除。破壊的な削除を最後に置くことで、 途中で失敗したときに失うものを最小にする - _apply_compose_changes は書き込み失敗を MigrationError で呼び出し元へ伝え、 書けたぶんは巻き戻す。巻き戻し自体が失敗したら何が残っているかを列挙する Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: 部分復号・空行・注入スキップ・インライン env_file の取りこぼしを直す PR #93 のレビュー指摘 4 件に対応する。 - compose_migrate.enable() に disable() と同じ targets 引数を持たせた。 `env decrypt --project` で一部だけ復号したとき、これまでは compose 内の 全マーカーを戻していたため、まだ暗号化されたままの共通設定 (${DEVBASE_ROOT}/.env) の参照まで有効になり Compose の起動が失敗していた。 無効化と復元で同じ判定 (_compose_targets) を使うようにして揃えた。 キー行 (`env_file:`) は有効なエントリが 1 つ以上戻ったときだけ復元する。 - disable() が env_file リスト内の空行で break していたのをスキップに変えた。 空行以降のエントリを無効化し損ねるうえ、「有効なエントリ 0 件」と誤判定して `env_file:` キー自体をコメントアウトし、起動失敗を招いていた。ブロックの 終端判定はインデントが受け持つため、空行で止める必要はない。 - 機密注入のスキップ判定を (コマンド, サブコマンド) の組に変えた。args.command にはトップレベルしか入らないため、鍵がまだ無い / 復号できない状態で実行される `env keygen` / `env encrypt` / `env decrypt` でも注入が走っていた。 - 行単位では扱えない env_file 記法 (インライン配列・単一文字列) を検出して ファイルと行番号つきで警告するようにした。移行の対象から漏れることを黙って いると、利用者は壊れた構成のまま起動して初めて気付く。対応範囲はモジュールの docstring にも明記した。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: scale 生成で非機密 environment を残し、機密以外の env_file 欠落を隠さない 生成する構成ファイル (.docker-compose.scale.yml) の作り方を 2 点直した。 - environment を丸ごと落としていたため、元の compose.yml が持つ非機密の固定値や 機能フラグまで消え、スケールした途端に生成コンテナの挙動が変わっていた。 secret_env_names に挙がったキーだけを値なし参照へ置き換え、それ以外は値ごと 残すようにした。元の記法は尊重し、map なら値 None の map、list なら裸のキー名 として出力する。機密キーの値が生成ファイルに残らないことは従来どおり保証する。 - 実在しない env_file 参照を無条件に落としていたため、利用者のタイプミスや未配置 の必須設定まで黙って成功扱いになり、Compose が知らせてくれる構成不備を隠して いた。落とす対象を「暗号化移行で消える既知の機密参照」に限定し、判定は compose_migrate.is_secret_entry (新規の公開関数) に集約した。 既存テストのうち、environment を落とす前提だったものは新仕様に合わせて更新した。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: 移行の中断条件を厳しくし、compose.yml の書き込みと機密の渡し先を直す 暗号化移行が「機密だけ退避されて構成は壊れたまま成功する」経路を塞ぎ、 生成する構成で非 dev サービスに機密が渡らない問題を直す。 - compose.yml を読めない場合は警告してスキップせず MigrationError で移行 全体を中止する。飛ばして続けると平文だけが退避され、存在しないファイルを 指す参照が残ったままコマンドが成功してしまうため - 機密ファイルを指すインライン記法 (`env_file: [.env]` / `env_file: .env`) を検出したら移行を失敗させ、手で直してからの再実行を案内する。機密と 無関係なインライン記法は移行に影響しないので従来どおり警告のみ。判定は compose_migrate.secret_inline_env_file_lines() に切り出した - compose.yml の書き込みを write_secure_bytes_atomic へ差し替え、途中で 失敗しても部分的なファイルが残らないようにする。compose.yml は機密では ないため、既存ファイルの権限を読み取って mode に渡し 0600 へ落とさない - 元々機密ファイルを env_file で参照していた非 dev サービス (db など) にも 機密の変数名を列挙する。移行後は参照がコメントアウトされ YAML から消える ため、compose.yml の生テキストを見る compose_migrate.services_with_secret_env_file() で渡し先を決める。参照を 持たないサービスには従来どおり注入しない - 末尾スペース / 行末コメント付きのエントリ (`- ${DEVBASE_ROOT}/.env # 共通設定`) が正しく無効化・復元されることを示すテストを追加 (現行実装で処理済み) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: 機密は「そのサービスが元々参照していた由来」のキーだけに絞って渡す これまでは「機密参照を持つか」の真偽だけで渡し先を決めていたため、共通の .env だけを参照していた db のようなサービスにも、プロジェクト専用のトークン まで全件が environment へ列挙されていた。元々受け取っていなかった機密が渡る のは機密範囲の拡大にあたるため、由来 (共通 / プロジェクト) 単位で絞り込む。 - compose_migrate.services_with_secret_env_file() の戻り値を 「サービス名 → 参照種別の集合 (TARGET_GLOBAL / TARGET_PROJECT)」へ変更。 コメントアウト済みの参照も従来どおり種別つきで数える - runtime.SecretEnv に global_names / project_names を持たせ、names は 両者を畳んだ全体を返すプロパティへ (呼び出し側の互換は維持) - generate_scaled_compose は非 dev サービスへ、そのサービスが参照していた 由来のキーだけを列挙する。dev は従来どおり全件 - 生テキストを読めない場合の「dev のみ・全件」フォールバックは維持 既知の限界として、同じキーが共通機密とプロジェクト機密の両方にある場合は Compose が実行プロセスの環境変数から 1 つの値しか解決できないため、共通側 だけを参照していたサービスにも合成後 (プロジェクト優先) の値が渡る。値を サービスごとに変えるには生成ファイルへ機密の値を書く必要があり、本 PR の 前提と矛盾するため受け入れる。コードコメントと plan35.md §7 に明記した。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: env_file ブロック内のコメント行で走査を止めない `env_file:` 配下に利用者が書いた単独のコメント行があると `_LIST_ITEM_RE` に一致せず走査が打ち切られ、コメント行より後ろの機密参照が 無効化されないまま残っていた。その状態で平文を退避すると、Compose が存在 しないファイルを参照して起動できなくなる。 空行と同じく単独のコメント行も読み飛ばすようにし、ブロックの終端は インデントだけが決めるようにした。無効化済みの行 (DISABLED_MARK 付き) は 見た目がコメント行でも中身はエントリなので、コメント判定より先に除いて いる (順序を誤ると enable が何も復元できなくなる)。 disable / enable で重複していたブロック走査は `_scan_env_file_block` へ 括り出した。片方だけ直すと無効化と復元がずれるため。 `services_with_secret_env_file` も同じ理由で参照種別を取りこぼしていたので、 共通の `_is_skippable` で読み飛ばすようにした。 テストは、既存の test_user_comments_are_preserved が「何も書き換えられて いない」ために往復の一致だけで通っていた点を補強し (修正前にこのアサートが失敗することを確認済み)、コメント行の後ろの参照が 無効化されること・往復で元に戻ること・コメントと空行が混在する場合・ services_with_secret_env_file が種別を拾えることを追加した。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: env_file の long syntax・クォート付きサービス名・CRLF を取りこぼさない 行ベースの走査に残っていた 3 つの穴を塞ぎ、あわせて「何を扱い、何を扱わないか」 をモジュールの契約として docstring に書き出した。 - long syntax (`- path: .env`) を参照として認識する。1 行で閉じているものは 従来どおり無効化・復元し、`required: false` などの続きの行を持つ形・フロー 記法・シーケンスでない値は書き換えず、機密を指していれば移行を中止する。 続きの行で走査を打ち切らないので、その後ろに並ぶ機密参照も取りこぼさない。 - サービス名を YAML と同じ姿へ正規化する。`"db":` を引用符込みで記録すると パース済みの `db` と一致せず、そのサービスへ機密が渡らなかった。 - 各行の元の行末を保って書き換える。`rstrip('\n') + '\n'` で CRLF が LF に 変わり、encrypt → decrypt の往復で元の compose.yml に戻らなかった。移行 コマンド側も read_bytes で読み、改行コードを勝手に揃えないようにした。 インライン記法だけを対象にしていた中止判定は扱えない記法全体に広げ、名前を secret_unsupported_env_file_lines へ変更した。扱えない記法は黙って通さず、 必ず中止か警告のどちらかに落ちる不変条件を docstring に明記している。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: 単一文字列の env_file を中止せず移行対象に含める `env_file: .env` のように値が単一文字列で書かれた定義は、これまで「行単位では 扱えない記法」として扱い、機密を指す場合は移行ごと中止していた。利用者は手で `- ...` の並びへ書き換えないと暗号化できず、実質的に使えない状態だった。 この形はエントリが 1 つしかなく 1 行で完結するため、`env_file:` の行そのものを コメントアウトすれば安全に無効化でき、`enable` でも元のバイト列へ戻せる。 `_inline_scalar_ref` で「1 行で完結する単一文字列」だけを切り出し、disable / enable / 中止判定 (`secret_unsupported_env_file_lines`) の 3 箇所で同じ判定を 使うようにした。 フロー記法 (`env_file: [ ... ]` / `{ path: ... }`)、ブロックスカラー、閉じていない クォートなど 1 行で安全に判断できない記法は、従来どおり警告・中止のままにする。 機密を指さない単一文字列 (`env_file: config/app.env`) は触らない。モジュール 冒頭の「扱う記法 / 中止する記法 / 触らない記法」の契約も実装に合わせて更新した。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: 書き換え後の compose.yml を YAML として検証し機密参照の残りを検出する `env_file: >-` のようなブロックスカラーは先頭行に参照先が無く、行ベースの 走査では機密を指しているか判別できない。その結果 `env_file: >-` +次行 `.env` の構成は無効化も中止もされず、暗号化後も存在しない平文への参照が残ったまま コマンドが成功していた。モジュールが掲げる「扱えない記法は必ず中止か警告に 落ちる」という不変条件が破れている。 記法ごとに穴を塞ぐ対応では同種の見落としが出続けるため、記法の判別に依らない 事後検証を最後の砦として追加する: - `compose_migrate.remaining_secret_env_file_refs()` を追加。書き換え後の テキストを `yaml.safe_load` でパースし、各サービスの `env_file` に残った 機密参照を返す。文字列 / 文字列のリスト / long syntax の dict のいずれも 平坦化して拾う。無効化した行は YAML のコメントなのでパーサからは見えず、 残っていれば走査が取りこぼしたことを意味する - パースできない `compose.yml` は `ComposeParseError` を投げる。検証できない 以上「参照が無い」とは言い切れないため、読み取り失敗と同じ扱いで中止する - `env_migrate` の暗号化側で全 `compose.yml` に検証を掛け、残っていれば どのファイルのどのサービスにどの参照が残るかを示して `MigrationError` で 中止する。差分ゼロのファイルも対象にする (走査が何も見つけられなかった ファイルこそ取りこぼしの疑いが濃い) - 復号側では行わない。平文が戻る以上その参照は有効で正しく、ここで止めると 壊れた状態からの復帰手段を塞いでしまう 行ベースの走査は「うまく書き換えられれば書き換える、取りこぼしたら事後検証が 止める」という二段構えになる。既存の `secret_unsupported_env_file_lines()` はより早い段階で分かりやすいエラーを 出すための仕組みとして残す。設計意図はモジュール docstring とコード内 コメントに明記した。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: 退避先を排他的に作り、機密は原文のバイト列のまま往復させる - バックアップ先の衝突で過去の平文を失わないようにする 秒単位の日時ディレクトリは既存のバックアップと衝突しうる。従来は `exist_ok=True` で掘っていたため、衝突すると `shutil.move` が同名の `global.env` / プロジェクトの env を上書きし、「削除しないはずの過去の 平文」を失っていた。`_create_backup_dir` を追加して `exist_ok=False` で 排他的に作成し、既にあれば `-2` `-3` … と一意な名前へ寄せる。上限 (100 回) まで空きが無ければ平文に触れないまま中止する。退避先には平文の 機密が置かれるため 0700 で作る (親は他機能と共有するので既定のまま)。 - 往復でコメント・空行・`export` 表記が失われないようにする 暗号化時に平文を辞書へ畳んでいたため、`decrypt` してもコメント・空行・ `export KEY=...` 表記・値のクォートが戻らず、案内している「暗号化前の 状態へそのまま復帰」を満たしていなかった。`SecretStore` / `PlaintextBackend` / `AgeBackend` に生バイト列を扱う `save_bytes` / `load_bytes` を追加し、移行は原文のバイト列のまま暗号化・復元する。 読み戻し検証も「暗号化 → 復号 → 元のバイト列と一致」で維持する。 辞書経由の `save` / `load` はそのまま残し、`env set` などで値を書き換えた ときに正規化されるのは平文だけを使っていた頃と同じ挙動として変えない (「値を書き換えるまでは原文が保たれ、書き換えると正規化される」)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: プロジェクト切替で切替元の機密が環境変数に残らないようにする cli._load_secret_env は dispatch の前に「現在地のプロジェクト」の機密を os.environ へ載せるが、TUI や `project up <other>` の直接起動ではその後に 対象プロジェクトへ切り替わる。切替先に同名キーが無い機密 (切替元固有の トークン等) は載せ直しでは上書きされず残り、Compose や子プロセスへ 引き継がれてしまう。 - runtime.inject が「載せた変数名とその注入前の値」を記録し、 runtime.clear_injected で注入前の状態へ戻せるようにした。自分が載せた キーだけを対象にし、利用者がシェルで設定していた同名の変数は元の値へ 戻すので消えない - container._inject_secrets は対象プロジェクトへ chdir した後に呼ばれる ため、載せ直しの前に clear_injected を通して切替元の機密を落とす - _resolve_project_name は os.chdir と併せて PWD も切り替える。機密の 解決 (runtime.current_project_name) は wrapper の cd を前提に PWD を 先に見るため、PWD が切替前のままだと切替先ではなく呼び出し元の機密を 読んでしまい、載せ直しが機能しないため (TUI の _run_in_project と同様) 非機密設定 (env) 側の _CALLER_ENV_KEYS / _resolve_project_name と同じ性質を 機密にも与えることになる。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: 注入履歴を対象の環境マッピングごとに持たせる 注入履歴がモジュールレベルに 1 つしか無かったため、inject(environ=A) の後に clear_injected(environ=B) を呼ぶと、A に対して記録した内容で B を誤って 「復元」し、かつ A には機密が載ったまま残っていた。 履歴を「どの環境マッピングへ注入したか」と結び付け、clear_injected は同じ 対象に記録された履歴だけを解除して、その対象の履歴を破棄するようにした。 dict は hashable でないため id() をキーにするが、対象そのものへの参照も 一緒に保持し、id の再利用による誤爆を防ぐ。os.environ を既定対象とする 従来の使い勝手は変えていない。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: PLAN35-ops-docs 受信者更新・平文走査・ドキュメント (#94) * feat: 受信者の更新と平文の点検を追加し、書き出し・取り込みを暗号化へ揃える 暗号化は「平文がどこにも残っていないこと」で初めて意味を持つ。移行で取り残された 控えや除外設定の穴は黙って残り続けるため、点検する手段を用意して繰り返し確認 できるようにする。あわせて誰が復号できるかを変える手段を追加する。 - devbase env rekey: 受信者を足し引きし、暗号化済みの機密をまとめて暗号化し直す。 先に全件を復号してから書き直すため、途中で復号に失敗しても一部だけ新しい 受信者で暗号化された状態にならない。受信者リストが無い状態からの追加では 自分の公開鍵も登録する。登録しないと自分が受信者から外れ、自分の機密を 復号できなくなるため - devbase env doctor: 鍵の権限、保存先の衝突、退避された平文、日時付きの控え、 除外設定の穴を点検する。問題があれば非ゼロで返し、定期実行でも気付けるように した。権限は勝手に直さず、直し方だけを示す 書き出し・取り込みを秘密ストア経由へ揃えた。移行後の環境で取り込みが平文の .env を作ると、暗号化ファイルと平文が同時に存在する状態を自分で作り出して しまう。既存内容の読み取りと書き出しの両方をストア越しに行い、保存形式を 維持する。副次的に、取り込み前の控えも暗号文のまま保存されるようになり、 バックアップに平文が滞留する経路が塞がる。 除外設定に日時付きの控え (.env.bak-20260807172231 等) を追加した。完全一致の パターンでは弾けず、実際に未追跡のまま検出された経緯がある。 containers/lfm/compose.yml が YAML として壊れていた (8 行目に迷子の c) のを 直した。本方針の変更とは独立した既存の不具合だが、構成ファイルを機械的に 読む処理が増えるため、この機会に直しておく。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: rekey を巻き戻せる単位にし、除外設定のルート指定を誤検知しないようにする ## env rekey が途中失敗で不整合を残す問題 受信者リストを先に更新したあとに暗号文の書き直しが失敗すると、旧受信者宛と 新受信者宛の暗号文が混在した。自分の鍵を外す操作では残った旧暗号文をもう 復号できないため、`devbase env rekey` の再実行でも復旧できなくなる。 env_migrate の `_Rollback` を `devbase.env.rollback.Rollback` へ移して共有し、 rekey を「全件の新しい暗号文を用意 → 受信者リストを更新 → 各暗号文を差し替え」 の順で適用する単一のトランザクションにした。旧暗号文と旧受信者リストは生の バイト列で控え、どこで失敗しても逆順に書き戻す。破壊的な操作を後ろへ寄せる 考え方と、巻き戻し自体が失敗したときに何が残っているかを列挙する挙動は env_migrate から引き継いでいる。 ## env doctor が除外設定のルート指定を誤検知する問題 `/.env` や `/secrets/` のように先頭に `/` を付けたリポジトリルート指定が 考慮されておらず、正しく除外できているのに「不足」と報告していた。正しい 設定を毎回叱るのは点検コマンドとして害になる。 判定を `_normalize_ignore_pattern` へ括り出し、先頭の `/` と `**/`、末尾の `/`、行末コメント、前後の空白を吸収してから突き合わせるようにした。日時付き 控えの判定も完全一致の前方後方比較をやめ、代表的な名前 `.env.bak-<日時>` に 実際にマッチするかで見る (`.env*` のような広い指定も拾えるようにするため)。 `!` の再包含や配下の一部だけの除外は従来どおり不足として報告する。 どこまでを許容しどこからを検出漏れとして受け入れるかは docstring に記した。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: 除外設定の点検を git check-ignore に委ね、誤って「除外できている」と言わないようにする `.gitignore` を独自に文字列正規化して判定していたため、Git の実際の解釈と 食い違っていた。 - Git は行頭の `#` だけをコメントとして扱うのに行末コメントを落としていたため、 `.env # 機密` (実際には `.env # 機密` というパターン) を「`.env` を除外している」 と誤判定していた - 後段の `!` による再包含を見ていなかったため、`.env` の後に `!.env` があっても 「除外されている」と判定していた - 行頭の空白も同様に落としていたが、Git は落とさない いずれも「除外できていないのに doctor が成功する」方向の誤りで、平文の誤コミット につながる。`.gitignore` の解釈は Git の実装が正であり、独自に真似る限り同種の 食い違いは残るため、判定そのものを Git へ委ねる。 - 代表パス (`.env` / `.env.bak-<日時>` / `secrets/` 配下 / `projects/<name>/.env`) を `git check-ignore --no-index` で評価し、除外されないものを実パスで報告する - `git` が無い / `DEVBASE_ROOT` が Git リポジトリでない場合は「確認できませんでした」 と報告する。文字列判定へはフォールバックしない (不正確な判定を残さないため) - 不要になった `_normalize_ignore_pattern` と `fnmatch` による突き合わせを削除 - テストは実際に `git init` した一時リポジトリで Git の解釈と一致することを確かめる Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: PLAN35 の完了サマリを plan へ追記 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Z922jZ4R3488KR3GETgS1 * fix: env edit で原文のまま編集・保存しコメントを保持する 暗号化された設定を `devbase env edit` で開くと、辞書経由 (dump_bytes / parse_bytes) で往復していたためコメント・空行・export 表記が失われていた。 SecretEnvFile に load_bytes / save_bytes を追加し、_edit_encrypted は 編集後のバイト列をそのまま保存するようにした。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lzjfzt1hB9Bt8aV359dvW4 * fix: up は復号と構成生成を既存コンテナ停止の前に完了させる 鍵の紛失・権限不備・暗号文の破損で復号に失敗すると、直前の docker compose down により稼働中の開発環境まで止まったままになっていた。 - cmd_up の順序を「生成 (=復号) → 停止 → 起動」へ変更 - 停止には生成前の構成を渡す (_previous_scale_compose)。生成は .docker-compose.scale.yml を上書きするため、新構成で停止すると スケールを縮める起動で旧インスタンスが取り残される - 生成が途中で失敗した場合は旧構成を書き戻す - 退避ファイル (.docker-compose.scale.yml.prev) を .gitignore へ追加 - 回帰テスト tests/commands/test_container_up_order.py を追加 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lzjfzt1hB9Bt8aV359dvW4 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
機密を平文ファイルを介さずにコンテナへ届ける経路へ移行する。起動ラッパーが共通の機密ファイルを読み込むのをやめ、機密は Python 本体が必要になった時点で復号してプロセス内で合成する。コンテナへは変数名だけを列挙した構成で渡すため、暗号文も平文ファイルも Docker Compose には渡らない。
あわせて、平文と暗号化構成のあいだを往復する移行コマンドを追加する。
なぜラッパーから外す必要があるのか
これまで機密を読む経路は 2 つあった。起動ラッパーによる
sourceと、各プロジェクトのcompose.ymlのenv_fileである。前者はシェルから読める場所に平文が必要で、後者は暗号文をそのまま値として読んでしまう。ファイルを暗号化するだけでは、コンテナに壊れた値が入って起動が失敗する。 この 2 経路の切り替えが本 PR の中身になる。ホスト側で機密を必要とする処理の洗い出し
移行前に、ラッパーが読み込んだ値に依存するホスト側処理を実測で洗い出した。該当したのは 2 系統だけだった。
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY(ローカル S3 互換サービスの資格情報として展開される)docker compose buildが展開しうるdevbase env exec経由で呼び、Python 側から渡すDEVBASE_ROOT/DOCKER_GID/COMPOSE_PROJECT_NAMEは機密ではなく、ラッパーが従来どおり自前で設定する。変数名だけを渡す
台数拡張時に生成する構成へ、値ではなく変数名を書き込む。
別ファイルの上書きを重ねる方式ではなく生成物そのものへ書くため、台数を増やした構成での適用順序という論点が発生しない。Compose v5.1.4 で、実行プロセス側に無い変数は失敗ではなく空として扱われることも確認した。
渡す範囲は元の構成が参照していたとおりに絞る。共通の設定だけを読んでいたサービスへプロジェクト専用の機密を渡すことはない。既存の
environmentは保持し、機密のキーだけを値なしの参照へ置き換えるため、非機密の固定値や機能フラグは失われない。重ね順は従来の
env_fileの並びを維持する。非機密設定に同じキーがある場合は値としてそちらを採用する —environmentはenv_fileより優先されるため、こうしないと「プロジェクト設定が共通設定を上書きする」という関係が反転する。プロジェクトを切り替えたときは、切替元で載せた機密を注入前の状態へ戻してから切替先の機密を載せる。上書きするだけでは、切替先に同名のキーが無い機密が残留して子プロセスへ引き継がれる。
移行コマンド
devbase env encrypt/decryptを追加した。往復して元の状態に戻せる。backups/env-encrypt/<日時>/へ排他的に作った場所へ退避し、削除は利用者に委ねて場所を案内する。既存のバックアップを上書きすることはないexport表記も往復で失われない (値を書き換えると従来どおり正規化される)compose.ymlを黙って書き換えない。--dry-runで差分だけ確認できるdecryptで機械的に復元できる。改行コードも元のまま保つ--project NAMEを付けた場合は共通設定を対象から外す。「このプロジェクトだけ」と言われたのに全体へ効く設定まで動かさない構成の書き換えを二段構えで保証する
compose.ymlの書き換えは行単位で行うが、YAML にはenv_fileの書き方が複数ある (リスト / 単一文字列 / long syntax / フロー記法 / ブロックスカラー)。行単位の走査が未知の記法を取りこぼすと、平文を退避した後に存在しないファイルを参照したまま成功してしまう。そこで書き換え後のテキストを YAML としてパースし直し、機密参照が残っていないことを確認してから移行を進める。取りこぼしがあれば、どのファイルのどのサービスに何が残っているかを示して中止する。行単位の走査が扱えない記法に当たった場合も、黙って通さず必ず中止か警告に落ちる。
Test plan
tests/env/test_runtime.py— 重ね順、非機密設定による上書き、列挙する変数名の範囲、切替時の残留除去、対象マッピングごとの復元tests/env/test_compose_migrate.py— 無効化と復元の往復 (コメント・空行・CRLF・行末コメントを含む)、記法ごとの扱い、事後検証、参照種別の判定tests/commands/test_env_migrate.py— 平文の退避、読み戻し検証の失敗時に平文を失わないこと、途中失敗の巻き戻し、バックアップの非上書き、原文のバイト単位往復、--dry-run、--projectの範囲tests/volume/test_compose_secret_env.py— 生成物に機密の値が残らないこと、参照の由来に応じた絞り込み、非機密のenvironmentの保持、実在しない機密参照だけの除去tests/cli/test_wrapper_secrets.py/test_secret_injection.py— ラッパーが機密ファイルをsourceしないこと、注入をスキップするコマンドの範囲ruff check --select=E9,F63,F7,F82 libパスkeygen→encrypt→ 生成構成に値が無いこと →env execで値が子プロセスに渡ること →decryptでcompose.ymlと平文が元どおりに戻ること開発用: 関連情報 (レビュー対象外)
issues/plan35.md(段階 3・4・5 移行)containers/lfm/compose.ymlが YAML として壊れている (8 行目に迷子のc) ことが判明。PR4 で修正する