Skip to content

Commit f1fa837

Browse files
takemi-ohamaclaude
andauthored
feat(env): PLAN03-1 PR5 ドキュメント追加 + import/export リファクタ (#20)
* refactor(env): PLAN03-1 PR5 env export/import モジュールを整理 - 711 行に肥大化していた `io_import.py` を以下の 3 モジュールに分割する。 公開 API (`ImportOptions`, `import_bundle`, `ImportError`) は維持し、 テストの `_read_passphrase` 直接 import や `getpass` パッチも継続して動く: - `io_import.py` (209 行): 引数検証 / 復号判定 / 全体オーケストレーション - `_import_merge.py`: `Plan` データクラス、`plan_env_merge` / `plan_sources`、 既存コメント・空行を保持した merge ロジック、ログ整形 - `_import_atomic.py`: 2 フェーズ書き込み (`backup_existing` → `write_atomic` → `commit`)、`gc_backups`、ロールバック - export / import で重複していた共通 helper を `io_common.py` に集約する: - `read_passphrase()` — env / stdin 入力、tty 時の getpass エコー抑止 - `resolve_recipient_specs()` / `resolve_identity_specs()` — 省略時の `~/.ssh/id_ed25519(.pub)` → `id_rsa(.pub)` fallback - `write_secure_bytes()` — `os.open(O_WRONLY|O_CREAT|O_TRUNC, mode=0o600)` で TOCTOU を避けてセキュアにバイト列を書き出す共通実装。`storage.LocalBackend` と `_import_atomic` から呼び出す - `_plan_env_merge()` の 4 段ネスト if/elif を 4 つの小さな関数 (`_plan_replace` / `_plan_replace_keys` / `_plan_keep_existing` / `_plan_prefer_incoming`) に分割し、`plan_env_merge` 本体はモード選択のみに 簡素化する - `storage.LocalBackend.write_bytes` を `io_common.write_secure_bytes` への薄いラッパに置き換え、重複していた `os.open` + chmod パターンを削除 - `io_export.py` (185 → 168 行) の `_read_passphrase` / `_resolve_recipients` は `io_common` への delegation に置き換え、`encrypt_payload` / `validate_options` の helper 関数に export 本体のロジックを分解 - `_commit()` 移動に伴い、テスト 3 件の `monkeypatch.setattr(_io_import.os, 'replace', ...)` パッチ先を `_import_atomic.os` に更新。`log_plans` 移動に伴う caplog の logger 名も `devbase.env._import_merge` に追従 リファクタの動機: - io_import.py が PR1〜PR3 を通じて 711 行まで肥大化し、merge 計画 / atomic 書き込み / orchestration が同居して読みづらかった - io_export と io_import で `_read_passphrase` / 既定鍵 fallback / セキュア書き込みが ほぼ重複していた 挙動の変更は無く、全 136 テストが引き続き green を維持する。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(env): PLAN03-1 PR5 env export/import 利用者向けガイドを追加 `docs/user/env-export-import.md` (456 行) を新設し、以下を網羅する: - 対象ファイル一覧 (global / projects/*/.env / .env.sources.yml) と 公開可能な雛形 (`projects/*/env`) を含めない設計理由 - クイックスタート (既定鍵での export → 別マシンでの import) - バンドル構造 (manifest.yml の sha256 検証、version 互換ポリシー) - age 暗号化: recipient / identity / passphrase の 3 方式、対応鍵種別表、 ssh-ecdsa 非対応への対処 (`age-keygen` / `ssh-keygen -t ed25519`)、 既定鍵 (`~/.ssh/id_ed25519` → `id_rsa`) - 入出力先: ローカル / stdio / S3。S3 の SSE 強制と `--unsafe-allow-unencrypted-bucket`、`DEVBASE_S3_*` 環境変数 - export / import の全オプション表、merge モード (keep-existing / prefer-incoming / --replace-keys / --replace) の動作比較 - `.env.sources.yml` の取り扱い (既定スキップ + 参照用コピー、 `--merge-metadata` での新規エントリ追加) - 2 フェーズ書き込み + backup + ロールバックの仕組み、 `--keep-last N` での GC、ACID 非保証の注意 - 典型ワークフロー 4 件 (別マシン移行 / 定期バックアップ / S3 チーム共有 / CI 配布) - トラブルシューティング 8 件 加えて以下のリンクを追加: - `README.md`: 「利用者向け」ドキュメント表に env-export-import.md への リンクを追加し、`env` グループの説明に export / import を併記 - `docs/user/environment-variables.md`: 「別マシンへの移行 / バックアップ」 節を新設して env-export-import.md へ誘導、ベストプラクティスに追記 - `CHANGELOG.md` (Unreleased): docs 追加とリファクタリングを記載 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(env): warning ログの文面矛盾を解消 (unsafe フラグ続行時) `_verify_bucket_encryption` で `--unsafe-allow-unencrypted-bucket` 指定時に 「export を中止します。(unsafe フラグにより続行)」という矛盾した警告が出ていた。 問題説明 (problem) と対処案内 (guidance) を分離し、warning は problem のみ、 StorageError raise 時は問題+対処案内を出すよう統一。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9c419fc commit f1fa837

11 files changed

Lines changed: 1279 additions & 702 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,15 @@
1010
- 暗号化が未設定のバケットへ export する場合は `--unsafe-allow-unencrypted-bucket` の明示が必要です (オブジェクト単位の SSE はこのフラグに関係なく常に付与されます)。
1111
- SSE 種別 (`DEVBASE_S3_SSE`) / KMS 鍵 (`DEVBASE_S3_SSE_KMS_KEY_ID`) / エンドポイント (`DEVBASE_S3_ENDPOINT_URL`) / リージョン (`DEVBASE_S3_REGION`) は環境変数で上書きできます。MinIO / LocalStack の利用も可能です。
1212
- `boto3` は main dependency として常に同梱されます (S3 を使わないユーザにも 25MB 程度入りますが、引数検出や lazy install の複雑さを避けるトレードオフです)。
13+
- `devbase env export` / `devbase env import` の利用者向けドキュメント [`docs/user/env-export-import.md`](docs/user/env-export-import.md) を新設しました (PLAN03-1 PR5)。
14+
- バンドル構造、age 暗号化 (recipient / identity / passphrase)、入出力先 (local / stdio / S3)、merge モード比較、`.env.sources.yml` の扱い、2 フェーズ書き込みとバックアップ、典型ワークフロー、トラブルシューティングまでを網羅します。
15+
- README と環境変数ガイドからのリンクも追加しました。
1316

1417
### Changed
1518
- `gs://` (GCS) スキームは **PLAN03-1 PR4 廃案** により対応しません。指定すると明示的なエラーメッセージで失敗します (旧: "未実装")。
19+
- `lib/devbase/env/` 配下の export / import モジュールをリファクタリングしました (PLAN03-1 PR5)。公開 API (`ExportOptions`, `ImportOptions`, `export`, `import_bundle`) に互換性のない変更はありません。
20+
- export / import で重複していた passphrase 読み取り / 既定鍵 fallback / セキュアな bytes 書き込みを `io_common.py` に集約。
21+
- 711 行に肥大化していた `io_import.py` を「orchestration (`io_import.py`, 209 行)」「merge 計画 (`_import_merge.py`)」「2 フェーズ atomic 書き込み + backup GC (`_import_atomic.py`)」の 3 モジュールに分割。
1622

1723
## [2.2.0] - 2026-04-20
1824

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ devbaseのコマンドは4つのグループにまとめられています。
7676
| グループ | 略記 | 説明 |
7777
|---------|------|------|
7878
| `container` | `ct` | コンテナ管理(up / down / login / ps / logs / scale / build) |
79-
| `env` || 環境変数管理(init / sync / list / set / get / delete / edit / project) |
79+
| `env` || 環境変数管理(init / sync / list / set / get / delete / edit / project / export / import|
8080
| `plugin` | `pl` | プラグイン管理(list / install / uninstall / update / info / sync / repo) |
8181
| `snapshot` | `ss` | スナップショット管理(create / list / restore / copy / delete / rotate) |
8282

@@ -106,6 +106,7 @@ devbaseのコマンドは4つのグループにまとめられています。
106106
| [CLIリファレンス](docs/user/cli-reference.md) | 全コマンドの構文・オプション・使用例 |
107107
| [プラグインレジストリ](docs/user/plugin-registries.md) | 公開・社内レジストリの一覧と追加方法 |
108108
| [環境変数ガイド](docs/user/environment-variables.md) | 3レベル構造、コレクター、ソース同期 |
109+
| [環境変数の export/import ガイド](docs/user/env-export-import.md) | バンドル形式・age 暗号化・S3 連携・merge/replace の運用 |
109110
| [コンテナ操作ガイド](docs/user/container-operations.md) | ライフサイクル、並行開発、ボリューム構造 |
110111
| [スナップショットガイド](docs/user/snapshot-guide.md) | 増分バックアップ、世代管理、復元手順 |
111112
| [トラブルシューティング](docs/user/troubleshooting.md) | カテゴリ別の問題と解決策 |

docs/user/env-export-import.md

Lines changed: 456 additions & 0 deletions
Large diffs are not rendered by default.

docs/user/environment-variables.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,9 +240,22 @@ ls ~/.aws/
240240

241241
> **Warning:** 環境変数を変更した後は `devbase up` でコンテナを再起動してください。起動中のコンテナには反映されません。
242242
243+
## 別マシンへの移行 / バックアップ
244+
245+
複数プロジェクトの `.env` 群を 1 つのバンドルにまとめ、暗号化したまま転送・復元するには `devbase env export` / `devbase env import` を使います。詳細は [環境変数の export/import ガイド](env-export-import.md) を参照してください。
246+
247+
```bash
248+
# 既存マシンで export (~/.ssh/id_ed25519.pub があれば鍵指定省略可)
249+
devbase env export ./bundle.dbenv
250+
251+
# 新マシンで import (既定は keep-existing マージ)
252+
devbase env import ./bundle.dbenv
253+
```
254+
243255
## ベストプラクティス
244256

245257
1. **機密情報は `.env` に格納する** -- Git 管理対象の `env` ファイルには機密情報を含めない
246258
2. **プロジェクト固有の設定は `-p` フラグを使う** -- グローバル設定を汚染しない
247259
3. **`env sync` を定期的に実行する** -- ホストマシンの認証情報更新後は必ず同期
248260
4. **`.env.sources.yml` を Git 管理しない** -- 環境固有のハッシュ情報のため
261+
5. **別マシンへの移行は `devbase env export` を使う** -- `scp -r` で個別コピーする代わりに、暗号化バンドル 1 ファイルで安全に移動できる ([詳細](env-export-import.md))

lib/devbase/env/_import_atomic.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
"""``devbase env import`` の 2 フェーズ atomic 書き込み + backup / GC
2+
3+
import_bundle が作る :class:`_import_merge.Plan` 群を、以下の手順で適用する:
4+
5+
1. ``backup_existing`` — 既存 target をタイムスタンプ付き backup_dir にコピー
6+
2. ``write_atomic`` (per plan) — ``<target>.import.tmp`` に 0600 で書き出し
7+
3. ``commit`` — 全 tmp を ``os.replace`` で一括 rename。途中失敗時は backup から
8+
best-effort で rollback し、未 rename の tmp も後始末する
9+
10+
加えて ``gc_backups`` で古い backup ディレクトリを ``keep_last`` 個まで圧縮する。
11+
モジュール内で ``os`` を直接参照しており、テストはこの ``os.replace`` を
12+
monkeypatch することで commit 失敗パスを再現する。
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import os
18+
import re
19+
import shutil
20+
from datetime import datetime
21+
from pathlib import Path
22+
from typing import List, Optional, Sequence, Tuple
23+
24+
from devbase.errors import DevbaseError
25+
from devbase.log import get_logger
26+
27+
from devbase.env import io_common as _io_common
28+
from devbase.env._import_merge import Plan
29+
30+
logger = get_logger(__name__)
31+
32+
# _make_backup_dir が生成するタイムスタンプ形式のみを GC 対象にする。
33+
# 以下のいずれかにマッチするディレクトリのみ削除する:
34+
# YYYYMMDD-HHMMSS (旧フォーマット, 後方互換)
35+
# YYYYMMDD-HHMMSS-NNNNNN (microsecond 付き)
36+
# YYYYMMDD-HHMMSS-NNNNNN-NN (同一マイクロ秒内の連番付き)
37+
# これ以外のディレクトリは devbase が作ったものではないので削除しない
38+
# (--backup-dir 親に無関係なディレクトリがあっても安全)。
39+
_BACKUP_DIR_NAME_RE = re.compile(r'^\d{8}-\d{6}(?:-\d{6}(?:-\d+)?)?$')
40+
41+
42+
class AtomicError(DevbaseError):
43+
"""atomic 書き込み中のエラー (ImportError へ委譲する用途で投げる)"""
44+
45+
46+
def make_backup_dir(devbase_root: Path, backup_dir: Optional[str]) -> Path:
47+
"""``--backup-dir`` 指定 or ``$DEVBASE_ROOT/backups/env-import/`` 配下に
48+
タイムスタンプ命名の backup ディレクトリを作る。
49+
50+
秒精度のみだと同一秒に 2 回 import を走らせたときに同じディレクトリを再利用して
51+
前回バックアップを上書きしてしまうため、microsecond + 連番を付与して衝突を回避する
52+
(PR #15 codex 指摘)。
53+
"""
54+
base = (Path(backup_dir).expanduser() if backup_dir
55+
else devbase_root / 'backups' / 'env-import')
56+
base.mkdir(parents=True, exist_ok=True)
57+
58+
stem = datetime.now().strftime('%Y%m%d-%H%M%S-%f') # microsecond まで
59+
primary = base / stem
60+
if not primary.exists():
61+
primary.mkdir(parents=True)
62+
return primary
63+
# 同一マイクロ秒に複数回走った場合の安全弁: 連番を付与
64+
for n in range(1, 1000):
65+
candidate = base / f'{stem}-{n:02d}'
66+
if not candidate.exists():
67+
candidate.mkdir(parents=True)
68+
return candidate
69+
raise AtomicError(
70+
f"backup ディレクトリの衝突回避に失敗しました (base={base}, stem={stem})"
71+
)
72+
73+
74+
def _backup_relative(target: Path, devbase_root: Path) -> Path:
75+
"""target を devbase_root 相対表現に変換。外にあるパスはファイル名のみを使う。"""
76+
try:
77+
return target.relative_to(devbase_root)
78+
except ValueError:
79+
return Path(target.name)
80+
81+
82+
def backup_existing(plans: Sequence[Plan],
83+
sources_copy: Optional[Tuple[Path, bytes]],
84+
backup_dir: Path, devbase_root: Path) -> None:
85+
"""phase 1 前に既存ファイルの内容を ``backup_dir`` にコピーする"""
86+
for plan in plans:
87+
if not plan.target.exists():
88+
continue
89+
dst = backup_dir / _backup_relative(plan.target, devbase_root)
90+
dst.parent.mkdir(parents=True, exist_ok=True)
91+
shutil.copy2(plan.target, dst)
92+
93+
# バンドルに含まれていた sources.yml の参照用コピー (上書きしないケース)
94+
if sources_copy is not None:
95+
_, data = sources_copy
96+
dst = backup_dir / 'sources.yml.imported'
97+
dst.parent.mkdir(parents=True, exist_ok=True)
98+
_io_common.write_secure_bytes(dst, data)
99+
100+
101+
def write_atomic(plan: Plan) -> Path:
102+
"""phase 1: 新内容を ``<target>.import.tmp`` として 0600 で書き出す"""
103+
tmp = plan.target.with_suffix(plan.target.suffix + '.import.tmp')
104+
if tmp.exists():
105+
# 過去の失敗の残骸を掃除
106+
try:
107+
tmp.unlink()
108+
except OSError:
109+
pass
110+
_io_common.write_secure_bytes(tmp, plan.new_bytes)
111+
return tmp
112+
113+
114+
def commit(plans_and_tmps: List[Tuple[Plan, Path]], backup_dir: Path,
115+
devbase_root: Path) -> List[Path]:
116+
"""phase 2: tmp → target に rename。
117+
118+
途中失敗時は best-effort で rollback したうえで、まだ rename されていない
119+
``.import.tmp`` ファイルもクリーンアップする (PR #15 gemini 指摘)。
120+
"""
121+
committed: List[Tuple[Plan, Path]] = []
122+
remaining = list(plans_and_tmps)
123+
try:
124+
while remaining:
125+
plan, tmp = remaining[0]
126+
os.replace(tmp, plan.target)
127+
try:
128+
os.chmod(plan.target, 0o600)
129+
except OSError:
130+
pass
131+
committed.append((plan, plan.target))
132+
remaining.pop(0)
133+
except OSError as e:
134+
logger.error("commit フェーズで失敗しました: %s", e)
135+
try:
136+
_rollback(committed, backup_dir, devbase_root)
137+
finally:
138+
cleanup_tmps(tmp for _, tmp in remaining)
139+
raise AtomicError(f"commit フェーズで失敗しました: {e}") from e
140+
return [t for _, t in committed]
141+
142+
143+
def _rollback(committed: Sequence[Tuple[Plan, Path]], backup_dir: Path,
144+
devbase_root: Path) -> None:
145+
"""best-effort ロールバック:
146+
- 既存上書き (backup あり) → backup から復元
147+
- backup が無いケース → 元ファイルが存在しなかった (= 新規作成) と
148+
みなして unlink し、元の「不在」状態に戻す。``op='create'`` だけでなく
149+
``op='sources-merge'`` で sources.yml を新規作成したケースもここで
150+
unlink する (PR #15 gemini 指摘)。
151+
152+
``backup_existing`` は target が存在した場合のみ backup を作る。よって
153+
「backup が無い」事実は「元ファイルが存在しなかった」ことを示している。
154+
"""
155+
for _, target in committed:
156+
src = backup_dir / _backup_relative(target, devbase_root)
157+
if src.exists():
158+
try:
159+
shutil.copy2(src, target)
160+
logger.warning("rollback: %s を %s から復元しました", target, src)
161+
except OSError as e:
162+
logger.error("rollback 失敗: %s -> %s: %s", src, target, e)
163+
continue
164+
# 元ファイル不在 → 新規作成された target を unlink して元の状態に戻す
165+
try:
166+
target.unlink()
167+
logger.warning("rollback: 新規作成された %s を削除しました", target)
168+
except FileNotFoundError:
169+
pass
170+
except OSError as e:
171+
logger.error("rollback unlink 失敗: %s: %s", target, e)
172+
173+
174+
def cleanup_tmps(tmps) -> None:
175+
"""``.import.tmp`` の残骸を削除する (失敗は無視)"""
176+
for tmp in tmps:
177+
try:
178+
if tmp.exists():
179+
tmp.unlink()
180+
except OSError:
181+
pass
182+
183+
184+
def gc_backups(backup_dir: Path, keep_last: int) -> None:
185+
"""``backup_dir`` の親ディレクトリで古い backup を ``keep_last`` 個まで残して GC する。
186+
187+
devbase 生成のタイムスタンプ形式 (``YYYYMMDD-HHMMSS[-NNNNNN[-NN]]``) に
188+
マッチするディレクトリのみが GC 対象。``--backup-dir`` 親に無関係な
189+
ファイル / ディレクトリがあっても、それらは触らない。
190+
"""
191+
if keep_last <= 0:
192+
return
193+
parent = backup_dir.parent
194+
if not parent.is_dir():
195+
return
196+
candidates = [p for p in parent.iterdir()
197+
if p.is_dir() and _BACKUP_DIR_NAME_RE.match(p.name)]
198+
if len(candidates) <= keep_last:
199+
return
200+
# 名前 (= タイムスタンプ) でソート、古いものから捨てる。keep_last は通常 10 程度なので
201+
# 全件ソートで十分 (heapq.nsmallest を使うほどの規模ではない)。
202+
candidates.sort(key=lambda p: p.name)
203+
for d in candidates[:-keep_last]:
204+
try:
205+
shutil.rmtree(d)
206+
logger.info("古い backup を削除しました: %s", d)
207+
except OSError as e:
208+
logger.warning("backup 削除に失敗 (%s): %s", d, e)

0 commit comments

Comments
 (0)