Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ __pycache__/
.venv/
.env
.env.backup
.gemini/
.docker-compose.scale.yml
plugins.yml
plugins/*/
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@

## [Unreleased]

### Added
- `devbase env export` / `devbase env import` で **S3 URI (`s3://bucket/key`) を入出力先として指定**できるようになりました (PLAN03-1 PR3)。
- 既定でオブジェクト単位の SSE (`aws:kms` または `AES256`) を強制し、export 時はバケット側のデフォルト暗号化も `GetBucketEncryption` で事前確認します。
- 暗号化が未設定のバケットへ 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

OSS 化に伴う初回リリース。devbase は本バージョンより `devbasex` Organization 配下で公開されます。
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down Expand Up @@ -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) | カテゴリ別の問題と解決策 |
Expand Down
456 changes: 456 additions & 0 deletions docs/user/env-export-import.md

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions docs/user/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
33 changes: 33 additions & 0 deletions etc/_devbase
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ _devbase() {
'delete:Delete a variable'
'edit:Open .env in editor'
'project:Setup project-specific variables'
'export:Export .env files as an encrypted bundle (age)'
'import:Import .env bundle (age decrypt + merge)'
)

plugin_subcommands=(
Expand Down Expand Up @@ -150,6 +152,37 @@ _devbase() {
get|delete)
_arguments '1:key:'
;;
export)
_arguments \
'1:dest:_files' \
'*--include-project[Limit to specified project (repeatable)]:name:' \
'*--exclude-project[Exclude project (repeatable)]:name:' \
'--no-global[Exclude $DEVBASE_ROOT/.env]' \
'--no-metadata[Exclude $DEVBASE_ROOT/.env.sources.yml]' \
'*--recipient[age / OpenSSH public key (repeatable)]:key:' \
'--passphrase-env[Read passphrase from env var]:var:' \
'--passphrase-stdin[Read passphrase from stdin]' \
'--force-unencrypted[Write as plaintext tar.gz]' \
'--unsafe-allow-unencrypted-bucket[Allow uploading unencrypted tar.gz to S3 (off by default)]'
;;
import)
_arguments \
'1:source:_files' \
'--merge[Merge strategy]:mode:(keep-existing prefer-incoming)' \
'--replace-keys[Replace only these keys (comma-separated)]:keys:' \
'--replace[Replace existing files entirely]' \
'--dry-run[Preview changes without writing]' \
'*--identity[age / OpenSSH private key file (repeatable)]:file:_files' \
'--passphrase-env[Read passphrase from env var]:var:' \
'--passphrase-stdin[Read passphrase from stdin]' \
'*--include-project[Limit to specified project (repeatable)]:name:' \
'*--exclude-project[Exclude project (repeatable)]:name:' \
'--no-global[Do not import $DEVBASE_ROOT/.env]' \
'--no-metadata[Do not import $DEVBASE_ROOT/.env.sources.yml]' \
'--merge-metadata[Add only new sources entries from bundle]' \
'--backup-dir[Override backup directory]:dir:_files -/' \
'--keep-last[Keep only the last N backup directories]:n:'
;;
*)
_describe -t env-commands 'env command' env_subcommands
;;
Expand Down
12 changes: 11 additions & 1 deletion etc/devbase-completion.bash
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ _devbase_completions() {

local commands="init status shell-rc container ct env plugin pl snapshot ss up down login build ps help"
local container_subcommands="up down ps login logs scale build"
local env_subcommands="init sync list set get delete edit project"
local env_subcommands="init sync list set get delete edit project export import"
local plugin_subcommands="list install uninstall update info sync repo"
local repo_subcommands="add remove list refresh"
local snapshot_subcommands="create list restore copy delete rotate"
Expand Down Expand Up @@ -81,6 +81,16 @@ _devbase_completions() {
COMPREPLY=($(compgen -W "--project -p" -- "$cur"))
fi
;;
export)
if [[ "$cur" == -* ]]; then
COMPREPLY=($(compgen -W "--include-project --exclude-project --no-global --no-metadata --recipient --passphrase-env --passphrase-stdin --force-unencrypted --unsafe-allow-unencrypted-bucket" -- "$cur"))
fi
;;
import)
if [[ "$cur" == -* ]]; then
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"))
fi
;;
esac
fi
# plugin subcommand arguments
Expand Down
117 changes: 111 additions & 6 deletions lib/devbase/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,23 @@
# Subcommand map for prefix resolution: {(aliases...): [subcmds]}
SUBCMD_MAP = {
('container', 'ct'): ['up', 'down', 'ps', 'login', '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'],
}

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


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

env_export = env_sub.add_parser(
'export',
help='Export .env files as an encrypted bundle (age)',
)
env_export.add_argument('dest', nargs='?', default=None,
help="Output path (default: ./devbase-env-<TS>.dbenv, '-' for stdout)")
env_export.add_argument('--include-project', action='append', default=None,
metavar='NAME', dest='include_projects',
help='Limit to specified project (repeatable)')
env_export.add_argument('--exclude-project', action='append', default=[],
metavar='NAME', dest='exclude_projects',
help='Exclude project (repeatable)')
env_export.add_argument('--no-global', action='store_true',
help='Exclude $DEVBASE_ROOT/.env')
env_export.add_argument('--no-metadata', action='store_true',
help='Exclude $DEVBASE_ROOT/.env.sources.yml')
env_export.add_argument('--recipient', action='append', default=[],
metavar='KEY', dest='recipients',
help=("age / OpenSSH public key (repeatable). "
"Formats: 'age1...', 'ssh-ed25519 AAAA...', 'ssh-rsa AAAA...', "
"'@PATH' for file reference. "
"Default: ~/.ssh/id_ed25519.pub, then ~/.ssh/id_rsa.pub "
"(first existing one)"))
env_export.add_argument('--passphrase-env', metavar='VAR', default=None,
help='Read passphrase from environment variable VAR')
env_export.add_argument('--passphrase-stdin', action='store_true',
help='Read passphrase from the first line of stdin')
env_export.add_argument('--force-unencrypted', action='store_true',
help='Write as plaintext tar.gz (rejected by default; '
'warns when sensitive keys are detected)')
env_export.add_argument('--unsafe-allow-unencrypted-bucket', action='store_true',
help='Allow S3 export to buckets without default encryption '
'(per-object SSE is always applied regardless of this flag). '
'Has no effect for non-s3:// destinations.')

env_import = env_sub.add_parser(
'import',
help='Import .env files from a bundle (age-encrypted or plaintext tar.gz)',
)
env_import.add_argument('source',
help="Bundle path or '-' for stdin")
env_import.add_argument('--merge', choices=['keep-existing', 'prefer-incoming'],
default='keep-existing',
help=("Key-level merge mode. keep-existing (default) keeps "
"existing keys and adds new ones; prefer-incoming "
"overwrites with bundle values"))
env_import.add_argument('--replace-keys', metavar='KEYS', default='',
help=("Comma-separated keys to force-overwrite from bundle "
"(other keys behave like keep-existing). "
"Cannot be combined with --replace"))
env_import.add_argument('--replace', action='store_true',
help='Replace each target .env file wholesale (backup is taken)')
env_import.add_argument('--dry-run', action='store_true',
help='Show planned diff without writing')
env_import.add_argument('--identity', action='append', default=[],
metavar='FILE', dest='identities',
help=("age / OpenSSH private key file (repeatable). "
"Default: ~/.ssh/id_ed25519, then ~/.ssh/id_rsa "
"(first existing one)"))
env_import.add_argument('--passphrase-env', metavar='VAR', default=None,
help='Read passphrase from environment variable VAR')
env_import.add_argument('--passphrase-stdin', action='store_true',
help='Read passphrase from the first line of stdin')
env_import.add_argument('--include-project', action='append', default=None,
metavar='NAME', dest='include_projects',
help='Limit to specified project (repeatable)')
env_import.add_argument('--exclude-project', action='append', default=[],
metavar='NAME', dest='exclude_projects',
help='Exclude project (repeatable)')
env_import.add_argument('--no-global', action='store_true',
help='Do not import $DEVBASE_ROOT/.env')
env_import.add_argument('--no-metadata', action='store_true',
help='Do not import $DEVBASE_ROOT/.env.sources.yml '
'(default behavior is reference-only copy; this fully ignores it)')
env_import.add_argument('--merge-metadata', action='store_true',
help='Merge new source entries into existing .env.sources.yml '
'(machine-specific fields are preserved as-is from bundle; '
'run `devbase env sync` after import to refresh)')
env_import.add_argument('--backup-dir', metavar='DIR', default=None,
help='Override backup directory '
'(default: $DEVBASE_ROOT/backups/env-import/<ts>)')
env_import.add_argument('--keep-last', type=int, default=10, metavar='N',
help='Keep only the last N backup directories (default: 10, 0 to disable)')


def _add_plugin_parser(subparsers):
"""Plugin group parser"""
Expand Down Expand Up @@ -250,14 +346,22 @@ def _create_parser():
return parser


def _resolve_prefix(input_cmd, candidates):
def _resolve_prefix(input_cmd, candidates, preferences=None):
"""Resolve an abbreviated command to its full name via unique prefix matching.

Returns the full command name if exactly one candidate matches,
otherwise returns the input as-is (ambiguous or no match).
Returns the full command name if exactly one candidate matches.
If ambiguous, falls back to `preferences[input_cmd]` (if provided) to keep
backward compatibility with previously-unique abbreviations.
Otherwise returns the input as-is.
"""
matches = [c for c in candidates if c.startswith(input_cmd)]
return matches[0] if len(matches) == 1 else input_cmd
if len(matches) == 1:
return matches[0]
if preferences and input_cmd in preferences:
preferred = preferences[input_cmd]
if preferred in matches:
return preferred
return input_cmd


def _expand_argv():
Expand All @@ -273,7 +377,8 @@ def _expand_argv():
cmd = sys.argv[1]
for aliases, subcmds in SUBCMD_MAP.items():
if cmd in aliases:
sys.argv[2] = _resolve_prefix(sys.argv[2], subcmds)
preferences = SUBCMD_PREFIX_PREFERENCES.get(aliases)
sys.argv[2] = _resolve_prefix(sys.argv[2], subcmds, preferences)
break

# plugin repo sub-subcommand
Expand Down
Loading
Loading