Skip to content

Commit 9c419fc

Browse files
takemi-ohamaclaude
andauthored
feat(env): PLAN03-1 PR3 devbase env export/import S3 backend (#19)
* chore: PLAN03-1 PR3 Draft PR 作成 (S3 backend) * feat(env): PLAN03-1 PR3 devbase env export/import S3 backend - `s3://bucket/key` を `devbase env export` / `devbase env import` の 入出力先として指定できるようにする - export 時は ServerSideEncryption (`aws:kms` 既定, `AES256` 切替可) を 常に PutObject に付与し、加えて GetBucketEncryption で **バケット側の 既定暗号化** も事前確認する - 暗号化未設定 / 確認不可 (AccessDenied) のバケットへは `--unsafe-allow-unencrypted-bucket` を明示しない限り export を拒否する (オブジェクト単位の SSE はこのフラグに関係なく常に付与される) - SSE 種別 / KMS 鍵 / エンドポイント / リージョンは環境変数 (`DEVBASE_S3_SSE`, `DEVBASE_S3_SSE_KMS_KEY_ID`, `DEVBASE_S3_ENDPOINT_URL`, `DEVBASE_S3_REGION`) で上書きできる - `boto3` は optional dep として `[project.optional-dependencies] s3` に追加 (`pip install 'devbase[s3]'` でインストール) - `gs://` (GCS) は PLAN03-1 PR4 廃案のため明示エラーで拒否する Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(env): PLAN03-1 PR3 storage.py minor 修正 (cross-review round 1) PR #19 のクロスレビュー (codex / gemini) で指摘された minor 3 件に対応。 - `_parse_s3_uri`: `urlparse` は S3 キーに含まれる `?` / `#` を query / fragment として落としてしまうため、AWS CLI と同じ挙動になるよう スキームを除去した上で `partition('/')` で分割する。 - boto3 未インストール時のエラーメッセージを `pip install boto3` から 本プロジェクトの optional dependency 経由 (`pip install 'devbase[s3]'` / `uv add 'devbase[s3]'`) に変更。 - `_verify_bucket_encryption`: MinIO / LocalStack 等の S3 互換ストレージで GetBucketEncryption が NotImplemented を返すケースに備え、 `--unsafe-allow-unencrypted-bucket` 指定時は未知エラーも警告のみで続行する 逃げ道として機能させる (CHANGELOG の S3 互換ストレージ対応との整合)。 新規テスト: query/fragment 保持、未知エラーの拒否、unsafe フラグでの続行を追加。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(env): PLAN03-1 PR3 boto3 を main dependency に昇格 boto3 を `[project.optional-dependencies].s3` から `[project].dependencies` に移し、ImportError ハンドラとフォローアップ案内文を撤去する。 意図: - S3 URI を初めて指定したユーザに `pip install 'devbase[s3]'` を 打たせる UX を廃する。25MB 程度のコスト増 (botocore 24MB) は 実装複雑度ゼロと引き換え。 - 引数検出 (`s3://` 走査) や lazy 自動 install を採らないのは、 CI / オフライン / read-only コンテナで挙動が安定するため。 storage.py / test_storage.py の boto3-missing 関連コードを削除。 CHANGELOG.md の optional 記述も同期更新。 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 604a03e commit 9c419fc

9 files changed

Lines changed: 662 additions & 13 deletions

File tree

.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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@
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+
14+
### Changed
15+
- `gs://` (GCS) スキームは **PLAN03-1 PR4 廃案** により対応しません。指定すると明示的なエラーメッセージで失敗します (旧: "未実装")。
16+
717
## [2.2.0] - 2026-04-20
818

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

lib/devbase/cli.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,10 @@ def _add_env_parser(subparsers):
151151
env_export.add_argument('--force-unencrypted', action='store_true',
152152
help='Write as plaintext tar.gz (rejected by default; '
153153
'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.')
154158

155159
env_import = env_sub.add_parser(
156160
'import',

lib/devbase/commands/env.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,9 @@ def cmd_env_export(devbase_root: Path, args) -> int:
398398
passphrase_env=getattr(args, 'passphrase_env', None),
399399
passphrase_stdin=getattr(args, 'passphrase_stdin', False),
400400
force_unencrypted=getattr(args, 'force_unencrypted', False),
401+
unsafe_allow_unencrypted_bucket=getattr(
402+
args, 'unsafe_allow_unencrypted_bucket', False
403+
),
401404
)
402405
return export(devbase_root, opts)
403406

lib/devbase/env/io_export.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ class ExportOptions:
3939
passphrase_env: Optional[str] = None
4040
passphrase_stdin: bool = False
4141
force_unencrypted: bool = False
42+
# S3 backend 専用: バケット既定暗号化が未設定でも export を許可するか
43+
# (オブジェクト単位の SSE はこのフラグに関係なく常に付与される)
44+
unsafe_allow_unencrypted_bucket: bool = False
4245

4346

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

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

173181
if _storage.is_stdio(dest):

lib/devbase/env/storage.py

Lines changed: 222 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1-
"""env バンドルの入出力先 (local / stdio / 将来 s3, gcs) を抽象化する"""
1+
"""env バンドルの入出力先 (local / stdio / s3) を抽象化する"""
22

33
from __future__ import annotations
44

55
import os
66
import sys
7+
from dataclasses import dataclass
78
from pathlib import Path
8-
from typing import Protocol
9+
from typing import Optional, Protocol, Tuple
910
from urllib.parse import urlparse
1011

1112
from devbase.errors import DevbaseError
13+
from devbase.log import get_logger
14+
15+
logger = get_logger(__name__)
1216

1317

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

100104

101-
def resolve(uri: str) -> StorageBackend:
102-
"""URI スキームから対応する backend を返す"""
105+
@dataclass
106+
class S3Options:
107+
"""S3Backend の挙動パラメータ。
108+
109+
`unsafe_allow_unencrypted_bucket` は **export 専用**: True にすると
110+
バケット側のデフォルト暗号化未設定でも export を許可する。
111+
オブジェクト個別の SSE は `sse` / `sse_kms_key_id` で常に強制される。
112+
"""
113+
unsafe_allow_unencrypted_bucket: bool = False
114+
sse: str = 'aws:kms' # 'aws:kms' or 'AES256'
115+
sse_kms_key_id: Optional[str] = None
116+
endpoint_url: Optional[str] = None
117+
region: Optional[str] = None
118+
119+
@classmethod
120+
def from_env(
121+
cls,
122+
*,
123+
unsafe_allow_unencrypted_bucket: bool = False,
124+
) -> 'S3Options':
125+
"""環境変数から既定値を読み取って組み立てる。
126+
127+
env vars (任意):
128+
DEVBASE_S3_SSE -> sse (既定: aws:kms)
129+
DEVBASE_S3_SSE_KMS_KEY_ID -> sse_kms_key_id
130+
DEVBASE_S3_ENDPOINT_URL -> endpoint_url (MinIO/LocalStack 用)
131+
DEVBASE_S3_REGION -> region
132+
133+
boto3 が認識する AWS_PROFILE / AWS_REGION / AWS_ENDPOINT_URL[_S3] /
134+
AWS_ACCESS_KEY_ID 等はそのまま尊重される。
135+
"""
136+
sse = os.environ.get('DEVBASE_S3_SSE', 'aws:kms')
137+
if sse not in ('aws:kms', 'AES256'):
138+
raise StorageError(
139+
f"DEVBASE_S3_SSE は 'aws:kms' か 'AES256' を指定してください: {sse!r}"
140+
)
141+
return cls(
142+
unsafe_allow_unencrypted_bucket=unsafe_allow_unencrypted_bucket,
143+
sse=sse,
144+
sse_kms_key_id=os.environ.get('DEVBASE_S3_SSE_KMS_KEY_ID'),
145+
endpoint_url=os.environ.get('DEVBASE_S3_ENDPOINT_URL'),
146+
region=os.environ.get('DEVBASE_S3_REGION'),
147+
)
148+
149+
150+
def _parse_s3_uri(uri: str) -> Tuple[str, str]:
151+
"""s3://bucket/key/path を (bucket, key) に分解する
152+
153+
`urlparse` は S3 キー名に含まれる `?` / `#` を `query` / `fragment` として
154+
切り落としてしまうため、AWS CLI の挙動に合わせてスキームを除去した上で
155+
直接 `/` 分割する。
156+
"""
157+
if not uri[:5].lower() == 's3://':
158+
raise StorageError(f"S3 URI が期待されますが: {uri!r}")
159+
rest = uri[5:]
160+
bucket, sep, key = rest.partition('/')
161+
if not bucket:
162+
raise StorageError(
163+
f"S3 URI のバケット名が空です: {uri!r} "
164+
"(s3://bucket/key の形式で指定してください)"
165+
)
166+
if not sep or not key:
167+
raise StorageError(
168+
f"S3 URI のキーが空です: {uri!r} "
169+
"(s3://bucket/key の形式で指定してください)"
170+
)
171+
return bucket, key
172+
173+
174+
class S3Backend:
175+
"""AWS S3 / S3 互換ストレージ (MinIO 等)。
176+
177+
- write_bytes: PutObject 時に ServerSideEncryption を常に付与し、
178+
`unsafe_allow_unencrypted_bucket=False` のときは
179+
GetBucketEncryption で**バケット側の既定暗号化**も事前確認する。
180+
- read_bytes: GetObject (暗号化はバケット/オブジェクト側設定に従う)。
181+
"""
182+
183+
def __init__(self, options: Optional[S3Options] = None):
184+
self._options = options or S3Options()
185+
self._client = None
186+
187+
def _get_client(self):
188+
if self._client is not None:
189+
return self._client
190+
import boto3
191+
192+
kwargs = {}
193+
if self._options.endpoint_url:
194+
kwargs['endpoint_url'] = self._options.endpoint_url
195+
if self._options.region:
196+
kwargs['region_name'] = self._options.region
197+
try:
198+
self._client = boto3.client('s3', **kwargs)
199+
except Exception as e:
200+
raise StorageError(f"S3 クライアントの生成に失敗しました: {e}") from e
201+
return self._client
202+
203+
@staticmethod
204+
def _error_code(exc: BaseException) -> Optional[str]:
205+
"""botocore.exceptions.ClientError から AWS error code を取り出す"""
206+
resp = getattr(exc, 'response', None)
207+
if isinstance(resp, dict):
208+
return resp.get('Error', {}).get('Code')
209+
return None
210+
211+
def _verify_bucket_encryption(self, client, bucket: str) -> None:
212+
"""バケットレベルの既定暗号化を確認。
213+
214+
- 暗号化が設定済み: OK
215+
- 暗号化が未設定 (ServerSideEncryptionConfigurationNotFoundError):
216+
unsafe フラグがあれば警告のみ、無ければ StorageError
217+
- AccessDenied 等で確認できなかった場合は事故防止のため拒否
218+
(`--unsafe-allow-unencrypted-bucket` でのみバイパス可)
219+
"""
220+
try:
221+
client.get_bucket_encryption(Bucket=bucket)
222+
return
223+
except Exception as e:
224+
code = self._error_code(e)
225+
if code == 'ServerSideEncryptionConfigurationNotFoundError':
226+
msg = (
227+
f"S3 バケット '{bucket}' のデフォルト暗号化が未設定です。"
228+
"バケットポリシーで SSE-KMS or SSE-S3 を有効化するか、"
229+
"明示的に '--unsafe-allow-unencrypted-bucket' を指定してください "
230+
"(オブジェクト単位の SSE はこのオプションに関係なく常に付与されます)"
231+
)
232+
if self._options.unsafe_allow_unencrypted_bucket:
233+
logger.warning("%s (unsafe フラグにより続行)", msg)
234+
return
235+
raise StorageError(msg) from e
236+
if code in ('AccessDenied', 'AccessDeniedException'):
237+
msg = (
238+
f"S3 バケット '{bucket}' の暗号化設定を確認できません "
239+
"(GetBucketEncryption 権限がありません)。"
240+
"バケットポリシーの確認が取れないため export を中止します。"
241+
"権限を付与するか、'--unsafe-allow-unencrypted-bucket' を明示してください"
242+
)
243+
if self._options.unsafe_allow_unencrypted_bucket:
244+
logger.warning("%s (unsafe フラグにより続行)", msg)
245+
return
246+
raise StorageError(msg) from e
247+
# MinIO / LocalStack 等の S3 互換ストレージでは
248+
# GetBucketEncryption が NotImplemented / MethodNotAllowed / 501 等を返す
249+
# ことがある。`--unsafe-allow-unencrypted-bucket` 指定時は逃げ道として
250+
# 警告のみで続行する (オブジェクト個別の SSE は引き続き付与される)。
251+
msg = (
252+
f"バケット暗号化設定の確認に失敗しました ({bucket}): {e}"
253+
)
254+
if self._options.unsafe_allow_unencrypted_bucket:
255+
logger.warning("%s (unsafe フラグにより続行)", msg)
256+
return
257+
raise StorageError(msg) from e
258+
259+
def write_bytes(self, dest: str, data: bytes) -> None:
260+
bucket, key = _parse_s3_uri(dest)
261+
client = self._get_client()
262+
self._verify_bucket_encryption(client, bucket)
263+
264+
put_kwargs = {
265+
'Bucket': bucket,
266+
'Key': key,
267+
'Body': data,
268+
'ServerSideEncryption': self._options.sse,
269+
}
270+
if self._options.sse == 'aws:kms' and self._options.sse_kms_key_id:
271+
put_kwargs['SSEKMSKeyId'] = self._options.sse_kms_key_id
272+
273+
try:
274+
client.put_object(**put_kwargs)
275+
except Exception as e:
276+
raise StorageError(
277+
f"S3 への書き込みに失敗しました ({dest}): {e}"
278+
) from e
279+
logger.info("S3 へ書き込みました: %s (sse=%s)", dest, self._options.sse)
280+
281+
def read_bytes(self, source: str) -> bytes:
282+
bucket, key = _parse_s3_uri(source)
283+
client = self._get_client()
284+
try:
285+
response = client.get_object(Bucket=bucket, Key=key)
286+
body = response['Body']
287+
except Exception as e:
288+
code = self._error_code(e)
289+
if code in ('NoSuchKey', 'NoSuchBucket', '404'):
290+
raise StorageError(
291+
f"S3 オブジェクトが見つかりません: {source}"
292+
) from e
293+
raise StorageError(
294+
f"S3 からの読み込みに失敗しました ({source}): {e}"
295+
) from e
296+
try:
297+
return body.read()
298+
except Exception as e:
299+
raise StorageError(
300+
f"S3 レスポンスボディの読み取りに失敗しました ({source}): {e}"
301+
) from e
302+
303+
304+
def resolve(uri: str, *, s3_options: Optional[S3Options] = None) -> StorageBackend:
305+
"""URI スキームから対応する backend を返す。
306+
307+
s3:// は `s3_options` を受け取れる (省略時は S3Options.from_env())。
308+
`gs://` は PLAN03-1 PR4 廃案により対応しない。
309+
"""
103310
if uri == '-':
104311
return StdioBackend()
105312

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

112-
if scheme in ('s3', 'gs'):
319+
if scheme == 's3':
320+
return S3Backend(s3_options if s3_options is not None else S3Options.from_env())
321+
322+
if scheme == 'gs':
113323
raise StorageError(
114-
f"スキーム '{scheme}://' は本 PR では未実装です "
115-
"(後続 PR で対応予定)"
324+
"スキーム 'gs://' (GCS) は PLAN03-1 PR4 廃案により対応していません。"
325+
"必要な場合は s3:// 経由 (S3 互換ゲートウェイ) を検討するか、"
326+
"ローカルファイルを介して転送してください"
116327
)
117328

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

127338
def is_stdio(uri: str) -> bool:
128339
return uri == '-'
340+
341+
342+
def is_s3(uri: str) -> bool:
343+
return urlparse(uri).scheme.lower() == 's3'

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ requires-python = ">=3.10"
66
dependencies = [
77
"pyyaml>=6.0",
88
"pyrage>=1.2",
9+
"boto3>=1.34",
910
]
1011

1112
[dependency-groups]

0 commit comments

Comments
 (0)