diff --git a/CHANGELOG.md b/CHANGELOG.md index 23027d4..6e950a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,15 @@ - 暗号化が未設定のバケットへ export する場合は `--unsafe-allow-unencrypted-bucket` の明示が必要です (オブジェクト単位の SSE はこのフラグに関係なく常に付与されます)。 - SSE 種別 (`DEVBASE_S3_SSE`) / KMS 鍵 (`DEVBASE_S3_SSE_KMS_KEY_ID`) / エンドポイント (`DEVBASE_S3_ENDPOINT_URL`) / リージョン (`DEVBASE_S3_REGION`) は環境変数で上書きできます。MinIO / LocalStack の利用も可能です。 - `boto3` は main dependency として常に同梱されます (S3 を使わないユーザにも 25MB 程度入りますが、引数検出や lazy install の複雑さを避けるトレードオフです)。 +- `devbase env export` / `devbase env import` の利用者向けドキュメント [`docs/user/env-export-import.md`](docs/user/env-export-import.md) を新設しました (PLAN03-1 PR5)。 + - バンドル構造、age 暗号化 (recipient / identity / passphrase)、入出力先 (local / stdio / S3)、merge モード比較、`.env.sources.yml` の扱い、2 フェーズ書き込みとバックアップ、典型ワークフロー、トラブルシューティングまでを網羅します。 + - README と環境変数ガイドからのリンクも追加しました。 ### Changed - `gs://` (GCS) スキームは **PLAN03-1 PR4 廃案** により対応しません。指定すると明示的なエラーメッセージで失敗します (旧: "未実装")。 +- `lib/devbase/env/` 配下の export / import モジュールをリファクタリングしました (PLAN03-1 PR5)。公開 API (`ExportOptions`, `ImportOptions`, `export`, `import_bundle`) に互換性のない変更はありません。 + - export / import で重複していた passphrase 読み取り / 既定鍵 fallback / セキュアな bytes 書き込みを `io_common.py` に集約。 + - 711 行に肥大化していた `io_import.py` を「orchestration (`io_import.py`, 209 行)」「merge 計画 (`_import_merge.py`)」「2 フェーズ atomic 書き込み + backup GC (`_import_atomic.py`)」の 3 モジュールに分割。 ## [2.2.0] - 2026-04-20 diff --git a/README.md b/README.md index 9f1cab5..9635598 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ devbaseのコマンドは4つのグループにまとめられています。 | グループ | 略記 | 説明 | |---------|------|------| | `container` | `ct` | コンテナ管理(up / down / login / ps / logs / scale / build) | -| `env` | — | 環境変数管理(init / sync / list / set / get / delete / edit / project) | +| `env` | — | 環境変数管理(init / sync / list / set / get / delete / edit / project / export / import) | | `plugin` | `pl` | プラグイン管理(list / install / uninstall / update / info / sync / repo) | | `snapshot` | `ss` | スナップショット管理(create / list / restore / copy / delete / rotate) | @@ -106,6 +106,7 @@ devbaseのコマンドは4つのグループにまとめられています。 | [CLIリファレンス](docs/user/cli-reference.md) | 全コマンドの構文・オプション・使用例 | | [プラグインレジストリ](docs/user/plugin-registries.md) | 公開・社内レジストリの一覧と追加方法 | | [環境変数ガイド](docs/user/environment-variables.md) | 3レベル構造、コレクター、ソース同期 | +| [環境変数の export/import ガイド](docs/user/env-export-import.md) | バンドル形式・age 暗号化・S3 連携・merge/replace の運用 | | [コンテナ操作ガイド](docs/user/container-operations.md) | ライフサイクル、並行開発、ボリューム構造 | | [スナップショットガイド](docs/user/snapshot-guide.md) | 増分バックアップ、世代管理、復元手順 | | [トラブルシューティング](docs/user/troubleshooting.md) | カテゴリ別の問題と解決策 | diff --git a/docs/user/env-export-import.md b/docs/user/env-export-import.md new file mode 100644 index 0000000..3b363b7 --- /dev/null +++ b/docs/user/env-export-import.md @@ -0,0 +1,456 @@ +# 環境変数の export / import ガイド + +`devbase env export` / `devbase env import` は、複数プロジェクトにまたがる `.env` 群を **暗号化したまま 1 つのバンドル**にまとめ、別マシン・別ストレージ・チーム内で再利用するためのコマンドです。 + +> **どんなときに使うか** +> - 新しい開発マシン / WSL / コンテナで `devbase` を再構築するときに、認証情報・API キー一式を一括移植したい +> - チームで「同じ環境」を共有したい (S3 経由でローテ済みクレデンシャルを配布する等) +> - 個別 `.env` を `scp -r` する代わりに、機密を暗号化したまま安全に転送したい + +## 目次 + +- [概要 / 対象ファイル](#概要--対象ファイル) +- [クイックスタート](#クイックスタート) +- [バンドル構造](#バンドル構造) +- [暗号化 (age)](#暗号化-age) +- [入出力先 (ローカル / stdio / S3)](#入出力先-ローカル--stdio--s3) +- [`devbase env export` リファレンス](#devbase-env-export-リファレンス) +- [`devbase env import` リファレンス](#devbase-env-import-リファレンス) +- [`.env.sources.yml` の扱い](#envsourcesyml-の扱い) +- [バックアップとロールバック](#バックアップとロールバック) +- [典型ワークフロー](#典型ワークフロー) +- [トラブルシューティング](#トラブルシューティング) + +## 概要 / 対象ファイル + +`devbase env export` がバンドルに含めるのは以下の 3 種類のファイルです: + +| ファイル | 役割 | 機密性 | 既定で含む | +|---|---|---|:---:| +| `$DEVBASE_ROOT/.env` | グローバル変数 (`AWS_CONFIG_BASE64` などの認証情報) | 高 | ✓ | +| `$DEVBASE_ROOT/projects//.env` | プロジェクト固有変数 (API キー / DB パスワード等) | 高 | ✓ | +| `$DEVBASE_ROOT/.env.sources.yml` | コレクターの同期メタデータ (ファイルハッシュ / 同期時刻) | 中 | ✓ | + +`$DEVBASE_ROOT/projects//env` (公開可能な雛形, git 管理対象) は **対象外** です。雛形は git で配布する設計のためバンドルに含めません。 + +3 レベル構造の全体像は [環境変数ガイド](environment-variables.md) を参照してください。 + +## クイックスタート + +```bash +# 既存マシンで export (~/.ssh/id_ed25519.pub があれば暗号化キー指定省略可) +devbase env export ./bundle.dbenv + +# バンドルを転送 (scp / S3 / メール添付など、暗号化済みなので経路は問わない) +scp ./bundle.dbenv newhost:/tmp/ + +# 新しいマシンで import (~/.ssh/id_ed25519 があれば identity 指定省略可) +ssh newhost +devbase env import /tmp/bundle.dbenv +``` + +import は **既定で `merge=keep-existing`** です。既存の `.env` に同じキーがあれば**保持**し、新規キーだけが追加されます。確認したいときは: + +```bash +devbase env import /tmp/bundle.dbenv --dry-run +``` + +書き込みは行わず、追加 / 上書き / スキップされるキーが一覧表示されます。 + +## バンドル構造 + +バンドルファイル ( `*.dbenv` または `*.dbenv.tar.gz` ) は内部的に **tar.gz**、外側を **age** で暗号化した 1 つのファイルです。 + +``` +manifest.yml # version / created_at / 各ファイルの sha256 +env/global.env # $DEVBASE_ROOT/.env をそのままコピー +env/sources.yml # .env.sources.yml (--no-metadata で除外可) +env/projects//.env +... +``` + +`manifest.yml` の例: + +```yaml +version: 1 +created_at: '2026-05-21T10:00:00+09:00' +devbase_version: 2.2.0 +files: + - path: env/global.env + sha256: <64 文字 hex> + origin: $DEVBASE_ROOT/.env + - path: env/projects/carmo/.env + sha256: <64 文字 hex> + origin: $DEVBASE_ROOT/projects/carmo/.env +``` + +import 時は以下を検証します: + +- `manifest.version` が devbase 本体のサポート最大値以下であること +- 各ファイルの sha256 が manifest と一致すること +- manifest に記載のないファイルがバンドル内に存在しないこと + +`version` が大きすぎる場合 (= 新しい devbase で作られたバンドルを古い devbase で開いた場合) は明示的にエラーになり、devbase 本体の更新を促すメッセージが出ます。 + +## 暗号化 (age) + +devbase は [age](https://age-encryption.org/) (`pyrage` 同梱) でバンドルを暗号化します。鍵の渡し方は **recipient 公開鍵** / **identity 秘密鍵** / **passphrase** の 3 通りで、export と import で対称に使い分けます。 + +### 鍵種別 + +| 鍵 | recipient (export 用) | identity (import 用) | 備考 | +|---|---|---|---| +| age X25519 (`age-keygen` 生成) | `age1...` | `AGE-SECRET-KEY-1...` | age ネイティブ、最も推奨 | +| OpenSSH ed25519 (`~/.ssh/id_ed25519`) | `ssh-ed25519 AAAA...` | `~/.ssh/id_ed25519` | そのまま使える | +| OpenSSH RSA (`~/.ssh/id_rsa`) | `ssh-rsa AAAA...` | `~/.ssh/id_rsa` | そのまま使える | +| OpenSSH ECDSA / DSA | ✗ | ✗ | **age 非対応**。下記参照 | +| scrypt パスフレーズ | (鍵不要) | (鍵不要) | `--passphrase-env` / `--passphrase-stdin` | + +`--recipient` には公開鍵文字列を直接渡すか、`@PATH` でファイル参照できます (例: `--recipient @~/.ssh/id_ed25519.pub`)。`--identity` は秘密鍵ファイルのパスを渡します。 + +### 既定鍵 + +- export の `--recipient` 省略時: **`~/.ssh/id_ed25519.pub` → `~/.ssh/id_rsa.pub`** を順に探し、存在する公開鍵を使用 +- import の `--identity` 省略時: **`~/.ssh/id_ed25519` → `~/.ssh/id_rsa`** を順に探し、存在する秘密鍵を使用 + +どちらも存在しない場合はエラーになります。明示指定 (`--recipient` / `--identity`) するか、`age-keygen` で age 専用鍵を生成してください。 + +### ssh-ecdsa 鍵しか持っていない場合 + +age は ssh-ecdsa / ssh-dss に対応していません。`ssh-ed25519` をまだ持っていない場合は、いずれかの方法で鍵を用意してください: + +```bash +# 方法 1: ed25519 鍵を作る (汎用、SSH と兼用可) +ssh-keygen -t ed25519 + +# 方法 2: age 専用鍵を作る (この用途だけに使いたい場合) +age-keygen -o ~/.config/devbase/age.key +# 公開鍵は最後の行に "Public key: age1..." と表示される +``` + +### passphrase ベース + +CI など鍵配布が難しい環境では passphrase 方式が使えます。**コマンドラインに直接書かない** (プロセス一覧に残るため) のがルールです。 + +```bash +# 環境変数経由 +DEVBASE_BUNDLE_PASS='change-me-strong' devbase env export ./bundle.dbenv \ + --passphrase-env DEVBASE_BUNDLE_PASS + +# stdin 経由 (パイプ運用) +echo 'change-me-strong' | devbase env export ./bundle.dbenv --passphrase-stdin +``` + +> tty で `--passphrase-stdin` を指定した場合は `getpass` でエコー抑止された対話プロンプトに切り替わるので、パスフレーズが画面に出ません。 + +### 平文 export (デバッグ用途のみ) + +通常は暗号化必須ですが、デバッグや構造確認のためにあえて平文で書き出したい場合は `--force-unencrypted` を明示します: + +```bash +devbase env export ./bundle.dbenv.tar.gz --force-unencrypted +``` + +- 拡張子は意図的に `*.dbenv.tar.gz` (拡張子で暗号化有無を判別できるようにするため) +- ファイル中に `KEY` / `SECRET` / `TOKEN` / `PASSWORD` / `CREDENTIALS` / `BASE64` を含むキーが見つかると **強い警告**が出ます +- ファイルパーミッションは引き続き `0600` で書き出されます + +## 入出力先 (ローカル / stdio / S3) + +`DEST` / `SOURCE` には以下を指定できます: + +| 形式 | 例 | 用途 | +|---|---|---| +| ローカルファイル | `./bundle.dbenv`, `/tmp/x.dbenv` | 既定。1 ファイルとして保存 | +| `file://` URI | `file:///tmp/x.dbenv`, `file://localhost/tmp/x.dbenv` | URI 形式が必要なツールとの連携 | +| stdio | `-` | パイプ運用 (gpg / age と組み合わせる、ssh 経由など) | +| S3 URI | `s3://bucket/path/to/bundle.dbenv` | チーム共有 / クラウドバックアップ | + +### S3 の暗号化要件 + +S3 への書き込み時は以下が **常に** 適用されます (`--force-unencrypted` でも上書きできません): + +- オブジェクト個別の SSE (`aws:kms` 既定、`AES256` も選択可) +- export 前にバケット側の既定暗号化を `GetBucketEncryption` で確認 + - 未設定の場合は **export を拒否** (`--unsafe-allow-unencrypted-bucket` で明示的にバイパスは可能) + - `AccessDenied` で確認できない場合も既定では拒否 (権限を付けるか同フラグでバイパス) + +S3 関連の環境変数: + +| 変数 | 役割 | 既定 | +|---|---|---| +| `DEVBASE_S3_SSE` | オブジェクト単位の SSE 種別 (`aws:kms` / `AES256`) | `aws:kms` | +| `DEVBASE_S3_SSE_KMS_KEY_ID` | `aws:kms` 時の KMS 鍵 ID | (バケット既定) | +| `DEVBASE_S3_ENDPOINT_URL` | カスタムエンドポイント (MinIO / LocalStack 用) | (AWS S3) | +| `DEVBASE_S3_REGION` | リージョン上書き | (AWS SDK 設定に依存) | + +`AWS_PROFILE` / `AWS_REGION` / `AWS_ACCESS_KEY_ID` 等 boto3 が認識する標準変数はそのまま尊重されます。 + +### stdio (パイプ運用) + +`DEST='-'` / `SOURCE='-'` で stdout / stdin を使えます。GPG など別の暗号化ツールと組み合わせたい場合に便利です: + +```bash +# devbase の age 暗号化を切って GPG で再暗号化したい (極めて例外的な構成) +devbase env export - --force-unencrypted | gpg --encrypt -r alice@example.com > bundle.gpg + +# 逆方向 +gpg --decrypt bundle.gpg | devbase env import - +``` + +> **制約**: `DEST='-'` / `SOURCE='-'` と `--passphrase-stdin` は **併用不可** (stdin/stdout が衝突するため明示的にエラーにします)。 + +## `devbase env export` リファレンス + +``` +devbase env export [DEST] [options] +``` + +### 引数 + +- `DEST`: 出力先。省略時は `./devbase-env-.dbenv` (`--force-unencrypted` 時は `.dbenv.tar.gz`) + +### オプション + +| オプション | 説明 | +|---|---| +| `--include-project NAME` | 対象プロジェクトを限定 (複数指定可) | +| `--exclude-project NAME` | 除外プロジェクト (複数指定可) | +| `--no-global` | グローバル `.env` を含めない | +| `--no-metadata` | `.env.sources.yml` を含めない | +| `--force-unencrypted` | 平文 tar.gz として書き出す (機密キー検知時は警告) | +| `--recipient KEY` | age 公開鍵で暗号化 (複数指定可)。`age1...` / `ssh-ed25519 ...` / `ssh-rsa ...` / `@PATH` | +| `--passphrase-env VAR` | 環境変数 VAR からパスフレーズ取得 | +| `--passphrase-stdin` | stdin の最初の行をパスフレーズとして使用 | +| `--unsafe-allow-unencrypted-bucket` | S3: バケット既定暗号化未設定でも export を許可 (オブジェクト単位の SSE は引き続き付与) | + +### 使用例 + +```bash +# 既定鍵 (~/.ssh/id_ed25519.pub or id_rsa.pub) で暗号化 +devbase env export ./bundle.dbenv + +# 複数 recipient (チームメンバー全員に配布) +devbase env export ./team.dbenv \ + --recipient @~/.ssh/id_ed25519.pub \ + --recipient 'age1abc...' \ + --recipient @charlie.pub + +# 特定プロジェクトのみ +devbase env export ./carmo.dbenv --include-project carmo + +# S3 に直接保存 (KMS 暗号化) +DEVBASE_S3_SSE_KMS_KEY_ID=alias/devbase \ + devbase env export s3://my-bucket/envs/2026-05-23.dbenv \ + --recipient @~/.ssh/id_ed25519.pub +``` + +## `devbase env import` リファレンス + +``` +devbase env import SOURCE [options] +``` + +### 引数 + +- `SOURCE`: 入力元。ローカルパス / `s3://...` / `-` (stdin) + +### オプション + +| オプション | 説明 | +|---|---| +| `--merge MODE` | キー単位マージ。`keep-existing` (既定) / `prefer-incoming` | +| `--replace-keys KEY,...` | 指定キーのみバンドル値で上書き (残りは keep-existing 相当) | +| `--replace` | 既存 `.env` を丸ごと差し替え (バックアップは取る) | +| `--dry-run` | 書き込まず差分のみ表示 | +| `--identity FILE` | age / OpenSSH 秘密鍵ファイル (複数指定可) | +| `--passphrase-env VAR` | 環境変数 VAR からパスフレーズ取得 | +| `--passphrase-stdin` | stdin の最初の行をパスフレーズとして使用 | +| `--include-project NAME` | 対象プロジェクトを限定 | +| `--exclude-project NAME` | 除外プロジェクト | +| `--no-global` | グローバル `.env` を import しない | +| `--no-metadata` | バンドル内 sources.yml を完全に無視 | +| `--merge-metadata` | sources.yml で新規 source エントリのみ追加 | +| `--backup-dir DIR` | 上書き前バックアップの保存先 (既定: `$DEVBASE_ROOT/backups/env-import/`) | +| `--keep-last N` | backup-dir 内の古い backup を最新 N 個に整理 (既定 10、0 で無効) | + +### merge モード比較 + +| モード | 既存にある同名キー | 既存に無いキー | 主な用途 | +|---|---|---|---| +| `--merge keep-existing` (既定) | **既存を残す** (skip) | 追加 | 既存環境を壊さず新規キーだけ取り込む | +| `--merge prefer-incoming` | バンドル値で上書き | 追加 | ローテ済みクレデンシャルを一斉配布 | +| `--replace-keys K1,K2,...` | K1/K2 のみ上書き、それ以外は keep-existing | 追加 | 特定キーだけピンポイント更新 | +| `--replace` | (ファイル単位で) バンドル内容で**丸ごと差し替え** | 追加 | クリーンな再同期 (バックアップ必須) | + +`--replace` 以外のモードは、既存 `.env` 内の **コメント・空行・キー順** を保持したまま値だけ差し替えます。 + +### 使用例 + +```bash +# 既定: 既存を保持しつつ新規キーのみ追加 +devbase env import ./bundle.dbenv + +# 何が起こるか先に見たい +devbase env import ./bundle.dbenv --dry-run + +# ローテ済み credentials を一斉配布 +devbase env import ./bundle.dbenv --merge prefer-incoming + +# 特定キーだけ更新 (例: AWS credentials のローテ) +devbase env import ./bundle.dbenv --replace-keys AWS_CONFIG_BASE64,AWS_SESSION_TOKEN + +# 特定プロジェクトだけ復元 +devbase env import ./bundle.dbenv --include-project carmo + +# S3 から取得 + passphrase で復号 +devbase env import s3://my-bucket/envs/2026-05-23.dbenv \ + --passphrase-env DEVBASE_BUNDLE_PASS +``` + +## `.env.sources.yml` の扱い + +`.env.sources.yml` には **マシン固有の絶対パス・同期時刻・元ファイルのハッシュ** が含まれます。別マシンでそのまま上書きすると整合性が壊れるため、以下のポリシーで扱います: + +- **既定**: import 時に既存 `.env.sources.yml` は **上書きしない**。バンドル内の sources.yml は `backups/env-import//sources.yml.imported` に参照用コピーのみ残す +- `--no-metadata`: バンドル内 sources.yml を完全に無視 (既定挙動と等価だが明示用) +- `--merge-metadata`: バンドル側で新規に登場する source エントリのみ追加 (既存エントリの `origin_path` / `synced_at` などのマシン固有値は再計算されず、import 先環境の値が保持される) + +## バックアップとロールバック + +import は部分適用を最小化するため **2 フェーズ書き込み** + **ファイル単位 backup** で動きます。 + +1. **Phase 0 (backup)**: 全対象ファイルを `backups/env-import//` にコピー (元ファイルが存在する場合のみ) +2. **Phase 1 (prepare)**: 全ファイルの新内容を `.import.tmp` に 0600 で書き出し、全件成功するまで rename しない +3. **Phase 2 (commit)**: 全 tmp の書き出し成功を確認してから `os.replace` で順次差し替え + +Phase 2 の途中で失敗した場合は backup から **best-effort で `_rollback()`** します。元ファイルが無かった (= 新規作成) ファイルは backup が無いので unlink で削除し、元の「不在」状態に戻します。 + +> **重要**: OS / FS の制約上、厳密な ACID は保証しません (途中の電源断やディスク full などは tmp 残骸を残しうる)。本来は別マシンへの初回投入のような大移動でのみ使い、稼働中の環境では `--dry-run` で確認してから実行してください。 + +### backup ディレクトリ + +``` +$DEVBASE_ROOT/backups/env-import/ + 20260523-101530-123456/ # ts (microsecond + 連番付き、衝突回避) + .env # 既存 global .env のコピー + projects/alpha/.env # 既存 project .env のコピー + sources.yml.imported # バンドル内 sources.yml の参照用コピー + 20260524-094210-456789/ + ... +``` + +ディレクトリ名はタイムスタンプ命名 (`YYYYMMDD-HHMMSS[-NNNNNN[-NN]]`)。同一秒に複数回 import しても上書きされません。 + +### 古い backup の GC + +`--keep-last N` (既定 10) で古い backup を自動 GC します: + +```bash +# 最新 5 個だけ残す +devbase env import ./bundle.dbenv --keep-last 5 + +# GC 無効化 +devbase env import ./bundle.dbenv --keep-last 0 +``` + +GC 対象は **devbase が生成するタイムスタンプ命名のディレクトリのみ**。`--backup-dir` で指定した親ディレクトリに無関係なファイル / サブディレクトリがあっても、それらは触らない設計です。 + +## 典型ワークフロー + +### A. 新しいマシンへ移行 + +```bash +# 既存マシン +devbase env export ~/devbase-2026-05-23.dbenv + +# 転送 (経路はなんでも良い、暗号化済み) +scp ~/devbase-2026-05-23.dbenv newhost:~ + +# 新マシン +ssh newhost +devbase env import ~/devbase-2026-05-23.dbenv +devbase env list # 復元確認 +``` + +### B. 単一マシンの定期バックアップ + +```bash +# cron で週次バックアップ (~/.ssh/id_ed25519.pub があれば鍵指定不要) +0 3 * * 0 cd /home/me/devbase && devbase env export \ + ~/backups/devbase-$(date +\%Y\%m\%d).dbenv +``` + +### C. S3 経由のチーム共有 + +```bash +# 管理者: ローテ済みクレデンシャルを team 全員の鍵で暗号化して S3 へ +devbase env export s3://team-secrets/devbase/latest.dbenv \ + --recipient @keys/alice.pub \ + --recipient @keys/bob.pub \ + --recipient @keys/charlie.pub + +# 各メンバー: 既存キーは保持しつつローテ分だけ更新 +devbase env import s3://team-secrets/devbase/latest.dbenv \ + --replace-keys AWS_CONFIG_BASE64,GCP_CREDENTIALS_BASE64_default +``` + +### D. CI でローテキーを配布 + +```bash +# CI ジョブ: secret manager から passphrase を取って復号 +export DEVBASE_BUNDLE_PASS=$(aws secretsmanager get-secret-value \ + --secret-id devbase/bundle-pass --query SecretString --output text) + +devbase env import s3://team-secrets/devbase/latest.dbenv \ + --passphrase-env DEVBASE_BUNDLE_PASS \ + --merge prefer-incoming +``` + +## トラブルシューティング + +### `バンドルは暗号化されていますが復号キーが指定されていません` + +`~/.ssh/id_ed25519` も `~/.ssh/id_rsa` も無く、`--identity` / `--passphrase-*` も指定されていない状態です。export 時に使った鍵に対応する秘密鍵を `--identity` で渡してください。 + +### `OpenSSH 秘密鍵の解釈に失敗しました` / `age は ssh-ed25519 / ssh-rsa のみ対応です` + +age が **ssh-ecdsa / ssh-dss に対応していない**ことが原因です。`age-keygen` で age 専用鍵を作るか、`ssh-keygen -t ed25519` で ed25519 鍵を作ってください。 + +### `passphrase 復号に失敗しました (パスフレーズが誤っている可能性があります)` + +パスフレーズが間違っているか、バンドルが破損しています。`--passphrase-env` で渡した変数の中身に余計な改行 / 空白が無いかを確認してください。 + +### `manifest.version=N はこの devbase ではサポートされていません` + +新しい devbase で作られたバンドルを古い devbase で開こうとしています。devbase 本体を更新してください。 + +### `S3 バケット 'X' のデフォルト暗号化が未設定です` + +S3 バケットに既定の SSE が設定されていません。以下のいずれかで対応: + +```bash +# 推奨: バケット側に SSE を有効化 +aws s3api put-bucket-encryption --bucket X --server-side-encryption-configuration ... + +# あるいは明示的にバイパス (オブジェクト単位の SSE は引き続き付与される) +devbase env export s3://X/key --unsafe-allow-unencrypted-bucket ... +``` + +### `平文 export に機密キーが含まれます` + +`--force-unencrypted` で平文 tar.gz を作ろうとし、`AWS_CONFIG_BASE64` などの機密キーが検出されました。**警告であり継続します**が、保管・転送時の暗号化を強く推奨します。 + +### `SOURCE='-' (stdin) と --passphrase-stdin は併用できません` + +stdin から同時に「バンドル本体」と「パスフレーズ」を読むことはできません。`--passphrase-env` で環境変数経由に切り替えるか、`SOURCE` をファイルパスにしてください。 + +### import 後に `.import.tmp` ファイルが残った + +Phase 2 (commit) の途中で異常終了した可能性があります。次回 import 時に同じファイル名で書き直すので、通常は自動的にクリーンアップされます。気になる場合は `find $DEVBASE_ROOT -name '*.import.tmp' -delete` で削除できます。 + +## 関連ドキュメント + +- [環境変数ガイド](environment-variables.md) — 3 レベル構造とコレクター +- [CLI リファレンス](cli-reference.md) — 全コマンド一覧 +- [はじめに](getting-started.md) — 初回セットアップ diff --git a/docs/user/environment-variables.md b/docs/user/environment-variables.md index 656ea8d..431d570 100644 --- a/docs/user/environment-variables.md +++ b/docs/user/environment-variables.md @@ -240,9 +240,22 @@ ls ~/.aws/ > **Warning:** 環境変数を変更した後は `devbase up` でコンテナを再起動してください。起動中のコンテナには反映されません。 +## 別マシンへの移行 / バックアップ + +複数プロジェクトの `.env` 群を 1 つのバンドルにまとめ、暗号化したまま転送・復元するには `devbase env export` / `devbase env import` を使います。詳細は [環境変数の export/import ガイド](env-export-import.md) を参照してください。 + +```bash +# 既存マシンで export (~/.ssh/id_ed25519.pub があれば鍵指定省略可) +devbase env export ./bundle.dbenv + +# 新マシンで import (既定は keep-existing マージ) +devbase env import ./bundle.dbenv +``` + ## ベストプラクティス 1. **機密情報は `.env` に格納する** -- Git 管理対象の `env` ファイルには機密情報を含めない 2. **プロジェクト固有の設定は `-p` フラグを使う** -- グローバル設定を汚染しない 3. **`env sync` を定期的に実行する** -- ホストマシンの認証情報更新後は必ず同期 4. **`.env.sources.yml` を Git 管理しない** -- 環境固有のハッシュ情報のため +5. **別マシンへの移行は `devbase env export` を使う** -- `scp -r` で個別コピーする代わりに、暗号化バンドル 1 ファイルで安全に移動できる ([詳細](env-export-import.md)) diff --git a/lib/devbase/env/_import_atomic.py b/lib/devbase/env/_import_atomic.py new file mode 100644 index 0000000..9c788d5 --- /dev/null +++ b/lib/devbase/env/_import_atomic.py @@ -0,0 +1,208 @@ +"""``devbase env import`` の 2 フェーズ atomic 書き込み + backup / GC + +import_bundle が作る :class:`_import_merge.Plan` 群を、以下の手順で適用する: + + 1. ``backup_existing`` — 既存 target をタイムスタンプ付き backup_dir にコピー + 2. ``write_atomic`` (per plan) — ``.import.tmp`` に 0600 で書き出し + 3. ``commit`` — 全 tmp を ``os.replace`` で一括 rename。途中失敗時は backup から + best-effort で rollback し、未 rename の tmp も後始末する + +加えて ``gc_backups`` で古い backup ディレクトリを ``keep_last`` 個まで圧縮する。 +モジュール内で ``os`` を直接参照しており、テストはこの ``os.replace`` を +monkeypatch することで commit 失敗パスを再現する。 +""" + +from __future__ import annotations + +import os +import re +import shutil +from datetime import datetime +from pathlib import Path +from typing import List, Optional, Sequence, Tuple + +from devbase.errors import DevbaseError +from devbase.log import get_logger + +from devbase.env import io_common as _io_common +from devbase.env._import_merge import Plan + +logger = get_logger(__name__) + +# _make_backup_dir が生成するタイムスタンプ形式のみを GC 対象にする。 +# 以下のいずれかにマッチするディレクトリのみ削除する: +# YYYYMMDD-HHMMSS (旧フォーマット, 後方互換) +# YYYYMMDD-HHMMSS-NNNNNN (microsecond 付き) +# YYYYMMDD-HHMMSS-NNNNNN-NN (同一マイクロ秒内の連番付き) +# これ以外のディレクトリは devbase が作ったものではないので削除しない +# (--backup-dir 親に無関係なディレクトリがあっても安全)。 +_BACKUP_DIR_NAME_RE = re.compile(r'^\d{8}-\d{6}(?:-\d{6}(?:-\d+)?)?$') + + +class AtomicError(DevbaseError): + """atomic 書き込み中のエラー (ImportError へ委譲する用途で投げる)""" + + +def make_backup_dir(devbase_root: Path, backup_dir: Optional[str]) -> Path: + """``--backup-dir`` 指定 or ``$DEVBASE_ROOT/backups/env-import/`` 配下に + タイムスタンプ命名の backup ディレクトリを作る。 + + 秒精度のみだと同一秒に 2 回 import を走らせたときに同じディレクトリを再利用して + 前回バックアップを上書きしてしまうため、microsecond + 連番を付与して衝突を回避する + (PR #15 codex 指摘)。 + """ + base = (Path(backup_dir).expanduser() if backup_dir + else devbase_root / 'backups' / 'env-import') + base.mkdir(parents=True, exist_ok=True) + + stem = datetime.now().strftime('%Y%m%d-%H%M%S-%f') # microsecond まで + primary = base / stem + if not primary.exists(): + primary.mkdir(parents=True) + return primary + # 同一マイクロ秒に複数回走った場合の安全弁: 連番を付与 + for n in range(1, 1000): + candidate = base / f'{stem}-{n:02d}' + if not candidate.exists(): + candidate.mkdir(parents=True) + return candidate + raise AtomicError( + f"backup ディレクトリの衝突回避に失敗しました (base={base}, stem={stem})" + ) + + +def _backup_relative(target: Path, devbase_root: Path) -> Path: + """target を devbase_root 相対表現に変換。外にあるパスはファイル名のみを使う。""" + try: + return target.relative_to(devbase_root) + except ValueError: + return Path(target.name) + + +def backup_existing(plans: Sequence[Plan], + sources_copy: Optional[Tuple[Path, bytes]], + backup_dir: Path, devbase_root: Path) -> None: + """phase 1 前に既存ファイルの内容を ``backup_dir`` にコピーする""" + for plan in plans: + if not plan.target.exists(): + continue + dst = backup_dir / _backup_relative(plan.target, devbase_root) + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(plan.target, dst) + + # バンドルに含まれていた sources.yml の参照用コピー (上書きしないケース) + if sources_copy is not None: + _, data = sources_copy + dst = backup_dir / 'sources.yml.imported' + dst.parent.mkdir(parents=True, exist_ok=True) + _io_common.write_secure_bytes(dst, data) + + +def write_atomic(plan: Plan) -> Path: + """phase 1: 新内容を ``.import.tmp`` として 0600 で書き出す""" + tmp = plan.target.with_suffix(plan.target.suffix + '.import.tmp') + if tmp.exists(): + # 過去の失敗の残骸を掃除 + try: + tmp.unlink() + except OSError: + pass + _io_common.write_secure_bytes(tmp, plan.new_bytes) + return tmp + + +def commit(plans_and_tmps: List[Tuple[Plan, Path]], backup_dir: Path, + devbase_root: Path) -> List[Path]: + """phase 2: tmp → target に rename。 + + 途中失敗時は best-effort で rollback したうえで、まだ rename されていない + ``.import.tmp`` ファイルもクリーンアップする (PR #15 gemini 指摘)。 + """ + committed: List[Tuple[Plan, Path]] = [] + remaining = list(plans_and_tmps) + try: + while remaining: + plan, tmp = remaining[0] + os.replace(tmp, plan.target) + try: + os.chmod(plan.target, 0o600) + except OSError: + pass + committed.append((plan, plan.target)) + remaining.pop(0) + except OSError as e: + logger.error("commit フェーズで失敗しました: %s", e) + try: + _rollback(committed, backup_dir, devbase_root) + finally: + cleanup_tmps(tmp for _, tmp in remaining) + raise AtomicError(f"commit フェーズで失敗しました: {e}") from e + return [t for _, t in committed] + + +def _rollback(committed: Sequence[Tuple[Plan, Path]], backup_dir: Path, + devbase_root: Path) -> None: + """best-effort ロールバック: + - 既存上書き (backup あり) → backup から復元 + - backup が無いケース → 元ファイルが存在しなかった (= 新規作成) と + みなして unlink し、元の「不在」状態に戻す。``op='create'`` だけでなく + ``op='sources-merge'`` で sources.yml を新規作成したケースもここで + unlink する (PR #15 gemini 指摘)。 + + ``backup_existing`` は target が存在した場合のみ backup を作る。よって + 「backup が無い」事実は「元ファイルが存在しなかった」ことを示している。 + """ + for _, target in committed: + src = backup_dir / _backup_relative(target, devbase_root) + if src.exists(): + try: + shutil.copy2(src, target) + logger.warning("rollback: %s を %s から復元しました", target, src) + except OSError as e: + logger.error("rollback 失敗: %s -> %s: %s", src, target, e) + continue + # 元ファイル不在 → 新規作成された target を unlink して元の状態に戻す + try: + target.unlink() + logger.warning("rollback: 新規作成された %s を削除しました", target) + except FileNotFoundError: + pass + except OSError as e: + logger.error("rollback unlink 失敗: %s: %s", target, e) + + +def cleanup_tmps(tmps) -> None: + """``.import.tmp`` の残骸を削除する (失敗は無視)""" + for tmp in tmps: + try: + if tmp.exists(): + tmp.unlink() + except OSError: + pass + + +def gc_backups(backup_dir: Path, keep_last: int) -> None: + """``backup_dir`` の親ディレクトリで古い backup を ``keep_last`` 個まで残して GC する。 + + devbase 生成のタイムスタンプ形式 (``YYYYMMDD-HHMMSS[-NNNNNN[-NN]]``) に + マッチするディレクトリのみが GC 対象。``--backup-dir`` 親に無関係な + ファイル / ディレクトリがあっても、それらは触らない。 + """ + if keep_last <= 0: + return + parent = backup_dir.parent + if not parent.is_dir(): + return + candidates = [p for p in parent.iterdir() + if p.is_dir() and _BACKUP_DIR_NAME_RE.match(p.name)] + if len(candidates) <= keep_last: + return + # 名前 (= タイムスタンプ) でソート、古いものから捨てる。keep_last は通常 10 程度なので + # 全件ソートで十分 (heapq.nsmallest を使うほどの規模ではない)。 + candidates.sort(key=lambda p: p.name) + for d in candidates[:-keep_last]: + try: + shutil.rmtree(d) + logger.info("古い backup を削除しました: %s", d) + except OSError as e: + logger.warning("backup 削除に失敗 (%s): %s", d, e) diff --git a/lib/devbase/env/_import_merge.py b/lib/devbase/env/_import_merge.py new file mode 100644 index 0000000..e39444a --- /dev/null +++ b/lib/devbase/env/_import_merge.py @@ -0,0 +1,319 @@ +"""``devbase env import`` の merge / replace 計画 + +ファイル単位の操作内容 (新規作成 / マージ / 置換 / sources-merge) を +:class:`Plan` として表現し、``incoming`` と ``existing`` から差分計算する。 + +実書き込み (atomic rename / backup / rollback) は :mod:`_import_atomic` の役割で、 +このモジュールは「何を書くか」だけを決定する。 +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Tuple + +import yaml + +from devbase.errors import DevbaseError +from devbase.log import get_logger + +from devbase.env.store import EnvEntry, EnvFile + +logger = get_logger(__name__) + +_PROJECT_ENV_RE = re.compile(r'^env/projects/([^/]+)/\.env$') + +# import_bundle が許容する --merge モード一覧。CLI の choices と一致させる。 +MERGE_MODES: Tuple[str, ...] = ('keep-existing', 'prefer-incoming') + + +class MergeError(DevbaseError): + """merge 計画作成中のエラー (ImportError へ委譲する用途で投げる)""" + + +@dataclass +class Plan: + """1 ファイル分の書き出し計画。 + + ``added_keys`` / ``overwritten_keys`` / ``skipped_keys`` は dry-run およびログ表示で + "何が起こるか" をユーザに伝えるために保持する。 + """ + target: Path + arcname: str + new_bytes: bytes + added_keys: List[str] = field(default_factory=list) + overwritten_keys: List[str] = field(default_factory=list) + skipped_keys: List[str] = field(default_factory=list) + op: str = 'merge' # 'merge' | 'replace' | 'create' | 'sources-merge' + + +def target_for(arcname: str, devbase_root: Path) -> Path: + """バンドル内 arcname を ``devbase_root`` 配下の書き出し先 Path に解決する""" + if arcname == 'env/global.env': + return devbase_root / '.env' + if arcname == 'env/sources.yml': + return devbase_root / '.env.sources.yml' + m = _PROJECT_ENV_RE.match(arcname) + if m: + return devbase_root / 'projects' / m.group(1) / '.env' + raise MergeError(f"未対応のバンドルエントリ: {arcname}") + + +def filter_members( + members: Dict[str, bytes], + *, + include_global: bool, + include_metadata: bool, + include_projects: Optional[Sequence[str]], + exclude_projects: Sequence[str], +) -> Dict[str, bytes]: + """include/exclude 指定で展開済みメンバーを絞り込む""" + included = set(include_projects) if include_projects else None + excluded = set(exclude_projects) + result: Dict[str, bytes] = {} + + for arcname, data in members.items(): + if arcname == 'env/global.env': + if include_global: + result[arcname] = data + continue + if arcname == 'env/sources.yml': + if include_metadata: + result[arcname] = data + continue + m = _PROJECT_ENV_RE.match(arcname) + if not m: + # 他の形式は manifest 検証で拒否されているはずだが念のため。 + logger.debug("未対応の arcname を無視します: %s", arcname) + continue + name = m.group(1) + if name in excluded: + continue + if included is not None and name not in included: + continue + result[arcname] = data + return result + + +def _merge_into_existing_bytes(existing_bytes: bytes, + merged: Dict[str, str]) -> bytes: + """既存 ``.env`` のコメント / 空行 / キー順を保持したまま、``merged`` で値を差し替える。 + + 既存に無いキーは末尾に sorted 順で append。``merged`` から除外されたキーは + 出力からも除外する (現状の merge ロジック上発生しないが、安全側で対応)。 + + ``EnvFile.dump_bytes`` で再シリアライズするとコメント・空行が失われるため、 + ``EnvFile.parse_entries`` ベースで再構成している (PR #15 gemini 指摘)。 + """ + seen: set[str] = set() + out_entries: List[EnvEntry] = [] + for e in EnvFile.parse_entries(existing_bytes): + if e.kind != 'kv' or e.key is None: + out_entries.append(e) + continue + if e.key in merged: + out_entries.append(EnvEntry( + kind='kv', raw=e.raw, key=e.key, value=merged[e.key] + )) + seen.add(e.key) + # merged から除外されているキーは entries からも落とす + for key in sorted(k for k in merged if k not in seen): + out_entries.append(EnvEntry(kind='kv', raw='', key=key, value=merged[key])) + return EnvFile.dump_entries_bytes(out_entries) + + +def _plan_replace(target: Path, arcname: str, incoming: Dict[str, str], + existing: Dict[str, str], incoming_bytes: bytes, + target_exists: bool) -> Plan: + """--replace: ファイル丸ごとを incoming で置き換える""" + added = sorted(set(incoming) - set(existing)) + overwritten = sorted( + k for k in incoming if k in existing and incoming[k] != existing[k] + ) + return Plan( + target=target, + arcname=arcname, + new_bytes=incoming_bytes, + added_keys=added, + overwritten_keys=overwritten, + # op 判定はファイル実体の有無で行う: + # コメントのみの既存 .env を 'create' と誤判定しないため (PR #15 round5 指摘)。 + op='replace' if target_exists else 'create', + ) + + +def _plan_keep_existing(incoming: Dict[str, str], existing: Dict[str, str], + merged: Dict[str, str], added: List[str], + skipped: List[str]) -> None: + """既存キーは保持。新規キーのみ追加""" + for key, value in incoming.items(): + if key in existing: + skipped.append(key) + else: + merged[key] = value + added.append(key) + + +def _plan_prefer_incoming(incoming: Dict[str, str], existing: Dict[str, str], + merged: Dict[str, str], added: List[str], + overwritten: List[str]) -> None: + """incoming で既存キーを上書き""" + for key, value in incoming.items(): + if key in existing: + if existing[key] != value: + overwritten.append(key) + else: + added.append(key) + merged[key] = value + + +def _plan_replace_keys(incoming: Dict[str, str], existing: Dict[str, str], + replace_keys: Sequence[str], merged: Dict[str, str], + added: List[str], overwritten: List[str], + skipped: List[str]) -> None: + """--replace-keys: 指定キーのみ上書き、残りは keep-existing 相当 + + keep-existing 相当 = 既存にあれば残す、無ければ新規追加 (skipped は + 上書きを抑止したキーのみ)。 + """ + replace_set = set(replace_keys) + for key, value in incoming.items(): + if key in replace_set: + if key in existing and existing[key] != value: + overwritten.append(key) + elif key not in existing: + added.append(key) + merged[key] = value + elif key in existing: + if existing[key] != value: + skipped.append(key) + else: + added.append(key) + merged[key] = value + + +def plan_env_merge(target: Path, incoming_bytes: bytes, arcname: str, *, + merge: str = 'keep-existing', + replace: bool = False, + replace_keys: Sequence[str] = ()) -> Plan: + """1 つの ``.env`` に対する merge / replace 計画を作る + + 新規作成 (= target 不在) ケースでは ``incoming_bytes`` をそのまま採用する。 + ``EnvFile.dump_bytes`` で再シリアライズすると、export 側で既に escape された値が + parse_bytes 経由でも完全に round-trip できる前提が崩れた瞬間に二重エスケープが + 発生するためである (PR #15 codex 指摘)。 + + 既存ファイルが存在する merge 経路では :func:`_merge_into_existing_bytes` で + 既存のコメント / 空行 / キー順を保持したまま値だけ差し替える (PR #15 gemini 指摘)。 + """ + incoming = EnvFile.parse_bytes(incoming_bytes) + target_exists = target.exists() + existing_bytes = target.read_bytes() if target_exists else b'' + existing = EnvFile.parse_bytes(existing_bytes) if target_exists else {} + + if replace: + return _plan_replace(target, arcname, incoming, existing, + incoming_bytes, target_exists) + + merged: Dict[str, str] = dict(existing) + added: List[str] = [] + overwritten: List[str] = [] + skipped: List[str] = [] + + if replace_keys: + _plan_replace_keys(incoming, existing, replace_keys, + merged, added, overwritten, skipped) + elif merge == 'keep-existing': + _plan_keep_existing(incoming, existing, merged, added, skipped) + elif merge == 'prefer-incoming': + _plan_prefer_incoming(incoming, existing, merged, added, overwritten) + else: + raise MergeError(f"不明な --merge モード: {merge!r}") + + new_bytes = (_merge_into_existing_bytes(existing_bytes, merged) + if target_exists else incoming_bytes) + return Plan( + target=target, + arcname=arcname, + new_bytes=new_bytes, + added_keys=sorted(added), + overwritten_keys=sorted(overwritten), + skipped_keys=sorted(skipped), + op='merge' if target_exists else 'create', + ) + + +def plan_sources(target: Path, incoming_bytes: bytes, *, + merge_metadata: bool) -> Optional[Plan]: + """``.env.sources.yml`` の取り扱い計画 + + 既定: 上書きしないため ``None`` を返す (参照用コピーの保存は呼び出し側で実施)。 + ``merge_metadata=True``: 新規 source エントリのみ追加した内容で更新する。 + """ + if not merge_metadata: + return None + + try: + incoming = yaml.safe_load(incoming_bytes) or {} + except yaml.YAMLError as e: + raise MergeError(f"バンドルの sources.yml が壊れています: {e}") from e + if not isinstance(incoming, dict): + raise MergeError("バンドルの sources.yml が dict ではありません") + incoming_sources = incoming.get('sources') or {} + if not isinstance(incoming_sources, dict): + raise MergeError("バンドルの sources.yml の sources が dict ではありません") + + existing: Dict = {} + if target.exists(): + try: + existing = yaml.safe_load(target.read_bytes()) or {} + except yaml.YAMLError as e: + raise MergeError( + f"既存の {target.name} のパースに失敗しました: {e}" + ) from e + if not isinstance(existing, dict): + existing = {} + existing_sources = existing.get('sources') + if not isinstance(existing_sources, dict): + existing_sources = {} + + merged_sources = dict(existing_sources) + added: List[str] = [] + for name, entry in incoming_sources.items(): + if name in merged_sources: + continue + merged_sources[name] = entry + added.append(name) + if not added: + return None # 変化なし + + existing['sources'] = merged_sources + new_bytes = yaml.safe_dump( + existing, default_flow_style=False, allow_unicode=True + ).encode('utf-8') + return Plan( + target=target, + arcname='env/sources.yml', + new_bytes=new_bytes, + added_keys=sorted(added), + op='sources-merge', + ) + + +def log_plans(plans: Sequence[Plan], dry_run: bool) -> None: + """dry-run / 通常実行のいずれでも plan の内容を logger.info で表示する""" + prefix = "[dry-run] " if dry_run else "" + for plan in plans: + logger.info( + "%s%s: %s (+%d add / ~%d overwrite / -%d skip)", + prefix, plan.op, plan.target, + len(plan.added_keys), len(plan.overwritten_keys), len(plan.skipped_keys), + ) + if plan.added_keys: + logger.info(" added: %s", ", ".join(plan.added_keys)) + if plan.overwritten_keys: + logger.info(" overwrite: %s", ", ".join(plan.overwritten_keys)) + if plan.skipped_keys: + logger.info(" skip (existing kept): %s", ", ".join(plan.skipped_keys)) diff --git a/lib/devbase/env/io_common.py b/lib/devbase/env/io_common.py new file mode 100644 index 0000000..f18995a --- /dev/null +++ b/lib/devbase/env/io_common.py @@ -0,0 +1,115 @@ +"""env export / import で共通利用する I/O ヘルパ + +io_export / io_import の両方で必要になる「ファイル不在を許容する passphrase 読み取り」 +「省略時の既定 age 鍵 fallback」「0600 でセキュアにバイト列を書き出す」処理を +1 箇所に集約する。 +""" + +from __future__ import annotations + +import getpass +import os +import sys +from pathlib import Path +from typing import List, Optional, Sequence, Type + +from devbase.errors import DevbaseError +from devbase.log import get_logger + +from devbase.env import cipher as _cipher + +logger = get_logger(__name__) + + +def read_passphrase( + passphrase_env: Optional[str], + passphrase_stdin: bool, + error_class: Type[DevbaseError], +) -> Optional[str]: + """env 変数 / stdin から passphrase を読み取る。どちらも指定が無ければ ``None``。 + + 両方指定済みかなどの組み合わせ検証は呼び出し側の責務 (エラーメッセージを + 文脈に合わせるため)。tty 入力時は ``getpass.getpass`` でエコー抑止、 + パイプ入力時は ``stdin.readline()`` で 1 行読む。 + """ + if passphrase_env: + value = os.environ.get(passphrase_env) + if not value: + raise error_class(f"環境変数 {passphrase_env} が空または未設定です") + return value + if passphrase_stdin: + if sys.stdin.isatty(): + try: + return getpass.getpass("passphrase: ", stream=sys.stderr) + except EOFError as e: + raise error_class("stdin からパスフレーズを読み取れませんでした") from e + line = sys.stdin.readline() + if not line: + raise error_class("stdin からパスフレーズを読み取れませんでした") + return line.rstrip('\n') + return None + + +def resolve_recipient_specs(specs: Sequence[str]) -> List[str]: + """recipient 指定の解決。 + + 明示指定があればそのまま返す。空なら ``~/.ssh/id_ed25519.pub`` → ``id_rsa.pub`` + の順で存在する公開鍵を探し、最初に見つかったものを ``@PATH`` 参照として返す。 + """ + if specs: + return list(specs) + for path in _cipher.default_recipient_paths(): + if path.exists(): + logger.info("recipient 既定鍵を使用: %s", path) + return [f'@{path}'] + return [] + + +def resolve_identity_specs(specs: Sequence[str]) -> List[str]: + """identity 指定の解決。 + + 明示指定があればそのまま返す。空なら ``~/.ssh/id_ed25519`` → ``id_rsa`` の + 順で存在する秘密鍵を探し、最初に見つかったものを返す。 + """ + if specs: + return list(specs) + for path in _cipher.default_identity_paths(): + if path.exists(): + logger.info("identity 既定鍵を使用: %s", path) + return [str(path)] + return [] + + +def write_secure_bytes(path: Path, data: bytes, *, mode: int = 0o600) -> None: + """``path`` に ``data`` を書き出す (新規・既存どちらも ``mode`` を強制)。 + + ``open(..., 'wb')`` 直後に ``chmod`` する素朴な実装では、umask が緩い環境で + 作成→chmod の間にパーミッションが一瞬広がるウィンドウがある。これを避けるため: + + - 既存ファイルは書き込み前に ``chmod`` で権限を絞ってから ``O_TRUNC`` で上書き + - ``os.open(..., flags, mode)`` で作成時点から ``mode`` を適用 + - mode 引数が無視される環境 (Windows 等) のため後追いでも ``chmod`` を試みる + + ``chmod`` が失敗するプラットフォームでは例外を握りつぶす (主に Windows)。 + """ + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists(): + try: + os.chmod(path, mode) + except OSError: + pass + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + fd = os.open(path, flags, mode) + try: + with os.fdopen(fd, 'wb') as f: + f.write(data) + except BaseException: + try: + os.close(fd) + except OSError: + pass + raise + try: + os.chmod(path, mode) + except OSError: + pass diff --git a/lib/devbase/env/io_export.py b/lib/devbase/env/io_export.py index 08645a6..bed85b2 100644 --- a/lib/devbase/env/io_export.py +++ b/lib/devbase/env/io_export.py @@ -2,10 +2,8 @@ from __future__ import annotations -import getpass -import os +import getpass # noqa: F401 (tests monkey-patch devbase.env.io_export.getpass) import re -import sys from dataclasses import dataclass, field from datetime import datetime from pathlib import Path @@ -16,12 +14,14 @@ from devbase.env import bundle as _bundle from devbase.env import cipher as _cipher +from devbase.env import io_common as _io_common from devbase.env import storage as _storage logger = get_logger(__name__) -# 機密情報の検出パターン (平文出力時の警告用) +# 平文出力時に "機密キーが含まれます" の警告を出す判定パターン _SENSITIVE_PATTERNS = ('KEY', 'SECRET', 'TOKEN', 'PASSWORD', 'CREDENTIALS', 'BASE64') +_ENV_KEY_RE = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=', re.MULTILINE) class ExportError(DevbaseError): @@ -50,48 +50,17 @@ def _default_dest(force_unencrypted: bool) -> str: return f'./devbase-env-{ts}{suffix}' -def _resolve_recipients(specs: Sequence[str]) -> List[str]: - """recipient 指定の解決。 - - 空なら既定鍵を優先順 (``~/.ssh/id_ed25519.pub`` → ``~/.ssh/id_rsa.pub``) で - 探索し、最初に見つかったものを利用する。 - """ - if specs: - return list(specs) - for path in _cipher.default_recipient_paths(): - if path.exists(): - logger.info("recipient 既定鍵を使用: %s", path) - return [f'@{path}'] - return [] +def _read_passphrase(opts: ExportOptions) -> Optional[str]: + """既存テストとの互換のために残している thin wrapper。 + 実体は :mod:`devbase.env.io_common.read_passphrase`。""" + return _io_common.read_passphrase( + opts.passphrase_env, opts.passphrase_stdin, ExportError + ) -def _read_passphrase(opts: ExportOptions) -> Optional[str]: - if opts.passphrase_env: - value = os.environ.get(opts.passphrase_env) - if not value: - raise ExportError( - f"環境変数 {opts.passphrase_env} が空または未設定です" - ) - return value - if opts.passphrase_stdin: - # tty で対話実行している場合は getpass.getpass でエコー抑止 - # (パイプ入力時は echo の概念がないので従来どおり stdin.readline で読む)。 - if sys.stdin.isatty(): - try: - return getpass.getpass("passphrase: ", stream=sys.stderr) - except EOFError as e: - raise ExportError("stdin からパスフレーズを読み取れませんでした") from e - line = sys.stdin.readline() - if not line: - raise ExportError("stdin からパスフレーズを読み取れませんでした") - return line.rstrip('\n') - return None - - -def _has_sensitive_keys(entries) -> List[str]: - """env 形式のテキストから機密キーを抽出する (平文出力時の警告用)""" - hits = set() - key_re = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=', re.MULTILINE) +def _sensitive_keys(entries: Sequence[_bundle.BundleEntry]) -> List[str]: + """平文出力に含まれる機密キー候補を返す (警告表示用、.env エントリのみ走査)""" + hits: set[str] = set() for entry in entries: if not entry.arcname.endswith('.env'): continue @@ -99,16 +68,13 @@ def _has_sensitive_keys(entries) -> List[str]: text = entry.data.decode('utf-8', errors='ignore') except Exception: continue - for key in key_re.findall(text): - upper = key.upper() - if any(p in upper for p in _SENSITIVE_PATTERNS): + for key in _ENV_KEY_RE.findall(text): + if any(p in key.upper() for p in _SENSITIVE_PATTERNS): hits.add(key) return sorted(hits) -def export(devbase_root: Path, opts: ExportOptions) -> int: - """export 本体。CLI ハンドラから呼ばれる""" - # 引数組み合わせの早期検証 +def _validate_options(opts: ExportOptions) -> None: if opts.passphrase_stdin and opts.dest == '-': raise ExportError( "DEST='-' (stdout) と --passphrase-stdin は併用できません " @@ -117,6 +83,43 @@ def export(devbase_root: Path, opts: ExportOptions) -> int: if opts.passphrase_env and opts.passphrase_stdin: raise ExportError("--passphrase-env と --passphrase-stdin は併用できません") + +def _encrypt_payload(tar_blob: bytes, opts: ExportOptions) -> bytes: + """``opts`` の鍵指定に従って tar.gz を暗号化する。鍵が無ければ既定鍵を試す""" + passphrase = _read_passphrase(opts) + recipients = ( + [] if passphrase is not None + else _io_common.resolve_recipient_specs(opts.recipients) + ) + if not recipients and not passphrase: + raise ExportError( + "暗号化キーが指定されていません。次のいずれかを指定してください:\n" + " --recipient KEY age / OpenSSH 公開鍵\n" + " --passphrase-env VAR 環境変数からパスフレーズ取得\n" + " --passphrase-stdin stdin の最初の行をパスフレーズとして使用\n" + " --force-unencrypted 平文 tar.gz として書き出す (機密キー検知時は警告)\n" + " ~/.ssh/id_ed25519.pub または ~/.ssh/id_rsa.pub があれば " + "--recipient 省略時の既定として使用されます (ed25519 優先)" + ) + return _cipher.encrypt(tar_blob, recipients=recipients, passphrase=passphrase) + + +def _warn_if_plaintext_sensitive(entries: Sequence[_bundle.BundleEntry]) -> None: + sensitive = _sensitive_keys(entries) + if not sensitive: + return + head = ', '.join(sensitive[:10]) + suffix = ' ...' if len(sensitive) > 10 else '' + logger.warning("平文 export に機密キーが含まれます: %s%s", head, suffix) + logger.warning( + "ファイルパーミッションは 0600 で書き出されますが、保管・転送時の暗号化を強く推奨します" + ) + + +def export(devbase_root: Path, opts: ExportOptions) -> int: + """export 本体。CLI ハンドラから呼ばれる""" + _validate_options(opts) + entries = _bundle.make_entries_from_disk( devbase_root, include_global=opts.include_global, @@ -143,38 +146,18 @@ def export(devbase_root: Path, opts: ExportOptions) -> int: raise ExportError( "--force-unencrypted は recipient / passphrase と併用できません" ) - sensitive = _has_sensitive_keys(entries) - if sensitive: - logger.warning( - "平文 export に機密キーが含まれます: %s", - ', '.join(sensitive[:10]) + (' ...' if len(sensitive) > 10 else '') - ) - logger.warning( - "ファイルパーミッションは 0600 で書き出されますが、保管・転送時の暗号化を強く推奨します" - ) + _warn_if_plaintext_sensitive(entries) payload = tar_blob else: - passphrase = _read_passphrase(opts) - recipients = _resolve_recipients(opts.recipients) if passphrase is None else [] - if not recipients and not passphrase: - raise ExportError( - "暗号化キーが指定されていません。次のいずれかを指定してください:\n" - " --recipient KEY age / OpenSSH 公開鍵\n" - " --passphrase-env VAR 環境変数からパスフレーズ取得\n" - " --passphrase-stdin stdin の最初の行をパスフレーズとして使用\n" - " --force-unencrypted 平文 tar.gz として書き出す (機密キー検知時は警告)\n" - " ~/.ssh/id_ed25519.pub または ~/.ssh/id_rsa.pub があれば " - "--recipient 省略時の既定として使用されます (ed25519 優先)" - ) - payload = _cipher.encrypt(tar_blob, recipients=recipients, passphrase=passphrase) + payload = _encrypt_payload(tar_blob, opts) logger.debug("暗号化後サイズ: %d bytes", len(payload)) dest = opts.dest or _default_dest(opts.force_unencrypted) # S3 など backend 固有のオプションを渡したい場合は s3_options を組み立てる。 # それ以外 (local/stdio) では未使用なので無害。 - s3_options = _storage.S3Options.from_env( + s3_options = (_storage.S3Options.from_env( unsafe_allow_unencrypted_bucket=opts.unsafe_allow_unencrypted_bucket, - ) if _storage.is_s3(dest) else None + ) if _storage.is_s3(dest) else None) backend = _storage.resolve(dest, s3_options=s3_options) backend.write_bytes(dest, payload) diff --git a/lib/devbase/env/io_import.py b/lib/devbase/env/io_import.py index 5f025b1..6107e1f 100644 --- a/lib/devbase/env/io_import.py +++ b/lib/devbase/env/io_import.py @@ -1,56 +1,43 @@ """devbase env import の高レベル実装 責務: - - SOURCE (file / stdio) の読み込み + - SOURCE (file / stdio / s3) の読み込み - age 復号 (バンドルが暗号化されていれば) - tar.gz バンドルの展開と sha256 / manifest version の検証 (bundle.unpack) - - --merge / --replace-keys / --replace のセマンティクスで .env 群を更新 + - merge / replace / replace-keys 計画の作成と適用 - .env.sources.yml は既定で上書きせず参照用コピーのみ (--merge-metadata で 新規 source のみ追加) - 2 フェーズ書き出し (prepare → commit) で部分適用を最小化 - --backup-dir / --keep-last N で backup を GC - --dry-run で差分プレビュー + +実装の詳細は :mod:`_import_merge` (merge 計画) と :mod:`_import_atomic` +(backup / atomic 書き込み / rollback) に分割している。 """ from __future__ import annotations -import getpass -import os -import re -import shutil -import sys +import getpass # noqa: F401 (tests monkey-patch devbase.env.io_import.getpass) from dataclasses import dataclass, field -from datetime import datetime from pathlib import Path -from typing import Dict, List, Optional, Sequence, Tuple - -import yaml +from typing import List, Optional, Tuple from devbase.errors import DevbaseError from devbase.log import get_logger +from devbase.env import _import_atomic as _atomic +from devbase.env import _import_merge as _merge from devbase.env import bundle as _bundle from devbase.env import cipher as _cipher +from devbase.env import io_common as _io_common from devbase.env import storage as _storage -from devbase.env.store import EnvEntry, EnvFile logger = get_logger(__name__) -# gzip magic. tar.gz バンドルは先頭 2 byte が 0x1f 0x8b。age 暗号化済みは -# テキストヘッダ "age-encryption.org/v1\n" で始まるため magic で識別できる。 +# 暗号化済みは age テキストヘッダ "age-encryption.org/v1\n" で始まるのに対し、 +# 平文 tar.gz は先頭 2 byte が gzip magic (0x1f 0x8b) となる。これで判別する。 _GZIP_MAGIC = b'\x1f\x8b' -_MERGE_MODES = ('keep-existing', 'prefer-incoming') - -# _make_backup_dir が生成するタイムスタンプ形式のみを GC 対象にする。 -# 以下のいずれかにマッチするディレクトリのみ削除する: -# YYYYMMDD-HHMMSS (旧フォーマット, 後方互換) -# YYYYMMDD-HHMMSS-NNNNNN (microsecond 付き) -# YYYYMMDD-HHMMSS-NNNNNN-NN (同一マイクロ秒内の連番付き) -# これ以外のディレクトリは devbase が作ったものではないので削除しない -# (--backup-dir 親に無関係なディレクトリがあっても安全)。 -_BACKUP_DIR_NAME_RE = re.compile(r'^\d{8}-\d{6}(?:-\d{6}(?:-\d+)?)?$') - class ImportError(DevbaseError): """import エラー""" @@ -75,53 +62,34 @@ class ImportOptions: keep_last: int = 10 -@dataclass -class _Plan: - """1 ファイル分の書き出し計画""" - target: Path - arcname: str - new_bytes: bytes - # 差分サマリ (dry-run / ログ用) - added_keys: List[str] = field(default_factory=list) - overwritten_keys: List[str] = field(default_factory=list) - skipped_keys: List[str] = field(default_factory=list) - # ファイル単位の操作種別 - op: str = 'merge' # 'merge' | 'replace' | 'create' | 'sources-merge' +def _read_passphrase(opts: ImportOptions) -> Optional[str]: + """既存テストとの互換のために残している thin wrapper。 + 実体は :mod:`devbase.env.io_common.read_passphrase`。""" + return _io_common.read_passphrase( + opts.passphrase_env, opts.passphrase_stdin, ImportError + ) -def _read_passphrase(opts: ImportOptions) -> Optional[str]: - if opts.passphrase_env: - value = os.environ.get(opts.passphrase_env) - if not value: - raise ImportError(f"環境変数 {opts.passphrase_env} が空または未設定です") - return value - if opts.passphrase_stdin: - if sys.stdin.isatty(): - try: - return getpass.getpass("passphrase: ", stream=sys.stderr) - except EOFError as e: - raise ImportError("stdin からパスフレーズを読み取れませんでした") from e - line = sys.stdin.readline() - if not line: - raise ImportError("stdin からパスフレーズを読み取れませんでした") - return line.rstrip('\n') - return None - - -def _resolve_identities(specs: Sequence[str]) -> List[str]: - if specs: - return list(specs) - for path in _cipher.default_identity_paths(): - if path.exists(): - logger.info("identity 既定鍵を使用: %s", path) - return [str(path)] - return [] +def _validate_options(opts: ImportOptions) -> None: + if opts.merge not in _merge.MERGE_MODES: + raise ImportError( + f"--merge の値が不正です: {opts.merge!r} " + f"(許可: {', '.join(_merge.MERGE_MODES)})" + ) + if opts.replace and opts.replace_keys: + raise ImportError("--replace と --replace-keys は併用できません") + if opts.passphrase_stdin and opts.source == '-': + raise ImportError( + "SOURCE='-' (stdin) と --passphrase-stdin は併用できません " + "(stdin が衝突します)" + ) + if opts.passphrase_env and opts.passphrase_stdin: + raise ImportError("--passphrase-env と --passphrase-stdin は併用できません") def _decrypt_if_needed(blob: bytes, opts: ImportOptions) -> bytes: """先頭バイトで暗号化済みかを判定して必要なら復号する""" if blob[:2] == _GZIP_MAGIC: - # 平文 tar.gz。鍵指定があっても無視せず警告にとどめる if opts.identities or opts.passphrase_env or opts.passphrase_stdin: logger.warning( "バンドルは平文ですが identity / passphrase が指定されています " @@ -133,7 +101,7 @@ def _decrypt_if_needed(blob: bytes, opts: ImportOptions) -> bytes: if passphrase is not None: return _cipher.decrypt(blob, passphrase=passphrase) - identities = _resolve_identities(opts.identities) + identities = _io_common.resolve_identity_specs(opts.identities) if not identities: raise ImportError( "バンドルは暗号化されていますが復号キーが指定されていません。\n" @@ -146,533 +114,64 @@ def _decrypt_if_needed(blob: bytes, opts: ImportOptions) -> bytes: return _cipher.decrypt(blob, identities=identities) -def _filter_members(members: Dict[str, bytes], - opts: ImportOptions) -> Dict[str, bytes]: - """include/exclude 指定で展開済みメンバーを絞り込む""" - included = set(opts.include_projects) if opts.include_projects else None - excluded = set(opts.exclude_projects) - result: Dict[str, bytes] = {} - - proj_re = re.compile(r'^env/projects/([^/]+)/\.env$') - - for arcname, data in members.items(): - if arcname == 'env/global.env': - if not opts.include_global: - continue - result[arcname] = data - continue - if arcname == 'env/sources.yml': - if not opts.include_metadata: - continue - result[arcname] = data - continue - m = proj_re.match(arcname) - if m: - name = m.group(1) - if name in excluded: - continue - if included is not None and name not in included: - continue - result[arcname] = data - continue - # 他の形式は manifest 検証で拒否されているはずだが念のため - logger.debug("未対応の arcname を無視します: %s", arcname) - return result - - -def _target_for(arcname: str, devbase_root: Path) -> Path: - if arcname == 'env/global.env': - return devbase_root / '.env' - if arcname == 'env/sources.yml': - return devbase_root / '.env.sources.yml' - m = re.match(r'^env/projects/([^/]+)/\.env$', arcname) - if m: - return devbase_root / 'projects' / m.group(1) / '.env' - raise ImportError(f"未対応のバンドルエントリ: {arcname}") - - -def _merge_into_existing_bytes(existing_bytes: bytes, - merged: Dict[str, str]) -> bytes: - """既存 ``.env`` のコメント / 空行 / キー順を保持したまま、 - ``merged`` の内容で値を上書きしてバイト列化する。 - - ``merged`` のうち既存ファイルに無いキーは末尾に追加する。 - 既存ファイルにあって ``merged`` から削除されているキーは、entries からも除外して - 出力する (現状の merge ロジック上、削除されるケースは無いが安全側で対応)。 - - PR #15 gemini 指摘: ``EnvFile.dump_bytes`` で再シリアライズすると ``key=value`` - だけの出力になりコメント・空行が失われる。これを避けるため entries ベースで - 再構成する。 - """ - entries = EnvFile.parse_entries(existing_bytes) - seen: set = set() - out_entries: List[EnvEntry] = [] - for e in entries: - if e.kind == 'kv' and e.key is not None: - if e.key in merged: - # 値を merge 後のものに差し替え (key 順 / コメントは保持) - out_entries.append(EnvEntry( - kind='kv', raw=e.raw, key=e.key, value=merged[e.key] - )) - seen.add(e.key) - # merged から除外されているキーは entries からも落とす - else: - out_entries.append(e) - # 既存に無かった新規キーは末尾に append (定常的な key 順を維持するため sorted) - for key in sorted(k for k in merged if k not in seen): - out_entries.append(EnvEntry( - kind='kv', raw='', key=key, value=merged[key] - )) - return EnvFile.dump_entries_bytes(out_entries) - - -def _build_merge_plan( - target: Path, - arcname: str, - incoming_bytes: bytes, - existing_bytes: bytes, - target_exists: bool, - merged: Dict[str, str], - added: List[str], - overwritten: List[str], - skipped: List[str], -) -> _Plan: - """merge 系 (replace_keys / keep-existing / prefer-incoming) 共通の _Plan 生成。 - - - 新規作成時 (``target_exists`` が False) は ``incoming_bytes`` をそのまま採用して - 二重エスケープを回避 (PR #15 codex 指摘) - - 既存ファイルへの merge 時は ``_merge_into_existing_bytes`` でコメント / 空行 / - キー順を保持したまま値を差し替える (PR #15 gemini 指摘)。 - ``existing`` (key=value dict) の空判定で create/merge を決めると、コメント / - 空行のみで構成された既存 .env が ``incoming_bytes`` で上書きされてコメントが - 失われるため、ファイル実体の有無で判定する (PR #15 round5 指摘)。 - """ - new_bytes = (_merge_into_existing_bytes(existing_bytes, merged) - if target_exists else incoming_bytes) - return _Plan( - target=target, - arcname=arcname, - new_bytes=new_bytes, - added_keys=sorted(added), - overwritten_keys=sorted(overwritten), - skipped_keys=sorted(skipped), - op='merge' if target_exists else 'create', - ) - - -def _plan_env_merge(target: Path, incoming_bytes: bytes, - opts: ImportOptions, arcname: str) -> _Plan: - """1 つの .env に対する merge / replace 計画を作る - - 既存ファイルが無い (= create) ケースでは、バンドル側の ``incoming_bytes`` を - そのまま採用する。``EnvFile.dump_bytes`` で再シリアライズすると、export 側で - 既に escape された値を parse_bytes 経由でも完全に round-trip できる前提が - 崩れた瞬間に二重エスケープが発生するためである (PR #15 codex 指摘)。 - - 既存ファイルが存在する merge 経路では ``_merge_into_existing_bytes`` で - 既存のコメント / 空行 / キー順を保持したまま値だけ差し替える - (PR #15 gemini 指摘)。 - - 各分岐で重複していた ``new_bytes`` 生成と ``_Plan`` 構築は - ``_build_merge_plan`` に括り出している (PR #15 gemini round4 指摘)。 - """ - incoming = EnvFile.parse_bytes(incoming_bytes) - existing: Dict[str, str] = {} - existing_bytes: bytes = b'' - target_exists = target.exists() - if target_exists: - existing_bytes = target.read_bytes() - existing = EnvFile.parse_bytes(existing_bytes) - - if opts.replace: - added = sorted(set(incoming) - set(existing)) - overwritten = sorted(k for k in incoming if k in existing and incoming[k] != existing[k]) - # replace は バンドル側の値で完全に置き換える (merge 経路と別系統) - # op 判定は ``existing`` (key=value dict) ではなくファイル実体の有無で行う: - # コメントのみの既存 .env を 'create' と誤判定しないため (PR #15 round5 指摘)。 - return _Plan( - target=target, - arcname=arcname, - new_bytes=incoming_bytes, - added_keys=added, - overwritten_keys=overwritten, - skipped_keys=[], - op='replace' if target_exists else 'create', - ) - - merged: Dict[str, str] = dict(existing) - added: List[str] = [] - overwritten: List[str] = [] - skipped: List[str] = [] - - if opts.replace_keys: - replace_set = set(opts.replace_keys) - for key, value in incoming.items(): - if key in replace_set: - if key in existing: - if existing[key] != value: - overwritten.append(key) - merged[key] = value - else: - added.append(key) - merged[key] = value - else: - # --replace-keys 指定外のキーは keep-existing 相当: - # 既存にあれば残し、無ければ新規追加 (skipped は overwrite を - # 抑止した = 上書きしなかったキーのみ)。 - if key in existing: - if existing[key] != value: - skipped.append(key) +def _build_plans( + filtered: dict, devbase_root: Path, opts: ImportOptions +) -> Tuple[List[_merge.Plan], Optional[Tuple[Path, bytes]]]: + """フィルタ済みメンバーから書き出し計画と sources.yml の参照用コピー対象を返す""" + plans: List[_merge.Plan] = [] + sources_reference: Optional[Tuple[Path, bytes]] = None + try: + for arcname, data in sorted(filtered.items()): + target = _merge.target_for(arcname, devbase_root) + if arcname == 'env/sources.yml': + plan = _merge.plan_sources(target, data, + merge_metadata=opts.merge_metadata) + if plan is not None: + plans.append(plan) else: - added.append(key) - merged[key] = value - elif opts.merge == 'keep-existing': - for key, value in incoming.items(): - if key in existing: - skipped.append(key) + sources_reference = (target, data) else: - merged[key] = value - added.append(key) - elif opts.merge == 'prefer-incoming': - for key, value in incoming.items(): - if key in existing: - if existing[key] != value: - overwritten.append(key) - merged[key] = value - else: - merged[key] = value - added.append(key) - else: - raise ImportError(f"不明な --merge モード: {opts.merge!r}") - - return _build_merge_plan( - target=target, - arcname=arcname, - incoming_bytes=incoming_bytes, - existing_bytes=existing_bytes, - target_exists=target_exists, - merged=merged, - added=added, - overwritten=overwritten, - skipped=skipped, - ) - - -def _plan_sources(target: Path, incoming_bytes: bytes, - opts: ImportOptions) -> Optional[_Plan]: - """.env.sources.yml の取り扱い計画 - - 既定: 上書きせず None を返す (backup_dir に参照用コピーのみ書く)。 - --merge-metadata: 新規 source エントリのみ追加した内容で更新する。 - """ - if not opts.merge_metadata: - # 上書きしないので _Plan は返さない。参照用 copy は run() 側で処理。 - return None - - try: - incoming = yaml.safe_load(incoming_bytes) or {} - except yaml.YAMLError as e: - raise ImportError(f"バンドルの sources.yml が壊れています: {e}") from e - if not isinstance(incoming, dict): - raise ImportError("バンドルの sources.yml が dict ではありません") - incoming_sources = incoming.get('sources') or {} - if not isinstance(incoming_sources, dict): - raise ImportError("バンドルの sources.yml の sources が dict ではありません") - - existing: Dict = {} - if target.exists(): - try: - existing = yaml.safe_load(target.read_bytes()) or {} - except yaml.YAMLError as e: - raise ImportError( - f"既存の {target.name} のパースに失敗しました: {e}" - ) from e - if not isinstance(existing, dict): - existing = {} - existing.setdefault('sources', {}) - if not isinstance(existing['sources'], dict): - existing['sources'] = {} - - added: List[str] = [] - merged_sources = dict(existing['sources']) - for name, entry in incoming_sources.items(): - if name in merged_sources: - continue - merged_sources[name] = entry - added.append(name) - - if not added: - return None # 変化なし - - existing['sources'] = merged_sources - new_bytes = yaml.safe_dump( - existing, default_flow_style=False, allow_unicode=True - ).encode('utf-8') - return _Plan( - target=target, - arcname='env/sources.yml', - new_bytes=new_bytes, - added_keys=sorted(added), - overwritten_keys=[], - skipped_keys=[], - op='sources-merge', - ) - - -def _make_backup_dir(devbase_root: Path, opts: ImportOptions) -> Path: - """バックアップディレクトリを作成する。 - - 秒精度のみだと同一秒に 2 回 import を走らせたときに同じディレクトリを再利用して - 前回バックアップを上書きしてしまうため、microsecond + 連番を付与して衝突を回避する - (PR #15 codex 指摘)。 - """ - if opts.backup_dir: - base = Path(opts.backup_dir).expanduser() - else: - base = devbase_root / 'backups' / 'env-import' - base.mkdir(parents=True, exist_ok=True) - - now = datetime.now() - stem = now.strftime('%Y%m%d-%H%M%S-%f') # microsecond まで - path = base / stem - if not path.exists(): - path.mkdir(parents=True) - return path - # 同一マイクロ秒に複数回走った場合の安全弁: 連番を付与 - for n in range(1, 1000): - candidate = base / f'{stem}-{n:02d}' - if not candidate.exists(): - candidate.mkdir(parents=True) - return candidate - raise ImportError( - f"backup ディレクトリの衝突回避に失敗しました (base={base}, stem={stem})" - ) - - -def _backup_existing(plans: Sequence[_Plan], sources_copy: Optional[Tuple[Path, bytes]], - backup_dir: Path, devbase_root: Path) -> None: - """phase 1 前に既存ファイルの内容を backup_dir にコピーする""" - for plan in plans: - if not plan.target.exists(): - continue - try: - relative = plan.target.relative_to(devbase_root) - except ValueError: - relative = Path(plan.target.name) - dst = backup_dir / relative - dst.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(plan.target, dst) - - # バンドルに含まれていた sources.yml の参照用コピー (上書きしないケース) - if sources_copy is not None: - target, data = sources_copy - dst = backup_dir / 'sources.yml.imported' - dst.parent.mkdir(parents=True, exist_ok=True) - dst.write_bytes(data) - try: - os.chmod(dst, 0o600) - except OSError: - pass - - -def _write_atomic(plan: _Plan) -> Path: - """phase 1: 新内容を .import.tmp として書き出す (0600)""" - tmp = plan.target.with_suffix(plan.target.suffix + '.import.tmp') - tmp.parent.mkdir(parents=True, exist_ok=True) - if tmp.exists(): - # 過去の失敗の残骸を掃除 - try: - tmp.unlink() - except OSError: - pass - flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC - fd = os.open(tmp, flags, 0o600) - try: - with os.fdopen(fd, 'wb') as f: - f.write(plan.new_bytes) - except BaseException: - try: - os.close(fd) - except OSError: - pass - raise - try: - os.chmod(tmp, 0o600) - except OSError: - pass - return tmp - - -def _commit(plans_and_tmps: List[Tuple[_Plan, Path]], backup_dir: Path, - devbase_root: Path) -> List[Path]: - """phase 2: tmp → target に rename。 - - 途中失敗時は best-effort で rollback したうえで、まだ rename されていない - 残りの ``.import.tmp`` ファイルもクリーンアップする (PR #15 gemini 指摘)。 - """ - committed: List[Tuple[_Plan, Path]] = [] - remaining_tmps = [tmp for _, tmp in plans_and_tmps] - try: - for idx, (plan, tmp) in enumerate(plans_and_tmps): - os.replace(tmp, plan.target) - try: - os.chmod(plan.target, 0o600) - except OSError: - pass - committed.append((plan, plan.target)) - # rename 済みの tmp は残らないが、リストから除外して後続 cleanup を簡潔に - remaining_tmps[idx] = None # type: ignore[call-overload] - except OSError as e: - logger.error("commit フェーズで失敗しました: %s", e) - try: - _rollback(committed, backup_dir, devbase_root) - finally: - # rename 前で残っている .import.tmp を後始末 - _cleanup_tmps([t for t in remaining_tmps if t is not None]) - raise ImportError(f"commit フェーズで失敗しました: {e}") from e - return [t for _, t in committed] - - -def _rollback(committed: Sequence[Tuple[_Plan, Path]], backup_dir: Path, - devbase_root: Path) -> None: - """best-effort ロールバック: - - 既存上書き (backup あり) → backup から復元 - - backup が無いケース → 元ファイルが存在しなかった (= 新規作成) と - みなして unlink し、元の「不在」状態に戻す。``op='create'`` だけでなく - ``op='sources-merge'`` で sources.yml を新規作成したケースもここで - unlink する (PR #15 gemini 指摘)。 - - ``_backup_existing`` は target が存在した場合のみ backup を作る。よって - 「backup が無い」事実は「元ファイルが存在しなかった」ことを示している。 - """ - for plan, target in committed: - try: - relative = target.relative_to(devbase_root) - except ValueError: - relative = Path(target.name) - src = backup_dir / relative - if src.exists(): - try: - shutil.copy2(src, target) - logger.warning("rollback: %s を %s から復元しました", target, src) - except OSError as e: - logger.error("rollback 失敗: %s -> %s: %s", src, target, e) - else: - # 元ファイル不在 → 新規作成された target を unlink して元の状態に戻す - try: - target.unlink() - logger.warning("rollback: 新規作成された %s を削除しました", target) - except FileNotFoundError: - pass - except OSError as e: - logger.error("rollback unlink 失敗: %s: %s", target, e) - - -def _cleanup_tmps(tmps: Sequence[Path]) -> None: - for tmp in tmps: - try: - if tmp.exists(): - tmp.unlink() - except OSError: - pass - - -def _gc_backups(backup_dir: Path, keep_last: int) -> None: - """backup_dir の親ディレクトリ内の古い backup を keep_last 個まで残して GC する。 - - 安全性のため、削除対象は devbase が生成するタイムスタンプ形式 - (YYYYMMDD-HHMMSS) のディレクトリに限定する。--backup-dir で指定された - 親ディレクトリに無関係なファイル/ディレクトリがあっても、それらは触らない。 - """ - if keep_last <= 0: - return - parent = backup_dir.parent - if not parent.is_dir(): - return - siblings = sorted( - (p for p in parent.iterdir() - if p.is_dir() and _BACKUP_DIR_NAME_RE.match(p.name)), - key=lambda p: p.name, - ) - if len(siblings) <= keep_last: - return - to_remove = siblings[:-keep_last] - for d in to_remove: - try: - shutil.rmtree(d) - logger.info("古い backup を削除しました: %s", d) - except OSError as e: - logger.warning("backup 削除に失敗 (%s): %s", d, e) - - -def _log_plans(plans: Sequence[_Plan], dry_run: bool) -> None: - prefix = "[dry-run] " if dry_run else "" - for plan in plans: - logger.info( - "%s%s: %s (+%d add / ~%d overwrite / -%d skip)", - prefix, plan.op, plan.target, - len(plan.added_keys), len(plan.overwritten_keys), len(plan.skipped_keys), - ) - if plan.added_keys: - logger.info(" added: %s", ", ".join(plan.added_keys)) - if plan.overwritten_keys: - logger.info(" overwrite: %s", ", ".join(plan.overwritten_keys)) - if plan.skipped_keys: - logger.info(" skip (existing kept): %s", ", ".join(plan.skipped_keys)) + plans.append(_merge.plan_env_merge( + target, data, arcname, + merge=opts.merge, + replace=opts.replace, + replace_keys=opts.replace_keys, + )) + except _merge.MergeError as e: + raise ImportError(str(e)) from e + return plans, sources_reference def import_bundle(devbase_root: Path, opts: ImportOptions) -> int: """import 本体。CLI ハンドラから呼ばれる""" - # 引数の早期検証 - if opts.merge not in _MERGE_MODES: - raise ImportError( - f"--merge の値が不正です: {opts.merge!r} (許可: {', '.join(_MERGE_MODES)})" - ) - if opts.replace and opts.replace_keys: - raise ImportError("--replace と --replace-keys は併用できません") - if opts.passphrase_stdin and opts.source == '-': - raise ImportError( - "SOURCE='-' (stdin) と --passphrase-stdin は併用できません " - "(stdin が衝突します)" - ) - if opts.passphrase_env and opts.passphrase_stdin: - raise ImportError("--passphrase-env と --passphrase-stdin は併用できません") + _validate_options(opts) - # SOURCE 読み込み backend = _storage.resolve(opts.source) blob = backend.read_bytes(opts.source) logger.debug("読み込みサイズ: %d bytes", len(blob)) - # 復号 (必要なら) + 展開 + manifest 検証 (sha256 / version) tar_blob = _decrypt_if_needed(blob, opts) manifest, members = _bundle.unpack(tar_blob) logger.info("バンドル version=%s, 生成=%s, devbase=%s", manifest.get('version'), manifest.get('created_at'), manifest.get('devbase_version')) - filtered = _filter_members(members, opts) + filtered = _merge.filter_members( + members, + include_global=opts.include_global, + include_metadata=opts.include_metadata, + include_projects=opts.include_projects, + exclude_projects=opts.exclude_projects, + ) if not filtered: raise ImportError( "import 対象がありません " "(--no-global / --include-project の指定とバンドル内容を確認してください)" ) - # 計画作成 - plans: List[_Plan] = [] - sources_reference: Optional[Tuple[Path, bytes]] = None - for arcname, data in sorted(filtered.items()): - target = _target_for(arcname, devbase_root) - if arcname == 'env/sources.yml': - plan = _plan_sources(target, data, opts) - if plan is not None: - plans.append(plan) - else: - # 既定動作: 上書きしないので参照用 copy のみバックアップする - sources_reference = (target, data) - else: - plans.append(_plan_env_merge(target, data, opts, arcname)) + plans, sources_reference = _build_plans(filtered, devbase_root, opts) - _log_plans(plans, opts.dry_run) + _merge.log_plans(plans, opts.dry_run) if sources_reference is not None and not opts.merge_metadata: logger.info( "%ssources.yml は上書きしません (--merge-metadata 指定時のみ更新, " @@ -683,29 +182,28 @@ def import_bundle(devbase_root: Path, opts: ImportOptions) -> int: if opts.dry_run: logger.info("[dry-run] 書き込みは行いません") return 0 - if not plans and sources_reference is None: logger.info("変更はありません") return 0 - # backup → phase 1 (tmp 書き出し) → phase 2 (rename) - backup_dir = _make_backup_dir(devbase_root, opts) + backup_dir = _atomic.make_backup_dir(devbase_root, opts.backup_dir) logger.info("backup ディレクトリ: %s", backup_dir) - _backup_existing(plans, sources_reference, backup_dir, devbase_root) + _atomic.backup_existing(plans, sources_reference, backup_dir, devbase_root) - tmps: List[Path] = [] - plans_and_tmps: List[Tuple[_Plan, Path]] = [] + plans_and_tmps: List[Tuple[_merge.Plan, Path]] = [] try: for plan in plans: - tmp = _write_atomic(plan) - tmps.append(tmp) + tmp = _atomic.write_atomic(plan) plans_and_tmps.append((plan, tmp)) except Exception: - _cleanup_tmps(tmps) + _atomic.cleanup_tmps(tmp for _, tmp in plans_and_tmps) raise - _commit(plans_and_tmps, backup_dir, devbase_root) + try: + _atomic.commit(plans_and_tmps, backup_dir, devbase_root) + except _atomic.AtomicError as e: + raise ImportError(str(e)) from e logger.info("import 完了: %d ファイル更新", len(plans)) - _gc_backups(backup_dir, opts.keep_last) + _atomic.gc_backups(backup_dir, opts.keep_last) return 0 diff --git a/lib/devbase/env/storage.py b/lib/devbase/env/storage.py index adaa744..ffb36c5 100644 --- a/lib/devbase/env/storage.py +++ b/lib/devbase/env/storage.py @@ -45,39 +45,13 @@ class LocalBackend: """ローカルファイルシステム""" def write_bytes(self, dest: str, data: bytes) -> None: + # 暗号化済みバンドルでも平文 export でも 0600 強制 (TOCTOU 回避)。 + # 共通実装は io_common.write_secure_bytes へ集約。 + from devbase.env import io_common as _io_common + path = _to_local_path(dest) try: - if path.parent and not path.parent.exists(): - path.parent.mkdir(parents=True, exist_ok=True) - # TOCTOU 回避: open(..., 'wb') 後に chmod すると、umask が緩い環境では - # 一瞬 0644 等で平文 export が露出する。 - # os.open に mode=0o600 を渡し、O_CREAT|O_TRUNC|O_WRONLY で作成時点 - # から 0600 を強制する。既存ファイルも書き込み前に chmod で権限を絞る。 - if path.exists(): - try: - os.chmod(path, 0o600) - except OSError: - # Windows 等で chmod が無効でも処理を続行 - pass - flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC - fd = os.open(path, flags, 0o600) - try: - with os.fdopen(fd, 'wb') as f: - f.write(data) - except BaseException: - # fdopen 失敗時は fd を明示的に閉じる (fdopen 成功時は with が close) - try: - os.close(fd) - except OSError: - pass - raise - # mode 引数が無視される環境 (Windows 等) でも後追いで chmod を試みる - try: - os.chmod(path, 0o600) - except OSError: - pass - except StorageError: - raise + _io_common.write_secure_bytes(path, data) except OSError as e: raise StorageError(f"書き込みに失敗しました ({path}): {e}") from e @@ -223,38 +197,38 @@ def _verify_bucket_encryption(self, client, bucket: str) -> None: except Exception as e: code = self._error_code(e) if code == 'ServerSideEncryptionConfigurationNotFoundError': - msg = ( - f"S3 バケット '{bucket}' のデフォルト暗号化が未設定です。" + problem = f"S3 バケット '{bucket}' のデフォルト暗号化が未設定です。" + guidance = ( "バケットポリシーで SSE-KMS or SSE-S3 を有効化するか、" "明示的に '--unsafe-allow-unencrypted-bucket' を指定してください " "(オブジェクト単位の SSE はこのオプションに関係なく常に付与されます)" ) if self._options.unsafe_allow_unencrypted_bucket: - logger.warning("%s (unsafe フラグにより続行)", msg) + logger.warning("%s (unsafe フラグにより続行)", problem) return - raise StorageError(msg) from e + raise StorageError(f"{problem}{guidance}") from e if code in ('AccessDenied', 'AccessDeniedException'): - msg = ( + problem = ( f"S3 バケット '{bucket}' の暗号化設定を確認できません " "(GetBucketEncryption 権限がありません)。" + ) + guidance = ( "バケットポリシーの確認が取れないため export を中止します。" "権限を付与するか、'--unsafe-allow-unencrypted-bucket' を明示してください" ) if self._options.unsafe_allow_unencrypted_bucket: - logger.warning("%s (unsafe フラグにより続行)", msg) + logger.warning("%s (unsafe フラグにより続行)", problem) return - raise StorageError(msg) from e + raise StorageError(f"{problem}{guidance}") from e # MinIO / LocalStack 等の S3 互換ストレージでは # GetBucketEncryption が NotImplemented / MethodNotAllowed / 501 等を返す # ことがある。`--unsafe-allow-unencrypted-bucket` 指定時は逃げ道として # 警告のみで続行する (オブジェクト個別の SSE は引き続き付与される)。 - msg = ( - f"バケット暗号化設定の確認に失敗しました ({bucket}): {e}" - ) + problem = f"バケット暗号化設定の確認に失敗しました ({bucket}): {e}" if self._options.unsafe_allow_unencrypted_bucket: - logger.warning("%s (unsafe フラグにより続行)", msg) + logger.warning("%s (unsafe フラグにより続行)", problem) return - raise StorageError(msg) from e + raise StorageError(problem) from e def write_bytes(self, dest: str, data: bytes) -> None: bucket, key = _parse_s3_uri(dest) diff --git a/tests/cli/test_env_import.py b/tests/cli/test_env_import.py index f509895..6f063b8 100644 --- a/tests/cli/test_env_import.py +++ b/tests/cli/test_env_import.py @@ -417,6 +417,7 @@ def test_rollback_unlinks_newly_created_files_on_commit_failure( fake_root, dest_root, age_keys, tmp_path, monkeypatch): """commit フェーズ途中失敗時、元ファイル不在で新規作成された target は unlink され、 部分適用状態が残らないこと""" + from devbase.env import _import_atomic as _atomic from devbase.env import io_import as _io_import _, id_file = age_keys @@ -436,7 +437,7 @@ def failing_replace(src, dst): raise OSError("simulated commit failure") return original_replace(src, dst) - monkeypatch.setattr(_io_import.os, 'replace', failing_replace) + monkeypatch.setattr(_atomic.os, 'replace', failing_replace) with pytest.raises(_io_import.ImportError, match="commit フェーズで失敗"): import_bundle(dest_root, ImportOptions( @@ -578,6 +579,7 @@ def test_rollback_unlinks_newly_created_sources_yml( fake_root, dest_root, age_keys, tmp_path, monkeypatch): """sources.yml を --merge-metadata で新規作成中に commit 失敗すると、 ロールバックで sources.yml が削除されること (PR #15 gemini 指摘)""" + from devbase.env import _import_atomic as _atomic from devbase.env import io_import as _io_import _, id_file = age_keys @@ -594,7 +596,7 @@ def failing_replace(src, dst): raise OSError("simulated commit failure on sources.yml") return original_replace(src, dst) - monkeypatch.setattr(_io_import.os, 'replace', failing_replace) + monkeypatch.setattr(_atomic.os, 'replace', failing_replace) with pytest.raises(_io_import.ImportError, match="commit"): import_bundle(dest_root, ImportOptions( @@ -609,6 +611,7 @@ def test_commit_failure_cleans_remaining_import_tmp_files( fake_root, dest_root, age_keys, tmp_path, monkeypatch): """_commit 失敗時に、まだ rename されていない .import.tmp ファイルが残らないこと (PR #15 gemini 指摘)""" + from devbase.env import _import_atomic as _atomic from devbase.env import io_import as _io_import _, id_file = age_keys @@ -623,7 +626,7 @@ def failing_replace(src, dst): raise OSError("simulated commit failure") return original_replace(src, dst) - monkeypatch.setattr(_io_import.os, 'replace', failing_replace) + monkeypatch.setattr(_atomic.os, 'replace', failing_replace) with pytest.raises(_io_import.ImportError, match="commit"): import_bundle(dest_root, ImportOptions( @@ -921,7 +924,8 @@ def test_env_import_comment_only_existing_replace_reports_op_replace( _setup_comment_only_dest(dest_root) - with caplog.at_level(logging.INFO, logger="devbase.env.io_import"): + # plan 表示は _import_merge.log_plans で行われるためそのモジュールの logger を捕捉する + with caplog.at_level(logging.INFO, logger="devbase.env._import_merge"): rc = import_bundle(dest_root, ImportOptions( source=str(bundle_path), identities=[str(id_file)], replace=True))