@@ -3,8 +3,16 @@ package storage
33import (
44 "context"
55 "errors"
6+ "fmt"
67 "io"
78 "time"
9+
10+ "github.com/aws/aws-sdk-go-v2/aws"
11+ awsconfig "github.com/aws/aws-sdk-go-v2/config"
12+ "github.com/aws/aws-sdk-go-v2/credentials"
13+ "github.com/aws/aws-sdk-go-v2/service/s3"
14+ "github.com/aws/aws-sdk-go-v2/service/s3/types"
15+ "github.com/aws/smithy-go"
816)
917
1018// S3Config configures an S3-compatible Storage. The same implementation
@@ -19,47 +27,185 @@ type S3Config struct {
1927 UsePathStyle bool // true for MinIO / R2; false for AWS virtual-hosted style
2028}
2129
22- // S3 is an S3-compatible Storage.
30+ // S3 is an S3-compatible Storage backed by aws-sdk-go-v2 .
2331//
24- // The actual AWS SDK wiring will be filled in once the main service is
25- // running; leaving it as a stub here keeps module dependencies light during
26- // scaffolding. Implementations of the six Storage methods will use
27- // github.com/aws/aws-sdk-go-v2/service/s3 .
32+ // The same type handles every S3-compatible vendor because the differences
33+ // all boil down to three knobs: Endpoint, Region, and UsePathStyle. A
34+ // presigner is kept alongside the client so SignedURL doesn't need to
35+ // reconstruct one per call .
2836type S3 struct {
29- cfg S3Config
37+ cfg S3Config
38+ client * s3.Client
39+ presigner * s3.PresignClient
3040}
3141
32- // NewS3 returns a new S3-compatible Storage.
42+ // NewS3 returns a new S3-compatible Storage. It constructs the SDK client
43+ // eagerly so misconfiguration surfaces at boot, not on the first Put.
3344func NewS3 (cfg S3Config ) (* S3 , error ) {
3445 if cfg .Bucket == "" {
3546 return nil , errors .New ("s3 storage: bucket is required" )
3647 }
37- if cfg .Endpoint == "" {
38- return nil , errors .New ("s3 storage: endpoint is required" )
48+ // Endpoint is required for non-AWS vendors; AWS itself is happy with
49+ // region alone. We don't guess — force the caller to be explicit.
50+ if cfg .Endpoint == "" && cfg .Region == "" {
51+ return nil , errors .New ("s3 storage: endpoint or region is required" )
52+ }
53+ if cfg .Region == "" {
54+ cfg .Region = "us-east-1" // placeholder some non-AWS vendors require
55+ }
56+
57+ // Loading with LoadDefaultConfig picks up the standard AWS credential
58+ // chain (env, shared config, IAM role) when AccessKey/SecretKey are
59+ // empty — useful for AWS IRSA / instance profile deployments.
60+ loadOpts := []func (* awsconfig.LoadOptions ) error {
61+ awsconfig .WithRegion (cfg .Region ),
62+ }
63+ if cfg .AccessKey != "" && cfg .SecretKey != "" {
64+ loadOpts = append (loadOpts , awsconfig .WithCredentialsProvider (
65+ credentials .NewStaticCredentialsProvider (cfg .AccessKey , cfg .SecretKey , "" ),
66+ ))
67+ }
68+
69+ awsCfg , err := awsconfig .LoadDefaultConfig (context .Background (), loadOpts ... )
70+ if err != nil {
71+ return nil , fmt .Errorf ("s3 storage: load aws config: %w" , err )
3972 }
40- return & S3 {cfg : cfg }, nil
41- }
4273
43- // The following methods are intentionally stubs for the scaffold. They will
44- // be implemented against aws-sdk-go-v2 when the engine gains ingest/query
45- // functionality in Phase 1.
74+ client := s3 .NewFromConfig (awsCfg , func (o * s3.Options ) {
75+ if cfg .Endpoint != "" {
76+ o .BaseEndpoint = aws .String (cfg .Endpoint )
77+ }
78+ o .UsePathStyle = cfg .UsePathStyle
79+ })
4680
81+ return & S3 {
82+ cfg : cfg ,
83+ client : client ,
84+ presigner : s3 .NewPresignClient (client ),
85+ }, nil
86+ }
87+
88+ // Put uploads r to key. Content-Type and custom metadata are forwarded.
89+ // Size from meta is advisory — the SDK streams r either way.
4790func (s * S3 ) Put (ctx context.Context , key string , r io.Reader , meta Metadata ) error {
48- return errors .New ("s3 storage: Put not yet implemented" )
91+ in := & s3.PutObjectInput {
92+ Bucket : aws .String (s .cfg .Bucket ),
93+ Key : aws .String (key ),
94+ Body : r ,
95+ }
96+ if meta .ContentType != "" {
97+ in .ContentType = aws .String (meta .ContentType )
98+ }
99+ if len (meta .Custom ) > 0 {
100+ in .Metadata = meta .Custom
101+ }
102+ if _ , err := s .client .PutObject (ctx , in ); err != nil {
103+ return fmt .Errorf ("s3 storage: put %q: %w" , key , err )
104+ }
105+ return nil
49106}
50107
108+ // Get fetches key. Caller closes the returned reader. Missing keys map to
109+ // storage.ErrNotFound so downstream handlers can branch on a sentinel.
51110func (s * S3 ) Get (ctx context.Context , key string ) (io.ReadCloser , Metadata , error ) {
52- return nil , Metadata {}, errors .New ("s3 storage: Get not yet implemented" )
111+ out , err := s .client .GetObject (ctx , & s3.GetObjectInput {
112+ Bucket : aws .String (s .cfg .Bucket ),
113+ Key : aws .String (key ),
114+ })
115+ if err != nil {
116+ if isNotFound (err ) {
117+ return nil , Metadata {}, ErrNotFound
118+ }
119+ return nil , Metadata {}, fmt .Errorf ("s3 storage: get %q: %w" , key , err )
120+ }
121+ meta := Metadata {
122+ Key : key ,
123+ Size : aws .ToInt64 (out .ContentLength ),
124+ Custom : out .Metadata ,
125+ }
126+ if out .ContentType != nil {
127+ meta .ContentType = * out .ContentType
128+ }
129+ if out .ETag != nil {
130+ meta .ETag = * out .ETag
131+ }
132+ if out .LastModified != nil {
133+ meta .ModifiedAt = * out .LastModified
134+ }
135+ return out .Body , meta , nil
53136}
54137
138+ // Delete removes key. A missing key is reported as ErrNotFound even though
139+ // S3 itself is idempotent — callers of storage.Storage expect this signal.
55140func (s * S3 ) Delete (ctx context.Context , key string ) error {
56- return errors .New ("s3 storage: Delete not yet implemented" )
141+ // S3 DeleteObject doesn't error on missing keys, so probe first.
142+ exists , err := s .Exists (ctx , key )
143+ if err != nil {
144+ return err
145+ }
146+ if ! exists {
147+ return ErrNotFound
148+ }
149+ if _ , err := s .client .DeleteObject (ctx , & s3.DeleteObjectInput {
150+ Bucket : aws .String (s .cfg .Bucket ),
151+ Key : aws .String (key ),
152+ }); err != nil {
153+ return fmt .Errorf ("s3 storage: delete %q: %w" , key , err )
154+ }
155+ return nil
57156}
58157
158+ // Exists reports whether key exists via HEAD.
59159func (s * S3 ) Exists (ctx context.Context , key string ) (bool , error ) {
60- return false , errors .New ("s3 storage: Exists not yet implemented" )
160+ _ , err := s .client .HeadObject (ctx , & s3.HeadObjectInput {
161+ Bucket : aws .String (s .cfg .Bucket ),
162+ Key : aws .String (key ),
163+ })
164+ if err == nil {
165+ return true , nil
166+ }
167+ if isNotFound (err ) {
168+ return false , nil
169+ }
170+ return false , fmt .Errorf ("s3 storage: head %q: %w" , key , err )
61171}
62172
173+ // SignedURL returns a GET presigned URL good for expiry. Useful for handing
174+ // sections directly to browsers without streaming through the engine.
63175func (s * S3 ) SignedURL (ctx context.Context , key string , expiry time.Duration ) (string , error ) {
64- return "" , errors .New ("s3 storage: SignedURL not yet implemented" )
176+ if expiry <= 0 {
177+ expiry = 15 * time .Minute
178+ }
179+ out , err := s .presigner .PresignGetObject (ctx ,
180+ & s3.GetObjectInput {
181+ Bucket : aws .String (s .cfg .Bucket ),
182+ Key : aws .String (key ),
183+ },
184+ s3 .WithPresignExpires (expiry ),
185+ )
186+ if err != nil {
187+ return "" , fmt .Errorf ("s3 storage: presign %q: %w" , key , err )
188+ }
189+ return out .URL , nil
190+ }
191+
192+ // isNotFound recognizes the two shapes S3 uses for "key doesn't exist":
193+ // typed NoSuchKey on GET and smithy's generic NotFound on HEAD. Either
194+ // collapses to our ErrNotFound so the rest of the stack only checks one
195+ // sentinel.
196+ func isNotFound (err error ) bool {
197+ var nsk * types.NoSuchKey
198+ if errors .As (err , & nsk ) {
199+ return true
200+ }
201+ var nf * types.NotFound
202+ if errors .As (err , & nf ) {
203+ return true
204+ }
205+ var ae smithy.APIError
206+ if errors .As (err , & ae ) {
207+ code := ae .ErrorCode ()
208+ return code == "NoSuchKey" || code == "NotFound" || code == "404"
209+ }
210+ return false
65211}
0 commit comments