Skip to content

Commit dfa0113

Browse files
committed
release: PLAN03-1 devbase env export / import + S3 backend
(cross-review rotation: PR #13 を squash 統合)
1 parent 25c30e4 commit dfa0113

30 files changed

Lines changed: 5677 additions & 44 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ __pycache__/
22
.venv/
33
.env
44
.env.backup
5+
.gemini/
56
.docker-compose.scale.yml
67
plugins.yml
78
plugins/*/

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@
44

55
## [Unreleased]
66

7+
### Added
8+
- `devbase env export` / `devbase env import`**S3 URI (`s3://bucket/key`) を入出力先として指定**できるようになりました (PLAN03-1 PR3)。
9+
- 既定でオブジェクト単位の SSE (`aws:kms` または `AES256`) を強制し、export 時はバケット側のデフォルト暗号化も `GetBucketEncryption` で事前確認します。
10+
- 暗号化が未設定のバケットへ export する場合は `--unsafe-allow-unencrypted-bucket` の明示が必要です (オブジェクト単位の SSE はこのフラグに関係なく常に付与されます)。
11+
- SSE 種別 (`DEVBASE_S3_SSE`) / KMS 鍵 (`DEVBASE_S3_SSE_KMS_KEY_ID`) / エンドポイント (`DEVBASE_S3_ENDPOINT_URL`) / リージョン (`DEVBASE_S3_REGION`) は環境変数で上書きできます。MinIO / LocalStack の利用も可能です。
12+
- `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 と環境変数ガイドからのリンクも追加しました。
16+
17+
### Changed
18+
- `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 モジュールに分割。
22+
723
## [2.2.0] - 2026-04-20
824

925
OSS 化に伴う初回リリース。devbase は本バージョンより `devbasex` Organization 配下で公開されます。

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))

etc/_devbase

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ _devbase() {
7373
'delete:Delete a variable'
7474
'edit:Open .env in editor'
7575
'project:Setup project-specific variables'
76+
'export:Export .env files as an encrypted bundle (age)'
77+
'import:Import .env bundle (age decrypt + merge)'
7678
)
7779

7880
plugin_subcommands=(
@@ -150,6 +152,37 @@ _devbase() {
150152
get|delete)
151153
_arguments '1:key:'
152154
;;
155+
export)
156+
_arguments \
157+
'1:dest:_files' \
158+
'*--include-project[Limit to specified project (repeatable)]:name:' \
159+
'*--exclude-project[Exclude project (repeatable)]:name:' \
160+
'--no-global[Exclude $DEVBASE_ROOT/.env]' \
161+
'--no-metadata[Exclude $DEVBASE_ROOT/.env.sources.yml]' \
162+
'*--recipient[age / OpenSSH public key (repeatable)]:key:' \
163+
'--passphrase-env[Read passphrase from env var]:var:' \
164+
'--passphrase-stdin[Read passphrase from stdin]' \
165+
'--force-unencrypted[Write as plaintext tar.gz]' \
166+
'--unsafe-allow-unencrypted-bucket[Allow uploading unencrypted tar.gz to S3 (off by default)]'
167+
;;
168+
import)
169+
_arguments \
170+
'1:source:_files' \
171+
'--merge[Merge strategy]:mode:(keep-existing prefer-incoming)' \
172+
'--replace-keys[Replace only these keys (comma-separated)]:keys:' \
173+
'--replace[Replace existing files entirely]' \
174+
'--dry-run[Preview changes without writing]' \
175+
'*--identity[age / OpenSSH private key file (repeatable)]:file:_files' \
176+
'--passphrase-env[Read passphrase from env var]:var:' \
177+
'--passphrase-stdin[Read passphrase from stdin]' \
178+
'*--include-project[Limit to specified project (repeatable)]:name:' \
179+
'*--exclude-project[Exclude project (repeatable)]:name:' \
180+
'--no-global[Do not import $DEVBASE_ROOT/.env]' \
181+
'--no-metadata[Do not import $DEVBASE_ROOT/.env.sources.yml]' \
182+
'--merge-metadata[Add only new sources entries from bundle]' \
183+
'--backup-dir[Override backup directory]:dir:_files -/' \
184+
'--keep-last[Keep only the last N backup directories]:n:'
185+
;;
153186
*)
154187
_describe -t env-commands 'env command' env_subcommands
155188
;;

etc/devbase-completion.bash

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ _devbase_completions() {
1212

1313
local commands="init status shell-rc container ct env plugin pl snapshot ss up down login build ps help"
1414
local container_subcommands="up down ps login logs scale build"
15-
local env_subcommands="init sync list set get delete edit project"
15+
local env_subcommands="init sync list set get delete edit project export import"
1616
local plugin_subcommands="list install uninstall update info sync repo"
1717
local repo_subcommands="add remove list refresh"
1818
local snapshot_subcommands="create list restore copy delete rotate"
@@ -81,6 +81,16 @@ _devbase_completions() {
8181
COMPREPLY=($(compgen -W "--project -p" -- "$cur"))
8282
fi
8383
;;
84+
export)
85+
if [[ "$cur" == -* ]]; then
86+
COMPREPLY=($(compgen -W "--include-project --exclude-project --no-global --no-metadata --recipient --passphrase-env --passphrase-stdin --force-unencrypted --unsafe-allow-unencrypted-bucket" -- "$cur"))
87+
fi
88+
;;
89+
import)
90+
if [[ "$cur" == -* ]]; then
91+
COMPREPLY=($(compgen -W "--merge --replace-keys --replace --dry-run --identity --passphrase-env --passphrase-stdin --include-project --exclude-project --no-global --no-metadata --merge-metadata --backup-dir --keep-last" -- "$cur"))
92+
fi
93+
;;
8494
esac
8595
fi
8696
# plugin subcommand arguments

lib/devbase/cli.py

Lines changed: 111 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,23 @@
3636
# Subcommand map for prefix resolution: {(aliases...): [subcmds]}
3737
SUBCMD_MAP = {
3838
('container', 'ct'): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build'],
39-
('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project'],
39+
('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project', 'export', 'import'],
4040
('plugin', 'pl'): ['list', 'install', 'uninstall', 'update', 'info', 'sync', 'repo'],
4141
('snapshot', 'ss'): ['create', 'list', 'restore', 'copy', 'delete', 'rotate'],
4242
}
4343

44+
# 後方互換: prefix が複数候補にマッチする場合に、特定の入力を特定のサブコマンドに
45+
# 優先的に解決させる。例えば `devbase env e` は従来 `edit` のみに解決されていたが、
46+
# `export` 追加後は ambiguous になるため、既存ショートカットを維持するために維持先を明示する。
47+
SUBCMD_PREFIX_PREFERENCES = {
48+
('env',): {
49+
'e': 'edit',
50+
# `import` 追加で `i` が `init` / `import` の両方にマッチして ambiguous に
51+
# なるため、既存ショートカット (`devbase env i` → `init`) を維持する。
52+
'i': 'init',
53+
},
54+
}
55+
4456

4557
def _require_devbase_root() -> Path:
4658
"""Get DEVBASE_ROOT from environment, exiting if not set."""
@@ -109,6 +121,90 @@ def _add_env_parser(subparsers):
109121
env_sub.add_parser('edit', help='Open .env in editor')
110122
env_sub.add_parser('project', help='Setup project-specific variables')
111123

124+
env_export = env_sub.add_parser(
125+
'export',
126+
help='Export .env files as an encrypted bundle (age)',
127+
)
128+
env_export.add_argument('dest', nargs='?', default=None,
129+
help="Output path (default: ./devbase-env-<TS>.dbenv, '-' for stdout)")
130+
env_export.add_argument('--include-project', action='append', default=None,
131+
metavar='NAME', dest='include_projects',
132+
help='Limit to specified project (repeatable)')
133+
env_export.add_argument('--exclude-project', action='append', default=[],
134+
metavar='NAME', dest='exclude_projects',
135+
help='Exclude project (repeatable)')
136+
env_export.add_argument('--no-global', action='store_true',
137+
help='Exclude $DEVBASE_ROOT/.env')
138+
env_export.add_argument('--no-metadata', action='store_true',
139+
help='Exclude $DEVBASE_ROOT/.env.sources.yml')
140+
env_export.add_argument('--recipient', action='append', default=[],
141+
metavar='KEY', dest='recipients',
142+
help=("age / OpenSSH public key (repeatable). "
143+
"Formats: 'age1...', 'ssh-ed25519 AAAA...', 'ssh-rsa AAAA...', "
144+
"'@PATH' for file reference. "
145+
"Default: ~/.ssh/id_ed25519.pub, then ~/.ssh/id_rsa.pub "
146+
"(first existing one)"))
147+
env_export.add_argument('--passphrase-env', metavar='VAR', default=None,
148+
help='Read passphrase from environment variable VAR')
149+
env_export.add_argument('--passphrase-stdin', action='store_true',
150+
help='Read passphrase from the first line of stdin')
151+
env_export.add_argument('--force-unencrypted', action='store_true',
152+
help='Write as plaintext tar.gz (rejected by default; '
153+
'warns when sensitive keys are detected)')
154+
env_export.add_argument('--unsafe-allow-unencrypted-bucket', action='store_true',
155+
help='Allow S3 export to buckets without default encryption '
156+
'(per-object SSE is always applied regardless of this flag). '
157+
'Has no effect for non-s3:// destinations.')
158+
159+
env_import = env_sub.add_parser(
160+
'import',
161+
help='Import .env files from a bundle (age-encrypted or plaintext tar.gz)',
162+
)
163+
env_import.add_argument('source',
164+
help="Bundle path or '-' for stdin")
165+
env_import.add_argument('--merge', choices=['keep-existing', 'prefer-incoming'],
166+
default='keep-existing',
167+
help=("Key-level merge mode. keep-existing (default) keeps "
168+
"existing keys and adds new ones; prefer-incoming "
169+
"overwrites with bundle values"))
170+
env_import.add_argument('--replace-keys', metavar='KEYS', default='',
171+
help=("Comma-separated keys to force-overwrite from bundle "
172+
"(other keys behave like keep-existing). "
173+
"Cannot be combined with --replace"))
174+
env_import.add_argument('--replace', action='store_true',
175+
help='Replace each target .env file wholesale (backup is taken)')
176+
env_import.add_argument('--dry-run', action='store_true',
177+
help='Show planned diff without writing')
178+
env_import.add_argument('--identity', action='append', default=[],
179+
metavar='FILE', dest='identities',
180+
help=("age / OpenSSH private key file (repeatable). "
181+
"Default: ~/.ssh/id_ed25519, then ~/.ssh/id_rsa "
182+
"(first existing one)"))
183+
env_import.add_argument('--passphrase-env', metavar='VAR', default=None,
184+
help='Read passphrase from environment variable VAR')
185+
env_import.add_argument('--passphrase-stdin', action='store_true',
186+
help='Read passphrase from the first line of stdin')
187+
env_import.add_argument('--include-project', action='append', default=None,
188+
metavar='NAME', dest='include_projects',
189+
help='Limit to specified project (repeatable)')
190+
env_import.add_argument('--exclude-project', action='append', default=[],
191+
metavar='NAME', dest='exclude_projects',
192+
help='Exclude project (repeatable)')
193+
env_import.add_argument('--no-global', action='store_true',
194+
help='Do not import $DEVBASE_ROOT/.env')
195+
env_import.add_argument('--no-metadata', action='store_true',
196+
help='Do not import $DEVBASE_ROOT/.env.sources.yml '
197+
'(default behavior is reference-only copy; this fully ignores it)')
198+
env_import.add_argument('--merge-metadata', action='store_true',
199+
help='Merge new source entries into existing .env.sources.yml '
200+
'(machine-specific fields are preserved as-is from bundle; '
201+
'run `devbase env sync` after import to refresh)')
202+
env_import.add_argument('--backup-dir', metavar='DIR', default=None,
203+
help='Override backup directory '
204+
'(default: $DEVBASE_ROOT/backups/env-import/<ts>)')
205+
env_import.add_argument('--keep-last', type=int, default=10, metavar='N',
206+
help='Keep only the last N backup directories (default: 10, 0 to disable)')
207+
112208

113209
def _add_plugin_parser(subparsers):
114210
"""Plugin group parser"""
@@ -250,14 +346,22 @@ def _create_parser():
250346
return parser
251347

252348

253-
def _resolve_prefix(input_cmd, candidates):
349+
def _resolve_prefix(input_cmd, candidates, preferences=None):
254350
"""Resolve an abbreviated command to its full name via unique prefix matching.
255351
256-
Returns the full command name if exactly one candidate matches,
257-
otherwise returns the input as-is (ambiguous or no match).
352+
Returns the full command name if exactly one candidate matches.
353+
If ambiguous, falls back to `preferences[input_cmd]` (if provided) to keep
354+
backward compatibility with previously-unique abbreviations.
355+
Otherwise returns the input as-is.
258356
"""
259357
matches = [c for c in candidates if c.startswith(input_cmd)]
260-
return matches[0] if len(matches) == 1 else input_cmd
358+
if len(matches) == 1:
359+
return matches[0]
360+
if preferences and input_cmd in preferences:
361+
preferred = preferences[input_cmd]
362+
if preferred in matches:
363+
return preferred
364+
return input_cmd
261365

262366

263367
def _expand_argv():
@@ -273,7 +377,8 @@ def _expand_argv():
273377
cmd = sys.argv[1]
274378
for aliases, subcmds in SUBCMD_MAP.items():
275379
if cmd in aliases:
276-
sys.argv[2] = _resolve_prefix(sys.argv[2], subcmds)
380+
preferences = SUBCMD_PREFIX_PREFERENCES.get(aliases)
381+
sys.argv[2] = _resolve_prefix(sys.argv[2], subcmds, preferences)
277382
break
278383

279384
# plugin repo sub-subcommand

0 commit comments

Comments
 (0)