1- """env バンドルの入出力先 (local / stdio / 将来 s3, gcs ) を抽象化する"""
1+ """env バンドルの入出力先 (local / stdio / s3 ) を抽象化する"""
22
33from __future__ import annotations
44
55import os
66import sys
7+ from dataclasses import dataclass
78from pathlib import Path
8- from typing import Protocol
9+ from typing import Optional , Protocol , Tuple
910from urllib .parse import urlparse
1011
1112from devbase .errors import DevbaseError
13+ from devbase .log import get_logger
14+
15+ logger = get_logger (__name__ )
1216
1317
1418class 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
127338def is_stdio (uri : str ) -> bool :
128339 return uri == '-'
340+
341+
342+ def is_s3 (uri : str ) -> bool :
343+ return urlparse (uri ).scheme .lower () == 's3'
0 commit comments