Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@

## [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 の複雑さを避けるトレードオフです)。

### Changed
- `gs://` (GCS) スキームは **PLAN03-1 PR4 廃案** により対応しません。指定すると明示的なエラーメッセージで失敗します (旧: "未実装")。

## [2.2.0] - 2026-04-20

OSS 化に伴う初回リリース。devbase は本バージョンより `devbasex` Organization 配下で公開されます。
Expand Down
4 changes: 4 additions & 0 deletions lib/devbase/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ def _add_env_parser(subparsers):
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',
Expand Down
3 changes: 3 additions & 0 deletions lib/devbase/commands/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,9 @@ def cmd_env_export(devbase_root: Path, args) -> int:
passphrase_env=getattr(args, 'passphrase_env', None),
passphrase_stdin=getattr(args, 'passphrase_stdin', False),
force_unencrypted=getattr(args, 'force_unencrypted', False),
unsafe_allow_unencrypted_bucket=getattr(
args, 'unsafe_allow_unencrypted_bucket', False
),
)
return export(devbase_root, opts)

Expand Down
10 changes: 9 additions & 1 deletion lib/devbase/env/io_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ class ExportOptions:
passphrase_env: Optional[str] = None
passphrase_stdin: bool = False
force_unencrypted: bool = False
# S3 backend 専用: バケット既定暗号化が未設定でも export を許可するか
# (オブジェクト単位の SSE はこのフラグに関係なく常に付与される)
unsafe_allow_unencrypted_bucket: bool = False


def _default_dest(force_unencrypted: bool) -> str:
Expand Down Expand Up @@ -167,7 +170,12 @@ def export(devbase_root: Path, opts: ExportOptions) -> int:
logger.debug("暗号化後サイズ: %d bytes", len(payload))

dest = opts.dest or _default_dest(opts.force_unencrypted)
backend = _storage.resolve(dest)
# S3 など backend 固有のオプションを渡したい場合は s3_options を組み立てる。
# それ以外 (local/stdio) では未使用なので無害。
s3_options = _storage.S3Options.from_env(
unsafe_allow_unencrypted_bucket=opts.unsafe_allow_unencrypted_bucket,
) if _storage.is_s3(dest) else None
backend = _storage.resolve(dest, s3_options=s3_options)
backend.write_bytes(dest, payload)

if _storage.is_stdio(dest):
Expand Down
229 changes: 222 additions & 7 deletions lib/devbase/env/storage.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
"""env バンドルの入出力先 (local / stdio / 将来 s3, gcs) を抽象化する"""
"""env バンドルの入出力先 (local / stdio / s3) を抽象化する"""

from __future__ import annotations

import os
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
from typing import Optional, Protocol, Tuple
from urllib.parse import urlparse

from devbase.errors import DevbaseError
from devbase.log import get_logger

logger = get_logger(__name__)


class StorageError(DevbaseError):
Expand Down Expand Up @@ -98,8 +102,211 @@ def read_bytes(self, source: str) -> bytes:
return sys.stdin.buffer.read()


def resolve(uri: str) -> StorageBackend:
"""URI スキームから対応する backend を返す"""
@dataclass
class S3Options:
"""S3Backend の挙動パラメータ。

`unsafe_allow_unencrypted_bucket` は **export 専用**: True にすると
バケット側のデフォルト暗号化未設定でも export を許可する。
オブジェクト個別の SSE は `sse` / `sse_kms_key_id` で常に強制される。
"""
unsafe_allow_unencrypted_bucket: bool = False
sse: str = 'aws:kms' # 'aws:kms' or 'AES256'
sse_kms_key_id: Optional[str] = None
endpoint_url: Optional[str] = None
region: Optional[str] = None

@classmethod
def from_env(
cls,
*,
unsafe_allow_unencrypted_bucket: bool = False,
) -> 'S3Options':
"""環境変数から既定値を読み取って組み立てる。

env vars (任意):
DEVBASE_S3_SSE -> sse (既定: aws:kms)
DEVBASE_S3_SSE_KMS_KEY_ID -> sse_kms_key_id
DEVBASE_S3_ENDPOINT_URL -> endpoint_url (MinIO/LocalStack 用)
DEVBASE_S3_REGION -> region

boto3 が認識する AWS_PROFILE / AWS_REGION / AWS_ENDPOINT_URL[_S3] /
AWS_ACCESS_KEY_ID 等はそのまま尊重される。
"""
sse = os.environ.get('DEVBASE_S3_SSE', 'aws:kms')
if sse not in ('aws:kms', 'AES256'):
raise StorageError(
f"DEVBASE_S3_SSE は 'aws:kms' か 'AES256' を指定してください: {sse!r}"
)
return cls(
unsafe_allow_unencrypted_bucket=unsafe_allow_unencrypted_bucket,
sse=sse,
sse_kms_key_id=os.environ.get('DEVBASE_S3_SSE_KMS_KEY_ID'),
endpoint_url=os.environ.get('DEVBASE_S3_ENDPOINT_URL'),
region=os.environ.get('DEVBASE_S3_REGION'),
)


def _parse_s3_uri(uri: str) -> Tuple[str, str]:
"""s3://bucket/key/path を (bucket, key) に分解する

`urlparse` は S3 キー名に含まれる `?` / `#` を `query` / `fragment` として
切り落としてしまうため、AWS CLI の挙動に合わせてスキームを除去した上で
直接 `/` 分割する。
"""
if not uri[:5].lower() == 's3://':
raise StorageError(f"S3 URI が期待されますが: {uri!r}")
rest = uri[5:]
bucket, sep, key = rest.partition('/')
if not bucket:
raise StorageError(
f"S3 URI のバケット名が空です: {uri!r} "
"(s3://bucket/key の形式で指定してください)"
)
if not sep or not key:
raise StorageError(
f"S3 URI のキーが空です: {uri!r} "
"(s3://bucket/key の形式で指定してください)"
)
return bucket, key


class S3Backend:
"""AWS S3 / S3 互換ストレージ (MinIO 等)。

- write_bytes: PutObject 時に ServerSideEncryption を常に付与し、
`unsafe_allow_unencrypted_bucket=False` のときは
GetBucketEncryption で**バケット側の既定暗号化**も事前確認する。
- read_bytes: GetObject (暗号化はバケット/オブジェクト側設定に従う)。
"""

def __init__(self, options: Optional[S3Options] = None):
self._options = options or S3Options()
self._client = None

def _get_client(self):
if self._client is not None:
return self._client
import boto3

kwargs = {}
if self._options.endpoint_url:
kwargs['endpoint_url'] = self._options.endpoint_url
if self._options.region:
kwargs['region_name'] = self._options.region
try:
self._client = boto3.client('s3', **kwargs)
except Exception as e:
raise StorageError(f"S3 クライアントの生成に失敗しました: {e}") from e
return self._client

@staticmethod
def _error_code(exc: BaseException) -> Optional[str]:
"""botocore.exceptions.ClientError から AWS error code を取り出す"""
resp = getattr(exc, 'response', None)
if isinstance(resp, dict):
return resp.get('Error', {}).get('Code')
return None

def _verify_bucket_encryption(self, client, bucket: str) -> None:
"""バケットレベルの既定暗号化を確認。

- 暗号化が設定済み: OK
- 暗号化が未設定 (ServerSideEncryptionConfigurationNotFoundError):
unsafe フラグがあれば警告のみ、無ければ StorageError
- AccessDenied 等で確認できなかった場合は事故防止のため拒否
(`--unsafe-allow-unencrypted-bucket` でのみバイパス可)
"""
try:
client.get_bucket_encryption(Bucket=bucket)
return
except Exception as e:
code = self._error_code(e)
if code == 'ServerSideEncryptionConfigurationNotFoundError':
msg = (
f"S3 バケット '{bucket}' のデフォルト暗号化が未設定です。"
"バケットポリシーで SSE-KMS or SSE-S3 を有効化するか、"
"明示的に '--unsafe-allow-unencrypted-bucket' を指定してください "
"(オブジェクト単位の SSE はこのオプションに関係なく常に付与されます)"
)
if self._options.unsafe_allow_unencrypted_bucket:
logger.warning("%s (unsafe フラグにより続行)", msg)
return
raise StorageError(msg) from e
if code in ('AccessDenied', 'AccessDeniedException'):
msg = (
f"S3 バケット '{bucket}' の暗号化設定を確認できません "
"(GetBucketEncryption 権限がありません)。"
"バケットポリシーの確認が取れないため export を中止します。"
"権限を付与するか、'--unsafe-allow-unencrypted-bucket' を明示してください"
)
if self._options.unsafe_allow_unencrypted_bucket:
logger.warning("%s (unsafe フラグにより続行)", msg)
return
raise StorageError(msg) from e
# MinIO / LocalStack 等の S3 互換ストレージでは
# GetBucketEncryption が NotImplemented / MethodNotAllowed / 501 等を返す
# ことがある。`--unsafe-allow-unencrypted-bucket` 指定時は逃げ道として
# 警告のみで続行する (オブジェクト個別の SSE は引き続き付与される)。
msg = (
f"バケット暗号化設定の確認に失敗しました ({bucket}): {e}"
)
if self._options.unsafe_allow_unencrypted_bucket:
logger.warning("%s (unsafe フラグにより続行)", msg)
return
raise StorageError(msg) from e

def write_bytes(self, dest: str, data: bytes) -> None:
bucket, key = _parse_s3_uri(dest)
client = self._get_client()
self._verify_bucket_encryption(client, bucket)

put_kwargs = {
'Bucket': bucket,
'Key': key,
'Body': data,
'ServerSideEncryption': self._options.sse,
}
if self._options.sse == 'aws:kms' and self._options.sse_kms_key_id:
put_kwargs['SSEKMSKeyId'] = self._options.sse_kms_key_id

try:
client.put_object(**put_kwargs)
except Exception as e:
raise StorageError(
f"S3 への書き込みに失敗しました ({dest}): {e}"
) from e
logger.info("S3 へ書き込みました: %s (sse=%s)", dest, self._options.sse)

def read_bytes(self, source: str) -> bytes:
bucket, key = _parse_s3_uri(source)
client = self._get_client()
try:
response = client.get_object(Bucket=bucket, Key=key)
body = response['Body']
except Exception as e:
code = self._error_code(e)
if code in ('NoSuchKey', 'NoSuchBucket', '404'):
raise StorageError(
f"S3 オブジェクトが見つかりません: {source}"
) from e
raise StorageError(
f"S3 からの読み込みに失敗しました ({source}): {e}"
) from e
try:
return body.read()
except Exception as e:
raise StorageError(
f"S3 レスポンスボディの読み取りに失敗しました ({source}): {e}"
) from e


def resolve(uri: str, *, s3_options: Optional[S3Options] = None) -> StorageBackend:
"""URI スキームから対応する backend を返す。

s3:// は `s3_options` を受け取れる (省略時は S3Options.from_env())。
`gs://` は PLAN03-1 PR4 廃案により対応しない。
"""
if uri == '-':
return StdioBackend()

Expand All @@ -109,10 +316,14 @@ def resolve(uri: str) -> StorageBackend:
if scheme in ('', 'file'):
return LocalBackend()

if scheme in ('s3', 'gs'):
if scheme == 's3':
return S3Backend(s3_options if s3_options is not None else S3Options.from_env())

if scheme == 'gs':
raise StorageError(
f"スキーム '{scheme}://' は本 PR では未実装です "
"(後続 PR で対応予定)"
"スキーム 'gs://' (GCS) は PLAN03-1 PR4 廃案により対応していません。"
"必要な場合は s3:// 経由 (S3 互換ゲートウェイ) を検討するか、"
"ローカルファイルを介して転送してください"
)

# Windows のドライブレター付きパス (例: C:\path, d:/path) は
Expand All @@ -126,3 +337,7 @@ def resolve(uri: str) -> StorageBackend:

def is_stdio(uri: str) -> bool:
return uri == '-'


def is_s3(uri: str) -> bool:
return urlparse(uri).scheme.lower() == 's3'
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ requires-python = ">=3.10"
dependencies = [
"pyyaml>=6.0",
"pyrage>=1.2",
"boto3>=1.34",
]

[dependency-groups]
Expand Down
Loading